feat(kb): Obsidian 式文库 —— 笔记浏览 + [[双链]] + 反向链接(Tab 化)
把知识库做出 Obsidian 感:入库的每份文件/笔记留原文,可浏览、可读、可互链。 - store: sundynix_doc(owner+kb+name 唯一,存原文),SaveDoc(OnConflict 覆盖)/ListVault。 - gateway: runIngest 留存原文(文件用文件名、文本用首行作笔记名);GET /kb/vault?kb= 取文库(owner 隔离)。 - Markdown 组件:解析 [[名称]] / [[名称|别名]] → onLink 可点(Obsidian 双链)。 - KbView 改 Tab(入库 / 文库 / 检索 / 图谱): - 文库 = 左文档列表 + 右 Markdown 笔记([[双链]]点击跳转)+ 反向链接面板(扫全库 [[本笔记]])。 - 检索、图谱各占整页;图谱放大到 460。 验证(Preview):入两条带 [[双链]] 的笔记 → 文库列出 2 篇 → 打开「项目A概述」渲染出可点的 [[模块X]][[模块Y]] + 反向链接显示「模块X」→ 点 [[模块X]] 跳转到该笔记、其 [[项目A概述]] 亦可点。 curl 证隔离:alice 取 wt 的 vault → 空。tsc+vite+gateway build 通过;重建 .app 重启窗口。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,29 +1,38 @@
|
|||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
|
|
||||||
// 轻量 Markdown 渲染 —— 零依赖、行级解析,覆盖报告正文用到的子集:
|
// 轻量 Markdown 渲染 —— 零依赖、行级解析,覆盖报告正文用到的子集:
|
||||||
// # / ## / ### 标题、**粗** *斜* `码`、- 与 1. 列表、> 引用、--- 分隔、段落。
|
// # / ## / ### 标题、**粗** *斜* `码`、[[双链]]、- 与 1. 列表、> 引用、--- 分隔、段落。
|
||||||
// 流式安全:每个 token 重渲染,残缺语法也能容忍。
|
// 流式安全:每个 token 重渲染,残缺语法也能容忍。onLink 非空时 [[名称]] 可点(Obsidian 式)。
|
||||||
|
|
||||||
// 行内:把一段文本切成 **粗** / *斜* / `码` / 纯文本节点。
|
// 行内:把一段文本切成 [[双链]] / **粗** / *斜* / `码` / 纯文本节点。
|
||||||
function inline(text: string, keyPrefix: string): ReactNode[] {
|
function inline(text: string, keyPrefix: string, onLink?: (name: string) => void): ReactNode[] {
|
||||||
const out: ReactNode[] = [];
|
const out: ReactNode[] = [];
|
||||||
const re = /(\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g;
|
const re = /(\[\[([^\]]+)\]\]|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g;
|
||||||
let last = 0;
|
let last = 0;
|
||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while ((m = re.exec(text)) !== null) {
|
while ((m = re.exec(text)) !== null) {
|
||||||
if (m.index > last) out.push(text.slice(last, m.index));
|
if (m.index > last) out.push(text.slice(last, m.index));
|
||||||
const key = `${keyPrefix}-${i++}`;
|
const key = `${keyPrefix}-${i++}`;
|
||||||
if (m[2] !== undefined) out.push(<strong key={key} className="font-semibold text-slate-100">{m[2]}</strong>);
|
if (m[2] !== undefined) {
|
||||||
else if (m[3] !== undefined) out.push(<code key={key} className="rounded bg-ink-800 px-1 py-0.5 font-mono text-[0.85em] text-accent-400">{m[3]}</code>);
|
const name = m[2].split("|")[0].trim(); // 支持 [[名称|别名]]
|
||||||
else if (m[4] !== undefined) out.push(<em key={key} className="italic text-slate-300">{m[4]}</em>);
|
const label = m[2].includes("|") ? m[2].split("|")[1].trim() : m[2];
|
||||||
|
out.push(
|
||||||
|
<button key={key} onClick={() => onLink?.(name)} className="rounded bg-brand/10 px-1 text-brand-400 hover:bg-brand/20">
|
||||||
|
{label}
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
|
} else if (m[3] !== undefined) out.push(<strong key={key} className="font-semibold text-slate-100">{m[3]}</strong>);
|
||||||
|
else if (m[4] !== undefined) out.push(<code key={key} className="rounded bg-ink-800 px-1 py-0.5 font-mono text-[0.85em] text-accent-400">{m[4]}</code>);
|
||||||
|
else if (m[5] !== undefined) out.push(<em key={key} className="italic text-slate-300">{m[5]}</em>);
|
||||||
last = re.lastIndex;
|
last = re.lastIndex;
|
||||||
}
|
}
|
||||||
if (last < text.length) out.push(text.slice(last));
|
if (last < text.length) out.push(text.slice(last));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Markdown({ text, className }: { text: string; className?: string }) {
|
export function Markdown({ text, className, onLink }: { text: string; className?: string; onLink?: (name: string) => void }) {
|
||||||
|
const il = (t: string, k: string) => inline(t, k, onLink);
|
||||||
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
||||||
const blocks: ReactNode[] = [];
|
const blocks: ReactNode[] = [];
|
||||||
let para: string[] = [];
|
let para: string[] = [];
|
||||||
@@ -34,7 +43,7 @@ export function Markdown({ text, className }: { text: string; className?: string
|
|||||||
if (para.length) {
|
if (para.length) {
|
||||||
blocks.push(
|
blocks.push(
|
||||||
<p key={`p${k++}`} className="my-2 leading-relaxed text-slate-300">
|
<p key={`p${k++}`} className="my-2 leading-relaxed text-slate-300">
|
||||||
{inline(para.join(" "), `p${k}`)}
|
{il(para.join(" "), `p${k}`)}
|
||||||
</p>,
|
</p>,
|
||||||
);
|
);
|
||||||
para = [];
|
para = [];
|
||||||
@@ -44,7 +53,7 @@ export function Markdown({ text, className }: { text: string; className?: string
|
|||||||
if (list) {
|
if (list) {
|
||||||
const items = list.items.map((it, idx) => (
|
const items = list.items.map((it, idx) => (
|
||||||
<li key={idx} className="leading-relaxed text-slate-300">
|
<li key={idx} className="leading-relaxed text-slate-300">
|
||||||
{inline(it, `li${k}-${idx}`)}
|
{il(it, `li${k}-${idx}`)}
|
||||||
</li>
|
</li>
|
||||||
));
|
));
|
||||||
blocks.push(
|
blocks.push(
|
||||||
@@ -76,14 +85,14 @@ export function Markdown({ text, className }: { text: string; className?: string
|
|||||||
flushList();
|
flushList();
|
||||||
const lvl = h[1].length;
|
const lvl = h[1].length;
|
||||||
const cls = lvl === 1 ? "mt-1 mb-3 text-xl font-bold text-slate-100" : lvl === 2 ? "mt-4 mb-2 text-base font-semibold text-slate-100" : "mt-3 mb-1 text-sm font-semibold text-slate-200";
|
const cls = lvl === 1 ? "mt-1 mb-3 text-xl font-bold text-slate-100" : lvl === 2 ? "mt-4 mb-2 text-base font-semibold text-slate-100" : "mt-3 mb-1 text-sm font-semibold text-slate-200";
|
||||||
const content = inline(h[2], `h${k}`);
|
const content = il(h[2], `h${k}`);
|
||||||
blocks.push(lvl === 1 ? <h1 key={`h${k++}`} className={cls}>{content}</h1> : lvl === 2 ? <h2 key={`h${k++}`} className={cls}>{content}</h2> : <h3 key={`h${k++}`} className={cls}>{content}</h3>);
|
blocks.push(lvl === 1 ? <h1 key={`h${k++}`} className={cls}>{content}</h1> : lvl === 2 ? <h2 key={`h${k++}`} className={cls}>{content}</h2> : <h3 key={`h${k++}`} className={cls}>{content}</h3>);
|
||||||
} else if (line.startsWith(">")) {
|
} else if (line.startsWith(">")) {
|
||||||
flushPara();
|
flushPara();
|
||||||
flushList();
|
flushList();
|
||||||
blocks.push(
|
blocks.push(
|
||||||
<blockquote key={`q${k++}`} className="my-2 border-l-2 border-brand/50 pl-3 text-sm text-slate-400">
|
<blockquote key={`q${k++}`} className="my-2 border-l-2 border-brand/50 pl-3 text-sm text-slate-400">
|
||||||
{inline(line.replace(/^>\s?/, ""), `q${k}`)}
|
{il(line.replace(/^>\s?/, ""), `q${k}`)}
|
||||||
</blockquote>,
|
</blockquote>,
|
||||||
);
|
);
|
||||||
} else if (ol) {
|
} else if (ol) {
|
||||||
|
|||||||
@@ -121,6 +121,19 @@ export async function createKb(id: Identity, name: string, kind: string): Promis
|
|||||||
return { name: data.name, kind: data.kind ?? kind };
|
return { name: data.name, kind: data.kind ?? kind };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VaultDoc {
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// listVault: GET /api/v1/kb/vault —— 某知识库的原始文档(Obsidian 式文库浏览)。
|
||||||
|
export async function listVault(id: Identity, kb: string): Promise<VaultDoc[]> {
|
||||||
|
const res = await fetch(`${GATEWAY}/api/v1/kb/vault?kb=${encodeURIComponent(kb)}`, { headers: idHeaders(id) });
|
||||||
|
const data = (await res.json()) as { docs?: VaultDoc[]; error?: string };
|
||||||
|
if (!res.ok) throw new Error(data.error ?? `vault failed: ${res.status}`);
|
||||||
|
return data.docs ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
// ingestKb: POST /api/v1/kb/ingest —— 文本入库(异步,返回 job_id)。
|
// ingestKb: POST /api/v1/kb/ingest —— 文本入库(异步,返回 job_id)。
|
||||||
export async function ingestKb(id: Identity, kb: string, text: string): Promise<string> {
|
export async function ingestKb(id: Identity, kb: string, text: string): Promise<string> {
|
||||||
const res = await fetch(`${GATEWAY}/api/v1/kb/ingest`, {
|
const res = await fetch(`${GATEWAY}/api/v1/kb/ingest`, {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Upload,
|
Upload,
|
||||||
FileUp,
|
FileUp,
|
||||||
@@ -16,6 +16,9 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Trash2,
|
Trash2,
|
||||||
Lock,
|
Lock,
|
||||||
|
BookOpen,
|
||||||
|
Link2,
|
||||||
|
RefreshCw,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -26,14 +29,17 @@ import {
|
|||||||
graphKb,
|
graphKb,
|
||||||
listKb,
|
listKb,
|
||||||
createKb,
|
createKb,
|
||||||
|
listVault,
|
||||||
type IngestEvent,
|
type IngestEvent,
|
||||||
type KbHit,
|
type KbHit,
|
||||||
type Triple,
|
type Triple,
|
||||||
type KbInfo,
|
type KbInfo,
|
||||||
|
type VaultDoc,
|
||||||
type Identity,
|
type Identity,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import { GraphView } from "../components/GraphView";
|
import { GraphView } from "../components/GraphView";
|
||||||
import { Button, Input, Textarea, Select, Badge, cn, useToast } from "../ui";
|
import { Markdown } from "../components/Markdown";
|
||||||
|
import { Button, Input, Textarea, Select, Badge, Tabs, EmptyState, cn, useToast, type TabDef } from "../ui";
|
||||||
|
|
||||||
interface IngestLog {
|
interface IngestLog {
|
||||||
t: string;
|
t: string;
|
||||||
@@ -58,9 +64,10 @@ interface Progress {
|
|||||||
interface FileJob {
|
interface FileJob {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: string; // 排队/解析/向量化/写入/抽取/完成/失败
|
status: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
type KbTab = "ingest" | "vault" | "search" | "graph";
|
||||||
|
|
||||||
const STAGE: Record<string, { icon: LucideIcon; label: string }> = {
|
const STAGE: Record<string, { icon: LucideIcon; label: string }> = {
|
||||||
解析: { icon: Upload, label: "解析文件" },
|
解析: { icon: Upload, label: "解析文件" },
|
||||||
@@ -85,7 +92,6 @@ function stageToStatus(stage: string): string {
|
|||||||
if (stage === "失败" || stage === "连接中断") return "失败";
|
if (stage === "失败" || stage === "连接中断") return "失败";
|
||||||
return "排队";
|
return "排队";
|
||||||
}
|
}
|
||||||
|
|
||||||
function dedupTriples(ts: Triple[]): Triple[] {
|
function dedupTriples(ts: Triple[]): Triple[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const out: Triple[] = [];
|
const out: Triple[] = [];
|
||||||
@@ -99,11 +105,12 @@ function dedupTriples(ts: Triple[]): Triple[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 知识库管理:按 owner 隔离 + 项目/案件/文件夹组织;批量文件入库(文件列表) + 实时时间线 + 力导向图谱 + 混合检索。
|
// 知识库:owner 隔离 + 项目/案件/文件夹组织;Tab 分(入库 / 文库(Obsidian 式) / 检索 / 图谱)。
|
||||||
export function KbView({ identity }: { identity: Identity }) {
|
export function KbView({ identity }: { identity: Identity }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [kbs, setKbs] = useState<KbInfo[]>([]);
|
const [kbs, setKbs] = useState<KbInfo[]>([]);
|
||||||
const [kb, setKb] = useState("default");
|
const [kb, setKb] = useState("default");
|
||||||
|
const [tab, setTab] = useState<KbTab>("ingest");
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const [newKind, setNewKind] = useState("project");
|
const [newKind, setNewKind] = useState("project");
|
||||||
@@ -122,24 +129,22 @@ export function KbView({ identity }: { identity: Identity }) {
|
|||||||
const [graph, setGraph] = useState<Triple[] | null>(null);
|
const [graph, setGraph] = useState<Triple[] | null>(null);
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
|
||||||
// 让文件夹输入框可选整个目录(标准类型无 webkitdirectory,挂在 DOM 上)。
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (folderRef.current) folderRef.current.setAttribute("webkitdirectory", "");
|
if (folderRef.current) folderRef.current.setAttribute("webkitdirectory", "");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshKbs = async () => {
|
const refreshKbs = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const list = await listKb(identity);
|
const list = await listKb(identity);
|
||||||
setKbs(list);
|
setKbs(list);
|
||||||
if (list.length && !list.some((k) => k.name === kb)) setKb(list[0].name);
|
setKb((cur) => (list.length && !list.some((k) => k.name === cur) ? list[0].name : cur));
|
||||||
} catch {
|
} catch {
|
||||||
/* 降级:用默认库 */
|
/* 降级用默认库 */
|
||||||
}
|
}
|
||||||
};
|
}, [identity]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshKbs();
|
void refreshKbs();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [refreshKbs]);
|
||||||
}, [identity.userId]);
|
|
||||||
|
|
||||||
const onCreate = async () => {
|
const onCreate = async () => {
|
||||||
const name = newName.trim();
|
const name = newName.trim();
|
||||||
@@ -209,7 +214,6 @@ export function KbView({ identity }: { identity: Identity }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 批量入库:每个文件起一个 job,行内显示各自状态。
|
|
||||||
const ingestFiles = (list: FileList | File[] | null | undefined) => {
|
const ingestFiles = (list: FileList | File[] | null | undefined) => {
|
||||||
const arr = Array.from(list ?? []);
|
const arr = Array.from(list ?? []);
|
||||||
arr.forEach((file, idx) => {
|
arr.forEach((file, idx) => {
|
||||||
@@ -234,13 +238,14 @@ export function KbView({ identity }: { identity: Identity }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onGraph = async () => {
|
const onGraph = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setGraph(await graphKb(identity, kb));
|
setGraph(await graphKb(identity, kb));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.push("error", (e as Error).message);
|
toast.push("error", (e as Error).message);
|
||||||
}
|
}
|
||||||
};
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [identity, kb]);
|
||||||
|
|
||||||
const vecPct = prog?.vecTotal ? Math.round(((prog.vecDone ?? 0) / prog.vecTotal) * 100) : 0;
|
const vecPct = prog?.vecTotal ? Math.round(((prog.vecDone ?? 0) / prog.vecTotal) * 100) : 0;
|
||||||
const graphData = graph ?? prog?.triples ?? null;
|
const graphData = graph ?? prog?.triples ?? null;
|
||||||
@@ -248,9 +253,16 @@ export function KbView({ identity }: { identity: Identity }) {
|
|||||||
const doneCount = files.filter((f) => f.status === "完成").length;
|
const doneCount = files.filter((f) => f.status === "完成").length;
|
||||||
const failCount = files.filter((f) => f.status === "失败").length;
|
const failCount = files.filter((f) => f.status === "失败").length;
|
||||||
|
|
||||||
|
const tabs: TabDef<KbTab>[] = [
|
||||||
|
{ key: "ingest", label: "入库" },
|
||||||
|
{ key: "vault", label: "文库" },
|
||||||
|
{ key: "search", label: "检索" },
|
||||||
|
{ key: "graph", label: "图谱" },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* 知识库选择 / 新建 */}
|
{/* 知识库选择 / 新建 + 隔离徽标 */}
|
||||||
<div className="flex flex-wrap items-center gap-2 border-b border-line bg-ink-900 px-4 py-2">
|
<div className="flex flex-wrap items-center gap-2 border-b border-line bg-ink-900 px-4 py-2">
|
||||||
<span className="text-sm font-semibold text-slate-300">知识库</span>
|
<span className="text-sm font-semibold text-slate-300">知识库</span>
|
||||||
<Select className="h-8 w-48" value={kb} onChange={(e) => setKb(e.target.value)}>
|
<Select className="h-8 w-48" value={kb} onChange={(e) => setKb(e.target.value)}>
|
||||||
@@ -286,9 +298,15 @@ export function KbView({ identity }: { identity: Identity }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1">
|
{/* Tab 条 */}
|
||||||
{/* 左:入库 + 文件列表 + 时间线 */}
|
<div className="flex items-center border-b border-line px-3">
|
||||||
<section className="flex w-1/2 flex-col overflow-y-auto border-r border-line p-4">
|
<Tabs tabs={tabs} value={tab} onChange={setTab} />
|
||||||
|
{busy && <span className="ml-2 flex items-center gap-1 text-[11px] text-accent-400"><Loader2 className="h-3 w-3 animate-spin" /> 入库中</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
|
{tab === "ingest" && (
|
||||||
|
<div className="h-full overflow-y-auto p-4">
|
||||||
<h3 className="mb-2 text-xs font-semibold text-slate-400">入库到「{kb}」</h3>
|
<h3 className="mb-2 text-xs font-semibold text-slate-400">入库到「{kb}」</h3>
|
||||||
<div
|
<div
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
@@ -301,12 +319,10 @@ export function KbView({ identity }: { identity: Identity }) {
|
|||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
ingestFiles(e.dataTransfer.files);
|
ingestFiles(e.dataTransfer.files);
|
||||||
}}
|
}}
|
||||||
className={cn("relative rounded-md", dragOver && "ring-2 ring-brand")}
|
className={cn("relative max-w-2xl rounded-md", dragOver && "ring-2 ring-brand")}
|
||||||
>
|
>
|
||||||
<Textarea className="h-20 w-full resize-none" value={text} onChange={(e) => setText(e.target.value)} placeholder="每行一条知识,或把一批文件 / 整个文件夹拖到这里" />
|
<Textarea className="h-20 w-full resize-none" value={text} onChange={(e) => setText(e.target.value)} placeholder="每行一条知识,或把一批文件 / 整个文件夹拖到这里(笔记支持 [[双链]])" />
|
||||||
{dragOver && (
|
{dragOver && <div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md bg-ink-950/85 text-xs font-medium text-brand-400">松手以批量入库</div>}
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md bg-ink-950/85 text-xs font-medium text-brand-400">松手以批量入库</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
<Button variant="primary" size="sm" icon={Upload} onClick={onIngest} disabled={busy || !text.trim()}>
|
<Button variant="primary" size="sm" icon={Upload} onClick={onIngest} disabled={busy || !text.trim()}>
|
||||||
@@ -321,58 +337,61 @@ export function KbView({ identity }: { identity: Identity }) {
|
|||||||
<input ref={fileRef} type="file" multiple accept=".txt,.md,.csv,.docx,.xlsx,.pdf" onChange={(e) => ingestFiles(e.target.files)} className="hidden" />
|
<input ref={fileRef} type="file" multiple accept=".txt,.md,.csv,.docx,.xlsx,.pdf" onChange={(e) => ingestFiles(e.target.files)} className="hidden" />
|
||||||
<input ref={folderRef} type="file" multiple onChange={(e) => ingestFiles(e.target.files)} className="hidden" />
|
<input ref={folderRef} type="file" multiple onChange={(e) => ingestFiles(e.target.files)} className="hidden" />
|
||||||
</div>
|
</div>
|
||||||
<span className="mt-1 text-[10px] text-slate-500">支持批量 txt/md/csv/docx/xlsx/pdf;可整文件夹拖入(docx/xlsx/pdf 经 mcp-py 解析)</span>
|
<span className="mt-1 block text-[10px] text-slate-500">支持批量 txt/md/csv/docx/xlsx/pdf;可整文件夹拖入(docx/xlsx/pdf 经 mcp-py 解析)</span>
|
||||||
|
|
||||||
{/* 批量文件列表 */}
|
<div className="mt-3 grid max-w-3xl gap-3 lg:grid-cols-2">
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<div className="mt-3 rounded-lg border border-line bg-ink-850 p-2.5">
|
<div className="rounded-lg border border-line bg-ink-850 p-2.5">
|
||||||
<div className="mb-1.5 flex items-center text-[11px] text-slate-400">
|
<div className="mb-1.5 flex items-center text-[11px] text-slate-400">
|
||||||
<span>批量入库 {files.length} 个文件</span>
|
<span>批量入库 {files.length} 个</span>
|
||||||
<span className="ml-auto text-slate-500">
|
<span className="ml-auto text-slate-500">完成 {doneCount} · 失败 {failCount}</span>
|
||||||
完成 {doneCount} · 失败 {failCount}
|
|
||||||
</span>
|
|
||||||
<button className="ml-2 text-slate-600 hover:text-slate-400" onClick={() => setFiles([])} title="清空列表">
|
<button className="ml-2 text-slate-600 hover:text-slate-400" onClick={() => setFiles([])} title="清空列表">
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ul className="max-h-40 space-y-1 overflow-auto">
|
<ul className="max-h-48 space-y-1 overflow-auto">
|
||||||
{files.map((f) => (
|
{files.map((f) => (
|
||||||
<li key={f.id} className="flex items-center gap-2 rounded bg-ink-900 px-2 py-1 text-[11px]">
|
<li key={f.id} className="flex items-center gap-2 rounded bg-ink-900 px-2 py-1 text-[11px]">
|
||||||
<FileText className="h-3.5 w-3.5 shrink-0 text-slate-500" />
|
<FileText className="h-3.5 w-3.5 shrink-0 text-slate-500" />
|
||||||
<span className="truncate text-slate-300" title={f.name}>{f.name}</span>
|
<span className="truncate text-slate-300" title={f.name}>{f.name}</span>
|
||||||
<span className="ml-auto">
|
<span className="ml-auto"><FileStatus status={f.status} /></span>
|
||||||
<FileStatus status={f.status} />
|
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{prog && (
|
||||||
|
<div className={files.length > 0 ? "" : "lg:col-span-2"}>
|
||||||
|
<Timeline prog={prog} vecPct={vecPct} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{prog && <Timeline prog={prog} vecPct={vecPct} />}
|
{logs.length > 0 && (
|
||||||
|
<ul className="mt-3 max-w-2xl space-y-1">
|
||||||
<h3 className="mb-1 mt-4 text-xs font-semibold text-slate-400">入库历史</h3>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{logs.length === 0 && <li className="text-xs text-slate-600">尚无文本入库记录。</li>}
|
|
||||||
{logs.map((l, i) => (
|
{logs.map((l, i) => (
|
||||||
<li key={i} className={cn("text-xs", l.ok ? "text-success" : "text-danger")}>
|
<li key={i} className={cn("text-xs", l.ok ? "text-success" : "text-danger")}>
|
||||||
<span className="text-slate-600">{l.t}</span> {l.ok ? "✓" : "✗"} {l.msg}
|
<span className="text-slate-600">{l.t}</span> {l.ok ? "✓" : "✗"} {l.msg}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 右:检索台 + 知识图谱 */}
|
{tab === "vault" && <VaultPanel identity={identity} kb={kb} />}
|
||||||
<section className="flex w-1/2 flex-col overflow-y-auto p-4">
|
|
||||||
|
{tab === "search" && (
|
||||||
|
<div className="h-full overflow-y-auto p-4">
|
||||||
<h3 className="mb-2 text-xs font-semibold text-slate-400">检索调试台 · 「{kb}」(混合召回 + rerank)</h3>
|
<h3 className="mb-2 text-xs font-semibold text-slate-400">检索调试台 · 「{kb}」(混合召回 + rerank)</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex max-w-3xl gap-2">
|
||||||
<Input className="flex-1" value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onSearch()} placeholder="输入查询,语义召回相关片段…" />
|
<Input className="flex-1" value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onSearch()} placeholder="输入查询,语义召回相关片段…" />
|
||||||
<Input type="number" className="w-16" value={topK} min={1} max={20} onChange={(e) => setTopK(Number(e.target.value))} title="TopK" />
|
<Input type="number" className="w-16" value={topK} min={1} max={20} onChange={(e) => setTopK(Number(e.target.value))} title="TopK" />
|
||||||
<Button variant="primary" size="md" icon={Search} onClick={onSearch} disabled={searching || !q.trim()}>
|
<Button variant="primary" size="md" icon={Search} onClick={onSearch} disabled={searching || !q.trim()}>
|
||||||
{searching ? "检索中…" : "检索"}
|
{searching ? "检索中…" : "检索"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ul className="mt-3 space-y-2">
|
<ul className="mt-3 max-w-3xl space-y-2">
|
||||||
{hits === null && <li className="text-xs text-slate-600">输入查询后展示命中片段与分数。</li>}
|
{hits === null && <li className="text-xs text-slate-600">输入查询后展示命中片段与分数。</li>}
|
||||||
{hits !== null && hits.length === 0 && <li className="text-xs text-slate-600">无命中(该库为空或 RAG 未配置)。</li>}
|
{hits !== null && hits.length === 0 && <li className="text-xs text-slate-600">无命中(该库为空或 RAG 未配置)。</li>}
|
||||||
{hits?.map((h, i) => (
|
{hits?.map((h, i) => (
|
||||||
@@ -386,17 +405,119 @@ export function KbView({ identity }: { identity: Identity }) {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-between border-t border-line pt-3">
|
{tab === "graph" && (
|
||||||
<h3 className="text-xs font-semibold text-slate-400">知识图谱(Neo4j · 力导向)</h3>
|
<div className="h-full overflow-y-auto p-4">
|
||||||
|
<div className="mb-2 flex max-w-3xl items-center justify-between">
|
||||||
|
<h3 className="text-xs font-semibold text-slate-400">知识图谱 · 「{kb}」(Neo4j · 力导向)</h3>
|
||||||
<Button size="sm" icon={Network} onClick={onGraph}>
|
<Button size="sm" icon={Network} onClick={onGraph}>
|
||||||
查看图谱
|
刷新图谱
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2">
|
<div className="max-w-3xl">
|
||||||
<GraphView triples={graphData ?? []} />
|
<GraphView triples={graphData ?? []} height={460} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeReg(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
// VaultPanel:Obsidian 式文库 —— 左文档列表 / 右 Markdown 笔记([[双链]]可点)+ 反向链接。
|
||||||
|
function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) {
|
||||||
|
const [docs, setDocs] = useState<VaultDoc[]>([]);
|
||||||
|
const [sel, setSel] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const d = await listVault(identity, kb);
|
||||||
|
setDocs(d);
|
||||||
|
setSel((s) => (d.some((x) => x.name === s) ? s : d[0]?.name ?? null));
|
||||||
|
} catch {
|
||||||
|
setDocs([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [identity, kb]);
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const names = new Set(docs.map((d) => d.name));
|
||||||
|
const current = docs.find((d) => d.name === sel);
|
||||||
|
const open = (name: string) => {
|
||||||
|
if (names.has(name)) setSel(name);
|
||||||
|
};
|
||||||
|
const backlinks = current
|
||||||
|
? docs.filter((d) => d.name !== current.name && new RegExp(`\\[\\[\\s*${escapeReg(current.name)}(\\|[^\\]]*)?\\s*\\]\\]`).test(d.content))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!loading && docs.length === 0) {
|
||||||
|
return <EmptyState icon={BookOpen} title="文库为空" desc={`「${kb}」还没有文档。到「入库」拖入文件或写笔记(支持 [[双链]])。`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0">
|
||||||
|
<aside className="flex w-56 shrink-0 flex-col overflow-y-auto border-r border-line p-2">
|
||||||
|
<div className="mb-1 flex items-center justify-between px-1 text-[11px] text-slate-500">
|
||||||
|
<span>文档 {docs.length}</span>
|
||||||
|
<button onClick={load} className="text-slate-600 hover:text-slate-300" title="刷新">
|
||||||
|
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{docs.map((d) => (
|
||||||
|
<li key={d.name}>
|
||||||
|
<button
|
||||||
|
onClick={() => setSel(d.name)}
|
||||||
|
className={cn("flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-left text-xs", d.name === sel ? "bg-brand/15 text-brand-400" : "text-slate-300 hover:bg-ink-800")}
|
||||||
|
>
|
||||||
|
<FileText className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate" title={d.name}>{d.name}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-5">
|
||||||
|
{current ? (
|
||||||
|
<>
|
||||||
|
<h2 className="mb-3 flex items-center gap-2 text-base font-semibold text-slate-100">
|
||||||
|
<FileText className="h-4 w-4 text-brand-400" />
|
||||||
|
{current.name}
|
||||||
|
</h2>
|
||||||
|
<Markdown text={current.content} className="text-sm" onLink={open} />
|
||||||
|
<div className="mt-6 border-t border-line pt-3">
|
||||||
|
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-slate-400">
|
||||||
|
<Link2 className="h-3.5 w-3.5" /> 反向链接({backlinks.length})
|
||||||
|
</div>
|
||||||
|
{backlinks.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-slate-600">暂无其它笔记用 [[{current.name}]] 链接到这里。</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{backlinks.map((b) => (
|
||||||
|
<li key={b.name}>
|
||||||
|
<button onClick={() => setSel(b.name)} className="text-xs text-brand-400 hover:underline">
|
||||||
|
{b.name}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-slate-600">选择左侧文档查看。</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -417,7 +538,7 @@ function FileStatus({ status }: { status: string }) {
|
|||||||
// Timeline 渲染入库各阶段:状态灯 + 标签 + 详情;解析预览、切块块、抽取的知识三元组逐步呈现。
|
// Timeline 渲染入库各阶段:状态灯 + 标签 + 详情;解析预览、切块块、抽取的知识三元组逐步呈现。
|
||||||
function Timeline({ prog, vecPct }: { prog: Progress; vecPct: number }) {
|
function Timeline({ prog, vecPct }: { prog: Progress; vecPct: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-lg border border-line bg-ink-850 p-3">
|
<div className="rounded-lg border border-line bg-ink-850 p-3">
|
||||||
<ol className="relative ml-1 space-y-2 border-l border-line pl-4">
|
<ol className="relative ml-1 space-y-2 border-l border-line pl-4">
|
||||||
{prog.steps.map((s, i) => {
|
{prog.steps.map((s, i) => {
|
||||||
const meta = STAGE[s.stage] ?? { icon: Loader2, label: s.stage };
|
const meta = STAGE[s.stage] ?? { icon: Loader2, label: s.stage };
|
||||||
|
|||||||
@@ -77,10 +77,39 @@ func (h *Handler) KbIngest(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
_ = h.db.EnsureKB(c.Request.Context(), userID(c), rawKB(body.KB), "general")
|
_ = h.db.EnsureKB(c.Request.Context(), userID(c), rawKB(body.KB), "general")
|
||||||
job := newJobID()
|
job := newJobID()
|
||||||
go h.runIngest(job, scopedKB(c, body.KB), "", nil, body.Text)
|
go h.runIngest(job, userID(c), rawKB(body.KB), scopedKB(c, body.KB), "", nil, body.Text)
|
||||||
c.JSON(http.StatusAccepted, gin.H{"job_id": job})
|
c.JSON(http.StatusAccepted, gin.H{"job_id": job})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KbVault: GET /api/v1/kb/vault?kb= —— 某知识库的全部原始文档(名+内容),供 Obsidian 式文库浏览。
|
||||||
|
func (h *Handler) KbVault(c *gin.Context) {
|
||||||
|
rows, err := h.db.ListVault(c.Request.Context(), userID(c), rawKB(c.Query("kb")))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
docs := make([]gin.H, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
docs = append(docs, gin.H{"name": r.Name, "content": r.Content})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"docs": docs})
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteName 取文本首个非空行作笔记名(截断 40 字),用于文本入库的文库留存。
|
||||||
|
func noteName(text string) string {
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line != "" {
|
||||||
|
r := []rune(line)
|
||||||
|
if len(r) > 40 {
|
||||||
|
return string(r[:40])
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "笔记"
|
||||||
|
}
|
||||||
|
|
||||||
// KbIngestFile: POST /api/v1/kb/ingest_file(multipart)—— 文件入库(异步,返回 job_id)。
|
// KbIngestFile: POST /api/v1/kb/ingest_file(multipart)—— 文件入库(异步,返回 job_id)。
|
||||||
// 流水线(解析→切块→向量化→写入)的进度经 sundynix.streams.<job_id> 回流,UI 用 SSE 看。
|
// 流水线(解析→切块→向量化→写入)的进度经 sundynix.streams.<job_id> 回流,UI 用 SSE 看。
|
||||||
func (h *Handler) KbIngestFile(c *gin.Context) {
|
func (h *Handler) KbIngestFile(c *gin.Context) {
|
||||||
@@ -103,13 +132,14 @@ func (h *Handler) KbIngestFile(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
_ = h.db.EnsureKB(c.Request.Context(), userID(c), rawKB(kb), "general")
|
_ = h.db.EnsureKB(c.Request.Context(), userID(c), rawKB(kb), "general")
|
||||||
job := newJobID()
|
job := newJobID()
|
||||||
go h.runIngest(job, scopedKB(c, kb), fh.Filename, data, "")
|
go h.runIngest(job, userID(c), rawKB(kb), scopedKB(c, kb), fh.Filename, data, "")
|
||||||
c.JSON(http.StatusAccepted, gin.H{"job_id": job, "file": fh.Filename})
|
c.JSON(http.StatusAccepted, gin.H{"job_id": job, "file": fh.Filename})
|
||||||
}
|
}
|
||||||
|
|
||||||
// runIngest 后台跑入库流水线,逐阶段把进度发到 sundynix.streams.<job>。
|
// runIngest 后台跑入库流水线,逐阶段把进度发到 sundynix.streams.<job>。
|
||||||
|
// owner+kbName 用于"文库"原文留存;scoped 是 owner/kb 作向量/全文/图谱分区键。
|
||||||
// filename 非空表示文件入库(先经 mcp-py 解析);否则用 rawText。
|
// filename 非空表示文件入库(先经 mcp-py 解析);否则用 rawText。
|
||||||
func (h *Handler) runIngest(job, kb, filename string, data []byte, rawText string) {
|
func (h *Handler) runIngest(job, owner, kbName, scoped, filename string, data []byte, rawText string) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
emit := func(ev contract.IngestEvent) { _ = h.bus.PublishIngest(job, &ev) }
|
emit := func(ev contract.IngestEvent) { _ = h.bus.PublishIngest(job, &ev) }
|
||||||
time.Sleep(400 * time.Millisecond) // 给 SSE 客户端订阅时间(core NATS 无缓冲)
|
time.Sleep(400 * time.Millisecond) // 给 SSE 客户端订阅时间(core NATS 无缓冲)
|
||||||
@@ -131,9 +161,18 @@ func (h *Handler) runIngest(job, kb, filename string, data []byte, rawText strin
|
|||||||
text = parsed
|
text = parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 文库留存原文:文件用文件名,文本用首行作笔记名(best-effort,不阻断入库)。
|
||||||
|
docName := filename
|
||||||
|
if docName == "" {
|
||||||
|
docName = noteName(text)
|
||||||
|
}
|
||||||
|
if text != "" {
|
||||||
|
_ = h.db.SaveDoc(ctx, owner, kbName, docName, text)
|
||||||
|
}
|
||||||
|
|
||||||
// 调 mcp-go kb_ingest(带 job_id):它会发 切块/向量化/写入/完成 事件 + CompleteStream。
|
// 调 mcp-go kb_ingest(带 job_id):它会发 切块/向量化/写入/完成 事件 + CompleteStream。
|
||||||
res, err := h.bus.CallTool(ctx, contract.ToolSubjectGo("kb_ingest"),
|
res, err := h.bus.CallTool(ctx, contract.ToolSubjectGo("kb_ingest"),
|
||||||
&contract.ToolCall{Tool: "kb_ingest", Args: map[string]any{"kb": kb, "text": text, "job_id": job}})
|
&contract.ToolCall{Tool: "kb_ingest", Args: map[string]any{"kb": scoped, "text": text, "job_id": job}})
|
||||||
if err != nil || res == nil || !res.OK {
|
if err != nil || res == nil || !res.OK {
|
||||||
msg := "kb_ingest 失败"
|
msg := "kb_ingest 失败"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus) *gin.Engine {
|
|||||||
api.POST("/kb/ingest_file", h.KbIngestFile) // 文件入库(docx/xlsx/pdf… 异步)
|
api.POST("/kb/ingest_file", h.KbIngestFile) // 文件入库(docx/xlsx/pdf… 异步)
|
||||||
api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(实时监控)
|
api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(实时监控)
|
||||||
api.POST("/kb/search", h.KbSearch) // 知识库检索台(→ mcp-go kb_search)
|
api.POST("/kb/search", h.KbSearch) // 知识库检索台(→ mcp-go kb_search)
|
||||||
|
api.GET("/kb/vault", h.KbVault) // 文库:原始文档浏览(Obsidian 式)
|
||||||
api.GET("/kb/graph", h.KbGraph) // 知识图谱三元组(→ mcp-go kb_graph,Neo4j)
|
api.GET("/kb/graph", h.KbGraph) // 知识图谱三元组(→ mcp-go kb_graph,Neo4j)
|
||||||
|
|
||||||
api.POST("/reports", h.GenerateReport) // 报告生成(intent=report 任务 → Dispatcher 专用编排)
|
api.POST("/reports", h.GenerateReport) // 报告生成(intent=report 任务 → Dispatcher 专用编排)
|
||||||
|
|||||||
@@ -45,6 +45,40 @@ func (p *Postgres) EnsureKB(ctx context.Context, owner, name, kind string) error
|
|||||||
}).Create(&KB{Owner: owner, Name: name, Kind: kind}).Error
|
}).Create(&KB{Owner: owner, Name: name, Kind: kind}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Doc 是入库的一份原始文档/笔记(供 Obsidian 式"文库"浏览:列表 + Markdown 阅读 + 双链)。
|
||||||
|
// 表名 sundynix_doc。(owner,kb,name) 唯一;按 owner 隔离。
|
||||||
|
type Doc struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
Owner string `gorm:"size:64;uniqueIndex:idx_doc_okn"`
|
||||||
|
KB string `gorm:"size:64;uniqueIndex:idx_doc_okn"`
|
||||||
|
Name string `gorm:"size:160;uniqueIndex:idx_doc_okn"`
|
||||||
|
Content string `gorm:"type:text"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Doc) TableName() string { return "sundynix_doc" }
|
||||||
|
|
||||||
|
// SaveDoc 写入/更新一份文档(owner+kb+name 唯一,重名覆盖内容)。
|
||||||
|
func (p *Postgres) SaveDoc(ctx context.Context, owner, kb, name, content string) error {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "owner"}, {Name: "kb"}, {Name: "name"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"content"}),
|
||||||
|
}).Create(&Doc{Owner: owner, KB: kb, Name: name, Content: content}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListVault 返回某 owner 某 kb 的全部文档(名+内容),供文库浏览/双链/反链。
|
||||||
|
func (p *Postgres) ListVault(ctx context.Context, owner, kb string) ([]Doc, error) {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var rows []Doc
|
||||||
|
err := p.db.WithContext(ctx).Where("owner = ? AND kb = ?", owner, kb).Order("id").Find(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
// LLMModel 是一个模型后端配置(控制面:管理员在此登记可用模型)。
|
// LLMModel 是一个模型后端配置(控制面:管理员在此登记可用模型)。
|
||||||
// 表名 sundynix_model(遵守前缀约定)。每个 kind 同一时刻仅一条 Active=true。
|
// 表名 sundynix_model(遵守前缀约定)。每个 kind 同一时刻仅一条 Active=true。
|
||||||
type LLMModel struct {
|
type LLMModel struct {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func OpenPostgres(dsn string) *Postgres {
|
|||||||
log.Printf("[store] postgres 不可用,降级运行(不持久化): %v", err)
|
log.Printf("[store] postgres 不可用,降级运行(不持久化): %v", err)
|
||||||
return &Postgres{}
|
return &Postgres{}
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&User{}, &Task{}, &LLMModel{}, &KB{}); err != nil {
|
if err := db.AutoMigrate(&User{}, &Task{}, &LLMModel{}, &KB{}, &Doc{}); err != nil {
|
||||||
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
|
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
|
||||||
return &Postgres{}
|
return &Postgres{}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user