This commit is contained in:
ZyGuo committed 2026-06-12 09:07:23 +08:00
commit 41ce95cbf1
211 files changed
+20108 -2205

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
# 批量人物蒸馏 → llmwiki
一个**完全独立的单文件脚本 + 内置定时器**:读 Excel 名单,串行调用线上 DeerFlow 服务问答接口,
复用「女娲 / huashu-nuwa」技能逐个蒸馏人物,**由脚本抓取回复并保存**成 llmwiki 风格 markdown。
- **不联网**:只用已配置的知识库查询技能(`knowledge-search-v2`)检索资料。
- **定时执行**:每天 **23:00 ~ 次日 08:00** 自动跑,到点暂停、次日继续。
- **md 由 py 保存**:agent 只产出内容,脚本抓最终回复写成 `.md`(不让项目自己写文件)。
## 你只需要做两件事
1. **改一行地址**:打开 `distill_people.py`,把顶部 `BASE_URL = "..."` 改成你的线上服务地址。
2. **放名单**:同目录放 `people.xlsx`(或 `people.csv`),**第一列 = 人名**,其余列=可选提示。
其余(用户名 `distiller`、口令 `123ewq`、技能名、时间窗)都已内置,不用填。
## 运行
```bash
python distill_people.py # 常驻,按 23:00~08:00 窗口自动执行(推荐挂后台)
python distill_people.py --now # 忽略时间窗,立刻把名单跑完(测试用)
python distill_people.py --redo # 忽略已完成,全部重做
```
读 `.xlsx` 需要 `openpyxl`;用 `.csv` 则零额外依赖。
## 输出(都在 output/ 一个文件夹)
- `<人名>.md` —— 每个人的 llmwiki 档案
- `manifest.json` —— 执行清单(状态/耗时/run_id)
- `index.md` —— 可读清单表(谁完成/失败,点开看 md)
**断点续跑**:已完成的人自动跳过;中途往名单里加人,下个执行窗会带上。
+563
View File
@@ -0,0 +1,563 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
批量人物蒸馏 → llmwiki markdown(完全独立的单文件客户端 + 内置定时器)
特点
----
- 串行(一个一个来)调用正在运行的 DeerFlow 服务问答接口
- 复用线上「女娲 / huashu-nuwa」技能做提炼;**不联网**,只用已配置的
知识库查询技能(knowledge-search-v2)检索资料
- agent 只负责产出内容,**markdown 由本脚本抓取最终回复后自己保存**
- 内置时间窗:每天 23:00 ~ 次日 08:00 才执行,到点自动暂停、次日继续
- 断点续跑:已完成的人自动跳过;中途加名单也会被带上
你只需要改一处:下面的 BASE_URL(线上服务地址)。其余都不用填。
名单
----
脚本同目录放 people.xlsx 或 people.csv,**第一列 = 人名**,其余列=可选提示。
运行
----
python distill_people.py # 常驻,按 23:00~08:00 窗口自动执行
python distill_people.py --now # 忽略时间窗,立刻把名单跑完(测试用)
python distill_people.py --redo # 忽略已完成,全部重做
"""
from __future__ import annotations
import argparse
import csv
import json
import re
import sys
import time
from datetime import datetime, time as dtime
from pathlib import Path
try:
import requests
except ImportError:
sys.exit("缺少依赖 requests:pip install requests")
# ════════════════════════════════════════════════════════════════════════
# 只改这一行:你的线上服务地址(不带结尾斜杠)
# ════════════════════════════════════════════════════════════════════════
BASE_URL = "http://47.94.209.59:2026/"
# ════════════════════════════════════════════════════════════════════════
# ---- 以下都已内置,正常无需改动 ----
USERNAME = "distiller" # 免密自动注册
PASSWORD = "123ewq" # 共享口令(你们开了口令门)
NUWA_SKILL = "huashu-nuwa" # 女娲技能
KB_SKILL = "knowledge-search-v2" # 知识库查询技能(不联网,用它检索)
DEPTH = "full" # full 完整 / fast 精简
HERE = Path(__file__).resolve().parent
OUTPUT_DIR = HERE / "output"
PEOPLE_FILE_DEFAULT = HERE / "people.example.csv"
# 执行时间窗:23:00 ~ 次日 08:00(跨午夜)
WIN_START = dtime(23, 0)
WIN_END = dtime(8, 0)
PER_PERSON_TIMEOUT = 1800 # 单人最长等待(秒)
POLL_INTERVAL = 5 # 轮询间隔(秒)
HTTP_TIMEOUT = 60
TERMINAL_STATUS = {"success", "error", "timeout", "interrupted"}
# ================================ llmwiki 模板 ================================
LLMWIKI_TEMPLATE = """\
---
name: <中文名/常用名>
aliases: [<别名/英文名...>]
type: person
era: <在世 | 历史人物>
domains: [<领域1>, <领域2>]
distilled_at: <YYYY-MM-DD>
sources_count: <检索到的来源条数>
primary_source_ratio: <一手来源占比, 0~1 的小数>
confidence: <high | medium | low>
---
# <人物名>
> **一句话定位**:<此人看世界的独特方式,一句话>
## 身份卡
- **活跃年代**:
- **核心身份**:
- **自述(50字, 以其语气)**:
## 核心观点(真信念 · 反复出现的主张)
1. ...
## 心智模型(3–7 个;每个含:一句话 / 证据场景≥2 / 应用 / 局限 / 来源)
### 1. <模型名>
- **一句话**:
- **证据场景**:
- **应用方式**:
- **局限**:
- **来源**:
## 决策启发式(5–10 条 · 如果 X 则 Y,带案例)
- 若 <X> → <Y>
## 表达 DNA
| 维度 | 特征 |
|------|------|
| 句式 | |
| 高频词 | |
| 幽默 | |
| 确定性 | |
| 引用习惯 | |
## 价值观与反模式
- **价值观排序**:
- **明确反对**:
## 内在张力(≥2 对矛盾)
- <A> ↔ <B>
## 经历时间线
| 年份 | 事件 | 思想意义 |
|------|------|---------|
| | | |
## 代表语录
- “...” —(出处/年份,标一手/二手)
## 智识谱系
- **受影响于**:
- **影响了**:
## 外部评价与争议
-
## 诚实边界
- <至少 3 条具体局限,含信息截止日期>
## 调研来源
**一手(N)**:
**二手(N)**:
"""
def build_prompt(name: str, hints: str, depth: str) -> str:
depth_note = (
"本次走【完整模式】:尽量覆盖著作/对话/表达/他者评价/决策/时间线 6 个维度。"
if depth == "full"
else "本次走【精简模式】:重点覆盖核心观点、心智模型、时间线三块即可。"
)
hint_block = f"\n补充提示(来自名单其它列,可作为检索关键词):{hints}\n" if hints.strip() else ""
return f"""\
你的任务:把人物【{name}】蒸馏成一份 llmwiki 风格的人物档案 markdown。
请复用本服务中已启用的「女娲 / {NUWA_SKILL}」技能的提炼方法论
(心智模型三重验证、表达DNA分析、诚实边界)。
⚠️ 本环境**无法联网,严禁使用 WebSearch 等任何联网工具**。
所有资料**只能通过已配置的知识库查询技能「{KB_SKILL}」检索获取**。
请充分调用该知识库技能,围绕该人物多角度检索(生平、观点、著作、语录、决策、评价等),
基于检索到的内容做提炼。知识库里查不到的部分,如实在「诚实边界」标注,不要编造。
{depth_note}
{hint_block}
输出要求:
1. **不要写任何文件,不要调用 write_file / present_files**。
2. 直接在你的回复正文里输出**完整中文 markdown 全文**,严格按下面模板结构
(保留各级标题,逐项填实,不要保留尖括号占位符),**不要任何额外解说、前言或结语**:
------------------ 模板开始 ------------------
{LLMWIKI_TEMPLATE}
------------------ 模板结束 ------------------
现在开始,独立完成,不要向我提问。"""
# ================================ HTTP 客户端 ================================
class DeerFlowClient:
def __init__(self, base_url: str):
self.base = base_url.rstrip("/")
self.s = requests.Session()
self.token: str | None = None
def _headers(self) -> dict:
h = {"Content-Type": "application/json"}
if self.token:
h["Authorization"] = f"Bearer {self.token}" # 只用 Bearer,不带 cookie → 跳过 CSRF
return h
def login(self) -> bool:
url = f"{self.base}/api/v1/auth/login/username"
params = {"password": PASSWORD} if PASSWORD else None
try:
r = self.s.post(url, params=params, json={"username": USERNAME}, timeout=HTTP_TIMEOUT)
except requests.RequestException as e:
print(f" [登录] 连接失败:{e}")
return False
if r.status_code == 200:
self.token = r.json().get("access_token")
self.s.cookies.clear()
print(f" [登录] 成功,用户={USERNAME}")
return True
print(f" [登录] 返回 {r.status_code}:{r.text[:200]}")
return False
def ensure_skill_enabled(self, name: str) -> None:
try:
r = self.s.get(f"{self.base}/api/skills", headers=self._headers(), timeout=HTTP_TIMEOUT)
if r.status_code == 200:
skills = r.json().get("skills", [])
hit = next((x for x in skills if x.get("name") == name), None)
if hit is None:
print(f" [技能] 警告:服务里没找到 '{name}'")
return
if hit.get("enabled"):
print(f" [技能] '{name}' 已启用")
return
pr = self.s.put(f"{self.base}/api/skills/{name}", headers=self._headers(),
json={"enabled": True}, timeout=HTTP_TIMEOUT)
print(f" [技能] 启用 '{name}' → {pr.status_code}")
except requests.RequestException as e:
print(f" [技能] 检查 '{name}' 失败(忽略):{e}")
def create_thread(self) -> str:
r = self.s.post(f"{self.base}/api/threads", headers=self._headers(), json={}, timeout=HTTP_TIMEOUT)
r.raise_for_status()
return r.json()["thread_id"]
def start_run(self, thread_id: str, prompt: str) -> str:
body = {
"input": {"messages": [{"role": "user", "content": prompt}]},
"excluded_tools": ["ask_clarification"], # 禁澄清,无人值守不卡住
}
r = self.s.post(f"{self.base}/api/threads/{thread_id}/runs",
headers=self._headers(), json=body, timeout=HTTP_TIMEOUT)
r.raise_for_status()
return r.json()["run_id"]
def poll_run(self, thread_id: str, run_id: str) -> str:
deadline = time.monotonic() + PER_PERSON_TIMEOUT
last = ""
while time.monotonic() < deadline:
try:
r = self.s.get(f"{self.base}/api/threads/{thread_id}/runs/{run_id}",
headers=self._headers(), timeout=HTTP_TIMEOUT)
if r.status_code == 200:
st = r.json().get("status", "")
if st != last:
print(f" ...状态:{st}")
last = st
if st in TERMINAL_STATUS:
return st
except requests.RequestException as e:
print(f" 轮询出错(重试):{e}")
time.sleep(POLL_INTERVAL)
return "timeout"
def fetch_result_text(self, thread_id: str, run_id: str) -> str | None:
"""抓取该 thread 的最终 AI 回复文本(由 py 保存为 md)。
注意:本部署的 `/runs/{rid}/messages`、`/messages`、`/events` 端点都返回空
(事件库按登录用户过滤,后台 worker 写入时未带 user_id → 读不到)。
可靠来源是 LangGraph 检查点状态 `/api/threads/{tid}/state` → values.messages,
其中每条消息形如 {"type": "human"|"ai"|"tool", "content": <str | 内容块列表>, ...}。
取最后一条有正文的 ai 消息即可。
"""
try:
r = self.s.get(f"{self.base}/api/threads/{thread_id}/state",
headers=self._headers(), timeout=HTTP_TIMEOUT)
if r.status_code != 200:
print(f" 取回复返回 {r.status_code}:{r.text[:200]}")
return None
messages = (r.json().get("values") or {}).get("messages") or []
# 从后往前找最后一条「有正文」的 AI 回复(跳过只含工具调用的中间消息)
for msg in reversed(messages):
if not isinstance(msg, dict) or msg.get("type") != "ai":
continue
txt = _extract_text(msg)
if txt and txt.strip():
return txt
except requests.RequestException as e:
print(f" 取回复出错:{e}")
return None
def _extract_text(payload) -> str:
"""从一条消息的 content 里提取纯文本。
payload 可能是:
- LangChain 消息的 model_dump 字典(正文在 payload["content"])
- 直接的字符串
- 内容块列表([{"type":"text","text":"..."}, ...])
"""
if payload is None:
return ""
# 若是整条消息的 model_dump,正文在其 content 字段
if isinstance(payload, dict):
c = payload.get("content", payload.get("text", ""))
else:
c = payload
if isinstance(c, str):
return c
if isinstance(c, list):
parts = []
for b in c:
if isinstance(b, dict):
# 只取文本块,跳过 tool_use / thinking 等非文本块
if b.get("type") in (None, "text", "text_block"):
parts.append(b.get("text") or b.get("content") or "")
elif b.get("text"):
parts.append(b["text"])
elif isinstance(b, str):
parts.append(b)
return "\n".join(p for p in parts if p)
return str(c) if c else ""
def _clean_markdown(text: str) -> str:
"""去掉 agent 可能套上的 ```markdown ... ``` 围栏,并规整 YAML frontmatter。"""
t = text.strip()
m = re.match(r"^```(?:markdown|md)?\s*\n(.*)\n```$", t, re.S)
if m:
t = m.group(1).strip()
return _normalize_frontmatter(t)
def _normalize_frontmatter(text: str) -> str:
"""修复模型常见的 frontmatter 跑偏,让 YAML 能正常解析。
模型经常把字段名写成 markdown 加粗(``**name:**``)、加空行、留行尾双空格,
这些都会让 frontmatter 解析失败。这里把开头 ``---`` 到下一个 ``---`` 之间的内容
规整为干净的 ``key: value`` 形式。正文(第二个 ``---`` 之后)保持原样。
"""
if not text.startswith("---"):
return text
# 拆出 frontmatter 块:开头 --- 与下一行单独的 --- 之间
m = re.match(r"^---[ \t]*\n(.*?)\n---[ \t]*\n?(.*)$", text, re.S)
if not m:
return text
body_fm, rest = m.group(1), m.group(2)
out_lines = []
for line in body_fm.splitlines():
s = line.rstrip() # 去行尾双空格
s = s.replace("**", "") # 去 markdown 加粗标记
if not s.strip():
continue # 丢掉空行
out_lines.append(s)
return "---\n" + "\n".join(out_lines) + "\n---\n\n" + rest.lstrip("\n")
# ================================ 名单读取 ================================
def slugify(name: str) -> str:
s = re.sub(r'[\\/:*?"<>|]+', "", name).strip()
s = re.sub(r"\s+", "_", s)
return s or "unnamed"
def read_people(path: Path) -> list[dict]:
if not path.exists():
return []
rows: list[list[str]] = []
if path.suffix.lower() in (".xlsx", ".xlsm"):
try:
import openpyxl
except ImportError:
sys.exit("读取 .xlsx 需要 openpyxl:pip install openpyxl(或把名单存成 .csv)")
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
for row in wb.active.iter_rows(values_only=True):
rows.append(["" if c is None else str(c).strip() for c in row])
elif path.suffix.lower() == ".csv":
with open(path, encoding="utf-8-sig", newline="") as f:
rows = [[c.strip() for c in r] for r in csv.reader(f)]
else:
sys.exit(f"不支持的名单格式:{path.suffix}(用 .xlsx 或 .csv)")
if rows and re.search(r"(姓名|名字|人物|name)", rows[0][0] if rows[0] else "", re.I):
rows = rows[1:]
people, seen = [], set()
for r in rows:
if not r or not r[0].strip():
continue
name = r[0].strip()
if name in seen:
continue
seen.add(name)
hints = " | ".join(x for x in r[1:] if x.strip())
people.append({"name": name, "hints": hints})
return people
# ================================ 时间窗 ================================
def in_window(now: datetime | None = None) -> bool:
now = now or datetime.now()
t = now.time()
return t >= WIN_START or t < WIN_END # 跨午夜
def seconds_until_window(now: datetime | None = None) -> int:
now = now or datetime.now()
if in_window(now):
return 0
target = now.replace(hour=WIN_START.hour, minute=WIN_START.minute, second=0, microsecond=0)
return max(1, int((target - now).total_seconds()))
def sleep_interruptible(total: int) -> None:
end = time.monotonic() + total
while time.monotonic() < end:
time.sleep(min(30, max(1, int(end - time.monotonic()))))
# ================================ 蒸馏单人 ================================
def distill_one(client: DeerFlowClient, person: dict, out_dir: Path,
manifest: dict, save_manifest, depth: str) -> None:
name, hints = person["name"], person["hints"]
slug = slugify(name)
md_path = out_dir / f"{slug}.md"
t0 = time.time()
rec = {"name": name, "slug": slug, "status": "running",
"started_at": _now(), "file": f"{slug}.md"}
manifest[name] = rec
save_manifest()
try:
tid = client.create_thread()
run_id = client.start_run(tid, build_prompt(name, hints, depth))
status = client.poll_run(tid, run_id)
rec.update(thread_id=tid, run_id=run_id, run_status=status)
text = client.fetch_result_text(tid, run_id) if status == "success" else None
if status == "success" and text:
md = _clean_markdown(text)
md_path.write_text(md, encoding="utf-8")
rec["status"] = "succeeded"
rec["chars"] = len(md)
print(f" ✅ 完成 → {md_path.name}({len(md)} 字,耗时 {int(time.time()-t0)}s)")
else:
rec["status"] = "failed"
rec["error"] = f"run={status}, text={'有' if text else '无'}"
if text:
(out_dir / f"{slug}.partial.md").write_text(text, encoding="utf-8")
print(f" ❌ 失败(run={status})")
except Exception as e:
rec["status"] = "failed"
rec["error"] = str(e)
print(f" ❌ 异常:{e}")
rec["finished_at"] = _now()
rec["seconds"] = int(time.time() - t0)
manifest[name] = rec
save_manifest()
# ================================ 主流程 ================================
def main():
ap = argparse.ArgumentParser(description="批量人物蒸馏 → llmwiki(定时窗 23:00~08:00)")
ap.add_argument("excel", nargs="?", default=str(PEOPLE_FILE_DEFAULT),
help="名单文件(.xlsx 或 .csv),默认同目录 people.xlsx")
ap.add_argument("--now", action="store_true", help="忽略时间窗,立刻跑完整批(测试用)")
ap.add_argument("--redo", action="store_true", help="忽略已完成,全部重做")
args = ap.parse_args()
excel = Path(args.excel)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
manifest_path = OUTPUT_DIR / "manifest.json"
manifest: dict = {}
if manifest_path.exists():
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception:
manifest = {}
# 校验名单存在
if not read_people(excel):
sys.exit(f"名单为空或不存在:{excel}\n请放 people.xlsx(第一列写人名)。")
# --redo:一次性清掉名单里这些人的旧记录,让他们重做一遍;
# 之后本次会话内跑成功的会正常计为「已完成」,循环才会往后推进
# (否则每轮 pending() 都把已完成的人当未完成,永远卡在第 1 个人)
if args.redo:
for p in read_people(excel):
manifest.pop(p["name"], None)
client = DeerFlowClient(BASE_URL)
if not client.login():
sys.exit("登录失败。请检查 BASE_URL 是否指向正在运行的服务、口令是否正确。")
client.ensure_skill_enabled(NUWA_SKILL)
client.ensure_skill_enabled(KB_SKILL)
def save_manifest():
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
_write_index(OUTPUT_DIR, read_people(excel), manifest)
def pending() -> list[dict]:
people = read_people(excel)
out = []
for p in people:
r = manifest.get(p["name"], {})
done = r.get("status") == "succeeded" and (OUTPUT_DIR / f"{slugify(p['name'])}.md").exists()
if not done:
out.append(p)
return out
print(f"启动 | 服务={BASE_URL} | 输出={OUTPUT_DIR}")
print(f"模式={'立即跑完' if args.now else '定时窗 23:00~08:00'} | 名单={excel}")
try:
while True:
todo = pending()
if not todo:
print(f"[{_now()}] 全部已完成,待命中(每小时复查名单)...")
if args.now:
break
sleep_interruptible(3600)
continue
if not args.now and not in_window():
wait = seconds_until_window()
nxt = datetime.now().replace(hour=WIN_START.hour, minute=0, second=0, microsecond=0)
print(f"[{_now()}] 不在执行窗口,剩 {len(todo)} 人待蒸;"
f"休眠到 {nxt.strftime('%H:%M')}(约 {wait//60} 分钟)...")
sleep_interruptible(wait)
continue
# 在窗口内(或 --now):处理一个人,然后回到循环重新判断窗口/名单
p = todo[0]
idx_total = len(read_people(excel))
done_n = sum(1 for v in manifest.values() if v.get("status") == "succeeded")
print(f"[{_now()}] ({done_n}/{idx_total} 已完成) 蒸馏:{p['name']}")
distill_one(client, p, OUTPUT_DIR, manifest, save_manifest, DEPTH)
except KeyboardInterrupt:
print("\n已手动停止。重跑脚本会从未完成处继续。")
ok = sum(1 for r in manifest.values() if r.get("status") == "succeeded")
fail = sum(1 for r in manifest.values() if r.get("status") == "failed")
print(f"\n结束:成功 {ok} / 失败 {fail}")
print(f"结果目录:{OUTPUT_DIR} | 清单:{manifest_path} | 索引:{OUTPUT_DIR/'index.md'}")
def _now() -> str:
return time.strftime("%Y-%m-%d %H:%M:%S")
def _write_index(out_dir: Path, people: list[dict], manifest: dict) -> None:
lines = ["# 蒸馏执行清单", "", f"更新时间:{_now()}", "",
"| # | 人物 | 状态 | 字数 | 耗时 | 文件 |",
"|---|------|------|------|------|------|"]
badge = {"succeeded": "✅完成", "failed": "❌失败", "running": "⏳进行中"}
for i, p in enumerate(people, 1):
r = manifest.get(p["name"], {})
st = badge.get(r.get("status", ""), "⌛排队")
f = r.get("file", "")
link = f"[{f}]({f})" if r.get("status") == "succeeded" and f else "-"
lines.append(f"| {i} | {p['name']} | {st} | {r.get('chars','-')} | {r.get('seconds','-')} | {link} |")
(out_dir / "index.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
姓名,领域(可选提示),资料链接或备注(可选)
查理·芒格,投资/多元思维,
理查德·费曼,物理/教学,
纳瓦尔·拉维坎特,创业/杠杆,https://nav.al/
1 姓名 领域(可选提示) 资料链接或备注(可选)
2 查理·芒格 投资/多元思维
3 理查德·费曼 物理/教学
4 纳瓦尔·拉维坎特 创业/杠杆 https://nav.al/
+8 -7
View File
@@ -27,15 +27,16 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000 VITE_AIPI_BACKENP_URL=http://localhost:8003
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL) # 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/笔记本前端地址(用于 auth/callback 等跳转) # 工作室/空间前端地址(用于 auth/callback 等跳转)
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn # VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000 VITE_AIPI_FRONTEND_URL=http://localhost:3000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=https://present.a.uat.4.cn/present/domain
# B 系统知识库 iframe 根地址
VITE_AIPI_KNOWES_URL=https://ch1.b.uat.4.cn/knowes
# MaxKey 鉴权(getTokenByOnlineTicket) # MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
+6 -4
View File
@@ -15,15 +15,17 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL) # 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转) # 工作室/空间前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=http://present.a.uat.4.cn/present/domain
# B 系统知识库 iframe 根地址
VITE_AIPI_KNOWES_URL=http://ch1.b.uat.4.cn/knowes
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# VITE_NOTE_API_BASE_URL=http://127.0.0.1:8000 # VITE_NOTE_API_BASE_URL=http://127.0.0.1:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
+2 -3
View File
@@ -14,8 +14,8 @@
# - 本机连 127.0.0.1:8001:复制 .env.development.local.example → .env.development.local # - 本机连 127.0.0.1:8001:复制 .env.development.local.example → .env.development.local
# - 内网打包部署:改 .env.network 后执行 pnpm build:network(或改 .env.production 后 pnpm build) # - 内网打包部署:改 .env.network 后执行 pnpm build:network(或改 .env.production 后 pnpm build)
# #
# 若浏览器直连后端报 CORS,请在 Gateway 配置 CORS_ORIGINS,包含前端源站,例如: # 若浏览器直连后端报 CORS,请在 Gateway 配置 GATEWAY_CORS_ORIGINS,包含前端源站;无界嵌入时还要加宿主源站,例如:
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174 # GATEWAY_CORS_ORIGINS=http://127.0.0.1:5174,http://localhost:5174,http://127.0.0.1:5183,http://localhost:5183
# --- 开发环境(见 .env.development)--- # --- 开发环境(见 .env.development)---
# VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow # VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
@@ -23,7 +23,6 @@
# VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000 # VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn # VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn # VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
# VITE_STUDIO_ENABLED=true # 仅控制“工作室”入口显示,列表内容仍依赖 notebook 接口数据
# --- 生产打包(见 .env.production)--- # --- 生产打包(见 .env.production)---
# 部署前修改 .env.production 中的地址,再执行 pnpm build # 部署前修改 .env.production 中的地址,再执行 pnpm build
+6 -4
View File
@@ -28,14 +28,16 @@ VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000 VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL) # 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/笔记本前端地址(用于 auth/callback 等跳转) # 工作室/空间前端地址(用于 auth/callback 等跳转)
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn # VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000 VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000 VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=http://present.a.uat.4.cn/present/domain
# B 系统知识库 iframe 根地址
VITE_AIPI_KNOWES_URL=http://ch1.b.uat.4.cn/knowes
# MaxKey 鉴权(getTokenByOnlineTicket) # MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
+10 -7
View File
@@ -24,18 +24,21 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL) # 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_NOTEBOOK_API_BASE_URL=https://knowledgetoolserver.b.uat.4.cn # 我的空间前端
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据 VITE_AIPI_FRONTEND_URL= xxx
VITE_STUDIO_ENABLED=true
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolweb.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=https://present.a.uat.4.cn/present/domain
# B 系统知识库 iframe 根地址
VITE_AIPI_KNOWES_URL=https://ch1.b.uat.4.cn/knowes
# MaxKey 鉴权(getTokenByOnlineTicket) # MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 工作室/空间前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolweb.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址 # 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL= # VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -5,4 +5,3 @@
# VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api # VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# VITE_AIPI_BACKENP_URL=https://your-notebook-backend-host # VITE_AIPI_BACKENP_URL=https://your-notebook-backend-host
# VITE_AIPI_FRONTEND_URL=https://your-notebook-frontend-host # VITE_AIPI_FRONTEND_URL=https://your-notebook-frontend-host
# VITE_STUDIO_ENABLED=true
+8 -2
View File
@@ -24,7 +24,7 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.sit.4.cn/knowes/api/iframeKnowledgeUrl VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.sit.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.sit.4.cn/likun_b1_mope VITE_AIPI_MOPECHAT_URL=https://ch1.b.sit.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL) # 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.sit.4.cn VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.sit.4.cn
VITE_NOTEBOOK_API_BASE_URL=https://knowledgetoolserver.b.sit.4.cn VITE_NOTEBOOK_API_BASE_URL=https://knowledgetoolserver.b.sit.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据 # 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
@@ -32,10 +32,16 @@ VITE_STUDIO_ENABLED=true
# 工作室/笔记本前端地址(用于 auth/callback 等跳转) # 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.sit.4.cn/consumer VITE_AIPI_CONSUMER_URL=https://ch1.b.sit.4.cn/consumer
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
VITE_AIPI_PRESENT_URL=http://present.a.sit.4.cn/present/domain
# B 系统知识库 iframe 根地址
VITE_AIPI_KNOWES_URL=https://ch1.b.sit.4.cn/knowes
# MaxKey 鉴权(getTokenByOnlineTicket) # MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.sit.4.cn VITE_AIPI_PGLINKTO_URL=https://pg.b.sit.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.sit.4.cn VITE_AIPI_XKLINKTO_URL=https://kz.b.sit.4.cn
# 工作室/空间前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn
# 可选:Canvas 独立 LangGraph 地址 # 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL= # VITE_CANVAS_LANGGRAPH_BASE_URL=
+1 -1
View File
@@ -74,7 +74,7 @@
"hast": "^1.0.0", "hast": "^1.0.0",
"katex": "^0.16.28", "katex": "^0.16.28",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"mermaid": "11.14.0", "mermaid": "^11.15.0",
"motion": "^12.26.2", "motion": "^12.26.2",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
+3
View File
@@ -22,6 +22,7 @@ import PublicSharePage from "./pages/PublicSharePage";
import PublicLeaderboardPage from "./pages/PublicLeaderboardPage"; import PublicLeaderboardPage from "./pages/PublicLeaderboardPage";
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage"; import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
import EmbedChatPage from "./pages/EmbedChatPage"; import EmbedChatPage from "./pages/EmbedChatPage";
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
import { ChatRuntime } from "./pages/WorkspaceRoutes"; import { ChatRuntime } from "./pages/WorkspaceRoutes";
import { NotFoundPage } from "./pages/NotFoundPage"; import { NotFoundPage } from "./pages/NotFoundPage";
@@ -96,6 +97,8 @@ export function App() {
</ChatRuntime> </ChatRuntime>
} }
/> />
{/* Embedded roundtable planning — body only, no sidebars; username login (not wujie ticket) */}
<Route path="/embed/roundtable" element={<EmbedRoundtablePage />} />
<Route path="/page/*" element={<PageRoutes />} /> <Route path="/page/*" element={<PageRoutes />} />
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Routes> </Routes>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="62" viewBox="0 0 1920 62"><defs><style>.a{fill:url(#a);}.b{fill:rgba(0,98,217,0.1);}.c{fill:#fff;}.d{fill:rgba(0,98,217,0.2);}.e{filter:url(#h);}.f{filter:url(#e);}.g{filter:url(#b);}</style><linearGradient id="a" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#0b3776"/><stop offset="1" stop-color="#0c1935"/></linearGradient><filter id="b"><feOffset dy="-3" input="SourceAlpha"/><feGaussianBlur result="c"/><feFlood flood-color="#0062d9" flood-opacity="0.102" result="d"/><feComposite operator="out" in="SourceGraphic" in2="c"/><feComposite operator="in" in="d"/><feComposite operator="in" in2="SourceGraphic"/></filter><filter id="e"><feOffset dy="-3" input="SourceAlpha"/><feGaussianBlur result="f"/><feFlood flood-color="#0062d9" flood-opacity="0.102" result="g"/><feComposite operator="out" in="SourceGraphic" in2="f"/><feComposite operator="in" in="g"/><feComposite operator="in" in2="SourceGraphic"/></filter><filter id="h"><feOffset dy="-3" input="SourceAlpha"/><feGaussianBlur result="i"/><feFlood flood-color="#0062d9" flood-opacity="0.2" result="j"/><feComposite operator="out" in="SourceGraphic" in2="i"/><feComposite operator="in" in="j"/><feComposite operator="in" in2="SourceGraphic"/></filter></defs><g transform="translate(-2009 -3361)"><rect class="a" width="1920" height="62" transform="translate(2009 3361)"/><g data-type="innerShadowGroup"><path class="b" d="M0,0H318L268,62H0Z" transform="translate(2009 3361)"/><g class="g" transform="matrix(1, 0, 0, 1, 2009, 3361)"><path class="c" d="M0,0H318L268,62H0Z"/></g></g><g data-type="innerShadowGroup"><path class="b" d="M0,0H310L260,62H0Z" transform="translate(2009 3361)"/><g class="f" transform="matrix(1, 0, 0, 1, 2009, 3361)"><path class="c" d="M0,0H310L260,62H0Z"/></g></g><g data-type="innerShadowGroup"><path class="d" d="M0,0H302L252,62H0Z" transform="translate(2009 3361)"/><g class="e" transform="matrix(1, 0, 0, 1, 2009, 3361)"><path class="c" d="M0,0H302L252,62H0Z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1,898 @@
"use client";
import {
createContext,
type FC,
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import {
Search,
Loader2,
Plus,
Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth";
import Empty from "./empty";
import { NetworkActor } from "./network-actor";
// ── 适配层 ─────────────────────────────────────────────────
function authenticatedFetch(url: string, init?: RequestInit) {
const token = getStoredAuth()?.access_token;
return fetch(url, {
...init,
headers: {
...(init?.headers ?? {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
}
function getYUserId(): string {
if (typeof window === "undefined") return "1157761008018653184";
try {
const raw = localStorage.getItem("deerflow.external_user_info");
if (raw) {
const obj = JSON.parse(raw) as { yUserId?: string };
if (obj?.yUserId) return obj.yUserId;
}
} catch { }
return "1157761008018653184";
}
const searchTypes = [
{ value: "human", label: "人物基因库" },
{ value: "organization", label: "组织基因库" },
{ value: "community", label: "群体基因库" },
{ value: "issue", label: "议题基因库" },
{ value: "event", label: "事件基因库" },
{ value: "threat", label: "风险与机会" },
] as const;
// ── Context ────────────────────────────────────────────────
interface DocumentUploadDialogContextType {
openDialog: (initialSearchText?: string) => void;
closeDialog: (
mode?: "search",
data?: { query: string; mode: string }
) => Promise<void>;
// 上传成功回调,返回上传的来源信息
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
}
// ── 任务轮询类型 ─────────────────────────────────────────────
interface TaskSource {
task_id: string;
title: string;
status: 'processing' | 'success' | 'error';
source_type: string;
}
interface UploadResponse {
status: string;
uploaded: number;
skipped: number;
sources: TaskSource[];
}
interface listDataItem {
id?: string | number;
name?: string;
redisKey?: string;
abstractz?: string;
checked: boolean;
title: string;
content: string;
url: string;
// 上传文件轮询相关字段
task_id?: string;
processing_status?: 'processing' | 'success' | 'error';
is_uploaded_file?: boolean;
country?: string;
dateOfBirth?: string;
occupationData?: string;
party?: string;
sexOrGender?: string;
imagePath?: string;
mongoId?: string;
countryData?: string;
countrySingle?: string;
subtype?: string;
text?: string;
twoLevel?: string;
account_ids?: unknown[];
community_description?: string;
community_name?: string;
community_type?: string;
create_time?: string;
creator?: string;
groups?: string;
labels?: string;
nations?: string;
params?: string;
platforms?: string;
real_total?: number;
total?: number;
update_time?: string;
virtual_total?: number;
direction?: string;
hot_num?: number;
issue_hot?: number;
issue_name?: string;
issue_type?: string;
news_num?: number;
post_num?: number;
topic?: string;
abstract?: string;
allwords?: string[];
category?: string;
controversy_num?: number;
date?: string;
discriminant_reason?: string;
discriminant_result?: string;
doc_author?: string;
doc_ids?: string[];
emotion_info?: string;
first_publish_time?: string;
grade?: string;
human_id_list?: string;
human_list?: string[];
if_artificial?: number;
if_follow?: number;
if_shehua?: number;
if_show?: number;
image_path?: string;
import_num?: number;
insert_time?: string;
jw?: string;
keywords?: string[];
location?: string[];
location_list?: unknown[];
location_type?: number;
longitude?: number;
media_board?: string;
media_type?: string;
nowords?: unknown[];
onewords?: unknown[];
organization_id_list?: string;
organization_list?: unknown[];
raw_id?: string;
report_num?: number;
site_name?: string;
source_type?: string;
sub_category?: string;
subject?: string;
super_category?: string;
surge_num?: number;
tone?: string;
topic_id?: string;
total_num?: number;
type?: string;
userid?: string;
wx_sub_score?: string;
wx_total_score?: string;
}
const DocumentUploadDialogContext =
createContext<DocumentUploadDialogContextType | null>(null);
export const useDocumentUploadDialog = () => {
const context = useContext(DocumentUploadDialogContext);
if (!context) {
throw new Error(
"useDocumentUploadDialog must be used within DocumentUploadDialogProvider"
);
}
return context;
};
// ── Provider ───────────────────────────────────────────────
export const DocumentUploadDialogProvider: FC<{
children: ReactNode;
onClose?: (
mode?: "search",
data?: { query: string; mode: string }
) => void | Promise<void>;
searchSpace: { id: number | string;[key: string]: unknown };
onUploadSuccess?: () => void;
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
}> = ({ children, onClose, searchSpace, onUploadSuccess, onUploadComplete }) => {
const [isOpen, setIsOpen] = useState(false);
const [initialSearchText, setInitialSearchText] = useState("");
const isClosingRef = useRef(false);
const openDialog = useCallback((initialSearchText?: string) => {
if (isClosingRef.current) return;
setInitialSearchText(initialSearchText || "");
setIsOpen(true);
}, []);
const closeDialog = useCallback(
async (mode?: "search", data?: { query: string; mode: string }) => {
isClosingRef.current = true;
if (onClose) {
try {
await onClose(mode, data);
} catch (error) {
console.error("Error in Provider onClose:", error);
}
}
setIsOpen(false);
setTimeout(() => {
isClosingRef.current = false;
}, 300);
},
[onClose]
);
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
if (!isClosingRef.current) {
void closeDialog("search", { query: "", mode: "" });
void closeDialog();
}
} else {
if (!isClosingRef.current) {
setIsOpen(true);
}
}
},
[closeDialog]
);
return (
<DocumentUploadDialogContext.Provider value={{ openDialog, closeDialog, onUploadComplete }}>
{children}
<DocumentUploadPopupContent
isOpen={isOpen}
onOpenChange={handleOpenChange}
searchSpace={searchSpace}
onUploadSuccess={onUploadSuccess}
closeDialog={closeDialog}
initialSearchText={initialSearchText}
/>
</DocumentUploadDialogContext.Provider>
);
};
// ── Popup content ──────────────────────────────────────────
const UPLOAD_ACCEPT =
".pdf,.docx,.xlsx,.pptx,.html,.csv,.md,.txt,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.mp3,.mp4,.wav,.webm";
const DocumentUploadPopupContent: FC<{
isOpen: boolean;
onOpenChange: (open: boolean) => void;
searchSpace: { id: number | string;[key: string]: unknown };
onUploadSuccess?: () => void;
closeDialog: (
mode?: "search",
data?: { query: string; mode: string }
) => Promise<void>;
initialSearchText?: string;
}> = ({ isOpen, onOpenChange, searchSpace, onUploadSuccess, initialSearchText }) => {
const { onUploadComplete } = useContext(DocumentUploadDialogContext) || {};
const searchSpaceId = String(searchSpace?.id);
// 从环境变量获取配置
const backendUrl = import.meta.env.VITE_AIPI_BACKENP_URL || "http://localhost:8001";
const consumerUrl = import.meta.env.VITE_AIPI_CONSUMER_URL || "http://ch1.b.uat.4.cn/consumer";
const presentUrl = import.meta.env.VITE_AIPI_PRESENT_URL || "http://present.a.uat.4.cn/present/domain";
const knowesUrl = import.meta.env.VITE_AIPI_KNOWES_URL || "http://ch1.b.uat.4.cn/knowes";
const knowledgeUrl = import.meta.env.VITE_AIPI_KNOWLEDGE_URL || "http://chlib.b.uat.4.cn/knowlibrary";
const [searchText, setSearchText] = useState("");
const [searchValue, setSearchValue] = useState("");
const [activeTab, setActiveTab] = useState<"search" | "Y" | "upload" | "A" | "actor">("search");
const [isSearching, setIsSearching] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [listData, setListData] = useState<listDataItem[]>([]);
const [searchType, setSearchType] = useState("human");
const [selectedSearchSources, setSelectedSearchSources] = useState<listDataItem[]>([]);
// upload tab state
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
useEffect(() => {
if (isOpen && initialSearchText) {
setSearchText(initialSearchText);
setSearchValue(initialSearchText);
}
}, [isOpen, initialSearchText]);
if (!searchSpaceId) return null;
const handleSuccess = () => {
onUploadSuccess?.();
onOpenChange(false);
};
const searchActiveChange = async (type: "search" | "Y" | "upload" | "A" | "actor") => {
setActiveTab(type);
if (type === "A") {
await searchAChange(searchType);
}
};
const handleFileUpload = async (files: File[]) => {
if (!files.length) return;
setUploading(true);
try {
const token = getStoredAuth()?.access_token ?? "";
const formData = new FormData();
formData.append("search_space_id", searchSpaceId);
for (const file of files) {
formData.append("files", file);
}
const res = await fetch(`${backendUrl}/api/v1/documents/fileupload`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (res.ok) {
const data = await res.json() as UploadResponse;
// 调用上传完成回调,传递来源信息
if (data.sources && data.sources.length > 0) {
onUploadComplete?.(data.sources.map(s => ({
id: undefined, // 后端返回的数据中暂时没有 id
title: s.title,
task_id: s.task_id,
status: s.status,
})));
}
toast.success(`上传成功 ${data.uploaded || files.length} 个文件`);
// 只关闭上传弹窗
onOpenChange(false);
} else {
toast.error("上传失败,请重试");
}
} catch {
toast.error("上传失败,请重试");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const handleImport = async () => {
if (selectedSearchSources.length === 0) {
toast.warning("没有可导入的来源");
return;
}
setIsImporting(true);
const dataList = selectedSearchSources.map((item) => ({
title: item.title,
content: item.content,
url: item.url,
}));
try {
const response = await authenticatedFetch(`${backendUrl}/api/v1/source/import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
search_space_id: Number(searchSpaceId),
source_list: dataList,
}),
});
if (response.ok) {
const data = await response.json() as { imported_count?: number };
toast.success(`成功导入 ${data.imported_count || dataList.length} 个来源`);
setListData([]);
handleBatchDelete();
} else {
const errorData = await response.json() as { detail?: string };
toast.error(errorData.detail || "导入失败,请重试");
}
} catch {
toast.error("导入失败,请重试");
} finally {
setIsImporting(false);
}
};
const listDataCheckedChange = (index: number, checked: boolean) => {
setListData((prevSources) => {
const newSources = prevSources.map((item, aIndex) =>
aIndex === index ? { ...item, checked } : item
);
setSelectedSearchSources(newSources.filter((item) => item.checked));
return newSources;
});
};
const handleBatchDelete = () => {
setListData((prev) => prev.map((item) => ({ ...item, checked: false })));
setSelectedSearchSources([]);
};
function urlSafeBtoa(str: string): string {
const encoder = new TextEncoder();
const uint8Array = encoder.encode(str);
const binaryString = Array.prototype.map
.call(uint8Array, (byte: number) => String.fromCharCode(byte))
.join("");
return btoa(binaryString).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}
const linkToA = (url: string) => { window.open(url); };
const queryTopicTargetHumanList = async () => {
setIsSearching(true);
const response = await authenticatedFetch(`${consumerUrl}/topicQuery/queryTopicTargetHumanList`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
body: JSON.stringify({ name: searchText, page: 1, size: 50 }),
});
if (response.ok) {
const data = await response.json() as { data: listDataItem[] };
setIsSearching(false);
const result = data.data;
result.forEach((item) => {
item.checked = false;
item.title = item.name || "";
item.content = item.abstractz || "";
const redirect = urlSafeBtoa(`/gene/#/human/basic?mid=${item.id}`);
item.url = `${presentUrl}?inredirect=${redirect}`;
});
setListData(result);
}
setIsSearching(false);
};
const organizationSearch = async () => {
setIsSearching(true);
const response = await authenticatedFetch(`${consumerUrl}/topicQuery/queryCommunity`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
body: JSON.stringify({ name: searchText, page: 1, size: 50 }),
});
if (response.ok) {
const data = await response.json() as { data: listDataItem[] };
setIsSearching(false);
const result = data.data;
result.forEach((item) => {
item.checked = false;
item.title = item.name || "";
item.content = item.abstractz || "";
const redirect = urlSafeBtoa(`/gene/#/organization/basicAttr?mid=${item.mongoId}`);
item.url = `${presentUrl}?inredirect=${redirect}`;
});
setListData(result);
}
setIsSearching(false);
};
const queryCommunityList = async () => {
setIsSearching(true);
const params = { keyword: searchText, page: 1, size: 50 };
const queryString = Object.entries(params).map(([k, v]) => `${k}=${v}`).join("&");
const response = await authenticatedFetch(`${consumerUrl}/topicQuery/queryCommunityList?${queryString}`, {
method: "GET",
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
});
if (response.ok) {
const data = await response.json() as { data: listDataItem[] };
setIsSearching(false);
const result = data.data;
result.forEach((item) => {
item.checked = false;
item.title = item.community_name || "";
item.content = item.community_description || "";
const redirect = urlSafeBtoa(`/gene/#/commune/basicCommune?cid=${item.id}`);
item.url = `${presentUrl}?inredirect=${redirect}`;
});
setListData(result);
}
setIsSearching(false);
};
const issueSearch = async () => {
setIsSearching(true);
const response = await authenticatedFetch(`${consumerUrl}/issue/list?keyword=${searchText}`, {
method: "GET",
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
});
if (response.ok) {
const data = await response.json() as { data: listDataItem[] };
setIsSearching(false);
const result = data.data;
result.forEach((item) => {
item.checked = false;
item.title = item.issue_name || "";
item.content = item.issue_type || "";
const redirect = encodeURIComponent(`/gene/#/issue/zzyyIssue?mid=${item.id}&event_type=issue`);
item.url = `${presentUrl}?inredirect=${redirect}`;
});
setListData(result);
}
setIsSearching(false);
};
const eventSearch = async () => {
setIsSearching(true);
const response = await authenticatedFetch(`${consumerUrl}/event/queryEvents`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
body: JSON.stringify({ word: searchText, page: 1, size: 50, type: "hot", timeRange: "" }),
});
if (response.ok) {
const data = await response.json() as { data: listDataItem[] };
setIsSearching(false);
const result = data.data;
result.forEach((item) => {
item.checked = false;
const redirect = urlSafeBtoa(`/gene/#/event/basicEvent?event_type=hot&mid=${item.id}`);
item.url = `${presentUrl}?inredirect=${redirect}`;
});
setListData(result);
}
setIsSearching(false);
};
const topicQuery = async () => {
setIsSearching(true);
let result: listDataItem[] = [];
for (const discriminant of ["威胁", "ZJ机"]) {
const queryString = `keyword=${searchText}&discriminant=${discriminant}`;
const response = await authenticatedFetch(`${consumerUrl}/topicQuery/queryEventFromA?${queryString}`, {
method: "GET",
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
});
if (response.ok) {
const data = await response.json() as { data: listDataItem[] };
const results = data.data;
results.forEach((item) => {
item.checked = false;
const redirect = urlSafeBtoa(`/gene/#/event/basicEvent?event_type=hot&mid=${item.id}`);
item.url = `${presentUrl}?inredirect=${redirect}`;
});
result = [...result, ...results];
}
}
setIsSearching(false);
setListData(result);
};
const searchAChange = async (type: string) => {
if (type === "human") await queryTopicTargetHumanList();
else if (type === "organization") await organizationSearch();
else if (type === "community") await queryCommunityList();
else if (type === "issue") await issueSearch();
else if (type === "event") await eventSearch();
else if (type === "threat") await topicQuery();
};
const searchTypeChange = (value: string) => {
setSearchType(value);
setListData([]);
void searchAChange(value);
};
const searchDataChange = () => {
setIsSearching(true);
setSearchValue(searchText);
setTimeout(() => setIsSearching(false), 500);
if (activeTab === "A") void searchAChange(searchType);
};
const yUserId = getYUserId();
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent
className="w-[90vw] max-w-[90vw] sm:max-w-[90vw] h-[90vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 [&>button]:top-4 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogTitle className="sr-only">添加来源</DialogTitle>
<DialogDescription className="sr-only">从外部系统搜索并导入来源</DialogDescription>
<div className="flex-1 min-h-0 relative overflow-hidden">
<div className="h-full overflow-y-auto">
<div className="px-3 sm:px-12 pt-12 sm:pt-24 pb-6 sm:pb-16">
{/* 搜索框 */}
<div className="relative rounded-lg border border-neutral-200 bg-white mb-[10px] transition-colors hover:border-neutral-300">
<Textarea
placeholder="搜索资料..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
searchDataChange();
}
}}
className="max-h-[160px] resize-none border-0 bg-transparent px-10 py-3 pr-20 text-sm text-neutral-700 placeholder:text-neutral-400 focus-visible:ring-0 focus-visible:outline-none"
rows={1}
/>
<div className="absolute left-3 top-3 text-neutral-400 pointer-events-none">
<Search className="h-4 w-4" strokeWidth={1.5} />
</div>
<Button
size="sm"
onClick={searchDataChange}
disabled={isSearching || !searchText.trim()}
className="absolute right-2 top-2 h-7 rounded-md bg-neutral-900 px-3 text-xs font-medium text-white transition-colors hover:bg-neutral-800 disabled:pointer-events-none disabled:opacity-40"
>
{isSearching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <span>搜索</span>}
</Button>
</div>
{/* Tab 标签页 */}
<div className="border-b border-gray-200 rounded-t-xl px-4 bg-gray-50">
<div className="flex flex-nowrap overflow-x-auto">
{(
[
{ key: "search" as const, label: "B系统(知识库)" },
{ key: "A" as const, label: "A系统(基因库)" },
{ key: "Y" as const, label: "Y系统(信息库)" },
{ key: "actor" as const, label: "B系统(XWT检索)" },
{ key: "upload" as const, label: "上传" },
] as const
).map(({ key, label }) => (
<button
key={key}
onClick={() => void searchActiveChange(key)}
className={`relative pb-3 px-6 pt-4 text-sm font-medium transition-colors ${activeTab === key ? "text-gray-900" : "text-gray-400 hover:text-gray-600"
}`}
>
{label}
{activeTab === key && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-900 rounded-full" />
)}
</button>
))}
</div>
</div>
<div className="my-5">
{/* B系统知识库 */}
{activeTab === "search" && !isSearching && (
<iframe
className="w-full h-[calc(100vh-200px)]"
src={`${knowesUrl}/fromB1?yUserId=${yUserId}&processType=searchMode&bookChatId=${searchSpaceId}&inputValue=${searchValue}`}
/>
)}
{/* Y系统信息库 */}
{activeTab === "Y" && !isSearching && (
<iframe
className="w-full h-[calc(100vh-200px)]"
src={`${knowledgeUrl}/#/sousou?chatType=new-chat&bookChatId=${searchSpaceId}&inputValue=${searchText}`}
/>
)}
{/* A系统基因库 */}
{activeTab === "A" && (
<div className="bg-white rounded-lg">
<div className="custom-radio-group pt-[10px] ml-[10px]">
{searchTypes.map((option) => (
<div
key={option.value}
className={`custom-radio-btn ${searchType === option.value ? "active" : ""}`}
onClick={() => searchTypeChange(option.value)}
>
{option.label}
</div>
))}
</div>
{selectedSearchSources.length > 0 && (
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
<span className="text-sm text-gray-600">已选中 {selectedSearchSources.length} 项</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={handleBatchDelete} className="h-8 px-3 text-sm">
<Trash2 className="h-3.5 w-3.5 mr-1" />
删除选中
</Button>
<Button
size="sm"
onClick={() => void handleImport()}
disabled={isImporting}
className="h-8 px-3 text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200"
>
{isImporting ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Plus className="h-3.5 w-3.5 mr-1" />}
导入选中
</Button>
</div>
</div>
)}
{listData.length === 0 && <Empty />}
{searchType === "human" && (
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
{listData.map((item, index) => (
<div key={index} className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
{/* 上传文件显示 loading 状态 */}
{item.is_uploaded_file && item.processing_status === 'processing' && (
<div className="absolute inset-0 bg-white/80 flex items-center justify-center z-10">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
)}
{/* 上传文件完成状态标记 */}
{item.is_uploaded_file && item.processing_status === 'error' && (
<div className="absolute top-1 left-1">
<span className="text-xs text-red-500">处理失败</span>
</div>
)}
{item.is_uploaded_file && item.processing_status === 'success' && (
<div className="absolute top-1 left-1">
<span className="text-xs text-green-500">✓ 已完成</span>
</div>
)}
<div className="w-[50px] mr-[10px]">
<img src={item.imagePath || ""} className="w-[50px] h-[50px]" alt="" />
</div>
<div className="flex-1 min-w-0">
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.name}</p>
<p className="truncate text-[#909090]">职业:{item.occupationData}</p>
<p className="truncate text-[#909090]">属地:{item.country}</p>
</div>
<div className="absolute top-[5px] right-[10px]">
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
</div>
</div>
))}
</div>
)}
{searchType === "organization" && (
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
{listData.map((item, index) => (
<div key={index} className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
<div className="w-[50px] mr-[10px]">
<img src={item.imagePath || ""} className="w-[50px] h-[50px]" alt="" />
</div>
<div className="flex-1 min-w-0">
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.name}</p>
<p className="truncate text-[#909090]">组织类别:{item.subtype}</p>
<p className="truncate text-[#909090]">简介:{item.text}</p>
</div>
<div className="absolute top-[5px] right-[10px]">
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
</div>
</div>
))}
</div>
)}
{searchType === "community" && (
<div className="bg-white p-[10px] grid grid-cols-4 gap-[10px]">
{listData.map((item, index) => (
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
<div>
<div className="truncate mb-[5px] hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.title}</div>
<div className="flex items-center text-[#909090] mb-[5px]">
{item.nations && <span className="truncate max-w-[200px] inline-block border border-[#cf1322] rounded px-1 text-[#cf1322]">{item.nations}</span>}
{item.labels && <span className="inline-block border border-[#bbb] rounded px-1 ml-[10px]">{item.labels}</span>}
</div>
<div className="flex justify-between mb-[5px]">
<span className="text-[#909090]">群体规模:<span className="text-[#cf1322]">{item.total}</span></span>
<span className="text-[#909090]">对应实体数:<span className="text-[#cf1322]">{item.real_total}</span></span>
</div>
<div><span className="text-[#909090]">涉及平台:<span className="text-[#222222]">{item.platforms}</span></span></div>
</div>
<div className="absolute top-[5px] right-[10px]">
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
</div>
</div>
))}
</div>
)}
{searchType === "issue" && (
<div className="bg-white p-[10px] grid grid-cols-4 gap-[10px]">
{listData.map((item, index) => (
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
<div>
<div className="truncate mb-[5px]">{item.title}</div>
<div className="flex justify-between mb-[5px]">
<span className="text-[#909090]">方向:<span className="text-[#222222]">{item.direction}</span></span>
<span className="text-[#909090]">类别:<span className="text-[#222222]">{item.issue_type}</span></span>
</div>
</div>
<div className="absolute top-[5px] right-[10px]">
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
</div>
</div>
))}
</div>
)}
{searchType === "event" && (
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
{listData.map((item, index) => (
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
<div className="flex-1 min-w-0">
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.title}</p>
<p className="truncate text-[#909090]">首发新闻:{item.site_name}</p>
<p className="truncate text-[#909090]">事件时间:{item.first_publish_time}</p>
<p className="truncate text-[#909090]">关键词:{item.keywords?.join("、")}</p>
</div>
<div className="absolute top-[5px] right-[10px]">
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
</div>
</div>
))}
</div>
)}
{searchType === "threat" && (
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
{listData.map((item, index) => (
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
<div className="flex-1 min-w-0 pr-[25px]">
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.title}</p>
<p className="truncate text-[#909090]">发布平台:{item.site_name || "暂无数据"}</p>
<p className="truncate text-[#909090]">事件时间:{item.first_publish_time || "暂无数据"}</p>
<p className="truncate text-[#909090]">关键词:{item.keywords?.join("、") || "暂无数据"}</p>
</div>
<div className="absolute top-[5px] right-[10px]">
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
</div>
</div>
))}
</div>
)}
</div>
)}
{/* B系统XWT检索 */}
{activeTab === "actor" && !isSearching && (
<NetworkActor
inputValue={searchText}
searchSpaceId={searchSpaceId}
onSuccess={handleSuccess}
/>
)}
{/* 上传 */}
{activeTab === "upload" && (
<div className="flex flex-col items-center justify-center gap-4 py-10">
<input
ref={fileInputRef}
type="file"
multiple
accept={UPLOAD_ACCEPT}
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
if (files.length) void handleFileUpload(files);
}}
/>
<Button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="px-6"
>
{uploading ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />上传中...</>
) : (
<><Plus className="h-4 w-4 mr-2" />选择文件上传</>
)}
</Button>
<p className="text-xs text-muted-foreground">支持 PDF、Word、Excel、PPT、HTML、CSV、Markdown、TXT 及常见图片格式</p>
</div>
)}
</div>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 h-2 sm:h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,20 @@
import React from 'react';
const Empty = ({
description = '暂无数据',
size = 'default'
}: {
description?: string;
size?: 'default' | 'small';
}) => {
return (
<div className={`empty-container ${size === 'small' ? 'empty-small' : ''}`}>
<div className="empty-image">
<img src="/icon/empty-list.png" alt="暂无数据" />
</div>
{description && <p className="empty-description">{description}</p>}
</div>
);
};
export default Empty;
@@ -0,0 +1,630 @@
"use client";
import {
useEffect,
useRef,
useState,
} from "react";
import { Textarea } from "@/components/ui/textarea";
import { Loader2, Plus, Search, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth";
function authenticatedFetch(url: string, init?: RequestInit) {
const token = getStoredAuth()?.access_token;
return fetch(url, {
...init,
headers: {
...(init?.headers ?? {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
}
interface NetworkActorProps {
inputValue: string;
searchSpaceId: string;
onSuccess?: () => void;
}
interface platformItem {
title: string;
icon: string;
checked: boolean;
}
interface languageItem {
title: string;
checked: boolean;
}
interface PoliticsDirItem {
地区: string;
语言: string;
年龄段: string;
性别: string;
教育程度: string;
关注话题: { 话题: string; 倾向: string; 佐证: string; 风格: string }[];
类型: string;
对HZZLC: string;
对MZZLC: string;
对TZZLC: string[];
账号描述: string;
发文频率: string;
活跃时间: string;
}
interface 用户画像 {
地区: string;
教育程度: string;
关注话题: { 倾向: string; 话题: string; 佐证: string; 风格: string }[];
语言: string;
年龄段: string;
账号描述: string;
对TZZLC: string[];
对HZZLC: string;
对MZZLC: string[];
性别: string;
类型: string;
发文频率: string;
活跃时间: string;
}
interface hx_info {
语言: string;
用户画像: 用户画像;
}
interface UserDataItem {
comment_count: number;
country: string | null;
agent_task_id: string | null;
education: string | null;
groupId: string | null;
forward_count: number;
political_stand: string;
language: string;
type: string;
concern: string | null;
profile_image: string | null;
concern_topic: string | null;
screen_name: string;
school: string | null;
is_alive: number;
createdTime: string;
data_provider: string | null;
id: string;
text: string | null;
group: string | null;
read_count: number;
group_name: string;
area: string | null;
image: string | null;
updatedTime: string;
like_count: number;
m_politics_dir: string;
camp: string | null;
tags: string[];
army: string | null;
is_sync_a: number;
tweets_count: number;
groupName: string | null;
history_key: string;
user_id: string;
real_id: string | null;
h_politics_dir: string | null;
domain: string | null;
name: string | null;
position: string | null;
job: string | null;
age_round: string | null;
birthday: string | null;
is_virtual: number;
isMedia: number;
hx_info: hx_info;
gender: string | null;
agentInfo: PoliticsDirItem;
profile_url: string | null;
tagId: string[];
user_name: string;
a_update_time: string | null;
values: string | null;
follows_count: number;
title: string | null;
is_protected: number;
platform: string;
importType: string;
update_time: string;
key: string;
introduction: string | null;
brief: string | null;
creator: string;
history_title: string;
tweets_ratio: string | null;
is_verified: number;
t_politics_dir: string[];
eventMap: string | null;
fans_count: number;
a_create_time: string | null;
hobbies: string[];
location: string | null;
average_count: number;
ratio: string | null;
classification?: string | null;
groupDiscussTags?: string[];
checked?: boolean;
content?: string;
url?: string;
}
export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkActorProps) {
const [platforms, setPlatforms] = useState<platformItem[]>([
{ title: 'X', icon: 'x.png', checked: false },
{ title: 'Facebook', icon: 'facebook.png', checked: false },
{ title: 'Reddit', icon: 'reddit.png', checked: false },
{ title: 'PTT', icon: 'ptt.png', checked: false },
{ title: 'YouTube', icon: 'youtube.png', checked: false },
{ title: 'Dcard', icon: 'dcard.png', checked: false },
{ title: 'Mobile01', icon: 'mobile01.png', checked: false },
{ title: 'Threads', icon: 'threads.png', checked: false },
{ title: 'Instagram', icon: 'ins.png', checked: false },
]);
const platformList = useRef<string[]>([]);
const [languages, setLanguages] = useState<languageItem[]>([
{ title: '繁体中文', checked: false },
{ title: '简体中文', checked: false },
{ title: '英文', checked: false },
{ title: '日文', checked: false },
{ title: '其他', checked: false },
]);
const languageList = useRef<string[]>([]);
const [allPlatforms, setAllPlatforms] = useState<boolean>(true);
const [allLanguage, setAllLanguage] = useState<boolean>(true);
const [sortType, setSortType] = useState<string>('fans_count');
const sortTypeValue = useRef('fans_count');
const [fansSort, setFansSort] = useState<string>('desc');
const sortValue = useRef('desc');
const [relevanceSort, setRelevanceSort] = useState<string>('desc');
const [userList, setUserList] = useState<UserDataItem[]>([]);
const [searchText, setSearchText] = useState<string>(inputValue);
const [isSearching, setIsSearching] = useState<boolean>(false);
const [searchType, setSearchType] = useState<string>('network');
const searchValue = useRef<string>('network');
const [selNetCount, setSelNetCount] = useState<number>(0);
const [actorCount, setActorCount] = useState<number>(0);
const [directionType, setDirectionType] = useState<string>('TW');
const directionValue = useRef<string>('TW');
const consumerUrl = 'http://ch1.b.uat.4.cn/consumer';
const backendUrl = typeof window !== 'undefined'
? (import.meta.env.VITE_BACKEND_BASE_URL || 'http://localhost:8001')
: 'http://localhost:8001';
const [selectedSearchSources, setSelectedSearchSources] = useState<UserDataItem[]>([]);
const [isImporting, setIsImporting] = useState(false);
const searchChange = async () => {
setIsSearching(true);
if (searchValue.current === 'network') {
await agentUserSearch();
} else {
await agentGroupSearch();
}
};
const setDirectionChange = async (type: string) => {
setDirectionType(type);
directionValue.current = type;
await searchChange();
};
const searchTypeChange = async (value: string) => {
setSearchType(value);
searchValue.current = value;
await searchChange();
};
const allPlatformChange = async () => {
setAllPlatforms(true);
setPlatforms((prev) => prev.map((item) => ({ ...item, checked: false })));
platformList.current = [];
await searchChange();
};
const handleTogglePlatform = async (index: number) => {
setPlatforms((prev) => {
const newPlatforms = prev.map((item, i) =>
i === index ? { ...item, checked: !item.checked } : item
);
const checkedTitles = newPlatforms.filter((item) => item.checked).map((item) => item.title);
platformList.current = checkedTitles;
setAllPlatforms(newPlatforms.every((item) => !item.checked));
return newPlatforms;
});
await searchChange();
};
const allLanguageChange = async () => {
setAllLanguage(true);
setLanguages((prev) => prev.map((item) => ({ ...item, checked: false })));
languageList.current = [];
await searchChange();
};
const handleToggleLanguage = async (index: number) => {
setLanguages((prev) => {
const newItems = prev.map((item, i) =>
i === index ? { ...item, checked: !item.checked } : item
);
const checkedTitles = newItems.filter((item) => item.checked).map((item) => item.title);
languageList.current = checkedTitles;
setAllLanguage(newItems.every((item) => !item.checked));
return newItems;
});
await searchChange();
};
const sortTypeChange = async (type: string) => {
setSortType(type);
sortTypeValue.current = type;
sortValue.current = sortValue.current === 'desc' ? 'asc' : 'desc';
if (type === 'fans_count') setFansSort(sortValue.current);
else if (type === '_score') setRelevanceSort(sortValue.current);
await searchChange();
};
const agentUserSearch = async () => {
try {
const response = await authenticatedFetch(`${consumerUrl}/portrait/agentUser`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admin' },
body: JSON.stringify({
historyKey: [],
historyTitle: '',
keyword: searchText,
language: languageList.current,
page: 1,
limit: 12,
platform: platformList.current,
politicalStand: directionValue.current,
sort: sortTypeValue.current,
asc: sortValue.current,
typeKeyword: 9,
}),
});
if (response.ok) {
const data = await response.json();
setIsSearching(false);
const result: UserDataItem[] = data.data.list;
result.forEach((item) => {
item.profile_image = item.profile_image
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
: 'http://ty.b.uat.4.cn/manage/img/default.png';
item.classification = `${item.political_stand}-${item.history_key}-${item.history_title}`;
item.title = item.user_name || item.screen_name || item.user_id;
item.content = item.agentInfo['账号描述'];
item.url = `http://ty.b.uat.4.cn/fromB/toDetail?user_id=${item.user_id}&platform=${item.platform}`;
});
setUserList(result);
setSelNetCount(data.data.total);
setActorCount(data.data.zs_total);
}
} catch (error) {
console.error('Search error:', error);
}
};
const agentGroupSearch = async () => {
try {
const response = await authenticatedFetch(`${consumerUrl}/portrait/agentGroup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admin' },
body: JSON.stringify({
historyKey: [],
historyTitle: '',
keyword: searchText,
language: languageList.current,
page: 1,
limit: 12,
platform: platformList.current,
politicalStand: directionValue.current,
sort: sortTypeValue.current,
asc: sortValue.current,
typeKeyword: 9,
}),
});
if (response.ok) {
const data = await response.json();
setIsSearching(false);
const result: UserDataItem[] = data.data.data;
result.forEach((item) => {
item.hobbies = item.groupDiscussTags || [];
item.profile_image = item.profile_image
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
: 'http://ty.b.uat.4.cn/manage/img/default.png';
item.classification = `${item.political_stand}`;
item.title = item.group_name || item.groupName;
if (item.hx_info?.['用户画像']) {
item.content = item.hx_info['用户画像']['账号描述'];
}
item.url = '';
});
setUserList(result);
setActorCount(data.data.total);
}
} catch (error) {
console.error('Search error:', error);
}
};
const listDataCheckedChange = (index: number, checked: boolean) => {
setUserList((prevSources) => {
const newSources = prevSources.map((item, aIndex) =>
aIndex === index ? { ...item, checked } : item
);
setSelectedSearchSources(newSources.filter((item) => item.checked));
return newSources;
});
};
const linkToB2 = (data: UserDataItem) => {
const url = `http://ty.b.uat.4.cn/fromB/toDetail?user_id=${data.user_id}&platform=${data.platform}`;
window.open(url, '_blank');
};
const handleBatchDelete = () => {
setUserList((prev) => prev.map((item) => ({ ...item, checked: false })));
setSelectedSearchSources([]);
};
const handleImport = async () => {
if (selectedSearchSources.length === 0) {
toast.warning('没有可导入的来源');
return;
}
setIsImporting(true);
const dataList = selectedSearchSources.map((item) => ({
title: item.title,
content: item.content,
url: item.url,
}));
try {
const response = await authenticatedFetch(`${backendUrl}/api/v1/source/import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
search_space_id: Number(searchSpaceId),
source_list: dataList,
}),
});
if (response.ok) {
const data = await response.json();
toast.success(`成功导入 ${data.imported_count || dataList.length} 个来源`);
setSearchText('');
setUserList([]);
handleBatchDelete();
onSuccess?.();
} else {
const errorData = await response.json();
toast.error(errorData.detail || '导入失败,请重试');
}
} catch {
toast.error('导入失败,请重试');
} finally {
setIsImporting(false);
}
};
useEffect(() => {
void searchChange();
}, []);
return (
<div>
<div className="bg-white rounded-[5px] p-[10px]">
<div className="border border-[#e8e8e8] rounded-[5px] p-[10px]">
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">所属平台:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allPlatforms ? 'platform-active' : ''}`}
onClick={() => void allPlatformChange()}
>全部</span>
{platforms.map((item, index) => (
<span
key={index}
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
onClick={() => void handleTogglePlatform(index)}
>
<img src={`/platform/${item.icon}`} className="w-[24px]" alt="" />
<span className="w-[2px] h-[12px] ml-[5px] mr-[5px] bg-[#dbdbdb]"></span>
<span className="ml-[5px]">{item.title}</span>
</span>
))}
</span>
</span>
</div>
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">常用语言:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allLanguage ? 'platform-active' : ''}`}
onClick={() => void allLanguageChange()}
>全部</span>
{languages.map((item, index) => (
<span
key={index}
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
onClick={() => void handleToggleLanguage(index)}
>
<span>{item.title}</span>
</span>
))}
</span>
</span>
</div>
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">方向:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === 'TW' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('TW')}>W方向</span>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === 'US' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('US')}>U方向</span>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === '其他' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('其他')}>其他</span>
</span>
</span>
</div>
<div className="mb-[10px] flex">
<span className="text-[#3D3D3D]">类型:</span>
<span className="flex-1 min-w-0">
<span className="flex items-center gap-[10px] flex-wrap">
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${searchType === 'network' ? 'platform-active' : ''}`} onClick={() => void searchTypeChange('network')}>网络XWT</span>
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${searchType === 'group' ? 'platform-active' : ''}`} onClick={() => void searchTypeChange('group')}>群组XWT</span>
</span>
</span>
</div>
</div>
</div>
<div className="bg-white rounded-[5px] pt-[15px] mt-[10px]">
<div className="flex items-center justify-between pb-[10px] mb-[10px] border-b border-b-[#f1f3f5]">
<div className="flex items-center ml-[10px]">
<div className="title-bar mr-[8px]"></div>
<div className="text-[#454859]">用户表格管理</div>
</div>
<div className="flex items-center">
<div
className={`relative flex items-center cursor-pointer border ${sortType === 'fans_count' ? 'border-[#cf1322]' : ''} p-[7px] pl-[10px] pr-[20px] rounded-[5px]`}
onClick={() => void sortTypeChange('fans_count')}
>
<span className="leading-[16px]">粉丝数排序</span>
<div className="ml-[10px]">
<div className={`sort-up ${fansSort === 'asc' ? 'sort-up-active' : ''}`}></div>
<div className={`mt-[4px] sort-down ${fansSort === 'desc' ? 'sort-down-active' : ''}`}></div>
</div>
</div>
<div
className={`relative flex items-center ml-[10px] cursor-pointer border ${sortType === '_score' ? 'border-[#cf1322]' : ''} p-[7px] pl-[10px] pr-[20px] rounded-[5px]`}
onClick={() => void sortTypeChange('_score')}
>
<span className="leading-[16px]">相关度排序</span>
<div className="ml-[10px]">
<div className={`sort-up ${relevanceSort === 'asc' ? 'sort-up-active' : ''}`}></div>
<div className={`mt-[4px] sort-down ${relevanceSort === 'desc' ? 'sort-down-active' : ''}`}></div>
</div>
</div>
{searchType === 'network' && (
<>
<div className="flex items-center ml-[35px]">
<img className="w-[24px] h-[24px]" src="/icon/group-yellow-logo.svg" alt="" />
<div className="ml-[5px]">
<div className="text-[#454859] leading-[16px]">当前选中网络XWT数</div>
<div className="text-[#d97b00] leading-[16px] mt-[5px]">{selNetCount}</div>
</div>
</div>
<div className="ml-[5px] mr-[5px] h-[30px] w-[2px] -skew-x-[30deg] bg-[#dbdbdb]"></div>
</>
)}
<div className={`flex items-center ${searchType === 'network' ? 'ml-[5px]' : 'ml-[20px]'} mr-[25px]`}>
<img className="w-[24px] h-[24px]" src="/icon/group-red-logo.svg" alt="" />
<div className="ml-[5px]">
<div className="text-[#454859] leading-[16px]">网络XWT总数</div>
<div className="text-[#cf1322] leading-[16px] mt-[5px]">{actorCount}</div>
</div>
</div>
</div>
</div>
{selectedSearchSources.length > 0 && (
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
<span className="text-sm text-gray-600">已选中 {selectedSearchSources.length} 项</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={handleBatchDelete} className="h-8 px-3 text-sm">
<Trash2 className="h-3.5 w-3.5 mr-1" />
删除选中
</Button>
<Button
size="sm"
onClick={() => void handleImport()}
disabled={isImporting}
className="h-8 px-3 text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200"
>
{isImporting ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Plus className="h-3.5 w-3.5 mr-1" />}
导入选中
</Button>
</div>
</div>
)}
<div className="p-[10px]">
<div className="grid grid-cols-4 gap-[10px] user-list">
{userList.map((item, index) => (
<div key={index} className="user-item p-[10px] relative">
<div className="absolute top-[5px] right-[10px]">
<input
type="checkbox"
checked={item.checked ?? false}
onChange={() => listDataCheckedChange(index, !item.checked)}
className="w-4 h-4 cursor-pointer accent-primary"
/>
</div>
<div className="flex items-center mb-[10px] cursor-pointer" onClick={() => linkToB2(item)}>
<img src={item.profile_image ?? ''} className="w-[97px] h-[97px] rounded-[5px]" alt="" />
<div className="flex-1 min-w-0 pl-[10px]">
<div className="truncate mb-[10px] text-[18px]">{item.user_name || item.screen_name || item.user_id || item.group_name || item.groupName}</div>
<div className="flex justify-between mb-[10px]">
<div className="flex items-center">
<img src="/icon/group-yellow-logo.svg" className="w-[16px] h-[16px] mr-[5px]" alt="" />
<span>粉丝数:{item.fans_count || '-'}</span>
</div>
<div className="flex items-center">
<img src={`/platform/${item.platform}.png`} className="w-[25px] mr-[5px]" alt="" />
<span>{item.platform}</span>
</div>
</div>
<div className="flex items-center">
<img src="/icon/person-red-logo.svg" className="w-[16px] mr-[5px]" alt="" />
<span>实体人:{item.name}</span>
</div>
</div>
</div>
<div className="cursor-pointer" onClick={() => linkToB2(item)}>
<div className="flex items-center mb-[10px]">
<div className="w-[70px] flex items-center">
<img src="/icon/classification-logo.png" className="w-[16px] mr-[5px]" alt="" />
<span>分类:</span>
</div>
<div className="flex-1 min-w-0">
<div className="inline-block h-[28px] pl-[8px] pr-[8px] bg-[#fef6f6] text-[#cf1322]">{item.classification}</div>
</div>
</div>
<div className="flex items-center mb-[10px]">
<div className="w-[70px] flex items-center">
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
<span>标签:</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-[5px]">
{(item.hobbies ?? []).map((hobbie) => (
<div key={hobbie} className="h-[28px] pl-[8px] pr-[8px] bg-[#f6fdfb] text-[#1eb07f]">{hobbie}</div>
))}
</div>
</div>
</div>
<div className="flex items-baseline mb-[10px]">
<div className="w-[70px] flex items-center">
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
<span>简介:</span>
</div>
<div className="flex-1 min-w-0">
<div className="line-clamp-3 text-ellipsis overflow-hidden">
{(item.agentInfo && item.agentInfo['账号描述']) || (item.hx_info?.['用户画像']?.['账号描述']) || ''}
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,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<string, number>;
selectedNodeId: string | null;
/** null = no active search; otherwise the set of matching node ids. */
searchMatchIds: Set<string> | 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<string, number>;
expandedNodeIds: Set<string>;
} | null;
/** Floating pills rendered top-left over the canvas. */
toolbar?: ReactNode;
/** Small floating hint rendered bottom-center over the canvas. */
hint?: ReactNode;
/** Centered overlay (empty / loading states). */
overlay?: ReactNode;
}
interface DragState {
pointerId: number;
node: SimNode | null;
startX: number;
startY: number;
originX: number;
originY: number;
moved: boolean;
}
const easeCubicOut = (t: number) => 1 - Math.pow(1 - t, 3);
export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
function GraphCanvas(props, ref) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const minimapRef = useRef<HTMLCanvasElement>(null);
const simRef = useRef<ForceSimulation | null>(null);
const nodeMapRef = useRef<Map<string, SimNode>>(new Map());
const adjacencyRef = useRef<Map<string, Set<string>>>(new Map());
const labelThresholdRef = useRef(3);
const transformRef = useRef<Transform>({ x: 0, y: 0, k: 0 });
const sizeRef = useRef({ w: 0, h: 0 });
const hoverRef = useRef<SimNode | null>(null);
const dragRef = useRef<DragState | null>(null);
const animRef = useRef<{
from: Transform;
to: Transform;
start: number;
duration: number;
} | null>(null);
const needsDrawRef = useRef(true);
const autoFitRef = useRef(false);
// 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<string, Set<string>>();
for (const e of props.edges) {
const source = byId.get(e.source);
const target = byId.get(e.target);
if (!source || !target) continue;
const label = getEdgeLabel(e);
simEdges.push({
id: getEdgeId(e),
source,
target,
label,
info: getEdgeInfo(label),
weight: e.weight ?? 1,
distance: getLinkDistance(label) + source.radius + target.radius,
});
if (!adjacency.has(source.id)) adjacency.set(source.id, new Set());
if (!adjacency.has(target.id)) adjacency.set(target.id, new Set());
adjacency.get(source.id)!.add(target.id);
adjacency.get(target.id)!.add(source.id);
}
// Label budget: always label hubs; ~20 labels max by default.
const sortedDegrees = simNodes.map((n) => n.degree).sort((a, b) => b - a);
labelThresholdRef.current = Math.max(
2,
sortedDegrees[Math.min(19, sortedDegrees.length - 1)] ?? 2,
);
nodeMapRef.current = byId;
adjacencyRef.current = adjacency;
const sim = new ForceSimulation(simNodes, simEdges);
if (props.layoutType === "circular") {
applyCircularLayout(simNodes);
sim.alpha = 0;
} else {
sim.alpha = previous.size === 0 ? 1 : 0.5;
}
simRef.current = sim;
if (previous.size === 0) {
autoFitRef.current = false;
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
const { sx, sy } = localPoint(e);
const drag = dragRef.current;
if (drag && drag.pointerId === e.pointerId) {
const dx = sx - drag.startX;
const dy = sy - drag.startY;
if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;
if (drag.node) {
const world = screenToWorld(sx, sy);
drag.node.fx = world.x;
drag.node.fy = world.y;
if (layoutRef.current === "force") simRef.current?.reheat(0.3);
propsRef.current.onTooltip(null);
} else {
transformRef.current = {
...transformRef.current,
x: drag.originX + dx,
y: drag.originY + dy,
};
}
requestDraw();
return;
}
const world = screenToWorld(sx, sy);
setHover(hitTest(world.x, world.y), e);
};
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== e.pointerId) return;
dragRef.current = null;
if (!drag.moved) {
// Plain click: select node / clear selection on empty space.
if (drag.node) {
drag.node.fx = null;
drag.node.fy = null;
propsRef.current.onSelectNode(drag.node.id);
} else {
propsRef.current.onSelectNode(null);
}
requestDraw();
}
// A real drag leaves the node pinned at its drop position.
};
const handleDoubleClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
const { sx, sy } = localPoint(e);
const world = screenToWorld(sx, sy);
const node = hitTest(world.x, world.y);
if (node) {
propsRef.current.onSelectNode(node.id);
focusNode(node.id);
}
};
const handlePointerLeave = () => {
setHover(null);
};
// Wheel zoom needs a non-passive listener to preventDefault.
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
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 (
<div className="kg-canvas-wrap" ref={containerRef}>
<canvas
ref={canvasRef}
className="kg-canvas"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerLeave}
onDoubleClick={handleDoubleClick}
/>
{props.toolbar ? (
<div className="kg-canvas-toolbar">{props.toolbar}</div>
) : null}
{props.hint}
{minimapVisible ? (
<canvas ref={minimapRef} className="kg-minimap" width={180} height={120} />
) : null}
{props.overlay}
</div>
);
},
);
@@ -0,0 +1,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<string, string> = {
thread: "对话沉淀",
search: "搜索沉淀",
tool: "工具结果",
file: "文件导入",
manual: "手动创建",
report: "报告",
};
const STATUS_ZH: Record<string, string> = {
draft: "草稿",
approved: "已审核",
archived: "已归档",
};
export interface NeighborItem {
node: GraphNode;
relation: string;
}
interface GraphDetailPanelProps {
node: GraphNode | null;
degree: number;
neighbors: NeighborItem[];
/** Note detail (only for note-type nodes; undefined while absent/loading). */
note: KnowledgeNote | undefined;
noteLoading: boolean;
/** Explore-mode context; null when in full mode. */
explore: {
/** Depth of this node in the explore graph; null = not yet visible. */
depth: number | null;
expanded: boolean;
isRoot: boolean;
} | null;
onViewSource: (noteId: string) => void;
onLocate: (id: string) => void;
onHide: (id: string) => void;
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 (
<aside className="kg-panel kg-detail">
<div className="kg-panel-header">
<span className="kg-panel-title">节点详情</span>
<button
type="button"
className="kg-panel-close-btn"
title="收起详情面板"
onClick={props.onClose}
>
<X size={15} />
</button>
</div>
{!node ? (
<div className="kg-detail-empty">
<MousePointerClick size={28} />
<div className="kg-detail-empty-title">选择一个节点查看详情</div>
<div>
点击图谱中的文档、主题或实体节点,
<br />
查看摘要、标签、关联关系和相邻节点。
</div>
</div>
) : (
<DetailBody {...props} node={node} />
)}
</aside>
);
}
function DetailBody(props: GraphDetailPanelProps & { node: GraphNode }) {
const { node, note } = props;
const kind = getNodeKind(node);
const kindMeta = NODE_KIND_META[kind];
const isNote = (node.type || "").toLowerCase() === "note";
return (
<div className="kg-panel-content">
<h2 className="kg-detail-title">{getNodeTitle(node)}</h2>
<div>
<span
className="kg-detail-type"
style={{
background: `${kindMeta.color}26`,
color: kindMeta.color,
}}
>
<span className="kg-legend-dot" style={{ background: kindMeta.color }} />
{kindMeta.label}
{node.category && node.category !== kindMeta.label
? ` · ${node.category}`
: ""}
</span>
<span className="kg-detail-type kg-detail-type-neutral">
度数 {props.degree}
</span>
{props.explore && props.explore.depth !== null ? (
<>
<span className="kg-detail-type kg-detail-type-primary">
{props.explore.isRoot
? "探索起点"
: `第 ${props.explore.depth} 层`}
</span>
<span
className={`kg-detail-type ${
props.explore.expanded
? "kg-detail-type-success"
: "kg-detail-type-warn"
}`}
>
{props.explore.expanded ? "已展开" : "可展开"}
</span>
</>
) : null}
</div>
{props.noteLoading ? (
<div className="kg-detail-summary" style={{ display: "flex", gap: 8 }}>
<Loader2 size={14} className="animate-spin" /> 加载笔记详情...
</div>
) : null}
{note?.summary ? <p className="kg-detail-summary">{note.summary}</p> : null}
{note?.tags?.length ? (
<div className="kg-tag-list">
{note.tags.map((tag) => (
<span key={tag} className="kg-tag">
#{tag}
</span>
))}
</div>
) : null}
{note ? (
<div className="kg-detail-meta">
<span className="kg-detail-meta-label">来源</span>
<span className="kg-detail-meta-value">
{SOURCE_TYPE_ZH[note.source_type] ?? note.source_type}
</span>
<span className="kg-detail-meta-label">状态</span>
<span className="kg-detail-meta-value">
{STATUS_ZH[note.status] ?? note.status}
</span>
<span className="kg-detail-meta-label">创建时间</span>
<span className="kg-detail-meta-value">{formatTime(note.created_at)}</span>
<span className="kg-detail-meta-label">更新时间</span>
<span className="kg-detail-meta-value">{formatTime(note.updated_at)}</span>
{note.created_by_name ? (
<>
<span className="kg-detail-meta-label">创建人</span>
<span className="kg-detail-meta-value">{note.created_by_name}</span>
</>
) : null}
</div>
) : null}
<div className="kg-detail-actions">
{isNote ? (
<button
type="button"
className="kg-icon-button"
onClick={() => props.onViewSource(node.id)}
>
<FileText size={14} /> 查看原文
</button>
) : null}
<button
type="button"
className="kg-icon-button"
onClick={() => props.onLocate(node.id)}
>
<Crosshair size={14} /> 定位节点
</button>
<button
type="button"
className="kg-icon-button"
onClick={() => props.onHide(node.id)}
>
<EyeOff size={14} /> 隐藏节点
</button>
<button
type="button"
className="kg-icon-button"
title="加入黑名单后该节点将不再出现在图谱中,可在左侧「图谱黑名单」中移除"
onClick={() => props.onBlacklist(node.id)}
>
<Ban size={14} /> 加入黑名单
</button>
{props.explore ? (
<>
<button
type="button"
className="kg-icon-button"
disabled={props.explore.depth === null || props.explore.expanded}
title={
props.explore.expanded
? "该节点的一跳邻居已全部展开"
: "展开该节点的一跳邻居"
}
onClick={() => props.onExpandNode(node.id)}
>
<Waypoints size={14} /> 展开一跳邻居
</button>
<button
type="button"
className="kg-icon-button"
title="清空当前探索路径,以此节点为新起点"
onClick={() => props.onSetExploreRoot(node.id)}
>
<Compass size={14} /> 以此为新起点
</button>
</>
) : null}
</div>
<h3 className="kg-detail-section-title">
关联节点({props.neighbors.length})
</h3>
{props.neighbors.length === 0 ? (
<div className="kg-search-hint">该节点暂无可见的关联节点</div>
) : (
<div className="kg-neighbor-list">
{props.neighbors.map(({ node: neighbor, relation }) => {
const neighborKind = NODE_KIND_META[getNodeKind(neighbor)];
const relationInfo = getEdgeInfo(relation);
return (
<div
key={`${neighbor.id}:${relation}`}
className="kg-neighbor-item"
onClick={() => props.onPickNeighbor(neighbor.id)}
>
<span
className="kg-legend-dot"
style={{ background: neighborKind.color, flexShrink: 0 }}
/>
<span className="kg-neighbor-title">{getNodeTitle(neighbor)}</span>
<span
className="kg-neighbor-relation"
style={{ color: relationInfo.color }}
>
{relationInfo.zh}
</span>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,57 @@
import type { NodeKind } from "./graphTypes";
import { getEdgeInfo, NODE_KIND_META, NODE_KIND_ORDER } from "./graphUtils";
interface GraphFooterProps {
nodeKindCounts: Map<NodeKind, number>;
edgeLabels: string[];
zoomLevel: number;
selectedTitle: string | null;
visibleNodes: number;
visibleEdges: number;
}
export function GraphFooter(props: GraphFooterProps) {
const kinds = NODE_KIND_ORDER.filter((k) => (props.nodeKindCounts.get(k) ?? 0) > 0);
return (
<footer className="kg-footer">
<div className="kg-legend">
{kinds.map((kind) => (
<span key={kind} className="kg-legend-item">
<span
className="kg-legend-dot"
style={{ background: NODE_KIND_META[kind].color }}
/>
{NODE_KIND_META[kind].label}
</span>
))}
{props.edgeLabels.slice(0, 6).map((label) => {
const info = getEdgeInfo(label);
return (
<span key={label} className="kg-legend-item">
<span
className="kg-legend-line"
style={{
borderTopColor: info.color,
borderTopStyle: info.dashed ? "dashed" : "solid",
}}
/>
{info.zh}
</span>
);
})}
</div>
<div className="kg-footer-status">
{props.selectedTitle ? (
<span className="kg-footer-selected" title={props.selectedTitle}>
选中:{props.selectedTitle}
</span>
) : null}
<span>
显示 {props.visibleNodes} 节点 · {props.visibleEdges} 关系
</span>
<span>缩放 {Math.round(props.zoomLevel * 100)}%</span>
</div>
</footer>
);
}
@@ -0,0 +1,124 @@
import {
ArrowLeft,
Download,
Loader2,
Maximize2,
RefreshCw,
Scan,
Shuffle,
ZoomIn,
ZoomOut,
} from "lucide-react";
interface GraphHeaderProps {
nodeCount: number;
edgeCount: number;
totalNodes: number;
totalEdges: number;
refreshing: boolean;
onBack: () => void;
onRelayout: () => void;
onFitView: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
onExport: () => void;
onFullscreen: () => void;
onRefresh: () => void;
}
export function GraphHeader(props: GraphHeaderProps) {
const filteredNodes =
props.nodeCount === props.totalNodes
? `${props.totalNodes}`
: `${props.nodeCount}/${props.totalNodes}`;
const filteredEdges =
props.edgeCount === props.totalEdges
? `${props.totalEdges}`
: `${props.edgeCount}/${props.totalEdges}`;
return (
<header className="kg-header">
<div className="kg-header-left">
<button type="button" className="kg-back" onClick={props.onBack}>
<ArrowLeft size={15} />
<span className="kg-button-text">返回</span>
</button>
<div className="kg-title-block">
<h1 className="kg-title">知识图谱</h1>
<span className="kg-subtitle">知识关系可视化</span>
</div>
<div className="kg-stat-pills">
<span className="kg-stat-pill">{filteredNodes} 个节点</span>
<span className="kg-stat-pill kg-stat-pill-violet">
{filteredEdges} 条关系
</span>
</div>
</div>
<div className="kg-header-actions">
<button
type="button"
className="kg-icon-button"
title="重新布局"
onClick={props.onRelayout}
>
<Shuffle size={15} />
<span className="kg-button-text">重新布局</span>
</button>
<button
type="button"
className="kg-icon-button"
title="适应视图"
onClick={props.onFitView}
>
<Scan size={15} />
<span className="kg-button-text">适应视图</span>
</button>
<button
type="button"
className="kg-icon-button"
title="放大"
onClick={props.onZoomIn}
>
<ZoomIn size={15} />
</button>
<button
type="button"
className="kg-icon-button"
title="缩小"
onClick={props.onZoomOut}
>
<ZoomOut size={15} />
</button>
<button
type="button"
className="kg-icon-button"
title="导出图片"
onClick={props.onExport}
>
<Download size={15} />
</button>
<button
type="button"
className="kg-icon-button"
title="全屏"
onClick={props.onFullscreen}
>
<Maximize2 size={15} />
</button>
<button
type="button"
className="kg-icon-button"
title="刷新图谱数据"
onClick={props.onRefresh}
disabled={props.refreshing}
>
{props.refreshing ? (
<Loader2 size={15} className="animate-spin" />
) : (
<RefreshCw size={15} />
)}
</button>
</div>
</header>
);
}
@@ -0,0 +1,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<NodeKind, number>;
edgeLabelCounts: Map<string, number>;
disabledNodeKinds: Set<NodeKind>;
disabledEdgeLabels: Set<string>;
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 (
<section className="kg-filter-section">
<button
type="button"
className="kg-filter-section-header"
onClick={props.onToggle}
aria-expanded={!props.collapsed}
>
<span>{props.title}</span>
<span className="kg-filter-section-extra">
{props.extra}
{props.collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</span>
</button>
{!props.collapsed ? (
<div className="kg-filter-section-body">{props.children}</div>
) : null}
</section>
);
}
export function GraphSidebar(props: GraphSidebarProps) {
// 节点类型 / 关系类型默认收起,统计与核心节点默认展开。
const [collapsedSections, setCollapsedSections] = useState<
Record<SectionKey, boolean>
>({
nodeTypes: true,
edgeTypes: true,
graphStats: false,
coreNodes: false,
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 (
<aside className="kg-panel kg-sidebar">
<div className="kg-panel-header">
<span className="kg-panel-title">搜索与筛选</span>
<div className="kg-panel-header-actions">
{props.hiddenCount > 0 ? (
<button
type="button"
className="kg-text-button"
onClick={props.onRestoreHidden}
>
恢复 {props.hiddenCount} 个隐藏节点
</button>
) : null}
<button
type="button"
className="kg-panel-close-btn"
title="收起筛选面板"
onClick={props.onClose}
>
<X size={15} />
</button>
</div>
</div>
<div className="kg-panel-content">
<div className="kg-search-wrap">
<Search size={14} />
<input
className="kg-search"
placeholder="搜索节点标题..."
value={props.searchKeyword}
onChange={(e) => props.onSearchChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") props.onSearchEnter();
}}
/>
</div>
{props.searchMatchCount !== null ? (
<div
className={`kg-search-hint${props.searchMatchCount === 0 ? " kg-search-hint-warn" : ""}`}
>
{props.searchMatchCount === 0
? "没有匹配的节点,换个关键词试试"
: `匹配 ${props.searchMatchCount} 个节点,回车定位`}
</div>
) : null}
<FilterSection
title="节点类型"
extra={`${kinds.length} 类`}
collapsed={collapsedSections.nodeTypes}
onToggle={() => toggleFilterSection("nodeTypes")}
>
{kinds.map((kind) => (
<label key={kind} className="kg-check-row">
<span className="kg-check-row-main">
<input
type="checkbox"
checked={!props.disabledNodeKinds.has(kind)}
onChange={() => props.onToggleNodeKind(kind)}
/>
<span
className="kg-legend-dot"
style={{ background: NODE_KIND_META[kind].color }}
/>
<span className="kg-check-label">{NODE_KIND_META[kind].label}</span>
</span>
<span className="kg-count">{props.nodeKindCounts.get(kind)}</span>
</label>
))}
</FilterSection>
{edgeLabels.length > 0 ? (
<FilterSection
title="关系类型"
extra={`${edgeLabels.length} 类`}
collapsed={collapsedSections.edgeTypes}
onToggle={() => toggleFilterSection("edgeTypes")}
>
{edgeLabels.map(([label, count]) => {
const info = getEdgeInfo(label);
return (
<label key={label} className="kg-check-row">
<span className="kg-check-row-main">
<input
type="checkbox"
checked={!props.disabledEdgeLabels.has(label)}
onChange={() => props.onToggleEdgeLabel(label)}
/>
<span
className="kg-legend-line"
style={{
borderTopColor: info.color,
borderTopStyle: info.dashed ? "dashed" : "solid",
}}
/>
<span className="kg-check-label">{info.zh}</span>
</span>
<span className="kg-count">{count}</span>
</label>
);
})}
</FilterSection>
) : null}
<FilterSection
title="图谱统计"
collapsed={collapsedSections.graphStats}
onToggle={() => toggleFilterSection("graphStats")}
>
<div className="kg-stats-grid">
<div className="kg-stat-card">
<div className="kg-stat-card-value">{props.stats.nodes}</div>
<div className="kg-stat-card-label">节点</div>
</div>
<div className="kg-stat-card">
<div className="kg-stat-card-value">{props.stats.edges}</div>
<div className="kg-stat-card-label">关系</div>
</div>
<div className="kg-stat-card">
<div className="kg-stat-card-value">
{props.stats.avgDegree.toFixed(2)}
</div>
<div className="kg-stat-card-label">平均度</div>
</div>
<div className="kg-stat-card">
<div className="kg-stat-card-value">{props.stats.largestComponent}</div>
<div className="kg-stat-card-label">最大连通分量</div>
</div>
</div>
</FilterSection>
{props.topNodes.length > 0 ? (
<FilterSection
title="核心节点"
extra={`${props.topNodes.length} 个`}
collapsed={collapsedSections.coreNodes}
onToggle={() => toggleFilterSection("coreNodes")}
>
{props.topNodes.map(({ node, degree }, i) => (
<div
key={node.id}
className="kg-rank-item"
onClick={() => props.onPickNode(node.id)}
>
<span className="kg-rank-index">{i + 1}</span>
<span className="kg-rank-title">{getNodeTitle(node)}</span>
<span className="kg-rank-degree">{degree} 度</span>
</div>
))}
</FilterSection>
) : null}
<FilterSection
title="图谱黑名单"
extra={`${props.blacklistItems.length} 个`}
collapsed={collapsedSections.blacklist}
onToggle={() => toggleFilterSection("blacklist")}
>
<div className="kg-search-hint">
黑名单中的主题 / 节点不会出现在图谱中。可在节点详情里加入黑名单。
</div>
<button
type="button"
className="kg-icon-button"
disabled={props.blacklistPending || props.junkCandidateCount === 0}
title="将单字符、纯数字符号、通用泛词等低价值主题批量加入黑名单"
onClick={props.onCleanJunk}
>
<Eraser size={13} /> 清理低价值主题
{props.junkCandidateCount > 0 ? `(${props.junkCandidateCount})` : ""}
</button>
{props.blacklistItems.length === 0 ? (
<div className="kg-search-hint">暂无黑名单条目。</div>
) : (
props.blacklistItems.map((item) => (
<div key={item.id} className="kg-check-row">
<span className="kg-check-row-main">
<span className="kg-check-label" title={item.label}>
{item.label}
</span>
</span>
<button
type="button"
className="kg-text-button"
disabled={props.blacklistPending}
title="移出黑名单,重新显示该节点"
onClick={() => props.onRemoveBlacklist(item.id)}
>
移除
</button>
</div>
))
)}
</FilterSection>
</div>
</aside>
);
}
@@ -0,0 +1,29 @@
import type { TooltipState } from "./graphTypes";
import { getNodeTitle, NODE_KIND_META } from "./graphUtils";
interface GraphTooltipProps {
tooltip: TooltipState | null;
}
export function GraphTooltip({ tooltip }: GraphTooltipProps) {
if (!tooltip) return null;
const meta = NODE_KIND_META[tooltip.kind];
// Keep the tooltip inside the viewport.
const left = Math.min(tooltip.x + 14, window.innerWidth - 336);
const top = Math.min(tooltip.y + 14, window.innerHeight - 120);
return (
<div className="kg-tooltip" style={{ left, top }}>
<div className="kg-tooltip-title">{getNodeTitle(tooltip.node)}</div>
<div className="kg-tooltip-desc">
<span className="kg-legend-item">
<span className="kg-legend-dot" style={{ background: meta.color }} />
{meta.label}
</span>
{tooltip.node.category ? <span>· {tooltip.node.category}</span> : null}
<span>· 度数 {tooltip.degree}</span>
</div>
<div className="kg-tooltip-hint">单击查看详情 · 双击聚焦 · 拖拽固定位置</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,111 @@
/** Shared types for the knowledge-graph workbench. */
/** Raw node as returned by `GET /api/knowledge/graph` (plus compat fields). */
export interface GraphNode {
id: string;
/** "note" | "entity" on the current backend; kept loose for compat. */
type?: string;
label?: string;
title?: string;
name?: string;
/** note: source_type (thread/file/manual/...); entity: entity_type. */
category?: string | null;
}
/** Raw edge as returned by `GET /api/knowledge/graph` (plus compat fields). */
export interface GraphEdge {
id?: string;
source: string;
target: string;
/** Relation type: "links" | "related" | LLM relation_type. */
label?: string;
type?: string;
relation?: string;
weight?: number;
directed?: boolean;
}
export interface GraphData {
nodes: GraphNode[];
edges: GraphEdge[];
}
/** Normalized visual bucket for a node. */
export type NodeKind =
| "document"
| "concept"
| "person"
| "org"
| "location"
| "event"
| "tag"
| "other";
export type LayoutType = "force" | "circular";
/** Graph display mode: full graph vs. click-to-expand exploration. */
export type GraphMode = "full" | "explore";
/** One incremental expansion step in explore mode (for undo / rebuild). */
export interface ExploreStep {
nodeId: string;
depth: number;
addedNodeIds: string[];
addedEdgeIds: string[];
timestamp: number;
}
/** Style metadata of an edge label. */
export interface EdgeInfo {
color: string;
zh: string;
dashed: boolean;
}
/** A node inside the force simulation (world coordinates). */
export interface SimNode {
id: string;
node: GraphNode;
kind: NodeKind;
degree: number;
radius: number;
color: string;
x: number;
y: number;
vx: number;
vy: number;
fx: number | null;
fy: number | null;
}
export interface SimEdge {
id: string;
source: SimNode;
target: SimNode;
label: string;
info: EdgeInfo;
weight: number;
distance: number;
}
export interface Transform {
x: number;
y: number;
k: number;
}
export interface TooltipState {
/** Viewport (client) coordinates. */
x: number;
y: number;
node: GraphNode;
kind: NodeKind;
degree: number;
}
export interface GraphStats {
nodes: number;
edges: number;
avgDegree: number;
largestComponent: number;
}
@@ -0,0 +1,352 @@
import type {
EdgeInfo,
GraphEdge,
GraphNode,
GraphStats,
NodeKind,
SimEdge,
SimNode,
} from "./graphTypes";
// ---------------------------------------------------------------------------
// Node helpers
// ---------------------------------------------------------------------------
export const getNodeTitle = (node: GraphNode): string =>
node.title || node.label || node.name || node.id;
const KIND_PATTERNS: Array<[NodeKind, RegExp]> = [
["person", /^(person|people|human|人物|人名|人员)$/i],
["org", /^(org|organization|organisation|company|institution|party|组织|机构|公司|团体|政党|国会)$/i],
["location", /^(location|place|country|city|region|gpe|地点|地区|国家|城市|地理位置)$/i],
["event", /^(event|事件|活动|会议|战争|选举|讲话)$/i],
["tag", /^(tag|keyword|标签|关键词)$/i],
["concept", /^(concept|topic|term|technology|theory|product|project|主题|概念|术语|技术|理论|产品|项目|政策)$/i],
];
/** Map a raw node (note source_type / entity entity_type) to a visual bucket. */
export function getNodeKind(node: GraphNode): NodeKind {
const type = (node.type || "").toLowerCase();
if (type === "note" || type === "document" || type === "file") return "document";
const category = (node.category || "").trim();
if (category) {
for (const [kind, re] of KIND_PATTERNS) {
if (re.test(category)) return kind;
}
}
// Unmatched LLM entity types still read better as "concept" than as a grey blob.
if (type === "entity") return category ? "other" : "concept";
return "other";
}
export const NODE_KIND_META: Record<NodeKind, { label: string; color: string }> = {
document: { label: "文档", color: "#38bdf8" },
concept: { label: "主题", color: "#a78bfa" },
person: { label: "人物", color: "#fbbf24" },
org: { label: "组织", color: "#2dd4bf" },
location: { label: "地点", color: "#34d399" },
event: { label: "事件", color: "#fb7185" },
tag: { label: "标签", color: "#a3e635" },
other: { label: "其他", color: "#94a3b8" },
};
/** Stable ordering for filters / legend. */
export const NODE_KIND_ORDER: NodeKind[] = [
"document",
"concept",
"person",
"org",
"location",
"event",
"tag",
"other",
];
export const getNodeRadius = (degree: number): number =>
Math.min(30, 9 + Math.sqrt(Math.max(degree, 0)) * 4);
export function truncateLabel(text: string, max = 14): string {
return text.length > max ? `${text.slice(0, max)}…` : text;
}
// ---------------------------------------------------------------------------
// Edge helpers
// ---------------------------------------------------------------------------
export const getEdgeLabel = (edge: GraphEdge): string =>
edge.label || edge.type || edge.relation || "related";
/** Stable edge identity shared by explore-mode visibility sets and the canvas. */
export const getEdgeId = (edge: GraphEdge): string =>
edge.id ?? `${edge.source}->${edge.target}:${getEdgeLabel(edge)}`;
const KNOWN_EDGE_INFO: Record<string, EdgeInfo> = {
related: { color: "#94a3b8", zh: "相关", dashed: false },
links: { color: "#a78bfa", zh: "引用", dashed: true },
references: { color: "#a78bfa", zh: "引用", dashed: true },
contains: { color: "#34d399", zh: "包含", dashed: false },
belongs_to: { color: "#2dd4bf", zh: "属于", dashed: false },
similar: { color: "#fbbf24", zh: "相似", dashed: true },
mentions: { color: "#38bdf8", zh: "提及", dashed: false },
};
const EDGE_PALETTE = [
"#38bdf8",
"#a78bfa",
"#34d399",
"#fbbf24",
"#fb7185",
"#2dd4bf",
"#fb923c",
"#e879f9",
];
function hashString(text: string): number {
let h = 0;
for (let i = 0; i < text.length; i++) {
h = (h * 31 + text.charCodeAt(i)) | 0;
}
return Math.abs(h);
}
/** Color / display name / line style of an edge label (stable for unknown labels). */
export function getEdgeInfo(label: string): EdgeInfo {
const known = KNOWN_EDGE_INFO[label.toLowerCase()];
if (known) return known;
return {
color: EDGE_PALETTE[hashString(label) % EDGE_PALETTE.length],
zh: label,
dashed: false,
};
}
export function getLinkDistance(label: string): number {
switch (label.toLowerCase()) {
case "contains":
return 100;
case "links":
case "references":
return 130;
case "similar":
return 170;
default:
return 145;
}
}
// ---------------------------------------------------------------------------
// Graph statistics
// ---------------------------------------------------------------------------
export function computeDegrees(
nodes: GraphNode[],
edges: GraphEdge[],
): Map<string, number> {
const degrees = new Map<string, number>();
for (const n of nodes) degrees.set(n.id, 0);
for (const e of edges) {
if (degrees.has(e.source)) degrees.set(e.source, (degrees.get(e.source) ?? 0) + 1);
if (degrees.has(e.target)) degrees.set(e.target, (degrees.get(e.target) ?? 0) + 1);
}
return degrees;
}
export function computeGraphStats(
nodes: GraphNode[],
edges: GraphEdge[],
): GraphStats {
const adjacency = new Map<string, string[]>();
for (const n of nodes) adjacency.set(n.id, []);
let validEdges = 0;
for (const e of edges) {
if (!adjacency.has(e.source) || !adjacency.has(e.target)) continue;
validEdges += 1;
adjacency.get(e.source)!.push(e.target);
adjacency.get(e.target)!.push(e.source);
}
// Largest connected component via BFS.
const visited = new Set<string>();
let largest = 0;
for (const n of nodes) {
if (visited.has(n.id)) continue;
let size = 0;
const queue = [n.id];
visited.add(n.id);
while (queue.length) {
const cur = queue.pop()!;
size += 1;
for (const next of adjacency.get(cur) ?? []) {
if (!visited.has(next)) {
visited.add(next);
queue.push(next);
}
}
}
largest = Math.max(largest, size);
}
return {
nodes: nodes.length,
edges: validEdges,
avgDegree: nodes.length ? (validEdges * 2) / nodes.length : 0,
largestComponent: largest,
};
}
// ---------------------------------------------------------------------------
// Layouts
// ---------------------------------------------------------------------------
const GOLDEN_ANGLE = 2.3999632297286533;
/** Phyllotaxis seeding around the origin — hubs (high degree) near the center. */
export function seedPositions(nodes: SimNode[]): void {
const sorted = [...nodes].sort((a, b) => b.degree - a.degree);
sorted.forEach((n, i) => {
const r = 22 * Math.sqrt(i + 0.5);
const angle = i * GOLDEN_ANGLE;
n.x = r * Math.cos(angle);
n.y = r * Math.sin(angle);
n.vx = 0;
n.vy = 0;
n.fx = null;
n.fy = null;
});
}
/** Static circular layout, grouped by kind then degree. */
export function applyCircularLayout(nodes: SimNode[]): void {
const sorted = [...nodes].sort((a, b) => {
const ka = NODE_KIND_ORDER.indexOf(a.kind);
const kb = NODE_KIND_ORDER.indexOf(b.kind);
return ka !== kb ? ka - kb : b.degree - a.degree;
});
const radius = Math.max(180, (sorted.length * 56) / (2 * Math.PI));
sorted.forEach((n, i) => {
const angle = (i / sorted.length) * Math.PI * 2 - Math.PI / 2;
n.x = radius * Math.cos(angle);
n.y = radius * Math.sin(angle);
n.vx = 0;
n.vy = 0;
n.fx = null;
n.fy = null;
});
}
// ---------------------------------------------------------------------------
// Force simulation (d3-force-like, dependency-free, fine for <500 nodes)
// ---------------------------------------------------------------------------
export class ForceSimulation {
alpha = 1;
readonly alphaMin = 0.004;
private readonly alphaDecay = 0.0228;
private readonly velocityDecay = 0.38;
private readonly chargeStrength = -420;
private readonly chargeCutoff2 = 600 * 600;
constructor(
public nodes: SimNode[],
public edges: SimEdge[],
) {}
reheat(alpha = 0.9): void {
this.alpha = Math.max(this.alpha, alpha);
}
/** Advance one step; returns false when the layout has cooled down. */
tick(): boolean {
if (this.alpha < this.alphaMin) return false;
this.alpha += (0 - this.alpha) * this.alphaDecay;
const a = this.alpha;
const { nodes, edges } = this;
// Link spring force.
for (const e of edges) {
const s = e.source;
const t = e.target;
let dx = t.x - s.x;
let dy = t.y - s.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1e-3;
const f = ((dist - e.distance) / dist) * 0.45 * a;
dx *= f;
dy *= f;
t.vx -= dx * 0.5;
t.vy -= dy * 0.5;
s.vx += dx * 0.5;
s.vy += dy * 0.5;
}
// Many-body repulsion with a distance cutoff.
for (let i = 0; i < nodes.length; i++) {
const ni = nodes[i];
for (let j = i + 1; j < nodes.length; j++) {
const nj = nodes[j];
let dx = nj.x - ni.x;
let dy = nj.y - ni.y;
let d2 = dx * dx + dy * dy;
if (d2 > this.chargeCutoff2) continue;
if (d2 < 1) {
// Deterministic nudge for coincident nodes.
dx = ((i % 3) - 1) * 0.5 || 0.5;
dy = ((j % 3) - 1) * 0.5 || -0.5;
d2 = dx * dx + dy * dy;
}
const d = Math.sqrt(d2);
const f = (this.chargeStrength * a) / d2;
const fx = (dx / d) * f;
const fy = (dy / d) * f;
ni.vx += fx;
ni.vy += fy;
nj.vx -= fx;
nj.vy -= fy;
}
}
// Gentle pull toward the origin keeps disconnected clusters on screen.
for (const n of nodes) {
n.vx += (0 - n.x) * 0.05 * a;
n.vy += (0 - n.y) * 0.05 * a;
}
// Positional collision resolution (prevents node overlap).
for (let i = 0; i < nodes.length; i++) {
const ni = nodes[i];
for (let j = i + 1; j < nodes.length; j++) {
const nj = nodes[j];
const minDist = ni.radius + nj.radius + 8;
let dx = nj.x - ni.x;
let dy = nj.y - ni.y;
const d2 = dx * dx + dy * dy;
if (d2 >= minDist * minDist || d2 === 0) continue;
const d = Math.sqrt(d2);
const push = ((minDist - d) / d) * 0.5;
dx *= push;
dy *= push;
if (ni.fx == null) {
ni.x -= dx;
ni.y -= dy;
}
if (nj.fx == null) {
nj.x += dx;
nj.y += dy;
}
}
}
// Integrate velocities.
for (const n of nodes) {
if (n.fx != null && n.fy != null) {
n.x = n.fx;
n.y = n.fy;
n.vx = 0;
n.vy = 0;
} else {
n.vx *= 1 - this.velocityDecay;
n.vy *= 1 - this.velocityDecay;
n.x += n.vx;
n.y += n.vy;
}
}
return true;
}
}
+11 -1
View File
@@ -4,12 +4,22 @@ import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import { isWujieEnv } from "@/core/embed/wujie";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Dialog({ function Dialog({
modal,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) { }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />; // 无界嵌入时 Radix modal 会给 body 注入异常 margin-right,导致右侧空白。
// 默认降为 non-modal;显式传 modal={true} 仍可强制 modal。
return (
<DialogPrimitive.Root
data-slot="dialog"
modal={modal ?? !isWujieEnv()}
{...props}
/>
);
} }
const DialogTrigger = React.forwardRef< const DialogTrigger = React.forwardRef<
@@ -0,0 +1,39 @@
import { ChevronRight } from "lucide-react";
import { Fragment } from "react";
import { cn } from "@/lib/utils";
/** Inline, de-emphasized breadcrumb: folder path + current document title. */
export function Breadcrumb({
segments,
current,
className,
}: {
segments: string[];
current: string;
className?: string;
}) {
return (
<div
className={cn(
"flex items-center gap-1 overflow-hidden text-xs text-slate-400 dark:text-slate-500",
className,
)}
>
{segments.map((segment, i) => (
<Fragment key={`${segment}-${i}`}>
<span className="hidden shrink-0 whitespace-nowrap sm:inline">
{segment}
</span>
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
</Fragment>
))}
<span
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
title={current}
>
{current}
</span>
</div>
);
}
@@ -0,0 +1,120 @@
import { Download, Pencil, Save, Trash2, X } from "lucide-react";
import { cn } from "@/lib/utils";
function ToolbarButton({
icon,
label,
onClick,
disabled,
danger,
emphasized,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
disabled?: boolean;
danger?: boolean;
emphasized?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"inline-flex h-7 cursor-pointer items-center gap-1 rounded-md px-2 text-xs text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 disabled:cursor-not-allowed disabled:opacity-50 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200",
danger &&
"text-red-500 hover:bg-red-50 hover:text-red-600 dark:text-red-400 dark:hover:bg-red-500/10 dark:hover:text-red-300",
emphasized &&
"border border-slate-200 bg-white font-medium dark:border-slate-700 dark:bg-slate-900",
)}
>
{icon}
{label}
</button>
);
}
/** Compact inline toolbar: subtle meta (status / chars / updated) + actions. */
export function DocumentToolbar({
charCount,
updatedAt,
statusLabel,
editing,
saving,
archiving,
onEdit,
onCancelEdit,
onSave,
onExport,
onArchive,
}: {
charCount: number;
updatedAt: string;
statusLabel: string;
editing: boolean;
saving: boolean;
archiving: boolean;
onEdit: () => void;
onCancelEdit: () => void;
onSave: () => void;
onExport: () => void;
onArchive: () => void;
}) {
return (
<div className="flex shrink-0 items-center gap-1.5">
{editing ? (
<span className="inline-flex h-5 items-center rounded bg-amber-50 px-1.5 text-[11px] font-medium text-amber-600 dark:bg-amber-500/10 dark:text-amber-400">
编辑中
</span>
) : (
<span className="hidden h-5 items-center rounded bg-slate-100 px-1.5 text-[11px] text-slate-500 sm:inline-flex dark:bg-slate-800 dark:text-slate-400">
{statusLabel}
</span>
)}
<span className="hidden whitespace-nowrap text-xs text-slate-400 lg:inline dark:text-slate-500">
{charCount.toLocaleString()} 字符 · {updatedAt}
</span>
<div className="ml-1 flex items-center gap-1">
{editing ? (
<>
<ToolbarButton
icon={<X className="size-3.5" />}
label="取消"
onClick={onCancelEdit}
/>
<ToolbarButton
icon={<Save className="size-3.5" />}
label={saving ? "保存中..." : "保存"}
onClick={onSave}
disabled={saving}
emphasized
/>
</>
) : (
<>
<ToolbarButton
icon={<Pencil className="size-3.5" />}
label="编辑"
onClick={onEdit}
/>
<ToolbarButton
icon={<Download className="size-3.5" />}
label="导出"
onClick={onExport}
/>
<ToolbarButton
icon={<Trash2 className="size-3.5" />}
label="归档"
onClick={onArchive}
disabled={archiving}
danger
/>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,18 @@
/** Shown in the reading column when no document is selected. */
export function EmptyDocumentState() {
return (
<div className="flex h-full items-center justify-center bg-white px-6 text-center dark:bg-slate-900">
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-10 py-12 sm:px-12 dark:border-slate-700 dark:bg-slate-800/50">
<div className="mb-4 text-[40px] leading-none">📚</div>
<div className="mb-2 text-lg font-bold text-slate-700 dark:text-slate-200">
选择一篇文档开始阅读
</div>
<div className="text-sm leading-7 text-slate-400">
你可以从左侧目录树中选择知识文档,
<br />
也可以导入 Markdown、JSON、TXT 或文件夹来构建 LLM Wiki。
</div>
</div>
</div>
);
}
@@ -0,0 +1,90 @@
import { ListTree } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import type { TocItem } from "./wikiTypes";
/**
* Floating in-document outline: a small "目录" pill at the top-right of the
* reading area that opens a popover listing h2/h3 headings. Replaces the old
* always-visible right column. Hidden entirely when the document has too few
* headings to be worth navigating.
*/
export function FloatingToc({
items,
activeId,
onItemClick,
className,
}: {
items: TocItem[];
activeId: string | null;
onItemClick: (id: string) => void;
className?: string;
}) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
// Close when the document (and thus the heading list) changes.
useEffect(() => {
setOpen(false);
}, [items]);
// Close on click outside so the popover never lingers over the text.
useEffect(() => {
if (!open) return;
const onPointerDown = (event: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, [open]);
if (items.length < 2) return null;
return (
<div ref={rootRef} className={cn("flex flex-col items-end", className)}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className={cn(
"inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-xs shadow-sm backdrop-blur transition-colors",
open
? "border-slate-300 bg-white text-slate-700 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200"
: "border-slate-200 bg-white/90 text-slate-500 hover:text-slate-700 dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-400 dark:hover:text-slate-200",
)}
>
<ListTree className="size-3.5" />
目录
</button>
{open ? (
<nav className="mt-2 max-h-[55vh] w-64 overflow-y-auto rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl dark:border-slate-700 dark:bg-slate-900">
{items.map((item) => (
<button
key={item.id}
type="button"
title={item.text}
onClick={() => {
onItemClick(item.id);
setOpen(false);
}}
className={cn(
"block w-full cursor-pointer truncate rounded-md px-2 py-1.5 text-left text-[13px] leading-snug transition-colors",
item.level >= 3 && "pl-5",
activeId === item.id
? "bg-slate-100 font-medium text-red-600 dark:bg-slate-800 dark:text-red-400"
: "text-slate-500 hover:bg-slate-50 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-800/60 dark:hover:text-slate-200",
)}
>
{item.text}
</button>
))}
</nav>
) : null}
</div>
);
}
@@ -0,0 +1,189 @@
import {
Loader2,
PanelLeftClose,
PanelLeftOpen,
Search,
SlidersHorizontal,
} from "lucide-react";
import { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { KnowledgeNote } from "@/core/knowledge/types";
import { cn } from "@/lib/utils";
import { KnowledgeTree } from "./KnowledgeTree";
import type { TreeNode } from "./wikiTypes";
import { SOURCE_TYPE_LABELS } from "./wikiUtils";
interface KnowledgeSidebarProps {
keyword: string;
onKeywordChange: (v: string) => void;
sourceType: string;
onSourceTypeChange: (v: string) => void;
status: string;
onStatusChange: (v: string) => void;
tree: TreeNode[];
expandedIds: Set<string>;
onToggleFolder: (id: string) => void;
selectedId?: string;
onSelect: (note: KnowledgeNote) => void;
onExportNote: (note: KnowledgeNote) => void;
total: number;
isLoading: boolean;
/** Collapsed to a narrow rail (desktop only; the mobile drawer always expands). */
collapsed?: boolean;
onToggleCollapse?: () => void;
className?: string;
}
/** Left column: search + filters + directory tree, collapsible to a narrow rail. */
export function KnowledgeSidebar(props: KnowledgeSidebarProps) {
const [filtersOpen, setFiltersOpen] = useState(false);
const activeFilters =
(props.sourceType !== "all" ? 1 : 0) + (props.status !== "all" ? 1 : 0);
const searching = props.keyword.trim().length > 0;
if (props.collapsed) {
return (
<aside className="hidden min-h-0 flex-col items-center gap-1 border-r border-slate-100 bg-white py-2.5 md:flex dark:border-slate-800 dark:bg-slate-900">
<button
type="button"
onClick={props.onToggleCollapse}
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-300"
aria-label="展开目录"
title="展开目录"
>
<PanelLeftOpen className="size-4" />
</button>
<button
type="button"
onClick={props.onToggleCollapse}
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-300"
aria-label="搜索文档"
title="搜索文档"
>
<Search className="size-4" />
</button>
</aside>
);
}
return (
<aside
className={cn(
"flex min-h-0 flex-col border-r border-slate-100 bg-white dark:border-slate-800 dark:bg-slate-900",
props.className,
)}
>
<div className="border-b border-slate-100 p-2.5 dark:border-slate-800">
<div className="flex items-center gap-1.5">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
<input
value={props.keyword}
onChange={(e) => props.onKeywordChange(e.target.value)}
placeholder="搜索标题或内容..."
className="h-8 w-full rounded-lg border border-slate-200 bg-slate-50 pl-8 pr-2.5 text-[13px] text-slate-700 outline-none transition-colors placeholder:text-slate-400 focus:border-slate-400 focus:bg-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:focus:border-slate-500 dark:focus:bg-slate-900"
/>
</div>
<button
type="button"
onClick={() => setFiltersOpen((v) => !v)}
className={cn(
"relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg transition-colors",
filtersOpen || activeFilters > 0
? "bg-red-50 text-red-500 dark:bg-red-500/10 dark:text-red-400"
: "text-slate-400 hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-300",
)}
aria-label="高级筛选"
title="高级筛选"
>
<SlidersHorizontal className="size-4" />
{activeFilters > 0 ? (
<span className="absolute -right-0.5 -top-0.5 flex size-3.5 items-center justify-center rounded-full bg-red-500 text-[9px] font-bold text-white">
{activeFilters}
</span>
) : null}
</button>
{props.onToggleCollapse ? (
<button
type="button"
onClick={props.onToggleCollapse}
className="inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 max-md:hidden dark:hover:bg-slate-800 dark:hover:text-slate-300"
aria-label="收起目录"
title="收起目录"
>
<PanelLeftClose className="size-4" />
</button>
) : null}
</div>
{filtersOpen ? (
<div className="mt-2 grid grid-cols-2 gap-1.5">
<Select value={props.sourceType} onValueChange={props.onSourceTypeChange}>
<SelectTrigger className="h-8 w-full text-[13px]">
<SelectValue placeholder="来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部来源</SelectItem>
{Object.entries(SOURCE_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={props.status} onValueChange={props.onStatusChange}>
<SelectTrigger className="h-8 w-full text-[13px]">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="draft">草稿</SelectItem>
<SelectItem value="approved">已批准</SelectItem>
<SelectItem value="archived">已归档</SelectItem>
</SelectContent>
</Select>
</div>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
<div className="px-2 pb-1.5 pt-0.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400 dark:text-slate-500">
文档目录
</div>
{props.isLoading ? (
<div className="flex items-center gap-2 p-4 text-sm text-slate-400">
<Loader2 className="size-4 animate-spin" /> 加载中...
</div>
) : props.tree.length === 0 ? (
<div className="mx-2 rounded-lg border border-dashed border-slate-200 p-5 text-center text-sm text-slate-400 dark:border-slate-700">
{searching || activeFilters > 0
? "没有匹配的文档"
: "暂无知识。点击「新建知识」或在对话页「保存到知识库」。"}
</div>
) : (
<KnowledgeTree
nodes={props.tree}
selectedId={props.selectedId}
expandedIds={props.expandedIds}
forceExpand={searching}
onToggleFolder={props.onToggleFolder}
onSelect={props.onSelect}
onExportNote={props.onExportNote}
/>
)}
</div>
<div className="flex h-8 shrink-0 items-center justify-center border-t border-slate-100 text-[11px] text-slate-400 dark:border-slate-800 dark:text-slate-500">
共 {props.total} 篇文档
</div>
</aside>
);
}
@@ -0,0 +1,132 @@
import { ChevronRight, FileText, Folder, FolderOpen, MoreHorizontal } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { KnowledgeNote } from "@/core/knowledge/types";
import { cn } from "@/lib/utils";
import type { TreeNode } from "./wikiTypes";
interface KnowledgeTreeProps {
nodes: TreeNode[];
selectedId?: string;
expandedIds: Set<string>;
/** When searching, render every folder open regardless of expandedIds. */
forceExpand?: boolean;
onToggleFolder: (id: string) => void;
onSelect: (note: KnowledgeNote) => void;
onExportNote: (note: KnowledgeNote) => void;
}
function TreeRow(props: KnowledgeTreeProps & { node: TreeNode; depth: number }) {
const { node, depth, selectedId, expandedIds, forceExpand } = props;
const indent = { paddingLeft: `${6 + depth * 10}px` };
if (node.type === "folder") {
const open = forceExpand || expandedIds.has(node.id);
return (
<div>
<div
role="button"
tabIndex={0}
onClick={() => props.onToggleFolder(node.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") props.onToggleFolder(node.id);
}}
style={indent}
title={node.name}
className="group flex h-7 cursor-pointer select-none items-center gap-1 rounded-md pr-2 text-[13px] font-medium text-slate-500 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/70"
>
<ChevronRight
className={cn(
"size-3.5 shrink-0 text-slate-400 transition-transform",
open && "rotate-90",
)}
/>
{open ? (
<FolderOpen className="size-3.5 shrink-0 text-slate-400 dark:text-slate-500" />
) : (
<Folder className="size-3.5 shrink-0 text-slate-400 dark:text-slate-500" />
)}
<span className="flex-1 truncate">{node.name}</span>
<span className="text-[11px] font-normal text-slate-300 opacity-0 group-hover:opacity-100 dark:text-slate-600">
{node.children?.length ?? 0}
</span>
</div>
{open && node.children ? (
<div>
{node.children.map((child) => (
<TreeRow key={child.id} {...props} node={child} depth={depth + 1} />
))}
</div>
) : null}
</div>
);
}
const active = node.id === selectedId;
return (
<div
role="button"
tabIndex={0}
onClick={() => node.item && props.onSelect(node.item)}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && node.item) props.onSelect(node.item);
}}
style={indent}
className={cn(
"group flex h-7 cursor-pointer select-none items-center gap-1.5 rounded-md border-l-2 border-transparent pr-1 text-[13px]",
active
? "border-red-500 bg-slate-100 font-medium text-slate-900 dark:bg-slate-800 dark:text-slate-100"
: "text-slate-500 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/70",
)}
>
<FileText
className={cn(
"size-3.5 shrink-0",
active ? "text-red-500 dark:text-red-400" : "text-slate-400 dark:text-slate-500",
)}
/>
<span className="flex-1 truncate" title={node.name}>
{node.name}
</span>
{node.item ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="inline-flex size-5 shrink-0 items-center justify-center rounded text-slate-400 opacity-0 hover:bg-slate-200/60 group-hover:opacity-100 data-[state=open]:opacity-100 dark:hover:bg-slate-700"
aria-label="更多操作"
>
<MoreHorizontal className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onClick={() => props.onSelect(node.item!)}>
打开文档
</DropdownMenuItem>
<DropdownMenuItem onClick={() => props.onExportNote(node.item!)}>
导出 Markdown
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
);
}
/** Recursive folder/document tree for the wiki sidebar. */
export function KnowledgeTree(props: KnowledgeTreeProps) {
return (
<div className="flex flex-col gap-px">
{props.nodes.map((node) => (
<TreeRow key={node.id} {...props} node={node} depth={0} />
))}
</div>
);
}
@@ -0,0 +1,49 @@
import { useEffect, useRef } from "react";
import MarkdownRenderer, {
type WikiLinkMap,
type WikiLinkTarget,
} from "@/strategy-components/MarkdownRenderer";
import type { TocItem } from "./wikiTypes";
import "./wiki.css";
/**
* Renders the note body via the shared MarkdownRenderer and mounts the toc ids
* onto the rendered h1–h4 elements so outline clicks / scroll-spy can target them.
* extractToc and this component walk headings in the same document order, so a
* positional mapping is sufficient.
*/
export function MarkdownViewer({
content,
toc,
wikiLinks,
onWikiLinkClick,
}: {
content: string;
toc: TocItem[];
wikiLinks?: WikiLinkMap;
onWikiLinkClick?: (link: WikiLinkTarget) => void;
}) {
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const headings = root.querySelectorAll<HTMLElement>("h1, h2, h3, h4");
headings.forEach((el, index) => {
const item = toc[index];
if (item) el.id = item.id;
});
}, [content, toc]);
return (
<div ref={rootRef} className="wiki-markdown">
<MarkdownRenderer
content={content}
wikiLinks={wikiLinks}
onWikiLinkClick={onWikiLinkClick}
/>
</div>
);
}
@@ -0,0 +1,120 @@
import { FileText, Link2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type {
WikiLinkMap,
WikiLinkTarget,
} from "@/strategy-components/MarkdownRenderer";
import type { RelatedLink } from "./wikiUtils";
/**
* Compact "相关文档" module rendered from the note's `## Related` wikilinks.
* Each entry is resolved through the backend wikilink map so the visible
* title always matches the actual navigation target; duplicates (same note)
* are collapsed and unresolved targets are shown muted and non-clickable.
*/
export function RelatedDocs({
links,
wikiLinks,
onOpen,
}: {
links: RelatedLink[];
wikiLinks?: WikiLinkMap;
onOpen: (link: WikiLinkTarget) => void;
}) {
if (links.length === 0) return null;
// Resolving is still in flight — render nothing rather than dead links.
const resolving = !wikiLinks;
const seen = new Set<string>();
const entries: { link: RelatedLink; resolved?: WikiLinkTarget }[] = [];
for (const link of links) {
const resolved = wikiLinks?.[link.target];
const key =
resolved?.note_id ?? resolved?.vault_path ?? link.target.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
entries.push({ link, resolved });
}
if (entries.length === 0) return null;
return (
<section className="mt-10 border-t border-slate-100 pt-5 dark:border-slate-800">
<h2 className="mb-3 flex items-center gap-1.5 text-sm font-semibold text-slate-600 dark:text-slate-300">
<Link2 className="size-3.5 text-slate-400" />
相关文档
<span className="text-xs font-normal text-slate-400">
{entries.length}
</span>
</h2>
<div className="grid gap-2 sm:grid-cols-2">
{entries.map(({ link, resolved }) => {
const label = link.alias || resolved?.title || link.target;
const clickable = !!resolved?.exists;
const body = (
<>
<FileText
className={cn(
"size-4 shrink-0",
clickable
? "text-slate-400 transition-colors group-hover:text-red-500"
: "text-slate-300 dark:text-slate-600",
)}
/>
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[13px] font-medium",
clickable
? "text-slate-700 dark:text-slate-200"
: "text-slate-400 dark:text-slate-500",
)}
>
{label}
</span>
{resolved?.vault_path ? (
<span className="block truncate text-[11px] text-slate-400 dark:text-slate-500">
{resolved.vault_path}
</span>
) : null}
</span>
</>
);
if (clickable) {
return (
<button
key={link.target}
type="button"
title={label}
onClick={() => resolved && onOpen(resolved)}
className="group flex cursor-pointer items-center gap-2.5 rounded-lg border border-slate-200 px-3 py-2 text-left transition-colors hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700/80 dark:hover:border-slate-600 dark:hover:bg-slate-800/60"
>
{body}
</button>
);
}
return (
<div
key={link.target}
title={resolving ? label : `${label}(未收录)`}
className={cn(
"flex items-center gap-2.5 rounded-lg border border-dashed border-slate-200 px-3 py-2 dark:border-slate-700/80",
!resolving && "opacity-70",
)}
>
{body}
{!resolving ? (
<span className="shrink-0 text-[11px] text-slate-300 dark:text-slate-600">
未收录
</span>
) : null}
</div>
);
})}
</div>
</section>
);
}
@@ -0,0 +1,378 @@
import { ExternalLink, Sparkles } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useKnowledgeActions } from "@/core/knowledge/hooks";
import type { KnowledgeNote } from "@/core/knowledge/types";
import { cn } from "@/lib/utils";
import type {
WikiLinkMap,
WikiLinkTarget,
} from "@/strategy-components/MarkdownRenderer";
import { Breadcrumb } from "./Breadcrumb";
import { DocumentToolbar } from "./DocumentToolbar";
import { FloatingToc } from "./FloatingToc";
import { MarkdownViewer } from "./MarkdownViewer";
import { RelatedDocs } from "./RelatedDocs";
import {
SOURCE_TYPE_LABELS,
STATUS_LABELS,
downloadMarkdown,
extractToc,
formatDateTime,
noteBreadcrumbSegments,
splitRelatedSection,
} from "./wikiUtils";
const ENTITY_TYPE_META: Record<string, { label: string; className: string }> = {
person: { label: "人物", className: "bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-500/15 dark:text-rose-300 dark:border-rose-500/30" },
org: { label: "机构", className: "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30" },
product: { label: "产品", className: "bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30" },
technology: { label: "技术", className: "bg-cyan-50 text-cyan-700 border-cyan-200 dark:bg-cyan-500/15 dark:text-cyan-300 dark:border-cyan-500/30" },
concept: { label: "概念", className: "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30" },
region: { label: "地区", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
country: { label: "国家", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
};
function entityMeta(type?: string | null) {
return (
ENTITY_TYPE_META[(type || "").toLowerCase()] ?? {
label: "其他",
className:
"bg-slate-100 text-slate-600 border-slate-200 dark:bg-slate-700/40 dark:text-slate-300 dark:border-slate-600",
}
);
}
/** Human label for an audit actor: prefer the resolved name (email), else a short id. */
function actorLabel(name?: string | null, id?: string | null): string {
if (name && name.trim()) return name.trim();
if (id && id.trim()) return `${id.trim().slice(0, 8)}…`;
return "—";
}
function MetaTag({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex h-6 items-center rounded-md bg-slate-100 px-2 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
{children}
</span>
);
}
/** Middle column: compact header + markdown reading area (or inline editor). */
export function WikiContent({
note,
wikiLinks,
onWikiLinkClick,
}: {
note: KnowledgeNote;
wikiLinks?: WikiLinkMap;
onWikiLinkClick: (link: WikiLinkTarget) => void;
}) {
const navigate = useNavigate();
const { update, remove } = useKnowledgeActions();
const scrollRef = useRef<HTMLDivElement>(null);
const tickingRef = useRef(false);
const [editing, setEditing] = useState(false);
const [draftTitle, setDraftTitle] = useState(note.title);
const [draftTags, setDraftTags] = useState(note.tags.join(", "));
const [draftContent, setDraftContent] = useState(note.content_md);
const [draftStatus, setDraftStatus] = useState<string>(note.status);
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
// Pull the backend-injected `## Related` wikilinks out of the body so they
// render as a structured module; editing still works on the full source.
const { body, related } = useMemo(
() => splitRelatedSection(note.content_md),
[note.content_md],
);
const toc = useMemo(() => extractToc(body), [body]);
// The floating outline only shows h2/h3 — deeper levels are noise.
const outlineItems = useMemo(
() => toc.filter((item) => item.level === 2 || item.level === 3),
[toc],
);
useEffect(() => {
setEditing(false);
setDraftTitle(note.title);
setDraftTags(note.tags.join(", "));
setDraftContent(note.content_md);
setDraftStatus(note.status);
scrollRef.current?.scrollTo({ top: 0 });
setActiveHeadingId(null);
// Re-sync drafts whenever a different note (or fresh server copy) arrives.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [note.id, note.updated_at]);
const handleScroll = () => {
if (tickingRef.current) return;
tickingRef.current = true;
window.requestAnimationFrame(() => {
tickingRef.current = false;
const container = scrollRef.current;
if (!container) return;
const headings = container.querySelectorAll<HTMLElement>(
".wiki-markdown h1[id], .wiki-markdown h2[id], .wiki-markdown h3[id], .wiki-markdown h4[id]",
);
if (headings.length === 0) return;
const containerTop = container.getBoundingClientRect().top;
let current: string | null = headings[0].id;
for (const el of headings) {
if (el.getBoundingClientRect().top - containerTop <= 96) {
current = el.id;
} else {
break;
}
}
setActiveHeadingId(current);
});
};
const handleTocClick = (id: string) => {
setActiveHeadingId(id);
document
.getElementById(id)
?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const handleSave = async () => {
try {
await update.mutateAsync({
noteId: note.id,
data: {
title: draftTitle.trim() || note.title,
content_md: draftContent,
tags: draftTags
.split(/[,,\s]+/)
.map((t) => t.trim())
.filter(Boolean),
status: draftStatus,
},
});
toast.success("已保存");
setEditing(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleArchive = async () => {
try {
await remove.mutateAsync({ noteId: note.id });
toast.success("已归档");
navigate("/page/workspace/knowledge");
} catch (error) {
toast.error(error instanceof Error ? error.message : "归档失败");
}
};
return (
<div className="flex min-h-0 min-w-0 flex-col bg-white dark:bg-slate-900">
<header className="flex h-12 shrink-0 items-center gap-3 border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900">
<Breadcrumb
segments={noteBreadcrumbSegments(note)}
current={note.title}
className="min-w-0 flex-1"
/>
<DocumentToolbar
charCount={note.content_md.length}
updatedAt={formatDateTime(note.updated_at)}
statusLabel={STATUS_LABELS[note.status] ?? note.status}
editing={editing}
saving={update.isPending}
archiving={remove.isPending}
onEdit={() => setEditing(true)}
onCancelEdit={() => setEditing(false)}
onSave={() => void handleSave()}
onExport={() => downloadMarkdown(note)}
onArchive={() => void handleArchive()}
/>
</header>
<div className="relative min-h-0 flex-1">
{!editing ? (
<FloatingToc
items={outlineItems}
activeId={activeHeadingId}
onItemClick={handleTocClick}
className="absolute right-4 top-3 z-10 sm:right-6"
/>
) : null}
<div
ref={scrollRef}
onScroll={editing ? undefined : handleScroll}
className="h-full overflow-y-auto"
>
<div className="mx-auto w-full max-w-[980px] px-5 pb-28 pt-8 sm:px-8 lg:px-10">
{editing ? (
<div className="flex flex-col gap-3">
<Input
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
placeholder="标题"
/>
<div className="flex items-center gap-2">
<Input
value={draftTags}
onChange={(e) => setDraftTags(e.target.value)}
placeholder="标签,逗号分隔"
/>
<Select value={draftStatus} onValueChange={setDraftStatus}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">草稿</SelectItem>
<SelectItem value="approved">已批准</SelectItem>
<SelectItem value="archived">已归档</SelectItem>
</SelectContent>
</Select>
</div>
<Textarea
rows={24}
value={draftContent}
onChange={(e) => setDraftContent(e.target.value)}
className="font-mono text-sm"
/>
</div>
) : (
<>
<h1 className="mb-2 text-2xl font-bold leading-snug text-slate-900 dark:text-slate-100">
{note.title}
</h1>
{note.summary ? (
<div className="my-4 rounded-lg border-l-2 border-slate-200 bg-slate-50 px-4 py-3 text-sm leading-7 text-slate-600 dark:border-slate-700 dark:bg-slate-800/50 dark:text-slate-300">
{note.summary}
</div>
) : null}
<div className="mb-7 mt-3 flex flex-wrap items-center gap-1.5">
<MetaTag>
来源:{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
</MetaTag>
<MetaTag>状态:{STATUS_LABELS[note.status] ?? note.status}</MetaTag>
{note.created_at ? (
<MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag>
) : null}
{note.tags.map((tag) => (
<span
key={tag}
className="inline-flex h-6 items-center rounded-md bg-rose-50 px-2 text-xs font-medium text-rose-500 dark:bg-rose-500/10 dark:text-rose-300"
>
#{tag}
</span>
))}
</div>
{note.entities && note.entities.length > 0 ? (
<div className="mb-7 flex flex-wrap items-center gap-1.5">
<span className="mr-0.5 inline-flex items-center gap-1 text-xs text-slate-400">
<Sparkles className="size-3" /> 实体
</span>
{note.entities.map((ent) => {
const meta = entityMeta(ent.entity_type);
return (
<span
key={`${ent.name}-${ent.entity_type ?? ""}`}
title={meta.label}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium",
meta.className,
)}
>
<span className="size-1.5 shrink-0 rounded-full bg-current opacity-70" />
<span>{ent.name}</span>
<span className="rounded-full bg-current/15 px-1.5 py-0.5 text-[10px] leading-none opacity-90">
{meta.label}
</span>
</span>
);
})}
</div>
) : null}
<MarkdownViewer
content={body}
toc={toc}
wikiLinks={wikiLinks}
onWikiLinkClick={onWikiLinkClick}
/>
<RelatedDocs
links={related}
wikiLinks={wikiLinks}
onOpen={onWikiLinkClick}
/>
{note.sources && note.sources.length > 0 ? (
<div className="mt-10 border-t border-slate-100 pt-5 dark:border-slate-800">
<h2 className="mb-3 text-sm font-semibold text-slate-600 dark:text-slate-300">
来源
</h2>
<ul className="flex flex-col gap-1.5">
{note.sources.map((src) => (
<li key={src.id} className="text-[13px]">
{src.url ? (
<a
href={src.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-blue-600 hover:underline dark:text-blue-400"
>
{src.title || src.url}
<ExternalLink className="size-3" />
</a>
) : src.thread_id ? (
<button
type="button"
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
onClick={() =>
navigate(`/page/workspace/chats/${src.thread_id}`)
}
>
{src.title || `对话 ${src.thread_id.slice(0, 8)}`}
</button>
) : (
<span className="text-slate-600 dark:text-slate-300">
{src.title || src.tool_name || "来源"}
</span>
)}
{src.snippet ? (
<p className="mt-0.5 line-clamp-2 text-xs text-slate-400">
{src.snippet}
</p>
) : null}
</li>
))}
</ul>
</div>
) : null}
<div className="mt-12 border-t border-slate-100 pt-3 text-[12px] text-slate-400/90 dark:border-slate-800 dark:text-slate-500">
创建人 {actorLabel(note.created_by_name, note.created_by)} · 更新人{" "}
{actorLabel(note.updated_by_name, note.updated_by)} · 更新于{" "}
{formatDateTime(note.updated_at)}
</div>
</>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,112 @@
import {
BookOpen,
Copy,
Menu,
Network,
Plus,
RefreshCw,
Upload,
} from "lucide-react";
import { cn } from "@/lib/utils";
function HeaderButton({
icon,
label,
onClick,
variant = "outline",
className,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
variant?: "outline" | "primary";
className?: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg border px-3 text-sm transition-colors",
variant === "primary"
? "border-red-600 bg-red-600 text-white hover:bg-red-700"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800",
className,
)}
>
{icon}
<span className="hidden sm:inline">{label}</span>
</button>
);
}
/** Light 56px wiki header: logo + product name on the left, action entries on the right. */
export function WikiHeader({
onToggleSidebar,
onImportFile,
onBatchImport,
onDedup,
onGraph,
onCreate,
}: {
onToggleSidebar: () => void;
onImportFile: () => void;
onBatchImport: () => void;
onDedup: () => void;
onGraph: () => void;
onCreate: () => void;
}) {
return (
<header className="flex h-14 shrink-0 items-center justify-between border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center gap-3">
<button
type="button"
onClick={onToggleSidebar}
className="inline-flex size-9 items-center justify-center rounded-lg text-slate-500 hover:bg-slate-100 md:hidden dark:text-slate-400 dark:hover:bg-slate-800"
aria-label="打开目录"
>
<Menu className="size-5" />
</button>
<div className="flex size-9 items-center justify-center rounded-[9px] bg-red-600 text-white">
<BookOpen className="size-5" />
</div>
<div className="leading-none">
<div className="text-base font-extrabold tracking-tight text-slate-900 dark:text-slate-100">
问答梳理
</div>
<div className="mt-1 text-xs text-emerald-500">LLM Wiki · 问答梳理中心</div>
</div>
</div>
<div className="flex items-center gap-2">
<HeaderButton
icon={<Upload className="size-4" />}
label="导入文件"
onClick={onImportFile}
/>
<HeaderButton
icon={<RefreshCw className="size-4" />}
label="批量导入"
onClick={onBatchImport}
/>
<HeaderButton
icon={<Copy className="size-4" />}
label="查重"
onClick={onDedup}
/>
<HeaderButton
icon={<Network className="size-4" />}
label="图谱"
onClick={onGraph}
/>
<HeaderButton
icon={<Plus className="size-4" />}
label="新建知识"
onClick={onCreate}
variant="primary"
/>
</div>
</header>
);
}
+168
View File
@@ -0,0 +1,168 @@
/* Wiki reading-area typography: overrides the generic MarkdownRenderer classes
inside .wiki-markdown to get a document-site reading experience. */
.wiki-markdown h1 {
font-size: 26px;
line-height: 1.4;
font-weight: 700;
color: #111827;
margin: 0 0 20px;
}
.wiki-markdown h2 {
font-size: 20px;
font-weight: 700;
line-height: 1.4;
margin: 36px 0 14px;
padding-bottom: 8px;
border-bottom: 1px solid #eef1f5;
color: #111827;
}
.wiki-markdown h3 {
font-size: 17px;
font-weight: 600;
margin: 26px 0 10px;
color: #1f2937;
}
.wiki-markdown h4 {
font-size: 15.5px;
font-weight: 600;
margin: 22px 0 8px;
color: #334155;
}
.wiki-markdown p {
font-size: 15.5px;
line-height: 1.75;
color: #334155;
margin-bottom: 14px;
}
.wiki-markdown ul,
.wiki-markdown ol {
padding-left: 22px;
margin-bottom: 14px;
}
.wiki-markdown li {
margin: 5px 0;
line-height: 1.75;
color: #334155;
font-size: 15.5px;
}
.wiki-markdown li > ul,
.wiki-markdown li > ol {
margin-top: 5px;
margin-bottom: 5px;
}
.wiki-markdown strong {
color: #111827;
font-weight: 600;
}
.wiki-markdown a {
color: #2563eb;
text-decoration: none;
}
.wiki-markdown a:hover {
text-decoration: underline;
}
.wiki-markdown blockquote {
border-left: 3px solid #e2e8f0;
background: #f8fafc;
padding: 10px 16px;
color: #475569;
margin: 16px 0;
border-radius: 0 8px 8px 0;
font-style: normal;
}
.wiki-markdown blockquote p {
margin-bottom: 0;
}
.wiki-markdown table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
font-size: 14px;
}
.wiki-markdown th,
.wiki-markdown td {
border: 1px solid #e8ecf1;
padding: 8px 12px;
}
.wiki-markdown th {
background: #f8fafc;
font-weight: 600;
color: #334155;
}
.wiki-markdown hr {
margin: 28px 0;
border-color: #eef1f5;
}
/* Anchor target offset so headings clear the floating "目录" pill. */
.wiki-markdown h1,
.wiki-markdown h2,
.wiki-markdown h3,
.wiki-markdown h4 {
scroll-margin-top: 24px;
}
/* Dark theme adjustments (app toggles the .dark class on a root element). */
.dark .wiki-markdown h1,
.dark .wiki-markdown h2 {
color: #f1f5f9;
}
.dark .wiki-markdown h2 {
border-bottom-color: #1f2937;
}
.dark .wiki-markdown h3,
.dark .wiki-markdown h4 {
color: #e2e8f0;
}
.dark .wiki-markdown p,
.dark .wiki-markdown li {
color: #cbd5e1;
}
.dark .wiki-markdown strong {
color: #f8fafc;
}
.dark .wiki-markdown a {
color: #60a5fa;
}
.dark .wiki-markdown blockquote {
background: #1e293b;
border-left-color: #475569;
color: #cbd5e1;
}
.dark .wiki-markdown th {
background: #1e293b;
color: #e2e8f0;
}
.dark .wiki-markdown th,
.dark .wiki-markdown td {
border-color: #334155;
}
.dark .wiki-markdown hr {
border-color: #334155;
}
@@ -0,0 +1,17 @@
import type { KnowledgeNote } from "@/core/knowledge/types";
/** One entry of the in-document outline, parsed from markdown headings (h1–h4). */
export interface TocItem {
id: string;
text: string;
level: number;
}
/** A node of the left-hand knowledge directory tree. */
export interface TreeNode {
id: string;
name: string;
type: "folder" | "file";
children?: TreeNode[];
item?: KnowledgeNote;
}
@@ -0,0 +1,236 @@
import type { KnowledgeNote } from "@/core/knowledge/types";
import type { TocItem, TreeNode } from "./wikiTypes";
export const SOURCE_TYPE_LABELS: Record<string, string> = {
thread: "对话沉淀",
search: "检索沉淀",
tool: "工具沉淀",
file: "文件导入",
manual: "手动创建",
report: "研究报告",
};
export const STATUS_LABELS: Record<string, string> = {
draft: "草稿",
approved: "已批准",
archived: "已归档",
};
const FALLBACK_FOLDER = "未分类";
/** Stable slug for a heading: keeps CJK characters, collapses whitespace to dashes. */
export function slugify(text: string): string {
return text
.trim()
.toLowerCase()
.replace(/[#`*_~[\]()]/g, "")
.replace(/\s+/g, "-")
.replace(/[^\w一-龥-]/g, "");
}
/**
* Parse h1–h4 headings out of raw markdown (skipping fenced code blocks) and
* assign each a stable id; duplicate titles get a numeric suffix. The same id
* sequence is mounted onto the rendered headings by MarkdownViewer.
*/
export function extractToc(markdown: string): TocItem[] {
const usedIds = new Map<string, number>();
const items: TocItem[] = [];
let inFence = false;
for (const line of markdown.split("\n")) {
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence;
continue;
}
if (inFence) continue;
const match = /^(#{1,4})\s+(.+)$/.exec(line);
if (!match) continue;
const level = match[1].length;
const text = match[2].replace(/[#`*_~]/g, "").trim();
const baseId = slugify(text) || "heading";
const count = usedIds.get(baseId) ?? 0;
usedIds.set(baseId, count + 1);
items.push({ id: count === 0 ? baseId : `${baseId}-${count + 1}`, text, level });
}
return items;
}
/** One entry parsed from the note's trailing `## Related` wikilink list. */
export interface RelatedLink {
target: string;
alias?: string;
}
/**
* Split the backend-injected `## Related` section (a plain list of
* `- [[Title]]` bullets, see knowledge/service.py `_inject_related_notes`)
* out of the markdown body so it can be rendered as a structured module
* instead of a loose bullet list. If the section contains anything other
* than wikilink bullets, the body is returned untouched (no data loss).
*/
export function splitRelatedSection(markdown: string): {
body: string;
related: RelatedLink[];
} {
const lines = markdown.split("\n");
let inFence = false;
let start = -1;
for (let i = 0; i < lines.length; i++) {
if (/^\s*(```|~~~)/.test(lines[i])) {
inFence = !inFence;
continue;
}
if (inFence) continue;
if (/^##\s+(Related|相关文档|相关)\s*$/i.test(lines[i].trim())) {
start = i;
break;
}
}
if (start === -1) return { body: markdown, related: [] };
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^#{1,2}\s+/.test(lines[i])) {
end = i;
break;
}
}
const related: RelatedLink[] = [];
for (const raw of lines.slice(start + 1, end)) {
const line = raw.trim();
if (!line) continue;
const match = /^[-*]\s*\[\[([^\]\n|]+)(?:\|([^\]\n]+))?\]\]\s*$/.exec(line);
if (!match) return { body: markdown, related: [] };
const target = match[1].trim();
if (!related.some((r) => r.target === target)) {
related.push({ target, alias: match[2]?.trim() || undefined });
}
}
if (related.length === 0) return { body: markdown, related: [] };
const body = [...lines.slice(0, start), ...lines.slice(end)]
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trimEnd();
return { body, related };
}
/** Folder path segments for a note: vault_path directories, else its source-type group. */
export function noteFolderSegments(note: KnowledgeNote): string[] {
const rawPath = note.vault_path?.replace(/\\/g, "/").replace(/^\/+/, "");
if (rawPath && rawPath.includes("/")) {
let segments = rawPath.split("/").slice(0, -1).filter(Boolean);
// Vault notes live under projects/<project>/<category>/ — the first two
// levels are constant scaffolding, so hide them from the directory tree.
if (segments[0] === "projects" || segments[0] === "project") {
segments = segments.slice(2);
}
if (segments.length > 0) return segments;
}
return [SOURCE_TYPE_LABELS[note.source_type] ?? FALLBACK_FOLDER];
}
/** Breadcrumb segments for a note (folders only; the title is appended by the caller). */
export function noteBreadcrumbSegments(note: KnowledgeNote): string[] {
return ["问答梳理", ...noteFolderSegments(note)];
}
function sortTree(nodes: TreeNode[]): void {
nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
return a.name.localeCompare(b.name, "zh-CN");
});
for (const node of nodes) {
if (node.children) sortTree(node.children);
}
}
/**
* Build the directory tree from the flat note list. Notes with a vault_path
* become nested folders mirroring that path; others are grouped by source type.
*/
export function buildKnowledgeTree(notes: KnowledgeNote[]): TreeNode[] {
const root: TreeNode[] = [];
const folderMap = new Map<string, TreeNode>();
const ensureFolder = (segments: string[]): TreeNode[] => {
let children = root;
let path = "";
for (const segment of segments) {
path = path ? `${path}/${segment}` : segment;
let node = folderMap.get(path);
if (!node) {
node = { id: `folder:${path}`, name: segment, type: "folder", children: [] };
folderMap.set(path, node);
children.push(node);
}
children = node.children!;
}
return children;
};
for (const note of notes) {
ensureFolder(noteFolderSegments(note)).push({
id: note.id,
name: note.title || "未命名文档",
type: "file",
item: note,
});
}
sortTree(root);
return root;
}
/** Ids of every folder in the tree (used by "expand all" while searching). */
export function collectFolderIds(nodes: TreeNode[]): string[] {
const ids: string[] = [];
const walk = (list: TreeNode[]) => {
for (const node of list) {
if (node.type === "folder") {
ids.push(node.id);
if (node.children) walk(node.children);
}
}
};
walk(nodes);
return ids;
}
/** Folder ids on the path from the root down to the given note, or null if absent. */
export function findAncestorFolderIds(
nodes: TreeNode[],
noteId: string,
): string[] | null {
for (const node of nodes) {
if (node.type === "file" && node.id === noteId) return [];
if (node.type === "folder" && node.children) {
const sub = findAncestorFolderIds(node.children, noteId);
if (sub) return [node.id, ...sub];
}
}
return null;
}
export function formatDateTime(value?: string | null): string {
return value ? value.slice(0, 19).replace("T", " ") : "—";
}
export function formatDate(value?: string | null): string {
return value ? value.slice(0, 10) : "—";
}
/** Trigger a client-side download of the note body as a .md file. */
export function downloadMarkdown(note: KnowledgeNote): void {
const blob = new Blob([note.content_md], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${(note.title || "knowledge").replace(/[\\/:*?"<>|]/g, "_")}.md`;
anchor.click();
URL.revokeObjectURL(url);
}
@@ -6,6 +6,14 @@ import { type Agent } from "@/core/agents";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/**
* Landing (home) hero shown above the input box before the first message of an
* agent conversation: centered avatar, a "Hi,我是{name}" greeting and the
* agent's description as a slogan. The description is clamped to two lines
* (full text on hover) so a long blurb can never push the input box below the
* fold. The agent's 常用问题 are rendered by the page as a list beside the
* input box, not here.
*/
export function AgentWelcome({ export function AgentWelcome({
className, className,
agent, agent,
@@ -23,16 +31,23 @@ export function AgentWelcome({
return ( return (
<div <div
className={cn( className={cn(
"mx-auto flex w-full flex-col items-center justify-center gap-2 px-8 py-4 text-center", "mx-auto flex w-full max-w-2xl flex-col items-center justify-center gap-3 px-6 text-center",
className, className,
)} )}
> >
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-full"> <div className="bg-primary/10 ring-primary/15 mb-2 flex h-[72px] w-[72px] items-center justify-center rounded-3xl ring-1">
<BotIcon className="text-primary h-6 w-6" /> <BotIcon className="text-primary h-9 w-9" />
</div>
<div className="text-3xl font-bold tracking-tight">
Hi,我是{displayName}
</div> </div>
<div className="text-2xl font-bold">{displayName}</div>
{description && ( {description && (
<p className="text-muted-foreground max-w-sm text-sm">{description}</p> <p
className="text-muted-foreground line-clamp-2 max-w-xl text-base leading-relaxed"
title={description}
>
{description}
</p>
)} )}
</div> </div>
); );
@@ -4,11 +4,9 @@ import {
BotIcon, BotIcon,
CopyIcon, CopyIcon,
GlobeIcon, GlobeIcon,
InfoIcon,
LockIcon, LockIcon,
MessageSquareIcon, MessageSquareIcon,
PinIcon, PinIcon,
SearchIcon,
ShieldIcon, ShieldIcon,
SparklesIcon, SparklesIcon,
SquarePenIcon, SquarePenIcon,
@@ -47,12 +45,24 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { usePublishSquares } from "@/core/system-settings/hooks";
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog"; import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
import { TagChipList } from "@/components/workspace/tags/tag-chip"; import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog"; import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { import {
normalizeAgentQuestions,
useAgent, useAgent,
useCreateAgent, useCreateAgent,
useDeleteAgent, useDeleteAgent,
@@ -63,7 +73,7 @@ import {
useUpdateAgent, useUpdateAgent,
} from "@/core/agents"; } from "@/core/agents";
import { getAgent } from "@/core/agents/api"; import { getAgent } from "@/core/agents/api";
import type { Agent } from "@/core/agents"; import type { Agent, AgentQuestion, UpdateAgentRequest } from "@/core/agents";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
@@ -207,12 +217,21 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
const [editName, setEditName] = useState(agent.name); const [editName, setEditName] = useState(agent.name);
const [editDescription, setEditDescription] = useState(agent.description ?? ""); const [editDescription, setEditDescription] = useState(agent.description ?? "");
const [editSoul, setEditSoul] = useState(agent.soul ?? ""); const [editSoul, setEditSoul] = useState(agent.soul ?? "");
// Common questions (常用问题) — primary editing now lives in the agent chat
// page's right-hand AgentDetailPanel; this dialog stays as a backup entry.
// Each row has a short title (shown on the chip) and the full prompt sent
// when clicked.
const [editCommonQuestions, setEditCommonQuestions] = useState<AgentQuestion[]>(
() => normalizeAgentQuestions(agent.common_questions),
);
const [editPublished, setEditPublished] = useState(agent.published); const [editPublished, setEditPublished] = useState(agent.published);
const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private); const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private);
const [editSquareId, setEditSquareId] = useState(agent.square_id ?? "");
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
const { data: publishSquares } = usePublishSquares();
const [selectedSkills, setSelectedSkills] = useState<string[]>( const [selectedSkills, setSelectedSkills] = useState<string[]>(
agent.skills ?? [], agent.skills ?? [],
); );
const [skillSearch, setSkillSearch] = useState("");
const [detailSkillName, setDetailSkillName] = useState<string | null>(null); const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
// The /api/agents list response intentionally omits `soul` (perf optimization // The /api/agents list response intentionally omits `soul` (perf optimization
@@ -249,13 +268,16 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
setEditDescription(agent.description ?? ""); setEditDescription(agent.description ?? "");
setEditPublished(agent.published); setEditPublished(agent.published);
setEditIsPrivate(agent.is_private); setEditIsPrivate(agent.is_private);
setEditSquareId(agent.square_id ?? "");
setSelectedSkills(agent.skills ?? []); setSelectedSkills(agent.skills ?? []);
setSkillSearch(""); setEditCommonQuestions(normalizeAgentQuestions(agent.common_questions));
}, [ }, [
agent.common_questions,
agent.description, agent.description,
agent.is_private, agent.is_private,
agent.name, agent.name,
agent.published, agent.published,
agent.square_id,
agent.skills, agent.skills,
editOpen, editOpen,
]); ]);
@@ -269,6 +291,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
// include `soul`) keep working. // include `soul`) keep working.
if (agentDetail) { if (agentDetail) {
setEditSoul(agentDetail.soul ?? ""); setEditSoul(agentDetail.soul ?? "");
setEditCommonQuestions(normalizeAgentQuestions(agentDetail.common_questions));
} else { } else {
setEditSoul(agent.soul ?? ""); setEditSoul(agent.soul ?? "");
} }
@@ -333,20 +356,40 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
toast.warning("私密广场与公开发布互斥,请只勾选其一"); toast.warning("私密广场与公开发布互斥,请只勾选其一");
return; return;
} }
try {
await updateAgent.mutateAsync({
id: agent.id,
request: {
name: trimmedName,
description: editDescription,
soul: editSoul,
// Private agents cannot also be published — force the value the // Private agents cannot also be published — force the value the
// backend will see, regardless of any stale state in editPublished. // backend will see, regardless of any stale state in editPublished.
published: editIsPrivate ? false : editPublished, const nextPublished = editIsPrivate ? false : editPublished;
is_private: editIsPrivate, const nextQuestions = editCommonQuestions
skills: selectedSkills, .map((q) => ({
}, title: q.title.trim() || q.prompt.trim(),
}); prompt: q.prompt.trim() || q.title.trim(),
}))
.filter((q) => q.prompt.length > 0);
// Only send the fields that actually changed: the backend merges partial
// updates (model_fields_set), so e.g. a 常用问题 edit no longer rewrites
// skills/SOUL and saving stays fast.
const baselineQuestions = normalizeAgentQuestions(
agentDetail?.common_questions ?? agent.common_questions,
);
const request: UpdateAgentRequest = {};
if (trimmedName !== agent.name) request.name = trimmedName;
if (editDescription !== (agent.description ?? "")) request.description = editDescription;
if (editSoul !== (agentDetail?.soul ?? agent.soul ?? "")) request.soul = editSoul;
if (nextPublished !== agent.published) request.published = nextPublished;
if (editIsPrivate !== agent.is_private) request.is_private = editIsPrivate;
if ((editSquareId || "") !== (agent.square_id ?? "")) request.square_id = editSquareId;
if (JSON.stringify(selectedSkills) !== JSON.stringify(agent.skills ?? []))
request.skills = selectedSkills;
if (JSON.stringify(nextQuestions) !== JSON.stringify(baselineQuestions))
request.common_questions = nextQuestions;
if (Object.keys(request).length === 0) {
setEditOpen(false);
return;
}
try {
await updateAgent.mutateAsync({ id: agent.id, request });
toast.success("智能体已更新"); toast.success("智能体已更新");
setEditOpen(false); setEditOpen(false);
} catch (err) { } catch (err) {
@@ -362,6 +405,12 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
toast.warning("该智能体已加入私密广场,不可发布"); toast.warning("该智能体已加入私密广场,不可发布");
return; return;
} }
// Publishing: prompt for which square to publish into (when any squares are
// configured). Unpublishing: just flip it off.
if (!agent.published && (publishSquares?.squares.length ?? 0) > 0) {
setPublishPickerOpen(true);
return;
}
try { try {
await updateAgent.mutateAsync({ await updateAgent.mutateAsync({
id: agent.id, id: agent.id,
@@ -373,6 +422,21 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
} }
} }
async function handlePublishToSquare(squareId: string) {
try {
await updateAgent.mutateAsync({
id: agent.id,
request: { published: true, square_id: squareId },
});
const sqName =
publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场";
toast.success(`已发布到「${sqName}」`);
setPublishPickerOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleTogglePrivate() { async function handleTogglePrivate() {
const turningOn = !agent.is_private; const turningOn = !agent.is_private;
try { try {
@@ -434,34 +498,11 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
} }
} }
function toggleSkill(skillName: string) {
setSelectedSkills((prev) => {
if (prev.includes(skillName)) {
return prev.filter((name) => name !== skillName);
}
return [...prev, skillName];
});
}
const enabledSkills = useMemo( const enabledSkills = useMemo(
() => skills.filter((skill) => skill.enabled), () => skills.filter((skill) => skill.enabled),
[skills], [skills],
); );
// Search over English name, Chinese name (name_zh) and description, so both
// 中英文 work. Already-selected skills stay searchable too (selection is
// independent of this filter).
const filteredSkills = useMemo(() => {
const q = skillSearch.trim().toLowerCase();
if (!q) return enabledSkills;
return enabledSkills.filter(
(skill) =>
skill.name.toLowerCase().includes(q) ||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
(skill.description ?? "").toLowerCase().includes(q),
);
}, [enabledSkills, skillSearch]);
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by // Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
// both the card header and the compact list row. // both the card header and the compact list row.
const statusBadges = ( const statusBadges = (
@@ -831,7 +872,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
<DialogHeader> <DialogHeader>
<DialogTitle>编辑智能体:{desensitize(agent.name)}</DialogTitle> <DialogTitle>编辑智能体:{desensitize(agent.name)}</DialogTitle>
<DialogDescription> <DialogDescription>
可以修改名称、描述、技能白名单、SOUL 内容与发布状态。 可以修改名称、描述、技能白名单、SOUL 内容、常用问题与发布状态。
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
@@ -859,65 +900,13 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
已选 {selectedSkills.length} 个 已选 {selectedSkills.length} 个
</span> </span>
</div> </div>
{enabledSkills.length > 0 && ( <SkillTransferSelector
<div className="relative"> skills={enabledSkills}
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" /> selected={selectedSkills}
<Input onChange={setSelectedSkills}
placeholder="搜索技能(支持中英文名称、描述)…" isLoading={isSkillsLoading}
value={skillSearch} onViewDetail={setDetailSkillName}
onChange={(e) => setSkillSearch(e.currentTarget.value)}
className="h-8 pl-7 text-xs"
/> />
</div>
)}
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
) : enabledSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">
当前没有可用技能
</p>
) : filteredSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">没有匹配的技能</p>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{filteredSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<div
key={skill.name}
className="flex w-full min-w-0 items-center"
>
<Button
type="button"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill.name)}
title={desensitize(skill.description ?? "")}
className="h-8 min-w-0 flex-1 rounded-r-none"
>
<span className="truncate">
{skill.name_zh?.trim()
? skill.name_zh
: desensitize(skill.name)}
</span>
</Button>
<Button
type="button"
size="icon"
variant={checked ? "default" : "outline"}
onClick={() => setDetailSkillName(skill.name)}
title={t.settings.skills.viewDetailAction}
className="h-8 w-7 rounded-l-none border-l-0"
>
<InfoIcon className="size-3.5" />
</Button>
</div>
);
})}
</div>
)}
</div>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
不选任何技能会保存为无技能(skills = [])。 不选任何技能会保存为无技能(skills = [])。
</p> </p>
@@ -932,7 +921,17 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
? "正在加载 SOUL.md..." ? "正在加载 SOUL.md..."
: "输入 SOUL.md 内容" : "输入 SOUL.md 内容"
} }
className="min-h-40 font-mono text-xs" className="max-h-[45vh] min-h-48 resize-y font-mono text-xs"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">常用问题</label>
<p className="text-muted-foreground text-xs">
这些问题会在该智能体的首页与简介栏作为推荐问题展示,点击即可一键提问。
</p>
<AgentQuestionEditor
value={editCommonQuestions}
onChange={setEditCommonQuestions}
/> />
</div> </div>
<div className="flex items-center justify-between rounded-md border p-3"> <div className="flex items-center justify-between rounded-md border p-3">
@@ -956,6 +955,32 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
}} }}
/> />
</div> </div>
{editPublished && !editIsPrivate && (publishSquares?.squares.length ?? 0) > 0 && (
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="text-sm font-medium">发布广场</p>
<p className="text-muted-foreground text-xs">
选择发布到哪个广场;不选则归入默认广场。每个智能体只能归属一个广场。
</p>
</div>
<Select
value={editSquareId || publishSquares?.default_square_id || ""}
onValueChange={setEditSquareId}
>
<SelectTrigger className="h-9 w-44 text-sm">
<SelectValue placeholder="默认广场" />
</SelectTrigger>
<SelectContent position="popper">
{publishSquares?.squares.map((sq) => (
<SelectItem key={sq.id} value={sq.id}>
{sq.name}
{sq.id === publishSquares?.default_square_id ? "(默认)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex items-center justify-between rounded-md border p-3"> <div className="flex items-center justify-between rounded-md border p-3">
<div> <div>
<p className="text-sm font-medium">加入私密广场</p> <p className="text-sm font-medium">加入私密广场</p>
@@ -1034,6 +1059,16 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
if (!o) setDetailSkillName(null); if (!o) setDetailSkillName(null);
}} }}
/> />
<PublishSquarePickerDialog
open={publishPickerOpen}
onOpenChange={setPublishPickerOpen}
squares={publishSquares?.squares ?? []}
defaultSquareId={agent.square_id || publishSquares?.default_square_id}
loading={updateAgent.isPending}
onConfirm={(squareId) => void handlePublishToSquare(squareId)}
description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。"
/>
</> </>
); );
} }
@@ -0,0 +1,878 @@
"use client";
import {
BotIcon,
CheckIcon,
GlobeIcon,
InfoIcon,
Loader2Icon,
LockIcon,
MessageCircleQuestionIcon,
PanelRightCloseIcon,
PanelRightOpenIcon,
PlusIcon,
ShieldIcon,
SparklesIcon,
SquarePenIcon,
Trash2Icon,
UserRoundIcon,
XIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo, useState, type ReactNode } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
import { SkillPickerSheet } from "@/components/workspace/agents/skill-picker-sheet";
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { Tooltip } from "@/components/workspace/tooltip";
import {
normalizeAgentQuestions,
useDeleteAgent,
useSetAgentFeatured,
useUpdateAgent,
type Agent,
type AgentQuestion,
type UpdateAgentRequest,
} from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useSkills } from "@/core/skills/hooks";
import { usePublishSquares } from "@/core/system-settings/hooks";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/**
* Turn the admin-authored SOUL.md markdown into a clean, readable profile blurb
* for the narrow panel. The view mode is a profile card, not a document view —
* so we strip markdown syntax (headings, emphasis, inline code, list bullets,
* blockquotes) rather than rendering it, keeping the line structure intact.
*/
function cleanProfileText(raw: string): string {
return raw
.split("\n")
.map((line) =>
line
.replace(/^\s{0,3}#{1,6}\s*/, "")
.replace(/^\s{0,3}>\s?/, "")
.replace(/^(\s*)[-*+]\s+/, "$1• ")
.replace(/(\*\*|__)(.*?)\1/g, "$2")
.replace(/(\*|_)(.*?)\1/g, "$2")
.replace(/`([^`]+)`/g, "$1"),
)
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
/** Local edit-mode draft — committed in one `updateAgent` call on 保存. */
interface AgentDraft {
name: string;
description: string;
soul: string;
questions: AgentQuestion[];
skills: string[];
published: boolean;
featured: boolean;
}
function draftFromAgent(agent: Agent): AgentDraft {
return {
name: agent.name,
description: agent.description ?? "",
soul: agent.soul ?? "",
questions: normalizeAgentQuestions(agent.common_questions),
skills: agent.skills ?? [],
published: agent.published,
featured: agent.featured_order != null,
};
}
function Section({
icon,
title,
extra,
children,
}: {
icon?: ReactNode;
title: string;
extra?: ReactNode;
children: ReactNode;
}) {
return (
<section className="bg-card/60 rounded-xl border p-4">
<div className="mb-3 flex items-center justify-between gap-2">
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
{icon}
{title}
</h4>
{extra}
</div>
{children}
</section>
);
}
/**
* 智能体详情面板 — the right-hand agent configuration center on the agent chat
* page. View mode shows the full profile (avatar, name, description, SOUL,
* 常用问题, enabled skills, publish/featured state); 管理员或发布者 can flip
* into an inline edit mode (no dialog) that edits everything locally and
* commits in a single 保存. 添加技能 opens a secondary drawer (SkillPickerSheet).
* Everyone else gets a read-only profile. On narrow screens (<lg) the panel
* collapses into a rail whose button opens the same content in a Sheet, so it
* never overlaps the chat input.
*/
export function AgentDetailPanel({
agent,
agentName,
onAskQuestion,
disabled,
}: {
agent: Agent | null | undefined;
agentName: string;
onAskQuestion: (text: string) => void;
disabled?: boolean;
}) {
const router = useRouter();
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<AgentDraft | null>(null);
const [editingQuestionIndex, setEditingQuestionIndex] = useState<
number | null
>(null);
const [questionDraft, setQuestionDraft] = useState<AgentQuestion>({
title: "",
prompt: "",
});
const [pickerOpen, setPickerOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
const updateAgent = useUpdateAgent();
const deleteAgent = useDeleteAgent();
const setFeatured = useSetAgentFeatured();
const { data: publishSquares } = usePublishSquares();
const { skills, isLoading: isSkillsLoading } = useSkills();
// Same pool the edit dialog offers: only globally enabled skills are
// whitelist candidates.
const enabledPool = useMemo(
() => skills.filter((skill) => skill.enabled),
[skills],
);
// Permission model mirrors agent-card: admins manage the full catalog,
// owners manage their own agents, everyone else is read-only.
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
const isOwned =
!!agent &&
!agent.builtin &&
agent.user_id !== null &&
agent.user_id === currentUserId;
const canEdit = !!agent && (isOwned || isAdmin);
// Built-ins are inherently visible to everyone, so publish toggle is moot.
const canTogglePublish = !!agent && (isOwned || (isAdmin && !agent.builtin));
const isSaving = updateAgent.isPending || setFeatured.isPending;
const displayName = desensitize(agent?.name ?? agentName);
const questions = useMemo(
() => normalizeAgentQuestions(agent?.common_questions),
[agent?.common_questions],
);
const soulPreview = agent?.soul?.trim()
? cleanProfileText(desensitize(agent.soul))
: undefined;
function beginEdit() {
if (!agent || !canEdit) return;
cancelQuestionEdit();
setDraft(draftFromAgent(agent));
setEditing(true);
}
function cancelEdit() {
setEditing(false);
setDraft(null);
setPickerOpen(false);
}
function beginQuestionEdit(index: number, question: AgentQuestion) {
if (!canEdit) return;
setEditingQuestionIndex(index);
setQuestionDraft(question);
}
function cancelQuestionEdit() {
setEditingQuestionIndex(null);
setQuestionDraft({ title: "", prompt: "" });
}
async function saveQuestionEdit(index: number) {
if (!agent) return;
const nextQuestions = questions.map((question, questionIndex) =>
questionIndex === index
? {
title: questionDraft.title.trim() || questionDraft.prompt.trim(),
prompt: questionDraft.prompt.trim() || questionDraft.title.trim(),
}
: question,
);
const cleanedQuestions = nextQuestions.filter(
(question) => question.prompt.trim().length > 0,
);
try {
await updateAgent.mutateAsync({
id: agent.id,
request: { common_questions: cleanedQuestions },
});
toast.success("Updated");
cancelQuestionEdit();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function saveEdit() {
if (!agent || !draft) return;
const trimmedName = draft.name.trim();
if (!trimmedName) {
toast.error("名称不能为空");
return;
}
// 私密广场与公开发布互斥 — force what the backend will accept.
const nextPublished = agent.is_private ? false : draft.published;
const nextQuestions = draft.questions
.map((q) => ({
title: q.title.trim() || q.prompt.trim(),
prompt: q.prompt.trim() || q.title.trim(),
}))
.filter((q) => q.prompt.length > 0);
// Only send the fields that actually changed: the backend merges partial
// updates (model_fields_set), so e.g. editing 常用问题 alone no longer
// re-saves skills/SOUL/发布状态 and the save returns quickly.
const request: UpdateAgentRequest = {};
if (trimmedName !== agent.name) request.name = trimmedName;
if (draft.description !== (agent.description ?? ""))
request.description = draft.description;
if (draft.soul !== (agent.soul ?? "")) request.soul = draft.soul;
if (nextPublished !== agent.published) request.published = nextPublished;
if (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? []))
request.skills = draft.skills;
if (
JSON.stringify(nextQuestions) !==
JSON.stringify(normalizeAgentQuestions(agent.common_questions))
)
request.common_questions = nextQuestions;
try {
if (Object.keys(request).length > 0) {
await updateAgent.mutateAsync({ id: agent.id, request });
}
// 首页推荐 rides a dedicated endpoint — only call it when toggled.
const wasFeatured = agent.featured_order != null;
if (isAdmin && draft.featured !== wasFeatured) {
if (draft.featured && !nextPublished) {
toast.warning("只有已发布的智能体可以加入首页推荐,已跳过该项");
} else {
await setFeatured.mutateAsync({
id: agent.id,
order: draft.featured ? Date.now() : null,
});
}
}
toast.success("智能体已更新");
setEditing(false);
setDraft(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleTogglePublished() {
if (!agent) return;
if (!agent.published && agent.is_private) {
toast.warning("该智能体已加入私密广场,不可发布");
return;
}
// Publishing: pick a target square when any are configured.
if (!agent.published && (publishSquares?.squares.length ?? 0) > 0) {
setPublishPickerOpen(true);
return;
}
try {
await updateAgent.mutateAsync({
id: agent.id,
request: { published: !agent.published },
});
toast.success(agent.published ? "已下架" : "已发布");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handlePublishToSquare(squareId: string) {
if (!agent) return;
try {
await updateAgent.mutateAsync({
id: agent.id,
request: { published: true, square_id: squareId },
});
const sqName =
publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场";
toast.success(`已发布到「${sqName}」`);
setPublishPickerOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete() {
if (!agent) return;
try {
await deleteAgent.mutateAsync(agent.id);
toast.success("智能体已删除");
setDeleteOpen(false);
router.push("/page/workspace/agents");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
const statusBadges = agent && (
<>
{agent.builtin && (
<Badge variant="secondary" className="text-xs">
内置
</Badge>
)}
{agent.published && !agent.is_private && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
>
<GlobeIcon className="size-3" />
已发布
</Badge>
)}
{!agent.published && !agent.builtin && (
<Badge variant="outline" className="text-muted-foreground text-xs">
未发布
</Badge>
)}
{agent.is_private && (
<Badge
variant="outline"
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
>
<ShieldIcon className="size-3" />
私密
</Badge>
)}
{agent.featured_order != null && (
<Badge
variant="outline"
className="border-amber-500/40 text-amber-700 dark:text-amber-300 text-xs"
>
<SparklesIcon className="size-3" />
首页推荐
</Badge>
)}
</>
);
const headerActions = canEdit ? (
editing ? (
<div className="flex items-center gap-1.5">
<Button size="sm" onClick={() => void saveEdit()} disabled={isSaving}>
{isSaving && <Loader2Icon className="size-3.5 animate-spin" />}
保存
</Button>
<Button
size="sm"
variant="outline"
onClick={cancelEdit}
disabled={isSaving}
>
取消
</Button>
</div>
) : (
<div className="flex items-center gap-1">
<Button
size="sm"
variant="outline"
className="hidden"
onClick={beginEdit}
aria-hidden="true"
tabIndex={-1}
>
<SquarePenIcon className="size-3.5" />
编辑
</Button>
{canTogglePublish && (
<Tooltip content={agent?.published ? "下架" : "发布"}>
<Button
size="icon-sm"
variant="ghost"
onClick={() => void handleTogglePublished()}
disabled={updateAgent.isPending}
aria-label={agent?.published ? "下架" : "发布"}
>
{agent?.published ? (
<LockIcon className="size-3.5" />
) : (
<GlobeIcon className="size-3.5" />
)}
</Button>
</Tooltip>
)}
<Tooltip content="删除智能体">
<Button
size="icon-sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => setDeleteOpen(true)}
aria-label="删除智能体"
>
<Trash2Icon className="size-3.5" />
</Button>
</Tooltip>
</div>
)
) : null;
const skillNamesShown = editing && draft ? draft.skills : (agent?.skills ?? []);
const body = !agent ? (
<div className="text-muted-foreground flex h-40 items-center justify-center gap-2 text-sm">
<Loader2Icon className="size-4 animate-spin" />
智能体信息加载中…
</div>
) : (
<div className="space-y-4 px-4 py-4">
{/* ── Identity / 基础信息 ─────────────────────────────────────────── */}
<section className="bg-card/60 rounded-xl border p-4">
<div className="flex items-start gap-3">
<div className="bg-primary/10 ring-primary/15 flex size-12 shrink-0 items-center justify-center rounded-2xl ring-1">
<BotIcon className="text-primary size-6" />
</div>
<div className="min-w-0 flex-1 space-y-1.5">
{editing && draft ? (
<Input
value={draft.name}
onChange={(e) =>
setDraft({ ...draft, name: e.currentTarget.value })
}
placeholder="智能体名称"
className="h-8 text-sm font-semibold"
/>
) : (
<div
className="truncate text-base font-bold"
title={displayName}
>
{displayName}
</div>
)}
<div className="flex flex-wrap items-center gap-1">
{statusBadges}
</div>
{!editing && agent.owner_username && !agent.builtin && (
<div className="text-muted-foreground flex items-center gap-1 text-xs">
<UserRoundIcon className="size-3 shrink-0" />
发布者:{desensitize(agent.owner_username)}
</div>
)}
</div>
</div>
<div className="mt-3">
{editing && draft ? (
<Textarea
value={draft.description}
onChange={(e) =>
setDraft({ ...draft, description: e.currentTarget.value })
}
placeholder="智能体描述"
className="min-h-16 text-sm"
/>
) : agent.description ? (
<p className="text-muted-foreground text-sm leading-relaxed">
{desensitize(agent.description)}
</p>
) : (
<p className="text-muted-foreground/60 text-sm">暂无描述</p>
)}
</div>
{/* Publish / featured toggles — only meaningful in edit mode. */}
{editing && draft && (
<div className="mt-3 space-y-2">
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<p className="text-sm font-medium">公开发布</p>
<p className="text-muted-foreground text-xs">
{agent.is_private
? "已加入私密广场,不可发布"
: "打开后对所有用户可见"}
</p>
</div>
<Switch
checked={draft.published && !agent.is_private}
disabled={agent.is_private}
onCheckedChange={(v) => setDraft({ ...draft, published: v })}
/>
</div>
{isAdmin && (
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<p className="text-sm font-medium">首页推荐</p>
<p className="text-muted-foreground text-xs">
加入首页选择器(需已发布)
</p>
</div>
<Switch
checked={draft.featured}
onCheckedChange={(v) => setDraft({ ...draft, featured: v })}
/>
</div>
)}
</div>
)}
</section>
{/* ── SOUL / 人设 ────────────────────────────────────────────────── */}
{(editing || soulPreview) && (
<Section
icon={<SparklesIcon className="size-3.5" />}
title="人设(SOUL)"
>
{editing && draft ? (
<Textarea
value={draft.soul}
onChange={(e) =>
setDraft({ ...draft, soul: e.currentTarget.value })
}
placeholder="输入 SOUL.md 内容"
className="max-h-[45vh] min-h-48 resize-y font-mono text-xs"
/>
) : (
// Viewport-relative cap: the SOUL preview expands with the screen
// (taller than the old fixed 240px box) but never swallows the
// whole panel — long content still scrolls inside.
<p className="text-muted-foreground max-h-[40vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
{soulPreview}
</p>
)}
</Section>
)}
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
{(editing || questions.length > 0) && (
<Section
icon={<MessageCircleQuestionIcon className="size-3.5" />}
title="常用问题"
extra={
<span className="text-muted-foreground text-xs tabular-nums">
{(editing && draft ? draft.questions : questions).length} 个
</span>
}
>
{editing && draft ? (
<AgentQuestionEditor
value={draft.questions}
onChange={(next) => setDraft({ ...draft, questions: next })}
disabled={isSaving}
/>
) : (
<div className="flex flex-col gap-2">
{questions.map((question, index) => {
const isEditingThis = editingQuestionIndex === index;
return (
<div
key={index}
className="border-border/60 bg-background/60 rounded-xl border p-2"
>
{isEditingThis ? (
<div className="space-y-2">
<Input
value={questionDraft.title}
disabled={isSaving}
placeholder="Title"
onChange={(e) =>
setQuestionDraft({
...questionDraft,
title: e.currentTarget.value,
})
}
className="h-8 text-sm font-medium"
/>
<Textarea
value={questionDraft.prompt}
disabled={isSaving}
placeholder="Prompt"
onChange={(e) =>
setQuestionDraft({
...questionDraft,
prompt: e.currentTarget.value,
})
}
className="min-h-16 resize-none text-sm"
/>
<div className="flex justify-end gap-1.5">
<Button
type="button"
size="sm"
variant="outline"
onClick={cancelQuestionEdit}
disabled={isSaving}
>
<XIcon className="size-3.5" />
Cancel
</Button>
<Button
type="button"
size="sm"
onClick={() => void saveQuestionEdit(index)}
disabled={isSaving}
>
{isSaving ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<CheckIcon className="size-3.5" />
)}
Save
</Button>
</div>
</div>
) : (
<div className="flex items-center gap-2">
<button
type="button"
disabled={disabled}
onClick={() => onAskQuestion(question.prompt)}
title={desensitize(question.prompt)}
className={cn(
"hover:bg-accent min-w-0 flex-1 rounded-lg px-2 py-1.5 text-left text-sm leading-relaxed transition-colors",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<span className="block truncate">
{desensitize(question.title)}
</span>
</button>
{canEdit && (
<Button
type="button"
size="icon-sm"
variant="ghost"
onClick={() => beginQuestionEdit(index, question)}
disabled={isSaving}
aria-label="Edit question"
>
<SquarePenIcon className="size-3.5" />
</Button>
)}
</div>
)}
</div>
);
})}
</div>
)}
</Section>
)}
{/* ── 技能管理 ───────────────────────────────────────────────────── */}
<Section
icon={<SparklesIcon className="size-3.5" />}
title={`已启用技能 ${skillNamesShown.length} 个`}
extra={
editing ? (
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={() => setPickerOpen(true)}
>
<PlusIcon className="size-3.5" />
添加技能
</Button>
) : undefined
}
>
<EnabledSkillList
skillNames={skillNamesShown}
skills={skills}
isLoading={isSkillsLoading}
editing={editing}
onRemove={
editing && draft
? (name) =>
setDraft({
...draft,
skills: draft.skills.filter((n) => n !== name),
})
: undefined
}
onViewDetail={setDetailSkillName}
/>
</Section>
</div>
);
const panelChrome = (collapseButton: ReactNode) => (
<>
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<span className="text-sm font-semibold">智能体信息</span>
<div className="flex items-center gap-1">
{headerActions}
{collapseButton}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
</>
);
return (
<>
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
{collapsed ? (
<div className="hidden shrink-0 flex-col items-center border-l pt-3 lg:flex">
<Tooltip content="智能体信息">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setCollapsed(false)}
aria-label="展开智能体信息"
>
<PanelRightOpenIcon className="size-4" />
</Button>
</Tooltip>
</div>
) : (
<aside className="bg-card/40 hidden h-full w-[360px] shrink-0 flex-col border-l lg:flex">
{panelChrome(
<Tooltip content="收起">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setCollapsed(true)}
aria-label="收起智能体信息"
>
<PanelRightCloseIcon className="size-4" />
</Button>
</Tooltip>,
)}
</aside>
)}
{/* ── Narrow screens (<lg): rail trigger + drawer with the same body ── */}
<div className="flex shrink-0 flex-col items-center border-l pt-3 lg:hidden">
<Tooltip content="智能体信息">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setMobileOpen(true)}
aria-label="打开智能体信息"
>
<InfoIcon className="size-4" />
</Button>
</Tooltip>
</div>
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b px-4 pb-3 pt-4">
<SheetTitle className="flex items-center justify-between text-sm">
智能体信息
<span className="mr-6">{headerActions}</span>
</SheetTitle>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
</SheetContent>
</Sheet>
{/* ── Secondary surfaces ───────────────────────────────────────────── */}
<SkillPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
skills={enabledPool}
isLoading={isSkillsLoading}
selected={editing && draft ? draft.skills : []}
onChange={(next) => {
if (editing && draft) setDraft({ ...draft, skills: next });
}}
onViewDetail={setDetailSkillName}
/>
<SkillDetailDialog
skillName={detailSkillName}
open={detailSkillName !== null}
onOpenChange={(o) => {
if (!o) setDetailSkillName(null);
}}
/>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>删除智能体</DialogTitle>
<DialogDescription>
确定要删除「{displayName}」吗?该操作不可恢复。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteOpen(false)}
disabled={deleteAgent.isPending}
>
取消
</Button>
<Button
variant="destructive"
onClick={() => void handleDelete()}
disabled={deleteAgent.isPending}
>
{deleteAgent.isPending ? "删除中…" : "删除"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<PublishSquarePickerDialog
open={publishPickerOpen}
onOpenChange={setPublishPickerOpen}
squares={publishSquares?.squares ?? []}
defaultSquareId={agent?.square_id || publishSquares?.default_square_id}
loading={updateAgent.isPending}
onConfirm={(squareId) => void handlePublishToSquare(squareId)}
description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。"
/>
</>
);
}
@@ -15,7 +15,7 @@ import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents"; import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
import type { Agent, AgentSort } from "@/core/agents"; import type { Agent, AgentSort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { usePrivateSquareStatus, useViewMode } from "@/core/system-settings/hooks"; import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
import { AgentCard } from "./agent-card"; import { AgentCard } from "./agent-card";
@@ -30,21 +30,21 @@ export function AgentGallery() {
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const [viewMode, setViewMode] = useViewMode("agent"); const [viewMode, setViewMode] = useViewMode("agent");
const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">( const [activeTab, setActiveTab] = useState<string>("mine");
"mine",
);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]); const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<AgentSort>("time_desc"); const [sort, setSort] = useState<AgentSort>("time_desc");
const { data: publishSquares } = usePublishSquares();
// Each configured publish square (发布广场) becomes its own browse tab; the
// backend always returns at least one (the built-in default).
const squareTabs = publishSquares?.squares ?? [];
// Publisher filter: click a card's 发布者 to keep only that user's agents. // Publisher filter: click a card's 发布者 to keep only that user's agents.
// {id} is the owner user_id (server filter), {label} the desensitized name. // {id} is the owner user_id (server filter), {label} the desensitized name.
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>( const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
null, null,
); );
// Per-tab 1-based page index, preserved while switching tabs. // Per-tab 1-based page index (keyed by tab value), preserved across switches.
const [pages, setPages] = useState<{ all: number; mine: number; private: number }>( const [pages, setPages] = useState<Record<string, number>>({});
{ all: 1, mine: 1, private: 1 },
);
// Search + tag filtering are both server-side now. The backend mirrors the // Search + tag filtering are both server-side now. The backend mirrors the
// frontend's desensitize map, so typing the displayed abbreviation (e.g. // frontend's desensitize map, so typing the displayed abbreviation (e.g.
@@ -52,9 +52,22 @@ export function AgentGallery() {
// filters change, every tab returns to its first page. // filters change, every tab returns to its first page.
const tagKey = tagIds.join(","); const tagKey = tagIds.join(",");
useEffect(() => { useEffect(() => {
setPages({ all: 1, mine: 1, private: 1 }); setPages({});
}, [search, tagKey, sort, publisher?.id]); }, [search, tagKey, sort, publisher?.id]);
// If the active square tab disappears (admin removed that square), fall back
// to "我的" so the gallery never points at a dead tab.
useEffect(() => {
if (
activeTab !== "mine" &&
activeTab !== "private" &&
squareTabs.length > 0 &&
!squareTabs.some((sq) => sq.id === activeTab)
) {
setActiveTab("mine");
}
}, [activeTab, squareTabs]);
const { data: privateStatus } = usePrivateSquareStatus(); const { data: privateStatus } = usePrivateSquareStatus();
const privateFeatureAvailable = !!privateStatus?.enabled; const privateFeatureAvailable = !!privateStatus?.enabled;
const privateFeatureUsable = const privateFeatureUsable =
@@ -68,8 +81,9 @@ export function AgentGallery() {
// Only the active tab fetches. "广场" maps to the server "square" scope // Only the active tab fetches. "广场" maps to the server "square" scope
// (built-in/published, never private), "我的" to "mine" (owned + favorited). // (built-in/published, never private), "我的" to "mine" (owned + favorited).
const isSquareTab = activeTab !== "mine" && activeTab !== "private";
const mainScope = activeTab === "mine" ? "mine" : "square"; const mainScope = activeTab === "mine" ? "mine" : "square";
const mainPage = activeTab === "mine" ? pages.mine : pages.all; const mainPage = pages[activeTab] ?? 1;
const { const {
agents: mainAgents, agents: mainAgents,
total: mainTotal, total: mainTotal,
@@ -84,6 +98,8 @@ export function AgentGallery() {
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
enabled: activeTab !== "private", enabled: activeTab !== "private",
owner: publisher?.id, owner: publisher?.id,
// Each square tab fetches only that square's agents.
square: isSquareTab ? activeTab : undefined,
}); });
const { const {
@@ -119,7 +135,7 @@ export function AgentGallery() {
const showSearchBar = activeTab !== "private" || privateUnlocked; const showSearchBar = activeTab !== "private" || privateUnlocked;
const activeTotal = activeTab === "private" ? privateTotal : mainTotal; const activeTotal = activeTab === "private" ? privateTotal : mainTotal;
const setTabPage = (tab: "all" | "mine" | "private", page: number) => const setTabPage = (tab: string, page: number) =>
setPages((prev) => ({ ...prev, [tab]: page })); setPages((prev) => ({ ...prev, [tab]: page }));
const handleNewAgent = () => { const handleNewAgent = () => {
@@ -174,7 +190,7 @@ export function AgentGallery() {
list: Agent[]; list: Agent[];
total: number; total: number;
isLoading: boolean; isLoading: boolean;
tab: "all" | "mine" | "private"; tab: string;
emptyDescription: string; emptyDescription: string;
showNewButton?: boolean; showNewButton?: boolean;
}) => { }) => {
@@ -195,7 +211,7 @@ export function AgentGallery() {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{renderGrid(list)} {renderGrid(list)}
<Pagination <Pagination
page={pages[tab]} page={pages[tab] ?? 1}
pageSize={PAGE_SIZE} pageSize={PAGE_SIZE}
total={total} total={total}
onPageChange={(p) => setTabPage(tab, p)} onPageChange={(p) => setTabPage(tab, p)}
@@ -324,7 +340,7 @@ export function AgentGallery() {
<Tabs <Tabs
value={activeTab} value={activeTab}
onValueChange={(v) => { onValueChange={(v) => {
setActiveTab(v as "all" | "mine" | "private"); setActiveTab(v);
// Each tab starts from a clean slate: switching tabs clears the // Each tab starts from a clean slate: switching tabs clears the
// keyword / tag / sort / publisher filters instead of carrying them // keyword / tag / sort / publisher filters instead of carrying them
// over. The TagSearchBar's own input box is reset via its key below. // over. The TagSearchBar's own input box is reset via its key below.
@@ -337,9 +353,13 @@ export function AgentGallery() {
> >
<div className="mx-6 mt-4 flex flex-col gap-3"> <div className="mx-6 mt-4 flex flex-col gap-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<TabsList className="w-auto self-start"> <TabsList className="w-auto max-w-full flex-wrap justify-start self-start">
<TabsTrigger value="mine">我的</TabsTrigger> <TabsTrigger value="mine">我的</TabsTrigger>
<TabsTrigger value="all">广场</TabsTrigger> {squareTabs.map((sq) => (
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
{sq.name}
</TabsTrigger>
))}
{privateFeatureAvailable && ( {privateFeatureAvailable && (
<TabsTrigger value="private"> <TabsTrigger value="private">
<LockIcon className="mr-1 h-3.5 w-3.5" /> <LockIcon className="mr-1 h-3.5 w-3.5" />
@@ -414,15 +434,21 @@ export function AgentGallery() {
)} )}
</div> </div>
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0"> {squareTabs.map((sq) => (
<TabsContent
key={sq.id}
value={sq.id}
className="flex-1 overflow-y-auto p-6 mt-0"
>
{renderListBody({ {renderListBody({
list: mainAgents, list: mainAgents,
total: mainTotal, total: mainTotal,
isLoading: mainLoading, isLoading: mainLoading,
tab: "all", tab: sq.id,
emptyDescription: t.agents.emptyDescription, emptyDescription: `「${sq.name}」广场暂时还没有智能体。`,
})} })}
</TabsContent> </TabsContent>
))}
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0"> <TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
{renderListBody({ {renderListBody({
@@ -0,0 +1,120 @@
"use client";
import {
ChevronDownIcon,
ChevronUpIcon,
PlusIcon,
XIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type { AgentQuestion } from "@/core/agents";
/**
* Editable list of an agent's 常用问题 (recommended questions). Each row has a
* short `title` (the chip label) and the full `prompt` sent on click. Supports
* add / remove / inline edit / reorder (move up & down). Pure controlled
* component — persistence stays with the caller (edit dialog or detail panel).
*/
export function AgentQuestionEditor({
value,
onChange,
disabled,
}: {
value: AgentQuestion[];
onChange: (next: AgentQuestion[]) => void;
disabled?: boolean;
}) {
function update(index: number, patch: Partial<AgentQuestion>) {
const next = [...value];
next[index] = { ...next[index]!, ...patch };
onChange(next);
}
function remove(index: number) {
onChange(value.filter((_, i) => i !== index));
}
function move(index: number, delta: -1 | 1) {
const target = index + delta;
if (target < 0 || target >= value.length) return;
const next = [...value];
[next[index], next[target]] = [next[target]!, next[index]!];
onChange(next);
}
return (
<div className="space-y-3">
{value.length === 0 && (
<p className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-xs">
尚未配置常用问题,点击下方按钮添加。
</p>
)}
{value.map((item, index) => (
<div key={index} className="bg-background/60 space-y-1.5 rounded-lg border p-2.5">
<div className="flex items-center gap-1.5">
<Input
value={item.title}
disabled={disabled}
placeholder="标题(如:能蒸馏哪些人)"
onChange={(e) => update(index, { title: e.currentTarget.value })}
className="h-8 text-sm font-medium"
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={disabled || index === 0}
onClick={() => move(index, -1)}
aria-label="上移"
title="上移"
>
<ChevronUpIcon className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={disabled || index === value.length - 1}
onClick={() => move(index, 1)}
aria-label="下移"
title="下移"
>
<ChevronDownIcon className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={disabled}
onClick={() => remove(index)}
aria-label="删除问题"
title="删除问题"
>
<XIcon className="size-4" />
</Button>
</div>
<Textarea
value={item.prompt}
disabled={disabled}
placeholder="点击后实际发送的完整问题内容…"
onChange={(e) => update(index, { prompt: e.currentTarget.value })}
className="min-h-16 resize-none text-sm"
/>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
disabled={disabled}
onClick={() => onChange([...value, { title: "", prompt: "" }])}
>
<PlusIcon className="size-3.5" /> 添加问题
</Button>
</div>
);
}
@@ -0,0 +1,153 @@
"use client";
import { InboxIcon, InfoIcon, XIcon } from "lucide-react";
import { useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { Skill } from "@/core/skills";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/** Chinese display label with English-name fallback. */
export function skillDisplayLabel(skill: Skill) {
return skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name);
}
/** First character of the display label — the letter avatar on skill cards. */
export function skillInitial(skill: Skill) {
const label = skill.name_zh?.trim() || skill.name;
return label.trim().charAt(0).toUpperCase() || "?";
}
/** Human label for the skill's provenance (内置 / 用户上传 / 对话沉淀). */
export function skillSourceLabel(skill: Skill): string | null {
if (skill.builtin) return "内置";
if (skill.source === "upload") return "用户上传";
if (skill.source === "agent") return "对话沉淀";
return null;
}
/**
* The agent's enabled-skill list shown in the detail panel: one card per
* whitelisted skill name with a letter avatar, Chinese name, category/source
* badges and the short description. In editing mode each card gains a remove
* button. Skill names with no metadata in the pool (e.g. a skill that was
* deleted after being whitelisted) still render as a degraded card so the
* whitelist stays visible and removable.
*/
export function EnabledSkillList({
skillNames,
skills,
editing,
onRemove,
onViewDetail,
isLoading,
}: {
/** The agent's persisted whitelist — `agent.skills` (English names). */
skillNames: string[];
/** Full skill pool used to resolve metadata for each name. */
skills: Skill[];
editing?: boolean;
onRemove?: (name: string) => void;
onViewDetail?: (name: string) => void;
isLoading?: boolean;
}) {
const byName = useMemo(
() => new Map(skills.map((s) => [s.name, s])),
[skills],
);
if (isLoading && skillNames.length > 0) {
return (
<div className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-xs">
技能加载中…
</div>
);
}
if (skillNames.length === 0) {
return (
<div className="text-muted-foreground flex flex-col items-center gap-1.5 rounded-lg border border-dashed px-3 py-5 text-xs">
<InboxIcon className="size-5 opacity-50" />
<span>尚未启用任何技能</span>
</div>
);
}
return (
<div className="space-y-2">
{skillNames.map((name) => {
const skill = byName.get(name);
return (
<div
key={name}
className={cn(
"group/skill bg-background/60 flex items-start gap-2.5 rounded-xl border p-2.5 transition-colors",
onViewDetail && "hover:border-primary/40 hover:bg-accent/40",
)}
>
<div className="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-lg text-sm font-semibold">
{skill ? skillInitial(skill) : "?"}
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<span
className="min-w-0 truncate text-sm font-medium"
title={skill ? skillDisplayLabel(skill) : desensitize(name)}
>
{skill ? skillDisplayLabel(skill) : desensitize(name)}
</span>
{skill?.category?.trim() && (
<Badge variant="secondary" className="shrink-0 text-[10px]">
{skill.category}
</Badge>
)}
{skill && skillSourceLabel(skill) && (
<Badge variant="outline" className="shrink-0 text-[10px]">
{skillSourceLabel(skill)}
</Badge>
)}
</div>
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
{skill
? skill.description?.trim()
? desensitize(skill.description)
: "暂无描述"
: "该技能已不存在或不可用,可将其移除。"}
</p>
</div>
<div className="flex shrink-0 items-center gap-0.5">
{skill && onViewDetail && (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground size-7"
onClick={() => onViewDetail(name)}
title="查看技能详情"
aria-label="查看技能详情"
>
<InfoIcon className="size-3.5" />
</Button>
)}
{editing && onRemove && (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive size-7"
onClick={() => onRemove(name)}
title="移除技能"
aria-label="移除技能"
>
<XIcon className="size-3.5" />
</Button>
)}
</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,436 @@
"use client";
import {
CheckIcon,
InfoIcon,
Loader2Icon,
PlusIcon,
SearchIcon,
UploadIcon,
XIcon,
} from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import {
skillDisplayLabel,
skillInitial,
skillSourceLabel,
} from "@/components/workspace/agents/enabled-skill-list";
import type { Skill } from "@/core/skills";
import {
useInstallSkillUpload,
useValidateSkillUpload,
} from "@/core/skills/hooks";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
const ALL_CATEGORIES = "__all__";
type SourceFilter = "all" | "builtin" | "upload" | "agent";
const SOURCE_OPTIONS: { value: SourceFilter; label: string }[] = [
{ value: "all", label: "全部来源" },
{ value: "builtin", label: "内置技能" },
{ value: "upload", label: "用户上传" },
{ value: "agent", label: "对话沉淀" },
];
function matchesSource(skill: Skill, source: SourceFilter) {
switch (source) {
case "all":
return true;
case "builtin":
return !!skill.builtin;
case "upload":
return skill.source === "upload";
case "agent":
return skill.source === "agent";
}
}
function matchesQuery(skill: Skill, q: string) {
if (!q) return true;
return (
skill.name.toLowerCase().includes(q) ||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
(skill.description ?? "").toLowerCase().includes(q)
);
}
/**
* 技能选择器 — a secondary right-hand drawer opened from the agent detail
* panel's "添加技能" button. Search (中英文名称 + 描述), category tabs, source
* filter (内置 / 用户上传 / 对话沉淀), skill-pack upload, card grid with
* enable/disable switches, bulk add-filtered / clear-selected, and a footer
* showing the live selected count. Selection is controlled by the caller (the
* panel's local draft) — nothing here persists until the panel saves.
*/
export function SkillPickerSheet({
open,
onOpenChange,
skills,
isLoading,
selected,
onChange,
onViewDetail,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Selectable pool (caller pre-filters by `enabled`). */
skills: Skill[];
isLoading?: boolean;
/** Currently selected skill names — the draft `skills: string[]`. */
selected: string[];
onChange: (next: string[]) => void;
onViewDetail?: (name: string) => void;
}) {
const [search, setSearch] = useState("");
const [category, setCategory] = useState<string>(ALL_CATEGORIES);
const [source, setSource] = useState<SourceFilter>("all");
const fileInputRef = useRef<HTMLInputElement | null>(null);
const validateUpload = useValidateSkillUpload();
const installUpload = useInstallSkillUpload();
const isUploading = validateUpload.isPending || installUpload.isPending;
const selectedSet = useMemo(() => new Set(selected), [selected]);
const categories = useMemo(() => {
const set = new Set<string>();
for (const skill of skills) {
if (skill.category?.trim()) set.add(skill.category.trim());
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [skills]);
const query = search.trim().toLowerCase();
const filtered = useMemo(
() =>
skills.filter(
(skill) =>
matchesQuery(skill, query) &&
matchesSource(skill, source) &&
(category === ALL_CATEGORIES || skill.category?.trim() === category),
),
[skills, query, source, category],
);
const filteredUnselected = useMemo(
() => filtered.filter((skill) => !selectedSet.has(skill.name)),
[filtered, selectedSet],
);
function toggle(name: string) {
if (selectedSet.has(name)) {
onChange(selected.filter((n) => n !== name));
} else {
onChange([...selected, name]);
}
}
function addAllFiltered() {
if (filteredUnselected.length === 0) return;
onChange([...selected, ...filteredUnselected.map((s) => s.name)]);
}
function clearSelected() {
if (selected.length === 0) return;
onChange([]);
}
async function handleUploadFile(file: File) {
const lower = file.name.toLowerCase();
if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) {
toast.error("仅支持 .zip / .skill 技能包");
return;
}
try {
const validation = await validateUpload.mutateAsync(file);
if (validation.conflict) {
toast.warning(
`已存在同名技能「${validation.skill_name}」,请到「技能」设置页上传以选择是否覆盖。`,
);
return;
}
const result = await installUpload.mutateAsync({
file,
targetCategory: "custom",
});
// Auto-select the freshly installed skill so the user doesn't have to
// hunt for it in the refreshed pool.
if (!selectedSet.has(result.skill_name)) {
onChange([...selected, result.skill_name]);
}
toast.success(`技能「${result.skill_name}」已安装并加入已选`);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 p-0 sm:max-w-xl"
>
<SheetHeader className="border-b px-5 pb-3 pt-4">
<SheetTitle>添加技能</SheetTitle>
<SheetDescription>
为该智能体启用技能。修改后需在右侧面板点击「保存」才会生效。
</SheetDescription>
</SheetHeader>
{/* Toolbar: search / source / upload */}
<div className="space-y-2.5 border-b px-5 py-3">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2" />
<Input
placeholder="搜索技能(中英文名称、描述)…"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
className="h-8 pl-8 text-xs"
/>
</div>
<Select
value={source}
onValueChange={(v) => setSource(v as SourceFilter)}
>
<SelectTrigger className="h-8 w-28 shrink-0 px-2 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{SOURCE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
size="sm"
variant="outline"
className="h-8 shrink-0 text-xs"
disabled={isUploading}
onClick={() => fileInputRef.current?.click()}
title="上传 .zip / .skill 技能包"
>
{isUploading ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<UploadIcon className="size-3.5" />
)}
上传技能
</Button>
<input
ref={fileInputRef}
type="file"
accept=".zip,.skill"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleUploadFile(file);
}}
/>
</div>
{/* Category tabs */}
{categories.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{[ALL_CATEGORIES, ...categories].map((c) => (
<button
key={c}
type="button"
onClick={() => setCategory(c)}
className={cn(
"rounded-full border px-2.5 py-1 text-xs transition-colors",
category === c
? "border-primary/50 bg-primary/10 text-primary font-medium"
: "border-border text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
{c === ALL_CATEGORIES ? "全部" : c}
</button>
))}
</div>
)}
{/* Bulk actions */}
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs tabular-nums">
{filtered.length} 个匹配技能
</span>
<div className="flex items-center gap-1.5">
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-xs"
disabled={filteredUnselected.length === 0}
onClick={addAllFiltered}
>
<PlusIcon className="size-3.5" />
添加当前筛选结果
{filteredUnselected.length > 0
? `(${filteredUnselected.length})`
: ""}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-muted-foreground h-7 text-xs"
disabled={selected.length === 0}
onClick={clearSelected}
>
<XIcon className="size-3.5" />
清空已选
</Button>
</div>
</div>
</div>
{/* Skill card grid */}
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-3">
{isLoading ? (
<div className="text-muted-foreground flex h-32 items-center justify-center gap-2 text-sm">
<Loader2Icon className="size-4 animate-spin" />
技能加载中…
</div>
) : filtered.length === 0 ? (
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center gap-1.5 text-sm">
<span>没有匹配的技能</span>
{(query || category !== ALL_CATEGORIES || source !== "all") && (
<span className="text-xs">试试调整搜索词或筛选条件</span>
)}
</div>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{filtered.map((skill) => {
const isSelected = selectedSet.has(skill.name);
return (
<div
key={skill.name}
role="button"
tabIndex={0}
onClick={() => toggle(skill.name)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggle(skill.name);
}
}}
title={isSelected ? "点击禁用" : "点击启用"}
className={cn(
"group/card flex cursor-pointer flex-col gap-2 rounded-xl border p-3 transition-colors",
isSelected
? "border-primary/50 bg-primary/5 ring-primary/15 ring-1"
: "bg-background/60 hover:border-primary/40 hover:bg-accent/40",
)}
>
<div className="flex items-start gap-2.5">
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold",
isSelected
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground",
)}
>
{skillInitial(skill)}
</div>
<div className="min-w-0 flex-1">
<div
className="truncate text-sm font-medium"
title={skillDisplayLabel(skill)}
>
{skillDisplayLabel(skill)}
</div>
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
{skill.description?.trim()
? desensitize(skill.description)
: "暂无描述"}
</p>
</div>
<Switch
checked={isSelected}
onCheckedChange={() => toggle(skill.name)}
onClick={(e) => e.stopPropagation()}
aria-label={isSelected ? "禁用技能" : "启用技能"}
className="shrink-0"
/>
</div>
<div className="flex items-center gap-1.5">
{skill.category?.trim() && (
<Badge variant="secondary" className="text-[10px]">
{skill.category}
</Badge>
)}
{skillSourceLabel(skill) && (
<Badge variant="outline" className="text-[10px]">
{skillSourceLabel(skill)}
</Badge>
)}
{isSelected && (
<Badge className="bg-primary/15 text-primary hover:bg-primary/15 text-[10px]">
<CheckIcon className="size-2.5" />
已启用
</Badge>
)}
{onViewDetail && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onViewDetail(skill.name);
}}
title="查看技能详情"
className="text-muted-foreground hover:text-foreground ml-auto opacity-60 transition-opacity group-hover/card:opacity-100"
>
<InfoIcon className="size-3.5" />
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{/* Footer: selected count + done */}
<div className="flex shrink-0 items-center justify-between border-t px-5 py-3">
<span className="text-sm">
已选 <span className="text-primary font-semibold tabular-nums">{selected.length}</span> 个技能
</span>
<Button size="sm" onClick={() => onOpenChange(false)}>
完成
</Button>
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,429 @@
"use client";
import {
ChevronLeftIcon,
ChevronRightIcon,
InboxIcon,
InfoIcon,
PlusIcon,
SearchIcon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { Skill } from "@/core/skills";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
const PAGE_SIZE = 5;
const ALL_CATEGORIES = "__all__";
interface SkillTransferSelectorProps {
/** Full pool of selectable skills (caller pre-filters by `enabled`). */
skills: Skill[];
/** Currently selected skill names — the persisted `skills: string[]`. */
selected: string[];
/** Emits the next selection (always a `string[]`, `[]` when empty). */
onChange: (next: string[]) => void;
isLoading?: boolean;
/** Open the skill detail dialog for a given skill name. */
onViewDetail?: (name: string) => void;
}
function skillLabel(skill: Skill) {
return skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name);
}
function matchesQuery(skill: Skill, q: string) {
if (!q) return true;
return (
skill.name.toLowerCase().includes(q) ||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
(skill.description ?? "").toLowerCase().includes(q)
);
}
export function SkillTransferSelector({
skills,
selected,
onChange,
isLoading,
onViewDetail,
}: SkillTransferSelectorProps) {
const [search, setSearch] = useState("");
const [category, setCategory] = useState<string>(ALL_CATEGORIES);
const [availablePage, setAvailablePage] = useState(0);
const [selectedPage, setSelectedPage] = useState(0);
const selectedSet = useMemo(() => new Set(selected), [selected]);
// Distinct categories for the filter dropdown (skip blank ones).
const categories = useMemo(() => {
const set = new Set<string>();
for (const skill of skills) {
if (skill.category?.trim()) set.add(skill.category.trim());
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [skills]);
const query = search.trim().toLowerCase();
// Left pane: unselected skills, filtered by search + category.
const availableSkills = useMemo(() => {
return skills.filter(
(skill) =>
!selectedSet.has(skill.name) &&
matchesQuery(skill, query) &&
(category === ALL_CATEGORIES || skill.category?.trim() === category),
);
}, [skills, selectedSet, query, category]);
// Right pane: selected skills, filtered by search only (category filtering
// the chosen set would just hide active selections, which is confusing).
const selectedSkills = useMemo(() => {
const byName = new Map(skills.map((s) => [s.name, s]));
return selected
.map((name) => byName.get(name))
.filter((s): s is Skill => Boolean(s) && matchesQuery(s!, query));
}, [skills, selected, query]);
// Clamp pagination whenever the underlying lists shrink (search/category
// changes, items moved between panes).
const availablePages = Math.max(1, Math.ceil(availableSkills.length / PAGE_SIZE));
const selectedPages = Math.max(1, Math.ceil(selectedSkills.length / PAGE_SIZE));
useEffect(() => {
if (availablePage > availablePages - 1) setAvailablePage(availablePages - 1);
}, [availablePage, availablePages]);
useEffect(() => {
if (selectedPage > selectedPages - 1) setSelectedPage(selectedPages - 1);
}, [selectedPage, selectedPages]);
const availableView = availableSkills.slice(
availablePage * PAGE_SIZE,
availablePage * PAGE_SIZE + PAGE_SIZE,
);
const selectedView = selectedSkills.slice(
selectedPage * PAGE_SIZE,
selectedPage * PAGE_SIZE + PAGE_SIZE,
);
function add(name: string) {
if (selectedSet.has(name)) return;
onChange([...selected, name]);
}
function remove(name: string) {
onChange(selected.filter((n) => n !== name));
}
function addAllFiltered() {
if (availableSkills.length === 0) return;
const merged = [...selected];
for (const skill of availableSkills) {
if (!selectedSet.has(skill.name)) merged.push(skill.name);
}
onChange(merged);
}
function clearSelected() {
if (selected.length === 0) return;
onChange([]);
}
if (isLoading) {
return (
<div className="text-muted-foreground rounded-md border p-4 text-sm dark:border-[#1B477E] dark:bg-[#022248]">
技能加载中...
</div>
);
}
if (skills.length === 0) {
return (
<div className="text-muted-foreground rounded-md border p-4 text-sm dark:border-[#1B477E] dark:bg-[#022248]">
当前没有可用技能。
</div>
);
}
return (
<div className="space-y-2.5">
{/* Toolbar: search + category filter */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
<Input
placeholder="搜索全部技能(中英文名称、描述)…"
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
setAvailablePage(0);
setSelectedPage(0);
}}
className="h-8 pl-7 text-xs"
/>
</div>
{categories.length > 0 && (
<Select
value={category}
onValueChange={(v) => {
setCategory(v);
setAvailablePage(0);
}}
>
<SelectTrigger
className={cn(
"border-input bg-background h-8 w-32 px-2 text-xs shadow-xs outline-none",
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:hover:bg-[#022248]",
"[&_svg]:dark:text-[#AACEE2]",
)}
>
<SelectValue placeholder="全部分类" />
</SelectTrigger>
<SelectContent
position="popper"
className="dark:border-[#1B477E] dark:bg-[#022248]"
>
<SelectItem
value={ALL_CATEGORIES}
className="dark:text-[#AACEFF] dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white"
>
全部分类
</SelectItem>
{categories.map((c) => (
<SelectItem
key={c}
value={c}
className="dark:text-[#AACEFF] dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white"
>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Bulk actions */}
<div className="flex items-center justify-end gap-2">
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-xs"
disabled={availableSkills.length === 0}
onClick={addAllFiltered}
>
<PlusIcon className="size-3.5" />
添加当前搜索结果{availableSkills.length > 0 ? `(${availableSkills.length})` : ""}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-muted-foreground h-7 text-xs"
disabled={selected.length === 0}
onClick={clearSelected}
>
<XIcon className="size-3.5" />
清空已选
</Button>
</div>
{/* Two-column transfer panes */}
<div className="grid grid-cols-2 gap-3">
<SkillPane
title="可用技能"
count={availableSkills.length}
items={availableView}
variant="available"
page={availablePage}
totalPages={availablePages}
onPageChange={setAvailablePage}
onPick={add}
onViewDetail={onViewDetail}
emptyHint={query || category !== ALL_CATEGORIES ? "没有匹配的技能" : "已全部启用"}
/>
<SkillPane
title="已启用技能"
count={selectedSkills.length}
items={selectedView}
variant="selected"
page={selectedPage}
totalPages={selectedPages}
onPageChange={setSelectedPage}
onPick={remove}
onViewDetail={onViewDetail}
emptyHint={query ? "没有匹配的已选技能" : "尚未启用任何技能"}
/>
</div>
</div>
);
}
interface SkillPaneProps {
title: string;
count: number;
items: Skill[];
variant: "available" | "selected";
page: number;
totalPages: number;
onPageChange: (page: number) => void;
onPick: (name: string) => void;
onViewDetail?: (name: string) => void;
emptyHint: string;
}
function SkillPane({
title,
count,
items,
variant,
page,
totalPages,
onPageChange,
onPick,
onViewDetail,
emptyHint,
}: SkillPaneProps) {
const isSelected = variant === "selected";
return (
<div className="flex flex-col overflow-hidden rounded-md border dark:border-[#1B477E] dark:bg-[#022248]">
<div className="bg-muted/40 flex items-center justify-between border-b px-2.5 py-1.5 text-xs font-medium dark:border-[#1B477E] dark:bg-[#02305f]">
<span>{title}</span>
<span className="text-muted-foreground tabular-nums">{count}</span>
</div>
<div className="h-[240px] space-y-1.5 overflow-y-auto p-2">
{items.length === 0 ? (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-1.5 text-xs">
<InboxIcon className="size-5 opacity-50" />
<span>{emptyHint}</span>
</div>
) : (
items.map((skill) => (
<SkillItemCard
key={skill.name}
skill={skill}
isSelected={isSelected}
onPick={() => onPick(skill.name)}
onViewDetail={
onViewDetail ? () => onViewDetail(skill.name) : undefined
}
/>
))
)}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t px-2 py-1 text-xs dark:border-[#1B477E]">
<Button
type="button"
size="icon-sm"
variant="ghost"
className="size-6"
disabled={page <= 0}
onClick={() => onPageChange(page - 1)}
>
<ChevronLeftIcon className="size-3.5" />
</Button>
<span className="text-muted-foreground tabular-nums">
{page + 1}/{totalPages}
</span>
<Button
type="button"
size="icon-sm"
variant="ghost"
className="size-6"
disabled={page >= totalPages - 1}
onClick={() => onPageChange(page + 1)}
>
<ChevronRightIcon className="size-3.5" />
</Button>
</div>
)}
</div>
);
}
interface SkillItemCardProps {
skill: Skill;
isSelected: boolean;
onPick: () => void;
onViewDetail?: () => void;
}
function SkillItemCard({
skill,
isSelected,
onPick,
onViewDetail,
}: SkillItemCardProps) {
return (
<div
role="button"
tabIndex={0}
onClick={onPick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onPick();
}
}}
title={isSelected ? "点击移除" : "点击添加"}
className={cn(
"group/item relative cursor-pointer rounded-md border px-2.5 py-1.5 pr-7 transition-colors",
isSelected
? "border-primary/60 bg-primary/5 hover:border-primary dark:border-[#3B82F6] dark:bg-[#0a3568]"
: "hover:border-primary/40 hover:bg-accent/40 dark:border-[#213D5E] dark:bg-[#02244C] dark:hover:border-[#3B82F6]/60",
)}
>
<div className="flex items-center gap-1.5">
<span
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-full text-[10px]",
isSelected
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground group-hover/item:bg-primary/15 group-hover/item:text-primary",
)}
>
{isSelected ? (
<XIcon className="size-3" />
) : (
<PlusIcon className="size-3" />
)}
</span>
<div className="min-w-0 flex-1">
<div
className="truncate text-xs font-medium"
title={skillLabel(skill)}
>
{skillLabel(skill)}
</div>
{skill.description?.trim() && (
<div className="text-muted-foreground truncate text-[11px]">
{desensitize(skill.description)}
</div>
)}
</div>
</div>
{onViewDetail && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onViewDetail();
}}
title="查看技能详情"
className="text-muted-foreground hover:text-foreground absolute right-1.5 top-1.5 opacity-60 transition-opacity group-hover/item:opacity-100"
>
<InfoIcon className="size-3.5" />
</button>
)}
</div>
);
}
@@ -76,6 +76,7 @@ import { useThread } from "../messages/context";
import { Tooltip } from "../tooltip"; import { Tooltip } from "../tooltip";
import { useArtifacts } from "./context"; import { useArtifacts } from "./context";
import { SpreadsheetPreview } from "./spreadsheet-preview";
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll"; const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
@@ -137,6 +138,11 @@ export function ArtifactFileDetail({
const isSkillFile = useMemo(() => { const isSkillFile = useMemo(() => {
return filepath.endsWith(".skill"); return filepath.endsWith(".skill");
}, [filepath]); }, [filepath]);
// .xlsx is a ZIP archive — the generic binary iframe preview renders its
// raw bytes as mojibake, so it gets a dedicated table preview instead.
const isSpreadsheetFile = useMemo(() => {
return /\.xlsx$/i.test(filepath);
}, [filepath]);
const { isCodeFile, language } = useMemo(() => { const { isCodeFile, language } = useMemo(() => {
if (useWriteFileStreaming) { if (useWriteFileStreaming) {
let nextLanguage = checkCodeFile(filepath).language; let nextLanguage = checkCodeFile(filepath).language;
@@ -174,13 +180,14 @@ export function ArtifactFileDetail({
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1; const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => { const fileTypeLabel = useMemo(() => {
if (isImageFile(filepath)) return "IMG"; if (isImageFile(filepath)) return "IMG";
if (isSpreadsheetFile) return "XLSX";
if (isMermaidFile(filepath) || language === "mermaid") return "MMD"; if (isMermaidFile(filepath) || language === "mermaid") return "MMD";
if (language === "html") return "HTML"; if (language === "html") return "HTML";
if (language === "markdown") return "MD"; if (language === "markdown") return "MD";
if (isSkillFile) return "SKILL"; if (isSkillFile) return "SKILL";
if (language) return language.toUpperCase(); if (language) return language.toUpperCase();
return "FILE"; return "FILE";
}, [filepath, isSkillFile, language]); }, [filepath, isSkillFile, isSpreadsheetFile, language]);
const [viewMode, setViewMode] = useState<"code" | "preview">("code"); const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true); const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
@@ -191,6 +198,9 @@ export function ArtifactFileDetail({
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>( const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(
null, null,
); );
const [spreadsheetData, setSpreadsheetData] = useState<ArrayBuffer | null>(
null,
);
const artifactContentRef = useRef<HTMLDivElement | null>(null); const artifactContentRef = useRef<HTMLDivElement | null>(null);
const resumeButtonTimeoutRef = useRef<number | null>(null); const resumeButtonTimeoutRef = useRef<number | null>(null);
@@ -310,6 +320,7 @@ export function ArtifactFileDetail({
useEffect(() => { useEffect(() => {
if (isCodeFile) { if (isCodeFile) {
setSpreadsheetData(null);
setBinaryPreviewUrl((prev) => { setBinaryPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev); if (prev) URL.revokeObjectURL(prev);
return null; return null;
@@ -325,6 +336,13 @@ export function ArtifactFileDetail({
urlOfArtifact({ filepath, threadId, isMock, publicShareCode }), urlOfArtifact({ filepath, threadId, isMock, publicShareCode }),
); );
if (cancelled || !res.ok) return; if (cancelled || !res.ok) return;
if (isSpreadsheetFile) {
const buffer = await res.arrayBuffer();
if (!cancelled) {
setSpreadsheetData(buffer);
}
return;
}
const blob = await res.blob(); const blob = await res.blob();
if (cancelled) return; if (cancelled) return;
const u = URL.createObjectURL(blob); const u = URL.createObjectURL(blob);
@@ -342,12 +360,13 @@ export function ArtifactFileDetail({
return () => { return () => {
cancelled = true; cancelled = true;
setSpreadsheetData(null);
setBinaryPreviewUrl((prev) => { setBinaryPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev); if (prev) URL.revokeObjectURL(prev);
return null; return null;
}); });
}; };
}, [filepath, threadId, isMock, isCodeFile, publicShareCode]); }, [filepath, threadId, isMock, isCodeFile, isSpreadsheetFile, publicShareCode]);
const handlePauseAutoScroll = useCallback(() => { const handlePauseAutoScroll = useCallback(() => {
if (!thread.isLoading || !autoScrollEnabled) { if (!thread.isLoading || !autoScrollEnabled) {
@@ -766,7 +785,18 @@ export function ArtifactFileDetail({
/> />
</div> </div>
)} )}
{!isCodeFile && isSpreadsheetFile && (
spreadsheetData ? (
<SpreadsheetPreview data={spreadsheetData} />
) : (
<div className="text-muted-foreground flex size-full items-center justify-center gap-2 text-sm">
<LoaderIcon className="size-5 animate-spin" />
{t.common.loading}
</div>
)
)}
{!isCodeFile && {!isCodeFile &&
!isSpreadsheetFile &&
(binaryPreviewUrl ? ( (binaryPreviewUrl ? (
<iframe className="size-full bg-white" src={binaryPreviewUrl} /> <iframe className="size-full bg-white" src={binaryPreviewUrl} />
) : ( ) : (
@@ -0,0 +1,151 @@
"use client";
import { LoaderIcon, TriangleAlertIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { parseXlsx, type ParsedWorksheet } from "@/core/utils/xlsx";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/**
* Renders an .xlsx ArrayBuffer as sheet tabs + an HTML table, replacing the
* old binary-blob iframe preview that showed the raw ZIP bytes as mojibake.
*/
export function SpreadsheetPreview({
data,
className,
}: {
data: ArrayBuffer;
className?: string;
}) {
const [sheets, setSheets] = useState<ParsedWorksheet[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [activeSheetIndex, setActiveSheetIndex] = useState(0);
useEffect(() => {
let cancelled = false;
setSheets(null);
setError(null);
setActiveSheetIndex(0);
parseXlsx(data)
.then((parsed) => {
if (!cancelled) {
setSheets(parsed);
}
})
.catch((err) => {
console.error("xlsx preview parse failed:", err);
if (!cancelled) {
setError(err instanceof Error ? err.message : "解析失败");
}
});
return () => {
cancelled = true;
};
}, [data]);
if (error) {
return (
<div
className={cn(
"text-muted-foreground flex size-full flex-col items-center justify-center gap-2 p-6 text-sm",
className,
)}
>
<TriangleAlertIcon className="size-5" />
<div>无法预览此表格文件:{error}</div>
<div className="text-xs">可点击右上角下载后用本地软件打开。</div>
</div>
);
}
if (!sheets) {
return (
<div
className={cn(
"text-muted-foreground flex size-full items-center justify-center gap-2 text-sm",
className,
)}
>
<LoaderIcon className="size-5 animate-spin" />
正在解析表格…
</div>
);
}
const activeSheet = sheets[activeSheetIndex] ?? sheets[0]!;
const [headerRow, ...bodyRows] = activeSheet.rows;
const columnCount = Math.max(
headerRow?.length ?? 0,
...activeSheet.rows.map((row) => row.length),
1,
);
return (
<div
data-artifact-preview-scroll
className={cn("flex size-full flex-col overflow-hidden", className)}
>
{sheets.length > 1 && (
<div className="border-border/70 flex shrink-0 items-center gap-1 overflow-x-auto border-b px-3 py-2">
{sheets.map((sheet, index) => (
<button
key={`${sheet.name}-${index}`}
type="button"
onClick={() => setActiveSheetIndex(index)}
className={cn(
"shrink-0 rounded-md px-2.5 py-1 text-xs transition-colors",
index === activeSheetIndex
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{desensitize(sheet.name)}
</button>
))}
</div>
)}
<div className="min-h-0 flex-1 overflow-auto p-3">
{activeSheet.rows.length === 0 ? (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
(空工作表)
</div>
) : (
<table className="border-border w-max min-w-full border-collapse text-sm">
<thead>
<tr>
{Array.from({ length: columnCount }, (_, columnIndex) => (
<th
key={columnIndex}
className="border-border bg-muted/60 text-foreground sticky top-0 border px-3 py-1.5 text-left font-medium whitespace-pre-wrap"
>
{desensitize(headerRow?.[columnIndex] ?? "")}
</th>
))}
</tr>
</thead>
<tbody>
{bodyRows.map((row, rowIndex) => (
<tr key={rowIndex} className="even:bg-muted/30">
{Array.from({ length: columnCount }, (_, columnIndex) => (
<td
key={columnIndex}
className="border-border border px-3 py-1.5 align-top whitespace-pre-wrap"
>
{desensitize(row[columnIndex] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
)}
{activeSheet.truncated && (
<div className="text-muted-foreground py-2 text-center text-xs">
表格过大,仅预览部分内容;完整数据请下载文件查看。
</div>
)}
</div>
</div>
);
}
@@ -33,7 +33,7 @@ import {
ArtifactFileList, ArtifactFileList,
useArtifacts, useArtifacts,
} from "../artifacts"; } from "../artifacts";
import { useThread } from "../messages/context"; import { ReferenceBatchesContext, useThread } from "../messages/context";
import { SkillDisplaySources } from "../skill-display-sources"; import { SkillDisplaySources } from "../skill-display-sources";
const CLOSE_MODE = { chat: 100, artifacts: 0 }; const CLOSE_MODE = { chat: 100, artifacts: 0 };
@@ -362,10 +362,15 @@ const ChatBox: React.FC<{
}, [sidePanelOpen]); }, [sidePanelOpen]);
if (disableArtifacts) { if (disableArtifacts) {
return <>{children}</>; return (
<ReferenceBatchesContext.Provider value={referenceBatches}>
{children}
</ReferenceBatchesContext.Provider>
);
} }
return ( return (
<ReferenceBatchesContext.Provider value={referenceBatches}>
<ResizablePanelGroup <ResizablePanelGroup
id={`${resizableIdBase}-panels`} id={`${resizableIdBase}-panels`}
orientation="horizontal" orientation="horizontal"
@@ -490,6 +495,7 @@ const ChatBox: React.FC<{
</div> </div>
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
</ReferenceBatchesContext.Provider>
); );
}; };
@@ -133,6 +133,7 @@ export function InputBox({
promptPrefix, promptPrefix,
agentSelector, agentSelector,
assistantId, assistantId,
hideAdminRecommendedQuestions,
...props ...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & { }: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null; assistantId?: string | null;
@@ -149,6 +150,9 @@ export function InputBox({
}; };
extraHeader?: React.ReactNode; extraHeader?: React.ReactNode;
isNewThread?: boolean; isNewThread?: boolean;
/** Suppress the global admin "recommended questions" row. The agent chat page
* sets this so only the agent's own common questions surface on its home. */
hideAdminRecommendedQuestions?: boolean;
threadId: string; threadId: string;
initialValue?: string; initialValue?: string;
onContextChange?: ( onContextChange?: (
@@ -208,6 +212,7 @@ export function InputBox({
const { questions: recommendedQuestions } = useRecommendedQuestions(); const { questions: recommendedQuestions } = useRecommendedQuestions();
const userPreferences = useUserPreferences(); const userPreferences = useUserPreferences();
const showAdminRecommendedQuestions = const showAdminRecommendedQuestions =
!hideAdminRecommendedQuestions &&
!userPreferences.hide_admin_prompt_prefix && !userPreferences.hide_admin_prompt_prefix &&
recommendedQuestions.length > 0; recommendedQuestions.length > 0;
@@ -1,6 +1,7 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react"; import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
export interface ThreadContextType { export interface ThreadContextType {
@@ -26,3 +27,18 @@ export function useThread() {
} }
return context; return context;
} }
/**
* One turn's reference sources, keyed to the assistant message that answered
* the turn. Provided by ChatBox so inline citation rendering and the
* right-side reference panel always resolve `[n]` against the SAME sources
* (the panel prefers backend reference batches, which the per-message
* frontend collection in MessageList knows nothing about).
*/
export interface ReferenceBatch {
id: string;
assistant_message_id?: string;
sources: RuntimeDisplaySource[];
}
export const ReferenceBatchesContext = createContext<ReferenceBatch[]>([]);
@@ -358,6 +358,20 @@ function MessageImage({
); );
} }
// While a notebook citation marker is still streaming in (e.g. "[[cite:64" or
// "[[cite:101]"), Streamdown's parseIncompleteMarkdown treats the trailing
// unclosed "[" as an incomplete link and rewrites it to
// "[[cite:64](streamdown:incomplete-link)", which the rehypeCitations regex can
// no longer match — so the citation flickers as a broken link mid-stream. We
// strip the half-arrived marker so it stays hidden until the closing "]]"
// arrives, at which point rehypeCitations renders it. A complete
// "[[cite:101]]" has no trailing incomplete marker, so this is a no-op.
const INCOMPLETE_CITE_TAIL = /\[\[(?:c(?:i(?:t(?:e(?::\d*\]?)?)?)?)?)?$/;
function stripIncompleteCitationMarker(content: string): string {
return content.replace(INCOMPLETE_CITE_TAIL, '');
}
function MessageContent_({ function MessageContent_({
className, className,
message, message,
@@ -460,7 +474,8 @@ function MessageContent_({
? stripArtifactTag(stripUploadedFilesTag(rawContent)) ? stripArtifactTag(stripUploadedFilesTag(rawContent))
: ''; : '';
} }
return applyInlineCitations(rawContent ?? '', displaySources); const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
return stripIncompleteCitationMarker(withCitations);
}, [rawContent, isHuman, displaySources]); }, [rawContent, isHuman, displaySources]);
const parsedHumanContent = useMemo( const parsedHumanContent = useMemo(
() => () =>
@@ -37,7 +37,7 @@ import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { ArtifactFileList } from "../artifacts/artifact-file-list"; import { ArtifactFileList } from "../artifacts/artifact-file-list";
import { StreamingIndicator } from "../streaming-indicator"; import { StreamingIndicator } from "../streaming-indicator";
import { ThreadContext } from "./context"; import { ReferenceBatchesContext, ThreadContext } from "./context";
import { MarkdownContent } from "./markdown-content"; import { MarkdownContent } from "./markdown-content";
import { MessageGroup } from "./message-group"; import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item"; import { MessageListItem } from "./message-list-item";
@@ -146,6 +146,11 @@ export function MessageList({
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), () => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills], [ragModeEnabled, skills],
); );
// Same per-turn reference batches the right-side panel renders (backend
// batches preferred). Inline [n] tokens must resolve against these so badge
// numbering matches the panel, and so citations still render when the
// frontend tool-message scan below comes up empty (e.g. after a reload).
const referenceBatches = useContext(ReferenceBatchesContext);
// Track which clarification messages are still awaiting an answer. // Track which clarification messages are still awaiting an answer.
// Pending = clarification tool messages that appear after the last human message // Pending = clarification tool messages that appear after the last human message
@@ -270,11 +275,19 @@ export function MessageList({
{groupMessages(messages, (group) => { {groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") { if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => { return group.messages.map((msg) => {
const displaySources = const isAssistantMessage =
ragModeEnabled && ragModeEnabled &&
group.type === "assistant" && group.type === "assistant" &&
msg.type === "ai" msg.type === "ai";
? collectRuntimeDisplaySources(messages, skillDisplayMap, { const batchSources = isAssistantMessage
? referenceBatches.find(
(batch) => batch.assistant_message_id === msg.id,
)?.sources ?? []
: [];
const displaySources = isAssistantMessage
? batchSources.length > 0
? batchSources
: collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id), afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id, beforeMessageId: msg.id,
includeBeforeMessage: true, includeBeforeMessage: true,
@@ -0,0 +1,114 @@
"use client";
import { CheckIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { PublishSquare } from "@/core/system-settings/api";
import { cn } from "@/lib/utils";
interface PublishSquarePickerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
squares: PublishSquare[];
/** Pre-selected square id (current square, or the configured default). */
defaultSquareId?: string;
/** Called with the chosen square id when the user confirms. */
onConfirm: (squareId: string) => void;
loading?: boolean;
title?: string;
description?: string;
}
/** A "publish to which square (发布广场)?" chooser. Squares are rendered as a
* selectable list (not a dropdown) so the choice is explicit. */
export function PublishSquarePickerDialog({
open,
onOpenChange,
squares,
defaultSquareId,
onConfirm,
loading,
title = "选择发布广场",
description = "请选择要把它发布到哪个广场。每个项目只能归属一个广场,不会重复出现。",
}: PublishSquarePickerDialogProps) {
const [selected, setSelected] = useState<string>(
defaultSquareId || squares[0]?.id || "",
);
// Re-seed the selection whenever the dialog (re)opens or the default changes.
useEffect(() => {
if (open) {
setSelected(defaultSquareId || squares[0]?.id || "");
}
}, [open, defaultSquareId, squares]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="max-h-[50vh] space-y-1.5 overflow-y-auto py-1">
{squares.map((sq) => {
const active = selected === sq.id;
return (
<button
key={sq.id}
type="button"
onClick={() => setSelected(sq.id)}
className={cn(
"flex w-full items-start gap-2 rounded-md border px-3 py-2 text-left transition-colors",
active
? "border-primary bg-primary/5 dark:border-[#3B82F6] dark:bg-[#0a3568]"
: "hover:border-primary/40 hover:bg-accent/40 dark:border-[#213D5E]",
)}
>
<span
className={cn(
"mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border",
active
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/40",
)}
>
{active && <CheckIcon className="size-3" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{sq.name}
</span>
{sq.description?.trim() && (
<span className="text-muted-foreground block truncate text-xs">
{sq.description}
</span>
)}
</span>
</button>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
取消
</Button>
<Button
onClick={() => onConfirm(selected)}
disabled={loading || !selected}
>
{loading ? "发布中…" : "确认发布"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -31,6 +31,7 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage"; import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage"; import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
import RecommendedQuestionsPage from "@/pages/RecommendedQuestionsPage"; import RecommendedQuestionsPage from "@/pages/RecommendedQuestionsPage";
@@ -349,6 +350,8 @@ export function AppearanceSettingsPage() {
<PromptPrefixSettingsPage embedded /> <PromptPrefixSettingsPage embedded />
<Separator /> <Separator />
<PrivateSquareSettingsPage embedded /> <PrivateSquareSettingsPage embedded />
<Separator />
<PublishSquaresSettingsPage embedded />
</> </>
)} )}
</div> </div>
@@ -58,6 +58,7 @@ import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ModelNameSelect } from "@/components/workspace/model-name-select"; import { ModelNameSelect } from "@/components/workspace/model-name-select";
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog"; import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { TagChipList } from "@/components/workspace/tags/tag-chip"; import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog"; import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
@@ -81,7 +82,7 @@ import {
useSkills, useSkills,
useValidateSkillUpload, useValidateSkillUpload,
} from "@/core/skills/hooks"; } from "@/core/skills/hooks";
import { useViewMode } from "@/core/system-settings/hooks"; import { usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
import type { Skill } from "@/core/skills/type"; import type { Skill } from "@/core/skills/type";
import { env } from "@/env"; import { env } from "@/env";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
@@ -164,6 +165,10 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
null, null,
); );
const [viewMode, setViewMode] = useViewMode("skill"); const [viewMode, setViewMode] = useViewMode("skill");
const { data: publishSquares } = usePublishSquares();
// Each configured publish square (发布广场) becomes its own browse tab; the
// backend always returns at least one (the built-in default).
const squareTabs = publishSquares?.squares ?? [];
const reconcileMutation = useReconcileCustomSkills(); const reconcileMutation = useReconcileCustomSkills();
const fillNamesMutation = useGenerateMissingSkillNameZh(); const fillNamesMutation = useGenerateMissingSkillNameZh();
// Optional model override for the admin "一键补充中文名" action (null = default). // Optional model override for the admin "一键补充中文名" action (null = default).
@@ -214,14 +219,25 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
}; };
}, [skills, currentUserId]); }, [skills, currentUserId]);
// "mine" tab shows my own skills; every other tab value is a square id whose
// public skills come from the published pool.
const isSquareTab = filter !== "mine";
const scopedSkills = filter === "mine" ? activeMine : activeAll; const scopedSkills = filter === "mine" ? activeMine : activeAll;
const filteredSkills = useMemo( const filteredSkills = useMemo(() => {
() => let list = publisher
publisher
? scopedSkills.filter((s) => s.owner_user_id === publisher.id) ? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
: scopedSkills, : scopedSkills;
[scopedSkills, publisher], // On a square tab, keep only that square's skills. The default square also
); // sweeps in legacy/unassigned published skills (square_id === "").
if (isSquareTab) {
const isDefault = filter === publishSquares?.default_square_id;
list = list.filter((s) => {
const sid = s.square_id ?? "";
return sid === filter || (isDefault && !sid);
});
}
return list;
}, [scopedSkills, publisher, filter, isSquareTab, publishSquares?.default_square_id]);
const orderedSkills = useMemo( const orderedSkills = useMemo(
() => () =>
filteredSkills filteredSkills
@@ -247,6 +263,18 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
useEffect(() => { useEffect(() => {
setPage(1); setPage(1);
}, [search, tagIds, sort, publisher?.id, filter]); }, [search, tagIds, sort, publisher?.id, filter]);
// If the active square tab disappears (admin removed that square), fall back
// to "我的" so the page never points at a dead tab.
useEffect(() => {
if (
filter !== "mine" &&
squareTabs.length > 0 &&
!squareTabs.some((sq) => sq.id === filter)
) {
setFilter("mine");
}
}, [filter, squareTabs]);
const pagedSkills = useMemo( const pagedSkills = useMemo(
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), () => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[orderedSkills, page], [orderedSkills, page],
@@ -304,9 +332,13 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<header className="flex flex-wrap items-center justify-between gap-3"> <header className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Tabs value={filter} onValueChange={setFilter}> <Tabs value={filter} onValueChange={setFilter}>
<TabsList variant="line"> <TabsList variant="line" className="flex-wrap">
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger> <TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger> {squareTabs.map((sq) => (
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
{sq.name}
</TabsTrigger>
))}
</TabsList> </TabsList>
</Tabs> </Tabs>
<Button <Button
@@ -544,6 +576,7 @@ export function SkillRow({
const isCard = layout === "card"; const isCard = layout === "card";
const { t } = useI18n(); const { t } = useI18n();
const setPublished = useSetSkillPublished(); const setPublished = useSetSkillPublished();
const { data: cardPublishSquares } = usePublishSquares();
const setPinned = useSetSkillPinned(); const setPinned = useSetSkillPinned();
const setFeatured = useSetSkillFeatured(); const setFeatured = useSetSkillFeatured();
const deleteSkill = useDeleteCustomSkill(); const deleteSkill = useDeleteCustomSkill();
@@ -553,6 +586,7 @@ export function SkillRow({
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
const [duplicateOpen, setDuplicateOpen] = useState(false); const [duplicateOpen, setDuplicateOpen] = useState(false);
const [duplicateName, setDuplicateName] = useState(""); const [duplicateName, setDuplicateName] = useState("");
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
const isCustom = skill.category === "custom"; const isCustom = skill.category === "custom";
// Built-in flag falls back to "no DB owner" so legacy responses without // Built-in flag falls back to "no DB owner" so legacy responses without
@@ -566,6 +600,12 @@ export function SkillRow({
const canTogglePublish = canManage && !isBuiltin; const canTogglePublish = canManage && !isBuiltin;
async function handleTogglePublished(next: boolean) { async function handleTogglePublished(next: boolean) {
// Publishing: prompt for which square to publish into (when configured).
// Unpublishing: just flip it off.
if (next && (cardPublishSquares?.squares.length ?? 0) > 0) {
setPublishPickerOpen(true);
return;
}
try { try {
await setPublished.mutateAsync({ skillName: skill.name, published: next }); await setPublished.mutateAsync({ skillName: skill.name, published: next });
toast.success( toast.success(
@@ -578,6 +618,24 @@ export function SkillRow({
} }
} }
// Publish the skill into a chosen square. Each skill belongs to exactly one
// square; re-publishing with a new square_id relocates rather than duplicates.
async function handlePublishToSquare(squareId: string) {
try {
await setPublished.mutateAsync({
skillName: skill.name,
published: true,
squareId,
});
const sqName =
cardPublishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场";
toast.success(`已发布到「${sqName}」`);
setPublishPickerOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete() { async function handleDelete() {
try { try {
await deleteSkill.mutateAsync(skill.name); await deleteSkill.mutateAsync(skill.name);
@@ -979,6 +1037,16 @@ export function SkillRow({
open={detailOpen} open={detailOpen}
onOpenChange={setDetailOpen} onOpenChange={setDetailOpen}
/> />
<PublishSquarePickerDialog
open={publishPickerOpen}
onOpenChange={setPublishPickerOpen}
squares={cardPublishSquares?.squares ?? []}
defaultSquareId={skill.square_id || cardPublishSquares?.default_square_id}
loading={setPublished.isPending}
onConfirm={(squareId) => void handlePublishToSquare(squareId)}
description="请选择要把该技能发布到哪个广场。每个技能只能归属一个广场,不会重复出现。"
/>
</> </>
); );
} }
@@ -273,9 +273,24 @@ function StatRow({
); );
} }
/** Select 不允许空字符串 value,用此哨兵代表"用主模型"(对应 model_name=null)。 */ interface CuratorDraft {
interval_hours: number;
stale_after_days: number;
archive_after_days: number;
model_name: string | null;
}
const DEFAULT_MODEL_VALUE = "__default__"; const DEFAULT_MODEL_VALUE = "__default__";
function toDraft(config: CuratorConfig): CuratorDraft {
return {
interval_hours: config.curator.interval_hours,
stale_after_days: config.curator.stale_after_days,
archive_after_days: config.curator.archive_after_days,
model_name: config.curator.model_name,
};
}
function ConfigCard() { function ConfigCard() {
const { t } = useI18n(); const { t } = useI18n();
const tt = t.settings.skillEvolution.config; const tt = t.settings.skillEvolution.config;
@@ -283,10 +298,10 @@ function ConfigCard() {
const updateConfig = useUpdateCuratorConfig(); const updateConfig = useUpdateCuratorConfig();
const { models } = useModels(); const { models } = useModels();
const [draft, setDraft] = useState<CuratorConfig | null>(null); const [draft, setDraft] = useState<CuratorDraft | null>(null);
useEffect(() => { useEffect(() => {
if (config && !draft) { if (config && !draft) {
setDraft(config); setDraft(toDraft(config));
} }
}, [config, draft]); }, [config, draft]);
@@ -305,14 +320,12 @@ function ConfigCard() {
); );
} }
const isDirty = const original = config ? toDraft(config) : null;
!!config && const isDirty = !!original && JSON.stringify(draft) !== JSON.stringify(original);
JSON.stringify(draft) !== JSON.stringify(config);
const handleSave = async () => { const handleSave = async () => {
try { try {
const updated = await updateConfig.mutateAsync(draft); await updateConfig.mutateAsync(draft);
setDraft(updated);
toast.success(tt.saveSuccess); toast.success(tt.saveSuccess);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : String(err)); toast.error(err instanceof Error ? err.message : String(err));
@@ -326,29 +339,6 @@ function ConfigCard() {
<CardDescription>{tt.description}</CardDescription> <CardDescription>{tt.description}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
<FieldRow
label={tt.enabledLabel}
helper={tt.enabledHelper}
>
<Switch
checked={draft.enabled}
onCheckedChange={(v) => setDraft({ ...draft, enabled: v })}
/>
</FieldRow>
<FieldRow
label={tt.curatorEnabledLabel}
helper={tt.curatorEnabledHelper}
>
<Switch
checked={draft.curator.enabled}
onCheckedChange={(v) =>
setDraft({
...draft,
curator: { ...draft.curator, enabled: v },
})
}
/>
</FieldRow>
<FieldRow <FieldRow
label={tt.intervalHoursLabel} label={tt.intervalHoursLabel}
helper={tt.intervalHoursHelper} helper={tt.intervalHoursHelper}
@@ -357,15 +347,9 @@ function ConfigCard() {
type="number" type="number"
min={1} min={1}
className="max-w-[10rem]" className="max-w-[10rem]"
value={draft.curator.interval_hours} value={draft.interval_hours}
onChange={(e) => onChange={(e) =>
setDraft({ setDraft({ ...draft, interval_hours: Math.max(1, Number(e.target.value) || 1) })
...draft,
curator: {
...draft.curator,
interval_hours: Math.max(1, Number(e.target.value) || 1),
},
})
} }
/> />
</FieldRow> </FieldRow>
@@ -377,15 +361,9 @@ function ConfigCard() {
type="number" type="number"
min={1} min={1}
className="max-w-[10rem]" className="max-w-[10rem]"
value={draft.curator.stale_after_days} value={draft.stale_after_days}
onChange={(e) => onChange={(e) =>
setDraft({ setDraft({ ...draft, stale_after_days: Math.max(1, Number(e.target.value) || 1) })
...draft,
curator: {
...draft.curator,
stale_after_days: Math.max(1, Number(e.target.value) || 1),
},
})
} }
/> />
</FieldRow> </FieldRow>
@@ -397,15 +375,9 @@ function ConfigCard() {
type="number" type="number"
min={1} min={1}
className="max-w-[10rem]" className="max-w-[10rem]"
value={draft.curator.archive_after_days} value={draft.archive_after_days}
onChange={(e) => onChange={(e) =>
setDraft({ setDraft({ ...draft, archive_after_days: Math.max(1, Number(e.target.value) || 1) })
...draft,
curator: {
...draft.curator,
archive_after_days: Math.max(1, Number(e.target.value) || 1),
},
})
} }
/> />
</FieldRow> </FieldRow>
@@ -414,15 +386,9 @@ function ConfigCard() {
helper={tt.curatorModelHelper} helper={tt.curatorModelHelper}
> >
<Select <Select
value={draft.curator.model_name ?? DEFAULT_MODEL_VALUE} value={draft.model_name ?? DEFAULT_MODEL_VALUE}
onValueChange={(v) => onValueChange={(v) =>
setDraft({ setDraft({ ...draft, model_name: v === DEFAULT_MODEL_VALUE ? null : v })
...draft,
curator: {
...draft.curator,
model_name: v === DEFAULT_MODEL_VALUE ? null : v,
},
})
} }
> >
<SelectTrigger <SelectTrigger
@@ -451,7 +417,7 @@ function ConfigCard() {
variant="outline" variant="outline"
size="sm" size="sm"
disabled={!isDirty || updateConfig.isPending} disabled={!isDirty || updateConfig.isPending}
onClick={() => config && setDraft(config)} onClick={() => original && setDraft(original)}
> >
{tt.reset} {tt.reset}
</Button> </Button>
@@ -19,9 +19,6 @@ import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSq
import { SidebarCollapseButton } from "./workspace-header"; import { SidebarCollapseButton } from "./workspace-header";
import { WorkspaceNavStudio } from "./workspace-nav-studio"; import { WorkspaceNavStudio } from "./workspace-nav-studio";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { env } from "@/env";
const studioEnabled = env.STUDIO_ENABLED?.toLowerCase() === "true";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标 // 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
const NAV_ITEM_CLASS = const NAV_ITEM_CLASS =
@@ -172,7 +169,7 @@ export function WorkspaceNavChatList({
> >
<Link href="/page/workspace/knowledge"> <Link href="/page/workspace/knowledge">
<BookOpen /> <BookOpen />
<span>知识库</span> <span>问答梳理</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
@@ -180,7 +177,7 @@ export function WorkspaceNavChatList({
)} )}
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
{!showCollapseTrigger && studioEnabled && <WorkspaceNavStudio />} {!showCollapseTrigger && <WorkspaceNavStudio />}
</> </>
); );
} }
@@ -65,7 +65,8 @@ export function WorkspaceNavStudio() {
} }
}; };
if (isError || (!isLoading && notebooks.length === 0)) { // 加载中、token 失败、空列表都不渲染整段,避免在 token 未确认前先闪一段工作室 UI 再消失。
if (isLoading || isError || notebooks.length === 0) {
return null; return null;
} }
@@ -90,25 +91,19 @@ export function WorkspaceNavStudio() {
<CollapsibleContent> <CollapsibleContent>
<SidebarMenu className="mt-0.5 gap-0.5"> <SidebarMenu className="mt-0.5 gap-0.5">
{/* 新建笔记本 */} {/* 新建空间 */}
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton
className={cn(NAV_ITEM_CLASS)} className={cn(NAV_ITEM_CLASS)}
onClick={() => { setDialogOpen(true); setError(""); setName(""); }} onClick={() => { setDialogOpen(true); setError(""); setName(""); }}
> >
<Plus className="shrink-0" /> <Plus className="shrink-0" />
<span>新建笔记本</span> <span>新建空间</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
{/* 笔记本列表(最多展示 SIDEBAR_PREVIEW_COUNT 条) */} {/* 空间列表(最多展示 SIDEBAR_PREVIEW_COUNT 条) */}
{isLoading {preview.map((nb) => (
? Array.from({ length: 3 }).map((_, i) => (
<SidebarMenuItem key={i}>
<div className="mx-3 my-1 h-5 animate-pulse rounded-md bg-sidebar-accent/40" />
</SidebarMenuItem>
))
: preview.map((nb) => (
<SidebarMenuItem key={nb.id}> <SidebarMenuItem key={nb.id}>
<SidebarMenuButton <SidebarMenuButton
asChild asChild
@@ -126,8 +121,8 @@ export function WorkspaceNavStudio() {
</SidebarMenuItem> </SidebarMenuItem>
))} ))}
{/* 所有笔记本 */} {/* 所有空间 */}
{!isLoading && notebooks.length > 0 && ( {notebooks.length > 0 && (
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton
asChild asChild
@@ -135,8 +130,8 @@ export function WorkspaceNavStudio() {
className={cn(NAV_ITEM_CLASS)} className={cn(NAV_ITEM_CLASS)}
> >
<Link to="/page/workspace/studio/notebooks"> <Link to="/page/workspace/studio/notebooks">
<span className="mr-0.5 text-base leading-none">···</span> {/* <span className="mr-0.5 text-base leading-none">···</span> */}
<span>所有笔记本</span> <span>所有空间</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
@@ -149,7 +144,7 @@ export function WorkspaceNavStudio() {
<Dialog open={dialogOpen} onOpenChange={(v) => !creating && setDialogOpen(v)}> <Dialog open={dialogOpen} onOpenChange={(v) => !creating && setDialogOpen(v)}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>新建笔记本</DialogTitle> <DialogTitle>新建空间</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-4 pt-2"> <div className="flex flex-col gap-4 pt-2">
<input <input
@@ -157,7 +152,7 @@ export function WorkspaceNavStudio() {
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }} onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }}
placeholder="笔记本名称" placeholder="空间名称"
disabled={creating} disabled={creating}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50" className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
/> />
-12
View File
@@ -93,15 +93,6 @@ export interface AdminLeaderboardDimension {
last_seen_at: string; last_seen_at: string;
} }
export interface AdminLeaderboardQuestion {
text: string;
user_id: string;
email: string;
thread_id: string;
run_id: string;
asked_at: string;
}
export interface AdminLeaderboardTrendPoint { export interface AdminLeaderboardTrendPoint {
date: string; date: string;
runs: number; runs: number;
@@ -138,8 +129,6 @@ export interface AdminLeaderboardResponse {
models: AdminLeaderboardDimension[]; models: AdminLeaderboardDimension[];
agents: AdminLeaderboardDimension[]; agents: AdminLeaderboardDimension[];
run_statuses: AdminLeaderboardDimension[]; run_statuses: AdminLeaderboardDimension[];
recent_questions: AdminLeaderboardQuestion[];
frequent_questions: AdminLeaderboardQuestion[];
trends: AdminLeaderboardTrendPoint[]; trends: AdminLeaderboardTrendPoint[];
} }
@@ -154,7 +143,6 @@ export interface AdminUserAnalyticsResponse {
models: AdminLeaderboardDimension[]; models: AdminLeaderboardDimension[];
agents: AdminLeaderboardDimension[]; agents: AdminLeaderboardDimension[];
run_statuses: AdminLeaderboardDimension[]; run_statuses: AdminLeaderboardDimension[];
recent_questions: AdminLeaderboardQuestion[];
trends: AdminLeaderboardTrendPoint[]; trends: AdminLeaderboardTrendPoint[];
} }
+3
View File
@@ -33,6 +33,8 @@ export interface ListAgentsParams {
sort?: AgentSort; sort?: AgentSort;
/** Keep only agents published by this user id (the card's publisher). */ /** Keep only agents published by this user id (the card's publisher). */
owner?: string; owner?: string;
/** Keep only agents filed under this publish square (发布广场). */
square?: string;
} }
function buildAgentsQuery(params: ListAgentsParams): string { function buildAgentsQuery(params: ListAgentsParams): string {
@@ -44,6 +46,7 @@ function buildAgentsQuery(params: ListAgentsParams): string {
if (params.pageSize != null) qs.set("page_size", String(params.pageSize)); if (params.pageSize != null) qs.set("page_size", String(params.pageSize));
if (params.sort) qs.set("sort", params.sort); if (params.sort) qs.set("sort", params.sort);
if (params.owner) qs.set("owner", params.owner); if (params.owner) qs.set("owner", params.owner);
if (params.square) qs.set("square", params.square);
return qs.toString(); return qs.toString();
} }
+5
View File
@@ -88,12 +88,15 @@ export function useAgentsPaginated(params: {
enabled?: boolean; enabled?: boolean;
/** Keep only agents published by this user id (the card's publisher). */ /** Keep only agents published by this user id (the card's publisher). */
owner?: string; owner?: string;
/** Keep only agents filed under this publish square (发布广场). */
square?: string;
}) { }) {
const { scope, page, pageSize, enabled = true } = params; const { scope, page, pageSize, enabled = true } = params;
const search = params.search ?? ""; const search = params.search ?? "";
const tagIds = params.tagIds ?? []; const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc"; const sort = params.sort ?? "time_desc";
const owner = params.owner ?? ""; const owner = params.owner ?? "";
const square = params.square ?? "";
const { data, isLoading, isFetching, error } = useQuery({ const { data, isLoading, isFetching, error } = useQuery({
queryKey: [ queryKey: [
"agents", "agents",
@@ -105,6 +108,7 @@ export function useAgentsPaginated(params: {
page, page,
pageSize, pageSize,
owner, owner,
square,
], ],
queryFn: () => queryFn: () =>
listAgentsPaginated({ listAgentsPaginated({
@@ -115,6 +119,7 @@ export function useAgentsPaginated(params: {
page, page,
pageSize, pageSize,
owner: owner || undefined, owner: owner || undefined,
square: square || undefined,
}), }),
enabled, enabled,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
+43
View File
@@ -1,5 +1,38 @@
import type { Tag } from "@/core/tags"; import type { Tag } from "@/core/tags";
/**
* A recommended question shown on the agent chat home / sidebar. `title` is the
* short chip label; `prompt` is the full message sent when clicked. Legacy
* agents stored questions as bare strings — use {@link normalizeAgentQuestions}
* to read either shape.
*/
export interface AgentQuestion {
title: string;
prompt: string;
}
/** Coerce the raw `common_questions` payload (legacy `string[]` or the new
* `{title, prompt}[]`) into a uniform, non-empty `AgentQuestion[]`. */
export function normalizeAgentQuestions(
raw: (string | AgentQuestion)[] | null | undefined,
): AgentQuestion[] {
if (!raw) return [];
const out: AgentQuestion[] = [];
for (const item of raw) {
if (typeof item === "string") {
const text = item.trim();
if (!text) continue;
out.push({ title: text.length <= 40 ? text : `${text.slice(0, 40)}…`, prompt: text });
continue;
}
const prompt = (item?.prompt ?? "").trim();
const title = (item?.title ?? "").trim() || prompt;
if (!prompt && !title) continue;
out.push({ title: title || prompt, prompt: prompt || title });
}
return out;
}
export interface Agent { export interface Agent {
id: string; id: string;
name: string; name: string;
@@ -9,6 +42,8 @@ export interface Agent {
owner_username?: string | null; owner_username?: string | null;
published: boolean; published: boolean;
is_private: boolean; is_private: boolean;
/** Publish square (发布广场) the agent is filed under. "" = the default square. */
square_id?: string;
builtin: boolean; builtin: boolean;
created_at?: string | null; created_at?: string | null;
updated_at?: string | null; updated_at?: string | null;
@@ -16,6 +51,10 @@ export interface Agent {
model?: string | null; model?: string | null;
tool_groups?: string[] | null; tool_groups?: string[] | null;
skills?: string[] | null; skills?: string[] | null;
/** Recommended questions (常用问题) shown on the agent chat home / sidebar.
* New agents store `{title, prompt}`; legacy agents may still return `string[]`.
* Read via {@link normalizeAgentQuestions}. */
common_questions?: (string | AgentQuestion)[] | null;
tags?: Tag[]; tags?: Tag[];
featured_order?: number | null; featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */ /** Whether the current user has favorited (收藏) this agent. */
@@ -43,9 +82,11 @@ export interface CreateAgentRequest {
soul?: string; soul?: string;
published?: boolean; published?: boolean;
is_private?: boolean; is_private?: boolean;
square_id?: string;
skills?: string[] | null; skills?: string[] | null;
model?: string | null; model?: string | null;
tool_groups?: string[] | null; tool_groups?: string[] | null;
common_questions?: AgentQuestion[] | null;
} }
export interface UpdateAgentRequest { export interface UpdateAgentRequest {
@@ -54,7 +95,9 @@ export interface UpdateAgentRequest {
soul?: string | null; soul?: string | null;
published?: boolean | null; published?: boolean | null;
is_private?: boolean | null; is_private?: boolean | null;
square_id?: string | null;
skills?: string[] | null; skills?: string[] | null;
model?: string | null; model?: string | null;
tool_groups?: string[] | null; tool_groups?: string[] | null;
common_questions?: AgentQuestion[] | null;
} }
+1 -1
View File
@@ -69,7 +69,7 @@ export function getLangGraphBaseURL(isMock?: boolean) {
} }
} }
/** 笔记本外部系统后端 API 地址,通过 VITE_AIPI_BACKENP_URL 配置。 */ /** 空间外部系统后端 API 地址,通过 VITE_AIPI_BACKENP_URL 配置。 */
export function getNotebookApiBaseURL(): string { export function getNotebookApiBaseURL(): string {
if (env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL) { if (env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL) {
return env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL.replace(/\/+$/, ""); return env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL.replace(/\/+$/, "");
+8 -1
View File
@@ -17,8 +17,15 @@ export async function getCuratorConfig(): Promise<CuratorConfig> {
return response.json() as Promise<CuratorConfig>; return response.json() as Promise<CuratorConfig>;
} }
export interface CuratorConfigUpdatePayload {
interval_hours: number;
stale_after_days: number;
archive_after_days: number;
model_name: string | null;
}
export async function updateCuratorConfig( export async function updateCuratorConfig(
payload: CuratorConfig, payload: CuratorConfigUpdatePayload,
): Promise<CuratorConfig> { ): Promise<CuratorConfig> {
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`, { const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`, {
method: "PUT", method: "PUT",
+2 -1
View File
@@ -10,6 +10,7 @@ import {
runCurator, runCurator,
updateCuratorConfig, updateCuratorConfig,
} from "./api"; } from "./api";
import type { CuratorConfigUpdatePayload } from "./api";
import type { CuratorConfig } from "./types"; import type { CuratorConfig } from "./types";
// All curator-related queries are per-user data (status / config / logs all // All curator-related queries are per-user data (status / config / logs all
@@ -51,7 +52,7 @@ export function useUpdateCuratorConfig() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const keys = useCuratorKeys(); const keys = useCuratorKeys();
return useMutation({ return useMutation({
mutationFn: (payload: CuratorConfig) => updateCuratorConfig(payload), mutationFn: (payload: CuratorConfigUpdatePayload) => updateCuratorConfig(payload),
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: keys.config }); void queryClient.invalidateQueries({ queryKey: keys.config });
void queryClient.invalidateQueries({ queryKey: keys.status }); void queryClient.invalidateQueries({ queryKey: keys.status });
+45
View File
@@ -31,11 +31,29 @@ export interface WujieProps {
/** 嵌入问答:DeerFlow 本地用户名登录。 */ /** 嵌入问答:DeerFlow 本地用户名登录。 */
username?: string; username?: string;
userName?: string; userName?: string;
/** 用户名+密码登录的共享口令(后端开启 username_login.require_password 时校验)。 */
password?: string;
/** 圆桌规划嵌入:任务名称(注入 Step 1 首条用户消息)。 */
taskTitle?: string;
/** 圆桌规划嵌入:任务描述(注入 Step 1 首条用户消息)。 */
taskGoal?: string;
/** 圆桌规划嵌入:指定业务链条 id(直传,跳过业务映射表)。 */
chainId?: string;
/** 圆桌规划嵌入:业务码(可选回退;后端业务映射表查 chainId)。 */
businessCode?: string;
[key: string]: unknown; [key: string]: unknown;
} }
/** 无界跨实例事件总线(子→父 / 父→子 通信)。 */
interface WujieBus {
$emit?: (event: string, ...args: unknown[]) => void;
$on?: (event: string, handler: (...args: unknown[]) => void) => void;
$off?: (event: string, handler: (...args: unknown[]) => void) => void;
}
interface WujieRuntime { interface WujieRuntime {
props?: WujieProps; props?: WujieProps;
bus?: WujieBus;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -59,6 +77,33 @@ export function readWujieProps(): WujieProps {
return window.$wujie?.props ?? {}; return window.$wujie?.props ?? {};
} }
/**
* 子→父:通过无界事件总线广播一个事件。父页面用 `bus.$on(event, handler)` 监听。
* 非无界环境 / 总线缺失时静默忽略(返回 false),可安全无条件调用。
*/
export function emitWujieEvent(event: string, payload?: unknown): boolean {
if (typeof window === "undefined") return false;
const emit = window.$wujie?.bus?.$emit;
if (typeof emit !== "function") return false;
emit(event, payload);
return true;
}
/**
* 父→子:订阅无界事件总线上的一个事件。返回取消订阅函数(在 effect cleanup 里调用)。
* 非无界环境 / 总线缺失时返回 no-op,可安全无条件调用。
*/
export function onWujieEvent(
event: string,
handler: (...args: unknown[]) => void,
): () => void {
if (typeof window === "undefined") return () => {};
const bus = window.$wujie?.bus;
if (typeof bus?.$on !== "function") return () => {};
bus.$on(event, handler);
return () => bus.$off?.(event, handler);
}
/** /**
* 全局读取无界父级注入的 props 的 hook。 * 全局读取无界父级注入的 props 的 hook。
* *
+59
View File
@@ -127,6 +127,30 @@ export async function ingestThreadsBatch(payload: BatchIngestPayload = {}) {
); );
} }
export interface IngestFileResult {
note: KnowledgeNote;
created: boolean;
notes: KnowledgeNote[];
llm_fallback: boolean;
}
/** Import a file (md/txt/pdf/word/ppt/excel) into the KB as wiki note(s). */
export async function ingestKnowledgeFile(
file: File,
opts: { title?: string; tags?: string; status?: string; model_name?: string | null } = {},
) {
const form = new FormData();
form.append("file", file);
if (opts.title) form.append("title", opts.title);
if (opts.tags) form.append("tags", opts.tags);
if (opts.status) form.append("status", opts.status);
if (opts.model_name) form.append("model_name", opts.model_name);
// No Content-Type header — the browser sets the multipart boundary itself.
return parseResponse<IngestFileResult>(
await apiFetch(`${baseUrl()}/ingest/file`, { method: "POST", body: form }),
);
}
/** Resolve `[[wikilink]]` targets to note ids / vault pages. */ /** Resolve `[[wikilink]]` targets to note ids / vault pages. */
export async function resolveWikilinks(targets: string[]) { export async function resolveWikilinks(targets: string[]) {
const res = await parseResponse<{ resolved: WikilinkResolutionMap }>( const res = await parseResponse<{ resolved: WikilinkResolutionMap }>(
@@ -224,3 +248,38 @@ export interface KnowledgeGraph {
export async function getKnowledgeGraph() { export async function getKnowledgeGraph() {
return parseResponse<KnowledgeGraph>(await apiFetch(`${baseUrl()}/graph`)); return parseResponse<KnowledgeGraph>(await apiFetch(`${baseUrl()}/graph`));
} }
// --- graph blacklist ---
export interface GraphBlacklistItem {
id: string;
label: string;
created_by?: string | null;
created_at?: string | null;
}
export async function getGraphBlacklist() {
const res = await parseResponse<{ items: GraphBlacklistItem[] }>(
await apiFetch(`${baseUrl()}/graph/blacklist`),
);
return res.items;
}
/** Blacklist node labels (entity names / note titles); they disappear from the graph. */
export async function addGraphBlacklist(labels: string[]) {
return parseResponse<{ added: number; items: GraphBlacklistItem[] }>(
await apiFetch(`${baseUrl()}/graph/blacklist`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ labels }),
}),
);
}
export async function removeGraphBlacklist(entryId: string) {
return parseResponse<{ ok: boolean }>(
await apiFetch(`${baseUrl()}/graph/blacklist/${encodeURIComponent(entryId)}`, {
method: "DELETE",
}),
);
}
+49
View File
@@ -6,16 +6,21 @@ import {
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { import {
addGraphBlacklist,
createKnowledgeNote, createKnowledgeNote,
deleteKnowledgeNote, deleteKnowledgeNote,
exportKnowledgeGraph, exportKnowledgeGraph,
fetchVaultPage, fetchVaultPage,
getGraphBlacklist,
getKnowledgeGraph,
getKnowledgeNote, getKnowledgeNote,
ingestKnowledgeFile,
ingestThread, ingestThread,
ingestThreadsBatch, ingestThreadsBatch,
listDuplicates, listDuplicates,
listKnowledgeNotes, listKnowledgeNotes,
mergeKnowledgeNotes, mergeKnowledgeNotes,
removeGraphBlacklist,
resolveNoteWikilinks, resolveNoteWikilinks,
searchKnowledge, searchKnowledge,
updateKnowledgeNote, updateKnowledgeNote,
@@ -70,6 +75,40 @@ export function useKnowledgeSearch(query: string, enabled: boolean) {
}); });
} }
export function useKnowledgeGraph() {
return useQuery({
queryKey: [...ROOT_KEY, "graph"],
queryFn: getKnowledgeGraph,
refetchOnWindowFocus: false,
});
}
export function useGraphBlacklist() {
return useQuery({
queryKey: [...ROOT_KEY, "graph-blacklist"],
queryFn: getGraphBlacklist,
refetchOnWindowFocus: false,
});
}
export function useGraphBlacklistActions() {
const queryClient = useQueryClient();
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: [...ROOT_KEY, "graph"] });
void queryClient.invalidateQueries({
queryKey: [...ROOT_KEY, "graph-blacklist"],
});
};
return {
add: useMutation({ mutationFn: addGraphBlacklist, onSuccess: invalidate }),
remove: useMutation({
mutationFn: removeGraphBlacklist,
onSuccess: invalidate,
}),
};
}
export function useKnowledgeActions() { export function useKnowledgeActions() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const invalidate = () => const invalidate = () =>
@@ -106,6 +145,16 @@ export function useKnowledgeActions() {
mutationFn: ingestThreadsBatch, mutationFn: ingestThreadsBatch,
onSuccess: invalidate, onSuccess: invalidate,
}), }),
ingestFile: useMutation({
mutationFn: ({
file,
opts,
}: {
file: File;
opts?: Parameters<typeof ingestKnowledgeFile>[1];
}) => ingestKnowledgeFile(file, opts),
onSuccess: invalidate,
}),
exportGraph: useMutation({ mutationFn: exportKnowledgeGraph }), exportGraph: useMutation({ mutationFn: exportKnowledgeGraph }),
merge: useMutation({ merge: useMutation({
mutationFn: ({ primaryId, duplicateIds }: { primaryId: string; duplicateIds: string[] }) => mutationFn: ({ primaryId, duplicateIds }: { primaryId: string; duplicateIds: string[] }) =>
+11 -1
View File
@@ -434,7 +434,17 @@ export function findToolCallResult(
} }
export function isHiddenFromUIMessage(message: Message) { export function isHiddenFromUIMessage(message: Message) {
return message.additional_kwargs?.hide_from_ui === true; if (message.additional_kwargs?.hide_from_ui === true) {
return true;
}
// Conversation-summary messages are context for the model only — never shown
// to the user. New summaries are injected transiently (never persisted), but
// threads compacted by older builds still carry a persisted `name: "summary"`
// HumanMessage, so hide those too.
if (message.name === "summary") {
return true;
}
return false;
} }
/** /**
@@ -283,7 +283,7 @@ export const sidebarMenuItems: MenuItem[] = [
}, },
{ {
id: "resource-knowledge-native", id: "resource-knowledge-native",
label: "知识库", label: "问答梳理",
icon: BookOpen, icon: BookOpen,
path: "/page/workspace/knowledge", path: "/page/workspace/knowledge",
}, },
+40 -1
View File
@@ -623,7 +623,46 @@ function normalizeLooseCitationTokens(
return indexes.map((index) => `[${index}]`).join(""); return indexes.map((index) => `[${index}]`).join("");
}; };
let next = content.replace( // 全角括号写法:【1】→ [1]
let next = content.replace(/【\s*(\d{1,3})\s*】/g, (match, rawIndex: string) =>
isValidIndex(Number(rawIndex)) ? `[${rawIndex}]` : match,
);
// 合并写法:[1,2,3] / [1,2] / [1、2] → [1][2][3]
next = next.replace(
/\\?\[(\d{1,3}(?:\s*[,,、]\s*\d{1,3})+)\\?\](?!\()/g,
(match, rawList: string) => {
const indexes = rawList.split(/[,,、]/).map((part) => Number(part.trim()));
if (indexes.some((index) => !isValidIndex(index))) {
return match;
}
return indexes.map((index) => `[${index}]`).join("");
},
);
// 区间写法:[1-3] / [2~5] → [1][2][3]
next = next.replace(
/\\?\[(\d{1,3})\s*[-–~~]\s*(\d{1,3})\\?\](?!\()/g,
(match, rawStart: string, rawEnd: string) => {
const start = Number(rawStart);
const end = Number(rawEnd);
if (
end <= start ||
end - start > 20 ||
!isValidIndex(start) ||
!isValidIndex(end)
) {
return match;
}
const tokens: string[] = [];
for (let index = start; index <= end; index++) {
tokens.push(`[${index}]`);
}
return tokens.join("");
},
);
next = next.replace(
/(^|[^\d\[\]\\])((?:\d{1,3}|\\?\[\d{1,3}\\?\])(?:\\?\[\d{1,3}\\?\]|\d{1,3})+)/g, /(^|[^\d\[\]\\])((?:\d{1,3}|\\?\[\d{1,3}\\?\])(?:\\?\[\d{1,3}\\?\]|\d{1,3})+)/g,
(match, prefix: string, rawRun: string) => { (match, prefix: string, rawRun: string) => {
if (!rawRun.includes("[")) { if (!rawRun.includes("[")) {
+8 -1
View File
@@ -7,6 +7,8 @@ export interface ListSkillsParams {
search?: string; search?: string;
tagIds?: string[]; tagIds?: string[];
sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc"; sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc";
/** Keep only skills filed under this publish square (发布广场). */
square?: string;
} }
export async function loadSkills(params: ListSkillsParams = {}) { export async function loadSkills(params: ListSkillsParams = {}) {
@@ -14,6 +16,7 @@ export async function loadSkills(params: ListSkillsParams = {}) {
if (params.search) qs.set("search", params.search); if (params.search) qs.set("search", params.search);
for (const id of params.tagIds ?? []) qs.append("tag_ids", id); for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
if (params.sort) qs.set("sort", params.sort); if (params.sort) qs.set("sort", params.sort);
if (params.square) qs.set("square", params.square);
const query = qs.toString(); const query = qs.toString();
const skills = await apiFetch( const skills = await apiFetch(
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`, `${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
@@ -242,18 +245,22 @@ export interface SetSkillPublishedResponse {
name: string; name: string;
owner_user_id: string | null; owner_user_id: string | null;
published: boolean; published: boolean;
square_id?: string;
} }
export async function setSkillPublished( export async function setSkillPublished(
skillName: string, skillName: string,
published: boolean, published: boolean,
squareId?: string,
): Promise<SetSkillPublishedResponse> { ): Promise<SetSkillPublishedResponse> {
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/published`, `${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/published`,
{ {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ published }), body: JSON.stringify(
squareId != null ? { published, square_id: squareId } : { published },
),
}, },
); );
if (!response.ok) { if (!response.ok) {
+3 -1
View File
@@ -203,10 +203,12 @@ export function useSetSkillPublished() {
mutationFn: ({ mutationFn: ({
skillName, skillName,
published, published,
squareId,
}: { }: {
skillName: string; skillName: string;
published: boolean; published: boolean;
}) => setSkillPublished(skillName, published), squareId?: string;
}) => setSkillPublished(skillName, published, squareId),
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] }); void queryClient.invalidateQueries({ queryKey: ["skills"] });
// Unpublishing triggers the backend cascade that strips this skill // Unpublishing triggers the backend cascade that strips this skill
+2
View File
@@ -55,6 +55,8 @@ export interface Skill {
// backend. Null for built-in / public skills with no human owner. // backend. Null for built-in / public skills with no human owner.
owner_username?: string | null; owner_username?: string | null;
published?: boolean; published?: boolean;
/** Publish square (发布广场) the skill is filed under once published. "" = default square. */
square_id?: string;
builtin?: boolean; builtin?: boolean;
// Long-form detail/introduction authored by the skill owner or an admin. // Long-form detail/introduction authored by the skill owner or an admin.
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown. // Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
+26 -7
View File
@@ -20,14 +20,16 @@ export async function fetchStudioToken(email: string): Promise<string> {
return token; return token;
} }
export async function fetchStudioNotebooks(token: string): Promise<StudioNotebook[]> { export async function fetchStudioNotebooks(
token: string,
): Promise<StudioNotebook[]> {
const apiBase = base(); const apiBase = base();
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置"); if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces`, { const res = await fetch(`${apiBase}/api/v1/searchspaces`, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
if (!res.ok) throw new Error(`获取笔记本列表失败:${res.status}`); if (!res.ok) throw new Error(`获取空间列表失败:${res.status}`);
return res.json() as Promise<StudioNotebook[]>; return res.json() as Promise<StudioNotebook[]>;
} }
@@ -62,13 +64,23 @@ export async function uploadSourceText(
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置"); if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/documents/textupload`, { const res = await fetch(`${apiBase}/api/v1/documents/textupload`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, headers: {
body: JSON.stringify({ search_space_id: notebookId, content, title: title || "粘贴的文字" }), "Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
search_space_id: notebookId,
content,
title: title || "粘贴的文字",
}),
}); });
if (!res.ok) throw new Error(`上传失败:${res.status}`); if (!res.ok) throw new Error(`上传失败:${res.status}`);
} }
export async function deleteSearchSpace(id: number, token: string): Promise<void> { export async function deleteSearchSpace(
id: number,
token: string,
): Promise<void> {
const apiBase = base(); const apiBase = base();
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置"); if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, { const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
@@ -78,12 +90,19 @@ export async function deleteSearchSpace(id: number, token: string): Promise<void
if (!res.ok) throw new Error(`删除失败:${res.status}`); if (!res.ok) throw new Error(`删除失败:${res.status}`);
} }
export async function renameSearchSpace(id: number, name: string, token: string): Promise<void> { export async function renameSearchSpace(
id: number,
name: string,
token: string,
): Promise<void> {
const apiBase = base(); const apiBase = base();
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置"); if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, { const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
}); });
if (!res.ok) throw new Error(`重命名失败:${res.status}`); if (!res.ok) throw new Error(`重命名失败:${res.status}`);
+47 -19
View File
@@ -4,11 +4,15 @@ import { useStream } from "@langchain/langgraph-sdk/react";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
import { fetchNotebookSources, fetchStudioNotebooks, fetchStudioToken } from "./api"; import {
fetchNotebookSources,
fetchStudioNotebooks,
fetchStudioToken,
} from "./api";
import type { AgentThreadState } from "@/core/threads/types"; import type { AgentThreadState } from "@/core/threads/types";
import type { SourceItem, StudioNotebook } from "./types"; import type { SourceItem, StudioNotebook } from "./types";
export function useStudioNotebooks(): { export function useStudioNotebooks(enabled = true): {
notebooks: StudioNotebook[]; notebooks: StudioNotebook[];
isLoading: boolean; isLoading: boolean;
isError: boolean; isError: boolean;
@@ -19,17 +23,22 @@ export function useStudioNotebooks(): {
const tokenQuery = useQuery({ const tokenQuery = useQuery({
queryKey: ["studio", "token", email], queryKey: ["studio", "token", email],
queryFn: () => fetchStudioToken(email), queryFn: () => fetchStudioToken(email),
enabled: Boolean(email), enabled: enabled && Boolean(email),
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
retry: 1, // 账号不存在(404/422)是永久错误,重试无意义,立即让 isError 落地
retry: false,
refetchOnWindowFocus: false,
}); });
const notebooksQuery = useQuery({ const notebooksQuery = useQuery({
queryKey: ["studio", "notebooks", tokenQuery.data], queryKey: ["studio", "notebooks", tokenQuery.data],
queryFn: () => fetchStudioNotebooks(tokenQuery.data!), queryFn: () => fetchStudioNotebooks(tokenQuery.data!),
enabled: Boolean(tokenQuery.data), enabled: enabled && Boolean(tokenQuery.data),
staleTime: 60 * 1000, // 空间列表不会高频变动;新建/删除路径已通过 invalidateQueries 主动失效
// (见 workspace-nav-studio.tsx),不需要短 staleTime + windowFocus 双兜底。
staleTime: 10 * 60 * 1000,
retry: 1, retry: 1,
refetchOnWindowFocus: false,
}); });
return { return {
@@ -71,7 +80,8 @@ export function useNotebookChat(
queryFn: () => fetchStudioToken(email), queryFn: () => fetchStudioToken(email),
enabled: Boolean(email), enabled: Boolean(email),
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
retry: 1, retry: false,
refetchOnWindowFocus: false,
}); });
const thread = useStream<AgentThreadState>({ const thread = useStream<AgentThreadState>({
@@ -99,7 +109,9 @@ export function useNotebookChat(
const sourceIds = const sourceIds =
selectedSourceIds.length > 0 selectedSourceIds.length > 0
? toDocIds(selectedSourceIds) ? toDocIds(selectedSourceIds)
: sources.map((s) => s.document?.id).filter((id): id is number => id !== undefined); : sources
.map((s) => s.document?.id)
.filter((id): id is number => id !== undefined);
await thread.submit( await thread.submit(
{ messages: [{ type: "human", content: text }] }, { messages: [{ type: "human", content: text }] },
@@ -118,36 +130,52 @@ export function useNotebookChat(
); );
}; };
return { thread, messages: thread.messages, isStreaming: thread.isLoading, send, threadId, setThreadId }; return {
thread,
messages: thread.messages,
isStreaming: thread.isLoading,
send,
threadId,
setThreadId,
};
} }
export function useNotebookSources(notebookId: number | null): { export function useNotebookSources(
notebookId: number | null,
overrideToken?: string,
): {
sources: SourceItem[]; sources: SourceItem[];
isLoading: boolean; isLoading: boolean;
isError: boolean; isError: boolean;
} { } {
const email = getStoredAuth()?.email ?? ""; const email = getStoredAuth()?.email ?? "";
// 复用与 useStudioNotebooks 相同的 token query key,命中缓存不重复请求 // 若外部直接提供 user_token(如 notebook-token 接口返回的),跳过 getTokenByEmail
const tokenQuery = useQuery({ const tokenQuery = useQuery({
queryKey: ["studio", "token", email], queryKey: ["studio", "token", email],
queryFn: () => fetchStudioToken(email), queryFn: () => fetchStudioToken(email),
enabled: Boolean(email), enabled: !overrideToken && Boolean(email),
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
retry: 1, retry: false,
refetchOnWindowFocus: false,
}); });
const resolvedToken = overrideToken ?? tokenQuery.data;
const sourcesQuery = useQuery({ const sourcesQuery = useQuery({
queryKey: ["studio", "sources", notebookId], queryKey: ["studio", "sources", notebookId, overrideToken ?? "default"],
queryFn: () => fetchNotebookSources(notebookId!, tokenQuery.data!), queryFn: () => fetchNotebookSources(notebookId!, resolvedToken!),
enabled: notebookId !== null && Boolean(tokenQuery.data), enabled: notebookId !== null && Boolean(resolvedToken),
staleTime: 60 * 1000, // 笔记文档列表低频变动;增删时调用方应主动 invalidateQueries(["studio", "sources"]) 失效。
staleTime: 5 * 60 * 1000,
retry: 1, retry: 1,
refetchOnWindowFocus: false,
}); });
return { return {
sources: sourcesQuery.data ?? [], sources: sourcesQuery.data ?? [],
isLoading: tokenQuery.isLoading || sourcesQuery.isLoading, isLoading:
isError: tokenQuery.isError || sourcesQuery.isError, (!overrideToken && tokenQuery.isLoading) || sourcesQuery.isLoading,
isError: (!overrideToken && tokenQuery.isError) || sourcesQuery.isError,
}; };
} }
@@ -89,6 +89,52 @@ export async function loadPrivateSquareStatus(): Promise<PrivateSquareStatus> {
return (await response.json()) as PrivateSquareStatus; return (await response.json()) as PrivateSquareStatus;
} }
export interface PublishSquare {
id: string;
name: string;
description: string;
}
export interface PublishSquaresSettings {
squares: PublishSquare[];
default_square_id: string;
}
export interface PublishSquaresUpdate {
squares?: PublishSquare[];
default_square_id?: string;
}
export async function loadPublishSquares(): Promise<PublishSquaresSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/publish-squares`,
);
if (!response.ok) {
throw new Error(`Failed to load publish squares: ${response.status}`);
}
return (await response.json()) as PublishSquaresSettings;
}
export async function updatePublishSquares(
body: PublishSquaresUpdate,
): Promise<PublishSquaresSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/publish-squares`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update publish squares" }));
throw new Error(detail.detail ?? "Failed to update publish squares");
}
return (await response.json()) as PublishSquaresSettings;
}
export type ThemeMode = "light" | "dark"; export type ThemeMode = "light" | "dark";
export type ViewMode = "card" | "list"; export type ViewMode = "card" | "list";
@@ -8,21 +8,25 @@ import {
loadLeaderboardSettings, loadLeaderboardSettings,
loadPrivateSquareStatus, loadPrivateSquareStatus,
loadPromptPrefixSettings, loadPromptPrefixSettings,
loadPublishSquares,
updateAppearanceSettings, updateAppearanceSettings,
updateCitationDisplaySettings, updateCitationDisplaySettings,
updateLeaderboardSettings, updateLeaderboardSettings,
updatePrivateSquareSettings, updatePrivateSquareSettings,
updatePromptPrefixSettings, updatePromptPrefixSettings,
updatePublishSquares,
type AppearanceUpdate, type AppearanceUpdate,
type CitationDisplayUpdate, type CitationDisplayUpdate,
type LeaderboardSettingsUpdate, type LeaderboardSettingsUpdate,
type PrivateSquareUpdate, type PrivateSquareUpdate,
type PromptPrefixUpdate, type PromptPrefixUpdate,
type PublishSquaresUpdate,
type ViewMode, type ViewMode,
} from "./api"; } from "./api";
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const; const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const; const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const;
const PUBLISH_SQUARES_KEY = ["systemSettings", "publishSquares"] as const;
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const; const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const; const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const; const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
@@ -67,6 +71,31 @@ export function useUpdatePrivateSquareSettings() {
}); });
} }
/** The configured publish squares (发布广场). Readable by any logged-in user —
* the gallery sub-tabs and the publish-flow square picker both need it. */
export function usePublishSquares() {
return useQuery({
queryKey: PUBLISH_SQUARES_KEY,
queryFn: () => loadPublishSquares(),
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdatePublishSquares() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: PublishSquaresUpdate) => updatePublishSquares(body),
onSuccess: (data) => {
queryClient.setQueryData(PUBLISH_SQUARES_KEY, data);
// Squares changing can re-bucket gallery tabs; refresh agent/skill lists.
queryClient.invalidateQueries({ queryKey: ["agents"] });
queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useAppearanceSettings() { export function useAppearanceSettings() {
return useQuery({ return useQuery({
queryKey: APPEARANCE_KEY, queryKey: APPEARANCE_KEY,
+116 -3
View File
@@ -12,7 +12,7 @@ import { getAPIClient } from "../api";
import { apiFetch } from "../api"; import { apiFetch } from "../api";
import { getStoredAuth } from "../auth"; import { getStoredAuth } from "../auth";
import { rememberAskTime } from "../chat-timestamps"; import { rememberAskTime } from "../chat-timestamps";
import { getBackendBaseURL } from "../config"; import { getBackendBaseURL, getLangGraphBaseURL } from "../config";
import { useI18n } from "../i18n/hooks"; import { useI18n } from "../i18n/hooks";
import type { FileInMessage } from "../messages/utils"; import type { FileInMessage } from "../messages/utils";
import type { LocalSettings } from "../settings"; import type { LocalSettings } from "../settings";
@@ -761,9 +761,122 @@ export function useThreads(
offset += response.length; offset += response.length;
} }
return threads.filter( // Safety net on top of the backend's system-thread exclusion: drop
(t) => (t.metadata as Record<string, unknown> | null)?.thread_type !== "notebook", // machine-created threads (scheduled tasks, roundtable workers,
// notebooks) in case an older backend still returns them.
return threads.filter((t) => {
const metadata = t.metadata as Record<string, unknown> | null;
if (metadata?.system) return false;
const threadType = metadata?.thread_type;
return (
threadType !== "notebook" &&
threadType !== "scheduler" &&
threadType !== "roundtable"
); );
});
},
refetchOnWindowFocus: false,
});
}
export interface ThreadsPageResult {
threads: AgentThread[];
hasMore: boolean;
}
/**
* Server-side paginated conversation list for the chats page. Unlike
* `useThreads` (which loads a bounded recent window for the sidebar), this
* fetches exactly one page per request so a long history is never pulled in
* one go. `search` matches thread titles on the backend; one extra row is
* over-fetched only to detect whether a next page exists. The query key stays
* under ["threads", "search"] so existing invalidations refresh pages too.
*/
export function useThreadsPage(options: {
page: number;
pageSize: number;
search?: string;
metadata?: Record<string, unknown>;
}) {
const { page, pageSize, search, metadata } = options;
const normalizedSearch = search?.trim() ?? "";
return useQuery<ThreadsPageResult>({
queryKey: [
"threads",
"search",
"paged",
page,
pageSize,
normalizedSearch,
metadata ?? null,
],
queryFn: async () => {
// Direct fetch instead of the LangGraph SDK: the SDK's threads.search
// drops fields it doesn't know about, and `query` (title search) is a
// gateway extension.
const response = await apiFetch(
`${getLangGraphBaseURL()}/threads/search`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
limit: pageSize + 1,
offset: (page - 1) * pageSize,
metadata: metadata ?? {},
query: normalizedSearch || undefined,
}),
},
);
if (!response.ok) {
throw new Error("Failed to load conversations");
}
const rows = (await response.json()) as AgentThread[];
return {
threads: rows.slice(0, pageSize),
hasMore: rows.length > pageSize,
};
},
placeholderData: (previous) => previous,
refetchOnWindowFocus: false,
});
}
/**
* Total number of conversations matching the same filters as
* `useThreadsPage`, via the lightweight `/threads/count` endpoint — powers
* the "共 N 条 / X 页" display without fetching any rows. Depends only on the
* filters (not the page), so paging doesn't refire it.
*/
export function useThreadsCount(options: {
search?: string;
metadata?: Record<string, unknown>;
}) {
const normalizedSearch = options.search?.trim() ?? "";
return useQuery<number>({
queryKey: [
"threads",
"search",
"count",
normalizedSearch,
options.metadata ?? null,
],
queryFn: async () => {
const response = await apiFetch(
`${getLangGraphBaseURL()}/threads/count`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
metadata: options.metadata ?? {},
query: normalizedSearch || undefined,
}),
},
);
if (!response.ok) {
throw new Error("Failed to count conversations");
}
const data = (await response.json()) as { total: number };
return data.total;
}, },
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
+338
View File
@@ -0,0 +1,338 @@
/**
* Dependency-free .xlsx reader for in-browser previews.
*
* An .xlsx file is a ZIP archive of XML parts. We unzip it with the native
* DecompressionStream("deflate-raw") and parse the parts with DOMParser, so
* spreadsheet previews work in this offline deployment without adding a
* SheetJS-style dependency. Covers the parts sandbox-generated workbooks
* (openpyxl / pandas) actually use: shared strings, inline strings, numbers,
* booleans and date-formatted cells. Legacy binary .xls is NOT a ZIP and is
* not supported here.
*/
export interface ParsedWorksheet {
name: string;
/** Dense row-major cell text. Ragged rows are allowed. */
rows: string[][];
/** True when rows were cut off at MAX_PREVIEW_ROWS. */
truncated: boolean;
}
const MAX_PREVIEW_ROWS = 1000;
const MAX_PREVIEW_COLS = 200;
export async function parseXlsx(data: ArrayBuffer): Promise<ParsedWorksheet[]> {
const entries = readZipEntries(new Uint8Array(data));
const readXml = async (path: string): Promise<Document | null> => {
const entry = entries.get(path);
if (!entry) {
return null;
}
const bytes = await extractZipEntry(new Uint8Array(data), entry);
const text = new TextDecoder("utf-8").decode(bytes);
return new DOMParser().parseFromString(text, "application/xml");
};
const workbook = await readXml("xl/workbook.xml");
if (!workbook) {
throw new Error("不是有效的 xlsx 文件(缺少 xl/workbook.xml)");
}
const rels = await readXml("xl/_rels/workbook.xml.rels");
const relTargets = new Map<string, string>();
if (rels) {
for (const rel of Array.from(elementsOf(rels, "Relationship"))) {
const id = rel.getAttribute("Id");
const target = rel.getAttribute("Target");
if (id && target) {
relTargets.set(id, target);
}
}
}
const sharedStrings = await readSharedStrings(readXml);
const dateStyleFlags = await readDateStyleFlags(readXml);
const sheets: ParsedWorksheet[] = [];
for (const sheet of Array.from(elementsOf(workbook, "sheet"))) {
const name = sheet.getAttribute("name") ?? `Sheet${sheets.length + 1}`;
const rid =
sheet.getAttribute("r:id") ??
sheet.getAttributeNS(
"http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"id",
) ??
"";
const target = relTargets.get(rid) ?? `worksheets/sheet${sheets.length + 1}.xml`;
const path = target.startsWith("/")
? target.slice(1)
: `xl/${target.replace(/^\.\//, "")}`;
const doc = await readXml(path);
if (!doc) {
continue;
}
sheets.push({
name,
...parseWorksheet(doc, sharedStrings, dateStyleFlags),
});
}
if (sheets.length === 0) {
throw new Error("xlsx 文件中没有可读取的工作表");
}
return sheets;
}
/** Namespace-agnostic tag lookup — xlsx parts use a default namespace. */
function elementsOf(root: Document | Element, localName: string): Element[] {
return Array.from(root.getElementsByTagNameNS("*", localName));
}
async function readSharedStrings(
readXml: (path: string) => Promise<Document | null>,
): Promise<string[]> {
const doc = await readXml("xl/sharedStrings.xml");
if (!doc) {
return [];
}
return elementsOf(doc, "si").map((si) =>
elementsOf(si, "t")
.map((t) => t.textContent ?? "")
.join(""),
);
}
/**
* For each cell style (cellXfs index), whether its number format is a
* date/time format — those cells hold Excel serial date numbers that must be
* rendered as dates instead of raw numbers like 45000.
*/
async function readDateStyleFlags(
readXml: (path: string) => Promise<Document | null>,
): Promise<boolean[]> {
const doc = await readXml("xl/styles.xml");
if (!doc) {
return [];
}
const customDateFormats = new Set<number>();
for (const numFmt of elementsOf(doc, "numFmt")) {
const id = Number(numFmt.getAttribute("numFmtId"));
const code = numFmt.getAttribute("formatCode") ?? "";
if (Number.isFinite(id) && isDateFormatCode(code)) {
customDateFormats.add(id);
}
}
const cellXfs = elementsOf(doc, "cellXfs")[0];
if (!cellXfs) {
return [];
}
return elementsOf(cellXfs, "xf").map((xf) => {
const numFmtId = Number(xf.getAttribute("numFmtId") ?? "0");
return isBuiltinDateFormat(numFmtId) || customDateFormats.has(numFmtId);
});
}
function isBuiltinDateFormat(numFmtId: number): boolean {
return (
(numFmtId >= 14 && numFmtId <= 22) ||
(numFmtId >= 27 && numFmtId <= 36) ||
(numFmtId >= 45 && numFmtId <= 47) ||
(numFmtId >= 50 && numFmtId <= 58)
);
}
function isDateFormatCode(code: string): boolean {
// Strip quoted literals, color/condition tags and escapes, then look for
// date/time tokens.
const cleaned = code
.replace(/"[^"]*"/g, "")
.replace(/\[[^\]]*\]/g, "")
.replace(/\\./g, "");
return /[ymdhs]/i.test(cleaned) && !/#|0/.test(cleaned);
}
function parseWorksheet(
doc: Document,
sharedStrings: string[],
dateStyleFlags: boolean[],
): { rows: string[][]; truncated: boolean } {
const rows: string[][] = [];
let truncated = false;
for (const row of elementsOf(doc, "row")) {
const rowNumber = Number(row.getAttribute("r") ?? rows.length + 1);
if (rowNumber > MAX_PREVIEW_ROWS) {
truncated = true;
break;
}
while (rows.length < rowNumber - 1) {
rows.push([]);
}
const cells: string[] = [];
for (const cell of elementsOf(row, "c")) {
const ref = cell.getAttribute("r") ?? "";
const columnIndex = ref
? columnIndexOfRef(ref)
: cells.length;
if (columnIndex >= MAX_PREVIEW_COLS) {
truncated = true;
continue;
}
while (cells.length < columnIndex) {
cells.push("");
}
cells[columnIndex] = readCellText(cell, sharedStrings, dateStyleFlags);
}
rows.push(cells);
}
// Drop trailing fully-empty rows so a sparse sheet doesn't render a long
// empty tail.
while (rows.length > 0 && rows[rows.length - 1]!.every((cell) => !cell)) {
rows.pop();
}
return { rows, truncated };
}
function readCellText(
cell: Element,
sharedStrings: string[],
dateStyleFlags: boolean[],
): string {
const type = cell.getAttribute("t") ?? "n";
if (type === "inlineStr") {
const is = elementsOf(cell, "is")[0];
if (!is) {
return "";
}
return elementsOf(is, "t")
.map((t) => t.textContent ?? "")
.join("");
}
const value = elementsOf(cell, "v")[0]?.textContent ?? "";
if (type === "s") {
return sharedStrings[Number(value)] ?? "";
}
if (type === "b") {
return value === "1" ? "TRUE" : "FALSE";
}
if (type === "str" || type === "e") {
return value;
}
// Numeric cell — render date-styled serial numbers as dates.
if (value !== "") {
const styleIndex = Number(cell.getAttribute("s") ?? "-1");
if (dateStyleFlags[styleIndex]) {
const formatted = formatExcelSerialDate(Number(value));
if (formatted) {
return formatted;
}
}
}
return value;
}
function formatExcelSerialDate(serial: number): string | null {
if (!Number.isFinite(serial)) {
return null;
}
// Excel's day 0 is 1899-12-30 (the offset absorbs the fictitious 1900-02-29).
const ms = Math.round(serial * 86400000);
const date = new Date(Date.UTC(1899, 11, 30) + ms);
if (Number.isNaN(date.getTime())) {
return null;
}
const pad = (n: number) => String(n).padStart(2, "0");
const datePart = `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
const timePart = `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`;
if (serial < 1) {
return timePart;
}
return timePart === "00:00:00" ? datePart : `${datePart} ${timePart}`;
}
function columnIndexOfRef(ref: string): number {
let index = 0;
for (let i = 0; i < ref.length; i++) {
const code = ref.charCodeAt(i);
if (code >= 65 && code <= 90) {
index = index * 26 + (code - 64);
} else if (code >= 97 && code <= 122) {
index = index * 26 + (code - 96);
} else {
break;
}
}
return Math.max(0, index - 1);
}
// ---------------------------------------------------------------------------
// Minimal ZIP reading (central directory + stored/deflate entries)
// ---------------------------------------------------------------------------
interface ZipEntry {
compressionMethod: number;
compressedSize: number;
localHeaderOffset: number;
}
function readZipEntries(bytes: Uint8Array): Map<string, ZipEntry> {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
// End of central directory record: signature 0x06054b50, located within the
// last 22..(22+65535) bytes depending on the archive comment length.
let eocd = -1;
const scanStart = Math.max(0, bytes.length - 22 - 65535);
for (let i = bytes.length - 22; i >= scanStart; i--) {
if (view.getUint32(i, true) === 0x06054b50) {
eocd = i;
break;
}
}
if (eocd < 0) {
throw new Error("不是有效的 xlsx/zip 文件");
}
const entryCount = view.getUint16(eocd + 10, true);
let offset = view.getUint32(eocd + 16, true);
const entries = new Map<string, ZipEntry>();
for (let i = 0; i < entryCount; i++) {
if (view.getUint32(offset, true) !== 0x02014b50) {
break;
}
const compressionMethod = view.getUint16(offset + 10, true);
const compressedSize = view.getUint32(offset + 20, true);
const nameLength = view.getUint16(offset + 28, true);
const extraLength = view.getUint16(offset + 30, true);
const commentLength = view.getUint16(offset + 32, true);
const localHeaderOffset = view.getUint32(offset + 42, true);
const name = new TextDecoder("utf-8").decode(
bytes.subarray(offset + 46, offset + 46 + nameLength),
);
entries.set(name, { compressionMethod, compressedSize, localHeaderOffset });
offset += 46 + nameLength + extraLength + commentLength;
}
return entries;
}
async function extractZipEntry(
bytes: Uint8Array,
entry: ZipEntry,
): Promise<Uint8Array> {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const base = entry.localHeaderOffset;
if (view.getUint32(base, true) !== 0x04034b50) {
throw new Error("xlsx/zip 文件结构损坏");
}
const nameLength = view.getUint16(base + 26, true);
const extraLength = view.getUint16(base + 28, true);
const dataStart = base + 30 + nameLength + extraLength;
const compressed = bytes.subarray(dataStart, dataStart + entry.compressedSize);
if (entry.compressionMethod === 0) {
return compressed;
}
if (entry.compressionMethod !== 8) {
throw new Error(`不支持的 zip 压缩方式: ${entry.compressionMethod}`);
}
// .slice() copies into a fresh ArrayBuffer, which also satisfies BlobPart's
// non-shared-buffer requirement.
const stream = new Blob([compressed.slice()]).stream().pipeThrough(
new DecompressionStream("deflate-raw"),
);
return new Uint8Array(await new Response(stream).arrayBuffer());
}
+1 -3
View File
@@ -9,8 +9,7 @@ function readEnv(
| "VITE_AIPI_BACKENP_URL" | "VITE_AIPI_BACKENP_URL"
| "VITE_AIPI_FRONTEND_URL" | "VITE_AIPI_FRONTEND_URL"
| "VITE_STATIC_WEBSITE_ONLY" | "VITE_STATIC_WEBSITE_ONLY"
| "VITE_GITHUB_OAUTH_TOKEN" | "VITE_GITHUB_OAUTH_TOKEN",
| "VITE_STUDIO_ENABLED",
): EnvValue { ): EnvValue {
const value = import.meta.env[key]; const value = import.meta.env[key];
return typeof value === "string" && value.trim() ? value : undefined; return typeof value === "string" && value.trim() ? value : undefined;
@@ -26,5 +25,4 @@ export const env = {
NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readEnv("VITE_AIPI_BACKENP_URL"), NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readEnv("VITE_AIPI_BACKENP_URL"),
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"), NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"),
GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"), GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"),
STUDIO_ENABLED: readEnv("VITE_STUDIO_ENABLED"),
}; };
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { PenLine } from 'lucide-react'; import { PenLine, SlidersHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
@@ -22,6 +22,12 @@ interface AIWritingFormProps {
disabled?: boolean; disabled?: boolean;
/** workspace 入口:隐藏严格模式、目标读者 */ /** workspace 入口:隐藏严格模式、目标读者 */
hideAdvancedFields?: boolean; hideAdvancedFields?: boolean;
/** 预选空间 ID(传入时自动切换素材来源为「我的空间」) */
initialNotebookId?: number;
/** 预选文档 ID 列表 */
initialDocumentIds?: number[];
/** 外部直接提供的 studio bearer token,跳过 getTokenByEmail */
studioToken?: string;
} }
const INPUT_CLASS = const INPUT_CLASS =
@@ -39,7 +45,7 @@ const MAX_INTENT_LENGTH = 500;
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [ const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
{ value: 'knowledge_base', label: '知识库', desc: '默认 web/intranet 检索' }, { value: 'knowledge_base', label: '知识库', desc: '默认 web/intranet 检索' },
{ value: 'skill', label: '技能检索', desc: '用已启用的技能编排检索' }, { value: 'skill', label: '技能检索', desc: '用已启用的技能编排检索' },
{ value: 'notebook', label: '我的笔记', desc: '从笔记本中检索素材' }, { value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
]; ];
const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222]'; const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222]';
@@ -50,6 +56,9 @@ export function AIWritingForm({
onSubmit, onSubmit,
disabled, disabled,
hideAdvancedFields = false, hideAdvancedFields = false,
initialNotebookId,
initialDocumentIds,
studioToken,
}: AIWritingFormProps) { }: AIWritingFormProps) {
const { const {
intent, setIntent, intent, setIntent,
@@ -62,13 +71,15 @@ export function AIWritingForm({
materialSource, setMaterialSource, materialSource, setMaterialSource,
modelName, setModelName, modelName, setModelName,
} = useWritingForm(); } = useWritingForm();
// 笔记来源:选中的 search_space_id 和多个 document_id(master 引入,useWritingForm 未含) // 笔记来源:选中的 search_space_id 和多个 document_id
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(null); const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>([]); initialNotebookId ?? null,
);
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>(initialDocumentIds ?? []);
const { models, isLoading: modelsLoading } = useModels(); const { models, isLoading: modelsLoading } = useModels();
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(); const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(!studioToken);
const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId); const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId, studioToken);
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes(); const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes();
@@ -86,6 +97,7 @@ export function AIWritingForm({
[models, modelName], [models, modelName],
); );
// 初始化时加载文章类型 // 初始化时加载文章类型
useEffect(() => { useEffect(() => {
refreshTypes(); refreshTypes();
@@ -127,7 +139,8 @@ export function AIWritingForm({
function handleMaterialSourceChange(val: MaterialSource) { function handleMaterialSourceChange(val: MaterialSource) {
setMaterialSource(val); setMaterialSource(val);
if (val !== 'notebook') { // studioToken 模式下空间和文档已锁定,切换素材来源时不清空
if (val !== 'notebook' && !studioToken) {
setSelectedNotebookId(null); setSelectedNotebookId(null);
setSelectedDocumentIds([]); setSelectedDocumentIds([]);
} }
@@ -153,9 +166,9 @@ export function AIWritingForm({
wordCountTarget: wordCount, wordCountTarget: wordCount,
keywordCount: DEFAULT_KEYWORD_COUNT, keywordCount: DEFAULT_KEYWORD_COUNT,
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose', writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
materialSource, materialSource: materialSource,
modelName: selectedModel?.name || modelName || undefined, modelName: selectedModel?.name || modelName || undefined,
// 只有选了"我的笔记"才传笔记参数 // 只有选了"我的空间"才传笔记参数
noteSearchSpaceId: materialSource === 'notebook' ? (selectedNotebookId ?? undefined) : undefined, noteSearchSpaceId: materialSource === 'notebook' ? (selectedNotebookId ?? undefined) : undefined,
noteSourceIds: materialSource === 'notebook' && selectedNotebookId != null ? selectedDocumentIds : undefined, noteSourceIds: materialSource === 'notebook' && selectedNotebookId != null ? selectedDocumentIds : undefined,
}); });
@@ -163,17 +176,19 @@ export function AIWritingForm({
return ( return (
<form onSubmit={handleSubmit} className="space-y-4 px-4 py-4"> <form onSubmit={handleSubmit} className="space-y-4 px-4 py-4">
<div className="rounded-2xl border border-[#ebe3e2] bg-[#fcf8f8] p-4 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white [&_h3]:dark:text-white [&_label]:dark:text-white"> <div className="rounded-2xl border border-[#ece5e4] bg-card p-5 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white [&_h3]:dark:text-white [&_label]:dark:text-white">
<div className="flex items-center gap-2 border-b border-[#ece5e4] pb-3 dark:border-[#213D5E]"> <div className="flex items-center gap-2.5 border-b border-[#ece5e4] pb-3 dark:border-[#213D5E]">
<PenLine className="size-4 text-[#8b6f6f] dark:text-white" /> <SlidersHorizontal className="size-4 text-primary dark:text-white" />
<h3 className="text-[18px] font-semibold leading-none text-[#2f2f2f] dark:text-white"> <h3 className="text-[18px] font-semibold leading-none tracking-tight text-[#2f2f2f] dark:text-white">
写作配置选项 写作配置选项
</h3> </h3>
</div> </div>
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className={FIELD_LABEL_CLASS}>写作意图</label> <label className={FIELD_LABEL_CLASS}>
写作意图 <span className="text-primary dark:text-white">*</span>
</label>
<span className="text-sm text-muted-foreground dark:text-white/70"> <span className="text-sm text-muted-foreground dark:text-white/70">
{intent.length} / {MAX_INTENT_LENGTH} 字 {intent.length} / {MAX_INTENT_LENGTH} 字
</span> </span>
@@ -283,16 +298,18 @@ export function AIWritingForm({
</SelectContent> </SelectContent>
</Select> </Select>
{/* 选了"我的笔记"才展示笔记本 / 文档选择器 */} {/* 选了"我的空间"才展示空间 / 文档选择器 */}
{materialSource === 'notebook' && ( {materialSource === 'notebook' && (
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
{/* studioToken 模式:空间已锁定,不展示选择器 */}
{!studioToken && (
<Select <Select
value={selectedNotebookId !== null ? String(selectedNotebookId) : ''} value={selectedNotebookId !== null ? String(selectedNotebookId) : ''}
onValueChange={handleNotebookChange} onValueChange={handleNotebookChange}
disabled={disabled || notebooksLoading} disabled={disabled || notebooksLoading}
> >
<SelectTrigger className={FIELD_TRIGGER_CLASS}> <SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择笔记本'} /> <SelectValue placeholder={notebooksLoading ? '加载中…' : '选择空间'} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{notebooks.map((nb) => ( {notebooks.map((nb) => (
@@ -302,13 +319,14 @@ export function AIWritingForm({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
)}
{selectedNotebookId !== null && ( {selectedNotebookId !== null && (
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{sourcesLoading {sourcesLoading
? '文档加载中…' ? '文档加载中…'
: `选择文档(可多选;不选则搜整个笔记本${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`} : `选择文档(可多选;不选则搜整个空间${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`}
</p> </p>
{!sourcesLoading && ( {!sourcesLoading && (
<div className="max-h-36 overflow-y-auto rounded-lg border border-[#e8dedd] bg-white divide-y divide-[#f0e8e7]"> <div className="max-h-36 overflow-y-auto rounded-lg border border-[#e8dedd] bg-white divide-y divide-[#f0e8e7]">
@@ -368,7 +386,7 @@ export function AIWritingForm({
<Button <Button
type="submit" type="submit"
disabled={disabled || !intent.trim()} disabled={disabled || !intent.trim()}
className="mt-4 h-12 w-full gap-2 rounded-2xl text-base font-semibold" className="mt-5 h-12 w-full gap-2 rounded-2xl text-base font-semibold shadow-lg shadow-primary/20 transition-all hover:shadow-primary/30 active:scale-[0.98]"
> >
<PenLine className="size-4" /> <PenLine className="size-4" />
开始 AI 写作 开始 AI 写作
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react'; import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -16,7 +16,7 @@ import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useAIWriting } from '../../contexts/AIWritingContext'; import { useAIWriting } from '../../contexts/AIWritingContext';
import { useAIWritingHistory } from '../../contexts/AIWritingHistoryContext'; import { useAIWritingHistory } from '../../contexts/AIWritingHistoryContext';
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions'; import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
import { adminCleanupAIWritingSessions } from '../../api/ai-writing-sessions'; import { adminCleanupAIWritingSessions, getAIWritingSession } from '../../api/ai-writing-sessions';
/** /**
* 暂停 / 继续功能开关(默认 false = 关)。 * 暂停 / 继续功能开关(默认 false = 关)。
@@ -70,10 +70,17 @@ const STATUS_LABEL: Record<string, string> = {
export function AIWritingPanel({ export function AIWritingPanel({
hideAdvancedFields = false, hideAdvancedFields = false,
initialNotebookId,
initialDocumentIds,
studioToken,
}: { }: {
hideAdvancedFields?: boolean; hideAdvancedFields?: boolean;
initialNotebookId?: number;
initialDocumentIds?: number[];
studioToken?: string;
} = {}) { } = {}) {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting(); const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory(); const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory();
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions(); const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
@@ -213,6 +220,18 @@ export function AIWritingPanel({
return () => window.removeEventListener('ai-writing:open-session', onOpenSession); return () => window.removeEventListener('ai-writing:open-session', onOpenSession);
}, []); }, []);
// 路径3:URL ?session=xxx 直接定位到指定会话
useEffect(() => {
const sessionId = searchParams.get('session');
if (!sessionId) return;
// 消费参数,避免刷新重复触发
setSearchParams((prev) => { prev.delete('session'); return prev; }, { replace: true });
getAIWritingSession(sessionId)
.then((session) => handleSelectHistoryRef.current(session))
.catch(() => { /* session 不存在时静默忽略 */ });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅挂载时执行一次
// ── ⚠️ 临时调试:admin 一键清理所有过期会话 ──────────────────────────────── // ── ⚠️ 临时调试:admin 一键清理所有过期会话 ────────────────────────────────
// ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。 // ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。
// 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。 // 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。
@@ -339,6 +358,9 @@ export function AIWritingPanel({
<AIWritingForm <AIWritingForm
onSubmit={startWriting} onSubmit={startWriting}
hideAdvancedFields={hideAdvancedFields} hideAdvancedFields={hideAdvancedFields}
initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds}
studioToken={studioToken}
/> />
)} )}
@@ -29,6 +29,7 @@ import { cn } from '@/lib/utils';
import { useArticleTypes } from '../../hooks/useArticleTypes'; import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useWritingForm } from '../../contexts/WritingFormContext'; import { useWritingForm } from '../../contexts/WritingFormContext';
import { WritingWelcome } from './WritingWelcome';
/** /**
* 写作配置 Q&A 面板(AI 写作入口页右侧,仅在未开始写作时显示)。 * 写作配置 Q&A 面板(AI 写作入口页右侧,仅在未开始写作时显示)。
@@ -103,6 +104,9 @@ export function WritingSetupChat() {
[applyProposal], [applyProposal],
); );
// 对话还没有任何消息时显示欢迎/空状态屏(与原型图一致)
const showWelcome = thread.messages.length === 0 && !thread.isLoading;
const handleReject = useCallback(async () => { const handleReject = useCallback(async () => {
await handleChatSubmit('这份配置先不应用,请继续帮我调整。'); await handleChatSubmit('这份配置先不应用,请继续帮我调整。');
}, [handleChatSubmit]); }, [handleChatSubmit]);
@@ -111,13 +115,19 @@ export function WritingSetupChat() {
<ThreadContext.Provider value={{ thread }}> <ThreadContext.Provider value={{ thread }}>
<ArtifactsProvider> <ArtifactsProvider>
<div className="flex size-full flex-col bg-background"> <div className="flex size-full flex-col bg-background">
{/* 空状态隐藏对话头部,直接展示欢迎屏(与原型图一致);有消息后再显示头部 */}
{!showWelcome && (
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3"> <header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
<Sparkles className="size-4 text-primary" /> <Sparkles className="size-4 text-primary" />
<h2 className="text-sm font-semibold">写作助手</h2> <h2 className="text-sm font-semibold">写作助手</h2>
<span className="text-muted-foreground text-xs">描述你想写什么,我来帮你填好左侧配置</span> <span className="text-muted-foreground text-xs">描述你想写什么,我来帮你填好右侧配置</span>
</header> </header>
)}
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
{showWelcome ? (
<WritingWelcome />
) : (
<MessageList <MessageList
className="size-full" className="size-full"
threadId={threadId} threadId={threadId}
@@ -126,20 +136,25 @@ export function WritingSetupChat() {
onSetupWritingApprove={handleApprove} onSetupWritingApprove={handleApprove}
onSetupWritingReject={handleReject} onSetupWritingReject={handleReject}
/> />
)}
</div> </div>
<div className="bg-background shrink-0 space-y-2 border-t px-4 py-4"> <div className="bg-background shrink-0 border-t px-4 py-4">
<div className="flex items-center gap-2"> {/* 与原型图一致:白色圆角卡片包裹「模型选择行 + 输入框」 */}
<span className="text-muted-foreground text-xs">当前对话模型</span> <div className="space-y-3 rounded-2xl border border-[#ece5e4] bg-card p-3 shadow-sm dark:border-[#1B477E] dark:bg-[#022248]/40">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<Sparkles className="size-3.5 shrink-0 text-primary" />
<span className="shrink-0">当前对话模型</span>
{modelsLoading || models.length === 0 ? ( {modelsLoading || models.length === 0 ? (
<div className="border-input bg-background text-muted-foreground flex h-7 min-w-[10rem] items-center rounded-md border px-2 text-xs shadow-xs dark:border-[#1B477E] dark:bg-[#022248]"> <div className="border-input bg-background text-muted-foreground flex h-7 min-w-[9rem] items-center rounded-md border px-2 text-xs shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
{modelsLoading ? '加载中…' : '暂无模型'} {modelsLoading ? '加载中…' : '暂无模型'}
</div> </div>
) : ( ) : (
<Select value={chatModel} onValueChange={setChatModel} disabled={thread.isLoading}> <Select value={chatModel} onValueChange={setChatModel} disabled={thread.isLoading}>
<SelectTrigger <SelectTrigger
className={cn( className={cn(
'border-input bg-background h-7 min-w-[10rem] px-2 text-xs shadow-xs outline-none', 'border-input bg-background h-7 min-w-[9rem] max-w-[14rem] px-2 text-xs shadow-xs outline-none',
'dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]', 'dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]',
)} )}
> >
@@ -155,9 +170,13 @@ export function WritingSetupChat() {
</Select> </Select>
)} )}
</div> </div>
<span className="hidden shrink-0 text-[11px] text-muted-foreground/70 sm:block">
输入想法,按 Enter 直接解析填充
</span>
</div>
<PromptInput onSubmit={({ text }) => void handleChatSubmit(text)}> <PromptInput onSubmit={({ text }) => void handleChatSubmit(text)}>
<PromptInputTextarea <PromptInputTextarea
placeholder="例如:帮我写一篇 2000 字、面向行业专家的新能源汽车出口分析" placeholder="例如:帮我写一篇 1500 字、面向行业专家关于人工智能商业趋势的分析报道"
/> />
<PromptInputFooter className="justify-end"> <PromptInputFooter className="justify-end">
<PromptInputSubmit status={submitStatus} /> <PromptInputSubmit status={submitStatus} />
@@ -165,6 +184,7 @@ export function WritingSetupChat() {
</PromptInput> </PromptInput>
</div> </div>
</div> </div>
</div>
</ArtifactsProvider> </ArtifactsProvider>
</ThreadContext.Provider> </ThreadContext.Provider>
); );
@@ -0,0 +1,73 @@
import { PenTool, Sliders, Sparkles } from 'lucide-react';
/**
* AI 写作入口页左栏「欢迎/空状态」屏。
*
* 仅在写作助手对话还没有任何消息时显示(见 WritingSetupChat)。一旦用户在底栏发出
* 第一条消息,就回到正常的对话时间线。
*/
const STEPS = [
{
n: '1',
title: '对白意图解析',
desc: '在底栏用人话随便说出你的想法,AI 会瞬间解析要求并填充右侧配置。',
icon: Sparkles,
iconClass: 'bg-indigo-50 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300',
},
{
n: '2',
title: '参数多端对齐',
desc: '右侧配置框即刻对齐所需的体裁、目标行文大纲和字数限制。',
icon: Sliders,
iconClass: 'bg-amber-50 text-amber-600 dark:bg-amber-500/15 dark:text-amber-300',
},
{
n: '3',
title: '深度排版落成',
desc: '点击右侧红色创作按钮,精美的行业或创意文档极速铺开。',
icon: PenTool,
iconClass: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-300',
},
];
export function WritingWelcome() {
return (
<div className="size-full overflow-y-auto">
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col px-6 py-7">
{/* 标题 + 介绍 + 三步引导卡片:在区域里垂直居中 */}
<div className="flex flex-1 flex-col justify-center gap-6">
<div>
<h2 className="text-2xl font-bold leading-snug tracking-tight text-slate-800 dark:text-white">
欢迎使用「妙笔 AI」智力打字协作工坊
</h2>
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
这是一个专业的自动化写作系统。你只需在下方对话框中
<strong className="font-semibold text-slate-700 dark:text-white/80">
输入你的本意(例如:「帮我写一篇 1500 字的新能源汽车报告」)
</strong>
,系统即会自动为你在右侧填充所需的字数、体裁等格式进行对齐;你也可在右侧进行纯人工的参数深度定制,并一键开启 AI 深度协作创作。
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{STEPS.map((step) => (
<div
key={step.n}
className="flex flex-col gap-2 rounded-xl border border-slate-100/80 bg-slate-50/50 p-4 transition-colors hover:bg-slate-50 dark:border-white/5 dark:bg-white/[0.02] dark:hover:bg-white/5"
>
<div className={`flex size-8 items-center justify-center rounded-lg ${step.iconClass}`}>
<step.icon className="size-4" />
</div>
<h4 className="text-sm font-semibold text-slate-800 dark:text-white">
{step.n}. {step.title}
</h4>
<p className="text-xs leading-relaxed text-slate-500 dark:text-white/55">{step.desc}</p>
</div>
))}
</div>
</div>
</div>
</div>
);
}
@@ -246,7 +246,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
saveAIWritingTranscript(state.sessionId, { saveAIWritingTranscript(state.sessionId, {
progressEvents: state.progressEvents, progressEvents: state.progressEvents,
completedInterventions: state.completedInterventions, completedInterventions: state.completedInterventions,
}).catch(() => {}); }).catch(() => { });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.status, state.sessionId]); }, [state.status, state.sessionId]);
@@ -256,6 +256,10 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
const stream = streamRef.current; const stream = streamRef.current;
if (!stream) return; if (!stream) return;
try { try {
// 所见即所得:用户在表单选了什么 materialSource,前端就原样传过去。
// 三个合法值:knowledge_base / skill / notebook,后端 state.py 接受任意 str。
// 与 material_source 独立的 notebook_search_space_id / notebook_source_ids
// 控制是否真的去笔记后端拉素材(仅当用户选了"我的空间"模式时由表单填充)。
await stream.submit( await stream.submit(
{ {
user_intent: request.userIntent, user_intent: request.userIntent,
@@ -266,7 +270,9 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
word_count_target: request.wordCountTarget, word_count_target: request.wordCountTarget,
keyword_count: Math.max(1, Math.min(10, request.keywordCount)), keyword_count: Math.max(1, Math.min(10, request.keywordCount)),
writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose', writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose',
material_source: request.materialSource === 'skill' ? 'skill' : 'knowledge_base', material_source: request.materialSource,
notebook_search_space_id: request.noteSearchSpaceId ?? null,
notebook_source_ids: request.noteSourceIds ?? null,
revision_count: 0, revision_count: 0,
max_revisions: 3, max_revisions: 3,
status: 'researching', status: 'researching',
@@ -53,7 +53,7 @@ export function useWritingFormOptional(): WritingFormValue | null {
return useContext(WritingFormContext); return useContext(WritingFormContext);
} }
export function WritingFormProvider({ children }: { children: ReactNode }) { export function WritingFormProvider({ children, initialMaterialSource }: { children: ReactNode; initialMaterialSource?: 'knowledge_base' | 'skill' | 'notebook' }) {
const [intent, setIntent] = useState(''); const [intent, setIntent] = useState('');
const [showOutline, setShowOutline] = useState(false); const [showOutline, setShowOutline] = useState(false);
const [outlineText, setOutlineText] = useState(''); const [outlineText, setOutlineText] = useState('');
@@ -61,7 +61,7 @@ export function WritingFormProvider({ children }: { children: ReactNode }) {
const [wordCountInput, setWordCountInput] = useState('800'); const [wordCountInput, setWordCountInput] = useState('800');
const [audience, setAudience] = useState('普通读者'); const [audience, setAudience] = useState('普通读者');
const [strictMode, setStrictMode] = useState(false); const [strictMode, setStrictMode] = useState(false);
const [materialSource, setMaterialSource] = useState<'knowledge_base' | 'skill' | 'notebook'>('knowledge_base'); const [materialSource, setMaterialSource] = useState<'knowledge_base' | 'skill' | 'notebook'>(initialMaterialSource ?? 'knowledge_base');
const [modelName, setModelName] = useState(''); const [modelName, setModelName] = useState('');
const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => { const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => {
@@ -115,11 +115,17 @@ const SETUP_LAYOUT = { [SETUP_CHAT_ID]: 65, [SETUP_FORM_ID]: 35 };
/** /**
* 写作配置表单面板(写作助手回填的目标),抽出复用:idle 时在右栏,开始写作后在左栏。 * 写作配置表单面板(写作助手回填的目标),抽出复用:idle 时在右栏,开始写作后在左栏。
*/ */
function WritingFormPanel({ hideAdvancedFields }: { hideAdvancedFields: boolean }) { interface NotebookProps {
initialNotebookId?: number;
initialDocumentIds?: number[];
studioToken?: string;
}
function WritingFormPanel({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
return ( return (
// 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 // 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析
<div className="flex-1 min-h-0 overflow-hidden"> <div className="flex-1 min-h-0 overflow-hidden">
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} /> <AIWritingPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
</div> </div>
); );
} }
@@ -129,7 +135,7 @@ function WritingFormPanel({ hideAdvancedFields }: { hideAdvancedFields: boolean
* - idle(初始问答阶段):左=「写作助手 Q&A」,右=「写作配置选项」表单; * - idle(初始问答阶段):左=「写作助手 Q&A」,右=「写作配置选项」表单;
* - 开始写作后:回到原布局——左=写作配置/时间线面板,右=草稿预览。 * - 开始写作后:回到原布局——左=写作配置/时间线面板,右=草稿预览。
*/ */
function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }) { function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
const layoutRef = useRef<GroupImperativeHandle>(null); const layoutRef = useRef<GroupImperativeHandle>(null);
const { state } = useAIWriting(); const { state } = useAIWriting();
const { isHistoryMode } = useAIWritingHistory(); const { isHistoryMode } = useAIWritingHistory();
@@ -162,7 +168,7 @@ function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }
minSize={20} minSize={20}
className="overflow-hidden flex flex-col" className="overflow-hidden flex flex-col"
> >
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} /> <WritingFormPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>
); );
@@ -202,8 +208,14 @@ function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }
export default function AIWritingPage({ export default function AIWritingPage({
hideAdvancedFields = false, hideAdvancedFields = false,
initialNotebookId,
initialDocumentIds,
studioToken,
}: { }: {
hideAdvancedFields?: boolean; hideAdvancedFields?: boolean;
initialNotebookId?: number;
initialDocumentIds?: number[];
studioToken?: string;
} = {}) { } = {}) {
return ( return (
<CanvasThreadProvider> <CanvasThreadProvider>
@@ -211,11 +223,11 @@ export default function AIWritingPage({
<CanvasUIProvider> <CanvasUIProvider>
<AIWritingHistoryProvider> <AIWritingHistoryProvider>
<AIWritingProvider> <AIWritingProvider>
<WritingFormProvider> <WritingFormProvider initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}>
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background"> <div className="h-full flex flex-col overflow-hidden bg-background">
<div className="flex-1 min-h-0 overflow-hidden"> <div className="flex-1 min-h-0 overflow-hidden">
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} /> <AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
</div> </div>
</div> </div>
</TooltipProvider> </TooltipProvider>
@@ -7,7 +7,7 @@ export interface AIMaterial {
source: string; source: string;
publishedAt: string; publishedAt: string;
relevanceScore: number; relevanceScore: number;
sourceType: 'internet' | 'intranet' | 'skill' | 'notebook'; sourceType: "internet" | "intranet" | "skill" | "notebook";
url?: string; url?: string;
recUuid?: string; recUuid?: string;
// skill 类素材的元信息(skill_name / container_path / category / skill_full_content 等) // skill 类素材的元信息(skill_name / container_path / category / skill_full_content 等)
@@ -22,7 +22,13 @@ export interface AIMaterial {
// skill_result:某个技能检索返回了 N 条素材 // skill_result:某个技能检索返回了 N 条素材
// stop_early:累计素材达到上限,停止后续技能 // stop_early:累计素材达到上限,停止后续技能
export interface SkillSearchStep { export interface SkillSearchStep {
type: 'list_skills' | 'match_skills' | 'select_skills' | 'invoke_skill' | 'skill_result' | 'stop_early'; type:
| "list_skills"
| "match_skills"
| "select_skills"
| "invoke_skill"
| "skill_result"
| "stop_early";
message: string; message: string;
skillNames?: string[]; skillNames?: string[];
skillName?: string; skillName?: string;
@@ -65,23 +71,28 @@ export interface AIOutline {
* - 任何意外形态 → 空 KeyPoint(防止崩溃,不抛异常) * - 任何意外形态 → 空 KeyPoint(防止崩溃,不抛异常)
*/ */
export function normalizeKeyPoint(raw: unknown): KeyPoint { export function normalizeKeyPoint(raw: unknown): KeyPoint {
if (typeof raw === 'string') { if (typeof raw === "string") {
return { pointTitle: raw, subPoints: [] }; return { pointTitle: raw, subPoints: [] };
} }
if (raw && typeof raw === 'object') { if (raw && typeof raw === "object") {
const r = raw as Record<string, unknown>; const r = raw as Record<string, unknown>;
const title = const title =
typeof r.pointTitle === 'string' ? r.pointTitle : typeof r.pointTitle === "string"
typeof r.point_title === 'string' ? r.point_title : ? r.pointTitle
typeof r.title === 'string' ? r.title : : typeof r.point_title === "string"
''; ? r.point_title
: typeof r.title === "string"
? r.title
: "";
const subsRaw = (r.subPoints ?? r.sub_points ?? []) as unknown; const subsRaw = (r.subPoints ?? r.sub_points ?? []) as unknown;
const subPoints: string[] = Array.isArray(subsRaw) const subPoints: string[] = Array.isArray(subsRaw)
? subsRaw.map((s) => (typeof s === 'string' ? s : String(s ?? ''))).filter(Boolean) ? subsRaw
.map((s) => (typeof s === "string" ? s : String(s ?? "")))
.filter(Boolean)
: []; : [];
return { pointTitle: title, subPoints }; return { pointTitle: title, subPoints };
} }
return { pointTitle: '', subPoints: [] }; return { pointTitle: "", subPoints: [] };
} }
// ── 素材不足求助 ────────────────────────────────────────────────────────────── // ── 素材不足求助 ──────────────────────────────────────────────────────────────
@@ -93,7 +104,7 @@ export interface BlockedSection {
} }
/** 单个被阻塞章节的处理决策 */ /** 单个被阻塞章节的处理决策 */
export type SectionDecisionKind = 'supplement' | 'delete' | 'loose'; export type SectionDecisionKind = "supplement" | "delete" | "loose";
export interface SectionDecision { export interface SectionDecision {
sectionTitle: string; sectionTitle: string;
@@ -103,15 +114,15 @@ export interface SectionDecision {
// ── 编辑审核 ────────────────────────────────────────────────────────────────── // ── 编辑审核 ──────────────────────────────────────────────────────────────────
export interface ReviewIssue { export interface ReviewIssue {
category: 'fact' | 'logic' | 'language'; category: "fact" | "logic" | "language";
severity: 'major' | 'minor'; severity: "major" | "minor";
location: string; location: string;
issue: string; issue: string;
suggestion: string; suggestion: string;
} }
export interface ReviewResult { export interface ReviewResult {
verdict: 'pass' | 'reject'; verdict: "pass" | "reject";
factScore: number; factScore: number;
logicScore: number; logicScore: number;
languageScore: number; languageScore: number;
@@ -123,44 +134,44 @@ export interface ReviewResult {
// ── 写作状态 ────────────────────────────────────────────────────────────────── // ── 写作状态 ──────────────────────────────────────────────────────────────────
export type WritingStatus = export type WritingStatus =
| 'idle' | "idle"
| 'researching' | "researching"
| 'awaiting_material_confirm' | "awaiting_material_confirm"
| 'writing_outline' | "writing_outline"
| 'awaiting_outline_confirm' | "awaiting_outline_confirm"
| 'writing_draft' | "writing_draft"
| 'awaiting_section_help' | "awaiting_section_help"
| 'awaiting_draft_confirm' | "awaiting_draft_confirm"
| 'reviewing' | "reviewing"
| 'awaiting_review_confirm' | "awaiting_review_confirm"
| 'revising' | "revising"
| 'done' | "done"
| 'error'; | "error";
// ── SSE 进度事件 ────────────────────────────────────────────────────────────── // ── SSE 进度事件 ──────────────────────────────────────────────────────────────
export type ProgressEventType = export type ProgressEventType =
| 'connected' | "connected"
| 'step_start' | "step_start"
| 'step_done' | "step_done"
| 'intent_chunk' | "intent_chunk"
| 'intent_ready' | "intent_ready"
| 'research_progress' | "research_progress"
| 'materials_ready' | "materials_ready"
| 'outline_chunk' | "outline_chunk"
| 'outline_ready' | "outline_ready"
| 'draft_chunk' | "draft_chunk"
| 'draft_ready' | "draft_ready"
| 'review_chunk' | "review_chunk"
| 'review_item_done' | "review_item_done"
| 'review_ready' | "review_ready"
| 'await_user' | "await_user"
| 'resumed' | "resumed"
| 'notice' | "notice"
| 'skill_search_step' | "skill_search_step"
| 'skill_select_chunk' | "skill_select_chunk"
| 'done' | "done"
| 'error'; | "error";
export interface ProgressEvent { export interface ProgressEvent {
type: ProgressEventType; type: ProgressEventType;
@@ -183,13 +194,18 @@ export interface ProgressEvent {
draftTitle?: string; draftTitle?: string;
revisionCount?: number; revisionCount?: number;
// review_item_done (streaming per-category result) // review_item_done (streaming per-category result)
reviewItemCategory?: 'fact' | 'logic' | 'language'; reviewItemCategory?: "fact" | "logic" | "language";
reviewItemScore?: number; reviewItemScore?: number;
reviewItemIssues?: ReviewIssue[]; reviewItemIssues?: ReviewIssue[];
// review_ready // review_ready
reviewResult?: ReviewResult; reviewResult?: ReviewResult;
// await_user // await_user
pausePoint?: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm' | 'section_help'; pausePoint?:
| "material_confirm"
| "outline_confirm"
| "draft_confirm"
| "review_confirm"
| "section_help";
pauseContext?: Record<string, unknown>; pauseContext?: Record<string, unknown>;
// section_help:素材不足、待用户决策的章节 // section_help:素材不足、待用户决策的章节
blockedSections?: BlockedSection[]; blockedSections?: BlockedSection[];
@@ -229,19 +245,19 @@ export interface AIWritingRequest {
/** 素材检索关键词数量(1~10,默认 1) */ /** 素材检索关键词数量(1~10,默认 1) */
keywordCount: number; keywordCount: number;
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */ /** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
writingMode: 'strict' | 'loose'; writingMode: "strict" | "loose";
/** 素材来源:knowledge_base=知识库 / skill=技能检索 / notebook=我的笔记 */ /** 素材来源:knowledge_base=知识库 / skill=技能检索 / notebook=我的空间 */
materialSource: 'knowledge_base' | 'skill' | 'notebook'; materialSource: "knowledge_base" | "skill" | "notebook";
/** 笔记来源(可选):笔记本 search_space_id */ /** 笔记来源(可选):空间 search_space_id */
noteSearchSpaceId?: number; noteSearchSpaceId?: number;
/** 笔记来源(可选):笔记本内指定文档的 document_id 列表,空数组表示检索整个笔记本 */ /** 笔记来源(可选):空间内指定文档的 document_id 列表,空数组表示检索整个空间 */
noteSourceIds?: number[]; noteSourceIds?: number[];
} }
// ── 流式审核条目 ────────────────────────────────────────────────────────────── // ── 流式审核条目 ──────────────────────────────────────────────────────────────
export interface StreamingReviewItem { export interface StreamingReviewItem {
category: 'fact' | 'logic' | 'language'; category: "fact" | "logic" | "language";
// 是否已生成完毕(true 时展示结构化评分/意见,false 时展示流式打字机文本) // 是否已生成完毕(true 时展示结构化评分/意见,false 时展示流式打字机文本)
isDone: boolean; isDone: boolean;
// 流式过程文本(正文 / 思考分开) // 流式过程文本(正文 / 思考分开)
@@ -274,7 +290,12 @@ export interface StreamingDraftSection {
// ── 已完成干预记录 ──────────────────────────────────────────────────────────── // ── 已完成干预记录 ────────────────────────────────────────────────────────────
export interface CompletedIntervention { export interface CompletedIntervention {
pausePoint: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm' | 'section_help'; pausePoint:
| "material_confirm"
| "outline_confirm"
| "draft_confirm"
| "review_confirm"
| "section_help";
action: string; action: string;
// material_confirm // material_confirm
materials?: AIMaterial[]; materials?: AIMaterial[];
@@ -49,7 +49,6 @@ import { MarkdownContent } from "@/components/workspace/messages/markdown-conten
import type { import type {
AdminLeaderboardDimension, AdminLeaderboardDimension,
AdminLeaderboardOverview, AdminLeaderboardOverview,
AdminLeaderboardQuestion,
AdminLeaderboardResponse, AdminLeaderboardResponse,
AdminLeaderboardSkill, AdminLeaderboardSkill,
AdminLeaderboardTrendPoint, AdminLeaderboardTrendPoint,
@@ -66,7 +65,7 @@ type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency"
type RangeState = { since: string; until: string; days?: number }; type RangeState = { since: string; until: string; days?: number };
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal"; type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
const DEFAULT_REPORT_MODEL = "__default__"; const DEFAULT_REPORT_MODEL = "__default__";
const LEADERBOARD_PAGE_SIZE = 20; const LEADERBOARD_PAGE_SIZE = 10;
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = { const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
crimson: { crimson: {
@@ -132,11 +131,6 @@ function fmtTime(value?: string | null): string {
return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
} }
function clampText(value: string, max = 120): string {
const text = value.trim();
return text.length > max ? `${text.slice(0, max)}...` : text;
}
function userScore(user: AdminLeaderboardUser, tab: UserRankTab): number { function userScore(user: AdminLeaderboardUser, tab: UserRankTab): number {
if (tab === "questions") return user.qa_count; if (tab === "questions") return user.qa_count;
if (tab === "tokens") return user.total_tokens; if (tab === "tokens") return user.total_tokens;
@@ -207,12 +201,6 @@ function markdownList(items: string[]): string {
function buildReportFocusMarkdown(data: AdminLeaderboardResponse | undefined, range: RangeState): string { function buildReportFocusMarkdown(data: AdminLeaderboardResponse | undefined, range: RangeState): string {
if (!data) return ""; if (!data) return "";
const recentQuestions = data.recent_questions
.slice(0, 8)
.map((item) => `${clampText(item.text, 120)}(${fmtTime(item.asked_at)})`);
const frequentQuestions = data.frequent_questions
.slice(0, 8)
.map((item) => clampText(item.text, 120));
const frequentUsage = [ const frequentUsage = [
...data.skills.slice(0, 5).map((item) => `技能:${item.name},${fmtNumber(item.call_count)} 次调用`), ...data.skills.slice(0, 5).map((item) => `技能:${item.name},${fmtNumber(item.call_count)} 次调用`),
...data.tools.slice(0, 5).map((item) => `工具:${item.name},${fmtNumber(item.call_count)} 次调用`), ...data.tools.slice(0, 5).map((item) => `工具:${item.name},${fmtNumber(item.call_count)} 次调用`),
@@ -223,12 +211,6 @@ function buildReportFocusMarkdown(data: AdminLeaderboardResponse | undefined, ra
"", "",
`统计周期:${range.since} 至 ${range.until}`, `统计周期:${range.since} 至 ${range.until}`,
"", "",
"## 最近关注",
markdownList(recentQuestions),
"",
"## 高频问题",
markdownList(frequentQuestions),
"",
"## 使用频率重点", "## 使用频率重点",
markdownList(frequentUsage), markdownList(frequentUsage),
].join("\n"); ].join("\n");
@@ -905,43 +887,6 @@ function DimensionRanking({
); );
} }
function QuestionPanel({
title,
items,
showUser = true,
}: {
title: string;
items: AdminLeaderboardQuestion[];
showUser?: boolean;
}) {
return (
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-center justify-between gap-3 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#213D5E]">
<div className="flex items-center gap-2">
<MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
</div>
<Badge variant="outline">{items.length}</Badge>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="divide-y divide-[#F1D6D6] dark:divide-[#213D5E]">
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368] dark:text-slate-400">暂无问题记录</div>}
{items.map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
<div className="text-sm leading-6 text-[#202124] dark:text-slate-100">{clampText(item.text, 180)}</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400">
{showUser && <span>{item.email || item.user_id || "未知用户"}</span>}
<span>{fmtTime(item.asked_at)}</span>
{item.thread_id && <span>会话 {item.thread_id.slice(0, 8)}</span>}
</div>
</div>
))}
</div>
</ScrollArea>
</section>
);
}
function Metric({ label, value }: { label: string; value: number }) { function Metric({ label, value }: { label: string; value: number }) {
return ( return (
<div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#213D5E]"> <div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#213D5E]">
@@ -1006,16 +951,12 @@ function UserDetailPanel({
</div> </div>
</section> </section>
<Tabs defaultValue="questions" className="space-y-3"> <Tabs defaultValue="trend" className="space-y-3">
<TabsList className="mb-3"> <TabsList className="mb-3">
<TabsTrigger value="questions">提问</TabsTrigger>
<TabsTrigger value="trend">趋势</TabsTrigger> <TabsTrigger value="trend">趋势</TabsTrigger>
<TabsTrigger value="skills">技能</TabsTrigger> <TabsTrigger value="skills">技能</TabsTrigger>
<TabsTrigger value="dimensions">维度</TabsTrigger> <TabsTrigger value="dimensions">维度</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="questions" className="min-h-[420px]">
<QuestionPanel title="最近提问" items={data.recent_questions} showUser={false} />
</TabsContent>
<TabsContent value="trend" className="min-h-[420px]"> <TabsContent value="trend" className="min-h-[420px]">
<div className="mb-3 flex items-center gap-2 font-semibold text-[#231815] dark:text-white"> <div className="mb-3 flex items-center gap-2 font-semibold text-[#231815] dark:text-white">
<TrendingUp className="size-4 text-[#C1121F] dark:text-[#7FACF2]" /> <TrendingUp className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
@@ -1354,36 +1295,7 @@ export default function AdminUserLeaderboardPage() {
</Button> </Button>
</div> </div>
{data && reportStatus === "idle" && !reportText && ( {data && reportStatus === "idle" && !reportText && (
<div className="mx-6 mt-4 grid shrink-0 gap-3 md:grid-cols-3"> <div className="mx-6 mt-4 grid shrink-0 gap-3">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<MessageSquareText className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
最近关注
</div>
<div className="space-y-2">
{data.recent_questions.slice(0, 3).map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="text-xs leading-5 text-[#5F6368] dark:text-slate-300">
<span className="font-medium text-[#C1121F] dark:text-[#7FACF2]">{fmtTime(item.asked_at)}</span>
<span className="ml-1">{clampText(item.text, 58)}</span>
</div>
))}
{data.recent_questions.length === 0 && <div className="text-xs text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>}
</div>
</section>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<TrendingUp className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
高频问题
</div>
<div className="space-y-2">
{data.frequent_questions.slice(0, 3).map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="text-xs leading-5 text-[#5F6368] dark:text-slate-300">
{index + 1}. {clampText(item.text, 72)}
</div>
))}
{data.frequent_questions.length === 0 && <div className="text-xs text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>}
</div>
</section>
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]"> <section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white"> <div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
<Sparkles className="size-4 text-[#C1121F] dark:text-[#7FACF2]" /> <Sparkles className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
@@ -1645,12 +1557,6 @@ export default function AdminUserLeaderboardPage() {
<DimensionRanking title="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" /> <DimensionRanking title="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" />
<DimensionRanking title="运行状态分布" icon={Gauge} items={data.run_statuses} empty="当前时间段暂无运行状态" /> <DimensionRanking title="运行状态分布" icon={Gauge} items={data.run_statuses} empty="当前时间段暂无运行状态" />
</div> </div>
<div className="grid gap-5 xl:grid-cols-2">
<QuestionPanel title="最近经常问的问题" items={data.frequent_questions} />
<QuestionPanel title="最新问题" items={data.recent_questions} />
</div>
</> </>
)} )}
</div> </div>
+132 -74
View File
@@ -3,26 +3,23 @@
import { BotIcon, PlusSquare } from "lucide-react"; import { BotIcon, PlusSquare } from "lucide-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useRouteBase } from "@/core/route-base"; import { useRouteBase } from "@/core/route-base";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
import { AgentWelcome } from "@/components/workspace/agent-welcome"; import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts"; import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats"; import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger"; import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box"; import { InputBox } from "@/components/workspace/input-box";
import { import { MessageList } from "@/components/workspace/messages";
MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title"; import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list"; import { TodoList } from "@/components/workspace/todo-list";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator"; import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
import { Tooltip } from "@/components/workspace/tooltip"; import { Tooltip } from "@/components/workspace/tooltip";
import { useAgent } from "@/core/agents"; import { normalizeAgentQuestions, useAgent } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
@@ -65,6 +62,10 @@ export default function AgentChatPage() {
const agentDisplayName = desensitize( const agentDisplayName = desensitize(
agent?.name ?? (isAgentLoading ? t.common.loading : ""), agent?.name ?? (isAgentLoading ? t.common.loading : ""),
); );
const commonQuestions = useMemo(
() => normalizeAgentQuestions(agent?.common_questions),
[agent?.common_questions],
);
const { threadId, setThreadId, isNewThread, setIsNewThread } = const { threadId, setThreadId, isNewThread, setIsNewThread } =
useThreadChat(); useThreadChat();
@@ -162,25 +163,29 @@ export default function AgentChatPage() {
await thread.stop(); await thread.stop();
}, [thread]); }, [thread]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const isLandingState = isNewThread && !hasStartedConversation; const isLandingState = isNewThread && !hasStartedConversation;
const isDemoMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
const status = thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready";
return ( return (
<ThreadContext.Provider value={{ thread }}> <ThreadContext.Provider value={{ thread }}>
{/* Root: fixed-height horizontal split. ChatBox (chat + artifact
preview) lives in the left flex cell; the agent detail panel is a
separate flex sibling OUTSIDE ChatBox, so the artifact preview
always opens between the chat and the detail panel — the detail
panel stays rightmost. */}
<div className="flex size-full min-h-0 overflow-hidden">
<div className="min-h-0 min-w-0 flex-1">
<ChatBox threadId={threadId}> <ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between"> {/* Main conversation column */}
<header <main className="flex size-full min-h-0 min-w-0 flex-col">
className={cn( {/* Header in normal flow, fixed height. */}
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 border-b px-4", <header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md">
isLandingState
? "border-transparent bg-transparent"
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
)}
>
{/* Agent badge */}
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1"> <div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" /> <BotIcon className="text-primary h-3.5 w-3.5" />
<span <span
@@ -191,16 +196,19 @@ export default function AgentChatPage() {
</span> </span>
</div> </div>
<div className="flex w-full items-center text-sm font-medium"> <div className="flex min-w-0 flex-1 items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} /> <ThreadTitle threadId={threadId} thread={thread} />
</div> </div>
<div className="mr-4 flex items-center">
<div className="flex items-center">
<Tooltip content={t.agents.newChat}> <Tooltip content={t.agents.newChat}>
<Button <Button
size="sm" size="sm"
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
router.push(`/page/workspace/agents/${agent_id}/chats/new`); router.push(
`/page/workspace/agents/${agent_id}/chats/new`,
);
}} }}
> >
<PlusSquare /> {t.agents.newChat} <PlusSquare /> {t.agents.newChat}
@@ -215,78 +223,128 @@ export default function AgentChatPage() {
</div> </div>
</header> </header>
<main className="flex min-h-0 max-w-full grow flex-col"> {isLandingState ? (
<div className="flex size-full justify-center"> /* Home: centered hero, suggested questions, and input.
<MessageList Everything lives inside the scrollable main column. No fixed,
className={cn("size-full", !isLandingState && "pt-10")} no absolute, no viewport centering, so the avatar can't touch
threadId={threadId} the header and the input can't cover the sidebar. */
thread={thread} <div className="flex min-h-0 flex-1 flex-col items-center overflow-y-auto px-6 pb-10 pt-16 sm:pt-24">
paddingBottom={messageListPaddingBottom} <AgentWelcome
tokenUsageEnabled={tokenUsageEnabled} agent={agent}
onClarificationSubmit={handleClarificationSubmit} agentName={agentDisplayName}
onEditResubmit={(text) => handleSubmit({ text, files: [] })} className="mb-7"
/> />
</div> <div className="w-full max-w-(--container-width-md)">
{commonQuestions.length > 0 && (
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4"> <div className="mb-2 flex max-h-20 flex-wrap items-start justify-start gap-2 overflow-y-auto px-1">
<div {commonQuestions.map((question, index) => (
<button
key={index}
type="button"
disabled={isDemoMode}
onClick={() =>
handleSubmit({ text: question.prompt, files: [] })
}
title={desensitize(question.prompt)}
className={cn( className={cn(
"relative w-full", "border-border/70 bg-background/85 hover:bg-accent hover:border-border max-w-full rounded-full border px-3 py-1.5 text-left text-sm leading-5 shadow-sm transition-colors",
isLandingState && "-translate-y-[calc(50vh-96px)]", "disabled:cursor-not-allowed disabled:opacity-50",
isLandingState
? "max-w-(--container-width-md)"
: "max-w-(--container-width-lg)",
)} )}
> >
<div className="absolute -top-4 right-0 left-0 z-0"> <span className="block max-w-[220px] truncate">
<div className="absolute right-0 bottom-0 left-0"> {desensitize(question.title)}
<TodoList </span>
className="bg-background/5" </button>
todos={thread.values.todos ?? []} ))}
hidden={
!thread.values.todos || thread.values.todos.length === 0
}
/>
</div> </div>
</div> )}
<div className="min-w-0 flex-1">
<div className="w-full -translate-y-4">
<InputBox <InputBox
className="w-full" className="w-full"
isNewThread={isLandingState} isNewThread
hideAdminRecommendedQuestions
threadId={threadId} threadId={threadId}
assistantId={agent_id} assistantId={agent_id}
autoFocus={isLandingState} autoFocus
status={ status={status}
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
context={settings.context} context={settings.context}
extraHeader={ disabled={isDemoMode}
isLandingState && ( onContextChange={(context) =>
<AgentWelcome agent={agent} agentName={agentDisplayName} /> setSettings("context", context)
)
} }
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onContextChange={(context) => setSettings("context", context)}
onFollowupsVisibilityChange={setShowFollowups} onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onStop={handleStop} onStop={handleStop}
/> />
</div> {isDemoMode && (
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( <div className="text-muted-foreground/67 mt-3 w-full text-center text-xs">
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode} {t.common.notAvailableInDemoMode}
</div> </div>
)} )}
</div> </div>
</div> </div>
</main>
</div> </div>
) : (
/* Conversation: messages scroll, input
docked at the bottom of the main column only. */
<div className="flex min-h-0 flex-1 flex-col">
<MessageList
className="min-h-0 flex-1"
threadId={threadId}
thread={thread}
paddingBottom={showFollowups ? 96 : 24}
tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit}
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
/>
<div className="from-background pointer-events-none shrink-0 bg-gradient-to-t to-transparent px-4 pb-4 pt-2">
<div className="pointer-events-auto mx-auto w-full max-w-(--container-width-lg)">
<TodoList
className="bg-background/5 mb-2"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos ||
thread.values.todos.length === 0
}
/>
<InputBox
className="w-full"
isNewThread={false}
threadId={threadId}
assistantId={agent_id}
status={status}
context={settings.context}
disabled={isDemoMode}
onContextChange={(context) =>
setSettings("context", context)
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
{isDemoMode && (
<div className="text-muted-foreground/67 mt-2 w-full text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
</div>
)}
</main>
</ChatBox> </ChatBox>
</div>
{/* Right: agent detail panel — always the rightmost column. */}
<AgentDetailPanel
agent={agent}
agentName={agentDisplayName}
disabled={thread.isLoading || isDemoMode}
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
/>
</div>
</ThreadContext.Provider> </ThreadContext.Provider>
); );
} }
+60 -42
View File
@@ -22,7 +22,8 @@ import {
} from "@/components/workspace/workspace-container"; } from "@/components/workspace/workspace-container";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
useThreads, useThreadsCount,
useThreadsPage,
useToggleThreadFavorite, useToggleThreadFavorite,
useToggleThreadPin, useToggleThreadPin,
} from "@/core/threads/hooks"; } from "@/core/threads/hooks";
@@ -42,10 +43,10 @@ const PAGE_SIZE = 20;
export default function ChatsPage() { export default function ChatsPage() {
const pathOfThread = usePathOfThread(); const pathOfThread = usePathOfThread();
const { t } = useI18n(); const { t } = useI18n();
const { data: threads } = useThreads();
const { toggle: togglePin } = useToggleThreadPin(); const { toggle: togglePin } = useToggleThreadPin();
const { toggle: toggleFavorite } = useToggleThreadFavorite(); const { toggle: toggleFavorite } = useToggleThreadFavorite();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [onlyFavorites, setOnlyFavorites] = useState(false); const [onlyFavorites, setOnlyFavorites] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
@@ -54,46 +55,58 @@ export default function ChatsPage() {
document.title = `${t.pages.chats} - ${t.pages.appName}`; document.title = `${t.pages.chats} - ${t.pages.appName}`;
}, [t.pages.chats, t.pages.appName]); }, [t.pages.chats, t.pages.appName]);
// Debounce the search box so each keystroke doesn't fire a backend query.
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300);
return () => clearTimeout(timer);
}, [search]);
// Reset to the first page whenever the filtered result set changes. // Reset to the first page whenever the filtered result set changes.
useEffect(() => { useEffect(() => {
setPage(1); setPage(1);
}, [search, onlyFavorites]); }, [debouncedSearch, onlyFavorites]);
const filteredThreads = useMemo(() => { // Conversations are paginated on the backend: each page is one bounded
return (threads ?? []).filter((thread) => { // query (title search + favorites filter included), so a long history is
if ( // never fetched in one go. Pinned conversations come from their own small
thread.metadata?.thread_type === "scheduler" || // query and are surfaced in a dedicated section on the first page only,
thread.metadata?.thread_type === "roundtable" // and dropped from the paginated rows so they don't repeat on every page.
) { const favoriteFilter = useMemo(
return false; () => (onlyFavorites ? { is_favorite: true } : {}),
} [onlyFavorites],
if (onlyFavorites && !isThreadFavorite(thread)) { );
return false; const pinnedFilter = useMemo(
} () => ({ ...favoriteFilter, is_pinned: true }),
return titleOfThread(thread) [favoriteFilter],
.toLowerCase() );
.includes(search.toLowerCase()); const { data: pageData } = useThreadsPage({
page,
pageSize: PAGE_SIZE,
search: debouncedSearch,
metadata: favoriteFilter,
});
const { data: pinnedData } = useThreadsPage({
page: 1,
pageSize: 100,
search: debouncedSearch,
metadata: pinnedFilter,
});
const { data: totalCount } = useThreadsCount({
search: debouncedSearch,
metadata: favoriteFilter,
}); });
}, [threads, search, onlyFavorites]);
// Pinned conversations are surfaced in their own section on the first page const pinnedThreads = pinnedData?.threads ?? [];
// only, and excluded from the paginated list (so they don't repeat on every const pagedThreads = useMemo(
// page as the user pages through history). () => (pageData?.threads ?? []).filter((t) => !isThreadPinned(t)),
const pinnedThreads = useMemo( [pageData?.threads],
() => filteredThreads.filter(isThreadPinned),
[filteredThreads],
);
const restThreads = useMemo(
() => filteredThreads.filter((t) => !isThreadPinned(t)),
[filteredThreads],
);
const totalPages = Math.max(1, Math.ceil(restThreads.length / PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
const pagedThreads = restThreads.slice(
(currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE,
); );
const currentPage = page;
const hasMore = pageData?.hasMore ?? false;
// Page windows are sliced over the raw (pinned-inclusive) row order, so the
// page count derives from the raw total as well.
const totalPages =
totalCount != null ? Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) : null;
const renderThread = (thread: AgentThread) => { const renderThread = (thread: AgentThread) => {
const pinned = isThreadPinned(thread); const pinned = isThreadPinned(thread);
@@ -154,7 +167,7 @@ export default function ChatsPage() {
); );
}; };
const isEmpty = filteredThreads.length === 0; const isEmpty = pagedThreads.length === 0 && pinnedThreads.length === 0;
return ( return (
<WorkspaceContainer> <WorkspaceContainer>
@@ -197,6 +210,11 @@ export default function ChatsPage() {
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
{totalCount != null && (
<div className="text-muted-foreground w-full max-w-(--container-width-md) text-right text-xs tabular-nums">
共 {totalCount} 条对话
</div>
)}
</header> </header>
<main className="min-h-0 flex-1"> <main className="min-h-0 flex-1">
<ScrollArea className="size-full py-4"> <ScrollArea className="size-full py-4">
@@ -215,7 +233,7 @@ export default function ChatsPage() {
{onlyFavorites ? t.chats.noFavorites : t.chats.noChats} {onlyFavorites ? t.chats.noFavorites : t.chats.noChats}
</div> </div>
)} )}
{totalPages > 1 && ( {(currentPage > 1 || hasMore) && (
<div className="flex items-center justify-center gap-3 py-6"> <div className="flex items-center justify-center gap-3 py-6">
<Button <Button
variant="outline" variant="outline"
@@ -227,15 +245,15 @@ export default function ChatsPage() {
{t.chats.prevPage} {t.chats.prevPage}
</Button> </Button>
<span className="text-muted-foreground text-sm tabular-nums"> <span className="text-muted-foreground text-sm tabular-nums">
{currentPage} / {totalPages} {totalPages != null
? `${currentPage} / ${totalPages}`
: currentPage}
</span> </span>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
disabled={currentPage >= totalPages} disabled={!hasMore}
onClick={() => onClick={() => setPage((p) => p + 1)}
setPage((p) => Math.min(totalPages, p + 1))
}
> >
{t.chats.nextPage} {t.chats.nextPage}
<ChevronRight className="size-4" /> <ChevronRight className="size-4" />
@@ -0,0 +1,121 @@
"use client";
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useTheme } from "@/contexts/ThemeContext";
import { loginByUsername } from "@/core/auth/api";
import { setStoredAuth } from "@/core/auth";
import { readWujieProps, isWujieEnv } from "@/core/embed/wujie";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import { ChatRuntime } from "./WorkspaceRoutes";
import "@/roundtable-planning/styles/roundtable-planning.css";
const DEFAULT_USERNAME = "guest";
/**
* 把宿主传入的主题标识映射成 light/dark。**默认深蓝(dark)**:
* 只有明确传 light/红白 才走浅色,其余(dark / dark-blue / 深蓝 / 空)一律深蓝。
* 不复用 `resolveEmbedTheme`(它只认严格的 "dark-blue",传 "dark" 会被判成浅色)。
*/
function resolveEmbedRoundtableTheme(raw: string): "dark" | "light" {
const v = raw.trim().toLowerCase();
if (v === "light" || v === "red" || v === "红白") return "light";
return "dark";
}
/**
* 圆桌规划嵌入页(独立入口,**与现有 wujie ticket 登录无关**)。
*
* 用于宿主(如 parallel-agents-vue 的 RoundtableDrawer)用无界把圆桌**主体页面**塞进抽屉:
* - **登录**:走「用户名免密登录」(`loginByUsername`),username 来自 `window.$wujie.props.username`
* (或 URL `?username=`),不经 LoginPage 的 MaxKey/consumer ticket 流程。
* - **无侧边栏**:本路由挂在顶层(`/embed/roundtable`),在 `AppProviders` 之下、`PageRoutes` 之外,
* 天然不含页面侧边栏 / workspace 导航,只渲染圆桌主体。
* - **任务预填**:taskTitle / taskGoal / chainId 由 `RoundtablePlanningPage` 自身的
* `readWujiePrefill()` 直接从 `window.$wujie.props` 读取(抽屉打开即自动跑第一步、按链条进第二步)。
*/
export default function EmbedRoundtablePage() {
const searchParams = useSearchParams();
const { setTheme } = useTheme();
// username / password / theme:wujie props 优先,URL query 兜底。
const wp = readWujieProps();
const username =
(typeof wp.username === "string" && wp.username.trim()) ||
(typeof wp.userName === "string" && wp.userName.trim()) ||
searchParams.get("username")?.trim() ||
searchParams.get("userName")?.trim() ||
DEFAULT_USERNAME;
// 共享口令(用户名+密码登录模式):仅当后端开启 username_login.require_password 时校验。
// 不 trim —— 口令可能含首尾空格;未传则按「免密」走(后端未开门禁时照常)。
const password =
(typeof wp.password === "string" && wp.password) ||
searchParams.get("password") ||
undefined;
// 主题色:宿主传入优先;默认「深蓝」= dark 主题(其主色为蓝,oklch hue 259)。
const themeParam =
(typeof wp.theme === "string" && wp.theme.trim()) ||
searchParams.get("theme")?.trim() ||
"dark";
const [authReady, setAuthReady] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
useEffect(() => {
setTheme(resolveEmbedRoundtableTheme(themeParam));
}, [themeParam, setTheme]);
useEffect(() => {
if (!isWujieEnv()) return;
document.documentElement.classList.add("wujie-embed");
return () => document.documentElement.classList.remove("wujie-embed");
}, []);
useEffect(() => {
let cancelled = false;
async function runLogin() {
setAuthError(null);
try {
const auth = await loginByUsername(username, password);
if (cancelled) return;
setStoredAuth(auth);
setAuthReady(true);
} catch (error) {
if (cancelled) return;
setAuthError(error instanceof Error ? error.message : "登录失败");
}
}
void runLogin();
return () => {
cancelled = true;
};
}, [username, password]);
if (authError) {
return (
<div className="flex size-full items-center justify-center bg-background p-6 text-sm text-red-600">
登录失败:{authError}
</div>
);
}
if (!authReady) {
return (
<div className="flex size-full items-center justify-center bg-background p-6 text-sm text-muted-foreground">
正在进入圆桌…
</div>
);
}
// RoundtablePlanningPage 依赖 SubtasksProvider / ArtifactsProvider / PromptInputProvider
// (Step 2 沙箱用 useArtifacts)。ChatRuntime 正是这三者的封装;不套 WorkspaceLayout,
// 故无侧边栏,只渲染圆桌主体。
return (
<div className="h-full w-full overflow-hidden">
<ChatRuntime>
<RoundtablePlanningPage />
</ChatRuntime>
</div>
);
}
+274 -459
View File
@@ -1,23 +1,8 @@
import { import { Loader2 } from "lucide-react";
BookOpen, import { useEffect, useMemo, useRef, useState } from "react";
Copy,
ExternalLink,
Loader2,
Network,
Pencil,
Plus,
RefreshCw,
Save,
Search,
Sparkles,
Trash2,
X,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -29,14 +14,16 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { EmptyDocumentState } from "@/components/wiki/EmptyDocumentState";
import { KnowledgeSidebar } from "@/components/wiki/KnowledgeSidebar";
import { WikiContent } from "@/components/wiki/WikiContent";
import { WikiHeader } from "@/components/wiki/WikiHeader";
import {
buildKnowledgeTree,
downloadMarkdown,
findAncestorFolderIds,
} from "@/components/wiki/wikiUtils";
import { ModelNameSelect } from "@/components/workspace/model-name-select"; import { ModelNameSelect } from "@/components/workspace/model-name-select";
import { import {
useKnowledgeActions, useKnowledgeActions,
@@ -52,45 +39,14 @@ import MarkdownRenderer, {
type WikiLinkTarget, type WikiLinkTarget,
} from "@/strategy-components/MarkdownRenderer"; } from "@/strategy-components/MarkdownRenderer";
const SOURCE_TYPE_LABELS: Record<string, string> = { const SIDEBAR_COLLAPSED_KEY = "wiki.sidebar.collapsed";
thread: "对话",
search: "检索",
tool: "工具",
file: "文件",
manual: "手动",
report: "报告",
};
const STATUS_LABELS: Record<string, string> = { function readSidebarCollapsed(): boolean {
draft: "草稿", try {
approved: "已批准", return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1";
archived: "已归档", } catch {
}; return false;
/** Human label for an audit actor: prefer the resolved name (email), else a short id. */
function actorLabel(name?: string | null, id?: string | null): string {
if (name && name.trim()) return name.trim();
if (id && id.trim()) return `${id.trim().slice(0, 8)}…`;
return "—";
}
const ENTITY_TYPE_META: Record<string, { label: string; className: string }> = {
person: { label: "人物", className: "bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-500/15 dark:text-rose-300 dark:border-rose-500/30" },
org: { label: "机构", className: "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30" },
product: { label: "产品", className: "bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30" },
technology: { label: "技术", className: "bg-cyan-50 text-cyan-700 border-cyan-200 dark:bg-cyan-500/15 dark:text-cyan-300 dark:border-cyan-500/30" },
concept: { label: "概念", className: "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30" },
region: { label: "地区", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
country: { label: "国家", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
};
function entityMeta(type?: string | null) {
return (
ENTITY_TYPE_META[(type || "").toLowerCase()] ?? {
label: "其他",
className: "bg-slate-100 text-slate-600 border-slate-200 dark:bg-slate-700/40 dark:text-slate-300 dark:border-slate-600",
} }
);
} }
function useDebounced<T>(value: T, delay = 350): T { function useDebounced<T>(value: T, delay = 350): T {
@@ -102,54 +58,6 @@ function useDebounced<T>(value: T, delay = 350): T {
return debounced; return debounced;
} }
function NoteListItem({
note,
active,
onClick,
}: {
note: KnowledgeNote;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full flex-col gap-1 rounded-md border p-3 text-left transition-colors",
active
? "border-primary bg-primary/5"
: "border-border/70 hover:bg-muted/50",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="line-clamp-1 text-sm font-medium">{note.title}</span>
<Badge variant="outline" className="shrink-0 text-[10px]">
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
</Badge>
</div>
{note.summary ? (
<span className="text-muted-foreground line-clamp-2 text-xs">
{note.summary}
</span>
) : null}
<div className="mt-1 flex flex-wrap items-center gap-1">
{note.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]"
>
#{tag}
</span>
))}
<span className="text-muted-foreground ml-auto text-[10px]">
{note.updated_at?.slice(0, 10)}
</span>
</div>
</button>
);
}
function CreateNoteDialog({ function CreateNoteDialog({
open, open,
onOpenChange, onOpenChange,
@@ -323,6 +231,119 @@ function BatchImportDialog({
); );
} }
function ImportFileDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { ingestFile } = useKnowledgeActions();
const navigate = useNavigate();
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [tags, setTags] = useState("");
const [model, setModel] = useState<string | null>(null);
const reset = () => {
setFile(null);
setTitle("");
setTags("");
setModel(null);
};
const handleImport = async () => {
if (!file) {
toast.error("请选择文件");
return;
}
try {
const result = await ingestFile.mutateAsync({
file,
opts: {
title: title.trim() || undefined,
tags: tags.trim() || undefined,
status: "approved",
model_name: model,
},
});
const count = result.notes?.length ?? 1;
if (result.llm_fallback) {
// The model produced nothing usable — the file was stored verbatim as a
// draft instead of distilled into a wiki note.
toast.warning(
`已导入 ${count} 条(大模型异常,改用规则抽取并存为草稿)`,
);
} else {
toast.success(`已从文件生成 ${count} 条知识`);
}
onOpenChange(false);
reset();
if (result.note?.id) {
navigate(`/page/workspace/knowledge/${result.note.id}`);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "文件导入失败");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>导入文件生成 Wiki</DialogTitle>
<DialogDescription>
上传文档(Markdown / TXT / PDF / Word / PPT / Excel),自动提炼为知识库 wiki 词条并写入 vault。
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<label className="flex flex-col gap-1.5 text-sm">
选择文件
<Input
type="file"
accept=".md,.markdown,.txt,.pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
className="cursor-pointer"
/>
{file ? (
<span className="text-muted-foreground truncate text-xs">
{file.name} · {(file.size / 1024).toFixed(0)} KB
</span>
) : null}
</label>
<Input
placeholder="标题(可选,留空则由模型生成)"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Input
placeholder="标签,逗号分隔(可选)"
value={tags}
onChange={(e) => setTags(e.target.value)}
/>
<label className="flex items-center justify-between gap-2 text-sm">
提炼大模型
<ModelNameSelect
value={model}
onChange={setModel}
disabled={ingestFile.isPending}
triggerClassName="h-9 w-[11rem]"
/>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={() => void handleImport()} disabled={ingestFile.isPending || !file}>
{ingestFile.isPending ? "导入中..." : "导入并生成"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function DedupDialog({ function DedupDialog({
open, open,
onOpenChange, onOpenChange,
@@ -436,13 +457,92 @@ function VaultPageDialog({
); );
} }
function NoteDetail({ noteId }: { noteId: string }) { export default function KnowledgeBasePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { data: note, isLoading } = useKnowledgeNote(noteId); const { noteId } = useParams<{ noteId: string }>();
const { data: wikiLinks } = useNoteWikilinks(noteId);
const { update, remove } = useKnowledgeActions(); // --- list / tree state ---
const [keyword, setKeyword] = useState("");
const debouncedKeyword = useDebounced(keyword);
const [sourceType, setSourceType] = useState("all");
const [status, setStatus] = useState("all");
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const treeInitializedRef = useRef(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(readSidebarCollapsed);
// --- dialogs ---
const [createOpen, setCreateOpen] = useState(false);
const [batchOpen, setBatchOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [dedupOpen, setDedupOpen] = useState(false);
const [vaultPath, setVaultPath] = useState<string | null>(null); const [vaultPath, setVaultPath] = useState<string | null>(null);
const params = useMemo(
() => ({
keyword: debouncedKeyword || undefined,
source_type: sourceType === "all" ? undefined : sourceType,
status: status === "all" ? undefined : status,
limit: 500,
}),
[debouncedKeyword, sourceType, status],
);
const { data, isLoading } = useKnowledgeNotes(params);
const notes = data?.items ?? [];
const tree = useMemo(() => buildKnowledgeTree(notes), [notes]);
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
const { data: wikiLinks } = useNoteWikilinks(noteId);
// First load: expand the root-level folders so the tree reads as a directory.
useEffect(() => {
if (treeInitializedRef.current || tree.length === 0) return;
treeInitializedRef.current = true;
setExpandedIds(
new Set(tree.filter((n) => n.type === "folder").map((n) => n.id)),
);
}, [tree]);
// Keep the selected document's ancestor folders open (deep links included).
useEffect(() => {
if (!noteId || tree.length === 0) return;
const ancestors = findAncestorFolderIds(tree, noteId);
if (!ancestors || ancestors.length === 0) return;
setExpandedIds((prev) => {
if (ancestors.every((id) => prev.has(id))) return prev;
const next = new Set(prev);
for (const id of ancestors) next.add(id);
return next;
});
}, [noteId, tree]);
const handleToggleFolder = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleSelectNote = (item: KnowledgeNote) => {
setMobileSidebarOpen(false);
navigate(`/page/workspace/knowledge/${item.id}`);
};
const handleToggleSidebarCollapse = () => {
setSidebarCollapsed((prev) => {
const next = !prev;
try {
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, next ? "1" : "0");
} catch {
// localStorage unavailable (private mode) — state stays session-only.
}
return next;
});
};
const handleWikiLink = (link: WikiLinkTarget) => { const handleWikiLink = (link: WikiLinkTarget) => {
if (link.kind === "note" && link.note_id) { if (link.kind === "note" && link.note_id) {
navigate(`/page/workspace/knowledge/${link.note_id}`); navigate(`/page/workspace/knowledge/${link.note_id}`);
@@ -450,235 +550,81 @@ function NoteDetail({ noteId }: { noteId: string }) {
setVaultPath(link.vault_path); setVaultPath(link.vault_path);
} }
}; };
const [editing, setEditing] = useState(false);
const [draftTitle, setDraftTitle] = useState("");
const [draftTags, setDraftTags] = useState("");
const [draftContent, setDraftContent] = useState("");
const [draftStatus, setDraftStatus] = useState("approved");
useEffect(() => {
setEditing(false);
setVaultPath(null);
}, [noteId]);
useEffect(() => {
if (note) {
setDraftTitle(note.title);
setDraftTags(note.tags.join(", "));
setDraftContent(note.content_md);
setDraftStatus(note.status);
}
}, [note]);
if (isLoading || !note) {
return (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
{isLoading ? "加载中..." : "知识不存在"}
</div>
);
}
const handleSave = async () => {
try {
await update.mutateAsync({
noteId: note.id,
data: {
title: draftTitle.trim() || note.title,
content_md: draftContent,
tags: draftTags
.split(/[,,\s]+/)
.map((t) => t.trim())
.filter(Boolean),
status: draftStatus,
},
});
toast.success("已保存");
setEditing(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const handleArchive = async () => {
try {
await remove.mutateAsync({ noteId: note.id });
toast.success("已归档");
navigate("/page/workspace/knowledge");
} catch (error) {
toast.error(error instanceof Error ? error.message : "归档失败");
}
};
return ( return (
<div className="flex h-full flex-col"> <div className="flex size-full min-h-0 flex-col bg-slate-50 dark:bg-slate-950">
<div className="flex items-center justify-between gap-2 border-b p-3"> <WikiHeader
<div className="flex items-center gap-2"> onToggleSidebar={() => setMobileSidebarOpen((v) => !v)}
<Badge variant="outline"> onImportFile={() => setImportOpen(true)}
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type} onBatchImport={() => setBatchOpen(true)}
</Badge> onDedup={() => setDedupOpen(true)}
<Badge variant="secondary">{STATUS_LABELS[note.status] ?? note.status}</Badge> onGraph={() => navigate("/page/workspace/knowledge/graph")}
</div> onCreate={() => setCreateOpen(true)}
<div className="flex items-center gap-2">
{editing ? (
<>
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
<X className="size-4" /> 取消
</Button>
<Button size="sm" onClick={() => void handleSave()} disabled={update.isPending}>
<Save className="size-4" /> 保存
</Button>
</>
) : (
<>
<Button size="sm" variant="ghost" onClick={() => setEditing(true)}>
<Pencil className="size-4" /> 编辑
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => void handleArchive()}
disabled={remove.isPending}
>
<Trash2 className="size-4" /> 归档
</Button>
</>
)}
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="p-4">
{editing ? (
<div className="flex flex-col gap-3">
<Input
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
placeholder="标题"
/> />
<div className="flex items-center gap-2">
<Input
value={draftTags}
onChange={(e) => setDraftTags(e.target.value)}
placeholder="标签,逗号分隔"
/>
<Select value={draftStatus} onValueChange={setDraftStatus}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">草稿</SelectItem>
<SelectItem value="approved">已批准</SelectItem>
<SelectItem value="archived">已归档</SelectItem>
</SelectContent>
</Select>
</div>
<Textarea
rows={20}
value={draftContent}
onChange={(e) => setDraftContent(e.target.value)}
className="font-mono text-sm"
/>
</div>
) : (
<>
<h1 className="mb-3 text-xl font-semibold">{note.title}</h1>
{note.tags.length > 0 ? ( <div
<div className="mb-2 flex flex-wrap items-center gap-1.5">
{note.tags.map((tag) => (
<span
key={tag}
className="border-primary/20 bg-primary/5 text-primary/90 hover:bg-primary/10 inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors"
>
#{tag}
</span>
))}
</div>
) : null}
{note.entities && note.entities.length > 0 ? (
<div className="mb-4 flex flex-wrap items-center gap-1.5">
<span className="text-muted-foreground mr-0.5 inline-flex items-center gap-1 text-xs">
<Sparkles className="size-3" /> 实体
</span>
{note.entities.map((ent) => {
const meta = entityMeta(ent.entity_type);
return (
<span
key={`${ent.name}-${ent.entity_type ?? ""}`}
title={meta.label}
className={cn( className={cn(
"inline-flex items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium", "grid min-h-0 flex-1 grid-cols-1",
meta.className, sidebarCollapsed && !mobileSidebarOpen
? "md:grid-cols-[44px_minmax(0,1fr)]"
: "md:grid-cols-[288px_minmax(0,1fr)]",
)} )}
> >
<span className="size-1.5 shrink-0 rounded-full bg-current opacity-70" /> {mobileSidebarOpen ? (
<span>{ent.name}</span> <div
<span className="rounded-full bg-current/15 px-1.5 py-0.5 text-[10px] leading-none opacity-90"> className="fixed inset-0 z-40 bg-black/30 md:hidden"
{meta.label} onClick={() => setMobileSidebarOpen(false)}
</span> aria-hidden
</span> />
);
})}
</div>
) : null} ) : null}
<KnowledgeSidebar
keyword={keyword}
onKeywordChange={setKeyword}
sourceType={sourceType}
onSourceTypeChange={setSourceType}
status={status}
onStatusChange={setStatus}
tree={tree}
expandedIds={expandedIds}
onToggleFolder={handleToggleFolder}
selectedId={noteId}
onSelect={handleSelectNote}
onExportNote={downloadMarkdown}
total={data?.total ?? 0}
isLoading={isLoading}
collapsed={sidebarCollapsed && !mobileSidebarOpen}
onToggleCollapse={handleToggleSidebarCollapse}
className={cn(
"max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:z-50 max-md:w-[300px] max-md:shadow-xl max-md:transition-transform max-md:duration-200",
mobileSidebarOpen ? "max-md:translate-x-0" : "max-md:-translate-x-full",
)}
/>
<MarkdownRenderer {!noteId ? (
content={note.content_md} <EmptyDocumentState />
) : noteLoading || !note ? (
<div className="flex h-full items-center justify-center bg-white text-sm text-slate-400 dark:bg-slate-900">
{noteLoading ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="size-4 animate-spin" /> 加载中...
</span>
) : (
"知识不存在"
)}
</div>
) : (
<WikiContent
note={note}
wikiLinks={wikiLinks} wikiLinks={wikiLinks}
onWikiLinkClick={handleWikiLink} onWikiLinkClick={handleWikiLink}
/> />
{note.sources && note.sources.length > 0 ? (
<div className="mt-6 border-t pt-4">
<h2 className="mb-2 text-sm font-semibold">来源</h2>
<ul className="flex flex-col gap-2">
{note.sources.map((src) => (
<li key={src.id} className="text-sm">
{src.url ? (
<a
href={src.url}
target="_blank"
rel="noreferrer"
className="text-primary inline-flex items-center gap-1 hover:underline"
>
{src.title || src.url}
<ExternalLink className="size-3" />
</a>
) : src.thread_id ? (
<button
type="button"
className="text-primary hover:underline"
onClick={() =>
navigate(`/page/workspace/chats/${src.thread_id}`)
}
>
{src.title || `对话 ${src.thread_id.slice(0, 8)}`}
</button>
) : (
<span>{src.title || src.tool_name || "来源"}</span>
)}
{src.snippet ? (
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs">
{src.snippet}
</p>
) : null}
</li>
))}
</ul>
</div>
) : null}
<div className="text-muted-foreground mt-6 border-t pt-3 text-xs">
创建人 {actorLabel(note.created_by_name, note.created_by)} · 更新人{" "}
{actorLabel(note.updated_by_name, note.updated_by)} · 更新于{" "}
{note.updated_at?.slice(0, 19).replace("T", " ")}
</div>
</>
)} )}
</div> </div>
</ScrollArea>
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
<ImportFileDialog open={importOpen} onOpenChange={setImportOpen} />
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
<VaultPageDialog <VaultPageDialog
path={vaultPath} path={vaultPath}
onOpenChange={(open) => { onOpenChange={(open) => {
@@ -688,134 +634,3 @@ function NoteDetail({ noteId }: { noteId: string }) {
</div> </div>
); );
} }
export default function KnowledgeBasePage() {
const navigate = useNavigate();
const { noteId } = useParams<{ noteId: string }>();
const [keyword, setKeyword] = useState("");
const debouncedKeyword = useDebounced(keyword);
const [sourceType, setSourceType] = useState("all");
const [status, setStatus] = useState("all");
const [createOpen, setCreateOpen] = useState(false);
const [batchOpen, setBatchOpen] = useState(false);
const [dedupOpen, setDedupOpen] = useState(false);
const params = useMemo(
() => ({
keyword: debouncedKeyword || undefined,
source_type: sourceType === "all" ? undefined : sourceType,
status: status === "all" ? undefined : status,
limit: 50,
}),
[debouncedKeyword, sourceType, status],
);
const { data, isLoading } = useKnowledgeNotes(params);
const notes = data?.items ?? [];
return (
<div className="flex size-full min-h-0 flex-col">
<header className="flex flex-wrap items-center gap-2 border-b p-3">
<BookOpen className="size-5" />
<h1 className="mr-2 text-base font-semibold">知识库</h1>
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-1/2 size-4 -translate-y-1/2" />
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索标题 / 摘要 / 正文 / 标签"
className="w-64 pl-8"
/>
</div>
<Select value={sourceType} onValueChange={setSourceType}>
<SelectTrigger className="w-28">
<SelectValue placeholder="来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部来源</SelectItem>
{Object.entries(SOURCE_TYPE_LABELS).map(([v, label]) => (
<SelectItem key={v} value={v}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-28">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="draft">草稿</SelectItem>
<SelectItem value="approved">已批准</SelectItem>
<SelectItem value="archived">已归档</SelectItem>
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setBatchOpen(true)}>
<RefreshCw className="size-4" /> 批量导入
</Button>
<Button variant="outline" size="sm" onClick={() => setDedupOpen(true)}>
<Copy className="size-4" /> 查重
</Button>
<Button
variant="outline"
size="sm"
onClick={() => navigate("/page/workspace/knowledge/graph")}
>
<Network className="size-4" /> 图谱
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="size-4" /> 新建
</Button>
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[360px_minmax(0,1fr)]">
<div className="flex min-h-0 flex-col border-r">
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-2 p-3">
{isLoading ? (
<div className="text-muted-foreground flex items-center gap-2 p-4 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中...
</div>
) : notes.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
暂无知识。点击「新建」或在对话页「保存到知识库」。
</div>
) : (
notes.map((note) => (
<NoteListItem
key={note.id}
note={note}
active={note.id === noteId}
onClick={() => navigate(`/page/workspace/knowledge/${note.id}`)}
/>
))
)}
</div>
</ScrollArea>
{data ? (
<div className="text-muted-foreground border-t p-2 text-center text-xs">
共 {data.total} 条知识
</div>
) : null}
</div>
<div className="min-h-0">
{noteId ? (
<NoteDetail noteId={noteId} />
) : (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
从左侧选择一条知识查看详情
</div>
)}
</div>
</div>
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
</div>
);
}
+848 -64
View File
@@ -1,79 +1,863 @@
import { ArrowLeft, Loader2, RefreshCw } from "lucide-react"; import { Loader2, Network, RotateCcw, Tags, Undo2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import {
import { knowledgeExportFileUrl } from "@/core/knowledge/api"; GraphCanvas,
import { useKnowledgeActions } from "@/core/knowledge/hooks"; type GraphCanvasHandle,
} from "@/components/knowledge-graph/GraphCanvas";
import { GraphDetailPanel } from "@/components/knowledge-graph/GraphDetailPanel";
import { GraphFooter } from "@/components/knowledge-graph/GraphFooter";
import { GraphHeader } from "@/components/knowledge-graph/GraphHeader";
import { GraphSidebar } from "@/components/knowledge-graph/GraphSidebar";
import { GraphTooltip } from "@/components/knowledge-graph/GraphTooltip";
import type {
ExploreStep,
GraphMode,
LayoutType,
NodeKind,
TooltipState,
} from "@/components/knowledge-graph/graphTypes";
import {
computeDegrees,
computeGraphStats,
getEdgeId,
getEdgeLabel,
getNodeKind,
getNodeTitle,
} from "@/components/knowledge-graph/graphUtils";
import "@/components/knowledge-graph/graphStyles.css";
import { useTheme } from "@/contexts/ThemeContext";
import {
useGraphBlacklist,
useGraphBlacklistActions,
useKnowledgeGraph,
useKnowledgeNote,
} from "@/core/knowledge/hooks";
/** 全量图谱默认只渲染度数最高的核心节点,超出可手动切换显示全部。 */
const FULL_GRAPH_NODE_LIMIT = 300;
/** 常见的低价值实体名(抽取噪声),用于"一键清理"候选。 */
const GENERIC_ENTITY_LABELS = new Set([
"用户",
"助手",
"系统",
"问题",
"回答",
"内容",
"信息",
"数据",
"功能",
"方法",
"结果",
"示例",
"例子",
"总结",
"文档",
"文件",
"其他",
"未知",
"对话",
"聊天",
"测试",
"工具",
"平台",
"项目",
"任务",
"报告",
"分析",
"none",
"null",
"unknown",
"test",
"todo",
]);
/** 单字符、纯数字/符号(日期等)或通用泛词视为低价值主题。 */
function isLowValueLabel(raw: string): boolean {
const label = raw.trim();
if (label.length <= 1) return true;
if (/^[\d\s\p{P}\p{S}]+$/u.test(label)) return true;
return GENERIC_ENTITY_LABELS.has(label.toLowerCase());
}
export default function KnowledgeGraphPage() { export default function KnowledgeGraphPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { exportGraph } = useKnowledgeActions(); const { theme } = useTheme();
const [src, setSrc] = useState<string | null>(null); const pageRef = useRef<HTMLDivElement>(null);
const [stats, setStats] = useState<{ nodes: number; edges: number } | null>(null); const canvasRef = useRef<GraphCanvasHandle>(null);
const regenerate = useCallback(async () => { const { data, isLoading, isError, error, refetch, isFetching } =
try { useKnowledgeGraph();
const result = await exportGraph.mutateAsync(); const { data: blacklistItems = [] } = useGraphBlacklist();
setStats(result.stats); const blacklistActions = useGraphBlacklistActions();
// Cache-bust the iframe so it reflects the freshly generated graph.
setSrc(`${knowledgeExportFileUrl("graph.html")}?t=${Date.now()}`); // ------------------------------------------------------------------ state
} catch (error) { const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
toast.error(error instanceof Error ? error.message : "生成图谱失败"); const [searchKeyword, setSearchKeyword] = useState("");
} const [disabledNodeKinds, setDisabledNodeKinds] = useState<Set<NodeKind>>(
// eslint-disable-next-line react-hooks/exhaustive-deps new Set(),
);
const [disabledEdgeLabels, setDisabledEdgeLabels] = useState<Set<string>>(
new Set(),
);
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<string>>(new Set());
const [showAllNodes, setShowAllNodes] = useState(false);
const [layoutType, setLayoutType] = useState<LayoutType>("force");
const [showLabels, setShowLabels] = useState(true);
const [showSidebar, setShowSidebar] = useState(true);
const [showDetail, setShowDetail] = useState(false);
const [zoomLevel, setZoomLevel] = useState(1);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const [graphMode, setGraphMode] = useState<GraphMode>("full");
const [exploreRootId, setExploreRootId] = useState<string | null>(null);
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<string>>(new Set());
const [visibleNodeIds, setVisibleNodeIds] = useState<Set<string>>(new Set());
const [visibleEdgeIds, setVisibleEdgeIds] = useState<Set<string>>(new Set());
const [nodeDepthMap, setNodeDepthMap] = useState<Map<string, number>>(new Map());
const [exploreHistory, setExploreHistory] = useState<ExploreStep[]>([]);
// Auto-collapse the side panels on narrow viewports.
useEffect(() => {
const detailQuery = window.matchMedia("(max-width: 1280px)");
const sidebarQuery = window.matchMedia("(max-width: 900px)");
const apply = () => {
if (detailQuery.matches) setShowDetail(false);
setShowSidebar(!sidebarQuery.matches);
};
apply();
detailQuery.addEventListener("change", apply);
sidebarQuery.addEventListener("change", apply);
return () => {
detailQuery.removeEventListener("change", apply);
sidebarQuery.removeEventListener("change", apply);
};
}, []); }, []);
useEffect(() => { // ---------------------------------------------------------------- derived
void regenerate(); const allNodes = useMemo(() => data?.nodes ?? [], [data]);
}, [regenerate]); const allEdges = useMemo(() => data?.edges ?? [], [data]);
return ( const nodeById = useMemo(
<div className="flex size-full min-h-0 flex-col"> () => new Map(allNodes.map((n) => [n.id, n])),
<header className="flex items-center gap-2 border-b p-3"> [allNodes],
<Button );
variant="ghost" const degrees = useMemo(
size="sm" () => computeDegrees(allNodes, allEdges),
onClick={() => navigate("/page/workspace/knowledge")} [allNodes, allEdges],
> );
<ArrowLeft className="size-4" /> 返回 const stats = useMemo(
</Button> () => computeGraphStats(allNodes, allEdges),
<h1 className="text-base font-semibold">知识图谱</h1> [allNodes, allEdges],
{stats ? ( );
<span className="text-muted-foreground text-xs">
{stats.nodes} 个节点 · {stats.edges} 条关系 const nodeKindCounts = useMemo(() => {
</span> const counts = new Map<NodeKind, number>();
) : null} for (const n of allNodes) {
<Button const kind = getNodeKind(n);
variant="outline" counts.set(kind, (counts.get(kind) ?? 0) + 1);
size="sm" }
className="ml-auto" return counts;
onClick={() => void regenerate()} }, [allNodes]);
disabled={exportGraph.isPending}
> const edgeLabelCounts = useMemo(() => {
{exportGraph.isPending ? ( const counts = new Map<string, number>();
<Loader2 className="size-4 animate-spin" /> for (const e of allEdges) {
) : ( const label = getEdgeLabel(e);
<RefreshCw className="size-4" /> counts.set(label, (counts.get(label) ?? 0) + 1);
)} }
重新生成 return counts;
</Button> }, [allEdges]);
</header>
<div className="min-h-0 flex-1"> const filteredNodes = useMemo(
{src ? ( () =>
<iframe allNodes.filter(
src={src} (n) => !disabledNodeKinds.has(getNodeKind(n)) && !hiddenNodeIds.has(n.id),
title="知识图谱" ),
className="size-full border-0" [allNodes, disabledNodeKinds, hiddenNodeIds],
sandbox="allow-scripts allow-same-origin" );
/>
) : ( const filteredEdges = useMemo(() => {
<div className="text-muted-foreground flex h-full items-center justify-center text-sm"> const visible = new Set(filteredNodes.map((n) => n.id));
<Loader2 className="mr-2 size-4 animate-spin" /> 生成图谱中... return allEdges.filter(
(e) =>
visible.has(e.source) &&
visible.has(e.target) &&
!disabledEdgeLabels.has(getEdgeLabel(e)),
);
}, [allEdges, filteredNodes, disabledEdgeLabels]);
// 节点过多时全量模式默认只渲染度数最高的核心节点,避免布局/渲染卡顿。
const fullGraphTruncated =
!showAllNodes && filteredNodes.length > FULL_GRAPH_NODE_LIMIT;
const limitedNodes = useMemo(() => {
if (!fullGraphTruncated) return filteredNodes;
return [...filteredNodes]
.sort((a, b) => (degrees.get(b.id) ?? 0) - (degrees.get(a.id) ?? 0))
.slice(0, FULL_GRAPH_NODE_LIMIT);
}, [filteredNodes, fullGraphTruncated, degrees]);
const limitedEdges = useMemo(() => {
if (!fullGraphTruncated) return filteredEdges;
const visible = new Set(limitedNodes.map((n) => n.id));
return filteredEdges.filter(
(e) => visible.has(e.source) && visible.has(e.target),
);
}, [filteredEdges, fullGraphTruncated, limitedNodes]);
const resetExplore = useCallback(() => {
setExploreRootId(null);
setExpandedNodeIds(new Set());
setVisibleNodeIds(new Set());
setVisibleEdgeIds(new Set());
setNodeDepthMap(new Map());
setExploreHistory([]);
}, []);
const rebuildExploreState = useCallback((history: ExploreStep[]) => {
const nextVisibleNodeIds = new Set<string>();
const nextVisibleEdgeIds = new Set<string>();
const nextNodeDepthMap = new Map<string, number>();
const nextExpandedNodeIds = new Set<string>();
for (const step of history) {
for (const nodeId of step.addedNodeIds) {
nextVisibleNodeIds.add(nodeId);
if (!nextNodeDepthMap.has(nodeId)) nextNodeDepthMap.set(nodeId, step.depth);
}
for (const edgeId of step.addedEdgeIds) nextVisibleEdgeIds.add(edgeId);
if (step.depth > 0) nextExpandedNodeIds.add(step.nodeId);
}
setVisibleNodeIds(nextVisibleNodeIds);
setVisibleEdgeIds(nextVisibleEdgeIds);
setNodeDepthMap(nextNodeDepthMap);
setExpandedNodeIds(nextExpandedNodeIds);
}, []);
const startExploreFromNode = useCallback((nodeId: string) => {
setExploreRootId(nodeId);
setVisibleNodeIds(new Set([nodeId]));
setVisibleEdgeIds(new Set());
setExpandedNodeIds(new Set());
setNodeDepthMap(new Map([[nodeId, 0]]));
setExploreHistory([
{
nodeId,
depth: 0,
addedNodeIds: [nodeId],
addedEdgeIds: [],
timestamp: Date.now(),
},
]);
window.setTimeout(() => canvasRef.current?.focusNode(nodeId), 0);
}, []);
const expandOneHop = useCallback(
(nodeId: string) => {
const currentDepth = nodeDepthMap.get(nodeId) ?? 0;
const nextDepth = currentDepth + 1;
const addedNodeIds: string[] = [];
const addedEdgeIds: string[] = [];
const nextVisibleNodeIds = new Set(visibleNodeIds);
const nextVisibleEdgeIds = new Set(visibleEdgeIds);
const nextNodeDepthMap = new Map(nodeDepthMap);
for (const edge of filteredEdges) {
if (edge.source !== nodeId && edge.target !== nodeId) continue;
const edgeId = getEdgeId(edge);
const neighborId = edge.source === nodeId ? edge.target : edge.source;
if (!nodeById.has(neighborId)) continue;
if (!nextVisibleEdgeIds.has(edgeId)) {
nextVisibleEdgeIds.add(edgeId);
addedEdgeIds.push(edgeId);
}
if (!nextVisibleNodeIds.has(neighborId)) {
nextVisibleNodeIds.add(neighborId);
nextNodeDepthMap.set(neighborId, nextDepth);
addedNodeIds.push(neighborId);
}
}
nextVisibleNodeIds.add(nodeId);
nextNodeDepthMap.set(nodeId, currentDepth);
setVisibleNodeIds(nextVisibleNodeIds);
setVisibleEdgeIds(nextVisibleEdgeIds);
setNodeDepthMap(nextNodeDepthMap);
setExpandedNodeIds((prev) => new Set(prev).add(nodeId));
setExploreHistory((prev) => [
...prev,
{ nodeId, depth: nextDepth, addedNodeIds, addedEdgeIds, timestamp: Date.now() },
]);
},
[filteredEdges, nodeById, nodeDepthMap, visibleEdgeIds, visibleNodeIds],
);
const undoExploreStep = useCallback(() => {
if (exploreHistory.length <= 1) {
resetExplore();
return;
}
const nextHistory = exploreHistory.slice(0, -1);
setExploreHistory(nextHistory);
rebuildExploreState(nextHistory);
}, [exploreHistory, rebuildExploreState, resetExplore]);
useEffect(() => {
if (graphMode === "explore") resetExplore();
}, [
disabledNodeKinds,
disabledEdgeLabels,
hiddenNodeIds,
searchKeyword,
resetExplore,
]);
const displayedGraph = useMemo(() => {
if (graphMode === "full") return { nodes: limitedNodes, edges: limitedEdges };
if (!exploreRootId) return { nodes: limitedNodes, edges: [] };
return {
nodes: allNodes.filter((node) => visibleNodeIds.has(node.id)),
edges: allEdges.filter((edge) => visibleEdgeIds.has(getEdgeId(edge))),
};
}, [
allEdges,
allNodes,
exploreRootId,
limitedEdges,
limitedNodes,
graphMode,
visibleEdgeIds,
visibleNodeIds,
]);
const searchMatchIds = useMemo(() => {
const keyword = searchKeyword.trim().toLowerCase();
if (!keyword) return null;
const matches = new Set<string>();
for (const n of filteredNodes) {
if (getNodeTitle(n).toLowerCase().includes(keyword)) matches.add(n.id);
}
return matches;
}, [searchKeyword, filteredNodes]);
const topNodes = useMemo(() => {
// 搜索时核心节点榜单只保留命中的节点,与画布高亮保持一致。
const pool = searchMatchIds
? filteredNodes.filter((node) => searchMatchIds.has(node.id))
: allNodes;
return pool
.map((node) => ({ node, degree: degrees.get(node.id) ?? 0 }))
.sort((a, b) => b.degree - a.degree)
.slice(0, 8);
}, [allNodes, filteredNodes, searchMatchIds, degrees]);
const selectedNode = selectedNodeId ? (nodeById.get(selectedNodeId) ?? null) : null;
const selectedNeighbors = useMemo(() => {
if (!selectedNodeId) return [];
const items: Array<{ node: (typeof allNodes)[number]; relation: string }> = [];
const seen = new Set<string>();
for (const e of displayedGraph.edges) {
const otherId =
e.source === selectedNodeId
? e.target
: e.target === selectedNodeId
? e.source
: null;
if (!otherId || seen.has(otherId)) continue;
const other = nodeById.get(otherId);
if (!other) continue;
seen.add(otherId);
items.push({ node: other, relation: getEdgeLabel(e) });
}
return items.sort(
(a, b) => (degrees.get(b.node.id) ?? 0) - (degrees.get(a.node.id) ?? 0),
);
}, [selectedNodeId, displayedGraph.edges, nodeById, degrees, allNodes]);
// Note detail for note-type nodes (summary / tags / timestamps / status).
const noteId =
selectedNode && (selectedNode.type || "").toLowerCase() === "note"
? selectedNode.id
: undefined;
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
// --------------------------------------------------------------- handlers
const handleSelectNode = useCallback(
(id: string | null) => {
if (!id) {
setSelectedNodeId(null);
setShowDetail(false);
return;
}
setSelectedNodeId(id);
setShowDetail(true);
if (graphMode !== "explore") return;
if (!exploreRootId) {
startExploreFromNode(id);
return;
}
if (!visibleNodeIds.has(id)) return;
if (!expandedNodeIds.has(id)) expandOneHop(id);
},
[
expandedNodeIds,
expandOneHop,
exploreRootId,
graphMode,
startExploreFromNode,
visibleNodeIds,
],
);
const handlePickNode = useCallback(
(id: string) => {
setSelectedNodeId(id);
setShowDetail(true);
if (graphMode === "explore" && !exploreRootId) startExploreFromNode(id);
canvasRef.current?.focusNode(id);
},
[exploreRootId, graphMode, startExploreFromNode],
);
const handleSearchEnter = useCallback(() => {
if (!searchMatchIds || searchMatchIds.size === 0) return;
const first = searchMatchIds.values().next().value;
if (first) handlePickNode(first);
}, [searchMatchIds, handlePickNode]);
const handleToggleNodeKind = useCallback((kind: NodeKind) => {
setDisabledNodeKinds((prev) => {
const next = new Set(prev);
if (next.has(kind)) next.delete(kind);
else next.add(kind);
return next;
});
}, []);
const handleToggleEdgeLabel = useCallback((label: string) => {
setDisabledEdgeLabels((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
}, []);
const handleHideNode = useCallback(
(id: string) => {
setHiddenNodeIds((prev) => new Set(prev).add(id));
setSelectedNodeId((current) => (current === id ? null : current));
if (selectedNodeId === id) setShowDetail(false);
},
[selectedNodeId],
);
const handleRestoreHidden = useCallback(() => {
setHiddenNodeIds(new Set());
}, []);
// ------------------------------------------------------------- blacklist
const addToBlacklist = blacklistActions.add;
const removeFromBlacklist = blacklistActions.remove;
const handleBlacklistNode = useCallback(
(id: string) => {
const node = nodeById.get(id);
if (!node) return;
const label = getNodeTitle(node);
addToBlacklist.mutate([label], {
onSuccess: () => toast.success(`已将「${label}」加入黑名单`),
onError: (e) =>
toast.error(e instanceof Error ? e.message : "加入黑名单失败"),
});
setSelectedNodeId((current) => (current === id ? null : current));
if (selectedNodeId === id) setShowDetail(false);
},
[nodeById, addToBlacklist, selectedNodeId],
);
const handleRemoveBlacklist = useCallback(
(entryId: string) => {
removeFromBlacklist.mutate(entryId, {
onError: (e) =>
toast.error(e instanceof Error ? e.message : "移除黑名单失败"),
});
},
[removeFromBlacklist],
);
// 低价值主题候选:实体节点中的单字符 / 纯数字符号 / 通用泛词。
const junkCandidates = useMemo(() => {
const blocked = new Set(
blacklistItems.map((item) => item.label.trim().toLowerCase()),
);
const seen = new Set<string>();
const out: string[] = [];
for (const n of allNodes) {
if ((n.type || "").toLowerCase() !== "entity") continue;
const label = getNodeTitle(n).trim();
const key = label.toLowerCase();
if (!label || seen.has(key) || blocked.has(key)) continue;
if (!isLowValueLabel(label)) continue;
seen.add(key);
out.push(label);
}
return out;
}, [allNodes, blacklistItems]);
const handleCleanJunk = useCallback(() => {
if (junkCandidates.length === 0) {
toast.info("没有发现明显的低价值主题");
return;
}
const preview = junkCandidates.slice(0, 10).join("、");
const confirmed = window.confirm(
`将 ${junkCandidates.length} 个低价值主题加入黑名单?\n例如:${preview}${junkCandidates.length > 10 ? " 等" : ""}`,
);
if (!confirmed) return;
addToBlacklist.mutate(junkCandidates, {
onSuccess: (res) => toast.success(`已清理 ${res.added} 个低价值主题`),
onError: (e) =>
toast.error(e instanceof Error ? e.message : "清理失败"),
});
}, [junkCandidates, addToBlacklist]);
const handleRefresh = useCallback(() => {
void refetch().then((result) => {
if (result.isError) {
toast.error(
result.error instanceof Error ? result.error.message : "刷新图谱失败",
);
}
});
}, [refetch]);
const handleFullscreen = useCallback(() => {
const el = pageRef.current;
if (!el) return;
if (document.fullscreenElement) void document.exitFullscreen();
else void el.requestFullscreen();
}, []);
const handleViewSource = useCallback(
(id: string) => {
navigate(`/page/workspace/knowledge/${encodeURIComponent(id)}`);
},
[navigate],
);
const handleSetExploreRoot = useCallback(
(id: string) => {
setGraphMode("explore");
startExploreFromNode(id);
setSelectedNodeId(id);
setShowDetail(true);
},
[startExploreFromNode],
);
const exploreDepth = useMemo(() => {
let max = 0;
for (const depth of nodeDepthMap.values()) max = Math.max(max, depth);
return max;
}, [nodeDepthMap]);
const exploreRootTitle = exploreRootId
? getNodeTitle(nodeById.get(exploreRootId) ?? { id: exploreRootId })
: null;
const selectedExploreInfo =
graphMode === "explore" && selectedNodeId
? {
depth: nodeDepthMap.get(selectedNodeId) ?? null,
expanded: expandedNodeIds.has(selectedNodeId),
isRoot: selectedNodeId === exploreRootId,
}
: null;
// ---------------------------------------------------------------- overlay
let overlay: React.ReactNode = null;
if (isLoading) {
overlay = (
<div className="kg-canvas-status">
<Loader2 size={26} className="animate-spin" />
<div className="kg-canvas-status-title">正在加载知识图谱...</div>
</div> </div>
)} );
} else if (isError) {
overlay = (
<div className="kg-canvas-status">
<Network size={30} />
<div className="kg-canvas-status-title">图谱加载失败</div>
<div className="kg-canvas-status-desc">
{error instanceof Error ? error.message : "请求失败,请稍后重试。"}
</div>
<button type="button" className="kg-icon-button" onClick={handleRefresh}>
重试
</button>
</div>
);
} else if (allNodes.length === 0) {
overlay = (
<div className="kg-canvas-status">
<Network size={30} />
<div className="kg-canvas-status-title">暂无知识图谱数据</div>
<div className="kg-canvas-status-desc">
请先导入知识文档,或从知识库中抽取实体、标签与关系后再查看图谱。
</div> </div>
</div> </div>
); );
} else if (filteredNodes.length === 0) {
overlay = (
<div className="kg-canvas-status">
<Network size={30} />
<div className="kg-canvas-status-title">当前筛选没有可显示的节点</div>
<div className="kg-canvas-status-desc">
请在左侧勾选更多节点类型,或恢复已隐藏的节点。
</div>
</div>
);
}
const toolbar = (
<>
<div className="kg-floating-pill">
<span className="kg-pill-label">模式</span>
<button
type="button"
className={`kg-pill-button${graphMode === "explore" ? " kg-pill-button-active" : ""}`}
onClick={() => setGraphMode("explore")}
>
探索模式
</button>
<button
type="button"
className={`kg-pill-button${graphMode === "full" ? " kg-pill-button-active" : ""}`}
onClick={() => setGraphMode("full")}
>
全量图谱
</button>
</div>
{graphMode === "explore" ? (
<>
<div className="kg-floating-pill">
起点:{exploreRootTitle ? exploreRootTitle : "未选择"}
</div>
<div className="kg-floating-pill">
深度 {exploreDepth} · 已显示 {displayedGraph.nodes.length} 节点 /{" "}
{displayedGraph.edges.length} 关系
</div>
<button
type="button"
className="kg-explore-button"
disabled={exploreHistory.length === 0}
onClick={undoExploreStep}
>
<Undo2 size={13} /> 返回上一层
</button>
<button
type="button"
className="kg-explore-button"
disabled={!exploreRootId}
onClick={resetExplore}
>
<RotateCcw size={13} /> 重置探索
</button>
</>
) : null}
{graphMode === "full" && filteredNodes.length > FULL_GRAPH_NODE_LIMIT ? (
<div className="kg-floating-pill">
{fullGraphTruncated
? `节点较多,已显示核心 ${FULL_GRAPH_NODE_LIMIT} / ${filteredNodes.length}`
: `已显示全部 ${filteredNodes.length} 个节点`}
<button
type="button"
className="kg-pill-button"
title={
fullGraphTruncated
? "渲染全部节点(节点很多时可能卡顿)"
: "只渲染度数最高的核心节点"
}
onClick={() => setShowAllNodes((v) => !v)}
>
{fullGraphTruncated ? "显示全部" : "仅核心节点"}
</button>
</div>
) : null}
<div className="kg-floating-pill">
<span className="kg-pill-label">布局</span>
<button
type="button"
className={`kg-pill-button${layoutType === "force" ? " kg-pill-button-active" : ""}`}
onClick={() => setLayoutType("force")}
>
力导向
</button>
<button
type="button"
className={`kg-pill-button${layoutType === "circular" ? " kg-pill-button-active" : ""}`}
onClick={() => setLayoutType("circular")}
>
环形
</button>
</div>
<div className="kg-floating-pill">
<button
type="button"
className={`kg-pill-button${showLabels ? " kg-pill-button-active" : ""}`}
onClick={() => setShowLabels((v) => !v)}
>
<Tags size={13} /> 标签 {showLabels ? "开" : "关"}
</button>
</div>
</>
);
const hint =
graphMode === "explore" ? (
<div className="kg-canvas-hint">
{!exploreRootId
? "点击任意节点作为起点,开始逐层探索知识关系。"
: "点击已显示节点,可继续展开它的一跳邻居。已展开的节点和路径会保留。"}
</div>
) : null;
return (
<div className="kg-page" ref={pageRef}>
<GraphHeader
nodeCount={displayedGraph.nodes.length}
edgeCount={displayedGraph.edges.length}
totalNodes={allNodes.length}
totalEdges={allEdges.length}
refreshing={isFetching}
onBack={() => navigate("/page/workspace/knowledge")}
onRelayout={() => canvasRef.current?.relayout()}
onFitView={() => canvasRef.current?.fitView()}
onZoomIn={() => canvasRef.current?.zoomIn()}
onZoomOut={() => canvasRef.current?.zoomOut()}
onExport={() => canvasRef.current?.exportImage()}
onFullscreen={handleFullscreen}
onRefresh={handleRefresh}
/>
<div
className={[
"kg-body",
showSidebar ? "" : "kg-hide-left",
showDetail ? "" : "kg-hide-right",
]
.filter(Boolean)
.join(" ")}
>
{showSidebar ? (
<GraphSidebar
searchKeyword={searchKeyword}
searchMatchCount={searchMatchIds ? searchMatchIds.size : null}
nodeKindCounts={nodeKindCounts}
edgeLabelCounts={edgeLabelCounts}
disabledNodeKinds={disabledNodeKinds}
disabledEdgeLabels={disabledEdgeLabels}
stats={stats}
topNodes={topNodes}
hiddenCount={hiddenNodeIds.size}
blacklistItems={blacklistItems}
blacklistPending={addToBlacklist.isPending || removeFromBlacklist.isPending}
junkCandidateCount={junkCandidates.length}
onSearchChange={setSearchKeyword}
onSearchEnter={handleSearchEnter}
onToggleNodeKind={handleToggleNodeKind}
onToggleEdgeLabel={handleToggleEdgeLabel}
onRestoreHidden={handleRestoreHidden}
onRemoveBlacklist={handleRemoveBlacklist}
onCleanJunk={handleCleanJunk}
onPickNode={handlePickNode}
onClose={() => setShowSidebar(false)}
/>
) : null}
<div className="kg-canvas-shell">
{!showSidebar ? (
<button
type="button"
className="kg-side-rail-toggle kg-side-rail-toggle-left"
onClick={() => setShowSidebar(true)}
>
筛选
</button>
) : null}
<GraphCanvas
ref={canvasRef}
nodes={displayedGraph.nodes}
edges={displayedGraph.edges}
degrees={degrees}
selectedNodeId={selectedNodeId}
searchMatchIds={searchMatchIds}
layoutType={layoutType}
showLabels={showLabels}
theme={theme}
onSelectNode={handleSelectNode}
onTooltip={setTooltip}
onZoomChange={setZoomLevel}
explore={
graphMode === "explore"
? {
preview: !exploreRootId,
rootId: exploreRootId,
nodeDepthMap,
expandedNodeIds,
}
: null
}
toolbar={toolbar}
hint={hint}
overlay={overlay}
/>
{!showDetail ? (
<button
type="button"
className="kg-side-rail-toggle kg-side-rail-toggle-right"
onClick={() => setShowDetail(true)}
>
详情
</button>
) : null}
</div>
{showDetail ? (
<GraphDetailPanel
node={selectedNode}
degree={selectedNodeId ? (degrees.get(selectedNodeId) ?? 0) : 0}
neighbors={selectedNeighbors}
note={noteId ? note : undefined}
noteLoading={Boolean(noteId) && noteLoading}
explore={selectedExploreInfo}
onViewSource={handleViewSource}
onLocate={(id) => canvasRef.current?.focusNode(id)}
onHide={handleHideNode}
onBlacklist={handleBlacklistNode}
onPickNeighbor={handlePickNode}
onExpandNode={expandOneHop}
onSetExploreRoot={handleSetExploreRoot}
onClose={() => setShowDetail(false)}
/>
) : null}
</div>
<GraphFooter
nodeKindCounts={nodeKindCounts}
edgeLabels={[...edgeLabelCounts.keys()]}
zoomLevel={zoomLevel}
selectedTitle={selectedNode ? getNodeTitle(selectedNode) : null}
visibleNodes={displayedGraph.nodes.length}
visibleEdges={displayedGraph.edges.length}
/>
<GraphTooltip tooltip={tooltip} />
</div>
);
} }
@@ -122,7 +122,7 @@ export default function LightAppNoteListPage() {
> >
<Plus className="h-5 w-5" /> <Plus className="h-5 w-5" />
</span> </span>
<span className="text-base font-medium">新建笔记本</span> <span className="text-base font-medium">新建空间</span>
</button> </button>
); );
} }
@@ -146,7 +146,7 @@ export default function LightAppNoteListPage() {
> >
<Plus className="h-5 w-5" /> <Plus className="h-5 w-5" />
</span> </span>
<span className="text-base font-medium">新建笔记本</span> <span className="text-base font-medium">新建空间</span>
</button> </button>
); );
}; };
@@ -230,7 +230,7 @@ export default function LightAppNoteListPage() {
isDark ? "text-slate-100" : "text-slate-950", isDark ? "text-slate-100" : "text-slate-950",
)} )}
> >
我的笔记本 我的空间
</h1> </h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff. Show more