给博客添加一个私有化的 AI 对话功能

最近我一直在琢磨,怎么能让我的博客更有趣一点。除了写文章,还能不能提供一些更具互动性的功能?很自然地,我想到了现在大火的 AI 对话。
市面上有很多现成的第三方聊天插件,但它们或多或少都有一些问题:要么是界面样式丑陋且难以定制,要么是需要将访客数据交给第三方,再或者就是价格不菲。作为一个喜欢折腾的开发者,我更倾向于一个能完全由自己掌控的解决方案。
于是,我花了点时间,为我的博客打造了一个私有化的 AI 对话功能。它主要由两部分组成:
- 一个部署在 Cloudflare Workers 上的通用 AI 代理。
- 一个用 Svelte 编写的、功能丰富的聊天前端组件。
这个组合的好处是显而易见的:
- 隐私和控制:API Key 和聊天记录完全由用户浏览器端掌控,不经过我的服务器。
- 高度灵活:它支持任何与 OpenAI API 格式兼容的 AI 服务,无论是 OpenAI 官方、Azure、Google Gemini,还是自建的本地模型,只要 API 接口兼容,就能无缝切换。
- 界面美观:前端组件完全自定义,可以深度融入博客的整体风格。
接下来,我将详细介绍如何将这个功能集成到你自己的网站中。
后端:一个解决 CORS 的通用 AI 代理
如果你尝试过直接在前端(浏览器)调用 AI 服务的 API,你大概率会遇到一个经典问题:CORS (跨域资源共享) 错误。出于安全考虑,浏览器会阻止你的网站向一个不同的域名(比如 api.openai.com
)发送请求。
为了解决这个问题,我编写了一个非常简单的 Cloudflare Worker,它的作用就像一个中间人或代理,前端将请求发送给这个 Worker,Worker 再把请求转发给真正的 AI API。因为 Worker 运行在云端服务器上,所以它不受浏览器同源策略的限制。
这个 Worker 的设计非常纯粹和无状态(stateless),它本身不存储任何 API Key 或配置。所有的配置都由前端在每次请求时动态提供。
如何使用这个 Worker?
-
部署:将
ai-worker/index.ts
的代码部署到你自己的 Cloudflare Workers 服务上,你会得到一个唯一的 Worker URL,例如https://my-ai-proxy.workers.dev
。 -
配置请求头:当你的前端应用调用这个 Worker 时,必须在请求头(Headers)中包含两个关键信息:
Authorization
: 你的 AI 服务提供商的 API Key。格式为Bearer <YOUR_API_KEY>
。X-API-URL
: 你想要调用的目标 AI API 的完整地址。例如https://api.openai.com/v1/chat/completions
。
这个 Worker 会原封不动地将你的请求体(包含模型名称、消息历史等)和 Authorization
头转发给 X-API-URL
指定的地址,并将收到的响应以流式(Streaming)的方式传回给前端。
前端:功能强大的 Svelte 聊天组件
前端部分是一个独立的 Svelte 组件 (src/components/Chat.svelte
),它负责渲染整个聊天界面和处理用户交互。
它包含了很多实用的功能:
- 配置面板:一个可展开的调试/配置面板,用于设置 Worker 地址和 AI 参数。
- 本地存储:所有配置都会自动保存在浏览器的
localStorage
中,用户只需配置一次。 - Markdown & 代码高亮:AI 的回复会被解析为 Markdown,代码块也能正确高亮。
- 图片粘贴:支持直接从剪贴板粘贴图片并发送给支持多模态输入的模型。
- 流式响应:逐字显示 AI 的回复,带来流畅的打字机效果。
如何使用这个组件?
-
创建页面:首先,你需要一个页面来承载这个聊天组件。在 Astro 项目中,可以创建一个
src/pages/chat.astro
文件。---import Layout from '@/layouts/Layout.astro';import Chat from '@/components/Chat.svelte';---<Layout seo={{ title: 'AI Chat' }}><div class="chat-container-wrapper"><Chat client:load /></div></Layout><style is:global>/* 确保聊天界面占满整个屏幕 */html, body, #__astro, .main-content {height: 100%;padding: 0 !important;max-width: 100% !important;}.chat-container-wrapper {height: 100%;overflow: hidden;}</style> -
安装依赖:这个组件依赖
marked
和dompurify
来处理和净化 AI 的 Markdown 回复。Terminal window pnpm install marked dompurify -
配置和使用:当用户第一次访问聊天页面时,界面会提示他们进行配置。
- 点击右上角的 “显示调试” 按钮,展开配置面板。
- Worker URL:填入上一步你部署好的 Cloudflare Worker 的地址。
- API Key:填入你的 AI 服务提供商的 API Key。这个 Key 只会存在你的浏览器本地,非常安全。
- API Endpoint:填入目标 API 地址。
- Model:指定要使用的模型名称,例如
gpt-4o
或glm-4.5
。 - System Prompt:可选,可以设置一个系统提示来定义 AI 的角色和行为。
完成配置后,就可以开始对话了。
总结
通过“Cloudflare Worker 代理 + Svelte 前端组件”这个组合,我成功地为网站添加了一个功能完善、高度可控且注重隐私的 AI 对话功能。它不仅解决了跨域调用的技术难题,还提供了远超第三方插件的灵活性和定制性。
文件内容
直接给你抄作业😡😋
src/pages/chat.astro---import Layout from '@/layouts/Layout.astro';import Chat from '@/components/Chat.svelte';
const seo = { title: 'AI Chat', description: 'An interactive chat interface powered by AI.',};---
<Layout {seo}> <div class="page-wrapper"> <header class="page-header"> <a href="https://www.497995.xyz" class="home-link"> ← 返回主页 </a> </header> <div class="chat-container-wrapper"> <Chat client:load /> </div> </div></Layout><style is:global> /* Ensure body and container are full height */ html, body, #__astro { height: 100%; } /* Override layout padding for this page */ .main-content { padding: 0 !important; max-width: 100% !important; height: 100%; } .page-wrapper { position: relative; /* Needed for the ::before pseudo-element */ z-index: 0; display: flex; flex-direction: column; height: 100%; background-color: transparent; } .page-wrapper::before { content: ''; position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-image: url('https://game.gtimg.cn/images/yxzj/img201606/skin/hero-info/197/197-bigskin-6.jpg'); background-position: center; background-size: cover; background-repeat: no-repeat; opacity: 0; pointer-events: none; z-index: -1; transition: opacity 0.5s ease-in-out; } .page-wrapper.bg-loaded::before { opacity: 0.15; } .page-header { padding: 0.75rem 1.5rem; /* Make header transparent to see the background */ background-color: rgba(255, 255, 255, 0.7); backdrop-filter: saturate(180%) blur(10px); -webkit-backdrop-filter: saturate(180%) blur(10px); border-bottom: 1px solid rgba(229, 231, 235, 0.5); flex-shrink: 0; position: relative; /* Ensure header is above the ::before pseudo-element */ z-index: 1; } .home-link { text-decoration: none; color: #374151; font-weight: 500; } .home-link:hover { color: #3b82f6; } .chat-container-wrapper { flex-grow: 1; overflow: hidden; position: relative; /* Ensure chat content is above the ::before pseudo-element */ z-index: 1; }</style>
<script> document.addEventListener('astro:page-load', () => { const bgUrl = 'https://game.gtimg.cn/images/yxzj/img201606/skin/hero-info/197/197-bigskin-6.jpg'; const pageWrapper = document.querySelector('.page-wrapper') as HTMLElement;
if (pageWrapper) { const img = new Image(); img.onload = () => { pageWrapper.classList.add('bg-loaded'); }; img.src = bgUrl; } });</script>```
(bgUrl替换成自己的背景)index.js````astroexport default { async fetch(request) { // Handle CORS preflight requests sent by browsers. if (request.method === 'OPTIONS') { return handleOptions(request); }
if (request.method !== 'POST') { return new Response(JSON.stringify({ error: 'Method Not Allowed' }), { status: 405, headers: corsHeaders(), }); }
// 1. Extract required headers from the client's request. const apiKey = request.headers.get('Authorization'); const apiUrl = request.headers.get('X-API-URL');
// 2. Validate that the required headers are present. if (!apiKey) { return new Response(JSON.stringify({ error: 'Request missing Authorization header' }), { status: 400, headers: corsHeaders({ 'Content-Type': 'application/json' }), }); }
if (!apiUrl) { return new Response(JSON.stringify({ error: 'Request missing X-API-URL header' }), { status: 400, headers: corsHeaders({ 'Content-Type': 'application/json' }), }); }
// 3. Forward the request to the target AI service. // The request body from the client is passed through directly. const proxyRequest = new Request(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': apiKey, }, body: request.body, // @ts-ignore duplex: 'half', // Necessary for streaming request bodies. });
try { const response = await fetch(proxyRequest);
// 4. Stream the response back to the client. // This handles both successful streaming responses and error responses from the target API. const { readable, writable } = new TransformStream(); response.body?.pipeTo(writable);
const responseHeaders = corsHeaders({ 'Content-Type': response.headers.get('Content-Type') || 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', });
return new Response(readable, { status: response.status, statusText: response.statusText, headers: responseHeaders, });
} catch (error) { console.error('Failed to connect to the target API:', error); return new Response(JSON.stringify({ error: 'Failed to connect to the target API.' }), { status: 502, // Bad Gateway headers: corsHeaders({ 'Content-Type': 'application/json' }), }); } },};
// Utility to generate common CORS headers for responses.const corsHeaders = (additionalHeaders = {}) => { return new Headers({ 'Access-Control-Allow-Origin': '*', // Allow any origin to call this proxy 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-URL', ...additionalHeaders, });};
// Handles CORS preflight (OPTIONS) requests.function handleOptions(request) { const requestHeaders = request.headers; if ( requestHeaders.get('Origin') !== null && requestHeaders.get('Access-Control-Request-Method') !== null && requestHeaders.get('Access-Control-Request-Headers') !== null ) { // Standard CORS preflight response. return new Response(null, { headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-URL', 'Access-Control-Max-Age': '86400', // Cache preflight response for 24 hours }, }); } else { // Standard OPTIONS response. return new Response(null, { headers: { Allow: 'POST, OPTIONS', }, }); }}