Compare commits
1 Commits
195e157e1a
...
ios-pull-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9572d0320f |
@@ -39,22 +39,6 @@ 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.
|
||||||
|
|
||||||
## Active Runs
|
|
||||||
|
|
||||||
### `GET /v1/active-runs`
|
|
||||||
- Response:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"chats": ["chat-id-with-active-stream"],
|
|
||||||
"searches": ["search-id-with-active-stream"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Behavior notes:
|
|
||||||
- Lists in-memory chat/search streams that are still running on this server process.
|
|
||||||
- Clients should use this after app start or page refresh to restore per-row generating indicators.
|
|
||||||
- The lists are not durable across server restarts.
|
|
||||||
|
|
||||||
## Chats
|
## Chats
|
||||||
|
|
||||||
### `GET /v1/chats`
|
### `GET /v1/chats`
|
||||||
@@ -276,32 +260,6 @@ Search run notes:
|
|||||||
- Persists answer text/citations + ranked results.
|
- Persists answer text/citations + ranked results.
|
||||||
- If both search and answer fail, endpoint returns an error.
|
- If both search and answer fail, endpoint returns an error.
|
||||||
|
|
||||||
### `POST /v1/searches/:searchId/run/stream`
|
|
||||||
- Body: same as `POST /v1/searches/:searchId/run`
|
|
||||||
- Response: `text/event-stream`
|
|
||||||
|
|
||||||
Events:
|
|
||||||
- `search_results`: `{ "requestId": string|null, "results": SearchResultItem[] }`
|
|
||||||
- `search_error`: `{ "error": string }`
|
|
||||||
- `answer`: `{ "answerText": string|null, "answerRequestId": string|null, "answerCitations": SearchDetail["answerCitations"] }`
|
|
||||||
- `answer_error`: `{ "error": string }`
|
|
||||||
- terminal `done`: `{ "search": SearchDetail }`
|
|
||||||
- terminal `error`: `{ "message": string }`
|
|
||||||
|
|
||||||
Behavior notes:
|
|
||||||
- The stream is owned by the backend after it starts. If the original HTTP client disconnects, the backend keeps running and persists the final search state.
|
|
||||||
- While a search stream is active, `GET /v1/active-runs` includes the `searchId`.
|
|
||||||
- If a stream is already active for the same `searchId`, this endpoint attaches to the existing stream instead of starting a second run.
|
|
||||||
|
|
||||||
### `POST /v1/searches/:searchId/run/stream/attach`
|
|
||||||
- Body: none
|
|
||||||
- Response: `text/event-stream` with the same event names as `POST /v1/searches/:searchId/run/stream`
|
|
||||||
- Not found: `404 { "message": "active search stream not found" }`
|
|
||||||
|
|
||||||
Behavior notes:
|
|
||||||
- Replays buffered events for the active in-memory stream, then emits new events until `done` or `error`.
|
|
||||||
- Intended for clients that discovered a pending search via `GET /v1/active-runs`, such as after browser refresh.
|
|
||||||
|
|
||||||
## Type Shapes
|
## Type Shapes
|
||||||
|
|
||||||
`ChatSummary`
|
`ChatSummary`
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ This document defines the server-sent events (SSE) contract for chat completions
|
|||||||
|
|
||||||
Endpoint:
|
Endpoint:
|
||||||
- `POST /v1/chat-completions/stream`
|
- `POST /v1/chat-completions/stream`
|
||||||
- `POST /v1/chats/:chatId/stream/attach`
|
|
||||||
|
|
||||||
Transport:
|
Transport:
|
||||||
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
|
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
|
||||||
@@ -62,23 +61,6 @@ Notes:
|
|||||||
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
|
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
|
||||||
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
|
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
|
||||||
|
|
||||||
Persisted chat streams with a `chatId` are backend-owned active runs:
|
|
||||||
- Once started, the backend keeps the stream running even if the HTTP client disconnects or refreshes.
|
|
||||||
- While running, `GET /v1/active-runs` includes the `chatId`.
|
|
||||||
- Starting a second persisted stream for the same active `chatId` returns `409`.
|
|
||||||
- Clients can reattach with `POST /v1/chats/:chatId/stream/attach`.
|
|
||||||
|
|
||||||
## Attach Endpoint
|
|
||||||
|
|
||||||
`POST /v1/chats/:chatId/stream/attach`
|
|
||||||
- Body: none.
|
|
||||||
- Response uses the same `text/event-stream` transport and event names as `POST /v1/chat-completions/stream`.
|
|
||||||
- Replays buffered events for the active in-memory stream, then emits new events until `done` or `error`.
|
|
||||||
- Returns `404 { "message": "active chat stream not found" }` if no stream is currently active for that chat.
|
|
||||||
- Authentication is the same as all other API endpoints.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Event Stream Contract
|
## Event Stream Contract
|
||||||
|
|
||||||
Event order:
|
Event order:
|
||||||
|
|||||||
@@ -8,19 +8,8 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
|
|||||||
- `just build` will:
|
- `just build` will:
|
||||||
1. generate `Sybil.xcodeproj` with `xcodegen` if missing,
|
1. generate `Sybil.xcodeproj` with `xcodegen` if missing,
|
||||||
2. build scheme `Sybil` for `iPhone 16e` simulator.
|
2. build scheme `Sybil` for `iPhone 16e` simulator.
|
||||||
- Preferred test command: `just test`
|
|
||||||
- `just test` runs the Swift package tests through `xcodebuild test` on the `iPhone 16e` iOS simulator from `ios/Packages/Sybil`.
|
|
||||||
- `just test` disables Xcode parallel testing because the current async view-model tests use timing-sensitive selection tasks.
|
|
||||||
- Do not use plain `swift test` for this package; it runs as host macOS and hits a deployment mismatch with `MarkdownUI`.
|
|
||||||
- If `xcbeautify` is installed it is used automatically; otherwise raw `xcodebuild` output is used.
|
- If `xcbeautify` is installed it is used automatically; otherwise raw `xcodebuild` output is used.
|
||||||
|
|
||||||
## Simulator Workflow
|
|
||||||
- Run the app in the simulator with `just run` from `/Users/buzzert/src/sybil-2/ios`.
|
|
||||||
- `just run` boots the `iPhone 16e` simulator if needed, builds with a stable derived data path, installs `Sybil.app`, and launches bundle id `net.buzzert.sybil2`.
|
|
||||||
- Capture a simulator screenshot with `just screenshot` from `/Users/buzzert/src/sybil-2/ios`; it writes `build/sybil-screenshot.png` by default.
|
|
||||||
- To choose a screenshot path, run `just screenshot path=build/name.png`.
|
|
||||||
- The underlying screenshot command is `xcrun simctl io booted screenshot <path>` and requires a booted simulator.
|
|
||||||
|
|
||||||
## App Structure
|
## App Structure
|
||||||
- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
|
- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
|
||||||
- Shared iOS app code lives in Swift package:
|
- Shared iOS app code lives in Swift package:
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
@@ -23,8 +23,8 @@ targets:
|
|||||||
TARGETED_DEVICE_FAMILY: "1,2,6"
|
TARGETED_DEVICE_FAMILY: "1,2,6"
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
MARKETING_VERSION: 1.5
|
MARKETING_VERSION: 1.4
|
||||||
CURRENT_PROJECT_VERSION: 6
|
CURRENT_PROJECT_VERSION: 5
|
||||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ public struct SplitView: View {
|
|||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var shouldRefreshOnForeground = false
|
@State private var shouldRefreshOnForeground = false
|
||||||
@State private var composerFocusRequest = 0
|
@State private var composerFocusRequest = 0
|
||||||
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
|
|
||||||
|
|
||||||
private var keyboardActions: SybilKeyboardActions? {
|
private var keyboardActions: SybilKeyboardActions? {
|
||||||
guard !viewModel.isCheckingSession, viewModel.isAuthenticated else {
|
guard !viewModel.isCheckingSession, viewModel.isAuthenticated else {
|
||||||
@@ -51,26 +50,18 @@ public struct SplitView: View {
|
|||||||
} else if horizontalSizeClass == .compact {
|
} else if horizontalSizeClass == .compact {
|
||||||
SybilPhoneShellView(viewModel: viewModel)
|
SybilPhoneShellView(viewModel: viewModel)
|
||||||
} else {
|
} else {
|
||||||
GeometryReader { proxy in
|
NavigationSplitView {
|
||||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
|
||||||
SybilSidebarView(viewModel: viewModel)
|
SybilSidebarView(viewModel: viewModel)
|
||||||
} detail: {
|
} detail: {
|
||||||
SybilWorkspaceView(
|
SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) {
|
||||||
viewModel: viewModel,
|
|
||||||
composerFocusRequest: composerFocusRequest,
|
|
||||||
navigationLeadingControl: splitNavigationLeadingControl(for: proxy.size),
|
|
||||||
onShowSidebar: showSidebar,
|
|
||||||
onRequestNewChat: {
|
|
||||||
viewModel.startNewChat()
|
viewModel.startNewChat()
|
||||||
composerFocusRequest += 1
|
composerFocusRequest += 1
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.navigationSplitViewStyle(.balanced)
|
.navigationSplitViewStyle(.balanced)
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.font(.sybil(.body))
|
.font(.sybil(.body))
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
||||||
@@ -81,37 +72,24 @@ public struct SplitView: View {
|
|||||||
switch nextPhase {
|
switch nextPhase {
|
||||||
case .background:
|
case .background:
|
||||||
shouldRefreshOnForeground = true
|
shouldRefreshOnForeground = true
|
||||||
viewModel.markAppInactiveForNetwork()
|
|
||||||
case .active:
|
case .active:
|
||||||
viewModel.markAppActiveForNetwork()
|
|
||||||
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
shouldRefreshOnForeground = false
|
shouldRefreshOnForeground = false
|
||||||
Task {
|
Task {
|
||||||
await viewModel.refreshAfterAppBecameActive(
|
await viewModel.refreshVisibleContent(
|
||||||
refreshCollections: true,
|
refreshCollections: true,
|
||||||
refreshSelection: viewModel.hasRefreshableSelection
|
refreshSelection: viewModel.hasRefreshableSelection
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case .inactive:
|
case .inactive:
|
||||||
shouldRefreshOnForeground = true
|
break
|
||||||
viewModel.markAppInactiveForNetwork()
|
|
||||||
@unknown default:
|
@unknown default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func splitNavigationLeadingControl(for size: CGSize) -> SybilWorkspaceNavigationLeadingControl {
|
|
||||||
return size.width < size.height ? .showSidebar : .hidden
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showSidebar() {
|
|
||||||
withAnimation(.easeInOut(duration: 0.22)) {
|
|
||||||
columnVisibility = .all
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SybilCommands: Commands {
|
public struct SybilCommands: Commands {
|
||||||
|
|||||||
@@ -116,10 +116,6 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
try await request("/v1/models", method: "GET", responseType: ModelCatalogResponse.self)
|
try await request("/v1/models", method: "GET", responseType: ModelCatalogResponse.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getActiveRuns() async throws -> ActiveRunsResponse {
|
|
||||||
try await request("/v1/active-runs", method: "GET", responseType: ActiveRunsResponse.self)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runCompletionStream(
|
func runCompletionStream(
|
||||||
body: CompletionStreamRequest,
|
body: CompletionStreamRequest,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
@@ -137,35 +133,43 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
)
|
)
|
||||||
|
|
||||||
try await stream(request: request) { eventName, dataText in
|
try await stream(request: request) { eventName, dataText in
|
||||||
try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
switch eventName {
|
||||||
|
case "meta":
|
||||||
|
let payload: CompletionStreamMeta = try Self.decodeEvent(dataText, as: CompletionStreamMeta.self, eventName: eventName)
|
||||||
|
await onEvent(.meta(payload))
|
||||||
|
case "tool_call":
|
||||||
|
let payload: CompletionStreamToolCall = try Self.decodeEvent(dataText, as: CompletionStreamToolCall.self, eventName: eventName)
|
||||||
|
await onEvent(.toolCall(payload))
|
||||||
|
case "delta":
|
||||||
|
let payload: CompletionStreamDelta = try Self.decodeEvent(dataText, as: CompletionStreamDelta.self, eventName: eventName)
|
||||||
|
await onEvent(.delta(payload))
|
||||||
|
case "done":
|
||||||
|
do {
|
||||||
|
let payload: CompletionStreamDone = try Self.decodeEvent(dataText, as: CompletionStreamDone.self, eventName: eventName)
|
||||||
|
await onEvent(.done(payload))
|
||||||
|
} catch {
|
||||||
|
if let recovered = Self.decodeLastJSONLine(dataText, as: CompletionStreamDone.self) {
|
||||||
|
SybilLog.warning(
|
||||||
|
SybilLog.network,
|
||||||
|
"Recovered chat stream done payload from concatenated SSE data"
|
||||||
|
)
|
||||||
|
await onEvent(.done(recovered))
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "error":
|
||||||
|
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.error(payload))
|
||||||
|
default:
|
||||||
|
SybilLog.warning(SybilLog.network, "Ignoring unknown chat stream event '\(eventName)'")
|
||||||
|
await onEvent(.ignored)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SybilLog.info(SybilLog.network, "Chat stream completed")
|
SybilLog.info(SybilLog.network, "Chat stream completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
func attachCompletionStream(
|
|
||||||
chatID: String,
|
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
|
||||||
) async throws {
|
|
||||||
let request = try makeRequest(
|
|
||||||
path: "/v1/chats/\(chatID)/stream/attach",
|
|
||||||
method: "POST",
|
|
||||||
body: nil,
|
|
||||||
acceptsSSE: true
|
|
||||||
)
|
|
||||||
|
|
||||||
SybilLog.info(
|
|
||||||
SybilLog.network,
|
|
||||||
"Attaching chat 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, "Attached chat stream completed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runSearchStream(
|
func runSearchStream(
|
||||||
searchID: String,
|
searchID: String,
|
||||||
body: SearchRunRequest,
|
body: SearchRunRequest,
|
||||||
@@ -184,35 +188,34 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
)
|
)
|
||||||
|
|
||||||
try await stream(request: request) { eventName, dataText in
|
try await stream(request: request) { eventName, dataText in
|
||||||
try await Self.handleSearchStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
switch eventName {
|
||||||
|
case "search_results":
|
||||||
|
let payload: SearchResultsPayload = try Self.decodeEvent(dataText, as: SearchResultsPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.searchResults(payload))
|
||||||
|
case "search_error":
|
||||||
|
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.searchError(payload))
|
||||||
|
case "answer":
|
||||||
|
let payload: SearchAnswerPayload = try Self.decodeEvent(dataText, as: SearchAnswerPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.answer(payload))
|
||||||
|
case "answer_error":
|
||||||
|
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.answerError(payload))
|
||||||
|
case "done":
|
||||||
|
let payload: SearchDonePayload = try Self.decodeEvent(dataText, as: SearchDonePayload.self, eventName: eventName)
|
||||||
|
await onEvent(.done(payload))
|
||||||
|
case "error":
|
||||||
|
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
||||||
|
await onEvent(.error(payload))
|
||||||
|
default:
|
||||||
|
SybilLog.warning(SybilLog.network, "Ignoring unknown search stream event '\(eventName)'")
|
||||||
|
await onEvent(.ignored)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SybilLog.info(SybilLog.network, "Search stream completed")
|
SybilLog.info(SybilLog.network, "Search stream completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
func attachSearchStream(
|
|
||||||
searchID: String,
|
|
||||||
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
|
||||||
) async throws {
|
|
||||||
let request = try makeRequest(
|
|
||||||
path: "/v1/searches/\(searchID)/run/stream/attach",
|
|
||||||
method: "POST",
|
|
||||||
body: nil,
|
|
||||||
acceptsSSE: true
|
|
||||||
)
|
|
||||||
|
|
||||||
SybilLog.info(
|
|
||||||
SybilLog.network,
|
|
||||||
"Attaching search stream POST \(request.url?.absoluteString ?? "<unknown>")"
|
|
||||||
)
|
|
||||||
|
|
||||||
try await stream(request: request) { eventName, dataText in
|
|
||||||
try await Self.handleSearchStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
|
||||||
}
|
|
||||||
|
|
||||||
SybilLog.info(SybilLog.network, "Attached search stream completed")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func request<Response: Decodable>(
|
private func request<Response: Decodable>(
|
||||||
_ path: String,
|
_ path: String,
|
||||||
method: String,
|
method: String,
|
||||||
@@ -495,75 +498,6 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
return try? Self.decodeJSON(type, from: data)
|
return try? Self.decodeJSON(type, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func handleCompletionStreamEvent(
|
|
||||||
eventName: String,
|
|
||||||
dataText: String,
|
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
|
||||||
) async throws {
|
|
||||||
switch eventName {
|
|
||||||
case "meta":
|
|
||||||
let payload: CompletionStreamMeta = try Self.decodeEvent(dataText, as: CompletionStreamMeta.self, eventName: eventName)
|
|
||||||
await onEvent(.meta(payload))
|
|
||||||
case "tool_call":
|
|
||||||
let payload: CompletionStreamToolCall = try Self.decodeEvent(dataText, as: CompletionStreamToolCall.self, eventName: eventName)
|
|
||||||
await onEvent(.toolCall(payload))
|
|
||||||
case "delta":
|
|
||||||
let payload: CompletionStreamDelta = try Self.decodeEvent(dataText, as: CompletionStreamDelta.self, eventName: eventName)
|
|
||||||
await onEvent(.delta(payload))
|
|
||||||
case "done":
|
|
||||||
do {
|
|
||||||
let payload: CompletionStreamDone = try Self.decodeEvent(dataText, as: CompletionStreamDone.self, eventName: eventName)
|
|
||||||
await onEvent(.done(payload))
|
|
||||||
} catch {
|
|
||||||
if let recovered = Self.decodeLastJSONLine(dataText, as: CompletionStreamDone.self) {
|
|
||||||
SybilLog.warning(
|
|
||||||
SybilLog.network,
|
|
||||||
"Recovered chat stream done payload from concatenated SSE data"
|
|
||||||
)
|
|
||||||
await onEvent(.done(recovered))
|
|
||||||
} else {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "error":
|
|
||||||
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.error(payload))
|
|
||||||
default:
|
|
||||||
SybilLog.warning(SybilLog.network, "Ignoring unknown chat stream event '\(eventName)'")
|
|
||||||
await onEvent(.ignored)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func handleSearchStreamEvent(
|
|
||||||
eventName: String,
|
|
||||||
dataText: String,
|
|
||||||
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
|
||||||
) async throws {
|
|
||||||
switch eventName {
|
|
||||||
case "search_results":
|
|
||||||
let payload: SearchResultsPayload = try Self.decodeEvent(dataText, as: SearchResultsPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.searchResults(payload))
|
|
||||||
case "search_error":
|
|
||||||
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.searchError(payload))
|
|
||||||
case "answer":
|
|
||||||
let payload: SearchAnswerPayload = try Self.decodeEvent(dataText, as: SearchAnswerPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.answer(payload))
|
|
||||||
case "answer_error":
|
|
||||||
let payload: SearchErrorPayload = try Self.decodeEvent(dataText, as: SearchErrorPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.answerError(payload))
|
|
||||||
case "done":
|
|
||||||
let payload: SearchDonePayload = try Self.decodeEvent(dataText, as: SearchDonePayload.self, eventName: eventName)
|
|
||||||
await onEvent(.done(payload))
|
|
||||||
case "error":
|
|
||||||
let payload: StreamErrorPayload = try Self.decodeEvent(dataText, as: StreamErrorPayload.self, eventName: eventName)
|
|
||||||
await onEvent(.error(payload))
|
|
||||||
default:
|
|
||||||
SybilLog.warning(SybilLog.network, "Ignoring unknown search stream event '\(eventName)'")
|
|
||||||
await onEvent(.ignored)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func flushSSEEvent(
|
private static func flushSSEEvent(
|
||||||
eventName: inout String,
|
eventName: inout String,
|
||||||
dataLines: inout [String]
|
dataLines: inout [String]
|
||||||
|
|||||||
@@ -13,22 +13,13 @@ protocol SybilAPIClienting: Sendable {
|
|||||||
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary
|
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary
|
||||||
func deleteSearch(searchID: String) async throws
|
func deleteSearch(searchID: String) async throws
|
||||||
func listModels() async throws -> ModelCatalogResponse
|
func listModels() async throws -> ModelCatalogResponse
|
||||||
func getActiveRuns() async throws -> ActiveRunsResponse
|
|
||||||
func runCompletionStream(
|
func runCompletionStream(
|
||||||
body: CompletionStreamRequest,
|
body: CompletionStreamRequest,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
) async throws
|
) async throws
|
||||||
func attachCompletionStream(
|
|
||||||
chatID: String,
|
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
|
||||||
) async throws
|
|
||||||
func runSearchStream(
|
func runSearchStream(
|
||||||
searchID: String,
|
searchID: String,
|
||||||
body: SearchRunRequest,
|
body: SearchRunRequest,
|
||||||
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
) async throws
|
) async throws
|
||||||
func attachSearchStream(
|
|
||||||
searchID: String,
|
|
||||||
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
|
||||||
) async throws
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ struct SybilChatTranscriptView: View {
|
|||||||
var messages: [Message]
|
var messages: [Message]
|
||||||
var isLoading: Bool
|
var isLoading: Bool
|
||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
var topContentInset: CGFloat = 0
|
var onRefresh: (() async -> Void)? = nil
|
||||||
var bottomContentInset: CGFloat = 0
|
@State private var hasHandledInitialTranscriptScroll = false
|
||||||
|
|
||||||
private var hasPendingAssistant: Bool {
|
private var hasPendingAssistant: Bool {
|
||||||
messages.contains { message in
|
messages.contains { message in
|
||||||
@@ -15,8 +15,22 @@ struct SybilChatTranscriptView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 26) {
|
LazyVStack(alignment: .leading, spacing: 26) {
|
||||||
|
if isLoading && messages.isEmpty {
|
||||||
|
Text("Loading messages…")
|
||||||
|
.font(.sybil(.footnote))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
.padding(.top, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(messages) { message in
|
||||||
|
MessageBubble(message: message, isSending: isSending)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.id(message.id)
|
||||||
|
}
|
||||||
|
|
||||||
if isSending && !hasPendingAssistant {
|
if isSending && !hasPendingAssistant {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -26,31 +40,44 @@ struct SybilChatTranscriptView: View {
|
|||||||
.font(.sybil(.footnote))
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
}
|
}
|
||||||
.scaleEffect(x: 1, y: -1)
|
.id("typing-indicator")
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(messages.reversed()) { message in
|
Color.clear
|
||||||
MessageBubble(message: message, isSending: isSending)
|
.frame(height: 2)
|
||||||
.frame(maxWidth: .infinity)
|
.id("chat-bottom-anchor")
|
||||||
.scaleEffect(x: 1, y: -1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isLoading && messages.isEmpty {
|
|
||||||
Text("Loading messages…")
|
|
||||||
.font(.sybil(.footnote))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
.padding(.top, 24)
|
|
||||||
.scaleEffect(x: 1, y: -1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.top, 18 + bottomContentInset)
|
.padding(.vertical, 18)
|
||||||
.padding(.bottom, 18 + topContentInset)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.refreshable {
|
||||||
|
await onRefresh?()
|
||||||
|
}
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.scaleEffect(x: 1, y: -1)
|
.onAppear {
|
||||||
|
scrollToBottom(with: proxy, animated: false)
|
||||||
|
}
|
||||||
|
.onChange(of: messages.map(\.id)) { _, _ in
|
||||||
|
scrollToBottom(with: proxy, animated: hasHandledInitialTranscriptScroll && !isLoading)
|
||||||
|
hasHandledInitialTranscriptScroll = true
|
||||||
|
}
|
||||||
|
.onChange(of: isSending) { _, _ in
|
||||||
|
scrollToBottom(with: proxy, animated: hasHandledInitialTranscriptScroll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool) {
|
||||||
|
if animated {
|
||||||
|
withAnimation(.easeOut(duration: 0.22)) {
|
||||||
|
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
proxy.scrollTo("chat-bottom-anchor", anchor: .bottom)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +137,6 @@ private struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, isUser ? 14 : 2)
|
.padding(.horizontal, isUser ? 14 : 2)
|
||||||
.padding(.vertical, isUser ? 13 : 2)
|
.padding(.vertical, isUser ? 13 : 2)
|
||||||
.textSelection(.enabled)
|
|
||||||
.background(
|
.background(
|
||||||
Group {
|
Group {
|
||||||
if isUser {
|
if isUser {
|
||||||
@@ -243,7 +269,6 @@ private struct ToolCallActivityChip: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.textSelection(.enabled)
|
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.background(
|
.background(
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ extension Theme {
|
|||||||
.paragraph { configuration in
|
.paragraph { configuration in
|
||||||
configuration.label
|
configuration.label
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.relativeLineSpacing(.em(0.46))
|
.relativeLineSpacing(.em(0.36))
|
||||||
.markdownMargin(top: .zero, bottom: .em(0.82))
|
.markdownMargin(top: .zero, bottom: .em(0.82))
|
||||||
}
|
}
|
||||||
.blockquote { configuration in
|
.blockquote { configuration in
|
||||||
|
|||||||
@@ -354,16 +354,6 @@ public struct SearchDetail: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var results: [SearchResultItem]
|
public var results: [SearchResultItem]
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ActiveRunsResponse: Codable, Hashable, Sendable {
|
|
||||||
public var chats: [String]
|
|
||||||
public var searches: [String]
|
|
||||||
|
|
||||||
public init(chats: [String] = [], searches: [String] = []) {
|
|
||||||
self.chats = chats
|
|
||||||
self.searches = searches
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct SearchRunRequest: Codable, Sendable {
|
public struct SearchRunRequest: Codable, Sendable {
|
||||||
public var query: String?
|
public var query: String?
|
||||||
public var title: String?
|
public var title: String?
|
||||||
|
|||||||
@@ -22,473 +22,58 @@ enum PhoneRoute: Hashable {
|
|||||||
|
|
||||||
struct SybilPhoneShellView: View {
|
struct SybilPhoneShellView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
@State private var route: PhoneRoute = .draftChat
|
@State private var path: [PhoneRoute] = []
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var shouldRefreshOnForeground = false
|
@State private var shouldRefreshOnForeground = false
|
||||||
@State private var composerFocusRequest = 0
|
@State private var composerFocusRequest = 0
|
||||||
@State private var phoneStackWidth: CGFloat = BackSwipeMetrics.referenceWidth
|
|
||||||
@State private var isSidebarOverlayPresented = false
|
|
||||||
@State private var sidebarSwipeOffset: CGFloat = 0
|
|
||||||
@State private var sidebarSwipeIsActive = false
|
|
||||||
@State private var sidebarSwipeIsCompleting = false
|
|
||||||
@State private var sidebarSwipeHasLatched = false
|
|
||||||
@State private var sidebarHighlightSelection: SidebarSelection?
|
|
||||||
@State private var sidebarHighlightClearTask: Task<Void, Never>?
|
|
||||||
@State private var openingSelectionRequestID: UUID?
|
|
||||||
|
|
||||||
private var canRecognizeSidebarSwipe: Bool {
|
|
||||||
!isSidebarOverlayPresented && !sidebarSwipeIsCompleting
|
|
||||||
}
|
|
||||||
|
|
||||||
private var sidebarOverlayProgress: CGFloat {
|
|
||||||
if isSidebarOverlayPresented {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return SidebarOverlaySwipeMetrics.progress(
|
|
||||||
for: sidebarSwipeOffset,
|
|
||||||
width: phoneStackWidth
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var shouldRenderSidebarOverlay: Bool {
|
|
||||||
isSidebarOverlayPresented ||
|
|
||||||
sidebarSwipeIsActive ||
|
|
||||||
sidebarSwipeIsCompleting ||
|
|
||||||
sidebarOverlayProgress > 0.001
|
|
||||||
}
|
|
||||||
|
|
||||||
private var currentRouteSelection: SidebarSelection? {
|
|
||||||
switch route {
|
|
||||||
case let .chat(chatID):
|
|
||||||
return .chat(chatID)
|
|
||||||
case let .search(searchID):
|
|
||||||
return .search(searchID)
|
|
||||||
case .draftChat, .draftSearch, .settings:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var highlightedSidebarSelection: SidebarSelection? {
|
|
||||||
sidebarHighlightSelection ?? currentRouteSelection
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { proxy in
|
NavigationStack(path: $path) {
|
||||||
phoneStack(width: proxy.size.width)
|
SybilPhoneSidebarRoot(viewModel: viewModel, path: $path)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.navigationTitle("")
|
||||||
.onAppear {
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
updatePhoneStackWidth(proxy.size.width)
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
SybilWordmark(size: 18)
|
||||||
}
|
}
|
||||||
.onChange(of: proxy.size.width) { _, width in
|
}
|
||||||
updatePhoneStackWidth(width)
|
.navigationDestination(for: PhoneRoute.self) { route in
|
||||||
|
SybilPhoneDestinationView(
|
||||||
|
viewModel: viewModel,
|
||||||
|
path: $path,
|
||||||
|
composerFocusRequest: $composerFocusRequest,
|
||||||
|
route: route
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
.animation(.easeOut(duration: 0.22), value: route)
|
|
||||||
.animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented)
|
|
||||||
.onChange(of: scenePhase) { _, nextPhase in
|
.onChange(of: scenePhase) { _, nextPhase in
|
||||||
switch nextPhase {
|
switch nextPhase {
|
||||||
case .background:
|
case .background:
|
||||||
shouldRefreshOnForeground = true
|
shouldRefreshOnForeground = true
|
||||||
viewModel.markAppInactiveForNetwork()
|
|
||||||
case .active:
|
case .active:
|
||||||
viewModel.markAppActiveForNetwork()
|
|
||||||
guard shouldRefreshOnForeground else {
|
guard shouldRefreshOnForeground else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
shouldRefreshOnForeground = false
|
shouldRefreshOnForeground = false
|
||||||
Task {
|
Task {
|
||||||
await viewModel.refreshAfterAppBecameActive(
|
await viewModel.refreshVisibleContent(
|
||||||
refreshCollections: isSidebarOverlayPresented,
|
refreshCollections: path.isEmpty,
|
||||||
refreshSelection: !isSidebarOverlayPresented && viewModel.hasRefreshableSelection
|
refreshSelection: !path.isEmpty && viewModel.hasRefreshableSelection
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case .inactive:
|
case .inactive:
|
||||||
shouldRefreshOnForeground = true
|
break
|
||||||
viewModel.markAppInactiveForNetwork()
|
|
||||||
@unknown default:
|
@unknown default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func phoneStack(width: CGFloat) -> some View {
|
|
||||||
ZStack(alignment: .topLeading) {
|
|
||||||
phoneWorkspaceLayer
|
|
||||||
.zIndex(0)
|
|
||||||
|
|
||||||
phoneSidebarOverlayLayer(width: width)
|
|
||||||
.zIndex(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var phoneWorkspaceLayer: some View {
|
|
||||||
SybilPhoneDestinationView(
|
|
||||||
viewModel: viewModel,
|
|
||||||
composerFocusRequest: $composerFocusRequest,
|
|
||||||
route: route,
|
|
||||||
onRequestBack: { _ in showSidebarOverlay() },
|
|
||||||
onRequestNewChat: sidebarWorkspaceNewChatAction,
|
|
||||||
onShowSidebar: showSidebarOverlay
|
|
||||||
)
|
|
||||||
.background(SybilTheme.background)
|
|
||||||
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
|
|
||||||
.opacity(SidebarOverlaySwipeMetrics.workspaceOpacity(for: sidebarOverlayProgress))
|
|
||||||
.allowsHitTesting(!shouldRenderSidebarOverlay)
|
|
||||||
.background {
|
|
||||||
sidebarSwipeInstaller
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func phoneSidebarOverlayLayer(width: CGFloat) -> some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
phoneOverlayTopBar
|
|
||||||
|
|
||||||
SybilPhoneSidebarRoot(
|
|
||||||
viewModel: viewModel,
|
|
||||||
highlightedSelection: highlightedSidebarSelection,
|
|
||||||
onSelect: openSidebarSelection,
|
|
||||||
onRoute: showRouteAndClearSidebarHighlight
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.opacity(sidebarOverlayProgress)
|
|
||||||
.blur(radius: SidebarOverlaySwipeMetrics.overlayBlurRadius(for: sidebarOverlayProgress))
|
|
||||||
.offset(x: SidebarOverlaySwipeMetrics.overlayOffset(for: sidebarOverlayProgress, width: width))
|
|
||||||
.allowsHitTesting(isSidebarOverlayPresented)
|
|
||||||
.accessibilityHidden(!isSidebarOverlayPresented)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var sidebarSwipeInstaller: some View {
|
|
||||||
WorkspaceSwipePanInstaller(
|
|
||||||
direction: .right,
|
|
||||||
isEnabled: canRecognizeSidebarSwipe,
|
|
||||||
onBegan: { width in
|
|
||||||
beginSidebarSwipe(containerWidth: width)
|
|
||||||
},
|
|
||||||
onChanged: { translationX, width in
|
|
||||||
updateSidebarSwipe(with: translationX, containerWidth: width)
|
|
||||||
},
|
|
||||||
onEnded: { translationX, width, velocityX, didFinish in
|
|
||||||
finishSidebarSwipe(
|
|
||||||
translationX: translationX,
|
|
||||||
containerWidth: width,
|
|
||||||
velocityX: velocityX,
|
|
||||||
didFinish: didFinish
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var sidebarWorkspaceNewChatAction: (() -> Void)? {
|
|
||||||
guard !isSidebarOverlayPresented else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
startNewChatFromDestination()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var phoneOverlayTopBar: some View {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
SybilWordmark(size: 21)
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button {
|
|
||||||
hideSidebarOverlay()
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "chevron.right.2")
|
|
||||||
.font(.system(size: 21, weight: .bold))
|
|
||||||
.foregroundStyle(SybilTheme.text)
|
|
||||||
.frame(width: 54, height: 54)
|
|
||||||
.background(
|
|
||||||
Circle()
|
|
||||||
.fill(.ultraThinMaterial)
|
|
||||||
.overlay(
|
|
||||||
Circle()
|
|
||||||
.fill(SybilTheme.surface.opacity(0.76))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
Circle()
|
|
||||||
.stroke(SybilTheme.border.opacity(0.64), lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel("Hide conversations")
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 10)
|
|
||||||
.padding(.bottom, 12)
|
|
||||||
.background {
|
|
||||||
SybilPhoneOverlayBlurBand(edge: .top)
|
|
||||||
.ignoresSafeArea(edges: .top)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updatePhoneStackWidth(_ width: CGFloat) {
|
|
||||||
phoneStackWidth = max(width, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startNewChatFromDestination() {
|
|
||||||
viewModel.startNewChat()
|
|
||||||
composerFocusRequest += 1
|
|
||||||
showRoute(.draftChat)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showRoute(_ nextRoute: PhoneRoute) {
|
|
||||||
let update = {
|
|
||||||
route = nextRoute
|
|
||||||
}
|
|
||||||
|
|
||||||
if isSidebarOverlayPresented {
|
|
||||||
withAnimation(.easeOut(duration: 0.22)) {
|
|
||||||
update()
|
|
||||||
isSidebarOverlayPresented = false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
update()
|
|
||||||
}
|
|
||||||
|
|
||||||
resetSidebarSwipe(animated: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showRouteAndClearSidebarHighlight(_ nextRoute: PhoneRoute) {
|
|
||||||
showRoute(nextRoute)
|
|
||||||
clearSidebarHighlight()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showSidebarOverlay() {
|
|
||||||
withAnimation(.easeOut(duration: 0.18)) {
|
|
||||||
isSidebarOverlayPresented = true
|
|
||||||
}
|
|
||||||
resetSidebarSwipe(animated: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func hideSidebarOverlay() {
|
|
||||||
withAnimation(.easeOut(duration: 0.18)) {
|
|
||||||
isSidebarOverlayPresented = false
|
|
||||||
}
|
|
||||||
resetSidebarSwipe(animated: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openSidebarSelection(_ selection: SidebarSelection) {
|
|
||||||
if openingSelectionRequestID != nil, sidebarHighlightSelection == selection {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let requestID = UUID()
|
|
||||||
openingSelectionRequestID = requestID
|
|
||||||
setSidebarHighlight(selection)
|
|
||||||
|
|
||||||
Task {
|
|
||||||
await viewModel.selectForNavigation(selection)
|
|
||||||
guard openingSelectionRequestID == requestID else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
showRoute(PhoneRoute.from(selection: selection))
|
|
||||||
openingSelectionRequestID = nil
|
|
||||||
clearSidebarHighlight(selection, after: .milliseconds(260))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setSidebarHighlight(_ selection: SidebarSelection) {
|
|
||||||
sidebarHighlightClearTask?.cancel()
|
|
||||||
sidebarHighlightSelection = selection
|
|
||||||
}
|
|
||||||
|
|
||||||
private func clearSidebarHighlight(_ selection: SidebarSelection, after delay: Duration) {
|
|
||||||
sidebarHighlightClearTask?.cancel()
|
|
||||||
sidebarHighlightClearTask = Task { @MainActor in
|
|
||||||
try? await Task.sleep(for: delay)
|
|
||||||
guard !Task.isCancelled,
|
|
||||||
sidebarHighlightSelection == selection,
|
|
||||||
openingSelectionRequestID == nil else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sidebarHighlightSelection = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func clearSidebarHighlight() {
|
|
||||||
sidebarHighlightClearTask?.cancel()
|
|
||||||
openingSelectionRequestID = nil
|
|
||||||
sidebarHighlightSelection = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func beginSidebarSwipe(containerWidth: CGFloat) {
|
|
||||||
let update = {
|
|
||||||
phoneStackWidth = max(containerWidth, 1)
|
|
||||||
sidebarSwipeIsActive = true
|
|
||||||
sidebarSwipeHasLatched = false
|
|
||||||
}
|
|
||||||
|
|
||||||
var transaction = Transaction()
|
|
||||||
transaction.disablesAnimations = true
|
|
||||||
withTransaction(transaction, update)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateSidebarSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
|
|
||||||
let nextOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
|
|
||||||
let nextLatched = SidebarOverlaySwipeMetrics.isLatched(
|
|
||||||
offset: nextOffset,
|
|
||||||
width: containerWidth,
|
|
||||||
isCurrentlyLatched: sidebarSwipeHasLatched
|
|
||||||
)
|
|
||||||
|
|
||||||
var transaction = Transaction()
|
|
||||||
transaction.disablesAnimations = true
|
|
||||||
withTransaction(transaction) {
|
|
||||||
phoneStackWidth = max(containerWidth, 1)
|
|
||||||
sidebarSwipeOffset = nextOffset
|
|
||||||
sidebarSwipeHasLatched = nextLatched
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func finishSidebarSwipe(
|
|
||||||
translationX: CGFloat,
|
|
||||||
containerWidth: CGFloat,
|
|
||||||
velocityX: CGFloat,
|
|
||||||
didFinish: Bool
|
|
||||||
) {
|
|
||||||
guard sidebarSwipeIsActive else {
|
|
||||||
resetSidebarSwipe(animated: false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let finalOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: translationX, width: containerWidth)
|
|
||||||
let finalLatched = SidebarOverlaySwipeMetrics.isLatched(
|
|
||||||
offset: finalOffset,
|
|
||||||
width: containerWidth,
|
|
||||||
isCurrentlyLatched: sidebarSwipeHasLatched
|
|
||||||
)
|
|
||||||
updateSidebarSwipe(with: translationX, containerWidth: containerWidth)
|
|
||||||
|
|
||||||
if didFinish && SidebarOverlaySwipeMetrics.shouldComplete(
|
|
||||||
offset: finalOffset,
|
|
||||||
velocityX: velocityX,
|
|
||||||
width: containerWidth,
|
|
||||||
isLatched: finalLatched
|
|
||||||
) {
|
|
||||||
completeSidebarSwipe()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resetSidebarSwipe(animated: true, velocityX: velocityX)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func completeSidebarSwipe() {
|
|
||||||
guard !sidebarSwipeIsCompleting else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sidebarSwipeIsCompleting = true
|
|
||||||
withAnimation(.easeOut(duration: 0.18)) {
|
|
||||||
isSidebarOverlayPresented = true
|
|
||||||
}
|
|
||||||
resetSidebarSwipe(animated: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resetSidebarSwipe(animated: Bool, velocityX: CGFloat = 0) {
|
|
||||||
let currentOffset = sidebarSwipeOffset
|
|
||||||
let reset = {
|
|
||||||
sidebarSwipeOffset = 0
|
|
||||||
sidebarSwipeIsActive = false
|
|
||||||
sidebarSwipeIsCompleting = false
|
|
||||||
sidebarSwipeHasLatched = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if animated {
|
|
||||||
withAnimation(
|
|
||||||
SidebarOverlaySwipeMetrics.springAnimation(
|
|
||||||
currentOffset: currentOffset,
|
|
||||||
targetOffset: 0,
|
|
||||||
velocityX: velocityX
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
reset()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
reset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum SidebarOverlaySwipeMetrics {
|
|
||||||
static func clampedOffset(for rawTranslation: CGFloat, width: CGFloat) -> CGFloat {
|
|
||||||
BackSwipeMetrics.clampedOffset(for: rawTranslation, width: width)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func progress(for offset: CGFloat, width: CGFloat) -> CGFloat {
|
|
||||||
BackSwipeMetrics.progress(for: offset, width: width)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isLatched(offset: CGFloat, width: CGFloat, isCurrentlyLatched: Bool = false) -> Bool {
|
|
||||||
BackSwipeMetrics.isLatched(offset: offset, width: width, isCurrentlyLatched: isCurrentlyLatched)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func shouldComplete(offset: CGFloat, velocityX: CGFloat, width: CGFloat, isLatched: Bool) -> Bool {
|
|
||||||
BackSwipeMetrics.shouldComplete(offset: offset, velocityX: velocityX, width: width, isLatched: isLatched)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func springAnimation(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Animation {
|
|
||||||
BackSwipeMetrics.springAnimation(currentOffset: currentOffset, targetOffset: targetOffset, velocityX: velocityX)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func overlayOffset(for progress: CGFloat, width: CGFloat) -> CGFloat {
|
|
||||||
-(1 - min(max(progress, 0), 1)) * min(max(width * 0.18, 44), 76)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func overlayBlurRadius(for progress: CGFloat) -> CGFloat {
|
|
||||||
(1 - min(max(progress, 0), 1)) * 18
|
|
||||||
}
|
|
||||||
|
|
||||||
static func workspaceBlurRadius(for progress: CGFloat) -> CGFloat {
|
|
||||||
min(max(progress, 0), 1) * 14
|
|
||||||
}
|
|
||||||
|
|
||||||
static func workspaceOpacity(for progress: CGFloat) -> CGFloat {
|
|
||||||
1 - (min(max(progress, 0), 1) * 0.22)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SybilPhoneOverlayBlurBand: View {
|
|
||||||
var edge: VerticalEdge
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
Rectangle()
|
|
||||||
.fill(.ultraThinMaterial)
|
|
||||||
.opacity(0.34)
|
|
||||||
|
|
||||||
Rectangle()
|
|
||||||
.fill(
|
|
||||||
LinearGradient(
|
|
||||||
colors: gradientColors,
|
|
||||||
startPoint: edge == .top ? .top : .bottom,
|
|
||||||
endPoint: edge == .top ? .bottom : .top
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var gradientColors: [Color] {
|
|
||||||
[
|
|
||||||
Color.black.opacity(0.94),
|
|
||||||
SybilTheme.background.opacity(0.78),
|
|
||||||
Color.black.opacity(0)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SybilPhoneSidebarRoot: View {
|
private struct SybilPhoneSidebarRoot: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
var highlightedSelection: SidebarSelection?
|
@Binding var path: [PhoneRoute]
|
||||||
var onSelect: (SidebarSelection) -> Void
|
|
||||||
var onRoute: (PhoneRoute) -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -504,15 +89,54 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
.overlay(SybilTheme.border)
|
.overlay(SybilTheme.border)
|
||||||
}
|
}
|
||||||
|
|
||||||
SybilSidebarItemList(
|
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
||||||
viewModel: viewModel,
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
isSelected: { item in
|
ProgressView()
|
||||||
highlightedSelection == item.selection
|
.tint(SybilTheme.primary)
|
||||||
},
|
Text("Loading conversations…")
|
||||||
onSelect: { item in
|
.font(.sybil(.footnote))
|
||||||
onSelect(item.selection)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.padding(16)
|
||||||
|
} else if viewModel.sidebarItems.isEmpty {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Image(systemName: "message.badge")
|
||||||
|
.font(.system(size: 20, weight: .medium))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
Text("Start a chat or run your first search.")
|
||||||
|
.font(.sybil(.footnote))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding(16)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(viewModel.sidebarItems) { item in
|
||||||
|
NavigationLink(value: PhoneRoute.from(selection: item.selection)) {
|
||||||
|
SybilPhoneSidebarRow(item: item)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contextMenu {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task {
|
||||||
|
await viewModel.deleteItem(item.selection)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.refreshCollectionsFromUser()
|
||||||
|
}
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.background(SybilTheme.panelGradient)
|
.background(SybilTheme.panelGradient)
|
||||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
@@ -527,20 +151,19 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
|
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
|
||||||
viewModel.openSettings()
|
path = [.settings]
|
||||||
onRoute(.settings)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
|
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
|
||||||
viewModel.startNewSearch()
|
viewModel.startNewSearch()
|
||||||
onRoute(.draftSearch)
|
path = [.draftSearch]
|
||||||
}
|
}
|
||||||
|
|
||||||
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
|
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
|
||||||
viewModel.startNewChat()
|
viewModel.startNewChat()
|
||||||
onRoute(.draftChat)
|
path = [.draftChat]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 18)
|
.padding(.horizontal, 18)
|
||||||
@@ -578,24 +201,79 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SybilPhoneDestinationView: View {
|
private struct SybilPhoneSidebarRow: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
var item: SidebarItem
|
||||||
@Binding var composerFocusRequest: Int
|
|
||||||
let route: PhoneRoute
|
|
||||||
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
|
||||||
let onRequestNewChat: (() -> Void)?
|
|
||||||
let onShowSidebar: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SybilWorkspaceView(
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
viewModel: viewModel,
|
HStack(spacing: 8) {
|
||||||
composerFocusRequest: composerFocusRequest,
|
Image(systemName: item.kind == .chat ? "message" : "globe")
|
||||||
navigationLeadingControl: .showSidebar,
|
.font(.system(size: 12, weight: .semibold))
|
||||||
onShowSidebar: onShowSidebar,
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
onRequestBack: onRequestBack,
|
.frame(width: 22, height: 22)
|
||||||
onRequestNewChat: onRequestNewChat
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.fill(SybilTheme.surface.opacity(0.72))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(item.title)
|
||||||
|
.font(.sybil(.subheadline, weight: .semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(item.updatedAt.sybilRelativeLabel)
|
||||||
|
.font(.sybil(.caption2))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
|
||||||
|
if let initiated = item.initiatedLabel {
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(initiated)
|
||||||
|
.font(.sybil(.caption2))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
||||||
|
.lineLimit(1)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SybilPhoneDestinationView: View {
|
||||||
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
@Binding var path: [PhoneRoute]
|
||||||
|
@Binding var composerFocusRequest: Int
|
||||||
|
let route: PhoneRoute
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) {
|
||||||
|
viewModel.startNewChat()
|
||||||
|
composerFocusRequest += 1
|
||||||
|
if path.isEmpty {
|
||||||
|
path = [.draftChat]
|
||||||
|
} else {
|
||||||
|
path[path.index(before: path.endIndex)] = .draftChat
|
||||||
|
}
|
||||||
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.task(id: route) {
|
.task(id: route) {
|
||||||
applyRoute()
|
applyRoute()
|
||||||
}
|
}
|
||||||
@@ -604,14 +282,8 @@ private struct SybilPhoneDestinationView: View {
|
|||||||
private func applyRoute() {
|
private func applyRoute() {
|
||||||
switch route {
|
switch route {
|
||||||
case let .chat(chatID):
|
case let .chat(chatID):
|
||||||
guard viewModel.draftKind != nil || viewModel.selectedItem != .chat(chatID) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
viewModel.select(.chat(chatID))
|
viewModel.select(.chat(chatID))
|
||||||
case let .search(searchID):
|
case let .search(searchID):
|
||||||
guard viewModel.draftKind != nil || viewModel.selectedItem != .search(searchID) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
viewModel.select(.search(searchID))
|
viewModel.select(.search(searchID))
|
||||||
case .draftChat:
|
case .draftChat:
|
||||||
viewModel.startNewChat()
|
viewModel.startNewChat()
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ struct SybilSearchResultsView: View {
|
|||||||
var isLoading: Bool
|
var isLoading: Bool
|
||||||
var isRunning: Bool
|
var isRunning: Bool
|
||||||
var isStartingChat: Bool = false
|
var isStartingChat: Bool = false
|
||||||
var topContentInset: CGFloat = 0
|
var onRefresh: (() async -> Void)? = nil
|
||||||
var bottomContentInset: CGFloat = 0
|
|
||||||
var onStartChat: (() -> Void)? = nil
|
var onStartChat: (() -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -100,9 +99,12 @@ struct SybilSearchResultsView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.top, 20 + topContentInset)
|
.padding(.vertical, 20)
|
||||||
.padding(.bottom, 20 + bottomContentInset)
|
|
||||||
}
|
}
|
||||||
|
.refreshable {
|
||||||
|
await onRefresh?()
|
||||||
|
}
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import SwiftUI
|
|||||||
struct SybilSidebarView: View {
|
struct SybilSidebarView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
|
||||||
|
private func iconName(for item: SidebarItem) -> String {
|
||||||
|
switch item.kind {
|
||||||
|
case .chat: return "message"
|
||||||
|
case .search: return "globe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func isSelected(_ item: SidebarItem) -> Bool {
|
private func isSelected(_ item: SidebarItem) -> Bool {
|
||||||
viewModel.draftKind == nil && viewModel.selectedItem == item.selection
|
viewModel.draftKind == nil && viewModel.selectedItem == item.selection
|
||||||
}
|
}
|
||||||
@@ -50,13 +57,103 @@ struct SybilSidebarView: View {
|
|||||||
.overlay(SybilTheme.border)
|
.overlay(SybilTheme.border)
|
||||||
}
|
}
|
||||||
|
|
||||||
SybilSidebarItemList(
|
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
||||||
viewModel: viewModel,
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
isSelected: isSelected,
|
ProgressView()
|
||||||
onSelect: { item in
|
.tint(SybilTheme.primary)
|
||||||
viewModel.select(item.selection)
|
Text("Loading conversations…")
|
||||||
|
.font(.sybil(.footnote))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.padding(16)
|
||||||
|
} else if viewModel.sidebarItems.isEmpty {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Image(systemName: "message.badge")
|
||||||
|
.font(.system(size: 20, weight: .medium))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
Text("Start a chat or run your first search.")
|
||||||
|
.font(.sybil(.footnote))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding(16)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(viewModel.sidebarItems) { item in
|
||||||
|
Button {
|
||||||
|
viewModel.select(item.selection)
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: iconName(for: item))
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(isSelected(item) ? SybilTheme.accent : SybilTheme.textMuted)
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.fill(isSelected(item) ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.stroke(isSelected(item) ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(item.title)
|
||||||
|
.font(.sybil(.subheadline, weight: .semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(item.updatedAt.sybilRelativeLabel)
|
||||||
|
.font(.sybil(.caption2))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
|
||||||
|
if let initiated = item.initiatedLabel {
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(initiated)
|
||||||
|
.font(.sybil(.caption2))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
||||||
|
.lineLimit(1)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(isSelected(item) ? SybilTheme.selectedRowGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(isSelected(item) ? SybilTheme.primary.opacity(0.55) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contextMenu {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task {
|
||||||
|
await viewModel.deleteItem(item.selection)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.refreshCollectionsFromUser()
|
||||||
|
}
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
.background(SybilTheme.panelGradient)
|
.background(SybilTheme.panelGradient)
|
||||||
@@ -106,151 +203,3 @@ struct SybilSidebarView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SybilSidebarItemList: View {
|
|
||||||
@Bindable var viewModel: SybilViewModel
|
|
||||||
var isSelected: (SidebarItem) -> Bool
|
|
||||||
var onSelect: (SidebarItem) -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
ProgressView()
|
|
||||||
.tint(SybilTheme.primary)
|
|
||||||
Text("Loading conversations…")
|
|
||||||
.font(.sybil(.footnote))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
||||||
.padding(16)
|
|
||||||
} else if viewModel.sidebarItems.isEmpty {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
Image(systemName: "message.badge")
|
|
||||||
.font(.system(size: 20, weight: .medium))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
Text("Start a chat or run your first search.")
|
|
||||||
.font(.sybil(.footnote))
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.padding(16)
|
|
||||||
} else {
|
|
||||||
ScrollView {
|
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
|
||||||
ForEach(viewModel.sidebarItems) { item in
|
|
||||||
Button {
|
|
||||||
onSelect(item)
|
|
||||||
} label: {
|
|
||||||
SybilSidebarRow(item: item, isSelected: isSelected(item))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.contextMenu {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
Task {
|
|
||||||
await viewModel.deleteItem(item.selection)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Delete", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(10)
|
|
||||||
}
|
|
||||||
.refreshable {
|
|
||||||
await viewModel.refreshVisibleContent(
|
|
||||||
refreshCollections: true,
|
|
||||||
refreshSelection: false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SybilSidebarRow: View {
|
|
||||||
var item: SidebarItem
|
|
||||||
var isSelected: Bool
|
|
||||||
|
|
||||||
private var isHighlighted: Bool {
|
|
||||||
isSelected
|
|
||||||
}
|
|
||||||
|
|
||||||
private var iconName: String {
|
|
||||||
switch item.kind {
|
|
||||||
case .chat: return "message"
|
|
||||||
case .search: return "globe"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: iconName)
|
|
||||||
.font(.system(size: 12, weight: .semibold))
|
|
||||||
.foregroundStyle(isHighlighted ? SybilTheme.accent : SybilTheme.textMuted)
|
|
||||||
.frame(width: 22, height: 22)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 7)
|
|
||||||
.fill(isHighlighted ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 7)
|
|
||||||
.stroke(isHighlighted ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(item.title)
|
|
||||||
.font(.sybil(.subheadline, weight: .semibold))
|
|
||||||
.lineLimit(1)
|
|
||||||
.layoutPriority(1)
|
|
||||||
|
|
||||||
Spacer(minLength: 8)
|
|
||||||
|
|
||||||
if item.isRunning {
|
|
||||||
SybilSidebarActivityIndicator()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Text(item.updatedAt.sybilRelativeLabel)
|
|
||||||
.font(.sybil(.caption2))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
|
|
||||||
if let initiated = item.initiatedLabel {
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
Text(initiated)
|
|
||||||
.font(.sybil(.caption2))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
|
||||||
.lineLimit(1)
|
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundStyle(SybilTheme.text)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(isHighlighted ? SybilTheme.selectedRowGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.stroke(isHighlighted ? SybilTheme.primary.opacity(0.55) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
|
||||||
)
|
|
||||||
.contentShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SybilSidebarActivityIndicator: View {
|
|
||||||
var body: some View {
|
|
||||||
ProgressView()
|
|
||||||
.progressViewStyle(.circular)
|
|
||||||
.controlSize(.small)
|
|
||||||
.tint(SybilTheme.accent)
|
|
||||||
.scaleEffect(0.82)
|
|
||||||
.frame(width: 16, height: 16)
|
|
||||||
.accessibilityLabel("Generating")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ enum SybilFontRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static let registeredFonts: Void = {
|
private static let registeredFonts: Void = {
|
||||||
for fontName in ["Inter", "Orbitron", "StalinistOne-Regular"] {
|
for fontName in ["Inter", "Orbitron"] {
|
||||||
guard let url = Bundle.main.url(forResource: fontName, withExtension: "ttf", subdirectory: "Fonts") ??
|
guard let url = Bundle.main.url(forResource: fontName, withExtension: "ttf", subdirectory: "Fonts") ??
|
||||||
Bundle.main.url(forResource: fontName, withExtension: "ttf")
|
Bundle.main.url(forResource: fontName, withExtension: "ttf")
|
||||||
else {
|
else {
|
||||||
@@ -203,7 +203,7 @@ struct SybilWordmark: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text("SYBIL")
|
Text("SYBIL")
|
||||||
.font(.custom("Stalinist One", size: size))
|
.font(.custom("Orbitron", size: size))
|
||||||
.fontWeight(.black)
|
.fontWeight(.black)
|
||||||
.tracking(0)
|
.tracking(0)
|
||||||
.foregroundStyle(SybilTheme.brandGradient)
|
.foregroundStyle(SybilTheme.brandGradient)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,9 +8,6 @@ private struct MockClientCallSnapshot: Sendable {
|
|||||||
var listSearches = 0
|
var listSearches = 0
|
||||||
var getChat = 0
|
var getChat = 0
|
||||||
var getSearch = 0
|
var getSearch = 0
|
||||||
var getActiveRuns = 0
|
|
||||||
var attachCompletionStream = 0
|
|
||||||
var attachSearchStream = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct UnexpectedClientCall: Error {}
|
private struct UnexpectedClientCall: Error {}
|
||||||
@@ -20,77 +17,25 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
private let searchesResponse: [SearchSummary]
|
private let searchesResponse: [SearchSummary]
|
||||||
private let chatDetails: [String: ChatDetail]
|
private let chatDetails: [String: ChatDetail]
|
||||||
private let searchDetails: [String: SearchDetail]
|
private let searchDetails: [String: SearchDetail]
|
||||||
private let createChatResponse: ChatSummary?
|
|
||||||
private let activeRunsResponse: ActiveRunsResponse
|
|
||||||
|
|
||||||
private var snapshot = MockClientCallSnapshot()
|
private var snapshot = MockClientCallSnapshot()
|
||||||
private var getChatDelayNanoseconds: UInt64 = 0
|
|
||||||
private var getSearchDelayNanoseconds: UInt64 = 0
|
|
||||||
private var completionStreamNetworkErrorMessage: String?
|
|
||||||
private var completionStreamDelayNanoseconds: UInt64 = 0
|
|
||||||
private var completionAttachEvents: [String: [CompletionStreamEvent]] = [:]
|
|
||||||
private var completionAttachDelayNanoseconds: UInt64 = 0
|
|
||||||
private var searchStreamNetworkErrorMessage: String?
|
|
||||||
private var searchStreamDelayNanoseconds: UInt64 = 0
|
|
||||||
private var searchAttachEvents: [String: [SearchStreamEvent]] = [:]
|
|
||||||
private var searchAttachDelayNanoseconds: UInt64 = 0
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
chatsResponse: [ChatSummary] = [],
|
chatsResponse: [ChatSummary] = [],
|
||||||
searchesResponse: [SearchSummary] = [],
|
searchesResponse: [SearchSummary] = [],
|
||||||
chatDetails: [String: ChatDetail] = [:],
|
chatDetails: [String: ChatDetail] = [:],
|
||||||
searchDetails: [String: SearchDetail] = [:],
|
searchDetails: [String: SearchDetail] = [:]
|
||||||
createChatResponse: ChatSummary? = nil,
|
|
||||||
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse()
|
|
||||||
) {
|
) {
|
||||||
self.chatsResponse = chatsResponse
|
self.chatsResponse = chatsResponse
|
||||||
self.searchesResponse = searchesResponse
|
self.searchesResponse = searchesResponse
|
||||||
self.chatDetails = chatDetails
|
self.chatDetails = chatDetails
|
||||||
self.searchDetails = searchDetails
|
self.searchDetails = searchDetails
|
||||||
self.createChatResponse = createChatResponse
|
|
||||||
self.activeRunsResponse = activeRunsResponse
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentSnapshot() -> MockClientCallSnapshot {
|
func currentSnapshot() -> MockClientCallSnapshot {
|
||||||
snapshot
|
snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
|
||||||
completionStreamNetworkErrorMessage = message
|
|
||||||
completionStreamDelayNanoseconds = delayNanoseconds
|
|
||||||
}
|
|
||||||
|
|
||||||
func setGetChatDelay(_ delayNanoseconds: UInt64) {
|
|
||||||
getChatDelayNanoseconds = delayNanoseconds
|
|
||||||
}
|
|
||||||
|
|
||||||
func setGetSearchDelay(_ delayNanoseconds: UInt64) {
|
|
||||||
getSearchDelayNanoseconds = delayNanoseconds
|
|
||||||
}
|
|
||||||
|
|
||||||
func setSearchStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
|
||||||
searchStreamNetworkErrorMessage = message
|
|
||||||
searchStreamDelayNanoseconds = delayNanoseconds
|
|
||||||
}
|
|
||||||
|
|
||||||
func setCompletionAttachEvents(
|
|
||||||
chatID: String,
|
|
||||||
events: [CompletionStreamEvent],
|
|
||||||
delayNanoseconds: UInt64 = 0
|
|
||||||
) {
|
|
||||||
completionAttachEvents[chatID] = events
|
|
||||||
completionAttachDelayNanoseconds = delayNanoseconds
|
|
||||||
}
|
|
||||||
|
|
||||||
func setSearchAttachEvents(
|
|
||||||
searchID: String,
|
|
||||||
events: [SearchStreamEvent],
|
|
||||||
delayNanoseconds: UInt64 = 0
|
|
||||||
) {
|
|
||||||
searchAttachEvents[searchID] = events
|
|
||||||
searchAttachDelayNanoseconds = delayNanoseconds
|
|
||||||
}
|
|
||||||
|
|
||||||
func verifySession() async throws -> AuthSession {
|
func verifySession() async throws -> AuthSession {
|
||||||
AuthSession(authenticated: true, mode: "open")
|
AuthSession(authenticated: true, mode: "open")
|
||||||
}
|
}
|
||||||
@@ -101,17 +46,11 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func createChat(title: String?) async throws -> ChatSummary {
|
func createChat(title: String?) async throws -> ChatSummary {
|
||||||
if let createChatResponse {
|
|
||||||
return createChatResponse
|
|
||||||
}
|
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
func getChat(chatID: String) async throws -> ChatDetail {
|
func getChat(chatID: String) async throws -> ChatDetail {
|
||||||
snapshot.getChat += 1
|
snapshot.getChat += 1
|
||||||
if getChatDelayNanoseconds > 0 {
|
|
||||||
try await Task.sleep(nanoseconds: getChatDelayNanoseconds)
|
|
||||||
}
|
|
||||||
guard let detail = chatDetails[chatID] else {
|
guard let detail = chatDetails[chatID] else {
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
@@ -137,9 +76,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
|
|
||||||
func getSearch(searchID: String) async throws -> SearchDetail {
|
func getSearch(searchID: String) async throws -> SearchDetail {
|
||||||
snapshot.getSearch += 1
|
snapshot.getSearch += 1
|
||||||
if getSearchDelayNanoseconds > 0 {
|
|
||||||
try await Task.sleep(nanoseconds: getSearchDelayNanoseconds)
|
|
||||||
}
|
|
||||||
guard let detail = searchDetails[searchID] else {
|
guard let detail = searchDetails[searchID] else {
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
@@ -158,65 +94,20 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
ModelCatalogResponse(providers: [:])
|
ModelCatalogResponse(providers: [:])
|
||||||
}
|
}
|
||||||
|
|
||||||
func getActiveRuns() async throws -> ActiveRunsResponse {
|
|
||||||
snapshot.getActiveRuns += 1
|
|
||||||
return activeRunsResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
func runCompletionStream(
|
func runCompletionStream(
|
||||||
body: CompletionStreamRequest,
|
body: CompletionStreamRequest,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
) async throws {
|
) async throws {
|
||||||
if completionStreamDelayNanoseconds > 0 {
|
|
||||||
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
|
|
||||||
}
|
|
||||||
if let completionStreamNetworkErrorMessage {
|
|
||||||
throw APIError.networkError(message: completionStreamNetworkErrorMessage)
|
|
||||||
}
|
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
func attachCompletionStream(
|
|
||||||
chatID: String,
|
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
|
||||||
) async throws {
|
|
||||||
snapshot.attachCompletionStream += 1
|
|
||||||
let events = completionAttachEvents[chatID] ?? []
|
|
||||||
for event in events {
|
|
||||||
await onEvent(event)
|
|
||||||
}
|
|
||||||
if completionAttachDelayNanoseconds > 0 {
|
|
||||||
try await Task.sleep(nanoseconds: completionAttachDelayNanoseconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runSearchStream(
|
func runSearchStream(
|
||||||
searchID: String,
|
searchID: String,
|
||||||
body: SearchRunRequest,
|
body: SearchRunRequest,
|
||||||
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
) async throws {
|
) async throws {
|
||||||
if searchStreamDelayNanoseconds > 0 {
|
|
||||||
try await Task.sleep(nanoseconds: searchStreamDelayNanoseconds)
|
|
||||||
}
|
|
||||||
if let searchStreamNetworkErrorMessage {
|
|
||||||
throw APIError.networkError(message: searchStreamNetworkErrorMessage)
|
|
||||||
}
|
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
func attachSearchStream(
|
|
||||||
searchID: String,
|
|
||||||
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
|
||||||
) async throws {
|
|
||||||
snapshot.attachSearchStream += 1
|
|
||||||
let events = searchAttachEvents[searchID] ?? []
|
|
||||||
for event in events {
|
|
||||||
await onEvent(event)
|
|
||||||
}
|
|
||||||
if searchAttachDelayNanoseconds > 0 {
|
|
||||||
try await Task.sleep(nanoseconds: searchAttachDelayNanoseconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -376,231 +267,6 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
|
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func selectingChatClearsStaleTranscriptUntilNewDetailLoads() async throws {
|
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_210)
|
|
||||||
let staleDetail = makeChatDetail(id: "chat-old", date: date, body: "stale transcript")
|
|
||||||
let freshDetail = makeChatDetail(id: "chat-new", date: date, body: "fresh transcript")
|
|
||||||
let client = MockSybilClient(chatDetails: ["chat-new": freshDetail])
|
|
||||||
await client.setGetChatDelay(50_000_000)
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
viewModel.isAuthenticated = true
|
|
||||||
viewModel.isCheckingSession = false
|
|
||||||
viewModel.selectedItem = .chat("chat-old")
|
|
||||||
viewModel.selectedChat = staleDetail
|
|
||||||
|
|
||||||
viewModel.select(.chat("chat-new"))
|
|
||||||
|
|
||||||
#expect(viewModel.displayedMessages.isEmpty)
|
|
||||||
#expect(viewModel.isLoadingSelection)
|
|
||||||
|
|
||||||
try await Task.sleep(nanoseconds: 90_000_000)
|
|
||||||
|
|
||||||
#expect(viewModel.displayedMessages.first?.content == "fresh transcript")
|
|
||||||
#expect(!viewModel.isLoadingSelection)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func navigationSelectionWaitsForFastTranscriptLoad() async throws {
|
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_220)
|
|
||||||
let detail = makeChatDetail(id: "chat-fast", date: date, body: "loaded before push")
|
|
||||||
let client = MockSybilClient(chatDetails: ["chat-fast": detail])
|
|
||||||
await client.setGetChatDelay(20_000_000)
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
viewModel.isAuthenticated = true
|
|
||||||
viewModel.isCheckingSession = false
|
|
||||||
|
|
||||||
await viewModel.selectForNavigation(.chat("chat-fast"), preloadTimeout: .milliseconds(500))
|
|
||||||
|
|
||||||
#expect(viewModel.selectedItem == .chat("chat-fast"))
|
|
||||||
#expect(viewModel.displayedMessages.first?.content == "loaded before push")
|
|
||||||
#expect(!viewModel.isLoadingSelection)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func navigationSelectionTimesOutAndKeepsLoadingTranscript() async throws {
|
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_230)
|
|
||||||
let detail = makeChatDetail(id: "chat-slow", date: date, body: "loaded after push")
|
|
||||||
let client = MockSybilClient(chatDetails: ["chat-slow": detail])
|
|
||||||
await client.setGetChatDelay(100_000_000)
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
viewModel.isAuthenticated = true
|
|
||||||
viewModel.isCheckingSession = false
|
|
||||||
|
|
||||||
await viewModel.selectForNavigation(.chat("chat-slow"), preloadTimeout: .milliseconds(10))
|
|
||||||
|
|
||||||
#expect(viewModel.selectedItem == .chat("chat-slow"))
|
|
||||||
#expect(viewModel.displayedMessages.isEmpty)
|
|
||||||
#expect(viewModel.isLoadingSelection)
|
|
||||||
|
|
||||||
try await Task.sleep(nanoseconds: 150_000_000)
|
|
||||||
|
|
||||||
#expect(viewModel.displayedMessages.first?.content == "loaded after push")
|
|
||||||
#expect(!viewModel.isLoadingSelection)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func newDraftChatDoesNotShowTypingStateFromPreviousSend() async throws {
|
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_240)
|
|
||||||
let detail = makeChatDetail(id: "chat-typing", date: date, body: "existing transcript")
|
|
||||||
let client = MockSybilClient(chatDetails: ["chat-typing": detail])
|
|
||||||
await client.setCompletionStreamNetworkError(
|
|
||||||
"Network error -1005 while requesting POST: The network connection was lost.",
|
|
||||||
delayNanoseconds: 50_000_000
|
|
||||||
)
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
viewModel.isAuthenticated = true
|
|
||||||
viewModel.isCheckingSession = false
|
|
||||||
viewModel.selectedItem = .chat("chat-typing")
|
|
||||||
viewModel.selectedChat = detail
|
|
||||||
viewModel.composer = "continue"
|
|
||||||
|
|
||||||
let sendTask = Task {
|
|
||||||
await viewModel.sendComposer()
|
|
||||||
}
|
|
||||||
try await Task.sleep(nanoseconds: 10_000_000)
|
|
||||||
|
|
||||||
#expect(viewModel.isSendingVisibleChat)
|
|
||||||
|
|
||||||
viewModel.startNewChat()
|
|
||||||
|
|
||||||
#expect(viewModel.displayedMessages.isEmpty)
|
|
||||||
#expect(!viewModel.isSendingVisibleChat)
|
|
||||||
|
|
||||||
await sendTask.value
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func reconnectAttachesSelectedActiveChatStream() async throws {
|
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_260)
|
|
||||||
let chat = makeChatSummary(id: "chat-active", date: date)
|
|
||||||
let detail = makeChatDetail(id: "chat-active", date: date, body: "existing transcript")
|
|
||||||
let client = MockSybilClient(
|
|
||||||
chatsResponse: [chat],
|
|
||||||
chatDetails: ["chat-active": detail],
|
|
||||||
activeRunsResponse: ActiveRunsResponse(chats: ["chat-active"])
|
|
||||||
)
|
|
||||||
await client.setCompletionAttachEvents(
|
|
||||||
chatID: "chat-active",
|
|
||||||
events: [.delta(CompletionStreamDelta(text: "streaming"))],
|
|
||||||
delayNanoseconds: 100_000_000
|
|
||||||
)
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
|
|
||||||
await viewModel.reconnect()
|
|
||||||
try await Task.sleep(nanoseconds: 20_000_000)
|
|
||||||
|
|
||||||
let snapshot = await client.currentSnapshot()
|
|
||||||
#expect(snapshot.getActiveRuns >= 1)
|
|
||||||
#expect(snapshot.attachCompletionStream == 1)
|
|
||||||
#expect(viewModel.sidebarItems.first?.isRunning == true)
|
|
||||||
#expect(viewModel.isSendingVisibleChat)
|
|
||||||
#expect(viewModel.displayedMessages.last?.content == "streaming")
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func activeRunOnDifferentChatDoesNotDisableComposer() async throws {
|
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_270)
|
|
||||||
let activeChat = makeChatSummary(id: "chat-active", date: date)
|
|
||||||
let idleChat = makeChatSummary(id: "chat-idle", date: date.addingTimeInterval(1))
|
|
||||||
let client = MockSybilClient(
|
|
||||||
chatsResponse: [idleChat, activeChat],
|
|
||||||
chatDetails: [
|
|
||||||
"chat-active": makeChatDetail(id: "chat-active", date: date, body: "active transcript"),
|
|
||||||
"chat-idle": makeChatDetail(id: "chat-idle", date: date, body: "idle transcript")
|
|
||||||
],
|
|
||||||
activeRunsResponse: ActiveRunsResponse(chats: ["chat-active"])
|
|
||||||
)
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
viewModel.selectedItem = .chat("chat-idle")
|
|
||||||
viewModel.composer = "new message"
|
|
||||||
|
|
||||||
await viewModel.reconnect()
|
|
||||||
|
|
||||||
#expect(viewModel.selectedItem == .chat("chat-idle"))
|
|
||||||
#expect(viewModel.sidebarItems.first(where: { $0.selection == .chat("chat-active") })?.isRunning == true)
|
|
||||||
#expect(!viewModel.isActiveSelectionSending)
|
|
||||||
#expect(viewModel.canSendComposer)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func backgroundChatStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws {
|
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_300)
|
|
||||||
let chat = makeChatSummary(id: "chat-3", date: date)
|
|
||||||
let initialDetail = makeChatDetail(id: "chat-3", date: date, body: "stale transcript")
|
|
||||||
let refreshedDetail = makeChatDetail(id: "chat-3", date: date, body: "fresh transcript")
|
|
||||||
let client = MockSybilClient(
|
|
||||||
chatsResponse: [chat],
|
|
||||||
chatDetails: ["chat-3": refreshedDetail]
|
|
||||||
)
|
|
||||||
await client.setCompletionStreamNetworkError(
|
|
||||||
"Network error -1005 while requesting POST: The network connection was lost.",
|
|
||||||
delayNanoseconds: 50_000_000
|
|
||||||
)
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
viewModel.isAuthenticated = true
|
|
||||||
viewModel.isCheckingSession = false
|
|
||||||
viewModel.selectedItem = .chat("chat-3")
|
|
||||||
viewModel.selectedChat = initialDetail
|
|
||||||
viewModel.composer = "continue"
|
|
||||||
|
|
||||||
let sendTask = Task {
|
|
||||||
await viewModel.sendComposer()
|
|
||||||
}
|
|
||||||
try await Task.sleep(nanoseconds: 10_000_000)
|
|
||||||
viewModel.markAppInactiveForNetwork()
|
|
||||||
await sendTask.value
|
|
||||||
|
|
||||||
#expect(viewModel.errorMessage == nil)
|
|
||||||
#expect(viewModel.composer.isEmpty)
|
|
||||||
#expect(!viewModel.isSending)
|
|
||||||
#expect(viewModel.selectedChat?.messages.first?.content == "stale transcript")
|
|
||||||
|
|
||||||
await viewModel.refreshAfterAppBecameActive(refreshCollections: false, refreshSelection: true)
|
|
||||||
|
|
||||||
let snapshot = await client.currentSnapshot()
|
|
||||||
#expect(snapshot.getChat == 1)
|
|
||||||
#expect(viewModel.errorMessage == nil)
|
|
||||||
#expect(viewModel.selectedChat?.messages.first?.content == "fresh transcript")
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func backgroundSearchStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws {
|
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_400)
|
|
||||||
let refreshedDetail = makeSearchDetail(id: "search-3", date: date, answer: "fresh answer")
|
|
||||||
let client = MockSybilClient(
|
|
||||||
searchDetails: ["search-3": refreshedDetail]
|
|
||||||
)
|
|
||||||
await client.setSearchStreamNetworkError(
|
|
||||||
"Network error -1005 while requesting POST: The network connection was lost.",
|
|
||||||
delayNanoseconds: 50_000_000
|
|
||||||
)
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
viewModel.isAuthenticated = true
|
|
||||||
viewModel.isCheckingSession = false
|
|
||||||
viewModel.selectedItem = .search("search-3")
|
|
||||||
viewModel.selectedSearch = makeSearchDetail(id: "search-3", date: date, answer: "stale answer")
|
|
||||||
viewModel.composer = "refresh me"
|
|
||||||
|
|
||||||
let sendTask = Task {
|
|
||||||
await viewModel.sendComposer()
|
|
||||||
}
|
|
||||||
try await Task.sleep(nanoseconds: 10_000_000)
|
|
||||||
viewModel.markAppInactiveForNetwork()
|
|
||||||
await sendTask.value
|
|
||||||
|
|
||||||
#expect(viewModel.errorMessage == nil)
|
|
||||||
#expect(viewModel.composer.isEmpty)
|
|
||||||
#expect(!viewModel.isSending)
|
|
||||||
|
|
||||||
await viewModel.refreshAfterAppBecameActive(refreshCollections: false, refreshSelection: true)
|
|
||||||
|
|
||||||
let snapshot = await client.currentSnapshot()
|
|
||||||
#expect(snapshot.getSearch == 1)
|
|
||||||
#expect(viewModel.errorMessage == nil)
|
|
||||||
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func newChatSwipeMetricsClampProgressAndLatch() async throws {
|
@Test func newChatSwipeMetricsClampProgressAndLatch() async throws {
|
||||||
let width: CGFloat = 390
|
let width: CGFloat = 390
|
||||||
let maxTravel = NewChatSwipeMetrics.maxTravel(for: width)
|
let maxTravel = NewChatSwipeMetrics.maxTravel(for: width)
|
||||||
@@ -617,14 +283,4 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
#expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 2, verticalTravel: 1, leftwardVelocity: 120, verticalVelocity: 30))
|
#expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 2, verticalTravel: 1, leftwardVelocity: 120, verticalVelocity: 30))
|
||||||
#expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 8, verticalTravel: 24, leftwardVelocity: 20, verticalVelocity: 140))
|
#expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 8, verticalTravel: 24, leftwardVelocity: 20, verticalVelocity: 140))
|
||||||
#expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 18, verticalTravel: 18, leftwardVelocity: 80, verticalVelocity: 90))
|
#expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 18, verticalTravel: 18, leftwardVelocity: 80, verticalVelocity: 90))
|
||||||
#expect(!NewChatSwipeMetrics.shouldComplete(offset: -24, velocityX: 0, width: width, isLatched: false))
|
|
||||||
#expect(NewChatSwipeMetrics.shouldComplete(offset: -24, velocityX: -800, width: width, isLatched: false))
|
|
||||||
#expect(!NewChatSwipeMetrics.shouldComplete(offset: -(latchDistance + 1), velocityX: 800, width: width, isLatched: true))
|
|
||||||
#expect(BackSwipeMetrics.clampedOffset(for: 500, width: width) == maxTravel)
|
|
||||||
#expect(BackSwipeMetrics.progress(for: maxTravel / 2, width: width) == 0.5)
|
|
||||||
#expect(BackSwipeMetrics.isLatched(offset: latchDistance + 1, width: width))
|
|
||||||
#expect(BackSwipeMetrics.shouldBeginPan(rightwardTravel: 24, verticalTravel: 8, rightwardVelocity: 0, verticalVelocity: 0))
|
|
||||||
#expect(!BackSwipeMetrics.shouldBeginPan(rightwardTravel: 8, verticalTravel: 24, rightwardVelocity: 20, verticalVelocity: 140))
|
|
||||||
#expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false))
|
|
||||||
#expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true))
|
|
||||||
}
|
}
|
||||||
|
|||||||
22
ios/justfile
22
ios/justfile
@@ -1,28 +1,10 @@
|
|||||||
simulator := "platform=iOS Simulator,name=iPhone 16e,OS=latest"
|
|
||||||
simulator_name := "iPhone 16e"
|
|
||||||
derived_data := "build/DerivedData"
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@just build
|
@just build
|
||||||
|
|
||||||
build:
|
build:
|
||||||
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
||||||
if command -v xcbeautify >/dev/null 2>&1; then \
|
if command -v xcbeautify >/dev/null 2>&1; then \
|
||||||
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
xcodebuild -scheme Sybil -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' | xcbeautify; \
|
||||||
else \
|
else \
|
||||||
xcodebuild -scheme Sybil -destination '{{simulator}}'; \
|
xcodebuild -scheme Sybil -destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest'; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
test:
|
|
||||||
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
|
|
||||||
|
|
||||||
run:
|
|
||||||
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
|
||||||
xcrun simctl boot '{{simulator_name}}' 2>/dev/null || true
|
|
||||||
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}'
|
|
||||||
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
|
||||||
xcrun simctl launch booted net.buzzert.sybil2
|
|
||||||
|
|
||||||
screenshot path="build/sybil-screenshot.png":
|
|
||||||
mkdir -p "$(dirname '{{path}}')"
|
|
||||||
xcrun simctl io booted screenshot '{{path}}'
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,59 +0,0 @@
|
|||||||
export type SseStreamEvent = {
|
|
||||||
event: string;
|
|
||||||
data: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SseStreamListener = (event: SseStreamEvent) => void;
|
|
||||||
|
|
||||||
export class ActiveSseStream {
|
|
||||||
private readonly events: SseStreamEvent[] = [];
|
|
||||||
private readonly listeners = new Set<SseStreamListener>();
|
|
||||||
private completed = false;
|
|
||||||
private resolveDone!: () => void;
|
|
||||||
|
|
||||||
readonly done: Promise<void>;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.done = new Promise((resolve) => {
|
|
||||||
this.resolveDone = resolve;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get isCompleted() {
|
|
||||||
return this.completed;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(event: string, data: unknown) {
|
|
||||||
if (this.completed) return;
|
|
||||||
const entry = { event, data };
|
|
||||||
this.events.push(entry);
|
|
||||||
for (const listener of this.listeners) {
|
|
||||||
listener(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
complete(finalEvent?: SseStreamEvent) {
|
|
||||||
if (this.completed) return;
|
|
||||||
if (finalEvent) {
|
|
||||||
this.emit(finalEvent.event, finalEvent.data);
|
|
||||||
}
|
|
||||||
this.completed = true;
|
|
||||||
this.listeners.clear();
|
|
||||||
this.resolveDone();
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe(listener: SseStreamListener) {
|
|
||||||
for (const event of this.events) {
|
|
||||||
listener(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.completed) {
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
|
|
||||||
this.listeners.add(listener);
|
|
||||||
return () => {
|
|
||||||
this.listeners.delete(listener);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { ActiveSseStream, type SseStreamEvent } from "./active-streams.js";
|
|
||||||
import { prisma } from "./db.js";
|
import { prisma } from "./db.js";
|
||||||
import { requireAdmin } from "./auth.js";
|
import { requireAdmin } from "./auth.js";
|
||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
import { buildComparableAttachments } from "./llm/message-content.js";
|
import { buildComparableAttachments } from "./llm/message-content.js";
|
||||||
import { runMultiplex } from "./llm/multiplexer.js";
|
import { runMultiplex } from "./llm/multiplexer.js";
|
||||||
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
|
import { runMultiplexStream } from "./llm/streaming.js";
|
||||||
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
||||||
import { openaiClient } from "./llm/providers.js";
|
import { openaiClient } from "./llm/providers.js";
|
||||||
import { exaClient } from "./search/exa.js";
|
import { exaClient } from "./search/exa.js";
|
||||||
@@ -121,26 +120,6 @@ const CompletionMessageSchema = z
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const CompletionStreamBody = z
|
|
||||||
.object({
|
|
||||||
chatId: z.string().optional(),
|
|
||||||
persist: z.boolean().optional(),
|
|
||||||
provider: z.enum(["openai", "anthropic", "xai"]),
|
|
||||||
model: z.string().min(1),
|
|
||||||
messages: z.array(CompletionMessageSchema),
|
|
||||||
temperature: z.number().min(0).max(2).optional(),
|
|
||||||
maxTokens: z.number().int().positive().optional(),
|
|
||||||
})
|
|
||||||
.superRefine((value, ctx) => {
|
|
||||||
if (value.persist === false && value.chatId) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: "chatId must be omitted when persist is false",
|
|
||||||
path: ["chatId"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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)) {
|
||||||
@@ -314,246 +293,6 @@ function buildSseHeaders(originHeader: string | undefined) {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearchRunRequest = z.infer<typeof SearchRunBody>;
|
|
||||||
|
|
||||||
const activeChatStreams = new Map<string, ActiveSseStream>();
|
|
||||||
const activeSearchStreams = new Map<string, ActiveSseStream>();
|
|
||||||
|
|
||||||
function getErrorMessage(err: unknown) {
|
|
||||||
return err instanceof Error ? err.message : String(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeSseEvent(reply: FastifyReply, event: SseStreamEvent) {
|
|
||||||
if (reply.raw.destroyed || reply.raw.writableEnded) return;
|
|
||||||
reply.raw.write(`event: ${event.event}\n`);
|
|
||||||
reply.raw.write(`data: ${JSON.stringify(event.data)}\n\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function streamActiveRun(req: FastifyRequest, reply: FastifyReply, stream: ActiveSseStream) {
|
|
||||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
|
||||||
reply.raw.flushHeaders?.();
|
|
||||||
|
|
||||||
let unsubscribe = () => {};
|
|
||||||
let closed = false;
|
|
||||||
const closedPromise = new Promise<void>((resolve) => {
|
|
||||||
const onClose = () => {
|
|
||||||
closed = true;
|
|
||||||
unsubscribe();
|
|
||||||
reply.raw.off("close", onClose);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
reply.raw.on("close", onClose);
|
|
||||||
stream.done.finally(() => {
|
|
||||||
reply.raw.off("close", onClose);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
unsubscribe = stream.subscribe((event) => writeSseEvent(reply, event));
|
|
||||||
await Promise.race([stream.done, closedPromise]);
|
|
||||||
unsubscribe();
|
|
||||||
|
|
||||||
if (!closed && !reply.raw.destroyed && !reply.raw.writableEnded) {
|
|
||||||
reply.raw.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapChatStreamEvent(ev: StreamEvent): SseStreamEvent {
|
|
||||||
if (ev.type === "tool_call") return { event: "tool_call", data: ev.event };
|
|
||||||
return { event: ev.type, data: ev };
|
|
||||||
}
|
|
||||||
|
|
||||||
function startActiveChatStream(chatId: string, body: z.infer<typeof CompletionStreamBody>) {
|
|
||||||
const stream = new ActiveSseStream();
|
|
||||||
activeChatStreams.set(chatId, stream);
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
let sawTerminalEvent = false;
|
|
||||||
try {
|
|
||||||
for await (const ev of runMultiplexStream(body)) {
|
|
||||||
const event = mapChatStreamEvent(ev);
|
|
||||||
if (ev.type === "done" || ev.type === "error") {
|
|
||||||
sawTerminalEvent = true;
|
|
||||||
stream.complete(event);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
stream.emit(event.event, event.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sawTerminalEvent) {
|
|
||||||
stream.complete({ event: "error", data: { message: "chat stream ended unexpectedly" } });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
stream.complete({ event: "error", data: { message: getErrorMessage(err) } });
|
|
||||||
} finally {
|
|
||||||
activeChatStreams.delete(chatId);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeSearchRunStream(searchId: string, body: SearchRunRequest, stream: ActiveSseStream) {
|
|
||||||
const startedAt = performance.now();
|
|
||||||
const query = body.query?.trim();
|
|
||||||
if (!query) {
|
|
||||||
stream.complete({ event: "error", data: { message: "query is required" } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedTitle = body.title?.trim() || query.slice(0, 80);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const exa = exaClient();
|
|
||||||
const searchPromise = exa.search(query, {
|
|
||||||
type: body.type ?? "auto",
|
|
||||||
numResults: body.numResults ?? 10,
|
|
||||||
includeDomains: body.includeDomains,
|
|
||||||
excludeDomains: body.excludeDomains,
|
|
||||||
moderation: true,
|
|
||||||
userLocation: "US",
|
|
||||||
contents: false,
|
|
||||||
} as any);
|
|
||||||
const answerPromise = exa.answer(query, {
|
|
||||||
text: true,
|
|
||||||
model: "exa",
|
|
||||||
userLocation: "US",
|
|
||||||
});
|
|
||||||
|
|
||||||
let searchResponse: any | null = null;
|
|
||||||
let answerResponse: any | null = null;
|
|
||||||
let enrichedResults: any[] | null = null;
|
|
||||||
let searchError: string | null = null;
|
|
||||||
let answerError: string | null = null;
|
|
||||||
|
|
||||||
const searchSettled = searchPromise.then(
|
|
||||||
async (value) => {
|
|
||||||
searchResponse = value;
|
|
||||||
const previewResults = (value?.results ?? []).map((result: any, index: number) => mapSearchResultPreview(result, index));
|
|
||||||
stream.emit("search_results", {
|
|
||||||
requestId: value?.requestId ?? null,
|
|
||||||
results: previewResults,
|
|
||||||
});
|
|
||||||
|
|
||||||
const urls = (value?.results ?? []).map((result: any) => result?.url).filter((url: string | undefined) => typeof url === "string");
|
|
||||||
if (!urls.length) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const contentsResponse = await exa.getContents(urls, {
|
|
||||||
text: { maxCharacters: 1200 },
|
|
||||||
highlights: {
|
|
||||||
query,
|
|
||||||
maxCharacters: 320,
|
|
||||||
numSentences: 2,
|
|
||||||
highlightsPerUrl: 2,
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
const byUrl = new Map<string, any>();
|
|
||||||
for (const contentItem of contentsResponse?.results ?? []) {
|
|
||||||
byUrl.set(normalizeUrlForMatch(contentItem?.url), contentItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
enrichedResults = (value?.results ?? []).map((result: any) => {
|
|
||||||
const contentItem = byUrl.get(normalizeUrlForMatch(result?.url));
|
|
||||||
if (!contentItem) return result;
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
text: contentItem.text ?? result.text ?? null,
|
|
||||||
highlights: Array.isArray(contentItem.highlights) ? contentItem.highlights : result.highlights ?? null,
|
|
||||||
highlightScores: Array.isArray(contentItem.highlightScores) ? contentItem.highlightScores : result.highlightScores ?? null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.emit("search_results", {
|
|
||||||
requestId: value?.requestId ?? null,
|
|
||||||
results: enrichedResults.map((result: any, index: number) => mapSearchResultPreview(result, index)),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// keep preview results if content enrichment fails
|
|
||||||
}
|
|
||||||
},
|
|
||||||
(reason) => {
|
|
||||||
searchError = reason?.message ?? String(reason);
|
|
||||||
stream.emit("search_error", { error: searchError });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const answerSettled = answerPromise.then(
|
|
||||||
(value) => {
|
|
||||||
answerResponse = value;
|
|
||||||
stream.emit("answer", {
|
|
||||||
answerText: parseAnswerText(value),
|
|
||||||
answerRequestId: value?.requestId ?? null,
|
|
||||||
answerCitations: (value?.citations as any) ?? null,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
(reason) => {
|
|
||||||
answerError = reason?.message ?? String(reason);
|
|
||||||
stream.emit("answer_error", { error: answerError });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await Promise.all([searchSettled, answerSettled]);
|
|
||||||
|
|
||||||
const latencyMs = Math.round(performance.now() - startedAt);
|
|
||||||
const persistedResults = enrichedResults ?? searchResponse?.results ?? [];
|
|
||||||
const rows = persistedResults.map((result: any, index: number) => mapSearchResultRow(searchId, result, index));
|
|
||||||
const answerText = parseAnswerText(answerResponse);
|
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.search.update({
|
|
||||||
where: { id: searchId },
|
|
||||||
data: {
|
|
||||||
query,
|
|
||||||
title: normalizedTitle,
|
|
||||||
requestId: searchResponse?.requestId ?? null,
|
|
||||||
rawResponse: searchResponse as any,
|
|
||||||
latencyMs,
|
|
||||||
error: searchError,
|
|
||||||
answerText,
|
|
||||||
answerRequestId: answerResponse?.requestId ?? null,
|
|
||||||
answerCitations: (answerResponse?.citations as any) ?? null,
|
|
||||||
answerRawResponse: answerResponse as any,
|
|
||||||
answerError,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await tx.searchResult.deleteMany({ where: { searchId } });
|
|
||||||
if (rows.length) {
|
|
||||||
await tx.searchResult.createMany({ data: rows as any });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const search = await prisma.search.findUnique({
|
|
||||||
where: { id: searchId },
|
|
||||||
include: { results: { orderBy: { rank: "asc" } } },
|
|
||||||
});
|
|
||||||
if (!search) {
|
|
||||||
stream.complete({ event: "error", data: { message: "search not found" } });
|
|
||||||
} else {
|
|
||||||
stream.complete({ event: "done", data: { search } });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const message = getErrorMessage(err);
|
|
||||||
try {
|
|
||||||
await prisma.search.update({
|
|
||||||
where: { id: searchId },
|
|
||||||
data: {
|
|
||||||
query,
|
|
||||||
title: normalizedTitle,
|
|
||||||
latencyMs: Math.round(performance.now() - startedAt),
|
|
||||||
error: message,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// keep the stream terminal event even if the backing search row disappeared
|
|
||||||
}
|
|
||||||
stream.complete({ event: "error", data: { message } });
|
|
||||||
} finally {
|
|
||||||
activeSearchStreams.delete(searchId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function registerRoutes(app: FastifyInstance) {
|
export async function registerRoutes(app: FastifyInstance) {
|
||||||
app.get("/health", { logLevel: "silent" }, async () => ({ ok: true }));
|
app.get("/health", { logLevel: "silent" }, async () => ({ ok: true }));
|
||||||
|
|
||||||
@@ -567,14 +306,6 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
return { providers: getModelCatalogSnapshot() };
|
return { providers: getModelCatalogSnapshot() };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/v1/active-runs", async (req) => {
|
|
||||||
requireAdmin(req);
|
|
||||||
return {
|
|
||||||
chats: Array.from(activeChatStreams.keys()),
|
|
||||||
searches: Array.from(activeSearchStreams.keys()),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/v1/chats", async (req) => {
|
app.get("/v1/chats", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
const chats = await prisma.chat.findMany({
|
const chats = await prisma.chat.findMany({
|
||||||
@@ -964,24 +695,162 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
const query = body.query?.trim() || existing.query?.trim();
|
const query = body.query?.trim() || existing.query?.trim();
|
||||||
if (!query) return app.httpErrors.badRequest("query is required");
|
if (!query) return app.httpErrors.badRequest("query is required");
|
||||||
|
|
||||||
const existingStream = activeSearchStreams.get(searchId);
|
const startedAt = performance.now();
|
||||||
if (existingStream) {
|
const normalizedTitle = body.title?.trim() || query.slice(0, 80);
|
||||||
return streamActiveRun(req, reply, existingStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stream = new ActiveSseStream();
|
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||||
activeSearchStreams.set(searchId, stream);
|
|
||||||
void executeSearchRunStream(searchId, { ...body, query }, stream);
|
const send = (event: string, data: any) => {
|
||||||
return streamActiveRun(req, reply, stream);
|
if (reply.raw.writableEnded) return;
|
||||||
|
reply.raw.write(`event: ${event}\n`);
|
||||||
|
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exa = exaClient();
|
||||||
|
const searchPromise = exa.search(query, {
|
||||||
|
type: body.type ?? "auto",
|
||||||
|
numResults: body.numResults ?? 10,
|
||||||
|
includeDomains: body.includeDomains,
|
||||||
|
excludeDomains: body.excludeDomains,
|
||||||
|
moderation: true,
|
||||||
|
userLocation: "US",
|
||||||
|
contents: false,
|
||||||
|
} as any);
|
||||||
|
const answerPromise = exa.answer(query, {
|
||||||
|
text: true,
|
||||||
|
model: "exa",
|
||||||
|
userLocation: "US",
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/searches/:searchId/run/stream/attach", async (req, reply) => {
|
let searchResponse: any | null = null;
|
||||||
requireAdmin(req);
|
let answerResponse: any | null = null;
|
||||||
const Params = z.object({ searchId: z.string() });
|
let enrichedResults: any[] | null = null;
|
||||||
const { searchId } = Params.parse(req.params);
|
let searchError: string | null = null;
|
||||||
const stream = activeSearchStreams.get(searchId);
|
let answerError: string | null = null;
|
||||||
if (!stream) return app.httpErrors.notFound("active search stream not found");
|
|
||||||
return streamActiveRun(req, reply, stream);
|
const searchSettled = searchPromise.then(
|
||||||
|
async (value) => {
|
||||||
|
searchResponse = value;
|
||||||
|
const previewResults = (value?.results ?? []).map((result: any, index: number) => mapSearchResultPreview(result, index));
|
||||||
|
send("search_results", {
|
||||||
|
requestId: value?.requestId ?? null,
|
||||||
|
results: previewResults,
|
||||||
|
});
|
||||||
|
|
||||||
|
const urls = (value?.results ?? []).map((result: any) => result?.url).filter((url: string | undefined) => typeof url === "string");
|
||||||
|
if (!urls.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contentsResponse = await exa.getContents(urls, {
|
||||||
|
text: { maxCharacters: 1200 },
|
||||||
|
highlights: {
|
||||||
|
query,
|
||||||
|
maxCharacters: 320,
|
||||||
|
numSentences: 2,
|
||||||
|
highlightsPerUrl: 2,
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
const byUrl = new Map<string, any>();
|
||||||
|
for (const contentItem of contentsResponse?.results ?? []) {
|
||||||
|
byUrl.set(normalizeUrlForMatch(contentItem?.url), contentItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
enrichedResults = (value?.results ?? []).map((result: any) => {
|
||||||
|
const contentItem = byUrl.get(normalizeUrlForMatch(result?.url));
|
||||||
|
if (!contentItem) return result;
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
text: contentItem.text ?? result.text ?? null,
|
||||||
|
highlights: Array.isArray(contentItem.highlights) ? contentItem.highlights : result.highlights ?? null,
|
||||||
|
highlightScores: Array.isArray(contentItem.highlightScores) ? contentItem.highlightScores : result.highlightScores ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
send("search_results", {
|
||||||
|
requestId: value?.requestId ?? null,
|
||||||
|
results: enrichedResults.map((result: any, index: number) => mapSearchResultPreview(result, index)),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// keep preview results if content enrichment fails
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(reason) => {
|
||||||
|
searchError = reason?.message ?? String(reason);
|
||||||
|
send("search_error", { error: searchError });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const answerSettled = answerPromise.then(
|
||||||
|
(value) => {
|
||||||
|
answerResponse = value;
|
||||||
|
send("answer", {
|
||||||
|
answerText: parseAnswerText(value),
|
||||||
|
answerRequestId: value?.requestId ?? null,
|
||||||
|
answerCitations: (value?.citations as any) ?? null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(reason) => {
|
||||||
|
answerError = reason?.message ?? String(reason);
|
||||||
|
send("answer_error", { error: answerError });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([searchSettled, answerSettled]);
|
||||||
|
|
||||||
|
const latencyMs = Math.round(performance.now() - startedAt);
|
||||||
|
const persistedResults = enrichedResults ?? searchResponse?.results ?? [];
|
||||||
|
const rows = persistedResults.map((result: any, index: number) => mapSearchResultRow(searchId, result, index));
|
||||||
|
const answerText = parseAnswerText(answerResponse);
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.search.update({
|
||||||
|
where: { id: searchId },
|
||||||
|
data: {
|
||||||
|
query,
|
||||||
|
title: normalizedTitle,
|
||||||
|
requestId: searchResponse?.requestId ?? null,
|
||||||
|
rawResponse: searchResponse as any,
|
||||||
|
latencyMs,
|
||||||
|
error: searchError,
|
||||||
|
answerText,
|
||||||
|
answerRequestId: answerResponse?.requestId ?? null,
|
||||||
|
answerCitations: (answerResponse?.citations as any) ?? null,
|
||||||
|
answerRawResponse: answerResponse as any,
|
||||||
|
answerError,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.searchResult.deleteMany({ where: { searchId } });
|
||||||
|
if (rows.length) {
|
||||||
|
await tx.searchResult.createMany({ data: rows as any });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const search = await prisma.search.findUnique({
|
||||||
|
where: { id: searchId },
|
||||||
|
include: { results: { orderBy: { rank: "asc" } } },
|
||||||
|
});
|
||||||
|
if (!search) {
|
||||||
|
send("error", { message: "search not found" });
|
||||||
|
} else {
|
||||||
|
send("done", { search });
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
await prisma.search.update({
|
||||||
|
where: { id: searchId },
|
||||||
|
data: {
|
||||||
|
query,
|
||||||
|
title: normalizedTitle,
|
||||||
|
latencyMs: Math.round(performance.now() - startedAt),
|
||||||
|
error: err?.message ?? String(err),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
send("error", { message: err?.message ?? String(err) });
|
||||||
|
} finally {
|
||||||
|
reply.raw.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/v1/chats/:chatId", async (req) => {
|
app.get("/v1/chats/:chatId", async (req) => {
|
||||||
@@ -1026,15 +895,6 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
return { message: msg };
|
return { message: msg };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/chats/:chatId/stream/attach", async (req, reply) => {
|
|
||||||
requireAdmin(req);
|
|
||||||
const Params = z.object({ chatId: z.string() });
|
|
||||||
const { chatId } = Params.parse(req.params);
|
|
||||||
const stream = activeChatStreams.get(chatId);
|
|
||||||
if (!stream) return app.httpErrors.notFound("active chat stream not found");
|
|
||||||
return streamActiveRun(req, reply, stream);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Main: create a completion via provider+model and store everything.
|
// Main: create a completion via provider+model and store everything.
|
||||||
app.post("/v1/chat-completions", async (req) => {
|
app.post("/v1/chat-completions", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
@@ -1075,7 +935,27 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
app.post("/v1/chat-completions/stream", async (req, reply) => {
|
app.post("/v1/chat-completions/stream", async (req, reply) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
|
|
||||||
const parsed = CompletionStreamBody.safeParse(req.body);
|
const Body = z
|
||||||
|
.object({
|
||||||
|
chatId: z.string().optional(),
|
||||||
|
persist: z.boolean().optional(),
|
||||||
|
provider: z.enum(["openai", "anthropic", "xai"]),
|
||||||
|
model: z.string().min(1),
|
||||||
|
messages: z.array(CompletionMessageSchema),
|
||||||
|
temperature: z.number().min(0).max(2).optional(),
|
||||||
|
maxTokens: z.number().int().positive().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
if (value.persist === false && value.chatId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "chatId must be omitted when persist is false",
|
||||||
|
path: ["chatId"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = Body.safeParse(req.body);
|
||||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||||
const body = parsed.data;
|
const body = parsed.data;
|
||||||
|
|
||||||
@@ -1090,24 +970,23 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
await storeNonAssistantMessages(body.chatId, body.messages);
|
await storeNonAssistantMessages(body.chatId, body.messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.persist !== false && body.chatId) {
|
|
||||||
if (activeChatStreams.has(body.chatId)) {
|
|
||||||
return app.httpErrors.conflict("chat completion already running");
|
|
||||||
}
|
|
||||||
const stream = startActiveChatStream(body.chatId, body);
|
|
||||||
return streamActiveRun(req, reply, stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||||
reply.raw.flushHeaders();
|
reply.raw.flushHeaders();
|
||||||
|
|
||||||
|
const send = (event: string, data: any) => {
|
||||||
|
reply.raw.write(`event: ${event}\n`);
|
||||||
|
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||||
|
};
|
||||||
|
|
||||||
for await (const ev of runMultiplexStream(body)) {
|
for await (const ev of runMultiplexStream(body)) {
|
||||||
writeSseEvent(reply, mapChatStreamEvent(ev));
|
if (ev.type === "meta") send("meta", ev);
|
||||||
|
else if (ev.type === "tool_call") send("tool_call", ev.event);
|
||||||
|
else if (ev.type === "delta") send("delta", ev);
|
||||||
|
else if (ev.type === "done") send("done", ev);
|
||||||
|
else if (ev.type === "error") send("error", ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!reply.raw.destroyed && !reply.raw.writableEnded) {
|
|
||||||
reply.raw.end();
|
reply.raw.end();
|
||||||
}
|
|
||||||
return reply;
|
return reply;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { ActiveSseStream, type SseStreamEvent } from "../src/active-streams.js";
|
|
||||||
|
|
||||||
test("ActiveSseStream replays buffered events to late subscribers", () => {
|
|
||||||
const stream = new ActiveSseStream();
|
|
||||||
stream.emit("delta", { text: "hel" });
|
|
||||||
stream.emit("delta", { text: "lo" });
|
|
||||||
|
|
||||||
const events: SseStreamEvent[] = [];
|
|
||||||
const unsubscribe = stream.subscribe((event) => events.push(event));
|
|
||||||
unsubscribe();
|
|
||||||
|
|
||||||
assert.deepEqual(events, [
|
|
||||||
{ event: "delta", data: { text: "hel" } },
|
|
||||||
{ event: "delta", data: { text: "lo" } },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ActiveSseStream replays terminal events after completion", async () => {
|
|
||||||
const stream = new ActiveSseStream();
|
|
||||||
stream.emit("delta", { text: "done" });
|
|
||||||
stream.complete({ event: "done", data: { text: "done" } });
|
|
||||||
await stream.done;
|
|
||||||
|
|
||||||
const events: SseStreamEvent[] = [];
|
|
||||||
stream.subscribe((event) => events.push(event));
|
|
||||||
|
|
||||||
assert.equal(stream.isCompleted, true);
|
|
||||||
assert.deepEqual(events, [
|
|
||||||
{ event: "delta", data: { text: "done" } },
|
|
||||||
{ event: "done", data: { text: "done" } },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
656
web/src/App.tsx
656
web/src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -1,31 +0,0 @@
|
|||||||
import { useEffect } from "preact/hooks";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const CHARACTER_IDLE_SRC = "/character-idle.gif";
|
|
||||||
const CHARACTER_BUSY_SRC = "/character-busy.gif";
|
|
||||||
|
|
||||||
type SybilCharacterProps = {
|
|
||||||
className?: string;
|
|
||||||
isBusy?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SybilCharacter({ className, isBusy = false }: SybilCharacterProps) {
|
|
||||||
useEffect(() => {
|
|
||||||
const busyImage = new Image();
|
|
||||||
busyImage.src = CHARACTER_BUSY_SRC;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
aria-hidden="true"
|
|
||||||
alt=""
|
|
||||||
className={cn(
|
|
||||||
"aspect-square rounded-xl border border-violet-200/24 bg-white/6 object-cover p-1 shadow-[inset_0_1px_0_hsl(252_90%_86%/0.12),0_10px_24px_hsl(240_80%_2%/0.3)]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
data-state={isBusy ? "busy" : "idle"}
|
|
||||||
draggable={false}
|
|
||||||
src={isBusy ? CHARACTER_BUSY_SRC : CHARACTER_IDLE_SRC}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,14 +4,6 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "StalinistOne";
|
|
||||||
src: url("/StalinistOne-Regular.ttf") format("truetype");
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--background: 235 45% 4%;
|
--background: 235 45% 4%;
|
||||||
@@ -65,8 +57,8 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sybil-wordmark {
|
.sybil-wordmark {
|
||||||
font-family: "StalinistOne", "Orbitron", "Inter", sans-serif;
|
font-family: "Orbitron", "Inter", sans-serif;
|
||||||
font-weight: 400;
|
font-weight: 900;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
@@ -192,13 +184,7 @@ textarea {
|
|||||||
margin-top: 0.65rem;
|
margin-top: 0.65rem;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
list-style: none;
|
list-style-position: inside;
|
||||||
}
|
|
||||||
|
|
||||||
.md-content li > ul,
|
|
||||||
.md-content li > ol {
|
|
||||||
margin-top: 0.3rem;
|
|
||||||
padding-left: 1.35rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content li + li {
|
.md-content li + li {
|
||||||
|
|||||||
@@ -139,11 +139,6 @@ export type ModelCatalogResponse = {
|
|||||||
providers: Record<Provider, ProviderModelInfo>;
|
providers: Record<Provider, ProviderModelInfo>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ActiveRunsResponse = {
|
|
||||||
chats: string[];
|
|
||||||
searches: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type CompletionResponse = {
|
type CompletionResponse = {
|
||||||
chatId: string | null;
|
chatId: string | null;
|
||||||
message: {
|
message: {
|
||||||
@@ -222,10 +217,6 @@ export async function listModels() {
|
|||||||
return api<ModelCatalogResponse>("/v1/models");
|
return api<ModelCatalogResponse>("/v1/models");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getActiveRuns() {
|
|
||||||
return api<ActiveRunsResponse>("/v1/active-runs");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createChat(input?: string | CreateChatRequest) {
|
export async function createChat(input?: string | CreateChatRequest) {
|
||||||
const body = typeof input === "string" ? { title: input } : input ?? {};
|
const body = typeof input === "string" ? { title: input } : input ?? {};
|
||||||
const data = await api<{ chat: ChatSummary }>("/v1/chats", {
|
const data = await api<{ chat: ChatSummary }>("/v1/chats", {
|
||||||
@@ -342,85 +333,6 @@ type RunSearchStreamHandlers = {
|
|||||||
onError?: (payload: { message: string }) => void;
|
onError?: (payload: { message: string }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function readSseStream(response: Response, dispatch: (eventName: string, payload: any) => void) {
|
|
||||||
if (!response.ok) {
|
|
||||||
const fallback = `${response.status} ${response.statusText}`;
|
|
||||||
let message = fallback;
|
|
||||||
try {
|
|
||||||
const body = (await response.json()) as { message?: string };
|
|
||||||
if (body.message) message = body.message;
|
|
||||||
} catch {
|
|
||||||
// keep fallback message
|
|
||||||
}
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.body) {
|
|
||||||
throw new Error("No response stream");
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
let eventName = "message";
|
|
||||||
let dataLines: string[] = [];
|
|
||||||
|
|
||||||
const flushEvent = () => {
|
|
||||||
if (!dataLines.length) {
|
|
||||||
eventName = "message";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataText = dataLines.join("\n");
|
|
||||||
let payload: any = null;
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(dataText);
|
|
||||||
} catch {
|
|
||||||
payload = { message: dataText };
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(eventName, payload);
|
|
||||||
|
|
||||||
dataLines = [];
|
|
||||||
eventName = "message";
|
|
||||||
};
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { value, done } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
let newlineIndex = buffer.indexOf("\n");
|
|
||||||
|
|
||||||
while (newlineIndex >= 0) {
|
|
||||||
const rawLine = buffer.slice(0, newlineIndex);
|
|
||||||
buffer = buffer.slice(newlineIndex + 1);
|
|
||||||
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
||||||
|
|
||||||
if (!line) {
|
|
||||||
flushEvent();
|
|
||||||
} else if (line.startsWith("event:")) {
|
|
||||||
eventName = line.slice("event:".length).trim();
|
|
||||||
} else if (line.startsWith("data:")) {
|
|
||||||
dataLines.push(line.slice("data:".length).trimStart());
|
|
||||||
}
|
|
||||||
|
|
||||||
newlineIndex = buffer.indexOf("\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer += decoder.decode();
|
|
||||||
if (buffer.length) {
|
|
||||||
const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
|
|
||||||
if (line.startsWith("event:")) {
|
|
||||||
eventName = line.slice("event:".length).trim();
|
|
||||||
} else if (line.startsWith("data:")) {
|
|
||||||
dataLines.push(line.slice("data:".length).trimStart());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flushEvent();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runSearchStream(
|
export async function runSearchStream(
|
||||||
searchId: string,
|
searchId: string,
|
||||||
body: SearchRunRequest,
|
body: SearchRunRequest,
|
||||||
@@ -525,30 +437,6 @@ export async function runSearchStream(
|
|||||||
flushEvent();
|
flushEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function attachSearchStream(searchId: string, handlers: RunSearchStreamHandlers, options?: { signal?: AbortSignal }) {
|
|
||||||
const headers = new Headers({
|
|
||||||
Accept: "text/event-stream",
|
|
||||||
});
|
|
||||||
if (authToken) {
|
|
||||||
headers.set("Authorization", `Bearer ${authToken}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/v1/searches/${searchId}/run/stream/attach`, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
signal: options?.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
await readSseStream(response, (eventName, payload) => {
|
|
||||||
if (eventName === "search_results") handlers.onSearchResults?.(payload);
|
|
||||||
else if (eventName === "search_error") handlers.onSearchError?.(payload);
|
|
||||||
else if (eventName === "answer") handlers.onAnswer?.(payload);
|
|
||||||
else if (eventName === "answer_error") handlers.onAnswerError?.(payload);
|
|
||||||
else if (eventName === "done") handlers.onDone?.(payload);
|
|
||||||
else if (eventName === "error") handlers.onError?.(payload);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runCompletion(body: {
|
export async function runCompletion(body: {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
provider: Provider;
|
provider: Provider;
|
||||||
@@ -668,26 +556,3 @@ export async function runCompletionStream(
|
|||||||
}
|
}
|
||||||
flushEvent();
|
flushEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function attachCompletionStream(chatId: string, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal }) {
|
|
||||||
const headers = new Headers({
|
|
||||||
Accept: "text/event-stream",
|
|
||||||
});
|
|
||||||
if (authToken) {
|
|
||||||
headers.set("Authorization", `Bearer ${authToken}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/v1/chats/${chatId}/stream/attach`, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
signal: options?.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
await readSseStream(response, (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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/app.tsx","./src/main.tsx","./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-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/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
{"root":["./src/app.tsx","./src/main.tsx","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.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/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user