火芯のOpenChat:一个纯前端 AI 对话工具的实现记录

最近给新加了一个在线小工具:火芯のOpenChat

在线体验地址https://blog.hxdxw.cn/tools/openchat/index.html

它是一个基于 Vue 3 + TailwindCSS 写的纯前端 AI 对话页面,主要目标是:测试模型使用,只要兼容 OpenAI 接口,就可以在网页里配置 Base URLAPI 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

使用注意

因为它是纯前端项目,所以有几个限制:

  1. API Key 会保存在浏览器 LocalStorage 中。
  2. 模型服务必须允许浏览器跨域请求。
  3. 只适合个人、内网或可信环境使用。
  4. 如果要公开给多人使用,建议改成后端代理,避免 API Key 暴露在浏览器里。

总结

这个项目本质上是一个轻量版 OpenAI 兼容聊天客户端。

它没有复杂后端,也不需要数据库,只依赖浏览器能力完成配置、会话保存、模型获取和聊天请求。对于个人博客来说,它的优势是部署简单:构建一次,上传静态文件就能使用。

后续如果继续扩展,我可能会考虑加入:

  • Markdown 渲染
  • 代码高亮
  • 导出会话
  • 图片/多模态输入
  • 后端代理模式
  • 更多主题样式

目前这个版本已经可以满足一个博客内嵌 AI 对话工具的基本需求。

评论区:

还没有评论,快来抢沙发吧!