feat: 前端组件准备
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
// server/utils/answer.ts - OCS 答题提示词构建、答案清洗和响应格式化
|
||||
|
||||
/** `/api/search` 从 OCS 请求中最终抽取出的标准参数 */
|
||||
export interface SearchParams {
|
||||
/** 题目正文,对应 OCS 的 `${title}` */
|
||||
title: string;
|
||||
/** 题型:single、multiple、judgement、completion;未知值也允许透传给提示词 */
|
||||
type: string;
|
||||
/** 选项文本,通常是 A/B/C/D 多行内容;没有选项时为空字符串 */
|
||||
options: string;
|
||||
}
|
||||
|
||||
/** OCS AnswererWrapper handler 期望的成功响应结构 */
|
||||
export interface OcsSuccessResponse {
|
||||
code: 1;
|
||||
/** 返回原题,handler 会将它作为匹配题目展示 */
|
||||
question: string;
|
||||
/** 返回最终答案;多选题必须是 `#` 分隔字符串,而不是数组 */
|
||||
answer: string;
|
||||
}
|
||||
|
||||
/** OCS AnswererWrapper handler 期望的失败响应结构 */
|
||||
export interface OcsErrorResponse {
|
||||
code: 0;
|
||||
/** 只放本地业务文案,不暴露上游或内部异常详情 */
|
||||
msg: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 不同题型对应的补充说明
|
||||
*
|
||||
* 这里保留旧 Python 服务的语义:题型只影响提示词,不做硬编码答题
|
||||
*/
|
||||
const QUESTION_TYPE_PROMPTS: Record<string, string> = {
|
||||
single: "这是一道单选题。",
|
||||
multiple: "这是一道多选题,答案请用#符号分隔。",
|
||||
judgement: "这是一道判断题,需要回答:正确/对/true/√ 或者 错误/错/false/×。",
|
||||
completion: "这是一道填空题。"
|
||||
};
|
||||
|
||||
/**
|
||||
* 全局系统提示词
|
||||
*
|
||||
* 重点约束“只给答案、不解释”,因为 OCS 需要的是可直接填入的答案文本,
|
||||
* 多余解释会降低自动匹配和展示效果
|
||||
*/
|
||||
export const ANSWER_SYSTEM_PROMPT =
|
||||
"你是一个专业的考试答题助手。请直接回答答案,不要解释。选择题只回答选项的内容(如:地球);多选题用#号分隔答案,只回答选项的内容(如中国#世界#地球);判断题只回答: 正确/对/true/√ 或 错误/错/false/×;填空题直接给出答案。";
|
||||
|
||||
/**
|
||||
* 将 OCS 请求参数整理成发给模型的用户提示词
|
||||
*
|
||||
* 旧 Python 服务就是在这里把题目、题型和选项拼成结构化文本;
|
||||
* Nuxt 版保持同样逻辑,方便答案行为尽量一致
|
||||
*/
|
||||
export const parseQuestionAndOptions = (
|
||||
question: string,
|
||||
options: string,
|
||||
questionType: string
|
||||
) => {
|
||||
let prompt = `问题: ${question}\n`;
|
||||
const typePrompt = QUESTION_TYPE_PROMPTS[questionType];
|
||||
|
||||
if (typePrompt) {
|
||||
prompt += `${typePrompt}\n`;
|
||||
}
|
||||
|
||||
if (options) {
|
||||
prompt += `选项:\n${options}\n`;
|
||||
}
|
||||
|
||||
prompt += "请直接给出答案,不要解释。";
|
||||
return prompt;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从模型返回文本里提取最终答案
|
||||
*
|
||||
* 旧服务对多选题做了一个兼容处理:如果模型返回类似 “答案是 A、B、C”,
|
||||
* 就尝试抽取 A-F 并改成 OCS 需要的 `A#B#C`。如果模型已经返回 `#`
|
||||
* 分隔内容,或者不是多选题,则直接返回原文本
|
||||
*/
|
||||
export const extractAnswer = (aiResponse: string, questionType: string) => {
|
||||
const trimmedResponse = aiResponse.trim();
|
||||
|
||||
if (questionType === "multiple") {
|
||||
const lines = trimmedResponse.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (/[A-D]/iu.test(line) && !line.includes("#")) {
|
||||
const options = ["A", "B", "C", "D", "E", "F"].filter((option) =>
|
||||
line.toUpperCase().includes(option)
|
||||
);
|
||||
|
||||
if (options.length > 0) {
|
||||
return options.join("#");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return trimmedResponse;
|
||||
};
|
||||
|
||||
/** 生成 OCS 兼容成功响应,集中在一个函数里避免各 handler 自己拼结构 */
|
||||
export const formatAnswerForOcs = (
|
||||
question: string,
|
||||
answer: string
|
||||
): OcsSuccessResponse => {
|
||||
return {
|
||||
code: 1,
|
||||
question,
|
||||
answer
|
||||
};
|
||||
};
|
||||
|
||||
/** 生成 OCS 兼容失败响应;错误文案由调用处决定 */
|
||||
export const createOcsErrorResponse = (msg: string): OcsErrorResponse => {
|
||||
return {
|
||||
code: 0,
|
||||
msg
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
// server/utils/auth.ts - OCS API 可选访问令牌校验
|
||||
import { getQuery, type H3Event } from "h3";
|
||||
|
||||
import { serverEnv } from "~~/server/utils/env";
|
||||
|
||||
/**
|
||||
* H3 的 query 值可能是字符串、数组或 undefined
|
||||
* 访问令牌只接受第一个值,和多数 Web 框架读取 query 的行为保持一致
|
||||
*/
|
||||
const firstQueryValue = (value: unknown) => {
|
||||
if (Array.isArray(value)) return value[0]?.toString() || "";
|
||||
return value?.toString() || "";
|
||||
};
|
||||
|
||||
/**
|
||||
* 校验可选访问令牌
|
||||
*
|
||||
* - 没配置 `ACCESS_TOKEN` 时,服务保持旧 Python 项目的开放行为
|
||||
* - 配置后,兼容两种传递方式:`X-Access-Token` 请求头或 `?token=...`
|
||||
*/
|
||||
export const verifyAccessToken = (event: H3Event) => {
|
||||
if (!serverEnv.accessToken) return true;
|
||||
|
||||
const headerToken = event.node.req.headers["x-access-token"]?.toString();
|
||||
const queryToken = firstQueryValue(getQuery(event).token);
|
||||
|
||||
return (
|
||||
headerToken === serverEnv.accessToken ||
|
||||
queryToken === serverEnv.accessToken
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* OCS 搜索接口使用 `{ code, msg }`,而 stats/cache 使用 `{ success, message }`
|
||||
* 这里先返回最基础的 OCS 形状,其他接口复用其中的 msg 文案
|
||||
*/
|
||||
export const invalidAccessTokenResponse = () => {
|
||||
return {
|
||||
code: 0,
|
||||
msg: "无效的访问令牌"
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
// server/utils/cache.ts - 简单内存缓存,按题目、题型和选项生成缓存键
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { serverEnv } from "~~/server/utils/env";
|
||||
|
||||
/**
|
||||
* 旧 Python 服务使用进程内字典缓存答案
|
||||
*
|
||||
* Nuxt 版保持同样的“内存缓存”语义:服务重启后缓存清空,不跨实例共享;
|
||||
* 这对个人题库服务足够简单,也避免为了缓存引入数据库或 Redis
|
||||
*/
|
||||
export class SimpleCache {
|
||||
/** Map key 是题目、题型和选项计算出的 md5;value 保存写入时间和答案 */
|
||||
private readonly cache = new Map<
|
||||
string,
|
||||
{
|
||||
timestamp: number;
|
||||
value: string;
|
||||
}
|
||||
>();
|
||||
|
||||
constructor(private readonly expirationSeconds: number) {}
|
||||
|
||||
/**
|
||||
* 根据题目、题型和选项生成缓存键
|
||||
*
|
||||
* 同一道题如果选项不同,不能复用旧答案,所以三个字段都参与计算
|
||||
*/
|
||||
private generateKey(question: string, questionType = "", options = "") {
|
||||
return createHash("md5")
|
||||
.update(`${question}|${questionType}|${options}`, "utf8")
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取缓存答案
|
||||
*
|
||||
* 命中过期项时会顺手删除,避免长时间运行后堆积无效缓存
|
||||
*/
|
||||
get(question: string, questionType = "", options = "") {
|
||||
const key = this.generateKey(question, questionType, options);
|
||||
const item = this.cache.get(key);
|
||||
if (!item) return undefined;
|
||||
|
||||
if (Date.now() - item.timestamp < this.expirationSeconds * 1000) {
|
||||
return item.value;
|
||||
}
|
||||
|
||||
this.cache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 写入答案缓存,时间戳使用当前进程时间即可 */
|
||||
set(question: string, answer: string, questionType = "", options = "") {
|
||||
const key = this.generateKey(question, questionType, options);
|
||||
this.cache.set(key, {
|
||||
timestamp: Date.now(),
|
||||
value: answer
|
||||
});
|
||||
}
|
||||
|
||||
/** 清空全部缓存,对应 `/api/cache/clear` */
|
||||
clear() {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量移除过期项
|
||||
*
|
||||
* 目前在统计缓存大小时调用,避免 stats 返回已经过期的数量
|
||||
*/
|
||||
removeExpired() {
|
||||
const now = Date.now();
|
||||
let removedCount = 0;
|
||||
|
||||
for (const [key, item] of this.cache.entries()) {
|
||||
if (now - item.timestamp >= this.expirationSeconds * 1000) {
|
||||
this.cache.delete(key);
|
||||
removedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
/** 获取当前有效缓存数量 */
|
||||
size() {
|
||||
this.removeExpired();
|
||||
return this.cache.size;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局答案缓存实例
|
||||
*
|
||||
* 如果 `ENABLE_CACHE=false`,导出 undefined,调用处用可选链即可保持逻辑简洁
|
||||
*/
|
||||
export const answerCache = serverEnv.enableCache
|
||||
? new SimpleCache(serverEnv.cacheExpiration)
|
||||
: undefined;
|
||||
+23
-2
@@ -1,6 +1,14 @@
|
||||
// server/utils/db.ts - Prisma Client 单例,当前答题 API 不依赖数据库,保留给后续业务使用
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { PrismaClient } from "../../prisma/generated/client";
|
||||
|
||||
import { PrismaClient } from "~~/prisma/generated/client";
|
||||
|
||||
/**
|
||||
* 将 `DATABASE_URL` 解析为 MariaDB driver 的连接池配置
|
||||
*
|
||||
* Prisma 7 使用 driver adapter 后,需要显式传入 adapter;
|
||||
* 这里不把连接串原样传给前端或日志,只在服务端初始化连接池
|
||||
*/
|
||||
const createMariaDbConfig = () => {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
@@ -18,10 +26,16 @@ const createMariaDbConfig = () => {
|
||||
database: decodeURIComponent(url.pathname.slice(1)),
|
||||
connectionLimit: 5,
|
||||
connectTimeout: 15_000,
|
||||
acquireTimeout: 20_000,
|
||||
acquireTimeout: 20_000
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建 Prisma Client
|
||||
*
|
||||
* 连接池超时设置比默认值更宽松,是因为当前数据库是远程 MySQL,
|
||||
* 默认超时时间过短时 Node driver 可能还没建好 socket 就失败
|
||||
*/
|
||||
const prismaClientSingleton = () => {
|
||||
const adapter = new PrismaMariaDb(createMariaDbConfig());
|
||||
return new PrismaClient({ adapter });
|
||||
@@ -29,10 +43,17 @@ const prismaClientSingleton = () => {
|
||||
|
||||
type PrismaClientSingleton = ReturnType<typeof prismaClientSingleton>;
|
||||
|
||||
/**
|
||||
* 开发环境热更新会反复加载模块
|
||||
*
|
||||
* 把 Prisma Client 挂到 globalThis 上,可以避免每次 HMR 都新建连接池
|
||||
*/
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClientSingleton | undefined;
|
||||
};
|
||||
|
||||
/** 全项目唯一 Prisma Client 实例 */
|
||||
export const prisma = globalForPrisma.prisma ?? prismaClientSingleton();
|
||||
|
||||
// 生产环境由进程生命周期管理;开发环境缓存到全局,减少 HMR 连接泄漏
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// server/utils/env.ts - 服务端环境变量读取与规范化
|
||||
|
||||
/**
|
||||
* 允许 `.env` 里保留类似 Python 旧项目的写法:
|
||||
* `OPENAI_API_BASE=https://example.com/v1 # 注释`
|
||||
* Nuxt/Node 读到的是整行值,所以这里主动裁掉行尾注释
|
||||
*/
|
||||
const stripInlineComment = (value: string) => {
|
||||
return value.replace(/\s+#.*$/u, "").trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取字符串环境变量
|
||||
*
|
||||
* - 空字符串视为未配置,回退到 fallback
|
||||
* - 返回值统一去掉行尾注释,避免 baseURL、model 之类配置带上注释文本
|
||||
*/
|
||||
const readString = (key: string, fallback = "") => {
|
||||
const value = process.env[key];
|
||||
if (value === undefined || value === null || value.trim() === "") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return stripInlineComment(value);
|
||||
};
|
||||
|
||||
/** 读取可选字符串;空值统一转成 undefined,方便后续判断是否启用某能力 */
|
||||
const readOptionalString = (key: string) => {
|
||||
const value = readString(key);
|
||||
return value || undefined;
|
||||
};
|
||||
|
||||
/** 兼容常见布尔写法,便于 Docker、面板和手写 `.env` 配置 */
|
||||
const readBoolean = (key: string, fallback: boolean) => {
|
||||
const value = readString(key);
|
||||
if (!value) return fallback;
|
||||
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
|
||||
};
|
||||
|
||||
/** 读取整数配置;非法值不抛错,使用默认值保证服务能启动 */
|
||||
const readInteger = (key: string, fallback: number) => {
|
||||
const value = Number.parseInt(readString(key), 10);
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
};
|
||||
|
||||
/** 读取小数配置,主要用于 OpenAI temperature */
|
||||
const readNumber = (key: string, fallback: number) => {
|
||||
const value = Number.parseFloat(readString(key));
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
};
|
||||
|
||||
/** OpenAI 兼容接口 baseURL 统一不带结尾斜杠,后续再拼 `/chat/completions` */
|
||||
const normalizeBaseUrl = (url: string) => {
|
||||
return url.replace(/\/+$/u, "");
|
||||
};
|
||||
|
||||
/**
|
||||
* 服务端环境配置的唯一出口
|
||||
*
|
||||
* 业务代码不要直接读 `process.env`,这样可以把默认值、格式修正和安全边界
|
||||
* 都收敛在这里,后续迁移部署平台时也只需要检查这个文件
|
||||
*/
|
||||
export const serverEnv = {
|
||||
/** OpenAI 或兼容中转平台 API Key,仅服务端使用,不能返回给前端 */
|
||||
openAiApiKey: readString("OPENAI_API_KEY"),
|
||||
/** OpenAI 兼容 API base,例如 `https://api.openai.com/v1` */
|
||||
openAiApiBase: normalizeBaseUrl(
|
||||
readString("OPENAI_API_BASE", "https://api.openai.com/v1")
|
||||
),
|
||||
/** Chat Completions 使用的模型名 */
|
||||
openAiModel: readString("OPENAI_MODEL", "gpt-3.5-turbo"),
|
||||
/** 单次回答最大 token 数,沿用旧 Python 服务的配置语义 */
|
||||
maxTokens: readInteger("MAX_TOKENS", 500),
|
||||
/** 模型采样温度,越低越稳定 */
|
||||
temperature: readNumber("TEMPERATURE", 0.7),
|
||||
/** 可选访问令牌;配置后 API 需要 header 或 query 携带 token */
|
||||
accessToken: readOptionalString("ACCESS_TOKEN"),
|
||||
/** 是否启用内存缓存 */
|
||||
enableCache: readBoolean("ENABLE_CACHE", true),
|
||||
/** 缓存过期时间,单位秒 */
|
||||
cacheExpiration: readInteger("CACHE_EXPIRATION", 86_400),
|
||||
/** 预留日志级别配置,目前日志工具只负责安全输出 */
|
||||
logLevel: readString("LOG_LEVEL", "INFO"),
|
||||
/** 单行 SSE data 的最大字节数,用于防止异常流无限堆内存 */
|
||||
maxSseLineBytes: readInteger("OPENAI_STREAM_MAX_SSE_LINE_BYTES", 1_048_576)
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
// server/utils/logging.ts - 安全日志工具,避免输出密钥、cookie 和完整敏感内容
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type { H3Event } from "h3";
|
||||
|
||||
/**
|
||||
* 常见敏感片段匹配
|
||||
*
|
||||
* 这里不是完整 DLP,只处理最容易误打出来的 key、Bearer token 和 token=xxx
|
||||
* 真实请求体、响应体本来就不应该进入日志
|
||||
*/
|
||||
const SENSITIVE_VALUE_RE =
|
||||
/(sk-[A-Za-z0-9_-]+|Bearer\s+[A-Za-z0-9._-]+|token[=:]\s*[^,\s}]+)/giu;
|
||||
|
||||
/** 对错误摘要做最小脱敏和截断,避免日志过长或泄露密钥 */
|
||||
const sanitizeLogText = (value: string) => {
|
||||
return value.replace(SENSITIVE_VALUE_RE, "[redacted]").slice(0, 500);
|
||||
};
|
||||
|
||||
/**
|
||||
* 将 unknown 异常变成安全可打印对象
|
||||
*
|
||||
* API 对前端统一返回“服务器内部错误”,日志里只保留类型和脱敏摘要,
|
||||
* 既能排查方向,又不把上游响应体或 key 打出去
|
||||
*/
|
||||
export const toSafeLogError = (error: unknown) => {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name,
|
||||
message: sanitizeLogText(error.message)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: "UnknownError",
|
||||
message: sanitizeLogText(String(error))
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建带 requestId 的 API 日志器
|
||||
*
|
||||
* 每个 handler 只需要记录“阶段”和少量结构化字段;这里统一补 method、path、
|
||||
* 耗时等上下文,方便后续按一次请求串起来查
|
||||
*/
|
||||
export const createApiLogger = (event: H3Event, scope: string) => {
|
||||
const requestId =
|
||||
event.node.req.headers["x-request-id"]?.toString() || randomUUID();
|
||||
const startedAt = Date.now();
|
||||
|
||||
/** 内部统一出口,避免不同 level 打出的字段不一致 */
|
||||
const write = (
|
||||
level: "info" | "warn" | "error",
|
||||
stage: string,
|
||||
extra: Record<string, unknown> = {}
|
||||
) => {
|
||||
const payload = {
|
||||
requestId,
|
||||
scope,
|
||||
stage,
|
||||
method: event.node.req.method,
|
||||
path: event.path,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
...extra
|
||||
};
|
||||
|
||||
console[level](`[${scope}] ${stage}`, JSON.stringify(payload));
|
||||
};
|
||||
|
||||
/** 返回轻量 API,handler 不直接接触 console,便于以后替换日志实现 */
|
||||
return {
|
||||
requestId,
|
||||
info: (stage: string, extra?: Record<string, unknown>) =>
|
||||
write("info", stage, extra),
|
||||
warn: (stage: string, extra?: Record<string, unknown>) =>
|
||||
write("warn", stage, extra),
|
||||
error: (stage: string, extra?: Record<string, unknown>) =>
|
||||
write("error", stage, extra)
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/** 答题调用结果:对外只用 answer,upstreamResponse 留给服务端排查 */
|
||||
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.");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
// server/utils/runtimeState.ts - 服务启动时间和最近问答记录的内存状态
|
||||
import { answerCache } from "~~/server/utils/cache";
|
||||
import { serverEnv } from "~~/server/utils/env";
|
||||
|
||||
/** 最近问答记录,沿用旧 Python 服务 dashboard/stats 的内存记录语义 */
|
||||
export interface QaRecord {
|
||||
/** 本地可读时间,方便后续如果恢复 dashboard 时直接展示 */
|
||||
time: string;
|
||||
/** ISO 时间,方便机器处理和排序 */
|
||||
timestamp: string;
|
||||
/** 题目正文 */
|
||||
question: string;
|
||||
/** 题型 */
|
||||
type: string;
|
||||
/** 选项文本 */
|
||||
options: string;
|
||||
/** 最终返回给 OCS 的答案 */
|
||||
answer: string;
|
||||
}
|
||||
|
||||
/** 只保留最近 100 条,避免长时间运行后内存无限增长 */
|
||||
const MAX_RECORDS = 100;
|
||||
/** 进程启动时间,用于 `/api/stats` 返回 uptime */
|
||||
const startTime = Date.now();
|
||||
/** 进程内问答记录;服务重启或多实例部署时不会共享 */
|
||||
const qaRecords: QaRecord[] = [];
|
||||
|
||||
/** 对外展示的服务版本,和 README/API 文档保持一致 */
|
||||
export const SERVICE_VERSION = "1.1.0";
|
||||
|
||||
/** 格式化成本地 `YYYY-MM-DD HH:mm:ss`,对齐旧 Python 服务记录格式 */
|
||||
const formatLocalDateTime = (date: Date) => {
|
||||
const pad = (value: number) => value.toString().padStart(2, "0");
|
||||
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
|
||||
date.getDate()
|
||||
)} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(
|
||||
date.getSeconds()
|
||||
)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 追加一条问答记录
|
||||
*
|
||||
* 记录只用于统计和未来可能恢复的 dashboard,不作为题库持久化数据
|
||||
*/
|
||||
export const addQaRecord = (record: Omit<QaRecord, "time" | "timestamp">) => {
|
||||
const now = new Date();
|
||||
|
||||
qaRecords.push({
|
||||
time: formatLocalDateTime(now),
|
||||
timestamp: now.toISOString(),
|
||||
...record
|
||||
});
|
||||
|
||||
if (qaRecords.length > MAX_RECORDS) {
|
||||
qaRecords.shift();
|
||||
}
|
||||
};
|
||||
|
||||
/** 生成 `/api/stats` 响应,实时计算 uptime 和有效缓存数量 */
|
||||
export const getRuntimeStats = () => {
|
||||
return {
|
||||
version: SERVICE_VERSION,
|
||||
uptime: (Date.now() - startTime) / 1000,
|
||||
model: serverEnv.openAiModel,
|
||||
cache_enabled: serverEnv.enableCache,
|
||||
cache_size: answerCache?.size() ?? 0,
|
||||
qa_records_count: qaRecords.length
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user