diff --git a/.env.example b/.env.example index a0015673..ef054b8f 100644 --- a/.env.example +++ b/.env.example @@ -66,11 +66,6 @@ # SONA_WEIBO_AISEARCH_REFER=weibo_aisearch # SONA_REFERENCE_FETCH_TIMEOUT_SEC=12 # SONA_WEIBO_AISEARCH_PLAYWRIGHT_FALLBACK=true -# 是否输出 structured(叙事分槽 + report_bridge,供 report_html 与 wiki RAG 对齐模板) -# SONA_WEIBO_AISEARCH_STRUCTURE=true -# 每槽最多条数、写入 structured 的单条最大字符 -# SONA_WEIBO_AISEARCH_SLOT_EACH=8 -# SONA_WEIBO_AISEARCH_SNIPPET_IN_STRUCTURE=240 # ----------------------------------------------------------------------------- # 五、/wiki 知识库与 RAG(workflow/wiki_cli、workflow/wiki_rag) @@ -91,9 +86,6 @@ # RAG 合成温度、注入微博文本最大字符数 # SONA_WIKI_LLM_TEMPERATURE=0.25 # SONA_WIKI_WEIBO_MAX_CHARS=3500 -# 是否在 wiki 智搜附录中附带「叙事分槽」索引块(与 HTML 模板占位符对齐) -# SONA_WIKI_WEIBO_STRUCTURE_IN_AUX=true -# SONA_WIKI_WEIBO_STRUCTURE_MAX_CHARS=1600 # 半自动回流:高价值回答自动写入 wiki/output/_candidates(不自动入正式 output) # SONA_WIKI_OUTPUT_CANDIDATE_ENABLED=true @@ -120,27 +112,64 @@ # SONA_DEBUG_LOG_PATH= # Graph RAG / Neo4j(可选) -# SONA_NEO4J_URI=bolt://127.0.0.1:7687 +# SONA_NEO4J_URI=neo4j://127.0.0.1:7687 # SONA_NEO4J_USER=neo4j # SONA_NEO4J_PASSWORD= # SONA_NEO4J_DATABASE= # SONA_ENABLE_GRAPH_RAG=auto +# Wiki 熊猫/大熊猫图谱问答(可选;与上方 Graph RAG 的 SONA_NEO4J_* 独立) +# SONA_WIKI_NEO4J_PRIORITY=1 +# NEO4J_URI=bolt://127.0.0.1:7687 +# NEO4J_USERNAME=neo4j +# NEO4J_PASSWORD= +# NEO4J_DATABASE=neo4j + +# 专题监测本地 SQLite(默认启用;设 0 时使用内存 demo/test store) +# SONA_TOPIC_MONITOR_SQLITE=1 + # ----------------------------------------------------------------------------- # 八、热点 / 多引擎(tools/hottopics、utils/hot_topics_env 等,按需) # ----------------------------------------------------------------------------- +# 热点事件簇领域标注:默认允许使用 LLM 批量标注;设 0 则只用关键词规则 +# HOT_DOMAIN_USE_LLM=1 + # INSIGHT_ENGINE_API_KEY= # INSIGHT_ENGINE_BASE_URL= # INSIGHT_ENGINE_MODEL_NAME= +# 热点流程通义默认:https://dashscope.aliyuncs.com/compatible-mode/v1(与 model.yaml 一致) +# 若 .env 中仍有旧版 baseurl=https://coding.dashscope.aliyuncs.com/v1 会被自动改写,建议删除该行 + +# 情感分析默认不复用 CSV 情感列;设 1 才允许列兜底/质量保护回退 +# SONA_SENTIMENT_ALLOW_COLUMN_FALLBACK=0 # QUERY_ENGINE_API_KEY= # QUERY_ENGINE_BASE_URL= # QUERY_ENGINE_MODEL_NAME= +# 热点流程默认将 QUERY_* 与 INSIGHT_* 对齐;勿单独留错误的 QUERY key(否则 Forum 401 而 Insight 正常)。 +# 若需论坛与舆情分析使用不同模型,可设 HOT_TOPICS_QUERY_SEPARATE=1 并自行配置 QUERY_*。 +# 热点「事件聚类」领域:默认用 INSIGHT_ENGINE_* 对簇代表标题批量调用 qwen-plus 分领域;仅关键词规则可设 HOT_DOMAIN_USE_LLM=0 +# HOT_DOMAIN_USE_LLM=0 +# 聚类簇数量上限(25–100),减轻打满后「其他」大兜底簇:HOT_CLUSTER_MAX=55 +# HOT_CLUSTER_MAX=50 # KIMI_BASE_URL= # KIMI_MODEL_NAME= +# ----------------------------------------------------------------------------- +# 八点五、情感分析(事件分析 Step7,默认仅 LLM,不用 CSV 情感列兜底) +# ----------------------------------------------------------------------------- + +# 流水线调用 analysis_sentiment 的超时(秒),默认 600 +# SONA_SENTIMENT_TIMEOUT_SEC=600 +# 工具内 LLM 批处理总墙钟(秒),默认 600(应 ≤ 流水线超时) +# SONA_SENTIMENT_MAX_WALLTIME_SEC=600 +# 默认 1:强制走大模型;设为 0 且下方 ALLOW_COLUMN_FALLBACK=1 时才可回退 CSV 列 +# SONA_SENTIMENT_FORCE_LLM=1 +# 设为 1 才允许复用 CSV「情感」列或在 LLM 失败/质量保护时回退列统计(默认 0) +# SONA_SENTIMENT_ALLOW_COLUMN_FALLBACK=0 + # ----------------------------------------------------------------------------- # 九、代理(数据采集走代理时) # ----------------------------------------------------------------------------- diff --git a/.gitignore b/.gitignore index 2999857e..d4bf90fb 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ share/python-wheels/ .installed.cfg *.egg MANIFEST +# 专题监测本地库 +data/topic_monitor_local.db + .pytest_cache/ .mypy_cache/ .ruff_cache/ @@ -76,6 +79,14 @@ logs/ *.log *.log.* +# Frontend / Node +node_modules/ +.next/ +out/ +frontend/node_modules/ +frontend/.next/ +frontend/out/ + # Eval artifacts eval_results/ @@ -89,6 +100,10 @@ sanbox/ # Memory - 会话记忆 memory/ +# Local SQLite app data +data/*.db +data/*.db-* + # 热点流程产物(独立流程) data_langgraph/ data_langgraph_hourly/ @@ -110,10 +125,11 @@ Desktop.ini *.bak *.swp *~ +cache/ # 本地课程作业与知识库压缩包(不纳入版本库) 期末报告-智能体修改-作业/ opinion_analysis_kb.zip # 舆情知识库目录(体积大、本地维护;已从 Git 索引移除,不再推送到远端) -opinion_analysis_kb/ \ No newline at end of file +opinion_analysis_kb/ diff --git a/README.md b/README.md index c7db4fd6..753160f5 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ - **🧠 垂类知识库增强**:支持控烟、健康、交通、大熊猫等领域包,并通过 `workflow/domain_routing.json` 自动注入优先证据 - **🕸️ Graph RAG 可选增强**:通过 Neo4j Aura/本地 Neo4j 召回相似案例、理论框架和处置经验;连接失败时自动降级 - **📚 案例库与专题监测**:完整报告可自动沉淀到 `opinion_analysis_kb/references/wiki/cases/`,并提供 `/case` 相似案例检索与 `/monitor` 专题快照/日报周报演示 -- **🧩 HTTP API 与轻量 GUI**:`sona serve` 提供 FastAPI 接口,`streamlit_app.py` 提供多页 Streamlit 查看器 +- **🧩 HTTP API 与轻量 GUI**:`sona serve` 提供 FastAPI;`frontend/` 提供 Next.js 分析工作台;`streamlit_app.py` 保留多页控制台 - **📊 报告质量增强**:时间线证据/影响标签、情绪结构、四阶段行动清单、热点风险分级与案例候选输出 ### 支持的模型提供商 @@ -366,7 +366,7 @@ sona serve --host 127.0.0.1 --port 8765 - `/hot` - 运行独立的热点抓取与态势感知流程(可选参数:配置路径) - `/case` - 检索本地案例库,输出相似案例列表与横向对照 - `/monitor` - 运行专题监测命令,支持创建专题、查看状态、生成日报/周报;未配置外部库时可运行内存演示 -- `/wiki` - 基于本地知识库(`opinion_analysis_kb/references/wiki/`)的问答,输出摘要与引用来源 +- `/wiki` - 基于本地知识库(`opinion_analysis_kb/references/wiki/`)的问答,输出摘要与引用来源;可通过环境变量 `SONA_WIKI_AUTO_COMPILE`、`SONA_WIKI_AUTO_COMPILE_ON_QUERY` 控制是否在写入专家研判或每次检索前自动把 `expert_notes/**/*.md` 增量编译进 `wiki/sources`(默认开启;`tests/conftest.py` 对 CI 关闭「检索前编译」) - `/wiki-approve` - 审核并回流高价值候选 - `/clear` - 清除 memory 和 sandbox - `/exit` - 退出程序 @@ -382,18 +382,80 @@ sona serve --host 127.0.0.1 --port 8765 # 探活 curl http://127.0.0.1:8765/health -# 终端 2:启动 Streamlit 多页 GUI(需安装 streamlit) +# 终端 2A:启动 Next.js 前端(推荐) +npm --prefix frontend install +npm --prefix frontend run dev + +# 终端 2B:或启动 Streamlit 多页 GUI(保留入口) streamlit run streamlit_app.py ``` +Next.js 前端访问地址:`http://127.0.0.1:3000`。 + +注意:`server.py` / `api.server:app` 不会由前端自动启动;必须先运行 `sona serve`。Next.js 只通过 BFF 代理访问后端 API。若后端端口不是 `8765`,在 `frontend/.env.local` 设置: + +```env +SONA_API_BASE=http://127.0.0.1:8765 +``` + +#### Next.js 分析工作台是什么 + +`frontend/` 是 React + Next.js App Router 前端,页面只访问 `/api/sona/...`,由 Next.js Route Handler 代理到 `sona serve` 的 FastAPI。它支持: + +| 能力 | 对应 API | +|------|----------| +| 会话创建 / 恢复 | `POST /v1/chat/sessions`、`GET /v1/chat/sessions` | +| 普通对话流式输出 | `POST /v1/chat/sessions/{task_id}/messages:stream` | +| `/event` 事件分析 | `POST /v1/analyze-event` | +| `/wiki` / `/wiki-approve` | `POST /v1/wiki/query`、`POST /v1/wiki/approve` | +| `/case` 案例检索 | `POST /v1/cases/search` | +| `/hot` 热点态势 | `POST /v1/hot/run` | +| `/monitor` 专题监测 | `GET/POST /v1/monitor/...` | +| `/models` / `/tools` | `GET /v1/models`、`GET /v1/tools` | + +`/set`、`/compress`、`/clear`、`/exit` 属于 CLI 管理或生命周期指令,前端 v1 不在聊天输入中执行。 + +#### 分析员控制台(Streamlit)是什么 + +多页 Streamlit **不是**「第二个舆情监测大屏」,而是给分析员用的 **控制台**: + +| 区域 | 作用 | +|------|------| +| **仪表盘**(`streamlit_app.py`) | 探活 `sona serve`、展示本 API 进程内最近任务、工作流速查与环境变量说明 | +| **新建 / 报告 / 任务** | 走 `POST /v1/analyze-event` 与任务轮询(重操作,可能长时间同步) | +| **案例 / 专题** | 演示案例检索、编辑 `config/topics.yaml` | +| **经典会话** | 与 CLI 一致的对话界面,支持 `/hot`、`/wiki`、事件路由等 | + +UI 采用与 BettaFish「微舆」类似的 **高对比、硬边框** 风格;空状态与 API 离线有统一提示块。 + +**仅跑控制台时的环境变量(常用)** + +| 变量 | 说明 | +|------|------| +| `API_BASE` | Streamlit 访问的 API 根地址,默认 `http://127.0.0.1:8765` | +| `SONA_API_BASE` | Next.js BFF 访问的 FastAPI 根地址,默认 `http://127.0.0.1:8765` | +| `SONA_API_CORS_ORIGINS` | API 的 CORS 白名单(见 `docs/api_design.md`) | +| `.env` 内模型与采集 Key | 与 CLI 相同;缺省则事件分析或采集会失败 | + +知识库目录 `opinion_analysis_kb/` 若被 `.gitignore` 排除,克隆仓库后需按团队约定自行放入或从网盘恢复。 + 主要 API: - `GET /health`:服务探活 +- `POST /v1/chat/sessions`:创建前端会话 +- `POST /v1/chat/sessions/{task_id}/messages:stream`:流式对话与自动路由 - `POST /v1/analyze-event`:同步执行一次事件分析,返回 `task_id` 与报告路径 +- `POST /v1/wiki/query`、`POST /v1/cases/search`:知识库问答与案例检索 +- `POST /v1/hot/run`、`GET/POST /v1/monitor/...`:热点与专题监测 - `GET /v1/tasks`:查看当前 API 进程内存中的任务 - `GET /v1/tasks/{task_id}/report`:返回 HTML 报告 -更多约定见 `docs/api_design.md` 和 `docs/gui_decision.md`。 +更多约定见 `docs/api_design.md`、`docs/gui_decision.md` 和 `docs/frontend_next.md`。 + +**控制台 / 新建任务常见问题** + +- **`WORKFLOW_ERROR` / `data_collect` / 微博 / `BrowserType.launch: Executable doesn't exist`**:未安装 Playwright 浏览器。在项目根执行 **`playwright install chromium`**(或 `playwright install`),见上文「数据采集」一节。另需配置 **NetInsight** 账号(`NETINSIGHT_USER` / `NETINSIGHT_PASS`),否则会出现「登录失败」。 +- **`ModuleNotFoundError: utils.hot_time_parser`**:请拉取包含 `utils/hot_time_parser.py` 的版本;经典会话页已增加项目根 `sys.path` 兜底。 **`/hot` 热点流程说明**: - 从公网聚合接口拉取各平台热搜(需本机可访问外网),再在本地用 **OpenAI 兼容 API** 做归纳与报告。 diff --git a/agent/reactagent.py b/agent/reactagent.py index 0163ba95..baf2fbf2 100644 --- a/agent/reactagent.py +++ b/agent/reactagent.py @@ -233,32 +233,105 @@ def _stream_mode_flow( existing_data_path = opts.get("existing_data_path") skip_data_collect = bool(opts.get("skip_data_collect", False)) force_fresh_start = opts.get("force_fresh_start") - yield {"type": "tool_call", "tool_name": "full_report_mode_node", "args": {"query": user_input}, "run_id": f"mode_full_{task_id or 'na'}"} - report_length = str(opts.get("report_length") or "").strip() or None - file_url_or_path = run_full_report_mode( - user_query=user_input, - task_id=task_id or "", - session_manager=session_manager, - debug=True, - existing_data_path=existing_data_path, - skip_data_collect=skip_data_collect, - force_fresh_start=force_fresh_start, - report_length=report_length, - ) + progress_queue: queue.Queue = queue.Queue() + result_holder: Dict[str, Any] = {"value": None, "error": None} + + def on_workflow_progress(event: Dict[str, Any]) -> Any: + hook = opts.get("_web_progress_hook") + hook_result = None + if callable(hook): + hook_result = hook(event) + progress_queue.put(("step", dict(event))) + return hook_result + + def run_pipeline() -> None: + try: + report_length = str(opts.get("report_length") or "").strip() or None + result_holder["value"] = run_full_report_mode( + user_query=user_input, + task_id=task_id or "", + session_manager=session_manager, + debug=True, + existing_data_path=existing_data_path, + skip_data_collect=skip_data_collect, + force_fresh_start=force_fresh_start, + report_length=report_length, + progress_callback=on_workflow_progress, + skip_session_user_message=bool(opts.get("_skip_session_user_message")), + ) + except Exception as exc: # noqa: BLE001 + result_holder["error"] = exc + finally: + progress_queue.put(("done", None)) + + yield { + "type": "tool_call", + "tool_name": "full_report_mode_node", + "args": {"query": user_input}, + "run_id": f"mode_full_{task_id or 'na'}", + } + worker = threading.Thread(target=run_pipeline, daemon=True) + worker.start() + while True: + try: + kind, payload = progress_queue.get(timeout=0.3) + except queue.Empty: + if not worker.is_alive(): + break + continue + if kind == "step": + yield {"type": "workflow_step", **payload} + elif kind == "done": + break + worker.join() + if result_holder["error"] is not None: + raise result_holder["error"] + file_url_or_path = result_holder["value"] + result_text = str(file_url_or_path or "").strip() + if result_text.startswith("已完成舆情事件分析工作流。报告:") or result_text.startswith("这次在 "): + final_text = result_text + else: + final_text = f"已完成舆情事件分析工作流。报告:{result_text}" yield { "type": "tool_result", "tool_name": "full_report_mode_node", - "result": str(file_url_or_path or ""), + "result": result_text, "run_id": f"mode_full_{task_id or 'na'}", } - final_text = ( - "完整舆情报告流程已完成。\n" - f"- 报告地址:{str(file_url_or_path or '未返回')}\n" - "- 已复用事件分析工作流节点(采集/分析/报告生成)。" - ) - yield {"type": "message", "message": AIMessage(content=final_text), "message_id": f"mode_full_msg_{task_id or 'na'}"} + yield { + "type": "message", + "message": AIMessage(content=final_text), + "message_id": f"mode_full_msg_{task_id or 'na'}", + "persist": False, + } return + +def _extract_reasoning_content(chunk: Any) -> str: + """Best-effort extraction for OpenAI-compatible reasoning/thinking chunks.""" + candidates = [] + for attr in ("additional_kwargs", "response_metadata"): + value = getattr(chunk, attr, None) + if isinstance(value, dict): + candidates.append(value) + if isinstance(chunk, dict): + candidates.append(chunk) + + keys = ("reasoning_content", "reasoning", "thinking_content", "thinking") + for data in candidates: + for key in keys: + value = data.get(key) + if isinstance(value, str) and value: + return value + delta = data.get("delta") + if isinstance(delta, dict): + for key in keys: + value = delta.get(key) + if isinstance(value, str) and value: + return value + return "" + + # 创建带消息历史的 Agent # 注意:此函数目前未使用,保留作为预留功能,用于未来可能需要直接使用带历史管理的 Agent 的场景 def _create_agent_with_history(): @@ -433,6 +506,7 @@ async def _stream_events(): current_message_id = None current_content = "" + current_reasoning = "" # 追踪当前正在执行的工具 run_id,用于过滤工具内部 LLM 的流式输出 active_tool_run_ids: set[str] = set() @@ -457,6 +531,16 @@ async def _stream_events(): if active_tool_run_ids: continue chunk = data.get("chunk") + if chunk: + reasoning_delta = _extract_reasoning_content(chunk) + if reasoning_delta: + current_reasoning += reasoning_delta + result_queue.put({ + "type": "thinking", + "content": reasoning_delta, + "message_id": event.get("run_id", ""), + "accumulated": current_reasoning + }) if chunk and hasattr(chunk, "content") and chunk.content: if current_message_id is None: current_message_id = event.get("run_id", "") @@ -483,6 +567,7 @@ async def _stream_events(): }) current_message_id = None current_content = "" + current_reasoning = "" # 工具调用 elif event_type == "on_tool_start": diff --git a/api/agent_run_store.py b/api/agent_run_store.py new file mode 100644 index 00000000..1e388eed --- /dev/null +++ b/api/agent_run_store.py @@ -0,0 +1,110 @@ +"""In-memory Agent run registry for the frontend run/pause/resume API.""" + +from __future__ import annotations + +import threading +import uuid +from datetime import datetime +from typing import Any, Dict, List, Optional + +from api.schema import AgentEventType, AgentRunEnvelope, AgentRunEvent, AgentRunStatus + + +class AgentRunRecord: + """Mutable server-side state for one Agent run.""" + + def __init__(self, *, session_id: str, query: str, options: Dict[str, Any]) -> None: + self.run_id = str(uuid.uuid4()) + self.session_id = session_id + self.turn_id = str(uuid.uuid4()) + self.query = query + self.options = dict(options) + self.status: str = AgentRunStatus.QUEUED + self.events: List[AgentRunEvent] = [] + self.started = False + self.finished = False + self.approval_condition = threading.Condition() + self.pending_approval_id = "" + self.approval_decision: Optional[Dict[str, Any]] = None + + @property + def task_id(self) -> str: + """Legacy alias while route variables migrate to session_id.""" + return self.session_id + + def envelope(self) -> AgentRunEnvelope: + try: + from utils.session_repository import get_session_repository + + get_session_repository().upsert_agent_run(self) + except Exception: + pass + return AgentRunEnvelope( + run_id=self.run_id, + session_id=self.session_id, + turn_id=self.turn_id, + status=self.status, + query=self.query, + events=list(self.events), + ) + + def add_event( + self, + event_type: AgentEventType | str, + *, + status: str = "", + title: str = "", + detail: str = "", + payload: Optional[Dict[str, Any]] = None, + ) -> AgentRunEvent: + event = AgentRunEvent( + event_id=str(uuid.uuid4()), + run_id=self.run_id, + session_id=self.session_id, + turn_id=self.turn_id, + event_type=str(event_type), + status=status, + title=title, + detail=detail, + payload=payload or {}, + created_at=datetime.now().isoformat(), + ) + self.events.append(event) + try: + from utils.session_repository import get_session_repository + + repository = get_session_repository() + repository.upsert_agent_run(self) + repository.append_agent_event(self.session_id, event.model_dump()) + except Exception: + pass + return event + + +class AgentRunStore: + def __init__(self) -> None: + self._lock = threading.Lock() + self._runs: Dict[str, AgentRunRecord] = {} + + def create(self, *, session_id: str, query: str, options: Dict[str, Any]) -> AgentRunRecord: + record = AgentRunRecord(session_id=session_id, query=query, options=options) + with self._lock: + self._runs[record.run_id] = record + try: + from utils.session_repository import get_session_repository + + get_session_repository().upsert_agent_run(record) + except Exception: + pass + return record + + def get(self, run_id: str) -> Optional[AgentRunRecord]: + with self._lock: + return self._runs.get(run_id) + + +_store = AgentRunStore() + + +def get_agent_run_store() -> AgentRunStore: + return _store diff --git a/api/event_runner.py b/api/event_runner.py index 5269152f..98116540 100644 --- a/api/event_runner.py +++ b/api/event_runner.py @@ -3,13 +3,17 @@ from __future__ import annotations import asyncio +import logging import os import sys +import traceback +from pathlib import Path from cli.event_analysis_workflow import run_event_analysis_workflow from cli.router import route_query from utils.path import ensure_task_dirs, get_task_dir from utils.session_manager import get_session_manager +from workflow.telemetry import append_ndjson_log from api.report_utils import extract_report_html_path from api.schema import ( @@ -21,6 +25,10 @@ TaskStatus, ) +_ROOT = Path(__file__).resolve().parents[1] +LOG_PATH = os.getenv("SONA_DEBUG_LOG_PATH", str(_ROOT / ".cursor" / "debug.log")) +LOGGER = logging.getLogger(__name__) + def run_analyze_event(body: AnalyzeEventRequest) -> TaskEnvelope: """ @@ -65,23 +73,48 @@ def run_analyze_event(body: AnalyzeEventRequest) -> TaskEnvelope: skip_data_collect=skip_data_collect, ) except Exception as exc: # noqa: BLE001 — surface to API client + error_message = str(exc) + LOGGER.exception("analyze-event workflow failed task_id=%s: %s", task_id, error_message) + try: + Path(LOG_PATH).parent.mkdir(parents=True, exist_ok=True) + except Exception: + pass + append_ndjson_log( + log_path=LOG_PATH, + run_id=task_id, + hypothesis_id="API_EVENT_WORKFLOW_FAILED", + location="api/event_runner.py:run_analyze_event", + message="POST /v1/analyze-event 工作流失败", + data={ + "task_id": task_id, + "query": body.query, + "error_type": type(exc).__name__, + "error_message": error_message, + "traceback": traceback.format_exc(), + }, + ) return TaskEnvelope( task_id=task_id, + session_id=task_id, status=TaskStatus.FAILED, - artifacts=TaskArtifacts(sandbox_dir=str(get_task_dir(task_id))), - error=ApiError(error_code=ERROR_WORKFLOW, error_message=str(exc)), + artifacts=TaskArtifacts( + trace_path=LOG_PATH, + sandbox_dir=str(get_task_dir(task_id)), + ), + error=ApiError(error_code=ERROR_WORKFLOW, error_message=error_message), ) session_data = manager.load_session(task_id) or {} report_path = extract_report_html_path(session_data) - stm_file = manager.stm_dir / f"{task_id}.json" artifacts = TaskArtifacts( report_path=report_path, + trace_path=LOG_PATH, sandbox_dir=str(get_task_dir(task_id)), - session_hint=str(stm_file), + session_hint=str(getattr(manager.repository, "db_path", "")), ) return TaskEnvelope( task_id=task_id, + session_id=task_id, status=TaskStatus.SUCCEEDED, artifacts=artifacts, error=None, diff --git a/api/report_utils.py b/api/report_utils.py index 14c942c5..74bfe217 100644 --- a/api/report_utils.py +++ b/api/report_utils.py @@ -3,20 +3,110 @@ from __future__ import annotations import json -from typing import Any, Dict +import os +import re +from pathlib import Path +from typing import Any, Dict, Optional +from urllib.parse import unquote, urlparse + +from api.schema import ( + ERROR_WORKFLOW, + ApiError, + TaskArtifacts, + TaskEnvelope, + TaskStatus, +) +from utils.path import get_task_dir +from utils.session_manager import get_session_manager + +_ROOT = Path(__file__).resolve().parents[1] +_DEFAULT_TRACE_PATH = os.getenv("SONA_DEBUG_LOG_PATH", str(_ROOT / ".cursor" / "debug.log")) +_FILE_URL_RE = re.compile(r"(file://[^\s)\]]+)", re.IGNORECASE) +_REPORT_PATH_RE = re.compile( + r"([A-Za-z]:[\\/][^\s)\]\"'<>},]+|(?:\.?[\\/]|sandbox[\\/])?[A-Za-z0-9_.\-\u4e00-\u9fff\\/]+\.html?)", + re.IGNORECASE, +) + + +def _file_url_to_path(value: str) -> str: + parsed = urlparse(value) + if parsed.scheme.lower() != "file": + return value + path = unquote(parsed.path or "") + if parsed.netloc and parsed.netloc.lower() not in ("localhost", ""): + path = f"//{parsed.netloc}{path}" + if re.match(r"^/[A-Za-z]:[\\/]", path): + path = path[1:] + return path.replace("/", os.sep) + + +def _normalize_report_path(value: Any) -> str: + text = str(value or "").strip().strip("\"'") + if not text: + return "" + if text.lower().startswith("file://"): + return _file_url_to_path(text) + return text + + +def _extract_report_path_from_text(value: str) -> str: + text = str(value or "").strip() + if not text: + return "" + match = _FILE_URL_RE.search(text) + if match: + return _normalize_report_path(match.group(1)) + match = _REPORT_PATH_RE.search(text) + if not match: + return "" + return _normalize_report_path(match.group(1)) + + +def _localize_sandbox_report_path(task_id: str, raw_path: str) -> str: + path_text = _normalize_report_path(raw_path) + if not task_id or not path_text: + return path_text + path_obj = Path(path_text).expanduser() + if path_obj.is_file(): + return str(path_obj) + + normalized = path_text.replace("\\", "/") + marker = f"/sandbox/{task_id}/" + index = normalized.lower().find(marker.lower()) + if index >= 0: + tail = normalized[index + len(marker) :] + local_path = _ROOT / "sandbox" / task_id / Path(*tail.split("/")) + if local_path.is_file(): + return str(local_path) + return path_text + + +def _find_latest_report_in_task_sandbox(task_id: str) -> str: + if not task_id: + return "" + task_dir = get_task_dir(task_id) + if not task_dir.exists(): + return "" + reports = [path for path in task_dir.rglob("*.html") if path.is_file()] + if not reports: + return "" + reports.sort(key=lambda path: path.stat().st_mtime, reverse=True) + return str(reports[0]) def extract_report_html_path(session_data: Dict[str, Any]) -> str: """ - Parse the latest ``report_html`` tool message and return filesystem path. + Parse the latest report-producing message and return filesystem path. - Mirrors the logic used by ``streamlit_app._extract_report_info``. + Streamed full-report runs persist the wrapper tool ``full_report_mode_node`` + rather than the inner ``report_html`` tool, so support both shapes. """ messages = session_data.get("messages", []) for msg in reversed(messages): if msg.get("role") != "tool": continue - if msg.get("tool_name") != "report_html": + tool_name = msg.get("tool_name") + if tool_name not in ("report_html", "full_report_mode_node"): continue raw = str(msg.get("content", "") or "").strip() if not raw: @@ -24,10 +114,122 @@ def extract_report_html_path(session_data: Dict[str, Any]) -> str: try: data = json.loads(raw) except Exception: + data = None + if isinstance(data, dict): + html_path = _normalize_report_path(data.get("html_file_path") or data.get("report_path") or data.get("file_url")) + if html_path: + return html_path + else: + html_path = _extract_report_path_from_text(raw) + if html_path: + return html_path + for msg in reversed(messages): + if msg.get("role") != "assistant": continue - if not isinstance(data, dict): - continue - html_path = str(data.get("html_file_path", "") or "").strip() + html_path = _extract_report_path_from_text(str(msg.get("content", "") or "")) if html_path: return html_path + events = session_data.get("agent_events", []) + if isinstance(events, list): + for event in reversed(events): + if not isinstance(event, dict): + continue + payload = event.get("payload") + payload_dict = payload if isinstance(payload, dict) else {} + tool_name = str(payload_dict.get("tool_name") or event.get("title") or "") + event_type = str(event.get("event_type") or "") + if ( + tool_name not in ("report_html", "full_report_mode_node") + and "report" not in tool_name.lower() + and event_type != "artifact_created" + ): + continue + for candidate in ( + payload_dict.get("result"), + payload_dict.get("html_file_path"), + payload_dict.get("report_path"), + payload_dict.get("file_url"), + event.get("detail"), + ): + if isinstance(candidate, dict): + html_path = _normalize_report_path( + candidate.get("html_file_path") + or candidate.get("report_path") + or candidate.get("file_url") + ) + else: + raw = str(candidate or "").strip() + try: + parsed = json.loads(raw) if raw else None + except Exception: + parsed = None + if isinstance(parsed, dict): + html_path = _normalize_report_path( + parsed.get("html_file_path") + or parsed.get("report_path") + or parsed.get("file_url") + ) + else: + html_path = _extract_report_path_from_text(raw) + if html_path: + return html_path return "" + + +def build_task_envelope_from_session( + task_id: str, + *, + failed: bool = False, + error_message: str = "", +) -> TaskEnvelope: + """Build a TaskEnvelope from persisted chat/session messages.""" + manager = get_session_manager() + session_data = manager.load_session(task_id) or {} + report_path = _localize_sandbox_report_path(task_id, extract_report_html_path(session_data)) + if not report_path or not Path(report_path).expanduser().is_file(): + report_path = _find_latest_report_in_task_sandbox(task_id) or report_path + artifacts = TaskArtifacts( + report_path=report_path, + trace_path=_DEFAULT_TRACE_PATH, + sandbox_dir=str(get_task_dir(task_id)), + session_hint=str(getattr(manager.repository, "db_path", "")), + ) + if failed: + return TaskEnvelope( + task_id=task_id, + session_id=task_id, + status=TaskStatus.FAILED, + artifacts=artifacts, + error=ApiError( + error_code=ERROR_WORKFLOW, + error_message=error_message or "Chat workflow failed", + ), + ) + return TaskEnvelope( + task_id=task_id, + session_id=task_id, + status=TaskStatus.SUCCEEDED, + artifacts=artifacts, + error=None, + ) + + +def resolve_report_path_for_task(task_id: str) -> Optional[str]: + """Resolve HTML report path from task store or persisted session.""" + from api.task_store import get_task_store + + env = get_task_store().get(task_id) + if env is not None: + raw_path = (env.artifacts.report_path or "").strip() + localized = _localize_sandbox_report_path(task_id, raw_path) + if localized and Path(localized).expanduser().is_file(): + return localized + + session_data = get_session_manager().load_session(task_id) or {} + session_path = _localize_sandbox_report_path(task_id, extract_report_html_path(session_data)) + if session_path and Path(session_path).expanduser().is_file(): + return session_path + sandbox_path = _find_latest_report_in_task_sandbox(task_id) + if sandbox_path: + return sandbox_path + return session_path or None diff --git a/api/schema.py b/api/schema.py index 4759b5d8..74d53239 100644 --- a/api/schema.py +++ b/api/schema.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import StrEnum -from typing import Any, List, Optional +from typing import Any, Dict, List, Optional from pydantic import BaseModel, ConfigDict, Field @@ -17,6 +17,35 @@ class TaskStatus(StrEnum): FAILED = "failed" +class AgentRunStatus(StrEnum): + """Lifecycle of a frontend Agent run.""" + + QUEUED = "queued" + RUNNING = "running" + WAITING_APPROVAL = "waiting_approval" + SUCCEEDED = "succeeded" + FAILED = "failed" + ABORTED = "aborted" + + +class AgentEventType(StrEnum): + """Canonical frontend Agent event names.""" + + AGENT_MESSAGE_DELTA = "agent_message_delta" + RESEARCH_PROGRESS = "research_progress" + AGENT_STEP_STARTED = "agent_step_started" + AGENT_STEP_UPDATED = "agent_step_updated" + AGENT_STEP_COMPLETED = "agent_step_completed" + TOOL_CALL_STARTED = "tool_call_started" + TOOL_CALL_COMPLETED = "tool_call_completed" + TOOL_CALL_FAILED = "tool_call_failed" + APPROVAL_REQUESTED = "approval_requested" + APPROVAL_RESOLVED = "approval_resolved" + ARTIFACT_CREATED = "artifact_created" + RUN_COMPLETED = "run_completed" + RUN_FAILED = "run_failed" + + class ApiError(BaseModel): """Structured error returned to API clients.""" @@ -46,6 +75,7 @@ class TaskEnvelope(BaseModel): model_config = ConfigDict(extra="forbid") task_id: str = Field(..., description="Task or session identifier (UUID or app-defined).") + session_id: str = Field(default="", description="Canonical session identifier.") status: TaskStatus artifacts: TaskArtifacts = Field(default_factory=TaskArtifacts) error: Optional[ApiError] = Field(default=None, description="Set when status is failed or partial error info is exposed.") @@ -61,6 +91,310 @@ class AnalyzeEventRequest(BaseModel): disable_blocking_prompts: bool = Field(default=False) +class SessionCreateRequest(BaseModel): + """Create a chat/session container for frontend use.""" + + model_config = ConfigDict(extra="forbid") + + initial_query: str = Field(default="Frontend session", min_length=1) + + +class SessionUpdateRequest(BaseModel): + """Update frontend chat session metadata.""" + + model_config = ConfigDict(extra="forbid") + + description: Optional[str] = Field(default=None, min_length=1, max_length=120) + + +class SessionEnvelope(BaseModel): + """Serialized session data returned to frontend clients.""" + + model_config = ConfigDict(extra="allow") + + schema_version: int = Field(default=3) + session_id: str + created_at: str = "" + updated_at: str = "" + status: str = "active" + description: str = "" + initial_query: str = "" + messages: List[Dict[str, Any]] = Field(default_factory=list) + agent_events: List[Dict[str, Any]] = Field(default_factory=list) + token_usage: Dict[str, Any] = Field(default_factory=dict) + + +class SessionListResponse(BaseModel): + """GET /v1/chat/sessions payload.""" + + model_config = ConfigDict(extra="forbid") + + sessions: List[SessionEnvelope] = Field(default_factory=list) + + +class ChatMessageRequest(BaseModel): + """Request body for streaming a message through the chat runtime.""" + + model_config = ConfigDict(extra="forbid") + + query: str = Field(..., min_length=1) + auto_route: bool = Field(default=True) + prefer_existing_data: bool = Field(default=True) + workflow_options: Dict[str, Any] = Field(default_factory=dict) + + +class SessionMessageUpdateRequest(BaseModel): + """Edit one persisted chat message.""" + + model_config = ConfigDict(extra="forbid") + + content: str = Field(..., min_length=1) + mode: str = Field(default="message", pattern="^(message|turn|branch)$") + + +class AgentRunCreateRequest(BaseModel): + """Create a web Agent run for a chat session.""" + + model_config = ConfigDict(extra="forbid") + + query: str = Field(..., min_length=1) + auto_route: bool = Field(default=True) + prefer_existing_data: bool = Field(default=True) + mode: str = Field(default="") + command: str = Field(default="") + workflow_options: Dict[str, Any] = Field(default_factory=dict) + + +class AgentRunEvent(BaseModel): + """Persisted frontend Agent event.""" + + model_config = ConfigDict(extra="allow") + + event_id: str + run_id: str + session_id: str + turn_id: str + event_type: AgentEventType | str + status: str = "" + title: str = "" + detail: str = "" + payload: Dict[str, Any] = Field(default_factory=dict) + created_at: str = "" + + +class AgentRunEnvelope(BaseModel): + """Frontend Agent run state.""" + + model_config = ConfigDict(extra="allow") + + run_id: str + session_id: str + turn_id: str + status: AgentRunStatus | str + query: str = "" + events: List[AgentRunEvent] = Field(default_factory=list) + + +class AgentApprovalRequest(BaseModel): + """Resolve a waiting frontend Agent approval.""" + + model_config = ConfigDict(extra="forbid") + + action: str = Field(..., pattern="^(accept|edit|abort)$") + patch: Dict[str, Any] = Field(default_factory=dict) + + +class WikiQueryRequest(BaseModel): + """Request body for POST /v1/wiki/query.""" + + model_config = ConfigDict(extra="forbid") + + query: str = Field(..., min_length=1) + session_id: Optional[str] = Field(default=None, description="Optional chat session id for persistence.") + task_id: Optional[str] = Field(default=None, description="Legacy alias for session_id.") + topk: int = Field(default=6, ge=1, le=12) + style: str = Field(default="teach") + weibo_aux: bool = True + + +class WikiApproveRequest(BaseModel): + """Request body for POST /v1/wiki/approve.""" + + model_config = ConfigDict(extra="forbid") + + selector: str = "" + + +class CaseSearchRequest(BaseModel): + """Request body for POST /v1/cases/search.""" + + model_config = ConfigDict(extra="forbid") + + query: str = Field(..., min_length=1) + session_id: Optional[str] = Field(default=None, description="Optional chat session id for persistence.") + task_id: Optional[str] = Field(default=None, description="Legacy alias for session_id.") + + +class HotRunRequest(BaseModel): + """Request body for POST /v1/hot/run.""" + + model_config = ConfigDict(extra="forbid") + + config_path: str = "" + + +class ArtifactPathResponse(BaseModel): + """Generic path-producing workflow response.""" + + model_config = ConfigDict(extra="allow") + + status: str = "succeeded" + path: str = "" + + +class ModelInfo(BaseModel): + """Model configuration entry safe for frontend display.""" + + model_config = ConfigDict(extra="allow") + + name: str + description: str = "" + provider: str = "" + model: str = "" + api_key_env: str = "" + + +class ModelListResponse(BaseModel): + """GET /v1/models payload.""" + + model_config = ConfigDict(extra="forbid") + + models: List[ModelInfo] = Field(default_factory=list) + + +class ToolInfo(BaseModel): + """Agent tool metadata safe for frontend display.""" + + model_config = ConfigDict(extra="allow") + + name: str + description: str = "" + + +class ToolListResponse(BaseModel): + """GET /v1/tools payload.""" + + model_config = ConfigDict(extra="forbid") + + tools: List[ToolInfo] = Field(default_factory=list) + + +class SkillInfo(BaseModel): + """Backend skill metadata safe for frontend display.""" + + model_config = ConfigDict(extra="allow") + + id: str + name: str + description: str = "" + source: str = "backend" + enabled: bool = True + + +class SkillListResponse(BaseModel): + """GET /v1/skills payload.""" + + model_config = ConfigDict(extra="forbid") + + skills: List[SkillInfo] = Field(default_factory=list) + + +class MemorySettings(BaseModel): + """Persisted memory settings exposed to the frontend.""" + + model_config = ConfigDict(extra="allow") + + enable_memory: bool = True + wiki_style: str = Field(default="teach", pattern="^(teach|concise)$") + wiki_topk: int = Field(default=6, ge=1, le=12) + wiki_weibo_aux: bool = True + updated_at: str = "" + + +class MemorySettingsUpdateRequest(BaseModel): + """Patch memory settings and optionally current session prefs.""" + + model_config = ConfigDict(extra="forbid") + + session_id: Optional[str] = None + task_id: Optional[str] = None + enable_memory: Optional[bool] = None + wiki_style: Optional[str] = Field(default=None, pattern="^(teach|concise)$") + wiki_topk: Optional[int] = Field(default=None, ge=1, le=12) + wiki_weibo_aux: Optional[bool] = None + + +class MemorySettingsResponse(BaseModel): + """GET/PATCH /v1/settings/memory payload.""" + + model_config = ConfigDict(extra="allow") + + settings: MemorySettings + session_prefs: Dict[str, Any] = Field(default_factory=dict) + + +class ComposerCommand(BaseModel): + """Slash command shortcut exposed in the chat composer.""" + + model_config = ConfigDict(extra="forbid") + + id: str + label: str + command: str = Field(..., description="Slash command prefix, e.g. /event") + description: str = "" + action: str = Field(default="prefill", description="prefill | run") + default_query: str = Field( + default="", + description="Full command executed when action=run, e.g. '/monitor list'", + ) + + +class ComposerCommandListResponse(BaseModel): + """GET /v1/commands payload.""" + + model_config = ConfigDict(extra="forbid") + + commands: List[ComposerCommand] = Field(default_factory=list) + + +class MonitorTopicCreateRequest(BaseModel): + """Create a monitoring topic.""" + + model_config = ConfigDict(extra="forbid") + + name: str = Field(..., min_length=1) + domain: str = Field(default="综合舆情") + keywords: List[str] = Field(default_factory=list) + description: str = "" + run_initial_cycle: bool = True + + +class MonitorReportRequest(BaseModel): + """Generate a periodic monitoring report.""" + + model_config = ConfigDict(extra="forbid") + + period: str = Field(default="daily") + + +class MonitorTopicListResponse(BaseModel): + """GET /v1/monitor/topics payload.""" + + model_config = ConfigDict(extra="forbid") + + topics: List[Dict[str, Any]] = Field(default_factory=list) + + class HealthResponse(BaseModel): """GET /health payload.""" diff --git a/api/server.py b/api/server.py index 05749d0c..3d7eddad 100644 --- a/api/server.py +++ b/api/server.py @@ -2,16 +2,62 @@ from __future__ import annotations +import json import os +import threading +import time from pathlib import Path +from typing import Any, Dict, Iterable from fastapi import APIRouter, Depends, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, StreamingResponse from api.event_runner import run_analyze_event -from api.schema import AnalyzeEventRequest, HealthResponse, TaskEnvelope, TaskListResponse, TaskStatus +from api.agent_run_store import AgentRunRecord, get_agent_run_store +from api.report_utils import build_task_envelope_from_session, resolve_report_path_for_task +from api.schema import ( + AgentApprovalRequest, + AgentEventType, + AgentRunCreateRequest, + AgentRunEnvelope, + AgentRunStatus, + AnalyzeEventRequest, + ArtifactPathResponse, + CaseSearchRequest, + ChatMessageRequest, + HealthResponse, + HotRunRequest, + ModelInfo, + ModelListResponse, + MonitorReportRequest, + MonitorTopicCreateRequest, + MonitorTopicListResponse, + MemorySettings, + MemorySettingsResponse, + MemorySettingsUpdateRequest, + SessionCreateRequest, + SessionEnvelope, + SessionListResponse, + SessionMessageUpdateRequest, + SessionUpdateRequest, + SkillInfo, + SkillListResponse, + TaskEnvelope, + TaskListResponse, + TaskStatus, + ComposerCommand, + ComposerCommandListResponse, + ToolInfo, + ToolListResponse, + WikiApproveRequest, + WikiQueryRequest, +) from api.task_store import TaskStore, get_task_store +from utils.harness_memory import get_session_prefs, normalize_session_pref_patch, set_session_prefs +from utils.message_utils import messages_from_session_data +from utils.path import ensure_task_dirs, get_memory_dir, get_project_root +from utils.session_manager import get_session_manager def _cors_settings() -> tuple[list[str], bool]: @@ -38,6 +84,914 @@ def _cors_settings() -> tuple[list[str], bool]: v1_router = APIRouter(prefix="/v1", tags=["workflows"]) +def _session_envelope(data: Dict[str, Any]) -> SessionEnvelope: + """Normalize loose session JSON into the public session shape.""" + session_id = str(data.get("session_id") or data.get("task_id") or "") + return SessionEnvelope( + schema_version=int(data.get("schema_version") or 3), + session_id=session_id, + created_at=str(data.get("created_at") or ""), + updated_at=str(data.get("updated_at") or ""), + status=str(data.get("status") or "active"), + description=str(data.get("description") or ""), + initial_query=str(data.get("initial_query") or ""), + messages=data.get("messages") if isinstance(data.get("messages"), list) else [], + agent_events=data.get("agent_events") if isinstance(data.get("agent_events"), list) else [], + token_usage=data.get("token_usage") if isinstance(data.get("token_usage"), dict) else {}, + ) + + +def _sse(event_name: str, payload: Dict[str, Any]) -> str: + """Format one server-sent event frame.""" + return f"event: {event_name}\ndata: {json.dumps(payload, ensure_ascii=False, default=str)}\n\n" + + +def _message_content(message: Any) -> str: + """Extract printable content from LangChain messages or plain values.""" + if hasattr(message, "content"): + return str(getattr(message, "content") or "") + if isinstance(message, dict): + return str(message.get("content") or "") + return str(message or "") + + +def _tool_calls(message: Any) -> list[dict[str, Any]]: + raw = getattr(message, "tool_calls", None) or [] + out: list[dict[str, Any]] = [] + for item in raw: + if isinstance(item, dict): + out.append(item) + else: + out.append( + { + "name": getattr(item, "name", ""), + "args": getattr(item, "args", {}), + "id": getattr(item, "id", ""), + } + ) + return out + + +def _looks_like_tool_result_json(content: str) -> bool: + try: + parsed = json.loads(content) + except (json.JSONDecodeError, TypeError): + return False + if not isinstance(parsed, dict): + return False + tool_result_fields = { + "eventIntroduction", + "searchWords", + "timeRange", + "search_matrix", + "result_file_path", + "save_path", + "html_file_path", + "file_url", + } + return any(field in parsed for field in tool_result_fields) + + +def _execution_plan_steps(route_decision: str, route_data: Dict[str, Any]) -> list[str]: + intent_result = route_data.get("intent_result") + data_result = route_data.get("data_result") + has_data = bool(getattr(data_result, "has_data", False)) if data_result is not None else False + + if route_decision in ("event_analysis_workflow", "event_analysis_with_existing_data"): + if has_data and route_decision == "event_analysis_with_existing_data": + steps = [ + "extract_search_terms(确认事件与关键词)", + "复用已有数据(跳过 data_collect)", + "analysis_timeline(时间线)", + "analysis_sentiment(情感)", + "keyword/region/author/volume 统计", + "report_html(生成报告)", + ] + else: + steps = [ + "extract_search_terms(提取事件与检索参数)", + "data_num(关键词数量分配)", + "data_collect(采集数据)", + "analysis_timeline(时间线)", + "analysis_sentiment(情感)", + "keyword/region/author/volume 统计", + "report_html(生成报告)", + ] + elif route_decision == "event_brief_workflow": + steps = [ + "extract_search_terms(提取事件简介/关键词/时间范围)", + "输出轻量事件概述(不采集、不生成长报告)", + ] + elif route_decision == "hottopics_workflow": + steps = ["run_hot_command(热点聚合与态势感知)", "输出热点分析结果"] + elif route_decision == "wiki_query": + steps = ["wiki_retrieve(检索本地 Wiki/案例/方法论)", "wiki_synthesize(组织答案与来源)"] + else: + steps = ["reactagent(按 ReAct 决策按需调用工具)", "返回问答结果"] + + reason = str(getattr(intent_result, "reasoning", "") or "").strip() + if reason: + steps.insert(0, f"路由依据:{reason}") + return steps + + +def _research_phase(step: str, title: str) -> str: + text = f"{step} {title}".lower() + if "collect_plan" in text or "confirm" in text: + return "approval" + if "extract" in text or "step1" in text or "search_terms" in text: + return "plan" + if "data_collect" in text or "step4" in text: + return "data_collect" + if "data_num" in text or "step3" in text: + return "data_num" + if "dataset" in text or "step5" in text: + return "dataset_summary" + if "stats" in text or "distribution" in text or "portrait" in text or "step6." in text: + return "stats" + if "timeline" in text or "sentiment" in text or "step7" in text: + return "analyze" + if "judgement" in text or "user_judgement" in text or "step9" in text: + return "judgement" + if "interpretation" in text or "rag" in text or "wiki" in text or "oprag" in text: + return "synthesize" + if "report" in text or "done" in text: + return "report" + return "research" + + +def _research_progress_payload(progress_event: Dict[str, Any]) -> Dict[str, Any]: + step = str(progress_event.get("step") or "") + title = str(progress_event.get("title") or "") + payload = progress_event.get("payload") if isinstance(progress_event.get("payload"), dict) else {} + phase = str(payload.get("phase") or _research_phase(step, title)) + raw_status = str(progress_event.get("status") or payload.get("status") or "").strip().lower() + status = raw_status if raw_status in {"running", "completed", "failed"} else ("completed" if step == "done" else "running") + return { + "kind": "deep_research_progress", + "phase": phase, + "step": step, + "status": status, + "detail": str(progress_event.get("detail") or ""), + "payload": payload, + } + + +def _approval_plugin_payload(event_id: str, title: str, detail: str, payload: Dict[str, Any]) -> Dict[str, Any]: + return { + "toolCallId": event_id, + "apiName": "confirm_collect_plan", + "identifier": "sona.deep_research", + "type": "builtin", + "arguments": json.dumps(payload, ensure_ascii=False), + "state": {"status": "pending"}, + "intervention": { + "status": "pending", + "title": title, + "prompt": detail, + "actions": ["accept", "edit", "abort"], + "payload": payload, + }, + } + + +def _sync_session_to_task_store( + task_id: str, + *, + failed: bool = False, + error_message: str = "", +) -> None: + """Mirror chat session workflow output into the in-memory task store.""" + get_task_store().put( + build_task_envelope_from_session( + task_id, + failed=failed, + error_message=error_message, + ) + ) + + +def _persist_agent_event(task_id: str, event: Dict[str, Any]) -> None: + get_session_manager().add_agent_event(task_id, {"event": "agent_run_event", **event}) + + +def _agent_run_event_frame(record: AgentRunRecord, event: Dict[str, Any]) -> str: + payload = dict(event) + payload.setdefault("run_id", record.run_id) + payload.setdefault("session_id", record.session_id) + payload.setdefault("turn_id", record.turn_id) + return _sse(str(payload.get("event_type") or "agent_event"), payload) + + +def _previous_messages_before_query(session_data: Dict[str, Any], query: str) -> list[Any]: + """Return chat history without the current user query just persisted for the run.""" + normalized_query = query.strip() + messages = session_data.get("messages") if isinstance(session_data.get("messages"), list) else [] + for index in range(len(messages) - 1, -1, -1): + item = messages[index] + if not isinstance(item, dict) or item.get("role") != "user": + continue + if str(item.get("content") or "").strip() == normalized_query: + session_data = {**session_data, "messages": [*messages[:index], *messages[index + 1 :]]} + break + return messages_from_session_data(session_data) + + +def _append_agent_event( + record: AgentRunRecord, + event_queue: "queue.Queue[Dict[str, Any]]", + event_type: AgentEventType | str, + *, + status: str = "", + title: str = "", + detail: str = "", + payload: Dict[str, Any] | None = None, +) -> Dict[str, Any]: + event = record.add_event( + event_type, + status=status, + title=title, + detail=detail, + payload=payload or {}, + ) + data = event.model_dump(mode="json") + event_id = str(data.get("event_id") or "") + plugin = data.get("payload", {}).get("plugin") if isinstance(data.get("payload"), dict) else None + if isinstance(plugin, dict) and plugin.get("toolCallId") == "__event_id__": + plugin["toolCallId"] = event_id + intervention = plugin.get("intervention") + if isinstance(intervention, dict): + intervention.setdefault("approvalEventId", event_id) + _persist_agent_event(record.task_id, data) + event_queue.put(data) + return data + + +def _agent_run_stream(record: AgentRunRecord) -> Iterable[str]: + """Run or replay a frontend Agent run as canonical SSE events.""" + import queue + + from agent.reactagent import stream as agent_stream + from cli.router import route_query + + event_queue: "queue.Queue[Dict[str, Any]]" = queue.Queue() + manager = get_session_manager() + + if record.started: + replay_index = 0 + while True: + while replay_index < len(record.events): + event = record.events[replay_index] + replay_index += 1 + yield _agent_run_event_frame(record, event.model_dump(mode="json")) + if record.finished: + return + time.sleep(0.2) + + record.started = True + + def worker() -> None: + streamed_content = "" + try: + record.status = AgentRunStatus.RUNNING + _append_agent_event( + record, + event_queue, + AgentEventType.AGENT_STEP_STARTED, + status="running", + title="路由与执行计划", + detail="正在判断任务类型并准备执行。", + ) + + route_decision = "reactagent" + route_data: Dict[str, Any] = {} + task_mode = "qa" + workflow_options: Dict[str, Any] = dict(record.options.get("workflow_options") or {}) + workflow_options["_skip_session_user_message"] = True + command = str(record.options.get("command") or "").strip() + explicit_mode = str(record.options.get("mode") or "").strip().lower() + if explicit_mode == "wiki" or command.lower().startswith("/wiki"): + route_decision = "wiki_query" + task_mode = "wiki" + elif explicit_mode == "event" or command.lower().startswith("/event"): + route_decision = "event_analysis_workflow" + task_mode = "full_report" + workflow_options.setdefault("report_length", "中篇") + elif record.options.get("auto_route", True): + route_decision, route_data = route_query(record.query, record.task_id) + data_result = route_data.get("data_result") + route_policy = route_data.get("route_policy", {}) or {} + if route_decision in ("event_analysis_workflow", "event_analysis_with_existing_data"): + task_mode = "full_report" + workflow_options.setdefault("report_length", route_policy.get("report_length") or "中篇") + if ( + record.options.get("prefer_existing_data", True) + and data_result + and getattr(data_result, "has_data", False) + and getattr(data_result, "data_paths", None) + ): + workflow_options["existing_data_path"] = data_result.data_paths[0] + workflow_options["skip_data_collect"] = True + elif route_decision == "event_brief_workflow": + task_mode = "brief" + + _append_agent_event( + record, + event_queue, + AgentEventType.AGENT_STEP_COMPLETED, + status="completed", + title="路由完成", + detail=f"{route_decision} · {task_mode}", + payload={"route": route_decision, "task_mode": task_mode}, + ) + + for index, step in enumerate(_execution_plan_steps(route_decision, route_data), start=1): + _append_agent_event( + record, + event_queue, + AgentEventType.AGENT_STEP_STARTED, + status="running", + title=f"执行计划 {index}", + detail=step, + payload={"step": f"plan_{index}"}, + ) + + if task_mode == "wiki": + from workflow.wiki_cli import answer_wiki_query + + topk = int(workflow_options.get("wiki_topk") or 6) + style = str(workflow_options.get("wiki_style") or "teach") + weibo_aux = bool(workflow_options.get("wiki_weibo_aux", True)) + _append_agent_event( + record, + event_queue, + AgentEventType.TOOL_CALL_STARTED, + status="running", + title="wiki_retrieve", + detail=record.query, + payload={ + "tool_name": "wiki_retrieve", + "args": {"query": record.query, "topk": topk, "style": style, "weibo_aux": weibo_aux}, + }, + ) + result = answer_wiki_query( + record.query, + topk=topk, + style=style, + weibo_aux=weibo_aux, + project_root=get_project_root(), + ) + answer = str(result.get("answer") or "未返回回答") + sources = result.get("sources") if isinstance(result.get("sources"), list) else [] + _append_agent_event( + record, + event_queue, + AgentEventType.TOOL_CALL_COMPLETED, + status="completed", + title="wiki_retrieve", + detail=json.dumps({"sources": sources}, ensure_ascii=False), + payload={"tool_name": "wiki_retrieve", "result": {"answer": answer, "sources": sources}}, + ) + accumulated = "" + chunk_size = 32 + for start in range(0, len(answer), chunk_size): + accumulated = answer[: start + chunk_size] + _append_agent_event( + record, + event_queue, + AgentEventType.AGENT_MESSAGE_DELTA, + status="running", + title="回复生成", + detail=accumulated, + payload={"content": answer[start : start + chunk_size], "accumulated": accumulated}, + ) + manager.add_message(record.task_id, "assistant", answer) + record.status = AgentRunStatus.SUCCEEDED + _sync_session_to_task_store(record.task_id) + _append_agent_event( + record, + event_queue, + AgentEventType.RUN_COMPLETED, + status="succeeded", + title="工作流完成", + detail="本次 Wiki run 已完成。", + ) + return + + def approval_hook(progress_event: Dict[str, Any]) -> Dict[str, Any] | None: + step = str(progress_event.get("step") or "") + title = str(progress_event.get("title") or "") + if step != "collect_plan" and "采集方案" not in title: + return None + payload = progress_event.get("payload") + if not isinstance(payload, dict): + payload = {} + approval = _append_agent_event( + record, + event_queue, + AgentEventType.APPROVAL_REQUESTED, + status="pending", + title=title or "建议搜索采集方案(等待确认)", + detail=str(progress_event.get("detail") or ""), + payload={ + **payload, + "plugin": _approval_plugin_payload( + "__event_id__", + title or "建议搜索采集方案(等待确认)", + str(progress_event.get("detail") or ""), + payload, + ), + }, + ) + record.status = AgentRunStatus.WAITING_APPROVAL + record.pending_approval_id = str(approval.get("event_id") or "") + with record.approval_condition: + while record.approval_decision is None: + record.approval_condition.wait(timeout=1.0) + decision = dict(record.approval_decision) + record.approval_decision = None + action = str(decision.get("action") or "accept") + if action == "abort": + record.status = AgentRunStatus.ABORTED + approval_event_id = record.pending_approval_id + record.pending_approval_id = "" + _append_agent_event( + record, + event_queue, + AgentEventType.APPROVAL_RESOLVED, + status="aborted", + title="采集方案已终止", + detail="用户终止了本次工作流。", + payload={ + "approval_event_id": approval_event_id, + "action": action, + "plugin": { + "toolCallId": approval_event_id, + "apiName": "confirm_collect_plan", + "identifier": "sona.deep_research", + "type": "builtin", + "state": {"status": "aborted"}, + "intervention": {"status": "aborted", "action": action}, + }, + }, + ) + raise RuntimeError("Agent run aborted by user") + if action == "edit": + patch = decision.get("patch") if isinstance(decision.get("patch"), dict) else {} + payload.update(patch) + record.status = AgentRunStatus.RUNNING + approval_event_id = record.pending_approval_id + record.pending_approval_id = "" + _append_agent_event( + record, + event_queue, + AgentEventType.APPROVAL_RESOLVED, + status="accepted" if action == "accept" else "edited", + title="采集方案已确认", + detail="用户已确认采集方案,工作流继续执行。", + payload={ + "approval_event_id": approval_event_id, + "action": action, + "patch": decision.get("patch") if isinstance(decision.get("patch"), dict) else {}, + "plugin": { + "toolCallId": approval_event_id, + "apiName": "confirm_collect_plan", + "identifier": "sona.deep_research", + "type": "builtin", + "state": {"status": "success"}, + "intervention": { + "status": "accepted" if action == "accept" else "edited", + "action": action, + "patch": decision.get("patch") if isinstance(decision.get("patch"), dict) else {}, + }, + }, + }, + ) + return {"action": action, "patch": decision.get("patch") if isinstance(decision.get("patch"), dict) else {}} + + workflow_options["_web_progress_hook"] = approval_hook + + session_data = manager.load_session(record.task_id) or {} + previous_messages = _previous_messages_before_query(session_data, record.query) + assistant_persisted = False + for item in agent_stream( + record.query, + task_id=record.task_id, + previous_messages=previous_messages, + task_mode=task_mode, + workflow_options=workflow_options, + ): + if not isinstance(item, dict): + _append_agent_event( + record, + event_queue, + AgentEventType.AGENT_STEP_UPDATED, + status="running", + title="状态更新", + detail=str(item), + ) + continue + item_type = str(item.get("type") or "") + if item_type == "token": + accumulated = str(item.get("accumulated") or "") + if accumulated: + streamed_content = accumulated + elif item.get("content"): + streamed_content += str(item.get("content") or "") + _append_agent_event( + record, + event_queue, + AgentEventType.AGENT_MESSAGE_DELTA, + status="running", + title="回复生成", + detail=streamed_content, + payload={"content": item.get("content", ""), "accumulated": streamed_content}, + ) + elif item_type == "message": + content = _message_content(item.get("message")).strip() + if content: + streamed_content = content + if item.get("persist", True): + manager.add_message(record.task_id, "assistant", content) + assistant_persisted = True + else: + assistant_persisted = True + _append_agent_event( + record, + event_queue, + AgentEventType.AGENT_MESSAGE_DELTA, + status="running", + title="回复生成", + detail=content, + ) + elif item_type == "thinking": + accumulated = str(item.get("accumulated") or item.get("content") or "") + _append_agent_event( + record, + event_queue, + "agent_thinking_delta", + status="running", + title="思考", + detail=accumulated, + payload={"content": item.get("content", ""), "accumulated": accumulated}, + ) + elif item_type == "tool_call": + tool_call_id = str(item.get("run_id") or "") + tool_name = str(item.get("tool_name") or "unknown") + persist_tool_context = tool_name not in {"brief_mode_node", "full_report_mode_node"} + if tool_call_id and persist_tool_context: + manager.add_message( + record.task_id, + "assistant", + "", + tool_calls=[ + { + "name": tool_name, + "args": item.get("args", {}), + "id": tool_call_id, + } + ], + ) + _append_agent_event( + record, + event_queue, + AgentEventType.TOOL_CALL_STARTED, + status="running", + title=str(item.get("tool_name") or "工具调用"), + detail=json.dumps(item.get("args", {}), ensure_ascii=False, indent=2), + payload={"tool_name": item.get("tool_name", ""), "args": item.get("args", {}), "run_id": item.get("run_id", "")}, + ) + elif item_type == "tool_result": + result = str(item.get("result") or "") + tool_name = str(item.get("tool_name") or "unknown") + if tool_name not in {"brief_mode_node", "full_report_mode_node"}: + manager.add_message( + record.task_id, + "tool", + result, + tool_name=tool_name, + tool_call_id=str(item.get("run_id") or ""), + ) + event_type = AgentEventType.TOOL_CALL_COMPLETED + if "report" in tool_name.lower() or "report_" in result: + event_type = AgentEventType.ARTIFACT_CREATED + _append_agent_event( + record, + event_queue, + event_type, + status="completed", + title=tool_name, + detail=result, + payload={"tool_name": item.get("tool_name", ""), "result": result, "run_id": item.get("run_id", "")}, + ) + elif item_type == "workflow_step": + research_payload = _research_progress_payload(item) + _append_agent_event( + record, + event_queue, + AgentEventType.RESEARCH_PROGRESS, + status=str(research_payload.get("status") or "running"), + title=str(item.get("title") or "深度研究进度"), + detail=str(item.get("detail") or ""), + payload=research_payload, + ) + _append_agent_event( + record, + event_queue, + AgentEventType.AGENT_STEP_UPDATED, + status="running", + title=str(item.get("title") or "工作流"), + detail=str(item.get("detail") or ""), + payload={"step": str(item.get("step") or ""), "payload": item.get("payload", {})}, + ) + elif item_type == "compression": + _append_agent_event( + record, + event_queue, + AgentEventType.AGENT_STEP_COMPLETED, + status="completed", + title="上下文压缩", + detail=str(item.get("summary") or item.get("message") or "上下文已压缩"), + ) + + if streamed_content.strip() and not assistant_persisted: + manager.add_message(record.task_id, "assistant", streamed_content.strip()) + record.status = AgentRunStatus.SUCCEEDED + _sync_session_to_task_store(record.task_id) + _append_agent_event( + record, + event_queue, + AgentEventType.RUN_COMPLETED, + status="succeeded", + title="工作流完成", + detail="本次 Agent run 已完成。", + ) + except Exception as exc: # noqa: BLE001 + if record.status == AgentRunStatus.ABORTED: + _sync_session_to_task_store(record.task_id, failed=True, error_message="Agent run aborted by user") + _append_agent_event( + record, + event_queue, + AgentEventType.RUN_FAILED, + status="aborted", + title="工作流已终止", + detail="用户终止了本次工作流。", + ) + else: + record.status = AgentRunStatus.FAILED + _sync_session_to_task_store(record.task_id, failed=True, error_message=str(exc)) + _append_agent_event( + record, + event_queue, + AgentEventType.RUN_FAILED, + status="failed", + title="工作流失败", + detail=str(exc), + ) + finally: + record.finished = True + event_queue.put({"event_type": "__end__"}) + + threading.Thread(target=worker, daemon=True).start() + while True: + event = event_queue.get() + if event.get("event_type") == "__end__": + break + yield _agent_run_event_frame(record, event) + + +def _chat_stream( + *, + task_id: str, + body: ChatMessageRequest, +) -> Iterable[str]: + """Bridge agent stream events into SSE while keeping SessionManager updated.""" + from agent.reactagent import stream as agent_stream + from cli.router import route_query + + manager = get_session_manager() + session_data = manager.load_session(task_id) + if not session_data: + yield _sse("error", {"error": "SESSION_NOT_FOUND", "message": "Session not found"}) + return + + query = body.query.strip() + route_decision = "reactagent" + route_data: Dict[str, Any] = {} + task_mode = "qa" + workflow_options: Dict[str, Any] = dict(body.workflow_options or {}) + previous_messages = messages_from_session_data(session_data) + + if body.auto_route: + try: + route_decision, route_data = route_query(query, task_id) + data_result = route_data.get("data_result") + route_policy = route_data.get("route_policy", {}) or {} + if route_decision in ("event_analysis_workflow", "event_analysis_with_existing_data"): + task_mode = "full_report" + workflow_options.setdefault("report_length", route_policy.get("report_length") or "中篇") + if ( + body.prefer_existing_data + and data_result + and getattr(data_result, "has_data", False) + and getattr(data_result, "data_paths", None) + ): + workflow_options["existing_data_path"] = data_result.data_paths[0] + workflow_options["skip_data_collect"] = True + elif route_decision == "event_brief_workflow": + task_mode = "brief" + elif route_decision == "hottopics_workflow": + yield _sse("route", {"route": route_decision, "task_mode": "hot"}) + yield from _run_hot_as_sse() + manager.add_message(task_id, "user", query) + manager.add_message(task_id, "assistant", "已执行热点态势感知流程。") + return + except Exception as exc: # noqa: BLE001 + yield _sse("route_error", {"message": str(exc)}) + + yield _sse("route", {"route": route_decision, "task_mode": task_mode}) + for index, step in enumerate(_execution_plan_steps(route_decision, route_data), start=1): + yield _sse( + "workflow_step", + { + "step": f"plan_{index}", + "title": f"执行计划 {index}", + "detail": step, + }, + ) + manager.add_message(task_id, "user", query) + + streamed_content = "" + + def _persist_streamed_reply() -> None: + """Persist token-streamed assistant text when no final message was saved.""" + nonlocal streamed_content + text = streamed_content.strip() + if not text: + return + session = manager.load_session(task_id) or {} + messages = session.get("messages") if isinstance(session.get("messages"), list) else [] + last = messages[-1] if messages else None + if ( + isinstance(last, dict) + and last.get("role") == "assistant" + and str(last.get("content") or "").strip() == text + ): + streamed_content = "" + return + manager.add_message(task_id, "assistant", text) + streamed_content = "" + + try: + for item in agent_stream( + query, + task_id=task_id, + previous_messages=previous_messages, + task_mode=task_mode, + workflow_options=workflow_options, + ): + if not isinstance(item, dict): + yield _sse("state_update", {"value": str(item)}) + continue + + item_type = str(item.get("type") or "state_update") + if item_type == "message": + content = _message_content(item.get("message")).strip() + calls = _tool_calls(item.get("message")) + is_tool_result_json = bool(content and not calls and _looks_like_tool_result_json(content)) + if content or calls: + if (calls or not is_tool_result_json) and item.get("persist", True): + manager.add_message(task_id, "assistant", content, tool_calls=calls or None) + streamed_content = "" + yield _sse( + "message", + { + "message_id": item.get("message_id", ""), + "content": "" if is_tool_result_json else content, + "tool_calls": calls, + }, + ) + elif item_type == "thinking": + accumulated = str(item.get("accumulated") or item.get("content") or "") + yield _sse( + "thinking", + { + "content": item.get("content", ""), + "message_id": item.get("message_id", ""), + "accumulated": accumulated, + }, + ) + elif item_type == "tool_call": + tool_call_id = str(item.get("run_id") or "") + if tool_call_id: + manager.add_message( + task_id, + "assistant", + "", + tool_calls=[ + { + "name": str(item.get("tool_name") or "unknown"), + "args": item.get("args", {}), + "id": tool_call_id, + } + ], + ) + yield _sse( + "tool_call", + { + "tool_name": item.get("tool_name", ""), + "args": item.get("args", {}), + "run_id": item.get("run_id", ""), + }, + ) + elif item_type == "tool_result": + result = str(item.get("result") or "") + manager.add_message( + task_id, + "tool", + result, + tool_name=str(item.get("tool_name") or "unknown"), + tool_call_id=str(item.get("run_id") or ""), + ) + yield _sse( + "tool_result", + { + "tool_name": item.get("tool_name", ""), + "result": result, + "run_id": item.get("run_id", ""), + }, + ) + elif item_type == "token": + accumulated = str(item.get("accumulated") or "") + if accumulated: + streamed_content = accumulated + elif item.get("content"): + streamed_content += str(item.get("content") or "") + yield _sse( + "token", + { + "content": item.get("content", ""), + "message_id": item.get("message_id", ""), + "accumulated": streamed_content, + }, + ) + elif item_type == "compression": + compressed = item.get("compressed_messages") + if isinstance(compressed, list): + manager.replace_messages(task_id, compressed, reset_token_usage=True) + yield _sse("compression", item) + elif item_type == "workflow_step": + workflow_event = { + "event": "workflow_step", + "step": str(item.get("step") or ""), + "title": str(item.get("title") or ""), + "detail": str(item.get("detail") or ""), + "payload": item.get("payload", {}), + "status": str(item.get("status") or "running"), + } + manager.add_message( + task_id, + "system", + json.dumps(workflow_event, ensure_ascii=False), + ) + yield _sse( + "workflow_step", + { + "step": workflow_event["step"], + "title": workflow_event["title"], + "detail": workflow_event["detail"], + "payload": workflow_event["payload"], + "status": workflow_event["status"], + }, + ) + else: + yield _sse(item_type, {"payload": item}) + _persist_streamed_reply() + _sync_session_to_task_store(task_id) + yield _sse("done", {"session_id": task_id}) + except Exception as exc: # noqa: BLE001 + _persist_streamed_reply() + _sync_session_to_task_store(task_id, failed=True, error_message=str(exc)) + yield _sse("error", {"error": "CHAT_STREAM_ERROR", "message": str(exc)}) + + +def _run_hot_as_sse() -> Iterable[str]: + yield _sse("tool_call", {"tool_name": "hottopics", "args": {}, "run_id": "hot"}) + try: + from utils.hot_topics_env import ensure_hot_topics_cwd, prepare_hot_topics_environment + + prepare_hot_topics_environment() + ensure_hot_topics_cwd() + from tools.hottopics import run as run_hot_topics + + report_path = run_hot_topics(config_path=None) + yield _sse("tool_result", {"tool_name": "hottopics", "result": report_path, "run_id": "hot"}) + yield _sse("message", {"content": f"热点态势报告已生成:{report_path or '未返回路径'}"}) + yield _sse("done", {"path": report_path}) + except Exception as exc: # noqa: BLE001 + yield _sse("error", {"error": "HOT_WORKFLOW_ERROR", "message": str(exc)}) + + @v1_router.post("/analyze-event", response_model=TaskEnvelope) def analyze_event( body: AnalyzeEventRequest, @@ -49,6 +1003,531 @@ def analyze_event( return envelope +@v1_router.post("/chat/sessions", response_model=SessionEnvelope) +def create_chat_session(body: SessionCreateRequest) -> SessionEnvelope: + """Create a frontend chat session, equivalent to CLI /new.""" + manager = get_session_manager() + task_id = manager.create_session(body.initial_query.strip() or "Frontend session") + ensure_task_dirs(task_id) + data = manager.load_session(task_id) or {"session_id": task_id} + return _session_envelope(data) + + +@v1_router.get("/chat/sessions", response_model=SessionListResponse) +def list_chat_sessions(limit: int = 20) -> SessionListResponse: + """List persisted chat sessions, equivalent to CLI /memory.""" + manager = get_session_manager() + sessions = [_session_envelope(item) for item in manager.list_sessions(limit=max(1, min(limit, 100)))] + return SessionListResponse(sessions=sessions) + + +@v1_router.get("/chat/sessions/{task_id}", response_model=SessionEnvelope) +def get_chat_session(task_id: str) -> SessionEnvelope: + """Return one persisted chat session.""" + data = get_session_manager().load_session(task_id) + if data is None: + raise HTTPException(status_code=404, detail="Session not found") + return _session_envelope(data) + + +@v1_router.get("/chat/sessions/{task_id}/report") +def get_chat_session_report(task_id: str) -> FileResponse: + """Return the latest HTML report for a chat session.""" + raw_path = resolve_report_path_for_task(task_id) + if not raw_path: + raise HTTPException(status_code=404, detail="Report path not recorded") + path = Path(raw_path).expanduser() + if not path.is_file(): + raise HTTPException(status_code=404, detail="Report file missing on disk") + return FileResponse( + path, + media_type="text/html; charset=utf-8", + filename=path.name, + ) + + +@v1_router.patch("/chat/sessions/{task_id}", response_model=SessionEnvelope) +def update_chat_session(task_id: str, body: SessionUpdateRequest) -> SessionEnvelope: + """Update persisted chat session metadata.""" + data = get_session_manager().update_session( + task_id, + description=body.description.strip() if body.description else None, + ) + if data is None: + raise HTTPException(status_code=404, detail="Session not found") + return _session_envelope(data) + + +@v1_router.delete("/chat/sessions/{task_id}", response_model=SessionListResponse) +def delete_chat_session(task_id: str, limit: int = 20) -> SessionListResponse: + """Delete a persisted chat session and return the remaining recent sessions.""" + manager = get_session_manager() + if not manager.delete_session(task_id): + raise HTTPException(status_code=404, detail="Session not found") + get_task_store().delete(task_id) + sessions = [_session_envelope(item) for item in manager.list_sessions(limit=max(1, min(limit, 100)))] + return SessionListResponse(sessions=sessions) + + +@v1_router.patch("/chat/sessions/{task_id}/messages/{message_id}", response_model=SessionEnvelope) +def update_chat_message( + task_id: str, + message_id: str, + body: SessionMessageUpdateRequest, +) -> SessionEnvelope: + """Edit one persisted canonical message.""" + data = get_session_manager().update_message( + task_id, + message_id, + content=body.content, + mode=body.mode, + ) + if data is None: + raise HTTPException(status_code=404, detail="Session message not found") + return _session_envelope(data) + + +@v1_router.delete("/chat/sessions/{task_id}/messages/{message_id}", response_model=SessionEnvelope) +def delete_chat_message(task_id: str, message_id: str, mode: str = "turn") -> SessionEnvelope: + """Delete one persisted message or visible conversation turn.""" + if mode not in {"message", "turn", "branch"}: + raise HTTPException(status_code=422, detail="mode must be message, turn, or branch") + data = get_session_manager().delete_message(task_id, message_id, mode=mode) + if data is None: + raise HTTPException(status_code=404, detail="Session message not found") + return _session_envelope(data) + + +@v1_router.post("/chat/sessions/{task_id}/runs", response_model=AgentRunEnvelope) +def create_agent_run(task_id: str, body: AgentRunCreateRequest) -> AgentRunEnvelope: + """Create a canonical web Agent run; stream it from /events.""" + manager = get_session_manager() + if manager.load_session(task_id) is None: + raise HTTPException(status_code=404, detail="Session not found") + query = body.query.strip() + if not query: + raise HTTPException(status_code=422, detail="query is required") + manager.add_message(task_id, "user", query) + record = get_agent_run_store().create( + session_id=task_id, + query=query, + options={ + "auto_route": body.auto_route, + "prefer_existing_data": body.prefer_existing_data, + "mode": body.mode, + "command": body.command, + "workflow_options": body.workflow_options, + }, + ) + return record.envelope() + + +@v1_router.get("/chat/sessions/{task_id}/runs/{run_id}", response_model=AgentRunEnvelope) +def get_agent_run(task_id: str, run_id: str) -> AgentRunEnvelope: + """Return persisted web Agent run state.""" + record = get_agent_run_store().get(run_id) + if record is None or record.task_id != task_id: + raise HTTPException(status_code=404, detail="Agent run not found") + return record.envelope() + + +@v1_router.get("/chat/sessions/{task_id}/runs/{run_id}/events") +def stream_agent_run_events(task_id: str, run_id: str) -> StreamingResponse: + """Stream canonical web Agent events for one run.""" + record = get_agent_run_store().get(run_id) + if record is None or record.task_id != task_id: + raise HTTPException(status_code=404, detail="Agent run not found") + return StreamingResponse( + _agent_run_stream(record), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@v1_router.post("/chat/sessions/{task_id}/runs/{run_id}/approval", response_model=AgentRunEnvelope) +def resolve_agent_run_approval(task_id: str, run_id: str, body: AgentApprovalRequest) -> AgentRunEnvelope: + """Resolve a pending Agent approval request.""" + record = get_agent_run_store().get(run_id) + if record is None or record.task_id != task_id: + raise HTTPException(status_code=404, detail="Agent run not found") + if record.status != AgentRunStatus.WAITING_APPROVAL or not record.pending_approval_id: + raise HTTPException(status_code=409, detail="Agent run is not waiting for approval") + with record.approval_condition: + record.approval_decision = {"action": body.action, "patch": body.patch} + record.approval_condition.notify_all() + return record.envelope() + + +@v1_router.post("/chat/sessions/{task_id}/messages:stream") +def stream_chat_message(task_id: str, body: ChatMessageRequest) -> StreamingResponse: + """Stream a chat response as SSE, including token/tool/message events.""" + if get_session_manager().load_session(task_id) is None: + raise HTTPException(status_code=404, detail="Session not found") + return StreamingResponse( + _chat_stream(task_id=task_id, body=body), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@v1_router.post("/wiki/query") +def query_wiki(body: WikiQueryRequest) -> Dict[str, Any]: + """Run local Wiki/RAG query, equivalent to CLI /wiki.""" + from workflow.wiki_cli import answer_wiki_query + + result = answer_wiki_query( + body.query, + topk=body.topk, + style=body.style, + weibo_aux=body.weibo_aux, + project_root=get_project_root(), + ) + session_id = body.session_id or body.task_id + if session_id: + manager = get_session_manager() + if manager.load_session(session_id): + manager.add_message(session_id, "user", body.query) + manager.add_message(session_id, "assistant", str(result.get("answer") or "")) + return result + + +@v1_router.post("/wiki/approve") +def approve_wiki_candidate(body: WikiApproveRequest) -> Dict[str, Any]: + """Approve latest or selected Wiki candidate, equivalent to CLI /wiki-approve.""" + from cli.wiki_ui import _approve_candidate_to_output, _pick_candidate + + root = get_project_root() + candidate = _pick_candidate(root, selector=body.selector or None) + if candidate is None: + raise HTTPException(status_code=404, detail="No wiki candidate found") + return _approve_candidate_to_output(root, candidate) + + +@v1_router.post("/cases/search") +def search_cases(body: CaseSearchRequest) -> Dict[str, Any]: + """Search local case library, equivalent to CLI /case.""" + from workflow.wiki_cli import answer_case_query + + result = answer_case_query(body.query, project_root=get_project_root()) + session_id = body.session_id or body.task_id + if session_id: + manager = get_session_manager() + if manager.load_session(session_id): + manager.add_message(session_id, "user", body.query) + manager.add_message(session_id, "assistant", str(result.get("answer") or "")) + return result + + +@v1_router.post("/hot/run", response_model=ArtifactPathResponse) +def run_hot(body: HotRunRequest) -> ArtifactPathResponse: + """Run hot topics workflow, equivalent to CLI /hot.""" + from utils.hot_topics_env import ensure_hot_topics_cwd, prepare_hot_topics_environment + + prepare_hot_topics_environment() + ensure_hot_topics_cwd() + from tools.hottopics import run as run_hot_topics + + config_path = body.config_path.strip() or None + report_path = run_hot_topics(config_path=config_path) + return ArtifactPathResponse(path=report_path or "") + + +@v1_router.get("/models", response_model=ModelListResponse) +def list_models() -> ModelListResponse: + """Return model configuration as JSON, equivalent to CLI /models.""" + import yaml + + config_path = get_project_root() / "config" / "model.yaml" + if not config_path.is_file(): + raise HTTPException(status_code=404, detail="model.yaml not found") + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + descriptions = { + "main": "主流程模型:作为 ReAct Agent 的底座", + "tools": "工具模型:用于各种工具调用", + "extract": "搜索词提取模型", + "analysis": "分析模型:时间线、情感等", + "report": "HTML 报告生成模型", + } + models: list[ModelInfo] = [] + for name in ["main", "tools", "extract", "analysis", "report"]: + value = config.get(name) + if not isinstance(value, dict): + continue + models.append( + ModelInfo( + name=name, + description=descriptions.get(name, ""), + provider=str(value.get("provider") or ""), + model=str(value.get("model") or ""), + api_key_env=str(value.get("api_key_env") or ""), + ) + ) + return ModelListResponse(models=models) + + +def _extract_tool_description(text: str) -> str: + if not text: + return "" + marker = "描述:" + if marker in text: + text = text.split(marker, 1)[1] + for section in ("使用时机:", "输入:", "输出:", "注意:"): + if section in text: + text = text.split(section, 1)[0] + return " ".join(text.split())[:600] + + +def _memory_settings_path() -> Path: + return get_memory_dir() / "settings.json" + + +def _load_memory_settings() -> Dict[str, Any]: + path = _memory_settings_path() + if not path.is_file(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def _save_memory_settings(settings: Dict[str, Any]) -> Dict[str, Any]: + path = _memory_settings_path() + path.parent.mkdir(parents=True, exist_ok=True) + next_settings = dict(settings) + next_settings["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%S") + path.write_text(json.dumps(next_settings, ensure_ascii=False, indent=2), encoding="utf-8") + return next_settings + + +def _memory_settings_model(data: Dict[str, Any] | None = None) -> MemorySettings: + raw = dict(data or {}) + defaults = { + "enable_memory": True, + "wiki_style": "teach", + "wiki_topk": 6, + "wiki_weibo_aux": True, + "updated_at": "", + } + defaults.update({key: value for key, value in raw.items() if value is not None}) + return MemorySettings.model_validate(defaults) + + +def _session_prefs_for_task(task_id: str | None) -> Dict[str, Any]: + if not task_id: + return {} + data = get_session_manager().load_session(task_id) + if data is None: + return {} + return get_session_prefs(data) + + +COMPOSER_COMMANDS: list[ComposerCommand] = [ + ComposerCommand( + id="event", + label="事件分析", + command="/event", + description="调用 POST /v1/analyze-event,生成事件脉络与 HTML 报告", + action="prefill", + ), + ComposerCommand( + id="wiki", + label="知识库", + command="/wiki", + description="调用 POST /v1/wiki/query,查询本地 Wiki", + action="prefill", + ), + ComposerCommand( + id="case", + label="案例检索", + command="/case", + description="调用 POST /v1/cases/search,检索历史案例", + action="prefill", + ), + ComposerCommand( + id="hot", + label="热点", + command="/hot", + description="调用 POST /v1/hot/run", + action="run", + default_query="/hot", + ), + ComposerCommand( + id="monitor_list", + label="专题", + command="/monitor", + description="GET /v1/monitor/topics", + action="run", + default_query="/monitor list", + ), + ComposerCommand( + id="monitor_demo", + label="专题演示", + command="/monitor", + description="POST /v1/monitor/demo", + action="run", + default_query="/monitor demo", + ), + ComposerCommand( + id="memory", + label="最近话题", + command="/memory", + description="GET /v1/chat/sessions", + action="run", + default_query="/memory", + ), +] + + +@v1_router.get("/commands", response_model=ComposerCommandListResponse) +def list_composer_commands() -> ComposerCommandListResponse: + """Return slash-command shortcuts for the chat composer toolbar.""" + return ComposerCommandListResponse(commands=COMPOSER_COMMANDS) + + +@v1_router.get("/tools", response_model=ToolListResponse) +def list_tools() -> ToolListResponse: + """Return agent tool metadata as JSON, equivalent to CLI /tools.""" + from agent.reactagent import AGENT_TOOLS + + tools: list[ToolInfo] = [] + for tool in AGENT_TOOLS: + name = str(getattr(tool, "name", "") or tool) + description = str(getattr(tool, "description", "") or getattr(tool, "__doc__", "") or "") + tools.append(ToolInfo(name=name, description=_extract_tool_description(description))) + return ToolListResponse(tools=tools) + + +@v1_router.get("/skills", response_model=SkillListResponse) +def list_skills() -> SkillListResponse: + """Return backend skills available to the frontend settings screen.""" + from agent.reactagent import AGENT_TOOLS, QA_TOOLS + + seen: set[str] = set() + skills: list[SkillInfo] = [] + for source, tool_group in (("agent_tool", AGENT_TOOLS), ("qa_tool", QA_TOOLS)): + for tool in tool_group: + name = str(getattr(tool, "name", "") or tool).strip() + if not name or name in seen: + continue + seen.add(name) + description = str(getattr(tool, "description", "") or getattr(tool, "__doc__", "") or "") + skills.append( + SkillInfo( + id=name, + name=name, + description=_extract_tool_description(description), + source=source, + enabled=True, + ) + ) + return SkillListResponse(skills=skills) + + +@v1_router.get("/settings/memory", response_model=MemorySettingsResponse) +def get_memory_settings(task_id: str | None = None) -> MemorySettingsResponse: + """Return persisted memory settings and optional current-session prefs.""" + return MemorySettingsResponse( + settings=_memory_settings_model(_load_memory_settings()), + session_prefs=_session_prefs_for_task(task_id), + ) + + +@v1_router.patch("/settings/memory", response_model=MemorySettingsResponse) +def update_memory_settings(body: MemorySettingsUpdateRequest) -> MemorySettingsResponse: + """Patch memory settings and mirror supported prefs into the selected session.""" + current = _memory_settings_model(_load_memory_settings()).model_dump() + patch = body.model_dump(exclude_unset=True, exclude_none=True) + task_id = str(patch.pop("session_id", "") or patch.pop("task_id", "") or "").strip() + if patch: + current.update(patch) + current = _save_memory_settings(_memory_settings_model(current).model_dump()) + + session_prefs: Dict[str, Any] = {} + if task_id and current.get("enable_memory", True): + manager = get_session_manager() + session_data = manager.load_session(task_id) + if session_data is None: + raise HTTPException(status_code=404, detail="Session not found") + pref_patch = normalize_session_pref_patch(current) + if pref_patch: + session_data = set_session_prefs(session_data, patch=pref_patch) + manager.save_session(task_id, session_data) + session_prefs = _session_prefs_for_task(task_id) + + return MemorySettingsResponse( + settings=_memory_settings_model(current), + session_prefs=session_prefs, + ) + + +@v1_router.get("/monitor/topics", response_model=MonitorTopicListResponse) +def list_monitor_topics() -> MonitorTopicListResponse: + """List monitoring topics, equivalent to /monitor list.""" + from workflow.topic_monitoring_pipeline import TopicMonitoringPipeline + + pipeline = TopicMonitoringPipeline() + return MonitorTopicListResponse(topics=pipeline.db.list_monitor_topics()) + + +@v1_router.post("/monitor/demo") +def run_monitor_demo() -> Dict[str, Any]: + """Run built-in high-speed-rail monitor demo, equivalent to /monitor demo.""" + from workflow.topic_monitoring_pipeline import run_high_speed_rail_demo + + return run_high_speed_rail_demo() + + +@v1_router.post("/monitor/topics") +def create_monitor_topic(body: MonitorTopicCreateRequest) -> Dict[str, Any]: + """Create a monitoring topic, equivalent to /monitor create.""" + from workflow.topic_monitoring_pipeline import TopicMonitoringPipeline + + pipeline = TopicMonitoringPipeline() + topic = pipeline.create_topic( + name=body.name, + domain=body.domain, + keywords=body.keywords, + description=body.description or "由前端 API 创建", + owner="frontend", + ) + result: Dict[str, Any] = {"topic": topic} + if body.run_initial_cycle: + result["cycle"] = pipeline.run_monitoring_cycle([str(topic.get("id") or "")]) + return result + + +@v1_router.get("/monitor/topics/{topic_id}/status") +def get_monitor_topic_status(topic_id: str) -> Dict[str, Any]: + """Return monitoring topic status, equivalent to /monitor status.""" + from workflow.topic_monitoring_pipeline import TopicMonitoringPipeline + + result = TopicMonitoringPipeline().get_topic_status(topic_id) + if result.get("error"): + raise HTTPException(status_code=404, detail=str(result["error"])) + return result + + +@v1_router.post("/monitor/topics/{topic_id}/cycle") +def run_monitor_topic_cycle(topic_id: str) -> Dict[str, Any]: + """Run one monitoring cycle for a topic.""" + from workflow.topic_monitoring_pipeline import TopicMonitoringPipeline + + return TopicMonitoringPipeline().run_monitoring_cycle([topic_id]) + + +@v1_router.post("/monitor/topics/{topic_id}/report") +def create_monitor_topic_report(topic_id: str, body: MonitorReportRequest) -> Dict[str, Any]: + """Generate monitor daily/weekly report, equivalent to /monitor report.""" + from workflow.topic_monitoring_pipeline import TopicMonitoringPipeline + + try: + return TopicMonitoringPipeline().generate_periodic_report(topic_id, period=body.period) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @v1_router.get("/tasks", response_model=TaskListResponse) def list_tasks(store: TaskStore = Depends(get_task_store)) -> TaskListResponse: """List tasks stored in the current API process memory (for Streamlit GUI).""" @@ -68,14 +1547,12 @@ def get_task(task_id: str, store: TaskStore = Depends(get_task_store)) -> TaskEn def get_task_report(task_id: str, store: TaskStore = Depends(get_task_store)) -> FileResponse: """Return HTML report file when available (mode B in docs/api_design.md).""" env = store.get(task_id) - if env is None: - raise HTTPException(status_code=404, detail="Task not found") - if env.status != TaskStatus.SUCCEEDED: + if env is not None and env.status != TaskStatus.SUCCEEDED: raise HTTPException( status_code=409, detail="Task did not succeed; report unavailable", ) - raw_path = (env.artifacts.report_path or "").strip() + raw_path = resolve_report_path_for_task(task_id) if not raw_path: raise HTTPException(status_code=404, detail="Report path not recorded") path = Path(raw_path).expanduser() diff --git a/api/task_store.py b/api/task_store.py index 8220d945..4ccb6069 100644 --- a/api/task_store.py +++ b/api/task_store.py @@ -1,15 +1,21 @@ -"""In-memory task registry for the HTTP API (single-process demo / dev).""" +"""Session-backed task compatibility registry for the HTTP API.""" from __future__ import annotations import threading from typing import List, Optional -from api.schema import TaskEnvelope +from api.schema import TaskEnvelope, TaskStatus class TaskStore: - """Thread-safe store of TaskEnvelope by task_id.""" + """Thread-safe compatibility index of TaskEnvelope by session/task id. + + The frontend originally consumed /v1/tasks as an in-memory task list. Chat + sessions are now the durable context, so this store stitches transient task + status together with session-derived artifacts instead of acting as a + separate source of truth. + """ def __init__(self) -> None: self._lock = threading.Lock() @@ -18,15 +24,94 @@ def __init__(self) -> None: def put(self, envelope: TaskEnvelope) -> None: with self._lock: self._tasks[envelope.task_id] = envelope + try: + from utils.session_repository import get_session_repository + + get_session_repository().upsert_task_envelope(envelope) + except Exception: + # TaskStore remains a compatibility cache; repository persistence + # must not break a live workflow response. + pass + + def _from_session(self, task_id: str, base: Optional[TaskEnvelope] = None) -> Optional[TaskEnvelope]: + from api.report_utils import build_task_envelope_from_session + from utils.session_manager import get_session_manager + + if not get_session_manager().load_session(task_id): + return base + + failed = base.status == TaskStatus.FAILED if base else False + error_message = base.error.error_message if base and base.error else "" + envelope = build_task_envelope_from_session( + task_id, + failed=failed, + error_message=error_message, + ) + if base and base.status in {TaskStatus.QUEUED, TaskStatus.RUNNING}: + envelope.status = base.status + envelope.error = base.error + return envelope def get(self, task_id: str) -> Optional[TaskEnvelope]: with self._lock: - return self._tasks.get(task_id) + existing = self._tasks.get(task_id) + if existing is not None: + try: + from utils.session_manager import get_session_manager + + session_key = existing.session_id or existing.task_id + if session_key and not get_session_manager().load_session(session_key): + return None + except Exception: + pass + return existing + try: + from utils.session_repository import get_session_repository + + for envelope in get_session_repository().list_task_envelopes(): + if envelope.task_id == task_id: + with self._lock: + self._tasks[task_id] = envelope + return envelope + except Exception: + pass + return None + + def delete(self, task_id: str) -> None: + with self._lock: + self._tasks.pop(task_id, None) + + def clear(self) -> None: + with self._lock: + self._tasks.clear() def list_all(self) -> List[TaskEnvelope]: - """Return all stored envelopes (newest last; order not guaranteed).""" + """Return explicit task/run envelopes, not every chat session.""" + + with self._lock: + merged = dict(self._tasks) + + try: + from utils.session_repository import get_session_repository + + for envelope in get_session_repository().list_task_envelopes(): + merged.setdefault(envelope.task_id, envelope) + except Exception: + pass + with self._lock: - return list(self._tasks.values()) + self._tasks.update(merged) + try: + from utils.session_manager import get_session_manager + + manager = get_session_manager() + return [ + envelope + for envelope in merged.values() + if manager.load_session(envelope.session_id or envelope.task_id) + ] + except Exception: + return list(merged.values()) _store = TaskStore() diff --git a/cli/display.py b/cli/display.py index e8c4ef6d..44e1af62 100644 --- a/cli/display.py +++ b/cli/display.py @@ -81,8 +81,14 @@ def print_welcome() -> None: console.print(" [dim] 示例: /event 315晚会舆情分析[/dim]") console.print(" [cyan]/memory[/cyan] - 查看并恢复之前的会话") console.print(" [cyan]/hot[/cyan] - 热点抓取与态势感知(独立流程,生成 HTML)") + console.print(" [cyan]/case[/cyan] - 检索案例库(相似案例对照)") + console.print(" [dim] 示例: /case 高铁服务争议案例[/dim]") + console.print(" [cyan]/monitor[/cyan] - 专题监测(创建专题、日报/周报、内存演示)") + console.print(" [dim] 示例: /monitor demo 或 /monitor help[/dim]") console.print(" [cyan]/wiki[/cyan] - 知识库问答(answer + sources)") console.print(" [dim] 示例: /wiki 什么是舆情反转?[/dim]") + console.print(" [cyan]/wiki-approve[/cyan] - 审核并回流高价值 wiki 候选到 output") + console.print(" [dim] 示例: /wiki-approve 或 /wiki-approve 关键词[/dim]") console.print(" [cyan]/models[/cyan] - 查看所有模型配置") console.print(" [cyan]/tools[/cyan] - 查看所有可用工具") console.print(" [cyan]/clear[/cyan] - 清除 memory 和 sandbox") diff --git a/cli/event_analysis_workflow.py b/cli/event_analysis_workflow.py index dd3e5231..64706385 100644 --- a/cli/event_analysis_workflow.py +++ b/cli/event_analysis_workflow.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from typing import Any, Dict, Optional +from typing import Any, Callable, Dict, Optional from tools import extract_search_terms, search_reference_insights from utils.session_manager import SessionManager @@ -23,6 +23,8 @@ def run_event_analysis_workflow( skip_data_collect: bool = False, force_fresh_start: Optional[bool] = None, report_length: Optional[str] = None, + progress_callback: Optional[Any] = None, + skip_session_user_message: bool = False, ) -> str: """Backward-compatible entry: delegates to workflow runner / pipeline.""" return _dispatch_pipeline( @@ -35,6 +37,8 @@ def run_event_analysis_workflow( skip_data_collect=skip_data_collect, force_fresh_start=force_fresh_start, report_length=report_length, + progress_callback=progress_callback, + skip_session_user_message=skip_session_user_message, ) @@ -48,6 +52,8 @@ def run_full_report_mode( skip_data_collect: bool = False, force_fresh_start: Optional[bool] = None, report_length: Optional[str] = None, + progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None, + skip_session_user_message: bool = False, ) -> str: """完整报告模式(供 Agent full_report 等复用)。""" return run_event_analysis_workflow( @@ -59,6 +65,8 @@ def run_full_report_mode( skip_data_collect=skip_data_collect, force_fresh_start=force_fresh_start, report_length=report_length, + progress_callback=progress_callback, + skip_session_user_message=skip_session_user_message, ) diff --git a/cli/hot_ui.py b/cli/hot_ui.py index 421c0c61..e7c3d840 100644 --- a/cli/hot_ui.py +++ b/cli/hot_ui.py @@ -39,7 +39,7 @@ def run_hot_command(config_path: Optional[str] = None) -> None: ) console.print() - # 展示当前热点流程实际使用的 base_url(便于确认走 coding plan) + # 展示当前热点流程实际使用的 base_url(model.yaml / 环境变量;非 Coding 时为 compatible-mode) base_url = os.environ.get("INSIGHT_ENGINE_BASE_URL") or "" if base_url: console.print(f"[dim]hot LLM base_url: {base_url}[/dim]") @@ -49,6 +49,14 @@ def run_hot_command(config_path: Optional[str] = None) -> None: console.print(f"[green]✅ 热点报告已生成: {report_path}[/green]") else: console.print("[yellow]流程已执行,但未返回报告路径。[/yellow]") + try: + import tools.hottopics as _ht_snap + + jp = getattr(_ht_snap, "LAST_HOT_SNAPSHOT_JSON", None) + if jp: + console.print(f"[dim]hot JSON 快照: {jp}[/dim]") + except Exception: + pass console.print() except Exception as exc: console.print(f"[red]❌ 热点流程执行失败: {exc}[/red]") diff --git a/cli/main.py b/cli/main.py index 9f08cea2..16795615 100644 --- a/cli/main.py +++ b/cli/main.py @@ -150,7 +150,8 @@ def interactive() -> None: # 默认行为:只提示,不创建会话 console.print( "[yellow]提示: 使用 '/new' 开启新会话,'/memory' 恢复会话," - "'/event' 事件分析,'/wiki' 知识问答,'/hot' 热点态势。[/yellow]" + "'/event' 事件分析,'/hot' 热点态势,'/case' 案例库," + "'/monitor' 专题监测,'/wiki' 知识问答。[/yellow]" ) except KeyboardInterrupt: diff --git a/cli/monitor_ui.py b/cli/monitor_ui.py index d43730cf..739de3ef 100644 --- a/cli/monitor_ui.py +++ b/cli/monitor_ui.py @@ -2,22 +2,62 @@ from __future__ import annotations +import os +import re +from datetime import datetime, timezone from pathlib import Path +from typing import Any, List, Optional, Tuple +import jieba from rich.prompt import Prompt from cli.display import console from workflow.topic_monitoring_pipeline import TopicMonitoringPipeline, run_high_speed_rail_demo +_MONITOR_SUBCOMMANDS = frozenset( + {"help", "h", "?", "demo", "list", "create", "status", "report"} +) + def _print_help() -> None: console.print("[cyan]/monitor[/cyan] - 专题监测命令") console.print(" [cyan]/monitor help[/cyan] - 显示命令帮助") console.print(" [cyan]/monitor demo[/cyan] - 运行高铁舆情内存演示") console.print(" [cyan]/monitor list[/cyan] - 列出当前进程内/外部库专题") - console.print(" [cyan]/monitor create 名称|领域|关键词1,关键词2[/cyan]") + console.print(" [cyan]/monitor create 名称|领域|关键词1,关键词2[/cyan] (结构化)") + console.print(" [dim] 自然语言新建(整句即可):[/dim][cyan]/monitor 帮我建立重大交通事故的舆情监测专题[/cyan]") console.print(" [cyan]/monitor status [/cyan] - 查询专题状态") console.print(" [cyan]/monitor report [daily|weekly][/cyan] - 生成日报/周报") + console.print(" [dim] 环境变量:SONA_MONITOR_SKIP_EXTRACT=1 跳过 extract 精炼;SONA_TOPIC_MONITOR_INTERVAL_HOURS 默认采集间隔。[/dim]") + console.print(" [dim] 持久化:默认写入项目 data/topic_monitor_local.db(可用 SONA_TOPIC_MONITOR_SQLITE 改路径)。[/dim]") + console.print( + " [dim] 网察:SONA_TOPIC_MONITOR_USE_OPINION_NETINSIGHT=1 + SONA_OPINION_SYSTEM_ROOT + " + "NETINSIGHT_USER/PASS 时,create 首轮即拉帖;定时增量仍用 scripts/run_topic_monitor_tick.py。[/dim]" + ) + + +def _optional_netinsight_search_func( + pipeline: TopicMonitoringPipeline, +) -> tuple[Optional[Any], bool]: + """ + 若已开启 opinion NetInsight,返回 (search_func, True);否则 (None, False)。 + 构建失败时打印警告并返回 (None, False),专题仍可使用 tick 补采。 + """ + from workflow.topic_netinsight_adapter import ( + build_opinion_netinsight_search_func, + topic_monitor_use_opinion_netinsight, + ) + + if not topic_monitor_use_opinion_netinsight(): + return None, False + try: + return build_opinion_netinsight_search_func(pipeline), True + except Exception as exc: # noqa: BLE001 + console.print( + f"[yellow]NetInsight 未就绪({exc}),首轮将不拉帖。" + "请检查 SONA_OPINION_SYSTEM_ROOT、client.py 与 NETINSIGHT_USER/PASS。[/yellow]" + ) + return None, False def _parse_create_args(args: str) -> tuple[str, str, list[str]]: @@ -28,6 +68,252 @@ def _parse_create_args(args: str) -> tuple[str, str, list[str]]: return name, domain, [k for k in keywords if k] +def _looks_like_create_intent(text: str) -> bool: + """判断整句是否像「新建监测专题」的自然语言,而非误拼的子命令。""" + s = text.strip() + if not s: + return False + if any(v in s for v in ("建立", "创建", "新建", "开通")) and any( + x in s for x in ("专题", "监测", "舆情") + ): + return True + if any(x in s for x in ("监测专题", "舆情监测", "舆情专题")): + return True + if "专题" in s and "舆情" in s: + return True + return False + + +def _infer_domain(core: str) -> str: + if any(x in core for x in ("交通", "事故", "高速", "道路", "地铁", "铁路", "物流", "航运", "民航")): + return "交通" + if any(x in core for x in ("医", "药", "院", "疾控", "健康", "疫情", "控烟")): + return "公共卫生" + if any(x in core for x in ("教育", "学校", "大学", "招生")): + return "教育" + if any(x in core for x in ("环保", "污染", "碳", "气候")): + return "生态环境" + return "综合舆情" + + +def _keywords_from_core(core: str, domain: str) -> List[str]: + """从主题核心短语拆出监测关键词(去重、保留顺序)。""" + kws: List[str] = [] + seen: set[str] = set() + noise = frozenset( + { + "建立", + "创建", + "新建", + "开通", + "专题", + "监测", + "舆情", + "的", + "和", + "与", + "一个", + "帮我", + "我要", + "我想", + } + ) + + def push(w: str) -> None: + w = w.strip() + if len(w) < 2 or w in seen or w in noise: + return + seen.add(w) + kws.append(w) + + push(core) + try: + for w in jieba.lcut(core): + push(w) + except Exception: + pass + if domain == "交通": + for extra in ("交通事故", "交通安全", "追尾"): + if extra in core: + push(extra) + return kws[:16] + + +def _try_parse_natural_create(text: str) -> Optional[Tuple[str, str, List[str]]]: + """ + 从自然语言中解析专题名称、领域、关键词。 + + 例如:「帮我建立 重大交通事故的舆情监测专题」「帮重大交通事故的舆情监测专题」 + """ + s = text.strip() + if not s or not _looks_like_create_intent(s): + return None + + core = s + suffixes = ( + "的舆情监测专题", + "舆情监测专题", + "的舆情专题", + "舆情专题", + "专题监测", + "监测专题", + "的专题", + "的监测", + "舆情监测", + ) + for suf in suffixes: + if core.endswith(suf): + core = core[: -len(suf)].strip() + break + + prefixes = ( + "请帮我", + "能不能帮我", + "麻烦帮我", + "帮我", + "请", + "我想", + "我要", + "给建立一个", + "给我建立", + "给建立", + "建立一个", + "建立", + "创建一个", + "创建", + "新建一个", + "新建", + "开通", + "搞一个", + "做一个", + ) + changed = True + while changed: + changed = False + for pre in sorted(prefixes, key=len, reverse=True): + if core.startswith(pre): + core = core[len(pre) :].strip() + changed = True + break + + # 口语里单独的「帮」+ 主题 + if core.startswith("帮") and len(core) > 1 and core[1] not in "忙": + core = core[1:].strip() + + core = re.sub(r"[\s ,,。]+", "", core) + core = core.strip("的") + if len(core) < 2: + return None + + domain = _infer_domain(core) + if "舆情" in core or "监测" in core: + name = f"{core}专题" if len(core) <= 28 else f"{core[:26]}…专题" + else: + name = f"{core}舆情监测" + if len(name) > 40: + name = f"{core[:32]}…监测" + + keywords = _keywords_from_core(core, domain) + if not keywords: + return None + return name, domain, keywords + + +def _finalize_create_topic( + pipeline: TopicMonitoringPipeline, + *, + name: str, + domain: str, + keywords: List[str], + description: str, + source_label: str, + narrative_query: str | None = None, +) -> None: + if not name or not domain or not keywords: + console.print("[red]专题名称、领域和关键词都不能为空。[/red]") + return + + from workflow.topic_monitoring_workflow import ( + build_default_topic_config, + format_monitor_workflow_hints, + refine_monitor_keywords, + ) + + final_keywords = list(keywords) + merged_plan: dict | None = None + nq = (narrative_query or "").strip() + if nq and os.environ.get("SONA_MONITOR_SKIP_EXTRACT", "").strip().lower() not in ("1", "true", "yes"): + console.print("[dim]调用 extract_search_terms(与事件分析 Step1 同源)精炼监测关键词…[/dim]") + pack = refine_monitor_keywords(user_text=nq, seed_keywords=final_keywords) + merged_plan = pack.get("search_plan") if isinstance(pack.get("search_plan"), dict) else {} + if pack.get("merged_keywords"): + final_keywords = list(pack["merged_keywords"]) + if not pack.get("used_extract"): + err = str(pack.get("extract_error") or "").strip() + if err: + console.print(f"[yellow]关键词精炼未使用模型输出({err}),已保留种子词。[/yellow]") + + topic = pipeline.create_topic( + name=name, + domain=domain, + keywords=final_keywords, + description=description, + ) + tid = str(topic.get("id") or "") + console.print("[green]已创建专题[/green]") + console.print(f"名称: [bold]{name}[/bold]") + console.print(f"领域: {domain}") + console.print(f"关键词: {', '.join(final_keywords)}") + console.print(f"ID: [cyan]{tid}[/cyan]") + console.print(f"[dim]来源: {source_label}[/dim]") + + cfg = build_default_topic_config(merged_search_plan=merged_plan or {}) + cfg["monitoring_started_at"] = datetime.now(timezone.utc).replace(tzinfo=None).isoformat(timespec="seconds") + pipeline.patch_topic_config(tid, cfg) + console.print("[dim]── 监测编排(默认全平台 + 建议间隔)──[/dim]") + for ln in format_monitor_workflow_hints(cfg).split("\n"): + console.print(f"[dim]{ln}[/dim]") + + search_func, netinsight_on = _optional_netinsight_search_func(pipeline) + if netinsight_on and search_func is not None: + console.print("[dim]首轮监测:通过 NetInsight 拉取网帖(可能需数分钟)…[/dim]") + elif not netinsight_on: + console.print( + "[yellow]未开启网察首轮拉帖:[/yellow]请在 .env 设置 " + "SONA_TOPIC_MONITOR_USE_OPINION_NETINSIGHT=1 及网察账号后重建," + "或执行 ``python scripts/run_topic_monitor_tick.py``。" + ) + + cycle = pipeline.run_monitoring_cycle([tid], search_func=search_func) + post_count = 0 + summary = "" + for item in cycle.get("results") or []: + if str(item.get("topic_id") or "") != tid: + continue + snap = item.get("snapshot") if isinstance(item.get("snapshot"), dict) else {} + post_count = int(snap.get("post_count") or 0) + summary = str(snap.get("summary") or "") + break + + if search_func is not None: + console.print( + f"[green]首轮监测完成[/green]:快照 post_count={post_count}" + + (f",{summary}" if summary else "") + ) + else: + console.print( + "[dim]已执行首轮监测周期(无 search_func 时无外链帖子,快照可能为「无新数据」)。[/dim]" + ) + console.print( + "[dim]定时增量:[/dim] ``python scripts/run_topic_monitor_tick.py`` " + "(处理全部活跃专题,逻辑与 create 首轮一致)。" + ) + console.print( + f"[cyan]下一步:[/cyan] `/monitor status {tid}` 查看状态;" + f"`/monitor report {tid} daily` 生成舆情专报(Markdown)。" + ) + + def run_monitor_command(raw_query: str | None = None) -> None: query = str(raw_query or "").strip() if not query: @@ -66,7 +352,15 @@ def run_monitor_command(raw_query: str | None = None) -> None: return if command == "create": - name, domain, keywords = _parse_create_args(rest) + name, domain, keywords = "", "", [] + if rest and "|" in rest: + name, domain, keywords = _parse_create_args(rest) + elif rest: + parsed = _try_parse_natural_create(rest) + if parsed: + name, domain, keywords = parsed + else: + name, domain, keywords = _parse_create_args(rest) if not name: name = Prompt.ask("请输入专题名称").strip() if not domain: @@ -74,12 +368,17 @@ def run_monitor_command(raw_query: str | None = None) -> None: if not keywords: raw_keywords = Prompt.ask("请输入关键词,逗号分隔").strip() keywords = [k.strip() for k in raw_keywords.split(",") if k.strip()] - if not name or not domain or not keywords: - console.print("[red]专题名称、领域和关键词都不能为空。[/red]") - return - topic = pipeline.create_topic(name=name, domain=domain, keywords=keywords, description="由 /monitor 创建") - console.print("[green]已创建专题[/green]") - console.print(f"ID: [cyan]{topic.get('id')}[/cyan]") + desc = "由 /monitor create 创建" + narrative = (rest or "").strip() or f"{name} {domain} {' '.join(keywords)}" + _finalize_create_topic( + pipeline, + name=name, + domain=domain, + keywords=keywords, + description=desc, + source_label="/monitor create", + narrative_query=narrative, + ) return if command == "status": @@ -112,11 +411,27 @@ def run_monitor_command(raw_query: str | None = None) -> None: console.print(f"报告路径: [cyan]{result['report_path']}[/cyan]") return + if command not in _MONITOR_SUBCOMMANDS: + parsed = _try_parse_natural_create(query) + if parsed: + name, domain, keywords = parsed + desc = f"CLI 自然语言创建|{query[:240]}" + _finalize_create_topic( + pipeline, + name=name, + domain=domain, + keywords=keywords, + description=desc, + source_label="/monitor(自然语言)", + narrative_query=query, + ) + return + console.print(f"[yellow]未知 /monitor 子命令: {command}[/yellow]") _print_help() except Exception as exc: console.print(f"[red]/monitor 执行失败: {exc}[/red]") console.print( - "[yellow]若要接 Supabase/Postgres,请配置 SUPABASE_URL/SUPABASE_KEY " - "或 DATABASE_URL/POSTGRES_URL,并先执行 workflow/topic_monitoring_schema.sql。[/yellow]" + "[yellow]持久化:专题数据默认保存在 data/topic_monitor_local.db;" + "可用环境变量 SONA_TOPIC_MONITOR_SQLITE 指定其它 .db 路径。[/yellow]" ) diff --git a/cli/router.py b/cli/router.py index 82ef0d19..ce8647b0 100644 --- a/cli/router.py +++ b/cli/router.py @@ -167,6 +167,10 @@ def recognize(self, query: str) -> IntentResult: # 检查是否明确要求重新搜索 is_re_search = any(p.search(query) for p in self.re_search_patterns) + is_public_opinion_probe = bool( + re.search(r"(舆情|舆论)", query) + and re.search(r"(有什么|有哪些|哪些|如何|怎么样|怎么回事|态势|趋势)", query) + ) hot_keywords = [] for pattern in self.hot_patterns: @@ -194,6 +198,10 @@ def recognize(self, query: str) -> IntentResult: confidence = min(0.7 + 0.1 * len(matched_keywords), 0.95) intent = "event_analysis" reasoning = f"检测到舆情分析关键词: {', '.join(matched_keywords[:3])}" + elif is_public_opinion_probe: + confidence = 0.78 + intent = "event_analysis" + reasoning = "检测到实体舆情探询表达,进入舆情分析流程" else: # 没有明确关键词,默认为一般查询 confidence = 0.5 diff --git a/cli/wiki_ui.py b/cli/wiki_ui.py index 1d6629a5..2941cc3c 100644 --- a/cli/wiki_ui.py +++ b/cli/wiki_ui.py @@ -122,7 +122,18 @@ def run_wiki_command(raw_query: str | None = None) -> None: get_env_config() result = answer_wiki_query(query, topk=6, style="teach", project_root=Path(__file__).resolve().parents[1]) meta = result.get("_wiki_meta") if isinstance(result.get("_wiki_meta"), dict) else {} - if meta.get("llm_used"): + if meta.get("wiki_route") == "neo4j_qa_only": + nq = meta.get("neo4j_qa") if isinstance(meta.get("neo4j_qa"), dict) else {} + mode = str(nq.get("answer_mode") or "—") + rc = nq.get("rows_count", "—") + console.print( + f"[green]✓[/green] [bold]本轮仅使用 Neo4j 图谱问答[/bold] " + f"([cyan]tools/neo4j_qa[/cyan];模式 [cyan]{mode}[/cyan];图谱证据条数 [cyan]{rc}[/cyan])" + ) + err = str(meta.get("neo4j_qa_error") or meta.get("neo4j_qa_invoke_error") or "").strip() + if err: + console.print(f"[yellow](neo4j_qa:{err})[/yellow]") + elif meta.get("llm_used"): console.print("[green]✓[/green] [bold]已使用 LLM(tools profile)基于检索片段生成回答[/bold]") elif result.get("sources"): hint = str(meta.get("llm_error") or "").strip() @@ -138,25 +149,34 @@ def run_wiki_command(raw_query: str | None = None) -> None: ) else: console.print("[yellow](未使用 LLM:未知原因,已用检索模板)[/yellow]") - if isinstance(meta.get("weibo_aux"), dict) and meta["weibo_aux"].get("used"): - console.print("[dim](已附加微博智搜辅助线索)[/dim]") - elif isinstance(meta.get("weibo_aux"), dict) and str(meta["weibo_aux"].get("error") or "").strip(): - console.print(f"[dim](微博智搜未取到片段:{meta['weibo_aux'].get('error')})[/dim]") - score_meta = meta.get("value_score") if isinstance(meta.get("value_score"), dict) else {} - if score_meta: - total = score_meta.get("total", 0) - threshold = score_meta.get("threshold", 0) - is_high = bool(score_meta.get("is_high_value")) - badge = "[green]高价值[/green]" if is_high else "[dim]普通[/dim]" - console.print(f"[dim](回答价值评分:{total}/{threshold},判定:{badge})[/dim]") - candidate_meta = meta.get("output_candidate") if isinstance(meta.get("output_candidate"), dict) else {} - if candidate_meta.get("created"): - console.print( - f"[green]✓[/green] [bold]已回流候选[/bold]:" - f"[cyan]{candidate_meta.get('path', '')}[/cyan]" - ) - elif str(candidate_meta.get("error") or "").strip(): - console.print(f"[yellow](候选回流失败:{candidate_meta.get('error')})[/yellow]") + if meta.get("wiki_route") != "neo4j_qa_only": + neo_pf = meta.get("neo4j_prefetch") if isinstance(meta.get("neo4j_prefetch"), dict) else {} + if neo_pf.get("used"): + console.print(f"[dim](已优先合并 Neo4j 图谱证据:{neo_pf.get('rows', 0)} 条三元组)[/dim]") + elif str(neo_pf.get("error") or "").strip(): + console.print(f"[dim](Neo4j 预取跳过:{neo_pf.get('error')})[/dim]") + if meta.get("wiki_route") != "neo4j_qa_only": + if isinstance(meta.get("weibo_aux"), dict) and meta["weibo_aux"].get("used"): + console.print("[dim](已附加微博智搜辅助线索)[/dim]") + elif isinstance(meta.get("weibo_aux"), dict) and str(meta["weibo_aux"].get("error") or "").strip(): + console.print(f"[dim](微博智搜未取到片段:{meta['weibo_aux'].get('error')})[/dim]") + if meta.get("wiki_route") != "neo4j_qa_only": + score_meta = meta.get("value_score") if isinstance(meta.get("value_score"), dict) else {} + if score_meta: + total = score_meta.get("total", 0) + threshold = score_meta.get("threshold", 0) + is_high = bool(score_meta.get("is_high_value")) + badge = "[green]高价值[/green]" if is_high else "[dim]普通[/dim]" + console.print(f"[dim](回答价值评分:{total}/{threshold},判定:{badge})[/dim]") + if meta.get("wiki_route") != "neo4j_qa_only": + candidate_meta = meta.get("output_candidate") if isinstance(meta.get("output_candidate"), dict) else {} + if candidate_meta.get("created"): + console.print( + f"[green]✓[/green] [bold]已回流候选[/bold]:" + f"[cyan]{candidate_meta.get('path', '')}[/cyan]" + ) + elif str(candidate_meta.get("error") or "").strip(): + console.print(f"[yellow](候选回流失败:{candidate_meta.get('error')})[/yellow]") console.print("\n[bold]Wiki Answer[/bold]") console.print(result.get("answer", "")) sources = result.get("sources") if isinstance(result.get("sources"), list) else [] @@ -167,12 +187,17 @@ def run_wiki_command(raw_query: str | None = None) -> None: continue title = str(item.get("title", "")) path = str(item.get("path", "")) + path_disp = str(item.get("path_display") or "").strip() snippet = str(item.get("snippet", "")) score = item.get("score", 0) abs_path = str(item.get("abs_path") or "").strip() file_uri = str(item.get("file_uri") or "").strip() console.print(f"{i}. {title} [dim](score={score})[/dim]") - console.print(f" 相对路径: {path}") + show_path = path_disp or path + if path_disp and path_disp != path: + console.print(f" 路径: {show_path} [dim](完整: {path})[/dim]") + else: + console.print(f" 路径: {show_path}") if abs_path: console.print(f" [cyan]本地文件: {abs_path}[/cyan]") if file_uri: diff --git a/config/model.yaml b/config/model.yaml index fab35ed9..4dca51ab 100644 --- a/config/model.yaml +++ b/config/model.yaml @@ -13,34 +13,26 @@ # deepseek:DEEPSEEK_APIKEY # kimi:KIMI_APIKEY -# Base URL 配置(可选,用于自定义端点) -# qwen 默认: https://dashscope.aliyuncs.com/compatible-mode/v1 -# coding plan: https://coding.dashscope.aliyuncs.com/v1 - # 主流程模型:作为 ReAct Agent 的底座 main: - provider: qwen - model: qwen3.5-plus - api_key_env: QWEN_APIKEY - base_url: https://coding.dashscope.aliyuncs.com/v1 + provider: deepseek + model: deepseek-chat + api_key_env: DEEPSEEK_APIKEY # 工具模型:用于各种工具调用(搜索词提取、时间线分析、情感分析等) tools: provider: qwen - model: qwen3.5-plus + model: qwen-plus api_key_env: QWEN_APIKEY - base_url: https://coding.dashscope.aliyuncs.com/v1 # HTML报告生成模型:生成舆情分析HTML报告 report: provider: qwen - model: qwen3.5-plus + model: qwen-plus api_key_env: QWEN_APIKEY - base_url: https://coding.dashscope.aliyuncs.com/v1 -# 情感分析打分模型:analysis_sentiment 默认优先读取该 profile;缺省时自动回退到 tools +# 情感分析打分模型:对清洗后的内容列做 0-10 分情感评分(默认 qwen-plus) sentiment: provider: qwen - model: qwen3.5-plus - api_key_env: QWEN_APIKEY - base_url: https://coding.dashscope.aliyuncs.com/v1 + model: qwen-plus + api_key_env: QWEN_APIKEY \ No newline at end of file diff --git a/docs/frontend_next.md b/docs/frontend_next.md new file mode 100644 index 00000000..0447510e --- /dev/null +++ b/docs/frontend_next.md @@ -0,0 +1,56 @@ +# Next.js 前端集成说明 + +Sona 现在保留 Python CLI / Streamlit 入口,并在仓库根新增独立 `frontend/` 作为 Next.js App Router 前端。 + +## 启动 + +终端 1:启动 Python API。 + +```bash +sona serve --host 127.0.0.1 --port 8765 +``` + +终端 2:启动 Next.js。 + +```bash +npm --prefix frontend install +npm --prefix frontend run dev +``` + +前端默认访问 `http://127.0.0.1:3000`。Next.js BFF 读取 `SONA_API_BASE`,默认代理到 `http://127.0.0.1:8765`。可在 `frontend/.env.local` 中覆盖: + +```env +SONA_API_BASE=http://127.0.0.1:8765 +``` + +## `/` 指令迁移 + +前端不直接驱动 CLI 终端循环,而是调用 FastAPI 专用端点: + +| 前端指令 | API | +| --- | --- | +| `/new` | `POST /v1/chat/sessions` | +| `/memory` | `GET /v1/chat/sessions` | +| 普通输入 | `POST /v1/chat/sessions/{task_id}/messages:stream` | +| `/event ` | `POST /v1/analyze-event` | +| `/wiki ` | `POST /v1/wiki/query` | +| `/wiki-approve ` | `POST /v1/wiki/approve` | +| `/case ` | `POST /v1/cases/search` | +| `/hot [config]` | `POST /v1/hot/run` | +| `/monitor list` | `GET /v1/monitor/topics` | +| `/monitor demo` | `POST /v1/monitor/demo` | +| `/monitor create 名称\|领域\|关键词1,关键词2` | `POST /v1/monitor/topics` | +| `/monitor status ` | `GET /v1/monitor/topics/{topic_id}/status` | +| `/monitor report [daily\|weekly]` | `POST /v1/monitor/topics/{topic_id}/report` | +| `/models` | `GET /v1/models` | +| `/tools` | `GET /v1/tools` | + +`/set`、`/compress`、`/clear`、`/exit` 属于 CLI 管理或生命周期指令,前端 v1 不在聊天输入中执行。 + +## 验证 + +```bash +.\.venv\Scripts\python.exe -m pytest tests/contracts/test_frontend_api.py +npm --prefix frontend run lint +npm --prefix frontend run build +``` diff --git a/docs/gui_decision.md b/docs/gui_decision.md index 95e93889..7c0abe9c 100644 --- a/docs/gui_decision.md +++ b/docs/gui_decision.md @@ -22,7 +22,7 @@ ## 目录结构(合并后) ``` -streamlit_app.py # 首页:导航 + API 探活 +streamlit_app.py # 仪表盘:API 探活、最近任务、工作流速查 streamlit_legacy_chat.py # 原单文件会话逻辑(供子页引用) pages/ 01_任务状态.py diff --git a/docs/session_contract.md b/docs/session_contract.md new file mode 100644 index 00000000..92448ab4 --- /dev/null +++ b/docs/session_contract.md @@ -0,0 +1,166 @@ +# Session Contract v4 + +The web/API runtime stores conversations in a SQLite database file. The default +path is `data/sona.db`, and `SONA_SESSION_DB_PATH` can override it. The same DB +file also hosts official LangGraph SQLite checkpoint tables created by +`langgraph-checkpoint-sqlite`; application code must not reimplement or mutate +those internal checkpoint tables directly. + +Legacy JSON files under `memory/STM` are migration/archive input only. They must +not drive the frontend recent-topic list or `/v1/tasks` in production. + +## Design Rules + +- `messages` is the only source used to rebuild model context. +- `session_id` is the public API identifier and maps 1:1 to LangGraph `thread_id`. +- LangGraph checkpoint state is addressed with `thread_id`, `checkpoint_ns`, and + `checkpoint_id`; Sona stores those references on `agent_runs`. +- `agent_events` stores UI/runtime stream events and must never be injected into model context. +- Tool context must be round-trippable: every `tool` message with `tool_call_id` must have a preceding assistant message with a matching `tool_calls[].id`. +- Tool messages follow a Lobe-compatible shape: assistant messages carry `tools`; tool result messages link back with `parent_id` and `plugin`. +- Session files are schema-versioned and normalized on load/save. +- Legacy STM files are imported with `scripts/migrate_stm_to_sqlite.py`; smoke/test sessions are skipped by default. +- `agent_events`, `agent_runs`, and `artifacts` are not model context. They are UI/runtime/audit data. +- Cross-session long-term memory is stored as namespace/key JSON records in + `memory_store`, not in `messages` or checkpoint history. +- SQLite is the portable single-backend deployment target. Multi-backend + concurrent writers should move the repository/checkpointer implementation to + Postgres without changing the frontend contract. + +## Session Shape + +```json +{ + "schema_version": 3, + "task_id": "uuid", + "thread_id": "uuid", + "status": "active", + "created_at": "iso", + "updated_at": "iso", + "description": "display title", + "initial_query": "first user query", + "messages": [], + "agent_events": [], + "harness_memory": { + "session_prefs": {}, + "notes": {} + }, + "token_usage": { + "total_tokens": 0, + "prompt_tokens": 0, + "completion_tokens": 0, + "steps": [] + } +} +``` + +`schema_version` remains the session envelope version. Application database +migrations are tracked separately in `schema_migrations`. + +SQLite stores that envelope across normalized tables: + +- `sessions`: thread metadata, title, timestamps, status, soft delete. +- `conversation_items`: ordered model-context item stream, append-first with stable item IDs. +- `agent_runs`: execution lifecycle plus LangGraph checkpoint references. +- `agent_events`: UI/audit stream, excluded from model context. +- `artifacts`: reports, traces, sandbox/object paths. +- `session_prefs`: session-scoped harness memory. +- `memory_store`: long-term JSON memory organized by namespace and key. +- `schema_migrations`: application database schema version. + +LangGraph owns its official checkpoint tables in the same SQLite file. Current +known table names include `checkpoints` and `writes`, but application code must +interact with them through `SqliteSaver`. + +## Message Contract + +Allowed roles: `user`, `assistant`, `tool`, `system`. + +Common fields: + +- `id`: stable message id. +- `role`: one allowed role. +- `content`: string. +- `timestamp`: ISO timestamp. +- `metadata`: optional object. + +Assistant tool-call fields: + +```json +{ + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "tool-call-id", + "name": "tool_name", + "args": {}, + "type": "function", + "function": { + "name": "tool_name", + "arguments": "{}" + }, + "identifier": "sona", + "apiName": "tool_name" + } + ], + "tools": [ + { + "id": "tool-call-id", + "name": "tool_name", + "args": {}, + "type": "function", + "function": { + "name": "tool_name", + "arguments": "{}" + }, + "identifier": "sona", + "apiName": "tool_name" + } + ] +} +``` + +Tool result fields: + +```json +{ + "role": "tool", + "parent_id": "assistant-message-id", + "tool_name": "tool_name", + "tool_call_id": "tool-call-id", + "content": "tool output", + "plugin": { + "toolCallId": "tool-call-id", + "apiName": "tool_name", + "identifier": "sona", + "type": "default", + "arguments": "", + "state": { "status": "success" } + } +} +``` + +## Lobe Mapping + +LobeChat stores tool calls in the assistant message `tools` JSONB field. Tool result messages are ordinary messages with `role: "tool"` and a `parentId` relationship to the assistant tool-call message. Extra tool metadata is kept in a plugin side table keyed by the tool result message and `toolCallId`. + +This repository keeps a file-based equivalent: + +- `assistant.tool_calls`: LangChain/OpenAI-compatible context recovery. +- `assistant.tools`: Lobe-compatible UI/tool payload. +- `tool.parent_id`: file-based equivalent of Lobe `parentId`. +- `tool.plugin`: file-based equivalent of Lobe `message_plugins`. + +The frontend can render this the Lobe way by grouping tool messages under `parent_id`, while model context recovery keeps using `tool_calls` plus `tool_call_id`. + +## Migration + +Run: + +```powershell +.\.venv\Scripts\python.exe scripts\migrate_stm_to_sqlite.py --dry-run +.\.venv\Scripts\python.exe scripts\migrate_stm_to_sqlite.py --apply +``` + +The script leaves STM files untouched. Known smoke/test sessions are skipped unless `--include-test` is provided. diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 00000000..5872c3e9 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +SONA_API_BASE=http://127.0.0.1:8765 diff --git a/frontend/app/api/sona/[...path]/route.ts b/frontend/app/api/sona/[...path]/route.ts new file mode 100644 index 00000000..59f05c6a --- /dev/null +++ b/frontend/app/api/sona/[...path]/route.ts @@ -0,0 +1,59 @@ +const DEFAULT_SONA_API_BASE = 'http://127.0.0.1:8765'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +type RouteContext = { + params: Promise<{ path: string[] }>; +}; + +async function proxy(request: Request, context: RouteContext) { + const { path } = await context.params; + const base = (process.env.SONA_API_BASE || DEFAULT_SONA_API_BASE).replace(/\/+$/, ''); + const incoming = new URL(request.url); + const target = new URL(`${base}/${path.join('/')}`); + target.search = incoming.search; + + const headers = new Headers(request.headers); + headers.delete('host'); + headers.delete('connection'); + headers.delete('content-length'); + + const method = request.method.toUpperCase(); + const body = method === 'GET' || method === 'HEAD' ? undefined : await request.arrayBuffer(); + + try { + const upstream = await fetch(target, { + method, + headers, + body, + cache: 'no-store', + }); + + const responseHeaders = new Headers(upstream.headers); + responseHeaders.delete('content-encoding'); + responseHeaders.delete('transfer-encoding'); + responseHeaders.delete('content-length'); + + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: responseHeaders, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown proxy error'; + return Response.json( + { + detail: `Sona API unavailable: ${message}`, + apiBase: base, + }, + { status: 502 }, + ); + } +} + +export const GET = proxy; +export const POST = proxy; +export const PUT = proxy; +export const PATCH = proxy; +export const DELETE = proxy; diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 00000000..49cd6e2a --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,1396 @@ +:root { + --page: #f4f4f5; + --sidebar: #f7f7f8; + --surface: #ffffff; + --ink: #1f1f22; + --muted: #85858b; + --line: #e6e6e8; + --hover: #ececee; + --active: #e9e9eb; + --accent: #3f6872; + --accent-dark: #2f535c; + --accent-hover: #4d7882; + --accent-soft: #e8eef0; + --accent-tint: #f3f7f8; + --accent-line: #bccbd0; + --accent-shadow: rgba(47, 83, 92, 0.13); + --danger: #e5484d; + --shadow: 0 14px 38px rgba(20, 20, 20, 0.08); + --radius: 10px; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + margin: 0; + overflow: hidden; + background: var(--page); + color: var(--ink); +} + +body { + font-family: Arial, "Microsoft YaHei", sans-serif; + letter-spacing: 0; +} + +button, +input, +textarea { + font: inherit; +} + +.sonarShell { + display: flex; + height: 100svh; + overflow: hidden; + padding: 8px; + gap: 0; + background: var(--page); +} + +.sonaSideNav { + flex-shrink: 0; + height: 100%; +} + +.sonaSideNavSearch { + display: flex; + align-items: center; + gap: 10px; + margin: 4px 12px 8px; + padding: 0 10px; + min-height: 36px; + border-radius: var(--radius); + color: #8a8a90; +} + +.sonaSideNavSearch:focus-within, +.sonaSideNavSearch:hover { + background: var(--hover); +} + +.sonaSideNavSearch input { + width: 100%; + border: 0; + outline: 0; + background: transparent; + color: var(--ink); + font-size: 14px; +} + +.sonaSideNavSectionLink { + display: block; + width: calc(100% - 16px); + margin: 4px 8px 0; + padding: 6px 12px; + border: 0; + border-radius: var(--radius); + background: transparent; + color: #8a8a90; + cursor: pointer; + font-size: 13px; + text-align: left; +} + +.sonaSideNavSectionLink:hover { + background: var(--hover); + color: var(--ink); +} + +#lobe-ui-theme-app .sonaSideNav .ant-menu-item-selected, +#lobe-ui-theme-app .sonaSideNav .ant-menu-submenu-selected > .ant-menu-submenu-title { + background-color: var(--accent-soft) !important; + color: var(--accent-dark) !important; +} + +#lobe-ui-theme-app .sonaSideNav .ant-menu-item-selected .ant-menu-title-content, +#lobe-ui-theme-app .sonaSideNav .ant-menu-item-selected .ant-menu-item-icon, +#lobe-ui-theme-app .sonaSideNav .ant-menu-submenu-selected .ant-menu-title-content, +#lobe-ui-theme-app .sonaSideNav .ant-menu-submenu-selected .ant-menu-item-icon { + color: var(--accent-dark) !important; +} + +#lobe-ui-theme-app .sonaSideNav .ant-menu-item-active:not(.ant-menu-item-selected), +#lobe-ui-theme-app .sonaSideNav .ant-menu-submenu-title:hover { + background-color: var(--hover) !important; + color: var(--ink) !important; +} + +.headerActions button, +.headerTitle button, +.sidebarFoot button { + display: grid; + width: 32px; + height: 32px; + place-items: center; + border: 0; + border-radius: 8px; + background: transparent; + color: #8a8a90; + cursor: pointer; +} + +.headerActions button:hover, +.headerTitle button:hover, +.sidebarFoot button:hover { + background: var(--hover); + color: var(--ink); +} + +.agentIdentity { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; + height: 40px; + padding: 0 8px; + border: 0; + border-radius: var(--radius); + background: transparent; + color: var(--ink); + cursor: pointer; + text-align: left; +} + +.agentIdentity:hover { + background: var(--hover); +} + +.agentIdentity strong { + overflow: hidden; + flex: 1; + font-size: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sonaMark, +.sonaAvatar { + display: inline-grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 50%; + background: #fff; + box-shadow: inset 0 0 0 1px var(--line); + font-size: 20px; + line-height: 1; +} + +.sonaAvatar.user { + font-size: 14px; + font-weight: 600; + color: #4f4f57; +} + +.sonaMark.large { + width: 36px; + height: 36px; + font-size: 26px; +} + +.sidebarNav, +.sidebarSection, +.topicList { + display: grid; + gap: 4px; +} + +.sidebarNav button, +.homeItem, +.sectionTitle { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-height: 40px; + padding: 0 12px; + border: 0; + border-radius: var(--radius); + background: transparent; + color: #696970; + cursor: pointer; + font-size: 15px; + text-align: left; +} + +.sidebarNav button:hover, +.homeItem:hover, +.sectionTitle:hover, +.topicItem:hover { + background: var(--hover); + color: var(--ink); +} + +.statusDot { + display: block; + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; +} + +.statusDot.ok { + color: #1a7f37; +} + +.statusDot.bad { + color: var(--danger); +} + +.searchBox { + display: flex; + align-items: center; + gap: 12px; + min-height: 40px; + padding: 0 12px; + border-radius: var(--radius); + color: #8a8a90; +} + +.searchBox:focus-within, +.searchBox:hover { + background: var(--hover); +} + +.searchBox input { + width: 100%; + border: 0; + outline: 0; + background: transparent; + color: var(--ink); + font-size: 15px; +} + +.sectionTitle { + justify-content: flex-start; + min-height: 32px; + color: #8a8a90; + font-size: 13px; +} + +.sectionTitle span { + margin-left: 0; +} + +.topicList { + min-height: 0; + overflow: auto; + padding-top: 6px; +} + +.topicItem { + display: grid; + grid-template-columns: 22px minmax(0, 1fr) 28px; + gap: 8px; + align-items: center; + width: 100%; + min-height: 42px; + padding: 0 8px 0 12px; + border: 0; + border-radius: var(--radius); + background: transparent; + color: var(--ink); + cursor: pointer; + text-align: left; +} + +.topicItem > span:first-child { + color: #8a8a90; + font-size: 18px; +} + +.topicItem strong { + overflow: hidden; + font-size: 14px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.topicMenu { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border-radius: 8px; + color: #8a8a90; +} + +.topicMenu:hover { + background: #dedee1; + color: var(--ink); +} + +.sonaFooterMenu { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border-radius: 8px; + color: #8a8a90; + cursor: pointer; +} + +.sonaFooterMenu:hover { + background: var(--hover); + color: var(--ink); +} + +.emptyTopics { + margin: 8px 12px; + color: var(--muted); + font-size: 13px; +} + +.sidebarFoot { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: auto; + padding: 4px 8px 0; + color: var(--muted); + font-size: 12px; +} + +.ok { + color: #1a7f37; +} + +.bad, +.errorText { + color: var(--danger); +} + +.errorText { + margin: 0 10px; + font-size: 12px; + line-height: 1.45; +} + +.conversationPane { + display: flex; + min-width: 0; + min-height: 0; + flex: 1; + height: 100%; + overflow: hidden; + flex-direction: column; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface); +} + +.sonarHeader { + display: flex; + align-items: center; + justify-content: space-between; + height: 48px; + flex: none; + padding: 0 12px 0 18px; + border-bottom: 1px solid var(--line); + background: rgba(255, 255, 255, 0.96); +} + +.headerTitle { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.headerTitle strong { + overflow: hidden; + max-width: 420px; + font-size: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.headerActions { + display: flex; + align-items: center; + gap: 6px; +} + +.headerTokenUsage { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.workspaceBody { + display: flex; + min-height: 0; + flex: 1; + overflow: hidden; +} + +.conversationStage { + display: flex; + position: relative; + min-width: 0; + min-height: 0; + flex: 1; + flex-direction: column; + overflow: hidden; +} + +.conversationScroll { + min-height: 0; + flex: 1; + overflow: auto; +} + +.conversationScroll.isLoading .chatCanvas, +.conversationScroll.isLoading .homeCanvas { + min-height: 240px; +} + +.sessionLoadingBar { + display: flex; + justify-content: center; + padding: 24px 0 8px; +} + +.homeCanvas, +.chatCanvas, +.settingsCanvas { + width: min(100%, 1084px); + margin: 0 auto; + padding: 48px 24px 24px; +} + +.chatCanvas { + width: min(100%, 1080px); + padding-top: 76px; +} + +.settingsCanvas { + width: min(100%, 1024px); + padding-top: 32px; +} + +.chatCanvas .lobe-flex { + max-width: 100%; +} + +.chatCanvas time { + color: var(--muted); + font-size: 12px; +} + +.sonaChatThread { + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; +} + +.sonaChatRow { + display: flex; + flex-direction: column; + gap: 8px; + max-width: min(100%, 760px); +} + +.sonaChatRow.isUser { + align-self: flex-end; + align-items: flex-end; +} + +.sonaChatRow.isAssistant { + align-self: flex-start; + align-items: flex-start; +} + +.sonaChatRowHead { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 12px; +} + +.sonaChatRowHead strong { + color: var(--ink); + font-size: 13px; + font-weight: 600; +} + +.sonaChatRowAvatar { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border-radius: 50%; + background: #fff; + box-shadow: inset 0 0 0 1px var(--line); + font-size: 14px; + line-height: 1; +} + +.sonaChatBubble { + width: 100%; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 14px; + background: #fff; + color: var(--ink); + font-size: 15px; + line-height: 1.65; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.sonaChatRow.isUser .sonaChatBubble { + border-color: var(--accent-line); + background: var(--accent-soft); +} + +.sonaChatRow.isAssistant .sonaChatBubble { + background: #fafafa; +} + +.sonaChatRow.isAssistant .sonaChatBubble.sonaChatAnswer { + white-space: normal; +} + +.sonaChatAnswer { + margin-top: 0; +} + +.sonaChatMarkdown { + width: 100%; +} + +.sonaChatAnswerBody { + display: grid; + gap: 10px; +} + +.sonaAssistantMessageContent { + display: grid; + width: 100%; + gap: 10px; +} + +.sonaAssistantInlinePanels { + display: grid; + width: min(100%, 760px); + margin-top: 10px; + gap: 8px; +} + +.sonaChatAnswerText { + margin: 0; + white-space: pre-wrap; +} + +.sonaReportFileCard { + width: min(100%, 420px); + border-radius: 12px; + background: #fff; + cursor: pointer; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease, + transform 0.15s ease; +} + +.sonaReportFileCard:hover { + border-color: var(--accent-line); + box-shadow: 0 8px 24px var(--accent-shadow); + transform: translateY(-1px); +} + +.sonaReportFileCard:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.sonaAgentSteps { + width: 100%; + background: #f8f9fb; +} + +.sonaAgentStepsTitle { + display: flex; + align-items: center; + gap: 8px; + color: #6b6b73; + font-size: 13px; + font-weight: 600; +} + +.sonaAgentStepsCount { + padding: 2px 8px; + border-radius: 999px; + background: #ececf0; + color: #4f4f57; + font-size: 11px; + font-weight: 500; +} + +.sonaAgentStepsList { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.sonaAgentStep { + padding: 8px 10px; + border-radius: 8px; + background: #fff; + border: 1px solid #ececf0; +} + +.sonaAgentStepTitle { + display: block; + margin-bottom: 6px; + color: #4f4f57; + font-size: 12px; + font-weight: 600; +} + +.sonaAgentStep pre { + margin: 0; + max-height: 200px; + overflow: auto; + padding: 8px; + border-radius: 6px; + background: #17181c; + color: #e8edf5; + font-size: 11px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +.sonaAgentStep--thinking .sonaAgentStepTitle { + color: var(--accent-dark); +} + +.sonaAgentStep--tool .sonaAgentStepTitle { + color: #b45309; +} + +.sonaAgentStep--approval .sonaAgentStepTitle { + color: #b45309; +} + +.sonaApprovalPanel { + width: 100%; + padding: 12px; + border: 1px solid #f0d39a; + border-radius: 8px; + background: #fffaf0; +} + +.sonaApprovalHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.sonaApprovalHeader strong { + color: #78350f; + font-size: 14px; +} + +.sonaApprovalHeader span { + flex-shrink: 0; + color: #9a5b12; + font-size: 12px; +} + +.sonaApprovalPanel pre { + max-height: 260px; + margin: 0; + overflow: auto; + color: #2f2f36; + font-size: 12px; + line-height: 1.55; + white-space: pre-wrap; +} + +.sonaApprovalSummary { + display: grid; + gap: 8px; + margin: 0; +} + +.sonaApprovalSummaryRow { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.72); +} + +.sonaApprovalSummaryRow dt { + color: #9a5b12; + font-size: 12px; + font-weight: 700; +} + +.sonaApprovalSummaryRow dd { + margin: 0; + color: #2f2f36; + font-size: 12px; + line-height: 1.55; + word-break: break-word; +} + +.sonaApprovalActions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.sonaAgentStep--workflow .sonaAgentStepTitle { + color: var(--accent-dark); +} + +.sonaResearchPanel { + width: 100%; + background: var(--accent-tint); +} + +.sonaResearchHeader { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + color: var(--accent-dark); +} + +.sonaResearchHeader strong { + font-size: 14px; +} + +.sonaResearchHeader span { + margin-left: auto; + color: #667085; + font-size: 12px; +} + +.sonaResearchList { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.sonaResearchItem { + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + gap: 10px; + align-items: start; +} + +.sonaResearchDot { + width: 8px; + height: 8px; + margin-top: 6px; + border-radius: 999px; + background: var(--accent); +} + +.sonaResearchLoading { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + min-height: 20px; + color: var(--accent-dark); +} + +.sonaResearchItem--completed .sonaResearchDot { + background: #16a34a; +} + +.sonaResearchItem--failed .sonaResearchDot { + background: var(--danger); +} + +.sonaResearchTitle { + display: flex; + gap: 8px; + align-items: center; + min-width: 0; +} + +.sonaResearchTitle strong { + min-width: 0; + color: #2f3645; + font-size: 13px; +} + +.sonaResearchTitle span { + flex-shrink: 0; + padding: 1px 7px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent-dark); + font-size: 11px; +} + +.sonaResearchItem p { + margin: 2px 0 0; + color: #667085; + font-size: 12px; + line-height: 1.5; +} + +#lobe-ui-theme-app .sonaChatBubble p { + margin: 0 0 0.75em; + line-height: 1.65; +} + +#lobe-ui-theme-app .sonaChatBubble p:last-child { + margin-bottom: 0; +} + +.homeHero { + max-width: 1084px; + margin: 0 auto; +} + +.settingsPage { + display: grid; + gap: 28px; + width: 100%; +} + +.settingsHead { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.settingsHead > span { + display: grid; + width: 32px; + height: 32px; + flex: none; + place-items: center; + border-radius: 8px; + background: #f1f3f6; + color: #3c4658; +} + +.settingsHead h1 { + margin: 0; + font-size: 24px; + line-height: 1.25; +} + +.settingsHead p { + margin: 6px 0 0; + color: var(--muted); + font-size: 14px; + line-height: 1.55; +} + +.settingsList { + display: grid; + gap: 0; + border-top: 1px solid var(--line); +} + +.settingsRow { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 18px; + min-height: 66px; + padding: 14px 0; + border-bottom: 1px solid var(--line); +} + +.settingsRow div { + display: grid; + gap: 5px; + min-width: 0; +} + +.settingsRow strong { + color: var(--ink); + font-size: 14px; +} + +.settingsRow span { + color: var(--muted); + font-size: 13px; + line-height: 1.45; +} + +.heroAgent { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 20px; +} + +.heroAgent strong { + font-size: 22px; +} + +.homeHero h1, +.homeHero p { + margin: 0; + font-size: 18px; + line-height: 1.7; +} + +.homeHero p { + margin-bottom: 28px; +} + +.homeShortcuts { + display: flex; + justify-content: center; + gap: 10px; + margin: 18px 0 64px; +} + +.homeShortcuts button, +.briefHead button { + border: 1px solid var(--line); + border-radius: 999px; + background: #fff; + color: #59595f; + cursor: pointer; +} + +.homeShortcuts button { + height: 36px; + padding: 0 16px; + box-shadow: 0 2px 8px rgba(20, 20, 20, 0.04); +} + +.homeShortcuts button:hover, +.briefHead button:hover { + border-color: var(--accent-line); + color: var(--accent); +} + +.briefList { + display: grid; + gap: 10px; + width: min(100%, 1084px); +} + +.briefHead { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; + color: #77777d; +} + +.briefHead strong { + font-size: 16px; +} + +.briefHead button { + height: 30px; + padding: 0 12px; +} + +.briefList > button { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + min-height: 112px; + padding: 18px; + border: 1px solid var(--line); + border-radius: 14px; + background: #fff; + color: var(--ink); + cursor: pointer; + text-align: left; +} + +.briefList > button:hover { + border-color: #d2d8e5; + box-shadow: 0 8px 28px rgba(20, 20, 20, 0.06); +} + +.briefList span { + font-size: 18px; + font-weight: 600; +} + +.briefList small { + grid-column: 2 / -1; + color: var(--muted); + font-size: 14px; +} + +.chatEmpty { + display: grid; + min-height: 360px; + place-items: center; + align-content: center; + gap: 10px; +} + +.chatEmpty strong { + font-size: 20px; +} + +.chatEmptyHint { + max-width: 420px; + margin: 0; + color: var(--muted); + font-size: 14px; + line-height: 1.6; + text-align: center; +} + +.chatEmptyError { + max-width: 420px; + margin: 8px 0 0; + color: var(--danger); + font-size: 13px; + line-height: 1.5; + text-align: center; +} + +.streaming { + width: fit-content; + margin: 8px 0 0 50px; + color: var(--muted); +} + +/* LobeHub 主界面输入框 — https://app.lobehub.com/ */ +.sonaChatComposerDock { + z-index: 3; + flex: none; + padding: 0 16px 20px; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0.94) 28%, + #fff 100% + ); +} + +.sonaChatComposerShell { + width: min(960px, 100%); +} + +.sonaChatComposerCard { + display: flex; + flex-direction: column; + min-height: 150px; + overflow: hidden; + border: 1px solid #e3e3e8; + border-radius: 16px; + background: #fff; + box-shadow: + 0 0 0 1px rgba(15, 23, 42, 0.02), + 0 10px 36px rgba(15, 23, 42, 0.08); +} + +.sonaChatComposerEditor { + width: 100%; + min-height: 120px; + flex: 1; + padding: 16px 16px 8px; + border: 0; + outline: 0; + resize: none; + background: transparent; + color: var(--ink); + font-size: 15px; + line-height: 1.6; +} + +.sonaChatComposerEditor::placeholder { + color: #9b9ba4; +} + +.sonaChatComposerEditor:focus { + outline: none; +} + +.sonaChatComposerFoot { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; + padding: 4px 12px 12px; +} + +.sonaChatComposerFootLeft { + min-width: 0; + flex: 1; +} + +.sonaChatComposerFootRight { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 10px; +} + +.sonaChatComposerHint { + color: #9b9ba4; +} + +.sonaSendButton.ant-btn { + --send-button-size: 32px; + width: 32px; + height: 32px; + min-width: 32px; + padding: 0; + border-radius: 50%; + box-shadow: none; +} + +.sonaSendButton.ant-btn .ant-btn-icon { + display: grid; + place-items: center; + font-size: 16px; +} + +.sonaSendButton.ant-btn:not(:disabled):hover { + filter: brightness(1.04); +} + +.sonaToolChip.ant-btn { + height: 28px; + padding-inline: 10px; + border: 1px solid #e8e8ec; + border-radius: 8px; + background: #fafafa; + color: #4f4f57; + font-size: 12px; +} + +.sonaToolChip.ant-btn:hover { + border-color: #d8dee8; + background: #f3f5f8 !important; + color: #111116 !important; +} + +@media (max-width: 720px) { + .sonaChatComposerHint { + display: none; + } + + .sonaChatComposerFoot { + flex-wrap: wrap; + } +} + +.topicPanel { + width: 360px; + min-width: 360px; + height: 100%; + overflow: auto; + border-left: 1px solid var(--line); + background: #fafafa; + transition: width 180ms ease, min-width 180ms ease, opacity 160ms ease; +} + +.topicPanel.closed { + width: 0; + min-width: 0; + opacity: 0; + overflow: hidden; + pointer-events: none; +} + +.topicPanel > .ant-segmented { + margin: 12px; + width: calc(100% - 24px); +} + +.panel { + padding: 4px 16px 16px; +} + +.panelTitle, +.quickActions { + display: flex; + align-items: center; + gap: 8px; +} + +.taskList, +.listPanel { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.quickActions { + margin-top: 12px; +} + +.taskList button { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px; + width: 100%; + padding: 10px; + border: 1px solid var(--line); + border-radius: 10px; + background: #fff; + color: var(--ink); + cursor: pointer; + text-align: left; +} + +.taskList button:hover { + background: var(--hover); +} + +.taskList button small { + grid-column: 1 / -1; + line-height: 1.45; +} + +.muted, +.lineItem small { + color: var(--muted); + font-size: 12px; +} + +.reportFrame { + width: 100%; + height: 460px; + margin-top: 14px; + border: 1px solid var(--line); + border-radius: 10px; + background: white; +} + +.lineItem { + display: grid; + gap: 4px; + padding: 10px 0; + border-bottom: 1px solid var(--line); +} + +.profileSummary { + display: flex; + gap: 8px; + margin: 12px 0 4px; +} + +.profileSummary span { + padding: 4px 9px; + border: 1px solid var(--line); + border-radius: 999px; + background: #fff; + color: var(--muted); + font-size: 12px; +} + +.lineItem span { + color: var(--muted); + font-size: 13px; + line-height: 1.45; +} + +.activityPanel { + display: grid; + gap: 8px; + margin: 12px 16px 0; + padding-bottom: 12px; + border-bottom: 1px solid var(--line); +} + +.activityItem { + display: grid; + gap: 4px; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.activityItem strong { + color: var(--ink); + font-size: 13px; +} + +.activityItem span { + max-height: 84px; + overflow: auto; + white-space: pre-wrap; +} + +.jsonBox { + min-height: 260px; + overflow: auto; + padding: 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: #17181c; + color: #edf2f7; + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; +} + +@media (max-width: 1180px) { + .topicPanel { + position: absolute; + z-index: 30; + right: 8px; + top: 56px; + height: calc(100svh - 64px); + border: 1px solid var(--line); + border-radius: 10px; + box-shadow: -12px 0 30px rgba(15, 23, 42, 0.12); + } +} + +@media (max-width: 760px) { + .sonarShell { + padding: 0; + } + + .sonaSideNav { + display: none; + } + + .conversationPane { + border: 0; + border-radius: 0; + } + + .homeCanvas, + .chatCanvas { + padding: 44px 12px 16px; + } + + .sonaChatComposerDock { + padding-inline: 10px; + padding-block-end: 12px; + } + + .topicPanel.open { + right: 0; + width: min(100vw, 360px); + min-width: min(100vw, 360px); + } +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 00000000..7ed0ce8e --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,27 @@ +import 'antd/dist/reset.css'; +import './globals.css'; + +import { AntdRegistry } from '@ant-design/nextjs-registry'; +import type { Metadata } from 'next'; +import { Providers } from './providers'; + +export const metadata: Metadata = { + title: 'Sona', + description: 'Sona chat workspace', +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + {children} + + + + ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 00000000..7135c299 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from 'react'; +import { SonaWorkspace } from '@/features/workspace/SonaWorkspace'; + +export default function Home() { + return ( + + + + ); +} diff --git a/frontend/app/providers.tsx b/frontend/app/providers.tsx new file mode 100644 index 00000000..6b593f93 --- /dev/null +++ b/frontend/app/providers.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { ConfigProvider, ThemeProvider } from '@lobehub/ui'; +import { motion } from 'motion/react'; +import { useSyncExternalStore } from 'react'; + +const subscribe = () => () => {}; +const getClientSnapshot = () => true; +const getServerSnapshot = () => false; + +/** + * Lobe ThemeProvider uses antd-style/emotion; SSR markup order differs from the client. + * Mount theme only after hydration to avoid mismatch (see ant-app vs emotion global style). + * ConfigProvider (motion) must wrap the tree on every render — Lobe UI components require it. + */ +export function Providers({ children }: Readonly<{ children: React.ReactNode }>) { + const themeReady = useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot); + + if (!themeReady) { + return ( +
+ {children} +
+ ); + } + + return ( + + + {children} + + + ); +} diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 00000000..c84747a5 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,6 @@ +import nextVitals from 'eslint-config-next/core-web-vitals'; +import nextTs from 'eslint-config-next/typescript'; + +const eslintConfig = [...nextVitals, ...nextTs]; + +export default eslintConfig; diff --git a/frontend/features/workspace/SonaChatAnswer.tsx b/frontend/features/workspace/SonaChatAnswer.tsx new file mode 100644 index 00000000..349c05a6 --- /dev/null +++ b/frontend/features/workspace/SonaChatAnswer.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { parseAnswerSegments, hasReportRefs } from '@/features/workspace/reportRefs'; +import { SonaMarkdown } from '@/features/workspace/SonaMarkdown'; +import { SonaReportFileCard } from '@/features/workspace/SonaReportFileCard'; + +type SonaChatAnswerProps = { + answer: string; + currentSessionId?: string; + onOpenReport?: (sessionId: string) => void; + streaming?: boolean; +}; + +export function SonaChatAnswer({ + answer, + currentSessionId, + onOpenReport, + streaming, +}: SonaChatAnswerProps) { + if (!hasReportRefs(answer)) { + return ; + } + + const segments = parseAnswerSegments(answer, currentSessionId); + + return ( +
+ {segments.map((segment, index) => { + if (segment.kind === 'text') { + return ( + + ); + } + return ( + + ); + })} +
+ ); +} + diff --git a/frontend/features/workspace/SonaChatComposer.tsx b/frontend/features/workspace/SonaChatComposer.tsx new file mode 100644 index 00000000..9edad7f2 --- /dev/null +++ b/frontend/features/workspace/SonaChatComposer.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { useMemo, useRef } from 'react'; +import { Dropdown } from 'antd'; +import { Button, Flexbox, Text, Tooltip } from '@lobehub/ui'; +import { ArrowBigUp, ChevronDown, CornerDownLeft, SendHorizontal } from 'lucide-react'; +import type { ComposerCommand } from '@/types/sona'; +import { sortComposerCommands } from './sonaToolUi'; + +const VISIBLE_COMMAND_COUNT = 5; +const COMPOSER_MIN_HEIGHT = 120; + +type SonaChatComposerProps = { + busy?: boolean; + commands: ComposerCommand[]; + onCommand: (command: ComposerCommand) => void; + onInput: (value: string) => void; + onSend: () => void; + value: string; +}; + +function CommandStrip({ + commands, + onCommand, +}: Readonly<{ + commands: ComposerCommand[]; + onCommand: (command: ComposerCommand) => void; +}>) { + const sorted = useMemo(() => sortComposerCommands(commands), [commands]); + const visible = sorted.slice(0, VISIBLE_COMMAND_COUNT); + const overflow = sorted.slice(VISIBLE_COMMAND_COUNT); + + if (!sorted.length) { + return null; + } + + return ( + + {visible.map((command) => ( + + + + ))} + {overflow.length ? ( + ({ + key: command.id, + label: command.label, + title: command.description, + onClick: () => onCommand(command), + })), + }} + trigger={['click']} + > + + + ) : null} + + ); +} + +function ComposerHint() { + return ( + + + + 发送 + + + / + + + + + + + Ctrl+Enter 换行 + + + ); +} + +export function SonaChatComposer({ + busy = false, + commands, + onCommand, + onInput, + onSend, + value, +}: Readonly) { + const isComposingRef = useRef(false); + const canSend = busy || Boolean(value.trim()); + + function handleKeyDown(event: React.KeyboardEvent) { + if (isComposingRef.current) return; + if (event.key !== 'Enter') return; + if (event.ctrlKey || event.metaKey) return; + if (event.shiftKey) return; + event.preventDefault(); + if (canSend) onSend(); + } + + return ( +
+ +
+