最近给新加了一个在线小工具:火芯のOpenChat。
在线体验地址https://blog.hxdxw.cn/tools/openchat/index.html
它是一个基于 Vue 3 + TailwindCSS 写的纯前端 AI 对话页面,主要目标是:测试模型使用,只要兼容 OpenAI 接口,就可以在网页里配置 Base URL、API Key 和模型 ID 后直接使用。
技术栈:Vue 3、Vite、TailwindCSS、TypeScript、LocalStorage、OpenAI Compatible API
功能概览
这个工具目前支持:
- 多会话列表
- 会话本地保存
- OpenAI 兼容接口配置
- 一键获取模型列表
- 模型下拉选择
- 流式输出
- 非流式请求兜底
- 文本文件导入
- 上下文长度估算
- 子目录静态部署
它没有后端服务,也没有数据库。所有会话、设置、API Key 都保存在浏览器的 LocalStorage 里。
这意味着它部署简单,但也有一个前提:模型服务必须允许浏览器跨域请求,也就是服务端需要开启 CORS。
页面结构
界面整体分为三块:
顶部 LOGO 栏
左侧会话列表
右侧当前会话与输入框左侧负责会话选择、创建和设置入口;右侧负责显示消息、选择模型和输入消息。
核心状态定义大致如下:
export type ChatMessage = {
id: string;
role: "system" | "user" | "assistant";
content: string;
createdAt: number;
};
export type ChatSession = {
id: string;
title: string;
messages: ChatMessage[];
createdAt: number;
updatedAt: number;
};
export type AppSettings = {
baseUrl: string;
apiKey: string;
model: string;
contextLimit: number;
modelsCache?: { ids: string[]; fetchedAt: number };
};其中 baseUrl 默认是:
https://api.openai.com/v1后续请求会自动补全:
/models
/chat/completions本地持久化
项目使用 LocalStorage 保存状态,核心代码在 src/lib/storage.ts。
const KEY = "openchat:v1";
const defaultState = {
settings: {
baseUrl: "https://api.openai.com/v1",
apiKey: "",
model: "",
contextLimit: 4096,
},
sessions: [],
activeSessionId: null,
};读取状态时,如果浏览器里已经有旧配置,会自动合并默认值:
export function loadState(): PersistedState {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return structuredClone(defaultState);
const parsed = JSON.parse(raw) as Partial<PersistedState>;
return {
settings: {
...defaultState.settings,
...(parsed.settings ?? {}),
},
sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [],
activeSessionId:
typeof parsed.activeSessionId === "string" ? parsed.activeSessionId : null,
};
} catch {
return structuredClone(defaultState);
}
}保存则直接写回:
export function saveState(state: PersistedState) {
localStorage.setItem(KEY, JSON.stringify(state));
}为了减少频繁写入,状态变化时做了一个简单的延迟保存:
let persistTimer: number | null = null;
watch(
() => [state.settings, state.sessions, state.activeSessionId],
() => {
if (persistTimer) window.clearTimeout(persistTimer);
persistTimer = window.setTimeout(() => {
saveState({
settings: state.settings,
sessions: state.sessions,
activeSessionId: state.activeSessionId,
});
}, 200);
},
{ deep: true },
);一键获取模型列表
设置弹窗里有一个获取模型列表按钮。它会请求:
{Base URL}/models例如默认情况下就是:
https://api.openai.com/v1/models核心代码:
export async function fetchModelIds(args: {
baseUrl: string;
apiKey: string;
signal?: AbortSignal;
}) {
const baseUrl = normalizeBaseUrl(args.baseUrl);
if (!baseUrl) throw new Error("Base URL 不能为空");
const res = await fetch(`${baseUrl}/models`, {
method: "GET",
headers: {
...authHeaders(args.apiKey),
},
signal: args.signal,
});
if (!res.ok) throw await errorFromResponse(res, "获取模型列表失败");
const json = (await res.json()) as ModelsResponse;
const ids =
json?.data
?.map((m) => (typeof m?.id === "string" ? m.id : ""))
.filter(Boolean) ?? [];
return Array.from(new Set(ids)).sort((a, b) => a.localeCompare(b));
}这里把模型 ID 去重并排序,然后缓存到设置里:
props.settings.modelsCache = { ids, fetchedAt: Date.now() };如果当前还没有选择模型,会自动选第一个模型:
if (!props.settings.model && ids.length) props.settings.model = ids[0]!;聊天请求
聊天接口使用 OpenAI 兼容格式,请求地址为:
{Base URL}/chat/completions发送消息时,先把当前会话历史转成 OpenAI messages 格式:
const wireMessages = [...s.messages, { role: "user" as const, content: userText }]
.map((m) => ({
role: m.role,
content: m.content,
}));然后先尝试流式请求:
const stream = await streamChatCompletion({
baseUrl: state.settings.baseUrl,
apiKey: state.settings.apiKey,
model: state.settings.model,
messages: wireMessages,
signal: aborter.value.signal,
});每收到一段内容,就更新助手消息:
for await (const delta of stream) {
anyChunk = true;
acc += delta;
actions.updateMessage(s.id, assistant.id, acc);
}这样页面就能实现类似 ChatGPT 的逐字输出效果。
流式输出解析
流式输出使用的是 SSE 格式,服务端通常会返回类似这样的内容:
data: {"choices":[{"delta":{"content":"你好"}}]}
data: {"choices":[{"delta":{"content":"世界"}}]}
data: [DONE]项目里通过 ReadableStream 逐行读取:
async function* readSseLines(res: Response): AsyncGenerator<string> {
if (!res.body) return;
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n");
buffer = parts.pop() ?? "";
for (const part of parts) yield part;
}
buffer += decoder.decode();
if (buffer) {
for (const part of buffer.split("\n")) yield part;
}
}解析时只处理 data: 开头的行:
for await (const line of readSseLines(res)) {
const trimmed = line.trim();
if (!trimmed) continue;
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice("data:".length).trim();
if (payload === "[DONE]") return;
const chunk = JSON.parse(payload) as ChatCompletionChunk;
const content = chunk?.choices?.[0]?.delta?.content;
if (typeof content === "string" && content.length) {
yield content;
}
}非流式兜底
有些 OpenAI 兼容服务不支持 stream: true,所以项目做了非流式兜底。
如果流式请求没有返回任何内容,就再请求一次非流式接口:
if (!anyChunk) {
const once = await createChatCompletionOnce({
baseUrl: state.settings.baseUrl,
apiKey: state.settings.apiKey,
model: state.settings.model,
messages: wireMessages,
signal: aborter.value.signal,
});
acc = once;
actions.updateMessage(s.id, assistant.id, acc);
}非流式请求体:
body: JSON.stringify({
model: args.model,
messages: args.messages,
stream: false,
})读取结果:
const json = (await res.json()) as ChatCompletionChunk;
const content = json?.choices?.[0]?.message?.content;
if (typeof content !== "string") return "";
return content;停止生成
点击停止时,会调用 AbortController 中断请求:
const aborter = ref<AbortController | null>(null);
function stop() {
aborter.value?.abort();
aborter.value = null;
sending.value = false;
}发送请求时把 signal 传给 fetch:
signal: aborter.value.signal,如果捕获到 AbortError,页面会显示:
(已停止)文件导入
输入框支持导入文本文件,例如:
.txt
.md
.json
.csv
.log
.yaml
.yml文件大小限制为 512KB:
const MAX_TEXT_BYTES = 512 * 1024;
export async function readFileAsText(file: File) {
if (file.size > MAX_TEXT_BYTES) {
throw new Error(`文件过大(>${MAX_TEXT_BYTES / 1024}KB):${file.name}`);
}
return await file.text();
}导入后会拼接成提示词的一部分:
export function attachmentToPromptBlock(a: TextAttachment) {
return [
`\n\n[附件:${a.name} | ${a.type || "unknown"} | ${Math.round(a.size / 1024)}KB]\n`,
"```",
a.text,
"```",
].join("\n");
}上下文估算
页面右下角有一个上下文占用圆环。这里并没有引入真实 tokenizer,而是做了一个轻量估算:
function estimateTokens(value: string) {
const latinWords = value.match(/[A-Za-z0-9_]+/g)?.length ?? 0;
const cjkChars = value.match(/[\u3400-\u9fff]/g)?.length ?? 0;
const punctuation = value.match(/[^\sA-Za-z0-9_\u3400-\u9fff]/g)?.length ?? 0;
return Math.max(
0,
Math.ceil(latinWords * 1.35 + cjkChars + punctuation * 0.35),
);
}它不追求完全准确,只用于给用户一个大致参考。
上下文上限可以在设置里手动填写,默认是:
4096 tokens子目录部署
因为这个工具被放到了 Typecho 的子目录下:
/tools/openchat/index.html所以 Vite 构建时必须使用相对路径,否则资源会变成:
/assets/xxx.js这会导致页面部署到子目录后一片空白。
解决方法是在 vite.config.ts 中设置:
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
base: "./",
plugins: [vue()],
});这样构建后的资源路径会变成:
./assets/xxx.js
./assets/xxx.css无论放在 /tools/openchat/ 还是其他子目录,都可以正常加载。
构建与部署
本地构建:
.\npmw.cmd run build构建完成后,把 dist 目录里的内容上传到服务器目录:
/tools/openchat/最终访问:
https://blog.hxdxw.cn/tools/openchat/index.html使用注意
因为它是纯前端项目,所以有几个限制:
- API Key 会保存在浏览器
LocalStorage中。 - 模型服务必须允许浏览器跨域请求。
- 只适合个人、内网或可信环境使用。
- 如果要公开给多人使用,建议改成后端代理,避免 API Key 暴露在浏览器里。
总结
这个项目本质上是一个轻量版 OpenAI 兼容聊天客户端。
它没有复杂后端,也不需要数据库,只依赖浏览器能力完成配置、会话保存、模型获取和聊天请求。对于个人博客来说,它的优势是部署简单:构建一次,上传静态文件就能使用。
后续如果继续扩展,我可能会考虑加入:
- Markdown 渲染
- 代码高亮
- 导出会话
- 图片/多模态输入
- 后端代理模式
- 更多主题样式
目前这个版本已经可以满足一个博客内嵌 AI 对话工具的基本需求。
评论区:
还没有评论,快来抢沙发吧!