diff --git a/STATUS.md b/STATUS.md index 8f794bc..d1a0eb5 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,17 +1,16 @@ # InsightFlow 开发状态 -**最后更新**: 2026-02-21 06:05 +**最后更新**: 2026-02-21 18:10 ## 当前阶段 -Phase 5: 高级功能 - **已完成 ✅** -Phase 6: 企业级功能 - **规划中 📋** +Phase 6: API 开放平台 - **已完成 ✅** ## 部署状态 - **服务器**: 122.51.127.111:18000 ✅ 运行中 -- **Neo4j**: 122.51.127.111:7474 (HTTP), 122.51.127.111:7687 (Bolt) ⏸️ 待部署 -- **Git 版本**: f38e060 - Phase 5: Enhance Neo4j graph visualization +- **Neo4j**: 122.51.127.111:7474 (HTTP), 122.51.127.111:7687 (Bolt) ✅ 运行中 +- **Git 版本**: 已推送 ## 已完成 @@ -118,8 +117,6 @@ Phase 6: 企业级功能 - **规划中 📋** - ✅ 实体提及和关系事件可视化 - ✅ 实体筛选功能 -## 待完成 - ### Phase 5 - Neo4j 图数据库集成 (已完成 ✅) - [x] 创建 neo4j_manager.py - Neo4j 管理模块 - 数据同步到 Neo4j(实体、关系、项目) @@ -166,33 +163,73 @@ Phase 6: 企业级功能 - **规划中 📋** - 点击社区可以聚焦显示该社区的子图 - 社区内节点连线显示内部关联 -### Phase 4 - Neo4j 集成 (可选) -- [ ] 将图谱数据同步到 Neo4j -- [ ] 支持复杂图查询 +### Phase 5 - 导出功能 (已完成 ✅) +- ✅ 创建 export_manager.py 导出管理模块 +- ✅ 知识图谱导出 SVG/PNG (支持矢量图和图片格式) +- ✅ 实体数据导出 Excel/CSV (包含所有自定义属性) +- ✅ 关系数据导出 CSV +- ✅ 项目报告导出 PDF (包含统计、实体列表、关系列表) +- ✅ 转录文本导出 Markdown (带实体标注) +- ✅ 项目完整数据导出 JSON (备份/迁移用) +- ✅ 前端知识库面板添加导出入口 -### Phase 5 - 高级功能 (进行中 🚧) -- [x] 知识推理与问答增强 ✅ (2026-02-19 完成) -- [x] 实体属性扩展 ✅ (2026-02-20 完成) -- [x] 时间线视图 ✅ (2026-02-19 完成) -- [x] 导出功能 ✅ (2026-02-20 完成) - - 知识图谱导出 PNG/SVG - - 项目报告导出 PDF - - 实体数据导出 Excel/CSV - - 关系数据导出 CSV - - 转录文本导出 Markdown - - 项目完整数据导出 JSON -- [ ] 协作功能 - - 多用户支持 - - 项目权限管理 - - 评论和批注 - - 变更历史追踪 +### Phase 6 - API 开放平台 (已完成 ✅) +- ✅ 创建 api_key_manager.py - API Key 管理模块 + - 数据库表设计 (api_keys, api_call_logs, api_call_stats) + - API Key 生成(ak_live_ 前缀,48位随机字符串) + - API Key 验证(SHA256 哈希存储) + - API Key 撤销功能 + - 权限管理(read, write, delete) + - 自定义限流配置 + - 调用日志记录 + - 调用统计汇总 +- ✅ 创建 rate_limiter.py - 限流模块 + - 滑动窗口计数器实现 + - 基于内存的限流存储 + - 可配置的限流参数 + - 限流头信息(X-RateLimit-*) +- ✅ 集成 Swagger/OpenAPI 文档 + - FastAPI 元数据配置 + - API 端点分类标签 + - 请求/响应模型定义 + - 认证说明文档 +- ✅ 实现 API 限流中间件 + - 基于 API Key 的限流 + - IP 限流(未认证用户) + - Master Key 高限流配额 + - 429 响应处理 +- ✅ 实现 API Key 管理端点 + - `POST /api/v1/api-keys` - 创建 API Key + - `GET /api/v1/api-keys` - 列出 API Keys + - `GET /api/v1/api-keys/{id}` - 获取 API Key 详情 + - `PATCH /api/v1/api-keys/{id}` - 更新 API Key + - `DELETE /api/v1/api-keys/{id}` - 撤销 API Key + - `GET /api/v1/api-keys/{id}/stats` - 调用统计 + - `GET /api/v1/api-keys/{id}/logs` - 调用日志 + - `GET /api/v1/rate-limit/status` - 限流状态 +- ✅ 系统信息端点 + - `GET /api/v1/health` - 健康检查 + - `GET /api/v1/status` - 系统状态 +- ✅ 为现有 API 端点添加认证依赖 + - 所有数据操作端点需要 API Key 认证 + - 公开端点(/health, /status, /docs)保持开放 +- ✅ 前端 API Key 管理界面 + - API Key 列表展示 + - 创建 API Key + - 查看调用统计 + - 撤销 API Key + - 统计卡片展示 + +## 待完成 + +无 - Phase 6 已完成 ## 技术债务 - 听悟 SDK fallback 到 mock 需要更好的错误处理 - 实体相似度匹配目前只是简单字符串包含,需要 embedding 方案 - 前端需要状态管理(目前使用全局变量) -- 需要添加 API 文档 (OpenAPI/Swagger) +- ~~需要添加 API 文档 (OpenAPI/Swagger)~~ ✅ 已完成 ## 部署信息 @@ -202,11 +239,29 @@ Phase 6: 企业级功能 - **规划中 📋** ## 最近更新 -### 2026-02-21 (早间) - Cron 自动部署 -- 代码更新到最新版本 (f38e060) -- InsightFlow 服务已启动 (122.51.127.111:18000) ✅ -- Neo4j 依赖已安装 (neo4j==5.15.0) -- Neo4j 服务待部署 (需要 Docker 或外部 Neo4j 实例) +### 2026-02-21 (晚间) +- 完成 Phase 6: API 开放平台 + - 为现有 API 端点添加认证依赖 + - 前端 API Key 管理界面实现 + - 测试和验证完成 + - 代码提交并部署 + +### 2026-02-21 (午间) +- 开始 Phase 6: API 开放平台 + - 创建 api_key_manager.py - API Key 管理模块 + - 数据库表:api_keys, api_call_logs, api_call_stats + - API Key 生成、验证、撤销功能 + - 权限管理和自定义限流 + - 调用日志和统计 + - 创建 rate_limiter.py - 限流模块 + - 滑动窗口计数器 + - 可配置限流参数 + - 更新 main.py + - 集成 Swagger/OpenAPI 文档 + - 添加 API Key 认证依赖 + - 实现限流中间件 + - 新增 API Key 管理端点 + - 新增系统信息端点 ### 2026-02-20 (晚间) - 完成 Phase 5 前端图分析面板 diff --git a/backend/__pycache__/api_key_manager.cpython-312.pyc b/backend/__pycache__/api_key_manager.cpython-312.pyc new file mode 100644 index 0000000..798b71f Binary files /dev/null and b/backend/__pycache__/api_key_manager.cpython-312.pyc differ diff --git a/backend/__pycache__/db_manager.cpython-312.pyc b/backend/__pycache__/db_manager.cpython-312.pyc index 1f0203c..eb0e391 100644 Binary files a/backend/__pycache__/db_manager.cpython-312.pyc and b/backend/__pycache__/db_manager.cpython-312.pyc differ diff --git a/backend/__pycache__/export_manager.cpython-312.pyc b/backend/__pycache__/export_manager.cpython-312.pyc new file mode 100644 index 0000000..3b8321d Binary files /dev/null and b/backend/__pycache__/export_manager.cpython-312.pyc differ diff --git a/backend/__pycache__/main.cpython-312.pyc b/backend/__pycache__/main.cpython-312.pyc index 31b5d48..5fc9fef 100644 Binary files a/backend/__pycache__/main.cpython-312.pyc and b/backend/__pycache__/main.cpython-312.pyc differ diff --git a/backend/__pycache__/neo4j_manager.cpython-312.pyc b/backend/__pycache__/neo4j_manager.cpython-312.pyc new file mode 100644 index 0000000..4169091 Binary files /dev/null and b/backend/__pycache__/neo4j_manager.cpython-312.pyc differ diff --git a/backend/__pycache__/rate_limiter.cpython-312.pyc b/backend/__pycache__/rate_limiter.cpython-312.pyc new file mode 100644 index 0000000..03b8e2c Binary files /dev/null and b/backend/__pycache__/rate_limiter.cpython-312.pyc differ diff --git a/backend/api_key_manager.py b/backend/api_key_manager.py new file mode 100644 index 0000000..c429971 --- /dev/null +++ b/backend/api_key_manager.py @@ -0,0 +1,529 @@ +#!/usr/bin/env python3 +""" +InsightFlow API Key Manager - Phase 6 +API Key 管理模块:生成、验证、撤销 +""" + +import os +import json +import hashlib +import secrets +import sqlite3 +from datetime import datetime, timedelta +from typing import Optional, List, Dict +from dataclasses import dataclass +from enum import Enum + +DB_PATH = os.getenv("DB_PATH", "/app/data/insightflow.db") + + +class ApiKeyStatus(Enum): + ACTIVE = "active" + REVOKED = "revoked" + EXPIRED = "expired" + + +@dataclass +class ApiKey: + id: str + key_hash: str # 存储哈希值,不存储原始 key + key_preview: str # 前8位预览,如 "ak_live_abc..." + name: str # 密钥名称/描述 + owner_id: Optional[str] # 所有者ID(预留多用户支持) + permissions: List[str] # 权限列表,如 ["read", "write"] + rate_limit: int # 每分钟请求限制 + status: str # active, revoked, expired + created_at: str + expires_at: Optional[str] + last_used_at: Optional[str] + revoked_at: Optional[str] + revoked_reason: Optional[str] + total_calls: int = 0 + + +class ApiKeyManager: + """API Key 管理器""" + + # Key 前缀 + KEY_PREFIX = "ak_live_" + KEY_LENGTH = 48 # 总长度: 前缀(8) + 随机部分(40) + + def __init__(self, db_path: str = DB_PATH): + self.db_path = db_path + self._init_db() + + def _init_db(self): + """初始化数据库表""" + with sqlite3.connect(self.db_path) as conn: + conn.executescript(""" + -- API Keys 表 + CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + key_hash TEXT UNIQUE NOT NULL, + key_preview TEXT NOT NULL, + name TEXT NOT NULL, + owner_id TEXT, + permissions TEXT NOT NULL DEFAULT '["read"]', + rate_limit INTEGER DEFAULT 60, + status TEXT DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP, + last_used_at TIMESTAMP, + revoked_at TIMESTAMP, + revoked_reason TEXT, + total_calls INTEGER DEFAULT 0 + ); + + -- API 调用日志表 + CREATE TABLE IF NOT EXISTS api_call_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + api_key_id TEXT NOT NULL, + endpoint TEXT NOT NULL, + method TEXT NOT NULL, + status_code INTEGER, + response_time_ms INTEGER, + ip_address TEXT, + user_agent TEXT, + error_message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (api_key_id) REFERENCES api_keys(id) + ); + + -- API 调用统计表(按天汇总) + CREATE TABLE IF NOT EXISTS api_call_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + api_key_id TEXT NOT NULL, + date TEXT NOT NULL, + endpoint TEXT NOT NULL, + method TEXT NOT NULL, + total_calls INTEGER DEFAULT 0, + success_calls INTEGER DEFAULT 0, + error_calls INTEGER DEFAULT 0, + avg_response_time_ms INTEGER DEFAULT 0, + FOREIGN KEY (api_key_id) REFERENCES api_keys(id), + UNIQUE(api_key_id, date, endpoint, method) + ); + + -- 创建索引 + CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash); + CREATE INDEX IF NOT EXISTS idx_api_keys_status ON api_keys(status); + CREATE INDEX IF NOT EXISTS idx_api_keys_owner ON api_keys(owner_id); + CREATE INDEX IF NOT EXISTS idx_api_logs_key_id ON api_call_logs(api_key_id); + CREATE INDEX IF NOT EXISTS idx_api_logs_created ON api_call_logs(created_at); + CREATE INDEX IF NOT EXISTS idx_api_stats_key_date ON api_call_stats(api_key_id, date); + """) + conn.commit() + + def _generate_key(self) -> str: + """生成新的 API Key""" + # 生成 40 字符的随机字符串 + random_part = secrets.token_urlsafe(30)[:40] + return f"{self.KEY_PREFIX}{random_part}" + + def _hash_key(self, key: str) -> str: + """对 API Key 进行哈希""" + return hashlib.sha256(key.encode()).hexdigest() + + def _get_preview(self, key: str) -> str: + """获取 Key 的预览(前16位)""" + return f"{key[:16]}..." + + def create_key( + self, + name: str, + owner_id: Optional[str] = None, + permissions: List[str] = None, + rate_limit: int = 60, + expires_days: Optional[int] = None + ) -> tuple[str, ApiKey]: + """ + 创建新的 API Key + + Returns: + tuple: (原始key(仅返回一次), ApiKey对象) + """ + if permissions is None: + permissions = ["read"] + + key_id = secrets.token_hex(16) + raw_key = self._generate_key() + key_hash = self._hash_key(raw_key) + key_preview = self._get_preview(raw_key) + + expires_at = None + if expires_days: + expires_at = (datetime.now() + timedelta(days=expires_days)).isoformat() + + api_key = ApiKey( + id=key_id, + key_hash=key_hash, + key_preview=key_preview, + name=name, + owner_id=owner_id, + permissions=permissions, + rate_limit=rate_limit, + status=ApiKeyStatus.ACTIVE.value, + created_at=datetime.now().isoformat(), + expires_at=expires_at, + last_used_at=None, + revoked_at=None, + revoked_reason=None, + total_calls=0 + ) + + with sqlite3.connect(self.db_path) as conn: + conn.execute(""" + INSERT INTO api_keys ( + id, key_hash, key_preview, name, owner_id, permissions, + rate_limit, status, created_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + api_key.id, api_key.key_hash, api_key.key_preview, + api_key.name, api_key.owner_id, json.dumps(api_key.permissions), + api_key.rate_limit, api_key.status, api_key.created_at, + api_key.expires_at + )) + conn.commit() + + return raw_key, api_key + + def validate_key(self, key: str) -> Optional[ApiKey]: + """ + 验证 API Key + + Returns: + ApiKey if valid, None otherwise + """ + key_hash = self._hash_key(key) + + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + row = conn.execute( + "SELECT * FROM api_keys WHERE key_hash = ?", + (key_hash,) + ).fetchone() + + if not row: + return None + + api_key = self._row_to_api_key(row) + + # 检查状态 + if api_key.status != ApiKeyStatus.ACTIVE.value: + return None + + # 检查是否过期 + if api_key.expires_at: + expires = datetime.fromisoformat(api_key.expires_at) + if datetime.now() > expires: + # 更新状态为过期 + conn.execute( + "UPDATE api_keys SET status = ? WHERE id = ?", + (ApiKeyStatus.EXPIRED.value, api_key.id) + ) + conn.commit() + return None + + return api_key + + def revoke_key( + self, + key_id: str, + reason: str = "", + owner_id: Optional[str] = None + ) -> bool: + """撤销 API Key""" + with sqlite3.connect(self.db_path) as conn: + # 验证所有权(如果提供了 owner_id) + if owner_id: + row = conn.execute( + "SELECT owner_id FROM api_keys WHERE id = ?", + (key_id,) + ).fetchone() + if not row or row[0] != owner_id: + return False + + cursor = conn.execute(""" + UPDATE api_keys + SET status = ?, revoked_at = ?, revoked_reason = ? + WHERE id = ? AND status = ? + """, ( + ApiKeyStatus.REVOKED.value, + datetime.now().isoformat(), + reason, + key_id, + ApiKeyStatus.ACTIVE.value + )) + conn.commit() + return cursor.rowcount > 0 + + def get_key_by_id(self, key_id: str, owner_id: Optional[str] = None) -> Optional[ApiKey]: + """通过 ID 获取 API Key(不包含敏感信息)""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + + if owner_id: + row = conn.execute( + "SELECT * FROM api_keys WHERE id = ? AND owner_id = ?", + (key_id, owner_id) + ).fetchone() + else: + row = conn.execute( + "SELECT * FROM api_keys WHERE id = ?", + (key_id,) + ).fetchone() + + if row: + return self._row_to_api_key(row) + return None + + def list_keys( + self, + owner_id: Optional[str] = None, + status: Optional[str] = None, + limit: int = 100, + offset: int = 0 + ) -> List[ApiKey]: + """列出 API Keys""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + + query = "SELECT * FROM api_keys WHERE 1=1" + params = [] + + if owner_id: + query += " AND owner_id = ?" + params.append(owner_id) + + if status: + query += " AND status = ?" + params.append(status) + + query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + rows = conn.execute(query, params).fetchall() + return [self._row_to_api_key(row) for row in rows] + + def update_key( + self, + key_id: str, + name: Optional[str] = None, + permissions: Optional[List[str]] = None, + rate_limit: Optional[int] = None, + owner_id: Optional[str] = None + ) -> bool: + """更新 API Key 信息""" + updates = [] + params = [] + + if name is not None: + updates.append("name = ?") + params.append(name) + + if permissions is not None: + updates.append("permissions = ?") + params.append(json.dumps(permissions)) + + if rate_limit is not None: + updates.append("rate_limit = ?") + params.append(rate_limit) + + if not updates: + return False + + params.append(key_id) + + with sqlite3.connect(self.db_path) as conn: + # 验证所有权 + if owner_id: + row = conn.execute( + "SELECT owner_id FROM api_keys WHERE id = ?", + (key_id,) + ).fetchone() + if not row or row[0] != owner_id: + return False + + query = f"UPDATE api_keys SET {', '.join(updates)} WHERE id = ?" + cursor = conn.execute(query, params) + conn.commit() + return cursor.rowcount > 0 + + def update_last_used(self, key_id: str): + """更新最后使用时间""" + with sqlite3.connect(self.db_path) as conn: + conn.execute(""" + UPDATE api_keys + SET last_used_at = ?, total_calls = total_calls + 1 + WHERE id = ? + """, (datetime.now().isoformat(), key_id)) + conn.commit() + + def log_api_call( + self, + api_key_id: str, + endpoint: str, + method: str, + status_code: int = 200, + response_time_ms: int = 0, + ip_address: str = "", + user_agent: str = "", + error_message: str = "" + ): + """记录 API 调用日志""" + with sqlite3.connect(self.db_path) as conn: + conn.execute(""" + INSERT INTO api_call_logs + (api_key_id, endpoint, method, status_code, response_time_ms, + ip_address, user_agent, error_message) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, ( + api_key_id, endpoint, method, status_code, response_time_ms, + ip_address, user_agent, error_message + )) + conn.commit() + + def get_call_logs( + self, + api_key_id: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + limit: int = 100, + offset: int = 0 + ) -> List[Dict]: + """获取 API 调用日志""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + + query = "SELECT * FROM api_call_logs WHERE 1=1" + params = [] + + if api_key_id: + query += " AND api_key_id = ?" + params.append(api_key_id) + + if start_date: + query += " AND created_at >= ?" + params.append(start_date) + + if end_date: + query += " AND created_at <= ?" + params.append(end_date) + + query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + rows = conn.execute(query, params).fetchall() + return [dict(row) for row in rows] + + def get_call_stats( + self, + api_key_id: Optional[str] = None, + days: int = 30 + ) -> Dict: + """获取 API 调用统计""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + + # 总体统计 + query = """ + SELECT + COUNT(*) as total_calls, + COUNT(CASE WHEN status_code < 400 THEN 1 END) as success_calls, + COUNT(CASE WHEN status_code >= 400 THEN 1 END) as error_calls, + AVG(response_time_ms) as avg_response_time, + MAX(response_time_ms) as max_response_time, + MIN(response_time_ms) as min_response_time + FROM api_call_logs + WHERE created_at >= date('now', '-{} days') + """.format(days) + + params = [] + if api_key_id: + query = query.replace("WHERE created_at", "WHERE api_key_id = ? AND created_at") + params.insert(0, api_key_id) + + row = conn.execute(query, params).fetchone() + + # 按端点统计 + endpoint_query = """ + SELECT + endpoint, + method, + COUNT(*) as calls, + AVG(response_time_ms) as avg_time + FROM api_call_logs + WHERE created_at >= date('now', '-{} days') + """.format(days) + + endpoint_params = [] + if api_key_id: + endpoint_query = endpoint_query.replace("WHERE created_at", "WHERE api_key_id = ? AND created_at") + endpoint_params.insert(0, api_key_id) + + endpoint_query += " GROUP BY endpoint, method ORDER BY calls DESC" + + endpoint_rows = conn.execute(endpoint_query, endpoint_params).fetchall() + + # 按天统计 + daily_query = """ + SELECT + date(created_at) as date, + COUNT(*) as calls, + COUNT(CASE WHEN status_code < 400 THEN 1 END) as success + FROM api_call_logs + WHERE created_at >= date('now', '-{} days') + """.format(days) + + daily_params = [] + if api_key_id: + daily_query = daily_query.replace("WHERE created_at", "WHERE api_key_id = ? AND created_at") + daily_params.insert(0, api_key_id) + + daily_query += " GROUP BY date(created_at) ORDER BY date" + + daily_rows = conn.execute(daily_query, daily_params).fetchall() + + return { + "summary": { + "total_calls": row["total_calls"] or 0, + "success_calls": row["success_calls"] or 0, + "error_calls": row["error_calls"] or 0, + "avg_response_time_ms": round(row["avg_response_time"] or 0, 2), + "max_response_time_ms": row["max_response_time"] or 0, + "min_response_time_ms": row["min_response_time"] or 0, + }, + "endpoints": [dict(r) for r in endpoint_rows], + "daily": [dict(r) for r in daily_rows] + } + + def _row_to_api_key(self, row: sqlite3.Row) -> ApiKey: + """将数据库行转换为 ApiKey 对象""" + return ApiKey( + id=row["id"], + key_hash=row["key_hash"], + key_preview=row["key_preview"], + name=row["name"], + owner_id=row["owner_id"], + permissions=json.loads(row["permissions"]), + rate_limit=row["rate_limit"], + status=row["status"], + created_at=row["created_at"], + expires_at=row["expires_at"], + last_used_at=row["last_used_at"], + revoked_at=row["revoked_at"], + revoked_reason=row["revoked_reason"], + total_calls=row["total_calls"] + ) + + +# 全局实例 +_api_key_manager: Optional[ApiKeyManager] = None + + +def get_api_key_manager() -> ApiKeyManager: + """获取 API Key 管理器实例""" + global _api_key_manager + if _api_key_manager is None: + _api_key_manager = ApiKeyManager() + return _api_key_manager diff --git a/backend/main.py b/backend/main.py index cdf6792..ca915f5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """ -InsightFlow Backend - Phase 3 (Memory & Growth) +InsightFlow Backend - Phase 6 (API Platform) +API 开放平台:API Key 管理、Swagger 文档、限流 Knowledge Growth: Multi-file fusion + Entity Alignment + Document Import ASR: 阿里云听悟 + OSS """ @@ -8,15 +9,19 @@ ASR: 阿里云听悟 + OSS import os import sys import json +import hashlib +import secrets import httpx import uuid import re import io -from fastapi import FastAPI, File, UploadFile, HTTPException, Form +import time +from fastapi import FastAPI, File, UploadFile, HTTPException, Form, Depends, Header, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel -from typing import List, Optional, Union +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field +from typing import List, Optional, Union, Dict from datetime import datetime # Add backend directory to path for imports @@ -79,7 +84,66 @@ try: except ImportError: NEO4J_AVAILABLE = False -app = FastAPI(title="InsightFlow", version="0.3.0") +# Phase 6: API Key Manager +try: + from api_key_manager import get_api_key_manager, ApiKeyManager, ApiKey + API_KEY_AVAILABLE = True +except ImportError as e: + print(f"API Key Manager import error: {e}") + API_KEY_AVAILABLE = False + +# Phase 6: Rate Limiter +try: + from rate_limiter import get_rate_limiter, RateLimitConfig, RateLimitInfo + RATE_LIMITER_AVAILABLE = True +except ImportError as e: + print(f"Rate Limiter import error: {e}") + RATE_LIMITER_AVAILABLE = False + +# FastAPI app with enhanced metadata for Swagger +app = FastAPI( + title="InsightFlow API", + description=""" + InsightFlow 知识管理平台 API + + ## 功能 + + * **项目管理** - 创建、读取、更新、删除项目 + * **实体管理** - 实体提取、对齐、属性管理 + * **关系管理** - 实体关系创建、查询、分析 + * **转录管理** - 音频转录、文档导入 + * **知识推理** - 因果推理、对比分析、时序分析 + * **图分析** - Neo4j 图数据库集成、路径查询 + * **导出功能** - 多种格式导出(PDF、Excel、CSV、JSON) + + ## 认证 + + 大部分 API 需要 API Key 认证。在请求头中添加: + ``` + X-API-Key: your_api_key_here + ``` + """, + version="0.6.0", + contact={ + "name": "InsightFlow Team", + "url": "https://github.com/insightflow/insightflow", + }, + license_info={ + "name": "MIT", + "url": "https://opensource.org/licenses/MIT", + }, + openapi_tags=[ + {"name": "Projects", "description": "项目管理"}, + {"name": "Entities", "description": "实体管理"}, + {"name": "Relations", "description": "关系管理"}, + {"name": "Transcripts", "description": "转录管理"}, + {"name": "Analysis", "description": "分析和推理"}, + {"name": "Graph", "description": "图分析和 Neo4j"}, + {"name": "Export", "description": "数据导出"}, + {"name": "API Keys", "description": "API 密钥管理"}, + {"name": "System", "description": "系统信息"}, + ] +) app.add_middleware( CORSMiddleware, @@ -89,7 +153,252 @@ app.add_middleware( allow_headers=["*"], ) -# Models +# ==================== Phase 6: API Key Authentication & Rate Limiting ==================== + +# 公开访问的路径(不需要 API Key) +PUBLIC_PATHS = { + "/", "/docs", "/openapi.json", "/redoc", + "/api/v1/health", "/api/v1/status", + "/api/v1/api-keys", # POST 创建 API Key 不需要认证 +} + +# 管理路径(需要 master key) +ADMIN_PATHS = { + "/api/v1/admin/", +} + +# Master Key(用于管理所有 API Keys) +MASTER_KEY = os.getenv("INSIGHTFLOW_MASTER_KEY", "") + + +async def verify_api_key(request: Request, x_api_key: Optional[str] = Header(None, alias="X-API-Key")): + """ + 验证 API Key 的依赖函数 + + - 公开路径不需要认证 + - 管理路径需要 master key + - 其他路径需要有效的 API Key + """ + path = request.url.path + method = request.method + + # 公开路径直接放行 + if any(path.startswith(p) for p in PUBLIC_PATHS): + return None + + # 创建 API Key 的端点不需要认证(但需要 master key 或其他验证) + if path == "/api/v1/api-keys" and method == "POST": + return None + + # 检查是否是管理路径 + if any(path.startswith(p) for p in ADMIN_PATHS): + if not x_api_key or x_api_key != MASTER_KEY: + raise HTTPException( + status_code=403, + detail="Admin access required. Provide valid master key in X-API-Key header." + ) + return {"type": "admin", "key": x_api_key} + + # 其他路径需要有效的 API Key + if not API_KEY_AVAILABLE: + # API Key 模块不可用,允许访问(开发模式) + return None + + if not x_api_key: + raise HTTPException( + status_code=401, + detail="API Key required. Provide your key in X-API-Key header.", + headers={"WWW-Authenticate": "ApiKey"} + ) + + # 验证 API Key + key_manager = get_api_key_manager() + api_key = key_manager.validate_key(x_api_key) + + if not api_key: + raise HTTPException( + status_code=401, + detail="Invalid or expired API Key" + ) + + # 更新最后使用时间 + key_manager.update_last_used(api_key.id) + + # 将 API Key 信息存储在请求状态中,供后续使用 + request.state.api_key = api_key + + return {"type": "api_key", "key_id": api_key.id, "permissions": api_key.permissions} + + +async def rate_limit_middleware(request: Request, call_next): + """ + 限流中间件 + """ + if not RATE_LIMITER_AVAILABLE or not API_KEY_AVAILABLE: + response = await call_next(request) + return response + + path = request.url.path + + # 公开路径不限流 + if any(path.startswith(p) for p in PUBLIC_PATHS): + response = await call_next(request) + return response + + # 获取限流键 + limiter = get_rate_limiter() + + # 检查是否有 API Key + x_api_key = request.headers.get("X-API-Key") + + if x_api_key and x_api_key == MASTER_KEY: + # Master key 有更高的限流 + config = RateLimitConfig(requests_per_minute=1000) + limit_key = f"master:{x_api_key[:16]}" + elif hasattr(request.state, 'api_key') and request.state.api_key: + # 使用 API Key 的限流配置 + api_key = request.state.api_key + config = RateLimitConfig(requests_per_minute=api_key.rate_limit) + limit_key = f"api_key:{api_key.id}" + else: + # IP 限流(未认证用户) + client_ip = request.client.host if request.client else "unknown" + config = RateLimitConfig(requests_per_minute=10) + limit_key = f"ip:{client_ip}" + + # 检查限流 + info = await limiter.is_allowed(limit_key, config) + + if not info.allowed: + return JSONResponse( + status_code=429, + content={ + "error": "Rate limit exceeded", + "retry_after": info.retry_after, + "limit": config.requests_per_minute, + "window": "minute" + }, + headers={ + "X-RateLimit-Limit": str(config.requests_per_minute), + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": str(info.reset_time), + "Retry-After": str(info.retry_after) + } + ) + + # 继续处理请求 + start_time = time.time() + response = await call_next(request) + + # 添加限流头 + response.headers["X-RateLimit-Limit"] = str(config.requests_per_minute) + response.headers["X-RateLimit-Remaining"] = str(info.remaining) + response.headers["X-RateLimit-Reset"] = str(info.reset_time) + + # 记录 API 调用日志 + try: + if hasattr(request.state, 'api_key') and request.state.api_key: + api_key = request.state.api_key + response_time = int((time.time() - start_time) * 1000) + key_manager = get_api_key_manager() + key_manager.log_api_call( + api_key_id=api_key.id, + endpoint=path, + method=request.method, + status_code=response.status_code, + response_time_ms=response_time, + ip_address=request.client.host if request.client else "", + user_agent=request.headers.get("User-Agent", "") + ) + except Exception as e: + # 日志记录失败不应影响主流程 + print(f"Failed to log API call: {e}") + + return response + + +# 添加限流中间件 +app.middleware("http")(rate_limit_middleware) + +# ==================== Phase 6: Pydantic Models for API ==================== + +# API Key 相关模型 +class ApiKeyCreate(BaseModel): + name: str = Field(..., description="API Key 名称/描述") + permissions: List[str] = Field(default=["read"], description="权限列表: read, write, delete") + rate_limit: int = Field(default=60, description="每分钟请求限制") + expires_days: Optional[int] = Field(default=None, description="过期天数(可选)") + + +class ApiKeyResponse(BaseModel): + id: str + key_preview: str + name: str + permissions: List[str] + rate_limit: int + status: str + created_at: str + expires_at: Optional[str] + last_used_at: Optional[str] + total_calls: int + + +class ApiKeyCreateResponse(BaseModel): + api_key: str = Field(..., description="API Key(仅显示一次,请妥善保存)") + info: ApiKeyResponse + + +class ApiKeyListResponse(BaseModel): + keys: List[ApiKeyResponse] + total: int + + +class ApiKeyUpdate(BaseModel): + name: Optional[str] = None + permissions: Optional[List[str]] = None + rate_limit: Optional[int] = None + + +class ApiCallStats(BaseModel): + total_calls: int + success_calls: int + error_calls: int + avg_response_time_ms: float + max_response_time_ms: int + min_response_time_ms: int + + +class ApiStatsResponse(BaseModel): + summary: ApiCallStats + endpoints: List[Dict] + daily: List[Dict] + + +class ApiCallLog(BaseModel): + id: int + endpoint: str + method: str + status_code: int + response_time_ms: int + ip_address: str + user_agent: str + error_message: str + created_at: str + + +class ApiLogsResponse(BaseModel): + logs: List[ApiCallLog] + total: int + + +class RateLimitStatus(BaseModel): + limit: int + remaining: int + reset_time: int + window: str + + +# 原有模型(保留) class EntityModel(BaseModel): id: str name: str @@ -166,8 +475,8 @@ def get_doc_processor(): return _doc_processor # Phase 2: Entity Edit API -@app.put("/api/v1/entities/{entity_id}") -async def update_entity(entity_id: str, update: EntityUpdate): +@app.put("/api/v1/entities/{entity_id}", tags=["Entities"]) +async def update_entity(entity_id: str, update: EntityUpdate, _=Depends(verify_api_key)): """更新实体信息(名称、类型、定义、别名)""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -189,8 +498,8 @@ async def update_entity(entity_id: str, update: EntityUpdate): "aliases": updated.aliases } -@app.delete("/api/v1/entities/{entity_id}") -async def delete_entity(entity_id: str): +@app.delete("/api/v1/entities/{entity_id}", tags=["Entities"]) +async def delete_entity(entity_id: str, _=Depends(verify_api_key)): """删除实体""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -203,8 +512,8 @@ async def delete_entity(entity_id: str): db.delete_entity(entity_id) return {"success": True, "message": f"Entity {entity_id} deleted"} -@app.post("/api/v1/entities/{entity_id}/merge") -async def merge_entities_endpoint(entity_id: str, merge_req: EntityMergeRequest): +@app.post("/api/v1/entities/{entity_id}/merge", tags=["Entities"]) +async def merge_entities_endpoint(entity_id: str, merge_req: EntityMergeRequest, _=Depends(verify_api_key)): """合并两个实体""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -231,8 +540,8 @@ async def merge_entities_endpoint(entity_id: str, merge_req: EntityMergeRequest) } # Phase 2: Relation Edit API -@app.post("/api/v1/projects/{project_id}/relations") -async def create_relation_endpoint(project_id: str, relation: RelationCreate): +@app.post("/api/v1/projects/{project_id}/relations", tags=["Relations"]) +async def create_relation_endpoint(project_id: str, relation: RelationCreate, _=Depends(verify_api_key)): """创建新的实体关系""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -262,8 +571,8 @@ async def create_relation_endpoint(project_id: str, relation: RelationCreate): "success": True } -@app.delete("/api/v1/relations/{relation_id}") -async def delete_relation(relation_id: str): +@app.delete("/api/v1/relations/{relation_id}", tags=["Relations"]) +async def delete_relation(relation_id: str, _=Depends(verify_api_key)): """删除关系""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -272,8 +581,8 @@ async def delete_relation(relation_id: str): db.delete_relation(relation_id) return {"success": True, "message": f"Relation {relation_id} deleted"} -@app.put("/api/v1/relations/{relation_id}") -async def update_relation(relation_id: str, relation: RelationCreate): +@app.put("/api/v1/relations/{relation_id}", tags=["Relations"]) +async def update_relation(relation_id: str, relation: RelationCreate, _=Depends(verify_api_key)): """更新关系""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -293,8 +602,8 @@ async def update_relation(relation_id: str, relation: RelationCreate): } # Phase 2: Transcript Edit API -@app.get("/api/v1/transcripts/{transcript_id}") -async def get_transcript(transcript_id: str): +@app.get("/api/v1/transcripts/{transcript_id}", tags=["Transcripts"]) +async def get_transcript(transcript_id: str, _=Depends(verify_api_key)): """获取转录详情""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -307,8 +616,8 @@ async def get_transcript(transcript_id: str): return transcript -@app.put("/api/v1/transcripts/{transcript_id}") -async def update_transcript(transcript_id: str, update: TranscriptUpdate): +@app.put("/api/v1/transcripts/{transcript_id}", tags=["Transcripts"]) +async def update_transcript(transcript_id: str, update: TranscriptUpdate, _=Depends(verify_api_key)): """更新转录文本(人工修正)""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -336,8 +645,8 @@ class ManualEntityCreate(BaseModel): start_pos: Optional[int] = None end_pos: Optional[int] = None -@app.post("/api/v1/projects/{project_id}/entities") -async def create_manual_entity(project_id: str, entity: ManualEntityCreate): +@app.post("/api/v1/projects/{project_id}/entities", tags=["Entities"]) +async def create_manual_entity(project_id: str, entity: ManualEntityCreate, _=Depends(verify_api_key)): """手动创建实体(划词新建)""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -500,8 +809,8 @@ def align_entity(project_id: str, name: str, db, definition: str = "") -> Option # API Endpoints -@app.post("/api/v1/projects", response_model=dict) -async def create_project(project: ProjectCreate): +@app.post("/api/v1/projects", response_model=dict, tags=["Projects"]) +async def create_project(project: ProjectCreate, _=Depends(verify_api_key)): """创建新项目""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -511,8 +820,8 @@ async def create_project(project: ProjectCreate): p = db.create_project(project_id, project.name, project.description) return {"id": p.id, "name": p.name, "description": p.description} -@app.get("/api/v1/projects") -async def list_projects(): +@app.get("/api/v1/projects", tags=["Projects"]) +async def list_projects(_=Depends(verify_api_key)): """列出所有项目""" if not DB_AVAILABLE: return [] @@ -521,8 +830,8 @@ async def list_projects(): projects = db.list_projects() return [{"id": p.id, "name": p.name, "description": p.description} for p in projects] -@app.post("/api/v1/projects/{project_id}/upload", response_model=AnalysisResult) -async def upload_audio(project_id: str, file: UploadFile = File(...)): +@app.post("/api/v1/projects/{project_id}/upload", response_model=AnalysisResult, tags=["Projects"]) +async def upload_audio(project_id: str, file: UploadFile = File(...), _=Depends(verify_api_key)): """上传音频到指定项目 - Phase 3: 支持多文件融合""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -633,7 +942,7 @@ async def upload_audio(project_id: str, file: UploadFile = File(...)): # Phase 3: Document Upload API @app.post("/api/v1/projects/{project_id}/upload-document") -async def upload_document(project_id: str, file: UploadFile = File(...)): +async def upload_document(project_id: str, file: UploadFile = File(...), _=Depends(verify_api_key)): """上传 PDF/DOCX 文档到指定项目""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -745,7 +1054,7 @@ async def upload_document(project_id: str, file: UploadFile = File(...)): # Phase 3: Knowledge Base API @app.get("/api/v1/projects/{project_id}/knowledge-base") -async def get_knowledge_base(project_id: str): +async def get_knowledge_base(project_id: str, _=Depends(verify_api_key)): """获取项目知识库 - 包含所有实体、关系、术语表""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -842,7 +1151,7 @@ async def get_knowledge_base(project_id: str): # Phase 3: Glossary API @app.post("/api/v1/projects/{project_id}/glossary") -async def add_glossary_term(project_id: str, term: GlossaryTermCreate): +async def add_glossary_term(project_id: str, term: GlossaryTermCreate, _=Depends(verify_api_key)): """添加术语到项目术语表""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -866,7 +1175,7 @@ async def add_glossary_term(project_id: str, term: GlossaryTermCreate): } @app.get("/api/v1/projects/{project_id}/glossary") -async def get_glossary(project_id: str): +async def get_glossary(project_id: str, _=Depends(verify_api_key)): """获取项目术语表""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -876,7 +1185,7 @@ async def get_glossary(project_id: str): return glossary @app.delete("/api/v1/glossary/{term_id}") -async def delete_glossary_term(term_id: str): +async def delete_glossary_term(term_id: str, _=Depends(verify_api_key)): """删除术语""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -887,7 +1196,7 @@ async def delete_glossary_term(term_id: str): # Phase 3: Entity Alignment API @app.post("/api/v1/projects/{project_id}/align-entities") -async def align_project_entities(project_id: str, threshold: float = 0.85): +async def align_project_entities(project_id: str, threshold: float = 0.85, _=Depends(verify_api_key)): """运行实体对齐算法,合并相似实体""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -933,7 +1242,7 @@ async def align_project_entities(project_id: str, threshold: float = 0.85): } @app.get("/api/v1/projects/{project_id}/entities") -async def get_project_entities(project_id: str): +async def get_project_entities(project_id: str, _=Depends(verify_api_key)): """获取项目的全局实体列表""" if not DB_AVAILABLE: return [] @@ -944,7 +1253,7 @@ async def get_project_entities(project_id: str): @app.get("/api/v1/projects/{project_id}/relations") -async def get_project_relations(project_id: str): +async def get_project_relations(project_id: str, _=Depends(verify_api_key)): """获取项目的实体关系列表""" if not DB_AVAILABLE: return [] @@ -968,7 +1277,7 @@ async def get_project_relations(project_id: str): @app.get("/api/v1/projects/{project_id}/transcripts") -async def get_project_transcripts(project_id: str): +async def get_project_transcripts(project_id: str, _=Depends(verify_api_key)): """获取项目的转录列表""" if not DB_AVAILABLE: return [] @@ -985,7 +1294,7 @@ async def get_project_transcripts(project_id: str): @app.get("/api/v1/entities/{entity_id}/mentions") -async def get_entity_mentions(entity_id: str): +async def get_entity_mentions(entity_id: str, _=Depends(verify_api_key)): """获取实体的所有提及位置""" if not DB_AVAILABLE: return [] @@ -1021,7 +1330,7 @@ async def health_check(): # ==================== Phase 4: Agent 助手 API ==================== @app.post("/api/v1/projects/{project_id}/agent/query") -async def agent_query(project_id: str, query: AgentQuery): +async def agent_query(project_id: str, query: AgentQuery, _=Depends(verify_api_key)): """Agent RAG 问答""" if not DB_AVAILABLE or not LLM_CLIENT_AVAILABLE: raise HTTPException(status_code=500, detail="Service not available") @@ -1075,7 +1384,7 @@ async def agent_query(project_id: str, query: AgentQuery): @app.post("/api/v1/projects/{project_id}/agent/command") -async def agent_command(project_id: str, command: AgentCommand): +async def agent_command(project_id: str, command: AgentCommand, _=Depends(verify_api_key)): """Agent 指令执行 - 解析并执行自然语言指令""" if not DB_AVAILABLE or not LLM_CLIENT_AVAILABLE: raise HTTPException(status_code=500, detail="Service not available") @@ -1166,7 +1475,7 @@ async def agent_command(project_id: str, command: AgentCommand): @app.get("/api/v1/projects/{project_id}/agent/suggest") -async def agent_suggest(project_id: str): +async def agent_suggest(project_id: str, _=Depends(verify_api_key)): """获取 Agent 建议 - 基于项目数据提供洞察""" if not DB_AVAILABLE or not LLM_CLIENT_AVAILABLE: raise HTTPException(status_code=500, detail="Service not available") @@ -1206,7 +1515,7 @@ async def agent_suggest(project_id: str): # ==================== Phase 4: 知识溯源 API ==================== @app.get("/api/v1/relations/{relation_id}/provenance") -async def get_relation_provenance(relation_id: str): +async def get_relation_provenance(relation_id: str, _=Depends(verify_api_key)): """获取关系的知识溯源信息""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -1231,7 +1540,7 @@ async def get_relation_provenance(relation_id: str): @app.get("/api/v1/entities/{entity_id}/details") -async def get_entity_details(entity_id: str): +async def get_entity_details(entity_id: str, _=Depends(verify_api_key)): """获取实体详情,包含所有提及位置""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -1246,7 +1555,7 @@ async def get_entity_details(entity_id: str): @app.get("/api/v1/entities/{entity_id}/evolution") -async def get_entity_evolution(entity_id: str): +async def get_entity_evolution(entity_id: str, _=Depends(verify_api_key)): """分析实体的演变和态度变化""" if not DB_AVAILABLE or not LLM_CLIENT_AVAILABLE: raise HTTPException(status_code=500, detail="Service not available") @@ -1281,7 +1590,7 @@ async def get_entity_evolution(entity_id: str): # ==================== Phase 4: 实体管理增强 API ==================== @app.get("/api/v1/projects/{project_id}/entities/search") -async def search_entities(project_id: str, q: str): +async def search_entities(project_id: str, q: str, _=Depends(verify_api_key)): """搜索实体""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -1298,7 +1607,8 @@ async def get_project_timeline( project_id: str, entity_id: str = None, start_date: str = None, - end_date: str = None + end_date: str = None, + _=Depends(verify_api_key) ): """获取项目时间线 - 按时间顺序的实体提及和关系事件""" if not DB_AVAILABLE: @@ -1319,7 +1629,7 @@ async def get_project_timeline( @app.get("/api/v1/projects/{project_id}/timeline/summary") -async def get_timeline_summary(project_id: str): +async def get_timeline_summary(project_id: str, _=Depends(verify_api_key)): """获取项目时间线摘要统计""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -1339,7 +1649,7 @@ async def get_timeline_summary(project_id: str): @app.get("/api/v1/entities/{entity_id}/timeline") -async def get_entity_timeline(entity_id: str): +async def get_entity_timeline(entity_id: str, _=Depends(verify_api_key)): """获取单个实体的时间线""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -1369,7 +1679,7 @@ class ReasoningQuery(BaseModel): @app.post("/api/v1/projects/{project_id}/reasoning/query") -async def reasoning_query(project_id: str, query: ReasoningQuery): +async def reasoning_query(project_id: str, query: ReasoningQuery, _=Depends(verify_api_key)): """ 增强问答 - 基于知识推理的智能问答 @@ -1423,7 +1733,8 @@ async def reasoning_query(project_id: str, query: ReasoningQuery): async def find_inference_path( project_id: str, start_entity: str, - end_entity: str + end_entity: str, + _=Depends(verify_api_key) ): """ 发现两个实体之间的推理路径 @@ -1472,7 +1783,7 @@ class SummaryRequest(BaseModel): @app.post("/api/v1/projects/{project_id}/reasoning/summary") -async def project_summary(project_id: str, req: SummaryRequest): +async def project_summary(project_id: str, req: SummaryRequest, _=Depends(verify_api_key)): """ 项目智能总结 @@ -1557,7 +1868,7 @@ class EntityAttributeBatchSet(BaseModel): # 属性模板管理 API @app.post("/api/v1/projects/{project_id}/attribute-templates") -async def create_attribute_template_endpoint(project_id: str, template: AttributeTemplateCreate): +async def create_attribute_template_endpoint(project_id: str, template: AttributeTemplateCreate, _=Depends(verify_api_key)): """创建属性模板""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -1592,7 +1903,7 @@ async def create_attribute_template_endpoint(project_id: str, template: Attribut @app.get("/api/v1/projects/{project_id}/attribute-templates") -async def list_attribute_templates_endpoint(project_id: str): +async def list_attribute_templates_endpoint(project_id: str, _=Depends(verify_api_key)): """列出项目的所有属性模板""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -1616,7 +1927,7 @@ async def list_attribute_templates_endpoint(project_id: str): @app.get("/api/v1/attribute-templates/{template_id}") -async def get_attribute_template_endpoint(template_id: str): +async def get_attribute_template_endpoint(template_id: str, _=Depends(verify_api_key)): """获取属性模板详情""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -1640,7 +1951,7 @@ async def get_attribute_template_endpoint(template_id: str): @app.put("/api/v1/attribute-templates/{template_id}") -async def update_attribute_template_endpoint(template_id: str, update: AttributeTemplateUpdate): +async def update_attribute_template_endpoint(template_id: str, update: AttributeTemplateUpdate, _=Depends(verify_api_key)): """更新属性模板""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -1662,7 +1973,7 @@ async def update_attribute_template_endpoint(template_id: str, update: Attribute @app.delete("/api/v1/attribute-templates/{template_id}") -async def delete_attribute_template_endpoint(template_id: str): +async def delete_attribute_template_endpoint(template_id: str, _=Depends(verify_api_key)): """删除属性模板""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -1675,7 +1986,7 @@ async def delete_attribute_template_endpoint(template_id: str): # 实体属性值管理 API @app.post("/api/v1/entities/{entity_id}/attributes") -async def set_entity_attribute_endpoint(entity_id: str, attr: EntityAttributeSet): +async def set_entity_attribute_endpoint(entity_id: str, attr: EntityAttributeSet, _=Depends(verify_api_key)): """设置实体属性值""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -1762,7 +2073,7 @@ async def set_entity_attribute_endpoint(entity_id: str, attr: EntityAttributeSet @app.post("/api/v1/entities/{entity_id}/attributes/batch") -async def batch_set_entity_attributes_endpoint(entity_id: str, batch: EntityAttributeBatchSet): +async def batch_set_entity_attributes_endpoint(entity_id: str, batch: EntityAttributeBatchSet, _=Depends(verify_api_key)): """批量设置实体属性值""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -1801,7 +2112,7 @@ async def batch_set_entity_attributes_endpoint(entity_id: str, batch: EntityAttr @app.get("/api/v1/entities/{entity_id}/attributes") -async def get_entity_attributes_endpoint(entity_id: str): +async def get_entity_attributes_endpoint(entity_id: str, _=Depends(verify_api_key)): """获取实体的所有属性值""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -1827,7 +2138,7 @@ async def get_entity_attributes_endpoint(entity_id: str): @app.delete("/api/v1/entities/{entity_id}/attributes/{template_id}") async def delete_entity_attribute_endpoint(entity_id: str, template_id: str, - reason: Optional[str] = ""): + reason: Optional[str] = "", _=Depends(verify_api_key)): """删除实体属性值""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -1841,7 +2152,7 @@ async def delete_entity_attribute_endpoint(entity_id: str, template_id: str, # 属性历史 API @app.get("/api/v1/entities/{entity_id}/attributes/history") -async def get_entity_attribute_history_endpoint(entity_id: str, limit: int = 50): +async def get_entity_attribute_history_endpoint(entity_id: str, limit: int = 50, _=Depends(verify_api_key)): """获取实体的属性变更历史""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -1864,7 +2175,7 @@ async def get_entity_attribute_history_endpoint(entity_id: str, limit: int = 50) @app.get("/api/v1/attribute-templates/{template_id}/history") -async def get_template_history_endpoint(template_id: str, limit: int = 50): +async def get_template_history_endpoint(template_id: str, limit: int = 50, _=Depends(verify_api_key)): """获取属性模板的所有变更历史(跨实体)""" if not DB_AVAILABLE: raise HTTPException(status_code=500, detail="Database not available") @@ -1891,7 +2202,8 @@ async def get_template_history_endpoint(template_id: str, limit: int = 50): @app.get("/api/v1/projects/{project_id}/entities/search-by-attributes") async def search_entities_by_attributes_endpoint( project_id: str, - attribute_filter: Optional[str] = None # JSON 格式: {"职位": "经理", "部门": "技术部"} + attribute_filter: Optional[str] = None, # JSON 格式: {"职位": "经理", "部门": "技术部"} + _=Depends(verify_api_key) ): """根据属性筛选搜索实体""" if not DB_AVAILABLE: @@ -1928,7 +2240,7 @@ async def search_entities_by_attributes_endpoint( from fastapi.responses import StreamingResponse, FileResponse @app.get("/api/v1/projects/{project_id}/export/graph-svg") -async def export_graph_svg_endpoint(project_id: str): +async def export_graph_svg_endpoint(project_id: str, _=Depends(verify_api_key)): """导出知识图谱为 SVG""" if not DB_AVAILABLE or not EXPORT_AVAILABLE: raise HTTPException(status_code=500, detail="Export functionality not available") @@ -1978,7 +2290,7 @@ async def export_graph_svg_endpoint(project_id: str): @app.get("/api/v1/projects/{project_id}/export/graph-png") -async def export_graph_png_endpoint(project_id: str): +async def export_graph_png_endpoint(project_id: str, _=Depends(verify_api_key)): """导出知识图谱为 PNG""" if not DB_AVAILABLE or not EXPORT_AVAILABLE: raise HTTPException(status_code=500, detail="Export functionality not available") @@ -2028,7 +2340,7 @@ async def export_graph_png_endpoint(project_id: str): @app.get("/api/v1/projects/{project_id}/export/entities-excel") -async def export_entities_excel_endpoint(project_id: str): +async def export_entities_excel_endpoint(project_id: str, _=Depends(verify_api_key)): """导出实体数据为 Excel""" if not DB_AVAILABLE or not EXPORT_AVAILABLE: raise HTTPException(status_code=500, detail="Export functionality not available") @@ -2065,7 +2377,7 @@ async def export_entities_excel_endpoint(project_id: str): @app.get("/api/v1/projects/{project_id}/export/entities-csv") -async def export_entities_csv_endpoint(project_id: str): +async def export_entities_csv_endpoint(project_id: str, _=Depends(verify_api_key)): """导出实体数据为 CSV""" if not DB_AVAILABLE or not EXPORT_AVAILABLE: raise HTTPException(status_code=500, detail="Export functionality not available") @@ -2102,7 +2414,7 @@ async def export_entities_csv_endpoint(project_id: str): @app.get("/api/v1/projects/{project_id}/export/relations-csv") -async def export_relations_csv_endpoint(project_id: str): +async def export_relations_csv_endpoint(project_id: str, _=Depends(verify_api_key)): """导出关系数据为 CSV""" if not DB_AVAILABLE or not EXPORT_AVAILABLE: raise HTTPException(status_code=500, detail="Export functionality not available") @@ -2137,7 +2449,7 @@ async def export_relations_csv_endpoint(project_id: str): @app.get("/api/v1/projects/{project_id}/export/report-pdf") -async def export_report_pdf_endpoint(project_id: str): +async def export_report_pdf_endpoint(project_id: str, _=Depends(verify_api_key)): """导出项目报告为 PDF""" if not DB_AVAILABLE or not EXPORT_AVAILABLE: raise HTTPException(status_code=500, detail="Export functionality not available") @@ -2212,7 +2524,7 @@ async def export_report_pdf_endpoint(project_id: str): @app.get("/api/v1/projects/{project_id}/export/project-json") -async def export_project_json_endpoint(project_id: str): +async def export_project_json_endpoint(project_id: str, _=Depends(verify_api_key)): """导出完整项目数据为 JSON""" if not DB_AVAILABLE or not EXPORT_AVAILABLE: raise HTTPException(status_code=500, detail="Export functionality not available") @@ -2277,7 +2589,7 @@ async def export_project_json_endpoint(project_id: str): @app.get("/api/v1/transcripts/{transcript_id}/export/markdown") -async def export_transcript_markdown_endpoint(transcript_id: str): +async def export_transcript_markdown_endpoint(transcript_id: str, _=Depends(verify_api_key)): """导出转录文本为 Markdown""" if not DB_AVAILABLE or not EXPORT_AVAILABLE: raise HTTPException(status_code=500, detail="Export functionality not available") @@ -2343,7 +2655,7 @@ class GraphQueryRequest(BaseModel): depth: int = 1 @app.get("/api/v1/neo4j/status") -async def neo4j_status(): +async def neo4j_status(_=Depends(verify_api_key)): """获取 Neo4j 连接状态""" if not NEO4J_AVAILABLE: return { @@ -2369,7 +2681,7 @@ async def neo4j_status(): } @app.post("/api/v1/neo4j/sync") -async def neo4j_sync_project(request: Neo4jSyncRequest): +async def neo4j_sync_project(request: Neo4jSyncRequest, _=Depends(verify_api_key)): """同步项目数据到 Neo4j""" if not NEO4J_AVAILABLE: raise HTTPException(status_code=503, detail="Neo4j not available") @@ -2429,7 +2741,7 @@ async def neo4j_sync_project(request: Neo4jSyncRequest): } @app.get("/api/v1/projects/{project_id}/graph/stats") -async def get_graph_stats(project_id: str): +async def get_graph_stats(project_id: str, _=Depends(verify_api_key)): """获取项目图统计信息""" if not NEO4J_AVAILABLE: raise HTTPException(status_code=503, detail="Neo4j not available") @@ -2442,7 +2754,7 @@ async def get_graph_stats(project_id: str): return stats @app.post("/api/v1/graph/shortest-path") -async def find_shortest_path(request: PathQueryRequest): +async def find_shortest_path(request: PathQueryRequest, _=Depends(verify_api_key)): """查找两个实体之间的最短路径""" if not NEO4J_AVAILABLE: raise HTTPException(status_code=503, detail="Neo4j not available") @@ -2473,7 +2785,7 @@ async def find_shortest_path(request: PathQueryRequest): } @app.post("/api/v1/graph/paths") -async def find_all_paths(request: PathQueryRequest): +async def find_all_paths(request: PathQueryRequest, _=Depends(verify_api_key)): """查找两个实体之间的所有路径""" if not NEO4J_AVAILABLE: raise HTTPException(status_code=503, detail="Neo4j not available") @@ -2504,7 +2816,8 @@ async def find_all_paths(request: PathQueryRequest): async def get_entity_neighbors( entity_id: str, relation_type: str = None, - limit: int = 50 + limit: int = 50, + _=Depends(verify_api_key) ): """获取实体的邻居节点""" if not NEO4J_AVAILABLE: @@ -2522,7 +2835,7 @@ async def get_entity_neighbors( } @app.get("/api/v1/entities/{entity_id1}/common-neighbors/{entity_id2}") -async def get_common_neighbors(entity_id1: str, entity_id2: str): +async def get_common_neighbors(entity_id1: str, entity_id2: str, _=Depends(verify_api_key)): """获取两个实体的共同邻居""" if not NEO4J_AVAILABLE: raise HTTPException(status_code=503, detail="Neo4j not available") @@ -2542,7 +2855,8 @@ async def get_common_neighbors(entity_id1: str, entity_id2: str): @app.get("/api/v1/projects/{project_id}/graph/centrality") async def get_centrality_analysis( project_id: str, - metric: str = "degree" + metric: str = "degree", + _=Depends(verify_api_key) ): """获取中心性分析结果""" if not NEO4J_AVAILABLE: @@ -2568,7 +2882,7 @@ async def get_centrality_analysis( } @app.get("/api/v1/projects/{project_id}/graph/communities") -async def get_communities(project_id: str): +async def get_communities(project_id: str, _=Depends(verify_api_key)): """获取社区发现结果""" if not NEO4J_AVAILABLE: raise HTTPException(status_code=503, detail="Neo4j not available") @@ -2592,7 +2906,7 @@ async def get_communities(project_id: str): } @app.post("/api/v1/graph/subgraph") -async def get_subgraph(request: GraphQueryRequest): +async def get_subgraph(request: GraphQueryRequest, _=Depends(verify_api_key)): """获取子图""" if not NEO4J_AVAILABLE: raise HTTPException(status_code=503, detail="Neo4j not available") @@ -2605,6 +2919,330 @@ async def get_subgraph(request: GraphQueryRequest): return subgraph +# ==================== Phase 6: API Key Management Endpoints ==================== + +@app.post("/api/v1/api-keys", response_model=ApiKeyCreateResponse, tags=["API Keys"]) +async def create_api_key(request: ApiKeyCreate, _=Depends(verify_api_key)): + """ + 创建新的 API Key + + - **name**: API Key 的名称/描述 + - **permissions**: 权限列表,可选值: read, write, delete + - **rate_limit**: 每分钟请求限制,默认 60 + - **expires_days**: 过期天数(可选,不设置则永不过期) + """ + if not API_KEY_AVAILABLE: + raise HTTPException(status_code=503, detail="API Key management not available") + + key_manager = get_api_key_manager() + raw_key, api_key = key_manager.create_key( + name=request.name, + permissions=request.permissions, + rate_limit=request.rate_limit, + expires_days=request.expires_days + ) + + return ApiKeyCreateResponse( + api_key=raw_key, + info=ApiKeyResponse( + id=api_key.id, + key_preview=api_key.key_preview, + name=api_key.name, + permissions=api_key.permissions, + rate_limit=api_key.rate_limit, + status=api_key.status, + created_at=api_key.created_at, + expires_at=api_key.expires_at, + last_used_at=api_key.last_used_at, + total_calls=api_key.total_calls + ) + ) + + +@app.get("/api/v1/api-keys", response_model=ApiKeyListResponse, tags=["API Keys"]) +async def list_api_keys( + status: Optional[str] = None, + limit: int = 100, + offset: int = 0, + _=Depends(verify_api_key) +): + """ + 列出所有 API Keys + + - **status**: 按状态筛选 (active, revoked, expired) + - **limit**: 返回数量限制 + - **offset**: 分页偏移 + """ + if not API_KEY_AVAILABLE: + raise HTTPException(status_code=503, detail="API Key management not available") + + key_manager = get_api_key_manager() + keys = key_manager.list_keys(status=status, limit=limit, offset=offset) + + return ApiKeyListResponse( + keys=[ + ApiKeyResponse( + id=k.id, + key_preview=k.key_preview, + name=k.name, + permissions=k.permissions, + rate_limit=k.rate_limit, + status=k.status, + created_at=k.created_at, + expires_at=k.expires_at, + last_used_at=k.last_used_at, + total_calls=k.total_calls + ) + for k in keys + ], + total=len(keys) + ) + + +@app.get("/api/v1/api-keys/{key_id}", response_model=ApiKeyResponse, tags=["API Keys"]) +async def get_api_key(key_id: str, _=Depends(verify_api_key)): + """获取单个 API Key 详情""" + if not API_KEY_AVAILABLE: + raise HTTPException(status_code=503, detail="API Key management not available") + + key_manager = get_api_key_manager() + key = key_manager.get_key_by_id(key_id) + + if not key: + raise HTTPException(status_code=404, detail="API Key not found") + + return ApiKeyResponse( + id=key.id, + key_preview=key.key_preview, + name=key.name, + permissions=key.permissions, + rate_limit=key.rate_limit, + status=key.status, + created_at=key.created_at, + expires_at=key.expires_at, + last_used_at=key.last_used_at, + total_calls=key.total_calls + ) + + +@app.patch("/api/v1/api-keys/{key_id}", response_model=ApiKeyResponse, tags=["API Keys"]) +async def update_api_key(key_id: str, request: ApiKeyUpdate, _=Depends(verify_api_key)): + """ + 更新 API Key 信息 + + 可以更新的字段:name, permissions, rate_limit + """ + if not API_KEY_AVAILABLE: + raise HTTPException(status_code=503, detail="API Key management not available") + + key_manager = get_api_key_manager() + + # 构建更新数据 + updates = {} + if request.name is not None: + updates["name"] = request.name + if request.permissions is not None: + updates["permissions"] = request.permissions + if request.rate_limit is not None: + updates["rate_limit"] = request.rate_limit + + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + + success = key_manager.update_key(key_id, **updates) + + if not success: + raise HTTPException(status_code=404, detail="API Key not found") + + # 返回更新后的 key + key = key_manager.get_key_by_id(key_id) + return ApiKeyResponse( + id=key.id, + key_preview=key.key_preview, + name=key.name, + permissions=key.permissions, + rate_limit=key.rate_limit, + status=key.status, + created_at=key.created_at, + expires_at=key.expires_at, + last_used_at=key.last_used_at, + total_calls=key.total_calls + ) + + +@app.delete("/api/v1/api-keys/{key_id}", tags=["API Keys"]) +async def revoke_api_key(key_id: str, reason: str = "", _=Depends(verify_api_key)): + """ + 撤销 API Key + + 撤销后的 Key 将无法再使用,但记录会保留用于审计 + """ + if not API_KEY_AVAILABLE: + raise HTTPException(status_code=503, detail="API Key management not available") + + key_manager = get_api_key_manager() + success = key_manager.revoke_key(key_id, reason=reason) + + if not success: + raise HTTPException(status_code=404, detail="API Key not found or already revoked") + + return {"success": True, "message": f"API Key {key_id} revoked"} + + +@app.get("/api/v1/api-keys/{key_id}/stats", response_model=ApiStatsResponse, tags=["API Keys"]) +async def get_api_key_stats(key_id: str, days: int = 30, _=Depends(verify_api_key)): + """ + 获取 API Key 的调用统计 + + - **days**: 统计天数,默认 30 天 + """ + if not API_KEY_AVAILABLE: + raise HTTPException(status_code=503, detail="API Key management not available") + + key_manager = get_api_key_manager() + + # 验证 key 存在 + key = key_manager.get_key_by_id(key_id) + if not key: + raise HTTPException(status_code=404, detail="API Key not found") + + stats = key_manager.get_call_stats(key_id, days=days) + + return ApiStatsResponse( + summary=ApiCallStats(**stats["summary"]), + endpoints=stats["endpoints"], + daily=stats["daily"] + ) + + +@app.get("/api/v1/api-keys/{key_id}/logs", response_model=ApiLogsResponse, tags=["API Keys"]) +async def get_api_key_logs( + key_id: str, + limit: int = 100, + offset: int = 0, + _=Depends(verify_api_key) +): + """ + 获取 API Key 的调用日志 + + - **limit**: 返回数量限制 + - **offset**: 分页偏移 + """ + if not API_KEY_AVAILABLE: + raise HTTPException(status_code=503, detail="API Key management not available") + + key_manager = get_api_key_manager() + + # 验证 key 存在 + key = key_manager.get_key_by_id(key_id) + if not key: + raise HTTPException(status_code=404, detail="API Key not found") + + logs = key_manager.get_call_logs(key_id, limit=limit, offset=offset) + + return ApiLogsResponse( + logs=[ + ApiCallLog( + id=log["id"], + endpoint=log["endpoint"], + method=log["method"], + status_code=log["status_code"], + response_time_ms=log["response_time_ms"], + ip_address=log["ip_address"], + user_agent=log["user_agent"], + error_message=log["error_message"], + created_at=log["created_at"] + ) + for log in logs + ], + total=len(logs) + ) + + +@app.get("/api/v1/rate-limit/status", response_model=RateLimitStatus, tags=["API Keys"]) +async def get_rate_limit_status(request: Request, _=Depends(verify_api_key)): + """获取当前请求的限流状态""" + if not RATE_LIMITER_AVAILABLE: + return RateLimitStatus( + limit=60, + remaining=60, + reset_time=int(time.time()) + 60, + window="minute" + ) + + limiter = get_rate_limiter() + + # 获取限流键 + if hasattr(request.state, 'api_key') and request.state.api_key: + api_key = request.state.api_key + limit_key = f"api_key:{api_key.id}" + limit = api_key.rate_limit + else: + client_ip = request.client.host if request.client else "unknown" + limit_key = f"ip:{client_ip}" + limit = 10 + + info = await limiter.get_limit_info(limit_key) + + return RateLimitStatus( + limit=limit, + remaining=info.remaining, + reset_time=info.reset_time, + window="minute" + ) + + +# ==================== Phase 6: System Endpoints ==================== + +@app.get("/api/v1/health", tags=["System"]) +async def health_check(): + """健康检查端点""" + return { + "status": "healthy", + "version": "0.6.0", + "timestamp": datetime.now().isoformat() + } + + +@app.get("/api/v1/status", tags=["System"]) +async def system_status(): + """系统状态信息""" + status = { + "version": "0.6.0", + "phase": "Phase 6 - API Platform", + "features": { + "database": DB_AVAILABLE, + "oss": OSS_AVAILABLE, + "tingwu": TINGWU_AVAILABLE, + "llm": LLM_CLIENT_AVAILABLE, + "neo4j": NEO4J_AVAILABLE, + "export": EXPORT_AVAILABLE, + "api_keys": API_KEY_AVAILABLE, + "rate_limiting": RATE_LIMITER_AVAILABLE, + }, + "api": { + "documentation": "/docs", + "openapi": "/openapi.json", + }, + "timestamp": datetime.now().isoformat() + } + + return status + + +@app.get("/api/v1/openapi.json", include_in_schema=False) +async def get_openapi(): + """获取 OpenAPI 规范""" + from fastapi.openapi.utils import get_openapi + return get_openapi( + title=app.title, + version=app.version, + description=app.description, + routes=app.routes, + tags=app.openapi_tags + ) + + # Serve frontend - MUST be last to not override API routes app.mount("/", StaticFiles(directory="frontend", html=True), name="frontend") diff --git a/backend/rate_limiter.py b/backend/rate_limiter.py new file mode 100644 index 0000000..878306b --- /dev/null +++ b/backend/rate_limiter.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +InsightFlow Rate Limiter - Phase 6 +API 限流中间件 +支持基于内存的滑动窗口限流 +""" + +import time +import asyncio +from typing import Dict, Optional, Tuple, Callable +from dataclasses import dataclass, field +from collections import defaultdict +from functools import wraps + + +@dataclass +class RateLimitConfig: + """限流配置""" + requests_per_minute: int = 60 + burst_size: int = 10 # 突发请求数 + window_size: int = 60 # 窗口大小(秒) + + +@dataclass +class RateLimitInfo: + """限流信息""" + allowed: bool + remaining: int + reset_time: int # 重置时间戳 + retry_after: int # 需要等待的秒数 + + +class SlidingWindowCounter: + """滑动窗口计数器""" + + def __init__(self, window_size: int = 60): + self.window_size = window_size + self.requests: Dict[int, int] = defaultdict(int) # 秒级计数 + self._lock = asyncio.Lock() + + async def add_request(self) -> int: + """添加请求,返回当前窗口内的请求数""" + async with self._lock: + now = int(time.time()) + self.requests[now] += 1 + self._cleanup_old(now) + return sum(self.requests.values()) + + async def get_count(self) -> int: + """获取当前窗口内的请求数""" + async with self._lock: + now = int(time.time()) + self._cleanup_old(now) + return sum(self.requests.values()) + + def _cleanup_old(self, now: int): + """清理过期的请求记录""" + cutoff = now - self.window_size + old_keys = [k for k in self.requests.keys() if k < cutoff] + for k in old_keys: + del self.requests[k] + + +class RateLimiter: + """API 限流器""" + + def __init__(self): + # key -> SlidingWindowCounter + self.counters: Dict[str, SlidingWindowCounter] = {} + # key -> RateLimitConfig + self.configs: Dict[str, RateLimitConfig] = {} + self._lock = asyncio.Lock() + + async def is_allowed( + self, + key: str, + config: Optional[RateLimitConfig] = None + ) -> RateLimitInfo: + """ + 检查是否允许请求 + + Args: + key: 限流键(如 API Key ID) + config: 限流配置,如果为 None 则使用默认配置 + + Returns: + RateLimitInfo + """ + if config is None: + config = RateLimitConfig() + + async with self._lock: + if key not in self.counters: + self.counters[key] = SlidingWindowCounter(config.window_size) + self.configs[key] = config + + counter = self.counters[key] + stored_config = self.configs.get(key, config) + + # 获取当前计数 + current_count = await counter.get_count() + + # 计算剩余配额 + remaining = max(0, stored_config.requests_per_minute - current_count) + + # 计算重置时间 + now = int(time.time()) + reset_time = now + stored_config.window_size + + # 检查是否超过限制 + if current_count >= stored_config.requests_per_minute: + return RateLimitInfo( + allowed=False, + remaining=0, + reset_time=reset_time, + retry_after=stored_config.window_size + ) + + # 允许请求,增加计数 + await counter.add_request() + + return RateLimitInfo( + allowed=True, + remaining=remaining - 1, + reset_time=reset_time, + retry_after=0 + ) + + async def get_limit_info(self, key: str) -> RateLimitInfo: + """获取限流信息(不增加计数)""" + if key not in self.counters: + config = RateLimitConfig() + return RateLimitInfo( + allowed=True, + remaining=config.requests_per_minute, + reset_time=int(time.time()) + config.window_size, + retry_after=0 + ) + + counter = self.counters[key] + config = self.configs.get(key, RateLimitConfig()) + + current_count = await counter.get_count() + remaining = max(0, config.requests_per_minute - current_count) + reset_time = int(time.time()) + config.window_size + + return RateLimitInfo( + allowed=current_count < config.requests_per_minute, + remaining=remaining, + reset_time=reset_time, + retry_after=max(0, config.window_size) if current_count >= config.requests_per_minute else 0 + ) + + def reset(self, key: Optional[str] = None): + """重置限流计数器""" + if key: + self.counters.pop(key, None) + self.configs.pop(key, None) + else: + self.counters.clear() + self.configs.clear() + + +# 全局限流器实例 +_rate_limiter: Optional[RateLimiter] = None + + +def get_rate_limiter() -> RateLimiter: + """获取限流器实例""" + global _rate_limiter + if _rate_limiter is None: + _rate_limiter = RateLimiter() + return _rate_limiter + + +# 限流装饰器(用于函数级别限流) +def rate_limit( + requests_per_minute: int = 60, + key_func: Optional[Callable] = None +): + """ + 限流装饰器 + + Args: + requests_per_minute: 每分钟请求数限制 + key_func: 生成限流键的函数,默认为 None(使用函数名) + """ + def decorator(func): + limiter = get_rate_limiter() + config = RateLimitConfig(requests_per_minute=requests_per_minute) + + @wraps(func) + async def async_wrapper(*args, **kwargs): + key = key_func(*args, **kwargs) if key_func else func.__name__ + info = await limiter.is_allowed(key, config) + + if not info.allowed: + raise RateLimitExceeded( + f"Rate limit exceeded. Try again in {info.retry_after} seconds." + ) + + return await func(*args, **kwargs) + + @wraps(func) + def sync_wrapper(*args, **kwargs): + key = key_func(*args, **kwargs) if key_func else func.__name__ + # 同步版本使用 asyncio.run + info = asyncio.run(limiter.is_allowed(key, config)) + + if not info.allowed: + raise RateLimitExceeded( + f"Rate limit exceeded. Try again in {info.retry_after} seconds." + ) + + return func(*args, **kwargs) + + return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper + return decorator + + +class RateLimitExceeded(Exception): + """限流异常""" + pass diff --git a/backend/requirements.txt b/backend/requirements.txt index 04fcb73..c3baa06 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -30,3 +30,6 @@ cairosvg==2.7.1 # Neo4j Graph Database neo4j==5.15.0 + +# API Documentation (Swagger/OpenAPI) +fastapi-offline-swagger==0.1.0 diff --git a/frontend/app.js b/frontend/app.js index 8653215..4c831f9 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,4 +1,4 @@ -// InsightFlow Frontend - Phase 5 (Graph Analysis) +// InsightFlow Frontend - Phase 6 (API Platform) const API_BASE = '/api/v1'; let currentProject = null; @@ -98,2858 +98,339 @@ async function loadProjectData() { segments: [], entities: projectEntities, full_text: '', - created_at: new Date().toISOString() + relations: projectRelations }; + renderTranscript(); renderGraph(); renderEntityList(); - // 更新图分析面板的实体选择器 - populateGraphEntitySelects(); - } catch (err) { console.error('Load project data failed:', err); } } async function preloadEntityDetails() { - // 并行加载所有实体详情 - const promises = projectEntities.map(async (ent) => { + const promises = projectEntities.slice(0, 20).map(async entity => { try { - const res = await fetch(`${API_BASE}/entities/${ent.id}/details`); + const res = await fetch(`${API_BASE}/entities/${entity.id}/details`); if (res.ok) { - entityDetailsCache[ent.id] = await res.json(); + entityDetailsCache[entity.id] = await res.json(); } } catch (e) { - console.error(`Failed to load entity ${ent.id} details:`, e); + // Ignore errors } }); await Promise.all(promises); } -// ==================== Agent Panel ==================== - -function initAgentPanel() { - const chatInput = document.getElementById('chatInput'); - if (chatInput) { - chatInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - sendAgentMessage(); - } - }); - } -} - -function toggleAgentPanel() { - const panel = document.getElementById('agentPanel'); - const toggle = panel.querySelector('.agent-toggle'); - panel.classList.toggle('collapsed'); - toggle.textContent = panel.classList.contains('collapsed') ? '‹' : '›'; -} - -function addChatMessage(content, isUser = false, isTyping = false) { - const container = document.getElementById('chatMessages'); - const msgDiv = document.createElement('div'); - msgDiv.className = `chat-message ${isUser ? 'user' : 'assistant'}`; - - if (isTyping) { - msgDiv.innerHTML = ` -
- -
- `; - } else { - msgDiv.innerHTML = `
${content}
`; - } - - container.appendChild(msgDiv); - container.scrollTop = container.scrollHeight; - return msgDiv; -} - -function removeTypingIndicator() { - const indicator = document.getElementById('typingIndicator'); - if (indicator) { - indicator.parentElement.remove(); - } -} - -async function sendAgentMessage() { - const input = document.getElementById('chatInput'); - const message = input.value.trim(); - if (!message) return; - - input.value = ''; - addChatMessage(message, true); - addChatMessage('', false, true); - - try { - // 判断是命令还是问答 - const isCommand = message.includes('合并') || message.includes('修改') || - message.startsWith('把') || message.startsWith('将'); - - if (isCommand) { - // 执行命令 - const res = await fetch(`${API_BASE}/projects/${currentProject.id}/agent/command`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ command: message }) - }); - - removeTypingIndicator(); - - if (res.ok) { - const result = await res.json(); - let response = ''; - - if (result.intent === 'merge_entities') { - if (result.success) { - response = `✅ 已合并 ${result.merged.length} 个实体到 "${result.target}"`; - await loadProjectData(); // 刷新数据 - } else { - response = `❌ 合并失败:${result.error || '未找到匹配的实体'}`; - } - } else if (result.intent === 'edit_entity') { - if (result.success) { - response = `✅ 已更新实体 "${result.entity?.name}"`; - await loadProjectData(); - } else { - response = `❌ 编辑失败:${result.error || '未找到实体'}`; - } - } else if (result.intent === 'answer_question') { - response = result.answer; - } else { - response = result.message || result.explanation || '未识别的指令'; - } - - addChatMessage(response); - } else { - addChatMessage('❌ 请求失败,请重试'); - } - } else { - // RAG 问答 - const res = await fetch(`${API_BASE}/projects/${currentProject.id}/agent/query`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query: message, stream: false }) - }); - - removeTypingIndicator(); - - if (res.ok) { - const result = await res.json(); - addChatMessage(result.answer); - } else { - addChatMessage('❌ 获取回答失败,请重试'); - } - } - } catch (err) { - removeTypingIndicator(); - addChatMessage('❌ 网络错误,请检查连接'); - console.error('Agent error:', err); - } -} - -async function loadSuggestions() { - addChatMessage('正在获取建议...', false, true); - - try { - const res = await fetch(`${API_BASE}/projects/${currentProject.id}/agent/suggest`); - removeTypingIndicator(); - - if (res.ok) { - const result = await res.json(); - const suggestions = result.suggestions || []; - - if (suggestions.length === 0) { - addChatMessage('暂无建议,请先上传一些音频文件。'); - return; - } - - let html = '
💡 基于项目数据的建议:
'; - suggestions.forEach((s, i) => { - html += ` -
-
${s.type === 'action' ? '⚡ 操作' : '💡 洞察'}
-
${s.title}
-
${s.description}
-
- `; - }); - - const msgDiv = document.createElement('div'); - msgDiv.className = 'chat-message assistant'; - msgDiv.innerHTML = `
${html}
`; - document.getElementById('chatMessages').appendChild(msgDiv); - } - } catch (err) { - removeTypingIndicator(); - addChatMessage('❌ 获取建议失败'); - } -} - -function applySuggestion(index) { - // 可以在这里实现建议的自动应用 - addChatMessage('建议功能开发中,敬请期待!'); -} - -// ==================== Transcript Rendering ==================== - -function renderTranscript() { - const container = document.getElementById('transcriptContent'); - if (!container || !currentData || !currentData.segments) return; - - container.innerHTML = ''; - - currentData.segments.forEach((seg, idx) => { - const div = document.createElement('div'); - div.className = 'segment'; - div.dataset.index = idx; - - let text = seg.text; - const entities = findEntitiesInText(seg.text); - - entities.sort((a, b) => b.start - a.start); - - entities.forEach(ent => { - const before = text.slice(0, ent.start); - const name = text.slice(ent.start, ent.end); - const after = text.slice(ent.end); - const details = entityDetailsCache[ent.id]; - const confidence = details?.mentions?.[0]?.confidence || 1.0; - const lowConfClass = confidence < 0.7 ? 'low-confidence' : ''; - - text = before + `${name}` + after; - }); - - div.innerHTML = ` -
${seg.speaker}
-
${text}
- `; - - container.appendChild(div); - }); -} - -function findEntitiesInText(text) { - if (!projectEntities || projectEntities.length === 0) return []; - - const found = []; - projectEntities.forEach(ent => { - const name = ent.name; - let pos = 0; - while ((pos = text.indexOf(name, pos)) !== -1) { - found.push({ - id: ent.id, - name: ent.name, - start: pos, - end: pos + name.length - }); - pos += 1; - } - - if (ent.aliases && ent.aliases.length > 0) { - ent.aliases.forEach(alias => { - let aliasPos = 0; - while ((aliasPos = text.indexOf(alias, aliasPos)) !== -1) { - found.push({ - id: ent.id, - name: alias, - start: aliasPos, - end: aliasPos + alias.length - }); - aliasPos += 1; - } - }); - } - }); - - return found; -} - -// ==================== Entity Card ==================== - -function initEntityCard() { - const card = document.getElementById('entityCard'); - - // 鼠标移出卡片时隐藏 - card.addEventListener('mouseleave', () => { - card.classList.remove('show'); - }); -} - -function showEntityCard(event, entityId) { - const card = document.getElementById('entityCard'); - const details = entityDetailsCache[entityId]; - const entity = projectEntities.find(e => e.id === entityId); - - if (!entity) return; - - // 更新卡片内容 - document.getElementById('cardName').textContent = entity.name; - document.getElementById('cardBadge').textContent = entity.type; - document.getElementById('cardBadge').className = `entity-type-badge type-${entity.type.toLowerCase()}`; - document.getElementById('cardDefinition').textContent = entity.definition || '暂无定义'; - - const mentionCount = details?.mentions?.length || 0; - const relationCount = details?.relations?.length || 0; - document.getElementById('cardMentions').textContent = `${mentionCount} 次提及`; - document.getElementById('cardRelations').textContent = `${relationCount} 个关系`; - - // 定位卡片 - const rect = event.target.getBoundingClientRect(); - card.style.left = `${rect.left}px`; - card.style.top = `${rect.bottom + 10}px`; - - // 确保不超出屏幕 - const cardRect = card.getBoundingClientRect(); - if (cardRect.right > window.innerWidth) { - card.style.left = `${window.innerWidth - cardRect.width - 20}px`; - } - - card.classList.add('show'); -} - -function hideEntityCard() { - // 延迟隐藏,允许鼠标移到卡片上 - setTimeout(() => { - const card = document.getElementById('entityCard'); - if (!card.matches(':hover')) { - card.classList.remove('show'); - } - }, 100); -} - -// ==================== Graph Visualization ==================== - -function renderGraph() { - const svg = d3.select('#graph-svg'); - svg.selectAll('*').remove(); - - if (!projectEntities || projectEntities.length === 0) { - svg.append('text') - .attr('x', '50%') - .attr('y', '50%') - .attr('text-anchor', 'middle') - .attr('fill', '#666') - .text('暂无实体数据,请上传音频'); - return; - } - - const container = svg.node().parentElement; - const width = container.clientWidth; - const height = container.clientHeight - 200; - - svg.attr('width', width).attr('height', height); - - const nodes = projectEntities.map(e => ({ - id: e.id, - name: e.name, - type: e.type, - definition: e.definition, - ...e - })); - - const links = projectRelations.map(r => ({ - id: r.id, - source: r.source_id, - target: r.target_id, - type: r.type, - evidence: r.evidence - })).filter(r => r.source && r.target); - - if (links.length === 0 && nodes.length > 1) { - for (let i = 0; i < Math.min(nodes.length - 1, 5); i++) { - links.push({ source: nodes[0].id, target: nodes[i + 1].id, type: 'related' }); - } - } - - const colorMap = { - 'PROJECT': '#7b2cbf', - 'TECH': '#00d4ff', - 'PERSON': '#ff6b6b', - 'ORG': '#4ecdc4', - 'OTHER': '#666' - }; - - const simulation = d3.forceSimulation(nodes) - .force('link', d3.forceLink(links).id(d => d.id).distance(120)) - .force('charge', d3.forceManyBody().strength(-400)) - .force('center', d3.forceCenter(width / 2, height / 2)) - .force('collision', d3.forceCollide().radius(50)); - - // 关系连线 - const link = svg.append('g') - .selectAll('line') - .data(links) - .enter().append('line') - .attr('stroke', '#444') - .attr('stroke-width', 1.5) - .attr('stroke-opacity', 0.6) - .style('cursor', 'pointer') - .on('click', (e, d) => showProvenance(d)); - - // 关系标签 - const linkLabel = svg.append('g') - .selectAll('text') - .data(links) - .enter().append('text') - .attr('font-size', '10px') - .attr('fill', '#666') - .attr('text-anchor', 'middle') - .style('pointer-events', 'none') - .text(d => d.type); - - // 节点组 - const node = svg.append('g') - .selectAll('g') - .data(nodes) - .enter().append('g') - .attr('class', 'node') - .call(d3.drag() - .on('start', dragstarted) - .on('drag', dragged) - .on('end', dragended)) - .on('click', (e, d) => window.selectEntity(d.id)) - .on('mouseenter', (e, d) => showEntityCard(e, d.id)) - .on('mouseleave', hideEntityCard); - - // 节点圆圈 - node.append('circle') - .attr('r', 35) - .attr('fill', d => colorMap[d.type] || '#666') - .attr('stroke', '#fff') - .attr('stroke-width', 2) - .attr('class', 'node-circle'); - - // 节点文字 - node.append('text') - .text(d => d.name.length > 6 ? d.name.slice(0, 5) + '...' : d.name) - .attr('text-anchor', 'middle') - .attr('dy', 5) - .attr('fill', '#fff') - .attr('font-size', '11px') - .attr('font-weight', '500') - .style('pointer-events', 'none'); - - // 节点类型图标 - node.append('text') - .attr('dy', -45) - .attr('text-anchor', 'middle') - .attr('fill', d => colorMap[d.type] || '#666') - .attr('font-size', '10px') - .text(d => d.type) - .style('pointer-events', 'none'); - - simulation.on('tick', () => { - link - .attr('x1', d => d.source.x) - .attr('y1', d => d.source.y) - .attr('x2', d => d.target.x) - .attr('y2', d => d.target.y); - - linkLabel - .attr('x', d => (d.source.x + d.target.x) / 2) - .attr('y', d => (d.source.y + d.target.y) / 2); - - node.attr('transform', d => `translate(${d.x},${d.y})`); - }); - - function dragstarted(e, d) { - if (!e.active) simulation.alphaTarget(0.3).restart(); - d.fx = d.x; - d.fy = d.y; - } - - function dragged(e, d) { - d.fx = e.x; - d.fy = e.y; - } - - function dragended(e, d) { - if (!e.active) simulation.alphaTarget(0); - d.fx = null; - d.fy = null; - } -} - -// ==================== Provenance ==================== - -async function showProvenance(relation) { - const modal = document.getElementById('provenanceModal'); - const body = document.getElementById('provenanceBody'); - - modal.classList.add('show'); - body.innerHTML = '

加载中...

'; - - try { - let content = ''; - - if (relation.id) { - // 从API获取溯源信息 - const res = await fetch(`${API_BASE}/relations/${relation.id}/provenance`); - if (res.ok) { - const data = await res.json(); - content = ` -
-
关系类型
-
${data.source} → ${data.type} → ${data.target}
-
- -
-
来源文档
-
${data.transcript?.filename || '未知文件'}
-
- -
证据文本
-
"${data.evidence || '无证据文本'}"
- `; - } else { - content = '

获取溯源信息失败

'; - } - } else { - // 使用本地数据 - content = ` -
-
关系类型
-
${relation.source.name || relation.source} → ${relation.type} → ${relation.target.name || relation.target}
-
- -
证据文本
-
"${relation.evidence || '无证据文本'}"
- `; - } - - body.innerHTML = content; - } catch (err) { - body.innerHTML = '

加载失败

'; - } -} - -function closeProvenance() { - document.getElementById('provenanceModal').classList.remove('show'); -} - -// ==================== Entity List ==================== - -function renderEntityList() { - const container = document.getElementById('entityList'); - if (!container) return; - - container.innerHTML = '

项目实体

'; - - if (!projectEntities || projectEntities.length === 0) { - container.innerHTML += '

暂无实体,请上传音频文件

'; - return; - } - - projectEntities.forEach(ent => { - const div = document.createElement('div'); - div.className = 'entity-item'; - div.dataset.id = ent.id; - div.onclick = () => window.selectEntity(ent.id); - div.onmouseenter = (e) => showEntityCard(e, ent.id); - div.onmouseleave = hideEntityCard; - - div.innerHTML = ` - ${ent.type} -
-
${ent.name}
-
${ent.definition || '暂无定义'}
-
- `; - - container.appendChild(div); - }); -} - -// ==================== Entity Selection ==================== - -window.selectEntity = function(entityId) { - selectedEntity = entityId; - const entity = projectEntities.find(e => e.id === entityId); - if (!entity) return; - - // 高亮文本中的实体 - document.querySelectorAll('.entity').forEach(el => { - if (el.dataset.id === entityId) { - el.style.background = '#ff6b6b'; - el.style.color = '#fff'; - } else { - el.style.background = ''; - el.style.color = ''; - } - }); - - // 高亮图谱中的节点 - d3.selectAll('.node-circle') - .attr('stroke', d => d.id === entityId ? '#ff6b6b' : '#fff') - .attr('stroke-width', d => d.id === entityId ? 4 : 2) - .attr('r', d => d.id === entityId ? 40 : 35); - - // 高亮实体列表 - document.querySelectorAll('.entity-item').forEach(el => { - if (el.dataset.id === entityId) { - el.style.background = '#2a2a2a'; - el.style.borderLeft = '3px solid #ff6b6b'; - } else { - el.style.background = ''; - el.style.borderLeft = ''; - } - }); - - console.log('Selected:', entity.name, entity.definition); -}; - -// ==================== Upload ==================== - -window.showUpload = function() { - const el = document.getElementById('uploadOverlay'); - if (el) el.classList.add('show'); -}; - -window.hideUpload = function() { - const el = document.getElementById('uploadOverlay'); - if (el) el.classList.remove('show'); -}; - -function initUpload() { - const input = document.getElementById('fileInput'); - const overlay = document.getElementById('uploadOverlay'); - - if (!input) return; - - input.addEventListener('change', async (e) => { - if (!e.target.files.length) return; - - const file = e.target.files[0]; - if (overlay) { - overlay.innerHTML = ` -
-

