feat(kol): add SSE streaming for prompt assist
- Extract assist runtime (prompt building, response parsing, finalize) from the worker into kol_assist_runtime so the sync streaming path and the async task path share the same logic. - Add POST /kol/manage/assist/stream as a Server-Sent Events endpoint emitting start/delta/completed/error events, and wire it to a streamAssist helper on kolManageApi that reports ApiClientError with the server's error envelope. - Stream generated content live in KolPromptEditor and KolGenerateView, switching KolPromptEditArea to a boolean aiBusy flag and refining the generator page layout. - Add KolAssistStreamEvent to shared types. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { createApiClient } from "@geo/http-client";
|
||||
import { ApiClientError, createApiClient } from "@geo/http-client";
|
||||
import type {
|
||||
ApiEnvelope,
|
||||
ArticleDetail,
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
KnowledgeTextItemRequest,
|
||||
KnowledgeURLItemRequest,
|
||||
KolAssistRequest,
|
||||
KolAssistStreamEvent,
|
||||
KolAssistSubmitResponse,
|
||||
KolAssistTask,
|
||||
KolPackageSummary,
|
||||
@@ -384,6 +385,27 @@ export const kolManageApi = {
|
||||
body,
|
||||
);
|
||||
},
|
||||
streamAssist(
|
||||
body: KolAssistRequest,
|
||||
handlers: {
|
||||
onEvent: (event: KolAssistStreamEvent) => void;
|
||||
onClose?: () => void;
|
||||
},
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
return subscribeSSE<KolAssistStreamEvent>(
|
||||
"/api/tenant/kol/manage/assist/stream",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
handlers,
|
||||
signal,
|
||||
);
|
||||
},
|
||||
getAssist(id: string) {
|
||||
return apiClient.get<KolAssistTask>(`/api/tenant/kol/manage/assist/${id}`);
|
||||
},
|
||||
@@ -701,7 +723,7 @@ async function subscribeSSE<TEvent extends SSEEventShape>(
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`stream request failed with status ${response.status}`);
|
||||
throw await buildSSERequestError(response);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("stream response body is empty");
|
||||
@@ -730,6 +752,44 @@ async function subscribeSSE<TEvent extends SSEEventShape>(
|
||||
}
|
||||
}
|
||||
|
||||
type SSEErrorEnvelope = {
|
||||
code?: number;
|
||||
message?: string;
|
||||
detail?: string;
|
||||
request_id?: string;
|
||||
};
|
||||
|
||||
async function buildSSERequestError(response: Response): Promise<ApiClientError> {
|
||||
try {
|
||||
const payload = (await response.clone().json()) as SSEErrorEnvelope | null;
|
||||
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
||||
return new ApiClientError({
|
||||
message: payload.message ?? "unknown_error",
|
||||
code: payload.code,
|
||||
status: response.status,
|
||||
detail: payload.detail,
|
||||
requestId: payload.request_id,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors and fall back to a generic error below.
|
||||
}
|
||||
|
||||
let detail: string | undefined;
|
||||
try {
|
||||
const text = (await response.text()).trim();
|
||||
detail = text || undefined;
|
||||
} catch {
|
||||
detail = undefined;
|
||||
}
|
||||
|
||||
return new ApiClientError({
|
||||
message: "unknown_error",
|
||||
status: response.status,
|
||||
detail: detail ?? `stream request failed with status ${response.status}`,
|
||||
});
|
||||
}
|
||||
|
||||
export const brandsApi = {
|
||||
list() {
|
||||
return apiClient.get<Brand[]>("/api/tenant/brands");
|
||||
|
||||
Reference in New Issue
Block a user