Compare commits
9 Commits
codex/ios-
...
d9f27213e7
| Author | SHA1 | Date | |
|---|---|---|---|
| d9f27213e7 | |||
| 3a6c40cb3c | |||
| 188c460826 | |||
| 90278020f5 | |||
| cafe4bb9ae | |||
| 57a6287b2b | |||
| dc9336acf9 | |||
| ba6fc9c660 | |||
| fa429dcbb3 |
3
Tiltfile
3
Tiltfile
@@ -1,5 +1,8 @@
|
||||
update_settings(max_parallel_updates=4)
|
||||
|
||||
load('ext://dotenv', 'dotenv')
|
||||
dotenv() # defaults to .env in the current directory
|
||||
|
||||
local_resource(
|
||||
"server",
|
||||
cmd="npm ci --no-audit --no-fund",
|
||||
|
||||
@@ -135,6 +135,16 @@ Behavior notes:
|
||||
### `GET /v1/searches/:searchId`
|
||||
- Response: `{ "search": SearchDetail }`
|
||||
|
||||
### `POST /v1/searches/:searchId/chat`
|
||||
- Body: `{ "title"?: string }`
|
||||
- Response: `{ "chat": ChatSummary }`
|
||||
- Not found: `404 { "message": "search not found" }`
|
||||
|
||||
Behavior notes:
|
||||
- Creates a new chat seeded with a hidden `system` message containing the search query, answer text, answer citations, and top search results.
|
||||
- Clients should include existing `system` messages when sending the chat history to `/v1/chat-completions` or `/v1/chat-completions/stream`; they may hide those messages in the transcript UI.
|
||||
- The default chat title is `Search: <query-or-title>`, unless `title` is supplied.
|
||||
|
||||
### `POST /v1/searches/:searchId/run`
|
||||
- Body:
|
||||
```json
|
||||
|
||||
@@ -4,8 +4,9 @@ public struct SplitView: View {
|
||||
@State private var viewModel = SybilViewModel()
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
public init() {
|
||||
@MainActor public init() {
|
||||
SybilFontRegistry.registerIfNeeded()
|
||||
SybilTheme.applySystemAppearance()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
@@ -25,7 +26,6 @@ public struct SplitView: View {
|
||||
} else {
|
||||
NavigationSplitView {
|
||||
SybilSidebarView(viewModel: viewModel)
|
||||
.navigationTitle("Sybil")
|
||||
} detail: {
|
||||
SybilWorkspaceView(viewModel: viewModel)
|
||||
}
|
||||
@@ -34,6 +34,7 @@ public struct SplitView: View {
|
||||
}
|
||||
}
|
||||
.font(.sybil(.body))
|
||||
.preferredColorScheme(.dark)
|
||||
.task {
|
||||
await viewModel.bootstrap()
|
||||
}
|
||||
|
||||
@@ -96,6 +96,16 @@ actor SybilAPIClient {
|
||||
return response.search
|
||||
}
|
||||
|
||||
func createChatFromSearch(searchID: String, title: String? = nil) async throws -> ChatSummary {
|
||||
let response = try await request(
|
||||
"/v1/searches/\(searchID)/chat",
|
||||
method: "POST",
|
||||
body: AnyEncodable(SearchChatCreateBody(title: title)),
|
||||
responseType: ChatCreateResponse.self
|
||||
)
|
||||
return response.chat
|
||||
}
|
||||
|
||||
func deleteSearch(searchID: String) async throws {
|
||||
_ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self)
|
||||
}
|
||||
@@ -552,3 +562,7 @@ private struct SearchCreateBody: Encodable {
|
||||
var title: String?
|
||||
var query: String?
|
||||
}
|
||||
|
||||
private struct SearchChatCreateBody: Encodable {
|
||||
var title: String?
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ struct SybilChatTranscriptView: View {
|
||||
|
||||
ForEach(messages) { message in
|
||||
MessageBubble(message: message, isSending: isSending)
|
||||
.frame(maxWidth: .infinity)
|
||||
.id(message.id)
|
||||
}
|
||||
|
||||
@@ -86,10 +87,8 @@ private struct MessageBubble: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
if isUser {
|
||||
Spacer(minLength: 44)
|
||||
}
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
leadingSpacer
|
||||
|
||||
if let toolCallMetadata {
|
||||
ToolCallActivityChip(
|
||||
@@ -136,12 +135,24 @@ private struct MessageBubble: View {
|
||||
.frame(maxWidth: isUser ? 420 : nil, alignment: isUser ? .trailing : .leading)
|
||||
}
|
||||
|
||||
if !isUser {
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
trailingSpacer
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var leadingSpacer: some View {
|
||||
if isUser {
|
||||
Spacer(minLength: 44)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var trailingSpacer: some View {
|
||||
if !isUser {
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolCallActivityChip: View {
|
||||
|
||||
@@ -10,6 +10,7 @@ extension Theme {
|
||||
.text {
|
||||
FontFamily(.custom("Inter"))
|
||||
FontSize(15)
|
||||
ForegroundColor(SybilTheme.text)
|
||||
}
|
||||
.code {
|
||||
FontFamilyVariant(.monospaced)
|
||||
|
||||
@@ -30,8 +30,8 @@ struct SybilPhoneShellView: View {
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
SybilWordmark(size: 19)
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
SybilWordmark(size: 18)
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: PhoneRoute.self) { route in
|
||||
|
||||
@@ -5,23 +5,60 @@ struct SybilSearchResultsView: View {
|
||||
var search: SearchDetail?
|
||||
var isLoading: Bool
|
||||
var isRunning: Bool
|
||||
var isStartingChat: Bool = false
|
||||
var onStartChat: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if let query = search?.query, !query.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Results for")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
Text(query)
|
||||
.font(.sybil(.title3, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Results for")
|
||||
.font(.sybil(.footnote))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
Text(query)
|
||||
.font(.sybil(.title3, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text(resultCountLabel)
|
||||
.font(.sybil(.caption))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
Text(resultCountLabel)
|
||||
.font(.sybil(.caption))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
|
||||
if let onStartChat {
|
||||
Button {
|
||||
onStartChat()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if isStartingChat {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(SybilTheme.text)
|
||||
} else {
|
||||
Image(systemName: "bubble.left.and.text.bubble.right")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
Text(isStartingChat ? "Starting chat..." : "Chat with results")
|
||||
.font(.sybil(.caption, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(SybilTheme.primary.opacity(0.14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(SybilTheme.primary.opacity(0.30), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canStartChat)
|
||||
.opacity(canStartChat ? 1 : 0.55)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +113,13 @@ struct SybilSearchResultsView: View {
|
||||
return "\(count) result\(count == 1 ? "" : "s")"
|
||||
}
|
||||
|
||||
private var canStartChat: Bool {
|
||||
guard let search, !isLoading, !isRunning, !isStartingChat else {
|
||||
return false
|
||||
}
|
||||
return search.answerText?.isEmpty == false || !search.results.isEmpty
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var answerCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
|
||||
@@ -18,8 +18,6 @@ struct SybilSidebarView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
SybilWordmark(size: 31)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
sidebarActionButton(
|
||||
title: "New chat",
|
||||
@@ -174,6 +172,13 @@ struct SybilSidebarView: View {
|
||||
.padding(10)
|
||||
}
|
||||
.background(SybilTheme.panelGradient)
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
SybilWordmark(size: 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sidebarActionButton(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import CoreText
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
enum SybilFontRegistry {
|
||||
static func registerIfNeeded() {
|
||||
@@ -78,6 +79,23 @@ enum SybilTheme {
|
||||
static let userBubble = Color(red: 0.29, green: 0.13, blue: 0.65)
|
||||
static let danger = Color(red: 0.96, green: 0.32, blue: 0.40)
|
||||
|
||||
@MainActor static func applySystemAppearance() {
|
||||
let navAppearance = UINavigationBarAppearance()
|
||||
navAppearance.configureWithOpaqueBackground()
|
||||
navAppearance.backgroundColor = UIColor(red: 0.02, green: 0.02, blue: 0.05, alpha: 1)
|
||||
navAppearance.shadowColor = UIColor(red: 0.24, green: 0.20, blue: 0.38, alpha: 0.9)
|
||||
navAppearance.titleTextAttributes = [
|
||||
.foregroundColor: UIColor(red: 0.96, green: 0.94, blue: 1.0, alpha: 1)
|
||||
]
|
||||
navAppearance.largeTitleTextAttributes = navAppearance.titleTextAttributes
|
||||
|
||||
UINavigationBar.appearance().prefersLargeTitles = false
|
||||
UINavigationBar.appearance().standardAppearance = navAppearance
|
||||
UINavigationBar.appearance().compactAppearance = navAppearance
|
||||
UINavigationBar.appearance().scrollEdgeAppearance = navAppearance
|
||||
UINavigationBar.appearance().compactScrollEdgeAppearance = navAppearance
|
||||
}
|
||||
|
||||
static var backgroundGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
|
||||
@@ -87,6 +87,7 @@ final class SybilViewModel {
|
||||
var isLoadingCollections = false
|
||||
var isLoadingSelection = false
|
||||
var isSending = false
|
||||
var isCreatingSearchChat = false
|
||||
var errorMessage: String?
|
||||
|
||||
var composer = ""
|
||||
@@ -202,20 +203,20 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
var displayedMessages: [Message] {
|
||||
let canonical = selectedChat?.messages ?? []
|
||||
let canonical = displayableMessages(selectedChat?.messages ?? [])
|
||||
guard let pending = pendingChatState else {
|
||||
return canonical
|
||||
}
|
||||
|
||||
if let pendingID = pending.chatID {
|
||||
if case let .chat(selectedID) = selectedItem, selectedID == pendingID {
|
||||
return pending.messages
|
||||
return displayableMessages(pending.messages)
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
|
||||
if draftKind == .chat {
|
||||
return pending.messages
|
||||
return displayableMessages(pending.messages)
|
||||
}
|
||||
|
||||
return canonical
|
||||
@@ -473,6 +474,36 @@ final class SybilViewModel {
|
||||
isSending = false
|
||||
}
|
||||
|
||||
func startChatFromSelectedSearch() async {
|
||||
guard let search = selectedSearch, !isCreatingSearchChat, !isSending else {
|
||||
return
|
||||
}
|
||||
|
||||
isCreatingSearchChat = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let client = try client()
|
||||
let chat = try await client.createChatFromSearch(searchID: search.id)
|
||||
draftKind = nil
|
||||
pendingChatState = nil
|
||||
composer = ""
|
||||
|
||||
chats.removeAll(where: { $0.id == chat.id })
|
||||
chats.insert(chat, at: 0)
|
||||
|
||||
selectedItem = .chat(chat.id)
|
||||
selectedSearch = nil
|
||||
|
||||
await refreshCollections(preferredSelection: .chat(chat.id))
|
||||
} catch {
|
||||
errorMessage = normalizeAPIError(error)
|
||||
SybilLog.error(SybilLog.ui, "Create chat from search failed", error: error)
|
||||
}
|
||||
|
||||
isCreatingSearchChat = false
|
||||
}
|
||||
|
||||
private func loadInitialData(using client: SybilAPIClient) async {
|
||||
isLoadingCollections = true
|
||||
errorMessage = nil
|
||||
@@ -974,6 +1005,10 @@ final class SybilViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func displayableMessages(_ messages: [Message]) -> [Message] {
|
||||
messages.filter { $0.role != .system }
|
||||
}
|
||||
|
||||
private func chatTitle(title: String?, messages: [Message]?) -> String {
|
||||
if let title = title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||
return title
|
||||
|
||||
@@ -3,13 +3,8 @@ import SwiftUI
|
||||
|
||||
struct SybilWorkspaceView: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@FocusState private var composerFocused: Bool
|
||||
|
||||
private var isCompact: Bool {
|
||||
horizontalSizeClass == .compact
|
||||
}
|
||||
|
||||
private var isSettingsSelected: Bool {
|
||||
if case .settings = viewModel.selectedItem {
|
||||
return true
|
||||
@@ -18,7 +13,7 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
|
||||
private var showsHeader: Bool {
|
||||
!isCompact || viewModel.errorMessage != nil
|
||||
viewModel.errorMessage != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -37,8 +32,13 @@ struct SybilWorkspaceView: View {
|
||||
SybilSearchResultsView(
|
||||
search: viewModel.selectedSearch,
|
||||
isLoading: viewModel.isLoadingSelection,
|
||||
isRunning: viewModel.isSending
|
||||
)
|
||||
isRunning: viewModel.isSending,
|
||||
isStartingChat: viewModel.isCreatingSearchChat
|
||||
) {
|
||||
Task {
|
||||
await viewModel.startChatFromSelectedSearch()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SybilChatTranscriptView(
|
||||
messages: viewModel.displayedMessages,
|
||||
@@ -55,20 +55,17 @@ struct SybilWorkspaceView: View {
|
||||
composerBar
|
||||
}
|
||||
}
|
||||
.navigationTitle(isCompact ? "" : viewModel.selectedTitle)
|
||||
.navigationTitle(viewModel.selectedTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarRole(.editor)
|
||||
.toolbar {
|
||||
if isCompact {
|
||||
ToolbarItem(placement: .principal) {
|
||||
Text(viewModel.selectedTitle)
|
||||
.font(.sybil(.headline, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if isCompact && !viewModel.isSearchMode && !isSettingsSelected {
|
||||
if !isSettingsSelected {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
compactProviderModelMenu
|
||||
if viewModel.isSearchMode {
|
||||
searchModeChip
|
||||
} else {
|
||||
providerModelMenu
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,30 +75,6 @@ struct SybilWorkspaceView: View {
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if !isCompact {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Spacer()
|
||||
|
||||
if !viewModel.isSearchMode && !isSettingsSelected {
|
||||
providerControls
|
||||
} else if viewModel.isSearchMode {
|
||||
Label("Search mode", systemImage: "globe")
|
||||
.font(.sybil(.caption, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.accent)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(SybilTheme.accent.opacity(0.10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(SybilTheme.accent.opacity(0.24), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let error = viewModel.errorMessage {
|
||||
Text(error)
|
||||
.font(.sybil(.footnote))
|
||||
@@ -114,7 +87,7 @@ struct SybilWorkspaceView: View {
|
||||
.background(SybilTheme.panelGradient.opacity(0.58))
|
||||
}
|
||||
|
||||
private var compactProviderModelMenu: some View {
|
||||
private var providerModelMenu: some View {
|
||||
Menu {
|
||||
Text("\(viewModel.provider.displayName) • \(viewModel.model)")
|
||||
.font(.sybil(.caption))
|
||||
@@ -158,55 +131,20 @@ struct SybilWorkspaceView: View {
|
||||
.accessibilityLabel("Provider and model")
|
||||
}
|
||||
|
||||
private var providerControls: some View {
|
||||
HStack(spacing: 8) {
|
||||
Menu {
|
||||
ForEach(Provider.allCases, id: \.self) { candidate in
|
||||
Button(candidate.displayName) {
|
||||
viewModel.setProvider(candidate)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(viewModel.provider.displayName, systemImage: "chevron.down")
|
||||
.labelStyle(.titleAndIcon)
|
||||
.font(.sybil(.caption, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(SybilTheme.surface.opacity(0.78))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(SybilTheme.border.opacity(0.88), lineWidth: 1)
|
||||
)
|
||||
private var searchModeChip: some View {
|
||||
Label("Search", systemImage: "globe")
|
||||
.font(.sybil(.caption, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.accent)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(SybilTheme.accent.opacity(0.10))
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(SybilTheme.accent.opacity(0.24), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
Menu {
|
||||
ForEach(viewModel.providerModelOptions, id: \.self) { model in
|
||||
Button(model) {
|
||||
viewModel.setModel(model)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(viewModel.model, systemImage: "chevron.down")
|
||||
.labelStyle(.titleAndIcon)
|
||||
.font(.sybil(.caption, weight: .medium))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(SybilTheme.surface.opacity(0.78))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(SybilTheme.border.opacity(0.88), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var composerBar: some View {
|
||||
|
||||
@@ -108,6 +108,13 @@ function mapSearchResultPreview(result: any, index: number) {
|
||||
};
|
||||
}
|
||||
|
||||
function truncateContextPart(value: string | null | undefined, maxLength: number) {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.length <= maxLength) return trimmed;
|
||||
return `${trimmed.slice(0, maxLength - 1).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function parseAnswerText(answerResponse: any) {
|
||||
if (typeof answerResponse?.answer === "string") return answerResponse.answer;
|
||||
if (answerResponse?.answer) return JSON.stringify(answerResponse.answer, null, 2);
|
||||
@@ -153,6 +160,57 @@ function normalizeUrlForMatch(input: string | null | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildSearchChatContext(search: any) {
|
||||
const query = truncateContextPart(search.query, 500) ?? truncateContextPart(search.title, 500) ?? "Untitled search";
|
||||
const lines: string[] = [
|
||||
"You are Sybil. The user started this chat from a saved web search. Use the search answer and result context below when answering follow-up questions. If the context is insufficient, say so and use available tools when appropriate.",
|
||||
"",
|
||||
`Search query: ${query}`,
|
||||
];
|
||||
|
||||
const answer = truncateContextPart(search.answerText, 6000);
|
||||
if (answer) {
|
||||
lines.push("", "Search answer:", answer);
|
||||
}
|
||||
|
||||
if (Array.isArray(search.answerCitations) && search.answerCitations.length) {
|
||||
lines.push("", "Answer citations:");
|
||||
for (const [index, citation] of search.answerCitations.slice(0, 8).entries()) {
|
||||
const title = truncateContextPart(citation?.title, 160);
|
||||
const url = truncateContextPart(citation?.url ?? citation?.id, 400);
|
||||
if (title || url) {
|
||||
lines.push(`${index + 1}. ${[title, url].filter(Boolean).join(" - ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(search.results) && search.results.length) {
|
||||
lines.push("", "Search results:");
|
||||
for (const result of search.results.slice(0, 10)) {
|
||||
const title = truncateContextPart(result.title, 180) ?? result.url;
|
||||
const url = truncateContextPart(result.url, 500);
|
||||
const published = truncateContextPart(result.publishedDate, 80);
|
||||
const author = truncateContextPart(result.author, 120);
|
||||
const text = truncateContextPart(result.text, 1000);
|
||||
const highlights = Array.isArray(result.highlights)
|
||||
? result.highlights
|
||||
.map((highlight: unknown) => truncateContextPart(typeof highlight === "string" ? highlight : null, 360))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
lines.push(`${result.rank + 1}. ${title}`);
|
||||
if (url) lines.push(` URL: ${url}`);
|
||||
if (published || author) lines.push(` Source detail: ${[published, author].filter(Boolean).join(" - ")}`);
|
||||
if (text) lines.push(` Text: ${text}`);
|
||||
for (const highlight of highlights.slice(0, 2)) {
|
||||
lines.push(` Highlight: ${highlight}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildSseHeaders(originHeader: string | undefined) {
|
||||
const origin = originHeader && originHeader !== "null" ? originHeader : "*";
|
||||
const headers: Record<string, string> = {
|
||||
@@ -370,6 +428,54 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
return { search };
|
||||
});
|
||||
|
||||
app.post("/v1/searches/:searchId/chat", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ searchId: z.string() });
|
||||
const Body = z.object({ title: z.string().optional() });
|
||||
const { searchId } = Params.parse(req.params);
|
||||
const body = Body.parse(req.body ?? {});
|
||||
|
||||
const search = await prisma.search.findUnique({
|
||||
where: { id: searchId },
|
||||
include: { results: { orderBy: { rank: "asc" } } },
|
||||
});
|
||||
if (!search) return app.httpErrors.notFound("search not found");
|
||||
|
||||
const fallbackTitle = search.query?.trim() || search.title?.trim() || "Search results";
|
||||
const title = body.title?.trim() || `Search: ${fallbackTitle.slice(0, 72)}`;
|
||||
const context = buildSearchChatContext(search);
|
||||
|
||||
const chat = await prisma.chat.create({
|
||||
data: {
|
||||
title,
|
||||
messages: {
|
||||
create: {
|
||||
role: "system" as any,
|
||||
content: context,
|
||||
metadata: {
|
||||
kind: "search_context",
|
||||
searchId: search.id,
|
||||
query: search.query,
|
||||
resultCount: search.results.length,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { chat };
|
||||
});
|
||||
|
||||
app.post("/v1/searches/:searchId/run", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ searchId: z.string() });
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
|
||||
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
||||
import {
|
||||
createChat,
|
||||
createChatFromSearch,
|
||||
createSearch,
|
||||
deleteChat,
|
||||
deleteSearch,
|
||||
@@ -164,6 +165,10 @@ function isToolCallLogMessage(message: Message) {
|
||||
return asToolLogMetadata(message.metadata) !== null;
|
||||
}
|
||||
|
||||
function isDisplayableMessage(message: Message) {
|
||||
return message.role !== "system";
|
||||
}
|
||||
|
||||
function buildOptimisticToolMessage(event: ToolCallEvent): Message {
|
||||
return {
|
||||
id: `temp-tool-${event.toolCallId}`,
|
||||
@@ -427,6 +432,7 @@ export default function App() {
|
||||
const [isLoadingCollections, setIsLoadingCollections] = useState(false);
|
||||
const [isLoadingSelection, setIsLoadingSelection] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [isStartingSearchChat, setIsStartingSearchChat] = useState(false);
|
||||
const [pendingChatState, setPendingChatState] = useState<{ chatId: string | null; messages: Message[] } | null>(null);
|
||||
const [composer, setComposer] = useState("");
|
||||
const [provider, setProvider] = useState<Provider>("openai");
|
||||
@@ -699,14 +705,14 @@ export default function App() {
|
||||
selectedItem?.kind === "chat" &&
|
||||
selectedItem.id === pendingChatState.chatId;
|
||||
const displayMessages = useMemo(() => {
|
||||
if (!pendingChatState) return messages;
|
||||
if (!pendingChatState) return messages.filter(isDisplayableMessage);
|
||||
if (pendingChatState.chatId) {
|
||||
if (selectedItem?.kind === "chat" && selectedItem.id === pendingChatState.chatId) {
|
||||
return pendingChatState.messages;
|
||||
return pendingChatState.messages.filter(isDisplayableMessage);
|
||||
}
|
||||
return messages;
|
||||
return messages.filter(isDisplayableMessage);
|
||||
}
|
||||
return isSearchMode ? messages : pendingChatState.messages;
|
||||
return (isSearchMode ? messages : pendingChatState.messages).filter(isDisplayableMessage);
|
||||
}, [isSearchMode, messages, pendingChatState, selectedItem]);
|
||||
|
||||
const selectedChatSummary = useMemo(() => {
|
||||
@@ -1149,6 +1155,47 @@ export default function App() {
|
||||
await refreshCollections({ kind: "search", id: searchId });
|
||||
};
|
||||
|
||||
const handleStartChatFromSearch = async () => {
|
||||
if (!selectedSearch || isStartingSearchChat || isSending) return;
|
||||
|
||||
setError(null);
|
||||
setIsStartingSearchChat(true);
|
||||
try {
|
||||
const chat = await createChatFromSearch(selectedSearch.id);
|
||||
setDraftKind(null);
|
||||
setPendingChatState(null);
|
||||
setComposer("");
|
||||
setChats((current) => {
|
||||
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
||||
return [chat, ...withoutExisting];
|
||||
});
|
||||
setSelectedItem({ kind: "chat", id: chat.id });
|
||||
setSelectedChat({
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
lastUsedModel: chat.lastUsedModel,
|
||||
messages: [],
|
||||
});
|
||||
setSelectedSearch(null);
|
||||
await refreshCollections({ kind: "chat", id: chat.id });
|
||||
await refreshChat(chat.id);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("bearer token")) {
|
||||
handleAuthFailure(message);
|
||||
} else {
|
||||
setError(message);
|
||||
}
|
||||
} finally {
|
||||
setIsStartingSearchChat(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
const content = composer.trim();
|
||||
if (!content || isSending) return;
|
||||
@@ -1388,7 +1435,13 @@ export default function App() {
|
||||
{!isSearchMode ? (
|
||||
<ChatMessagesPanel messages={displayMessages} isLoading={isLoadingSelection} isSending={isSendingActiveChat} />
|
||||
) : (
|
||||
<SearchResultsPanel search={selectedSearch} isLoading={isLoadingSelection} isRunning={isSearchRunning} />
|
||||
<SearchResultsPanel
|
||||
search={selectedSearch}
|
||||
isLoading={isLoadingSelection}
|
||||
isRunning={isSearchRunning}
|
||||
isStartingChat={isStartingSearchChat}
|
||||
onStartChat={selectedSearch ? handleStartChatFromSearch : undefined}
|
||||
/>
|
||||
)}
|
||||
<div ref={transcriptEndRef} />
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { SearchDetail } from "@/lib/api";
|
||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MessageSquare } from "lucide-preact";
|
||||
|
||||
function formatHost(url: string) {
|
||||
try {
|
||||
@@ -29,6 +30,8 @@ type Props = {
|
||||
className?: string;
|
||||
enableKeyboardNavigation?: boolean;
|
||||
openLinksInNewTab?: boolean;
|
||||
isStartingChat?: boolean;
|
||||
onStartChat?: () => void;
|
||||
};
|
||||
|
||||
export function SearchResultsPanel({
|
||||
@@ -38,6 +41,8 @@ export function SearchResultsPanel({
|
||||
className,
|
||||
enableKeyboardNavigation = false,
|
||||
openLinksInNewTab = true,
|
||||
isStartingChat = false,
|
||||
onStartChat,
|
||||
}: Props) {
|
||||
const ANSWER_COLLAPSED_HEIGHT_CLASS = "h-[3rem]";
|
||||
const [isAnswerExpanded, setIsAnswerExpanded] = useState(false);
|
||||
@@ -133,17 +138,31 @@ export function SearchResultsPanel({
|
||||
const isAnswerLoading = isRunning && !hasAnswerText;
|
||||
const hasCitations = citationEntries.length > 0;
|
||||
const isExpandable = hasAnswerText && (canExpandAnswer || hasCitations);
|
||||
const canStartChat = !!search && !isLoading && !isRunning && !isStartingChat && (!!search.answerText || search.results.length > 0);
|
||||
|
||||
return (
|
||||
<div className={className ?? "mx-auto w-full max-w-4xl"}>
|
||||
{search?.query ? (
|
||||
<div className="mb-5">
|
||||
<p className="text-sm text-muted-foreground">Results for</p>
|
||||
<h2 className="mt-1 break-words text-xl font-semibold text-violet-50">{search.query}</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{search.results.length} result{search.results.length === 1 ? "" : "s"}
|
||||
{search.latencyMs ? ` • ${search.latencyMs} ms` : ""}
|
||||
</p>
|
||||
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-muted-foreground">Results for</p>
|
||||
<h2 className="mt-1 break-words text-xl font-semibold text-violet-50">{search.query}</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{search.results.length} result{search.results.length === 1 ? "" : "s"}
|
||||
{search.latencyMs ? ` • ${search.latencyMs} ms` : ""}
|
||||
</p>
|
||||
</div>
|
||||
{onStartChat ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-10 shrink-0 items-center justify-center gap-2 rounded-lg border border-violet-300/24 bg-violet-300/10 px-3 text-sm font-medium text-violet-50 transition hover:bg-violet-300/16 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={onStartChat}
|
||||
disabled={!canStartChat}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
{isStartingChat ? "Starting chat..." : "Chat with results"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -239,6 +239,14 @@ export async function getSearch(searchId: string) {
|
||||
return data.search;
|
||||
}
|
||||
|
||||
export async function createChatFromSearch(searchId: string, body?: { title?: string }) {
|
||||
const data = await api<{ chat: ChatSummary }>(`/v1/searches/${searchId}/chat`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
return data.chat;
|
||||
}
|
||||
|
||||
export async function deleteSearch(searchId: string) {
|
||||
await api<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user