🆕 程式碼
1) app/api/kb/[tenant]/[ns]/clear-cache/route.js
import { NextResponse } from "next/server";
import { withAuth } from "../../../../../src/api/withAuth.js";
import { clearCache } from "../../../../../src/day29_cache.js";
export const runtime = "nodejs";
export const POST = withAuth(async (req, ctx) => {
clearCache();
return NextResponse.json({ ok:true, cleared:true });
}, ["editor","admin"]);
👉 抽象化策略 + 快取提示 + Sources/章節/Highlight 全部顯示。
"use client";
import { useState } from "react";
import { authHeaders } from "@/lib/auth";
export default function StudioPage() {
const [tenant, setTenant] = useState("acme");
const [ns, setNs] = useState("faq");
const [query, setQuery] = useState("");
const [strategy, setStrategy] = useState("default");
const [answer, setAnswer] = useState("");
const [answerHtml, setAnswerHtml] = useState("");
const [sources, setSources] = useState<any[]>([]);
const [chunks, setChunks] = useState<any[]>([]);
const [sections, setSections] = useState<any[]>([]);
const [cached, setCached] = useState(false);
const [loading, setLoading] = useState(false);
async function ask() {
setLoading(true);
setAnswer(""); setAnswerHtml(""); setSources([]); setChunks([]); setSections([]); setCached(false);
const r = await fetch(`/api/kb/${tenant}/${ns}/ask`, {
method:"POST",
headers: { "Content-Type":"application/json", ...authHeaders() },
body: JSON.stringify({ q: query, strategy, highlight:true })
});
const j = await r.json();
setLoading(false);
if (!j.ok) { setAnswer("⚠️ 錯誤: " + j.error); return; }
setAnswer(j.answer);
setAnswerHtml(j.answerHtml || "");
setSources(j.sources || []);
setChunks(j.sourceChunks || []);
setSections(j.sections || []);
setCached(j.cached || false);
}
async function clearCache() {
await fetch(`/api/kb/${tenant}/${ns}/clear-cache`, { method:"POST", headers:authHeaders() });
alert("快取已清空");
}
return (
<div className="p-6 space-y-4">
<h1 className="text-xl font-bold">🔎 知識庫 Demo</h1>
<div className="flex gap-3">
<input value={tenant} onChange={e=>setTenant(e.target.value)} className="input input-bordered input-sm w-32" />
<input value={ns} onChange={e=>setNs(e.target.value)} className="input input-bordered input-sm w-32" />
<select className="select select-bordered select-sm" value={strategy} onChange={e=>setStrategy(e.target.value)}>
<option value="default">Default</option>
<option value="section">Section-first</option>
<option value="qrewrite">Query Rewrite</option>
<option value="hybrid">Hybrid</option>
<option value="rerank">Rerank</option>
<option value="xlingual">X-Lingual</option>
</select>
<button onClick={clearCache} className="btn btn-xs btn-error">清除快取</button>
</div>
<div className="flex gap-2">
<input value={query} onChange={e=>setQuery(e.target.value)} placeholder="輸入問題..." className="input input-bordered flex-1" />
<button disabled={loading} onClick={ask} className="btn btn-primary">{loading?"查詢中...":"發問"}</button>
</div>
{answer && (
<div className="p-4 border rounded bg-base-100">
<div className="flex items-center gap-2">
<h2 className="font-semibold">回答</h2>
{cached && <span className="badge badge-success">⚡ 快取</span>}
<span className="badge">{strategy}</span>
</div>
<div className="mt-2 prose" dangerouslySetInnerHTML={{ __html: answerHtml || answer }} />
</div>
)}
{sections.length > 0 && (
<div className="mt-4">
<h3 className="font-semibold">相關章節</h3>
<ul className="list-disc pl-6">
{sections.map(s => (
<li key={`${s.docId}#${s.slug}`}>
<code>{s.docId}</code> - {s.title} <span className="text-xs opacity-60">score {s.score?.toFixed(2)}</span>
</li>
))}
</ul>
</div>
)}
{sources.length > 0 && (
<div className="mt-4">
<h3 className="font-semibold">來源片段</h3>
<ul className="list-disc pl-6">
{sources.map((s,i)=>(
<li key={s.id}>
<span className="badge mr-2">#{i+1}</span>
<code>{s.docId}</code> ({s.id}) - score {s.score?.toFixed(2)}
</li>
))}
</ul>
</div>
)}
</div>
);
}
/studio
▶️ 驗收流程
啟動 /studio → 選擇不同策略 → 發問。
可以看到:答案、Highlight、章節 Breadcrumb、來源片段、快取提示。
用「退貨要多久?」測試:
Default → 一般結果。
Section-first → 顯示章節清單。
QRewrite → 展示等價問法擴展。
Hybrid → 命中數字代號。
Rerank → 排序更精準。
X-Lingual → 輸入中文、命中英文 KB。
再次提問 → ⚡ 快取生效。