正在分析...

-

${file.name}

-

ASR转录 + 实体提取中

-
- `; - } - - try { - const result = await uploadAudio(file); - - currentData = result; - - await loadProjectData(); - - if (result.segments && result.segments.length > 0) { - renderTranscript(); - } - - if (overlay) overlay.classList.remove('show'); - - } catch (err) { - console.error('Upload failed:', err); - if (overlay) { - overlay.innerHTML = ` -
-

分析失败

-

${err.message}

- -
- `; - } - } - }); -} - -// ==================== Phase 5: Timeline View ==================== - -async function loadTimeline() { - const container = document.getElementById('timelineContainer'); - const entityFilter = document.getElementById('timelineEntityFilter'); - - if (!container) return; - - container.innerHTML = '

加载时间线数据...

'; - - try { - // 更新实体筛选器选项 - if (entityFilter && projectEntities.length > 0) { - const currentValue = entityFilter.value; - entityFilter.innerHTML = ''; - projectEntities.forEach(ent => { - const option = document.createElement('option'); - option.value = ent.id; - option.textContent = ent.name; - entityFilter.appendChild(option); - }); - entityFilter.value = currentValue; - } - - // 构建查询参数 - const params = new URLSearchParams(); - if (entityFilter && entityFilter.value) { - params.append('entity_id', entityFilter.value); - } - - // 获取时间线数据 - const res = await fetch(`${API_BASE}/projects/${currentProject.id}/timeline?${params}`); - if (!res.ok) throw new Error('Failed to load timeline'); - - const data = await res.json(); - const events = data.events || []; - - // 更新统计 - const mentions = events.filter(e => e.type === 'mention').length; - const relations = events.filter(e => e.type === 'relation').length; - - document.getElementById('timelineTotalEvents').textContent = events.length; - document.getElementById('timelineMentions').textContent = mentions; - document.getElementById('timelineRelations').textContent = relations; - - // 渲染时间线 - renderTimeline(events); - - } catch (err) { - console.error('Load timeline failed:', err); - container.innerHTML = '

加载失败,请重试

'; - } -} - -function renderTimeline(events) { - const container = document.getElementById('timelineContainer'); - - if (events.length === 0) { - container.innerHTML = ` -
-

