diff --git a/.env.example b/.env.example index bae9572..902a066 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,10 @@ # Database +APP_ENVIRONMENT=development +SECURITY_STRICT_MODE=false +CORS_ALLOWED_ORIGINS=http://localhost:8102,http://127.0.0.1:8102 +TRUSTED_HOSTS=* +SECURITY_HEADERS_ENABLED=true + POSTGRES_USER=admin POSTGRES_PASSWORD=password POSTGRES_DB=kg_admin @@ -6,6 +12,36 @@ DATABASE_URL=postgresql://admin:password@postgres:5432/kg_admin DB_SCHEMA=kg_admin_new2 DB_MIGRATIONS_ENABLED=false +# Data Center MySQL (independent from graph/system PostgreSQL) +MYSQL_ROOT_PASSWORD=change-this-root-password +MYSQL_DATABASE=platform_control +MYSQL_USER=data_center +MYSQL_PASSWORD=change-this-data-password +DATA_MYSQL_URL=mysql://data_center:change-this-data-password@mysql:3306/platform_control +DATA_MYSQL_REQUIRED=true +DATA_MYSQL_POOL_MIN_SIZE=2 +DATA_MYSQL_POOL_MAX_SIZE=20 +DATA_SQL_CONSOLE_WRITE_ENABLED=false +INTERFACE_API_SECRET=change-me-interface-api-secret +DATA_MYSQL_DIRECT_ACCESS_ENABLED=false +# Legacy fallback used by older deployments. Prefer DATA_MYSQL_SSH_HOST. +DATA_MYSQL_PUBLIC_HOST= +DATA_MYSQL_PUBLIC_PORT=0 +DATA_MYSQL_DIRECT_TRANSPORT=SSH 隧道(强制) +DATA_MYSQL_SSH_TUNNEL_REQUIRED=true +DATA_MYSQL_SSH_HOST= +DATA_MYSQL_SSH_PORT=22 +DATA_MYSQL_SSH_AUTH_METHOD=SSH 私钥 / SSH Agent +DATA_MYSQL_ADMIN_HOST=127.0.0.1 +DATA_MYSQL_ADMIN_PORT=3307 +DATA_MYSQL_ADMIN_ACCOUNT_POLICY=一人一号 · 单库授权 · 禁止 root +DATA_MYSQL_AUDIT_ENABLED=false +DATA_BACKUP_ENABLED=false +DATA_BACKUP_ENCRYPTION_REQUIRED=true +DATA_BACKUP_RETENTION_DAYS=30 +# 必须位于代码仓库之外,文件权限设为 600,内容至少 32 个随机字符。 +DATA_BACKUP_PASSPHRASE_FILE=/etc/travel-kg/secrets/mysql-backup-passphrase + # FalkorDB FALKORDB_HOST=falkordb FALKORDB_INTERNAL_PORT=6379 @@ -16,9 +52,17 @@ FALKORDB_PASSWORD= # Auth AUTH_SECRET=change-me-32-chars-minimum-secret AUTH_ALGORITHM=HS256 -AUTH_TOKEN_EXPIRE_MINUTES=480 +AUTH_ISSUER=zn-kg-admin +AUTH_AUDIENCE=zn-kg-admin-web +AUTH_TOKEN_EXPIRE_MINUTES=60 AUTH_DEFAULT_USERNAME=admin@example.com AUTH_DEFAULT_PASSWORD=change-me +AUTH_LOGIN_MAX_ATTEMPTS=5 +AUTH_LOGIN_WINDOW_SECONDS=300 +AUTH_LOGIN_LOCK_SECONDS=900 +INTERFACE_API_DEFAULT_EXPIRY_DAYS=30 +INTERFACE_API_MAX_EXPIRY_DAYS=90 +INTERFACE_API_RATE_LIMIT_PER_MINUTE=120 # LLM, optional LLM_PROVIDER=openai-compatible @@ -54,6 +98,8 @@ API_HOST_BIND=0.0.0.0 API_PORT=8102 POSTGRES_HOST_BIND=127.0.0.1 POSTGRES_PORT=5433 +MYSQL_HOST_BIND=127.0.0.1 +MYSQL_PORT=3307 FALKORDB_HOST_BIND=127.0.0.1 FALKORDB_PORT=6380 FALKORDB_BROWSER_HOST_BIND=127.0.0.1 diff --git a/.gitignore b/.gitignore index 987bb7b..30d5fd7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +.claude/ .env .env.* !.env.example @@ -21,6 +22,10 @@ admin-web/tsconfig.tsbuildinfo logs/ tmp/ .codex-tmp/ +backups/ +*.sql.gz.enc +*.sql.gz.enc.sha256 +*.sql.gz.enc.json *.log *.pid app/static/admin/ diff --git a/Dockerfile b/Dockerfile index c7b395a..d3811a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,12 @@ COPY app ./app COPY schema搭建 ./schema搭建 COPY --from=admin-web-build /build/app/static/admin ./app/static/admin +RUN addgroup --system appgroup \ + && adduser --system --ingroup appgroup --no-create-home appuser \ + && chown -R appuser:appgroup /app + +USER appuser + EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ diff --git a/GRAPH_PROJECT_LIFECYCLE_HANDOFF.md b/GRAPH_PROJECT_LIFECYCLE_HANDOFF.md new file mode 100644 index 0000000..dc64988 --- /dev/null +++ b/GRAPH_PROJECT_LIFECYCLE_HANDOFF.md @@ -0,0 +1,279 @@ +# 知识图谱项目生命周期改造——新对话对接文档 + +> 新对话执行指令:请先完整阅读本文件,再从当前代码状态继续实现。不要重新设计,不要修改或删除 PostgreSQL 数据中心及其业务数据。 + +## 1. 当前任务目标 + +完善 `http://localhost:8102/admin/projects` 的知识图谱项目管理,使一个全新的图谱项目能够通过页面完成: + +1. 创建项目基本信息。 +2. 导入或编辑本体 Schema。 +3. 导入节点与关系数据。 +4. 校验并创建项目及 FalkorDB 图谱。 +5. 右键项目卡片进行重命名或安全删除。 + +本任务只处理“知识图谱项目生命周期”,不改造关系型数据库数据中心。 + +## 2. 关键架构边界 + +系统中的两类数据相互独立,用途不同: + +- 知识图谱项目:项目元数据、本体 Schema、图版本和 FalkorDB 图谱,用于图谱管理与可视化。 +- PostgreSQL 数据中心:`/admin/data` 下的正式关系型业务数据和 `biz_*` Schema,用于表格化数据管理。 + +删除知识图谱项目时,严禁删除或修改: + +- `/admin/data` 中的数据库、数据表和记录。 +- PostgreSQL 数据平台注册信息。 +- 任意 `biz_*` Schema。 +- 与其他图谱项目共享的 FalkorDB 图。 + +## 3. 当前代码位置 + +项目目录:`/Users/xuexue/new2` + +主要文件: + +- 后端项目接口:`/Users/xuexue/new2/app/api/projects.py` +- 前端图谱中心:`/Users/xuexue/new2/admin-web/src/ProjectLanding.tsx` +- 前端 API 封装:`/Users/xuexue/new2/admin-web/src/api.ts` +- 前端样式:`/Users/xuexue/new2/admin-web/src/styles.css` + +当前已有接口: + +- `GET /projects` +- `POST /projects` +- `GET /projects/{project_id}/graph-releases` +- `POST /projects/{project_id}/graph-releases` + +目前还没有完整的创建引导、重命名、删除影响分析和级联清理接口。 + +## 4. 新建项目引导流程 + +点击“创建项目”后使用四步向导,建议使用 Ant Design `Steps`: + +### 第一步:基本信息 + +必填字段: + +- `project_id`:项目稳定标识,创建后不允许修改。 +- `display_name`:项目显示名称,允许后续重命名。 +- `tenant_id`:租户标识。 +- `graph_name`:FalkorDB 图名称。 + +校验要求: + +- `project_id` 和 `graph_name` 只允许安全字符。 +- 检查项目、图名称是否已存在。 +- 不允许覆盖已有项目或图。 + +### 第二步:本体 Schema + +支持以下两种方式: + +- 上传 JSON 文件。 +- 在文本编辑器中直接粘贴或修改 JSON。 + +参考结构: + +```json +{ + "namespace": "project_id", + "version": "1.0.0", + "entity_types": { + "Place": { + "label": "地点", + "fields": { + "name": { "type": "string", "required": true } + } + } + }, + "relation_types": { + "RELATED_TO": { + "from": "Place", + "to": "Place", + "properties": {} + } + } +} +``` + +必须校验:JSON 格式、实体类型、关系两端类型、字段定义和版本信息。 + +### 第三步:图谱数据 + +支持上传或粘贴节点、关系 JSON: + +```json +{ + "nodes": [ + { + "id": "node-1", + "type": "Place", + "properties": { "name": "示例地点" } + } + ], + "relations": [ + { + "type": "RELATED_TO", + "source": "node-1", + "target": "node-2", + "properties": {} + } + ] +} +``` + +导入前必须校验: + +- 节点 ID 不重复。 +- 节点类型在 Schema 中存在。 +- 关系类型在 Schema 中存在。 +- `source`、`target` 节点存在。 +- 属性类型满足 Schema 定义。 + +### 第四步:确认创建 + +展示: + +- 项目信息。 +- Schema 实体类型数、关系类型数。 +- 节点数、关系数。 +- 将创建的 `graph_name`。 + +用户确认后再执行创建。任一步失败时应回滚 PostgreSQL 元数据;FalkorDB 清理采用可重试的补偿机制,并向用户显示明确错误。 + +## 5. 建议后端接口 + +### 一次性创建 + +`POST /projects/provision` + +请求体包含项目基本信息、Schema 和图谱数据。后端依次执行: + +1. 完整校验,不写数据。 +2. 创建项目元数据。 +3. 保存并规范化本体 Schema。 +4. 创建 GraphRelease。 +5. 创建 FalkorDB 图并导入节点、关系。 +6. 发布或激活图版本。 + +### 重命名 + +`PATCH /projects/{project_id}` + +只允许修改 `display_name` 等展示属性,不修改稳定的 `project_id`、PostgreSQL Schema 和 FalkorDB `graph_name`。 + +### 删除影响分析 + +`GET /projects/{project_id}/deletion-impact` + +返回待删除内容的精确数量:项目元数据、图版本、本体定义、任务记录、候选/审核数据,以及是否存在独占 FalkorDB 图。 + +### 删除项目 + +`DELETE /projects/{project_id}?confirm_project_id={project_id}` + +要求用户输入完整 `project_id` 二次确认。后端只删除图谱侧资源: + +- 项目元数据。 +- GraphRelease。 +- 本体 Schema 及规范化本体表记录。 +- 与该项目相关的导入、候选、审核、发布和任务元数据。 +- 仅该项目独占的 FalkorDB 图。 + +若 `graph_name` 被其他项目引用,不删除图,只返回共享提示。 + +## 6. 前端交互要求 + +### 项目卡片 + +- 左键或“进入项目”保持现有行为。 +- 右键项目卡片时调用 `preventDefault()`,显示网页自定义菜单,禁止出现浏览器原生菜单。 +- 菜单项: + - 修改项目名称 + - 删除项目 + +建议使用 Ant Design `Dropdown` 的 `contextMenu` 触发方式。 + +### 重命名 + +- 弹窗中显示不可修改的 `project_id`。 +- 只编辑项目显示名称。 +- 成功后刷新项目卡片,不影响图谱数据。 + +### 删除 + +删除弹窗先调用影响分析接口,明确展示会删除与不会删除的内容。 + +用户必须输入项目 ID 才能启用删除按钮。按钮使用危险色,并明确提示: + +> 只删除知识图谱项目及其独占图谱资源,不删除 PostgreSQL 数据中心业务数据。 + +## 7. 删除范围实现建议 + +不要硬编码猜测表名。先检查当前模型和外键关系,再根据 `project_id` 查询实际关联表。 + +推荐顺序: + +1. 锁定目标项目并再次核对 `project_id`。 +2. 检查 `graph_name` 是否被其他项目引用。 +3. 删除最末端任务、候选、审核和导入元数据。 +4. 删除 GraphRelease 和本体 Schema 相关记录。 +5. 删除项目元数据。 +6. 提交 PostgreSQL 事务。 +7. 删除独占 FalkorDB 图;失败时记录可重试清理任务,不能静默忽略。 + +如果现有数据库约束无法可靠级联,应该显式删除并增加测试,不要使用大范围模糊 SQL。 + +## 8. 验收标准 + +### 创建 + +- 能用一个临时测试项目完成“基本信息 → Schema → 图数据 → 创建”。 +- 创建后项目卡片可见。 +- 能进入项目并查询新图谱。 +- Schema、节点、关系数量正确。 + +### 重命名 + +- 只改变显示名称。 +- `project_id`、`graph_name` 和图数据保持不变。 + +### 删除 + +- 右键显示自定义菜单。 +- 删除前展示影响范围并要求输入项目 ID。 +- 删除测试项目后,其图谱项目元数据和独占 FalkorDB 图被清理。 +- `/admin/data` 的数据库、表数量和记录数完全不变。 +- 共享图不被误删。 + +### 构建与测试 + +至少执行: + +```bash +cd /Users/xuexue/new2/admin-web +npm run build +``` + +同时运行相关 Python 编译检查和后端测试。只能使用新建的临时测试项目验证删除,禁止删除现有项目。 + +## 9. 当前工作区注意事项 + +`/Users/xuexue/new2` 当前已有大量未提交修改和 QA 文件,这些修改属于之前任务,不得重置、覆盖或批量清理。 + +继续实现前先执行: + +```bash +cd /Users/xuexue/new2 +git status --short +``` + +只修改本任务必要文件。不要执行 `git reset --hard`、`git checkout --` 或其他会丢失用户修改的命令。未经用户明确要求,不要提交或推送代码。 + +## 10. 当前交接状态 + +本次仅整理了实施边界和对接方案,没有继续改动项目生命周期代码,以免在超长对话上下文和脏工作区中误改现有成果。 + +新对话应先检查实际数据模型、项目外键和 FalkorDB 客户端封装,再按本文实现;不要重新讨论 PostgreSQL 数据中心设计,也不要把 SQL 数据中心与知识图谱项目强绑定。 diff --git a/README.md b/README.md index 4bc552a..1a47c30 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@ ## 项目亮点 -- 完整后台:数据源、批次、实体审核、证据质量、图谱广场、发布回滚、权限、任务和通知模块。 -- 双数据库架构:PostgreSQL 保存后台业务数据与审核流程,FalkorDB 保存可查询图谱。 +- 精简后台:图谱中心、数据中心、知识广场、本体 Schema 查看,以及用户、权限和调用日志。 +- 双数据库架构:PostgreSQL 保存项目与结构化业务数据,FalkorDB 保存可查询图谱。 - 可复现数据:仓库内置 PostgreSQL 与 FalkorDB 快照,下载后可恢复图谱数据。 - Docker 一键启动:`docker compose up -d --build` 同时启动 API、管理后台、PostgreSQL 和 FalkorDB。 - 旅行客服场景:内置固定线路、周边资源、酒店报价、车辆、行程推荐和图谱问答相关接口。 -- 可扩展 Agent:保留高德、网页、小红书、抖音、事件抽取、多源对齐、审计等 Agent 代码。 +- 可扩展 Agent:保留高德、网页、小红书、抖音、事件抽取和多源对齐等 Agent 代码。 ## 系统架构 diff --git a/admin-web/design-qa.md b/admin-web/design-qa.md new file mode 100644 index 0000000..c64b919 --- /dev/null +++ b/admin-web/design-qa.md @@ -0,0 +1,40 @@ +# Data Center Layout Design QA + +- Source visual truth: `/var/folders/sl/bt4qhk351ndfm_1x3_4wkw440000gn/T/TemporaryItems/NSIRD_screencaptureui_L8OsOI/Screenshot 2026-08-24 at 7.21.31 PM.png` +- Focused source region: `/var/folders/sl/bt4qhk351ndfm_1x3_4wkw440000gn/T/TemporaryItems/NSIRD_screencaptureui_tbBMIs/Screenshot 2026-08-24 at 7.23.55 PM.png` +- Confirmed target: remove the full top toolbar, rename the explorer heading to “数据中心”, and place “新建数据库” inside `.data-center-tree` as a sibling after the database project nodes. +- Implementation route: `http://localhost:8102/admin/data` +- Implementation screenshot: unavailable; the available in-app browser redirects to `/admin/login`, and the signed-in Chrome surface is not connected. +- Source pixels: 2940 × 1748 for the full screenshot; focused crop 548 × 1578. +- Implementation pixels / CSS viewport / density normalization: unavailable because the authenticated implementation could not be captured. +- State: authenticated data-center workbench with one selected, collapsed database node. + +## Full-view comparison evidence + +Blocked. The source screenshot was inspected, but no authenticated browser-rendered implementation screenshot is available at the same viewport and state. + +## Focused-region comparison evidence + +Blocked for the same reason. The focused source crop is available, but the matching implementation crop cannot be captured without an authenticated browser surface. + +## Findings + +- QA evidence blocker: visual comparison cannot be completed until the rebuilt page is opened in an authenticated browser and captured. +- Source-level checks confirm the old toolbar markup and PostgreSQL summary were removed, the explorer title was renamed, and the new database action is a root-level tree sibling rather than a child of the active database. +- Fonts and typography: not visually verified. +- Spacing and layout rhythm: not visually verified. +- Colors and visual tokens: not visually verified. +- Image and icon fidelity: no new raster assets were introduced; final icon placement is not visually verified. +- Copy and content: source-level checks passed; browser rendering is not visually verified. + +## Comparison history + +- Initial pass: blocked before visual comparison because the implementation route redirected the available browser to the login page. + +## Implementation checklist + +- Refresh the authenticated `/admin/data` page. +- Capture the full page and the left data-center tree at the source viewport. +- Compare the source and implementation together and resolve any P0/P1/P2 differences. + +final result: blocked diff --git a/admin-web/index.html b/admin-web/index.html index c1168e4..5c306ec 100644 --- a/admin-web/index.html +++ b/admin-web/index.html @@ -6,7 +6,7 @@ - 智念城市知识图谱管理系统 + 智念数字资产管理系统
diff --git a/admin-web/public/assets/nianxx-wordmark-black-transparent.png b/admin-web/public/assets/nianxx-wordmark-black-transparent.png new file mode 100644 index 0000000..f7dd65c Binary files /dev/null and b/admin-web/public/assets/nianxx-wordmark-black-transparent.png differ diff --git a/admin-web/src/AdminLogin.tsx b/admin-web/src/AdminLogin.tsx index 897eb04..c0463a3 100644 --- a/admin-web/src/AdminLogin.tsx +++ b/admin-web/src/AdminLogin.tsx @@ -3,6 +3,7 @@ import { Button, Form, Input, message } from "antd"; import { UserOutlined, LockOutlined } from "@ant-design/icons"; import { login } from "./api"; import Brand from "./Brand"; +import LoginCompanyLogo from "./LoginCompanyLogo"; import LoginMascot, { type MascotMode } from "./LoginMascot"; export default function AdminLogin({ onLogin }: { onLogin: () => void }) { @@ -58,6 +59,7 @@ export default function AdminLogin({ onLogin }: { onLogin: () => void }) { background: "url('/admin/assets/login-bg.jpg') center/cover no-repeat", }} > +
void }) {
- 智念城市知识图谱管理系统 + 智念数字资产管理系统
- CITY KNOWLEDGE GRAPH PLATFORM + DIGITAL ASSET MANAGEMENT PLATFORM
diff --git a/admin-web/src/AdminLoginV2.tsx b/admin-web/src/AdminLoginV2.tsx index 666d3ed..ebceb98 100644 --- a/admin-web/src/AdminLoginV2.tsx +++ b/admin-web/src/AdminLoginV2.tsx @@ -3,6 +3,7 @@ import { Button, Form, Input, message, Tag } from "antd"; import { UserOutlined, LockOutlined } from "@ant-design/icons"; import { login } from "./api"; import Brand from "./Brand"; +import LoginCompanyLogo from "./LoginCompanyLogo"; import MochiMascot, { type MascotMode } from "./MochiMascot"; /** Alternative login (V2) — original "Mochi" mascot crowd. Route: /admin/login-v2 @@ -55,6 +56,7 @@ export default function AdminLoginV2({ onLogin }: { onLogin: () => void }) { background: "url('/admin/assets/login-bg.jpg') center/cover no-repeat", }} > +
void }) {
- 智念城市知识图谱管理系统 + 智念数字资产管理系统
原创吉祥物 Mochi · 预览版 diff --git a/admin-web/src/App.tsx b/admin-web/src/App.tsx index ef67e09..dfd5ee1 100644 --- a/admin-web/src/App.tsx +++ b/admin-web/src/App.tsx @@ -2,10 +2,9 @@ import { useState, useEffect, useCallback } from "react"; import { Routes, Route, useNavigate, useLocation, Navigate } from "react-router-dom"; import { Layout, Menu, Button, Spin, Tag, Tooltip } from "antd"; import { - HomeOutlined, ApartmentOutlined, ImportOutlined, CheckCircleOutlined, - SendOutlined, ToolOutlined, SettingOutlined, LogoutOutlined, - MenuFoldOutlined, MenuUnfoldOutlined, CompassOutlined, - ProjectOutlined, DatabaseOutlined, + HomeOutlined, ApartmentOutlined, SettingOutlined, LogoutOutlined, + MenuFoldOutlined, MenuUnfoldOutlined, + ProjectOutlined, DatabaseOutlined, ApiOutlined, } from "@ant-design/icons"; import AdminLogin from "./AdminLogin"; import AdminLoginV2 from "./AdminLoginV2"; @@ -18,47 +17,17 @@ import DataCenterPanel from "./panels/data-platform/DataCenterPanel"; // Plaza panels (STEP 02) import PlazaOverview from "./panels/plaza/PlazaOverview"; import GraphCanvas from "./panels/plaza/GraphCanvas"; -import ManualIngestPanel from "./panels/plaza/ManualIngestPanel"; import ProjectUserExperiencePanel from "./panels/plaza/ProjectUserExperiencePanel"; -import AuditReport from "./panels/plaza/AuditReport"; -import UsageStats from "./panels/plaza/UsageStats"; -import HealthAlerts from "./panels/plaza/HealthAlerts"; -// Modeling panels -import VocabularyPanel from "./panels/modeling/VocabularyPanel"; +// Schema viewer for schemas created from the graph center. import OntologySchemaPanel from "./panels/modeling/OntologySchemaPanel"; -import SchemaHealth from "./panels/modeling/SchemaHealth"; - -// Acquisition panels (STEP 01) -import BatchCenter from "./panels/acquisition/BatchCenter"; -import SourceProfilesPanel from "./panels/acquisition/SourceProfilesPanel"; -import ConflictDesk from "./panels/acquisition/ConflictDesk"; - -// Review panels (STEP 03) -import AssetLibrary from "./panels/review/AssetLibrary"; -import FieldLevelReview from "./panels/review/FieldLevelReview"; -import AccessionView from "./panels/review/AccessionView"; -import ExpertSignoff from "./panels/review/ExpertSignoff"; -import PoiEvidenceQuality from "./panels/review/PoiEvidenceQuality"; - -// Publish panels (STEP 05) -import PublishPanel from "./panels/publish/PublishPanel"; -import RollbackPanel from "./panels/publish/RollbackPanel"; - -// Workbench panels (STEP 04) -import InventoryPanel from "./panels/workbench/InventoryPanel"; -import AcquisitionTasksPanel from "./panels/workbench/AcquisitionTasksPanel"; -import TreatmentHistory from "./panels/workbench/TreatmentHistory"; // System panels import AgentTasksPanel from "./panels/system/AgentTasksPanel"; import LogsPanel from "./panels/system/LogsPanel"; import PermissionsAudit from "./panels/system/PermissionsAudit"; import UserManagement from "./panels/system/UserManagement"; -import AreaManagement from "./panels/system/AreaManagement"; -import Notifications from "./panels/system/Notifications"; -import NotificationBell from "./panels/system/NotificationBell"; -import AgentSettings from "./panels/system/AgentSettings"; +import InterfaceCenterPanel from "./panels/system/InterfaceCenterPanel"; const { Sider, Content } = Layout; const RUNTIME_CONTEXT = { @@ -73,48 +42,19 @@ const MENU_ITEMS = [ { key: "/projects", icon: , label: "图谱中心" }, { key: "/data", icon: , label: "数据中心" }, { key: "/plaza", icon: , label: "知识广场", children: [ - { key: "/plaza/overview", label: "馆藏概览" }, - { key: "/plaza/user", icon: , label: "业务问答" }, + { key: "/plaza/overview", label: "数据大屏" }, + { key: "/plaza/user", label: "业务问答" }, { key: "/plaza/graph", label: "图谱浏览器" }, - { key: "/plaza/manual-ingest", label: "人工录入" }, - { key: "/plaza/audit", label: "AI 质量稽查" }, - { key: "/plaza/usage", label: "使用统计" }, - { key: "/plaza/alerts", label: "健康告警" }, ]}, { key: "/modeling", icon: , label: "本体建模", children: [ { key: "/modeling/schema", label: "本体 Schema" }, - { key: "/modeling/vocabulary", label: "权威词表" }, - { key: "/modeling/health", label: "Schema 健康" }, - ]}, - { key: "/acquisition", icon: , label: "数据采集", children: [ - { key: "/acquisition/batches", label: "来源批次中心" }, - { key: "/acquisition/sources", label: "来源档案" }, - { key: "/acquisition/conflicts", label: "冲突处理台" }, - ]}, - { key: "/review", icon: , label: "审核入藏", children: [ - { key: "/review/candidates", label: "候选审核" }, - { key: "/review/poi-evidence", label: "POI 证据质检" }, - { key: "/review/field-level", label: "字段级审核" }, - { key: "/review/accession", label: "入藏记录" }, - { key: "/review/expert-signoff", label: "专家会签" }, - ]}, - { key: "/publish", icon: , label: "图谱发布", children: [ - { key: "/publish/jobs", label: "发布管理" }, - { key: "/publish/rollback", label: "发布回滚" }, - ]}, - { key: "/workbench", icon: , label: "治理工作台", children: [ - { key: "/workbench/inventory", label: "知识盘点" }, - { key: "/workbench/tasks", label: "补藏队列" }, - { key: "/workbench/history", label: "历史治理" }, ]}, { key: "/system", icon: , label: "系统", children: [ + { key: "/system/interfaces", icon: , label: "接口中心" }, { key: "/system/agents", label: "Agent 任务" }, { key: "/system/logs", label: "调用日志" }, { key: "/system/users", label: "用户管理" }, { key: "/system/permissions", label: "角色权限" }, - { key: "/system/areas", label: "城区责任" }, - { key: "/system/notifications", label: "我的消息" }, - { key: "/system/settings", label: "Agent 设置" }, ]}, ]; @@ -164,7 +104,13 @@ export default function App() { ); } - const onMenuClick = ({ key }: { key: string }) => navigate(`/admin${key}`); + const onMenuClick = ({ key }: { key: string }) => { + if (key === "/data") { + window.open("/admin/data", "_blank", "noopener,noreferrer"); + return; + } + navigate(`/admin${key}`); + }; const selectedKey = location.pathname.replace("/admin", "") || "/projects"; const openKeys = [selectedKey.split("/").slice(0, 2).join("/")]; @@ -173,58 +119,59 @@ export default function App() { return ( - -
-
- {!collapsed && } - -
- -
- -
- -
- {!collapsed && ( -
- new2 · {RUNTIME_CONTEXT.port} - 项目 · {displayProjectName(projectContext.projectId)} - Schema · {RUNTIME_CONTEXT.dbSchema} -
- )} -
- - - + {!showDataCenterWorkbench && ( + +
+
+ {!collapsed && } + +
+ +
+ +
+ +
+ {!collapsed && ( +
+ new2 · {RUNTIME_CONTEXT.port} + 项目 · {displayProjectName(projectContext.projectId)} + Schema · {RUNTIME_CONTEXT.dbSchema} +
+ )} +
+ + + +
+
-
- + + )} {showProjectContextBar && } @@ -235,45 +182,16 @@ export default function App() { } /> } /> } /> - } /> - } /> - } /> - } /> - {/* Modeling */} + {/* Schema viewer */} } /> - } /> - } /> - - {/* Acquisition */} - } /> - } /> - } /> - - {/* Review */} - } /> - } /> - } /> - } /> - } /> - - {/* Publish */} - } /> - } /> - - {/* Workbench */} - } /> - } /> - } /> {/* System */} + } /> } /> } /> } /> } /> - } /> - } /> - } /> navigate("/admin/projects")} />} /> } /> diff --git a/admin-web/src/Brand.tsx b/admin-web/src/Brand.tsx index d455d64..eaba444 100644 --- a/admin-web/src/Brand.tsx +++ b/admin-web/src/Brand.tsx @@ -27,7 +27,7 @@ export default function Brand({ ) : ( <> - nian.xxx + nian.xx new2 diff --git a/admin-web/src/LoginCompanyLogo.tsx b/admin-web/src/LoginCompanyLogo.tsx new file mode 100644 index 0000000..0a626c6 --- /dev/null +++ b/admin-web/src/LoginCompanyLogo.tsx @@ -0,0 +1,10 @@ +export default function LoginCompanyLogo() { + return ( +
+ nian.xx 公司 Logo +
+ ); +} diff --git a/admin-web/src/api.ts b/admin-web/src/api.ts index 825e928..d50cbd6 100644 --- a/admin-web/src/api.ts +++ b/admin-web/src/api.ts @@ -364,65 +364,156 @@ export const exportProjectTableCsv = ( `/data-platform/databases/${encodeURIComponent(projectId)}/tables/${encodeURIComponent(tableCode)}/records/export`, { params, responseType: "blob" }, ); + +// ── Interface Center (controlled external Data APIs) ── +export type InterfaceSummary = { + active_clients: number; + active_credentials: number; + active_policies: number; + calls_24h: number; + errors_24h: number; + data_engine: string; + public_base_path: string; + direct_database_access: string; + direct_access_enabled?: boolean; + direct_access_host?: string; + direct_access_port?: number; + direct_access_transport?: string; + ssh_tunnel_required?: boolean; + ssh_host?: string; + ssh_port?: number; + ssh_auth_method?: string; + database_host?: string; + database_port?: number; + database_account_policy?: string; + mysql_host_bind?: string; + mysql_publicly_bound?: boolean; + database_audit_enabled?: boolean; + application_audit_enabled?: boolean; + sql_console_write_enabled?: boolean; + backup_enabled?: boolean; + backup_encryption_required?: boolean; + backup_retention_days?: number; +}; + +export type InterfaceClient = { + id: string; + name: string; + description: string; + owner: string; + status: "active" | "disabled"; + credential_count?: number; + policy_count?: number; + created_at?: string; + updated_at?: string; +}; + +export type InterfaceCredential = { + id: string; + client_id: string; + client_name?: string; + name: string; + key_prefix: string; + api_key?: string; + expires_at?: string | null; + last_used_at?: string | null; + revoked_at?: string | null; + created_at?: string; +}; + +export type InterfacePolicy = { + id: string; + client_id: string; + client_name?: string; + database_id: string; + database_name?: string; + table_code: string; + actions: string[]; + readable_fields: string[]; + writable_fields: string[]; + row_filter: Record; + status: "active" | "disabled"; + created_at?: string; + updated_at?: string; +}; + +export type InterfaceCatalogField = { + code: string; + label: string; + data_type: string; +}; + +export type InterfaceCatalogTable = { + code: string; + label: string; + group: string; + fields: InterfaceCatalogField[]; +}; + +export type InterfaceCatalogDatabase = { + project_id: string; + display_name: string; + database_name: string; + tables: InterfaceCatalogTable[]; +}; + +export type InterfaceCallLog = { + id: number; + request_id: string; + client_name?: string | null; + method: string; + path: string; + database_id?: string | null; + table_code?: string | null; + action_name?: string | null; + status_code: number; + duration_ms: number; + source_ip?: string | null; + error_message?: string | null; + created_at: string; +}; + +export const getInterfaceCenterSummary = () => + api.get("/interface-center/summary"); +export const getInterfaceCenterCatalog = () => + api.get("/interface-center/catalog"); +export const listInterfaceClients = () => + api.get("/interface-center/clients"); +export const createInterfaceClient = (data: unknown) => + api.post("/interface-center/clients", data); +export const updateInterfaceClient = (clientId: string, data: unknown) => + api.patch(`/interface-center/clients/${encodeURIComponent(clientId)}`, data); +export const deleteInterfaceClient = (clientId: string) => + api.delete(`/interface-center/clients/${encodeURIComponent(clientId)}`); +export const listInterfaceCredentials = (clientId?: string) => + api.get("/interface-center/credentials", { + params: clientId ? { client_id: clientId } : undefined, + }); +export const createInterfaceCredential = (clientId: string, data: unknown) => + api.post( + `/interface-center/clients/${encodeURIComponent(clientId)}/credentials`, + data, + ); +export const revokeInterfaceCredential = (credentialId: string) => + api.post(`/interface-center/credentials/${encodeURIComponent(credentialId)}/revoke`, {}); +export const listInterfacePolicies = () => + api.get("/interface-center/policies"); +export const createInterfacePolicy = (data: unknown) => + api.post("/interface-center/policies", data); +export const updateInterfacePolicy = (policyId: string, data: unknown) => + api.patch(`/interface-center/policies/${encodeURIComponent(policyId)}`, data); +export const deleteInterfacePolicy = (policyId: string) => + api.delete(`/interface-center/policies/${encodeURIComponent(policyId)}`); +export const listInterfaceCallLogs = (limit = 200) => + api.get("/interface-center/logs", { params: { limit } }); + export const listOntologySchemas = () => api.get("/ontology-schemas"); export const getCurrentOntologySchema = () => api.get("/ontology-schemas/current"); export const getOntologySchema = (schemaId: string | number) => api.get(`/ontology-schemas/${encodeURIComponent(String(schemaId))}`); -// ── Source Profiles (STEP 01) ── -export const listSourceProfiles = (params?: Record) => - api.get("/source-profiles", { params }); -export const createSourceProfile = (data: unknown) => - api.post("/source-profiles", data); -export const updateSourceProfile = (id: number, data: unknown) => - api.patch(`/source-profiles/${id}`, data); - -// ── Batches (STEP 01) ── -export const listBatches = (params?: Record) => - api.get("/batches", { params }); -export const getBatch = (id: number) => api.get(`/batches/${id}`); -export const getBatchRawRecords = (id: number, params?: Record) => - api.get(`/batches/${id}/raw-records`, { params }); -export const getBatchQuality = (id: number) => - api.get(`/batches/${id}/quality-summary`); - -// ── Question Traces (STEP 02) ── -export const createQuestionTrace = (data: unknown) => - api.post("/question-traces", data); -export const listQuestionTraces = (params?: Record) => - api.get("/question-traces", { params }); -export const getQuestionTrace = (id: number) => - api.get(`/question-traces/${id}`); -export const simulateQuestions = () => - api.post("/question-traces/simulate"); - -// ── Simulation Panel (STEP 02) ── -export const listSimQuestions = () => api.get("/simulation-panel"); -export const createSimQuestion = (data: unknown) => - api.post("/simulation-panel", data); -export const updateSimQuestion = (id: number, data: unknown) => - api.patch(`/simulation-panel/${id}`, data); -export const deleteSimQuestion = (id: number) => - api.delete(`/simulation-panel/${id}`); -export const importSimQuestions = (text: string) => - api.post("/simulation-panel/import", { text }); -export const adoptHitQuestions = () => - api.post("/simulation-panel/adopt-hits"); -export const aiGenerateSimQuestions = (count: number, topic?: string) => - api.post("/simulation-panel/ai-generate", { count, topic }); - -// ── Audit (STEP 02) ── -export const runAudit = () => api.post("/audit/run"); -export const listAuditReports = () => api.get("/audit/reports"); -export const latestAuditReport = () => api.get("/audit/reports/latest"); -export const listAuditGaps = () => api.get("/audit/gaps"); -export const getAuditRun = (id: number) => api.get(`/audit-runs/${id}`); -export const latestAuditRun = () => api.get("/audit-runs/latest"); - // ── Plaza (STEP 02) ── export const plazaOverview = () => api.get("/plaza/overview"); -export const plazaUsage = () => api.get("/plaza/usage"); -export const plazaAlerts = () => api.get("/plaza/alerts"); export const plazaAmapConfig = () => api.get("/plaza/amap-config"); export const plazaWeatherHourly = () => api.get("/plaza/weather/hourly"); export const plazaMapPois = () => api.get("/plaza/map-pois"); @@ -450,94 +541,6 @@ export const graphOverview = () => api.get("/graph/overview"); export const graphQuery = (cypher: string, limit = 200) => api.post("/graph/query", { cypher, limit }); -// ── Entities (STEP 03) ── -export const listCandidates = (params?: Record) => - api.get("/entities", { params }); -export const getEntity = (id: number) => api.get(`/entities/${id}`); -export const getEntityProvenance = (id: number) => - api.get(`/entities/${id}/provenance`); -export const listPoiEvidenceQuality = (params?: Record) => - api.get("/evidence/poi-quality", { params }); -export const getPoiEvidenceQuality = (placeKey: string, params?: Record) => - api.get(`/evidence/poi-quality/${encodeURIComponent(placeKey)}`, { params }); -export const approveEntity = (id: number) => - api.post(`/entities/${id}/approve`); -export const rejectEntity = (id: number, note?: string) => - api.post(`/entities/${id}/reject`, null, { params: { note } }); -export const fieldDecisions = (id: number, data: unknown) => - api.patch(`/entities/${id}/field-decisions`, data); -export const mergeEntities = (id: number, targetId: number, note?: string) => - api.post(`/entities/${id}/merge-with/${targetId}`, null, { params: { note } }); - -// ── Acquisition Tasks (STEP 04) ── -export const listAcquisitionTasks = (params?: Record) => - api.get("/acquisition-tasks", { params }); -export const createAcquisitionTask = (data: unknown) => - api.post("/acquisition-tasks", data); -export const createTaskFromGap = (data: unknown) => - api.post("/acquisition-tasks/from-gap", data); -export const getAcquisitionTask = (id: number) => - api.get(`/acquisition-tasks/${id}`); -export const assignTask = (id: number, assignee: string) => - api.post(`/acquisition-tasks/${id}/assign`, { assignee }); -export const startTask = (id: number) => - api.post(`/acquisition-tasks/${id}/start`); -export const completeTask = (id: number, resultSummary?: string) => - api.post(`/acquisition-tasks/${id}/complete`, { result_summary: resultSummary }); -export const cancelTask = (id: number, note?: string) => - api.post(`/acquisition-tasks/${id}/cancel`, { note }); -// work-order review state machine + urge -export const reviewApproveTask = (id: number, note?: string) => - api.post(`/acquisition-tasks/${id}/review-approve`, { note }); -export const reviewRejectTask = (id: number, note?: string) => - api.post(`/acquisition-tasks/${id}/review-reject`, { note }); -export const urgeTask = (id: number, note?: string) => - api.post(`/acquisition-tasks/${id}/urge`, { note }); - -// ── Inventory (STEP 04) ── -export const listInventoryIssues = (params?: Record) => - api.get("/inventory/issues", { params }); -export const runInventoryScan = () => api.post("/inventory/scan"); -export const resolveIssue = (id: number, note?: string) => - api.post(`/inventory/issues/${id}/resolve`, { resolution_note: note }); - -// ── Treatment History (STEP 04) ── -export const listTreatmentHistory = (params?: Record) => - api.get("/treatment-history", { params }); - -// ── Vocabulary (STEP 05) ── -export const listVocabulary = (params?: Record) => - api.get("/vocabulary", { params }); -export const createVocabularyTerm = (data: unknown) => - api.post("/vocabulary", data); -export const updateVocabularyTerm = (id: number, data: unknown) => - api.patch(`/vocabulary/${id}`, data); -export const lookupVocabulary = (name: string) => - api.get("/vocabulary/lookup", { params: { name } }); -export const mergeVocabulary = (id: number, targetId: number) => - api.post(`/vocabulary/${id}/merge-into/${targetId}`); - -// ── Conflicts (STEP 05) ── -export const listConflicts = () => api.get("/conflicts"); -export const resolveConflict = (id: number, data: unknown) => - api.post(`/conflicts/${id}/resolve`, data); - -// ── Aligner (STEP 05) ── -export const alignSuggest = (candidateIds: number[]) => - api.post("/aligner/suggest", { candidate_ids: candidateIds }); -export const autoMerge = (candidateIds: number[]) => - api.post("/aligner/auto-merge", { candidate_ids: candidateIds }); - -// ── Publish Jobs (STEP 05) ── -export const listPublishJobs = () => api.get("/publish-jobs"); -export const getPublishJob = (id: number) => api.get(`/publish-jobs/${id}`); -export const createPublishJob = (candidateIds: number[]) => - api.post("/publish-jobs", { candidate_ids: candidateIds }); -export const getPublishDiff = (id: number) => - api.get(`/publish-jobs/${id}/diff`); -export const rollbackPublish = (id: number, reason?: string) => - api.post(`/publish-jobs/${id}/rollback`, { reason }); - // ── RBAC & Accounts (P1) ── export const listRoles = () => api.get("/roles"); export const createRole = (data: unknown) => api.post("/roles", data); @@ -563,34 +566,6 @@ export const updateUser = (id: number, data: unknown) => api.patch(`/users/${id}`, data); export const deleteUser = (id: number) => api.delete(`/users/${id}`); -// ── City Areas & responsibility (P3) ── -export const listAreas = () => api.get("/areas"); -export const syncAreasFromGraph = () => api.post("/areas/sync-from-graph"); -export const updateArea = (areaId: string, data: unknown) => - api.patch(`/areas/${areaId}`, data); -export const getAreaTree = () => api.get("/areas/tree"); -export const getUserAreas = (userId: number) => - api.get(`/users/${userId}/areas`); -export const setUserAreas = ( - userId: number, - areaIds: string[], - customAreas: string[] -) => api.put(`/users/${userId}/areas`, { area_ids: areaIds, custom_areas: customAreas }); - -// ── Notifications / inbox (P4) ── -export const listNotifications = (onlyUnread = false) => - api.get("/notifications", { params: { only_unread: onlyUnread } }); -export const unreadCount = () => api.get("/notifications/unread-count"); -export const markNotificationRead = (id: number) => - api.post(`/notifications/${id}/read`); -export const markAllNotificationsRead = () => - api.post("/notifications/read-all"); - -// ── Agent settings ── -export const getAgentSettings = () => api.get("/agent-settings"); -export const saveAgentSettings = (config: unknown) => - api.put("/agent-settings", config); - // ── Super Agent (autonomous KG curator) ── export const superAgentRun = () => api.post("/super-agent/run", {}); export const superAgentLatest = () => api.get("/super-agent/runs/latest"); diff --git a/admin-web/src/panels/acquisition/BatchCenter.tsx b/admin-web/src/panels/acquisition/BatchCenter.tsx deleted file mode 100644 index 5457246..0000000 --- a/admin-web/src/panels/acquisition/BatchCenter.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useEffect, useState } from "react"; -import { Table, Card, Tag, Drawer } from "antd"; -import { listBatches, getBatch, getBatchQuality } from "../../api"; - -const STATUS_COLORS: Record = { - pending: "default", validated: "blue", partially_failed: "orange", completed: "green", failed: "red", -}; - -export default function BatchCenter() { - const [batches, setBatches] = useState>>([]); - const [selectedId, setSelectedId] = useState(null); - const [detail, setDetail] = useState>({}); - const [quality, setQuality] = useState>({}); - - useEffect(() => { listBatches().then(({ data }) => setBatches(data)).catch(() => {}); }, []); - - const selectBatch = async (id: number) => { - setSelectedId(id); - const [{ data: d }, { data: q }] = await Promise.all([getBatch(id), getBatchQuality(id)]); - setDetail(d); - setQuality(q); - }; - - return ( -
-

来源批次中心

- ({ onClick: () => selectBatch(r.batch_id as number), style: { cursor: "pointer" } })} - columns={[ - { title: "ID", dataIndex: "batch_id", width: 60 }, - { title: "来源", dataIndex: "source_code", width: 100 }, - { title: "总行数", dataIndex: "total_rows", width: 80 }, - { title: "成功", dataIndex: "success_rows", width: 80 }, - { title: "状态", dataIndex: "status", width: 100, render: (v: string) => {v} }, - { title: "时间", dataIndex: "created_at", width: 180 }, - ]} - /> - setSelectedId(null)} width={600}> - -

