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