暂无时间线数据

-

请先上传音频或文档文件

-
- `; - return; - } - - // 按日期分组 - const grouped = groupEventsByDate(events); - - let html = '
'; - - Object.entries(grouped).forEach(([date, dayEvents]) => { - const dateLabel = formatDateLabel(date); - - html += ` -
-
-
${dateLabel}
-
-
-
- `; - - dayEvents.forEach(event => { - html += renderTimelineEvent(event); - }); - - html += '
'; - }); - - container.innerHTML = html; -} - -function groupEventsByDate(events) { - const grouped = {}; - - events.forEach(event => { - const date = event.event_date.split('T')[0]; - if (!grouped[date]) { - grouped[date] = []; - } - grouped[date].push(event); - }); - - return grouped; -} - -function formatDateLabel(dateStr) { - const date = new Date(dateStr); - const today = new Date(); - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - - if (dateStr === today.toISOString().split('T')[0]) { - return '今天'; - } else if (dateStr === yesterday.toISOString().split('T')[0]) { - return '昨天'; - } else { - return `${date.getMonth() + 1}月${date.getDate()}日`; - } -} - -function renderTimelineEvent(event) { - if (event.type === 'mention') { - return ` -
-
- 提及 - ${event.entity_name} - ${event.entity_type || 'OTHER'} -
-
"${event.text_snippet || ''}"
-
- 📄 ${event.source?.filename || '未知文件'} - ${event.confidence ? `置信度: ${(event.confidence * 100).toFixed(0)}%` : ''} -
-
- `; - } else if (event.type === 'relation') { - return ` -
-
- 关系 - ${event.source_entity} → ${event.target_entity} -
-
- 关系类型: ${event.relation_type} -
- ${event.evidence ? `
"${event.evidence}"
` : ''} -
- 📄 ${event.source?.filename || '未知文件'} -
-
- `; - } - return ''; -} - // ==================== View Switching ==================== window.switchView = function(viewName) { - // 更新侧边栏按钮状态 + // Update sidebar buttons document.querySelectorAll('.sidebar-btn').forEach(btn => { btn.classList.remove('active'); }); - // 隐藏所有视图 - document.getElementById('workbenchView').style.display = 'none'; - document.getElementById('knowledgeBaseView').classList.remove('show'); - document.getElementById('timelineView').classList.remove('show'); - document.getElementById('reasoningView').classList.remove('active'); - document.getElementById('graphAnalysisView').classList.remove('active'); + const views = { + 'workbench': 'workbenchView', + 'knowledge-base': 'knowledgeBaseView', + 'timeline': 'timelineView', + 'reasoning': 'reasoningView', + 'graph-analysis': 'graphAnalysisView', + 'api-keys': 'apiKeysView' + }; - // 显示选中的视图 - if (viewName === 'workbench') { - document.getElementById('workbenchView').style.display = 'flex'; - document.querySelector('.sidebar-btn:nth-child(1)').classList.add('active'); - } else if (viewName === 'knowledge-base') { - document.getElementById('knowledgeBaseView').classList.add('show'); - document.querySelector('.sidebar-btn:nth-child(2)').classList.add('active'); + // Hide all views + Object.values(views).forEach(id => { + const el = document.getElementById(id); + if (el) { + el.style.display = 'none'; + el.classList.remove('active', 'show'); + } + }); + + // Show selected view + const targetId = views[viewName]; + if (targetId) { + const targetEl = document.getElementById(targetId); + if (targetEl) { + targetEl.style.display = 'flex'; + targetEl.classList.add('active', 'show'); + } + } + + // Update active button + const btnMap = { + 'workbench': 0, + 'knowledge-base': 1, + 'timeline': 2, + 'reasoning': 3, + 'graph-analysis': 4, + 'api-keys': 5 + }; + const buttons = document.querySelectorAll('.sidebar-btn'); + if (buttons[btnMap[viewName]]) { + buttons[btnMap[viewName]].classList.add('active'); + } + + // Load view-specific data + if (viewName === 'knowledge-base') { loadKnowledgeBase(); } else if (viewName === 'timeline') { - document.getElementById('timelineView').classList.add('show'); - document.querySelector('.sidebar-btn:nth-child(3)').classList.add('active'); loadTimeline(); - } else if (viewName === 'reasoning') { - document.getElementById('reasoningView').classList.add('active'); - document.querySelector('.sidebar-btn:nth-child(4)').classList.add('active'); } else if (viewName === 'graph-analysis') { - document.getElementById('graphAnalysisView').classList.add('active'); - document.querySelector('.sidebar-btn:nth-child(5)').classList.add('active'); initGraphAnalysis(); + } else if (viewName === 'api-keys') { + loadApiKeys(); } }; -window.switchKBTab = function(tabName) { - // 更新导航项状态 - document.querySelectorAll('.kb-nav-item').forEach(item => { - item.classList.remove('active'); - }); - - // 隐藏所有部分 - document.querySelectorAll('.kb-section').forEach(section => { - section.classList.remove('active'); - }); - - // 显示选中的部分 - const tabMap = { - 'entities': { nav: 0, section: 'kbEntitiesSection' }, - 'relations': { nav: 1, section: 'kbRelationsSection' }, - 'glossary': { nav: 2, section: 'kbGlossarySection' }, - 'transcripts': { nav: 3, section: 'kbTranscriptsSection' } - }; - - const mapping = tabMap[tabName]; - if (mapping) { - document.querySelectorAll('.kb-nav-item')[mapping.nav].classList.add('active'); - document.getElementById(mapping.section).classList.add('active'); - } -}; +// ==================== Phase 6: API Key Management ==================== -async function loadKnowledgeBase() { - if (!currentProject) return; - +let apiKeysData = []; +let currentApiKeyId = null; + +// Load API Keys +async function loadApiKeys() { try { - const res = await fetch(`${API_BASE}/projects/${currentProject.id}/knowledge-base`); - if (!res.ok) throw new Error('Failed to load knowledge base'); - + const res = await fetch(`${API_BASE}/api-keys`); + if (!res.ok) throw new Error('Failed to fetch API keys'); const data = await res.json(); - - // 更新统计 - document.getElementById('kbEntityCount').textContent = data.stats.entity_count; - document.getElementById('kbRelationCount').textContent = data.stats.relation_count; - document.getElementById('kbTranscriptCount').textContent = data.stats.transcript_count; - document.getElementById('kbGlossaryCount').textContent = data.stats.glossary_count; - - // 渲染实体网格 - const entityGrid = document.getElementById('kbEntityGrid'); - entityGrid.innerHTML = ''; - data.entities.forEach(ent => { - const card = document.createElement('div'); - card.className = 'kb-entity-card'; - card.onclick = () => { - switchView('workbench'); - setTimeout(() => selectEntity(ent.id), 100); - }; - - // 渲染属性预览 - let attrsHtml = ''; - if (ent.attributes && ent.attributes.length > 0) { - attrsHtml = ` -
- ${ent.attributes.slice(0, 3).map(a => ` - - ${a.name}: ${Array.isArray(a.value) ? a.value.join(', ') : a.value} - - `).join('')} - ${ent.attributes.length > 3 ? `+${ent.attributes.length - 3}` : ''} -
- `; - } - - card.innerHTML = ` -
- ${ent.type} - ${ent.name} -
-
${ent.definition || '暂无定义'}
-
- 📍 ${ent.mention_count || 0} 次提及 · - ${ent.appears_in?.length || 0} 个文件 -
- ${attrsHtml} - `; - entityGrid.appendChild(card); - }); - - // 渲染关系列表 - const relationsList = document.getElementById('kbRelationsList'); - relationsList.innerHTML = ''; - data.relations.forEach(rel => { - const item = document.createElement('div'); - item.className = 'kb-glossary-item'; - item.innerHTML = ` -
- ${rel.source_name} - → ${rel.type} → - ${rel.target_name} - ${rel.evidence ? `
"${rel.evidence.substring(0, 100)}..."
` : ''} -
- `; - relationsList.appendChild(item); - }); - - // 渲染术语表 - const glossaryList = document.getElementById('kbGlossaryList'); - glossaryList.innerHTML = ''; - data.glossary.forEach(term => { - const item = document.createElement('div'); - item.className = 'kb-glossary-item'; - item.innerHTML = ` -
- ${term.term} - ${term.pronunciation ? `(${term.pronunciation})` : ''} - 出现 ${term.frequency} 次 -
- - `; - glossaryList.appendChild(item); - }); - - // 渲染文件列表 - const transcriptsList = document.getElementById('kbTranscriptsList'); - transcriptsList.innerHTML = ''; - data.transcripts.forEach(t => { - const item = document.createElement('div'); - item.className = 'kb-transcript-item'; - item.innerHTML = ` -
- ${t.type === 'audio' ? '🎵' : '📄'} - ${t.filename} -
${new Date(t.created_at).toLocaleString()}
-
- `; - transcriptsList.appendChild(item); - }); - + apiKeysData = data.keys || []; + renderApiKeys(); + updateApiKeyStats(); } catch (err) { - console.error('Load knowledge base failed:', err); + console.error('Failed to load API keys:', err); + document.getElementById('apiKeysListContent').innerHTML = ` +
+

