Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
411a8cebc7 | ||
|
|
14191de8f4 | ||
|
|
4ef48fad82 | ||
|
|
21beab26f1 | ||
|
|
050a46d7a8 | ||
|
|
3217c3f66e | ||
|
|
3a9c9bb74a | ||
|
|
04ac39499f | ||
|
|
26d917e96a |
+2
-2
@@ -31,7 +31,7 @@ Chat upload limits:
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
|
||||
"openai": { "models": ["chat-latest", "gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
|
||||
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
|
||||
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
|
||||
"gemini": { "models": ["gemini-3.5-flash"], "loadedAt": null, "error": null },
|
||||
@@ -39,7 +39,7 @@ Chat upload limits:
|
||||
}
|
||||
}
|
||||
```
|
||||
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
|
||||
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation, including the exact `chat-latest` alias when returned by OpenAI. Audio, image-generation, embedding, moderation, and other specialized model ids remain excluded. See [OpenAI's Chat Latest documentation](https://developers.openai.com/api/docs/models/chat-latest) for supported capabilities.
|
||||
- Gemini model lists are loaded from Google's native Models API and filtered to Gemini `generateContent` model ids.
|
||||
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
|
||||
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
|
||||
|
||||
@@ -109,6 +109,7 @@ Request body:
|
||||
```
|
||||
|
||||
Behavior notes:
|
||||
- `provider` and `model` are required. Web and native clients default Quick Question to `provider: "openai"` and `model: "chat-latest"` when no Quick Question selection has been saved. Saved Quick Question choices take precedence over this default and are separate from regular chat preferences.
|
||||
- `question` is required, trimmed by the server, and must not be empty.
|
||||
- The server prepends a Quick Question system prompt that asks for a succinct, direct, self-contained answer without follow-up questions. Clients do not send or maintain this prompt.
|
||||
- Quick Questions are always non-persistent. The endpoint does not create a chat or store messages, tool-call logs, assistant output, or `LlmCall` metadata.
|
||||
|
||||
@@ -74,6 +74,16 @@ actor SybilAPIClient: SybilAPIClienting {
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func forkChat(chatID: String, messageID: String? = nil) async throws -> ChatSummary {
|
||||
let response = try await request(
|
||||
"/v1/chats/\(chatID)/fork",
|
||||
method: "POST",
|
||||
body: AnyEncodable(ChatForkBody(messageId: messageID)),
|
||||
responseType: ChatCreateResponse.self
|
||||
)
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
||||
let response = try await request(
|
||||
"/v1/chats/\(chatID)",
|
||||
@@ -704,6 +714,10 @@ private struct ChatTitleUpdateBody: Encodable {
|
||||
var title: String
|
||||
}
|
||||
|
||||
private struct ChatForkBody: Encodable {
|
||||
var messageId: String?
|
||||
}
|
||||
|
||||
private struct StarUpdateBody: Encodable {
|
||||
var starred: Bool
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ protocol SybilAPIClienting: Sendable {
|
||||
messages: [CompletionRequestMessage]?
|
||||
) async throws -> ChatSummary
|
||||
func getChat(chatID: String) async throws -> ChatDetail
|
||||
func forkChat(chatID: String, messageID: String?) async throws -> ChatSummary
|
||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
|
||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
|
||||
func deleteChat(chatID: String) async throws
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import MarkdownUI
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SybilChatTranscriptView: View {
|
||||
var messages: [Message]
|
||||
var isLoading: Bool
|
||||
var isSending: Bool
|
||||
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||
var topContentInset: CGFloat = 0
|
||||
var bottomContentInset: CGFloat = 0
|
||||
var bottomPinRequestID: Int = 0
|
||||
var isForking: Bool = false
|
||||
var onForkResponse: ((String) -> Void)? = nil
|
||||
|
||||
@State private var hasTrackedToolCallMessages = false
|
||||
@State private var knownToolCallMessageIDs: Set<String> = []
|
||||
@@ -41,7 +45,15 @@ struct SybilChatTranscriptView: View {
|
||||
ForEach(renderItems) { item in
|
||||
switch item {
|
||||
case let .message(message):
|
||||
MessageBubble(message: message, isSending: isSending)
|
||||
MessageBubble(
|
||||
message: message,
|
||||
isSending: isSending,
|
||||
streamingAssistantPresentation: streamingAssistantPresentation,
|
||||
isForking: isForking,
|
||||
onFork: {
|
||||
onForkResponse?(message.id)
|
||||
}
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
case let .toolGroup(id, messages):
|
||||
ToolCallStackView(
|
||||
@@ -140,6 +152,9 @@ func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderIt
|
||||
private struct MessageBubble: View {
|
||||
var message: Message
|
||||
var isSending: Bool
|
||||
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||
var isForking: Bool
|
||||
var onFork: (() -> Void)?
|
||||
|
||||
private var toolCallMetadata: ToolCallMetadata? {
|
||||
message.toolCallMetadata
|
||||
@@ -149,13 +164,46 @@ private struct MessageBubble: View {
|
||||
message.role == .user
|
||||
}
|
||||
|
||||
private var isPendingAssistant: Bool {
|
||||
message.id.hasPrefix("temp-assistant-") &&
|
||||
isSending &&
|
||||
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
private var isStreamingAssistant: Bool {
|
||||
message.role == .assistant && message.id.hasPrefix("temp-assistant-")
|
||||
}
|
||||
|
||||
private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? {
|
||||
guard streamingAssistantPresentation?.messageID == message.id else { return nil }
|
||||
return streamingAssistantPresentation
|
||||
}
|
||||
|
||||
private var canFork: Bool {
|
||||
message.role == .assistant &&
|
||||
!message.id.hasPrefix("temp-") &&
|
||||
onFork != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if canFork {
|
||||
bubbleContent
|
||||
.contextMenu {
|
||||
Button {
|
||||
UIPasteboard.general.string = message.content
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
Button {
|
||||
onFork?()
|
||||
} label: {
|
||||
Label("Fork", systemImage: "arrow.triangle.branch")
|
||||
}
|
||||
.disabled(isForking)
|
||||
}
|
||||
} else {
|
||||
bubbleContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bubbleContent: some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
leadingSpacer
|
||||
|
||||
@@ -174,16 +222,12 @@ private struct MessageBubble: View {
|
||||
)
|
||||
}
|
||||
|
||||
if isPendingAssistant {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.primary)
|
||||
Text("Thinking…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
if isStreamingAssistant {
|
||||
StreamingAssistantContentView(
|
||||
content: message.content,
|
||||
isSending: isSending,
|
||||
presentation: matchingStreamingAssistantPresentation
|
||||
)
|
||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
Markdown(message.content)
|
||||
.tint(SybilTheme.primary)
|
||||
@@ -232,6 +276,123 @@ private struct MessageBubble: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct StreamingTraceHeightPreferenceKey: PreferenceKey {
|
||||
static let defaultValue: CGFloat = 0
|
||||
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = max(value, nextValue())
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamingAssistantContentView: View {
|
||||
private enum Phase: Hashable {
|
||||
case pending
|
||||
case trace(Int)
|
||||
case answer
|
||||
}
|
||||
|
||||
var content: String
|
||||
var isSending: Bool
|
||||
var presentation: StreamingAssistantPresentation?
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var retainedTraceHeight: CGFloat = 0
|
||||
|
||||
private var traceText: String? {
|
||||
guard let trace = presentation?.ephemeralTrace?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!trace.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return trace
|
||||
}
|
||||
|
||||
private var hasAnswer: Bool {
|
||||
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
private var phase: Phase {
|
||||
if hasAnswer {
|
||||
return .answer
|
||||
}
|
||||
if traceText != nil {
|
||||
return .trace(presentation?.traceRevision ?? 0)
|
||||
}
|
||||
return .pending
|
||||
}
|
||||
|
||||
private var traceTransition: AnyTransition {
|
||||
guard !reduceMotion else { return .identity }
|
||||
return .asymmetric(
|
||||
insertion: .opacity.combined(with: .offset(y: 5)),
|
||||
removal: .opacity.combined(with: .offset(y: -5))
|
||||
)
|
||||
}
|
||||
|
||||
private var answerTransition: AnyTransition {
|
||||
guard !reduceMotion else { return .identity }
|
||||
return .opacity.combined(with: .offset(y: 3))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if let traceText {
|
||||
Text(traceText)
|
||||
.font(.sybil(size: 15))
|
||||
.italic()
|
||||
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
||||
.lineSpacing(5)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.opacity(0.78)
|
||||
.textSelection(.disabled)
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityLabel("Assistant is working")
|
||||
.background {
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: StreamingTraceHeightPreferenceKey.self,
|
||||
value: geometry.size.height
|
||||
)
|
||||
}
|
||||
}
|
||||
.id("thinking-trace-\(presentation?.traceRevision ?? 0)")
|
||||
.transition(traceTransition)
|
||||
.zIndex(1)
|
||||
}
|
||||
|
||||
if hasAnswer {
|
||||
Markdown(content)
|
||||
.tint(SybilTheme.primary)
|
||||
.foregroundStyle(SybilTheme.text.opacity(0.95))
|
||||
.markdownTheme(.sybilReadable)
|
||||
.textSelection(.enabled)
|
||||
.id("streaming-assistant-answer")
|
||||
.transition(answerTransition)
|
||||
.zIndex(2)
|
||||
}
|
||||
|
||||
if isSending && !hasAnswer && traceText == nil {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.primary)
|
||||
Text("Thinking…")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.transition(reduceMotion ? .identity : .opacity)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: max(24, retainedTraceHeight), alignment: .topLeading)
|
||||
.onPreferenceChange(StreamingTraceHeightPreferenceKey.self) { measuredHeight in
|
||||
guard measuredHeight.isFinite, measuredHeight > retainedTraceHeight else { return }
|
||||
retainedTraceHeight = measuredHeight
|
||||
}
|
||||
.animation(reduceMotion ? nil : .easeOut(duration: 0.20), value: phase)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolCallStackView: View {
|
||||
private struct CardLayout {
|
||||
var x: CGFloat
|
||||
|
||||
@@ -130,7 +130,10 @@ struct SybilPhoneShellView: View {
|
||||
route: route,
|
||||
onRequestBack: { _ in showSidebarOverlay() },
|
||||
onRequestNewChat: sidebarWorkspaceNewChatAction,
|
||||
onShowSidebar: showSidebarOverlay
|
||||
onShowSidebar: showSidebarOverlay,
|
||||
onOpenForkedChat: { chatID in
|
||||
showRoute(.chat(chatID))
|
||||
}
|
||||
)
|
||||
.background(SybilTheme.background)
|
||||
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
|
||||
@@ -511,6 +514,9 @@ private struct SybilPhoneSidebarRoot: View {
|
||||
},
|
||||
onSelect: { item in
|
||||
onSelect(item.selection)
|
||||
},
|
||||
onOpenForkedChat: { chatID in
|
||||
onSelect(.chat(chatID))
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -585,6 +591,7 @@ private struct SybilPhoneDestinationView: View {
|
||||
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
||||
let onRequestNewChat: (() -> Void)?
|
||||
let onShowSidebar: () -> Void
|
||||
let onOpenForkedChat: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
SybilWorkspaceView(
|
||||
@@ -593,7 +600,8 @@ private struct SybilPhoneDestinationView: View {
|
||||
navigationLeadingControl: .showSidebar,
|
||||
onShowSidebar: onShowSidebar,
|
||||
onRequestBack: onRequestBack,
|
||||
onRequestNewChat: onRequestNewChat
|
||||
onRequestNewChat: onRequestNewChat,
|
||||
onOpenForkedChat: onOpenForkedChat
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.task(id: route) {
|
||||
|
||||
@@ -62,7 +62,11 @@ struct SybilQuickQuestionView: View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if hasAnswerContent {
|
||||
ForEach(viewModel.quickQuestionMessages) { message in
|
||||
QuickQuestionMessageView(message: message, isSending: viewModel.isQuickQuestionSending)
|
||||
QuickQuestionMessageView(
|
||||
message: message,
|
||||
isSending: viewModel.isQuickQuestionSending,
|
||||
streamingAssistantPresentation: viewModel.quickQuestionStreamingAssistantPresentation
|
||||
)
|
||||
}
|
||||
|
||||
if let error = viewModel.quickQuestionError {
|
||||
@@ -98,13 +102,12 @@ struct SybilQuickQuestionView: View {
|
||||
set: { viewModel.updateQuickQuestionPrompt($0) }
|
||||
),
|
||||
selection: $promptSelection,
|
||||
axis: .vertical
|
||||
axis: .horizontal
|
||||
)
|
||||
.focused($promptFocused)
|
||||
.font(.body)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.autocorrectionDisabled(false)
|
||||
.lineLimit(1 ... 6)
|
||||
.submitLabel(.send)
|
||||
.onSubmit(submitQuestion)
|
||||
.padding(.horizontal, 12)
|
||||
@@ -263,11 +266,11 @@ private struct QuickQuestionPickerPill: View {
|
||||
private struct QuickQuestionMessageView: View {
|
||||
var message: Message
|
||||
var isSending: Bool
|
||||
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||
|
||||
private var isPendingAssistant: Bool {
|
||||
message.id.hasPrefix("temp-assistant-quick-") &&
|
||||
isSending &&
|
||||
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? {
|
||||
guard streamingAssistantPresentation?.messageID == message.id else { return nil }
|
||||
return streamingAssistantPresentation
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -276,15 +279,12 @@ private struct QuickQuestionMessageView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else if isPendingAssistant {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.primary)
|
||||
Text("Thinking...")
|
||||
.font(.caption)
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
} else if message.id.hasPrefix("temp-assistant-quick-") {
|
||||
StreamingAssistantContentView(
|
||||
content: message.content,
|
||||
isSending: isSending,
|
||||
presentation: matchingStreamingAssistantPresentation
|
||||
)
|
||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
Markdown(message.content)
|
||||
.font(.body)
|
||||
|
||||
@@ -52,9 +52,9 @@ final class SybilSettingsStore {
|
||||
self.preferredModelByProvider = preferredModels
|
||||
|
||||
self.quickQuestionPreferredProvider =
|
||||
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? provider
|
||||
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
|
||||
self.quickQuestionPreferredModelByProvider = [
|
||||
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? preferredModels[.openai] ?? "gpt-4.1-mini",
|
||||
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? "chat-latest",
|
||||
.anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest",
|
||||
.xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini",
|
||||
.gemini: defaults.string(forKey: Keys.quickQuestionPreferredGeminiModel) ?? preferredModels[.gemini] ?? "gemini-3.5-flash",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SybilSidebarView: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
@@ -111,6 +112,7 @@ struct SybilSidebarItemList: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
var isSelected: (SidebarItem) -> Bool
|
||||
var onSelect: (SidebarItem) -> Void
|
||||
var onOpenForkedChat: ((String) -> Void)? = nil
|
||||
@State private var renameTarget: SidebarItem?
|
||||
@State private var renameTitle = ""
|
||||
|
||||
@@ -168,13 +170,26 @@ struct SybilSidebarItemList: View {
|
||||
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
|
||||
}
|
||||
|
||||
if item.kind == .chat {
|
||||
if case let .chat(chatID) = item.selection {
|
||||
Button {
|
||||
forkChat(item)
|
||||
} label: {
|
||||
Label("Fork", systemImage: "arrow.triangle.branch")
|
||||
}
|
||||
.disabled(viewModel.isForkingChat || item.isRunning)
|
||||
|
||||
Button {
|
||||
renameTarget = item
|
||||
renameTitle = item.title
|
||||
} label: {
|
||||
Label("Rename", systemImage: "pencil")
|
||||
}
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = chatID
|
||||
} label: {
|
||||
Label("Copy Session ID", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
@@ -215,6 +230,28 @@ struct SybilSidebarItemList: View {
|
||||
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private func forkChat(_ item: SidebarItem) {
|
||||
guard case let .chat(chatID) = item.selection else {
|
||||
return
|
||||
}
|
||||
|
||||
let selectionAtStart = viewModel.selectedItem
|
||||
let draftKindAtStart = viewModel.draftKind
|
||||
Task {
|
||||
guard let forkedChat = await viewModel.forkChat(chatID: chatID),
|
||||
viewModel.selectedItem == selectionAtStart,
|
||||
viewModel.draftKind == draftKindAtStart else {
|
||||
return
|
||||
}
|
||||
|
||||
if let onOpenForkedChat {
|
||||
onOpenForkedChat(forkedChat.id)
|
||||
} else {
|
||||
viewModel.select(.chat(forkedChat.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SybilSidebarRow: View {
|
||||
|
||||
@@ -40,9 +40,72 @@ struct SidebarItem: Identifiable, Hashable {
|
||||
var isRunning: Bool
|
||||
}
|
||||
|
||||
enum StreamingAssistantContentUpdate: Equatable, Sendable {
|
||||
case unchanged
|
||||
case replace(String)
|
||||
}
|
||||
|
||||
struct StreamingAssistantState: Equatable, Sendable {
|
||||
private(set) var segmentText = ""
|
||||
private(set) var initiatedToolCallIDs: Set<String> = []
|
||||
private(set) var ephemeralTrace: String?
|
||||
private(set) var traceRevision = 0
|
||||
|
||||
mutating func receiveDelta(_ text: String) -> StreamingAssistantContentUpdate {
|
||||
guard !text.isEmpty else { return .unchanged }
|
||||
segmentText += text
|
||||
ephemeralTrace = nil
|
||||
return .replace(segmentText)
|
||||
}
|
||||
|
||||
mutating func receiveToolCall(id: String, status: String) -> StreamingAssistantContentUpdate {
|
||||
guard status.lowercased() == "initiated", initiatedToolCallIDs.insert(id).inserted else {
|
||||
return .unchanged
|
||||
}
|
||||
|
||||
let trace = segmentText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
segmentText = ""
|
||||
if !trace.isEmpty {
|
||||
ephemeralTrace = trace
|
||||
traceRevision += 1
|
||||
}
|
||||
return .replace("")
|
||||
}
|
||||
|
||||
mutating func receiveDone(_ text: String) -> StreamingAssistantContentUpdate {
|
||||
segmentText = ""
|
||||
ephemeralTrace = nil
|
||||
return .replace(text)
|
||||
}
|
||||
|
||||
mutating func receiveError() -> StreamingAssistantContentUpdate {
|
||||
segmentText = ""
|
||||
ephemeralTrace = nil
|
||||
return .unchanged
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamingAssistantPresentation: Equatable, Sendable {
|
||||
var messageID: String
|
||||
var ephemeralTrace: String?
|
||||
var traceRevision: Int
|
||||
}
|
||||
|
||||
private struct PendingChatState {
|
||||
var chatID: String?
|
||||
var messages: [Message]
|
||||
var streamingAssistantState = StreamingAssistantState()
|
||||
|
||||
var streamingAssistantPresentation: StreamingAssistantPresentation? {
|
||||
guard let messageID = messages.last(where: { $0.id.hasPrefix("temp-assistant-") })?.id else {
|
||||
return nil
|
||||
}
|
||||
return StreamingAssistantPresentation(
|
||||
messageID: messageID,
|
||||
ephemeralTrace: streamingAssistantState.ephemeralTrace,
|
||||
traceRevision: streamingAssistantState.traceRevision
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum ActiveSendContext: Hashable {
|
||||
@@ -107,6 +170,7 @@ final class SybilViewModel {
|
||||
var isLoadingCollections = false
|
||||
var isLoadingSelection = false
|
||||
var isCreatingSearchChat = false
|
||||
var isForkingChat = false
|
||||
var chatBottomPinRequestID = 0
|
||||
var errorMessage: String?
|
||||
|
||||
@@ -125,6 +189,7 @@ final class SybilViewModel {
|
||||
var quickQuestionSubmittedModel: String?
|
||||
var isQuickQuestionSending = false
|
||||
var isConvertingQuickQuestion = false
|
||||
private var quickQuestionStreamingAssistantState = StreamingAssistantState()
|
||||
|
||||
@ObservationIgnored
|
||||
private var hasBootstrapped = false
|
||||
@@ -157,7 +222,7 @@ final class SybilViewModel {
|
||||
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
|
||||
|
||||
private let fallbackModels: [Provider: [String]] = [
|
||||
.openai: ["gpt-4.1-mini"],
|
||||
.openai: ["gpt-4.1-mini", "chat-latest"],
|
||||
.anthropic: ["claude-3-5-sonnet-latest"],
|
||||
.xai: ["grok-3-mini"],
|
||||
.gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
||||
@@ -360,6 +425,30 @@ final class SybilViewModel {
|
||||
return canonical
|
||||
}
|
||||
|
||||
var displayedStreamingAssistantPresentation: StreamingAssistantPresentation? {
|
||||
if case let .chat(chatID) = selectedItem,
|
||||
let pending = pendingChatStates[chatID] {
|
||||
return pending.streamingAssistantPresentation
|
||||
}
|
||||
|
||||
if draftKind == .chat, let pending = pendingDraftChatState {
|
||||
return pending.streamingAssistantPresentation
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var quickQuestionStreamingAssistantPresentation: StreamingAssistantPresentation? {
|
||||
guard let messageID = quickQuestionMessages.last(where: { $0.id.hasPrefix("temp-assistant-quick-") })?.id else {
|
||||
return nil
|
||||
}
|
||||
return StreamingAssistantPresentation(
|
||||
messageID: messageID,
|
||||
ephemeralTrace: quickQuestionStreamingAssistantState.ephemeralTrace,
|
||||
traceRevision: quickQuestionStreamingAssistantState.traceRevision
|
||||
)
|
||||
}
|
||||
|
||||
var displayedSearch: SearchDetail? {
|
||||
if case let .search(searchID) = selectedItem,
|
||||
let activeSearch = activeSearchDetails[searchID] {
|
||||
@@ -620,6 +709,7 @@ final class SybilViewModel {
|
||||
quickQuestionTask = nil
|
||||
quickQuestionRunID = nil
|
||||
isQuickQuestionSending = false
|
||||
quickQuestionStreamingAssistantState = StreamingAssistantState()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -869,6 +959,36 @@ final class SybilViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func forkChat(chatID: String, messageID: String? = nil) async -> ChatSummary? {
|
||||
guard isAuthenticated, !isForkingChat else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard messageID != nil || !isChatRowRunning(chatID) else {
|
||||
errorMessage = "Wait for the response to finish before forking this chat."
|
||||
return nil
|
||||
}
|
||||
|
||||
let forkDescription = messageID.map { "chat \(chatID) from response \($0)" } ?? "chat \(chatID)"
|
||||
SybilLog.info(SybilLog.ui, "Forking \(forkDescription)")
|
||||
errorMessage = nil
|
||||
isForkingChat = true
|
||||
defer {
|
||||
isForkingChat = false
|
||||
}
|
||||
|
||||
do {
|
||||
let forkedChat = try await client().forkChat(chatID: chatID, messageID: messageID)
|
||||
applyChatSummary(forkedChat, moveToFront: true)
|
||||
return forkedChat
|
||||
} catch {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
SybilLog.error(SybilLog.ui, "Fork failed", error: error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func renameChat(chatID: String, title: String) async {
|
||||
guard isAuthenticated else {
|
||||
return
|
||||
@@ -1140,6 +1260,7 @@ final class SybilViewModel {
|
||||
quickQuestionSubmittedPrompt = prompt
|
||||
quickQuestionSubmittedProvider = provider
|
||||
quickQuestionSubmittedModel = model
|
||||
quickQuestionStreamingAssistantState = StreamingAssistantState()
|
||||
quickQuestionMessages = [
|
||||
Message(
|
||||
id: "temp-assistant-quick-\(UUID().uuidString)",
|
||||
@@ -1184,6 +1305,8 @@ final class SybilViewModel {
|
||||
return
|
||||
}
|
||||
|
||||
let update = quickQuestionStreamingAssistantState.receiveError()
|
||||
applyQuickQuestionAssistantContentUpdate(update)
|
||||
quickQuestionError = normalizeAPIError(error)
|
||||
SybilLog.error(SybilLog.ui, "Quick question failed", error: error)
|
||||
}
|
||||
@@ -1198,17 +1321,16 @@ final class SybilViewModel {
|
||||
upsertQuickQuestionToolCallMessage(payload)
|
||||
|
||||
case let .delta(payload):
|
||||
guard !payload.text.isEmpty else { return }
|
||||
mutateQuickQuestionAssistantMessage { existing in
|
||||
existing + payload.text
|
||||
}
|
||||
let update = quickQuestionStreamingAssistantState.receiveDelta(payload.text)
|
||||
applyQuickQuestionAssistantContentUpdate(update)
|
||||
|
||||
case let .done(payload):
|
||||
mutateQuickQuestionAssistantMessage { _ in
|
||||
payload.text
|
||||
}
|
||||
let update = quickQuestionStreamingAssistantState.receiveDone(payload.text)
|
||||
applyQuickQuestionAssistantContentUpdate(update)
|
||||
|
||||
case let .error(payload):
|
||||
let update = quickQuestionStreamingAssistantState.receiveError()
|
||||
applyQuickQuestionAssistantContentUpdate(update)
|
||||
await streamStatus.setError(payload.message)
|
||||
|
||||
case .ignored:
|
||||
@@ -1607,21 +1729,28 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
serverActiveChatIDs.remove(chatID)
|
||||
pendingChatStates[chatID] = nil
|
||||
await refreshCollections(preferredSelection: selectedItem, refreshSelection: false, attachVisibleActiveRun: false)
|
||||
|
||||
if selectedItem == selection, draftKind == nil {
|
||||
selectedChat = try await client.getChat(chatID: chatID)
|
||||
selectedSearch = nil
|
||||
let refreshedChat = try await client.getChat(chatID: chatID)
|
||||
if selectedItem == selection, draftKind == nil {
|
||||
selectedChat = refreshedChat
|
||||
selectedSearch = nil
|
||||
}
|
||||
}
|
||||
pendingChatStates[chatID] = nil
|
||||
} catch {
|
||||
serverActiveChatIDs.remove(chatID)
|
||||
pendingChatStates[chatID] = nil
|
||||
defer {
|
||||
pendingChatStates[chatID] = nil
|
||||
}
|
||||
|
||||
if isCancellation(error) {
|
||||
return
|
||||
}
|
||||
|
||||
applyPendingAssistantError(chatID: chatID)
|
||||
|
||||
if isActiveStreamNotFound(error) {
|
||||
SybilLog.info(SybilLog.app, "Active chat stream \(chatID) no longer exists")
|
||||
} else if shouldSuppressInactiveTransportError(error) {
|
||||
@@ -1635,8 +1764,11 @@ final class SybilViewModel {
|
||||
|
||||
if selectedItem == selection, draftKind == nil {
|
||||
do {
|
||||
selectedChat = try await client().getChat(chatID: chatID)
|
||||
selectedSearch = nil
|
||||
let refreshedChat = try await client().getChat(chatID: chatID)
|
||||
if selectedItem == selection, draftKind == nil {
|
||||
selectedChat = refreshedChat
|
||||
selectedSearch = nil
|
||||
}
|
||||
} catch {
|
||||
SybilLog.warning(SybilLog.app, "Chat refresh after attach failure failed: \(SybilLog.describe(error))")
|
||||
}
|
||||
@@ -1856,6 +1988,13 @@ final class SybilViewModel {
|
||||
} else {
|
||||
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
||||
}
|
||||
defer {
|
||||
if let resolvedChatID = chatID {
|
||||
pendingChatStates[resolvedChatID] = nil
|
||||
} else {
|
||||
clearPendingChatState(for: sendContext)
|
||||
}
|
||||
}
|
||||
requestChatBottomPin()
|
||||
|
||||
if chatID == nil {
|
||||
@@ -2045,17 +2184,13 @@ final class SybilViewModel {
|
||||
upsertPendingToolCallMessage(payload, chatID: chatID)
|
||||
|
||||
case let .delta(payload):
|
||||
guard !payload.text.isEmpty else { return }
|
||||
mutatePendingAssistantMessage(chatID: chatID) { existing in
|
||||
existing + payload.text
|
||||
}
|
||||
applyPendingAssistantDelta(payload.text, chatID: chatID)
|
||||
|
||||
case let .done(payload):
|
||||
mutatePendingAssistantMessage(chatID: chatID) { _ in
|
||||
payload.text
|
||||
}
|
||||
finalizePendingAssistant(payload.text, chatID: chatID)
|
||||
|
||||
case let .error(payload):
|
||||
applyPendingAssistantError(chatID: chatID)
|
||||
await streamStatus.setError(payload.message)
|
||||
|
||||
case .ignored:
|
||||
@@ -2233,29 +2368,68 @@ final class SybilViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func mutatePendingAssistantMessage(chatID: String, _ transform: (String) -> String) {
|
||||
private func applyPendingAssistantDelta(_ text: String, chatID: String) {
|
||||
guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let index = pending.messages.indices.last { pending.messages[$0].id.hasPrefix("temp-assistant-") }
|
||||
guard let index else {
|
||||
return
|
||||
}
|
||||
|
||||
var message = pending.messages[index]
|
||||
message.content = transform(message.content)
|
||||
pending.messages[index] = message
|
||||
let update = pending.streamingAssistantState.receiveDelta(text)
|
||||
applyStreamingAssistantContentUpdate(
|
||||
update,
|
||||
to: &pending.messages,
|
||||
assistantMessagePrefix: "temp-assistant-"
|
||||
)
|
||||
pendingChatStates[chatID] = pending
|
||||
}
|
||||
|
||||
private func mutateQuickQuestionAssistantMessage(_ transform: (String) -> String) {
|
||||
let index = quickQuestionMessages.indices.last { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }
|
||||
guard let index else {
|
||||
private func finalizePendingAssistant(_ text: String, chatID: String) {
|
||||
guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content)
|
||||
let update = pending.streamingAssistantState.receiveDone(text)
|
||||
applyStreamingAssistantContentUpdate(
|
||||
update,
|
||||
to: &pending.messages,
|
||||
assistantMessagePrefix: "temp-assistant-"
|
||||
)
|
||||
pendingChatStates[chatID] = pending
|
||||
}
|
||||
|
||||
private func applyPendingAssistantError(chatID: String) {
|
||||
guard var pending = pendingChatStates[chatID] else {
|
||||
return
|
||||
}
|
||||
|
||||
let update = pending.streamingAssistantState.receiveError()
|
||||
applyStreamingAssistantContentUpdate(
|
||||
update,
|
||||
to: &pending.messages,
|
||||
assistantMessagePrefix: "temp-assistant-"
|
||||
)
|
||||
pendingChatStates[chatID] = pending
|
||||
}
|
||||
|
||||
private func applyQuickQuestionAssistantContentUpdate(_ update: StreamingAssistantContentUpdate) {
|
||||
applyStreamingAssistantContentUpdate(
|
||||
update,
|
||||
to: &quickQuestionMessages,
|
||||
assistantMessagePrefix: "temp-assistant-quick-"
|
||||
)
|
||||
}
|
||||
|
||||
private func applyStreamingAssistantContentUpdate(
|
||||
_ update: StreamingAssistantContentUpdate,
|
||||
to messages: inout [Message],
|
||||
assistantMessagePrefix: String
|
||||
) {
|
||||
guard case let .replace(content) = update,
|
||||
let index = messages.indices.last(where: { messages[$0].id.hasPrefix(assistantMessagePrefix) })
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
messages[index].content = content
|
||||
}
|
||||
|
||||
private func upsertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
|
||||
@@ -2263,6 +2437,16 @@ final class SybilViewModel {
|
||||
return
|
||||
}
|
||||
|
||||
let update = pending.streamingAssistantState.receiveToolCall(
|
||||
id: payload.toolCallId,
|
||||
status: payload.status
|
||||
)
|
||||
applyStreamingAssistantContentUpdate(
|
||||
update,
|
||||
to: &pending.messages,
|
||||
assistantMessagePrefix: "temp-assistant-"
|
||||
)
|
||||
|
||||
if let existingIndex = pending.messages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
|
||||
pending.messages[existingIndex] = toolCallMessage(for: payload, id: pending.messages[existingIndex].id)
|
||||
pendingChatStates[chatID] = pending
|
||||
@@ -2281,6 +2465,12 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
private func upsertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
|
||||
let update = quickQuestionStreamingAssistantState.receiveToolCall(
|
||||
id: payload.toolCallId,
|
||||
status: payload.status
|
||||
)
|
||||
applyQuickQuestionAssistantContentUpdate(update)
|
||||
|
||||
if let existingIndex = quickQuestionMessages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
|
||||
quickQuestionMessages[existingIndex] = toolCallMessage(for: payload, id: quickQuestionMessages[existingIndex].id)
|
||||
return
|
||||
@@ -2430,7 +2620,8 @@ final class SybilViewModel {
|
||||
|
||||
private func clearPendingChatState(for context: ActiveSendContext) {
|
||||
switch context {
|
||||
case .draftChat:
|
||||
case let .draftChat(identity):
|
||||
guard draftKind == .chat, draftIdentity == identity else { return }
|
||||
pendingDraftChatState = nil
|
||||
case let .chat(chatID):
|
||||
pendingChatStates[chatID] = nil
|
||||
|
||||
@@ -19,6 +19,7 @@ struct SybilWorkspaceView: View {
|
||||
var onShowSidebar: (() -> Void)? = nil
|
||||
var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil
|
||||
var onRequestNewChat: (() -> Void)? = nil
|
||||
var onOpenForkedChat: ((String) -> Void)? = nil
|
||||
@FocusState private var composerFocused: Bool
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var isShowingAttachmentOptions = false
|
||||
@@ -193,9 +194,12 @@ struct SybilWorkspaceView: View {
|
||||
messages: viewModel.displayedMessages,
|
||||
isLoading: viewModel.isLoadingSelection,
|
||||
isSending: viewModel.isSendingVisibleChat,
|
||||
streamingAssistantPresentation: viewModel.displayedStreamingAssistantPresentation,
|
||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
||||
bottomPinRequestID: viewModel.chatBottomPinRequestID
|
||||
bottomPinRequestID: viewModel.chatBottomPinRequestID,
|
||||
isForking: viewModel.isForkingChat,
|
||||
onForkResponse: forkResponse
|
||||
)
|
||||
.id(transcriptScrollContextID)
|
||||
}
|
||||
@@ -229,6 +233,28 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func forkResponse(messageID: String) {
|
||||
guard viewModel.draftKind == nil,
|
||||
case let .chat(chatID) = viewModel.selectedItem else {
|
||||
return
|
||||
}
|
||||
|
||||
let selectionAtStart = viewModel.selectedItem
|
||||
Task {
|
||||
guard let forkedChat = await viewModel.forkChat(chatID: chatID, messageID: messageID),
|
||||
viewModel.selectedItem == selectionAtStart,
|
||||
viewModel.draftKind == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
if let onOpenForkedChat {
|
||||
onOpenForkedChat(forkedChat.id)
|
||||
} else {
|
||||
viewModel.select(.chat(forkedChat.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var customWorkspaceNavigationBar: some View {
|
||||
HStack(spacing: 14) {
|
||||
workspaceNavigationLeadingControl
|
||||
|
||||
@@ -13,6 +13,9 @@ private struct MockClientCallSnapshot: Sendable {
|
||||
var listSearches = 0
|
||||
var createChat = 0
|
||||
var getChat = 0
|
||||
var forkChat = 0
|
||||
var lastForkChatID: String?
|
||||
var lastForkMessageID: String?
|
||||
var updateChatTitle = 0
|
||||
var suggestChatTitle = 0
|
||||
var updateChatStar = 0
|
||||
@@ -43,16 +46,19 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
private let chatDetails: [String: ChatDetail]
|
||||
private let searchDetails: [String: SearchDetail]
|
||||
private let createChatResponse: ChatSummary?
|
||||
private let forkChatResponse: ChatSummary?
|
||||
private let updateChatTitleResponses: [String: ChatSummary]
|
||||
private let suggestChatTitleResponses: [String: ChatSummary]
|
||||
private let updateChatStarResponses: [String: ChatSummary]
|
||||
private let updateSearchStarResponses: [String: SearchSummary]
|
||||
private let activeRunsResponse: ActiveRunsResponse
|
||||
private let modelCatalogResponse: ModelCatalogResponse
|
||||
|
||||
private var snapshot = MockClientCallSnapshot()
|
||||
private var lastCreateChatCall: ChatCreateCallSnapshot?
|
||||
private var lastQuickQuestionStreamBody: QuickQuestionStreamRequest?
|
||||
private var completionStreamEvents: [CompletionStreamEvent]?
|
||||
private var completionStreamPostEventNetworkErrorMessage: String?
|
||||
private var listChatsDelayNanoseconds: UInt64 = 0
|
||||
private var listSearchesDelayNanoseconds: UInt64 = 0
|
||||
private var getChatDelayNanoseconds: UInt64 = 0
|
||||
@@ -61,6 +67,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
private var completionStreamDelayNanoseconds: UInt64 = 0
|
||||
private var completionAttachEvents: [String: [CompletionStreamEvent]] = [:]
|
||||
private var completionAttachDelayNanoseconds: UInt64 = 0
|
||||
private var chatDetailResponseSequences: [String: [ChatDetail]] = [:]
|
||||
private var searchStreamNetworkErrorMessage: String?
|
||||
private var searchStreamDelayNanoseconds: UInt64 = 0
|
||||
private var searchAttachEvents: [String: [SearchStreamEvent]] = [:]
|
||||
@@ -72,12 +79,14 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
chatDetails: [String: ChatDetail] = [:],
|
||||
searchDetails: [String: SearchDetail] = [:],
|
||||
createChatResponse: ChatSummary? = nil,
|
||||
forkChatResponse: ChatSummary? = nil,
|
||||
updateChatTitleResponses: [String: ChatSummary] = [:],
|
||||
suggestChatTitleResponses: [String: ChatSummary] = [:],
|
||||
updateChatStarResponses: [String: ChatSummary] = [:],
|
||||
updateSearchStarResponses: [String: SearchSummary] = [:],
|
||||
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
|
||||
workspaceItemsResponse: [WorkspaceItem]? = nil
|
||||
workspaceItemsResponse: [WorkspaceItem]? = nil,
|
||||
modelCatalogResponse: ModelCatalogResponse = ModelCatalogResponse(providers: [:])
|
||||
) {
|
||||
self.chatsResponse = chatsResponse
|
||||
self.searchesResponse = searchesResponse
|
||||
@@ -85,11 +94,13 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
self.chatDetails = chatDetails
|
||||
self.searchDetails = searchDetails
|
||||
self.createChatResponse = createChatResponse
|
||||
self.forkChatResponse = forkChatResponse
|
||||
self.updateChatTitleResponses = updateChatTitleResponses
|
||||
self.suggestChatTitleResponses = suggestChatTitleResponses
|
||||
self.updateChatStarResponses = updateChatStarResponses
|
||||
self.updateSearchStarResponses = updateSearchStarResponses
|
||||
self.activeRunsResponse = activeRunsResponse
|
||||
self.modelCatalogResponse = modelCatalogResponse
|
||||
}
|
||||
|
||||
private static func makeWorkspaceItems(chats: [ChatSummary], searches: [SearchSummary]) -> [WorkspaceItem] {
|
||||
@@ -113,6 +124,10 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
completionStreamDelayNanoseconds = delayNanoseconds
|
||||
}
|
||||
|
||||
func setCompletionStreamPostEventNetworkError(_ message: String) {
|
||||
completionStreamPostEventNetworkErrorMessage = message
|
||||
}
|
||||
|
||||
func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
||||
completionStreamNetworkErrorMessage = message
|
||||
completionStreamDelayNanoseconds = delayNanoseconds
|
||||
@@ -127,6 +142,10 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
getChatDelayNanoseconds = delayNanoseconds
|
||||
}
|
||||
|
||||
func setChatDetailResponses(chatID: String, responses: [ChatDetail]) {
|
||||
chatDetailResponseSequences[chatID] = responses
|
||||
}
|
||||
|
||||
func setGetSearchDelay(_ delayNanoseconds: UInt64) {
|
||||
getSearchDelayNanoseconds = delayNanoseconds
|
||||
}
|
||||
@@ -199,12 +218,27 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
if getChatDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: getChatDelayNanoseconds)
|
||||
}
|
||||
if var responses = chatDetailResponseSequences[chatID], !responses.isEmpty {
|
||||
let response = responses.removeFirst()
|
||||
chatDetailResponseSequences[chatID] = responses
|
||||
return response
|
||||
}
|
||||
guard let detail = chatDetails[chatID] else {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
func forkChat(chatID: String, messageID: String?) async throws -> ChatSummary {
|
||||
snapshot.forkChat += 1
|
||||
snapshot.lastForkChatID = chatID
|
||||
snapshot.lastForkMessageID = messageID
|
||||
guard let forkChatResponse else {
|
||||
throw UnexpectedClientCall()
|
||||
}
|
||||
return forkChatResponse
|
||||
}
|
||||
|
||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
||||
snapshot.updateChatTitle += 1
|
||||
guard let summary = updateChatTitleResponses[chatID] else {
|
||||
@@ -275,7 +309,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
}
|
||||
|
||||
func listModels() async throws -> ModelCatalogResponse {
|
||||
ModelCatalogResponse(providers: [:])
|
||||
modelCatalogResponse
|
||||
}
|
||||
|
||||
func getActiveRuns() async throws -> ActiveRunsResponse {
|
||||
@@ -319,6 +353,9 @@ private actor MockSybilClient: SybilAPIClienting {
|
||||
for event in completionStreamEvents {
|
||||
await onEvent(event)
|
||||
}
|
||||
if let completionStreamPostEventNetworkErrorMessage {
|
||||
throw APIError.networkError(message: completionStreamPostEventNetworkErrorMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
throw UnexpectedClientCall()
|
||||
@@ -457,6 +494,25 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
)
|
||||
}
|
||||
|
||||
private func makeCompletionToolCall(
|
||||
id: String,
|
||||
status: String,
|
||||
summary: String = "Searched the web"
|
||||
) -> CompletionStreamToolCall {
|
||||
CompletionStreamToolCall(
|
||||
toolCallId: id,
|
||||
name: "web_search",
|
||||
status: status,
|
||||
summary: summary,
|
||||
args: ["query": .string("Sybil streaming traces")],
|
||||
startedAt: "2026-08-30T12:00:00.000Z",
|
||||
completedAt: status == "initiated" ? nil : "2026-08-30T12:00:00.120Z",
|
||||
durationMs: status == "initiated" ? nil : 120,
|
||||
error: nil,
|
||||
resultPreview: status == "initiated" ? nil : "{\"ok\":true}"
|
||||
)
|
||||
}
|
||||
|
||||
@Test func chatForkMetadataDecodesBackwardCompatiblyAndSurvivesWorkspaceConversions() throws {
|
||||
let decoder = JSONDecoder()
|
||||
let legacySummary = try decoder.decode(
|
||||
@@ -543,6 +599,63 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
#expect(toolMessage.id == "tool-a")
|
||||
}
|
||||
|
||||
@Test func streamingAssistantStatePromotesAndReplacesEphemeralTraces() {
|
||||
var state = StreamingAssistantState()
|
||||
|
||||
#expect(state.receiveDelta(" I'll search ") == .replace(" I'll search "))
|
||||
#expect(state.receiveDelta("for that. ") == .replace(" I'll search for that. "))
|
||||
#expect(state.receiveToolCall(id: "call-1", status: "INITIATED") == .replace(""))
|
||||
#expect(state.segmentText == "")
|
||||
#expect(state.ephemeralTrace == "I'll search for that.")
|
||||
#expect(state.traceRevision == 1)
|
||||
|
||||
#expect(state.receiveToolCall(id: "call-1", status: "completed") == .unchanged)
|
||||
#expect(state.receiveToolCall(id: "call-1", status: "initiated") == .unchanged)
|
||||
#expect(state.ephemeralTrace == "I'll search for that.")
|
||||
#expect(state.traceRevision == 1)
|
||||
|
||||
#expect(state.receiveToolCall(id: "parallel-call", status: "initiated") == .replace(""))
|
||||
#expect(state.ephemeralTrace == "I'll search for that.")
|
||||
#expect(state.traceRevision == 1)
|
||||
|
||||
#expect(state.receiveDelta("I need one more source.") == .replace("I need one more source."))
|
||||
#expect(state.ephemeralTrace == nil)
|
||||
#expect(state.receiveToolCall(id: "call-2", status: "initiated") == .replace(""))
|
||||
#expect(state.ephemeralTrace == "I need one more source.")
|
||||
#expect(state.traceRevision == 2)
|
||||
|
||||
#expect(state.receiveDelta("Final answer") == .replace("Final answer"))
|
||||
#expect(state.ephemeralTrace == nil)
|
||||
#expect(state.receiveDone("Canonical final answer.") == .replace("Canonical final answer."))
|
||||
#expect(state.segmentText == "")
|
||||
#expect(state.ephemeralTrace == nil)
|
||||
}
|
||||
|
||||
@Test func streamingAssistantErrorClearsTraceWithoutReplacingPartialContent() {
|
||||
var traceState = StreamingAssistantState()
|
||||
#expect(traceState.receiveDelta("I'll inspect that.") == .replace("I'll inspect that."))
|
||||
#expect(traceState.receiveToolCall(id: "inspect", status: "initiated") == .replace(""))
|
||||
#expect(traceState.ephemeralTrace == "I'll inspect that.")
|
||||
#expect(traceState.receiveError() == .unchanged)
|
||||
#expect(traceState.segmentText.isEmpty)
|
||||
#expect(traceState.ephemeralTrace == nil)
|
||||
|
||||
var partialState = StreamingAssistantState()
|
||||
var renderedContent = ""
|
||||
if case let .replace(content) = partialState.receiveDelta("Partial answer") {
|
||||
renderedContent = content
|
||||
}
|
||||
let errorUpdate = partialState.receiveError()
|
||||
if case let .replace(content) = errorUpdate {
|
||||
renderedContent = content
|
||||
}
|
||||
|
||||
#expect(errorUpdate == .unchanged)
|
||||
#expect(renderedContent == "Partial answer")
|
||||
#expect(partialState.segmentText.isEmpty)
|
||||
#expect(partialState.ephemeralTrace == nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws {
|
||||
let defaults = UserDefaults(suiteName: #function)!
|
||||
@@ -681,6 +794,63 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
#expect(viewModel.errorMessage == nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func wholeChatForkAddsReturnedChatToWorkspace() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_160)
|
||||
let source = makeChatSummary(id: "chat-source", date: date)
|
||||
var forked = makeChatSummary(id: "chat-fork", date: date.addingTimeInterval(10))
|
||||
forked.title = "Fork of Chat chat-source"
|
||||
forked.parentChatId = source.id
|
||||
forked.titleGenerationPending = true
|
||||
let client = MockSybilClient(forkChatResponse: forked)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.chats = [source]
|
||||
viewModel.workspaceItems = [WorkspaceItem(chat: source)]
|
||||
viewModel.selectedItem = .chat(source.id)
|
||||
|
||||
let result = await viewModel.forkChat(chatID: source.id)
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.forkChat == 1)
|
||||
#expect(snapshot.lastForkChatID == source.id)
|
||||
#expect(snapshot.lastForkMessageID == nil)
|
||||
#expect(result == forked)
|
||||
#expect(viewModel.chats.first == forked)
|
||||
#expect(viewModel.workspaceItems.first == WorkspaceItem(chat: forked))
|
||||
#expect(viewModel.selectedItem == .chat(source.id))
|
||||
#expect(!viewModel.isForkingChat)
|
||||
#expect(viewModel.errorMessage == nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func responseForkPassesAssistantMessageID() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_165)
|
||||
let source = makeChatSummary(id: "chat-source", date: date)
|
||||
var forked = makeChatSummary(id: "response-fork", date: date.addingTimeInterval(10))
|
||||
forked.title = "Fork of 'A useful response'"
|
||||
forked.parentChatId = source.id
|
||||
forked.titleGenerationPending = true
|
||||
let client = MockSybilClient(forkChatResponse: forked)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.chats = [source]
|
||||
viewModel.workspaceItems = [WorkspaceItem(chat: source)]
|
||||
|
||||
let result = await viewModel.forkChat(chatID: source.id, messageID: "assistant-message")
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.forkChat == 1)
|
||||
#expect(snapshot.lastForkChatID == source.id)
|
||||
#expect(snapshot.lastForkMessageID == "assistant-message")
|
||||
#expect(result == forked)
|
||||
#expect(viewModel.sidebarItems.contains(where: { $0.selection == .chat(forked.id) }))
|
||||
#expect(!viewModel.isForkingChat)
|
||||
#expect(viewModel.errorMessage == nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func starringItemsUpdatesSidebarState() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_175)
|
||||
@@ -848,6 +1018,29 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
#expect(!viewModel.isLoadingSelection)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func failedFirstSendClearsPendingStateMigratedToCreatedChat() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_235)
|
||||
let createdChat = makeChatSummary(id: "chat-created-before-failure", date: date)
|
||||
let client = MockSybilClient(createChatResponse: createdChat)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.startNewChat()
|
||||
viewModel.model = ""
|
||||
viewModel.composer = "This send should fail after chat creation"
|
||||
|
||||
await viewModel.sendComposer()
|
||||
|
||||
let snapshot = await client.currentSnapshot()
|
||||
#expect(snapshot.createChat == 1)
|
||||
#expect(snapshot.runCompletionStream == 0)
|
||||
#expect(viewModel.selectedItem == .chat(createdChat.id))
|
||||
#expect(viewModel.selectedChat?.id == createdChat.id)
|
||||
#expect(viewModel.displayedMessages.isEmpty)
|
||||
#expect(viewModel.displayedStreamingAssistantPresentation == nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func newDraftChatDoesNotShowTypingStateFromPreviousSend() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_240)
|
||||
@@ -1067,11 +1260,66 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
#expect(snapshot.runQuickQuestionStream == 1)
|
||||
#expect(snapshot.runCompletionStream == 0)
|
||||
#expect(body?.provider == .openai)
|
||||
#expect(body?.model == "chat-latest")
|
||||
#expect(body?.question == "How do I reset my password?")
|
||||
#expect(viewModel.quickQuestionAnswerText == "Reset it from Settings.")
|
||||
#expect(!viewModel.isQuickQuestionSending)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func quickQuestionKeepsToolNarrationEphemeralAndFinalizesCanonicalAnswer() async throws {
|
||||
let client = MockSybilClient()
|
||||
await client.setCompletionStreamEvents([
|
||||
.delta(CompletionStreamDelta(text: "I'll search for that.")),
|
||||
.toolCall(makeCompletionToolCall(id: "quick-search", status: "initiated")),
|
||||
.toolCall(makeCompletionToolCall(id: "quick-search", status: "completed")),
|
||||
.delta(CompletionStreamDelta(text: "The streamed answer")),
|
||||
.done(CompletionStreamDone(text: "The canonical answer."))
|
||||
])
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.quickQuestionPrompt = "What changed?"
|
||||
|
||||
let task = viewModel.sendQuickQuestion()
|
||||
await task?.value
|
||||
|
||||
let toolMessages = viewModel.quickQuestionMessages.filter { $0.toolCallMetadata != nil }
|
||||
let assistantMessage = try #require(viewModel.quickQuestionMessages.last(where: { $0.role == .assistant }))
|
||||
#expect(toolMessages.count == 1)
|
||||
#expect(toolMessages.first?.toolCallMetadata?.status == "completed")
|
||||
#expect(assistantMessage.content == "The canonical answer.")
|
||||
#expect(!assistantMessage.content.contains("I'll search"))
|
||||
#expect(viewModel.quickQuestionAnswerText == "The canonical answer.")
|
||||
#expect(viewModel.quickQuestionStreamingAssistantPresentation?.ephemeralTrace == nil)
|
||||
#expect(!viewModel.isQuickQuestionSending)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func quickQuestionTransportErrorClearsTraceAndPreservesPartialAnswer() async throws {
|
||||
let client = MockSybilClient()
|
||||
await client.setCompletionStreamEvents([
|
||||
.delta(CompletionStreamDelta(text: "I'll inspect that.")),
|
||||
.toolCall(makeCompletionToolCall(id: "quick-inspect", status: "initiated")),
|
||||
.delta(CompletionStreamDelta(text: "Partial answer"))
|
||||
])
|
||||
await client.setCompletionStreamPostEventNetworkError("connection lost")
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
viewModel.isAuthenticated = true
|
||||
viewModel.isCheckingSession = false
|
||||
viewModel.quickQuestionPrompt = "What changed?"
|
||||
|
||||
let task = viewModel.sendQuickQuestion()
|
||||
await task?.value
|
||||
|
||||
let assistantMessage = try #require(viewModel.quickQuestionMessages.last(where: { $0.role == .assistant }))
|
||||
#expect(assistantMessage.content == "Partial answer")
|
||||
#expect(viewModel.quickQuestionAnswerText == "Partial answer")
|
||||
#expect(viewModel.quickQuestionStreamingAssistantPresentation?.ephemeralTrace == nil)
|
||||
#expect(viewModel.quickQuestionError != nil)
|
||||
#expect(!viewModel.isQuickQuestionSending)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func quickQuestionConvertCreatesSeededChat() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_250)
|
||||
@@ -1126,6 +1374,42 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
#expect(viewModel.quickQuestionPrompt.isEmpty)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func quickQuestionDefaultsToChatLatestIndependentlyOfRegularChatPreferences() async {
|
||||
let defaults = UserDefaults(suiteName: #function)!
|
||||
defaults.removePersistentDomain(forName: #function)
|
||||
defer { defaults.removePersistentDomain(forName: #function) }
|
||||
defaults.set("anthropic", forKey: "sybil.ios.preferredProvider")
|
||||
defaults.set("gpt-4o", forKey: "sybil.ios.preferredOpenAIModel")
|
||||
let settings = SybilSettingsStore(defaults: defaults)
|
||||
let client = MockSybilClient(modelCatalogResponse: ModelCatalogResponse(providers: [
|
||||
.openai: ProviderModelInfo(models: ["gpt-4.1-mini", "chat-latest"], loadedAt: nil, error: nil),
|
||||
.anthropic: ProviderModelInfo(models: ["claude-3-5-sonnet-latest"], loadedAt: nil, error: nil)
|
||||
]))
|
||||
let viewModel = SybilViewModel(settings: settings) { _ in client }
|
||||
|
||||
#expect(viewModel.provider == .anthropic)
|
||||
#expect(settings.preferredModelByProvider[.openai] == "gpt-4o")
|
||||
#expect(viewModel.quickQuestionProvider == .openai)
|
||||
#expect(viewModel.quickQuestionModel == "chat-latest")
|
||||
#expect(viewModel.quickQuestionProviderModelOptions.contains("chat-latest"))
|
||||
|
||||
viewModel.setQuickQuestionProvider(.anthropic)
|
||||
viewModel.setQuickQuestionProvider(.openai)
|
||||
#expect(viewModel.quickQuestionModel == "chat-latest")
|
||||
|
||||
await viewModel.bootstrap()
|
||||
#expect(viewModel.quickQuestionModel == "chat-latest")
|
||||
#expect(viewModel.quickQuestionProviderModelOptions == ["gpt-4.1-mini", "chat-latest"])
|
||||
|
||||
viewModel.setQuickQuestionProvider(.anthropic)
|
||||
viewModel.setQuickQuestionProvider(.openai)
|
||||
#expect(viewModel.quickQuestionModel == "chat-latest")
|
||||
#expect(SybilSettingsStore(defaults: defaults).quickQuestionPreferredModelByProvider[.openai] == "chat-latest")
|
||||
#expect(settings.preferredProvider == .anthropic)
|
||||
#expect(settings.preferredModelByProvider[.openai] == "gpt-4o")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func quickQuestionProviderAndModelSelectionPersistSeparately() async throws {
|
||||
let defaults = UserDefaults(suiteName: #function)!
|
||||
@@ -1184,6 +1468,44 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
||||
#expect(viewModel.displayedMessages.last?.content == "streaming")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func completedAttachKeepsFinalContentVisibleUntilCanonicalChatLoads() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_265)
|
||||
let chat = makeChatSummary(id: "chat-active-complete", date: date)
|
||||
let baseDetail = makeChatDetail(id: chat.id, date: date, body: "base transcript")
|
||||
let canonicalDetail = makeChatDetail(id: chat.id, date: date, body: "canonical attached response")
|
||||
let client = MockSybilClient(
|
||||
chatsResponse: [chat],
|
||||
chatDetails: [chat.id: canonicalDetail],
|
||||
activeRunsResponse: ActiveRunsResponse(chats: [chat.id])
|
||||
)
|
||||
await client.setChatDetailResponses(chatID: chat.id, responses: [baseDetail, canonicalDetail])
|
||||
await client.setGetChatDelay(80_000_000)
|
||||
await client.setCompletionAttachEvents(
|
||||
chatID: chat.id,
|
||||
events: [.done(CompletionStreamDone(text: "attached response"))]
|
||||
)
|
||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||
|
||||
await viewModel.reconnect()
|
||||
|
||||
var snapshot = await client.currentSnapshot()
|
||||
for _ in 0..<80 {
|
||||
guard snapshot.getChat < 2 else { break }
|
||||
try await Task.sleep(nanoseconds: 5_000_000)
|
||||
snapshot = await client.currentSnapshot()
|
||||
}
|
||||
|
||||
#expect(snapshot.getChat >= 2)
|
||||
#expect(viewModel.displayedMessages.last?.content == "attached response")
|
||||
|
||||
for _ in 0..<80 {
|
||||
guard viewModel.displayedMessages.last?.content != "canonical attached response" else { break }
|
||||
try await Task.sleep(nanoseconds: 5_000_000)
|
||||
}
|
||||
#expect(viewModel.displayedMessages.last?.content == "canonical attached response")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func activeRunOnDifferentChatDoesNotDisableComposer() async throws {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_270)
|
||||
|
||||
+9
-2
@@ -16,8 +16,13 @@ second **Option+Space**, the close button, or clicking outside dismisses it.
|
||||
Hiding the panel preserves the draft and answer and lets an in-flight answer finish.
|
||||
**Open in Chat** saves the question and answer in the regular workspace.
|
||||
|
||||
Quick Question defaults to OpenAI `chat-latest` on iPhone, iPad, and Mac. Its saved
|
||||
provider/model selection takes precedence and is separate from regular chat preferences.
|
||||
|
||||
The regular Mac chat/search composer uses the same Return/Shift+Return rules.
|
||||
Holding Return does not repeatedly submit. The iOS software keyboard is unchanged.
|
||||
Holding Return does not repeatedly submit. On iOS, Return submits from Quick
|
||||
Question, while the normal chat/search composer continues to use Return for a
|
||||
new line.
|
||||
|
||||
Closing the last Mac window leaves Sybil running, so Option+Space still works.
|
||||
Use **Sybil → Quit Sybil** or **Command+Q** to quit the application. The Catalyst
|
||||
@@ -66,7 +71,9 @@ action, and does not build or embed the macOS helper.
|
||||
answer remain available. Reopen the workspace from the panel, then explicitly
|
||||
quit with Command+Q. Rebuild and relaunch after changing lifecycle plist keys.
|
||||
- In both Mac and iOS Quick Question, reopen an existing prompt and type to replace
|
||||
it. In the Mac workspace, verify Return submits and Shift+Return adds a newline.
|
||||
it. On iOS, verify Return submits Quick Question while Return in the normal
|
||||
prompt adds a newline. In the Mac workspace, verify Return submits and
|
||||
Shift+Return adds a newline.
|
||||
- Check Command+N both with a workspace open and after closing every workspace.
|
||||
Command+Shift+N must open another window even when one already exists, and
|
||||
Command+Option+N must start a search without opening a window.
|
||||
|
||||
@@ -95,7 +95,7 @@ function isLikelyResponsesApiModel(model: string) {
|
||||
if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false;
|
||||
if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false;
|
||||
if (id.includes("search") || id.includes("computer-use")) return false;
|
||||
return /^(gpt-|o\d|chatgpt-)/.test(id);
|
||||
return id === "chat-latest" || /^(gpt-|o\d|chatgpt-)/.test(id);
|
||||
}
|
||||
|
||||
function isLikelyGeminiChatModel(model: string) {
|
||||
|
||||
@@ -1,6 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { describeProviderChatBackend } from "../src/llm/provider-adapters.js";
|
||||
import { env } from "../src/env.js";
|
||||
import { describeProviderChatBackend, fetchProviderCatalogModels } from "../src/llm/provider-adapters.js";
|
||||
|
||||
for (const { name, listedModels, expectedModels } of [
|
||||
{
|
||||
name: "OpenAI catalog includes chat-latest while excluding non-chat models",
|
||||
listedModels: [
|
||||
"gpt-4.1-mini",
|
||||
"chat-latest",
|
||||
"o3",
|
||||
"chatgpt-4o-latest",
|
||||
"chat-latest",
|
||||
"text-embedding-3-small",
|
||||
"omni-moderation-latest",
|
||||
"gpt-4o-audio-preview",
|
||||
"gpt-realtime",
|
||||
"gpt-4o-transcribe",
|
||||
"gpt-4o-mini-tts",
|
||||
"gpt-image-1",
|
||||
"chatgpt-image-latest",
|
||||
"gpt-4o-search-preview",
|
||||
"computer-use-preview",
|
||||
"sora-2",
|
||||
],
|
||||
expectedModels: ["chat-latest", "chatgpt-4o-latest", "gpt-4.1-mini", "o3"],
|
||||
},
|
||||
{
|
||||
name: "OpenAI catalog does not invent models absent from the provider response",
|
||||
listedModels: ["gpt-4.1-mini"],
|
||||
expectedModels: ["gpt-4.1-mini"],
|
||||
},
|
||||
]) {
|
||||
test(name, async (t) => {
|
||||
const originalKey = env.OPENAI_API_KEY;
|
||||
env.OPENAI_API_KEY = "test-openai-key";
|
||||
t.after(() => { env.OPENAI_API_KEY = originalKey; });
|
||||
t.mock.method(globalThis, "fetch", async (input: RequestInfo | URL) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
assert.equal(url, "https://api.openai.com/v1/models");
|
||||
return Response.json({ data: listedModels.map((id) => ({ id })) });
|
||||
});
|
||||
|
||||
assert.deepEqual(await fetchProviderCatalogModels("openai"), expectedModels);
|
||||
});
|
||||
}
|
||||
|
||||
test("provider backend registry selects chat protocol and managed-tool mode", () => {
|
||||
assert.deepEqual(describeProviderChatBackend("openai", []), {
|
||||
|
||||
+132
-112
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import type { TargetedTouchEvent } from "preact";
|
||||
import {
|
||||
Check,
|
||||
@@ -80,7 +80,17 @@ import {
|
||||
resolveSidebarSelectionAfterRefresh,
|
||||
type SidebarSelection,
|
||||
} from "@/lib/sidebar-selection";
|
||||
import { buildQuickQuestionRequest } from "@/lib/quick-question";
|
||||
import { buildQuickQuestionRequest, pickQuickQuestionModel, QUICK_QUESTION_DEFAULT_MODEL } from "@/lib/quick-question";
|
||||
import {
|
||||
appendStreamingDelta,
|
||||
clearStreamingAssistantTrace,
|
||||
createStreamingAssistantMetadata,
|
||||
createStreamingAttemptState,
|
||||
finalizeStreamingAssistant,
|
||||
promoteStreamingAssistantTrace,
|
||||
setStreamingAssistantSegment,
|
||||
transitionStreamingToolCall,
|
||||
} from "@/lib/chat-stream-presentation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type DraftSelectionKind = "chat" | "search";
|
||||
@@ -151,7 +161,7 @@ function buildWorkspaceUrl(selection: SidebarSelection | null) {
|
||||
}
|
||||
|
||||
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
||||
openai: ["gpt-4.1-mini"],
|
||||
openai: ["gpt-4.1-mini", QUICK_QUESTION_DEFAULT_MODEL],
|
||||
anthropic: ["claude-3-5-sonnet-latest"],
|
||||
xai: ["grok-3-mini"],
|
||||
gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
||||
@@ -644,9 +654,10 @@ type ModelComboboxProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function ModelCombobox({ options, value, onChange, disabled = false }: ModelComboboxProps) {
|
||||
function ModelCombobox({ options, value, onChange, disabled = false, className }: ModelComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draftValue, setDraftValue] = useState(value);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
@@ -700,7 +711,12 @@ function ModelCombobox({ options, value, onChange, disabled = false }: ModelComb
|
||||
|
||||
return (
|
||||
<div className="relative" ref={rootRef}>
|
||||
<div className="flex h-10 min-w-56 items-center rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)]">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 min-w-56 items-center rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draftValue}
|
||||
@@ -938,7 +954,6 @@ export default function App() {
|
||||
isCheckingSession,
|
||||
isSigningIn,
|
||||
isAuthenticated,
|
||||
authMode,
|
||||
authError,
|
||||
handleAuthFailure: baseHandleAuthFailure,
|
||||
handleSignIn,
|
||||
@@ -976,7 +991,7 @@ export default function App() {
|
||||
);
|
||||
const [quickModel, setQuickModel] = useState(() => {
|
||||
const stored = loadStoredQuickQuestionModelSelection();
|
||||
return stored.modelPreferences[stored.provider] ?? PROVIDER_FALLBACK_MODELS[stored.provider][0];
|
||||
return pickQuickQuestionModel(stored.provider, PROVIDER_FALLBACK_MODELS[stored.provider], stored.modelPreferences[stored.provider]);
|
||||
});
|
||||
const [isQuickQuestionOpen, setIsQuickQuestionOpen] = useState(false);
|
||||
const [quickPrompt, setQuickPrompt] = useState("");
|
||||
@@ -1008,6 +1023,7 @@ export default function App() {
|
||||
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
const renameChatInputRef = useRef<HTMLInputElement>(null);
|
||||
const chatSettingsDialogRef = useRef<HTMLDialogElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dragDepthRef = useRef(0);
|
||||
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
|
||||
@@ -1433,7 +1449,7 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
if (quickModel.trim()) return;
|
||||
setQuickModel((current) => {
|
||||
return current.trim() || pickProviderModel(quickProviderModelOptions, quickProviderModelPreferences[quickProvider]);
|
||||
return current.trim() || pickQuickQuestionModel(quickProvider, quickProviderModelOptions, quickProviderModelPreferences[quickProvider]);
|
||||
});
|
||||
}, [quickModel, quickProvider, quickProviderModelOptions, quickProviderModelPreferences]);
|
||||
|
||||
@@ -1920,6 +1936,7 @@ export default function App() {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (chatSettingsDialogRef.current?.open) return;
|
||||
const hasPrimaryModifier = event.metaKey || event.ctrlKey;
|
||||
if (!hasPrimaryModifier || event.altKey) return;
|
||||
|
||||
@@ -2321,16 +2338,16 @@ export default function App() {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [renameChatDialog]);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!isChatSettingsOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || isSavingChatSettings) return;
|
||||
event.preventDefault();
|
||||
setIsChatSettingsOpen(false);
|
||||
const dialog = chatSettingsDialogRef.current;
|
||||
const previouslyFocused = document.activeElement;
|
||||
dialog?.showModal();
|
||||
return () => {
|
||||
dialog?.close();
|
||||
if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus({ preventScroll: true });
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isChatSettingsOpen, isSavingChatSettings]);
|
||||
}, [isChatSettingsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isQuickQuestionOpen) return;
|
||||
@@ -2486,7 +2503,7 @@ export default function App() {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
name: null,
|
||||
metadata: null,
|
||||
metadata: createStreamingAssistantMetadata(),
|
||||
};
|
||||
|
||||
let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null;
|
||||
@@ -2591,7 +2608,7 @@ export default function App() {
|
||||
|
||||
while (true) {
|
||||
let streamErrorMessage: string | null = null;
|
||||
let replayedAssistantText = "";
|
||||
let streamingAttempt = createStreamingAttemptState();
|
||||
const abortController = new AbortController();
|
||||
chatStreamAbortRefs.current.set(chatId, abortController);
|
||||
|
||||
@@ -2609,45 +2626,39 @@ export default function App() {
|
||||
if (payload.chatId !== chatId) return;
|
||||
},
|
||||
onToolCall: (payload) => {
|
||||
const transition = transitionStreamingToolCall(streamingAttempt, payload);
|
||||
streamingAttempt = transition.attempt;
|
||||
setPendingChatStates((current) => {
|
||||
const pendingState = current[chatId];
|
||||
if (!pendingState) return current;
|
||||
const messages = transition.startedNewToolCall
|
||||
? promoteStreamingAssistantTrace(pendingState.messages, "temp-assistant-", transition.traceText ?? "")
|
||||
: pendingState.messages;
|
||||
return {
|
||||
...current,
|
||||
[chatId]: {
|
||||
messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"),
|
||||
messages: upsertOptimisticToolMessage(messages, payload, "temp-assistant-"),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
onDelta: (payload) => {
|
||||
if (!payload.text) return;
|
||||
replayedAssistantText += payload.text;
|
||||
streamingAttempt = appendStreamingDelta(streamingAttempt, payload.text);
|
||||
const segmentText = streamingAttempt.segmentText;
|
||||
setPendingChatStates((current) => {
|
||||
const pendingState = current[chatId];
|
||||
if (!pendingState) return current;
|
||||
let updated = false;
|
||||
const nextMessages = pendingState.messages.map((message, index, all) => {
|
||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-");
|
||||
if (!isTarget) return message;
|
||||
updated = true;
|
||||
return { ...message, content: replayedAssistantText };
|
||||
});
|
||||
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
|
||||
const nextMessages = setStreamingAssistantSegment(pendingState.messages, "temp-assistant-", segmentText);
|
||||
return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } };
|
||||
});
|
||||
},
|
||||
onDone: (payload) => {
|
||||
setPendingChatStates((current) => {
|
||||
const pendingState = current[chatId];
|
||||
if (!pendingState) return current;
|
||||
let updated = false;
|
||||
const nextMessages = pendingState.messages.map((message, index, all) => {
|
||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-");
|
||||
if (!isTarget) return message;
|
||||
updated = true;
|
||||
return { ...message, content: payload.text };
|
||||
});
|
||||
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
|
||||
const nextMessages = finalizeStreamingAssistant(pendingState.messages, "temp-assistant-", payload.text);
|
||||
return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } };
|
||||
});
|
||||
},
|
||||
onError: (payload) => {
|
||||
@@ -2896,6 +2907,7 @@ export default function App() {
|
||||
const abortController = new AbortController();
|
||||
chatStreamAbortRefs.current.set(chatId, abortController);
|
||||
let streamErrorMessage: string | null = null;
|
||||
let streamingAttempt = createStreamingAttemptState();
|
||||
|
||||
try {
|
||||
const baseChat = await getChat(chatId);
|
||||
@@ -2912,7 +2924,7 @@ export default function App() {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
name: null,
|
||||
metadata: null,
|
||||
metadata: createStreamingAssistantMetadata(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -2922,44 +2934,39 @@ export default function App() {
|
||||
chatId,
|
||||
{
|
||||
onToolCall: (payload) => {
|
||||
const transition = transitionStreamingToolCall(streamingAttempt, payload);
|
||||
streamingAttempt = transition.attempt;
|
||||
setPendingChatStates((current) => {
|
||||
const pendingState = current[chatId];
|
||||
if (!pendingState) return current;
|
||||
const messages = transition.startedNewToolCall
|
||||
? promoteStreamingAssistantTrace(pendingState.messages, "temp-assistant-", transition.traceText ?? "")
|
||||
: pendingState.messages;
|
||||
return {
|
||||
...current,
|
||||
[chatId]: {
|
||||
messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"),
|
||||
messages: upsertOptimisticToolMessage(messages, payload, "temp-assistant-"),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
onDelta: (payload) => {
|
||||
if (!payload.text) return;
|
||||
streamingAttempt = appendStreamingDelta(streamingAttempt, payload.text);
|
||||
const segmentText = streamingAttempt.segmentText;
|
||||
setPendingChatStates((current) => {
|
||||
const pendingState = current[chatId];
|
||||
if (!pendingState) return current;
|
||||
let updated = false;
|
||||
const nextMessages = pendingState.messages.map((message, index, all) => {
|
||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-");
|
||||
if (!isTarget) return message;
|
||||
updated = true;
|
||||
return { ...message, content: message.content + payload.text };
|
||||
});
|
||||
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
|
||||
const nextMessages = setStreamingAssistantSegment(pendingState.messages, "temp-assistant-", segmentText);
|
||||
return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } };
|
||||
});
|
||||
},
|
||||
onDone: (payload) => {
|
||||
setPendingChatStates((current) => {
|
||||
const pendingState = current[chatId];
|
||||
if (!pendingState) return current;
|
||||
let updated = false;
|
||||
const nextMessages = pendingState.messages.map((message, index, all) => {
|
||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-");
|
||||
if (!isTarget) return message;
|
||||
updated = true;
|
||||
return { ...message, content: payload.text };
|
||||
});
|
||||
return updated ? { ...current, [chatId]: { messages: nextMessages } } : current;
|
||||
const nextMessages = finalizeStreamingAssistant(pendingState.messages, "temp-assistant-", payload.text);
|
||||
return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } };
|
||||
});
|
||||
},
|
||||
onError: (payload) => {
|
||||
@@ -3211,7 +3218,7 @@ export default function App() {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
name: null,
|
||||
metadata: null,
|
||||
metadata: createStreamingAssistantMetadata(),
|
||||
};
|
||||
|
||||
quickQuestionAbortRef.current?.abort();
|
||||
@@ -3225,6 +3232,7 @@ export default function App() {
|
||||
setIsQuickQuestionSending(true);
|
||||
|
||||
let streamErrorMessage: string | null = null;
|
||||
let streamingAttempt = createStreamingAttemptState();
|
||||
|
||||
try {
|
||||
await runQuickQuestionStream(
|
||||
@@ -3235,33 +3243,26 @@ export default function App() {
|
||||
}),
|
||||
{
|
||||
onToolCall: (payload) => {
|
||||
const transition = transitionStreamingToolCall(streamingAttempt, payload);
|
||||
streamingAttempt = transition.attempt;
|
||||
setQuickQuestionMessages((current) => {
|
||||
return upsertOptimisticToolMessage(current, payload, "temp-assistant-quick-");
|
||||
const messages = transition.startedNewToolCall
|
||||
? promoteStreamingAssistantTrace(current, "temp-assistant-quick-", transition.traceText ?? "")
|
||||
: current;
|
||||
return upsertOptimisticToolMessage(messages, payload, "temp-assistant-quick-");
|
||||
});
|
||||
},
|
||||
onDelta: (payload) => {
|
||||
if (!payload.text) return;
|
||||
streamingAttempt = appendStreamingDelta(streamingAttempt, payload.text);
|
||||
const segmentText = streamingAttempt.segmentText;
|
||||
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;
|
||||
return setStreamingAssistantSegment(current, "temp-assistant-quick-", segmentText);
|
||||
});
|
||||
},
|
||||
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;
|
||||
return finalizeStreamingAssistant(current, "temp-assistant-quick-", payload.text);
|
||||
});
|
||||
},
|
||||
onError: (payload) => {
|
||||
@@ -3276,6 +3277,7 @@ export default function App() {
|
||||
}
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) return;
|
||||
setQuickQuestionMessages((current) => clearStreamingAssistantTrace(current, "temp-assistant-quick-"));
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("bearer token")) {
|
||||
handleAuthFailure(message);
|
||||
@@ -3438,7 +3440,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<div className="app-grid-surface app-safe-frame h-full">
|
||||
<div className="workspace-shell flex h-full w-full overflow-hidden bg-transparent md:gap-2">
|
||||
<div className="workspace-shell flex h-full w-full overflow-hidden bg-transparent">
|
||||
{isMobileSidebarOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -3450,19 +3452,13 @@ export default function App() {
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"workspace-sidebar glass-panel fixed inset-y-0 left-0 z-40 flex w-[86vw] max-w-80 shrink-0 flex-col border-r border-violet-300/18 transition-transform md:static md:z-auto md:w-80 md:max-w-none md:rounded-2xl md:border",
|
||||
"workspace-sidebar glass-panel fixed inset-y-0 left-0 z-40 flex w-[86vw] max-w-80 shrink-0 flex-col border-r border-violet-300/18 transition-transform md:static md:z-auto md:w-80 md:max-w-none",
|
||||
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||
)}
|
||||
>
|
||||
<div className="relative min-h-24 px-4 pb-4 pt-5">
|
||||
<div className="pr-24">
|
||||
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
|
||||
SYBIL
|
||||
</div>
|
||||
<p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
|
||||
</p>
|
||||
<div className="relative min-h-24 px-4 pb-4 pt-5 flex items-center">
|
||||
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
|
||||
SYBIL
|
||||
</div>
|
||||
<SybilCharacter
|
||||
className="absolute right-4 top-3 h-[calc(100%-1.5rem)]"
|
||||
@@ -3613,7 +3609,7 @@ export default function App() {
|
||||
</aside>
|
||||
|
||||
<main
|
||||
className="workspace-content glass-panel relative flex min-w-0 flex-1 touch-pan-y flex-col overflow-hidden border-violet-300/18 md:touch-auto md:rounded-2xl md:border"
|
||||
className="workspace-content glass-panel relative flex min-w-0 flex-1 touch-pan-y flex-col overflow-hidden md:touch-auto"
|
||||
onTouchStart={handleMobileWorkspaceTouchStart}
|
||||
onTouchMove={handleMobileWorkspaceTouchMove}
|
||||
onTouchEnd={handleMobileWorkspaceTouchEnd}
|
||||
@@ -3827,44 +3823,66 @@ export default function App() {
|
||||
</div>
|
||||
) : null}
|
||||
{isChatSettingsOpen ? (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
||||
<dialog
|
||||
ref={chatSettingsDialogRef}
|
||||
aria-labelledby="chat-settings-title"
|
||||
className="fixed inset-0 m-0 flex h-dvh max-h-none w-full max-w-none items-center justify-center overflow-hidden bg-transparent p-0 text-foreground backdrop:bg-black/70 backdrop:backdrop-blur-md md:p-6"
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
if (!isSavingChatSettings) setIsChatSettingsOpen(false);
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget && !isSavingChatSettings) setIsChatSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
<form
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="chat-settings-title"
|
||||
className="glass-panel flex max-h-[88vh] w-full max-w-2xl flex-col rounded-2xl border border-violet-300/24 p-4 shadow-2xl shadow-black/45 md:p-5"
|
||||
className="glass-panel flex h-full min-h-0 w-full flex-col overflow-hidden md:h-auto md:max-h-full md:max-w-2xl md:rounded-2xl md:border md:border-violet-300/24"
|
||||
onSubmit={(event) => void handleChatSettingsSubmit(event)}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="chat-settings-header grid shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2 border-b border-violet-300/16 md:flex md:justify-between md:gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-11 justify-self-start px-2 text-violet-200 hover:bg-violet-400/10 hover:text-violet-100 md:hidden"
|
||||
onClick={() => setIsChatSettingsOpen(false)}
|
||||
disabled={isSavingChatSettings}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<h2 id="chat-settings-title" className="text-sm font-semibold text-violet-50">
|
||||
<h2 id="chat-settings-title" className="text-base font-semibold text-violet-50 md:text-sm">
|
||||
Chat settings
|
||||
</h2>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">{chatSettingsTitleDraft.trim() || "New chat"}</p>
|
||||
<p className="mt-1 hidden truncate text-xs text-muted-foreground md:block">{chatSettingsTitleDraft.trim() || "New chat"}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
className="hidden h-8 w-8 shrink-0 md:inline-flex"
|
||||
onClick={() => setIsChatSettingsOpen(false)}
|
||||
disabled={isSavingChatSettings}
|
||||
aria-label="Close chat settings"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
className="h-11 justify-self-end gap-1.5 px-2 font-semibold text-violet-200 hover:bg-violet-400/10 hover:text-violet-100 md:hidden"
|
||||
disabled={isSavingChatSettings}
|
||||
>
|
||||
{isSavingChatSettings ? <LoaderCircle className="h-4 w-4 animate-spin" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
|
||||
<div className="chat-settings-content min-h-0 flex-1 space-y-5 overflow-y-auto overscroll-contain md:space-y-4">
|
||||
<div>
|
||||
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Chat title</span>
|
||||
<label for="chat-settings-chat-title" className="mb-1.5 block text-xs font-semibold text-violet-100/72">Chat title</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="chat-settings-chat-title"
|
||||
value={chatSettingsTitleDraft}
|
||||
onInput={(event) => {
|
||||
setChatSettingsTitleDraft(event.currentTarget.value);
|
||||
@@ -3872,7 +3890,7 @@ export default function App() {
|
||||
}}
|
||||
maxLength={120}
|
||||
placeholder={draftKind === null && selectedItem?.kind === "chat" ? "Chat title" : "Optional title"}
|
||||
className="h-11 min-w-0 flex-1 rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
||||
className="h-11 min-w-0 flex-1 rounded-lg border border-violet-300/22 bg-background/70 px-3 text-base text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70 md:text-sm"
|
||||
disabled={isSavingChatSettings}
|
||||
/>
|
||||
{chatSettingsChatId ? (
|
||||
@@ -3900,7 +3918,7 @@ export default function App() {
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Provider</span>
|
||||
<select
|
||||
className="h-10 w-full rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
||||
className="h-11 w-full rounded-lg border border-violet-300/22 bg-background/70 px-3 text-base 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 md:h-10 md:text-sm"
|
||||
value={chatSettingsProviderDraft}
|
||||
onChange={(event) => {
|
||||
const nextProvider = event.currentTarget.value as Provider;
|
||||
@@ -3922,6 +3940,7 @@ export default function App() {
|
||||
<label className="block min-w-0">
|
||||
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Model</span>
|
||||
<ModelCombobox
|
||||
className="h-11 min-w-0 bg-background/70 text-base md:h-10 md:text-sm"
|
||||
options={chatSettingsProviderModelOptions}
|
||||
value={chatSettingsModelDraft}
|
||||
disabled={isSavingChatSettings}
|
||||
@@ -3943,7 +3962,7 @@ export default function App() {
|
||||
if (chatSettingsError) setChatSettingsError(null);
|
||||
}}
|
||||
placeholder="Add per-chat instructions"
|
||||
className="min-h-32 resize-y border-violet-300/24 bg-background/72 text-sm text-violet-50 placeholder:text-violet-200/45"
|
||||
className="min-h-32 resize-y border-violet-300/24 bg-background/70 text-base text-violet-50 placeholder:text-violet-200/45 md:text-sm"
|
||||
disabled={isSavingChatSettings}
|
||||
/>
|
||||
</label>
|
||||
@@ -3957,6 +3976,7 @@ export default function App() {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-11 gap-1.5 md:h-8"
|
||||
onClick={() => setChatSettingsEnabledToolsDraft(getDefaultEnabledTools(availableChatTools))}
|
||||
disabled={isSavingChatSettings}
|
||||
>
|
||||
@@ -3967,6 +3987,7 @@ export default function App() {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-11 gap-1.5 md:h-8"
|
||||
onClick={() => setChatSettingsEnabledToolsDraft([])}
|
||||
disabled={isSavingChatSettings}
|
||||
>
|
||||
@@ -3976,31 +3997,30 @@ export default function App() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{availableChatTools.length ? (
|
||||
availableChatTools.map((tool) => {
|
||||
const checked = chatSettingsEnabledToolsDraft.includes(tool.name);
|
||||
return (
|
||||
<label
|
||||
key={tool.name}
|
||||
className="flex cursor-pointer items-start gap-3 rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 transition hover:border-violet-300/34 hover:bg-violet-400/8"
|
||||
title={tool.description}
|
||||
className="flex min-h-11 min-w-0 cursor-pointer items-center gap-3 rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 transition hover:border-violet-300/34 hover:bg-violet-400/8 focus-within:border-violet-300/45 focus-within:ring-1 focus-within:ring-ring/70"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleChatSettingsTool(tool.name)}
|
||||
className="mt-1 h-4 w-4 rounded border-violet-300/35 bg-background/80 accent-violet-400"
|
||||
className="h-4 w-4 shrink-0 rounded border-violet-300/35 bg-background/80 accent-violet-400"
|
||||
aria-description={tool.description}
|
||||
disabled={isSavingChatSettings}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-violet-50">{getToolLabel(tool.name)}</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">{tool.description}</span>
|
||||
</span>
|
||||
<span className="min-w-0 break-words text-sm font-medium text-violet-50">{getToolLabel(tool.name)}</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 text-sm text-muted-foreground">
|
||||
<p className="rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 text-sm text-muted-foreground md:col-span-2">
|
||||
No chat tools are available.
|
||||
</p>
|
||||
)}
|
||||
@@ -4008,8 +4028,8 @@ export default function App() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{chatSettingsError ? <p className="mt-3 text-sm text-rose-300">{chatSettingsError}</p> : null}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
{chatSettingsError ? <p role="alert" className="chat-settings-error shrink-0 break-words border-t border-violet-300/16 text-sm text-rose-300">{chatSettingsError}</p> : null}
|
||||
<div className="hidden shrink-0 justify-end gap-2 border-t border-violet-300/16 p-5 md:flex">
|
||||
<Button type="button" variant="secondary" onClick={() => setIsChatSettingsOpen(false)} disabled={isSavingChatSettings}>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -4019,7 +4039,7 @@ export default function App() {
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
) : null}
|
||||
{renameChatDialog ? (
|
||||
<div
|
||||
@@ -4078,7 +4098,7 @@ export default function App() {
|
||||
) : 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"
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-3 md:p-6"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) setIsQuickQuestionOpen(false);
|
||||
}}
|
||||
@@ -4156,7 +4176,7 @@ export default function App() {
|
||||
const nextProvider = event.currentTarget.value as Provider;
|
||||
setQuickProvider(nextProvider);
|
||||
const options = getModelOptions(modelCatalog, nextProvider);
|
||||
setQuickModel(pickProviderModel(options, quickProviderModelPreferences[nextProvider]));
|
||||
setQuickModel(pickQuickQuestionModel(nextProvider, options, quickProviderModelPreferences[nextProvider]));
|
||||
}}
|
||||
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
|
||||
aria-label="Quick question provider"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import type { ComponentChildren, JSX } from "preact";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||
import { getMessageAttachments, type Message } from "@/lib/api";
|
||||
import { asStreamingAssistantMetadata } from "@/lib/chat-stream-presentation";
|
||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
||||
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
|
||||
|
||||
@@ -377,13 +378,13 @@ function ToolCallStack({
|
||||
);
|
||||
})}
|
||||
{!expanded && hiddenCount ? (
|
||||
<span className="absolute bottom-1 right-10 z-20 rounded-full border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
|
||||
<span className="absolute bottom-1 right-10 z-20 rounded-none border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
|
||||
+{hiddenCount}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full"
|
||||
className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-none"
|
||||
aria-expanded={expanded ? "true" : "false"}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
|
||||
title={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
|
||||
@@ -396,8 +397,144 @@ function ToolCallStack({
|
||||
);
|
||||
}
|
||||
|
||||
type RenderedThinkingTrace = {
|
||||
text: string;
|
||||
revision: number;
|
||||
};
|
||||
|
||||
const THINKING_TRACE_EXIT_MS = 220;
|
||||
|
||||
export function StreamingAssistantPresentation({ message, isSending }: { message: Message; isSending: boolean }) {
|
||||
const metadata = asStreamingAssistantMetadata(message.metadata);
|
||||
const traceText = metadata?.ephemeralTrace?.trim() || null;
|
||||
const traceRevision = metadata?.traceRevision ?? 0;
|
||||
const [activeTrace, setActiveTrace] = useState<RenderedThinkingTrace | null>(() =>
|
||||
traceText ? { text: traceText, revision: traceRevision } : null
|
||||
);
|
||||
const [outgoingTrace, setOutgoingTrace] = useState<RenderedThinkingTrace | null>(null);
|
||||
const [reservedTraceHeight, setReservedTraceHeight] = useState(0);
|
||||
const activeTraceRef = useRef<HTMLDivElement | null>(null);
|
||||
const outgoingTraceRef = useRef<HTMLDivElement | null>(null);
|
||||
const exitTimerRef = useRef<number | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const isSameTrace =
|
||||
(!activeTrace && !traceText) ||
|
||||
(activeTrace?.text === traceText && activeTrace?.revision === traceRevision && Boolean(activeTrace) === Boolean(traceText));
|
||||
if (isSameTrace) return;
|
||||
|
||||
if (exitTimerRef.current !== null) {
|
||||
window.clearTimeout(exitTimerRef.current);
|
||||
exitTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (activeTrace) {
|
||||
setOutgoingTrace(activeTrace);
|
||||
exitTimerRef.current = window.setTimeout(() => {
|
||||
setOutgoingTrace(null);
|
||||
exitTimerRef.current = null;
|
||||
}, THINKING_TRACE_EXIT_MS);
|
||||
} else {
|
||||
setOutgoingTrace(null);
|
||||
}
|
||||
|
||||
setActiveTrace(traceText ? { text: traceText, revision: traceRevision } : null);
|
||||
}, [traceRevision, traceText]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (exitTimerRef.current !== null) window.clearTimeout(exitTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const traceElements = [activeTraceRef.current, outgoingTraceRef.current].filter(
|
||||
(element): element is HTMLDivElement => element !== null
|
||||
);
|
||||
if (!traceElements.length) return;
|
||||
|
||||
const reserveMeasuredHeight = () => {
|
||||
const measuredHeight = Math.max(...traceElements.map((element) => element.getBoundingClientRect().height));
|
||||
if (measuredHeight > 0) {
|
||||
setReservedTraceHeight((current) => Math.max(current, Math.ceil(measuredHeight)));
|
||||
}
|
||||
};
|
||||
|
||||
reserveMeasuredHeight();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
|
||||
const observer = new ResizeObserver(reserveMeasuredHeight);
|
||||
for (const element of traceElements) observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [activeTrace, outgoingTrace]);
|
||||
|
||||
const hasAnswer = message.content.trim().length > 0;
|
||||
const showTyping = isSending && !hasAnswer && !traceText && !activeTrace && !outgoingTrace;
|
||||
const phase = hasAnswer ? "answer" : traceText || activeTrace || outgoingTrace ? "trace" : "pending";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="streaming-assistant-slot"
|
||||
data-streaming-assistant="true"
|
||||
data-streaming-phase={phase}
|
||||
style={reservedTraceHeight ? { minHeight: `${reservedTraceHeight}px` } : undefined}
|
||||
>
|
||||
{outgoingTrace ? (
|
||||
<div
|
||||
ref={outgoingTraceRef}
|
||||
key={`outgoing-trace-${outgoingTrace.revision}`}
|
||||
className="streaming-assistant-layer ephemeral-thinking-trace ephemeral-thinking-trace-exit"
|
||||
data-thinking-trace="true"
|
||||
data-thinking-trace-state="outgoing"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{outgoingTrace.text}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{activeTrace ? (
|
||||
<div
|
||||
ref={activeTraceRef}
|
||||
key={`active-trace-${activeTrace.revision}`}
|
||||
className="streaming-assistant-layer ephemeral-thinking-trace ephemeral-thinking-trace-enter"
|
||||
data-thinking-trace="true"
|
||||
data-thinking-trace-state="active"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{activeTrace.text}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{activeTrace && !hasAnswer ? (
|
||||
<span className="sr-only" role="status" aria-live="polite">
|
||||
Assistant is working
|
||||
</span>
|
||||
) : null}
|
||||
{hasAnswer ? (
|
||||
<div className="streaming-assistant-layer streaming-assistant-answer" data-streaming-answer="true">
|
||||
<MarkdownContent
|
||||
markdown={message.content}
|
||||
openLinksInNewTab
|
||||
className="leading-[1.82] text-violet-50 [&_a]:text-inherit [&_a]:underline"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{showTyping ? (
|
||||
<span
|
||||
className="streaming-assistant-layer inline-flex items-center gap-1"
|
||||
data-streaming-pending="true"
|
||||
aria-label="Assistant is typing"
|
||||
role="status"
|
||||
>
|
||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
|
||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
|
||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:280ms]" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageContextMenu }: Props) {
|
||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-"));
|
||||
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
||||
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
|
||||
const seenToolCallMessageIDsRef = useRef<Set<string> | null>(null);
|
||||
@@ -457,6 +594,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
||||
}
|
||||
|
||||
const isUser = message.role === "user";
|
||||
const streamingAssistantMetadata = asStreamingAssistantMetadata(message.metadata);
|
||||
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0;
|
||||
const attachments = getMessageAttachments(message.metadata);
|
||||
return (
|
||||
@@ -465,7 +603,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
||||
className={cn(
|
||||
"max-w-[85%] space-y-3",
|
||||
isUser
|
||||
? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
|
||||
? "chat-user-message border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
|
||||
: "text-base leading-7 text-violet-50"
|
||||
)}
|
||||
onContextMenu={
|
||||
@@ -475,7 +613,9 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
||||
}
|
||||
>
|
||||
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
|
||||
{isPendingAssistant ? (
|
||||
{streamingAssistantMetadata ? (
|
||||
<StreamingAssistantPresentation message={message} isSending={isSending} />
|
||||
) : isPendingAssistant ? (
|
||||
<span className="inline-flex items-center gap-1" aria-label="Assistant is typing" role="status">
|
||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
|
||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
|
||||
|
||||
+139
-26
@@ -31,7 +31,7 @@
|
||||
--secondary-foreground: 254 38% 94%;
|
||||
--accent: 188 86% 50%;
|
||||
--accent-foreground: 230 45% 8%;
|
||||
--radius: 0.5rem;
|
||||
--radius: 0px;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -72,6 +72,7 @@ select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color-scheme: dark;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
::selection {
|
||||
@@ -113,13 +114,39 @@ textarea {
|
||||
max(0.75rem, var(--safe-area-left));
|
||||
}
|
||||
|
||||
.chat-settings-header {
|
||||
padding:
|
||||
calc(0.5rem + var(--safe-area-top))
|
||||
max(0.75rem, var(--safe-area-right))
|
||||
0.5rem
|
||||
max(0.75rem, var(--safe-area-left));
|
||||
}
|
||||
|
||||
.chat-settings-content {
|
||||
padding:
|
||||
1rem
|
||||
max(1rem, var(--safe-area-right))
|
||||
max(1.5rem, var(--safe-area-bottom))
|
||||
max(1rem, var(--safe-area-left));
|
||||
scroll-padding-block: 1rem;
|
||||
}
|
||||
|
||||
.chat-settings-error {
|
||||
padding:
|
||||
0.75rem
|
||||
max(1rem, var(--safe-area-right))
|
||||
max(0.75rem, var(--safe-area-bottom))
|
||||
max(1rem, var(--safe-area-left));
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-safe-frame {
|
||||
padding:
|
||||
max(0.5rem, var(--safe-area-top))
|
||||
max(0.5rem, var(--safe-area-right))
|
||||
max(0.5rem, var(--safe-area-bottom))
|
||||
max(0.5rem, var(--safe-area-left));
|
||||
.chat-settings-header,
|
||||
.chat-settings-content {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.chat-settings-error {
|
||||
padding: 0.75rem 1.25rem;
|
||||
}
|
||||
|
||||
.app-search-safe-pad {
|
||||
@@ -132,19 +159,11 @@ textarea {
|
||||
}
|
||||
|
||||
@media (horizontal-viewport-segments: 2) {
|
||||
.app-safe-frame {
|
||||
padding:
|
||||
max(0.5rem, var(--safe-area-top))
|
||||
max(0.5rem, var(--safe-area-right))
|
||||
max(0.5rem, var(--safe-area-bottom))
|
||||
max(0.5rem, var(--safe-area-left));
|
||||
}
|
||||
|
||||
.workspace-shell {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
calc(env(viewport-segment-width 0 0) - max(0.5rem, var(--safe-area-left)))
|
||||
calc(env(viewport-segment-width 1 0) - max(0.5rem, var(--safe-area-right)));
|
||||
calc(env(viewport-segment-width 0 0) - var(--safe-area-left))
|
||||
calc(env(viewport-segment-width 1 0) - var(--safe-area-right));
|
||||
column-gap: calc(env(viewport-segment-left 1 0) - env(viewport-segment-right 0 0));
|
||||
}
|
||||
|
||||
@@ -153,14 +172,10 @@ textarea {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
transform: none;
|
||||
border-width: 1px;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.workspace-content {
|
||||
min-width: 0;
|
||||
border-width: 1px;
|
||||
border-radius: 1rem;
|
||||
touch-action: auto;
|
||||
}
|
||||
|
||||
@@ -179,6 +194,13 @@ textarea {
|
||||
0 14px 36px hsl(240 80% 2% / 0.28);
|
||||
}
|
||||
|
||||
.chat-user-message {
|
||||
/* Preserve the original bubble radius independently of the square UI theme.
|
||||
Browsers without corner-shape support fall back to circular corners. */
|
||||
border-radius: 0.75rem;
|
||||
corner-shape: squircle;
|
||||
}
|
||||
|
||||
.tool-call-stack-shell {
|
||||
perspective: 900px;
|
||||
transform-style: preserve-3d;
|
||||
@@ -321,6 +343,97 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
.streaming-assistant-slot {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 1.82em;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.streaming-assistant-layer {
|
||||
grid-area: 1 / 1;
|
||||
min-width: 0;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.ephemeral-thinking-trace {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: hsl(252 35% 88% / 0.8);
|
||||
font-style: italic;
|
||||
opacity: 0.78;
|
||||
filter: saturate(0.72);
|
||||
will-change: opacity, transform, filter;
|
||||
}
|
||||
|
||||
.ephemeral-thinking-trace-enter {
|
||||
animation: ephemeral-thinking-trace-enter 200ms cubic-bezier(0.2, 0.78, 0.24, 1) both;
|
||||
}
|
||||
|
||||
.ephemeral-thinking-trace-exit {
|
||||
pointer-events: none;
|
||||
animation: ephemeral-thinking-trace-exit 220ms cubic-bezier(0.4, 0, 0.8, 0.28) both;
|
||||
}
|
||||
|
||||
.streaming-assistant-answer {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
animation: streaming-assistant-answer-enter 180ms cubic-bezier(0.2, 0.78, 0.24, 1) both;
|
||||
}
|
||||
|
||||
@keyframes ephemeral-thinking-trace-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: saturate(0.72) blur(1px);
|
||||
transform: translate3d(0, 0.32rem, 0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0.78;
|
||||
filter: saturate(0.72) blur(0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ephemeral-thinking-trace-exit {
|
||||
from {
|
||||
opacity: 0.78;
|
||||
filter: saturate(0.72) blur(0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
filter: saturate(0.72) blur(1px);
|
||||
transform: translate3d(0, -0.32rem, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes streaming-assistant-answer-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 0.18rem, 0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ephemeral-thinking-trace-enter,
|
||||
.ephemeral-thinking-trace-exit,
|
||||
.streaming-assistant-answer {
|
||||
animation: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.ephemeral-thinking-trace-exit {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.md-content {
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -339,7 +452,7 @@ textarea {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
border: 1px solid hsl(var(--border) / 0.86);
|
||||
border-radius: 0.625rem;
|
||||
border-radius: var(--radius);
|
||||
background: hsl(246 34% 10% / 0.76);
|
||||
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.06);
|
||||
}
|
||||
@@ -358,7 +471,7 @@ textarea {
|
||||
}
|
||||
|
||||
.md-table-scroll::-webkit-scrollbar-thumb {
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius);
|
||||
background: hsl(263 78% 72% / 0.34);
|
||||
}
|
||||
|
||||
@@ -470,7 +583,7 @@ textarea {
|
||||
|
||||
.md-content code {
|
||||
background: hsl(249 40% 10% / 0.78);
|
||||
border-radius: 0.3rem;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.05rem 0.3rem;
|
||||
font-size: 0.86em;
|
||||
box-decoration-break: clone;
|
||||
@@ -480,7 +593,7 @@ textarea {
|
||||
.md-content pre {
|
||||
overflow-x: auto;
|
||||
border: 1px solid hsl(253 31% 29% / 0.72);
|
||||
border-radius: 0.625rem;
|
||||
border-radius: var(--radius);
|
||||
background: hsl(249 40% 10% / 0.82);
|
||||
padding: 0.75rem;
|
||||
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.05);
|
||||
@@ -504,7 +617,7 @@ textarea {
|
||||
.md-cite-token {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid hsl(274 40% 55%);
|
||||
background: hsl(277 33% 21%);
|
||||
color: hsl(304 42% 96%);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { Message, ToolCallEvent } from "@/lib/api";
|
||||
|
||||
export const STREAMING_ASSISTANT_METADATA_KIND = "streaming_assistant";
|
||||
|
||||
export type StreamingAssistantMetadata = {
|
||||
kind: typeof STREAMING_ASSISTANT_METADATA_KIND;
|
||||
ephemeralTrace: string | null;
|
||||
traceRevision: number;
|
||||
};
|
||||
|
||||
export type StreamingAttemptState = {
|
||||
segmentText: string;
|
||||
initiatedToolCallIds: string[];
|
||||
};
|
||||
|
||||
export type StreamingToolCallTransition = {
|
||||
attempt: StreamingAttemptState;
|
||||
startedNewToolCall: boolean;
|
||||
traceText: string | null;
|
||||
};
|
||||
|
||||
export function createStreamingAssistantMetadata(): StreamingAssistantMetadata {
|
||||
return {
|
||||
kind: STREAMING_ASSISTANT_METADATA_KIND,
|
||||
ephemeralTrace: null,
|
||||
traceRevision: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function asStreamingAssistantMetadata(value: unknown): StreamingAssistantMetadata | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.kind !== STREAMING_ASSISTANT_METADATA_KIND) return null;
|
||||
|
||||
return {
|
||||
kind: STREAMING_ASSISTANT_METADATA_KIND,
|
||||
ephemeralTrace: typeof record.ephemeralTrace === "string" ? record.ephemeralTrace : null,
|
||||
traceRevision:
|
||||
typeof record.traceRevision === "number" && Number.isFinite(record.traceRevision)
|
||||
? Math.max(0, Math.floor(record.traceRevision))
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createStreamingAttemptState(): StreamingAttemptState {
|
||||
return {
|
||||
segmentText: "",
|
||||
initiatedToolCallIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function appendStreamingDelta(attempt: StreamingAttemptState, delta: string): StreamingAttemptState {
|
||||
if (!delta) return attempt;
|
||||
return {
|
||||
...attempt,
|
||||
segmentText: attempt.segmentText + delta,
|
||||
};
|
||||
}
|
||||
|
||||
export function transitionStreamingToolCall(
|
||||
attempt: StreamingAttemptState,
|
||||
event: Pick<ToolCallEvent, "toolCallId" | "status">
|
||||
): StreamingToolCallTransition {
|
||||
if (event.status !== "initiated" || attempt.initiatedToolCallIds.includes(event.toolCallId)) {
|
||||
return { attempt, startedNewToolCall: false, traceText: null };
|
||||
}
|
||||
|
||||
const traceText = attempt.segmentText.trim();
|
||||
return {
|
||||
attempt: {
|
||||
segmentText: "",
|
||||
initiatedToolCallIds: attempt.initiatedToolCallIds.concat(event.toolCallId),
|
||||
},
|
||||
startedNewToolCall: true,
|
||||
traceText: traceText || null,
|
||||
};
|
||||
}
|
||||
|
||||
function updateStreamingAssistant(
|
||||
messages: Message[],
|
||||
assistantMessagePrefix: string,
|
||||
update: (message: Message, metadata: StreamingAssistantMetadata) => Message
|
||||
) {
|
||||
let didUpdate = false;
|
||||
const nextMessages = messages.map((message, index, all) => {
|
||||
const isTarget = index === all.length - 1 && message.id.startsWith(assistantMessagePrefix);
|
||||
if (!isTarget) return message;
|
||||
didUpdate = true;
|
||||
return update(message, asStreamingAssistantMetadata(message.metadata) ?? createStreamingAssistantMetadata());
|
||||
});
|
||||
return didUpdate ? nextMessages : messages;
|
||||
}
|
||||
|
||||
export function setStreamingAssistantSegment(messages: Message[], assistantMessagePrefix: string, segmentText: string) {
|
||||
return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({
|
||||
...message,
|
||||
content: segmentText,
|
||||
metadata: {
|
||||
...metadata,
|
||||
ephemeralTrace: null,
|
||||
} satisfies StreamingAssistantMetadata,
|
||||
}));
|
||||
}
|
||||
|
||||
export function promoteStreamingAssistantTrace(messages: Message[], assistantMessagePrefix: string, traceText: string) {
|
||||
const normalizedTrace = traceText.trim();
|
||||
return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({
|
||||
...message,
|
||||
content: "",
|
||||
metadata: normalizedTrace
|
||||
? ({
|
||||
...metadata,
|
||||
ephemeralTrace: normalizedTrace,
|
||||
traceRevision: metadata.traceRevision + 1,
|
||||
} satisfies StreamingAssistantMetadata)
|
||||
: metadata,
|
||||
}));
|
||||
}
|
||||
|
||||
export function finalizeStreamingAssistant(messages: Message[], assistantMessagePrefix: string, finalText: string) {
|
||||
return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({
|
||||
...message,
|
||||
content: finalText,
|
||||
metadata: {
|
||||
...metadata,
|
||||
ephemeralTrace: null,
|
||||
} satisfies StreamingAssistantMetadata,
|
||||
}));
|
||||
}
|
||||
|
||||
export function clearStreamingAssistantTrace(messages: Message[], assistantMessagePrefix: string) {
|
||||
return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({
|
||||
...message,
|
||||
metadata: {
|
||||
...metadata,
|
||||
ephemeralTrace: null,
|
||||
} satisfies StreamingAssistantMetadata,
|
||||
}));
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { Provider } from "./api";
|
||||
|
||||
export const QUICK_QUESTION_DEFAULT_MODEL = "chat-latest";
|
||||
|
||||
export function pickQuickQuestionModel(provider: Provider, options: string[], preferred: string | null = null) {
|
||||
if (preferred?.trim()) return preferred.trim();
|
||||
if (provider === "openai") return QUICK_QUESTION_DEFAULT_MODEL;
|
||||
return options[0] ?? "";
|
||||
}
|
||||
|
||||
export function buildQuickQuestionRequest({
|
||||
provider,
|
||||
model,
|
||||
|
||||
@@ -29,9 +29,14 @@ const config: Config = {
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
// Keep surfaces square at every size; rounded-full is reserved for dots.
|
||||
DEFAULT: "var(--radius)",
|
||||
sm: "var(--radius)",
|
||||
md: "var(--radius)",
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
xl: "var(--radius)",
|
||||
"2xl": "var(--radius)",
|
||||
"3xl": "var(--radius)",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
appendStreamingDelta,
|
||||
asStreamingAssistantMetadata,
|
||||
clearStreamingAssistantTrace,
|
||||
createStreamingAssistantMetadata,
|
||||
createStreamingAttemptState,
|
||||
finalizeStreamingAssistant,
|
||||
promoteStreamingAssistantTrace,
|
||||
setStreamingAssistantSegment,
|
||||
transitionStreamingToolCall,
|
||||
} from "../src/lib/chat-stream-presentation.ts";
|
||||
|
||||
function assistantMessage(content = "") {
|
||||
return {
|
||||
id: "temp-assistant-test",
|
||||
createdAt: "2026-08-30T00:00:00.000Z",
|
||||
role: "assistant",
|
||||
content,
|
||||
name: null,
|
||||
metadata: createStreamingAssistantMetadata(),
|
||||
};
|
||||
}
|
||||
|
||||
function metadata(messages) {
|
||||
return asStreamingAssistantMetadata(messages.at(-1)?.metadata);
|
||||
}
|
||||
|
||||
test("a tool boundary promotes the current segment and the next delta starts the answer in its place", () => {
|
||||
let messages = [assistantMessage()];
|
||||
let attempt = appendStreamingDelta(createStreamingAttemptState(), "I'll search for that.");
|
||||
messages = setStreamingAssistantSegment(messages, "temp-assistant-", attempt.segmentText);
|
||||
|
||||
const toolTransition = transitionStreamingToolCall(attempt, {
|
||||
toolCallId: "search-1",
|
||||
status: "initiated",
|
||||
});
|
||||
attempt = toolTransition.attempt;
|
||||
messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", toolTransition.traceText ?? "");
|
||||
|
||||
assert.equal(messages.at(-1).content, "");
|
||||
assert.deepEqual(metadata(messages), {
|
||||
kind: "streaming_assistant",
|
||||
ephemeralTrace: "I'll search for that.",
|
||||
traceRevision: 1,
|
||||
});
|
||||
|
||||
attempt = appendStreamingDelta(attempt, "Here is the answer");
|
||||
messages = setStreamingAssistantSegment(messages, "temp-assistant-", attempt.segmentText);
|
||||
|
||||
assert.equal(messages.at(-1).content, "Here is the answer");
|
||||
assert.equal(metadata(messages)?.ephemeralTrace, null);
|
||||
|
||||
messages = finalizeStreamingAssistant(messages, "temp-assistant-", "Here is the answer.");
|
||||
assert.equal(messages.at(-1).content, "Here is the answer.");
|
||||
assert.equal(metadata(messages)?.ephemeralTrace, null);
|
||||
});
|
||||
|
||||
test("a later tool round replaces the previous trace and parallel initiated calls do not erase it", () => {
|
||||
let messages = [assistantMessage("First trace")];
|
||||
let attempt = appendStreamingDelta(createStreamingAttemptState(), "First trace");
|
||||
let transition = transitionStreamingToolCall(attempt, { toolCallId: "tool-1", status: "initiated" });
|
||||
attempt = transition.attempt;
|
||||
messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", transition.traceText ?? "");
|
||||
|
||||
attempt = appendStreamingDelta(attempt, "I need one more lookup.");
|
||||
messages = setStreamingAssistantSegment(messages, "temp-assistant-", attempt.segmentText);
|
||||
transition = transitionStreamingToolCall(attempt, { toolCallId: "tool-2", status: "initiated" });
|
||||
attempt = transition.attempt;
|
||||
messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", transition.traceText ?? "");
|
||||
|
||||
assert.equal(metadata(messages)?.ephemeralTrace, "I need one more lookup.");
|
||||
assert.equal(metadata(messages)?.traceRevision, 2);
|
||||
|
||||
transition = transitionStreamingToolCall(attempt, { toolCallId: "tool-3", status: "initiated" });
|
||||
assert.equal(transition.startedNewToolCall, true);
|
||||
assert.equal(transition.traceText, null);
|
||||
messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", transition.traceText ?? "");
|
||||
|
||||
assert.equal(metadata(messages)?.ephemeralTrace, "I need one more lookup.");
|
||||
assert.equal(metadata(messages)?.traceRevision, 2);
|
||||
});
|
||||
|
||||
test("terminal and duplicate tool events never promote or reset the in-progress segment", () => {
|
||||
const initial = appendStreamingDelta(createStreamingAttemptState(), "candidate");
|
||||
const completed = transitionStreamingToolCall(initial, { toolCallId: "tool-1", status: "completed" });
|
||||
|
||||
assert.equal(completed.startedNewToolCall, false);
|
||||
assert.strictEqual(completed.attempt, initial);
|
||||
|
||||
const initiated = transitionStreamingToolCall(initial, { toolCallId: "tool-1", status: "initiated" });
|
||||
const withNextSegment = appendStreamingDelta(initiated.attempt, "next segment");
|
||||
const duplicate = transitionStreamingToolCall(withNextSegment, { toolCallId: "tool-1", status: "initiated" });
|
||||
|
||||
assert.equal(duplicate.startedNewToolCall, false);
|
||||
assert.strictEqual(duplicate.attempt, withNextSegment);
|
||||
assert.equal(duplicate.attempt.segmentText, "next segment");
|
||||
});
|
||||
|
||||
test("a fresh per-attempt accumulator can replay and reclassify an already-seen tool round", () => {
|
||||
let messages = [assistantMessage("stale partial answer")];
|
||||
let replayAttempt = appendStreamingDelta(createStreamingAttemptState(), "Replayed trace");
|
||||
messages = setStreamingAssistantSegment(messages, "temp-assistant-", replayAttempt.segmentText);
|
||||
|
||||
const replayedTool = transitionStreamingToolCall(replayAttempt, { toolCallId: "same-tool-id", status: "initiated" });
|
||||
messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", replayedTool.traceText ?? "");
|
||||
|
||||
assert.equal(replayedTool.startedNewToolCall, true);
|
||||
assert.equal(messages.at(-1).content, "");
|
||||
assert.equal(metadata(messages)?.ephemeralTrace, "Replayed trace");
|
||||
});
|
||||
|
||||
test("message helpers preserve identity when no matching temporary assistant exists", () => {
|
||||
const messages = [
|
||||
{
|
||||
id: "persisted-assistant",
|
||||
createdAt: "2026-08-30T00:00:00.000Z",
|
||||
role: "assistant",
|
||||
content: "Persisted",
|
||||
name: null,
|
||||
metadata: null,
|
||||
},
|
||||
];
|
||||
|
||||
assert.strictEqual(setStreamingAssistantSegment(messages, "temp-assistant-", "new"), messages);
|
||||
assert.strictEqual(promoteStreamingAssistantTrace(messages, "temp-assistant-", "trace"), messages);
|
||||
assert.strictEqual(finalizeStreamingAssistant(messages, "temp-assistant-", "done"), messages);
|
||||
assert.strictEqual(clearStreamingAssistantTrace(messages, "temp-assistant-"), messages);
|
||||
});
|
||||
|
||||
test("a terminal error clears the ephemeral trace without discarding a partial answer", () => {
|
||||
let messages = [
|
||||
{
|
||||
...assistantMessage("Partial answer"),
|
||||
metadata: {
|
||||
...createStreamingAssistantMetadata(),
|
||||
ephemeralTrace: "Still checking one source.",
|
||||
traceRevision: 1,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
messages = clearStreamingAssistantTrace(messages, "temp-assistant-");
|
||||
|
||||
assert.equal(messages.at(-1).content, "Partial answer");
|
||||
assert.equal(metadata(messages)?.ephemeralTrace, null);
|
||||
});
|
||||
@@ -1,6 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildQuickQuestionRequest } from "../src/lib/quick-question.ts";
|
||||
import { buildQuickQuestionRequest, pickQuickQuestionModel } from "../src/lib/quick-question.ts";
|
||||
|
||||
test("quick questions default to chat-latest before and after the model catalog loads", () => {
|
||||
for (const options of [[], ["gpt-4.1-mini"], ["gpt-4.1-mini", "chat-latest"]]) {
|
||||
const model = pickQuickQuestionModel("openai", options);
|
||||
assert.equal(model, "chat-latest");
|
||||
assert.deepEqual(buildQuickQuestionRequest({ provider: "openai", model, content: "What is UTC?" }), {
|
||||
provider: "openai",
|
||||
model: "chat-latest",
|
||||
question: "What is UTC?",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("quick questions preserve saved models and use provider-specific choices when switching providers", () => {
|
||||
assert.equal(pickQuickQuestionModel("openai", ["chat-latest"], " gpt-4.1-mini "), "gpt-4.1-mini");
|
||||
assert.equal(pickQuickQuestionModel("openai", ["gpt-4.1-mini"], " "), "chat-latest");
|
||||
assert.equal(pickQuickQuestionModel("anthropic", ["claude-3-5-sonnet-latest"]), "claude-3-5-sonnet-latest");
|
||||
assert.equal(pickQuickQuestionModel("anthropic", ["claude-3-5-sonnet-latest"], "claude-3-haiku"), "claude-3-haiku");
|
||||
assert.equal(pickQuickQuestionModel("anthropic", []), "");
|
||||
assert.equal(pickQuickQuestionModel("openai", ["gpt-4.1-mini", "chat-latest"]), "chat-latest");
|
||||
});
|
||||
|
||||
test("quick question requests use the dedicated server endpoint shape", () => {
|
||||
const request = buildQuickQuestionRequest({
|
||||
|
||||
Reference in New Issue
Block a user