feat(kb): 个人知识库——.kb/ 纯文件机制+/v1/kb* API+前端 modal+注入契约(bump 0.59.0)

- core/kb.py: 状态/视图层(INDEX 单行格式 parse/format、已入库判据=INDEX 有条目、
  删单篇连带原件与索引行、kb_block 注入——有库才注)
- core/kb_ingest.py: 入库管线 markitdown→扫描件 OCR 兜底(方舟)→flash 摘要(失败降级
  不阻塞)→追加 INDEX;编排 to_thread+per-(user,库) 锁,幂等可续跑
- 0022 迁移: usage_events.task_id 放宽可 NULL(kb 入库无 task 上下文);
  记账溯源 kind="kb_ingest"/"vision" + units {"kb","source"}
- web/app.py: /v1/kb* 8 端点(列/建/删库、详情带进度、上传即入库、手动 ingest、看/删单篇),
  不设 HTTP 检索端点(agent 走 fs 工具)
- 前端: kb.js 两栏 modal(上传 XHR/进度轮询/删除确认);rail 左下新增知识库入口,
  四按钮改图标+小字两行布局
- agent_builder 在 memory_block 后注 kb_block(docker/host 路径换算同 .memory);
  documents skill「何时不用」补自建资料路由行
- DESIGN §3.8 机制小节 / CHANGELOG 0.59.0 / PROGRESS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-22 15:20:43 +08:00
parent 5d2560985f
commit c6232d8325
15 changed files with 1100 additions and 10 deletions

View File

@ -5,6 +5,11 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.59.0 — 2026-07-22
- 新增「个人知识库」:左下角新入口,可以建多个库(如「行业标准」「项目资料」),把常用的 PDF / Word / PPT / Excel / 网页 / 文本资料上传进去,系统自动转成文字版、写好摘要索引;之后在对话里直接提问,我会按需查你的库作答并标注来源。扫描件 PDF 也会自动识别文字入库。库和文档可随时在面板里查看、删除。
- 左下角资源入口(技能 / 记忆 / 知识库 / 定时)改为图标在上、小字在下的紧凑样式。
## 0.58.55 — 2026-07-21
- 扫描版 PDF 现在也能读了:拍照或扫描生成的 PDF(老标准、检测报告、红头文件等,以前解析出来是空白)会自动逐页识别成文字,表格还原成表格、公式还原成公式,之后写申报书、编标准、做 PPT 都能直接引用其中内容。

View File

