Merge branch 'ji' into dhk-1

Resolve column_additions_mysql.sql conflict: keep square_id columns from master and roundtable_drafts.task_id from dhk-1.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-06-11 17:40:08 +08:00
commit bcc3d617a0
142 files changed
+14953 -2383

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
# 批量人物蒸馏 → llmwiki
一个**完全独立的单文件脚本 + 内置定时器**:读 Excel 名单,串行调用线上 DeerFlow 服务问答接口,
复用「女娲 / huashu-nuwa」技能逐个蒸馏人物,**由脚本抓取回复并保存**成 llmwiki 风格 markdown。
- **不联网**:只用已配置的知识库查询技能(`knowledge-search-v2`)检索资料。
- **定时执行**:每天 **23:00 ~ 次日 08:00** 自动跑,到点暂停、次日继续。
- **md 由 py 保存**:agent 只产出内容,脚本抓最终回复写成 `.md`(不让项目自己写文件)。
## 你只需要做两件事
1. **改一行地址**:打开 `distill_people.py`,把顶部 `BASE_URL = "..."` 改成你的线上服务地址。
2. **放名单**:同目录放 `people.xlsx`(或 `people.csv`),**第一列 = 人名**,其余列=可选提示。
其余(用户名 `distiller`、口令 `123ewq`、技能名、时间窗)都已内置,不用填。
## 运行
```bash
python distill_people.py # 常驻,按 23:00~08:00 窗口自动执行(推荐挂后台)
python distill_people.py --now # 忽略时间窗,立刻把名单跑完(测试用)
python distill_people.py --redo # 忽略已完成,全部重做
```
读 `.xlsx` 需要 `openpyxl`;用 `.csv` 则零额外依赖。
## 输出(都在 output/ 一个文件夹)
- `<人名>.md` —— 每个人的 llmwiki 档案
- `manifest.json` —— 执行清单(状态/耗时/run_id)
- `index.md` —— 可读清单表(谁完成/失败,点开看 md)
**断点续跑**:已完成的人自动跳过;中途往名单里加人,下个执行窗会带上。
+563
View File
@@ -0,0 +1,563 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
批量人物蒸馏 → llmwiki markdown(完全独立的单文件客户端 + 内置定时器)
特点
----
- 串行(一个一个来)调用正在运行的 DeerFlow 服务问答接口
- 复用线上「女娲 / huashu-nuwa」技能做提炼;**不联网**,只用已配置的
知识库查询技能(knowledge-search-v2)检索资料
- agent 只负责产出内容,**markdown 由本脚本抓取最终回复后自己保存**
- 内置时间窗:每天 23:00 ~ 次日 08:00 才执行,到点自动暂停、次日继续
- 断点续跑:已完成的人自动跳过;中途加名单也会被带上
你只需要改一处:下面的 BASE_URL(线上服务地址)。其余都不用填。
名单
----
脚本同目录放 people.xlsx 或 people.csv,**第一列 = 人名**,其余列=可选提示。
运行
----
python distill_people.py # 常驻,按 23:00~08:00 窗口自动执行
python distill_people.py --now # 忽略时间窗,立刻把名单跑完(测试用)
python distill_people.py --redo # 忽略已完成,全部重做
"""
from __future__ import annotations
import argparse
import csv
import json
import re
import sys
import time
from datetime import datetime, time as dtime
from pathlib import Path
try:
import requests
except ImportError:
sys.exit("缺少依赖 requests:pip install requests")
# ════════════════════════════════════════════════════════════════════════
# 只改这一行:你的线上服务地址(不带结尾斜杠)
# ════════════════════════════════════════════════════════════════════════
BASE_URL = "http://47.94.209.59:2026/"
# ════════════════════════════════════════════════════════════════════════
# ---- 以下都已内置,正常无需改动 ----
USERNAME = "distiller" # 免密自动注册
PASSWORD = "123ewq" # 共享口令(你们开了口令门)
NUWA_SKILL = "huashu-nuwa" # 女娲技能
KB_SKILL = "knowledge-search-v2" # 知识库查询技能(不联网,用它检索)
DEPTH = "full" # full 完整 / fast 精简
HERE = Path(__file__).resolve().parent
OUTPUT_DIR = HERE / "output"
PEOPLE_FILE_DEFAULT = HERE / "people.example.csv"
# 执行时间窗:23:00 ~ 次日 08:00(跨午夜)
WIN_START = dtime(23, 0)
WIN_END = dtime(8, 0)
PER_PERSON_TIMEOUT = 1800 # 单人最长等待(秒)
POLL_INTERVAL = 5 # 轮询间隔(秒)
HTTP_TIMEOUT = 60
TERMINAL_STATUS = {"success", "error", "timeout", "interrupted"}
# ================================ llmwiki 模板 ================================
LLMWIKI_TEMPLATE = """\
---
name: <中文名/常用名>
aliases: [<别名/英文名...>]
type: person
era: <在世 | 历史人物>
domains: [<领域1>, <领域2>]
distilled_at: <YYYY-MM-DD>
sources_count: <检索到的来源条数>
primary_source_ratio: <一手来源占比, 0~1 的小数>
confidence: <high | medium | low>
---
# <人物名>
> **一句话定位**:<此人看世界的独特方式,一句话>
## 身份卡
- **活跃年代**:
- **核心身份**:
- **自述(50字, 以其语气)**:
## 核心观点(真信念 · 反复出现的主张)
1. ...
## 心智模型(3–7 个;每个含:一句话 / 证据场景≥2 / 应用 / 局限 / 来源)
### 1. <模型名>
- **一句话**:
- **证据场景**:
- **应用方式**:
- **局限**:
- **来源**:
## 决策启发式(5–10 条 · 如果 X 则 Y,带案例)
- 若 <X> → <Y>
## 表达 DNA
| 维度 | 特征 |
|------|------|
| 句式 | |
| 高频词 | |
| 幽默 | |
| 确定性 | |
| 引用习惯 | |
## 价值观与反模式
- **价值观排序**:
- **明确反对**:
## 内在张力(≥2 对矛盾)
- <A> ↔ <B>
## 经历时间线
| 年份 | 事件 | 思想意义 |
|------|------|---------|
| | | |
## 代表语录
- “...” —(出处/年份,标一手/二手)
## 智识谱系
- **受影响于**:
- **影响了**:
## 外部评价与争议
-
## 诚实边界
- <至少 3 条具体局限,含信息截止日期>
## 调研来源
**一手(N)**:
**二手(N)**:
"""
def build_prompt(name: str, hints: str, depth: str) -> str:
depth_note = (
"本次走【完整模式】:尽量覆盖著作/对话/表达/他者评价/决策/时间线 6 个维度。"
if depth == "full"
else "本次走【精简模式】:重点覆盖核心观点、心智模型、时间线三块即可。"
)
hint_block = f"\n补充提示(来自名单其它列,可作为检索关键词):{hints}\n" if hints.strip() else ""
return f"""\
你的任务:把人物【{name}】蒸馏成一份 llmwiki 风格的人物档案 markdown。
请复用本服务中已启用的「女娲 / {NUWA_SKILL}」技能的提炼方法论
(心智模型三重验证、表达DNA分析、诚实边界)。
⚠️ 本环境**无法联网,严禁使用 WebSearch 等任何联网工具**。
所有资料**只能通过已配置的知识库查询技能「{KB_SKILL}」检索获取**。
请充分调用该知识库技能,围绕该人物多角度检索(生平、观点、著作、语录、决策、评价等),
基于检索到的内容做提炼。知识库里查不到的部分,如实在「诚实边界」标注,不要编造。
{depth_note}
{hint_block}
输出要求:
1. **不要写任何文件,不要调用 write_file / present_files**。
2. 直接在你的回复正文里输出**完整中文 markdown 全文**,严格按下面模板结构
(保留各级标题,逐项填实,不要保留尖括号占位符),**不要任何额外解说、前言或结语**:
------------------ 模板开始 ------------------
{LLMWIKI_TEMPLATE}
------------------ 模板结束 ------------------
现在开始,独立完成,不要向我提问。"""
# ================================ HTTP 客户端 ================================
class DeerFlowClient:
def __init__(self, base_url: str):
self.base = base_url.rstrip("/")
self.s = requests.Session()
self.token: str | None = None
def _headers(self) -> dict:
h = {"Content-Type": "application/json"}
if self.token:
h["Authorization"] = f"Bearer {self.token}" # 只用 Bearer,不带 cookie → 跳过 CSRF
return h
def login(self) -> bool:
url = f"{self.base}/api/v1/auth/login/username"
params = {"password": PASSWORD} if PASSWORD else None
try:
r = self.s.post(url, params=params, json={"username": USERNAME}, timeout=HTTP_TIMEOUT)
except requests.RequestException as e:
print(f" [登录] 连接失败:{e}")
return False
if r.status_code == 200:
self.token = r.json().get("access_token")
self.s.cookies.clear()
print(f" [登录] 成功,用户={USERNAME}")
return True
print(f" [登录] 返回 {r.status_code}:{r.text[:200]}")
return False
def ensure_skill_enabled(self, name: str) -> None:
try:
r = self.s.get(f"{self.base}/api/skills", headers=self._headers(), timeout=HTTP_TIMEOUT)
if r.status_code == 200:
skills = r.json().get("skills", [])
hit = next((x for x in skills if x.get("name") == name), None)
if hit is None:
print(f" [技能] 警告:服务里没找到 '{name}'")
return
if hit.get("enabled"):
print(f" [技能] '{name}' 已启用")
return
pr = self.s.put(f"{self.base}/api/skills/{name}", headers=self._headers(),
json={"enabled": True}, timeout=HTTP_TIMEOUT)
print(f" [技能] 启用 '{name}' → {pr.status_code}")
except requests.RequestException as e:
print(f" [技能] 检查 '{name}' 失败(忽略):{e}")
def create_thread(self) -> str:
r = self.s.post(f"{self.base}/api/threads", headers=self._headers(), json={}, timeout=HTTP_TIMEOUT)
r.raise_for_status()
return r.json()["thread_id"]
def start_run(self, thread_id: str, prompt: str) -> str:
body = {
"input": {"messages": [{"role": "user", "content": prompt}]},
"excluded_tools": ["ask_clarification"], # 禁澄清,无人值守不卡住
}
r = self.s.post(f"{self.base}/api/threads/{thread_id}/runs",
headers=self._headers(), json=body, timeout=HTTP_TIMEOUT)
r.raise_for_status()
return r.json()["run_id"]
def poll_run(self, thread_id: str, run_id: str) -> str:
deadline = time.monotonic() + PER_PERSON_TIMEOUT
last = ""
while time.monotonic() < deadline:
try:
r = self.s.get(f"{self.base}/api/threads/{thread_id}/runs/{run_id}",
headers=self._headers(), timeout=HTTP_TIMEOUT)
if r.status_code == 200:
st = r.json().get("status", "")
if st != last:
print(f" ...状态:{st}")
last = st
if st in TERMINAL_STATUS:
return st
except requests.RequestException as e:
print(f" 轮询出错(重试):{e}")
time.sleep(POLL_INTERVAL)
return "timeout"
def fetch_result_text(self, thread_id: str, run_id: str) -> str | None:
"""抓取该 thread 的最终 AI 回复文本(由 py 保存为 md)。
注意:本部署的 `/runs/{rid}/messages`、`/messages`、`/events` 端点都返回空
(事件库按登录用户过滤,后台 worker 写入时未带 user_id → 读不到)。
可靠来源是 LangGraph 检查点状态 `/api/threads/{tid}/state` → values.messages,
其中每条消息形如 {"type": "human"|"ai"|"tool", "content": <str | 内容块列表>, ...}。
取最后一条有正文的 ai 消息即可。
"""
try:
r = self.s.get(f"{self.base}/api/threads/{thread_id}/state",
headers=self._headers(), timeout=HTTP_TIMEOUT)
if r.status_code != 200:
print(f" 取回复返回 {r.status_code}:{r.text[:200]}")
return None
messages = (r.json().get("values") or {}).get("messages") or []
# 从后往前找最后一条「有正文」的 AI 回复(跳过只含工具调用的中间消息)
for msg in reversed(messages):
if not isinstance(msg, dict) or msg.get("type") != "ai":
continue
txt = _extract_text(msg)
if txt and txt.strip():
return txt
except requests.RequestException as e:
print(f" 取回复出错:{e}")
return None
def _extract_text(payload) -> str:
"""从一条消息的 content 里提取纯文本。
payload 可能是:
- LangChain 消息的 model_dump 字典(正文在 payload["content"])
- 直接的字符串
- 内容块列表([{"type":"text","text":"..."}, ...])
"""
if payload is None:
return ""
# 若是整条消息的 model_dump,正文在其 content 字段
if isinstance(payload, dict):
c = payload.get("content", payload.get("text", ""))
else:
c = payload
if isinstance(c, str):
return c
if isinstance(c, list):
parts = []
for b in c:
if isinstance(b, dict):
# 只取文本块,跳过 tool_use / thinking 等非文本块
if b.get("type") in (None, "text", "text_block"):
parts.append(b.get("text") or b.get("content") or "")
elif b.get("text"):
parts.append(b["text"])
elif isinstance(b, str):
parts.append(b)
return "\n".join(p for p in parts if p)
return str(c) if c else ""
def _clean_markdown(text: str) -> str:
"""去掉 agent 可能套上的 ```markdown ... ``` 围栏,并规整 YAML frontmatter。"""
t = text.strip()
m = re.match(r"^```(?:markdown|md)?\s*\n(.*)\n```$", t, re.S)
if m:
t = m.group(1).strip()
return _normalize_frontmatter(t)
def _normalize_frontmatter(text: str) -> str:
"""修复模型常见的 frontmatter 跑偏,让 YAML 能正常解析。
模型经常把字段名写成 markdown 加粗(``**name:**``)、加空行、留行尾双空格,
这些都会让 frontmatter 解析失败。这里把开头 ``---`` 到下一个 ``---`` 之间的内容
规整为干净的 ``key: value`` 形式。正文(第二个 ``---`` 之后)保持原样。
"""
if not text.startswith("---"):
return text
# 拆出 frontmatter 块:开头 --- 与下一行单独的 --- 之间
m = re.match(r"^---[ \t]*\n(.*?)\n---[ \t]*\n?(.*)$", text, re.S)
if not m:
return text
body_fm, rest = m.group(1), m.group(2)
out_lines = []
for line in body_fm.splitlines():
s = line.rstrip() # 去行尾双空格
s = s.replace("**", "") # 去 markdown 加粗标记
if not s.strip():
continue # 丢掉空行
out_lines.append(s)
return "---\n" + "\n".join(out_lines) + "\n---\n\n" + rest.lstrip("\n")
# ================================ 名单读取 ================================
def slugify(name: str) -> str:
s = re.sub(r'[\\/:*?"<>|]+', "", name).strip()
s = re.sub(r"\s+", "_", s)
return s or "unnamed"
def read_people(path: Path) -> list[dict]:
if not path.exists():
return []
rows: list[list[str]] = []
if path.suffix.lower() in (".xlsx", ".xlsm"):
try:
import openpyxl
except ImportError:
sys.exit("读取 .xlsx 需要 openpyxl:pip install openpyxl(或把名单存成 .csv)")
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
for row in wb.active.iter_rows(values_only=True):
rows.append(["" if c is None else str(c).strip() for c in row])
elif path.suffix.lower() == ".csv":
with open(path, encoding="utf-8-sig", newline="") as f:
rows = [[c.strip() for c in r] for r in csv.reader(f)]
else:
sys.exit(f"不支持的名单格式:{path.suffix}(用 .xlsx 或 .csv)")
if rows and re.search(r"(姓名|名字|人物|name)", rows[0][0] if rows[0] else "", re.I):
rows = rows[1:]
people, seen = [], set()
for r in rows:
if not r or not r[0].strip():
continue
name = r[0].strip()
if name in seen:
continue
seen.add(name)
hints = " | ".join(x for x in r[1:] if x.strip())
people.append({"name": name, "hints": hints})
return people
# ================================ 时间窗 ================================
def in_window(now: datetime | None = None) -> bool:
now = now or datetime.now()
t = now.time()
return t >= WIN_START or t < WIN_END # 跨午夜
def seconds_until_window(now: datetime | None = None) -> int:
now = now or datetime.now()
if in_window(now):
return 0
target = now.replace(hour=WIN_START.hour, minute=WIN_START.minute, second=0, microsecond=0)
return max(1, int((target - now).total_seconds()))
def sleep_interruptible(total: int) -> None:
end = time.monotonic() + total
while time.monotonic() < end:
time.sleep(min(30, max(1, int(end - time.monotonic()))))
# ================================ 蒸馏单人 ================================
def distill_one(client: DeerFlowClient, person: dict, out_dir: Path,
manifest: dict, save_manifest, depth: str) -> None:
name, hints = person["name"], person["hints"]
slug = slugify(name)
md_path = out_dir / f"{slug}.md"
t0 = time.time()
rec = {"name": name, "slug": slug, "status": "running",
"started_at": _now(), "file": f"{slug}.md"}
manifest[name] = rec
save_manifest()
try:
tid = client.create_thread()
run_id = client.start_run(tid, build_prompt(name, hints, depth))
status = client.poll_run(tid, run_id)
rec.update(thread_id=tid, run_id=run_id, run_status=status)
text = client.fetch_result_text(tid, run_id) if status == "success" else None
if status == "success" and text:
md = _clean_markdown(text)
md_path.write_text(md, encoding="utf-8")
rec["status"] = "succeeded"
rec["chars"] = len(md)
print(f" ✅ 完成 → {md_path.name}({len(md)} 字,耗时 {int(time.time()-t0)}s)")
else:
rec["status"] = "failed"
rec["error"] = f"run={status}, text={'有' if text else '无'}"
if text:
(out_dir / f"{slug}.partial.md").write_text(text, encoding="utf-8")
print(f" ❌ 失败(run={status})")
except Exception as e:
rec["status"] = "failed"
rec["error"] = str(e)
print(f" ❌ 异常:{e}")
rec["finished_at"] = _now()
rec["seconds"] = int(time.time() - t0)
manifest[name] = rec
save_manifest()
# ================================ 主流程 ================================
def main():
ap = argparse.ArgumentParser(description="批量人物蒸馏 → llmwiki(定时窗 23:00~08:00)")
ap.add_argument("excel", nargs="?", default=str(PEOPLE_FILE_DEFAULT),
help="名单文件(.xlsx 或 .csv),默认同目录 people.xlsx")
ap.add_argument("--now", action="store_true", help="忽略时间窗,立刻跑完整批(测试用)")
ap.add_argument("--redo", action="store_true", help="忽略已完成,全部重做")
args = ap.parse_args()
excel = Path(args.excel)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
manifest_path = OUTPUT_DIR / "manifest.json"
manifest: dict = {}
if manifest_path.exists():
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception:
manifest = {}
# 校验名单存在
if not read_people(excel):
sys.exit(f"名单为空或不存在:{excel}\n请放 people.xlsx(第一列写人名)。")
# --redo:一次性清掉名单里这些人的旧记录,让他们重做一遍;
# 之后本次会话内跑成功的会正常计为「已完成」,循环才会往后推进
# (否则每轮 pending() 都把已完成的人当未完成,永远卡在第 1 个人)
if args.redo:
for p in read_people(excel):
manifest.pop(p["name"], None)
client = DeerFlowClient(BASE_URL)
if not client.login():
sys.exit("登录失败。请检查 BASE_URL 是否指向正在运行的服务、口令是否正确。")
client.ensure_skill_enabled(NUWA_SKILL)
client.ensure_skill_enabled(KB_SKILL)
def save_manifest():
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
_write_index(OUTPUT_DIR, read_people(excel), manifest)
def pending() -> list[dict]:
people = read_people(excel)
out = []
for p in people:
r = manifest.get(p["name"], {})
done = r.get("status") == "succeeded" and (OUTPUT_DIR / f"{slugify(p['name'])}.md").exists()
if not done:
out.append(p)
return out
print(f"启动 | 服务={BASE_URL} | 输出={OUTPUT_DIR}")
print(f"模式={'立即跑完' if args.now else '定时窗 23:00~08:00'} | 名单={excel}")
try:
while True:
todo = pending()
if not todo:
print(f"[{_now()}] 全部已完成,待命中(每小时复查名单)...")
if args.now:
break
sleep_interruptible(3600)
continue
if not args.now and not in_window():
wait = seconds_until_window()
nxt = datetime.now().replace(hour=WIN_START.hour, minute=0, second=0, microsecond=0)
print(f"[{_now()}] 不在执行窗口,剩 {len(todo)} 人待蒸;"
f"休眠到 {nxt.strftime('%H:%M')}(约 {wait//60} 分钟)...")
sleep_interruptible(wait)
continue
# 在窗口内(或 --now):处理一个人,然后回到循环重新判断窗口/名单
p = todo[0]
idx_total = len(read_people(excel))
done_n = sum(1 for v in manifest.values() if v.get("status") == "succeeded")
print(f"[{_now()}] ({done_n}/{idx_total} 已完成) 蒸馏:{p['name']}")
distill_one(client, p, OUTPUT_DIR, manifest, save_manifest, DEPTH)
except KeyboardInterrupt:
print("\n已手动停止。重跑脚本会从未完成处继续。")
ok = sum(1 for r in manifest.values() if r.get("status") == "succeeded")
fail = sum(1 for r in manifest.values() if r.get("status") == "failed")
print(f"\n结束:成功 {ok} / 失败 {fail}")
print(f"结果目录:{OUTPUT_DIR} | 清单:{manifest_path} | 索引:{OUTPUT_DIR/'index.md'}")
def _now() -> str:
return time.strftime("%Y-%m-%d %H:%M:%S")
def _write_index(out_dir: Path, people: list[dict], manifest: dict) -> None:
lines = ["# 蒸馏执行清单", "", f"更新时间:{_now()}", "",
"| # | 人物 | 状态 | 字数 | 耗时 | 文件 |",
"|---|------|------|------|------|------|"]
badge = {"succeeded": "✅完成", "failed": "❌失败", "running": "⏳进行中"}
for i, p in enumerate(people, 1):
r = manifest.get(p["name"], {})
st = badge.get(r.get("status", ""), "⌛排队")
f = r.get("file", "")
link = f"[{f}]({f})" if r.get("status") == "succeeded" and f else "-"
lines.append(f"| {i} | {p['name']} | {st} | {r.get('chars','-')} | {r.get('seconds','-')} | {link} |")
(out_dir / "index.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
姓名,领域(可选提示),资料链接或备注(可选)
查理·芒格,投资/多元思维,
理查德·费曼,物理/教学,
纳瓦尔·拉维坎特,创业/杠杆,https://nav.al/
1 姓名 领域(可选提示) 资料链接或备注(可选)
2 查理·芒格 投资/多元思维
3 理查德·费曼 物理/教学
4 纳瓦尔·拉维坎特 创业/杠杆 https://nav.al/
+8 -7
View File
@@ -27,15 +27,16 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_BACKENP_URL=http://localhost:8003
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/空间前端地址(用于 auth/callback 等跳转)
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
VITE_AIPI_FRONTEND_URL=http://localhost:3000
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=https://present.a.uat.4.cn/present/domain
# B 系统知识库 iframe 根地址
VITE_AIPI_KNOWES_URL=https://ch1.b.uat.4.cn/knowes
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
+6 -4
View File
@@ -15,15 +15,17 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
# 工作室/空间前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=http://present.a.uat.4.cn/present/domain
# B 系统知识库 iframe 根地址
VITE_AIPI_KNOWES_URL=http://ch1.b.uat.4.cn/knowes
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# VITE_NOTE_API_BASE_URL=http://127.0.0.1:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
-1
View File
@@ -23,7 +23,6 @@
# VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
# VITE_STUDIO_ENABLED=true # 仅控制“工作室”入口显示,列表内容仍依赖 notebook 接口数据
# --- 生产打包(见 .env.production)---
# 部署前修改 .env.production 中的地址,再执行 pnpm build
+6 -4
View File
@@ -28,14 +28,16 @@ VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/空间前端地址(用于 auth/callback 等跳转)
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=http://present.a.uat.4.cn/present/domain
# B 系统知识库 iframe 根地址
VITE_AIPI_KNOWES_URL=http://ch1.b.uat.4.cn/knowes
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
+10 -7
View File
@@ -24,18 +24,21 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_NOTEBOOK_API_BASE_URL=https://knowledgetoolserver.b.uat.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolweb.b.uat.4.cn
# 我的空间前端
VITE_AIPI_FRONTEND_URL= xxx
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=https://present.a.uat.4.cn/present/domain
# B 系统知识库 iframe 根地址
VITE_AIPI_KNOWES_URL=https://ch1.b.uat.4.cn/knowes
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 工作室/空间前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolweb.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -5,4 +5,3 @@
# VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# VITE_AIPI_BACKENP_URL=https://your-notebook-backend-host
# VITE_AIPI_FRONTEND_URL=https://your-notebook-frontend-host
# VITE_STUDIO_ENABLED=true
+8 -2
View File
@@ -24,7 +24,7 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.sit.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.sit.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.sit.4.cn
VITE_NOTEBOOK_API_BASE_URL=https://knowledgetoolserver.b.sit.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
@@ -32,10 +32,16 @@ VITE_STUDIO_ENABLED=true
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.sit.4.cn/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=http://present.a.sit.4.cn/present/domain
# B 系统知识库 iframe 根地址
VITE_AIPI_KNOWES_URL=https://ch1.b.sit.4.cn/knowes
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.sit.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.sit.4.cn
# 工作室/空间前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
+1 -1
View File
@@ -74,7 +74,7 @@
"hast": "^1.0.0",
"katex": "^0.16.28",
"lucide-react": "^0.562.0",
"mermaid": "11.14.0",
"mermaid": "^11.15.0",
"motion": "^12.26.2",
"nanoid": "^5.1.6",
"next-themes": "^0.4.6",
@@ -0,0 +1,898 @@
"use client";
import {
createContext,
type FC,
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import {
Search,
Loader2,
Plus,
Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth";
import Empty from "./empty";
import { NetworkActor } from "./network-actor";
// ── 适配层 ─────────────────────────────────────────────────
function authenticatedFetch(url: string, init?: RequestInit) {
const token = getStoredAuth()?.access_token;
return fetch(url, {
...init,
headers: {
...(init?.headers ?? {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
}
function getYUserId(): string {
if (typeof window === "undefined") return "1157761008018653184";
try {
const raw = localStorage.getItem("deerflow.external_user_info");
if (raw) {
const obj = JSON.parse(raw) as { yUserId?: string };
if (obj?.yUserId) return obj.yUserId;
}
} catch { }
return "1157761008018653184";
}
const searchTypes = [
{ value: "human", label: "人物基因库" },
{ value: "organization", label: "组织基因库" },
{ value: "community", label: "群体基因库" },
{ value: "issue", label: "议题基因库" },
{ value: "event", label: "事件基因库" },
{ value: "threat", label: "风险与机会" },
] as const;
// ── Context ────────────────────────────────────────────────
interface DocumentUploadDialogContextType {
openDialog: (initialSearchText?: string) => void;
closeDialog: (
mode?: "search",
data?: { query: string; mode: string }
) => Promise<void>;
// 上传成功回调,返回上传的来源信息
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
}
// ── 任务轮询类型 ─────────────────────────────────────────────
interface TaskSource {
task_id: string;
title: string;
status: 'processing' | 'success' | 'error';
source_type: string;
}
interface UploadResponse {
status: string;
uploaded: number;
skipped: number;
sources: TaskSource[];
}
interface listDataItem {
id?: string | number;
name?: string;
redisKey?: string;
abstractz?: string;
checked: boolean;
title: string;
content: string;
url: string;
// 上传文件轮询相关字段
task_id?: string;
processing_status?: 'processing' | 'success' | 'error';
is_uploaded_file?: boolean;
country?: string;
dateOfBirth?: string;
occupationData?: string;
party?: string;
sexOrGender?: string;
imagePath?: string;
mongoId?: string;
countryData?: string;
countrySingle?: string;
subtype?: string;
text?: string;
twoLevel?: string;
account_ids?: unknown[];
community_description?: string;
community_name?: string;
community_type?: string;
create_time?: string;
creator?: string;
groups?: string;
labels?: string;
nations?: string;
params?: string;
platforms?: string;
real_total?: number;
total?: number;
update_time?: string;
virtual_total?: number;
direction?: string;
hot_num?: number;
issue_hot?: number;
issue_name?: string;
issue_type?: string;
news_num?: number;
post_num?: number;
topic?: string;
abstract?: string;
allwords?: string[];
category?: string;
controversy_num?: number;
date?: string;
discriminant_reason?: string;
discriminant_result?: string;
doc_author?: string;
doc_ids?: string[];
emotion_info?: string;
first_publish_time?: string;
grade?: string;
human_id_list?: string;
human_list?: string[];
if_artificial?: number;
if_follow?: number;
if_shehua?: number;
if_show?: number;
image_path?: string;
import_num?: number;
insert_time?: string;
jw?: string;
keywords?: string[];
location?: string[];
location_list?: unknown[];
location_type?: number;
longitude?: number;
media_board?: string;
media_type?: string;
nowords?: unknown[];
onewords?: unknown[];
organization_id_list?: string;
organization_list?: unknown[];
raw_id?: string;
report_num?: number;
site_name?: string;
source_type?: string;
sub_category?: string;
subject?: string;
super_category?: string;
surge_num?: number;
tone?: string;
topic_id?: string;
total_num?: number;
type?: string;
userid?: string;
wx_sub_score?: string;
wx_total_score?: string;
}
const DocumentUploadDialogContext =
createContext<DocumentUploadDialogContextType | null>(null);
export const useDocumentUploadDialog = () => {
const context = useContext(DocumentUploadDialogContext);
if (!context) {
throw new Error(
"useDocumentUploadDialog must be used within DocumentUploadDialogProvider"
);
}
return context;
};
// ── Provider ───────────────────────────────────────────────
export const DocumentUploadDialogProvider: FC<{
children: ReactNode;
onClose?: (
mode?: "search",
data?: { query: string; mode: string }
) => void | Promise<void>;
searchSpace: { id: number | string;[key: string]: unknown };
onUploadSuccess?: () => void;
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
}> = ({ children, onClose, searchSpace, onUploadSuccess, onUploadComplete }) => {
const [isOpen, setIsOpen] = useState(false);
const [initialSearchText, setInitialSearchText] = useState("");
const isClosingRef = useRef(false);
const openDialog = useCallback((initialSearchText?: string) => {
if (isClosingRef.current) return;
setInitialSearchText(initialSearchText || "");
setIsOpen(true);
}, []);
const closeDialog = useCallback(
async (mode?: "search", data?: { query: string; mode: string }) => {
isClosingRef.current = true;
if (onClose) {
try {
await onClose(mode, data);
} catch (error) {
console.error("Error in Provider onClose:", error);
}
}
setIsOpen(false);
setTimeout(() => {
isClosingRef.current = false;
}, 300);
},
[onClose]
);
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
if (!isClosingRef.current) {
void closeDialog("search", { query: "", mode: "" });
void closeDialog();
}
} else {
if (!isClosingRef.current) {
setIsOpen(true);
}
}
},
[closeDialog]
);
return (
<DocumentUploadDialogContext.Provider value={{ openDialog, closeDialog, onUploadComplete }}>
{children}
<DocumentUploadPopupContent
isOpen={isOpen}
onOpenChange={handleOpenChange}
searchSpace={searchSpace}
onUploadSuccess={onUploadSuccess}
closeDialog={closeDialog}
initialSearchText={initialSearchText}
/>
</DocumentUploadDialogContext.Provider>
);
};
// ── Popup content ──────────────────────────────────────────
const UPLOAD_ACCEPT =
".pdf,.docx,.xlsx,.pptx,.html,.csv,.md,.txt,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.mp3,.mp4,.wav,.webm";
const DocumentUploadPopupContent: FC<{
isOpen: boolean;
onOpenChange: (open: boolean) => void;
searchSpace: { id: number | string;[key: string]: unknown };
onUploadSuccess?: () => void;
closeDialog: (
mode?: "search",
data?: { query: string; mode: string }
) => Promise<void>;
initialSearchText?: string;
}> = ({ isOpen, onOpenChange, searchSpace, onUploadSuccess, initialSearchText }) => {
const { onUploadComplete } = useContext(DocumentUploadDialogContext) || {};
const searchSpaceId = String(searchSpace?.id);
// 从环境变量获取配置
const backendUrl = import.meta.env.VITE_AIPI_BACKENP_URL || "http://localhost:8001";
const consumerUrl = import.meta.env.VITE_AIPI_CONSUMER_URL || "http://ch1.b.uat.4.cn/consumer";
const presentUrl = import.meta.env.VITE_AIPI_PRESENT_URL || "http://present.a.uat.4.cn/present/domain";
const knowesUrl = import.meta.env.VITE_AIPI_KNOWES_URL || "http://ch1.b.uat.4.cn/knowes";
const knowledgeUrl = import.meta.env.VITE_AIPI_KNOWLEDGE_URL || "http://chlib.b.uat.4.cn/knowlibrary";
const [searchText, setSearchText] = useState("");
const [searchValue, setSearchValue] = useState("");
const [activeTab, setActiveTab] = useState<"search" | "Y" | "upload" | "A" | "actor">("search");
const [isSearching, setIsSearching] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [listData, setListData] = useState<listDataItem[]>([]);
const [searchType, setSearchType] = useState("human");
const [selectedSearchSources, setSelectedSearchSources] = useState<listDataItem[]>([]);
// upload tab state
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
useEffect(() => {
if (isOpen && initialSearchText) {
setSearchText(initialSearchText);
setSearchValue(initialSearchText);
}
}, [isOpen, initialSearchText]);
if (!searchSpaceId) return null;
const handleSuccess = () => {
onUploadSuccess?.();
onOpenChange(false);
};
const searchActiveChange = async (type: "search" | "Y" | "upload" | "A" | "actor") => {
setActiveTab(type);
if (type === "A") {
await searchAChange(searchType);
}
};
const handleFileUpload = async (files: File[]) => {
if (!files.length) return;
setUploading(true);
try {
const token = getStoredAuth()?.access_token ?? "";
const formData = new FormData();
formData.append("search_space_id", searchSpaceId);
for (const file of files) {
formData.append("files", file);
}
const res = await fetch(`${backendUrl}/api/v1/documents/fileupload`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (res.ok) {
const data = await res.json() as UploadResponse;
// 调用上传完成回调,传递来源信息
if (data.sources && data.sources.length > 0) {
onUploadComplete?.(data.sources.map(s => ({
id: undefined, // 后端返回的数据中暂时没有 id
title: s.title,
task_id: s.task_id,
status: s.status,
})));
}
toast.success(`上传成功 ${data.uploaded || files.length} 个文件`);
// 只关闭上传弹窗
onOpenChange(false);
} else {
toast.error("上传失败,请重试");
}
} catch {
toast.error("上传失败,请重试");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const handleImport = async () => {
if (selectedSearchSources.length === 0) {
toast.warning("没有可导入的来源");
return;
}
setIsImporting(true);
const dataList = selectedSearchSources.map((item) => ({
title: item.title,
content: item.content,
url: item.url,
}));
try {
const response = await authenticatedFetch(`${backendUrl}/api/v1/source/import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
search_space_id: Number(searchSpaceId),
source_list: dataList,
}),
});
if (response.ok) {
const data = await response.json() as { imported_count?: number };
toast.success(`成功导入 ${data.imported_count || dataList.length} 个来源`);
setListData([]);
handleBatchDelete();
} else {
const errorData = await response.json() as { detail?: string };
toast.error(errorData.detail || "导入失败,请重试");
}
} catch {
toast.error("导入失败,请重试");
} finally {
setIsImporting(false);
}
};
const listDataCheckedChange = (index: number, checked: boolean) => {
setListData((prevSources) => {
const newSources = prevSources.map((item, aIndex) =>
aIndex === index ? { ...item, checked } : item
);
setSelectedSearchSources(newSources.filter((item) => item.checked));
return newSources;
});
};
const handleBatchDelete = () => {
setListData((prev) => prev.map((item) => ({ ...item, checked: false })));
setSelectedSearchSources([]);
};
function urlSafeBtoa(str: string): string {
const encoder = new TextEncoder();
const uint8Array = encoder.encode(str);
const binaryString = Array.prototype.map
.call(uint8Array, (byte: number) => String.fromCharCode(byte))
.join("");
return btoa(binaryString).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}
const linkToA = (url: string) => { window.open(url); };
const queryTopicTargetHumanList = async () => {
setIsSearching(true);
const response = await authenticatedFetch(`${consumerUrl}/topicQuery/queryTopicTargetHumanList`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
body: JSON.stringify({ name: searchText, page: 1, size: 50 }),
});
if (response.ok) {
const data = await response.json() as { data: listDataItem[] };
setIsSearching(false);
const result = data.data;
result.forEach((item) => {
item.checked = false;
item.title = item.name || "";
item.content = item.abstractz || "";
const redirect = urlSafeBtoa(`/gene/#/human/basic?mid=${item.id}`);
item.url = `${presentUrl}?inredirect=${redirect}`;
});
setListData(result);
}
setIsSearching(false);
};
const organizationSearch = async () => {
setIsSearching(true);
const response = await authenticatedFetch(`${consumerUrl}/topicQuery/queryCommunity`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
body: JSON.stringify({ name: searchText, page: 1, size: 50 }),
});
if (response.ok) {
const data = await response.json() as { data: listDataItem[] };
setIsSearching(false);
const result = data.data;
result.forEach((item) => {
item.checked = false;
item.title = item.name || "";
item.content = item.abstractz || "";
const redirect = urlSafeBtoa(`/gene/#/organization/basicAttr?mid=${item.mongoId}`);
item.url = `${presentUrl}?inredirect=${redirect}`;
});
setListData(result);
}
setIsSearching(false);
};
const queryCommunityList = async () => {
setIsSearching(true);
const params = { keyword: searchText, page: 1, size: 50 };
const queryString = Object.entries(params).map(([k, v]) => `${k}=${v}`).join("&");
const response = await authenticatedFetch(`${consumerUrl}/topicQuery/queryCommunityList?${queryString}`, {
method: "GET",
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
});
if (response.ok) {
const data = await response.json() as { data: listDataItem[] };
setIsSearching(false);
const result = data.data;
result.forEach((item) => {
item.checked = false;
item.title = item.community_name || "";
item.content = item.community_description || "";
const redirect = urlSafeBtoa(`/gene/#/commune/basicCommune?cid=${item.id}`);
item.url = `${presentUrl}?inredirect=${redirect}`;
});
setListData(result);
}
setIsSearching(false);
};
const issueSearch = async () => {
setIsSearching(true);
const response = await authenticatedFetch(`${consumerUrl}/issue/list?keyword=${searchText}`, {
method: "GET",
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
});
if (response.ok) {
const data = await response.json() as { data: listDataItem[] };
setIsSearching(false);
const result = data.data;
result.forEach((item) => {
item.checked = false;
item.title = item.issue_name || "";
item.content = item.issue_type || "";
const redirect = encodeURIComponent(`/gene/#/issue/zzyyIssue?mid=${item.id}&event_type=issue`);
item.url = `${presentUrl}?inredirect=${redirect}`;
});
setListData(result);
}
setIsSearching(false);
};
const eventSearch = async () => {
setIsSearching(true);
const response = await authenticatedFetch(`${consumerUrl}/event/queryEvents`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
body: JSON.stringify({ word: searchText, page: 1, size: 50, type: "hot", timeRange: "" }),
});
if (response.ok) {
const data = await response.json() as { data: listDataItem[] };
setIsSearching(false);
const result = data.data;
result.forEach((item) => {
item.checked = false;
const redirect = urlSafeBtoa(`/gene/#/event/basicEvent?event_type=hot&mid=${item.id}`);
item.url = `${presentUrl}?inredirect=${redirect}`;
});
setListData(result);
}
setIsSearching(false);
};
const topicQuery = async () => {
setIsSearching(true);
let result: listDataItem[] = [];
for (const discriminant of ["威胁", "ZJ机"]) {
const queryString = `keyword=${searchText}&discriminant=${discriminant}`;
const response = await authenticatedFetch(`${consumerUrl}/topicQuery/queryEventFromA?${queryString}`, {
method: "GET",
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
});
if (response.ok) {
const data = await response.json() as { data: listDataItem[] };
const results = data.data;
results.forEach((item) => {
item.checked = false;
const redirect = urlSafeBtoa(`/gene/#/event/basicEvent?event_type=hot&mid=${item.id}`);
item.url = `${presentUrl}?inredirect=${redirect}`;
});
result = [...result, ...results];
}
}
setIsSearching(false);
setListData(result);
};
const searchAChange = async (type: string) => {
if (type === "human") await queryTopicTargetHumanList();
else if (type === "organization") await organizationSearch();
else if (type === "community") await queryCommunityList();
else if (type === "issue") await issueSearch();
else if (type === "event") await eventSearch();
else if (type === "threat") await topicQuery();
};
const searchTypeChange = (value: string) => {
setSearchType(value);
setListData([]);
void searchAChange(value);
};
const searchDataChange = () => {
setIsSearching(true);
setSearchValue(searchText);
setTimeout(() => setIsSearching(false), 500);
if (activeTab === "A") void searchAChange(searchType);
};
const yUserId = getYUserId();
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent
className="w-[90vw] max-w-[90vw] sm:max-w-[90vw] h-[90vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 [&>button]:top-4 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogTitle className="sr-only">添加来源</DialogTitle>
<DialogDescription className="sr-only">从外部系统搜索并导入来源</DialogDescription>
<div className="flex-1 min-h-0 relative overflow-hidden">
<div className="h-full overflow-y-auto">
<div className="px-3 sm:px-12 pt-12 sm:pt-24 pb-6 sm:pb-16">
{/* 搜索框 */}
<div className="relative rounded-lg border border-neutral-200 bg-white mb-[10px] transition-colors hover:border-neutral-300">
<Textarea
placeholder="搜索资料..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
searchDataChange();
}
}}
className="max-h-[160px] resize-none border-0 bg-transparent px-10 py-3 pr-20 text-sm text-neutral-700 placeholder:text-neutral-400 focus-visible:ring-0 focus-visible:outline-none"
rows={1}
/>
<div className="absolute left-3 top-3 text-neutral-400 pointer-events-none">
<Search className="h-4 w-4" strokeWidth={1.5} />
</div>
<Button
size="sm"
onClick={searchDataChange}
disabled={isSearching || !searchText.trim()}
className="absolute right-2 top-2 h-7 rounded-md bg-neutral-900 px-3 text-xs font-medium text-white transition-colors hover:bg-neutral-800 disabled:pointer-events-none disabled:opacity-40"
>
{isSearching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <span>搜索</span>}
</Button>
</div>
{/* Tab 标签页 */}
<div className="border-b border-gray-200 rounded-t-xl px-4 bg-gray-50">
<div className="flex flex-nowrap overflow-x-auto">
{(
[
{ key: "search" as const, label: "B系统(知识库)" },
{ key: "A" as const, label: "A系统(基因库)" },
{ key: "Y" as const, label: "Y系统(信息库)" },
{ key: "actor" as const, label: "B系统(XWT检索)" },
{ key: "upload" as const, label: "上传" },
] as const
).map(({ key, label }) => (
<button
key={key}
onClick={() => void searchActiveChange(key)}
className={`relative pb-3 px-6 pt-4 text-sm font-medium transition-colors ${activeTab === key ? "text-gray-900" : "text-gray-400 hover:text-gray-600"
}`}
>
{label}
{activeTab === key && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-900 rounded-full" />
)}
</button>
))}
</div>
</div>
<div className="my-5">
{/* B系统知识库 */}
{activeTab === "search" && !isSearching && (
<iframe
className="w-full h-[calc(100vh-200px)]"
src={`${knowesUrl}/fromB1?yUserId=${yUserId}&processType=searchMode&bookChatId=${searchSpaceId}&inputValue=${searchValue}`}
/>
)}
{/* Y系统信息库 */}
{activeTab === "Y" && !isSearching && (
<iframe
className="w-full h-[calc(100vh-200px)]"
src={`${knowledgeUrl}/#/sousou?chatType=new-chat&bookChatId=${searchSpaceId}&inputValue=${searchText}`}
/>
)}
{/* A系统基因库 */}
{activeTab === "A" && (
<div className="bg-white rounded-lg">
<div className="custom-radio-group pt-[10px] ml-[10px]">
{searchTypes.map((option) => (
<div
key={option.value}
className={`custom-radio-btn ${searchType === option.value ? "active" : ""}`}
onClick={() => searchTypeChange(option.value)}
>
{option.label}
</div>
))}
</div>
{selectedSearchSources.length > 0 && (
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
<span className="text-sm text-gray-600">已选中 {selectedSearchSources.length} 项</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={handleBatchDelete} className="h-8 px-3 text-sm">
<Trash2 className="h-3.5 w-3.5 mr-1" />
删除选中
</Button>
<Button
size="sm"
onClick={() => void handleImport()}
disabled={isImporting}
className="h-8 px-3 text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200"
>
{isImporting ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Plus className="h-3.5 w-3.5 mr-1" />}
导入选中
</Button>
</div>
</div>
)}
{listData.length === 0 && <Empty />}
{searchType === "human" && (
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
{listData.map((item, index) => (
<div key={index} className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
{/* 上传文件显示 loading 状态 */}
{item.is_uploaded_file && item.processing_status === 'processing' && (
<div className="absolute inset-0 bg-white/80 flex items-center justify-center z-10">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
)}
{/* 上传文件完成状态标记 */}
{item.is_uploaded_file && item.processing_status === 'error' && (
<div className="absolute top-1 left-1">
<span className="text-xs text-red-500">处理失败</span>
</div>
)}
{item.is_uploaded_file && item.processing_status === 'success' && (
<div className="absolute top-1 left-1">
<span className="text-xs text-green-500">✓ 已完成</span>
</div>
)}
<div className="w-[50px] mr-[10px]">
<img src={item.imagePath || ""} className="w-[50px] h-[50px]" alt="" />
</div>
<div className="flex-1 min-w-0">
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.name}</p>
<p className="truncate text-[#909090]">职业:{item.occupationData}</p>
<p className="truncate text-[#909090]">属地:{item.country}</p>
</div>
<div className="absolute top-[5px] right-[10px]">
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
</div>
</div>
))}
</div>
)}
{searchType === "organization" && (
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
{listData.map((item, index) => (
<div key={index} className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
<div className="w-[50px] mr-[10px]">
<img src={item.imagePath || ""} className="w-[50px] h-[50px]" alt="" />
</div>
<div className="flex-1 min-w-0">
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.name}</p>
<p className="truncate text-[#909090]">组织类别:{item.subtype}</p>
<p className="truncate text-[#909090]">简介:{item.text}</p>
</div>
<div className="absolute top-[5px] right-[10px]">
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
</div>
</div>
))}
</div>
)}
{searchType === "community" && (
<div className="bg-white p-[10px] grid grid-cols-4 gap-[10px]">
{listData.map((item, index) => (
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
<div>
<div className="truncate mb-[5px] hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.title}</div>
<div className="flex items-center text-[#909090] mb-[5px]">
{item.nations && <span className="truncate max-w-[200px] inline-block border border-[#cf1322] rounded px-1 text-[#cf1322]">{item.nations}</span>}
{item.labels && <span className="inline-block border border-[#bbb] rounded px-1 ml-[10px]">{item.labels}</span>}
</div>
<div className="flex justify-between mb-[5px]">
<span className="text-[#909090]">群体规模:<span className="text-[#cf1322]">{item.total}</span></span>
<span className="text-[#909090]">对应实体数:<span className="text-[#cf1322]">{item.real_total}</span></span>
</div>
<div><span className="text-[#909090]">涉及平台:<span className="text-[#222222]">{item.platforms}</span></span></div>
</div>
<div className="absolute top-[5px] right-[10px]">
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
</div>
</div>
))}
</div>
)}
{searchType === "issue" && (
<div className="bg-white p-[10px] grid grid-cols-4 gap-[10px]">
{listData.map((item, index) => (
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
<div>
<div className="truncate mb-[5px]">{item.title}</div>
<div className="flex justify-between mb-[5px]">
<span className="text-[#909090]">方向:<span className="text-[#222222]">{item.direction}</span></span>
<span className="text-[#909090]">类别:<span className="text-[#222222]">{item.issue_type}</span></span>
</div>
</div>
<div className="absolute top-[5px] right-[10px]">
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
</div>
</div>
))}
</div>
)}
{searchType === "event" && (
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
{listData.map((item, index) => (
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
<div className="flex-1 min-w-0">
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.title}</p>
<p className="truncate text-[#909090]">首发新闻:{item.site_name}</p>
<p className="truncate text-[#909090]">事件时间:{item.first_publish_time}</p>
<p className="truncate text-[#909090]">关键词:{item.keywords?.join("、")}</p>
</div>
<div className="absolute top-[5px] right-[10px]">
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
</div>
</div>
))}
</div>
)}
{searchType === "threat" && (
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
{listData.map((item, index) => (
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
<div className="flex-1 min-w-0 pr-[25px]">
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.title}</p>
<p className="truncate text-[#909090]">发布平台:{item.site_name || "暂无数据"}</p>
<p className="truncate text-[#909090]">事件时间:{item.first_publish_time || "暂无数据"}</p>
<p className="truncate text-[#909090]">关键词:{item.keywords?.join("、") || "暂无数据"}</p>
</div>
<div className="absolute top-[5px] right-[10px]">
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
</div>
</div>
))}
</div>
)}
</div>
)}
{/* B系统XWT检索 */}
{activeTab === "actor" && !isSearching && (
<NetworkActor
inputValue={searchText}
searchSpaceId={searchSpaceId}
onSuccess={handleSuccess}
/>
)}
{/* 上传 */}
{activeTab === "upload" && (
<div className="flex flex-col items-center justify-center gap-4 py-10">
<input
ref={fileInputRef}
type="file"
multiple
accept={UPLOAD_ACCEPT}
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
if (files.length) void handleFileUpload(files);
}}
/>
<Button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="px-6"
>
{uploading ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />上传中...</>
) : (
<><Plus className="h-4 w-4 mr-2" />选择文件上传</>
)}
</Button>
<p className="text-xs text-muted-foreground">支持 PDF、Word、Excel、PPT、HTML、CSV、Markdown、TXT 及常见图片格式</p>
</div>
)}
</div>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 h-2 sm:h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,20 @@
import React from 'react';
const Empty = ({
description = '暂无数据',
size = 'default'
}: {
description?: string;
size?: 'default' | 'small';
}) => {
return (
<div className={`empty-container ${size === 'small' ? 'empty-small' : ''}`}>
<div className="empty-image">
<img src="/icon/empty-list.png" alt="暂无数据" />
</div>
{description && <p className="empty-description">{description}</p>}
</div>
);
};
export default Empty;
@@ -0,0 +1,630 @@
"use client";
import {
useEffect,
useRef,
useState,
} from "react";
import { Textarea } from "@/components/ui/textarea";
import { Loader2, Plus, Search, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth";
function authenticatedFetch(url: string, init?: RequestInit) {
const token = getStoredAuth()?.access_token;
return fetch(url, {
...init,
headers: {
...(init?.headers ?? {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
}
interface NetworkActorProps {
inputValue: string;
searchSpaceId: string;
onSuccess?: () => void;
}
interface platformItem {
title: string;
icon: string;
checked: boolean;
}
interface languageItem {
title: string;
checked: boolean;
}
interface PoliticsDirItem {
地区: string;
语言: string;
年龄段: string;
性别: string;
教育程度: string;
关注话题: { 话题: string; 倾向: string; 佐证: string; 风格: string }[];
类型: string;
对HZZLC: string;
对MZZLC: string;
对TZZLC: string[];
账号描述: string;
发文频率: string;
活跃时间: string;
}
interface 用户画像 {
地区: string;
教育程度: string;
关注话题: { 倾向: string; 话题: string; 佐证: string; 风格: string }[];
语言: string;
年龄段: string;
账号描述: string;
对TZZLC: string[];
对HZZLC: string;
对MZZLC: string[];
性别: string;
类型: string;
发文频率: string;
活跃时间: string;
}
interface hx_info {
语言: string;
用户画像: 用户画像;
}
interface UserDataItem {
comment_count: number;
country: string | null;
agent_task_id: string | null;
education: string | null;
groupId: string | null;
forward_count: number;
political_stand: string;
language: string;
type: string;
concern: string | null;
profile_image: string | null;
concern_topic: string | null;
screen_name: string;
school: string | null;
is_alive: number;
createdTime: string;
data_provider: string | null;
id: string;
text: string | null;
group: string | null;
read_count: number;
group_name: string;
area: string | null;
image: string | null;
updatedTime: string;
like_count: number;
m_politics_dir: string;
camp: string | null;
tags: string[];
army: string | null;
is_sync_a: number;
tweets_count: number;
groupName: string | null;
history_key: string;
user_id: string;
real_id: string | null;
h_politics_dir: string | null;
domain: string | null;
name: string | null;
position: string | null;
job: string | null;
age_round: string | null;
birthday: string | null;
is_virtual: number;
isMedia: number;
hx_info: hx_info;
gender: string | null;
agentInfo: PoliticsDirItem;
profile_url: string | null;
tagId: string[];
user_name: string;
a_update_time: string | null;
values: string | null;
follows_count: number;
title: string | null;
is_protected: number;
platform: string;
importType: string;
update_time: string;
key: string;
introduction: string | null;
brief: string | null;
creator: string;
history_title: string;
tweets_ratio: string | null;
is_verified: number;
t_politics_dir: string[];
eventMap: string | null;
fans_count: number;
a_create_time: string | null;
hobbies: string[];
location: string | null;
average_count: number;
ratio: string | null;
classification?: string | null;
groupDiscussTags?: string[];
checked?: boolean;
content?: string;
url?: string;
}
export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkActorProps) {
const [platforms, setPlatforms] = useState<platformItem[]>([
{ title: 'X', icon: 'x.png', checked: false },
{ title: 'Facebook', icon: 'facebook.png', checked: false },
{ title: 'Reddit', icon: 'reddit.png', checked: false },
{ title: 'PTT', icon: 'ptt.png', checked: false },
{ title: 'YouTube', icon: 'youtube.png', checked: false },
{ title: 'Dcard', icon: 'dcard.png', checked: false },
{ title: 'Mobile01', icon: 'mobile01.png', checked: false },
{ title: 'Threads', icon: 'threads.png', checked: false },
{ title: 'Instagram', icon: 'ins.png', checked: false },
]);
const platformList = useRef<string[]>([]);
const [languages, setLanguages] = useState<languageItem[]>([
{ title: '繁体中文', checked: false },
{ title: '简体中文', checked: false },
{ title: '英文', checked: false },
{ title: '日文', checked: false },
{ title: '其他', checked: false },
]);
const languageList = useRef<string[]>([]);
const [allPlatforms, setAllPlatforms] = useState<boolean>(true);
const [allLanguage, setAllLanguage] = useState<boolean>(true);
const [sortType, setSortType] = useState<string>('fans_count');
const sortTypeValue = useRef('fans_count');
const [fansSort, setFansSort] = useState<string>('desc');
const sortValue = useRef('desc');
const [relevanceSort, setRelevanceSort] = useState<string>('desc');
const [userList, setUserList] = useState<UserDataItem[]>([]);
const [searchText, setSearchText] = useState<string>(inputValue);
const [isSearching, setIsSearching] = useState<boolean>(false);
const [searchType, setSearchType] = useState<string>('network');
const searchValue = useRef<string>('network');
const [selNetCount, setSelNetCount] = useState<number>(0);
const [actorCount, setActorCount] = useState<number>(0);
const [directionType, setDirectionType] = useState<string>('TW');
const directionValue = useRef<string>('TW');
const consumerUrl = 'http://ch1.b.uat.4.cn/consumer';
const backendUrl = typeof window !== 'undefined'
? (import.meta.env.VITE_BACKEND_BASE_URL || 'http://localhost:8001')
: 'http://localhost:8001';
const [selectedSearchSources, setSelectedSearchSources] = useState<UserDataItem[]>([]);
const [isImporting, setIsImporting] = useState(false);
const searchChange = async () => {
setIsSearching(true);
if (searchValue.current === 'network') {
await agentUserSearch();
} else {
await agentGroupSearch();
}
};
const setDirectionChange = async (type: string) => {
setDirectionType(type);
directionValue.current = type;
await searchChange();
};
const searchTypeChange = async (value: string) => {
setSearchType(value);
searchValue.current = value;
await searchChange();
};
const allPlatformChange = async () => {
setAllPlatforms(true);
setPlatforms((prev) => prev.map((item) => ({ ...item, checked: false })));
platformList.current = [];
await searchChange();
};
const handleTogglePlatform = async (index: number) => {
setPlatforms((prev) => {
const newPlatforms = prev.map((item, i) =>
i === index ? { ...item, checked: !item.checked } : item
);
const checkedTitles = newPlatforms.filter((item) => item.checked).map((item) => item.title);
platformList.current = checkedTitles;
setAllPlatforms(newPlatforms.every((item) => !item.checked));
return newPlatforms;
});
await searchChange();
};
const allLanguageChange = async () => {
setAllLanguage(true);
setLanguages((prev) => prev.map((item) => ({ ...item, checked: false })));
languageList.current = [];
await searchChange();
};
const handleToggleLanguage = async (index: number) => {
setLanguages((prev) => {
const newItems = prev.map((item, i) =>
i === index ? { ...item, checked: !item.checked } : item
);
const checkedTitles = newItems.filter((item) => item.checked).map((item) => item.title);
languageList.current = checkedTitles;
setAllLanguage(newItems.every((item) => !item.checked));
return newItems;
});
await searchChange();
};
const sortTypeChange = async (type: string) => {
setSortType(type);
sortTypeValue.current = type;
sortValue.current = sortValue.current === 'desc' ? 'asc' : 'desc';
if (type === 'fans_count') setFansSort(sortValue.current);
else if (type === '_score') setRelevanceSort(sortValue.current);
await searchChange();
};
const agentUserSearch = async () => {
try {
const response = await authenticatedFetch(`${consumerUrl}/portrait/agentUser`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admin' },
body: JSON.stringify({
historyKey: [],
historyTitle: '',
keyword: searchText,
language: languageList.current,
page: 1,
limit: 12,
platform: platformList.current,
politicalStand: directionValue.current,
sort: sortTypeValue.current,
asc: sortValue.current,
typeKeyword: 9,
}),
});
if (response.ok) {
const data = await response.json();
setIsSearching(false);
const result: UserDataItem[] = data.data.list;
result.forEach((item) => {
item.profile_image = item.profile_image
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
: 'http://ty.b.uat.4.cn/manage/img/default.png';
item.classification = `${item.political_stand}-${item.history_key}-${item.history_title}`;
item.title = item.user_name || item.screen_name || item.user_id;
item.content = item.agentInfo['账号描述'];
item.url = `http://ty.b.uat.4.cn/fromB/toDetail?user_id=${item.user_id}&platform=${item.platform}`;
});
setUserList(result);
setSelNetCount(data.data.total);
setActorCount(data.data.zs_total);
}
} catch (error) {
console.error('Search error:', error);
}
};
const agentGroupSearch = async () => {
try {
const response = await authenticatedFetch(`${consumerUrl}/portrait/agentGroup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admin' },
body: JSON.stringify({
historyKey: [],
historyTitle: '',
keyword: searchText,
language: languageList.current,
page: 1,
limit: 12,
platform: platformList.current,
politicalStand: directionValue.current,
sort: sortTypeValue.current,
asc: sortValue.current,
typeKeyword: 9,
}),
});
if (response.ok) {
const data = await response.json();
setIsSearching(false);
const result: UserDataItem[] = data.data.data;
result.forEach((item) => {
item.hobbies = item.groupDiscussTags || [];
item.profile_image = item.profile_image
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
: 'http://ty.b.uat.4.cn/manage/img/default.png';
item.classification = `${item.political_stand}`;
item.title = item.group_name || item.groupName;
if (item.hx_info?.['用户画像']) {
item.content = item.hx_info['用户画像']['账号描述'];
}
item.url = '';
});
setUserList(result);
setActorCount(data.data.total);
}
} catch (error) {
console.error('Search error:', error);
}
};
const listDataCheckedChange = (index: number, checked: boolean) => {
setUserList((prevSources) => {
const newSources = prevSources.map((item, aIndex) =>
aIndex === index ? { ...item, checked } : item
);
setSelectedSearchSources(newSources.filter((item) => item.checked));
return newSources;
});
};
const linkToB2 = (data: UserDataItem) => {
const url = `http://ty.b.uat.4.cn/fromB/toDetail?user_id=${data.user_id}&platform=${data.platform}`;
window.open(url, '_blank');
};
const handleBatchDelete = () => {
setUserList((prev) => prev.map((item) => ({ ...item, checked: false })));
setSelectedSearchSources([]);
};
const handleImport = async () => {
if (selectedSearchSources.length === 0) {
toast.warning('没有可导入的来源');
return;
}
setIsImporting(true);
const dataList = selectedSearchSources.map((item) => ({
title: item.title,
content: item.content,
url: item.url,
}));
try {
const response = await authenticatedFetch(`${backendUrl}/api/v1/source/import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
search_space_id: Number(searchSpaceId),
source_list: dataList,
}),
});
if (response.ok) {
const data = await response.json();
toast.success(`成功导入 ${data.imported_count || dataList.length} 个来源`);
setSearchText('');
setUserList([]);
handleBatchDelete();
onSuccess?.();
} else {
const errorData = await response.json();
toast.error(errorData.detail || '导入失败,请重试');
}
} catch {
toast.error('导入失败,请重试');
} finally {
setIsImporting(false);
}
};
useEffect(() => {
void searchChange();
}, []);
return (
<div>
<div className="bg-white rounded-[5px] p-[10px]">
<div className="border border-[#e8e8e8] rounded-[5px] p-[10px]">
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">所属平台:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allPlatforms ? 'platform-active' : ''}`}
onClick={() => void allPlatformChange()}
>全部</span>
{platforms.map((item, index) => (
<span
key={index}
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
onClick={() => void handleTogglePlatform(index)}
>
<img src={`/platform/${item.icon}`} className="w-[24px]" alt="" />
<span className="w-[2px] h-[12px] ml-[5px] mr-[5px] bg-[#dbdbdb]"></span>
<span className="ml-[5px]">{item.title}</span>
</span>
))}
</span>
</span>
</div>
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">常用语言:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allLanguage ? 'platform-active' : ''}`}
onClick={() => void allLanguageChange()}
>全部</span>
{languages.map((item, index) => (
<span
key={index}
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
onClick={() => void handleToggleLanguage(index)}
>
<span>{item.title}</span>
</span>
))}
</span>
</span>
</div>
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">方向:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === 'TW' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('TW')}>W方向</span>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === 'US' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('US')}>U方向</span>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === '其他' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('其他')}>其他</span>
</span>
</span>
</div>
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">类型:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${searchType === 'network' ? 'platform-active' : ''}`} onClick={() => void searchTypeChange('network')}>网络XWT</span>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${searchType === 'group' ? 'platform-active' : ''}`} onClick={() => void searchTypeChange('group')}>群组XWT</span>
</span>
</span>
</div>
</div>
</div>
<div className="bg-white rounded-[5px] pt-[15px] mt-[10px]">
<div className="flex items-center justify-between pb-[10px] mb-[10px] border-b border-b-[#f1f3f5]">
<div className="flex items-center ml-[10px]">
<div className="title-bar mr-[8px]"></div>
<div className="text-[#454859]">用户表格管理</div>
</div>
<div className="flex items-center">
<div
className={`relative flex items-center cursor-pointer border ${sortType === 'fans_count' ? 'border-[#cf1322]' : ''} p-[7px] pl-[10px] pr-[20px] rounded-[5px]`}
onClick={() => void sortTypeChange('fans_count')}
>
<span className="leading-[16px]">粉丝数排序</span>
<div className="ml-[10px]">
<div className={`sort-up ${fansSort === 'asc' ? 'sort-up-active' : ''}`}></div>
<div className={`mt-[4px] sort-down ${fansSort === 'desc' ? 'sort-down-active' : ''}`}></div>
</div>
</div>
<div
className={`relative flex items-center ml-[10px] cursor-pointer border ${sortType === '_score' ? 'border-[#cf1322]' : ''} p-[7px] pl-[10px] pr-[20px] rounded-[5px]`}
onClick={() => void sortTypeChange('_score')}
>
<span className="leading-[16px]">相关度排序</span>
<div className="ml-[10px]">
<div className={`sort-up ${relevanceSort === 'asc' ? 'sort-up-active' : ''}`}></div>
<div className={`mt-[4px] sort-down ${relevanceSort === 'desc' ? 'sort-down-active' : ''}`}></div>
</div>
</div>
{searchType === 'network' && (
<>
<div className="flex items-center ml-[35px]">
<img className="w-[24px] h-[24px]" src="/icon/group-yellow-logo.svg" alt="" />
<div className="ml-[5px]">
<div className="text-[#454859] leading-[16px]">当前选中网络XWT数</div>
<div className="text-[#d97b00] leading-[16px] mt-[5px]">{selNetCount}</div>
</div>
</div>
<div className="ml-[5px] mr-[5px] h-[30px] w-[2px] -skew-x-[30deg] bg-[#dbdbdb]"></div>
</>
)}
<div className={`flex items-center ${searchType === 'network' ? 'ml-[5px]' : 'ml-[20px]'} mr-[25px]`}>
<img className="w-[24px] h-[24px]" src="/icon/group-red-logo.svg" alt="" />
<div className="ml-[5px]">
<div className="text-[#454859] leading-[16px]">网络XWT总数</div>
<div className="text-[#cf1322] leading-[16px] mt-[5px]">{actorCount}</div>
</div>
</div>
</div>
</div>
{selectedSearchSources.length > 0 && (
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
<span className="text-sm text-gray-600">已选中 {selectedSearchSources.length} 项</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={handleBatchDelete} className="h-8 px-3 text-sm">
<Trash2 className="h-3.5 w-3.5 mr-1" />
删除选中
</Button>
<Button
size="sm"
onClick={() => void handleImport()}
disabled={isImporting}
className="h-8 px-3 text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200"
>
{isImporting ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Plus className="h-3.5 w-3.5 mr-1" />}
导入选中
</Button>
</div>
</div>
)}
<div className="p-[10px]">
<div className="grid grid-cols-4 gap-[10px] user-list">
{userList.map((item, index) => (
<div key={index} className="user-item p-[10px] relative">
<div className="absolute top-[5px] right-[10px]">
<input
type="checkbox"
checked={item.checked ?? false}
onChange={() => listDataCheckedChange(index, !item.checked)}
className="w-4 h-4 cursor-pointer accent-primary"
/>
</div>
<div className="flex items-center mb-[10px] cursor-pointer" onClick={() => linkToB2(item)}>
<img src={item.profile_image ?? ''} className="w-[97px] h-[97px] rounded-[5px]" alt="" />
<div className="flex-1 min-w-0 pl-[10px]">
<div className="truncate mb-[10px] text-[18px]">{item.user_name || item.screen_name || item.user_id || item.group_name || item.groupName}</div>
<div className="flex justify-between mb-[10px]">
<div className="flex items-center">
<img src="/icon/group-yellow-logo.svg" className="w-[16px] h-[16px] mr-[5px]" alt="" />
<span>粉丝数:{item.fans_count || '-'}</span>
</div>
<div className="flex items-center">
<img src={`/platform/${item.platform}.png`} className="w-[25px] mr-[5px]" alt="" />
<span>{item.platform}</span>
</div>
</div>
<div className="flex items-center">
<img src="/icon/person-red-logo.svg" className="w-[16px] mr-[5px]" alt="" />
<span>实体人:{item.name}</span>
</div>
</div>
</div>
<div className="cursor-pointer" onClick={() => linkToB2(item)}>
<div className="flex items-center mb-[10px]">
<div className="w-[70px] flex items-center">
<img src="/icon/classification-logo.png" className="w-[16px] mr-[5px]" alt="" />
<span>分类:</span>
</div>
<div className="flex-1 min-w-0">
<div className="inline-block h-[28px] pl-[8px] pr-[8px] bg-[#fef6f6] text-[#cf1322]">{item.classification}</div>
</div>
</div>
<div className="flex items-center mb-[10px]">
<div className="w-[70px] flex items-center">
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
<span>标签:</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-[5px]">
{(item.hobbies ?? []).map((hobbie) => (
<div key={hobbie} className="h-[28px] pl-[8px] pr-[8px] bg-[#f6fdfb] text-[#1eb07f]">{hobbie}</div>
))}
</div>
</div>
</div>
<div className="flex items-baseline mb-[10px]">
<div className="w-[70px] flex items-center">
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
<span>简介:</span>
</div>
<div className="flex-1 min-w-0">
<div className="line-clamp-3 text-ellipsis overflow-hidden">
{(item.agentInfo && item.agentInfo['账号描述']) || (item.hx_info?.['用户画像']?.['账号描述']) || ''}
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,854 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
type ReactNode,
} from "react";
import type {
GraphEdge,
GraphNode,
LayoutType,
SimEdge,
SimNode,
TooltipState,
Transform,
} from "./graphTypes";
import {
applyCircularLayout,
ForceSimulation,
getEdgeId,
getEdgeInfo,
getEdgeLabel,
getLinkDistance,
getNodeKind,
getNodeRadius,
getNodeTitle,
NODE_KIND_META,
seedPositions,
truncateLabel,
} from "./graphUtils";
const MIN_ZOOM = 0.15;
const MAX_ZOOM = 4;
export interface GraphCanvasHandle {
zoomIn: () => void;
zoomOut: () => void;
fitView: () => void;
relayout: () => void;
focusNode: (id: string) => void;
exportImage: () => void;
}
interface GraphCanvasProps {
nodes: GraphNode[];
edges: GraphEdge[];
/** Degrees computed on the FULL graph so node sizes stay stable under filters. */
degrees: Map<string, number>;
selectedNodeId: string | null;
/** null = no active search; otherwise the set of matching node ids. */
searchMatchIds: Set<string> | null;
layoutType: LayoutType;
showLabels: boolean;
onSelectNode: (id: string | null) => void;
onTooltip: (tooltip: TooltipState | null) => void;
onZoomChange: (k: number) => void;
/**
* Explore-mode rendering context; null/undefined renders the normal full view.
* preview = no root picked yet: nodes are shown faded as pickable起点 candidates.
*/
explore?: {
preview: boolean;
rootId: string | null;
nodeDepthMap: Map<string, number>;
expandedNodeIds: Set<string>;
} | null;
/** Floating pills rendered top-left over the canvas. */
toolbar?: ReactNode;
/** Small floating hint rendered bottom-center over the canvas. */
hint?: ReactNode;
/** Centered overlay (empty / loading states). */
overlay?: ReactNode;
}
interface DragState {
pointerId: number;
node: SimNode | null;
startX: number;
startY: number;
originX: number;
originY: number;
moved: boolean;
}
const easeCubicOut = (t: number) => 1 - Math.pow(1 - t, 3);
export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
function GraphCanvas(props, ref) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const minimapRef = useRef<HTMLCanvasElement>(null);
const simRef = useRef<ForceSimulation | null>(null);
const nodeMapRef = useRef<Map<string, SimNode>>(new Map());
const adjacencyRef = useRef<Map<string, Set<string>>>(new Map());
const labelThresholdRef = useRef(3);
const transformRef = useRef<Transform>({ x: 0, y: 0, k: 0 });
const sizeRef = useRef({ w: 0, h: 0 });
const hoverRef = useRef<SimNode | null>(null);
const dragRef = useRef<DragState | null>(null);
const animRef = useRef<{
from: Transform;
to: Transform;
start: number;
duration: number;
} | null>(null);
const needsDrawRef = useRef(true);
const autoFitRef = useRef(false);
const rafRef = useRef(0);
// Latest props readable from the rAF loop / event handlers.
const propsRef = useRef(props);
propsRef.current = props;
const requestDraw = useCallback(() => {
needsDrawRef.current = true;
}, []);
// ---------------------------------------------------------------- build
useEffect(() => {
const previous = nodeMapRef.current;
const simNodes: SimNode[] = props.nodes.map((n) => {
const degree = props.degrees.get(n.id) ?? 0;
const kind = getNodeKind(n);
const old = previous.get(n.id);
return {
id: n.id,
node: n,
kind,
degree,
radius: getNodeRadius(degree),
color: NODE_KIND_META[kind].color,
x: old?.x ?? Number.NaN,
y: old?.y ?? Number.NaN,
vx: 0,
vy: 0,
fx: old?.fx ?? null,
fy: old?.fy ?? null,
};
});
const fresh = simNodes.filter((n) => Number.isNaN(n.x));
if (fresh.length === simNodes.length) {
seedPositions(simNodes);
} else {
// Filters re-enabled some nodes: spiral the newcomers in near the center.
fresh.forEach((n, i) => {
const r = 60 + 16 * Math.sqrt(i + 1);
const angle = i * 2.3999632297;
n.x = r * Math.cos(angle);
n.y = r * Math.sin(angle);
});
}
const byId = new Map(simNodes.map((n) => [n.id, n]));
const simEdges: SimEdge[] = [];
const adjacency = new Map<string, Set<string>>();
for (const e of props.edges) {
const source = byId.get(e.source);
const target = byId.get(e.target);
if (!source || !target) continue;
const label = getEdgeLabel(e);
simEdges.push({
id: getEdgeId(e),
source,
target,
label,
info: getEdgeInfo(label),
weight: e.weight ?? 1,
distance: getLinkDistance(label) + source.radius + target.radius,
});
if (!adjacency.has(source.id)) adjacency.set(source.id, new Set());
if (!adjacency.has(target.id)) adjacency.set(target.id, new Set());
adjacency.get(source.id)!.add(target.id);
adjacency.get(target.id)!.add(source.id);
}
// Label budget: always label hubs; ~20 labels max by default.
const sortedDegrees = simNodes.map((n) => n.degree).sort((a, b) => b - a);
labelThresholdRef.current = Math.max(
2,
sortedDegrees[Math.min(19, sortedDegrees.length - 1)] ?? 2,
);
nodeMapRef.current = byId;
adjacencyRef.current = adjacency;
const sim = new ForceSimulation(simNodes, simEdges);
if (props.layoutType === "circular") {
applyCircularLayout(simNodes);
sim.alpha = 0;
} else {
sim.alpha = previous.size === 0 ? 1 : 0.5;
}
simRef.current = sim;
if (previous.size === 0) autoFitRef.current = false;
requestDraw();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.nodes, props.edges, props.degrees]);
// Layout switching.
const layoutRef = useRef(props.layoutType);
useEffect(() => {
if (layoutRef.current === props.layoutType) return;
layoutRef.current = props.layoutType;
const sim = simRef.current;
if (!sim) return;
if (props.layoutType === "circular") {
applyCircularLayout(sim.nodes);
sim.alpha = 0;
} else {
for (const n of sim.nodes) {
n.fx = null;
n.fy = null;
}
sim.reheat(0.9);
}
autoFitRef.current = false;
requestDraw();
}, [props.layoutType, requestDraw]);
// Redraw on highlight-affecting prop changes.
useEffect(() => {
requestDraw();
}, [
props.selectedNodeId,
props.searchMatchIds,
props.showLabels,
props.explore,
requestDraw,
]);
// ------------------------------------------------------------ transform
const clampZoom = (k: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, k));
const setTransform = useCallback((t: Transform) => {
transformRef.current = t;
propsRef.current.onZoomChange(t.k);
needsDrawRef.current = true;
}, []);
const animateTo = useCallback((to: Transform, duration = 350) => {
animRef.current = {
from: { ...transformRef.current },
to,
start: performance.now(),
duration,
};
needsDrawRef.current = true;
}, []);
const zoomBy = useCallback(
(factor: number, cx?: number, cy?: number) => {
const t = transformRef.current;
const { w, h } = sizeRef.current;
const px = cx ?? w / 2;
const py = cy ?? h / 2;
const k = clampZoom(t.k * factor);
const wx = (px - t.x) / t.k;
const wy = (py - t.y) / t.k;
setTransform({ x: px - wx * k, y: py - wy * k, k });
},
[setTransform],
);
const fitView = useCallback(
(animated = true) => {
const sim = simRef.current;
const { w, h } = sizeRef.current;
if (!sim || !sim.nodes.length || !w || !h) return;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const n of sim.nodes) {
minX = Math.min(minX, n.x - n.radius);
minY = Math.min(minY, n.y - n.radius);
maxX = Math.max(maxX, n.x + n.radius);
maxY = Math.max(maxY, n.y + n.radius);
}
const pad = 70;
const bw = Math.max(maxX - minX, 1);
const bh = Math.max(maxY - minY, 1);
const k = clampZoom(Math.min((w - pad * 2) / bw, (h - pad * 2) / bh, 2.2));
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const target = { x: w / 2 - cx * k, y: h / 2 - cy * k, k };
if (animated) animateTo(target);
else setTransform(target);
},
[animateTo, setTransform],
);
const focusNode = useCallback(
(id: string) => {
const node = nodeMapRef.current.get(id);
const { w, h } = sizeRef.current;
if (!node || !w || !h) return;
const k = clampZoom(Math.max(transformRef.current.k, 1.4));
animateTo({ x: w / 2 - node.x * k, y: h / 2 - node.y * k, k });
},
[animateTo],
);
const relayout = useCallback(() => {
const sim = simRef.current;
if (!sim) return;
if (layoutRef.current === "circular") {
applyCircularLayout(sim.nodes);
sim.alpha = 0;
fitView(true);
} else {
seedPositions(sim.nodes);
sim.alpha = 1;
autoFitRef.current = false;
}
requestDraw();
}, [fitView, requestDraw]);
const exportImage = useCallback(() => {
const main = canvasRef.current;
if (!main) return;
const out = document.createElement("canvas");
out.width = main.width;
out.height = main.height;
const ctx = out.getContext("2d");
if (!ctx) return;
ctx.fillStyle = "#0b1020";
ctx.fillRect(0, 0, out.width, out.height);
ctx.drawImage(main, 0, 0);
out.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "knowledge-graph.png";
a.click();
URL.revokeObjectURL(url);
}, "image/png");
}, []);
useImperativeHandle(
ref,
() => ({
zoomIn: () => zoomBy(1.25),
zoomOut: () => zoomBy(1 / 1.25),
fitView: () => fitView(true),
relayout,
focusNode,
exportImage,
}),
[zoomBy, fitView, relayout, focusNode, exportImage],
);
// ----------------------------------------------------------------- draw
const draw = useCallback(() => {
const canvas = canvasRef.current;
const sim = simRef.current;
if (!canvas || !sim) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const { w, h } = sizeRef.current;
const t = transformRef.current;
const p = propsRef.current;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.translate(t.x, t.y);
ctx.scale(t.k, t.k);
const hover = hoverRef.current;
const activeId = hover?.id ?? p.selectedNodeId;
const neighbors = activeId ? adjacencyRef.current.get(activeId) : undefined;
const search = p.searchMatchIds;
const k = t.k || 1;
const explore = p.explore;
// ---- edges
for (const e of sim.edges) {
const touchesActive =
activeId !== null &&
(e.source.id === activeId || e.target.id === activeId);
let alpha: number;
if (activeId) alpha = touchesActive ? 0.95 : 0.07;
else if (search)
alpha = search.has(e.source.id) || search.has(e.target.id) ? 0.75 : 0.06;
else alpha = 0.38;
if (explore && !explore.preview) {
const sourceDepth = explore.nodeDepthMap.get(e.source.id) ?? 0;
const targetDepth = explore.nodeDepthMap.get(e.target.id) ?? 0;
const edgeDepth = Math.max(sourceDepth, targetDepth);
alpha *= Math.max(0.48, 1 - edgeDepth * 0.12);
}
ctx.globalAlpha = alpha;
ctx.strokeStyle = e.info.color;
ctx.lineWidth = (touchesActive ? 2.4 : 1.2) / k;
ctx.setLineDash(e.info.dashed ? [6 / k, 5 / k] : []);
ctx.beginPath();
ctx.moveTo(e.source.x, e.source.y);
ctx.lineTo(e.target.x, e.target.y);
ctx.stroke();
// Relation label at the midpoint while a node is focused.
if (touchesActive && k >= 0.55) {
const mx = (e.source.x + e.target.x) / 2;
const my = (e.source.y + e.target.y) / 2;
ctx.setLineDash([]);
ctx.font = `${10.5 / k}px "Noto Sans SC", system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.lineWidth = 3 / k;
ctx.strokeStyle = "rgba(2, 6, 23, 0.9)";
ctx.strokeText(e.info.zh, mx, my);
ctx.fillStyle = e.info.color;
ctx.fillText(e.info.zh, mx, my);
}
}
ctx.setLineDash([]);
// ---- nodes
const labelThreshold = labelThresholdRef.current;
for (const n of sim.nodes) {
const isActive = n.id === activeId;
const isSelected = n.id === p.selectedNodeId;
const isNeighbor = neighbors?.has(n.id) ?? false;
const depth = explore?.nodeDepthMap.get(n.id);
const isExploreRoot = explore?.rootId === n.id;
const isExpanded = explore?.expandedNodeIds.has(n.id) ?? false;
let alpha: number;
if (activeId) alpha = isActive || isNeighbor ? 1 : 0.14;
else if (search) alpha = search.has(n.id) ? 1 : 0.14;
else alpha = 1;
let baseRadius = n.radius;
let labelByExplore = false;
let strokeWidth = 1.4 / k;
let strokeColor = "rgba(2, 6, 23, 0.55)";
let fillColor = n.color;
if (explore?.preview) {
alpha *= 0.42;
baseRadius = Math.max(8, n.radius * 0.86);
labelByExplore = isActive || isSelected;
} else if (explore && depth != null) {
baseRadius = depth === 0 ? 34 : Math.max(14, 28 - depth * 4);
alpha *= Math.max(0.45, 1 - depth * 0.12);
labelByExplore = depth <= 2 || isActive || isSelected;
strokeWidth = (depth === 0 ? 4 : isExpanded ? 3 : 1.5) / k;
strokeColor = depth === 0 ? "#e0f2fe" : isExpanded ? "#7dd3fc" : "rgba(2, 6, 23, 0.55)";
if (isExploreRoot) fillColor = "#38bdf8";
}
const r = baseRadius * (n.id === hover?.id ? 1.15 : 1);
ctx.globalAlpha = alpha;
// Glow for hubs and the active node.
if (
alpha > 0.5 &&
(isActive || isSelected || isExploreRoot || isExpanded || n.degree >= labelThreshold)
) {
ctx.shadowColor = fillColor;
ctx.shadowBlur = isActive || isSelected || isExploreRoot ? 24 : 12;
} else {
ctx.shadowBlur = 0;
}
ctx.beginPath();
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
ctx.fillStyle = fillColor;
ctx.fill();
ctx.shadowBlur = 0;
// Rim.
ctx.lineWidth = strokeWidth;
ctx.strokeStyle = strokeColor;
ctx.stroke();
if (isExploreRoot) {
ctx.beginPath();
ctx.arc(n.x, n.y, r + 8 / k, 0, Math.PI * 2);
ctx.lineWidth = 1.4 / k;
ctx.strokeStyle = "rgba(125, 211, 252, 0.9)";
ctx.stroke();
} else if (isExpanded) {
ctx.beginPath();
ctx.arc(n.x + r * 0.58, n.y - r * 0.58, Math.max(4 / k, 3), 0, Math.PI * 2);
ctx.fillStyle = "#7dd3fc";
ctx.fill();
ctx.lineWidth = 1 / k;
ctx.strokeStyle = "rgba(2, 6, 23, 0.8)";
ctx.stroke();
}
// Selection: double ring.
if (isSelected) {
ctx.beginPath();
ctx.arc(n.x, n.y, r + 4 / k, 0, Math.PI * 2);
ctx.lineWidth = 2 / k;
ctx.strokeStyle = "#ffffff";
ctx.stroke();
ctx.beginPath();
ctx.arc(n.x, n.y, r + 8 / k, 0, Math.PI * 2);
ctx.lineWidth = 1.2 / k;
ctx.strokeStyle = n.color;
ctx.stroke();
} else if (n.id === hover?.id) {
ctx.beginPath();
ctx.arc(n.x, n.y, r + 3.5 / k, 0, Math.PI * 2);
ctx.lineWidth = 1.6 / k;
ctx.strokeStyle = "rgba(255, 255, 255, 0.85)";
ctx.stroke();
}
// Pin marker for dragged-and-fixed nodes.
if (n.fx != null && !isSelected) {
ctx.beginPath();
ctx.arc(n.x, n.y, Math.max(r * 0.28, 2 / k), 0, Math.PI * 2);
ctx.fillStyle = "rgba(255, 255, 255, 0.8)";
ctx.fill();
}
// Label.
const wantsLabel =
isActive ||
isSelected ||
isNeighbor ||
labelByExplore ||
(search?.has(n.id) ?? false) ||
(p.showLabels && n.degree >= labelThreshold && k >= 0.45);
if (wantsLabel && alpha > 0.5) {
const text = truncateLabel(getNodeTitle(n.node), isActive ? 26 : 14);
const fontSize = (isActive || isSelected ? 13 : 12) / k;
ctx.font = `600 ${fontSize}px "Noto Sans SC", system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.lineWidth = 3.5 / k;
ctx.strokeStyle = "rgba(2, 6, 23, 0.9)";
ctx.strokeText(text, n.x, n.y + r + 5 / k);
ctx.fillStyle = isActive || isSelected ? "#ffffff" : "#e2e8f0";
ctx.fillText(text, n.x, n.y + r + 5 / k);
}
}
ctx.globalAlpha = 1;
drawMinimap();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const drawMinimap = useCallback(() => {
const minimap = minimapRef.current;
const sim = simRef.current;
if (!minimap || !sim || !sim.nodes.length) return;
const ctx = minimap.getContext("2d");
if (!ctx) return;
const mw = minimap.width;
const mh = minimap.height;
ctx.clearRect(0, 0, mw, mh);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const n of sim.nodes) {
minX = Math.min(minX, n.x);
minY = Math.min(minY, n.y);
maxX = Math.max(maxX, n.x);
maxY = Math.max(maxY, n.y);
}
const pad = 10;
const scale = Math.min(
(mw - pad * 2) / Math.max(maxX - minX, 1),
(mh - pad * 2) / Math.max(maxY - minY, 1),
);
const ox = (mw - (maxX - minX) * scale) / 2 - minX * scale;
const oy = (mh - (maxY - minY) * scale) / 2 - minY * scale;
for (const n of sim.nodes) {
ctx.beginPath();
ctx.arc(n.x * scale + ox, n.y * scale + oy, 1.6, 0, Math.PI * 2);
ctx.fillStyle = n.color;
ctx.globalAlpha = 0.9;
ctx.fill();
}
ctx.globalAlpha = 1;
// Viewport rectangle.
const t = transformRef.current;
const { w, h } = sizeRef.current;
if (t.k > 0 && w && h) {
const x0 = ((0 - t.x) / t.k) * scale + ox;
const y0 = ((0 - t.y) / t.k) * scale + oy;
const x1 = ((w - t.x) / t.k) * scale + ox;
const y1 = ((h - t.y) / t.k) * scale + oy;
ctx.strokeStyle = "rgba(56, 189, 248, 0.8)";
ctx.lineWidth = 1;
ctx.strokeRect(x0, y0, x1 - x0, y1 - y0);
}
}, []);
// ----------------------------------------------------------------- loop
useEffect(() => {
const loop = () => {
const sim = simRef.current;
let active = false;
const anim = animRef.current;
if (anim) {
const progress = Math.min(
(performance.now() - anim.start) / anim.duration,
1,
);
const e = easeCubicOut(progress);
transformRef.current = {
x: anim.from.x + (anim.to.x - anim.from.x) * e,
y: anim.from.y + (anim.to.y - anim.from.y) * e,
k: anim.from.k + (anim.to.k - anim.from.k) * e,
};
if (progress >= 1) {
animRef.current = null;
propsRef.current.onZoomChange(transformRef.current.k);
}
active = true;
}
if (sim && layoutRef.current === "force" && sim.tick()) {
active = true;
// Auto-fit once after the initial layout settles.
if (!autoFitRef.current && sim.alpha < 0.3) {
autoFitRef.current = true;
fitView(true);
}
} else if (sim && !autoFitRef.current && sim.nodes.length) {
autoFitRef.current = true;
fitView(false);
}
if (active || needsDrawRef.current) {
needsDrawRef.current = false;
draw();
}
rafRef.current = requestAnimationFrame(loop);
};
rafRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(rafRef.current);
}, [draw, fitView]);
// --------------------------------------------------------------- resize
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
let timer: number | undefined;
const apply = () => {
const rect = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current = { w: rect.width, h: rect.height };
canvas.width = Math.max(1, Math.round(rect.width * dpr));
canvas.height = Math.max(1, Math.round(rect.height * dpr));
if (transformRef.current.k === 0) {
setTransform({ x: rect.width / 2, y: rect.height / 2, k: 0.9 });
}
requestDraw();
};
apply();
const observer = new ResizeObserver(() => {
window.clearTimeout(timer);
timer = window.setTimeout(apply, 120);
});
observer.observe(container);
return () => {
window.clearTimeout(timer);
observer.disconnect();
};
}, [requestDraw, setTransform]);
// --------------------------------------------------------------- events
const screenToWorld = (sx: number, sy: number) => {
const t = transformRef.current;
return { x: (sx - t.x) / t.k, y: (sy - t.y) / t.k };
};
const hitTest = (wx: number, wy: number): SimNode | null => {
const sim = simRef.current;
if (!sim) return null;
// Topmost (drawn last) first.
for (let i = sim.nodes.length - 1; i >= 0; i--) {
const n = sim.nodes[i];
const dx = wx - n.x;
const dy = wy - n.y;
const r = n.radius + 3 / (transformRef.current.k || 1);
if (dx * dx + dy * dy <= r * r) return n;
}
return null;
};
const localPoint = (e: { clientX: number; clientY: number }) => {
const rect = canvasRef.current!.getBoundingClientRect();
return { sx: e.clientX - rect.left, sy: e.clientY - rect.top };
};
const setHover = (node: SimNode | null, e?: React.PointerEvent) => {
if (hoverRef.current?.id !== node?.id) {
hoverRef.current = node;
requestDraw();
}
const canvas = canvasRef.current;
if (canvas) canvas.style.cursor = node ? "pointer" : "grab";
if (node && e) {
propsRef.current.onTooltip({
x: e.clientX,
y: e.clientY,
node: node.node,
kind: node.kind,
degree: node.degree,
});
} else {
propsRef.current.onTooltip(null);
}
};
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (e.button !== 0) return;
const { sx, sy } = localPoint(e);
const { x: wx, y: wy } = screenToWorld(sx, sy);
const node = hitTest(wx, wy);
animRef.current = null;
dragRef.current = {
pointerId: e.pointerId,
node,
startX: sx,
startY: sy,
originX: transformRef.current.x,
originY: transformRef.current.y,
moved: false,
};
if (node) {
node.fx = node.x;
node.fy = node.y;
}
e.currentTarget.setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
const { sx, sy } = localPoint(e);
const drag = dragRef.current;
if (drag && drag.pointerId === e.pointerId) {
const dx = sx - drag.startX;
const dy = sy - drag.startY;
if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;
if (drag.node) {
const world = screenToWorld(sx, sy);
drag.node.fx = world.x;
drag.node.fy = world.y;
if (layoutRef.current === "force") simRef.current?.reheat(0.3);
propsRef.current.onTooltip(null);
} else {
transformRef.current = {
...transformRef.current,
x: drag.originX + dx,
y: drag.originY + dy,
};
}
requestDraw();
return;
}
const world = screenToWorld(sx, sy);
setHover(hitTest(world.x, world.y), e);
};
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== e.pointerId) return;
dragRef.current = null;
if (!drag.moved) {
// Plain click: select node / clear selection on empty space.
if (drag.node) {
drag.node.fx = null;
drag.node.fy = null;
propsRef.current.onSelectNode(drag.node.id);
} else {
propsRef.current.onSelectNode(null);
}
requestDraw();
}
// A real drag leaves the node pinned at its drop position.
};
const handleDoubleClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
const { sx, sy } = localPoint(e);
const world = screenToWorld(sx, sy);
const node = hitTest(world.x, world.y);
if (node) {
propsRef.current.onSelectNode(node.id);
focusNode(node.id);
}
};
const handlePointerLeave = () => {
setHover(null);
};
// Wheel zoom needs a non-passive listener to preventDefault.
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
zoomBy(
Math.exp(-e.deltaY * 0.0012),
e.clientX - rect.left,
e.clientY - rect.top,
);
};
canvas.addEventListener("wheel", onWheel, { passive: false });
return () => canvas.removeEventListener("wheel", onWheel);
}, [zoomBy]);
const minimapVisible = useMemo(() => props.nodes.length > 0, [props.nodes]);
return (
<div className="kg-canvas-wrap" ref={containerRef}>
<canvas
ref={canvasRef}
className="kg-canvas"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerLeave}
onDoubleClick={handleDoubleClick}
/>
{props.toolbar ? (
<div className="kg-canvas-toolbar">{props.toolbar}</div>
) : null}
{props.hint}
{minimapVisible ? (
<canvas ref={minimapRef} className="kg-minimap" width={180} height={120} />
) : null}
{props.overlay}
</div>
);
},
);
@@ -0,0 +1,284 @@
import {
Compass,
Crosshair,
EyeOff,
FileText,
Loader2,
MousePointerClick,
Waypoints,
X,
} from "lucide-react";
import type { KnowledgeNote } from "@/core/knowledge/types";
import type { GraphNode } from "./graphTypes";
import {
getEdgeInfo,
getNodeKind,
getNodeTitle,
NODE_KIND_META,
} from "./graphUtils";
const SOURCE_TYPE_ZH: Record<string, string> = {
thread: "对话沉淀",
search: "搜索沉淀",
tool: "工具结果",
file: "文件导入",
manual: "手动创建",
report: "报告",
};
const STATUS_ZH: Record<string, string> = {
draft: "草稿",
approved: "已审核",
archived: "已归档",
};
export interface NeighborItem {
node: GraphNode;
relation: string;
}
interface GraphDetailPanelProps {
node: GraphNode | null;
degree: number;
neighbors: NeighborItem[];
/** Note detail (only for note-type nodes; undefined while absent/loading). */
note: KnowledgeNote | undefined;
noteLoading: boolean;
/** Explore-mode context; null when in full mode. */
explore: {
/** Depth of this node in the explore graph; null = not yet visible. */
depth: number | null;
expanded: boolean;
isRoot: boolean;
} | null;
onViewSource: (noteId: string) => void;
onLocate: (id: string) => void;
onHide: (id: string) => void;
onPickNeighbor: (id: string) => void;
onExpandNode: (id: string) => void;
onSetExploreRoot: (id: string) => void;
onClose: () => void;
}
function formatTime(value: string | null | undefined): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { hour12: false });
}
export function GraphDetailPanel(props: GraphDetailPanelProps) {
const { node } = props;
return (
<aside className="kg-panel kg-detail">
<div className="kg-panel-header">
<span className="kg-panel-title">节点详情</span>
<button
type="button"
className="kg-panel-close-btn"
title="收起详情面板"
onClick={props.onClose}
>
<X size={15} />
</button>
</div>
{!node ? (
<div className="kg-detail-empty">
<MousePointerClick size={28} />
<div className="kg-detail-empty-title">选择一个节点查看详情</div>
<div>
点击图谱中的文档、主题或实体节点,
<br />
查看摘要、标签、关联关系和相邻节点。
</div>
</div>
) : (
<DetailBody {...props} node={node} />
)}
</aside>
);
}
function DetailBody(props: GraphDetailPanelProps & { node: GraphNode }) {
const { node, note } = props;
const kind = getNodeKind(node);
const kindMeta = NODE_KIND_META[kind];
const isNote = (node.type || "").toLowerCase() === "note";
return (
<div className="kg-panel-content">
<h2 className="kg-detail-title">{getNodeTitle(node)}</h2>
<div>
<span
className="kg-detail-type"
style={{
background: `${kindMeta.color}26`,
color: kindMeta.color,
}}
>
<span className="kg-legend-dot" style={{ background: kindMeta.color }} />
{kindMeta.label}
{node.category && node.category !== kindMeta.label
? ` · ${node.category}`
: ""}
</span>
<span
className="kg-detail-type"
style={{ background: "rgba(148, 163, 184, 0.12)", color: "#cbd5e1" }}
>
度数 {props.degree}
</span>
{props.explore && props.explore.depth !== null ? (
<>
<span
className="kg-detail-type"
style={{ background: "rgba(56, 189, 248, 0.14)", color: "#7dd3fc" }}
>
{props.explore.isRoot
? "探索起点"
: `第 ${props.explore.depth} 层`}
</span>
<span
className="kg-detail-type"
style={
props.explore.expanded
? { background: "rgba(52, 211, 153, 0.14)", color: "#6ee7b7" }
: { background: "rgba(251, 191, 36, 0.14)", color: "#fcd34d" }
}
>
{props.explore.expanded ? "已展开" : "可展开"}
</span>
</>
) : null}
</div>
{props.noteLoading ? (
<div className="kg-detail-summary" style={{ display: "flex", gap: 8 }}>
<Loader2 size={14} className="animate-spin" /> 加载笔记详情...
</div>
) : null}
{note?.summary ? <p className="kg-detail-summary">{note.summary}</p> : null}
{note?.tags?.length ? (
<div className="kg-tag-list">
{note.tags.map((tag) => (
<span key={tag} className="kg-tag">
#{tag}
</span>
))}
</div>
) : null}
{note ? (
<div className="kg-detail-meta">
<span className="kg-detail-meta-label">来源</span>
<span className="kg-detail-meta-value">
{SOURCE_TYPE_ZH[note.source_type] ?? note.source_type}
</span>
<span className="kg-detail-meta-label">状态</span>
<span className="kg-detail-meta-value">
{STATUS_ZH[note.status] ?? note.status}
</span>
<span className="kg-detail-meta-label">创建时间</span>
<span className="kg-detail-meta-value">{formatTime(note.created_at)}</span>
<span className="kg-detail-meta-label">更新时间</span>
<span className="kg-detail-meta-value">{formatTime(note.updated_at)}</span>
{note.created_by_name ? (
<>
<span className="kg-detail-meta-label">创建人</span>
<span className="kg-detail-meta-value">{note.created_by_name}</span>
</>
) : null}
</div>
) : null}
<div className="kg-detail-actions">
{isNote ? (
<button
type="button"
className="kg-icon-button"
onClick={() => props.onViewSource(node.id)}
>
<FileText size={14} /> 查看原文
</button>
) : null}
<button
type="button"
className="kg-icon-button"
onClick={() => props.onLocate(node.id)}
>
<Crosshair size={14} /> 定位节点
</button>
<button
type="button"
className="kg-icon-button"
onClick={() => props.onHide(node.id)}
>
<EyeOff size={14} /> 隐藏节点
</button>
{props.explore ? (
<>
<button
type="button"
className="kg-icon-button"
disabled={props.explore.depth === null || props.explore.expanded}
title={
props.explore.expanded
? "该节点的一跳邻居已全部展开"
: "展开该节点的一跳邻居"
}
onClick={() => props.onExpandNode(node.id)}
>
<Waypoints size={14} /> 展开一跳邻居
</button>
<button
type="button"
className="kg-icon-button"
title="清空当前探索路径,以此节点为新起点"
onClick={() => props.onSetExploreRoot(node.id)}
>
<Compass size={14} /> 以此为新起点
</button>
</>
) : null}
</div>
<h3 className="kg-detail-section-title">
关联节点({props.neighbors.length})
</h3>
{props.neighbors.length === 0 ? (
<div className="kg-search-hint">该节点暂无可见的关联节点</div>
) : (
<div className="kg-neighbor-list">
{props.neighbors.map(({ node: neighbor, relation }) => {
const neighborKind = NODE_KIND_META[getNodeKind(neighbor)];
const relationInfo = getEdgeInfo(relation);
return (
<div
key={`${neighbor.id}:${relation}`}
className="kg-neighbor-item"
onClick={() => props.onPickNeighbor(neighbor.id)}
>
<span
className="kg-legend-dot"
style={{ background: neighborKind.color, flexShrink: 0 }}
/>
<span className="kg-neighbor-title">{getNodeTitle(neighbor)}</span>
<span
className="kg-neighbor-relation"
style={{ color: relationInfo.color }}
>
{relationInfo.zh}
</span>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,57 @@
import type { NodeKind } from "./graphTypes";
import { getEdgeInfo, NODE_KIND_META, NODE_KIND_ORDER } from "./graphUtils";
interface GraphFooterProps {
nodeKindCounts: Map<NodeKind, number>;
edgeLabels: string[];
zoomLevel: number;
selectedTitle: string | null;
visibleNodes: number;
visibleEdges: number;
}
export function GraphFooter(props: GraphFooterProps) {
const kinds = NODE_KIND_ORDER.filter((k) => (props.nodeKindCounts.get(k) ?? 0) > 0);
return (
<footer className="kg-footer">
<div className="kg-legend">
{kinds.map((kind) => (
<span key={kind} className="kg-legend-item">
<span
className="kg-legend-dot"
style={{ background: NODE_KIND_META[kind].color }}
/>
{NODE_KIND_META[kind].label}
</span>
))}
{props.edgeLabels.slice(0, 6).map((label) => {
const info = getEdgeInfo(label);
return (
<span key={label} className="kg-legend-item">
<span
className="kg-legend-line"
style={{
borderTopColor: info.color,
borderTopStyle: info.dashed ? "dashed" : "solid",
}}
/>
{info.zh}
</span>
);
})}
</div>
<div className="kg-footer-status">
{props.selectedTitle ? (
<span className="kg-footer-selected" title={props.selectedTitle}>
选中:{props.selectedTitle}
</span>
) : null}
<span>
显示 {props.visibleNodes} 节点 · {props.visibleEdges} 关系
</span>
<span>缩放 {Math.round(props.zoomLevel * 100)}%</span>
</div>
</footer>
);
}
@@ -0,0 +1,124 @@
import {
ArrowLeft,
Download,
Loader2,
Maximize2,
RefreshCw,
Scan,
Shuffle,
ZoomIn,
ZoomOut,
} from "lucide-react";
interface GraphHeaderProps {
nodeCount: number;
edgeCount: number;
totalNodes: number;
totalEdges: number;
refreshing: boolean;
onBack: () => void;
onRelayout: () => void;
onFitView: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
onExport: () => void;
onFullscreen: () => void;
onRefresh: () => void;
}
export function GraphHeader(props: GraphHeaderProps) {
const filteredNodes =
props.nodeCount === props.totalNodes
? `${props.totalNodes}`
: `${props.nodeCount}/${props.totalNodes}`;
const filteredEdges =
props.edgeCount === props.totalEdges
? `${props.totalEdges}`
: `${props.edgeCount}/${props.totalEdges}`;
return (
<header className="kg-header">
<div className="kg-header-left">
<button type="button" className="kg-back" onClick={props.onBack}>
<ArrowLeft size={15} />
<span className="kg-button-text">返回</span>
</button>
<div className="kg-title-block">
<h1 className="kg-title">知识图谱</h1>
<span className="kg-subtitle">知识关系可视化</span>
</div>
<div className="kg-stat-pills">
<span className="kg-stat-pill">{filteredNodes} 个节点</span>
<span className="kg-stat-pill kg-stat-pill-violet">
{filteredEdges} 条关系
</span>
</div>
</div>
<div className="kg-header-actions">
<button
type="button"
className="kg-icon-button"
title="重新布局"
onClick={props.onRelayout}
>
<Shuffle size={15} />
<span className="kg-button-text">重新布局</span>
</button>
<button
type="button"
className="kg-icon-button"
title="适应视图"
onClick={props.onFitView}
>
<Scan size={15} />
<span className="kg-button-text">适应视图</span>
</button>
<button
type="button"
className="kg-icon-button"
title="放大"
onClick={props.onZoomIn}
>
<ZoomIn size={15} />
</button>
<button
type="button"
className="kg-icon-button"
title="缩小"
onClick={props.onZoomOut}
>
<ZoomOut size={15} />
</button>
<button
type="button"
className="kg-icon-button"
title="导出图片"
onClick={props.onExport}
>
<Download size={15} />
</button>
<button
type="button"
className="kg-icon-button"
title="全屏"
onClick={props.onFullscreen}
>
<Maximize2 size={15} />
</button>
<button
type="button"
className="kg-icon-button"
title="刷新图谱数据"
onClick={props.onRefresh}
disabled={props.refreshing}
>
{props.refreshing ? (
<Loader2 size={15} className="animate-spin" />
) : (
<RefreshCw size={15} />
)}
</button>
</div>
</header>
);
}
@@ -0,0 +1,236 @@
import { ChevronDown, ChevronRight, Search, X } from "lucide-react";
import { useState } from "react";
import type { GraphNode, GraphStats, NodeKind } from "./graphTypes";
import {
getEdgeInfo,
getNodeTitle,
NODE_KIND_META,
NODE_KIND_ORDER,
} from "./graphUtils";
interface GraphSidebarProps {
searchKeyword: string;
searchMatchCount: number | null;
nodeKindCounts: Map<NodeKind, number>;
edgeLabelCounts: Map<string, number>;
disabledNodeKinds: Set<NodeKind>;
disabledEdgeLabels: Set<string>;
stats: GraphStats;
topNodes: Array<{ node: GraphNode; degree: number }>;
hiddenCount: number;
onSearchChange: (keyword: string) => void;
onSearchEnter: () => void;
onToggleNodeKind: (kind: NodeKind) => void;
onToggleEdgeLabel: (label: string) => void;
onRestoreHidden: () => void;
onPickNode: (id: string) => void;
onClose: () => void;
}
type SectionKey = "nodeTypes" | "edgeTypes" | "graphStats" | "coreNodes";
interface FilterSectionProps {
title: string;
extra?: string;
collapsed: boolean;
onToggle: () => void;
children: React.ReactNode;
}
function FilterSection(props: FilterSectionProps) {
return (
<section className="kg-filter-section">
<button
type="button"
className="kg-filter-section-header"
onClick={props.onToggle}
aria-expanded={!props.collapsed}
>
<span>{props.title}</span>
<span className="kg-filter-section-extra">
{props.extra}
{props.collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</span>
</button>
{!props.collapsed ? (
<div className="kg-filter-section-body">{props.children}</div>
) : null}
</section>
);
}
export function GraphSidebar(props: GraphSidebarProps) {
// 节点类型 / 关系类型默认收起,统计与核心节点默认展开。
const [collapsedSections, setCollapsedSections] = useState<
Record<SectionKey, boolean>
>({
nodeTypes: true,
edgeTypes: true,
graphStats: false,
coreNodes: false,
});
const toggleFilterSection = (key: SectionKey) => {
setCollapsedSections((prev) => ({ ...prev, [key]: !prev[key] }));
};
const kinds = NODE_KIND_ORDER.filter((k) => (props.nodeKindCounts.get(k) ?? 0) > 0);
const edgeLabels = [...props.edgeLabelCounts.entries()].sort((a, b) => b[1] - a[1]);
return (
<aside className="kg-panel kg-sidebar">
<div className="kg-panel-header">
<span className="kg-panel-title">搜索与筛选</span>
<div className="kg-panel-header-actions">
{props.hiddenCount > 0 ? (
<button
type="button"
className="kg-text-button"
onClick={props.onRestoreHidden}
>
恢复 {props.hiddenCount} 个隐藏节点
</button>
) : null}
<button
type="button"
className="kg-panel-close-btn"
title="收起筛选面板"
onClick={props.onClose}
>
<X size={15} />
</button>
</div>
</div>
<div className="kg-panel-content">
<div className="kg-search-wrap">
<Search size={14} />
<input
className="kg-search"
placeholder="搜索节点标题..."
value={props.searchKeyword}
onChange={(e) => props.onSearchChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") props.onSearchEnter();
}}
/>
</div>
{props.searchMatchCount !== null ? (
<div
className={`kg-search-hint${props.searchMatchCount === 0 ? " kg-search-hint-warn" : ""}`}
>
{props.searchMatchCount === 0
? "没有匹配的节点,换个关键词试试"
: `匹配 ${props.searchMatchCount} 个节点,回车定位`}
</div>
) : null}
<FilterSection
title="节点类型"
extra={`${kinds.length} 类`}
collapsed={collapsedSections.nodeTypes}
onToggle={() => toggleFilterSection("nodeTypes")}
>
{kinds.map((kind) => (
<label key={kind} className="kg-check-row">
<span className="kg-check-row-main">
<input
type="checkbox"
checked={!props.disabledNodeKinds.has(kind)}
onChange={() => props.onToggleNodeKind(kind)}
/>
<span
className="kg-legend-dot"
style={{ background: NODE_KIND_META[kind].color }}
/>
<span className="kg-check-label">{NODE_KIND_META[kind].label}</span>
</span>
<span className="kg-count">{props.nodeKindCounts.get(kind)}</span>
</label>
))}
</FilterSection>
{edgeLabels.length > 0 ? (
<FilterSection
title="关系类型"
extra={`${edgeLabels.length} 类`}
collapsed={collapsedSections.edgeTypes}
onToggle={() => toggleFilterSection("edgeTypes")}
>
{edgeLabels.map(([label, count]) => {
const info = getEdgeInfo(label);
return (
<label key={label} className="kg-check-row">
<span className="kg-check-row-main">
<input
type="checkbox"
checked={!props.disabledEdgeLabels.has(label)}
onChange={() => props.onToggleEdgeLabel(label)}
/>
<span
className="kg-legend-line"
style={{
borderTopColor: info.color,
borderTopStyle: info.dashed ? "dashed" : "solid",
}}
/>
<span className="kg-check-label">{info.zh}</span>
</span>
<span className="kg-count">{count}</span>
</label>
);
})}
</FilterSection>
) : null}
<FilterSection
title="图谱统计"
collapsed={collapsedSections.graphStats}
onToggle={() => toggleFilterSection("graphStats")}
>
<div className="kg-stats-grid">
<div className="kg-stat-card">
<div className="kg-stat-card-value">{props.stats.nodes}</div>
<div className="kg-stat-card-label">节点</div>
</div>
<div className="kg-stat-card">
<div className="kg-stat-card-value">{props.stats.edges}</div>
<div className="kg-stat-card-label">关系</div>
</div>
<div className="kg-stat-card">
<div className="kg-stat-card-value">
{props.stats.avgDegree.toFixed(2)}
</div>
<div className="kg-stat-card-label">平均度</div>
</div>
<div className="kg-stat-card">
<div className="kg-stat-card-value">{props.stats.largestComponent}</div>
<div className="kg-stat-card-label">最大连通分量</div>
</div>
</div>
</FilterSection>
{props.topNodes.length > 0 ? (
<FilterSection
title="核心节点"
extra={`${props.topNodes.length} 个`}
collapsed={collapsedSections.coreNodes}
onToggle={() => toggleFilterSection("coreNodes")}
>
{props.topNodes.map(({ node, degree }, i) => (
<div
key={node.id}
className="kg-rank-item"
onClick={() => props.onPickNode(node.id)}
>
<span className="kg-rank-index">{i + 1}</span>
<span className="kg-rank-title">{getNodeTitle(node)}</span>
<span className="kg-rank-degree">{degree} 度</span>
</div>
))}
</FilterSection>
) : null}
</div>
</aside>
);
}
@@ -0,0 +1,29 @@
import type { TooltipState } from "./graphTypes";
import { getNodeTitle, NODE_KIND_META } from "./graphUtils";
interface GraphTooltipProps {
tooltip: TooltipState | null;
}
export function GraphTooltip({ tooltip }: GraphTooltipProps) {
if (!tooltip) return null;
const meta = NODE_KIND_META[tooltip.kind];
// Keep the tooltip inside the viewport.
const left = Math.min(tooltip.x + 14, window.innerWidth - 336);
const top = Math.min(tooltip.y + 14, window.innerHeight - 120);
return (
<div className="kg-tooltip" style={{ left, top }}>
<div className="kg-tooltip-title">{getNodeTitle(tooltip.node)}</div>
<div className="kg-tooltip-desc">
<span className="kg-legend-item">
<span className="kg-legend-dot" style={{ background: meta.color }} />
{meta.label}
</span>
{tooltip.node.category ? <span>· {tooltip.node.category}</span> : null}
<span>· 度数 {tooltip.degree}</span>
</div>
<div className="kg-tooltip-hint">单击查看详情 · 双击聚焦 · 拖拽固定位置</div>
</div>
);
}
@@ -0,0 +1,919 @@
/* Knowledge-graph workbench — dark, professional analysis-tool styling. */
.kg-page {
--kg-bg: #0b1020;
--kg-panel: rgba(15, 23, 42, 0.78);
--kg-panel-solid: #0f172a;
--kg-border: rgba(148, 163, 184, 0.18);
--kg-text-main: #e5e7eb;
--kg-text-secondary: #94a3b8;
--kg-text-muted: #64748b;
--kg-primary: #38bdf8;
--kg-primary-soft: rgba(56, 189, 248, 0.16);
height: 100%;
min-height: 0;
background:
radial-gradient(circle at 20% 10%, rgba(56, 189, 248, 0.1), transparent 32%),
radial-gradient(circle at 80% 20%, rgba(167, 139, 250, 0.1), transparent 28%),
#0b1020;
color: var(--kg-text-main);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ------------------------------------------------------------------ header */
.kg-header {
height: 56px;
border-bottom: 1px solid var(--kg-border);
background: rgba(15, 23, 42, 0.86);
backdrop-filter: blur(14px);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 18px;
flex-shrink: 0;
}
.kg-header-left {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.kg-back {
height: 32px;
padding: 0 10px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--kg-text-main);
font-size: 13px;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
flex-shrink: 0;
}
.kg-back:hover {
background: rgba(148, 163, 184, 0.12);
}
.kg-title-block {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.kg-title {
font-size: 18px;
font-weight: 800;
color: #ffffff;
white-space: nowrap;
}
.kg-subtitle {
font-size: 12px;
color: var(--kg-text-secondary);
white-space: nowrap;
}
.kg-stat-pills {
display: flex;
align-items: center;
gap: 8px;
}
.kg-stat-pill {
height: 28px;
padding: 0 10px;
border-radius: 999px;
background: rgba(56, 189, 248, 0.12);
border: 1px solid rgba(56, 189, 248, 0.28);
color: #bae6fd;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.kg-stat-pill.kg-stat-pill-violet {
background: rgba(167, 139, 250, 0.12);
border-color: rgba(167, 139, 250, 0.28);
color: #ddd6fe;
}
.kg-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.kg-icon-button {
height: 34px;
min-width: 34px;
padding: 0 8px;
border-radius: 9px;
border: 1px solid var(--kg-border);
background: rgba(15, 23, 42, 0.65);
color: #cbd5e1;
font-size: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease,
border-color 0.15s ease;
}
.kg-icon-button:hover {
color: #ffffff;
background: rgba(56, 189, 248, 0.14);
border-color: rgba(56, 189, 248, 0.35);
}
.kg-icon-button:disabled {
opacity: 0.5;
cursor: default;
}
.kg-icon-button.kg-icon-button-active {
color: #ffffff;
background: rgba(56, 189, 248, 0.18);
border-color: rgba(56, 189, 248, 0.45);
}
.kg-header-divider {
width: 1px;
height: 20px;
background: var(--kg-border);
margin: 0 2px;
}
/* -------------------------------------------------------------------- body */
.kg-body {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 280px minmax(0, 1fr) 320px;
gap: 12px;
padding: 12px;
}
.kg-body.kg-hide-left {
grid-template-columns: minmax(0, 1fr) 320px;
}
.kg-body.kg-hide-right {
grid-template-columns: 280px minmax(0, 1fr);
}
.kg-body.kg-hide-left.kg-hide-right {
grid-template-columns: minmax(0, 1fr);
}
.kg-panel {
background: var(--kg-panel);
border: 1px solid var(--kg-border);
border-radius: 16px;
backdrop-filter: blur(14px);
overflow: hidden;
min-height: 0;
display: flex;
flex-direction: column;
}
.kg-panel-header {
height: 46px;
padding: 0 16px;
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.kg-panel-header-actions {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.kg-panel-title {
font-size: 14px;
font-weight: 700;
color: var(--kg-text-main);
}
.kg-panel-close-btn {
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(15, 23, 42, 0.65);
color: #94a3b8;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
}
.kg-panel-close-btn:hover {
color: #ffffff;
background: rgba(56, 189, 248, 0.14);
border-color: rgba(56, 189, 248, 0.35);
}
.kg-panel-content {
padding: 14px;
overflow-y: auto;
min-height: 0;
flex: 1;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.3) transparent;
}
/* ----------------------------------------------------------------- sidebar */
.kg-search-wrap {
position: relative;
}
.kg-search-wrap > svg {
position: absolute;
left: 11px;
top: 50%;
transform: translateY(-50%);
color: var(--kg-text-muted);
pointer-events: none;
}
.kg-search {
height: 36px;
width: 100%;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(2, 6, 23, 0.45);
color: var(--kg-text-main);
padding: 0 12px 0 32px;
font-size: 13px;
outline: none;
}
.kg-search::placeholder {
color: var(--kg-text-muted);
}
.kg-search:focus {
border-color: rgba(56, 189, 248, 0.55);
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.1);
}
.kg-search-hint {
margin-top: 6px;
font-size: 12px;
color: var(--kg-text-muted);
}
.kg-search-hint.kg-search-hint-warn {
color: #fbbf24;
}
.kg-filter-section {
border-top: 1px solid rgba(148, 163, 184, 0.1);
padding: 10px 0;
}
.kg-filter-section:first-of-type {
margin-top: 12px;
}
.kg-filter-section-header {
width: 100%;
height: 34px;
border: none;
background: transparent;
color: #cbd5e1;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 0;
font-size: 13px;
font-weight: 700;
}
.kg-filter-section-header:hover {
color: #ffffff;
}
.kg-filter-section-extra {
color: #64748b;
font-size: 12px;
font-weight: 400;
display: inline-flex;
align-items: center;
gap: 4px;
}
.kg-filter-section-body {
padding-top: 8px;
}
.kg-check-row {
min-height: 30px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #cbd5e1;
font-size: 13px;
cursor: pointer;
border-radius: 8px;
padding: 0 6px;
user-select: none;
}
.kg-check-row:hover {
background: rgba(148, 163, 184, 0.08);
}
.kg-check-row-main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.kg-check-row input[type="checkbox"] {
accent-color: #38bdf8;
width: 14px;
height: 14px;
flex-shrink: 0;
}
.kg-check-row .kg-legend-dot {
flex-shrink: 0;
}
.kg-check-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kg-count {
color: var(--kg-text-muted);
font-size: 12px;
flex-shrink: 0;
}
.kg-stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.kg-stat-card {
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.12);
background: rgba(2, 6, 23, 0.35);
padding: 8px 10px;
}
.kg-stat-card-value {
font-size: 16px;
font-weight: 800;
color: #ffffff;
}
.kg-stat-card-label {
font-size: 11px;
color: var(--kg-text-muted);
margin-top: 2px;
}
.kg-rank-item {
display: flex;
gap: 8px;
padding: 8px 6px;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
cursor: pointer;
border-radius: 8px;
}
.kg-rank-item:hover {
background: rgba(56, 189, 248, 0.08);
}
.kg-rank-index {
width: 20px;
color: var(--kg-primary);
font-weight: 700;
font-size: 12px;
flex-shrink: 0;
}
.kg-rank-title {
flex: 1;
color: #cbd5e1;
font-size: 12px;
line-height: 1.5;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.kg-rank-degree {
color: var(--kg-text-muted);
font-size: 11px;
flex-shrink: 0;
}
.kg-text-button {
border: none;
background: transparent;
color: var(--kg-primary);
font-size: 12px;
cursor: pointer;
padding: 0;
}
.kg-text-button:hover {
text-decoration: underline;
}
/* ------------------------------------------------------------------ canvas */
.kg-canvas-wrap {
position: relative;
background:
linear-gradient(rgba(148, 163, 184, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.04) 1px, transparent 1px),
radial-gradient(circle at 50% 40%, rgba(56, 189, 248, 0.08), transparent 42%),
rgba(2, 6, 23, 0.5);
background-size:
28px 28px,
28px 28px,
auto,
auto;
border: 1px solid var(--kg-border);
border-radius: 18px;
overflow: hidden;
min-width: 0;
min-height: 0;
width: 100%;
height: 100%;
}
.kg-canvas-shell {
position: relative;
min-width: 0;
min-height: 0;
}
.kg-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
touch-action: none;
}
.kg-canvas-toolbar {
position: absolute;
top: 14px;
left: 14px;
z-index: 10;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.kg-floating-pill {
height: 32px;
padding: 0 6px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.82);
border: 1px solid var(--kg-border);
color: #cbd5e1;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 2px;
backdrop-filter: blur(12px);
}
.kg-pill-label {
padding: 0 6px;
color: var(--kg-text-muted);
}
.kg-pill-button {
height: 24px;
padding: 0 10px;
border-radius: 999px;
border: none;
background: transparent;
color: #cbd5e1;
font-size: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
}
.kg-pill-button:hover {
color: #ffffff;
}
.kg-pill-button.kg-pill-button-active {
background: rgba(56, 189, 248, 0.18);
color: #ffffff;
}
.kg-explore-button {
height: 32px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(15, 23, 42, 0.72);
color: #cbd5e1;
font-size: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
backdrop-filter: blur(12px);
}
.kg-explore-button:hover {
background: rgba(56, 189, 248, 0.14);
color: #ffffff;
}
.kg-explore-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.kg-canvas-hint {
position: absolute;
left: 50%;
bottom: 22px;
z-index: 11;
transform: translateX(-50%);
max-width: min(560px, calc(100% - 48px));
padding: 8px 14px;
border-radius: 999px;
border: 1px solid rgba(56, 189, 248, 0.22);
background: rgba(15, 23, 42, 0.78);
color: #bae6fd;
font-size: 12px;
text-align: center;
backdrop-filter: blur(12px);
pointer-events: none;
}
.kg-side-rail-toggle {
position: absolute;
top: 50%;
z-index: 20;
width: 32px;
min-height: 88px;
border-radius: 12px;
border: 1px solid rgba(56, 189, 248, 0.28);
background: rgba(15, 23, 42, 0.86);
color: #bae6fd;
display: flex;
align-items: center;
justify-content: center;
writing-mode: vertical-rl;
letter-spacing: 4px;
cursor: pointer;
backdrop-filter: blur(12px);
transform: translateY(-50%);
}
.kg-side-rail-toggle-left {
left: 12px;
}
.kg-side-rail-toggle-right {
right: 12px;
}
.kg-side-rail-toggle:hover {
background: rgba(56, 189, 248, 0.16);
color: #ffffff;
}
.kg-minimap {
position: absolute;
right: 14px;
bottom: 14px;
width: 180px;
height: 120px;
border-radius: 12px;
background: rgba(15, 23, 42, 0.78);
border: 1px solid var(--kg-border);
overflow: hidden;
z-index: 10;
pointer-events: none;
}
.kg-canvas-status {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
text-align: center;
color: var(--kg-text-secondary);
padding: 32px;
z-index: 5;
}
.kg-canvas-status-title {
font-size: 16px;
font-weight: 700;
color: var(--kg-text-main);
}
.kg-canvas-status-desc {
font-size: 13px;
line-height: 1.8;
max-width: 360px;
color: var(--kg-text-muted);
}
/* ----------------------------------------------------------------- tooltip */
.kg-tooltip {
position: fixed;
z-index: 999;
max-width: 320px;
padding: 12px;
border-radius: 12px;
background: rgba(15, 23, 42, 0.94);
border: 1px solid rgba(56, 189, 248, 0.25);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
color: var(--kg-text-main);
pointer-events: none;
backdrop-filter: blur(14px);
}
.kg-tooltip-title {
font-size: 13px;
font-weight: 700;
margin-bottom: 6px;
line-height: 1.5;
}
.kg-tooltip-desc {
font-size: 12px;
color: var(--kg-text-secondary);
line-height: 1.6;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.kg-tooltip-hint {
margin-top: 8px;
font-size: 11px;
color: var(--kg-text-muted);
}
/* ------------------------------------------------------------------ detail */
.kg-detail-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: var(--kg-text-muted);
padding: 32px;
gap: 10px;
font-size: 13px;
line-height: 1.8;
}
.kg-detail-empty-title {
color: var(--kg-text-secondary);
font-size: 14px;
font-weight: 600;
}
.kg-detail-title {
font-size: 17px;
line-height: 1.5;
font-weight: 800;
color: #ffffff;
margin-bottom: 10px;
word-break: break-word;
}
.kg-detail-type {
display: inline-flex;
height: 24px;
padding: 0 9px;
border-radius: 999px;
font-size: 12px;
align-items: center;
gap: 6px;
margin-right: 6px;
margin-bottom: 12px;
}
.kg-detail-summary {
color: #cbd5e1;
font-size: 13px;
line-height: 1.8;
margin-bottom: 16px;
word-break: break-word;
}
.kg-detail-meta {
display: grid;
grid-template-columns: 72px 1fr;
row-gap: 8px;
column-gap: 8px;
font-size: 12px;
margin-bottom: 16px;
}
.kg-detail-meta-label {
color: var(--kg-text-muted);
}
.kg-detail-meta-value {
color: #cbd5e1;
word-break: break-word;
}
.kg-tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.kg-tag {
height: 24px;
padding: 0 8px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.12);
color: #cbd5e1;
font-size: 12px;
display: inline-flex;
align-items: center;
}
.kg-detail-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 18px;
}
.kg-detail-section-title {
font-size: 12px;
font-weight: 700;
color: var(--kg-text-secondary);
margin: 0 0 8px;
}
.kg-neighbor-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.kg-neighbor-item {
padding: 8px 10px;
border-radius: 10px;
background: rgba(2, 6, 23, 0.35);
border: 1px solid rgba(148, 163, 184, 0.1);
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.kg-neighbor-item:hover {
background: rgba(56, 189, 248, 0.1);
border-color: rgba(56, 189, 248, 0.24);
}
.kg-neighbor-title {
flex: 1;
min-width: 0;
font-size: 12px;
color: #cbd5e1;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kg-neighbor-relation {
font-size: 11px;
flex-shrink: 0;
}
/* ------------------------------------------------------------------ footer */
.kg-footer {
min-height: 42px;
margin: 0 12px 12px;
border-radius: 14px;
background: var(--kg-panel);
border: 1px solid var(--kg-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 4px 14px;
color: var(--kg-text-secondary);
font-size: 12px;
flex-shrink: 0;
flex-wrap: wrap;
}
.kg-legend {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.kg-legend-item {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.kg-legend-dot {
width: 9px;
height: 9px;
border-radius: 999px;
}
.kg-legend-line {
width: 20px;
height: 0;
border-top-width: 2px;
border-top-style: solid;
border-radius: 999px;
}
.kg-footer-status {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
white-space: nowrap;
}
.kg-footer-selected {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
color: #bae6fd;
}
/* -------------------------------------------------------------- responsive */
@media (max-width: 1100px) {
.kg-header .kg-button-text {
display: none;
}
.kg-subtitle {
display: none;
}
}
@@ -0,0 +1,111 @@
/** Shared types for the knowledge-graph workbench. */
/** Raw node as returned by `GET /api/knowledge/graph` (plus compat fields). */
export interface GraphNode {
id: string;
/** "note" | "entity" on the current backend; kept loose for compat. */
type?: string;
label?: string;
title?: string;
name?: string;
/** note: source_type (thread/file/manual/...); entity: entity_type. */
category?: string | null;
}
/** Raw edge as returned by `GET /api/knowledge/graph` (plus compat fields). */
export interface GraphEdge {
id?: string;
source: string;
target: string;
/** Relation type: "links" | "related" | LLM relation_type. */
label?: string;
type?: string;
relation?: string;
weight?: number;
directed?: boolean;
}
export interface GraphData {
nodes: GraphNode[];
edges: GraphEdge[];
}
/** Normalized visual bucket for a node. */
export type NodeKind =
| "document"
| "concept"
| "person"
| "org"
| "location"
| "event"
| "tag"
| "other";
export type LayoutType = "force" | "circular";
/** Graph display mode: full graph vs. click-to-expand exploration. */
export type GraphMode = "full" | "explore";
/** One incremental expansion step in explore mode (for undo / rebuild). */
export interface ExploreStep {
nodeId: string;
depth: number;
addedNodeIds: string[];
addedEdgeIds: string[];
timestamp: number;
}
/** Style metadata of an edge label. */
export interface EdgeInfo {
color: string;
zh: string;
dashed: boolean;
}
/** A node inside the force simulation (world coordinates). */
export interface SimNode {
id: string;
node: GraphNode;
kind: NodeKind;
degree: number;
radius: number;
color: string;
x: number;
y: number;
vx: number;
vy: number;
fx: number | null;
fy: number | null;
}
export interface SimEdge {
id: string;
source: SimNode;
target: SimNode;
label: string;
info: EdgeInfo;
weight: number;
distance: number;
}
export interface Transform {
x: number;
y: number;
k: number;
}
export interface TooltipState {
/** Viewport (client) coordinates. */
x: number;
y: number;
node: GraphNode;
kind: NodeKind;
degree: number;
}
export interface GraphStats {
nodes: number;
edges: number;
avgDegree: number;
largestComponent: number;
}
@@ -0,0 +1,352 @@
import type {
EdgeInfo,
GraphEdge,
GraphNode,
GraphStats,
NodeKind,
SimEdge,
SimNode,
} from "./graphTypes";
// ---------------------------------------------------------------------------
// Node helpers
// ---------------------------------------------------------------------------
export const getNodeTitle = (node: GraphNode): string =>
node.title || node.label || node.name || node.id;
const KIND_PATTERNS: Array<[NodeKind, RegExp]> = [
["person", /^(person|people|human|人物|人名|人员)$/i],
["org", /^(org|organization|organisation|company|institution|party|组织|机构|公司|团体|政党|国会)$/i],
["location", /^(location|place|country|city|region|gpe|地点|地区|国家|城市|地理位置)$/i],
["event", /^(event|事件|活动|会议|战争|选举|讲话)$/i],
["tag", /^(tag|keyword|标签|关键词)$/i],
["concept", /^(concept|topic|term|technology|theory|product|project|主题|概念|术语|技术|理论|产品|项目|政策)$/i],
];
/** Map a raw node (note source_type / entity entity_type) to a visual bucket. */
export function getNodeKind(node: GraphNode): NodeKind {
const type = (node.type || "").toLowerCase();
if (type === "note" || type === "document" || type === "file") return "document";
const category = (node.category || "").trim();
if (category) {
for (const [kind, re] of KIND_PATTERNS) {
if (re.test(category)) return kind;
}
}
// Unmatched LLM entity types still read better as "concept" than as a grey blob.
if (type === "entity") return category ? "other" : "concept";
return "other";
}
export const NODE_KIND_META: Record<NodeKind, { label: string; color: string }> = {
document: { label: "文档", color: "#38bdf8" },
concept: { label: "主题", color: "#a78bfa" },
person: { label: "人物", color: "#fbbf24" },
org: { label: "组织", color: "#2dd4bf" },
location: { label: "地点", color: "#34d399" },
event: { label: "事件", color: "#fb7185" },
tag: { label: "标签", color: "#a3e635" },
other: { label: "其他", color: "#94a3b8" },
};
/** Stable ordering for filters / legend. */
export const NODE_KIND_ORDER: NodeKind[] = [
"document",
"concept",
"person",
"org",
"location",
"event",
"tag",
"other",
];
export const getNodeRadius = (degree: number): number =>
Math.min(30, 9 + Math.sqrt(Math.max(degree, 0)) * 4);
export function truncateLabel(text: string, max = 14): string {
return text.length > max ? `${text.slice(0, max)}…` : text;
}
// ---------------------------------------------------------------------------
// Edge helpers
// ---------------------------------------------------------------------------
export const getEdgeLabel = (edge: GraphEdge): string =>
edge.label || edge.type || edge.relation || "related";
/** Stable edge identity shared by explore-mode visibility sets and the canvas. */
export const getEdgeId = (edge: GraphEdge): string =>
edge.id ?? `${edge.source}->${edge.target}:${getEdgeLabel(edge)}`;
const KNOWN_EDGE_INFO: Record<string, EdgeInfo> = {
related: { color: "#94a3b8", zh: "相关", dashed: false },
links: { color: "#a78bfa", zh: "引用", dashed: true },
references: { color: "#a78bfa", zh: "引用", dashed: true },
contains: { color: "#34d399", zh: "包含", dashed: false },
belongs_to: { color: "#2dd4bf", zh: "属于", dashed: false },
similar: { color: "#fbbf24", zh: "相似", dashed: true },
mentions: { color: "#38bdf8", zh: "提及", dashed: false },
};
const EDGE_PALETTE = [
"#38bdf8",
"#a78bfa",
"#34d399",
"#fbbf24",
"#fb7185",
"#2dd4bf",
"#fb923c",
"#e879f9",
];
function hashString(text: string): number {
let h = 0;
for (let i = 0; i < text.length; i++) {
h = (h * 31 + text.charCodeAt(i)) | 0;
}
return Math.abs(h);
}
/** Color / display name / line style of an edge label (stable for unknown labels). */
export function getEdgeInfo(label: string): EdgeInfo {
const known = KNOWN_EDGE_INFO[label.toLowerCase()];
if (known) return known;
return {
color: EDGE_PALETTE[hashString(label) % EDGE_PALETTE.length],
zh: label,
dashed: false,
};
}
export function getLinkDistance(label: string): number {
switch (label.toLowerCase()) {
case "contains":
return 100;
case "links":
case "references":
return 130;
case "similar":
return 170;
default:
return 145;
}
}
// ---------------------------------------------------------------------------
// Graph statistics
// ---------------------------------------------------------------------------
export function computeDegrees(
nodes: GraphNode[],
edges: GraphEdge[],
): Map<string, number> {
const degrees = new Map<string, number>();
for (const n of nodes) degrees.set(n.id, 0);
for (const e of edges) {
if (degrees.has(e.source)) degrees.set(e.source, (degrees.get(e.source) ?? 0) + 1);
if (degrees.has(e.target)) degrees.set(e.target, (degrees.get(e.target) ?? 0) + 1);
}
return degrees;
}
export function computeGraphStats(
nodes: GraphNode[],
edges: GraphEdge[],
): GraphStats {
const adjacency = new Map<string, string[]>();
for (const n of nodes) adjacency.set(n.id, []);
let validEdges = 0;
for (const e of edges) {
if (!adjacency.has(e.source) || !adjacency.has(e.target)) continue;
validEdges += 1;
adjacency.get(e.source)!.push(e.target);
adjacency.get(e.target)!.push(e.source);
}
// Largest connected component via BFS.
const visited = new Set<string>();
let largest = 0;
for (const n of nodes) {
if (visited.has(n.id)) continue;
let size = 0;
const queue = [n.id];
visited.add(n.id);
while (queue.length) {
const cur = queue.pop()!;
size += 1;
for (const next of adjacency.get(cur) ?? []) {
if (!visited.has(next)) {
visited.add(next);
queue.push(next);
}
}
}
largest = Math.max(largest, size);
}
return {
nodes: nodes.length,
edges: validEdges,
avgDegree: nodes.length ? (validEdges * 2) / nodes.length : 0,
largestComponent: largest,
};
}
// ---------------------------------------------------------------------------
// Layouts
// ---------------------------------------------------------------------------
const GOLDEN_ANGLE = 2.3999632297286533;
/** Phyllotaxis seeding around the origin — hubs (high degree) near the center. */
export function seedPositions(nodes: SimNode[]): void {
const sorted = [...nodes].sort((a, b) => b.degree - a.degree);
sorted.forEach((n, i) => {
const r = 22 * Math.sqrt(i + 0.5);
const angle = i * GOLDEN_ANGLE;
n.x = r * Math.cos(angle);
n.y = r * Math.sin(angle);
n.vx = 0;
n.vy = 0;
n.fx = null;
n.fy = null;
});
}
/** Static circular layout, grouped by kind then degree. */
export function applyCircularLayout(nodes: SimNode[]): void {
const sorted = [...nodes].sort((a, b) => {
const ka = NODE_KIND_ORDER.indexOf(a.kind);
const kb = NODE_KIND_ORDER.indexOf(b.kind);
return ka !== kb ? ka - kb : b.degree - a.degree;
});
const radius = Math.max(180, (sorted.length * 56) / (2 * Math.PI));
sorted.forEach((n, i) => {
const angle = (i / sorted.length) * Math.PI * 2 - Math.PI / 2;
n.x = radius * Math.cos(angle);
n.y = radius * Math.sin(angle);
n.vx = 0;
n.vy = 0;
n.fx = null;
n.fy = null;
});
}
// ---------------------------------------------------------------------------
// Force simulation (d3-force-like, dependency-free, fine for <500 nodes)
// ---------------------------------------------------------------------------
export class ForceSimulation {
alpha = 1;
readonly alphaMin = 0.004;
private readonly alphaDecay = 0.0228;
private readonly velocityDecay = 0.38;
private readonly chargeStrength = -420;
private readonly chargeCutoff2 = 600 * 600;
constructor(
public nodes: SimNode[],
public edges: SimEdge[],
) {}
reheat(alpha = 0.9): void {
this.alpha = Math.max(this.alpha, alpha);
}
/** Advance one step; returns false when the layout has cooled down. */
tick(): boolean {
if (this.alpha < this.alphaMin) return false;
this.alpha += (0 - this.alpha) * this.alphaDecay;
const a = this.alpha;
const { nodes, edges } = this;
// Link spring force.
for (const e of edges) {
const s = e.source;
const t = e.target;
let dx = t.x - s.x;
let dy = t.y - s.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1e-3;
const f = ((dist - e.distance) / dist) * 0.45 * a;
dx *= f;
dy *= f;
t.vx -= dx * 0.5;
t.vy -= dy * 0.5;
s.vx += dx * 0.5;
s.vy += dy * 0.5;
}
// Many-body repulsion with a distance cutoff.
for (let i = 0; i < nodes.length; i++) {
const ni = nodes[i];
for (let j = i + 1; j < nodes.length; j++) {
const nj = nodes[j];
let dx = nj.x - ni.x;
let dy = nj.y - ni.y;
let d2 = dx * dx + dy * dy;
if (d2 > this.chargeCutoff2) continue;
if (d2 < 1) {
// Deterministic nudge for coincident nodes.
dx = ((i % 3) - 1) * 0.5 || 0.5;
dy = ((j % 3) - 1) * 0.5 || -0.5;
d2 = dx * dx + dy * dy;
}
const d = Math.sqrt(d2);
const f = (this.chargeStrength * a) / d2;
const fx = (dx / d) * f;
const fy = (dy / d) * f;
ni.vx += fx;
ni.vy += fy;
nj.vx -= fx;
nj.vy -= fy;
}
}
// Gentle pull toward the origin keeps disconnected clusters on screen.
for (const n of nodes) {
n.vx += (0 - n.x) * 0.05 * a;
n.vy += (0 - n.y) * 0.05 * a;
}
// Positional collision resolution (prevents node overlap).
for (let i = 0; i < nodes.length; i++) {
const ni = nodes[i];
for (let j = i + 1; j < nodes.length; j++) {
const nj = nodes[j];
const minDist = ni.radius + nj.radius + 8;
let dx = nj.x - ni.x;
let dy = nj.y - ni.y;
const d2 = dx * dx + dy * dy;
if (d2 >= minDist * minDist || d2 === 0) continue;
const d = Math.sqrt(d2);
const push = ((minDist - d) / d) * 0.5;
dx *= push;
dy *= push;
if (ni.fx == null) {
ni.x -= dx;
ni.y -= dy;
}
if (nj.fx == null) {
nj.x += dx;
nj.y += dy;
}
}
}
// Integrate velocities.
for (const n of nodes) {
if (n.fx != null && n.fy != null) {
n.x = n.fx;
n.y = n.fy;
n.vx = 0;
n.vy = 0;
} else {
n.vx *= 1 - this.velocityDecay;
n.vy *= 1 - this.velocityDecay;
n.x += n.vx;
n.y += n.vy;
}
}
return true;
}
}
@@ -0,0 +1,39 @@
import { ChevronRight } from "lucide-react";
import { Fragment } from "react";
import { cn } from "@/lib/utils";
/** Inline, de-emphasized breadcrumb: folder path + current document title. */
export function Breadcrumb({
segments,
current,
className,
}: {
segments: string[];
current: string;
className?: string;
}) {
return (
<div
className={cn(
"flex items-center gap-1 overflow-hidden text-xs text-slate-400 dark:text-slate-500",
className,
)}
>
{segments.map((segment, i) => (
<Fragment key={`${segment}-${i}`}>
<span className="hidden shrink-0 whitespace-nowrap sm:inline">
{segment}
</span>
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
</Fragment>
))}
<span
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
title={current}
>
{current}
</span>
</div>
);
}
@@ -0,0 +1,120 @@
import { Download, Pencil, Save, Trash2, X } from "lucide-react";
import { cn } from "@/lib/utils";
function ToolbarButton({
icon,
label,
onClick,
disabled,
danger,
emphasized,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
disabled?: boolean;
danger?: boolean;
emphasized?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"inline-flex h-7 cursor-pointer items-center gap-1 rounded-md px-2 text-xs text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 disabled:cursor-not-allowed disabled:opacity-50 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200",
danger &&
"text-red-500 hover:bg-red-50 hover:text-red-600 dark:text-red-400 dark:hover:bg-red-500/10 dark:hover:text-red-300",
emphasized &&
"border border-slate-200 bg-white font-medium dark:border-slate-700 dark:bg-slate-900",
)}
>
{icon}
{label}
</button>
);
}
/** Compact inline toolbar: subtle meta (status / chars / updated) + actions. */
export function DocumentToolbar({
charCount,
updatedAt,
statusLabel,
editing,
saving,
archiving,
onEdit,
onCancelEdit,
onSave,
onExport,
onArchive,
}: {
charCount: number;
updatedAt: string;
statusLabel: string;
editing: boolean;
saving: boolean;
archiving: boolean;
onEdit: () => void;
onCancelEdit: () => void;
onSave: () => void;
onExport: () => void;
onArchive: () => void;
}) {
return (
<div className="flex shrink-0 items-center gap-1.5">
{editing ? (
<span className="inline-flex h-5 items-center rounded bg-amber-50 px-1.5 text-[11px] font-medium text-amber-600 dark:bg-amber-500/10 dark:text-amber-400">
编辑中
</span>
) : (
<span className="hidden h-5 items-center rounded bg-slate-100 px-1.5 text-[11px] text-slate-500 sm:inline-flex dark:bg-slate-800 dark:text-slate-400">
{statusLabel}
</span>
)}
<span className="hidden whitespace-nowrap text-xs text-slate-400 lg:inline dark:text-slate-500">
{charCount.toLocaleString()} 字符 · {updatedAt}
</span>
<div className="ml-1 flex items-center gap-1">
{editing ? (
<>
<ToolbarButton
icon={<X className="size-3.5" />}
label="取消"
onClick={onCancelEdit}
/>
<ToolbarButton
icon={<Save className="size-3.5" />}
label={saving ? "保存中..." : "保存"}
onClick={onSave}
disabled={saving}
emphasized
/>
</>
) : (
<>
<ToolbarButton
icon={<Pencil className="size-3.5" />}
label="编辑"
onClick={onEdit}
/>
<ToolbarButton
icon={<Download className="size-3.5" />}
label="导出"
onClick={onExport}
/>
<ToolbarButton
icon={<Trash2 className="size-3.5" />}
label="归档"
onClick={onArchive}
disabled={archiving}
danger
/>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,18 @@
/** Shown in the reading column when no document is selected. */
export function EmptyDocumentState() {
return (
<div className="flex h-full items-center justify-center bg-white px-6 text-center dark:bg-slate-900">
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-10 py-12 sm:px-12 dark:border-slate-700 dark:bg-slate-800/50">
<div className="mb-4 text-[40px] leading-none">📚</div>
<div className="mb-2 text-lg font-bold text-slate-700 dark:text-slate-200">
选择一篇文档开始阅读
</div>
<div className="text-sm leading-7 text-slate-400">
你可以从左侧目录树中选择知识文档,
<br />
也可以导入 Markdown、JSON、TXT 或文件夹来构建 LLM Wiki。
</div>
</div>
</div>
);
}
@@ -0,0 +1,90 @@
import { ListTree } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import type { TocItem } from "./wikiTypes";
/**
* Floating in-document outline: a small "目录" pill at the top-right of the
* reading area that opens a popover listing h2/h3 headings. Replaces the old
* always-visible right column. Hidden entirely when the document has too few
* headings to be worth navigating.
*/
export function FloatingToc({
items,
activeId,
onItemClick,
className,
}: {
items: TocItem[];
activeId: string | null;
onItemClick: (id: string) => void;
className?: string;
}) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
// Close when the document (and thus the heading list) changes.
useEffect(() => {
setOpen(false);
}, [items]);
// Close on click outside so the popover never lingers over the text.
useEffect(() => {
if (!open) return;
const onPointerDown = (event: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, [open]);
if (items.length < 2) return null;
return (
<div ref={rootRef} className={cn("flex flex-col items-end", className)}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className={cn(
"inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-xs shadow-sm backdrop-blur transition-colors",
open
? "border-slate-300 bg-white text-slate-700 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200"
: "border-slate-200 bg-white/90 text-slate-500 hover:text-slate-700 dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-400 dark:hover:text-slate-200",
)}
>
<ListTree className="size-3.5" />
目录
</button>
{open ? (
<nav className="mt-2 max-h-[55vh] w-64 overflow-y-auto rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl dark:border-slate-700 dark:bg-slate-900">
{items.map((item) => (
<button
key={item.id}
type="button"
title={item.text}
onClick={() => {
onItemClick(item.id);
setOpen(false);
}}
className={cn(
"block w-full cursor-pointer truncate rounded-md px-2 py-1.5 text-left text-[13px] leading-snug transition-colors",
item.level >= 3 && "pl-5",
activeId === item.id
? "bg-slate-100 font-medium text-red-600 dark:bg-slate-800 dark:text-red-400"
: "text-slate-500 hover:bg-slate-50 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-800/60 dark:hover:text-slate-200",
)}
>
{item.text}
</button>
))}
</nav>
) : null}
</div>
);
}
@@ -0,0 +1,189 @@
import {
Loader2,
PanelLeftClose,
PanelLeftOpen,
Search,
SlidersHorizontal,
} from "lucide-react";
import { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { KnowledgeNote } from "@/core/knowledge/types";
import { cn } from "@/lib/utils";
import { KnowledgeTree } from "./KnowledgeTree";
import type { TreeNode } from "./wikiTypes";
import { SOURCE_TYPE_LABELS } from "./wikiUtils";
interface KnowledgeSidebarProps {
keyword: string;
onKeywordChange: (v: string) => void;
sourceType: string;
onSourceTypeChange: (v: string) => void;
status: string;
onStatusChange: (v: string) => void;
tree: TreeNode[];
expandedIds: Set<string>;
onToggleFolder: (id: string) => void;
selectedId?: string;
onSelect: (note: KnowledgeNote) => void;
onExportNote: (note: KnowledgeNote) => void;
total: number;
isLoading: boolean;
/** Collapsed to a narrow rail (desktop only; the mobile drawer always expands). */
collapsed?: boolean;
onToggleCollapse?: () => void;
className?: string;
}
/** Left column: search + filters + directory tree, collapsible to a narrow rail. */
export function KnowledgeSidebar(props: KnowledgeSidebarProps) {
const [filtersOpen, setFiltersOpen] = useState(false);
const activeFilters =
(props.sourceType !== "all" ? 1 : 0) + (props.status !== "all" ? 1 : 0);
const searching = props.keyword.trim().length > 0;
if (props.collapsed) {
return (
<aside className="hidden min-h-0 flex-col items-center gap-1 border-r border-slate-100 bg-white py-2.5 md:flex dark:border-slate-800 dark:bg-slate-900">
<button
type="button"
onClick={props.onToggleCollapse}
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-300"
aria-label="展开目录"
title="展开目录"
>
<PanelLeftOpen className="size-4" />
</button>
<button
type="button"
onClick={props.onToggleCollapse}
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-300"
aria-label="搜索文档"
title="搜索文档"
>
<Search className="size-4" />
</button>
</aside>
);
}
return (
<aside
className={cn(
"flex min-h-0 flex-col border-r border-slate-100 bg-white dark:border-slate-800 dark:bg-slate-900",
props.className,
)}
>
<div className="border-b border-slate-100 p-2.5 dark:border-slate-800">
<div className="flex items-center gap-1.5">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
<input
value={props.keyword}
onChange={(e) => props.onKeywordChange(e.target.value)}
placeholder="搜索标题或内容..."
className="h-8 w-full rounded-lg border border-slate-200 bg-slate-50 pl-8 pr-2.5 text-[13px] text-slate-700 outline-none transition-colors placeholder:text-slate-400 focus:border-slate-400 focus:bg-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:focus:border-slate-500 dark:focus:bg-slate-900"
/>
</div>
<button
type="button"
onClick={() => setFiltersOpen((v) => !v)}
className={cn(
"relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg transition-colors",
filtersOpen || activeFilters > 0
? "bg-red-50 text-red-500 dark:bg-red-500/10 dark:text-red-400"
: "text-slate-400 hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-300",
)}
aria-label="高级筛选"
title="高级筛选"
>
<SlidersHorizontal className="size-4" />
{activeFilters > 0 ? (
<span className="absolute -right-0.5 -top-0.5 flex size-3.5 items-center justify-center rounded-full bg-red-500 text-[9px] font-bold text-white">
{activeFilters}
</span>
) : null}
</button>
{props.onToggleCollapse ? (
<button
type="button"
onClick={props.onToggleCollapse}
className="inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 max-md:hidden dark:hover:bg-slate-800 dark:hover:text-slate-300"
aria-label="收起目录"
title="收起目录"
>
<PanelLeftClose className="size-4" />
</button>
) : null}
</div>
{filtersOpen ? (
<div className="mt-2 grid grid-cols-2 gap-1.5">
<Select value={props.sourceType} onValueChange={props.onSourceTypeChange}>
<SelectTrigger className="h-8 w-full text-[13px]">
<SelectValue placeholder="来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部来源</SelectItem>
{Object.entries(SOURCE_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={props.status} onValueChange={props.onStatusChange}>
<SelectTrigger className="h-8 w-full text-[13px]">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="draft">草稿</SelectItem>
<SelectItem value="approved">已批准</SelectItem>
<SelectItem value="archived">已归档</SelectItem>
</SelectContent>
</Select>
</div>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
<div className="px-2 pb-1.5 pt-0.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400 dark:text-slate-500">
文档目录
</div>
{props.isLoading ? (
<div className="flex items-center gap-2 p-4 text-sm text-slate-400">
<Loader2 className="size-4 animate-spin" /> 加载中...
</div>
) : props.tree.length === 0 ? (
<div className="mx-2 rounded-lg border border-dashed border-slate-200 p-5 text-center text-sm text-slate-400 dark:border-slate-700">
{searching || activeFilters > 0
? "没有匹配的文档"
: "暂无知识。点击「新建知识」或在对话页「保存到知识库」。"}
</div>
) : (
<KnowledgeTree
nodes={props.tree}
selectedId={props.selectedId}
expandedIds={props.expandedIds}
forceExpand={searching}
onToggleFolder={props.onToggleFolder}
onSelect={props.onSelect}
onExportNote={props.onExportNote}
/>
)}
</div>
<div className="flex h-8 shrink-0 items-center justify-center border-t border-slate-100 text-[11px] text-slate-400 dark:border-slate-800 dark:text-slate-500">
共 {props.total} 篇文档
</div>
</aside>
);
}
@@ -0,0 +1,132 @@
import { ChevronRight, FileText, Folder, FolderOpen, MoreHorizontal } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { KnowledgeNote } from "@/core/knowledge/types";
import { cn } from "@/lib/utils";
import type { TreeNode } from "./wikiTypes";
interface KnowledgeTreeProps {
nodes: TreeNode[];
selectedId?: string;
expandedIds: Set<string>;
/** When searching, render every folder open regardless of expandedIds. */
forceExpand?: boolean;
onToggleFolder: (id: string) => void;
onSelect: (note: KnowledgeNote) => void;
onExportNote: (note: KnowledgeNote) => void;
}
function TreeRow(props: KnowledgeTreeProps & { node: TreeNode; depth: number }) {
const { node, depth, selectedId, expandedIds, forceExpand } = props;
const indent = { paddingLeft: `${6 + depth * 10}px` };
if (node.type === "folder") {
const open = forceExpand || expandedIds.has(node.id);
return (
<div>
<div
role="button"
tabIndex={0}
onClick={() => props.onToggleFolder(node.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") props.onToggleFolder(node.id);
}}
style={indent}
title={node.name}
className="group flex h-7 cursor-pointer select-none items-center gap-1 rounded-md pr-2 text-[13px] font-medium text-slate-500 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/70"
>
<ChevronRight
className={cn(
"size-3.5 shrink-0 text-slate-400 transition-transform",
open && "rotate-90",
)}
/>
{open ? (
<FolderOpen className="size-3.5 shrink-0 text-slate-400 dark:text-slate-500" />
) : (
<Folder className="size-3.5 shrink-0 text-slate-400 dark:text-slate-500" />
)}
<span className="flex-1 truncate">{node.name}</span>
<span className="text-[11px] font-normal text-slate-300 opacity-0 group-hover:opacity-100 dark:text-slate-600">
{node.children?.length ?? 0}
</span>
</div>
{open && node.children ? (
<div>
{node.children.map((child) => (
<TreeRow key={child.id} {...props} node={child} depth={depth + 1} />
))}
</div>
) : null}
</div>
);
}
const active = node.id === selectedId;
return (
<div
role="button"
tabIndex={0}
onClick={() => node.item && props.onSelect(node.item)}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && node.item) props.onSelect(node.item);
}}
style={indent}
className={cn(
"group flex h-7 cursor-pointer select-none items-center gap-1.5 rounded-md border-l-2 border-transparent pr-1 text-[13px]",
active
? "border-red-500 bg-slate-100 font-medium text-slate-900 dark:bg-slate-800 dark:text-slate-100"
: "text-slate-500 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/70",
)}
>
<FileText
className={cn(
"size-3.5 shrink-0",
active ? "text-red-500 dark:text-red-400" : "text-slate-400 dark:text-slate-500",
)}
/>
<span className="flex-1 truncate" title={node.name}>
{node.name}
</span>
{node.item ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="inline-flex size-5 shrink-0 items-center justify-center rounded text-slate-400 opacity-0 hover:bg-slate-200/60 group-hover:opacity-100 data-[state=open]:opacity-100 dark:hover:bg-slate-700"
aria-label="更多操作"
>
<MoreHorizontal className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onClick={() => props.onSelect(node.item!)}>
打开文档
</DropdownMenuItem>
<DropdownMenuItem onClick={() => props.onExportNote(node.item!)}>
导出 Markdown
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
);
}
/** Recursive folder/document tree for the wiki sidebar. */
export function KnowledgeTree(props: KnowledgeTreeProps) {
return (
<div className="flex flex-col gap-px">
{props.nodes.map((node) => (
<TreeRow key={node.id} {...props} node={node} depth={0} />
))}
</div>
);
}
@@ -0,0 +1,49 @@
import { useEffect, useRef } from "react";
import MarkdownRenderer, {
type WikiLinkMap,
type WikiLinkTarget,
} from "@/strategy-components/MarkdownRenderer";
import type { TocItem } from "./wikiTypes";
import "./wiki.css";
/**
* Renders the note body via the shared MarkdownRenderer and mounts the toc ids
* onto the rendered h1–h4 elements so outline clicks / scroll-spy can target them.
* extractToc and this component walk headings in the same document order, so a
* positional mapping is sufficient.
*/
export function MarkdownViewer({
content,
toc,
wikiLinks,
onWikiLinkClick,
}: {
content: string;
toc: TocItem[];
wikiLinks?: WikiLinkMap;
onWikiLinkClick?: (link: WikiLinkTarget) => void;
}) {
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const headings = root.querySelectorAll<HTMLElement>("h1, h2, h3, h4");
headings.forEach((el, index) => {
const item = toc[index];
if (item) el.id = item.id;
});
}, [content, toc]);
return (
<div ref={rootRef} className="wiki-markdown">
<MarkdownRenderer
content={content}
wikiLinks={wikiLinks}
onWikiLinkClick={onWikiLinkClick}
/>
</div>
);
}
@@ -0,0 +1,120 @@
import { FileText, Link2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type {
WikiLinkMap,
WikiLinkTarget,
} from "@/strategy-components/MarkdownRenderer";
import type { RelatedLink } from "./wikiUtils";
/**
* Compact "相关文档" module rendered from the note's `## Related` wikilinks.
* Each entry is resolved through the backend wikilink map so the visible
* title always matches the actual navigation target; duplicates (same note)
* are collapsed and unresolved targets are shown muted and non-clickable.
*/
export function RelatedDocs({
links,
wikiLinks,
onOpen,
}: {
links: RelatedLink[];
wikiLinks?: WikiLinkMap;
onOpen: (link: WikiLinkTarget) => void;
}) {
if (links.length === 0) return null;
// Resolving is still in flight — render nothing rather than dead links.
const resolving = !wikiLinks;
const seen = new Set<string>();
const entries: { link: RelatedLink; resolved?: WikiLinkTarget }[] = [];
for (const link of links) {
const resolved = wikiLinks?.[link.target];
const key =
resolved?.note_id ?? resolved?.vault_path ?? link.target.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
entries.push({ link, resolved });
}
if (entries.length === 0) return null;
return (
<section className="mt-10 border-t border-slate-100 pt-5 dark:border-slate-800">
<h2 className="mb-3 flex items-center gap-1.5 text-sm font-semibold text-slate-600 dark:text-slate-300">
<Link2 className="size-3.5 text-slate-400" />
相关文档
<span className="text-xs font-normal text-slate-400">
{entries.length}
</span>
</h2>
<div className="grid gap-2 sm:grid-cols-2">
{entries.map(({ link, resolved }) => {
const label = link.alias || resolved?.title || link.target;
const clickable = !!resolved?.exists;
const body = (
<>
<FileText
className={cn(
"size-4 shrink-0",
clickable
? "text-slate-400 transition-colors group-hover:text-red-500"
: "text-slate-300 dark:text-slate-600",
)}
/>
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[13px] font-medium",
clickable
? "text-slate-700 dark:text-slate-200"
: "text-slate-400 dark:text-slate-500",
)}
>
{label}
</span>
{resolved?.vault_path ? (
<span className="block truncate text-[11px] text-slate-400 dark:text-slate-500">
{resolved.vault_path}
</span>
) : null}
</span>
</>
);
if (clickable) {
return (
<button
key={link.target}
type="button"
title={label}
onClick={() => resolved && onOpen(resolved)}
className="group flex cursor-pointer items-center gap-2.5 rounded-lg border border-slate-200 px-3 py-2 text-left transition-colors hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700/80 dark:hover:border-slate-600 dark:hover:bg-slate-800/60"
>
{body}
</button>
);
}
return (
<div
key={link.target}
title={resolving ? label : `${label}(未收录)`}
className={cn(
"flex items-center gap-2.5 rounded-lg border border-dashed border-slate-200 px-3 py-2 dark:border-slate-700/80",
!resolving && "opacity-70",
)}
>
{body}
{!resolving ? (
<span className="shrink-0 text-[11px] text-slate-300 dark:text-slate-600">
未收录
</span>
) : null}
</div>
);
})}
</div>
</section>
);
}
@@ -0,0 +1,378 @@
import { ExternalLink, Sparkles } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useKnowledgeActions } from "@/core/knowledge/hooks";
import type { KnowledgeNote } from "@/core/knowledge/types";
import { cn } from "@/lib/utils";
import type {
WikiLinkMap,
WikiLinkTarget,
} from "@/strategy-components/MarkdownRenderer";
import { Breadcrumb } from "./Breadcrumb";
import { DocumentToolbar } from "./DocumentToolbar";
import { FloatingToc } from "./FloatingToc";
import { MarkdownViewer } from "./MarkdownViewer";
import { RelatedDocs } from "./RelatedDocs";
import {
SOURCE_TYPE_LABELS,
STATUS_LABELS,
downloadMarkdown,
extractToc,
formatDateTime,
noteBreadcrumbSegments,
splitRelatedSection,
} from "./wikiUtils";
const ENTITY_TYPE_META: Record<string, { label: string; className: string }> = {
person: { label: "人物", className: "bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-500/15 dark:text-rose-300 dark:border-rose-500/30" },
org: { label: "机构", className: "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30" },
product: { label: "产品", className: "bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30" },
technology: { label: "技术", className: "bg-cyan-50 text-cyan-700 border-cyan-200 dark:bg-cyan-500/15 dark:text-cyan-300 dark:border-cyan-500/30" },
concept: { label: "概念", className: "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30" },
region: { label: "地区", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
country: { label: "国家", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
};
function entityMeta(type?: string | null) {
return (
ENTITY_TYPE_META[(type || "").toLowerCase()] ?? {
label: "其他",
className:
"bg-slate-100 text-slate-600 border-slate-200 dark:bg-slate-700/40 dark:text-slate-300 dark:border-slate-600",
}
);
}
/** Human label for an audit actor: prefer the resolved name (email), else a short id. */
function actorLabel(name?: string | null, id?: string | null): string {
if (name && name.trim()) return name.trim();
if (id && id.trim()) return `${id.trim().slice(0, 8)}…`;
return "—";
}
function MetaTag({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex h-6 items-center rounded-md bg-slate-100 px-2 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
{children}
</span>
);
}
/** Middle column: compact header + markdown reading area (or inline editor). */
export function WikiContent({
note,
wikiLinks,
onWikiLinkClick,
}: {
note: KnowledgeNote;
wikiLinks?: WikiLinkMap;
onWikiLinkClick: (link: WikiLinkTarget) => void;
}) {
const navigate = useNavigate();
const { update, remove } = useKnowledgeActions();
const scrollRef = useRef<HTMLDivElement>(null);
const tickingRef = useRef(false);
const [editing, setEditing] = useState(false);
const [draftTitle, setDraftTitle] = useState(note.title);
const [draftTags, setDraftTags] = useState(note.tags.join(", "));
const [draftContent, setDraftContent] = useState(note.content_md);
const [draftStatus, setDraftStatus] = useState<string>(note.status);
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
// Pull the backend-injected `## Related` wikilinks out of the body so they
// render as a structured module; editing still works on the full source.
const { body, related } = useMemo(
() => splitRelatedSection(note.content_md),
[note.content_md],
);
const toc = useMemo(() => extractToc(body), [body]);
// The floating outline only shows h2/h3 — deeper levels are noise.
const outlineItems = useMemo(
() => toc.filter((item) => item.level === 2 || item.level === 3),
[toc],
);
useEffect(() => {
setEditing(false);
setDraftTitle(note.title);
setDraftTags(note.tags.join(", "));
setDraftContent(note.content_md);
setDraftStatus(note.status);
scrollRef.current?.scrollTo({ top: 0 });
setActiveHeadingId(null);
// Re-sync drafts whenever a different note (or fresh server copy) arrives.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [note.id, note.updated_at]);
const handleScroll = () => {
if (tickingRef.current) return;
tickingRef.current = true;
window.requestAnimationFrame(() => {
tickingRef.current = false;
const container = scrollRef.current;
if (!container) return;
const headings = container.querySelectorAll<HTMLElement>(
".wiki-markdown h1[id], .wiki-markdown h2[id], .wiki-markdown h3[id], .wiki-markdown h4[id]",
);
if (headings.length === 0) return;
const containerTop = container.getBoundingClientRect().top;
let current: string | null = headings[0].id;
for (const el of headings) {
if (el.getBoundingClientRect().top - containerTop <= 96) {
current = el.id;
} else {
break;
}
}
setActiveHeadingId(current);
});
};
const handleTocClick = (id: string) => {
setActiveHeadingId(id);
document
.getElementById(id)
?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const handleSave = async () => {
try {
await update.mutateAsync({
noteId: note.id,
data: {
title: draftTitle.trim() || note.title,
content_md: draftContent,
tags: draftTags
.split(/[,,\s]+/)
.map((t) => t.trim())
.filter(Boolean),
status: draftStatus,
},
});
toast.success("已保存");
setEditing(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleArchive = async () => {
try {
await remove.mutateAsync({ noteId: note.id });
toast.success("已归档");
navigate("/page/workspace/knowledge");
} catch (error) {
toast.error(error instanceof Error ? error.message : "归档失败");
}
};
return (
<div className="flex min-h-0 min-w-0 flex-col bg-white dark:bg-slate-900">
<header className="flex h-12 shrink-0 items-center gap-3 border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900">
<Breadcrumb
segments={noteBreadcrumbSegments(note)}
current={note.title}
className="min-w-0 flex-1"
/>
<DocumentToolbar
charCount={note.content_md.length}
updatedAt={formatDateTime(note.updated_at)}
statusLabel={STATUS_LABELS[note.status] ?? note.status}
editing={editing}
saving={update.isPending}
archiving={remove.isPending}
onEdit={() => setEditing(true)}
onCancelEdit={() => setEditing(false)}
onSave={() => void handleSave()}
onExport={() => downloadMarkdown(note)}
onArchive={() => void handleArchive()}
/>
</header>
<div className="relative min-h-0 flex-1">
{!editing ? (
<FloatingToc
items={outlineItems}
activeId={activeHeadingId}
onItemClick={handleTocClick}
className="absolute right-4 top-3 z-10 sm:right-6"
/>
) : null}
<div
ref={scrollRef}
onScroll={editing ? undefined : handleScroll}
className="h-full overflow-y-auto"
>
<div className="mx-auto w-full max-w-[980px] px-5 pb-28 pt-8 sm:px-8 lg:px-10">
{editing ? (
<div className="flex flex-col gap-3">
<Input
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
placeholder="标题"
/>
<div className="flex items-center gap-2">
<Input
value={draftTags}
onChange={(e) => setDraftTags(e.target.value)}
placeholder="标签,逗号分隔"
/>
<Select value={draftStatus} onValueChange={setDraftStatus}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">草稿</SelectItem>
<SelectItem value="approved">已批准</SelectItem>
<SelectItem value="archived">已归档</SelectItem>
</SelectContent>
</Select>
</div>
<Textarea
rows={24}
value={draftContent}
onChange={(e) => setDraftContent(e.target.value)}
className="font-mono text-sm"
/>
</div>
) : (
<>
<h1 className="mb-2 text-2xl font-bold leading-snug text-slate-900 dark:text-slate-100">
{note.title}
</h1>
{note.summary ? (
<div className="my-4 rounded-lg border-l-2 border-slate-200 bg-slate-50 px-4 py-3 text-sm leading-7 text-slate-600 dark:border-slate-700 dark:bg-slate-800/50 dark:text-slate-300">
{note.summary}
</div>
) : null}
<div className="mb-7 mt-3 flex flex-wrap items-center gap-1.5">
<MetaTag>
来源:{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
</MetaTag>
<MetaTag>状态:{STATUS_LABELS[note.status] ?? note.status}</MetaTag>
{note.created_at ? (
<MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag>
) : null}
{note.tags.map((tag) => (
<span
key={tag}
className="inline-flex h-6 items-center rounded-md bg-rose-50 px-2 text-xs font-medium text-rose-500 dark:bg-rose-500/10 dark:text-rose-300"
>
#{tag}
</span>
))}
</div>
{note.entities && note.entities.length > 0 ? (
<div className="mb-7 flex flex-wrap items-center gap-1.5">
<span className="mr-0.5 inline-flex items-center gap-1 text-xs text-slate-400">
<Sparkles className="size-3" /> 实体
</span>
{note.entities.map((ent) => {
const meta = entityMeta(ent.entity_type);
return (
<span
key={`${ent.name}-${ent.entity_type ?? ""}`}
title={meta.label}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium",
meta.className,
)}
>
<span className="size-1.5 shrink-0 rounded-full bg-current opacity-70" />
<span>{ent.name}</span>
<span className="rounded-full bg-current/15 px-1.5 py-0.5 text-[10px] leading-none opacity-90">
{meta.label}
</span>
</span>
);
})}
</div>
) : null}
<MarkdownViewer
content={body}
toc={toc}
wikiLinks={wikiLinks}
onWikiLinkClick={onWikiLinkClick}
/>
<RelatedDocs
links={related}
wikiLinks={wikiLinks}
onOpen={onWikiLinkClick}
/>
{note.sources && note.sources.length > 0 ? (
<div className="mt-10 border-t border-slate-100 pt-5 dark:border-slate-800">
<h2 className="mb-3 text-sm font-semibold text-slate-600 dark:text-slate-300">
来源
</h2>
<ul className="flex flex-col gap-1.5">
{note.sources.map((src) => (
<li key={src.id} className="text-[13px]">
{src.url ? (
<a
href={src.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-blue-600 hover:underline dark:text-blue-400"
>
{src.title || src.url}
<ExternalLink className="size-3" />
</a>
) : src.thread_id ? (
<button
type="button"
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
onClick={() =>
navigate(`/page/workspace/chats/${src.thread_id}`)
}
>
{src.title || `对话 ${src.thread_id.slice(0, 8)}`}
</button>
) : (
<span className="text-slate-600 dark:text-slate-300">
{src.title || src.tool_name || "来源"}
</span>
)}
{src.snippet ? (
<p className="mt-0.5 line-clamp-2 text-xs text-slate-400">
{src.snippet}
</p>
) : null}
</li>
))}
</ul>
</div>
) : null}
<div className="mt-12 border-t border-slate-100 pt-3 text-[12px] text-slate-400/90 dark:border-slate-800 dark:text-slate-500">
创建人 {actorLabel(note.created_by_name, note.created_by)} · 更新人{" "}
{actorLabel(note.updated_by_name, note.updated_by)} · 更新于{" "}
{formatDateTime(note.updated_at)}
</div>
</>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,112 @@
import {
BookOpen,
Copy,
Menu,
Network,
Plus,
RefreshCw,
Upload,
} from "lucide-react";
import { cn } from "@/lib/utils";
function HeaderButton({
icon,
label,
onClick,
variant = "outline",
className,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
variant?: "outline" | "primary";
className?: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg border px-3 text-sm transition-colors",
variant === "primary"
? "border-red-600 bg-red-600 text-white hover:bg-red-700"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800",
className,
)}
>
{icon}
<span className="hidden sm:inline">{label}</span>
</button>
);
}
/** Light 56px wiki header: logo + product name on the left, action entries on the right. */
export function WikiHeader({
onToggleSidebar,
onImportFile,
onBatchImport,
onDedup,
onGraph,
onCreate,
}: {
onToggleSidebar: () => void;
onImportFile: () => void;
onBatchImport: () => void;
onDedup: () => void;
onGraph: () => void;
onCreate: () => void;
}) {
return (
<header className="flex h-14 shrink-0 items-center justify-between border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center gap-3">
<button
type="button"
onClick={onToggleSidebar}
className="inline-flex size-9 items-center justify-center rounded-lg text-slate-500 hover:bg-slate-100 md:hidden dark:text-slate-400 dark:hover:bg-slate-800"
aria-label="打开目录"
>
<Menu className="size-5" />
</button>
<div className="flex size-9 items-center justify-center rounded-[9px] bg-red-600 text-white">
<BookOpen className="size-5" />
</div>
<div className="leading-none">
<div className="text-base font-extrabold tracking-tight text-slate-900 dark:text-slate-100">
知识库
</div>
<div className="mt-1 text-xs text-emerald-500">LLM Wiki · 知识库中心</div>
</div>
</div>
<div className="flex items-center gap-2">
<HeaderButton
icon={<Upload className="size-4" />}
label="导入文件"
onClick={onImportFile}
/>
<HeaderButton
icon={<RefreshCw className="size-4" />}
label="批量导入"
onClick={onBatchImport}
/>
<HeaderButton
icon={<Copy className="size-4" />}
label="查重"
onClick={onDedup}
/>
<HeaderButton
icon={<Network className="size-4" />}
label="图谱"
onClick={onGraph}
/>
<HeaderButton
icon={<Plus className="size-4" />}
label="新建知识"
onClick={onCreate}
variant="primary"
/>
</div>
</header>
);
}
+168
View File
@@ -0,0 +1,168 @@
/* Wiki reading-area typography: overrides the generic MarkdownRenderer classes
inside .wiki-markdown to get a document-site reading experience. */
.wiki-markdown h1 {
font-size: 26px;
line-height: 1.4;
font-weight: 700;
color: #111827;
margin: 0 0 20px;
}
.wiki-markdown h2 {
font-size: 20px;
font-weight: 700;
line-height: 1.4;
margin: 36px 0 14px;
padding-bottom: 8px;
border-bottom: 1px solid #eef1f5;
color: #111827;
}
.wiki-markdown h3 {
font-size: 17px;
font-weight: 600;
margin: 26px 0 10px;
color: #1f2937;
}
.wiki-markdown h4 {
font-size: 15.5px;
font-weight: 600;
margin: 22px 0 8px;
color: #334155;
}
.wiki-markdown p {
font-size: 15.5px;
line-height: 1.75;
color: #334155;
margin-bottom: 14px;
}
.wiki-markdown ul,
.wiki-markdown ol {
padding-left: 22px;
margin-bottom: 14px;
}
.wiki-markdown li {
margin: 5px 0;
line-height: 1.75;
color: #334155;
font-size: 15.5px;
}
.wiki-markdown li > ul,
.wiki-markdown li > ol {
margin-top: 5px;
margin-bottom: 5px;
}
.wiki-markdown strong {
color: #111827;
font-weight: 600;
}
.wiki-markdown a {
color: #2563eb;
text-decoration: none;
}
.wiki-markdown a:hover {
text-decoration: underline;
}
.wiki-markdown blockquote {
border-left: 3px solid #e2e8f0;
background: #f8fafc;
padding: 10px 16px;
color: #475569;
margin: 16px 0;
border-radius: 0 8px 8px 0;
font-style: normal;
}
.wiki-markdown blockquote p {
margin-bottom: 0;
}
.wiki-markdown table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
font-size: 14px;
}
.wiki-markdown th,
.wiki-markdown td {
border: 1px solid #e8ecf1;
padding: 8px 12px;
}
.wiki-markdown th {
background: #f8fafc;
font-weight: 600;
color: #334155;
}
.wiki-markdown hr {
margin: 28px 0;
border-color: #eef1f5;
}
/* Anchor target offset so headings clear the floating "目录" pill. */
.wiki-markdown h1,
.wiki-markdown h2,
.wiki-markdown h3,
.wiki-markdown h4 {
scroll-margin-top: 24px;
}
/* Dark theme adjustments (app toggles the .dark class on a root element). */
.dark .wiki-markdown h1,
.dark .wiki-markdown h2 {
color: #f1f5f9;
}
.dark .wiki-markdown h2 {
border-bottom-color: #1f2937;
}
.dark .wiki-markdown h3,
.dark .wiki-markdown h4 {
color: #e2e8f0;
}
.dark .wiki-markdown p,
.dark .wiki-markdown li {
color: #cbd5e1;
}
.dark .wiki-markdown strong {
color: #f8fafc;
}
.dark .wiki-markdown a {
color: #60a5fa;
}
.dark .wiki-markdown blockquote {
background: #1e293b;
border-left-color: #475569;
color: #cbd5e1;
}
.dark .wiki-markdown th {
background: #1e293b;
color: #e2e8f0;
}
.dark .wiki-markdown th,
.dark .wiki-markdown td {
border-color: #334155;
}
.dark .wiki-markdown hr {
border-color: #334155;
}
@@ -0,0 +1,17 @@
import type { KnowledgeNote } from "@/core/knowledge/types";
/** One entry of the in-document outline, parsed from markdown headings (h1–h4). */
export interface TocItem {
id: string;
text: string;
level: number;
}
/** A node of the left-hand knowledge directory tree. */
export interface TreeNode {
id: string;
name: string;
type: "folder" | "file";
children?: TreeNode[];
item?: KnowledgeNote;
}
@@ -0,0 +1,230 @@
import type { KnowledgeNote } from "@/core/knowledge/types";
import type { TocItem, TreeNode } from "./wikiTypes";
export const SOURCE_TYPE_LABELS: Record<string, string> = {
thread: "对话沉淀",
search: "检索沉淀",
tool: "工具沉淀",
file: "文件导入",
manual: "手动创建",
report: "研究报告",
};
export const STATUS_LABELS: Record<string, string> = {
draft: "草稿",
approved: "已批准",
archived: "已归档",
};
const FALLBACK_FOLDER = "未分类";
/** Stable slug for a heading: keeps CJK characters, collapses whitespace to dashes. */
export function slugify(text: string): string {
return text
.trim()
.toLowerCase()
.replace(/[#`*_~[\]()]/g, "")
.replace(/\s+/g, "-")
.replace(/[^\w一-龥-]/g, "");
}
/**
* Parse h1–h4 headings out of raw markdown (skipping fenced code blocks) and
* assign each a stable id; duplicate titles get a numeric suffix. The same id
* sequence is mounted onto the rendered headings by MarkdownViewer.
*/
export function extractToc(markdown: string): TocItem[] {
const usedIds = new Map<string, number>();
const items: TocItem[] = [];
let inFence = false;
for (const line of markdown.split("\n")) {
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence;
continue;
}
if (inFence) continue;
const match = /^(#{1,4})\s+(.+)$/.exec(line);
if (!match) continue;
const level = match[1].length;
const text = match[2].replace(/[#`*_~]/g, "").trim();
const baseId = slugify(text) || "heading";
const count = usedIds.get(baseId) ?? 0;
usedIds.set(baseId, count + 1);
items.push({ id: count === 0 ? baseId : `${baseId}-${count + 1}`, text, level });
}
return items;
}
/** One entry parsed from the note's trailing `## Related` wikilink list. */
export interface RelatedLink {
target: string;
alias?: string;
}
/**
* Split the backend-injected `## Related` section (a plain list of
* `- [[Title]]` bullets, see knowledge/service.py `_inject_related_notes`)
* out of the markdown body so it can be rendered as a structured module
* instead of a loose bullet list. If the section contains anything other
* than wikilink bullets, the body is returned untouched (no data loss).
*/
export function splitRelatedSection(markdown: string): {
body: string;
related: RelatedLink[];
} {
const lines = markdown.split("\n");
let inFence = false;
let start = -1;
for (let i = 0; i < lines.length; i++) {
if (/^\s*(```|~~~)/.test(lines[i])) {
inFence = !inFence;
continue;
}
if (inFence) continue;
if (/^##\s+(Related|相关文档|相关)\s*$/i.test(lines[i].trim())) {
start = i;
break;
}
}
if (start === -1) return { body: markdown, related: [] };
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^#{1,2}\s+/.test(lines[i])) {
end = i;
break;
}
}
const related: RelatedLink[] = [];
for (const raw of lines.slice(start + 1, end)) {
const line = raw.trim();
if (!line) continue;
const match = /^[-*]\s*\[\[([^\]\n|]+)(?:\|([^\]\n]+))?\]\]\s*$/.exec(line);
if (!match) return { body: markdown, related: [] };
const target = match[1].trim();
if (!related.some((r) => r.target === target)) {
related.push({ target, alias: match[2]?.trim() || undefined });
}
}
if (related.length === 0) return { body: markdown, related: [] };
const body = [...lines.slice(0, start), ...lines.slice(end)]
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trimEnd();
return { body, related };
}
/** Folder path segments for a note: vault_path directories, else its source-type group. */
export function noteFolderSegments(note: KnowledgeNote): string[] {
const rawPath = note.vault_path?.replace(/\\/g, "/").replace(/^\/+/, "");
if (rawPath && rawPath.includes("/")) {
return rawPath.split("/").slice(0, -1).filter(Boolean);
}
return [SOURCE_TYPE_LABELS[note.source_type] ?? FALLBACK_FOLDER];
}
/** Breadcrumb segments for a note (folders only; the title is appended by the caller). */
export function noteBreadcrumbSegments(note: KnowledgeNote): string[] {
return ["知识库", ...noteFolderSegments(note)];
}
function sortTree(nodes: TreeNode[]): void {
nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
return a.name.localeCompare(b.name, "zh-CN");
});
for (const node of nodes) {
if (node.children) sortTree(node.children);
}
}
/**
* Build the directory tree from the flat note list. Notes with a vault_path
* become nested folders mirroring that path; others are grouped by source type.
*/
export function buildKnowledgeTree(notes: KnowledgeNote[]): TreeNode[] {
const root: TreeNode[] = [];
const folderMap = new Map<string, TreeNode>();
const ensureFolder = (segments: string[]): TreeNode[] => {
let children = root;
let path = "";
for (const segment of segments) {
path = path ? `${path}/${segment}` : segment;
let node = folderMap.get(path);
if (!node) {
node = { id: `folder:${path}`, name: segment, type: "folder", children: [] };
folderMap.set(path, node);
children.push(node);
}
children = node.children!;
}
return children;
};
for (const note of notes) {
ensureFolder(noteFolderSegments(note)).push({
id: note.id,
name: note.title || "未命名文档",
type: "file",
item: note,
});
}
sortTree(root);
return root;
}
/** Ids of every folder in the tree (used by "expand all" while searching). */
export function collectFolderIds(nodes: TreeNode[]): string[] {
const ids: string[] = [];
const walk = (list: TreeNode[]) => {
for (const node of list) {
if (node.type === "folder") {
ids.push(node.id);
if (node.children) walk(node.children);
}
}
};
walk(nodes);
return ids;
}
/** Folder ids on the path from the root down to the given note, or null if absent. */
export function findAncestorFolderIds(
nodes: TreeNode[],
noteId: string,
): string[] | null {
for (const node of nodes) {
if (node.type === "file" && node.id === noteId) return [];
if (node.type === "folder" && node.children) {
const sub = findAncestorFolderIds(node.children, noteId);
if (sub) return [node.id, ...sub];
}
}
return null;
}
export function formatDateTime(value?: string | null): string {
return value ? value.slice(0, 19).replace("T", " ") : "—";
}
export function formatDate(value?: string | null): string {
return value ? value.slice(0, 10) : "—";
}
/** Trigger a client-side download of the note body as a .md file. */
export function downloadMarkdown(note: KnowledgeNote): void {
const blob = new Blob([note.content_md], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${(note.title || "knowledge").replace(/[\\/:*?"<>|]/g, "_")}.md`;
anchor.click();
URL.revokeObjectURL(url);
}
@@ -1,39 +1,54 @@
"use client";
import { BotIcon } from "lucide-react";
import { type Agent } from "@/core/agents";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
export function AgentWelcome({
className,
agent,
agentName,
}: {
className?: string;
agent: Agent | null | undefined;
agentName: string;
}) {
const displayName = desensitize(agent?.name ?? agentName);
const description = agent?.description
? desensitize(agent.description)
: undefined;
return (
<div
className={cn(
"mx-auto flex w-full flex-col items-center justify-center gap-2 px-8 py-4 text-center",
className,
)}
>
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-full">
<BotIcon className="text-primary h-6 w-6" />
</div>
<div className="text-2xl font-bold">{displayName}</div>
{description && (
<p className="text-muted-foreground max-w-sm text-sm">{description}</p>
)}
</div>
);
}
"use client";
import { BotIcon } from "lucide-react";
import { type Agent } from "@/core/agents";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/**
* Landing (home) hero shown above the input box before the first message of an
* agent conversation: centered avatar, a "Hi,我是{name}" greeting and the
* agent's description as a slogan. The description is clamped to two lines
* (full text on hover) so a long blurb can never push the input box below the
* fold. The agent's 常用问题 are rendered by the page as a list beside the
* input box, not here.
*/
export function AgentWelcome({
className,
agent,
agentName,
}: {
className?: string;
agent: Agent | null | undefined;
agentName: string;
}) {
const displayName = desensitize(agent?.name ?? agentName);
const description = agent?.description
? desensitize(agent.description)
: undefined;
return (
<div
className={cn(
"mx-auto flex w-full max-w-2xl flex-col items-center justify-center gap-3 px-6 text-center",
className,
)}
>
<div className="bg-primary/10 ring-primary/15 mb-2 flex h-[72px] w-[72px] items-center justify-center rounded-3xl ring-1">
<BotIcon className="text-primary h-9 w-9" />
</div>
<div className="text-3xl font-bold tracking-tight">
Hi,我是{displayName}
</div>
{description && (
<p
className="text-muted-foreground line-clamp-2 max-w-xl text-base leading-relaxed"
title={description}
>
{description}
</p>
)}
</div>
);
}
@@ -4,11 +4,9 @@ import {
BotIcon,
CopyIcon,
GlobeIcon,
InfoIcon,
LockIcon,
MessageSquareIcon,
PinIcon,
SearchIcon,
ShieldIcon,
SparklesIcon,
SquarePenIcon,
@@ -47,12 +45,24 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { usePublishSquares } from "@/core/system-settings/hooks";
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import {
normalizeAgentQuestions,
useAgent,
useCreateAgent,
useDeleteAgent,
@@ -63,7 +73,7 @@ import {
useUpdateAgent,
} from "@/core/agents";
import { getAgent } from "@/core/agents/api";
import type { Agent } from "@/core/agents";
import type { Agent, AgentQuestion } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useSkills } from "@/core/skills/hooks";
@@ -207,12 +217,21 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
const [editName, setEditName] = useState(agent.name);
const [editDescription, setEditDescription] = useState(agent.description ?? "");
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
// Common questions (常用问题) — primary editing now lives in the agent chat
// page's right-hand AgentDetailPanel; this dialog stays as a backup entry.
// Each row has a short title (shown on the chip) and the full prompt sent
// when clicked.
const [editCommonQuestions, setEditCommonQuestions] = useState<AgentQuestion[]>(
() => normalizeAgentQuestions(agent.common_questions),
);
const [editPublished, setEditPublished] = useState(agent.published);
const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private);
const [editSquareId, setEditSquareId] = useState(agent.square_id ?? "");
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
const { data: publishSquares } = usePublishSquares();
const [selectedSkills, setSelectedSkills] = useState<string[]>(
agent.skills ?? [],
);
const [skillSearch, setSkillSearch] = useState("");
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
// The /api/agents list response intentionally omits `soul` (perf optimization
@@ -249,13 +268,16 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
setEditDescription(agent.description ?? "");
setEditPublished(agent.published);
setEditIsPrivate(agent.is_private);
setEditSquareId(agent.square_id ?? "");
setSelectedSkills(agent.skills ?? []);
setSkillSearch("");
setEditCommonQuestions(normalizeAgentQuestions(agent.common_questions));
}, [
agent.common_questions,
agent.description,
agent.is_private,
agent.name,
agent.published,
agent.square_id,
agent.skills,
editOpen,
]);
@@ -269,6 +291,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
// include `soul`) keep working.
if (agentDetail) {
setEditSoul(agentDetail.soul ?? "");
setEditCommonQuestions(normalizeAgentQuestions(agentDetail.common_questions));
} else {
setEditSoul(agent.soul ?? "");
}
@@ -344,7 +367,14 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
// backend will see, regardless of any stale state in editPublished.
published: editIsPrivate ? false : editPublished,
is_private: editIsPrivate,
square_id: editSquareId,
skills: selectedSkills,
common_questions: editCommonQuestions
.map((q) => ({
title: q.title.trim() || q.prompt.trim(),
prompt: q.prompt.trim() || q.title.trim(),
}))
.filter((q) => q.prompt.length > 0),
},
});
toast.success("智能体已更新");
@@ -362,6 +392,12 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
toast.warning("该智能体已加入私密广场,不可发布");
return;
}
// Publishing: prompt for which square to publish into (when any squares are
// configured). Unpublishing: just flip it off.
if (!agent.published && (publishSquares?.squares.length ?? 0) > 0) {
setPublishPickerOpen(true);
return;
}
try {
await updateAgent.mutateAsync({
id: agent.id,
@@ -373,6 +409,21 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
}
}
async function handlePublishToSquare(squareId: string) {
try {
await updateAgent.mutateAsync({
id: agent.id,
request: { published: true, square_id: squareId },
});
const sqName =
publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场";
toast.success(`已发布到「${sqName}」`);
setPublishPickerOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleTogglePrivate() {
const turningOn = !agent.is_private;
try {
@@ -434,34 +485,11 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
}
}
function toggleSkill(skillName: string) {
setSelectedSkills((prev) => {
if (prev.includes(skillName)) {
return prev.filter((name) => name !== skillName);
}
return [...prev, skillName];
});
}
const enabledSkills = useMemo(
() => skills.filter((skill) => skill.enabled),
[skills],
);
// Search over English name, Chinese name (name_zh) and description, so both
// 中英文 work. Already-selected skills stay searchable too (selection is
// independent of this filter).
const filteredSkills = useMemo(() => {
const q = skillSearch.trim().toLowerCase();
if (!q) return enabledSkills;
return enabledSkills.filter(
(skill) =>
skill.name.toLowerCase().includes(q) ||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
(skill.description ?? "").toLowerCase().includes(q),
);
}, [enabledSkills, skillSearch]);
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
// both the card header and the compact list row.
const statusBadges = (
@@ -831,7 +859,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
<DialogHeader>
<DialogTitle>编辑智能体:{desensitize(agent.name)}</DialogTitle>
<DialogDescription>
可以修改名称、描述、技能白名单、SOUL 内容与发布状态。
可以修改名称、描述、技能白名单、SOUL 内容、常用问题与发布状态。
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
@@ -859,65 +887,13 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
已选 {selectedSkills.length} 个
</span>
</div>
{enabledSkills.length > 0 && (
<div className="relative">
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
<Input
placeholder="搜索技能(支持中英文名称、描述)…"
value={skillSearch}
onChange={(e) => setSkillSearch(e.currentTarget.value)}
className="h-8 pl-7 text-xs"
/>
</div>
)}
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
) : enabledSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">
当前没有可用技能
</p>
) : filteredSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">没有匹配的技能</p>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{filteredSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<div
key={skill.name}
className="flex w-full min-w-0 items-center"
>
<Button
type="button"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill.name)}
title={desensitize(skill.description ?? "")}
className="h-8 min-w-0 flex-1 rounded-r-none"
>
<span className="truncate">
{skill.name_zh?.trim()
? skill.name_zh
: desensitize(skill.name)}
</span>
</Button>
<Button
type="button"
size="icon"
variant={checked ? "default" : "outline"}
onClick={() => setDetailSkillName(skill.name)}
title={t.settings.skills.viewDetailAction}
className="h-8 w-7 rounded-l-none border-l-0"
>
<InfoIcon className="size-3.5" />
</Button>
</div>
);
})}
</div>
)}
</div>
<SkillTransferSelector
skills={enabledSkills}
selected={selectedSkills}
onChange={setSelectedSkills}
isLoading={isSkillsLoading}
onViewDetail={setDetailSkillName}
/>
<p className="text-muted-foreground text-xs">
不选任何技能会保存为无技能(skills = [])。
</p>
@@ -935,6 +911,16 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
className="min-h-40 font-mono text-xs"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">常用问题</label>
<p className="text-muted-foreground text-xs">
这些问题会在该智能体的首页与简介栏作为推荐问题展示,点击即可一键提问。
</p>
<AgentQuestionEditor
value={editCommonQuestions}
onChange={setEditCommonQuestions}
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="text-sm font-medium">公开发布</p>
@@ -956,6 +942,32 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
}}
/>
</div>
{editPublished && !editIsPrivate && (publishSquares?.squares.length ?? 0) > 0 && (
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="text-sm font-medium">发布广场</p>
<p className="text-muted-foreground text-xs">
选择发布到哪个广场;不选则归入默认广场。每个智能体只能归属一个广场。
</p>
</div>
<Select
value={editSquareId || publishSquares?.default_square_id || ""}
onValueChange={setEditSquareId}
>
<SelectTrigger className="h-9 w-44 text-sm">
<SelectValue placeholder="默认广场" />
</SelectTrigger>
<SelectContent position="popper">
{publishSquares?.squares.map((sq) => (
<SelectItem key={sq.id} value={sq.id}>
{sq.name}
{sq.id === publishSquares?.default_square_id ? "(默认)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="text-sm font-medium">加入私密广场</p>
@@ -1034,6 +1046,16 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
if (!o) setDetailSkillName(null);
}}
/>
<PublishSquarePickerDialog
open={publishPickerOpen}
onOpenChange={setPublishPickerOpen}
squares={publishSquares?.squares ?? []}
defaultSquareId={agent.square_id || publishSquares?.default_square_id}
loading={updateAgent.isPending}
onConfirm={(squareId) => void handlePublishToSquare(squareId)}
description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。"
/>
</>
);
}
@@ -0,0 +1,736 @@
"use client";
import {
BotIcon,
GlobeIcon,
InfoIcon,
Loader2Icon,
LockIcon,
MessageCircleQuestionIcon,
PanelRightCloseIcon,
PanelRightOpenIcon,
PlusIcon,
ShieldIcon,
SparklesIcon,
SquarePenIcon,
Trash2Icon,
UserRoundIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo, useState, type ReactNode } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
import { SkillPickerSheet } from "@/components/workspace/agents/skill-picker-sheet";
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { Tooltip } from "@/components/workspace/tooltip";
import {
normalizeAgentQuestions,
useDeleteAgent,
useSetAgentFeatured,
useUpdateAgent,
type Agent,
type AgentQuestion,
} from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useSkills } from "@/core/skills/hooks";
import { usePublishSquares } from "@/core/system-settings/hooks";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/**
* Turn the admin-authored SOUL.md markdown into a clean, readable profile blurb
* for the narrow panel. The view mode is a profile card, not a document view —
* so we strip markdown syntax (headings, emphasis, inline code, list bullets,
* blockquotes) rather than rendering it, keeping the line structure intact.
*/
function cleanProfileText(raw: string): string {
return raw
.split("\n")
.map((line) =>
line
.replace(/^\s{0,3}#{1,6}\s*/, "")
.replace(/^\s{0,3}>\s?/, "")
.replace(/^(\s*)[-*+]\s+/, "$1• ")
.replace(/(\*\*|__)(.*?)\1/g, "$2")
.replace(/(\*|_)(.*?)\1/g, "$2")
.replace(/`([^`]+)`/g, "$1"),
)
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
/** Local edit-mode draft — committed in one `updateAgent` call on 保存. */
interface AgentDraft {
name: string;
description: string;
soul: string;
questions: AgentQuestion[];
skills: string[];
published: boolean;
featured: boolean;
}
function draftFromAgent(agent: Agent): AgentDraft {
return {
name: agent.name,
description: agent.description ?? "",
soul: agent.soul ?? "",
questions: normalizeAgentQuestions(agent.common_questions),
skills: agent.skills ?? [],
published: agent.published,
featured: agent.featured_order != null,
};
}
function Section({
icon,
title,
extra,
children,
}: {
icon?: ReactNode;
title: string;
extra?: ReactNode;
children: ReactNode;
}) {
return (
<section className="bg-card/60 rounded-xl border p-4">
<div className="mb-3 flex items-center justify-between gap-2">
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
{icon}
{title}
</h4>
{extra}
</div>
{children}
</section>
);
}
/**
* 智能体详情面板 — the right-hand agent configuration center on the agent chat
* page. View mode shows the full profile (avatar, name, description, SOUL,
* 常用问题, enabled skills, publish/featured state); 管理员或发布者 can flip
* into an inline edit mode (no dialog) that edits everything locally and
* commits in a single 保存. 添加技能 opens a secondary drawer (SkillPickerSheet).
* Everyone else gets a read-only profile. On narrow screens (<lg) the panel
* collapses into a rail whose button opens the same content in a Sheet, so it
* never overlaps the chat input.
*/
export function AgentDetailPanel({
agent,
agentName,
onAskQuestion,
disabled,
}: {
agent: Agent | null | undefined;
agentName: string;
onAskQuestion: (text: string) => void;
disabled?: boolean;
}) {
const router = useRouter();
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<AgentDraft | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
const updateAgent = useUpdateAgent();
const deleteAgent = useDeleteAgent();
const setFeatured = useSetAgentFeatured();
const { data: publishSquares } = usePublishSquares();
const { skills, isLoading: isSkillsLoading } = useSkills();
// Same pool the edit dialog offers: only globally enabled skills are
// whitelist candidates.
const enabledPool = useMemo(
() => skills.filter((skill) => skill.enabled),
[skills],
);
// Permission model mirrors agent-card: admins manage the full catalog,
// owners manage their own agents, everyone else is read-only.
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
const isOwned =
!!agent &&
!agent.builtin &&
agent.user_id !== null &&
agent.user_id === currentUserId;
const canEdit = !!agent && (isOwned || isAdmin);
// Built-ins are inherently visible to everyone, so publish toggle is moot.
const canTogglePublish = !!agent && (isOwned || (isAdmin && !agent.builtin));
const isSaving = updateAgent.isPending || setFeatured.isPending;
const displayName = desensitize(agent?.name ?? agentName);
const questions = useMemo(
() => normalizeAgentQuestions(agent?.common_questions),
[agent?.common_questions],
);
const soulPreview = agent?.soul?.trim()
? cleanProfileText(desensitize(agent.soul))
: undefined;
function beginEdit() {
if (!agent || !canEdit) return;
setDraft(draftFromAgent(agent));
setEditing(true);
}
function cancelEdit() {
setEditing(false);
setDraft(null);
setPickerOpen(false);
}
async function saveEdit() {
if (!agent || !draft) return;
const trimmedName = draft.name.trim();
if (!trimmedName) {
toast.error("名称不能为空");
return;
}
// 私密广场与公开发布互斥 — force what the backend will accept.
const nextPublished = agent.is_private ? false : draft.published;
const nextQuestions = draft.questions
.map((q) => ({
title: q.title.trim() || q.prompt.trim(),
prompt: q.prompt.trim() || q.title.trim(),
}))
.filter((q) => q.prompt.length > 0);
try {
await updateAgent.mutateAsync({
id: agent.id,
request: {
name: trimmedName,
description: draft.description,
soul: draft.soul,
published: nextPublished,
skills: draft.skills,
common_questions: nextQuestions,
},
});
// 首页推荐 rides a dedicated endpoint — only call it when toggled.
const wasFeatured = agent.featured_order != null;
if (isAdmin && draft.featured !== wasFeatured) {
if (draft.featured && !nextPublished) {
toast.warning("只有已发布的智能体可以加入首页推荐,已跳过该项");
} else {
await setFeatured.mutateAsync({
id: agent.id,
order: draft.featured ? Date.now() : null,
});
}
}
toast.success("智能体已更新");
setEditing(false);
setDraft(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleTogglePublished() {
if (!agent) return;
if (!agent.published && agent.is_private) {
toast.warning("该智能体已加入私密广场,不可发布");
return;
}
// Publishing: pick a target square when any are configured.
if (!agent.published && (publishSquares?.squares.length ?? 0) > 0) {
setPublishPickerOpen(true);
return;
}
try {
await updateAgent.mutateAsync({
id: agent.id,
request: { published: !agent.published },
});
toast.success(agent.published ? "已下架" : "已发布");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handlePublishToSquare(squareId: string) {
if (!agent) return;
try {
await updateAgent.mutateAsync({
id: agent.id,
request: { published: true, square_id: squareId },
});
const sqName =
publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场";
toast.success(`已发布到「${sqName}」`);
setPublishPickerOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete() {
if (!agent) return;
try {
await deleteAgent.mutateAsync(agent.id);
toast.success("智能体已删除");
setDeleteOpen(false);
router.push("/page/workspace/agents");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
const statusBadges = agent && (
<>
{agent.builtin && (
<Badge variant="secondary" className="text-xs">
内置
</Badge>
)}
{agent.published && !agent.is_private && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
>
<GlobeIcon className="size-3" />
已发布
</Badge>
)}
{!agent.published && !agent.builtin && (
<Badge variant="outline" className="text-muted-foreground text-xs">
未发布
</Badge>
)}
{agent.is_private && (
<Badge
variant="outline"
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
>
<ShieldIcon className="size-3" />
私密
</Badge>
)}
{agent.featured_order != null && (
<Badge
variant="outline"
className="border-amber-500/40 text-amber-700 dark:text-amber-300 text-xs"
>
<SparklesIcon className="size-3" />
首页推荐
</Badge>
)}
</>
);
const headerActions = canEdit ? (
editing ? (
<div className="flex items-center gap-1.5">
<Button size="sm" onClick={() => void saveEdit()} disabled={isSaving}>
{isSaving && <Loader2Icon className="size-3.5 animate-spin" />}
保存
</Button>
<Button
size="sm"
variant="outline"
onClick={cancelEdit}
disabled={isSaving}
>
取消
</Button>
</div>
) : (
<div className="flex items-center gap-1">
<Button size="sm" variant="outline" onClick={beginEdit}>
<SquarePenIcon className="size-3.5" />
编辑
</Button>
{canTogglePublish && (
<Tooltip content={agent?.published ? "下架" : "发布"}>
<Button
size="icon-sm"
variant="ghost"
onClick={() => void handleTogglePublished()}
disabled={updateAgent.isPending}
aria-label={agent?.published ? "下架" : "发布"}
>
{agent?.published ? (
<LockIcon className="size-3.5" />
) : (
<GlobeIcon className="size-3.5" />
)}
</Button>
</Tooltip>
)}
<Tooltip content="删除智能体">
<Button
size="icon-sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => setDeleteOpen(true)}
aria-label="删除智能体"
>
<Trash2Icon className="size-3.5" />
</Button>
</Tooltip>
</div>
)
) : null;
const skillNamesShown = editing && draft ? draft.skills : (agent?.skills ?? []);
const body = !agent ? (
<div className="text-muted-foreground flex h-40 items-center justify-center gap-2 text-sm">
<Loader2Icon className="size-4 animate-spin" />
智能体信息加载中…
</div>
) : (
<div className="space-y-4 px-4 py-4">
{/* ── Identity / 基础信息 ─────────────────────────────────────────── */}
<section className="bg-card/60 rounded-xl border p-4">
<div className="flex items-start gap-3">
<div className="bg-primary/10 ring-primary/15 flex size-12 shrink-0 items-center justify-center rounded-2xl ring-1">
<BotIcon className="text-primary size-6" />
</div>
<div className="min-w-0 flex-1 space-y-1.5">
{editing && draft ? (
<Input
value={draft.name}
onChange={(e) =>
setDraft({ ...draft, name: e.currentTarget.value })
}
placeholder="智能体名称"
className="h-8 text-sm font-semibold"
/>
) : (
<div
className="truncate text-base font-bold"
title={displayName}
>
{displayName}
</div>
)}
<div className="flex flex-wrap items-center gap-1">
{statusBadges}
</div>
{!editing && agent.owner_username && !agent.builtin && (
<div className="text-muted-foreground flex items-center gap-1 text-xs">
<UserRoundIcon className="size-3 shrink-0" />
发布者:{desensitize(agent.owner_username)}
</div>
)}
</div>
</div>
<div className="mt-3">
{editing && draft ? (
<Textarea
value={draft.description}
onChange={(e) =>
setDraft({ ...draft, description: e.currentTarget.value })
}
placeholder="智能体描述"
className="min-h-16 text-sm"
/>
) : agent.description ? (
<p className="text-muted-foreground text-sm leading-relaxed">
{desensitize(agent.description)}
</p>
) : (
<p className="text-muted-foreground/60 text-sm">暂无描述</p>
)}
</div>
{/* Publish / featured toggles — only meaningful in edit mode. */}
{editing && draft && (
<div className="mt-3 space-y-2">
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<p className="text-sm font-medium">公开发布</p>
<p className="text-muted-foreground text-xs">
{agent.is_private
? "已加入私密广场,不可发布"
: "打开后对所有用户可见"}
</p>
</div>
<Switch
checked={draft.published && !agent.is_private}
disabled={agent.is_private}
onCheckedChange={(v) => setDraft({ ...draft, published: v })}
/>
</div>
{isAdmin && (
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<p className="text-sm font-medium">首页推荐</p>
<p className="text-muted-foreground text-xs">
加入首页选择器(需已发布)
</p>
</div>
<Switch
checked={draft.featured}
onCheckedChange={(v) => setDraft({ ...draft, featured: v })}
/>
</div>
)}
</div>
)}
</section>
{/* ── SOUL / 人设 ────────────────────────────────────────────────── */}
{(editing || soulPreview) && (
<Section
icon={<SparklesIcon className="size-3.5" />}
title="人设(SOUL)"
>
{editing && draft ? (
<Textarea
value={draft.soul}
onChange={(e) =>
setDraft({ ...draft, soul: e.currentTarget.value })
}
placeholder="输入 SOUL.md 内容"
className="min-h-40 font-mono text-xs"
/>
) : (
<p className="text-muted-foreground max-h-60 overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
{soulPreview}
</p>
)}
</Section>
)}
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
{(editing || questions.length > 0) && (
<Section
icon={<MessageCircleQuestionIcon className="size-3.5" />}
title="常用问题"
extra={
<span className="text-muted-foreground text-xs tabular-nums">
{(editing && draft ? draft.questions : questions).length} 个
</span>
}
>
{editing && draft ? (
<AgentQuestionEditor
value={draft.questions}
onChange={(next) => setDraft({ ...draft, questions: next })}
disabled={isSaving}
/>
) : (
<div className="flex flex-col gap-2">
{questions.map((question, index) => (
<button
key={index}
type="button"
disabled={disabled}
onClick={() => onAskQuestion(question.prompt)}
title={desensitize(question.prompt)}
className={cn(
"border-border/60 bg-background/60 hover:bg-accent hover:border-border w-full rounded-xl border px-3 py-2.5 text-left text-sm leading-relaxed transition-colors",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{desensitize(question.title)}
</button>
))}
</div>
)}
</Section>
)}
{/* ── 技能管理 ───────────────────────────────────────────────────── */}
<Section
icon={<SparklesIcon className="size-3.5" />}
title={`已启用技能 ${skillNamesShown.length} 个`}
extra={
editing ? (
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={() => setPickerOpen(true)}
>
<PlusIcon className="size-3.5" />
添加技能
</Button>
) : undefined
}
>
<EnabledSkillList
skillNames={skillNamesShown}
skills={skills}
isLoading={isSkillsLoading}
editing={editing}
onRemove={
editing && draft
? (name) =>
setDraft({
...draft,
skills: draft.skills.filter((n) => n !== name),
})
: undefined
}
onViewDetail={setDetailSkillName}
/>
</Section>
</div>
);
const panelChrome = (collapseButton: ReactNode) => (
<>
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<span className="text-sm font-semibold">智能体信息</span>
<div className="flex items-center gap-1">
{headerActions}
{collapseButton}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
</>
);
return (
<>
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
{collapsed ? (
<div className="hidden shrink-0 flex-col items-center border-l pt-3 lg:flex">
<Tooltip content="智能体信息">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setCollapsed(false)}
aria-label="展开智能体信息"
>
<PanelRightOpenIcon className="size-4" />
</Button>
</Tooltip>
</div>
) : (
<aside className="bg-card/40 hidden h-full w-[360px] shrink-0 flex-col border-l lg:flex">
{panelChrome(
<Tooltip content="收起">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setCollapsed(true)}
aria-label="收起智能体信息"
>
<PanelRightCloseIcon className="size-4" />
</Button>
</Tooltip>,
)}
</aside>
)}
{/* ── Narrow screens (<lg): rail trigger + drawer with the same body ── */}
<div className="flex shrink-0 flex-col items-center border-l pt-3 lg:hidden">
<Tooltip content="智能体信息">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setMobileOpen(true)}
aria-label="打开智能体信息"
>
<InfoIcon className="size-4" />
</Button>
</Tooltip>
</div>
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b px-4 pb-3 pt-4">
<SheetTitle className="flex items-center justify-between text-sm">
智能体信息
<span className="mr-6">{headerActions}</span>
</SheetTitle>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
</SheetContent>
</Sheet>
{/* ── Secondary surfaces ───────────────────────────────────────────── */}
<SkillPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
skills={enabledPool}
isLoading={isSkillsLoading}
selected={editing && draft ? draft.skills : []}
onChange={(next) => {
if (editing && draft) setDraft({ ...draft, skills: next });
}}
onViewDetail={setDetailSkillName}
/>
<SkillDetailDialog
skillName={detailSkillName}
open={detailSkillName !== null}
onOpenChange={(o) => {
if (!o) setDetailSkillName(null);
}}
/>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>删除智能体</DialogTitle>
<DialogDescription>
确定要删除「{displayName}」吗?该操作不可恢复。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteOpen(false)}
disabled={deleteAgent.isPending}
>
取消
</Button>
<Button
variant="destructive"
onClick={() => void handleDelete()}
disabled={deleteAgent.isPending}
>
{deleteAgent.isPending ? "删除中…" : "删除"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<PublishSquarePickerDialog
open={publishPickerOpen}
onOpenChange={setPublishPickerOpen}
squares={publishSquares?.squares ?? []}
defaultSquareId={agent?.square_id || publishSquares?.default_square_id}
loading={updateAgent.isPending}
onConfirm={(squareId) => void handlePublishToSquare(squareId)}
description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。"
/>
</>
);
}
@@ -15,7 +15,7 @@ import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
import type { Agent, AgentSort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { usePrivateSquareStatus, useViewMode } from "@/core/system-settings/hooks";
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
import { AgentCard } from "./agent-card";
@@ -30,21 +30,21 @@ export function AgentGallery() {
const { t } = useI18n();
const router = useRouter();
const [viewMode, setViewMode] = useViewMode("agent");
const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">(
"mine",
);
const [activeTab, setActiveTab] = useState<string>("mine");
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<AgentSort>("time_desc");
const { data: publishSquares } = usePublishSquares();
// Each configured publish square (发布广场) becomes its own browse tab; the
// backend always returns at least one (the built-in default).
const squareTabs = publishSquares?.squares ?? [];
// Publisher filter: click a card's 发布者 to keep only that user's agents.
// {id} is the owner user_id (server filter), {label} the desensitized name.
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
null,
);
// Per-tab 1-based page index, preserved while switching tabs.
const [pages, setPages] = useState<{ all: number; mine: number; private: number }>(
{ all: 1, mine: 1, private: 1 },
);
// Per-tab 1-based page index (keyed by tab value), preserved across switches.
const [pages, setPages] = useState<Record<string, number>>({});
// Search + tag filtering are both server-side now. The backend mirrors the
// frontend's desensitize map, so typing the displayed abbreviation (e.g.
@@ -52,9 +52,22 @@ export function AgentGallery() {
// filters change, every tab returns to its first page.
const tagKey = tagIds.join(",");
useEffect(() => {
setPages({ all: 1, mine: 1, private: 1 });
setPages({});
}, [search, tagKey, sort, publisher?.id]);
// If the active square tab disappears (admin removed that square), fall back
// to "我的" so the gallery never points at a dead tab.
useEffect(() => {
if (
activeTab !== "mine" &&
activeTab !== "private" &&
squareTabs.length > 0 &&
!squareTabs.some((sq) => sq.id === activeTab)
) {
setActiveTab("mine");
}
}, [activeTab, squareTabs]);
const { data: privateStatus } = usePrivateSquareStatus();
const privateFeatureAvailable = !!privateStatus?.enabled;
const privateFeatureUsable =
@@ -68,8 +81,9 @@ export function AgentGallery() {
// Only the active tab fetches. "广场" maps to the server "square" scope
// (built-in/published, never private), "我的" to "mine" (owned + favorited).
const isSquareTab = activeTab !== "mine" && activeTab !== "private";
const mainScope = activeTab === "mine" ? "mine" : "square";
const mainPage = activeTab === "mine" ? pages.mine : pages.all;
const mainPage = pages[activeTab] ?? 1;
const {
agents: mainAgents,
total: mainTotal,
@@ -84,6 +98,8 @@ export function AgentGallery() {
pageSize: PAGE_SIZE,
enabled: activeTab !== "private",
owner: publisher?.id,
// Each square tab fetches only that square's agents.
square: isSquareTab ? activeTab : undefined,
});
const {
@@ -119,7 +135,7 @@ export function AgentGallery() {
const showSearchBar = activeTab !== "private" || privateUnlocked;
const activeTotal = activeTab === "private" ? privateTotal : mainTotal;
const setTabPage = (tab: "all" | "mine" | "private", page: number) =>
const setTabPage = (tab: string, page: number) =>
setPages((prev) => ({ ...prev, [tab]: page }));
const handleNewAgent = () => {
@@ -174,7 +190,7 @@ export function AgentGallery() {
list: Agent[];
total: number;
isLoading: boolean;
tab: "all" | "mine" | "private";
tab: string;
emptyDescription: string;
showNewButton?: boolean;
}) => {
@@ -195,7 +211,7 @@ export function AgentGallery() {
<div className="flex flex-col gap-4">
{renderGrid(list)}
<Pagination
page={pages[tab]}
page={pages[tab] ?? 1}
pageSize={PAGE_SIZE}
total={total}
onPageChange={(p) => setTabPage(tab, p)}
@@ -324,7 +340,7 @@ export function AgentGallery() {
<Tabs
value={activeTab}
onValueChange={(v) => {
setActiveTab(v as "all" | "mine" | "private");
setActiveTab(v);
// Each tab starts from a clean slate: switching tabs clears the
// keyword / tag / sort / publisher filters instead of carrying them
// over. The TagSearchBar's own input box is reset via its key below.
@@ -337,9 +353,13 @@ export function AgentGallery() {
>
<div className="mx-6 mt-4 flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<TabsList className="w-auto self-start">
<TabsList className="w-auto max-w-full flex-wrap justify-start self-start">
<TabsTrigger value="mine">我的</TabsTrigger>
<TabsTrigger value="all">广场</TabsTrigger>
{squareTabs.map((sq) => (
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
{sq.name}
</TabsTrigger>
))}
{privateFeatureAvailable && (
<TabsTrigger value="private">
<LockIcon className="mr-1 h-3.5 w-3.5" />
@@ -414,15 +434,21 @@ export function AgentGallery() {
)}
</div>
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
{renderListBody({
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
tab: "all",
emptyDescription: t.agents.emptyDescription,
})}
</TabsContent>
{squareTabs.map((sq) => (
<TabsContent
key={sq.id}
value={sq.id}
className="flex-1 overflow-y-auto p-6 mt-0"
>
{renderListBody({
list: mainAgents,
total: mainTotal,
isLoading: mainLoading,
tab: sq.id,
emptyDescription: `「${sq.name}」广场暂时还没有智能体。`,
})}
</TabsContent>
))}
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
{renderListBody({
@@ -0,0 +1,120 @@
"use client";
import {
ChevronDownIcon,
ChevronUpIcon,
PlusIcon,
XIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type { AgentQuestion } from "@/core/agents";
/**
* Editable list of an agent's 常用问题 (recommended questions). Each row has a
* short `title` (the chip label) and the full `prompt` sent on click. Supports
* add / remove / inline edit / reorder (move up & down). Pure controlled
* component — persistence stays with the caller (edit dialog or detail panel).
*/
export function AgentQuestionEditor({
value,
onChange,
disabled,
}: {
value: AgentQuestion[];
onChange: (next: AgentQuestion[]) => void;
disabled?: boolean;
}) {
function update(index: number, patch: Partial<AgentQuestion>) {
const next = [...value];
next[index] = { ...next[index]!, ...patch };
onChange(next);
}
function remove(index: number) {
onChange(value.filter((_, i) => i !== index));
}
function move(index: number, delta: -1 | 1) {
const target = index + delta;
if (target < 0 || target >= value.length) return;
const next = [...value];
[next[index], next[target]] = [next[target]!, next[index]!];
onChange(next);
}
return (
<div className="space-y-3">
{value.length === 0 && (
<p className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-xs">
尚未配置常用问题,点击下方按钮添加。
</p>
)}
{value.map((item, index) => (
<div key={index} className="bg-background/60 space-y-1.5 rounded-lg border p-2.5">
<div className="flex items-center gap-1.5">
<Input
value={item.title}
disabled={disabled}
placeholder="标题(如:能蒸馏哪些人)"
onChange={(e) => update(index, { title: e.currentTarget.value })}
className="h-8 text-sm font-medium"
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={disabled || index === 0}
onClick={() => move(index, -1)}
aria-label="上移"
title="上移"
>
<ChevronUpIcon className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={disabled || index === value.length - 1}
onClick={() => move(index, 1)}
aria-label="下移"
title="下移"
>
<ChevronDownIcon className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={disabled}
onClick={() => remove(index)}
aria-label="删除问题"
title="删除问题"
>
<XIcon className="size-4" />
</Button>
</div>
<Textarea
value={item.prompt}
disabled={disabled}
placeholder="点击后实际发送的完整问题内容…"
onChange={(e) => update(index, { prompt: e.currentTarget.value })}
className="min-h-16 resize-none text-sm"
/>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
disabled={disabled}
onClick={() => onChange([...value, { title: "", prompt: "" }])}
>
<PlusIcon className="size-3.5" /> 添加问题
</Button>
</div>
);
}
@@ -0,0 +1,153 @@
"use client";
import { InboxIcon, InfoIcon, XIcon } from "lucide-react";
import { useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { Skill } from "@/core/skills";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/** Chinese display label with English-name fallback. */
export function skillDisplayLabel(skill: Skill) {
return skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name);
}
/** First character of the display label — the letter avatar on skill cards. */
export function skillInitial(skill: Skill) {
const label = skill.name_zh?.trim() || skill.name;
return label.trim().charAt(0).toUpperCase() || "?";
}
/** Human label for the skill's provenance (内置 / 用户上传 / 对话沉淀). */
export function skillSourceLabel(skill: Skill): string | null {
if (skill.builtin) return "内置";
if (skill.source === "upload") return "用户上传";
if (skill.source === "agent") return "对话沉淀";
return null;
}
/**
* The agent's enabled-skill list shown in the detail panel: one card per
* whitelisted skill name with a letter avatar, Chinese name, category/source
* badges and the short description. In editing mode each card gains a remove
* button. Skill names with no metadata in the pool (e.g. a skill that was
* deleted after being whitelisted) still render as a degraded card so the
* whitelist stays visible and removable.
*/
export function EnabledSkillList({
skillNames,
skills,
editing,
onRemove,
onViewDetail,
isLoading,
}: {
/** The agent's persisted whitelist — `agent.skills` (English names). */
skillNames: string[];
/** Full skill pool used to resolve metadata for each name. */
skills: Skill[];
editing?: boolean;
onRemove?: (name: string) => void;
onViewDetail?: (name: string) => void;
isLoading?: boolean;
}) {
const byName = useMemo(
() => new Map(skills.map((s) => [s.name, s])),
[skills],
);
if (isLoading && skillNames.length > 0) {
return (
<div className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-xs">
技能加载中…
</div>
);
}
if (skillNames.length === 0) {
return (
<div className="text-muted-foreground flex flex-col items-center gap-1.5 rounded-lg border border-dashed px-3 py-5 text-xs">
<InboxIcon className="size-5 opacity-50" />
<span>尚未启用任何技能</span>
</div>
);
}
return (
<div className="space-y-2">
{skillNames.map((name) => {
const skill = byName.get(name);
return (
<div
key={name}
className={cn(
"group/skill bg-background/60 flex items-start gap-2.5 rounded-xl border p-2.5 transition-colors",
onViewDetail && "hover:border-primary/40 hover:bg-accent/40",
)}
>
<div className="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-lg text-sm font-semibold">
{skill ? skillInitial(skill) : "?"}
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<span
className="min-w-0 truncate text-sm font-medium"
title={skill ? skillDisplayLabel(skill) : desensitize(name)}
>
{skill ? skillDisplayLabel(skill) : desensitize(name)}
</span>
{skill?.category?.trim() && (
<Badge variant="secondary" className="shrink-0 text-[10px]">
{skill.category}
</Badge>
)}
{skill && skillSourceLabel(skill) && (
<Badge variant="outline" className="shrink-0 text-[10px]">
{skillSourceLabel(skill)}
</Badge>
)}
</div>
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
{skill
? skill.description?.trim()
? desensitize(skill.description)
: "暂无描述"
: "该技能已不存在或不可用,可将其移除。"}
</p>
</div>
<div className="flex shrink-0 items-center gap-0.5">
{skill && onViewDetail && (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground size-7"
onClick={() => onViewDetail(name)}
title="查看技能详情"
aria-label="查看技能详情"
>
<InfoIcon className="size-3.5" />
</Button>
)}
{editing && onRemove && (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive size-7"
onClick={() => onRemove(name)}
title="移除技能"
aria-label="移除技能"
>
<XIcon className="size-3.5" />
</Button>
)}
</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,436 @@
"use client";
import {
CheckIcon,
InfoIcon,
Loader2Icon,
PlusIcon,
SearchIcon,
UploadIcon,
XIcon,
} from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import {
skillDisplayLabel,
skillInitial,
skillSourceLabel,
} from "@/components/workspace/agents/enabled-skill-list";
import type { Skill } from "@/core/skills";
import {
useInstallSkillUpload,
useValidateSkillUpload,
} from "@/core/skills/hooks";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
const ALL_CATEGORIES = "__all__";
type SourceFilter = "all" | "builtin" | "upload" | "agent";
const SOURCE_OPTIONS: { value: SourceFilter; label: string }[] = [
{ value: "all", label: "全部来源" },
{ value: "builtin", label: "内置技能" },
{ value: "upload", label: "用户上传" },
{ value: "agent", label: "对话沉淀" },
];
function matchesSource(skill: Skill, source: SourceFilter) {
switch (source) {
case "all":
return true;
case "builtin":
return !!skill.builtin;
case "upload":
return skill.source === "upload";
case "agent":
return skill.source === "agent";
}
}
function matchesQuery(skill: Skill, q: string) {
if (!q) return true;
return (
skill.name.toLowerCase().includes(q) ||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
(skill.description ?? "").toLowerCase().includes(q)
);
}
/**
* 技能选择器 — a secondary right-hand drawer opened from the agent detail
* panel's "添加技能" button. Search (中英文名称 + 描述), category tabs, source
* filter (内置 / 用户上传 / 对话沉淀), skill-pack upload, card grid with
* enable/disable switches, bulk add-filtered / clear-selected, and a footer
* showing the live selected count. Selection is controlled by the caller (the
* panel's local draft) — nothing here persists until the panel saves.
*/
export function SkillPickerSheet({
open,
onOpenChange,
skills,
isLoading,
selected,
onChange,
onViewDetail,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Selectable pool (caller pre-filters by `enabled`). */
skills: Skill[];
isLoading?: boolean;
/** Currently selected skill names — the draft `skills: string[]`. */
selected: string[];
onChange: (next: string[]) => void;
onViewDetail?: (name: string) => void;
}) {
const [search, setSearch] = useState("");
const [category, setCategory] = useState<string>(ALL_CATEGORIES);
const [source, setSource] = useState<SourceFilter>("all");
const fileInputRef = useRef<HTMLInputElement | null>(null);
const validateUpload = useValidateSkillUpload();
const installUpload = useInstallSkillUpload();
const isUploading = validateUpload.isPending || installUpload.isPending;
const selectedSet = useMemo(() => new Set(selected), [selected]);
const categories = useMemo(() => {
const set = new Set<string>();
for (const skill of skills) {
if (skill.category?.trim()) set.add(skill.category.trim());
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [skills]);
const query = search.trim().toLowerCase();
const filtered = useMemo(
() =>
skills.filter(
(skill) =>
matchesQuery(skill, query) &&
matchesSource(skill, source) &&
(category === ALL_CATEGORIES || skill.category?.trim() === category),
),
[skills, query, source, category],
);
const filteredUnselected = useMemo(
() => filtered.filter((skill) => !selectedSet.has(skill.name)),
[filtered, selectedSet],
);
function toggle(name: string) {
if (selectedSet.has(name)) {
onChange(selected.filter((n) => n !== name));
} else {
onChange([...selected, name]);
}
}
function addAllFiltered() {
if (filteredUnselected.length === 0) return;
onChange([...selected, ...filteredUnselected.map((s) => s.name)]);
}
function clearSelected() {
if (selected.length === 0) return;
onChange([]);
}
async function handleUploadFile(file: File) {
const lower = file.name.toLowerCase();
if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) {
toast.error("仅支持 .zip / .skill 技能包");
return;
}
try {
const validation = await validateUpload.mutateAsync(file);
if (validation.conflict) {
toast.warning(
`已存在同名技能「${validation.skill_name}」,请到「技能」设置页上传以选择是否覆盖。`,
);
return;
}
const result = await installUpload.mutateAsync({
file,
targetCategory: "custom",
});
// Auto-select the freshly installed skill so the user doesn't have to
// hunt for it in the refreshed pool.
if (!selectedSet.has(result.skill_name)) {
onChange([...selected, result.skill_name]);
}
toast.success(`技能「${result.skill_name}」已安装并加入已选`);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 p-0 sm:max-w-xl"
>
<SheetHeader className="border-b px-5 pb-3 pt-4">
<SheetTitle>添加技能</SheetTitle>
<SheetDescription>
为该智能体启用技能。修改后需在右侧面板点击「保存」才会生效。
</SheetDescription>
</SheetHeader>
{/* Toolbar: search / source / upload */}
<div className="space-y-2.5 border-b px-5 py-3">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2" />
<Input
placeholder="搜索技能(中英文名称、描述)…"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
className="h-8 pl-8 text-xs"
/>
</div>
<Select
value={source}
onValueChange={(v) => setSource(v as SourceFilter)}
>
<SelectTrigger className="h-8 w-28 shrink-0 px-2 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{SOURCE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
size="sm"
variant="outline"
className="h-8 shrink-0 text-xs"
disabled={isUploading}
onClick={() => fileInputRef.current?.click()}
title="上传 .zip / .skill 技能包"
>
{isUploading ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<UploadIcon className="size-3.5" />
)}
上传技能
</Button>
<input
ref={fileInputRef}
type="file"
accept=".zip,.skill"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleUploadFile(file);
}}
/>
</div>
{/* Category tabs */}
{categories.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{[ALL_CATEGORIES, ...categories].map((c) => (
<button
key={c}
type="button"
onClick={() => setCategory(c)}
className={cn(
"rounded-full border px-2.5 py-1 text-xs transition-colors",
category === c
? "border-primary/50 bg-primary/10 text-primary font-medium"
: "border-border text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
{c === ALL_CATEGORIES ? "全部" : c}
</button>
))}
</div>
)}
{/* Bulk actions */}
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs tabular-nums">
{filtered.length} 个匹配技能
</span>
<div className="flex items-center gap-1.5">
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-xs"
disabled={filteredUnselected.length === 0}
onClick={addAllFiltered}
>
<PlusIcon className="size-3.5" />
添加当前筛选结果
{filteredUnselected.length > 0
? `(${filteredUnselected.length})`
: ""}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-muted-foreground h-7 text-xs"
disabled={selected.length === 0}
onClick={clearSelected}
>
<XIcon className="size-3.5" />
清空已选
</Button>
</div>
</div>
</div>
{/* Skill card grid */}
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-3">
{isLoading ? (
<div className="text-muted-foreground flex h-32 items-center justify-center gap-2 text-sm">
<Loader2Icon className="size-4 animate-spin" />
技能加载中…
</div>
) : filtered.length === 0 ? (
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center gap-1.5 text-sm">
<span>没有匹配的技能</span>
{(query || category !== ALL_CATEGORIES || source !== "all") && (
<span className="text-xs">试试调整搜索词或筛选条件</span>
)}
</div>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{filtered.map((skill) => {
const isSelected = selectedSet.has(skill.name);
return (
<div
key={skill.name}
role="button"
tabIndex={0}
onClick={() => toggle(skill.name)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggle(skill.name);
}
}}
title={isSelected ? "点击禁用" : "点击启用"}
className={cn(
"group/card flex cursor-pointer flex-col gap-2 rounded-xl border p-3 transition-colors",
isSelected
? "border-primary/50 bg-primary/5 ring-primary/15 ring-1"
: "bg-background/60 hover:border-primary/40 hover:bg-accent/40",
)}
>
<div className="flex items-start gap-2.5">
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold",
isSelected
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground",
)}
>
{skillInitial(skill)}
</div>
<div className="min-w-0 flex-1">
<div
className="truncate text-sm font-medium"
title={skillDisplayLabel(skill)}
>
{skillDisplayLabel(skill)}
</div>
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
{skill.description?.trim()
? desensitize(skill.description)
: "暂无描述"}
</p>
</div>
<Switch
checked={isSelected}
onCheckedChange={() => toggle(skill.name)}
onClick={(e) => e.stopPropagation()}
aria-label={isSelected ? "禁用技能" : "启用技能"}
className="shrink-0"
/>
</div>
<div className="flex items-center gap-1.5">
{skill.category?.trim() && (
<Badge variant="secondary" className="text-[10px]">
{skill.category}
</Badge>
)}
{skillSourceLabel(skill) && (
<Badge variant="outline" className="text-[10px]">
{skillSourceLabel(skill)}
</Badge>
)}
{isSelected && (
<Badge className="bg-primary/15 text-primary hover:bg-primary/15 text-[10px]">
<CheckIcon className="size-2.5" />
已启用
</Badge>
)}
{onViewDetail && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onViewDetail(skill.name);
}}
title="查看技能详情"
className="text-muted-foreground hover:text-foreground ml-auto opacity-60 transition-opacity group-hover/card:opacity-100"
>
<InfoIcon className="size-3.5" />
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{/* Footer: selected count + done */}
<div className="flex shrink-0 items-center justify-between border-t px-5 py-3">
<span className="text-sm">
已选 <span className="text-primary font-semibold tabular-nums">{selected.length}</span> 个技能
</span>
<Button size="sm" onClick={() => onOpenChange(false)}>
完成
</Button>
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,429 @@
"use client";
import {
ChevronLeftIcon,
ChevronRightIcon,
InboxIcon,
InfoIcon,
PlusIcon,
SearchIcon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { Skill } from "@/core/skills";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
const PAGE_SIZE = 5;
const ALL_CATEGORIES = "__all__";
interface SkillTransferSelectorProps {
/** Full pool of selectable skills (caller pre-filters by `enabled`). */
skills: Skill[];
/** Currently selected skill names — the persisted `skills: string[]`. */
selected: string[];
/** Emits the next selection (always a `string[]`, `[]` when empty). */
onChange: (next: string[]) => void;
isLoading?: boolean;
/** Open the skill detail dialog for a given skill name. */
onViewDetail?: (name: string) => void;
}
function skillLabel(skill: Skill) {
return skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name);
}
function matchesQuery(skill: Skill, q: string) {
if (!q) return true;
return (
skill.name.toLowerCase().includes(q) ||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
(skill.description ?? "").toLowerCase().includes(q)
);
}
export function SkillTransferSelector({
skills,
selected,
onChange,
isLoading,
onViewDetail,
}: SkillTransferSelectorProps) {
const [search, setSearch] = useState("");
const [category, setCategory] = useState<string>(ALL_CATEGORIES);
const [availablePage, setAvailablePage] = useState(0);
const [selectedPage, setSelectedPage] = useState(0);
const selectedSet = useMemo(() => new Set(selected), [selected]);
// Distinct categories for the filter dropdown (skip blank ones).
const categories = useMemo(() => {
const set = new Set<string>();
for (const skill of skills) {
if (skill.category?.trim()) set.add(skill.category.trim());
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [skills]);
const query = search.trim().toLowerCase();
// Left pane: unselected skills, filtered by search + category.
const availableSkills = useMemo(() => {
return skills.filter(
(skill) =>
!selectedSet.has(skill.name) &&
matchesQuery(skill, query) &&
(category === ALL_CATEGORIES || skill.category?.trim() === category),
);
}, [skills, selectedSet, query, category]);
// Right pane: selected skills, filtered by search only (category filtering
// the chosen set would just hide active selections, which is confusing).
const selectedSkills = useMemo(() => {
const byName = new Map(skills.map((s) => [s.name, s]));
return selected
.map((name) => byName.get(name))
.filter((s): s is Skill => Boolean(s) && matchesQuery(s!, query));
}, [skills, selected, query]);
// Clamp pagination whenever the underlying lists shrink (search/category
// changes, items moved between panes).
const availablePages = Math.max(1, Math.ceil(availableSkills.length / PAGE_SIZE));
const selectedPages = Math.max(1, Math.ceil(selectedSkills.length / PAGE_SIZE));
useEffect(() => {
if (availablePage > availablePages - 1) setAvailablePage(availablePages - 1);
}, [availablePage, availablePages]);
useEffect(() => {
if (selectedPage > selectedPages - 1) setSelectedPage(selectedPages - 1);
}, [selectedPage, selectedPages]);
const availableView = availableSkills.slice(
availablePage * PAGE_SIZE,
availablePage * PAGE_SIZE + PAGE_SIZE,
);
const selectedView = selectedSkills.slice(
selectedPage * PAGE_SIZE,
selectedPage * PAGE_SIZE + PAGE_SIZE,
);
function add(name: string) {
if (selectedSet.has(name)) return;
onChange([...selected, name]);
}
function remove(name: string) {
onChange(selected.filter((n) => n !== name));
}
function addAllFiltered() {
if (availableSkills.length === 0) return;
const merged = [...selected];
for (const skill of availableSkills) {
if (!selectedSet.has(skill.name)) merged.push(skill.name);
}
onChange(merged);
}
function clearSelected() {
if (selected.length === 0) return;
onChange([]);
}
if (isLoading) {
return (
<div className="text-muted-foreground rounded-md border p-4 text-sm dark:border-[#1B477E] dark:bg-[#022248]">
技能加载中...
</div>
);
}
if (skills.length === 0) {
return (
<div className="text-muted-foreground rounded-md border p-4 text-sm dark:border-[#1B477E] dark:bg-[#022248]">
当前没有可用技能。
</div>
);
}
return (
<div className="space-y-2.5">
{/* Toolbar: search + category filter */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
<Input
placeholder="搜索全部技能(中英文名称、描述)…"
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
setAvailablePage(0);
setSelectedPage(0);
}}
className="h-8 pl-7 text-xs"
/>
</div>
{categories.length > 0 && (
<Select
value={category}
onValueChange={(v) => {
setCategory(v);
setAvailablePage(0);
}}
>
<SelectTrigger
className={cn(
"border-input bg-background h-8 w-32 px-2 text-xs shadow-xs outline-none",
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:hover:bg-[#022248]",
"[&_svg]:dark:text-[#AACEE2]",
)}
>
<SelectValue placeholder="全部分类" />
</SelectTrigger>
<SelectContent
position="popper"
className="dark:border-[#1B477E] dark:bg-[#022248]"
>
<SelectItem
value={ALL_CATEGORIES}
className="dark:text-[#AACEFF] dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white"
>
全部分类
</SelectItem>
{categories.map((c) => (
<SelectItem
key={c}
value={c}
className="dark:text-[#AACEFF] dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white"
>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Bulk actions */}
<div className="flex items-center justify-end gap-2">
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-xs"
disabled={availableSkills.length === 0}
onClick={addAllFiltered}
>
<PlusIcon className="size-3.5" />
添加当前搜索结果{availableSkills.length > 0 ? `(${availableSkills.length})` : ""}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-muted-foreground h-7 text-xs"
disabled={selected.length === 0}
onClick={clearSelected}
>
<XIcon className="size-3.5" />
清空已选
</Button>
</div>
{/* Two-column transfer panes */}
<div className="grid grid-cols-2 gap-3">
<SkillPane
title="可用技能"
count={availableSkills.length}
items={availableView}
variant="available"
page={availablePage}
totalPages={availablePages}
onPageChange={setAvailablePage}
onPick={add}
onViewDetail={onViewDetail}
emptyHint={query || category !== ALL_CATEGORIES ? "没有匹配的技能" : "已全部启用"}
/>
<SkillPane
title="已启用技能"
count={selectedSkills.length}
items={selectedView}
variant="selected"
page={selectedPage}
totalPages={selectedPages}
onPageChange={setSelectedPage}
onPick={remove}
onViewDetail={onViewDetail}
emptyHint={query ? "没有匹配的已选技能" : "尚未启用任何技能"}
/>
</div>
</div>
);
}
interface SkillPaneProps {
title: string;
count: number;
items: Skill[];
variant: "available" | "selected";
page: number;
totalPages: number;
onPageChange: (page: number) => void;
onPick: (name: string) => void;
onViewDetail?: (name: string) => void;
emptyHint: string;
}
function SkillPane({
title,
count,
items,
variant,
page,
totalPages,
onPageChange,
onPick,
onViewDetail,
emptyHint,
}: SkillPaneProps) {
const isSelected = variant === "selected";
return (
<div className="flex flex-col overflow-hidden rounded-md border dark:border-[#1B477E] dark:bg-[#022248]">
<div className="bg-muted/40 flex items-center justify-between border-b px-2.5 py-1.5 text-xs font-medium dark:border-[#1B477E] dark:bg-[#02305f]">
<span>{title}</span>
<span className="text-muted-foreground tabular-nums">{count}</span>
</div>
<div className="h-[240px] space-y-1.5 overflow-y-auto p-2">
{items.length === 0 ? (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-1.5 text-xs">
<InboxIcon className="size-5 opacity-50" />
<span>{emptyHint}</span>
</div>
) : (
items.map((skill) => (
<SkillItemCard
key={skill.name}
skill={skill}
isSelected={isSelected}
onPick={() => onPick(skill.name)}
onViewDetail={
onViewDetail ? () => onViewDetail(skill.name) : undefined
}
/>
))
)}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t px-2 py-1 text-xs dark:border-[#1B477E]">
<Button
type="button"
size="icon-sm"
variant="ghost"
className="size-6"
disabled={page <= 0}
onClick={() => onPageChange(page - 1)}
>
<ChevronLeftIcon className="size-3.5" />
</Button>
<span className="text-muted-foreground tabular-nums">
{page + 1}/{totalPages}
</span>
<Button
type="button"
size="icon-sm"
variant="ghost"
className="size-6"
disabled={page >= totalPages - 1}
onClick={() => onPageChange(page + 1)}
>
<ChevronRightIcon className="size-3.5" />
</Button>
</div>
)}
</div>
);
}
interface SkillItemCardProps {
skill: Skill;
isSelected: boolean;
onPick: () => void;
onViewDetail?: () => void;
}
function SkillItemCard({
skill,
isSelected,
onPick,
onViewDetail,
}: SkillItemCardProps) {
return (
<div
role="button"
tabIndex={0}
onClick={onPick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onPick();
}
}}
title={isSelected ? "点击移除" : "点击添加"}
className={cn(
"group/item relative cursor-pointer rounded-md border px-2.5 py-1.5 pr-7 transition-colors",
isSelected
? "border-primary/60 bg-primary/5 hover:border-primary dark:border-[#3B82F6] dark:bg-[#0a3568]"
: "hover:border-primary/40 hover:bg-accent/40 dark:border-[#213D5E] dark:bg-[#02244C] dark:hover:border-[#3B82F6]/60",
)}
>
<div className="flex items-center gap-1.5">
<span
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-full text-[10px]",
isSelected
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground group-hover/item:bg-primary/15 group-hover/item:text-primary",
)}
>
{isSelected ? (
<XIcon className="size-3" />
) : (
<PlusIcon className="size-3" />
)}
</span>
<div className="min-w-0 flex-1">
<div
className="truncate text-xs font-medium"
title={skillLabel(skill)}
>
{skillLabel(skill)}
</div>
{skill.description?.trim() && (
<div className="text-muted-foreground truncate text-[11px]">
{desensitize(skill.description)}
</div>
)}
</div>
</div>
{onViewDetail && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onViewDetail();
}}
title="查看技能详情"
className="text-muted-foreground hover:text-foreground absolute right-1.5 top-1.5 opacity-60 transition-opacity group-hover/item:opacity-100"
>
<InfoIcon className="size-3.5" />
</button>
)}
</div>
);
}
@@ -86,8 +86,8 @@ import {
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import { useUserPreferences } from "@/core/user-preferences";
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import { useUserPreferences } from "@/core/user-preferences";
import {
isThreadRagModeLocked,
lockThreadRagMode,
@@ -133,6 +133,7 @@ export function InputBox({
promptPrefix,
agentSelector,
assistantId,
hideAdminRecommendedQuestions,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
@@ -149,6 +150,9 @@ export function InputBox({
};
extraHeader?: React.ReactNode;
isNewThread?: boolean;
/** Suppress the global admin "recommended questions" row. The agent chat page
* sets this so only the agent's own common questions surface on its home. */
hideAdminRecommendedQuestions?: boolean;
threadId: string;
initialValue?: string;
onContextChange?: (
@@ -205,11 +209,12 @@ export function InputBox({
null,
);
const { questions: recommendedQuestions } = useRecommendedQuestions();
const userPreferences = useUserPreferences();
const showAdminRecommendedQuestions =
!userPreferences.hide_admin_prompt_prefix &&
recommendedQuestions.length > 0;
const { questions: recommendedQuestions } = useRecommendedQuestions();
const userPreferences = useUserPreferences();
const showAdminRecommendedQuestions =
!hideAdminRecommendedQuestions &&
!userPreferences.hide_admin_prompt_prefix &&
recommendedQuestions.length > 0;
useEffect(() => {
if (models.length === 0) {
@@ -516,7 +521,7 @@ export function InputBox({
</div>
)}
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
{recommendedQuestions.map((q) => (
<button
@@ -358,6 +358,20 @@ function MessageImage({
);
}
// While a notebook citation marker is still streaming in (e.g. "[[cite:64" or
// "[[cite:101]"), Streamdown's parseIncompleteMarkdown treats the trailing
// unclosed "[" as an incomplete link and rewrites it to
// "[[cite:64](streamdown:incomplete-link)", which the rehypeCitations regex can
// no longer match — so the citation flickers as a broken link mid-stream. We
// strip the half-arrived marker so it stays hidden until the closing "]]"
// arrives, at which point rehypeCitations renders it. A complete
// "[[cite:101]]" has no trailing incomplete marker, so this is a no-op.
const INCOMPLETE_CITE_TAIL = /\[\[(?:c(?:i(?:t(?:e(?::\d*\]?)?)?)?)?)?$/;
function stripIncompleteCitationMarker(content: string): string {
return content.replace(INCOMPLETE_CITE_TAIL, '');
}
function MessageContent_({
className,
message,
@@ -460,7 +474,8 @@ function MessageContent_({
? stripArtifactTag(stripUploadedFilesTag(rawContent))
: '';
}
return applyInlineCitations(rawContent ?? '', displaySources);
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
return stripIncompleteCitationMarker(withCitations);
}, [rawContent, isHuman, displaySources]);
const parsedHumanContent = useMemo(
() =>
@@ -0,0 +1,114 @@
"use client";
import { CheckIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { PublishSquare } from "@/core/system-settings/api";
import { cn } from "@/lib/utils";
interface PublishSquarePickerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
squares: PublishSquare[];
/** Pre-selected square id (current square, or the configured default). */
defaultSquareId?: string;
/** Called with the chosen square id when the user confirms. */
onConfirm: (squareId: string) => void;
loading?: boolean;
title?: string;
description?: string;
}
/** A "publish to which square (发布广场)?" chooser. Squares are rendered as a
* selectable list (not a dropdown) so the choice is explicit. */
export function PublishSquarePickerDialog({
open,
onOpenChange,
squares,
defaultSquareId,
onConfirm,
loading,
title = "选择发布广场",
description = "请选择要把它发布到哪个广场。每个项目只能归属一个广场,不会重复出现。",
}: PublishSquarePickerDialogProps) {
const [selected, setSelected] = useState<string>(
defaultSquareId || squares[0]?.id || "",
);
// Re-seed the selection whenever the dialog (re)opens or the default changes.
useEffect(() => {
if (open) {
setSelected(defaultSquareId || squares[0]?.id || "");
}
}, [open, defaultSquareId, squares]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="max-h-[50vh] space-y-1.5 overflow-y-auto py-1">
{squares.map((sq) => {
const active = selected === sq.id;
return (
<button
key={sq.id}
type="button"
onClick={() => setSelected(sq.id)}
className={cn(
"flex w-full items-start gap-2 rounded-md border px-3 py-2 text-left transition-colors",
active
? "border-primary bg-primary/5 dark:border-[#3B82F6] dark:bg-[#0a3568]"
: "hover:border-primary/40 hover:bg-accent/40 dark:border-[#213D5E]",
)}
>
<span
className={cn(
"mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border",
active
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/40",
)}
>
{active && <CheckIcon className="size-3" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{sq.name}
</span>
{sq.description?.trim() && (
<span className="text-muted-foreground block truncate text-xs">
{sq.description}
</span>
)}
</span>
</button>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
取消
</Button>
<Button
onClick={() => onConfirm(selected)}
disabled={loading || !selected}
>
{loading ? "发布中…" : "确认发布"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -31,6 +31,7 @@ import {
import { cn } from "@/lib/utils";
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
import RecommendedQuestionsPage from "@/pages/RecommendedQuestionsPage";
@@ -349,6 +350,8 @@ export function AppearanceSettingsPage() {
<PromptPrefixSettingsPage embedded />
<Separator />
<PrivateSquareSettingsPage embedded />
<Separator />
<PublishSquaresSettingsPage embedded />
</>
)}
</div>
@@ -58,6 +58,7 @@ import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
@@ -81,7 +82,7 @@ import {
useSkills,
useValidateSkillUpload,
} from "@/core/skills/hooks";
import { useViewMode } from "@/core/system-settings/hooks";
import { usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
import type { Skill } from "@/core/skills/type";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
@@ -164,6 +165,10 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
null,
);
const [viewMode, setViewMode] = useViewMode("skill");
const { data: publishSquares } = usePublishSquares();
// Each configured publish square (发布广场) becomes its own browse tab; the
// backend always returns at least one (the built-in default).
const squareTabs = publishSquares?.squares ?? [];
const reconcileMutation = useReconcileCustomSkills();
const fillNamesMutation = useGenerateMissingSkillNameZh();
// Optional model override for the admin "一键补充中文名" action (null = default).
@@ -214,14 +219,25 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
};
}, [skills, currentUserId]);
// "mine" tab shows my own skills; every other tab value is a square id whose
// public skills come from the published pool.
const isSquareTab = filter !== "mine";
const scopedSkills = filter === "mine" ? activeMine : activeAll;
const filteredSkills = useMemo(
() =>
publisher
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
: scopedSkills,
[scopedSkills, publisher],
);
const filteredSkills = useMemo(() => {
let list = publisher
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
: scopedSkills;
// On a square tab, keep only that square's skills. The default square also
// sweeps in legacy/unassigned published skills (square_id === "").
if (isSquareTab) {
const isDefault = filter === publishSquares?.default_square_id;
list = list.filter((s) => {
const sid = s.square_id ?? "";
return sid === filter || (isDefault && !sid);
});
}
return list;
}, [scopedSkills, publisher, filter, isSquareTab, publishSquares?.default_square_id]);
const orderedSkills = useMemo(
() =>
filteredSkills
@@ -247,6 +263,18 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
useEffect(() => {
setPage(1);
}, [search, tagIds, sort, publisher?.id, filter]);
// If the active square tab disappears (admin removed that square), fall back
// to "我的" so the page never points at a dead tab.
useEffect(() => {
if (
filter !== "mine" &&
squareTabs.length > 0 &&
!squareTabs.some((sq) => sq.id === filter)
) {
setFilter("mine");
}
}, [filter, squareTabs]);
const pagedSkills = useMemo(
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[orderedSkills, page],
@@ -304,9 +332,13 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<header className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<Tabs value={filter} onValueChange={setFilter}>
<TabsList variant="line">
<TabsList variant="line" className="flex-wrap">
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
{squareTabs.map((sq) => (
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
{sq.name}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Button
@@ -544,6 +576,7 @@ export function SkillRow({
const isCard = layout === "card";
const { t } = useI18n();
const setPublished = useSetSkillPublished();
const { data: cardPublishSquares } = usePublishSquares();
const setPinned = useSetSkillPinned();
const setFeatured = useSetSkillFeatured();
const deleteSkill = useDeleteCustomSkill();
@@ -553,6 +586,7 @@ export function SkillRow({
const [detailOpen, setDetailOpen] = useState(false);
const [duplicateOpen, setDuplicateOpen] = useState(false);
const [duplicateName, setDuplicateName] = useState("");
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
const isCustom = skill.category === "custom";
// Built-in flag falls back to "no DB owner" so legacy responses without
@@ -566,6 +600,12 @@ export function SkillRow({
const canTogglePublish = canManage && !isBuiltin;
async function handleTogglePublished(next: boolean) {
// Publishing: prompt for which square to publish into (when configured).
// Unpublishing: just flip it off.
if (next && (cardPublishSquares?.squares.length ?? 0) > 0) {
setPublishPickerOpen(true);
return;
}
try {
await setPublished.mutateAsync({ skillName: skill.name, published: next });
toast.success(
@@ -578,6 +618,24 @@ export function SkillRow({
}
}
// Publish the skill into a chosen square. Each skill belongs to exactly one
// square; re-publishing with a new square_id relocates rather than duplicates.
async function handlePublishToSquare(squareId: string) {
try {
await setPublished.mutateAsync({
skillName: skill.name,
published: true,
squareId,
});
const sqName =
cardPublishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场";
toast.success(`已发布到「${sqName}」`);
setPublishPickerOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete() {
try {
await deleteSkill.mutateAsync(skill.name);
@@ -979,6 +1037,16 @@ export function SkillRow({
open={detailOpen}
onOpenChange={setDetailOpen}
/>
<PublishSquarePickerDialog
open={publishPickerOpen}
onOpenChange={setPublishPickerOpen}
squares={cardPublishSquares?.squares ?? []}
defaultSquareId={skill.square_id || cardPublishSquares?.default_square_id}
loading={setPublished.isPending}
onConfirm={(squareId) => void handlePublishToSquare(squareId)}
description="请选择要把该技能发布到哪个广场。每个技能只能归属一个广场,不会重复出现。"
/>
</>
);
}
@@ -273,9 +273,24 @@ function StatRow({
);
}
/** Select 不允许空字符串 value,用此哨兵代表"用主模型"(对应 model_name=null)。 */
interface CuratorDraft {
interval_hours: number;
stale_after_days: number;
archive_after_days: number;
model_name: string | null;
}
const DEFAULT_MODEL_VALUE = "__default__";
function toDraft(config: CuratorConfig): CuratorDraft {
return {
interval_hours: config.curator.interval_hours,
stale_after_days: config.curator.stale_after_days,
archive_after_days: config.curator.archive_after_days,
model_name: config.curator.model_name,
};
}
function ConfigCard() {
const { t } = useI18n();
const tt = t.settings.skillEvolution.config;
@@ -283,10 +298,10 @@ function ConfigCard() {
const updateConfig = useUpdateCuratorConfig();
const { models } = useModels();
const [draft, setDraft] = useState<CuratorConfig | null>(null);
const [draft, setDraft] = useState<CuratorDraft | null>(null);
useEffect(() => {
if (config && !draft) {
setDraft(config);
setDraft(toDraft(config));
}
}, [config, draft]);
@@ -305,14 +320,12 @@ function ConfigCard() {
);
}
const isDirty =
!!config &&
JSON.stringify(draft) !== JSON.stringify(config);
const original = config ? toDraft(config) : null;
const isDirty = !!original && JSON.stringify(draft) !== JSON.stringify(original);
const handleSave = async () => {
try {
const updated = await updateConfig.mutateAsync(draft);
setDraft(updated);
await updateConfig.mutateAsync(draft);
toast.success(tt.saveSuccess);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
@@ -326,29 +339,6 @@ function ConfigCard() {
<CardDescription>{tt.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<FieldRow
label={tt.enabledLabel}
helper={tt.enabledHelper}
>
<Switch
checked={draft.enabled}
onCheckedChange={(v) => setDraft({ ...draft, enabled: v })}
/>
</FieldRow>
<FieldRow
label={tt.curatorEnabledLabel}
helper={tt.curatorEnabledHelper}
>
<Switch
checked={draft.curator.enabled}
onCheckedChange={(v) =>
setDraft({
...draft,
curator: { ...draft.curator, enabled: v },
})
}
/>
</FieldRow>
<FieldRow
label={tt.intervalHoursLabel}
helper={tt.intervalHoursHelper}
@@ -357,15 +347,9 @@ function ConfigCard() {
type="number"
min={1}
className="max-w-[10rem]"
value={draft.curator.interval_hours}
value={draft.interval_hours}
onChange={(e) =>
setDraft({
...draft,
curator: {
...draft.curator,
interval_hours: Math.max(1, Number(e.target.value) || 1),
},
})
setDraft({ ...draft, interval_hours: Math.max(1, Number(e.target.value) || 1) })
}
/>
</FieldRow>
@@ -377,15 +361,9 @@ function ConfigCard() {
type="number"
min={1}
className="max-w-[10rem]"
value={draft.curator.stale_after_days}
value={draft.stale_after_days}
onChange={(e) =>
setDraft({
...draft,
curator: {
...draft.curator,
stale_after_days: Math.max(1, Number(e.target.value) || 1),
},
})
setDraft({ ...draft, stale_after_days: Math.max(1, Number(e.target.value) || 1) })
}
/>
</FieldRow>
@@ -397,15 +375,9 @@ function ConfigCard() {
type="number"
min={1}
className="max-w-[10rem]"
value={draft.curator.archive_after_days}
value={draft.archive_after_days}
onChange={(e) =>
setDraft({
...draft,
curator: {
...draft.curator,
archive_after_days: Math.max(1, Number(e.target.value) || 1),
},
})
setDraft({ ...draft, archive_after_days: Math.max(1, Number(e.target.value) || 1) })
}
/>
</FieldRow>
@@ -414,15 +386,9 @@ function ConfigCard() {
helper={tt.curatorModelHelper}
>
<Select
value={draft.curator.model_name ?? DEFAULT_MODEL_VALUE}
value={draft.model_name ?? DEFAULT_MODEL_VALUE}
onValueChange={(v) =>
setDraft({
...draft,
curator: {
...draft.curator,
model_name: v === DEFAULT_MODEL_VALUE ? null : v,
},
})
setDraft({ ...draft, model_name: v === DEFAULT_MODEL_VALUE ? null : v })
}
>
<SelectTrigger
@@ -451,7 +417,7 @@ function ConfigCard() {
variant="outline"
size="sm"
disabled={!isDirty || updateConfig.isPending}
onClick={() => config && setDraft(config)}
onClick={() => original && setDraft(original)}
>
{tt.reset}
</Button>
@@ -19,9 +19,6 @@ import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSq
import { SidebarCollapseButton } from "./workspace-header";
import { WorkspaceNavStudio } from "./workspace-nav-studio";
import { cn } from "@/lib/utils";
import { env } from "@/env";
const studioEnabled = env.STUDIO_ENABLED?.toLowerCase() === "true";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
const NAV_ITEM_CLASS =
@@ -180,7 +177,7 @@ export function WorkspaceNavChatList({
)}
</SidebarMenu>
</SidebarGroup>
{!showCollapseTrigger && studioEnabled && <WorkspaceNavStudio />}
{!showCollapseTrigger && <WorkspaceNavStudio />}
</>
);
}
@@ -65,7 +65,8 @@ export function WorkspaceNavStudio() {
}
};
if (isError || (!isLoading && notebooks.length === 0)) {
// 加载中、token 失败、空列表都不渲染整段,避免在 token 未确认前先闪一段工作室 UI 再消失。
if (isLoading || isError || notebooks.length === 0) {
return null;
}
@@ -90,44 +91,38 @@ export function WorkspaceNavStudio() {
<CollapsibleContent>
<SidebarMenu className="mt-0.5 gap-0.5">
{/* 新建笔记本 */}
{/* 新建空间 */}
<SidebarMenuItem>
<SidebarMenuButton
className={cn(NAV_ITEM_CLASS)}
onClick={() => { setDialogOpen(true); setError(""); setName(""); }}
>
<Plus className="shrink-0" />
<span>新建笔记本</span>
<span>新建空间</span>
</SidebarMenuButton>
</SidebarMenuItem>
{/* 笔记本列表(最多展示 SIDEBAR_PREVIEW_COUNT 条) */}
{isLoading
? Array.from({ length: 3 }).map((_, i) => (
<SidebarMenuItem key={i}>
<div className="mx-3 my-1 h-5 animate-pulse rounded-md bg-sidebar-accent/40" />
</SidebarMenuItem>
))
: preview.map((nb) => (
<SidebarMenuItem key={nb.id}>
<SidebarMenuButton
asChild
isActive={pathname === `/page/workspace/studio/notebooks/${nb.id}`}
className={cn(NAV_ITEM_CLASS)}
>
<Link
to={`/page/workspace/studio/notebooks/${nb.id}`}
title={nb.description || nb.name}
>
<BookOpen className="shrink-0 size-4" />
<span className="truncate">{nb.name}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
{/* 空间列表(最多展示 SIDEBAR_PREVIEW_COUNT 条) */}
{preview.map((nb) => (
<SidebarMenuItem key={nb.id}>
<SidebarMenuButton
asChild
isActive={pathname === `/page/workspace/studio/notebooks/${nb.id}`}
className={cn(NAV_ITEM_CLASS)}
>
<Link
to={`/page/workspace/studio/notebooks/${nb.id}`}
title={nb.description || nb.name}
>
<BookOpen className="shrink-0 size-4" />
<span className="truncate">{nb.name}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
{/* 所有笔记本 */}
{!isLoading && notebooks.length > 0 && (
{/* 所有空间 */}
{notebooks.length > 0 && (
<SidebarMenuItem>
<SidebarMenuButton
asChild
@@ -135,8 +130,8 @@ export function WorkspaceNavStudio() {
className={cn(NAV_ITEM_CLASS)}
>
<Link to="/page/workspace/studio/notebooks">
<span className="mr-0.5 text-base leading-none">···</span>
<span>所有笔记本</span>
{/* <span className="mr-0.5 text-base leading-none">···</span> */}
<span>所有空间</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -149,7 +144,7 @@ export function WorkspaceNavStudio() {
<Dialog open={dialogOpen} onOpenChange={(v) => !creating && setDialogOpen(v)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>新建笔记本</DialogTitle>
<DialogTitle>新建空间</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2">
<input
@@ -157,7 +152,7 @@ export function WorkspaceNavStudio() {
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }}
placeholder="笔记本名称"
placeholder="空间名称"
disabled={creating}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
/>
-12
View File
@@ -93,15 +93,6 @@ export interface AdminLeaderboardDimension {
last_seen_at: string;
}
export interface AdminLeaderboardQuestion {
text: string;
user_id: string;
email: string;
thread_id: string;
run_id: string;
asked_at: string;
}
export interface AdminLeaderboardTrendPoint {
date: string;
runs: number;
@@ -138,8 +129,6 @@ export interface AdminLeaderboardResponse {
models: AdminLeaderboardDimension[];
agents: AdminLeaderboardDimension[];
run_statuses: AdminLeaderboardDimension[];
recent_questions: AdminLeaderboardQuestion[];
frequent_questions: AdminLeaderboardQuestion[];
trends: AdminLeaderboardTrendPoint[];
}
@@ -154,7 +143,6 @@ export interface AdminUserAnalyticsResponse {
models: AdminLeaderboardDimension[];
agents: AdminLeaderboardDimension[];
run_statuses: AdminLeaderboardDimension[];
recent_questions: AdminLeaderboardQuestion[];
trends: AdminLeaderboardTrendPoint[];
}
+3
View File
@@ -33,6 +33,8 @@ export interface ListAgentsParams {
sort?: AgentSort;
/** Keep only agents published by this user id (the card's publisher). */
owner?: string;
/** Keep only agents filed under this publish square (发布广场). */
square?: string;
}
function buildAgentsQuery(params: ListAgentsParams): string {
@@ -44,6 +46,7 @@ function buildAgentsQuery(params: ListAgentsParams): string {
if (params.pageSize != null) qs.set("page_size", String(params.pageSize));
if (params.sort) qs.set("sort", params.sort);
if (params.owner) qs.set("owner", params.owner);
if (params.square) qs.set("square", params.square);
return qs.toString();
}
+5
View File
@@ -88,12 +88,15 @@ export function useAgentsPaginated(params: {
enabled?: boolean;
/** Keep only agents published by this user id (the card's publisher). */
owner?: string;
/** Keep only agents filed under this publish square (发布广场). */
square?: string;
}) {
const { scope, page, pageSize, enabled = true } = params;
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const owner = params.owner ?? "";
const square = params.square ?? "";
const { data, isLoading, isFetching, error } = useQuery({
queryKey: [
"agents",
@@ -105,6 +108,7 @@ export function useAgentsPaginated(params: {
page,
pageSize,
owner,
square,
],
queryFn: () =>
listAgentsPaginated({
@@ -115,6 +119,7 @@ export function useAgentsPaginated(params: {
page,
pageSize,
owner: owner || undefined,
square: square || undefined,
}),
enabled,
placeholderData: keepPreviousData,
+43
View File
@@ -1,5 +1,38 @@
import type { Tag } from "@/core/tags";
/**
* A recommended question shown on the agent chat home / sidebar. `title` is the
* short chip label; `prompt` is the full message sent when clicked. Legacy
* agents stored questions as bare strings — use {@link normalizeAgentQuestions}
* to read either shape.
*/
export interface AgentQuestion {
title: string;
prompt: string;
}
/** Coerce the raw `common_questions` payload (legacy `string[]` or the new
* `{title, prompt}[]`) into a uniform, non-empty `AgentQuestion[]`. */
export function normalizeAgentQuestions(
raw: (string | AgentQuestion)[] | null | undefined,
): AgentQuestion[] {
if (!raw) return [];
const out: AgentQuestion[] = [];
for (const item of raw) {
if (typeof item === "string") {
const text = item.trim();
if (!text) continue;
out.push({ title: text.length <= 40 ? text : `${text.slice(0, 40)}…`, prompt: text });
continue;
}
const prompt = (item?.prompt ?? "").trim();
const title = (item?.title ?? "").trim() || prompt;
if (!prompt && !title) continue;
out.push({ title: title || prompt, prompt: prompt || title });
}
return out;
}
export interface Agent {
id: string;
name: string;
@@ -9,6 +42,8 @@ export interface Agent {
owner_username?: string | null;
published: boolean;
is_private: boolean;
/** Publish square (发布广场) the agent is filed under. "" = the default square. */
square_id?: string;
builtin: boolean;
created_at?: string | null;
updated_at?: string | null;
@@ -16,6 +51,10 @@ export interface Agent {
model?: string | null;
tool_groups?: string[] | null;
skills?: string[] | null;
/** Recommended questions (常用问题) shown on the agent chat home / sidebar.
* New agents store `{title, prompt}`; legacy agents may still return `string[]`.
* Read via {@link normalizeAgentQuestions}. */
common_questions?: (string | AgentQuestion)[] | null;
tags?: Tag[];
featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
@@ -43,9 +82,11 @@ export interface CreateAgentRequest {
soul?: string;
published?: boolean;
is_private?: boolean;
square_id?: string;
skills?: string[] | null;
model?: string | null;
tool_groups?: string[] | null;
common_questions?: AgentQuestion[] | null;
}
export interface UpdateAgentRequest {
@@ -54,7 +95,9 @@ export interface UpdateAgentRequest {
soul?: string | null;
published?: boolean | null;
is_private?: boolean | null;
square_id?: string | null;
skills?: string[] | null;
model?: string | null;
tool_groups?: string[] | null;
common_questions?: AgentQuestion[] | null;
}
+1 -1
View File
@@ -69,7 +69,7 @@ export function getLangGraphBaseURL(isMock?: boolean) {
}
}
/** 笔记本外部系统后端 API 地址,通过 VITE_AIPI_BACKENP_URL 配置。 */
/** 空间外部系统后端 API 地址,通过 VITE_AIPI_BACKENP_URL 配置。 */
export function getNotebookApiBaseURL(): string {
if (env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL) {
return env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL.replace(/\/+$/, "");
+8 -1
View File
@@ -17,8 +17,15 @@ export async function getCuratorConfig(): Promise<CuratorConfig> {
return response.json() as Promise<CuratorConfig>;
}
export interface CuratorConfigUpdatePayload {
interval_hours: number;
stale_after_days: number;
archive_after_days: number;
model_name: string | null;
}
export async function updateCuratorConfig(
payload: CuratorConfig,
payload: CuratorConfigUpdatePayload,
): Promise<CuratorConfig> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`, {
method: "PUT",
+2 -1
View File
@@ -10,6 +10,7 @@ import {
runCurator,
updateCuratorConfig,
} from "./api";
import type { CuratorConfigUpdatePayload } from "./api";
import type { CuratorConfig } from "./types";
// All curator-related queries are per-user data (status / config / logs all
@@ -51,7 +52,7 @@ export function useUpdateCuratorConfig() {
const queryClient = useQueryClient();
const keys = useCuratorKeys();
return useMutation({
mutationFn: (payload: CuratorConfig) => updateCuratorConfig(payload),
mutationFn: (payload: CuratorConfigUpdatePayload) => updateCuratorConfig(payload),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: keys.config });
void queryClient.invalidateQueries({ queryKey: keys.status });
+24
View File
@@ -127,6 +127,30 @@ export async function ingestThreadsBatch(payload: BatchIngestPayload = {}) {
);
}
export interface IngestFileResult {
note: KnowledgeNote;
created: boolean;
notes: KnowledgeNote[];
llm_fallback: boolean;
}
/** Import a file (md/txt/pdf/word/ppt/excel) into the KB as wiki note(s). */
export async function ingestKnowledgeFile(
file: File,
opts: { title?: string; tags?: string; status?: string; model_name?: string | null } = {},
) {
const form = new FormData();
form.append("file", file);
if (opts.title) form.append("title", opts.title);
if (opts.tags) form.append("tags", opts.tags);
if (opts.status) form.append("status", opts.status);
if (opts.model_name) form.append("model_name", opts.model_name);
// No Content-Type header — the browser sets the multipart boundary itself.
return parseResponse<IngestFileResult>(
await apiFetch(`${baseUrl()}/ingest/file`, { method: "POST", body: form }),
);
}
/** Resolve `[[wikilink]]` targets to note ids / vault pages. */
export async function resolveWikilinks(targets: string[]) {
const res = await parseResponse<{ resolved: WikilinkResolutionMap }>(
+20
View File
@@ -10,7 +10,9 @@ import {
deleteKnowledgeNote,
exportKnowledgeGraph,
fetchVaultPage,
getKnowledgeGraph,
getKnowledgeNote,
ingestKnowledgeFile,
ingestThread,
ingestThreadsBatch,
listDuplicates,
@@ -70,6 +72,14 @@ export function useKnowledgeSearch(query: string, enabled: boolean) {
});
}
export function useKnowledgeGraph() {
return useQuery({
queryKey: [...ROOT_KEY, "graph"],
queryFn: getKnowledgeGraph,
refetchOnWindowFocus: false,
});
}
export function useKnowledgeActions() {
const queryClient = useQueryClient();
const invalidate = () =>
@@ -106,6 +116,16 @@ export function useKnowledgeActions() {
mutationFn: ingestThreadsBatch,
onSuccess: invalidate,
}),
ingestFile: useMutation({
mutationFn: ({
file,
opts,
}: {
file: File;
opts?: Parameters<typeof ingestKnowledgeFile>[1];
}) => ingestKnowledgeFile(file, opts),
onSuccess: invalidate,
}),
exportGraph: useMutation({ mutationFn: exportKnowledgeGraph }),
merge: useMutation({
mutationFn: ({ primaryId, duplicateIds }: { primaryId: string; duplicateIds: string[] }) =>
+11 -1
View File
@@ -434,7 +434,17 @@ export function findToolCallResult(
}
export function isHiddenFromUIMessage(message: Message) {
return message.additional_kwargs?.hide_from_ui === true;
if (message.additional_kwargs?.hide_from_ui === true) {
return true;
}
// Conversation-summary messages are context for the model only — never shown
// to the user. New summaries are injected transiently (never persisted), but
// threads compacted by older builds still carry a persisted `name: "summary"`
// HumanMessage, so hide those too.
if (message.name === "summary") {
return true;
}
return false;
}
/**
+8 -1
View File
@@ -7,6 +7,8 @@ export interface ListSkillsParams {
search?: string;
tagIds?: string[];
sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc";
/** Keep only skills filed under this publish square (发布广场). */
square?: string;
}
export async function loadSkills(params: ListSkillsParams = {}) {
@@ -14,6 +16,7 @@ export async function loadSkills(params: ListSkillsParams = {}) {
if (params.search) qs.set("search", params.search);
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
if (params.sort) qs.set("sort", params.sort);
if (params.square) qs.set("square", params.square);
const query = qs.toString();
const skills = await apiFetch(
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
@@ -242,18 +245,22 @@ export interface SetSkillPublishedResponse {
name: string;
owner_user_id: string | null;
published: boolean;
square_id?: string;
}
export async function setSkillPublished(
skillName: string,
published: boolean,
squareId?: string,
): Promise<SetSkillPublishedResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/published`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ published }),
body: JSON.stringify(
squareId != null ? { published, square_id: squareId } : { published },
),
},
);
if (!response.ok) {
+3 -1
View File
@@ -203,10 +203,12 @@ export function useSetSkillPublished() {
mutationFn: ({
skillName,
published,
squareId,
}: {
skillName: string;
published: boolean;
}) => setSkillPublished(skillName, published),
squareId?: string;
}) => setSkillPublished(skillName, published, squareId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
// Unpublishing triggers the backend cascade that strips this skill
+2
View File
@@ -55,6 +55,8 @@ export interface Skill {
// backend. Null for built-in / public skills with no human owner.
owner_username?: string | null;
published?: boolean;
/** Publish square (发布广场) the skill is filed under once published. "" = default square. */
square_id?: string;
builtin?: boolean;
// Long-form detail/introduction authored by the skill owner or an admin.
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
+26 -7
View File
@@ -20,14 +20,16 @@ export async function fetchStudioToken(email: string): Promise<string> {
return token;
}
export async function fetchStudioNotebooks(token: string): Promise<StudioNotebook[]> {
export async function fetchStudioNotebooks(
token: string,
): Promise<StudioNotebook[]> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`获取笔记本列表失败:${res.status}`);
if (!res.ok) throw new Error(`获取空间列表失败:${res.status}`);
return res.json() as Promise<StudioNotebook[]>;
}
@@ -62,13 +64,23 @@ export async function uploadSourceText(
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/documents/textupload`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ search_space_id: notebookId, content, title: title || "粘贴的文字" }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
search_space_id: notebookId,
content,
title: title || "粘贴的文字",
}),
});
if (!res.ok) throw new Error(`上传失败:${res.status}`);
}
export async function deleteSearchSpace(id: number, token: string): Promise<void> {
export async function deleteSearchSpace(
id: number,
token: string,
): Promise<void> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
@@ -78,12 +90,19 @@ export async function deleteSearchSpace(id: number, token: string): Promise<void
if (!res.ok) throw new Error(`删除失败:${res.status}`);
}
export async function renameSearchSpace(id: number, name: string, token: string): Promise<void> {
export async function renameSearchSpace(
id: number,
name: string,
token: string,
): Promise<void> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error(`重命名失败:${res.status}`);
+47 -19
View File
@@ -4,11 +4,15 @@ import { useStream } from "@langchain/langgraph-sdk/react";
import { getStoredAuth } from "@/core/auth";
import { getAPIClient } from "@/core/api";
import { fetchNotebookSources, fetchStudioNotebooks, fetchStudioToken } from "./api";
import {
fetchNotebookSources,
fetchStudioNotebooks,
fetchStudioToken,
} from "./api";
import type { AgentThreadState } from "@/core/threads/types";
import type { SourceItem, StudioNotebook } from "./types";
export function useStudioNotebooks(): {
export function useStudioNotebooks(enabled = true): {
notebooks: StudioNotebook[];
isLoading: boolean;
isError: boolean;
@@ -19,17 +23,22 @@ export function useStudioNotebooks(): {
const tokenQuery = useQuery({
queryKey: ["studio", "token", email],
queryFn: () => fetchStudioToken(email),
enabled: Boolean(email),
enabled: enabled && Boolean(email),
staleTime: 5 * 60 * 1000,
retry: 1,
// 账号不存在(404/422)是永久错误,重试无意义,立即让 isError 落地
retry: false,
refetchOnWindowFocus: false,
});
const notebooksQuery = useQuery({
queryKey: ["studio", "notebooks", tokenQuery.data],
queryFn: () => fetchStudioNotebooks(tokenQuery.data!),
enabled: Boolean(tokenQuery.data),
staleTime: 60 * 1000,
enabled: enabled && Boolean(tokenQuery.data),
// 空间列表不会高频变动;新建/删除路径已通过 invalidateQueries 主动失效
// (见 workspace-nav-studio.tsx),不需要短 staleTime + windowFocus 双兜底。
staleTime: 10 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
});
return {
@@ -71,7 +80,8 @@ export function useNotebookChat(
queryFn: () => fetchStudioToken(email),
enabled: Boolean(email),
staleTime: 5 * 60 * 1000,
retry: 1,
retry: false,
refetchOnWindowFocus: false,
});
const thread = useStream<AgentThreadState>({
@@ -99,7 +109,9 @@ export function useNotebookChat(
const sourceIds =
selectedSourceIds.length > 0
? toDocIds(selectedSourceIds)
: sources.map((s) => s.document?.id).filter((id): id is number => id !== undefined);
: sources
.map((s) => s.document?.id)
.filter((id): id is number => id !== undefined);
await thread.submit(
{ messages: [{ type: "human", content: text }] },
@@ -118,36 +130,52 @@ export function useNotebookChat(
);
};
return { thread, messages: thread.messages, isStreaming: thread.isLoading, send, threadId, setThreadId };
return {
thread,
messages: thread.messages,
isStreaming: thread.isLoading,
send,
threadId,
setThreadId,
};
}
export function useNotebookSources(notebookId: number | null): {
export function useNotebookSources(
notebookId: number | null,
overrideToken?: string,
): {
sources: SourceItem[];
isLoading: boolean;
isError: boolean;
} {
const email = getStoredAuth()?.email ?? "";
// 复用与 useStudioNotebooks 相同的 token query key,命中缓存不重复请求
// 若外部直接提供 user_token(如 notebook-token 接口返回的),跳过 getTokenByEmail
const tokenQuery = useQuery({
queryKey: ["studio", "token", email],
queryFn: () => fetchStudioToken(email),
enabled: Boolean(email),
enabled: !overrideToken && Boolean(email),
staleTime: 5 * 60 * 1000,
retry: 1,
retry: false,
refetchOnWindowFocus: false,
});
const resolvedToken = overrideToken ?? tokenQuery.data;
const sourcesQuery = useQuery({
queryKey: ["studio", "sources", notebookId],
queryFn: () => fetchNotebookSources(notebookId!, tokenQuery.data!),
enabled: notebookId !== null && Boolean(tokenQuery.data),
staleTime: 60 * 1000,
queryKey: ["studio", "sources", notebookId, overrideToken ?? "default"],
queryFn: () => fetchNotebookSources(notebookId!, resolvedToken!),
enabled: notebookId !== null && Boolean(resolvedToken),
// 笔记文档列表低频变动;增删时调用方应主动 invalidateQueries(["studio", "sources"]) 失效。
staleTime: 5 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
});
return {
sources: sourcesQuery.data ?? [],
isLoading: tokenQuery.isLoading || sourcesQuery.isLoading,
isError: tokenQuery.isError || sourcesQuery.isError,
isLoading:
(!overrideToken && tokenQuery.isLoading) || sourcesQuery.isLoading,
isError: (!overrideToken && tokenQuery.isError) || sourcesQuery.isError,
};
}
@@ -89,6 +89,52 @@ export async function loadPrivateSquareStatus(): Promise<PrivateSquareStatus> {
return (await response.json()) as PrivateSquareStatus;
}
export interface PublishSquare {
id: string;
name: string;
description: string;
}
export interface PublishSquaresSettings {
squares: PublishSquare[];
default_square_id: string;
}
export interface PublishSquaresUpdate {
squares?: PublishSquare[];
default_square_id?: string;
}
export async function loadPublishSquares(): Promise<PublishSquaresSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/publish-squares`,
);
if (!response.ok) {
throw new Error(`Failed to load publish squares: ${response.status}`);
}
return (await response.json()) as PublishSquaresSettings;
}
export async function updatePublishSquares(
body: PublishSquaresUpdate,
): Promise<PublishSquaresSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/publish-squares`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update publish squares" }));
throw new Error(detail.detail ?? "Failed to update publish squares");
}
return (await response.json()) as PublishSquaresSettings;
}
export type ThemeMode = "light" | "dark";
export type ViewMode = "card" | "list";
@@ -8,21 +8,25 @@ import {
loadLeaderboardSettings,
loadPrivateSquareStatus,
loadPromptPrefixSettings,
loadPublishSquares,
updateAppearanceSettings,
updateCitationDisplaySettings,
updateLeaderboardSettings,
updatePrivateSquareSettings,
updatePromptPrefixSettings,
updatePublishSquares,
type AppearanceUpdate,
type CitationDisplayUpdate,
type LeaderboardSettingsUpdate,
type PrivateSquareUpdate,
type PromptPrefixUpdate,
type PublishSquaresUpdate,
type ViewMode,
} from "./api";
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const;
const PUBLISH_SQUARES_KEY = ["systemSettings", "publishSquares"] as const;
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
@@ -67,6 +71,31 @@ export function useUpdatePrivateSquareSettings() {
});
}
/** The configured publish squares (发布广场). Readable by any logged-in user —
* the gallery sub-tabs and the publish-flow square picker both need it. */
export function usePublishSquares() {
return useQuery({
queryKey: PUBLISH_SQUARES_KEY,
queryFn: () => loadPublishSquares(),
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdatePublishSquares() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: PublishSquaresUpdate) => updatePublishSquares(body),
onSuccess: (data) => {
queryClient.setQueryData(PUBLISH_SQUARES_KEY, data);
// Squares changing can re-bucket gallery tabs; refresh agent/skill lists.
queryClient.invalidateQueries({ queryKey: ["agents"] });
queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useAppearanceSettings() {
return useQuery({
queryKey: APPEARANCE_KEY,
+1 -3
View File
@@ -9,8 +9,7 @@ function readEnv(
| "VITE_AIPI_BACKENP_URL"
| "VITE_AIPI_FRONTEND_URL"
| "VITE_STATIC_WEBSITE_ONLY"
| "VITE_GITHUB_OAUTH_TOKEN"
| "VITE_STUDIO_ENABLED",
| "VITE_GITHUB_OAUTH_TOKEN",
): EnvValue {
const value = import.meta.env[key];
return typeof value === "string" && value.trim() ? value : undefined;
@@ -26,5 +25,4 @@ export const env = {
NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readEnv("VITE_AIPI_BACKENP_URL"),
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"),
GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"),
STUDIO_ENABLED: readEnv("VITE_STUDIO_ENABLED"),
};
@@ -22,6 +22,12 @@ interface AIWritingFormProps {
disabled?: boolean;
/** workspace 入口:隐藏严格模式、目标读者 */
hideAdvancedFields?: boolean;
/** 预选空间 ID(传入时自动切换素材来源为「我的空间」) */
initialNotebookId?: number;
/** 预选文档 ID 列表 */
initialDocumentIds?: number[];
/** 外部直接提供的 studio bearer token,跳过 getTokenByEmail */
studioToken?: string;
}
const INPUT_CLASS =
@@ -39,7 +45,7 @@ const MAX_INTENT_LENGTH = 500;
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
{ value: 'knowledge_base', label: '知识库', desc: '默认 web/intranet 检索' },
{ value: 'skill', label: '技能检索', desc: '用已启用的技能编排检索' },
{ value: 'notebook', label: '我的笔记', desc: '从笔记本中检索素材' },
{ value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
];
const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222]';
@@ -50,6 +56,9 @@ export function AIWritingForm({
onSubmit,
disabled,
hideAdvancedFields = false,
initialNotebookId,
initialDocumentIds,
studioToken,
}: AIWritingFormProps) {
const {
intent, setIntent,
@@ -62,13 +71,15 @@ export function AIWritingForm({
materialSource, setMaterialSource,
modelName, setModelName,
} = useWritingForm();
// 笔记来源:选中的 search_space_id 和多个 document_id(master 引入,useWritingForm 未含)
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(null);
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>([]);
// 笔记来源:选中的 search_space_id 和多个 document_id
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(
initialNotebookId ?? null,
);
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>(initialDocumentIds ?? []);
const { models, isLoading: modelsLoading } = useModels();
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks();
const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId);
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(!studioToken);
const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId, studioToken);
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes();
@@ -86,6 +97,7 @@ export function AIWritingForm({
[models, modelName],
);
// 初始化时加载文章类型
useEffect(() => {
refreshTypes();
@@ -127,7 +139,8 @@ export function AIWritingForm({
function handleMaterialSourceChange(val: MaterialSource) {
setMaterialSource(val);
if (val !== 'notebook') {
// studioToken 模式下空间和文档已锁定,切换素材来源时不清空
if (val !== 'notebook' && !studioToken) {
setSelectedNotebookId(null);
setSelectedDocumentIds([]);
}
@@ -153,9 +166,9 @@ export function AIWritingForm({
wordCountTarget: wordCount,
keywordCount: DEFAULT_KEYWORD_COUNT,
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
materialSource,
materialSource: materialSource,
modelName: selectedModel?.name || modelName || undefined,
// 只有选了"我的笔记"才传笔记参数
// 只有选了"我的空间"才传笔记参数
noteSearchSpaceId: materialSource === 'notebook' ? (selectedNotebookId ?? undefined) : undefined,
noteSourceIds: materialSource === 'notebook' && selectedNotebookId != null ? selectedDocumentIds : undefined,
});
@@ -285,32 +298,35 @@ export function AIWritingForm({
</SelectContent>
</Select>
{/* 选了"我的笔记"才展示笔记本 / 文档选择器 */}
{/* 选了"我的空间"才展示空间 / 文档选择器 */}
{materialSource === 'notebook' && (
<div className="mt-2 space-y-2">
<Select
value={selectedNotebookId !== null ? String(selectedNotebookId) : ''}
onValueChange={handleNotebookChange}
disabled={disabled || notebooksLoading}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择笔记本'} />
</SelectTrigger>
<SelectContent>
{notebooks.map((nb) => (
<SelectItem key={nb.id} value={String(nb.id)} className="text-sm">
{nb.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* studioToken 模式:空间已锁定,不展示选择器 */}
{!studioToken && (
<Select
value={selectedNotebookId !== null ? String(selectedNotebookId) : ''}
onValueChange={handleNotebookChange}
disabled={disabled || notebooksLoading}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择空间'} />
</SelectTrigger>
<SelectContent>
{notebooks.map((nb) => (
<SelectItem key={nb.id} value={String(nb.id)} className="text-sm">
{nb.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{selectedNotebookId !== null && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
{sourcesLoading
? '文档加载中…'
: `选择文档(可多选;不选则搜整个笔记本${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`}
: `选择文档(可多选;不选则搜整个空间${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`}
</p>
{!sourcesLoading && (
<div className="max-h-36 overflow-y-auto rounded-lg border border-[#e8dedd] bg-white divide-y divide-[#f0e8e7]">
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
@@ -16,7 +16,7 @@ import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useAIWriting } from '../../contexts/AIWritingContext';
import { useAIWritingHistory } from '../../contexts/AIWritingHistoryContext';
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
import { adminCleanupAIWritingSessions } from '../../api/ai-writing-sessions';
import { adminCleanupAIWritingSessions, getAIWritingSession } from '../../api/ai-writing-sessions';
/**
* 暂停 / 继续功能开关(默认 false = 关)。
@@ -70,10 +70,17 @@ const STATUS_LABEL: Record<string, string> = {
export function AIWritingPanel({
hideAdvancedFields = false,
initialNotebookId,
initialDocumentIds,
studioToken,
}: {
hideAdvancedFields?: boolean;
initialNotebookId?: number;
initialDocumentIds?: number[];
studioToken?: string;
} = {}) {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory();
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
@@ -213,6 +220,18 @@ export function AIWritingPanel({
return () => window.removeEventListener('ai-writing:open-session', onOpenSession);
}, []);
// 路径3:URL ?session=xxx 直接定位到指定会话
useEffect(() => {
const sessionId = searchParams.get('session');
if (!sessionId) return;
// 消费参数,避免刷新重复触发
setSearchParams((prev) => { prev.delete('session'); return prev; }, { replace: true });
getAIWritingSession(sessionId)
.then((session) => handleSelectHistoryRef.current(session))
.catch(() => { /* session 不存在时静默忽略 */ });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅挂载时执行一次
// ── ⚠️ 临时调试:admin 一键清理所有过期会话 ────────────────────────────────
// ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。
// 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。
@@ -339,6 +358,9 @@ export function AIWritingPanel({
<AIWritingForm
onSubmit={startWriting}
hideAdvancedFields={hideAdvancedFields}
initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds}
studioToken={studioToken}
/>
)}
@@ -246,7 +246,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
saveAIWritingTranscript(state.sessionId, {
progressEvents: state.progressEvents,
completedInterventions: state.completedInterventions,
}).catch(() => {});
}).catch(() => { });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.status, state.sessionId]);
@@ -256,6 +256,10 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
const stream = streamRef.current;
if (!stream) return;
try {
// 所见即所得:用户在表单选了什么 materialSource,前端就原样传过去。
// 三个合法值:knowledge_base / skill / notebook,后端 state.py 接受任意 str。
// 与 material_source 独立的 notebook_search_space_id / notebook_source_ids
// 控制是否真的去笔记后端拉素材(仅当用户选了"我的空间"模式时由表单填充)。
await stream.submit(
{
user_intent: request.userIntent,
@@ -266,7 +270,9 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
word_count_target: request.wordCountTarget,
keyword_count: Math.max(1, Math.min(10, request.keywordCount)),
writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose',
material_source: request.materialSource === 'skill' ? 'skill' : 'knowledge_base',
material_source: request.materialSource,
notebook_search_space_id: request.noteSearchSpaceId ?? null,
notebook_source_ids: request.noteSourceIds ?? null,
revision_count: 0,
max_revisions: 3,
status: 'researching',
@@ -368,8 +374,8 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
}
}, [state.sessionId, state.currentPause, state.materials, state.outline,
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult,
state.status]);
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult,
state.status]);
const pauseWriting = useCallback(() => {
void streamRef.current?.stop();
@@ -53,7 +53,7 @@ export function useWritingFormOptional(): WritingFormValue | null {
return useContext(WritingFormContext);
}
export function WritingFormProvider({ children }: { children: ReactNode }) {
export function WritingFormProvider({ children, initialMaterialSource }: { children: ReactNode; initialMaterialSource?: 'knowledge_base' | 'skill' | 'notebook' }) {
const [intent, setIntent] = useState('');
const [showOutline, setShowOutline] = useState(false);
const [outlineText, setOutlineText] = useState('');
@@ -61,7 +61,7 @@ export function WritingFormProvider({ children }: { children: ReactNode }) {
const [wordCountInput, setWordCountInput] = useState('800');
const [audience, setAudience] = useState('普通读者');
const [strictMode, setStrictMode] = useState(false);
const [materialSource, setMaterialSource] = useState<'knowledge_base' | 'skill' | 'notebook'>('knowledge_base');
const [materialSource, setMaterialSource] = useState<'knowledge_base' | 'skill' | 'notebook'>(initialMaterialSource ?? 'knowledge_base');
const [modelName, setModelName] = useState('');
const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => {
@@ -115,11 +115,17 @@ const SETUP_LAYOUT = { [SETUP_CHAT_ID]: 65, [SETUP_FORM_ID]: 35 };
/**
* 写作配置表单面板(写作助手回填的目标),抽出复用:idle 时在右栏,开始写作后在左栏。
*/
function WritingFormPanel({ hideAdvancedFields }: { hideAdvancedFields: boolean }) {
interface NotebookProps {
initialNotebookId?: number;
initialDocumentIds?: number[];
studioToken?: string;
}
function WritingFormPanel({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
return (
// 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} />
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
</div>
);
}
@@ -129,7 +135,7 @@ function WritingFormPanel({ hideAdvancedFields }: { hideAdvancedFields: boolean
* - idle(初始问答阶段):左=「写作助手 Q&A」,右=「写作配置选项」表单;
* - 开始写作后:回到原布局——左=写作配置/时间线面板,右=草稿预览。
*/
function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }) {
function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
const layoutRef = useRef<GroupImperativeHandle>(null);
const { state } = useAIWriting();
const { isHistoryMode } = useAIWritingHistory();
@@ -162,7 +168,7 @@ function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }
minSize={20}
className="overflow-hidden flex flex-col"
>
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} />
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
</ResizablePanel>
</ResizablePanelGroup>
);
@@ -202,8 +208,14 @@ function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }
export default function AIWritingPage({
hideAdvancedFields = false,
initialNotebookId,
initialDocumentIds,
studioToken,
}: {
hideAdvancedFields?: boolean;
initialNotebookId?: number;
initialDocumentIds?: number[];
studioToken?: string;
} = {}) {
return (
<CanvasThreadProvider>
@@ -211,11 +223,11 @@ export default function AIWritingPage({
<CanvasUIProvider>
<AIWritingHistoryProvider>
<AIWritingProvider>
<WritingFormProvider>
<WritingFormProvider initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}>
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background">
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} />
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
</div>
</div>
</TooltipProvider>
@@ -7,7 +7,7 @@ export interface AIMaterial {
source: string;
publishedAt: string;
relevanceScore: number;
sourceType: 'internet' | 'intranet' | 'skill' | 'notebook';
sourceType: "internet" | "intranet" | "skill" | "notebook";
url?: string;
recUuid?: string;
// skill 类素材的元信息(skill_name / container_path / category / skill_full_content 等)
@@ -22,7 +22,13 @@ export interface AIMaterial {
// skill_result:某个技能检索返回了 N 条素材
// stop_early:累计素材达到上限,停止后续技能
export interface SkillSearchStep {
type: 'list_skills' | 'match_skills' | 'select_skills' | 'invoke_skill' | 'skill_result' | 'stop_early';
type:
| "list_skills"
| "match_skills"
| "select_skills"
| "invoke_skill"
| "skill_result"
| "stop_early";
message: string;
skillNames?: string[];
skillName?: string;
@@ -41,8 +47,8 @@ export interface SkillSearchStep {
// guard 兼容,防止前端流式接收到旧大纲时崩溃。详见 normalizeKeyPoint() helper。
export interface KeyPoint {
pointTitle: string; // 小节标题(渲染为 ### 三级标题)
subPoints: string[]; // 子要点(可空;非空时在小节正文起始处以 bullet 列表呈现)
pointTitle: string; // 小节标题(渲染为 ### 三级标题)
subPoints: string[]; // 子要点(可空;非空时在小节正文起始处以 bullet 列表呈现)
}
export interface OutlineSection {
@@ -65,23 +71,28 @@ export interface AIOutline {
* - 任何意外形态 → 空 KeyPoint(防止崩溃,不抛异常)
*/
export function normalizeKeyPoint(raw: unknown): KeyPoint {
if (typeof raw === 'string') {
if (typeof raw === "string") {
return { pointTitle: raw, subPoints: [] };
}
if (raw && typeof raw === 'object') {
if (raw && typeof raw === "object") {
const r = raw as Record<string, unknown>;
const title =
typeof r.pointTitle === 'string' ? r.pointTitle :
typeof r.point_title === 'string' ? r.point_title :
typeof r.title === 'string' ? r.title :
'';
typeof r.pointTitle === "string"
? r.pointTitle
: typeof r.point_title === "string"
? r.point_title
: typeof r.title === "string"
? r.title
: "";
const subsRaw = (r.subPoints ?? r.sub_points ?? []) as unknown;
const subPoints: string[] = Array.isArray(subsRaw)
? subsRaw.map((s) => (typeof s === 'string' ? s : String(s ?? ''))).filter(Boolean)
? subsRaw
.map((s) => (typeof s === "string" ? s : String(s ?? "")))
.filter(Boolean)
: [];
return { pointTitle: title, subPoints };
}
return { pointTitle: '', subPoints: [] };
return { pointTitle: "", subPoints: [] };
}
// ── 素材不足求助 ──────────────────────────────────────────────────────────────
@@ -93,7 +104,7 @@ export interface BlockedSection {
}
/** 单个被阻塞章节的处理决策 */
export type SectionDecisionKind = 'supplement' | 'delete' | 'loose';
export type SectionDecisionKind = "supplement" | "delete" | "loose";
export interface SectionDecision {
sectionTitle: string;
@@ -103,15 +114,15 @@ export interface SectionDecision {
// ── 编辑审核 ──────────────────────────────────────────────────────────────────
export interface ReviewIssue {
category: 'fact' | 'logic' | 'language';
severity: 'major' | 'minor';
category: "fact" | "logic" | "language";
severity: "major" | "minor";
location: string;
issue: string;
suggestion: string;
}
export interface ReviewResult {
verdict: 'pass' | 'reject';
verdict: "pass" | "reject";
factScore: number;
logicScore: number;
languageScore: number;
@@ -123,44 +134,44 @@ export interface ReviewResult {
// ── 写作状态 ──────────────────────────────────────────────────────────────────
export type WritingStatus =
| 'idle'
| 'researching'
| 'awaiting_material_confirm'
| 'writing_outline'
| 'awaiting_outline_confirm'
| 'writing_draft'
| 'awaiting_section_help'
| 'awaiting_draft_confirm'
| 'reviewing'
| 'awaiting_review_confirm'
| 'revising'
| 'done'
| 'error';
| "idle"
| "researching"
| "awaiting_material_confirm"
| "writing_outline"
| "awaiting_outline_confirm"
| "writing_draft"
| "awaiting_section_help"
| "awaiting_draft_confirm"
| "reviewing"
| "awaiting_review_confirm"
| "revising"
| "done"
| "error";
// ── SSE 进度事件 ──────────────────────────────────────────────────────────────
export type ProgressEventType =
| 'connected'
| 'step_start'
| 'step_done'
| 'intent_chunk'
| 'intent_ready'
| 'research_progress'
| 'materials_ready'
| 'outline_chunk'
| 'outline_ready'
| 'draft_chunk'
| 'draft_ready'
| 'review_chunk'
| 'review_item_done'
| 'review_ready'
| 'await_user'
| 'resumed'
| 'notice'
| 'skill_search_step'
| 'skill_select_chunk'
| 'done'
| 'error';
| "connected"
| "step_start"
| "step_done"
| "intent_chunk"
| "intent_ready"
| "research_progress"
| "materials_ready"
| "outline_chunk"
| "outline_ready"
| "draft_chunk"
| "draft_ready"
| "review_chunk"
| "review_item_done"
| "review_ready"
| "await_user"
| "resumed"
| "notice"
| "skill_search_step"
| "skill_select_chunk"
| "done"
| "error";
export interface ProgressEvent {
type: ProgressEventType;
@@ -183,13 +194,18 @@ export interface ProgressEvent {
draftTitle?: string;
revisionCount?: number;
// review_item_done (streaming per-category result)
reviewItemCategory?: 'fact' | 'logic' | 'language';
reviewItemCategory?: "fact" | "logic" | "language";
reviewItemScore?: number;
reviewItemIssues?: ReviewIssue[];
// review_ready
reviewResult?: ReviewResult;
// await_user
pausePoint?: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm' | 'section_help';
pausePoint?:
| "material_confirm"
| "outline_confirm"
| "draft_confirm"
| "review_confirm"
| "section_help";
pauseContext?: Record<string, unknown>;
// section_help:素材不足、待用户决策的章节
blockedSections?: BlockedSection[];
@@ -229,19 +245,19 @@ export interface AIWritingRequest {
/** 素材检索关键词数量(1~10,默认 1) */
keywordCount: number;
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
writingMode: 'strict' | 'loose';
/** 素材来源:knowledge_base=知识库 / skill=技能检索 / notebook=我的笔记 */
materialSource: 'knowledge_base' | 'skill' | 'notebook';
/** 笔记来源(可选):笔记本 search_space_id */
writingMode: "strict" | "loose";
/** 素材来源:knowledge_base=知识库 / skill=技能检索 / notebook=我的空间 */
materialSource: "knowledge_base" | "skill" | "notebook";
/** 笔记来源(可选):空间 search_space_id */
noteSearchSpaceId?: number;
/** 笔记来源(可选):笔记本内指定文档的 document_id 列表,空数组表示检索整个笔记本 */
/** 笔记来源(可选):空间内指定文档的 document_id 列表,空数组表示检索整个空间 */
noteSourceIds?: number[];
}
// ── 流式审核条目 ──────────────────────────────────────────────────────────────
export interface StreamingReviewItem {
category: 'fact' | 'logic' | 'language';
category: "fact" | "logic" | "language";
// 是否已生成完毕(true 时展示结构化评分/意见,false 时展示流式打字机文本)
isDone: boolean;
// 流式过程文本(正文 / 思考分开)
@@ -266,7 +282,7 @@ export interface RevisionSummary {
export interface StreamingDraftSection {
sectionIndex: number;
sectionTitle: string;
text: string; // 正文内容(非思考)
text: string; // 正文内容(非思考)
thinkingText: string; // 思考内容(<think>...</think> 内)
isDone: boolean;
}
@@ -274,7 +290,12 @@ export interface StreamingDraftSection {
// ── 已完成干预记录 ────────────────────────────────────────────────────────────
export interface CompletedIntervention {
pausePoint: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm' | 'section_help';
pausePoint:
| "material_confirm"
| "outline_confirm"
| "draft_confirm"
| "review_confirm"
| "section_help";
action: string;
// material_confirm
materials?: AIMaterial[];
@@ -49,7 +49,6 @@ import { MarkdownContent } from "@/components/workspace/messages/markdown-conten
import type {
AdminLeaderboardDimension,
AdminLeaderboardOverview,
AdminLeaderboardQuestion,
AdminLeaderboardResponse,
AdminLeaderboardSkill,
AdminLeaderboardTrendPoint,
@@ -66,7 +65,7 @@ type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency"
type RangeState = { since: string; until: string; days?: number };
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
const DEFAULT_REPORT_MODEL = "__default__";
const LEADERBOARD_PAGE_SIZE = 20;
const LEADERBOARD_PAGE_SIZE = 10;
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
crimson: {
@@ -132,11 +131,6 @@ function fmtTime(value?: string | null): string {
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function clampText(value: string, max = 120): string {
const text = value.trim();
return text.length > max ? `${text.slice(0, max)}...` : text;
}
function userScore(user: AdminLeaderboardUser, tab: UserRankTab): number {
if (tab === "questions") return user.qa_count;
if (tab === "tokens") return user.total_tokens;
@@ -207,12 +201,6 @@ function markdownList(items: string[]): string {
function buildReportFocusMarkdown(data: AdminLeaderboardResponse | undefined, range: RangeState): string {
if (!data) return "";
const recentQuestions = data.recent_questions
.slice(0, 8)
.map((item) => `${clampText(item.text, 120)}(${fmtTime(item.asked_at)})`);
const frequentQuestions = data.frequent_questions
.slice(0, 8)
.map((item) => clampText(item.text, 120));
const frequentUsage = [
...data.skills.slice(0, 5).map((item) => `技能:${item.name},${fmtNumber(item.call_count)} 次调用`),
...data.tools.slice(0, 5).map((item) => `工具:${item.name},${fmtNumber(item.call_count)} 次调用`),
@@ -223,12 +211,6 @@ function buildReportFocusMarkdown(data: AdminLeaderboardResponse | undefined, ra
"",
`统计周期:${range.since} 至 ${range.until}`,
"",
"## 最近关注",
markdownList(recentQuestions),
"",
"## 高频问题",
markdownList(frequentQuestions),
"",
"## 使用频率重点",
markdownList(frequentUsage),
].join("\n");
@@ -905,43 +887,6 @@ function DimensionRanking({
);
}
function QuestionPanel({
title,
items,
showUser = true,
}: {
title: string;
items: AdminLeaderboardQuestion[];
showUser?: boolean;
}) {
return (
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-center justify-between gap-3 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#213D5E]">
<div className="flex items-center gap-2">
<MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
</div>
<Badge variant="outline">{items.length}</Badge>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="divide-y divide-[#F1D6D6] dark:divide-[#213D5E]">
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368] dark:text-slate-400">暂无问题记录</div>}
{items.map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
<div className="text-sm leading-6 text-[#202124] dark:text-slate-100">{clampText(item.text, 180)}</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400">
{showUser && <span>{item.email || item.user_id || "未知用户"}</span>}
<span>{fmtTime(item.asked_at)}</span>
{item.thread_id && <span>会话 {item.thread_id.slice(0, 8)}</span>}
</div>
</div>
))}
</div>
</ScrollArea>
</section>
);
}
function Metric({ label, value }: { label: string; value: number }) {
return (
<div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#213D5E]">
@@ -1006,16 +951,12 @@ function UserDetailPanel({
</div>
</section>
<Tabs defaultValue="questions" className="space-y-3">
<Tabs defaultValue="trend" className="space-y-3">
<TabsList className="mb-3">
<TabsTrigger value="questions">提问</TabsTrigger>
<TabsTrigger value="trend">趋势</TabsTrigger>
<TabsTrigger value="skills">技能</TabsTrigger>
<TabsTrigger value="dimensions">维度</TabsTrigger>
</TabsList>
<TabsContent value="questions" className="min-h-[420px]">
<QuestionPanel title="最近提问" items={data.recent_questions} showUser={false} />
</TabsContent>
<TabsContent value="trend" className="min-h-[420px]">
<div className="mb-3 flex items-center gap-2 font-semibold text-[#231815] dark:text-white">
<TrendingUp className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
@@ -1354,36 +1295,7 @@ export default function AdminUserLeaderboardPage() {
</Button>
</div>
{data && reportStatus === "idle" && !reportText && (
<div className="mx-6 mt-4 grid shrink-0 gap-3 md:grid-cols-3">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<MessageSquareText className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
最近关注
</div>
<div className="space-y-2">
{data.recent_questions.slice(0, 3).map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="text-xs leading-5 text-[#5F6368] dark:text-slate-300">
<span className="font-medium text-[#C1121F] dark:text-[#7FACF2]">{fmtTime(item.asked_at)}</span>
<span className="ml-1">{clampText(item.text, 58)}</span>
</div>
))}
{data.recent_questions.length === 0 && <div className="text-xs text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>}
</div>
</section>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<TrendingUp className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
高频问题
</div>
<div className="space-y-2">
{data.frequent_questions.slice(0, 3).map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="text-xs leading-5 text-[#5F6368] dark:text-slate-300">
{index + 1}. {clampText(item.text, 72)}
</div>
))}
{data.frequent_questions.length === 0 && <div className="text-xs text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>}
</div>
</section>
<div className="mx-6 mt-4 grid shrink-0 gap-3">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<Sparkles className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
@@ -1645,12 +1557,6 @@ export default function AdminUserLeaderboardPage() {
<DimensionRanking title="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" />
<DimensionRanking title="运行状态分布" icon={Gauge} items={data.run_statuses} empty="当前时间段暂无运行状态" />
</div>
<div className="grid gap-5 xl:grid-cols-2">
<QuestionPanel title="最近经常问的问题" items={data.frequent_questions} />
<QuestionPanel title="最新问题" items={data.recent_questions} />
</div>
</>
)}
</div>
+184 -114
View File
@@ -1,28 +1,25 @@
"use client";
import { BotIcon, PlusSquare } from "lucide-react";
import { BotIcon, MessageCircleQuestionIcon, PlusSquare } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useRouteBase } from "@/core/route-base";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box";
import {
MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
} from "@/components/workspace/messages";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
import { Tooltip } from "@/components/workspace/tooltip";
import { useAgent } from "@/core/agents";
import { normalizeAgentQuestions, useAgent } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
@@ -65,6 +62,11 @@ export default function AgentChatPage() {
const agentDisplayName = desensitize(
agent?.name ?? (isAgentLoading ? t.common.loading : ""),
);
// 常用问题 shown as a list beside the input box on the landing screen.
const commonQuestions = useMemo(
() => normalizeAgentQuestions(agent?.common_questions),
[agent?.common_questions],
);
const { threadId, setThreadId, isNewThread, setIsNewThread } =
useThreadChat();
@@ -162,129 +164,197 @@ export default function AgentChatPage() {
await thread.stop();
}, [thread]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const isLandingState = isNewThread && !hasStartedConversation;
const isDemoMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
const status = thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready";
return (
<ThreadContext.Provider value={{ thread }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 border-b px-4",
isLandingState
? "border-transparent bg-transparent"
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
)}
>
{/* Agent badge */}
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />
<span
className="max-w-[200px] truncate text-xs font-medium"
title={agentDisplayName}
>
{agentDisplayName}
</span>
</div>
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="mr-4 flex items-center">
<Tooltip content={t.agents.newChat}>
<Button
size="sm"
variant="secondary"
onClick={() => {
router.push(`/page/workspace/agents/${agent_id}/chats/new`);
}}
{/* Root: fixed-height horizontal split. The left main column owns the
chat (home / conversation) and the input; the right sidebar is a
separate flex sibling, so nothing in the main column can ever bleed
over it. */}
<div className="flex size-full min-h-0 overflow-hidden">
{/* ── Left: main conversation column ───────────────────────────── */}
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
{/* Header — in normal flow (not absolute), fixed height. */}
<header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md">
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />
<span
className="max-w-[200px] truncate text-xs font-medium"
title={agentDisplayName}
>
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
<TokenUsageIndicator
enabled={tokenUsageEnabled}
messages={thread.messages}
/>
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
{agentDisplayName}
</span>
</div>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<MessageList
className={cn("size-full", !isLandingState && "pt-10")}
threadId={threadId}
thread={thread}
paddingBottom={messageListPaddingBottom}
tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit}
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
/>
</div>
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"relative w-full",
isLandingState && "-translate-y-[calc(50vh-96px)]",
isLandingState
? "max-w-(--container-width-md)"
: "max-w-(--container-width-lg)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
<div className="absolute right-0 bottom-0 left-0">
<div className="flex items-center">
<Tooltip content={t.agents.newChat}>
<Button
size="sm"
variant="secondary"
onClick={() => {
router.push(
`/page/workspace/agents/${agent_id}/chats/new`,
);
}}
>
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
<TokenUsageIndicator
enabled={tokenUsageEnabled}
messages={thread.messages}
/>
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
{isLandingState ? (
/* ── home: centered hero + suggested questions + input ──────
Everything lives inside the scrollable main column. No fixed,
no absolute, no viewport centering — so the avatar can't touch
the header and the input can't cover the sidebar. */
<div className="flex min-h-0 flex-1 flex-col items-center overflow-y-auto px-6 pb-10 pt-16 sm:pt-24">
<AgentWelcome
agent={agent}
agentName={agentDisplayName}
className="mb-7"
/>
{/* 常用问题 sit as a list to the LEFT of the input box (not as
floating pills above it). On narrow screens the list stacks
above the input instead of squeezing it. */}
<div
className={cn(
"flex w-full flex-col gap-3 sm:flex-row sm:items-end sm:gap-4",
commonQuestions.length > 0
? "max-w-(--container-width-lg)"
: "max-w-(--container-width-md)",
)}
>
{commonQuestions.length > 0 && (
<div className="border-border/60 bg-card/60 w-full shrink-0 rounded-xl border p-3 sm:w-64">
<div className="text-muted-foreground mb-2 flex items-center gap-1.5 text-xs font-medium">
<MessageCircleQuestionIcon className="size-3.5" />
常用问题
</div>
<div className="flex max-h-44 flex-col gap-1.5 overflow-y-auto">
{commonQuestions.map((question, index) => (
<button
key={index}
type="button"
disabled={isDemoMode}
onClick={() =>
handleSubmit({ text: question.prompt, files: [] })
}
title={desensitize(question.prompt)}
className={cn(
"border-border/60 bg-background/60 hover:bg-accent hover:border-border w-full truncate rounded-lg border px-3 py-2 text-left text-sm transition-colors",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{desensitize(question.title)}
</button>
))}
</div>
</div>
)}
<div className="min-w-0 flex-1">
<InputBox
className="w-full"
isNewThread
hideAdminRecommendedQuestions
threadId={threadId}
assistantId={agent_id}
autoFocus
status={status}
context={settings.context}
disabled={isDemoMode}
onContextChange={(context) =>
setSettings("context", context)
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
{isDemoMode && (
<div className="text-muted-foreground/67 mt-3 w-full text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
</div>
) : (
/* ── conversation (active / history): messages scroll, input
docked at the bottom of the main column only. */
<div className="flex min-h-0 flex-1 flex-col">
<MessageList
className="min-h-0 flex-1"
threadId={threadId}
thread={thread}
paddingBottom={showFollowups ? 96 : 24}
tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit}
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
/>
<div className="from-background pointer-events-none shrink-0 bg-gradient-to-t to-transparent px-4 pb-4 pt-2">
<div className="pointer-events-auto mx-auto w-full max-w-(--container-width-lg)">
<TodoList
className="bg-background/5"
className="bg-background/5 mb-2"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
!thread.values.todos ||
thread.values.todos.length === 0
}
/>
<InputBox
className="w-full"
isNewThread={false}
threadId={threadId}
assistantId={agent_id}
status={status}
context={settings.context}
disabled={isDemoMode}
onContextChange={(context) =>
setSettings("context", context)
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
{isDemoMode && (
<div className="text-muted-foreground/67 mt-2 w-full text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
<div className="w-full -translate-y-4">
<InputBox
className="w-full"
isNewThread={isLandingState}
threadId={threadId}
assistantId={agent_id}
autoFocus={isLandingState}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
extraHeader={
isLandingState && (
<AgentWelcome agent={agent} agentName={agentDisplayName} />
)
}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onContextChange={(context) => setSettings("context", context)}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
</div>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
)}
</main>
{/* ── Right: agent detail panel (profile + inline editing for
admins / owners; read-only for everyone else) ───────────────── */}
<AgentDetailPanel
agent={agent}
agentName={agentDisplayName}
disabled={thread.isLoading || isDemoMode}
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
/>
</div>
</ChatBox>
</ThreadContext.Provider>
+284 -469
View File
@@ -1,23 +1,8 @@
import {
BookOpen,
Copy,
ExternalLink,
Loader2,
Network,
Pencil,
Plus,
RefreshCw,
Save,
Search,
Sparkles,
Trash2,
X,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Loader2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -29,14 +14,16 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { EmptyDocumentState } from "@/components/wiki/EmptyDocumentState";
import { KnowledgeSidebar } from "@/components/wiki/KnowledgeSidebar";
import { WikiContent } from "@/components/wiki/WikiContent";
import { WikiHeader } from "@/components/wiki/WikiHeader";
import {
buildKnowledgeTree,
downloadMarkdown,
findAncestorFolderIds,
} from "@/components/wiki/wikiUtils";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import {
useKnowledgeActions,
@@ -52,45 +39,14 @@ import MarkdownRenderer, {
type WikiLinkTarget,
} from "@/strategy-components/MarkdownRenderer";
const SOURCE_TYPE_LABELS: Record<string, string> = {
thread: "对话",
search: "检索",
tool: "工具",
file: "文件",
manual: "手动",
report: "报告",
};
const SIDEBAR_COLLAPSED_KEY = "wiki.sidebar.collapsed";
const STATUS_LABELS: Record<string, string> = {
draft: "草稿",
approved: "已批准",
archived: "已归档",
};
/** Human label for an audit actor: prefer the resolved name (email), else a short id. */
function actorLabel(name?: string | null, id?: string | null): string {
if (name && name.trim()) return name.trim();
if (id && id.trim()) return `${id.trim().slice(0, 8)}…`;
return "—";
}
const ENTITY_TYPE_META: Record<string, { label: string; className: string }> = {
person: { label: "人物", className: "bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-500/15 dark:text-rose-300 dark:border-rose-500/30" },
org: { label: "机构", className: "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30" },
product: { label: "产品", className: "bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30" },
technology: { label: "技术", className: "bg-cyan-50 text-cyan-700 border-cyan-200 dark:bg-cyan-500/15 dark:text-cyan-300 dark:border-cyan-500/30" },
concept: { label: "概念", className: "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30" },
region: { label: "地区", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
country: { label: "国家", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
};
function entityMeta(type?: string | null) {
return (
ENTITY_TYPE_META[(type || "").toLowerCase()] ?? {
label: "其他",
className: "bg-slate-100 text-slate-600 border-slate-200 dark:bg-slate-700/40 dark:text-slate-300 dark:border-slate-600",
}
);
function readSidebarCollapsed(): boolean {
try {
return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1";
} catch {
return false;
}
}
function useDebounced<T>(value: T, delay = 350): T {
@@ -102,54 +58,6 @@ function useDebounced<T>(value: T, delay = 350): T {
return debounced;
}
function NoteListItem({
note,
active,
onClick,
}: {
note: KnowledgeNote;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full flex-col gap-1 rounded-md border p-3 text-left transition-colors",
active
? "border-primary bg-primary/5"
: "border-border/70 hover:bg-muted/50",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="line-clamp-1 text-sm font-medium">{note.title}</span>
<Badge variant="outline" className="shrink-0 text-[10px]">
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
</Badge>
</div>
{note.summary ? (
<span className="text-muted-foreground line-clamp-2 text-xs">
{note.summary}
</span>
) : null}
<div className="mt-1 flex flex-wrap items-center gap-1">
{note.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]"
>
#{tag}
</span>
))}
<span className="text-muted-foreground ml-auto text-[10px]">
{note.updated_at?.slice(0, 10)}
</span>
</div>
</button>
);
}
function CreateNoteDialog({
open,
onOpenChange,
@@ -323,6 +231,119 @@ function BatchImportDialog({
);
}
function ImportFileDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { ingestFile } = useKnowledgeActions();
const navigate = useNavigate();
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [tags, setTags] = useState("");
const [model, setModel] = useState<string | null>(null);
const reset = () => {
setFile(null);
setTitle("");
setTags("");
setModel(null);
};
const handleImport = async () => {
if (!file) {
toast.error("请选择文件");
return;
}
try {
const result = await ingestFile.mutateAsync({
file,
opts: {
title: title.trim() || undefined,
tags: tags.trim() || undefined,
status: "approved",
model_name: model,
},
});
const count = result.notes?.length ?? 1;
if (result.llm_fallback) {
// The model produced nothing usable — the file was stored verbatim as a
// draft instead of distilled into a wiki note.
toast.warning(
`已导入 ${count} 条(大模型异常,改用规则抽取并存为草稿)`,
);
} else {
toast.success(`已从文件生成 ${count} 条知识`);
}
onOpenChange(false);
reset();
if (result.note?.id) {
navigate(`/page/workspace/knowledge/${result.note.id}`);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "文件导入失败");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>导入文件生成 Wiki</DialogTitle>
<DialogDescription>
上传文档(Markdown / TXT / PDF / Word / PPT / Excel),自动提炼为知识库 wiki 词条并写入 vault。
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<label className="flex flex-col gap-1.5 text-sm">
选择文件
<Input
type="file"
accept=".md,.markdown,.txt,.pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
className="cursor-pointer"
/>
{file ? (
<span className="text-muted-foreground truncate text-xs">
{file.name} · {(file.size / 1024).toFixed(0)} KB
</span>
) : null}
</label>
<Input
placeholder="标题(可选,留空则由模型生成)"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Input
placeholder="标签,逗号分隔(可选)"
value={tags}
onChange={(e) => setTags(e.target.value)}
/>
<label className="flex items-center justify-between gap-2 text-sm">
提炼大模型
<ModelNameSelect
value={model}
onChange={setModel}
disabled={ingestFile.isPending}
triggerClassName="h-9 w-[11rem]"
/>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={() => void handleImport()} disabled={ingestFile.isPending || !file}>
{ingestFile.isPending ? "导入中..." : "导入并生成"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function DedupDialog({
open,
onOpenChange,
@@ -436,13 +457,92 @@ function VaultPageDialog({
);
}
function NoteDetail({ noteId }: { noteId: string }) {
export default function KnowledgeBasePage() {
const navigate = useNavigate();
const { data: note, isLoading } = useKnowledgeNote(noteId);
const { data: wikiLinks } = useNoteWikilinks(noteId);
const { update, remove } = useKnowledgeActions();
const { noteId } = useParams<{ noteId: string }>();
// --- list / tree state ---
const [keyword, setKeyword] = useState("");
const debouncedKeyword = useDebounced(keyword);
const [sourceType, setSourceType] = useState("all");
const [status, setStatus] = useState("all");
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const treeInitializedRef = useRef(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(readSidebarCollapsed);
// --- dialogs ---
const [createOpen, setCreateOpen] = useState(false);
const [batchOpen, setBatchOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [dedupOpen, setDedupOpen] = useState(false);
const [vaultPath, setVaultPath] = useState<string | null>(null);
const params = useMemo(
() => ({
keyword: debouncedKeyword || undefined,
source_type: sourceType === "all" ? undefined : sourceType,
status: status === "all" ? undefined : status,
limit: 500,
}),
[debouncedKeyword, sourceType, status],
);
const { data, isLoading } = useKnowledgeNotes(params);
const notes = data?.items ?? [];
const tree = useMemo(() => buildKnowledgeTree(notes), [notes]);
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
const { data: wikiLinks } = useNoteWikilinks(noteId);
// First load: expand the root-level folders so the tree reads as a directory.
useEffect(() => {
if (treeInitializedRef.current || tree.length === 0) return;
treeInitializedRef.current = true;
setExpandedIds(
new Set(tree.filter((n) => n.type === "folder").map((n) => n.id)),
);
}, [tree]);
// Keep the selected document's ancestor folders open (deep links included).
useEffect(() => {
if (!noteId || tree.length === 0) return;
const ancestors = findAncestorFolderIds(tree, noteId);
if (!ancestors || ancestors.length === 0) return;
setExpandedIds((prev) => {
if (ancestors.every((id) => prev.has(id))) return prev;
const next = new Set(prev);
for (const id of ancestors) next.add(id);
return next;
});
}, [noteId, tree]);
const handleToggleFolder = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleSelectNote = (item: KnowledgeNote) => {
setMobileSidebarOpen(false);
navigate(`/page/workspace/knowledge/${item.id}`);
};
const handleToggleSidebarCollapse = () => {
setSidebarCollapsed((prev) => {
const next = !prev;
try {
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, next ? "1" : "0");
} catch {
// localStorage unavailable (private mode) — state stays session-only.
}
return next;
});
};
const handleWikiLink = (link: WikiLinkTarget) => {
if (link.kind === "note" && link.note_id) {
navigate(`/page/workspace/knowledge/${link.note_id}`);
@@ -450,235 +550,81 @@ function NoteDetail({ noteId }: { noteId: string }) {
setVaultPath(link.vault_path);
}
};
const [editing, setEditing] = useState(false);
const [draftTitle, setDraftTitle] = useState("");
const [draftTags, setDraftTags] = useState("");
const [draftContent, setDraftContent] = useState("");
const [draftStatus, setDraftStatus] = useState("approved");
useEffect(() => {
setEditing(false);
setVaultPath(null);
}, [noteId]);
useEffect(() => {
if (note) {
setDraftTitle(note.title);
setDraftTags(note.tags.join(", "));
setDraftContent(note.content_md);
setDraftStatus(note.status);
}
}, [note]);
if (isLoading || !note) {
return (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
{isLoading ? "加载中..." : "知识不存在"}
</div>
);
}
const handleSave = async () => {
try {
await update.mutateAsync({
noteId: note.id,
data: {
title: draftTitle.trim() || note.title,
content_md: draftContent,
tags: draftTags
.split(/[,,\s]+/)
.map((t) => t.trim())
.filter(Boolean),
status: draftStatus,
},
});
toast.success("已保存");
setEditing(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleArchive = async () => {
try {
await remove.mutateAsync({ noteId: note.id });
toast.success("已归档");
navigate("/page/workspace/knowledge");
} catch (error) {
toast.error(error instanceof Error ? error.message : "归档失败");
}
};
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between gap-2 border-b p-3">
<div className="flex items-center gap-2">
<Badge variant="outline">
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
</Badge>
<Badge variant="secondary">{STATUS_LABELS[note.status] ?? note.status}</Badge>
</div>
<div className="flex items-center gap-2">
{editing ? (
<>
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
<X className="size-4" /> 取消
</Button>
<Button size="sm" onClick={() => void handleSave()} disabled={update.isPending}>
<Save className="size-4" /> 保存
</Button>
</>
) : (
<>
<Button size="sm" variant="ghost" onClick={() => setEditing(true)}>
<Pencil className="size-4" /> 编辑
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => void handleArchive()}
disabled={remove.isPending}
>
<Trash2 className="size-4" /> 归档
</Button>
</>
<div className="flex size-full min-h-0 flex-col bg-slate-50 dark:bg-slate-950">
<WikiHeader
onToggleSidebar={() => setMobileSidebarOpen((v) => !v)}
onImportFile={() => setImportOpen(true)}
onBatchImport={() => setBatchOpen(true)}
onDedup={() => setDedupOpen(true)}
onGraph={() => navigate("/page/workspace/knowledge/graph")}
onCreate={() => setCreateOpen(true)}
/>
<div
className={cn(
"grid min-h-0 flex-1 grid-cols-1",
sidebarCollapsed && !mobileSidebarOpen
? "md:grid-cols-[44px_minmax(0,1fr)]"
: "md:grid-cols-[288px_minmax(0,1fr)]",
)}
>
{mobileSidebarOpen ? (
<div
className="fixed inset-0 z-40 bg-black/30 md:hidden"
onClick={() => setMobileSidebarOpen(false)}
aria-hidden
/>
) : null}
<KnowledgeSidebar
keyword={keyword}
onKeywordChange={setKeyword}
sourceType={sourceType}
onSourceTypeChange={setSourceType}
status={status}
onStatusChange={setStatus}
tree={tree}
expandedIds={expandedIds}
onToggleFolder={handleToggleFolder}
selectedId={noteId}
onSelect={handleSelectNote}
onExportNote={downloadMarkdown}
total={data?.total ?? 0}
isLoading={isLoading}
collapsed={sidebarCollapsed && !mobileSidebarOpen}
onToggleCollapse={handleToggleSidebarCollapse}
className={cn(
"max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:z-50 max-md:w-[300px] max-md:shadow-xl max-md:transition-transform max-md:duration-200",
mobileSidebarOpen ? "max-md:translate-x-0" : "max-md:-translate-x-full",
)}
</div>
/>
{!noteId ? (
<EmptyDocumentState />
) : noteLoading || !note ? (
<div className="flex h-full items-center justify-center bg-white text-sm text-slate-400 dark:bg-slate-900">
{noteLoading ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="size-4 animate-spin" /> 加载中...
</span>
) : (
"知识不存在"
)}
</div>
) : (
<WikiContent
note={note}
wikiLinks={wikiLinks}
onWikiLinkClick={handleWikiLink}
/>
)}
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="p-4">
{editing ? (
<div className="flex flex-col gap-3">
<Input
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
placeholder="标题"
/>
<div className="flex items-center gap-2">
<Input
value={draftTags}
onChange={(e) => setDraftTags(e.target.value)}
placeholder="标签,逗号分隔"
/>
<Select value={draftStatus} onValueChange={setDraftStatus}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">草稿</SelectItem>
<SelectItem value="approved">已批准</SelectItem>
<SelectItem value="archived">已归档</SelectItem>
</SelectContent>
</Select>
</div>
<Textarea
rows={20}
value={draftContent}
onChange={(e) => setDraftContent(e.target.value)}
className="font-mono text-sm"
/>
</div>
) : (
<>
<h1 className="mb-3 text-xl font-semibold">{note.title}</h1>
{note.tags.length > 0 ? (
<div className="mb-2 flex flex-wrap items-center gap-1.5">
{note.tags.map((tag) => (
<span
key={tag}
className="border-primary/20 bg-primary/5 text-primary/90 hover:bg-primary/10 inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors"
>
#{tag}
</span>
))}
</div>
) : null}
{note.entities && note.entities.length > 0 ? (
<div className="mb-4 flex flex-wrap items-center gap-1.5">
<span className="text-muted-foreground mr-0.5 inline-flex items-center gap-1 text-xs">
<Sparkles className="size-3" /> 实体
</span>
{note.entities.map((ent) => {
const meta = entityMeta(ent.entity_type);
return (
<span
key={`${ent.name}-${ent.entity_type ?? ""}`}
title={meta.label}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium",
meta.className,
)}
>
<span className="size-1.5 shrink-0 rounded-full bg-current opacity-70" />
<span>{ent.name}</span>
<span className="rounded-full bg-current/15 px-1.5 py-0.5 text-[10px] leading-none opacity-90">
{meta.label}
</span>
</span>
);
})}
</div>
) : null}
<MarkdownRenderer
content={note.content_md}
wikiLinks={wikiLinks}
onWikiLinkClick={handleWikiLink}
/>
{note.sources && note.sources.length > 0 ? (
<div className="mt-6 border-t pt-4">
<h2 className="mb-2 text-sm font-semibold">来源</h2>
<ul className="flex flex-col gap-2">
{note.sources.map((src) => (
<li key={src.id} className="text-sm">
{src.url ? (
<a
href={src.url}
target="_blank"
rel="noreferrer"
className="text-primary inline-flex items-center gap-1 hover:underline"
>
{src.title || src.url}
<ExternalLink className="size-3" />
</a>
) : src.thread_id ? (
<button
type="button"
className="text-primary hover:underline"
onClick={() =>
navigate(`/page/workspace/chats/${src.thread_id}`)
}
>
{src.title || `对话 ${src.thread_id.slice(0, 8)}`}
</button>
) : (
<span>{src.title || src.tool_name || "来源"}</span>
)}
{src.snippet ? (
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs">
{src.snippet}
</p>
) : null}
</li>
))}
</ul>
</div>
) : null}
<div className="text-muted-foreground mt-6 border-t pt-3 text-xs">
创建人 {actorLabel(note.created_by_name, note.created_by)} · 更新人{" "}
{actorLabel(note.updated_by_name, note.updated_by)} · 更新于{" "}
{note.updated_at?.slice(0, 19).replace("T", " ")}
</div>
</>
)}
</div>
</ScrollArea>
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
<ImportFileDialog open={importOpen} onOpenChange={setImportOpen} />
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
<VaultPageDialog
path={vaultPath}
onOpenChange={(open) => {
@@ -688,134 +634,3 @@ function NoteDetail({ noteId }: { noteId: string }) {
</div>
);
}
export default function KnowledgeBasePage() {
const navigate = useNavigate();
const { noteId } = useParams<{ noteId: string }>();
const [keyword, setKeyword] = useState("");
const debouncedKeyword = useDebounced(keyword);
const [sourceType, setSourceType] = useState("all");
const [status, setStatus] = useState("all");
const [createOpen, setCreateOpen] = useState(false);
const [batchOpen, setBatchOpen] = useState(false);
const [dedupOpen, setDedupOpen] = useState(false);
const params = useMemo(
() => ({
keyword: debouncedKeyword || undefined,
source_type: sourceType === "all" ? undefined : sourceType,
status: status === "all" ? undefined : status,
limit: 50,
}),
[debouncedKeyword, sourceType, status],
);
const { data, isLoading } = useKnowledgeNotes(params);
const notes = data?.items ?? [];
return (
<div className="flex size-full min-h-0 flex-col">
<header className="flex flex-wrap items-center gap-2 border-b p-3">
<BookOpen className="size-5" />
<h1 className="mr-2 text-base font-semibold">知识库</h1>
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-1/2 size-4 -translate-y-1/2" />
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索标题 / 摘要 / 正文 / 标签"
className="w-64 pl-8"
/>
</div>
<Select value={sourceType} onValueChange={setSourceType}>
<SelectTrigger className="w-28">
<SelectValue placeholder="来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部来源</SelectItem>
{Object.entries(SOURCE_TYPE_LABELS).map(([v, label]) => (
<SelectItem key={v} value={v}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-28">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="draft">草稿</SelectItem>
<SelectItem value="approved">已批准</SelectItem>
<SelectItem value="archived">已归档</SelectItem>
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setBatchOpen(true)}>
<RefreshCw className="size-4" /> 批量导入
</Button>
<Button variant="outline" size="sm" onClick={() => setDedupOpen(true)}>
<Copy className="size-4" /> 查重
</Button>
<Button
variant="outline"
size="sm"
onClick={() => navigate("/page/workspace/knowledge/graph")}
>
<Network className="size-4" /> 图谱
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="size-4" /> 新建
</Button>
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[360px_minmax(0,1fr)]">
<div className="flex min-h-0 flex-col border-r">
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-2 p-3">
{isLoading ? (
<div className="text-muted-foreground flex items-center gap-2 p-4 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中...
</div>
) : notes.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
暂无知识。点击「新建」或在对话页「保存到知识库」。
</div>
) : (
notes.map((note) => (
<NoteListItem
key={note.id}
note={note}
active={note.id === noteId}
onClick={() => navigate(`/page/workspace/knowledge/${note.id}`)}
/>
))
)}
</div>
</ScrollArea>
{data ? (
<div className="text-muted-foreground border-t p-2 text-center text-xs">
共 {data.total} 条知识
</div>
) : null}
</div>
<div className="min-h-0">
{noteId ? (
<NoteDetail noteId={noteId} />
) : (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
从左侧选择一条知识查看详情
</div>
)}
</div>
</div>
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
</div>
);
}
+678 -62
View File
@@ -1,79 +1,695 @@
import { ArrowLeft, Loader2, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Loader2, Network, RotateCcw, Tags, Undo2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { knowledgeExportFileUrl } from "@/core/knowledge/api";
import { useKnowledgeActions } from "@/core/knowledge/hooks";
import {
GraphCanvas,
type GraphCanvasHandle,
} from "@/components/knowledge-graph/GraphCanvas";
import { GraphDetailPanel } from "@/components/knowledge-graph/GraphDetailPanel";
import { GraphFooter } from "@/components/knowledge-graph/GraphFooter";
import { GraphHeader } from "@/components/knowledge-graph/GraphHeader";
import { GraphSidebar } from "@/components/knowledge-graph/GraphSidebar";
import { GraphTooltip } from "@/components/knowledge-graph/GraphTooltip";
import type {
ExploreStep,
GraphMode,
LayoutType,
NodeKind,
TooltipState,
} from "@/components/knowledge-graph/graphTypes";
import {
computeDegrees,
computeGraphStats,
getEdgeId,
getEdgeLabel,
getNodeKind,
getNodeTitle,
} from "@/components/knowledge-graph/graphUtils";
import "@/components/knowledge-graph/graphStyles.css";
import { useKnowledgeGraph, useKnowledgeNote } from "@/core/knowledge/hooks";
export default function KnowledgeGraphPage() {
const navigate = useNavigate();
const { exportGraph } = useKnowledgeActions();
const [src, setSrc] = useState<string | null>(null);
const [stats, setStats] = useState<{ nodes: number; edges: number } | null>(null);
const pageRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<GraphCanvasHandle>(null);
const regenerate = useCallback(async () => {
try {
const result = await exportGraph.mutateAsync();
setStats(result.stats);
// Cache-bust the iframe so it reflects the freshly generated graph.
setSrc(`${knowledgeExportFileUrl("graph.html")}?t=${Date.now()}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成图谱失败");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
const { data, isLoading, isError, error, refetch, isFetching } =
useKnowledgeGraph();
// ------------------------------------------------------------------ state
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [searchKeyword, setSearchKeyword] = useState("");
const [disabledNodeKinds, setDisabledNodeKinds] = useState<Set<NodeKind>>(
new Set(),
);
const [disabledEdgeLabels, setDisabledEdgeLabels] = useState<Set<string>>(
new Set(),
);
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<string>>(new Set());
const [layoutType, setLayoutType] = useState<LayoutType>("force");
const [showLabels, setShowLabels] = useState(true);
const [showSidebar, setShowSidebar] = useState(true);
const [showDetail, setShowDetail] = useState(false);
const [zoomLevel, setZoomLevel] = useState(1);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const [graphMode, setGraphMode] = useState<GraphMode>("explore");
const [exploreRootId, setExploreRootId] = useState<string | null>(null);
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<string>>(new Set());
const [visibleNodeIds, setVisibleNodeIds] = useState<Set<string>>(new Set());
const [visibleEdgeIds, setVisibleEdgeIds] = useState<Set<string>>(new Set());
const [nodeDepthMap, setNodeDepthMap] = useState<Map<string, number>>(new Map());
const [exploreHistory, setExploreHistory] = useState<ExploreStep[]>([]);
// Auto-collapse the side panels on narrow viewports.
useEffect(() => {
const detailQuery = window.matchMedia("(max-width: 1280px)");
const sidebarQuery = window.matchMedia("(max-width: 900px)");
const apply = () => {
if (detailQuery.matches) setShowDetail(false);
setShowSidebar(!sidebarQuery.matches);
};
apply();
detailQuery.addEventListener("change", apply);
sidebarQuery.addEventListener("change", apply);
return () => {
detailQuery.removeEventListener("change", apply);
sidebarQuery.removeEventListener("change", apply);
};
}, []);
// ---------------------------------------------------------------- derived
const allNodes = useMemo(() => data?.nodes ?? [], [data]);
const allEdges = useMemo(() => data?.edges ?? [], [data]);
const nodeById = useMemo(
() => new Map(allNodes.map((n) => [n.id, n])),
[allNodes],
);
const degrees = useMemo(
() => computeDegrees(allNodes, allEdges),
[allNodes, allEdges],
);
const stats = useMemo(
() => computeGraphStats(allNodes, allEdges),
[allNodes, allEdges],
);
const nodeKindCounts = useMemo(() => {
const counts = new Map<NodeKind, number>();
for (const n of allNodes) {
const kind = getNodeKind(n);
counts.set(kind, (counts.get(kind) ?? 0) + 1);
}
return counts;
}, [allNodes]);
const edgeLabelCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const e of allEdges) {
const label = getEdgeLabel(e);
counts.set(label, (counts.get(label) ?? 0) + 1);
}
return counts;
}, [allEdges]);
const filteredNodes = useMemo(
() =>
allNodes.filter(
(n) => !disabledNodeKinds.has(getNodeKind(n)) && !hiddenNodeIds.has(n.id),
),
[allNodes, disabledNodeKinds, hiddenNodeIds],
);
const filteredEdges = useMemo(() => {
const visible = new Set(filteredNodes.map((n) => n.id));
return allEdges.filter(
(e) =>
visible.has(e.source) &&
visible.has(e.target) &&
!disabledEdgeLabels.has(getEdgeLabel(e)),
);
}, [allEdges, filteredNodes, disabledEdgeLabels]);
const resetExplore = useCallback(() => {
setExploreRootId(null);
setExpandedNodeIds(new Set());
setVisibleNodeIds(new Set());
setVisibleEdgeIds(new Set());
setNodeDepthMap(new Map());
setExploreHistory([]);
}, []);
const rebuildExploreState = useCallback((history: ExploreStep[]) => {
const nextVisibleNodeIds = new Set<string>();
const nextVisibleEdgeIds = new Set<string>();
const nextNodeDepthMap = new Map<string, number>();
const nextExpandedNodeIds = new Set<string>();
for (const step of history) {
for (const nodeId of step.addedNodeIds) {
nextVisibleNodeIds.add(nodeId);
if (!nextNodeDepthMap.has(nodeId)) nextNodeDepthMap.set(nodeId, step.depth);
}
for (const edgeId of step.addedEdgeIds) nextVisibleEdgeIds.add(edgeId);
if (step.depth > 0) nextExpandedNodeIds.add(step.nodeId);
}
setVisibleNodeIds(nextVisibleNodeIds);
setVisibleEdgeIds(nextVisibleEdgeIds);
setNodeDepthMap(nextNodeDepthMap);
setExpandedNodeIds(nextExpandedNodeIds);
}, []);
const startExploreFromNode = useCallback((nodeId: string) => {
setExploreRootId(nodeId);
setVisibleNodeIds(new Set([nodeId]));
setVisibleEdgeIds(new Set());
setExpandedNodeIds(new Set());
setNodeDepthMap(new Map([[nodeId, 0]]));
setExploreHistory([
{
nodeId,
depth: 0,
addedNodeIds: [nodeId],
addedEdgeIds: [],
timestamp: Date.now(),
},
]);
window.setTimeout(() => canvasRef.current?.focusNode(nodeId), 0);
}, []);
const expandOneHop = useCallback(
(nodeId: string) => {
const currentDepth = nodeDepthMap.get(nodeId) ?? 0;
const nextDepth = currentDepth + 1;
const addedNodeIds: string[] = [];
const addedEdgeIds: string[] = [];
const nextVisibleNodeIds = new Set(visibleNodeIds);
const nextVisibleEdgeIds = new Set(visibleEdgeIds);
const nextNodeDepthMap = new Map(nodeDepthMap);
for (const edge of filteredEdges) {
if (edge.source !== nodeId && edge.target !== nodeId) continue;
const edgeId = getEdgeId(edge);
const neighborId = edge.source === nodeId ? edge.target : edge.source;
if (!nodeById.has(neighborId)) continue;
if (!nextVisibleEdgeIds.has(edgeId)) {
nextVisibleEdgeIds.add(edgeId);
addedEdgeIds.push(edgeId);
}
if (!nextVisibleNodeIds.has(neighborId)) {
nextVisibleNodeIds.add(neighborId);
nextNodeDepthMap.set(neighborId, nextDepth);
addedNodeIds.push(neighborId);
}
}
nextVisibleNodeIds.add(nodeId);
nextNodeDepthMap.set(nodeId, currentDepth);
setVisibleNodeIds(nextVisibleNodeIds);
setVisibleEdgeIds(nextVisibleEdgeIds);
setNodeDepthMap(nextNodeDepthMap);
setExpandedNodeIds((prev) => new Set(prev).add(nodeId));
setExploreHistory((prev) => [
...prev,
{ nodeId, depth: nextDepth, addedNodeIds, addedEdgeIds, timestamp: Date.now() },
]);
},
[filteredEdges, nodeById, nodeDepthMap, visibleEdgeIds, visibleNodeIds],
);
const undoExploreStep = useCallback(() => {
if (exploreHistory.length <= 1) {
resetExplore();
return;
}
const nextHistory = exploreHistory.slice(0, -1);
setExploreHistory(nextHistory);
rebuildExploreState(nextHistory);
}, [exploreHistory, rebuildExploreState, resetExplore]);
useEffect(() => {
void regenerate();
}, [regenerate]);
if (graphMode === "explore") resetExplore();
}, [
disabledNodeKinds,
disabledEdgeLabels,
hiddenNodeIds,
searchKeyword,
resetExplore,
]);
const displayedGraph = useMemo(() => {
if (graphMode === "full") return { nodes: filteredNodes, edges: filteredEdges };
if (!exploreRootId) return { nodes: filteredNodes, edges: [] };
return {
nodes: allNodes.filter((node) => visibleNodeIds.has(node.id)),
edges: allEdges.filter((edge) => visibleEdgeIds.has(getEdgeId(edge))),
};
}, [
allEdges,
allNodes,
exploreRootId,
filteredEdges,
filteredNodes,
graphMode,
visibleEdgeIds,
visibleNodeIds,
]);
const searchMatchIds = useMemo(() => {
const keyword = searchKeyword.trim().toLowerCase();
if (!keyword) return null;
const matches = new Set<string>();
for (const n of filteredNodes) {
if (getNodeTitle(n).toLowerCase().includes(keyword)) matches.add(n.id);
}
return matches;
}, [searchKeyword, filteredNodes]);
const topNodes = useMemo(
() =>
[...allNodes]
.map((node) => ({ node, degree: degrees.get(node.id) ?? 0 }))
.sort((a, b) => b.degree - a.degree)
.slice(0, 8),
[allNodes, degrees],
);
const selectedNode = selectedNodeId ? (nodeById.get(selectedNodeId) ?? null) : null;
const selectedNeighbors = useMemo(() => {
if (!selectedNodeId) return [];
const items: Array<{ node: (typeof allNodes)[number]; relation: string }> = [];
const seen = new Set<string>();
for (const e of displayedGraph.edges) {
const otherId =
e.source === selectedNodeId
? e.target
: e.target === selectedNodeId
? e.source
: null;
if (!otherId || seen.has(otherId)) continue;
const other = nodeById.get(otherId);
if (!other) continue;
seen.add(otherId);
items.push({ node: other, relation: getEdgeLabel(e) });
}
return items.sort(
(a, b) => (degrees.get(b.node.id) ?? 0) - (degrees.get(a.node.id) ?? 0),
);
}, [selectedNodeId, displayedGraph.edges, nodeById, degrees, allNodes]);
// Note detail for note-type nodes (summary / tags / timestamps / status).
const noteId =
selectedNode && (selectedNode.type || "").toLowerCase() === "note"
? selectedNode.id
: undefined;
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
// --------------------------------------------------------------- handlers
const handleSelectNode = useCallback(
(id: string | null) => {
if (!id) {
setSelectedNodeId(null);
setShowDetail(false);
return;
}
setSelectedNodeId(id);
setShowDetail(true);
if (graphMode !== "explore") return;
if (!exploreRootId) {
startExploreFromNode(id);
return;
}
if (!visibleNodeIds.has(id)) return;
if (!expandedNodeIds.has(id)) expandOneHop(id);
},
[
expandedNodeIds,
expandOneHop,
exploreRootId,
graphMode,
startExploreFromNode,
visibleNodeIds,
],
);
const handlePickNode = useCallback(
(id: string) => {
setSelectedNodeId(id);
setShowDetail(true);
if (graphMode === "explore" && !exploreRootId) startExploreFromNode(id);
canvasRef.current?.focusNode(id);
},
[exploreRootId, graphMode, startExploreFromNode],
);
const handleSearchEnter = useCallback(() => {
if (!searchMatchIds || searchMatchIds.size === 0) return;
const first = searchMatchIds.values().next().value;
if (first) handlePickNode(first);
}, [searchMatchIds, handlePickNode]);
const handleToggleNodeKind = useCallback((kind: NodeKind) => {
setDisabledNodeKinds((prev) => {
const next = new Set(prev);
if (next.has(kind)) next.delete(kind);
else next.add(kind);
return next;
});
}, []);
const handleToggleEdgeLabel = useCallback((label: string) => {
setDisabledEdgeLabels((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
}, []);
const handleHideNode = useCallback(
(id: string) => {
setHiddenNodeIds((prev) => new Set(prev).add(id));
setSelectedNodeId((current) => (current === id ? null : current));
if (selectedNodeId === id) setShowDetail(false);
},
[selectedNodeId],
);
const handleRestoreHidden = useCallback(() => {
setHiddenNodeIds(new Set());
}, []);
const handleRefresh = useCallback(() => {
void refetch().then((result) => {
if (result.isError) {
toast.error(
result.error instanceof Error ? result.error.message : "刷新图谱失败",
);
}
});
}, [refetch]);
const handleFullscreen = useCallback(() => {
const el = pageRef.current;
if (!el) return;
if (document.fullscreenElement) void document.exitFullscreen();
else void el.requestFullscreen();
}, []);
const handleViewSource = useCallback(
(id: string) => {
navigate(`/page/workspace/knowledge/${encodeURIComponent(id)}`);
},
[navigate],
);
const handleSetExploreRoot = useCallback(
(id: string) => {
setGraphMode("explore");
startExploreFromNode(id);
setSelectedNodeId(id);
setShowDetail(true);
},
[startExploreFromNode],
);
const exploreDepth = useMemo(() => {
let max = 0;
for (const depth of nodeDepthMap.values()) max = Math.max(max, depth);
return max;
}, [nodeDepthMap]);
const exploreRootTitle = exploreRootId
? getNodeTitle(nodeById.get(exploreRootId) ?? { id: exploreRootId })
: null;
const selectedExploreInfo =
graphMode === "explore" && selectedNodeId
? {
depth: nodeDepthMap.get(selectedNodeId) ?? null,
expanded: expandedNodeIds.has(selectedNodeId),
isRoot: selectedNodeId === exploreRootId,
}
: null;
// ---------------------------------------------------------------- overlay
let overlay: React.ReactNode = null;
if (isLoading) {
overlay = (
<div className="kg-canvas-status">
<Loader2 size={26} className="animate-spin" />
<div className="kg-canvas-status-title">正在加载知识图谱...</div>
</div>
);
} else if (isError) {
overlay = (
<div className="kg-canvas-status">
<Network size={30} />
<div className="kg-canvas-status-title">图谱加载失败</div>
<div className="kg-canvas-status-desc">
{error instanceof Error ? error.message : "请求失败,请稍后重试。"}
</div>
<button type="button" className="kg-icon-button" onClick={handleRefresh}>
重试
</button>
</div>
);
} else if (allNodes.length === 0) {
overlay = (
<div className="kg-canvas-status">
<Network size={30} />
<div className="kg-canvas-status-title">暂无知识图谱数据</div>
<div className="kg-canvas-status-desc">
请先导入知识文档,或从知识库中抽取实体、标签与关系后再查看图谱。
</div>
</div>
);
} else if (filteredNodes.length === 0) {
overlay = (
<div className="kg-canvas-status">
<Network size={30} />
<div className="kg-canvas-status-title">当前筛选没有可显示的节点</div>
<div className="kg-canvas-status-desc">
请在左侧勾选更多节点类型,或恢复已隐藏的节点。
</div>
</div>
);
}
const toolbar = (
<>
<div className="kg-floating-pill">
<span className="kg-pill-label">模式</span>
<button
type="button"
className={`kg-pill-button${graphMode === "explore" ? " kg-pill-button-active" : ""}`}
onClick={() => setGraphMode("explore")}
>
探索模式
</button>
<button
type="button"
className={`kg-pill-button${graphMode === "full" ? " kg-pill-button-active" : ""}`}
onClick={() => setGraphMode("full")}
>
全量图谱
</button>
</div>
{graphMode === "explore" ? (
<>
<div className="kg-floating-pill">
起点:{exploreRootTitle ? exploreRootTitle : "未选择"}
</div>
<div className="kg-floating-pill">
深度 {exploreDepth} · 已显示 {displayedGraph.nodes.length} 节点 /{" "}
{displayedGraph.edges.length} 关系
</div>
<button
type="button"
className="kg-explore-button"
disabled={exploreHistory.length === 0}
onClick={undoExploreStep}
>
<Undo2 size={13} /> 返回上一层
</button>
<button
type="button"
className="kg-explore-button"
disabled={!exploreRootId}
onClick={resetExplore}
>
<RotateCcw size={13} /> 重置探索
</button>
</>
) : null}
<div className="kg-floating-pill">
<span className="kg-pill-label">布局</span>
<button
type="button"
className={`kg-pill-button${layoutType === "force" ? " kg-pill-button-active" : ""}`}
onClick={() => setLayoutType("force")}
>
力导向
</button>
<button
type="button"
className={`kg-pill-button${layoutType === "circular" ? " kg-pill-button-active" : ""}`}
onClick={() => setLayoutType("circular")}
>
环形
</button>
</div>
<div className="kg-floating-pill">
<button
type="button"
className={`kg-pill-button${showLabels ? " kg-pill-button-active" : ""}`}
onClick={() => setShowLabels((v) => !v)}
>
<Tags size={13} /> 标签 {showLabels ? "开" : "关"}
</button>
</div>
</>
);
const hint =
graphMode === "explore" ? (
<div className="kg-canvas-hint">
{!exploreRootId
? "点击任意节点作为起点,开始逐层探索知识关系。"
: "点击已显示节点,可继续展开它的一跳邻居。已展开的节点和路径会保留。"}
</div>
) : null;
return (
<div className="flex size-full min-h-0 flex-col">
<header className="flex items-center gap-2 border-b p-3">
<Button
variant="ghost"
size="sm"
onClick={() => navigate("/page/workspace/knowledge")}
>
<ArrowLeft className="size-4" /> 返回
</Button>
<h1 className="text-base font-semibold">知识图谱</h1>
{stats ? (
<span className="text-muted-foreground text-xs">
{stats.nodes} 个节点 · {stats.edges} 条关系
</span>
) : null}
<Button
variant="outline"
size="sm"
className="ml-auto"
onClick={() => void regenerate()}
disabled={exportGraph.isPending}
>
{exportGraph.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
重新生成
</Button>
</header>
<div className="min-h-0 flex-1">
{src ? (
<iframe
src={src}
title="知识图谱"
className="size-full border-0"
sandbox="allow-scripts allow-same-origin"
<div className="kg-page" ref={pageRef}>
<GraphHeader
nodeCount={displayedGraph.nodes.length}
edgeCount={displayedGraph.edges.length}
totalNodes={allNodes.length}
totalEdges={allEdges.length}
refreshing={isFetching}
onBack={() => navigate("/page/workspace/knowledge")}
onRelayout={() => canvasRef.current?.relayout()}
onFitView={() => canvasRef.current?.fitView()}
onZoomIn={() => canvasRef.current?.zoomIn()}
onZoomOut={() => canvasRef.current?.zoomOut()}
onExport={() => canvasRef.current?.exportImage()}
onFullscreen={handleFullscreen}
onRefresh={handleRefresh}
/>
<div
className={[
"kg-body",
showSidebar ? "" : "kg-hide-left",
showDetail ? "" : "kg-hide-right",
]
.filter(Boolean)
.join(" ")}
>
{showSidebar ? (
<GraphSidebar
searchKeyword={searchKeyword}
searchMatchCount={searchMatchIds ? searchMatchIds.size : null}
nodeKindCounts={nodeKindCounts}
edgeLabelCounts={edgeLabelCounts}
disabledNodeKinds={disabledNodeKinds}
disabledEdgeLabels={disabledEdgeLabels}
stats={stats}
topNodes={topNodes}
hiddenCount={hiddenNodeIds.size}
onSearchChange={setSearchKeyword}
onSearchEnter={handleSearchEnter}
onToggleNodeKind={handleToggleNodeKind}
onToggleEdgeLabel={handleToggleEdgeLabel}
onRestoreHidden={handleRestoreHidden}
onPickNode={handlePickNode}
onClose={() => setShowSidebar(false)}
/>
) : (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
<Loader2 className="mr-2 size-4 animate-spin" /> 生成图谱中...
</div>
)}
) : null}
<div className="kg-canvas-shell">
{!showSidebar ? (
<button
type="button"
className="kg-side-rail-toggle kg-side-rail-toggle-left"
onClick={() => setShowSidebar(true)}
>
筛选
</button>
) : null}
<GraphCanvas
ref={canvasRef}
nodes={displayedGraph.nodes}
edges={displayedGraph.edges}
degrees={degrees}
selectedNodeId={selectedNodeId}
searchMatchIds={searchMatchIds}
layoutType={layoutType}
showLabels={showLabels}
onSelectNode={handleSelectNode}
onTooltip={setTooltip}
onZoomChange={setZoomLevel}
explore={
graphMode === "explore"
? {
preview: !exploreRootId,
rootId: exploreRootId,
nodeDepthMap,
expandedNodeIds,
}
: null
}
toolbar={toolbar}
hint={hint}
overlay={overlay}
/>
{!showDetail ? (
<button
type="button"
className="kg-side-rail-toggle kg-side-rail-toggle-right"
onClick={() => setShowDetail(true)}
>
详情
</button>
) : null}
</div>
{showDetail ? (
<GraphDetailPanel
node={selectedNode}
degree={selectedNodeId ? (degrees.get(selectedNodeId) ?? 0) : 0}
neighbors={selectedNeighbors}
note={noteId ? note : undefined}
noteLoading={Boolean(noteId) && noteLoading}
explore={selectedExploreInfo}
onViewSource={handleViewSource}
onLocate={(id) => canvasRef.current?.focusNode(id)}
onHide={handleHideNode}
onPickNeighbor={handlePickNode}
onExpandNode={expandOneHop}
onSetExploreRoot={handleSetExploreRoot}
onClose={() => setShowDetail(false)}
/>
) : null}
</div>
<GraphFooter
nodeKindCounts={nodeKindCounts}
edgeLabels={[...edgeLabelCounts.keys()]}
zoomLevel={zoomLevel}
selectedTitle={selectedNode ? getNodeTitle(selectedNode) : null}
visibleNodes={displayedGraph.nodes.length}
visibleEdges={displayedGraph.edges.length}
/>
<GraphTooltip tooltip={tooltip} />
</div>
);
}
@@ -122,7 +122,7 @@ export default function LightAppNoteListPage() {
>
<Plus className="h-5 w-5" />
</span>
<span className="text-base font-medium">新建笔记本</span>
<span className="text-base font-medium">新建空间</span>
</button>
);
}
@@ -146,7 +146,7 @@ export default function LightAppNoteListPage() {
>
<Plus className="h-5 w-5" />
</span>
<span className="text-base font-medium">新建笔记本</span>
<span className="text-base font-medium">新建空间</span>
</button>
);
};
@@ -230,7 +230,7 @@ export default function LightAppNoteListPage() {
isDark ? "text-slate-100" : "text-slate-950",
)}
>
我的笔记本
我的空间
</h1>
<div className="flex items-center gap-2">
@@ -0,0 +1,118 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import { getNotebookApiBaseURL } from '@/core/config';
interface NotebookTokenData {
doc_ids: number[];
search_space_id: number;
user_id: string;
user_token: string;
}
export default function NotebookAIWritingPage() {
const [searchParams] = useSearchParams();
const token = searchParams.get('token');
const [data, setData] = useState<NotebookTokenData | null>(null);
const [spaceName, setSpaceName] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!token) {
setError('缺少 token 参数');
return;
}
const base = getNotebookApiBaseURL();
fetch(`${base}/api/v1/ai-writing/notebook-token/${token}`)
.then((res) => {
if (!res.ok) throw new Error(`请求失败 ${res.status}`);
return res.json() as Promise<NotebookTokenData>;
})
.then((d) => {
setData(d);
return fetch(`${base}/api/v1/searchspaces/${d.search_space_id}`, {
headers: { Authorization: `Bearer ${d.user_token}` },
});
})
.then((res) => {
if (!res.ok) return null;
return res.json() as Promise<{ name: string }>;
})
.then((space) => {
if (space?.name) setSpaceName(space.name);
})
.catch((e: Error) => setError(e.message));
}, [token]);
// Keep-alive:每 50 秒 ping 一次 token 接口,防止 Redis TTL 过期
useEffect(() => {
if (!token) return;
const base = getNotebookApiBaseURL();
const id = setInterval(() => {
fetch(`${base}/api/v1/ai-writing/notebook-token/${token}`).catch(() => {});
}, 50_000);
return () => clearInterval(id);
}, [token]);
return (
<div className="h-full flex flex-col overflow-hidden">
{/* 索财笔记 Header —— 只承载空间信息(品牌 / 右侧标签已移除) */}
<div className="relative flex items-center gap-3 px-5 h-[52px] shrink-0 bg-gradient-to-r from-[#1e3a5f] to-[#1a3255] border-b border-[#2a4a70]">
{/* 顶部极细品牌高光线 */}
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#7fb3e8]/55 to-transparent" />
{/* 空间信息:精致玻璃胶囊 */}
{data?.search_space_id && (
<div
className="inline-flex h-7 items-center gap-2 rounded-full border border-[#7fb3e8]/30 bg-gradient-to-b from-[#2d5080]/55 to-[#1a3a60]/55 pl-2 pr-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.06),0_1px_2px_rgba(0,0,0,0.18)] backdrop-blur-sm transition-all hover:border-[#7fb3e8]/55 hover:-translate-y-px hover:shadow-[inset_0_1px_0_rgba(255,255,255,0.08),0_2px_10px_-2px_rgba(59,130,246,0.45)]"
title={`当前空间:${spaceName ?? data.search_space_id}`}
>
{/* 图标徽标 */}
<span className="flex size-[18px] items-center justify-center rounded-[5px] bg-gradient-to-br from-[#3b82f6] to-[#1d4ed8] shadow-[0_0_0_1px_rgba(127,179,232,0.25),0_2px_6px_-1px_rgba(29,78,216,0.6)]">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="size-[11px] text-[#e0f0ff]"
>
<polygon points="12 2 2 7 12 12 22 7 12 2" />
<polyline points="2 17 12 22 22 17" />
<polyline points="2 12 12 17 22 12" />
</svg>
</span>
<span className="text-[11px] font-medium tracking-wide text-[#7fb3e8]">空间</span>
<span className="h-3 w-px bg-[#7fb3e8]/25" />
<span className="max-w-[200px] truncate text-[12.5px] font-semibold text-[#d6e8fb]">
{spaceName ?? data.search_space_id}
</span>
{/* 已连接状态点 */}
<span className="size-1.5 shrink-0 rounded-full bg-[#4ade80] shadow-[0_0_0_2px_rgba(74,222,128,0.18)]" />
</div>
)}
{/* 弹性占位 */}
<div className="flex-1" />
</div>
{/* 主体 */}
<div className="flex-1 min-h-0 overflow-hidden">
{error ? (
<div className="flex items-center justify-center h-full text-red-400 text-sm">{error}</div>
) : !data ? (
<div className="flex items-center justify-center h-full text-[#7fb3e8] text-sm">加载中…</div>
) : (
<AIWritingPage
hideAdvancedFields
initialNotebookId={data.search_space_id}
initialDocumentIds={data.doc_ids}
studioToken={data.user_token}
/>
)}
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { Route, Routes } from 'react-router-dom';
import NotebookAIWritingPage from './NotebookAIWritingPage';
export default function NotebookRoutes() {
return (
<Routes>
<Route path="ai-writing" element={<NotebookAIWritingPage />} />
</Routes>
);
}
+2
View File
@@ -11,6 +11,7 @@ import StrategyRoutes from "./StrategyRoutes";
import Header from "@/strategy-components/Header";
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
import RoundtableRoutes from "./RoundtableRoutes";
import NotebookRoutes from "./NotebookRoutes";
function PageLayout() {
const [collapsed, setCollapsed] = useState(false);
@@ -68,6 +69,7 @@ function PageLayout() {
<Route path="strategy/*" element={<StrategyRoutes />} />
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
<Route path="roundtable/*" element={<RoundtableRoutes />} />
<Route path="notebook/*" element={<NotebookRoutes />} />
</Routes>
</main>
</div>
@@ -34,12 +34,10 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
AdminLeaderboardDimension,
AdminLeaderboardOverview,
AdminLeaderboardQuestion,
AdminLeaderboardResponse,
AdminLeaderboardSkill,
AdminLeaderboardTrendPoint,
@@ -52,8 +50,10 @@ type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency"
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
const RANK_COLORS = ["#C1121F", "#E11D48", "#F59E0B", "#991B1B", "#B45309"] as const;
const LEADERBOARD_PAGE_SIZE = 20;
const RANGE_DAYS = 30;
const LEADERBOARD_PAGE_SIZE = 10;
// 公开页只统计近 7 天:跨日快照在每次请求时合并,天数越多合并越慢(尤其常问问题的
// 模糊聚合是 O(n^2))。7 天足够展示近期热度,同时把请求耗时压到最低。
const RANGE_DAYS = 7;
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
crimson: {
@@ -98,11 +98,6 @@ function fmtDate(value?: string | null): string {
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("zh-CN", { hour12: false });
}
function clampText(value: string, max = 120): string {
const text = value.trim();
return text.length > max ? `${text.slice(0, max)}...` : text;
}
function userScore(user: AdminLeaderboardUser, tab: UserRankTab): number {
if (tab === "questions") return user.qa_count;
if (tab === "tokens") return user.total_tokens;
@@ -367,12 +362,6 @@ export default function PublicLeaderboardPage() {
<DimensionRanking title="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" />
<DimensionRanking title="运行状态分布" icon={Gauge} items={data.run_statuses} empty="当前时间段暂无运行状态" />
</div>
{/* 问题 */}
<div className="grid gap-5 xl:grid-cols-2">
<QuestionPanel title="最近经常问的问题" items={data.frequent_questions} />
<QuestionPanel title="最新问题" items={data.recent_questions} />
</div>
</div>
)}
@@ -688,40 +677,6 @@ function DimensionRanking({
);
}
function QuestionPanel({
title,
items,
}: {
title: string;
items: AdminLeaderboardQuestion[];
}) {
return (
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-center justify-between gap-3 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#213D5E]">
<div className="flex items-center gap-2">
<MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
</div>
<Badge variant="outline">{items.length}</Badge>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="divide-y divide-[#F1D6D6] dark:divide-[#213D5E]">
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368] dark:text-slate-400">暂无问题记录</div>}
{items.map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
<div className="text-sm leading-6 text-[#202124] dark:text-slate-100">{clampText(item.text, 180)}</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400">
<span>{item.email || item.user_id || "未知用户"}</span>
<span>{fmtTime(item.asked_at)}</span>
</div>
</div>
))}
</div>
</ScrollArea>
</section>
);
}
function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
type TrendMetric = keyof Pick<AdminLeaderboardTrendPoint, "runs" | "questions" | "users" | "tokens">;
const [metric, setMetric] = useState<"all" | TrendMetric>("all");
@@ -0,0 +1,193 @@
"use client";
import { Loader2Icon, PlusIcon, StarIcon, Trash2Icon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
usePublishSquares,
useUpdatePublishSquares,
} from "@/core/system-settings/hooks";
import type { PublishSquare } from "@/core/system-settings/api";
/** Local draft row — `id` is stable (server- or client-generated) so the
* "default" radio can reference it before the first save. */
type Draft = PublishSquare;
function genId(): string {
// Frontend-only id; the backend also accepts/normalizes these.
return `sq_${Math.random().toString(36).slice(2, 10)}`;
}
export default function PublishSquaresSettingsPage({
embedded = false,
}: {
embedded?: boolean;
} = {}) {
const { data, isLoading, error } = usePublishSquares();
const update = useUpdatePublishSquares();
const [squares, setSquares] = useState<Draft[]>([]);
const [defaultId, setDefaultId] = useState<string>("");
const initializedRef = useRef(false);
useEffect(() => {
if (!data || initializedRef.current) return;
setSquares(data.squares.map((s) => ({ ...s })));
setDefaultId(data.default_square_id);
initializedRef.current = true;
}, [data]);
function addSquare() {
setSquares((prev) => [...prev, { id: genId(), name: "", description: "" }]);
}
function patchSquare(id: string, patch: Partial<Draft>) {
setSquares((prev) =>
prev.map((s) => (s.id === id ? { ...s, ...patch } : s)),
);
}
function removeSquare(id: string) {
setSquares((prev) => prev.filter((s) => s.id !== id));
if (defaultId === id) setDefaultId("");
}
async function handleSave() {
const cleaned = squares
.map((s) => ({ ...s, name: s.name.trim(), description: s.description.trim() }))
.filter((s) => s.name.length > 0);
if (cleaned.length === 0) {
toast.error("请至少配置一个广场(名称不能为空)");
return;
}
// Re-anchor the default to a surviving square if the picked one was removed.
const nextDefault = cleaned.some((s) => s.id === defaultId)
? defaultId
: cleaned[0].id;
try {
const saved = await update.mutateAsync({
squares: cleaned,
default_square_id: nextDefault,
});
setSquares(saved.squares.map((s) => ({ ...s })));
setDefaultId(saved.default_square_id);
toast.success("已保存");
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
}
}
return (
<div
className={
embedded
? "flex w-full flex-col gap-6"
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
}
>
<div>
<h1
className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}
>
发布广场配置
</h1>
<p className="text-muted-foreground mt-1 text-sm">
管理员可在此配置多个「发布广场」。发布智能体或技能时,未选择广场则归入默认广场,也可选择发布到其他广场;
每个智能体或技能只能归属于一个广场,不会重复出现在多个广场中。
</p>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
</div>
) : error ? (
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
加载失败:{error.message}
</div>
) : (
<div className="flex flex-col gap-4 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="flex flex-col gap-3">
{squares.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
暂无广场,点击下方「添加广场」创建第一个。
</p>
) : (
squares.map((s) => {
const isDefault = defaultId === s.id;
return (
<div
key={s.id}
className="flex flex-col gap-2 rounded-lg border p-3 dark:border-[#1B477E]"
>
<div className="flex items-center gap-2">
<Button
type="button"
size="icon-sm"
variant="ghost"
title={isDefault ? "默认广场" : "设为默认广场"}
className={
isDefault ? "text-amber-500 hover:text-amber-600" : ""
}
onClick={() => setDefaultId(s.id)}
>
<StarIcon
className={isDefault ? "size-4 fill-current" : "size-4"}
/>
</Button>
<Input
value={s.name}
placeholder="广场名称(如:公开广场 / 合作伙伴 / 内测)"
onChange={(e) => patchSquare(s.id, { name: e.target.value })}
className="h-9 flex-1 text-sm"
/>
<Button
type="button"
size="icon-sm"
variant="ghost"
className="text-destructive hover:text-destructive"
title="删除广场"
onClick={() => removeSquare(s.id)}
>
<Trash2Icon className="size-4" />
</Button>
</div>
<Input
value={s.description}
placeholder="广场描述(选填)"
onChange={(e) =>
patchSquare(s.id, { description: e.target.value })
}
className="h-8 text-xs"
/>
{isDefault && (
<span className="text-muted-foreground text-xs">
默认广场:发布时未选择广场的智能体/技能会归入此处。
</span>
)}
</div>
);
})
)}
</div>
<div className="flex items-center justify-between">
<Button type="button" variant="outline" size="sm" onClick={addSquare}>
<PlusIcon className="mr-1.5 size-4" />
添加广场
</Button>
<Button onClick={handleSave} disabled={update.isPending}>
{update.isPending && (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
)}
保存
</Button>
</div>
</div>
)}
</div>
);
}
File diff suppressed because it is too large. Load diff
+15 -15
View File
@@ -180,18 +180,18 @@ export default function StudioNotebooksPage() {
<div className="min-h-full bg-background p-8">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-foreground">笔记本</h1>
<h1 className="text-2xl font-semibold text-foreground">空间</h1>
<button
onClick={() => { setCreateOpen(true); setCreateError(""); setCreateName(""); }}
className="flex items-center gap-1.5 rounded-full bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<Plus className="size-4" />
新建笔记本
新建空间
</button>
</div>
{isError && (
<p className="text-sm text-muted-foreground">加载笔记本失败,请稍后重试。</p>
<p className="text-sm text-muted-foreground">加载空间失败,请稍后重试。</p>
)}
{/* Grid */}
@@ -199,20 +199,20 @@ export default function StudioNotebooksPage() {
{isLoading
? Array.from({ length: 8 }).map((_, i) => <NotebookCardSkeleton key={i} />)
: notebooks.map((nb) => (
<NotebookCard
key={nb.id}
nb={nb}
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
/>
))}
<NotebookCard
key={nb.id}
nb={nb}
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
/>
))}
</div>
{/* ── 新建 Dialog ── */}
<Dialog open={createOpen} onOpenChange={(v) => !creating && setCreateOpen(v)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>新建笔记本</DialogTitle>
<DialogTitle>新建空间</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2">
<input
@@ -220,7 +220,7 @@ export default function StudioNotebooksPage() {
value={createName}
onChange={(e) => setCreateName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }}
placeholder="笔记本名称"
placeholder="空间名称"
disabled={creating}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
/>
@@ -239,7 +239,7 @@ export default function StudioNotebooksPage() {
<Dialog open={!!renameTarget} onOpenChange={(v) => !renaming && !v && setRenameTarget(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>重命名笔记本</DialogTitle>
<DialogTitle>重命名空间</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2">
<div className="flex items-center gap-3">
@@ -272,12 +272,12 @@ export default function StudioNotebooksPage() {
<Dialog open={!!deleteTarget} onOpenChange={(v) => !deleting && !v && setDeleteTarget(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>删除笔记本</DialogTitle>
<DialogTitle>删除空间</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 pt-1">
<p className="text-sm text-muted-foreground">
确定要删除{deleteTarget ? <span className="font-medium text-foreground">「{deleteTarget.name}」</span> : ""}吗?
删除后笔记本内所有来源数据将一并移除,且无法恢复。
删除后空间内所有来源数据将一并移除,且无法恢复。
</p>
{deleteError && <p className="text-xs text-destructive">{deleteError}</p>}
<div className="flex justify-end gap-2">
@@ -31,6 +31,7 @@ import CheckpointMigrationPage from './CheckpointMigrationPage';
import MemoryPage from './MemoryPage';
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
import SkillEvolutionPage from './SkillEvolutionPage';
import SkillsPage from './SkillsPage';
import UsersPage from './UsersPage';
@@ -496,6 +497,18 @@ export default function WorkspaceRoutes({
)
}
/>
<Route
path="settings/publish-squares"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<PublishSquaresSettingsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/llm-metrics"
element={
+11 -5
View File
@@ -163,7 +163,7 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
6. **GuardrailMiddleware** - Pre-tool-call authorization via pluggable `GuardrailProvider` protocol (optional, if `guardrails.enabled` in config). Evaluates each tool call and returns error ToolMessage on deny. Three provider options: built-in `AllowlistProvider` (zero deps), OAP policy providers (e.g. `aport-agent-guardrails`), or custom providers. See [docs/GUARDRAILS.md](docs/GUARDRAILS.md) for setup, usage, and how to implement a provider.
7. **SandboxAuditMiddleware** - Audits sandboxed shell/file operations for security logging before tool execution continues
8. **ToolErrorHandlingMiddleware** - Converts tool exceptions into error `ToolMessage`s so the run can continue instead of aborting
9. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
9. **SummarizationMiddleware** (`DeerFlowSummarizationMiddleware`) - Context reduction when approaching token limits (optional, if enabled). **Non-destructive**: overrides the stock `before_model` (which persists a `RemoveMessage(REMOVE_ALL_MESSAGES)`) to a no-op and instead compresses **transiently** in `wrap_model_call` — the model request is replaced with `[summary, *recent]` while the full original conversation stays in state (so the frontend keeps rendering the user's real Q&A). The `summary` message is `hide_from_ui` and never persisted; summaries are cached per-thread (bounded, sticky boundary) to avoid an LLM call on every model invocation. Tests: `tests/test_summarization_middleware.py`
10. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
11. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
12. **TitleMiddleware** - Two-phase auto-title (never shows "untitled"): `before_model` sets an instant question-derived *provisional* title the moment the first user message arrives (no LLM, zero latency), then `after_model` asynchronously generates the final LLM title and replaces it (tracked via the `title_provisional` state flag). Normalizes structured message content before prompting the title model
@@ -175,7 +175,7 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
18. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
8a. **ToolMetricsMiddleware** - Appended immediately after `ToolErrorHandlingMiddleware` in `_build_runtime_middlewares`, so it sits *deepest* in the `wrap_tool_call` chain. Records one `tool_call_metrics` row per tool/skill/MCP invocation — timing duration and bucketing failures into a coarse `error_category` (rate_limited / ip_blocked / auth_error / timeout / network_error / not_found / empty_result / tool_error). It catches both raised exceptions (before #8 converts them) and *self-handled* errors returned as error `ToolMessage`s (search tools that swallow HTTP 429/403). It also captures the targeted Agent Skill into `tool_call_metrics.skill_name`: the lead agent invokes a skill by `read_file`-ing its `/mnt/skills/<category>/<name>/SKILL.md` path (per the `<skill_system>` prompt block — there is no dedicated "use skill" tool), so `skill_name` is recovered by scanning every tool call's string arguments for the `skills/{public,custom}/{name}/` path shape (`_SKILL_PATH_RE`), plus the explicit `name` argument of `skill_view`/`skill_manage`/`skill_archive`. `skill_view`-specific success detection handles its swallowed failures (`Skill 'x' not found.` returned as a plain string rather than raised). Regression coverage: `tests/test_tool_metrics_middleware.py`. Writes are best-effort fire-and-forget. **Per-run skill merging**: `SqlToolMetricsStore.record` upserts skill rows by `(run_id, skill_name)` instead of inserting one row per call — a skill touched by several tool calls / retries inside one run collapses to a single row. Any success in the run wins (a skill that failed then finally succeeded is recorded as one `success`); a skill that only ever failed yields one `error` row whose `error_message` accumulates every attempt's message; durations are summed. Non-skill tool calls, and skill calls with no `run_id`, keep one row each. The read→merge→write is guarded by a per-event-loop `asyncio.Lock`. Surfaced by the admin `GET /api/admin/tool-metrics` router (which has a `kind=skill` filter and a per-skill `/summary` breakdown).
9. **PromptPrefixMiddleware** - Injects the admin-configured prompt prefix into the model request only. The prefix is tagged onto the first human message's `additional_kwargs['prompt_prefix']` by the Gateway (`app.gateway.services`) and prepended to content here at call time, so the persisted/displayed message stays prefix-free
10. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
10. **SummarizationMiddleware** (`DeerFlowSummarizationMiddleware`) - Context reduction when approaching token limits (optional, if enabled). **Non-destructive**: overrides the stock `before_model` (which persists a `RemoveMessage(REMOVE_ALL_MESSAGES)`) to a no-op and instead compresses **transiently** in `wrap_model_call` — the model request is replaced with `[summary, *recent]` while the full original conversation stays in state (so the frontend keeps rendering the user's real Q&A). The `summary` message is `hide_from_ui` and never persisted; summaries are cached per-thread (bounded, sticky boundary) to avoid an LLM call on every model invocation. Tests: `tests/test_summarization_middleware.py`
11. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
12. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
13. **TitleMiddleware** - Two-phase auto-title (never shows "untitled"): `before_model` sets an instant question-derived *provisional* title the moment the first user message arrives (no LLM, zero latency), then `after_model` asynchronously generates the final LLM title and replaces it (tracked via the `title_provisional` state flag). Normalizes structured message content before prompting the title model
@@ -250,13 +250,13 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
| **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. |
| **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). |
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` → `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "已改用规则抽取". The "保存到知识库" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector→keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[…]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). Backed by the `deerflow.knowledge` module (see below). |
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` → `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "已改用规则抽取". The "保存到知识库" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector→keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[…]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). Backed by the `deerflow.knowledge` module (see below). |
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
### Admin Leaderboard Snapshots (`/api/admin/users/leaderboard*`)
The admin 用户排行榜 is served from **precomputed per-day snapshots** in `admin_leaderboard_daily_stats` (keyed by `(stat_date, settings_hash)`), not by aggregating raw rows on every request. A background scheduler inside the Gateway (`_leaderboard_snapshot_scheduler_loop` in `app/gateway/app.py`, singleton file-locked) ticks every `ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS` (default 300s): it prewarms recent days, then builds any `queued`/`error`/stale-`running` day. `GET /leaderboard` merges the per-day snapshots for the requested range. **Today** is rebuilt whenever its snapshot is older than `ADMIN_LEADERBOARD_TODAY_TTL_SECONDS` (default 300s); a past day, once `ready`, is treated as fresh forever.
The admin 用户排行榜 is served from **precomputed per-day snapshots** in `admin_leaderboard_daily_stats` (keyed by `(stat_date, settings_hash)`), not by aggregating raw rows on every request. A background scheduler inside the Gateway (`_leaderboard_snapshot_scheduler_loop` in `app/gateway/app.py`, singleton file-locked) ticks every `ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS` (default 300s): it prewarms recent days, then builds any `queued`/`error`/stale-`running` day. `GET /leaderboard` merges the per-day snapshots for the requested range. **Today** is rebuilt whenever its snapshot is older than `ADMIN_LEADERBOARD_TODAY_TTL_SECONDS` (default 43200s = half a day, so the leaderboard refreshes ~twice daily); a past day, once `ready`, is treated as fresh forever.
Two on-demand/automatic refresh paths layer on top:
- **Manual "刷新当天"** — `POST /leaderboard/refresh-today` force-rebuilds *today's* snapshot **synchronously** (bounded by the same semaphore/timeout the scheduler uses), then returns the merged range. Lets an admin pull the latest real-time records into the stats without waiting for the TTL. Frontend: a "刷新当天" button on `AdminUserLeaderboardPage` → `useRefreshTodayLeaderboard` (`core/admin/hooks.ts`), which primes + invalidates the `["admin-activity","leaderboard"]` query.
@@ -290,7 +290,7 @@ Self-contained product knowledge base. **All knowledge capability lives in this
- **DB mirror** — tables `knowledge_notes` / `knowledge_sources` / `knowledge_tags` / `knowledge_note_tags` (`knowledge/models.py`, registered in `persistence/models/__init__.py`, auto-created by `Base.metadata.create_all`; portable types `PortableLongText`/`BeijingDateTime`). Powers listing/filter/search/audit. Global/shared — no `user_id` column; `created_by`/`updated_by` are audit-only (resolved via `get_effective_user_id()`).
- **Markdown vault** — obsidian-wiki compatible, default `{$DEER_FLOW_HOME}/data/knowledge/obsidian-vault`. `ObsidianWikiAdapter` (`obsidian_adapter.py`) writes wiki-capture frontmatter+body, and after **every** write syncs `index.md` / `log.md` (CAPTURE/INGEST/UPDATE/ARCHIVE/EXPORT) / `hot.md` / `.manifest.json` (source-hash dedup for `skip_existing`). `markdown_vault.py` handles frontmatter build/parse, slugging, wikilink extraction and **path-traversal-proof** resolution.
**Module layout:** `models.py` (ORM: notes/sources/tags/note_tags + embeddings/versions/entities/relations) · `repository.py` (DB only, no LLM) · `schemas.py` (NoteDraft/SourceDraft DTOs) · `skill_templates.py` · `markdown_vault.py` · `manifest.py` · `extractor.py` (rule-based fallback) · `llm_extractor.py` (**default**: distills declarative knowledge per wiki-capture, structured JSON incl. entities/relations; falls back to `extractor.py`) · `sources.py` · `exporter.py` (`graph.json`/`graph.graphml`/`cypher.txt` + self-contained vanilla-JS `graph.html`; entity nodes + relation edges) · `chunking.py` + `embeddings.py` (OpenAI-compatible embedding client, keyword fallback) + `search.py` (keyword/vector/hybrid) · `sensitive.py` (secret/PII redaction) · `dedup.py` (Jaccard + hash dedup) · `auto_ingest.py` (background queue/worker) · `obsidian_adapter.py` · `service.py` (`KnowledgeService` orchestrator — the only thing the router touches). Tests: `tests/test_knowledge.py` (20 cases). Frontend: `frontend-web/src/core/knowledge/*` + `pages/KnowledgeBasePage.tsx` / `KnowledgeGraphPage.tsx` + chat `knowledge-save-trigger.tsx` / `knowledge-rag-toggle.tsx`; spec at `frontend-web/docs/product-knowledge-base-dev.md`.
**Module layout:** `models.py` (ORM: notes/sources/tags/note_tags + embeddings/versions/entities/relations) · `repository.py` (DB only, no LLM) · `schemas.py` (NoteDraft/SourceDraft DTOs) · `skill_templates.py` · `markdown_vault.py` · `manifest.py` · `extractor.py` (rule-based fallback) · `llm_extractor.py` (**default**: distills declarative knowledge per wiki-capture, structured JSON incl. entities/relations; falls back to `extractor.py`) · `sources.py` · `exporter.py` (`graph.json`/`graph.graphml`/`cypher.txt` + self-contained vanilla-JS `graph.html`; entity nodes + relation edges. **`graph.html` is theme-aware** — it reads `?theme=light|dark` from its own URL since the embedding iframe can't inherit the app's CSS; `KnowledgeGraphPage.tsx` appends the active day/night mode and re-skins on toggle. The layout cools down and **freezes** so dense graphs stay clickable, node radius scales with degree, and hovering/clicking a node focuses it + its direct neighbours while dimming the rest + a detail panel — taming heavy node/edge clutter) · `chunking.py` + `embeddings.py` (OpenAI-compatible embedding client, keyword fallback) + `search.py` (keyword/vector/hybrid) · `sensitive.py` (secret/PII redaction) · `dedup.py` (Jaccard + hash dedup) · `auto_ingest.py` (background queue/worker) · `obsidian_adapter.py` · `service.py` (`KnowledgeService` orchestrator — the only thing the router touches; capture entry points `capture_thread` / `capture_search` / `capture_file` / `create_manual_note`, all converging on `_persist_draft`). Tests: `tests/test_knowledge.py` (20 cases). Frontend: `frontend-web/src/core/knowledge/*` + `pages/KnowledgeBasePage.tsx` / `KnowledgeGraphPage.tsx` + chat `knowledge-save-trigger.tsx` / `knowledge-rag-toggle.tsx`; spec at `frontend-web/docs/product-knowledge-base-dev.md`.
**Wikilinks & multi-note capture** (`knowledge.{related_notes_enabled,related_notes_limit,multi_note_enabled}`, all default-on; `project_name` defaults to `zncm`):
- **Navigable `[[wikilinks]]`** — `service.resolve_wikilink(s)` maps a `[[target]]` to a knowledge note (by id → `vault_path` → title) or a vault meta page (`read_vault_page`, path-traversal-proof), else `missing`. `markdown_vault.normalize_wikilink_target` canonicalizes targets (drops `|alias`, slashes, `.md`). The frontend `MarkdownRenderer` renders non-numeric `[[...]]` as clickable links from the resolver map (numeric `[[123]]` stay citation badges); note links navigate, vault links open a `VaultPageDialog`, missing links are greyed.
@@ -311,6 +311,12 @@ Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.age
**Homepage selector (agent picker on the new-chat page)** — single admin-curated list backed by `agents.featured_order` (nullable BIGINT). NULL = not featured. Only `published = true` agents can be featured (router enforces on writes); any published agent is eligible regardless of ownership. Cleared with `order: null`. The list rendered to end users is `WHERE featured_order IS NOT NULL` + visibility-filter, sorted by `featured_order ASC`. Admins reorder by sending the desired ids to `PUT /api/agents/featured/order` (assigns indices 0..N-1). BIGINT is used rather than INT so the management UI can seed initial values from `Date.now()` (overflows INT on MySQL).
### Publish Squares (发布广场)
Admin-configurable, **access-control-free** categories that published agents/skills are filed under (distinct from the password-gated 私密广场). The square list lives in `system_settings.json` → `publish_squares` (`PublishSquaresSettings`: ordered `squares: [{id,name,description}]` + `default_square_id`); helpers `list_publish_squares` / `resolve_default_square_id` / `normalize_square_id` always yield a valid square (built-in "默认广场" fallback when none configured). Admin CRUD: `GET/PUT /api/system-settings/publish-squares` (GET readable by any user — the gallery sub-tabs + publish picker need it; PUT admin-only, auto-slugs missing ids and re-anchors a dangling default).
Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, `String(64)` NOT NULL default `''`, migration `20260611_01`, idempotent SQL in `column_additions_mysql.sql`). One column = at most one square, so an item can never be "published twice". `''` = unassigned → resolved to the default square at read/filter time. Writes normalize via `normalize_square_id`: agents on create/update (`square_id` field), skills on publish (`PUT /api/skills/custom/{name}/published` accepts optional `square_id`). Listing filters: `GET /api/agents?square=` and `GET /api/skills?square=` keep only that square's items; the **default** square additionally sweeps in legacy `''` rows (`square_is_default`). Tests: `tests/test_publish_squares.py`. Frontend: `core/system-settings` (`usePublishSquares`/`useUpdatePublishSquares`), admin `PublishSquaresSettingsPage` (embedded in the appearance/settings hub), square picker in the agent edit dialog + per-skill card, and a 广场-tab square sub-filter in both galleries.
### Portable Column Types (`deerflow/persistence/types.py`)
Two `TypeDecorator`s keep ORM columns correct across the `sqlite` / `postgres` / `mysql` backends. **All ORM timestamp/JSON columns must use these, not the raw SQLAlchemy types.**
@@ -19,24 +19,30 @@
并配合 ``SkillStopMiddleware`` 在 ``agent_orchestration`` 调用后停图,便于网关从
AIMessage.tool_calls 解析派活列表。
- ``seat`` —— 子智能体交付:开 thinking + medium reasoning;**可写文件**
(``write_file`` / ``str_replace`` 产出 md 等成品文档),但禁派活 / 澄清 /
展示文件 / 看图 / 联网搜索 / **``bash``**。禁 ``bash`` 的原因:圆桌席位是
「文档产出者」,弱模型爱写 ``.py`` 脚本再跑出产物(乱且抓不到预览),与
``report`` 角色禁 ``bash`` 同理;让它**直接 write_file 写成品文档**。
(``write_file`` / ``str_replace`` 产出 md 等成品文档)+ **可跑 ``bash``**
(2026-06 放开:业务需要跑知识库检索 / Python 辅助分析)。禁派活 / 澄清 /
展示文件 / 看图 / 联网搜索。``report`` 通过 ``[*_SEAT_EXCLUDED, ...]`` 继承本角色,
所以 Step3 报告也跟着拿到 bash;只有 ``leader`` 和 ``seat_parallel`` 仍各自显式禁。
历史警示(保留):弱模型用 ``bash`` 跑 ``python xxx.py > out.md`` 生成产物会
**绕过前端虚拟预览**(预览只拦截 ``write_file.args.content``)→ 沙箱里有文件但
Step2 右侧预览空白。席位 SOUL 必须明确「bash 只用于辅助查询拿数据,**最终交付
必须用 write_file 写 .md 落盘**,不要让脚本生成最终产物」。
- ``seat_parallel`` —— **DAG 快速(fast)模式的并行席位(方案 A,2026-06)**:**禁写文件**
(``write_file`` / ``str_replace``,产物走正文)+ 禁 ``bash`` + 禁「打断 / 越权」类,
(``write_file`` / ``str_replace``,产物走正文)+ **禁 ``bash``** + 禁「打断 / 越权」类,
**放开 ``web_search`` 与技能**(搜索不碰 sandbox;技能靠 ``read_file`` SKILL.md)——
否则并行席位几乎调不动工具、产生不了步骤(步骤条不显示)。
并行席位同时跑 bash 会在共享 sandbox 里抢路径 / 占进程数;且 fast 模式整体不
写文件,bash 跑出来的产物既不能预览也没意义。
「写文件产出 md」归 **沙箱(sandbox)模式**(走 ``seat`` policy,单列 + 沙箱跟随预览);
fast 模式快、双列、产物走正文 —— 用户明确要求 fast 不生成文件。详见
``docs/roundtable-dag-orchestration.md`` §4 / §12.1 / §12.8 / §12.10。
与 ``seat`` 的差异:``seat`` 禁 ``web_search``(旧「圆桌一律禁搜索」),
``seat_parallel`` **放开搜索**(本期诉求)。
- ``report`` —— Step3 报告绘制:在 ``seat``(已禁 ``bash``)基础上**额外禁
``str_replace``**:
* ``bash`` 已在 ``seat`` 禁 → 杜绝写 Python 脚本再跑出 HTML(脚本产物落在
outputs,但前端虚拟预览只抓 ``write_file`` 的 ``args.content``,抓不到脚本
产物 → 预览停在半截 ``write_file``,正是「没写完就去生成 py 文件」的现象);
``seat_parallel`` **放开搜索**(本期诉求);``seat`` 已放开 ``bash``,本角色仍禁。
- ``report`` —— Step3 报告绘制:在 ``seat`` 基础上**额外禁 ``str_replace``**:
* 继承 ``seat`` 的 bash 放开(2026-06)—— Step3 也可跑 bash 做辅助查询,同
``seat`` 警示:**结果必须 write_file 落盘**,否则前端虚拟预览会空白
(预览只抓 ``write_file.args.content``,抓不到脚本产物 → 预览停在半截
``write_file``,正是「没写完就去生成 py 文件」的现象)。
* 禁 ``str_replace`` → 强制返工用整文件 ``write_file`` 重写
(``str_replace`` 没有 ``content`` 字段,会让前端虚拟预览读到 undefined
而清空沙箱)。
@@ -72,16 +78,21 @@ _LEADER_EXCLUDED: list[str] = [
"hindsight_retain",
"read_peer_delivery", # 总控靠 _collect_delivery_status 直读各席位,不需要这个席位侧工具
]
# seat:席位**可以写文件**(write_file / str_replace)产出 md 等成品文档,但**禁 bash** ——
# 圆桌席位是「文档产出者」,不该跑 shell/python 脚本(弱模型爱写 .py 脚本再跑生成产物,既乱又
# 抓不到产物预览,与 report 角色禁 bash 同理)。禁 web_search(圆桌串行一律禁搜索,旧决策)。
# seat:席位**可以写文件**(write_file / str_replace)产出 md 等成品文档,**bash 放开**
# (2026-06 决策,业务需要 bash 跑知识库检索 / Python 辅助分析)。禁派活 / 澄清 /
# 展示文件 / 看图 / 联网搜索。``report`` 通过 ``[*_SEAT_EXCLUDED, ...]`` 继承本角色,
# 所以 Step3 报告跟着拿到 bash;只有 leader / seat_parallel 仍各自显式禁。
#
# **历史警示(保留)**:弱模型用 bash 跑 ``python xxx.py > out.md`` 生成最终产物会
# 绕过前端虚拟预览(只拦 write_file.args.content) → 沙箱里有文件但 Step2 右侧预览
# 空白。必须靠席位 SOUL 强约束「bash 只用于辅助查询拿数据,最终交付必须用 write_file
# 写 .md 落盘」。
_SEAT_EXCLUDED: list[str] = [
"web_search",
"ask_clarification",
"present_files",
"view_image",
"agent_orchestration",
"bash",
]
# 并行席位 = **快速(fast)模式**(双列):**禁写文件**(产物走正文)+ 禁跑命令 +「打断/越权」类。
# **禁 web_search** —— 离线内网部署无外网,席位检索一律走「技能(skill)」(靠 read_file 读
@@ -20,7 +20,6 @@ import os
import re
from collections.abc import AsyncIterator
from contextvars import ContextVar
from difflib import SequenceMatcher
from hashlib import sha256
from time import perf_counter
from typing import Any
@@ -83,7 +82,7 @@ _LEADERBOARD_DAILY_SNAPSHOT_LIMIT = max(
)
_LEADERBOARD_TODAY_TTL_SECONDS = max(
30.0,
float(os.getenv("ADMIN_LEADERBOARD_TODAY_TTL_SECONDS", "300")),
float(os.getenv("ADMIN_LEADERBOARD_TODAY_TTL_SECONDS", "43200")),
)
_LEADERBOARD_QUERY_TIMEOUT_MS = max(
500,
@@ -269,15 +268,6 @@ class AdminLeaderboardDimension(BaseModel):
last_seen_at: str = ""
class AdminLeaderboardQuestion(BaseModel):
text: str
user_id: str = ""
email: str = ""
thread_id: str = ""
run_id: str = ""
asked_at: str = ""
class AdminLeaderboardTrendPoint(BaseModel):
date: str
runs: int = 0
@@ -314,8 +304,6 @@ class AdminLeaderboardResponse(BaseModel):
models: list[AdminLeaderboardDimension] = Field(default_factory=list)
agents: list[AdminLeaderboardDimension] = Field(default_factory=list)
run_statuses: list[AdminLeaderboardDimension] = Field(default_factory=list)
recent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
frequent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
@@ -330,7 +318,6 @@ class AdminUserAnalyticsResponse(BaseModel):
models: list[AdminLeaderboardDimension] = Field(default_factory=list)
agents: list[AdminLeaderboardDimension] = Field(default_factory=list)
run_statuses: list[AdminLeaderboardDimension] = Field(default_factory=list)
recent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
@@ -1014,129 +1001,6 @@ async def _query_model_rankings(
]
async def _query_recent_questions(
session,
since: datetime,
until: datetime,
*,
user_id: str | None,
limit: int,
exclude_user_ids: set[str] | None = None,
include_scheduled: bool = True,
include_admins: bool = True,
include_failed: bool = True,
cleanup_system_questions: bool = True,
) -> list[AdminLeaderboardQuestion]:
exclude_user_ids = exclude_user_ids or set()
stmt = (
select(RunRow.first_human_message, RunRow.user_id, UserRow.email, RunRow.thread_id, RunRow.run_id, RunRow.created_at)
.outerjoin(UserRow, UserRow.id == RunRow.user_id)
.where(_run_time_filter(since, until), RunRow.first_human_message.is_not(None), RunRow.first_human_message != "")
.order_by(RunRow.created_at.desc())
.limit(limit)
)
if user_id:
stmt = stmt.where(RunRow.user_id == user_id)
stmt = _apply_run_scope(
stmt,
user_column=RunRow.user_id,
run_id_column=RunRow.run_id,
exclude_user_ids=exclude_user_ids,
include_admins=include_admins,
include_failed=include_failed,
include_scheduled=include_scheduled,
)
rows = (await _execute_leaderboard_query(session, "recent_questions", stmt)).all()
questions: list[AdminLeaderboardQuestion] = []
for r in rows:
text = _clean_question_text(r[0]) if cleanup_system_questions else str(r[0] or "").strip()
if cleanup_system_questions and not _is_displayable_question(text):
continue
questions.append(
AdminLeaderboardQuestion(
text=text[:500],
user_id=r[1] or "",
email=r[2] or "",
thread_id=r[3] or "",
run_id=r[4] or "",
asked_at=_iso(r[5]),
)
)
return questions
async def _query_frequent_questions(
session,
since: datetime,
until: datetime,
limit: int,
exclude_user_ids: set[str] | None = None,
include_scheduled: bool = True,
include_admins: bool = True,
include_failed: bool = True,
cleanup_system_questions: bool = True,
) -> list[AdminLeaderboardQuestion]:
exclude_user_ids = exclude_user_ids or set()
stmt = (
select(
RunRow.first_human_message,
RunRow.user_id,
UserRow.email,
RunRow.created_at,
)
.outerjoin(UserRow, UserRow.id == RunRow.user_id)
.where(_run_time_filter(since, until), RunRow.first_human_message.is_not(None), RunRow.first_human_message != "")
.order_by(RunRow.created_at.desc())
.limit(min(max(limit * 20, 200), 1000))
)
stmt = _apply_run_scope(
stmt,
user_column=RunRow.user_id,
run_id_column=RunRow.run_id,
exclude_user_ids=exclude_user_ids,
include_admins=include_admins,
include_failed=include_failed,
include_scheduled=include_scheduled,
)
rows = (await _execute_leaderboard_query(session, "frequent_questions", stmt)).all()
groups: list[dict] = []
for r in rows:
text = _clean_question_text(r[0]) if cleanup_system_questions else str(r[0] or "").strip()
if cleanup_system_questions and not _is_displayable_question(text):
continue
key = _question_similarity_key(text)
if not key:
continue
target = None
for group in groups:
score = SequenceMatcher(None, key, group["key"]).ratio()
if score >= 0.72 or key in group["key"] or group["key"] in key:
target = group
break
if target is None:
groups.append({"key": key, "text": text, "count": 1, "user_id": r[1] or "", "email": r[2] or "", "asked_at": r[3]})
else:
target["count"] += 1
if r[3] and (target.get("asked_at") is None or r[3] > target["asked_at"]):
target.update({"text": text, "user_id": r[1] or "", "email": r[2] or "", "asked_at": r[3]})
groups.sort(key=lambda item: (item["count"], _iso(item.get("asked_at"))), reverse=True)
questions: list[AdminLeaderboardQuestion] = []
for group in groups[:limit]:
text = group["text"][:460]
if group["count"] > 1:
text = f"{text}(相似问题 {group['count']} 次)"
questions.append(
AdminLeaderboardQuestion(
text=text,
user_id=group["user_id"],
email=group["email"],
asked_at=_iso(group.get("asked_at")),
)
)
return questions
async def _query_trends(
session,
since: datetime,
@@ -1306,10 +1170,6 @@ async def _build_user_leaderboard_response(
"include_scheduled": include_scheduled,
"include_admins": include_admins,
}
question_common = {
**common,
"cleanup_system_questions": settings.cleanup_system_questions,
}
overview = await _query_overview(session, since_dt, until_dt, **common)
await _leaderboard_pause()
users = await _query_user_leaderboard(session, since_dt, until_dt, **common)
@@ -1326,10 +1186,6 @@ async def _build_user_leaderboard_response(
await _leaderboard_pause()
run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="status", limit=limit, **common)
await _leaderboard_pause()
recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=None, limit=limit, **question_common)
await _leaderboard_pause()
frequent_questions = await _query_frequent_questions(session, since_dt, until_dt, limit=limit, **question_common)
await _leaderboard_pause()
trends = await _query_trends(session, since_dt, until_dt, **common)
return AdminLeaderboardResponse(
@@ -1347,8 +1203,6 @@ async def _build_user_leaderboard_response(
models=models,
agents=agents,
run_statuses=run_statuses,
recent_questions=recent_questions,
frequent_questions=frequent_questions,
trends=trends,
)
@@ -1572,95 +1426,6 @@ def _merge_dimensions_from_daily(
return sorted(merged_items, key=lambda item: (item.count, _sort_datetime(item.last_seen_at)), reverse=True)[:limit]
def _merge_recent_questions_from_daily(
responses: list[AdminLeaderboardResponse],
limit: int,
) -> list[AdminLeaderboardQuestion]:
questions: list[AdminLeaderboardQuestion] = []
seen: set[str] = set()
for response in responses:
for question in response.recent_questions:
key = question.run_id or f"{question.user_id}:{question.asked_at}:{question.text}"
if key in seen:
continue
seen.add(key)
questions.append(question)
questions.sort(key=lambda item: _sort_datetime(item.asked_at), reverse=True)
return questions[:limit]
_FREQUENT_QUESTION_COUNT_RE = re.compile(r"(相似问题\s+(\d+)\s+次)$")
def _split_frequent_question_count(text: str) -> tuple[str, int]:
match = _FREQUENT_QUESTION_COUNT_RE.search(text)
if not match:
return text, 1
return _FREQUENT_QUESTION_COUNT_RE.sub("", text).strip(), max(1, int(match.group(1)))
def _merge_frequent_questions_from_daily(
responses: list[AdminLeaderboardResponse],
limit: int,
) -> list[AdminLeaderboardQuestion]:
groups: list[dict] = []
for response in responses:
for question in response.frequent_questions:
base_text, count = _split_frequent_question_count(question.text)
key = _question_similarity_key(base_text)
if not key:
continue
target = None
for group in groups:
score = SequenceMatcher(None, key, group["key"]).ratio()
if score >= 0.72 or key in group["key"] or group["key"] in key:
target = group
break
if target is None:
groups.append(
{
"key": key,
"text": base_text,
"count": count,
"user_id": question.user_id,
"email": question.email,
"thread_id": question.thread_id,
"run_id": question.run_id,
"asked_at": question.asked_at,
}
)
else:
target["count"] += count
if _sort_datetime(question.asked_at) > _sort_datetime(target.get("asked_at")):
target.update(
{
"text": base_text,
"user_id": question.user_id,
"email": question.email,
"thread_id": question.thread_id,
"run_id": question.run_id,
"asked_at": question.asked_at,
}
)
groups.sort(key=lambda item: (item["count"], _sort_datetime(item.get("asked_at"))), reverse=True)
questions: list[AdminLeaderboardQuestion] = []
for group in groups[:limit]:
text_value = group["text"][:460]
if group["count"] > 1:
text_value = f"{text_value}(相似问题 {group['count']} 次)"
questions.append(
AdminLeaderboardQuestion(
text=text_value,
user_id=group["user_id"],
email=group["email"],
thread_id=group["thread_id"],
run_id=group["run_id"],
asked_at=group["asked_at"],
)
)
return questions
def _merge_trends_from_daily(responses: list[AdminLeaderboardResponse]) -> list[AdminLeaderboardTrendPoint]:
trends_by_date: dict[str, AdminLeaderboardTrendPoint] = {}
for response in responses:
@@ -1719,8 +1484,6 @@ def _merge_daily_leaderboard_payloads(
models=_merge_dimensions_from_daily(responses, "models", limit),
agents=_merge_dimensions_from_daily(responses, "agents", limit),
run_statuses=_merge_dimensions_from_daily(responses, "run_statuses", limit),
recent_questions=_merge_recent_questions_from_daily(responses, limit),
frequent_questions=_merge_frequent_questions_from_daily(responses, limit),
trends=_merge_trends_from_daily(responses),
)
@@ -2267,8 +2030,6 @@ async def _build_leaderboard_report_prompt(
models = snapshot.models
agents = snapshot.agents
run_statuses = snapshot.run_statuses
recent_questions = snapshot.recent_questions
frequent_questions = snapshot.frequent_questions
trends = snapshot.trends
top_users = snapshot.users_by_activity[:12]
context = {
@@ -2280,8 +2041,6 @@ async def _build_leaderboard_report_prompt(
"top_models": [m.model_dump() for m in models[:12]],
"agents": [a.model_dump() for a in agents[:12]],
"run_statuses": [s.model_dump() for s in run_statuses[:12]],
"frequent_questions": [q.model_dump() for q in frequent_questions[:30]],
"recent_questions": [q.model_dump() for q in recent_questions[:40]],
"trends": [t.model_dump() for t in trends[-21:]],
}
return (
@@ -2289,12 +2048,10 @@ async def _build_leaderboard_report_prompt(
"报告需要有结论,不要只罗列数字;请使用 Markdown 标题、列表和表格,让前端可以直接渲染。"
"重点要求:"
"1) 你是专门负责分析用户行为的专家,不要自称企业 AI 应用运营分析师;"
"2) 优先突出用户最近常用问题、最近关注的实时问题、事件、主题和业务方向,必须引用 recent_questions 中的近期问题作为依据;"
"3) 单独分析 frequent_questions,归纳高频关注簇,说明这些高频问题反映出的需求强度和重复频率;"
"4) 结合 top_users、top_skills、top_tools、top_models 和 trends,说明使用频率、使用增长、常用能力和重点用户行为画像;"
"5) 可以提及使用强度、关注变化和行为机会,但不要把报告重点放在系统本身的问题、故障、失败率或回答质量缺陷上;"
"6) 给管理员 3-5 条可执行建议,建议应围绕用户关注主题、资源配置、内容供给、培训引导和高频能力优化。"
"输出结构:摘要、最近关注、高频问题与频率、重点用户行为、常用能力、趋势观察、行动建议。"
"2) 结合 top_users、top_skills、top_tools、top_models 和 trends,说明使用频率、使用增长、常用能力和重点用户行为画像;"
"3) 可以提及使用强度、关注变化和行为机会,但不要把报告重点放在系统本身的问题、故障、失败率或回答质量缺陷上;"
"4) 给管理员 3-5 条可执行建议,建议应围绕资源配置、内容供给、培训引导和高频能力优化。"
"输出结构:摘要、重点用户行为、常用能力、趋势观察、行动建议。"
"\n\n统计数据如下:\n"
f"{json.dumps(context, ensure_ascii=False, default=str)}"
)
@@ -2452,10 +2209,6 @@ async def get_user_analytics(
"include_scheduled": settings.include_scheduled,
"include_admins": settings.include_admins,
}
question_common = {
**common,
"cleanup_system_questions": settings.cleanup_system_questions,
}
since_dt, until_dt, range_days = _resolve_range(days, since, until)
async with session_factory() as session:
await _set_analytics_read_timeout(session)
@@ -2470,7 +2223,6 @@ async def get_user_analytics(
models = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="model", limit=limit, **common)
agents = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="agent", limit=limit, **common)
run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="status", limit=limit, **common)
recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=user_id, limit=limit, **question_common)
trends = await _query_trends(session, since_dt, until_dt, user_id=user_id, **common)
return AdminUserAnalyticsResponse(
@@ -2484,7 +2236,6 @@ async def get_user_analytics(
models=models,
agents=agents,
run_statuses=run_statuses,
recent_questions=recent_questions,
trends=trends,
)
@@ -18,10 +18,10 @@ from app.gateway.auth.password import verify_password_async
from app.gateway.deps import get_agent_store, get_config, get_tag_store
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.config.agents_api_config import get_agents_api_config
from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
from deerflow.config.agents_config import CommonQuestion, list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
from deerflow.config.app_config import AppConfig
from deerflow.config.paths import get_paths
from deerflow.config.system_settings import load_system_settings
from deerflow.config.system_settings import load_system_settings, normalize_square_id, resolve_default_square_id
from deerflow.persistence.agents import AgentStore
from deerflow.persistence.tags import TagStore
from deerflow.runtime.user_context import get_effective_user_id
@@ -42,6 +42,7 @@ class AgentResponse(BaseModel):
owner_username: str | None = Field(default=None, description="Publisher username (email local part). Null for built-in agents.")
published: bool = Field(default=False, description="Whether a user-created agent is public")
is_private: bool = Field(default=False, description="Whether the agent lives in the password-gated private square instead of the public one")
square_id: str = Field(default="", description="Publish square (发布广场) the agent is filed under. Empty string = the default square. Independent of is_private.")
builtin: bool = Field(default=False, description="True when user_id is null")
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
@@ -53,6 +54,11 @@ class AgentResponse(BaseModel):
tool_groups: list[str] | None = None
skills: list[str] | None = None
common_questions: list[CommonQuestion] | None = Field(
default=None,
description="Recommended questions shown on the agent chat home / sidebar (常用问题). Each item is {title, prompt}; legacy string lists are coerced.",
)
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent")
featured_order: int | None = Field(
@@ -98,9 +104,11 @@ class AgentCreateRequest(BaseModel):
soul: str = Field(default="", description="SOUL.md content - agent personality and behavioral guardrails")
published: bool = Field(default=False, description="Whether the new agent should be public")
is_private: bool = Field(default=False, description="When True, the new agent goes into the password-gated private square instead of the public one")
square_id: str | None = Field(default=None, description="Publish square (发布广场) to file the agent under. Omit / empty = the default square.")
skills: list[str] | None = Field(default=None, description="Skill whitelist persisted to the agent's config.yaml")
model: str | None = Field(default=None, description="Optional model override persisted to config.yaml")
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
common_questions: list[CommonQuestion] | None = Field(default=None, description="Recommended questions (常用问题) persisted to config.yaml. Each item is {title, prompt}; legacy strings are coerced.")
class AgentUpdateRequest(BaseModel):
@@ -111,9 +119,11 @@ class AgentUpdateRequest(BaseModel):
soul: str | None = Field(default=None, description="Updated SOUL.md content")
published: bool | None = Field(default=None, description="Updated publication state")
is_private: bool | None = Field(default=None, description="Move the agent into / out of the private square (true / false)")
square_id: str | None = Field(default=None, description="Publish square (发布广场) to file the agent under. Empty string = the default square.")
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (config.yaml)")
model: str | None = Field(default=None, description="Updated model override (config.yaml)")
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
common_questions: list[CommonQuestion] | None = Field(default=None, description="Updated recommended questions (常用问题, config.yaml). Each item is {title, prompt}; legacy strings are coerced.")
class AgentSelectorResponse(BaseModel):
@@ -217,6 +227,7 @@ def _write_agent_config(
skills: list[str] | None = None,
model: str | None = None,
tool_groups: list[str] | None = None,
common_questions: list[CommonQuestion] | None = None,
) -> None:
agent_dir = get_paths().agent_dir(agent_id)
agent_dir.mkdir(parents=True, exist_ok=True)
@@ -232,19 +243,36 @@ def _write_agent_config(
config_data["model"] = model
if tool_groups is not None:
config_data["tool_groups"] = tool_groups
if common_questions is not None:
# Persist as plain dicts (dropping blank rows) so config.yaml stays a
# clean mapping rather than a pydantic repr. Accepts already-coerced
# CommonQuestion models or raw dicts/strings.
normalized = [
q if isinstance(q, CommonQuestion) else CommonQuestion.model_validate(q)
for q in common_questions
]
config_data["common_questions"] = [
q.model_dump() for q in normalized if q.prompt.strip()
]
with open(config_file, "w", encoding="utf-8") as f:
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
def _load_config_extras(agent_id: str) -> dict[str, Any]:
"""Read skills/model/tool_groups from the agent's config.yaml, tolerant of missing files."""
"""Read skills/model/tool_groups/common_questions from the agent's config.yaml, tolerant of missing files."""
empty = {"skills": None, "model": None, "tool_groups": None, "common_questions": None}
try:
cfg = load_agent_config(agent_id)
except (FileNotFoundError, ValueError):
return {"skills": None, "model": None, "tool_groups": None}
return dict(empty)
if cfg is None:
return {"skills": None, "model": None, "tool_groups": None}
return {"skills": cfg.skills, "model": cfg.model, "tool_groups": cfg.tool_groups}
return dict(empty)
return {
"skills": cfg.skills,
"model": cfg.model,
"tool_groups": cfg.tool_groups,
"common_questions": cfg.common_questions,
}
def _write_agent_soul(agent_id: str, soul: str) -> None:
@@ -404,7 +432,7 @@ def _record_to_response(
can happen lazily when the agent is actually opened.
"""
soul: str | None = None
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None}
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None, "common_questions": None}
if include_extras:
_ensure_agent_dir_from_record(record)
extras = _load_config_extras(record["id"])
@@ -418,6 +446,7 @@ def _record_to_response(
user_id=record.get("user_id"),
published=bool(record.get("published", False)),
is_private=bool(record.get("is_private", False)),
square_id=str(record.get("square_id") or ""),
builtin=record.get("user_id") is None,
created_at=record.get("created_at"),
updated_at=record.get("updated_at"),
@@ -425,6 +454,7 @@ def _record_to_response(
model=extras["model"],
tool_groups=extras["tool_groups"],
skills=extras["skills"],
common_questions=extras.get("common_questions"),
featured_order=record.get("featured_order"),
)
@@ -559,6 +589,10 @@ async def list_agents(
default=None,
description="Keep only agents published by this user id (the card's publisher). Built-in agents never match.",
),
square: str | None = Query(
default=None,
description="Keep only agents filed under this publish square (发布广场). Omit for all squares.",
),
admin: bool = Query(
default=False,
description=(
@@ -579,6 +613,11 @@ async def list_agents(
raise HTTPException(status_code=403, detail="Only system admins can list all agents")
paginate = scope is not None or page is not None
square_id = square.strip() if square and square.strip() else None
square_is_default = False
if square_id:
sq = load_system_settings().publish_squares
square_is_default = square_id == resolve_default_square_id(sq)
if paginate:
eff_page = page or 1
rows, total = await store.list_paginated(
@@ -591,6 +630,8 @@ async def list_agents(
admin=admin,
sort=sort,
owner=owner,
square_id=square_id,
square_is_default=square_is_default,
)
responses = await _build_list_responses(store, tag_store, user_id, rows)
return AgentsListResponse(agents=responses, total=total, page=eff_page, page_size=page_size)
@@ -608,6 +649,12 @@ async def list_agents(
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
if owner and owner.strip():
responses = [r for r in responses if r.user_id == owner.strip()]
if square_id:
responses = [
r
for r in responses
if (r.square_id or "") == square_id or (square_is_default and not (r.square_id or ""))
]
responses = _sort_agent_responses(responses, sort)
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
except HTTPException:
@@ -725,6 +772,7 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
skills=request.skills,
model=request.model,
tool_groups=request.tool_groups,
common_questions=request.common_questions,
)
_write_agent_soul(agent_id, request.soul)
@@ -736,6 +784,8 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
"user_id": _current_user_id(http_request),
"published": request.published,
"is_private": request.is_private,
# Normalize to a valid configured square id (default when unset).
"square_id": normalize_square_id(request.square_id, load_system_settings().publish_squares),
}
)
# Mirror skills/tool_groups/model into the listing cache so the gallery
@@ -782,6 +832,10 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
update_data["published"] = bool(request.published)
if "is_private" in fields_set:
update_data["is_private"] = bool(request.is_private)
if "square_id" in fields_set:
# Normalize to a valid configured square id (default when unset /
# unknown), so the row never holds a dangling square reference.
update_data["square_id"] = normalize_square_id(request.square_id, load_system_settings().publish_squares)
# Mutual exclusion: an agent moved into the private square cannot
# also be publicly published. Force ``published=false`` whenever the
# same request sets ``is_private=true``, even if the client forgot
@@ -797,8 +851,9 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
next_skills = request.skills if "skills" in fields_set else existing_extras["skills"]
next_model = request.model if "model" in fields_set else existing_extras["model"]
next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"]
next_common_questions = request.common_questions if "common_questions" in fields_set else existing_extras["common_questions"]
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups"))
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions"))
if config_dirty:
_write_agent_config(
agent_id,
@@ -807,6 +862,7 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
skills=next_skills,
model=next_model,
tool_groups=next_tool_groups,
common_questions=next_common_questions,
)
# Keep the listing cache in lock-step with config.yaml on every write.
await store.replace_skills(agent_id, next_skills)
@@ -13,9 +13,12 @@ from __future__ import annotations
import logging
import mimetypes
import re
import tempfile
from pathlib import Path
from typing import Any
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
@@ -26,6 +29,12 @@ from deerflow.knowledge.service import EmptyThreadError
from deerflow.runtime import serialize_channel_values
from deerflow.runtime.references import build_reference_batches
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.utils.file_conversion import CONVERTIBLE_EXTENSIONS, convert_file_to_markdown
# Plain-text/markdown files are read directly; binary documents are converted.
_TEXT_EXTENSIONS = {".md", ".markdown", ".txt", ".text", ""}
# Bound the in-memory read so a knowledge import can't OOM the gateway.
_MAX_IMPORT_BYTES = 25 * 1024 * 1024
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/knowledge", tags=["knowledge"])
@@ -299,6 +308,80 @@ async def ingest_threads_batch(body: BatchIngestRequest, request: Request) -> di
return {"ok": True, "processed": len(items), "created": created, "skipped": skipped, "failed": failed, "llm_fallback": llm_fallback, "items": items}
async def _extract_upload_text(file: UploadFile) -> tuple[str, str]:
"""Read an uploaded file and return ``(filename, markdown_text)``.
Plain-text/markdown is decoded directly; PDF/Office documents are converted
to Markdown via the shared converter. Raises ``HTTPException`` on empty /
oversized / unsupported / unparseable files.
"""
name = file.filename or "document"
ext = Path(name).suffix.lower()
raw = await file.read()
if not raw:
raise HTTPException(status_code=400, detail="文件为空")
if len(raw) > _MAX_IMPORT_BYTES:
raise HTTPException(status_code=413, detail=f"文件过大,最大支持 {_MAX_IMPORT_BYTES // (1024 * 1024)}MB")
if ext in _TEXT_EXTENSIONS:
text = raw.decode("utf-8", errors="ignore")
elif ext in CONVERTIBLE_EXTENSIONS:
# Convert in an isolated temp dir; a fixed safe name avoids path issues
# while preserving the extension so the converter detects the type.
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp) / f"import{ext}"
tmp_path.write_bytes(raw)
md_path = await convert_file_to_markdown(tmp_path)
if md_path is None:
raise HTTPException(status_code=422, detail=f"无法解析文件:{name}")
text = md_path.read_text(encoding="utf-8", errors="ignore")
else:
raise HTTPException(status_code=415, detail=f"不支持的文件类型:{ext or '未知'}(支持 md/txt/pdf/word/ppt/excel)")
if not text.strip():
raise HTTPException(status_code=422, detail="文件内容为空或无法提取文本")
return name, text
@router.post("/ingest/file")
@require_auth
async def ingest_file(
request: Request,
file: UploadFile = File(...),
title: str | None = Form(default=None),
tags: str | None = Form(default=None),
status: str = Form(default="approved"),
model_name: str | None = Form(default=None),
) -> dict[str, Any]:
"""Import an uploaded file into the knowledge base as wiki note(s).
Converts the file to Markdown, distills it via the wiki-capture LLM pipeline
(same as thread capture), and writes the resulting declarative note(s) into
the shared vault. ``tags`` is a comma/space-separated string.
"""
service = get_knowledge_service(request)
name, text = await _extract_upload_text(file)
tag_list = [t for t in re.split(r"[,,\s]+", tags or "") if t]
try:
result = await service.capture_file(
content_md=text,
filename=name,
title=(title or None),
tags=tag_list,
status=status,
created_by=get_effective_user_id(),
model_name=(model_name or None),
)
except EmptyThreadError:
raise HTTPException(status_code=422, detail="文件内容无可沉淀的知识")
return {
"note": result["note"],
"created": result["created"],
"notes": result.get("notes", [result["note"]]),
"llm_fallback": result.get("llm_fallback", False),
}
@router.post("/ingest/search")
@require_auth
async def ingest_search(body: IngestSearchRequest, request: Request) -> dict[str, Any]:
@@ -62,13 +62,6 @@ def _anonymize(resp: AdminLeaderboardResponse) -> AdminLeaderboardResponse:
user.email = _mask_email(user.email)
user.user_id = hashed(user.user_id)
for bucket in (resp.recent_questions, resp.frequent_questions):
for question in bucket:
question.email = _mask_email(question.email)
question.user_id = hashed(question.user_id)
question.thread_id = ""
question.run_id = ""
return resp
@@ -166,6 +166,7 @@ class SkillResponse(BaseModel):
owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)")
owner_username: str | None = Field(default=None, description="Publisher username (email local part). Null for built-in skills.")
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
square_id: str = Field(default="", description="Publish square (发布广场) the skill is filed under once published. Empty string = the default square.")
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
detail: str | None = Field(default=None, description="Human-authored long-form detail text (Markdown), editable by owner/admin")
display: dict[str, Any] | None = Field(default=None, description="Optional frontend display adapter")
@@ -308,12 +309,14 @@ def _skill_to_response(
builtin = skill.category != SkillCategory.CUSTOM
detail: str | None = None
name_zh: str | None = None
square_id = ""
if ownership is not None:
owner_user_id = ownership.get("owner_user_id")
published = bool(ownership.get("published", False))
builtin = bool(ownership.get("builtin", owner_user_id is None))
detail = ownership.get("detail")
name_zh = ownership.get("name_zh")
square_id = str(ownership.get("square_id") or "")
display = None if display_override is _UNSET else display_override
if display_override is _UNSET:
@@ -342,6 +345,7 @@ def _skill_to_response(
source=entry.get("source", "upload"),
owner_user_id=owner_user_id,
published=published,
square_id=square_id,
builtin=builtin,
detail=detail,
display=display,
@@ -772,6 +776,7 @@ async def list_skills(
tag_store: TagStore = Depends(get_tag_store),
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name or Chinese name (name_zh)"),
tag_ids: list[str] | None = Query(default=None, description="Keep skills carrying any of these tags (OR)"),
square: str | None = Query(default=None, description="Keep only skills filed under this publish square (发布广场). Omit for all squares."),
sort: str = Query(
default="time_desc",
pattern="^(time_desc|time_asc|activity_desc|activity_asc|usage_desc|usage_asc|name_asc|name_desc)$",
@@ -831,6 +836,16 @@ async def list_skills(
)
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
responses = await _attach_owner_usernames(responses)
square_id = square.strip() if square and square.strip() else None
if square_id:
from deerflow.config.system_settings import load_system_settings, resolve_default_square_id
square_is_default = square_id == resolve_default_square_id(load_system_settings().publish_squares)
responses = [
r
for r in responses
if (r.square_id or "") == square_id or (square_is_default and not (r.square_id or ""))
]
responses = _sort_skill_responses(responses, sort)
return SkillsListResponse(skills=responses)
except HTTPException:
@@ -1366,12 +1381,14 @@ class SkillPublishRequest(BaseModel):
"""Request body for toggling a custom skill's published flag."""
published: bool = Field(..., description="Whether to publish (True) or unpublish (False)")
square_id: str | None = Field(default=None, description="Publish square (发布广场) to file the skill under when publishing. Omit / empty = the default square.")
class SkillPublishResponse(BaseModel):
name: str
owner_user_id: str | None
published: bool
square_id: str = ""
@router.put(
@@ -1393,10 +1410,18 @@ async def update_custom_skill_published(
record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
was_published = bool(record.get("published", False))
# When publishing, also file the skill into the chosen square (normalized
# to a valid configured square id; default when unset). Only one square
# per skill — a single column, so it can never be "published twice".
update_fields: dict[str, Any] = {"published": body.published}
if body.published:
from deerflow.config.system_settings import load_system_settings, normalize_square_id
update_fields["square_id"] = normalize_square_id(body.square_id, load_system_settings().publish_squares)
if mode == "admin":
updated = await skill_store.update_admin(skill_name, {"published": body.published})
updated = await skill_store.update_admin(skill_name, update_fields)
else:
updated = await skill_store.update(skill_name, user_id, {"published": body.published})
updated = await skill_store.update(skill_name, user_id, update_fields)
if updated is None:
raise HTTPException(status_code=500, detail="Failed to persist published state")
@@ -1419,6 +1444,7 @@ async def update_custom_skill_published(
name=updated["name"],
owner_user_id=updated.get("owner_user_id"),
published=bool(updated.get("published", False)),
square_id=str(updated.get("square_id") or ""),
)
except HTTPException:
raise
@@ -2762,14 +2788,15 @@ class CuratorConfigResponse(BaseModel):
class CuratorConfigRequest(BaseModel):
"""Payload for updating skill_evolution overrides.
"""Payload for updating user-facing curator settings in skill_evolution.json.
All fields are required so the frontend always sends a complete config;
backend writes the entire payload to skill_evolution.json atomically.
All other settings (enabled, timeouts) are global-only and controlled via config.yaml.
"""
enabled: bool = Field(..., description="Whether the agent can create and modify skills under skills/custom.")
curator: CuratorSubConfig
interval_hours: int = Field(..., ge=1, description="Hours between curator runs.")
stale_after_days: int = Field(..., ge=1, description="Days of inactivity before a skill is marked stale.")
archive_after_days: int = Field(..., ge=1, description="Days of inactivity before a stale skill is archived.")
model_name: str | None = Field(default=None, description="Model for the curator LLM review; null = primary model.")
class CuratorLogRecord(BaseModel):
@@ -2785,11 +2812,7 @@ class CuratorLogsResponse(BaseModel):
def _build_curator_config_response(evo) -> CuratorConfigResponse:
"""Pure helper so both the GET route and the post-PUT refresh share one shape.
``evo`` is a ``SkillEvolutionConfig`` instance (already merged with per-user
overrides if any).
"""
"""Pure helper so both the GET route and the post-PUT refresh share one shape."""
return CuratorConfigResponse(
enabled=evo.enabled,
curator=CuratorSubConfig(
@@ -2805,8 +2828,7 @@ def _build_curator_config_response(evo) -> CuratorConfigResponse:
@router.get("/curator/config", response_model=CuratorConfigResponse, summary="Get Skill Evolution Config")
async def get_curator_config(request: Request) -> CuratorConfigResponse:
"""Return the current curator config for the authenticated user (yaml
defaults + that user's overrides)."""
"""Return the current curator config for the authenticated user (yaml defaults + per-user overrides)."""
try:
from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user
@@ -2819,21 +2841,22 @@ async def get_curator_config(request: Request) -> CuratorConfigResponse:
@router.put("/curator/config", response_model=CuratorConfigResponse, summary="Update Skill Evolution Config")
async def update_curator_config(body: CuratorConfigRequest, request: Request) -> CuratorConfigResponse:
"""Persist per-user overrides to ``users/{user_id}/skill_evolution.json``
and return the effective merged config.
"""Persist the three user-facing curator thresholds to the per-user ``skill_evolution.json``.
The payload is validated against the ``SkillEvolutionConfig`` shape; unknown
fields are rejected.
Other settings (enabled, model, timeouts) are global and controlled via config.yaml.
"""
try:
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user, save_user_overrides
user_id = _current_user_id(request)
payload = body.model_dump()
# Round-trip through SkillEvolutionConfig to ensure persisted shape stays valid.
SkillEvolutionConfig.model_validate(payload)
save_user_overrides(user_id, payload)
save_user_overrides(user_id, {
"curator": {
"interval_hours": body.interval_hours,
"stale_after_days": body.stale_after_days,
"archive_after_days": body.archive_after_days,
"model_name": body.model_name,
},
})
return _build_curator_config_response(load_skill_evolution_config_for_user(user_id))
except HTTPException:
raise
@@ -14,8 +14,12 @@ from deerflow.config import get_app_config
from deerflow.config.system_settings import (
LeaderboardSettings,
PromptPrefixSettings,
PublishSquare,
PublishSquaresSettings,
SystemSettings,
list_publish_squares,
load_system_settings,
resolve_default_square_id,
save_system_settings,
)
@@ -219,6 +223,88 @@ async def update_private_square(body: PrivateSquareUpdateRequest, request: Reque
)
class PublishSquareItem(BaseModel):
id: str = Field(..., description="Stable square id")
name: str = Field(..., description="Display name / tab label")
description: str = Field(default="", description="Optional description")
class PublishSquaresResponse(BaseModel):
"""The configured publish squares plus the resolved default.
Readable by any logged-in user — squares are pure category tabs with no
access control, so the gallery needs the list to render its sub-tabs and
the publish flow needs it to populate the square picker.
"""
squares: list[PublishSquareItem] = Field(default_factory=list)
default_square_id: str = Field(..., description="Square id used when a publisher picks none")
class PublishSquaresUpdateRequest(BaseModel):
squares: list[PublishSquareItem] | None = Field(default=None, description="Full ordered replacement list of squares")
default_square_id: str | None = Field(default=None, description="Which square id is the publish default")
def _publish_squares_response(settings: PublishSquaresSettings) -> PublishSquaresResponse:
squares = list_publish_squares(settings)
return PublishSquaresResponse(
squares=[PublishSquareItem(id=s.id, name=s.name, description=s.description) for s in squares],
default_square_id=resolve_default_square_id(settings),
)
@router.get("/publish-squares", response_model=PublishSquaresResponse)
async def get_publish_squares() -> PublishSquaresResponse:
"""Return the configured publish squares (readable by any logged-in user)."""
return _publish_squares_response(load_system_settings().publish_squares)
@router.put("/publish-squares", response_model=PublishSquaresResponse)
async def update_publish_squares(body: PublishSquaresUpdateRequest, request: Request) -> PublishSquaresResponse:
"""Replace the publish-squares configuration. Admin only.
``squares`` is a full ordered replacement. Each square needs a non-empty
name; a missing id is auto-generated from a slug of the name. Duplicate ids
are de-duplicated (first wins). ``default_square_id`` must reference one of
the surviving squares, else it falls back to the first.
"""
from uuid import uuid4
await _require_admin(request)
current = load_system_settings()
fields = body.model_dump(exclude_unset=True)
update: dict[str, object] = {}
if "squares" in fields and fields["squares"] is not None:
normalized: list[PublishSquare] = []
seen_ids: set[str] = set()
for raw in body.squares or []:
name = (raw.name or "").strip()
if not name:
continue
sid = (raw.id or "").strip() or uuid4().hex[:8]
if sid in seen_ids:
continue
seen_ids.add(sid)
normalized.append(PublishSquare(id=sid, name=name, description=(raw.description or "").strip()))
update["squares"] = normalized
if "default_square_id" in fields and fields["default_square_id"] is not None:
update["default_square_id"] = str(fields["default_square_id"]).strip()
if update:
updated = current.publish_squares.model_copy(update=update)
# Re-anchor the default to a surviving square id.
if updated.squares and updated.default_square_id not in {s.id for s in updated.squares}:
updated = updated.model_copy(update={"default_square_id": updated.squares[0].id})
saved = save_system_settings(current.model_copy(update={"publish_squares": updated}))
else:
saved = current
return _publish_squares_response(saved.publish_squares)
class AppearanceResponse(BaseModel):
"""Effective system-wide appearance defaults."""
+145 -150
View File
@@ -10,90 +10,134 @@ gateway:
port: 8001
models:
# - name: deepseek-chat
# display_name: DeepSeek Chat
# use: langchain_openai:ChatOpenAI
# model: deepseek-chat
# api_key: sk-1520028668cb4cc388ed8315ff1e856e
# base_url: https://api.deepseek.com
# request_timeout: 600.0
# max_retries: 2
# max_tokens: 4096
# temperature: 1.0
# supports_vision: false
# supports_thinking: true
# - name: deepseek-v4-flash
# display_name: DeepSeek V4 Flash
# use: langchain_openai:ChatOpenAI
# model: deepseek-v4-flash
# api_key: sk-e1c22b7931dc446f8b67e57878edd105
# base_url: https://api.deepseek.com
# request_timeout: 600.0
# max_retries: 2
# max_tokens: 4096
# temperature: 1.0
# supports_vision: false
# supports_thinking: true
# - name: deepseek-v4-pro
# display_name: DeepSeek V4 Pro
# use: langchain_openai:ChatOpenAI
# model: deepseek-v4-pro
# api_key: sk-e1c22b7931dc446f8b67e57878edd105
# base_url: https://api.deepseek.com
# request_timeout: 600.0
# max_retries: 2
# max_tokens: 4096
# temperature: 1.0
# supports_vision: false
# supports_thinking: true
# - name: MiniMax-M2.7
# display_name: MiniMax-M2.7
# use: langchain_openai:ChatOpenAI
# model: MiniMax-M2.7
# api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
# base_url: https://api.minimaxi.com/v1
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
# - name: glm-5.1
# display_name: glm-5.1
# use: langchain_openai:ChatOpenAI
# model: glm-5.1
# api_key: 75121e4bed7640d5b71ac95cf8b4310a.RA3GyRBAR3kv7VrR
# base_url: https://open.bigmodel.cn/api/paas/v4/
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
# - name: deepseek-ai/DeepSeek-OCR
# display_name: deepseek-ai/DeepSeek-OCR
# use: langchain_openai:ChatOpenAI
# model: deepseek-ai/DeepSeek-OCR
# api_key: sk-beshfxfxfrllsczpqfrgjbugfbpzxgqgedbehceytpcvvtyb
# base_url: https://api.siliconflow.cn/v1/
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
# - name: zai-org/GLM-5-FP8
# display_name: zai-org/GLM-5-FP8
# use: langchain_openai:ChatOpenAI
# model: zai-org/GLM-5-FP8
# api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
# base_url: https://api.us-west-2.modal.direct/v1
# request_timeout: 600.0
# max_retries: 2
# max_tokens: 4096
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
- name: deepseek-ai/DeepSeek-V4-Flash
display_name: deepseek-ai/DeepSeek-V4-Flash
- name: deepseek-chat
display_name: DeepSeek Chat
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-V4-Flash
model: deepseek-chat
api_key: sk-3fee562f834a4c08bb0d471ef44cb445
base_url: https://api.deepseek.com
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: false
supports_thinking: true
- name: zai-org/GLM-5-FP8
display_name: zai-org/GLM-5-FP8
use: langchain_openai:ChatOpenAI
model: zai-org/GLM-5-FP8
api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
base_url: https://api.us-west-2.modal.direct/v1
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: deepseek-v4-flash
display_name: DeepSeek V4 Flash
use: langchain_openai:ChatOpenAI
model: deepseek-v4-flash
api_key: sk-3fee562f834a4c08bb0d471ef44cb445
base_url: https://api.deepseek.com
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: false
supports_thinking: true
- name: deepseek-v4-pro
display_name: DeepSeek V4 Pro
use: langchain_openai:ChatOpenAI
model: deepseek-v4-pro
api_key: sk-e1c22b7931dc446f8b67e57878edd105
base_url: https://api.deepseek.com
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: false
supports_thinking: true
- name: MiniMax-M2.7
display_name: MiniMax-M2.7
use: langchain_openai:ChatOpenAI
model: MiniMax-M2.7
api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
base_url: https://api.minimaxi.com/v1
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: glm-5.1
display_name: glm-5.1
use: langchain_openai:ChatOpenAI
model: glm-5.1
api_key: 75121e4bed7640d5b71ac95cf8b4310a.RA3GyRBAR3kv7VrR
base_url: https://open.bigmodel.cn/api/paas/v4/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: deepseek-ai/DeepSeek-OCR
display_name: deepseek-ai/DeepSeek-OCR
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-OCR
api_key: sk-beshfxfxfrllsczpqfrgjbugfbpzxgqgedbehceytpcvvtyb
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: zai-org/GLM-5-FP8
display_name: zai-org/GLM-5-FP8
use: langchain_openai:ChatOpenAI
model: zai-org/GLM-5-FP8
api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
base_url: https://api.us-west-2.modal.direct/v1
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: Qwen/Qwen3.6-27B
display_name: Qwen/Qwen3.6-27B
use: langchain_openai:ChatOpenAI
model: Qwen/Qwen3.6-27B
api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: Pro/MiniMaxAI/MiniMax-M2.5
display_name: Pro/MiniMaxAI/MiniMax-M2.5
use: langchain_openai:ChatOpenAI
model: Pro/MiniMaxAI/MiniMax-M2.5
api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: inclusionAI/Ling-flash-2.0
display_name: inclusionAI/Ling-flash-2.0
use: langchain_openai:ChatOpenAI
model: inclusionAI/Ling-flash-2.0
api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: deepseek-ai/DeepSeek-V4-Pro
display_name: deepseek-ai/DeepSeek-V4-Pro
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-V4-Pro
api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
@@ -122,26 +166,6 @@ uploads:
auto_convert_documents: true
pdf_converter: auto
# Chrome 插件「当前网页发送到 Deer」入口。默认关闭,打开后才启用
# /api/browser-context/capture。它只保存当前页 Markdown 到线程 uploads,
# 不会改变普通聊天、上传或工具调用流程。
browser_context:
enabled: true
max_content_chars: 200000
max_title_chars: 300
max_url_chars: 2048
filename_prefix: webpage
route_base: /page/workspace
task_result_timeout_seconds: 60
task_poll_interval_seconds: 1
task_lease_seconds: 120
tool_result_max_chars: 60000
# 交互式页面操作(browser_act:点击/输入/滚动/等待/跳转)。关闭后只保留只读抓取。
actions_enabled: true
action_result_timeout_seconds: 45
max_observed_elements: 150
observation_text_max_chars: 12000
title:
enabled: true
max_words: 6
@@ -159,7 +183,6 @@ title:
tool_groups:
- name: web
- name: browser
- name: file:read
- name: file:write
- name: bash
@@ -175,14 +198,6 @@ tools:
use: deerflow.community.jina_ai.tools:web_fetch_tool
timeout: 15
- name: browser_fetch_page
group: browser
use: deerflow.tools.builtins.browser_context_tool:browser_fetch_page_tool
- name: browser_act
group: browser
use: deerflow.tools.builtins.browser_act_tool:browser_act_tool
- name: ls
group: file:read
use: deerflow.sandbox.tools:ls_tool
@@ -216,12 +231,28 @@ tools:
sandbox:
use: deerflow.sandbox.local:LocalSandboxProvider
allow_host_bash: true
mounts:
- host_path: C:\Users\iie\Desktop\deerflow-new\offline-backend-20260512\backend\knowledge\knowledge_base
container_path: /mnt/knowledge
read_only: true
skills:
path: skills
security_scan:
enabled: false
skill_evolution:
enabled: true
curator:
enabled: true # 每 24 小时自动清理 + 合并 skill
interval_hours: 24
review_worker:
enabled: true # 被动复盘开关
min_tool_calls: 5 # 触发阈值:本轮至少 5 次工具调用,与 prompt 中"≥5 步"硬条件一致
max_messages: 60 # 传给 LLM 的最大消息条数
timeout_seconds: 120 # 单次 review 超时(秒)
model_name: # 留空则使用主模型
agents_api:
enabled: true
@@ -252,7 +283,7 @@ auth_login:
database:
backend: sqlite
sqlite_dir: /app/backend/.deer-flow/data
sqlite_dir: .deer-flow/data
pool_size: 20 # 基础连接池(postgres/mysql 生效;sqlite 忽略)
max_overflow: 20 # 高峰期可临时超出 pool_size 的额外连接数,真实上限 = pool_size + max_overflow
pool_timeout: 30 # 池满时等待空闲连接的秒数,超时报错
@@ -260,7 +291,7 @@ database:
checkpointer:
type: sqlite
connection_string: /app/backend/.deer-flow/data/deerflow.db
connection_string: .deer-flow/data/deerflow.db
# 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
# 三层覆盖优先级(高->低):
@@ -342,39 +373,3 @@ log_system:
enabled: true
url: "https://your-log-system.example.com"
label: "日志系统"
# 产品内知识库 —— obsidian-wiki 兼容 vault。知识元数据写入数据库,正文
# content_md 写入 Markdown vault(index.md/log.md/hot.md/.manifest.json/frontmatter/
# wikilinks/wiki-export 图谱产物)。知识库为全局共享,不做 user_id 隔离,
# 写入时记录 created_by / updated_by 作为审计。
knowledge:
enabled: true
provider: obsidian_wiki
vault_dir: data/knowledge/obsidian-vault
project_name: zncm
auto_ingest_enabled: false
auto_ingest_min_chars: 300
# 用大模型按 wiki-capture 规范提炼陈述式知识(而非堆砌对话原文)。
# 关闭则回退到规则抽取;extract_model_name 留空时用 models[0]。
llm_extract_enabled: true
extract_model_name: ""
extract_max_input_chars: 12000
max_thread_messages: 80
max_source_items: 20
rag_enabled: false
rag_limit: 5
# 阶段三:写入前脱敏(token/密钥/手机号/身份证/邮箱);重复检测相似度阈值。
sensitive_redaction_enabled: true
dedup_similarity_threshold: 0.82
export:
enabled: true
graph_html_enabled: true
graph_json_enabled: true
query:
use_hot_md: true
use_index_md: true
use_frontmatter_scan: true
embedding:
enabled: false
provider: local
model: ""
@@ -4,13 +4,26 @@ DeerFlow includes automatic conversation summarization to handle long conversati
## Overview
The summarization feature uses LangChain's `SummarizationMiddleware` to monitor conversation history and trigger summarization based on configurable thresholds. When activated, it:
The summarization feature uses DeerFlow's `DeerFlowSummarizationMiddleware` (a subclass of LangChain's `SummarizationMiddleware`) to monitor conversation history and trigger summarization based on configurable thresholds. When activated, it:
1. Monitors message token counts in real-time
2. Triggers summarization when thresholds are met
3. Keeps recent messages intact while summarizing older exchanges
4. Maintains AI/Tool message pairs together for context continuity
5. Injects the summary back into the conversation
5. Substitutes the summary for the older messages **in the model request only**
### Non-destructive (display-preserving) compression
> **Important:** DeerFlow compresses **transiently**, not destructively.
LangChain's stock `SummarizationMiddleware` runs in `before_model` and persists a `RemoveMessage(REMOVE_ALL_MESSAGES)` to the checkpoint — which permanently deletes the original Q&A from both the model context *and* the conversation the frontend renders. DeerFlow overrides this:
- `before_model` / `abefore_model` are **no-ops** (the destructive path is disabled).
- Compression runs in `wrap_model_call` / `awrap_model_call`: for a single model call, the message list is replaced with `[summary, *recent]` via `request.override(messages=...)`. **Nothing is written back to state.**
- The full original messages stay in the checkpoint, so the frontend keeps showing the user's real conversation. The summary message is named `summary` and flagged `hide_from_ui`, and because it only ever lives in the transient request it never reaches the frontend at all.
- The generated summary is cached **per-thread** so we don't pay an extra LLM call on every model invocation. The cached summary is reused (no LLM call) until enough *new* messages accumulate to push the compressed context back over the trigger threshold (a sticky boundary mirroring the stock middleware's post-compaction behavior). The cache is bounded (oldest threads evicted first).
> **Legacy threads:** conversations compacted by an older (destructive) build already lost their original messages and carry a persisted `summary` message. The frontend hides any `name: "summary"` message, so the stray summary bubble no longer shows — but those already-discarded messages cannot be recovered.
## Configuration
@@ -169,10 +182,7 @@ The default LangChain prompt instructs the model to:
- Messages to summarize (older messages beyond the `keep` threshold)
- Messages to preserve (recent messages within the `keep` threshold)
4. **Summary Generation**: The model generates a concise summary of the older messages
5. **Context Replacement**: The message history is updated:
- All old messages are removed
- A single summary message is added
- Recent messages are preserved
5. **Context Replacement (transient)**: The message list **handed to the model for this call** is rebuilt as `[summary, *recent]`. The persisted state is left untouched — old messages are **not** removed, so the frontend still renders the full original conversation.
6. **AI/Tool Pair Protection**: The system ensures AI messages and their corresponding tool messages stay together
7. **Skill Rescue**: Before the summary is generated, the most recently loaded skill files (tool results whose tool name is in `skill_file_read_tool_names` and whose target path is under `skills.container_path`) are lifted out of the summarization set and prepended to the preserved tail. Selection walks newest-first under three budgets: `preserve_recent_skill_count`, `preserve_recent_skill_tokens`, and `preserve_recent_skill_tokens_per_skill`. The triggering AIMessage and all of its paired ToolMessages move together so tool_call ↔ tool_result pairing stays intact.
@@ -298,8 +308,9 @@ The middleware intelligently preserves message context:
### Code Structure
- **Configuration**: `packages/harness/deerflow/config/summarization_config.py`
- **Integration**: `packages/harness/deerflow/agents/lead_agent/agent.py`
- **Middleware**: Uses `langchain.agents.middleware.SummarizationMiddleware`
- **Integration**: `packages/harness/deerflow/agents/lead_agent/agent.py` (`_create_summarization_middleware`)
- **Middleware**: `packages/harness/deerflow/agents/middlewares/summarization_middleware.py` — `DeerFlowSummarizationMiddleware`, subclassing `langchain.agents.middleware.SummarizationMiddleware` but overriding compaction to be transient (`wrap_model_call`) instead of destructive (`before_model`)
- **Tests**: `tests/test_summarization_middleware.py`
### Middleware Order
@@ -313,9 +324,9 @@ Summarization runs after ThreadData and Sandbox initialization but before Title
### State Management
- Summarization is stateless - configuration is loaded once at startup
- Summaries are added as regular messages in the conversation history
- The checkpointer persists the summarized history automatically
- Configuration is loaded once at startup
- Summarization is **non-destructive**: the summary replaces older messages only in the transient model request (`wrap_model_call`), never in persisted state. The checkpointer therefore keeps the full original conversation
- The per-thread summary cache lives in the middleware instance (in-memory, bounded). It is a cost optimization only — a cold/stale cache simply re-summarizes, which is always correct
## Example Configurations
@@ -6,7 +6,7 @@
"code-retrieval": { "enabled": true },
"news-briefing": { "enabled": true },
"workspace-search": { "enabled": true },
"policy-knowledge": { "enabled": true },
"policy-knowledge": { "enabled": false },
"html-page-builder": { "enabled": true },
"html-charts": { "enabled": true }
}
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff. Show more