fix: restore workspace components accidentally dropped from git index
workspace/ files existed on disk but were not included in previous incremental commit, causing git to record them as deleted. Re-adding all workspace card components, AgentWorkspace, ActivityTimeline, and WorkspaceCardRenderer to properly track them. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
47921d1a45
commit
c6ca9dc126
@@ -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 |
|
||||
@@ -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` 事件。
|
||||
@@ -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 事件
|
||||
@@ -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 项目具体改造点(按文件路径拆解)
|
||||
@@ -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
@@ -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 后端。**
|
||||
|
||||
而且整个过程中:
|
||||
**前端交互不改,只替换数据来源和后端能力。**
|
||||
@@ -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"],...}'
|
||||
# 期望:回复包含文档下载链接
|
||||
```
|
||||
@@ -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. 汇总结果
|
||||
@@ -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 准确
|
||||
@@ -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=
|
||||
```
|
||||
---
|
||||
@@ -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 后端。**
|
||||
|
||||
而且整个过程中:
|
||||
**前端交互不改,只替换数据来源和后端能力。**
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
@@ -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 组件实例快照”。
|
||||
|
||||
@@ -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 组件实例快照”。
|
||||
|
||||
@@ -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` 的交互方向,而不是只做一个右侧栏凑数。
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user