🆕 程式碼
src/auth/jwt.js
// src/auth/jwt.js
import jwt from "jsonwebtoken";
const SECRET = process.env.JWT_SECRET || "dev-secret-change-me";
const EXPIRES = "7d";
export function signToken({ userId, role = "viewer", tenant }) {
if (!userId || !tenant) throw new Error("userId / tenant 必填");
return jwt.sign({ sub: userId, role, tenant }, SECRET, { expiresIn: EXPIRES });
}
export function verifyToken(token) {
try {
return jwt.verify(token, SECRET);
} catch {
return null;
}
}
export function requireRole(user, allow = ["viewer"]) {
const order = { viewer: 0, editor: 1, admin: 2 };
const max = Math.max(...allow.map(r => order[r]));
return user && order[user.role] >= Math.min(max, 2);
}
src/api/withAuth.js
// src/api/withAuth.js
import { NextResponse } from "next/server";
import { verifyToken, requireRole } from "../auth/jwt.js";
export function withAuth(handler, allowRoles = ["viewer"]) {
return async (req, ctx) => {
const auth = req.headers.get("authorization") || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
const user = token ? verifyToken(token) : null;
if (!user || !requireRole(user, allowRoles)) {
return NextResponse.json({ ok:false, error: "Unauthorized" }, { status: 401 });
}
// 將 user 注入 ctx
ctx.user = user;
return handler(req, ctx);
};
}
app/api/auth/login/route.js
// app/api/auth/login/route.js
import { NextResponse } from "next/server";
import { signToken } from "../../../../src/auth/jwt.js";
export const runtime = "nodejs";
export async function POST(req) {
try {
const { userId, tenant, role = "viewer" } = await req.json();
if (!userId || !tenant) {
return NextResponse.json({ ok:false, error:"userId / tenant 必填" }, { status:400 });
}
const token = signToken({ userId, tenant, role });
return NextResponse.json({ ok:true, token, role, tenant });
} catch (e) {
return NextResponse.json({ ok:false, error: e.message }, { status:500 });
}
}
src/utils/tenantfs.js
// src/utils/tenantfs.js
import fs from "fs";
import path from "path";
export function sanitizeName(s="") {
// 僅允許 [a-z0-9-] 與 .md/.txt
if (!/^[a-z0-9-]+$/i.test(s)) throw new Error("名稱不合法");
return s;
}
export function isAllowedFile(name="") {
return /^[\w-. ]+.(md|txt)$/i.test(name);
}
export function baseDirs() {
const root = process.cwd();
return {
kbRoot: path.join(root, "knowledge"),
idxRoot: path.join(root, "data", "rag"),
};
}
export function ensureTenantNS(tenant, ns) {
const { kbRoot, idxRoot } = baseDirs();
const t = sanitizeName(tenant);
const n = sanitizeName(ns);
const kbDir = path.join(kbRoot, t, n);
const idxDir = path.join(idxRoot, t);
if (!fs.existsSync(kbDir)) fs.mkdirSync(kbDir, { recursive: true });
if (!fs.existsSync(idxDir)) fs.mkdirSync(idxDir, { recursive: true });
return { kbDir, idxFile: path.join(idxDir, ${n}.index.json
) };
}
export function listKBFiles(tenant, ns) {
const { kbDir } = ensureTenantNS(tenant, ns);
return fs.readdirSync(kbDir)
.filter(isAllowedFile)
.map(name => {
const p = path.join(kbDir, name);
const s = fs.statSync(p);
return { name, bytes: s.size, mtime: s.mtimeMs };
}).sort((a,b)=>b.mtime-a.mtime);
}
export function saveKBFile(tenant, ns, filename, buf) {
if (!isAllowedFile(filename)) throw new Error("僅允許 .md/.txt");
const { kbDir } = ensureTenantNS(tenant, ns);
const p = path.join(kbDir, path.basename(filename));
fs.writeFileSync(p, buf);
return p;
}
export function deleteKBFile(tenant, ns, filename) {
const { kbDir } = ensureTenantNS(tenant, ns);
const p = path.join(kbDir, path.basename(filename));
if (fs.existsSync(p)) fs.unlinkSync(p);
}
直接覆蓋 src/day16_rag_store.js
// src/day16_rag_store.js
import fs from "fs";
import path from "path";
import { openai } from "./aiClient.js";
import { chunkText, clean } from "./utils/text.js";
import { ensureTenantNS } from "./utils/tenantfs.js";
const EMBED_MODEL = process.env.OPENAI_EMBEDDING_MODEL || "text-embedding-3-small";
async function embedMany(texts=[]) {
if (!texts.length) return [];
const res = await openai.embeddings.create({ model: EMBED_MODEL, input: texts });
return res.data.map(d => d.embedding);
}
function listDocs(dir) {
return fs.readdirSync(dir).filter(f => /.md$|.txt$/i.test(f)).map(f => path.join(dir, f));
}
export async function buildIndex({ tenant, ns, chunkSize=800, overlap=80 }) {
const { kbDir, idxFile } = ensureTenantNS(tenant, ns);
const files = listDocs(kbDir);
if (!files.length) throw new Error("此命名空間沒有 .md/.txt 檔案");
const docs=[];
for (const fp of files) {
const raw = clean(fs.readFileSync(fp,"utf-8"));
const chs = chunkText(raw, chunkSize, overlap);
for (const c of chs) docs.push({ docId: path.basename(fp), chunkId: c.id, text: c.content });
}
const BATCH=64, vecs=[];
for (let i=0;i<docs.length;i+=BATCH) {
vecs.push(...await embedMany(docs.slice(i,i+BATCH).map(d=>d.text)));
}
const index = docs.map((d,i)=>({ id:${d.docId}#${d.chunkId}
, docId:d.docId, text:d.text, vector:vecs[i] }));
fs.writeFileSync(idxFile, JSON.stringify({ builtAt: Date.now(), model: EMBED_MODEL, index }, null, 2), "utf-8");
return { idxFile, count:index.length };
}
function cosine(a,b){let dot=0,na=0,nb=0;for(let i=0;i<a.length;i++){dot+=a[i]*b[i];na+=a[i]*a[i];nb+=b[i]*b[i]}return dot/(Math.sqrt(na)*Math.sqrt(nb)+1e-9)}
function loadIndex(tenant, ns){
const { idxFile } = ensureTenantNS(tenant, ns);
if (!fs.existsSync(idxFile)) throw new Error("索引不存在,請先重建");
return JSON.parse(fs.readFileSync(idxFile, "utf-8")).index || [];
}
export async function retrieve({ tenant, ns, query, topK=4 }) {
const index = loadIndex(tenant, ns);
const qv = (await embedMany([query]))[0];
const scored = index.map(it => ({ ...it, score: cosine(qv, it.vector) })).sort((a,b)=>b.score-a.score);
return scored.slice(0, topK);
}
export async function answerWithRAG({ tenant, ns, query, topK=4 }) {
const hits = await retrieve({ tenant, ns, query, topK });
const ctx = hits.map((h,i)=># 片段${i+1}(${h.docId}, score=${h.score.toFixed(3)})\n${h.text}
).join("\n\n");
const res = await openai.chat.completions.create({
model:"gpt-4o-mini", temperature:0.2,
messages:[
{ role:"system", content:"你是客服知識庫助理。僅依據提供片段回答;不足請明確說明。" },
{ role:"user", content:問題:${query}\n\n片段:\n${ctx}\n\n請用繁體中文:先結論、再步驟、最後列來源(片段序號與檔名)。
}
]
});
const answer = res.choices?.[0]?.message?.content?.trim() || "目前找不到足夠資訊。";
return { answer, sources: hits.map(h=>({id:h.id, docId:h.docId, score:h.score})) };
}
app/api/tenants/list/route.js
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { baseDirs } from "../../../../src/utils/tenantfs.js";
import { withAuth } from "../../../../src/api/withAuth.js";
export const runtime = "nodejs";
export const GET = withAuth(async (_req, ctx) => {
const { kbRoot } = baseDirs();
if (!fs.existsSync(kbRoot)) return NextResponse.json({ ok:true, tenants: [] });
const tenants = fs.readdirSync(kbRoot).filter(d => fs.statSync(path.join(kbRoot,d)).isDirectory());
return NextResponse.json({ ok:true, tenants });
}, ["viewer"]);
app/api/tenants/create/route.js
import { NextResponse } from "next/server";
import { ensureTenantNS } from "../../../../src/utils/tenantfs.js";
import { withAuth } from "../../../../src/api/withAuth.js";
export const runtime = "nodejs";
export const POST = withAuth(async (req, ctx) => {
if (ctx.user.role !== "admin") return NextResponse.json({ ok:false, error:"Admin only" }, { status:403 });
const { tenant, ns="default" } = await req.json();
ensureTenantNS(tenant, ns);
return NextResponse.json({ ok:true });
}, ["admin"]);
app/api/namespaces/list/route.js
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { baseDirs, sanitizeName } from "../../../../src/utils/tenantfs.js";
import { withAuth } from "../../../../src/api/withAuth.js";
export const runtime = "nodejs";
export const GET = withAuth(async (req, ctx) => {
const tenant = sanitizeName(new URL(req.url).searchParams.get("tenant") || ctx.user.tenant);
const root = path.join(baseDirs().kbRoot, tenant);
if (!fs.existsSync(root)) return NextResponse.json({ ok:true, namespaces: [] });
const namespaces = fs.readdirSync(root).filter(d => fs.statSync(path.join(root,d)).isDirectory());
return NextResponse.json({ ok:true, namespaces });
}, ["viewer"]);
app/api/namespaces/create/route.js
import { NextResponse } from "next/server";
import { ensureTenantNS, sanitizeName } from "../../../../src/utils/tenantfs.js";
import { withAuth } from "../../../../src/api/withAuth.js";
export const runtime = "nodejs";
export const POST = withAuth(async (req, ctx) => {
if (ctx.user.role === "viewer") return NextResponse.json({ ok:false, error:"Editor/Admin only" }, { status:403 });
const { tenant, ns } = await req.json();
ensureTenantNS(tenant || ctx.user.tenant, sanitizeName(ns));
return NextResponse.json({ ok:true });
}, ["editor","admin"]);
以下五個 route 使用 動態路由:app/api/kb/[tenant]/[ns]/...
upload
import { NextResponse } from "next/server";
import { withAuth } from "../../../../../src/api/withAuth.js";
import { saveKBFile } from "../../../../../src/utils/tenantfs.js";
export const runtime = "nodejs";
export const POST = withAuth(async (req, ctx) => {
if (ctx.user.role === "viewer") return NextResponse.json({ ok:false, error:"Editor/Admin only" }, { status:403 });
const { tenant, ns } = ctx.params;
const form = await req.formData();
const file = form.get("file");
const name = (form.get("name") || file?.name || "").trim();
if (!file || !name) return NextResponse.json({ ok:false, error:"缺少檔案或檔名" }, { status:400 });
const buf = Buffer.from(await file.arrayBuffer());
saveKBFile(tenant, ns, name, buf);
return NextResponse.json({ ok:true, name });
}, ["editor","admin"]);
list
import { NextResponse } from "next/server";
import { withAuth } from "../../../../../src/api/withAuth.js";
import { listKBFiles } from "../../../../../src/utils/tenantfs.js";
export const runtime = "nodejs";
export const GET = withAuth(async (req, ctx) => {
const { tenant, ns } = ctx.params;
const files = listKBFiles(tenant, ns);
return NextResponse.json({ ok:true, files });
}, ["viewer","editor","admin"]);
delete
import { NextResponse } from "next/server";
import { withAuth } from "../../../../../src/api/withAuth.js";
import { deleteKBFile } from "../../../../../src/utils/tenantfs.js";
export const runtime = "nodejs";
export const POST = withAuth(async (req, ctx) => {
if (ctx.user.role === "viewer") return NextResponse.json({ ok:false, error:"Editor/Admin only" }, { status:403 });
const { tenant, ns } = ctx.params;
const { name } = await req.json();
if (!name) return NextResponse.json({ ok:false, error:"name 必填" }, { status:400 });
deleteKBFile(tenant, ns, name);
return NextResponse.json({ ok:true });
}, ["editor","admin"]);
reindex
import { NextResponse } from "next/server";
import { withAuth } from "../../../../../src/api/withAuth.js";
import { buildIndex } from "../../../../../src/day16_rag_store.js";
export const runtime = "nodejs";
export const POST = withAuth(async (req, ctx) => {
if (ctx.user.role === "viewer") return NextResponse.json({ ok:false, error:"Editor/Admin only" }, { status:403 });
const { tenant, ns } = ctx.params;
const out = await buildIndex({ tenant, ns });
return NextResponse.json({ ok:true, ...out });
}, ["editor","admin"]);
ask
import { NextResponse } from "next/server";
import { withAuth } from "../../../../../src/api/withAuth.js";
import { answerWithRAG } from "../../../../../src/day16_rag_store.js";
export const runtime = "nodejs";
export const POST = withAuth(async (req, ctx) => {
const { tenant, ns } = ctx.params;
const { q } = await req.json();
if (!q || !q.trim()) return NextResponse.json({ ok:false, error:"q 必填" }, { status:400 });
const { answer, sources } = await answerWithRAG({ tenant, ns, query: q, topK: 4 });
return NextResponse.json({ ok:true, answer, sources });
}, ["viewer","editor","admin"]);
直接覆蓋 app/studio/page.tsx
"use client";
import { useEffect, useState } from "react";
type FileRow = { name:string; bytes:number; mtime:number };
type Src = { id:string; docId:string; score:number };
export default function Studio() {
// Auth
const [token, setToken] = useState<string|undefined>();
const [tenant, setTenant] = useState("");
const [role, setRole] = useState<"viewer"|"editor"|"admin">("viewer");
// Tenants / Namespaces
const [tenants, setTenants] = useState<string[]>([]);
const [nsList, setNsList] = useState<string[]>([]);
const [ns, setNs] = useState("faq");
// KB
const [files, setFiles] = useState<FileRow[]>([]);
const [loadingList, setLoadingList] = useState(false);
const [uploading, setUploading] = useState(false);
const [reindexing, setReindexing] = useState(false);
// QA
const [askQ, setAskQ] = useState("");
const [answer, setAnswer] = useState("");
const [sources, setSources] = useState<Src[]>([]);
const [asking, setAsking] = useState(false);
const [err, setErr] = useState("");
function authHeaders() {
return token ? { Authorization: Bearer ${token}
} : {};
}
async function login(userId: string, tenantName: string, roleSel: string) {
setErr(""); setAnswer("");
const r = await fetch("/api/auth/login", {
method: "POST", headers: { "Content-Type":"application/json" },
body: JSON.stringify({ userId, tenant: tenantName, role: roleSel })
});
const j = await r.json();
if (!j.ok) throw new Error(j.error);
setToken(j.token); setTenant(j.tenant); setRole(j.role);
localStorage.setItem("studio_token", j.token);
}
async function fetchTenants() {
const r = await fetch("/api/tenants/list", { headers: { ...authHeaders() }});
const j = await r.json();
if (!j.ok) throw new Error(j.error);
setTenants(j.tenants || []);
}
async function fetchNamespaces(t = tenant) {
if (!t) return;
const r = await fetch(/api/namespaces/list?tenant=${t}
, { headers: { ...authHeaders() }});
const j = await r.json();
if (!j.ok) throw new Error(j.error);
setNsList(j.namespaces || []);
if ((j.namespaces || []).length && !j.namespaces.includes(ns)) setNs(j.namespaces[0]);
}
async function refreshKB() {
if (!tenant || !ns) return;
setLoadingList(true);
try {
const r = await fetch(/api/kb/${tenant}/${ns}/list
, { headers: { ...authHeaders() }});
const j = await r.json(); if (!j.ok) throw new Error(j.error);
setFiles(j.files || []);
} catch(e:any){ setErr(e.message); }
finally { setLoadingList(false); }
}
useEffect(()=> {
const tk = localStorage.getItem("studio_token") || "";
if (tk) setToken(tk);
}, []);
useEffect(()=> { if (token) fetchTenants().catch(e=>setErr(e.message)); }, [token]);
useEffect(()=> { if (tenant && token) fetchNamespaces().catch(e=>setErr(e.message)); }, [tenant, token]);
useEffect(()=> { if (tenant && ns && token) refreshKB(); }, [tenant, ns, token]);
async function upload(e: React.FormEvent) {
e.preventDefault(); if (!tenant || !ns) return;
const form = e.currentTarget; const fd = new FormData(form);
setUploading(true); setErr("");
try {
const r = await fetch(/api/kb/${tenant}/${ns}/upload
, { method:"POST", body: fd, headers: { ...authHeaders() }});
const j = await r.json(); if (!j.ok) throw new Error(j.error);
form.reset(); await refreshKB();
} catch(e:any){ setErr(e.message); }
finally { setUploading(false); }
}
async function del(name:string){
if (!confirm(刪除 ${name} ?
)) return;
const r = await fetch(/api/kb/${tenant}/${ns}/delete
, { method:"POST", headers: { "Content-Type":"application/json", ...authHeaders() }, body: JSON.stringify({ name }) });
const j = await r.json(); if (!j.ok) return setErr(j.error);
refreshKB();
}
async function reindex() {
setReindexing(true); setErr("");
try {
const r = await fetch(/api/kb/${tenant}/${ns}/reindex
, { method:"POST", headers: { ...authHeaders() }});
const j = await r.json(); if (!j.ok) throw new Error(j.error);
alert(索引完成:${j.count} 片段
);
} catch(e:any){ setErr(e.message); }
finally { setReindexing(false); }
}
async function ask() {
setAsking(true); setErr(""); setAnswer(""); setSources([]);
try {
const r = await fetch(/api/kb/${tenant}/${ns}/ask
, {
method:"POST",
headers: { "Content-Type":"application/json", ...authHeaders() },
body: JSON.stringify({ q: askQ })
});
const j = await r.json(); if (!j.ok) throw new Error(j.error);
setAnswer(j.answer); setSources(j.sources || []);
} catch(e:any){ setErr(e.message); }
finally { setAsking(false); }
}
return (
<div className="navbar bg-base-100 rounded-box shadow">
<div className="flex-1 px-2 font-bold">Day 18|RAG Studio(多租戶/RBAC)</div>
{!token ? (
<form className="flex gap-2 px-2"
onSubmit={async (e)=>{ e.preventDefault();
const f = e.currentTarget as HTMLFormElement;
const userId = (f.elements.namedItem("userId") as HTMLInputElement).value;
const t = (f.elements.namedItem("tenant") as HTMLInputElement).value;
const role = (f.elements.namedItem("role") as HTMLSelectElement).value;
try { await login(userId, t, role); } catch(e:any){ setErr(e.message); }
}}>
<input name="userId" className="input input-bordered input-sm" placeholder="userId" required />
<input name="tenant" className="input input-bordered input-sm" placeholder="tenant(如 acme)" required />
<select name="role" className="select select-bordered select-sm">
<option>viewer</option><option>editor</option><option>admin</option>
</select>
<button className="btn btn-primary btn-sm" type="submit">登入</button>
</form>
) : (
<div className="flex items-center gap-2 px-2">
<span className="badge">{tenant}</span>
<span className="badge badge-outline">{role}</span>
<button className="btn btn-ghost btn-sm" onClick={()=>{ localStorage.removeItem("studio_token"); setToken(undefined); }}>登出</button>
</div>
)}
</div>
{err && <div className="alert alert-error"><span>{err}</span></div>}
{token && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* 左:管理 */}
<div className="card bg-base-100 shadow">
<div className="card-body space-y-4">
<div className="flex items-center gap-2">
<select className="select select-bordered" value={tenant} onChange={e=>setTenant(e.target.value)}>
<option value="">選擇 Tenant</option>
{tenants.map(t=><option key={t} value={t}>{t}</option>)}
</select>
<select className="select select-bordered" value={ns} onChange={e=>setNs(e.target.value)}>
<option value="">選擇 Namespace</option>
{nsList.map(x=><option key={x} value={x}>{x}</option>)}
</select>
{(role !== "viewer") && (
<button className={`btn btn-outline btn-sm ${reindexing?"btn-disabled":""}`} onClick={reindex}>
{reindexing ? "索引中..." : "重建索引"}
</button>
)}
</div>
{(role !== "viewer") && (
<form className="flex items-center gap-3" onSubmit={upload}>
<input type="file" name="file" accept=".md,.txt" className="file-input file-input-bordered" required />
<input type="text" name="name" placeholder="儲存檔名(例:refund.md)" className="input input-bordered" required />
<button className={`btn btn-primary ${uploading?"btn-disabled":""}`} type="submit">
{uploading ? "上傳中..." : "上傳"}
</button>
</form>
)}
<div className="overflow-x-auto">
<table className="table">
<thead><tr><th>檔名</th><th>大小</th><th>修改時間</th><th></th></tr></thead>
<tbody>
{loadingList ? (
<tr><td colSpan={4}>讀取中...</td></tr>
) : files.length===0 ? (
<tr><td colSpan={4}>尚無資料</td></tr>
) : files.map(f=>(
<tr key={f.name}>
<td>{f.name}</td>
<td>{(f.bytes/1024).toFixed(1)} KB</td>
<td>{new Date(f.mtime).toLocaleString()}</td>
<td>
{(role !== "viewer") && (
<button className="btn btn-ghost btn-xs" onClick={()=>del(f.name)}>刪除</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
{/* 右:問答 */}
<div className="card bg-base-100 shadow">
<div className="card-body space-y-3">
<h2 className="card-title">RAG 問答</h2>
<textarea className="textarea textarea-bordered h-32" placeholder="輸入你的問題"
value={askQ} onChange={e=>setAskQ(e.target.value)} />
<button className={`btn btn-primary ${asking?"btn-disabled":""}`} onClick={ask}>
{asking ? "查詢中..." : "詢問"}
</button>
{answer && (
<div className="prose max-w-none">
<h3>回答</h3>
<div className="whitespace-pre-wrap">{answer}</div>
<h3>來源</h3>
<ul className="list-disc pl-6">
{sources.map((s,i)=><li key={i}>{s.docId}({s.score.toFixed(2)})</li>)}
</ul>
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
}