状态: {String(detail.status)}

-

成功行: {String(detail.success_rows)} / 失败行: {String(detail.failed_rows)}

-
- - {quality.candidates ? Object.entries(quality.candidates as Record).map(([k, v]) => ( - {k}: {v} - )) : null} - -
- - ); -} diff --git a/admin-web/src/panels/acquisition/ConflictDesk.tsx b/admin-web/src/panels/acquisition/ConflictDesk.tsx deleted file mode 100644 index cec1a66..0000000 --- a/admin-web/src/panels/acquisition/ConflictDesk.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useEffect, useState } from "react"; -import { Table, Tag, Button, message } from "antd"; -import { listConflicts, resolveConflict } from "../../api"; - -export default function ConflictDesk() { - const [conflicts, setConflicts] = useState>>([]); - - const fetch = async () => { - const { data } = await listConflicts(); - setConflicts(data); - }; - - useEffect(() => { fetch(); }, []); - - const handleResolve = async (id: number) => { - await resolveConflict(id, { resolution: "manual", note: "人工解决" }); - message.success("已解决"); - fetch(); - }; - - return ( -
-

冲突处理台

-
`${r.entity_id}-${r.field}-${i}`} - columns={[ - { title: "实体", dataIndex: "natural_key" }, - { title: "字段", dataIndex: "field", width: 120 }, - { title: "当前值", dataIndex: "current_value" }, - { title: "来源", dataIndex: "source", width: 100, render: (v: string) => {v} }, - { title: "置信度", dataIndex: "confidence", width: 80, render: (v: number) => v?.toFixed(2) }, - { title: "操作", width: 80, render: (_, r) => ( - - )}, - ]} - /> - - ); -} diff --git a/admin-web/src/panels/acquisition/SourceProfilesPanel.tsx b/admin-web/src/panels/acquisition/SourceProfilesPanel.tsx deleted file mode 100644 index 1aa874b..0000000 --- a/admin-web/src/panels/acquisition/SourceProfilesPanel.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useEffect, useState } from "react"; -import { Table, Button, Modal, Form, Input, Select, InputNumber, Switch, Space, message, Tag } from "antd"; -import { PlusOutlined } from "@ant-design/icons"; -import { listSourceProfiles, createSourceProfile, updateSourceProfile } from "../../api"; - -export default function SourceProfilesPanel() { - const [profiles, setProfiles] = useState>>([]); - const [open, setOpen] = useState(false); - const [form] = Form.useForm(); - - const fetch = async () => { - const { data } = await listSourceProfiles(); - setProfiles(data); - }; - - useEffect(() => { fetch(); }, []); - - const handleCreate = async (values: Record) => { - await createSourceProfile(values); - message.success("已创建来源档案"); - setOpen(false); - form.resetFields(); - fetch(); - }; - - return ( -
-

来源档案

-
{v} }, - { title: "权威等级", dataIndex: "authority_level", width: 80 }, - { title: "更新频率", dataIndex: "update_frequency", width: 100 }, - { title: "启用", dataIndex: "enabled", width: 60, render: (v: boolean, r) => ( - { - await updateSourceProfile(r.id as number, { enabled: checked }); - fetch(); - }} /> - )}, - ]} - /> - setOpen(false)} onOk={() => form.submit()} width={600}> -
- - - - - - - -
- - ); -} diff --git a/admin-web/src/panels/data-platform/DataCenterPanel.tsx b/admin-web/src/panels/data-platform/DataCenterPanel.tsx index d954582..41ce9a5 100644 --- a/admin-web/src/panels/data-platform/DataCenterPanel.tsx +++ b/admin-web/src/panels/data-platform/DataCenterPanel.tsx @@ -870,7 +870,7 @@ function SqlConsoleView({ confirmAndRun(); } }} - placeholder="输入 SELECT、WITH、INSERT、UPDATE、DELETE 或 EXPLAIN SQL" + placeholder="安全只读模式:输入 SELECT、WITH、SHOW、DESCRIBE 或 EXPLAIN SQL" />
执行结果 @@ -1171,7 +1171,7 @@ export default function DataCenterPanel() { setExpandedProjectId(databaseId); setDatabaseModalOpen(false); databaseForm.resetFields(); - message.success("关系数据库已创建,与知识图谱相互独立"); + message.success("空关系数据库已创建,与知识图谱相互独立"); } catch (error) { message.error(errorText(error, "关系数据库创建失败")); } finally { @@ -1819,33 +1819,13 @@ export default function DataCenterPanel() { return (
-
-
- 数据中心 -
-
- {selectedDatabase && ( - <> - PostgreSQL · 已连接 - {selectedDatabase.table_count} 张表 - {selectedDatabase.record_count.toLocaleString()} 条记录 - - )} - -
-
-
-
@@ -2468,8 +2463,8 @@ export default function DataCenterPanel() { destroyOnClose >
- 关系数据库在数据中心独立创建,只生成 PostgreSQL Schema 和标准业务表; - 不会创建、修改或同步任何知识图谱项目。 + 将在 MySQL 数据中心创建一个空数据库,不会自动生成任何模板数据表; + 后续可按需新建数据表或导入数据,也不会修改或同步任何知识图谱项目。
@@ -2486,7 +2481,6 @@ export default function DataCenterPanel() { >({}); - - useEffect(() => { - Promise.all([ - api.get("/entities", { params: { status: "published" } }), - api.get("/inventory/issues", { params: { status: "open" } }), - ]).then(([{ data: entities }, { data: issues }]) => { - const blockerCount = (issues as Array>).filter( - (i) => i.severity === "blocker" - ).length; - setStats({ - published: (entities as Array).length, - openIssues: (issues as Array).length, - blockerCount, - }); - }).catch(() => {}); - }, []); - - const healthScore = Math.max(0, 100 - ((stats.blockerCount || 0) * 20 + (stats.openIssues || 0) * 2)); - - return ( -
-

Schema 健康检查

- -
- - = 80 ? : healthScore >= 50 ? : } - /> - = 80 ? "success" : healthScore >= 50 ? "active" : "exception"} /> - - - - } /> - - - } /> - - - - ); -} diff --git a/admin-web/src/panels/modeling/VocabularyPanel.tsx b/admin-web/src/panels/modeling/VocabularyPanel.tsx deleted file mode 100644 index 961262c..0000000 --- a/admin-web/src/panels/modeling/VocabularyPanel.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useEffect, useState } from "react"; -import { Table, Button, Modal, Form, Input, Select, Space, Tag, message, Popconfirm } from "antd"; -import { PlusOutlined } from "@ant-design/icons"; -import { listVocabulary, createVocabularyTerm, updateVocabularyTerm, mergeVocabulary } from "../../api"; - -export default function VocabularyPanel() { - const [terms, setTerms] = useState>>([]); - const [open, setOpen] = useState(false); - const [form] = Form.useForm(); - - const fetch = () => { - listVocabulary().then(({ data }) => setTerms(data)).catch(() => {}); - }; - - useEffect(() => { fetch(); }, []); - - const handleCreate = async (values: Record) => { - await createVocabularyTerm(values); - message.success("已创建词汇"); - setOpen(false); - form.resetFields(); - fetch(); - }; - - return ( -
-

权威词表

-
{v} }, - { title: "别名", dataIndex: "aliases", render: (v: unknown) => { - const aliases = Array.isArray(v) ? v : []; - return aliases.map((a: string) => {a}); - }}, - { title: "操作", width: 80, render: (_, r) => ( - { await updateVocabularyTerm(r.id as number, { canonical_name: "_deleted" }); fetch(); }}> - - - )}, - ]} - /> - setOpen(false)} onOk={() => form.submit()}> - - - - - - - - - - - - ); -} diff --git a/admin-web/src/panels/plaza/AuditReport.tsx b/admin-web/src/panels/plaza/AuditReport.tsx deleted file mode 100644 index a51f065..0000000 --- a/admin-web/src/panels/plaza/AuditReport.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { useEffect, useState } from "react"; -import { Card, Table, Button, Tag, message, Progress, Tabs, Alert, Space } from "antd"; -import { ThunderboltOutlined } from "@ant-design/icons"; -import { latestAuditReport, simulateQuestions, listAuditGaps, createTaskFromGap, getAuditRun } from "../../api"; -import SimulationPanel from "./SimulationPanel"; - -export default function AuditReport() { - const [report, setReport] = useState>({}); - const [gaps, setGaps] = useState>>([]); - const [running, setRunning] = useState(false); - const [runId, setRunId] = useState(null); - const [run, setRun] = useState | null>(null); - - const fetchData = async () => { - const { data: r } = await latestAuditReport(); - setReport(r); - const { data: g } = await listAuditGaps(); - setGaps(g); - }; - - useEffect(() => { fetchData(); }, []); - - useEffect(() => { - if (!runId) return; - let alive = true; - const tick = async () => { - try { - const { data } = await getAuditRun(runId); - if (!alive) return; - setRun(data); - if (data.status !== "running") { - setRunId(null); - setRunning(false); - if (data.status === "done") { - message.success(`稽查完成:命中 ${data.hits} · 缺口 ${data.gaps} · 完善 ${data.low_quality} · 冲突 ${data.conflicts}`); - } else { - message.error("稽查失败:" + (data.error || "未知错误")); - } - await fetchData(); - } - } catch { /* keep polling */ } - }; - tick(); - const t = setInterval(tick, 2000); - return () => { alive = false; clearInterval(t); }; - }, [runId]); - - const handleRun = async () => { - setRunning(true); - setRun(null); - try { - const { data } = await simulateQuestions(); - if (!data.run_id) { - message.info("题库为空,请先到「模拟题库」添加问题"); - setRunning(false); - return; - } - message.success(`已提交 ${data.total} 题,后台稽查中…`); - setRun({ total: data.total, done: 0, status: "running", hits: 0, gaps: 0, low_quality: 0, conflicts: 0 }); - setRunId(data.run_id); - } catch { - message.error("启动稽查失败"); - setRunning(false); - } - }; - - const handleCreateTask = async (traceId: number) => { - await createTaskFromGap({ trace_id: traceId, priority: 4 }); - message.success("已创建补藏任务"); - }; - - const coverageRate = (report.coverage_rate as number) ?? 0; - - return ( -
-

AI 质量稽查

- -
- -
- {run && ( - - - - {run.status === "running" ? "AI 稽查进行中" : run.status === "done" ? "稽查完成" : "稽查失败"} - :{run.done}/{run.total} - - 命中 {run.hits} - 缺口 {run.gaps} - 完善 {run.low_quality} - 冲突 {run.conflicts} - - -
- } - /> - )} - - - - 已评估: {String(report.total_evaluated ?? "-")} 条 · 命中: {String(report.hits ?? "-")} · 缺口: {String(report.gaps ?? "-")} - - -

知识缺口 {gaps.length}

-
v?.toFixed(2) }, - { title: "动作", dataIndex: "suggested_action", width: 80, render: (v: string) => {v} }, - { title: "操作", width: 120, render: (_, r) => ( - - )}, - ]} - /> - - ), - }, - { - key: "bank", - label: "模拟题库", - children: , - }, - ]} - /> - - ); -} diff --git a/admin-web/src/panels/plaza/HealthAlerts.tsx b/admin-web/src/panels/plaza/HealthAlerts.tsx deleted file mode 100644 index 91475a5..0000000 --- a/admin-web/src/panels/plaza/HealthAlerts.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useEffect, useState } from "react"; -import { Table, Tag } from "antd"; -import { plazaAlerts } from "../../api"; - -const SEV_COLORS: Record = { blocker: "red", warning: "orange", info: "blue" }; - -export default function HealthAlerts() { - const [alerts, setAlerts] = useState>>([]); - - useEffect(() => { - plazaAlerts().then(({ data }) => setAlerts(data)).catch(() => {}); - }, []); - - return ( -
-

健康告警

-
{v} }, - { title: "类型", dataIndex: "issue_type", width: 120 }, - { title: "描述", dataIndex: "description" }, - { title: "时间", dataIndex: "detected_at", width: 180 }, - ]} - /> - - ); -} diff --git a/admin-web/src/panels/plaza/ManualIngestPanel.tsx b/admin-web/src/panels/plaza/ManualIngestPanel.tsx deleted file mode 100644 index 94a17aa..0000000 --- a/admin-web/src/panels/plaza/ManualIngestPanel.tsx +++ /dev/null @@ -1,1678 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { - Alert, - Button, - Card, - Checkbox, - Drawer, - Empty, - Input, - message, - Radio, - Select, - Space, - Steps, - Table, - Tabs, - Tag, - Typography, - Upload, -} from "antd"; -import { - ArrowLeftOutlined, - CheckCircleOutlined, - FileMarkdownOutlined, - FileSearchOutlined, - InboxOutlined, - SearchOutlined, -} from "@ant-design/icons"; -import * as echarts from "echarts"; -import api, { getAgentSettings } from "../../api"; - -const { TextArea } = Input; -const { Text, Title } = Typography; - -const SAMPLE_TEXT = `中文名:花溪公园 -地理位置:贵阳市花溪区花溪大道南段3108号 -气候条件:亚热带湿润温和型气候 -开放时间:全天开放;收费时段8:30-18:00 -景点级别:AAAA级 -门票价格:6元 -占地面积:50.1 公顷 -著名景点:百步桥、麟山、东舍、芙蓉洲、棋亭 -邻近景点:天河潭、青岩古镇 -美誉:高原明珠 -所属国家:中国 -所属城市:贵州省贵阳市 -建议游玩时长:2-3小时 -适宜游玩季节:3-10月最佳 - -1937年开始,这里正式辟建为公园。1959年陈毅元帅曾游花溪,并以诗句称赞其自然布局。1985年划为花溪区管理。`; - -type KgRow = Record; -type ExtractResult = { - source?: Record; - summary?: Record; - entities: KgRow[]; - events: KgRow[]; - concepts: KgRow[]; - relations: KgRow[]; - statements: KgRow[]; - schema_proposals: KgRow[]; - evidence_links: KgRow[]; - graph_preview?: { nodes: KgRow[]; edges: KgRow[] }; -}; - -type EvidenceHit = { - start_char?: number | null; - end_char?: number | null; - score?: number; - quote?: string; - context?: string; - method?: string; -}; - -const GRAPH_COLORS: Record = { - Entity: "#00D4AA", - Place: "#00D4AA", - ScenicArea: "#13C2C2", - Attraction: "#52C41A", - Area: "#5B8FF9", - ScenicSpot: "#F6BD16", - NaturalFeature: "#269A99", - Facility: "#73D13D", - TransitFacility: "#1677FF", - TransitStop: "#1677FF", - BusLine: "#2F54EB", - TransportMode: "#69B1FF", - RouteTemplate: "#FAAD14", - RouteSegment: "#FA8C16", - PathSchedule: "#D48806", - MediaAsset: "#EB2F96", - Specialty: "#FA541C", - Person: "#722ED1", - Organization: "#531DAB", - Concept: "#9270CA", - Event: "#E8684A", - ConstructionEvent: "#E8684A", - VisitEvent: "#FF9D4D", - ManagementChangeEvent: "#5D7092", -}; - -const STRATEGY_LABELS: Record = { - whole: "整篇", - chunked: "智能分段", -}; - -type ExtractModelOption = { - value: string; - label: string; - disabled?: boolean; -}; - -type ManualTaskRow = { - key: string; - name: string; - document: string; - businessScene: string; - sceneRule: string; - extractModel: string; - fileType: string; - segmentStrategy: string; - directImport: boolean; - status: string; - owner: string; - createdAt: string; - markdownDocument?: string; - error?: string; -}; - -type ManualIngestSession = { - version: 1; - sourceName: string; - sourceUrl: string; - rootEntity: string; - format: string; - extractStrategy: string; - extractModelKey: string; - text: string; - wizardStep: number; - fileType: "local" | "yuque"; - businessScene: string; - promptText: string; - allowSchemaExtra: boolean; - directImport: boolean; - creating: boolean; - taskCreatedAt: string; - taskRecord: ManualTaskRow | null; - result: ExtractResult | null; - convertedMarkdownFile?: string; - convertedMarkdownPath?: string; - updatedAt: number; -}; - -const MANUAL_INGEST_SESSION_KEY = "kg-manual-ingest-session-v1"; -const MANUAL_INGEST_SESSION_EVENT = "kg-manual-ingest-session-updated"; - -function readManualIngestSession(): ManualIngestSession | null { - if (typeof window === "undefined") return null; - try { - const raw = window.localStorage.getItem(MANUAL_INGEST_SESSION_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw) as ManualIngestSession; - return parsed?.version === 1 ? parsed : null; - } catch (error) { - console.warn("Failed to read manual ingest session", error); - return null; - } -} - -function writeManualIngestSession(session: ManualIngestSession | null, notify = false) { - if (typeof window === "undefined") return; - try { - if (!session) { - window.localStorage.removeItem(MANUAL_INGEST_SESSION_KEY); - } else { - window.localStorage.setItem( - MANUAL_INGEST_SESSION_KEY, - JSON.stringify({ ...session, updatedAt: Date.now() }), - ); - } - if (notify) window.dispatchEvent(new Event(MANUAL_INGEST_SESSION_EVENT)); - } catch (error) { - console.warn("Failed to persist manual ingest session", error); - } -} - -const BUSINESS_SCHEMAS = [ - { - scene: "scenic", - label: "景点 / 景区", - schema: "scenic_spot_schema_v0_3", - version: "v0.3", - description: "适合百科、导览词、人工补充材料,按最新景区 Schema 抽景区主体、景点、事件、交通、媒体和内部路径。", - entityTypes: [ - "ScenicArea", - "Attraction", - "Area", - "Facility", - "TransitFacility", - "Specialty", - "BusLine", - "Person", - "Organization", - "Event", - "Concept", - "RouteTemplate", - "TransportMode", - "RouteSegment", - "PathSchedule", - "MediaAsset", - ], - fields: [ - "名称", - "地址", - "行政区", - "开放时间", - "门票价格", - "景区级别", - "建议游玩时长", - "最佳旅行时间", - "主照片", - "导览图", - "全景图", - "交通线路", - "内部路径", - "到达方式", - "步行时间", - "费用", - ], - relations: [ - "LOCATED_IN", - "PART_OF", - "HAS_PART", - "HAS_EVENT", - "EVENT_AT", - "INVOLVES", - "PARTICIPATED_IN", - "HAS_CONCEPT", - "HAS_ROUTE", - "ROUTE_STARTS_AT", - "ROUTE_ENDS_AT", - "ROUTE_USES_LINE", - "ACCESSIBLE_BY", - "STOPS_AT", - "HAS_ROUTE_SEGMENT", - "USES_TRANSPORT_MODE", - "SEGMENT_STARTS_AT", - "SEGMENT_ENDS_AT", - "HAS_SCHEDULE", - "SCENIC_PATH_TO", - "HAS_MEDIA", - "NEARBY_ATTRACTION", - "NEAR_TRANSIT", - ], - }, - { - scene: "dining", - label: "餐饮 / 美食", - schema: "city_dining_poi_schema_v0.1", - version: "v0.1", - description: "适合餐厅、小吃、夜市、商圈餐饮材料,重点抽菜品、人均、营业时间、口味标签和适合人群。", - entityTypes: ["Restaurant", "FoodStall", "Dish", "Cuisine", "DiningDistrict", "Brand"], - fields: ["店名", "地址", "营业时间", "人均消费", "招牌菜", "菜系", "口味标签", "适合场景", "联系电话"], - relations: ["LOCATED_IN", "SERVES_DISH", "HAS_CUISINE", "HAS_PRICE_LEVEL", "NEARBY_POI", "HAS_BUSINESS_HOURS"], - }, - { - scene: "travel_agency", - label: "旅行社", - schema: "travel_agency_business_schema_v0.1", - version: "v0.1", - description: "适合旅行社产品、团期报价、每日行程、景点、酒店餐厅车辆资源、接送报价、业务规则、销售话术和客资线索材料。", - entityTypes: [ - "TourProduct", - "TourVariant", - "ItineraryDay", - "ScenicAttraction", - "HotelResource", - "RestaurantResource", - "VehicleService", - "TransferQuote", - "PolicyRule", - "SalesScript", - "CustomerLead", - "SalesChannel", - "Area", - ], - fields: [ - "产品ID", - "产品名称", - "产品系列", - "产品类型", - "行程天数", - "住宿晚数", - "成团方式", - "最小人数", - "最大人数", - "车型/座椅布局", - "默认酒店等级", - "餐标", - "服务承诺", - "费用包含摘要", - "费用不含摘要", - "自愿消费项目", - "报名须知", - "退费政策", - "风险提示", - "季节标签", - "销售渠道", - "变体名称", - "适用季节/团期", - "适用日期范围", - "人数档", - "房型/客栈类型", - "成人价/结算价", - "儿童价", - "免票人群价", - "单房差", - "景交/小交通费", - "可售规则", - "第几天", - "日标题", - "出发城市", - "抵达城市", - "行程摘要", - "交通摘要", - "用餐", - "住宿", - "车程提示", - "当天自理项目", - "当天提示", - "景点名称", - "景区等级", - "景点类型", - "门票政策", - "景区小交通费", - "可选消费", - "最佳季节", - "天气风险", - "替换景点", - "卖点", - "酒店名称", - "酒店等级", - "区域", - "地址", - "特点", - "挂牌价", - "淡季价", - "旺季价", - "联系人", - "联系电话", - "餐厅名称", - "人均", - "特色菜品", - "适用场景", - "车型", - "座位数", - "舒适等级", - "行李限制", - "服务范围", - "供应方", - "出发地", - "目的地", - "每趟价格", - "报价说明", - "规则类型", - "适用对象", - "规则内容", - "重要性", - "渠道", - "转化阶段", - "触发场景", - "话术模板", - "意图标签", - "需确认客资字段", - "合规提示", - "追单时间", - "来源渠道", - "出行月份", - "出发日期", - "总人数", - "成人数", - "儿童数", - "老人数", - "房间数", - "住宿偏好", - "用车偏好", - "预算档", - "必去景点", - "避让需求", - "异议标签", - "线索状态", - ], - relations: [ - "HAS_VARIANT", - "HAS_DAY", - "VISITS", - "STAYS_AT", - "MEALS_AT", - "USES_VEHICLE", - "HAS_POLICY", - "HAS_SCRIPT", - "MATCHES_PRODUCT", - "FROM_AREA", - "TO_AREA", - "FROM_SOURCE", - "HAS_OBJECTION", - "CAN_UPGRADE_TO", - "REPLACEMENT_FOR", - ], - }, - { - scene: "hotel", - label: "住宿 / 酒店", - schema: "city_hotel_stay_schema_v0.1", - version: "v0.1", - description: "适合酒店、民宿、度假住宿资料,重点抽房型、设施、价格、星级、周边交通和适合客群。", - entityTypes: ["Hotel", "Homestay", "RoomType", "Amenity", "HotelBrand", "TransitFacility"], - fields: ["名称", "地址", "星级/档次", "房型", "价格区间", "设施服务", "入住退房", "周边交通", "适合人群"], - relations: ["LOCATED_IN", "HAS_ROOM_TYPE", "HAS_AMENITY", "NEAR_TRANSIT", "PART_OF_BRAND", "SUITABLE_FOR"], - }, - { - scene: "transit", - label: "交通 / 到达", - schema: "city_transit_access_schema_v0.1", - version: "v0.1", - description: "适合公交、地铁、停车、到达攻略,重点抽线路、站点、换乘、停车场和可达关系。", - entityTypes: ["BusLine", "MetroLine", "TransitStop", "ParkingLot", "RouteTemplate", "District"], - fields: ["线路名称", "站点", "首末班时间", "换乘方式", "票价", "停车信息", "到达目的地", "步行距离"], - relations: ["STOPS_AT", "TRANSFER_AT", "ACCESSIBLE_BY", "ROUTE_STARTS_AT", "ROUTE_ENDS_AT", "NEAR_TRANSIT"], - }, - { - scene: "shopping", - label: "商圈 / 购物", - schema: "city_commercial_area_schema_v0.1", - version: "v0.1", - description: "适合商场、街区、市集材料,重点抽商户、业态、营业时间、活动和消费场景。", - entityTypes: ["CommercialArea", "Mall", "Shop", "Brand", "Event", "DiningPoi"], - fields: ["名称", "地址", "营业时间", "主力店", "业态分类", "活动", "停车", "客群", "周边联动"], - relations: ["LOCATED_IN", "HAS_SHOP", "HAS_EVENT", "HAS_CATEGORY", "NEARBY_POI", "HAS_PARKING"], - }, -]; - -const COUNT_FIELDS = ["entities", "events", "concepts", "relations", "statements"] as const; - -function spanQuote(row: KgRow): string { - const spans = row?.source_spans || []; - if (Array.isArray(spans) && spans[0]?.quote) return String(spans[0].quote); - if (row?.quote) return String(row.quote); - return ""; -} - -function ConfidenceTag({ value }: { value?: number }) { - const score = Number(value ?? 0); - const color = score >= 0.85 ? "green" : score >= 0.75 ? "gold" : "orange"; - return {Number.isFinite(score) ? score.toFixed(2) : "-"}; -} - -function EvidenceButton({ row, onLocate }: { row: KgRow; onLocate: (quote: string) => void }) { - const quote = spanQuote(row); - if (!quote) return 无证据; - return ( - - ); -} - -function SourceHighlighter({ text, hit }: { text: string; hit: EvidenceHit | null }) { - const start = typeof hit?.start_char === "number" ? hit.start_char : -1; - const end = typeof hit?.end_char === "number" ? hit.end_char : -1; - if (!text) { - return ; - } - if (start >= 0 && end > start) { - return ( -
- {text.slice(0, start)} - - {text.slice(start, end)} - - {text.slice(end)} -
- ); - } - return
{text}
; -} - -function MarkdownPreview({ markdown }: { markdown: string }) { - if (!markdown.trim()) { - return ; - } - return ( -
- {markdown.split(/\r?\n/).map((line, index) => { - const key = `${index}-${line.slice(0, 16)}`; - const heading = line.match(/^(#{1,6})\s+(.+)$/); - if (heading) { - const level = Math.min(heading[1].length, 4) as 1 | 2 | 3 | 4; - return {heading[2]}; - } - if (!line.trim()) return
; - if (/^\s*([-*+]|\d+[.)])\s+\S/.test(line)) { - return
{line.replace(/^\s*([-*+]|\d+[.)])\s+/, "")}
; - } - if (line.includes("|") && line.split("|").length >= 3) { - return
{line}
; - } - return

{line}

; - })} -
- ); -} - -function connectedPreviewGraph(graph?: { nodes: KgRow[]; edges: KgRow[] }) { - const allNodes = graph?.nodes || []; - const nodeIds = new Set(allNodes.map((n) => String(n.id))); - const edges = (graph?.edges || []).filter((e) => nodeIds.has(String(e.source)) && nodeIds.has(String(e.target))); - if (!edges.length) return { nodes: [] as KgRow[], edges: [] as KgRow[] }; - const connectedIds = new Set(); - edges.forEach((edge) => { - connectedIds.add(String(edge.source)); - connectedIds.add(String(edge.target)); - }); - const nodes = allNodes.filter((node) => connectedIds.has(String(node.id)) || node.root); - return { nodes, edges }; -} - -function truncateGraphLabel(value: any, max = 14) { - const text = String(value || ""); - return text.length > max ? `${text.slice(0, max)}...` : text; -} - -function buildStableGraphLayout(nodes: KgRow[], edges: KgRow[], width: number, height: number) { - const degree: Record = {}; - const adjacency: Record = {}; - edges.forEach((edge) => { - const source = String(edge.source); - const target = String(edge.target); - degree[source] = (degree[source] || 0) + 1; - degree[target] = (degree[target] || 0) + 1; - adjacency[source] = [...(adjacency[source] || []), target]; - adjacency[target] = [...(adjacency[target] || []), source]; - }); - - const rootNode = - nodes.find((node) => node.root) || - [...nodes].sort((a, b) => (degree[String(b.id)] || 0) - (degree[String(a.id)] || 0))[0]; - const rootId = String(rootNode?.id || nodes[0]?.id || ""); - const levels: Record = {}; - if (rootId) { - levels[rootId] = 0; - const queue = [rootId]; - while (queue.length) { - const current = queue.shift() as string; - const nextLevel = (levels[current] || 0) + 1; - (adjacency[current] || []).forEach((next) => { - if (levels[next] === undefined) { - levels[next] = nextLevel; - queue.push(next); - } - }); - } - } - - const cx = width / 2; - const cy = height / 2; - const maxRadius = Math.max(260, Math.min(width * 0.43, height * 0.42)); - const radiusByLevel = [0, maxRadius * 0.34, maxRadius * 0.64, maxRadius * 0.9]; - const buckets: Record = {}; - nodes.forEach((node) => { - const id = String(node.id); - const level = Math.min(levels[id] ?? 3, 3); - buckets[level] = [...(buckets[level] || []), node]; - }); - - const positioned: Record = {}; - Object.entries(buckets).forEach(([levelKey, bucket]) => { - const level = Number(levelKey); - const sorted = [...bucket].sort((a, b) => { - const byDegree = (degree[String(b.id)] || 0) - (degree[String(a.id)] || 0); - return byDegree || String(a.name || "").localeCompare(String(b.name || ""), "zh-Hans-CN"); - }); - if (level === 0) { - sorted.forEach((node, index) => { - positioned[String(node.id)] = { - x: cx + index * 42, - y: cy, - }; - }); - return; - } - const radius = radiusByLevel[level] || maxRadius; - const startAngle = -Math.PI / 2 + (level - 1) * 0.28; - sorted.forEach((node, index) => { - const angle = startAngle + (Math.PI * 2 * index) / Math.max(sorted.length, 1); - positioned[String(node.id)] = { - x: cx + Math.cos(angle) * radius, - y: cy + Math.sin(angle) * radius * 0.74, - }; - }); - }); - - return { - rootId, - degree, - nodes: nodes.map((node) => ({ - ...node, - ...(positioned[String(node.id)] || { x: cx, y: cy }), - })), - }; -} - -function ManualGraphPreview({ graph }: { graph?: { nodes: KgRow[]; edges: KgRow[] } }) { - const ref = useRef(null); - useEffect(() => { - if (!ref.current) return; - const chart = echarts.init(ref.current, undefined, { renderer: "canvas" }); - const { nodes, edges } = connectedPreviewGraph(graph); - if (!nodes.length || !edges.length) { - chart.clear(); - chart.dispose(); - return; - } - const applyOption = () => { - const { rootId, degree, nodes: layoutNodes } = buildStableGraphLayout( - nodes, - edges, - ref.current?.clientWidth || 1280, - ref.current?.clientHeight || 720, - ); - const groups = Array.from(new Set(nodes.map((n) => n.group || "Entity"))); - chart.setOption({ - animation: false, - tooltip: { - formatter: (params: any) => { - if (params.dataType === "edge") { - return `${params.data.sourceName || params.data.source} → ${params.data.targetName || params.data.target}
${params.data.label || ""}`; - } - return `${params.data.fullName || params.data.name}
${params.data.category || ""}
连接度:${degree[String(params.data.id)] || 0}`; - }, - }, - series: [{ - type: "graph", - layout: "none", - roam: true, - draggable: true, - categories: groups.map((g) => ({ name: g, itemStyle: { color: GRAPH_COLORS[g] || "#6DC8EC" } })), - label: { - show: true, - fontSize: 12, - color: "#1f2329", - position: "right", - distance: 7, - width: 118, - overflow: "truncate", - formatter: (p: any) => p.data.name, - }, - edgeSymbol: ["none", "arrow"], - edgeSymbolSize: [0, 8], - edgeLabel: { - show: true, - formatter: (p: any) => (p.data.showLabel ? p.data.label : ""), - fontSize: 10, - color: "#506070", - backgroundColor: "rgba(255,255,255,0.86)", - borderRadius: 4, - padding: [2, 4], - }, - lineStyle: { color: "#7d8da8", curveness: 0.08, width: 1.6, opacity: 0.78 }, - emphasis: { focus: "adjacency", lineStyle: { width: 3.2, opacity: 1 } }, - data: layoutNodes.map((n) => ({ - id: n.id, - name: truncateGraphLabel(n.name, n.root ? 18 : 12), - fullName: n.name, - value: (n.value || 16) + Math.min(18, (degree[String(n.id)] || 0) * 2), - category: n.group || "Entity", - x: n.x, - y: n.y, - fixed: true, - symbolSize: n.root ? 42 : Math.max(18, (n.value || 16) + Math.min(18, (degree[String(n.id)] || 0) * 2)), - itemStyle: { borderColor: n.root ? "#1d4ed8" : "#fff", borderWidth: n.root ? 3 : 2, shadowBlur: n.root ? 10 : 0, shadowColor: "rgba(29,78,216,0.25)" }, - })), - links: edges.map((e) => ({ - source: e.source, - target: e.target, - label: truncateGraphLabel(e.label, 18), - sourceName: nodes.find((n) => String(n.id) === String(e.source))?.name, - targetName: nodes.find((n) => String(n.id) === String(e.target))?.name, - showLabel: edges.length <= 55 || ((String(e.source) === rootId || String(e.target) === rootId) && edges.length <= 95), - lineStyle: e.inferred ? { type: "dashed", opacity: 0.45, width: 1.1 } : undefined, - })), - }], - }); - }; - applyOption(); - const resize = () => { - chart.resize(); - applyOption(); - }; - window.addEventListener("resize", resize); - return () => { - window.removeEventListener("resize", resize); - chart.dispose(); - }; - }, [graph]); - const { nodes, edges } = connectedPreviewGraph(graph); - if (!nodes.length || !edges.length) { - return ( -
- -
- ); - } - return
; -} - -export default function ManualIngestPanel() { - const [sourceName, setSourceName] = useState(""); - const [sourceUrl, setSourceUrl] = useState(""); - const [rootEntity, setRootEntity] = useState(""); - const [format, setFormat] = useState("auto"); - const [extractMode] = useState("llm"); - const [extractStrategy, setExtractStrategy] = useState("whole"); - const [extractModelKey, setExtractModelKey] = useState(""); - const [extractModelLoading, setExtractModelLoading] = useState(false); - const [extractModelOptions, setExtractModelOptions] = useState([]); - const [extractModelHint, setExtractModelHint] = useState("读取系统设置中的 LLM 模型配置。"); - const [text, setText] = useState(""); - const [wizardStep, setWizardStep] = useState(0); - const [fileType, setFileType] = useState<"local" | "yuque">("local"); - const [businessScene, setBusinessScene] = useState(BUSINESS_SCHEMAS[0].scene); - const [promptText, setPromptText] = useState("请按所选城市业务场景 Schema 抽取,字段和关系必须有原文证据。"); - const [allowSchemaExtra, setAllowSchemaExtra] = useState(false); - const [directImport, setDirectImport] = useState(false); - const [logOpen, setLogOpen] = useState(false); - const [configOpen, setConfigOpen] = useState(false); - const [resultOpen, setResultOpen] = useState(false); - const [resultTab, setResultTab] = useState("graph"); - const [creating, setCreating] = useState(true); - const [taskCreatedAt, setTaskCreatedAt] = useState(""); - const [loading, setLoading] = useState(false); - const [result, setResult] = useState(null); - const [taskRecord, setTaskRecord] = useState(null); - const [hit, setHit] = useState(null); - const [sessionHydrated, setSessionHydrated] = useState(false); - const [fileConverting, setFileConverting] = useState(false); - const [convertedMarkdownFile, setConvertedMarkdownFile] = useState(""); - const [convertedMarkdownPath, setConvertedMarkdownPath] = useState(""); - const [markdownPreviewOpen, setMarkdownPreviewOpen] = useState(false); - const [markdownPreviewLoading, setMarkdownPreviewLoading] = useState(false); - const [markdownPreviewText, setMarkdownPreviewText] = useState(""); - const [markdownPreviewPath, setMarkdownPreviewPath] = useState(""); - const [markdownPreviewTab, setMarkdownPreviewTab] = useState("preview"); - - const summary = result?.summary || {}; - const hasResult = !!result; - const selectedScene = useMemo( - () => BUSINESS_SCHEMAS.find((item) => item.scene === businessScene) || BUSINESS_SCHEMAS[0], - [businessScene], - ); - const selectedExtractModelLabel = useMemo( - () => extractModelOptions.find((item) => item.value === extractModelKey)?.label || "未选择模型", - [extractModelKey, extractModelOptions], - ); - - const restoreSession = () => { - const cached = readManualIngestSession(); - if (!cached) { - setSessionHydrated(true); - return; - } - setSourceName(cached.sourceName || ""); - setSourceUrl(cached.sourceUrl || ""); - setRootEntity(cached.rootEntity || ""); - setFormat(cached.format || "auto"); - setExtractStrategy(cached.extractStrategy || "whole"); - setExtractModelKey(cached.extractModelKey || ""); - setText(cached.text || ""); - setWizardStep(Number.isFinite(cached.wizardStep) ? cached.wizardStep : 0); - setFileType(cached.fileType === "yuque" ? "yuque" : "local"); - setBusinessScene(cached.businessScene || BUSINESS_SCHEMAS[0].scene); - setPromptText(cached.promptText || "请按所选城市业务场景 Schema 抽取,字段和关系必须有原文证据。"); - setAllowSchemaExtra(Boolean(cached.allowSchemaExtra)); - setDirectImport(Boolean(cached.directImport)); - setCreating(Boolean(cached.creating)); - setTaskCreatedAt(cached.taskCreatedAt || ""); - setTaskRecord(cached.taskRecord || null); - setResult(cached.result || null); - setConvertedMarkdownFile(cached.convertedMarkdownFile || ""); - setConvertedMarkdownPath(cached.convertedMarkdownPath || ""); - setLoading(false); - setFileConverting(false); - setHit(null); - setResultOpen(false); - setSessionHydrated(true); - }; - - useEffect(() => { - restoreSession(); - const onSessionUpdated = () => restoreSession(); - window.addEventListener(MANUAL_INGEST_SESSION_EVENT, onSessionUpdated); - return () => window.removeEventListener(MANUAL_INGEST_SESSION_EVENT, onSessionUpdated); - }, []); - - useEffect(() => { - let active = true; - const loadExtractModels = async () => { - setExtractModelLoading(true); - try { - const { data } = await getAgentSettings(); - const extract = data?.extract || {}; - const models = extract.models || {}; - const entries = Object.entries(models) as [string, any][]; - const configured = (model: any) => !!(model?.api_key && model?.base_url); - const nextOptions: ExtractModelOption[] = entries - .filter(([, model]) => configured(model) && model?.enabled) - .map(([key, model]) => ({ - value: key, - label: `${model?.label || key} · ${model?.model || key}`, - disabled: !extract.enabled, - })); - if (!active) return; - setExtractModelOptions(nextOptions); - setExtractModelHint( - extract.enabled - ? "来自系统设置中的 LLM 模型配置;本页每次只调用当前选中的一个知识大模型。" - : "系统设置中的 LLM 模型已停用,请先到 Agent 设置启用。", - ); - setExtractModelKey((current) => ( - nextOptions.some((item) => item.value === current && !item.disabled) - ? current - : nextOptions.find((item) => !item.disabled)?.value || "" - )); - } catch { - if (!active) return; - setExtractModelHint("知识大模型配置加载失败,请稍后重试或检查 Agent 设置。"); - } finally { - if (active) setExtractModelLoading(false); - } - }; - loadExtractModels(); - return () => { - active = false; - }; - }, []); - - const locateEvidence = async (quote: string) => { - if (!quote) return; - try { - const { data } = await api.post("/manual-ingest/evidence-locate", { text, quote }); - setHit(data); - message.success(data.method === "exact_quote" ? "已定位到原文证据" : "已用相似度召回证据位置"); - } catch (err: any) { - message.error(err?.response?.data?.detail || "证据定位失败"); - } - }; - - const runExtract = async () => { - if (fileConverting) { - message.info("请等待文件转换完成"); - return; - } - if (!text.trim()) { - message.warning("请先上传或粘贴文档内容"); - return; - } - if (!extractModelKey) { - message.warning("请选择一个知识大模型"); - return; - } - const createdAt = new Date().toLocaleString("zh-CN", { hour12: false }); - const taskKey = `manual-task-${Date.now()}`; - const pendingTask: ManualTaskRow = { - key: taskKey, - name: documentName, - document: uploadDocument, - businessScene: selectedScene.label, - sceneRule: selectedScene.schema, - extractModel: selectedExtractModelLabel, - fileType: fileType === "local" ? "本地文件" : "语雀文档", - segmentStrategy: STRATEGY_LABELS[extractStrategy], - directImport, - status: "执行中", - owner: "111111", - createdAt, - markdownDocument: convertedMarkdownFile, - }; - setTaskCreatedAt(createdAt); - setTaskRecord(pendingTask); - setResult(null); - setCreating(false); - setLoading(true); - setHit(null); - setResultOpen(false); - message.success("任务已提交"); - writeManualIngestSession({ - version: 1, - sourceName, - sourceUrl, - rootEntity, - format, - extractStrategy, - extractModelKey, - text, - wizardStep, - fileType, - businessScene, - promptText, - allowSchemaExtra, - directImport, - creating: false, - taskCreatedAt: createdAt, - taskRecord: pendingTask, - result: null, - convertedMarkdownFile, - convertedMarkdownPath, - updatedAt: Date.now(), - }, true); - try { - const { data } = await api.post("/manual-ingest/extract", { - text, - source_name: sourceName || "未命名任务", - source_url: sourceUrl, - root_entity: rootEntity, - input_format: format, - extract_mode: extractMode, - extract_model_key: extractModelKey, - extract_strategy: extractStrategy, - business_scene: businessScene, - schema_model: selectedScene.schema, - schema_fields: selectedScene.fields, - schema_entity_types: selectedScene.entityTypes, - schema_relations: selectedScene.relations, - }); - const currentSession = readManualIngestSession(); - if (currentSession?.taskRecord?.key && currentSession.taskRecord.key !== taskKey) return; - const completedTask = { ...pendingTask, status: "完成" }; - setResult(data); - setTaskRecord((current) => (current?.key === taskKey ? completedTask : current)); - writeManualIngestSession({ - version: 1, - sourceName, - sourceUrl, - rootEntity, - format, - extractStrategy, - extractModelKey, - text, - wizardStep, - fileType, - businessScene, - promptText, - allowSchemaExtra, - directImport, - creating: false, - taskCreatedAt: createdAt, - taskRecord: completedTask, - result: data, - convertedMarkdownFile, - convertedMarkdownPath, - updatedAt: Date.now(), - }, true); - setResultOpen(false); - message.success("LLM 深度知识抽取完成"); - } catch (err: any) { - const currentSession = readManualIngestSession(); - if (currentSession?.taskRecord?.key && currentSession.taskRecord.key !== taskKey) return; - const errorDetail = err?.response?.data?.detail || err?.message || "抽取失败"; - const failedTask = { ...pendingTask, status: "失败", error: errorDetail }; - setTaskRecord((current) => (current?.key === taskKey ? failedTask : current)); - writeManualIngestSession({ - version: 1, - sourceName, - sourceUrl, - rootEntity, - format, - extractStrategy, - extractModelKey, - text, - wizardStep, - fileType, - businessScene, - promptText, - allowSchemaExtra, - directImport, - creating: false, - taskCreatedAt: createdAt, - taskRecord: failedTask, - result: null, - convertedMarkdownFile, - convertedMarkdownPath, - updatedAt: Date.now(), - }, true); - message.error(errorDetail); - } finally { - setLoading(false); - } - }; - - const entityColumns = useMemo(() => [ - { title: "ID", dataIndex: "temp_id", width: 180 }, - { title: "名称", dataIndex: "name", width: 150 }, - { title: "类型", dataIndex: "entity_type", width: 120, render: (v: string) => {v} }, - { title: "说明", dataIndex: "description" }, - { title: "置信度", dataIndex: "confidence", width: 90, render: (v: number) => }, - { title: "证据", width: 110, render: (_: any, row: KgRow) => }, - ], [text]); - - const eventColumns = useMemo(() => [ - { title: "ID", dataIndex: "temp_id", width: 190 }, - { title: "发生时间", dataIndex: "time_text", width: 130 }, - { title: "事件", dataIndex: "title" }, - { title: "类型", dataIndex: "event_type", width: 150, render: (v: string) => {v} }, - { title: "置信度", dataIndex: "confidence", width: 90, render: (v: number) => }, - { title: "证据", width: 110, render: (_: any, row: KgRow) => }, - ], [text]); - - const conceptColumns = useMemo(() => [ - { title: "ID", dataIndex: "temp_id", width: 190 }, - { title: "概念", dataIndex: "name", width: 150 }, - { title: "类型", dataIndex: "concept_type", width: 130, render: (v: string) => {v} }, - { title: "说明", dataIndex: "description" }, - { title: "置信度", dataIndex: "confidence", width: 90, render: (v: number) => }, - { title: "证据", width: 110, render: (_: any, row: KgRow) => }, - ], [text]); - - const relationColumns = [ - { title: "关系", dataIndex: "relation_type", width: 180 }, - { title: "Schema", dataIndex: "schema_status", width: 110, render: (v: string) => {v || "core"} }, - { title: "Source", dataIndex: "source_type", width: 120 }, - { title: "Target", dataIndex: "target_type", width: 120 }, - { title: "说明", dataIndex: "description" }, - { title: "置信度", dataIndex: "confidence", width: 90, render: (v: number) => }, - ]; - - const statementColumns = useMemo(() => [ - { title: "主语", dataIndex: "subject_ref", width: 190 }, - { title: "谓词", dataIndex: "predicate", width: 170 }, - { title: "宾语", dataIndex: "object_ref" }, - { title: "类型", dataIndex: "object_kind", width: 90, render: (v: string) => {v} }, - { title: "置信度", dataIndex: "confidence", width: 90, render: (v: number) => }, - { title: "证据", width: 110, render: (_: any, row: KgRow) => }, - ], [text]); - - const trimmedText = text.trim(); - const charCount = trimmedText.length; - const totalCandidates = COUNT_FIELDS.reduce((sum, key) => sum + Number(summary[key] ?? 0), 0); - const documentName = sourceName.trim() || "未命名任务"; - const uploadDocument = sourceUrl.trim() || (fileType === "yuque" ? "语雀文档" : charCount ? "人工粘贴文档" : "-"); - const taskRows = taskRecord ? [taskRecord] : []; - const displayTaskName = taskRecord?.name || documentName; - const displayUploadDocument = taskRecord?.document || uploadDocument; - const displayMarkdownDocument = taskRecord?.markdownDocument || convertedMarkdownFile || convertedMarkdownPath; - const displayBusinessScene = taskRecord?.businessScene || selectedScene.label; - const displaySceneRule = taskRecord?.sceneRule || selectedScene.schema; - const displaySegmentStrategy = taskRecord?.segmentStrategy || STRATEGY_LABELS[extractStrategy]; - const displayExtractModel = taskRecord?.extractModel || selectedExtractModelLabel; - const displayFileType = taskRecord?.fileType || (fileType === "local" ? "本地文件" : "语雀文档"); - const displayDirectImport = taskRecord ? taskRecord.directImport : directImport; - - const logLines = [ - `${taskCreatedAt || new Date().toLocaleString("zh-CN", { hour12: false })}: create knowledge extraction task:${displayTaskName}`, - `${taskCreatedAt || new Date().toLocaleString("zh-CN", { hour12: false })}: upload document:${displayUploadDocument}`, - displayMarkdownDocument - ? `${taskCreatedAt || new Date().toLocaleString("zh-CN", { hour12: false })}: markitdown markdown:${displayMarkdownDocument}` - : "", - `${taskCreatedAt || new Date().toLocaleString("zh-CN", { hour12: false })}: business scene:${displayBusinessScene}`, - `${taskCreatedAt || new Date().toLocaleString("zh-CN", { hour12: false })}: auto schema:${displaySceneRule}`, - `${taskCreatedAt || new Date().toLocaleString("zh-CN", { hour12: false })}: knowledge llm:${displayExtractModel}`, - `${taskCreatedAt || new Date().toLocaleString("zh-CN", { hour12: false })}: segment strategy:${displaySegmentStrategy}`, - loading - ? `${new Date().toLocaleString("zh-CN", { hour12: false })}: invoke extract operator:KAGExtractor chunk:#1` - : `${new Date().toLocaleString("zh-CN", { hour12: false })}: extraction status:${ - result ? "completed" : taskRecord?.status === "失败" ? "failed" : "waiting" - }`, - taskRecord?.error ? `${new Date().toLocaleString("zh-CN", { hour12: false })}: error:${taskRecord.error}` : "", - taskRecord?.status === "失败" && !taskRecord.error - ? `${new Date().toLocaleString("zh-CN", { hour12: false })}: error:旧任务未保存后端错误详情,请重新创建任务以查看准确原因` - : "", - result - ? `${new Date().toLocaleString("zh-CN", { hour12: false })}: imported candidates:${totalCandidates}` - : "", - ].filter(Boolean); - - const resultTabItems = [ - { key: "graph", label: "实例图" }, - { key: "tables", label: "结构化结果" }, - { key: "evidence", label: "证据原文" }, - { key: "schema", label: "Schema 建议" }, - ]; - - const renderResultContent = () => { - if (!result) return null; - if (resultTab === "graph") { - return ; - } - if (resultTab === "tables") { - return ( - -
- Entity 候选 -
- -
- Event 候选 -
- -
- Concept 候选 -
- -
- Relation Schema 候选 -
- -
- Statement 候选 -
`${row.subject_ref}-${row.predicate}-${row.object_ref}`} size="small" dataSource={result.statements} columns={statementColumns} pagination={{ pageSize: 10 }} scroll={{ x: 900 }} /> - - - ); - } - if (resultTab === "evidence") { - return
; - } - return ( - <> - -
`${row.proposal_type}-${row.name}`} - size="small" - dataSource={result.schema_proposals} - pagination={false} - scroll={{ x: 900 }} - columns={[ - { title: "建议类型", dataIndex: "proposal_type", width: 140 }, - { title: "名称", dataIndex: "name", width: 180 }, - { title: "Source", dataIndex: "source_type", width: 120 }, - { title: "Target", dataIndex: "target_type", width: 120 }, - { title: "原因", dataIndex: "reason" }, - { title: "置信度", dataIndex: "confidence", width: 90, render: (value: number) => }, - ]} - /> - - ); - }; - - useEffect(() => { - if (!sessionHydrated) return; - writeManualIngestSession({ - version: 1, - sourceName, - sourceUrl, - rootEntity, - format, - extractStrategy, - extractModelKey, - text, - wizardStep, - fileType, - businessScene, - promptText, - allowSchemaExtra, - directImport, - creating, - taskCreatedAt, - taskRecord, - result, - convertedMarkdownFile, - convertedMarkdownPath, - updatedAt: Date.now(), - }); - }, [ - sessionHydrated, - sourceName, - sourceUrl, - rootEntity, - format, - extractStrategy, - extractModelKey, - text, - wizardStep, - fileType, - businessScene, - promptText, - allowSchemaExtra, - directImport, - creating, - taskCreatedAt, - taskRecord, - result, - convertedMarkdownFile, - convertedMarkdownPath, - ]); - - const fillSample = () => { - setText(SAMPLE_TEXT); - setRootEntity("花溪公园"); - setSourceName("旅行示例文档"); - setSourceUrl("demo-source://manual-ingest/huaxi-park.txt"); - setBusinessScene("scenic"); - }; - - const resetDraft = () => { - setSourceName(""); - setSourceUrl(""); - setRootEntity(""); - setFormat("auto"); - setExtractStrategy("whole"); - setExtractModelKey(extractModelOptions.find((item) => !item.disabled)?.value || ""); - setBusinessScene(BUSINESS_SCHEMAS[0].scene); - setAllowSchemaExtra(false); - setDirectImport(false); - setText(""); - setConvertedMarkdownFile(""); - setConvertedMarkdownPath(""); - setMarkdownPreviewOpen(false); - setMarkdownPreviewText(""); - setMarkdownPreviewPath(""); - setHit(null); - setWizardStep(0); - }; - - const startCreateTask = () => { - resetDraft(); - setResultOpen(false); - setCreating(true); - }; - - const cancelCreateTask = () => { - resetDraft(); - setResultOpen(false); - setCreating(false); - }; - - const deleteTask = () => { - resetDraft(); - setResult(null); - setTaskRecord(null); - setTaskCreatedAt(""); - setResultOpen(false); - setLogOpen(false); - setConfigOpen(false); - setCreating(false); - writeManualIngestSession(null, true); - }; - - const changeBusinessScene = (scene: string) => { - const nextScene = BUSINESS_SCHEMAS.find((item) => item.scene === scene) || BUSINESS_SCHEMAS[0]; - setBusinessScene(nextScene.scene); - }; - - const convertUploadToMarkdown = async (file: File) => { - setFileConverting(true); - setHit(null); - const formData = new FormData(); - formData.append("file", file); - try { - const { data } = await api.post("/manual-ingest/convert-file", formData); - const markdown = String(data?.markdown || ""); - if (!markdown.trim()) { - message.warning("文件已上传,但未转换出可抽取文本"); - return; - } - const filename = String(data?.filename || file.name); - const markdownPath = String(data?.markdown_path || ""); - setText(markdown); - setSourceUrl(filename); - setFormat("markdown"); - setConvertedMarkdownFile(markdownPath || `${filename}.markitdown.md`); - setConvertedMarkdownPath(markdownPath); - if (!sourceName.trim()) { - setSourceName(filename.replace(/\.[^.]+$/, "")); - } - message.success(`已转换为 MarkItDown Markdown,共 ${Number(data?.markdown_chars || markdown.length)} 字`); - } catch (err: any) { - message.error(err?.response?.data?.detail || err?.message || "文件转换失败"); - } finally { - setFileConverting(false); - } - }; - - const openMarkdownPreview = async () => { - const path = convertedMarkdownPath || convertedMarkdownFile; - setMarkdownPreviewPath(path); - setMarkdownPreviewText(text); - setMarkdownPreviewTab("preview"); - setMarkdownPreviewOpen(true); - if (!path) return; - setMarkdownPreviewLoading(true); - try { - const { data } = await api.get("/manual-ingest/markdown-preview", { params: { path } }); - setMarkdownPreviewText(String(data?.markdown || text)); - setMarkdownPreviewPath(String(data?.markdown_path || path)); - } catch (err: any) { - message.warning(err?.response?.data?.detail || "读取 Markdown 文件失败,先显示当前文档内容"); - } finally { - setMarkdownPreviewLoading(false); - } - }; - - const nextStep = () => { - if (wizardStep === 0) { - if (fileConverting) { - message.info("请等待文件转换完成"); - return; - } - if (!sourceName.trim()) { - message.warning("请输入知识任务名称"); - return; - } - if (!text.trim()) { - message.warning("请上传或粘贴文档内容"); - return; - } - } - setWizardStep((step) => Math.min(step + 1, 2)); - }; - - const renderStepContent = () => { - if (wizardStep === 0) { - return ( -
-
- - setSourceName(event.target.value)} placeholder="请输入" /> -
- -
- - setFileType(event.target.value)}> - 本地文件 - 语雀文档 - -
- - {fileType === "yuque" && ( -
- - setSourceUrl(event.target.value)} placeholder="请输入语雀文档链接" /> -
- )} - -
- - { - void convertUploadToMarkdown(file as File); - return Upload.LIST_IGNORE; - }} - > -

-

{fileConverting ? "正在转换为 MarkItDown Markdown..." : "点击或将文件拖拽到这里上传"}

-

支持 PDF、Word、PPT、Excel、HTML、Markdown、CSV、JSON、XML、图片、音频和 ZIP

-
- {convertedMarkdownFile && ( - - )} -
- -
- -