mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 05:30:21 +00:00
feat: add docs chat prototype and related scripts
- Introduced a minimal documentation chatbot that builds a search index from markdown files and serves responses via an API. - Added scripts for building the index and serving the chat API. - Updated package.json with new commands for chat index building and serving. - Created a new Vercel configuration file for deployment. - Added a README for the docs chat prototype detailing usage and integration.
This commit is contained in:
193
scripts/docs-chat/serve.mjs
Normal file
193
scripts/docs-chat/serve.mjs
Normal file
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Minimal docs-chat API.
|
||||
* Env: OPENAI_API_KEY, DOCS_CHAT_INDEX, PORT
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import http from "node:http";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const defaultIndex = path.join(__dirname, "search-index.json");
|
||||
const indexPath = process.env.DOCS_CHAT_INDEX || defaultIndex;
|
||||
const port = Number(process.env.PORT || 3001);
|
||||
|
||||
let index = null;
|
||||
|
||||
function loadIndex() {
|
||||
if (index) return index;
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
console.error(
|
||||
`Missing index at ${indexPath}. Run: node scripts/docs-chat/build-index.mjs --out ${defaultIndex}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
index = JSON.parse(fs.readFileSync(indexPath, "utf8"));
|
||||
return index;
|
||||
}
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
};
|
||||
|
||||
function sendJson(res, status, body) {
|
||||
res.writeHead(status, { ...corsHeaders, "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function scoreChunk(query, chunk) {
|
||||
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const text = `${chunk.title} ${chunk.content}`.toLowerCase();
|
||||
let score = 0;
|
||||
for (const word of words) {
|
||||
if (word.length < 2) continue;
|
||||
if (text.includes(word)) score += 1;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
function retrieve(query, limit = 8) {
|
||||
const { chunks } = loadIndex();
|
||||
const scored = chunks.map((chunk) => ({
|
||||
chunk,
|
||||
score: scoreChunk(query, chunk),
|
||||
}));
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
return scored
|
||||
.filter((item) => item.score > 0)
|
||||
.slice(0, limit)
|
||||
.map((item) => item.chunk);
|
||||
}
|
||||
|
||||
async function streamOpenAI(systemPrompt, userMessage, onToken) {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) throw new Error("OPENAI_API_KEY is required for /chat");
|
||||
|
||||
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "gpt-4o-mini",
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userMessage },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
const errorText = await res.text();
|
||||
throw new Error(`OpenAI ${res.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
for await (const chunk of res.body) {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("data:")) continue;
|
||||
const data = trimmed.slice(5).trim();
|
||||
if (data === "[DONE]") return;
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
const delta = json.choices?.[0]?.delta?.content;
|
||||
if (delta) onToken(delta);
|
||||
} catch {
|
||||
// Ignore malformed SSE lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChat(req, res) {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
let message = "";
|
||||
try {
|
||||
message = JSON.parse(body || "{}").message;
|
||||
} catch {
|
||||
sendJson(res, 400, { error: "Invalid JSON" });
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== "string") {
|
||||
sendJson(res, 400, { error: "message required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks = retrieve(message);
|
||||
if (chunks.length === 0) {
|
||||
res.writeHead(200, {
|
||||
...corsHeaders,
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
});
|
||||
res.end(
|
||||
"I couldn't find relevant documentation excerpts for that question. Try rephrasing or search the docs."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const context = chunks
|
||||
.map(
|
||||
(chunk) =>
|
||||
`[${chunk.title}](${chunk.url})\n${chunk.content.slice(0, 1200)}`
|
||||
)
|
||||
.join("\n\n---\n\n");
|
||||
|
||||
const systemPrompt =
|
||||
"You are a helpful assistant for OpenClaw documentation. " +
|
||||
"Answer only from the provided documentation excerpts. " +
|
||||
"If the answer is not in the excerpts, say so and suggest checking the docs. " +
|
||||
"Cite sources by name or URL when relevant.\n\nDocumentation excerpts:\n" +
|
||||
context;
|
||||
|
||||
res.writeHead(200, {
|
||||
...corsHeaders,
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Transfer-Encoding": "chunked",
|
||||
});
|
||||
|
||||
try {
|
||||
await streamOpenAI(systemPrompt, message, (token) => {
|
||||
res.write(token);
|
||||
});
|
||||
res.end();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.end("\n\n[Error contacting OpenAI]");
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204, corsHeaders);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && (req.url === "/" || req.url === "/health")) {
|
||||
loadIndex();
|
||||
sendJson(res, 200, { ok: true, chunks: index.chunks.length });
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && req.url === "/chat") {
|
||||
await handleChat(req, res);
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, { error: "Not found" });
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
loadIndex();
|
||||
console.error(
|
||||
`docs-chat API running at http://localhost:${port} (chunks: ${index.chunks.length})`
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user