diff --git a/sundynix-desktop/frontend/src/components/Markdown.tsx b/sundynix-desktop/frontend/src/components/Markdown.tsx
index ef842c7..51d0833 100644
--- a/sundynix-desktop/frontend/src/components/Markdown.tsx
+++ b/sundynix-desktop/frontend/src/components/Markdown.tsx
@@ -1,29 +1,38 @@
import { type ReactNode } from "react";
// 轻量 Markdown 渲染 —— 零依赖、行级解析,覆盖报告正文用到的子集:
-// # / ## / ### 标题、**粗** *斜* `码`、- 与 1. 列表、> 引用、--- 分隔、段落。
-// 流式安全:每个 token 重渲染,残缺语法也能容忍。
+// # / ## / ### 标题、**粗** *斜* `码`、[[双链]]、- 与 1. 列表、> 引用、--- 分隔、段落。
+// 流式安全:每个 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 re = /(\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g;
+ const re = /(\[\[([^\]]+)\]\]|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g;
let last = 0;
let m: RegExpExecArray | null;
let i = 0;
while ((m = re.exec(text)) !== null) {
if (m.index > last) out.push(text.slice(last, m.index));
const key = `${keyPrefix}-${i++}`;
- if (m[2] !== undefined) out.push({m[2]});
- else if (m[3] !== undefined) out.push({m[3]});
- else if (m[4] !== undefined) out.push({m[4]});
+ if (m[2] !== undefined) {
+ const name = m[2].split("|")[0].trim(); // 支持 [[名称|别名]]
+ const label = m[2].includes("|") ? m[2].split("|")[1].trim() : m[2];
+ out.push(
+ ,
+ );
+ } else if (m[3] !== undefined) out.push({m[3]});
+ else if (m[4] !== undefined) out.push({m[4]});
+ else if (m[5] !== undefined) out.push({m[5]});
last = re.lastIndex;
}
if (last < text.length) out.push(text.slice(last));
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 blocks: ReactNode[] = [];
let para: string[] = [];
@@ -34,7 +43,7 @@ export function Markdown({ text, className }: { text: string; className?: string
if (para.length) {
blocks.push(
- {inline(para.join(" "), `p${k}`)}
+ {il(para.join(" "), `p${k}`)}
,
);
para = [];
@@ -44,7 +53,7 @@ export function Markdown({ text, className }: { text: string; className?: string
if (list) {
const items = list.items.map((it, idx) => (
- {inline(it, `li${k}-${idx}`)}
+ {il(it, `li${k}-${idx}`)}
));
blocks.push(
@@ -76,14 +85,14 @@ export function Markdown({ text, className }: { text: string; className?: string
flushList();
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 content = inline(h[2], `h${k}`);
+ const content = il(h[2], `h${k}`);
blocks.push(lvl === 1 ? {content}
: lvl === 2 ? {content}
: {content}
);
} else if (line.startsWith(">")) {
flushPara();
flushList();
blocks.push(
- {inline(line.replace(/^>\s?/, ""), `q${k}`)}
+ {il(line.replace(/^>\s?/, ""), `q${k}`)}
,
);
} else if (ol) {
diff --git a/sundynix-desktop/frontend/src/lib/api.ts b/sundynix-desktop/frontend/src/lib/api.ts
index c1f5082..c410174 100644
--- a/sundynix-desktop/frontend/src/lib/api.ts
+++ b/sundynix-desktop/frontend/src/lib/api.ts
@@ -121,6 +121,19 @@ export async function createKb(id: Identity, name: string, kind: string): Promis
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 {
+ 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)。
export async function ingestKb(id: Identity, kb: string, text: string): Promise {
const res = await fetch(`${GATEWAY}/api/v1/kb/ingest`, {
diff --git a/sundynix-desktop/frontend/src/views/KbView.tsx b/sundynix-desktop/frontend/src/views/KbView.tsx
index 4a18f6a..5e8888d 100644
--- a/sundynix-desktop/frontend/src/views/KbView.tsx
+++ b/sundynix-desktop/frontend/src/views/KbView.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import {
Upload,
FileUp,
@@ -16,6 +16,9 @@ import {
Loader2,
Trash2,
Lock,
+ BookOpen,
+ Link2,
+ RefreshCw,
type LucideIcon,
} from "lucide-react";
import {
@@ -26,14 +29,17 @@ import {
graphKb,
listKb,
createKb,
+ listVault,
type IngestEvent,
type KbHit,
type Triple,
type KbInfo,
+ type VaultDoc,
type Identity,
} from "../lib/api";
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 {
t: string;
@@ -58,9 +64,10 @@ interface Progress {
interface FileJob {
id: string;
name: string;
- status: string; // 排队/解析/向量化/写入/抽取/完成/失败
+ status: string;
error?: string;
}
+type KbTab = "ingest" | "vault" | "search" | "graph";
const STAGE: Record = {
解析: { icon: Upload, label: "解析文件" },
@@ -85,7 +92,6 @@ function stageToStatus(stage: string): string {
if (stage === "失败" || stage === "连接中断") return "失败";
return "排队";
}
-
function dedupTriples(ts: Triple[]): Triple[] {
const seen = new Set();
const out: Triple[] = [];
@@ -99,11 +105,12 @@ function dedupTriples(ts: Triple[]): Triple[] {
return out;
}
-// 知识库管理:按 owner 隔离 + 项目/案件/文件夹组织;批量文件入库(文件列表) + 实时时间线 + 力导向图谱 + 混合检索。
+// 知识库:owner 隔离 + 项目/案件/文件夹组织;Tab 分(入库 / 文库(Obsidian 式) / 检索 / 图谱)。
export function KbView({ identity }: { identity: Identity }) {
const toast = useToast();
const [kbs, setKbs] = useState([]);
const [kb, setKb] = useState("default");
+ const [tab, setTab] = useState("ingest");
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const [newKind, setNewKind] = useState("project");
@@ -122,24 +129,22 @@ export function KbView({ identity }: { identity: Identity }) {
const [graph, setGraph] = useState(null);
const [dragOver, setDragOver] = useState(false);
- // 让文件夹输入框可选整个目录(标准类型无 webkitdirectory,挂在 DOM 上)。
useEffect(() => {
if (folderRef.current) folderRef.current.setAttribute("webkitdirectory", "");
}, []);
- const refreshKbs = async () => {
+ const refreshKbs = useCallback(async () => {
try {
const list = await listKb(identity);
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 {
- /* 降级:用默认库 */
+ /* 降级用默认库 */
}
- };
+ }, [identity]);
useEffect(() => {
void refreshKbs();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [identity.userId]);
+ }, [refreshKbs]);
const onCreate = async () => {
const name = newName.trim();
@@ -209,7 +214,6 @@ export function KbView({ identity }: { identity: Identity }) {
}
};
- // 批量入库:每个文件起一个 job,行内显示各自状态。
const ingestFiles = (list: FileList | File[] | null | undefined) => {
const arr = Array.from(list ?? []);
arr.forEach((file, idx) => {
@@ -234,13 +238,14 @@ export function KbView({ identity }: { identity: Identity }) {
}
};
- const onGraph = async () => {
+ const onGraph = useCallback(async () => {
try {
setGraph(await graphKb(identity, kb));
} catch (e) {
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 graphData = graph ?? prog?.triples ?? null;
@@ -248,9 +253,16 @@ export function KbView({ identity }: { identity: Identity }) {
const doneCount = files.filter((f) => f.status === "完成").length;
const failCount = files.filter((f) => f.status === "失败").length;
+ const tabs: TabDef[] = [
+ { key: "ingest", label: "入库" },
+ { key: "vault", label: "文库" },
+ { key: "search", label: "检索" },
+ { key: "graph", label: "图谱" },
+ ];
+
return (
- {/* 知识库选择 / 新建 */}
+ {/* 知识库选择 / 新建 + 隔离徽标 */}
知识库
-
- {/* 左:入库 + 文件列表 + 时间线 */}
-
- 入库到「{kb}」
- {
- e.preventDefault();
- setDragOver(true);
- }}
- onDragLeave={() => setDragOver(false)}
- onDrop={(e) => {
- e.preventDefault();
- setDragOver(false);
- ingestFiles(e.dataTransfer.files);
- }}
- className={cn("relative rounded-md", dragOver && "ring-2 ring-brand")}
- >
-
-
-
-
-
- ingestFiles(e.target.files)} className="hidden" />
- ingestFiles(e.target.files)} className="hidden" />
-
- 支持批量 txt/md/csv/docx/xlsx/pdf;可整文件夹拖入(docx/xlsx/pdf 经 mcp-py 解析)
+ {/* Tab 条 */}
+
+
+ {busy && 入库中}
+
- {/* 批量文件列表 */}
- {files.length > 0 && (
-
-
- 批量入库 {files.length} 个文件
-
- 完成 {doneCount} · 失败 {failCount}
-
-
-
-
- {files.map((f) => (
- -
-
- {f.name}
-
-
-
+
+ {tab === "ingest" && (
+
+
入库到「{kb}」
+
{
+ e.preventDefault();
+ setDragOver(true);
+ }}
+ onDragLeave={() => setDragOver(false)}
+ onDrop={(e) => {
+ e.preventDefault();
+ setDragOver(false);
+ ingestFiles(e.dataTransfer.files);
+ }}
+ className={cn("relative max-w-2xl rounded-md", dragOver && "ring-2 ring-brand")}
+ >
+
+
+
+
+
+ ingestFiles(e.target.files)} className="hidden" />
+ ingestFiles(e.target.files)} className="hidden" />
+
+
支持批量 txt/md/csv/docx/xlsx/pdf;可整文件夹拖入(docx/xlsx/pdf 经 mcp-py 解析)
+
+
+ {files.length > 0 && (
+
+
+ 批量入库 {files.length} 个
+ 完成 {doneCount} · 失败 {failCount}
+
+
+
+ {files.map((f) => (
+ -
+
+ {f.name}
+
+
+ ))}
+
+
+ )}
+ {prog && (
+
0 ? "" : "lg:col-span-2"}>
+
+
+ )}
+
+
+ {logs.length > 0 && (
+
+ {logs.map((l, i) => (
+ -
+ {l.t} {l.ok ? "✓" : "✗"} {l.msg}
))}
+ )}
+
+ )}
+
+ {tab === "vault" &&
}
+
+ {tab === "search" && (
+
+
检索调试台 · 「{kb}」(混合召回 + rerank)
+
+ setQ(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onSearch()} placeholder="输入查询,语义召回相关片段…" />
+ setTopK(Number(e.target.value))} title="TopK" />
+
- )}
-
- {prog &&
}
-
-
入库历史
-
- {logs.length === 0 && - 尚无文本入库记录。
}
- {logs.map((l, i) => (
- -
- {l.t} {l.ok ? "✓" : "✗"} {l.msg}
-
- ))}
-
-
-
- {/* 右:检索台 + 知识图谱 */}
-
- 检索调试台 · 「{kb}」(混合召回 + rerank)
-
-
+ )}
-
-
知识图谱(Neo4j · 力导向)
-
+ {tab === "graph" && (
+
+
+
知识图谱 · 「{kb}」(Neo4j · 力导向)
+
+
+
+
+
-
-
-
-
+ )}
+
+
+ );
+}
+
+function escapeReg(s: string): string {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+// VaultPanel:Obsidian 式文库 —— 左文档列表 / 右 Markdown 笔记([[双链]]可点)+ 反向链接。
+function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) {
+ const [docs, setDocs] = useState
([]);
+ const [sel, setSel] = useState(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 ;
+ }
+
+ return (
+
+
+
+ {current ? (
+ <>
+
+
+ {current.name}
+
+
+
+
+ 反向链接({backlinks.length})
+
+ {backlinks.length === 0 ? (
+
暂无其它笔记用 [[{current.name}]] 链接到这里。
+ ) : (
+
+ {backlinks.map((b) => (
+ -
+
+
+ ))}
+
+ )}
+
+ >
+ ) : (
+
选择左侧文档查看。
+ )}
);
@@ -417,7 +538,7 @@ function FileStatus({ status }: { status: string }) {
// Timeline 渲染入库各阶段:状态灯 + 标签 + 详情;解析预览、切块块、抽取的知识三元组逐步呈现。
function Timeline({ prog, vecPct }: { prog: Progress; vecPct: number }) {
return (
-
+
{prog.steps.map((s, i) => {
const meta = STAGE[s.stage] ?? { icon: Loader2, label: s.stage };
diff --git a/sundynix-gateway/internal/handler/kb.go b/sundynix-gateway/internal/handler/kb.go
index 7938a08..1573968 100644
--- a/sundynix-gateway/internal/handler/kb.go
+++ b/sundynix-gateway/internal/handler/kb.go
@@ -77,10 +77,39 @@ func (h *Handler) KbIngest(c *gin.Context) {
}
_ = h.db.EnsureKB(c.Request.Context(), userID(c), rawKB(body.KB), "general")
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})
}
+// 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)。
// 流水线(解析→切块→向量化→写入)的进度经 sundynix.streams. 回流,UI 用 SSE 看。
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")
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})
}
// runIngest 后台跑入库流水线,逐阶段把进度发到 sundynix.streams.。
+// owner+kbName 用于"文库"原文留存;scoped 是 owner/kb 作向量/全文/图谱分区键。
// 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()
emit := func(ev contract.IngestEvent) { _ = h.bus.PublishIngest(job, &ev) }
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
}
+ // 文库留存原文:文件用文件名,文本用首行作笔记名(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。
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 {
msg := "kb_ingest 失败"
if err != nil {
diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go
index 973368d..76f8555 100644
--- a/sundynix-gateway/internal/router/router.go
+++ b/sundynix-gateway/internal/router/router.go
@@ -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.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(实时监控)
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.POST("/reports", h.GenerateReport) // 报告生成(intent=report 任务 → Dispatcher 专用编排)
diff --git a/sundynix-gateway/internal/store/model.go b/sundynix-gateway/internal/store/model.go
index fc78e41..a4eb907 100644
--- a/sundynix-gateway/internal/store/model.go
+++ b/sundynix-gateway/internal/store/model.go
@@ -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
}
+// 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 是一个模型后端配置(控制面:管理员在此登记可用模型)。
// 表名 sundynix_model(遵守前缀约定)。每个 kind 同一时刻仅一条 Active=true。
type LLMModel struct {
diff --git a/sundynix-gateway/internal/store/pgsql.go b/sundynix-gateway/internal/store/pgsql.go
index c3db995..44589a6 100644
--- a/sundynix-gateway/internal/store/pgsql.go
+++ b/sundynix-gateway/internal/store/pgsql.go
@@ -34,7 +34,7 @@ func OpenPostgres(dsn string) *Postgres {
log.Printf("[store] postgres 不可用,降级运行(不持久化): %v", err)
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)
return &Postgres{}
}