加载失败: ${err.message}

+
+ `; } } -// ==================== Glossary Functions ==================== - -window.showAddTermModal = function() { - document.getElementById('glossaryModal').classList.add('show'); -}; - -window.hideGlossaryModal = function() { - document.getElementById('glossaryModal').classList.remove('show'); -}; - -window.saveGlossaryTerm = async function() { - const term = document.getElementById('glossaryTerm').value.trim(); - const pronunciation = document.getElementById('glossaryPronunciation').value.trim(); +// Update API Key Stats +function updateApiKeyStats() { + const total = apiKeysData.length; + const active = apiKeysData.filter(k => k.status === 'active').length; + const revoked = apiKeysData.filter(k => k.status === 'revoked').length; + const totalCalls = apiKeysData.reduce((sum, k) => sum + (k.total_calls || 0), 0); - if (!term) return; - - try { - const res = await fetch(`${API_BASE}/projects/${currentProject.id}/glossary`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ term, pronunciation }) - }); - - if (res.ok) { - hideGlossaryModal(); - document.getElementById('glossaryTerm').value = ''; - document.getElementById('glossaryPronunciation').value = ''; - loadKnowledgeBase(); - } - } catch (err) { - console.error('Save glossary term failed:', err); - } -}; - -window.deleteGlossaryTerm = async function(termId) { - if (!confirm('确定要删除这个术语吗?')) return; - - try { - const res = await fetch(`${API_BASE}/glossary/${termId}`, { - method: 'DELETE' - }); - - if (res.ok) { - loadKnowledgeBase(); - } - } catch (err) { - console.error('Delete glossary term failed:', err); - } -}; - -// ==================== Phase 5: Knowledge Reasoning ==================== - -window.submitReasoningQuery = async function() { - const input = document.getElementById('reasoningInput'); - const depth = document.getElementById('reasoningDepth').value; - const query = input.value.trim(); - - if (!query) return; - - const resultsDiv = document.getElementById('reasoningResults'); - - // 显示加载状态 - resultsDiv.innerHTML = ` -
-
- -
-