@ -85,6 +85,16 @@ Session = 消息列表,ORM 直写 PG `messages`(append-only,jsonb 存 LiteLLM
- **前端记忆面板只读,"改"全走对话**:看全貌是读、直读 FS 才是地面真相;改走 agent 自管 = 单一写入口、不写坏 frontmatter。故意零写/删 API;将来若"删一条"摩擦大再单加 delete(唯一廉价确定性 mutation)。路径穿越校验收口在 `core/memory.py`
- **快捷指令 ≠ memory**(`core/shortcuts.py`):触发词→完整指令,存 `.memory/shortcuts.md` 但**内容永不注上下文**——入口层(渠道核心 + web post_message 共用)整条精确匹配确定性替换,0 额外 token、渠道无关;maintenance 蹭 memory 心智(对话让模型写)。若反过来塞 core.md 靠模型概率召回:既不确定又每轮烧 token,正是要绕开的坑。
### 3.8 个人知识库(`core/kb.py` + `core/kb_ingest.py`,✅ 2026-07-22)
用户自建资料(规范/报告/标准/内部文档)的长期查阅层,与 §3.7 记忆同范式:**纯文件 + prompt 注入契约,无向量无 DB**(判据同"真实文件为准":个人库几十~百余文件,agentic search 足够;索引若引入只能是可重建派生缓存)。**两层格局**:zcbot 内建 `.kb/` 私有小库(本节)+ 院检索服务共享大库(document_search,zcbot 只当客户端)——分工标准 = 文件数 × 查询频次,路由规则写进注入契约(学科文献→院库 / 自建资料→`.kb`)。
- **做成机制而非 skill**(判据:有独立于会话的持久状态需用户管理 → 机制):落盘 `user_root/.kb/<库名>/`(INDEX.md + docs/ 转换后 md + sources/ 原件)。**"已入库"判据 = INDEX.md 有条目**,sources 有而 INDEX 无 = 待入库 → 入库幂等、崩溃可恢复、零 migration。dotfile 命名同 `.memory` 双向防呆,GET /v1/files 天然隐藏。
- **入库管线**(`core/kb_ingest.py`,上传即触发 + 手动兜底):markitdown Python API 转 md → 扫描件 PDF(文本近零)走方舟文档理解 OCR 兜底(§8.13 同通道)→ deepseek flash 单次 chat 写 标题/摘要/关键词(失败降级文件名+正文开头,不阻塞)→ 追加 INDEX 行。编排照定时执行器:create_task + to_thread + per-(user,库) 内存锁去重;进度存内存供前端轮询,崩了靠 FS 判据续跑。
- **agent 侧零新工具**:`kb_block`(照 memory_block)把 INDEX 全文 + 契约(路由规则 / INDEX 行格式 / 答题标来源)注 prompt,**用户有库才注入**;fs 工具在 user_root 内可读写、docker 沙箱整 user_root bind → `.kb` 天然可达。INDEX 行格式是对话内手动入库与后台产出的同一契约。
- **API 薄壳**(`/v1/kb*` 8 端点):列/建/删库、详情(带入库进度)、上传即入库、手动 ingest、看/删单篇。**不设 HTTP 检索端点**——检索是 agent 的事。前端两栏 modal(kb.js)管上传/删除,查询全走对话。
- **记账**:`usage_events` kind="kb_ingest"(OCR 那笔走 kind="vision"),无 task 上下文 → 0022 放宽 task_id 可 NULL,溯源靠 units JSONB `{"kb", "source"}`
---
## 4. 模型路由

View File

@ -23,6 +23,7 @@
### 2026-07
- **07-22 / 0.59.0 / 个人知识库(纯文件 .kb/ 机制,照记忆范式)**:方案 07-22 对齐后落地(DESIGN §3.8)。**core**:`core/kb.py`(状态/视图层:库名与文件名校验、INDEX 单行格式 `- [标题](docs/x.md)|来源 sources/x.pdf摘要关键词…` 的 parse/format、"已入库判据 = INDEX 有条目"派生 pending、删单篇连带原件与 INDEX 行、`kb_block` 注入)+ `core/kb_ingest.py`(入库管线:markitdown Python API → 扫描件 PDF 文本近零走方舟 OCR 兜底(§8.13 同通道)→ flash 单次 chat 写标题/摘要/关键词(失败降级不阻塞)→ 追加 INDEX;编排 create_task+to_thread+per-(user,库) 锁,进度内存态供轮询)。**记账**:0022 迁移放宽 `usage_events.task_id` 可 NULL(kb 入库无 task 上下文),溯源 = kind="kb_ingest"/"vision" + units `{"kb","source"}`(record_chat/vision_usage 签名同步放宽)。**API**:`/v1/kb*` 8 端点(列/建/删库、详情带入库进度、上传即入库、手动 ingest、看/删单篇),不设 HTTP 检索端点。**agent 侧零新工具**:agent_builder 在 memory_block 后注 `kb_block`(有库才注,docker/host 路径换算同 .memory);documents skill「何时不用」补自建资料路由行。**前端**:`kb.js` 两栏 modal(建/删库、上传 XHR、入库进度 3s 轮询、看/删单篇),rail 底部四按钮改图标+小字两行布局(用户 07-22 定)。冒烟:core 层 28 项全过(真实 markitdown + flash 摘要),TestClient API 15 项全过(usage 行实测 task_id=NULL + units 溯源),受影响存量测试(usage_accounting / system_prompt_paths)绿。
- **07-21 / 0.58.55 / 扫描件 PDF 直读(read_document,方舟文档理解)**:markitdown 只抽文本层,扫描件(老标准/检测报告/红头指南)转出为空=死路。探针(`scripts/probe_ark_doc.py`)验证方舟 chat file 内容块直读 PDF:格式 `{"type":"file","file":{filename,file_data}}` + `data:application/pdf;base64,` 前缀、base64 内联 17MB 可用(免 TOS)、单页栅格化 3600 万像素硬限(PIL 存 PDF 需标对 dpi)、~1300 输入 token/页(约 1 厘/页)、100 页魔术串全覆盖。落地 `tools/read_document.py`(seed_2_lite 同 variant 同 key,记账走 record_vision_usage):体积/页数双闸(30MB/100 页,pdfminer 软探页数免白付撞上下文)+ `finish_reason=length` 截断提示 + **多页 OCR `save_md` 全文落盘只返 1500 字预览**(防上下文爆);`image_ref.py` 抽 `load_pdf_as_data_url` 复用三形态路径解析与 user_root 边界。agent_builder 注册 + 系统提示 `_MEDIA_READDOC_SEG`(何时调/何时不调防重复花钱);六 skill(paper/patent/standard/proposal/rebuttal/ppt)摄取段加扫描件兜底一行。冒烟 `scripts/smoke_read_document.py` 全过(3 页 ¥0.0066,表格→md 表、公式→LaTeX)。选型对比(外部解析 API=新增第三方数据面 / 本地 OCR=过度投资)见 DESIGN §8.13。host 侧工具,**无需重建沙箱镜像**。
- **07-21 / 0.58.55 / 长对话点目录圆点首次跳不到位修复**:根因链=loadMessagesAround 后 renderMessages 尾部无条件滚底钉到窗口末尾,底部 sentinel 入视口立刻触发 loadNewerMessages 整窗重渲染删掉平滑滚动目标。修:renderMessages 加 stickBottom 参数(三个调窗口路径传 false);jumpToMessage 重建窗口后瞬时定位(auto)不留动画窗口期;`_msgScrollObserver` 在 `_outlineJumpLock` 期间不补载、解锁时对 sentinel 重投交叉状态。
- **07-21 / 0.58.54 / 手机端文件预览改悬浮卡片(四边留边距、不压输入区)**:用户反馈移动端预览弹框全屏贴边(100vw×100dvh、直角)观感差且糊在消息输入框上。改 `dev.html` ≤640px 媒体查询:遮罩层 `padding:10px` 四边留呼吸边距、底部 `calc(--preview-bottom-inset + 10px)` 在 chat-form 让位之上再加间隙;卡片尺寸改 `100%`(相对遮罩内容区,弃 100dvh,旧 WebView 兼容更稳),恢复默认圆角。输入区仍走既有 `body.fp-open #chat-form` z-index 抬升保持可用;桌面端不动。
@ -226,6 +227,8 @@ core/session.py 153 ← ORM
core/task.py 82 ← PG-backed TaskState
core/skills.py 180 ← 多来源 registry(SkillSource)+ source 标记 + 覆盖感知(user wins)+ load_errors + container_dir
core/memory.py 81 ← per-user `.memory/` dotfile
core/kb.py ~260 ← per-user `.kb/` 个人知识库(状态/视图/kb_block 注入)
core/kb_ingest.py ~280 ← kb 入库管线(markitdown→OCR 兜底→flash 摘要→INDEX)
core/export_docx.py 383
core/storage/{__init__,engine,models,usage,utils}.py ← 4 表(0004-0007 演进);record_chat/image_usage
core/ark_client.py 105 ← 火山方舟 HTTP 客户端
@ -235,11 +238,11 @@ core/agent_builder.py 340 ← 装配 lib(有 ARK_API_KEY 才挂 SeedreamT
core/executor.py / sandbox/{network,pool}.py / executor_docker.py ← Executor ABC + Docker per-user 容器池
tools/{base,fs,shell,run_python,skill_tool,skill_authoring,seedream,seedance,look_at_image,read_document,image_ref,web_search,web_fetch,documents,materials_project,transcribe_audio}.py ← read_document=扫描件 PDF OCR(方舟文档理解);image_ref=图/PDF 路径解析+base64 共享
main.py ~210 ← 入口:web / db / probe / user / sandbox check
db/migrations/versions/ 0001-0008
db/migrations/versions/ 0001-0022
web/app.py ~1360 ← /v1 JSON API + user_id 隔离 + run lock + cancel + files + pptx 预览 + skills(列表/正文/删)
web/auth.py ~190 ← 邮箱密码 + platform_key → JWT
web/broker.py / sinks.py / pptx_render.py
web/static/dev.html + js/*.js ← dev SPA 拆 15 个零构建 ES module(main.js 入口;skills.js=技能查看 modal)
web/static/dev.html + js/*.js ← dev SPA 零构建 ES module(main.js 入口;skills.js=技能 modal;kb.js=知识库 modal)
web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx
─────────────────────────────────
Python 合计 ~3400 行(+ dev SPA + vendor 1MB);加 skills 脚本 + 配置,总仓库约 3800 行

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.58.55"
__version__ = "0.59.0"

View File

@ -29,6 +29,7 @@ from rich.console import Console
from core.capabilities import ModelCapabilities
from core.executor_docker import DockerExecutor
from core.executor_host import HostExecutor
from core.kb import kb_block
from core.llm import LLM
from core.loop import AgentLoop
from core.memory import memory_block
@ -361,6 +362,11 @@ def _build_system_prompt(
user_root(workspace_dir, user_id) / ".memory"
)
prompt += memory_block(workspace_dir, user_id, mem_dir_display)
# 个人知识库 .kb/:路径换算同 .memory(docker 给容器路径);用户没建库时注空串零成本。
kb_dir_display = "/workspace/.kb" if is_docker else str(
user_root(workspace_dir, user_id) / ".kb"
)
prompt += kb_block(workspace_dir, user_id, kb_dir_display)
if media_block:
prompt += "\n\n" + media_block
wd_abs = working_dir.resolve()

288
core/kb.py Normal file
View File

@ -0,0 +1,288 @@
"""个人知识库: `workspace/users/<user_id>/.kb/<库名>/` —— 纯文件,无向量无 DB。
记忆机制范式(core/memory.py):真实文件为准 + agentic search,索引只是
可重建的派生视图每个库一个目录:
.kb/<库名>/
INDEX.md 库目录(单行一条,格式见 INDEX_LINE_FORMAT), prompt 的召回依据
docs/<x>.md 转换后的 markdown 正文(agent read/grep 按需拉)
sources/<x> 原始文件(pdf/docx/...),留档 + 待入库队列
**已入库判据 = INDEX.md 有指向该 doc 的条目**;sources 有而 INDEX 无对应 doc
= 待入库由此入库天然幂等(重跑只补缺口)崩溃可恢复(半截转换重来即可)
DB migration入库编排在 core/kb_ingest.py,本模块只管状态读写与视图
.kb dotfile: GET /v1/files 天然隐藏validate_task_name `.` 起头
.memory/.skills 同款防呆agent 无需新工具:fs 工具 user_root 内可读写,
docker 沙箱把整个 user_root bind /workspace,.kb 随之可见
"""
from __future__ import annotations
import re
import shutil
from pathlib import Path
from typing import Any, Dict, List, Optional
from uuid import UUID
# INDEX 单行格式(全角 分隔,摘要/关键词内允许半角标点)。agent 对话内手动入库
# 与后台 ingest 产出同一格式 —— 契约文本(kb_block)里原样给出。
INDEX_LINE_FORMAT = "- [标题](docs/<文件名>.md)|来源 sources/<原件名>|摘要:<两三句>|关键词:<逗号分隔>"
_INDEX_LINE_RE = re.compile(
r"^-\s*\[(?P<title>[^\]]*)\]\((?P<doc>docs/[^)]+)\)"
r"\s*\s*来源\s*(?P<source>sources/[^]+?)"
r"\s*\s*摘要[:]\s*(?P<summary>[^]*)"
r"(?:\s*\s*关键词[:]\s*(?P<keywords>.*))?\s*$"
)
# 库名:中文/字母/数字/-/_,拒 dotfile、路径分隔、Windows 保留字符。
_KB_NAME_RE = re.compile(r"^[\w一-鿿][\w一-鿿\-. ]{0,39}$")
# 库内文件名(docs/ 与 sources/ 下的扁平文件):拒斜杠 / `..` / dotfile。
_FILE_NAME_RE = re.compile(r"^[^/\\]{1,200}$")
def kb_root(workspace_dir: Path, user_id: UUID) -> Path:
return workspace_dir / "users" / str(user_id) / ".kb"
def is_safe_kb_name(name: str) -> bool:
if not name or name != name.strip() or name.startswith("."):
return False
if ".." in name or any(c in name for c in '/\\:*?"<>|'):
return False
return bool(_KB_NAME_RE.match(name))
def is_safe_file_name(name: str) -> bool:
if not name or name.startswith(".") or ".." in name:
return False
return bool(_FILE_NAME_RE.match(name))
def kb_dir(workspace_dir: Path, user_id: UUID, name: str) -> Optional[Path]:
"""库目录(校验名字合法 + 落在 .kb 子树内);非法返回 None(调用方转 4xx)。"""
if not is_safe_kb_name(name):
return None
root = kb_root(workspace_dir, user_id).resolve()
d = (root / name).resolve()
if d.parent != root:
return None
return d
def parse_index(text: str) -> List[Dict[str, str]]:
"""解析 INDEX.md → [{title, doc, source, summary, keywords}];不合格式的行忽略。"""
out: List[Dict[str, str]] = []
for raw in text.splitlines():
m = _INDEX_LINE_RE.match(raw.strip())
if not m:
continue
out.append({
"title": m.group("title").strip(),
"doc": m.group("doc").strip(),
"source": (m.group("source") or "").strip(),
"summary": (m.group("summary") or "").strip(),
"keywords": (m.group("keywords") or "").strip(),
})
return out
def format_index_line(*, title: str, doc: str, source: str, summary: str, keywords: str) -> str:
"""产出与 INDEX_LINE_FORMAT 一致的单行(后台 ingest 用;agent 侧照契约手写)。"""
def clean(s: str) -> str:
return " ".join((s or "").split()).replace("", "|")
return (
f"- [{clean(title)}]({doc})|来源 {source}"
f"|摘要:{clean(summary)}|关键词:{clean(keywords)}"
)
def _read_index(d: Path) -> List[Dict[str, str]]:
p = d / "INDEX.md"
if not p.is_file():
return []
try:
return parse_index(p.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError):
return []
def _list_sources(d: Path) -> List[str]:
src = d / "sources"
if not src.is_dir():
return []
return sorted(p.name for p in src.iterdir() if p.is_file() and not p.name.startswith("."))
def pending_sources(d: Path) -> List[str]:
"""sources 有而 INDEX 无 = 待入库(判据即幂等性来源,见模块注释)。"""
indexed = {e["source"].removeprefix("sources/") for e in _read_index(d)}
return [n for n in _list_sources(d) if n not in indexed]
def list_kbs(workspace_dir: Path, user_id: UUID) -> List[Dict[str, Any]]:
"""所有库概览:[{name, doc_count, pending_count}],按名排序。"""
root = kb_root(workspace_dir, user_id)
if not root.is_dir():
return []
out: List[Dict[str, Any]] = []
for d in sorted(root.iterdir()):
if not d.is_dir() or d.name.startswith("."):
continue
entries = _read_index(d)
out.append({
"name": d.name,
"doc_count": len(entries),
"pending_count": len(pending_sources(d)),
})
return out
def create_kb(workspace_dir: Path, user_id: UUID, name: str) -> Optional[Path]:
"""建库(幂等):目录 + 空 INDEX.md + docs/ + sources/。名字非法返回 None。"""
d = kb_dir(workspace_dir, user_id, name)
if d is None:
return None
(d / "docs").mkdir(parents=True, exist_ok=True)
(d / "sources").mkdir(parents=True, exist_ok=True)
idx = d / "INDEX.md"
if not idx.exists():
idx.write_text(f"# {name}\n\n", encoding="utf-8")
return d
def delete_kb(workspace_dir: Path, user_id: UUID, name: str) -> bool:
"""整库删除(原件 + docs + INDEX 一起没,前端已二次确认)。"""
d = kb_dir(workspace_dir, user_id, name)
if d is None or not d.is_dir():
return False
shutil.rmtree(d)
return True
def kb_detail(workspace_dir: Path, user_id: UUID, name: str) -> Optional[Dict[str, Any]]:
"""单库全貌:INDEX 条目 + 待入库 sources 列表。库不存在返回 None。"""
d = kb_dir(workspace_dir, user_id, name)
if d is None or not d.is_dir():
return None
return {
"name": name,
"entries": _read_index(d),
"pending": pending_sources(d),
}
def read_doc(workspace_dir: Path, user_id: UUID, name: str, filename: str) -> Optional[str]:
"""读单篇 docs/<filename> 原文;非法 / 不存在 → None(调用方转 404)。"""
d = kb_dir(workspace_dir, user_id, name)
if d is None or not is_safe_file_name(filename) or not filename.endswith(".md"):
return None
target = (d / "docs" / filename).resolve()
if target.parent != (d / "docs").resolve() or not target.is_file():
return None
try:
return target.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return None
def delete_doc(workspace_dir: Path, user_id: UUID, name: str, filename: str) -> bool:
"""删单篇:docs 文件 + INDEX 对应行 + 对应 source 原件一起删(否则原件会被当
待入库重新转一遍 判据使然)"""
d = kb_dir(workspace_dir, user_id, name)
if d is None or not is_safe_file_name(filename) or not filename.endswith(".md"):
return False
doc_rel = f"docs/{filename}"
entries = _read_index(d)
hit = next((e for e in entries if e["doc"] == doc_rel), None)
target = (d / "docs" / filename).resolve()
if target.parent != (d / "docs").resolve():
return False
if not target.is_file() and hit is None:
return False
if target.is_file():
target.unlink()
if hit is not None:
src_name = hit["source"].removeprefix("sources/")
if is_safe_file_name(src_name):
src = d / "sources" / src_name
if src.is_file():
src.unlink()
idx = d / "INDEX.md"
try:
lines = idx.read_text(encoding="utf-8").splitlines()
kept = [
ln for ln in lines
if not (_INDEX_LINE_RE.match(ln.strip()) and f"({doc_rel})" in ln)
]
idx.write_text("\n".join(kept).rstrip() + "\n", encoding="utf-8")
except (OSError, UnicodeDecodeError):
pass
return True
def save_source(workspace_dir: Path, user_id: UUID, name: str, filename: str, data: bytes) -> Optional[str]:
"""上传原件落 sources/(同名覆盖 —— 重传即重新入库的自然语义)。
返回落盘文件名;库不存在 / 文件名非法 None"""
d = kb_dir(workspace_dir, user_id, name)
if d is None or not d.is_dir() or not is_safe_file_name(filename):
return None
src_dir = d / "sources"
src_dir.mkdir(parents=True, exist_ok=True)
# 覆盖旧 doc 判据:同名 source 若已在 INDEX,删掉旧条目让它重新排队入库
doc_rel_hits = [e for e in _read_index(d) if e["source"] == f"sources/{filename}"]
for e in doc_rel_hits:
delete_doc(workspace_dir, user_id, name, e["doc"].removeprefix("docs/"))
(src_dir / filename).write_bytes(data)
return filename
# ── prompt 注入(照 memory_block 范式) ────────────────────────────────
_KB_CONTRACT = """\
用法规矩:
- **路由**:**材料学科学术文献**优先 `document_search`(院内共享大库);**用户自建
资料**(个人上传的规范 / 报告 / 标准 / 内部文档)用下面的知识库 先看各库 INDEX
条目定位, `read` 对应 `docs/*.md` 正文,量大时 `grep` 先缩范围
- **答题标来源**:引用了哪个库哪篇就在回答里注明(标题或文件名)
- **对话内入库**:用户在对话里给了值得长期留的资料时,可直接写入:原件放
`sources/`(没有原件就跳过)正文转成 markdown `docs/<slug>.md`再往该库
INDEX.md 追加一行,**格式必须是**:
`{fmt}`
(与后台自动入库产出一致;摘要写准 它是下次召回的依据)"""
def kb_block(
workspace_dir: Path,
user_id: UUID,
kb_dir_display: Optional[str] = None,
) -> str:
"""构造注入 system prompt 的知识库段;用户没有任何库时返回空串(零成本)。
kb_dir_display: `.kb/` agent 视角下的路径前缀(docker `/workspace/.kb`,
host None 宿主绝对路径) memory_block mem_dir_display 同款约定
INDEX 全文而非只注库名:INDEX 就是召回索引( memory extended
description 逻辑),几十篇的个人库体量注得起;正文仍按需 read
"""
kbs = list_kbs(workspace_dir, user_id)
if not kbs:
return ""
root = kb_root(workspace_dir, user_id)
base = (kb_dir_display if kb_dir_display is not None else str(root)).rstrip("/")
parts = ["\n\n## 个人知识库 (user 级,跨 task 共享)\n"]
parts.append(_KB_CONTRACT.replace("{fmt}", INDEX_LINE_FORMAT))
for kb in kbs:
d = root / kb["name"]
parts.append(f"\n\n### 库「{kb['name']}」(`{base}/{kb['name']}/`)\n")
entries = _read_index(d)
if not entries:
parts.append("(空库,尚无已入库文档)\n")
continue
for e in entries:
kw = f"|关键词:{e['keywords']}" if e["keywords"] else ""
parts.append(
f"- [{e['title']}](`{base}/{kb['name']}/{e['doc']}`)"
f"|摘要:{e['summary']}{kw}\n"
)
return "".join(parts)

299
core/kb_ingest.py Normal file
View File

@ -0,0 +1,299 @@
"""kb 入库后台任务:sources/ 待入库原件 → docs/*.md + 摘要 + INDEX 行。
流水线(每份原件独立,失败不连坐):
1. markitdown Python API 抽文本(pdf/docx/pptx/xlsx/html/txt...)
2. PDF 文本近零(扫描件) 方舟 seed-2.0-lite 文档理解 OCR 兜底
( tools/read_document.py 同款请求体;ARK_API_KEY 未配则该件报错留待)
3. deepseek flash 单次 chat 标题/摘要/关键词( core/context_fold.py 范式;
LLM 失败降级为文件名 + 正文开头,入库不阻塞)
4. 追加 INDEX (core.kb.format_index_line)写入即"已入库"
5. record_chat_usage(kind="kb_ingest", task_id=None, units {"kb","source"})
编排照定时执行器范式:web asyncio.create_task(asyncio.to_thread(run_ingest, ...)),
本模块全同步;per-(user,) threading.Lock 非阻塞抢占 抢不到 = 已在入库,直接返回
(上传即触发 + 手动触发天然去重)进度状态存内存 dict 供前端轮询;崩溃丢状态无妨,
"待入库"判据在文件系统(INDEX 缺口),重触发即续跑
"""
from __future__ import annotations
import base64
import hashlib
import re
import threading
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from uuid import UUID
from core.ark_client import ArkClient, ArkError
from core.capabilities import ModelCapabilities
from core.kb import format_index_line, kb_dir, parse_index, pending_sources
from core.llm import LLM
from core.storage.usage import record_chat_usage, record_vision_usage
# 摘要模型固定走最便宜档(与 web FALLBACK_MODEL_PROFILE 同值;单次几千 token,不随任务模型)
SUMMARY_PROFILE = "deepseek_v4.flash"
# 判定"文本近零"(扫描件)的阈值:markitdown 抽出的字符数低于此值即认为无文本层
_MIN_TEXT_CHARS = 120
# 喂给摘要模型的正文截断长度(开头段落通常含标题/摘要/目录,足够定性)
_SUMMARY_INPUT_CHARS = 5000
_OCR_QUESTION = (
"这是一份多页 PDF 文档。请逐页把其中的文字完整 OCR 成 markdown:"
"每页以「== 第N页 ==」开头;表格转成 markdown 表格;保留标题层级与段落换行;"
"公式尽量用 LaTeX;不要总结、不要遗漏、不要自行补充原文没有的内容。"
)
_SUMMARY_PROMPT = """\
下面是文档{filename}的正文开头(可能被截断)请为它写入库索引条目,严格按以下三行格式输出,不要输出任何其他文字:
标题<文档标题,没有明确标题就概括一个,不超过 40 >
摘要<两三句话说清这份文档讲什么有什么用,不超过 120 >
关键词<3-6 个检索关键词,用逗号分隔>
正文开头:
{content}"""
# ── 进度状态(内存,供 API 轮询) ─────────────────────────────────────
_guard = threading.Lock()
_locks: Dict[Tuple[str, str], threading.Lock] = {}
_status: Dict[Tuple[str, str], Dict[str, Any]] = {}
def _lock_for(key: Tuple[str, str]) -> threading.Lock:
with _guard:
return _locks.setdefault(key, threading.Lock())
def ingest_status(user_id: UUID, name: str) -> Dict[str, Any]:
"""当前/最近一次入库进度(无记录返回 idle);前端轮询 + 库详情附带。"""
st = _status.get((str(user_id), name))
return dict(st) if st else {"running": False, "total": 0, "done": 0, "errors": []}
# ── 转换 ─────────────────────────────────────────────────────────────
def _extract_text(src: Path) -> str:
"""markitdown 抽文本;不支持的格式 / 解析失败抛 ValueError(留待重试)。"""
from markitdown import MarkItDown
try:
result = MarkItDown(enable_plugins=False).convert(str(src))
except Exception as e:
raise ValueError(f"markitdown 转换失败: {type(e).__name__}: {e}")
return (result.text_content or "").strip()
def _ocr_pdf(src: Path, *, user_id: UUID, kb_name: str) -> str:
"""扫描件 PDF 走方舟文档理解 OCR(照 tools/read_document.py 请求体)。
失败抛 ValueError记账 kind=visiontask_id=Noneunits kb/source 溯源
"""
from core.ark_client import ArkConfig
ark_cfg = ArkConfig.load()
vision_cfg = (ark_cfg.raw.get("vision") or {}) if ark_cfg else {}
vis_key, cfg = "", None
for k, v in vision_cfg.items():
if isinstance(v, dict):
vis_key, cfg = k, v
break
if ark_cfg is None or cfg is None:
raise ValueError("扫描件 PDF 需 OCR,但方舟(ARK_API_KEY / vision 段)未配置")
max_bytes = int(float(cfg.get("max_pdf_mb", 30)) * 1024 * 1024)
size = src.stat().st_size
if size > max_bytes:
raise ValueError(f"PDF {size / 1e6:.1f}MB 超过 OCR 上限 {max_bytes / 1e6:.0f}MB")
data_url = "data:application/pdf;base64," + base64.b64encode(src.read_bytes()).decode()
body = {
"model": cfg["model_id"],
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": _OCR_QUESTION},
{"type": "file", "file": {"filename": src.name, "file_data": data_url}},
],
}],
}
timeout_s = float(cfg.get("doc_request_timeout_s", 600))
try:
with ArkClient(ark_cfg, timeout_s=timeout_s) as client:
resp = client.post_json(cfg.get("endpoint", "/chat/completions"), body, timeout_s=timeout_s)
except ArkError as e:
raise ValueError(f"OCR 调用失败: {e}")
choices = resp.get("choices") or []
msg = (choices[0].get("message") if choices and isinstance(choices[0], dict) else None) or {}
content = msg.get("content")
if isinstance(content, list):
content = "\n".join(
c.get("text", "") for c in content if isinstance(c, dict) and c.get("type") == "text"
)
text = (content or "").strip() if isinstance(content, (str,)) else ""
if not text:
raise ValueError("OCR 响应无文本(PDF 损坏或页面超像素上限)")
usage = resp.get("usage") or {}
try:
record_vision_usage(
task_id=None,
user_id=user_id,
model_profile=f"doubao.{vis_key}",
prompt_tokens=int(usage.get("prompt_tokens", 0) or 0),
completion_tokens=int(usage.get("completion_tokens", 0) or 0),
input_cny_per_mtoken=float(cfg.get("price_cny_per_mtoken_input", 0)),
output_cny_per_mtoken=float(cfg.get("price_cny_per_mtoken_output", 0)),
extra_units={"kb": kb_name, "source": src.name},
)
except Exception:
pass # 记账失败不阻塞入库(与 loop 同纪律)
return text
# ── 摘要 ─────────────────────────────────────────────────────────────
def _parse_summary(raw: str) -> Tuple[str, str, str]:
"""解析三行格式;缺行返回空串由调用方兜底。"""
title = summary = keywords = ""
for line in raw.splitlines():
line = line.strip().lstrip("*# ")
m = re.match(r"^(标题|摘要|关键词)[:]\s*(.*)$", line)
if not m:
continue
key, val = m.group(1), m.group(2).strip()
if key == "标题" and not title:
title = val
elif key == "摘要" and not summary:
summary = val
elif key == "关键词" and not keywords:
keywords = val
return title, summary, keywords
def _summarize(
llm: LLM, caps: ModelCapabilities, *, text: str, filename: str,
user_id: UUID, kb_name: str,
) -> Tuple[str, str, str]:
"""单次 chat 产 (标题, 摘要, 关键词);失败降级文件名 + 正文开头,不阻塞入库。"""
fallback = (Path(filename).stem, text[:100].replace("\n", " "), "")
try:
response = llm.chat(messages=[{
"role": "user",
"content": _SUMMARY_PROMPT.format(filename=filename, content=text[:_SUMMARY_INPUT_CHARS]),
}], tools=None)
choices = getattr(response, "choices", None) or []
raw = ((choices[0].message.content if choices else "") or "").strip()
except Exception as e:
print(f"[kb_ingest] summary llm failed ({filename}): {type(e).__name__}: {e}", flush=True)
return fallback
usage = getattr(response, "usage", None)
try:
record_chat_usage(
task_id=None,
user_id=user_id,
message_id=None,
model_profile=f"{caps.family}.{caps.variant}",
prompt_tokens=getattr(usage, "prompt_tokens", 0) or 0,
completion_tokens=getattr(usage, "completion_tokens", 0) or 0,
input_cny_per_mtoken=caps.input_cny_per_mtoken,
output_cny_per_mtoken=caps.output_cny_per_mtoken,
response=response,
kind="kb_ingest",
extra_units={"kb": kb_name, "source": filename},
)
except Exception:
pass
title, summary, keywords = _parse_summary(raw)
return (title or fallback[0], summary or fallback[1], keywords)
# ── 主流程 ───────────────────────────────────────────────────────────
def _doc_name_for(d: Path, source_name: str) -> str:
"""source 原件名 → docs/ 下的 md 文件名;与既有他源文档撞名时加短 hash 后缀。"""
stem = Path(source_name).stem
stem = re.sub(r'[\\/:*?"<>|\[\]()]+', "_", stem).strip("._ ") or "doc"
stem = stem[:80]
entries = parse_index((d / "INDEX.md").read_text(encoding="utf-8")) if (d / "INDEX.md").is_file() else []
doc_rel = f"docs/{stem}.md"
for e in entries:
if e["doc"] == doc_rel and e["source"] != f"sources/{source_name}":
suffix = hashlib.md5(source_name.encode("utf-8")).hexdigest()[:6]
return f"{stem}_{suffix}.md"
return f"{stem}.md"
def _append_index(d: Path, line: str) -> None:
idx = d / "INDEX.md"
text = idx.read_text(encoding="utf-8") if idx.is_file() else ""
if text and not text.endswith("\n"):
text += "\n"
idx.write_text(text + line + "\n", encoding="utf-8")
def run_ingest(workspace_dir: Path, user_id: UUID, kb_name: str, models_dir: Path) -> bool:
"""同步跑一轮入库(web 层用 asyncio.to_thread 下沉)。
返回 False = 没跑(锁被占,已有入库在进行 / 库不存在);True = 跑完(含空转)
幂等:只处理 INDEX 缺口,单件失败记入 status.errors 并保持待入库,下次重触发续跑
"""
d = kb_dir(workspace_dir, user_id, kb_name)
if d is None or not d.is_dir():
return False
key = (str(user_id), kb_name)
lock = _lock_for(key)
if not lock.acquire(blocking=False):
return False
try:
pending = pending_sources(d)
st: Dict[str, Any] = {
"running": True, "total": len(pending), "done": 0,
"current": None, "errors": [],
"started_at": datetime.now().isoformat(timespec="seconds"),
}
_status[key] = st
if not pending:
return True
llm: Optional[LLM] = None
caps: Optional[ModelCapabilities] = None
try:
caps = ModelCapabilities.load(SUMMARY_PROFILE, models_dir)
llm = LLM(caps)
except Exception as e:
print(f"[kb_ingest] summary model load failed: {type(e).__name__}: {e}", flush=True)
for name in pending:
st["current"] = name
src = d / "sources" / name
try:
text = _extract_text(src)
if len(text) < _MIN_TEXT_CHARS and src.suffix.lower() == ".pdf":
text = _ocr_pdf(src, user_id=user_id, kb_name=kb_name)
if len(text) < _MIN_TEXT_CHARS:
raise ValueError(f"抽出文本过少({len(text)} 字符),无法入库")
doc_name = _doc_name_for(d, name)
(d / "docs").mkdir(parents=True, exist_ok=True)
(d / "docs" / doc_name).write_text(text, encoding="utf-8")
if llm is not None and caps is not None:
title, summary, keywords = _summarize(
llm, caps, text=text, filename=name, user_id=user_id, kb_name=kb_name
)
else:
title, summary, keywords = Path(name).stem, text[:100].replace("\n", " "), ""
_append_index(d, format_index_line(
title=title, doc=f"docs/{doc_name}", source=f"sources/{name}",
summary=summary, keywords=keywords,
))
st["done"] += 1
except Exception as e:
msg = str(e) if isinstance(e, ValueError) else f"{type(e).__name__}: {e}"
st["errors"].append({"source": name, "error": msg})
print(f"[kb_ingest] {kb_name}/{name} failed: {msg}", flush=True)
return True
finally:
st = _status.get(key)
if st is not None:
st["running"] = False
st["current"] = None
st["finished_at"] = datetime.now().isoformat(timespec="seconds")
lock.release()

View File

@ -163,10 +163,12 @@ class UsageEvent(Base):
user_id: Mapped[UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("users.user_id"), nullable=False
)
task_id: Mapped[UUID] = mapped_column(
# NULL = 非 task 维度的用量(0022 放宽)。目前仅 kind="kb_ingest"(知识库入库,
# 溯源在 units JSONB:{"kb": 库名, "source": 原件名}),对账按 kind 聚合即可区分。
task_id: Mapped[Optional[UUID]] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("tasks.task_id", ondelete="CASCADE"),
nullable=False,
nullable=True,
)
message_id: Mapped[Optional[UUID]] = mapped_column(
PG_UUID(as_uuid=True),

View File

@ -78,7 +78,7 @@ def _fallback_chat_cost_cny(
def record_chat_usage(
*,
task_id: UUID,
task_id: Optional[UUID],
user_id: UUID,
message_id: Optional[UUID],
model_profile: str,
@ -99,6 +99,8 @@ def record_chat_usage(
`model_profile` 形如 `"deepseek_v4.pro"`(family.variant)
`kind` 默认 "chat";chat 形态的辅助调用可传别的标签( "context_fold"),
kind GROUP BY 对账 + 派生统计(折叠次数 = count) prompt_optimize 同范式
`task_id` 可为 None(0022): task 维度的调用( kind="kb_ingest")没有 task
可挂,溯源靠 kind + extra_units(kb_ingest {"kb": 库名, "source": 原件名})
返回算出的 cost_cny(已落库),调用方可用作 SSE 显示
"""
cost_usd = _safe_chat_cost_usd(response)
@ -395,7 +397,7 @@ def record_video_usage(
def record_vision_usage(
*,
task_id: UUID,
task_id: Optional[UUID],
user_id: UUID,
model_profile: str,
prompt_tokens: int,

View File

@ -0,0 +1,38 @@
"""usage_events.task_id 放宽为 NULL(知识库入库记账).
Revision ID: 0022
Revises: 0021
Create Date: 2026-07-22
kb 入库(kind="kb_ingest")在任何 task 之外跑(上传/手动触发的后台任务),
没有 task 上下文可挂 task_id NOT NULL 挡住这类事件放宽为 NULL:
纯放宽约束,存量行全部满足,零数据迁移;NULL = "非 task 维度的用量事件"
统计侧不受影响( user/kind 聚合的 query 不依赖 task_id 非空)
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0022"
down_revision: Union[str, None] = "0021"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.alter_column(
"usage_events", "task_id",
existing_type=sa.dialects.postgresql.UUID(as_uuid=True),
nullable=True,
)
def downgrade() -> None:
# 回滚前提:已无 task_id IS NULL 的行(kb_ingest 事件需先清理),否则失败。
op.alter_column(
"usage_events", "task_id",
existing_type=sa.dialects.postgresql.UUID(as_uuid=True),
nullable=False,
)

View File

@ -20,6 +20,7 @@ description: 查内部材料学科知识库(document_search API,7 个学科:胶
- 用户只问通识(直接答)
- 用户已经给了具体内部文档路径(直接读,不要二次校验)
- 用户问的是**自己上传的自建资料**(规范 / 报告 / 内部文档)→ 走个人知识库(system prompt「个人知识库」段注入的 `.kb/` 索引,fs 工具直接 read/grep),不在本库搜
## 三个 tool

View File

@ -866,6 +866,10 @@ class ChangePasswordRequest(BaseModel):
new_password: str
class KbCreateRequest(BaseModel):
name: str # 库名(中文/字母/数字/-/_,拒 dotfile 与路径字符,≤40 字符)
# ────────────────────── App 工厂 ──────────────────────
# web/static 目录路径 — /static 静态挂载用,dev.html 也放这
@ -2520,6 +2524,132 @@ def create_app() -> FastAPI:
raise HTTPException(404, f"memory file not found: {filename!r}")
return {"filename": filename, "content": content}
# ───────────── 个人知识库(纯文件 .kb/,照记忆机制范式)─────────────
# 状态全在 FS(core/kb.py),端点只是薄壳;入库后台任务照定时执行器范式
# (create_task + to_thread,per-(user,库) 锁在 core/kb_ingest.py 内去重)。
# 不设 HTTP 检索端点 —— agent 侧走 fs 工具 read/grep(kb_block 注入契约)。
_kb_ingest_tasks: set = set() # 持引用防 GC(fire-and-forget task 的惯例)
def _spawn_kb_ingest(user_id: UUID, name: str) -> None:
from core.agent_builder import load_config, resolve_workspace
from core.kb_ingest import run_ingest
cfg = load_config()
ws = resolve_workspace(None)
t = asyncio.create_task(
asyncio.to_thread(run_ingest, ws, user_id, name, ROOT / cfg["models_dir"])
)
_kb_ingest_tasks.add(t)
t.add_done_callback(_kb_ingest_tasks.discard)
@app.get("/v1/kb", tags=["kb"])
def kb_list(user_id: UUID = Depends(require_user)):
"""所有库概览:[{name, doc_count, pending_count}]。前端入口一次拉满。"""
from core.agent_builder import resolve_workspace
from core.kb import list_kbs
return {"results": list_kbs(resolve_workspace(None), user_id)}
@app.post("/v1/kb", tags=["kb"])
def kb_create(body: KbCreateRequest, user_id: UUID = Depends(require_user)):
"""建库(幂等)。名字非法 → 400。"""
from core.agent_builder import resolve_workspace
from core.kb import create_kb
name = (body.name or "").strip()
if create_kb(resolve_workspace(None), user_id, name) is None:
raise HTTPException(400, f"invalid kb name: {body.name!r}")
return {"name": name}
@app.get("/v1/kb/{name}", tags=["kb"])
def kb_detail_ep(name: str, user_id: UUID = Depends(require_user)):
"""单库全貌:INDEX 条目 + 待入库列表 + 入库进度(前端轮询这里)。"""
from core.agent_builder import resolve_workspace
from core.kb import kb_detail
from core.kb_ingest import ingest_status
detail = kb_detail(resolve_workspace(None), user_id, name)
if detail is None:
raise HTTPException(404, f"kb not found: {name!r}")
detail["ingest"] = ingest_status(user_id, name)
return detail
@app.delete("/v1/kb/{name}", tags=["kb"])
def kb_delete(name: str, user_id: UUID = Depends(require_user)):
"""整库删除(原件 + docs + INDEX)。入库进行中 → 409(避免半截写盘)。"""
from core.agent_builder import resolve_workspace
from core.kb import delete_kb
from core.kb_ingest import ingest_status
if ingest_status(user_id, name).get("running"):
raise HTTPException(409, "该库正在入库,等入库结束再删除")
if not delete_kb(resolve_workspace(None), user_id, name):
raise HTTPException(404, f"kb not found: {name!r}")
return {"deleted": name}
@app.post("/v1/kb/{name}/upload", tags=["kb"])
async def kb_upload(
name: str,
files: list[UploadFile] = File(...),
user_id: UUID = Depends(require_user),
):
"""上传原件到 sources/ 并立即触发入库(上传即入库)。
同名覆盖 = 重新入库(core/kb.py::save_source 会摘掉旧 INDEX 条目重排队)
磁盘配额 gate /v1/files/upload 同款
"""
from core.agent_builder import load_config as _load_cfg, resolve_workspace
from core.kb import kb_dir, save_source
from core.storage.disk_quota import check_disk_quota, parse_bytes
_quotas_cfg = (_load_cfg().get("quotas") or {})
_limit = parse_bytes(_quotas_cfg.get("disk_bytes_per_user"))
if _limit is not None and _limit > 0:
_err = check_disk_quota(user_id, _limit)
if _err is not None:
raise HTTPException(413, _err)
ws = resolve_workspace(None)
d = kb_dir(ws, user_id, name)
if d is None or not d.is_dir():
raise HTTPException(404, f"kb not found: {name!r}")
saved: list[dict] = []
for up in files or []:
raw_name = up.filename or ""
data = await up.read()
ok = save_source(ws, user_id, name, raw_name, data)
if ok is None:
raise HTTPException(400, f"invalid filename: {raw_name!r}")
saved.append({"name": ok, "size": len(data)})
if not saved:
raise HTTPException(400, "no files uploaded")
_spawn_kb_ingest(user_id, name)
return {"count": len(saved), "saved": saved}
@app.post("/v1/kb/{name}/ingest", tags=["kb"])
async def kb_ingest_trigger(name: str, user_id: UUID = Depends(require_user)):
"""手动触发入库(兜待入库缺口:上传时崩溃 / 单件失败重试)。幂等,已在跑则空转。"""
from core.agent_builder import resolve_workspace
from core.kb import kb_dir
if kb_dir(resolve_workspace(None), user_id, name) is None:
raise HTTPException(404, f"kb not found: {name!r}")
_spawn_kb_ingest(user_id, name)
return {"started": True}
@app.get("/v1/kb/{name}/docs/{filename}", tags=["kb"])
def kb_doc_read(name: str, filename: str, user_id: UUID = Depends(require_user)):
"""读单篇转换后 markdown(点开列表项时拉)。穿越校验收口 core/kb.py::read_doc。"""
from core.agent_builder import resolve_workspace
from core.kb import read_doc
content = read_doc(resolve_workspace(None), user_id, name, filename)
if content is None:
raise HTTPException(404, f"doc not found: {filename!r}")
return {"filename": filename, "content": content}
@app.delete("/v1/kb/{name}/docs/{filename}", tags=["kb"])
def kb_doc_delete(name: str, filename: str, user_id: UUID = Depends(require_user)):
"""删单篇(docs 文件 + INDEX 行 + source 原件,防原件被重新入库)。"""
from core.agent_builder import resolve_workspace
from core.kb import delete_doc
if not delete_doc(resolve_workspace(None), user_id, name, filename):
raise HTTPException(404, f"doc not found: {filename!r}")
return {"deleted": filename}
# ───────────── 定时任务(DESIGN §8.5)─────────────
# 前端只读展示 + 停用/删除两个便捷动作;建/改全走对话(schedule_* 工具)。
# 与对话工具共用 core.scheduler 服务层,两条路径不漂移。

View File

@ -248,14 +248,15 @@
.app-msg.error::before { content: "\2715"; color: var(--c-red); }
.app-msg.info::before { content: "\2139"; color: var(--c-blue); }
/* ───── 左侧 rail 底部「我的资源」入口(技能,后续可加记忆)───── */
/* ───── 左侧 rail 底部「我的资源」入口(技能 / 记忆 / 知识库 / 定时)─────
四个并列后横排放不下 → 图标在上、小字在下两行布局 */
#rail-resources {
flex-shrink: 0; border-top: 1px solid var(--border);
padding: 8px; display: flex; gap: 6px;
}
#rail-resources > button {
flex: 1; font-size: 13px;
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
flex: 1; font-size: 11px; padding: 6px 2px 4px;
display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
}
#rail-resources > button svg { flex-shrink: 0; opacity: .85; }
/* 版本号:钉在右侧文件面板底部存储条最左,带细分隔线;点击弹更新日志,
@ -474,6 +475,48 @@
#mem-cols { flex-direction: column; }
}
/* ───── 个人知识库 modal(两栏;上传/删除在 GUI,检索走对话)───── */
#kb-modal { z-index: 112; }
#kb-modal .card {
width: 880px; max-width: 94vw; height: 80vh; max-height: 80vh;
display: flex; flex-direction: column;
}
#kb-modal h3 {
margin: 0; padding: 12px 16px; font-size: 16px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 8px;
}
#kb-modal h3 .spacer { flex: 1; }
#kb-modal h3 svg { opacity: .85; }
#kb-modal .sk-x {
border: none; background: transparent; font-size: 16px;
cursor: pointer; color: var(--muted); padding: 2px 6px;
}
#kb-hint {
padding: 8px 16px; font-size: 12px; color: var(--muted);
border-bottom: 1px solid var(--border); background: #fafafa;
}
#kb-cols { flex: 1; display: flex; min-height: 0; }
#kb-detail { flex: 1; min-width: 0; overflow: auto; padding: 16px 20px; }
#kb-detail .sk-d-head { display: flex; align-items: center; gap: 8px; }
#kb-detail .sk-d-head .spacer { flex: 1; }
.kb-ingest-bar {
margin-top: 10px; padding: 6px 10px; font-size: 12px; border-radius: 6px;
background: color-mix(in srgb, var(--accent, #2563eb) 10%, transparent);
}
.kb-ingest-err { margin-top: 8px; font-size: 12px; color: #b45309; }
.kb-pending { padding: 3px 0; font-size: 12px; color: var(--muted); }
.kb-entry { display: flex; align-items: flex-start; gap: 6px; padding: 6px 0; border-bottom: 1px dashed var(--border); }
.kb-entry-main { flex: 1; min-width: 0; cursor: pointer; }
.kb-entry-main:hover .sk-name { text-decoration: underline; }
.kb-entry .kb-doc-del { flex-shrink: 0; opacity: .55; }
.kb-entry:hover .kb-doc-del { opacity: 1; }
@media (max-width: 760px) {
#kb-modal .card { width: 96vw; height: 88vh; max-height: 88vh; }
#kb-cols { flex-direction: column; }
#kb-list { width: auto; max-height: 30vh; border-right: none; border-bottom: 1px solid var(--border); }
}
/* ───── 3-pane layout ───── */
#app { display: none; height: 100vh; }
#app.ready {
@ -1627,6 +1670,24 @@
</div>
</div>
<!-- ───── 个人知识库 modal(纯文件 .kb/;上传即入库,检索走对话)───── -->
<div id="kb-modal" class="modal">
<div class="card">
<h3>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"></ellipse><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path></svg>
<span>个人知识库</span>
<span class="spacer"></span>
<button id="kb-close" class="sk-x" title="关闭"></button>
</h3>
<div id="kb-hint">上传资料(PDF / Word / PPT / Excel / 网页 / 文本)自动转成 markdown 并写摘要入库,之后<b>在对话里直接问</b>,我会按索引查你的库并标注来源。扫描件 PDF 自动 OCR。</div>
<div id="kb-cols">
<div id="kb-list" class="sk-pane"><div class="muted" style="padding:8px;">加载中…</div></div>
<div id="kb-detail"><div class="sk-empty">← 选一个库查看,或「+ 新建库」</div></div>
</div>
</div>
</div>
<input type="file" id="kb-upload-input" multiple style="display:none;" />
<!-- ───── embed-mode waiting overlay (token 握手中) ───── -->
<div id="embed-waiting">
<div class="spinner"></div>
@ -1706,6 +1767,10 @@
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg>
<span>记忆</span>
</button>
<button id="hd-kb" title="个人知识库(上传资料自动入库,对话可查)">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"></ellipse><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path></svg>
<span>知识库</span>
</button>
<button id="hd-crons" title="查看定时任务(建 / 改请在对话里说)">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>
<span>定时</span>

239
web/static/js/kb.js Normal file
View File

@ -0,0 +1,239 @@
// 个人知识库 modal:左栏列库,右栏库详情(已入库条目 + 待入库 + 入库进度)/ 单篇正文。
// 照 memory.js master-detail 范式;删除走 dialogConfirm(crons.js 同款);上传用 XHR
// FormData(files.js 同款,带 Bearer);入库进行中每 3s 轮询库详情(procs.js 自适应启停)。
// 后端:/v1/kb*(core/kb.py 纯文件,真实文件为准);检索侧 agent 走 fs 工具,前端只当"眼睛+搬运工"。
import { $ } from "./dom.js";
import { api } from "./api.js";
import { state } from "./state.js";
import { escapeHtml } from "./format.js";
import { renderMd, highlightIn } from "./markdown.js";
import { dialogConfirm, dialogPrompt, message } from "./dialog.js";
const PLACEHOLDER = '<div class="sk-empty">← 选一个库查看,或「+ 新建库」</div>';
const POLL_MS = 3000;
let _current = null; // 当前选中库名
let _pollTimer = null;
function openKbModal() {
$("kb-modal").classList.add("show");
$("kb-detail").innerHTML = PLACEHOLDER;
_current = null;
renderList();
}
export function closeKbModal() {
$("kb-modal").classList.remove("show");
stopPolling();
}
function startPolling() {
if (!_pollTimer) _pollTimer = setInterval(() => { if (_current) showKb(_current, null, true); }, POLL_MS);
}
function stopPolling() {
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
}
// ───── 左栏:库列表 ─────
async function renderList() {
const list = $("kb-list");
let data;
try {
data = await api("GET", "/v1/kb");
} catch (e) {
list.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
return;
}
const kbs = data.results || [];
let html = '<div class="sk-group-title">我的库</div>';
html += kbs.length
? kbs.map((k) => `<div class="sk-item${k.name === _current ? " active" : ""}" data-kb="${escapeHtml(k.name)}">
<div class="sk-name">${escapeHtml(k.name)}${k.pending_count ? ` <span class="sk-badge">待入库 ${k.pending_count}</span>` : ""}</div>
<div class="sk-desc">${k.doc_count} 篇文档</div>
</div>`).join("")
: '<div class="muted" style="padding:4px 8px;font-size:12px;">还没有库。建一个,把常用资料(规范 / 报告 / 标准…)传进来,以后对话我能直接查。</div>';
html += '<div style="padding:8px;"><button id="kb-new" class="small" style="width:100%;">+ 新建库</button></div>';
list.innerHTML = html;
}
async function createKb() {
const name = await dialogPrompt({
title: "新建知识库",
message: "库名(中文 / 字母 / 数字,如「行业标准」「项目资料」):",
okText: "创建",
});
if (!name || !name.trim()) return;
try {
await api("POST", "/v1/kb", { name: name.trim() });
} catch (e) { message("创建失败: " + e.message, "error"); return; }
await renderList();
showKb(name.trim(), null);
}
// ───── 右栏:库详情 ─────
function highlightSel(name) {
$("kb-list").querySelectorAll(".sk-item").forEach((el) => {
el.classList.toggle("active", el.getAttribute("data-kb") === name);
});
}
async function showKb(name, itemEl, isPoll = false) {
_current = name;
if (!isPoll) {
highlightSel(name);
$("kb-detail").innerHTML = '<div class="muted" style="padding:8px;">加载中…</div>';
}
let d;
try {
d = await api("GET", "/v1/kb/" + encodeURIComponent(name));
} catch (e) {
if (!isPoll) $("kb-detail").innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
stopPolling();
return;
}
if (_current !== name) return; // 轮询回来时已切走
renderDetail(d);
if (d.ingest && d.ingest.running) startPolling(); else stopPolling();
}
function renderDetail(d) {
const ing = d.ingest || {};
const entries = d.entries || [];
const pending = d.pending || [];
let html = `<div class="sk-d-head">
<span class="sk-d-name">${escapeHtml(d.name)}</span>
<span class="sk-badge">${entries.length} </span>
<span class="spacer"></span>
<button id="kb-upload-btn" class="small primary">上传文件</button>
<button id="kb-del" class="small danger" data-kb="${escapeHtml(d.name)}">删除库</button>
</div>`;
if (ing.running) {
html += `<div class="kb-ingest-bar">入库中… ${ing.done}/${ing.total}${ing.current ? " · " + escapeHtml(ing.current) : ""}</div>`;
}
if (ing.errors && ing.errors.length) {
html += `<div class="kb-ingest-err">${ing.errors.map((x) =>
`<div>⚠ ${escapeHtml(x.source)}:${escapeHtml(x.error)}</div>`).join("")}</div>`;
}
if (pending.length) {
html += `<div class="sk-group-title" style="margin-top:10px;">待入库 (${pending.length})</div>`;
html += pending.map((n) => `<div class="kb-pending">${escapeHtml(n)}</div>`).join("");
if (!ing.running) {
html += `<div style="padding:6px 0;"><button id="kb-reingest" class="small">重试入库</button></div>`;
}
}
html += '<div class="sk-group-title" style="margin-top:10px;">已入库</div>';
html += entries.length
? entries.map((e) => `<div class="kb-entry">
<div class="kb-entry-main" data-doc="${escapeHtml(e.doc.replace(/^docs\//, ""))}">
<div class="sk-name">${escapeHtml(e.title)}</div>
<div class="sk-desc">${escapeHtml(e.summary)}${e.keywords ? " " + escapeHtml(e.keywords) : ""}</div>
</div>
<button class="small danger kb-doc-del" data-doc="${escapeHtml(e.doc.replace(/^docs\//, ""))}" title="删除该篇(含原件)"></button>
</div>`).join("")
: '<div class="muted" style="padding:4px 0;font-size:12px;">空库。点「上传文件」把资料传进来,自动转 markdown 并写摘要。</div>';
$("kb-detail").innerHTML = html;
}
// ───── 单篇查看 ─────
async function showDoc(filename) {
const detail = $("kb-detail");
detail.innerHTML = '<div class="muted" style="padding:8px;">加载中…</div>';
let data;
try {
data = await api("GET", "/v1/kb/" + encodeURIComponent(_current) + "/docs/" + encodeURIComponent(filename));
} catch (e) {
detail.innerHTML = `<div class="err" style="padding:8px;">加载失败: ${escapeHtml(e.message)}</div>`;
return;
}
detail.innerHTML =
`<div class="sk-d-head"><button id="kb-back" class="small">← 返回</button>
<span class="sk-d-name">${escapeHtml(filename)}</span></div>` +
`<div class="sk-detail-md">${renderMd(data.content)}</div>`;
highlightIn(detail);
}
// ───── 上传(files.js 同款 XHR,带进度)─────
function uploadTo(name, fileList) {
const files = Array.from(fileList || []);
if (!files.length) return;
const fd = new FormData();
for (const f of files) fd.append("files", f);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/v1/kb/" + encodeURIComponent(name) + "/upload");
xhr.setRequestHeader("Authorization", "Bearer " + state.token);
xhr.onload = () => {
let payload = null;
try { payload = JSON.parse(xhr.responseText); } catch (e) {}
if (xhr.status >= 200 && xhr.status < 300) {
message(`已上传 ${payload && payload.count || files.length} 个文件,开始入库…`);
showKb(name, null); // 立即刷详情;running 会自动开轮询
} else {
const msg = (payload && payload.detail) || (xhr.status + " " + xhr.statusText);
message("上传失败: " + msg, "error");
}
};
xhr.onerror = () => message("上传失败: 网络错误", "error");
xhr.send(fd);
message("上传中…");
}
// ───── 顶层绑定 ─────
$("hd-kb").onclick = openKbModal;
$("kb-close").onclick = closeKbModal;
$("kb-modal").addEventListener("click", (e) => {
if (e.target.id === "kb-modal") closeKbModal(); // 点遮罩关闭
});
$("kb-list").addEventListener("click", (e) => {
if (e.target.id === "kb-new" || e.target.closest("#kb-new")) { createKb(); return; }
const item = e.target.closest(".sk-item");
if (item) showKb(item.getAttribute("data-kb"), item);
});
$("kb-upload-input").addEventListener("change", () => {
const inp = $("kb-upload-input");
if (_current) uploadTo(_current, inp.files);
inp.value = "";
});
$("kb-detail").addEventListener("click", async (e) => {
if (e.target.id === "kb-upload-btn") { $("kb-upload-input").click(); return; }
if (e.target.id === "kb-back") { showKb(_current, null); return; }
if (e.target.id === "kb-reingest") {
try { await api("POST", "/v1/kb/" + encodeURIComponent(_current) + "/ingest"); } catch (err) {
message("触发失败: " + err.message, "error"); return;
}
showKb(_current, null);
return;
}
const del = e.target.closest("#kb-del");
if (del) {
const name = del.getAttribute("data-kb");
if (!await dialogConfirm({
title: "删除知识库",
message: `删除库「${name}」?其中所有文档与原件将一并删除,不可恢复。`,
okText: "删除", danger: true,
})) return;
try { await api("DELETE", "/v1/kb/" + encodeURIComponent(name)); } catch (err) {
message("删除失败: " + err.message, "error"); return;
}
_current = null;
stopPolling();
$("kb-detail").innerHTML = PLACEHOLDER;
await renderList();
return;
}
const docDel = e.target.closest(".kb-doc-del");
if (docDel) {
const doc = docDel.getAttribute("data-doc");
if (!await dialogConfirm({
title: "删除文档",
message: `删除「${doc}」?对应原件与索引条目一并删除,不可恢复。`,
okText: "删除", danger: true,
})) return;
try {
await api("DELETE", "/v1/kb/" + encodeURIComponent(_current) + "/docs/" + encodeURIComponent(doc));
} catch (err) { message("删除失败: " + err.message, "error"); return; }
showKb(_current, null);
renderList(); // 左栏计数同步
return;
}
const main = e.target.closest(".kb-entry-main");
if (main) showDoc(main.getAttribute("data-doc"));
});

View File

@ -8,6 +8,7 @@ import { api } from "./api.js";
import { closeChpwModal } from "./auth.js";
import { closeSkillsModal } from "./skills.js";
import { closeMemoryModal } from "./memory.js";
import { closeKbModal } from "./kb.js";
import { closeCronsModal } from "./crons.js";
import { closeWechatModal } from "./wechat.js";
import { closeChangelogModal, markVersion } from "./changelog.js";
@ -110,6 +111,7 @@ document.addEventListener("keydown", (e) => {
if ($("chpw-modal").classList.contains("show")) { closeChpwModal(); return; }
if ($("skills-modal").classList.contains("show")) { closeSkillsModal(); return; }
if ($("memory-modal").classList.contains("show")) { closeMemoryModal(); return; }
if ($("kb-modal").classList.contains("show")) { closeKbModal(); return; }
if ($("crons-modal").classList.contains("show")) { closeCronsModal(); return; }
if ($("wechat-modal").classList.contains("show")) { closeWechatModal(); return; }
if ($("changelog-modal").classList.contains("show")) { closeChangelogModal(); return; }