feat: implement Generative UI — Agent Workspace + workspace_card SSE protocol

## Backend
- Add workspace_card SSE event protocol: {id, name, props, merge}
- Add _extract_llm_text / _maybe_emit_workspace_card helpers in chat.py
- Refactor all tools to dual-output format: {llm_text, ui: {name, props}}
  - kb_search → KnowledgeResultCard
  - ticket_list/detail → TicketSummaryCard / TicketDetailCard
  - web_search → SearchResultCard
  - generate_document → DocumentResultCard
  - sandbox_run → SandboxResultCard
- Update SYSTEM_PROMPT: instruct LLM not to repeat tool data (UI shows it)

## Frontend
- Three-column layout: sidebar + chat + Agent Workspace (360px right panel)
- WorkspaceSession state model with ActivityNode + WorkspaceCard
- New components/workspace/: AgentWorkspace, ActivityTimeline, WorkspaceCardRenderer
- 6 card components: Knowledge/Ticket/Search/Document/Sandbox/ErrorCard
- GeminiChat: workspace state management, SSE routing for workspace_card events
- GeminiMessage: replace TracePanel with lightweight activity summary line
- lib/api.ts: add WorkspaceSession/ActivityNode/WorkspaceCard types

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-10 02:57:09 +08:00
co-authored by Claude Sonnet 4.6
parent 1ad75a3602
commit 0a2b339550
35 changed files with 7199 additions and 86 deletions
+34 -1
View File
@@ -136,6 +136,9 @@ async def _run_graph_stream(
"duration_ms": duration_ms,
"ts": ts,
})
card = _maybe_emit_workspace_card(call_id, output_str)
if card:
yield _sse(card)
elif kind == "on_chain_start":
chain_name = event.get("name", "")
@@ -238,6 +241,35 @@ def _sse(data: dict) -> bytes:
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n".encode("utf-8")
def _extract_llm_text(output_str: str) -> str:
"""If output is dual-format JSON, extract the llm_text field; otherwise return as-is."""
try:
data = json.loads(output_str)
if isinstance(data, dict) and "llm_text" in data:
return str(data["llm_text"])
except Exception:
pass
return output_str
def _maybe_emit_workspace_card(call_id: str | None, output_str: str) -> dict | None:
"""Try to extract workspace_card data from dual-format tool output."""
try:
data = json.loads(output_str)
ui = data.get("ui")
if ui and isinstance(ui, dict) and "name" in ui and "props" in ui:
return {
"type": "workspace_card",
"id": call_id or str(uuid.uuid4()),
"name": ui["name"],
"props": ui["props"],
"merge": False,
}
except Exception:
pass
return None
_TOOL_TITLES: dict[str, str] = {
"kb_search": "检索知识库",
"ticket_list": "查询工单列表",
@@ -259,7 +291,7 @@ _ERROR_KEYWORDS = (
def _is_tool_error(output: str) -> bool:
lo = output.lower()
lo = _extract_llm_text(output).lower()
return any(kw in lo for kw in _ERROR_KEYWORDS)
@@ -302,6 +334,7 @@ def _summarize_input(tool_name: str, inp: dict | str) -> str:
def _summarize_output(tool_name: str, output: str) -> str:
output = _extract_llm_text(output)
if not output or output.strip() in ("", "(no output)"):
return "无结果"
match tool_name:
+12
View File
@@ -37,6 +37,18 @@ SYSTEM_PROMPT = (
"- Use search_web first to find relevant pages\n"
"- Use read_url to get full content from the most relevant URLs (1-3 max)\n"
"- Use sort_by_relevance to rank results if you have many documents\n"
"\n## Tool result presentation rules\n"
"The frontend renders tool results as structured UI cards — users see the full data visually.\n"
"After calling a tool, your text response MUST follow these rules:\n"
"- DO NOT repeat or list the raw data from the tool result\n"
"- Write only: one brief sentence summarizing what was found + any analytical insight\n"
"- The tool result JSON contains a 'llm_text' field — use that as your starting point\n"
"Specifically:\n"
"- kb_search: State whether the knowledge base answered the question. Do NOT re-list document names or content.\n"
"- ticket_list/ticket_detail: Give a one-line status distribution insight. Do NOT enumerate each ticket.\n"
"- generate_document: Confirm document type and that it is ready. The download link is shown in UI.\n"
"- sandbox_run: State whether execution succeeded. If error, explain the root cause briefly.\n"
"- web_search: State whether useful sources were found. Do NOT re-list URLs or snippets.\n"
)
# Cache compiled graphs to avoid re-creation on every request.
+11 -4
View File
@@ -7,6 +7,7 @@ Doc Creator Agent API to generate the document.
from __future__ import annotations
import json as _json
import logging
import re
@@ -60,17 +61,23 @@ async def generate_document(prompt: str) -> str:
data = resp.json()
except httpx.HTTPStatusError as exc:
logger.error("Doc Creator API error: %s %s", exc.response.status_code, exc.response.text)
return f"Document generation failed (HTTP {exc.response.status_code}). Please try again later."
return _json.dumps({"llm_text": f"文档生成失败(HTTP {exc.response.status_code}),请稍后重试。", "ui": {"name": "ErrorCard", "props": {"error": f"HTTP {exc.response.status_code}", "tool": "generate_document"}}}, ensure_ascii=False)
except Exception:
logger.exception("Doc Creator Agent request failed")
return "Document generation failed due to a network error. Please try again later."
return _json.dumps({"llm_text": "文档生成失败(网络错误),请稍后重试。", "ui": {"name": "ErrorCard", "props": {"error": "网络错误", "tool": "generate_document"}}}, ensure_ascii=False)
title = data.get("title", "Document")
file_url = data.get("file_url", "")
if not file_url:
return "Document was generated but no download link was returned."
return _json.dumps({"llm_text": "文档已生成但未返回下载链接。", "ui": {"name": "ErrorCard", "props": {"error": "未返回下载链接", "tool": "generate_document"}}}, ensure_ascii=False)
type_labels = {"ppt": "PPT", "table": "Excel", "word": "Word"}
label = type_labels.get(output_type, "Document")
return f"[{label}] {title}\nDownload: {file_url}"
return _json.dumps({
"llm_text": f"文档《{title}》已生成({label}),可点击下载。",
"ui": {
"name": "DocumentResultCard",
"props": {"title": title, "type": label, "file_url": file_url},
},
}, ensure_ascii=False)
+17 -6
View File
@@ -8,6 +8,7 @@ a user-friendly message instead of crashing the ReAct agent loop.
from __future__ import annotations
import asyncio
import json as _json
import logging
import httpx
@@ -49,14 +50,17 @@ async def kb_search(query: str) -> str:
results = data.get("results", [])
if not results:
return "知识库中未找到相关内容。"
return _json.dumps({"llm_text": "知识库中未找到相关内容。", "ui": {"name": "ErrorCard", "props": {"error": "未找到相关内容", "tool": "kb_search"}}}, ensure_ascii=False)
results_for_ui = []
parts: list[str] = []
for r in results:
for r in results[:5]:
title = r.get("title", "Untitled")
content = r.get("content", "")
category = r.get("category", "")
# Truncate very long content to keep context manageable
snippet = content[:200] if content else ""
results_for_ui.append({"title": title, "category": category, "snippet": snippet})
# Full content for LLM
if len(content) > 1500:
content = content[:1500] + "..."
header = f"[{title}]"
@@ -64,7 +68,14 @@ async def kb_search(query: str) -> str:
header += f" ({category})"
parts.append(f"{header}\n{content}")
return "\n\n---\n\n".join(parts)
llm_text = f"知识库检索到 {len(results_for_ui)} 条结果。" + "\n\n---\n\n".join(parts)
return _json.dumps({
"llm_text": llm_text,
"ui": {
"name": "KnowledgeResultCard",
"props": {"query": query, "total": len(results_for_ui), "results": results_for_ui},
},
}, ensure_ascii=False)
except httpx.ReadTimeout:
if attempt == 0:
@@ -72,8 +83,8 @@ async def kb_search(query: str) -> str:
await asyncio.sleep(2)
continue
logger.error("KB Agent read timeout after retry")
return "知识库检索超时,请稍后重试。"
return _json.dumps({"llm_text": "知识库检索超时,请稍后重试。", "ui": {"name": "ErrorCard", "props": {"error": "请求超时", "tool": "kb_search"}}}, ensure_ascii=False)
except Exception as exc:
logger.error("KB Agent search failed: %s", exc, exc_info=True)
return f"知识库检索出错:{exc}"
return _json.dumps({"llm_text": f"知识库检索出错:{exc}", "ui": {"name": "ErrorCard", "props": {"error": str(exc), "tool": "kb_search"}}}, ensure_ascii=False)
+18 -10
View File
@@ -6,7 +6,9 @@ stdout/stderr, and destroys the sandbox on completion.
from __future__ import annotations
import json as _json
import logging
import time as _time
from langchain_core.tools import tool
@@ -45,6 +47,7 @@ async def sandbox_run(code: str, language: str = "python") -> str:
sandbox = None
try:
t_start = _time.time()
sandbox = daytona.create()
if language in ("bash", "shell", "sh"):
@@ -52,20 +55,25 @@ async def sandbox_run(code: str, language: str = "python") -> str:
else:
response = sandbox.process.code_run(code, timeout=30)
exit_code = getattr(response, "exit_code", None)
result = getattr(response, "result", str(response))
exit_code = getattr(response, "exit_code", None) or 0
result = getattr(response, "result", str(response)) or ""
duration_ms = int((_time.time() - t_start) * 1000)
# Truncate very long output
if len(result) > 10000:
result = result[:10000] + "\n... (output truncated)"
if exit_code and exit_code != 0:
return f"[Exit code: {exit_code}]\n{result}"
return result or "(no output)"
has_more = len(result) > 1000
stdout = result[:1000] if has_more else result
success = exit_code == 0
llm_text = f"代码执行{'成功' if success else '失败'}(exit code: {exit_code})。"
return _json.dumps({
"llm_text": llm_text,
"ui": {
"name": "SandboxResultCard",
"props": {"language": language, "exit_code": exit_code, "stdout": stdout, "has_more": has_more, "duration_ms": duration_ms},
},
}, ensure_ascii=False)
except Exception as exc:
logger.exception("Sandbox execution failed")
return f"Sandbox execution failed: {exc}"
return _json.dumps({"llm_text": f"Sandbox execution failed: {exc}", "ui": {"name": "ErrorCard", "props": {"error": str(exc), "tool": "sandbox_run"}}}, ensure_ascii=False)
finally:
if sandbox is not None:
try:
+44 -27
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
import asyncio
import contextvars
import json as _json
import logging
from typing import Any
@@ -123,27 +124,22 @@ def _format_result(item: dict[str, Any], idx: int, content_override: str = "") -
return f"[{idx}] {title}\nURL: {url}\n{description}"
async def _search_flash(query: str) -> str:
"""Fast search: top 3 results, no Reader, no Rerank."""
async def _search_flash(query: str) -> list[dict[str, Any]]:
"""Fast search: top 3 results, no Reader, no Rerank. Returns raw result list."""
strategy = SEARCH_STRATEGIES["flash"]
results = await _jina_search(query, top=strategy["top"], timeout=strategy["timeout"])
if not results:
return "No search results found."
parts = [_format_result(r, i + 1) for i, r in enumerate(results)]
return "\n\n---\n\n".join(parts)
return results
async def _search_pro(query: str) -> str:
"""Deep search: top 10 results, concurrent Reader, Rerank to top 5."""
async def _search_pro(query: str) -> list[dict[str, Any]]:
"""Deep search: top 10 results, concurrent Reader, Rerank to top 5. Returns raw result list."""
strategy = SEARCH_STRATEGIES["pro"]
timeout = strategy["timeout"]
# Step 1: Search
results = await _jina_search(query, top=strategy["top"], timeout=timeout)
if not results:
return "No search results found."
return []
# Step 2: Concurrent Reader — fetch full text for all results
read_tasks = [
@@ -166,7 +162,6 @@ async def _search_pro(query: str) -> str:
documents_for_rerank = []
for r in results:
doc_text = r.get("_full_text", "") or r.get("description", "") or r.get("content", "")
# Keep rerank input manageable
documents_for_rerank.append(doc_text[:3000] if doc_text else r.get("title", ""))
reranked = await _jina_rerank(
@@ -176,23 +171,43 @@ async def _search_pro(query: str) -> str:
timeout=timeout,
)
# Build output in reranked order
# Return in reranked order
if reranked:
parts = []
for rank, rr in enumerate(reranked, 1):
ordered = []
for rr in reranked:
idx = rr.get("index", 0)
if idx < len(results):
r = results[idx]
content = r.get("_full_text", "") or r.get("description", "")
parts.append(_format_result(r, rank, content_override=content))
return "\n\n---\n\n".join(parts)
ordered.append(results[idx])
return ordered
else:
# Fallback: return first 5 without reranking
parts = [
_format_result(r, i + 1, content_override=r.get("_full_text", ""))
for i, r in enumerate(results[:5])
]
return "\n\n---\n\n".join(parts)
return results[:5]
def _build_dual_output(query: str, raw_results: list[dict[str, Any]]) -> str:
"""Build dual-format JSON from raw Jina search results."""
if not raw_results:
return _json.dumps({"llm_text": "未找到相关搜索结果。", "ui": {"name": "ErrorCard", "props": {"error": "未找到搜索结果", "tool": "web_search"}}}, ensure_ascii=False)
results_for_ui = []
parts = []
for i, r in enumerate(raw_results):
title = r.get("title", "Untitled")
url = r.get("url", "")
description = r.get("_full_text", "") or r.get("description", r.get("content", ""))
results_for_ui.append({"title": title, "url": url, "snippet": description[:200]})
# LLM format
if len(description) > 2000:
description = description[:2000] + "..."
parts.append(_format_result(r, i + 1, content_override=description))
llm_text = f"网络搜索找到 {len(results_for_ui)} 条相关来源。\n\n" + "\n\n---\n\n".join(parts)
return _json.dumps({
"llm_text": llm_text,
"ui": {
"name": "SearchResultCard",
"props": {"query": query, "total": len(results_for_ui), "results": results_for_ui},
},
}, ensure_ascii=False)
# ── The LangChain tool exposed to the ReAct agent ──────────────────────
@@ -231,9 +246,11 @@ async def web_search(query: str) -> str:
# Execute search based on model strategy
if model == "pro":
result = await _search_pro(query)
raw_results = await _search_pro(query)
else:
result = await _search_flash(query)
raw_results = await _search_flash(query)
result = _build_dual_output(query, raw_results)
# Cache the result
await set_cached_search(query, model, result)
+37 -16
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import json as _json
import httpx
from langchain_core.tools import tool
@@ -57,9 +59,10 @@ async def ticket_list(page: int = 1, page_size: int = 20) -> str:
tickets = data.get("tickets", [])
if not tickets:
return "No tickets found."
return _json.dumps({"llm_text": "未找到工单。", "ui": {"name": "ErrorCard", "props": {"error": "未找到工单", "tool": "ticket_list"}}}, ensure_ascii=False)
lines: list[str] = []
stats: dict[str, int] = {}
tickets_for_ui = []
for t in tickets:
ticket_id = t.get("ticketNumber", t.get("id", "?"))
title = t.get("description", "")[:80]
@@ -67,12 +70,18 @@ async def ticket_list(page: int = 1, page_size: int = 20) -> str:
priority = _map_priority(t.get("priority", ""))
created = t.get("createdAt", "")[:10]
customer = t.get("customer", {}).get("name", "Unknown")
lines.append(
f"- [{ticket_id}] {title} | status={status} priority={priority} "
f"customer={customer} created={created}"
)
stats[status] = stats.get(status, 0) + 1
tickets_for_ui.append({"id": str(ticket_id), "title": title, "status": status, "priority": priority, "customer": customer, "created": created})
return f"Found {len(tickets)} tickets:\n" + "\n".join(lines)
high_priority = sum(1 for t in tickets_for_ui if t["priority"] in ("P0", "P1"))
llm_text = f"查询到 {len(tickets)} 条工单,P0/P1 共 {high_priority} 条。"
return _json.dumps({
"llm_text": llm_text,
"ui": {
"name": "TicketSummaryCard",
"props": {"total": len(tickets), "tickets": tickets_for_ui, "stats": stats},
},
}, ensure_ascii=False)
@tool
@@ -105,13 +114,25 @@ async def ticket_detail(ticket_id: str) -> str:
created = t.get("createdAt", "")
sla = t.get("slaDeadline", "")
return (
f"Ticket: {ticket_number}\n"
f"Status: {status} | Priority: {priority}\n"
f"Platform: {platform} | Model: {model_used}\n"
f"Customer: {customer_name} | Account: {account}\n"
f"Engineer: {engineer}\n"
f"Created: {created} | SLA: {sla}\n"
f"Description: {description}\n"
f"Request Example: {request_example}"
llm_text = (
f"工单 {ticket_number}:状态={status},优先级={priority},"
f"客户={customer_name},工程师={engineer}。"
f"描述:{description[:200]}"
)
return _json.dumps({
"llm_text": llm_text,
"ui": {
"name": "TicketDetailCard",
"props": {
"id": str(ticket_number),
"title": description[:80],
"status": status,
"priority": priority,
"platform": platform,
"customer": customer_name,
"engineer": engineer,
"created": created[:10] if created else "",
"description": description[:500],
},
},
}, ensure_ascii=False)
+28
View File
@@ -0,0 +1,28 @@
# CoT (Chain of Thought) 实现方案
> 目标:当用户选择 Auto 或 Pro 模式时,实时展示模型推理过程(正在做什么)
## 文档结构
- [architecture.md](./architecture.md) — 整体架构与数据流
- [backend.md](./backend.md) — 后端实现方案(Python/LangGraph)
- [frontend.md](./frontend.md) — 前端实现方案(Next.js/React)
## 核心结论
**方案选择:Prompt 标签 + 流式状态机解析**
| 方案 | 描述 | 结论 |
|------|------|------|
| A. 原生 reasoning tokens | Azure o1/o3 的 reasoning_content | 备用升级路径,gpt-5.4 支持情况待验证 |
| B. Prompt 标签解析 ✅ | 注入 `<think>` 标签,流式解析 | **默认实现**,兼容所有 GPT 模型 |
| C. LangGraph 多节点 | 专门的 thinking 节点 | 双倍延迟/成本,不采用 |
## Auto vs Pro 差异
| 维度 | Flash | Auto | Pro |
|------|-------|------|-----|
| CoT | 关闭 | 轻量(关键决策点) | 完整(每步详细推理) |
| max_tokens | 500 | 2048 | 4096 |
| temperature | 0.2 | 0.3 | 0.3 |
| 前端 thinking UI | 无 | 3个脉冲点,done后消失 | 可折叠 thinking block |
+103
View File
@@ -0,0 +1,103 @@
# CoT 整体架构与数据流
## 数据流
```
用户选择 Auto / Pro
↓
后端注入 CoT system prompt
(要求模型输出 <think>推理</think>最终回答)
↓
LLM 流式输出:
"<think>让我先分析...</think>最终回答内容..."
↓
ThinkTagParser 状态机实时解析
IN_THINK 状态 → thinking token
IN_ANSWER 状态 → answer token
↓
SSE 事件分流:
{"type": "thinking", "content": "让我先分析..."}
{"type": "token", "content": "最终回答..."}
{"type": "tool_start", "tool": "kb_search"}
{"type": "tool_end", "tool": "kb_search"}
{"type": "done"}
↓
前端 SSE 解析器接收事件
↓
ThinkingBlock 组件(Auto: 脉冲动画 / Pro: 折叠块)
ToolCallIndicator 组件(工具调用进度)
消息内容正常渲染
```
## SSE 事件协议
```jsonc
// CoT 推理过程(流式分块,Auto/Pro 模式)
{"type": "thinking", "content": "让我先分析这个问题..."}
// 最终回答(流式分块,所有模式)
{"type": "token", "content": "根据分析,答案是..."}
// 工具调用开始
{"type": "tool_start", "tool": "kb_search"}
// 工具调用结束
{"type": "tool_end", "tool": "kb_search"}
// 流结束
{"type": "done"}
// 错误
{"type": "error", "content": "错误信息"}
```
## ThinkTagParser 状态机
```
feed("<think>")
INITIAL ─────────────────→ IN_THINK
│
feed(text) │ emit("thinking", text)
│
feed("</think>")
↓
IN_ANSWER
│
feed(text) │ emit("token", text)
↓
(done)
```
**边界处理**:流式 token 可能在标签中间截断(如 `"<thi"` + `"nk>"`),需要 buffer 积累直到标签完整。
## ReAct Agent 中的 CoT
使用 `create_react_agent` 时,LangGraph 多轮调用 LLM:
```
轮1: LLM 决定调用工具
→ tool_start 事件告知前端"我在做什么"
→ LLM 通常不输出 text content(tool_call 模式)
工具执行中...
→ tool_end 事件
轮N(最终): LLM 基于工具结果生成回答
→ <think> 标签内是完整推理
→ thinking + token 事件流出
```
结论:`tool_start`/`tool_end` 本身已经是"正在做什么"的可视化,最终轮的 `<think>` 提供深度推理展示。
## 原生 Reasoning Tokens(升级路径)
Azure OpenAI 从 API version `2024-12-17` 起,o1/o3/o4-mini 系列支持:
- 请求参数:`reasoning_effort: "low" | "medium" | "high"`
- 响应字段:`message.additional_kwargs.reasoning_content`
如确认 `gpt-5.4` 部署支持,只需在 `_get_llm()` 中添加:
```python
if thinking and native_reasoning_supported:
kwargs["reasoning_effort"] = "medium" if model == "auto" else "high"
```
SSE 层无需任何改动,因为 `reasoning_content` 和 `<think>` 标签都走同一个 `thinking` 事件。
+286
View File
@@ -0,0 +1,286 @@
# CoT 后端实现方案
## 改动文件清单
| 文件 | 改动性质 |
|------|---------|
| `app/schemas.py` | `model` 字段加入 `auto` 选项 |
| `app/graph/builder.py` | MODEL_PARAMS 加 `auto`;CoT prompt 注入逻辑 |
| `app/graph/nodes.py` | `call_model` 注入含 CoT 的 system prompt |
| `app/api/chat.py` | `ThinkTagParser` + `_stream_response` 扩展 |
| `app/graph/thinking.py` | **新建**:`ThinkTagParser` 状态机 |
---
## 1. schemas.py
```python
# 原来
model: str = Field(default="flash", pattern="^(flash|pro)$")
# 改为
model: str = Field(default="flash", pattern="^(flash|auto|pro)$")
```
---
## 2. graph/thinking.py(新建)
```python
"""Streaming <think> tag parser for CoT extraction."""
from __future__ import annotations
class ThinkTagParser:
"""Parse streaming tokens and separate <think>...</think> from answer.
Yields (event_type, content) tuples where event_type is
"thinking" (inside <think> block) or "token" (final answer).
Handles token boundary issues: tags may arrive split across tokens.
"""
_OPEN_TAG = "<think>"
_CLOSE_TAG = "</think>"
def __init__(self) -> None:
self._buffer = ""
self._in_think = False
self._think_done = False
def feed(self, token: str) -> list[tuple[str, str]]:
"""Feed one streaming token. Returns list of (type, content) pairs."""
self._buffer += token
events: list[tuple[str, str]] = []
while self._buffer:
if not self._in_think and not self._think_done:
# Waiting for <think>
idx = self._buffer.find(self._OPEN_TAG)
if idx == -1:
# No opening tag found; check for partial tag at end
cut = self._safe_cut(self._buffer, "<")
if cut > 0:
events.append(("token", self._buffer[:cut]))
self._buffer = self._buffer[cut:]
elif cut == 0:
break # Entire buffer might be a partial tag
else:
events.append(("token", self._buffer))
self._buffer = ""
else:
# Flush any content before <think> as token
if idx > 0:
events.append(("token", self._buffer[:idx]))
self._buffer = self._buffer[idx + len(self._OPEN_TAG):]
self._in_think = True
elif self._in_think:
# Inside <think>, looking for </think>
idx = self._buffer.find(self._CLOSE_TAG)
if idx == -1:
cut = self._safe_cut(self._buffer, "<")
if cut > 0:
events.append(("thinking", self._buffer[:cut]))
self._buffer = self._buffer[cut:]
elif cut == 0:
break
else:
events.append(("thinking", self._buffer))
self._buffer = ""
else:
if idx > 0:
events.append(("thinking", self._buffer[:idx]))
self._buffer = self._buffer[idx + len(self._CLOSE_TAG):]
self._in_think = False
self._think_done = True
else:
# After </think>: everything is the final answer
events.append(("token", self._buffer))
self._buffer = ""
return events
def flush(self) -> list[tuple[str, str]]:
"""Flush remaining buffer at stream end."""
if not self._buffer:
return []
kind = "thinking" if self._in_think else "token"
result = [(kind, self._buffer)]
self._buffer = ""
return result
@staticmethod
def _safe_cut(text: str, char: str) -> int:
"""Return index of last occurrence of char, or -1 if not found.
Returns 0 if char is at position 0 (entire string is potential tag).
"""
idx = text.rfind(char)
return idx # -1 if not found, 0 if at start
```
---
## 3. graph/builder.py
```python
# Model parameter presets
MODEL_PARAMS: dict[str, dict] = {
"flash": {"max_tokens": 500, "temperature": 0.2, "thinking": False},
"auto": {"max_tokens": 2048, "temperature": 0.3, "thinking": True},
"pro": {"max_tokens": 4096, "temperature": 0.3, "thinking": True},
}
SYSTEM_PROMPT_BASE = (
"You are SOC Assistant, an enterprise AI assistant. "
"You help users with knowledge base queries, ticket management, "
"and general questions. Always respond in the same language the user uses. "
"Be concise, accurate, and helpful."
)
# Auto: concise thinking (key decisions only)
COT_PROMPT_AUTO = (
"\n\nBefore answering, briefly think through the key decision points "
"inside <think> tags, then give your final answer outside the tags.\n"
"Format:\n<think>\n[key reasoning steps]\n</think>\n\n[final answer]"
)
# Pro: full step-by-step reasoning
COT_PROMPT_PRO = (
"\n\nBefore answering, think through the problem step by step inside "
"<think> tags. Analyze the question thoroughly, consider multiple "
"approaches, then provide your final answer outside the tags.\n"
"Format:\n<think>\n[detailed step-by-step reasoning]\n</think>\n\n[final answer]"
)
def _get_system_prompt(model: str) -> str:
if model == "auto":
return SYSTEM_PROMPT_BASE + COT_PROMPT_AUTO
if model == "pro":
return SYSTEM_PROMPT_BASE + COT_PROMPT_PRO
return SYSTEM_PROMPT_BASE
def _get_llm(model: str) -> AzureChatOpenAI:
params = MODEL_PARAMS.get(model, MODEL_PARAMS["flash"])
return AzureChatOpenAI(
azure_endpoint=settings.azure_openai_endpoint,
api_key=settings.azure_openai_api_key,
api_version=settings.azure_openai_api_version,
azure_deployment=settings.azure_openai_deployment,
max_tokens=params["max_tokens"],
temperature=params["temperature"],
streaming=True,
)
```
---
## 4. graph/nodes.py
```python
from langchain_core.messages import SystemMessage
from app.graph.builder import _get_llm, _get_system_prompt
async def call_model(state: ChatState) -> dict:
model = state.get("model", "flash")
llm = _get_llm(model)
messages = list(state["messages"])
system_content = _get_system_prompt(model)
messages.insert(0, SystemMessage(content=system_content))
response = await llm.ainvoke(messages)
return {"messages": [response]}
```
---
## 5. api/chat.py(核心改动)
在 `_stream_response` 中集成 `ThinkTagParser`:
```python
from app.graph.thinking import ThinkTagParser
async def _stream_response(request: ChatRequest) -> AsyncIterator[bytes]:
# ... 现有初始化代码 ...
thinking_enabled = request.model in ("auto", "pro")
parser = ThinkTagParser() if thinking_enabled else None
try:
async for event in graph.astream_events(input_data, config=config, version="v2"):
kind = event.get("event", "")
if kind == "on_chat_model_stream":
chunk = event.get("data", {}).get("chunk")
if chunk and hasattr(chunk, "content") and chunk.content:
if isinstance(chunk.content, str):
raw_token = chunk.content
if parser:
for evt_type, evt_content in parser.feed(raw_token):
if not evt_content:
continue
if evt_type == "thinking":
full_thinking.append(evt_content)
else:
full_content.append(evt_content)
sse = json.dumps(
{"type": evt_type, "content": evt_content},
ensure_ascii=False,
)
yield f"data: {sse}\n\n".encode("utf-8")
else:
# Flash mode: direct token passthrough
full_content.append(raw_token)
sse = json.dumps(
{"type": "token", "content": raw_token},
ensure_ascii=False,
)
yield f"data: {sse}\n\n".encode("utf-8")
elif kind == "on_tool_start":
tool_name = event.get("name", "unknown")
sse = json.dumps({"type": "tool_start", "tool": tool_name}, ensure_ascii=False)
yield f"data: {sse}\n\n".encode("utf-8")
elif kind == "on_tool_end":
tool_name = event.get("name", "unknown")
sse = json.dumps({"type": "tool_end", "tool": tool_name}, ensure_ascii=False)
yield f"data: {sse}\n\n".encode("utf-8")
except Exception as exc:
# ... 现有错误处理 ...
pass
finally:
# Flush parser buffer
if parser:
for evt_type, evt_content in parser.flush():
if evt_content:
sse = json.dumps({"type": evt_type, "content": evt_content}, ensure_ascii=False)
yield f"data: {sse}\n\n".encode("utf-8")
ai_content = "".join(full_content)
if ai_content:
await _persist_ai_message(request.conversation_id, ai_content)
done_data = json.dumps({"type": "done"})
yield f"data: {done_data}\n\n".encode("utf-8")
```
---
## 实施顺序
1. `app/schemas.py` — 加 `auto`
2. `app/graph/thinking.py` — 新建 `ThinkTagParser`
3. `app/graph/builder.py` — MODEL_PARAMS + prompt 函数
4. `app/graph/nodes.py` — 注入 system prompt
5. `app/api/chat.py` — 集成 parser + 新 SSE 事件
+480
View File
@@ -0,0 +1,480 @@
# Auto / Pro 模式下的 CoT 可视化方案
## 目标
在不暴露模型原始 Chain-of-Thought(CoT)的前提下,让用户能够直观看到:
- 当前系统正在做什么
- 是否进入了工具调用
- 调用了什么工具
- 工具调用的大致输入/输出摘要
- 当前步骤耗时与状态
- 最终答案是如何逐步形成的
本方案的核心不是“展示原始 CoT”,而是展示一层**结构化执行轨迹(reasoning trace / agent activity trace)**。
---
## 为什么不建议直接展示原始 CoT
### 风险
直接展示模型原始 CoT 会带来以下问题:
1. **可能泄露系统提示词、工具策略、内部规则**
2. **推理内容冗长、不稳定、不适合用户阅读**
3. **不同模型对 CoT 的输出风格差异很大,难以统一前端体验**
4. **可能包含错误中间判断,影响用户信任**
5. **在 Auto / Pro 模式中,原始推理链可能过于技术化,用户看不懂**
### 更合适的做法
把原始 CoT 转换成可控的、结构化的、面向用户的“执行过程摘要”,只暴露:
- 当前阶段
- 是否进入工具调用
- 工具名称
- 参数摘要
- 返回摘要
- 当前状态
- 耗时
---
## 推荐产品形态
建议把“CoT 展示”做成三层结构。
### 第一层:状态条(简版)
适合默认展示给所有用户。
示例:
- 分析问题
- 选择工具
- 调用知识库
- 调用工单系统
- 整理答案
- 已完成
这一层只表达“进度感”和“正在做什么”,不暴露细节。
### 第二层:事件时间线(中版)
适合 Auto 模式下点击展开查看。
每条事件包含:
- 时间点
- 步骤名称
- 工具名称(如有)
- 状态:进行中 / 成功 / 失败 / 重试
- 耗时
示例:
1. 分析用户问题
2. 判断需要查询知识库
3. 调用 `kb_search`
4. 知识库返回 3 条结果
5. 调用 `ticket_list`
6. 返回最近 5 条工单
7. 基于结果生成最终回复
### 第三层:可展开详情(详版)
适合 Pro 模式。
每一步可展开查看:
- 阶段说明
- 工具输入摘要
- 工具输出摘要
- 错误信息(如有)
- 重试信息(如有)
- 耗时
- 当前步骤说明
注意:这里依然不直接暴露原始 CoT 文本,只显示**受控摘要**。
---
## Auto / Pro 两种模式的建议差异
### Auto 模式
推荐默认展示“简版执行轨迹”:
- 正在分析问题
- 已调用知识库
- 已调用工单系统
- 正在整理答案
特点:
- 信息量少
- 不打扰主聊天体验
- 用户可以看到系统不是“黑箱”
- 适合普通用户
### Pro 模式
推荐展示“详细执行轨迹”:
- 当前阶段
- 工具名
- 入参摘要
- 返回摘要
- 耗时
- 失败/重试信息
- 最终归纳步骤
特点:
- 更像开发者/高级用户视图
- 更适合调试、排障、验收
- 有助于建立系统透明度
---
## 后端事件流设计建议
如果当前后端已经有:
- `on_tool_start`
- `on_tool_end`
那已经具备基础条件。
建议在 SSE / Stream 事件中统一补齐以下事件类型。
### 1. reasoning 事件
用于表示阶段性思考摘要。
```json
{
"type": "reasoning",
"stage": "分析问题",
"message": "正在判断是否需要外部工具"
}
```
### 2. tool_start 事件
```json
{
"type": "tool_start",
"tool": "kb_search",
"title": "调用知识库",
"input_summary": "查询关键词:产品规划"
}
```
### 3. tool_end 事件
```json
{
"type": "tool_end",
"tool": "kb_search",
"title": "知识库返回结果",
"output_summary": "命中 3 条知识库记录",
"duration_ms": 842,
"status": "success"
}
```
### 4. tool_error 事件
```json
{
"type": "tool_error",
"tool": "ticket_list",
"title": "工单系统调用失败",
"error_summary": "请求超时",
"duration_ms": 3000,
"status": "error"
}
```
### 5. status 事件
```json
{
"type": "status",
"stage": "整理答案",
"message": "正在结合上下文生成最终回复"
}
```
### 6. final 事件
```json
{
"type": "final",
"message": "最终回复内容"
}
```
---
## 建议的数据结构
前端可以统一维护一个 trace item 数组,例如:
```ts
interface TraceItem {
id: string;
type: "reasoning" | "tool_start" | "tool_end" | "tool_error" | "status";
stage?: string;
tool?: string;
title: string;
message?: string;
inputSummary?: string;
outputSummary?: string;
errorSummary?: string;
status?: "running" | "success" | "error";
durationMs?: number;
createdAt: number;
}
```
这样前端很好做时间线、折叠面板、状态图标和耗时展示。
---
## 前端展示建议
### 组件拆分建议
建议新增三个层次的组件:
1. `TraceStatusBar`
- 展示当前阶段进度
- 适合默认显示
2. `TraceTimeline`
- 展示完整事件流
- 支持折叠/展开
3. `TraceTimelineItem`
- 每个步骤卡片
- 可显示工具、耗时、状态、摘要
### 展示样式建议
每个步骤卡片包含:
- 图标(思考 / 工具 / 成功 / 失败 / 生成中)
- 标题
- 副标题
- 时间 / 耗时
- 可展开详情
比如:
- `分析问题`
- `调用知识库`
- `知识库返回 3 条结果`
- `调用工单系统`
- `生成最终答案`
颜色建议:
- 蓝色:进行中
- 绿色:成功
- 红色:失败
- 灰色:普通状态/历史步骤
---
## 工具调用摘要生成建议
重点:不要把完整参数和完整返回直接丢给前端。
应在后端做摘要清洗,例如:
### 输入摘要
原始参数:
```json
{
"query": "搜索知识库中关于产品规划的内容",
"top_k": 5,
"filters": {"source": "internal"}
}
```
转换后:
- 查询关键词:产品规划
- 返回条数:5
- 数据源:internal
### 输出摘要
原始返回可能很长,不适合直接展示。
转换后:
- 命中 3 条知识库记录
- 返回最近 5 条工单
- 找到 2 条相关外部搜索结果
---
## 安全边界
必须明确哪些信息可以展示,哪些不能展示。
### 可以展示
- 阶段名
- 工具名
- 参数摘要
- 输出摘要
- 耗时
- 状态
- 错误摘要
### 不建议直接展示
- 原始 system prompt
- 原始思维链文本
- 完整工具参数(可能含敏感信息)
- 完整工具原始返回
- 内部路由策略细节
- 模型原始 scratchpad
---
## 和现有 SOC 项目的对接建议
根据当前项目情况,最适合的落地方式是:
### 后端
在现有流式聊天接口中,补充和规范以下事件:
- reasoning / status
- tool_start
- tool_end
- tool_error
- final
如果当前 `backend/app/api/chat.py` 已经处理:
- `on_tool_start`
- `on_tool_end`
那可以继续补一层“摘要映射”,把底层事件包装成前端可直接消费的 trace event。
### 前端
在聊天消息区域中,为 assistant message 增加一个“执行过程”区域:
- 默认折叠
- Auto 模式展示简版
- Pro 模式展示详版
推荐位置:
- 放在 assistant 回复消息上方或下方
- 与最终答案同属一个回答块
- 不要单独跳页面
---
## 推荐交互细节
### 方案 A:消息内嵌型(推荐)
最终回复卡片中增加:
- `查看执行过程`
- 展开后显示时间线
优点:
- 用户不用切换页面
- 和回答强绑定
- 最符合聊天产品体验
### 方案 B:侧边抽屉型
在 Pro 模式下点击“过程详情”后,右侧打开一个 trace drawer。
优点:
- 空间更大
- 适合展示更多细节
缺点:
- 实现更重
- 对当前 SOC 页面结构改动更大
结论:
- 先做消息内嵌型
- 后续再扩展侧边抽屉型
---
## MVP 最小落地版本
如果要快速上线,建议只做以下能力:
### 后端 MVP
输出 4 类事件:
- `status`
- `tool_start`
- `tool_end`
- `final`
### 前端 MVP
显示一个可折叠区域:
- `分析问题`
- `调用工具:知识库`
- `工具返回:3 条结果`
- `生成答案`
### Auto / Pro 区别
- Auto:默认折叠,只展示 1 行状态摘要
- Pro:默认展开详细时间线
这样最省改动,也能马上解决“用户看不到模型在做什么”的问题。
---
## 最终建议
一句话总结:
**不要展示原始 CoT,应该展示“结构化执行轨迹”。**
对 SOC 项目最合理的方案是:
1. 后端补齐 reasoning / tool / status 事件
2. 前端把这些事件渲染成时间线
3. Auto 展示简版,Pro 展示详版
4. 只展示摘要,不暴露原始推理链
这样既能满足“用户想知道模型做了什么”,又不会引入原始 CoT 暴露风险。
---
## 可以继续的下一步
后续如果需要,可以继续细化为三份落地文档:
1. 后端事件协议定义
2. 前端组件与交互设计
3. SOC 项目具体改造点(按文件路径拆解)
+417
View File
@@ -0,0 +1,417 @@
# CoT 前端实现方案
## 改动文件清单
| 文件 | 改动性质 |
|------|---------|
| `lib/api.ts` | 修复 SSE `event:` 行解析;扩展 event type |
| `components/gemini/GeminiMessage.tsx` | `Message` 类型扩展;插入 ThinkingBlock + ToolCallIndicator |
| `components/gemini/GeminiChat.tsx` | 流回调处理新事件;传 `selectedModel` 给 GeminiMessage |
| `components/gemini/ThinkingBlock.tsx` | **新建**:CoT 折叠展示组件 |
| `components/gemini/ToolCallIndicator.tsx` | **新建**:工具调用状态指示器 |
---
## 1. lib/api.ts — 类型扩展 + SSE 解析修复
### 类型扩展
```typescript
export interface ChatStreamEvent {
type: "token" | "tool_start" | "tool_end" | "done" | "error" | "thinking" | "answer";
content?: string;
tool?: string;
}
```
### SSE 解析器修复
后端标准 SSE 格式:
```
event: thinking
data: {"content": "让我先分析..."}
```
当前解析器只处理 `data:` 行,忽略 `event:` 行。需修复 buffer 解析逻辑:
```typescript
// 在 streamChat 的 buffer 解析循环中
let currentEventName: string | null = null;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("event: ")) {
currentEventName = trimmed.slice(7).trim();
continue;
}
if (trimmed.startsWith("data: ")) {
const json = trimmed.slice(6);
if (!json) { currentEventName = null; continue; }
try {
const parsed = JSON.parse(json);
// event: 行的类型覆盖 data 内的 type 字段
const event: ChatStreamEvent = currentEventName
? { ...parsed, type: currentEventName as ChatStreamEvent["type"] }
: parsed;
currentEventName = null;
onEvent(event);
if (event.type === "done") { onDone(); return; }
} catch {
currentEventName = null;
}
continue;
}
if (trimmed === "") {
currentEventName = null; // SSE 事件边界
}
}
```
**向后兼容**:如果后端仍发 `data: {"type": "token", ...}`(无 `event:` 行),走 `parsed.type` 分支,完全兼容。
---
## 2. GeminiMessage.tsx — 类型扩展
### Message 类型新增字段
```typescript
export interface Message {
id: string;
role: "user" | "assistant";
content: string;
timestamp?: Date;
attachments?: AttachmentData[];
// CoT 新增字段
thinking?: string; // 推理过程全文(流式追加)
thinkingDone?: boolean; // thinking 流是否结束
toolCalls?: ToolCallRecord[]; // 工具调用历史
}
export interface ToolCallRecord {
tool: string;
startedAt: number; // Date.now()
endedAt?: number;
}
```
### 渲染区插入新组件
在 assistant 消息内容区域顶部插入(现有内容渲染不变):
```tsx
interface GeminiMessageProps {
message: Message;
onRegenerate?: (id: string) => void;
selectedModel?: "flash" | "auto" | "pro"; // 新增可选参数
}
// 在 assistant 消息的 flex-1 div 内,内容渲染前插入:
{message.thinking !== undefined && selectedModel !== "flash" && (
<ThinkingBlock
content={message.thinking}
isDone={message.thinkingDone ?? false}
model={selectedModel ?? "auto"}
/>
)}
{message.toolCalls && message.toolCalls.length > 0 && (
<ToolCallIndicator toolCalls={message.toolCalls} />
)}
```
---
## 3. GeminiChat.tsx — 流事件处理
在 `streamChat` 的 `onEvent` 回调中新增:
```typescript
// thinking 事件:追加推理文本
if (event.type === "thinking" && event.content) {
setConversations((prev) =>
prev.map((c) => {
if (c.id !== streamConvId) return c;
return {
...c,
messages: c.messages.map((m) =>
m.id === aiMsgId
? { ...m, thinking: (m.thinking ?? "") + event.content! }
: m
),
};
})
);
}
// answer/token 事件:标记 thinkingDone,追加回答文本
if ((event.type === "token" || event.type === "answer") && event.content) {
setConversations((prev) =>
prev.map((c) => {
if (c.id !== streamConvId) return c;
return {
...c,
messages: c.messages.map((m) =>
m.id === aiMsgId
? {
...m,
thinkingDone: true,
content: m.content + event.content,
}
: m
),
};
})
);
}
// tool_start:记录工具调用开始
if (event.type === "tool_start" && event.tool) {
setConversations((prev) =>
prev.map((c) => {
if (c.id !== streamConvId) return c;
return {
...c,
messages: c.messages.map((m) =>
m.id === aiMsgId
? {
...m,
toolCalls: [
...(m.toolCalls ?? []),
{ tool: event.tool!, startedAt: Date.now() },
],
}
: m
),
};
})
);
}
// tool_end:记录工具调用结束时间
if (event.type === "tool_end" && event.tool) {
setConversations((prev) =>
prev.map((c) => {
if (c.id !== streamConvId) return c;
return {
...c,
messages: c.messages.map((m) => {
if (m.id !== aiMsgId) return m;
const calls = [...(m.toolCalls ?? [])];
const idx = calls
.map((tc, i) => ({ tc, i }))
.reverse()
.find(({ tc }) => tc.tool === event.tool && !tc.endedAt)?.i ?? -1;
if (idx !== -1) calls[idx] = { ...calls[idx], endedAt: Date.now() };
return { ...m, toolCalls: calls };
}),
};
})
);
}
```
在 `onDone` 回调中确保 `thinkingDone = true`:
```typescript
// done 时
setConversations((prev) =>
prev.map((c) => ({
...c,
messages: c.messages.map((m) =>
m.id === aiMsgId ? { ...m, thinkingDone: true } : m
),
}))
);
```
将 `selectedModel` 传给 `GeminiMessage`:
```tsx
<GeminiMessage
key={msg.id}
message={msg}
onRegenerate={msg.role === "assistant" ? handleRegenerate : undefined}
selectedModel={selectedModel}
/>
```
---
## 4. ThinkingBlock.tsx(新建)
```tsx
"use client";
import { useState } from "react";
import { ChevronDown, ChevronRight, Brain } from "lucide-react";
import { cn } from "@/lib/utils";
interface ThinkingBlockProps {
content: string;
isDone: boolean;
model: "auto" | "pro";
}
export function ThinkingBlock({ content, isDone, model }: ThinkingBlockProps) {
const [expanded, setExpanded] = useState(false);
// Auto 模式:只显示脉冲动画,thinking 结束后消失
if (model === "auto") {
if (isDone) return null;
return (
<div className="flex items-center gap-1.5 mb-3 text-xs text-[var(--gem-text-muted)]">
<span className="w-1.5 h-1.5 rounded-full bg-[#4285f4] animate-pulse" />
<span
className="w-1.5 h-1.5 rounded-full bg-[#7c55f0] animate-pulse"
style={{ animationDelay: "150ms" }}
/>
<span
className="w-1.5 h-1.5 rounded-full bg-[#a855f7] animate-pulse"
style={{ animationDelay: "300ms" }}
/>
<span className="ml-1">正在思考...</span>
</div>
);
}
// Pro 模式:完整 thinking block,可折叠
return (
<div className="mb-3">
<button
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-1.5 text-xs text-[var(--gem-text-muted)] hover:text-[var(--gem-text)] transition-colors duration-150 cursor-pointer"
aria-expanded={expanded}
>
<Brain
size={13}
className={cn(!isDone && "animate-pulse text-[#4285f4]")}
/>
<span>{isDone ? "已完成思考" : "正在思考..."}</span>
{expanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{!isDone && (
<span className="ml-1 text-[10px] opacity-60">{content.length} 字</span>
)}
</button>
{expanded && (
<div className="mt-2 pl-3 border-l-2 border-[var(--gem-border)] max-h-48 overflow-y-auto">
<p className="text-xs text-[var(--gem-text-muted)] leading-relaxed whitespace-pre-wrap font-mono">
{content}
{!isDone && (
<span className="inline-block w-1.5 h-3.5 bg-[var(--gem-text-muted)] ml-0.5 animate-pulse align-middle" />
)}
</p>
</div>
)}
</div>
);
}
```
---
## 5. ToolCallIndicator.tsx(新建)
```tsx
"use client";
import { Database, Search, FileText, Code2, Box, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ToolCallRecord } from "./GeminiMessage";
const TOOL_META: Record<string, { label: string; Icon: React.ElementType }> = {
kb_search: { label: "查询知识库", Icon: Database },
web_search: { label: "搜索网络", Icon: Search },
generate_document: { label: "生成文档", Icon: FileText },
sandbox_run: { label: "执行代码", Icon: Code2 },
ticket_list: { label: "查询工单列表", Icon: Box },
ticket_detail: { label: "获取工单详情", Icon: Box },
};
interface ToolCallIndicatorProps {
toolCalls: ToolCallRecord[];
}
export function ToolCallIndicator({ toolCalls }: ToolCallIndicatorProps) {
if (toolCalls.length === 0) return null;
return (
<div className="flex flex-col gap-1.5 mb-3">
{toolCalls.map((call, idx) => {
const meta = TOOL_META[call.tool] ?? { label: call.tool, Icon: Box };
const { label, Icon } = meta;
const isDone = call.endedAt !== undefined;
const duration = isDone
? ((call.endedAt! - call.startedAt) / 1000).toFixed(1)
: null;
return (
<div
key={idx}
className={cn(
"flex items-center gap-2 text-xs rounded-lg px-3 py-1.5 w-fit",
isDone
? "text-[var(--gem-text-muted)] bg-[var(--gem-surface-2)]"
: "text-[#4285f4] bg-[#4285f4]/10"
)}
>
{isDone ? (
<Icon size={13} className="flex-shrink-0 opacity-60" />
) : (
<Loader2 size={13} className="flex-shrink-0 animate-spin" />
)}
<span>
{isDone ? `已${label}` : `正在${label}...`}
</span>
{isDone && duration && (
<span className="opacity-50">{duration}s</span>
)}
</div>
);
})}
</div>
);
}
```
---
## 视觉效果示意
```
Auto 模式(thinking 进行中):
● ● ● 正在思考...
[第一个 token 到达后自动消失]
──────────────────────────────
Pro 模式(thinking 进行中):
🧠 正在思考... ▶ 128字
Pro 模式(展开后):
🧠 已完成思考 ▼
│ 让我先分析这个问题的关键点...
│ 考虑到用户提到了X,应该从Y角度
│ 分析。工单系统的...▌
──────────────────────────────
工具调用进行中:
⟳ 正在查询知识库... [蓝色背景]
工具调用完成:
✓ 已查询知识库 1.2s [灰色背景]
[最终回答正常流式打字...]
```
---
## 实施顺序
1. `lib/api.ts` — 类型扩展 + SSE 解析器修复
2. `components/gemini/ThinkingBlock.tsx` — 新建
3. `components/gemini/ToolCallIndicator.tsx` — 新建
4. `components/gemini/GeminiMessage.tsx` — 类型扩展 + 渲染插入
5. `components/gemini/GeminiChat.tsx` — 流事件处理 + 传参
+918
View File
@@ -0,0 +1,918 @@
# SOC 项目后端功能方案(基于 LangChain,按功能拆解)
> 约束:在未获得明确允许前,不修改前端交互,只补后端能力、编排链路和数据层。
> 目标:严格围绕当前 `~/go/soc` 这个 Gemini 风格前端,按“每个功能用什么技术完成什么功能”来写,核心框架改为 **LangChain / LangGraph**,不再以 FastAPI 作为方案重点。
---
## 1. 对话流式回复
### 要完成什么功能
- 用户在当前聊天输入框发送消息
- 后端实时返回回答内容
- 支持“思考中 / 检索中 / 生成中”的状态
- 不改变现有前端交互,只替换当前前端 `simulateAIResponse()`
### 用什么技术
- **LangChain**:负责组织提示词、消息上下文、模型调用
- **LangGraph**:负责整个对话节点编排与状态流转
- **Azure OpenAI**:生成最终回答
- **SSE**:把 LangChain/LangGraph 执行过程和回答流式推给前端
- **PostgreSQL**:保存会话和消息记录
### 怎么落地
- 以 `LangGraph StateGraph` 建立一个对话图:
- `receive_message`
- `load_history`
- `route_tools`
- `call_llm`
- `persist_message`
- 前端发送消息后,后端触发 graph 执行
- Azure OpenAI 通过 LangChain chat model 调用
- 生成的 token 和中间状态通过 SSE 返回给前端
### 输出结果
- 前端仍然保持当前 Gemini 风格聊天交互
- 从 mock 回复升级为真实流式 AI 回复
- 后续所有工具调用都能接到同一个 graph 里
---
## 2. 会话管理
### 要完成什么功能
- 左侧历史会话列表从真实数据读取
- 支持新建、切换、删除会话
- 支持自动生成标题
- 会话上下文可在 LangChain 中继续使用
### 用什么技术
- **PostgreSQL**:保存 conversation 和 message
- **LangChain Memory / Message History 抽象**:管理历史消息上下文
- **SQLAlchemy / SQLModel**:管理数据表
### 怎么落地
- conversations/messages 数据存入 PostgreSQL
- 在 LangChain 层使用 `BaseChatMessageHistory` 风格封装数据库消息
- 每次进入 graph 时先加载历史消息
- 标题生成可以由 LLM 在首轮消息后自动归纳
### 输出结果
- 当前左侧 mock 会话可以替换为真实会话记录
- 后端真正具备多轮上下文记忆能力
---
## 3. 内部知识库检索
### 要完成什么功能
- 当用户问产品、方案、配置、内部文档时
- 自动检索内部知识库
- 再让模型基于检索结果生成回答
- 回答中附带引用来源
### 用什么技术
- **LangChain Tool**:把 KB_AGENT 封装成知识库工具
- **KB_AGENT 接口**:作为实际搜索源
- **Azure OpenAI**:总结检索结果并生成回答
- **LangGraph**:决定何时调用知识库节点
### 怎么落地
- 编写 `kb_search_tool`
- 工具内部调用 `KB_AGENT_URL + KB_AGENT_SEARCH_PATH`
- 返回统一的文档列表结构
- graph 中当识别为内部知识类问题时,先进入 `kb_search` 节点,再进入 `llm_answer`
### 输出结果
- 产品知识问答不再靠模型空想
- 回答可基于真实内部资料
- 更适合售前、售后、研发支持
---
## 4. 外部 AI 搜索
### 要完成什么功能
- 处理实时互联网问题、行业动态、外部资料调研
- 支持搜索、网页读取、重排
- 支持 fast / deep / auto 三种搜索质量
- 后续支持图片和视频检索结果
### 用什么技术
- **LangChain Tool**:封装外部搜索工具链
- **Jina Search API**:外部搜索
- **Jina Reader**:网页正文读取
- **Rerank 模型**:重排结果
- **LangGraph**:编排 Search -> Read -> Rerank -> Answer
- **Azure OpenAI**:生成最终总结回答
### 怎么落地
- 分成三个 tool:
- `web_search_tool`
- `web_read_tool`
- `rerank_tool`
- 在 graph 中建立外部搜索链路:
- 搜索候选
- 读取正文
- 重排结果
- 将高质量上下文交给 LLM
- `fast/deep/auto` 可作为 graph state 中的参数
### 输出结果
- 外部信息回答准确度显著提升
- 满足文档要求里的企业级外部搜索能力
- 为后续图片、视频结果展示预留结构
---
## 5. 工单系统只读接入
### 要完成什么功能
- 查询工单列表
- 查询工单详情
- 汇总 P0/P1 工单
- 支持聊天中分析工单趋势、共性问题、故障重点
- 替换当前前端 mock 工单摘要
### 用什么技术
- **LangChain Tool**:把 gongdan API 封装为工单工具
- **Gongdan HTTP API**:工单实际数据源
- **Azure OpenAI**:对工单结果做总结和归纳
- **PostgreSQL(可选缓存)**:保存查询结果和摘要缓存
### 怎么落地
- 编写工具:
- `ticket_list_tool`
- `ticket_detail_tool`
- `ticket_summary_tool`
- 当用户问题涉及工单时,graph 路由到 ticket 节点
- 工具取回结果后,再由 LLM 进行总结
- 当前前端的 TicketSummary 数据以后改成读取真实接口结果,但不改交互样式
### 输出结果
- 工单分析能力可直接在聊天里使用
- 首页/聊天区的工单摘要可从 mock 变成真实数据
---
## 6. 文档生成
### 要完成什么功能
- 用户要求生成方案、汇报、纪要、总结文档时
- 后端自动进入文档生成流程
- 返回任务状态和结果
- 生成正式文档链接或结果卡片
### 用什么技术
- **LangChain Tool / Runnable**:封装文档生成能力
- **Doc Creator Agent HTTP API**:实际生成正式文档
- **LangGraph**:把“文档生成”作为 graph 的分支节点
- **PostgreSQL**:保存文档任务记录
- **Azure OpenAI**:先整理文档结构或提纲
### 怎么落地
- graph 中识别“生成文档”类意图
- 先用 LLM 生成结构化文档提纲
- 再调用 doc creator agent
- 把任务状态写入数据库
- 前端依旧保持聊天式入口,只在消息中显示结果卡片
### 输出结果
- 销售、售前、汇报场景可以直接从聊天进入正式文档输出
- 文档生成成为对话系统中的标准能力节点
---
## 7. 沙盒代码执行
### 要完成什么功能
- 分析 CSV、JSON、日志、结构化数据
- 在安全环境中执行代码
- 返回分析结果、图表和文件
- 不改变前端交互,只把结果塞回当前聊天流里
### 用什么技术
- **LangChain Tool**:把沙盒能力封装为可调用工具
- **Daytona Sandbox**:安全执行环境
- **Python 数据工具链**:pandas、matplotlib、json、csv
- **LangGraph**:按意图路由到 sandbox 节点
- **PostgreSQL**:保存执行记录
### 怎么落地
- 第一阶段不开放任意代码执行
- 只先封装几个固定工具:
- `csv_summary_tool`
- `json_transform_tool`
- `data_analysis_tool`
- `chart_generate_tool`
- graph 根据问题和附件类型决定是否调用 sandbox
### 输出结果
- 后端不仅能“回答”,还能“执行”和“计算”
- 数据类问题能返回真正算出来的结果
---
## 8. 附件上传与解析
### 要完成什么功能
- 接收用户上传的附件
- 保存附件元数据
- 提取文本内容进入上下文
- 为知识问答、文档生成、沙盒分析提供输入
### 用什么技术
- **对象存储/本地存储**:保存附件
- **LangChain Document Loader**:解析 PDF、DOCX、TXT、CSV 等文件
- **PostgreSQL**:保存附件元数据
- **LangGraph**:把附件解析结果接入 graph state
### 怎么落地
- 上传后先保存附件和元数据
- 再用 LangChain loader 抽取文本
- 将解析结果挂到当前会话 state 中
- 当用户继续提问时,graph 可以把附件内容作为上下文输入 LLM 或工具
### 输出结果
- 未来可以真正支持“基于附件提问”和“基于附件分析”
- 为文档生成和沙盒执行提供输入材料
---
## 9. 工具编排层
### 要完成什么功能
- 判断用户当前问题到底需要哪种能力
- 决定先查 KB、先查工单、还是先查外部搜索
- 决定是否触发文档生成或沙盒分析
- 把多个工具结果统一整理给模型
### 用什么技术
- **LangGraph**:整个系统的核心编排框架
- **LangChain Tools**:封装 KB、Search、Tickets、Docs、Sandbox
- **Azure OpenAI**:辅助做意图判断、结果总结
### 怎么落地
- graph 中至少有这些节点:
- `router`
- `kb_search`
- `web_search`
- `ticket_query`
- `doc_generate`
- `sandbox_run`
- `llm_answer`
- `persist`
- 第一阶段可以先“规则路由 + LLM总结”
- 第二阶段再升级为“LLM路由 + 工具调用决策”
### 输出结果
- 后端不再是散乱接口集合,而是统一 Agent 编排系统
- 前端只保留一个 Gemini 风格聊天入口即可
---
## 10. 数据持久化与基础设施
### 要完成什么功能
- 保存历史会话
- 保存消息记录
- 保存工具调用记录
- 保存附件记录
- 保存文档任务记录
- 保存 graph 执行状态和日志
- 提升缓存能力、异步任务能力和文件持久化能力
### 用什么技术
- **PostgreSQL**:主数据库,保存会话、消息、任务、工具记录
- **LangGraph Checkpointer / State Persistence**:保存 graph 执行状态
- **Redis**:缓存热点结果、会话临时状态、短期上下文、速率控制
- **Azure Storage Account**:保存附件、图表、导出文件、文档产物
- **Azure Service Bus**:承载异步任务与解耦长链路处理
### 怎么落地
- PostgreSQL 中至少建立以下表:
- `conversations`
- `messages`
- `tool_runs`
- `attachments`
- `document_tasks`
- `graph_runs`
- Redis 用于:
- 外部搜索结果缓存
- KB 搜索缓存
- 工单摘要缓存
- 正在运行的 graph/session 临时状态
- SSE 会话短状态同步
- Azure Storage Account 用于:
- 用户上传附件原始文件
- Sandbox 输出文件
- 图表与中间产物
- 文档生成结果文件
- Azure Service Bus 用于:
- 文档生成异步任务派发
- Sandbox 长任务调度
- 外部搜索深度模式异步并发编排
- 后续告警/通知类事件扩展
### 输出结果
- 对话、工具、任务都有追踪记录
- graph 执行链路具备可恢复能力
- 系统具备缓存、异步任务和文件持久化基础设施
---
## 11. Redis 缓存层
### 要完成什么功能
- 降低外部接口重复调用成本
- 提升对话链路响应速度
- 处理短期状态、热点数据和限流控制
### 用什么技术
- **Azure Redis**:缓存层
- **LangChain / LangGraph 外围状态管理**:结合缓存保存中间态
### 怎么落地
- 缓存这些内容:
- 相同 query 的 KB 搜索结果
- 相同 query 的外部搜索与重排结果
- 工单摘要结果
- 文档生成任务短状态
- 会话级短期上下文摘要
- 为外部搜索和知识库增加 TTL
- 为 Service Bus 异步任务增加状态缓存
### 输出结果
- 系统速度更稳定
- 外部服务成本更低
- 可支撑更高并发下的会话请求
---
## 12. 存储账户(文件与产物存储)
### 要完成什么功能
- 持久化用户上传附件
- 保存文档生成结果
- 保存 Sandbox 执行生成的图表/文件
- 为前端提供附件与结果文件访问地址
### 用什么技术
- **Azure Blob Storage**:统一文件对象存储
- **LangChain Document Loader**:结合存储文件做解析
### 怎么落地
- 上传文件后先保存到 Blob Storage
- 数据库中记录 blob URL、文件类型、所属消息/会话
- 文档生成与 Sandbox 产物统一落到 Blob Storage
- 前端保持现有交互,仅在消息中附带文件结果卡片或链接
### 输出结果
- 所有附件和中间产物有统一落盘位置
- 后续分析、下载、追踪都更方便
---
## 13. Service Bus 异步任务层
### 要完成什么功能
- 处理长耗时任务
- 解耦即时对话链路和后台异步处理链路
- 支持重试、失败恢复、延后处理
### 用什么技术
- **Azure Service Bus**:消息队列 / 异步任务总线
- **LangGraph**:消费任务后继续执行长链路节点
### 怎么落地
- 把这些任务异步化:
- 文档生成
- Sandbox 长任务
- 深度外部搜索
- 未来的大批量分析任务
- 聊天主链路先返回“任务已受理”状态
- Worker 从 Service Bus 拉取任务继续执行
- 执行结果写数据库和存储账户,再回推前端
### 输出结果
- 避免主对话链路阻塞
- 长任务处理更稳定
- 适合企业级系统扩展
---
## 14. MCP 方式接入外部搜索
### 要完成什么功能
- 利用 `https://mcp.jina.ai/sse` 这一类能力,以 MCP 方式接入外部搜索
- 让外部搜索不只是普通 HTTP API,而是可作为标准工具节点接入 LangChain / LangGraph
### 用什么技术
- **MCP(Model Context Protocol)**:统一工具协议
- **Jina MCP SSE / v1**:外部搜索与读取能力来源
- **LangChain Tool 封装层**:把 MCP 调用转换成 graph 可调用工具
### 怎么落地
- 优先测试 Jina 提供的 `/sse` 和 `/v1` 两种入口
- 将 Search 和 Read 分别封装成两个 tool
- 在外部搜索节点中统一走 MCP 接入层,保留将来替换搜索供应商的可能
- 重排仍保留单独节点,以便保障搜索质量控制
### 输出结果
- 外部搜索链路更标准化
- 更容易扩展到更多 MCP 服务
- 对 LangChain / LangGraph 编排更友好
---
## 15. 基于当前前端代码补充的后端缺口与完善方案
> 这一章专门对应当前前端已经存在、但此前后端方案没有完整覆盖的功能点。不含认证和权限,只补业务后端能力。
### 15.1 消息反馈(赞 / 踩)
#### 要完成什么
- 用户对 assistant 消息进行点赞或点踩
- 后端记录反馈结果
- 后续可用于回答质量分析、提示词优化和问题回溯
#### 用什么技术
- **PostgreSQL**:保存反馈记录
- **LangGraph 旁路记录**:反馈不进入主对话 graph
- **Redis(可选)**:做短期统计缓存
#### 怎么落地
- 新增表:`message_feedback`
- `id`
- `message_id`
- `conversation_id`
- `feedback_type` (`up` / `down`)
- `reason`(可空,后续扩展)
- `created_at`
- 新增接口:
- `POST /api/messages/{id}/feedback`
- 前端点击赞/踩后直接调用该接口
- 第一阶段先只记录 `up/down`,不做复杂原因分类
---
### 15.2 模型切换映射
#### 要完成什么
- 前端已有 Flash / Pro 与顶部模型选择入口
- 第一阶段后端先统一固定使用 **GPT-5.4**
- 但保留字段和映射结构,后续再扩展多模型、多链路
#### 用什么技术
- **LangChain model wrapper**:模型封装
- **LangGraph state**:保存 `model_profile`
- **PostgreSQL conversation metadata**:记录选择结果
#### 怎么落地
- 前端若传模型字段,第一阶段统一映射为:
- `model_provider = azure_openai`
- `model_name = gpt-5.4`
- 保留 metadata 字段:
- `selected_model`
- `selected_mode`
- 当前只做字段记录与透传,不做真正多模型切换
- 第二阶段再扩为 flash/pro 对应不同 graph 策略
---
### 15.3 工具显式开关控制
#### 要完成什么
- 前端工具 chips:
- 搜索
- 内部知识库
- 沙盒
- 文档生成
- 用户手动启用哪些工具,后端就只允许调用这些工具
- 用户未选择时,后端才走自动路由
#### 用什么技术
- **LangGraph state**:保存当前消息工具选择
- **LangChain tools registry**:统一工具注册
- **tool allowlist / denylist**:工具调用控制
#### 怎么落地
- 前端发消息时附带:
```json
{
"enabled_tools": ["search", "knowledge"]
}
```
- graph state 增加:
- `enabled_tools`
- `tool_selection_mode` (`auto` / `manual`)
- router 节点规则:
- `manual` 模式:只能从 allowlist 中路由
- `auto` 模式:按规则或模型自由决策
- 工具执行前统一做可用性校验
---
### 15.4 多文件上传与消息绑定
#### 要完成什么
- 一次上传多个文件
- 每个文件单独保存
- 文件和某条消息绑定
- 文件可参与知识问答、搜索、Sandbox 分析和文档生成
#### 用什么技术
- **Azure Blob Storage**:存储文件
- **PostgreSQL**:存储附件元数据
- **LangChain Document Loaders**:解析附件内容
- **消息-附件关联机制**:支撑多文件场景
#### 怎么落地
- 新增表:`attachments`
- `id`
- `conversation_id`
- `message_id`(允许先空,待消息发送后再绑定)
- `file_name`
- `content_type`
- `storage_url`
- `parse_status`
- `parsed_text`
- `created_at`
- 新增接口:
- `POST /api/attachments`
- `POST /api/messages/{id}/attachments/bind`
- 推荐流程:
1. 前端先上传多个文件
2. 后端返回 attachment ids
3. 前端发消息时附带 attachment ids
4. 后端完成消息与附件绑定
- 解析流程异步化,避免阻塞主聊天链路
---
### 15.5 扩展程序连接管理
#### 要完成什么
- 支持扩展程序的连接、断开、修改 key、查看状态
- 页面刷新后仍保留扩展连接状态
- 扩展状态可被后端 graph 感知
#### 用什么技术
- **PostgreSQL**:保存扩展配置与状态
- **加密存储机制**:保存敏感配置
- **extension registry**:统一扩展管理
- **LangChain tool 注册机制**:根据扩展状态暴露工具
#### 怎么落地
- 新增表:`extensions`
- `id`
- `extension_type` (`ticket` / `sales` / `cloud`)
- `display_name`
- `status`
- `config_encrypted`
- `last_check_at`
- `last_check_status`
- 新增接口:
- `GET /api/extensions`
- `POST /api/extensions/{type}/connect`
- `POST /api/extensions/{type}/disconnect`
- `POST /api/extensions/{type}/validate`
- 第一阶段先完成工单系统全链路,销售和云管先保留扩展框架
---
### 15.6 销售系统 / 云管系统预留
#### 要完成什么
- 虽然当前两套系统还在开发,但后端要预留统一扩展接入结构
- 避免未来工单、销售、云管三套系统接入方式不一致
#### 用什么技术
- **统一 extension schema**
- **summary provider 接口**
- **tool provider 接口**
- **connection config schema**
#### 怎么落地
- 一期不要求真实接入销售/云管 API
- 但必须预留:
- 扩展类型定义
- tool 注册入口
- summary 注册入口
- 状态位和配置结构
- 后续新增业务系统时不需要推翻现有后端结构
---
### 15.7 通用扩展摘要机制
#### 要完成什么
- 不只是工单系统,未来销售、云管系统接入后,也能输出首页/对话页摘要卡片
- 后端统一提供摘要机制
#### 用什么技术
- **summary provider registry**:每个扩展实现自己的摘要提供者
- **Redis**:缓存摘要结果
- **PostgreSQL**:记录摘要生成时间与状态
- **LangChain summarizer(可选)**:对原始数据做摘要
#### 怎么落地
- 新增统一摘要接口:
- `GET /api/extensions/summaries`
- 返回结构示例:
```json
[
{
"extension_type": "ticket",
"status": "connected",
"summary_type": "ticket_summary",
"data": {}
}
]
```
- 第一阶段先实现 ticket summary provider
- 但接口设计按多扩展统一返回
---
### 15.8 结构化消息块协议
#### 要完成什么
- 后端不能只返回纯文本
- 需要支持:
- 文本
- 引用来源
- 摘要卡片
- 文件结果
- 工具状态
- 错误块
#### 用什么技术
- **LangGraph 标准化事件输出**
- **message block schema**
- **前后端统一 JSON 协议**
#### 怎么落地
- 定义统一 block 结构:
```json
{
"type": "text | citation | summary_card | artifact | tool_status | error",
"payload": {}
}
```
- assistant message 最终存储结构:
```json
{
"id": "...",
"blocks": []
}
```
- SSE 中间态也复用 block/event 体系
- 第一阶段前端至少支持:
- `text`
- `tool_status`
- `citation`
- `summary_card`
---
### 15.9 长任务状态回传
#### 要完成什么
- 文档生成、Sandbox 数据分析、深度搜索等任务可能耗时较长
- 前端需要看到任务状态,而不是一直假 loading
#### 用什么技术
- **Azure Service Bus**:异步任务投递
- **PostgreSQL**:任务状态持久化
- **Redis**:缓存短状态
- **SSE / 轮询**:状态回传给前端
#### 怎么落地
- 新增表:`async_tasks`
- `id`
- `task_type`
- `conversation_id`
- `message_id`
- `status`
- `progress_text`
- `result_payload`
- `created_at`
- `updated_at`
- 新增接口:
- `GET /api/tasks/{id}`
- 第一阶段先采用“数据库状态 + 前端轮询”
- 后续再增强为 SSE 任务事件推送
---
### 15.10 扩展连接状态注入 graph
#### 要完成什么
- 某个扩展是否已连接,必须直接决定 graph 中哪些工具可用
- 未连接扩展不能被调用
- 已连接扩展才能参与 agent 路由
#### 用什么技术
- **extension registry**
- **LangGraph state injection**
- **tool availability resolver**
#### 怎么落地
- graph 执行前先加载当前扩展连接状态
- 注入 state:
```json
{
"available_extensions": ["ticket"]
}
```
- router 节点判断:
- 工单问题 + ticket 已连接 -> 允许调用
- 工单问题 + ticket 未连接 -> 返回“扩展未连接”
- 销售 / 云管未来直接复用该机制
---
### 15.11 会话重命名 / 置顶等预留
#### 要完成什么
- 为左侧会话更多操作菜单预留后端能力
- 支持未来扩展:
- 重命名
- 置顶
- 自定义排序
#### 用什么技术
- **PostgreSQL conversation metadata**
- **排序字段 / pinned 字段**
#### 怎么落地
- conversations 表补充字段:
- `custom_title`
- `pinned`
- `sort_order`
- 接口统一走:
- `PATCH /api/conversations/{id}`
- 即使前端暂未开放置顶,也建议先预留字段
---
### 15.12 会话级偏好元数据
#### 要完成什么
- 记录会话偏好信息,例如:
- 当前选中的模型
- 当前启用工具
- 默认搜索模式
- 当前关联扩展
- 会话恢复时自动延续这些设置
#### 用什么技术
- **PostgreSQL JSON metadata**
- **LangGraph state hydration**
#### 怎么落地
- conversations 表增加:
- `metadata_json`
- 典型结构示例:
```json
{
"selected_model": "gpt-5.4",
"selected_mode": "pro",
"enabled_tools": ["knowledge", "search"],
"preferred_search_mode": "deep"
}
```
- 会话恢复时把 metadata 注入 graph 初始 state
---
## 16. 接口层总表
> 虽然本方案不以 FastAPI 为重点,但前端要接入,仍然需要有 HTTP/SSE 出口。这里把它视为“接入层”,不是方案核心。
### 第一阶段建议建设的接口
#### 基础接口
- `GET /health`
#### 会话接口
- `GET /api/conversations`
- `POST /api/conversations`
- `GET /api/conversations/{id}`
- `PATCH /api/conversations/{id}`
- `DELETE /api/conversations/{id}`
#### 聊天接口
- `POST /api/chat/stream`
#### 工单接口
- `GET /api/tickets/summary`
- `GET /api/tickets`
- `GET /api/tickets/{id}`
#### 搜索接口
- `POST /api/search/internal`
- `POST /api/search/external`
#### 文档接口
- `POST /api/documents/generate`
- `GET /api/documents/{task_id}`
#### 附件接口
- `POST /api/attachments`
- `GET /api/attachments/{id}`
#### 沙盒接口
- `POST /api/sandbox/run`
---
## 16. 推荐技术组合总结
### 核心框架
- **LangChain**:模型调用、Prompt 组织、Tool 封装、Memory 适配
- **LangGraph**:对话状态机、工具路由、任务编排、长链路执行
### 数据层
- **PostgreSQL**:会话/消息/工具调用/任务持久化
- **Redis**:缓存、短状态、限流
- **Azure Blob Storage**:附件、产物、文档存储
- **Azure Service Bus**:异步任务编排
- **SQLAlchemy / SQLModel**:ORM
- **Alembic**:迁移管理
### AI 与搜索
- **Azure OpenAI**:LLM 生成与总结
- **KB_AGENT**:内部知识库检索
- **Jina MCP SSE / v1 + Search / Reader / Rerank**:外部搜索链路
### 外部业务系统
- **Gongdan API**:工单只读
- **Doc Creator Agent**:文档生成
- **Daytona Sandbox**:受控代码执行
### 协议与接入
- **SSE**:流式输出到前端
- **HTTP API**:前端接入层
---
## 17. 第一阶段开发顺序
### 第一步
先完成:
- LangChain + LangGraph 基础工程
- PostgreSQL 接入
- conversations/messages 表
- 基础聊天 graph
- `/api/chat/stream`
- `/api/conversations`
### 第二步
接入:
- Azure OpenAI
- KB_AGENT tool
- 工单 tools
### 第三步
接入:
- Jina MCP SSE / v1 搜索链路
- Search / Reader / Rerank tool chain
- 来源引用
- graph 中间状态流式事件
- Redis 缓存
### 第四步
接入:
- 文档生成 tool
- 附件解析 loader
- sandbox tools
- Azure Blob Storage
- Azure Service Bus
- graph 持久化和恢复
---
## 18. 最终结论
这个项目当前最合适的后端方案,如果明确要求基于 LangChain 框架来做,那就应该是:
- 用 **LangChain + LangGraph** 做整个后端核心
- 用 **Azure OpenAI** 做模型生成和总结
- 用 **KB_AGENT** 做内部知识检索工具
- 用 **Jina Search/Reader/Rerank** 做外部搜索工具链
- 用 **Gongdan API** 做工单查询工具
- 用 **Doc Creator Agent** 做正式文档生成工具
- 用 **Daytona Sandbox** 做受控执行工具
- 用 **PostgreSQL** 做会话、消息、任务和 graph 状态持久化
- 用 **Redis** 做缓存和短状态管理
- 用 **Azure Blob Storage** 做附件与产物存储
- 用 **Azure Service Bus** 做长任务异步编排
- 用 **MCP 方式** 标准化接入 Jina 外部搜索
整个系统本质上是:
**一个基于 LangGraph 编排、具备缓存/存储/异步任务能力的企业级对话 Agent 后端。**
而且整个过程中:
**前端交互不改,只替换数据来源和后端能力。**
+324
View File
@@ -0,0 +1,324 @@
# so-c-chat-clone 后端建设计划
## Context
基于 `gpthd.md` 的 18 项功能方案和 `EXTERNAL_SERVICES.md` 中的 10 个已接入外部服务,从零构建 `backend/` 目录下的企业级对话 Agent 后端。前端 `backend/` 目前为空,mock 数据需替换为真实 API。
**核心约束:**
- 前端代码未经明确指定不允许修改
- Azure 资源只能操作 `AuthData` 和 `Operation` 两个资源组
- CI/CD 由用户自行创建,Agent 只负责推代码到 GitHub
**前端改动(已授权):**
`GeminiInput.tsx` 的 `onSubmit` 扩展参数,将 `activeTools` 和 `selectedModel` 传给后端。**工具是否实际调用由 LangGraph ReAct Agent 自行判断**;如果 Agent 决定不使用某个工具,必须在回复中向用户说明原因。视觉交互完全不变。
---
## 第一阶段:基础工程 + 对话核心
### 目标
完成可运行的后端骨架,实现真实 LLM 对话,替换前端 `simulateAIResponse()`。
### 文件结构
```
backend/
├── pyproject.toml # 依赖管理
├── .env.example # 环境变量模板
├── .gitignore # Python 忽略规则
├── app/
│ ├── main.py # Litestar 入口,路由注册,CORS
│ ├── config.py # 环境变量读取(pydantic-settings)
│ ├── schemas.py # 请求/响应 Pydantic 模型
│ ├── graph/
│ │ ├── state.py # LangGraph MessagesState 定义
│ │ ├── nodes.py # call_model 节点
│ │ └── builder.py # StateGraph 构建,compile with checkpointer
│ ├── store/
│ │ └── postgres.py # AsyncPostgresSaver 初始化,conversations/messages 表
│ └── api/
│ ├── chat.py # POST /api/chat/stream(SSE)
│ ├── conversations.py # GET/POST/PATCH/DELETE /api/conversations
│ └── health.py # GET /health
```
### 关键实现
**LangGraph 基础图(graph/builder.py):**
```python
from langgraph.graph import StateGraph, MessagesState
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
graph = StateGraph(MessagesState)
graph.add_node("agent", call_model_node)
graph.set_entry_point("agent")
graph.set_finish_point("agent")
app_graph = graph.compile(checkpointer=AsyncPostgresSaver.from_conn_string(DATABASE_URL))
```
**SSE 流式输出(api/chat.py):**
```python
async def stream_chat(request: ChatRequest) -> ServerSentEvent:
async for event in app_graph.astream_events(
{"messages": [HumanMessage(content=request.message)]},
config={"configurable": {"thread_id": request.conversation_id}},
version="v2"
):
if event["event"] == "on_chat_model_stream":
yield ServerSentEvent(data=event["data"]["chunk"].content)
```
**模型参数(nodes.py):**
- `model=flash` → `max_tokens=500, temperature=0.2`
- `model=pro` → `max_tokens=4096, temperature=0.3`
**依赖:**
```
litestar[standard] uvicorn
langchain langchain-openai langgraph
langgraph-checkpoint-postgres
asyncpg sqlalchemy[asyncio]
pydantic-settings python-dotenv httpx
```
**数据库表(PostgreSQL):**
- `conversations(id, title, created_at, updated_at)`
- `messages(id, conversation_id, role, content, created_at)`
- LangGraph checkpointer 自动建表
**完成后推 GitHub,同步前端改动(已授权):**
- `GeminiInput.tsx`:`onSubmit: (tools: string[], model: string) => void`
- `GeminiChat.tsx`:`handleSend` 接收 tools/model,传入 `/api/chat/stream`
- Agent system prompt 中注明:若决定不调用用户选中的工具,必须在回复中说明原因
---
## 第二阶段:Tool 接入(KB + 工单 + LLM 路由)
### 目标
接入内部知识库和工单系统,LangGraph ReAct Agent 自动路由工具调用。
### 新增文件
```
backend/app/
├── tools/
│ ├── kb.py # kb_search_tool → KB_AGENT /api/v1/search
│ └── tickets.py # ticket_list_tool, ticket_detail_tool, ticket_summary_tool
├── graph/
│ └── builder.py # 升级为 create_react_agent,绑定 tools
└── api/
└── tickets.py # GET /api/tickets/summary, /api/tickets, /api/tickets/{id}
```
**工具封装示例(tools/kb.py):**
```python
@tool
async def kb_search(query: str) -> str:
"""检索企业内部知识库"""
resp = await client.post(KB_AGENT_URL + KB_AGENT_SEARCH_PATH,
json={"query": query, "top": 5, "search_mode": "hybrid"},
headers={"api-key": KB_AGENT_API_KEY})
results = resp.json()["results"]
return "\n\n".join(f"【{r['title']}】\n{r['content']}" for r in results)
```
**动态 Tool 绑定(根据前端传入 tools 参数):**
```python
ALL_TOOLS = {"knowledge": kb_search, "search": web_search, ...}
active = [ALL_TOOLS[t] for t in request.tools if t in ALL_TOOLS]
agent = create_react_agent(llm, active, checkpointer=checkpointer)
```
**工单接口:** 代理转发 Gongdan API,返回字段对齐前端 `TicketData`(`id/title/status/priority/createdAt`)
**完成后推 GitHub。**
---
## 第三阶段:外部搜索 + Redis 缓存
### 目标
接入 Jina MCP/v1 搜索链路(Search + Reader + Rerank),Redis 缓存热点结果。
### 新增文件
```
backend/app/
├── tools/
│ └── search.py # web_search_tool(Jina Search → Reader → Rerank)
└── cache/
└── redis.py # Redis 客户端,搜索结果 TTL 缓存
```
**Jina 接入(优先测试 /v1,备选 MCP SSE):**
```python
# 搜索
POST https://s.jina.ai/ Authorization: Bearer JINA_API_KEY
# 读取全文
GET https://r.jina.ai/{url} Authorization: Bearer JINA_API_KEY
# 重排
POST https://api.jina.ai/v1/rerank
model: jina-reranker-v2-base-multilingual
```
**按 model 深度区分:**
| model | top | timeout | Reader | Rerank |
|-------|-----|---------|--------|--------|
| flash | 3 | 8s | 跳过 | 跳过 |
| pro | 10 | 20s | 并发 | top 5 |
**Redis 配置(EXTERNAL_SERVICES.md):**
```
oper.redis.cache.windows.net:6380
password=bY8ZNwyJX60UwN5NPqnl6HRODfTV0efkDAzCaF1PrOU=,ssl=True
```
缓存 key:`search:{query_hash}:{model}`,TTL=300s
**完成后推 GitHub。**
---
## 第四阶段:文档生成 + 沙盒 + 附件 + 异步任务
### 目标
接入 Doc Creator Agent、Daytona Sandbox、Azure Blob Storage、Azure Service Bus。
### 新增文件
```
backend/app/
├── tools/
│ ├── document.py # doc_generate_tool → Doc Creator Agent
│ └── sandbox.py # sandbox_run_tool → Daytona API
├── storage/
│ └── blob.py # Azure Blob Storage 上传/下载
└── tasks/
└── bus.py # Azure Service Bus 异步任务派发/消费
```
**Doc Creator(tools/document.py):**
```python
@tool
async def generate_document(prompt: str) -> str:
"""生成 Word/PPT/Excel 文档,返回下载链接"""
output_type = detect_doc_type(prompt) # ppt/table/word
resp = await client.post(DOC_AGENT_URL,
json={"prompt": prompt, "output_type": output_type},
headers={"Authorization": f"Bearer {DOC_AGENT_KEY}"})
data = resp.json()
return f"📄 [{data['title']}]({data['file_url']})"
```
**Daytona(tools/sandbox.py):**
创建 workspace → 上传代码 → 执行 → 获取 stdout/stderr → 销毁
**Azure Blob Storage(EXTERNAL_SERVICES.md):**
```
AccountName=authdatablol
AccountKey=sm3ysR0zAmS9OLti...
```
用于保存附件、Sandbox 产物、文档生成结果。
**Azure Service Bus(EXTERNAL_SERVICES.md):**
```
Endpoint=sb://databus.servicebus.windows.net/
```
文档生成、深度搜索等长任务通过 Service Bus 异步化,主链路先返回"任务受理"。
**完成后推 GitHub,通知用户配置 CI/CD。**
---
## 环境变量清单(backend/.env.example)
```
# Azure OpenAI
AZURE_OPENAI_ENDPOINT=
AZURE_OPENAI_API_KEY=
AZURE_OPENAI_API_VERSION=2025-04-01-preview
AZURE_OPENAI_DEPLOYMENT=gpt-5.4
# KB Agent
KB_AGENT_URL=https://agnetdoc-cve0guf5h8eggmej.southeastasia-01.azurewebsites.net
KB_AGENT_API_KEY=
KB_AGENT_SEARCH_PATH=/api/v1/search
# Jina
JINA_API_KEY=
# Daytona
DAYTONA_API_KEY=
DAYTONA_API_URL=https://app.daytona.io/api
# Doc Agent
DOC_AGENT_URL=http://doc-creator-agent-b0d02105-a557fe.taijiagnet.com
DOC_AGENT_KEY=
# Gongdan
GONGDAN_API_BASE=https://gongdan-b5fzbtgteqd5gzfb.eastasia-01.azurewebsites.net
GONGDAN_API_KEY=
# PostgreSQL
DATABASE_URL=postgresql+asyncpg://azuredb:PASSWORD@dataope.postgres.database.azure.com:5432/soc?ssl=require
# Redis
REDIS_URL=rediss://:bY8ZNwyJX60UwN5NPqnl6HRODfTV0efkDAzCaF1PrOU=@oper.redis.cache.windows.net:6380
# Azure Blob Storage
AZURE_STORAGE_CONNECTION_STRING=
# Azure Service Bus
AZURE_SERVICE_BUS_CONNECTION_STRING=
```
---
## Azure 资源操作约束
所有 `az` 命令必须带 `--resource-group Operation` 或 `--resource-group AuthData`,否则停止执行。
---
## GitHub 推送节奏
每完成一个阶段:
```bash
git pull origin main
git add backend/
git commit -m "feat(backend): 阶段N - ..."
git push origin main
```
CI/CD 由用户自行在 GitHub Actions 中配置,Agent 不创建 workflow 文件。
---
## 验证方式
**第一阶段验证:**
```bash
cd backend && uvicorn app.main:app --port 8000 --reload
curl -N http://localhost:8000/api/chat/stream \
-X POST -H "Content-Type: application/json" \
-d '{"message":"你好","conversation_id":"conv-test","tools":[],"model":"flash"}'
# 期望:SSE 流式返回 AI 回复
```
**第二阶段验证:**
```bash
curl http://localhost:8000/api/tickets
# 期望:真实工单数据数组,字段对齐前端 TicketData
```
**第三阶段验证:**
```bash
curl -N http://localhost:8000/api/chat/stream \
-d '{"message":"查一下最新的 AI 新闻","tools":["search"],"model":"pro",...}'
# 期望:回复引用真实搜索结果
```
**第四阶段验证:**
```bash
curl -N http://localhost:8000/api/chat/stream \
-d '{"message":"帮我生成一份PPT方案","tools":["document"],...}'
# 期望:回复包含文档下载链接
```
+329
View File
@@ -0,0 +1,329 @@
# 浏览器全功能测试计划(SOC)
更新时间:2026-04-08
测试方式:通过浏览器进行真实页面操作验证
目标:对当前 SOC 前后端已暴露能力做一轮全功能联调测试
---
## 1. 测试目标
通过浏览器从用户视角验证以下功能是否真正可用:
1. 页面是否能正常打开和加载
2. 会话列表是否正常展示
3. 新建会话是否正常
4. 历史会话读取是否正常
5. 聊天发送与流式输出是否正常
6. 工具启用/禁用是否生效
7. 工单扩展连接和展示逻辑是否符合当前实现
8. 工单摘要是否正常加载
9. 文件上传、附件展示、附件下载是否正常
10. 页面刷新、切换会话后的数据一致性是否正常
11. 基础异常场景下前端是否有合理反馈
---
## 2. 测试范围
### 本轮重点测试
- 前端主聊天页面
- 会话管理
- 聊天流式响应
- 工具选择
- 扩展连接
- 工单摘要
- 文件上传与附件展示
- 附件下载
- 页面基础稳定性
### 本轮不重点覆盖
- 模型切换
- 管理后台类功能
- 大规模压测
- 安全渗透测试
- 权限隔离深测
- Service Bus 后台任务全链路深度验收
- Blob 内部对象人工逐个核验
---
## 3. 测试前置条件
执行浏览器测试前,需要确认:
1. 前端页面可访问
2. 后端 API 可访问
3. 数据库已可正常连接
4. Azure OpenAI 配置可用
5. 工单接口可访问
6. 上传相关 Blob Storage 配置可用
7. 当前测试环境明确(本地或生产)
---
## 4. 功能测试清单
### A. 页面与基础加载
#### 用例 A1:主页加载
步骤:
1. 打开系统页面
2. 观察首屏渲染
预期:
- 页面成功打开
- 无明显白屏/崩溃
- 控制台无阻断性错误
#### 用例 A2:侧边栏与主界面结构
步骤:
1. 检查侧边栏
2. 检查顶部栏
3. 检查输入框
预期:
- 主界面结构完整
- 关键操作入口可见
---
### B. 会话功能
#### 用例 B1:会话列表加载
步骤:
1. 打开页面
2. 观察历史会话列表
预期:
- 能成功拉取会话列表
- 不报错
#### 用例 B2:打开已有会话
步骤:
1. 点击一个已有会话
2. 观察消息加载
预期:
- 会话详情正常打开
- 历史消息正确显示
#### 用例 B3:新建会话
步骤:
1. 点击新建会话
2. 输入问题并发送
预期:
- 自动形成新会话
- 新会话进入列表
#### 用例 B4:刷新后会话一致性
步骤:
1. 新建或进入一个会话
2. 刷新页面
3. 再次查看会话内容
预期:
- 会话仍存在
- 消息数据不丢失
---
### C. 聊天主链路
#### 用例 C1:普通聊天发送
步骤:
1. 输入普通问题
2. 发送
预期:
- 用户消息显示
- 后端正常响应
- 前端能收到流式结果
#### 用例 C2:流式输出体验
步骤:
1. 发送稍复杂的问题
2. 观察回答是否逐步出现
预期:
- 回答不是一次性卡死后才出现
- token 流体验正常
#### 用例 C3:连续多轮对话
步骤:
1. 连续发送多条消息
2. 观察上下文是否连续
预期:
- 会话上下文连续
- 消息顺序正常
#### 用例 C4:重新生成
步骤:
1. 找到 assistant 消息
2. 触发 regenerate
预期:
- 能重新发起生成
- 页面状态正常
---
### D. 工具
#### 用例 D1:工具开关
步骤:
1. 开启/关闭工具选项
2. 发送消息
预期:
- 请求参数随工具状态变化
- 页面表现正常
---
### E. 扩展与工单能力
#### 用例 E1:打开扩展面板
步骤:
1. 打开扩展面板
2. 查看各扩展状态
预期:
- 面板可打开
- 扩展项展示正常
#### 用例 E2:工单扩展连接
步骤:
1. 在工单系统扩展中输入 key
2. 点击连接
预期:
- 按当前实现,前端会变成 connected
- 需要特别记录:这是前端模拟连接,不代表真实鉴权成功
#### 用例 E3:工单摘要加载
步骤:
1. 让工单扩展处于 connected 状态
2. 观察 ticket summary 是否出现
预期:
- 前端请求 `/api/tickets/summary`
- 成功时摘要展示正常
#### 用例 E4:工单数据真实性侧验证
步骤:
1. 观察摘要/工单接口返回
2. 检查网络请求
预期:
- 请求真实到后端 tickets 接口
- 不是前端静态写死
---
### F. 上传与附件
#### 用例 F1:单文件上传
步骤:
1. 选择一个小文件
2. 上传并发送消息
预期:
- 上传成功
- 消息中显示附件
#### 用例 F2:多文件上传
步骤:
1. 一次选择多个文件
2. 上传并发送消息
预期:
- 多文件都能显示
- 状态分别可见
#### 用例 F3:附件下载
步骤:
1. 点击附件下载
2. 观察下载接口请求和结果
预期:
- 正常触发 `/api/attachments/{id}/download`
- 可下载或跳转到有效资源
#### 用例 F4:超限/失败上传
步骤:
1. 上传异常文件或超大文件
2. 观察页面反馈
预期:
- 前端有失败提示
- 不出现假成功
---
### G. 稳定性与异常
#### 用例 G1:接口失败时页面反馈
步骤:
1. 观察异常请求场景
2. 记录页面提示与控制台错误
预期:
- 页面有基本反馈
- 不应无提示失败
#### 用例 G2:页面刷新与重进
步骤:
1. 刷新页面
2. 再次进入关键功能
预期:
- 不应出现明显状态错乱
---
## 5. 记录方式
测试中记录以下内容:
- 测试时间
- 页面 URL
- 测试环境
- 操作步骤
- 页面表现
- 控制台报错
- 网络请求 URL / 状态码
- 通过 / 失败 / 待确认
- 问题归因(前端 / 后端 / 配置 / 外部依赖)
---
## 6. 输出结果格式
测试完成后按以下格式输出:
1. 已验证通过
2. 存在问题
3. 待进一步确认
并对每个问题补充:
- 复现步骤
- 影响范围
- 初步判断原因
---
## 7. 当前执行顺序
1. 打开页面确认能访问
2. 验证会话功能
3. 验证聊天主链路
4. 验证工具切换
5. 验证扩展和工单能力
6. 验证上传和下载
7. 验证刷新和异常场景
8. 汇总结果
+668
View File
@@ -0,0 +1,668 @@
# COT 可视化(结构化执行轨迹)生产落地计划
## 背景与方案评估
### 关于"Gemini CoT"的定位澄清
Gemini 的原生 CoT 是指模型在输出最终答案前的推理 token(类似 Claude Extended Thinking)。Azure OpenAI / GPT-4o **不暴露模型级别的推理 scratchpad**,因此本方案实现的是 **Agent Activity Trace(结构化执行轨迹)**,本质是:
> 拦截 LangGraph ReAct 图的运行事件 → 结构化摘要 → SSE 推送 → 前端时间线渲染
这是比暴露原始 CoT 更合理的选择,也是 Gemini/ChatGPT Pro 实际采用的方式。
### 现状与差距
| 层 | 现状 | 生产缺口 |
|---|---|---|
| 后端 SSE | 只有 token/tool_start/tool_end/done,无摘要字段 | 需补全 6 类事件 + 摘要 + 耗时 + 错误检测 |
| 前端 SSE 消费 | tool_start/tool_end 完全忽略 | 新增全部事件处理分支 |
| 前端数据模型 | `Message` 无 traceItems 字段 | 扩展接口 |
| 前端 UI | 无 Trace 组件 | 新建 TracePanel,复用已有 Collapsible/Spinner |
| GeminiMessage | 不接收 model prop | 需透传 selectedModel |
| 数据持久化 | Message 表无 metadata 字段 | Trace 为会话内存态,不持久化(历史消息无 trace,合理) |
---
## 生产级事件协议
### 后端完整事件集(6 类)
```json
// 1. 状态事件 — 阶段感知
{"type": "status", "stage": "分析问题", "message": "正在理解您的问题..."}
// 2. 工具调用开始
{
"type": "tool_start",
"tool": "kb_search",
"title": "检索知识库",
"input_summary": "查询:产品规划路线图",
"ts": 1712620800000
}
// 3. 工具调用成功结束
{
"type": "tool_end",
"tool": "kb_search",
"title": "检索知识库",
"output_summary": "命中 3 条知识库记录",
"status": "success",
"duration_ms": 842,
"ts": 1712620800842
}
// 4. 工具调用失败
{
"type": "tool_error",
"tool": "web_search",
"title": "外部搜索",
"error_summary": "请求超时,已跳过",
"duration_ms": 8000,
"ts": 1712620808000
}
// 5. token(现有,不变)
{"type": "token", "content": "根据知识库..."}
// 6. done(现有,不变)
{"type": "done"}
```
---
## 后端实现(backend/app/api/chat.py)
### 全部改动
**新增导入:**
```python
import time
```
**在 `stream_response` 生成器函数中:**
```python
async def generate():
full_content: list[str] = []
tool_start_ts: dict[str, int] = {} # 记录各工具的起始时间戳
has_tool_activity = False # 是否有过工具调用
final_status_emitted = False # "整理答案"状态是否已发出
try:
# ① 在 graph 开始前发出初始状态
yield _sse({"type": "status", "stage": "分析问题", "message": "正在理解您的问题..."})
async for event in graph.astream_events(input_data, config=config, version="v2"):
kind = event.get("event", "")
# ② token 事件:在首个 token 前,若有工具调用则发"整理答案"状态
if kind == "on_chat_model_stream":
chunk = event.get("data", {}).get("chunk")
if chunk and hasattr(chunk, "content") and chunk.content:
if isinstance(chunk.content, str):
# 若工具调用已完成,在首 token 前插入"整理答案"状态
if has_tool_activity and not final_status_emitted:
yield _sse({"type": "status", "stage": "整理答案",
"message": "正在结合检索结果生成回复..."})
final_status_emitted = True
full_content.append(chunk.content)
yield _sse({"type": "token", "content": chunk.content})
# ③ 工具开始
elif kind == "on_tool_start":
tool_name = event.get("name", "unknown")
tool_input = event.get("data", {}).get("input", {})
ts = int(time.time() * 1000)
tool_start_ts[tool_name] = ts
has_tool_activity = True
yield _sse({
"type": "tool_start",
"tool": tool_name,
"title": TOOL_TITLES.get(tool_name, tool_name),
"input_summary": _summarize_input(tool_name, tool_input),
"ts": ts,
})
# ④ 工具结束(含错误检测)
elif kind == "on_tool_end":
tool_name = event.get("name", "unknown")
output = event.get("data", {}).get("output", "")
output_str = output if isinstance(output, str) else str(output)
ts = int(time.time() * 1000)
duration_ms = ts - tool_start_ts.pop(tool_name, ts)
is_error = _is_tool_error(output_str)
if is_error:
yield _sse({
"type": "tool_error",
"tool": tool_name,
"title": TOOL_TITLES.get(tool_name, tool_name),
"error_summary": _extract_error_summary(output_str),
"duration_ms": duration_ms,
"ts": ts,
})
else:
yield _sse({
"type": "tool_end",
"tool": tool_name,
"title": TOOL_TITLES.get(tool_name, tool_name),
"output_summary": _summarize_output(tool_name, output_str),
"status": "success",
"duration_ms": duration_ms,
"ts": ts,
})
except Exception as exc:
# 现有错误处理逻辑保持不变
...
finally:
ai_content = "".join(full_content)
if ai_content:
await _persist_ai_message(request.conversation_id, ai_content)
yield _sse({"type": "done"})
```
**新增辅助函数(同文件尾部):**
```python
def _sse(data: dict) -> bytes:
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n".encode("utf-8")
TOOL_TITLES: dict[str, str] = {
"kb_search": "检索知识库",
"ticket_list": "查询工单列表",
"ticket_detail": "查询工单详情",
"web_search": "外部搜索",
"generate_document": "生成文档",
"sandbox_run": "执行沙盒代码",
}
# 工具输出中的错误关键词(工具均返回字符串而非 raise)
_ERROR_KEYWORDS = (
"出错", "失败", "超时", "error", "failed", "timeout", "not available",
"no download link", "检索出错", "检索超时", "execution failed",
)
def _is_tool_error(output: str) -> bool:
lo = output.lower()
return any(kw in lo for kw in _ERROR_KEYWORDS)
def _extract_error_summary(output: str) -> str:
# 取首行,截断到 60 字符
first_line = output.split("\n")[0].strip()
return first_line[:60] if first_line else "工具调用失败"
def _summarize_input(tool_name: str, inp: dict | str) -> str:
if isinstance(inp, str):
return inp[:60]
match tool_name:
case "kb_search":
return f"查询:{str(inp.get('query', ''))[:50]}"
case "ticket_list":
return f"第 {inp.get('page', 1)} 页,每页 {inp.get('page_size', 20)} 条"
case "ticket_detail":
return f"工单 ID:{inp.get('ticket_id', '')}"
case "web_search":
return f"搜索:{str(inp.get('query', ''))[:50]}"
case "generate_document":
return str(inp.get('prompt', ''))[:60]
case "sandbox_run":
lang = inp.get('language', 'python')
lines = len(str(inp.get('code', '')).splitlines())
return f"{lang} 代码({lines} 行)"
case _:
return str(inp)[:60]
def _summarize_output(tool_name: str, output: str) -> str:
if not output or output.strip() == "(no output)":
return "无结果"
match tool_name:
case "kb_search":
count = output.count("---") + 1 if "---" in output else 1
return f"命中 {count} 条知识库记录"
case "ticket_list":
import re
m = re.search(r"Found (\d+) tickets", output)
return f"返回 {m.group(1)} 条工单" if m else "工单列表已获取"
case "ticket_detail":
return "工单详情已获取"
case "web_search":
count = output.count("##")
return f"找到 {max(count, 1)} 条搜索结果"
case "generate_document":
if "Download:" in output:
doc_type = "文档"
if "[PPT]" in output:
doc_type = "PPT"
elif "[Excel]" in output or "[Table]" in output:
doc_type = "表格"
elif "[Word]" in output:
doc_type = "Word 文档"
return f"{doc_type}已生成,可下载"
return "文档生成完成"
case "sandbox_run":
lines = len(output.splitlines())
exit_match = output.startswith("[Exit code:")
suffix = "(含错误)" if exit_match else ""
return f"执行完成,输出 {lines} 行{suffix}"
case _:
return output[:60]
```
---
## 前端实现
### 文件 1:lib/api.ts — 类型扩展
```typescript
// 扩展 ChatStreamEvent(完整字段)
export interface ChatStreamEvent {
type: "token" | "status" | "tool_start" | "tool_end" | "tool_error" | "done" | "error";
// token
content?: string;
// status
stage?: string;
message?: string;
// tool_start / tool_end / tool_error
tool?: string;
title?: string;
input_summary?: string;
output_summary?: string;
error_summary?: string;
status?: "success" | "error";
duration_ms?: number;
ts?: number;
}
// 前端 Trace 条目(统一结构)
export interface TraceItem {
id: string; // 唯一 id
type: "status" | "tool_start" | "tool_end" | "tool_error";
tool?: string; // 工具名(tool_* 类型)
title: string; // 展示标题
message?: string; // status 的描述文本
inputSummary?: string;
outputSummary?: string;
errorSummary?: string;
itemStatus: "running" | "success" | "error" | "info"; // UI 状态
durationMs?: number;
startTs: number; // 毫秒时间戳
}
```
**streamChat 函数签名不变**,只需更新 `ChatStreamEvent` 类型定义即可。
---
### 文件 2:components/gemini/GeminiMessage.tsx — 接口扩展与 TracePanel 集成
**扩展 `Message` 接口:**
```typescript
import type { TraceItem } from "@/lib/api";
export interface Message {
id: string;
role: "user" | "assistant";
content: string;
timestamp?: Date;
attachments?: AttachmentData[];
traceItems?: TraceItem[]; // 新增:执行轨迹(流式构建,不持久化)
}
```
**扩展 `GeminiMessageProps`:**
```typescript
interface GeminiMessageProps {
message: Message;
model?: "flash" | "auto" | "pro"; // 新增:用于决定 TracePanel 展示层级
onRegenerate?: (id: string) => void;
}
```
**Assistant 消息 JSX — 在 content 上方插入 TracePanel:**
```tsx
// 在 assistant 分支内,<div className="space-y-0.5"> 之前:
{message.traceItems && message.traceItems.length > 0 && (
<TracePanel
items={message.traceItems}
model={model ?? "auto"}
className="mb-3"
/>
)}
<div className="space-y-0.5">{renderContent(message.content)}</div>
```
---
### 文件 3:components/gemini/GeminiChat.tsx — 事件处理与 model 透传
**handleSend 的 onEvent 回调 — 完整替换:**
```typescript
(event) => {
if (event.type === "token" && event.content) {
// 现有 token 逻辑,不变
setConversations((prev) => prev.map((c) => {
if (c.id !== streamConvId) return c;
const exists = c.messages.some((m) => m.id === aiMsgId);
if (!exists) {
return { ...c, messages: [...c.messages, {
id: aiMsgId, role: "assistant" as const,
content: event.content!, timestamp: new Date(),
traceItems: [], // 初始化 traceItems
}]};
}
return { ...c, messages: c.messages.map((m) =>
m.id === aiMsgId ? { ...m, content: m.content + event.content } : m
)};
}));
} else if (event.type === "status") {
// status 事件:追加 info 条目(分析问题 / 整理答案)
const item: TraceItem = {
id: `status-${event.ts ?? Date.now()}`,
type: "status",
title: event.stage ?? "处理中",
message: event.message,
itemStatus: "info",
startTs: event.ts ?? Date.now(),
};
_appendTraceItem(streamConvId, aiMsgId, item, setConversations);
} else if (event.type === "tool_start" && event.tool) {
// 工具开始:状态为 running
const item: TraceItem = {
id: `${event.tool}-${event.ts ?? Date.now()}`,
type: "tool_start",
tool: event.tool,
title: event.title ?? event.tool,
inputSummary: event.input_summary,
itemStatus: "running",
startTs: event.ts ?? Date.now(),
};
_appendTraceItem(streamConvId, aiMsgId, item, setConversations);
} else if (event.type === "tool_end" && event.tool) {
// 工具结束:更新 running → success
_updateTraceItem(streamConvId, aiMsgId, event.tool, {
type: "tool_end",
outputSummary: event.output_summary,
itemStatus: "success",
durationMs: event.duration_ms,
}, setConversations);
} else if (event.type === "tool_error" && event.tool) {
// 工具错误:更新 running → error
_updateTraceItem(streamConvId, aiMsgId, event.tool, {
type: "tool_error",
errorSummary: event.error_summary,
itemStatus: "error",
durationMs: event.duration_ms,
}, setConversations);
}
}
```
**新增辅助函数(文件顶层,组件外部):**
```typescript
function _appendTraceItem(
convId: string, msgId: string, item: TraceItem,
setConversations: React.Dispatch<React.SetStateAction<Conversation[]>>
) {
setConversations((prev) => prev.map((c) => {
if (c.id !== convId) return c;
return { ...c, messages: c.messages.map((m) =>
m.id === msgId ? { ...m, traceItems: [...(m.traceItems ?? []), item] } : m
)};
}));
}
function _updateTraceItem(
convId: string, msgId: string, tool: string,
updates: Partial<TraceItem>,
setConversations: React.Dispatch<React.SetStateAction<Conversation[]>>
) {
setConversations((prev) => prev.map((c) => {
if (c.id !== convId) return c;
return { ...c, messages: c.messages.map((m) => {
if (m.id !== msgId) return m;
// 找到最后一个同名 running 条目并更新
const items = [...(m.traceItems ?? [])];
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].tool === tool && items[i].itemStatus === "running") {
items[i] = { ...items[i], ...updates };
break;
}
}
return { ...m, traceItems: items };
})};
}));
}
```
**透传 selectedModel 给 GeminiMessage:**
找到 `GeminiMessage` 的渲染位置,新增 `model={selectedModel}` prop。
---
### 文件 4:components/gemini/TracePanel.tsx(新建)
完整组件,使用已有的 `Collapsible`(`components/ui/collapsible.tsx`)和 `Spinner`(`components/ui/spinner.tsx`)。
```tsx
"use client";
import { useState } from "react";
import { CheckCircle2, XCircle, ChevronDown, ChevronRight, Loader2, Zap, Brain } from "lucide-react";
import { cn } from "@/lib/utils";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import type { TraceItem } from "@/lib/api";
interface TracePanelProps {
items: TraceItem[];
model: "flash" | "auto" | "pro";
className?: string;
}
// ─── 工具图标映射 ─────────────────────────────────────────
const TOOL_ICONS: Record<string, string> = {
kb_search: "🗂️",
web_search: "🌐",
ticket_list: "🎫",
ticket_detail: "🎫",
generate_document: "📄",
sandbox_run: "⚙️",
};
// ─── 状态图标 ─────────────────────────────────────────────
function StatusIcon({ status }: { status: TraceItem["itemStatus"] }) {
switch (status) {
case "running":
return <Loader2 size={13} className="animate-spin text-[var(--gem-accent)]" />;
case "success":
return <CheckCircle2 size={13} className="text-emerald-400" />;
case "error":
return <XCircle size={13} className="text-red-400" />;
case "info":
return <Brain size={13} className="text-[var(--gem-text-muted)]" />;
}
}
// ─── 耗时格式化 ───────────────────────────────────────────
function formatDuration(ms?: number): string {
if (!ms) return "";
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
// ─── 单条 Trace 条目 ──────────────────────────────────────
function TraceItemRow({ item, expanded }: { item: TraceItem; expanded: boolean }) {
const icon = item.tool ? TOOL_ICONS[item.tool] ?? "🔧" : null;
const isRunning = item.itemStatus === "running";
return (
<div
className={cn(
"flex items-start gap-2 py-1.5 px-2 rounded-lg text-xs transition-colors",
isRunning && "bg-[var(--gem-surface-2)]",
)}
>
{/* 状态图标 */}
<div className="mt-0.5 shrink-0">
<StatusIcon status={item.itemStatus} />
</div>
{/* 内容 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
{icon && <span className="text-xs">{icon}</span>}
<span
className={cn(
"font-medium",
item.itemStatus === "error"
? "text-red-400"
: "text-[var(--gem-text)]",
)}
>
{item.title}
</span>
{item.durationMs !== undefined && (
<span className="text-[var(--gem-text-muted)] ml-auto shrink-0">
{formatDuration(item.durationMs)}
</span>
)}
</div>
{/* 详情(Pro 模式或展开状态下显示) */}
{expanded && (
<div className="mt-0.5 space-y-0.5">
{item.inputSummary && (
<p className="text-[var(--gem-text-muted)] truncate">{item.inputSummary}</p>
)}
{item.outputSummary && (
<p className="text-[var(--gem-text-secondary)] truncate">{item.outputSummary}</p>
)}
{item.errorSummary && (
<p className="text-red-400 truncate">{item.errorSummary}</p>
)}
{item.message && item.type === "status" && (
<p className="text-[var(--gem-text-muted)]">{item.message}</p>
)}
</div>
)}
</div>
</div>
);
}
// ─── 主组件 ───────────────────────────────────────────────
export function TracePanel({ items, model, className }: TracePanelProps) {
const isPro = model === "pro";
const [open, setOpen] = useState(isPro); // Pro 默认展开
// 生成单行摘要(Auto 模式折叠时显示)
const summaryText = (() => {
const running = items.filter((i) => i.itemStatus === "running");
if (running.length > 0) return `正在 ${running[running.length - 1].title}...`;
const tools = items.filter((i) => i.type === "tool_end");
const errors = items.filter((i) => i.type === "tool_error");
if (errors.length > 0) return `已完成(${errors.length} 个工具调用失败)`;
if (tools.length > 0) {
const names = tools.map((t) => t.title).join("、");
return `已完成:${names}`;
}
return "正在分析...";
})();
const hasRunning = items.some((i) => i.itemStatus === "running");
return (
<Collapsible open={open} onOpenChange={setOpen} className={cn("w-full", className)}>
{/* 触发行(始终可见)*/}
<CollapsibleTrigger asChild>
<button
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-xs",
"text-[var(--gem-text-muted)] hover:text-[var(--gem-text)]",
"hover:bg-[var(--gem-surface-2)] transition-colors",
)}
>
<Zap
size={12}
className={cn(
"shrink-0",
hasRunning ? "text-[var(--gem-accent)] animate-pulse" : "text-[var(--gem-text-muted)]",
)}
/>
<span className="flex-1 text-left truncate">{summaryText}</span>
{open
? <ChevronDown size={12} className="shrink-0" />
: <ChevronRight size={12} className="shrink-0" />
}
</button>
</CollapsibleTrigger>
{/* 展开内容 */}
<CollapsibleContent>
<div
className={cn(
"mt-1 ml-2 border-l border-[var(--gem-border)] pl-3 space-y-0.5",
)}
>
{items.map((item) => (
<TraceItemRow
key={item.id}
item={item}
expanded={isPro} // Pro 模式显示摘要详情
/>
))}
</div>
</CollapsibleContent>
</Collapsible>
);
}
```
---
## 关键文件清单
| 文件 | 改动类型 | 核心内容 |
|------|---------|---------|
| `backend/app/api/chat.py` | 修改 | 6 类 SSE 事件、摘要函数、错误检测、耗时计算 |
| `frontend/lib/api.ts` | 修改 | `ChatStreamEvent` 扩展、新增 `TraceItem` 类型 |
| `frontend/components/gemini/GeminiMessage.tsx` | 修改 | `Message` 加 `traceItems`、`GeminiMessageProps` 加 `model`、集成 `TracePanel` |
| `frontend/components/gemini/GeminiChat.tsx` | 修改 | onEvent 补全所有事件分支、`_appendTraceItem`/`_updateTraceItem` 辅助函数、透传 `model` |
| `frontend/components/gemini/TracePanel.tsx` | 新建 | Auto/Pro 双模式时间线,使用已有 Collapsible + Spinner |
---
## 约束说明
- **前端文件为 read-only**(CLAUDE.md 限制),需用户显式授权后执行
- `TracePanel` 仅使用现有 CSS 变量(`--gem-*`)和已有 UI 组件,不引入新依赖
- Trace 数据为会话内存态,历史消息加载时 `traceItems` 为空(符合预期)
- 工具错误通过输出字符串检测(因工具层均 return string 不 raise),关键词见 `_ERROR_KEYWORDS`
---
## 验证方式
1. **后端事件格式验证**
```bash
curl -N -X POST http://localhost:8000/api/chat/stream \
-H "Content-Type: application/json" \
-d '{"message":"帮我搜索产品规划","conversation_id":"test-1","tools":["knowledge"],"model":"flash"}'
```
期望看到:`status` → `tool_start`(含 input_summary)→ `tool_end`(含 output_summary + duration_ms)→ `token`... → `done`
2. **工具错误验证**:断开 KB Agent,发送知识库查询,期望看到 `tool_error` 事件(含 error_summary)
3. **Auto 模式**:选 Auto + 知识库工具,助手消息上方出现单行折叠状态栏,点击展开显示时间线
4. **Pro 模式**:切换 Pro,状态栏默认展开,每条工具调用显示标题 + 摘要 + 耗时
5. **无工具时**:不选任何工具,TracePanel 不出现(`traceItems` 为空)
6. **多工具顺序调用**:同时开启 knowledge + tickets,验证时间线条目顺序正确,各自 duration 准确
+152
View File
@@ -0,0 +1,152 @@
# 外部服务接入配置
> **使用说明**:此文档用于记录外部服务的接入方式、环境变量和调用示例,便于开发、联调与排障。
>
> 当前服务按“代码已支持 + 部署环境变量由 Azure Web App 提供”的口径记录为已接入;实际运行效果仍以部署环境变量是否正确配置为准。
>
> 已接入的服务会标注 ✅。
---
## 1. LLM 大语言模型
> 当前使用 Azure OpenAI,已在后端 graph.py / main.py 中集成。
### 环境变量(已配置)
```
AZURE_OPENAI_ENDPOINT=https://ai-gzy0016231ai975636166896.cognitiveservices.azure.com/openai/responses?api-version=2025-04-01-preview/
AZURE_OPENAI_API_KEY=DlsBBFJ0RgMGdKxsdBWnlYj6IRdULzflGsKFCXnMBzqs4ZVHMtqZJQQJ99CCACHYHv6XJ3w3AAAAACOG45do
AZURE_OPENAI_API_VERSION=2025-04-01-preview
AZURE_OPENAI_DEPLOYMENT=gpt-5.4
```
### 请求示例
```bash
curl -X POST "${AZURE_OPENAI_ENDPOINT}/openai/deployments/${AZURE_OPENAI_DEPLOYMENT}/chat/completions?api-version=${AZURE_OPENAI_API_VERSION}" \
-H "Content-Type: application/json" \
-H "api-key: ${AZURE_OPENAI_API_KEY}" \
-d '{
"messages": [{"role": "user", "content": "你好"}],
"max_tokens": 1000
}'
```
---
## 2. 内部知识库检索
> 当前通过 agnetdoc Function App 调用 Azure AI Search。
### 环境变量(已配置)
```
KB_AGENT_URL=https://agnetdoc-cve0guf5h8eggmej.southeastasia-01.azurewebsites.net
KB_AGENT_API_KEY=LdyzZlS3Nn1xFejqPsHn1nW-zsj9FLpC5KCbopCkQWKCAzFuLEUU4w==
KB_AGENT_SEARCH_PATH=/api/v1/search
KB_AGENT_SEARCH_TIMEOUT_SEC=15
```
### 请求示例
```bash
curl -X POST "${KB_AGENT_URL}/api/v1/search" \
-H "Content-Type: application/json" \
-H "api-key: ${KB_AGENT_API_KEY}" \
-d '{
"query": "Taiji Agent 产品规划",
"top": 8,
"search_mode": "hybrid"
}'
```
### 响应格式
```json
{
"results": [
{
"id": "xxx",
"title": "文档标题",
"content": "文档内容...",
"category": "分类",
"score": 0.85,
"url": "https://...",
"tags": ["tag1"],
"project": "项目名"
}
]
}
```
---
## 3. 外部 AI 搜索
目前外部搜索采用https://mcp.jina.ai/sse 或者 /v1 可优先测试
jina_e26dc30420a44a1e859216528065b203TkMRmsoz-FgMDQC5FZX9jr5oF2CI
要求使用搜索和读取两个工具,并且要结合重排模型使用。
满足企业级的搜索准确度,包括不限于图片和视频
按照深度和快速来定义搜索内容和搜索的质量,还需要满足前端的展示。
支持MCP
---
## 4. 沙盒代码执行
沙盒采用现成的解决方案。https://docs.langchain.com/oss/python/integrations/sandboxes/daytona
https://app.daytona.io/api
dtn_066b83f57f0337c96fae2ef1f5c8456477a39dfbd5fc615456263fd4947108c2
依然要满足前端输出要求。
## 5. 文档生成 Agent
http://doc-creator-agent-b0d02105-a557fe.taijiagnet.com
sk-t5R8jkEp6IA7_ghJ6Hy1rQ
http://agnetdoc.taijiaicloud.com/node/019cd223-9d13-7566-a2ea-52ee67645463
## 6. 工单系统
> gongdan 工单系统,只读集成。
### 环境变量(已配置)
```
GONGDAN_API_BASE=https://gongdan-b5fzbtgteqd5gzfb.eastasia-01.azurewebsites.net
GONGDAN_API_KEY=gd_live_a28b3db84385be75d1d3b6b6023784c27200d045
```
### 请求示例
```bash
# 工单列表
curl -X GET "${GONGDAN_API_BASE}/api/tickets?page=1&pageSize=20" \
-H "X-Api-Key: ${GONGDAN_API_KEY}"
# 工单详情
curl -X GET "${GONGDAN_API_BASE}/api/tickets/{ticketId}" \
-H "X-Api-Key: ${GONGDAN_API_KEY}"
```
---
## 7. Pgsql数据库
```
DATABASE_URL=postgresql://USER:PASSWORD@<host>:5432/yydn?sslmode=require
```
```
dataope.postgres.database.azure.com
azuredb:h13nYoFJX6QrfLzB8bdipEUCjsZq2P7W
```
---
### 8.Redis
```
oper.redis.cache.windows.net:6380,password=bY8ZNwyJX60UwN5NPqnl6HRODfTV0efkDAzCaF1PrOU=,ssl=True,abortConnect=False
```
---
### 9.存储账户
```
DefaultEndpointsProtocol=https;AccountName=authdatablol;AccountKey=sm3ysR0zAmS9OLtiHVau3Wj122YWQJTuMHAyHO4ReIrpe6+3r1K7oGfFLGCZSZh+1n72gbK1q/+C+AStgrZ7fw==;EndpointSuffix=core.windows.net
```
---
### 10.service bus
```
Endpoint=sb://databus.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=+b7+0KMW1UQt5mbJEkA7uRxds4h0h4VNK+ASbOH5q3E=
```
---
+918
View File
@@ -0,0 +1,918 @@
# SOC 项目后端功能方案(基于 LangChain,按功能拆解)
> 约束:在未获得明确允许前,不修改前端交互,只补后端能力、编排链路和数据层。
> 目标:严格围绕当前 `~/go/soc` 这个 Gemini 风格前端,按“每个功能用什么技术完成什么功能”来写,核心框架改为 **LangChain / LangGraph**,不再以 FastAPI 作为方案重点。
---
## 1. 对话流式回复
### 要完成什么功能
- 用户在当前聊天输入框发送消息
- 后端实时返回回答内容
- 支持“思考中 / 检索中 / 生成中”的状态
- 不改变现有前端交互,只替换当前前端 `simulateAIResponse()`
### 用什么技术
- **LangChain**:负责组织提示词、消息上下文、模型调用
- **LangGraph**:负责整个对话节点编排与状态流转
- **Azure OpenAI**:生成最终回答
- **SSE**:把 LangChain/LangGraph 执行过程和回答流式推给前端
- **PostgreSQL**:保存会话和消息记录
### 怎么落地
- 以 `LangGraph StateGraph` 建立一个对话图:
- `receive_message`
- `load_history`
- `route_tools`
- `call_llm`
- `persist_message`
- 前端发送消息后,后端触发 graph 执行
- Azure OpenAI 通过 LangChain chat model 调用
- 生成的 token 和中间状态通过 SSE 返回给前端
### 输出结果
- 前端仍然保持当前 Gemini 风格聊天交互
- 从 mock 回复升级为真实流式 AI 回复
- 后续所有工具调用都能接到同一个 graph 里
---
## 2. 会话管理
### 要完成什么功能
- 左侧历史会话列表从真实数据读取
- 支持新建、切换、删除会话
- 支持自动生成标题
- 会话上下文可在 LangChain 中继续使用
### 用什么技术
- **PostgreSQL**:保存 conversation 和 message
- **LangChain Memory / Message History 抽象**:管理历史消息上下文
- **SQLAlchemy / SQLModel**:管理数据表
### 怎么落地
- conversations/messages 数据存入 PostgreSQL
- 在 LangChain 层使用 `BaseChatMessageHistory` 风格封装数据库消息
- 每次进入 graph 时先加载历史消息
- 标题生成可以由 LLM 在首轮消息后自动归纳
### 输出结果
- 当前左侧 mock 会话可以替换为真实会话记录
- 后端真正具备多轮上下文记忆能力
---
## 3. 内部知识库检索
### 要完成什么功能
- 当用户问产品、方案、配置、内部文档时
- 自动检索内部知识库
- 再让模型基于检索结果生成回答
- 回答中附带引用来源
### 用什么技术
- **LangChain Tool**:把 KB_AGENT 封装成知识库工具
- **KB_AGENT 接口**:作为实际搜索源
- **Azure OpenAI**:总结检索结果并生成回答
- **LangGraph**:决定何时调用知识库节点
### 怎么落地
- 编写 `kb_search_tool`
- 工具内部调用 `KB_AGENT_URL + KB_AGENT_SEARCH_PATH`
- 返回统一的文档列表结构
- graph 中当识别为内部知识类问题时,先进入 `kb_search` 节点,再进入 `llm_answer`
### 输出结果
- 产品知识问答不再靠模型空想
- 回答可基于真实内部资料
- 更适合售前、售后、研发支持
---
## 4. 外部 AI 搜索
### 要完成什么功能
- 处理实时互联网问题、行业动态、外部资料调研
- 支持搜索、网页读取、重排
- 支持 fast / deep / auto 三种搜索质量
- 后续支持图片和视频检索结果
### 用什么技术
- **LangChain Tool**:封装外部搜索工具链
- **Jina Search API**:外部搜索
- **Jina Reader**:网页正文读取
- **Rerank 模型**:重排结果
- **LangGraph**:编排 Search -> Read -> Rerank -> Answer
- **Azure OpenAI**:生成最终总结回答
### 怎么落地
- 分成三个 tool:
- `web_search_tool`
- `web_read_tool`
- `rerank_tool`
- 在 graph 中建立外部搜索链路:
- 搜索候选
- 读取正文
- 重排结果
- 将高质量上下文交给 LLM
- `fast/deep/auto` 可作为 graph state 中的参数
### 输出结果
- 外部信息回答准确度显著提升
- 满足文档要求里的企业级外部搜索能力
- 为后续图片、视频结果展示预留结构
---
## 5. 工单系统只读接入
### 要完成什么功能
- 查询工单列表
- 查询工单详情
- 汇总 P0/P1 工单
- 支持聊天中分析工单趋势、共性问题、故障重点
- 替换当前前端 mock 工单摘要
### 用什么技术
- **LangChain Tool**:把 gongdan API 封装为工单工具
- **Gongdan HTTP API**:工单实际数据源
- **Azure OpenAI**:对工单结果做总结和归纳
- **PostgreSQL(可选缓存)**:保存查询结果和摘要缓存
### 怎么落地
- 编写工具:
- `ticket_list_tool`
- `ticket_detail_tool`
- `ticket_summary_tool`
- 当用户问题涉及工单时,graph 路由到 ticket 节点
- 工具取回结果后,再由 LLM 进行总结
- 当前前端的 TicketSummary 数据以后改成读取真实接口结果,但不改交互样式
### 输出结果
- 工单分析能力可直接在聊天里使用
- 首页/聊天区的工单摘要可从 mock 变成真实数据
---
## 6. 文档生成
### 要完成什么功能
- 用户要求生成方案、汇报、纪要、总结文档时
- 后端自动进入文档生成流程
- 返回任务状态和结果
- 生成正式文档链接或结果卡片
### 用什么技术
- **LangChain Tool / Runnable**:封装文档生成能力
- **Doc Creator Agent HTTP API**:实际生成正式文档
- **LangGraph**:把“文档生成”作为 graph 的分支节点
- **PostgreSQL**:保存文档任务记录
- **Azure OpenAI**:先整理文档结构或提纲
### 怎么落地
- graph 中识别“生成文档”类意图
- 先用 LLM 生成结构化文档提纲
- 再调用 doc creator agent
- 把任务状态写入数据库
- 前端依旧保持聊天式入口,只在消息中显示结果卡片
### 输出结果
- 销售、售前、汇报场景可以直接从聊天进入正式文档输出
- 文档生成成为对话系统中的标准能力节点
---
## 7. 沙盒代码执行
### 要完成什么功能
- 分析 CSV、JSON、日志、结构化数据
- 在安全环境中执行代码
- 返回分析结果、图表和文件
- 不改变前端交互,只把结果塞回当前聊天流里
### 用什么技术
- **LangChain Tool**:把沙盒能力封装为可调用工具
- **Daytona Sandbox**:安全执行环境
- **Python 数据工具链**:pandas、matplotlib、json、csv
- **LangGraph**:按意图路由到 sandbox 节点
- **PostgreSQL**:保存执行记录
### 怎么落地
- 第一阶段不开放任意代码执行
- 只先封装几个固定工具:
- `csv_summary_tool`
- `json_transform_tool`
- `data_analysis_tool`
- `chart_generate_tool`
- graph 根据问题和附件类型决定是否调用 sandbox
### 输出结果
- 后端不仅能“回答”,还能“执行”和“计算”
- 数据类问题能返回真正算出来的结果
---
## 8. 附件上传与解析
### 要完成什么功能
- 接收用户上传的附件
- 保存附件元数据
- 提取文本内容进入上下文
- 为知识问答、文档生成、沙盒分析提供输入
### 用什么技术
- **对象存储/本地存储**:保存附件
- **LangChain Document Loader**:解析 PDF、DOCX、TXT、CSV 等文件
- **PostgreSQL**:保存附件元数据
- **LangGraph**:把附件解析结果接入 graph state
### 怎么落地
- 上传后先保存附件和元数据
- 再用 LangChain loader 抽取文本
- 将解析结果挂到当前会话 state 中
- 当用户继续提问时,graph 可以把附件内容作为上下文输入 LLM 或工具
### 输出结果
- 未来可以真正支持“基于附件提问”和“基于附件分析”
- 为文档生成和沙盒执行提供输入材料
---
## 9. 工具编排层
### 要完成什么功能
- 判断用户当前问题到底需要哪种能力
- 决定先查 KB、先查工单、还是先查外部搜索
- 决定是否触发文档生成或沙盒分析
- 把多个工具结果统一整理给模型
### 用什么技术
- **LangGraph**:整个系统的核心编排框架
- **LangChain Tools**:封装 KB、Search、Tickets、Docs、Sandbox
- **Azure OpenAI**:辅助做意图判断、结果总结
### 怎么落地
- graph 中至少有这些节点:
- `router`
- `kb_search`
- `web_search`
- `ticket_query`
- `doc_generate`
- `sandbox_run`
- `llm_answer`
- `persist`
- 第一阶段可以先“规则路由 + LLM总结”
- 第二阶段再升级为“LLM路由 + 工具调用决策”
### 输出结果
- 后端不再是散乱接口集合,而是统一 Agent 编排系统
- 前端只保留一个 Gemini 风格聊天入口即可
---
## 10. 数据持久化与基础设施
### 要完成什么功能
- 保存历史会话
- 保存消息记录
- 保存工具调用记录
- 保存附件记录
- 保存文档任务记录
- 保存 graph 执行状态和日志
- 提升缓存能力、异步任务能力和文件持久化能力
### 用什么技术
- **PostgreSQL**:主数据库,保存会话、消息、任务、工具记录
- **LangGraph Checkpointer / State Persistence**:保存 graph 执行状态
- **Redis**:缓存热点结果、会话临时状态、短期上下文、速率控制
- **Azure Storage Account**:保存附件、图表、导出文件、文档产物
- **Azure Service Bus**:承载异步任务与解耦长链路处理
### 怎么落地
- PostgreSQL 中至少建立以下表:
- `conversations`
- `messages`
- `tool_runs`
- `attachments`
- `document_tasks`
- `graph_runs`
- Redis 用于:
- 外部搜索结果缓存
- KB 搜索缓存
- 工单摘要缓存
- 正在运行的 graph/session 临时状态
- SSE 会话短状态同步
- Azure Storage Account 用于:
- 用户上传附件原始文件
- Sandbox 输出文件
- 图表与中间产物
- 文档生成结果文件
- Azure Service Bus 用于:
- 文档生成异步任务派发
- Sandbox 长任务调度
- 外部搜索深度模式异步并发编排
- 后续告警/通知类事件扩展
### 输出结果
- 对话、工具、任务都有追踪记录
- graph 执行链路具备可恢复能力
- 系统具备缓存、异步任务和文件持久化基础设施
---
## 11. Redis 缓存层
### 要完成什么功能
- 降低外部接口重复调用成本
- 提升对话链路响应速度
- 处理短期状态、热点数据和限流控制
### 用什么技术
- **Azure Redis**:缓存层
- **LangChain / LangGraph 外围状态管理**:结合缓存保存中间态
### 怎么落地
- 缓存这些内容:
- 相同 query 的 KB 搜索结果
- 相同 query 的外部搜索与重排结果
- 工单摘要结果
- 文档生成任务短状态
- 会话级短期上下文摘要
- 为外部搜索和知识库增加 TTL
- 为 Service Bus 异步任务增加状态缓存
### 输出结果
- 系统速度更稳定
- 外部服务成本更低
- 可支撑更高并发下的会话请求
---
## 12. 存储账户(文件与产物存储)
### 要完成什么功能
- 持久化用户上传附件
- 保存文档生成结果
- 保存 Sandbox 执行生成的图表/文件
- 为前端提供附件与结果文件访问地址
### 用什么技术
- **Azure Blob Storage**:统一文件对象存储
- **LangChain Document Loader**:结合存储文件做解析
### 怎么落地
- 上传文件后先保存到 Blob Storage
- 数据库中记录 blob URL、文件类型、所属消息/会话
- 文档生成与 Sandbox 产物统一落到 Blob Storage
- 前端保持现有交互,仅在消息中附带文件结果卡片或链接
### 输出结果
- 所有附件和中间产物有统一落盘位置
- 后续分析、下载、追踪都更方便
---
## 13. Service Bus 异步任务层
### 要完成什么功能
- 处理长耗时任务
- 解耦即时对话链路和后台异步处理链路
- 支持重试、失败恢复、延后处理
### 用什么技术
- **Azure Service Bus**:消息队列 / 异步任务总线
- **LangGraph**:消费任务后继续执行长链路节点
### 怎么落地
- 把这些任务异步化:
- 文档生成
- Sandbox 长任务
- 深度外部搜索
- 未来的大批量分析任务
- 聊天主链路先返回“任务已受理”状态
- Worker 从 Service Bus 拉取任务继续执行
- 执行结果写数据库和存储账户,再回推前端
### 输出结果
- 避免主对话链路阻塞
- 长任务处理更稳定
- 适合企业级系统扩展
---
## 14. MCP 方式接入外部搜索
### 要完成什么功能
- 利用 `https://mcp.jina.ai/sse` 这一类能力,以 MCP 方式接入外部搜索
- 让外部搜索不只是普通 HTTP API,而是可作为标准工具节点接入 LangChain / LangGraph
### 用什么技术
- **MCP(Model Context Protocol)**:统一工具协议
- **Jina MCP SSE / v1**:外部搜索与读取能力来源
- **LangChain Tool 封装层**:把 MCP 调用转换成 graph 可调用工具
### 怎么落地
- 优先测试 Jina 提供的 `/sse` 和 `/v1` 两种入口
- 将 Search 和 Read 分别封装成两个 tool
- 在外部搜索节点中统一走 MCP 接入层,保留将来替换搜索供应商的可能
- 重排仍保留单独节点,以便保障搜索质量控制
### 输出结果
- 外部搜索链路更标准化
- 更容易扩展到更多 MCP 服务
- 对 LangChain / LangGraph 编排更友好
---
## 15. 基于当前前端代码补充的后端缺口与完善方案
> 这一章专门对应当前前端已经存在、但此前后端方案没有完整覆盖的功能点。不含认证和权限,只补业务后端能力。
### 15.1 消息反馈(赞 / 踩)
#### 要完成什么
- 用户对 assistant 消息进行点赞或点踩
- 后端记录反馈结果
- 后续可用于回答质量分析、提示词优化和问题回溯
#### 用什么技术
- **PostgreSQL**:保存反馈记录
- **LangGraph 旁路记录**:反馈不进入主对话 graph
- **Redis(可选)**:做短期统计缓存
#### 怎么落地
- 新增表:`message_feedback`
- `id`
- `message_id`
- `conversation_id`
- `feedback_type` (`up` / `down`)
- `reason`(可空,后续扩展)
- `created_at`
- 新增接口:
- `POST /api/messages/{id}/feedback`
- 前端点击赞/踩后直接调用该接口
- 第一阶段先只记录 `up/down`,不做复杂原因分类
---
### 15.2 模型切换映射
#### 要完成什么
- 前端已有 Flash / Pro 与顶部模型选择入口
- 第一阶段后端先统一固定使用 **GPT-5.4**
- 但保留字段和映射结构,后续再扩展多模型、多链路
#### 用什么技术
- **LangChain model wrapper**:模型封装
- **LangGraph state**:保存 `model_profile`
- **PostgreSQL conversation metadata**:记录选择结果
#### 怎么落地
- 前端若传模型字段,第一阶段统一映射为:
- `model_provider = azure_openai`
- `model_name = gpt-5.4`
- 保留 metadata 字段:
- `selected_model`
- `selected_mode`
- 当前只做字段记录与透传,不做真正多模型切换
- 第二阶段再扩为 flash/pro 对应不同 graph 策略
---
### 15.3 工具显式开关控制
#### 要完成什么
- 前端工具 chips:
- 搜索
- 内部知识库
- 沙盒
- 文档生成
- 用户手动启用哪些工具,后端就只允许调用这些工具
- 用户未选择时,后端才走自动路由
#### 用什么技术
- **LangGraph state**:保存当前消息工具选择
- **LangChain tools registry**:统一工具注册
- **tool allowlist / denylist**:工具调用控制
#### 怎么落地
- 前端发消息时附带:
```json
{
"enabled_tools": ["search", "knowledge"]
}
```
- graph state 增加:
- `enabled_tools`
- `tool_selection_mode` (`auto` / `manual`)
- router 节点规则:
- `manual` 模式:只能从 allowlist 中路由
- `auto` 模式:按规则或模型自由决策
- 工具执行前统一做可用性校验
---
### 15.4 多文件上传与消息绑定
#### 要完成什么
- 一次上传多个文件
- 每个文件单独保存
- 文件和某条消息绑定
- 文件可参与知识问答、搜索、Sandbox 分析和文档生成
#### 用什么技术
- **Azure Blob Storage**:存储文件
- **PostgreSQL**:存储附件元数据
- **LangChain Document Loaders**:解析附件内容
- **消息-附件关联机制**:支撑多文件场景
#### 怎么落地
- 新增表:`attachments`
- `id`
- `conversation_id`
- `message_id`(允许先空,待消息发送后再绑定)
- `file_name`
- `content_type`
- `storage_url`
- `parse_status`
- `parsed_text`
- `created_at`
- 新增接口:
- `POST /api/attachments`
- `POST /api/messages/{id}/attachments/bind`
- 推荐流程:
1. 前端先上传多个文件
2. 后端返回 attachment ids
3. 前端发消息时附带 attachment ids
4. 后端完成消息与附件绑定
- 解析流程异步化,避免阻塞主聊天链路
---
### 15.5 扩展程序连接管理
#### 要完成什么
- 支持扩展程序的连接、断开、修改 key、查看状态
- 页面刷新后仍保留扩展连接状态
- 扩展状态可被后端 graph 感知
#### 用什么技术
- **PostgreSQL**:保存扩展配置与状态
- **加密存储机制**:保存敏感配置
- **extension registry**:统一扩展管理
- **LangChain tool 注册机制**:根据扩展状态暴露工具
#### 怎么落地
- 新增表:`extensions`
- `id`
- `extension_type` (`ticket` / `sales` / `cloud`)
- `display_name`
- `status`
- `config_encrypted`
- `last_check_at`
- `last_check_status`
- 新增接口:
- `GET /api/extensions`
- `POST /api/extensions/{type}/connect`
- `POST /api/extensions/{type}/disconnect`
- `POST /api/extensions/{type}/validate`
- 第一阶段先完成工单系统全链路,销售和云管先保留扩展框架
---
### 15.6 销售系统 / 云管系统预留
#### 要完成什么
- 虽然当前两套系统还在开发,但后端要预留统一扩展接入结构
- 避免未来工单、销售、云管三套系统接入方式不一致
#### 用什么技术
- **统一 extension schema**
- **summary provider 接口**
- **tool provider 接口**
- **connection config schema**
#### 怎么落地
- 一期不要求真实接入销售/云管 API
- 但必须预留:
- 扩展类型定义
- tool 注册入口
- summary 注册入口
- 状态位和配置结构
- 后续新增业务系统时不需要推翻现有后端结构
---
### 15.7 通用扩展摘要机制
#### 要完成什么
- 不只是工单系统,未来销售、云管系统接入后,也能输出首页/对话页摘要卡片
- 后端统一提供摘要机制
#### 用什么技术
- **summary provider registry**:每个扩展实现自己的摘要提供者
- **Redis**:缓存摘要结果
- **PostgreSQL**:记录摘要生成时间与状态
- **LangChain summarizer(可选)**:对原始数据做摘要
#### 怎么落地
- 新增统一摘要接口:
- `GET /api/extensions/summaries`
- 返回结构示例:
```json
[
{
"extension_type": "ticket",
"status": "connected",
"summary_type": "ticket_summary",
"data": {}
}
]
```
- 第一阶段先实现 ticket summary provider
- 但接口设计按多扩展统一返回
---
### 15.8 结构化消息块协议
#### 要完成什么
- 后端不能只返回纯文本
- 需要支持:
- 文本
- 引用来源
- 摘要卡片
- 文件结果
- 工具状态
- 错误块
#### 用什么技术
- **LangGraph 标准化事件输出**
- **message block schema**
- **前后端统一 JSON 协议**
#### 怎么落地
- 定义统一 block 结构:
```json
{
"type": "text | citation | summary_card | artifact | tool_status | error",
"payload": {}
}
```
- assistant message 最终存储结构:
```json
{
"id": "...",
"blocks": []
}
```
- SSE 中间态也复用 block/event 体系
- 第一阶段前端至少支持:
- `text`
- `tool_status`
- `citation`
- `summary_card`
---
### 15.9 长任务状态回传
#### 要完成什么
- 文档生成、Sandbox 数据分析、深度搜索等任务可能耗时较长
- 前端需要看到任务状态,而不是一直假 loading
#### 用什么技术
- **Azure Service Bus**:异步任务投递
- **PostgreSQL**:任务状态持久化
- **Redis**:缓存短状态
- **SSE / 轮询**:状态回传给前端
#### 怎么落地
- 新增表:`async_tasks`
- `id`
- `task_type`
- `conversation_id`
- `message_id`
- `status`
- `progress_text`
- `result_payload`
- `created_at`
- `updated_at`
- 新增接口:
- `GET /api/tasks/{id}`
- 第一阶段先采用“数据库状态 + 前端轮询”
- 后续再增强为 SSE 任务事件推送
---
### 15.10 扩展连接状态注入 graph
#### 要完成什么
- 某个扩展是否已连接,必须直接决定 graph 中哪些工具可用
- 未连接扩展不能被调用
- 已连接扩展才能参与 agent 路由
#### 用什么技术
- **extension registry**
- **LangGraph state injection**
- **tool availability resolver**
#### 怎么落地
- graph 执行前先加载当前扩展连接状态
- 注入 state:
```json
{
"available_extensions": ["ticket"]
}
```
- router 节点判断:
- 工单问题 + ticket 已连接 -> 允许调用
- 工单问题 + ticket 未连接 -> 返回“扩展未连接”
- 销售 / 云管未来直接复用该机制
---
### 15.11 会话重命名 / 置顶等预留
#### 要完成什么
- 为左侧会话更多操作菜单预留后端能力
- 支持未来扩展:
- 重命名
- 置顶
- 自定义排序
#### 用什么技术
- **PostgreSQL conversation metadata**
- **排序字段 / pinned 字段**
#### 怎么落地
- conversations 表补充字段:
- `custom_title`
- `pinned`
- `sort_order`
- 接口统一走:
- `PATCH /api/conversations/{id}`
- 即使前端暂未开放置顶,也建议先预留字段
---
### 15.12 会话级偏好元数据
#### 要完成什么
- 记录会话偏好信息,例如:
- 当前选中的模型
- 当前启用工具
- 默认搜索模式
- 当前关联扩展
- 会话恢复时自动延续这些设置
#### 用什么技术
- **PostgreSQL JSON metadata**
- **LangGraph state hydration**
#### 怎么落地
- conversations 表增加:
- `metadata_json`
- 典型结构示例:
```json
{
"selected_model": "gpt-5.4",
"selected_mode": "pro",
"enabled_tools": ["knowledge", "search"],
"preferred_search_mode": "deep"
}
```
- 会话恢复时把 metadata 注入 graph 初始 state
---
## 16. 接口层总表
> 虽然本方案不以 FastAPI 为重点,但前端要接入,仍然需要有 HTTP/SSE 出口。这里把它视为“接入层”,不是方案核心。
### 第一阶段建议建设的接口
#### 基础接口
- `GET /health`
#### 会话接口
- `GET /api/conversations`
- `POST /api/conversations`
- `GET /api/conversations/{id}`
- `PATCH /api/conversations/{id}`
- `DELETE /api/conversations/{id}`
#### 聊天接口
- `POST /api/chat/stream`
#### 工单接口
- `GET /api/tickets/summary`
- `GET /api/tickets`
- `GET /api/tickets/{id}`
#### 搜索接口
- `POST /api/search/internal`
- `POST /api/search/external`
#### 文档接口
- `POST /api/documents/generate`
- `GET /api/documents/{task_id}`
#### 附件接口
- `POST /api/attachments`
- `GET /api/attachments/{id}`
#### 沙盒接口
- `POST /api/sandbox/run`
---
## 16. 推荐技术组合总结
### 核心框架
- **LangChain**:模型调用、Prompt 组织、Tool 封装、Memory 适配
- **LangGraph**:对话状态机、工具路由、任务编排、长链路执行
### 数据层
- **PostgreSQL**:会话/消息/工具调用/任务持久化
- **Redis**:缓存、短状态、限流
- **Azure Blob Storage**:附件、产物、文档存储
- **Azure Service Bus**:异步任务编排
- **SQLAlchemy / SQLModel**:ORM
- **Alembic**:迁移管理
### AI 与搜索
- **Azure OpenAI**:LLM 生成与总结
- **KB_AGENT**:内部知识库检索
- **Jina MCP SSE / v1 + Search / Reader / Rerank**:外部搜索链路
### 外部业务系统
- **Gongdan API**:工单只读
- **Doc Creator Agent**:文档生成
- **Daytona Sandbox**:受控代码执行
### 协议与接入
- **SSE**:流式输出到前端
- **HTTP API**:前端接入层
---
## 17. 第一阶段开发顺序
### 第一步
先完成:
- LangChain + LangGraph 基础工程
- PostgreSQL 接入
- conversations/messages 表
- 基础聊天 graph
- `/api/chat/stream`
- `/api/conversations`
### 第二步
接入:
- Azure OpenAI
- KB_AGENT tool
- 工单 tools
### 第三步
接入:
- Jina MCP SSE / v1 搜索链路
- Search / Reader / Rerank tool chain
- 来源引用
- graph 中间状态流式事件
- Redis 缓存
### 第四步
接入:
- 文档生成 tool
- 附件解析 loader
- sandbox tools
- Azure Blob Storage
- Azure Service Bus
- graph 持久化和恢复
---
## 18. 最终结论
这个项目当前最合适的后端方案,如果明确要求基于 LangChain 框架来做,那就应该是:
- 用 **LangChain + LangGraph** 做整个后端核心
- 用 **Azure OpenAI** 做模型生成和总结
- 用 **KB_AGENT** 做内部知识检索工具
- 用 **Jina Search/Reader/Rerank** 做外部搜索工具链
- 用 **Gongdan API** 做工单查询工具
- 用 **Doc Creator Agent** 做正式文档生成工具
- 用 **Daytona Sandbox** 做受控执行工具
- 用 **PostgreSQL** 做会话、消息、任务和 graph 状态持久化
- 用 **Redis** 做缓存和短状态管理
- 用 **Azure Blob Storage** 做附件与产物存储
- 用 **Azure Service Bus** 做长任务异步编排
- 用 **MCP 方式** 标准化接入 Jina 外部搜索
整个系统本质上是:
**一个基于 LangGraph 编排、具备缓存/存储/异步任务能力的企业级对话 Agent 后端。**
而且整个过程中:
**前端交互不改,只替换数据来源和后端能力。**
+61
View File
@@ -0,0 +1,61 @@
# SOC End-to-End Test Reports
---
## File Upload End-to-End Integration Test
**Date**: 2026-04-08
**Tester**: Claude Agent (Opus 4.6)
**Backend**: https://soc-backend.azurewebsites.net
**Frontend**: https://proud-pebble-04db8fd00.2.azurestaticapps.net
### Pre-test Fixes Applied
Before testing could succeed, three issues were identified and fixed:
1. **`delete_attachment` Litestar startup crash** (`ImproperlyConfiguredException`)
- Cause: `@delete(..., status_code=204)` with `-> None` return type triggers Litestar validation error
- Fix: Added `return_dto=None` to the `@delete` decorator in `backend/app/api/attachments.py`
2. **`create_tables` ExceptionGroup race condition**
- Cause: Two gunicorn workers call `CREATE TABLE` simultaneously; PostgreSQL raises `UniqueViolation`, but anyio wraps it in `ExceptionGroup` (a `BaseException` subclass) which bypasses `except Exception`
- Fix: Changed `except Exception` to `except BaseException` in `backend/app/store/postgres.py`
3. **ForeignKeyViolationError on upload with non-existent conversation_id**
- Cause: `attachments.conversation_id` has a FK constraint to `conversations.id`; uploading with an arbitrary `conversation_id` that doesn't exist fails
- Status: Not a bug -- expected behavior. Tests adapted to create a conversation first or upload without `conversation_id`
Commit: `48d7dd7` -- `fix: resolve attachment upload 500 errors (delete_attachment startup crash + create_tables ExceptionGroup)`
### Test Results
| # | Test Item | Method | Expected | Actual | Result |
|---|-----------|--------|----------|--------|--------|
| 1 | Health check | `GET /health` | 200 `{"status":"ok"}` | 200 `{"status":"ok"}` | **PASS** |
| 2 | Upload attachment (no conversation) | `POST /api/attachments/upload` multipart | 201 with `{id, filename, blob_url, size_bytes, content_type, created_at}` | 201 -- id=`7c0eba99`, filename=`CLAUDE.md`, blob_url=`https://authdatablol.blob.core.windows.net/soc-files/...`, size_bytes=4966, content_type=`text/markdown` | **PASS** |
| 2b | Upload attachment (with valid conversation_id) | `POST /api/attachments/upload?conversation_id={id}` | 201 with all fields + conversation_id set | 201 -- id=`a0dd9108`, conversation_id=`3d859ce5-...`, all fields present | **PASS** |
| 3 | Get attachment metadata | `GET /api/attachments/{id}` | 200 with attachment JSON | 200 -- all fields match upload response | **PASS** |
| 4 | Download attachment (SAS redirect) | `GET /api/attachments/{id}/download` | 302 with `Location` header containing SAS URL | 302 -- Location: `https://authdatablol.blob.core.windows.net/soc-files/...?se=...&sp=r&sv=...&sr=b&sig=...` | **PASS** |
| 5 | Delete attachment | `DELETE /api/attachments/{id}` | 204 No Content | 204 | **PASS** |
| 6 | Get after delete | `GET /api/attachments/{id}` | 404 | 404 `{"status_code":404,"detail":"Attachment ... not found"}` | **PASS** |
| 7 | Frontend homepage + upload button | `GET /` + source verification | 200 + upload UI code in source | 200 (25464 bytes) + `handleFileUpload`, `uploadAttachment`, file input in `GeminiInput.tsx` | **PASS** |
| 8 | SSE chat regression | `POST /api/chat/stream` | SSE tokens + done event | Received token events ("Hi! How can I help?") + `{"type":"done"}` | **PASS** |
| 9 | Tickets/summary regression | `GET /api/tickets/summary` | 200 with summary JSON | 200 `{"total":3,"by_status":{"pending":0,"processing":1,"resolved":2},"by_priority":{"P0":0,"P1":2,"P2":1,"P3":0}}` | **PASS** |
### Summary
**Result: 10/10 PASS** (counting 2 and 2b as separate items = 10 tests total)
All file upload CRUD operations work correctly end-to-end:
- Upload to Azure Blob Storage succeeds (both with and without conversation_id)
- Metadata persisted in PostgreSQL and retrievable via GET
- Download generates a time-limited SAS URL and returns 302 redirect
- Delete removes both the blob and the database record
- Frontend has upload UI wired to the backend API
- SSE chat and tickets/summary remain functional (no regressions)
### Note on `debug=True`
The `debug=True` flag was left enabled in `backend/app/main.py` to aid ongoing development. This should be set back to `debug=False` before production hardening.
---
+395
View File
@@ -0,0 +1,395 @@
# langgraphjs-gen-ui-examples 实跑与代码核对记录
## 1. 这次我实际做了什么
这次不是只看 README,我实际做了下面这些事:
1. clone 仓库
- 路径:`/Users/gongzhiyong/go/langgraphjs-gen-ui-examples`
2. 安装依赖
- 执行:`pnpm install`
- 已成功
3. 读取关键配置与代码
- `README.md`
- `package.json`
- `.env.example`
- `langgraph.json`
- `src/agent-uis/index.tsx`
- `src/agent-uis/writer/index.tsx`
- `src/agent/writer-agent/index.ts`
- `src/agent/open-code/index.ts`
- `src/agent/pizza-orderer/index.ts`
4. 核对该项目“交互到底由哪里承接”
## 2. 这次我没有做到什么
我还没有把它完整跑到真实可交互演示画面,原因不是偷懒,而是这个仓库本身不是一个“装完直接开网页就能看完整交互”的纯前端 demo。
它依赖:
- LangGraph server
- 模型 API key(OpenAI / Google,部分示例还要 Anthropic)
- Agent Chat UI 这一套承接壳
所以它的“完整交互形态”不是只靠本仓库单独就能闭环展示的。
因此这次能确认的是:
- 代码结构
- 交互承接机制
- gen-ui 的真实工作方式
但不能假装说:
- 我已经把所有示例一条条真实点过并完整体验完
这点必须说明白。
---
## 3. 关键结论:这个仓库不是独立完整聊天产品,而是“LangGraph + UI 组件映射层”
从 README 和代码看,`langgraphjs-gen-ui-examples` 的定位很明确:
> 这是给 Agent Chat UI 使用的一组 LangGraph.js generative UI 示例。
也就是说,它不是:
- 一个完整成品聊天应用
它更像:
- 一组 graph
- 一组 UI component map
- 一套 graph 向 UI 推送结构化组件的示例实现
这点非常关键。
### 证据 1:README 直接写明
README 原文核心意思是:
- `This repository contains a series of agents intended to be used with the Agent Chat UI`
这已经说明:
- 真正承接聊天壳和交互主框架的是 Agent Chat UI
- 这个 repo 负责的是 agent + generative UI 示例
### 证据 2:`langgraph.json`
代码里:
```json
{
"graphs": {
"agent": "./src/agent/supervisor/index.ts:graph",
"email_agent": "./src/agent/email-agent/index.ts:agent",
"chat": "./src/agent/chat-agent/index.ts:agent"
},
"ui": {
"agent": "./src/agent-uis/index.tsx"
}
}
```
这说明:
- graph 在后端/agent 侧
- UI 映射入口在 `src/agent-uis/index.tsx`
也就是它的核心不是“前端页面布局”,而是:
- graph 运行时能推送什么 UI
- UI 名字如何映射到 React 组件
---
## 4. 真实的 generative UI 工作方式
这是这次最重要的发现。
## 4.1 它不是 trace panel 升级版
它不是:
- tool_start
- tool_end
- 然后前端自己把这些事件渲染成卡片
它更接近:
- graph 在执行过程中直接推一个“UI 组件实例”
- 前端按 name + props 渲染这个组件
## 4.2 `ComponentMap` 是关键
`src/agent-uis/index.tsx`:
```ts
const ComponentMap = {
"stock-price": StockPrice,
portfolio: PortfolioView,
"accommodations-list": AccommodationsList,
"restaurants-list": RestaurantsList,
"buy-stock": BuyStock,
"code-plan": Plan,
"proposed-change": ProposedChange,
writer: Writer,
} as const;
```
这个文件明确说明:
- gen-ui 的核心单位不是“通用 trace item”
- 而是“命名组件”
- graph 推什么组件名,前端就渲染什么组件
这和我前面只讲“timeline + result card”的说法相比,更接近真实代码。
也就是说,如果你要无限接近它:
- 不能只做通用 workspace + 通用卡片系统
- 还要有“组件注册表 / 组件协议 / 组件 props 约定”
---
## 5. Writer 示例说明了什么
`src/agent/writer-agent/index.ts` 和 `src/agent-uis/writer/index.tsx` 是最关键的例子。
### 5.1 后端/graph 侧是怎么做的
writer graph 里用了:
```ts
import { typedUi } from "@langchain/langgraph-sdk/react-ui/server";
```
然后在 graph 执行过程中:
```ts
ui.push(
{ id, name: "writer", props: { ...tool, isGenerating: true } },
{ message, merge: true },
);
```
后面内容流式生成时继续:
```ts
ui.push(
{ id, name: "writer", props: { content, isGenerating: true } },
{ message: lastMessage, merge: true },
);
```
最后结束时:
```ts
ui.push(
{ id, name: "writer", props: { isGenerating: false } },
{ message: lastMessage, merge: true },
);
```
这说明它的机制不是:
- 前端根据工具结果“猜”出该渲染什么
而是:
- graph 明确 push 一个叫 `writer` 的 UI 组件
- 并且持续 merge 更新它的 props
### 5.2 前端组件侧是怎么承接的
`src/agent-uis/writer/index.tsx` 中:
- 组件会根据 `isGenerating` 显示生成中
- 有 `Artifact` 侧边面板
- 内容流式写进 textarea
- 生成过程中还能自动打开 artifact panel
这个交互不是“消息上的 trace 面板”能替代的。
它本质上是:
- 聊天消息只是触发器/上下文
- 真正的内容承接在独立 artifact/workspace
这对 SOC 的启发非常大。
---
## 6. Pizza / Open Code / Email 示例分别说明什么
## 6.1 Pizza
README 和代码都说明:
- pizza 示例主要展示 tool call/result UI
这说明它有一条路线是:
- 工具调用本身也能被 UI 组件化展示
## 6.2 Open Code
Open Code 是一个假的代码生成 agent,用来演示:
- plan
- proposed changes
- 审批/继续
- 多步 UI 交互
这说明它不是只有“卡片展示”,还有:
- 多步状态机 UI
- 用户确认后继续 graph
## 6.3 Email Agent
Email agent 用的是 interrupt / HumanInterrupt 标准 schema。
说明它还支持:
- graph 中断
- 前端自动渲染 HITL UI
- 用户处理后恢复 graph
这比“只展示 trace”又高了一个层级。
---
## 7. 对 SOC 的真实结论
这部分必须收得很实。
## 7.1 现在 SOC 最接近的不是它的“完整形态”,而是最外层轮廓
SOC 现在已有:
- chat
- SSE token
- status
- tool trace
- message 绑定
这些只对应到它的最外层轮廓。
SOC 现在还没有真正拥有的,是下面这几层:
### 第一层:UI 组件注册机制
类似:
- `writer`
- `stock-price`
- `portfolio`
- `code-plan`
- `proposed-change`
SOC 现在没有这种“组件名 -> React 组件”的标准化注册表。
### 第二层:后端主动 push UI 组件实例
这个 repo 的关键能力是:
- graph 直接 `ui.push({ id, name, props })`
SOC 当前没有这层。
SOC 现在还是:
- 发 status/tool/token
- 前端自己猜怎么显示
这两者差异很大。
### 第三层:artifact/workspace 是一等公民
writer 示例里,artifact 侧边面板是正式交互层。
SOC 现在没有真正的一等 workspace / artifact 层。
### 第四层:interrupt / HITL / resume 机制
SOC 目前也没有把这一层做成产品 UI。
---
## 8. 历史消息 gen-ui 持久化,为什么会变成大问题
现在可以更准确回答你前面那个问题。
因为在这个例子里,gen-ui 不是简单 trace,而是:
- 一个或多个具名 UI 组件实例
- 每个实例都有 props
- props 还会增量更新
- 还可能挂在某条 message 上 merge
所以历史消息持久化时,最稳的不是只存 trace,而是要存:
```json
{
"message_id": "...",
"ui_instances": [
{
"id": "...",
"name": "writer",
"props": {
"title": "...",
"content": "...",
"isGenerating": false
}
}
]
}
```
也就是:
- 存“组件实例快照”
- 不只是存“工具事件日志”
否则历史消息重开时,根本无法接近它那个交互。
---
## 9. 我现在对“能不能无限接近它”的新判断
在实际读过这些代码后,答案比之前更明确:
### 9.1 如果 SOC 只是把现在的 trace panel 升级成右侧栏
那*不能无限接近*。
因为这只是在 UI 布局层面模仿。
没有触及它真正的核心机制:
- 后端推具名 UI 组件
- 前端组件注册映射
- artifact / workspace 一等化
- message 级别的 UI merge
### 9.2 如果 SOC 允许新增一层“GenUI 协议 + 组件注册 + workspace 持久化”
那*可以高相似度接近*。
但前提不是“小修小补”,而是要补以下能力:
1. 后端 UI 事件协议
2. 前端 component registry
3. workspace / artifact 容器
4. message -> ui instance 绑定
5. 历史会话快照恢复
### 9.3 所以结论不能再说虚的
最准确的话是:
- *单靠当前那套 status + trace + answer 方案,不能无限接近 `langgraphjs-gen-ui-examples`*
- *如果把协议层升级为“具名 UI 组件实例流”,再加 workspace 持久化,才有资格说高相似度接近*
---
## 10. 对我前面方案的修正
我前面那版 `uigoto.md` 有一个本质问题:
我把目标抽象成了:
- timeline
- result cards
- workspace
这没错,但还不够贴这次真实读到的代码。
缺的关键一层是:
> gen-ui 不是“结果卡片集合”这么简单,而是“后端驱动的具名 UI 组件实例系统”。
这会直接影响:
- 前后端协议怎么设计
- 持久化怎么做
- 组件如何注册
- 历史消息如何恢复
- 后续交互复杂度能否提升到 interrupt / HITL / artifact 级别
所以如果接下来重写 SOC 方案,必须把这一层补进去。
---
## 11. 当前可以下的最硬结论
### 结论 1
`langgraphjs-gen-ui-examples` 不是单纯展示 trace 的项目,它的核心是:
- graph 执行时直接推 UI 组件实例
- 前端按组件注册表渲染
### 结论 2
它的完整交互壳很大程度依赖 Agent Chat UI,不是这个 repo 自己单独包办全部页面壳。
### 结论 3
SOC 如果想无限接近它,不能只升级 trace panel,必须升级成:
- UI 组件协议
- component registry
- artifact/workspace
- message 绑定持久化
### 结论 4
历史消息 gen-ui 持久化,最稳方案不是只存 trace,而是存“message 级 UI 组件实例快照”。
+715
View File
@@ -0,0 +1,715 @@
# SOC Generative UI 产品方案(uigoto)
## 1. 目标结论
本方案不再把当前前端的 `trace panel` 视为最终形态,而是将其定义为过渡能力。
本阶段产品目标明确为:
- 基于当前 SOC 前端代码继续演进
- 不切换主工程,不直接迁移到 `langgraphjs-gen-ui-examples`
- 以现有 `GeminiChat` / `GeminiMessage` / `TracePanel` / `api.ts` 为基础重构交互
- 目标交互不是“消息下方的工具折叠区”
- 而是“聊天主线程 + agent 活动层 + 结构化 UI 承接区”
- 最终要求:在当前项目约束下,无限接近 `langgraphjs-gen-ui-examples` 的交互体验
一句话定义:
> 当前项目从 `chat + trace` 升级为 `chat + activity + generative UI workspace`。
---
## 2. 基于当前代码的现状判断
当前前端已经具备以下基础:
### 2.1 已有能力
- 页面入口极简,`frontend/app/page.tsx` 直接挂 `GeminiChat`
- `GeminiChat.tsx` 已经是主控容器:
- 负责 sidebar / topbar / message list / input / extensions panel
- 已接入流式聊天 `streamChat(...)`
- 已接收 `status / tool_start / tool_end / tool_error / token`
- `GeminiMessage.tsx` 已支持:
- assistant message 渲染
- `traceItems` 绑定到单条 assistant message
- `TracePanel` 插入正文前
- `TracePanel.tsx` 已支持:
- 工具摘要
- 工具列表展开
- 成功/失败/运行中状态
- `api.ts` 已有前后端流协议类型:
- `ChatStreamEvent`
- `TraceItem`
- 页面整体骨架已经是聊天产品,不需要推倒重做
### 2.2 当前不足
当前代码的交互层级仍然偏低,主要问题如下:
#### 问题 A:trace 仍然附属于 message,而不是独立 UI 层
当前 `TracePanel` 只是 assistant message 上方的一块折叠区域。
这意味着:
- tool activity 只是“消息补充信息”
- 不是可持续存在的 agent workspace
- 用户注意力仍然集中在文本回复,不是结构化交互本身
#### 问题 B:status 虽然收到了,但没有成为真正可见 UI
当前 `GeminiChat.tsx` 接了 `status` 事件,但 `TracePanel.tsx` 会过滤掉非 tool 项。
这意味着:
- 后端的 status 没有变成产品层交互
- 用户看不到 agent 当前处于哪个阶段
- 还没有形成真正的 activity timeline
#### 问题 C:没有独立的 generative UI 承接区
当前页面结构只有:
- 左侧 sidebar
- 中间 chat
- 扩展面板 `ExtensionsPanel`
没有一个与 agent 执行结果强绑定的右侧 workspace / side panel / dynamic card panel。
这和 `langgraphjs-gen-ui-examples` 的交互差距最大。
#### 问题 D:tool 结果还是“摘要文本”,不是“组件状态”
当前 tool_end/tool_error 最终只是写入:
- `outputSummary`
- `errorSummary`
也就是说:
- tool 结果只被当成 message 附属文本
- 不能驱动卡片、详情面板、结果区块、交互组件
#### 问题 E:页面是“消息流 UI”,不是“agent 工作台 UI”
现在用户是在看聊天。
不是在看一个 agent 正在构造、更新、切换多个结果视图。
而用户现在明确偏好的是后者。
---
## 3. 产品目标:必须完成的范围
下面是本轮必须完成的范围。这个范围一旦确认,就不能做成“弱化版”。
## 3.1 最终交互目标
必须完成的目标交互如下:
### 目标 1:对话区仍保留,但不再是唯一主角
中间聊天区仍然保留,用于:
- 展示用户输入
- 展示 assistant 文本回答
- 展示简洁的 agent 活动摘要
但是:
- 详细工具过程
- 结构化结果
- 中间态 UI
- 后续可点击视图
都不能只塞在 message 里。
### 目标 2:新增独立的 Agent Workspace 区域
必须新增一个独立 UI 区域,位置优先级如下:
- 首选:右侧固定 workspace panel
- 次选:中间 chat 区下方的持续存在 workspace
- 不接受:仍然只挂在 message 气泡里作为折叠面板
这个 workspace 的职责是:
- 承接 agent 的结构化执行过程
- 承接 tool 返回的可视结果
- 在同一轮对话中持续更新
- 在回答结束后保留结果视图
### 目标 3:status 必须成为第一层可见交互
status 不能再只存在于后端事件和前端状态变量中。
必须在 UI 里直接可见。
必须具备以下阶段表达:
- 已接收问题
- 正在分析
- 正在调用某个工具
- 正在汇总结果
- 已完成
- 失败 / 中断
要求:
- 用户在不展开任何调试视图的前提下,也能看到 agent 当前阶段
- status 不得埋在细节抽屉里
### 目标 4:tool 调用必须既有摘要,也有实体卡片
每个重要工具调用,不仅要有 timeline 行,还要能够驱动结果卡片。
例如:
- 知识库检索 -> “知识卡片 / 命中条目列表”
- web 搜索 -> “来源列表卡片”
- ticket 工具 -> “工单摘要卡片 / 工单详情卡片”
- 文档生成 -> “文档卡片 / 下载入口”
- sandbox -> “执行结果卡片 / 输出块”
也就是说:
- tool trace 是“过程层”
- tool result card 是“结果层”
- 两层必须同时存在
### 目标 5:每一轮回答要形成完整闭环
每次一次完整请求,前端必须形成一轮完整可见闭环:
`user message -> status -> tool activity -> structured result cards -> assistant answer -> completed state`
不能只剩下:
- 有 token
- 有 trace
- 但没有结果区承接
### 目标 6:结果区要持续存在,不随消息滚动立即消失
用户喜欢的交互,本质上不是“看完就过去”,而是 agent 在右侧/固定区域留下工作结果。
因此必须做到:
- 当前轮次结果在回答结束后仍然可见
- 点击历史消息时,可以重新激活该轮对应 workspace
- workspace 与 conversation / message 建立明确绑定
---
## 4. 本轮不允许模糊的实现边界
为了保证“必须完成”,这里明确哪些属于本轮范围,哪些不属于。
## 4.1 本轮必须完成
### A. 页面布局升级
必须把当前页面从:
- sidebar + main chat + extensions panel
升级为:
- sidebar + chat main + agent workspace panel
要求:
- `ExtensionsPanel` 不再承担 generative UI 主职责
- workspace 是主产品结构,不是弹窗附属物
### B. activity timeline 升级
必须把当前 `TracePanel` 升级为真正的 `ActivityPanel`:
- 支持 status 节点
- 支持 tool 节点
- 支持完成/失败节点
- 支持当前轮次高亮
- 支持点击节点联动结果卡片
### C. result cards 体系
必须定义并实现第一批可交付卡片类型。
本轮最少要完成以下卡片:
- SearchResultCard
- KnowledgeResultCard
- TicketSummaryCard
- TicketDetailCard
- DocumentResultCard
- SandboxResultCard
- ErrorCard
- EmptyStateCard
这些卡片不要求一次做到极复杂,但必须是“真实组件”,不是把 JSON dump 出来。
### D. chat 与 workspace 联动
必须实现:
- 某条 assistant message 对应一个 workspace session
- 点击该 message,可重新展示它那一轮的 activity + result cards
- 正在生成时,workspace 实时更新
- 完成后,workspace 固化为当前轮结果
### E. 事件协议升级
必须在当前 SSE 事件协议上增加一层 UI 事件语义。
后端不一定一开始就一次发完整 UI schema,但前端必须按这个目标设计:
- activity 事件
- result card 事件
- workspace patch 事件
即使第一版是由前端根据 tool_end 映射卡片,也必须保留后续升级为后端直接下发 UI schema 的位置。
## 4.2 本轮明确不做
为了保证“无限接近目标交互”而不是分散精力,本轮先不做:
- 多标签 workspace 管理
- 拖拽式布局编辑
- 用户自定义卡片布局
- 通用低代码 schema 编辑器
- 全量历史 workspace 持久化版本管理
- 复杂多人协作 UI
这些都不是当前用户最核心的“像那个项目的交互”诉求。
---
## 5. 交互方案:必须实现到的细节
这是本方案最核心部分,重点是交互细节,不允许只停留在“有个右侧栏”。
## 5.1 总体布局
页面结构必须调整为三栏心智模型:
### 左栏:Conversation / Navigation
保留现有 `GeminiSidebar`,只做轻量微调。
职责不变:
- 新建会话
- 会话切换
- 会话管理
- 扩展入口
### 中栏:Chat Thread
保留聊天主线程,但要减轻其“承载所有信息”的职责。
中栏只负责:
- 用户消息
- assistant 文本输出
- 精炼版 activity 摘要
- 当前轮生成状态
中栏不再承担:
- 大量工具明细
- 详细结构化结果
- 复杂工具结果交互
### 右栏:Agent Workspace(本轮核心)
新增固定 workspace,默认始终可见。
必须具备:
- 标题区
- 当前阶段状态区
- activity timeline
- 结果卡片区
- 空状态 / 错误状态 / 完成状态
建议结构:
1. Header
- 当前轮标题
- 当前状态 badge
- collapse / expand 能力(可选)
2. Activity 区
- 展示 status + tool 节点
3. Result 区
- 展示当前轮生成的 card stack
4. Footer / meta 区
- 时间、完成状态、重试入口(可选)
---
## 5.2 Chat 区具体交互要求
### 交互要求 1:assistant message 上方只保留轻摘要
当前 `TracePanel` 的详单不应该继续作为主交互。
改造后:
- message 上方只保留一行 activity summary
- 文案例如:
- 正在分析问题
- 已查询知识库与外部信息
- 已生成工单摘要
- 已完成文档生成
不再默认把完整工具列表塞进 message 气泡区域。
### 交互要求 2:点击 assistant message 可聚焦右侧 workspace
每条 assistant message 都必须可触发右侧 workspace 聚焦。
行为定义:
- hover 时出现“查看工作区”提示或高亮
- 点击 assistant message,右侧显示它对应那一轮的 workspace
- 当前活跃 message 在聊天流中有弱高亮
### 交互要求 3:生成期间聊天区与 workspace 同步流动
生成中:
- 中间 message 逐步吐文本
- 右侧 activity / cards 同时更新
用户应感知到:
- 不是“等回答完再展示工具过程”
- 而是“回答和工作区同步生长”
---
## 5.3 Workspace 区具体交互要求
### 交互要求 4:workspace 默认固定显示,不是弹窗
这是硬约束。
不接受:
- 抽屉点击后才看见
- modal 弹窗式承接
- 工具面板必须展开才看到
必须默认可见。
### 交互要求 5:workspace 有明确状态头部
顶部必须显示:
- 当前轮标题(自动从用户问题摘要生成)
- 当前状态 badge
- 当前阶段短文案
例如:
- 分析中
- 调用知识库
- 汇总工单结果
- 生成完成
- 执行失败
### 交互要求 6:ActivityTimeline 必须是真正的时间线
ActivityTimeline 不是现在 `TracePanel` 那种折叠列表。
必须具备:
- 顺序节点
- 节点状态图标
- 当前节点高亮
- 完成节点保持可见
- 错误节点可单独标红
- 点击节点可联动结果区滚动/高亮
节点类型至少包括:
- status node
- tool node
- done node
- error node
### 交互要求 7:结果卡片区必须有“主卡片”概念
不是简单 list。
要求:
- 最新 / 当前最关键结果卡片优先显示在上方
- 次级结果按时间或重要性排列
- 当前高亮的 activity 节点对应的 card 自动高亮
### 交互要求 8:无结果时不能空白
在 agent 正在运行但还没产出 card 时,workspace 结果区必须有占位状态:
- 正在准备结果…
- 正在等待工具返回…
- 暂无结构化结果
不能出现大面积空白让用户以为没工作。
### 交互要求 9:回答完成后 workspace 要进入“已完成态”
回答完成后:
- timeline 固化
- 最终状态变为 completed
- 结果卡片保留
- message summary 从“进行中”切换为“已完成摘要”
---
## 5.4 卡片层交互要求
### 交互要求 10:卡片必须像产品组件,不像调试面板
卡片 UI 原则:
- 标题明确
- 信息分组清楚
- 可扫读
- 有主次层级
- 不直接暴露原始 JSON
### 交互要求 11:卡片内容要有“用户可读摘要 + 结构化字段”双层
例如搜索卡片:
- 顶部:找到 5 条高相关结果
- 下方:来源列表、标题、摘要、链接
例如 ticket 卡片:
- 顶部:共 12 条工单,P0 2 条
- 下方:按状态/优先级分组
### 交互要求 12:失败也要有失败卡片
如果工具失败:
- timeline 有错误节点
- 结果区同步出现 ErrorCard
- assistant 文本可继续回答,但 workspace 必须保留失败证据
### 交互要求 13:同一轮允许多卡片叠加
例如一次请求可能触发:
- KnowledgeResultCard
- SearchResultCard
- TicketSummaryCard
- FinalDocumentCard
这些都必须能在一轮 workspace 中共存。
---
## 6. 信息架构与前端状态模型
为了避免方案空泛,这里直接给前端目标状态模型。
## 6.1 当前 message 模型的问题
当前 `Message` 结构大致是:
- id
- role
- content
- attachments
- traceItems
这不够支撑 generative UI。
## 6.2 目标模型
前端状态必须增加“workspace session”概念。
建议新增:
```ts
interface WorkspaceSession {
id: string;
conversationId: string;
messageId: string;
title: string;
status: "idle" | "running" | "completed" | "error";
stageLabel: string;
timeline: ActivityNode[];
cards: WorkspaceCard[];
startedAt: number;
finishedAt?: number;
}
```
```ts
interface ActivityNode {
id: string;
type: "status" | "tool" | "done" | "error";
label: string;
detail?: string;
tool?: string;
callId?: string;
status: "running" | "success" | "error" | "info";
ts: number;
linkedCardIds?: string[];
}
```
```ts
interface WorkspaceCard {
id: string;
kind:
| "search-results"
| "knowledge-results"
| "ticket-summary"
| "ticket-detail"
| "document-result"
| "sandbox-result"
| "error"
| "empty";
title: string;
priority: number;
data: Record<string, unknown>;
sourceCallId?: string;
}
```
## 6.3 关键绑定关系
必须建立以下绑定:
- `assistant message` -> `workspaceSession`
- `timeline node` -> `workspace card`
- `tool call_id` -> `activity node` -> `card sourceCallId`
这一步是整个交互能否稳住的关键。
---
## 7. 事件协议升级要求
## 7.1 当前协议可复用部分
当前已有:
- token
- status
- tool_start
- tool_end
- tool_error
- done
- error
这个基础可以继续用。
## 7.2 必须补的语义层
虽然第一版可以前端自行从 tool_end 推导卡片,但协议设计必须预留为以下方向:
### 事件层 1:Activity Event
用于更新 timeline。
### 事件层 2:Workspace Card Event
用于插入或更新结果卡片。
建议未来协议形态:
```ts
type ChatStreamEvent =
| TokenEvent
| StatusEvent
| ToolStartEvent
| ToolEndEvent
| ToolErrorEvent
| WorkspaceCardEvent
| DoneEvent
| ErrorEvent
```
其中 `WorkspaceCardEvent` 应允许:
- append card
- update card
- mark card done
### 事件层 3:Workspace Meta Event
用于更新:
- session title
- stage label
- overall status
第一版即使后端不直接下发,也必须在前端 store 设计上预留。
---
## 8. 组件改造建议(基于现有文件)
## 8.1 必改组件
### 1. `frontend/components/gemini/GeminiChat.tsx`
这是主改造中心。
必须负责:
- 新增 workspace state
- 维护 active workspace session
- 接收 SSE 事件后同时更新:
- message content
- activity timeline
- workspace cards
- 管理 chat 与 workspace 联动
- 页面布局从双主区变三主区
### 2. `frontend/components/gemini/GeminiMessage.tsx`
必须改为:
- 弱化当前 `TracePanel`
- 增加 `message summary bar`
- 增加 “查看工作区”/高亮态
- assistant message 点击后切换 active workspace
### 3. `frontend/components/gemini/TracePanel.tsx`
不建议继续保留原职责。
建议:
- 要么升级重命名为 `ActivityTimeline.tsx`
- 要么拆成:
- `MessageActivitySummary.tsx`
- `WorkspaceActivityTimeline.tsx`
当前这个文件的“折叠工具列表”心智不够用了。
### 4. `frontend/lib/api.ts`
必须升级类型:
- 增加 workspace card event 类型预留
- 增加 UI card 数据结构类型
- 增加 session / activity / card 的类型定义
## 8.2 新增组件建议
至少新增:
- `AgentWorkspace.tsx`
- `WorkspaceHeader.tsx`
- `ActivityTimeline.tsx`
- `WorkspaceCardRenderer.tsx`
- `cards/SearchResultCard.tsx`
- `cards/KnowledgeResultCard.tsx`
- `cards/TicketSummaryCard.tsx`
- `cards/TicketDetailCard.tsx`
- `cards/DocumentResultCard.tsx`
- `cards/SandboxResultCard.tsx`
- `cards/ErrorCard.tsx`
- `cards/EmptyStateCard.tsx`
- `MessageActivitySummary.tsx`
---
## 9. 分阶段交付要求(但每阶段都有硬结果)
为了降低风险,允许分阶段做,但每阶段都必须是完整可见结果,不接受“先埋代码,UI 后补”。
## Phase 1:布局与状态层完成
必须交付:
- 页面三栏结构完成
- 右侧 workspace 固定出现
- status 在 workspace 顶部真实可见
- assistant message 可与 workspace 绑定
- 当前 trace panel 不再承担主展示职责
这是最低可验收版本。
## Phase 2:timeline 完成
必须交付:
- status/tool/done/error 节点都能显示
- timeline 可高亮当前节点
- timeline 与 message / workspace 联动
- 当前轮完整活动闭环可见
## Phase 3:第一批结果卡片完成
必须交付:
- 至少实现 4 类真实结果卡片
- 正在生成时能增量更新
- 失败时能显示 ErrorCard
- 完成后 workspace 保留结果
## Phase 4:接近目标项目交互的收口
必须交付:
- message 区与 workspace 的视觉关系收敛
- summary 文案收敛
- 动效和状态切换自然
- 当前交互整体无限接近目标项目的观感与操作路径
---
## 10. 我的方案选择
结合当前 SOC 项目现状,我明确选择:
- 不直接改 `langgraphjs-gen-ui-examples` 为主工程
- 以它为交互参考
- 在当前 SOC 前端基础上完成 generative UI 升级
理由:
- 当前项目前端骨架已成型
- SSE 聊天链路已接通
- trace / status / tool 事件已具备基础
- 改当前项目比反向适配示例仓库更稳、更贴当前后端
所以这份方案不是概念探索,而是当前项目的明确产品实现方向。
---
## 11. 最终硬性验收标准
只有同时满足以下条件,才算本方案完成:
### 验收 1
页面存在固定可见的 agent workspace,不再只是 message 下方 trace 折叠区。
### 验收 2
status / tool / done / error 都能在 UI 中形成可视 timeline。
### 验收 3
至少 4 类结构化结果卡片真实落地,并与时间线/工具调用绑定。
### 验收 4
聊天区与 workspace 双向联动:
- 点击消息可切换 workspace
- 生成中同步更新
- 完成后保留结果
### 验收 5
最终交互观感明显不再是“chat + 调试 trace”,而是“chat + agent workspace”。
### 验收 6
用户主观体验上,必须明显无限接近 `langgraphjs-gen-ui-examples` 的交互方向,而不是只做一个右侧栏凑数。
+69
View File
@@ -0,0 +1,69 @@
# GPTui
## 结论 1:按当前项目继续改,能不能做到接近 `langgraphjs-gen-ui-examples` 的效果?
能。
但前提是目标不能继续停留在当前这版 `trace panel`,而要升级成更接近 LangGraph generative UI 的交互:
- 不是只在消息里挂一个执行过程折叠区
- 而是让 agent 输出的事件驱动 UI 渲染
- tool / state / result 不只是文本,而是驱动卡片、侧边栏、嵌入式交互组件
- 最终形态应是:`chat + agent activity + generative UI component`
也就是说,当前项目不是不能做,而是目前只做到轻量 trace MVP,还没有进入 generative UI 这一层。
如果继续沿现有项目演进,理论上可以做到接近 `langgraphjs-gen-ui-examples` 的效果,但需要补一层明确的 UI 渲染契约,例如:
- 后端输出结构化 UI schema / UI event
- 前端根据 schema 决定渲染哪种组件
- tool 结果不只进消息正文,还能驱动独立的 UI 容器
- chat 区与右侧面板 / 卡片式区域协同渲染
所以结论是:
- 按当前项目继续改,能做到接近那个效果
- 但需要把目标从 `status + tool trace + answer` 升级为 `generative UI`
- 现在本地代码还没到那个层级
---
## 结论 2:直接去改 `https://github.com/langchain-ai/langgraphjs-gen-ui-examples`,兼容当前后端,能不能改成功?
可以改成功。
但我判断,这条路不如在现有项目里演进划算。
原因是这个示例仓库默认假设的基础设施和当前项目并不一致:
- 它默认是 LangGraph.js
- 配套 Agent Chat UI / LangGraph Server 的交互方式
- 有自己的一套 graph/server 协议与 generative UI 组件约定
而当前项目实际是:
- Python 后端
- 自己的 SSE 协议
- 自己的 React 前端组件结构
- 当前只实现到文本流 + tool trace + status 的轻量形态
所以如果硬把 `langgraphjs-gen-ui-examples` 改造成兼容当前后端,本质上需要改的是:
- 前端 transport 层
- 事件协议适配层
- UI 渲染契约
- graph/tool 输出结构
这不属于“小改兼容”,而是“借它的 UI 思路,重新接你自己的后端协议”。
所以更准确的判断是:
- 技术上可以改成功
- 但不是最低成本路线
- 最合理的方式,是参考它的交互设计与生成式 UI 思路,在当前项目里重做一层 generative UI 适配
最终结论:
- 现有项目继续改,能做出接近它的效果,而且这条路更合理
- 直接改那个示例项目去兼容当前后端,也能成,但会更别扭,成本更高
+474
View File
@@ -0,0 +1,474 @@
我已经完整阅读了后端的 API 密钥系统代码。下面是完整的接口文档:
---
# 工单系统 — 外部 API 密钥调用接口文档
## 一、认证方式
所有请求通过 HTTP 请求头 `X-Api-Key` 传递 API 密钥进行认证。
```
X-Api-Key: gd_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
- 密钥格式:`gd_live_` + 40 位十六进制字符
- 密钥仅在创建时返回一次明文,之后无法再次获取
- 密钥支持过期时间和启用/禁用控制
## 二、权限模型
### 2.1 模块权限
每个 API 密钥创建时需指定 `allowedModules`,限制可访问的模块范围。合法模块如下:
| 模块键 | 路由前缀 | 说明 |
|---|---|---|
| `ticket` | `/api/tickets` | 工单管理 |
| `customer` | `/api/customers` | 客户管理 |
| `engineer` | `/api/engineers` | 工程师/运维管理 |
| `attachment` | `/api/attachments` | 附件上传 |
| `statusMonitor` | `/api/status` | 状态监控 |
请求路径不在上述模块范围内的,跳过模块权限检查。
### 2.2 角色映射
API 密钥通过认证后,系统会生成一个**合成 ADMIN 用户**(`id: api-key:<keyId>`, `role: ADMIN`)。因此 API 密钥只能访问 `@Roles(...)` 中包含 `ADMIN` 的接口。
### 2.3 禁止访问的路径
以下管理端路径**明确禁止** API 密钥访问(即使密钥有效也返回 `401`):
- `/api/api-keys/**` — API 密钥管理
- `/api/api-permissions/**` — API 模块权限管理
---
## 三、接口列表
> 基础路径:`/api`
> 所有请求需携带 `X-Api-Key` 请求头
> 返回格式:JSON
---
### 3.1 工单模块 (`ticket`)
需要 `allowedModules` 包含 `"ticket"`。
#### 3.1.1 创建工单
```
POST /api/tickets
```
**请求体:**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `platform` | string | ✅ | 平台类型:`taiji` / `xm` / `original` |
| `accountInfo` | string | ✅ | 账号信息 |
| `modelUsed` | string | ✅ | 使用的模型 |
| `description` | string | ✅ | 问题描述 |
| `requestExample` | string | ✅ | 请求示例 |
| `contactInfo` | string | ❌ | 联系方式 |
| `framework` | string | ❌ | 使用框架 |
| `networkEnv` | string | ❌ | 网络环境:`local` / `cloud` |
| `attachmentUrls` | string[] | ❌ | 附件 URL 列表 |
| `requestedLevel` | string | ❌ | 请求工程师等级:`L1` / `L2` / `L3` |
#### 3.1.2 为指定客户创建工单
```
POST /api/tickets/for-customer/:customerId
```
**路径参数:** `customerId` — 客户 ID
**请求体:** 同 3.1.1
#### 3.1.3 查询工单列表
```
GET /api/tickets?page=1&pageSize=20&status=PENDING
```
**查询参数:**
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| `page` | number | ❌ | 1 | 页码 |
| `pageSize` | number | ❌ | 20 | 每页条数 |
| `status` | string | ❌ | — | 筛选状态:`PENDING` / `ACCEPTED` / `IN_PROGRESS` / `PENDING_CLOSE` / `CLOSED` |
#### 3.1.4 查询单个工单
```
GET /api/tickets/:id
```
#### 3.1.5 更新工单状态
```
PUT /api/tickets/:id/status
```
**请求体:**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `status` | string | ✅ | 目标状态 |
#### 3.1.6 自行接单
```
PUT /api/tickets/:id/self-assign
```
#### 3.1.7 分配工程师
```
PUT /api/tickets/:id/assign
```
**请求体:**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `engineerId` | string | ✅ | 工程师 ID |
#### 3.1.8 客户关闭工单
```
PUT /api/tickets/:id/customer-close
```
#### 3.1.9 申请关闭工单
```
PUT /api/tickets/:id/close-request
```
#### 3.1.10 审批关闭工单
```
PUT /api/tickets/:id/close-approve
```
#### 3.1.11 拒绝关闭工单
```
PUT /api/tickets/:id/close-reject
```
#### 3.1.12 催单
```
POST /api/tickets/:id/urge
```
**请求体:**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `note` | string | ❌ | 催单备注 |
#### 3.1.13 获取工单留言列表
```
GET /api/tickets/:id/messages
```
#### 3.1.14 添加工单留言
```
POST /api/tickets/:id/messages
```
**请求体:**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `content` | string | ✅ | 留言内容 |
| `attachmentUrls` | string[] | ❌ | 附件 URL 列表 |
#### 3.1.15 删除工单留言
```
DELETE /api/tickets/messages/:messageId
```
#### ⚠️ 不可访问的接口
| 接口 | 原因 |
|---|---|
| `GET /api/tickets/daily-usage/me` | 仅限 `CUSTOMER` 角色,API 密钥为 `ADMIN` 角色,无权限 |
---
### 3.2 客户模块 (`customer`)
需要 `allowedModules` 包含 `"customer"`。
#### 3.2.1 查询客户列表
```
GET /api/customers
```
#### 3.2.2 查询单个客户
```
GET /api/customers/:id
```
#### ⚠️ 不可访问的接口
以下接口仅限 `OPERATOR` 角色,API 密钥(`ADMIN`)无权访问:
| 接口 | 方法 |
|---|---|
| `POST /api/customers` | 创建客户 |
| `PATCH /api/customers/:id/tier` | 更新客户等级 |
| `PATCH /api/customers/:id/bind-engineer` | 绑定工程师 |
---
### 3.3 工程师/运维模块 (`engineer`)
需要 `allowedModules` 包含 `"engineer"`。
#### 3.3.1 查询工程师列表
```
GET /api/engineers
```
#### 3.3.2 创建工程师
```
POST /api/engineers
```
**请求体:**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `username` | string | ✅ | 用户名 |
| `email` | string | ✅ | 邮箱 |
| `password` | string | ✅ | 密码 |
| `level` | string | ✅ | 等级:`L1` / `L2` / `L3` |
| `isAdmin` | boolean | ❌ | 是否管理员 |
#### 3.3.3 创建运维人员
```
POST /api/engineers/operators
```
**请求体:**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `username` | string | ✅ | 用户名 |
| `email` | string | ✅ | 邮箱 |
| `password` | string | ✅ | 密码 |
#### 3.3.4 管理端 — 查询工程师列表
```
GET /api/engineers/admin/engineers
```
#### 3.3.5 管理端 — 更新工程师信息
```
PATCH /api/engineers/admin/engineers/:id
```
**请求体(均可选):**
| 字段 | 类型 | 说明 |
|---|---|---|
| `username` | string | 用户名 |
| `email` | string | 邮箱 |
| `level` | string | 等级:`L1` / `L2` / `L3` |
| `isAvailable` | boolean | 是否可用 |
#### 3.3.6 管理端 — 重置工程师密码
```
PATCH /api/engineers/admin/engineers/:id/password
```
**请求体:**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `newPassword` | string | ✅ | 新密码 |
#### 3.3.7 管理端 — 删除工程师
```
DELETE /api/engineers/admin/engineers/:id
```
#### 3.3.8 管理端 — 查询运维人员列表
```
GET /api/engineers/admin/operators
```
#### 3.3.9 管理端 — 更新运维人员信息
```
PATCH /api/engineers/admin/operators/:id
```
**请求体(均可选):**
| 字段 | 类型 | 说明 |
|---|---|---|
| `username` | string | 用户名 |
| `email` | string | 邮箱 |
#### 3.3.10 管理端 — 重置运维人员密码
```
PATCH /api/engineers/admin/operators/:id/password
```
**请求体:**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `newPassword` | string | ✅ | 新密码 |
#### 3.3.11 管理端 — 删除运维人员
```
DELETE /api/engineers/admin/operators/:id
```
#### ⚠️ 注意 — `me` 接口的局限性
以下接口虽然角色上允许 `ADMIN` 访问,但 API 密钥的合成用户 ID 为 `api-key:<keyId>`,不对应真实工程师账号,**调用会在服务层失败**:
| 接口 | 说明 |
|---|---|
| `PATCH /api/engineers/me/availability` | 更新可用状态 |
| `PATCH /api/engineers/me/email` | 更新邮箱 |
| `PATCH /api/engineers/me/password` | 修改密码 |
---
### 3.4 附件模块 (`attachment`)
需要 `allowedModules` 包含 `"attachment"`。
#### 3.4.1 获取上传 SAS Token
```
POST /api/attachments/sas-token
```
**请求体:**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `fileName` | string | ✅ | 文件名(会被安全化处理) |
**返回:** 包含 Azure Blob Storage 上传所需的 SAS Token。
---
### 3.5 状态监控模块 (`statusMonitor`)
需要 `allowedModules` 包含 `"statusMonitor"`。
#### 3.5.1 获取外部服务状态
```
GET /api/status/external
```
#### 3.5.2 获取运维仪表盘
```
GET /api/status/dashboard
```
#### 3.5.3 获取公开仪表盘
```
GET /api/status/public-dashboard
```
> 注:此接口本身无需认证即可访问。使用 API 密钥访问时会消耗模块权限检查。
---
## 四、白名单路径(无需模块检查)
以下路径不受 API 模块权限限制,但部分仍需 JWT 认证(API 密钥不可替代):
| 路径 | 说明 |
|---|---|
| `/api/auth/*` | 认证相关 |
| `/api/health` | 健康检查 |
| `/api/public/bing-background` | 必应壁纸 |
| `/api/api-permissions/*` | 权限管理(**禁止**API密钥访问) |
| `/api/api-keys*` | 密钥管理(**禁止**API密钥访问) |
---
## 五、错误码说明
| HTTP 状态码 | 说明 |
|---|---|
| `401 Unauthorized` | 密钥无效、已禁用、已过期,或尝试访问禁止路径 |
| `403 Forbidden` | 密钥的 `allowedModules` 不包含请求的模块 / 角色不足 |
| `400 Bad Request` | 请求参数校验失败 |
| `404 Not Found` | 资源不存在 |
---
## 六、示例调用
```bash
# 查询工单列表
curl -H "X-Api-Key: gd_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
"https://your-domain/api/tickets?page=1&pageSize=10"
# 创建工单
curl -X POST \
-H "X-Api-Key: gd_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"platform": "taiji",
"accountInfo": "test-account",
"modelUsed": "gpt-4",
"description": "API调用异常",
"requestExample": "curl https://api.example.com/v1/chat"
}' \
"https://your-domain/api/tickets"
```
---
## 七、认证流程总结
```
请求 → [全局 ApiKeyGuard] 读取 X-Api-Key → 验证密钥 → 设置 req.apiClient
→ [全局 ApiModulePermissionGuard] 解析路径模块 → 检查 allowedModules
→ [控制器 JwtAuthGuard] 检测 apiClient → 合成 ADMIN 用户 → 跳过 JWT 校验
→ [控制器 RolesGuard] 检查 ADMIN 是否在 @Roles() 的允许列表中
→ 执行业务逻辑
```
关键源码参考:
- 密钥验证:api-key.service.ts(`validateKey` 方法)
- 密钥 Guard:api-key.guard.ts
- 模块权限 Guard:api-module-permission.guard.ts
- JWT Guard 中的 API Key 处理:jwt-auth.guard.ts
+166 -9
View File
@@ -19,7 +19,11 @@ import {
type TicketSummaryData,
type AttachmentData,
type TraceItem,
type WorkspaceSession,
type ActivityNode,
type WorkspaceCard,
} from "@/lib/api";
import { AgentWorkspace } from "@/components/workspace/AgentWorkspace";
// ── Types ────────────────────────────────────────────────────────────────────
interface Conversation {
@@ -122,6 +126,105 @@ function updateTraceItem(
);
}
// ── Workspace helpers ─────────────────────────────────────────────────────────
function initWorkspaceSession(
aiMsgId: string,
convId: string,
title: string,
setWS: React.Dispatch<React.SetStateAction<Map<string, WorkspaceSession>>>
) {
setWS(prev => {
if (prev.has(aiMsgId)) return prev;
const next = new Map(prev);
next.set(aiMsgId, {
id: aiMsgId, conversationId: convId, messageId: aiMsgId,
title: title.slice(0, 40), status: "running", stageLabel: "正在分析...",
timeline: [], cards: [], startedAt: Date.now(),
});
return next;
});
}
function addWsActivityNode(
aiMsgId: string,
node: ActivityNode,
setWS: React.Dispatch<React.SetStateAction<Map<string, WorkspaceSession>>>
) {
setWS(prev => {
const s = prev.get(aiMsgId);
if (!s) return prev;
const next = new Map(prev);
next.set(aiMsgId, { ...s, timeline: [...s.timeline, node] });
return next;
});
}
function updateWsActivityNode(
aiMsgId: string,
callId: string | undefined,
tool: string,
updates: Partial<ActivityNode>,
setWS: React.Dispatch<React.SetStateAction<Map<string, WorkspaceSession>>>
) {
setWS(prev => {
const s = prev.get(aiMsgId);
if (!s) return prev;
const timeline = s.timeline.map(n => {
if (callId && n.callId === callId) return { ...n, ...updates };
if (!callId && n.tool === tool && n.nodeStatus === "running") return { ...n, ...updates };
return n;
});
const next = new Map(prev);
next.set(aiMsgId, { ...s, timeline });
return next;
});
}
function addWsCard(
aiMsgId: string,
card: WorkspaceCard,
setWS: React.Dispatch<React.SetStateAction<Map<string, WorkspaceSession>>>
) {
setWS(prev => {
const s = prev.get(aiMsgId);
if (!s) return prev;
const next = new Map(prev);
next.set(aiMsgId, { ...s, cards: [...s.cards, card] });
return next;
});
}
function mergeWsCard(
aiMsgId: string,
cardId: string,
props: Record<string, unknown>,
setWS: React.Dispatch<React.SetStateAction<Map<string, WorkspaceSession>>>
) {
setWS(prev => {
const s = prev.get(aiMsgId);
if (!s) return prev;
const cards = s.cards.map(c => c.id === cardId ? { ...c, props: { ...c.props, ...props } } : c);
const next = new Map(prev);
next.set(aiMsgId, { ...s, cards });
return next;
});
}
function completeWsSession(
aiMsgId: string,
setWS: React.Dispatch<React.SetStateAction<Map<string, WorkspaceSession>>>
) {
setWS(prev => {
const s = prev.get(aiMsgId);
if (!s) return prev;
const timeline = s.timeline.map(n => n.nodeStatus === "running" ? { ...n, nodeStatus: "success" as const } : n);
const next = new Map(prev);
next.set(aiMsgId, { ...s, status: "completed", stageLabel: "已完成", timeline, finishedAt: Date.now() });
return next;
});
}
// ── Main Component ────────────────────────────────────────────────────────────
export function GeminiChat() {
const [sidebarOpen, setSidebarOpen] = useState(true);
@@ -134,9 +237,13 @@ export function GeminiChat() {
const [ticketSummary, setTicketSummary] = useState<TicketSummaryData | null>(null);
const [activeTools, setActiveTools] = useState<Set<string>>(new Set());
const [selectedModel, setSelectedModel] = useState<"flash" | "auto" | "pro">("auto");
const [workspaceSessions, setWorkspaceSessions] = useState<Map<string, WorkspaceSession>>(new Map());
const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const activeWorkspace = activeWorkspaceId ? (workspaceSessions.get(activeWorkspaceId) ?? null) : null;
const activeConversation = conversations.find((c) => c.id === activeConvId) ?? null;
const messages = activeConversation?.messages ?? [];
@@ -339,7 +446,7 @@ export function GeminiChat() {
);
} else if (event.type === "status") {
// status 事件:确保 assistant 消息占位符已存在(trace 面板早于 token 出现)
// status 事件:确保 assistant 消息占位符已存在
setConversations((prev) =>
prev.map((c) => {
if (c.id !== streamConvId) return c;
@@ -354,6 +461,16 @@ export function GeminiChat() {
};
})
);
// workspace: init session + update stageLabel
initWorkspaceSession(aiMsgId, streamConvId, text, setWorkspaceSessions);
setActiveWorkspaceId(aiMsgId);
setWorkspaceSessions(prev => {
const s = prev.get(aiMsgId);
if (!s) return prev;
const next = new Map(prev);
next.set(aiMsgId, { ...s, stageLabel: event.message ?? "正在处理..." });
return next;
});
} else if (event.type === "tool_start" && event.tool) {
const item: TraceItem = {
@@ -388,6 +505,26 @@ export function GeminiChat() {
};
})
);
// workspace: init + add timeline node
initWorkspaceSession(aiMsgId, streamConvId, text, setWorkspaceSessions);
setActiveWorkspaceId(aiMsgId);
const wsNode: ActivityNode = {
id: event.call_id ?? `${event.tool}-${Date.now()}`,
type: "tool",
label: event.title ?? event.tool,
tool: event.tool,
callId: event.call_id,
nodeStatus: "running",
ts: event.ts ?? Date.now(),
};
addWsActivityNode(aiMsgId, wsNode, setWorkspaceSessions);
setWorkspaceSessions(prev => {
const s = prev.get(aiMsgId);
if (!s) return prev;
const next = new Map(prev);
next.set(aiMsgId, { ...s, stageLabel: `正在调用 ${event.title ?? event.tool}...` });
return next;
});
} else if (event.type === "tool_end" && event.tool) {
updateTraceItem(streamConvId, aiMsgId, event.call_id, event.tool, {
@@ -396,6 +533,7 @@ export function GeminiChat() {
itemStatus: "success",
durationMs: event.duration_ms,
}, setConversations);
updateWsActivityNode(aiMsgId, event.call_id, event.tool, { nodeStatus: "success" }, setWorkspaceSessions);
} else if (event.type === "tool_error" && event.tool) {
updateTraceItem(streamConvId, aiMsgId, event.call_id, event.tool, {
@@ -404,6 +542,23 @@ export function GeminiChat() {
itemStatus: "error",
durationMs: event.duration_ms,
}, setConversations);
updateWsActivityNode(aiMsgId, event.call_id, event.tool, { nodeStatus: "error" }, setWorkspaceSessions);
} else if (event.type === "workspace_card" && event.id && event.name) {
initWorkspaceSession(aiMsgId, streamConvId, text, setWorkspaceSessions);
if (event.merge) {
mergeWsCard(aiMsgId, event.id, event.props ?? {}, setWorkspaceSessions);
} else {
const newCard: WorkspaceCard = {
id: event.id,
name: event.name,
props: event.props ?? {},
title: event.name,
priority: 0,
sourceCallId: event.id,
};
addWsCard(aiMsgId, newCard, setWorkspaceSessions);
}
}
},
() => {
@@ -413,6 +568,7 @@ export function GeminiChat() {
() => {
// on done
setIsLoading(false);
completeWsSession(aiMsgId, setWorkspaceSessions);
}
);
}, [inputValue, isLoading, activeConvId, activeTools, apiModel]);
@@ -547,10 +703,8 @@ export function GeminiChat() {
{/* Main content area */}
<div
className={cn(
"flex flex-col flex-1 min-w-0 transition-all duration-300",
sidebarOpen ? "ml-[260px]" : "ml-0"
)}
className="flex flex-col flex-1 min-w-0 transition-all duration-300"
style={{ marginLeft: sidebarOpen ? 260 : 0 }}
>
{/* Top bar */}
<GeminiTopbar
@@ -563,15 +717,12 @@ export function GeminiChat() {
{messages.length === 0 && !isLoading ? (
<>
<GeminiWelcome onSuggestionClick={handleSuggestionClick} />
{/* 显示工单摘要(仅当工单系统已连接) */}
{ticketSystemConnected && ticketSummary && <TicketSummary summary={ticketSummary} />}
</>
) : (
<div className="max-w-3xl mx-auto px-4 pt-8 pb-4">
{/* 对话中也显示工单摘要 */}
<div className="max-w-2xl mx-auto px-4 pt-8 pb-4">
{ticketSystemConnected && ticketSummary && <TicketSummary summary={ticketSummary} />}
{messages.map((msg) => {
// Hide empty assistant message while streaming — the typing indicator covers it
if (msg.role === "assistant" && msg.content === "" && isLoading) {
return null;
}
@@ -581,6 +732,7 @@ export function GeminiChat() {
message={msg}
model={selectedModel}
onRegenerate={msg.role === "assistant" ? handleRegenerate : undefined}
onFocus={msg.role === "assistant" ? () => setActiveWorkspaceId(msg.id) : undefined}
/>
);
})}
@@ -605,6 +757,11 @@ export function GeminiChat() {
/>
</div>
{/* Right: Agent Workspace */}
<div className="w-[360px] flex-shrink-0 border-l border-[var(--gem-border)] bg-[var(--gem-surface)] overflow-hidden flex flex-col">
<AgentWorkspace session={activeWorkspace} isGenerating={isLoading} />
</div>
{/* Extensions Panel */}
<ExtensionsPanel
isOpen={extensionsPanelOpen}
+17 -12
View File
@@ -19,6 +19,7 @@ interface GeminiMessageProps {
message: Message;
model?: "flash" | "auto" | "pro";
onRegenerate?: (id: string) => void;
onFocus?: () => void;
}
// Simple markdown-like renderer
@@ -168,7 +169,7 @@ const GemIcon = ({ size = 18 }: { size?: number }) => (
</svg>
);
export function GeminiMessage({ message, model = "auto", onRegenerate }: GeminiMessageProps) {
export function GeminiMessage({ message, model = "auto", onRegenerate, onFocus }: GeminiMessageProps) {
const [copied, setCopied] = useState(false);
const [feedback, setFeedback] = useState<"up" | "down" | null>(null);
@@ -192,23 +193,27 @@ export function GeminiMessage({ message, model = "auto", onRegenerate }: GeminiM
}
return (
<div className="mb-6 group" role="article" aria-label="Gemini response">
<div
className="mb-6 group"
role="article"
aria-label="Gemini response"
onClick={onFocus}
style={onFocus ? { cursor: "pointer" } : undefined}
>
<div className="flex gap-3">
{/* Gem icon */}
<GemIcon />
{/* Content */}
<div className="flex-1 min-w-0">
{message.traceItems && message.traceItems.length > 0 && (
<>
<TracePanel
items={message.traceItems}
model={model}
/>
{/* 分隔线,绑定 trace 与正文 */}
{message.content && (
<div className="my-2 border-t border-[var(--gem-border)] opacity-40" />
)}
</>
<div className="flex items-center gap-1.5 mb-2 text-[11px] text-[var(--gem-text-muted)]">
<span>
{message.traceItems.some(t => t.itemStatus === "running")
? `调用中:${message.traceItems.filter(t => t.itemStatus === "running").map(t => t.title).join("、")}…`
: `已完成 ${message.traceItems.length} 个工具调用`
}
</span>
</div>
)}
<div className="space-y-0.5">{renderContent(message.content)}</div>
@@ -0,0 +1,65 @@
"use client";
import { Zap, Wrench, Flag, AlertCircle, Loader2, CheckCircle, XCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ActivityNode } from "@/lib/api";
interface ActivityTimelineProps {
nodes: ActivityNode[];
}
const TYPE_ICON: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
status: Zap,
tool: Wrench,
done: Flag,
error: AlertCircle,
};
function NodeIcon({ node }: { node: ActivityNode }) {
if (node.nodeStatus === "running") {
return <Loader2 size={14} className="text-[var(--gem-blue,#4285f4)] animate-spin" />;
}
if (node.nodeStatus === "success") {
return <CheckCircle size={14} className="text-green-400" />;
}
if (node.nodeStatus === "error") {
return <XCircle size={14} className="text-red-400" />;
}
const Icon = TYPE_ICON[node.type] ?? Zap;
return <Icon size={14} className="text-[var(--gem-text-muted)]" />;
}
export function ActivityTimeline({ nodes }: ActivityTimelineProps) {
if (nodes.length === 0) return null;
return (
<div className="space-y-0">
{nodes.map((node, i) => (
<div key={node.id} className="flex gap-3 relative">
{i < nodes.length - 1 && (
<div className="absolute left-[13px] top-7 bottom-0 w-px bg-[var(--gem-border)]" />
)}
<div className={cn(
"w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 z-10",
node.nodeStatus === "running"
? "bg-[var(--gem-blue,#4285f4)]/10 border border-[var(--gem-blue,#4285f4)]/30"
: node.nodeStatus === "error"
? "bg-red-500/10 border border-red-500/30"
: "bg-[var(--gem-surface-2)] border border-[var(--gem-border)]"
)}>
<NodeIcon node={node} />
</div>
<div className="flex-1 pb-4 min-w-0">
<p className={cn(
"text-xs font-medium leading-snug pt-1",
node.nodeStatus === "running" ? "text-[var(--gem-text)]" : "text-[var(--gem-text-secondary)]"
)}>
{node.label}
</p>
{node.detail && (
<p className="text-[10px] text-[var(--gem-text-muted)] mt-0.5">{node.detail}</p>
)}
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,100 @@
"use client";
import { BrainCircuit, Loader2, CheckCircle, XCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { ActivityTimeline } from "./ActivityTimeline";
import { WorkspaceCardRenderer } from "./WorkspaceCardRenderer";
import type { WorkspaceSession } from "@/lib/api";
interface AgentWorkspaceProps {
session: WorkspaceSession | null;
isGenerating: boolean;
}
function StatusBadge({ status }: { status: WorkspaceSession["status"] }) {
if (status === "running") {
return (
<span className="flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full bg-[var(--gem-blue,#4285f4)]/10 text-[var(--gem-blue,#4285f4)] border border-[var(--gem-blue,#4285f4)]/20">
<Loader2 size={9} className="animate-spin" />
运行中
</span>
);
}
if (status === "completed") {
return (
<span className="flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full bg-green-500/10 text-green-400 border border-green-500/20">
<CheckCircle size={9} />
已完成
</span>
);
}
if (status === "error") {
return (
<span className="flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full bg-red-500/10 text-red-400 border border-red-500/20">
<XCircle size={9} />
失败
</span>
);
}
return null;
}
export function AgentWorkspace({ session, isGenerating }: AgentWorkspaceProps) {
if (!session) {
return (
<div className="h-full flex flex-col items-center justify-center p-6 text-center">
<BrainCircuit size={32} className="text-[var(--gem-text-muted)] mb-3 opacity-40" />
<p className="text-sm text-[var(--gem-text-muted)] leading-relaxed">
向 Agent 发送消息后<br />工作区将在此展示执行过程
</p>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 py-3 border-b border-[var(--gem-border)] flex-shrink-0">
<div className="flex items-center gap-2 mb-1">
<BrainCircuit size={14} className="text-[var(--gem-blue,#4285f4)] flex-shrink-0" />
<h3 className="text-sm font-medium text-[var(--gem-text)] truncate flex-1">{session.title}</h3>
<StatusBadge status={session.status} />
</div>
{session.stageLabel && (
<p className="text-[11px] text-[var(--gem-text-muted)] pl-5">{session.stageLabel}</p>
)}
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Timeline */}
{session.timeline.length > 0 && (
<div>
<p className="text-[10px] text-[var(--gem-text-muted)] uppercase tracking-wide mb-3 font-medium">执行过程</p>
<ActivityTimeline nodes={session.timeline} />
</div>
)}
{/* Cards */}
{session.cards.length > 0 && (
<div>
{session.timeline.length > 0 && (
<div className="border-t border-[var(--gem-border)] mb-4" />
)}
<p className="text-[10px] text-[var(--gem-text-muted)] uppercase tracking-wide mb-3 font-medium">结果</p>
{session.cards.map((card) => (
<WorkspaceCardRenderer key={card.id} card={card} />
))}
</div>
)}
{/* Empty state while running */}
{session.status === "running" && session.cards.length === 0 && session.timeline.length === 0 && (
<div className="flex items-center gap-2 text-xs text-[var(--gem-text-muted)]">
<Loader2 size={12} className="animate-spin" />
<span>正在准备结果…</span>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,27 @@
"use client";
import { KnowledgeResultCard } from "./cards/KnowledgeResultCard";
import { TicketSummaryCard } from "./cards/TicketSummaryCard";
import { SearchResultCard } from "./cards/SearchResultCard";
import { DocumentResultCard } from "./cards/DocumentResultCard";
import { SandboxResultCard } from "./cards/SandboxResultCard";
import { ErrorCard } from "./cards/ErrorCard";
import type { WorkspaceCard } from "@/lib/api";
const COMPONENT_MAP: Record<string, React.ComponentType<any>> = {
KnowledgeResultCard,
TicketSummaryCard,
SearchResultCard,
DocumentResultCard,
SandboxResultCard,
ErrorCard,
};
export function WorkspaceCardRenderer({ card }: { card: WorkspaceCard }) {
const Component = COMPONENT_MAP[card.name];
if (!Component) return null;
return (
<div className="mb-3">
<Component {...(card.props as any)} />
</div>
);
}
@@ -0,0 +1,41 @@
"use client";
import { FileText, Download } from "lucide-react";
interface DocumentResultCardProps {
title: string;
type: string;
file_url: string;
size_hint?: string;
}
export function DocumentResultCard({ title, type, file_url, size_hint }: DocumentResultCardProps) {
return (
<div className="rounded-xl border border-[var(--gem-border)] bg-[var(--gem-surface)] p-4">
<div className="flex items-center gap-2 mb-3">
<FileText size={15} className="text-[var(--gem-blue,#4285f4)]" />
<span className="text-sm font-medium text-[var(--gem-text)]">文档生成</span>
<span className="ml-auto text-xs text-[var(--gem-text-muted)] bg-[var(--gem-surface-2)] px-2 py-0.5 rounded-full">
{type}
</span>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-[var(--gem-surface-2)]">
<FileText size={24} className="text-[var(--gem-blue,#4285f4)] flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--gem-text)] truncate">{title}</p>
{size_hint && <p className="text-xs text-[var(--gem-text-muted)]">{size_hint}</p>}
</div>
{file_url && (
<a
href={file_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[var(--gem-blue,#4285f4)] text-white text-xs font-medium hover:opacity-90 transition-opacity flex-shrink-0"
>
<Download size={12} />
下载
</a>
)}
</div>
</div>
);
}
@@ -0,0 +1,20 @@
"use client";
import { AlertCircle } from "lucide-react";
interface ErrorCardProps {
error: string;
tool?: string;
}
export function ErrorCard({ error, tool }: ErrorCardProps) {
return (
<div className="rounded-xl border border-red-500/30 bg-red-500/5 p-4">
<div className="flex items-center gap-2 mb-2">
<AlertCircle size={15} className="text-red-400" />
<span className="text-sm font-medium text-red-400">工具执行失败</span>
{tool && <span className="text-xs text-[var(--gem-text-muted)]">· {tool}</span>}
</div>
<p className="text-xs text-[var(--gem-text-muted)] leading-relaxed">{error}</p>
</div>
);
}
@@ -0,0 +1,46 @@
"use client";
import { BookOpen } from "lucide-react";
interface KnowledgeResult {
title: string;
category: string;
snippet: string;
}
interface KnowledgeResultCardProps {
query: string;
total: number;
results: KnowledgeResult[];
}
export function KnowledgeResultCard({ query, total, results }: KnowledgeResultCardProps) {
return (
<div className="rounded-xl border border-[var(--gem-border)] bg-[var(--gem-surface)] p-4">
<div className="flex items-center gap-2 mb-3">
<BookOpen size={15} className="text-[var(--gem-blue,#4285f4)]" />
<span className="text-sm font-medium text-[var(--gem-text)]">知识库检索</span>
<span className="ml-auto text-xs text-[var(--gem-text-muted)] bg-[var(--gem-surface-2)] px-2 py-0.5 rounded-full">
{total} 条结果
</span>
</div>
{query && (
<p className="text-xs text-[var(--gem-text-muted)] mb-3">查询:{query}</p>
)}
<div className="space-y-3">
{results.map((r, i) => (
<div key={i} className="border-t border-[var(--gem-border)] pt-2 first:border-0 first:pt-0">
<div className="flex items-start gap-2">
<span className="text-xs text-[var(--gem-text)] font-medium leading-snug flex-1">{r.title}</span>
{r.category && (
<span className="text-[10px] text-[var(--gem-text-muted)] bg-[var(--gem-surface-2)] px-1.5 py-0.5 rounded flex-shrink-0">{r.category}</span>
)}
</div>
{r.snippet && (
<p className="text-xs text-[var(--gem-text-muted)] mt-1 leading-relaxed line-clamp-2">{r.snippet}</p>
)}
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,43 @@
"use client";
import { Terminal, CheckCircle, XCircle } from "lucide-react";
interface SandboxResultCardProps {
language: string;
exit_code: number;
stdout: string;
has_more: boolean;
duration_ms?: number;
}
export function SandboxResultCard({ language, exit_code, stdout, has_more, duration_ms }: SandboxResultCardProps) {
const success = exit_code === 0;
return (
<div className="rounded-xl border border-[var(--gem-border)] bg-[var(--gem-surface)] p-4">
<div className="flex items-center gap-2 mb-3">
<Terminal size={15} className="text-[var(--gem-blue,#4285f4)]" />
<span className="text-sm font-medium text-[var(--gem-text)]">代码执行</span>
<span className="ml-auto flex items-center gap-1 text-xs">
{success
? <CheckCircle size={13} className="text-green-400" />
: <XCircle size={13} className="text-red-400" />
}
<span className={success ? "text-green-400" : "text-red-400"}>
{success ? "成功" : `退出码 ${exit_code}`}
</span>
</span>
</div>
<div className="flex items-center gap-2 mb-2">
<span className="text-[10px] text-[var(--gem-text-muted)] bg-[var(--gem-surface-2)] px-2 py-0.5 rounded">{language}</span>
{duration_ms != null && (
<span className="text-[10px] text-[var(--gem-text-muted)]">{duration_ms}ms</span>
)}
</div>
{stdout && (
<pre className="text-xs text-[var(--gem-text-secondary)] bg-[var(--gem-surface-2)] rounded-lg p-3 overflow-x-auto whitespace-pre-wrap max-h-48 overflow-y-auto font-mono">
{stdout}
{has_more && "\n… (输出已截断)"}
</pre>
)}
</div>
);
}
@@ -0,0 +1,54 @@
"use client";
import { Search, ExternalLink } from "lucide-react";
interface SearchResult {
title: string;
url: string;
snippet: string;
}
interface SearchResultCardProps {
query: string;
total: number;
results: SearchResult[];
}
export function SearchResultCard({ query, total, results }: SearchResultCardProps) {
return (
<div className="rounded-xl border border-[var(--gem-border)] bg-[var(--gem-surface)] p-4">
<div className="flex items-center gap-2 mb-3">
<Search size={15} className="text-[var(--gem-blue,#4285f4)]" />
<span className="text-sm font-medium text-[var(--gem-text)]">网络搜索</span>
<span className="ml-auto text-xs text-[var(--gem-text-muted)] bg-[var(--gem-surface-2)] px-2 py-0.5 rounded-full">
{total} 条来源
</span>
</div>
{query && (
<p className="text-xs text-[var(--gem-text-muted)] mb-3">查询:{query}</p>
)}
<div className="space-y-3">
{results.map((r, i) => (
<div key={i} className="border-t border-[var(--gem-border)] pt-2 first:border-0 first:pt-0">
<div className="flex items-start gap-1">
<a
href={r.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-[var(--gem-blue,#4285f4)] hover:underline flex-1 leading-snug"
>
{r.title}
</a>
<ExternalLink size={10} className="text-[var(--gem-text-muted)] flex-shrink-0 mt-0.5" />
</div>
{r.url && (
<p className="text-[10px] text-[var(--gem-text-muted)] truncate mt-0.5">{r.url}</p>
)}
{r.snippet && (
<p className="text-xs text-[var(--gem-text-muted)] mt-1 leading-relaxed line-clamp-2">{r.snippet}</p>
)}
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,68 @@
"use client";
import { Ticket } from "lucide-react";
interface TicketItem {
id: string;
title: string;
status: string;
priority: string;
customer: string;
created: string;
}
interface TicketSummaryCardProps {
total: number;
tickets: TicketItem[];
stats: Record<string, number>;
}
const PRIORITY_COLOR: Record<string, string> = {
P0: "text-red-400 bg-red-400/10",
P1: "text-orange-400 bg-orange-400/10",
P2: "text-yellow-400 bg-yellow-400/10",
P3: "text-[var(--gem-text-muted)] bg-[var(--gem-surface-2)]",
};
const STATUS_LABEL: Record<string, string> = {
pending: "待处理",
processing: "处理中",
resolved: "已解决",
closed: "已关闭",
};
export function TicketSummaryCard({ total, tickets, stats }: TicketSummaryCardProps) {
return (
<div className="rounded-xl border border-[var(--gem-border)] bg-[var(--gem-surface)] p-4">
<div className="flex items-center gap-2 mb-3">
<Ticket size={15} className="text-[var(--gem-blue,#4285f4)]" />
<span className="text-sm font-medium text-[var(--gem-text)]">工单查询</span>
<span className="ml-auto text-xs text-[var(--gem-text-muted)] bg-[var(--gem-surface-2)] px-2 py-0.5 rounded-full">
共 {total} 条
</span>
</div>
{Object.keys(stats).length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3">
{Object.entries(stats).map(([status, count]) => (
<span key={status} className="text-[10px] text-[var(--gem-text-muted)] bg-[var(--gem-surface-2)] px-2 py-0.5 rounded-full">
{STATUS_LABEL[status] ?? status} {count}
</span>
))}
</div>
)}
<div className="space-y-2">
{tickets.slice(0, 8).map((t) => (
<div key={t.id} className="flex items-center gap-2 text-xs">
<span className="text-[var(--gem-text-muted)] font-mono w-14 flex-shrink-0 truncate">{t.id}</span>
<span className="text-[var(--gem-text)] flex-1 truncate">{t.title}</span>
<span className={`px-1.5 py-0.5 rounded text-[10px] flex-shrink-0 ${PRIORITY_COLOR[t.priority] ?? "text-[var(--gem-text-muted)]"}`}>
{t.priority}
</span>
</div>
))}
{tickets.length > 8 && (
<p className="text-[10px] text-[var(--gem-text-muted)] pt-1">还有 {tickets.length - 8} 条…</p>
)}
</div>
</div>
);
}
+42 -1
View File
@@ -112,7 +112,7 @@ export function getAttachmentDownloadUrl(attachmentId: string): string {
// ── SSE Chat Stream ──────────────────────────────────────────────────────────
export interface ChatStreamEvent {
type: "token" | "status" | "tool_start" | "tool_end" | "tool_error" | "done" | "error";
type: "token" | "status" | "tool_start" | "tool_end" | "tool_error" | "done" | "error" | "workspace_card";
// token
content?: string;
// status
@@ -128,6 +128,47 @@ export interface ChatStreamEvent {
status?: "success" | "error";
duration_ms?: number;
ts?: number;
// workspace_card
id?: string;
name?: string;
props?: Record<string, unknown>;
merge?: boolean;
}
// ── Workspace 状态模型 ──────────────────────────────────────────────────────
export interface ActivityNode {
id: string;
type: "status" | "tool" | "done" | "error";
label: string;
detail?: string;
tool?: string;
callId?: string;
nodeStatus: "running" | "success" | "error" | "info";
ts: number;
linkedCardIds?: string[];
}
export interface WorkspaceCard {
id: string;
name: string;
props: Record<string, unknown>;
title: string;
priority: number;
sourceCallId?: string;
}
export interface WorkspaceSession {
id: string;
conversationId: string;
messageId: string;
title: string;
status: "idle" | "running" | "completed" | "error";
stageLabel: string;
timeline: ActivityNode[];
cards: WorkspaceCard[];
startedAt: number;
finishedAt?: number;
}
export interface TraceItem {