feat: 前端组件准备

This commit is contained in:
2026-05-22 15:02:31 +08:00
parent 397ccdca13
commit 5e05bf4f63
62 changed files with 6903 additions and 878 deletions
+226
View File
@@ -0,0 +1,226 @@
// server/utils/openai.ts - OpenAI Chat Completions 流式调用工具
import { serverEnv } from "~~/server/utils/env";
/** 旧项目的 `OPENAI_API_BASE` 语义是完整 base,例如 `https://api.openai.com/v1` */
const CHAT_COMPLETIONS_URL = `${serverEnv.openAiApiBase}/chat/completions`;
/** 复用编码器计算 SSE data 字节长度,避免循环里频繁创建对象 */
const textEncoder = new TextEncoder();
/** Chat Completions SSE 每个 `data:` chunk 的最小结构 */
interface ChatCompletionStreamChunk {
id?: string;
object?: string;
created?: number;
model?: string;
choices?: Array<{
delta?: {
content?: string;
role?: string;
};
finish_reason?: string | null;
index?: number;
}>;
usage?: unknown;
}
/** 读取完整 SSE 后汇总出的信息;只在服务端日志或内部状态中使用 */
interface ChatCompletionStreamResult {
/** 累积的 `choices[].delta.content` 文本 */
content: string;
/** content 字符长度,方便排查模型是否返回了异常长文本 */
contentLength: number;
/** 实际解析到的 SSE JSON chunk 数量 */
chunkCount: number;
/** 模型返回的结束原因,例如 stop、length;可能不存在 */
finishReason?: string | null;
/** 上游如果返回 usage,则保留下来供内部排查 */
usage?: unknown;
}
/** 答题调用结果:对外只用 answerupstreamResponse 留给服务端排查 */
export interface AskAnswerStreamResult {
answer: string;
upstreamResponse: ChatCompletionStreamResult;
}
/**
* 通过 Chat Completions 流式接口生成答案
*
* 对 OCS 客户端仍返回普通 JSON;流式只发生在服务端到 OpenAI 之间
* 这样既能更早读取上游内容,又不破坏 AnswererWrapper 的 handler 契约
*/
export const askAnswerStream = async ({
prompt,
systemPrompt
}: {
prompt: string;
systemPrompt: string;
}): Promise<AskAnswerStreamResult> => {
if (!serverEnv.openAiApiKey) {
throw new Error("OPENAI_API_KEY is not set.");
}
// 这里不要记录 request body:其中包含完整题目和可能的选项文本
const response = await fetch(CHAT_COMPLETIONS_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${serverEnv.openAiApiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: serverEnv.openAiModel,
temperature: serverEnv.temperature,
max_tokens: serverEnv.maxTokens,
stream: true,
messages: [
{
role: "system",
content: systemPrompt
},
{
role: "user",
content: prompt
}
]
})
});
if (!response.ok) {
throw new Error(`OpenAI request failed with status ${response.status}.`);
}
if (!response.body) {
throw new Error("OpenAI stream body is empty.");
}
// 将 SSE delta 读完后再返回给 API handler,由 handler 统一做答案清洗和缓存
const upstreamResponse = await readChatCompletionStream(response.body);
return {
answer: upstreamResponse.content.trim(),
upstreamResponse
};
};
/**
* 读取 Chat Completions SSE 流
*
* 上游返回形如:
* `data: {"choices":[{"delta":{"content":"..."}}]}`
* `data: [DONE]`
*
* 网络 chunk 不一定按行对齐,所以用 buffer 保存尚未拼完整的一行
*/
export const readChatCompletionStream = async (
stream: ReadableStream<Uint8Array>
): Promise<ChatCompletionStreamResult> => {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = "";
let content = "";
let chunkCount = 0;
let finishReason: string | null | undefined;
let usage: unknown;
/** 把一个已解析 JSON chunk 合并进最终结果 */
const collectChunk = (chunk: ChatCompletionStreamChunk) => {
chunkCount += 1;
const deltaContent = collectDeltaContent(chunk);
if (deltaContent) {
content += deltaContent;
}
if (chunk.usage) {
usage = chunk.usage;
}
finishReason =
chunk.choices?.find((choice) => choice.finish_reason)?.finish_reason ??
finishReason;
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
// stream: true 可以正确处理跨 chunk 的多字节字符,例如中文
buffer += decoder.decode(value, { stream: true });
assertSseBufferSize(buffer, serverEnv.maxSseLineBytes);
// 只处理已经遇到换行的完整 SSE 行,最后一段留到下次 chunk 再拼
const lines = buffer.split(/\r?\n/u);
buffer = lines.pop() ?? "";
for (const line of lines) {
const chunk = parseSseDataLine(line, serverEnv.maxSseLineBytes);
if (chunk) collectChunk(chunk);
}
}
// 处理流结束时仍留在 buffer 中的最后一行
const finalText = buffer + decoder.decode();
for (const line of finalText.split(/\r?\n/u)) {
const chunk = parseSseDataLine(line, serverEnv.maxSseLineBytes);
if (chunk) collectChunk(chunk);
}
return {
content,
contentLength: content.length,
chunkCount,
finishReason,
usage
};
};
/**
* 解析单行 SSE data
*
* 空行、event 行和 `[DONE]` 都不包含业务内容,直接跳过
* 超过大小限制的 data 行视为异常,防止上游或代理故障导致内存压力
*/
export const parseSseDataLine = (
line: string,
maxDataLineBytes: number
): ChatCompletionStreamChunk | null => {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) return null;
const data = trimmed.slice("data:".length).trim();
if (!data || data === "[DONE]") return null;
if (textEncoder.encode(data).byteLength > maxDataLineBytes) {
throw new Error("OpenAI stream data line is too large.");
}
return JSON.parse(data) as ChatCompletionStreamChunk;
};
/** 收集一个 chunk 中所有 choice 的增量文本 */
export const collectDeltaContent = (chunk: ChatCompletionStreamChunk) => {
return (
chunk.choices
?.map((choice) => choice.delta?.content || "")
.filter(Boolean)
.join("") || ""
);
};
/**
* 限制尚未出现换行的 SSE 缓冲区大小
*
* 正常 SSE 会频繁换行;如果一直没有换行且持续变大,说明可能不是合法 SSE
* 或上游返回了异常长单行,应该尽早失败
*/
export const assertSseBufferSize = (
buffer: string,
maxDataLineBytes: number
) => {
if (
!buffer.includes("\n") &&
textEncoder.encode(buffer).byteLength > maxDataLineBytes
) {
throw new Error("OpenAI stream buffer is too large.");
}
};