22
web/src/components/ai/generation-tool-card.tsx
Normal file
22
web/src/components/ai/generation-tool-card.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export type GenerationToolStatus = "running" | "completed" | "error" | "cancelled";
|
||||
|
||||
export function GenerationToolCard({ status, isBulk = false, heading, children }: { status: GenerationToolStatus; isBulk?: boolean; heading: ReactNode; children: ReactNode }) {
|
||||
const [open, setOpen] = useState(status !== "completed" || !isBulk);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "completed") setOpen(true);
|
||||
}, [status]);
|
||||
|
||||
return <>
|
||||
<div className="creation-message-heading">
|
||||
{heading}
|
||||
<button type="button" className="ml-auto grid size-7 shrink-0 place-items-center rounded-md text-foreground/45 transition-colors hover:bg-surface-hover hover:text-foreground" aria-label={open ? "收起生成详情" : "展开生成详情"} aria-expanded={open} onClick={() => setOpen((value) => !value)}>
|
||||
<ChevronDown className={`size-4 transition-transform${open ? " rotate-180" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
{open ? children : null}
|
||||
</>;
|
||||
}
|
||||
41
web/src/components/ai/message-reasoning.tsx
Normal file
41
web/src/components/ai/message-reasoning.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, Sparkles } from "lucide-react";
|
||||
|
||||
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
|
||||
|
||||
export function MessageReasoning({ reasoning, isStreaming }: { reasoning: string; isStreaming: boolean }) {
|
||||
const [open, setOpen] = useState(isStreaming);
|
||||
const [durationSeconds, setDurationSeconds] = useState<number>();
|
||||
const startedAtRef = useRef<number | undefined>(undefined);
|
||||
const autoClosedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStreaming) return;
|
||||
startedAtRef.current ??= Date.now();
|
||||
setOpen(true);
|
||||
}, [isStreaming]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isStreaming || !startedAtRef.current) return;
|
||||
setDurationSeconds(Math.max(1, Math.ceil((Date.now() - startedAtRef.current) / 1000)));
|
||||
startedAtRef.current = undefined;
|
||||
}, [isStreaming]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isStreaming || !open || autoClosedRef.current || durationSeconds === undefined) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
setOpen(false);
|
||||
autoClosedRef.current = true;
|
||||
}, 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [durationSeconds, isStreaming, open]);
|
||||
|
||||
return <div className="mb-2 text-xs text-foreground/55">
|
||||
<button type="button" className="inline-flex min-h-8 items-center gap-1.5 rounded-md px-2 transition-colors hover:bg-surface-hover hover:text-foreground" aria-expanded={open} onClick={() => setOpen((value) => !value)}>
|
||||
<Sparkles className="size-3.5" />
|
||||
<span>{isStreaming ? "正在思考" : durationSeconds ? `已思考 ${durationSeconds} 秒` : "思考过程"}</span>
|
||||
<ChevronDown className={`size-3.5 transition-transform${open ? " rotate-180" : ""}`} />
|
||||
</button>
|
||||
{open ? <div className="mt-2 rounded-md bg-surface-active p-3 text-foreground/70"><AIMessageMarkdown isStreaming={isStreaming}>{reasoning}</AIMessageMarkdown></div> : null}
|
||||
</div>;
|
||||
}
|
||||
@@ -255,6 +255,7 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
replaceEditableSelection(event.clipboardData.getData("text/plain"));
|
||||
}}
|
||||
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current)) return;
|
||||
if (mention && candidates.length) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
@@ -339,7 +340,16 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
syncMention(next, event.target.selectionStart);
|
||||
reportContentSize(event.currentTarget);
|
||||
}}
|
||||
onCompositionStart={(event) => {
|
||||
composingRef.current = true;
|
||||
props.onCompositionStart?.(event);
|
||||
}}
|
||||
onCompositionEnd={(event) => {
|
||||
composingRef.current = false;
|
||||
props.onCompositionEnd?.(event);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current)) return;
|
||||
if (mention && candidates.length) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -4,6 +4,8 @@ import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Copy, Dow
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
|
||||
import { GenerationToolCard, type GenerationToolStatus } from "@/components/ai/generation-tool-card";
|
||||
import { MessageReasoning } from "@/components/ai/message-reasoning";
|
||||
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
|
||||
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
|
||||
@@ -45,6 +47,7 @@ type CreationMessage = {
|
||||
role: "user" | "assistant";
|
||||
mode?: CreationMode;
|
||||
content: string;
|
||||
reasoning?: string;
|
||||
createdAt: string;
|
||||
status?: CreationStatus;
|
||||
model?: string;
|
||||
@@ -487,7 +490,10 @@ export default function CreatePage() {
|
||||
? await buildTextMessageContent(item)
|
||||
: item.content,
|
||||
})));
|
||||
await requestImageQuestion(requestConfig, history, (text) => updateOriginAssistant((item) => ({ ...item, content: text })), { signal: requestLifecycle.signal });
|
||||
await requestImageQuestion(requestConfig, history, (text) => updateOriginAssistant((item) => ({ ...item, content: text })), {
|
||||
signal: requestLifecycle.signal,
|
||||
onReasoning: (reasoning) => updateOriginAssistant((item) => ({ ...item, reasoning })),
|
||||
});
|
||||
} else if (mode === "image") {
|
||||
const taskCount = Math.max(1, Math.min(imageProfile.maxOutputs, Math.floor(Number(count) || 1)));
|
||||
const settled = await runGenerationOperationOnce(retryContext?.clientOperationId, () => runBackendGenerationTaskBatch({
|
||||
@@ -918,9 +924,10 @@ function CreationMessageView({ item, modelName, onRetryFailure, onCreateVariant,
|
||||
if (item.role === "user") return <CreationUserMessage item={item} />;
|
||||
const mode = item.mode || "text";
|
||||
const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : item.status === "error" ? "生成失败" : "";
|
||||
const heading = <><span className="creation-message-mark"><Sparkles /></span><strong>{mode === "image" ? "图像生成" : mode === "video" ? "视频生成" : "影策 AI"}</strong>{mode !== "text" ? <span className="creation-message-progress-copy">{item.status === "pending" ? `影策正在生成${mode === "video" ? "视频" : "图像"}……` : item.status === "done" ? `你的${mode === "video" ? "视频" : "图像"}已创建` : null}</span> : null}{modelName ? <span className="creation-message-model">{modelName}</span> : null}{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}</>;
|
||||
const toolStatus: GenerationToolStatus = item.status === "pending" ? "running" : item.status === "error" ? "error" : item.status === "cancelled" ? "cancelled" : "completed";
|
||||
return <article className={`creation-assistant-message is-${mode}`}>
|
||||
<div className="creation-message-heading"><span className="creation-message-mark"><Sparkles /></span><strong>{mode === "image" ? "图像生成" : mode === "video" ? "视频生成" : "影策 AI"}</strong>{mode !== "text" ? <span className="creation-message-progress-copy">{item.status === "pending" ? `影策正在生成${mode === "video" ? "视频" : "图像"}……` : item.status === "done" ? `你的${mode === "video" ? "视频" : "图像"}已创建` : null}</span> : null}{modelName ? <span className="creation-message-model">{modelName}</span> : null}{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}</div>
|
||||
{mode === "text" ? <div className="creation-message-content">{item.content ? <AIMessageMarkdown isStreaming={item.status === "streaming"}>{item.content}</AIMessageMarkdown> : <span>正在生成…</span>}</div> : <MediaResult item={item} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} onCancel={onCancel} />}
|
||||
{mode === "text" ? <><div className="creation-message-heading">{heading}</div>{item.reasoning ? <MessageReasoning reasoning={item.reasoning} isStreaming={item.status === "streaming"} /> : null}<div className="creation-message-content">{item.content ? <AIMessageMarkdown isStreaming={item.status === "streaming"}>{item.content}</AIMessageMarkdown> : <span>正在生成…</span>}</div></> : <GenerationToolCard status={toolStatus} isBulk={(item.resultUrls?.length || Number(item.settings?.count) || 1) > 1} heading={heading}><MediaResult item={item} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} onCancel={onCancel} /></GenerationToolCard>}
|
||||
{item.error && mode === "text" ? <div className="creation-message-error"><span>{generationErrorMessage(item.error)}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div> : null}
|
||||
</article>;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export type ResponseFunctionTool = {
|
||||
export type ToolResponseResult = {
|
||||
content: string;
|
||||
toolCalls: ResponseToolCall[];
|
||||
reasoning?: string;
|
||||
};
|
||||
|
||||
type ToolChoice = "auto" | "required" | { type: "function"; name: string };
|
||||
@@ -67,16 +68,16 @@ type ResponseApiPayload = {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
};
|
||||
type ResponseStreamState = { buffer: string; text: string; payload?: ResponseApiPayload; error?: string };
|
||||
type ResponseStreamState = { buffer: string; text: string; reasoning: string; payload?: ResponseApiPayload; error?: string };
|
||||
type ChatCompletionToolCall = { id?: string; type?: "function"; function?: { name?: string; arguments?: string } };
|
||||
type ChatCompletionPayload = {
|
||||
choices?: Array<{ message?: { content?: string | null; tool_calls?: ChatCompletionToolCall[] } }>;
|
||||
choices?: Array<{ message?: { content?: string | null; reasoning_content?: string | null; tool_calls?: ChatCompletionToolCall[] } }>;
|
||||
error?: { message?: string };
|
||||
code?: number;
|
||||
msg?: string;
|
||||
};
|
||||
type ChatCompletionStreamToolCall = { id: string; name: string; arguments: string };
|
||||
type ChatCompletionStreamState = { buffer: string; text: string; toolCalls: Map<number, ChatCompletionStreamToolCall>; error?: string };
|
||||
type ChatCompletionStreamState = { buffer: string; text: string; reasoning: string; toolCalls: Map<number, ChatCompletionStreamToolCall>; error?: string };
|
||||
|
||||
type ImageApiResponse = {
|
||||
data?: Array<Record<string, unknown>>;
|
||||
@@ -86,6 +87,7 @@ type ImageApiResponse = {
|
||||
};
|
||||
type GeminiPart = {
|
||||
text?: string;
|
||||
thought?: boolean;
|
||||
inlineData?: { mimeType?: string; data?: string };
|
||||
inline_data?: { mime_type?: string; mimeType?: string; data?: string };
|
||||
fileData?: { mimeType?: string; fileUri?: string };
|
||||
@@ -101,8 +103,8 @@ type GeminiPayload = {
|
||||
error?: { message?: string };
|
||||
promptFeedback?: { blockReason?: string };
|
||||
};
|
||||
type GeminiStreamState = { buffer: string; text: string; toolCalls: ResponseToolCall[]; error?: string };
|
||||
type RequestOptions = { signal?: AbortSignal; promptCacheKey?: string };
|
||||
type GeminiStreamState = { buffer: string; text: string; reasoning: string; toolCalls: ResponseToolCall[]; error?: string };
|
||||
type RequestOptions = { signal?: AbortSignal; promptCacheKey?: string; onReasoning?: (text: string) => void };
|
||||
|
||||
const QUALITY_BASE: Record<string, number> = {
|
||||
low: 1024,
|
||||
@@ -427,7 +429,7 @@ function parseChatCompletionPayload(payload: ChatCompletionPayload): ToolRespons
|
||||
function: { name: call.function?.name || "", arguments: call.function?.arguments || "{}" },
|
||||
}))
|
||||
.filter((call) => call.id && call.function.name);
|
||||
return { content: message?.content || "", toolCalls };
|
||||
return { content: message?.content || "", toolCalls, ...(message?.reasoning_content ? { reasoning: message.reasoning_content } : {}) };
|
||||
}
|
||||
|
||||
function parseToolResponse(payload: ResponseApiPayload): ToolResponseResult {
|
||||
@@ -495,7 +497,7 @@ async function readJsonPayload<T>(response: Response, fallback: string): Promise
|
||||
}
|
||||
}
|
||||
|
||||
function consumeResponseStreamBlock(block: string, state: ResponseStreamState, onDelta?: (text: string) => void) {
|
||||
function consumeResponseStreamBlock(block: string, state: ResponseStreamState, onDelta?: (text: string) => void, onReasoning?: (text: string) => void) {
|
||||
const data = block
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
@@ -515,6 +517,14 @@ function consumeResponseStreamBlock(block: string, state: ResponseStreamState, o
|
||||
state.text = event.text;
|
||||
onDelta?.(state.text);
|
||||
}
|
||||
if (["response.reasoning.delta", "response.reasoning_text.delta", "response.reasoning_summary_text.delta"].includes(type) && typeof event.delta === "string") {
|
||||
state.reasoning += event.delta;
|
||||
onReasoning?.(state.reasoning);
|
||||
}
|
||||
if (["response.reasoning.done", "response.reasoning_text.done", "response.reasoning_summary_text.done"].includes(type) && !state.reasoning && typeof event.text === "string") {
|
||||
state.reasoning = event.text;
|
||||
onReasoning?.(state.reasoning);
|
||||
}
|
||||
if (type === "response.completed" && isRecord(event.response)) {
|
||||
state.payload = event.response as ResponseApiPayload;
|
||||
} else if (Array.isArray(event.output)) {
|
||||
@@ -522,17 +532,17 @@ function consumeResponseStreamBlock(block: string, state: ResponseStreamState, o
|
||||
}
|
||||
}
|
||||
|
||||
function consumeResponseStreamText(state: ResponseStreamState, text: string, onDelta?: (text: string) => void, flush = false) {
|
||||
function consumeResponseStreamText(state: ResponseStreamState, text: string, onDelta?: (text: string) => void, onReasoning?: (text: string) => void, flush = false) {
|
||||
state.buffer += text;
|
||||
for (;;) {
|
||||
const match = state.buffer.match(/\r?\n\r?\n/);
|
||||
if (!match) break;
|
||||
const index = match.index ?? 0;
|
||||
consumeResponseStreamBlock(state.buffer.slice(0, index), state, onDelta);
|
||||
consumeResponseStreamBlock(state.buffer.slice(0, index), state, onDelta, onReasoning);
|
||||
state.buffer = state.buffer.slice(index + match[0].length);
|
||||
}
|
||||
if (flush && state.buffer.trim()) {
|
||||
consumeResponseStreamBlock(state.buffer, state, onDelta);
|
||||
consumeResponseStreamBlock(state.buffer, state, onDelta, onReasoning);
|
||||
state.buffer = "";
|
||||
}
|
||||
}
|
||||
@@ -555,22 +565,22 @@ async function requestStreamingResponse(config: AiConfig, body: Record<string, u
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const state: ResponseStreamState = { buffer: "", text: "" };
|
||||
const state: ResponseStreamState = { buffer: "", text: "", reasoning: "" };
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
consumeResponseStreamText(state, decoder.decode(value, { stream: true }), onDelta);
|
||||
consumeResponseStreamText(state, decoder.decode(value, { stream: true }), onDelta, options?.onReasoning);
|
||||
if (state.error) throw new Error(state.error);
|
||||
}
|
||||
consumeResponseStreamText(state, decoder.decode(), onDelta, true);
|
||||
consumeResponseStreamText(state, decoder.decode(), onDelta, options?.onReasoning, true);
|
||||
if (state.error) throw new Error(state.error);
|
||||
if (!state.payload) return { content: state.text, toolCalls: [] };
|
||||
if (!state.payload) return { content: state.text, toolCalls: [], ...(state.reasoning ? { reasoning: state.reasoning } : {}) };
|
||||
validateResponsePayload(state.payload);
|
||||
const result = parseToolResponse(state.payload);
|
||||
return { ...result, content: state.text || result.content };
|
||||
return { ...result, content: state.text || result.content, ...(state.reasoning ? { reasoning: state.reasoning } : {}) };
|
||||
}
|
||||
|
||||
function consumeChatCompletionStreamBlock(block: string, state: ChatCompletionStreamState, onDelta?: (text: string) => void) {
|
||||
function consumeChatCompletionStreamBlock(block: string, state: ChatCompletionStreamState, onDelta?: (text: string) => void, onReasoning?: (text: string) => void) {
|
||||
const data = block
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
@@ -589,6 +599,11 @@ function consumeChatCompletionStreamBlock(block: string, state: ChatCompletionSt
|
||||
state.text += delta.content;
|
||||
onDelta?.(state.text);
|
||||
}
|
||||
const reasoningDelta = stringValue(delta.reasoning_content) || stringValue(delta.reasoning) || stringValue(delta.reasoning_text);
|
||||
if (reasoningDelta) {
|
||||
state.reasoning += reasoningDelta;
|
||||
onReasoning?.(state.reasoning);
|
||||
}
|
||||
const chunks = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
|
||||
chunks.forEach((value, fallbackIndex) => {
|
||||
if (!isRecord(value)) return;
|
||||
@@ -603,17 +618,17 @@ function consumeChatCompletionStreamBlock(block: string, state: ChatCompletionSt
|
||||
});
|
||||
}
|
||||
|
||||
function consumeChatCompletionStreamText(state: ChatCompletionStreamState, text: string, onDelta?: (text: string) => void, flush = false) {
|
||||
function consumeChatCompletionStreamText(state: ChatCompletionStreamState, text: string, onDelta?: (text: string) => void, onReasoning?: (text: string) => void, flush = false) {
|
||||
state.buffer += text;
|
||||
for (;;) {
|
||||
const match = state.buffer.match(/\r?\n\r?\n/);
|
||||
if (!match) break;
|
||||
const index = match.index ?? 0;
|
||||
consumeChatCompletionStreamBlock(state.buffer.slice(0, index), state, onDelta);
|
||||
consumeChatCompletionStreamBlock(state.buffer.slice(0, index), state, onDelta, onReasoning);
|
||||
state.buffer = state.buffer.slice(index + match[0].length);
|
||||
}
|
||||
if (flush && state.buffer.trim()) {
|
||||
consumeChatCompletionStreamBlock(state.buffer, state, onDelta);
|
||||
consumeChatCompletionStreamBlock(state.buffer, state, onDelta, onReasoning);
|
||||
state.buffer = "";
|
||||
}
|
||||
}
|
||||
@@ -629,24 +644,28 @@ async function requestStreamingChatCompletion(config: AiConfig, body: Record<str
|
||||
});
|
||||
if (!response.ok) throw new Error(await readFetchError(response, "请求失败"));
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (!response.body || !contentType.includes("text/event-stream")) return parseChatCompletionPayload(await readJsonPayload<ChatCompletionPayload>(response, "请求失败"));
|
||||
if (!response.body || !contentType.includes("text/event-stream")) {
|
||||
const result = parseChatCompletionPayload(await readJsonPayload<ChatCompletionPayload>(response, "请求失败"));
|
||||
if (result.reasoning) options?.onReasoning?.(result.reasoning);
|
||||
return result;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const state: ChatCompletionStreamState = { buffer: "", text: "", toolCalls: new Map() };
|
||||
const state: ChatCompletionStreamState = { buffer: "", text: "", reasoning: "", toolCalls: new Map() };
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
consumeChatCompletionStreamText(state, decoder.decode(value, { stream: true }), onDelta);
|
||||
consumeChatCompletionStreamText(state, decoder.decode(value, { stream: true }), onDelta, options?.onReasoning);
|
||||
if (state.error) throw new Error(state.error);
|
||||
}
|
||||
consumeChatCompletionStreamText(state, decoder.decode(), onDelta, true);
|
||||
consumeChatCompletionStreamText(state, decoder.decode(), onDelta, options?.onReasoning, true);
|
||||
if (state.error) throw new Error(state.error);
|
||||
const toolCalls = Array.from(state.toolCalls.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([, call]) => ({ id: call.id, type: "function" as const, function: { name: call.name, arguments: call.arguments || "{}" } }))
|
||||
.filter((call) => call.id && call.function.name);
|
||||
return { content: state.text, toolCalls };
|
||||
return { content: state.text, toolCalls, ...(state.reasoning ? { reasoning: state.reasoning } : {}) };
|
||||
}
|
||||
|
||||
function toGeminiBody(config: AiConfig, messages: ResponseInputMessage[], extra?: Record<string, unknown>) {
|
||||
@@ -733,39 +752,41 @@ async function requestGeminiStreamingResponse(config: AiConfig, body: Record<str
|
||||
if (!response.ok) throw new Error(await readFetchError(response, "请求失败"));
|
||||
if (!response.body) {
|
||||
const payload = (await response.json()) as GeminiPayload;
|
||||
return parseGeminiToolResponse(payload);
|
||||
const result = parseGeminiToolResponse(payload);
|
||||
if (result.reasoning) options?.onReasoning?.(result.reasoning);
|
||||
return result;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const state: GeminiStreamState = { buffer: "", text: "", toolCalls: [] };
|
||||
const state: GeminiStreamState = { buffer: "", text: "", reasoning: "", toolCalls: [] };
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
consumeGeminiStreamText(state, decoder.decode(value, { stream: true }), onDelta);
|
||||
consumeGeminiStreamText(state, decoder.decode(value, { stream: true }), onDelta, options?.onReasoning);
|
||||
if (state.error) throw new Error(state.error);
|
||||
}
|
||||
consumeGeminiStreamText(state, decoder.decode(), onDelta, true);
|
||||
consumeGeminiStreamText(state, decoder.decode(), onDelta, options?.onReasoning, true);
|
||||
if (state.error) throw new Error(state.error);
|
||||
return { content: state.text, toolCalls: state.toolCalls };
|
||||
return { content: state.text, toolCalls: state.toolCalls, ...(state.reasoning ? { reasoning: state.reasoning } : {}) };
|
||||
}
|
||||
|
||||
function consumeGeminiStreamText(state: GeminiStreamState, text: string, onDelta?: (text: string) => void, flush = false) {
|
||||
function consumeGeminiStreamText(state: GeminiStreamState, text: string, onDelta?: (text: string) => void, onReasoning?: (text: string) => void, flush = false) {
|
||||
state.buffer += text;
|
||||
for (;;) {
|
||||
const match = state.buffer.match(/\r?\n\r?\n/);
|
||||
if (!match) break;
|
||||
const index = match.index ?? 0;
|
||||
consumeGeminiStreamBlock(state.buffer.slice(0, index), state, onDelta);
|
||||
consumeGeminiStreamBlock(state.buffer.slice(0, index), state, onDelta, onReasoning);
|
||||
state.buffer = state.buffer.slice(index + match[0].length);
|
||||
}
|
||||
if (flush && state.buffer.trim()) {
|
||||
consumeGeminiStreamBlock(state.buffer, state, onDelta);
|
||||
consumeGeminiStreamBlock(state.buffer, state, onDelta, onReasoning);
|
||||
state.buffer = "";
|
||||
}
|
||||
}
|
||||
|
||||
function consumeGeminiStreamBlock(block: string, state: GeminiStreamState, onDelta?: (text: string) => void) {
|
||||
function consumeGeminiStreamBlock(block: string, state: GeminiStreamState, onDelta?: (text: string) => void, onReasoning?: (text: string) => void) {
|
||||
const data = block
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
@@ -774,6 +795,10 @@ function consumeGeminiStreamBlock(block: string, state: GeminiStreamState, onDel
|
||||
.trim();
|
||||
if (!data || data === "[DONE]") return;
|
||||
const result = parseGeminiToolResponse(JSON.parse(data) as GeminiPayload);
|
||||
if (result.reasoning) {
|
||||
state.reasoning += result.reasoning;
|
||||
onReasoning?.(state.reasoning);
|
||||
}
|
||||
if (result.content) {
|
||||
state.text += result.content;
|
||||
onDelta?.(state.text);
|
||||
@@ -784,7 +809,8 @@ function consumeGeminiStreamBlock(block: string, state: GeminiStreamState, onDel
|
||||
function parseGeminiToolResponse(payload: GeminiPayload): ToolResponseResult {
|
||||
validateGeminiPayload(payload);
|
||||
const parts = payload.candidates?.flatMap((candidate) => candidate.content?.parts || []) || [];
|
||||
const content = parts.map((part) => part.text || "").join("");
|
||||
const content = parts.filter((part) => !part.thought).map((part) => part.text || "").join("");
|
||||
const reasoning = parts.filter((part) => part.thought).map((part) => part.text || "").join("");
|
||||
const toolCalls = parts
|
||||
.map((part) => part.functionCall)
|
||||
.filter((call): call is NonNullable<GeminiPart["functionCall"]> => Boolean(call?.name))
|
||||
@@ -798,7 +824,7 @@ function parseGeminiToolResponse(payload: GeminiPayload): ToolResponseResult {
|
||||
...(thoughtSignature ? { thoughtSignature } : {}),
|
||||
};
|
||||
});
|
||||
return { content, toolCalls };
|
||||
return { content, toolCalls, ...(reasoning ? { reasoning } : {}) };
|
||||
}
|
||||
|
||||
async function requestGeminiImages(config: AiConfig, prompt: string, references: ReferenceImage[], count: number, options?: RequestOptions) {
|
||||
|
||||
77
web/test/text-reasoning-stream.test.ts
Normal file
77
web/test/text-reasoning-stream.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { GenerationToolCard } from "../src/components/ai/generation-tool-card";
|
||||
import type { ModelProtocol } from "../src/lib/model-protocols";
|
||||
import { requestImageQuestion } from "../src/services/api/image";
|
||||
import { createModelChannel, defaultConfig, type AiConfig } from "../src/stores/use-config-store";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function reasoningConfig(protocol: ModelProtocol | undefined, apiFormat: "openai" | "gemini" = "openai"): AiConfig {
|
||||
const channel = createModelChannel({
|
||||
id: "reasoning",
|
||||
name: "Reasoning",
|
||||
baseUrl: "https://reasoning.example/v1",
|
||||
apiKey: "synthetic-test-key",
|
||||
apiFormat,
|
||||
interfaceType: apiFormat === "gemini" ? undefined : protocol,
|
||||
models: ["reasoner"],
|
||||
modelCosts: [{ model: "reasoner", capability: "text", ...(protocol ? { protocol } : {}), billingMode: "fixed_request", unitPriceMicrocredits: 0 }],
|
||||
});
|
||||
return { ...defaultConfig, channels: [channel], model: "reasoning::reasoner", textModel: "reasoning::reasoner" };
|
||||
}
|
||||
|
||||
function streamResponse(events: unknown[]) {
|
||||
const body = `${events.map((event) => `data: ${JSON.stringify(event)}`).join("\n\n")}\n\n`;
|
||||
return new Response(body, { headers: { "Content-Type": "text/event-stream" } });
|
||||
}
|
||||
|
||||
async function requestWithReasoning(config: AiConfig, events: unknown[]) {
|
||||
globalThis.fetch = (async () => streamResponse(events)) as typeof fetch;
|
||||
let content = "";
|
||||
let reasoning = "";
|
||||
const result = await requestImageQuestion(config, [{ role: "user", content: "请回答" }], (value) => { content = value; }, { onReasoning: (value) => { reasoning = value; } });
|
||||
return { result, content, reasoning };
|
||||
}
|
||||
|
||||
describe("text reasoning streams", () => {
|
||||
test("separates Responses reasoning summaries from answer text", async () => {
|
||||
const output = await requestWithReasoning(reasoningConfig("openai-response"), [
|
||||
{ type: "response.reasoning_summary_text.delta", delta: "先分析" },
|
||||
{ type: "response.output_text.delta", delta: "最终答案" },
|
||||
]);
|
||||
|
||||
expect(output).toEqual({ result: "最终答案", content: "最终答案", reasoning: "先分析" });
|
||||
});
|
||||
|
||||
test("separates Chat Completions reasoning_content from answer text", async () => {
|
||||
const output = await requestWithReasoning(reasoningConfig("chat-completion"), [
|
||||
{ choices: [{ delta: { reasoning_content: "推理过程" } }] },
|
||||
{ choices: [{ delta: { content: "回答内容" } }] },
|
||||
]);
|
||||
|
||||
expect(output).toEqual({ result: "回答内容", content: "回答内容", reasoning: "推理过程" });
|
||||
});
|
||||
|
||||
test("does not duplicate Gemini thought parts into visible content", async () => {
|
||||
const output = await requestWithReasoning(reasoningConfig(undefined, "gemini"), [
|
||||
{ candidates: [{ content: { parts: [{ text: "内部思考", thought: true }, { text: "可见回答" }] } }] },
|
||||
]);
|
||||
|
||||
expect(output).toEqual({ result: "可见回答", content: "可见回答", reasoning: "内部思考" });
|
||||
});
|
||||
|
||||
test("keeps single results open and collapses completed bulk results on restore", () => {
|
||||
const single = renderToStaticMarkup(React.createElement(GenerationToolCard, { status: "completed", heading: "图像生成" }, React.createElement("span", null, "单图结果")));
|
||||
const bulk = renderToStaticMarkup(React.createElement(GenerationToolCard, { status: "completed", isBulk: true, heading: "图像生成" }, React.createElement("span", null, "批量结果")));
|
||||
|
||||
expect(single).toContain("单图结果");
|
||||
expect(bulk).not.toContain("批量结果");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user