This commit is contained in:
@@ -1,21 +1,22 @@
|
|||||||
# Streaming Chat API Contract
|
# Streaming Chat API Contract
|
||||||
|
|
||||||
This document defines the server-sent events (SSE) contract for chat completions.
|
This document defines the server-sent events (SSE) contract for chat completions and Quick Questions.
|
||||||
|
|
||||||
Endpoint:
|
Endpoint:
|
||||||
- `POST /v1/chat-completions/stream`
|
- `POST /v1/chat-completions/stream`
|
||||||
- `POST /v1/chats/:chatId/stream/attach`
|
- `POST /v1/chats/:chatId/stream/attach`
|
||||||
|
- `POST /v1/quick-questions/stream`
|
||||||
|
|
||||||
Transport:
|
Transport:
|
||||||
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
|
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
|
||||||
- Events are emitted in SSE format (`event: ...`, `data: ...`)
|
- Events are emitted in SSE format (`event: ...`, `data: ...`)
|
||||||
- Request body is JSON
|
- Request body is JSON
|
||||||
- Request body supports the same inline attachment schema and limits documented in `docs/api/rest.md`.
|
- Chat completion request bodies support the same inline attachment schema and limits documented in `docs/api/rest.md`. Quick Questions accept text only.
|
||||||
|
|
||||||
Authentication:
|
Authentication:
|
||||||
- Same as REST endpoints (`Authorization: Bearer <token>` when token mode is enabled)
|
- Same as REST endpoints (`Authorization: Bearer <token>` when token mode is enabled)
|
||||||
|
|
||||||
## Request Body
|
## Chat Completion Request Body
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -90,6 +91,31 @@ Persisted chat streams with a `chatId` are backend-owned active runs:
|
|||||||
|
|
||||||
This endpoint is intended for clients that restored an active `chatId` from `GET /v1/active-runs`, especially after browser refresh. Replayed `delta` events may include text that was originally emitted before the client attached.
|
This endpoint is intended for clients that restored an active `chatId` from `GET /v1/active-runs`, especially after browser refresh. Replayed `delta` events may include text that was originally emitted before the client attached.
|
||||||
|
|
||||||
|
## Quick Question Endpoint
|
||||||
|
|
||||||
|
`POST /v1/quick-questions/stream`
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"provider": "openai|anthropic|xai|gemini|hermes-agent",
|
||||||
|
"model": "string",
|
||||||
|
"question": "What is the capital of France?",
|
||||||
|
"enabledTools": ["web_search", "fetch_url"],
|
||||||
|
"userLocation": "optional city, region, country",
|
||||||
|
"temperature": 0.2,
|
||||||
|
"maxTokens": 256
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior notes:
|
||||||
|
- `question` is required, trimmed by the server, and must not be empty.
|
||||||
|
- The server prepends a Quick Question system prompt that asks for a succinct, direct, self-contained answer without follow-up questions. Clients do not send or maintain this prompt.
|
||||||
|
- Quick Questions are always non-persistent. The endpoint does not create a chat or store messages, tool-call logs, assistant output, or `LlmCall` metadata.
|
||||||
|
- The response uses the same `meta`, `tool_call`, `delta`, `done`, and `error` SSE events as chat completion streams. The `meta` event has `chatId: null` and `callId: null`.
|
||||||
|
- `enabledTools`, `temperature`, and `maxTokens` are optional and behave as they do for chat completion streams. When `enabledTools` is omitted, all available Sybil-managed tools are enabled by default.
|
||||||
|
- User location is inferred from the same request headers as chat completion streams when `userLocation` is omitted.
|
||||||
|
|
||||||
## Event Stream Contract
|
## Event Stream Contract
|
||||||
|
|
||||||
Event order:
|
Event order:
|
||||||
|
|||||||
@@ -183,6 +183,29 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
SybilLog.info(SybilLog.network, "Chat stream completed")
|
SybilLog.info(SybilLog.network, "Chat stream completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runQuickQuestionStream(
|
||||||
|
body: QuickQuestionStreamRequest,
|
||||||
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
let request = try makeRequest(
|
||||||
|
path: "/v1/quick-questions/stream",
|
||||||
|
method: "POST",
|
||||||
|
body: AnyEncodable(body),
|
||||||
|
acceptsSSE: true
|
||||||
|
)
|
||||||
|
|
||||||
|
SybilLog.info(
|
||||||
|
SybilLog.network,
|
||||||
|
"Starting quick question stream POST \(request.url?.absoluteString ?? "<unknown>")"
|
||||||
|
)
|
||||||
|
|
||||||
|
try await stream(request: request) { eventName, dataText in
|
||||||
|
try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
SybilLog.info(SybilLog.network, "Quick question stream completed")
|
||||||
|
}
|
||||||
|
|
||||||
func attachCompletionStream(
|
func attachCompletionStream(
|
||||||
chatID: String,
|
chatID: String,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
@@ -664,6 +687,12 @@ struct CompletionStreamRequest: Codable, Sendable {
|
|||||||
var userLocation: String? = nil
|
var userLocation: String? = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct QuickQuestionStreamRequest: Codable, Sendable {
|
||||||
|
var provider: Provider
|
||||||
|
var model: String
|
||||||
|
var question: String
|
||||||
|
}
|
||||||
|
|
||||||
private struct ChatCreateBody: Encodable {
|
private struct ChatCreateBody: Encodable {
|
||||||
var title: String?
|
var title: String?
|
||||||
var provider: Provider?
|
var provider: Provider?
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ protocol SybilAPIClienting: Sendable {
|
|||||||
body: CompletionStreamRequest,
|
body: CompletionStreamRequest,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
) async throws
|
) async throws
|
||||||
|
func runQuickQuestionStream(
|
||||||
|
body: QuickQuestionStreamRequest,
|
||||||
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
) async throws
|
||||||
func attachCompletionStream(
|
func attachCompletionStream(
|
||||||
chatID: String,
|
chatID: String,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
|||||||
@@ -1162,13 +1162,11 @@ final class SybilViewModel {
|
|||||||
let streamStatus = CompletionStreamStatus()
|
let streamStatus = CompletionStreamStatus()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await client().runCompletionStream(
|
try await client().runQuickQuestionStream(
|
||||||
body: CompletionStreamRequest(
|
body: QuickQuestionStreamRequest(
|
||||||
chatId: nil,
|
|
||||||
persist: false,
|
|
||||||
provider: provider,
|
provider: provider,
|
||||||
model: model,
|
model: model,
|
||||||
messages: [CompletionRequestMessage(role: .user, content: prompt)]
|
question: prompt
|
||||||
)
|
)
|
||||||
) { [weak self] event in
|
) { [weak self] event in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ private struct MockClientCallSnapshot: Sendable {
|
|||||||
var getSearch = 0
|
var getSearch = 0
|
||||||
var getActiveRuns = 0
|
var getActiveRuns = 0
|
||||||
var runCompletionStream = 0
|
var runCompletionStream = 0
|
||||||
|
var runQuickQuestionStream = 0
|
||||||
var attachCompletionStream = 0
|
var attachCompletionStream = 0
|
||||||
var attachSearchStream = 0
|
var attachSearchStream = 0
|
||||||
}
|
}
|
||||||
@@ -50,7 +51,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
|
|
||||||
private var snapshot = MockClientCallSnapshot()
|
private var snapshot = MockClientCallSnapshot()
|
||||||
private var lastCreateChatCall: ChatCreateCallSnapshot?
|
private var lastCreateChatCall: ChatCreateCallSnapshot?
|
||||||
private var lastCompletionStreamBody: CompletionStreamRequest?
|
private var lastQuickQuestionStreamBody: QuickQuestionStreamRequest?
|
||||||
private var completionStreamEvents: [CompletionStreamEvent]?
|
private var completionStreamEvents: [CompletionStreamEvent]?
|
||||||
private var listChatsDelayNanoseconds: UInt64 = 0
|
private var listChatsDelayNanoseconds: UInt64 = 0
|
||||||
private var listSearchesDelayNanoseconds: UInt64 = 0
|
private var listSearchesDelayNanoseconds: UInt64 = 0
|
||||||
@@ -103,8 +104,8 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
lastCreateChatCall
|
lastCreateChatCall
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentCompletionStreamBody() -> CompletionStreamRequest? {
|
func currentQuickQuestionStreamBody() -> QuickQuestionStreamRequest? {
|
||||||
lastCompletionStreamBody
|
lastQuickQuestionStreamBody
|
||||||
}
|
}
|
||||||
|
|
||||||
func setCompletionStreamEvents(_ events: [CompletionStreamEvent], delayNanoseconds: UInt64 = 0) {
|
func setCompletionStreamEvents(_ events: [CompletionStreamEvent], delayNanoseconds: UInt64 = 0) {
|
||||||
@@ -287,7 +288,27 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
) async throws {
|
) async throws {
|
||||||
snapshot.runCompletionStream += 1
|
snapshot.runCompletionStream += 1
|
||||||
lastCompletionStreamBody = body
|
if completionStreamDelayNanoseconds > 0 {
|
||||||
|
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
|
||||||
|
}
|
||||||
|
if let completionStreamNetworkErrorMessage {
|
||||||
|
throw APIError.networkError(message: completionStreamNetworkErrorMessage)
|
||||||
|
}
|
||||||
|
if let completionStreamEvents {
|
||||||
|
for event in completionStreamEvents {
|
||||||
|
await onEvent(event)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw UnexpectedClientCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runQuickQuestionStream(
|
||||||
|
body: QuickQuestionStreamRequest,
|
||||||
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
|
) async throws {
|
||||||
|
snapshot.runQuickQuestionStream += 1
|
||||||
|
lastQuickQuestionStreamBody = body
|
||||||
if completionStreamDelayNanoseconds > 0 {
|
if completionStreamDelayNanoseconds > 0 {
|
||||||
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
|
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
|
||||||
}
|
}
|
||||||
@@ -1005,7 +1026,8 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
await first?.value
|
await first?.value
|
||||||
|
|
||||||
let calls = await client.currentSnapshot()
|
let calls = await client.currentSnapshot()
|
||||||
#expect(calls.runCompletionStream == 1)
|
#expect(calls.runQuickQuestionStream == 1)
|
||||||
|
#expect(calls.runCompletionStream == 0)
|
||||||
#expect(viewModel.quickQuestionAnswerText == "One answer.")
|
#expect(viewModel.quickQuestionAnswerText == "One answer.")
|
||||||
#expect(!viewModel.isQuickQuestionSending)
|
#expect(!viewModel.isQuickQuestionSending)
|
||||||
}
|
}
|
||||||
@@ -1021,12 +1043,12 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
await task?.value
|
await task?.value
|
||||||
|
|
||||||
let calls = await client.currentSnapshot()
|
let calls = await client.currentSnapshot()
|
||||||
#expect(calls.runCompletionStream == 0)
|
#expect(calls.runQuickQuestionStream == 0)
|
||||||
#expect(!viewModel.isQuickQuestionSending)
|
#expect(!viewModel.isQuickQuestionSending)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
|
@Test func quickQuestionUsesDedicatedServerEndpoint() async throws {
|
||||||
let client = MockSybilClient()
|
let client = MockSybilClient()
|
||||||
await client.setCompletionStreamEvents([
|
await client.setCompletionStreamEvents([
|
||||||
.delta(CompletionStreamDelta(text: "Reset it from ")),
|
.delta(CompletionStreamDelta(text: "Reset it from ")),
|
||||||
@@ -1041,13 +1063,11 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
await task?.value
|
await task?.value
|
||||||
|
|
||||||
let snapshot = await client.currentSnapshot()
|
let snapshot = await client.currentSnapshot()
|
||||||
let body = await client.currentCompletionStreamBody()
|
let body = await client.currentQuickQuestionStreamBody()
|
||||||
#expect(snapshot.runCompletionStream == 1)
|
#expect(snapshot.runQuickQuestionStream == 1)
|
||||||
#expect(body?.persist == false)
|
#expect(snapshot.runCompletionStream == 0)
|
||||||
#expect(body?.chatId == nil)
|
|
||||||
#expect(body?.provider == .openai)
|
#expect(body?.provider == .openai)
|
||||||
#expect(body?.messages.first?.role == .user)
|
#expect(body?.question == "How do I reset my password?")
|
||||||
#expect(body?.messages.first?.content == "How do I reset my password?")
|
|
||||||
#expect(viewModel.quickQuestionAnswerText == "Reset it from Settings.")
|
#expect(viewModel.quickQuestionAnswerText == "Reset it from Settings.")
|
||||||
#expect(!viewModel.isQuickQuestionSending)
|
#expect(!viewModel.isQuickQuestionSending)
|
||||||
}
|
}
|
||||||
@@ -1572,7 +1592,7 @@ private final class MockQuickQuestionLifecycle {
|
|||||||
#expect(panel.state.prompt == "Keep this draft")
|
#expect(panel.state.prompt == "Keep this draft")
|
||||||
#expect(panel.state.answer == "An answer arrived while hidden.")
|
#expect(panel.state.answer == "An answer arrived while hidden.")
|
||||||
let calls = await client.currentSnapshot()
|
let calls = await client.currentSnapshot()
|
||||||
#expect(calls.runCompletionStream == 0)
|
#expect(calls.runQuickQuestionStream == 0)
|
||||||
controller.stop()
|
controller.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1630,7 +1650,7 @@ private final class MockQuickQuestionLifecycle {
|
|||||||
#expect(panel.presentationCount == 2)
|
#expect(panel.presentationCount == 2)
|
||||||
#expect(panel.state.canSend)
|
#expect(panel.state.canSend)
|
||||||
let calls = await client.currentSnapshot()
|
let calls = await client.currentSnapshot()
|
||||||
#expect(calls.runCompletionStream == 0)
|
#expect(calls.runQuickQuestionStream == 0)
|
||||||
controller.stop()
|
controller.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1652,7 +1672,7 @@ private final class MockQuickQuestionLifecycle {
|
|||||||
#expect(panel.state.prompt == "A signed-out question")
|
#expect(panel.state.prompt == "A signed-out question")
|
||||||
#expect(!viewModel.isQuickQuestionSending)
|
#expect(!viewModel.isQuickQuestionSending)
|
||||||
let calls = await client.currentSnapshot()
|
let calls = await client.currentSnapshot()
|
||||||
#expect(calls.runCompletionStream == 0)
|
#expect(calls.runQuickQuestionStream == 0)
|
||||||
controller.stop()
|
controller.stop()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { MultiplexRequest, Provider } from "./types.js";
|
||||||
|
|
||||||
|
export const QUICK_QUESTION_SYSTEM_PROMPT =
|
||||||
|
"You are answering a quick question in a one-shot experience intended to replace a quick Google search. " +
|
||||||
|
"Give a succinct, direct, self-contained answer. Do not ask follow-up questions, invite the user to continue, " +
|
||||||
|
"or offer additional help. If the question is ambiguous, make the most reasonable assumption and state it briefly only when needed.";
|
||||||
|
|
||||||
|
export type QuickQuestionRequest = {
|
||||||
|
provider: Provider;
|
||||||
|
model: string;
|
||||||
|
question: string;
|
||||||
|
enabledTools?: string[];
|
||||||
|
userLocation?: string;
|
||||||
|
temperature?: number;
|
||||||
|
maxTokens?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildQuickQuestionMultiplexRequest({
|
||||||
|
question,
|
||||||
|
...request
|
||||||
|
}: QuickQuestionRequest): MultiplexRequest {
|
||||||
|
return {
|
||||||
|
...request,
|
||||||
|
persist: false,
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: QUICK_QUESTION_SYSTEM_PROMPT },
|
||||||
|
{ role: "user", content: question },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
+33
-1
@@ -11,6 +11,7 @@ import { runMultiplex } from "./llm/multiplexer.js";
|
|||||||
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
|
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
|
||||||
import { getAvailableChatTools, normalizeEnabledChatTools } from "./llm/chat-tools.js";
|
import { getAvailableChatTools, normalizeEnabledChatTools } from "./llm/chat-tools.js";
|
||||||
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
||||||
|
import { buildQuickQuestionMultiplexRequest } from "./llm/quick-question.js";
|
||||||
import { openaiClient } from "./llm/providers.js";
|
import { openaiClient } from "./llm/providers.js";
|
||||||
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
||||||
import { exaClient } from "./search/exa.js";
|
import { exaClient } from "./search/exa.js";
|
||||||
@@ -205,6 +206,16 @@ const CompletionStreamBody = z
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const QuickQuestionStreamBody = z.object({
|
||||||
|
provider: ProviderSchema,
|
||||||
|
model: z.string().min(1),
|
||||||
|
question: z.string().trim().min(1),
|
||||||
|
enabledTools: EnabledToolsSchema.optional(),
|
||||||
|
userLocation: z.string().trim().min(1).max(200).optional(),
|
||||||
|
temperature: z.number().min(0).max(2).optional(),
|
||||||
|
maxTokens: z.number().int().positive().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttachment[]) {
|
function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttachment[]) {
|
||||||
if (!attachments?.length) return metadata as any;
|
if (!attachments?.length) return metadata as any;
|
||||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||||
@@ -1564,7 +1575,28 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Streaming SSE endpoint.
|
// One-shot, non-persistent Quick Question SSE endpoint.
|
||||||
|
app.post("/v1/quick-questions/stream", async (req, reply) => {
|
||||||
|
requireAdmin(req);
|
||||||
|
|
||||||
|
const parsed = QuickQuestionStreamBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||||
|
const body = withRequestUserLocation(parsed.data, req);
|
||||||
|
|
||||||
|
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||||
|
reply.raw.flushHeaders();
|
||||||
|
|
||||||
|
for await (const ev of runMultiplexStream(buildQuickQuestionMultiplexRequest(body))) {
|
||||||
|
writeSseEvent(reply, mapChatStreamEvent(ev));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reply.raw.destroyed && !reply.raw.writableEnded) {
|
||||||
|
reply.raw.end();
|
||||||
|
}
|
||||||
|
return reply;
|
||||||
|
});
|
||||||
|
|
||||||
|
// General chat completion SSE endpoint.
|
||||||
app.post("/v1/chat-completions/stream", async (req, reply) => {
|
app.post("/v1/chat-completions/stream", async (req, reply) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
buildQuickQuestionMultiplexRequest,
|
||||||
|
QUICK_QUESTION_SYSTEM_PROMPT,
|
||||||
|
} from "../src/llm/quick-question.js";
|
||||||
|
|
||||||
|
test("quick question requests receive the server-owned one-shot system prompt", () => {
|
||||||
|
const request = buildQuickQuestionMultiplexRequest({
|
||||||
|
provider: "openai",
|
||||||
|
model: "gpt-4.1-mini",
|
||||||
|
question: "How do I reset my password?",
|
||||||
|
enabledTools: ["web_search"],
|
||||||
|
userLocation: "Los Angeles, CA",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(request.persist, false);
|
||||||
|
assert.equal(request.chatId, undefined);
|
||||||
|
assert.deepEqual(request.messages, [
|
||||||
|
{ role: "system", content: QUICK_QUESTION_SYSTEM_PROMPT },
|
||||||
|
{ role: "user", content: "How do I reset my password?" },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(request.enabledTools, ["web_search"]);
|
||||||
|
assert.equal(request.userLocation, "Los Angeles, CA");
|
||||||
|
assert.match(QUICK_QUESTION_SYSTEM_PROMPT, /succinct, direct, self-contained answer/i);
|
||||||
|
assert.match(QUICK_QUESTION_SYSTEM_PROMPT, /do not ask follow-up questions/i);
|
||||||
|
});
|
||||||
+6
-5
@@ -43,6 +43,7 @@ import {
|
|||||||
getSearch,
|
getSearch,
|
||||||
listWorkspaceItems,
|
listWorkspaceItems,
|
||||||
runCompletionStream,
|
runCompletionStream,
|
||||||
|
runQuickQuestionStream,
|
||||||
runSearchStream,
|
runSearchStream,
|
||||||
suggestChatTitle,
|
suggestChatTitle,
|
||||||
updateChatTitle,
|
updateChatTitle,
|
||||||
@@ -79,6 +80,7 @@ import {
|
|||||||
resolveSidebarSelectionAfterRefresh,
|
resolveSidebarSelectionAfterRefresh,
|
||||||
type SidebarSelection,
|
type SidebarSelection,
|
||||||
} from "@/lib/sidebar-selection";
|
} from "@/lib/sidebar-selection";
|
||||||
|
import { buildQuickQuestionRequest } from "@/lib/quick-question";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type DraftSelectionKind = "chat" | "search";
|
type DraftSelectionKind = "chat" | "search";
|
||||||
@@ -3225,13 +3227,12 @@ export default function App() {
|
|||||||
let streamErrorMessage: string | null = null;
|
let streamErrorMessage: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runCompletionStream(
|
await runQuickQuestionStream(
|
||||||
{
|
buildQuickQuestionRequest({
|
||||||
persist: false,
|
|
||||||
provider: quickProvider,
|
provider: quickProvider,
|
||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
messages: [{ role: "user", content }],
|
content,
|
||||||
},
|
}),
|
||||||
{
|
{
|
||||||
onToolCall: (payload) => {
|
onToolCall: (payload) => {
|
||||||
setQuickQuestionMessages((current) => {
|
setQuickQuestionMessages((current) => {
|
||||||
|
|||||||
+36
-13
@@ -191,6 +191,14 @@ type CompletionStreamHandlers = {
|
|||||||
onError?: (payload: { message: string }) => void;
|
onError?: (payload: { message: string }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function dispatchCompletionStreamEvent(handlers: CompletionStreamHandlers, eventName: string, payload: any) {
|
||||||
|
if (eventName === "meta") handlers.onMeta?.(payload);
|
||||||
|
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
|
||||||
|
else if (eventName === "delta") handlers.onDelta?.(payload);
|
||||||
|
else if (eventName === "done") handlers.onDone?.(payload);
|
||||||
|
else if (eventName === "error") handlers.onError?.(payload);
|
||||||
|
}
|
||||||
|
|
||||||
type CreateChatRequest = {
|
type CreateChatRequest = {
|
||||||
title?: string;
|
title?: string;
|
||||||
provider?: Provider;
|
provider?: Provider;
|
||||||
@@ -627,13 +635,34 @@ export async function runCompletionStream(
|
|||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await readSseStream(response, (eventName, payload) => {
|
await readSseStream(response, (eventName, payload) => dispatchCompletionStreamEvent(handlers, eventName, payload));
|
||||||
if (eventName === "meta") handlers.onMeta?.(payload);
|
}
|
||||||
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
|
|
||||||
else if (eventName === "delta") handlers.onDelta?.(payload);
|
export async function runQuickQuestionStream(
|
||||||
else if (eventName === "done") handlers.onDone?.(payload);
|
body: {
|
||||||
else if (eventName === "error") handlers.onError?.(payload);
|
provider: Provider;
|
||||||
|
model: string;
|
||||||
|
question: string;
|
||||||
|
},
|
||||||
|
handlers: CompletionStreamHandlers,
|
||||||
|
options?: { signal?: AbortSignal }
|
||||||
|
) {
|
||||||
|
const headers = new Headers({
|
||||||
|
Accept: "text/event-stream",
|
||||||
|
"Content-Type": "application/json",
|
||||||
});
|
});
|
||||||
|
if (authToken) {
|
||||||
|
headers.set("Authorization", `Bearer ${authToken}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/v1/quick-questions/stream`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
await readSseStream(response, (eventName, payload) => dispatchCompletionStreamEvent(handlers, eventName, payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function attachCompletionStream(chatId: string, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal }) {
|
export async function attachCompletionStream(chatId: string, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal }) {
|
||||||
@@ -650,11 +679,5 @@ export async function attachCompletionStream(chatId: string, handlers: Completio
|
|||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
await readSseStream(response, (eventName, payload) => {
|
await readSseStream(response, (eventName, payload) => dispatchCompletionStreamEvent(handlers, eventName, payload));
|
||||||
if (eventName === "meta") handlers.onMeta?.(payload);
|
|
||||||
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
|
|
||||||
else if (eventName === "delta") handlers.onDelta?.(payload);
|
|
||||||
else if (eventName === "done") handlers.onDone?.(payload);
|
|
||||||
else if (eventName === "error") handlers.onError?.(payload);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Provider } from "./api";
|
||||||
|
|
||||||
|
export function buildQuickQuestionRequest({
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
content,
|
||||||
|
}: {
|
||||||
|
provider: Provider;
|
||||||
|
model: string;
|
||||||
|
content: string;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
question: content,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { buildQuickQuestionRequest } from "../src/lib/quick-question.ts";
|
||||||
|
|
||||||
|
test("quick question requests use the dedicated server endpoint shape", () => {
|
||||||
|
const request = buildQuickQuestionRequest({
|
||||||
|
provider: "openai",
|
||||||
|
model: "gpt-4.1-mini",
|
||||||
|
content: "How do I reset my password?",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(request, {
|
||||||
|
provider: "openai",
|
||||||
|
model: "gpt-4.1-mini",
|
||||||
|
question: "How do I reset my password?",
|
||||||
|
});
|
||||||
|
assert.equal("additionalSystemPrompt" in request, false);
|
||||||
|
});
|
||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-composer.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/chat-forking.ts","./src/lib/chat-model-selection.ts","./src/lib/sidebar-selection.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-composer.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/chat-forking.ts","./src/lib/chat-model-selection.ts","./src/lib/quick-question.ts","./src/lib/sidebar-selection.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user