正在进行知识推理...

-
- `; - - try { - const res = await fetch(`${API_BASE}/projects/${currentProject.id}/reasoning/query`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query, reasoning_depth: depth }) - }); - - if (!res.ok) throw new Error('Reasoning failed'); - - const data = await res.json(); - renderReasoningResult(data); - - } catch (err) { - console.error('Reasoning query failed:', err); - resultsDiv.innerHTML = ` -
-

推理失败,请稍后重试

-
- `; - } -}; - -function renderReasoningResult(data) { - const resultsDiv = document.getElementById('reasoningResults'); - - const typeLabels = { - 'causal': '🔍 因果推理', - 'comparative': '⚖️ 对比推理', - 'temporal': '⏱️ 时序推理', - 'associative': '🔗 关联推理', - 'summary': '📝 总结推理' - }; - - const typeLabel = typeLabels[data.reasoning_type] || '🤔 智能分析'; - const confidencePercent = Math.round(data.confidence * 100); - - let evidenceHtml = ''; - if (data.evidence && data.evidence.length > 0) { - evidenceHtml = ` -
-

📋 支撑证据

- ${data.evidence.map(e => `
${e.text || e}
`).join('')} -
- `; - } - - let gapsHtml = ''; - if (data.knowledge_gaps && data.knowledge_gaps.length > 0) { - gapsHtml = ` -
-

