2004 字
10 分钟

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

不想用第三方聊天插件?本文将教你如何使用 Cloudflare Worker 和 Svelte,为你的网站快速集成一个完全由你掌控、支持任何 OpenAI 兼容 API 的 AI 对话机器人!
2025-08-10
Views: ...

最近我一直在琢磨,怎么能让我的博客更有趣一点。除了写文章,还能不能提供一些更具互动性的功能?很自然地,我想到了现在大火的 AI 对话。

市面上有很多现成的第三方聊天插件,但它们或多或少都有一些问题:要么是界面样式丑陋且难以定制,要么是需要将访客数据交给第三方,再或者就是价格不菲。作为一个喜欢折腾的开发者,我更倾向于一个能完全由自己掌控的解决方案。

于是,我花了点时间,为我的博客打造了一个私有化的 AI 对话功能。它主要由两部分组成:

  1. 一个部署在 Cloudflare Workers 上的通用 AI 代理。
  2. 一个用 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?#

  1. 部署:将 ai-worker/index.ts 的代码部署到你自己的 Cloudflare Workers 服务上,你会得到一个唯一的 Worker URL,例如 https://my-ai-proxy.workers.dev

  2. 配置请求头:当你的前端应用调用这个 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 的回复,带来流畅的打字机效果。

如何使用这个组件?#

  1. 创建页面:首先,你需要一个页面来承载这个聊天组件。在 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>
  2. 安装依赖:这个组件依赖 markeddompurify 来处理和净化 AI 的 Markdown 回复。

    Terminal window
    pnpm install marked dompurify
  3. 配置和使用:当用户第一次访问聊天页面时,界面会提示他们进行配置。

    • 点击右上角的 “显示调试” 按钮,展开配置面板。
    • Worker URL:填入上一步你部署好的 Cloudflare Worker 的地址。
    • API Key:填入你的 AI 服务提供商的 API Key。这个 Key 只会存在你的浏览器本地,非常安全。
    • API Endpoint:填入目标 API 地址。
    • Model:指定要使用的模型名称,例如 gpt-4oglm-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">
&larr; 返回主页
</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
````astro
export 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',
},
});
}
}
给博客添加一个私有化的 AI 对话功能
https://www.497995.xyz/posts/self-hosted-ai-chat/
作者
或许是一只龙
发布于
2025-08-10
许可协议
CC BY-NC-SA 4.0