diff --git a/batch-distill/README.md b/batch-distill/README.md new file mode 100644 index 00000000..5fa50926 --- /dev/null +++ b/batch-distill/README.md @@ -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) + +**断点续跑**:已完成的人自动跳过;中途往名单里加人,下个执行窗会带上。 diff --git a/batch-distill/distill_people.py b/batch-distill/distill_people.py new file mode 100644 index 00000000..a57d9609 --- /dev/null +++ b/batch-distill/distill_people.py @@ -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: +sources_count: <检索到的来源条数> +primary_source_ratio: <一手来源占比, 0~1 的小数> +confidence: +--- + +# <人物名> + +> **一句话定位**:<此人看世界的独特方式,一句话> + +## 身份卡 +- **活跃年代**: +- **核心身份**: +- **自述(50字, 以其语气)**: + +## 核心观点(真信念 · 反复出现的主张) +1. ... + +## 心智模型(3–7 个;每个含:一句话 / 证据场景≥2 / 应用 / 局限 / 来源) +### 1. <模型名> +- **一句话**: +- **证据场景**: +- **应用方式**: +- **局限**: +- **来源**: + +## 决策启发式(5–10 条 · 如果 X 则 Y,带案例) +- 若 → + +## 表达 DNA +| 维度 | 特征 | +|------|------| +| 句式 | | +| 高频词 | | +| 幽默 | | +| 确定性 | | +| 引用习惯 | | + +## 价值观与反模式 +- **价值观排序**: +- **明确反对**: + +## 内在张力(≥2 对矛盾) +- ↔ + +## 经历时间线 +| 年份 | 事件 | 思想意义 | +|------|------|---------| +| | | | + +## 代表语录 +- “...” —(出处/年份,标一手/二手) + +## 智识谱系 +- **受影响于**: +- **影响了**: + +## 外部评价与争议 +- + +## 诚实边界 +- <至少 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": , ...}。 + 取最后一条有正文的 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() diff --git a/batch-distill/people.example.csv b/batch-distill/people.example.csv new file mode 100644 index 00000000..bed69453 --- /dev/null +++ b/batch-distill/people.example.csv @@ -0,0 +1,4 @@ +姓名,领域(可选提示),资料链接或备注(可选) +查理·芒格,投资/多元思维, +理查德·费曼,物理/教学, +纳瓦尔·拉维坎特,创业/杠杆,https://nav.al/ diff --git a/frontend-web/.env.example b/frontend-web/.env.example index 5939e301..e254e6c8 100644 --- a/frontend-web/.env.example +++ b/frontend-web/.env.example @@ -14,8 +14,8 @@ # - 本机连 127.0.0.1:8001:复制 .env.development.local.example → .env.development.local # - 内网打包部署:改 .env.network 后执行 pnpm build:network(或改 .env.production 后 pnpm build) # -# 若浏览器直连后端报 CORS,请在 Gateway 配置 CORS_ORIGINS,包含前端源站,例如: -# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174 +# 若浏览器直连后端报 CORS,请在 Gateway 配置 GATEWAY_CORS_ORIGINS,包含前端源站;无界嵌入时还要加宿主源站,例如: +# GATEWAY_CORS_ORIGINS=http://127.0.0.1:5174,http://localhost:5174,http://127.0.0.1:5183,http://localhost:5183 # --- 开发环境(见 .env.development)--- # VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 752afc7a..e2c8695c 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -7,6 +7,7 @@ import { QueryClientProvider } from "@/components/query-client-provider"; import { AuthSyncWatcher } from "@/core/auth/sync"; import { I18nProvider } from "@/core/i18n/context"; import { detectLocale } from "@/core/i18n/locale"; +import { resolveForcedThemeForPath } from "@/core/page-layout/chats-iframe-route"; import { ChatProvider } from "@/contexts/ChatContext"; import { ThemeProvider } from "@/contexts/ThemeContext"; @@ -22,13 +23,14 @@ import PublicSharePage from "./pages/PublicSharePage"; import PublicLeaderboardPage from "./pages/PublicLeaderboardPage"; import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage"; import EmbedChatPage from "./pages/EmbedChatPage"; +import EmbedRoundtablePage from "./pages/EmbedRoundtablePage"; import { ChatRuntime } from "./pages/WorkspaceRoutes"; import { NotFoundPage } from "./pages/NotFoundPage"; function AppProviders({ children }: { children: React.ReactNode }) { const locale = useMemo(() => detectLocale(), []); const location = useLocation(); - const forcedTheme = location.pathname === "/landing" ? "light" : undefined; + const forcedTheme = resolveForcedThemeForPath(location.pathname); return ( } /> + {/* Embedded roundtable planning — body only, no sidebars; username login (not wujie ticket) */} + } /> } /> } /> diff --git a/frontend-web/src/assets/img/blue-header.png b/frontend-web/src/assets/img/blue-header.png new file mode 100644 index 00000000..5d904a2c Binary files /dev/null and b/frontend-web/src/assets/img/blue-header.png differ diff --git a/frontend-web/src/assets/img/dark-blue-header.svg b/frontend-web/src/assets/img/dark-blue-header.svg new file mode 100644 index 00000000..27a56ee9 --- /dev/null +++ b/frontend-web/src/assets/img/dark-blue-header.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/img/red-header.png b/frontend-web/src/assets/img/red-header.png new file mode 100644 index 00000000..39e82ed2 Binary files /dev/null and b/frontend-web/src/assets/img/red-header.png differ diff --git a/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx b/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx new file mode 100644 index 00000000..8b3b3bc7 --- /dev/null +++ b/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx @@ -0,0 +1,926 @@ +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; + +/** Canvas-drawn colors per app theme (CSS variables don't reach the 2D context). */ +const CANVAS_PALETTE = { + dark: { + exportBg: "#0b1020", + labelHalo: "rgba(2, 6, 23, 0.9)", + nodeRim: "rgba(2, 6, 23, 0.55)", + rootRim: "#e0f2fe", + expandedRim: "#7dd3fc", + rootRing: "rgba(125, 211, 252, 0.9)", + expandedDot: "#7dd3fc", + expandedDotRim: "rgba(2, 6, 23, 0.8)", + selectionRing: "#ffffff", + hoverRing: "rgba(255, 255, 255, 0.85)", + pin: "rgba(255, 255, 255, 0.8)", + labelActive: "#ffffff", + label: "#e2e8f0", + minimapViewport: "rgba(56, 189, 248, 0.8)", + }, + light: { + exportBg: "#f8fafc", + labelHalo: "rgba(255, 255, 255, 0.92)", + nodeRim: "rgba(255, 255, 255, 0.9)", + rootRim: "#075985", + expandedRim: "#0284c7", + rootRing: "rgba(2, 132, 199, 0.85)", + expandedDot: "#0284c7", + expandedDotRim: "rgba(255, 255, 255, 0.9)", + selectionRing: "#0f172a", + hoverRing: "rgba(15, 23, 42, 0.7)", + pin: "rgba(15, 23, 42, 0.6)", + labelActive: "#0f172a", + label: "#334155", + minimapViewport: "rgba(2, 132, 199, 0.8)", + }, +} as const; + +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; + selectedNodeId: string | null; + /** null = no active search; otherwise the set of matching node ids. */ + searchMatchIds: Set | null; + layoutType: LayoutType; + showLabels: boolean; + /** App theme; drives the canvas-drawn colors (labels, rims, rings). */ + theme: "light" | "dark"; + 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; + expandedNodeIds: Set; + } | 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( + function GraphCanvas(props, ref) { + const containerRef = useRef(null); + const canvasRef = useRef(null); + const minimapRef = useRef(null); + + const simRef = useRef(null); + const nodeMapRef = useRef>(new Map()); + const adjacencyRef = useRef>>(new Map()); + const labelThresholdRef = useRef(3); + + const transformRef = useRef({ x: 0, y: 0, k: 0 }); + const sizeRef = useRef({ w: 0, h: 0 }); + const hoverRef = useRef(null); + const dragRef = useRef(null); + const animRef = useRef<{ + from: Transform; + to: Transform; + start: number; + duration: number; + } | null>(null); + const needsDrawRef = useRef(true); + const autoFitRef = useRef(false); + // While the initial layout is still settling we keep the whole graph in + // view every frame; any pan/zoom/drag from the user takes over. + const interactedRef = 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>(); + 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; + interactedRef.current = false; + // Pre-settle a fresh force layout synchronously (time-bounded) so the + // first paint is already roughly laid out instead of an exploding + // cloud that drifts out of the viewport. + if (props.layoutType === "force") { + const deadline = performance.now() + 120; + let guard = 0; + while (sim.alpha > 0.25 && guard < 300 && performance.now() < deadline) { + sim.tick(); + guard += 1; + } + } + } + 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; + interactedRef.current = false; + requestDraw(); + }, [props.layoutType, requestDraw]); + + // Redraw on highlight-affecting prop changes. + useEffect(() => { + requestDraw(); + }, [ + props.selectedNodeId, + props.searchMatchIds, + props.showLabels, + props.explore, + props.theme, + 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) => { + interactedRef.current = true; + 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; + interactedRef.current = true; + 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; + interactedRef.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 = CANVAS_PALETTE[propsRef.current.theme].exportBg; + 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; + const palette = CANVAS_PALETTE[p.theme]; + + // ---- 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 = palette.labelHalo; + 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: string = palette.nodeRim; + 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 + ? palette.rootRim + : isExpanded + ? palette.expandedRim + : palette.nodeRim; + 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 = palette.rootRing; + 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 = palette.expandedDot; + ctx.fill(); + ctx.lineWidth = 1 / k; + ctx.strokeStyle = palette.expandedDotRim; + 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 = palette.selectionRing; + 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 = palette.hoverRing; + 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 = palette.pin; + 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 = palette.labelHalo; + ctx.strokeText(text, n.x, n.y + r + 5 / k); + ctx.fillStyle = isActive || isSelected ? palette.labelActive : palette.label; + 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 = CANVAS_PALETTE[propsRef.current.theme].minimapViewport; + 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; + // Until the user pans/zooms/drags, keep the settling layout fully in + // view every frame so nodes never drift out of the viewport. + if (!interactedRef.current && sizeRef.current.w) { + fitView(false); + autoFitRef.current = true; + } else if (!autoFitRef.current && sim.alpha < 0.3) { + autoFitRef.current = true; + fitView(true); + } + } else if (sim && !autoFitRef.current && sim.nodes.length && sizeRef.current.w) { + 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) => { + if (e.button !== 0) return; + interactedRef.current = true; + 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) => { + 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) => { + 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) => { + 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(); + interactedRef.current = true; + 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 ( +
+ + {props.toolbar ? ( +
{props.toolbar}
+ ) : null} + {props.hint} + {minimapVisible ? ( + + ) : null} + {props.overlay} +
+ ); + }, +); diff --git a/frontend-web/src/components/knowledge-graph/GraphDetailPanel.tsx b/frontend-web/src/components/knowledge-graph/GraphDetailPanel.tsx new file mode 100644 index 00000000..e9ecb997 --- /dev/null +++ b/frontend-web/src/components/knowledge-graph/GraphDetailPanel.tsx @@ -0,0 +1,287 @@ +import { + Ban, + 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 = { + thread: "对话沉淀", + search: "搜索沉淀", + tool: "工具结果", + file: "文件导入", + manual: "手动创建", + report: "报告", +}; + +const STATUS_ZH: Record = { + 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; + onBlacklist: (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 ( + + ); +} + +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 ( +
+

{getNodeTitle(node)}

+
+ + + {kindMeta.label} + {node.category && node.category !== kindMeta.label + ? ` · ${node.category}` + : ""} + + + 度数 {props.degree} + + {props.explore && props.explore.depth !== null ? ( + <> + + {props.explore.isRoot + ? "探索起点" + : `第 ${props.explore.depth} 层`} + + + {props.explore.expanded ? "已展开" : "可展开"} + + + ) : null} +
+ + {props.noteLoading ? ( +
+ 加载笔记详情... +
+ ) : null} + + {note?.summary ?

{note.summary}

: null} + + {note?.tags?.length ? ( +
+ {note.tags.map((tag) => ( + + #{tag} + + ))} +
+ ) : null} + + {note ? ( +
+ 来源 + + {SOURCE_TYPE_ZH[note.source_type] ?? note.source_type} + + 状态 + + {STATUS_ZH[note.status] ?? note.status} + + 创建时间 + {formatTime(note.created_at)} + 更新时间 + {formatTime(note.updated_at)} + {note.created_by_name ? ( + <> + 创建人 + {note.created_by_name} + + ) : null} +
+ ) : null} + +
+ {isNote ? ( + + ) : null} + + + + {props.explore ? ( + <> + + + + ) : null} +
+ +

+ 关联节点({props.neighbors.length}) +

+ {props.neighbors.length === 0 ? ( +
该节点暂无可见的关联节点
+ ) : ( +
+ {props.neighbors.map(({ node: neighbor, relation }) => { + const neighborKind = NODE_KIND_META[getNodeKind(neighbor)]; + const relationInfo = getEdgeInfo(relation); + return ( +
props.onPickNeighbor(neighbor.id)} + > + + {getNodeTitle(neighbor)} + + {relationInfo.zh} + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend-web/src/components/knowledge-graph/GraphFooter.tsx b/frontend-web/src/components/knowledge-graph/GraphFooter.tsx new file mode 100644 index 00000000..7faa1051 --- /dev/null +++ b/frontend-web/src/components/knowledge-graph/GraphFooter.tsx @@ -0,0 +1,57 @@ +import type { NodeKind } from "./graphTypes"; +import { getEdgeInfo, NODE_KIND_META, NODE_KIND_ORDER } from "./graphUtils"; + +interface GraphFooterProps { + nodeKindCounts: Map; + 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 ( +
+
+ {kinds.map((kind) => ( + + + {NODE_KIND_META[kind].label} + + ))} + {props.edgeLabels.slice(0, 6).map((label) => { + const info = getEdgeInfo(label); + return ( + + + {info.zh} + + ); + })} +
+
+ {props.selectedTitle ? ( + + 选中:{props.selectedTitle} + + ) : null} + + 显示 {props.visibleNodes} 节点 · {props.visibleEdges} 关系 + + 缩放 {Math.round(props.zoomLevel * 100)}% +
+
+ ); +} diff --git a/frontend-web/src/components/knowledge-graph/GraphHeader.tsx b/frontend-web/src/components/knowledge-graph/GraphHeader.tsx new file mode 100644 index 00000000..24cc6503 --- /dev/null +++ b/frontend-web/src/components/knowledge-graph/GraphHeader.tsx @@ -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 ( +
+
+ +
+

知识图谱

+ 知识关系可视化 +
+
+ {filteredNodes} 个节点 + + {filteredEdges} 条关系 + +
+
+
+ + + + + + + +
+
+ ); +} diff --git a/frontend-web/src/components/knowledge-graph/GraphSidebar.tsx b/frontend-web/src/components/knowledge-graph/GraphSidebar.tsx new file mode 100644 index 00000000..30d1d562 --- /dev/null +++ b/frontend-web/src/components/knowledge-graph/GraphSidebar.tsx @@ -0,0 +1,292 @@ +import { ChevronDown, ChevronRight, Eraser, Search, X } from "lucide-react"; +import { useState } from "react"; + +import type { GraphBlacklistItem } from "@/core/knowledge/api"; + +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; + edgeLabelCounts: Map; + disabledNodeKinds: Set; + disabledEdgeLabels: Set; + stats: GraphStats; + topNodes: Array<{ node: GraphNode; degree: number }>; + hiddenCount: number; + blacklistItems: GraphBlacklistItem[]; + blacklistPending: boolean; + junkCandidateCount: number; + onSearchChange: (keyword: string) => void; + onSearchEnter: () => void; + onToggleNodeKind: (kind: NodeKind) => void; + onToggleEdgeLabel: (label: string) => void; + onRestoreHidden: () => void; + onRemoveBlacklist: (entryId: string) => void; + onCleanJunk: () => void; + onPickNode: (id: string) => void; + onClose: () => void; +} + +type SectionKey = + | "nodeTypes" + | "edgeTypes" + | "graphStats" + | "coreNodes" + | "blacklist"; + +interface FilterSectionProps { + title: string; + extra?: string; + collapsed: boolean; + onToggle: () => void; + children: React.ReactNode; +} + +function FilterSection(props: FilterSectionProps) { + return ( +
+ + {!props.collapsed ? ( +
{props.children}
+ ) : null} +
+ ); +} + +export function GraphSidebar(props: GraphSidebarProps) { + // 节点类型 / 关系类型默认收起,统计与核心节点默认展开。 + const [collapsedSections, setCollapsedSections] = useState< + Record + >({ + nodeTypes: true, + edgeTypes: true, + graphStats: false, + coreNodes: false, + blacklist: true, + }); + + 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 ( + + ); +} diff --git a/frontend-web/src/components/knowledge-graph/GraphTooltip.tsx b/frontend-web/src/components/knowledge-graph/GraphTooltip.tsx new file mode 100644 index 00000000..50e5bf33 --- /dev/null +++ b/frontend-web/src/components/knowledge-graph/GraphTooltip.tsx @@ -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 ( +
+
{getNodeTitle(tooltip.node)}
+
+ + + {meta.label} + + {tooltip.node.category ? · {tooltip.node.category} : null} + · 度数 {tooltip.degree} +
+
单击查看详情 · 双击聚焦 · 拖拽固定位置
+
+ ); +} diff --git a/frontend-web/src/components/knowledge-graph/graphStyles.css b/frontend-web/src/components/knowledge-graph/graphStyles.css new file mode 100644 index 00000000..e6595f66 --- /dev/null +++ b/frontend-web/src/components/knowledge-graph/graphStyles.css @@ -0,0 +1,1038 @@ +/* Knowledge-graph workbench — professional analysis-tool styling. + Light theme is the default; dark values are applied under `.dark` + (the app toggles the `dark` class on ). */ + +.kg-page { + --kg-bg: #f4f6fb; + --kg-bg-image: + radial-gradient(circle at 20% 10%, rgba(2, 132, 199, 0.07), transparent 32%), + radial-gradient(circle at 80% 20%, rgba(124, 58, 237, 0.06), transparent 28%); + --kg-panel: rgba(255, 255, 255, 0.82); + --kg-panel-solid: #ffffff; + --kg-header-bg: rgba(255, 255, 255, 0.88); + --kg-float-bg: rgba(255, 255, 255, 0.88); + --kg-tooltip-bg: rgba(255, 255, 255, 0.97); + --kg-tooltip-shadow: 0 18px 40px rgba(15, 23, 42, 0.18); + --kg-button-bg: rgba(255, 255, 255, 0.75); + --kg-input-bg: rgba(255, 255, 255, 0.9); + --kg-card-bg: rgba(248, 250, 252, 0.9); + --kg-canvas-bg: rgba(255, 255, 255, 0.55); + --kg-canvas-glow: rgba(2, 132, 199, 0.06); + --kg-grid-line: rgba(15, 23, 42, 0.05); + + --kg-border: rgba(15, 23, 42, 0.12); + --kg-border-soft: rgba(15, 23, 42, 0.09); + --kg-border-faint: rgba(15, 23, 42, 0.07); + --kg-divider: rgba(15, 23, 42, 0.06); + --kg-input-border: rgba(15, 23, 42, 0.16); + + --kg-text-strong: #0f172a; + --kg-text-main: #1e293b; + --kg-text-soft: #334155; + --kg-text-secondary: #64748b; + --kg-text-muted: #94a3b8; + + --kg-primary: #0284c7; + --kg-primary-text: #0369a1; + --kg-primary-accent: #0369a1; + --kg-primary-soft: rgba(2, 132, 199, 0.1); + --kg-primary-soft-strong: rgba(2, 132, 199, 0.16); + --kg-primary-soft-faint: rgba(2, 132, 199, 0.07); + --kg-primary-border: rgba(2, 132, 199, 0.3); + --kg-primary-border-strong: rgba(2, 132, 199, 0.45); + --kg-focus-border: rgba(2, 132, 199, 0.55); + --kg-focus-ring: rgba(2, 132, 199, 0.12); + + --kg-pill-bg: rgba(2, 132, 199, 0.08); + --kg-pill-border: rgba(2, 132, 199, 0.24); + --kg-violet-bg: rgba(124, 58, 237, 0.08); + --kg-violet-border: rgba(124, 58, 237, 0.24); + --kg-violet-text: #6d28d9; + + --kg-hover-bg: rgba(15, 23, 42, 0.07); + --kg-hover-bg-faint: rgba(15, 23, 42, 0.045); + --kg-warn: #b45309; + --kg-success-bg: rgba(5, 150, 105, 0.12); + --kg-success-text: #047857; + --kg-warn-bg: rgba(217, 119, 6, 0.12); + --kg-warn-text: #b45309; + + height: 100%; + min-height: 0; + background-color: var(--kg-bg); + background-image: var(--kg-bg-image); + color: var(--kg-text-main); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.dark .kg-page { + --kg-bg: #0b1020; + --kg-bg-image: + 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%); + --kg-panel: rgba(15, 23, 42, 0.78); + --kg-panel-solid: #0f172a; + --kg-header-bg: rgba(15, 23, 42, 0.86); + --kg-float-bg: rgba(15, 23, 42, 0.82); + --kg-tooltip-bg: rgba(15, 23, 42, 0.94); + --kg-tooltip-shadow: 0 18px 40px rgba(0, 0, 0, 0.35); + --kg-button-bg: rgba(15, 23, 42, 0.65); + --kg-input-bg: rgba(2, 6, 23, 0.45); + --kg-card-bg: rgba(2, 6, 23, 0.35); + --kg-canvas-bg: rgba(2, 6, 23, 0.5); + --kg-canvas-glow: rgba(56, 189, 248, 0.08); + --kg-grid-line: rgba(148, 163, 184, 0.04); + + --kg-border: rgba(148, 163, 184, 0.18); + --kg-border-soft: rgba(148, 163, 184, 0.14); + --kg-border-faint: rgba(148, 163, 184, 0.1); + --kg-divider: rgba(148, 163, 184, 0.08); + --kg-input-border: rgba(148, 163, 184, 0.2); + + --kg-text-strong: #ffffff; + --kg-text-main: #e5e7eb; + --kg-text-soft: #cbd5e1; + --kg-text-secondary: #94a3b8; + --kg-text-muted: #64748b; + + --kg-primary: #38bdf8; + --kg-primary-text: #bae6fd; + --kg-primary-accent: #7dd3fc; + --kg-primary-soft: rgba(56, 189, 248, 0.14); + --kg-primary-soft-strong: rgba(56, 189, 248, 0.18); + --kg-primary-soft-faint: rgba(56, 189, 248, 0.08); + --kg-primary-border: rgba(56, 189, 248, 0.35); + --kg-primary-border-strong: rgba(56, 189, 248, 0.45); + --kg-focus-border: rgba(56, 189, 248, 0.55); + --kg-focus-ring: rgba(56, 189, 248, 0.1); + + --kg-pill-bg: rgba(56, 189, 248, 0.12); + --kg-pill-border: rgba(56, 189, 248, 0.28); + --kg-violet-bg: rgba(167, 139, 250, 0.12); + --kg-violet-border: rgba(167, 139, 250, 0.28); + --kg-violet-text: #ddd6fe; + + --kg-hover-bg: rgba(148, 163, 184, 0.12); + --kg-hover-bg-faint: rgba(148, 163, 184, 0.08); + --kg-warn: #fbbf24; + --kg-success-bg: rgba(52, 211, 153, 0.14); + --kg-success-text: #6ee7b7; + --kg-warn-bg: rgba(251, 191, 36, 0.14); + --kg-warn-text: #fcd34d; +} + +/* ------------------------------------------------------------------ header */ + +.kg-header { + height: 56px; + border-bottom: 1px solid var(--kg-border); + background: var(--kg-header-bg); + 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: var(--kg-hover-bg); +} + +.kg-title-block { + display: flex; + align-items: baseline; + gap: 10px; + min-width: 0; +} + +.kg-title { + font-size: 18px; + font-weight: 800; + color: var(--kg-text-strong); + 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: var(--kg-pill-bg); + border: 1px solid var(--kg-pill-border); + color: var(--kg-primary-text); + font-size: 12px; + display: inline-flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +.kg-stat-pill.kg-stat-pill-violet { + background: var(--kg-violet-bg); + border-color: var(--kg-violet-border); + color: var(--kg-violet-text); +} + +.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: var(--kg-button-bg); + color: var(--kg-text-soft); + 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: var(--kg-text-strong); + background: var(--kg-primary-soft); + border-color: var(--kg-primary-border); +} + +.kg-icon-button:disabled { + opacity: 0.5; + cursor: default; +} + +.kg-icon-button.kg-icon-button-active { + color: var(--kg-text-strong); + background: var(--kg-primary-soft-strong); + border-color: var(--kg-primary-border-strong); +} + +.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 var(--kg-border-soft); + 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 var(--kg-border); + background: var(--kg-button-bg); + color: var(--kg-text-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; +} + +.kg-panel-close-btn:hover { + color: var(--kg-text-strong); + background: var(--kg-primary-soft); + border-color: var(--kg-primary-border); +} + +.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 var(--kg-input-border); + background: var(--kg-input-bg); + 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: var(--kg-focus-border); + box-shadow: 0 0 0 3px var(--kg-focus-ring); +} + +.kg-search-hint { + margin-top: 6px; + font-size: 12px; + color: var(--kg-text-muted); +} + +.kg-search-hint.kg-search-hint-warn { + color: var(--kg-warn); +} + +.kg-filter-section { + border-top: 1px solid var(--kg-border-faint); + 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: var(--kg-text-soft); + 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: var(--kg-text-strong); +} + +.kg-filter-section-extra { + color: var(--kg-text-muted); + 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: var(--kg-text-soft); + font-size: 13px; + cursor: pointer; + border-radius: 8px; + padding: 0 6px; + user-select: none; +} + +.kg-check-row:hover { + background: var(--kg-hover-bg-faint); +} + +.kg-check-row-main { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.kg-check-row input[type="checkbox"] { + accent-color: var(--kg-primary); + 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 var(--kg-border-faint); + background: var(--kg-card-bg); + padding: 8px 10px; +} + +.kg-stat-card-value { + font-size: 16px; + font-weight: 800; + color: var(--kg-text-strong); +} + +.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 var(--kg-divider); + cursor: pointer; + border-radius: 8px; +} + +.kg-rank-item:hover { + background: var(--kg-primary-soft-faint); +} + +.kg-rank-index { + width: 20px; + color: var(--kg-primary); + font-weight: 700; + font-size: 12px; + flex-shrink: 0; +} + +.kg-rank-title { + flex: 1; + color: var(--kg-text-soft); + 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-color: var(--kg-canvas-bg); + background-image: + linear-gradient(var(--kg-grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--kg-grid-line) 1px, transparent 1px), + radial-gradient(circle at 50% 40%, var(--kg-canvas-glow), transparent 42%); + background-size: + 28px 28px, + 28px 28px, + 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: var(--kg-float-bg); + border: 1px solid var(--kg-border); + color: var(--kg-text-soft); + 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: var(--kg-text-soft); + font-size: 12px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.kg-pill-button:hover { + color: var(--kg-text-strong); +} + +.kg-pill-button.kg-pill-button-active { + background: var(--kg-primary-soft-strong); + color: var(--kg-text-strong); +} + +.kg-explore-button { + height: 32px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--kg-border); + background: var(--kg-float-bg); + color: var(--kg-text-soft); + font-size: 12px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + backdrop-filter: blur(12px); +} + +.kg-explore-button:hover { + background: var(--kg-primary-soft); + color: var(--kg-text-strong); +} + +.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 var(--kg-pill-border); + background: var(--kg-panel); + color: var(--kg-primary-text); + 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 var(--kg-pill-border); + background: var(--kg-header-bg); + color: var(--kg-primary-text); + 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: var(--kg-primary-soft-strong); + color: var(--kg-text-strong); +} + +.kg-minimap { + position: absolute; + right: 14px; + bottom: 14px; + width: 180px; + height: 120px; + border-radius: 12px; + background: var(--kg-panel); + 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: var(--kg-tooltip-bg); + border: 1px solid var(--kg-pill-border); + box-shadow: var(--kg-tooltip-shadow); + 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: var(--kg-text-strong); + 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-type-neutral { + background: var(--kg-hover-bg); + color: var(--kg-text-soft); +} + +.kg-detail-type-primary { + background: var(--kg-primary-soft); + color: var(--kg-primary-accent); +} + +.kg-detail-type-success { + background: var(--kg-success-bg); + color: var(--kg-success-text); +} + +.kg-detail-type-warn { + background: var(--kg-warn-bg); + color: var(--kg-warn-text); +} + +.kg-detail-summary { + color: var(--kg-text-soft); + 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: var(--kg-text-soft); + 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: var(--kg-hover-bg); + color: var(--kg-text-soft); + 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: var(--kg-card-bg); + border: 1px solid var(--kg-border-faint); + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; +} + +.kg-neighbor-item:hover { + background: var(--kg-primary-soft-faint); + border-color: var(--kg-primary-border); +} + +.kg-neighbor-title { + flex: 1; + min-width: 0; + font-size: 12px; + color: var(--kg-text-soft); + 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: var(--kg-primary-text); +} + +/* -------------------------------------------------------------- responsive */ + +@media (max-width: 1100px) { + .kg-header .kg-button-text { + display: none; + } + + .kg-subtitle { + display: none; + } +} diff --git a/frontend-web/src/components/knowledge-graph/graphTypes.ts b/frontend-web/src/components/knowledge-graph/graphTypes.ts new file mode 100644 index 00000000..2643fd29 --- /dev/null +++ b/frontend-web/src/components/knowledge-graph/graphTypes.ts @@ -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; +} diff --git a/frontend-web/src/components/knowledge-graph/graphUtils.ts b/frontend-web/src/components/knowledge-graph/graphUtils.ts new file mode 100644 index 00000000..b5331630 --- /dev/null +++ b/frontend-web/src/components/knowledge-graph/graphUtils.ts @@ -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 = { + 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 = { + 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 { + const degrees = new Map(); + 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(); + 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(); + 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; + } +} diff --git a/frontend-web/src/components/ui/dialog.tsx b/frontend-web/src/components/ui/dialog.tsx index 4fa8d7fb..a95a00ae 100644 --- a/frontend-web/src/components/ui/dialog.tsx +++ b/frontend-web/src/components/ui/dialog.tsx @@ -4,28 +4,38 @@ import * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { XIcon } from "lucide-react"; +import { isWujieEnv } from "@/core/embed/wujie"; import { cn } from "@/lib/utils"; function Dialog({ + modal, ...props }: React.ComponentProps) { - return ; + // 无界嵌入时 Radix modal 会给 body 注入异常 margin-right,导致右侧空白。 + // 默认降为 non-modal;显式传 modal={true} 仍可强制 modal。 + return ( + + ); } -const DialogTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ ...props }, ref) => { - return ( - - ); -}); - -DialogTrigger.displayName = DialogPrimitive.Trigger.displayName; +const DialogTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => { + return ( + + ); +}); + +DialogTrigger.displayName = DialogPrimitive.Trigger.displayName; function DialogPortal({ ...props @@ -33,68 +43,68 @@ function DialogPortal({ return ; } -const DialogClose = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ ...props }, ref) => { - return ; -}); - -DialogClose.displayName = DialogPrimitive.Close.displayName; +const DialogClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => { + return ; +}); -const DialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - return ( - - ); -}); - -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; +DialogClose.displayName = DialogPrimitive.Close.displayName; -const DialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - showCloseButton?: boolean; - } ->(({ className, children, showCloseButton = true, ...props }, ref) => { - return ( - - - - {children} - {showCloseButton && ( - - - Close - - )} - - - ); -}); - -DialogContent.displayName = DialogPrimitive.Content.displayName; +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); + +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + showCloseButton?: boolean; + } +>(({ className, children, showCloseButton = true, ...props }, ref) => { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +}); + +DialogContent.displayName = DialogPrimitive.Content.displayName; function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { return ( diff --git a/frontend-web/src/components/wiki/Breadcrumb.tsx b/frontend-web/src/components/wiki/Breadcrumb.tsx new file mode 100644 index 00000000..fb9e39f8 --- /dev/null +++ b/frontend-web/src/components/wiki/Breadcrumb.tsx @@ -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 ( +
+ {segments.map((segment, i) => ( + + + {segment} + + + + ))} + + {current} + +
+ ); +} diff --git a/frontend-web/src/components/wiki/DocumentToolbar.tsx b/frontend-web/src/components/wiki/DocumentToolbar.tsx new file mode 100644 index 00000000..9fc659ff --- /dev/null +++ b/frontend-web/src/components/wiki/DocumentToolbar.tsx @@ -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 ( + + ); +} + +/** 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 ( +
+ {editing ? ( + + 编辑中 + + ) : ( + + {statusLabel} + + )} + + {charCount.toLocaleString()} 字符 · {updatedAt} + + +
+ {editing ? ( + <> + } + label="取消" + onClick={onCancelEdit} + /> + } + label={saving ? "保存中..." : "保存"} + onClick={onSave} + disabled={saving} + emphasized + /> + + ) : ( + <> + } + label="编辑" + onClick={onEdit} + /> + } + label="导出" + onClick={onExport} + /> + } + label="归档" + onClick={onArchive} + disabled={archiving} + danger + /> + + )} +
+
+ ); +} diff --git a/frontend-web/src/components/wiki/EmptyDocumentState.tsx b/frontend-web/src/components/wiki/EmptyDocumentState.tsx new file mode 100644 index 00000000..f0a18ccf --- /dev/null +++ b/frontend-web/src/components/wiki/EmptyDocumentState.tsx @@ -0,0 +1,18 @@ +/** Shown in the reading column when no document is selected. */ +export function EmptyDocumentState() { + return ( +
+
+
📚
+
+ 选择一篇文档开始阅读 +
+
+ 你可以从左侧目录树中选择知识文档, +
+ 也可以导入 Markdown、JSON、TXT 或文件夹来构建 LLM Wiki。 +
+
+
+ ); +} diff --git a/frontend-web/src/components/wiki/FloatingToc.tsx b/frontend-web/src/components/wiki/FloatingToc.tsx new file mode 100644 index 00000000..32c18971 --- /dev/null +++ b/frontend-web/src/components/wiki/FloatingToc.tsx @@ -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(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 ( +
+ + + {open ? ( + + ) : null} +
+ ); +} diff --git a/frontend-web/src/components/wiki/KnowledgeSidebar.tsx b/frontend-web/src/components/wiki/KnowledgeSidebar.tsx new file mode 100644 index 00000000..c765b250 --- /dev/null +++ b/frontend-web/src/components/wiki/KnowledgeSidebar.tsx @@ -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; + 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 ( + + ); + } + + return ( + + ); +} diff --git a/frontend-web/src/components/wiki/KnowledgeTree.tsx b/frontend-web/src/components/wiki/KnowledgeTree.tsx new file mode 100644 index 00000000..1631eebc --- /dev/null +++ b/frontend-web/src/components/wiki/KnowledgeTree.tsx @@ -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; + /** 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 ( +
+
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" + > + + {open ? ( + + ) : ( + + )} + {node.name} + + {node.children?.length ?? 0} + +
+ {open && node.children ? ( +
+ {node.children.map((child) => ( + + ))} +
+ ) : null} +
+ ); + } + + const active = node.id === selectedId; + return ( +
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", + )} + > + + + {node.name} + + {node.item ? ( + + + + + e.stopPropagation()}> + props.onSelect(node.item!)}> + 打开文档 + + props.onExportNote(node.item!)}> + 导出 Markdown + + + + ) : null} +
+ ); +} + +/** Recursive folder/document tree for the wiki sidebar. */ +export function KnowledgeTree(props: KnowledgeTreeProps) { + return ( +
+ {props.nodes.map((node) => ( + + ))} +
+ ); +} diff --git a/frontend-web/src/components/wiki/MarkdownViewer.tsx b/frontend-web/src/components/wiki/MarkdownViewer.tsx new file mode 100644 index 00000000..318c78b5 --- /dev/null +++ b/frontend-web/src/components/wiki/MarkdownViewer.tsx @@ -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(null); + + useEffect(() => { + const root = rootRef.current; + if (!root) return; + const headings = root.querySelectorAll("h1, h2, h3, h4"); + headings.forEach((el, index) => { + const item = toc[index]; + if (item) el.id = item.id; + }); + }, [content, toc]); + + return ( +
+ +
+ ); +} diff --git a/frontend-web/src/components/wiki/RelatedDocs.tsx b/frontend-web/src/components/wiki/RelatedDocs.tsx new file mode 100644 index 00000000..be9a34af --- /dev/null +++ b/frontend-web/src/components/wiki/RelatedDocs.tsx @@ -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(); + 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 ( +
+

+ + 相关文档 + + {entries.length} + +

+
+ {entries.map(({ link, resolved }) => { + const label = link.alias || resolved?.title || link.target; + const clickable = !!resolved?.exists; + const body = ( + <> + + + + {label} + + {resolved?.vault_path ? ( + + {resolved.vault_path} + + ) : null} + + + ); + + if (clickable) { + return ( + + ); + } + return ( +
+ {body} + {!resolving ? ( + + 未收录 + + ) : null} +
+ ); + })} +
+
+ ); +} diff --git a/frontend-web/src/components/wiki/WikiContent.tsx b/frontend-web/src/components/wiki/WikiContent.tsx new file mode 100644 index 00000000..38d4705f --- /dev/null +++ b/frontend-web/src/components/wiki/WikiContent.tsx @@ -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 = { + 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 ( + + {children} + + ); +} + +/** 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(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(note.status); + const [activeHeadingId, setActiveHeadingId] = useState(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( + ".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 ( +
+
+ + setEditing(true)} + onCancelEdit={() => setEditing(false)} + onSave={() => void handleSave()} + onExport={() => downloadMarkdown(note)} + onArchive={() => void handleArchive()} + /> +
+ +
+ {!editing ? ( + + ) : null} + +
+
+ {editing ? ( +
+ setDraftTitle(e.target.value)} + placeholder="标题" + /> +
+ setDraftTags(e.target.value)} + placeholder="标签,逗号分隔" + /> + +
+