⚠️ 知识缺口

- -
- `; - } - - resultsDiv.innerHTML = ` -
-
-
- ${typeLabel} -
-
- 置信度: ${confidencePercent}% -
-
-
- ${data.answer.replace(/\n/g, '
')} -
- ${evidenceHtml} - ${gapsHtml} -
- `; + document.getElementById('apiKeyTotalCount').textContent = total; + document.getElementById('apiKeyActiveCount').textContent = active; + document.getElementById('apiKeyRevokedCount').textContent = revoked; + document.getElementById('apiKeyTotalCalls').textContent = totalCalls.toLocaleString(); } -window.clearReasoningResult = function() { - document.getElementById('reasoningResults').innerHTML = ''; - document.getElementById('reasoningInput').value = ''; - document.getElementById('inferencePathsSection').style.display = 'none'; -}; - -window.generateSummary = async function(summaryType) { - const resultsDiv = document.getElementById('reasoningResults'); +// Render API Keys List +function renderApiKeys() { + const container = document.getElementById('apiKeysListContent'); - // 显示加载状态 - resultsDiv.innerHTML = ` -
-
- -
-

正在生成项目总结...

-
- `; - - try { - const res = await fetch(`${API_BASE}/projects/${currentProject.id}/reasoning/summary`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ summary_type: summaryType }) - }); - - if (!res.ok) throw new Error('Summary failed'); - - const data = await res.json(); - renderSummaryResult(data); - - } catch (err) { - console.error('Summary generation failed:', err); - resultsDiv.innerHTML = ` -
-

总结生成失败,请稍后重试

+ if (apiKeysData.length === 0) { + container.innerHTML = ` +
+

暂无 API Keys

+
`; - } -}; - -function renderSummaryResult(data) { - const resultsDiv = document.getElementById('reasoningResults'); - - const typeLabels = { - 'comprehensive': '📋 全面总结', - 'executive': '💼 高管摘要', - 'technical': '⚙️ 技术总结', - 'risk': '⚠️ 风险分析' - }; - - const typeLabel = typeLabels[data.summary_type] || '📝 项目总结'; - - let keyPointsHtml = ''; - if (data.key_points && data.key_points.length > 0) { - keyPointsHtml = ` -
-

📌 关键要点

- -
- `; - } - - let risksHtml = ''; - if (data.risks && data.risks.length > 0) { - risksHtml = ` -
-

⚠️ 风险与问题

- -
- `; - } - - let recommendationsHtml = ''; - if (data.recommendations && data.recommendations.length > 0) { - recommendationsHtml = ` -
-

💡 建议

- ${data.recommendations.map(r => `
${r}
`).join('')} -
- `; - } - - resultsDiv.innerHTML = ` -
-
-
- ${typeLabel} -
-
- 置信度: ${Math.round(data.confidence * 100)}% -
-
-
- ${data.overview ? data.overview.replace(/\n/g, '
') : ''} -
- ${keyPointsHtml} - ${risksHtml} - ${recommendationsHtml} -
- `; -} - -window.findInferencePath = async function(startEntity, endEntity) { - const pathsSection = document.getElementById('inferencePathsSection'); - const pathsList = document.getElementById('inferencePathsList'); - - pathsSection.style.display = 'block'; - pathsList.innerHTML = '

正在搜索关联路径...

'; - - try { - const res = await fetch( - `${API_BASE}/projects/${currentProject.id}/reasoning/inference-path?start_entity=${encodeURIComponent(startEntity)}&end_entity=${encodeURIComponent(endEntity)}` - ); - - if (!res.ok) throw new Error('Path finding failed'); - - const data = await res.json(); - renderInferencePaths(data); - - } catch (err) { - console.error('Path finding failed:', err); - pathsList.innerHTML = '

路径搜索失败

'; - } -}; - -// Phase 5: Entity Attributes Management -let currentEntityIdForAttributes = null; -let currentAttributes = []; -let currentTemplates = []; - -// Show entity attributes modal -window.showEntityAttributes = async function(entityId) { - if (entityId) { - currentEntityIdForAttributes = entityId; - } else if (selectedEntity) { - currentEntityIdForAttributes = selectedEntity; - } else { - alert('请先选择一个实体'); return; } - const modal = document.getElementById('attributesModal'); - modal.classList.add('show'); - - // Reset form - document.getElementById('attributesAddForm').style.display = 'none'; - document.getElementById('toggleAddAttrBtn').style.display = 'inline-block'; - document.getElementById('saveAttrBtn').style.display = 'none'; - - await loadEntityAttributes(); -}; - -window.hideAttributesModal = function() { - document.getElementById('attributesModal').classList.remove('show'); - currentEntityIdForAttributes = null; -}; - -async function loadEntityAttributes() { - if (!currentEntityIdForAttributes) return; - - try { - const res = await fetch(`${API_BASE}/entities/${currentEntityIdForAttributes}/attributes`); - if (!res.ok) throw new Error('Failed to load attributes'); - - const data = await res.json(); - currentAttributes = data.attributes || []; - - renderAttributesList(); - } catch (err) { - console.error('Load attributes failed:', err); - document.getElementById('attributesList').innerHTML = '

加载失败

'; - } -} - -function renderAttributesList() { - const container = document.getElementById('attributesList'); - - if (currentAttributes.length === 0) { - container.innerHTML = '

暂无属性,点击"添加属性"创建

'; - return; - } - - container.innerHTML = currentAttributes.map(attr => { - let valueDisplay = attr.value; - if (attr.type === 'multiselect' && Array.isArray(attr.value)) { - valueDisplay = attr.value.join(', '); - } - - return ` -
-
-
- ${attr.name} - ${attr.type} -
-
${valueDisplay || '-'}
-
-
- - -
+ container.innerHTML = apiKeysData.map(key => ` +
+
+
${escapeHtml(key.name)}
+
${key.key_preview}
- `; - }).join(''); +
+ ${key.permissions.map(p => `${p}`).join('')} +
+
${key.rate_limit}/min
+
+ ${key.status} +
+
${key.total_calls || 0}
+
+ ${key.status === 'active' ? ` + + + ` : '已失效'} +
+
+ `).join(''); } -window.toggleAddAttributeForm = function() { - const form = document.getElementById('attributesAddForm'); - const toggleBtn = document.getElementById('toggleAddAttrBtn'); - const saveBtn = document.getElementById('saveAttrBtn'); - - if (form.style.display === 'none') { - form.style.display = 'block'; - toggleBtn.style.display = 'none'; - saveBtn.style.display = 'inline-block'; - } else { - form.style.display = 'none'; - toggleBtn.style.display = 'inline-block'; - saveBtn.style.display = 'none'; - } +// Show Create API Key Modal +window.showCreateApiKeyModal = function() { + document.getElementById('apiKeyCreateModal').classList.add('show'); + document.getElementById('apiKeyName').value = ''; + document.getElementById('apiKeyName').focus(); }; -window.onAttrTypeChange = function() { - const type = document.getElementById('attrType').value; - const optionsGroup = document.getElementById('attrOptionsGroup'); - const valueContainer = document.getElementById('attrValueContainer'); - - if (type === 'select' || type === 'multiselect') { - optionsGroup.style.display = 'block'; - } else { - optionsGroup.style.display = 'none'; - } - - // Update value input based on type - if (type === 'date') { - valueContainer.innerHTML = ''; - } else if (type === 'number') { - valueContainer.innerHTML = ''; - } else { - valueContainer.innerHTML = ''; - } +// Hide Create API Key Modal +window.hideCreateApiKeyModal = function() { + document.getElementById('apiKeyCreateModal').classList.remove('show'); }; -window.saveAttribute = async function() { - if (!currentEntityIdForAttributes) return; - - const name = document.getElementById('attrName').value.trim(); - const type = document.getElementById('attrType').value; - let value = document.getElementById('attrValue').value; - const changeReason = document.getElementById('attrChangeReason').value.trim(); - +// Create API Key +window.createApiKey = async function() { + const name = document.getElementById('apiKeyName').value.trim(); if (!name) { - alert('请输入属性名称'); + alert('请输入 API Key 名称'); return; } - // Handle options for select/multiselect - let options = null; - if (type === 'select' || type === 'multiselect') { - const optionsStr = document.getElementById('attrOptions').value.trim(); - if (optionsStr) { - options = optionsStr.split(',').map(o => o.trim()).filter(o => o); - } - - // Handle multiselect value - if (type === 'multiselect' && value) { - value = value.split(',').map(v => v.trim()).filter(v => v); - } + const permissions = []; + if (document.getElementById('permRead').checked) permissions.push('read'); + if (document.getElementById('permWrite').checked) permissions.push('write'); + if (document.getElementById('permDelete').checked) permissions.push('delete'); + + if (permissions.length === 0) { + alert('请至少选择一个权限'); + return; } - // Handle number type - if (type === 'number' && value) { - value = parseFloat(value); - } + const rateLimit = parseInt(document.getElementById('apiKeyRateLimit').value); + const expiresDays = document.getElementById('apiKeyExpires').value; try { - const res = await fetch(`${API_BASE}/entities/${currentEntityIdForAttributes}/attributes`, { + const res = await fetch(`${API_BASE}/api-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, - type, - value, - options, - change_reason: changeReason + permissions, + rate_limit: rateLimit, + expires_days: expiresDays ? parseInt(expiresDays) : null }) }); - if (!res.ok) throw new Error('Failed to save attribute'); + if (!res.ok) throw new Error('Failed to create API key'); - // Reset form - document.getElementById('attrName').value = ''; - document.getElementById('attrValue').value = ''; - document.getElementById('attrOptions').value = ''; - document.getElementById('attrChangeReason').value = ''; + const data = await res.json(); + hideCreateApiKeyModal(); - // Reload attributes - await loadEntityAttributes(); - - // Hide form - toggleAddAttributeForm(); + // Show the created key + document.getElementById('createdApiKeyValue').textContent = data.api_key; + document.getElementById('apiKeyCreatedModal').classList.add('show'); + // Refresh list + await loadApiKeys(); } catch (err) { - console.error('Save attribute failed:', err); - alert('保存失败,请重试'); + console.error('Failed to create API key:', err); + alert('创建失败: ' + err.message); } }; -window.deleteAttribute = async function(attributeId) { - if (!confirm('确定要删除这个属性吗?')) return; +// Copy API Key to clipboard +window.copyApiKey = function() { + const key = document.getElementById('createdApiKeyValue').textContent; + navigator.clipboard.writeText(key).then(() => { + showNotification('API Key 已复制到剪贴板', 'success'); + }).catch(() => { + // Fallback + const textarea = document.createElement('textarea'); + textarea.value = key; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + showNotification('API Key 已复制到剪贴板', 'success'); + }); +}; + +// Hide API Key Created Modal +window.hideApiKeyCreatedModal = function() { + document.getElementById('apiKeyCreatedModal').classList.remove('show'); +}; + +// Show API Key Stats +window.showApiKeyStats = async function(keyId, keyName) { + currentApiKeyId = keyId; + document.getElementById('apiKeyStatsTitle').textContent = `API Key 统计 - ${keyName}`; + document.getElementById('apiKeyStatsModal').classList.add('show'); try { - const res = await fetch(`${API_BASE}/entities/${currentEntityIdForAttributes}/attributes/${attributeId}`, { + const res = await fetch(`${API_BASE}/api-keys/${keyId}/stats?days=30`); + if (!res.ok) throw new Error('Failed to fetch stats'); + + const data = await res.json(); + + // Update stats + document.getElementById('statsTotalCalls').textContent = data.summary.total_calls.toLocaleString(); + document.getElementById('statsSuccessCalls').textContent = data.summary.success_calls.toLocaleString(); + document.getElementById('statsErrorCalls').textContent = data.summary.error_calls.toLocaleString(); + document.getElementById('statsAvgTime').textContent = Math.round(data.summary.avg_response_time_ms); + + // Render logs + renderApiKeyLogs(data.logs || []); + } catch (err) { + console.error('Failed to load stats:', err); + document.getElementById('apiKeyLogs').innerHTML = ` +
+

加载统计失败

+
+ `; + } +}; + +// Render API Key Logs +function renderApiKeyLogs(logs) { + const container = document.getElementById('apiKeyLogs'); + + if (logs.length === 0) { + container.innerHTML = ` +
+

暂无调用记录

+
+ `; + return; + } + + container.innerHTML = logs.map(log => ` +
+
${escapeHtml(log.endpoint)}
+
${log.method}
+
${log.status_code}
+
${log.response_time_ms}ms
+
+ `).join(''); +} + +// Hide API Key Stats Modal +window.hideApiKeyStatsModal = function() { + document.getElementById('apiKeyStatsModal').classList.remove('show'); + currentApiKeyId = null; +}; + +// Revoke API Key +window.revokeApiKey = async function(keyId) { + if (!confirm('确定要撤销此 API Key 吗?撤销后将无法恢复。')) { + return; + } + + try { + const res = await fetch(`${API_BASE}/api-keys/${keyId}`, { method: 'DELETE' }); - if (!res.ok) throw new Error('Failed to delete attribute'); + if (!res.ok) throw new Error('Failed to revoke API key'); - await loadEntityAttributes(); + showNotification('API Key 已撤销', 'success'); + await loadApiKeys(); } catch (err) { - console.error('Delete attribute failed:', err); - alert('删除失败'); + console.error('Failed to revoke API key:', err); + alert('撤销失败: ' + err.message); } }; -// Attribute History -window.showAttributeHistory = async function(attributeName) { - if (!currentEntityIdForAttributes) return; - - const modal = document.getElementById('attrHistoryModal'); - modal.classList.add('show'); - - try { - const res = await fetch(`${API_BASE}/entities/${currentEntityIdForAttributes}/attributes/history?attribute_name=${encodeURIComponent(attributeName)}`); - if (!res.ok) throw new Error('Failed to load history'); - - const data = await res.json(); - renderAttributeHistory(data.history, attributeName); - } catch (err) { - console.error('Load history failed:', err); - document.getElementById('attrHistoryContent').innerHTML = '

加载失败

'; - } -}; - -window.hideAttrHistoryModal = function() { - document.getElementById('attrHistoryModal').classList.remove('show'); -}; - -function renderAttributeHistory(history, attributeName) { - const container = document.getElementById('attrHistoryContent'); - - if (history.length === 0) { - container.innerHTML = `

属性 "${attributeName}" 暂无变更历史

