diff --git a/docs/api/rest.md b/docs/api/rest.md index 06ae183..b1922a6 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -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: `, unless `title` is supplied. + ### `POST /v1/searches/:searchId/run` - Body: ```json diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift index def3e1c..531cbab 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAPIClient.swift @@ -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? +} diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilSearchResultsView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilSearchResultsView.swift index 321d9ec..04ca645 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilSearchResultsView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilSearchResultsView.swift @@ -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) { diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index e0c5fda..b46dd8d 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -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 diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift index e792065..b3ab492 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilWorkspaceView.swift @@ -37,8 +37,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, diff --git a/server/src/routes.ts b/server/src/routes.ts index 6ea9357..ac41e86 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -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 = { @@ -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() }); diff --git a/web/src/App.tsx b/web/src/App.tsx index 07a0ef5..8942137 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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("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 ? ( ) : ( - + )}
diff --git a/web/src/components/search/search-results-panel.tsx b/web/src/components/search/search-results-panel.tsx index 8490c3a..ed9afeb 100644 --- a/web/src/components/search/search-results-panel.tsx +++ b/web/src/components/search/search-results-panel.tsx @@ -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 (
{search?.query ? ( -
-

Results for

-

{search.query}

-

- {search.results.length} result{search.results.length === 1 ? "" : "s"} - {search.latencyMs ? ` • ${search.latencyMs} ms` : ""} -

+
+
+

Results for

+

{search.query}

+

+ {search.results.length} result{search.results.length === 1 ? "" : "s"} + {search.latencyMs ? ` • ${search.latencyMs} ms` : ""} +

+
+ {onStartChat ? ( + + ) : null}
) : null} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index c07cea9..4a9b090 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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" }); }