Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd6cd1ea7b | ||
|
|
3f9c1409fe | ||
|
|
411a8cebc7 | ||
|
|
14191de8f4 | ||
|
|
4ef48fad82 | ||
|
|
21beab26f1 | ||
|
|
050a46d7a8 | ||
|
|
3217c3f66e | ||
|
|
3a9c9bb74a | ||
|
|
04ac39499f | ||
|
|
26d917e96a | ||
|
|
922172fc60 | ||
|
|
5f9fc82b86 |
+13
-2
@@ -31,7 +31,7 @@ Chat upload limits:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"providers": {
|
"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 },
|
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
|
||||||
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
|
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
|
||||||
"gemini": { "models": ["gemini-3.5-flash"], "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.
|
- 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.
|
- `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.
|
- 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.
|
||||||
@@ -81,6 +81,8 @@ Behavior notes:
|
|||||||
|
|
||||||
### `GET /v1/workspace-items`
|
### `GET /v1/workspace-items`
|
||||||
- Response: `{ "items": WorkspaceItem[] }`
|
- Response: `{ "items": WorkspaceItem[] }`
|
||||||
|
- Optional query: `q` (trimmed string, up to 200 characters). Omit or leave blank for the recent-items list.
|
||||||
|
- With nonblank `q`, response: `{ "items": WorkspaceItem[], "hasMore": boolean }`; each item also has `searchSnippet: string | null` (plain text, no HTML).
|
||||||
- `WorkspaceItem` is a discriminated union sorted by `updatedAt` descending:
|
- `WorkspaceItem` is a discriminated union sorted by `updatedAt` descending:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -123,6 +125,15 @@ Behavior notes:
|
|||||||
- Root chats have `parentChatId: null`. Every fork points directly to its single root chat, including a fork created from another fork, so clients can group rows without traversing a fork chain.
|
- Root chats have `parentChatId: null`. Every fork points directly to its single root chat, including a fork created from another fork, so clients can group rows without traversing a fork chain.
|
||||||
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
|
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
|
||||||
|
|
||||||
|
History search (`q`) notes:
|
||||||
|
- Searches all saved history, including items outside the recent-items window. Chats match their title or the text of a user/assistant message; saved web searches match their title, query, or generated answer. System prompts, tool logs, attachments, and raw web-result payloads are excluded.
|
||||||
|
- Case- and diacritic-insensitive Unicode word-prefix matching: `plan` matches `planning`. All query words must match within the title, a single message, or one saved web search (across its indexed fields). Punctuation separates words; quotes, `*`, and operators have no special query syntax. Punctuation-only queries return no matches. This is word-prefix search, not arbitrary substring matching.
|
||||||
|
- Returns at most 100 distinct matching items, newest `updatedAt` first, with ties ordered by `type` then `id`. `hasMore` indicates additional matches; refine `q` to narrow them. Unmatched fork roots are not added in search mode. Selecting a fork result opens that fork directly. Search-mode fork results carry their root's `starred`/`starredAt`; clients should star/unstar the `parentChatId`, as in the grouped sidebar.
|
||||||
|
- `searchSnippet` is a bounded excerpt from a matching message or saved web search; title-only chat matches have `null`. Clients should render it as plain text.
|
||||||
|
- The web and iOS sidebar search fields use this endpoint across saved history. Both display matching excerpts; web title-only chat results display “Title match”.
|
||||||
|
- Invalid/non-string queries or queries exceeding 200 characters return `400`. Authentication is unchanged.
|
||||||
|
- SQLite FTS5 external-content indexes and prefix indexes serve these queries without scanning or transferring every transcript. The migration backfills existing content; transactional triggers maintain the indexes on inserts, updates, deletes, and cascades. Recent chat listing also has an `updatedAt` index.
|
||||||
|
|
||||||
## Chats
|
## Chats
|
||||||
|
|
||||||
### `GET /v1/chats`
|
### `GET /v1/chats`
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
# Streaming Chat API Contract
|
# Streaming Chat API Contract
|
||||||
|
|
||||||
This document defines the server-sent events (SSE) contract for chat completions.
|
This document defines the server-sent events (SSE) contract for chat completions and Quick Questions.
|
||||||
|
|
||||||
Endpoint:
|
Endpoint:
|
||||||
- `POST /v1/chat-completions/stream`
|
- `POST /v1/chat-completions/stream`
|
||||||
- `POST /v1/chats/:chatId/stream/attach`
|
- `POST /v1/chats/:chatId/stream/attach`
|
||||||
|
- `POST /v1/quick-questions/stream`
|
||||||
|
|
||||||
Transport:
|
Transport:
|
||||||
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
|
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
|
||||||
- Events are emitted in SSE format (`event: ...`, `data: ...`)
|
- Events are emitted in SSE format (`event: ...`, `data: ...`)
|
||||||
- Request body is JSON
|
- Request body is JSON
|
||||||
- Request body supports the same inline attachment schema and limits documented in `docs/api/rest.md`.
|
- Chat completion request bodies support the same inline attachment schema and limits documented in `docs/api/rest.md`. Quick Questions accept text only.
|
||||||
|
|
||||||
Authentication:
|
Authentication:
|
||||||
- Same as REST endpoints (`Authorization: Bearer <token>` when token mode is enabled)
|
- Same as REST endpoints (`Authorization: Bearer <token>` when token mode is enabled)
|
||||||
|
|
||||||
## Request Body
|
## Chat Completion Request Body
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -90,6 +91,32 @@ Persisted chat streams with a `chatId` are backend-owned active runs:
|
|||||||
|
|
||||||
This endpoint is intended for clients that restored an active `chatId` from `GET /v1/active-runs`, especially after browser refresh. Replayed `delta` events may include text that was originally emitted before the client attached.
|
This endpoint is intended for clients that restored an active `chatId` from `GET /v1/active-runs`, especially after browser refresh. Replayed `delta` events may include text that was originally emitted before the client attached.
|
||||||
|
|
||||||
|
## Quick Question Endpoint
|
||||||
|
|
||||||
|
`POST /v1/quick-questions/stream`
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
||||||
|
"model": "string",
|
||||||
|
"question": "What is the capital of France?",
|
||||||
|
"enabledTools": ["web_search", "fetch_url"],
|
||||||
|
"userLocation": "optional city, region, country",
|
||||||
|
"temperature": 0.2,
|
||||||
|
"maxTokens": 256
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
- The response uses the same `meta`, `tool_call`, `delta`, `done`, and `error` SSE events as chat completion streams. The `meta` event has `chatId: null` and `callId: null`.
|
||||||
|
- `enabledTools`, `temperature`, and `maxTokens` are optional and behave as they do for chat completion streams. When `enabledTools` is omitted, all available Sybil-managed tools are enabled by default.
|
||||||
|
- User location is inferred from the same request headers as chat completion streams when `userLocation` is omitted.
|
||||||
|
|
||||||
## Event Stream Contract
|
## Event Stream Contract
|
||||||
|
|
||||||
Event order:
|
Event order:
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<!-- macOS-only: keep the global Quick Question shortcut available with no windows. -->
|
||||||
|
<key>NSSupportsAutomaticTermination</key>
|
||||||
|
<false/>
|
||||||
<key>UIApplicationShortcutItems</key>
|
<key>UIApplicationShortcutItems</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
@@ -8,7 +8,7 @@ struct SybilApp: App
|
|||||||
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
|
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup(id: SybilCommands.workspaceWindowID) {
|
||||||
SplitView()
|
SplitView()
|
||||||
}
|
}
|
||||||
.commands {
|
.commands {
|
||||||
@@ -24,9 +24,18 @@ final class SybilAppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
SybilMacQuickQuestionController.shared.start()
|
||||||
|
#endif
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
func applicationWillTerminate(_ application: UIApplication) {
|
||||||
|
SybilMacQuickQuestionController.shared.stop()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
func application(
|
func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||||
@@ -56,11 +65,22 @@ final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
|
|||||||
willConnectTo session: UISceneSession,
|
willConnectTo session: UISceneSession,
|
||||||
options connectionOptions: UIScene.ConnectionOptions
|
options connectionOptions: UIScene.ConnectionOptions
|
||||||
) {
|
) {
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
if scene is UIWindowScene, session.role == .windowApplication {
|
||||||
|
SybilMacWindowCommands.shared.windowConnected(sessionID: session.persistentIdentifier)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
if let shortcutItem = connectionOptions.shortcutItem {
|
if let shortcutItem = connectionOptions.shortcutItem {
|
||||||
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
|
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
func sceneDidDisconnect(_ scene: UIScene) {
|
||||||
|
SybilMacWindowCommands.shared.windowDisconnected(sessionID: scene.session.persistentIdentifier)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
func windowScene(
|
func windowScene(
|
||||||
_ windowScene: UIWindowScene,
|
_ windowScene: UIWindowScene,
|
||||||
performActionFor shortcutItem: UIApplicationShortcutItem,
|
performActionFor shortcutItem: UIApplicationShortcutItem,
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ targets:
|
|||||||
dependencies:
|
dependencies:
|
||||||
- package: Sybil
|
- package: Sybil
|
||||||
product: Sybil
|
product: Sybil
|
||||||
|
- target: SybilMacQuickQuestion
|
||||||
|
destinationFilters: [macCatalyst]
|
||||||
|
embed: true
|
||||||
|
link: false
|
||||||
|
codeSign: true
|
||||||
|
copy:
|
||||||
|
destination: plugins
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
|
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
|
||||||
@@ -29,6 +36,7 @@ targets:
|
|||||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||||
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=macosx*]": YES
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import AppKit
|
||||||
|
import Carbon
|
||||||
|
|
||||||
|
// AppKit is unavailable to Catalyst targets. This native bundle uses only
|
||||||
|
// public AppKit/Carbon APIs and communicates with the client through Foundation.
|
||||||
|
@MainActor
|
||||||
|
@objc(SybilMacQuickQuestionPlugin)
|
||||||
|
final class MacQuickQuestionPlugin: NSObject, SybilQuickQuestionPanelBridging, NSWindowDelegate {
|
||||||
|
private weak var delegate: (any SybilQuickQuestionPanelDelegate)?
|
||||||
|
private var panel: QuickQuestionPanel?
|
||||||
|
private var panelView: QuickQuestionPanelView?
|
||||||
|
private var hotKey: EventHotKeyRef?
|
||||||
|
private var eventHandler: EventHandlerRef?
|
||||||
|
private var outsideClickMonitor: Any?
|
||||||
|
private var hotKeyState = SybilQuickQuestionHotKeyState()
|
||||||
|
private var isDismissing = false
|
||||||
|
private var shortcutAvailable = false
|
||||||
|
private var windowlessActivity: NSObjectProtocol?
|
||||||
|
private var latestState = SybilQuickQuestionPanelState()
|
||||||
|
private var previousApplication: NSRunningApplication?
|
||||||
|
|
||||||
|
required override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
var isVisible: Bool { panel?.isVisible == true }
|
||||||
|
|
||||||
|
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32 {
|
||||||
|
self.delegate = delegate
|
||||||
|
if windowlessActivity == nil {
|
||||||
|
// Keep the hotkey service alive with no windows. This option does
|
||||||
|
// not prevent sleep/App Nap or intercept an explicit Quit command.
|
||||||
|
windowlessActivity = ProcessInfo.processInfo.beginActivity(
|
||||||
|
options: .automaticTerminationDisabled,
|
||||||
|
reason: "Quick Question global shortcut"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard eventHandler == nil else { return noErr }
|
||||||
|
var eventTypes = [
|
||||||
|
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
|
||||||
|
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased))
|
||||||
|
]
|
||||||
|
let context = Unmanaged.passUnretained(self).toOpaque()
|
||||||
|
let handlerStatus = InstallEventHandler(
|
||||||
|
GetApplicationEventTarget(),
|
||||||
|
{ _, event, context in
|
||||||
|
guard let event, let context else { return OSStatus(eventNotHandledErr) }
|
||||||
|
var identifier = EventHotKeyID()
|
||||||
|
let status = GetEventParameter(
|
||||||
|
event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID),
|
||||||
|
nil, MemoryLayout<EventHotKeyID>.size, nil, &identifier
|
||||||
|
)
|
||||||
|
guard status == noErr, identifier.signature == 0x53595151, identifier.id == 1 else {
|
||||||
|
return OSStatus(eventNotHandledErr)
|
||||||
|
}
|
||||||
|
let isKeyDown = GetEventKind(event) == UInt32(kEventHotKeyPressed)
|
||||||
|
let plugin = Unmanaged<MacQuickQuestionPlugin>.fromOpaque(context).takeUnretainedValue()
|
||||||
|
// Application event handlers run on the main event loop.
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
if plugin.hotKeyState.shouldToggle(isKeyDown: isKeyDown) {
|
||||||
|
plugin.delegate?.quickQuestionToggleRequested()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return noErr
|
||||||
|
},
|
||||||
|
eventTypes.count, &eventTypes, context, &eventHandler
|
||||||
|
)
|
||||||
|
guard handlerStatus == noErr else { return handlerStatus }
|
||||||
|
let status = RegisterEventHotKey(
|
||||||
|
UInt32(kVK_Space), UInt32(optionKey),
|
||||||
|
EventHotKeyID(signature: 0x53595151, id: 1),
|
||||||
|
GetApplicationEventTarget(), 0, &hotKey
|
||||||
|
)
|
||||||
|
shortcutAvailable = status == noErr
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateState(_ data: Data) {
|
||||||
|
guard let state = try? JSONDecoder().decode(SybilQuickQuestionPanelState.self, from: data) else { return }
|
||||||
|
// Updating a background request must not create a non-hosting window.
|
||||||
|
// The controller creates/orders the panel only after UIKit is foreground.
|
||||||
|
latestState = state
|
||||||
|
panelView?.update(state, shortcutAvailable: shortcutAvailable)
|
||||||
|
if isVisible { resizePanel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func show() {
|
||||||
|
createPanelIfNeeded()
|
||||||
|
guard let panel, let panelView else { return }
|
||||||
|
panelView.update(latestState, shortcutAvailable: shortcutAvailable)
|
||||||
|
let screen = NSScreen.screens.first { $0.frame.contains(NSEvent.mouseLocation) } ?? NSScreen.main
|
||||||
|
let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1280, height: 800)
|
||||||
|
let width = min(680, visibleFrame.width - 48)
|
||||||
|
panel.setContentSize(NSSize(width: width, height: 240))
|
||||||
|
panelView.layoutSubtreeIfNeeded()
|
||||||
|
let height = min(panelView.preferredHeight, visibleFrame.height - 64)
|
||||||
|
let top = visibleFrame.maxY - min(visibleFrame.height * 0.18, 160)
|
||||||
|
let y = max(visibleFrame.minY + 24, top - height)
|
||||||
|
panel.setFrame(NSRect(x: visibleFrame.midX - width / 2, y: y, width: width, height: height), display: true)
|
||||||
|
// The controller has verified UIKit foreground readiness. A
|
||||||
|
// nonactivating panel can then take focus without raising a workspace.
|
||||||
|
panel.makeKeyAndOrderFront(nil)
|
||||||
|
panel.makeFirstResponder(panelView.promptView)
|
||||||
|
panelView.preparePromptForPresentation()
|
||||||
|
installOutsideClickMonitor()
|
||||||
|
}
|
||||||
|
|
||||||
|
func hide() {
|
||||||
|
dismissPanel(restoringPreviousApplication: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func activateForQuickQuestion() {
|
||||||
|
if let frontmost = NSWorkspace.shared.frontmostApplication,
|
||||||
|
frontmost != NSRunningApplication.current {
|
||||||
|
previousApplication = frontmost
|
||||||
|
}
|
||||||
|
// Do not order a window here. Catalyst must finish its own foreground
|
||||||
|
// transition before the controller calls show().
|
||||||
|
NSApp.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func activateMainWindow() {
|
||||||
|
// An explicit Open in Chat/Open Sybil action keeps focus in Sybil.
|
||||||
|
previousApplication = nil
|
||||||
|
NSApp.activate()
|
||||||
|
NSApp.windows.first { $0 !== panel && $0.canBecomeMain }?.makeKeyAndOrderFront(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismissPanel(restoringPreviousApplication: Bool) {
|
||||||
|
guard !isDismissing else { return }
|
||||||
|
let wasVisible = isVisible
|
||||||
|
isDismissing = true
|
||||||
|
if wasVisible { panel?.orderOut(nil) }
|
||||||
|
removeOutsideClickMonitor()
|
||||||
|
isDismissing = false
|
||||||
|
let applicationToRestore = previousApplication
|
||||||
|
previousApplication = nil
|
||||||
|
if restoringPreviousApplication, NSApp.isActive,
|
||||||
|
let applicationToRestore, !applicationToRestore.isTerminated {
|
||||||
|
NSApp.yieldActivation(to: applicationToRestore)
|
||||||
|
applicationToRestore.activate(options: [])
|
||||||
|
}
|
||||||
|
if wasVisible { delegate?.quickQuestionPanelDismissed() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
previousApplication = nil
|
||||||
|
hide()
|
||||||
|
removeOutsideClickMonitor()
|
||||||
|
if let hotKey { UnregisterEventHotKey(hotKey) }
|
||||||
|
if let eventHandler { RemoveEventHandler(eventHandler) }
|
||||||
|
hotKey = nil
|
||||||
|
eventHandler = nil
|
||||||
|
hotKeyState = SybilQuickQuestionHotKeyState()
|
||||||
|
if let windowlessActivity {
|
||||||
|
ProcessInfo.processInfo.endActivity(windowlessActivity)
|
||||||
|
}
|
||||||
|
windowlessActivity = nil
|
||||||
|
delegate = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowDidResignKey(_ notification: Notification) {
|
||||||
|
// Focus is already moving to a window/app the user chose.
|
||||||
|
dismissPanel(restoringPreviousApplication: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createPanelIfNeeded() {
|
||||||
|
guard panel == nil else { return }
|
||||||
|
let panel = QuickQuestionPanel(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 680, height: 240),
|
||||||
|
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false
|
||||||
|
)
|
||||||
|
panel.title = "Quick Question"
|
||||||
|
panel.level = .floating
|
||||||
|
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle]
|
||||||
|
panel.isFloatingPanel = true
|
||||||
|
panel.hidesOnDeactivate = false
|
||||||
|
panel.becomesKeyOnlyIfNeeded = false
|
||||||
|
panel.worksWhenModal = true
|
||||||
|
panel.isReleasedWhenClosed = false
|
||||||
|
panel.isOpaque = false
|
||||||
|
panel.backgroundColor = .clear
|
||||||
|
panel.hasShadow = true
|
||||||
|
panel.isMovableByWindowBackground = true
|
||||||
|
panel.appearance = NSAppearance(named: .darkAqua)
|
||||||
|
panel.delegate = self
|
||||||
|
panel.onDismiss = { [weak self] in self?.hide() }
|
||||||
|
let view = QuickQuestionPanelView()
|
||||||
|
view.delegate = delegate
|
||||||
|
view.onDismiss = { [weak self] in self?.hide() }
|
||||||
|
panel.contentView = view
|
||||||
|
panelView = view
|
||||||
|
self.panel = panel
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resizePanel() {
|
||||||
|
guard let panel, let panelView else { return }
|
||||||
|
panelView.layoutSubtreeIfNeeded()
|
||||||
|
let visibleFrame = panel.screen?.visibleFrame ?? panel.frame
|
||||||
|
let height = min(panelView.preferredHeight, visibleFrame.height - 64)
|
||||||
|
guard abs(height - panel.frame.height) > 1 else { return }
|
||||||
|
var frame = panel.frame
|
||||||
|
frame.origin.y = max(visibleFrame.minY + 24, frame.maxY - height)
|
||||||
|
frame.size.height = height
|
||||||
|
panel.setFrame(frame, display: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func installOutsideClickMonitor() {
|
||||||
|
guard outsideClickMonitor == nil else { return }
|
||||||
|
// Mouse-only monitoring does not require Accessibility or Input
|
||||||
|
// Monitoring permission. No global keyboard event tap is installed.
|
||||||
|
outsideClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
|
||||||
|
Task { @MainActor [weak self] in self?.hide() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeOutsideClickMonitor() {
|
||||||
|
if let outsideClickMonitor { NSEvent.removeMonitor(outsideClickMonitor) }
|
||||||
|
outsideClickMonitor = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class QuickQuestionPanel: NSPanel {
|
||||||
|
var onDismiss: (() -> Void)?
|
||||||
|
override var canBecomeKey: Bool { true }
|
||||||
|
override var canBecomeMain: Bool { false }
|
||||||
|
override func cancelOperation(_ sender: Any?) { onDismiss?() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class QuickQuestionPanelView: NSVisualEffectView, NSTextViewDelegate {
|
||||||
|
weak var delegate: (any SybilQuickQuestionPanelDelegate)?
|
||||||
|
var onDismiss: (() -> Void)?
|
||||||
|
|
||||||
|
let promptView = QuickQuestionPromptView()
|
||||||
|
private let stack = NSStackView()
|
||||||
|
private let answerView = NSTextView()
|
||||||
|
private let answerScroll = NSScrollView()
|
||||||
|
private let statusLabel = NSTextField(wrappingLabelWithString: "")
|
||||||
|
private let providerPopup = NSPopUpButton()
|
||||||
|
private let modelPopup = NSPopUpButton()
|
||||||
|
private let convertButton = NSButton(title: "Open in Chat", target: nil, action: nil)
|
||||||
|
private let openAppButton = NSButton(title: "Open Sybil", target: nil, action: nil)
|
||||||
|
private let sendButton = NSButton()
|
||||||
|
private let shortcutLabel = NSTextField(labelWithString: "⌥ Space")
|
||||||
|
private var panelState = SybilQuickQuestionPanelState()
|
||||||
|
|
||||||
|
var preferredHeight: CGFloat { stack.fittingSize.height + 40 }
|
||||||
|
|
||||||
|
override init(frame frameRect: NSRect) {
|
||||||
|
super.init(frame: frameRect)
|
||||||
|
material = .hudWindow
|
||||||
|
blendingMode = .behindWindow
|
||||||
|
state = .active
|
||||||
|
wantsLayer = true
|
||||||
|
layer?.cornerRadius = 18
|
||||||
|
layer?.masksToBounds = true
|
||||||
|
layer?.borderWidth = 1
|
||||||
|
layer?.borderColor = NSColor.white.withAlphaComponent(0.14).cgColor
|
||||||
|
|
||||||
|
stack.orientation = .vertical
|
||||||
|
stack.alignment = .leading
|
||||||
|
stack.spacing = 12
|
||||||
|
stack.detachesHiddenViews = true
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(stack)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
|
||||||
|
stack.topAnchor.constraint(equalTo: topAnchor, constant: 20),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20)
|
||||||
|
])
|
||||||
|
|
||||||
|
addRow(makeHeader())
|
||||||
|
configureTextView(promptView, fontSize: 22)
|
||||||
|
promptView.delegate = self
|
||||||
|
promptView.isRichText = false
|
||||||
|
promptView.allowsUndo = true
|
||||||
|
promptView.setAccessibilityLabel("Quick question prompt")
|
||||||
|
promptView.onSubmit = { [weak self] in self?.submit() }
|
||||||
|
promptView.onToggle = { [weak self] in self?.delegate?.quickQuestionToggleRequested() }
|
||||||
|
let promptScroll = makeScrollView(documentView: promptView)
|
||||||
|
promptScroll.heightAnchor.constraint(equalToConstant: 64).isActive = true
|
||||||
|
addRow(promptScroll)
|
||||||
|
|
||||||
|
configureTextView(answerView, fontSize: 15)
|
||||||
|
answerView.isEditable = false
|
||||||
|
answerView.isSelectable = true
|
||||||
|
answerView.setAccessibilityLabel("Quick question answer")
|
||||||
|
answerView.linkTextAttributes = [.foregroundColor: NSColor.systemPurple, .underlineStyle: NSUnderlineStyle.single.rawValue]
|
||||||
|
answerScroll.drawsBackground = false
|
||||||
|
answerScroll.hasVerticalScroller = true
|
||||||
|
answerScroll.autohidesScrollers = true
|
||||||
|
answerScroll.documentView = answerView
|
||||||
|
answerScroll.heightAnchor.constraint(equalToConstant: 280).isActive = true
|
||||||
|
addRow(answerScroll)
|
||||||
|
|
||||||
|
statusLabel.font = .systemFont(ofSize: 12)
|
||||||
|
statusLabel.textColor = .secondaryLabelColor
|
||||||
|
statusLabel.maximumNumberOfLines = 3
|
||||||
|
addRow(statusLabel)
|
||||||
|
addRow(makeControls())
|
||||||
|
let hint = NSTextField(labelWithString: "Return to ask · Shift+Return for a new line · Esc to close")
|
||||||
|
hint.font = .systemFont(ofSize: 11)
|
||||||
|
hint.textColor = .secondaryLabelColor
|
||||||
|
addRow(hint)
|
||||||
|
update(SybilQuickQuestionPanelState(), shortcutAvailable: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||||
|
|
||||||
|
func update(_ next: SybilQuickQuestionPanelState, shortcutAvailable: Bool) {
|
||||||
|
let previous = panelState
|
||||||
|
panelState = next
|
||||||
|
if promptView.string != next.prompt, !promptView.hasMarkedText() {
|
||||||
|
promptView.string = next.prompt
|
||||||
|
promptView.setSelectedRange(NSRange(location: (next.prompt as NSString).length, length: 0))
|
||||||
|
}
|
||||||
|
promptView.isEditable = !next.isBusy
|
||||||
|
promptView.needsDisplay = true
|
||||||
|
if previous.answer != next.answer {
|
||||||
|
let wasAtBottom = answerScroll.contentView.bounds.maxY >= answerView.bounds.maxY - 36
|
||||||
|
answerView.textStorage?.setAttributedString(Self.renderAnswer(next.answer))
|
||||||
|
if wasAtBottom { answerView.scrollToEndOfDocument(nil) }
|
||||||
|
}
|
||||||
|
answerScroll.isHidden = next.answer.isEmpty
|
||||||
|
|
||||||
|
if providerPopup.numberOfItems == 0 || previous.providers != next.providers {
|
||||||
|
providerPopup.removeAllItems()
|
||||||
|
for provider in next.providers {
|
||||||
|
providerPopup.addItem(withTitle: provider.title)
|
||||||
|
providerPopup.lastItem?.representedObject = provider.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let index = next.providers.firstIndex(where: { $0.id == next.provider }) {
|
||||||
|
providerPopup.selectItem(at: index)
|
||||||
|
}
|
||||||
|
if modelPopup.numberOfItems == 0 || previous.models != next.models || previous.model != next.model {
|
||||||
|
modelPopup.removeAllItems()
|
||||||
|
var models = next.models
|
||||||
|
if !next.model.isEmpty, !models.contains(next.model) { models.insert(next.model, at: 0) }
|
||||||
|
modelPopup.addItems(withTitles: models)
|
||||||
|
modelPopup.selectItem(withTitle: next.model)
|
||||||
|
}
|
||||||
|
providerPopup.isEnabled = next.isAuthenticated && !next.isBusy
|
||||||
|
modelPopup.isEnabled = next.isAuthenticated && !next.isBusy
|
||||||
|
convertButton.isEnabled = next.canConvert
|
||||||
|
sendButton.isEnabled = next.canSend
|
||||||
|
openAppButton.isHidden = next.isAuthenticated
|
||||||
|
convertButton.isHidden = !next.isAuthenticated
|
||||||
|
shortcutLabel.stringValue = shortcutAvailable ? "⌥ Space" : "Shortcut unavailable"
|
||||||
|
shortcutLabel.toolTip = shortcutAvailable ? "Option+Space toggles Quick Question" : "Another app may be using Option+Space. Open Quick Question from Sybil’s File menu."
|
||||||
|
|
||||||
|
let status: String
|
||||||
|
if let error = next.error {
|
||||||
|
status = error
|
||||||
|
statusLabel.textColor = .systemRed
|
||||||
|
} else {
|
||||||
|
statusLabel.textColor = .secondaryLabelColor
|
||||||
|
if next.isCheckingSession {
|
||||||
|
status = "Connecting to Sybil…"
|
||||||
|
} else if !next.isAuthenticated {
|
||||||
|
status = "Connect in Sybil before asking a question."
|
||||||
|
} else if next.isConverting {
|
||||||
|
status = "Opening in chat…"
|
||||||
|
} else if next.isSending {
|
||||||
|
status = next.toolSummaries.last ?? "Thinking…"
|
||||||
|
} else {
|
||||||
|
status = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statusLabel.stringValue = status
|
||||||
|
statusLabel.isHidden = status.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
func preparePromptForPresentation() {
|
||||||
|
// Select only when invoked, never on streamed updates or normal edits.
|
||||||
|
promptView.selectAll(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func textDidChange(_ notification: Notification) {
|
||||||
|
guard !panelState.isBusy else { return }
|
||||||
|
promptView.needsDisplay = true
|
||||||
|
delegate?.quickQuestionPromptChanged(promptView.string)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addRow(_ view: NSView) {
|
||||||
|
stack.addArrangedSubview(view)
|
||||||
|
view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeHeader() -> NSView {
|
||||||
|
let icon = NSImageView(image: NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)!)
|
||||||
|
icon.contentTintColor = .systemPurple
|
||||||
|
icon.widthAnchor.constraint(equalToConstant: 18).isActive = true
|
||||||
|
let title = NSTextField(labelWithString: "Quick Question")
|
||||||
|
title.font = .systemFont(ofSize: 14, weight: .semibold)
|
||||||
|
shortcutLabel.font = .monospacedSystemFont(ofSize: 11, weight: .medium)
|
||||||
|
shortcutLabel.textColor = .secondaryLabelColor
|
||||||
|
let close = NSButton(image: NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close Quick Question")!, target: self, action: #selector(closePanel))
|
||||||
|
close.isBordered = false
|
||||||
|
close.toolTip = "Close (Esc)"
|
||||||
|
close.setAccessibilityLabel("Close Quick Question")
|
||||||
|
close.widthAnchor.constraint(equalToConstant: 24).isActive = true
|
||||||
|
let row = NSStackView(views: [icon, title, NSView(), shortcutLabel, close])
|
||||||
|
row.spacing = 8
|
||||||
|
row.alignment = .centerY
|
||||||
|
row.heightAnchor.constraint(equalToConstant: 24).isActive = true
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeControls() -> NSView {
|
||||||
|
providerPopup.target = self
|
||||||
|
providerPopup.action = #selector(changeProvider)
|
||||||
|
providerPopup.setAccessibilityLabel("Quick question provider")
|
||||||
|
providerPopup.widthAnchor.constraint(equalToConstant: 120).isActive = true
|
||||||
|
modelPopup.target = self
|
||||||
|
modelPopup.action = #selector(changeModel)
|
||||||
|
modelPopup.setAccessibilityLabel("Quick question model")
|
||||||
|
modelPopup.widthAnchor.constraint(lessThanOrEqualToConstant: 230).isActive = true
|
||||||
|
modelPopup.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
convertButton.target = self
|
||||||
|
convertButton.action = #selector(convertToChat)
|
||||||
|
convertButton.bezelStyle = .rounded
|
||||||
|
openAppButton.target = self
|
||||||
|
openAppButton.action = #selector(openApp)
|
||||||
|
openAppButton.bezelStyle = .rounded
|
||||||
|
sendButton.image = NSImage(systemSymbolName: "arrow.up", accessibilityDescription: "Ask Quick Question")
|
||||||
|
sendButton.imagePosition = .imageOnly
|
||||||
|
sendButton.bezelStyle = .circular
|
||||||
|
sendButton.contentTintColor = .systemPurple
|
||||||
|
sendButton.target = self
|
||||||
|
sendButton.action = #selector(submit)
|
||||||
|
sendButton.setAccessibilityLabel("Ask Quick Question")
|
||||||
|
sendButton.toolTip = "Ask (Return)"
|
||||||
|
sendButton.widthAnchor.constraint(equalToConstant: 32).isActive = true
|
||||||
|
let row = NSStackView(views: [providerPopup, modelPopup, NSView(), openAppButton, convertButton, sendButton])
|
||||||
|
row.spacing = 8
|
||||||
|
row.alignment = .centerY
|
||||||
|
row.detachesHiddenViews = true
|
||||||
|
row.heightAnchor.constraint(equalToConstant: 32).isActive = true
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureTextView(_ view: NSTextView, fontSize: CGFloat) {
|
||||||
|
view.drawsBackground = false
|
||||||
|
view.textColor = .labelColor
|
||||||
|
view.insertionPointColor = .labelColor
|
||||||
|
view.font = .systemFont(ofSize: fontSize)
|
||||||
|
view.textContainerInset = NSSize(width: 0, height: 4)
|
||||||
|
view.isHorizontallyResizable = false
|
||||||
|
view.isVerticallyResizable = true
|
||||||
|
view.autoresizingMask = [.width]
|
||||||
|
view.textContainer?.widthTracksTextView = true
|
||||||
|
view.textContainer?.lineFragmentPadding = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeScrollView(documentView: NSView) -> NSScrollView {
|
||||||
|
let scroll = NSScrollView()
|
||||||
|
scroll.drawsBackground = false
|
||||||
|
scroll.hasVerticalScroller = true
|
||||||
|
scroll.autohidesScrollers = true
|
||||||
|
scroll.documentView = documentView
|
||||||
|
return scroll
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func renderAnswer(_ text: String) -> NSAttributedString {
|
||||||
|
let parsed = (try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text)
|
||||||
|
let rendered = NSMutableAttributedString(attributedString: NSAttributedString(parsed))
|
||||||
|
let paragraph = NSMutableParagraphStyle()
|
||||||
|
paragraph.lineSpacing = 4
|
||||||
|
rendered.addAttributes([.foregroundColor: NSColor.labelColor, .paragraphStyle: paragraph], range: NSRange(location: 0, length: rendered.length))
|
||||||
|
for run in parsed.runs {
|
||||||
|
let intent = run.inlinePresentationIntent ?? []
|
||||||
|
var font = intent.contains(.code) ? NSFont.monospacedSystemFont(ofSize: 14, weight: .regular) : NSFont.systemFont(ofSize: 15)
|
||||||
|
if intent.contains(.stronglyEmphasized) { font = NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask) }
|
||||||
|
if intent.contains(.emphasized) { font = NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) }
|
||||||
|
rendered.addAttribute(.font, value: font, range: NSRange(run.range, in: parsed))
|
||||||
|
}
|
||||||
|
return rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func closePanel() { onDismiss?() }
|
||||||
|
@objc private func changeProvider() {
|
||||||
|
guard let provider = providerPopup.selectedItem?.representedObject as? String else { return }
|
||||||
|
delegate?.quickQuestionProviderChanged(provider)
|
||||||
|
}
|
||||||
|
@objc private func changeModel() {
|
||||||
|
guard let model = modelPopup.titleOfSelectedItem else { return }
|
||||||
|
delegate?.quickQuestionModelChanged(model)
|
||||||
|
}
|
||||||
|
@objc private func convertToChat() { delegate?.quickQuestionConvertRequested() }
|
||||||
|
@objc private func openApp() { delegate?.quickQuestionOpenAppRequested() }
|
||||||
|
@objc private func submit() {
|
||||||
|
guard panelState.canSend, !promptView.hasMarkedText() else { return }
|
||||||
|
delegate?.quickQuestionSubmitRequested()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class QuickQuestionPromptView: NSTextView {
|
||||||
|
var onSubmit: (() -> Void)?
|
||||||
|
var onToggle: (() -> Void)?
|
||||||
|
|
||||||
|
override func draw(_ dirtyRect: NSRect) {
|
||||||
|
super.draw(dirtyRect)
|
||||||
|
guard string.isEmpty else { return }
|
||||||
|
("Ask anything…" as NSString).draw(
|
||||||
|
at: NSPoint(x: textContainerInset.width, y: textContainerInset.height),
|
||||||
|
withAttributes: [.font: font ?? NSFont.systemFont(ofSize: 22), .foregroundColor: NSColor.placeholderTextColor]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func keyDown(with event: NSEvent) {
|
||||||
|
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift])
|
||||||
|
if event.keyCode == 49, modifiers == .option {
|
||||||
|
if !event.isARepeat { onToggle?() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.keyCode == 36 || event.keyCode == 76), modifiers.isEmpty, !hasMarkedText() {
|
||||||
|
if !event.isARepeat { onSubmit?() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Let the text input system handle Shift+Return and IME confirmation.
|
||||||
|
super.keyDown(with: event)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
targets:
|
||||||
|
SybilMacQuickQuestion:
|
||||||
|
type: bundle
|
||||||
|
platform: macOS
|
||||||
|
deploymentTarget: "15.0"
|
||||||
|
sources:
|
||||||
|
- Sources
|
||||||
|
- ../../Packages/Sybil/Sources/Sybil/SybilMacQuickQuestionBridge.swift
|
||||||
|
dependencies:
|
||||||
|
- sdk: AppKit.framework
|
||||||
|
- sdk: Carbon.framework
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.quick-question-panel
|
||||||
|
PRODUCT_NAME: SybilMacQuickQuestion
|
||||||
|
PRODUCT_MODULE_NAME: SybilMacQuickQuestion
|
||||||
|
DEVELOPMENT_TEAM: DQQH5H6GBD
|
||||||
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
SWIFT_VERSION: 6.0
|
||||||
|
SUPPORTS_MACCATALYST: NO
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
INFOPLIST_KEY_CFBundleExecutable: $(EXECUTABLE_NAME)
|
||||||
|
INFOPLIST_KEY_NSPrincipalClass: SybilMacQuickQuestionPlugin
|
||||||
|
MACH_O_TYPE: mh_bundle
|
||||||
|
SKIP_INSTALL: YES
|
||||||
@@ -78,6 +78,7 @@ public struct SplitView: View {
|
|||||||
.font(.sybil(.body))
|
.font(.sybil(.body))
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
||||||
|
#if !targetEnvironment(macCatalyst)
|
||||||
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
|
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
|
||||||
SybilQuickQuestionView(
|
SybilQuickQuestionView(
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
@@ -85,7 +86,11 @@ public struct SplitView: View {
|
|||||||
)
|
)
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
.task {
|
.task {
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||||
|
#endif
|
||||||
await viewModel.bootstrap()
|
await viewModel.bootstrap()
|
||||||
presentPendingQuickQuestionIfPossible()
|
presentPendingQuickQuestionIfPossible()
|
||||||
}
|
}
|
||||||
@@ -107,6 +112,9 @@ public struct SplitView: View {
|
|||||||
shouldRefreshOnForeground = true
|
shouldRefreshOnForeground = true
|
||||||
viewModel.markAppInactiveForNetwork()
|
viewModel.markAppInactiveForNetwork()
|
||||||
case .active:
|
case .active:
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||||
|
#endif
|
||||||
viewModel.markAppActiveForNetwork()
|
viewModel.markAppActiveForNetwork()
|
||||||
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
||||||
return
|
return
|
||||||
@@ -151,8 +159,13 @@ public struct SplitView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hasPendingQuickQuestionPresentation = false
|
hasPendingQuickQuestionPresentation = false
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||||
|
SybilMacQuickQuestionController.shared.show()
|
||||||
|
#else
|
||||||
quickQuestionFocusRequest += 1
|
quickQuestionFocusRequest += 1
|
||||||
isQuickQuestionPresented = true
|
isQuickQuestionPresented = true
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleQuickQuestionDismissed() {
|
private func handleQuickQuestionDismissed() {
|
||||||
@@ -161,23 +174,57 @@ public struct SplitView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct SybilCommands: Commands {
|
public struct SybilCommands: Commands {
|
||||||
|
public static let workspaceWindowID = "sybil-workspace"
|
||||||
|
|
||||||
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
|
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
@Environment(\.openWindow) private var openWindow
|
||||||
|
@ObservedObject private var windowCommands = SybilMacWindowCommands.shared
|
||||||
|
#endif
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public var body: some Commands {
|
public var body: some Commands {
|
||||||
CommandGroup(replacing: .newItem) {
|
CommandGroup(replacing: .newItem) {
|
||||||
Button("New Chat") {
|
Button("New Chat") {
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
windowCommands.newChatOrWindow(
|
||||||
|
newChat: keyboardActions?.newChat,
|
||||||
|
openWindow: openNewWindow
|
||||||
|
)
|
||||||
|
#else
|
||||||
keyboardActions?.newChat()
|
keyboardActions?.newChat()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.keyboardShortcut("n", modifiers: .command)
|
.keyboardShortcut("n", modifiers: .command)
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
.disabled(!windowCommands.canStartNewChat(hasFocusedActions: keyboardActions != nil))
|
||||||
|
#else
|
||||||
.disabled(keyboardActions == nil)
|
.disabled(keyboardActions == nil)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
Button("New Window", action: openNewWindow)
|
||||||
|
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||||
|
#endif
|
||||||
|
|
||||||
Button("New Search") {
|
Button("New Search") {
|
||||||
keyboardActions?.newSearch()
|
keyboardActions?.newSearch()
|
||||||
}
|
}
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
.keyboardShortcut("n", modifiers: [.command, .option])
|
||||||
|
#else
|
||||||
.keyboardShortcut("n", modifiers: [.command, .shift])
|
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||||
|
#endif
|
||||||
.disabled(keyboardActions == nil)
|
.disabled(keyboardActions == nil)
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
Divider()
|
||||||
|
Button("Quick Question") {
|
||||||
|
SybilMacQuickQuestionController.shared.toggle()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.space, modifiers: .option)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandMenu("Conversation") {
|
CommandMenu("Conversation") {
|
||||||
@@ -194,6 +241,13 @@ public struct SybilCommands: Commands {
|
|||||||
.disabled(keyboardActions == nil)
|
.disabled(keyboardActions == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
private func openNewWindow() {
|
||||||
|
// Targeting a WindowGroup without a value creates a fresh scene every
|
||||||
|
// time, even when another workspace window is already open.
|
||||||
|
openWindow(id: Self.workspaceWindowID)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SybilKeyboardActions {
|
private struct SybilKeyboardActions {
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
return response.items
|
return response.items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse {
|
||||||
|
try await request(
|
||||||
|
"/v1/workspace-items",
|
||||||
|
method: "GET",
|
||||||
|
queryItems: [URLQueryItem(name: "q", value: query)],
|
||||||
|
responseType: WorkspaceSearchResponse.self
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func listChats() async throws -> [ChatSummary] {
|
func listChats() async throws -> [ChatSummary] {
|
||||||
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
|
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
|
||||||
return response.chats
|
return response.chats
|
||||||
@@ -74,6 +83,16 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
return response.chat
|
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 {
|
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
||||||
let response = try await request(
|
let response = try await request(
|
||||||
"/v1/chats/\(chatID)",
|
"/v1/chats/\(chatID)",
|
||||||
@@ -183,6 +202,29 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
SybilLog.info(SybilLog.network, "Chat stream completed")
|
SybilLog.info(SybilLog.network, "Chat stream completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runQuickQuestionStream(
|
||||||
|
body: QuickQuestionStreamRequest,
|
||||||
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
let request = try makeRequest(
|
||||||
|
path: "/v1/quick-questions/stream",
|
||||||
|
method: "POST",
|
||||||
|
body: AnyEncodable(body),
|
||||||
|
acceptsSSE: true
|
||||||
|
)
|
||||||
|
|
||||||
|
SybilLog.info(
|
||||||
|
SybilLog.network,
|
||||||
|
"Starting quick question stream POST \(request.url?.absoluteString ?? "<unknown>")"
|
||||||
|
)
|
||||||
|
|
||||||
|
try await stream(request: request) { eventName, dataText in
|
||||||
|
try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
SybilLog.info(SybilLog.network, "Quick question stream completed")
|
||||||
|
}
|
||||||
|
|
||||||
func attachCompletionStream(
|
func attachCompletionStream(
|
||||||
chatID: String,
|
chatID: String,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
@@ -257,9 +299,10 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
_ path: String,
|
_ path: String,
|
||||||
method: String,
|
method: String,
|
||||||
body: AnyEncodable? = nil,
|
body: AnyEncodable? = nil,
|
||||||
|
queryItems: [URLQueryItem] = [],
|
||||||
responseType: Response.Type
|
responseType: Response.Type
|
||||||
) async throws -> Response {
|
) async throws -> Response {
|
||||||
let request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false)
|
let request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false, queryItems: queryItems)
|
||||||
|
|
||||||
SybilLog.debug(
|
SybilLog.debug(
|
||||||
SybilLog.network,
|
SybilLog.network,
|
||||||
@@ -311,9 +354,10 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
path: String,
|
path: String,
|
||||||
method: String,
|
method: String,
|
||||||
body: AnyEncodable?,
|
body: AnyEncodable?,
|
||||||
acceptsSSE: Bool
|
acceptsSSE: Bool,
|
||||||
|
queryItems: [URLQueryItem] = []
|
||||||
) throws -> URLRequest {
|
) throws -> URLRequest {
|
||||||
let url = try buildURL(path: path)
|
let url = try buildURL(path: path, queryItems: queryItems)
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = method
|
request.httpMethod = method
|
||||||
request.timeoutInterval = 120
|
request.timeoutInterval = 120
|
||||||
@@ -336,7 +380,7 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildURL(path: String) throws -> URL {
|
private func buildURL(path: String, queryItems: [URLQueryItem]) throws -> URL {
|
||||||
guard var components = URLComponents(url: configuration.baseURL, resolvingAgainstBaseURL: false) else {
|
guard var components = URLComponents(url: configuration.baseURL, resolvingAgainstBaseURL: false) else {
|
||||||
throw APIError.invalidBaseURL
|
throw APIError.invalidBaseURL
|
||||||
}
|
}
|
||||||
@@ -347,6 +391,9 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
basePath.removeLast()
|
basePath.removeLast()
|
||||||
}
|
}
|
||||||
components.path = "\(basePath)/\(trimmedPath)"
|
components.path = "\(basePath)/\(trimmedPath)"
|
||||||
|
if !queryItems.isEmpty {
|
||||||
|
components.queryItems = (components.queryItems ?? []) + queryItems
|
||||||
|
}
|
||||||
|
|
||||||
guard let url = components.url else {
|
guard let url = components.url else {
|
||||||
throw APIError.invalidBaseURL
|
throw APIError.invalidBaseURL
|
||||||
@@ -664,6 +711,12 @@ struct CompletionStreamRequest: Codable, Sendable {
|
|||||||
var userLocation: String? = nil
|
var userLocation: String? = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct QuickQuestionStreamRequest: Codable, Sendable {
|
||||||
|
var provider: Provider
|
||||||
|
var model: String
|
||||||
|
var question: String
|
||||||
|
}
|
||||||
|
|
||||||
private struct ChatCreateBody: Encodable {
|
private struct ChatCreateBody: Encodable {
|
||||||
var title: String?
|
var title: String?
|
||||||
var provider: Provider?
|
var provider: Provider?
|
||||||
@@ -675,6 +728,10 @@ private struct ChatTitleUpdateBody: Encodable {
|
|||||||
var title: String
|
var title: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct ChatForkBody: Encodable {
|
||||||
|
var messageId: String?
|
||||||
|
}
|
||||||
|
|
||||||
private struct StarUpdateBody: Encodable {
|
private struct StarUpdateBody: Encodable {
|
||||||
var starred: Bool
|
var starred: Bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Foundation
|
|||||||
protocol SybilAPIClienting: Sendable {
|
protocol SybilAPIClienting: Sendable {
|
||||||
func verifySession() async throws -> AuthSession
|
func verifySession() async throws -> AuthSession
|
||||||
func listWorkspaceItems() async throws -> [WorkspaceItem]
|
func listWorkspaceItems() async throws -> [WorkspaceItem]
|
||||||
|
func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse
|
||||||
func listChats() async throws -> [ChatSummary]
|
func listChats() async throws -> [ChatSummary]
|
||||||
func createChat(
|
func createChat(
|
||||||
title: String?,
|
title: String?,
|
||||||
@@ -11,6 +12,7 @@ protocol SybilAPIClienting: Sendable {
|
|||||||
messages: [CompletionRequestMessage]?
|
messages: [CompletionRequestMessage]?
|
||||||
) async throws -> ChatSummary
|
) async throws -> ChatSummary
|
||||||
func getChat(chatID: String) async throws -> ChatDetail
|
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 updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
|
||||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
|
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
|
||||||
func deleteChat(chatID: String) async throws
|
func deleteChat(chatID: String) async throws
|
||||||
@@ -27,6 +29,10 @@ protocol SybilAPIClienting: Sendable {
|
|||||||
body: CompletionStreamRequest,
|
body: CompletionStreamRequest,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
) async throws
|
) async throws
|
||||||
|
func runQuickQuestionStream(
|
||||||
|
body: QuickQuestionStreamRequest,
|
||||||
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
) async throws
|
||||||
func attachCompletionStream(
|
func attachCompletionStream(
|
||||||
chatID: String,
|
chatID: String,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import MarkdownUI
|
import MarkdownUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct SybilChatTranscriptView: View {
|
struct SybilChatTranscriptView: View {
|
||||||
var messages: [Message]
|
var messages: [Message]
|
||||||
var isLoading: Bool
|
var isLoading: Bool
|
||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
|
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||||
var topContentInset: CGFloat = 0
|
var topContentInset: CGFloat = 0
|
||||||
var bottomContentInset: CGFloat = 0
|
var bottomContentInset: CGFloat = 0
|
||||||
var bottomPinRequestID: Int = 0
|
var bottomPinRequestID: Int = 0
|
||||||
|
var isForking: Bool = false
|
||||||
|
var onForkResponse: ((String) -> Void)? = nil
|
||||||
|
|
||||||
@State private var hasTrackedToolCallMessages = false
|
@State private var hasTrackedToolCallMessages = false
|
||||||
@State private var knownToolCallMessageIDs: Set<String> = []
|
@State private var knownToolCallMessageIDs: Set<String> = []
|
||||||
@@ -41,7 +45,15 @@ struct SybilChatTranscriptView: View {
|
|||||||
ForEach(renderItems) { item in
|
ForEach(renderItems) { item in
|
||||||
switch item {
|
switch item {
|
||||||
case let .message(message):
|
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)
|
.frame(maxWidth: .infinity)
|
||||||
case let .toolGroup(id, messages):
|
case let .toolGroup(id, messages):
|
||||||
ToolCallStackView(
|
ToolCallStackView(
|
||||||
@@ -140,6 +152,9 @@ func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderIt
|
|||||||
private struct MessageBubble: View {
|
private struct MessageBubble: View {
|
||||||
var message: Message
|
var message: Message
|
||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
|
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||||
|
var isForking: Bool
|
||||||
|
var onFork: (() -> Void)?
|
||||||
|
|
||||||
private var toolCallMetadata: ToolCallMetadata? {
|
private var toolCallMetadata: ToolCallMetadata? {
|
||||||
message.toolCallMetadata
|
message.toolCallMetadata
|
||||||
@@ -149,13 +164,46 @@ private struct MessageBubble: View {
|
|||||||
message.role == .user
|
message.role == .user
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isPendingAssistant: Bool {
|
private var isStreamingAssistant: Bool {
|
||||||
message.id.hasPrefix("temp-assistant-") &&
|
message.role == .assistant && message.id.hasPrefix("temp-assistant-")
|
||||||
isSending &&
|
}
|
||||||
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
|
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 {
|
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) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
leadingSpacer
|
leadingSpacer
|
||||||
|
|
||||||
@@ -174,16 +222,12 @@ private struct MessageBubble: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isPendingAssistant {
|
if isStreamingAssistant {
|
||||||
HStack(spacing: 8) {
|
StreamingAssistantContentView(
|
||||||
ProgressView()
|
content: message.content,
|
||||||
.controlSize(.small)
|
isSending: isSending,
|
||||||
.tint(SybilTheme.primary)
|
presentation: matchingStreamingAssistantPresentation
|
||||||
Text("Thinking…")
|
)
|
||||||
.font(.sybil(.footnote))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
Markdown(message.content)
|
Markdown(message.content)
|
||||||
.tint(SybilTheme.primary)
|
.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 ToolCallStackView: View {
|
||||||
private struct CardLayout {
|
private struct CardLayout {
|
||||||
var x: CGFloat
|
var x: CGFloat
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// This Foundation-only contract is compiled into both the Catalyst client and the
|
||||||
|
// native macOS bundle. Only Objective-C-compatible values cross the bundle boundary.
|
||||||
|
@MainActor
|
||||||
|
@objc(SybilQuickQuestionPanelBridging)
|
||||||
|
protocol SybilQuickQuestionPanelBridging: NSObjectProtocol {
|
||||||
|
init()
|
||||||
|
var isVisible: Bool { get }
|
||||||
|
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32
|
||||||
|
func updateState(_ data: Data)
|
||||||
|
func show()
|
||||||
|
func hide()
|
||||||
|
func activateForQuickQuestion()
|
||||||
|
func activateMainWindow()
|
||||||
|
func stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@objc(SybilQuickQuestionPanelDelegate)
|
||||||
|
protocol SybilQuickQuestionPanelDelegate: NSObjectProtocol {
|
||||||
|
func quickQuestionToggleRequested()
|
||||||
|
func quickQuestionPromptChanged(_ prompt: String)
|
||||||
|
func quickQuestionSubmitRequested()
|
||||||
|
func quickQuestionProviderChanged(_ provider: String)
|
||||||
|
func quickQuestionModelChanged(_ model: String)
|
||||||
|
func quickQuestionConvertRequested()
|
||||||
|
func quickQuestionOpenAppRequested()
|
||||||
|
func quickQuestionPanelDismissed()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SybilQuickQuestionPanelState: Codable, Equatable, Sendable {
|
||||||
|
struct ProviderOption: Codable, Equatable, Sendable {
|
||||||
|
var id: String
|
||||||
|
var title: String
|
||||||
|
}
|
||||||
|
|
||||||
|
var prompt = ""
|
||||||
|
var answer = ""
|
||||||
|
var toolSummaries: [String] = []
|
||||||
|
var provider = ""
|
||||||
|
var providers: [ProviderOption] = []
|
||||||
|
var model = ""
|
||||||
|
var models: [String] = []
|
||||||
|
var isSending = false
|
||||||
|
var isConverting = false
|
||||||
|
var canSend = false
|
||||||
|
var canConvert = false
|
||||||
|
var isAuthenticated = false
|
||||||
|
var isCheckingSession = true
|
||||||
|
var error: String?
|
||||||
|
|
||||||
|
var isBusy: Bool { isSending || isConverting }
|
||||||
|
var hasResponse: Bool { !answer.isEmpty || !toolSummaries.isEmpty || isSending || error != nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carbon can deliver multiple presses while a key is held. Consume one toggle
|
||||||
|
// per press/release pair, without suppressing a second deliberate press.
|
||||||
|
struct SybilQuickQuestionHotKeyState {
|
||||||
|
private var isPressed = false
|
||||||
|
|
||||||
|
mutating func shouldToggle(isKeyDown: Bool) -> Bool {
|
||||||
|
if !isKeyDown {
|
||||||
|
isPressed = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard !isPressed else { return false }
|
||||||
|
isPressed = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class SybilMacQuickQuestionController: NSObject, SybilQuickQuestionPanelDelegate {
|
||||||
|
public static let shared = SybilMacQuickQuestionController()
|
||||||
|
|
||||||
|
private var pluginBundle: Bundle?
|
||||||
|
private var bridge: (any SybilQuickQuestionPanelBridging)?
|
||||||
|
private var viewModel: SybilViewModel?
|
||||||
|
private var observationGeneration = 0
|
||||||
|
private var isPresentationRequested = false
|
||||||
|
private var activationRequested = false
|
||||||
|
private var presentationTask: Task<Void, Never>?
|
||||||
|
private var foregroundObservation: AnyCancellable?
|
||||||
|
private var notificationCenter = NotificationCenter.default
|
||||||
|
private var isApplicationInForeground: @MainActor () -> Bool = {
|
||||||
|
UIApplication.shared.applicationState != .background
|
||||||
|
}
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject foreground readiness as well as the panel so tests can exercise
|
||||||
|
// windowless activation without activating apps or showing real windows.
|
||||||
|
init(
|
||||||
|
bridge: any SybilQuickQuestionPanelBridging,
|
||||||
|
isApplicationInForeground: @escaping @MainActor () -> Bool = { true },
|
||||||
|
notificationCenter: NotificationCenter = NotificationCenter()
|
||||||
|
) {
|
||||||
|
self.bridge = bridge
|
||||||
|
self.isApplicationInForeground = isApplicationInForeground
|
||||||
|
self.notificationCenter = notificationCenter
|
||||||
|
super.init()
|
||||||
|
_ = bridge.start(delegate: self)
|
||||||
|
observeForegroundTransitionsIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func start() {
|
||||||
|
observeForegroundTransitionsIfNeeded()
|
||||||
|
guard bridge == nil else { return }
|
||||||
|
guard let url = Bundle.main.builtInPlugInsURL?.appendingPathComponent("SybilMacQuickQuestion.bundle"),
|
||||||
|
let bundle = Bundle(url: url)
|
||||||
|
else {
|
||||||
|
SybilLog.error(SybilLog.app, "The macOS Quick Question bundle is missing.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try bundle.loadAndReturnError()
|
||||||
|
guard let pluginClass = bundle.principalClass as? any SybilQuickQuestionPanelBridging.Type else {
|
||||||
|
SybilLog.error(SybilLog.app, "The macOS Quick Question bundle has an invalid principal class.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let plugin = pluginClass.init()
|
||||||
|
pluginBundle = bundle
|
||||||
|
bridge = plugin
|
||||||
|
let status = plugin.start(delegate: self)
|
||||||
|
if status != 0 {
|
||||||
|
SybilLog.warning(SybilLog.app, "Option+Space could not be registered (status \(status)). Quick Question remains available in the app menu.")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SybilLog.error(SybilLog.app, "Could not load the macOS Quick Question panel", error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func attach(_ viewModel: SybilViewModel) {
|
||||||
|
guard self.viewModel !== viewModel else { return }
|
||||||
|
// A window becoming active must not replace an in-flight quick question.
|
||||||
|
if self.viewModel != nil,
|
||||||
|
isPresentationRequested || bridge?.isVisible == true || self.viewModel?.isQuickQuestionSending == true || self.viewModel?.isConvertingQuickQuestion == true {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.viewModel = viewModel
|
||||||
|
if bridge?.isVisible == true { updateAndObserve() }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func toggle() {
|
||||||
|
start()
|
||||||
|
if isPresentationRequested || bridge?.isVisible == true {
|
||||||
|
cancelPresentation()
|
||||||
|
bridge?.hide()
|
||||||
|
} else {
|
||||||
|
show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func show() {
|
||||||
|
start()
|
||||||
|
isPresentationRequested = true
|
||||||
|
schedulePresentationIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() {
|
||||||
|
cancelPresentation()
|
||||||
|
foregroundObservation = nil
|
||||||
|
viewModel?.cancelQuickQuestion()
|
||||||
|
bridge?.stop()
|
||||||
|
bridge = nil
|
||||||
|
// Keep the loaded bundle alive for the lifetime of its code.
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observeForegroundTransitionsIfNeeded() {
|
||||||
|
guard foregroundObservation == nil else { return }
|
||||||
|
foregroundObservation = notificationCenter.publisher(for: UIApplication.willEnterForegroundNotification)
|
||||||
|
.merge(with: notificationCenter.publisher(for: UIApplication.didBecomeActiveNotification))
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.schedulePresentationIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func schedulePresentationIfNeeded() {
|
||||||
|
guard isPresentationRequested, bridge?.isVisible != true, presentationTask == nil else { return }
|
||||||
|
// Leave the Carbon/NSApplication event callback before touching windows.
|
||||||
|
// AppKit activation is asynchronous; an arbitrary delay or NSApp.isActive
|
||||||
|
// is not a substitute for checking UIKit's foreground lifecycle state.
|
||||||
|
presentationTask = Task { @MainActor [weak self] in
|
||||||
|
guard let self, !Task.isCancelled else { return }
|
||||||
|
self.presentationTask = nil
|
||||||
|
guard self.isPresentationRequested else { return }
|
||||||
|
guard self.isApplicationInForeground() else {
|
||||||
|
guard !self.activationRequested else { return }
|
||||||
|
self.activationRequested = true
|
||||||
|
SybilLog.debug(SybilLog.ui, "Waiting for Catalyst foreground before showing Quick Question")
|
||||||
|
self.bridge?.activateForQuickQuestion()
|
||||||
|
// Also handle an activation that completes synchronously. If it
|
||||||
|
// has not completed, subsequent UIKit notifications resume us.
|
||||||
|
self.schedulePresentationIfNeeded()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.activationRequested = false
|
||||||
|
self.updateAndObserve()
|
||||||
|
self.bridge?.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelPresentation() {
|
||||||
|
isPresentationRequested = false
|
||||||
|
activationRequested = false
|
||||||
|
presentationTask?.cancel()
|
||||||
|
presentationTask = nil
|
||||||
|
observationGeneration += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateAndObserve() {
|
||||||
|
guard let bridge else { return }
|
||||||
|
observationGeneration += 1
|
||||||
|
let generation = observationGeneration
|
||||||
|
let state = withObservationTracking {
|
||||||
|
viewModel?.macQuickQuestionPanelState ?? SybilQuickQuestionPanelState()
|
||||||
|
} onChange: { [weak self] in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self, self.observationGeneration == generation, self.bridge?.isVisible == true else { return }
|
||||||
|
self.updateAndObserve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
bridge.updateState(try JSONEncoder().encode(state))
|
||||||
|
} catch {
|
||||||
|
SybilLog.error(SybilLog.ui, "Could not update the macOS Quick Question panel", error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func quickQuestionToggleRequested() {
|
||||||
|
toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
func quickQuestionPromptChanged(_ prompt: String) {
|
||||||
|
guard let viewModel, !viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
|
||||||
|
viewModel.updateQuickQuestionPrompt(prompt)
|
||||||
|
updateAndObserve()
|
||||||
|
}
|
||||||
|
|
||||||
|
func quickQuestionSubmitRequested() {
|
||||||
|
guard let viewModel, viewModel.isAuthenticated, !viewModel.isCheckingSession else { return }
|
||||||
|
viewModel.sendQuickQuestion()
|
||||||
|
updateAndObserve()
|
||||||
|
}
|
||||||
|
|
||||||
|
func quickQuestionProviderChanged(_ provider: String) {
|
||||||
|
guard let viewModel, let provider = Provider(rawValue: provider),
|
||||||
|
!viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
|
||||||
|
viewModel.setQuickQuestionProvider(provider)
|
||||||
|
updateAndObserve()
|
||||||
|
}
|
||||||
|
|
||||||
|
func quickQuestionModelChanged(_ model: String) {
|
||||||
|
guard let viewModel, !viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
|
||||||
|
viewModel.setQuickQuestionModel(model)
|
||||||
|
updateAndObserve()
|
||||||
|
}
|
||||||
|
|
||||||
|
func quickQuestionConvertRequested() {
|
||||||
|
guard let viewModel, viewModel.canConvertQuickQuestion else { return }
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if await viewModel.convertQuickQuestionToChat() {
|
||||||
|
self.cancelPresentation()
|
||||||
|
self.openMainWindow()
|
||||||
|
self.bridge?.hide()
|
||||||
|
} else {
|
||||||
|
self.updateAndObserve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func quickQuestionOpenAppRequested() {
|
||||||
|
cancelPresentation()
|
||||||
|
openMainWindow()
|
||||||
|
bridge?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openMainWindow() {
|
||||||
|
let session = UIApplication.shared.connectedScenes
|
||||||
|
.compactMap { $0 as? UIWindowScene }
|
||||||
|
.first?.session
|
||||||
|
UIApplication.shared.requestSceneSessionActivation(session, userActivity: nil, options: nil)
|
||||||
|
bridge?.activateMainWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
func quickQuestionPanelDismissed() {
|
||||||
|
// Toggling out preserves the draft and lets an answer finish. Reopening
|
||||||
|
// reads the latest state, without starting a new request.
|
||||||
|
cancelPresentation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SybilViewModel {
|
||||||
|
var macQuickQuestionPanelState: SybilQuickQuestionPanelState {
|
||||||
|
SybilQuickQuestionPanelState(
|
||||||
|
prompt: quickQuestionPrompt,
|
||||||
|
answer: quickQuestionAnswerText,
|
||||||
|
toolSummaries: quickQuestionMessages.compactMap { message in
|
||||||
|
guard let metadata = message.toolCallMetadata else { return nil }
|
||||||
|
return metadata.summary ?? message.content
|
||||||
|
},
|
||||||
|
provider: quickQuestionProvider.rawValue,
|
||||||
|
providers: providerOptions.map { .init(id: $0.rawValue, title: $0.displayName) },
|
||||||
|
model: quickQuestionModel,
|
||||||
|
models: quickQuestionProviderModelOptions,
|
||||||
|
isSending: isQuickQuestionSending,
|
||||||
|
isConverting: isConvertingQuickQuestion,
|
||||||
|
canSend: isAuthenticated && !isCheckingSession && canSendQuickQuestion,
|
||||||
|
canConvert: canConvertQuickQuestion,
|
||||||
|
isAuthenticated: isAuthenticated,
|
||||||
|
isCheckingSession: isCheckingSession,
|
||||||
|
error: quickQuestionError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// Scene lifetime is independent of focused commands: a connected window may
|
||||||
|
// still be authenticating, inactive, or minimized. The native Quick Question
|
||||||
|
// panel is not a workspace scene and does not count as an open window here.
|
||||||
|
@MainActor
|
||||||
|
public final class SybilMacWindowCommands: ObservableObject {
|
||||||
|
public static let shared = SybilMacWindowCommands()
|
||||||
|
|
||||||
|
@Published private var connectedWindowIDs: Set<String> = []
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
var hasOpenWindows: Bool { !connectedWindowIDs.isEmpty }
|
||||||
|
|
||||||
|
public func windowConnected(sessionID: String) {
|
||||||
|
connectedWindowIDs.insert(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func windowDisconnected(sessionID: String) {
|
||||||
|
connectedWindowIDs.remove(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func canStartNewChat(hasFocusedActions: Bool) -> Bool {
|
||||||
|
!hasOpenWindows || hasFocusedActions
|
||||||
|
}
|
||||||
|
|
||||||
|
func newChatOrWindow(newChat: (() -> Void)?, openWindow: () -> Void) {
|
||||||
|
if hasOpenWindows {
|
||||||
|
newChat?()
|
||||||
|
} else {
|
||||||
|
openWindow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -219,6 +219,7 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var id: String
|
public var id: String
|
||||||
public var title: String?
|
public var title: String?
|
||||||
public var query: String?
|
public var query: String?
|
||||||
|
public var searchSnippet: String? = nil
|
||||||
public var parentChatId: String? = nil
|
public var parentChatId: String? = nil
|
||||||
public var titleGenerationPending = false
|
public var titleGenerationPending = false
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
@@ -302,6 +303,7 @@ extension WorkspaceItem {
|
|||||||
case id
|
case id
|
||||||
case title
|
case title
|
||||||
case query
|
case query
|
||||||
|
case searchSnippet
|
||||||
case parentChatId
|
case parentChatId
|
||||||
case titleGenerationPending
|
case titleGenerationPending
|
||||||
case createdAt
|
case createdAt
|
||||||
@@ -320,6 +322,7 @@ extension WorkspaceItem {
|
|||||||
id = try container.decode(String.self, forKey: .id)
|
id = try container.decode(String.self, forKey: .id)
|
||||||
title = try container.decodeIfPresent(String.self, forKey: .title)
|
title = try container.decodeIfPresent(String.self, forKey: .title)
|
||||||
query = try container.decodeIfPresent(String.self, forKey: .query)
|
query = try container.decodeIfPresent(String.self, forKey: .query)
|
||||||
|
searchSnippet = try container.decodeIfPresent(String.self, forKey: .searchSnippet)
|
||||||
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
|
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
|
||||||
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
|
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
|
||||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||||
@@ -730,6 +733,11 @@ struct SearchListResponse: Codable {
|
|||||||
var searches: [SearchSummary]
|
var searches: [SearchSummary]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct WorkspaceSearchResponse: Codable, Sendable {
|
||||||
|
var items: [WorkspaceItem]
|
||||||
|
var hasMore: Bool
|
||||||
|
}
|
||||||
|
|
||||||
struct WorkspaceListResponse: Codable {
|
struct WorkspaceListResponse: Codable {
|
||||||
var items: [WorkspaceItem]
|
var items: [WorkspaceItem]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,7 +130,10 @@ struct SybilPhoneShellView: View {
|
|||||||
route: route,
|
route: route,
|
||||||
onRequestBack: { _ in showSidebarOverlay() },
|
onRequestBack: { _ in showSidebarOverlay() },
|
||||||
onRequestNewChat: sidebarWorkspaceNewChatAction,
|
onRequestNewChat: sidebarWorkspaceNewChatAction,
|
||||||
onShowSidebar: showSidebarOverlay
|
onShowSidebar: showSidebarOverlay,
|
||||||
|
onOpenForkedChat: { chatID in
|
||||||
|
showRoute(.chat(chatID))
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.background(SybilTheme.background)
|
.background(SybilTheme.background)
|
||||||
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
|
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
|
||||||
@@ -239,6 +242,7 @@ struct SybilPhoneShellView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func showRoute(_ nextRoute: PhoneRoute) {
|
private func showRoute(_ nextRoute: PhoneRoute) {
|
||||||
|
dismissSidebarKeyboard()
|
||||||
let update = {
|
let update = {
|
||||||
route = nextRoute
|
route = nextRoute
|
||||||
}
|
}
|
||||||
@@ -268,12 +272,19 @@ struct SybilPhoneShellView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func hideSidebarOverlay() {
|
private func hideSidebarOverlay() {
|
||||||
|
dismissSidebarKeyboard()
|
||||||
withAnimation(.easeOut(duration: 0.18)) {
|
withAnimation(.easeOut(duration: 0.18)) {
|
||||||
isSidebarOverlayPresented = false
|
isSidebarOverlayPresented = false
|
||||||
}
|
}
|
||||||
resetSidebarSwipe(animated: false)
|
resetSidebarSwipe(animated: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func dismissSidebarKeyboard() {
|
||||||
|
if isSidebarOverlayPresented {
|
||||||
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func openSidebarSelection(_ selection: SidebarSelection) {
|
private func openSidebarSelection(_ selection: SidebarSelection) {
|
||||||
if openingSelectionRequestID != nil, sidebarHighlightSelection == selection {
|
if openingSelectionRequestID != nil, sidebarHighlightSelection == selection {
|
||||||
return
|
return
|
||||||
@@ -511,6 +522,9 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
},
|
},
|
||||||
onSelect: { item in
|
onSelect: { item in
|
||||||
onSelect(item.selection)
|
onSelect(item.selection)
|
||||||
|
},
|
||||||
|
onOpenForkedChat: { chatID in
|
||||||
|
onSelect(.chat(chatID))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -585,6 +599,7 @@ private struct SybilPhoneDestinationView: View {
|
|||||||
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
||||||
let onRequestNewChat: (() -> Void)?
|
let onRequestNewChat: (() -> Void)?
|
||||||
let onShowSidebar: () -> Void
|
let onShowSidebar: () -> Void
|
||||||
|
let onOpenForkedChat: (String) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SybilWorkspaceView(
|
SybilWorkspaceView(
|
||||||
@@ -593,7 +608,8 @@ private struct SybilPhoneDestinationView: View {
|
|||||||
navigationLeadingControl: .showSidebar,
|
navigationLeadingControl: .showSidebar,
|
||||||
onShowSidebar: onShowSidebar,
|
onShowSidebar: onShowSidebar,
|
||||||
onRequestBack: onRequestBack,
|
onRequestBack: onRequestBack,
|
||||||
onRequestNewChat: onRequestNewChat
|
onRequestNewChat: onRequestNewChat,
|
||||||
|
onOpenForkedChat: onOpenForkedChat
|
||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.task(id: route) {
|
.task(id: route) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ struct SybilQuickQuestionView: View {
|
|||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@FocusState private var promptFocused: Bool
|
@FocusState private var promptFocused: Bool
|
||||||
|
@State private var promptSelection: TextSelection?
|
||||||
|
|
||||||
private var hasAnswerContent: Bool {
|
private var hasAnswerContent: Bool {
|
||||||
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
|
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
|
||||||
@@ -36,6 +37,8 @@ struct SybilQuickQuestionView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
promptFocused = true
|
promptFocused = true
|
||||||
|
let prompt = viewModel.quickQuestionPrompt
|
||||||
|
promptSelection = TextSelection(range: prompt.startIndex..<prompt.endIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +62,11 @@ struct SybilQuickQuestionView: View {
|
|||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
if hasAnswerContent {
|
if hasAnswerContent {
|
||||||
ForEach(viewModel.quickQuestionMessages) { message in
|
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 {
|
if let error = viewModel.quickQuestionError {
|
||||||
@@ -94,13 +101,13 @@ struct SybilQuickQuestionView: View {
|
|||||||
get: { viewModel.quickQuestionPrompt },
|
get: { viewModel.quickQuestionPrompt },
|
||||||
set: { viewModel.updateQuickQuestionPrompt($0) }
|
set: { viewModel.updateQuickQuestionPrompt($0) }
|
||||||
),
|
),
|
||||||
axis: .vertical
|
selection: $promptSelection,
|
||||||
|
axis: .horizontal
|
||||||
)
|
)
|
||||||
.focused($promptFocused)
|
.focused($promptFocused)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.textInputAutocapitalization(.sentences)
|
.textInputAutocapitalization(.sentences)
|
||||||
.autocorrectionDisabled(false)
|
.autocorrectionDisabled(false)
|
||||||
.lineLimit(1 ... 6)
|
|
||||||
.submitLabel(.send)
|
.submitLabel(.send)
|
||||||
.onSubmit(submitQuestion)
|
.onSubmit(submitQuestion)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
@@ -259,11 +266,11 @@ private struct QuickQuestionPickerPill: View {
|
|||||||
private struct QuickQuestionMessageView: View {
|
private struct QuickQuestionMessageView: View {
|
||||||
var message: Message
|
var message: Message
|
||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
|
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
||||||
|
|
||||||
private var isPendingAssistant: Bool {
|
private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? {
|
||||||
message.id.hasPrefix("temp-assistant-quick-") &&
|
guard streamingAssistantPresentation?.messageID == message.id else { return nil }
|
||||||
isSending &&
|
return streamingAssistantPresentation
|
||||||
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -272,15 +279,12 @@ private struct QuickQuestionMessageView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
} else if isPendingAssistant {
|
} else if message.id.hasPrefix("temp-assistant-quick-") {
|
||||||
HStack(spacing: 8) {
|
StreamingAssistantContentView(
|
||||||
ProgressView()
|
content: message.content,
|
||||||
.controlSize(.small)
|
isSending: isSending,
|
||||||
.tint(SybilTheme.primary)
|
presentation: matchingStreamingAssistantPresentation
|
||||||
Text("Thinking...")
|
)
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
}
|
|
||||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
Markdown(message.content)
|
Markdown(message.content)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// A vertical TextField treats Return as editing, not form submission. Handle
|
||||||
|
// unmodified Return explicitly on Mac, leaving Shift+Return and other modified
|
||||||
|
// keys to the native text field. iOS keeps its software-keyboard behavior.
|
||||||
|
@MainActor
|
||||||
|
enum SybilReturnKeySubmission {
|
||||||
|
static func handle(
|
||||||
|
phase: KeyPress.Phases,
|
||||||
|
modifiers: EventModifiers,
|
||||||
|
submit: () -> Void
|
||||||
|
) -> KeyPress.Result {
|
||||||
|
guard modifiers.intersection([.shift, .command, .control, .option]).isEmpty else {
|
||||||
|
return .ignored
|
||||||
|
}
|
||||||
|
if phase == .down {
|
||||||
|
submit()
|
||||||
|
}
|
||||||
|
// Consume autorepeat without submitting or inserting a newline.
|
||||||
|
return .handled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
@ViewBuilder
|
||||||
|
func submitOnMacReturn(_ submit: @escaping () -> Void) -> some View {
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
onKeyPress(keys: [.return, KeyEquivalent("\u{3}")], phases: [.down, .repeat]) { press in
|
||||||
|
SybilReturnKeySubmission.handle(phase: press.phase, modifiers: press.modifiers, submit: submit)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
self
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,9 +52,9 @@ final class SybilSettingsStore {
|
|||||||
self.preferredModelByProvider = preferredModels
|
self.preferredModelByProvider = preferredModels
|
||||||
|
|
||||||
self.quickQuestionPreferredProvider =
|
self.quickQuestionPreferredProvider =
|
||||||
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? provider
|
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
|
||||||
self.quickQuestionPreferredModelByProvider = [
|
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",
|
.anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest",
|
||||||
.xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini",
|
.xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini",
|
||||||
.gemini: defaults.string(forKey: Keys.quickQuestionPreferredGeminiModel) ?? preferredModels[.gemini] ?? "gemini-3.5-flash",
|
.gemini: defaults.string(forKey: Keys.quickQuestionPreferredGeminiModel) ?? preferredModels[.gemini] ?? "gemini-3.5-flash",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct SybilSidebarView: View {
|
struct SybilSidebarView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
@@ -111,8 +112,10 @@ struct SybilSidebarItemList: View {
|
|||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
var isSelected: (SidebarItem) -> Bool
|
var isSelected: (SidebarItem) -> Bool
|
||||||
var onSelect: (SidebarItem) -> Void
|
var onSelect: (SidebarItem) -> Void
|
||||||
|
var onOpenForkedChat: ((String) -> Void)? = nil
|
||||||
@State private var renameTarget: SidebarItem?
|
@State private var renameTarget: SidebarItem?
|
||||||
@State private var renameTitle = ""
|
@State private var renameTitle = ""
|
||||||
|
@FocusState private var isSearchFocused: Bool
|
||||||
|
|
||||||
private var isRenameAlertPresented: Binding<Bool> {
|
private var isRenameAlertPresented: Binding<Bool> {
|
||||||
Binding {
|
Binding {
|
||||||
@@ -126,37 +129,98 @@ struct SybilSidebarItemList: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
TextField("Search chats and web searches", text: Binding(
|
||||||
|
get: { viewModel.sidebarSearchQuery },
|
||||||
|
set: { viewModel.setSidebarSearchQuery($0) }
|
||||||
|
))
|
||||||
|
.font(.sybil(.subheadline))
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.submitLabel(.search)
|
||||||
|
.focused($isSearchFocused)
|
||||||
|
.accessibilityLabel("Search conversation history and saved web searches")
|
||||||
|
.onSubmit {
|
||||||
|
isSearchFocused = false
|
||||||
|
Task { await viewModel.refreshSidebarSearch() }
|
||||||
|
}
|
||||||
|
|
||||||
|
if !viewModel.sidebarSearchQuery.isEmpty {
|
||||||
|
Button {
|
||||||
|
viewModel.setSidebarSearchQuery("")
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Clear history search")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(SybilTheme.surface, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(SybilTheme.border, lineWidth: 1))
|
||||||
|
.padding(10)
|
||||||
|
|
||||||
|
itemList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var itemList: some View {
|
||||||
Group {
|
Group {
|
||||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
if viewModel.isSearchingSidebar ||
|
||||||
|
(!viewModel.isSidebarSearchActive && viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
Text("Loading conversations…")
|
Text(viewModel.isSidebarSearchActive ? "Searching history…" : "Loading conversations…")
|
||||||
.font(.sybil(.footnote))
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.padding(16)
|
.padding(16)
|
||||||
} else if viewModel.sidebarItems.isEmpty {
|
} else if let error = viewModel.sidebarSearchError, viewModel.isSidebarSearchActive {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text(error)
|
||||||
|
.font(.sybil(.footnote))
|
||||||
|
.foregroundStyle(SybilTheme.danger)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Button("Try again") {
|
||||||
|
Task { await viewModel.refreshSidebarSearch() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding(16)
|
||||||
|
} else if viewModel.visibleSidebarItems.isEmpty {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Image(systemName: "message.badge")
|
Image(systemName: viewModel.isSidebarSearchActive ? "magnifyingglass" : "message.badge")
|
||||||
.font(.system(size: 20, weight: .medium))
|
.font(.system(size: 20, weight: .medium))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
Text("Start a chat or run your first search.")
|
Text(viewModel.isSidebarSearchActive ? "No matching chats or web searches." : "Start a chat or run your first search.")
|
||||||
.font(.sybil(.footnote))
|
.font(.sybil(.footnote))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
if viewModel.isSidebarSearchActive {
|
||||||
|
Text("Try a title, a query, or words from a conversation.")
|
||||||
|
.font(.sybil(.caption))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.padding(16)
|
.padding(16)
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(viewModel.sidebarItems) { item in
|
ForEach(viewModel.visibleSidebarItems) { item in
|
||||||
Button {
|
Button {
|
||||||
|
isSearchFocused = false
|
||||||
onSelect(item)
|
onSelect(item)
|
||||||
} label: {
|
} label: {
|
||||||
SybilSidebarRow(item: item, isSelected: isSelected(item))
|
SybilSidebarRow(item: item, isSelected: isSelected(item), searchQuery: viewModel.sidebarSearchQuery)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
@@ -168,13 +232,26 @@ struct SybilSidebarItemList: View {
|
|||||||
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
|
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 {
|
Button {
|
||||||
renameTarget = item
|
renameTarget = item
|
||||||
renameTitle = item.title
|
renameTitle = item.title
|
||||||
} label: {
|
} label: {
|
||||||
Label("Rename", systemImage: "pencil")
|
Label("Rename", systemImage: "pencil")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
UIPasteboard.general.string = chatID
|
||||||
|
} label: {
|
||||||
|
Label("Copy Session ID", systemImage: "doc.on.doc")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
@@ -186,9 +263,16 @@ struct SybilSidebarItemList: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if viewModel.isSidebarSearchActive && viewModel.sidebarSearchHasMore {
|
||||||
|
Text("Showing the 100 most recent matches. Refine your search to find more.")
|
||||||
|
.font(.sybil(.caption))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.padding(12)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(10)
|
.padding(10)
|
||||||
}
|
}
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
|
||||||
}
|
}
|
||||||
@@ -215,11 +299,52 @@ struct SybilSidebarItemList: View {
|
|||||||
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
.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 {
|
struct SybilSidebarRow: View {
|
||||||
var item: SidebarItem
|
var item: SidebarItem
|
||||||
var isSelected: Bool
|
var isSelected: Bool
|
||||||
|
var searchQuery = ""
|
||||||
|
|
||||||
|
private func highlightedSnippet(_ snippet: String) -> AttributedString {
|
||||||
|
var highlighted = AttributedString(snippet)
|
||||||
|
guard let words = try? NSRegularExpression(pattern: #"[\p{L}\p{N}\p{M}\p{Co}]+"#) else { return highlighted }
|
||||||
|
let terms = words.matches(in: searchQuery, range: NSRange(searchQuery.startIndex..., in: searchQuery)).compactMap { match -> String? in
|
||||||
|
guard let range = Range(match.range, in: searchQuery) else { return nil }
|
||||||
|
return String(searchQuery[range]).folding(options: [.diacriticInsensitive, .caseInsensitive], locale: Locale(identifier: "en_US_POSIX"))
|
||||||
|
}.filter { !$0.isEmpty }
|
||||||
|
for word in words.matches(in: snippet, range: NSRange(snippet.startIndex..., in: snippet)) {
|
||||||
|
guard let range = Range(word.range, in: snippet) else { continue }
|
||||||
|
let normalized = String(snippet[range]).folding(options: [.diacriticInsensitive, .caseInsensitive], locale: Locale(identifier: "en_US_POSIX"))
|
||||||
|
guard terms.contains(where: { normalized.hasPrefix($0) }),
|
||||||
|
let start = AttributedString.Index(range.lowerBound, within: highlighted),
|
||||||
|
let end = AttributedString.Index(range.upperBound, within: highlighted) else { continue }
|
||||||
|
highlighted[start..<end].font = .sybil(.caption, weight: .semibold)
|
||||||
|
}
|
||||||
|
return highlighted
|
||||||
|
}
|
||||||
|
|
||||||
private var isHighlighted: Bool {
|
private var isHighlighted: Bool {
|
||||||
isSelected
|
isSelected
|
||||||
@@ -281,6 +406,15 @@ struct SybilSidebarRow: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let snippet = item.searchSnippet, !snippet.isEmpty {
|
||||||
|
Text(highlightedSnippet(snippet.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)))
|
||||||
|
.font(.sybil(.caption))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.foregroundStyle(SybilTheme.text)
|
.foregroundStyle(SybilTheme.text)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
|
|||||||
@@ -38,11 +38,75 @@ struct SidebarItem: Identifiable, Hashable {
|
|||||||
var starredAt: Date?
|
var starredAt: Date?
|
||||||
var initiatedLabel: String?
|
var initiatedLabel: String?
|
||||||
var isRunning: Bool
|
var isRunning: Bool
|
||||||
|
var searchSnippet: String? = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
private struct PendingChatState {
|
||||||
var chatID: String?
|
var chatID: String?
|
||||||
var messages: [Message]
|
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 {
|
private enum ActiveSendContext: Hashable {
|
||||||
@@ -98,6 +162,15 @@ final class SybilViewModel {
|
|||||||
var chats: [ChatSummary] = []
|
var chats: [ChatSummary] = []
|
||||||
var searches: [SearchSummary] = []
|
var searches: [SearchSummary] = []
|
||||||
var workspaceItems: [WorkspaceItem] = []
|
var workspaceItems: [WorkspaceItem] = []
|
||||||
|
private(set) var sidebarSearchQuery = ""
|
||||||
|
private(set) var sidebarSearchResults: [WorkspaceItem] = []
|
||||||
|
private(set) var isSearchingSidebar = false
|
||||||
|
private(set) var sidebarSearchError: String?
|
||||||
|
private(set) var sidebarSearchHasMore = false
|
||||||
|
@ObservationIgnored
|
||||||
|
private var sidebarSearchTask: Task<Void, Never>?
|
||||||
|
@ObservationIgnored
|
||||||
|
private var sidebarSearchRequestID = UUID()
|
||||||
|
|
||||||
var selectedItem: SidebarSelection?
|
var selectedItem: SidebarSelection?
|
||||||
var selectedChat: ChatDetail?
|
var selectedChat: ChatDetail?
|
||||||
@@ -107,6 +180,7 @@ final class SybilViewModel {
|
|||||||
var isLoadingCollections = false
|
var isLoadingCollections = false
|
||||||
var isLoadingSelection = false
|
var isLoadingSelection = false
|
||||||
var isCreatingSearchChat = false
|
var isCreatingSearchChat = false
|
||||||
|
var isForkingChat = false
|
||||||
var chatBottomPinRequestID = 0
|
var chatBottomPinRequestID = 0
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
|
|
||||||
@@ -125,6 +199,7 @@ final class SybilViewModel {
|
|||||||
var quickQuestionSubmittedModel: String?
|
var quickQuestionSubmittedModel: String?
|
||||||
var isQuickQuestionSending = false
|
var isQuickQuestionSending = false
|
||||||
var isConvertingQuickQuestion = false
|
var isConvertingQuickQuestion = false
|
||||||
|
private var quickQuestionStreamingAssistantState = StreamingAssistantState()
|
||||||
|
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var hasBootstrapped = false
|
private var hasBootstrapped = false
|
||||||
@@ -157,7 +232,7 @@ final class SybilViewModel {
|
|||||||
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
|
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
|
||||||
|
|
||||||
private let fallbackModels: [Provider: [String]] = [
|
private let fallbackModels: [Provider: [String]] = [
|
||||||
.openai: ["gpt-4.1-mini"],
|
.openai: ["gpt-4.1-mini", "chat-latest"],
|
||||||
.anthropic: ["claude-3-5-sonnet-latest"],
|
.anthropic: ["claude-3-5-sonnet-latest"],
|
||||||
.xai: ["grok-3-mini"],
|
.xai: ["grok-3-mini"],
|
||||||
.gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
.gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
||||||
@@ -360,6 +435,30 @@ final class SybilViewModel {
|
|||||||
return canonical
|
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? {
|
var displayedSearch: SearchDetail? {
|
||||||
if case let .search(searchID) = selectedItem,
|
if case let .search(searchID) = selectedItem,
|
||||||
let activeSearch = activeSearchDetails[searchID] {
|
let activeSearch = activeSearchDetails[searchID] {
|
||||||
@@ -393,7 +492,19 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sidebarItems: [SidebarItem] {
|
var sidebarItems: [SidebarItem] {
|
||||||
workspaceItems.map { item in
|
makeSidebarItems(workspaceItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isSidebarSearchActive: Bool {
|
||||||
|
!sidebarSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var visibleSidebarItems: [SidebarItem] {
|
||||||
|
isSidebarSearchActive ? makeSidebarItems(sidebarSearchResults) : sidebarItems
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeSidebarItems(_ items: [WorkspaceItem]) -> [SidebarItem] {
|
||||||
|
items.map { item in
|
||||||
switch item.type {
|
switch item.type {
|
||||||
case .chat:
|
case .chat:
|
||||||
let initiatedLabel: String?
|
let initiatedLabel: String?
|
||||||
@@ -407,7 +518,8 @@ final class SybilViewModel {
|
|||||||
initiatedLabel = nil
|
initiatedLabel = nil
|
||||||
}
|
}
|
||||||
let starOwner = item.parentChatId.flatMap { parentChatID in
|
let starOwner = item.parentChatId.flatMap { parentChatID in
|
||||||
workspaceItems.first(where: { $0.type == .chat && $0.id == parentChatID })
|
workspaceItems.first(where: { $0.type == .chat && $0.id == parentChatID }) ??
|
||||||
|
sidebarSearchResults.first(where: { $0.type == .chat && $0.id == parentChatID })
|
||||||
} ?? item
|
} ?? item
|
||||||
|
|
||||||
return SidebarItem(
|
return SidebarItem(
|
||||||
@@ -418,7 +530,8 @@ final class SybilViewModel {
|
|||||||
starred: starOwner.starred,
|
starred: starOwner.starred,
|
||||||
starredAt: starOwner.starredAt,
|
starredAt: starOwner.starredAt,
|
||||||
initiatedLabel: initiatedLabel,
|
initiatedLabel: initiatedLabel,
|
||||||
isRunning: isChatRowRunning(item.id)
|
isRunning: isChatRowRunning(item.id),
|
||||||
|
searchSnippet: item.searchSnippet
|
||||||
)
|
)
|
||||||
|
|
||||||
case .search:
|
case .search:
|
||||||
@@ -430,20 +543,70 @@ final class SybilViewModel {
|
|||||||
starred: item.starred,
|
starred: item.starred,
|
||||||
starredAt: item.starredAt,
|
starredAt: item.starredAt,
|
||||||
initiatedLabel: "exa",
|
initiatedLabel: "exa",
|
||||||
isRunning: isSearchRowRunning(item.id)
|
isRunning: isSearchRowRunning(item.id),
|
||||||
|
searchSnippet: item.searchSnippet
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setSidebarSearchQuery(_ query: String) {
|
||||||
|
let previous = sidebarSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
sidebarSearchQuery = query
|
||||||
|
guard query.trimmingCharacters(in: .whitespacesAndNewlines) != previous else { return }
|
||||||
|
scheduleSidebarSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshSidebarSearch() async {
|
||||||
|
guard isSidebarSearchActive else { return }
|
||||||
|
scheduleSidebarSearch(debounce: false)
|
||||||
|
await sidebarSearchTask?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleSidebarSearch(debounce: Bool = true) {
|
||||||
|
sidebarSearchTask?.cancel()
|
||||||
|
let requestID = UUID()
|
||||||
|
sidebarSearchRequestID = requestID
|
||||||
|
sidebarSearchResults = []
|
||||||
|
sidebarSearchError = nil
|
||||||
|
sidebarSearchHasMore = false
|
||||||
|
isSearchingSidebar = false
|
||||||
|
let query = sidebarSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !query.isEmpty, isAuthenticated else { return }
|
||||||
|
guard query.utf16.count <= 200 else {
|
||||||
|
sidebarSearchError = "Use 200 characters or fewer."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isSearchingSidebar = true
|
||||||
|
sidebarSearchTask = Task { [weak self] in
|
||||||
|
do {
|
||||||
|
if debounce { try await Task.sleep(for: .milliseconds(300)) }
|
||||||
|
guard let self, !Task.isCancelled else { return }
|
||||||
|
let response = try await self.client().searchWorkspaceItems(query: query)
|
||||||
|
guard !Task.isCancelled, self.sidebarSearchRequestID == requestID else { return }
|
||||||
|
self.sidebarSearchResults = response.items
|
||||||
|
self.sidebarSearchHasMore = response.hasMore
|
||||||
|
self.isSearchingSidebar = false
|
||||||
|
} catch {
|
||||||
|
guard let self, !Task.isCancelled, self.sidebarSearchRequestID == requestID else { return }
|
||||||
|
self.isSearchingSidebar = false
|
||||||
|
if !self.isCancellation(error) {
|
||||||
|
self.sidebarSearchError = self.normalizeAPIError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var selectedChatSummary: ChatSummary? {
|
var selectedChatSummary: ChatSummary? {
|
||||||
guard case let .chat(chatID) = selectedItem else { return nil }
|
guard case let .chat(chatID) = selectedItem else { return nil }
|
||||||
return chats.first(where: { $0.id == chatID })
|
return chats.first(where: { $0.id == chatID }) ??
|
||||||
|
sidebarSearchResults.first(where: { $0.type == .chat && $0.id == chatID })?.chatSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectedSearchSummary: SearchSummary? {
|
var selectedSearchSummary: SearchSummary? {
|
||||||
guard case let .search(searchID) = selectedItem else { return nil }
|
guard case let .search(searchID) = selectedItem else { return nil }
|
||||||
return searches.first(where: { $0.id == searchID })
|
return searches.first(where: { $0.id == searchID }) ??
|
||||||
|
sidebarSearchResults.first(where: { $0.type == .search && $0.id == searchID })?.searchSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasRefreshableSelection: Bool {
|
var hasRefreshableSelection: Bool {
|
||||||
@@ -470,6 +633,7 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func reconnect() async {
|
func reconnect() async {
|
||||||
|
setSidebarSearchQuery("")
|
||||||
isCheckingSession = true
|
isCheckingSession = true
|
||||||
authError = nil
|
authError = nil
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
@@ -620,6 +784,7 @@ final class SybilViewModel {
|
|||||||
quickQuestionTask = nil
|
quickQuestionTask = nil
|
||||||
quickQuestionRunID = nil
|
quickQuestionRunID = nil
|
||||||
isQuickQuestionSending = false
|
isQuickQuestionSending = false
|
||||||
|
quickQuestionStreamingAssistantState = StreamingAssistantState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
@@ -636,9 +801,12 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cancelQuickQuestion()
|
cancelQuickQuestion()
|
||||||
|
// Lock submission before the task starts, including repeated Return
|
||||||
|
// events delivered in the same main-actor turn.
|
||||||
|
isQuickQuestionSending = true
|
||||||
let selectedProvider = quickQuestionProvider
|
let selectedProvider = quickQuestionProvider
|
||||||
let task = Task { [weak self] in
|
let task = Task { [weak self] in
|
||||||
guard let self else {
|
guard let self, !Task.isCancelled else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
|
await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
|
||||||
@@ -772,6 +940,15 @@ final class SybilViewModel {
|
|||||||
private func beginSelecting(_ selection: SidebarSelection) -> Task<Void, Never>? {
|
private func beginSelecting(_ selection: SidebarSelection) -> Task<Void, Never>? {
|
||||||
SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)")
|
SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)")
|
||||||
|
|
||||||
|
// Search can open history outside the recent collection. Retain its
|
||||||
|
// summary for provider settings, starring, and subsequent refreshes.
|
||||||
|
if let item = sidebarSearchResults.first(where: {
|
||||||
|
($0.type == .chat ? SidebarSelection.chat($0.id) : .search($0.id)) == selection
|
||||||
|
}) {
|
||||||
|
if let chat = item.chatSummary { applyChatSummary(chat, moveToFront: false) }
|
||||||
|
if let search = item.searchSummary { applySearchSummary(search, moveToFront: false) }
|
||||||
|
}
|
||||||
|
|
||||||
if draftKind == nil, selectedItem == selection {
|
if draftKind == nil, selectedItem == selection {
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
if case .search = selection {
|
if case .search = selection {
|
||||||
@@ -822,7 +999,7 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func selectAdjacentSidebarItem(offset: Int) {
|
private func selectAdjacentSidebarItem(offset: Int) {
|
||||||
let items = sidebarItems
|
let items = visibleSidebarItems
|
||||||
guard !items.isEmpty else {
|
guard !items.isEmpty else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -858,6 +1035,7 @@ final class SybilViewModel {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
await refreshCollections(preferredSelection: nil)
|
await refreshCollections(preferredSelection: nil)
|
||||||
|
await refreshSidebarSearch()
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = normalizeAPIError(error)
|
errorMessage = normalizeAPIError(error)
|
||||||
SybilLog.error(SybilLog.ui, "Delete failed", error: error)
|
SybilLog.error(SybilLog.ui, "Delete failed", error: error)
|
||||||
@@ -866,6 +1044,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 {
|
func renameChat(chatID: String, title: String) async {
|
||||||
guard isAuthenticated else {
|
guard isAuthenticated else {
|
||||||
return
|
return
|
||||||
@@ -883,6 +1091,7 @@ final class SybilViewModel {
|
|||||||
do {
|
do {
|
||||||
let updated = try await client().updateChatTitle(chatID: chatID, title: trimmedTitle)
|
let updated = try await client().updateChatTitle(chatID: chatID, title: trimmedTitle)
|
||||||
applyChatSummary(updated, moveToFront: true)
|
applyChatSummary(updated, moveToFront: true)
|
||||||
|
await refreshSidebarSearch()
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = normalizeAPIError(error)
|
errorMessage = normalizeAPIError(error)
|
||||||
SybilLog.error(SybilLog.ui, "Rename failed", error: error)
|
SybilLog.error(SybilLog.ui, "Rename failed", error: error)
|
||||||
@@ -982,6 +1191,10 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func refreshSidebarCollectionsFromPullToRefresh() async {
|
func refreshSidebarCollectionsFromPullToRefresh() async {
|
||||||
|
if isSidebarSearchActive {
|
||||||
|
await refreshSidebarSearch()
|
||||||
|
return
|
||||||
|
}
|
||||||
guard isAuthenticated, !isCheckingSession else {
|
guard isAuthenticated, !isCheckingSession else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1137,6 +1350,7 @@ final class SybilViewModel {
|
|||||||
quickQuestionSubmittedPrompt = prompt
|
quickQuestionSubmittedPrompt = prompt
|
||||||
quickQuestionSubmittedProvider = provider
|
quickQuestionSubmittedProvider = provider
|
||||||
quickQuestionSubmittedModel = model
|
quickQuestionSubmittedModel = model
|
||||||
|
quickQuestionStreamingAssistantState = StreamingAssistantState()
|
||||||
quickQuestionMessages = [
|
quickQuestionMessages = [
|
||||||
Message(
|
Message(
|
||||||
id: "temp-assistant-quick-\(UUID().uuidString)",
|
id: "temp-assistant-quick-\(UUID().uuidString)",
|
||||||
@@ -1159,13 +1373,11 @@ final class SybilViewModel {
|
|||||||
let streamStatus = CompletionStreamStatus()
|
let streamStatus = CompletionStreamStatus()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await client().runCompletionStream(
|
try await client().runQuickQuestionStream(
|
||||||
body: CompletionStreamRequest(
|
body: QuickQuestionStreamRequest(
|
||||||
chatId: nil,
|
|
||||||
persist: false,
|
|
||||||
provider: provider,
|
provider: provider,
|
||||||
model: model,
|
model: model,
|
||||||
messages: [CompletionRequestMessage(role: .user, content: prompt)]
|
question: prompt
|
||||||
)
|
)
|
||||||
) { [weak self] event in
|
) { [weak self] event in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -1183,6 +1395,8 @@ final class SybilViewModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let update = quickQuestionStreamingAssistantState.receiveError()
|
||||||
|
applyQuickQuestionAssistantContentUpdate(update)
|
||||||
quickQuestionError = normalizeAPIError(error)
|
quickQuestionError = normalizeAPIError(error)
|
||||||
SybilLog.error(SybilLog.ui, "Quick question failed", error: error)
|
SybilLog.error(SybilLog.ui, "Quick question failed", error: error)
|
||||||
}
|
}
|
||||||
@@ -1197,17 +1411,16 @@ final class SybilViewModel {
|
|||||||
upsertQuickQuestionToolCallMessage(payload)
|
upsertQuickQuestionToolCallMessage(payload)
|
||||||
|
|
||||||
case let .delta(payload):
|
case let .delta(payload):
|
||||||
guard !payload.text.isEmpty else { return }
|
let update = quickQuestionStreamingAssistantState.receiveDelta(payload.text)
|
||||||
mutateQuickQuestionAssistantMessage { existing in
|
applyQuickQuestionAssistantContentUpdate(update)
|
||||||
existing + payload.text
|
|
||||||
}
|
|
||||||
|
|
||||||
case let .done(payload):
|
case let .done(payload):
|
||||||
mutateQuickQuestionAssistantMessage { _ in
|
let update = quickQuestionStreamingAssistantState.receiveDone(payload.text)
|
||||||
payload.text
|
applyQuickQuestionAssistantContentUpdate(update)
|
||||||
}
|
|
||||||
|
|
||||||
case let .error(payload):
|
case let .error(payload):
|
||||||
|
let update = quickQuestionStreamingAssistantState.receiveError()
|
||||||
|
applyQuickQuestionAssistantContentUpdate(update)
|
||||||
await streamStatus.setError(payload.message)
|
await streamStatus.setError(payload.message)
|
||||||
|
|
||||||
case .ignored:
|
case .ignored:
|
||||||
@@ -1322,7 +1535,15 @@ final class SybilViewModel {
|
|||||||
async let activeRunsValue = client.getActiveRuns()
|
async let activeRunsValue = client.getActiveRuns()
|
||||||
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
|
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
|
||||||
|
|
||||||
applyWorkspaceItems(nextWorkspaceItems)
|
var items = nextWorkspaceItems
|
||||||
|
// A selected historical search result may be outside the latest 100.
|
||||||
|
if let preferredSelection,
|
||||||
|
let selectedSummary = workspaceItems.first(where: {
|
||||||
|
($0.type == .chat ? SidebarSelection.chat($0.id) : .search($0.id)) == preferredSelection
|
||||||
|
}), !items.contains(where: { $0.type == selectedSummary.type && $0.id == selectedSummary.id }) {
|
||||||
|
items.append(selectedSummary)
|
||||||
|
}
|
||||||
|
applyWorkspaceItems(items)
|
||||||
applyActiveRuns(nextActiveRuns)
|
applyActiveRuns(nextActiveRuns)
|
||||||
|
|
||||||
SybilLog.info(
|
SybilLog.info(
|
||||||
@@ -1500,6 +1721,11 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func upsertWorkspaceItem(_ item: WorkspaceItem, moveToFront: Bool) {
|
private func upsertWorkspaceItem(_ item: WorkspaceItem, moveToFront: Bool) {
|
||||||
|
if let index = sidebarSearchResults.firstIndex(where: { $0.type == item.type && $0.id == item.id }) {
|
||||||
|
var updated = item
|
||||||
|
updated.searchSnippet = sidebarSearchResults[index].searchSnippet
|
||||||
|
sidebarSearchResults[index] = updated
|
||||||
|
}
|
||||||
if let existingIndex = workspaceItems.firstIndex(where: { $0.type == item.type && $0.id == item.id }) {
|
if let existingIndex = workspaceItems.firstIndex(where: { $0.type == item.type && $0.id == item.id }) {
|
||||||
workspaceItems.remove(at: existingIndex)
|
workspaceItems.remove(at: existingIndex)
|
||||||
if moveToFront {
|
if moveToFront {
|
||||||
@@ -1523,6 +1749,9 @@ final class SybilViewModel {
|
|||||||
if let parentChatID = workspaceItems.first(where: { $0.type == .chat && $0.id == chatID })?.parentChatId {
|
if let parentChatID = workspaceItems.first(where: { $0.type == .chat && $0.id == chatID })?.parentChatId {
|
||||||
return parentChatID
|
return parentChatID
|
||||||
}
|
}
|
||||||
|
if let parentChatID = sidebarSearchResults.first(where: { $0.type == .chat && $0.id == chatID })?.parentChatId {
|
||||||
|
return parentChatID
|
||||||
|
}
|
||||||
return chatID
|
return chatID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1606,21 +1835,28 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serverActiveChatIDs.remove(chatID)
|
serverActiveChatIDs.remove(chatID)
|
||||||
pendingChatStates[chatID] = nil
|
|
||||||
await refreshCollections(preferredSelection: selectedItem, refreshSelection: false, attachVisibleActiveRun: false)
|
await refreshCollections(preferredSelection: selectedItem, refreshSelection: false, attachVisibleActiveRun: false)
|
||||||
|
|
||||||
if selectedItem == selection, draftKind == nil {
|
if selectedItem == selection, draftKind == nil {
|
||||||
selectedChat = try await client.getChat(chatID: chatID)
|
let refreshedChat = try await client.getChat(chatID: chatID)
|
||||||
|
if selectedItem == selection, draftKind == nil {
|
||||||
|
selectedChat = refreshedChat
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
pendingChatStates[chatID] = nil
|
||||||
} catch {
|
} catch {
|
||||||
serverActiveChatIDs.remove(chatID)
|
serverActiveChatIDs.remove(chatID)
|
||||||
|
defer {
|
||||||
pendingChatStates[chatID] = nil
|
pendingChatStates[chatID] = nil
|
||||||
|
}
|
||||||
|
|
||||||
if isCancellation(error) {
|
if isCancellation(error) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyPendingAssistantError(chatID: chatID)
|
||||||
|
|
||||||
if isActiveStreamNotFound(error) {
|
if isActiveStreamNotFound(error) {
|
||||||
SybilLog.info(SybilLog.app, "Active chat stream \(chatID) no longer exists")
|
SybilLog.info(SybilLog.app, "Active chat stream \(chatID) no longer exists")
|
||||||
} else if shouldSuppressInactiveTransportError(error) {
|
} else if shouldSuppressInactiveTransportError(error) {
|
||||||
@@ -1634,8 +1870,11 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
if selectedItem == selection, draftKind == nil {
|
if selectedItem == selection, draftKind == nil {
|
||||||
do {
|
do {
|
||||||
selectedChat = try await client().getChat(chatID: chatID)
|
let refreshedChat = try await client().getChat(chatID: chatID)
|
||||||
|
if selectedItem == selection, draftKind == nil {
|
||||||
|
selectedChat = refreshedChat
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SybilLog.warning(SybilLog.app, "Chat refresh after attach failure failed: \(SybilLog.describe(error))")
|
SybilLog.warning(SybilLog.app, "Chat refresh after attach failure failed: \(SybilLog.describe(error))")
|
||||||
}
|
}
|
||||||
@@ -1855,6 +2094,13 @@ final class SybilViewModel {
|
|||||||
} else {
|
} else {
|
||||||
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
||||||
}
|
}
|
||||||
|
defer {
|
||||||
|
if let resolvedChatID = chatID {
|
||||||
|
pendingChatStates[resolvedChatID] = nil
|
||||||
|
} else {
|
||||||
|
clearPendingChatState(for: sendContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
requestChatBottomPin()
|
requestChatBottomPin()
|
||||||
|
|
||||||
if chatID == nil {
|
if chatID == nil {
|
||||||
@@ -2044,17 +2290,13 @@ final class SybilViewModel {
|
|||||||
upsertPendingToolCallMessage(payload, chatID: chatID)
|
upsertPendingToolCallMessage(payload, chatID: chatID)
|
||||||
|
|
||||||
case let .delta(payload):
|
case let .delta(payload):
|
||||||
guard !payload.text.isEmpty else { return }
|
applyPendingAssistantDelta(payload.text, chatID: chatID)
|
||||||
mutatePendingAssistantMessage(chatID: chatID) { existing in
|
|
||||||
existing + payload.text
|
|
||||||
}
|
|
||||||
|
|
||||||
case let .done(payload):
|
case let .done(payload):
|
||||||
mutatePendingAssistantMessage(chatID: chatID) { _ in
|
finalizePendingAssistant(payload.text, chatID: chatID)
|
||||||
payload.text
|
|
||||||
}
|
|
||||||
|
|
||||||
case let .error(payload):
|
case let .error(payload):
|
||||||
|
applyPendingAssistantError(chatID: chatID)
|
||||||
await streamStatus.setError(payload.message)
|
await streamStatus.setError(payload.message)
|
||||||
|
|
||||||
case .ignored:
|
case .ignored:
|
||||||
@@ -2232,29 +2474,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 {
|
guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let index = pending.messages.indices.last { pending.messages[$0].id.hasPrefix("temp-assistant-") }
|
let update = pending.streamingAssistantState.receiveDelta(text)
|
||||||
guard let index else {
|
applyStreamingAssistantContentUpdate(
|
||||||
return
|
update,
|
||||||
}
|
to: &pending.messages,
|
||||||
|
assistantMessagePrefix: "temp-assistant-"
|
||||||
var message = pending.messages[index]
|
)
|
||||||
message.content = transform(message.content)
|
|
||||||
pending.messages[index] = message
|
|
||||||
pendingChatStates[chatID] = pending
|
pendingChatStates[chatID] = pending
|
||||||
}
|
}
|
||||||
|
|
||||||
private func mutateQuickQuestionAssistantMessage(_ transform: (String) -> String) {
|
private func finalizePendingAssistant(_ text: String, chatID: String) {
|
||||||
let index = quickQuestionMessages.indices.last { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }
|
guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
|
||||||
guard let index else {
|
|
||||||
return
|
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) {
|
private func upsertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
|
||||||
@@ -2262,6 +2543,16 @@ final class SybilViewModel {
|
|||||||
return
|
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)" }) {
|
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)
|
pending.messages[existingIndex] = toolCallMessage(for: payload, id: pending.messages[existingIndex].id)
|
||||||
pendingChatStates[chatID] = pending
|
pendingChatStates[chatID] = pending
|
||||||
@@ -2280,6 +2571,12 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func upsertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
|
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)" }) {
|
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)
|
quickQuestionMessages[existingIndex] = toolCallMessage(for: payload, id: quickQuestionMessages[existingIndex].id)
|
||||||
return
|
return
|
||||||
@@ -2429,7 +2726,8 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
private func clearPendingChatState(for context: ActiveSendContext) {
|
private func clearPendingChatState(for context: ActiveSendContext) {
|
||||||
switch context {
|
switch context {
|
||||||
case .draftChat:
|
case let .draftChat(identity):
|
||||||
|
guard draftKind == .chat, draftIdentity == identity else { return }
|
||||||
pendingDraftChatState = nil
|
pendingDraftChatState = nil
|
||||||
case let .chat(chatID):
|
case let .chat(chatID):
|
||||||
pendingChatStates[chatID] = nil
|
pendingChatStates[chatID] = nil
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import ImageIO
|
|
||||||
import Observation
|
import Observation
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -19,6 +18,7 @@ struct SybilWorkspaceView: View {
|
|||||||
var onShowSidebar: (() -> Void)? = nil
|
var onShowSidebar: (() -> Void)? = nil
|
||||||
var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil
|
var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil
|
||||||
var onRequestNewChat: (() -> Void)? = nil
|
var onRequestNewChat: (() -> Void)? = nil
|
||||||
|
var onOpenForkedChat: ((String) -> Void)? = nil
|
||||||
@FocusState private var composerFocused: Bool
|
@FocusState private var composerFocused: Bool
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var isShowingAttachmentOptions = false
|
@State private var isShowingAttachmentOptions = false
|
||||||
@@ -155,8 +155,6 @@ struct SybilWorkspaceView: View {
|
|||||||
workspaceContentStack
|
workspaceContentStack
|
||||||
|
|
||||||
if showsCustomWorkspaceNavigation {
|
if showsCustomWorkspaceNavigation {
|
||||||
SybilWorkspaceCharacterBackdrop(isBusy: viewModel.isActiveSelectionSending)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
customWorkspaceNavigationBar
|
customWorkspaceNavigationBar
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,9 +191,12 @@ struct SybilWorkspaceView: View {
|
|||||||
messages: viewModel.displayedMessages,
|
messages: viewModel.displayedMessages,
|
||||||
isLoading: viewModel.isLoadingSelection,
|
isLoading: viewModel.isLoadingSelection,
|
||||||
isSending: viewModel.isSendingVisibleChat,
|
isSending: viewModel.isSendingVisibleChat,
|
||||||
|
streamingAssistantPresentation: viewModel.displayedStreamingAssistantPresentation,
|
||||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
||||||
bottomPinRequestID: viewModel.chatBottomPinRequestID
|
bottomPinRequestID: viewModel.chatBottomPinRequestID,
|
||||||
|
isForking: viewModel.isForkingChat,
|
||||||
|
onForkResponse: forkResponse
|
||||||
)
|
)
|
||||||
.id(transcriptScrollContextID)
|
.id(transcriptScrollContextID)
|
||||||
}
|
}
|
||||||
@@ -229,6 +230,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 {
|
private var customWorkspaceNavigationBar: some View {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
workspaceNavigationLeadingControl
|
workspaceNavigationLeadingControl
|
||||||
@@ -601,6 +624,7 @@ struct SybilWorkspaceView: View {
|
|||||||
.onSubmit {
|
.onSubmit {
|
||||||
submitComposer()
|
submitComposer()
|
||||||
}
|
}
|
||||||
|
.submitOnMacReturn(submitComposer)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.background(
|
.background(
|
||||||
@@ -1348,173 +1372,6 @@ private struct SybilNavigationFadeBackground: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SybilWorkspaceCharacterBackdrop: View {
|
|
||||||
var isBusy: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack(alignment: .topTrailing) {
|
|
||||||
RadialGradient(
|
|
||||||
colors: [
|
|
||||||
SybilTheme.primary.opacity(0.36),
|
|
||||||
SybilTheme.primary.opacity(0.13),
|
|
||||||
Color.clear
|
|
||||||
],
|
|
||||||
center: .center,
|
|
||||||
startRadius: 10,
|
|
||||||
endRadius: 150
|
|
||||||
)
|
|
||||||
.frame(width: 136, height: 118)
|
|
||||||
.blur(radius: 7)
|
|
||||||
.offset(x: 28, y: -24)
|
|
||||||
|
|
||||||
SybilAnimatedGIFView(resourceName: isBusy ? "character-busy" : "character-idle")
|
|
||||||
.frame(width: 172, height: 172)
|
|
||||||
.opacity(0.92)
|
|
||||||
.mask {
|
|
||||||
LinearGradient(
|
|
||||||
colors: [
|
|
||||||
Color.black,
|
|
||||||
Color.black,
|
|
||||||
Color.black.opacity(0)
|
|
||||||
],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.mask {
|
|
||||||
LinearGradient(
|
|
||||||
colors: [
|
|
||||||
Color.clear,
|
|
||||||
Color.black.opacity(0.98),
|
|
||||||
Color.black
|
|
||||||
],
|
|
||||||
startPoint: .leading,
|
|
||||||
endPoint: .trailing
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.offset(x: 8, y: 4)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, minHeight: 156, maxHeight: 156, alignment: .topTrailing)
|
|
||||||
.clipped()
|
|
||||||
.ignoresSafeArea(edges: .top)
|
|
||||||
.accessibilityHidden(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SybilAnimatedGIFView: UIViewRepresentable {
|
|
||||||
var resourceName: String
|
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
|
||||||
Coordinator()
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UIImageView {
|
|
||||||
let imageView = SybilAnimatedUIImageView()
|
|
||||||
imageView.backgroundColor = .clear
|
|
||||||
imageView.contentMode = .scaleAspectFill
|
|
||||||
imageView.clipsToBounds = false
|
|
||||||
imageView.isOpaque = false
|
|
||||||
imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
||||||
imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
|
||||||
imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
||||||
imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
||||||
return imageView
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_ imageView: UIImageView, context: Context) {
|
|
||||||
guard context.coordinator.resourceName != resourceName else {
|
|
||||||
if !imageView.isAnimating {
|
|
||||||
imageView.startAnimating()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
context.coordinator.resourceName = resourceName
|
|
||||||
imageView.image = SybilAnimatedGIFCache.image(named: resourceName)
|
|
||||||
imageView.startAnimating()
|
|
||||||
}
|
|
||||||
|
|
||||||
final class Coordinator {
|
|
||||||
var resourceName: String?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class SybilAnimatedUIImageView: UIImageView {
|
|
||||||
override var intrinsicContentSize: CGSize {
|
|
||||||
CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private enum SybilAnimatedGIFCache {
|
|
||||||
private static var images: [String: UIImage] = [:]
|
|
||||||
|
|
||||||
static func image(named name: String) -> UIImage? {
|
|
||||||
if let cached = images[name] {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let image = loadImage(named: name) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
images[name] = image
|
|
||||||
|
|
||||||
return image
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func loadImage(named name: String) -> UIImage? {
|
|
||||||
let url = Bundle.main.url(forResource: name, withExtension: "gif", subdirectory: "Character") ??
|
|
||||||
Bundle.main.url(forResource: name, withExtension: "gif")
|
|
||||||
|
|
||||||
guard let url,
|
|
||||||
let data = try? Data(contentsOf: url),
|
|
||||||
let source = CGImageSourceCreateWithData(data as CFData, nil)
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let frameCount = CGImageSourceGetCount(source)
|
|
||||||
guard frameCount > 0 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var frames: [UIImage] = []
|
|
||||||
frames.reserveCapacity(frameCount)
|
|
||||||
var duration: TimeInterval = 0
|
|
||||||
|
|
||||||
for index in 0 ..< frameCount {
|
|
||||||
guard let cgImage = CGImageSourceCreateImageAtIndex(source, index, nil) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
frames.append(UIImage(cgImage: cgImage))
|
|
||||||
duration += frameDuration(at: index, source: source)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !frames.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if frames.count == 1 {
|
|
||||||
return frames[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
return UIImage.animatedImage(with: frames, duration: max(duration, 0.1))
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func frameDuration(at index: Int, source: CGImageSource) -> TimeInterval {
|
|
||||||
guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any],
|
|
||||||
let gifProperties = properties[kCGImagePropertyGIFDictionary] as? [CFString: Any]
|
|
||||||
else {
|
|
||||||
return 0.08
|
|
||||||
}
|
|
||||||
|
|
||||||
let unclampedDelay = gifProperties[kCGImagePropertyGIFUnclampedDelayTime] as? TimeInterval
|
|
||||||
let delay = unclampedDelay ?? gifProperties[kCGImagePropertyGIFDelayTime] as? TimeInterval ?? 0.08
|
|
||||||
return max(delay, 0.03)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct NewChatSwipeBackdrop: View {
|
private struct NewChatSwipeBackdrop: View {
|
||||||
var progress: CGFloat
|
var progress: CGFloat
|
||||||
var hasLatched: Bool
|
var hasLatched: Bool
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
|||||||
|
# Sybil for iOS and Mac Catalyst
|
||||||
|
|
||||||
|
Run `just run` for the iPhone simulator, or `just run-mac` for Mac Catalyst.
|
||||||
|
`just build-mac` builds without launching the Mac app. Both regenerate the Xcode
|
||||||
|
project from `project.yml`. The Mac recipes use ad-hoc signing for local development;
|
||||||
|
release/archive signing settings are unchanged.
|
||||||
|
|
||||||
|
The conversation sidebar includes **Search chats and web searches** on iPhone,
|
||||||
|
iPad, and Mac. It searches all saved history by title, conversation text, and saved
|
||||||
|
web-search query/answer, with single-line excerpts that bold matching words and
|
||||||
|
truncate in the middle. Search waits briefly while typing,
|
||||||
|
cancels superseded requests, and leaves the current conversation open. Clear the
|
||||||
|
field to return to recent items. Searches use word prefixes (for example, `plan`
|
||||||
|
finds `planning`); if more than 100 items match, refine the query.
|
||||||
|
|
||||||
|
## Quick Question on macOS
|
||||||
|
|
||||||
|
While the Catalyst app is running, **Option+Space** toggles a floating Quick
|
||||||
|
Question panel, including over other applications. The prompt is focused as soon
|
||||||
|
as it opens, with any existing prompt selected so typing replaces it. The same
|
||||||
|
select-on-open behavior applies to Quick Question on iPhone and iPad.
|
||||||
|
**Return** submits; **Shift+Return** inserts a new line. **Escape**, a
|
||||||
|
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. 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
|
||||||
|
build opts out of automatic termination, and the native shortcut service holds a
|
||||||
|
matching automatic-termination activity until it stops. No hidden keep-alive
|
||||||
|
window or sleep-preventing activity is used. Mac scene support also allows the
|
||||||
|
workspace to reopen after its last window is closed.
|
||||||
|
|
||||||
|
When Catalyst is backgrounded, opening Quick Question first requests app
|
||||||
|
activation and waits for UIKit to enter the foreground before creating or ordering
|
||||||
|
the native panel. Presentation is deferred out of the hotkey event callback.
|
||||||
|
Pressing Option+Space again cancels a pending presentation. Dismissing a panel
|
||||||
|
that required activation returns focus to the previous app; explicitly opening
|
||||||
|
the workspace keeps focus in Sybil.
|
||||||
|
|
||||||
|
On Mac, **Command+N** starts a new chat in the focused workspace, or opens a new
|
||||||
|
workspace window when none are open. **Command+Shift+N** always opens a new window,
|
||||||
|
including while signed out. **Command+Option+N** starts a new search; iOS retains
|
||||||
|
its existing Command+Shift+N search shortcut.
|
||||||
|
|
||||||
|
The shortcut is also shown under **File → Quick Question**. If another application
|
||||||
|
already owns Option+Space, the panel reports that the shortcut is unavailable;
|
||||||
|
the menu item can still be clicked. No Accessibility or Input Monitoring grant is
|
||||||
|
needed for the registered hotkey.
|
||||||
|
|
||||||
|
The native AppKit panel and Carbon hotkey live in `Apps/SybilMacQuickQuestion`,
|
||||||
|
a macOS bundle embedded only for the Mac Catalyst destination. Catalyst loads it
|
||||||
|
through the Foundation-only `SybilMacQuickQuestionBridge` contract. Requests,
|
||||||
|
provider/model preferences, streaming, and conversion to chat remain in the
|
||||||
|
existing `SybilViewModel`; there is no separate backend or API contract. The panel
|
||||||
|
uses AppKit controls rather than trying to load the macOS SwiftUI runtime into a
|
||||||
|
Catalyst process.
|
||||||
|
|
||||||
|
The iPhone/iPad app keeps its existing Quick Question sheet and Home Screen quick
|
||||||
|
action, and does not build or embed the macOS helper.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `just test`: existing Swift package tests on the iPhone simulator.
|
||||||
|
- From `Packages/Sybil`, run `xcodebuild test -scheme Sybil -destination 'platform=macOS,variant=Mac Catalyst' -parallel-testing-enabled NO` for package tests including the Catalyst panel controller.
|
||||||
|
- For a manual Mac check, open the panel from another app, type immediately,
|
||||||
|
submit, hide/reopen during streaming, and verify Escape/outside-click dismissal
|
||||||
|
returns input to the previous app. Check a second display and a full-screen Space
|
||||||
|
when available.
|
||||||
|
- Close every Mac window, invoke Option+Space, and verify the saved question and
|
||||||
|
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. 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.
|
||||||
|
- With all workspace windows closed, switch to another app and repeatedly open
|
||||||
|
and dismiss Quick Question, including two quick Option+Space presses while it
|
||||||
|
is opening. Check that no late panel appears after cancellation and that no
|
||||||
|
`UINSAppLifecycleStateRunningNoOpenWindows` assertion occurs.
|
||||||
@@ -24,6 +24,12 @@ run: generate
|
|||||||
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
||||||
xcrun simctl launch booted net.buzzert.sybil2
|
xcrun simctl launch booted net.buzzert.sybil2
|
||||||
|
|
||||||
|
build-mac: generate
|
||||||
|
xcodebuild -scheme Sybil -destination 'platform=macOS,variant=Mac Catalyst' -derivedDataPath '{{derived_data}}' CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO
|
||||||
|
|
||||||
|
run-mac: build-mac
|
||||||
|
open '{{derived_data}}/Build/Products/Debug-maccatalyst/Sybil.app'
|
||||||
|
|
||||||
beta:
|
beta:
|
||||||
fastlane ios beta
|
fastlane ios beta
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ packages:
|
|||||||
path: Packages/Sybil
|
path: Packages/Sybil
|
||||||
include:
|
include:
|
||||||
- Apps/Sybil/project.yml
|
- Apps/Sybil/project.yml
|
||||||
|
- Apps/SybilMacQuickQuestion/project.yml
|
||||||
|
|||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
SESSION="${SYBIL_TMUX_SESSION:-sybil-dev}"
|
||||||
|
LOG_DIR="$ROOT/.devbox/logs"
|
||||||
|
PID_DIR="$ROOT/.devbox/pids"
|
||||||
|
|
||||||
|
require_env() {
|
||||||
|
local name="$1"
|
||||||
|
if [[ -z "${!name:-}" ]]; then
|
||||||
|
echo "Missing required environment variable: $name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_env OPENAI_API_KEY
|
||||||
|
require_env EXA_API_KEY
|
||||||
|
|
||||||
|
mkdir -p "$LOG_DIR" "$PID_DIR"
|
||||||
|
|
||||||
|
export HOST="${HOST:-0.0.0.0}"
|
||||||
|
export PORT="${PORT:-8787}"
|
||||||
|
export DATABASE_URL="${DATABASE_URL:-file:$ROOT/server/dev.db}"
|
||||||
|
|
||||||
|
echo "Installing server dependencies..."
|
||||||
|
npm ci --prefix "$ROOT/server" --no-audit --no-fund
|
||||||
|
|
||||||
|
echo "Installing web dependencies..."
|
||||||
|
npm ci --prefix "$ROOT/web" --no-audit --no-fund
|
||||||
|
|
||||||
|
if command -v tmux >/dev/null 2>&1; then
|
||||||
|
tmux kill-session -t "$SESSION" >/dev/null 2>&1 || true
|
||||||
|
tmux new-session -d -s "$SESSION" -n server -c "$ROOT/server" "sleep infinity"
|
||||||
|
tmux set-environment -t "$SESSION" OPENAI_API_KEY "$OPENAI_API_KEY"
|
||||||
|
tmux set-environment -t "$SESSION" EXA_API_KEY "$EXA_API_KEY"
|
||||||
|
tmux set-environment -t "$SESSION" HOST "$HOST"
|
||||||
|
tmux set-environment -t "$SESSION" PORT "$PORT"
|
||||||
|
tmux set-environment -t "$SESSION" DATABASE_URL "$DATABASE_URL"
|
||||||
|
if [[ -n "${BRIX_CLUSTER:-}" ]]; then
|
||||||
|
tmux set-environment -t "$SESSION" BRIX_CLUSTER "$BRIX_CLUSTER"
|
||||||
|
fi
|
||||||
|
for name in \
|
||||||
|
CHAT_CODEX_TOOL_ENABLED \
|
||||||
|
CHAT_CODEX_REMOTE_HOST \
|
||||||
|
CHAT_CODEX_REMOTE_USER \
|
||||||
|
CHAT_CODEX_REMOTE_PORT \
|
||||||
|
CHAT_CODEX_REMOTE_WORKDIR \
|
||||||
|
CHAT_CODEX_SSH_KEY_PATH \
|
||||||
|
CHAT_CODEX_SSH_PRIVATE_KEY_B64 \
|
||||||
|
CHAT_CODEX_EXEC_TIMEOUT_MS \
|
||||||
|
CHAT_SHELL_TOOL_ENABLED \
|
||||||
|
CHAT_SHELL_EXEC_TIMEOUT_MS; do
|
||||||
|
if [[ -n "${!name:-}" ]]; then
|
||||||
|
tmux set-environment -t "$SESSION" "$name" "${!name}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
tmux respawn-pane -k -t "$SESSION:server.0" -c "$ROOT/server" "npm run dev"
|
||||||
|
tmux new-window -t "$SESSION" -n web -c "$ROOT/web" \
|
||||||
|
"npm run dev -- --host 0.0.0.0 --port 5173"
|
||||||
|
echo "Started Sybil in tmux session '$SESSION'."
|
||||||
|
echo "Server: http://127.0.0.1:8787"
|
||||||
|
echo "Web: http://127.0.0.1:5173"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "tmux not found; starting background processes with nohup."
|
||||||
|
|
||||||
|
if [[ -f "$PID_DIR/server.pid" ]]; then
|
||||||
|
kill "$(cat "$PID_DIR/server.pid")" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
if [[ -f "$PID_DIR/web.pid" ]]; then
|
||||||
|
kill "$(cat "$PID_DIR/web.pid")" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$ROOT/server"
|
||||||
|
exec env HOST="$HOST" PORT="$PORT" DATABASE_URL="$DATABASE_URL" npm run dev
|
||||||
|
) >"$LOG_DIR/server.log" 2>&1 &
|
||||||
|
echo "$!" >"$PID_DIR/server.pid"
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$ROOT/web"
|
||||||
|
exec npm run dev -- --host 0.0.0.0 --port 5173
|
||||||
|
) >"$LOG_DIR/web.log" 2>&1 &
|
||||||
|
echo "$!" >"$PID_DIR/web.pid"
|
||||||
|
|
||||||
|
echo "Started Sybil background processes."
|
||||||
|
echo "Server log: $LOG_DIR/server.log"
|
||||||
|
echo "Web log: $LOG_DIR/web.log"
|
||||||
Executable
+357
@@ -0,0 +1,357 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
REPO="${REPO:-Sybil-2}"
|
||||||
|
WORKLOAD="${WORKLOAD:-sybil-dev}"
|
||||||
|
CLUSTER="${CLUSTER:-viper}"
|
||||||
|
NAMESPACE="${NAMESPACE:-buzzert}"
|
||||||
|
QUOTA="${QUOTA:-research-api}"
|
||||||
|
SECRET="${SECRET:-sybil-dev-env}"
|
||||||
|
REMOTE_ROOT="${REMOTE_ROOT:-/root/code/$REPO}"
|
||||||
|
INGRESS_PORT="${INGRESS_PORT:-5173}"
|
||||||
|
CODEX_IMAGE="${CODEX_IMAGE:-openai.azurecr.io/rcall:crow-1b540b5e15f9-2026-01-22-2404-312}"
|
||||||
|
CODEX_SERVICE="${CODEX_SERVICE:-sybil-codex-devbox}"
|
||||||
|
CODEX_CONTAINER="${CODEX_CONTAINER:-codex-devbox}"
|
||||||
|
CODEX_SSH_SECRET="${CODEX_SSH_SECRET:-sybil-codex-ssh}"
|
||||||
|
CODEX_SSH_PORT="${CODEX_SSH_PORT:-2222}"
|
||||||
|
CODEX_WORKDIR="${CODEX_WORKDIR:-/workspace/sybil-codex}"
|
||||||
|
CODEX_SSH_KEY_PATH="${CODEX_SSH_KEY_PATH:-/run/secrets/sybil-codex-ssh/id_ed25519}"
|
||||||
|
|
||||||
|
echo "Syncing $REPO to $WORKLOAD on $CLUSTER/$NAMESPACE..."
|
||||||
|
brix create \
|
||||||
|
--project-root "$(dirname "$ROOT")" \
|
||||||
|
--path "$REPO=$ROOT" \
|
||||||
|
--repositories "$REPO" \
|
||||||
|
--include-untracked \
|
||||||
|
--reconcile-policy continuous \
|
||||||
|
-- \
|
||||||
|
"cluster=$CLUSTER" \
|
||||||
|
"namespace=$NAMESPACE" \
|
||||||
|
"name=$WORKLOAD" \
|
||||||
|
"pool.spec.quota=$QUOTA" \
|
||||||
|
"pool.spec.size.resource=cpu" \
|
||||||
|
"pool.spec.size.quantity=1" \
|
||||||
|
"pool.spec.priority=low"
|
||||||
|
|
||||||
|
if [[ "$REPO" != "openai" ]]; then
|
||||||
|
if kubectl --context "$CLUSTER" -n "$NAMESPACE" get git "$WORKLOAD" -o json 2>/dev/null | jq -e '.spec.repositories.openai?' >/dev/null; then
|
||||||
|
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch git "$WORKLOAD" --type=json -p='[{"op":"remove","path":"/spec/repositories/openai"}]'
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${OPENAI_API_KEY:-}" && -n "${EXA_API_KEY:-}" ]]; then
|
||||||
|
kubectl --context "$CLUSTER" -n "$NAMESPACE" create secret generic "$SECRET" \
|
||||||
|
--from-literal=OPENAI_API_KEY="$OPENAI_API_KEY" \
|
||||||
|
--from-literal=EXA_API_KEY="$EXA_API_KEY" \
|
||||||
|
--dry-run=client -o yaml | kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f -
|
||||||
|
elif [[ -n "${OPENAI_API_KEY:-}" || -n "${EXA_API_KEY:-}" ]]; then
|
||||||
|
echo "Only one API key env var is set; keeping existing Kubernetes secret $SECRET." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get secret "$SECRET" -o name 2>/dev/null || true)" == "" ]]; then
|
||||||
|
echo "Secret $SECRET does not exist. Re-run with OPENAI_API_KEY and EXA_API_KEY set." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get secret "$CODEX_SSH_SECRET" -o name 2>/dev/null || true)" == "" ]]; then
|
||||||
|
tmpdir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmpdir"' EXIT
|
||||||
|
ssh-keygen -t ed25519 -N "" -C "sybil-codex@$WORKLOAD" -f "$tmpdir/id_ed25519" >/dev/null
|
||||||
|
kubectl --context "$CLUSTER" -n "$NAMESPACE" create secret generic "$CODEX_SSH_SECRET" \
|
||||||
|
--from-file=id_ed25519="$tmpdir/id_ed25519" \
|
||||||
|
--from-file=id_ed25519.pub="$tmpdir/id_ed25519.pub" \
|
||||||
|
--dry-run=client -o yaml | kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f -
|
||||||
|
fi
|
||||||
|
|
||||||
|
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch secret "$SECRET" --type=merge -p "$(jq -n \
|
||||||
|
--arg host "$CODEX_SERVICE" \
|
||||||
|
--arg port "$CODEX_SSH_PORT" \
|
||||||
|
--arg workdir "$CODEX_WORKDIR" \
|
||||||
|
--arg keyPath "$CODEX_SSH_KEY_PATH" \
|
||||||
|
'{
|
||||||
|
stringData: {
|
||||||
|
CHAT_CODEX_TOOL_ENABLED: "true",
|
||||||
|
CHAT_CODEX_REMOTE_HOST: $host,
|
||||||
|
CHAT_CODEX_REMOTE_USER: "root",
|
||||||
|
CHAT_CODEX_REMOTE_PORT: $port,
|
||||||
|
CHAT_CODEX_REMOTE_WORKDIR: $workdir,
|
||||||
|
CHAT_CODEX_SSH_KEY_PATH: $keyPath,
|
||||||
|
CHAT_CODEX_EXEC_TIMEOUT_MS: "600000",
|
||||||
|
CHAT_SHELL_TOOL_ENABLED: "true"
|
||||||
|
}
|
||||||
|
}')"
|
||||||
|
|
||||||
|
ensure_codex_deployment() {
|
||||||
|
kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f - <<KUBE_MANIFEST
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: $CODEX_SERVICE
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: $CODEX_SERVICE
|
||||||
|
app.kubernetes.io/part-of: sybil-dev
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: $CODEX_SERVICE
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: $CODEX_SERVICE
|
||||||
|
app.kubernetes.io/part-of: sybil-dev
|
||||||
|
spec:
|
||||||
|
tolerations:
|
||||||
|
- key: openai.com/team
|
||||||
|
operator: Exists
|
||||||
|
effect: NoSchedule
|
||||||
|
containers:
|
||||||
|
- name: $CODEX_CONTAINER
|
||||||
|
image: $CODEX_IMAGE
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
command: ["/bin/bash", "-lc"]
|
||||||
|
args:
|
||||||
|
- |
|
||||||
|
set -euo pipefail
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
if ! command -v sshd >/dev/null 2>&1; then
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends openssh-server ca-certificates git curl
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
fi
|
||||||
|
if ! command -v codex >/dev/null 2>&1; then
|
||||||
|
npm install -g @openai/codex
|
||||||
|
fi
|
||||||
|
mkdir -p /run/sshd /root/.ssh "$CODEX_WORKDIR"
|
||||||
|
cp /run/secrets/sybil-codex-ssh/id_ed25519.pub /root/.ssh/authorized_keys
|
||||||
|
chmod 700 /root/.ssh
|
||||||
|
chmod 600 /root/.ssh/authorized_keys
|
||||||
|
{
|
||||||
|
if [[ -n "\${OPENAI_API_KEY:-}" ]]; then
|
||||||
|
printf 'export OPENAI_API_KEY=%q\n' "\$OPENAI_API_KEY"
|
||||||
|
fi
|
||||||
|
if [[ -n "\${EXA_API_KEY:-}" ]]; then
|
||||||
|
printf 'export EXA_API_KEY=%q\n' "\$EXA_API_KEY"
|
||||||
|
fi
|
||||||
|
} >/root/.sybil-codex-env
|
||||||
|
chmod 600 /root/.sybil-codex-env
|
||||||
|
cat >/usr/local/bin/sybil-codex-ssh-command <<'SSH_COMMAND'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ -f /root/.sybil-codex-env ]]; then
|
||||||
|
source /root/.sybil-codex-env
|
||||||
|
fi
|
||||||
|
if [[ -n "\${SSH_ORIGINAL_COMMAND:-}" ]]; then
|
||||||
|
exec /bin/bash -lc "\$SSH_ORIGINAL_COMMAND"
|
||||||
|
fi
|
||||||
|
exec /bin/bash -l
|
||||||
|
SSH_COMMAND
|
||||||
|
chmod 700 /usr/local/bin/sybil-codex-ssh-command
|
||||||
|
ssh-keygen -A
|
||||||
|
cat >/tmp/sybil-codex-sshd_config <<SSHD_CONFIG
|
||||||
|
Port $CODEX_SSH_PORT
|
||||||
|
ListenAddress 0.0.0.0
|
||||||
|
PermitRootLogin prohibit-password
|
||||||
|
PasswordAuthentication no
|
||||||
|
PubkeyAuthentication yes
|
||||||
|
AcceptEnv OPENAI_API_KEY EXA_API_KEY
|
||||||
|
AuthorizedKeysFile .ssh/authorized_keys
|
||||||
|
PermitUserEnvironment no
|
||||||
|
ForceCommand /usr/local/bin/sybil-codex-ssh-command
|
||||||
|
AllowTcpForwarding no
|
||||||
|
X11Forwarding no
|
||||||
|
Subsystem sftp internal-sftp
|
||||||
|
PidFile /run/sshd/sybil-codex.pid
|
||||||
|
SSHD_CONFIG
|
||||||
|
exec \$(command -v sshd) -D -e -f /tmp/sybil-codex-sshd_config
|
||||||
|
ports:
|
||||||
|
- name: ssh
|
||||||
|
containerPort: $CODEX_SSH_PORT
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: $SECRET
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: "1"
|
||||||
|
memory: 4Gi
|
||||||
|
limits:
|
||||||
|
cpu: "1"
|
||||||
|
memory: 4Gi
|
||||||
|
volumeMounts:
|
||||||
|
- name: sybil-codex-ssh
|
||||||
|
mountPath: /run/secrets/sybil-codex-ssh
|
||||||
|
readOnly: true
|
||||||
|
- name: sybil-codex-workspace
|
||||||
|
mountPath: /workspace
|
||||||
|
volumes:
|
||||||
|
- name: sybil-codex-ssh
|
||||||
|
secret:
|
||||||
|
secretName: $CODEX_SSH_SECRET
|
||||||
|
defaultMode: 384
|
||||||
|
- name: sybil-codex-workspace
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: $CODEX_SERVICE
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: $CODEX_SERVICE
|
||||||
|
app.kubernetes.io/part-of: sybil-dev
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: $CODEX_SERVICE
|
||||||
|
ports:
|
||||||
|
- name: ssh
|
||||||
|
port: $CODEX_SSH_PORT
|
||||||
|
targetPort: ssh
|
||||||
|
KUBE_MANIFEST
|
||||||
|
|
||||||
|
kubectl --context "$CLUSTER" -n "$NAMESPACE" rollout status "deployment/$CODEX_SERVICE" --timeout=300s
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_codex_deployment
|
||||||
|
|
||||||
|
ensure_env_from() {
|
||||||
|
local resource="$1"
|
||||||
|
local name="$2"
|
||||||
|
local jsonpath="$3"
|
||||||
|
local patch_path="$4"
|
||||||
|
local existing_env_from
|
||||||
|
|
||||||
|
existing_env_from="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get "$resource" "$name" \
|
||||||
|
-o "jsonpath=$jsonpath" 2>/dev/null || true)"
|
||||||
|
|
||||||
|
if [[ "$existing_env_from" == *"$SECRET"* ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local patch
|
||||||
|
if [[ -z "$existing_env_from" ]]; then
|
||||||
|
patch="[{\"op\":\"add\",\"path\":\"$patch_path\",\"value\":[{\"secretRef\":{\"name\":\"$SECRET\"}}]}]"
|
||||||
|
else
|
||||||
|
patch="[{\"op\":\"add\",\"path\":\"$patch_path/-\",\"value\":{\"secretRef\":{\"name\":\"$SECRET\"}}}]"
|
||||||
|
fi
|
||||||
|
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch "$resource" "$name" --type=json -p "$patch"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_env_from \
|
||||||
|
workload \
|
||||||
|
"$WORKLOAD" \
|
||||||
|
"{.spec.pools[0].template.template.spec.workers.template.spec.containers[0].envFrom}" \
|
||||||
|
"/spec/pools/0/template/template/spec/workers/template/spec/containers/0/envFrom"
|
||||||
|
|
||||||
|
ensure_env_from \
|
||||||
|
pool \
|
||||||
|
"$WORKLOAD" \
|
||||||
|
"{.spec.workers.template.spec.containers[0].envFrom}" \
|
||||||
|
"/spec/workers/template/spec/containers/0/envFrom"
|
||||||
|
|
||||||
|
codex_ssh_volume_json="$(jq -n --arg secret "$CODEX_SSH_SECRET" '{
|
||||||
|
name: "sybil-codex-ssh",
|
||||||
|
secret: {
|
||||||
|
secretName: $secret,
|
||||||
|
defaultMode: 384
|
||||||
|
}
|
||||||
|
}')"
|
||||||
|
codex_main_mount_json="$(jq -n '{name: "sybil-codex-ssh", mountPath: "/run/secrets/sybil-codex-ssh", readOnly: true}')"
|
||||||
|
|
||||||
|
patch_pool_codex_mount() {
|
||||||
|
local pool_json containers volumes patch
|
||||||
|
pool_json="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pool "$WORKLOAD" -o json)"
|
||||||
|
containers="$(jq \
|
||||||
|
--arg sidecarName "$CODEX_CONTAINER" \
|
||||||
|
--argjson mainMount "$codex_main_mount_json" \
|
||||||
|
'
|
||||||
|
def ensure_mount($mount):
|
||||||
|
.volumeMounts = (((.volumeMounts // []) | map(select(.name != $mount.name))) + [$mount]);
|
||||||
|
(.spec.workers.template.spec.containers // [])
|
||||||
|
| map(if .name == "main" then ensure_mount($mainMount) else . end)
|
||||||
|
| map(select(.name != $sidecarName))
|
||||||
|
' <<<"$pool_json")"
|
||||||
|
volumes="$(jq \
|
||||||
|
--argjson sshVolume "$codex_ssh_volume_json" \
|
||||||
|
'
|
||||||
|
(.spec.workers.template.spec.volumes // [])
|
||||||
|
| map(select(.name != $sshVolume.name and .name != "sybil-codex-workspace"))
|
||||||
|
+ [$sshVolume]
|
||||||
|
' <<<"$pool_json")"
|
||||||
|
patch="$(jq -n --argjson containers "$containers" --argjson volumes "$volumes" '{
|
||||||
|
spec: {
|
||||||
|
workers: {
|
||||||
|
template: {
|
||||||
|
spec: {
|
||||||
|
containers: $containers,
|
||||||
|
volumes: $volumes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}')"
|
||||||
|
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch pool "$WORKLOAD" --type=merge -p "$patch"
|
||||||
|
}
|
||||||
|
|
||||||
|
patch_workload_codex_mount() {
|
||||||
|
local workload_json pools patch
|
||||||
|
workload_json="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get workload "$WORKLOAD" -o json)"
|
||||||
|
pools="$(jq \
|
||||||
|
--arg groupName "$WORKLOAD" \
|
||||||
|
--arg sidecarName "$CODEX_CONTAINER" \
|
||||||
|
--argjson mainMount "$codex_main_mount_json" \
|
||||||
|
--argjson sshVolume "$codex_ssh_volume_json" \
|
||||||
|
'
|
||||||
|
def ensure_mount($mount):
|
||||||
|
.volumeMounts = (((.volumeMounts // []) | map(select(.name != $mount.name))) + [$mount]);
|
||||||
|
.spec.pools
|
||||||
|
| map(
|
||||||
|
if .groupName == $groupName then
|
||||||
|
.template.template.spec.workers.template.spec.containers =
|
||||||
|
((.template.template.spec.workers.template.spec.containers // [])
|
||||||
|
| map(if .name == "main" then ensure_mount($mainMount) else . end)
|
||||||
|
| map(select(.name != $sidecarName)))
|
||||||
|
| .template.template.spec.workers.template.spec.volumes =
|
||||||
|
((.template.template.spec.workers.template.spec.volumes // [])
|
||||||
|
| map(select(.name != $sshVolume.name and .name != "sybil-codex-workspace"))
|
||||||
|
+ [$sshVolume])
|
||||||
|
else
|
||||||
|
.
|
||||||
|
end
|
||||||
|
)
|
||||||
|
' <<<"$workload_json")"
|
||||||
|
patch="$(jq -n --argjson pools "$pools" '{spec: {pools: $pools}}')"
|
||||||
|
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch workload "$WORKLOAD" --type=merge -p "$patch"
|
||||||
|
}
|
||||||
|
|
||||||
|
patch_workload_codex_mount
|
||||||
|
patch_pool_codex_mount
|
||||||
|
|
||||||
|
echo "Waiting for $WORKLOAD-0 to run the current pool revision..."
|
||||||
|
deadline=$((SECONDS + 300))
|
||||||
|
while true; do
|
||||||
|
pool_revision="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pool "$WORKLOAD" -o jsonpath='{.status.revision}' 2>/dev/null || true)"
|
||||||
|
pod_revision="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.metadata.labels.brix\.openai\.com/revision}' 2>/dev/null || true)"
|
||||||
|
pod_ready="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true)"
|
||||||
|
pod_deletion_timestamp="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.metadata.deletionTimestamp}' 2>/dev/null || true)"
|
||||||
|
|
||||||
|
if [[ -n "$pool_revision" && "$pod_revision" == "$pool_revision" && "$pod_ready" == "True" && -z "$pod_deletion_timestamp" ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( SECONDS >= deadline )); then
|
||||||
|
echo "Timed out waiting for current ready pod. pool_revision=$pool_revision pod_revision=$pod_revision pod_ready=$pod_ready deleting=$pod_deletion_timestamp" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
brix run --clusters "$CLUSTER" --namespace "$NAMESPACE" --pools "$WORKLOAD" --dir "$REMOTE_ROOT" -- ./scripts/devbox-start.sh
|
||||||
|
|
||||||
|
ssh_check="ssh -n -i '$CODEX_SSH_KEY_PATH' -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/tmp/sybil-codex-known-hosts -p '$CODEX_SSH_PORT' root@'$CODEX_SERVICE' true"
|
||||||
|
brix run --clusters "$CLUSTER" --namespace "$NAMESPACE" --pools "$WORKLOAD" --dir "$REMOTE_ROOT" -- /bin/bash -lc "$ssh_check"
|
||||||
|
|
||||||
|
url="$(brix ingress create --cluster "$CLUSTER" --namespace "$NAMESPACE" --pod "$WORKLOAD-0" --ports "web:$INGRESS_PORT" --root web --timeout 300 "$WORKLOAD" | tail -1)"
|
||||||
|
|
||||||
|
echo "Devbox update complete: $url"
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
-- External-content indexes avoid storing a second copy of conversation text.
|
||||||
|
-- Keep these virtual tables and triggers in SQL: Prisma does not model FTS5.
|
||||||
|
CREATE VIRTUAL TABLE "ChatSearch" USING fts5(
|
||||||
|
title, content='Chat', content_rowid='rowid',
|
||||||
|
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Hidden system prompts and tool payloads are not searchable conversation text.
|
||||||
|
CREATE VIEW "MessageSearchContent" AS
|
||||||
|
SELECT rowid, content FROM "Message" WHERE role IN ('user', 'assistant');
|
||||||
|
CREATE VIRTUAL TABLE "MessageSearch" USING fts5(
|
||||||
|
content, content='MessageSearchContent', content_rowid='rowid',
|
||||||
|
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE "SavedSearchSearch" USING fts5(
|
||||||
|
title, query, answerText, content='Search', content_rowid='rowid',
|
||||||
|
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER "ChatSearch_insert" AFTER INSERT ON "Chat" BEGIN
|
||||||
|
INSERT INTO "ChatSearch" (rowid, title) VALUES (new.rowid, new.title);
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER "ChatSearch_delete" AFTER DELETE ON "Chat" BEGIN
|
||||||
|
INSERT INTO "ChatSearch" ("ChatSearch", rowid, title) VALUES ('delete', old.rowid, old.title);
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER "ChatSearch_update" AFTER UPDATE OF title ON "Chat"
|
||||||
|
WHEN old.title IS NOT new.title BEGIN
|
||||||
|
INSERT INTO "ChatSearch" ("ChatSearch", rowid, title) VALUES ('delete', old.rowid, old.title);
|
||||||
|
INSERT INTO "ChatSearch" (rowid, title) VALUES (new.rowid, new.title);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER "MessageSearch_insert" AFTER INSERT ON "Message"
|
||||||
|
WHEN new.role IN ('user', 'assistant') BEGIN
|
||||||
|
INSERT INTO "MessageSearch" (rowid, content) VALUES (new.rowid, new.content);
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER "MessageSearch_delete" AFTER DELETE ON "Message"
|
||||||
|
WHEN old.role IN ('user', 'assistant') BEGIN
|
||||||
|
INSERT INTO "MessageSearch" ("MessageSearch", rowid, content) VALUES ('delete', old.rowid, old.content);
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER "MessageSearch_update" AFTER UPDATE OF content, role ON "Message"
|
||||||
|
WHEN old.content IS NOT new.content OR old.role IS NOT new.role BEGIN
|
||||||
|
INSERT INTO "MessageSearch" ("MessageSearch", rowid, content)
|
||||||
|
SELECT 'delete', old.rowid, old.content WHERE old.role IN ('user', 'assistant');
|
||||||
|
INSERT INTO "MessageSearch" (rowid, content)
|
||||||
|
SELECT new.rowid, new.content WHERE new.role IN ('user', 'assistant');
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER "SavedSearchSearch_insert" AFTER INSERT ON "Search" BEGIN
|
||||||
|
INSERT INTO "SavedSearchSearch" (rowid, title, query, answerText)
|
||||||
|
VALUES (new.rowid, new.title, new.query, new.answerText);
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER "SavedSearchSearch_delete" AFTER DELETE ON "Search" BEGIN
|
||||||
|
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch", rowid, title, query, answerText)
|
||||||
|
VALUES ('delete', old.rowid, old.title, old.query, old.answerText);
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER "SavedSearchSearch_update" AFTER UPDATE OF title, query, answerText ON "Search"
|
||||||
|
WHEN old.title IS NOT new.title OR old.query IS NOT new.query OR old.answerText IS NOT new.answerText BEGIN
|
||||||
|
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch", rowid, title, query, answerText)
|
||||||
|
VALUES ('delete', old.rowid, old.title, old.query, old.answerText);
|
||||||
|
INSERT INTO "SavedSearchSearch" (rowid, title, query, answerText)
|
||||||
|
VALUES (new.rowid, new.title, new.query, new.answerText);
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Index existing history during migration, then maintain it transactionally.
|
||||||
|
INSERT INTO "ChatSearch" ("ChatSearch") VALUES ('rebuild');
|
||||||
|
INSERT INTO "MessageSearch" ("MessageSearch") VALUES ('rebuild');
|
||||||
|
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch") VALUES ('rebuild');
|
||||||
|
|
||||||
|
CREATE INDEX "Chat_updatedAt_idx" ON "Chat"("updatedAt");
|
||||||
@@ -76,6 +76,7 @@ model Chat {
|
|||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([parentChatId])
|
@@index([parentChatId])
|
||||||
|
@@index([updatedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Message {
|
model Message {
|
||||||
|
|||||||
@@ -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("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("image") || id.includes("dall-e") || id.includes("sora")) return false;
|
||||||
if (id.includes("search") || id.includes("computer-use")) 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) {
|
function isLikelyGeminiChatModel(model: string) {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { MultiplexRequest, Provider } from "./types.js";
|
||||||
|
|
||||||
|
export const QUICK_QUESTION_SYSTEM_PROMPT =
|
||||||
|
"You are answering a quick question in a one-shot experience intended to replace a quick Google search. " +
|
||||||
|
"Give a succinct, direct, self-contained answer. Do not ask follow-up questions, invite the user to continue, " +
|
||||||
|
"or offer additional help. If the question is ambiguous, make the most reasonable assumption and state it briefly only when needed.";
|
||||||
|
|
||||||
|
export type QuickQuestionRequest = {
|
||||||
|
provider: Provider;
|
||||||
|
model: string;
|
||||||
|
question: string;
|
||||||
|
enabledTools?: string[];
|
||||||
|
userLocation?: string;
|
||||||
|
temperature?: number;
|
||||||
|
maxTokens?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildQuickQuestionMultiplexRequest({
|
||||||
|
question,
|
||||||
|
...request
|
||||||
|
}: QuickQuestionRequest): MultiplexRequest {
|
||||||
|
return {
|
||||||
|
...request,
|
||||||
|
persist: false,
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: QUICK_QUESTION_SYSTEM_PROMPT },
|
||||||
|
{ role: "user", content: question },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
+65
-1
@@ -11,10 +11,12 @@ import { runMultiplex } from "./llm/multiplexer.js";
|
|||||||
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
|
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
|
||||||
import { getAvailableChatTools, normalizeEnabledChatTools } from "./llm/chat-tools.js";
|
import { getAvailableChatTools, normalizeEnabledChatTools } from "./llm/chat-tools.js";
|
||||||
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
||||||
|
import { buildQuickQuestionMultiplexRequest } from "./llm/quick-question.js";
|
||||||
import { openaiClient } from "./llm/providers.js";
|
import { openaiClient } from "./llm/providers.js";
|
||||||
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
||||||
import { exaClient } from "./search/exa.js";
|
import { exaClient } from "./search/exa.js";
|
||||||
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
|
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
|
||||||
|
import { searchWorkspace } from "./workspace-search.js";
|
||||||
import type { ChatAttachment } from "./llm/types.js";
|
import type { ChatAttachment } from "./llm/types.js";
|
||||||
|
|
||||||
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "gemini", "hermes-agent"]);
|
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "gemini", "hermes-agent"]);
|
||||||
@@ -205,6 +207,16 @@ const CompletionStreamBody = z
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const QuickQuestionStreamBody = z.object({
|
||||||
|
provider: ProviderSchema,
|
||||||
|
model: z.string().min(1),
|
||||||
|
question: z.string().trim().min(1),
|
||||||
|
enabledTools: EnabledToolsSchema.optional(),
|
||||||
|
userLocation: z.string().trim().min(1).max(200).optional(),
|
||||||
|
temperature: z.number().min(0).max(2).optional(),
|
||||||
|
maxTokens: z.number().int().positive().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttachment[]) {
|
function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttachment[]) {
|
||||||
if (!attachments?.length) return metadata as any;
|
if (!attachments?.length) return metadata as any;
|
||||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||||
@@ -916,6 +928,37 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.get("/v1/workspace-items", async (req) => {
|
app.get("/v1/workspace-items", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
|
const parsed = z.object({ q: z.string().trim().max(200).optional() }).safeParse(req.query);
|
||||||
|
if (!parsed.success) throw app.httpErrors.badRequest("q must be a string of at most 200 characters");
|
||||||
|
if (parsed.data.q) {
|
||||||
|
const { hits, hasMore } = await searchWorkspace(parsed.data.q);
|
||||||
|
const [chats, searches] = await Promise.all([
|
||||||
|
prisma.chat.findMany({
|
||||||
|
where: { id: { in: hits.filter((hit) => hit.type === "chat").map((hit) => hit.id) } },
|
||||||
|
select: {
|
||||||
|
...chatSummarySelect,
|
||||||
|
parentChat: { select: { projectItems: starredProjectItemsSelect } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.search.findMany({
|
||||||
|
where: { id: { in: hits.filter((hit) => hit.type === "search").map((hit) => hit.id) } },
|
||||||
|
select: searchSummarySelect,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const summaries = new Map<string, Record<string, unknown>>([
|
||||||
|
...chats.map(({ parentChat, ...chat }) => [
|
||||||
|
`chat:${chat.id}`,
|
||||||
|
// Search omits unmatched roots, but sidebar fork stars belong to the root.
|
||||||
|
{ ...serializeChatLike(chat), ...serializeStarFields(parentChat ?? chat) },
|
||||||
|
] as const),
|
||||||
|
...searches.map((search) => [`search:${search.id}`, serializeSearchLike(search)] as const),
|
||||||
|
]);
|
||||||
|
const items = hits.flatMap((hit) => {
|
||||||
|
const summary = summaries.get(`${hit.type}:${hit.id}`);
|
||||||
|
return summary ? [{ ...summary, ...hit }] : [];
|
||||||
|
});
|
||||||
|
return { items, hasMore };
|
||||||
|
}
|
||||||
return { items: await listWorkspaceItems() };
|
return { items: await listWorkspaceItems() };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1564,7 +1607,28 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Streaming SSE endpoint.
|
// One-shot, non-persistent Quick Question SSE endpoint.
|
||||||
|
app.post("/v1/quick-questions/stream", async (req, reply) => {
|
||||||
|
requireAdmin(req);
|
||||||
|
|
||||||
|
const parsed = QuickQuestionStreamBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||||
|
const body = withRequestUserLocation(parsed.data, req);
|
||||||
|
|
||||||
|
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||||
|
reply.raw.flushHeaders();
|
||||||
|
|
||||||
|
for await (const ev of runMultiplexStream(buildQuickQuestionMultiplexRequest(body))) {
|
||||||
|
writeSseEvent(reply, mapChatStreamEvent(ev));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reply.raw.destroyed && !reply.raw.writableEnded) {
|
||||||
|
reply.raw.end();
|
||||||
|
}
|
||||||
|
return reply;
|
||||||
|
});
|
||||||
|
|
||||||
|
// General chat completion SSE endpoint.
|
||||||
app.post("/v1/chat-completions/stream", async (req, reply) => {
|
app.post("/v1/chat-completions/stream", async (req, reply) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "./db.js";
|
||||||
|
|
||||||
|
export const WORKSPACE_SEARCH_LIMIT = 100;
|
||||||
|
|
||||||
|
// Treat input as literal Unicode words, never FTS operators/column expressions.
|
||||||
|
// Prefix matching supports type-ahead, including short terms such as "AI".
|
||||||
|
export function workspaceSearchExpression(query: string): string | null {
|
||||||
|
const words = query.match(/[\p{L}\p{N}\p{M}\p{Co}]+/gu);
|
||||||
|
return words?.map((word) => `"${word}"*`).join(" AND ") ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkspaceSearchHit = {
|
||||||
|
type: "chat" | "search";
|
||||||
|
id: string;
|
||||||
|
searchSnippet: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function workspaceSearchSQL(expression: string) {
|
||||||
|
// Batch excerpts after limiting matches. Unary + prevents SQLite from using
|
||||||
|
// the IN clause as repeated rowid lookups (and restarting FTS for every row).
|
||||||
|
// FTS still uses MATCH and the rowid bounds; IN filters that single scan.
|
||||||
|
return Prisma.sql`
|
||||||
|
WITH chat_hits AS (
|
||||||
|
SELECT c.id, NULL AS message_rowid
|
||||||
|
FROM "ChatSearch" JOIN "Chat" c ON c.rowid = "ChatSearch".rowid
|
||||||
|
WHERE "ChatSearch" MATCH ${expression}
|
||||||
|
UNION ALL
|
||||||
|
SELECT m.chatId AS id, MIN(m.rowid) AS message_rowid
|
||||||
|
FROM "MessageSearch" JOIN "Message" m ON m.rowid = "MessageSearch".rowid
|
||||||
|
WHERE "MessageSearch" MATCH ${expression}
|
||||||
|
GROUP BY m.chatId
|
||||||
|
), matches AS (
|
||||||
|
SELECT 'chat' AS type, c.id, c.updatedAt, MIN(h.message_rowid) AS message_rowid,
|
||||||
|
NULL AS search_rowid
|
||||||
|
FROM chat_hits h JOIN "Chat" c ON c.id = h.id
|
||||||
|
GROUP BY c.id
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'search' AS type, s.id, s.updatedAt, NULL AS message_rowid, s.rowid AS search_rowid
|
||||||
|
FROM "SavedSearchSearch" JOIN "Search" s ON s.rowid = "SavedSearchSearch".rowid
|
||||||
|
WHERE "SavedSearchSearch" MATCH ${expression}
|
||||||
|
), limited AS MATERIALIZED (
|
||||||
|
SELECT * FROM matches ORDER BY updatedAt DESC, type, id LIMIT ${WORKSPACE_SEARCH_LIMIT + 1}
|
||||||
|
), message_excerpts AS MATERIALIZED (
|
||||||
|
SELECT rowid, substr(snippet("MessageSearch", 0, '', '', ' … ', 24), 1, 480) AS snippet
|
||||||
|
FROM "MessageSearch"
|
||||||
|
WHERE "MessageSearch" MATCH ${expression}
|
||||||
|
AND rowid >= (SELECT MIN(message_rowid) FROM limited)
|
||||||
|
AND rowid <= (SELECT MAX(message_rowid) FROM limited)
|
||||||
|
AND +rowid IN (SELECT message_rowid FROM limited)
|
||||||
|
), search_excerpts AS MATERIALIZED (
|
||||||
|
SELECT rowid, substr(snippet("SavedSearchSearch", -1, '', '', ' … ', 24), 1, 480) AS snippet
|
||||||
|
FROM "SavedSearchSearch"
|
||||||
|
WHERE "SavedSearchSearch" MATCH ${expression}
|
||||||
|
AND rowid >= (SELECT MIN(search_rowid) FROM limited)
|
||||||
|
AND rowid <= (SELECT MAX(search_rowid) FROM limited)
|
||||||
|
AND +rowid IN (SELECT search_rowid FROM limited)
|
||||||
|
)
|
||||||
|
SELECT type, id, COALESCE(m.snippet, s.snippet) AS searchSnippet
|
||||||
|
FROM limited
|
||||||
|
LEFT JOIN message_excerpts m ON m.rowid = limited.message_rowid
|
||||||
|
LEFT JOIN search_excerpts s ON s.rowid = limited.search_rowid
|
||||||
|
ORDER BY updatedAt DESC, type, id
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchWorkspace(query: string) {
|
||||||
|
const expression = workspaceSearchExpression(query);
|
||||||
|
if (!expression) return { hits: [], hasMore: false };
|
||||||
|
// Match via the inverted indexes first. Hydrate summaries and generate excerpts
|
||||||
|
// only for the bounded result set, never fetch full transcripts into JS.
|
||||||
|
const hits = await prisma.$queryRaw<WorkspaceSearchHit[]>(workspaceSearchSQL(expression));
|
||||||
|
return { hits: hits.slice(0, WORKSPACE_SEARCH_LIMIT), hasMore: hits.length > WORKSPACE_SEARCH_LIMIT };
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join, resolve } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
@@ -11,6 +11,7 @@ import sensible from "@fastify/sensible";
|
|||||||
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-chat-forks-"));
|
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-chat-forks-"));
|
||||||
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
|
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
|
||||||
|
writeFileSync(join(databaseDir, "test.db"), "");
|
||||||
process.env.OPENAI_API_KEY = "";
|
process.env.OPENAI_API_KEY = "";
|
||||||
delete process.env.ADMIN_TOKEN;
|
delete process.env.ADMIN_TOKEN;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,50 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
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", () => {
|
test("provider backend registry selects chat protocol and managed-tool mode", () => {
|
||||||
assert.deepEqual(describeProviderChatBackend("openai", []), {
|
assert.deepEqual(describeProviderChatBackend("openai", []), {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
buildQuickQuestionMultiplexRequest,
|
||||||
|
QUICK_QUESTION_SYSTEM_PROMPT,
|
||||||
|
} from "../src/llm/quick-question.js";
|
||||||
|
|
||||||
|
test("quick question requests receive the server-owned one-shot system prompt", () => {
|
||||||
|
const request = buildQuickQuestionMultiplexRequest({
|
||||||
|
provider: "openai",
|
||||||
|
model: "gpt-4.1-mini",
|
||||||
|
question: "How do I reset my password?",
|
||||||
|
enabledTools: ["web_search"],
|
||||||
|
userLocation: "Los Angeles, CA",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(request.persist, false);
|
||||||
|
assert.equal(request.chatId, undefined);
|
||||||
|
assert.deepEqual(request.messages, [
|
||||||
|
{ role: "system", content: QUICK_QUESTION_SYSTEM_PROMPT },
|
||||||
|
{ role: "user", content: "How do I reset my password?" },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(request.enabledTools, ["web_search"]);
|
||||||
|
assert.equal(request.userLocation, "Los Angeles, CA");
|
||||||
|
assert.match(QUICK_QUESTION_SYSTEM_PROMPT, /succinct, direct, self-contained answer/i);
|
||||||
|
assert.match(QUICK_QUESTION_SYSTEM_PROMPT, /do not ask follow-up questions/i);
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { cpSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import sensible from "@fastify/sensible";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-workspace-search-"));
|
||||||
|
const migration = "20261007000000_add_workspace_full_text_search";
|
||||||
|
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
|
||||||
|
process.env.OPENAI_API_KEY = "";
|
||||||
|
delete process.env.ADMIN_TOKEN;
|
||||||
|
writeFileSync(join(databaseDir, "test.db"), "");
|
||||||
|
cpSync(join(serverRoot, "prisma/schema.prisma"), join(databaseDir, "schema.prisma"));
|
||||||
|
for (const name of readdirSync(join(serverRoot, "prisma/migrations"))) {
|
||||||
|
if (name !== migration) cpSync(join(serverRoot, "prisma/migrations", name), join(databaseDir, "migrations", name), { recursive: true });
|
||||||
|
}
|
||||||
|
function migrate() {
|
||||||
|
execFileSync(process.execPath, [join(serverRoot, "node_modules/prisma/build/index.js"), "migrate", "deploy", "--schema", join(databaseDir, "schema.prisma")], {
|
||||||
|
cwd: serverRoot, env: process.env, stdio: "pipe",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
migrate();
|
||||||
|
const { prisma } = await import("../src/db.js");
|
||||||
|
const oldChat = await prisma.chat.create({ data: {
|
||||||
|
title: "Old constellation notes",
|
||||||
|
messages: { create: [
|
||||||
|
{ role: "user", content: "We photographed a nebula before the migration." },
|
||||||
|
{ role: "system", content: "secretmigrationprompt" },
|
||||||
|
] },
|
||||||
|
} });
|
||||||
|
const oldSearch = await prisma.search.create({ data: { query: "constellation map", answerText: "The nebula is visible." } });
|
||||||
|
cpSync(join(serverRoot, "prisma/migrations", migration), join(databaseDir, "migrations", migration), { recursive: true });
|
||||||
|
migrate();
|
||||||
|
|
||||||
|
const { registerRoutes } = await import("../src/routes.js");
|
||||||
|
const { workspaceSearchExpression, workspaceSearchSQL } = await import("../src/workspace-search.js");
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
await app.register(sensible);
|
||||||
|
await registerRoutes(app);
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
async function search(query: string) {
|
||||||
|
const response = await app.inject({ method: "GET", url: `/v1/workspace-items?q=${encodeURIComponent(query)}` });
|
||||||
|
assert.equal(response.statusCode, 200, response.body);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await prisma.chat.deleteMany();
|
||||||
|
await prisma.search.deleteMany();
|
||||||
|
});
|
||||||
|
test.after(async () => {
|
||||||
|
await app.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
rmSync(databaseDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("migration indexes existing titles, messages, queries, and answers", async () => {
|
||||||
|
assert.deepEqual(new Set((await search("nebula")).items.map((item: any) => item.id)), new Set([oldChat.id, oldSearch.id]));
|
||||||
|
assert.equal((await search("constellation")).items.length, 2);
|
||||||
|
assert.equal((await search("secretmigrationprompt")).items.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matches full history, deduplicates messages, and excludes hidden content", async () => {
|
||||||
|
const chat = await prisma.chat.create({ data: {
|
||||||
|
title: "Travel notes", updatedAt: new Date("2000-01-01"),
|
||||||
|
messages: { create: [
|
||||||
|
{ role: "user", content: "Let's discuss café planning in Zürich" },
|
||||||
|
{ role: "assistant", content: "CAFÉ PLANNING includes booking a table" },
|
||||||
|
{ role: "system", content: "hiddensystemvalue" },
|
||||||
|
{ role: "tool", content: "hiddentoolvalue" },
|
||||||
|
] },
|
||||||
|
} });
|
||||||
|
await prisma.chat.createMany({ data: Array.from({ length: 105 }, (_, i) => ({ title: `Recent ${i}` })) });
|
||||||
|
const recent = (await app.inject({ method: "GET", url: "/v1/workspace-items" })).json();
|
||||||
|
assert.equal(recent.items.some((item: any) => item.id === chat.id), false);
|
||||||
|
const result = await search("CAFE plan");
|
||||||
|
assert.equal(result.hasMore, false);
|
||||||
|
assert.deepEqual(result.items.map((item: any) => item.id), [chat.id]);
|
||||||
|
assert.match(result.items[0].searchSnippet, /café planning/i);
|
||||||
|
assert.equal(result.items[0].messages, undefined);
|
||||||
|
assert.equal((await search("zuri")).items.length, 1);
|
||||||
|
assert.equal((await search("hiddensystemvalue")).items.length, 0);
|
||||||
|
assert.equal((await search("hiddentoolvalue")).items.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("indexes edits, role changes, forks, rollbacks, and cascading deletes", async () => {
|
||||||
|
const chat = await prisma.chat.create({ data: {
|
||||||
|
title: "originaltitle", messages: { create: { role: "assistant", content: "originalbody" } },
|
||||||
|
}, include: { messages: true } });
|
||||||
|
const messageID = chat.messages[0].id;
|
||||||
|
await prisma.chat.update({ where: { id: chat.id }, data: { title: "replacementtitle" } });
|
||||||
|
await prisma.message.update({ where: { id: messageID }, data: { content: "replacementbody" } });
|
||||||
|
assert.equal((await search("original")).items.length, 0);
|
||||||
|
assert.equal((await search("replacementbody")).items.length, 1);
|
||||||
|
await prisma.message.update({ where: { id: messageID }, data: { role: "tool" } });
|
||||||
|
assert.equal((await search("replacementbody")).items.length, 0);
|
||||||
|
await prisma.message.update({ where: { id: messageID }, data: { role: "user" } });
|
||||||
|
assert.equal((await search("replacementbody")).items.length, 1);
|
||||||
|
await assert.rejects(prisma.$transaction(async (tx) => {
|
||||||
|
await tx.message.update({ where: { id: messageID }, data: { content: "rolledbackvalue" } });
|
||||||
|
throw new Error("rollback");
|
||||||
|
}));
|
||||||
|
assert.equal((await search("rolledbackvalue")).items.length, 0);
|
||||||
|
assert.equal((await search("replacementbody")).items.length, 1);
|
||||||
|
const fork = await app.inject({ method: "POST", url: `/v1/chats/${chat.id}/fork`, payload: {} });
|
||||||
|
assert.equal(fork.statusCode, 200, fork.body);
|
||||||
|
assert.equal((await search("replacementbody")).items.length, 2);
|
||||||
|
await prisma.message.delete({ where: { id: messageID } });
|
||||||
|
const onlyFork = (await search("replacementbody")).items;
|
||||||
|
assert.deepEqual(onlyFork.map((item: any) => item.id), [fork.json().chat.id]);
|
||||||
|
const starResponse = await app.inject({ method: "PATCH", url: `/v1/chats/${chat.id}/star`, payload: { starred: true } });
|
||||||
|
assert.equal(starResponse.statusCode, 200, starResponse.body);
|
||||||
|
const starredFork = (await search("replacementbody")).items[0];
|
||||||
|
assert.equal(starredFork.starred, true);
|
||||||
|
assert.ok(starredFork.starredAt);
|
||||||
|
assert.equal(starredFork.parentChat, undefined);
|
||||||
|
await prisma.chat.delete({ where: { id: chat.id } });
|
||||||
|
assert.equal((await search("replacement")).items.length, 0);
|
||||||
|
for (const table of ["ChatSearch", "MessageSearch", "SavedSearchSearch"]) {
|
||||||
|
await prisma.$executeRawUnsafe(`INSERT INTO "${table}" ("${table}", rank) VALUES ('integrity-check', 1)`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saved searches match query and answer, and update/delete their index", async () => {
|
||||||
|
const saved = await prisma.search.create({ data: { title: "Bookmarks", query: "AI design", answerText: "Use a nebula illustration" } });
|
||||||
|
assert.equal((await search("ai des")).items[0].type, "search");
|
||||||
|
assert.match((await search("nebula")).items[0].searchSnippet, /nebula/);
|
||||||
|
await prisma.search.update({ where: { id: saved.id }, data: { title: "New title", query: "newquery", answerText: "newanswer" } });
|
||||||
|
assert.equal((await search("nebula")).items.length, 0);
|
||||||
|
assert.equal((await search("design")).items.length, 0);
|
||||||
|
assert.equal((await search("Bookmarks")).items.length, 0);
|
||||||
|
assert.equal((await search("newanswer")).items.length, 1);
|
||||||
|
await prisma.search.delete({ where: { id: saved.id } });
|
||||||
|
assert.equal((await search("new")).items.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("literal queries, validation, blank queries, and bounded snippets", async () => {
|
||||||
|
await prisma.chat.create({ data: { title: "AI OR safety", messages: { create: { role: "user", content: "longword".repeat(1000) } } } });
|
||||||
|
assert.equal((await search('"AI" OR safety*')).items.length, 1);
|
||||||
|
assert.equal((await search("AI OR other")).items.length, 0);
|
||||||
|
assert.equal((await search("AI")).items.length, 1);
|
||||||
|
assert.equal((await search("*** : () --")).items.length, 0);
|
||||||
|
assert.equal((await search("' UNION SELECT * FROM Chat; --")).items.length, 0);
|
||||||
|
assert.equal((await search("longword")).items[0].searchSnippet.length, 480);
|
||||||
|
const recent = (await app.inject({ method: "GET", url: "/v1/workspace-items" })).json();
|
||||||
|
assert.deepEqual(await search(" "), recent);
|
||||||
|
for (const query of ["q=" + "x".repeat(201), "q=one&q=two"]) {
|
||||||
|
const response = await app.inject({ method: "GET", url: `/v1/workspace-items?${query}` });
|
||||||
|
assert.equal(response.statusCode, 400, response.body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("caps combined results at 100 with deterministic newest-first ordering", async () => {
|
||||||
|
const date = new Date("2026-01-01");
|
||||||
|
await prisma.chat.createMany({ data: Array.from({ length: 100 }, (_, i) => ({ id: `chat-${String(i).padStart(3, "0")}`, title: "commonword", updatedAt: date })) });
|
||||||
|
await prisma.search.create({ data: { id: "saved", query: "commonword", updatedAt: new Date("2026-02-01") } });
|
||||||
|
const result = await search("common");
|
||||||
|
assert.equal(result.items.length, 100);
|
||||||
|
assert.equal(result.hasMore, true);
|
||||||
|
assert.equal(result.items[0].id, "saved");
|
||||||
|
assert.equal(result.items[1].id, "chat-000");
|
||||||
|
assert.equal(result.items.at(-1).id, "chat-098");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("query plan uses all three FTS indexes", async () => {
|
||||||
|
const plan = await prisma.$queryRaw<Array<{ detail: string }>>(Prisma.sql`EXPLAIN QUERY PLAN ${workspaceSearchSQL(workspaceSearchExpression("needle")!)}`);
|
||||||
|
for (const table of ["ChatSearch", "MessageSearch", "SavedSearchSearch"]) {
|
||||||
|
assert.ok(plan.some((row) => row.detail.includes(table) && /VIRTUAL TABLE INDEX.*M\d/.test(row.detail)), plan.map((row) => row.detail).join("\n"));
|
||||||
|
}
|
||||||
|
assert.equal(plan.some((row) => row.detail.includes("CORRELATED SCALAR SUBQUERY")), false, "Excerpts must not restart FTS for each result");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vacuum preserves external-content index row identities", async () => {
|
||||||
|
const removed = await prisma.chat.create({ data: { title: "removed" } });
|
||||||
|
const kept = await prisma.chat.create({ data: { title: "kepttitle", messages: { create: { role: "user", content: "keptbody" } } } });
|
||||||
|
await prisma.chat.delete({ where: { id: removed.id } });
|
||||||
|
await prisma.$executeRaw`VACUUM`;
|
||||||
|
assert.equal((await search("kepttitle")).items[0].id, kept.id);
|
||||||
|
assert.equal((await search("kepttitle")).items[0].searchSnippet, null);
|
||||||
|
assert.equal((await search("keptbody")).items[0].searchSnippet, "keptbody");
|
||||||
|
});
|
||||||
@@ -34,6 +34,13 @@ Default dev URL: `http://localhost:5173`
|
|||||||
## UI
|
## UI
|
||||||
|
|
||||||
- Left panel: mixed list of chat conversations and Exa searches.
|
- Left panel: mixed list of chat conversations and Exa searches.
|
||||||
|
- Sidebar search uses `GET /v1/workspace-items?q=…` to search all saved titles,
|
||||||
|
conversation messages, and web-search queries/answers. It waits 300 ms while
|
||||||
|
typing and cancels superseded requests. Results include a single-line matching excerpt
|
||||||
|
with matching words in bold and middle truncation
|
||||||
|
(or “Title match” for title-only chat matches), with up to 100 newest matches.
|
||||||
|
Clear the field or press Escape to return to recent items. Matching uses word
|
||||||
|
prefixes, so `plan` also finds `planning`.
|
||||||
- Right panel:
|
- Right panel:
|
||||||
- Chat mode: transcript + provider/model controls.
|
- Chat mode: transcript + provider/model controls.
|
||||||
- Search mode: top AI answer block + Google-style Exa results view.
|
- Search mode: top AI answer block + Google-style Exa results view.
|
||||||
|
|||||||
+235
-146
@@ -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 type { TargetedTouchEvent } from "preact";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
@@ -26,6 +26,7 @@ import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
|||||||
import { ChatComposer } from "@/components/chat/chat-composer";
|
import { ChatComposer } from "@/components/chat/chat-composer";
|
||||||
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
|
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
|
||||||
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
||||||
|
import { SearchMatchLabel } from "@/components/search/search-match-label";
|
||||||
import { SybilCharacter } from "@/components/sybil-character";
|
import { SybilCharacter } from "@/components/sybil-character";
|
||||||
import {
|
import {
|
||||||
createChat,
|
createChat,
|
||||||
@@ -43,6 +44,7 @@ import {
|
|||||||
getSearch,
|
getSearch,
|
||||||
listWorkspaceItems,
|
listWorkspaceItems,
|
||||||
runCompletionStream,
|
runCompletionStream,
|
||||||
|
runQuickQuestionStream,
|
||||||
runSearchStream,
|
runSearchStream,
|
||||||
suggestChatTitle,
|
suggestChatTitle,
|
||||||
updateChatTitle,
|
updateChatTitle,
|
||||||
@@ -65,25 +67,38 @@ import {
|
|||||||
type WorkspaceItem,
|
type WorkspaceItem,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { useSessionAuth } from "@/hooks/use-session-auth";
|
import { useSessionAuth } from "@/hooks/use-session-auth";
|
||||||
|
import { useWorkspaceSearch } from "@/hooks/use-workspace-search";
|
||||||
import {
|
import {
|
||||||
getChatModelSelection,
|
getChatModelSelection,
|
||||||
getChatModelSelectionSyncKey,
|
getChatModelSelectionSyncKey,
|
||||||
} from "@/lib/chat-model-selection";
|
} from "@/lib/chat-model-selection";
|
||||||
import {
|
import {
|
||||||
filterSidebarItemsWithForkGroups,
|
|
||||||
getForkGroupPresentation,
|
getForkGroupPresentation,
|
||||||
groupForkedChatItems,
|
groupForkedChatItems,
|
||||||
shouldRequestChatTitle,
|
shouldRequestChatTitle,
|
||||||
} from "@/lib/chat-forking";
|
} from "@/lib/chat-forking";
|
||||||
import {
|
import {
|
||||||
|
retainSelectedHistoryItem,
|
||||||
resolveSidebarSelectionAfterRefresh,
|
resolveSidebarSelectionAfterRefresh,
|
||||||
type SidebarSelection,
|
type SidebarSelection,
|
||||||
} from "@/lib/sidebar-selection";
|
} from "@/lib/sidebar-selection";
|
||||||
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type DraftSelectionKind = "chat" | "search";
|
type DraftSelectionKind = "chat" | "search";
|
||||||
type SidebarItem = SidebarSelection & {
|
type SidebarItem = SidebarSelection & {
|
||||||
title: string;
|
title: string;
|
||||||
|
searchSnippet: string | null;
|
||||||
parentChatId: string | null;
|
parentChatId: string | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -149,7 +164,7 @@ function buildWorkspaceUrl(selection: SidebarSelection | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
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"],
|
anthropic: ["claude-3-5-sonnet-latest"],
|
||||||
xai: ["grok-3-mini"],
|
xai: ["grok-3-mini"],
|
||||||
gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
||||||
@@ -642,9 +657,10 @@ type ModelComboboxProps = {
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
disabled?: boolean;
|
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 [open, setOpen] = useState(false);
|
||||||
const [draftValue, setDraftValue] = useState(value);
|
const [draftValue, setDraftValue] = useState(value);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -698,7 +714,12 @@ function ModelCombobox({ options, value, onChange, disabled = false }: ModelComb
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={rootRef}>
|
<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
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={draftValue}
|
value={draftValue}
|
||||||
@@ -825,6 +846,7 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
|||||||
kind: "chat" as const,
|
kind: "chat" as const,
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
title: getChatTitle(chat),
|
title: getChatTitle(chat),
|
||||||
|
searchSnippet: chat.searchSnippet ?? null,
|
||||||
parentChatId: chat.parentChatId,
|
parentChatId: chat.parentChatId,
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
@@ -842,6 +864,7 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
|||||||
kind: "search" as const,
|
kind: "search" as const,
|
||||||
id: search.id,
|
id: search.id,
|
||||||
title: getSearchTitle(search),
|
title: getSearchTitle(search),
|
||||||
|
searchSnippet: search.searchSnippet ?? null,
|
||||||
parentChatId: null,
|
parentChatId: null,
|
||||||
updatedAt: search.updatedAt,
|
updatedAt: search.updatedAt,
|
||||||
createdAt: search.createdAt,
|
createdAt: search.createdAt,
|
||||||
@@ -936,7 +959,6 @@ export default function App() {
|
|||||||
isCheckingSession,
|
isCheckingSession,
|
||||||
isSigningIn,
|
isSigningIn,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
authMode,
|
|
||||||
authError,
|
authError,
|
||||||
handleAuthFailure: baseHandleAuthFailure,
|
handleAuthFailure: baseHandleAuthFailure,
|
||||||
handleSignIn,
|
handleSignIn,
|
||||||
@@ -974,7 +996,7 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
const [quickModel, setQuickModel] = useState(() => {
|
const [quickModel, setQuickModel] = useState(() => {
|
||||||
const stored = loadStoredQuickQuestionModelSelection();
|
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 [isQuickQuestionOpen, setIsQuickQuestionOpen] = useState(false);
|
||||||
const [quickPrompt, setQuickPrompt] = useState("");
|
const [quickPrompt, setQuickPrompt] = useState("");
|
||||||
@@ -1006,6 +1028,7 @@ export default function App() {
|
|||||||
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const renameChatInputRef = useRef<HTMLInputElement>(null);
|
const renameChatInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const chatSettingsDialogRef = useRef<HTMLDialogElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const dragDepthRef = useRef(0);
|
const dragDepthRef = useRef(0);
|
||||||
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
|
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
|
||||||
@@ -1034,6 +1057,10 @@ export default function App() {
|
|||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||||
const [sidebarQuery, setSidebarQuery] = useState("");
|
const [sidebarQuery, setSidebarQuery] = useState("");
|
||||||
|
const [sidebarSearchRevision, setSidebarSearchRevision] = useState(0);
|
||||||
|
const sidebarSearch = useWorkspaceSearch(sidebarQuery, isAuthenticated, sidebarSearchRevision);
|
||||||
|
const isSidebarSearchActive = Boolean(sidebarQuery.trim());
|
||||||
|
const selectedHistoryItemRef = useRef<WorkspaceItem | null>(null);
|
||||||
const initialRouteSelectionRef = useRef<SidebarSelection | null>(readSidebarSelectionFromUrl());
|
const initialRouteSelectionRef = useRef<SidebarSelection | null>(readSidebarSelectionFromUrl());
|
||||||
const hasSyncedSelectionHistoryRef = useRef(false);
|
const hasSyncedSelectionHistoryRef = useRef(false);
|
||||||
|
|
||||||
@@ -1099,19 +1126,16 @@ export default function App() {
|
|||||||
}, [pendingAttachments]);
|
}, [pendingAttachments]);
|
||||||
|
|
||||||
const sidebarItems = useMemo(() => buildSidebarItems(workspaceItems), [workspaceItems]);
|
const sidebarItems = useMemo(() => buildSidebarItems(workspaceItems), [workspaceItems]);
|
||||||
const filteredSidebarItems = useMemo(() => {
|
const searchSidebarItems = useMemo(() => buildSidebarItems(sidebarSearch.items), [sidebarSearch.items]);
|
||||||
const query = sidebarQuery.trim().toLowerCase();
|
const knownSidebarItems = useMemo(() => {
|
||||||
if (!query) return groupForkedChatItems(sidebarItems).flat();
|
const matches = new Set(searchSidebarItems.map((item) => `${item.kind}:${item.id}`));
|
||||||
return filterSidebarItemsWithForkGroups(sidebarItems, (item) => {
|
return [...searchSidebarItems, ...sidebarItems.filter((item) => !matches.has(`${item.kind}:${item.id}`))];
|
||||||
const providerLabel = getProviderLabel(item.lastUsedProvider || item.initiatedProvider).toLowerCase();
|
}, [sidebarItems, searchSidebarItems]);
|
||||||
return [item.title, item.initiatedModel, item.lastUsedModel, providerLabel]
|
|
||||||
.filter(Boolean)
|
|
||||||
.some((value) => String(value).toLowerCase().includes(query));
|
|
||||||
});
|
|
||||||
}, [sidebarItems, sidebarQuery]);
|
|
||||||
const sidebarSections = useMemo(
|
const sidebarSections = useMemo(
|
||||||
() => buildSidebarSections(filteredSidebarItems, sidebarItems),
|
() => isSidebarSearchActive
|
||||||
[filteredSidebarItems, sidebarItems]
|
? (searchSidebarItems.length ? [{ label: "SEARCH RESULTS", items: searchSidebarItems }] : [])
|
||||||
|
: buildSidebarSections(sidebarItems),
|
||||||
|
[isSidebarSearchActive, searchSidebarItems, sidebarItems]
|
||||||
);
|
);
|
||||||
const orderedSidebarItems = useMemo(() => sidebarSections.flatMap((section) => section.items), [sidebarSections]);
|
const orderedSidebarItems = useMemo(() => sidebarSections.flatMap((section) => section.items), [sidebarSections]);
|
||||||
|
|
||||||
@@ -1130,6 +1154,8 @@ export default function App() {
|
|||||||
setChats([]);
|
setChats([]);
|
||||||
setSearches([]);
|
setSearches([]);
|
||||||
setWorkspaceItems([]);
|
setWorkspaceItems([]);
|
||||||
|
setSidebarQuery("");
|
||||||
|
selectedHistoryItemRef.current = null;
|
||||||
setSelectedItem(null);
|
setSelectedItem(null);
|
||||||
setSelectedChat(null);
|
setSelectedChat(null);
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
@@ -1181,6 +1207,10 @@ export default function App() {
|
|||||||
resetWorkspaceState();
|
resetWorkspaceState();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sidebarSearch.error?.includes("bearer token")) handleAuthFailure(sidebarSearch.error);
|
||||||
|
}, [sidebarSearch.error]);
|
||||||
|
|
||||||
const refreshCollections = async ({
|
const refreshCollections = async ({
|
||||||
initialSelection,
|
initialSelection,
|
||||||
reportTransientError = true,
|
reportTransientError = true,
|
||||||
@@ -1193,12 +1223,14 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
setIsLoadingCollections(true);
|
setIsLoadingCollections(true);
|
||||||
try {
|
try {
|
||||||
const nextWorkspaceItems = await listWorkspaceItems();
|
let nextWorkspaceItems = await listWorkspaceItems();
|
||||||
if (requestId !== collectionLoadRequestRef.current) return;
|
if (requestId !== collectionLoadRequestRef.current) return;
|
||||||
|
nextWorkspaceItems = retainSelectedHistoryItem(nextWorkspaceItems, selectedItemRef.current, selectedHistoryItemRef.current);
|
||||||
const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems);
|
const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems);
|
||||||
setWorkspaceItems(nextWorkspaceItems);
|
setWorkspaceItems(nextWorkspaceItems);
|
||||||
setChats(nextChats);
|
setChats(nextChats);
|
||||||
setSearches(nextSearches);
|
setSearches(nextSearches);
|
||||||
|
setSidebarSearchRevision((current) => current + 1);
|
||||||
|
|
||||||
setSelectedItem((current) => {
|
setSelectedItem((current) => {
|
||||||
const nextSelection = resolveSidebarSelectionAfterRefresh(current, nextWorkspaceItems, {
|
const nextSelection = resolveSidebarSelectionAfterRefresh(current, nextWorkspaceItems, {
|
||||||
@@ -1431,7 +1463,7 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (quickModel.trim()) return;
|
if (quickModel.trim()) return;
|
||||||
setQuickModel((current) => {
|
setQuickModel((current) => {
|
||||||
return current.trim() || pickProviderModel(quickProviderModelOptions, quickProviderModelPreferences[quickProvider]);
|
return current.trim() || pickQuickQuestionModel(quickProvider, quickProviderModelOptions, quickProviderModelPreferences[quickProvider]);
|
||||||
});
|
});
|
||||||
}, [quickModel, quickProvider, quickProviderModelOptions, quickProviderModelPreferences]);
|
}, [quickModel, quickProvider, quickProviderModelOptions, quickProviderModelPreferences]);
|
||||||
|
|
||||||
@@ -1494,16 +1526,16 @@ export default function App() {
|
|||||||
const isItemRunning = (item: SidebarSelection) =>
|
const isItemRunning = (item: SidebarSelection) =>
|
||||||
item.kind === "chat" ? !!pendingChatStates[item.id] || !!activeRuns.chats[item.id] : !!runningSearchStates[item.id] || !!activeRuns.searches[item.id];
|
item.kind === "chat" ? !!pendingChatStates[item.id] || !!activeRuns.chats[item.id] : !!runningSearchStates[item.id] || !!activeRuns.searches[item.id];
|
||||||
const getChatFamilyItems = (chatId: string) => {
|
const getChatFamilyItems = (chatId: string) => {
|
||||||
const chat = sidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
|
const chat = knownSidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
|
||||||
if (!chat) return [];
|
if (!chat) return [];
|
||||||
const rootId = chat.parentChatId ?? chat.id;
|
const rootId = chat.parentChatId ?? chat.id;
|
||||||
return sidebarItems.filter(
|
return knownSidebarItems.filter(
|
||||||
(item) => item.kind === "chat" && (item.id === rootId || item.parentChatId === rootId)
|
(item) => item.kind === "chat" && (item.id === rootId || item.parentChatId === rootId)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const isRootChat = (item: SidebarSelection) => {
|
const isRootChat = (item: SidebarSelection) => {
|
||||||
if (item.kind !== "chat") return false;
|
if (item.kind !== "chat") return false;
|
||||||
const chat = sidebarItems.find((candidate) => candidate.kind === "chat" && candidate.id === item.id);
|
const chat = knownSidebarItems.find((candidate) => candidate.kind === "chat" && candidate.id === item.id);
|
||||||
return chat?.parentChatId === null;
|
return chat?.parentChatId === null;
|
||||||
};
|
};
|
||||||
const isDeleteBlocked = (item: SidebarSelection) => {
|
const isDeleteBlocked = (item: SidebarSelection) => {
|
||||||
@@ -1512,9 +1544,10 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
const isSidebarTargetStarred = (item: SidebarSelection) => {
|
const isSidebarTargetStarred = (item: SidebarSelection) => {
|
||||||
if (item.kind === "chat") {
|
if (item.kind === "chat") {
|
||||||
return getChatFamilyItems(item.id).find((familyItem) => familyItem.parentChatId === null)?.starred ?? false;
|
const family = getChatFamilyItems(item.id);
|
||||||
|
return (family.find((familyItem) => familyItem.parentChatId === null) ?? family[0])?.starred ?? false;
|
||||||
}
|
}
|
||||||
return sidebarItems.find((candidate) => candidate.kind === "search" && candidate.id === item.id)?.starred ?? false;
|
return knownSidebarItems.find((candidate) => candidate.kind === "search" && candidate.id === item.id)?.starred ?? false;
|
||||||
};
|
};
|
||||||
const isCurrentSelection = (item: SidebarSelection) => {
|
const isCurrentSelection = (item: SidebarSelection) => {
|
||||||
const current = selectedItemRef.current;
|
const current = selectedItemRef.current;
|
||||||
@@ -1910,14 +1943,26 @@ export default function App() {
|
|||||||
: Math.min(orderedSidebarItems.length - 1, Math.max(0, currentIndex + direction));
|
: Math.min(orderedSidebarItems.length - 1, Math.max(0, currentIndex + direction));
|
||||||
const nextItem = orderedSidebarItems[nextIndex];
|
const nextItem = orderedSidebarItems[nextIndex];
|
||||||
const target: SidebarSelection = { kind: nextItem.kind, id: nextItem.id };
|
const target: SidebarSelection = { kind: nextItem.kind, id: nextItem.id };
|
||||||
|
rememberHistoryResult(target);
|
||||||
selectedItemRef.current = target;
|
selectedItemRef.current = target;
|
||||||
setSelectedItem(target);
|
setSelectedItem(target);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rememberHistoryResult = (target: SidebarSelection) => {
|
||||||
|
const item = sidebarSearch.items.find((item) => item.type === target.kind && item.id === target.id);
|
||||||
|
if (!item) return;
|
||||||
|
selectedHistoryItemRef.current = item;
|
||||||
|
const { chats: matchedChats, searches: matchedSearches } = splitWorkspaceItems([item]);
|
||||||
|
if (matchedChats.length) setChats((current) => [...matchedChats, ...current.filter((chat) => chat.id !== item.id)]);
|
||||||
|
if (matchedSearches.length) setSearches((current) => [...matchedSearches, ...current.filter((search) => search.id !== item.id)]);
|
||||||
|
setWorkspaceItems((current) => upsertWorkspaceItem(current, item, false));
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (chatSettingsDialogRef.current?.open) return;
|
||||||
const hasPrimaryModifier = event.metaKey || event.ctrlKey;
|
const hasPrimaryModifier = event.metaKey || event.ctrlKey;
|
||||||
if (!hasPrimaryModifier || event.altKey) return;
|
if (!hasPrimaryModifier || event.altKey) return;
|
||||||
|
|
||||||
@@ -1956,11 +2001,15 @@ export default function App() {
|
|||||||
if (selectedChat?.id === chatId) return getChatTitle(selectedChat, selectedChat.messages);
|
if (selectedChat?.id === chatId) return getChatTitle(selectedChat, selectedChat.messages);
|
||||||
const summary = chats.find((chat) => chat.id === chatId);
|
const summary = chats.find((chat) => chat.id === chatId);
|
||||||
if (summary) return getChatTitle(summary);
|
if (summary) return getChatTitle(summary);
|
||||||
const sidebarItem = sidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
|
const sidebarItem = knownSidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
|
||||||
return sidebarItem?.title ?? "New chat";
|
return sidebarItem?.title ?? "New chat";
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyChatSummary = (updatedChat: ChatSummary, moveToFront = true) => {
|
const applyChatSummary = (updatedChat: ChatSummary, moveToFront = true) => {
|
||||||
|
setSidebarSearchRevision((current) => current + 1);
|
||||||
|
if (selectedHistoryItemRef.current?.type === "chat" && selectedHistoryItemRef.current.id === updatedChat.id) {
|
||||||
|
selectedHistoryItemRef.current = chatWorkspaceItem(updatedChat);
|
||||||
|
}
|
||||||
supersedeCollectionLoad();
|
supersedeCollectionLoad();
|
||||||
setChats((current) => {
|
setChats((current) => {
|
||||||
const withoutExisting = current.filter((chat) => chat.id !== updatedChat.id);
|
const withoutExisting = current.filter((chat) => chat.id !== updatedChat.id);
|
||||||
@@ -1993,6 +2042,10 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const applySearchSummary = (updatedSearch: SearchSummary, moveToFront = true) => {
|
const applySearchSummary = (updatedSearch: SearchSummary, moveToFront = true) => {
|
||||||
|
setSidebarSearchRevision((current) => current + 1);
|
||||||
|
if (selectedHistoryItemRef.current?.type === "search" && selectedHistoryItemRef.current.id === updatedSearch.id) {
|
||||||
|
selectedHistoryItemRef.current = searchWorkspaceItem(updatedSearch);
|
||||||
|
}
|
||||||
supersedeCollectionLoad();
|
supersedeCollectionLoad();
|
||||||
setSearches((current) => {
|
setSearches((current) => {
|
||||||
const withoutExisting = current.filter((search) => search.id !== updatedSearch.id);
|
const withoutExisting = current.filter((search) => search.id !== updatedSearch.id);
|
||||||
@@ -2179,7 +2232,7 @@ export default function App() {
|
|||||||
const family = getChatFamilyItems(chatId);
|
const family = getChatFamilyItems(chatId);
|
||||||
const root = family.find((item) => item.parentChatId === null) ?? family[0];
|
const root = family.find((item) => item.parentChatId === null) ?? family[0];
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
applyChatSummary(await updateChatStar(root.id, !root.starred), false);
|
applyChatSummary(await updateChatStar(root.parentChatId ?? root.id, !root.starred), false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleStar = async (target: SidebarSelection) => {
|
const handleToggleStar = async (target: SidebarSelection) => {
|
||||||
@@ -2190,7 +2243,7 @@ export default function App() {
|
|||||||
if (target.kind === "chat") {
|
if (target.kind === "chat") {
|
||||||
await toggleChatFamilyStar(target.id);
|
await toggleChatFamilyStar(target.id);
|
||||||
} else {
|
} else {
|
||||||
const current = sidebarItems.find((item) => item.kind === "search" && item.id === target.id);
|
const current = knownSidebarItems.find((item) => item.kind === "search" && item.id === target.id);
|
||||||
const updatedSearch = await updateSearchStar(target.id, !current?.starred);
|
const updatedSearch = await updateSearchStar(target.id, !current?.starred);
|
||||||
applySearchSummary(updatedSearch, false);
|
applySearchSummary(updatedSearch, false);
|
||||||
}
|
}
|
||||||
@@ -2282,6 +2335,11 @@ export default function App() {
|
|||||||
} else {
|
} else {
|
||||||
await deleteSearch(target.id);
|
await deleteSearch(target.id);
|
||||||
}
|
}
|
||||||
|
const retained = selectedHistoryItemRef.current;
|
||||||
|
if (retained && ((retained.type === target.kind && retained.id === target.id) ||
|
||||||
|
(retained.type === "chat" && target.kind === "chat" && retained.parentChatId === target.id))) {
|
||||||
|
selectedHistoryItemRef.current = null;
|
||||||
|
}
|
||||||
await refreshCollections({ selectFallback: true });
|
await refreshCollections({ selectFallback: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
@@ -2319,16 +2377,16 @@ export default function App() {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [renameChatDialog]);
|
}, [renameChatDialog]);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isChatSettingsOpen) return;
|
if (!isChatSettingsOpen) return;
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const dialog = chatSettingsDialogRef.current;
|
||||||
if (event.key !== "Escape" || isSavingChatSettings) return;
|
const previouslyFocused = document.activeElement;
|
||||||
event.preventDefault();
|
dialog?.showModal();
|
||||||
setIsChatSettingsOpen(false);
|
return () => {
|
||||||
|
dialog?.close();
|
||||||
|
if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus({ preventScroll: true });
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
}, [isChatSettingsOpen]);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [isChatSettingsOpen, isSavingChatSettings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isQuickQuestionOpen) return;
|
if (!isQuickQuestionOpen) return;
|
||||||
@@ -2484,7 +2542,7 @@ export default function App() {
|
|||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "",
|
content: "",
|
||||||
name: null,
|
name: null,
|
||||||
metadata: null,
|
metadata: createStreamingAssistantMetadata(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null;
|
let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null;
|
||||||
@@ -2589,7 +2647,7 @@ export default function App() {
|
|||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
let streamErrorMessage: string | null = null;
|
let streamErrorMessage: string | null = null;
|
||||||
let replayedAssistantText = "";
|
let streamingAttempt = createStreamingAttemptState();
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
chatStreamAbortRefs.current.set(chatId, abortController);
|
chatStreamAbortRefs.current.set(chatId, abortController);
|
||||||
|
|
||||||
@@ -2607,45 +2665,39 @@ export default function App() {
|
|||||||
if (payload.chatId !== chatId) return;
|
if (payload.chatId !== chatId) return;
|
||||||
},
|
},
|
||||||
onToolCall: (payload) => {
|
onToolCall: (payload) => {
|
||||||
|
const transition = transitionStreamingToolCall(streamingAttempt, payload);
|
||||||
|
streamingAttempt = transition.attempt;
|
||||||
setPendingChatStates((current) => {
|
setPendingChatStates((current) => {
|
||||||
const pendingState = current[chatId];
|
const pendingState = current[chatId];
|
||||||
if (!pendingState) return current;
|
if (!pendingState) return current;
|
||||||
|
const messages = transition.startedNewToolCall
|
||||||
|
? promoteStreamingAssistantTrace(pendingState.messages, "temp-assistant-", transition.traceText ?? "")
|
||||||
|
: pendingState.messages;
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
[chatId]: {
|
[chatId]: {
|
||||||
messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"),
|
messages: upsertOptimisticToolMessage(messages, payload, "temp-assistant-"),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDelta: (payload) => {
|
onDelta: (payload) => {
|
||||||
if (!payload.text) return;
|
if (!payload.text) return;
|
||||||
replayedAssistantText += payload.text;
|
streamingAttempt = appendStreamingDelta(streamingAttempt, payload.text);
|
||||||
|
const segmentText = streamingAttempt.segmentText;
|
||||||
setPendingChatStates((current) => {
|
setPendingChatStates((current) => {
|
||||||
const pendingState = current[chatId];
|
const pendingState = current[chatId];
|
||||||
if (!pendingState) return current;
|
if (!pendingState) return current;
|
||||||
let updated = false;
|
const nextMessages = setStreamingAssistantSegment(pendingState.messages, "temp-assistant-", segmentText);
|
||||||
const nextMessages = pendingState.messages.map((message, index, all) => {
|
return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } };
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDone: (payload) => {
|
onDone: (payload) => {
|
||||||
setPendingChatStates((current) => {
|
setPendingChatStates((current) => {
|
||||||
const pendingState = current[chatId];
|
const pendingState = current[chatId];
|
||||||
if (!pendingState) return current;
|
if (!pendingState) return current;
|
||||||
let updated = false;
|
const nextMessages = finalizeStreamingAssistant(pendingState.messages, "temp-assistant-", payload.text);
|
||||||
const nextMessages = pendingState.messages.map((message, index, all) => {
|
return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } };
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (payload) => {
|
onError: (payload) => {
|
||||||
@@ -2894,6 +2946,7 @@ export default function App() {
|
|||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
chatStreamAbortRefs.current.set(chatId, abortController);
|
chatStreamAbortRefs.current.set(chatId, abortController);
|
||||||
let streamErrorMessage: string | null = null;
|
let streamErrorMessage: string | null = null;
|
||||||
|
let streamingAttempt = createStreamingAttemptState();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const baseChat = await getChat(chatId);
|
const baseChat = await getChat(chatId);
|
||||||
@@ -2910,7 +2963,7 @@ export default function App() {
|
|||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "",
|
content: "",
|
||||||
name: null,
|
name: null,
|
||||||
metadata: null,
|
metadata: createStreamingAssistantMetadata(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -2920,44 +2973,39 @@ export default function App() {
|
|||||||
chatId,
|
chatId,
|
||||||
{
|
{
|
||||||
onToolCall: (payload) => {
|
onToolCall: (payload) => {
|
||||||
|
const transition = transitionStreamingToolCall(streamingAttempt, payload);
|
||||||
|
streamingAttempt = transition.attempt;
|
||||||
setPendingChatStates((current) => {
|
setPendingChatStates((current) => {
|
||||||
const pendingState = current[chatId];
|
const pendingState = current[chatId];
|
||||||
if (!pendingState) return current;
|
if (!pendingState) return current;
|
||||||
|
const messages = transition.startedNewToolCall
|
||||||
|
? promoteStreamingAssistantTrace(pendingState.messages, "temp-assistant-", transition.traceText ?? "")
|
||||||
|
: pendingState.messages;
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
[chatId]: {
|
[chatId]: {
|
||||||
messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"),
|
messages: upsertOptimisticToolMessage(messages, payload, "temp-assistant-"),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDelta: (payload) => {
|
onDelta: (payload) => {
|
||||||
if (!payload.text) return;
|
if (!payload.text) return;
|
||||||
|
streamingAttempt = appendStreamingDelta(streamingAttempt, payload.text);
|
||||||
|
const segmentText = streamingAttempt.segmentText;
|
||||||
setPendingChatStates((current) => {
|
setPendingChatStates((current) => {
|
||||||
const pendingState = current[chatId];
|
const pendingState = current[chatId];
|
||||||
if (!pendingState) return current;
|
if (!pendingState) return current;
|
||||||
let updated = false;
|
const nextMessages = setStreamingAssistantSegment(pendingState.messages, "temp-assistant-", segmentText);
|
||||||
const nextMessages = pendingState.messages.map((message, index, all) => {
|
return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } };
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDone: (payload) => {
|
onDone: (payload) => {
|
||||||
setPendingChatStates((current) => {
|
setPendingChatStates((current) => {
|
||||||
const pendingState = current[chatId];
|
const pendingState = current[chatId];
|
||||||
if (!pendingState) return current;
|
if (!pendingState) return current;
|
||||||
let updated = false;
|
const nextMessages = finalizeStreamingAssistant(pendingState.messages, "temp-assistant-", payload.text);
|
||||||
const nextMessages = pendingState.messages.map((message, index, all) => {
|
return nextMessages === pendingState.messages ? current : { ...current, [chatId]: { messages: nextMessages } };
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (payload) => {
|
onError: (payload) => {
|
||||||
@@ -3209,7 +3257,7 @@ export default function App() {
|
|||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "",
|
content: "",
|
||||||
name: null,
|
name: null,
|
||||||
metadata: null,
|
metadata: createStreamingAssistantMetadata(),
|
||||||
};
|
};
|
||||||
|
|
||||||
quickQuestionAbortRef.current?.abort();
|
quickQuestionAbortRef.current?.abort();
|
||||||
@@ -3223,44 +3271,37 @@ export default function App() {
|
|||||||
setIsQuickQuestionSending(true);
|
setIsQuickQuestionSending(true);
|
||||||
|
|
||||||
let streamErrorMessage: string | null = null;
|
let streamErrorMessage: string | null = null;
|
||||||
|
let streamingAttempt = createStreamingAttemptState();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runCompletionStream(
|
await runQuickQuestionStream(
|
||||||
{
|
buildQuickQuestionRequest({
|
||||||
persist: false,
|
|
||||||
provider: quickProvider,
|
provider: quickProvider,
|
||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
messages: [{ role: "user", content }],
|
content,
|
||||||
},
|
}),
|
||||||
{
|
{
|
||||||
onToolCall: (payload) => {
|
onToolCall: (payload) => {
|
||||||
|
const transition = transitionStreamingToolCall(streamingAttempt, payload);
|
||||||
|
streamingAttempt = transition.attempt;
|
||||||
setQuickQuestionMessages((current) => {
|
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) => {
|
onDelta: (payload) => {
|
||||||
if (!payload.text) return;
|
if (!payload.text) return;
|
||||||
|
streamingAttempt = appendStreamingDelta(streamingAttempt, payload.text);
|
||||||
|
const segmentText = streamingAttempt.segmentText;
|
||||||
setQuickQuestionMessages((current) => {
|
setQuickQuestionMessages((current) => {
|
||||||
let updated = false;
|
return setStreamingAssistantSegment(current, "temp-assistant-quick-", segmentText);
|
||||||
const nextMessages = current.map((message, index, all) => {
|
|
||||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-quick-");
|
|
||||||
if (!isTarget) return message;
|
|
||||||
updated = true;
|
|
||||||
return { ...message, content: message.content + payload.text };
|
|
||||||
});
|
|
||||||
return updated ? nextMessages : current;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDone: (payload) => {
|
onDone: (payload) => {
|
||||||
setQuickQuestionMessages((current) => {
|
setQuickQuestionMessages((current) => {
|
||||||
let updated = false;
|
return finalizeStreamingAssistant(current, "temp-assistant-quick-", payload.text);
|
||||||
const nextMessages = current.map((message, index, all) => {
|
|
||||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-quick-");
|
|
||||||
if (!isTarget) return message;
|
|
||||||
updated = true;
|
|
||||||
return { ...message, content: payload.text };
|
|
||||||
});
|
|
||||||
return updated ? nextMessages : current;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (payload) => {
|
onError: (payload) => {
|
||||||
@@ -3275,6 +3316,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (abortController.signal.aborted) return;
|
if (abortController.signal.aborted) return;
|
||||||
|
setQuickQuestionMessages((current) => clearStreamingAssistantTrace(current, "temp-assistant-quick-"));
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
if (message.includes("bearer token")) {
|
if (message.includes("bearer token")) {
|
||||||
handleAuthFailure(message);
|
handleAuthFailure(message);
|
||||||
@@ -3437,7 +3479,7 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-grid-surface app-safe-frame h-full">
|
<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 ? (
|
{isMobileSidebarOpen ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -3449,20 +3491,14 @@ export default function App() {
|
|||||||
|
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
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"
|
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="relative min-h-24 px-4 pb-4 pt-5 flex items-center">
|
||||||
<div className="pr-24">
|
|
||||||
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
|
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
|
||||||
SYBIL
|
SYBIL
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
|
||||||
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SybilCharacter
|
<SybilCharacter
|
||||||
className="absolute right-4 top-3 h-[calc(100%-1.5rem)]"
|
className="absolute right-4 top-3 h-[calc(100%-1.5rem)]"
|
||||||
isBusy={isActiveSelectionSending || isQuickQuestionSending}
|
isBusy={isActiveSelectionSending || isQuickQuestionSending}
|
||||||
@@ -3506,33 +3542,55 @@ export default function App() {
|
|||||||
<input
|
<input
|
||||||
value={sidebarQuery}
|
value={sidebarQuery}
|
||||||
onInput={(event) => setSidebarQuery(event.currentTarget.value)}
|
onInput={(event) => setSidebarQuery(event.currentTarget.value)}
|
||||||
placeholder="Search chats"
|
placeholder="Search chats and web searches"
|
||||||
className="h-10 w-full rounded-lg border border-violet-300/18 bg-background/66 pl-9 pr-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.05)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
aria-label="Search conversation history and saved web searches"
|
||||||
|
autoComplete="off"
|
||||||
|
spellcheck={false}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Escape") setSidebarQuery("");
|
||||||
|
}}
|
||||||
|
className="h-10 w-full rounded-lg border border-violet-300/18 bg-background/66 pl-9 pr-9 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.05)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
||||||
/>
|
/>
|
||||||
|
{sidebarQuery ? (
|
||||||
|
<button type="button" onClick={() => setSidebarQuery("")} aria-label="Clear history search"
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-violet-200/70 hover:text-violet-50">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="bg-violet-300/10" />
|
<Separator className="bg-violet-300/10" />
|
||||||
<div className="flex-1 overflow-y-auto px-2 py-3">
|
<div className="flex-1 overflow-y-auto px-2 py-3">
|
||||||
{isLoadingCollections && sidebarItems.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p> : null}
|
{!isSidebarSearchActive && isLoadingCollections && sidebarItems.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p> : null}
|
||||||
{!isLoadingCollections && sidebarItems.length === 0 ? (
|
{!isSidebarSearchActive && !isLoadingCollections && sidebarItems.length === 0 ? (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-sm text-muted-foreground">
|
<div className="flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-sm text-muted-foreground">
|
||||||
<MessageSquare className="h-5 w-5" />
|
<MessageSquare className="h-5 w-5" />
|
||||||
Start a chat or run your first search.
|
Start a chat or run your first search.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{!isLoadingCollections && sidebarItems.length > 0 && filteredSidebarItems.length === 0 ? (
|
{isSidebarSearchActive ? (
|
||||||
<p className="px-2 py-3 text-sm text-muted-foreground">No chats found.</p>
|
<div aria-live="polite" role="status">
|
||||||
|
{sidebarSearch.isLoading ? <p className="flex items-center gap-2 px-3 py-3 text-sm text-muted-foreground"><LoaderCircle className="h-4 w-4 animate-spin" />Searching history…</p> : null}
|
||||||
|
{sidebarSearch.error ? (
|
||||||
|
<div className="space-y-2 px-3 py-3 text-sm">
|
||||||
|
<p className="text-red-300">{sidebarSearch.error}</p>
|
||||||
|
<Button variant="secondary" onClick={() => setSidebarSearchRevision((current) => current + 1)}>Try again</Button>
|
||||||
|
</div>
|
||||||
|
) : !sidebarSearch.isLoading && searchSidebarItems.length === 0 ? (
|
||||||
|
<p className="px-3 py-3 text-sm text-muted-foreground">No matching chats or web searches. Try a title, query, or words from a conversation.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{sidebarSections.map((section) => (
|
{sidebarSections.map((section) => (
|
||||||
<div key={section.label} className="mb-4">
|
<div key={section.label} className="mb-4">
|
||||||
<p className="px-3 pb-2 text-[11px] font-semibold text-violet-200/48">{section.label}</p>
|
<p className="px-3 pb-2 text-[11px] font-semibold text-violet-200/48">{section.label}</p>
|
||||||
{section.items.map((item) => {
|
{section.items.map((item) => {
|
||||||
const active = selectedItem?.kind === item.kind && selectedItem.id === item.id;
|
const active = selectedItem?.kind === item.kind && selectedItem.id === item.id;
|
||||||
const isForkChild = item.kind === "chat" && item.parentChatId !== null;
|
const isForkChild = !isSidebarSearchActive && item.kind === "chat" && item.parentChatId !== null;
|
||||||
const familyItems = item.kind === "chat" ? getChatFamilyItems(item.id) : [item];
|
const familyItems = !isSidebarSearchActive && item.kind === "chat" ? getChatFamilyItems(item.id) : [item];
|
||||||
const itemIsRunning = familyItems.some((familyItem) => isItemRunning(familyItem));
|
const itemIsRunning = familyItems.some((familyItem) => isItemRunning(familyItem));
|
||||||
const itemIsStarred =
|
const itemIsStarred =
|
||||||
familyItems.find((familyItem) => familyItem.parentChatId === null)?.starred ?? false;
|
familyItems.find((familyItem) => familyItem.parentChatId === null)?.starred ?? item.starred;
|
||||||
const itemUpdatedAt = familyItems.reduce(
|
const itemUpdatedAt = familyItems.reduce(
|
||||||
(newest, familyItem) =>
|
(newest, familyItem) =>
|
||||||
new Date(familyItem.updatedAt).getTime() > new Date(newest).getTime()
|
new Date(familyItem.updatedAt).getTime() > new Date(newest).getTime()
|
||||||
@@ -3558,6 +3616,7 @@ export default function App() {
|
|||||||
draftKindRef.current = null;
|
draftKindRef.current = null;
|
||||||
setDraftKind(null);
|
setDraftKind(null);
|
||||||
const target = { kind: item.kind, id: item.id } as SidebarSelection;
|
const target = { kind: item.kind, id: item.id } as SidebarSelection;
|
||||||
|
rememberHistoryResult(target);
|
||||||
selectedItemRef.current = target;
|
selectedItemRef.current = target;
|
||||||
setSelectedItem(target);
|
setSelectedItem(target);
|
||||||
setIsMobileSidebarOpen(false);
|
setIsMobileSidebarOpen(false);
|
||||||
@@ -3576,7 +3635,7 @@ export default function App() {
|
|||||||
active ? "border-cyan-200/35 bg-cyan-300/12 text-cyan-100" : "border-violet-300/18 text-violet-200/70"
|
active ? "border-cyan-200/35 bg-cyan-300/12 text-cyan-100" : "border-violet-300/18 text-violet-200/70"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.kind === "chat" ? <MessageSquare className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />}
|
{item.kind === "chat" ? (item.parentChatId ? <GitFork className="h-3.5 w-3.5" aria-label="Fork" /> : <MessageSquare className="h-3.5 w-3.5" />) : <Search className="h-3.5 w-3.5" />}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||||
<span className="truncate text-sm font-semibold">{item.title}</span>
|
<span className="truncate text-sm font-semibold">{item.title}</span>
|
||||||
@@ -3601,6 +3660,9 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
{isSidebarSearchActive ? (
|
||||||
|
<SearchMatchLabel text={item.searchSnippet || "Title match"} query={item.searchSnippet ? sidebarQuery : ""} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -3608,11 +3670,14 @@ export default function App() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{isSidebarSearchActive && sidebarSearch.hasMore ? (
|
||||||
|
<p className="px-3 py-2 text-xs text-muted-foreground">Showing the 100 most recent matches. Refine your search to find more.</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main
|
<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}
|
onTouchStart={handleMobileWorkspaceTouchStart}
|
||||||
onTouchMove={handleMobileWorkspaceTouchMove}
|
onTouchMove={handleMobileWorkspaceTouchMove}
|
||||||
onTouchEnd={handleMobileWorkspaceTouchEnd}
|
onTouchEnd={handleMobileWorkspaceTouchEnd}
|
||||||
@@ -3826,44 +3891,66 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isChatSettingsOpen ? (
|
{isChatSettingsOpen ? (
|
||||||
<div
|
<dialog
|
||||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
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) => {
|
onMouseDown={(event) => {
|
||||||
if (event.target === event.currentTarget && !isSavingChatSettings) setIsChatSettingsOpen(false);
|
if (event.target === event.currentTarget && !isSavingChatSettings) setIsChatSettingsOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
role="dialog"
|
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"
|
||||||
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"
|
|
||||||
onSubmit={(event) => void handleChatSettingsSubmit(event)}
|
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">
|
<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
|
Chat settings
|
||||||
</h2>
|
</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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-8 w-8"
|
className="hidden h-8 w-8 shrink-0 md:inline-flex"
|
||||||
onClick={() => setIsChatSettingsOpen(false)}
|
onClick={() => setIsChatSettingsOpen(false)}
|
||||||
disabled={isSavingChatSettings}
|
disabled={isSavingChatSettings}
|
||||||
aria-label="Close chat settings"
|
aria-label="Close chat settings"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</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>
|
||||||
|
|
||||||
<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>
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
id="chat-settings-chat-title"
|
||||||
value={chatSettingsTitleDraft}
|
value={chatSettingsTitleDraft}
|
||||||
onInput={(event) => {
|
onInput={(event) => {
|
||||||
setChatSettingsTitleDraft(event.currentTarget.value);
|
setChatSettingsTitleDraft(event.currentTarget.value);
|
||||||
@@ -3871,7 +3958,7 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
maxLength={120}
|
maxLength={120}
|
||||||
placeholder={draftKind === null && selectedItem?.kind === "chat" ? "Chat title" : "Optional title"}
|
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}
|
disabled={isSavingChatSettings}
|
||||||
/>
|
/>
|
||||||
{chatSettingsChatId ? (
|
{chatSettingsChatId ? (
|
||||||
@@ -3899,7 +3986,7 @@ export default function App() {
|
|||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Provider</span>
|
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Provider</span>
|
||||||
<select
|
<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}
|
value={chatSettingsProviderDraft}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const nextProvider = event.currentTarget.value as Provider;
|
const nextProvider = event.currentTarget.value as Provider;
|
||||||
@@ -3921,6 +4008,7 @@ export default function App() {
|
|||||||
<label className="block min-w-0">
|
<label className="block min-w-0">
|
||||||
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Model</span>
|
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Model</span>
|
||||||
<ModelCombobox
|
<ModelCombobox
|
||||||
|
className="h-11 min-w-0 bg-background/70 text-base md:h-10 md:text-sm"
|
||||||
options={chatSettingsProviderModelOptions}
|
options={chatSettingsProviderModelOptions}
|
||||||
value={chatSettingsModelDraft}
|
value={chatSettingsModelDraft}
|
||||||
disabled={isSavingChatSettings}
|
disabled={isSavingChatSettings}
|
||||||
@@ -3942,7 +4030,7 @@ export default function App() {
|
|||||||
if (chatSettingsError) setChatSettingsError(null);
|
if (chatSettingsError) setChatSettingsError(null);
|
||||||
}}
|
}}
|
||||||
placeholder="Add per-chat instructions"
|
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}
|
disabled={isSavingChatSettings}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -3956,6 +4044,7 @@ export default function App() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
className="h-11 gap-1.5 md:h-8"
|
||||||
onClick={() => setChatSettingsEnabledToolsDraft(getDefaultEnabledTools(availableChatTools))}
|
onClick={() => setChatSettingsEnabledToolsDraft(getDefaultEnabledTools(availableChatTools))}
|
||||||
disabled={isSavingChatSettings}
|
disabled={isSavingChatSettings}
|
||||||
>
|
>
|
||||||
@@ -3966,6 +4055,7 @@ export default function App() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
className="h-11 gap-1.5 md:h-8"
|
||||||
onClick={() => setChatSettingsEnabledToolsDraft([])}
|
onClick={() => setChatSettingsEnabledToolsDraft([])}
|
||||||
disabled={isSavingChatSettings}
|
disabled={isSavingChatSettings}
|
||||||
>
|
>
|
||||||
@@ -3975,31 +4065,30 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
{availableChatTools.length ? (
|
{availableChatTools.length ? (
|
||||||
availableChatTools.map((tool) => {
|
availableChatTools.map((tool) => {
|
||||||
const checked = chatSettingsEnabledToolsDraft.includes(tool.name);
|
const checked = chatSettingsEnabledToolsDraft.includes(tool.name);
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={tool.name}
|
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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={() => toggleChatSettingsTool(tool.name)}
|
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}
|
disabled={isSavingChatSettings}
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0 break-words text-sm font-medium text-violet-50">{getToolLabel(tool.name)}</span>
|
||||||
<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>
|
|
||||||
</label>
|
</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.
|
No chat tools are available.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -4007,8 +4096,8 @@ export default function App() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{chatSettingsError ? <p className="mt-3 text-sm text-rose-300">{chatSettingsError}</p> : null}
|
{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="mt-4 flex justify-end gap-2">
|
<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}>
|
<Button type="button" variant="secondary" onClick={() => setIsChatSettingsOpen(false)} disabled={isSavingChatSettings}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -4018,7 +4107,7 @@ export default function App() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</dialog>
|
||||||
) : null}
|
) : null}
|
||||||
{renameChatDialog ? (
|
{renameChatDialog ? (
|
||||||
<div
|
<div
|
||||||
@@ -4077,7 +4166,7 @@ export default function App() {
|
|||||||
) : null}
|
) : null}
|
||||||
{isQuickQuestionOpen ? (
|
{isQuickQuestionOpen ? (
|
||||||
<div
|
<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) => {
|
onMouseDown={(event) => {
|
||||||
if (event.target === event.currentTarget) setIsQuickQuestionOpen(false);
|
if (event.target === event.currentTarget) setIsQuickQuestionOpen(false);
|
||||||
}}
|
}}
|
||||||
@@ -4155,7 +4244,7 @@ export default function App() {
|
|||||||
const nextProvider = event.currentTarget.value as Provider;
|
const nextProvider = event.currentTarget.value as Provider;
|
||||||
setQuickProvider(nextProvider);
|
setQuickProvider(nextProvider);
|
||||||
const options = getModelOptions(modelCatalog, nextProvider);
|
const options = getModelOptions(modelCatalog, nextProvider);
|
||||||
setQuickModel(pickProviderModel(options, quickProviderModelPreferences[nextProvider]));
|
setQuickModel(pickQuickQuestionModel(nextProvider, options, quickProviderModelPreferences[nextProvider]));
|
||||||
}}
|
}}
|
||||||
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
|
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
|
||||||
aria-label="Quick question provider"
|
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 type { ComponentChildren, JSX } from "preact";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||||
import { getMessageAttachments, type Message } from "@/lib/api";
|
import { getMessageAttachments, type Message } from "@/lib/api";
|
||||||
|
import { asStreamingAssistantMetadata } from "@/lib/chat-stream-presentation";
|
||||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
||||||
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
|
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
|
||||||
|
|
||||||
@@ -377,13 +378,13 @@ function ToolCallStack({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{!expanded && hiddenCount ? (
|
{!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}
|
+{hiddenCount}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="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-expanded={expanded ? "true" : "false"}
|
||||||
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
|
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
|
||||||
title={`${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) {
|
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 renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
||||||
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
|
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
|
||||||
const seenToolCallMessageIDsRef = useRef<Set<string> | null>(null);
|
const seenToolCallMessageIDsRef = useRef<Set<string> | null>(null);
|
||||||
@@ -457,6 +594,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
|
const streamingAssistantMetadata = asStreamingAssistantMetadata(message.metadata);
|
||||||
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0;
|
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0;
|
||||||
const attachments = getMessageAttachments(message.metadata);
|
const attachments = getMessageAttachments(message.metadata);
|
||||||
return (
|
return (
|
||||||
@@ -465,7 +603,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
|||||||
className={cn(
|
className={cn(
|
||||||
"max-w-[85%] space-y-3",
|
"max-w-[85%] space-y-3",
|
||||||
isUser
|
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"
|
: "text-base leading-7 text-violet-50"
|
||||||
)}
|
)}
|
||||||
onContextMenu={
|
onContextMenu={
|
||||||
@@ -475,7 +613,9 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
|
{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-flex items-center gap-1" aria-label="Assistant is typing" role="status">
|
||||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
|
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
|
||||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
|
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/// <reference lib="es2022.intl" />
|
||||||
|
import { useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
const words = /[\p{L}\p{N}\p{M}\p{Co}]+/gu;
|
||||||
|
const fold = (text: string) => text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
|
||||||
|
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||||
|
|
||||||
|
export function SearchMatchLabel({ text: source, query }: { text: string; query: string }) {
|
||||||
|
const text = source.replace(/\s+/gu, " ").trim();
|
||||||
|
const containerRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const measureRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const ellipsisRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const [kept, setKept] = useState<number | null>(null);
|
||||||
|
const boundaries = useMemo(() => [0, ...Array.from(graphemes.segment(text), ({ index, segment }) => index + segment.length)], [text]);
|
||||||
|
const runs = useMemo(() => {
|
||||||
|
const terms = (query.match(words) ?? []).map(fold).filter(Boolean);
|
||||||
|
const result: Array<{ start: number; end: number; match: boolean }> = [];
|
||||||
|
let cursor = 0;
|
||||||
|
for (const word of text.matchAll(words)) {
|
||||||
|
if (word.index > cursor) result.push({ start: cursor, end: word.index, match: false });
|
||||||
|
cursor = word.index + word[0].length;
|
||||||
|
result.push({ start: word.index, end: cursor, match: terms.some((term) => fold(word[0]).startsWith(term)) });
|
||||||
|
}
|
||||||
|
if (cursor < text.length) result.push({ start: cursor, end: text.length, match: false });
|
||||||
|
return result;
|
||||||
|
}, [text, query]);
|
||||||
|
|
||||||
|
const renderRange = (start: number, end: number) => runs.flatMap((run) => {
|
||||||
|
const value = text.slice(Math.max(start, run.start), Math.min(end, run.end));
|
||||||
|
if (run.end <= start || run.start >= end) return [];
|
||||||
|
return [run.match
|
||||||
|
? <strong key={`${start}:${run.start}`} className="font-semibold text-violet-100/95">{value}</strong>
|
||||||
|
: <span key={`${start}:${run.start}`}>{value}</span>];
|
||||||
|
});
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
const measure = measureRef.current;
|
||||||
|
const ellipsis = ellipsisRef.current;
|
||||||
|
if (!container || !measure || !ellipsis) return;
|
||||||
|
let disposed = false;
|
||||||
|
const update = () => {
|
||||||
|
if (disposed) return;
|
||||||
|
const width = container.getBoundingClientRect().width;
|
||||||
|
if (measure.getBoundingClientRect().width <= width) { setKept(null); return; }
|
||||||
|
// Measure the styled DOM, including bold runs, instead of guessing a
|
||||||
|
// character limit. Grapheme boundaries keep accents and emoji intact.
|
||||||
|
const nodes = Array.from(measure.children, (child) => child.firstChild as Text);
|
||||||
|
const range = document.createRange();
|
||||||
|
const measureRange = (start: number, end: number) => {
|
||||||
|
if (start >= end) return 0;
|
||||||
|
let offset = 0;
|
||||||
|
let started = false;
|
||||||
|
for (const node of nodes) {
|
||||||
|
const next = offset + node.length;
|
||||||
|
if (!started && start <= next) { range.setStart(node, start - offset); started = true; }
|
||||||
|
if (end <= next) { range.setEnd(node, end - offset); break; }
|
||||||
|
offset = next;
|
||||||
|
}
|
||||||
|
return range.getBoundingClientRect().width;
|
||||||
|
};
|
||||||
|
const count = boundaries.length - 1;
|
||||||
|
const ellipsisWidth = ellipsis.getBoundingClientRect().width;
|
||||||
|
let low = 0;
|
||||||
|
let high = count - 1;
|
||||||
|
while (low < high) {
|
||||||
|
const middle = Math.ceil((low + high) / 2);
|
||||||
|
const prefix = boundaries[Math.ceil(middle / 2)];
|
||||||
|
const suffix = boundaries[count - Math.floor(middle / 2)];
|
||||||
|
if (measureRange(0, prefix) + ellipsisWidth + measureRange(suffix, text.length) <= width - 1) low = middle;
|
||||||
|
else high = middle - 1;
|
||||||
|
}
|
||||||
|
setKept(low);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
const observer = new ResizeObserver(update);
|
||||||
|
observer.observe(container);
|
||||||
|
void document.fonts.ready.then(update);
|
||||||
|
document.fonts.addEventListener("loadingdone", update);
|
||||||
|
return () => { disposed = true; observer.disconnect(); document.fonts.removeEventListener("loadingdone", update); };
|
||||||
|
}, [text, runs, boundaries]);
|
||||||
|
|
||||||
|
const count = boundaries.length - 1;
|
||||||
|
const truncated = kept !== null && kept < count;
|
||||||
|
return (
|
||||||
|
<span ref={containerRef} title={text} className="relative col-start-2 block min-w-0 overflow-hidden whitespace-nowrap text-xs leading-5 text-secondary-foreground/75">
|
||||||
|
<span className="sr-only">{text}</span>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
{truncated ? <>{renderRange(0, boundaries[Math.ceil(kept / 2)])}…{renderRange(boundaries[count - Math.floor(kept / 2)], text.length)}</> : renderRange(0, text.length)}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden="true" className="pointer-events-none invisible absolute left-0 top-0 whitespace-pre">
|
||||||
|
<span ref={measureRef}>{renderRange(0, text.length)}</span><span ref={ellipsisRef}>…</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import { searchWorkspaceItems, type WorkspaceItem, type WorkspaceSearchResponse } from "@/lib/api";
|
||||||
|
import { scheduleWorkspaceSearch } from "@/lib/workspace-search";
|
||||||
|
|
||||||
|
type SearchState = WorkspaceSearchResponse & { query: string; revision: number; error: string | null };
|
||||||
|
const EMPTY_ITEMS: WorkspaceItem[] = [];
|
||||||
|
|
||||||
|
export function useWorkspaceSearch(query: string, enabled: boolean, revision: number) {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
const [state, setState] = useState<SearchState | null>(null);
|
||||||
|
const validationError = trimmed.length > 200 ? "Use 200 characters or fewer." : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setState(null);
|
||||||
|
if (!enabled || !trimmed || validationError) return;
|
||||||
|
return scheduleWorkspaceSearch(trimmed, searchWorkspaceItems,
|
||||||
|
(result) => setState({ ...result, query: trimmed, revision, error: null }),
|
||||||
|
(error) => setState({ items: [], hasMore: false, query: trimmed, revision, error: error instanceof Error ? error.message : String(error) })
|
||||||
|
);
|
||||||
|
}, [trimmed, enabled, revision, validationError]);
|
||||||
|
|
||||||
|
// Hide obsolete results immediately, even before effect cleanup runs.
|
||||||
|
const current = enabled && state?.query === trimmed && state.revision === revision ? state : null;
|
||||||
|
return {
|
||||||
|
items: current?.items ?? EMPTY_ITEMS,
|
||||||
|
hasMore: current?.hasMore ?? false,
|
||||||
|
error: validationError ?? current?.error ?? null,
|
||||||
|
isLoading: enabled && Boolean(trimmed) && !validationError && !current,
|
||||||
|
};
|
||||||
|
}
|
||||||
+139
-26
@@ -31,7 +31,7 @@
|
|||||||
--secondary-foreground: 254 38% 94%;
|
--secondary-foreground: 254 38% 94%;
|
||||||
--accent: 188 86% 50%;
|
--accent: 188 86% 50%;
|
||||||
--accent-foreground: 230 45% 8%;
|
--accent-foreground: 230 45% 8%;
|
||||||
--radius: 0.5rem;
|
--radius: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -72,6 +72,7 @@ select,
|
|||||||
textarea {
|
textarea {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
@@ -113,13 +114,39 @@ textarea {
|
|||||||
max(0.75rem, var(--safe-area-left));
|
max(0.75rem, var(--safe-area-left));
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
.chat-settings-header {
|
||||||
.app-safe-frame {
|
|
||||||
padding:
|
padding:
|
||||||
max(0.5rem, var(--safe-area-top))
|
calc(0.5rem + var(--safe-area-top))
|
||||||
max(0.5rem, var(--safe-area-right))
|
max(0.75rem, var(--safe-area-right))
|
||||||
max(0.5rem, var(--safe-area-bottom))
|
0.5rem
|
||||||
max(0.5rem, var(--safe-area-left));
|
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) {
|
||||||
|
.chat-settings-header,
|
||||||
|
.chat-settings-content {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-settings-error {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-search-safe-pad {
|
.app-search-safe-pad {
|
||||||
@@ -132,19 +159,11 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (horizontal-viewport-segments: 2) {
|
@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 {
|
.workspace-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns:
|
grid-template-columns:
|
||||||
calc(env(viewport-segment-width 0 0) - max(0.5rem, var(--safe-area-left)))
|
calc(env(viewport-segment-width 0 0) - var(--safe-area-left))
|
||||||
calc(env(viewport-segment-width 1 0) - max(0.5rem, var(--safe-area-right)));
|
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));
|
column-gap: calc(env(viewport-segment-left 1 0) - env(viewport-segment-right 0 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,14 +172,10 @@ textarea {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
transform: none;
|
transform: none;
|
||||||
border-width: 1px;
|
|
||||||
border-radius: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-content {
|
.workspace-content {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-width: 1px;
|
|
||||||
border-radius: 1rem;
|
|
||||||
touch-action: auto;
|
touch-action: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +194,13 @@ textarea {
|
|||||||
0 14px 36px hsl(240 80% 2% / 0.28);
|
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 {
|
.tool-call-stack-shell {
|
||||||
perspective: 900px;
|
perspective: 900px;
|
||||||
transform-style: preserve-3d;
|
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 {
|
.md-content {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
@@ -339,7 +452,7 @@ textarea {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
border: 1px solid hsl(var(--border) / 0.86);
|
border: 1px solid hsl(var(--border) / 0.86);
|
||||||
border-radius: 0.625rem;
|
border-radius: var(--radius);
|
||||||
background: hsl(246 34% 10% / 0.76);
|
background: hsl(246 34% 10% / 0.76);
|
||||||
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.06);
|
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.06);
|
||||||
}
|
}
|
||||||
@@ -358,7 +471,7 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.md-table-scroll::-webkit-scrollbar-thumb {
|
.md-table-scroll::-webkit-scrollbar-thumb {
|
||||||
border-radius: 9999px;
|
border-radius: var(--radius);
|
||||||
background: hsl(263 78% 72% / 0.34);
|
background: hsl(263 78% 72% / 0.34);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,7 +583,7 @@ textarea {
|
|||||||
|
|
||||||
.md-content code {
|
.md-content code {
|
||||||
background: hsl(249 40% 10% / 0.78);
|
background: hsl(249 40% 10% / 0.78);
|
||||||
border-radius: 0.3rem;
|
border-radius: var(--radius);
|
||||||
padding: 0.05rem 0.3rem;
|
padding: 0.05rem 0.3rem;
|
||||||
font-size: 0.86em;
|
font-size: 0.86em;
|
||||||
box-decoration-break: clone;
|
box-decoration-break: clone;
|
||||||
@@ -480,7 +593,7 @@ textarea {
|
|||||||
.md-content pre {
|
.md-content pre {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border: 1px solid hsl(253 31% 29% / 0.72);
|
border: 1px solid hsl(253 31% 29% / 0.72);
|
||||||
border-radius: 0.625rem;
|
border-radius: var(--radius);
|
||||||
background: hsl(249 40% 10% / 0.82);
|
background: hsl(249 40% 10% / 0.82);
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.05);
|
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.05);
|
||||||
@@ -504,7 +617,7 @@ textarea {
|
|||||||
.md-cite-token {
|
.md-cite-token {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: 9999px;
|
border-radius: var(--radius);
|
||||||
border: 1px solid hsl(274 40% 55%);
|
border: 1px solid hsl(274 40% 55%);
|
||||||
background: hsl(277 33% 21%);
|
background: hsl(277 33% 21%);
|
||||||
color: hsl(304 42% 96%);
|
color: hsl(304 42% 96%);
|
||||||
|
|||||||
+43
-14
@@ -33,7 +33,9 @@ export type SearchWorkspaceItem = SearchSummary & {
|
|||||||
type: "search";
|
type: "search";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem;
|
export type WorkspaceItem = (ChatWorkspaceItem | SearchWorkspaceItem) & { searchSnippet?: string | null };
|
||||||
|
|
||||||
|
export type WorkspaceSearchResponse = { items: WorkspaceItem[]; hasMore: boolean };
|
||||||
|
|
||||||
export type Message = {
|
export type Message = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -191,6 +193,14 @@ type CompletionStreamHandlers = {
|
|||||||
onError?: (payload: { message: string }) => void;
|
onError?: (payload: { message: string }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function dispatchCompletionStreamEvent(handlers: CompletionStreamHandlers, eventName: string, payload: any) {
|
||||||
|
if (eventName === "meta") handlers.onMeta?.(payload);
|
||||||
|
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
|
||||||
|
else if (eventName === "delta") handlers.onDelta?.(payload);
|
||||||
|
else if (eventName === "done") handlers.onDone?.(payload);
|
||||||
|
else if (eventName === "error") handlers.onError?.(payload);
|
||||||
|
}
|
||||||
|
|
||||||
type CreateChatRequest = {
|
type CreateChatRequest = {
|
||||||
title?: string;
|
title?: string;
|
||||||
provider?: Provider;
|
provider?: Provider;
|
||||||
@@ -264,6 +274,10 @@ export async function listWorkspaceItems() {
|
|||||||
return data.items;
|
return data.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function searchWorkspaceItems(query: string, signal?: AbortSignal) {
|
||||||
|
return api<WorkspaceSearchResponse>(`/v1/workspace-items?${new URLSearchParams({ q: query })}`, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
export async function verifySession() {
|
export async function verifySession() {
|
||||||
return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session");
|
return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session");
|
||||||
}
|
}
|
||||||
@@ -627,13 +641,34 @@ export async function runCompletionStream(
|
|||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await readSseStream(response, (eventName, payload) => {
|
await readSseStream(response, (eventName, payload) => dispatchCompletionStreamEvent(handlers, eventName, payload));
|
||||||
if (eventName === "meta") handlers.onMeta?.(payload);
|
}
|
||||||
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
|
|
||||||
else if (eventName === "delta") handlers.onDelta?.(payload);
|
export async function runQuickQuestionStream(
|
||||||
else if (eventName === "done") handlers.onDone?.(payload);
|
body: {
|
||||||
else if (eventName === "error") handlers.onError?.(payload);
|
provider: Provider;
|
||||||
|
model: string;
|
||||||
|
question: string;
|
||||||
|
},
|
||||||
|
handlers: CompletionStreamHandlers,
|
||||||
|
options?: { signal?: AbortSignal }
|
||||||
|
) {
|
||||||
|
const headers = new Headers({
|
||||||
|
Accept: "text/event-stream",
|
||||||
|
"Content-Type": "application/json",
|
||||||
});
|
});
|
||||||
|
if (authToken) {
|
||||||
|
headers.set("Authorization", `Bearer ${authToken}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/v1/quick-questions/stream`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
await readSseStream(response, (eventName, payload) => dispatchCompletionStreamEvent(handlers, eventName, payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function attachCompletionStream(chatId: string, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal }) {
|
export async function attachCompletionStream(chatId: string, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal }) {
|
||||||
@@ -650,11 +685,5 @@ export async function attachCompletionStream(chatId: string, handlers: Completio
|
|||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await readSseStream(response, (eventName, payload) => {
|
await readSseStream(response, (eventName, payload) => dispatchCompletionStreamEvent(handlers, eventName, payload));
|
||||||
if (eventName === "meta") handlers.onMeta?.(payload);
|
|
||||||
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
|
|
||||||
else if (eventName === "delta") handlers.onDelta?.(payload);
|
|
||||||
else if (eventName === "done") handlers.onDone?.(payload);
|
|
||||||
else if (eventName === "error") handlers.onError?.(payload);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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,
|
||||||
|
content,
|
||||||
|
}: {
|
||||||
|
provider: Provider;
|
||||||
|
model: string;
|
||||||
|
content: string;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
question: content,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,16 @@ export type SidebarSelection = { kind: "chat" | "search"; id: string };
|
|||||||
|
|
||||||
type WorkspaceSelectionItem = { type: SidebarSelection["kind"]; id: string };
|
type WorkspaceSelectionItem = { type: SidebarSelection["kind"]; id: string };
|
||||||
|
|
||||||
|
export function retainSelectedHistoryItem<T extends WorkspaceSelectionItem>(
|
||||||
|
items: T[], selection: SidebarSelection | null, retained: T | null
|
||||||
|
): T[] {
|
||||||
|
if (retained && selection?.kind === retained.type && selection.id === retained.id &&
|
||||||
|
!items.some((item) => item.type === retained.type && item.id === retained.id)) {
|
||||||
|
return [...items, retained];
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
type ResolveSidebarSelectionOptions = {
|
type ResolveSidebarSelectionOptions = {
|
||||||
initialSelection?: SidebarSelection;
|
initialSelection?: SidebarSelection;
|
||||||
selectFallback?: boolean;
|
selectFallback?: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { WorkspaceSearchResponse } from "./api";
|
||||||
|
|
||||||
|
// Cleanup cancels both the debounce and fetch, and also rejects late responses
|
||||||
|
// from transports that finish after cancellation.
|
||||||
|
export function scheduleWorkspaceSearch(
|
||||||
|
query: string,
|
||||||
|
request: (query: string, signal: AbortSignal) => Promise<WorkspaceSearchResponse>,
|
||||||
|
onResult: (result: WorkspaceSearchResponse) => void,
|
||||||
|
onError: (error: unknown) => void
|
||||||
|
) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const result = await request(query, controller.signal);
|
||||||
|
if (!controller.signal.aborted) onResult(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (!controller.signal.aborted) onError(error);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -29,9 +29,14 @@ const config: Config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
borderRadius: {
|
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)",
|
lg: "var(--radius)",
|
||||||
md: "calc(var(--radius) - 2px)",
|
xl: "var(--radius)",
|
||||||
sm: "calc(var(--radius) - 4px)",
|
"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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
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({
|
||||||
|
provider: "openai",
|
||||||
|
model: "gpt-4.1-mini",
|
||||||
|
content: "How do I reset my password?",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(request, {
|
||||||
|
provider: "openai",
|
||||||
|
model: "gpt-4.1-mini",
|
||||||
|
question: "How do I reset my password?",
|
||||||
|
});
|
||||||
|
assert.equal("additionalSystemPrompt" in request, false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { scheduleWorkspaceSearch } from "../src/lib/workspace-search.ts";
|
||||||
|
import { retainSelectedHistoryItem, resolveSidebarSelectionAfterRefresh } from "../src/lib/sidebar-selection.ts";
|
||||||
|
|
||||||
|
const flush = async () => { await Promise.resolve(); await Promise.resolve(); };
|
||||||
|
|
||||||
|
test("typing is debounced and superseded requests never start", async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||||
|
const queries = [];
|
||||||
|
const results = [];
|
||||||
|
const request = async (query) => { queries.push(query); return { items: [], hasMore: false }; };
|
||||||
|
const reportError = (error) => assert.fail(String(error));
|
||||||
|
const cancelFirst = scheduleWorkspaceSearch("ne", request, (result) => results.push(result), reportError);
|
||||||
|
t.mock.timers.tick(299);
|
||||||
|
assert.deepEqual(queries, []);
|
||||||
|
cancelFirst();
|
||||||
|
const cancelLast = scheduleWorkspaceSearch("nebula", request, (result) => results.push(result), reportError);
|
||||||
|
t.mock.timers.tick(300);
|
||||||
|
await flush();
|
||||||
|
assert.deepEqual(queries, ["nebula"]);
|
||||||
|
assert.deepEqual(results, [{ items: [], hasMore: false }]);
|
||||||
|
cancelLast();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancellation aborts the transport and discards stale results and errors", async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||||
|
let resolveFirst;
|
||||||
|
let rejectSecond;
|
||||||
|
let signal;
|
||||||
|
const results = [];
|
||||||
|
const errors = [];
|
||||||
|
const first = scheduleWorkspaceSearch("first", (_query, value) => {
|
||||||
|
signal = value;
|
||||||
|
return new Promise((resolve) => { resolveFirst = resolve; });
|
||||||
|
}, (result) => results.push(result), (error) => errors.push(error));
|
||||||
|
t.mock.timers.tick(300);
|
||||||
|
first();
|
||||||
|
assert.equal(signal.aborted, true);
|
||||||
|
const second = scheduleWorkspaceSearch("second", () => new Promise((_resolve, reject) => { rejectSecond = reject; }),
|
||||||
|
(result) => results.push(result), (error) => errors.push(error));
|
||||||
|
t.mock.timers.tick(300);
|
||||||
|
second();
|
||||||
|
resolveFirst({ items: [{ id: "stale" }], hasMore: true });
|
||||||
|
rejectSecond(new Error("stale failure"));
|
||||||
|
await flush();
|
||||||
|
assert.deepEqual(results, []);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search results retain excerpts and truncation info; failures reach the UI", async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||||
|
const expected = { items: [{ type: "chat", id: "old", searchSnippet: "A nebula in the sky" }], hasMore: true };
|
||||||
|
let received;
|
||||||
|
let error;
|
||||||
|
const cancel = scheduleWorkspaceSearch("nebula", async () => expected, (result) => { received = result; }, assert.fail);
|
||||||
|
t.mock.timers.tick(300);
|
||||||
|
await flush();
|
||||||
|
assert.deepEqual(received, expected);
|
||||||
|
cancel();
|
||||||
|
const cancelFailure = scheduleWorkspaceSearch("nebula", async () => { throw new Error("Offline"); }, assert.fail, (result) => { error = result; });
|
||||||
|
t.mock.timers.tick(300);
|
||||||
|
await flush();
|
||||||
|
assert.equal(error.message, "Offline");
|
||||||
|
cancelFailure();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opening older chat/search results survives collection refresh without restoring deleted or unrelated items", () => {
|
||||||
|
const recent = [{ type: "chat", id: "recent" }];
|
||||||
|
for (const kind of ["chat", "search"]) {
|
||||||
|
const selection = { kind, id: "old" };
|
||||||
|
const retained = { type: kind, id: "old" };
|
||||||
|
const refreshed = retainSelectedHistoryItem(recent, selection, retained);
|
||||||
|
assert.deepEqual(resolveSidebarSelectionAfterRefresh(selection, refreshed), selection);
|
||||||
|
assert.equal(retainSelectedHistoryItem(refreshed, selection, retained), refreshed);
|
||||||
|
assert.equal(retainSelectedHistoryItem(recent, { kind, id: "different" }, retained), recent);
|
||||||
|
assert.equal(retainSelectedHistoryItem(recent, selection, null), recent);
|
||||||
|
assert.equal(retainSelectedHistoryItem(recent, null, retained), recent);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-composer.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/chat-forking.ts","./src/lib/chat-model-selection.ts","./src/lib/sidebar-selection.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-composer.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/chat-forking.ts","./src/lib/chat-model-selection.ts","./src/lib/quick-question.ts","./src/lib/sidebar-selection.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user