Merge branch 'ji' into dhk-1
Resolve column_additions_mysql.sql conflict: keep square_id columns from master and roundtable_drafts.task_id from dhk-1. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
bcc3d617a0
142 files changed
+14953
-2383
No files matched your search
@@ -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)
|
||||
|
||||
**断点续跑**:已完成的人自动跳过;中途往名单里加人,下个执行窗会带上。
|
||||
@@ -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()
|
||||
@@ -0,0 +1,4 @@
|
||||
姓名,领域(可选提示),资料链接或备注(可选)
|
||||
查理·芒格,投资/多元思维,
|
||||
理查德·费曼,物理/教学,
|
||||
纳瓦尔·拉维坎特,创业/杠杆,https://nav.al/
|
||||
|
@@ -27,15 +27,16 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
|
||||
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
|
||||
VITE_AIPI_BACKENP_URL=http://localhost:8003
|
||||
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
# 工作室/空间前端地址(用于 auth/callback 等跳转)
|
||||
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
|
||||
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
|
||||
VITE_STUDIO_ENABLED=true
|
||||
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
|
||||
VITE_AIPI_FRONTEND_URL=http://localhost:3000
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
|
||||
VITE_AIPI_PRESENT_URL=https://present.a.uat.4.cn/present/domain
|
||||
# B 系统知识库 iframe 根地址
|
||||
VITE_AIPI_KNOWES_URL=https://ch1.b.uat.4.cn/knowes
|
||||
|
||||
# MaxKey 鉴权(getTokenByOnlineTicket)
|
||||
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
|
||||
@@ -15,15 +15,17 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
|
||||
# 工作室/空间前端地址(用于 auth/callback 等跳转)
|
||||
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
|
||||
VITE_AIPI_PRESENT_URL=http://present.a.uat.4.cn/present/domain
|
||||
# B 系统知识库 iframe 根地址
|
||||
VITE_AIPI_KNOWES_URL=http://ch1.b.uat.4.cn/knowes
|
||||
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
|
||||
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
|
||||
|
||||
# VITE_NOTE_API_BASE_URL=http://127.0.0.1:8000
|
||||
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
|
||||
VITE_STUDIO_ENABLED=true
|
||||
@@ -23,7 +23,6 @@
|
||||
# VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
||||
# VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
# VITE_STUDIO_ENABLED=true # 仅控制“工作室”入口显示,列表内容仍依赖 notebook 接口数据
|
||||
|
||||
# --- 生产打包(见 .env.production)---
|
||||
# 部署前修改 .env.production 中的地址,再执行 pnpm build
|
||||
|
||||
@@ -28,14 +28,16 @@ VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
|
||||
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
|
||||
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
# 工作室/空间前端地址(用于 auth/callback 等跳转)
|
||||
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
|
||||
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
|
||||
VITE_STUDIO_ENABLED=true
|
||||
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
|
||||
VITE_AIPI_PRESENT_URL=http://present.a.uat.4.cn/present/domain
|
||||
# B 系统知识库 iframe 根地址
|
||||
VITE_AIPI_KNOWES_URL=http://ch1.b.uat.4.cn/knowes
|
||||
|
||||
# MaxKey 鉴权(getTokenByOnlineTicket)
|
||||
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
|
||||
@@ -24,18 +24,21 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
VITE_NOTEBOOK_API_BASE_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
|
||||
VITE_STUDIO_ENABLED=true
|
||||
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
|
||||
VITE_AIPI_FRONTEND_URL=https://knowledgetoolweb.b.uat.4.cn
|
||||
# 我的空间前端
|
||||
VITE_AIPI_FRONTEND_URL= xxx
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
|
||||
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
|
||||
VITE_AIPI_PRESENT_URL=https://present.a.uat.4.cn/present/domain
|
||||
# B 系统知识库 iframe 根地址
|
||||
VITE_AIPI_KNOWES_URL=https://ch1.b.uat.4.cn/knowes
|
||||
# MaxKey 鉴权(getTokenByOnlineTicket)
|
||||
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
|
||||
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
|
||||
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
|
||||
# 工作室/空间前端地址(用于 auth/callback 等跳转)
|
||||
VITE_AIPI_FRONTEND_URL=https://knowledgetoolweb.b.uat.4.cn
|
||||
|
||||
# 可选:Canvas 独立 LangGraph 地址
|
||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||
@@ -5,4 +5,3 @@
|
||||
# VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
|
||||
# VITE_AIPI_BACKENP_URL=https://your-notebook-backend-host
|
||||
# VITE_AIPI_FRONTEND_URL=https://your-notebook-frontend-host
|
||||
# VITE_STUDIO_ENABLED=true
|
||||
@@ -24,7 +24,7 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.sit.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.sit.4.cn/likun_b1_mope
|
||||
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.sit.4.cn
|
||||
VITE_NOTEBOOK_API_BASE_URL=https://knowledgetoolserver.b.sit.4.cn
|
||||
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
|
||||
@@ -32,10 +32,16 @@ VITE_STUDIO_ENABLED=true
|
||||
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
|
||||
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.sit.4.cn/consumer
|
||||
|
||||
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
|
||||
VITE_AIPI_PRESENT_URL=http://present.a.sit.4.cn/present/domain
|
||||
# B 系统知识库 iframe 根地址
|
||||
VITE_AIPI_KNOWES_URL=https://ch1.b.sit.4.cn/knowes
|
||||
# MaxKey 鉴权(getTokenByOnlineTicket)
|
||||
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
VITE_AIPI_PGLINKTO_URL=https://pg.b.sit.4.cn
|
||||
VITE_AIPI_XKLINKTO_URL=https://kz.b.sit.4.cn
|
||||
# 工作室/空间前端地址(用于 auth/callback 等跳转)
|
||||
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn
|
||||
|
||||
# 可选:Canvas 独立 LangGraph 地址
|
||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||
@@ -74,7 +74,7 @@
|
||||
"hast": "^1.0.0",
|
||||
"katex": "^0.16.28",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mermaid": "11.14.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"motion": "^12.26.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@@ -0,0 +1,898 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
type FC,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Search,
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import Empty from "./empty";
|
||||
import { NetworkActor } from "./network-actor";
|
||||
|
||||
// ── 适配层 ─────────────────────────────────────────────────
|
||||
|
||||
function authenticatedFetch(url: string, init?: RequestInit) {
|
||||
const token = getStoredAuth()?.access_token;
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.headers ?? {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getYUserId(): string {
|
||||
if (typeof window === "undefined") return "1157761008018653184";
|
||||
try {
|
||||
const raw = localStorage.getItem("deerflow.external_user_info");
|
||||
if (raw) {
|
||||
const obj = JSON.parse(raw) as { yUserId?: string };
|
||||
if (obj?.yUserId) return obj.yUserId;
|
||||
}
|
||||
} catch { }
|
||||
return "1157761008018653184";
|
||||
}
|
||||
|
||||
|
||||
|
||||
const searchTypes = [
|
||||
{ value: "human", label: "人物基因库" },
|
||||
{ value: "organization", label: "组织基因库" },
|
||||
{ value: "community", label: "群体基因库" },
|
||||
{ value: "issue", label: "议题基因库" },
|
||||
{ value: "event", label: "事件基因库" },
|
||||
{ value: "threat", label: "风险与机会" },
|
||||
] as const;
|
||||
|
||||
// ── Context ────────────────────────────────────────────────
|
||||
interface DocumentUploadDialogContextType {
|
||||
openDialog: (initialSearchText?: string) => void;
|
||||
closeDialog: (
|
||||
mode?: "search",
|
||||
data?: { query: string; mode: string }
|
||||
) => Promise<void>;
|
||||
// 上传成功回调,返回上传的来源信息
|
||||
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
|
||||
}
|
||||
|
||||
// ── 任务轮询类型 ─────────────────────────────────────────────
|
||||
interface TaskSource {
|
||||
task_id: string;
|
||||
title: string;
|
||||
status: 'processing' | 'success' | 'error';
|
||||
source_type: string;
|
||||
}
|
||||
|
||||
interface UploadResponse {
|
||||
status: string;
|
||||
uploaded: number;
|
||||
skipped: number;
|
||||
sources: TaskSource[];
|
||||
}
|
||||
|
||||
interface listDataItem {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
redisKey?: string;
|
||||
abstractz?: string;
|
||||
checked: boolean;
|
||||
title: string;
|
||||
content: string;
|
||||
url: string;
|
||||
// 上传文件轮询相关字段
|
||||
task_id?: string;
|
||||
processing_status?: 'processing' | 'success' | 'error';
|
||||
is_uploaded_file?: boolean;
|
||||
country?: string;
|
||||
dateOfBirth?: string;
|
||||
occupationData?: string;
|
||||
party?: string;
|
||||
sexOrGender?: string;
|
||||
imagePath?: string;
|
||||
mongoId?: string;
|
||||
countryData?: string;
|
||||
countrySingle?: string;
|
||||
subtype?: string;
|
||||
text?: string;
|
||||
twoLevel?: string;
|
||||
account_ids?: unknown[];
|
||||
community_description?: string;
|
||||
community_name?: string;
|
||||
community_type?: string;
|
||||
create_time?: string;
|
||||
creator?: string;
|
||||
groups?: string;
|
||||
labels?: string;
|
||||
nations?: string;
|
||||
params?: string;
|
||||
platforms?: string;
|
||||
real_total?: number;
|
||||
total?: number;
|
||||
update_time?: string;
|
||||
virtual_total?: number;
|
||||
direction?: string;
|
||||
hot_num?: number;
|
||||
issue_hot?: number;
|
||||
issue_name?: string;
|
||||
issue_type?: string;
|
||||
news_num?: number;
|
||||
post_num?: number;
|
||||
topic?: string;
|
||||
abstract?: string;
|
||||
allwords?: string[];
|
||||
category?: string;
|
||||
controversy_num?: number;
|
||||
date?: string;
|
||||
discriminant_reason?: string;
|
||||
discriminant_result?: string;
|
||||
doc_author?: string;
|
||||
doc_ids?: string[];
|
||||
emotion_info?: string;
|
||||
first_publish_time?: string;
|
||||
grade?: string;
|
||||
human_id_list?: string;
|
||||
human_list?: string[];
|
||||
if_artificial?: number;
|
||||
if_follow?: number;
|
||||
if_shehua?: number;
|
||||
if_show?: number;
|
||||
image_path?: string;
|
||||
import_num?: number;
|
||||
insert_time?: string;
|
||||
jw?: string;
|
||||
keywords?: string[];
|
||||
location?: string[];
|
||||
location_list?: unknown[];
|
||||
location_type?: number;
|
||||
longitude?: number;
|
||||
media_board?: string;
|
||||
media_type?: string;
|
||||
nowords?: unknown[];
|
||||
onewords?: unknown[];
|
||||
organization_id_list?: string;
|
||||
organization_list?: unknown[];
|
||||
raw_id?: string;
|
||||
report_num?: number;
|
||||
site_name?: string;
|
||||
source_type?: string;
|
||||
sub_category?: string;
|
||||
subject?: string;
|
||||
super_category?: string;
|
||||
surge_num?: number;
|
||||
tone?: string;
|
||||
topic_id?: string;
|
||||
total_num?: number;
|
||||
type?: string;
|
||||
userid?: string;
|
||||
wx_sub_score?: string;
|
||||
wx_total_score?: string;
|
||||
}
|
||||
|
||||
const DocumentUploadDialogContext =
|
||||
createContext<DocumentUploadDialogContextType | null>(null);
|
||||
|
||||
export const useDocumentUploadDialog = () => {
|
||||
const context = useContext(DocumentUploadDialogContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useDocumentUploadDialog must be used within DocumentUploadDialogProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// ── Provider ───────────────────────────────────────────────
|
||||
export const DocumentUploadDialogProvider: FC<{
|
||||
children: ReactNode;
|
||||
onClose?: (
|
||||
mode?: "search",
|
||||
data?: { query: string; mode: string }
|
||||
) => void | Promise<void>;
|
||||
searchSpace: { id: number | string;[key: string]: unknown };
|
||||
onUploadSuccess?: () => void;
|
||||
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
|
||||
}> = ({ children, onClose, searchSpace, onUploadSuccess, onUploadComplete }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [initialSearchText, setInitialSearchText] = useState("");
|
||||
const isClosingRef = useRef(false);
|
||||
|
||||
const openDialog = useCallback((initialSearchText?: string) => {
|
||||
if (isClosingRef.current) return;
|
||||
setInitialSearchText(initialSearchText || "");
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeDialog = useCallback(
|
||||
async (mode?: "search", data?: { query: string; mode: string }) => {
|
||||
isClosingRef.current = true;
|
||||
if (onClose) {
|
||||
try {
|
||||
await onClose(mode, data);
|
||||
} catch (error) {
|
||||
console.error("Error in Provider onClose:", error);
|
||||
}
|
||||
}
|
||||
setIsOpen(false);
|
||||
setTimeout(() => {
|
||||
isClosingRef.current = false;
|
||||
}, 300);
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
if (!isClosingRef.current) {
|
||||
void closeDialog("search", { query: "", mode: "" });
|
||||
void closeDialog();
|
||||
}
|
||||
} else {
|
||||
if (!isClosingRef.current) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
[closeDialog]
|
||||
);
|
||||
|
||||
return (
|
||||
<DocumentUploadDialogContext.Provider value={{ openDialog, closeDialog, onUploadComplete }}>
|
||||
{children}
|
||||
<DocumentUploadPopupContent
|
||||
isOpen={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
searchSpace={searchSpace}
|
||||
onUploadSuccess={onUploadSuccess}
|
||||
closeDialog={closeDialog}
|
||||
initialSearchText={initialSearchText}
|
||||
/>
|
||||
</DocumentUploadDialogContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Popup content ──────────────────────────────────────────
|
||||
const UPLOAD_ACCEPT =
|
||||
".pdf,.docx,.xlsx,.pptx,.html,.csv,.md,.txt,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.mp3,.mp4,.wav,.webm";
|
||||
|
||||
const DocumentUploadPopupContent: FC<{
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
searchSpace: { id: number | string;[key: string]: unknown };
|
||||
onUploadSuccess?: () => void;
|
||||
closeDialog: (
|
||||
mode?: "search",
|
||||
data?: { query: string; mode: string }
|
||||
) => Promise<void>;
|
||||
initialSearchText?: string;
|
||||
}> = ({ isOpen, onOpenChange, searchSpace, onUploadSuccess, initialSearchText }) => {
|
||||
const { onUploadComplete } = useContext(DocumentUploadDialogContext) || {};
|
||||
const searchSpaceId = String(searchSpace?.id);
|
||||
|
||||
// 从环境变量获取配置
|
||||
const backendUrl = import.meta.env.VITE_AIPI_BACKENP_URL || "http://localhost:8001";
|
||||
const consumerUrl = import.meta.env.VITE_AIPI_CONSUMER_URL || "http://ch1.b.uat.4.cn/consumer";
|
||||
const presentUrl = import.meta.env.VITE_AIPI_PRESENT_URL || "http://present.a.uat.4.cn/present/domain";
|
||||
const knowesUrl = import.meta.env.VITE_AIPI_KNOWES_URL || "http://ch1.b.uat.4.cn/knowes";
|
||||
const knowledgeUrl = import.meta.env.VITE_AIPI_KNOWLEDGE_URL || "http://chlib.b.uat.4.cn/knowlibrary";
|
||||
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"search" | "Y" | "upload" | "A" | "actor">("search");
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [listData, setListData] = useState<listDataItem[]>([]);
|
||||
const [searchType, setSearchType] = useState("human");
|
||||
const [selectedSearchSources, setSelectedSearchSources] = useState<listDataItem[]>([]);
|
||||
// upload tab state
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && initialSearchText) {
|
||||
setSearchText(initialSearchText);
|
||||
setSearchValue(initialSearchText);
|
||||
}
|
||||
}, [isOpen, initialSearchText]);
|
||||
|
||||
if (!searchSpaceId) return null;
|
||||
|
||||
const handleSuccess = () => {
|
||||
onUploadSuccess?.();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const searchActiveChange = async (type: "search" | "Y" | "upload" | "A" | "actor") => {
|
||||
setActiveTab(type);
|
||||
if (type === "A") {
|
||||
await searchAChange(searchType);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (files: File[]) => {
|
||||
if (!files.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const token = getStoredAuth()?.access_token ?? "";
|
||||
const formData = new FormData();
|
||||
formData.append("search_space_id", searchSpaceId);
|
||||
for (const file of files) {
|
||||
formData.append("files", file);
|
||||
}
|
||||
const res = await fetch(`${backendUrl}/api/v1/documents/fileupload`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json() as UploadResponse;
|
||||
// 调用上传完成回调,传递来源信息
|
||||
if (data.sources && data.sources.length > 0) {
|
||||
onUploadComplete?.(data.sources.map(s => ({
|
||||
id: undefined, // 后端返回的数据中暂时没有 id
|
||||
title: s.title,
|
||||
task_id: s.task_id,
|
||||
status: s.status,
|
||||
})));
|
||||
}
|
||||
toast.success(`上传成功 ${data.uploaded || files.length} 个文件`);
|
||||
// 只关闭上传弹窗
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error("上传失败,请重试");
|
||||
}
|
||||
} catch {
|
||||
toast.error("上传失败,请重试");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (selectedSearchSources.length === 0) {
|
||||
toast.warning("没有可导入的来源");
|
||||
return;
|
||||
}
|
||||
setIsImporting(true);
|
||||
const dataList = selectedSearchSources.map((item) => ({
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
url: item.url,
|
||||
}));
|
||||
try {
|
||||
const response = await authenticatedFetch(`${backendUrl}/api/v1/source/import`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
search_space_id: Number(searchSpaceId),
|
||||
source_list: dataList,
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { imported_count?: number };
|
||||
toast.success(`成功导入 ${data.imported_count || dataList.length} 个来源`);
|
||||
setListData([]);
|
||||
handleBatchDelete();
|
||||
} else {
|
||||
const errorData = await response.json() as { detail?: string };
|
||||
toast.error(errorData.detail || "导入失败,请重试");
|
||||
}
|
||||
} catch {
|
||||
toast.error("导入失败,请重试");
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const listDataCheckedChange = (index: number, checked: boolean) => {
|
||||
setListData((prevSources) => {
|
||||
const newSources = prevSources.map((item, aIndex) =>
|
||||
aIndex === index ? { ...item, checked } : item
|
||||
);
|
||||
setSelectedSearchSources(newSources.filter((item) => item.checked));
|
||||
return newSources;
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
setListData((prev) => prev.map((item) => ({ ...item, checked: false })));
|
||||
setSelectedSearchSources([]);
|
||||
};
|
||||
|
||||
|
||||
|
||||
function urlSafeBtoa(str: string): string {
|
||||
const encoder = new TextEncoder();
|
||||
const uint8Array = encoder.encode(str);
|
||||
const binaryString = Array.prototype.map
|
||||
.call(uint8Array, (byte: number) => String.fromCharCode(byte))
|
||||
.join("");
|
||||
return btoa(binaryString).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||
}
|
||||
|
||||
const linkToA = (url: string) => { window.open(url); };
|
||||
|
||||
const queryTopicTargetHumanList = async () => {
|
||||
setIsSearching(true);
|
||||
const response = await authenticatedFetch(`${consumerUrl}/topicQuery/queryTopicTargetHumanList`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
|
||||
body: JSON.stringify({ name: searchText, page: 1, size: 50 }),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { data: listDataItem[] };
|
||||
setIsSearching(false);
|
||||
const result = data.data;
|
||||
result.forEach((item) => {
|
||||
item.checked = false;
|
||||
item.title = item.name || "";
|
||||
item.content = item.abstractz || "";
|
||||
const redirect = urlSafeBtoa(`/gene/#/human/basic?mid=${item.id}`);
|
||||
item.url = `${presentUrl}?inredirect=${redirect}`;
|
||||
});
|
||||
setListData(result);
|
||||
}
|
||||
setIsSearching(false);
|
||||
};
|
||||
|
||||
const organizationSearch = async () => {
|
||||
setIsSearching(true);
|
||||
const response = await authenticatedFetch(`${consumerUrl}/topicQuery/queryCommunity`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
|
||||
body: JSON.stringify({ name: searchText, page: 1, size: 50 }),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { data: listDataItem[] };
|
||||
setIsSearching(false);
|
||||
const result = data.data;
|
||||
result.forEach((item) => {
|
||||
item.checked = false;
|
||||
item.title = item.name || "";
|
||||
item.content = item.abstractz || "";
|
||||
const redirect = urlSafeBtoa(`/gene/#/organization/basicAttr?mid=${item.mongoId}`);
|
||||
item.url = `${presentUrl}?inredirect=${redirect}`;
|
||||
});
|
||||
setListData(result);
|
||||
}
|
||||
setIsSearching(false);
|
||||
};
|
||||
|
||||
const queryCommunityList = async () => {
|
||||
setIsSearching(true);
|
||||
const params = { keyword: searchText, page: 1, size: 50 };
|
||||
const queryString = Object.entries(params).map(([k, v]) => `${k}=${v}`).join("&");
|
||||
const response = await authenticatedFetch(`${consumerUrl}/topicQuery/queryCommunityList?${queryString}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { data: listDataItem[] };
|
||||
setIsSearching(false);
|
||||
const result = data.data;
|
||||
result.forEach((item) => {
|
||||
item.checked = false;
|
||||
item.title = item.community_name || "";
|
||||
item.content = item.community_description || "";
|
||||
const redirect = urlSafeBtoa(`/gene/#/commune/basicCommune?cid=${item.id}`);
|
||||
item.url = `${presentUrl}?inredirect=${redirect}`;
|
||||
});
|
||||
setListData(result);
|
||||
}
|
||||
setIsSearching(false);
|
||||
};
|
||||
|
||||
const issueSearch = async () => {
|
||||
setIsSearching(true);
|
||||
const response = await authenticatedFetch(`${consumerUrl}/issue/list?keyword=${searchText}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { data: listDataItem[] };
|
||||
setIsSearching(false);
|
||||
const result = data.data;
|
||||
result.forEach((item) => {
|
||||
item.checked = false;
|
||||
item.title = item.issue_name || "";
|
||||
item.content = item.issue_type || "";
|
||||
const redirect = encodeURIComponent(`/gene/#/issue/zzyyIssue?mid=${item.id}&event_type=issue`);
|
||||
item.url = `${presentUrl}?inredirect=${redirect}`;
|
||||
});
|
||||
setListData(result);
|
||||
}
|
||||
setIsSearching(false);
|
||||
};
|
||||
|
||||
const eventSearch = async () => {
|
||||
setIsSearching(true);
|
||||
const response = await authenticatedFetch(`${consumerUrl}/event/queryEvents`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
|
||||
body: JSON.stringify({ word: searchText, page: 1, size: 50, type: "hot", timeRange: "" }),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { data: listDataItem[] };
|
||||
setIsSearching(false);
|
||||
const result = data.data;
|
||||
result.forEach((item) => {
|
||||
item.checked = false;
|
||||
const redirect = urlSafeBtoa(`/gene/#/event/basicEvent?event_type=hot&mid=${item.id}`);
|
||||
item.url = `${presentUrl}?inredirect=${redirect}`;
|
||||
});
|
||||
setListData(result);
|
||||
}
|
||||
setIsSearching(false);
|
||||
};
|
||||
|
||||
const topicQuery = async () => {
|
||||
setIsSearching(true);
|
||||
let result: listDataItem[] = [];
|
||||
for (const discriminant of ["威胁", "ZJ机"]) {
|
||||
const queryString = `keyword=${searchText}&discriminant=${discriminant}`;
|
||||
const response = await authenticatedFetch(`${consumerUrl}/topicQuery/queryEventFromA?${queryString}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { data: listDataItem[] };
|
||||
const results = data.data;
|
||||
results.forEach((item) => {
|
||||
item.checked = false;
|
||||
const redirect = urlSafeBtoa(`/gene/#/event/basicEvent?event_type=hot&mid=${item.id}`);
|
||||
item.url = `${presentUrl}?inredirect=${redirect}`;
|
||||
});
|
||||
result = [...result, ...results];
|
||||
}
|
||||
}
|
||||
setIsSearching(false);
|
||||
setListData(result);
|
||||
};
|
||||
|
||||
const searchAChange = async (type: string) => {
|
||||
if (type === "human") await queryTopicTargetHumanList();
|
||||
else if (type === "organization") await organizationSearch();
|
||||
else if (type === "community") await queryCommunityList();
|
||||
else if (type === "issue") await issueSearch();
|
||||
else if (type === "event") await eventSearch();
|
||||
else if (type === "threat") await topicQuery();
|
||||
};
|
||||
|
||||
const searchTypeChange = (value: string) => {
|
||||
setSearchType(value);
|
||||
setListData([]);
|
||||
void searchAChange(value);
|
||||
};
|
||||
|
||||
const searchDataChange = () => {
|
||||
setIsSearching(true);
|
||||
setSearchValue(searchText);
|
||||
setTimeout(() => setIsSearching(false), 500);
|
||||
if (activeTab === "A") void searchAChange(searchType);
|
||||
};
|
||||
|
||||
const yUserId = getYUserId();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="w-[90vw] max-w-[90vw] sm:max-w-[90vw] h-[90vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 [&>button]:top-4 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogTitle className="sr-only">添加来源</DialogTitle>
|
||||
<DialogDescription className="sr-only">从外部系统搜索并导入来源</DialogDescription>
|
||||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="px-3 sm:px-12 pt-12 sm:pt-24 pb-6 sm:pb-16">
|
||||
{/* 搜索框 */}
|
||||
<div className="relative rounded-lg border border-neutral-200 bg-white mb-[10px] transition-colors hover:border-neutral-300">
|
||||
<Textarea
|
||||
placeholder="搜索资料..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
searchDataChange();
|
||||
}
|
||||
}}
|
||||
className="max-h-[160px] resize-none border-0 bg-transparent px-10 py-3 pr-20 text-sm text-neutral-700 placeholder:text-neutral-400 focus-visible:ring-0 focus-visible:outline-none"
|
||||
rows={1}
|
||||
/>
|
||||
<div className="absolute left-3 top-3 text-neutral-400 pointer-events-none">
|
||||
<Search className="h-4 w-4" strokeWidth={1.5} />
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={searchDataChange}
|
||||
disabled={isSearching || !searchText.trim()}
|
||||
className="absolute right-2 top-2 h-7 rounded-md bg-neutral-900 px-3 text-xs font-medium text-white transition-colors hover:bg-neutral-800 disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
{isSearching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <span>搜索</span>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tab 标签页 */}
|
||||
<div className="border-b border-gray-200 rounded-t-xl px-4 bg-gray-50">
|
||||
<div className="flex flex-nowrap overflow-x-auto">
|
||||
{(
|
||||
[
|
||||
{ key: "search" as const, label: "B系统(知识库)" },
|
||||
{ key: "A" as const, label: "A系统(基因库)" },
|
||||
{ key: "Y" as const, label: "Y系统(信息库)" },
|
||||
{ key: "actor" as const, label: "B系统(XWT检索)" },
|
||||
{ key: "upload" as const, label: "上传" },
|
||||
] as const
|
||||
).map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => void searchActiveChange(key)}
|
||||
className={`relative pb-3 px-6 pt-4 text-sm font-medium transition-colors ${activeTab === key ? "text-gray-900" : "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{activeTab === key && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-900 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-5">
|
||||
{/* B系统知识库 */}
|
||||
{activeTab === "search" && !isSearching && (
|
||||
<iframe
|
||||
className="w-full h-[calc(100vh-200px)]"
|
||||
src={`${knowesUrl}/fromB1?yUserId=${yUserId}&processType=searchMode&bookChatId=${searchSpaceId}&inputValue=${searchValue}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Y系统信息库 */}
|
||||
{activeTab === "Y" && !isSearching && (
|
||||
<iframe
|
||||
className="w-full h-[calc(100vh-200px)]"
|
||||
src={`${knowledgeUrl}/#/sousou?chatType=new-chat&bookChatId=${searchSpaceId}&inputValue=${searchText}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* A系统基因库 */}
|
||||
{activeTab === "A" && (
|
||||
<div className="bg-white rounded-lg">
|
||||
<div className="custom-radio-group pt-[10px] ml-[10px]">
|
||||
{searchTypes.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`custom-radio-btn ${searchType === option.value ? "active" : ""}`}
|
||||
onClick={() => searchTypeChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedSearchSources.length > 0 && (
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
|
||||
<span className="text-sm text-gray-600">已选中 {selectedSearchSources.length} 项</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={handleBatchDelete} className="h-8 px-3 text-sm">
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
删除选中
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleImport()}
|
||||
disabled={isImporting}
|
||||
className="h-8 px-3 text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200"
|
||||
>
|
||||
{isImporting ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Plus className="h-3.5 w-3.5 mr-1" />}
|
||||
导入选中
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{listData.length === 0 && <Empty />}
|
||||
|
||||
{searchType === "human" && (
|
||||
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
|
||||
{listData.map((item, index) => (
|
||||
<div key={index} className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
|
||||
{/* 上传文件显示 loading 状态 */}
|
||||
{item.is_uploaded_file && item.processing_status === 'processing' && (
|
||||
<div className="absolute inset-0 bg-white/80 flex items-center justify-center z-10">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{/* 上传文件完成状态标记 */}
|
||||
{item.is_uploaded_file && item.processing_status === 'error' && (
|
||||
<div className="absolute top-1 left-1">
|
||||
<span className="text-xs text-red-500">处理失败</span>
|
||||
</div>
|
||||
)}
|
||||
{item.is_uploaded_file && item.processing_status === 'success' && (
|
||||
<div className="absolute top-1 left-1">
|
||||
<span className="text-xs text-green-500">✓ 已完成</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-[50px] mr-[10px]">
|
||||
<img src={item.imagePath || ""} className="w-[50px] h-[50px]" alt="" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.name}</p>
|
||||
<p className="truncate text-[#909090]">职业:{item.occupationData}</p>
|
||||
<p className="truncate text-[#909090]">属地:{item.country}</p>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{searchType === "organization" && (
|
||||
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
|
||||
{listData.map((item, index) => (
|
||||
<div key={index} className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
|
||||
<div className="w-[50px] mr-[10px]">
|
||||
<img src={item.imagePath || ""} className="w-[50px] h-[50px]" alt="" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.name}</p>
|
||||
<p className="truncate text-[#909090]">组织类别:{item.subtype}</p>
|
||||
<p className="truncate text-[#909090]">简介:{item.text}</p>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{searchType === "community" && (
|
||||
<div className="bg-white p-[10px] grid grid-cols-4 gap-[10px]">
|
||||
{listData.map((item, index) => (
|
||||
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
|
||||
<div>
|
||||
<div className="truncate mb-[5px] hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.title}</div>
|
||||
<div className="flex items-center text-[#909090] mb-[5px]">
|
||||
{item.nations && <span className="truncate max-w-[200px] inline-block border border-[#cf1322] rounded px-1 text-[#cf1322]">{item.nations}</span>}
|
||||
{item.labels && <span className="inline-block border border-[#bbb] rounded px-1 ml-[10px]">{item.labels}</span>}
|
||||
</div>
|
||||
<div className="flex justify-between mb-[5px]">
|
||||
<span className="text-[#909090]">群体规模:<span className="text-[#cf1322]">{item.total}</span></span>
|
||||
<span className="text-[#909090]">对应实体数:<span className="text-[#cf1322]">{item.real_total}</span></span>
|
||||
</div>
|
||||
<div><span className="text-[#909090]">涉及平台:<span className="text-[#222222]">{item.platforms}</span></span></div>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{searchType === "issue" && (
|
||||
<div className="bg-white p-[10px] grid grid-cols-4 gap-[10px]">
|
||||
{listData.map((item, index) => (
|
||||
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
|
||||
<div>
|
||||
<div className="truncate mb-[5px]">{item.title}</div>
|
||||
<div className="flex justify-between mb-[5px]">
|
||||
<span className="text-[#909090]">方向:<span className="text-[#222222]">{item.direction}</span></span>
|
||||
<span className="text-[#909090]">类别:<span className="text-[#222222]">{item.issue_type}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{searchType === "event" && (
|
||||
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
|
||||
{listData.map((item, index) => (
|
||||
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.title}</p>
|
||||
<p className="truncate text-[#909090]">首发新闻:{item.site_name}</p>
|
||||
<p className="truncate text-[#909090]">事件时间:{item.first_publish_time}</p>
|
||||
<p className="truncate text-[#909090]">关键词:{item.keywords?.join("、")}</p>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{searchType === "threat" && (
|
||||
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
|
||||
{listData.map((item, index) => (
|
||||
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
|
||||
<div className="flex-1 min-w-0 pr-[25px]">
|
||||
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.title}</p>
|
||||
<p className="truncate text-[#909090]">发布平台:{item.site_name || "暂无数据"}</p>
|
||||
<p className="truncate text-[#909090]">事件时间:{item.first_publish_time || "暂无数据"}</p>
|
||||
<p className="truncate text-[#909090]">关键词:{item.keywords?.join("、") || "暂无数据"}</p>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* B系统XWT检索 */}
|
||||
{activeTab === "actor" && !isSearching && (
|
||||
<NetworkActor
|
||||
inputValue={searchText}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 上传 */}
|
||||
{activeTab === "upload" && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-10">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={UPLOAD_ACCEPT}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (files.length) void handleFileUpload(files);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="px-6"
|
||||
>
|
||||
{uploading ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />上传中...</>
|
||||
) : (
|
||||
<><Plus className="h-4 w-4 mr-2" />选择文件上传</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">支持 PDF、Word、Excel、PPT、HTML、CSV、Markdown、TXT 及常见图片格式</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-2 sm:h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
const Empty = ({
|
||||
description = '暂无数据',
|
||||
size = 'default'
|
||||
}: {
|
||||
description?: string;
|
||||
size?: 'default' | 'small';
|
||||
}) => {
|
||||
return (
|
||||
<div className={`empty-container ${size === 'small' ? 'empty-small' : ''}`}>
|
||||
<div className="empty-image">
|
||||
<img src="/icon/empty-list.png" alt="暂无数据" />
|
||||
</div>
|
||||
{description && <p className="empty-description">{description}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Empty;
|
||||
@@ -0,0 +1,630 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Loader2, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
|
||||
function authenticatedFetch(url: string, init?: RequestInit) {
|
||||
const token = getStoredAuth()?.access_token;
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.headers ?? {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface NetworkActorProps {
|
||||
inputValue: string;
|
||||
searchSpaceId: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
interface platformItem {
|
||||
title: string;
|
||||
icon: string;
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
interface languageItem {
|
||||
title: string;
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
interface PoliticsDirItem {
|
||||
地区: string;
|
||||
语言: string;
|
||||
年龄段: string;
|
||||
性别: string;
|
||||
教育程度: string;
|
||||
关注话题: { 话题: string; 倾向: string; 佐证: string; 风格: string }[];
|
||||
类型: string;
|
||||
对HZZLC: string;
|
||||
对MZZLC: string;
|
||||
对TZZLC: string[];
|
||||
账号描述: string;
|
||||
发文频率: string;
|
||||
活跃时间: string;
|
||||
}
|
||||
|
||||
interface 用户画像 {
|
||||
地区: string;
|
||||
教育程度: string;
|
||||
关注话题: { 倾向: string; 话题: string; 佐证: string; 风格: string }[];
|
||||
语言: string;
|
||||
年龄段: string;
|
||||
账号描述: string;
|
||||
对TZZLC: string[];
|
||||
对HZZLC: string;
|
||||
对MZZLC: string[];
|
||||
性别: string;
|
||||
类型: string;
|
||||
发文频率: string;
|
||||
活跃时间: string;
|
||||
}
|
||||
|
||||
interface hx_info {
|
||||
语言: string;
|
||||
用户画像: 用户画像;
|
||||
}
|
||||
|
||||
interface UserDataItem {
|
||||
comment_count: number;
|
||||
country: string | null;
|
||||
agent_task_id: string | null;
|
||||
education: string | null;
|
||||
groupId: string | null;
|
||||
forward_count: number;
|
||||
political_stand: string;
|
||||
language: string;
|
||||
type: string;
|
||||
concern: string | null;
|
||||
profile_image: string | null;
|
||||
concern_topic: string | null;
|
||||
screen_name: string;
|
||||
school: string | null;
|
||||
is_alive: number;
|
||||
createdTime: string;
|
||||
data_provider: string | null;
|
||||
id: string;
|
||||
text: string | null;
|
||||
group: string | null;
|
||||
read_count: number;
|
||||
group_name: string;
|
||||
area: string | null;
|
||||
image: string | null;
|
||||
updatedTime: string;
|
||||
like_count: number;
|
||||
m_politics_dir: string;
|
||||
camp: string | null;
|
||||
tags: string[];
|
||||
army: string | null;
|
||||
is_sync_a: number;
|
||||
tweets_count: number;
|
||||
groupName: string | null;
|
||||
history_key: string;
|
||||
user_id: string;
|
||||
real_id: string | null;
|
||||
h_politics_dir: string | null;
|
||||
domain: string | null;
|
||||
name: string | null;
|
||||
position: string | null;
|
||||
job: string | null;
|
||||
age_round: string | null;
|
||||
birthday: string | null;
|
||||
is_virtual: number;
|
||||
isMedia: number;
|
||||
hx_info: hx_info;
|
||||
gender: string | null;
|
||||
agentInfo: PoliticsDirItem;
|
||||
profile_url: string | null;
|
||||
tagId: string[];
|
||||
user_name: string;
|
||||
a_update_time: string | null;
|
||||
values: string | null;
|
||||
follows_count: number;
|
||||
title: string | null;
|
||||
is_protected: number;
|
||||
platform: string;
|
||||
importType: string;
|
||||
update_time: string;
|
||||
key: string;
|
||||
introduction: string | null;
|
||||
brief: string | null;
|
||||
creator: string;
|
||||
history_title: string;
|
||||
tweets_ratio: string | null;
|
||||
is_verified: number;
|
||||
t_politics_dir: string[];
|
||||
eventMap: string | null;
|
||||
fans_count: number;
|
||||
a_create_time: string | null;
|
||||
hobbies: string[];
|
||||
location: string | null;
|
||||
average_count: number;
|
||||
ratio: string | null;
|
||||
classification?: string | null;
|
||||
groupDiscussTags?: string[];
|
||||
checked?: boolean;
|
||||
content?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkActorProps) {
|
||||
const [platforms, setPlatforms] = useState<platformItem[]>([
|
||||
{ title: 'X', icon: 'x.png', checked: false },
|
||||
{ title: 'Facebook', icon: 'facebook.png', checked: false },
|
||||
{ title: 'Reddit', icon: 'reddit.png', checked: false },
|
||||
{ title: 'PTT', icon: 'ptt.png', checked: false },
|
||||
{ title: 'YouTube', icon: 'youtube.png', checked: false },
|
||||
{ title: 'Dcard', icon: 'dcard.png', checked: false },
|
||||
{ title: 'Mobile01', icon: 'mobile01.png', checked: false },
|
||||
{ title: 'Threads', icon: 'threads.png', checked: false },
|
||||
{ title: 'Instagram', icon: 'ins.png', checked: false },
|
||||
]);
|
||||
const platformList = useRef<string[]>([]);
|
||||
const [languages, setLanguages] = useState<languageItem[]>([
|
||||
{ title: '繁体中文', checked: false },
|
||||
{ title: '简体中文', checked: false },
|
||||
{ title: '英文', checked: false },
|
||||
{ title: '日文', checked: false },
|
||||
{ title: '其他', checked: false },
|
||||
]);
|
||||
const languageList = useRef<string[]>([]);
|
||||
const [allPlatforms, setAllPlatforms] = useState<boolean>(true);
|
||||
const [allLanguage, setAllLanguage] = useState<boolean>(true);
|
||||
const [sortType, setSortType] = useState<string>('fans_count');
|
||||
const sortTypeValue = useRef('fans_count');
|
||||
const [fansSort, setFansSort] = useState<string>('desc');
|
||||
const sortValue = useRef('desc');
|
||||
const [relevanceSort, setRelevanceSort] = useState<string>('desc');
|
||||
const [userList, setUserList] = useState<UserDataItem[]>([]);
|
||||
const [searchText, setSearchText] = useState<string>(inputValue);
|
||||
const [isSearching, setIsSearching] = useState<boolean>(false);
|
||||
const [searchType, setSearchType] = useState<string>('network');
|
||||
const searchValue = useRef<string>('network');
|
||||
const [selNetCount, setSelNetCount] = useState<number>(0);
|
||||
const [actorCount, setActorCount] = useState<number>(0);
|
||||
const [directionType, setDirectionType] = useState<string>('TW');
|
||||
const directionValue = useRef<string>('TW');
|
||||
const consumerUrl = 'http://ch1.b.uat.4.cn/consumer';
|
||||
const backendUrl = typeof window !== 'undefined'
|
||||
? (import.meta.env.VITE_BACKEND_BASE_URL || 'http://localhost:8001')
|
||||
: 'http://localhost:8001';
|
||||
const [selectedSearchSources, setSelectedSearchSources] = useState<UserDataItem[]>([]);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
const searchChange = async () => {
|
||||
setIsSearching(true);
|
||||
if (searchValue.current === 'network') {
|
||||
await agentUserSearch();
|
||||
} else {
|
||||
await agentGroupSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const setDirectionChange = async (type: string) => {
|
||||
setDirectionType(type);
|
||||
directionValue.current = type;
|
||||
await searchChange();
|
||||
};
|
||||
|
||||
const searchTypeChange = async (value: string) => {
|
||||
setSearchType(value);
|
||||
searchValue.current = value;
|
||||
await searchChange();
|
||||
};
|
||||
|
||||
const allPlatformChange = async () => {
|
||||
setAllPlatforms(true);
|
||||
setPlatforms((prev) => prev.map((item) => ({ ...item, checked: false })));
|
||||
platformList.current = [];
|
||||
await searchChange();
|
||||
};
|
||||
|
||||
const handleTogglePlatform = async (index: number) => {
|
||||
setPlatforms((prev) => {
|
||||
const newPlatforms = prev.map((item, i) =>
|
||||
i === index ? { ...item, checked: !item.checked } : item
|
||||
);
|
||||
const checkedTitles = newPlatforms.filter((item) => item.checked).map((item) => item.title);
|
||||
platformList.current = checkedTitles;
|
||||
setAllPlatforms(newPlatforms.every((item) => !item.checked));
|
||||
return newPlatforms;
|
||||
});
|
||||
await searchChange();
|
||||
};
|
||||
|
||||
const allLanguageChange = async () => {
|
||||
setAllLanguage(true);
|
||||
setLanguages((prev) => prev.map((item) => ({ ...item, checked: false })));
|
||||
languageList.current = [];
|
||||
await searchChange();
|
||||
};
|
||||
|
||||
const handleToggleLanguage = async (index: number) => {
|
||||
setLanguages((prev) => {
|
||||
const newItems = prev.map((item, i) =>
|
||||
i === index ? { ...item, checked: !item.checked } : item
|
||||
);
|
||||
const checkedTitles = newItems.filter((item) => item.checked).map((item) => item.title);
|
||||
languageList.current = checkedTitles;
|
||||
setAllLanguage(newItems.every((item) => !item.checked));
|
||||
return newItems;
|
||||
});
|
||||
await searchChange();
|
||||
};
|
||||
|
||||
const sortTypeChange = async (type: string) => {
|
||||
setSortType(type);
|
||||
sortTypeValue.current = type;
|
||||
sortValue.current = sortValue.current === 'desc' ? 'asc' : 'desc';
|
||||
if (type === 'fans_count') setFansSort(sortValue.current);
|
||||
else if (type === '_score') setRelevanceSort(sortValue.current);
|
||||
await searchChange();
|
||||
};
|
||||
|
||||
const agentUserSearch = async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch(`${consumerUrl}/portrait/agentUser`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admin' },
|
||||
body: JSON.stringify({
|
||||
historyKey: [],
|
||||
historyTitle: '',
|
||||
keyword: searchText,
|
||||
language: languageList.current,
|
||||
page: 1,
|
||||
limit: 12,
|
||||
platform: platformList.current,
|
||||
politicalStand: directionValue.current,
|
||||
sort: sortTypeValue.current,
|
||||
asc: sortValue.current,
|
||||
typeKeyword: 9,
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setIsSearching(false);
|
||||
const result: UserDataItem[] = data.data.list;
|
||||
result.forEach((item) => {
|
||||
item.profile_image = item.profile_image
|
||||
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
|
||||
: 'http://ty.b.uat.4.cn/manage/img/default.png';
|
||||
item.classification = `${item.political_stand}-${item.history_key}-${item.history_title}`;
|
||||
item.title = item.user_name || item.screen_name || item.user_id;
|
||||
item.content = item.agentInfo['账号描述'];
|
||||
item.url = `http://ty.b.uat.4.cn/fromB/toDetail?user_id=${item.user_id}&platform=${item.platform}`;
|
||||
});
|
||||
setUserList(result);
|
||||
setSelNetCount(data.data.total);
|
||||
setActorCount(data.data.zs_total);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const agentGroupSearch = async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch(`${consumerUrl}/portrait/agentGroup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admin' },
|
||||
body: JSON.stringify({
|
||||
historyKey: [],
|
||||
historyTitle: '',
|
||||
keyword: searchText,
|
||||
language: languageList.current,
|
||||
page: 1,
|
||||
limit: 12,
|
||||
platform: platformList.current,
|
||||
politicalStand: directionValue.current,
|
||||
sort: sortTypeValue.current,
|
||||
asc: sortValue.current,
|
||||
typeKeyword: 9,
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setIsSearching(false);
|
||||
const result: UserDataItem[] = data.data.data;
|
||||
result.forEach((item) => {
|
||||
item.hobbies = item.groupDiscussTags || [];
|
||||
item.profile_image = item.profile_image
|
||||
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
|
||||
: 'http://ty.b.uat.4.cn/manage/img/default.png';
|
||||
item.classification = `${item.political_stand}`;
|
||||
item.title = item.group_name || item.groupName;
|
||||
if (item.hx_info?.['用户画像']) {
|
||||
item.content = item.hx_info['用户画像']['账号描述'];
|
||||
}
|
||||
item.url = '';
|
||||
});
|
||||
setUserList(result);
|
||||
setActorCount(data.data.total);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const listDataCheckedChange = (index: number, checked: boolean) => {
|
||||
setUserList((prevSources) => {
|
||||
const newSources = prevSources.map((item, aIndex) =>
|
||||
aIndex === index ? { ...item, checked } : item
|
||||
);
|
||||
setSelectedSearchSources(newSources.filter((item) => item.checked));
|
||||
return newSources;
|
||||
});
|
||||
};
|
||||
|
||||
const linkToB2 = (data: UserDataItem) => {
|
||||
const url = `http://ty.b.uat.4.cn/fromB/toDetail?user_id=${data.user_id}&platform=${data.platform}`;
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
setUserList((prev) => prev.map((item) => ({ ...item, checked: false })));
|
||||
setSelectedSearchSources([]);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (selectedSearchSources.length === 0) {
|
||||
toast.warning('没有可导入的来源');
|
||||
return;
|
||||
}
|
||||
setIsImporting(true);
|
||||
const dataList = selectedSearchSources.map((item) => ({
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
url: item.url,
|
||||
}));
|
||||
try {
|
||||
const response = await authenticatedFetch(`${backendUrl}/api/v1/source/import`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
search_space_id: Number(searchSpaceId),
|
||||
source_list: dataList,
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
toast.success(`成功导入 ${data.imported_count || dataList.length} 个来源`);
|
||||
setSearchText('');
|
||||
setUserList([]);
|
||||
handleBatchDelete();
|
||||
onSuccess?.();
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
toast.error(errorData.detail || '导入失败,请重试');
|
||||
}
|
||||
} catch {
|
||||
toast.error('导入失败,请重试');
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void searchChange();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-white rounded-[5px] p-[10px]">
|
||||
<div className="border border-[#e8e8e8] rounded-[5px] p-[10px]">
|
||||
<div className="mb-[10px] flex">
|
||||
<span className="text-[#3D3D3D]">所属平台:</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="flex items-center gap-[10px] flex-wrap">
|
||||
<span
|
||||
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allPlatforms ? 'platform-active' : ''}`}
|
||||
onClick={() => void allPlatformChange()}
|
||||
>全部</span>
|
||||
{platforms.map((item, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
|
||||
onClick={() => void handleTogglePlatform(index)}
|
||||
>
|
||||
<img src={`/platform/${item.icon}`} className="w-[24px]" alt="" />
|
||||
<span className="w-[2px] h-[12px] ml-[5px] mr-[5px] bg-[#dbdbdb]"></span>
|
||||
<span className="ml-[5px]">{item.title}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-[10px] flex">
|
||||
<span className="text-[#3D3D3D]">常用语言:</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="flex items-center gap-[10px] flex-wrap">
|
||||
<span
|
||||
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allLanguage ? 'platform-active' : ''}`}
|
||||
onClick={() => void allLanguageChange()}
|
||||
>全部</span>
|
||||
{languages.map((item, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
|
||||
onClick={() => void handleToggleLanguage(index)}
|
||||
>
|
||||
<span>{item.title}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-[10px] flex">
|
||||
<span className="text-[#3D3D3D]">方向:</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="flex items-center gap-[10px] flex-wrap">
|
||||
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === 'TW' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('TW')}>W方向</span>
|
||||
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === 'US' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('US')}>U方向</span>
|
||||
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === '其他' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('其他')}>其他</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-[10px] flex">
|
||||
<span className="text-[#3D3D3D]">类型:</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="flex items-center gap-[10px] flex-wrap">
|
||||
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${searchType === 'network' ? 'platform-active' : ''}`} onClick={() => void searchTypeChange('network')}>网络XWT</span>
|
||||
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${searchType === 'group' ? 'platform-active' : ''}`} onClick={() => void searchTypeChange('group')}>群组XWT</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-[5px] pt-[15px] mt-[10px]">
|
||||
<div className="flex items-center justify-between pb-[10px] mb-[10px] border-b border-b-[#f1f3f5]">
|
||||
<div className="flex items-center ml-[10px]">
|
||||
<div className="title-bar mr-[8px]"></div>
|
||||
<div className="text-[#454859]">用户表格管理</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`relative flex items-center cursor-pointer border ${sortType === 'fans_count' ? 'border-[#cf1322]' : ''} p-[7px] pl-[10px] pr-[20px] rounded-[5px]`}
|
||||
onClick={() => void sortTypeChange('fans_count')}
|
||||
>
|
||||
<span className="leading-[16px]">粉丝数排序</span>
|
||||
<div className="ml-[10px]">
|
||||
<div className={`sort-up ${fansSort === 'asc' ? 'sort-up-active' : ''}`}></div>
|
||||
<div className={`mt-[4px] sort-down ${fansSort === 'desc' ? 'sort-down-active' : ''}`}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`relative flex items-center ml-[10px] cursor-pointer border ${sortType === '_score' ? 'border-[#cf1322]' : ''} p-[7px] pl-[10px] pr-[20px] rounded-[5px]`}
|
||||
onClick={() => void sortTypeChange('_score')}
|
||||
>
|
||||
<span className="leading-[16px]">相关度排序</span>
|
||||
<div className="ml-[10px]">
|
||||
<div className={`sort-up ${relevanceSort === 'asc' ? 'sort-up-active' : ''}`}></div>
|
||||
<div className={`mt-[4px] sort-down ${relevanceSort === 'desc' ? 'sort-down-active' : ''}`}></div>
|
||||
</div>
|
||||
</div>
|
||||
{searchType === 'network' && (
|
||||
<>
|
||||
<div className="flex items-center ml-[35px]">
|
||||
<img className="w-[24px] h-[24px]" src="/icon/group-yellow-logo.svg" alt="" />
|
||||
<div className="ml-[5px]">
|
||||
<div className="text-[#454859] leading-[16px]">当前选中网络XWT数</div>
|
||||
<div className="text-[#d97b00] leading-[16px] mt-[5px]">{selNetCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-[5px] mr-[5px] h-[30px] w-[2px] -skew-x-[30deg] bg-[#dbdbdb]"></div>
|
||||
</>
|
||||
)}
|
||||
<div className={`flex items-center ${searchType === 'network' ? 'ml-[5px]' : 'ml-[20px]'} mr-[25px]`}>
|
||||
<img className="w-[24px] h-[24px]" src="/icon/group-red-logo.svg" alt="" />
|
||||
<div className="ml-[5px]">
|
||||
<div className="text-[#454859] leading-[16px]">网络XWT总数</div>
|
||||
<div className="text-[#cf1322] leading-[16px] mt-[5px]">{actorCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedSearchSources.length > 0 && (
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
|
||||
<span className="text-sm text-gray-600">已选中 {selectedSearchSources.length} 项</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={handleBatchDelete} className="h-8 px-3 text-sm">
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
删除选中
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleImport()}
|
||||
disabled={isImporting}
|
||||
className="h-8 px-3 text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200"
|
||||
>
|
||||
{isImporting ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Plus className="h-3.5 w-3.5 mr-1" />}
|
||||
导入选中
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-[10px]">
|
||||
<div className="grid grid-cols-4 gap-[10px] user-list">
|
||||
{userList.map((item, index) => (
|
||||
<div key={index} className="user-item p-[10px] relative">
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked ?? false}
|
||||
onChange={() => listDataCheckedChange(index, !item.checked)}
|
||||
className="w-4 h-4 cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center mb-[10px] cursor-pointer" onClick={() => linkToB2(item)}>
|
||||
<img src={item.profile_image ?? ''} className="w-[97px] h-[97px] rounded-[5px]" alt="" />
|
||||
<div className="flex-1 min-w-0 pl-[10px]">
|
||||
<div className="truncate mb-[10px] text-[18px]">{item.user_name || item.screen_name || item.user_id || item.group_name || item.groupName}</div>
|
||||
<div className="flex justify-between mb-[10px]">
|
||||
<div className="flex items-center">
|
||||
<img src="/icon/group-yellow-logo.svg" className="w-[16px] h-[16px] mr-[5px]" alt="" />
|
||||
<span>粉丝数:{item.fans_count || '-'}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<img src={`/platform/${item.platform}.png`} className="w-[25px] mr-[5px]" alt="" />
|
||||
<span>{item.platform}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<img src="/icon/person-red-logo.svg" className="w-[16px] mr-[5px]" alt="" />
|
||||
<span>实体人:{item.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cursor-pointer" onClick={() => linkToB2(item)}>
|
||||
<div className="flex items-center mb-[10px]">
|
||||
<div className="w-[70px] flex items-center">
|
||||
<img src="/icon/classification-logo.png" className="w-[16px] mr-[5px]" alt="" />
|
||||
<span>分类:</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="inline-block h-[28px] pl-[8px] pr-[8px] bg-[#fef6f6] text-[#cf1322]">{item.classification}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mb-[10px]">
|
||||
<div className="w-[70px] flex items-center">
|
||||
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
|
||||
<span>标签:</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-[5px]">
|
||||
{(item.hobbies ?? []).map((hobbie) => (
|
||||
<div key={hobbie} className="h-[28px] pl-[8px] pr-[8px] bg-[#f6fdfb] text-[#1eb07f]">{hobbie}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-baseline mb-[10px]">
|
||||
<div className="w-[70px] flex items-center">
|
||||
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
|
||||
<span>简介:</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="line-clamp-3 text-ellipsis overflow-hidden">
|
||||
{(item.agentInfo && item.agentInfo['账号描述']) || (item.hx_info?.['用户画像']?.['账号描述']) || ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import type {
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
LayoutType,
|
||||
SimEdge,
|
||||
SimNode,
|
||||
TooltipState,
|
||||
Transform,
|
||||
} from "./graphTypes";
|
||||
import {
|
||||
applyCircularLayout,
|
||||
ForceSimulation,
|
||||
getEdgeId,
|
||||
getEdgeInfo,
|
||||
getEdgeLabel,
|
||||
getLinkDistance,
|
||||
getNodeKind,
|
||||
getNodeRadius,
|
||||
getNodeTitle,
|
||||
NODE_KIND_META,
|
||||
seedPositions,
|
||||
truncateLabel,
|
||||
} from "./graphUtils";
|
||||
|
||||
const MIN_ZOOM = 0.15;
|
||||
const MAX_ZOOM = 4;
|
||||
|
||||
export interface GraphCanvasHandle {
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
fitView: () => void;
|
||||
relayout: () => void;
|
||||
focusNode: (id: string) => void;
|
||||
exportImage: () => void;
|
||||
}
|
||||
|
||||
interface GraphCanvasProps {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
/** Degrees computed on the FULL graph so node sizes stay stable under filters. */
|
||||
degrees: Map<string, number>;
|
||||
selectedNodeId: string | null;
|
||||
/** null = no active search; otherwise the set of matching node ids. */
|
||||
searchMatchIds: Set<string> | null;
|
||||
layoutType: LayoutType;
|
||||
showLabels: boolean;
|
||||
onSelectNode: (id: string | null) => void;
|
||||
onTooltip: (tooltip: TooltipState | null) => void;
|
||||
onZoomChange: (k: number) => void;
|
||||
/**
|
||||
* Explore-mode rendering context; null/undefined renders the normal full view.
|
||||
* preview = no root picked yet: nodes are shown faded as pickable起点 candidates.
|
||||
*/
|
||||
explore?: {
|
||||
preview: boolean;
|
||||
rootId: string | null;
|
||||
nodeDepthMap: Map<string, number>;
|
||||
expandedNodeIds: Set<string>;
|
||||
} | null;
|
||||
/** Floating pills rendered top-left over the canvas. */
|
||||
toolbar?: ReactNode;
|
||||
/** Small floating hint rendered bottom-center over the canvas. */
|
||||
hint?: ReactNode;
|
||||
/** Centered overlay (empty / loading states). */
|
||||
overlay?: ReactNode;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
pointerId: number;
|
||||
node: SimNode | null;
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
const easeCubicOut = (t: number) => 1 - Math.pow(1 - t, 3);
|
||||
|
||||
export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
function GraphCanvas(props, ref) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const minimapRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const simRef = useRef<ForceSimulation | null>(null);
|
||||
const nodeMapRef = useRef<Map<string, SimNode>>(new Map());
|
||||
const adjacencyRef = useRef<Map<string, Set<string>>>(new Map());
|
||||
const labelThresholdRef = useRef(3);
|
||||
|
||||
const transformRef = useRef<Transform>({ x: 0, y: 0, k: 0 });
|
||||
const sizeRef = useRef({ w: 0, h: 0 });
|
||||
const hoverRef = useRef<SimNode | null>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const animRef = useRef<{
|
||||
from: Transform;
|
||||
to: Transform;
|
||||
start: number;
|
||||
duration: number;
|
||||
} | null>(null);
|
||||
const needsDrawRef = useRef(true);
|
||||
const autoFitRef = useRef(false);
|
||||
const rafRef = useRef(0);
|
||||
|
||||
// Latest props readable from the rAF loop / event handlers.
|
||||
const propsRef = useRef(props);
|
||||
propsRef.current = props;
|
||||
|
||||
const requestDraw = useCallback(() => {
|
||||
needsDrawRef.current = true;
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------- build
|
||||
|
||||
useEffect(() => {
|
||||
const previous = nodeMapRef.current;
|
||||
const simNodes: SimNode[] = props.nodes.map((n) => {
|
||||
const degree = props.degrees.get(n.id) ?? 0;
|
||||
const kind = getNodeKind(n);
|
||||
const old = previous.get(n.id);
|
||||
return {
|
||||
id: n.id,
|
||||
node: n,
|
||||
kind,
|
||||
degree,
|
||||
radius: getNodeRadius(degree),
|
||||
color: NODE_KIND_META[kind].color,
|
||||
x: old?.x ?? Number.NaN,
|
||||
y: old?.y ?? Number.NaN,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
fx: old?.fx ?? null,
|
||||
fy: old?.fy ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const fresh = simNodes.filter((n) => Number.isNaN(n.x));
|
||||
if (fresh.length === simNodes.length) {
|
||||
seedPositions(simNodes);
|
||||
} else {
|
||||
// Filters re-enabled some nodes: spiral the newcomers in near the center.
|
||||
fresh.forEach((n, i) => {
|
||||
const r = 60 + 16 * Math.sqrt(i + 1);
|
||||
const angle = i * 2.3999632297;
|
||||
n.x = r * Math.cos(angle);
|
||||
n.y = r * Math.sin(angle);
|
||||
});
|
||||
}
|
||||
|
||||
const byId = new Map(simNodes.map((n) => [n.id, n]));
|
||||
const simEdges: SimEdge[] = [];
|
||||
const adjacency = new Map<string, Set<string>>();
|
||||
for (const e of props.edges) {
|
||||
const source = byId.get(e.source);
|
||||
const target = byId.get(e.target);
|
||||
if (!source || !target) continue;
|
||||
const label = getEdgeLabel(e);
|
||||
simEdges.push({
|
||||
id: getEdgeId(e),
|
||||
source,
|
||||
target,
|
||||
label,
|
||||
info: getEdgeInfo(label),
|
||||
weight: e.weight ?? 1,
|
||||
distance: getLinkDistance(label) + source.radius + target.radius,
|
||||
});
|
||||
if (!adjacency.has(source.id)) adjacency.set(source.id, new Set());
|
||||
if (!adjacency.has(target.id)) adjacency.set(target.id, new Set());
|
||||
adjacency.get(source.id)!.add(target.id);
|
||||
adjacency.get(target.id)!.add(source.id);
|
||||
}
|
||||
|
||||
// Label budget: always label hubs; ~20 labels max by default.
|
||||
const sortedDegrees = simNodes.map((n) => n.degree).sort((a, b) => b - a);
|
||||
labelThresholdRef.current = Math.max(
|
||||
2,
|
||||
sortedDegrees[Math.min(19, sortedDegrees.length - 1)] ?? 2,
|
||||
);
|
||||
|
||||
nodeMapRef.current = byId;
|
||||
adjacencyRef.current = adjacency;
|
||||
const sim = new ForceSimulation(simNodes, simEdges);
|
||||
if (props.layoutType === "circular") {
|
||||
applyCircularLayout(simNodes);
|
||||
sim.alpha = 0;
|
||||
} else {
|
||||
sim.alpha = previous.size === 0 ? 1 : 0.5;
|
||||
}
|
||||
simRef.current = sim;
|
||||
if (previous.size === 0) autoFitRef.current = false;
|
||||
requestDraw();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.nodes, props.edges, props.degrees]);
|
||||
|
||||
// Layout switching.
|
||||
const layoutRef = useRef(props.layoutType);
|
||||
useEffect(() => {
|
||||
if (layoutRef.current === props.layoutType) return;
|
||||
layoutRef.current = props.layoutType;
|
||||
const sim = simRef.current;
|
||||
if (!sim) return;
|
||||
if (props.layoutType === "circular") {
|
||||
applyCircularLayout(sim.nodes);
|
||||
sim.alpha = 0;
|
||||
} else {
|
||||
for (const n of sim.nodes) {
|
||||
n.fx = null;
|
||||
n.fy = null;
|
||||
}
|
||||
sim.reheat(0.9);
|
||||
}
|
||||
autoFitRef.current = false;
|
||||
requestDraw();
|
||||
}, [props.layoutType, requestDraw]);
|
||||
|
||||
// Redraw on highlight-affecting prop changes.
|
||||
useEffect(() => {
|
||||
requestDraw();
|
||||
}, [
|
||||
props.selectedNodeId,
|
||||
props.searchMatchIds,
|
||||
props.showLabels,
|
||||
props.explore,
|
||||
requestDraw,
|
||||
]);
|
||||
|
||||
// ------------------------------------------------------------ transform
|
||||
|
||||
const clampZoom = (k: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, k));
|
||||
|
||||
const setTransform = useCallback((t: Transform) => {
|
||||
transformRef.current = t;
|
||||
propsRef.current.onZoomChange(t.k);
|
||||
needsDrawRef.current = true;
|
||||
}, []);
|
||||
|
||||
const animateTo = useCallback((to: Transform, duration = 350) => {
|
||||
animRef.current = {
|
||||
from: { ...transformRef.current },
|
||||
to,
|
||||
start: performance.now(),
|
||||
duration,
|
||||
};
|
||||
needsDrawRef.current = true;
|
||||
}, []);
|
||||
|
||||
const zoomBy = useCallback(
|
||||
(factor: number, cx?: number, cy?: number) => {
|
||||
const t = transformRef.current;
|
||||
const { w, h } = sizeRef.current;
|
||||
const px = cx ?? w / 2;
|
||||
const py = cy ?? h / 2;
|
||||
const k = clampZoom(t.k * factor);
|
||||
const wx = (px - t.x) / t.k;
|
||||
const wy = (py - t.y) / t.k;
|
||||
setTransform({ x: px - wx * k, y: py - wy * k, k });
|
||||
},
|
||||
[setTransform],
|
||||
);
|
||||
|
||||
const fitView = useCallback(
|
||||
(animated = true) => {
|
||||
const sim = simRef.current;
|
||||
const { w, h } = sizeRef.current;
|
||||
if (!sim || !sim.nodes.length || !w || !h) return;
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const n of sim.nodes) {
|
||||
minX = Math.min(minX, n.x - n.radius);
|
||||
minY = Math.min(minY, n.y - n.radius);
|
||||
maxX = Math.max(maxX, n.x + n.radius);
|
||||
maxY = Math.max(maxY, n.y + n.radius);
|
||||
}
|
||||
const pad = 70;
|
||||
const bw = Math.max(maxX - minX, 1);
|
||||
const bh = Math.max(maxY - minY, 1);
|
||||
const k = clampZoom(Math.min((w - pad * 2) / bw, (h - pad * 2) / bh, 2.2));
|
||||
const cx = (minX + maxX) / 2;
|
||||
const cy = (minY + maxY) / 2;
|
||||
const target = { x: w / 2 - cx * k, y: h / 2 - cy * k, k };
|
||||
if (animated) animateTo(target);
|
||||
else setTransform(target);
|
||||
},
|
||||
[animateTo, setTransform],
|
||||
);
|
||||
|
||||
const focusNode = useCallback(
|
||||
(id: string) => {
|
||||
const node = nodeMapRef.current.get(id);
|
||||
const { w, h } = sizeRef.current;
|
||||
if (!node || !w || !h) return;
|
||||
const k = clampZoom(Math.max(transformRef.current.k, 1.4));
|
||||
animateTo({ x: w / 2 - node.x * k, y: h / 2 - node.y * k, k });
|
||||
},
|
||||
[animateTo],
|
||||
);
|
||||
|
||||
const relayout = useCallback(() => {
|
||||
const sim = simRef.current;
|
||||
if (!sim) return;
|
||||
if (layoutRef.current === "circular") {
|
||||
applyCircularLayout(sim.nodes);
|
||||
sim.alpha = 0;
|
||||
fitView(true);
|
||||
} else {
|
||||
seedPositions(sim.nodes);
|
||||
sim.alpha = 1;
|
||||
autoFitRef.current = false;
|
||||
}
|
||||
requestDraw();
|
||||
}, [fitView, requestDraw]);
|
||||
|
||||
const exportImage = useCallback(() => {
|
||||
const main = canvasRef.current;
|
||||
if (!main) return;
|
||||
const out = document.createElement("canvas");
|
||||
out.width = main.width;
|
||||
out.height = main.height;
|
||||
const ctx = out.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.fillStyle = "#0b1020";
|
||||
ctx.fillRect(0, 0, out.width, out.height);
|
||||
ctx.drawImage(main, 0, 0);
|
||||
out.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "knowledge-graph.png";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, "image/png");
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
zoomIn: () => zoomBy(1.25),
|
||||
zoomOut: () => zoomBy(1 / 1.25),
|
||||
fitView: () => fitView(true),
|
||||
relayout,
|
||||
focusNode,
|
||||
exportImage,
|
||||
}),
|
||||
[zoomBy, fitView, relayout, focusNode, exportImage],
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------- draw
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const sim = simRef.current;
|
||||
if (!canvas || !sim) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const { w, h } = sizeRef.current;
|
||||
const t = transformRef.current;
|
||||
const p = propsRef.current;
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.translate(t.x, t.y);
|
||||
ctx.scale(t.k, t.k);
|
||||
|
||||
const hover = hoverRef.current;
|
||||
const activeId = hover?.id ?? p.selectedNodeId;
|
||||
const neighbors = activeId ? adjacencyRef.current.get(activeId) : undefined;
|
||||
const search = p.searchMatchIds;
|
||||
const k = t.k || 1;
|
||||
const explore = p.explore;
|
||||
|
||||
// ---- edges
|
||||
for (const e of sim.edges) {
|
||||
const touchesActive =
|
||||
activeId !== null &&
|
||||
(e.source.id === activeId || e.target.id === activeId);
|
||||
let alpha: number;
|
||||
if (activeId) alpha = touchesActive ? 0.95 : 0.07;
|
||||
else if (search)
|
||||
alpha = search.has(e.source.id) || search.has(e.target.id) ? 0.75 : 0.06;
|
||||
else alpha = 0.38;
|
||||
|
||||
if (explore && !explore.preview) {
|
||||
const sourceDepth = explore.nodeDepthMap.get(e.source.id) ?? 0;
|
||||
const targetDepth = explore.nodeDepthMap.get(e.target.id) ?? 0;
|
||||
const edgeDepth = Math.max(sourceDepth, targetDepth);
|
||||
alpha *= Math.max(0.48, 1 - edgeDepth * 0.12);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = e.info.color;
|
||||
ctx.lineWidth = (touchesActive ? 2.4 : 1.2) / k;
|
||||
ctx.setLineDash(e.info.dashed ? [6 / k, 5 / k] : []);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(e.source.x, e.source.y);
|
||||
ctx.lineTo(e.target.x, e.target.y);
|
||||
ctx.stroke();
|
||||
|
||||
// Relation label at the midpoint while a node is focused.
|
||||
if (touchesActive && k >= 0.55) {
|
||||
const mx = (e.source.x + e.target.x) / 2;
|
||||
const my = (e.source.y + e.target.y) / 2;
|
||||
ctx.setLineDash([]);
|
||||
ctx.font = `${10.5 / k}px "Noto Sans SC", system-ui, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.lineWidth = 3 / k;
|
||||
ctx.strokeStyle = "rgba(2, 6, 23, 0.9)";
|
||||
ctx.strokeText(e.info.zh, mx, my);
|
||||
ctx.fillStyle = e.info.color;
|
||||
ctx.fillText(e.info.zh, mx, my);
|
||||
}
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// ---- nodes
|
||||
const labelThreshold = labelThresholdRef.current;
|
||||
for (const n of sim.nodes) {
|
||||
const isActive = n.id === activeId;
|
||||
const isSelected = n.id === p.selectedNodeId;
|
||||
const isNeighbor = neighbors?.has(n.id) ?? false;
|
||||
const depth = explore?.nodeDepthMap.get(n.id);
|
||||
const isExploreRoot = explore?.rootId === n.id;
|
||||
const isExpanded = explore?.expandedNodeIds.has(n.id) ?? false;
|
||||
let alpha: number;
|
||||
if (activeId) alpha = isActive || isNeighbor ? 1 : 0.14;
|
||||
else if (search) alpha = search.has(n.id) ? 1 : 0.14;
|
||||
else alpha = 1;
|
||||
|
||||
let baseRadius = n.radius;
|
||||
let labelByExplore = false;
|
||||
let strokeWidth = 1.4 / k;
|
||||
let strokeColor = "rgba(2, 6, 23, 0.55)";
|
||||
let fillColor = n.color;
|
||||
if (explore?.preview) {
|
||||
alpha *= 0.42;
|
||||
baseRadius = Math.max(8, n.radius * 0.86);
|
||||
labelByExplore = isActive || isSelected;
|
||||
} else if (explore && depth != null) {
|
||||
baseRadius = depth === 0 ? 34 : Math.max(14, 28 - depth * 4);
|
||||
alpha *= Math.max(0.45, 1 - depth * 0.12);
|
||||
labelByExplore = depth <= 2 || isActive || isSelected;
|
||||
strokeWidth = (depth === 0 ? 4 : isExpanded ? 3 : 1.5) / k;
|
||||
strokeColor = depth === 0 ? "#e0f2fe" : isExpanded ? "#7dd3fc" : "rgba(2, 6, 23, 0.55)";
|
||||
if (isExploreRoot) fillColor = "#38bdf8";
|
||||
}
|
||||
|
||||
const r = baseRadius * (n.id === hover?.id ? 1.15 : 1);
|
||||
ctx.globalAlpha = alpha;
|
||||
|
||||
// Glow for hubs and the active node.
|
||||
if (
|
||||
alpha > 0.5 &&
|
||||
(isActive || isSelected || isExploreRoot || isExpanded || n.degree >= labelThreshold)
|
||||
) {
|
||||
ctx.shadowColor = fillColor;
|
||||
ctx.shadowBlur = isActive || isSelected || isExploreRoot ? 24 : 12;
|
||||
} else {
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Rim.
|
||||
ctx.lineWidth = strokeWidth;
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.stroke();
|
||||
|
||||
if (isExploreRoot) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, r + 8 / k, 0, Math.PI * 2);
|
||||
ctx.lineWidth = 1.4 / k;
|
||||
ctx.strokeStyle = "rgba(125, 211, 252, 0.9)";
|
||||
ctx.stroke();
|
||||
} else if (isExpanded) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x + r * 0.58, n.y - r * 0.58, Math.max(4 / k, 3), 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#7dd3fc";
|
||||
ctx.fill();
|
||||
ctx.lineWidth = 1 / k;
|
||||
ctx.strokeStyle = "rgba(2, 6, 23, 0.8)";
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Selection: double ring.
|
||||
if (isSelected) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, r + 4 / k, 0, Math.PI * 2);
|
||||
ctx.lineWidth = 2 / k;
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, r + 8 / k, 0, Math.PI * 2);
|
||||
ctx.lineWidth = 1.2 / k;
|
||||
ctx.strokeStyle = n.color;
|
||||
ctx.stroke();
|
||||
} else if (n.id === hover?.id) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, r + 3.5 / k, 0, Math.PI * 2);
|
||||
ctx.lineWidth = 1.6 / k;
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, 0.85)";
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Pin marker for dragged-and-fixed nodes.
|
||||
if (n.fx != null && !isSelected) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, Math.max(r * 0.28, 2 / k), 0, Math.PI * 2);
|
||||
ctx.fillStyle = "rgba(255, 255, 255, 0.8)";
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Label.
|
||||
const wantsLabel =
|
||||
isActive ||
|
||||
isSelected ||
|
||||
isNeighbor ||
|
||||
labelByExplore ||
|
||||
(search?.has(n.id) ?? false) ||
|
||||
(p.showLabels && n.degree >= labelThreshold && k >= 0.45);
|
||||
if (wantsLabel && alpha > 0.5) {
|
||||
const text = truncateLabel(getNodeTitle(n.node), isActive ? 26 : 14);
|
||||
const fontSize = (isActive || isSelected ? 13 : 12) / k;
|
||||
ctx.font = `600 ${fontSize}px "Noto Sans SC", system-ui, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.lineWidth = 3.5 / k;
|
||||
ctx.strokeStyle = "rgba(2, 6, 23, 0.9)";
|
||||
ctx.strokeText(text, n.x, n.y + r + 5 / k);
|
||||
ctx.fillStyle = isActive || isSelected ? "#ffffff" : "#e2e8f0";
|
||||
ctx.fillText(text, n.x, n.y + r + 5 / k);
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
drawMinimap();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const drawMinimap = useCallback(() => {
|
||||
const minimap = minimapRef.current;
|
||||
const sim = simRef.current;
|
||||
if (!minimap || !sim || !sim.nodes.length) return;
|
||||
const ctx = minimap.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const mw = minimap.width;
|
||||
const mh = minimap.height;
|
||||
ctx.clearRect(0, 0, mw, mh);
|
||||
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const n of sim.nodes) {
|
||||
minX = Math.min(minX, n.x);
|
||||
minY = Math.min(minY, n.y);
|
||||
maxX = Math.max(maxX, n.x);
|
||||
maxY = Math.max(maxY, n.y);
|
||||
}
|
||||
const pad = 10;
|
||||
const scale = Math.min(
|
||||
(mw - pad * 2) / Math.max(maxX - minX, 1),
|
||||
(mh - pad * 2) / Math.max(maxY - minY, 1),
|
||||
);
|
||||
const ox = (mw - (maxX - minX) * scale) / 2 - minX * scale;
|
||||
const oy = (mh - (maxY - minY) * scale) / 2 - minY * scale;
|
||||
|
||||
for (const n of sim.nodes) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x * scale + ox, n.y * scale + oy, 1.6, 0, Math.PI * 2);
|
||||
ctx.fillStyle = n.color;
|
||||
ctx.globalAlpha = 0.9;
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Viewport rectangle.
|
||||
const t = transformRef.current;
|
||||
const { w, h } = sizeRef.current;
|
||||
if (t.k > 0 && w && h) {
|
||||
const x0 = ((0 - t.x) / t.k) * scale + ox;
|
||||
const y0 = ((0 - t.y) / t.k) * scale + oy;
|
||||
const x1 = ((w - t.x) / t.k) * scale + ox;
|
||||
const y1 = ((h - t.y) / t.k) * scale + oy;
|
||||
ctx.strokeStyle = "rgba(56, 189, 248, 0.8)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x0, y0, x1 - x0, y1 - y0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ----------------------------------------------------------------- loop
|
||||
|
||||
useEffect(() => {
|
||||
const loop = () => {
|
||||
const sim = simRef.current;
|
||||
let active = false;
|
||||
|
||||
const anim = animRef.current;
|
||||
if (anim) {
|
||||
const progress = Math.min(
|
||||
(performance.now() - anim.start) / anim.duration,
|
||||
1,
|
||||
);
|
||||
const e = easeCubicOut(progress);
|
||||
transformRef.current = {
|
||||
x: anim.from.x + (anim.to.x - anim.from.x) * e,
|
||||
y: anim.from.y + (anim.to.y - anim.from.y) * e,
|
||||
k: anim.from.k + (anim.to.k - anim.from.k) * e,
|
||||
};
|
||||
if (progress >= 1) {
|
||||
animRef.current = null;
|
||||
propsRef.current.onZoomChange(transformRef.current.k);
|
||||
}
|
||||
active = true;
|
||||
}
|
||||
|
||||
if (sim && layoutRef.current === "force" && sim.tick()) {
|
||||
active = true;
|
||||
// Auto-fit once after the initial layout settles.
|
||||
if (!autoFitRef.current && sim.alpha < 0.3) {
|
||||
autoFitRef.current = true;
|
||||
fitView(true);
|
||||
}
|
||||
} else if (sim && !autoFitRef.current && sim.nodes.length) {
|
||||
autoFitRef.current = true;
|
||||
fitView(false);
|
||||
}
|
||||
|
||||
if (active || needsDrawRef.current) {
|
||||
needsDrawRef.current = false;
|
||||
draw();
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(loop);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, [draw, fitView]);
|
||||
|
||||
// --------------------------------------------------------------- resize
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!container || !canvas) return;
|
||||
let timer: number | undefined;
|
||||
const apply = () => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
sizeRef.current = { w: rect.width, h: rect.height };
|
||||
canvas.width = Math.max(1, Math.round(rect.width * dpr));
|
||||
canvas.height = Math.max(1, Math.round(rect.height * dpr));
|
||||
if (transformRef.current.k === 0) {
|
||||
setTransform({ x: rect.width / 2, y: rect.height / 2, k: 0.9 });
|
||||
}
|
||||
requestDraw();
|
||||
};
|
||||
apply();
|
||||
const observer = new ResizeObserver(() => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(apply, 120);
|
||||
});
|
||||
observer.observe(container);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [requestDraw, setTransform]);
|
||||
|
||||
// --------------------------------------------------------------- events
|
||||
|
||||
const screenToWorld = (sx: number, sy: number) => {
|
||||
const t = transformRef.current;
|
||||
return { x: (sx - t.x) / t.k, y: (sy - t.y) / t.k };
|
||||
};
|
||||
|
||||
const hitTest = (wx: number, wy: number): SimNode | null => {
|
||||
const sim = simRef.current;
|
||||
if (!sim) return null;
|
||||
// Topmost (drawn last) first.
|
||||
for (let i = sim.nodes.length - 1; i >= 0; i--) {
|
||||
const n = sim.nodes[i];
|
||||
const dx = wx - n.x;
|
||||
const dy = wy - n.y;
|
||||
const r = n.radius + 3 / (transformRef.current.k || 1);
|
||||
if (dx * dx + dy * dy <= r * r) return n;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const localPoint = (e: { clientX: number; clientY: number }) => {
|
||||
const rect = canvasRef.current!.getBoundingClientRect();
|
||||
return { sx: e.clientX - rect.left, sy: e.clientY - rect.top };
|
||||
};
|
||||
|
||||
const setHover = (node: SimNode | null, e?: React.PointerEvent) => {
|
||||
if (hoverRef.current?.id !== node?.id) {
|
||||
hoverRef.current = node;
|
||||
requestDraw();
|
||||
}
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) canvas.style.cursor = node ? "pointer" : "grab";
|
||||
if (node && e) {
|
||||
propsRef.current.onTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
node: node.node,
|
||||
kind: node.kind,
|
||||
degree: node.degree,
|
||||
});
|
||||
} else {
|
||||
propsRef.current.onTooltip(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
const { sx, sy } = localPoint(e);
|
||||
const { x: wx, y: wy } = screenToWorld(sx, sy);
|
||||
const node = hitTest(wx, wy);
|
||||
animRef.current = null;
|
||||
dragRef.current = {
|
||||
pointerId: e.pointerId,
|
||||
node,
|
||||
startX: sx,
|
||||
startY: sy,
|
||||
originX: transformRef.current.x,
|
||||
originY: transformRef.current.y,
|
||||
moved: false,
|
||||
};
|
||||
if (node) {
|
||||
node.fx = node.x;
|
||||
node.fy = node.y;
|
||||
}
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const { sx, sy } = localPoint(e);
|
||||
const drag = dragRef.current;
|
||||
if (drag && drag.pointerId === e.pointerId) {
|
||||
const dx = sx - drag.startX;
|
||||
const dy = sy - drag.startY;
|
||||
if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;
|
||||
if (drag.node) {
|
||||
const world = screenToWorld(sx, sy);
|
||||
drag.node.fx = world.x;
|
||||
drag.node.fy = world.y;
|
||||
if (layoutRef.current === "force") simRef.current?.reheat(0.3);
|
||||
propsRef.current.onTooltip(null);
|
||||
} else {
|
||||
transformRef.current = {
|
||||
...transformRef.current,
|
||||
x: drag.originX + dx,
|
||||
y: drag.originY + dy,
|
||||
};
|
||||
}
|
||||
requestDraw();
|
||||
return;
|
||||
}
|
||||
const world = screenToWorld(sx, sy);
|
||||
setHover(hitTest(world.x, world.y), e);
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== e.pointerId) return;
|
||||
dragRef.current = null;
|
||||
if (!drag.moved) {
|
||||
// Plain click: select node / clear selection on empty space.
|
||||
if (drag.node) {
|
||||
drag.node.fx = null;
|
||||
drag.node.fy = null;
|
||||
propsRef.current.onSelectNode(drag.node.id);
|
||||
} else {
|
||||
propsRef.current.onSelectNode(null);
|
||||
}
|
||||
requestDraw();
|
||||
}
|
||||
// A real drag leaves the node pinned at its drop position.
|
||||
};
|
||||
|
||||
const handleDoubleClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const { sx, sy } = localPoint(e);
|
||||
const world = screenToWorld(sx, sy);
|
||||
const node = hitTest(world.x, world.y);
|
||||
if (node) {
|
||||
propsRef.current.onSelectNode(node.id);
|
||||
focusNode(node.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
setHover(null);
|
||||
};
|
||||
|
||||
// Wheel zoom needs a non-passive listener to preventDefault.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
zoomBy(
|
||||
Math.exp(-e.deltaY * 0.0012),
|
||||
e.clientX - rect.left,
|
||||
e.clientY - rect.top,
|
||||
);
|
||||
};
|
||||
canvas.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => canvas.removeEventListener("wheel", onWheel);
|
||||
}, [zoomBy]);
|
||||
|
||||
const minimapVisible = useMemo(() => props.nodes.length > 0, [props.nodes]);
|
||||
|
||||
return (
|
||||
<div className="kg-canvas-wrap" ref={containerRef}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="kg-canvas"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
/>
|
||||
{props.toolbar ? (
|
||||
<div className="kg-canvas-toolbar">{props.toolbar}</div>
|
||||
) : null}
|
||||
{props.hint}
|
||||
{minimapVisible ? (
|
||||
<canvas ref={minimapRef} className="kg-minimap" width={180} height={120} />
|
||||
) : null}
|
||||
{props.overlay}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,284 @@
|
||||
import {
|
||||
Compass,
|
||||
Crosshair,
|
||||
EyeOff,
|
||||
FileText,
|
||||
Loader2,
|
||||
MousePointerClick,
|
||||
Waypoints,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { KnowledgeNote } from "@/core/knowledge/types";
|
||||
|
||||
import type { GraphNode } from "./graphTypes";
|
||||
import {
|
||||
getEdgeInfo,
|
||||
getNodeKind,
|
||||
getNodeTitle,
|
||||
NODE_KIND_META,
|
||||
} from "./graphUtils";
|
||||
|
||||
const SOURCE_TYPE_ZH: Record<string, string> = {
|
||||
thread: "对话沉淀",
|
||||
search: "搜索沉淀",
|
||||
tool: "工具结果",
|
||||
file: "文件导入",
|
||||
manual: "手动创建",
|
||||
report: "报告",
|
||||
};
|
||||
|
||||
const STATUS_ZH: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
approved: "已审核",
|
||||
archived: "已归档",
|
||||
};
|
||||
|
||||
export interface NeighborItem {
|
||||
node: GraphNode;
|
||||
relation: string;
|
||||
}
|
||||
|
||||
interface GraphDetailPanelProps {
|
||||
node: GraphNode | null;
|
||||
degree: number;
|
||||
neighbors: NeighborItem[];
|
||||
/** Note detail (only for note-type nodes; undefined while absent/loading). */
|
||||
note: KnowledgeNote | undefined;
|
||||
noteLoading: boolean;
|
||||
/** Explore-mode context; null when in full mode. */
|
||||
explore: {
|
||||
/** Depth of this node in the explore graph; null = not yet visible. */
|
||||
depth: number | null;
|
||||
expanded: boolean;
|
||||
isRoot: boolean;
|
||||
} | null;
|
||||
onViewSource: (noteId: string) => void;
|
||||
onLocate: (id: string) => void;
|
||||
onHide: (id: string) => void;
|
||||
onPickNeighbor: (id: string) => void;
|
||||
onExpandNode: (id: string) => void;
|
||||
onSetExploreRoot: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatTime(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
export function GraphDetailPanel(props: GraphDetailPanelProps) {
|
||||
const { node } = props;
|
||||
|
||||
return (
|
||||
<aside className="kg-panel kg-detail">
|
||||
<div className="kg-panel-header">
|
||||
<span className="kg-panel-title">节点详情</span>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-panel-close-btn"
|
||||
title="收起详情面板"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
{!node ? (
|
||||
<div className="kg-detail-empty">
|
||||
<MousePointerClick size={28} />
|
||||
<div className="kg-detail-empty-title">选择一个节点查看详情</div>
|
||||
<div>
|
||||
点击图谱中的文档、主题或实体节点,
|
||||
<br />
|
||||
查看摘要、标签、关联关系和相邻节点。
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<DetailBody {...props} node={node} />
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailBody(props: GraphDetailPanelProps & { node: GraphNode }) {
|
||||
const { node, note } = props;
|
||||
const kind = getNodeKind(node);
|
||||
const kindMeta = NODE_KIND_META[kind];
|
||||
const isNote = (node.type || "").toLowerCase() === "note";
|
||||
|
||||
return (
|
||||
<div className="kg-panel-content">
|
||||
<h2 className="kg-detail-title">{getNodeTitle(node)}</h2>
|
||||
<div>
|
||||
<span
|
||||
className="kg-detail-type"
|
||||
style={{
|
||||
background: `${kindMeta.color}26`,
|
||||
color: kindMeta.color,
|
||||
}}
|
||||
>
|
||||
<span className="kg-legend-dot" style={{ background: kindMeta.color }} />
|
||||
{kindMeta.label}
|
||||
{node.category && node.category !== kindMeta.label
|
||||
? ` · ${node.category}`
|
||||
: ""}
|
||||
</span>
|
||||
<span
|
||||
className="kg-detail-type"
|
||||
style={{ background: "rgba(148, 163, 184, 0.12)", color: "#cbd5e1" }}
|
||||
>
|
||||
度数 {props.degree}
|
||||
</span>
|
||||
{props.explore && props.explore.depth !== null ? (
|
||||
<>
|
||||
<span
|
||||
className="kg-detail-type"
|
||||
style={{ background: "rgba(56, 189, 248, 0.14)", color: "#7dd3fc" }}
|
||||
>
|
||||
{props.explore.isRoot
|
||||
? "探索起点"
|
||||
: `第 ${props.explore.depth} 层`}
|
||||
</span>
|
||||
<span
|
||||
className="kg-detail-type"
|
||||
style={
|
||||
props.explore.expanded
|
||||
? { background: "rgba(52, 211, 153, 0.14)", color: "#6ee7b7" }
|
||||
: { background: "rgba(251, 191, 36, 0.14)", color: "#fcd34d" }
|
||||
}
|
||||
>
|
||||
{props.explore.expanded ? "已展开" : "可展开"}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{props.noteLoading ? (
|
||||
<div className="kg-detail-summary" style={{ display: "flex", gap: 8 }}>
|
||||
<Loader2 size={14} className="animate-spin" /> 加载笔记详情...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{note?.summary ? <p className="kg-detail-summary">{note.summary}</p> : null}
|
||||
|
||||
{note?.tags?.length ? (
|
||||
<div className="kg-tag-list">
|
||||
{note.tags.map((tag) => (
|
||||
<span key={tag} className="kg-tag">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{note ? (
|
||||
<div className="kg-detail-meta">
|
||||
<span className="kg-detail-meta-label">来源</span>
|
||||
<span className="kg-detail-meta-value">
|
||||
{SOURCE_TYPE_ZH[note.source_type] ?? note.source_type}
|
||||
</span>
|
||||
<span className="kg-detail-meta-label">状态</span>
|
||||
<span className="kg-detail-meta-value">
|
||||
{STATUS_ZH[note.status] ?? note.status}
|
||||
</span>
|
||||
<span className="kg-detail-meta-label">创建时间</span>
|
||||
<span className="kg-detail-meta-value">{formatTime(note.created_at)}</span>
|
||||
<span className="kg-detail-meta-label">更新时间</span>
|
||||
<span className="kg-detail-meta-value">{formatTime(note.updated_at)}</span>
|
||||
{note.created_by_name ? (
|
||||
<>
|
||||
<span className="kg-detail-meta-label">创建人</span>
|
||||
<span className="kg-detail-meta-value">{note.created_by_name}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="kg-detail-actions">
|
||||
{isNote ? (
|
||||
<button
|
||||
type="button"
|
||||
className="kg-icon-button"
|
||||
onClick={() => props.onViewSource(node.id)}
|
||||
>
|
||||
<FileText size={14} /> 查看原文
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="kg-icon-button"
|
||||
onClick={() => props.onLocate(node.id)}
|
||||
>
|
||||
<Crosshair size={14} /> 定位节点
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-icon-button"
|
||||
onClick={() => props.onHide(node.id)}
|
||||
>
|
||||
<EyeOff size={14} /> 隐藏节点
|
||||
</button>
|
||||
{props.explore ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-icon-button"
|
||||
disabled={props.explore.depth === null || props.explore.expanded}
|
||||
title={
|
||||
props.explore.expanded
|
||||
? "该节点的一跳邻居已全部展开"
|
||||
: "展开该节点的一跳邻居"
|
||||
}
|
||||
onClick={() => props.onExpandNode(node.id)}
|
||||
>
|
||||
<Waypoints size={14} /> 展开一跳邻居
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-icon-button"
|
||||
title="清空当前探索路径,以此节点为新起点"
|
||||
onClick={() => props.onSetExploreRoot(node.id)}
|
||||
>
|
||||
<Compass size={14} /> 以此为新起点
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h3 className="kg-detail-section-title">
|
||||
关联节点({props.neighbors.length})
|
||||
</h3>
|
||||
{props.neighbors.length === 0 ? (
|
||||
<div className="kg-search-hint">该节点暂无可见的关联节点</div>
|
||||
) : (
|
||||
<div className="kg-neighbor-list">
|
||||
{props.neighbors.map(({ node: neighbor, relation }) => {
|
||||
const neighborKind = NODE_KIND_META[getNodeKind(neighbor)];
|
||||
const relationInfo = getEdgeInfo(relation);
|
||||
return (
|
||||
<div
|
||||
key={`${neighbor.id}:${relation}`}
|
||||
className="kg-neighbor-item"
|
||||
onClick={() => props.onPickNeighbor(neighbor.id)}
|
||||
>
|
||||
<span
|
||||
className="kg-legend-dot"
|
||||
style={{ background: neighborKind.color, flexShrink: 0 }}
|
||||
/>
|
||||
<span className="kg-neighbor-title">{getNodeTitle(neighbor)}</span>
|
||||
<span
|
||||
className="kg-neighbor-relation"
|
||||
style={{ color: relationInfo.color }}
|
||||
>
|
||||
{relationInfo.zh}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { NodeKind } from "./graphTypes";
|
||||
import { getEdgeInfo, NODE_KIND_META, NODE_KIND_ORDER } from "./graphUtils";
|
||||
|
||||
interface GraphFooterProps {
|
||||
nodeKindCounts: Map<NodeKind, number>;
|
||||
edgeLabels: string[];
|
||||
zoomLevel: number;
|
||||
selectedTitle: string | null;
|
||||
visibleNodes: number;
|
||||
visibleEdges: number;
|
||||
}
|
||||
|
||||
export function GraphFooter(props: GraphFooterProps) {
|
||||
const kinds = NODE_KIND_ORDER.filter((k) => (props.nodeKindCounts.get(k) ?? 0) > 0);
|
||||
|
||||
return (
|
||||
<footer className="kg-footer">
|
||||
<div className="kg-legend">
|
||||
{kinds.map((kind) => (
|
||||
<span key={kind} className="kg-legend-item">
|
||||
<span
|
||||
className="kg-legend-dot"
|
||||
style={{ background: NODE_KIND_META[kind].color }}
|
||||
/>
|
||||
{NODE_KIND_META[kind].label}
|
||||
</span>
|
||||
))}
|
||||
{props.edgeLabels.slice(0, 6).map((label) => {
|
||||
const info = getEdgeInfo(label);
|
||||
return (
|
||||
<span key={label} className="kg-legend-item">
|
||||
<span
|
||||
className="kg-legend-line"
|
||||
style={{
|
||||
borderTopColor: info.color,
|
||||
borderTopStyle: info.dashed ? "dashed" : "solid",
|
||||
}}
|
||||
/>
|
||||
{info.zh}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="kg-footer-status">
|
||||
{props.selectedTitle ? (
|
||||
<span className="kg-footer-selected" title={props.selectedTitle}>
|
||||
选中:{props.selectedTitle}
|
||||
</span>
|
||||
) : null}
|
||||
<span>
|
||||
显示 {props.visibleNodes} 节点 · {props.visibleEdges} 关系
|
||||
</span>
|
||||
<span>缩放 {Math.round(props.zoomLevel * 100)}%</span>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
RefreshCw,
|
||||
Scan,
|
||||
Shuffle,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from "lucide-react";
|
||||
|
||||
interface GraphHeaderProps {
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
totalNodes: number;
|
||||
totalEdges: number;
|
||||
refreshing: boolean;
|
||||
onBack: () => void;
|
||||
onRelayout: () => void;
|
||||
onFitView: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onExport: () => void;
|
||||
onFullscreen: () => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function GraphHeader(props: GraphHeaderProps) {
|
||||
const filteredNodes =
|
||||
props.nodeCount === props.totalNodes
|
||||
? `${props.totalNodes}`
|
||||
: `${props.nodeCount}/${props.totalNodes}`;
|
||||
const filteredEdges =
|
||||
props.edgeCount === props.totalEdges
|
||||
? `${props.totalEdges}`
|
||||
: `${props.edgeCount}/${props.totalEdges}`;
|
||||
|
||||
return (
|
||||
<header className="kg-header">
|
||||
<div className="kg-header-left">
|
||||
<button type="button" className="kg-back" onClick={props.onBack}>
|
||||
<ArrowLeft size={15} />
|
||||
<span className="kg-button-text">返回</span>
|
||||
</button>
|
||||
<div className="kg-title-block">
|
||||
<h1 className="kg-title">知识图谱</h1>
|
||||
<span className="kg-subtitle">知识关系可视化</span>
|
||||
</div>
|
||||
<div className="kg-stat-pills">
|
||||
<span className="kg-stat-pill">{filteredNodes} 个节点</span>
|
||||
<span className="kg-stat-pill kg-stat-pill-violet">
|
||||
{filteredEdges} 条关系
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kg-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="kg-icon-button"
|
||||
title="重新布局"
|
||||
onClick={props.onRelayout}
|
||||
>
|
||||
<Shuffle size={15} />
|
||||
<span className="kg-button-text">重新布局</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-icon-button"
|
||||
title="适应视图"
|
||||
onClick={props.onFitView}
|
||||
>
|
||||
<Scan size={15} />
|
||||
<span className="kg-button-text">适应视图</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-icon-button"
|
||||
title="放大"
|
||||
onClick={props.onZoomIn}
|
||||
>
|
||||
<ZoomIn size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-icon-button"
|
||||
title="缩小"
|
||||
onClick={props.onZoomOut}
|
||||
>
|
||||
<ZoomOut size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-icon-button"
|
||||
title="导出图片"
|
||||
onClick={props.onExport}
|
||||
>
|
||||
<Download size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-icon-button"
|
||||
title="全屏"
|
||||
onClick={props.onFullscreen}
|
||||
>
|
||||
<Maximize2 size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-icon-button"
|
||||
title="刷新图谱数据"
|
||||
onClick={props.onRefresh}
|
||||
disabled={props.refreshing}
|
||||
>
|
||||
{props.refreshing ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={15} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { ChevronDown, ChevronRight, Search, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { GraphNode, GraphStats, NodeKind } from "./graphTypes";
|
||||
import {
|
||||
getEdgeInfo,
|
||||
getNodeTitle,
|
||||
NODE_KIND_META,
|
||||
NODE_KIND_ORDER,
|
||||
} from "./graphUtils";
|
||||
|
||||
interface GraphSidebarProps {
|
||||
searchKeyword: string;
|
||||
searchMatchCount: number | null;
|
||||
nodeKindCounts: Map<NodeKind, number>;
|
||||
edgeLabelCounts: Map<string, number>;
|
||||
disabledNodeKinds: Set<NodeKind>;
|
||||
disabledEdgeLabels: Set<string>;
|
||||
stats: GraphStats;
|
||||
topNodes: Array<{ node: GraphNode; degree: number }>;
|
||||
hiddenCount: number;
|
||||
onSearchChange: (keyword: string) => void;
|
||||
onSearchEnter: () => void;
|
||||
onToggleNodeKind: (kind: NodeKind) => void;
|
||||
onToggleEdgeLabel: (label: string) => void;
|
||||
onRestoreHidden: () => void;
|
||||
onPickNode: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type SectionKey = "nodeTypes" | "edgeTypes" | "graphStats" | "coreNodes";
|
||||
|
||||
interface FilterSectionProps {
|
||||
title: string;
|
||||
extra?: string;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function FilterSection(props: FilterSectionProps) {
|
||||
return (
|
||||
<section className="kg-filter-section">
|
||||
<button
|
||||
type="button"
|
||||
className="kg-filter-section-header"
|
||||
onClick={props.onToggle}
|
||||
aria-expanded={!props.collapsed}
|
||||
>
|
||||
<span>{props.title}</span>
|
||||
<span className="kg-filter-section-extra">
|
||||
{props.extra}
|
||||
{props.collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||
</span>
|
||||
</button>
|
||||
{!props.collapsed ? (
|
||||
<div className="kg-filter-section-body">{props.children}</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function GraphSidebar(props: GraphSidebarProps) {
|
||||
// 节点类型 / 关系类型默认收起,统计与核心节点默认展开。
|
||||
const [collapsedSections, setCollapsedSections] = useState<
|
||||
Record<SectionKey, boolean>
|
||||
>({
|
||||
nodeTypes: true,
|
||||
edgeTypes: true,
|
||||
graphStats: false,
|
||||
coreNodes: false,
|
||||
});
|
||||
|
||||
const toggleFilterSection = (key: SectionKey) => {
|
||||
setCollapsedSections((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
const kinds = NODE_KIND_ORDER.filter((k) => (props.nodeKindCounts.get(k) ?? 0) > 0);
|
||||
const edgeLabels = [...props.edgeLabelCounts.entries()].sort((a, b) => b[1] - a[1]);
|
||||
|
||||
return (
|
||||
<aside className="kg-panel kg-sidebar">
|
||||
<div className="kg-panel-header">
|
||||
<span className="kg-panel-title">搜索与筛选</span>
|
||||
<div className="kg-panel-header-actions">
|
||||
{props.hiddenCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="kg-text-button"
|
||||
onClick={props.onRestoreHidden}
|
||||
>
|
||||
恢复 {props.hiddenCount} 个隐藏节点
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="kg-panel-close-btn"
|
||||
title="收起筛选面板"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kg-panel-content">
|
||||
<div className="kg-search-wrap">
|
||||
<Search size={14} />
|
||||
<input
|
||||
className="kg-search"
|
||||
placeholder="搜索节点标题..."
|
||||
value={props.searchKeyword}
|
||||
onChange={(e) => props.onSearchChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") props.onSearchEnter();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{props.searchMatchCount !== null ? (
|
||||
<div
|
||||
className={`kg-search-hint${props.searchMatchCount === 0 ? " kg-search-hint-warn" : ""}`}
|
||||
>
|
||||
{props.searchMatchCount === 0
|
||||
? "没有匹配的节点,换个关键词试试"
|
||||
: `匹配 ${props.searchMatchCount} 个节点,回车定位`}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FilterSection
|
||||
title="节点类型"
|
||||
extra={`${kinds.length} 类`}
|
||||
collapsed={collapsedSections.nodeTypes}
|
||||
onToggle={() => toggleFilterSection("nodeTypes")}
|
||||
>
|
||||
{kinds.map((kind) => (
|
||||
<label key={kind} className="kg-check-row">
|
||||
<span className="kg-check-row-main">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!props.disabledNodeKinds.has(kind)}
|
||||
onChange={() => props.onToggleNodeKind(kind)}
|
||||
/>
|
||||
<span
|
||||
className="kg-legend-dot"
|
||||
style={{ background: NODE_KIND_META[kind].color }}
|
||||
/>
|
||||
<span className="kg-check-label">{NODE_KIND_META[kind].label}</span>
|
||||
</span>
|
||||
<span className="kg-count">{props.nodeKindCounts.get(kind)}</span>
|
||||
</label>
|
||||
))}
|
||||
</FilterSection>
|
||||
|
||||
{edgeLabels.length > 0 ? (
|
||||
<FilterSection
|
||||
title="关系类型"
|
||||
extra={`${edgeLabels.length} 类`}
|
||||
collapsed={collapsedSections.edgeTypes}
|
||||
onToggle={() => toggleFilterSection("edgeTypes")}
|
||||
>
|
||||
{edgeLabels.map(([label, count]) => {
|
||||
const info = getEdgeInfo(label);
|
||||
return (
|
||||
<label key={label} className="kg-check-row">
|
||||
<span className="kg-check-row-main">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!props.disabledEdgeLabels.has(label)}
|
||||
onChange={() => props.onToggleEdgeLabel(label)}
|
||||
/>
|
||||
<span
|
||||
className="kg-legend-line"
|
||||
style={{
|
||||
borderTopColor: info.color,
|
||||
borderTopStyle: info.dashed ? "dashed" : "solid",
|
||||
}}
|
||||
/>
|
||||
<span className="kg-check-label">{info.zh}</span>
|
||||
</span>
|
||||
<span className="kg-count">{count}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</FilterSection>
|
||||
) : null}
|
||||
|
||||
<FilterSection
|
||||
title="图谱统计"
|
||||
collapsed={collapsedSections.graphStats}
|
||||
onToggle={() => toggleFilterSection("graphStats")}
|
||||
>
|
||||
<div className="kg-stats-grid">
|
||||
<div className="kg-stat-card">
|
||||
<div className="kg-stat-card-value">{props.stats.nodes}</div>
|
||||
<div className="kg-stat-card-label">节点</div>
|
||||
</div>
|
||||
<div className="kg-stat-card">
|
||||
<div className="kg-stat-card-value">{props.stats.edges}</div>
|
||||
<div className="kg-stat-card-label">关系</div>
|
||||
</div>
|
||||
<div className="kg-stat-card">
|
||||
<div className="kg-stat-card-value">
|
||||
{props.stats.avgDegree.toFixed(2)}
|
||||
</div>
|
||||
<div className="kg-stat-card-label">平均度</div>
|
||||
</div>
|
||||
<div className="kg-stat-card">
|
||||
<div className="kg-stat-card-value">{props.stats.largestComponent}</div>
|
||||
<div className="kg-stat-card-label">最大连通分量</div>
|
||||
</div>
|
||||
</div>
|
||||
</FilterSection>
|
||||
|
||||
{props.topNodes.length > 0 ? (
|
||||
<FilterSection
|
||||
title="核心节点"
|
||||
extra={`${props.topNodes.length} 个`}
|
||||
collapsed={collapsedSections.coreNodes}
|
||||
onToggle={() => toggleFilterSection("coreNodes")}
|
||||
>
|
||||
{props.topNodes.map(({ node, degree }, i) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className="kg-rank-item"
|
||||
onClick={() => props.onPickNode(node.id)}
|
||||
>
|
||||
<span className="kg-rank-index">{i + 1}</span>
|
||||
<span className="kg-rank-title">{getNodeTitle(node)}</span>
|
||||
<span className="kg-rank-degree">{degree} 度</span>
|
||||
</div>
|
||||
))}
|
||||
</FilterSection>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { TooltipState } from "./graphTypes";
|
||||
import { getNodeTitle, NODE_KIND_META } from "./graphUtils";
|
||||
|
||||
interface GraphTooltipProps {
|
||||
tooltip: TooltipState | null;
|
||||
}
|
||||
|
||||
export function GraphTooltip({ tooltip }: GraphTooltipProps) {
|
||||
if (!tooltip) return null;
|
||||
const meta = NODE_KIND_META[tooltip.kind];
|
||||
// Keep the tooltip inside the viewport.
|
||||
const left = Math.min(tooltip.x + 14, window.innerWidth - 336);
|
||||
const top = Math.min(tooltip.y + 14, window.innerHeight - 120);
|
||||
|
||||
return (
|
||||
<div className="kg-tooltip" style={{ left, top }}>
|
||||
<div className="kg-tooltip-title">{getNodeTitle(tooltip.node)}</div>
|
||||
<div className="kg-tooltip-desc">
|
||||
<span className="kg-legend-item">
|
||||
<span className="kg-legend-dot" style={{ background: meta.color }} />
|
||||
{meta.label}
|
||||
</span>
|
||||
{tooltip.node.category ? <span>· {tooltip.node.category}</span> : null}
|
||||
<span>· 度数 {tooltip.degree}</span>
|
||||
</div>
|
||||
<div className="kg-tooltip-hint">单击查看详情 · 双击聚焦 · 拖拽固定位置</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,919 @@
|
||||
/* Knowledge-graph workbench — dark, professional analysis-tool styling. */
|
||||
|
||||
.kg-page {
|
||||
--kg-bg: #0b1020;
|
||||
--kg-panel: rgba(15, 23, 42, 0.78);
|
||||
--kg-panel-solid: #0f172a;
|
||||
--kg-border: rgba(148, 163, 184, 0.18);
|
||||
--kg-text-main: #e5e7eb;
|
||||
--kg-text-secondary: #94a3b8;
|
||||
--kg-text-muted: #64748b;
|
||||
--kg-primary: #38bdf8;
|
||||
--kg-primary-soft: rgba(56, 189, 248, 0.16);
|
||||
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 10%, rgba(56, 189, 248, 0.1), transparent 32%),
|
||||
radial-gradient(circle at 80% 20%, rgba(167, 139, 250, 0.1), transparent 28%),
|
||||
#0b1020;
|
||||
color: var(--kg-text-main);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ header */
|
||||
|
||||
.kg-header {
|
||||
height: 56px;
|
||||
border-bottom: 1px solid var(--kg-border);
|
||||
background: rgba(15, 23, 42, 0.86);
|
||||
backdrop-filter: blur(14px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kg-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kg-back {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--kg-text-main);
|
||||
font-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kg-back:hover {
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.kg-title-block {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kg-title {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kg-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--kg-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kg-stat-pills {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kg-stat-pill {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
border: 1px solid rgba(56, 189, 248, 0.28);
|
||||
color: #bae6fd;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kg-stat-pill.kg-stat-pill-violet {
|
||||
background: rgba(167, 139, 250, 0.12);
|
||||
border-color: rgba(167, 139, 250, 0.28);
|
||||
color: #ddd6fe;
|
||||
}
|
||||
|
||||
.kg-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kg-icon-button {
|
||||
height: 34px;
|
||||
min-width: 34px;
|
||||
padding: 0 8px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--kg-border);
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
color: #cbd5e1;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.kg-icon-button:hover {
|
||||
color: #ffffff;
|
||||
background: rgba(56, 189, 248, 0.14);
|
||||
border-color: rgba(56, 189, 248, 0.35);
|
||||
}
|
||||
|
||||
.kg-icon-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.kg-icon-button.kg-icon-button-active {
|
||||
color: #ffffff;
|
||||
background: rgba(56, 189, 248, 0.18);
|
||||
border-color: rgba(56, 189, 248, 0.45);
|
||||
}
|
||||
|
||||
.kg-header-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--kg-border);
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- body */
|
||||
|
||||
.kg-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr) 320px;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.kg-body.kg-hide-left {
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
}
|
||||
|
||||
.kg-body.kg-hide-right {
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.kg-body.kg-hide-left.kg-hide-right {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.kg-panel {
|
||||
background: var(--kg-panel);
|
||||
border: 1px solid var(--kg-border);
|
||||
border-radius: 16px;
|
||||
backdrop-filter: blur(14px);
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.kg-panel-header {
|
||||
height: 46px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kg-panel-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kg-panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--kg-text-main);
|
||||
}
|
||||
|
||||
.kg-panel-close-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
color: #94a3b8;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kg-panel-close-btn:hover {
|
||||
color: #ffffff;
|
||||
background: rgba(56, 189, 248, 0.14);
|
||||
border-color: rgba(56, 189, 248, 0.35);
|
||||
}
|
||||
|
||||
.kg-panel-content {
|
||||
padding: 14px;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.3) transparent;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- sidebar */
|
||||
|
||||
.kg-search-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.kg-search-wrap > svg {
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--kg-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.kg-search {
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(2, 6, 23, 0.45);
|
||||
color: var(--kg-text-main);
|
||||
padding: 0 12px 0 32px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.kg-search::placeholder {
|
||||
color: var(--kg-text-muted);
|
||||
}
|
||||
|
||||
.kg-search:focus {
|
||||
border-color: rgba(56, 189, 248, 0.55);
|
||||
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.1);
|
||||
}
|
||||
|
||||
.kg-search-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--kg-text-muted);
|
||||
}
|
||||
|
||||
.kg-search-hint.kg-search-hint-warn {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.kg-filter-section {
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.1);
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.kg-filter-section:first-of-type {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.kg-filter-section-header {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #cbd5e1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.kg-filter-section-header:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.kg-filter-section-extra {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.kg-filter-section-body {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.kg-check-row {
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: #cbd5e1;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
padding: 0 6px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.kg-check-row:hover {
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.kg-check-row-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kg-check-row input[type="checkbox"] {
|
||||
accent-color: #38bdf8;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kg-check-row .kg-legend-dot {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kg-check-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kg-count {
|
||||
color: var(--kg-text-muted);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kg-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kg-stat-card {
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
background: rgba(2, 6, 23, 0.35);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.kg-stat-card-value {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.kg-stat-card-label {
|
||||
font-size: 11px;
|
||||
color: var(--kg-text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.kg-rank-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 6px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.kg-rank-item:hover {
|
||||
background: rgba(56, 189, 248, 0.08);
|
||||
}
|
||||
|
||||
.kg-rank-index {
|
||||
width: 20px;
|
||||
color: var(--kg-primary);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kg-rank-title {
|
||||
flex: 1;
|
||||
color: #cbd5e1;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.kg-rank-degree {
|
||||
color: var(--kg-text-muted);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kg-text-button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--kg-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.kg-text-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ canvas */
|
||||
|
||||
.kg-canvas-wrap {
|
||||
position: relative;
|
||||
background:
|
||||
linear-gradient(rgba(148, 163, 184, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(148, 163, 184, 0.04) 1px, transparent 1px),
|
||||
radial-gradient(circle at 50% 40%, rgba(56, 189, 248, 0.08), transparent 42%),
|
||||
rgba(2, 6, 23, 0.5);
|
||||
background-size:
|
||||
28px 28px,
|
||||
28px 28px,
|
||||
auto,
|
||||
auto;
|
||||
border: 1px solid var(--kg-border);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.kg-canvas-shell {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.kg-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.kg-canvas-toolbar {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: 14px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kg-floating-pill {
|
||||
height: 32px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.82);
|
||||
border: 1px solid var(--kg-border);
|
||||
color: #cbd5e1;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.kg-pill-label {
|
||||
padding: 0 6px;
|
||||
color: var(--kg-text-muted);
|
||||
}
|
||||
|
||||
.kg-pill-button {
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #cbd5e1;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.kg-pill-button:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.kg-pill-button.kg-pill-button-active {
|
||||
background: rgba(56, 189, 248, 0.18);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.kg-explore-button {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
color: #cbd5e1;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.kg-explore-button:hover {
|
||||
background: rgba(56, 189, 248, 0.14);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.kg-explore-button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.kg-canvas-hint {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 22px;
|
||||
z-index: 11;
|
||||
transform: translateX(-50%);
|
||||
max-width: min(560px, calc(100% - 48px));
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(56, 189, 248, 0.22);
|
||||
background: rgba(15, 23, 42, 0.78);
|
||||
color: #bae6fd;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(12px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.kg-side-rail-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 20;
|
||||
width: 32px;
|
||||
min-height: 88px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(56, 189, 248, 0.28);
|
||||
background: rgba(15, 23, 42, 0.86);
|
||||
color: #bae6fd;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
writing-mode: vertical-rl;
|
||||
letter-spacing: 4px;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(12px);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.kg-side-rail-toggle-left {
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.kg-side-rail-toggle-right {
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.kg-side-rail-toggle:hover {
|
||||
background: rgba(56, 189, 248, 0.16);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.kg-minimap {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
bottom: 14px;
|
||||
width: 180px;
|
||||
height: 120px;
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 23, 42, 0.78);
|
||||
border: 1px solid var(--kg-border);
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.kg-canvas-status {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
color: var(--kg-text-secondary);
|
||||
padding: 32px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.kg-canvas-status-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--kg-text-main);
|
||||
}
|
||||
|
||||
.kg-canvas-status-desc {
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
max-width: 360px;
|
||||
color: var(--kg-text-muted);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- tooltip */
|
||||
|
||||
.kg-tooltip {
|
||||
position: fixed;
|
||||
z-index: 999;
|
||||
max-width: 320px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 23, 42, 0.94);
|
||||
border: 1px solid rgba(56, 189, 248, 0.25);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
||||
color: var(--kg-text-main);
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.kg-tooltip-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.kg-tooltip-desc {
|
||||
font-size: 12px;
|
||||
color: var(--kg-text-secondary);
|
||||
line-height: 1.6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.kg-tooltip-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--kg-text-muted);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ detail */
|
||||
|
||||
.kg-detail-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--kg-text-muted);
|
||||
padding: 32px;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.kg-detail-empty-title {
|
||||
color: var(--kg-text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.kg-detail-title {
|
||||
font-size: 17px;
|
||||
line-height: 1.5;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
margin-bottom: 10px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.kg-detail-type {
|
||||
display: inline-flex;
|
||||
height: 24px;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-right: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.kg-detail-summary {
|
||||
color: #cbd5e1;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 16px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.kg-detail-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
row-gap: 8px;
|
||||
column-gap: 8px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.kg-detail-meta-label {
|
||||
color: var(--kg-text-muted);
|
||||
}
|
||||
|
||||
.kg-detail-meta-value {
|
||||
color: #cbd5e1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.kg-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.kg-tag {
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
color: #cbd5e1;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.kg-detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.kg-detail-section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--kg-text-secondary);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.kg-neighbor-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kg-neighbor-item {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(2, 6, 23, 0.35);
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kg-neighbor-item:hover {
|
||||
background: rgba(56, 189, 248, 0.1);
|
||||
border-color: rgba(56, 189, 248, 0.24);
|
||||
}
|
||||
|
||||
.kg-neighbor-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: #cbd5e1;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kg-neighbor-relation {
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ footer */
|
||||
|
||||
.kg-footer {
|
||||
min-height: 42px;
|
||||
margin: 0 12px 12px;
|
||||
border-radius: 14px;
|
||||
background: var(--kg-panel);
|
||||
border: 1px solid var(--kg-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 4px 14px;
|
||||
color: var(--kg-text-secondary);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.kg-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.kg-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kg-legend-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.kg-legend-line {
|
||||
width: 20px;
|
||||
height: 0;
|
||||
border-top-width: 2px;
|
||||
border-top-style: solid;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.kg-footer-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kg-footer-selected {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #bae6fd;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- responsive */
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.kg-header .kg-button-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.kg-subtitle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/** Shared types for the knowledge-graph workbench. */
|
||||
|
||||
/** Raw node as returned by `GET /api/knowledge/graph` (plus compat fields). */
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
/** "note" | "entity" on the current backend; kept loose for compat. */
|
||||
type?: string;
|
||||
label?: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
/** note: source_type (thread/file/manual/...); entity: entity_type. */
|
||||
category?: string | null;
|
||||
}
|
||||
|
||||
/** Raw edge as returned by `GET /api/knowledge/graph` (plus compat fields). */
|
||||
export interface GraphEdge {
|
||||
id?: string;
|
||||
source: string;
|
||||
target: string;
|
||||
/** Relation type: "links" | "related" | LLM relation_type. */
|
||||
label?: string;
|
||||
type?: string;
|
||||
relation?: string;
|
||||
weight?: number;
|
||||
directed?: boolean;
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
}
|
||||
|
||||
/** Normalized visual bucket for a node. */
|
||||
export type NodeKind =
|
||||
| "document"
|
||||
| "concept"
|
||||
| "person"
|
||||
| "org"
|
||||
| "location"
|
||||
| "event"
|
||||
| "tag"
|
||||
| "other";
|
||||
|
||||
export type LayoutType = "force" | "circular";
|
||||
|
||||
/** Graph display mode: full graph vs. click-to-expand exploration. */
|
||||
export type GraphMode = "full" | "explore";
|
||||
|
||||
/** One incremental expansion step in explore mode (for undo / rebuild). */
|
||||
export interface ExploreStep {
|
||||
nodeId: string;
|
||||
depth: number;
|
||||
addedNodeIds: string[];
|
||||
addedEdgeIds: string[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Style metadata of an edge label. */
|
||||
export interface EdgeInfo {
|
||||
color: string;
|
||||
zh: string;
|
||||
dashed: boolean;
|
||||
}
|
||||
|
||||
/** A node inside the force simulation (world coordinates). */
|
||||
export interface SimNode {
|
||||
id: string;
|
||||
node: GraphNode;
|
||||
kind: NodeKind;
|
||||
degree: number;
|
||||
radius: number;
|
||||
color: string;
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
fx: number | null;
|
||||
fy: number | null;
|
||||
}
|
||||
|
||||
export interface SimEdge {
|
||||
id: string;
|
||||
source: SimNode;
|
||||
target: SimNode;
|
||||
label: string;
|
||||
info: EdgeInfo;
|
||||
weight: number;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
export interface Transform {
|
||||
x: number;
|
||||
y: number;
|
||||
k: number;
|
||||
}
|
||||
|
||||
export interface TooltipState {
|
||||
/** Viewport (client) coordinates. */
|
||||
x: number;
|
||||
y: number;
|
||||
node: GraphNode;
|
||||
kind: NodeKind;
|
||||
degree: number;
|
||||
}
|
||||
|
||||
export interface GraphStats {
|
||||
nodes: number;
|
||||
edges: number;
|
||||
avgDegree: number;
|
||||
largestComponent: number;
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import type {
|
||||
EdgeInfo,
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
GraphStats,
|
||||
NodeKind,
|
||||
SimEdge,
|
||||
SimNode,
|
||||
} from "./graphTypes";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const getNodeTitle = (node: GraphNode): string =>
|
||||
node.title || node.label || node.name || node.id;
|
||||
|
||||
const KIND_PATTERNS: Array<[NodeKind, RegExp]> = [
|
||||
["person", /^(person|people|human|人物|人名|人员)$/i],
|
||||
["org", /^(org|organization|organisation|company|institution|party|组织|机构|公司|团体|政党|国会)$/i],
|
||||
["location", /^(location|place|country|city|region|gpe|地点|地区|国家|城市|地理位置)$/i],
|
||||
["event", /^(event|事件|活动|会议|战争|选举|讲话)$/i],
|
||||
["tag", /^(tag|keyword|标签|关键词)$/i],
|
||||
["concept", /^(concept|topic|term|technology|theory|product|project|主题|概念|术语|技术|理论|产品|项目|政策)$/i],
|
||||
];
|
||||
|
||||
/** Map a raw node (note source_type / entity entity_type) to a visual bucket. */
|
||||
export function getNodeKind(node: GraphNode): NodeKind {
|
||||
const type = (node.type || "").toLowerCase();
|
||||
if (type === "note" || type === "document" || type === "file") return "document";
|
||||
const category = (node.category || "").trim();
|
||||
if (category) {
|
||||
for (const [kind, re] of KIND_PATTERNS) {
|
||||
if (re.test(category)) return kind;
|
||||
}
|
||||
}
|
||||
// Unmatched LLM entity types still read better as "concept" than as a grey blob.
|
||||
if (type === "entity") return category ? "other" : "concept";
|
||||
return "other";
|
||||
}
|
||||
|
||||
export const NODE_KIND_META: Record<NodeKind, { label: string; color: string }> = {
|
||||
document: { label: "文档", color: "#38bdf8" },
|
||||
concept: { label: "主题", color: "#a78bfa" },
|
||||
person: { label: "人物", color: "#fbbf24" },
|
||||
org: { label: "组织", color: "#2dd4bf" },
|
||||
location: { label: "地点", color: "#34d399" },
|
||||
event: { label: "事件", color: "#fb7185" },
|
||||
tag: { label: "标签", color: "#a3e635" },
|
||||
other: { label: "其他", color: "#94a3b8" },
|
||||
};
|
||||
|
||||
/** Stable ordering for filters / legend. */
|
||||
export const NODE_KIND_ORDER: NodeKind[] = [
|
||||
"document",
|
||||
"concept",
|
||||
"person",
|
||||
"org",
|
||||
"location",
|
||||
"event",
|
||||
"tag",
|
||||
"other",
|
||||
];
|
||||
|
||||
export const getNodeRadius = (degree: number): number =>
|
||||
Math.min(30, 9 + Math.sqrt(Math.max(degree, 0)) * 4);
|
||||
|
||||
export function truncateLabel(text: string, max = 14): string {
|
||||
return text.length > max ? `${text.slice(0, max)}…` : text;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const getEdgeLabel = (edge: GraphEdge): string =>
|
||||
edge.label || edge.type || edge.relation || "related";
|
||||
|
||||
/** Stable edge identity shared by explore-mode visibility sets and the canvas. */
|
||||
export const getEdgeId = (edge: GraphEdge): string =>
|
||||
edge.id ?? `${edge.source}->${edge.target}:${getEdgeLabel(edge)}`;
|
||||
|
||||
const KNOWN_EDGE_INFO: Record<string, EdgeInfo> = {
|
||||
related: { color: "#94a3b8", zh: "相关", dashed: false },
|
||||
links: { color: "#a78bfa", zh: "引用", dashed: true },
|
||||
references: { color: "#a78bfa", zh: "引用", dashed: true },
|
||||
contains: { color: "#34d399", zh: "包含", dashed: false },
|
||||
belongs_to: { color: "#2dd4bf", zh: "属于", dashed: false },
|
||||
similar: { color: "#fbbf24", zh: "相似", dashed: true },
|
||||
mentions: { color: "#38bdf8", zh: "提及", dashed: false },
|
||||
};
|
||||
|
||||
const EDGE_PALETTE = [
|
||||
"#38bdf8",
|
||||
"#a78bfa",
|
||||
"#34d399",
|
||||
"#fbbf24",
|
||||
"#fb7185",
|
||||
"#2dd4bf",
|
||||
"#fb923c",
|
||||
"#e879f9",
|
||||
];
|
||||
|
||||
function hashString(text: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
h = (h * 31 + text.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(h);
|
||||
}
|
||||
|
||||
/** Color / display name / line style of an edge label (stable for unknown labels). */
|
||||
export function getEdgeInfo(label: string): EdgeInfo {
|
||||
const known = KNOWN_EDGE_INFO[label.toLowerCase()];
|
||||
if (known) return known;
|
||||
return {
|
||||
color: EDGE_PALETTE[hashString(label) % EDGE_PALETTE.length],
|
||||
zh: label,
|
||||
dashed: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function getLinkDistance(label: string): number {
|
||||
switch (label.toLowerCase()) {
|
||||
case "contains":
|
||||
return 100;
|
||||
case "links":
|
||||
case "references":
|
||||
return 130;
|
||||
case "similar":
|
||||
return 170;
|
||||
default:
|
||||
return 145;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph statistics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function computeDegrees(
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
): Map<string, number> {
|
||||
const degrees = new Map<string, number>();
|
||||
for (const n of nodes) degrees.set(n.id, 0);
|
||||
for (const e of edges) {
|
||||
if (degrees.has(e.source)) degrees.set(e.source, (degrees.get(e.source) ?? 0) + 1);
|
||||
if (degrees.has(e.target)) degrees.set(e.target, (degrees.get(e.target) ?? 0) + 1);
|
||||
}
|
||||
return degrees;
|
||||
}
|
||||
|
||||
export function computeGraphStats(
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
): GraphStats {
|
||||
const adjacency = new Map<string, string[]>();
|
||||
for (const n of nodes) adjacency.set(n.id, []);
|
||||
let validEdges = 0;
|
||||
for (const e of edges) {
|
||||
if (!adjacency.has(e.source) || !adjacency.has(e.target)) continue;
|
||||
validEdges += 1;
|
||||
adjacency.get(e.source)!.push(e.target);
|
||||
adjacency.get(e.target)!.push(e.source);
|
||||
}
|
||||
// Largest connected component via BFS.
|
||||
const visited = new Set<string>();
|
||||
let largest = 0;
|
||||
for (const n of nodes) {
|
||||
if (visited.has(n.id)) continue;
|
||||
let size = 0;
|
||||
const queue = [n.id];
|
||||
visited.add(n.id);
|
||||
while (queue.length) {
|
||||
const cur = queue.pop()!;
|
||||
size += 1;
|
||||
for (const next of adjacency.get(cur) ?? []) {
|
||||
if (!visited.has(next)) {
|
||||
visited.add(next);
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
largest = Math.max(largest, size);
|
||||
}
|
||||
return {
|
||||
nodes: nodes.length,
|
||||
edges: validEdges,
|
||||
avgDegree: nodes.length ? (validEdges * 2) / nodes.length : 0,
|
||||
largestComponent: largest,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layouts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GOLDEN_ANGLE = 2.3999632297286533;
|
||||
|
||||
/** Phyllotaxis seeding around the origin — hubs (high degree) near the center. */
|
||||
export function seedPositions(nodes: SimNode[]): void {
|
||||
const sorted = [...nodes].sort((a, b) => b.degree - a.degree);
|
||||
sorted.forEach((n, i) => {
|
||||
const r = 22 * Math.sqrt(i + 0.5);
|
||||
const angle = i * GOLDEN_ANGLE;
|
||||
n.x = r * Math.cos(angle);
|
||||
n.y = r * Math.sin(angle);
|
||||
n.vx = 0;
|
||||
n.vy = 0;
|
||||
n.fx = null;
|
||||
n.fy = null;
|
||||
});
|
||||
}
|
||||
|
||||
/** Static circular layout, grouped by kind then degree. */
|
||||
export function applyCircularLayout(nodes: SimNode[]): void {
|
||||
const sorted = [...nodes].sort((a, b) => {
|
||||
const ka = NODE_KIND_ORDER.indexOf(a.kind);
|
||||
const kb = NODE_KIND_ORDER.indexOf(b.kind);
|
||||
return ka !== kb ? ka - kb : b.degree - a.degree;
|
||||
});
|
||||
const radius = Math.max(180, (sorted.length * 56) / (2 * Math.PI));
|
||||
sorted.forEach((n, i) => {
|
||||
const angle = (i / sorted.length) * Math.PI * 2 - Math.PI / 2;
|
||||
n.x = radius * Math.cos(angle);
|
||||
n.y = radius * Math.sin(angle);
|
||||
n.vx = 0;
|
||||
n.vy = 0;
|
||||
n.fx = null;
|
||||
n.fy = null;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Force simulation (d3-force-like, dependency-free, fine for <500 nodes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ForceSimulation {
|
||||
alpha = 1;
|
||||
readonly alphaMin = 0.004;
|
||||
private readonly alphaDecay = 0.0228;
|
||||
private readonly velocityDecay = 0.38;
|
||||
private readonly chargeStrength = -420;
|
||||
private readonly chargeCutoff2 = 600 * 600;
|
||||
|
||||
constructor(
|
||||
public nodes: SimNode[],
|
||||
public edges: SimEdge[],
|
||||
) {}
|
||||
|
||||
reheat(alpha = 0.9): void {
|
||||
this.alpha = Math.max(this.alpha, alpha);
|
||||
}
|
||||
|
||||
/** Advance one step; returns false when the layout has cooled down. */
|
||||
tick(): boolean {
|
||||
if (this.alpha < this.alphaMin) return false;
|
||||
this.alpha += (0 - this.alpha) * this.alphaDecay;
|
||||
const a = this.alpha;
|
||||
const { nodes, edges } = this;
|
||||
|
||||
// Link spring force.
|
||||
for (const e of edges) {
|
||||
const s = e.source;
|
||||
const t = e.target;
|
||||
let dx = t.x - s.x;
|
||||
let dy = t.y - s.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1e-3;
|
||||
const f = ((dist - e.distance) / dist) * 0.45 * a;
|
||||
dx *= f;
|
||||
dy *= f;
|
||||
t.vx -= dx * 0.5;
|
||||
t.vy -= dy * 0.5;
|
||||
s.vx += dx * 0.5;
|
||||
s.vy += dy * 0.5;
|
||||
}
|
||||
|
||||
// Many-body repulsion with a distance cutoff.
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const ni = nodes[i];
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const nj = nodes[j];
|
||||
let dx = nj.x - ni.x;
|
||||
let dy = nj.y - ni.y;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if (d2 > this.chargeCutoff2) continue;
|
||||
if (d2 < 1) {
|
||||
// Deterministic nudge for coincident nodes.
|
||||
dx = ((i % 3) - 1) * 0.5 || 0.5;
|
||||
dy = ((j % 3) - 1) * 0.5 || -0.5;
|
||||
d2 = dx * dx + dy * dy;
|
||||
}
|
||||
const d = Math.sqrt(d2);
|
||||
const f = (this.chargeStrength * a) / d2;
|
||||
const fx = (dx / d) * f;
|
||||
const fy = (dy / d) * f;
|
||||
ni.vx += fx;
|
||||
ni.vy += fy;
|
||||
nj.vx -= fx;
|
||||
nj.vy -= fy;
|
||||
}
|
||||
}
|
||||
|
||||
// Gentle pull toward the origin keeps disconnected clusters on screen.
|
||||
for (const n of nodes) {
|
||||
n.vx += (0 - n.x) * 0.05 * a;
|
||||
n.vy += (0 - n.y) * 0.05 * a;
|
||||
}
|
||||
|
||||
// Positional collision resolution (prevents node overlap).
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const ni = nodes[i];
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const nj = nodes[j];
|
||||
const minDist = ni.radius + nj.radius + 8;
|
||||
let dx = nj.x - ni.x;
|
||||
let dy = nj.y - ni.y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 >= minDist * minDist || d2 === 0) continue;
|
||||
const d = Math.sqrt(d2);
|
||||
const push = ((minDist - d) / d) * 0.5;
|
||||
dx *= push;
|
||||
dy *= push;
|
||||
if (ni.fx == null) {
|
||||
ni.x -= dx;
|
||||
ni.y -= dy;
|
||||
}
|
||||
if (nj.fx == null) {
|
||||
nj.x += dx;
|
||||
nj.y += dy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Integrate velocities.
|
||||
for (const n of nodes) {
|
||||
if (n.fx != null && n.fy != null) {
|
||||
n.x = n.fx;
|
||||
n.y = n.fy;
|
||||
n.vx = 0;
|
||||
n.vy = 0;
|
||||
} else {
|
||||
n.vx *= 1 - this.velocityDecay;
|
||||
n.vy *= 1 - this.velocityDecay;
|
||||
n.x += n.vx;
|
||||
n.y += n.vy;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Inline, de-emphasized breadcrumb: folder path + current document title. */
|
||||
export function Breadcrumb({
|
||||
segments,
|
||||
current,
|
||||
className,
|
||||
}: {
|
||||
segments: string[];
|
||||
current: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 overflow-hidden text-xs text-slate-400 dark:text-slate-500",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{segments.map((segment, i) => (
|
||||
<Fragment key={`${segment}-${i}`}>
|
||||
<span className="hidden shrink-0 whitespace-nowrap sm:inline">
|
||||
{segment}
|
||||
</span>
|
||||
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
|
||||
</Fragment>
|
||||
))}
|
||||
<span
|
||||
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
|
||||
title={current}
|
||||
>
|
||||
{current}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Download, Pencil, Save, Trash2, X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ToolbarButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
danger,
|
||||
emphasized,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
emphasized?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"inline-flex h-7 cursor-pointer items-center gap-1 rounded-md px-2 text-xs text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 disabled:cursor-not-allowed disabled:opacity-50 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200",
|
||||
danger &&
|
||||
"text-red-500 hover:bg-red-50 hover:text-red-600 dark:text-red-400 dark:hover:bg-red-500/10 dark:hover:text-red-300",
|
||||
emphasized &&
|
||||
"border border-slate-200 bg-white font-medium dark:border-slate-700 dark:bg-slate-900",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact inline toolbar: subtle meta (status / chars / updated) + actions. */
|
||||
export function DocumentToolbar({
|
||||
charCount,
|
||||
updatedAt,
|
||||
statusLabel,
|
||||
editing,
|
||||
saving,
|
||||
archiving,
|
||||
onEdit,
|
||||
onCancelEdit,
|
||||
onSave,
|
||||
onExport,
|
||||
onArchive,
|
||||
}: {
|
||||
charCount: number;
|
||||
updatedAt: string;
|
||||
statusLabel: string;
|
||||
editing: boolean;
|
||||
saving: boolean;
|
||||
archiving: boolean;
|
||||
onEdit: () => void;
|
||||
onCancelEdit: () => void;
|
||||
onSave: () => void;
|
||||
onExport: () => void;
|
||||
onArchive: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{editing ? (
|
||||
<span className="inline-flex h-5 items-center rounded bg-amber-50 px-1.5 text-[11px] font-medium text-amber-600 dark:bg-amber-500/10 dark:text-amber-400">
|
||||
编辑中
|
||||
</span>
|
||||
) : (
|
||||
<span className="hidden h-5 items-center rounded bg-slate-100 px-1.5 text-[11px] text-slate-500 sm:inline-flex dark:bg-slate-800 dark:text-slate-400">
|
||||
{statusLabel}
|
||||
</span>
|
||||
)}
|
||||
<span className="hidden whitespace-nowrap text-xs text-slate-400 lg:inline dark:text-slate-500">
|
||||
{charCount.toLocaleString()} 字符 · {updatedAt}
|
||||
</span>
|
||||
|
||||
<div className="ml-1 flex items-center gap-1">
|
||||
{editing ? (
|
||||
<>
|
||||
<ToolbarButton
|
||||
icon={<X className="size-3.5" />}
|
||||
label="取消"
|
||||
onClick={onCancelEdit}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Save className="size-3.5" />}
|
||||
label={saving ? "保存中..." : "保存"}
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
emphasized
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ToolbarButton
|
||||
icon={<Pencil className="size-3.5" />}
|
||||
label="编辑"
|
||||
onClick={onEdit}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Download className="size-3.5" />}
|
||||
label="导出"
|
||||
onClick={onExport}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Trash2 className="size-3.5" />}
|
||||
label="归档"
|
||||
onClick={onArchive}
|
||||
disabled={archiving}
|
||||
danger
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Shown in the reading column when no document is selected. */
|
||||
export function EmptyDocumentState() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-white px-6 text-center dark:bg-slate-900">
|
||||
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-10 py-12 sm:px-12 dark:border-slate-700 dark:bg-slate-800/50">
|
||||
<div className="mb-4 text-[40px] leading-none">📚</div>
|
||||
<div className="mb-2 text-lg font-bold text-slate-700 dark:text-slate-200">
|
||||
选择一篇文档开始阅读
|
||||
</div>
|
||||
<div className="text-sm leading-7 text-slate-400">
|
||||
你可以从左侧目录树中选择知识文档,
|
||||
<br />
|
||||
也可以导入 Markdown、JSON、TXT 或文件夹来构建 LLM Wiki。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ListTree } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type { TocItem } from "./wikiTypes";
|
||||
|
||||
/**
|
||||
* Floating in-document outline: a small "目录" pill at the top-right of the
|
||||
* reading area that opens a popover listing h2/h3 headings. Replaces the old
|
||||
* always-visible right column. Hidden entirely when the document has too few
|
||||
* headings to be worth navigating.
|
||||
*/
|
||||
export function FloatingToc({
|
||||
items,
|
||||
activeId,
|
||||
onItemClick,
|
||||
className,
|
||||
}: {
|
||||
items: TocItem[];
|
||||
activeId: string | null;
|
||||
onItemClick: (id: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close when the document (and thus the heading list) changes.
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [items]);
|
||||
|
||||
// Close on click outside so the popover never lingers over the text.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown);
|
||||
}, [open]);
|
||||
|
||||
if (items.length < 2) return null;
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={cn("flex flex-col items-end", className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-xs shadow-sm backdrop-blur transition-colors",
|
||||
open
|
||||
? "border-slate-300 bg-white text-slate-700 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200"
|
||||
: "border-slate-200 bg-white/90 text-slate-500 hover:text-slate-700 dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-400 dark:hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
<ListTree className="size-3.5" />
|
||||
目录
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<nav className="mt-2 max-h-[55vh] w-64 overflow-y-auto rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl dark:border-slate-700 dark:bg-slate-900">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
title={item.text}
|
||||
onClick={() => {
|
||||
onItemClick(item.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"block w-full cursor-pointer truncate rounded-md px-2 py-1.5 text-left text-[13px] leading-snug transition-colors",
|
||||
item.level >= 3 && "pl-5",
|
||||
activeId === item.id
|
||||
? "bg-slate-100 font-medium text-red-600 dark:bg-slate-800 dark:text-red-400"
|
||||
: "text-slate-500 hover:bg-slate-50 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-800/60 dark:hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
{item.text}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
Loader2,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { KnowledgeNote } from "@/core/knowledge/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { KnowledgeTree } from "./KnowledgeTree";
|
||||
import type { TreeNode } from "./wikiTypes";
|
||||
import { SOURCE_TYPE_LABELS } from "./wikiUtils";
|
||||
|
||||
interface KnowledgeSidebarProps {
|
||||
keyword: string;
|
||||
onKeywordChange: (v: string) => void;
|
||||
sourceType: string;
|
||||
onSourceTypeChange: (v: string) => void;
|
||||
status: string;
|
||||
onStatusChange: (v: string) => void;
|
||||
tree: TreeNode[];
|
||||
expandedIds: Set<string>;
|
||||
onToggleFolder: (id: string) => void;
|
||||
selectedId?: string;
|
||||
onSelect: (note: KnowledgeNote) => void;
|
||||
onExportNote: (note: KnowledgeNote) => void;
|
||||
total: number;
|
||||
isLoading: boolean;
|
||||
/** Collapsed to a narrow rail (desktop only; the mobile drawer always expands). */
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Left column: search + filters + directory tree, collapsible to a narrow rail. */
|
||||
export function KnowledgeSidebar(props: KnowledgeSidebarProps) {
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const activeFilters =
|
||||
(props.sourceType !== "all" ? 1 : 0) + (props.status !== "all" ? 1 : 0);
|
||||
const searching = props.keyword.trim().length > 0;
|
||||
|
||||
if (props.collapsed) {
|
||||
return (
|
||||
<aside className="hidden min-h-0 flex-col items-center gap-1 border-r border-slate-100 bg-white py-2.5 md:flex dark:border-slate-800 dark:bg-slate-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onToggleCollapse}
|
||||
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-300"
|
||||
aria-label="展开目录"
|
||||
title="展开目录"
|
||||
>
|
||||
<PanelLeftOpen className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onToggleCollapse}
|
||||
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-300"
|
||||
aria-label="搜索文档"
|
||||
title="搜索文档"
|
||||
>
|
||||
<Search className="size-4" />
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col border-r border-slate-100 bg-white dark:border-slate-800 dark:bg-slate-900",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<div className="border-b border-slate-100 p-2.5 dark:border-slate-800">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={props.keyword}
|
||||
onChange={(e) => props.onKeywordChange(e.target.value)}
|
||||
placeholder="搜索标题或内容..."
|
||||
className="h-8 w-full rounded-lg border border-slate-200 bg-slate-50 pl-8 pr-2.5 text-[13px] text-slate-700 outline-none transition-colors placeholder:text-slate-400 focus:border-slate-400 focus:bg-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:focus:border-slate-500 dark:focus:bg-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFiltersOpen((v) => !v)}
|
||||
className={cn(
|
||||
"relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg transition-colors",
|
||||
filtersOpen || activeFilters > 0
|
||||
? "bg-red-50 text-red-500 dark:bg-red-500/10 dark:text-red-400"
|
||||
: "text-slate-400 hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-300",
|
||||
)}
|
||||
aria-label="高级筛选"
|
||||
title="高级筛选"
|
||||
>
|
||||
<SlidersHorizontal className="size-4" />
|
||||
{activeFilters > 0 ? (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex size-3.5 items-center justify-center rounded-full bg-red-500 text-[9px] font-bold text-white">
|
||||
{activeFilters}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{props.onToggleCollapse ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onToggleCollapse}
|
||||
className="inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 max-md:hidden dark:hover:bg-slate-800 dark:hover:text-slate-300"
|
||||
aria-label="收起目录"
|
||||
title="收起目录"
|
||||
>
|
||||
<PanelLeftClose className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{filtersOpen ? (
|
||||
<div className="mt-2 grid grid-cols-2 gap-1.5">
|
||||
<Select value={props.sourceType} onValueChange={props.onSourceTypeChange}>
|
||||
<SelectTrigger className="h-8 w-full text-[13px]">
|
||||
<SelectValue placeholder="来源" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部来源</SelectItem>
|
||||
{Object.entries(SOURCE_TYPE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={props.status} onValueChange={props.onStatusChange}>
|
||||
<SelectTrigger className="h-8 w-full text-[13px]">
|
||||
<SelectValue placeholder="状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="draft">草稿</SelectItem>
|
||||
<SelectItem value="approved">已批准</SelectItem>
|
||||
<SelectItem value="archived">已归档</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
|
||||
<div className="px-2 pb-1.5 pt-0.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400 dark:text-slate-500">
|
||||
文档目录
|
||||
</div>
|
||||
{props.isLoading ? (
|
||||
<div className="flex items-center gap-2 p-4 text-sm text-slate-400">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中...
|
||||
</div>
|
||||
) : props.tree.length === 0 ? (
|
||||
<div className="mx-2 rounded-lg border border-dashed border-slate-200 p-5 text-center text-sm text-slate-400 dark:border-slate-700">
|
||||
{searching || activeFilters > 0
|
||||
? "没有匹配的文档"
|
||||
: "暂无知识。点击「新建知识」或在对话页「保存到知识库」。"}
|
||||
</div>
|
||||
) : (
|
||||
<KnowledgeTree
|
||||
nodes={props.tree}
|
||||
selectedId={props.selectedId}
|
||||
expandedIds={props.expandedIds}
|
||||
forceExpand={searching}
|
||||
onToggleFolder={props.onToggleFolder}
|
||||
onSelect={props.onSelect}
|
||||
onExportNote={props.onExportNote}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex h-8 shrink-0 items-center justify-center border-t border-slate-100 text-[11px] text-slate-400 dark:border-slate-800 dark:text-slate-500">
|
||||
共 {props.total} 篇文档
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { ChevronRight, FileText, Folder, FolderOpen, MoreHorizontal } from "lucide-react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { KnowledgeNote } from "@/core/knowledge/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type { TreeNode } from "./wikiTypes";
|
||||
|
||||
interface KnowledgeTreeProps {
|
||||
nodes: TreeNode[];
|
||||
selectedId?: string;
|
||||
expandedIds: Set<string>;
|
||||
/** When searching, render every folder open regardless of expandedIds. */
|
||||
forceExpand?: boolean;
|
||||
onToggleFolder: (id: string) => void;
|
||||
onSelect: (note: KnowledgeNote) => void;
|
||||
onExportNote: (note: KnowledgeNote) => void;
|
||||
}
|
||||
|
||||
function TreeRow(props: KnowledgeTreeProps & { node: TreeNode; depth: number }) {
|
||||
const { node, depth, selectedId, expandedIds, forceExpand } = props;
|
||||
const indent = { paddingLeft: `${6 + depth * 10}px` };
|
||||
|
||||
if (node.type === "folder") {
|
||||
const open = forceExpand || expandedIds.has(node.id);
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => props.onToggleFolder(node.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") props.onToggleFolder(node.id);
|
||||
}}
|
||||
style={indent}
|
||||
title={node.name}
|
||||
className="group flex h-7 cursor-pointer select-none items-center gap-1 rounded-md pr-2 text-[13px] font-medium text-slate-500 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/70"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 text-slate-400 transition-transform",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
{open ? (
|
||||
<FolderOpen className="size-3.5 shrink-0 text-slate-400 dark:text-slate-500" />
|
||||
) : (
|
||||
<Folder className="size-3.5 shrink-0 text-slate-400 dark:text-slate-500" />
|
||||
)}
|
||||
<span className="flex-1 truncate">{node.name}</span>
|
||||
<span className="text-[11px] font-normal text-slate-300 opacity-0 group-hover:opacity-100 dark:text-slate-600">
|
||||
{node.children?.length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
{open && node.children ? (
|
||||
<div>
|
||||
{node.children.map((child) => (
|
||||
<TreeRow key={child.id} {...props} node={child} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const active = node.id === selectedId;
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => node.item && props.onSelect(node.item)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === "Enter" || e.key === " ") && node.item) props.onSelect(node.item);
|
||||
}}
|
||||
style={indent}
|
||||
className={cn(
|
||||
"group flex h-7 cursor-pointer select-none items-center gap-1.5 rounded-md border-l-2 border-transparent pr-1 text-[13px]",
|
||||
active
|
||||
? "border-red-500 bg-slate-100 font-medium text-slate-900 dark:bg-slate-800 dark:text-slate-100"
|
||||
: "text-slate-500 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/70",
|
||||
)}
|
||||
>
|
||||
<FileText
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
active ? "text-red-500 dark:text-red-400" : "text-slate-400 dark:text-slate-500",
|
||||
)}
|
||||
/>
|
||||
<span className="flex-1 truncate" title={node.name}>
|
||||
{node.name}
|
||||
</span>
|
||||
{node.item ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex size-5 shrink-0 items-center justify-center rounded text-slate-400 opacity-0 hover:bg-slate-200/60 group-hover:opacity-100 data-[state=open]:opacity-100 dark:hover:bg-slate-700"
|
||||
aria-label="更多操作"
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuItem onClick={() => props.onSelect(node.item!)}>
|
||||
打开文档
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => props.onExportNote(node.item!)}>
|
||||
导出 Markdown
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Recursive folder/document tree for the wiki sidebar. */
|
||||
export function KnowledgeTree(props: KnowledgeTreeProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
{props.nodes.map((node) => (
|
||||
<TreeRow key={node.id} {...props} node={node} depth={0} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import MarkdownRenderer, {
|
||||
type WikiLinkMap,
|
||||
type WikiLinkTarget,
|
||||
} from "@/strategy-components/MarkdownRenderer";
|
||||
|
||||
import type { TocItem } from "./wikiTypes";
|
||||
import "./wiki.css";
|
||||
|
||||
/**
|
||||
* Renders the note body via the shared MarkdownRenderer and mounts the toc ids
|
||||
* onto the rendered h1–h4 elements so outline clicks / scroll-spy can target them.
|
||||
* extractToc and this component walk headings in the same document order, so a
|
||||
* positional mapping is sufficient.
|
||||
*/
|
||||
export function MarkdownViewer({
|
||||
content,
|
||||
toc,
|
||||
wikiLinks,
|
||||
onWikiLinkClick,
|
||||
}: {
|
||||
content: string;
|
||||
toc: TocItem[];
|
||||
wikiLinks?: WikiLinkMap;
|
||||
onWikiLinkClick?: (link: WikiLinkTarget) => void;
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
const headings = root.querySelectorAll<HTMLElement>("h1, h2, h3, h4");
|
||||
headings.forEach((el, index) => {
|
||||
const item = toc[index];
|
||||
if (item) el.id = item.id;
|
||||
});
|
||||
}, [content, toc]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="wiki-markdown">
|
||||
<MarkdownRenderer
|
||||
content={content}
|
||||
wikiLinks={wikiLinks}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { FileText, Link2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
WikiLinkMap,
|
||||
WikiLinkTarget,
|
||||
} from "@/strategy-components/MarkdownRenderer";
|
||||
|
||||
import type { RelatedLink } from "./wikiUtils";
|
||||
|
||||
/**
|
||||
* Compact "相关文档" module rendered from the note's `## Related` wikilinks.
|
||||
* Each entry is resolved through the backend wikilink map so the visible
|
||||
* title always matches the actual navigation target; duplicates (same note)
|
||||
* are collapsed and unresolved targets are shown muted and non-clickable.
|
||||
*/
|
||||
export function RelatedDocs({
|
||||
links,
|
||||
wikiLinks,
|
||||
onOpen,
|
||||
}: {
|
||||
links: RelatedLink[];
|
||||
wikiLinks?: WikiLinkMap;
|
||||
onOpen: (link: WikiLinkTarget) => void;
|
||||
}) {
|
||||
if (links.length === 0) return null;
|
||||
|
||||
// Resolving is still in flight — render nothing rather than dead links.
|
||||
const resolving = !wikiLinks;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const entries: { link: RelatedLink; resolved?: WikiLinkTarget }[] = [];
|
||||
for (const link of links) {
|
||||
const resolved = wikiLinks?.[link.target];
|
||||
const key =
|
||||
resolved?.note_id ?? resolved?.vault_path ?? link.target.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
entries.push({ link, resolved });
|
||||
}
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="mt-10 border-t border-slate-100 pt-5 dark:border-slate-800">
|
||||
<h2 className="mb-3 flex items-center gap-1.5 text-sm font-semibold text-slate-600 dark:text-slate-300">
|
||||
<Link2 className="size-3.5 text-slate-400" />
|
||||
相关文档
|
||||
<span className="text-xs font-normal text-slate-400">
|
||||
{entries.length}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{entries.map(({ link, resolved }) => {
|
||||
const label = link.alias || resolved?.title || link.target;
|
||||
const clickable = !!resolved?.exists;
|
||||
const body = (
|
||||
<>
|
||||
<FileText
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
clickable
|
||||
? "text-slate-400 transition-colors group-hover:text-red-500"
|
||||
: "text-slate-300 dark:text-slate-600",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-[13px] font-medium",
|
||||
clickable
|
||||
? "text-slate-700 dark:text-slate-200"
|
||||
: "text-slate-400 dark:text-slate-500",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{resolved?.vault_path ? (
|
||||
<span className="block truncate text-[11px] text-slate-400 dark:text-slate-500">
|
||||
{resolved.vault_path}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (clickable) {
|
||||
return (
|
||||
<button
|
||||
key={link.target}
|
||||
type="button"
|
||||
title={label}
|
||||
onClick={() => resolved && onOpen(resolved)}
|
||||
className="group flex cursor-pointer items-center gap-2.5 rounded-lg border border-slate-200 px-3 py-2 text-left transition-colors hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700/80 dark:hover:border-slate-600 dark:hover:bg-slate-800/60"
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={link.target}
|
||||
title={resolving ? label : `${label}(未收录)`}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 rounded-lg border border-dashed border-slate-200 px-3 py-2 dark:border-slate-700/80",
|
||||
!resolving && "opacity-70",
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
{!resolving ? (
|
||||
<span className="shrink-0 text-[11px] text-slate-300 dark:text-slate-600">
|
||||
未收录
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { ExternalLink, Sparkles } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useKnowledgeActions } from "@/core/knowledge/hooks";
|
||||
import type { KnowledgeNote } from "@/core/knowledge/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
WikiLinkMap,
|
||||
WikiLinkTarget,
|
||||
} from "@/strategy-components/MarkdownRenderer";
|
||||
|
||||
import { Breadcrumb } from "./Breadcrumb";
|
||||
import { DocumentToolbar } from "./DocumentToolbar";
|
||||
import { FloatingToc } from "./FloatingToc";
|
||||
import { MarkdownViewer } from "./MarkdownViewer";
|
||||
import { RelatedDocs } from "./RelatedDocs";
|
||||
import {
|
||||
SOURCE_TYPE_LABELS,
|
||||
STATUS_LABELS,
|
||||
downloadMarkdown,
|
||||
extractToc,
|
||||
formatDateTime,
|
||||
noteBreadcrumbSegments,
|
||||
splitRelatedSection,
|
||||
} from "./wikiUtils";
|
||||
|
||||
const ENTITY_TYPE_META: Record<string, { label: string; className: string }> = {
|
||||
person: { label: "人物", className: "bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-500/15 dark:text-rose-300 dark:border-rose-500/30" },
|
||||
org: { label: "机构", className: "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30" },
|
||||
product: { label: "产品", className: "bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30" },
|
||||
technology: { label: "技术", className: "bg-cyan-50 text-cyan-700 border-cyan-200 dark:bg-cyan-500/15 dark:text-cyan-300 dark:border-cyan-500/30" },
|
||||
concept: { label: "概念", className: "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30" },
|
||||
region: { label: "地区", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
|
||||
country: { label: "国家", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
|
||||
};
|
||||
|
||||
function entityMeta(type?: string | null) {
|
||||
return (
|
||||
ENTITY_TYPE_META[(type || "").toLowerCase()] ?? {
|
||||
label: "其他",
|
||||
className:
|
||||
"bg-slate-100 text-slate-600 border-slate-200 dark:bg-slate-700/40 dark:text-slate-300 dark:border-slate-600",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Human label for an audit actor: prefer the resolved name (email), else a short id. */
|
||||
function actorLabel(name?: string | null, id?: string | null): string {
|
||||
if (name && name.trim()) return name.trim();
|
||||
if (id && id.trim()) return `${id.trim().slice(0, 8)}…`;
|
||||
return "—";
|
||||
}
|
||||
|
||||
function MetaTag({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex h-6 items-center rounded-md bg-slate-100 px-2 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Middle column: compact header + markdown reading area (or inline editor). */
|
||||
export function WikiContent({
|
||||
note,
|
||||
wikiLinks,
|
||||
onWikiLinkClick,
|
||||
}: {
|
||||
note: KnowledgeNote;
|
||||
wikiLinks?: WikiLinkMap;
|
||||
onWikiLinkClick: (link: WikiLinkTarget) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { update, remove } = useKnowledgeActions();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const tickingRef = useRef(false);
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draftTitle, setDraftTitle] = useState(note.title);
|
||||
const [draftTags, setDraftTags] = useState(note.tags.join(", "));
|
||||
const [draftContent, setDraftContent] = useState(note.content_md);
|
||||
const [draftStatus, setDraftStatus] = useState<string>(note.status);
|
||||
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
|
||||
|
||||
// Pull the backend-injected `## Related` wikilinks out of the body so they
|
||||
// render as a structured module; editing still works on the full source.
|
||||
const { body, related } = useMemo(
|
||||
() => splitRelatedSection(note.content_md),
|
||||
[note.content_md],
|
||||
);
|
||||
const toc = useMemo(() => extractToc(body), [body]);
|
||||
// The floating outline only shows h2/h3 — deeper levels are noise.
|
||||
const outlineItems = useMemo(
|
||||
() => toc.filter((item) => item.level === 2 || item.level === 3),
|
||||
[toc],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEditing(false);
|
||||
setDraftTitle(note.title);
|
||||
setDraftTags(note.tags.join(", "));
|
||||
setDraftContent(note.content_md);
|
||||
setDraftStatus(note.status);
|
||||
scrollRef.current?.scrollTo({ top: 0 });
|
||||
setActiveHeadingId(null);
|
||||
// Re-sync drafts whenever a different note (or fresh server copy) arrives.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [note.id, note.updated_at]);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (tickingRef.current) return;
|
||||
tickingRef.current = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
tickingRef.current = false;
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
const headings = container.querySelectorAll<HTMLElement>(
|
||||
".wiki-markdown h1[id], .wiki-markdown h2[id], .wiki-markdown h3[id], .wiki-markdown h4[id]",
|
||||
);
|
||||
if (headings.length === 0) return;
|
||||
const containerTop = container.getBoundingClientRect().top;
|
||||
let current: string | null = headings[0].id;
|
||||
for (const el of headings) {
|
||||
if (el.getBoundingClientRect().top - containerTop <= 96) {
|
||||
current = el.id;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
setActiveHeadingId(current);
|
||||
});
|
||||
};
|
||||
|
||||
const handleTocClick = (id: string) => {
|
||||
setActiveHeadingId(id);
|
||||
document
|
||||
.getElementById(id)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
noteId: note.id,
|
||||
data: {
|
||||
title: draftTitle.trim() || note.title,
|
||||
content_md: draftContent,
|
||||
tags: draftTags
|
||||
.split(/[,,\s]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
status: draftStatus,
|
||||
},
|
||||
});
|
||||
toast.success("已保存");
|
||||
setEditing(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async () => {
|
||||
try {
|
||||
await remove.mutateAsync({ noteId: note.id });
|
||||
toast.success("已归档");
|
||||
navigate("/page/workspace/knowledge");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "归档失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-col bg-white dark:bg-slate-900">
|
||||
<header className="flex h-12 shrink-0 items-center gap-3 border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900">
|
||||
<Breadcrumb
|
||||
segments={noteBreadcrumbSegments(note)}
|
||||
current={note.title}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<DocumentToolbar
|
||||
charCount={note.content_md.length}
|
||||
updatedAt={formatDateTime(note.updated_at)}
|
||||
statusLabel={STATUS_LABELS[note.status] ?? note.status}
|
||||
editing={editing}
|
||||
saving={update.isPending}
|
||||
archiving={remove.isPending}
|
||||
onEdit={() => setEditing(true)}
|
||||
onCancelEdit={() => setEditing(false)}
|
||||
onSave={() => void handleSave()}
|
||||
onExport={() => downloadMarkdown(note)}
|
||||
onArchive={() => void handleArchive()}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{!editing ? (
|
||||
<FloatingToc
|
||||
items={outlineItems}
|
||||
activeId={activeHeadingId}
|
||||
onItemClick={handleTocClick}
|
||||
className="absolute right-4 top-3 z-10 sm:right-6"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={editing ? undefined : handleScroll}
|
||||
className="h-full overflow-y-auto"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[980px] px-5 pb-28 pt-8 sm:px-8 lg:px-10">
|
||||
{editing ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
value={draftTitle}
|
||||
onChange={(e) => setDraftTitle(e.target.value)}
|
||||
placeholder="标题"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={draftTags}
|
||||
onChange={(e) => setDraftTags(e.target.value)}
|
||||
placeholder="标签,逗号分隔"
|
||||
/>
|
||||
<Select value={draftStatus} onValueChange={setDraftStatus}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">草稿</SelectItem>
|
||||
<SelectItem value="approved">已批准</SelectItem>
|
||||
<SelectItem value="archived">已归档</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Textarea
|
||||
rows={24}
|
||||
value={draftContent}
|
||||
onChange={(e) => setDraftContent(e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="mb-2 text-2xl font-bold leading-snug text-slate-900 dark:text-slate-100">
|
||||
{note.title}
|
||||
</h1>
|
||||
|
||||
{note.summary ? (
|
||||
<div className="my-4 rounded-lg border-l-2 border-slate-200 bg-slate-50 px-4 py-3 text-sm leading-7 text-slate-600 dark:border-slate-700 dark:bg-slate-800/50 dark:text-slate-300">
|
||||
{note.summary}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-7 mt-3 flex flex-wrap items-center gap-1.5">
|
||||
<MetaTag>
|
||||
来源:{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
|
||||
</MetaTag>
|
||||
<MetaTag>状态:{STATUS_LABELS[note.status] ?? note.status}</MetaTag>
|
||||
{note.created_at ? (
|
||||
<MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag>
|
||||
) : null}
|
||||
{note.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex h-6 items-center rounded-md bg-rose-50 px-2 text-xs font-medium text-rose-500 dark:bg-rose-500/10 dark:text-rose-300"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{note.entities && note.entities.length > 0 ? (
|
||||
<div className="mb-7 flex flex-wrap items-center gap-1.5">
|
||||
<span className="mr-0.5 inline-flex items-center gap-1 text-xs text-slate-400">
|
||||
<Sparkles className="size-3" /> 实体
|
||||
</span>
|
||||
{note.entities.map((ent) => {
|
||||
const meta = entityMeta(ent.entity_type);
|
||||
return (
|
||||
<span
|
||||
key={`${ent.name}-${ent.entity_type ?? ""}`}
|
||||
title={meta.label}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium",
|
||||
meta.className,
|
||||
)}
|
||||
>
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-current opacity-70" />
|
||||
<span>{ent.name}</span>
|
||||
<span className="rounded-full bg-current/15 px-1.5 py-0.5 text-[10px] leading-none opacity-90">
|
||||
{meta.label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<MarkdownViewer
|
||||
content={body}
|
||||
toc={toc}
|
||||
wikiLinks={wikiLinks}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
/>
|
||||
|
||||
<RelatedDocs
|
||||
links={related}
|
||||
wikiLinks={wikiLinks}
|
||||
onOpen={onWikiLinkClick}
|
||||
/>
|
||||
|
||||
{note.sources && note.sources.length > 0 ? (
|
||||
<div className="mt-10 border-t border-slate-100 pt-5 dark:border-slate-800">
|
||||
<h2 className="mb-3 text-sm font-semibold text-slate-600 dark:text-slate-300">
|
||||
来源
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{note.sources.map((src) => (
|
||||
<li key={src.id} className="text-[13px]">
|
||||
{src.url ? (
|
||||
<a
|
||||
href={src.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
{src.title || src.url}
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
) : src.thread_id ? (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
|
||||
onClick={() =>
|
||||
navigate(`/page/workspace/chats/${src.thread_id}`)
|
||||
}
|
||||
>
|
||||
{src.title || `对话 ${src.thread_id.slice(0, 8)}`}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-slate-600 dark:text-slate-300">
|
||||
{src.title || src.tool_name || "来源"}
|
||||
</span>
|
||||
)}
|
||||
{src.snippet ? (
|
||||
<p className="mt-0.5 line-clamp-2 text-xs text-slate-400">
|
||||
{src.snippet}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-12 border-t border-slate-100 pt-3 text-[12px] text-slate-400/90 dark:border-slate-800 dark:text-slate-500">
|
||||
创建人 {actorLabel(note.created_by_name, note.created_by)} · 更新人{" "}
|
||||
{actorLabel(note.updated_by_name, note.updated_by)} · 更新于{" "}
|
||||
{formatDateTime(note.updated_at)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Copy,
|
||||
Menu,
|
||||
Network,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function HeaderButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
variant = "outline",
|
||||
className,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: "outline" | "primary";
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg border px-3 text-sm transition-colors",
|
||||
variant === "primary"
|
||||
? "border-red-600 bg-red-600 text-white hover:bg-red-700"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Light 56px wiki header: logo + product name on the left, action entries on the right. */
|
||||
export function WikiHeader({
|
||||
onToggleSidebar,
|
||||
onImportFile,
|
||||
onBatchImport,
|
||||
onDedup,
|
||||
onGraph,
|
||||
onCreate,
|
||||
}: {
|
||||
onToggleSidebar: () => void;
|
||||
onImportFile: () => void;
|
||||
onBatchImport: () => void;
|
||||
onDedup: () => void;
|
||||
onGraph: () => void;
|
||||
onCreate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSidebar}
|
||||
className="inline-flex size-9 items-center justify-center rounded-lg text-slate-500 hover:bg-slate-100 md:hidden dark:text-slate-400 dark:hover:bg-slate-800"
|
||||
aria-label="打开目录"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
<div className="flex size-9 items-center justify-center rounded-[9px] bg-red-600 text-white">
|
||||
<BookOpen className="size-5" />
|
||||
</div>
|
||||
<div className="leading-none">
|
||||
<div className="text-base font-extrabold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
知识库
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-emerald-500">LLM Wiki · 知识库中心</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<HeaderButton
|
||||
icon={<Upload className="size-4" />}
|
||||
label="导入文件"
|
||||
onClick={onImportFile}
|
||||
/>
|
||||
<HeaderButton
|
||||
icon={<RefreshCw className="size-4" />}
|
||||
label="批量导入"
|
||||
onClick={onBatchImport}
|
||||
/>
|
||||
<HeaderButton
|
||||
icon={<Copy className="size-4" />}
|
||||
label="查重"
|
||||
onClick={onDedup}
|
||||
/>
|
||||
<HeaderButton
|
||||
icon={<Network className="size-4" />}
|
||||
label="图谱"
|
||||
onClick={onGraph}
|
||||
/>
|
||||
<HeaderButton
|
||||
icon={<Plus className="size-4" />}
|
||||
label="新建知识"
|
||||
onClick={onCreate}
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/* Wiki reading-area typography: overrides the generic MarkdownRenderer classes
|
||||
inside .wiki-markdown to get a document-site reading experience. */
|
||||
|
||||
.wiki-markdown h1 {
|
||||
font-size: 26px;
|
||||
line-height: 1.4;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.wiki-markdown h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin: 36px 0 14px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #eef1f5;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.wiki-markdown h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin: 26px 0 10px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.wiki-markdown h4 {
|
||||
font-size: 15.5px;
|
||||
font-weight: 600;
|
||||
margin: 22px 0 8px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.wiki-markdown p {
|
||||
font-size: 15.5px;
|
||||
line-height: 1.75;
|
||||
color: #334155;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.wiki-markdown ul,
|
||||
.wiki-markdown ol {
|
||||
padding-left: 22px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.wiki-markdown li {
|
||||
margin: 5px 0;
|
||||
line-height: 1.75;
|
||||
color: #334155;
|
||||
font-size: 15.5px;
|
||||
}
|
||||
|
||||
.wiki-markdown li > ul,
|
||||
.wiki-markdown li > ol {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.wiki-markdown strong {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wiki-markdown a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wiki-markdown a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.wiki-markdown blockquote {
|
||||
border-left: 3px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
padding: 10px 16px;
|
||||
color: #475569;
|
||||
margin: 16px 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.wiki-markdown blockquote p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.wiki-markdown table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.wiki-markdown th,
|
||||
.wiki-markdown td {
|
||||
border: 1px solid #e8ecf1;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.wiki-markdown th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.wiki-markdown hr {
|
||||
margin: 28px 0;
|
||||
border-color: #eef1f5;
|
||||
}
|
||||
|
||||
/* Anchor target offset so headings clear the floating "目录" pill. */
|
||||
.wiki-markdown h1,
|
||||
.wiki-markdown h2,
|
||||
.wiki-markdown h3,
|
||||
.wiki-markdown h4 {
|
||||
scroll-margin-top: 24px;
|
||||
}
|
||||
|
||||
/* Dark theme adjustments (app toggles the .dark class on a root element). */
|
||||
.dark .wiki-markdown h1,
|
||||
.dark .wiki-markdown h2 {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.dark .wiki-markdown h2 {
|
||||
border-bottom-color: #1f2937;
|
||||
}
|
||||
|
||||
.dark .wiki-markdown h3,
|
||||
.dark .wiki-markdown h4 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.dark .wiki-markdown p,
|
||||
.dark .wiki-markdown li {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.dark .wiki-markdown strong {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.dark .wiki-markdown a {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.dark .wiki-markdown blockquote {
|
||||
background: #1e293b;
|
||||
border-left-color: #475569;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.dark .wiki-markdown th {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.dark .wiki-markdown th,
|
||||
.dark .wiki-markdown td {
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
.dark .wiki-markdown hr {
|
||||
border-color: #334155;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { KnowledgeNote } from "@/core/knowledge/types";
|
||||
|
||||
/** One entry of the in-document outline, parsed from markdown headings (h1–h4). */
|
||||
export interface TocItem {
|
||||
id: string;
|
||||
text: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
/** A node of the left-hand knowledge directory tree. */
|
||||
export interface TreeNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "folder" | "file";
|
||||
children?: TreeNode[];
|
||||
item?: KnowledgeNote;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import type { KnowledgeNote } from "@/core/knowledge/types";
|
||||
|
||||
import type { TocItem, TreeNode } from "./wikiTypes";
|
||||
|
||||
export const SOURCE_TYPE_LABELS: Record<string, string> = {
|
||||
thread: "对话沉淀",
|
||||
search: "检索沉淀",
|
||||
tool: "工具沉淀",
|
||||
file: "文件导入",
|
||||
manual: "手动创建",
|
||||
report: "研究报告",
|
||||
};
|
||||
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
approved: "已批准",
|
||||
archived: "已归档",
|
||||
};
|
||||
|
||||
const FALLBACK_FOLDER = "未分类";
|
||||
|
||||
/** Stable slug for a heading: keeps CJK characters, collapses whitespace to dashes. */
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[#`*_~[\]()]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^\w一-龥-]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse h1–h4 headings out of raw markdown (skipping fenced code blocks) and
|
||||
* assign each a stable id; duplicate titles get a numeric suffix. The same id
|
||||
* sequence is mounted onto the rendered headings by MarkdownViewer.
|
||||
*/
|
||||
export function extractToc(markdown: string): TocItem[] {
|
||||
const usedIds = new Map<string, number>();
|
||||
const items: TocItem[] = [];
|
||||
let inFence = false;
|
||||
|
||||
for (const line of markdown.split("\n")) {
|
||||
if (/^\s*(```|~~~)/.test(line)) {
|
||||
inFence = !inFence;
|
||||
continue;
|
||||
}
|
||||
if (inFence) continue;
|
||||
const match = /^(#{1,4})\s+(.+)$/.exec(line);
|
||||
if (!match) continue;
|
||||
|
||||
const level = match[1].length;
|
||||
const text = match[2].replace(/[#`*_~]/g, "").trim();
|
||||
const baseId = slugify(text) || "heading";
|
||||
const count = usedIds.get(baseId) ?? 0;
|
||||
usedIds.set(baseId, count + 1);
|
||||
items.push({ id: count === 0 ? baseId : `${baseId}-${count + 1}`, text, level });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** One entry parsed from the note's trailing `## Related` wikilink list. */
|
||||
export interface RelatedLink {
|
||||
target: string;
|
||||
alias?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the backend-injected `## Related` section (a plain list of
|
||||
* `- [[Title]]` bullets, see knowledge/service.py `_inject_related_notes`)
|
||||
* out of the markdown body so it can be rendered as a structured module
|
||||
* instead of a loose bullet list. If the section contains anything other
|
||||
* than wikilink bullets, the body is returned untouched (no data loss).
|
||||
*/
|
||||
export function splitRelatedSection(markdown: string): {
|
||||
body: string;
|
||||
related: RelatedLink[];
|
||||
} {
|
||||
const lines = markdown.split("\n");
|
||||
let inFence = false;
|
||||
let start = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (/^\s*(```|~~~)/.test(lines[i])) {
|
||||
inFence = !inFence;
|
||||
continue;
|
||||
}
|
||||
if (inFence) continue;
|
||||
if (/^##\s+(Related|相关文档|相关)\s*$/i.test(lines[i].trim())) {
|
||||
start = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (start === -1) return { body: markdown, related: [] };
|
||||
|
||||
let end = lines.length;
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
if (/^#{1,2}\s+/.test(lines[i])) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const related: RelatedLink[] = [];
|
||||
for (const raw of lines.slice(start + 1, end)) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const match = /^[-*]\s*\[\[([^\]\n|]+)(?:\|([^\]\n]+))?\]\]\s*$/.exec(line);
|
||||
if (!match) return { body: markdown, related: [] };
|
||||
const target = match[1].trim();
|
||||
if (!related.some((r) => r.target === target)) {
|
||||
related.push({ target, alias: match[2]?.trim() || undefined });
|
||||
}
|
||||
}
|
||||
if (related.length === 0) return { body: markdown, related: [] };
|
||||
|
||||
const body = [...lines.slice(0, start), ...lines.slice(end)]
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trimEnd();
|
||||
return { body, related };
|
||||
}
|
||||
|
||||
/** Folder path segments for a note: vault_path directories, else its source-type group. */
|
||||
export function noteFolderSegments(note: KnowledgeNote): string[] {
|
||||
const rawPath = note.vault_path?.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
if (rawPath && rawPath.includes("/")) {
|
||||
return rawPath.split("/").slice(0, -1).filter(Boolean);
|
||||
}
|
||||
return [SOURCE_TYPE_LABELS[note.source_type] ?? FALLBACK_FOLDER];
|
||||
}
|
||||
|
||||
/** Breadcrumb segments for a note (folders only; the title is appended by the caller). */
|
||||
export function noteBreadcrumbSegments(note: KnowledgeNote): string[] {
|
||||
return ["知识库", ...noteFolderSegments(note)];
|
||||
}
|
||||
|
||||
function sortTree(nodes: TreeNode[]): void {
|
||||
nodes.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
|
||||
return a.name.localeCompare(b.name, "zh-CN");
|
||||
});
|
||||
for (const node of nodes) {
|
||||
if (node.children) sortTree(node.children);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the directory tree from the flat note list. Notes with a vault_path
|
||||
* become nested folders mirroring that path; others are grouped by source type.
|
||||
*/
|
||||
export function buildKnowledgeTree(notes: KnowledgeNote[]): TreeNode[] {
|
||||
const root: TreeNode[] = [];
|
||||
const folderMap = new Map<string, TreeNode>();
|
||||
|
||||
const ensureFolder = (segments: string[]): TreeNode[] => {
|
||||
let children = root;
|
||||
let path = "";
|
||||
for (const segment of segments) {
|
||||
path = path ? `${path}/${segment}` : segment;
|
||||
let node = folderMap.get(path);
|
||||
if (!node) {
|
||||
node = { id: `folder:${path}`, name: segment, type: "folder", children: [] };
|
||||
folderMap.set(path, node);
|
||||
children.push(node);
|
||||
}
|
||||
children = node.children!;
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
for (const note of notes) {
|
||||
ensureFolder(noteFolderSegments(note)).push({
|
||||
id: note.id,
|
||||
name: note.title || "未命名文档",
|
||||
type: "file",
|
||||
item: note,
|
||||
});
|
||||
}
|
||||
|
||||
sortTree(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Ids of every folder in the tree (used by "expand all" while searching). */
|
||||
export function collectFolderIds(nodes: TreeNode[]): string[] {
|
||||
const ids: string[] = [];
|
||||
const walk = (list: TreeNode[]) => {
|
||||
for (const node of list) {
|
||||
if (node.type === "folder") {
|
||||
ids.push(node.id);
|
||||
if (node.children) walk(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(nodes);
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Folder ids on the path from the root down to the given note, or null if absent. */
|
||||
export function findAncestorFolderIds(
|
||||
nodes: TreeNode[],
|
||||
noteId: string,
|
||||
): string[] | null {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "file" && node.id === noteId) return [];
|
||||
if (node.type === "folder" && node.children) {
|
||||
const sub = findAncestorFolderIds(node.children, noteId);
|
||||
if (sub) return [node.id, ...sub];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatDateTime(value?: string | null): string {
|
||||
return value ? value.slice(0, 19).replace("T", " ") : "—";
|
||||
}
|
||||
|
||||
export function formatDate(value?: string | null): string {
|
||||
return value ? value.slice(0, 10) : "—";
|
||||
}
|
||||
|
||||
/** Trigger a client-side download of the note body as a .md file. */
|
||||
export function downloadMarkdown(note: KnowledgeNote): void {
|
||||
const blob = new Blob([note.content_md], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${(note.title || "knowledge").replace(/[\\/:*?"<>|]/g, "_")}.md`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -1,39 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon } from "lucide-react";
|
||||
|
||||
import { type Agent } from "@/core/agents";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AgentWelcome({
|
||||
className,
|
||||
agent,
|
||||
agentName,
|
||||
}: {
|
||||
className?: string;
|
||||
agent: Agent | null | undefined;
|
||||
agentName: string;
|
||||
}) {
|
||||
const displayName = desensitize(agent?.name ?? agentName);
|
||||
const description = agent?.description
|
||||
? desensitize(agent.description)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex w-full flex-col items-center justify-center gap-2 px-8 py-4 text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<BotIcon className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{displayName}</div>
|
||||
{description && (
|
||||
<p className="text-muted-foreground max-w-sm text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { BotIcon } from "lucide-react";
|
||||
|
||||
import { type Agent } from "@/core/agents";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Landing (home) hero shown above the input box before the first message of an
|
||||
* agent conversation: centered avatar, a "Hi,我是{name}" greeting and the
|
||||
* agent's description as a slogan. The description is clamped to two lines
|
||||
* (full text on hover) so a long blurb can never push the input box below the
|
||||
* fold. The agent's 常用问题 are rendered by the page as a list beside the
|
||||
* input box, not here.
|
||||
*/
|
||||
export function AgentWelcome({
|
||||
className,
|
||||
agent,
|
||||
agentName,
|
||||
}: {
|
||||
className?: string;
|
||||
agent: Agent | null | undefined;
|
||||
agentName: string;
|
||||
}) {
|
||||
const displayName = desensitize(agent?.name ?? agentName);
|
||||
const description = agent?.description
|
||||
? desensitize(agent.description)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex w-full max-w-2xl flex-col items-center justify-center gap-3 px-6 text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="bg-primary/10 ring-primary/15 mb-2 flex h-[72px] w-[72px] items-center justify-center rounded-3xl ring-1">
|
||||
<BotIcon className="text-primary h-9 w-9" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold tracking-tight">
|
||||
Hi,我是{displayName}
|
||||
</div>
|
||||
{description && (
|
||||
<p
|
||||
className="text-muted-foreground line-clamp-2 max-w-xl text-base leading-relaxed"
|
||||
title={description}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,9 @@ import {
|
||||
BotIcon,
|
||||
CopyIcon,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
LockIcon,
|
||||
MessageSquareIcon,
|
||||
PinIcon,
|
||||
SearchIcon,
|
||||
ShieldIcon,
|
||||
SparklesIcon,
|
||||
SquarePenIcon,
|
||||
@@ -47,12 +45,24 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { usePublishSquares } from "@/core/system-settings/hooks";
|
||||
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
|
||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
||||
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
|
||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import {
|
||||
normalizeAgentQuestions,
|
||||
useAgent,
|
||||
useCreateAgent,
|
||||
useDeleteAgent,
|
||||
@@ -63,7 +73,7 @@ import {
|
||||
useUpdateAgent,
|
||||
} from "@/core/agents";
|
||||
import { getAgent } from "@/core/agents/api";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import type { Agent, AgentQuestion } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
@@ -207,12 +217,21 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
const [editName, setEditName] = useState(agent.name);
|
||||
const [editDescription, setEditDescription] = useState(agent.description ?? "");
|
||||
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
||||
// Common questions (常用问题) — primary editing now lives in the agent chat
|
||||
// page's right-hand AgentDetailPanel; this dialog stays as a backup entry.
|
||||
// Each row has a short title (shown on the chip) and the full prompt sent
|
||||
// when clicked.
|
||||
const [editCommonQuestions, setEditCommonQuestions] = useState<AgentQuestion[]>(
|
||||
() => normalizeAgentQuestions(agent.common_questions),
|
||||
);
|
||||
const [editPublished, setEditPublished] = useState(agent.published);
|
||||
const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private);
|
||||
const [editSquareId, setEditSquareId] = useState(agent.square_id ?? "");
|
||||
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
|
||||
const { data: publishSquares } = usePublishSquares();
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>(
|
||||
agent.skills ?? [],
|
||||
);
|
||||
const [skillSearch, setSkillSearch] = useState("");
|
||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||
|
||||
// The /api/agents list response intentionally omits `soul` (perf optimization
|
||||
@@ -249,13 +268,16 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
setEditDescription(agent.description ?? "");
|
||||
setEditPublished(agent.published);
|
||||
setEditIsPrivate(agent.is_private);
|
||||
setEditSquareId(agent.square_id ?? "");
|
||||
setSelectedSkills(agent.skills ?? []);
|
||||
setSkillSearch("");
|
||||
setEditCommonQuestions(normalizeAgentQuestions(agent.common_questions));
|
||||
}, [
|
||||
agent.common_questions,
|
||||
agent.description,
|
||||
agent.is_private,
|
||||
agent.name,
|
||||
agent.published,
|
||||
agent.square_id,
|
||||
agent.skills,
|
||||
editOpen,
|
||||
]);
|
||||
@@ -269,6 +291,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
// include `soul`) keep working.
|
||||
if (agentDetail) {
|
||||
setEditSoul(agentDetail.soul ?? "");
|
||||
setEditCommonQuestions(normalizeAgentQuestions(agentDetail.common_questions));
|
||||
} else {
|
||||
setEditSoul(agent.soul ?? "");
|
||||
}
|
||||
@@ -344,7 +367,14 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
// backend will see, regardless of any stale state in editPublished.
|
||||
published: editIsPrivate ? false : editPublished,
|
||||
is_private: editIsPrivate,
|
||||
square_id: editSquareId,
|
||||
skills: selectedSkills,
|
||||
common_questions: editCommonQuestions
|
||||
.map((q) => ({
|
||||
title: q.title.trim() || q.prompt.trim(),
|
||||
prompt: q.prompt.trim() || q.title.trim(),
|
||||
}))
|
||||
.filter((q) => q.prompt.length > 0),
|
||||
},
|
||||
});
|
||||
toast.success("智能体已更新");
|
||||
@@ -362,6 +392,12 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
toast.warning("该智能体已加入私密广场,不可发布");
|
||||
return;
|
||||
}
|
||||
// Publishing: prompt for which square to publish into (when any squares are
|
||||
// configured). Unpublishing: just flip it off.
|
||||
if (!agent.published && (publishSquares?.squares.length ?? 0) > 0) {
|
||||
setPublishPickerOpen(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
@@ -373,6 +409,21 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublishToSquare(squareId: string) {
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
request: { published: true, square_id: squareId },
|
||||
});
|
||||
const sqName =
|
||||
publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场";
|
||||
toast.success(`已发布到「${sqName}」`);
|
||||
setPublishPickerOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTogglePrivate() {
|
||||
const turningOn = !agent.is_private;
|
||||
try {
|
||||
@@ -434,34 +485,11 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSkill(skillName: string) {
|
||||
setSelectedSkills((prev) => {
|
||||
if (prev.includes(skillName)) {
|
||||
return prev.filter((name) => name !== skillName);
|
||||
}
|
||||
return [...prev, skillName];
|
||||
});
|
||||
}
|
||||
|
||||
const enabledSkills = useMemo(
|
||||
() => skills.filter((skill) => skill.enabled),
|
||||
[skills],
|
||||
);
|
||||
|
||||
// Search over English name, Chinese name (name_zh) and description, so both
|
||||
// 中英文 work. Already-selected skills stay searchable too (selection is
|
||||
// independent of this filter).
|
||||
const filteredSkills = useMemo(() => {
|
||||
const q = skillSearch.trim().toLowerCase();
|
||||
if (!q) return enabledSkills;
|
||||
return enabledSkills.filter(
|
||||
(skill) =>
|
||||
skill.name.toLowerCase().includes(q) ||
|
||||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
|
||||
(skill.description ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [enabledSkills, skillSearch]);
|
||||
|
||||
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
|
||||
// both the card header and the compact list row.
|
||||
const statusBadges = (
|
||||
@@ -831,7 +859,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑智能体:{desensitize(agent.name)}</DialogTitle>
|
||||
<DialogDescription>
|
||||
可以修改名称、描述、技能白名单、SOUL 内容与发布状态。
|
||||
可以修改名称、描述、技能白名单、SOUL 内容、常用问题与发布状态。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
@@ -859,65 +887,13 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
已选 {selectedSkills.length} 个
|
||||
</span>
|
||||
</div>
|
||||
{enabledSkills.length > 0 && (
|
||||
<div className="relative">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="搜索技能(支持中英文名称、描述)…"
|
||||
value={skillSearch}
|
||||
onChange={(e) => setSkillSearch(e.currentTarget.value)}
|
||||
className="h-8 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
|
||||
{isSkillsLoading ? (
|
||||
<p className="text-muted-foreground text-sm">技能加载中...</p>
|
||||
) : enabledSkills.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
当前没有可用技能
|
||||
</p>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">没有匹配的技能</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{filteredSkills.map((skill) => {
|
||||
const checked = selectedSkills.includes(skill.name);
|
||||
return (
|
||||
<div
|
||||
key={skill.name}
|
||||
className="flex w-full min-w-0 items-center"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={checked ? "default" : "outline"}
|
||||
onClick={() => toggleSkill(skill.name)}
|
||||
title={desensitize(skill.description ?? "")}
|
||||
className="h-8 min-w-0 flex-1 rounded-r-none"
|
||||
>
|
||||
<span className="truncate">
|
||||
{skill.name_zh?.trim()
|
||||
? skill.name_zh
|
||||
: desensitize(skill.name)}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant={checked ? "default" : "outline"}
|
||||
onClick={() => setDetailSkillName(skill.name)}
|
||||
title={t.settings.skills.viewDetailAction}
|
||||
className="h-8 w-7 rounded-l-none border-l-0"
|
||||
>
|
||||
<InfoIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SkillTransferSelector
|
||||
skills={enabledSkills}
|
||||
selected={selectedSkills}
|
||||
onChange={setSelectedSkills}
|
||||
isLoading={isSkillsLoading}
|
||||
onViewDetail={setDetailSkillName}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
不选任何技能会保存为无技能(skills = [])。
|
||||
</p>
|
||||
@@ -935,6 +911,16 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
className="min-h-40 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">常用问题</label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
这些问题会在该智能体的首页与简介栏作为推荐问题展示,点击即可一键提问。
|
||||
</p>
|
||||
<AgentQuestionEditor
|
||||
value={editCommonQuestions}
|
||||
onChange={setEditCommonQuestions}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">公开发布</p>
|
||||
@@ -956,6 +942,32 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{editPublished && !editIsPrivate && (publishSquares?.squares.length ?? 0) > 0 && (
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">发布广场</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
选择发布到哪个广场;不选则归入默认广场。每个智能体只能归属一个广场。
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={editSquareId || publishSquares?.default_square_id || ""}
|
||||
onValueChange={setEditSquareId}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-44 text-sm">
|
||||
<SelectValue placeholder="默认广场" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{publishSquares?.squares.map((sq) => (
|
||||
<SelectItem key={sq.id} value={sq.id}>
|
||||
{sq.name}
|
||||
{sq.id === publishSquares?.default_square_id ? "(默认)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">加入私密广场</p>
|
||||
@@ -1034,6 +1046,16 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
if (!o) setDetailSkillName(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<PublishSquarePickerDialog
|
||||
open={publishPickerOpen}
|
||||
onOpenChange={setPublishPickerOpen}
|
||||
squares={publishSquares?.squares ?? []}
|
||||
defaultSquareId={agent.square_id || publishSquares?.default_square_id}
|
||||
loading={updateAgent.isPending}
|
||||
onConfirm={(squareId) => void handlePublishToSquare(squareId)}
|
||||
description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BotIcon,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
LockIcon,
|
||||
MessageCircleQuestionIcon,
|
||||
PanelRightCloseIcon,
|
||||
PanelRightOpenIcon,
|
||||
PlusIcon,
|
||||
ShieldIcon,
|
||||
SparklesIcon,
|
||||
SquarePenIcon,
|
||||
Trash2Icon,
|
||||
UserRoundIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
||||
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
|
||||
import { SkillPickerSheet } from "@/components/workspace/agents/skill-picker-sheet";
|
||||
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
|
||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import {
|
||||
normalizeAgentQuestions,
|
||||
useDeleteAgent,
|
||||
useSetAgentFeatured,
|
||||
useUpdateAgent,
|
||||
type Agent,
|
||||
type AgentQuestion,
|
||||
} from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { usePublishSquares } from "@/core/system-settings/hooks";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Turn the admin-authored SOUL.md markdown into a clean, readable profile blurb
|
||||
* for the narrow panel. The view mode is a profile card, not a document view —
|
||||
* so we strip markdown syntax (headings, emphasis, inline code, list bullets,
|
||||
* blockquotes) rather than rendering it, keeping the line structure intact.
|
||||
*/
|
||||
function cleanProfileText(raw: string): string {
|
||||
return raw
|
||||
.split("\n")
|
||||
.map((line) =>
|
||||
line
|
||||
.replace(/^\s{0,3}#{1,6}\s*/, "")
|
||||
.replace(/^\s{0,3}>\s?/, "")
|
||||
.replace(/^(\s*)[-*+]\s+/, "$1• ")
|
||||
.replace(/(\*\*|__)(.*?)\1/g, "$2")
|
||||
.replace(/(\*|_)(.*?)\1/g, "$2")
|
||||
.replace(/`([^`]+)`/g, "$1"),
|
||||
)
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Local edit-mode draft — committed in one `updateAgent` call on 保存. */
|
||||
interface AgentDraft {
|
||||
name: string;
|
||||
description: string;
|
||||
soul: string;
|
||||
questions: AgentQuestion[];
|
||||
skills: string[];
|
||||
published: boolean;
|
||||
featured: boolean;
|
||||
}
|
||||
|
||||
function draftFromAgent(agent: Agent): AgentDraft {
|
||||
return {
|
||||
name: agent.name,
|
||||
description: agent.description ?? "",
|
||||
soul: agent.soul ?? "",
|
||||
questions: normalizeAgentQuestions(agent.common_questions),
|
||||
skills: agent.skills ?? [],
|
||||
published: agent.published,
|
||||
featured: agent.featured_order != null,
|
||||
};
|
||||
}
|
||||
|
||||
function Section({
|
||||
icon,
|
||||
title,
|
||||
extra,
|
||||
children,
|
||||
}: {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
extra?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="bg-card/60 rounded-xl border p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
|
||||
{icon}
|
||||
{title}
|
||||
</h4>
|
||||
{extra}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能体详情面板 — the right-hand agent configuration center on the agent chat
|
||||
* page. View mode shows the full profile (avatar, name, description, SOUL,
|
||||
* 常用问题, enabled skills, publish/featured state); 管理员或发布者 can flip
|
||||
* into an inline edit mode (no dialog) that edits everything locally and
|
||||
* commits in a single 保存. 添加技能 opens a secondary drawer (SkillPickerSheet).
|
||||
* Everyone else gets a read-only profile. On narrow screens (<lg) the panel
|
||||
* collapses into a rail whose button opens the same content in a Sheet, so it
|
||||
* never overlaps the chat input.
|
||||
*/
|
||||
export function AgentDetailPanel({
|
||||
agent,
|
||||
agentName,
|
||||
onAskQuestion,
|
||||
disabled,
|
||||
}: {
|
||||
agent: Agent | null | undefined;
|
||||
agentName: string;
|
||||
onAskQuestion: (text: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<AgentDraft | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
|
||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||
|
||||
const updateAgent = useUpdateAgent();
|
||||
const deleteAgent = useDeleteAgent();
|
||||
const setFeatured = useSetAgentFeatured();
|
||||
const { data: publishSquares } = usePublishSquares();
|
||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||
// Same pool the edit dialog offers: only globally enabled skills are
|
||||
// whitelist candidates.
|
||||
const enabledPool = useMemo(
|
||||
() => skills.filter((skill) => skill.enabled),
|
||||
[skills],
|
||||
);
|
||||
|
||||
// Permission model mirrors agent-card: admins manage the full catalog,
|
||||
// owners manage their own agents, everyone else is read-only.
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
const isAdmin = storedAuth?.system_role === "admin";
|
||||
const isOwned =
|
||||
!!agent &&
|
||||
!agent.builtin &&
|
||||
agent.user_id !== null &&
|
||||
agent.user_id === currentUserId;
|
||||
const canEdit = !!agent && (isOwned || isAdmin);
|
||||
// Built-ins are inherently visible to everyone, so publish toggle is moot.
|
||||
const canTogglePublish = !!agent && (isOwned || (isAdmin && !agent.builtin));
|
||||
|
||||
const isSaving = updateAgent.isPending || setFeatured.isPending;
|
||||
|
||||
const displayName = desensitize(agent?.name ?? agentName);
|
||||
const questions = useMemo(
|
||||
() => normalizeAgentQuestions(agent?.common_questions),
|
||||
[agent?.common_questions],
|
||||
);
|
||||
const soulPreview = agent?.soul?.trim()
|
||||
? cleanProfileText(desensitize(agent.soul))
|
||||
: undefined;
|
||||
|
||||
function beginEdit() {
|
||||
if (!agent || !canEdit) return;
|
||||
setDraft(draftFromAgent(agent));
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditing(false);
|
||||
setDraft(null);
|
||||
setPickerOpen(false);
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!agent || !draft) return;
|
||||
const trimmedName = draft.name.trim();
|
||||
if (!trimmedName) {
|
||||
toast.error("名称不能为空");
|
||||
return;
|
||||
}
|
||||
// 私密广场与公开发布互斥 — force what the backend will accept.
|
||||
const nextPublished = agent.is_private ? false : draft.published;
|
||||
const nextQuestions = draft.questions
|
||||
.map((q) => ({
|
||||
title: q.title.trim() || q.prompt.trim(),
|
||||
prompt: q.prompt.trim() || q.title.trim(),
|
||||
}))
|
||||
.filter((q) => q.prompt.length > 0);
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
request: {
|
||||
name: trimmedName,
|
||||
description: draft.description,
|
||||
soul: draft.soul,
|
||||
published: nextPublished,
|
||||
skills: draft.skills,
|
||||
common_questions: nextQuestions,
|
||||
},
|
||||
});
|
||||
// 首页推荐 rides a dedicated endpoint — only call it when toggled.
|
||||
const wasFeatured = agent.featured_order != null;
|
||||
if (isAdmin && draft.featured !== wasFeatured) {
|
||||
if (draft.featured && !nextPublished) {
|
||||
toast.warning("只有已发布的智能体可以加入首页推荐,已跳过该项");
|
||||
} else {
|
||||
await setFeatured.mutateAsync({
|
||||
id: agent.id,
|
||||
order: draft.featured ? Date.now() : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
toast.success("智能体已更新");
|
||||
setEditing(false);
|
||||
setDraft(null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTogglePublished() {
|
||||
if (!agent) return;
|
||||
if (!agent.published && agent.is_private) {
|
||||
toast.warning("该智能体已加入私密广场,不可发布");
|
||||
return;
|
||||
}
|
||||
// Publishing: pick a target square when any are configured.
|
||||
if (!agent.published && (publishSquares?.squares.length ?? 0) > 0) {
|
||||
setPublishPickerOpen(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
request: { published: !agent.published },
|
||||
});
|
||||
toast.success(agent.published ? "已下架" : "已发布");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublishToSquare(squareId: string) {
|
||||
if (!agent) return;
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
request: { published: true, square_id: squareId },
|
||||
});
|
||||
const sqName =
|
||||
publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场";
|
||||
toast.success(`已发布到「${sqName}」`);
|
||||
setPublishPickerOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!agent) return;
|
||||
try {
|
||||
await deleteAgent.mutateAsync(agent.id);
|
||||
toast.success("智能体已删除");
|
||||
setDeleteOpen(false);
|
||||
router.push("/page/workspace/agents");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
const statusBadges = agent && (
|
||||
<>
|
||||
{agent.builtin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
内置
|
||||
</Badge>
|
||||
)}
|
||||
{agent.published && !agent.is_private && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||
>
|
||||
<GlobeIcon className="size-3" />
|
||||
已发布
|
||||
</Badge>
|
||||
)}
|
||||
{!agent.published && !agent.builtin && (
|
||||
<Badge variant="outline" className="text-muted-foreground text-xs">
|
||||
未发布
|
||||
</Badge>
|
||||
)}
|
||||
{agent.is_private && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
||||
>
|
||||
<ShieldIcon className="size-3" />
|
||||
私密
|
||||
</Badge>
|
||||
)}
|
||||
{agent.featured_order != null && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/40 text-amber-700 dark:text-amber-300 text-xs"
|
||||
>
|
||||
<SparklesIcon className="size-3" />
|
||||
首页推荐
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const headerActions = canEdit ? (
|
||||
editing ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" onClick={() => void saveEdit()} disabled={isSaving}>
|
||||
{isSaving && <Loader2Icon className="size-3.5 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={cancelEdit}
|
||||
disabled={isSaving}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="outline" onClick={beginEdit}>
|
||||
<SquarePenIcon className="size-3.5" />
|
||||
编辑
|
||||
</Button>
|
||||
{canTogglePublish && (
|
||||
<Tooltip content={agent?.published ? "下架" : "发布"}>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => void handleTogglePublished()}
|
||||
disabled={updateAgent.isPending}
|
||||
aria-label={agent?.published ? "下架" : "发布"}
|
||||
>
|
||||
{agent?.published ? (
|
||||
<LockIcon className="size-3.5" />
|
||||
) : (
|
||||
<GlobeIcon className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content="删除智能体">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
aria-label="删除智能体"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
) : null;
|
||||
|
||||
const skillNamesShown = editing && draft ? draft.skills : (agent?.skills ?? []);
|
||||
|
||||
const body = !agent ? (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center gap-2 text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
智能体信息加载中…
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 px-4 py-4">
|
||||
{/* ── Identity / 基础信息 ─────────────────────────────────────────── */}
|
||||
<section className="bg-card/60 rounded-xl border p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-primary/10 ring-primary/15 flex size-12 shrink-0 items-center justify-center rounded-2xl ring-1">
|
||||
<BotIcon className="text-primary size-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
{editing && draft ? (
|
||||
<Input
|
||||
value={draft.name}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, name: e.currentTarget.value })
|
||||
}
|
||||
placeholder="智能体名称"
|
||||
className="h-8 text-sm font-semibold"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="truncate text-base font-bold"
|
||||
title={displayName}
|
||||
>
|
||||
{displayName}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{statusBadges}
|
||||
</div>
|
||||
{!editing && agent.owner_username && !agent.builtin && (
|
||||
<div className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<UserRoundIcon className="size-3 shrink-0" />
|
||||
发布者:{desensitize(agent.owner_username)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
{editing && draft ? (
|
||||
<Textarea
|
||||
value={draft.description}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, description: e.currentTarget.value })
|
||||
}
|
||||
placeholder="智能体描述"
|
||||
className="min-h-16 text-sm"
|
||||
/>
|
||||
) : agent.description ? (
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{desensitize(agent.description)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground/60 text-sm">暂无描述</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Publish / featured toggles — only meaningful in edit mode. */}
|
||||
{editing && draft && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">公开发布</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{agent.is_private
|
||||
? "已加入私密广场,不可发布"
|
||||
: "打开后对所有用户可见"}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.published && !agent.is_private}
|
||||
disabled={agent.is_private}
|
||||
onCheckedChange={(v) => setDraft({ ...draft, published: v })}
|
||||
/>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">首页推荐</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
加入首页选择器(需已发布)
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={draft.featured}
|
||||
onCheckedChange={(v) => setDraft({ ...draft, featured: v })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── SOUL / 人设 ────────────────────────────────────────────────── */}
|
||||
{(editing || soulPreview) && (
|
||||
<Section
|
||||
icon={<SparklesIcon className="size-3.5" />}
|
||||
title="人设(SOUL)"
|
||||
>
|
||||
{editing && draft ? (
|
||||
<Textarea
|
||||
value={draft.soul}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, soul: e.currentTarget.value })
|
||||
}
|
||||
placeholder="输入 SOUL.md 内容"
|
||||
className="min-h-40 font-mono text-xs"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground max-h-60 overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{soulPreview}
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
|
||||
{(editing || questions.length > 0) && (
|
||||
<Section
|
||||
icon={<MessageCircleQuestionIcon className="size-3.5" />}
|
||||
title="常用问题"
|
||||
extra={
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{(editing && draft ? draft.questions : questions).length} 个
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{editing && draft ? (
|
||||
<AgentQuestionEditor
|
||||
value={draft.questions}
|
||||
onChange={(next) => setDraft({ ...draft, questions: next })}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{questions.map((question, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onAskQuestion(question.prompt)}
|
||||
title={desensitize(question.prompt)}
|
||||
className={cn(
|
||||
"border-border/60 bg-background/60 hover:bg-accent hover:border-border w-full rounded-xl border px-3 py-2.5 text-left text-sm leading-relaxed transition-colors",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{desensitize(question.title)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* ── 技能管理 ───────────────────────────────────────────────────── */}
|
||||
<Section
|
||||
icon={<SparklesIcon className="size-3.5" />}
|
||||
title={`已启用技能 ${skillNamesShown.length} 个`}
|
||||
extra={
|
||||
editing ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
<PlusIcon className="size-3.5" />
|
||||
添加技能
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<EnabledSkillList
|
||||
skillNames={skillNamesShown}
|
||||
skills={skills}
|
||||
isLoading={isSkillsLoading}
|
||||
editing={editing}
|
||||
onRemove={
|
||||
editing && draft
|
||||
? (name) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
skills: draft.skills.filter((n) => n !== name),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onViewDetail={setDetailSkillName}
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
|
||||
const panelChrome = (collapseButton: ReactNode) => (
|
||||
<>
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<span className="text-sm font-semibold">智能体信息</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{headerActions}
|
||||
{collapseButton}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
|
||||
{collapsed ? (
|
||||
<div className="hidden shrink-0 flex-col items-center border-l pt-3 lg:flex">
|
||||
<Tooltip content="智能体信息">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setCollapsed(false)}
|
||||
aria-label="展开智能体信息"
|
||||
>
|
||||
<PanelRightOpenIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<aside className="bg-card/40 hidden h-full w-[360px] shrink-0 flex-col border-l lg:flex">
|
||||
{panelChrome(
|
||||
<Tooltip content="收起">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setCollapsed(true)}
|
||||
aria-label="收起智能体信息"
|
||||
>
|
||||
<PanelRightCloseIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>,
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* ── Narrow screens (<lg): rail trigger + drawer with the same body ── */}
|
||||
<div className="flex shrink-0 flex-col items-center border-l pt-3 lg:hidden">
|
||||
<Tooltip content="智能体信息">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
aria-label="打开智能体信息"
|
||||
>
|
||||
<InfoIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
|
||||
<SheetHeader className="border-b px-4 pb-3 pt-4">
|
||||
<SheetTitle className="flex items-center justify-between text-sm">
|
||||
智能体信息
|
||||
<span className="mr-6">{headerActions}</span>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* ── Secondary surfaces ───────────────────────────────────────────── */}
|
||||
<SkillPickerSheet
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
skills={enabledPool}
|
||||
isLoading={isSkillsLoading}
|
||||
selected={editing && draft ? draft.skills : []}
|
||||
onChange={(next) => {
|
||||
if (editing && draft) setDraft({ ...draft, skills: next });
|
||||
}}
|
||||
onViewDetail={setDetailSkillName}
|
||||
/>
|
||||
|
||||
<SkillDetailDialog
|
||||
skillName={detailSkillName}
|
||||
open={detailSkillName !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setDetailSkillName(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除智能体</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除「{displayName}」吗?该操作不可恢复。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteOpen(false)}
|
||||
disabled={deleteAgent.isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={deleteAgent.isPending}
|
||||
>
|
||||
{deleteAgent.isPending ? "删除中…" : "删除"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<PublishSquarePickerDialog
|
||||
open={publishPickerOpen}
|
||||
onOpenChange={setPublishPickerOpen}
|
||||
squares={publishSquares?.squares ?? []}
|
||||
defaultSquareId={agent?.square_id || publishSquares?.default_square_id}
|
||||
loading={updateAgent.isPending}
|
||||
onConfirm={(squareId) => void handlePublishToSquare(squareId)}
|
||||
description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
||||
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
||||
import type { Agent, AgentSort } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { usePrivateSquareStatus, useViewMode } from "@/core/system-settings/hooks";
|
||||
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
|
||||
|
||||
import { AgentCard } from "./agent-card";
|
||||
|
||||
@@ -30,21 +30,21 @@ export function AgentGallery() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [viewMode, setViewMode] = useViewMode("agent");
|
||||
const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">(
|
||||
"mine",
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<string>("mine");
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
const [sort, setSort] = useState<AgentSort>("time_desc");
|
||||
const { data: publishSquares } = usePublishSquares();
|
||||
// Each configured publish square (发布广场) becomes its own browse tab; the
|
||||
// backend always returns at least one (the built-in default).
|
||||
const squareTabs = publishSquares?.squares ?? [];
|
||||
// Publisher filter: click a card's 发布者 to keep only that user's agents.
|
||||
// {id} is the owner user_id (server filter), {label} the desensitized name.
|
||||
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
|
||||
null,
|
||||
);
|
||||
// Per-tab 1-based page index, preserved while switching tabs.
|
||||
const [pages, setPages] = useState<{ all: number; mine: number; private: number }>(
|
||||
{ all: 1, mine: 1, private: 1 },
|
||||
);
|
||||
// Per-tab 1-based page index (keyed by tab value), preserved across switches.
|
||||
const [pages, setPages] = useState<Record<string, number>>({});
|
||||
|
||||
// Search + tag filtering are both server-side now. The backend mirrors the
|
||||
// frontend's desensitize map, so typing the displayed abbreviation (e.g.
|
||||
@@ -52,9 +52,22 @@ export function AgentGallery() {
|
||||
// filters change, every tab returns to its first page.
|
||||
const tagKey = tagIds.join(",");
|
||||
useEffect(() => {
|
||||
setPages({ all: 1, mine: 1, private: 1 });
|
||||
setPages({});
|
||||
}, [search, tagKey, sort, publisher?.id]);
|
||||
|
||||
// If the active square tab disappears (admin removed that square), fall back
|
||||
// to "我的" so the gallery never points at a dead tab.
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeTab !== "mine" &&
|
||||
activeTab !== "private" &&
|
||||
squareTabs.length > 0 &&
|
||||
!squareTabs.some((sq) => sq.id === activeTab)
|
||||
) {
|
||||
setActiveTab("mine");
|
||||
}
|
||||
}, [activeTab, squareTabs]);
|
||||
|
||||
const { data: privateStatus } = usePrivateSquareStatus();
|
||||
const privateFeatureAvailable = !!privateStatus?.enabled;
|
||||
const privateFeatureUsable =
|
||||
@@ -68,8 +81,9 @@ export function AgentGallery() {
|
||||
|
||||
// Only the active tab fetches. "广场" maps to the server "square" scope
|
||||
// (built-in/published, never private), "我的" to "mine" (owned + favorited).
|
||||
const isSquareTab = activeTab !== "mine" && activeTab !== "private";
|
||||
const mainScope = activeTab === "mine" ? "mine" : "square";
|
||||
const mainPage = activeTab === "mine" ? pages.mine : pages.all;
|
||||
const mainPage = pages[activeTab] ?? 1;
|
||||
const {
|
||||
agents: mainAgents,
|
||||
total: mainTotal,
|
||||
@@ -84,6 +98,8 @@ export function AgentGallery() {
|
||||
pageSize: PAGE_SIZE,
|
||||
enabled: activeTab !== "private",
|
||||
owner: publisher?.id,
|
||||
// Each square tab fetches only that square's agents.
|
||||
square: isSquareTab ? activeTab : undefined,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -119,7 +135,7 @@ export function AgentGallery() {
|
||||
const showSearchBar = activeTab !== "private" || privateUnlocked;
|
||||
|
||||
const activeTotal = activeTab === "private" ? privateTotal : mainTotal;
|
||||
const setTabPage = (tab: "all" | "mine" | "private", page: number) =>
|
||||
const setTabPage = (tab: string, page: number) =>
|
||||
setPages((prev) => ({ ...prev, [tab]: page }));
|
||||
|
||||
const handleNewAgent = () => {
|
||||
@@ -174,7 +190,7 @@ export function AgentGallery() {
|
||||
list: Agent[];
|
||||
total: number;
|
||||
isLoading: boolean;
|
||||
tab: "all" | "mine" | "private";
|
||||
tab: string;
|
||||
emptyDescription: string;
|
||||
showNewButton?: boolean;
|
||||
}) => {
|
||||
@@ -195,7 +211,7 @@ export function AgentGallery() {
|
||||
<div className="flex flex-col gap-4">
|
||||
{renderGrid(list)}
|
||||
<Pagination
|
||||
page={pages[tab]}
|
||||
page={pages[tab] ?? 1}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={total}
|
||||
onPageChange={(p) => setTabPage(tab, p)}
|
||||
@@ -324,7 +340,7 @@ export function AgentGallery() {
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => {
|
||||
setActiveTab(v as "all" | "mine" | "private");
|
||||
setActiveTab(v);
|
||||
// Each tab starts from a clean slate: switching tabs clears the
|
||||
// keyword / tag / sort / publisher filters instead of carrying them
|
||||
// over. The TagSearchBar's own input box is reset via its key below.
|
||||
@@ -337,9 +353,13 @@ export function AgentGallery() {
|
||||
>
|
||||
<div className="mx-6 mt-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<TabsList className="w-auto self-start">
|
||||
<TabsList className="w-auto max-w-full flex-wrap justify-start self-start">
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
<TabsTrigger value="all">广场</TabsTrigger>
|
||||
{squareTabs.map((sq) => (
|
||||
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
|
||||
{sq.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
{privateFeatureAvailable && (
|
||||
<TabsTrigger value="private">
|
||||
<LockIcon className="mr-1 h-3.5 w-3.5" />
|
||||
@@ -414,15 +434,21 @@ export function AgentGallery() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
{renderListBody({
|
||||
list: mainAgents,
|
||||
total: mainTotal,
|
||||
isLoading: mainLoading,
|
||||
tab: "all",
|
||||
emptyDescription: t.agents.emptyDescription,
|
||||
})}
|
||||
</TabsContent>
|
||||
{squareTabs.map((sq) => (
|
||||
<TabsContent
|
||||
key={sq.id}
|
||||
value={sq.id}
|
||||
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||
>
|
||||
{renderListBody({
|
||||
list: mainAgents,
|
||||
total: mainTotal,
|
||||
isLoading: mainLoading,
|
||||
tab: sq.id,
|
||||
emptyDescription: `「${sq.name}」广场暂时还没有智能体。`,
|
||||
})}
|
||||
</TabsContent>
|
||||
))}
|
||||
|
||||
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
{renderListBody({
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
PlusIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { AgentQuestion } from "@/core/agents";
|
||||
|
||||
/**
|
||||
* Editable list of an agent's 常用问题 (recommended questions). Each row has a
|
||||
* short `title` (the chip label) and the full `prompt` sent on click. Supports
|
||||
* add / remove / inline edit / reorder (move up & down). Pure controlled
|
||||
* component — persistence stays with the caller (edit dialog or detail panel).
|
||||
*/
|
||||
export function AgentQuestionEditor({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: AgentQuestion[];
|
||||
onChange: (next: AgentQuestion[]) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
function update(index: number, patch: Partial<AgentQuestion>) {
|
||||
const next = [...value];
|
||||
next[index] = { ...next[index]!, ...patch };
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
onChange(value.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function move(index: number, delta: -1 | 1) {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= value.length) return;
|
||||
const next = [...value];
|
||||
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{value.length === 0 && (
|
||||
<p className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-xs">
|
||||
尚未配置常用问题,点击下方按钮添加。
|
||||
</p>
|
||||
)}
|
||||
{value.map((item, index) => (
|
||||
<div key={index} className="bg-background/60 space-y-1.5 rounded-lg border p-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={item.title}
|
||||
disabled={disabled}
|
||||
placeholder="标题(如:能蒸馏哪些人)"
|
||||
onChange={(e) => update(index, { title: e.currentTarget.value })}
|
||||
className="h-8 text-sm font-medium"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={disabled || index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
aria-label="上移"
|
||||
title="上移"
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={disabled || index === value.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
aria-label="下移"
|
||||
title="下移"
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={disabled}
|
||||
onClick={() => remove(index)}
|
||||
aria-label="删除问题"
|
||||
title="删除问题"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={item.prompt}
|
||||
disabled={disabled}
|
||||
placeholder="点击后实际发送的完整问题内容…"
|
||||
onChange={(e) => update(index, { prompt: e.currentTarget.value })}
|
||||
className="min-h-16 resize-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange([...value, { title: "", prompt: "" }])}
|
||||
>
|
||||
<PlusIcon className="size-3.5" /> 添加问题
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { InboxIcon, InfoIcon, XIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Skill } from "@/core/skills";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Chinese display label with English-name fallback. */
|
||||
export function skillDisplayLabel(skill: Skill) {
|
||||
return skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name);
|
||||
}
|
||||
|
||||
/** First character of the display label — the letter avatar on skill cards. */
|
||||
export function skillInitial(skill: Skill) {
|
||||
const label = skill.name_zh?.trim() || skill.name;
|
||||
return label.trim().charAt(0).toUpperCase() || "?";
|
||||
}
|
||||
|
||||
/** Human label for the skill's provenance (内置 / 用户上传 / 对话沉淀). */
|
||||
export function skillSourceLabel(skill: Skill): string | null {
|
||||
if (skill.builtin) return "内置";
|
||||
if (skill.source === "upload") return "用户上传";
|
||||
if (skill.source === "agent") return "对话沉淀";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent's enabled-skill list shown in the detail panel: one card per
|
||||
* whitelisted skill name with a letter avatar, Chinese name, category/source
|
||||
* badges and the short description. In editing mode each card gains a remove
|
||||
* button. Skill names with no metadata in the pool (e.g. a skill that was
|
||||
* deleted after being whitelisted) still render as a degraded card so the
|
||||
* whitelist stays visible and removable.
|
||||
*/
|
||||
export function EnabledSkillList({
|
||||
skillNames,
|
||||
skills,
|
||||
editing,
|
||||
onRemove,
|
||||
onViewDetail,
|
||||
isLoading,
|
||||
}: {
|
||||
/** The agent's persisted whitelist — `agent.skills` (English names). */
|
||||
skillNames: string[];
|
||||
/** Full skill pool used to resolve metadata for each name. */
|
||||
skills: Skill[];
|
||||
editing?: boolean;
|
||||
onRemove?: (name: string) => void;
|
||||
onViewDetail?: (name: string) => void;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const byName = useMemo(
|
||||
() => new Map(skills.map((s) => [s.name, s])),
|
||||
[skills],
|
||||
);
|
||||
|
||||
if (isLoading && skillNames.length > 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-xs">
|
||||
技能加载中…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (skillNames.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-1.5 rounded-lg border border-dashed px-3 py-5 text-xs">
|
||||
<InboxIcon className="size-5 opacity-50" />
|
||||
<span>尚未启用任何技能</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{skillNames.map((name) => {
|
||||
const skill = byName.get(name);
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className={cn(
|
||||
"group/skill bg-background/60 flex items-start gap-2.5 rounded-xl border p-2.5 transition-colors",
|
||||
onViewDetail && "hover:border-primary/40 hover:bg-accent/40",
|
||||
)}
|
||||
>
|
||||
<div className="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-lg text-sm font-semibold">
|
||||
{skill ? skillInitial(skill) : "?"}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
className="min-w-0 truncate text-sm font-medium"
|
||||
title={skill ? skillDisplayLabel(skill) : desensitize(name)}
|
||||
>
|
||||
{skill ? skillDisplayLabel(skill) : desensitize(name)}
|
||||
</span>
|
||||
{skill?.category?.trim() && (
|
||||
<Badge variant="secondary" className="shrink-0 text-[10px]">
|
||||
{skill.category}
|
||||
</Badge>
|
||||
)}
|
||||
{skill && skillSourceLabel(skill) && (
|
||||
<Badge variant="outline" className="shrink-0 text-[10px]">
|
||||
{skillSourceLabel(skill)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
|
||||
{skill
|
||||
? skill.description?.trim()
|
||||
? desensitize(skill.description)
|
||||
: "暂无描述"
|
||||
: "该技能已不存在或不可用,可将其移除。"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{skill && onViewDetail && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground size-7"
|
||||
onClick={() => onViewDetail(name)}
|
||||
title="查看技能详情"
|
||||
aria-label="查看技能详情"
|
||||
>
|
||||
<InfoIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{editing && onRemove && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-destructive size-7"
|
||||
onClick={() => onRemove(name)}
|
||||
title="移除技能"
|
||||
aria-label="移除技能"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
skillDisplayLabel,
|
||||
skillInitial,
|
||||
skillSourceLabel,
|
||||
} from "@/components/workspace/agents/enabled-skill-list";
|
||||
import type { Skill } from "@/core/skills";
|
||||
import {
|
||||
useInstallSkillUpload,
|
||||
useValidateSkillUpload,
|
||||
} from "@/core/skills/hooks";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ALL_CATEGORIES = "__all__";
|
||||
|
||||
type SourceFilter = "all" | "builtin" | "upload" | "agent";
|
||||
|
||||
const SOURCE_OPTIONS: { value: SourceFilter; label: string }[] = [
|
||||
{ value: "all", label: "全部来源" },
|
||||
{ value: "builtin", label: "内置技能" },
|
||||
{ value: "upload", label: "用户上传" },
|
||||
{ value: "agent", label: "对话沉淀" },
|
||||
];
|
||||
|
||||
function matchesSource(skill: Skill, source: SourceFilter) {
|
||||
switch (source) {
|
||||
case "all":
|
||||
return true;
|
||||
case "builtin":
|
||||
return !!skill.builtin;
|
||||
case "upload":
|
||||
return skill.source === "upload";
|
||||
case "agent":
|
||||
return skill.source === "agent";
|
||||
}
|
||||
}
|
||||
|
||||
function matchesQuery(skill: Skill, q: string) {
|
||||
if (!q) return true;
|
||||
return (
|
||||
skill.name.toLowerCase().includes(q) ||
|
||||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
|
||||
(skill.description ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 技能选择器 — a secondary right-hand drawer opened from the agent detail
|
||||
* panel's "添加技能" button. Search (中英文名称 + 描述), category tabs, source
|
||||
* filter (内置 / 用户上传 / 对话沉淀), skill-pack upload, card grid with
|
||||
* enable/disable switches, bulk add-filtered / clear-selected, and a footer
|
||||
* showing the live selected count. Selection is controlled by the caller (the
|
||||
* panel's local draft) — nothing here persists until the panel saves.
|
||||
*/
|
||||
export function SkillPickerSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
skills,
|
||||
isLoading,
|
||||
selected,
|
||||
onChange,
|
||||
onViewDetail,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Selectable pool (caller pre-filters by `enabled`). */
|
||||
skills: Skill[];
|
||||
isLoading?: boolean;
|
||||
/** Currently selected skill names — the draft `skills: string[]`. */
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
onViewDetail?: (name: string) => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [category, setCategory] = useState<string>(ALL_CATEGORIES);
|
||||
const [source, setSource] = useState<SourceFilter>("all");
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const validateUpload = useValidateSkillUpload();
|
||||
const installUpload = useInstallSkillUpload();
|
||||
const isUploading = validateUpload.isPending || installUpload.isPending;
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const skill of skills) {
|
||||
if (skill.category?.trim()) set.add(skill.category.trim());
|
||||
}
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||
}, [skills]);
|
||||
|
||||
const query = search.trim().toLowerCase();
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
skills.filter(
|
||||
(skill) =>
|
||||
matchesQuery(skill, query) &&
|
||||
matchesSource(skill, source) &&
|
||||
(category === ALL_CATEGORIES || skill.category?.trim() === category),
|
||||
),
|
||||
[skills, query, source, category],
|
||||
);
|
||||
|
||||
const filteredUnselected = useMemo(
|
||||
() => filtered.filter((skill) => !selectedSet.has(skill.name)),
|
||||
[filtered, selectedSet],
|
||||
);
|
||||
|
||||
function toggle(name: string) {
|
||||
if (selectedSet.has(name)) {
|
||||
onChange(selected.filter((n) => n !== name));
|
||||
} else {
|
||||
onChange([...selected, name]);
|
||||
}
|
||||
}
|
||||
|
||||
function addAllFiltered() {
|
||||
if (filteredUnselected.length === 0) return;
|
||||
onChange([...selected, ...filteredUnselected.map((s) => s.name)]);
|
||||
}
|
||||
|
||||
function clearSelected() {
|
||||
if (selected.length === 0) return;
|
||||
onChange([]);
|
||||
}
|
||||
|
||||
async function handleUploadFile(file: File) {
|
||||
const lower = file.name.toLowerCase();
|
||||
if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) {
|
||||
toast.error("仅支持 .zip / .skill 技能包");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const validation = await validateUpload.mutateAsync(file);
|
||||
if (validation.conflict) {
|
||||
toast.warning(
|
||||
`已存在同名技能「${validation.skill_name}」,请到「技能」设置页上传以选择是否覆盖。`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = await installUpload.mutateAsync({
|
||||
file,
|
||||
targetCategory: "custom",
|
||||
});
|
||||
// Auto-select the freshly installed skill so the user doesn't have to
|
||||
// hunt for it in the refreshed pool.
|
||||
if (!selectedSet.has(result.skill_name)) {
|
||||
onChange([...selected, result.skill_name]);
|
||||
}
|
||||
toast.success(`技能「${result.skill_name}」已安装并加入已选`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex w-full flex-col gap-0 p-0 sm:max-w-xl"
|
||||
>
|
||||
<SheetHeader className="border-b px-5 pb-3 pt-4">
|
||||
<SheetTitle>添加技能</SheetTitle>
|
||||
<SheetDescription>
|
||||
为该智能体启用技能。修改后需在右侧面板点击「保存」才会生效。
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Toolbar: search / source / upload */}
|
||||
<div className="space-y-2.5 border-b px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="搜索技能(中英文名称、描述)…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={source}
|
||||
onValueChange={(v) => setSource(v as SourceFilter)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-28 shrink-0 px-2 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{SOURCE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 shrink-0 text-xs"
|
||||
disabled={isUploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="上传 .zip / .skill 技能包"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<UploadIcon className="size-3.5" />
|
||||
)}
|
||||
上传技能
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip,.skill"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void handleUploadFile(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category tabs */}
|
||||
{categories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{[ALL_CATEGORIES, ...categories].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setCategory(c)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs transition-colors",
|
||||
category === c
|
||||
? "border-primary/50 bg-primary/10 text-primary font-medium"
|
||||
: "border-border text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{c === ALL_CATEGORIES ? "全部" : c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{filtered.length} 个匹配技能
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs"
|
||||
disabled={filteredUnselected.length === 0}
|
||||
onClick={addAllFiltered}
|
||||
>
|
||||
<PlusIcon className="size-3.5" />
|
||||
添加当前筛选结果
|
||||
{filteredUnselected.length > 0
|
||||
? `(${filteredUnselected.length})`
|
||||
: ""}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground h-7 text-xs"
|
||||
disabled={selected.length === 0}
|
||||
onClick={clearSelected}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
清空已选
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skill card grid */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-3">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex h-32 items-center justify-center gap-2 text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
技能加载中…
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-muted-foreground flex h-32 flex-col items-center justify-center gap-1.5 text-sm">
|
||||
<span>没有匹配的技能</span>
|
||||
{(query || category !== ALL_CATEGORIES || source !== "all") && (
|
||||
<span className="text-xs">试试调整搜索词或筛选条件</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{filtered.map((skill) => {
|
||||
const isSelected = selectedSet.has(skill.name);
|
||||
return (
|
||||
<div
|
||||
key={skill.name}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => toggle(skill.name)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
toggle(skill.name);
|
||||
}
|
||||
}}
|
||||
title={isSelected ? "点击禁用" : "点击启用"}
|
||||
className={cn(
|
||||
"group/card flex cursor-pointer flex-col gap-2 rounded-xl border p-3 transition-colors",
|
||||
isSelected
|
||||
? "border-primary/50 bg-primary/5 ring-primary/15 ring-1"
|
||||
: "bg-background/60 hover:border-primary/40 hover:bg-accent/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold",
|
||||
isSelected
|
||||
? "bg-primary/15 text-primary"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{skillInitial(skill)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="truncate text-sm font-medium"
|
||||
title={skillDisplayLabel(skill)}
|
||||
>
|
||||
{skillDisplayLabel(skill)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
|
||||
{skill.description?.trim()
|
||||
? desensitize(skill.description)
|
||||
: "暂无描述"}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggle(skill.name)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={isSelected ? "禁用技能" : "启用技能"}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{skill.category?.trim() && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{skill.category}
|
||||
</Badge>
|
||||
)}
|
||||
{skillSourceLabel(skill) && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{skillSourceLabel(skill)}
|
||||
</Badge>
|
||||
)}
|
||||
{isSelected && (
|
||||
<Badge className="bg-primary/15 text-primary hover:bg-primary/15 text-[10px]">
|
||||
<CheckIcon className="size-2.5" />
|
||||
已启用
|
||||
</Badge>
|
||||
)}
|
||||
{onViewDetail && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewDetail(skill.name);
|
||||
}}
|
||||
title="查看技能详情"
|
||||
className="text-muted-foreground hover:text-foreground ml-auto opacity-60 transition-opacity group-hover/card:opacity-100"
|
||||
>
|
||||
<InfoIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: selected count + done */}
|
||||
<div className="flex shrink-0 items-center justify-between border-t px-5 py-3">
|
||||
<span className="text-sm">
|
||||
已选 <span className="text-primary font-semibold tabular-nums">{selected.length}</span> 个技能
|
||||
</span>
|
||||
<Button size="sm" onClick={() => onOpenChange(false)}>
|
||||
完成
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
InboxIcon,
|
||||
InfoIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { Skill } from "@/core/skills";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
const ALL_CATEGORIES = "__all__";
|
||||
|
||||
interface SkillTransferSelectorProps {
|
||||
/** Full pool of selectable skills (caller pre-filters by `enabled`). */
|
||||
skills: Skill[];
|
||||
/** Currently selected skill names — the persisted `skills: string[]`. */
|
||||
selected: string[];
|
||||
/** Emits the next selection (always a `string[]`, `[]` when empty). */
|
||||
onChange: (next: string[]) => void;
|
||||
isLoading?: boolean;
|
||||
/** Open the skill detail dialog for a given skill name. */
|
||||
onViewDetail?: (name: string) => void;
|
||||
}
|
||||
|
||||
function skillLabel(skill: Skill) {
|
||||
return skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name);
|
||||
}
|
||||
|
||||
function matchesQuery(skill: Skill, q: string) {
|
||||
if (!q) return true;
|
||||
return (
|
||||
skill.name.toLowerCase().includes(q) ||
|
||||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
|
||||
(skill.description ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillTransferSelector({
|
||||
skills,
|
||||
selected,
|
||||
onChange,
|
||||
isLoading,
|
||||
onViewDetail,
|
||||
}: SkillTransferSelectorProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [category, setCategory] = useState<string>(ALL_CATEGORIES);
|
||||
const [availablePage, setAvailablePage] = useState(0);
|
||||
const [selectedPage, setSelectedPage] = useState(0);
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
||||
|
||||
// Distinct categories for the filter dropdown (skip blank ones).
|
||||
const categories = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const skill of skills) {
|
||||
if (skill.category?.trim()) set.add(skill.category.trim());
|
||||
}
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||
}, [skills]);
|
||||
|
||||
const query = search.trim().toLowerCase();
|
||||
|
||||
// Left pane: unselected skills, filtered by search + category.
|
||||
const availableSkills = useMemo(() => {
|
||||
return skills.filter(
|
||||
(skill) =>
|
||||
!selectedSet.has(skill.name) &&
|
||||
matchesQuery(skill, query) &&
|
||||
(category === ALL_CATEGORIES || skill.category?.trim() === category),
|
||||
);
|
||||
}, [skills, selectedSet, query, category]);
|
||||
|
||||
// Right pane: selected skills, filtered by search only (category filtering
|
||||
// the chosen set would just hide active selections, which is confusing).
|
||||
const selectedSkills = useMemo(() => {
|
||||
const byName = new Map(skills.map((s) => [s.name, s]));
|
||||
return selected
|
||||
.map((name) => byName.get(name))
|
||||
.filter((s): s is Skill => Boolean(s) && matchesQuery(s!, query));
|
||||
}, [skills, selected, query]);
|
||||
|
||||
// Clamp pagination whenever the underlying lists shrink (search/category
|
||||
// changes, items moved between panes).
|
||||
const availablePages = Math.max(1, Math.ceil(availableSkills.length / PAGE_SIZE));
|
||||
const selectedPages = Math.max(1, Math.ceil(selectedSkills.length / PAGE_SIZE));
|
||||
useEffect(() => {
|
||||
if (availablePage > availablePages - 1) setAvailablePage(availablePages - 1);
|
||||
}, [availablePage, availablePages]);
|
||||
useEffect(() => {
|
||||
if (selectedPage > selectedPages - 1) setSelectedPage(selectedPages - 1);
|
||||
}, [selectedPage, selectedPages]);
|
||||
|
||||
const availableView = availableSkills.slice(
|
||||
availablePage * PAGE_SIZE,
|
||||
availablePage * PAGE_SIZE + PAGE_SIZE,
|
||||
);
|
||||
const selectedView = selectedSkills.slice(
|
||||
selectedPage * PAGE_SIZE,
|
||||
selectedPage * PAGE_SIZE + PAGE_SIZE,
|
||||
);
|
||||
|
||||
function add(name: string) {
|
||||
if (selectedSet.has(name)) return;
|
||||
onChange([...selected, name]);
|
||||
}
|
||||
function remove(name: string) {
|
||||
onChange(selected.filter((n) => n !== name));
|
||||
}
|
||||
function addAllFiltered() {
|
||||
if (availableSkills.length === 0) return;
|
||||
const merged = [...selected];
|
||||
for (const skill of availableSkills) {
|
||||
if (!selectedSet.has(skill.name)) merged.push(skill.name);
|
||||
}
|
||||
onChange(merged);
|
||||
}
|
||||
function clearSelected() {
|
||||
if (selected.length === 0) return;
|
||||
onChange([]);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="text-muted-foreground rounded-md border p-4 text-sm dark:border-[#1B477E] dark:bg-[#022248]">
|
||||
技能加载中...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (skills.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground rounded-md border p-4 text-sm dark:border-[#1B477E] dark:bg-[#022248]">
|
||||
当前没有可用技能。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{/* Toolbar: search + category filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="搜索全部技能(中英文名称、描述)…"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.currentTarget.value);
|
||||
setAvailablePage(0);
|
||||
setSelectedPage(0);
|
||||
}}
|
||||
className="h-8 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
{categories.length > 0 && (
|
||||
<Select
|
||||
value={category}
|
||||
onValueChange={(v) => {
|
||||
setCategory(v);
|
||||
setAvailablePage(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"border-input bg-background h-8 w-32 px-2 text-xs shadow-xs outline-none",
|
||||
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:hover:bg-[#022248]",
|
||||
"[&_svg]:dark:text-[#AACEE2]",
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="全部分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
className="dark:border-[#1B477E] dark:bg-[#022248]"
|
||||
>
|
||||
<SelectItem
|
||||
value={ALL_CATEGORIES}
|
||||
className="dark:text-[#AACEFF] dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white"
|
||||
>
|
||||
全部分类
|
||||
</SelectItem>
|
||||
{categories.map((c) => (
|
||||
<SelectItem
|
||||
key={c}
|
||||
value={c}
|
||||
className="dark:text-[#AACEFF] dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white"
|
||||
>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bulk actions */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs"
|
||||
disabled={availableSkills.length === 0}
|
||||
onClick={addAllFiltered}
|
||||
>
|
||||
<PlusIcon className="size-3.5" />
|
||||
添加当前搜索结果{availableSkills.length > 0 ? `(${availableSkills.length})` : ""}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground h-7 text-xs"
|
||||
disabled={selected.length === 0}
|
||||
onClick={clearSelected}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
清空已选
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Two-column transfer panes */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<SkillPane
|
||||
title="可用技能"
|
||||
count={availableSkills.length}
|
||||
items={availableView}
|
||||
variant="available"
|
||||
page={availablePage}
|
||||
totalPages={availablePages}
|
||||
onPageChange={setAvailablePage}
|
||||
onPick={add}
|
||||
onViewDetail={onViewDetail}
|
||||
emptyHint={query || category !== ALL_CATEGORIES ? "没有匹配的技能" : "已全部启用"}
|
||||
/>
|
||||
<SkillPane
|
||||
title="已启用技能"
|
||||
count={selectedSkills.length}
|
||||
items={selectedView}
|
||||
variant="selected"
|
||||
page={selectedPage}
|
||||
totalPages={selectedPages}
|
||||
onPageChange={setSelectedPage}
|
||||
onPick={remove}
|
||||
onViewDetail={onViewDetail}
|
||||
emptyHint={query ? "没有匹配的已选技能" : "尚未启用任何技能"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkillPaneProps {
|
||||
title: string;
|
||||
count: number;
|
||||
items: Skill[];
|
||||
variant: "available" | "selected";
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPick: (name: string) => void;
|
||||
onViewDetail?: (name: string) => void;
|
||||
emptyHint: string;
|
||||
}
|
||||
|
||||
function SkillPane({
|
||||
title,
|
||||
count,
|
||||
items,
|
||||
variant,
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
onPick,
|
||||
onViewDetail,
|
||||
emptyHint,
|
||||
}: SkillPaneProps) {
|
||||
const isSelected = variant === "selected";
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-md border dark:border-[#1B477E] dark:bg-[#022248]">
|
||||
<div className="bg-muted/40 flex items-center justify-between border-b px-2.5 py-1.5 text-xs font-medium dark:border-[#1B477E] dark:bg-[#02305f]">
|
||||
<span>{title}</span>
|
||||
<span className="text-muted-foreground tabular-nums">{count}</span>
|
||||
</div>
|
||||
<div className="h-[240px] space-y-1.5 overflow-y-auto p-2">
|
||||
{items.length === 0 ? (
|
||||
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-1.5 text-xs">
|
||||
<InboxIcon className="size-5 opacity-50" />
|
||||
<span>{emptyHint}</span>
|
||||
</div>
|
||||
) : (
|
||||
items.map((skill) => (
|
||||
<SkillItemCard
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
isSelected={isSelected}
|
||||
onPick={() => onPick(skill.name)}
|
||||
onViewDetail={
|
||||
onViewDetail ? () => onViewDetail(skill.name) : undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t px-2 py-1 text-xs dark:border-[#1B477E]">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="size-6"
|
||||
disabled={page <= 0}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
>
|
||||
<ChevronLeftIcon className="size-3.5" />
|
||||
</Button>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{page + 1}/{totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="size-6"
|
||||
disabled={page >= totalPages - 1}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
>
|
||||
<ChevronRightIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkillItemCardProps {
|
||||
skill: Skill;
|
||||
isSelected: boolean;
|
||||
onPick: () => void;
|
||||
onViewDetail?: () => void;
|
||||
}
|
||||
|
||||
function SkillItemCard({
|
||||
skill,
|
||||
isSelected,
|
||||
onPick,
|
||||
onViewDetail,
|
||||
}: SkillItemCardProps) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onPick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onPick();
|
||||
}
|
||||
}}
|
||||
title={isSelected ? "点击移除" : "点击添加"}
|
||||
className={cn(
|
||||
"group/item relative cursor-pointer rounded-md border px-2.5 py-1.5 pr-7 transition-colors",
|
||||
isSelected
|
||||
? "border-primary/60 bg-primary/5 hover:border-primary dark:border-[#3B82F6] dark:bg-[#0a3568]"
|
||||
: "hover:border-primary/40 hover:bg-accent/40 dark:border-[#213D5E] dark:bg-[#02244C] dark:hover:border-[#3B82F6]/60",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-4 shrink-0 items-center justify-center rounded-full text-[10px]",
|
||||
isSelected
|
||||
? "bg-primary/15 text-primary"
|
||||
: "bg-muted text-muted-foreground group-hover/item:bg-primary/15 group-hover/item:text-primary",
|
||||
)}
|
||||
>
|
||||
{isSelected ? (
|
||||
<XIcon className="size-3" />
|
||||
) : (
|
||||
<PlusIcon className="size-3" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="truncate text-xs font-medium"
|
||||
title={skillLabel(skill)}
|
||||
>
|
||||
{skillLabel(skill)}
|
||||
</div>
|
||||
{skill.description?.trim() && (
|
||||
<div className="text-muted-foreground truncate text-[11px]">
|
||||
{desensitize(skill.description)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onViewDetail && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewDetail();
|
||||
}}
|
||||
title="查看技能详情"
|
||||
className="text-muted-foreground hover:text-foreground absolute right-1.5 top-1.5 opacity-60 transition-opacity group-hover/item:opacity-100"
|
||||
>
|
||||
<InfoIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -86,8 +86,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import { useUserPreferences } from "@/core/user-preferences";
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import { useUserPreferences } from "@/core/user-preferences";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
@@ -133,6 +133,7 @@ export function InputBox({
|
||||
promptPrefix,
|
||||
agentSelector,
|
||||
assistantId,
|
||||
hideAdminRecommendedQuestions,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||
assistantId?: string | null;
|
||||
@@ -149,6 +150,9 @@ export function InputBox({
|
||||
};
|
||||
extraHeader?: React.ReactNode;
|
||||
isNewThread?: boolean;
|
||||
/** Suppress the global admin "recommended questions" row. The agent chat page
|
||||
* sets this so only the agent's own common questions surface on its home. */
|
||||
hideAdminRecommendedQuestions?: boolean;
|
||||
threadId: string;
|
||||
initialValue?: string;
|
||||
onContextChange?: (
|
||||
@@ -205,11 +209,12 @@ export function InputBox({
|
||||
null,
|
||||
);
|
||||
|
||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||
const userPreferences = useUserPreferences();
|
||||
const showAdminRecommendedQuestions =
|
||||
!userPreferences.hide_admin_prompt_prefix &&
|
||||
recommendedQuestions.length > 0;
|
||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||
const userPreferences = useUserPreferences();
|
||||
const showAdminRecommendedQuestions =
|
||||
!hideAdminRecommendedQuestions &&
|
||||
!userPreferences.hide_admin_prompt_prefix &&
|
||||
recommendedQuestions.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (models.length === 0) {
|
||||
@@ -516,7 +521,7 @@ export function InputBox({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
||||
{recommendedQuestions.map((q) => (
|
||||
<button
|
||||
|
||||
@@ -358,6 +358,20 @@ function MessageImage({
|
||||
);
|
||||
}
|
||||
|
||||
// While a notebook citation marker is still streaming in (e.g. "[[cite:64" or
|
||||
// "[[cite:101]"), Streamdown's parseIncompleteMarkdown treats the trailing
|
||||
// unclosed "[" as an incomplete link and rewrites it to
|
||||
// "[[cite:64](streamdown:incomplete-link)", which the rehypeCitations regex can
|
||||
// no longer match — so the citation flickers as a broken link mid-stream. We
|
||||
// strip the half-arrived marker so it stays hidden until the closing "]]"
|
||||
// arrives, at which point rehypeCitations renders it. A complete
|
||||
// "[[cite:101]]" has no trailing incomplete marker, so this is a no-op.
|
||||
const INCOMPLETE_CITE_TAIL = /\[\[(?:c(?:i(?:t(?:e(?::\d*\]?)?)?)?)?)?$/;
|
||||
|
||||
function stripIncompleteCitationMarker(content: string): string {
|
||||
return content.replace(INCOMPLETE_CITE_TAIL, '');
|
||||
}
|
||||
|
||||
function MessageContent_({
|
||||
className,
|
||||
message,
|
||||
@@ -460,7 +474,8 @@ function MessageContent_({
|
||||
? stripArtifactTag(stripUploadedFilesTag(rawContent))
|
||||
: '';
|
||||
}
|
||||
return applyInlineCitations(rawContent ?? '', displaySources);
|
||||
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
|
||||
return stripIncompleteCitationMarker(withCitations);
|
||||
}, [rawContent, isHuman, displaySources]);
|
||||
const parsedHumanContent = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { PublishSquare } from "@/core/system-settings/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PublishSquarePickerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
squares: PublishSquare[];
|
||||
/** Pre-selected square id (current square, or the configured default). */
|
||||
defaultSquareId?: string;
|
||||
/** Called with the chosen square id when the user confirms. */
|
||||
onConfirm: (squareId: string) => void;
|
||||
loading?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** A "publish to which square (发布广场)?" chooser. Squares are rendered as a
|
||||
* selectable list (not a dropdown) so the choice is explicit. */
|
||||
export function PublishSquarePickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
squares,
|
||||
defaultSquareId,
|
||||
onConfirm,
|
||||
loading,
|
||||
title = "选择发布广场",
|
||||
description = "请选择要把它发布到哪个广场。每个项目只能归属一个广场,不会重复出现。",
|
||||
}: PublishSquarePickerDialogProps) {
|
||||
const [selected, setSelected] = useState<string>(
|
||||
defaultSquareId || squares[0]?.id || "",
|
||||
);
|
||||
|
||||
// Re-seed the selection whenever the dialog (re)opens or the default changes.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelected(defaultSquareId || squares[0]?.id || "");
|
||||
}
|
||||
}, [open, defaultSquareId, squares]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[50vh] space-y-1.5 overflow-y-auto py-1">
|
||||
{squares.map((sq) => {
|
||||
const active = selected === sq.id;
|
||||
return (
|
||||
<button
|
||||
key={sq.id}
|
||||
type="button"
|
||||
onClick={() => setSelected(sq.id)}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2 rounded-md border px-3 py-2 text-left transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/5 dark:border-[#3B82F6] dark:bg-[#0a3568]"
|
||||
: "hover:border-primary/40 hover:bg-accent/40 dark:border-[#213D5E]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border",
|
||||
active
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
{active && <CheckIcon className="size-3" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{sq.name}
|
||||
</span>
|
||||
{sq.description?.trim() && (
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{sq.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onConfirm(selected)}
|
||||
disabled={loading || !selected}
|
||||
>
|
||||
{loading ? "发布中…" : "确认发布"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
|
||||
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
|
||||
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
|
||||
import RecommendedQuestionsPage from "@/pages/RecommendedQuestionsPage";
|
||||
|
||||
@@ -349,6 +350,8 @@ export function AppearanceSettingsPage() {
|
||||
<PromptPrefixSettingsPage embedded />
|
||||
<Separator />
|
||||
<PrivateSquareSettingsPage embedded />
|
||||
<Separator />
|
||||
<PublishSquaresSettingsPage embedded />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -58,6 +58,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
|
||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
@@ -81,7 +82,7 @@ import {
|
||||
useSkills,
|
||||
useValidateSkillUpload,
|
||||
} from "@/core/skills/hooks";
|
||||
import { useViewMode } from "@/core/system-settings/hooks";
|
||||
import { usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
@@ -164,6 +165,10 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
null,
|
||||
);
|
||||
const [viewMode, setViewMode] = useViewMode("skill");
|
||||
const { data: publishSquares } = usePublishSquares();
|
||||
// Each configured publish square (发布广场) becomes its own browse tab; the
|
||||
// backend always returns at least one (the built-in default).
|
||||
const squareTabs = publishSquares?.squares ?? [];
|
||||
const reconcileMutation = useReconcileCustomSkills();
|
||||
const fillNamesMutation = useGenerateMissingSkillNameZh();
|
||||
// Optional model override for the admin "一键补充中文名" action (null = default).
|
||||
@@ -214,14 +219,25 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
};
|
||||
}, [skills, currentUserId]);
|
||||
|
||||
// "mine" tab shows my own skills; every other tab value is a square id whose
|
||||
// public skills come from the published pool.
|
||||
const isSquareTab = filter !== "mine";
|
||||
const scopedSkills = filter === "mine" ? activeMine : activeAll;
|
||||
const filteredSkills = useMemo(
|
||||
() =>
|
||||
publisher
|
||||
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
|
||||
: scopedSkills,
|
||||
[scopedSkills, publisher],
|
||||
);
|
||||
const filteredSkills = useMemo(() => {
|
||||
let list = publisher
|
||||
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
|
||||
: scopedSkills;
|
||||
// On a square tab, keep only that square's skills. The default square also
|
||||
// sweeps in legacy/unassigned published skills (square_id === "").
|
||||
if (isSquareTab) {
|
||||
const isDefault = filter === publishSquares?.default_square_id;
|
||||
list = list.filter((s) => {
|
||||
const sid = s.square_id ?? "";
|
||||
return sid === filter || (isDefault && !sid);
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}, [scopedSkills, publisher, filter, isSquareTab, publishSquares?.default_square_id]);
|
||||
const orderedSkills = useMemo(
|
||||
() =>
|
||||
filteredSkills
|
||||
@@ -247,6 +263,18 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search, tagIds, sort, publisher?.id, filter]);
|
||||
|
||||
// If the active square tab disappears (admin removed that square), fall back
|
||||
// to "我的" so the page never points at a dead tab.
|
||||
useEffect(() => {
|
||||
if (
|
||||
filter !== "mine" &&
|
||||
squareTabs.length > 0 &&
|
||||
!squareTabs.some((sq) => sq.id === filter)
|
||||
) {
|
||||
setFilter("mine");
|
||||
}
|
||||
}, [filter, squareTabs]);
|
||||
const pagedSkills = useMemo(
|
||||
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||
[orderedSkills, page],
|
||||
@@ -304,9 +332,13 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tabs value={filter} onValueChange={setFilter}>
|
||||
<TabsList variant="line">
|
||||
<TabsList variant="line" className="flex-wrap">
|
||||
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
|
||||
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
|
||||
{squareTabs.map((sq) => (
|
||||
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
|
||||
{sq.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button
|
||||
@@ -544,6 +576,7 @@ export function SkillRow({
|
||||
const isCard = layout === "card";
|
||||
const { t } = useI18n();
|
||||
const setPublished = useSetSkillPublished();
|
||||
const { data: cardPublishSquares } = usePublishSquares();
|
||||
const setPinned = useSetSkillPinned();
|
||||
const setFeatured = useSetSkillFeatured();
|
||||
const deleteSkill = useDeleteCustomSkill();
|
||||
@@ -553,6 +586,7 @@ export function SkillRow({
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [duplicateOpen, setDuplicateOpen] = useState(false);
|
||||
const [duplicateName, setDuplicateName] = useState("");
|
||||
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
|
||||
|
||||
const isCustom = skill.category === "custom";
|
||||
// Built-in flag falls back to "no DB owner" so legacy responses without
|
||||
@@ -566,6 +600,12 @@ export function SkillRow({
|
||||
const canTogglePublish = canManage && !isBuiltin;
|
||||
|
||||
async function handleTogglePublished(next: boolean) {
|
||||
// Publishing: prompt for which square to publish into (when configured).
|
||||
// Unpublishing: just flip it off.
|
||||
if (next && (cardPublishSquares?.squares.length ?? 0) > 0) {
|
||||
setPublishPickerOpen(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await setPublished.mutateAsync({ skillName: skill.name, published: next });
|
||||
toast.success(
|
||||
@@ -578,6 +618,24 @@ export function SkillRow({
|
||||
}
|
||||
}
|
||||
|
||||
// Publish the skill into a chosen square. Each skill belongs to exactly one
|
||||
// square; re-publishing with a new square_id relocates rather than duplicates.
|
||||
async function handlePublishToSquare(squareId: string) {
|
||||
try {
|
||||
await setPublished.mutateAsync({
|
||||
skillName: skill.name,
|
||||
published: true,
|
||||
squareId,
|
||||
});
|
||||
const sqName =
|
||||
cardPublishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场";
|
||||
toast.success(`已发布到「${sqName}」`);
|
||||
setPublishPickerOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteSkill.mutateAsync(skill.name);
|
||||
@@ -979,6 +1037,16 @@ export function SkillRow({
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
/>
|
||||
|
||||
<PublishSquarePickerDialog
|
||||
open={publishPickerOpen}
|
||||
onOpenChange={setPublishPickerOpen}
|
||||
squares={cardPublishSquares?.squares ?? []}
|
||||
defaultSquareId={skill.square_id || cardPublishSquares?.default_square_id}
|
||||
loading={setPublished.isPending}
|
||||
onConfirm={(squareId) => void handlePublishToSquare(squareId)}
|
||||
description="请选择要把该技能发布到哪个广场。每个技能只能归属一个广场,不会重复出现。"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -273,9 +273,24 @@ function StatRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** Select 不允许空字符串 value,用此哨兵代表"用主模型"(对应 model_name=null)。 */
|
||||
interface CuratorDraft {
|
||||
interval_hours: number;
|
||||
stale_after_days: number;
|
||||
archive_after_days: number;
|
||||
model_name: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_MODEL_VALUE = "__default__";
|
||||
|
||||
function toDraft(config: CuratorConfig): CuratorDraft {
|
||||
return {
|
||||
interval_hours: config.curator.interval_hours,
|
||||
stale_after_days: config.curator.stale_after_days,
|
||||
archive_after_days: config.curator.archive_after_days,
|
||||
model_name: config.curator.model_name,
|
||||
};
|
||||
}
|
||||
|
||||
function ConfigCard() {
|
||||
const { t } = useI18n();
|
||||
const tt = t.settings.skillEvolution.config;
|
||||
@@ -283,10 +298,10 @@ function ConfigCard() {
|
||||
const updateConfig = useUpdateCuratorConfig();
|
||||
const { models } = useModels();
|
||||
|
||||
const [draft, setDraft] = useState<CuratorConfig | null>(null);
|
||||
const [draft, setDraft] = useState<CuratorDraft | null>(null);
|
||||
useEffect(() => {
|
||||
if (config && !draft) {
|
||||
setDraft(config);
|
||||
setDraft(toDraft(config));
|
||||
}
|
||||
}, [config, draft]);
|
||||
|
||||
@@ -305,14 +320,12 @@ function ConfigCard() {
|
||||
);
|
||||
}
|
||||
|
||||
const isDirty =
|
||||
!!config &&
|
||||
JSON.stringify(draft) !== JSON.stringify(config);
|
||||
const original = config ? toDraft(config) : null;
|
||||
const isDirty = !!original && JSON.stringify(draft) !== JSON.stringify(original);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const updated = await updateConfig.mutateAsync(draft);
|
||||
setDraft(updated);
|
||||
await updateConfig.mutateAsync(draft);
|
||||
toast.success(tt.saveSuccess);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
@@ -326,29 +339,6 @@ function ConfigCard() {
|
||||
<CardDescription>{tt.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<FieldRow
|
||||
label={tt.enabledLabel}
|
||||
helper={tt.enabledHelper}
|
||||
>
|
||||
<Switch
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(v) => setDraft({ ...draft, enabled: v })}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label={tt.curatorEnabledLabel}
|
||||
helper={tt.curatorEnabledHelper}
|
||||
>
|
||||
<Switch
|
||||
checked={draft.curator.enabled}
|
||||
onCheckedChange={(v) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
curator: { ...draft.curator, enabled: v },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label={tt.intervalHoursLabel}
|
||||
helper={tt.intervalHoursHelper}
|
||||
@@ -357,15 +347,9 @@ function ConfigCard() {
|
||||
type="number"
|
||||
min={1}
|
||||
className="max-w-[10rem]"
|
||||
value={draft.curator.interval_hours}
|
||||
value={draft.interval_hours}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
curator: {
|
||||
...draft.curator,
|
||||
interval_hours: Math.max(1, Number(e.target.value) || 1),
|
||||
},
|
||||
})
|
||||
setDraft({ ...draft, interval_hours: Math.max(1, Number(e.target.value) || 1) })
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
@@ -377,15 +361,9 @@ function ConfigCard() {
|
||||
type="number"
|
||||
min={1}
|
||||
className="max-w-[10rem]"
|
||||
value={draft.curator.stale_after_days}
|
||||
value={draft.stale_after_days}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
curator: {
|
||||
...draft.curator,
|
||||
stale_after_days: Math.max(1, Number(e.target.value) || 1),
|
||||
},
|
||||
})
|
||||
setDraft({ ...draft, stale_after_days: Math.max(1, Number(e.target.value) || 1) })
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
@@ -397,15 +375,9 @@ function ConfigCard() {
|
||||
type="number"
|
||||
min={1}
|
||||
className="max-w-[10rem]"
|
||||
value={draft.curator.archive_after_days}
|
||||
value={draft.archive_after_days}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
curator: {
|
||||
...draft.curator,
|
||||
archive_after_days: Math.max(1, Number(e.target.value) || 1),
|
||||
},
|
||||
})
|
||||
setDraft({ ...draft, archive_after_days: Math.max(1, Number(e.target.value) || 1) })
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
@@ -414,15 +386,9 @@ function ConfigCard() {
|
||||
helper={tt.curatorModelHelper}
|
||||
>
|
||||
<Select
|
||||
value={draft.curator.model_name ?? DEFAULT_MODEL_VALUE}
|
||||
value={draft.model_name ?? DEFAULT_MODEL_VALUE}
|
||||
onValueChange={(v) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
curator: {
|
||||
...draft.curator,
|
||||
model_name: v === DEFAULT_MODEL_VALUE ? null : v,
|
||||
},
|
||||
})
|
||||
setDraft({ ...draft, model_name: v === DEFAULT_MODEL_VALUE ? null : v })
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
@@ -451,7 +417,7 @@ function ConfigCard() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!isDirty || updateConfig.isPending}
|
||||
onClick={() => config && setDraft(config)}
|
||||
onClick={() => original && setDraft(original)}
|
||||
>
|
||||
{tt.reset}
|
||||
</Button>
|
||||
|
||||
@@ -19,9 +19,6 @@ import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSq
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
import { WorkspaceNavStudio } from "./workspace-nav-studio";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { env } from "@/env";
|
||||
|
||||
const studioEnabled = env.STUDIO_ENABLED?.toLowerCase() === "true";
|
||||
|
||||
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
||||
const NAV_ITEM_CLASS =
|
||||
@@ -180,7 +177,7 @@ export function WorkspaceNavChatList({
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
{!showCollapseTrigger && studioEnabled && <WorkspaceNavStudio />}
|
||||
{!showCollapseTrigger && <WorkspaceNavStudio />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -65,7 +65,8 @@ export function WorkspaceNavStudio() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isError || (!isLoading && notebooks.length === 0)) {
|
||||
// 加载中、token 失败、空列表都不渲染整段,避免在 token 未确认前先闪一段工作室 UI 再消失。
|
||||
if (isLoading || isError || notebooks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -90,44 +91,38 @@ export function WorkspaceNavStudio() {
|
||||
|
||||
<CollapsibleContent>
|
||||
<SidebarMenu className="mt-0.5 gap-0.5">
|
||||
{/* 新建笔记本 */}
|
||||
{/* 新建空间 */}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
className={cn(NAV_ITEM_CLASS)}
|
||||
onClick={() => { setDialogOpen(true); setError(""); setName(""); }}
|
||||
>
|
||||
<Plus className="shrink-0" />
|
||||
<span>新建笔记本</span>
|
||||
<span>新建空间</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
{/* 笔记本列表(最多展示 SIDEBAR_PREVIEW_COUNT 条) */}
|
||||
{isLoading
|
||||
? Array.from({ length: 3 }).map((_, i) => (
|
||||
<SidebarMenuItem key={i}>
|
||||
<div className="mx-3 my-1 h-5 animate-pulse rounded-md bg-sidebar-accent/40" />
|
||||
</SidebarMenuItem>
|
||||
))
|
||||
: preview.map((nb) => (
|
||||
<SidebarMenuItem key={nb.id}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={pathname === `/page/workspace/studio/notebooks/${nb.id}`}
|
||||
className={cn(NAV_ITEM_CLASS)}
|
||||
>
|
||||
<Link
|
||||
to={`/page/workspace/studio/notebooks/${nb.id}`}
|
||||
title={nb.description || nb.name}
|
||||
>
|
||||
<BookOpen className="shrink-0 size-4" />
|
||||
<span className="truncate">{nb.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{/* 空间列表(最多展示 SIDEBAR_PREVIEW_COUNT 条) */}
|
||||
{preview.map((nb) => (
|
||||
<SidebarMenuItem key={nb.id}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={pathname === `/page/workspace/studio/notebooks/${nb.id}`}
|
||||
className={cn(NAV_ITEM_CLASS)}
|
||||
>
|
||||
<Link
|
||||
to={`/page/workspace/studio/notebooks/${nb.id}`}
|
||||
title={nb.description || nb.name}
|
||||
>
|
||||
<BookOpen className="shrink-0 size-4" />
|
||||
<span className="truncate">{nb.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
|
||||
{/* 所有笔记本 */}
|
||||
{!isLoading && notebooks.length > 0 && (
|
||||
{/* 所有空间 */}
|
||||
{notebooks.length > 0 && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
@@ -135,8 +130,8 @@ export function WorkspaceNavStudio() {
|
||||
className={cn(NAV_ITEM_CLASS)}
|
||||
>
|
||||
<Link to="/page/workspace/studio/notebooks">
|
||||
<span className="mr-0.5 text-base leading-none">···</span>
|
||||
<span>所有笔记本</span>
|
||||
{/* <span className="mr-0.5 text-base leading-none">···</span> */}
|
||||
<span>所有空间</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
@@ -149,7 +144,7 @@ export function WorkspaceNavStudio() {
|
||||
<Dialog open={dialogOpen} onOpenChange={(v) => !creating && setDialogOpen(v)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建笔记本</DialogTitle>
|
||||
<DialogTitle>新建空间</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<input
|
||||
@@ -157,7 +152,7 @@ export function WorkspaceNavStudio() {
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }}
|
||||
placeholder="笔记本名称"
|
||||
placeholder="空间名称"
|
||||
disabled={creating}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
|
||||
@@ -93,15 +93,6 @@ export interface AdminLeaderboardDimension {
|
||||
last_seen_at: string;
|
||||
}
|
||||
|
||||
export interface AdminLeaderboardQuestion {
|
||||
text: string;
|
||||
user_id: string;
|
||||
email: string;
|
||||
thread_id: string;
|
||||
run_id: string;
|
||||
asked_at: string;
|
||||
}
|
||||
|
||||
export interface AdminLeaderboardTrendPoint {
|
||||
date: string;
|
||||
runs: number;
|
||||
@@ -138,8 +129,6 @@ export interface AdminLeaderboardResponse {
|
||||
models: AdminLeaderboardDimension[];
|
||||
agents: AdminLeaderboardDimension[];
|
||||
run_statuses: AdminLeaderboardDimension[];
|
||||
recent_questions: AdminLeaderboardQuestion[];
|
||||
frequent_questions: AdminLeaderboardQuestion[];
|
||||
trends: AdminLeaderboardTrendPoint[];
|
||||
}
|
||||
|
||||
@@ -154,7 +143,6 @@ export interface AdminUserAnalyticsResponse {
|
||||
models: AdminLeaderboardDimension[];
|
||||
agents: AdminLeaderboardDimension[];
|
||||
run_statuses: AdminLeaderboardDimension[];
|
||||
recent_questions: AdminLeaderboardQuestion[];
|
||||
trends: AdminLeaderboardTrendPoint[];
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface ListAgentsParams {
|
||||
sort?: AgentSort;
|
||||
/** Keep only agents published by this user id (the card's publisher). */
|
||||
owner?: string;
|
||||
/** Keep only agents filed under this publish square (发布广场). */
|
||||
square?: string;
|
||||
}
|
||||
|
||||
function buildAgentsQuery(params: ListAgentsParams): string {
|
||||
@@ -44,6 +46,7 @@ function buildAgentsQuery(params: ListAgentsParams): string {
|
||||
if (params.pageSize != null) qs.set("page_size", String(params.pageSize));
|
||||
if (params.sort) qs.set("sort", params.sort);
|
||||
if (params.owner) qs.set("owner", params.owner);
|
||||
if (params.square) qs.set("square", params.square);
|
||||
return qs.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -88,12 +88,15 @@ export function useAgentsPaginated(params: {
|
||||
enabled?: boolean;
|
||||
/** Keep only agents published by this user id (the card's publisher). */
|
||||
owner?: string;
|
||||
/** Keep only agents filed under this publish square (发布广场). */
|
||||
square?: string;
|
||||
}) {
|
||||
const { scope, page, pageSize, enabled = true } = params;
|
||||
const search = params.search ?? "";
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const sort = params.sort ?? "time_desc";
|
||||
const owner = params.owner ?? "";
|
||||
const square = params.square ?? "";
|
||||
const { data, isLoading, isFetching, error } = useQuery({
|
||||
queryKey: [
|
||||
"agents",
|
||||
@@ -105,6 +108,7 @@ export function useAgentsPaginated(params: {
|
||||
page,
|
||||
pageSize,
|
||||
owner,
|
||||
square,
|
||||
],
|
||||
queryFn: () =>
|
||||
listAgentsPaginated({
|
||||
@@ -115,6 +119,7 @@ export function useAgentsPaginated(params: {
|
||||
page,
|
||||
pageSize,
|
||||
owner: owner || undefined,
|
||||
square: square || undefined,
|
||||
}),
|
||||
enabled,
|
||||
placeholderData: keepPreviousData,
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
import type { Tag } from "@/core/tags";
|
||||
|
||||
/**
|
||||
* A recommended question shown on the agent chat home / sidebar. `title` is the
|
||||
* short chip label; `prompt` is the full message sent when clicked. Legacy
|
||||
* agents stored questions as bare strings — use {@link normalizeAgentQuestions}
|
||||
* to read either shape.
|
||||
*/
|
||||
export interface AgentQuestion {
|
||||
title: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
/** Coerce the raw `common_questions` payload (legacy `string[]` or the new
|
||||
* `{title, prompt}[]`) into a uniform, non-empty `AgentQuestion[]`. */
|
||||
export function normalizeAgentQuestions(
|
||||
raw: (string | AgentQuestion)[] | null | undefined,
|
||||
): AgentQuestion[] {
|
||||
if (!raw) return [];
|
||||
const out: AgentQuestion[] = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item === "string") {
|
||||
const text = item.trim();
|
||||
if (!text) continue;
|
||||
out.push({ title: text.length <= 40 ? text : `${text.slice(0, 40)}…`, prompt: text });
|
||||
continue;
|
||||
}
|
||||
const prompt = (item?.prompt ?? "").trim();
|
||||
const title = (item?.title ?? "").trim() || prompt;
|
||||
if (!prompt && !title) continue;
|
||||
out.push({ title: title || prompt, prompt: prompt || title });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -9,6 +42,8 @@ export interface Agent {
|
||||
owner_username?: string | null;
|
||||
published: boolean;
|
||||
is_private: boolean;
|
||||
/** Publish square (发布广场) the agent is filed under. "" = the default square. */
|
||||
square_id?: string;
|
||||
builtin: boolean;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
@@ -16,6 +51,10 @@ export interface Agent {
|
||||
model?: string | null;
|
||||
tool_groups?: string[] | null;
|
||||
skills?: string[] | null;
|
||||
/** Recommended questions (常用问题) shown on the agent chat home / sidebar.
|
||||
* New agents store `{title, prompt}`; legacy agents may still return `string[]`.
|
||||
* Read via {@link normalizeAgentQuestions}. */
|
||||
common_questions?: (string | AgentQuestion)[] | null;
|
||||
tags?: Tag[];
|
||||
featured_order?: number | null;
|
||||
/** Whether the current user has favorited (收藏) this agent. */
|
||||
@@ -43,9 +82,11 @@ export interface CreateAgentRequest {
|
||||
soul?: string;
|
||||
published?: boolean;
|
||||
is_private?: boolean;
|
||||
square_id?: string;
|
||||
skills?: string[] | null;
|
||||
model?: string | null;
|
||||
tool_groups?: string[] | null;
|
||||
common_questions?: AgentQuestion[] | null;
|
||||
}
|
||||
|
||||
export interface UpdateAgentRequest {
|
||||
@@ -54,7 +95,9 @@ export interface UpdateAgentRequest {
|
||||
soul?: string | null;
|
||||
published?: boolean | null;
|
||||
is_private?: boolean | null;
|
||||
square_id?: string | null;
|
||||
skills?: string[] | null;
|
||||
model?: string | null;
|
||||
tool_groups?: string[] | null;
|
||||
common_questions?: AgentQuestion[] | null;
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export function getLangGraphBaseURL(isMock?: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 笔记本外部系统后端 API 地址,通过 VITE_AIPI_BACKENP_URL 配置。 */
|
||||
/** 空间外部系统后端 API 地址,通过 VITE_AIPI_BACKENP_URL 配置。 */
|
||||
export function getNotebookApiBaseURL(): string {
|
||||
if (env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL) {
|
||||
return env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL.replace(/\/+$/, "");
|
||||
|
||||
@@ -17,8 +17,15 @@ export async function getCuratorConfig(): Promise<CuratorConfig> {
|
||||
return response.json() as Promise<CuratorConfig>;
|
||||
}
|
||||
|
||||
export interface CuratorConfigUpdatePayload {
|
||||
interval_hours: number;
|
||||
stale_after_days: number;
|
||||
archive_after_days: number;
|
||||
model_name: string | null;
|
||||
}
|
||||
|
||||
export async function updateCuratorConfig(
|
||||
payload: CuratorConfig,
|
||||
payload: CuratorConfigUpdatePayload,
|
||||
): Promise<CuratorConfig> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`, {
|
||||
method: "PUT",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
runCurator,
|
||||
updateCuratorConfig,
|
||||
} from "./api";
|
||||
import type { CuratorConfigUpdatePayload } from "./api";
|
||||
import type { CuratorConfig } from "./types";
|
||||
|
||||
// All curator-related queries are per-user data (status / config / logs all
|
||||
@@ -51,7 +52,7 @@ export function useUpdateCuratorConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
const keys = useCuratorKeys();
|
||||
return useMutation({
|
||||
mutationFn: (payload: CuratorConfig) => updateCuratorConfig(payload),
|
||||
mutationFn: (payload: CuratorConfigUpdatePayload) => updateCuratorConfig(payload),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: keys.config });
|
||||
void queryClient.invalidateQueries({ queryKey: keys.status });
|
||||
|
||||
@@ -127,6 +127,30 @@ export async function ingestThreadsBatch(payload: BatchIngestPayload = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
export interface IngestFileResult {
|
||||
note: KnowledgeNote;
|
||||
created: boolean;
|
||||
notes: KnowledgeNote[];
|
||||
llm_fallback: boolean;
|
||||
}
|
||||
|
||||
/** Import a file (md/txt/pdf/word/ppt/excel) into the KB as wiki note(s). */
|
||||
export async function ingestKnowledgeFile(
|
||||
file: File,
|
||||
opts: { title?: string; tags?: string; status?: string; model_name?: string | null } = {},
|
||||
) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (opts.title) form.append("title", opts.title);
|
||||
if (opts.tags) form.append("tags", opts.tags);
|
||||
if (opts.status) form.append("status", opts.status);
|
||||
if (opts.model_name) form.append("model_name", opts.model_name);
|
||||
// No Content-Type header — the browser sets the multipart boundary itself.
|
||||
return parseResponse<IngestFileResult>(
|
||||
await apiFetch(`${baseUrl()}/ingest/file`, { method: "POST", body: form }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve `[[wikilink]]` targets to note ids / vault pages. */
|
||||
export async function resolveWikilinks(targets: string[]) {
|
||||
const res = await parseResponse<{ resolved: WikilinkResolutionMap }>(
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
deleteKnowledgeNote,
|
||||
exportKnowledgeGraph,
|
||||
fetchVaultPage,
|
||||
getKnowledgeGraph,
|
||||
getKnowledgeNote,
|
||||
ingestKnowledgeFile,
|
||||
ingestThread,
|
||||
ingestThreadsBatch,
|
||||
listDuplicates,
|
||||
@@ -70,6 +72,14 @@ export function useKnowledgeSearch(query: string, enabled: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useKnowledgeGraph() {
|
||||
return useQuery({
|
||||
queryKey: [...ROOT_KEY, "graph"],
|
||||
queryFn: getKnowledgeGraph,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useKnowledgeActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () =>
|
||||
@@ -106,6 +116,16 @@ export function useKnowledgeActions() {
|
||||
mutationFn: ingestThreadsBatch,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
ingestFile: useMutation({
|
||||
mutationFn: ({
|
||||
file,
|
||||
opts,
|
||||
}: {
|
||||
file: File;
|
||||
opts?: Parameters<typeof ingestKnowledgeFile>[1];
|
||||
}) => ingestKnowledgeFile(file, opts),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
exportGraph: useMutation({ mutationFn: exportKnowledgeGraph }),
|
||||
merge: useMutation({
|
||||
mutationFn: ({ primaryId, duplicateIds }: { primaryId: string; duplicateIds: string[] }) =>
|
||||
|
||||
@@ -434,7 +434,17 @@ export function findToolCallResult(
|
||||
}
|
||||
|
||||
export function isHiddenFromUIMessage(message: Message) {
|
||||
return message.additional_kwargs?.hide_from_ui === true;
|
||||
if (message.additional_kwargs?.hide_from_ui === true) {
|
||||
return true;
|
||||
}
|
||||
// Conversation-summary messages are context for the model only — never shown
|
||||
// to the user. New summaries are injected transiently (never persisted), but
|
||||
// threads compacted by older builds still carry a persisted `name: "summary"`
|
||||
// HumanMessage, so hide those too.
|
||||
if (message.name === "summary") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface ListSkillsParams {
|
||||
search?: string;
|
||||
tagIds?: string[];
|
||||
sort?: "time_desc" | "time_asc" | "activity_desc" | "activity_asc" | "usage_desc" | "usage_asc" | "name_asc" | "name_desc";
|
||||
/** Keep only skills filed under this publish square (发布广场). */
|
||||
square?: string;
|
||||
}
|
||||
|
||||
export async function loadSkills(params: ListSkillsParams = {}) {
|
||||
@@ -14,6 +16,7 @@ export async function loadSkills(params: ListSkillsParams = {}) {
|
||||
if (params.search) qs.set("search", params.search);
|
||||
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
|
||||
if (params.sort) qs.set("sort", params.sort);
|
||||
if (params.square) qs.set("square", params.square);
|
||||
const query = qs.toString();
|
||||
const skills = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
|
||||
@@ -242,18 +245,22 @@ export interface SetSkillPublishedResponse {
|
||||
name: string;
|
||||
owner_user_id: string | null;
|
||||
published: boolean;
|
||||
square_id?: string;
|
||||
}
|
||||
|
||||
export async function setSkillPublished(
|
||||
skillName: string,
|
||||
published: boolean,
|
||||
squareId?: string,
|
||||
): Promise<SetSkillPublishedResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/published`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ published }),
|
||||
body: JSON.stringify(
|
||||
squareId != null ? { published, square_id: squareId } : { published },
|
||||
),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -203,10 +203,12 @@ export function useSetSkillPublished() {
|
||||
mutationFn: ({
|
||||
skillName,
|
||||
published,
|
||||
squareId,
|
||||
}: {
|
||||
skillName: string;
|
||||
published: boolean;
|
||||
}) => setSkillPublished(skillName, published),
|
||||
squareId?: string;
|
||||
}) => setSkillPublished(skillName, published, squareId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
// Unpublishing triggers the backend cascade that strips this skill
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface Skill {
|
||||
// backend. Null for built-in / public skills with no human owner.
|
||||
owner_username?: string | null;
|
||||
published?: boolean;
|
||||
/** Publish square (发布广场) the skill is filed under once published. "" = default square. */
|
||||
square_id?: string;
|
||||
builtin?: boolean;
|
||||
// Long-form detail/introduction authored by the skill owner or an admin.
|
||||
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
|
||||
|
||||
@@ -20,14 +20,16 @@ export async function fetchStudioToken(email: string): Promise<string> {
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function fetchStudioNotebooks(token: string): Promise<StudioNotebook[]> {
|
||||
export async function fetchStudioNotebooks(
|
||||
token: string,
|
||||
): Promise<StudioNotebook[]> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
|
||||
const res = await fetch(`${apiBase}/api/v1/searchspaces`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`获取笔记本列表失败:${res.status}`);
|
||||
if (!res.ok) throw new Error(`获取空间列表失败:${res.status}`);
|
||||
return res.json() as Promise<StudioNotebook[]>;
|
||||
}
|
||||
|
||||
@@ -62,13 +64,23 @@ export async function uploadSourceText(
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
const res = await fetch(`${apiBase}/api/v1/documents/textupload`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ search_space_id: notebookId, content, title: title || "粘贴的文字" }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
search_space_id: notebookId,
|
||||
content,
|
||||
title: title || "粘贴的文字",
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`上传失败:${res.status}`);
|
||||
}
|
||||
|
||||
export async function deleteSearchSpace(id: number, token: string): Promise<void> {
|
||||
export async function deleteSearchSpace(
|
||||
id: number,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
|
||||
@@ -78,12 +90,19 @@ export async function deleteSearchSpace(id: number, token: string): Promise<void
|
||||
if (!res.ok) throw new Error(`删除失败:${res.status}`);
|
||||
}
|
||||
|
||||
export async function renameSearchSpace(id: number, name: string, token: string): Promise<void> {
|
||||
export async function renameSearchSpace(
|
||||
id: number,
|
||||
name: string,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`重命名失败:${res.status}`);
|
||||
|
||||
@@ -4,11 +4,15 @@ import { useStream } from "@langchain/langgraph-sdk/react";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { fetchNotebookSources, fetchStudioNotebooks, fetchStudioToken } from "./api";
|
||||
import {
|
||||
fetchNotebookSources,
|
||||
fetchStudioNotebooks,
|
||||
fetchStudioToken,
|
||||
} from "./api";
|
||||
import type { AgentThreadState } from "@/core/threads/types";
|
||||
import type { SourceItem, StudioNotebook } from "./types";
|
||||
|
||||
export function useStudioNotebooks(): {
|
||||
export function useStudioNotebooks(enabled = true): {
|
||||
notebooks: StudioNotebook[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
@@ -19,17 +23,22 @@ export function useStudioNotebooks(): {
|
||||
const tokenQuery = useQuery({
|
||||
queryKey: ["studio", "token", email],
|
||||
queryFn: () => fetchStudioToken(email),
|
||||
enabled: Boolean(email),
|
||||
enabled: enabled && Boolean(email),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
// 账号不存在(404/422)是永久错误,重试无意义,立即让 isError 落地
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const notebooksQuery = useQuery({
|
||||
queryKey: ["studio", "notebooks", tokenQuery.data],
|
||||
queryFn: () => fetchStudioNotebooks(tokenQuery.data!),
|
||||
enabled: Boolean(tokenQuery.data),
|
||||
staleTime: 60 * 1000,
|
||||
enabled: enabled && Boolean(tokenQuery.data),
|
||||
// 空间列表不会高频变动;新建/删除路径已通过 invalidateQueries 主动失效
|
||||
// (见 workspace-nav-studio.tsx),不需要短 staleTime + windowFocus 双兜底。
|
||||
staleTime: 10 * 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -71,7 +80,8 @@ export function useNotebookChat(
|
||||
queryFn: () => fetchStudioToken(email),
|
||||
enabled: Boolean(email),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const thread = useStream<AgentThreadState>({
|
||||
@@ -99,7 +109,9 @@ export function useNotebookChat(
|
||||
const sourceIds =
|
||||
selectedSourceIds.length > 0
|
||||
? toDocIds(selectedSourceIds)
|
||||
: sources.map((s) => s.document?.id).filter((id): id is number => id !== undefined);
|
||||
: sources
|
||||
.map((s) => s.document?.id)
|
||||
.filter((id): id is number => id !== undefined);
|
||||
|
||||
await thread.submit(
|
||||
{ messages: [{ type: "human", content: text }] },
|
||||
@@ -118,36 +130,52 @@ export function useNotebookChat(
|
||||
);
|
||||
};
|
||||
|
||||
return { thread, messages: thread.messages, isStreaming: thread.isLoading, send, threadId, setThreadId };
|
||||
return {
|
||||
thread,
|
||||
messages: thread.messages,
|
||||
isStreaming: thread.isLoading,
|
||||
send,
|
||||
threadId,
|
||||
setThreadId,
|
||||
};
|
||||
}
|
||||
|
||||
export function useNotebookSources(notebookId: number | null): {
|
||||
export function useNotebookSources(
|
||||
notebookId: number | null,
|
||||
overrideToken?: string,
|
||||
): {
|
||||
sources: SourceItem[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
} {
|
||||
const email = getStoredAuth()?.email ?? "";
|
||||
|
||||
// 复用与 useStudioNotebooks 相同的 token query key,命中缓存不重复请求
|
||||
// 若外部直接提供 user_token(如 notebook-token 接口返回的),跳过 getTokenByEmail
|
||||
const tokenQuery = useQuery({
|
||||
queryKey: ["studio", "token", email],
|
||||
queryFn: () => fetchStudioToken(email),
|
||||
enabled: Boolean(email),
|
||||
enabled: !overrideToken && Boolean(email),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const resolvedToken = overrideToken ?? tokenQuery.data;
|
||||
|
||||
const sourcesQuery = useQuery({
|
||||
queryKey: ["studio", "sources", notebookId],
|
||||
queryFn: () => fetchNotebookSources(notebookId!, tokenQuery.data!),
|
||||
enabled: notebookId !== null && Boolean(tokenQuery.data),
|
||||
staleTime: 60 * 1000,
|
||||
queryKey: ["studio", "sources", notebookId, overrideToken ?? "default"],
|
||||
queryFn: () => fetchNotebookSources(notebookId!, resolvedToken!),
|
||||
enabled: notebookId !== null && Boolean(resolvedToken),
|
||||
// 笔记文档列表低频变动;增删时调用方应主动 invalidateQueries(["studio", "sources"]) 失效。
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return {
|
||||
sources: sourcesQuery.data ?? [],
|
||||
isLoading: tokenQuery.isLoading || sourcesQuery.isLoading,
|
||||
isError: tokenQuery.isError || sourcesQuery.isError,
|
||||
isLoading:
|
||||
(!overrideToken && tokenQuery.isLoading) || sourcesQuery.isLoading,
|
||||
isError: (!overrideToken && tokenQuery.isError) || sourcesQuery.isError,
|
||||
};
|
||||
}
|
||||
@@ -89,6 +89,52 @@ export async function loadPrivateSquareStatus(): Promise<PrivateSquareStatus> {
|
||||
return (await response.json()) as PrivateSquareStatus;
|
||||
}
|
||||
|
||||
export interface PublishSquare {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface PublishSquaresSettings {
|
||||
squares: PublishSquare[];
|
||||
default_square_id: string;
|
||||
}
|
||||
|
||||
export interface PublishSquaresUpdate {
|
||||
squares?: PublishSquare[];
|
||||
default_square_id?: string;
|
||||
}
|
||||
|
||||
export async function loadPublishSquares(): Promise<PublishSquaresSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/publish-squares`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load publish squares: ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as PublishSquaresSettings;
|
||||
}
|
||||
|
||||
export async function updatePublishSquares(
|
||||
body: PublishSquaresUpdate,
|
||||
): Promise<PublishSquaresSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/publish-squares`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to update publish squares" }));
|
||||
throw new Error(detail.detail ?? "Failed to update publish squares");
|
||||
}
|
||||
return (await response.json()) as PublishSquaresSettings;
|
||||
}
|
||||
|
||||
export type ThemeMode = "light" | "dark";
|
||||
export type ViewMode = "card" | "list";
|
||||
|
||||
|
||||
@@ -8,21 +8,25 @@ import {
|
||||
loadLeaderboardSettings,
|
||||
loadPrivateSquareStatus,
|
||||
loadPromptPrefixSettings,
|
||||
loadPublishSquares,
|
||||
updateAppearanceSettings,
|
||||
updateCitationDisplaySettings,
|
||||
updateLeaderboardSettings,
|
||||
updatePrivateSquareSettings,
|
||||
updatePromptPrefixSettings,
|
||||
updatePublishSquares,
|
||||
type AppearanceUpdate,
|
||||
type CitationDisplayUpdate,
|
||||
type LeaderboardSettingsUpdate,
|
||||
type PrivateSquareUpdate,
|
||||
type PromptPrefixUpdate,
|
||||
type PublishSquaresUpdate,
|
||||
type ViewMode,
|
||||
} from "./api";
|
||||
|
||||
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
|
||||
const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const;
|
||||
const PUBLISH_SQUARES_KEY = ["systemSettings", "publishSquares"] as const;
|
||||
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
|
||||
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
|
||||
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
|
||||
@@ -67,6 +71,31 @@ export function useUpdatePrivateSquareSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
/** The configured publish squares (发布广场). Readable by any logged-in user —
|
||||
* the gallery sub-tabs and the publish-flow square picker both need it. */
|
||||
export function usePublishSquares() {
|
||||
return useQuery({
|
||||
queryKey: PUBLISH_SQUARES_KEY,
|
||||
queryFn: () => loadPublishSquares(),
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePublishSquares() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: PublishSquaresUpdate) => updatePublishSquares(body),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(PUBLISH_SQUARES_KEY, data);
|
||||
// Squares changing can re-bucket gallery tabs; refresh agent/skill lists.
|
||||
queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAppearanceSettings() {
|
||||
return useQuery({
|
||||
queryKey: APPEARANCE_KEY,
|
||||
|
||||
@@ -9,8 +9,7 @@ function readEnv(
|
||||
| "VITE_AIPI_BACKENP_URL"
|
||||
| "VITE_AIPI_FRONTEND_URL"
|
||||
| "VITE_STATIC_WEBSITE_ONLY"
|
||||
| "VITE_GITHUB_OAUTH_TOKEN"
|
||||
| "VITE_STUDIO_ENABLED",
|
||||
| "VITE_GITHUB_OAUTH_TOKEN",
|
||||
): EnvValue {
|
||||
const value = import.meta.env[key];
|
||||
return typeof value === "string" && value.trim() ? value : undefined;
|
||||
@@ -26,5 +25,4 @@ export const env = {
|
||||
NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readEnv("VITE_AIPI_BACKENP_URL"),
|
||||
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"),
|
||||
GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"),
|
||||
STUDIO_ENABLED: readEnv("VITE_STUDIO_ENABLED"),
|
||||
};
|
||||
@@ -22,6 +22,12 @@ interface AIWritingFormProps {
|
||||
disabled?: boolean;
|
||||
/** workspace 入口:隐藏严格模式、目标读者 */
|
||||
hideAdvancedFields?: boolean;
|
||||
/** 预选空间 ID(传入时自动切换素材来源为「我的空间」) */
|
||||
initialNotebookId?: number;
|
||||
/** 预选文档 ID 列表 */
|
||||
initialDocumentIds?: number[];
|
||||
/** 外部直接提供的 studio bearer token,跳过 getTokenByEmail */
|
||||
studioToken?: string;
|
||||
}
|
||||
|
||||
const INPUT_CLASS =
|
||||
@@ -39,7 +45,7 @@ const MAX_INTENT_LENGTH = 500;
|
||||
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
|
||||
{ value: 'knowledge_base', label: '知识库', desc: '默认 web/intranet 检索' },
|
||||
{ value: 'skill', label: '技能检索', desc: '用已启用的技能编排检索' },
|
||||
{ value: 'notebook', label: '我的笔记', desc: '从笔记本中检索素材' },
|
||||
{ value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
|
||||
];
|
||||
|
||||
const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222]';
|
||||
@@ -50,6 +56,9 @@ export function AIWritingForm({
|
||||
onSubmit,
|
||||
disabled,
|
||||
hideAdvancedFields = false,
|
||||
initialNotebookId,
|
||||
initialDocumentIds,
|
||||
studioToken,
|
||||
}: AIWritingFormProps) {
|
||||
const {
|
||||
intent, setIntent,
|
||||
@@ -62,13 +71,15 @@ export function AIWritingForm({
|
||||
materialSource, setMaterialSource,
|
||||
modelName, setModelName,
|
||||
} = useWritingForm();
|
||||
// 笔记来源:选中的 search_space_id 和多个 document_id(master 引入,useWritingForm 未含)
|
||||
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(null);
|
||||
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>([]);
|
||||
// 笔记来源:选中的 search_space_id 和多个 document_id
|
||||
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(
|
||||
initialNotebookId ?? null,
|
||||
);
|
||||
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>(initialDocumentIds ?? []);
|
||||
|
||||
const { models, isLoading: modelsLoading } = useModels();
|
||||
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks();
|
||||
const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId);
|
||||
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(!studioToken);
|
||||
const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId, studioToken);
|
||||
|
||||
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes();
|
||||
|
||||
@@ -86,6 +97,7 @@ export function AIWritingForm({
|
||||
[models, modelName],
|
||||
);
|
||||
|
||||
|
||||
// 初始化时加载文章类型
|
||||
useEffect(() => {
|
||||
refreshTypes();
|
||||
@@ -127,7 +139,8 @@ export function AIWritingForm({
|
||||
|
||||
function handleMaterialSourceChange(val: MaterialSource) {
|
||||
setMaterialSource(val);
|
||||
if (val !== 'notebook') {
|
||||
// studioToken 模式下空间和文档已锁定,切换素材来源时不清空
|
||||
if (val !== 'notebook' && !studioToken) {
|
||||
setSelectedNotebookId(null);
|
||||
setSelectedDocumentIds([]);
|
||||
}
|
||||
@@ -153,9 +166,9 @@ export function AIWritingForm({
|
||||
wordCountTarget: wordCount,
|
||||
keywordCount: DEFAULT_KEYWORD_COUNT,
|
||||
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
|
||||
materialSource,
|
||||
materialSource: materialSource,
|
||||
modelName: selectedModel?.name || modelName || undefined,
|
||||
// 只有选了"我的笔记"才传笔记参数
|
||||
// 只有选了"我的空间"才传笔记参数
|
||||
noteSearchSpaceId: materialSource === 'notebook' ? (selectedNotebookId ?? undefined) : undefined,
|
||||
noteSourceIds: materialSource === 'notebook' && selectedNotebookId != null ? selectedDocumentIds : undefined,
|
||||
});
|
||||
@@ -285,32 +298,35 @@ export function AIWritingForm({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 选了"我的笔记"才展示笔记本 / 文档选择器 */}
|
||||
{/* 选了"我的空间"才展示空间 / 文档选择器 */}
|
||||
{materialSource === 'notebook' && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<Select
|
||||
value={selectedNotebookId !== null ? String(selectedNotebookId) : ''}
|
||||
onValueChange={handleNotebookChange}
|
||||
disabled={disabled || notebooksLoading}
|
||||
>
|
||||
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择笔记本'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{notebooks.map((nb) => (
|
||||
<SelectItem key={nb.id} value={String(nb.id)} className="text-sm">
|
||||
{nb.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* studioToken 模式:空间已锁定,不展示选择器 */}
|
||||
{!studioToken && (
|
||||
<Select
|
||||
value={selectedNotebookId !== null ? String(selectedNotebookId) : ''}
|
||||
onValueChange={handleNotebookChange}
|
||||
disabled={disabled || notebooksLoading}
|
||||
>
|
||||
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择空间'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{notebooks.map((nb) => (
|
||||
<SelectItem key={nb.id} value={String(nb.id)} className="text-sm">
|
||||
{nb.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{selectedNotebookId !== null && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{sourcesLoading
|
||||
? '文档加载中…'
|
||||
: `选择文档(可多选;不选则搜整个笔记本${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`}
|
||||
: `选择文档(可多选;不选则搜整个空间${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`}
|
||||
</p>
|
||||
{!sourcesLoading && (
|
||||
<div className="max-h-36 overflow-y-auto rounded-lg border border-[#e8dedd] bg-white divide-y divide-[#f0e8e7]">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||
import { useAIWriting } from '../../contexts/AIWritingContext';
|
||||
import { useAIWritingHistory } from '../../contexts/AIWritingHistoryContext';
|
||||
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
|
||||
import { adminCleanupAIWritingSessions } from '../../api/ai-writing-sessions';
|
||||
import { adminCleanupAIWritingSessions, getAIWritingSession } from '../../api/ai-writing-sessions';
|
||||
|
||||
/**
|
||||
* 暂停 / 继续功能开关(默认 false = 关)。
|
||||
@@ -70,10 +70,17 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
|
||||
export function AIWritingPanel({
|
||||
hideAdvancedFields = false,
|
||||
initialNotebookId,
|
||||
initialDocumentIds,
|
||||
studioToken,
|
||||
}: {
|
||||
hideAdvancedFields?: boolean;
|
||||
initialNotebookId?: number;
|
||||
initialDocumentIds?: number[];
|
||||
studioToken?: string;
|
||||
} = {}) {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
|
||||
const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory();
|
||||
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
|
||||
@@ -213,6 +220,18 @@ export function AIWritingPanel({
|
||||
return () => window.removeEventListener('ai-writing:open-session', onOpenSession);
|
||||
}, []);
|
||||
|
||||
// 路径3:URL ?session=xxx 直接定位到指定会话
|
||||
useEffect(() => {
|
||||
const sessionId = searchParams.get('session');
|
||||
if (!sessionId) return;
|
||||
// 消费参数,避免刷新重复触发
|
||||
setSearchParams((prev) => { prev.delete('session'); return prev; }, { replace: true });
|
||||
getAIWritingSession(sessionId)
|
||||
.then((session) => handleSelectHistoryRef.current(session))
|
||||
.catch(() => { /* session 不存在时静默忽略 */ });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 仅挂载时执行一次
|
||||
|
||||
// ── ⚠️ 临时调试:admin 一键清理所有过期会话 ────────────────────────────────
|
||||
// ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。
|
||||
// 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。
|
||||
@@ -339,6 +358,9 @@ export function AIWritingPanel({
|
||||
<AIWritingForm
|
||||
onSubmit={startWriting}
|
||||
hideAdvancedFields={hideAdvancedFields}
|
||||
initialNotebookId={initialNotebookId}
|
||||
initialDocumentIds={initialDocumentIds}
|
||||
studioToken={studioToken}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -246,7 +246,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
saveAIWritingTranscript(state.sessionId, {
|
||||
progressEvents: state.progressEvents,
|
||||
completedInterventions: state.completedInterventions,
|
||||
}).catch(() => {});
|
||||
}).catch(() => { });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.status, state.sessionId]);
|
||||
|
||||
@@ -256,6 +256,10 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
const stream = streamRef.current;
|
||||
if (!stream) return;
|
||||
try {
|
||||
// 所见即所得:用户在表单选了什么 materialSource,前端就原样传过去。
|
||||
// 三个合法值:knowledge_base / skill / notebook,后端 state.py 接受任意 str。
|
||||
// 与 material_source 独立的 notebook_search_space_id / notebook_source_ids
|
||||
// 控制是否真的去笔记后端拉素材(仅当用户选了"我的空间"模式时由表单填充)。
|
||||
await stream.submit(
|
||||
{
|
||||
user_intent: request.userIntent,
|
||||
@@ -266,7 +270,9 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
word_count_target: request.wordCountTarget,
|
||||
keyword_count: Math.max(1, Math.min(10, request.keywordCount)),
|
||||
writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose',
|
||||
material_source: request.materialSource === 'skill' ? 'skill' : 'knowledge_base',
|
||||
material_source: request.materialSource,
|
||||
notebook_search_space_id: request.noteSearchSpaceId ?? null,
|
||||
notebook_source_ids: request.noteSourceIds ?? null,
|
||||
revision_count: 0,
|
||||
max_revisions: 3,
|
||||
status: 'researching',
|
||||
@@ -368,8 +374,8 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}, [state.sessionId, state.currentPause, state.materials, state.outline,
|
||||
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult,
|
||||
state.status]);
|
||||
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult,
|
||||
state.status]);
|
||||
|
||||
const pauseWriting = useCallback(() => {
|
||||
void streamRef.current?.stop();
|
||||
|
||||
@@ -53,7 +53,7 @@ export function useWritingFormOptional(): WritingFormValue | null {
|
||||
return useContext(WritingFormContext);
|
||||
}
|
||||
|
||||
export function WritingFormProvider({ children }: { children: ReactNode }) {
|
||||
export function WritingFormProvider({ children, initialMaterialSource }: { children: ReactNode; initialMaterialSource?: 'knowledge_base' | 'skill' | 'notebook' }) {
|
||||
const [intent, setIntent] = useState('');
|
||||
const [showOutline, setShowOutline] = useState(false);
|
||||
const [outlineText, setOutlineText] = useState('');
|
||||
@@ -61,7 +61,7 @@ export function WritingFormProvider({ children }: { children: ReactNode }) {
|
||||
const [wordCountInput, setWordCountInput] = useState('800');
|
||||
const [audience, setAudience] = useState('普通读者');
|
||||
const [strictMode, setStrictMode] = useState(false);
|
||||
const [materialSource, setMaterialSource] = useState<'knowledge_base' | 'skill' | 'notebook'>('knowledge_base');
|
||||
const [materialSource, setMaterialSource] = useState<'knowledge_base' | 'skill' | 'notebook'>(initialMaterialSource ?? 'knowledge_base');
|
||||
const [modelName, setModelName] = useState('');
|
||||
|
||||
const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => {
|
||||
|
||||
@@ -115,11 +115,17 @@ const SETUP_LAYOUT = { [SETUP_CHAT_ID]: 65, [SETUP_FORM_ID]: 35 };
|
||||
/**
|
||||
* 写作配置表单面板(写作助手回填的目标),抽出复用:idle 时在右栏,开始写作后在左栏。
|
||||
*/
|
||||
function WritingFormPanel({ hideAdvancedFields }: { hideAdvancedFields: boolean }) {
|
||||
interface NotebookProps {
|
||||
initialNotebookId?: number;
|
||||
initialDocumentIds?: number[];
|
||||
studioToken?: string;
|
||||
}
|
||||
|
||||
function WritingFormPanel({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
|
||||
return (
|
||||
// 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} />
|
||||
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -129,7 +135,7 @@ function WritingFormPanel({ hideAdvancedFields }: { hideAdvancedFields: boolean
|
||||
* - idle(初始问答阶段):左=「写作助手 Q&A」,右=「写作配置选项」表单;
|
||||
* - 开始写作后:回到原布局——左=写作配置/时间线面板,右=草稿预览。
|
||||
*/
|
||||
function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }) {
|
||||
function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const { state } = useAIWriting();
|
||||
const { isHistoryMode } = useAIWritingHistory();
|
||||
@@ -162,7 +168,7 @@ function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }
|
||||
minSize={20}
|
||||
className="overflow-hidden flex flex-col"
|
||||
>
|
||||
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} />
|
||||
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
@@ -202,8 +208,14 @@ function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }
|
||||
|
||||
export default function AIWritingPage({
|
||||
hideAdvancedFields = false,
|
||||
initialNotebookId,
|
||||
initialDocumentIds,
|
||||
studioToken,
|
||||
}: {
|
||||
hideAdvancedFields?: boolean;
|
||||
initialNotebookId?: number;
|
||||
initialDocumentIds?: number[];
|
||||
studioToken?: string;
|
||||
} = {}) {
|
||||
return (
|
||||
<CanvasThreadProvider>
|
||||
@@ -211,11 +223,11 @@ export default function AIWritingPage({
|
||||
<CanvasUIProvider>
|
||||
<AIWritingHistoryProvider>
|
||||
<AIWritingProvider>
|
||||
<WritingFormProvider>
|
||||
<WritingFormProvider initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="h-full flex flex-col overflow-hidden bg-background">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} />
|
||||
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface AIMaterial {
|
||||
source: string;
|
||||
publishedAt: string;
|
||||
relevanceScore: number;
|
||||
sourceType: 'internet' | 'intranet' | 'skill' | 'notebook';
|
||||
sourceType: "internet" | "intranet" | "skill" | "notebook";
|
||||
url?: string;
|
||||
recUuid?: string;
|
||||
// skill 类素材的元信息(skill_name / container_path / category / skill_full_content 等)
|
||||
@@ -22,7 +22,13 @@ export interface AIMaterial {
|
||||
// skill_result:某个技能检索返回了 N 条素材
|
||||
// stop_early:累计素材达到上限,停止后续技能
|
||||
export interface SkillSearchStep {
|
||||
type: 'list_skills' | 'match_skills' | 'select_skills' | 'invoke_skill' | 'skill_result' | 'stop_early';
|
||||
type:
|
||||
| "list_skills"
|
||||
| "match_skills"
|
||||
| "select_skills"
|
||||
| "invoke_skill"
|
||||
| "skill_result"
|
||||
| "stop_early";
|
||||
message: string;
|
||||
skillNames?: string[];
|
||||
skillName?: string;
|
||||
@@ -41,8 +47,8 @@ export interface SkillSearchStep {
|
||||
// guard 兼容,防止前端流式接收到旧大纲时崩溃。详见 normalizeKeyPoint() helper。
|
||||
|
||||
export interface KeyPoint {
|
||||
pointTitle: string; // 小节标题(渲染为 ### 三级标题)
|
||||
subPoints: string[]; // 子要点(可空;非空时在小节正文起始处以 bullet 列表呈现)
|
||||
pointTitle: string; // 小节标题(渲染为 ### 三级标题)
|
||||
subPoints: string[]; // 子要点(可空;非空时在小节正文起始处以 bullet 列表呈现)
|
||||
}
|
||||
|
||||
export interface OutlineSection {
|
||||
@@ -65,23 +71,28 @@ export interface AIOutline {
|
||||
* - 任何意外形态 → 空 KeyPoint(防止崩溃,不抛异常)
|
||||
*/
|
||||
export function normalizeKeyPoint(raw: unknown): KeyPoint {
|
||||
if (typeof raw === 'string') {
|
||||
if (typeof raw === "string") {
|
||||
return { pointTitle: raw, subPoints: [] };
|
||||
}
|
||||
if (raw && typeof raw === 'object') {
|
||||
if (raw && typeof raw === "object") {
|
||||
const r = raw as Record<string, unknown>;
|
||||
const title =
|
||||
typeof r.pointTitle === 'string' ? r.pointTitle :
|
||||
typeof r.point_title === 'string' ? r.point_title :
|
||||
typeof r.title === 'string' ? r.title :
|
||||
'';
|
||||
typeof r.pointTitle === "string"
|
||||
? r.pointTitle
|
||||
: typeof r.point_title === "string"
|
||||
? r.point_title
|
||||
: typeof r.title === "string"
|
||||
? r.title
|
||||
: "";
|
||||
const subsRaw = (r.subPoints ?? r.sub_points ?? []) as unknown;
|
||||
const subPoints: string[] = Array.isArray(subsRaw)
|
||||
? subsRaw.map((s) => (typeof s === 'string' ? s : String(s ?? ''))).filter(Boolean)
|
||||
? subsRaw
|
||||
.map((s) => (typeof s === "string" ? s : String(s ?? "")))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
return { pointTitle: title, subPoints };
|
||||
}
|
||||
return { pointTitle: '', subPoints: [] };
|
||||
return { pointTitle: "", subPoints: [] };
|
||||
}
|
||||
|
||||
// ── 素材不足求助 ──────────────────────────────────────────────────────────────
|
||||
@@ -93,7 +104,7 @@ export interface BlockedSection {
|
||||
}
|
||||
|
||||
/** 单个被阻塞章节的处理决策 */
|
||||
export type SectionDecisionKind = 'supplement' | 'delete' | 'loose';
|
||||
export type SectionDecisionKind = "supplement" | "delete" | "loose";
|
||||
|
||||
export interface SectionDecision {
|
||||
sectionTitle: string;
|
||||
@@ -103,15 +114,15 @@ export interface SectionDecision {
|
||||
// ── 编辑审核 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ReviewIssue {
|
||||
category: 'fact' | 'logic' | 'language';
|
||||
severity: 'major' | 'minor';
|
||||
category: "fact" | "logic" | "language";
|
||||
severity: "major" | "minor";
|
||||
location: string;
|
||||
issue: string;
|
||||
suggestion: string;
|
||||
}
|
||||
|
||||
export interface ReviewResult {
|
||||
verdict: 'pass' | 'reject';
|
||||
verdict: "pass" | "reject";
|
||||
factScore: number;
|
||||
logicScore: number;
|
||||
languageScore: number;
|
||||
@@ -123,44 +134,44 @@ export interface ReviewResult {
|
||||
// ── 写作状态 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type WritingStatus =
|
||||
| 'idle'
|
||||
| 'researching'
|
||||
| 'awaiting_material_confirm'
|
||||
| 'writing_outline'
|
||||
| 'awaiting_outline_confirm'
|
||||
| 'writing_draft'
|
||||
| 'awaiting_section_help'
|
||||
| 'awaiting_draft_confirm'
|
||||
| 'reviewing'
|
||||
| 'awaiting_review_confirm'
|
||||
| 'revising'
|
||||
| 'done'
|
||||
| 'error';
|
||||
| "idle"
|
||||
| "researching"
|
||||
| "awaiting_material_confirm"
|
||||
| "writing_outline"
|
||||
| "awaiting_outline_confirm"
|
||||
| "writing_draft"
|
||||
| "awaiting_section_help"
|
||||
| "awaiting_draft_confirm"
|
||||
| "reviewing"
|
||||
| "awaiting_review_confirm"
|
||||
| "revising"
|
||||
| "done"
|
||||
| "error";
|
||||
|
||||
// ── SSE 进度事件 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type ProgressEventType =
|
||||
| 'connected'
|
||||
| 'step_start'
|
||||
| 'step_done'
|
||||
| 'intent_chunk'
|
||||
| 'intent_ready'
|
||||
| 'research_progress'
|
||||
| 'materials_ready'
|
||||
| 'outline_chunk'
|
||||
| 'outline_ready'
|
||||
| 'draft_chunk'
|
||||
| 'draft_ready'
|
||||
| 'review_chunk'
|
||||
| 'review_item_done'
|
||||
| 'review_ready'
|
||||
| 'await_user'
|
||||
| 'resumed'
|
||||
| 'notice'
|
||||
| 'skill_search_step'
|
||||
| 'skill_select_chunk'
|
||||
| 'done'
|
||||
| 'error';
|
||||
| "connected"
|
||||
| "step_start"
|
||||
| "step_done"
|
||||
| "intent_chunk"
|
||||
| "intent_ready"
|
||||
| "research_progress"
|
||||
| "materials_ready"
|
||||
| "outline_chunk"
|
||||
| "outline_ready"
|
||||
| "draft_chunk"
|
||||
| "draft_ready"
|
||||
| "review_chunk"
|
||||
| "review_item_done"
|
||||
| "review_ready"
|
||||
| "await_user"
|
||||
| "resumed"
|
||||
| "notice"
|
||||
| "skill_search_step"
|
||||
| "skill_select_chunk"
|
||||
| "done"
|
||||
| "error";
|
||||
|
||||
export interface ProgressEvent {
|
||||
type: ProgressEventType;
|
||||
@@ -183,13 +194,18 @@ export interface ProgressEvent {
|
||||
draftTitle?: string;
|
||||
revisionCount?: number;
|
||||
// review_item_done (streaming per-category result)
|
||||
reviewItemCategory?: 'fact' | 'logic' | 'language';
|
||||
reviewItemCategory?: "fact" | "logic" | "language";
|
||||
reviewItemScore?: number;
|
||||
reviewItemIssues?: ReviewIssue[];
|
||||
// review_ready
|
||||
reviewResult?: ReviewResult;
|
||||
// await_user
|
||||
pausePoint?: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm' | 'section_help';
|
||||
pausePoint?:
|
||||
| "material_confirm"
|
||||
| "outline_confirm"
|
||||
| "draft_confirm"
|
||||
| "review_confirm"
|
||||
| "section_help";
|
||||
pauseContext?: Record<string, unknown>;
|
||||
// section_help:素材不足、待用户决策的章节
|
||||
blockedSections?: BlockedSection[];
|
||||
@@ -229,19 +245,19 @@ export interface AIWritingRequest {
|
||||
/** 素材检索关键词数量(1~10,默认 1) */
|
||||
keywordCount: number;
|
||||
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
|
||||
writingMode: 'strict' | 'loose';
|
||||
/** 素材来源:knowledge_base=知识库 / skill=技能检索 / notebook=我的笔记 */
|
||||
materialSource: 'knowledge_base' | 'skill' | 'notebook';
|
||||
/** 笔记来源(可选):笔记本 search_space_id */
|
||||
writingMode: "strict" | "loose";
|
||||
/** 素材来源:knowledge_base=知识库 / skill=技能检索 / notebook=我的空间 */
|
||||
materialSource: "knowledge_base" | "skill" | "notebook";
|
||||
/** 笔记来源(可选):空间 search_space_id */
|
||||
noteSearchSpaceId?: number;
|
||||
/** 笔记来源(可选):笔记本内指定文档的 document_id 列表,空数组表示检索整个笔记本 */
|
||||
/** 笔记来源(可选):空间内指定文档的 document_id 列表,空数组表示检索整个空间 */
|
||||
noteSourceIds?: number[];
|
||||
}
|
||||
|
||||
// ── 流式审核条目 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StreamingReviewItem {
|
||||
category: 'fact' | 'logic' | 'language';
|
||||
category: "fact" | "logic" | "language";
|
||||
// 是否已生成完毕(true 时展示结构化评分/意见,false 时展示流式打字机文本)
|
||||
isDone: boolean;
|
||||
// 流式过程文本(正文 / 思考分开)
|
||||
@@ -266,7 +282,7 @@ export interface RevisionSummary {
|
||||
export interface StreamingDraftSection {
|
||||
sectionIndex: number;
|
||||
sectionTitle: string;
|
||||
text: string; // 正文内容(非思考)
|
||||
text: string; // 正文内容(非思考)
|
||||
thinkingText: string; // 思考内容(<think>...</think> 内)
|
||||
isDone: boolean;
|
||||
}
|
||||
@@ -274,7 +290,12 @@ export interface StreamingDraftSection {
|
||||
// ── 已完成干预记录 ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CompletedIntervention {
|
||||
pausePoint: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm' | 'section_help';
|
||||
pausePoint:
|
||||
| "material_confirm"
|
||||
| "outline_confirm"
|
||||
| "draft_confirm"
|
||||
| "review_confirm"
|
||||
| "section_help";
|
||||
action: string;
|
||||
// material_confirm
|
||||
materials?: AIMaterial[];
|
||||
|
||||
@@ -49,7 +49,6 @@ import { MarkdownContent } from "@/components/workspace/messages/markdown-conten
|
||||
import type {
|
||||
AdminLeaderboardDimension,
|
||||
AdminLeaderboardOverview,
|
||||
AdminLeaderboardQuestion,
|
||||
AdminLeaderboardResponse,
|
||||
AdminLeaderboardSkill,
|
||||
AdminLeaderboardTrendPoint,
|
||||
@@ -66,7 +65,7 @@ type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency"
|
||||
type RangeState = { since: string; until: string; days?: number };
|
||||
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
|
||||
const DEFAULT_REPORT_MODEL = "__default__";
|
||||
const LEADERBOARD_PAGE_SIZE = 20;
|
||||
const LEADERBOARD_PAGE_SIZE = 10;
|
||||
|
||||
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
|
||||
crimson: {
|
||||
@@ -132,11 +131,6 @@ function fmtTime(value?: string | null): string {
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
}
|
||||
|
||||
function clampText(value: string, max = 120): string {
|
||||
const text = value.trim();
|
||||
return text.length > max ? `${text.slice(0, max)}...` : text;
|
||||
}
|
||||
|
||||
function userScore(user: AdminLeaderboardUser, tab: UserRankTab): number {
|
||||
if (tab === "questions") return user.qa_count;
|
||||
if (tab === "tokens") return user.total_tokens;
|
||||
@@ -207,12 +201,6 @@ function markdownList(items: string[]): string {
|
||||
|
||||
function buildReportFocusMarkdown(data: AdminLeaderboardResponse | undefined, range: RangeState): string {
|
||||
if (!data) return "";
|
||||
const recentQuestions = data.recent_questions
|
||||
.slice(0, 8)
|
||||
.map((item) => `${clampText(item.text, 120)}(${fmtTime(item.asked_at)})`);
|
||||
const frequentQuestions = data.frequent_questions
|
||||
.slice(0, 8)
|
||||
.map((item) => clampText(item.text, 120));
|
||||
const frequentUsage = [
|
||||
...data.skills.slice(0, 5).map((item) => `技能:${item.name},${fmtNumber(item.call_count)} 次调用`),
|
||||
...data.tools.slice(0, 5).map((item) => `工具:${item.name},${fmtNumber(item.call_count)} 次调用`),
|
||||
@@ -223,12 +211,6 @@ function buildReportFocusMarkdown(data: AdminLeaderboardResponse | undefined, ra
|
||||
"",
|
||||
`统计周期:${range.since} 至 ${range.until}`,
|
||||
"",
|
||||
"## 最近关注",
|
||||
markdownList(recentQuestions),
|
||||
"",
|
||||
"## 高频问题",
|
||||
markdownList(frequentQuestions),
|
||||
"",
|
||||
"## 使用频率重点",
|
||||
markdownList(frequentUsage),
|
||||
].join("\n");
|
||||
@@ -905,43 +887,6 @@ function DimensionRanking({
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionPanel({
|
||||
title,
|
||||
items,
|
||||
showUser = true,
|
||||
}: {
|
||||
title: string;
|
||||
items: AdminLeaderboardQuestion[];
|
||||
showUser?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#213D5E]">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" />
|
||||
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
|
||||
</div>
|
||||
<Badge variant="outline">{items.length}</Badge>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="divide-y divide-[#F1D6D6] dark:divide-[#213D5E]">
|
||||
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368] dark:text-slate-400">暂无问题记录</div>}
|
||||
{items.map((item, index) => (
|
||||
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
|
||||
<div className="text-sm leading-6 text-[#202124] dark:text-slate-100">{clampText(item.text, 180)}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400">
|
||||
{showUser && <span>{item.email || item.user_id || "未知用户"}</span>}
|
||||
<span>{fmtTime(item.asked_at)}</span>
|
||||
{item.thread_id && <span>会话 {item.thread_id.slice(0, 8)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#213D5E]">
|
||||
@@ -1006,16 +951,12 @@ function UserDetailPanel({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Tabs defaultValue="questions" className="space-y-3">
|
||||
<Tabs defaultValue="trend" className="space-y-3">
|
||||
<TabsList className="mb-3">
|
||||
<TabsTrigger value="questions">提问</TabsTrigger>
|
||||
<TabsTrigger value="trend">趋势</TabsTrigger>
|
||||
<TabsTrigger value="skills">技能</TabsTrigger>
|
||||
<TabsTrigger value="dimensions">维度</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="questions" className="min-h-[420px]">
|
||||
<QuestionPanel title="最近提问" items={data.recent_questions} showUser={false} />
|
||||
</TabsContent>
|
||||
<TabsContent value="trend" className="min-h-[420px]">
|
||||
<div className="mb-3 flex items-center gap-2 font-semibold text-[#231815] dark:text-white">
|
||||
<TrendingUp className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
|
||||
@@ -1354,36 +1295,7 @@ export default function AdminUserLeaderboardPage() {
|
||||
</Button>
|
||||
</div>
|
||||
{data && reportStatus === "idle" && !reportText && (
|
||||
<div className="mx-6 mt-4 grid shrink-0 gap-3 md:grid-cols-3">
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
|
||||
<MessageSquareText className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
|
||||
最近关注
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{data.recent_questions.slice(0, 3).map((item, index) => (
|
||||
<div key={`${item.run_id || item.text}-${index}`} className="text-xs leading-5 text-[#5F6368] dark:text-slate-300">
|
||||
<span className="font-medium text-[#C1121F] dark:text-[#7FACF2]">{fmtTime(item.asked_at)}</span>
|
||||
<span className="ml-1">{clampText(item.text, 58)}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.recent_questions.length === 0 && <div className="text-xs text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
|
||||
<TrendingUp className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
|
||||
高频问题
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{data.frequent_questions.slice(0, 3).map((item, index) => (
|
||||
<div key={`${item.run_id || item.text}-${index}`} className="text-xs leading-5 text-[#5F6368] dark:text-slate-300">
|
||||
{index + 1}. {clampText(item.text, 72)}
|
||||
</div>
|
||||
))}
|
||||
{data.frequent_questions.length === 0 && <div className="text-xs text-[#8B6B6B] dark:text-[#7FACF2]">暂无数据</div>}
|
||||
</div>
|
||||
</section>
|
||||
<div className="mx-6 mt-4 grid shrink-0 gap-3">
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
|
||||
<Sparkles className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
|
||||
@@ -1645,12 +1557,6 @@ export default function AdminUserLeaderboardPage() {
|
||||
<DimensionRanking title="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" />
|
||||
<DimensionRanking title="运行状态分布" icon={Gauge} items={data.run_statuses} empty="当前时间段暂无运行状态" />
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<QuestionPanel title="最近经常问的问题" items={data.frequent_questions} />
|
||||
<QuestionPanel title="最新问题" items={data.recent_questions} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, PlusSquare } from "lucide-react";
|
||||
import { BotIcon, MessageCircleQuestionIcon, PlusSquare } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
|
||||
import { AgentWelcome } from "@/components/workspace/agent-welcome";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import {
|
||||
MessageList,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { useAgent } from "@/core/agents";
|
||||
import { normalizeAgentQuestions, useAgent } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
@@ -65,6 +62,11 @@ export default function AgentChatPage() {
|
||||
const agentDisplayName = desensitize(
|
||||
agent?.name ?? (isAgentLoading ? t.common.loading : ""),
|
||||
);
|
||||
// 常用问题 shown as a list beside the input box on the landing screen.
|
||||
const commonQuestions = useMemo(
|
||||
() => normalizeAgentQuestions(agent?.common_questions),
|
||||
[agent?.common_questions],
|
||||
);
|
||||
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread } =
|
||||
useThreadChat();
|
||||
@@ -162,129 +164,197 @@ export default function AgentChatPage() {
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
|
||||
const messageListPaddingBottom = showFollowups
|
||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||
: undefined;
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
const isDemoMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
|
||||
|
||||
const status = thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready";
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 border-b px-4",
|
||||
isLandingState
|
||||
? "border-transparent bg-transparent"
|
||||
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
|
||||
)}
|
||||
>
|
||||
{/* Agent badge */}
|
||||
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
||||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||
<span
|
||||
className="max-w-[200px] truncate text-xs font-medium"
|
||||
title={agentDisplayName}
|
||||
>
|
||||
{agentDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="mr-4 flex items-center">
|
||||
<Tooltip content={t.agents.newChat}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
router.push(`/page/workspace/agents/${agent_id}/chats/new`);
|
||||
}}
|
||||
{/* Root: fixed-height horizontal split. The left main column owns the
|
||||
chat (home / conversation) and the input; the right sidebar is a
|
||||
separate flex sibling, so nothing in the main column can ever bleed
|
||||
over it. */}
|
||||
<div className="flex size-full min-h-0 overflow-hidden">
|
||||
{/* ── Left: main conversation column ───────────────────────────── */}
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{/* Header — in normal flow (not absolute), fixed height. */}
|
||||
<header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md">
|
||||
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
||||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||
<span
|
||||
className="max-w-[200px] truncate text-xs font-medium"
|
||||
title={agentDisplayName}
|
||||
>
|
||||
<PlusSquare /> {t.agents.newChat}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
/>
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
{agentDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex size-full justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isLandingState && "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
|
||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isLandingState && "-translate-y-[calc(50vh-96px)]",
|
||||
isLandingState
|
||||
? "max-w-(--container-width-md)"
|
||||
: "max-w-(--container-width-lg)",
|
||||
)}
|
||||
>
|
||||
<div className="absolute -top-4 right-0 left-0 z-0">
|
||||
<div className="absolute right-0 bottom-0 left-0">
|
||||
<div className="flex items-center">
|
||||
<Tooltip content={t.agents.newChat}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/page/workspace/agents/${agent_id}/chats/new`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<PlusSquare /> {t.agents.newChat}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
/>
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isLandingState ? (
|
||||
/* ── home: centered hero + suggested questions + input ──────
|
||||
Everything lives inside the scrollable main column. No fixed,
|
||||
no absolute, no viewport centering — so the avatar can't touch
|
||||
the header and the input can't cover the sidebar. */
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center overflow-y-auto px-6 pb-10 pt-16 sm:pt-24">
|
||||
<AgentWelcome
|
||||
agent={agent}
|
||||
agentName={agentDisplayName}
|
||||
className="mb-7"
|
||||
/>
|
||||
{/* 常用问题 sit as a list to the LEFT of the input box (not as
|
||||
floating pills above it). On narrow screens the list stacks
|
||||
above the input instead of squeezing it. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-3 sm:flex-row sm:items-end sm:gap-4",
|
||||
commonQuestions.length > 0
|
||||
? "max-w-(--container-width-lg)"
|
||||
: "max-w-(--container-width-md)",
|
||||
)}
|
||||
>
|
||||
{commonQuestions.length > 0 && (
|
||||
<div className="border-border/60 bg-card/60 w-full shrink-0 rounded-xl border p-3 sm:w-64">
|
||||
<div className="text-muted-foreground mb-2 flex items-center gap-1.5 text-xs font-medium">
|
||||
<MessageCircleQuestionIcon className="size-3.5" />
|
||||
常用问题
|
||||
</div>
|
||||
<div className="flex max-h-44 flex-col gap-1.5 overflow-y-auto">
|
||||
{commonQuestions.map((question, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
disabled={isDemoMode}
|
||||
onClick={() =>
|
||||
handleSubmit({ text: question.prompt, files: [] })
|
||||
}
|
||||
title={desensitize(question.prompt)}
|
||||
className={cn(
|
||||
"border-border/60 bg-background/60 hover:bg-accent hover:border-border w-full truncate rounded-lg border px-3 py-2 text-left text-sm transition-colors",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{desensitize(question.title)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread
|
||||
hideAdminRecommendedQuestions
|
||||
threadId={threadId}
|
||||
assistantId={agent_id}
|
||||
autoFocus
|
||||
status={status}
|
||||
context={settings.context}
|
||||
disabled={isDemoMode}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
{isDemoMode && (
|
||||
<div className="text-muted-foreground/67 mt-3 w-full text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ── conversation (active / history): messages scroll, input
|
||||
docked at the bottom of the main column only. */
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<MessageList
|
||||
className="min-h-0 flex-1"
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={showFollowups ? 96 : 24}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
|
||||
<div className="from-background pointer-events-none shrink-0 bg-gradient-to-t to-transparent px-4 pb-4 pt-2">
|
||||
<div className="pointer-events-auto mx-auto w-full max-w-(--container-width-lg)">
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
className="bg-background/5 mb-2"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos || thread.values.todos.length === 0
|
||||
!thread.values.todos ||
|
||||
thread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread={false}
|
||||
threadId={threadId}
|
||||
assistantId={agent_id}
|
||||
status={status}
|
||||
context={settings.context}
|
||||
disabled={isDemoMode}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
{isDemoMode && (
|
||||
<div className="text-muted-foreground/67 mt-2 w-full text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full -translate-y-4">
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread={isLandingState}
|
||||
threadId={threadId}
|
||||
assistantId={agent_id}
|
||||
autoFocus={isLandingState}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isLandingState && (
|
||||
<AgentWelcome agent={agent} agentName={agentDisplayName} />
|
||||
)
|
||||
}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
</div>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* ── Right: agent detail panel (profile + inline editing for
|
||||
admins / owners; read-only for everyone else) ───────────────── */}
|
||||
<AgentDetailPanel
|
||||
agent={agent}
|
||||
agentName={agentDisplayName}
|
||||
disabled={thread.isLoading || isDemoMode}
|
||||
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Network,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Search,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -29,14 +14,16 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { EmptyDocumentState } from "@/components/wiki/EmptyDocumentState";
|
||||
import { KnowledgeSidebar } from "@/components/wiki/KnowledgeSidebar";
|
||||
import { WikiContent } from "@/components/wiki/WikiContent";
|
||||
import { WikiHeader } from "@/components/wiki/WikiHeader";
|
||||
import {
|
||||
buildKnowledgeTree,
|
||||
downloadMarkdown,
|
||||
findAncestorFolderIds,
|
||||
} from "@/components/wiki/wikiUtils";
|
||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
import {
|
||||
useKnowledgeActions,
|
||||
@@ -52,45 +39,14 @@ import MarkdownRenderer, {
|
||||
type WikiLinkTarget,
|
||||
} from "@/strategy-components/MarkdownRenderer";
|
||||
|
||||
const SOURCE_TYPE_LABELS: Record<string, string> = {
|
||||
thread: "对话",
|
||||
search: "检索",
|
||||
tool: "工具",
|
||||
file: "文件",
|
||||
manual: "手动",
|
||||
report: "报告",
|
||||
};
|
||||
const SIDEBAR_COLLAPSED_KEY = "wiki.sidebar.collapsed";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
approved: "已批准",
|
||||
archived: "已归档",
|
||||
};
|
||||
|
||||
/** Human label for an audit actor: prefer the resolved name (email), else a short id. */
|
||||
function actorLabel(name?: string | null, id?: string | null): string {
|
||||
if (name && name.trim()) return name.trim();
|
||||
if (id && id.trim()) return `${id.trim().slice(0, 8)}…`;
|
||||
return "—";
|
||||
}
|
||||
|
||||
const ENTITY_TYPE_META: Record<string, { label: string; className: string }> = {
|
||||
person: { label: "人物", className: "bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-500/15 dark:text-rose-300 dark:border-rose-500/30" },
|
||||
org: { label: "机构", className: "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30" },
|
||||
product: { label: "产品", className: "bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30" },
|
||||
technology: { label: "技术", className: "bg-cyan-50 text-cyan-700 border-cyan-200 dark:bg-cyan-500/15 dark:text-cyan-300 dark:border-cyan-500/30" },
|
||||
concept: { label: "概念", className: "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30" },
|
||||
region: { label: "地区", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
|
||||
country: { label: "国家", className: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30" },
|
||||
};
|
||||
|
||||
function entityMeta(type?: string | null) {
|
||||
return (
|
||||
ENTITY_TYPE_META[(type || "").toLowerCase()] ?? {
|
||||
label: "其他",
|
||||
className: "bg-slate-100 text-slate-600 border-slate-200 dark:bg-slate-700/40 dark:text-slate-300 dark:border-slate-600",
|
||||
}
|
||||
);
|
||||
function readSidebarCollapsed(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function useDebounced<T>(value: T, delay = 350): T {
|
||||
@@ -102,54 +58,6 @@ function useDebounced<T>(value: T, delay = 350): T {
|
||||
return debounced;
|
||||
}
|
||||
|
||||
function NoteListItem({
|
||||
note,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
note: KnowledgeNote;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-1 rounded-md border p-3 text-left transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border/70 hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="line-clamp-1 text-sm font-medium">{note.title}</span>
|
||||
<Badge variant="outline" className="shrink-0 text-[10px]">
|
||||
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
|
||||
</Badge>
|
||||
</div>
|
||||
{note.summary ? (
|
||||
<span className="text-muted-foreground line-clamp-2 text-xs">
|
||||
{note.summary}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{note.tags.slice(0, 4).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-muted-foreground ml-auto text-[10px]">
|
||||
{note.updated_at?.slice(0, 10)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateNoteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -323,6 +231,119 @@ function BatchImportDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function ImportFileDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
const { ingestFile } = useKnowledgeActions();
|
||||
const navigate = useNavigate();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [model, setModel] = useState<string | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setFile(null);
|
||||
setTitle("");
|
||||
setTags("");
|
||||
setModel(null);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file) {
|
||||
toast.error("请选择文件");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await ingestFile.mutateAsync({
|
||||
file,
|
||||
opts: {
|
||||
title: title.trim() || undefined,
|
||||
tags: tags.trim() || undefined,
|
||||
status: "approved",
|
||||
model_name: model,
|
||||
},
|
||||
});
|
||||
const count = result.notes?.length ?? 1;
|
||||
if (result.llm_fallback) {
|
||||
// The model produced nothing usable — the file was stored verbatim as a
|
||||
// draft instead of distilled into a wiki note.
|
||||
toast.warning(
|
||||
`已导入 ${count} 条(大模型异常,改用规则抽取并存为草稿)`,
|
||||
);
|
||||
} else {
|
||||
toast.success(`已从文件生成 ${count} 条知识`);
|
||||
}
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
if (result.note?.id) {
|
||||
navigate(`/page/workspace/knowledge/${result.note.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "文件导入失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>导入文件生成 Wiki</DialogTitle>
|
||||
<DialogDescription>
|
||||
上传文档(Markdown / TXT / PDF / Word / PPT / Excel),自动提炼为知识库 wiki 词条并写入 vault。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
选择文件
|
||||
<Input
|
||||
type="file"
|
||||
accept=".md,.markdown,.txt,.pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
{file ? (
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{file.name} · {(file.size / 1024).toFixed(0)} KB
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="标题(可选,留空则由模型生成)"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="标签,逗号分隔(可选)"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
/>
|
||||
<label className="flex items-center justify-between gap-2 text-sm">
|
||||
提炼大模型
|
||||
<ModelNameSelect
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
disabled={ingestFile.isPending}
|
||||
triggerClassName="h-9 w-[11rem]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => void handleImport()} disabled={ingestFile.isPending || !file}>
|
||||
{ingestFile.isPending ? "导入中..." : "导入并生成"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DedupDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -436,13 +457,92 @@ function VaultPageDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function NoteDetail({ noteId }: { noteId: string }) {
|
||||
export default function KnowledgeBasePage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: note, isLoading } = useKnowledgeNote(noteId);
|
||||
const { data: wikiLinks } = useNoteWikilinks(noteId);
|
||||
const { update, remove } = useKnowledgeActions();
|
||||
const { noteId } = useParams<{ noteId: string }>();
|
||||
|
||||
// --- list / tree state ---
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const debouncedKeyword = useDebounced(keyword);
|
||||
const [sourceType, setSourceType] = useState("all");
|
||||
const [status, setStatus] = useState("all");
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const treeInitializedRef = useRef(false);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(readSidebarCollapsed);
|
||||
|
||||
// --- dialogs ---
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [batchOpen, setBatchOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [dedupOpen, setDedupOpen] = useState(false);
|
||||
const [vaultPath, setVaultPath] = useState<string | null>(null);
|
||||
|
||||
const params = useMemo(
|
||||
() => ({
|
||||
keyword: debouncedKeyword || undefined,
|
||||
source_type: sourceType === "all" ? undefined : sourceType,
|
||||
status: status === "all" ? undefined : status,
|
||||
limit: 500,
|
||||
}),
|
||||
[debouncedKeyword, sourceType, status],
|
||||
);
|
||||
|
||||
const { data, isLoading } = useKnowledgeNotes(params);
|
||||
const notes = data?.items ?? [];
|
||||
const tree = useMemo(() => buildKnowledgeTree(notes), [notes]);
|
||||
|
||||
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
|
||||
const { data: wikiLinks } = useNoteWikilinks(noteId);
|
||||
|
||||
// First load: expand the root-level folders so the tree reads as a directory.
|
||||
useEffect(() => {
|
||||
if (treeInitializedRef.current || tree.length === 0) return;
|
||||
treeInitializedRef.current = true;
|
||||
setExpandedIds(
|
||||
new Set(tree.filter((n) => n.type === "folder").map((n) => n.id)),
|
||||
);
|
||||
}, [tree]);
|
||||
|
||||
// Keep the selected document's ancestor folders open (deep links included).
|
||||
useEffect(() => {
|
||||
if (!noteId || tree.length === 0) return;
|
||||
const ancestors = findAncestorFolderIds(tree, noteId);
|
||||
if (!ancestors || ancestors.length === 0) return;
|
||||
setExpandedIds((prev) => {
|
||||
if (ancestors.every((id) => prev.has(id))) return prev;
|
||||
const next = new Set(prev);
|
||||
for (const id of ancestors) next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, [noteId, tree]);
|
||||
|
||||
const handleToggleFolder = (id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectNote = (item: KnowledgeNote) => {
|
||||
setMobileSidebarOpen(false);
|
||||
navigate(`/page/workspace/knowledge/${item.id}`);
|
||||
};
|
||||
|
||||
const handleToggleSidebarCollapse = () => {
|
||||
setSidebarCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, next ? "1" : "0");
|
||||
} catch {
|
||||
// localStorage unavailable (private mode) — state stays session-only.
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleWikiLink = (link: WikiLinkTarget) => {
|
||||
if (link.kind === "note" && link.note_id) {
|
||||
navigate(`/page/workspace/knowledge/${link.note_id}`);
|
||||
@@ -450,235 +550,81 @@ function NoteDetail({ noteId }: { noteId: string }) {
|
||||
setVaultPath(link.vault_path);
|
||||
}
|
||||
};
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draftTitle, setDraftTitle] = useState("");
|
||||
const [draftTags, setDraftTags] = useState("");
|
||||
const [draftContent, setDraftContent] = useState("");
|
||||
const [draftStatus, setDraftStatus] = useState("approved");
|
||||
|
||||
useEffect(() => {
|
||||
setEditing(false);
|
||||
setVaultPath(null);
|
||||
}, [noteId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (note) {
|
||||
setDraftTitle(note.title);
|
||||
setDraftTags(note.tags.join(", "));
|
||||
setDraftContent(note.content_md);
|
||||
setDraftStatus(note.status);
|
||||
}
|
||||
}, [note]);
|
||||
|
||||
if (isLoading || !note) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
|
||||
{isLoading ? "加载中..." : "知识不存在"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
noteId: note.id,
|
||||
data: {
|
||||
title: draftTitle.trim() || note.title,
|
||||
content_md: draftContent,
|
||||
tags: draftTags
|
||||
.split(/[,,\s]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
status: draftStatus,
|
||||
},
|
||||
});
|
||||
toast.success("已保存");
|
||||
setEditing(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async () => {
|
||||
try {
|
||||
await remove.mutateAsync({ noteId: note.id });
|
||||
toast.success("已归档");
|
||||
navigate("/page/workspace/knowledge");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "归档失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between gap-2 border-b p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{STATUS_LABELS[note.status] ?? note.status}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{editing ? (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
|
||||
<X className="size-4" /> 取消
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void handleSave()} disabled={update.isPending}>
|
||||
<Save className="size-4" /> 保存
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(true)}>
|
||||
<Pencil className="size-4" /> 编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => void handleArchive()}
|
||||
disabled={remove.isPending}
|
||||
>
|
||||
<Trash2 className="size-4" /> 归档
|
||||
</Button>
|
||||
</>
|
||||
<div className="flex size-full min-h-0 flex-col bg-slate-50 dark:bg-slate-950">
|
||||
<WikiHeader
|
||||
onToggleSidebar={() => setMobileSidebarOpen((v) => !v)}
|
||||
onImportFile={() => setImportOpen(true)}
|
||||
onBatchImport={() => setBatchOpen(true)}
|
||||
onDedup={() => setDedupOpen(true)}
|
||||
onGraph={() => navigate("/page/workspace/knowledge/graph")}
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-h-0 flex-1 grid-cols-1",
|
||||
sidebarCollapsed && !mobileSidebarOpen
|
||||
? "md:grid-cols-[44px_minmax(0,1fr)]"
|
||||
: "md:grid-cols-[288px_minmax(0,1fr)]",
|
||||
)}
|
||||
>
|
||||
{mobileSidebarOpen ? (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/30 md:hidden"
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<KnowledgeSidebar
|
||||
keyword={keyword}
|
||||
onKeywordChange={setKeyword}
|
||||
sourceType={sourceType}
|
||||
onSourceTypeChange={setSourceType}
|
||||
status={status}
|
||||
onStatusChange={setStatus}
|
||||
tree={tree}
|
||||
expandedIds={expandedIds}
|
||||
onToggleFolder={handleToggleFolder}
|
||||
selectedId={noteId}
|
||||
onSelect={handleSelectNote}
|
||||
onExportNote={downloadMarkdown}
|
||||
total={data?.total ?? 0}
|
||||
isLoading={isLoading}
|
||||
collapsed={sidebarCollapsed && !mobileSidebarOpen}
|
||||
onToggleCollapse={handleToggleSidebarCollapse}
|
||||
className={cn(
|
||||
"max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:z-50 max-md:w-[300px] max-md:shadow-xl max-md:transition-transform max-md:duration-200",
|
||||
mobileSidebarOpen ? "max-md:translate-x-0" : "max-md:-translate-x-full",
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
|
||||
{!noteId ? (
|
||||
<EmptyDocumentState />
|
||||
) : noteLoading || !note ? (
|
||||
<div className="flex h-full items-center justify-center bg-white text-sm text-slate-400 dark:bg-slate-900">
|
||||
{noteLoading ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中...
|
||||
</span>
|
||||
) : (
|
||||
"知识不存在"
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<WikiContent
|
||||
note={note}
|
||||
wikiLinks={wikiLinks}
|
||||
onWikiLinkClick={handleWikiLink}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="p-4">
|
||||
{editing ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
value={draftTitle}
|
||||
onChange={(e) => setDraftTitle(e.target.value)}
|
||||
placeholder="标题"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={draftTags}
|
||||
onChange={(e) => setDraftTags(e.target.value)}
|
||||
placeholder="标签,逗号分隔"
|
||||
/>
|
||||
<Select value={draftStatus} onValueChange={setDraftStatus}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">草稿</SelectItem>
|
||||
<SelectItem value="approved">已批准</SelectItem>
|
||||
<SelectItem value="archived">已归档</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Textarea
|
||||
rows={20}
|
||||
value={draftContent}
|
||||
onChange={(e) => setDraftContent(e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="mb-3 text-xl font-semibold">{note.title}</h1>
|
||||
|
||||
{note.tags.length > 0 ? (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5">
|
||||
{note.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="border-primary/20 bg-primary/5 text-primary/90 hover:bg-primary/10 inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{note.entities && note.entities.length > 0 ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-muted-foreground mr-0.5 inline-flex items-center gap-1 text-xs">
|
||||
<Sparkles className="size-3" /> 实体
|
||||
</span>
|
||||
{note.entities.map((ent) => {
|
||||
const meta = entityMeta(ent.entity_type);
|
||||
return (
|
||||
<span
|
||||
key={`${ent.name}-${ent.entity_type ?? ""}`}
|
||||
title={meta.label}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium",
|
||||
meta.className,
|
||||
)}
|
||||
>
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-current opacity-70" />
|
||||
<span>{ent.name}</span>
|
||||
<span className="rounded-full bg-current/15 px-1.5 py-0.5 text-[10px] leading-none opacity-90">
|
||||
{meta.label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<MarkdownRenderer
|
||||
content={note.content_md}
|
||||
wikiLinks={wikiLinks}
|
||||
onWikiLinkClick={handleWikiLink}
|
||||
/>
|
||||
|
||||
{note.sources && note.sources.length > 0 ? (
|
||||
<div className="mt-6 border-t pt-4">
|
||||
<h2 className="mb-2 text-sm font-semibold">来源</h2>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{note.sources.map((src) => (
|
||||
<li key={src.id} className="text-sm">
|
||||
{src.url ? (
|
||||
<a
|
||||
href={src.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{src.title || src.url}
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
) : src.thread_id ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary hover:underline"
|
||||
onClick={() =>
|
||||
navigate(`/page/workspace/chats/${src.thread_id}`)
|
||||
}
|
||||
>
|
||||
{src.title || `对话 ${src.thread_id.slice(0, 8)}`}
|
||||
</button>
|
||||
) : (
|
||||
<span>{src.title || src.tool_name || "来源"}</span>
|
||||
)}
|
||||
{src.snippet ? (
|
||||
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs">
|
||||
{src.snippet}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="text-muted-foreground mt-6 border-t pt-3 text-xs">
|
||||
创建人 {actorLabel(note.created_by_name, note.created_by)} · 更新人{" "}
|
||||
{actorLabel(note.updated_by_name, note.updated_by)} · 更新于{" "}
|
||||
{note.updated_at?.slice(0, 19).replace("T", " ")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<ImportFileDialog open={importOpen} onOpenChange={setImportOpen} />
|
||||
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
|
||||
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
|
||||
<VaultPageDialog
|
||||
path={vaultPath}
|
||||
onOpenChange={(open) => {
|
||||
@@ -688,134 +634,3 @@ function NoteDetail({ noteId }: { noteId: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function KnowledgeBasePage() {
|
||||
const navigate = useNavigate();
|
||||
const { noteId } = useParams<{ noteId: string }>();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const debouncedKeyword = useDebounced(keyword);
|
||||
const [sourceType, setSourceType] = useState("all");
|
||||
const [status, setStatus] = useState("all");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [batchOpen, setBatchOpen] = useState(false);
|
||||
const [dedupOpen, setDedupOpen] = useState(false);
|
||||
|
||||
const params = useMemo(
|
||||
() => ({
|
||||
keyword: debouncedKeyword || undefined,
|
||||
source_type: sourceType === "all" ? undefined : sourceType,
|
||||
status: status === "all" ? undefined : status,
|
||||
limit: 50,
|
||||
}),
|
||||
[debouncedKeyword, sourceType, status],
|
||||
);
|
||||
|
||||
const { data, isLoading } = useKnowledgeNotes(params);
|
||||
const notes = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0 flex-col">
|
||||
<header className="flex flex-wrap items-center gap-2 border-b p-3">
|
||||
<BookOpen className="size-5" />
|
||||
<h1 className="mr-2 text-base font-semibold">知识库</h1>
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-2 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="搜索标题 / 摘要 / 正文 / 标签"
|
||||
className="w-64 pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select value={sourceType} onValueChange={setSourceType}>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue placeholder="来源" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部来源</SelectItem>
|
||||
{Object.entries(SOURCE_TYPE_LABELS).map(([v, label]) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue placeholder="状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="draft">草稿</SelectItem>
|
||||
<SelectItem value="approved">已批准</SelectItem>
|
||||
<SelectItem value="archived">已归档</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setBatchOpen(true)}>
|
||||
<RefreshCw className="size-4" /> 批量导入
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setDedupOpen(true)}>
|
||||
<Copy className="size-4" /> 查重
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate("/page/workspace/knowledge/graph")}
|
||||
>
|
||||
<Network className="size-4" /> 图谱
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="size-4" /> 新建
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<div className="flex min-h-0 flex-col border-r">
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 p-4 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中...
|
||||
</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
|
||||
暂无知识。点击「新建」或在对话页「保存到知识库」。
|
||||
</div>
|
||||
) : (
|
||||
notes.map((note) => (
|
||||
<NoteListItem
|
||||
key={note.id}
|
||||
note={note}
|
||||
active={note.id === noteId}
|
||||
onClick={() => navigate(`/page/workspace/knowledge/${note.id}`)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{data ? (
|
||||
<div className="text-muted-foreground border-t p-2 text-center text-xs">
|
||||
共 {data.total} 条知识
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0">
|
||||
{noteId ? (
|
||||
<NoteDetail noteId={noteId} />
|
||||
) : (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
|
||||
从左侧选择一条知识查看详情
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
|
||||
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +1,695 @@
|
||||
import { ArrowLeft, Loader2, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Loader2, Network, RotateCcw, Tags, Undo2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { knowledgeExportFileUrl } from "@/core/knowledge/api";
|
||||
import { useKnowledgeActions } from "@/core/knowledge/hooks";
|
||||
import {
|
||||
GraphCanvas,
|
||||
type GraphCanvasHandle,
|
||||
} from "@/components/knowledge-graph/GraphCanvas";
|
||||
import { GraphDetailPanel } from "@/components/knowledge-graph/GraphDetailPanel";
|
||||
import { GraphFooter } from "@/components/knowledge-graph/GraphFooter";
|
||||
import { GraphHeader } from "@/components/knowledge-graph/GraphHeader";
|
||||
import { GraphSidebar } from "@/components/knowledge-graph/GraphSidebar";
|
||||
import { GraphTooltip } from "@/components/knowledge-graph/GraphTooltip";
|
||||
import type {
|
||||
ExploreStep,
|
||||
GraphMode,
|
||||
LayoutType,
|
||||
NodeKind,
|
||||
TooltipState,
|
||||
} from "@/components/knowledge-graph/graphTypes";
|
||||
import {
|
||||
computeDegrees,
|
||||
computeGraphStats,
|
||||
getEdgeId,
|
||||
getEdgeLabel,
|
||||
getNodeKind,
|
||||
getNodeTitle,
|
||||
} from "@/components/knowledge-graph/graphUtils";
|
||||
import "@/components/knowledge-graph/graphStyles.css";
|
||||
import { useKnowledgeGraph, useKnowledgeNote } from "@/core/knowledge/hooks";
|
||||
|
||||
export default function KnowledgeGraphPage() {
|
||||
const navigate = useNavigate();
|
||||
const { exportGraph } = useKnowledgeActions();
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
const [stats, setStats] = useState<{ nodes: number; edges: number } | null>(null);
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<GraphCanvasHandle>(null);
|
||||
|
||||
const regenerate = useCallback(async () => {
|
||||
try {
|
||||
const result = await exportGraph.mutateAsync();
|
||||
setStats(result.stats);
|
||||
// Cache-bust the iframe so it reflects the freshly generated graph.
|
||||
setSrc(`${knowledgeExportFileUrl("graph.html")}?t=${Date.now()}`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "生成图谱失败");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const { data, isLoading, isError, error, refetch, isFetching } =
|
||||
useKnowledgeGraph();
|
||||
|
||||
// ------------------------------------------------------------------ state
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const [disabledNodeKinds, setDisabledNodeKinds] = useState<Set<NodeKind>>(
|
||||
new Set(),
|
||||
);
|
||||
const [disabledEdgeLabels, setDisabledEdgeLabels] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<string>>(new Set());
|
||||
const [layoutType, setLayoutType] = useState<LayoutType>("force");
|
||||
const [showLabels, setShowLabels] = useState(true);
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
const [graphMode, setGraphMode] = useState<GraphMode>("explore");
|
||||
const [exploreRootId, setExploreRootId] = useState<string | null>(null);
|
||||
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<string>>(new Set());
|
||||
const [visibleNodeIds, setVisibleNodeIds] = useState<Set<string>>(new Set());
|
||||
const [visibleEdgeIds, setVisibleEdgeIds] = useState<Set<string>>(new Set());
|
||||
const [nodeDepthMap, setNodeDepthMap] = useState<Map<string, number>>(new Map());
|
||||
const [exploreHistory, setExploreHistory] = useState<ExploreStep[]>([]);
|
||||
|
||||
// Auto-collapse the side panels on narrow viewports.
|
||||
useEffect(() => {
|
||||
const detailQuery = window.matchMedia("(max-width: 1280px)");
|
||||
const sidebarQuery = window.matchMedia("(max-width: 900px)");
|
||||
const apply = () => {
|
||||
if (detailQuery.matches) setShowDetail(false);
|
||||
setShowSidebar(!sidebarQuery.matches);
|
||||
};
|
||||
apply();
|
||||
detailQuery.addEventListener("change", apply);
|
||||
sidebarQuery.addEventListener("change", apply);
|
||||
return () => {
|
||||
detailQuery.removeEventListener("change", apply);
|
||||
sidebarQuery.removeEventListener("change", apply);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ---------------------------------------------------------------- derived
|
||||
const allNodes = useMemo(() => data?.nodes ?? [], [data]);
|
||||
const allEdges = useMemo(() => data?.edges ?? [], [data]);
|
||||
|
||||
const nodeById = useMemo(
|
||||
() => new Map(allNodes.map((n) => [n.id, n])),
|
||||
[allNodes],
|
||||
);
|
||||
const degrees = useMemo(
|
||||
() => computeDegrees(allNodes, allEdges),
|
||||
[allNodes, allEdges],
|
||||
);
|
||||
const stats = useMemo(
|
||||
() => computeGraphStats(allNodes, allEdges),
|
||||
[allNodes, allEdges],
|
||||
);
|
||||
|
||||
const nodeKindCounts = useMemo(() => {
|
||||
const counts = new Map<NodeKind, number>();
|
||||
for (const n of allNodes) {
|
||||
const kind = getNodeKind(n);
|
||||
counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [allNodes]);
|
||||
|
||||
const edgeLabelCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const e of allEdges) {
|
||||
const label = getEdgeLabel(e);
|
||||
counts.set(label, (counts.get(label) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [allEdges]);
|
||||
|
||||
const filteredNodes = useMemo(
|
||||
() =>
|
||||
allNodes.filter(
|
||||
(n) => !disabledNodeKinds.has(getNodeKind(n)) && !hiddenNodeIds.has(n.id),
|
||||
),
|
||||
[allNodes, disabledNodeKinds, hiddenNodeIds],
|
||||
);
|
||||
|
||||
const filteredEdges = useMemo(() => {
|
||||
const visible = new Set(filteredNodes.map((n) => n.id));
|
||||
return allEdges.filter(
|
||||
(e) =>
|
||||
visible.has(e.source) &&
|
||||
visible.has(e.target) &&
|
||||
!disabledEdgeLabels.has(getEdgeLabel(e)),
|
||||
);
|
||||
}, [allEdges, filteredNodes, disabledEdgeLabels]);
|
||||
|
||||
const resetExplore = useCallback(() => {
|
||||
setExploreRootId(null);
|
||||
setExpandedNodeIds(new Set());
|
||||
setVisibleNodeIds(new Set());
|
||||
setVisibleEdgeIds(new Set());
|
||||
setNodeDepthMap(new Map());
|
||||
setExploreHistory([]);
|
||||
}, []);
|
||||
|
||||
const rebuildExploreState = useCallback((history: ExploreStep[]) => {
|
||||
const nextVisibleNodeIds = new Set<string>();
|
||||
const nextVisibleEdgeIds = new Set<string>();
|
||||
const nextNodeDepthMap = new Map<string, number>();
|
||||
const nextExpandedNodeIds = new Set<string>();
|
||||
|
||||
for (const step of history) {
|
||||
for (const nodeId of step.addedNodeIds) {
|
||||
nextVisibleNodeIds.add(nodeId);
|
||||
if (!nextNodeDepthMap.has(nodeId)) nextNodeDepthMap.set(nodeId, step.depth);
|
||||
}
|
||||
for (const edgeId of step.addedEdgeIds) nextVisibleEdgeIds.add(edgeId);
|
||||
if (step.depth > 0) nextExpandedNodeIds.add(step.nodeId);
|
||||
}
|
||||
|
||||
setVisibleNodeIds(nextVisibleNodeIds);
|
||||
setVisibleEdgeIds(nextVisibleEdgeIds);
|
||||
setNodeDepthMap(nextNodeDepthMap);
|
||||
setExpandedNodeIds(nextExpandedNodeIds);
|
||||
}, []);
|
||||
|
||||
const startExploreFromNode = useCallback((nodeId: string) => {
|
||||
setExploreRootId(nodeId);
|
||||
setVisibleNodeIds(new Set([nodeId]));
|
||||
setVisibleEdgeIds(new Set());
|
||||
setExpandedNodeIds(new Set());
|
||||
setNodeDepthMap(new Map([[nodeId, 0]]));
|
||||
setExploreHistory([
|
||||
{
|
||||
nodeId,
|
||||
depth: 0,
|
||||
addedNodeIds: [nodeId],
|
||||
addedEdgeIds: [],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
window.setTimeout(() => canvasRef.current?.focusNode(nodeId), 0);
|
||||
}, []);
|
||||
|
||||
const expandOneHop = useCallback(
|
||||
(nodeId: string) => {
|
||||
const currentDepth = nodeDepthMap.get(nodeId) ?? 0;
|
||||
const nextDepth = currentDepth + 1;
|
||||
const addedNodeIds: string[] = [];
|
||||
const addedEdgeIds: string[] = [];
|
||||
const nextVisibleNodeIds = new Set(visibleNodeIds);
|
||||
const nextVisibleEdgeIds = new Set(visibleEdgeIds);
|
||||
const nextNodeDepthMap = new Map(nodeDepthMap);
|
||||
|
||||
for (const edge of filteredEdges) {
|
||||
if (edge.source !== nodeId && edge.target !== nodeId) continue;
|
||||
const edgeId = getEdgeId(edge);
|
||||
const neighborId = edge.source === nodeId ? edge.target : edge.source;
|
||||
if (!nodeById.has(neighborId)) continue;
|
||||
|
||||
if (!nextVisibleEdgeIds.has(edgeId)) {
|
||||
nextVisibleEdgeIds.add(edgeId);
|
||||
addedEdgeIds.push(edgeId);
|
||||
}
|
||||
if (!nextVisibleNodeIds.has(neighborId)) {
|
||||
nextVisibleNodeIds.add(neighborId);
|
||||
nextNodeDepthMap.set(neighborId, nextDepth);
|
||||
addedNodeIds.push(neighborId);
|
||||
}
|
||||
}
|
||||
|
||||
nextVisibleNodeIds.add(nodeId);
|
||||
nextNodeDepthMap.set(nodeId, currentDepth);
|
||||
setVisibleNodeIds(nextVisibleNodeIds);
|
||||
setVisibleEdgeIds(nextVisibleEdgeIds);
|
||||
setNodeDepthMap(nextNodeDepthMap);
|
||||
setExpandedNodeIds((prev) => new Set(prev).add(nodeId));
|
||||
setExploreHistory((prev) => [
|
||||
...prev,
|
||||
{ nodeId, depth: nextDepth, addedNodeIds, addedEdgeIds, timestamp: Date.now() },
|
||||
]);
|
||||
},
|
||||
[filteredEdges, nodeById, nodeDepthMap, visibleEdgeIds, visibleNodeIds],
|
||||
);
|
||||
|
||||
const undoExploreStep = useCallback(() => {
|
||||
if (exploreHistory.length <= 1) {
|
||||
resetExplore();
|
||||
return;
|
||||
}
|
||||
const nextHistory = exploreHistory.slice(0, -1);
|
||||
setExploreHistory(nextHistory);
|
||||
rebuildExploreState(nextHistory);
|
||||
}, [exploreHistory, rebuildExploreState, resetExplore]);
|
||||
|
||||
useEffect(() => {
|
||||
void regenerate();
|
||||
}, [regenerate]);
|
||||
if (graphMode === "explore") resetExplore();
|
||||
}, [
|
||||
disabledNodeKinds,
|
||||
disabledEdgeLabels,
|
||||
hiddenNodeIds,
|
||||
searchKeyword,
|
||||
resetExplore,
|
||||
]);
|
||||
|
||||
const displayedGraph = useMemo(() => {
|
||||
if (graphMode === "full") return { nodes: filteredNodes, edges: filteredEdges };
|
||||
if (!exploreRootId) return { nodes: filteredNodes, edges: [] };
|
||||
return {
|
||||
nodes: allNodes.filter((node) => visibleNodeIds.has(node.id)),
|
||||
edges: allEdges.filter((edge) => visibleEdgeIds.has(getEdgeId(edge))),
|
||||
};
|
||||
}, [
|
||||
allEdges,
|
||||
allNodes,
|
||||
exploreRootId,
|
||||
filteredEdges,
|
||||
filteredNodes,
|
||||
graphMode,
|
||||
visibleEdgeIds,
|
||||
visibleNodeIds,
|
||||
]);
|
||||
|
||||
const searchMatchIds = useMemo(() => {
|
||||
const keyword = searchKeyword.trim().toLowerCase();
|
||||
if (!keyword) return null;
|
||||
const matches = new Set<string>();
|
||||
for (const n of filteredNodes) {
|
||||
if (getNodeTitle(n).toLowerCase().includes(keyword)) matches.add(n.id);
|
||||
}
|
||||
return matches;
|
||||
}, [searchKeyword, filteredNodes]);
|
||||
|
||||
const topNodes = useMemo(
|
||||
() =>
|
||||
[...allNodes]
|
||||
.map((node) => ({ node, degree: degrees.get(node.id) ?? 0 }))
|
||||
.sort((a, b) => b.degree - a.degree)
|
||||
.slice(0, 8),
|
||||
[allNodes, degrees],
|
||||
);
|
||||
|
||||
const selectedNode = selectedNodeId ? (nodeById.get(selectedNodeId) ?? null) : null;
|
||||
const selectedNeighbors = useMemo(() => {
|
||||
if (!selectedNodeId) return [];
|
||||
const items: Array<{ node: (typeof allNodes)[number]; relation: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
for (const e of displayedGraph.edges) {
|
||||
const otherId =
|
||||
e.source === selectedNodeId
|
||||
? e.target
|
||||
: e.target === selectedNodeId
|
||||
? e.source
|
||||
: null;
|
||||
if (!otherId || seen.has(otherId)) continue;
|
||||
const other = nodeById.get(otherId);
|
||||
if (!other) continue;
|
||||
seen.add(otherId);
|
||||
items.push({ node: other, relation: getEdgeLabel(e) });
|
||||
}
|
||||
return items.sort(
|
||||
(a, b) => (degrees.get(b.node.id) ?? 0) - (degrees.get(a.node.id) ?? 0),
|
||||
);
|
||||
}, [selectedNodeId, displayedGraph.edges, nodeById, degrees, allNodes]);
|
||||
|
||||
// Note detail for note-type nodes (summary / tags / timestamps / status).
|
||||
const noteId =
|
||||
selectedNode && (selectedNode.type || "").toLowerCase() === "note"
|
||||
? selectedNode.id
|
||||
: undefined;
|
||||
const { data: note, isLoading: noteLoading } = useKnowledgeNote(noteId);
|
||||
|
||||
// --------------------------------------------------------------- handlers
|
||||
const handleSelectNode = useCallback(
|
||||
(id: string | null) => {
|
||||
if (!id) {
|
||||
setSelectedNodeId(null);
|
||||
setShowDetail(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedNodeId(id);
|
||||
setShowDetail(true);
|
||||
|
||||
if (graphMode !== "explore") return;
|
||||
if (!exploreRootId) {
|
||||
startExploreFromNode(id);
|
||||
return;
|
||||
}
|
||||
if (!visibleNodeIds.has(id)) return;
|
||||
if (!expandedNodeIds.has(id)) expandOneHop(id);
|
||||
},
|
||||
[
|
||||
expandedNodeIds,
|
||||
expandOneHop,
|
||||
exploreRootId,
|
||||
graphMode,
|
||||
startExploreFromNode,
|
||||
visibleNodeIds,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePickNode = useCallback(
|
||||
(id: string) => {
|
||||
setSelectedNodeId(id);
|
||||
setShowDetail(true);
|
||||
if (graphMode === "explore" && !exploreRootId) startExploreFromNode(id);
|
||||
canvasRef.current?.focusNode(id);
|
||||
},
|
||||
[exploreRootId, graphMode, startExploreFromNode],
|
||||
);
|
||||
|
||||
const handleSearchEnter = useCallback(() => {
|
||||
if (!searchMatchIds || searchMatchIds.size === 0) return;
|
||||
const first = searchMatchIds.values().next().value;
|
||||
if (first) handlePickNode(first);
|
||||
}, [searchMatchIds, handlePickNode]);
|
||||
|
||||
const handleToggleNodeKind = useCallback((kind: NodeKind) => {
|
||||
setDisabledNodeKinds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(kind)) next.delete(kind);
|
||||
else next.add(kind);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleEdgeLabel = useCallback((label: string) => {
|
||||
setDisabledEdgeLabels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(label)) next.delete(label);
|
||||
else next.add(label);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleHideNode = useCallback(
|
||||
(id: string) => {
|
||||
setHiddenNodeIds((prev) => new Set(prev).add(id));
|
||||
setSelectedNodeId((current) => (current === id ? null : current));
|
||||
if (selectedNodeId === id) setShowDetail(false);
|
||||
},
|
||||
[selectedNodeId],
|
||||
);
|
||||
|
||||
const handleRestoreHidden = useCallback(() => {
|
||||
setHiddenNodeIds(new Set());
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refetch().then((result) => {
|
||||
if (result.isError) {
|
||||
toast.error(
|
||||
result.error instanceof Error ? result.error.message : "刷新图谱失败",
|
||||
);
|
||||
}
|
||||
});
|
||||
}, [refetch]);
|
||||
|
||||
const handleFullscreen = useCallback(() => {
|
||||
const el = pageRef.current;
|
||||
if (!el) return;
|
||||
if (document.fullscreenElement) void document.exitFullscreen();
|
||||
else void el.requestFullscreen();
|
||||
}, []);
|
||||
|
||||
const handleViewSource = useCallback(
|
||||
(id: string) => {
|
||||
navigate(`/page/workspace/knowledge/${encodeURIComponent(id)}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handleSetExploreRoot = useCallback(
|
||||
(id: string) => {
|
||||
setGraphMode("explore");
|
||||
startExploreFromNode(id);
|
||||
setSelectedNodeId(id);
|
||||
setShowDetail(true);
|
||||
},
|
||||
[startExploreFromNode],
|
||||
);
|
||||
|
||||
const exploreDepth = useMemo(() => {
|
||||
let max = 0;
|
||||
for (const depth of nodeDepthMap.values()) max = Math.max(max, depth);
|
||||
return max;
|
||||
}, [nodeDepthMap]);
|
||||
|
||||
const exploreRootTitle = exploreRootId
|
||||
? getNodeTitle(nodeById.get(exploreRootId) ?? { id: exploreRootId })
|
||||
: null;
|
||||
|
||||
const selectedExploreInfo =
|
||||
graphMode === "explore" && selectedNodeId
|
||||
? {
|
||||
depth: nodeDepthMap.get(selectedNodeId) ?? null,
|
||||
expanded: expandedNodeIds.has(selectedNodeId),
|
||||
isRoot: selectedNodeId === exploreRootId,
|
||||
}
|
||||
: null;
|
||||
|
||||
// ---------------------------------------------------------------- overlay
|
||||
let overlay: React.ReactNode = null;
|
||||
if (isLoading) {
|
||||
overlay = (
|
||||
<div className="kg-canvas-status">
|
||||
<Loader2 size={26} className="animate-spin" />
|
||||
<div className="kg-canvas-status-title">正在加载知识图谱...</div>
|
||||
</div>
|
||||
);
|
||||
} else if (isError) {
|
||||
overlay = (
|
||||
<div className="kg-canvas-status">
|
||||
<Network size={30} />
|
||||
<div className="kg-canvas-status-title">图谱加载失败</div>
|
||||
<div className="kg-canvas-status-desc">
|
||||
{error instanceof Error ? error.message : "请求失败,请稍后重试。"}
|
||||
</div>
|
||||
<button type="button" className="kg-icon-button" onClick={handleRefresh}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else if (allNodes.length === 0) {
|
||||
overlay = (
|
||||
<div className="kg-canvas-status">
|
||||
<Network size={30} />
|
||||
<div className="kg-canvas-status-title">暂无知识图谱数据</div>
|
||||
<div className="kg-canvas-status-desc">
|
||||
请先导入知识文档,或从知识库中抽取实体、标签与关系后再查看图谱。
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (filteredNodes.length === 0) {
|
||||
overlay = (
|
||||
<div className="kg-canvas-status">
|
||||
<Network size={30} />
|
||||
<div className="kg-canvas-status-title">当前筛选没有可显示的节点</div>
|
||||
<div className="kg-canvas-status-desc">
|
||||
请在左侧勾选更多节点类型,或恢复已隐藏的节点。
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toolbar = (
|
||||
<>
|
||||
<div className="kg-floating-pill">
|
||||
<span className="kg-pill-label">模式</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`kg-pill-button${graphMode === "explore" ? " kg-pill-button-active" : ""}`}
|
||||
onClick={() => setGraphMode("explore")}
|
||||
>
|
||||
探索模式
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`kg-pill-button${graphMode === "full" ? " kg-pill-button-active" : ""}`}
|
||||
onClick={() => setGraphMode("full")}
|
||||
>
|
||||
全量图谱
|
||||
</button>
|
||||
</div>
|
||||
{graphMode === "explore" ? (
|
||||
<>
|
||||
<div className="kg-floating-pill">
|
||||
起点:{exploreRootTitle ? exploreRootTitle : "未选择"}
|
||||
</div>
|
||||
<div className="kg-floating-pill">
|
||||
深度 {exploreDepth} · 已显示 {displayedGraph.nodes.length} 节点 /{" "}
|
||||
{displayedGraph.edges.length} 关系
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-explore-button"
|
||||
disabled={exploreHistory.length === 0}
|
||||
onClick={undoExploreStep}
|
||||
>
|
||||
<Undo2 size={13} /> 返回上一层
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-explore-button"
|
||||
disabled={!exploreRootId}
|
||||
onClick={resetExplore}
|
||||
>
|
||||
<RotateCcw size={13} /> 重置探索
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<div className="kg-floating-pill">
|
||||
<span className="kg-pill-label">布局</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`kg-pill-button${layoutType === "force" ? " kg-pill-button-active" : ""}`}
|
||||
onClick={() => setLayoutType("force")}
|
||||
>
|
||||
力导向
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`kg-pill-button${layoutType === "circular" ? " kg-pill-button-active" : ""}`}
|
||||
onClick={() => setLayoutType("circular")}
|
||||
>
|
||||
环形
|
||||
</button>
|
||||
</div>
|
||||
<div className="kg-floating-pill">
|
||||
<button
|
||||
type="button"
|
||||
className={`kg-pill-button${showLabels ? " kg-pill-button-active" : ""}`}
|
||||
onClick={() => setShowLabels((v) => !v)}
|
||||
>
|
||||
<Tags size={13} /> 标签 {showLabels ? "开" : "关"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const hint =
|
||||
graphMode === "explore" ? (
|
||||
<div className="kg-canvas-hint">
|
||||
{!exploreRootId
|
||||
? "点击任意节点作为起点,开始逐层探索知识关系。"
|
||||
: "点击已显示节点,可继续展开它的一跳邻居。已展开的节点和路径会保留。"}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0 flex-col">
|
||||
<header className="flex items-center gap-2 border-b p-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate("/page/workspace/knowledge")}
|
||||
>
|
||||
<ArrowLeft className="size-4" /> 返回
|
||||
</Button>
|
||||
<h1 className="text-base font-semibold">知识图谱</h1>
|
||||
{stats ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{stats.nodes} 个节点 · {stats.edges} 条关系
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => void regenerate()}
|
||||
disabled={exportGraph.isPending}
|
||||
>
|
||||
{exportGraph.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-4" />
|
||||
)}
|
||||
重新生成
|
||||
</Button>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1">
|
||||
{src ? (
|
||||
<iframe
|
||||
src={src}
|
||||
title="知识图谱"
|
||||
className="size-full border-0"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
<div className="kg-page" ref={pageRef}>
|
||||
<GraphHeader
|
||||
nodeCount={displayedGraph.nodes.length}
|
||||
edgeCount={displayedGraph.edges.length}
|
||||
totalNodes={allNodes.length}
|
||||
totalEdges={allEdges.length}
|
||||
refreshing={isFetching}
|
||||
onBack={() => navigate("/page/workspace/knowledge")}
|
||||
onRelayout={() => canvasRef.current?.relayout()}
|
||||
onFitView={() => canvasRef.current?.fitView()}
|
||||
onZoomIn={() => canvasRef.current?.zoomIn()}
|
||||
onZoomOut={() => canvasRef.current?.zoomOut()}
|
||||
onExport={() => canvasRef.current?.exportImage()}
|
||||
onFullscreen={handleFullscreen}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={[
|
||||
"kg-body",
|
||||
showSidebar ? "" : "kg-hide-left",
|
||||
showDetail ? "" : "kg-hide-right",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{showSidebar ? (
|
||||
<GraphSidebar
|
||||
searchKeyword={searchKeyword}
|
||||
searchMatchCount={searchMatchIds ? searchMatchIds.size : null}
|
||||
nodeKindCounts={nodeKindCounts}
|
||||
edgeLabelCounts={edgeLabelCounts}
|
||||
disabledNodeKinds={disabledNodeKinds}
|
||||
disabledEdgeLabels={disabledEdgeLabels}
|
||||
stats={stats}
|
||||
topNodes={topNodes}
|
||||
hiddenCount={hiddenNodeIds.size}
|
||||
onSearchChange={setSearchKeyword}
|
||||
onSearchEnter={handleSearchEnter}
|
||||
onToggleNodeKind={handleToggleNodeKind}
|
||||
onToggleEdgeLabel={handleToggleEdgeLabel}
|
||||
onRestoreHidden={handleRestoreHidden}
|
||||
onPickNode={handlePickNode}
|
||||
onClose={() => setShowSidebar(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
|
||||
<Loader2 className="mr-2 size-4 animate-spin" /> 生成图谱中...
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<div className="kg-canvas-shell">
|
||||
{!showSidebar ? (
|
||||
<button
|
||||
type="button"
|
||||
className="kg-side-rail-toggle kg-side-rail-toggle-left"
|
||||
onClick={() => setShowSidebar(true)}
|
||||
>
|
||||
筛选
|
||||
</button>
|
||||
) : null}
|
||||
<GraphCanvas
|
||||
ref={canvasRef}
|
||||
nodes={displayedGraph.nodes}
|
||||
edges={displayedGraph.edges}
|
||||
degrees={degrees}
|
||||
selectedNodeId={selectedNodeId}
|
||||
searchMatchIds={searchMatchIds}
|
||||
layoutType={layoutType}
|
||||
showLabels={showLabels}
|
||||
onSelectNode={handleSelectNode}
|
||||
onTooltip={setTooltip}
|
||||
onZoomChange={setZoomLevel}
|
||||
explore={
|
||||
graphMode === "explore"
|
||||
? {
|
||||
preview: !exploreRootId,
|
||||
rootId: exploreRootId,
|
||||
nodeDepthMap,
|
||||
expandedNodeIds,
|
||||
}
|
||||
: null
|
||||
}
|
||||
toolbar={toolbar}
|
||||
hint={hint}
|
||||
overlay={overlay}
|
||||
/>
|
||||
{!showDetail ? (
|
||||
<button
|
||||
type="button"
|
||||
className="kg-side-rail-toggle kg-side-rail-toggle-right"
|
||||
onClick={() => setShowDetail(true)}
|
||||
>
|
||||
详情
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showDetail ? (
|
||||
<GraphDetailPanel
|
||||
node={selectedNode}
|
||||
degree={selectedNodeId ? (degrees.get(selectedNodeId) ?? 0) : 0}
|
||||
neighbors={selectedNeighbors}
|
||||
note={noteId ? note : undefined}
|
||||
noteLoading={Boolean(noteId) && noteLoading}
|
||||
explore={selectedExploreInfo}
|
||||
onViewSource={handleViewSource}
|
||||
onLocate={(id) => canvasRef.current?.focusNode(id)}
|
||||
onHide={handleHideNode}
|
||||
onPickNeighbor={handlePickNode}
|
||||
onExpandNode={expandOneHop}
|
||||
onSetExploreRoot={handleSetExploreRoot}
|
||||
onClose={() => setShowDetail(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<GraphFooter
|
||||
nodeKindCounts={nodeKindCounts}
|
||||
edgeLabels={[...edgeLabelCounts.keys()]}
|
||||
zoomLevel={zoomLevel}
|
||||
selectedTitle={selectedNode ? getNodeTitle(selectedNode) : null}
|
||||
visibleNodes={displayedGraph.nodes.length}
|
||||
visibleEdges={displayedGraph.edges.length}
|
||||
/>
|
||||
|
||||
<GraphTooltip tooltip={tooltip} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -122,7 +122,7 @@ export default function LightAppNoteListPage() {
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="text-base font-medium">新建笔记本</span>
|
||||
<span className="text-base font-medium">新建空间</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -146,7 +146,7 @@ export default function LightAppNoteListPage() {
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="text-base font-medium">新建笔记本</span>
|
||||
<span className="text-base font-medium">新建空间</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -230,7 +230,7 @@ export default function LightAppNoteListPage() {
|
||||
isDark ? "text-slate-100" : "text-slate-950",
|
||||
)}
|
||||
>
|
||||
我的笔记本
|
||||
我的空间
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||
import { getNotebookApiBaseURL } from '@/core/config';
|
||||
|
||||
interface NotebookTokenData {
|
||||
doc_ids: number[];
|
||||
search_space_id: number;
|
||||
user_id: string;
|
||||
user_token: string;
|
||||
}
|
||||
|
||||
export default function NotebookAIWritingPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const [data, setData] = useState<NotebookTokenData | null>(null);
|
||||
const [spaceName, setSpaceName] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError('缺少 token 参数');
|
||||
return;
|
||||
}
|
||||
const base = getNotebookApiBaseURL();
|
||||
fetch(`${base}/api/v1/ai-writing/notebook-token/${token}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`请求失败 ${res.status}`);
|
||||
return res.json() as Promise<NotebookTokenData>;
|
||||
})
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
return fetch(`${base}/api/v1/searchspaces/${d.search_space_id}`, {
|
||||
headers: { Authorization: `Bearer ${d.user_token}` },
|
||||
});
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<{ name: string }>;
|
||||
})
|
||||
.then((space) => {
|
||||
if (space?.name) setSpaceName(space.name);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message));
|
||||
}, [token]);
|
||||
|
||||
// Keep-alive:每 50 秒 ping 一次 token 接口,防止 Redis TTL 过期
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
const base = getNotebookApiBaseURL();
|
||||
const id = setInterval(() => {
|
||||
fetch(`${base}/api/v1/ai-writing/notebook-token/${token}`).catch(() => {});
|
||||
}, 50_000);
|
||||
return () => clearInterval(id);
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
{/* 索财笔记 Header —— 只承载空间信息(品牌 / 右侧标签已移除) */}
|
||||
<div className="relative flex items-center gap-3 px-5 h-[52px] shrink-0 bg-gradient-to-r from-[#1e3a5f] to-[#1a3255] border-b border-[#2a4a70]">
|
||||
{/* 顶部极细品牌高光线 */}
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#7fb3e8]/55 to-transparent" />
|
||||
|
||||
{/* 空间信息:精致玻璃胶囊 */}
|
||||
{data?.search_space_id && (
|
||||
<div
|
||||
className="inline-flex h-7 items-center gap-2 rounded-full border border-[#7fb3e8]/30 bg-gradient-to-b from-[#2d5080]/55 to-[#1a3a60]/55 pl-2 pr-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.06),0_1px_2px_rgba(0,0,0,0.18)] backdrop-blur-sm transition-all hover:border-[#7fb3e8]/55 hover:-translate-y-px hover:shadow-[inset_0_1px_0_rgba(255,255,255,0.08),0_2px_10px_-2px_rgba(59,130,246,0.45)]"
|
||||
title={`当前空间:${spaceName ?? data.search_space_id}`}
|
||||
>
|
||||
{/* 图标徽标 */}
|
||||
<span className="flex size-[18px] items-center justify-center rounded-[5px] bg-gradient-to-br from-[#3b82f6] to-[#1d4ed8] shadow-[0_0_0_1px_rgba(127,179,232,0.25),0_2px_6px_-1px_rgba(29,78,216,0.6)]">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-[11px] text-[#e0f0ff]"
|
||||
>
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2" />
|
||||
<polyline points="2 17 12 22 22 17" />
|
||||
<polyline points="2 12 12 17 22 12" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-[11px] font-medium tracking-wide text-[#7fb3e8]">空间</span>
|
||||
<span className="h-3 w-px bg-[#7fb3e8]/25" />
|
||||
<span className="max-w-[200px] truncate text-[12.5px] font-semibold text-[#d6e8fb]">
|
||||
{spaceName ?? data.search_space_id}
|
||||
</span>
|
||||
{/* 已连接状态点 */}
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-[#4ade80] shadow-[0_0_0_2px_rgba(74,222,128,0.18)]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 弹性占位 */}
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
{/* 主体 */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{error ? (
|
||||
<div className="flex items-center justify-center h-full text-red-400 text-sm">{error}</div>
|
||||
) : !data ? (
|
||||
<div className="flex items-center justify-center h-full text-[#7fb3e8] text-sm">加载中…</div>
|
||||
) : (
|
||||
<AIWritingPage
|
||||
hideAdvancedFields
|
||||
initialNotebookId={data.search_space_id}
|
||||
initialDocumentIds={data.doc_ids}
|
||||
studioToken={data.user_token}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import NotebookAIWritingPage from './NotebookAIWritingPage';
|
||||
|
||||
export default function NotebookRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="ai-writing" element={<NotebookAIWritingPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import StrategyRoutes from "./StrategyRoutes";
|
||||
import Header from "@/strategy-components/Header";
|
||||
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
|
||||
import RoundtableRoutes from "./RoundtableRoutes";
|
||||
import NotebookRoutes from "./NotebookRoutes";
|
||||
|
||||
function PageLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
@@ -68,6 +69,7 @@ function PageLayout() {
|
||||
<Route path="strategy/*" element={<StrategyRoutes />} />
|
||||
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
|
||||
<Route path="roundtable/*" element={<RoundtableRoutes />} />
|
||||
<Route path="notebook/*" element={<NotebookRoutes />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -34,12 +34,10 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type {
|
||||
AdminLeaderboardDimension,
|
||||
AdminLeaderboardOverview,
|
||||
AdminLeaderboardQuestion,
|
||||
AdminLeaderboardResponse,
|
||||
AdminLeaderboardSkill,
|
||||
AdminLeaderboardTrendPoint,
|
||||
@@ -52,8 +50,10 @@ type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency"
|
||||
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
|
||||
|
||||
const RANK_COLORS = ["#C1121F", "#E11D48", "#F59E0B", "#991B1B", "#B45309"] as const;
|
||||
const LEADERBOARD_PAGE_SIZE = 20;
|
||||
const RANGE_DAYS = 30;
|
||||
const LEADERBOARD_PAGE_SIZE = 10;
|
||||
// 公开页只统计近 7 天:跨日快照在每次请求时合并,天数越多合并越慢(尤其常问问题的
|
||||
// 模糊聚合是 O(n^2))。7 天足够展示近期热度,同时把请求耗时压到最低。
|
||||
const RANGE_DAYS = 7;
|
||||
|
||||
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
|
||||
crimson: {
|
||||
@@ -98,11 +98,6 @@ function fmtDate(value?: string | null): string {
|
||||
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function clampText(value: string, max = 120): string {
|
||||
const text = value.trim();
|
||||
return text.length > max ? `${text.slice(0, max)}...` : text;
|
||||
}
|
||||
|
||||
function userScore(user: AdminLeaderboardUser, tab: UserRankTab): number {
|
||||
if (tab === "questions") return user.qa_count;
|
||||
if (tab === "tokens") return user.total_tokens;
|
||||
@@ -367,12 +362,6 @@ export default function PublicLeaderboardPage() {
|
||||
<DimensionRanking title="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" />
|
||||
<DimensionRanking title="运行状态分布" icon={Gauge} items={data.run_statuses} empty="当前时间段暂无运行状态" />
|
||||
</div>
|
||||
|
||||
{/* 问题 */}
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<QuestionPanel title="最近经常问的问题" items={data.frequent_questions} />
|
||||
<QuestionPanel title="最新问题" items={data.recent_questions} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -688,40 +677,6 @@ function DimensionRanking({
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionPanel({
|
||||
title,
|
||||
items,
|
||||
}: {
|
||||
title: string;
|
||||
items: AdminLeaderboardQuestion[];
|
||||
}) {
|
||||
return (
|
||||
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[#F1D6D6] px-4 py-3 dark:border-[#213D5E]">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" />
|
||||
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
|
||||
</div>
|
||||
<Badge variant="outline">{items.length}</Badge>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="divide-y divide-[#F1D6D6] dark:divide-[#213D5E]">
|
||||
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368] dark:text-slate-400">暂无问题记录</div>}
|
||||
{items.map((item, index) => (
|
||||
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
|
||||
<div className="text-sm leading-6 text-[#202124] dark:text-slate-100">{clampText(item.text, 180)}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400">
|
||||
<span>{item.email || item.user_id || "未知用户"}</span>
|
||||
<span>{fmtTime(item.asked_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
|
||||
type TrendMetric = keyof Pick<AdminLeaderboardTrendPoint, "runs" | "questions" | "users" | "tokens">;
|
||||
const [metric, setMetric] = useState<"all" | TrendMetric>("all");
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon, PlusIcon, StarIcon, Trash2Icon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
usePublishSquares,
|
||||
useUpdatePublishSquares,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import type { PublishSquare } from "@/core/system-settings/api";
|
||||
|
||||
/** Local draft row — `id` is stable (server- or client-generated) so the
|
||||
* "default" radio can reference it before the first save. */
|
||||
type Draft = PublishSquare;
|
||||
|
||||
function genId(): string {
|
||||
// Frontend-only id; the backend also accepts/normalizes these.
|
||||
return `sq_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export default function PublishSquaresSettingsPage({
|
||||
embedded = false,
|
||||
}: {
|
||||
embedded?: boolean;
|
||||
} = {}) {
|
||||
const { data, isLoading, error } = usePublishSquares();
|
||||
const update = useUpdatePublishSquares();
|
||||
|
||||
const [squares, setSquares] = useState<Draft[]>([]);
|
||||
const [defaultId, setDefaultId] = useState<string>("");
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || initializedRef.current) return;
|
||||
setSquares(data.squares.map((s) => ({ ...s })));
|
||||
setDefaultId(data.default_square_id);
|
||||
initializedRef.current = true;
|
||||
}, [data]);
|
||||
|
||||
function addSquare() {
|
||||
setSquares((prev) => [...prev, { id: genId(), name: "", description: "" }]);
|
||||
}
|
||||
|
||||
function patchSquare(id: string, patch: Partial<Draft>) {
|
||||
setSquares((prev) =>
|
||||
prev.map((s) => (s.id === id ? { ...s, ...patch } : s)),
|
||||
);
|
||||
}
|
||||
|
||||
function removeSquare(id: string) {
|
||||
setSquares((prev) => prev.filter((s) => s.id !== id));
|
||||
if (defaultId === id) setDefaultId("");
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const cleaned = squares
|
||||
.map((s) => ({ ...s, name: s.name.trim(), description: s.description.trim() }))
|
||||
.filter((s) => s.name.length > 0);
|
||||
if (cleaned.length === 0) {
|
||||
toast.error("请至少配置一个广场(名称不能为空)");
|
||||
return;
|
||||
}
|
||||
// Re-anchor the default to a surviving square if the picked one was removed.
|
||||
const nextDefault = cleaned.some((s) => s.id === defaultId)
|
||||
? defaultId
|
||||
: cleaned[0].id;
|
||||
try {
|
||||
const saved = await update.mutateAsync({
|
||||
squares: cleaned,
|
||||
default_square_id: nextDefault,
|
||||
});
|
||||
setSquares(saved.squares.map((s) => ({ ...s })));
|
||||
setDefaultId(saved.default_square_id);
|
||||
toast.success("已保存");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
embedded
|
||||
? "flex w-full flex-col gap-6"
|
||||
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<h1
|
||||
className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}
|
||||
>
|
||||
发布广场配置
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
管理员可在此配置多个「发布广场」。发布智能体或技能时,未选择广场则归入默认广场,也可选择发布到其他广场;
|
||||
每个智能体或技能只能归属于一个广场,不会重复出现在多个广场中。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
|
||||
加载失败:{error.message}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div className="flex flex-col gap-3">
|
||||
{squares.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
暂无广场,点击下方「添加广场」创建第一个。
|
||||
</p>
|
||||
) : (
|
||||
squares.map((s) => {
|
||||
const isDefault = defaultId === s.id;
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex flex-col gap-2 rounded-lg border p-3 dark:border-[#1B477E]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
title={isDefault ? "默认广场" : "设为默认广场"}
|
||||
className={
|
||||
isDefault ? "text-amber-500 hover:text-amber-600" : ""
|
||||
}
|
||||
onClick={() => setDefaultId(s.id)}
|
||||
>
|
||||
<StarIcon
|
||||
className={isDefault ? "size-4 fill-current" : "size-4"}
|
||||
/>
|
||||
</Button>
|
||||
<Input
|
||||
value={s.name}
|
||||
placeholder="广场名称(如:公开广场 / 合作伙伴 / 内测)"
|
||||
onChange={(e) => patchSquare(s.id, { name: e.target.value })}
|
||||
className="h-9 flex-1 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive"
|
||||
title="删除广场"
|
||||
onClick={() => removeSquare(s.id)}
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={s.description}
|
||||
placeholder="广场描述(选填)"
|
||||
onChange={(e) =>
|
||||
patchSquare(s.id, { description: e.target.value })
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
{isDefault && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
默认广场:发布时未选择广场的智能体/技能会归入此处。
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button type="button" variant="outline" size="sm" onClick={addSquare}>
|
||||
<PlusIcon className="mr-1.5 size-4" />
|
||||
添加广场
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={update.isPending}>
|
||||
{update.isPending && (
|
||||
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -180,18 +180,18 @@ export default function StudioNotebooksPage() {
|
||||
<div className="min-h-full bg-background p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-foreground">笔记本</h1>
|
||||
<h1 className="text-2xl font-semibold text-foreground">空间</h1>
|
||||
<button
|
||||
onClick={() => { setCreateOpen(true); setCreateError(""); setCreateName(""); }}
|
||||
className="flex items-center gap-1.5 rounded-full bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
新建笔记本
|
||||
新建空间
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<p className="text-sm text-muted-foreground">加载笔记本失败,请稍后重试。</p>
|
||||
<p className="text-sm text-muted-foreground">加载空间失败,请稍后重试。</p>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
@@ -199,20 +199,20 @@ export default function StudioNotebooksPage() {
|
||||
{isLoading
|
||||
? Array.from({ length: 8 }).map((_, i) => <NotebookCardSkeleton key={i} />)
|
||||
: notebooks.map((nb) => (
|
||||
<NotebookCard
|
||||
key={nb.id}
|
||||
nb={nb}
|
||||
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
|
||||
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
|
||||
/>
|
||||
))}
|
||||
<NotebookCard
|
||||
key={nb.id}
|
||||
nb={nb}
|
||||
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
|
||||
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── 新建 Dialog ── */}
|
||||
<Dialog open={createOpen} onOpenChange={(v) => !creating && setCreateOpen(v)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建笔记本</DialogTitle>
|
||||
<DialogTitle>新建空间</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<input
|
||||
@@ -220,7 +220,7 @@ export default function StudioNotebooksPage() {
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }}
|
||||
placeholder="笔记本名称"
|
||||
placeholder="空间名称"
|
||||
disabled={creating}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
@@ -239,7 +239,7 @@ export default function StudioNotebooksPage() {
|
||||
<Dialog open={!!renameTarget} onOpenChange={(v) => !renaming && !v && setRenameTarget(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>重命名笔记本</DialogTitle>
|
||||
<DialogTitle>重命名空间</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -272,12 +272,12 @@ export default function StudioNotebooksPage() {
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(v) => !deleting && !v && setDeleteTarget(null)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除笔记本</DialogTitle>
|
||||
<DialogTitle>删除空间</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
确定要删除{deleteTarget ? <span className="font-medium text-foreground">「{deleteTarget.name}」</span> : ""}吗?
|
||||
删除后笔记本内所有来源数据将一并移除,且无法恢复。
|
||||
删除后空间内所有来源数据将一并移除,且无法恢复。
|
||||
</p>
|
||||
{deleteError && <p className="text-xs text-destructive">{deleteError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
@@ -31,6 +31,7 @@ import CheckpointMigrationPage from './CheckpointMigrationPage';
|
||||
import MemoryPage from './MemoryPage';
|
||||
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
|
||||
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
|
||||
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
|
||||
import SkillEvolutionPage from './SkillEvolutionPage';
|
||||
import SkillsPage from './SkillsPage';
|
||||
import UsersPage from './UsersPage';
|
||||
@@ -496,6 +497,18 @@ export default function WorkspaceRoutes({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/publish-squares"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PublishSquaresSettingsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
|
||||
@@ -163,7 +163,7 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
|
||||
6. **GuardrailMiddleware** - Pre-tool-call authorization via pluggable `GuardrailProvider` protocol (optional, if `guardrails.enabled` in config). Evaluates each tool call and returns error ToolMessage on deny. Three provider options: built-in `AllowlistProvider` (zero deps), OAP policy providers (e.g. `aport-agent-guardrails`), or custom providers. See [docs/GUARDRAILS.md](docs/GUARDRAILS.md) for setup, usage, and how to implement a provider.
|
||||
7. **SandboxAuditMiddleware** - Audits sandboxed shell/file operations for security logging before tool execution continues
|
||||
8. **ToolErrorHandlingMiddleware** - Converts tool exceptions into error `ToolMessage`s so the run can continue instead of aborting
|
||||
9. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
|
||||
9. **SummarizationMiddleware** (`DeerFlowSummarizationMiddleware`) - Context reduction when approaching token limits (optional, if enabled). **Non-destructive**: overrides the stock `before_model` (which persists a `RemoveMessage(REMOVE_ALL_MESSAGES)`) to a no-op and instead compresses **transiently** in `wrap_model_call` — the model request is replaced with `[summary, *recent]` while the full original conversation stays in state (so the frontend keeps rendering the user's real Q&A). The `summary` message is `hide_from_ui` and never persisted; summaries are cached per-thread (bounded, sticky boundary) to avoid an LLM call on every model invocation. Tests: `tests/test_summarization_middleware.py`
|
||||
10. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
||||
11. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
|
||||
12. **TitleMiddleware** - Two-phase auto-title (never shows "untitled"): `before_model` sets an instant question-derived *provisional* title the moment the first user message arrives (no LLM, zero latency), then `after_model` asynchronously generates the final LLM title and replaces it (tracked via the `title_provisional` state flag). Normalizes structured message content before prompting the title model
|
||||
@@ -175,7 +175,7 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
|
||||
18. **ClarificationMiddleware** - Intercepts `ask_clarification` tool calls, interrupts via `Command(goto=END)` (must be last)
|
||||
8a. **ToolMetricsMiddleware** - Appended immediately after `ToolErrorHandlingMiddleware` in `_build_runtime_middlewares`, so it sits *deepest* in the `wrap_tool_call` chain. Records one `tool_call_metrics` row per tool/skill/MCP invocation — timing duration and bucketing failures into a coarse `error_category` (rate_limited / ip_blocked / auth_error / timeout / network_error / not_found / empty_result / tool_error). It catches both raised exceptions (before #8 converts them) and *self-handled* errors returned as error `ToolMessage`s (search tools that swallow HTTP 429/403). It also captures the targeted Agent Skill into `tool_call_metrics.skill_name`: the lead agent invokes a skill by `read_file`-ing its `/mnt/skills/<category>/<name>/SKILL.md` path (per the `<skill_system>` prompt block — there is no dedicated "use skill" tool), so `skill_name` is recovered by scanning every tool call's string arguments for the `skills/{public,custom}/{name}/` path shape (`_SKILL_PATH_RE`), plus the explicit `name` argument of `skill_view`/`skill_manage`/`skill_archive`. `skill_view`-specific success detection handles its swallowed failures (`Skill 'x' not found.` returned as a plain string rather than raised). Regression coverage: `tests/test_tool_metrics_middleware.py`. Writes are best-effort fire-and-forget. **Per-run skill merging**: `SqlToolMetricsStore.record` upserts skill rows by `(run_id, skill_name)` instead of inserting one row per call — a skill touched by several tool calls / retries inside one run collapses to a single row. Any success in the run wins (a skill that failed then finally succeeded is recorded as one `success`); a skill that only ever failed yields one `error` row whose `error_message` accumulates every attempt's message; durations are summed. Non-skill tool calls, and skill calls with no `run_id`, keep one row each. The read→merge→write is guarded by a per-event-loop `asyncio.Lock`. Surfaced by the admin `GET /api/admin/tool-metrics` router (which has a `kind=skill` filter and a per-skill `/summary` breakdown).
|
||||
9. **PromptPrefixMiddleware** - Injects the admin-configured prompt prefix into the model request only. The prefix is tagged onto the first human message's `additional_kwargs['prompt_prefix']` by the Gateway (`app.gateway.services`) and prepended to content here at call time, so the persisted/displayed message stays prefix-free
|
||||
10. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
|
||||
10. **SummarizationMiddleware** (`DeerFlowSummarizationMiddleware`) - Context reduction when approaching token limits (optional, if enabled). **Non-destructive**: overrides the stock `before_model` (which persists a `RemoveMessage(REMOVE_ALL_MESSAGES)`) to a no-op and instead compresses **transiently** in `wrap_model_call` — the model request is replaced with `[summary, *recent]` while the full original conversation stays in state (so the frontend keeps rendering the user's real Q&A). The `summary` message is `hide_from_ui` and never persisted; summaries are cached per-thread (bounded, sticky boundary) to avoid an LLM call on every model invocation. Tests: `tests/test_summarization_middleware.py`
|
||||
11. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
||||
12. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
|
||||
13. **TitleMiddleware** - Two-phase auto-title (never shows "untitled"): `before_model` sets an instant question-derived *provisional* title the moment the first user message arrives (no LLM, zero latency), then `after_model` asynchronously generates the final LLM title and replaces it (tracked via the `title_provisional` state flag). Normalizes structured message content before prompting the title model
|
||||
@@ -250,13 +250,13 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
||||
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
|
||||
| **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. |
|
||||
| **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). |
|
||||
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` → `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "已改用规则抽取". The "保存到知识库" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector→keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[…]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). Backed by the `deerflow.knowledge` module (see below). |
|
||||
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` → `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "已改用规则抽取". The "保存到知识库" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector→keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[…]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). Backed by the `deerflow.knowledge` module (see below). |
|
||||
|
||||
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
|
||||
|
||||
### Admin Leaderboard Snapshots (`/api/admin/users/leaderboard*`)
|
||||
|
||||
The admin 用户排行榜 is served from **precomputed per-day snapshots** in `admin_leaderboard_daily_stats` (keyed by `(stat_date, settings_hash)`), not by aggregating raw rows on every request. A background scheduler inside the Gateway (`_leaderboard_snapshot_scheduler_loop` in `app/gateway/app.py`, singleton file-locked) ticks every `ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS` (default 300s): it prewarms recent days, then builds any `queued`/`error`/stale-`running` day. `GET /leaderboard` merges the per-day snapshots for the requested range. **Today** is rebuilt whenever its snapshot is older than `ADMIN_LEADERBOARD_TODAY_TTL_SECONDS` (default 300s); a past day, once `ready`, is treated as fresh forever.
|
||||
The admin 用户排行榜 is served from **precomputed per-day snapshots** in `admin_leaderboard_daily_stats` (keyed by `(stat_date, settings_hash)`), not by aggregating raw rows on every request. A background scheduler inside the Gateway (`_leaderboard_snapshot_scheduler_loop` in `app/gateway/app.py`, singleton file-locked) ticks every `ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS` (default 300s): it prewarms recent days, then builds any `queued`/`error`/stale-`running` day. `GET /leaderboard` merges the per-day snapshots for the requested range. **Today** is rebuilt whenever its snapshot is older than `ADMIN_LEADERBOARD_TODAY_TTL_SECONDS` (default 43200s = half a day, so the leaderboard refreshes ~twice daily); a past day, once `ready`, is treated as fresh forever.
|
||||
|
||||
Two on-demand/automatic refresh paths layer on top:
|
||||
- **Manual "刷新当天"** — `POST /leaderboard/refresh-today` force-rebuilds *today's* snapshot **synchronously** (bounded by the same semaphore/timeout the scheduler uses), then returns the merged range. Lets an admin pull the latest real-time records into the stats without waiting for the TTL. Frontend: a "刷新当天" button on `AdminUserLeaderboardPage` → `useRefreshTodayLeaderboard` (`core/admin/hooks.ts`), which primes + invalidates the `["admin-activity","leaderboard"]` query.
|
||||
@@ -290,7 +290,7 @@ Self-contained product knowledge base. **All knowledge capability lives in this
|
||||
- **DB mirror** — tables `knowledge_notes` / `knowledge_sources` / `knowledge_tags` / `knowledge_note_tags` (`knowledge/models.py`, registered in `persistence/models/__init__.py`, auto-created by `Base.metadata.create_all`; portable types `PortableLongText`/`BeijingDateTime`). Powers listing/filter/search/audit. Global/shared — no `user_id` column; `created_by`/`updated_by` are audit-only (resolved via `get_effective_user_id()`).
|
||||
- **Markdown vault** — obsidian-wiki compatible, default `{$DEER_FLOW_HOME}/data/knowledge/obsidian-vault`. `ObsidianWikiAdapter` (`obsidian_adapter.py`) writes wiki-capture frontmatter+body, and after **every** write syncs `index.md` / `log.md` (CAPTURE/INGEST/UPDATE/ARCHIVE/EXPORT) / `hot.md` / `.manifest.json` (source-hash dedup for `skip_existing`). `markdown_vault.py` handles frontmatter build/parse, slugging, wikilink extraction and **path-traversal-proof** resolution.
|
||||
|
||||
**Module layout:** `models.py` (ORM: notes/sources/tags/note_tags + embeddings/versions/entities/relations) · `repository.py` (DB only, no LLM) · `schemas.py` (NoteDraft/SourceDraft DTOs) · `skill_templates.py` · `markdown_vault.py` · `manifest.py` · `extractor.py` (rule-based fallback) · `llm_extractor.py` (**default**: distills declarative knowledge per wiki-capture, structured JSON incl. entities/relations; falls back to `extractor.py`) · `sources.py` · `exporter.py` (`graph.json`/`graph.graphml`/`cypher.txt` + self-contained vanilla-JS `graph.html`; entity nodes + relation edges) · `chunking.py` + `embeddings.py` (OpenAI-compatible embedding client, keyword fallback) + `search.py` (keyword/vector/hybrid) · `sensitive.py` (secret/PII redaction) · `dedup.py` (Jaccard + hash dedup) · `auto_ingest.py` (background queue/worker) · `obsidian_adapter.py` · `service.py` (`KnowledgeService` orchestrator — the only thing the router touches). Tests: `tests/test_knowledge.py` (20 cases). Frontend: `frontend-web/src/core/knowledge/*` + `pages/KnowledgeBasePage.tsx` / `KnowledgeGraphPage.tsx` + chat `knowledge-save-trigger.tsx` / `knowledge-rag-toggle.tsx`; spec at `frontend-web/docs/product-knowledge-base-dev.md`.
|
||||
**Module layout:** `models.py` (ORM: notes/sources/tags/note_tags + embeddings/versions/entities/relations) · `repository.py` (DB only, no LLM) · `schemas.py` (NoteDraft/SourceDraft DTOs) · `skill_templates.py` · `markdown_vault.py` · `manifest.py` · `extractor.py` (rule-based fallback) · `llm_extractor.py` (**default**: distills declarative knowledge per wiki-capture, structured JSON incl. entities/relations; falls back to `extractor.py`) · `sources.py` · `exporter.py` (`graph.json`/`graph.graphml`/`cypher.txt` + self-contained vanilla-JS `graph.html`; entity nodes + relation edges. **`graph.html` is theme-aware** — it reads `?theme=light|dark` from its own URL since the embedding iframe can't inherit the app's CSS; `KnowledgeGraphPage.tsx` appends the active day/night mode and re-skins on toggle. The layout cools down and **freezes** so dense graphs stay clickable, node radius scales with degree, and hovering/clicking a node focuses it + its direct neighbours while dimming the rest + a detail panel — taming heavy node/edge clutter) · `chunking.py` + `embeddings.py` (OpenAI-compatible embedding client, keyword fallback) + `search.py` (keyword/vector/hybrid) · `sensitive.py` (secret/PII redaction) · `dedup.py` (Jaccard + hash dedup) · `auto_ingest.py` (background queue/worker) · `obsidian_adapter.py` · `service.py` (`KnowledgeService` orchestrator — the only thing the router touches; capture entry points `capture_thread` / `capture_search` / `capture_file` / `create_manual_note`, all converging on `_persist_draft`). Tests: `tests/test_knowledge.py` (20 cases). Frontend: `frontend-web/src/core/knowledge/*` + `pages/KnowledgeBasePage.tsx` / `KnowledgeGraphPage.tsx` + chat `knowledge-save-trigger.tsx` / `knowledge-rag-toggle.tsx`; spec at `frontend-web/docs/product-knowledge-base-dev.md`.
|
||||
|
||||
**Wikilinks & multi-note capture** (`knowledge.{related_notes_enabled,related_notes_limit,multi_note_enabled}`, all default-on; `project_name` defaults to `zncm`):
|
||||
- **Navigable `[[wikilinks]]`** — `service.resolve_wikilink(s)` maps a `[[target]]` to a knowledge note (by id → `vault_path` → title) or a vault meta page (`read_vault_page`, path-traversal-proof), else `missing`. `markdown_vault.normalize_wikilink_target` canonicalizes targets (drops `|alias`, slashes, `.md`). The frontend `MarkdownRenderer` renders non-numeric `[[...]]` as clickable links from the resolver map (numeric `[[123]]` stay citation badges); note links navigate, vault links open a `VaultPageDialog`, missing links are greyed.
|
||||
@@ -311,6 +311,12 @@ Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.age
|
||||
|
||||
**Homepage selector (agent picker on the new-chat page)** — single admin-curated list backed by `agents.featured_order` (nullable BIGINT). NULL = not featured. Only `published = true` agents can be featured (router enforces on writes); any published agent is eligible regardless of ownership. Cleared with `order: null`. The list rendered to end users is `WHERE featured_order IS NOT NULL` + visibility-filter, sorted by `featured_order ASC`. Admins reorder by sending the desired ids to `PUT /api/agents/featured/order` (assigns indices 0..N-1). BIGINT is used rather than INT so the management UI can seed initial values from `Date.now()` (overflows INT on MySQL).
|
||||
|
||||
### Publish Squares (发布广场)
|
||||
|
||||
Admin-configurable, **access-control-free** categories that published agents/skills are filed under (distinct from the password-gated 私密广场). The square list lives in `system_settings.json` → `publish_squares` (`PublishSquaresSettings`: ordered `squares: [{id,name,description}]` + `default_square_id`); helpers `list_publish_squares` / `resolve_default_square_id` / `normalize_square_id` always yield a valid square (built-in "默认广场" fallback when none configured). Admin CRUD: `GET/PUT /api/system-settings/publish-squares` (GET readable by any user — the gallery sub-tabs + publish picker need it; PUT admin-only, auto-slugs missing ids and re-anchors a dangling default).
|
||||
|
||||
Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, `String(64)` NOT NULL default `''`, migration `20260611_01`, idempotent SQL in `column_additions_mysql.sql`). One column = at most one square, so an item can never be "published twice". `''` = unassigned → resolved to the default square at read/filter time. Writes normalize via `normalize_square_id`: agents on create/update (`square_id` field), skills on publish (`PUT /api/skills/custom/{name}/published` accepts optional `square_id`). Listing filters: `GET /api/agents?square=` and `GET /api/skills?square=` keep only that square's items; the **default** square additionally sweeps in legacy `''` rows (`square_is_default`). Tests: `tests/test_publish_squares.py`. Frontend: `core/system-settings` (`usePublishSquares`/`useUpdatePublishSquares`), admin `PublishSquaresSettingsPage` (embedded in the appearance/settings hub), square picker in the agent edit dialog + per-skill card, and a 广场-tab square sub-filter in both galleries.
|
||||
|
||||
### Portable Column Types (`deerflow/persistence/types.py`)
|
||||
|
||||
Two `TypeDecorator`s keep ORM columns correct across the `sqlite` / `postgres` / `mysql` backends. **All ORM timestamp/JSON columns must use these, not the raw SQLAlchemy types.**
|
||||
|
||||
@@ -19,24 +19,30 @@
|
||||
并配合 ``SkillStopMiddleware`` 在 ``agent_orchestration`` 调用后停图,便于网关从
|
||||
AIMessage.tool_calls 解析派活列表。
|
||||
- ``seat`` —— 子智能体交付:开 thinking + medium reasoning;**可写文件**
|
||||
(``write_file`` / ``str_replace`` 产出 md 等成品文档),但禁派活 / 澄清 /
|
||||
展示文件 / 看图 / 联网搜索 / **``bash``**。禁 ``bash`` 的原因:圆桌席位是
|
||||
「文档产出者」,弱模型爱写 ``.py`` 脚本再跑出产物(乱且抓不到预览),与
|
||||
``report`` 角色禁 ``bash`` 同理;让它**直接 write_file 写成品文档**。
|
||||
(``write_file`` / ``str_replace`` 产出 md 等成品文档)+ **可跑 ``bash``**
|
||||
(2026-06 放开:业务需要跑知识库检索 / Python 辅助分析)。禁派活 / 澄清 /
|
||||
展示文件 / 看图 / 联网搜索。``report`` 通过 ``[*_SEAT_EXCLUDED, ...]`` 继承本角色,
|
||||
所以 Step3 报告也跟着拿到 bash;只有 ``leader`` 和 ``seat_parallel`` 仍各自显式禁。
|
||||
历史警示(保留):弱模型用 ``bash`` 跑 ``python xxx.py > out.md`` 生成产物会
|
||||
**绕过前端虚拟预览**(预览只拦截 ``write_file.args.content``)→ 沙箱里有文件但
|
||||
Step2 右侧预览空白。席位 SOUL 必须明确「bash 只用于辅助查询拿数据,**最终交付
|
||||
必须用 write_file 写 .md 落盘**,不要让脚本生成最终产物」。
|
||||
- ``seat_parallel`` —— **DAG 快速(fast)模式的并行席位(方案 A,2026-06)**:**禁写文件**
|
||||
(``write_file`` / ``str_replace``,产物走正文)+ 禁 ``bash`` + 禁「打断 / 越权」类,
|
||||
(``write_file`` / ``str_replace``,产物走正文)+ **禁 ``bash``** + 禁「打断 / 越权」类,
|
||||
**放开 ``web_search`` 与技能**(搜索不碰 sandbox;技能靠 ``read_file`` SKILL.md)——
|
||||
否则并行席位几乎调不动工具、产生不了步骤(步骤条不显示)。
|
||||
并行席位同时跑 bash 会在共享 sandbox 里抢路径 / 占进程数;且 fast 模式整体不
|
||||
写文件,bash 跑出来的产物既不能预览也没意义。
|
||||
「写文件产出 md」归 **沙箱(sandbox)模式**(走 ``seat`` policy,单列 + 沙箱跟随预览);
|
||||
fast 模式快、双列、产物走正文 —— 用户明确要求 fast 不生成文件。详见
|
||||
``docs/roundtable-dag-orchestration.md`` §4 / §12.1 / §12.8 / §12.10。
|
||||
与 ``seat`` 的差异:``seat`` 禁 ``web_search``(旧「圆桌一律禁搜索」),
|
||||
``seat_parallel`` **放开搜索**(本期诉求)。
|
||||
- ``report`` —— Step3 报告绘制:在 ``seat``(已禁 ``bash``)基础上**额外禁
|
||||
``str_replace``**:
|
||||
* ``bash`` 已在 ``seat`` 禁 → 杜绝写 Python 脚本再跑出 HTML(脚本产物落在
|
||||
outputs,但前端虚拟预览只抓 ``write_file`` 的 ``args.content``,抓不到脚本
|
||||
产物 → 预览停在半截 ``write_file``,正是「没写完就去生成 py 文件」的现象);
|
||||
``seat_parallel`` **放开搜索**(本期诉求);``seat`` 已放开 ``bash``,本角色仍禁。
|
||||
- ``report`` —— Step3 报告绘制:在 ``seat`` 基础上**额外禁 ``str_replace``**:
|
||||
* 继承 ``seat`` 的 bash 放开(2026-06)—— Step3 也可跑 bash 做辅助查询,同
|
||||
``seat`` 警示:**结果必须 write_file 落盘**,否则前端虚拟预览会空白
|
||||
(预览只抓 ``write_file.args.content``,抓不到脚本产物 → 预览停在半截
|
||||
``write_file``,正是「没写完就去生成 py 文件」的现象)。
|
||||
* 禁 ``str_replace`` → 强制返工用整文件 ``write_file`` 重写
|
||||
(``str_replace`` 没有 ``content`` 字段,会让前端虚拟预览读到 undefined
|
||||
而清空沙箱)。
|
||||
@@ -72,16 +78,21 @@ _LEADER_EXCLUDED: list[str] = [
|
||||
"hindsight_retain",
|
||||
"read_peer_delivery", # 总控靠 _collect_delivery_status 直读各席位,不需要这个席位侧工具
|
||||
]
|
||||
# seat:席位**可以写文件**(write_file / str_replace)产出 md 等成品文档,但**禁 bash** ——
|
||||
# 圆桌席位是「文档产出者」,不该跑 shell/python 脚本(弱模型爱写 .py 脚本再跑生成产物,既乱又
|
||||
# 抓不到产物预览,与 report 角色禁 bash 同理)。禁 web_search(圆桌串行一律禁搜索,旧决策)。
|
||||
# seat:席位**可以写文件**(write_file / str_replace)产出 md 等成品文档,**bash 放开**
|
||||
# (2026-06 决策,业务需要 bash 跑知识库检索 / Python 辅助分析)。禁派活 / 澄清 /
|
||||
# 展示文件 / 看图 / 联网搜索。``report`` 通过 ``[*_SEAT_EXCLUDED, ...]`` 继承本角色,
|
||||
# 所以 Step3 报告跟着拿到 bash;只有 leader / seat_parallel 仍各自显式禁。
|
||||
#
|
||||
# **历史警示(保留)**:弱模型用 bash 跑 ``python xxx.py > out.md`` 生成最终产物会
|
||||
# 绕过前端虚拟预览(只拦 write_file.args.content) → 沙箱里有文件但 Step2 右侧预览
|
||||
# 空白。必须靠席位 SOUL 强约束「bash 只用于辅助查询拿数据,最终交付必须用 write_file
|
||||
# 写 .md 落盘」。
|
||||
_SEAT_EXCLUDED: list[str] = [
|
||||
"web_search",
|
||||
"ask_clarification",
|
||||
"present_files",
|
||||
"view_image",
|
||||
"agent_orchestration",
|
||||
"bash",
|
||||
]
|
||||
# 并行席位 = **快速(fast)模式**(双列):**禁写文件**(产物走正文)+ 禁跑命令 +「打断/越权」类。
|
||||
# **禁 web_search** —— 离线内网部署无外网,席位检索一律走「技能(skill)」(靠 read_file 读
|
||||
|
||||
@@ -20,7 +20,6 @@ import os
|
||||
import re
|
||||
from collections.abc import AsyncIterator
|
||||
from contextvars import ContextVar
|
||||
from difflib import SequenceMatcher
|
||||
from hashlib import sha256
|
||||
from time import perf_counter
|
||||
from typing import Any
|
||||
@@ -83,7 +82,7 @@ _LEADERBOARD_DAILY_SNAPSHOT_LIMIT = max(
|
||||
)
|
||||
_LEADERBOARD_TODAY_TTL_SECONDS = max(
|
||||
30.0,
|
||||
float(os.getenv("ADMIN_LEADERBOARD_TODAY_TTL_SECONDS", "300")),
|
||||
float(os.getenv("ADMIN_LEADERBOARD_TODAY_TTL_SECONDS", "43200")),
|
||||
)
|
||||
_LEADERBOARD_QUERY_TIMEOUT_MS = max(
|
||||
500,
|
||||
@@ -269,15 +268,6 @@ class AdminLeaderboardDimension(BaseModel):
|
||||
last_seen_at: str = ""
|
||||
|
||||
|
||||
class AdminLeaderboardQuestion(BaseModel):
|
||||
text: str
|
||||
user_id: str = ""
|
||||
email: str = ""
|
||||
thread_id: str = ""
|
||||
run_id: str = ""
|
||||
asked_at: str = ""
|
||||
|
||||
|
||||
class AdminLeaderboardTrendPoint(BaseModel):
|
||||
date: str
|
||||
runs: int = 0
|
||||
@@ -314,8 +304,6 @@ class AdminLeaderboardResponse(BaseModel):
|
||||
models: list[AdminLeaderboardDimension] = Field(default_factory=list)
|
||||
agents: list[AdminLeaderboardDimension] = Field(default_factory=list)
|
||||
run_statuses: list[AdminLeaderboardDimension] = Field(default_factory=list)
|
||||
recent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
|
||||
frequent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
|
||||
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -330,7 +318,6 @@ class AdminUserAnalyticsResponse(BaseModel):
|
||||
models: list[AdminLeaderboardDimension] = Field(default_factory=list)
|
||||
agents: list[AdminLeaderboardDimension] = Field(default_factory=list)
|
||||
run_statuses: list[AdminLeaderboardDimension] = Field(default_factory=list)
|
||||
recent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
|
||||
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -1014,129 +1001,6 @@ async def _query_model_rankings(
|
||||
]
|
||||
|
||||
|
||||
async def _query_recent_questions(
|
||||
session,
|
||||
since: datetime,
|
||||
until: datetime,
|
||||
*,
|
||||
user_id: str | None,
|
||||
limit: int,
|
||||
exclude_user_ids: set[str] | None = None,
|
||||
include_scheduled: bool = True,
|
||||
include_admins: bool = True,
|
||||
include_failed: bool = True,
|
||||
cleanup_system_questions: bool = True,
|
||||
) -> list[AdminLeaderboardQuestion]:
|
||||
exclude_user_ids = exclude_user_ids or set()
|
||||
stmt = (
|
||||
select(RunRow.first_human_message, RunRow.user_id, UserRow.email, RunRow.thread_id, RunRow.run_id, RunRow.created_at)
|
||||
.outerjoin(UserRow, UserRow.id == RunRow.user_id)
|
||||
.where(_run_time_filter(since, until), RunRow.first_human_message.is_not(None), RunRow.first_human_message != "")
|
||||
.order_by(RunRow.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
if user_id:
|
||||
stmt = stmt.where(RunRow.user_id == user_id)
|
||||
stmt = _apply_run_scope(
|
||||
stmt,
|
||||
user_column=RunRow.user_id,
|
||||
run_id_column=RunRow.run_id,
|
||||
exclude_user_ids=exclude_user_ids,
|
||||
include_admins=include_admins,
|
||||
include_failed=include_failed,
|
||||
include_scheduled=include_scheduled,
|
||||
)
|
||||
rows = (await _execute_leaderboard_query(session, "recent_questions", stmt)).all()
|
||||
questions: list[AdminLeaderboardQuestion] = []
|
||||
for r in rows:
|
||||
text = _clean_question_text(r[0]) if cleanup_system_questions else str(r[0] or "").strip()
|
||||
if cleanup_system_questions and not _is_displayable_question(text):
|
||||
continue
|
||||
questions.append(
|
||||
AdminLeaderboardQuestion(
|
||||
text=text[:500],
|
||||
user_id=r[1] or "",
|
||||
email=r[2] or "",
|
||||
thread_id=r[3] or "",
|
||||
run_id=r[4] or "",
|
||||
asked_at=_iso(r[5]),
|
||||
)
|
||||
)
|
||||
return questions
|
||||
|
||||
|
||||
async def _query_frequent_questions(
|
||||
session,
|
||||
since: datetime,
|
||||
until: datetime,
|
||||
limit: int,
|
||||
exclude_user_ids: set[str] | None = None,
|
||||
include_scheduled: bool = True,
|
||||
include_admins: bool = True,
|
||||
include_failed: bool = True,
|
||||
cleanup_system_questions: bool = True,
|
||||
) -> list[AdminLeaderboardQuestion]:
|
||||
exclude_user_ids = exclude_user_ids or set()
|
||||
stmt = (
|
||||
select(
|
||||
RunRow.first_human_message,
|
||||
RunRow.user_id,
|
||||
UserRow.email,
|
||||
RunRow.created_at,
|
||||
)
|
||||
.outerjoin(UserRow, UserRow.id == RunRow.user_id)
|
||||
.where(_run_time_filter(since, until), RunRow.first_human_message.is_not(None), RunRow.first_human_message != "")
|
||||
.order_by(RunRow.created_at.desc())
|
||||
.limit(min(max(limit * 20, 200), 1000))
|
||||
)
|
||||
stmt = _apply_run_scope(
|
||||
stmt,
|
||||
user_column=RunRow.user_id,
|
||||
run_id_column=RunRow.run_id,
|
||||
exclude_user_ids=exclude_user_ids,
|
||||
include_admins=include_admins,
|
||||
include_failed=include_failed,
|
||||
include_scheduled=include_scheduled,
|
||||
)
|
||||
rows = (await _execute_leaderboard_query(session, "frequent_questions", stmt)).all()
|
||||
groups: list[dict] = []
|
||||
for r in rows:
|
||||
text = _clean_question_text(r[0]) if cleanup_system_questions else str(r[0] or "").strip()
|
||||
if cleanup_system_questions and not _is_displayable_question(text):
|
||||
continue
|
||||
key = _question_similarity_key(text)
|
||||
if not key:
|
||||
continue
|
||||
target = None
|
||||
for group in groups:
|
||||
score = SequenceMatcher(None, key, group["key"]).ratio()
|
||||
if score >= 0.72 or key in group["key"] or group["key"] in key:
|
||||
target = group
|
||||
break
|
||||
if target is None:
|
||||
groups.append({"key": key, "text": text, "count": 1, "user_id": r[1] or "", "email": r[2] or "", "asked_at": r[3]})
|
||||
else:
|
||||
target["count"] += 1
|
||||
if r[3] and (target.get("asked_at") is None or r[3] > target["asked_at"]):
|
||||
target.update({"text": text, "user_id": r[1] or "", "email": r[2] or "", "asked_at": r[3]})
|
||||
|
||||
groups.sort(key=lambda item: (item["count"], _iso(item.get("asked_at"))), reverse=True)
|
||||
questions: list[AdminLeaderboardQuestion] = []
|
||||
for group in groups[:limit]:
|
||||
text = group["text"][:460]
|
||||
if group["count"] > 1:
|
||||
text = f"{text}(相似问题 {group['count']} 次)"
|
||||
questions.append(
|
||||
AdminLeaderboardQuestion(
|
||||
text=text,
|
||||
user_id=group["user_id"],
|
||||
email=group["email"],
|
||||
asked_at=_iso(group.get("asked_at")),
|
||||
)
|
||||
)
|
||||
return questions
|
||||
|
||||
|
||||
async def _query_trends(
|
||||
session,
|
||||
since: datetime,
|
||||
@@ -1306,10 +1170,6 @@ async def _build_user_leaderboard_response(
|
||||
"include_scheduled": include_scheduled,
|
||||
"include_admins": include_admins,
|
||||
}
|
||||
question_common = {
|
||||
**common,
|
||||
"cleanup_system_questions": settings.cleanup_system_questions,
|
||||
}
|
||||
overview = await _query_overview(session, since_dt, until_dt, **common)
|
||||
await _leaderboard_pause()
|
||||
users = await _query_user_leaderboard(session, since_dt, until_dt, **common)
|
||||
@@ -1326,10 +1186,6 @@ async def _build_user_leaderboard_response(
|
||||
await _leaderboard_pause()
|
||||
run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="status", limit=limit, **common)
|
||||
await _leaderboard_pause()
|
||||
recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=None, limit=limit, **question_common)
|
||||
await _leaderboard_pause()
|
||||
frequent_questions = await _query_frequent_questions(session, since_dt, until_dt, limit=limit, **question_common)
|
||||
await _leaderboard_pause()
|
||||
trends = await _query_trends(session, since_dt, until_dt, **common)
|
||||
|
||||
return AdminLeaderboardResponse(
|
||||
@@ -1347,8 +1203,6 @@ async def _build_user_leaderboard_response(
|
||||
models=models,
|
||||
agents=agents,
|
||||
run_statuses=run_statuses,
|
||||
recent_questions=recent_questions,
|
||||
frequent_questions=frequent_questions,
|
||||
trends=trends,
|
||||
)
|
||||
|
||||
@@ -1572,95 +1426,6 @@ def _merge_dimensions_from_daily(
|
||||
return sorted(merged_items, key=lambda item: (item.count, _sort_datetime(item.last_seen_at)), reverse=True)[:limit]
|
||||
|
||||
|
||||
def _merge_recent_questions_from_daily(
|
||||
responses: list[AdminLeaderboardResponse],
|
||||
limit: int,
|
||||
) -> list[AdminLeaderboardQuestion]:
|
||||
questions: list[AdminLeaderboardQuestion] = []
|
||||
seen: set[str] = set()
|
||||
for response in responses:
|
||||
for question in response.recent_questions:
|
||||
key = question.run_id or f"{question.user_id}:{question.asked_at}:{question.text}"
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
questions.append(question)
|
||||
questions.sort(key=lambda item: _sort_datetime(item.asked_at), reverse=True)
|
||||
return questions[:limit]
|
||||
|
||||
|
||||
_FREQUENT_QUESTION_COUNT_RE = re.compile(r"(相似问题\s+(\d+)\s+次)$")
|
||||
|
||||
|
||||
def _split_frequent_question_count(text: str) -> tuple[str, int]:
|
||||
match = _FREQUENT_QUESTION_COUNT_RE.search(text)
|
||||
if not match:
|
||||
return text, 1
|
||||
return _FREQUENT_QUESTION_COUNT_RE.sub("", text).strip(), max(1, int(match.group(1)))
|
||||
|
||||
|
||||
def _merge_frequent_questions_from_daily(
|
||||
responses: list[AdminLeaderboardResponse],
|
||||
limit: int,
|
||||
) -> list[AdminLeaderboardQuestion]:
|
||||
groups: list[dict] = []
|
||||
for response in responses:
|
||||
for question in response.frequent_questions:
|
||||
base_text, count = _split_frequent_question_count(question.text)
|
||||
key = _question_similarity_key(base_text)
|
||||
if not key:
|
||||
continue
|
||||
target = None
|
||||
for group in groups:
|
||||
score = SequenceMatcher(None, key, group["key"]).ratio()
|
||||
if score >= 0.72 or key in group["key"] or group["key"] in key:
|
||||
target = group
|
||||
break
|
||||
if target is None:
|
||||
groups.append(
|
||||
{
|
||||
"key": key,
|
||||
"text": base_text,
|
||||
"count": count,
|
||||
"user_id": question.user_id,
|
||||
"email": question.email,
|
||||
"thread_id": question.thread_id,
|
||||
"run_id": question.run_id,
|
||||
"asked_at": question.asked_at,
|
||||
}
|
||||
)
|
||||
else:
|
||||
target["count"] += count
|
||||
if _sort_datetime(question.asked_at) > _sort_datetime(target.get("asked_at")):
|
||||
target.update(
|
||||
{
|
||||
"text": base_text,
|
||||
"user_id": question.user_id,
|
||||
"email": question.email,
|
||||
"thread_id": question.thread_id,
|
||||
"run_id": question.run_id,
|
||||
"asked_at": question.asked_at,
|
||||
}
|
||||
)
|
||||
groups.sort(key=lambda item: (item["count"], _sort_datetime(item.get("asked_at"))), reverse=True)
|
||||
questions: list[AdminLeaderboardQuestion] = []
|
||||
for group in groups[:limit]:
|
||||
text_value = group["text"][:460]
|
||||
if group["count"] > 1:
|
||||
text_value = f"{text_value}(相似问题 {group['count']} 次)"
|
||||
questions.append(
|
||||
AdminLeaderboardQuestion(
|
||||
text=text_value,
|
||||
user_id=group["user_id"],
|
||||
email=group["email"],
|
||||
thread_id=group["thread_id"],
|
||||
run_id=group["run_id"],
|
||||
asked_at=group["asked_at"],
|
||||
)
|
||||
)
|
||||
return questions
|
||||
|
||||
|
||||
def _merge_trends_from_daily(responses: list[AdminLeaderboardResponse]) -> list[AdminLeaderboardTrendPoint]:
|
||||
trends_by_date: dict[str, AdminLeaderboardTrendPoint] = {}
|
||||
for response in responses:
|
||||
@@ -1719,8 +1484,6 @@ def _merge_daily_leaderboard_payloads(
|
||||
models=_merge_dimensions_from_daily(responses, "models", limit),
|
||||
agents=_merge_dimensions_from_daily(responses, "agents", limit),
|
||||
run_statuses=_merge_dimensions_from_daily(responses, "run_statuses", limit),
|
||||
recent_questions=_merge_recent_questions_from_daily(responses, limit),
|
||||
frequent_questions=_merge_frequent_questions_from_daily(responses, limit),
|
||||
trends=_merge_trends_from_daily(responses),
|
||||
)
|
||||
|
||||
@@ -2267,8 +2030,6 @@ async def _build_leaderboard_report_prompt(
|
||||
models = snapshot.models
|
||||
agents = snapshot.agents
|
||||
run_statuses = snapshot.run_statuses
|
||||
recent_questions = snapshot.recent_questions
|
||||
frequent_questions = snapshot.frequent_questions
|
||||
trends = snapshot.trends
|
||||
top_users = snapshot.users_by_activity[:12]
|
||||
context = {
|
||||
@@ -2280,8 +2041,6 @@ async def _build_leaderboard_report_prompt(
|
||||
"top_models": [m.model_dump() for m in models[:12]],
|
||||
"agents": [a.model_dump() for a in agents[:12]],
|
||||
"run_statuses": [s.model_dump() for s in run_statuses[:12]],
|
||||
"frequent_questions": [q.model_dump() for q in frequent_questions[:30]],
|
||||
"recent_questions": [q.model_dump() for q in recent_questions[:40]],
|
||||
"trends": [t.model_dump() for t in trends[-21:]],
|
||||
}
|
||||
return (
|
||||
@@ -2289,12 +2048,10 @@ async def _build_leaderboard_report_prompt(
|
||||
"报告需要有结论,不要只罗列数字;请使用 Markdown 标题、列表和表格,让前端可以直接渲染。"
|
||||
"重点要求:"
|
||||
"1) 你是专门负责分析用户行为的专家,不要自称企业 AI 应用运营分析师;"
|
||||
"2) 优先突出用户最近常用问题、最近关注的实时问题、事件、主题和业务方向,必须引用 recent_questions 中的近期问题作为依据;"
|
||||
"3) 单独分析 frequent_questions,归纳高频关注簇,说明这些高频问题反映出的需求强度和重复频率;"
|
||||
"4) 结合 top_users、top_skills、top_tools、top_models 和 trends,说明使用频率、使用增长、常用能力和重点用户行为画像;"
|
||||
"5) 可以提及使用强度、关注变化和行为机会,但不要把报告重点放在系统本身的问题、故障、失败率或回答质量缺陷上;"
|
||||
"6) 给管理员 3-5 条可执行建议,建议应围绕用户关注主题、资源配置、内容供给、培训引导和高频能力优化。"
|
||||
"输出结构:摘要、最近关注、高频问题与频率、重点用户行为、常用能力、趋势观察、行动建议。"
|
||||
"2) 结合 top_users、top_skills、top_tools、top_models 和 trends,说明使用频率、使用增长、常用能力和重点用户行为画像;"
|
||||
"3) 可以提及使用强度、关注变化和行为机会,但不要把报告重点放在系统本身的问题、故障、失败率或回答质量缺陷上;"
|
||||
"4) 给管理员 3-5 条可执行建议,建议应围绕资源配置、内容供给、培训引导和高频能力优化。"
|
||||
"输出结构:摘要、重点用户行为、常用能力、趋势观察、行动建议。"
|
||||
"\n\n统计数据如下:\n"
|
||||
f"{json.dumps(context, ensure_ascii=False, default=str)}"
|
||||
)
|
||||
@@ -2452,10 +2209,6 @@ async def get_user_analytics(
|
||||
"include_scheduled": settings.include_scheduled,
|
||||
"include_admins": settings.include_admins,
|
||||
}
|
||||
question_common = {
|
||||
**common,
|
||||
"cleanup_system_questions": settings.cleanup_system_questions,
|
||||
}
|
||||
since_dt, until_dt, range_days = _resolve_range(days, since, until)
|
||||
async with session_factory() as session:
|
||||
await _set_analytics_read_timeout(session)
|
||||
@@ -2470,7 +2223,6 @@ async def get_user_analytics(
|
||||
models = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="model", limit=limit, **common)
|
||||
agents = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="agent", limit=limit, **common)
|
||||
run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="status", limit=limit, **common)
|
||||
recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=user_id, limit=limit, **question_common)
|
||||
trends = await _query_trends(session, since_dt, until_dt, user_id=user_id, **common)
|
||||
|
||||
return AdminUserAnalyticsResponse(
|
||||
@@ -2484,7 +2236,6 @@ async def get_user_analytics(
|
||||
models=models,
|
||||
agents=agents,
|
||||
run_statuses=run_statuses,
|
||||
recent_questions=recent_questions,
|
||||
trends=trends,
|
||||
)
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ from app.gateway.auth.password import verify_password_async
|
||||
from app.gateway.deps import get_agent_store, get_config, get_tag_store
|
||||
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||
from deerflow.config.agents_api_config import get_agents_api_config
|
||||
from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
|
||||
from deerflow.config.agents_config import CommonQuestion, list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.config.paths import get_paths
|
||||
from deerflow.config.system_settings import load_system_settings
|
||||
from deerflow.config.system_settings import load_system_settings, normalize_square_id, resolve_default_square_id
|
||||
from deerflow.persistence.agents import AgentStore
|
||||
from deerflow.persistence.tags import TagStore
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
@@ -42,6 +42,7 @@ class AgentResponse(BaseModel):
|
||||
owner_username: str | None = Field(default=None, description="Publisher username (email local part). Null for built-in agents.")
|
||||
published: bool = Field(default=False, description="Whether a user-created agent is public")
|
||||
is_private: bool = Field(default=False, description="Whether the agent lives in the password-gated private square instead of the public one")
|
||||
square_id: str = Field(default="", description="Publish square (发布广场) the agent is filed under. Empty string = the default square. Independent of is_private.")
|
||||
builtin: bool = Field(default=False, description="True when user_id is null")
|
||||
created_at: datetime | str | None = None
|
||||
updated_at: datetime | str | None = None
|
||||
@@ -53,6 +54,11 @@ class AgentResponse(BaseModel):
|
||||
tool_groups: list[str] | None = None
|
||||
skills: list[str] | None = None
|
||||
|
||||
common_questions: list[CommonQuestion] | None = Field(
|
||||
default=None,
|
||||
description="Recommended questions shown on the agent chat home / sidebar (常用问题). Each item is {title, prompt}; legacy string lists are coerced.",
|
||||
)
|
||||
|
||||
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent")
|
||||
|
||||
featured_order: int | None = Field(
|
||||
@@ -98,9 +104,11 @@ class AgentCreateRequest(BaseModel):
|
||||
soul: str = Field(default="", description="SOUL.md content - agent personality and behavioral guardrails")
|
||||
published: bool = Field(default=False, description="Whether the new agent should be public")
|
||||
is_private: bool = Field(default=False, description="When True, the new agent goes into the password-gated private square instead of the public one")
|
||||
square_id: str | None = Field(default=None, description="Publish square (发布广场) to file the agent under. Omit / empty = the default square.")
|
||||
skills: list[str] | None = Field(default=None, description="Skill whitelist persisted to the agent's config.yaml")
|
||||
model: str | None = Field(default=None, description="Optional model override persisted to config.yaml")
|
||||
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
|
||||
common_questions: list[CommonQuestion] | None = Field(default=None, description="Recommended questions (常用问题) persisted to config.yaml. Each item is {title, prompt}; legacy strings are coerced.")
|
||||
|
||||
|
||||
class AgentUpdateRequest(BaseModel):
|
||||
@@ -111,9 +119,11 @@ class AgentUpdateRequest(BaseModel):
|
||||
soul: str | None = Field(default=None, description="Updated SOUL.md content")
|
||||
published: bool | None = Field(default=None, description="Updated publication state")
|
||||
is_private: bool | None = Field(default=None, description="Move the agent into / out of the private square (true / false)")
|
||||
square_id: str | None = Field(default=None, description="Publish square (发布广场) to file the agent under. Empty string = the default square.")
|
||||
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (config.yaml)")
|
||||
model: str | None = Field(default=None, description="Updated model override (config.yaml)")
|
||||
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
|
||||
common_questions: list[CommonQuestion] | None = Field(default=None, description="Updated recommended questions (常用问题, config.yaml). Each item is {title, prompt}; legacy strings are coerced.")
|
||||
|
||||
|
||||
class AgentSelectorResponse(BaseModel):
|
||||
@@ -217,6 +227,7 @@ def _write_agent_config(
|
||||
skills: list[str] | None = None,
|
||||
model: str | None = None,
|
||||
tool_groups: list[str] | None = None,
|
||||
common_questions: list[CommonQuestion] | None = None,
|
||||
) -> None:
|
||||
agent_dir = get_paths().agent_dir(agent_id)
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -232,19 +243,36 @@ def _write_agent_config(
|
||||
config_data["model"] = model
|
||||
if tool_groups is not None:
|
||||
config_data["tool_groups"] = tool_groups
|
||||
if common_questions is not None:
|
||||
# Persist as plain dicts (dropping blank rows) so config.yaml stays a
|
||||
# clean mapping rather than a pydantic repr. Accepts already-coerced
|
||||
# CommonQuestion models or raw dicts/strings.
|
||||
normalized = [
|
||||
q if isinstance(q, CommonQuestion) else CommonQuestion.model_validate(q)
|
||||
for q in common_questions
|
||||
]
|
||||
config_data["common_questions"] = [
|
||||
q.model_dump() for q in normalized if q.prompt.strip()
|
||||
]
|
||||
with open(config_file, "w", encoding="utf-8") as f:
|
||||
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
|
||||
def _load_config_extras(agent_id: str) -> dict[str, Any]:
|
||||
"""Read skills/model/tool_groups from the agent's config.yaml, tolerant of missing files."""
|
||||
"""Read skills/model/tool_groups/common_questions from the agent's config.yaml, tolerant of missing files."""
|
||||
empty = {"skills": None, "model": None, "tool_groups": None, "common_questions": None}
|
||||
try:
|
||||
cfg = load_agent_config(agent_id)
|
||||
except (FileNotFoundError, ValueError):
|
||||
return {"skills": None, "model": None, "tool_groups": None}
|
||||
return dict(empty)
|
||||
if cfg is None:
|
||||
return {"skills": None, "model": None, "tool_groups": None}
|
||||
return {"skills": cfg.skills, "model": cfg.model, "tool_groups": cfg.tool_groups}
|
||||
return dict(empty)
|
||||
return {
|
||||
"skills": cfg.skills,
|
||||
"model": cfg.model,
|
||||
"tool_groups": cfg.tool_groups,
|
||||
"common_questions": cfg.common_questions,
|
||||
}
|
||||
|
||||
|
||||
def _write_agent_soul(agent_id: str, soul: str) -> None:
|
||||
@@ -404,7 +432,7 @@ def _record_to_response(
|
||||
can happen lazily when the agent is actually opened.
|
||||
"""
|
||||
soul: str | None = None
|
||||
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None}
|
||||
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None, "common_questions": None}
|
||||
if include_extras:
|
||||
_ensure_agent_dir_from_record(record)
|
||||
extras = _load_config_extras(record["id"])
|
||||
@@ -418,6 +446,7 @@ def _record_to_response(
|
||||
user_id=record.get("user_id"),
|
||||
published=bool(record.get("published", False)),
|
||||
is_private=bool(record.get("is_private", False)),
|
||||
square_id=str(record.get("square_id") or ""),
|
||||
builtin=record.get("user_id") is None,
|
||||
created_at=record.get("created_at"),
|
||||
updated_at=record.get("updated_at"),
|
||||
@@ -425,6 +454,7 @@ def _record_to_response(
|
||||
model=extras["model"],
|
||||
tool_groups=extras["tool_groups"],
|
||||
skills=extras["skills"],
|
||||
common_questions=extras.get("common_questions"),
|
||||
featured_order=record.get("featured_order"),
|
||||
)
|
||||
|
||||
@@ -559,6 +589,10 @@ async def list_agents(
|
||||
default=None,
|
||||
description="Keep only agents published by this user id (the card's publisher). Built-in agents never match.",
|
||||
),
|
||||
square: str | None = Query(
|
||||
default=None,
|
||||
description="Keep only agents filed under this publish square (发布广场). Omit for all squares.",
|
||||
),
|
||||
admin: bool = Query(
|
||||
default=False,
|
||||
description=(
|
||||
@@ -579,6 +613,11 @@ async def list_agents(
|
||||
raise HTTPException(status_code=403, detail="Only system admins can list all agents")
|
||||
|
||||
paginate = scope is not None or page is not None
|
||||
square_id = square.strip() if square and square.strip() else None
|
||||
square_is_default = False
|
||||
if square_id:
|
||||
sq = load_system_settings().publish_squares
|
||||
square_is_default = square_id == resolve_default_square_id(sq)
|
||||
if paginate:
|
||||
eff_page = page or 1
|
||||
rows, total = await store.list_paginated(
|
||||
@@ -591,6 +630,8 @@ async def list_agents(
|
||||
admin=admin,
|
||||
sort=sort,
|
||||
owner=owner,
|
||||
square_id=square_id,
|
||||
square_is_default=square_is_default,
|
||||
)
|
||||
responses = await _build_list_responses(store, tag_store, user_id, rows)
|
||||
return AgentsListResponse(agents=responses, total=total, page=eff_page, page_size=page_size)
|
||||
@@ -608,6 +649,12 @@ async def list_agents(
|
||||
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
|
||||
if owner and owner.strip():
|
||||
responses = [r for r in responses if r.user_id == owner.strip()]
|
||||
if square_id:
|
||||
responses = [
|
||||
r
|
||||
for r in responses
|
||||
if (r.square_id or "") == square_id or (square_is_default and not (r.square_id or ""))
|
||||
]
|
||||
responses = _sort_agent_responses(responses, sort)
|
||||
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
|
||||
except HTTPException:
|
||||
@@ -725,6 +772,7 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
|
||||
skills=request.skills,
|
||||
model=request.model,
|
||||
tool_groups=request.tool_groups,
|
||||
common_questions=request.common_questions,
|
||||
)
|
||||
_write_agent_soul(agent_id, request.soul)
|
||||
|
||||
@@ -736,6 +784,8 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
|
||||
"user_id": _current_user_id(http_request),
|
||||
"published": request.published,
|
||||
"is_private": request.is_private,
|
||||
# Normalize to a valid configured square id (default when unset).
|
||||
"square_id": normalize_square_id(request.square_id, load_system_settings().publish_squares),
|
||||
}
|
||||
)
|
||||
# Mirror skills/tool_groups/model into the listing cache so the gallery
|
||||
@@ -782,6 +832,10 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
||||
update_data["published"] = bool(request.published)
|
||||
if "is_private" in fields_set:
|
||||
update_data["is_private"] = bool(request.is_private)
|
||||
if "square_id" in fields_set:
|
||||
# Normalize to a valid configured square id (default when unset /
|
||||
# unknown), so the row never holds a dangling square reference.
|
||||
update_data["square_id"] = normalize_square_id(request.square_id, load_system_settings().publish_squares)
|
||||
# Mutual exclusion: an agent moved into the private square cannot
|
||||
# also be publicly published. Force ``published=false`` whenever the
|
||||
# same request sets ``is_private=true``, even if the client forgot
|
||||
@@ -797,8 +851,9 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
||||
next_skills = request.skills if "skills" in fields_set else existing_extras["skills"]
|
||||
next_model = request.model if "model" in fields_set else existing_extras["model"]
|
||||
next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"]
|
||||
next_common_questions = request.common_questions if "common_questions" in fields_set else existing_extras["common_questions"]
|
||||
|
||||
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups"))
|
||||
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions"))
|
||||
if config_dirty:
|
||||
_write_agent_config(
|
||||
agent_id,
|
||||
@@ -807,6 +862,7 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
||||
skills=next_skills,
|
||||
model=next_model,
|
||||
tool_groups=next_tool_groups,
|
||||
common_questions=next_common_questions,
|
||||
)
|
||||
# Keep the listing cache in lock-step with config.yaml on every write.
|
||||
await store.replace_skills(agent_id, next_skills)
|
||||
|
||||
@@ -13,9 +13,12 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
|
||||
from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -26,6 +29,12 @@ from deerflow.knowledge.service import EmptyThreadError
|
||||
from deerflow.runtime import serialize_channel_values
|
||||
from deerflow.runtime.references import build_reference_batches
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
from deerflow.utils.file_conversion import CONVERTIBLE_EXTENSIONS, convert_file_to_markdown
|
||||
|
||||
# Plain-text/markdown files are read directly; binary documents are converted.
|
||||
_TEXT_EXTENSIONS = {".md", ".markdown", ".txt", ".text", ""}
|
||||
# Bound the in-memory read so a knowledge import can't OOM the gateway.
|
||||
_MAX_IMPORT_BYTES = 25 * 1024 * 1024
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/knowledge", tags=["knowledge"])
|
||||
@@ -299,6 +308,80 @@ async def ingest_threads_batch(body: BatchIngestRequest, request: Request) -> di
|
||||
return {"ok": True, "processed": len(items), "created": created, "skipped": skipped, "failed": failed, "llm_fallback": llm_fallback, "items": items}
|
||||
|
||||
|
||||
async def _extract_upload_text(file: UploadFile) -> tuple[str, str]:
|
||||
"""Read an uploaded file and return ``(filename, markdown_text)``.
|
||||
|
||||
Plain-text/markdown is decoded directly; PDF/Office documents are converted
|
||||
to Markdown via the shared converter. Raises ``HTTPException`` on empty /
|
||||
oversized / unsupported / unparseable files.
|
||||
"""
|
||||
name = file.filename or "document"
|
||||
ext = Path(name).suffix.lower()
|
||||
raw = await file.read()
|
||||
if not raw:
|
||||
raise HTTPException(status_code=400, detail="文件为空")
|
||||
if len(raw) > _MAX_IMPORT_BYTES:
|
||||
raise HTTPException(status_code=413, detail=f"文件过大,最大支持 {_MAX_IMPORT_BYTES // (1024 * 1024)}MB")
|
||||
|
||||
if ext in _TEXT_EXTENSIONS:
|
||||
text = raw.decode("utf-8", errors="ignore")
|
||||
elif ext in CONVERTIBLE_EXTENSIONS:
|
||||
# Convert in an isolated temp dir; a fixed safe name avoids path issues
|
||||
# while preserving the extension so the converter detects the type.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp) / f"import{ext}"
|
||||
tmp_path.write_bytes(raw)
|
||||
md_path = await convert_file_to_markdown(tmp_path)
|
||||
if md_path is None:
|
||||
raise HTTPException(status_code=422, detail=f"无法解析文件:{name}")
|
||||
text = md_path.read_text(encoding="utf-8", errors="ignore")
|
||||
else:
|
||||
raise HTTPException(status_code=415, detail=f"不支持的文件类型:{ext or '未知'}(支持 md/txt/pdf/word/ppt/excel)")
|
||||
|
||||
if not text.strip():
|
||||
raise HTTPException(status_code=422, detail="文件内容为空或无法提取文本")
|
||||
return name, text
|
||||
|
||||
|
||||
@router.post("/ingest/file")
|
||||
@require_auth
|
||||
async def ingest_file(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
title: str | None = Form(default=None),
|
||||
tags: str | None = Form(default=None),
|
||||
status: str = Form(default="approved"),
|
||||
model_name: str | None = Form(default=None),
|
||||
) -> dict[str, Any]:
|
||||
"""Import an uploaded file into the knowledge base as wiki note(s).
|
||||
|
||||
Converts the file to Markdown, distills it via the wiki-capture LLM pipeline
|
||||
(same as thread capture), and writes the resulting declarative note(s) into
|
||||
the shared vault. ``tags`` is a comma/space-separated string.
|
||||
"""
|
||||
service = get_knowledge_service(request)
|
||||
name, text = await _extract_upload_text(file)
|
||||
tag_list = [t for t in re.split(r"[,,\s]+", tags or "") if t]
|
||||
try:
|
||||
result = await service.capture_file(
|
||||
content_md=text,
|
||||
filename=name,
|
||||
title=(title or None),
|
||||
tags=tag_list,
|
||||
status=status,
|
||||
created_by=get_effective_user_id(),
|
||||
model_name=(model_name or None),
|
||||
)
|
||||
except EmptyThreadError:
|
||||
raise HTTPException(status_code=422, detail="文件内容无可沉淀的知识")
|
||||
return {
|
||||
"note": result["note"],
|
||||
"created": result["created"],
|
||||
"notes": result.get("notes", [result["note"]]),
|
||||
"llm_fallback": result.get("llm_fallback", False),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/ingest/search")
|
||||
@require_auth
|
||||
async def ingest_search(body: IngestSearchRequest, request: Request) -> dict[str, Any]:
|
||||
|
||||
@@ -62,13 +62,6 @@ def _anonymize(resp: AdminLeaderboardResponse) -> AdminLeaderboardResponse:
|
||||
user.email = _mask_email(user.email)
|
||||
user.user_id = hashed(user.user_id)
|
||||
|
||||
for bucket in (resp.recent_questions, resp.frequent_questions):
|
||||
for question in bucket:
|
||||
question.email = _mask_email(question.email)
|
||||
question.user_id = hashed(question.user_id)
|
||||
question.thread_id = ""
|
||||
question.run_id = ""
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
|
||||
@@ -166,6 +166,7 @@ class SkillResponse(BaseModel):
|
||||
owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)")
|
||||
owner_username: str | None = Field(default=None, description="Publisher username (email local part). Null for built-in skills.")
|
||||
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
|
||||
square_id: str = Field(default="", description="Publish square (发布广场) the skill is filed under once published. Empty string = the default square.")
|
||||
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
|
||||
detail: str | None = Field(default=None, description="Human-authored long-form detail text (Markdown), editable by owner/admin")
|
||||
display: dict[str, Any] | None = Field(default=None, description="Optional frontend display adapter")
|
||||
@@ -308,12 +309,14 @@ def _skill_to_response(
|
||||
builtin = skill.category != SkillCategory.CUSTOM
|
||||
detail: str | None = None
|
||||
name_zh: str | None = None
|
||||
square_id = ""
|
||||
if ownership is not None:
|
||||
owner_user_id = ownership.get("owner_user_id")
|
||||
published = bool(ownership.get("published", False))
|
||||
builtin = bool(ownership.get("builtin", owner_user_id is None))
|
||||
detail = ownership.get("detail")
|
||||
name_zh = ownership.get("name_zh")
|
||||
square_id = str(ownership.get("square_id") or "")
|
||||
|
||||
display = None if display_override is _UNSET else display_override
|
||||
if display_override is _UNSET:
|
||||
@@ -342,6 +345,7 @@ def _skill_to_response(
|
||||
source=entry.get("source", "upload"),
|
||||
owner_user_id=owner_user_id,
|
||||
published=published,
|
||||
square_id=square_id,
|
||||
builtin=builtin,
|
||||
detail=detail,
|
||||
display=display,
|
||||
@@ -772,6 +776,7 @@ async def list_skills(
|
||||
tag_store: TagStore = Depends(get_tag_store),
|
||||
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name or Chinese name (name_zh)"),
|
||||
tag_ids: list[str] | None = Query(default=None, description="Keep skills carrying any of these tags (OR)"),
|
||||
square: str | None = Query(default=None, description="Keep only skills filed under this publish square (发布广场). Omit for all squares."),
|
||||
sort: str = Query(
|
||||
default="time_desc",
|
||||
pattern="^(time_desc|time_asc|activity_desc|activity_asc|usage_desc|usage_asc|name_asc|name_desc)$",
|
||||
@@ -831,6 +836,16 @@ async def list_skills(
|
||||
)
|
||||
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
|
||||
responses = await _attach_owner_usernames(responses)
|
||||
square_id = square.strip() if square and square.strip() else None
|
||||
if square_id:
|
||||
from deerflow.config.system_settings import load_system_settings, resolve_default_square_id
|
||||
|
||||
square_is_default = square_id == resolve_default_square_id(load_system_settings().publish_squares)
|
||||
responses = [
|
||||
r
|
||||
for r in responses
|
||||
if (r.square_id or "") == square_id or (square_is_default and not (r.square_id or ""))
|
||||
]
|
||||
responses = _sort_skill_responses(responses, sort)
|
||||
return SkillsListResponse(skills=responses)
|
||||
except HTTPException:
|
||||
@@ -1366,12 +1381,14 @@ class SkillPublishRequest(BaseModel):
|
||||
"""Request body for toggling a custom skill's published flag."""
|
||||
|
||||
published: bool = Field(..., description="Whether to publish (True) or unpublish (False)")
|
||||
square_id: str | None = Field(default=None, description="Publish square (发布广场) to file the skill under when publishing. Omit / empty = the default square.")
|
||||
|
||||
|
||||
class SkillPublishResponse(BaseModel):
|
||||
name: str
|
||||
owner_user_id: str | None
|
||||
published: bool
|
||||
square_id: str = ""
|
||||
|
||||
|
||||
@router.put(
|
||||
@@ -1393,10 +1410,18 @@ async def update_custom_skill_published(
|
||||
record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
|
||||
|
||||
was_published = bool(record.get("published", False))
|
||||
# When publishing, also file the skill into the chosen square (normalized
|
||||
# to a valid configured square id; default when unset). Only one square
|
||||
# per skill — a single column, so it can never be "published twice".
|
||||
update_fields: dict[str, Any] = {"published": body.published}
|
||||
if body.published:
|
||||
from deerflow.config.system_settings import load_system_settings, normalize_square_id
|
||||
|
||||
update_fields["square_id"] = normalize_square_id(body.square_id, load_system_settings().publish_squares)
|
||||
if mode == "admin":
|
||||
updated = await skill_store.update_admin(skill_name, {"published": body.published})
|
||||
updated = await skill_store.update_admin(skill_name, update_fields)
|
||||
else:
|
||||
updated = await skill_store.update(skill_name, user_id, {"published": body.published})
|
||||
updated = await skill_store.update(skill_name, user_id, update_fields)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to persist published state")
|
||||
|
||||
@@ -1419,6 +1444,7 @@ async def update_custom_skill_published(
|
||||
name=updated["name"],
|
||||
owner_user_id=updated.get("owner_user_id"),
|
||||
published=bool(updated.get("published", False)),
|
||||
square_id=str(updated.get("square_id") or ""),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -2762,14 +2788,15 @@ class CuratorConfigResponse(BaseModel):
|
||||
|
||||
|
||||
class CuratorConfigRequest(BaseModel):
|
||||
"""Payload for updating skill_evolution overrides.
|
||||
"""Payload for updating user-facing curator settings in skill_evolution.json.
|
||||
|
||||
All fields are required so the frontend always sends a complete config;
|
||||
backend writes the entire payload to skill_evolution.json atomically.
|
||||
All other settings (enabled, timeouts) are global-only and controlled via config.yaml.
|
||||
"""
|
||||
|
||||
enabled: bool = Field(..., description="Whether the agent can create and modify skills under skills/custom.")
|
||||
curator: CuratorSubConfig
|
||||
interval_hours: int = Field(..., ge=1, description="Hours between curator runs.")
|
||||
stale_after_days: int = Field(..., ge=1, description="Days of inactivity before a skill is marked stale.")
|
||||
archive_after_days: int = Field(..., ge=1, description="Days of inactivity before a stale skill is archived.")
|
||||
model_name: str | None = Field(default=None, description="Model for the curator LLM review; null = primary model.")
|
||||
|
||||
|
||||
class CuratorLogRecord(BaseModel):
|
||||
@@ -2785,11 +2812,7 @@ class CuratorLogsResponse(BaseModel):
|
||||
|
||||
|
||||
def _build_curator_config_response(evo) -> CuratorConfigResponse:
|
||||
"""Pure helper so both the GET route and the post-PUT refresh share one shape.
|
||||
|
||||
``evo`` is a ``SkillEvolutionConfig`` instance (already merged with per-user
|
||||
overrides if any).
|
||||
"""
|
||||
"""Pure helper so both the GET route and the post-PUT refresh share one shape."""
|
||||
return CuratorConfigResponse(
|
||||
enabled=evo.enabled,
|
||||
curator=CuratorSubConfig(
|
||||
@@ -2805,8 +2828,7 @@ def _build_curator_config_response(evo) -> CuratorConfigResponse:
|
||||
|
||||
@router.get("/curator/config", response_model=CuratorConfigResponse, summary="Get Skill Evolution Config")
|
||||
async def get_curator_config(request: Request) -> CuratorConfigResponse:
|
||||
"""Return the current curator config for the authenticated user (yaml
|
||||
defaults + that user's overrides)."""
|
||||
"""Return the current curator config for the authenticated user (yaml defaults + per-user overrides)."""
|
||||
try:
|
||||
from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user
|
||||
|
||||
@@ -2819,21 +2841,22 @@ async def get_curator_config(request: Request) -> CuratorConfigResponse:
|
||||
|
||||
@router.put("/curator/config", response_model=CuratorConfigResponse, summary="Update Skill Evolution Config")
|
||||
async def update_curator_config(body: CuratorConfigRequest, request: Request) -> CuratorConfigResponse:
|
||||
"""Persist per-user overrides to ``users/{user_id}/skill_evolution.json``
|
||||
and return the effective merged config.
|
||||
"""Persist the three user-facing curator thresholds to the per-user ``skill_evolution.json``.
|
||||
|
||||
The payload is validated against the ``SkillEvolutionConfig`` shape; unknown
|
||||
fields are rejected.
|
||||
Other settings (enabled, model, timeouts) are global and controlled via config.yaml.
|
||||
"""
|
||||
try:
|
||||
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
|
||||
from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user, save_user_overrides
|
||||
|
||||
user_id = _current_user_id(request)
|
||||
payload = body.model_dump()
|
||||
# Round-trip through SkillEvolutionConfig to ensure persisted shape stays valid.
|
||||
SkillEvolutionConfig.model_validate(payload)
|
||||
save_user_overrides(user_id, payload)
|
||||
save_user_overrides(user_id, {
|
||||
"curator": {
|
||||
"interval_hours": body.interval_hours,
|
||||
"stale_after_days": body.stale_after_days,
|
||||
"archive_after_days": body.archive_after_days,
|
||||
"model_name": body.model_name,
|
||||
},
|
||||
})
|
||||
return _build_curator_config_response(load_skill_evolution_config_for_user(user_id))
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -14,8 +14,12 @@ from deerflow.config import get_app_config
|
||||
from deerflow.config.system_settings import (
|
||||
LeaderboardSettings,
|
||||
PromptPrefixSettings,
|
||||
PublishSquare,
|
||||
PublishSquaresSettings,
|
||||
SystemSettings,
|
||||
list_publish_squares,
|
||||
load_system_settings,
|
||||
resolve_default_square_id,
|
||||
save_system_settings,
|
||||
)
|
||||
|
||||
@@ -219,6 +223,88 @@ async def update_private_square(body: PrivateSquareUpdateRequest, request: Reque
|
||||
)
|
||||
|
||||
|
||||
class PublishSquareItem(BaseModel):
|
||||
id: str = Field(..., description="Stable square id")
|
||||
name: str = Field(..., description="Display name / tab label")
|
||||
description: str = Field(default="", description="Optional description")
|
||||
|
||||
|
||||
class PublishSquaresResponse(BaseModel):
|
||||
"""The configured publish squares plus the resolved default.
|
||||
|
||||
Readable by any logged-in user — squares are pure category tabs with no
|
||||
access control, so the gallery needs the list to render its sub-tabs and
|
||||
the publish flow needs it to populate the square picker.
|
||||
"""
|
||||
|
||||
squares: list[PublishSquareItem] = Field(default_factory=list)
|
||||
default_square_id: str = Field(..., description="Square id used when a publisher picks none")
|
||||
|
||||
|
||||
class PublishSquaresUpdateRequest(BaseModel):
|
||||
squares: list[PublishSquareItem] | None = Field(default=None, description="Full ordered replacement list of squares")
|
||||
default_square_id: str | None = Field(default=None, description="Which square id is the publish default")
|
||||
|
||||
|
||||
def _publish_squares_response(settings: PublishSquaresSettings) -> PublishSquaresResponse:
|
||||
squares = list_publish_squares(settings)
|
||||
return PublishSquaresResponse(
|
||||
squares=[PublishSquareItem(id=s.id, name=s.name, description=s.description) for s in squares],
|
||||
default_square_id=resolve_default_square_id(settings),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/publish-squares", response_model=PublishSquaresResponse)
|
||||
async def get_publish_squares() -> PublishSquaresResponse:
|
||||
"""Return the configured publish squares (readable by any logged-in user)."""
|
||||
return _publish_squares_response(load_system_settings().publish_squares)
|
||||
|
||||
|
||||
@router.put("/publish-squares", response_model=PublishSquaresResponse)
|
||||
async def update_publish_squares(body: PublishSquaresUpdateRequest, request: Request) -> PublishSquaresResponse:
|
||||
"""Replace the publish-squares configuration. Admin only.
|
||||
|
||||
``squares`` is a full ordered replacement. Each square needs a non-empty
|
||||
name; a missing id is auto-generated from a slug of the name. Duplicate ids
|
||||
are de-duplicated (first wins). ``default_square_id`` must reference one of
|
||||
the surviving squares, else it falls back to the first.
|
||||
"""
|
||||
from uuid import uuid4
|
||||
|
||||
await _require_admin(request)
|
||||
|
||||
current = load_system_settings()
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
update: dict[str, object] = {}
|
||||
|
||||
if "squares" in fields and fields["squares"] is not None:
|
||||
normalized: list[PublishSquare] = []
|
||||
seen_ids: set[str] = set()
|
||||
for raw in body.squares or []:
|
||||
name = (raw.name or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
sid = (raw.id or "").strip() or uuid4().hex[:8]
|
||||
if sid in seen_ids:
|
||||
continue
|
||||
seen_ids.add(sid)
|
||||
normalized.append(PublishSquare(id=sid, name=name, description=(raw.description or "").strip()))
|
||||
update["squares"] = normalized
|
||||
|
||||
if "default_square_id" in fields and fields["default_square_id"] is not None:
|
||||
update["default_square_id"] = str(fields["default_square_id"]).strip()
|
||||
|
||||
if update:
|
||||
updated = current.publish_squares.model_copy(update=update)
|
||||
# Re-anchor the default to a surviving square id.
|
||||
if updated.squares and updated.default_square_id not in {s.id for s in updated.squares}:
|
||||
updated = updated.model_copy(update={"default_square_id": updated.squares[0].id})
|
||||
saved = save_system_settings(current.model_copy(update={"publish_squares": updated}))
|
||||
else:
|
||||
saved = current
|
||||
return _publish_squares_response(saved.publish_squares)
|
||||
|
||||
|
||||
class AppearanceResponse(BaseModel):
|
||||
"""Effective system-wide appearance defaults."""
|
||||
|
||||
|
||||
@@ -10,90 +10,134 @@ gateway:
|
||||
port: 8001
|
||||
|
||||
models:
|
||||
# - name: deepseek-chat
|
||||
# display_name: DeepSeek Chat
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: deepseek-chat
|
||||
# api_key: sk-1520028668cb4cc388ed8315ff1e856e
|
||||
# base_url: https://api.deepseek.com
|
||||
# request_timeout: 600.0
|
||||
# max_retries: 2
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: false
|
||||
# supports_thinking: true
|
||||
|
||||
# - name: deepseek-v4-flash
|
||||
# display_name: DeepSeek V4 Flash
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: deepseek-v4-flash
|
||||
# api_key: sk-e1c22b7931dc446f8b67e57878edd105
|
||||
# base_url: https://api.deepseek.com
|
||||
# request_timeout: 600.0
|
||||
# max_retries: 2
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: false
|
||||
# supports_thinking: true
|
||||
|
||||
# - name: deepseek-v4-pro
|
||||
# display_name: DeepSeek V4 Pro
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: deepseek-v4-pro
|
||||
# api_key: sk-e1c22b7931dc446f8b67e57878edd105
|
||||
# base_url: https://api.deepseek.com
|
||||
# request_timeout: 600.0
|
||||
# max_retries: 2
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: false
|
||||
# supports_thinking: true
|
||||
# - name: MiniMax-M2.7
|
||||
# display_name: MiniMax-M2.7
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: MiniMax-M2.7
|
||||
# api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
|
||||
# base_url: https://api.minimaxi.com/v1
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: true
|
||||
# supports_thinking: true
|
||||
# - name: glm-5.1
|
||||
# display_name: glm-5.1
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: glm-5.1
|
||||
# api_key: 75121e4bed7640d5b71ac95cf8b4310a.RA3GyRBAR3kv7VrR
|
||||
# base_url: https://open.bigmodel.cn/api/paas/v4/
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: true
|
||||
# supports_thinking: true
|
||||
# - name: deepseek-ai/DeepSeek-OCR
|
||||
# display_name: deepseek-ai/DeepSeek-OCR
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: deepseek-ai/DeepSeek-OCR
|
||||
# api_key: sk-beshfxfxfrllsczpqfrgjbugfbpzxgqgedbehceytpcvvtyb
|
||||
# base_url: https://api.siliconflow.cn/v1/
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: true
|
||||
# supports_thinking: true
|
||||
# - name: zai-org/GLM-5-FP8
|
||||
# display_name: zai-org/GLM-5-FP8
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: zai-org/GLM-5-FP8
|
||||
# api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
|
||||
# base_url: https://api.us-west-2.modal.direct/v1
|
||||
# request_timeout: 600.0
|
||||
# max_retries: 2
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: true
|
||||
# supports_thinking: true
|
||||
- name: deepseek-ai/DeepSeek-V4-Flash
|
||||
display_name: deepseek-ai/DeepSeek-V4-Flash
|
||||
- name: deepseek-chat
|
||||
display_name: DeepSeek Chat
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-ai/DeepSeek-V4-Flash
|
||||
model: deepseek-chat
|
||||
api_key: sk-3fee562f834a4c08bb0d471ef44cb445
|
||||
base_url: https://api.deepseek.com
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: false
|
||||
supports_thinking: true
|
||||
|
||||
- name: zai-org/GLM-5-FP8
|
||||
display_name: zai-org/GLM-5-FP8
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: zai-org/GLM-5-FP8
|
||||
api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
|
||||
base_url: https://api.us-west-2.modal.direct/v1
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
|
||||
|
||||
- name: deepseek-v4-flash
|
||||
display_name: DeepSeek V4 Flash
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-v4-flash
|
||||
api_key: sk-3fee562f834a4c08bb0d471ef44cb445
|
||||
base_url: https://api.deepseek.com
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: false
|
||||
supports_thinking: true
|
||||
|
||||
- name: deepseek-v4-pro
|
||||
display_name: DeepSeek V4 Pro
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-v4-pro
|
||||
api_key: sk-e1c22b7931dc446f8b67e57878edd105
|
||||
base_url: https://api.deepseek.com
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: false
|
||||
supports_thinking: true
|
||||
- name: MiniMax-M2.7
|
||||
display_name: MiniMax-M2.7
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: MiniMax-M2.7
|
||||
api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
|
||||
base_url: https://api.minimaxi.com/v1
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: glm-5.1
|
||||
display_name: glm-5.1
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: glm-5.1
|
||||
api_key: 75121e4bed7640d5b71ac95cf8b4310a.RA3GyRBAR3kv7VrR
|
||||
base_url: https://open.bigmodel.cn/api/paas/v4/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: deepseek-ai/DeepSeek-OCR
|
||||
display_name: deepseek-ai/DeepSeek-OCR
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-ai/DeepSeek-OCR
|
||||
api_key: sk-beshfxfxfrllsczpqfrgjbugfbpzxgqgedbehceytpcvvtyb
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: zai-org/GLM-5-FP8
|
||||
display_name: zai-org/GLM-5-FP8
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: zai-org/GLM-5-FP8
|
||||
api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
|
||||
base_url: https://api.us-west-2.modal.direct/v1
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: Qwen/Qwen3.6-27B
|
||||
display_name: Qwen/Qwen3.6-27B
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: Qwen/Qwen3.6-27B
|
||||
api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: Pro/MiniMaxAI/MiniMax-M2.5
|
||||
display_name: Pro/MiniMaxAI/MiniMax-M2.5
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: Pro/MiniMaxAI/MiniMax-M2.5
|
||||
api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: inclusionAI/Ling-flash-2.0
|
||||
display_name: inclusionAI/Ling-flash-2.0
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: inclusionAI/Ling-flash-2.0
|
||||
api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: deepseek-ai/DeepSeek-V4-Pro
|
||||
display_name: deepseek-ai/DeepSeek-V4-Pro
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-ai/DeepSeek-V4-Pro
|
||||
api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
@@ -122,26 +166,6 @@ uploads:
|
||||
auto_convert_documents: true
|
||||
pdf_converter: auto
|
||||
|
||||
# Chrome 插件「当前网页发送到 Deer」入口。默认关闭,打开后才启用
|
||||
# /api/browser-context/capture。它只保存当前页 Markdown 到线程 uploads,
|
||||
# 不会改变普通聊天、上传或工具调用流程。
|
||||
browser_context:
|
||||
enabled: true
|
||||
max_content_chars: 200000
|
||||
max_title_chars: 300
|
||||
max_url_chars: 2048
|
||||
filename_prefix: webpage
|
||||
route_base: /page/workspace
|
||||
task_result_timeout_seconds: 60
|
||||
task_poll_interval_seconds: 1
|
||||
task_lease_seconds: 120
|
||||
tool_result_max_chars: 60000
|
||||
# 交互式页面操作(browser_act:点击/输入/滚动/等待/跳转)。关闭后只保留只读抓取。
|
||||
actions_enabled: true
|
||||
action_result_timeout_seconds: 45
|
||||
max_observed_elements: 150
|
||||
observation_text_max_chars: 12000
|
||||
|
||||
title:
|
||||
enabled: true
|
||||
max_words: 6
|
||||
@@ -159,7 +183,6 @@ title:
|
||||
|
||||
tool_groups:
|
||||
- name: web
|
||||
- name: browser
|
||||
- name: file:read
|
||||
- name: file:write
|
||||
- name: bash
|
||||
@@ -175,14 +198,6 @@ tools:
|
||||
use: deerflow.community.jina_ai.tools:web_fetch_tool
|
||||
timeout: 15
|
||||
|
||||
- name: browser_fetch_page
|
||||
group: browser
|
||||
use: deerflow.tools.builtins.browser_context_tool:browser_fetch_page_tool
|
||||
|
||||
- name: browser_act
|
||||
group: browser
|
||||
use: deerflow.tools.builtins.browser_act_tool:browser_act_tool
|
||||
|
||||
- name: ls
|
||||
group: file:read
|
||||
use: deerflow.sandbox.tools:ls_tool
|
||||
@@ -216,12 +231,28 @@ tools:
|
||||
sandbox:
|
||||
use: deerflow.sandbox.local:LocalSandboxProvider
|
||||
allow_host_bash: true
|
||||
mounts:
|
||||
- host_path: C:\Users\iie\Desktop\deerflow-new\offline-backend-20260512\backend\knowledge\knowledge_base
|
||||
container_path: /mnt/knowledge
|
||||
read_only: true
|
||||
|
||||
skills:
|
||||
path: skills
|
||||
security_scan:
|
||||
enabled: false
|
||||
|
||||
skill_evolution:
|
||||
enabled: true
|
||||
curator:
|
||||
enabled: true # 每 24 小时自动清理 + 合并 skill
|
||||
interval_hours: 24
|
||||
review_worker:
|
||||
enabled: true # 被动复盘开关
|
||||
min_tool_calls: 5 # 触发阈值:本轮至少 5 次工具调用,与 prompt 中"≥5 步"硬条件一致
|
||||
max_messages: 60 # 传给 LLM 的最大消息条数
|
||||
timeout_seconds: 120 # 单次 review 超时(秒)
|
||||
model_name: # 留空则使用主模型
|
||||
|
||||
agents_api:
|
||||
enabled: true
|
||||
|
||||
@@ -252,7 +283,7 @@ auth_login:
|
||||
|
||||
database:
|
||||
backend: sqlite
|
||||
sqlite_dir: /app/backend/.deer-flow/data
|
||||
sqlite_dir: .deer-flow/data
|
||||
pool_size: 20 # 基础连接池(postgres/mysql 生效;sqlite 忽略)
|
||||
max_overflow: 20 # 高峰期可临时超出 pool_size 的额外连接数,真实上限 = pool_size + max_overflow
|
||||
pool_timeout: 30 # 池满时等待空闲连接的秒数,超时报错
|
||||
@@ -260,7 +291,7 @@ database:
|
||||
|
||||
checkpointer:
|
||||
type: sqlite
|
||||
connection_string: /app/backend/.deer-flow/data/deerflow.db
|
||||
connection_string: .deer-flow/data/deerflow.db
|
||||
|
||||
# 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
|
||||
# 三层覆盖优先级(高->低):
|
||||
@@ -342,39 +373,3 @@ log_system:
|
||||
enabled: true
|
||||
url: "https://your-log-system.example.com"
|
||||
label: "日志系统"
|
||||
|
||||
# 产品内知识库 —— obsidian-wiki 兼容 vault。知识元数据写入数据库,正文
|
||||
# content_md 写入 Markdown vault(index.md/log.md/hot.md/.manifest.json/frontmatter/
|
||||
# wikilinks/wiki-export 图谱产物)。知识库为全局共享,不做 user_id 隔离,
|
||||
# 写入时记录 created_by / updated_by 作为审计。
|
||||
knowledge:
|
||||
enabled: true
|
||||
provider: obsidian_wiki
|
||||
vault_dir: data/knowledge/obsidian-vault
|
||||
project_name: zncm
|
||||
auto_ingest_enabled: false
|
||||
auto_ingest_min_chars: 300
|
||||
# 用大模型按 wiki-capture 规范提炼陈述式知识(而非堆砌对话原文)。
|
||||
# 关闭则回退到规则抽取;extract_model_name 留空时用 models[0]。
|
||||
llm_extract_enabled: true
|
||||
extract_model_name: ""
|
||||
extract_max_input_chars: 12000
|
||||
max_thread_messages: 80
|
||||
max_source_items: 20
|
||||
rag_enabled: false
|
||||
rag_limit: 5
|
||||
# 阶段三:写入前脱敏(token/密钥/手机号/身份证/邮箱);重复检测相似度阈值。
|
||||
sensitive_redaction_enabled: true
|
||||
dedup_similarity_threshold: 0.82
|
||||
export:
|
||||
enabled: true
|
||||
graph_html_enabled: true
|
||||
graph_json_enabled: true
|
||||
query:
|
||||
use_hot_md: true
|
||||
use_index_md: true
|
||||
use_frontmatter_scan: true
|
||||
embedding:
|
||||
enabled: false
|
||||
provider: local
|
||||
model: ""
|
||||
@@ -4,13 +4,26 @@ DeerFlow includes automatic conversation summarization to handle long conversati
|
||||
|
||||
## Overview
|
||||
|
||||
The summarization feature uses LangChain's `SummarizationMiddleware` to monitor conversation history and trigger summarization based on configurable thresholds. When activated, it:
|
||||
The summarization feature uses DeerFlow's `DeerFlowSummarizationMiddleware` (a subclass of LangChain's `SummarizationMiddleware`) to monitor conversation history and trigger summarization based on configurable thresholds. When activated, it:
|
||||
|
||||
1. Monitors message token counts in real-time
|
||||
2. Triggers summarization when thresholds are met
|
||||
3. Keeps recent messages intact while summarizing older exchanges
|
||||
4. Maintains AI/Tool message pairs together for context continuity
|
||||
5. Injects the summary back into the conversation
|
||||
5. Substitutes the summary for the older messages **in the model request only**
|
||||
|
||||
### Non-destructive (display-preserving) compression
|
||||
|
||||
> **Important:** DeerFlow compresses **transiently**, not destructively.
|
||||
|
||||
LangChain's stock `SummarizationMiddleware` runs in `before_model` and persists a `RemoveMessage(REMOVE_ALL_MESSAGES)` to the checkpoint — which permanently deletes the original Q&A from both the model context *and* the conversation the frontend renders. DeerFlow overrides this:
|
||||
|
||||
- `before_model` / `abefore_model` are **no-ops** (the destructive path is disabled).
|
||||
- Compression runs in `wrap_model_call` / `awrap_model_call`: for a single model call, the message list is replaced with `[summary, *recent]` via `request.override(messages=...)`. **Nothing is written back to state.**
|
||||
- The full original messages stay in the checkpoint, so the frontend keeps showing the user's real conversation. The summary message is named `summary` and flagged `hide_from_ui`, and because it only ever lives in the transient request it never reaches the frontend at all.
|
||||
- The generated summary is cached **per-thread** so we don't pay an extra LLM call on every model invocation. The cached summary is reused (no LLM call) until enough *new* messages accumulate to push the compressed context back over the trigger threshold (a sticky boundary mirroring the stock middleware's post-compaction behavior). The cache is bounded (oldest threads evicted first).
|
||||
|
||||
> **Legacy threads:** conversations compacted by an older (destructive) build already lost their original messages and carry a persisted `summary` message. The frontend hides any `name: "summary"` message, so the stray summary bubble no longer shows — but those already-discarded messages cannot be recovered.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -169,10 +182,7 @@ The default LangChain prompt instructs the model to:
|
||||
- Messages to summarize (older messages beyond the `keep` threshold)
|
||||
- Messages to preserve (recent messages within the `keep` threshold)
|
||||
4. **Summary Generation**: The model generates a concise summary of the older messages
|
||||
5. **Context Replacement**: The message history is updated:
|
||||
- All old messages are removed
|
||||
- A single summary message is added
|
||||
- Recent messages are preserved
|
||||
5. **Context Replacement (transient)**: The message list **handed to the model for this call** is rebuilt as `[summary, *recent]`. The persisted state is left untouched — old messages are **not** removed, so the frontend still renders the full original conversation.
|
||||
6. **AI/Tool Pair Protection**: The system ensures AI messages and their corresponding tool messages stay together
|
||||
7. **Skill Rescue**: Before the summary is generated, the most recently loaded skill files (tool results whose tool name is in `skill_file_read_tool_names` and whose target path is under `skills.container_path`) are lifted out of the summarization set and prepended to the preserved tail. Selection walks newest-first under three budgets: `preserve_recent_skill_count`, `preserve_recent_skill_tokens`, and `preserve_recent_skill_tokens_per_skill`. The triggering AIMessage and all of its paired ToolMessages move together so tool_call ↔ tool_result pairing stays intact.
|
||||
|
||||
@@ -298,8 +308,9 @@ The middleware intelligently preserves message context:
|
||||
### Code Structure
|
||||
|
||||
- **Configuration**: `packages/harness/deerflow/config/summarization_config.py`
|
||||
- **Integration**: `packages/harness/deerflow/agents/lead_agent/agent.py`
|
||||
- **Middleware**: Uses `langchain.agents.middleware.SummarizationMiddleware`
|
||||
- **Integration**: `packages/harness/deerflow/agents/lead_agent/agent.py` (`_create_summarization_middleware`)
|
||||
- **Middleware**: `packages/harness/deerflow/agents/middlewares/summarization_middleware.py` — `DeerFlowSummarizationMiddleware`, subclassing `langchain.agents.middleware.SummarizationMiddleware` but overriding compaction to be transient (`wrap_model_call`) instead of destructive (`before_model`)
|
||||
- **Tests**: `tests/test_summarization_middleware.py`
|
||||
|
||||
### Middleware Order
|
||||
|
||||
@@ -313,9 +324,9 @@ Summarization runs after ThreadData and Sandbox initialization but before Title
|
||||
|
||||
### State Management
|
||||
|
||||
- Summarization is stateless - configuration is loaded once at startup
|
||||
- Summaries are added as regular messages in the conversation history
|
||||
- The checkpointer persists the summarized history automatically
|
||||
- Configuration is loaded once at startup
|
||||
- Summarization is **non-destructive**: the summary replaces older messages only in the transient model request (`wrap_model_call`), never in persisted state. The checkpointer therefore keeps the full original conversation
|
||||
- The per-thread summary cache lives in the middleware instance (in-memory, bounded). It is a cost optimization only — a cold/stale cache simply re-summarizes, which is always correct
|
||||
|
||||
## Example Configurations
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"code-retrieval": { "enabled": true },
|
||||
"news-briefing": { "enabled": true },
|
||||
"workspace-search": { "enabled": true },
|
||||
"policy-knowledge": { "enabled": true },
|
||||
"policy-knowledge": { "enabled": false },
|
||||
"html-page-builder": { "enabled": true },
|
||||
"html-charts": { "enabled": true }
|
||||
}
|
||||
|
||||
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user