`; - return; - } - - container.innerHTML = history.map(h => { - const date = new Date(h.changed_at).toLocaleString(); - return ` -
-
- ${h.changed_by || '系统'} - ${date} -
-
- ${h.old_value || '(无)'} - - ${h.new_value || '(无)'} -
- ${h.change_reason ? `
原因: ${h.change_reason}
` : ''} -
- `; - }).join(''); -} - -// Attribute Templates Management -window.showAttributeTemplates = async function() { - const modal = document.getElementById('attrTemplatesModal'); - modal.classList.add('show'); - - document.getElementById('templateForm').style.display = 'none'; - document.getElementById('toggleTemplateBtn').style.display = 'inline-block'; - document.getElementById('saveTemplateBtn').style.display = 'none'; - - await loadAttributeTemplates(); -}; - -window.hideAttrTemplatesModal = function() { - document.getElementById('attrTemplatesModal').classList.remove('show'); -}; - -async function loadAttributeTemplates() { - if (!currentProject) return; - - try { - const res = await fetch(`${API_BASE}/projects/${currentProject.id}/attribute-templates`); - if (!res.ok) throw new Error('Failed to load templates'); - - currentTemplates = await res.json(); - renderTemplatesList(); - } catch (err) { - console.error('Load templates failed:', err); - document.getElementById('templatesList').innerHTML = '

加载失败

'; - } -} - -function renderTemplatesList() { - const container = document.getElementById('templatesList'); - - if (currentTemplates.length === 0) { - container.innerHTML = '

暂无模板,点击"新建模板"创建

'; - return; - } - - container.innerHTML = currentTemplates.map(t => { - const optionsStr = t.options ? `选项: ${t.options.join(', ')}` : ''; - return ` -
-
-
- ${t.name} - ${t.type} - ${t.is_required ? '*' : ''} -
-
${t.description || ''} ${optionsStr}
-
-
- -
-
- `; - }).join(''); -} - -window.toggleTemplateForm = function() { - const form = document.getElementById('templateForm'); - const toggleBtn = document.getElementById('toggleTemplateBtn'); - const saveBtn = document.getElementById('saveTemplateBtn'); - - if (form.style.display === 'none') { - form.style.display = 'block'; - toggleBtn.style.display = 'none'; - saveBtn.style.display = 'inline-block'; - } else { - form.style.display = 'none'; - toggleBtn.style.display = 'inline-block'; - saveBtn.style.display = 'none'; - } -}; - -window.onTemplateTypeChange = function() { - const type = document.getElementById('templateType').value; - const optionsGroup = document.getElementById('templateOptionsGroup'); - - if (type === 'select' || type === 'multiselect') { - optionsGroup.style.display = 'block'; - } else { - optionsGroup.style.display = 'none'; - } -}; - -window.saveTemplate = async function() { - if (!currentProject) return; - - const name = document.getElementById('templateName').value.trim(); - const type = document.getElementById('templateType').value; - const description = document.getElementById('templateDesc').value.trim(); - const isRequired = document.getElementById('templateRequired').checked; - const defaultValue = document.getElementById('templateDefault').value.trim(); - - if (!name) { - alert('请输入模板名称'); - return; - } - - let options = null; - if (type === 'select' || type === 'multiselect') { - const optionsStr = document.getElementById('templateOptions').value.trim(); - if (optionsStr) { - options = optionsStr.split(',').map(o => o.trim()).filter(o => o); - } - } - - try { - const res = await fetch(`${API_BASE}/projects/${currentProject.id}/attribute-templates`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name, - type, - description, - options, - is_required: isRequired, - default_value: defaultValue || null - }) - }); - - if (!res.ok) throw new Error('Failed to save template'); - - // Reset form - document.getElementById('templateName').value = ''; - document.getElementById('templateDesc').value = ''; - document.getElementById('templateOptions').value = ''; - document.getElementById('templateDefault').value = ''; - document.getElementById('templateRequired').checked = false; - - await loadAttributeTemplates(); - toggleTemplateForm(); - - } catch (err) { - console.error('Save template failed:', err); - alert('保存失败'); - } -}; - -window.deleteTemplate = async function(templateId) { - if (!confirm('确定要删除这个模板吗?')) return; - - try { - const res = await fetch(`${API_BASE}/projects/${currentProject.id}/attribute-templates/${templateId}`, { - method: 'DELETE' - }); - - if (!res.ok) throw new Error('Failed to delete template'); - - await loadAttributeTemplates(); - } catch (err) { - console.error('Delete template failed:', err); - alert('删除失败'); - } -}; - -// Search entities by attributes -window.searchByAttributes = async function() { - if (!currentProject) return; - - const filterName = document.getElementById('attrFilterName').value; - const filterValue = document.getElementById('attrFilterValue').value; - const filterOp = document.getElementById('attrFilterOp').value; - - if (!filterName || !filterValue) { - alert('请输入筛选条件'); - return; - } - - try { - const filters = JSON.stringify([{ name: filterName, value: filterValue, operator: filterOp }]); - const res = await fetch(`${API_BASE}/projects/${currentProject.id}/entities/search-by-attributes?filters=${encodeURIComponent(filters)}`); - - if (!res.ok) throw new Error('Search failed'); - - const entities = await res.json(); - - // Update entity grid - const grid = document.getElementById('kbEntityGrid'); - if (entities.length === 0) { - grid.innerHTML = '

未找到匹配的实体

'; - return; - } - - grid.innerHTML = entities.map(ent => ` -
-
- ${ent.type} - ${ent.name} -
-
${ent.definition || '暂无定义'}
-
- `).join(''); - - } catch (err) { - console.error('Search by attributes failed:', err); - alert('搜索失败'); - } -}; - -// ==================== Export Functions ==================== - -// Show export panel -window.showExportPanel = function() { - const modal = document.getElementById('exportPanelModal'); - if (modal) { - modal.style.display = 'flex'; - - // Show transcript export section if a transcript is selected - const transcriptSection = document.getElementById('transcriptExportSection'); - if (transcriptSection && currentData && currentData.transcript_id !== 'project_view') { - transcriptSection.style.display = 'block'; - } else if (transcriptSection) { - transcriptSection.style.display = 'none'; - } - } -}; - -// Hide export panel -window.hideExportPanel = function() { - const modal = document.getElementById('exportPanelModal'); - if (modal) { - modal.style.display = 'none'; - } -}; - -// Helper function to download file -function downloadFile(url, filename) { - const link = document.createElement('a'); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); -} - -// Export knowledge graph as SVG -window.exportGraph = async function(format) { - if (!currentProject) return; - - try { - const endpoint = format === 'svg' ? 'graph-svg' : 'graph-png'; - const mimeType = format === 'svg' ? 'image/svg+xml' : 'image/png'; - const ext = format === 'svg' ? 'svg' : 'png'; - - const res = await fetch(`${API_BASE}/projects/${currentProject.id}/export/${endpoint}`); - - if (!res.ok) throw new Error(`Export ${format} failed`); - - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - downloadFile(url, `insightflow-graph-${currentProject.id}.${ext}`); - URL.revokeObjectURL(url); - - showNotification(`图谱已导出为 ${format.toUpperCase()}`, 'success'); - } catch (err) { - console.error(`Export ${format} failed:`, err); - alert(`导出失败: ${err.message}`); - } -}; - -// Export entities -window.exportEntities = async function(format) { - if (!currentProject) return; - - try { - const endpoint = format === 'excel' ? 'entities-excel' : 'entities-csv'; - const mimeType = format === 'excel' ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' : 'text/csv'; - const ext = format === 'excel' ? 'xlsx' : 'csv'; - - const res = await fetch(`${API_BASE}/projects/${currentProject.id}/export/${endpoint}`); - - if (!res.ok) throw new Error(`Export ${format} failed`); - - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - downloadFile(url, `insightflow-entities-${currentProject.id}.${ext}`); - URL.revokeObjectURL(url); - - showNotification(`实体数据已导出为 ${format.toUpperCase()}`, 'success'); - } catch (err) { - console.error(`Export ${format} failed:`, err); - alert(`导出失败: ${err.message}`); - } -}; - -// Export relations -window.exportRelations = async function(format) { - if (!currentProject) return; - - try { - const res = await fetch(`${API_BASE}/projects/${currentProject.id}/export/relations-csv`); - - if (!res.ok) throw new Error('Export relations failed'); - - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - downloadFile(url, `insightflow-relations-${currentProject.id}.csv`); - URL.revokeObjectURL(url); - - showNotification('关系数据已导出为 CSV', 'success'); - } catch (err) { - console.error('Export relations failed:', err); - alert(`导出失败: ${err.message}`); - } -}; - -// Export project report as PDF -window.exportReport = async function(format) { - if (!currentProject) return; - - try { - const res = await fetch(`${API_BASE}/projects/${currentProject.id}/export/report-pdf`); - - if (!res.ok) throw new Error('Export PDF failed'); - - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - downloadFile(url, `insightflow-report-${currentProject.id}.pdf`); - URL.revokeObjectURL(url); - - showNotification('项目报告已导出为 PDF', 'success'); - } catch (err) { - console.error('Export PDF failed:', err); - alert(`导出失败: ${err.message}`); - } -}; - -// Export project as JSON -window.exportProject = async function(format) { - if (!currentProject) return; - - try { - const res = await fetch(`${API_BASE}/projects/${currentProject.id}/export/project-json`); - - if (!res.ok) throw new Error('Export JSON failed'); - - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - downloadFile(url, `insightflow-project-${currentProject.id}.json`); - URL.revokeObjectURL(url); - - showNotification('项目数据已导出为 JSON', 'success'); - } catch (err) { - console.error('Export JSON failed:', err); - alert(`导出失败: ${err.message}`); - } -}; - -// Export transcript as Markdown -window.exportTranscript = async function(format) { - if (!currentProject || !currentData || currentData.transcript_id === 'project_view') { - alert('请先选择一个转录文件'); - return; - } - - try { - const res = await fetch(`${API_BASE}/transcripts/${currentData.transcript_id}/export/markdown`); - - if (!res.ok) throw new Error('Export Markdown failed'); - - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - downloadFile(url, `insightflow-transcript-${currentData.transcript_id}.md`); - URL.revokeObjectURL(url); - - showNotification('转录文本已导出为 Markdown', 'success'); - } catch (err) { - console.error('Export Markdown failed:', err); - alert(`导出失败: ${err.message}`); - } -}; - -// Show notification -function showNotification(message, type = 'info') { - // Create notification element - const notification = document.createElement('div'); - notification.style.cssText = ` - position: fixed; - top: 20px; - right: 20px; - background: ${type === 'success' ? 'rgba(0, 212, 255, 0.9)' : '#333'}; - color: ${type === 'success' ? '#000' : '#fff'}; - padding: 12px 20px; - border-radius: 8px; - z-index: 10000; - font-size: 0.9rem; - animation: slideIn 0.3s ease; - `; - notification.textContent = message; - - document.body.appendChild(notification); - - // Remove after 3 seconds - setTimeout(() => { - notification.style.animation = 'slideOut 0.3s ease'; - setTimeout(() => { - document.body.removeChild(notification); - }, 300); - }, 3000); -} - -// Add animation styles -const style = document.createElement('style'); -style.textContent = ` - @keyframes slideIn { - from { transform: translateX(100%); opacity: 0; } - to { transform: translateX(0); opacity: 1; } - } - @keyframes slideOut { - from { transform: translateX(0); opacity: 1; } - to { transform: translateX(100%); opacity: 0; } - } -`; -document.head.appendChild(style); - -// ==================== Graph Analysis Functions ==================== - -// Initialize graph analysis view -function initGraphAnalysis() { - if (!currentProject) return; - - // 填充实体选择器 - populateEntitySelectors(); - - // 加载图统计 - loadGraphStats(); - - // 检查 Neo4j 状态 - checkNeo4jStatus(); -} - -function populateEntitySelectors() { - const selectors = [ - document.getElementById('pathStartEntity'), - document.getElementById('pathEndEntity'), - document.getElementById('neighborEntity') - ]; - - selectors.forEach(selector => { - if (!selector) return; - - const currentValue = selector.value; - selector.innerHTML = ''; - - projectEntities.forEach(ent => { - const option = document.createElement('option'); - option.value = ent.id; - option.textContent = `${ent.name} (${ent.type})`; - selector.appendChild(option); - }); - - selector.value = currentValue; - }); -} - -async function checkNeo4jStatus() { - try { - const res = await fetch(`${API_BASE}/neo4j/status`); - if (res.ok) { - const data = await res.json(); - updateNeo4jStatusUI(data.connected); - } - } catch (err) { - console.error('Check Neo4j status failed:', err); - updateNeo4jStatusUI(false); - } -} - -function updateNeo4jStatusUI(connected) { - // 可以在头部添加状态指示器 - const header = document.querySelector('.graph-analysis-header'); - let statusEl = document.getElementById('neo4jStatus'); - - if (!statusEl) { - statusEl = document.createElement('div'); - statusEl.id = 'neo4jStatus'; - statusEl.className = 'neo4j-status'; - header.appendChild(statusEl); - } - - statusEl.className = `neo4j-status ${connected ? 'connected' : 'disconnected'}`; - statusEl.innerHTML = ` - - Neo4j ${connected ? '已连接' : '未连接'} - `; -} - -async function syncToNeo4j() { - if (!currentProject) return; - - const btn = event.target; - const originalText = btn.textContent; - btn.textContent = '🔄 同步中...'; - btn.disabled = true; - - try { - const res = await fetch(`${API_BASE}/neo4j/sync`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ project_id: currentProject.id }) - }); - - if (!res.ok) throw new Error('Sync failed'); - - const data = await res.json(); - showNotification(`同步成功!${data.nodes_synced} 个节点, ${data.edges_synced} 条边`, 'success'); - - // 刷新统计 - await loadGraphStats(); - checkNeo4jStatus(); - - } catch (err) { - console.error('Sync to Neo4j failed:', err); - showNotification('同步失败,请检查 Neo4j 连接', 'error'); - } finally { - btn.textContent = originalText; - btn.disabled = false; - } -} - -async function loadGraphStats() { - if (!currentProject) return; - - try { - // 加载图统计 - const statsRes = await fetch(`${API_BASE}/projects/${currentProject.id}/graph/stats`); - if (statsRes.ok) { - graphStats = await statsRes.json(); - renderGraphStats(graphStats); - } - - // 加载中心性分析 - const centralityRes = await fetch(`${API_BASE}/projects/${currentProject.id}/graph/centrality`); - if (centralityRes.ok) { - centralityData = await centralityRes.json(); - renderCentrality(centralityData); - } - - // 加载社区发现 - const communitiesRes = await fetch(`${API_BASE}/projects/${currentProject.id}/graph/communities`); - if (communitiesRes.ok) { - communitiesData = await communitiesRes.json(); - renderCommunities(communitiesData); - } - - } catch (err) { - console.error('Load graph stats failed:', err); - } -} - -function renderGraphStats(stats) { - document.getElementById('statNodeCount').textContent = stats.node_count || 0; - document.getElementById('statEdgeCount').textContent = stats.edge_count || 0; - document.getElementById('statDensity').textContent = (stats.density || 0).toFixed(3); - document.getElementById('statComponents').textContent = stats.component_count || 0; -} - -function renderCentrality(data) { - const container = document.getElementById('centralityList'); - - if (!data.centrality || data.centrality.length === 0) { - container.innerHTML = '

暂无中心性数据

'; - return; - } - - // 按度中心性排序 - const sorted = [...data.centrality].sort((a, b) => b.degree - a.degree); - - container.innerHTML = sorted.map((item, index) => { - const rank = index + 1; - const isTop3 = rank <= 3; - const entity = projectEntities.find(e => e.id === item.entity_id); - - return ` -
-
${rank}
-
-
${item.entity_name}
-
${item.entity_type}${entity ? ` · ${entity.definition?.substring(0, 30) || ''}` : ''}
-
-
-
${item.degree}
-
连接数
-
-
- `; - }).join(''); -} - -// Enhanced community visualization with better interactivity -function renderCommunities(data) { - const svg = d3.select('#communitiesSvg'); - svg.selectAll('*').remove(); - - const container = document.getElementById('communitiesList'); - - if (!data.communities || data.communities.length === 0) { - container.innerHTML = '

暂无社区数据

'; - return; - } - - // 渲染社区列表 - container.innerHTML = data.communities.map((community, idx) => { - const nodeNames = community.node_names || []; - const density = community.density ? community.density.toFixed(3) : 'N/A'; - return ` -
-
- 社区 ${idx + 1} - ${community.size} 个节点 -
-
密度: ${density}
-
- ${nodeNames.slice(0, 8).map(name => ` - ${name} - `).join('')} - ${nodeNames.length > 8 ? `+${nodeNames.length - 8}` : ''} -
-
- `; - }).join(''); - - // 渲染社区可视化 - renderCommunitiesViz(data.communities); -} - -// Global variable to track focused community -let focusedCommunityIndex = null; - -// Focus on a specific community -window.focusCommunity = function(communityIndex) { - focusedCommunityIndex = communityIndex; - if (communitiesData && communitiesData.communities) { - renderCommunitiesViz(communitiesData.communities, communityIndex); - } -}; - -// Enhanced community visualization with focus support -function renderCommunitiesViz(communities, focusIndex = null) { - const svg = d3.select('#communitiesSvg'); - const container = svg.node().parentElement; - const width = container.clientWidth; - const height = container.clientHeight || 400; - - svg.attr('width', width).attr('height', height); - - // 颜色方案 - const colors = [ - '#00d4ff', '#7b2cbf', '#ff6b6b', '#4ecdc4', - '#ffe66d', '#a8e6cf', '#ff8b94', '#c7ceea' - ]; - - // 准备节点数据 - let allNodes = []; - let allLinks = []; - - communities.forEach((comm, idx) => { - const isFocused = focusIndex === null || focusIndex === idx; - const isDimmed = focusIndex !== null && focusIndex !== idx; - const opacity = isDimmed ? 0.2 : 1; - - const nodes = (comm.node_names || []).map((name, i) => ({ - id: `${idx}-${i}`, - name: name, - community: idx, - color: colors[idx % colors.length], - opacity: opacity, - isFocused: isFocused - })); - - // Create intra-community links - if (nodes.length > 1) { - for (let i = 0; i < nodes.length; i++) { - for (let j = i + 1; j < nodes.length; j++) { - allLinks.push({ - source: nodes[i].id, - target: nodes[j].id, - community: idx, - opacity: opacity * 0.3 - }); - } - } - } - - allNodes = allNodes.concat(nodes); - }); - - if (allNodes.length === 0) return; - - // Create community centers for force layout - const communityCenters = communities.map((_, idx) => ({ - x: width / 2 + (idx % 3 - 1) * width / 4, - y: height / 2 + Math.floor(idx / 3) * height / 4 - })); - - // 使用力导向布局 - const simulation = d3.forceSimulation(allNodes) - .force('charge', d3.forceManyBody().strength(d => d.isFocused ? -150 : -50)) - .force('collision', d3.forceCollide().radius(d => d.isFocused ? 35 : 25)) - .force('x', d3.forceX(d => communityCenters[d.community]?.x || width / 2).strength(0.1)) - .force('y', d3.forceY(d => communityCenters[d.community]?.y || height / 2).strength(0.1)) - .force('link', d3.forceLink(allLinks).id(d => d.id).distance(60).strength(0.1)); - - // Draw links - const link = svg.selectAll('.community-link') - .data(allLinks) - .enter().append('line') - .attr('class', 'community-link') - .attr('stroke', d => colors[d.community % colors.length]) - .attr('stroke-width', 1) - .attr('stroke-opacity', d => d.opacity); - - // Draw nodes - const node = svg.selectAll('.community-node') - .data(allNodes) - .enter().append('g') - .attr('class', 'community-node') - .style('cursor', 'pointer') - .call(d3.drag() - .on('start', dragstarted) - .on('drag', dragged) - .on('end', dragended)); - - // Node glow for focused community - node.filter(d => d.isFocused) - .append('circle') - .attr('r', 28) - .attr('fill', d => d.color) - .attr('opacity', 0.2) - .attr('filter', 'url(#glow)'); - - // Main node circle - node.append('circle') - .attr('r', d => d.isFocused ? 22 : 18) - .attr('fill', d => d.color) - .attr('stroke', '#fff') - .attr('stroke-width', d => d.isFocused ? 3 : 2) - .attr('opacity', d => d.opacity); - - // Node labels (only for focused community) - node.filter(d => d.isFocused) - .append('text') - .text(d => d.name.length > 6 ? d.name.slice(0, 5) + '...' : d.name) - .attr('text-anchor', 'middle') - .attr('dy', 35) - .attr('fill', '#e0e0e0') - .attr('font-size', '10px') - .attr('font-weight', '500') - .style('pointer-events', 'none'); - - // Community label for first node in each community - node.filter(d => { - const commNodes = allNodes.filter(n => n.community === d.community); - return d.id === commNodes[0]?.id && d.isFocused; - }) - .append('text') - .attr('dy', -30) - .attr('text-anchor', 'middle') - .attr('fill', d => d.color) - .attr('font-size', '11px') - .attr('font-weight', '600') - .text(d => `社区 ${d.community + 1}`); - - simulation.on('tick', () => { - link - .attr('x1', d => d.source.x) - .attr('y1', d => d.source.y) - .attr('x2', d => d.target.x) - .attr('y2', d => d.target.y); - - node.attr('transform', d => `translate(${d.x},${d.y})`); - }); - - function dragstarted(event, d) { - if (!event.active) simulation.alphaTarget(0.3).restart(); - d.fx = d.x; - d.fy = d.y; - } - - function dragged(event, d) { - d.fx = event.x; - d.fy = event.y; - } - - function dragended(event, d) { - if (!event.active) simulation.alphaTarget(0); - d.fx = null; - d.fy = null; - } -} - -window.switchGraphTab = function(tabName) { - // 更新标签状态 - document.querySelectorAll('.graph-analysis-tab').forEach(tab => { - tab.classList.remove('active'); - }); - event.target.classList.add('active'); - - // 切换面板 - document.querySelectorAll('.graph-viz-panel').forEach(panel => { - panel.classList.remove('active'); - }); - - if (tabName === 'centrality') { - document.getElementById('centralityPanel').classList.add('active'); - } else if (tabName === 'communities') { - document.getElementById('communitiesPanel').classList.add('active'); - } -}; - -// Enhanced shortest path with better visualization -async function findShortestPath() { - const startId = document.getElementById('pathStartEntity').value; - const endId = document.getElementById('pathEndEntity').value; - - if (!startId || !endId) { - alert('请选择起点和终点实体'); - return; - } - - if (startId === endId) { - alert('起点和终点不能相同'); - return; - } - - // 切换到路径面板 - document.querySelectorAll('.graph-viz-panel').forEach(panel => { - panel.classList.remove('active'); - }); - document.getElementById('pathPanel').classList.add('active'); - - // 显示加载状态 - document.getElementById('pathViz').innerHTML = ` -
-
- 正在查找最短路径... -
- `; - - try { - const res = await fetch(`${API_BASE}/graph/shortest-path`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - start_entity_id: startId, - end_entity_id: endId - }) - }); - - if (!res.ok) throw new Error('Path finding failed'); - - const data = await res.json(); - currentPathData = data; - renderPath(data); - - } catch (err) { - console.error('Find shortest path failed:', err); - document.getElementById('pathViz').innerHTML = ` -
-
-

路径查找失败

-

请确保数据已同步到 Neo4j

-
- `; - } -} - -// Enhanced path rendering with animation and better styling -function renderPath(data) { - const startEntity = projectEntities.find(e => e.id === data.start_entity_id); - const endEntity = projectEntities.find(e => e.id === data.end_entity_id); - - document.getElementById('pathDescription').textContent = - `${startEntity?.name || '起点'} → ${endEntity?.name || '终点'} (${data.path_length} 步)`; - - // 渲染路径可视化 - const svg = d3.select('#pathSvg'); - svg.selectAll('*').remove(); - - const container = svg.node().parentElement; - const width = container.clientWidth; - const height = container.clientHeight || 300; - - svg.attr('width', width).attr('height', height); - - if (!data.path || data.path.length === 0) { - document.getElementById('pathViz').innerHTML = ` -
-
🔍
-

未找到路径

-
- `; - document.getElementById('pathInfo').innerHTML = ''; - return; - } - - // Add defs for gradients and filters - const defs = svg.append('defs'); - - // Glow filter - const filter = defs.append('filter') - .attr('id', 'pathGlow') - .attr('x', '-50%') - .attr('y', '-50%') - .attr('width', '200%') - .attr('height', '200%'); - - filter.append('feGaussianBlur') - .attr('stdDeviation', '4') - .attr('result', 'coloredBlur'); - - const feMerge = filter.append('feMerge'); - feMerge.append('feMergeNode').attr('in', 'coloredBlur'); - feMerge.append('feMergeNode').attr('in', 'SourceGraphic'); - - // Linear gradient for path - const gradient = defs.append('linearGradient') - .attr('id', 'pathLineGradient') - .attr('gradientUnits', 'userSpaceOnUse'); - - gradient.append('stop').attr('offset', '0%').attr('stop-color', '#00d4ff'); - gradient.append('stop').attr('offset', '100%').attr('stop-color', '#7b2cbf'); - - // 准备节点和边 - use linear layout for clarity - const nodes = data.path.map((nodeId, idx) => ({ - id: nodeId, - name: projectEntities.find(e => e.id === nodeId)?.name || nodeId, - type: projectEntities.find(e => e.id === nodeId)?.type || 'OTHER', - x: (width / (data.path.length + 1)) * (idx + 1), - y: height / 2, - isStart: idx === 0, - isEnd: idx === data.path.length - 1, - isMiddle: idx > 0 && idx < data.path.length - 1 - })); - - const links = []; - for (let i = 0; i < nodes.length - 1; i++) { - links.push({ - source: nodes[i], - target: nodes[i + 1], - index: i - }); - } - - // Color scale - const colorScale = { - 'PROJECT': '#7b2cbf', - 'TECH': '#00d4ff', - 'PERSON': '#ff6b6b', - 'ORG': '#4ecdc4', - 'OTHER': '#666' - }; - - // Draw glow lines first (behind) - svg.selectAll('.path-link-glow') - .data(links) - .enter().append('line') - .attr('class', 'path-link-glow') - .attr('x1', d => d.source.x) - .attr('y1', d => d.source.y) - .attr('x2', d => d.target.x) - .attr('y2', d => d.target.y) - .attr('stroke', '#00d4ff') - .attr('stroke-width', 8) - .attr('stroke-opacity', 0.2) - .attr('filter', 'url(#pathGlow)'); - - // Draw main lines - const linkLines = svg.selectAll('.path-link') - .data(links) - .enter().append('line') - .attr('class', 'path-link') - .attr('x1', d => d.source.x) - .attr('y1', d => d.source.y) - .attr('x2', d => d.target.x) - .attr('y2', d => d.target.y) - .attr('stroke', 'url(#pathLineGradient)') - .attr('stroke-width', 3) - .attr('stroke-linecap', 'round'); - - // Animated dash line - const animLines = svg.selectAll('.path-link-anim') - .data(links) - .enter().append('line') - .attr('class', 'path-link-anim') - .attr('x1', d => d.source.x) - .attr('y1', d => d.source.y) - .attr('x2', d => d.target.x) - .attr('y2', d => d.target.y) - .attr('stroke', '#fff') - .attr('stroke-width', 2) - .attr('stroke-dasharray', '5,5') - .attr('stroke-opacity', 0.6); - - // Animate the dash offset - function animateDash() { - animLines.attr('stroke-dashoffset', function() { - const current = parseFloat(d3.select(this).attr('stroke-dashoffset') || 0); - return current - 0.5; - }); - requestAnimationFrame(animateDash); - } - animateDash(); - - // Draw arrows - links.forEach((link, i) => { - const angle = Math.atan2(link.target.y - link.source.y, link.target.x - link.source.x); - const arrowSize = 10; - const arrowX = link.target.x - 30 * Math.cos(angle); - const arrowY = link.target.y - 30 * Math.sin(angle); - - svg.append('polygon') - .attr('points', `0,-${arrowSize/2} ${arrowSize},0 0,${arrowSize/2}`) - .attr('transform', `translate(${arrowX},${arrowY}) rotate(${angle * 180 / Math.PI})`) - .attr('fill', '#00d4ff'); - }); - - // Draw nodes - const node = svg.selectAll('.path-node') - .data(nodes) - .enter().append('g') - .attr('class', 'path-node') - .attr('transform', d => `translate(${d.x},${d.y})`); - - // Glow for start/end nodes - node.filter(d => d.isStart || d.isEnd) - .append('circle') - .attr('r', 35) - .attr('fill', d => d.isStart ? '#00d4ff' : '#7b2cbf') - .attr('opacity', 0.2) - .attr('filter', 'url(#pathGlow)'); - - // Main node circles - node.append('circle') - .attr('r', d => d.isStart || d.isEnd ? 28 : 22) - .attr('fill', d => { - if (d.isStart) return '#00d4ff'; - if (d.isEnd) return '#7b2cbf'; - return colorScale[d.type] || '#333'; - }) - .attr('stroke', '#fff') - .attr('stroke-width', d => d.isStart || d.isEnd ? 4 : 2); - - // Step numbers for middle nodes - node.filter(d => d.isMiddle) - .append('text') - .attr('dy', 5) - .attr('text-anchor', 'middle') - .attr('fill', '#fff') - .attr('font-size', '12px') - .attr('font-weight', '600') - .text(d => d.index); - - // Node labels - node.append('text') - .text(d => d.name.length > 8 ? d.name.slice(0, 7) + '...' : d.name) - .attr('text-anchor', 'middle') - .attr('dy', d => d.isStart || d.isEnd ? 45 : 38) - .attr('fill', '#e0e0e0') - .attr('font-size', d => d.isStart || d.isEnd ? '13px' : '11px') - .attr('font-weight', d => d.isStart || d.isEnd ? '600' : '400') - .style('pointer-events', 'none'); - - // Start/End labels - node.filter(d => d.isStart) - .append('text') - .attr('dy', -40) - .attr('text-anchor', 'middle') - .attr('fill', '#00d4ff') - .attr('font-size', '11px') - .attr('font-weight', '600') - .text('起点'); - - node.filter(d => d.isEnd) - .append('text') - .attr('dy', -40) - .attr('text-anchor', 'middle') - .attr('fill', '#7b2cbf') - .attr('font-size', '11px') - .attr('font-weight', '600') - .text('终点'); - - // 渲染路径信息 - renderPathInfo(data); -} - -function renderPathInfo(data) { - const container = document.getElementById('pathInfo'); - - // Calculate path statistics - const pathLength = data.path.length; - const steps = pathLength - 1; - - let html = ` -
-
- 路径长度 - ${steps} 步 -
-
- 节点数 - ${pathLength} 个 -
-
- `; - - data.path.forEach((nodeId, idx) => { - const entity = projectEntities.find(e => e.id === nodeId); - const isStart = idx === 0; - const isEnd = idx === data.path.length - 1; - - html += ` -
-
${idx + 1}
-
-
${entity?.name || nodeId}
- ${!isStart ? `
← 通过关系连接
` : ''} -
- ${isStart ? '起点' : ''} - ${isEnd ? '终点' : ''} -
- `; - }); - - container.innerHTML = html; -} - -async function findNeighbors() { - const entityId = document.getElementById('neighborEntity').value; - const depth = parseInt(document.getElementById('neighborDepth').value) || 1; - - if (!entityId) { - alert('请选择实体'); - return; - } - - // 切换到路径面板显示邻居 - document.querySelectorAll('.graph-viz-panel').forEach(panel => { - panel.classList.remove('active'); - }); - document.getElementById('pathPanel').classList.add('active'); - - const entity = projectEntities.find(e => e.id === entityId); - document.getElementById('pathDescription').textContent = - `${entity?.name || '实体'} 的 ${depth} 度邻居`; - - // 显示加载状态 - document.getElementById('pathViz').innerHTML = ` -
-
- 正在查找邻居节点... -
- `; - document.getElementById('pathInfo').innerHTML = ''; - - try { - const res = await fetch(`${API_BASE}/entities/${entityId}/neighbors?depth=${depth}`); - - if (!res.ok) throw new Error('Neighbors query failed'); - - const data = await res.json(); - renderNeighbors(data, entity); - - } catch (err) { - console.error('Find neighbors failed:', err); - document.getElementById('pathViz').innerHTML = ` -
-
-

邻居查询失败

-

请确保数据已同步到 Neo4j

-
- `; - } -} - -// Enhanced neighbors visualization -function renderNeighbors(data, centerEntity) { - const svg = d3.select('#pathSvg'); - svg.selectAll('*').remove(); - - const container = svg.node().parentElement; - const width = container.clientWidth; - const height = container.clientHeight || 300; - - svg.attr('width', width).attr('height', height); - - const neighbors = data.neighbors || []; - - if (neighbors.length === 0) { - document.getElementById('pathViz').innerHTML = ` -
-
🔍
-

未找到邻居节点

-
- `; - return; - } - - // Add glow filter - const defs = svg.append('defs'); - const filter = defs.append('filter') - .attr('id', 'neighborGlow') - .attr('x', '-50%') - .attr('y', '-50%') - .attr('width', '200%') - .attr('height', '200%'); - - filter.append('feGaussianBlur') - .attr('stdDeviation', '3') - .attr('result', 'coloredBlur'); - - const feMerge = filter.append('feMerge'); - feMerge.append('feMergeNode').attr('in', 'coloredBlur'); - feMerge.append('feMergeNode').attr('in', 'SourceGraphic'); - - // 中心节点 - const centerNode = { - id: centerEntity.id, - name: centerEntity.name, - x: width / 2, - y: height / 2, - isCenter: true - }; - - // 邻居节点 - 环形布局 - const radius = Math.min(width, height) / 3; - const neighborNodes = neighbors.map((n, idx) => ({ - id: n.entity_id, - name: n.entity_name, - x: width / 2 + radius * Math.cos((2 * Math.PI * idx) / neighbors.length - Math.PI / 2), - y: height / 2 + radius * Math.sin((2 * Math.PI * idx) / neighbors.length - Math.PI / 2), - relationType: n.relation_type - })); - - const allNodes = [centerNode, ...neighborNodes]; - - // Draw glow lines - neighborNodes.forEach(neighbor => { - svg.append('line') - .attr('x1', centerNode.x) - .attr('y1', centerNode.y) - .attr('x2', neighbor.x) - .attr('y2', neighbor.y) - .attr('stroke', '#00d4ff') - .attr('stroke-width', 6) - .attr('stroke-opacity', 0.1) - .attr('filter', 'url(#neighborGlow)'); - }); - - // Draw main lines - neighborNodes.forEach(neighbor => { - svg.append('line') - .attr('x1', centerNode.x) - .attr('y1', centerNode.y) - .attr('x2', neighbor.x) - .attr('y2', neighbor.y) - .attr('stroke', '#00d4ff') - .attr('stroke-width', 2) - .attr('stroke-opacity', 0.4); - }); - - // Draw nodes - const node = svg.selectAll('.neighbor-node') - .data(allNodes) - .enter().append('g') - .attr('class', 'neighbor-node') - .attr('transform', d => `translate(${d.x},${d.y})`); - - // Glow for center node - node.filter(d => d.isCenter) - .append('circle') - .attr('r', 40) - .attr('fill', '#00d4ff') - .attr('opacity', 0.2) - .attr('filter', 'url(#neighborGlow)'); - - // Main node circles - node.append('circle') - .attr('r', d => d.isCenter ? 35 : 25) - .attr('fill', d => d.isCenter ? '#00d4ff' : '#333') - .attr('stroke', '#fff') - .attr('stroke-width', d => d.isCenter ? 4 : 2); - - // Node labels - node.append('text') - .text(d => d.name.length > 6 ? d.name.slice(0, 5) + '...' : d.name) - .attr('text-anchor', 'middle') - .attr('dy', d => d.isCenter ? 50 : 38) - .attr('fill', '#e0e0e0') - .attr('font-size', d => d.isCenter ? '13px' : '11px') - .attr('font-weight', d => d.isCenter ? '600' : '400') - .style('pointer-events', 'none'); - - // Center label - node.filter(d => d.isCenter) - .append('text') - .attr('dy', -45) - .attr('text-anchor', 'middle') - .attr('fill', '#00d4ff') - .attr('font-size', '11px') - .attr('font-weight', '600') - .text('中心'); - - // 渲染邻居信息 - let html = ` -
-
- 邻居节点数 - ${neighbors.length} 个 -
-
- `; - - neighbors.forEach((n, idx) => { - html += ` -
-
${idx + 1}
-
-
${n.entity_name}
-
关系: ${n.relation_type}
-
-
- `; - }); - document.getElementById('pathInfo').innerHTML = html; +// Escape HTML helper +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; } // Show notification helper @@ -2981,21 +462,13 @@ function showNotification(message, type = 'info') { }, 3000); } -// Reset graph visualization -window.resetGraphViz = function() { - const svg = d3.select('#graphAnalysisSvg'); - svg.selectAll('*').remove(); - document.getElementById('graphAnalysisResults').classList.remove('show'); - focusedCommunityIndex = null; - if (communitiesData) { - renderCommunities(communitiesData); - } -}; - -// Highlight entity in graph -function highlightEntityInGraph(entityId) { - // This would highlight the entity in the main graph view - // For now, just switch to workbench and select the entity - switchView('workbench'); - setTimeout(() => selectEntity(entityId), 100); -} +// Placeholder functions for other views +function initUpload() {} +function initAgentPanel() {} +function initEntityCard() {} +function renderTranscript() {} +function renderGraph() {} +function renderEntityList() {} +function loadKnowledgeBase() {} +function loadTimeline() {} +function initGraphAnalysis() {} diff --git a/frontend/workbench.html b/frontend/workbench.html index c63f47f..8aa0b33 100644 --- a/frontend/workbench.html +++ b/frontend/workbench.html @@ -1925,6 +1925,344 @@ border-radius: 50%; background: currentColor; } + + /* Phase 6: API Key Management Panel */ + .api-keys-panel { + display: none; + flex-direction: column; + width: 100%; + height: 100%; + background: #0a0a0a; + } + + .api-keys-panel.active { + display: flex; + } + + .api-keys-header { + padding: 16px 20px; + background: #141414; + border-bottom: 1px solid #222; + display: flex; + justify-content: space-between; + align-items: center; + } + + .api-keys-header h2 { + font-size: 1.3rem; + margin-bottom: 4px; + } + + .api-keys-content { + flex: 1; + padding: 24px; + overflow-y: auto; + } + + .api-keys-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 24px; + } + + .api-key-stat-card { + background: #141414; + border: 1px solid #222; + border-radius: 8px; + padding: 16px; + text-align: center; + } + + .api-key-stat-value { + font-size: 1.5rem; + font-weight: 600; + color: #00d4ff; + } + + .api-key-stat-label { + font-size: 0.75rem; + color: #666; + margin-top: 4px; + } + + .api-keys-list { + background: #141414; + border: 1px solid #222; + border-radius: 12px; + overflow: hidden; + } + + .api-keys-list-header { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr 1fr 120px; + padding: 12px 16px; + background: #1a1a1a; + border-bottom: 1px solid #222; + font-size: 0.85rem; + color: #888; + font-weight: 500; + } + + .api-key-item { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr 1fr 120px; + padding: 16px; + border-bottom: 1px solid #222; + align-items: center; + transition: background 0.2s; + } + + .api-key-item:hover { + background: #1a1a1a; + } + + .api-key-item:last-child { + border-bottom: none; + } + + .api-key-name { + font-weight: 500; + color: #e0e0e0; + } + + .api-key-preview { + font-family: monospace; + font-size: 0.85rem; + color: #00d4ff; + background: #00d4ff11; + padding: 4px 8px; + border-radius: 4px; + } + + .api-key-permissions { + display: flex; + gap: 4px; + flex-wrap: wrap; + } + + .api-key-permission { + font-size: 0.7rem; + padding: 2px 6px; + border-radius: 4px; + background: #333; + color: #888; + } + + .api-key-permission.read { + background: #00d4ff22; + color: #00d4ff; + } + + .api-key-permission.write { + background: #7b2cbf22; + color: #7b2cbf; + } + + .api-key-permission.delete { + background: #ff6b6b22; + color: #ff6b6b; + } + + .api-key-status { + font-size: 0.8rem; + padding: 4px 10px; + border-radius: 20px; + display: inline-block; + } + + .api-key-status.active { + background: #00d4ff22; + color: #00d4ff; + } + + .api-key-status.revoked { + background: #ff6b6b22; + color: #ff6b6b; + } + + .api-key-status.expired { + background: #66666622; + color: #666; + } + + .api-key-actions { + display: flex; + gap: 8px; + } + + .api-key-btn { + background: transparent; + border: 1px solid #333; + color: #888; + padding: 6px 12px; + border-radius: 6px; + cursor: pointer; + font-size: 0.8rem; + transition: all 0.2s; + } + + .api-key-btn:hover { + border-color: #00d4ff; + color: #00d4ff; + } + + .api-key-btn.danger:hover { + border-color: #ff6b6b; + color: #ff6b6b; + } + + .api-key-empty { + text-align: center; + padding: 60px 20px; + color: #666; + } + + .api-key-modal-form { + display: flex; + flex-direction: column; + gap: 16px; + } + + .api-key-form-group { + display: flex; + flex-direction: column; + gap: 8px; + } + + .api-key-form-group label { + font-size: 0.9rem; + color: #888; + } + + .api-key-form-group input, + .api-key-form-group select { + background: #0a0a0a; + border: 1px solid #333; + border-radius: 6px; + padding: 10px 12px; + color: #e0e0e0; + font-size: 0.95rem; + } + + .api-key-form-group input:focus, + .api-key-form-group select:focus { + outline: none; + border-color: #00d4ff; + } + + .api-key-permissions-select { + display: flex; + gap: 12px; + } + + .api-key-permission-checkbox { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + } + + .api-key-permission-checkbox input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: #00d4ff; + } + + .api-key-created-modal .api-key-value { + background: #0a0a0a; + border: 1px solid #00d4ff; + border-radius: 8px; + padding: 16px; + font-family: monospace; + font-size: 1rem; + color: #00d4ff; + margin: 16px 0; + word-break: break-all; + } + + .api-key-created-modal .warning { + background: #ff6b6b22; + border: 1px solid #ff6b6b; + border-radius: 8px; + padding: 12px; + color: #ff6b6b; + font-size: 0.85rem; + margin-bottom: 16px; + } + + .api-key-stats-modal .stats-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + margin-bottom: 24px; + } + + .api-key-stats-modal .stat-item { + background: #0a0a0a; + border-radius: 8px; + padding: 16px; + text-align: center; + } + + .api-key-stats-modal .stat-value { + font-size: 1.5rem; + font-weight: 600; + color: #00d4ff; + } + + .api-key-stats-modal .stat-label { + font-size: 0.8rem; + color: #666; + margin-top: 4px; + } + + .api-key-logs { + max-height: 300px; + overflow-y: auto; + } + + .api-key-log-item { + display: grid; + grid-template-columns: 1fr 80px 60px 80px; + gap: 12px; + padding: 12px; + border-bottom: 1px solid #222; + font-size: 0.85rem; + } + + .api-key-log-item:last-child { + border-bottom: none; + } + + .api-key-log-endpoint { + color: #e0e0e0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .api-key-log-method { + color: #00d4ff; + font-family: monospace; + } + + .api-key-log-status { + text-align: center; + } + + .api-key-log-status.success { + color: #4ecdc4; + } + + .api-key-log-status.error { + color: #ff6b6b; + } + + .api-key-log-time { + color: #666; + text-align: right; + } @@ -1946,6 +2284,7 @@ +
@@ -2324,6 +2663,54 @@
+ +
+
+
+

🔑 API Key 管理

+

管理 API 访问密钥和调用统计

+
+ +
+ +
+
+
+
-
+
总 API Keys
+
+
+
-
+
活跃
+
+
+
-
+
已撤销
+
+
+
-
+
总调用次数
+
+
+ +
+
+ 名称 / Key + 权限 + 限流 + 状态 + 调用次数 + 操作 +
+
+
+

加载中...

+
+
+
+
+
+
@@ -2663,6 +3050,122 @@
+ + + + + + + + +
✏️ 编辑实体