feat(langgraph): add enterprise UI card components and implement main.tsx Chat entry
- Create 5 enterprise UI card components under src/agent-uis/enterprise/: knowledge-result, ticket-summary, ticket-detail, search-result, sandbox-result - Update ComponentMap in src/agent-uis/index.tsx to replace () => null placeholders with real component imports - Implement src/main.tsx with useStream + LoadExternalComponent for full Chat UI, connecting to LangGraph agent with VITE_LANGGRAPH_URL env var 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
b621fbae92
commit
645f1ecaae
@@ -1,152 +0,0 @@
|
||||
# 外部服务接入配置
|
||||
|
||||
> **使用说明**:此文档用于记录外部服务的接入方式、环境变量和调用示例,便于开发、联调与排障。
|
||||
>
|
||||
> 当前服务按“代码已支持 + 部署环境变量由 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=
|
||||
```
|
||||
---
|
||||
@@ -1,27 +0,0 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
|
||||
# Virtual env
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -1,225 +0,0 @@
|
||||
"""Attachment API endpoints -- upload, metadata, download, delete.
|
||||
|
||||
Files are stored in Azure Blob Storage under the path:
|
||||
attachments/{conversation_id}/{uuid}_{filename}
|
||||
|
||||
After upload, if the content type is parseable (PDF, images, etc.),
|
||||
a task is dispatched to Service Bus for async processing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from litestar import Response, delete, get, post
|
||||
from litestar.datastructures import UploadFile
|
||||
from litestar.enums import RequestEncodingType
|
||||
from litestar.exceptions import NotFoundException
|
||||
from litestar.params import Body
|
||||
|
||||
from app.schemas import AttachmentOut
|
||||
from app.storage.blob import delete_blob, generate_sas_url, upload_blob
|
||||
from app.store.postgres import Attachment, Conversation, async_session_factory
|
||||
from app.tasks.bus import send_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Content types eligible for async parsing via Service Bus
|
||||
PARSEABLE_TYPES = {
|
||||
"application/pdf",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"text/csv",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
}
|
||||
|
||||
# 50 MB upload limit
|
||||
MAX_UPLOAD_BYTES = 50 * 1024 * 1024
|
||||
|
||||
|
||||
def _attachment_to_out(att: Attachment) -> AttachmentOut:
|
||||
"""Convert an ORM Attachment to the response schema."""
|
||||
return AttachmentOut(
|
||||
id=att.id,
|
||||
filename=att.filename,
|
||||
content_type=att.content_type,
|
||||
blob_url=att.blob_url,
|
||||
size_bytes=att.size_bytes,
|
||||
conversation_id=att.conversation_id,
|
||||
message_id=att.message_id,
|
||||
created_at=att.created_at.isoformat() if att.created_at else "",
|
||||
)
|
||||
|
||||
|
||||
@post("/api/attachments/upload")
|
||||
async def upload_attachment(
|
||||
data: UploadFile = Body(media_type=RequestEncodingType.MULTI_PART),
|
||||
conversation_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> AttachmentOut:
|
||||
"""POST /api/attachments/upload -- Upload a file to Blob Storage.
|
||||
|
||||
Accepts multipart/form-data with a single file field named ``data``.
|
||||
Optional query parameters ``conversation_id`` and ``message_id``
|
||||
associate the attachment with a conversation/message.
|
||||
|
||||
Returns the attachment metadata including the blob URL.
|
||||
"""
|
||||
file_bytes = await data.read()
|
||||
file_size = len(file_bytes)
|
||||
|
||||
if file_size > MAX_UPLOAD_BYTES:
|
||||
return Response(
|
||||
content={"detail": f"File too large. Max {MAX_UPLOAD_BYTES // (1024*1024)} MB."},
|
||||
status_code=413,
|
||||
)
|
||||
|
||||
filename = data.filename or "untitled"
|
||||
content_type = data.content_type or "application/octet-stream"
|
||||
attachment_id = str(uuid.uuid4())
|
||||
|
||||
# Validate conversation_id FK -- if it doesn't exist in the conversations
|
||||
# table, store the attachment as unlinked instead of raising a constraint error.
|
||||
if conversation_id:
|
||||
async with async_session_factory() as session:
|
||||
existing = await session.get(Conversation, conversation_id)
|
||||
if existing is None:
|
||||
logger.warning(
|
||||
"conversation_id %s not found, storing attachment as unlinked",
|
||||
conversation_id,
|
||||
)
|
||||
conversation_id = None
|
||||
|
||||
# Build a unique blob path
|
||||
conv_segment = conversation_id or "_unlinked"
|
||||
blob_name = f"attachments/{conv_segment}/{attachment_id}_{filename}"
|
||||
|
||||
# Upload to Azure Blob Storage
|
||||
blob_url = await upload_blob(
|
||||
data=file_bytes,
|
||||
filename=blob_name,
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
# Persist metadata in PostgreSQL
|
||||
attachment = Attachment(
|
||||
id=attachment_id,
|
||||
conversation_id=conversation_id,
|
||||
message_id=message_id,
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
blob_url=blob_url,
|
||||
size_bytes=file_size,
|
||||
)
|
||||
async with async_session_factory() as session:
|
||||
session.add(attachment)
|
||||
await session.commit()
|
||||
await session.refresh(attachment)
|
||||
|
||||
# Dispatch async parsing task if the file type is parseable
|
||||
if content_type in PARSEABLE_TYPES:
|
||||
try:
|
||||
await send_task(
|
||||
task_type="parse_attachment",
|
||||
payload={
|
||||
"attachment_id": attachment_id,
|
||||
"content_type": content_type,
|
||||
"blob_name": blob_name,
|
||||
"filename": filename,
|
||||
},
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
logger.info(
|
||||
"Dispatched parse_attachment task for %s (type=%s)",
|
||||
attachment_id,
|
||||
content_type,
|
||||
)
|
||||
except Exception:
|
||||
# Parsing is best-effort; upload itself is already persisted
|
||||
logger.warning(
|
||||
"Failed to dispatch parse_attachment task for %s",
|
||||
attachment_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return _attachment_to_out(attachment)
|
||||
|
||||
|
||||
@get("/api/attachments/{attachment_id:str}")
|
||||
async def get_attachment(attachment_id: str) -> AttachmentOut:
|
||||
"""GET /api/attachments/:id -- Get attachment metadata."""
|
||||
async with async_session_factory() as session:
|
||||
attachment = await session.get(Attachment, attachment_id)
|
||||
if attachment is None:
|
||||
raise NotFoundException(detail=f"Attachment {attachment_id} not found")
|
||||
return _attachment_to_out(attachment)
|
||||
|
||||
|
||||
@get("/api/attachments/{attachment_id:str}/download")
|
||||
async def download_attachment(attachment_id: str) -> Response:
|
||||
"""GET /api/attachments/:id/download -- Redirect to a time-limited SAS URL.
|
||||
|
||||
Generates a SAS token valid for 1 hour and returns a 302 redirect.
|
||||
"""
|
||||
async with async_session_factory() as session:
|
||||
attachment = await session.get(Attachment, attachment_id)
|
||||
if attachment is None:
|
||||
raise NotFoundException(detail=f"Attachment {attachment_id} not found")
|
||||
|
||||
# Extract blob name from the full URL by removing the base
|
||||
# blob_url looks like https://<account>.blob.core.windows.net/<container>/<blob_name>
|
||||
# We stored the blob_name as attachments/{conv}/{uuid}_{filename}
|
||||
# Re-derive it from the stored URL
|
||||
blob_url = attachment.blob_url
|
||||
# Find the blob name after the container segment
|
||||
container_marker = "/soc-files/"
|
||||
if container_marker in blob_url:
|
||||
blob_name = blob_url.split(container_marker, 1)[1]
|
||||
else:
|
||||
# Fallback: reconstruct from known pattern
|
||||
conv_segment = attachment.conversation_id or "_unlinked"
|
||||
blob_name = f"attachments/{conv_segment}/{attachment.id}_{attachment.filename}"
|
||||
|
||||
sas_url = await generate_sas_url(blob_name, expiry_hours=1)
|
||||
|
||||
return Response(
|
||||
content=None,
|
||||
status_code=302,
|
||||
headers={"Location": sas_url},
|
||||
)
|
||||
|
||||
|
||||
@delete("/api/attachments/{attachment_id:str}", status_code=204, return_dto=None)
|
||||
async def delete_attachment(attachment_id: str) -> None:
|
||||
"""DELETE /api/attachments/:id -- Delete attachment (blob + DB record)."""
|
||||
async with async_session_factory() as session:
|
||||
attachment = await session.get(Attachment, attachment_id)
|
||||
if attachment is None:
|
||||
raise NotFoundException(detail=f"Attachment {attachment_id} not found")
|
||||
|
||||
# Extract blob name
|
||||
blob_url = attachment.blob_url
|
||||
container_marker = "/soc-files/"
|
||||
if container_marker in blob_url:
|
||||
blob_name = blob_url.split(container_marker, 1)[1]
|
||||
else:
|
||||
conv_segment = attachment.conversation_id or "_unlinked"
|
||||
blob_name = f"attachments/{conv_segment}/{attachment.id}_{attachment.filename}"
|
||||
|
||||
# Delete from Blob Storage (best-effort; DB record deleted regardless)
|
||||
try:
|
||||
await delete_blob(blob_name)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to delete blob %s, proceeding with DB deletion",
|
||||
blob_name,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Delete from database
|
||||
await session.delete(attachment)
|
||||
await session.commit()
|
||||
@@ -1,357 +0,0 @@
|
||||
"""SSE streaming chat endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from langchain_core.messages import HumanMessage
|
||||
from litestar import post
|
||||
from litestar.response import Stream
|
||||
|
||||
from app.graph.builder import get_chat_graph
|
||||
from app.schemas import ChatRequest
|
||||
from app.store.memory import get_checkpointer
|
||||
from app.store.postgres import Conversation, Message, async_session_factory
|
||||
from app.tools import resolve_tools
|
||||
from app.tools.mcp_jina import create_jina_mcp_client, get_jina_mcp_tools
|
||||
|
||||
|
||||
async def _ensure_conversation(conversation_id: str, first_message: str) -> None:
|
||||
"""Create conversation and persist the user message."""
|
||||
async with async_session_factory() as session:
|
||||
existing = await session.get(Conversation, conversation_id)
|
||||
if existing is None:
|
||||
# Use first ~50 chars of message as title
|
||||
title = first_message[:50].strip() or "New conversation"
|
||||
conv = Conversation(id=conversation_id, title=title)
|
||||
session.add(conv)
|
||||
# Persist user message
|
||||
msg = Message(
|
||||
conversation_id=conversation_id,
|
||||
role="human",
|
||||
content=first_message,
|
||||
)
|
||||
session.add(msg)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _persist_ai_message(conversation_id: str, content: str) -> None:
|
||||
"""Persist the AI response message."""
|
||||
async with async_session_factory() as session:
|
||||
msg = Message(
|
||||
conversation_id=conversation_id,
|
||||
role="ai",
|
||||
content=content,
|
||||
)
|
||||
session.add(msg)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _run_graph_stream(
|
||||
request: ChatRequest,
|
||||
graph,
|
||||
all_tools: list,
|
||||
) -> AsyncIterator[bytes]:
|
||||
"""Run the LangGraph agent and yield SSE events."""
|
||||
config = {
|
||||
"configurable": {"thread_id": request.conversation_id},
|
||||
}
|
||||
|
||||
# When using ReAct agent (with tools), input is just messages.
|
||||
# When using plain graph (no tools), input includes model key.
|
||||
if all_tools:
|
||||
input_data = {"messages": [HumanMessage(content=request.message)]}
|
||||
else:
|
||||
input_data = {
|
||||
"messages": [HumanMessage(content=request.message)],
|
||||
"model": request.model,
|
||||
}
|
||||
|
||||
full_content: list[str] = []
|
||||
logger = logging.getLogger(__name__)
|
||||
tool_start_ts: dict[str, int] = {} # track per-tool start timestamps
|
||||
has_tool_activity = False
|
||||
|
||||
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:
|
||||
# Only stream text content, skip tool call chunks
|
||||
if isinstance(chunk.content, str):
|
||||
full_content.append(chunk.content)
|
||||
yield _sse({"type": "token", "content": chunk.content})
|
||||
|
||||
elif kind == "on_tool_start":
|
||||
call_id = event.get("run_id", str(uuid.uuid4()))
|
||||
tool_name = event.get("name", "unknown")
|
||||
tool_input = event.get("data", {}).get("input", {})
|
||||
ts = int(time.time() * 1000)
|
||||
tool_start_ts[call_id] = ts
|
||||
has_tool_activity = True
|
||||
yield _sse({
|
||||
"type": "tool_start",
|
||||
"call_id": call_id,
|
||||
"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":
|
||||
call_id = event.get("run_id", "")
|
||||
tool_name = event.get("name", "unknown")
|
||||
output = event.get("data", {}).get("output", "")
|
||||
output_str = _extract_output_str(output)
|
||||
ts = int(time.time() * 1000)
|
||||
duration_ms = ts - tool_start_ts.pop(call_id, ts)
|
||||
if _is_tool_error(output_str):
|
||||
yield _sse({
|
||||
"type": "tool_error",
|
||||
"call_id": call_id,
|
||||
"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",
|
||||
"call_id": call_id,
|
||||
"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,
|
||||
})
|
||||
|
||||
elif kind == "on_chain_start":
|
||||
chain_name = event.get("name", "")
|
||||
tags = event.get("tags", [])
|
||||
is_graph_step = any(t.startswith("graph:step:") for t in tags)
|
||||
if chain_name == "agent" and is_graph_step:
|
||||
if has_tool_activity:
|
||||
yield _sse({
|
||||
"type": "status",
|
||||
"stage": "generating",
|
||||
"message": "正在生成回复...",
|
||||
})
|
||||
else:
|
||||
yield _sse({
|
||||
"type": "status",
|
||||
"stage": "thinking",
|
||||
"message": "正在分析...",
|
||||
})
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("SSE stream error for conversation %s: %s", request.conversation_id, exc, exc_info=True)
|
||||
|
||||
error_msg = str(exc)
|
||||
user_hint = "请求处理出现错误,请重试"
|
||||
|
||||
# Detect checkpoint pollution: a prior tool crash left an AIMessage
|
||||
# with tool_calls but no corresponding ToolMessage. LangGraph refuses
|
||||
# to continue the thread. Purge the thread so the next request
|
||||
# starts from a clean state.
|
||||
if "tool_calls" in error_msg and "ToolMessage" in error_msg:
|
||||
try:
|
||||
checkpointer = await get_checkpointer()
|
||||
await checkpointer.adelete_thread(request.conversation_id)
|
||||
logger.warning(
|
||||
"Purged polluted checkpoint for thread %s",
|
||||
request.conversation_id,
|
||||
)
|
||||
user_hint = "对话状态异常,已自动重置,请重新发送消息"
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to purge checkpoint for thread %s",
|
||||
request.conversation_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
error_data = json.dumps(
|
||||
{"type": "error", "content": user_hint},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
yield f"data: {error_data}\n\n".encode("utf-8")
|
||||
|
||||
finally:
|
||||
# Persist whatever AI content was streamed before the error (if any)
|
||||
ai_content = "".join(full_content)
|
||||
if ai_content:
|
||||
await _persist_ai_message(request.conversation_id, ai_content)
|
||||
|
||||
# Always send done so the frontend closes the stream cleanly
|
||||
yield _sse({"type": "done"})
|
||||
|
||||
|
||||
async def _stream_response(request: ChatRequest) -> AsyncIterator[bytes]:
|
||||
"""Stream LLM response tokens via SSE."""
|
||||
# Ensure conversation exists and persist user message
|
||||
await _ensure_conversation(request.conversation_id, request.message)
|
||||
|
||||
# Resolve tools from frontend tool keys
|
||||
regular_tools, needs_jina_mcp = resolve_tools(request.tools)
|
||||
|
||||
if needs_jina_mcp:
|
||||
mcp_client = create_jina_mcp_client()
|
||||
jina_tools = await get_jina_mcp_tools(mcp_client)
|
||||
all_tools = regular_tools + jina_tools
|
||||
graph = await get_chat_graph(model=request.model, tools=all_tools)
|
||||
async for chunk in _run_graph_stream(request, graph, all_tools):
|
||||
yield chunk
|
||||
else:
|
||||
graph = await get_chat_graph(model=request.model, tools=regular_tools)
|
||||
async for chunk in _run_graph_stream(request, graph, regular_tools):
|
||||
yield chunk
|
||||
|
||||
|
||||
# ── Trace helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _extract_output_str(output) -> str:
|
||||
"""Extract plain text from tool output, handling MCP structured content."""
|
||||
if isinstance(output, str):
|
||||
return output
|
||||
# MCP ToolMessage: has .content as list of {'type': 'text', 'text': '...'}
|
||||
content = getattr(output, "content", None)
|
||||
if isinstance(content, list):
|
||||
parts = [c.get("text", "") for c in content if isinstance(c, dict) and c.get("type") == "text"]
|
||||
return "\n".join(parts)
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
return str(output)
|
||||
|
||||
|
||||
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": "查询工单详情",
|
||||
"generate_document": "生成文档",
|
||||
"sandbox_run": "执行沙盒代码",
|
||||
# Google Serper
|
||||
"serper_search": "Google 搜索",
|
||||
# Jina MCP tools
|
||||
"search_web": "外部搜索",
|
||||
"read_url": "读取网页",
|
||||
"sort_by_relevance": "相关性排序",
|
||||
}
|
||||
|
||||
_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:
|
||||
first_line = output.split("\n")[0].strip()
|
||||
return first_line[:80] 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 "serper_search":
|
||||
return f"搜索:{str(inp.get('query', ''))[:50]}"
|
||||
case "search_web":
|
||||
return f"搜索:{str(inp.get('query', ''))[:50]}"
|
||||
case "read_url":
|
||||
url = inp.get('url', '')
|
||||
if isinstance(url, list):
|
||||
url = url[0] if url else ''
|
||||
return f"读取:{str(url)[:60]}"
|
||||
case "sort_by_relevance":
|
||||
query = str(inp.get('query', ''))[:30]
|
||||
count = len(inp.get('documents', []))
|
||||
return f"排序 {count} 条结果,查询:{query}"
|
||||
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() in ("", "(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 as _re
|
||||
m = _re.search(r"Found (\d+) tickets", output)
|
||||
return f"返回 {m.group(1)} 条工单" if m else "工单列表已获取"
|
||||
case "ticket_detail":
|
||||
return "工单详情已获取"
|
||||
case "serper_search":
|
||||
return f"Google 搜索完成,{len(output.splitlines())} 行结果"
|
||||
case "search_web":
|
||||
count = output.count("##")
|
||||
return f"找到 {max(count, 1)} 条搜索结果"
|
||||
case "read_url":
|
||||
lines = len(output.splitlines())
|
||||
return f"读取完成,{lines} 行内容"
|
||||
case "sort_by_relevance":
|
||||
return "排序完成"
|
||||
case "generate_document":
|
||||
if "Download:" in output:
|
||||
if "[PPT]" in output:
|
||||
return "PPT 已生成,可下载"
|
||||
if "[Excel]" in output or "[Table]" in output:
|
||||
return "表格已生成,可下载"
|
||||
return "Word 文档已生成,可下载"
|
||||
return "文档生成完成"
|
||||
case "sandbox_run":
|
||||
lines = len(output.splitlines())
|
||||
has_error = output.startswith("[Exit code:")
|
||||
return f"执行完成,输出 {lines} 行{'(含错误)' if has_error else ''}"
|
||||
case _:
|
||||
return output[:60]
|
||||
|
||||
|
||||
@post("/api/chat/stream")
|
||||
async def stream_chat(data: ChatRequest) -> Stream:
|
||||
"""POST /api/chat/stream - SSE streaming chat endpoint."""
|
||||
if not data.conversation_id:
|
||||
data.conversation_id = str(uuid.uuid4())
|
||||
|
||||
return Stream(
|
||||
_stream_response(data),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Conversation CRUD endpoints backed by PostgreSQL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from litestar import delete, get, patch, post
|
||||
from litestar.exceptions import NotFoundException
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.schemas import (
|
||||
ConversationCreate,
|
||||
ConversationDetail,
|
||||
ConversationOut,
|
||||
ConversationUpdate,
|
||||
MessageOut,
|
||||
)
|
||||
from app.store.postgres import Conversation, Message, async_session_factory
|
||||
|
||||
|
||||
def _conv_to_out(conv: Conversation) -> ConversationOut:
|
||||
"""Convert a Conversation ORM object to the API response model."""
|
||||
return ConversationOut(
|
||||
id=conv.id,
|
||||
title=conv.title,
|
||||
created_at=conv.created_at.isoformat(),
|
||||
updated_at=conv.updated_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@get("/api/conversations")
|
||||
async def list_conversations() -> list[ConversationOut]:
|
||||
"""GET /api/conversations - List all conversations."""
|
||||
async with async_session_factory() as session:
|
||||
stmt = select(Conversation).order_by(Conversation.updated_at.desc())
|
||||
result = await session.execute(stmt)
|
||||
convs = result.scalars().all()
|
||||
return [_conv_to_out(c) for c in convs]
|
||||
|
||||
|
||||
@get("/api/conversations/{conversation_id:str}")
|
||||
async def get_conversation(conversation_id: str) -> ConversationDetail:
|
||||
"""GET /api/conversations/:id - Get a single conversation with messages."""
|
||||
async with async_session_factory() as session:
|
||||
conv = await session.get(Conversation, conversation_id)
|
||||
if conv is None:
|
||||
raise NotFoundException(detail=f"Conversation {conversation_id} not found")
|
||||
# Eagerly load messages
|
||||
stmt = select(Message).where(
|
||||
Message.conversation_id == conversation_id
|
||||
).order_by(Message.created_at)
|
||||
result = await session.execute(stmt)
|
||||
msgs = result.scalars().all()
|
||||
return ConversationDetail(
|
||||
id=conv.id,
|
||||
title=conv.title,
|
||||
created_at=conv.created_at.isoformat(),
|
||||
updated_at=conv.updated_at.isoformat(),
|
||||
messages=[
|
||||
MessageOut(
|
||||
id=m.id,
|
||||
role=m.role,
|
||||
content=m.content,
|
||||
created_at=m.created_at.isoformat(),
|
||||
)
|
||||
for m in msgs
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@post("/api/conversations")
|
||||
async def create_conversation(data: ConversationCreate) -> ConversationOut:
|
||||
"""POST /api/conversations - Create a new conversation."""
|
||||
conv = Conversation(
|
||||
id=str(uuid.uuid4()),
|
||||
title=data.title,
|
||||
)
|
||||
async with async_session_factory() as session:
|
||||
session.add(conv)
|
||||
await session.commit()
|
||||
await session.refresh(conv)
|
||||
return _conv_to_out(conv)
|
||||
|
||||
|
||||
@patch("/api/conversations/{conversation_id:str}")
|
||||
async def update_conversation(
|
||||
conversation_id: str,
|
||||
data: ConversationUpdate,
|
||||
) -> ConversationOut:
|
||||
"""PATCH /api/conversations/:id - Update conversation title."""
|
||||
async with async_session_factory() as session:
|
||||
conv = await session.get(Conversation, conversation_id)
|
||||
if conv is None:
|
||||
raise NotFoundException(
|
||||
detail=f"Conversation {conversation_id} not found"
|
||||
)
|
||||
conv.title = data.title
|
||||
conv.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(conv)
|
||||
return _conv_to_out(conv)
|
||||
|
||||
|
||||
@delete("/api/conversations/{conversation_id:str}", status_code=200)
|
||||
async def delete_conversation(conversation_id: str) -> dict:
|
||||
"""DELETE /api/conversations/:id - Delete a conversation."""
|
||||
async with async_session_factory() as session:
|
||||
conv = await session.get(Conversation, conversation_id)
|
||||
if conv is not None:
|
||||
await session.delete(conv)
|
||||
await session.commit()
|
||||
return {"deleted": True}
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Health check endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from litestar import get
|
||||
|
||||
|
||||
@get("/health")
|
||||
async def health_check() -> dict:
|
||||
return {"status": "ok"}
|
||||
@@ -1,131 +0,0 @@
|
||||
"""Ticket API endpoints — proxy to Gongdan system.
|
||||
|
||||
Returns data in a format aligned with the frontend TicketData interface:
|
||||
{ id, title, status, priority, createdAt }
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from litestar import get
|
||||
from litestar.exceptions import NotFoundException
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _gongdan_headers() -> dict[str, str]:
|
||||
return {"X-Api-Key": settings.gongdan_api_key}
|
||||
|
||||
|
||||
def _map_status(raw: str) -> str:
|
||||
mapping = {
|
||||
"OPEN": "pending",
|
||||
"ASSIGNED": "processing",
|
||||
"IN_PROGRESS": "processing",
|
||||
"PENDING_CUSTOMER": "processing",
|
||||
"RESOLVED": "resolved",
|
||||
"CLOSED": "resolved",
|
||||
}
|
||||
return mapping.get(raw, "pending")
|
||||
|
||||
|
||||
def _map_priority(raw: str) -> str:
|
||||
mapping = {
|
||||
"URGENT": "P0",
|
||||
"PRIORITY": "P1",
|
||||
"NORMAL": "P2",
|
||||
"LOW": "P3",
|
||||
}
|
||||
return mapping.get(raw, "P2")
|
||||
|
||||
|
||||
def _transform_ticket(t: dict) -> dict:
|
||||
"""Transform a Gongdan ticket to the frontend TicketData shape."""
|
||||
return {
|
||||
"id": t.get("id", ""),
|
||||
"ticketNumber": t.get("ticketNumber", ""),
|
||||
"title": t.get("description", "")[:120] or "No description",
|
||||
"status": _map_status(t.get("status", "")),
|
||||
"priority": _map_priority(t.get("priority", "")),
|
||||
"createdAt": t.get("createdAt", ""),
|
||||
}
|
||||
|
||||
|
||||
@get("/api/tickets/summary")
|
||||
async def get_tickets_summary() -> dict:
|
||||
"""GET /api/tickets/summary -- Aggregate ticket statistics.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"total": int,
|
||||
"by_status": {"pending": N, "processing": N, "resolved": N},
|
||||
"by_priority": {"P0": N, "P1": N, "P2": N, "P3": N}
|
||||
}
|
||||
"""
|
||||
# Fetch a large page to get representative counts.
|
||||
# For a production system this would be a dedicated aggregation API;
|
||||
# the Gongdan API only exposes list endpoints so we aggregate client-side.
|
||||
url = f"{settings.gongdan_api_base}/api/tickets"
|
||||
params = {"page": 1, "pageSize": 100}
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(url, params=params, headers=_gongdan_headers())
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
tickets = data.get("tickets", [])
|
||||
|
||||
status_counter: Counter[str] = Counter()
|
||||
priority_counter: Counter[str] = Counter()
|
||||
|
||||
for t in tickets:
|
||||
status_counter[_map_status(t.get("status", ""))] += 1
|
||||
priority_counter[_map_priority(t.get("priority", ""))] += 1
|
||||
|
||||
return {
|
||||
"total": len(tickets),
|
||||
"by_status": {
|
||||
"pending": status_counter.get("pending", 0),
|
||||
"processing": status_counter.get("processing", 0),
|
||||
"resolved": status_counter.get("resolved", 0),
|
||||
},
|
||||
"by_priority": {
|
||||
"P0": priority_counter.get("P0", 0),
|
||||
"P1": priority_counter.get("P1", 0),
|
||||
"P2": priority_counter.get("P2", 0),
|
||||
"P3": priority_counter.get("P3", 0),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@get("/api/tickets")
|
||||
async def list_tickets(page: int = 1, page_size: int = 20) -> list[dict]:
|
||||
"""GET /api/tickets — List tickets from Gongdan, formatted for frontend."""
|
||||
url = f"{settings.gongdan_api_base}/api/tickets"
|
||||
params = {"page": page, "pageSize": page_size}
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(url, params=params, headers=_gongdan_headers())
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
tickets = data.get("tickets", [])
|
||||
return [_transform_ticket(t) for t in tickets]
|
||||
|
||||
|
||||
@get("/api/tickets/{ticket_id:uuid}")
|
||||
async def get_ticket(ticket_id: UUID) -> dict:
|
||||
"""GET /api/tickets/:id — Get a single ticket detail."""
|
||||
url = f"{settings.gongdan_api_base}/api/tickets/{ticket_id}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(url, headers=_gongdan_headers())
|
||||
if resp.status_code == 404:
|
||||
raise NotFoundException(detail=f"Ticket {ticket_id} not found")
|
||||
resp.raise_for_status()
|
||||
t = resp.json()
|
||||
|
||||
return _transform_ticket(t)
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
# Cache module
|
||||
Vendored
-73
@@ -1,73 +0,0 @@
|
||||
"""Redis cache client for search result caching.
|
||||
|
||||
Uses Azure Redis (TLS on port 6380).
|
||||
Cache key pattern: search:{query_hash}:{model}
|
||||
TTL: 300 seconds (5 minutes).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
import redis.asyncio as redis
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_pool: redis.Redis | None = None
|
||||
|
||||
CACHE_TTL = 300 # seconds
|
||||
|
||||
|
||||
async def get_redis() -> redis.Redis:
|
||||
"""Return a singleton async Redis client."""
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = redis.from_url(
|
||||
settings.redis_url,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5,
|
||||
)
|
||||
return _pool
|
||||
|
||||
|
||||
async def close_redis() -> None:
|
||||
"""Close the Redis connection pool (for clean shutdown)."""
|
||||
global _pool
|
||||
if _pool is not None:
|
||||
await _pool.aclose()
|
||||
_pool = None
|
||||
|
||||
|
||||
def _cache_key(query: str, model: str) -> str:
|
||||
"""Build a cache key from query hash and model."""
|
||||
query_hash = hashlib.sha256(query.strip().lower().encode("utf-8")).hexdigest()[:16]
|
||||
return f"search:{query_hash}:{model}"
|
||||
|
||||
|
||||
async def get_cached_search(query: str, model: str) -> str | None:
|
||||
"""Look up a cached search result. Returns None on miss or error."""
|
||||
try:
|
||||
r = await get_redis()
|
||||
key = _cache_key(query, model)
|
||||
value = await r.get(key)
|
||||
if value is not None:
|
||||
logger.info("Cache HIT for key=%s", key)
|
||||
return value
|
||||
except Exception:
|
||||
logger.warning("Redis GET failed, treating as cache miss", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def set_cached_search(query: str, model: str, result: str) -> None:
|
||||
"""Store a search result in cache with TTL."""
|
||||
try:
|
||||
r = await get_redis()
|
||||
key = _cache_key(query, model)
|
||||
await r.set(key, result, ex=CACHE_TTL)
|
||||
logger.info("Cache SET key=%s ttl=%ds", key, CACHE_TTL)
|
||||
except Exception:
|
||||
logger.warning("Redis SET failed, result not cached", exc_info=True)
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Application configuration via pydantic-settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Azure OpenAI
|
||||
azure_openai_endpoint: str = ""
|
||||
azure_openai_api_key: str = ""
|
||||
azure_openai_api_version: str = "2025-04-01-preview"
|
||||
azure_openai_deployment: str = "gpt-5.4"
|
||||
|
||||
# PostgreSQL
|
||||
database_url: str = "postgresql+asyncpg://azuredb:h13nYoFJX6QrfLzB8bdipEUCjsZq2P7W@dataope.postgres.database.azure.com:5432/soc?ssl=require"
|
||||
|
||||
# LangGraph checkpointer uses psycopg (not asyncpg) connection string
|
||||
@property
|
||||
def database_url_psycopg(self) -> str:
|
||||
"""Return psycopg-compatible connection string for LangGraph checkpointer."""
|
||||
url = self.database_url.replace("postgresql+asyncpg://", "postgresql://")
|
||||
# psycopg uses sslmode=require, not ssl=require
|
||||
url = url.replace("?ssl=require", "?sslmode=require")
|
||||
url = url.replace("&ssl=require", "&sslmode=require")
|
||||
return url
|
||||
|
||||
# KB Agent
|
||||
kb_agent_url: str = "https://agnetdoc-cve0guf5h8eggmej.southeastasia-01.azurewebsites.net"
|
||||
kb_agent_api_key: str = ""
|
||||
kb_agent_search_path: str = "/api/v1/search"
|
||||
kb_agent_search_timeout_sec: int = 30
|
||||
|
||||
# Gongdan (ticket system)
|
||||
gongdan_api_base: str = "https://gongdan-b5fzbtgteqd5gzfb.eastasia-01.azurewebsites.net"
|
||||
gongdan_api_key: str = ""
|
||||
|
||||
# Jina AI (Search / Reader / Rerank)
|
||||
jina_api_key: str = ""
|
||||
|
||||
# Google Serper
|
||||
serper_api_key: str = "499940576bc8a7211ac98a3f3b83a4826bb8105b"
|
||||
|
||||
# Redis
|
||||
redis_url: str = "rediss://:bY8ZNwyJX60UwN5NPqnl6HRODfTV0efkDAzCaF1PrOU=@oper.redis.cache.windows.net:6380"
|
||||
|
||||
# Doc Creator Agent
|
||||
doc_agent_url: str = "http://doc-creator-agent-b0d02105-a557fe.taijiagnet.com"
|
||||
doc_agent_key: str = ""
|
||||
|
||||
# Daytona Sandbox
|
||||
daytona_api_key: str = ""
|
||||
daytona_api_url: str = "https://app.daytona.io/api"
|
||||
|
||||
# Azure Blob Storage
|
||||
azure_storage_connection_string: str = ""
|
||||
|
||||
# Azure Service Bus
|
||||
azure_service_bus_connection_string: str = ""
|
||||
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
debug: bool = False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -1,103 +0,0 @@
|
||||
"""Build and compile the LangGraph agent.
|
||||
|
||||
Phase 1 used a simple single-node StateGraph.
|
||||
Phase 2 upgrades to create_react_agent (ReAct pattern) with dynamic tool binding.
|
||||
|
||||
When no tools are requested, we fall back to a plain single-node graph so the
|
||||
agent does not produce unnecessary tool-call reasoning.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langchain_openai import AzureChatOpenAI
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
|
||||
from app.config import settings
|
||||
from app.graph.nodes import call_model
|
||||
from app.graph.state import ChatState
|
||||
from app.store.memory import get_checkpointer
|
||||
|
||||
# Model parameter presets
|
||||
MODEL_PARAMS: dict[str, dict] = {
|
||||
"flash": {"max_tokens": 500, "temperature": 0.2},
|
||||
"pro": {"max_tokens": 4096, "temperature": 0.3},
|
||||
}
|
||||
|
||||
# System prompt that instructs the ReAct agent
|
||||
SYSTEM_PROMPT = (
|
||||
"You are SOC Assistant, an enterprise AI assistant. "
|
||||
"You help users with knowledge base queries, ticket management, "
|
||||
"and general questions. "
|
||||
"When the user has enabled specific tools, you may use them if relevant. "
|
||||
"If you decide not to use an available tool, briefly explain why. "
|
||||
"Always respond in the same language the user uses. "
|
||||
"Be concise, accurate, and helpful.\n\n"
|
||||
"When web search tools are available:\n"
|
||||
"- Use search_web first to find relevant pages\n"
|
||||
"- Use read_url to get full content from the most relevant URLs (1-3 max)\n"
|
||||
"- Use sort_by_relevance to rank results if you have many documents\n"
|
||||
)
|
||||
|
||||
# Cache compiled graphs to avoid re-creation on every request.
|
||||
# Key: (model, frozenset(tool_names))
|
||||
_graph_cache: dict[tuple, object] = {}
|
||||
|
||||
|
||||
def _get_llm(model: str) -> AzureChatOpenAI:
|
||||
"""Create an AzureChatOpenAI instance with preset parameters."""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# MCP tool names that should not be cached (bound to per-request client)
|
||||
_MCP_TOOL_NAMES = {"search_web", "read_url", "sort_by_relevance"}
|
||||
|
||||
|
||||
async def get_chat_graph(model: str = "flash", tools: list | None = None):
|
||||
"""Get or create a compiled graph for the given model and tool set.
|
||||
|
||||
When tools are provided, creates a ReAct agent that can call tools.
|
||||
When no tools, falls back to a simple single-node graph.
|
||||
|
||||
MCP tools are bound to a per-request client session, so graphs
|
||||
containing them are never cached.
|
||||
"""
|
||||
tools = tools or []
|
||||
tool_names = frozenset(t.name for t in tools)
|
||||
has_mcp_tools = bool(tool_names & _MCP_TOOL_NAMES)
|
||||
|
||||
cache_key = (model, tool_names)
|
||||
if not has_mcp_tools and cache_key in _graph_cache:
|
||||
return _graph_cache[cache_key]
|
||||
|
||||
checkpointer = await get_checkpointer()
|
||||
llm = _get_llm(model)
|
||||
|
||||
if tools:
|
||||
# ReAct agent with tool calling
|
||||
graph = create_react_agent(
|
||||
llm,
|
||||
tools=tools,
|
||||
checkpointer=checkpointer,
|
||||
prompt=SYSTEM_PROMPT,
|
||||
)
|
||||
else:
|
||||
# Simple graph without tools (Phase 1 style)
|
||||
builder = StateGraph(ChatState)
|
||||
builder.add_node("agent", call_model)
|
||||
builder.set_entry_point("agent")
|
||||
builder.set_finish_point("agent")
|
||||
graph = builder.compile(checkpointer=checkpointer)
|
||||
|
||||
if not has_mcp_tools:
|
||||
_graph_cache[cache_key] = graph
|
||||
return graph
|
||||
@@ -1,36 +0,0 @@
|
||||
"""LangGraph node functions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langchain_openai import AzureChatOpenAI
|
||||
|
||||
from app.config import settings
|
||||
from app.graph.state import ChatState
|
||||
|
||||
# Model parameter presets
|
||||
MODEL_PARAMS: dict[str, dict] = {
|
||||
"flash": {"max_tokens": 500, "temperature": 0.2},
|
||||
"pro": {"max_tokens": 4096, "temperature": 0.3},
|
||||
}
|
||||
|
||||
|
||||
def _get_llm(model: str) -> AzureChatOpenAI:
|
||||
"""Create an AzureChatOpenAI instance with preset parameters."""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
async def call_model(state: ChatState) -> dict:
|
||||
"""Invoke the LLM with the current message history."""
|
||||
model = state.get("model", "flash")
|
||||
llm = _get_llm(model)
|
||||
response = await llm.ainvoke(state["messages"])
|
||||
return {"messages": [response]}
|
||||
@@ -1,11 +0,0 @@
|
||||
"""LangGraph state definition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langgraph.graph import MessagesState
|
||||
|
||||
|
||||
class ChatState(MessagesState):
|
||||
"""Extends MessagesState with model selection."""
|
||||
|
||||
model: str # "flash" or "pro"
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Litestar application entry point."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env before anything else reads settings
|
||||
load_dotenv()
|
||||
|
||||
from litestar import Litestar
|
||||
from litestar.config.cors import CORSConfig
|
||||
|
||||
from app.api.chat import stream_chat
|
||||
from app.api.conversations import (
|
||||
create_conversation,
|
||||
delete_conversation,
|
||||
get_conversation,
|
||||
list_conversations,
|
||||
update_conversation,
|
||||
)
|
||||
from app.api.attachments import (
|
||||
delete_attachment,
|
||||
download_attachment,
|
||||
get_attachment,
|
||||
upload_attachment,
|
||||
)
|
||||
from app.api.health import health_check
|
||||
from app.api.tickets import get_ticket, get_tickets_summary, list_tickets
|
||||
from app.cache.redis import close_redis
|
||||
from app.storage.blob import close_blob_client, ensure_container
|
||||
from app.store.memory import close_checkpointer
|
||||
from app.store.postgres import create_tables, dispose_engine
|
||||
from app.tasks.bus import close_service_bus
|
||||
|
||||
cors_config = CORSConfig(
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_credentials=False,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: Litestar) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan: create tables on startup, dispose engine on shutdown."""
|
||||
import logging
|
||||
await create_tables()
|
||||
# Pre-create blob container (best-effort; skip if storage not configured)
|
||||
try:
|
||||
await ensure_container()
|
||||
except Exception:
|
||||
logging.getLogger(__name__).warning(
|
||||
"Blob container init skipped (storage may not be configured)",
|
||||
exc_info=True,
|
||||
)
|
||||
yield
|
||||
await close_checkpointer()
|
||||
await close_redis()
|
||||
await close_blob_client()
|
||||
await close_service_bus()
|
||||
await dispose_engine()
|
||||
|
||||
|
||||
app = Litestar(
|
||||
route_handlers=[
|
||||
health_check,
|
||||
stream_chat,
|
||||
list_conversations,
|
||||
get_conversation,
|
||||
create_conversation,
|
||||
update_conversation,
|
||||
delete_conversation,
|
||||
get_tickets_summary,
|
||||
list_tickets,
|
||||
get_ticket,
|
||||
upload_attachment,
|
||||
get_attachment,
|
||||
download_attachment,
|
||||
delete_attachment,
|
||||
],
|
||||
cors_config=cors_config,
|
||||
lifespan=[lifespan],
|
||||
debug=False,
|
||||
)
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Request / Response Pydantic models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str = Field(..., min_length=1)
|
||||
conversation_id: str = Field(..., min_length=1)
|
||||
tools: list[str] = Field(default_factory=list)
|
||||
model: str = Field(default="flash", pattern="^(flash|pro)$")
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
"""Non-streaming chat response (for reference; SSE is primary)."""
|
||||
conversation_id: str
|
||||
content: str
|
||||
|
||||
|
||||
class ConversationCreate(BaseModel):
|
||||
title: str = Field(default="New conversation")
|
||||
|
||||
|
||||
class ConversationUpdate(BaseModel):
|
||||
title: str
|
||||
|
||||
|
||||
class MessageOut(BaseModel):
|
||||
id: str
|
||||
role: str
|
||||
content: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class ConversationOut(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class ConversationDetail(ConversationOut):
|
||||
"""Conversation with messages, returned by GET /api/conversations/{id}."""
|
||||
messages: list[MessageOut] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attachment schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AttachmentOut(BaseModel):
|
||||
"""Attachment metadata returned by upload and GET endpoints."""
|
||||
id: str
|
||||
filename: str
|
||||
content_type: str
|
||||
blob_url: str
|
||||
size_bytes: int
|
||||
conversation_id: str | None = None
|
||||
message_id: str | None = None
|
||||
created_at: str
|
||||
@@ -1,153 +0,0 @@
|
||||
"""Azure Blob Storage client for file uploads and downloads.
|
||||
|
||||
Used to persist attachments, sandbox output, and generated documents.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
|
||||
from azure.storage.blob.aio import BlobServiceClient
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client: BlobServiceClient | None = None
|
||||
DEFAULT_CONTAINER = "soc-files"
|
||||
|
||||
|
||||
async def _get_client() -> BlobServiceClient:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = BlobServiceClient.from_connection_string(
|
||||
settings.azure_storage_connection_string
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
async def ensure_container(container: str = DEFAULT_CONTAINER) -> None:
|
||||
"""Create the container if it does not already exist."""
|
||||
client = await _get_client()
|
||||
container_client = client.get_container_client(container)
|
||||
try:
|
||||
await container_client.get_container_properties()
|
||||
except Exception:
|
||||
await container_client.create_container()
|
||||
logger.info("Created blob container: %s", container)
|
||||
|
||||
|
||||
async def upload_blob(
|
||||
data: bytes,
|
||||
filename: str | None = None,
|
||||
container: str = DEFAULT_CONTAINER,
|
||||
content_type: str = "application/octet-stream",
|
||||
) -> str:
|
||||
"""Upload bytes to Azure Blob Storage and return the blob URL.
|
||||
|
||||
Args:
|
||||
data: File content as bytes.
|
||||
filename: Optional filename. A UUID is generated if not provided.
|
||||
container: Blob container name.
|
||||
content_type: MIME content type.
|
||||
|
||||
Returns:
|
||||
The public URL of the uploaded blob.
|
||||
"""
|
||||
client = await _get_client()
|
||||
await ensure_container(container)
|
||||
|
||||
blob_name = filename or f"{uuid.uuid4().hex}"
|
||||
blob_client = client.get_blob_client(container=container, blob=blob_name)
|
||||
|
||||
from azure.storage.blob import ContentSettings
|
||||
|
||||
await blob_client.upload_blob(
|
||||
data,
|
||||
overwrite=True,
|
||||
content_settings=ContentSettings(content_type=content_type),
|
||||
)
|
||||
logger.info("Uploaded blob: %s/%s (%d bytes)", container, blob_name, len(data))
|
||||
return blob_client.url
|
||||
|
||||
|
||||
async def download_blob(
|
||||
blob_name: str,
|
||||
container: str = DEFAULT_CONTAINER,
|
||||
) -> bytes:
|
||||
"""Download a blob's content as bytes.
|
||||
|
||||
Args:
|
||||
blob_name: Name of the blob to download.
|
||||
container: Blob container name.
|
||||
|
||||
Returns:
|
||||
The blob content as bytes.
|
||||
"""
|
||||
client = await _get_client()
|
||||
blob_client = client.get_blob_client(container=container, blob=blob_name)
|
||||
stream = await blob_client.download_blob()
|
||||
data = await stream.readall()
|
||||
return data
|
||||
|
||||
|
||||
async def delete_blob(
|
||||
blob_name: str,
|
||||
container: str = DEFAULT_CONTAINER,
|
||||
) -> None:
|
||||
"""Delete a blob from Azure Blob Storage.
|
||||
|
||||
Args:
|
||||
blob_name: Name of the blob to delete.
|
||||
container: Blob container name.
|
||||
"""
|
||||
client = await _get_client()
|
||||
blob_client = client.get_blob_client(container=container, blob=blob_name)
|
||||
await blob_client.delete_blob()
|
||||
logger.info("Deleted blob: %s/%s", container, blob_name)
|
||||
|
||||
|
||||
async def generate_sas_url(
|
||||
blob_name: str,
|
||||
container: str = DEFAULT_CONTAINER,
|
||||
expiry_hours: int = 1,
|
||||
) -> str:
|
||||
"""Generate a SAS URL for temporary read access to a blob.
|
||||
|
||||
Args:
|
||||
blob_name: Name of the blob.
|
||||
container: Blob container name.
|
||||
expiry_hours: Hours until the SAS token expires.
|
||||
|
||||
Returns:
|
||||
A full URL with SAS query parameters for time-limited access.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from azure.storage.blob import BlobSasPermissions, generate_blob_sas
|
||||
|
||||
client = await _get_client()
|
||||
# Extract account name and key from connection string for SAS generation
|
||||
account_name = settings.azure_storage_connection_string.split("AccountName=")[1].split(";")[0]
|
||||
account_key = settings.azure_storage_connection_string.split("AccountKey=")[1].split(";")[0]
|
||||
|
||||
sas_token = generate_blob_sas(
|
||||
account_name=account_name,
|
||||
container_name=container,
|
||||
blob_name=blob_name,
|
||||
account_key=account_key,
|
||||
permission=BlobSasPermissions(read=True),
|
||||
expiry=datetime.now(timezone.utc) + timedelta(hours=expiry_hours),
|
||||
)
|
||||
blob_client = client.get_blob_client(container=container, blob=blob_name)
|
||||
return f"{blob_client.url}?{sas_token}"
|
||||
|
||||
|
||||
async def close_blob_client() -> None:
|
||||
"""Close the blob service client."""
|
||||
global _client
|
||||
if _client is not None:
|
||||
await _client.close()
|
||||
_client = None
|
||||
@@ -1,40 +0,0 @@
|
||||
"""LangGraph checkpointer backed by PostgreSQL.
|
||||
|
||||
Uses psycopg async driver for the LangGraph checkpoint tables,
|
||||
while the rest of the app uses asyncpg via SQLAlchemy async.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
|
||||
from app.config import settings
|
||||
|
||||
_checkpointer: AsyncPostgresSaver | None = None
|
||||
_conn: AsyncConnection | None = None
|
||||
|
||||
|
||||
async def get_checkpointer() -> AsyncPostgresSaver:
|
||||
"""Return a singleton AsyncPostgresSaver instance.
|
||||
|
||||
Creates an async psycopg connection and sets up checkpoint tables.
|
||||
"""
|
||||
global _checkpointer, _conn
|
||||
if _checkpointer is None:
|
||||
_conn = await AsyncConnection.connect(
|
||||
settings.database_url_psycopg,
|
||||
autocommit=True,
|
||||
)
|
||||
_checkpointer = AsyncPostgresSaver(conn=_conn)
|
||||
await _checkpointer.setup()
|
||||
return _checkpointer
|
||||
|
||||
|
||||
async def close_checkpointer() -> None:
|
||||
"""Close the checkpointer connection (for clean shutdown)."""
|
||||
global _checkpointer, _conn
|
||||
if _conn is not None:
|
||||
await _conn.close()
|
||||
_conn = None
|
||||
_checkpointer = None
|
||||
@@ -1,137 +0,0 @@
|
||||
"""PostgreSQL models, engine, and session management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Engine & session factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_session() -> AsyncSession:
|
||||
"""Yield a new async session (for use in route handlers)."""
|
||||
async with async_session_factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORM base and models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Conversation(Base):
|
||||
__tablename__ = "conversations"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(64), primary_key=True, default=lambda: str(uuid.uuid4())
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(512), default="New conversation")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_utcnow, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, server_default=func.now()
|
||||
)
|
||||
|
||||
messages: Mapped[list[Message]] = relationship(
|
||||
back_populates="conversation",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="Message.created_at",
|
||||
)
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(64), primary_key=True, default=lambda: str(uuid.uuid4())
|
||||
)
|
||||
conversation_id: Mapped[str] = mapped_column(
|
||||
String(64), ForeignKey("conversations.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(32)) # "human", "ai", "system"
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_utcnow, server_default=func.now()
|
||||
)
|
||||
|
||||
conversation: Mapped[Conversation] = relationship(back_populates="messages")
|
||||
|
||||
|
||||
class Attachment(Base):
|
||||
__tablename__ = "attachments"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(64), primary_key=True, default=lambda: str(uuid.uuid4())
|
||||
)
|
||||
conversation_id: Mapped[str | None] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("conversations.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
message_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
filename: Mapped[str] = mapped_column(String(512))
|
||||
content_type: Mapped[str] = mapped_column(String(128), default="application/octet-stream")
|
||||
blob_url: Mapped[str] = mapped_column(Text)
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_utcnow, server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Table creation helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def create_tables() -> None:
|
||||
"""Create all tables if they don't exist.
|
||||
|
||||
Wrapped in try/except to handle the race condition when multiple
|
||||
gunicorn workers call create_all simultaneously and PostgreSQL
|
||||
raises a UniqueViolation on the pg_type_typname_nsp_index.
|
||||
|
||||
We catch BaseException (not just Exception) because anyio wraps
|
||||
concurrent errors in an ExceptionGroup which is a BaseException
|
||||
subclass and would otherwise crash the Litestar lifespan.
|
||||
"""
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
except BaseException:
|
||||
# Table likely already created by another worker -- safe to ignore
|
||||
import logging
|
||||
logging.getLogger(__name__).debug(
|
||||
"create_tables race condition (another worker likely created tables)",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def dispose_engine() -> None:
|
||||
"""Dispose the engine (for clean shutdown)."""
|
||||
await engine.dispose()
|
||||
@@ -1,110 +0,0 @@
|
||||
"""Azure Service Bus client for async task dispatch and consumption.
|
||||
|
||||
Long-running tasks (document generation, deep search) are published
|
||||
to a Service Bus queue so the main request can return immediately
|
||||
with a "task accepted" response. A background consumer picks up
|
||||
messages and processes them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from azure.servicebus.aio import ServiceBusClient, ServiceBusSender, ServiceBusReceiver
|
||||
from azure.servicebus import ServiceBusMessage
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client: ServiceBusClient | None = None
|
||||
QUEUE_NAME = "soc-tasks"
|
||||
|
||||
|
||||
async def _get_client() -> ServiceBusClient:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = ServiceBusClient.from_connection_string(
|
||||
settings.azure_service_bus_connection_string
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
async def send_task(
|
||||
task_type: str,
|
||||
payload: dict,
|
||||
conversation_id: str | None = None,
|
||||
) -> str:
|
||||
"""Publish an async task message to Service Bus.
|
||||
|
||||
Args:
|
||||
task_type: Type of task (e.g. "document_generate", "deep_search").
|
||||
payload: Task-specific data.
|
||||
conversation_id: Optional conversation context.
|
||||
|
||||
Returns:
|
||||
A unique task_id for tracking.
|
||||
"""
|
||||
client = await _get_client()
|
||||
task_id = uuid.uuid4().hex
|
||||
|
||||
message_body = json.dumps({
|
||||
"task_id": task_id,
|
||||
"task_type": task_type,
|
||||
"conversation_id": conversation_id,
|
||||
"payload": payload,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
})
|
||||
|
||||
sender: ServiceBusSender
|
||||
async with client.get_queue_sender(queue_name=QUEUE_NAME) as sender:
|
||||
await sender.send_messages(ServiceBusMessage(message_body))
|
||||
|
||||
logger.info("Sent task %s (type=%s) to Service Bus", task_id, task_type)
|
||||
return task_id
|
||||
|
||||
|
||||
async def receive_tasks(max_messages: int = 10, max_wait_time: int = 5) -> list[dict]:
|
||||
"""Receive pending task messages from Service Bus.
|
||||
|
||||
Args:
|
||||
max_messages: Maximum number of messages to receive.
|
||||
max_wait_time: Maximum wait time in seconds.
|
||||
|
||||
Returns:
|
||||
List of task dicts. Each message is completed (removed from queue)
|
||||
after being returned.
|
||||
"""
|
||||
client = await _get_client()
|
||||
tasks = []
|
||||
|
||||
receiver: ServiceBusReceiver
|
||||
async with client.get_queue_receiver(
|
||||
queue_name=QUEUE_NAME,
|
||||
max_wait_time=max_wait_time,
|
||||
) as receiver:
|
||||
messages = await receiver.receive_messages(
|
||||
max_message_count=max_messages,
|
||||
max_wait_time=max_wait_time,
|
||||
)
|
||||
for msg in messages:
|
||||
try:
|
||||
body = json.loads(str(msg))
|
||||
tasks.append(body)
|
||||
await receiver.complete_message(msg)
|
||||
except Exception:
|
||||
logger.exception("Failed to process Service Bus message")
|
||||
await receiver.dead_letter_message(msg, reason="parse_error")
|
||||
|
||||
return tasks
|
||||
|
||||
|
||||
async def close_service_bus() -> None:
|
||||
"""Close the Service Bus client."""
|
||||
global _client
|
||||
if _client is not None:
|
||||
await _client.close()
|
||||
_client = None
|
||||
@@ -1,28 +0,0 @@
|
||||
"""LangChain tool definitions for the ReAct agent."""
|
||||
|
||||
from app.tools.kb import kb_search
|
||||
from app.tools.tickets import ticket_list, ticket_detail
|
||||
from app.tools.document import generate_document
|
||||
from app.tools.sandbox import sandbox_run
|
||||
from app.tools.serper import serper_search
|
||||
|
||||
# Mapping from frontend tool names to LangChain tool objects.
|
||||
# "search" is no longer here — it is handled via Jina MCP in chat.py.
|
||||
# "serper" uses Google Serper REST API, independent from Jina MCP.
|
||||
_TOOL_MAP: dict[str, list] = {
|
||||
"knowledge": [kb_search],
|
||||
"tickets": [ticket_list, ticket_detail],
|
||||
"document": [generate_document],
|
||||
"sandbox": [sandbox_run],
|
||||
"serper": [serper_search],
|
||||
}
|
||||
|
||||
|
||||
def resolve_tools(tool_keys: list[str]) -> tuple[list, bool]:
|
||||
"""Return (regular_tools, needs_jina_mcp)."""
|
||||
needs_jina_mcp = "search" in tool_keys
|
||||
tools = []
|
||||
for key in tool_keys:
|
||||
if key in _TOOL_MAP:
|
||||
tools.extend(_TOOL_MAP[key])
|
||||
return tools, needs_jina_mcp
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Document generation tool using Doc Creator Agent.
|
||||
|
||||
Supports Word, PPT, and Excel document types. The tool detects
|
||||
the desired format from the user's prompt and calls the external
|
||||
Doc Creator Agent API to generate the document.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Keywords used to detect desired document type from the prompt
|
||||
_PPT_KEYWORDS = re.compile(r"(ppt|pptx|幻灯片|演示|slides?|presentation)", re.IGNORECASE)
|
||||
_TABLE_KEYWORDS = re.compile(r"(excel|xlsx?|表格|spreadsheet|csv|数据表)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _detect_doc_type(prompt: str) -> str:
|
||||
"""Detect document output type from the user prompt."""
|
||||
if _PPT_KEYWORDS.search(prompt):
|
||||
return "ppt"
|
||||
if _TABLE_KEYWORDS.search(prompt):
|
||||
return "table"
|
||||
return "word"
|
||||
|
||||
|
||||
@tool
|
||||
async def generate_document(prompt: str) -> str:
|
||||
"""Generate a Word, PPT, or Excel document based on the user's request.
|
||||
|
||||
Use this tool when the user asks you to create, generate, or write a
|
||||
document, presentation, spreadsheet, or report. The tool will produce
|
||||
a downloadable file link.
|
||||
|
||||
Args:
|
||||
prompt: A description of the document to generate, including its
|
||||
content requirements and desired format.
|
||||
"""
|
||||
output_type = _detect_doc_type(prompt)
|
||||
logger.info("Generating document type=%s for prompt: %s", output_type, prompt[:100])
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
resp = await client.post(
|
||||
settings.doc_agent_url,
|
||||
json={"prompt": prompt, "output_type": output_type},
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.doc_agent_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.error("Doc Creator API error: %s %s", exc.response.status_code, exc.response.text)
|
||||
return f"Document generation failed (HTTP {exc.response.status_code}). Please try again later."
|
||||
except Exception:
|
||||
logger.exception("Doc Creator Agent request failed")
|
||||
return "Document generation failed due to a network error. Please try again later."
|
||||
|
||||
title = data.get("title", "Document")
|
||||
file_url = data.get("file_url", "")
|
||||
|
||||
if not file_url:
|
||||
return "Document was generated but no download link was returned."
|
||||
|
||||
type_labels = {"ppt": "PPT", "table": "Excel", "word": "Word"}
|
||||
label = type_labels.get(output_type, "Document")
|
||||
return f"[{label}] {title}\nDownload: {file_url}"
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Knowledge base search tool — calls KB Agent (Azure AI Search).
|
||||
|
||||
Includes a single retry on ReadTimeout to handle Azure App Service cold
|
||||
starts (can take 20-30s). All exceptions are caught so the tool returns
|
||||
a user-friendly message instead of crashing the ReAct agent loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool
|
||||
async def kb_search(query: str) -> str:
|
||||
"""Search the internal knowledge base for documents related to a query.
|
||||
|
||||
Use this tool when the user asks about internal products, technical
|
||||
documentation, project plans, or anything that might be covered by
|
||||
the company knowledge base.
|
||||
|
||||
Args:
|
||||
query: The search query in natural language.
|
||||
"""
|
||||
url = f"{settings.kb_agent_url}{settings.kb_agent_search_path}"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"api-key": settings.kb_agent_api_key,
|
||||
}
|
||||
payload = {
|
||||
"query": query,
|
||||
"top": 5,
|
||||
"search_mode": "hybrid",
|
||||
}
|
||||
|
||||
for attempt in range(2):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=settings.kb_agent_search_timeout_sec) as client:
|
||||
resp = await client.post(url, json=payload, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
results = data.get("results", [])
|
||||
if not results:
|
||||
return "知识库中未找到相关内容。"
|
||||
|
||||
parts: list[str] = []
|
||||
for r in results:
|
||||
title = r.get("title", "Untitled")
|
||||
content = r.get("content", "")
|
||||
category = r.get("category", "")
|
||||
# Truncate very long content to keep context manageable
|
||||
if len(content) > 1500:
|
||||
content = content[:1500] + "..."
|
||||
header = f"[{title}]"
|
||||
if category:
|
||||
header += f" ({category})"
|
||||
parts.append(f"{header}\n{content}")
|
||||
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
except httpx.ReadTimeout:
|
||||
if attempt == 0:
|
||||
logger.warning("KB Agent read timeout (attempt 1), retrying after 2s...")
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
logger.error("KB Agent read timeout after retry")
|
||||
return "知识库检索超时,请稍后重试。"
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("KB Agent search failed: %s", exc, exc_info=True)
|
||||
return f"知识库检索出错:{exc}"
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Jina MCP tools via langchain-mcp-adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langchain_mcp_adapters.client import MultiServerMCPClient
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# Only load the 3 tools we need, avoid 19 tools filling the context
|
||||
JINA_MCP_TOOL_NAMES = {"search_web", "read_url", "sort_by_relevance"}
|
||||
JINA_MCP_URL = "https://mcp.jina.ai/v1"
|
||||
|
||||
|
||||
def create_jina_mcp_client() -> MultiServerMCPClient:
|
||||
return MultiServerMCPClient({
|
||||
"jina": {
|
||||
"url": JINA_MCP_URL,
|
||||
"transport": "streamable_http",
|
||||
"headers": {"Authorization": f"Bearer {settings.jina_api_key}"},
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
async def get_jina_mcp_tools(client: MultiServerMCPClient) -> list:
|
||||
"""Get filtered Jina MCP tools (search_web / read_url / sort_by_relevance)."""
|
||||
all_tools = await client.get_tools()
|
||||
return [t for t in all_tools if t.name in JINA_MCP_TOOL_NAMES]
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Sandboxed code execution tool using Daytona SDK.
|
||||
|
||||
Creates an ephemeral Daytona sandbox, runs user code, captures
|
||||
stdout/stderr, and destroys the sandbox on completion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool
|
||||
async def sandbox_run(code: str, language: str = "python") -> str:
|
||||
"""Execute code in a secure sandbox and return the output.
|
||||
|
||||
Use this tool when the user asks you to run, execute, or test code.
|
||||
The sandbox is isolated and ephemeral — it is destroyed after execution.
|
||||
|
||||
Supported languages: python, javascript, typescript, bash/shell.
|
||||
|
||||
Args:
|
||||
code: The source code to execute.
|
||||
language: Programming language (default: python).
|
||||
"""
|
||||
# Lazy import so the app starts even if daytona is not installed
|
||||
try:
|
||||
from daytona import Daytona, DaytonaConfig, DaytonaError
|
||||
except ImportError:
|
||||
return "Sandbox execution is not available: the daytona SDK is not installed."
|
||||
|
||||
logger.info("Sandbox run: language=%s, code length=%d", language, len(code))
|
||||
|
||||
config = DaytonaConfig(
|
||||
api_key=settings.daytona_api_key,
|
||||
api_url=settings.daytona_api_url,
|
||||
target="us",
|
||||
)
|
||||
daytona = Daytona(config)
|
||||
sandbox = None
|
||||
|
||||
try:
|
||||
sandbox = daytona.create()
|
||||
|
||||
if language in ("bash", "shell", "sh"):
|
||||
response = sandbox.process.exec(code, timeout=30)
|
||||
else:
|
||||
response = sandbox.process.code_run(code, timeout=30)
|
||||
|
||||
exit_code = getattr(response, "exit_code", None)
|
||||
result = getattr(response, "result", str(response))
|
||||
|
||||
# Truncate very long output
|
||||
if len(result) > 10000:
|
||||
result = result[:10000] + "\n... (output truncated)"
|
||||
|
||||
if exit_code and exit_code != 0:
|
||||
return f"[Exit code: {exit_code}]\n{result}"
|
||||
return result or "(no output)"
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Sandbox execution failed")
|
||||
return f"Sandbox execution failed: {exc}"
|
||||
finally:
|
||||
if sandbox is not None:
|
||||
try:
|
||||
daytona.remove(sandbox)
|
||||
except Exception:
|
||||
logger.warning("Failed to remove sandbox", exc_info=True)
|
||||
@@ -1,241 +0,0 @@
|
||||
"""External web search tool using Jina AI (Search + Reader + Rerank).
|
||||
|
||||
Strategy varies by model depth:
|
||||
|
||||
| model | top_results | timeout | Reader | Rerank |
|
||||
|-------|-------------|---------|-----------|-----------|
|
||||
| flash | 3 | 8s | skip | skip |
|
||||
| pro | 10 | 20s | concurrent| top 5 |
|
||||
|
||||
All results are cached in Redis with TTL=300s.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from app.cache.redis import get_cached_search, set_cached_search
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Jina API endpoints
|
||||
JINA_SEARCH_URL = "https://s.jina.ai/"
|
||||
JINA_READER_URL = "https://r.jina.ai/"
|
||||
JINA_RERANK_URL = "https://api.jina.ai/v1/rerank"
|
||||
JINA_RERANK_MODEL = "jina-reranker-v2-base-multilingual"
|
||||
|
||||
# Strategy presets per model
|
||||
SEARCH_STRATEGIES = {
|
||||
"flash": {
|
||||
"top": 3,
|
||||
"timeout": 8,
|
||||
"use_reader": False,
|
||||
"use_rerank": False,
|
||||
},
|
||||
"pro": {
|
||||
"top": 10,
|
||||
"timeout": 20,
|
||||
"use_reader": True,
|
||||
"use_rerank": True,
|
||||
"rerank_top": 5,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _jina_headers() -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {settings.jina_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
async def _jina_search(query: str, top: int, timeout: int) -> list[dict[str, Any]]:
|
||||
"""Call Jina Search API and return a list of result dicts."""
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(
|
||||
JINA_SEARCH_URL,
|
||||
headers=_jina_headers(),
|
||||
json={"q": query, "num": top},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
results = data.get("data", [])
|
||||
return results
|
||||
|
||||
|
||||
async def _jina_read(url: str, timeout: int) -> str:
|
||||
"""Call Jina Reader to extract full-text content from a URL."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.get(
|
||||
f"{JINA_READER_URL}{url}",
|
||||
headers=_jina_headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get("data", {}).get("content", "")
|
||||
except Exception:
|
||||
logger.warning("Jina Reader failed for %s", url, exc_info=True)
|
||||
return ""
|
||||
|
||||
|
||||
async def _jina_rerank(
|
||||
query: str, documents: list[str], top_n: int, timeout: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Call Jina Rerank API to reorder documents by relevance."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(
|
||||
JINA_RERANK_URL,
|
||||
headers=_jina_headers(),
|
||||
json={
|
||||
"model": JINA_RERANK_MODEL,
|
||||
"query": query,
|
||||
"documents": documents,
|
||||
"top_n": top_n,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get("results", [])
|
||||
except Exception:
|
||||
logger.warning("Jina Rerank failed", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
def _format_result(item: dict[str, Any], idx: int, content_override: str = "") -> str:
|
||||
"""Format a single search result for LLM consumption."""
|
||||
title = item.get("title", "Untitled")
|
||||
url = item.get("url", "")
|
||||
description = content_override or item.get("description", item.get("content", ""))
|
||||
# Truncate very long content
|
||||
if len(description) > 2000:
|
||||
description = description[:2000] + "..."
|
||||
return f"[{idx}] {title}\nURL: {url}\n{description}"
|
||||
|
||||
|
||||
async def _search_flash(query: str) -> str:
|
||||
"""Fast search: top 3 results, no Reader, no Rerank."""
|
||||
strategy = SEARCH_STRATEGIES["flash"]
|
||||
results = await _jina_search(query, top=strategy["top"], timeout=strategy["timeout"])
|
||||
|
||||
if not results:
|
||||
return "No search results found."
|
||||
|
||||
parts = [_format_result(r, i + 1) for i, r in enumerate(results)]
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
|
||||
async def _search_pro(query: str) -> str:
|
||||
"""Deep search: top 10 results, concurrent Reader, Rerank to top 5."""
|
||||
strategy = SEARCH_STRATEGIES["pro"]
|
||||
timeout = strategy["timeout"]
|
||||
|
||||
# Step 1: Search
|
||||
results = await _jina_search(query, top=strategy["top"], timeout=timeout)
|
||||
if not results:
|
||||
return "No search results found."
|
||||
|
||||
# Step 2: Concurrent Reader — fetch full text for all results
|
||||
read_tasks = [
|
||||
_jina_read(r.get("url", ""), timeout=timeout)
|
||||
for r in results
|
||||
if r.get("url")
|
||||
]
|
||||
full_texts = await asyncio.gather(*read_tasks, return_exceptions=True)
|
||||
|
||||
# Merge full text back into results
|
||||
url_idx = 0
|
||||
for r in results:
|
||||
if r.get("url"):
|
||||
text = full_texts[url_idx] if url_idx < len(full_texts) else ""
|
||||
if isinstance(text, str) and text:
|
||||
r["_full_text"] = text
|
||||
url_idx += 1
|
||||
|
||||
# Step 3: Rerank using description/full_text
|
||||
documents_for_rerank = []
|
||||
for r in results:
|
||||
doc_text = r.get("_full_text", "") or r.get("description", "") or r.get("content", "")
|
||||
# Keep rerank input manageable
|
||||
documents_for_rerank.append(doc_text[:3000] if doc_text else r.get("title", ""))
|
||||
|
||||
reranked = await _jina_rerank(
|
||||
query,
|
||||
documents_for_rerank,
|
||||
top_n=strategy["rerank_top"],
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# Build output in reranked order
|
||||
if reranked:
|
||||
parts = []
|
||||
for rank, rr in enumerate(reranked, 1):
|
||||
idx = rr.get("index", 0)
|
||||
if idx < len(results):
|
||||
r = results[idx]
|
||||
content = r.get("_full_text", "") or r.get("description", "")
|
||||
parts.append(_format_result(r, rank, content_override=content))
|
||||
return "\n\n---\n\n".join(parts)
|
||||
else:
|
||||
# Fallback: return first 5 without reranking
|
||||
parts = [
|
||||
_format_result(r, i + 1, content_override=r.get("_full_text", ""))
|
||||
for i, r in enumerate(results[:5])
|
||||
]
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
|
||||
# ── The LangChain tool exposed to the ReAct agent ──────────────────────
|
||||
|
||||
# Use contextvars to safely pass the model strategy per-request in an
|
||||
# async concurrent environment. Each asyncio Task (i.e. each SSE
|
||||
# request handler) gets its own copy, so concurrent requests never
|
||||
# overwrite each other's value.
|
||||
_current_model: contextvars.ContextVar[str] = contextvars.ContextVar(
|
||||
"search_model", default="flash"
|
||||
)
|
||||
|
||||
|
||||
def set_search_model(model: str) -> None:
|
||||
"""Set the search strategy model for the current request context."""
|
||||
_current_model.set(model)
|
||||
|
||||
|
||||
@tool
|
||||
async def web_search(query: str) -> str:
|
||||
"""Search the web for up-to-date information on any topic.
|
||||
|
||||
Use this tool when the user asks about current events, recent news,
|
||||
external technologies, public information, or anything not covered
|
||||
by the internal knowledge base.
|
||||
|
||||
Args:
|
||||
query: The search query in natural language.
|
||||
"""
|
||||
model = _current_model.get()
|
||||
|
||||
# Check Redis cache first
|
||||
cached = await get_cached_search(query, model)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Execute search based on model strategy
|
||||
if model == "pro":
|
||||
result = await _search_pro(query)
|
||||
else:
|
||||
result = await _search_flash(query)
|
||||
|
||||
# Cache the result
|
||||
await set_cached_search(query, model, result)
|
||||
|
||||
return result
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Google Serper search tool via LangChain integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langchain_community.utilities import GoogleSerperAPIWrapper
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
@tool
|
||||
async def serper_search(query: str) -> str:
|
||||
"""Search the web using Google Serper API. Returns Google search results including snippets, links, and answer boxes."""
|
||||
try:
|
||||
search = GoogleSerperAPIWrapper(serper_api_key=settings.serper_api_key)
|
||||
result = await search.arun(query)
|
||||
return result or "No results found."
|
||||
except Exception as e:
|
||||
return f"Serper search failed: {e}"
|
||||
@@ -1,117 +0,0 @@
|
||||
"""Ticket system tools — proxy to Gongdan API (read-only)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from langchain_core.tools import tool
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _gongdan_headers() -> dict[str, str]:
|
||||
return {"X-Api-Key": settings.gongdan_api_key}
|
||||
|
||||
|
||||
def _map_status(raw: str) -> str:
|
||||
"""Map Gongdan status values to frontend-friendly values."""
|
||||
mapping = {
|
||||
"OPEN": "pending",
|
||||
"ASSIGNED": "processing",
|
||||
"IN_PROGRESS": "processing",
|
||||
"PENDING_CUSTOMER": "processing",
|
||||
"RESOLVED": "resolved",
|
||||
"CLOSED": "resolved",
|
||||
}
|
||||
return mapping.get(raw, "pending")
|
||||
|
||||
|
||||
def _map_priority(raw: str) -> str:
|
||||
"""Map Gongdan priority to P0-P3."""
|
||||
mapping = {
|
||||
"URGENT": "P0",
|
||||
"PRIORITY": "P1",
|
||||
"NORMAL": "P2",
|
||||
"LOW": "P3",
|
||||
}
|
||||
return mapping.get(raw, "P2")
|
||||
|
||||
|
||||
@tool
|
||||
async def ticket_list(page: int = 1, page_size: int = 20) -> str:
|
||||
"""List tickets from the ticket system.
|
||||
|
||||
Use this tool when the user asks about tickets, work orders, issues,
|
||||
or wants to see a summary of current support requests.
|
||||
|
||||
Args:
|
||||
page: Page number (default 1).
|
||||
page_size: Number of tickets per page (default 20).
|
||||
"""
|
||||
url = f"{settings.gongdan_api_base}/api/tickets"
|
||||
params = {"page": page, "pageSize": page_size}
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(url, params=params, headers=_gongdan_headers())
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
tickets = data.get("tickets", [])
|
||||
if not tickets:
|
||||
return "No tickets found."
|
||||
|
||||
lines: list[str] = []
|
||||
for t in tickets:
|
||||
ticket_id = t.get("ticketNumber", t.get("id", "?"))
|
||||
title = t.get("description", "")[:80]
|
||||
status = _map_status(t.get("status", ""))
|
||||
priority = _map_priority(t.get("priority", ""))
|
||||
created = t.get("createdAt", "")[:10]
|
||||
customer = t.get("customer", {}).get("name", "Unknown")
|
||||
lines.append(
|
||||
f"- [{ticket_id}] {title} | status={status} priority={priority} "
|
||||
f"customer={customer} created={created}"
|
||||
)
|
||||
|
||||
return f"Found {len(tickets)} tickets:\n" + "\n".join(lines)
|
||||
|
||||
|
||||
@tool
|
||||
async def ticket_detail(ticket_id: str) -> str:
|
||||
"""Get detailed information about a specific ticket.
|
||||
|
||||
Use this tool when the user asks for details on a particular ticket
|
||||
or work order, providing its ID.
|
||||
|
||||
Args:
|
||||
ticket_id: The ticket UUID or ticket number.
|
||||
"""
|
||||
url = f"{settings.gongdan_api_base}/api/tickets/{ticket_id}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(url, headers=_gongdan_headers())
|
||||
resp.raise_for_status()
|
||||
t = resp.json()
|
||||
|
||||
ticket_number = t.get("ticketNumber", t.get("id", "?"))
|
||||
description = t.get("description", "N/A")
|
||||
status = _map_status(t.get("status", ""))
|
||||
priority = _map_priority(t.get("priority", ""))
|
||||
platform = t.get("platform", "N/A")
|
||||
model_used = t.get("modelUsed", "N/A")
|
||||
account = t.get("accountInfo", "N/A")
|
||||
request_example = t.get("requestExample", "")
|
||||
customer_name = t.get("customer", {}).get("name", "Unknown")
|
||||
engineer = t.get("assignedEngineer", {}).get("username", "Unassigned")
|
||||
created = t.get("createdAt", "")
|
||||
sla = t.get("slaDeadline", "")
|
||||
|
||||
return (
|
||||
f"Ticket: {ticket_number}\n"
|
||||
f"Status: {status} | Priority: {priority}\n"
|
||||
f"Platform: {platform} | Model: {model_used}\n"
|
||||
f"Customer: {customer_name} | Account: {account}\n"
|
||||
f"Engineer: {engineer}\n"
|
||||
f"Created: {created} | SLA: {sla}\n"
|
||||
f"Description: {description}\n"
|
||||
f"Request Example: {request_example}"
|
||||
)
|
||||
@@ -1,256 +0,0 @@
|
||||
# SOC Backend API Reference
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
http://localhost:8000 (dev)
|
||||
https://soc-backend.azurewebsites.net (production)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Health
|
||||
|
||||
### GET /health
|
||||
|
||||
Returns server status.
|
||||
|
||||
**Response** `200`
|
||||
```json
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chat
|
||||
|
||||
### POST /api/chat/stream
|
||||
|
||||
SSE streaming chat endpoint. Returns `text/event-stream`.
|
||||
|
||||
**Request Body**
|
||||
```json
|
||||
{
|
||||
"message": "your question",
|
||||
"conversation_id": "conv-abc123",
|
||||
"tools": ["search", "knowledge"],
|
||||
"model": "flash"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| message | string | yes | User message (min 1 char) |
|
||||
| conversation_id | string | yes | Conversation thread ID |
|
||||
| tools | string[] | no | Tool keys: `knowledge`, `tickets`, `search`, `document`, `sandbox` |
|
||||
| model | string | no | `flash` (default) or `pro` |
|
||||
|
||||
**SSE Events**
|
||||
```
|
||||
data: {"type": "token", "content": "Hello"}
|
||||
data: {"type": "tool_start", "tool": "web_search"}
|
||||
data: {"type": "tool_end", "tool": "web_search"}
|
||||
data: {"type": "done"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conversations
|
||||
|
||||
### GET /api/conversations
|
||||
|
||||
List all conversations.
|
||||
|
||||
**Response** `200` -- `ConversationOut[]`
|
||||
|
||||
### POST /api/conversations
|
||||
|
||||
Create a new conversation.
|
||||
|
||||
**Request Body**
|
||||
```json
|
||||
{"title": "My conversation"}
|
||||
```
|
||||
|
||||
### GET /api/conversations/{id}
|
||||
|
||||
Get conversation with messages.
|
||||
|
||||
**Response** `200` -- `ConversationDetail`
|
||||
|
||||
### PATCH /api/conversations/{id}
|
||||
|
||||
Update conversation title.
|
||||
|
||||
**Request Body**
|
||||
```json
|
||||
{"title": "Updated title"}
|
||||
```
|
||||
|
||||
### DELETE /api/conversations/{id}
|
||||
|
||||
Delete a conversation and all its messages.
|
||||
|
||||
**Response** `204`
|
||||
|
||||
---
|
||||
|
||||
## Tickets
|
||||
|
||||
### GET /api/tickets/summary
|
||||
|
||||
Aggregated ticket statistics.
|
||||
|
||||
**Response** `200`
|
||||
```json
|
||||
{
|
||||
"total": 42,
|
||||
"by_status": {"pending": 10, "processing": 20, "resolved": 12},
|
||||
"by_priority": {"P0": 2, "P1": 5, "P2": 25, "P3": 10}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/tickets
|
||||
|
||||
List tickets (proxied from Gongdan API).
|
||||
|
||||
**Query Parameters**
|
||||
|
||||
| Param | Type | Default |
|
||||
|-------|------|---------|
|
||||
| page | int | 1 |
|
||||
| page_size | int | 20 |
|
||||
|
||||
**Response** `200` -- `TicketData[]`
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "uuid",
|
||||
"ticketNumber": "TK-001",
|
||||
"title": "Issue description",
|
||||
"status": "pending",
|
||||
"priority": "P1",
|
||||
"createdAt": "2025-01-15T10:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### GET /api/tickets/{ticket_id}
|
||||
|
||||
Get a single ticket detail.
|
||||
|
||||
---
|
||||
|
||||
## Attachments
|
||||
|
||||
### POST /api/attachments/upload
|
||||
|
||||
Upload a file to Azure Blob Storage.
|
||||
|
||||
**Content-Type**: `multipart/form-data`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| data | file | yes | The file to upload (max 50 MB) |
|
||||
| conversation_id | string (query) | no | Link attachment to a conversation |
|
||||
| message_id | string (query) | no | Link attachment to a message |
|
||||
|
||||
**Request Example (curl)**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/attachments/upload \
|
||||
-F "data=@report.pdf" \
|
||||
-G -d "conversation_id=conv-abc123"
|
||||
```
|
||||
|
||||
**Response** `201`
|
||||
```json
|
||||
{
|
||||
"id": "a1b2c3d4-...",
|
||||
"filename": "report.pdf",
|
||||
"content_type": "application/pdf",
|
||||
"blob_url": "https://authdatablol.blob.core.windows.net/soc-files/attachments/conv-abc123/a1b2c3d4_report.pdf",
|
||||
"size_bytes": 1048576,
|
||||
"conversation_id": "conv-abc123",
|
||||
"message_id": null,
|
||||
"created_at": "2025-04-08T12:00:00+00:00"
|
||||
}
|
||||
```
|
||||
|
||||
**Error** `413` -- File exceeds 50 MB limit.
|
||||
|
||||
**Side Effect**: If the file type is parseable (PDF, images, CSV, DOCX, XLSX), a `parse_attachment` task is dispatched to Azure Service Bus for async processing.
|
||||
|
||||
### GET /api/attachments/{id}
|
||||
|
||||
Get attachment metadata.
|
||||
|
||||
**Response** `200` -- `AttachmentOut`
|
||||
```json
|
||||
{
|
||||
"id": "a1b2c3d4-...",
|
||||
"filename": "report.pdf",
|
||||
"content_type": "application/pdf",
|
||||
"blob_url": "https://...",
|
||||
"size_bytes": 1048576,
|
||||
"conversation_id": "conv-abc123",
|
||||
"message_id": null,
|
||||
"created_at": "2025-04-08T12:00:00+00:00"
|
||||
}
|
||||
```
|
||||
|
||||
**Error** `404` -- Attachment not found.
|
||||
|
||||
### GET /api/attachments/{id}/download
|
||||
|
||||
Redirect to a time-limited SAS URL (valid for 1 hour).
|
||||
|
||||
**Response** `302` with `Location` header pointing to the SAS URL.
|
||||
|
||||
**Error** `404` -- Attachment not found.
|
||||
|
||||
**Request Example**
|
||||
```bash
|
||||
# Follow redirect to download
|
||||
curl -L http://localhost:8000/api/attachments/a1b2c3d4/download -o report.pdf
|
||||
```
|
||||
|
||||
### DELETE /api/attachments/{id}
|
||||
|
||||
Delete an attachment (removes both the blob from Azure Storage and the database record).
|
||||
|
||||
**Response** `204` -- No content.
|
||||
|
||||
**Error** `404` -- Attachment not found.
|
||||
|
||||
**Request Example**
|
||||
```bash
|
||||
curl -X DELETE http://localhost:8000/api/attachments/a1b2c3d4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service Bus Task Message Format
|
||||
|
||||
When an attachment with a parseable content type is uploaded, a task message is published to the `soc-tasks` Service Bus queue:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "hex-uuid",
|
||||
"task_type": "parse_attachment",
|
||||
"conversation_id": "conv-abc123",
|
||||
"payload": {
|
||||
"attachment_id": "a1b2c3d4-...",
|
||||
"content_type": "application/pdf",
|
||||
"blob_name": "attachments/conv-abc123/a1b2c3d4_report.pdf",
|
||||
"filename": "report.pdf"
|
||||
},
|
||||
"created_at": "2025-04-08T12:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
Parseable content types:
|
||||
- `application/pdf`
|
||||
- `image/png`, `image/jpeg`, `image/webp`, `image/gif`
|
||||
- `text/csv`
|
||||
- `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` (XLSX)
|
||||
- `application/vnd.openxmlformats-officedocument.wordprocessingml.document` (DOCX)
|
||||
@@ -1,23 +0,0 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0", "wheel"]
|
||||
build-backend = "setuptools.backends._legacy:_Backend"
|
||||
|
||||
[project]
|
||||
name = "soc-backend"
|
||||
version = "0.1.0"
|
||||
description = "SOC Chat Backend - LangGraph + Litestar"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"litestar[standard]>=2.15.0",
|
||||
"uvicorn[standard]>=0.34.0",
|
||||
"langchain>=0.3.0",
|
||||
"langchain-openai>=0.3.0",
|
||||
"langgraph>=0.3.0",
|
||||
"langgraph-checkpoint>=2.0.0",
|
||||
"pydantic-settings>=2.7.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"httpx>=0.28.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["ruff", "pytest", "pytest-asyncio"]
|
||||
@@ -1,20 +0,0 @@
|
||||
litestar[standard]>=2.15.0
|
||||
uvicorn[standard]>=0.34.0
|
||||
langchain>=0.3.0
|
||||
langchain-openai>=0.3.0
|
||||
langgraph>=0.3.0
|
||||
langgraph-checkpoint>=2.0.0
|
||||
langgraph-checkpoint-postgres>=2.0.0
|
||||
pydantic-settings>=2.7.0
|
||||
python-dotenv>=1.0.0
|
||||
httpx>=0.28.0
|
||||
asyncpg>=0.30.0
|
||||
sqlalchemy[asyncio]>=2.0.0
|
||||
psycopg[binary]>=3.1.0
|
||||
gunicorn>=22.0.0
|
||||
redis[hiredis]>=5.0.0
|
||||
azure-storage-blob>=12.20.0
|
||||
azure-servicebus>=7.12.0
|
||||
daytona>=0.6.0
|
||||
langchain-mcp-adapters>=0.1.0
|
||||
langchain-community>=0.3.0
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Set PYTHONPATH to include bundled packages
|
||||
export PYTHONPATH="/home/site/wwwroot/.python_packages/lib/site-packages:/home/site/wwwroot/antenv/lib/python3.12/site-packages:$PYTHONPATH"
|
||||
|
||||
# Start gunicorn with uvicorn workers
|
||||
gunicorn -w 2 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --timeout 120 app.main:app
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"cursor-project-memory": {
|
||||
"baseUrl": "http://172.188.219.174:3101/mcp"
|
||||
}
|
||||
},
|
||||
"imports": []
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,103 +0,0 @@
|
||||
# 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` 事件。
|
||||
@@ -1,286 +0,0 @@
|
||||
# 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 事件
|
||||
@@ -1,480 +0,0 @@
|
||||
# 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 项目具体改造点(按文件路径拆解)
|
||||
@@ -1,417 +0,0 @@
|
||||
# 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
@@ -1,918 +0,0 @@
|
||||
# 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 后端。**
|
||||
|
||||
而且整个过程中:
|
||||
**前端交互不改,只替换数据来源和后端能力。**
|
||||
@@ -1,324 +0,0 @@
|
||||
# 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"],...}'
|
||||
# 期望:回复包含文档下载链接
|
||||
```
|
||||
@@ -1,329 +0,0 @@
|
||||
# 浏览器全功能测试计划(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. 汇总结果
|
||||
@@ -1,668 +0,0 @@
|
||||
# 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 准确
|
||||
@@ -1,152 +0,0 @@
|
||||
# 外部服务接入配置
|
||||
|
||||
> **使用说明**:此文档用于记录外部服务的接入方式、环境变量和调用示例,便于开发、联调与排障。
|
||||
>
|
||||
> 当前服务按“代码已支持 + 部署环境变量由 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=
|
||||
```
|
||||
---
|
||||
@@ -1,918 +0,0 @@
|
||||
# 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 后端。**
|
||||
|
||||
而且整个过程中:
|
||||
**前端交互不改,只替换数据来源和后端能力。**
|
||||
@@ -1,61 +0,0 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
@@ -1,395 +0,0 @@
|
||||
# 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 组件实例快照”。
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
# 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 组件实例快照”。
|
||||
|
||||
@@ -1,715 +0,0 @@
|
||||
# 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` 的交互方向,而不是只做一个右侧栏凑数。
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# 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
@@ -1,474 +0,0 @@
|
||||
我已经完整阅读了后端的 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
|
||||
@@ -1,30 +0,0 @@
|
||||
# Gemini chat clone
|
||||
|
||||
*Automatically synced with your [v0.app](https://v0.app) deployments*
|
||||
|
||||
[](https://vercel.com/fastheis-projects/v0-so-c-chat-clone)
|
||||
[](https://v0.app/chat/lx6pc6oofZh)
|
||||
|
||||
## Overview
|
||||
|
||||
This repository will stay in sync with your deployed chats on [v0.app](https://v0.app).
|
||||
Any changes you make to your deployed app will be automatically pushed to this repository from [v0.app](https://v0.app).
|
||||
|
||||
## Deployment
|
||||
|
||||
Your project is live at:
|
||||
|
||||
**[https://vercel.com/fastheis-projects/v0-so-c-chat-clone](https://vercel.com/fastheis-projects/v0-so-c-chat-clone)**
|
||||
|
||||
## Build your app
|
||||
|
||||
Continue building your app on:
|
||||
|
||||
**[https://v0.app/chat/lx6pc6oofZh](https://v0.app/chat/lx6pc6oofZh)**
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Create and modify your project using [v0.app](https://v0.app)
|
||||
2. Deploy your chats from the v0 interface
|
||||
3. Changes are automatically pushed to this repository
|
||||
4. Vercel deploys the latest version from this repository
|
||||
@@ -1,175 +0,0 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* ── Light theme (default) ─────────────────────────────────────────────── */
|
||||
:root {
|
||||
--gem-bg: #ffffff;
|
||||
--gem-surface: #f0f4f9;
|
||||
--gem-surface-hover: #e8ecf1;
|
||||
--gem-surface-2: #f5f5f5;
|
||||
--gem-surface-3: #eaeaea;
|
||||
--gem-border: #d9dce0;
|
||||
--gem-border-hover: #c0c4c8;
|
||||
--gem-text: #1f1f1f;
|
||||
--gem-text-secondary: #444746;
|
||||
--gem-text-muted: #5f6368;
|
||||
--gem-text-placeholder: #9aa0a6;
|
||||
--gem-blue: #4285f4;
|
||||
--gem-blue-hover: #3b78e7;
|
||||
--gem-purple: #a855f7;
|
||||
--gem-mid-gradient: #7c55f0;
|
||||
--gem-active-bg: #d3e3fd;
|
||||
--gem-ext-connected-bg: #e6f4e6;
|
||||
--gem-ext-connected-border: rgba(0, 128, 0, 0.2);
|
||||
--gem-scrollbar-thumb: #c0c4c8;
|
||||
--gem-scrollbar-thumb-hover: #a0a4a8;
|
||||
|
||||
/* shadcn vars — keep for UI lib compat */
|
||||
--background: var(--gem-bg);
|
||||
--foreground: var(--gem-text);
|
||||
--card: var(--gem-surface);
|
||||
--card-foreground: var(--gem-text);
|
||||
--popover: var(--gem-surface-2);
|
||||
--popover-foreground: var(--gem-text);
|
||||
--primary: #4285f4;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: var(--gem-surface-2);
|
||||
--secondary-foreground: var(--gem-text);
|
||||
--muted: var(--gem-surface-2);
|
||||
--muted-foreground: var(--gem-text-muted);
|
||||
--accent: var(--gem-surface-2);
|
||||
--accent-foreground: var(--gem-text);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: var(--gem-border);
|
||||
--input: var(--gem-surface);
|
||||
--ring: #4285f4;
|
||||
--radius: 0.75rem;
|
||||
--sidebar: var(--gem-surface);
|
||||
--sidebar-foreground: var(--gem-text);
|
||||
--sidebar-primary: #4285f4;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: var(--gem-surface-2);
|
||||
--sidebar-accent-foreground: var(--gem-text);
|
||||
--sidebar-border: var(--gem-border);
|
||||
--sidebar-ring: #4285f4;
|
||||
}
|
||||
|
||||
/* ── Dark theme ────────────────────────────────────────────────────────── */
|
||||
.dark {
|
||||
--gem-bg: #131314;
|
||||
--gem-surface: #1e1e1e;
|
||||
--gem-surface-hover: #252526;
|
||||
--gem-surface-2: #2a2a2a;
|
||||
--gem-surface-3: #333333;
|
||||
--gem-border: #3a3a3a;
|
||||
--gem-border-hover: #4a4a4a;
|
||||
--gem-text: #e3e3e3;
|
||||
--gem-text-secondary: #c4c7c5;
|
||||
--gem-text-muted: #9aa0a6;
|
||||
--gem-text-placeholder: #5f6368;
|
||||
--gem-blue: #4285f4;
|
||||
--gem-blue-hover: #3b78e7;
|
||||
--gem-purple: #a855f7;
|
||||
--gem-mid-gradient: #7c55f0;
|
||||
--gem-active-bg: #1a2a40;
|
||||
--gem-ext-connected-bg: #1a2a1a;
|
||||
--gem-ext-connected-border: rgba(0, 128, 0, 0.3);
|
||||
--gem-scrollbar-thumb: #3a3a3a;
|
||||
--gem-scrollbar-thumb-hover: #4a4a4a;
|
||||
|
||||
--background: var(--gem-bg);
|
||||
--foreground: var(--gem-text);
|
||||
--card: var(--gem-surface);
|
||||
--card-foreground: var(--gem-text);
|
||||
--popover: var(--gem-surface-2);
|
||||
--popover-foreground: var(--gem-text);
|
||||
--primary: #4285f4;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: var(--gem-surface-2);
|
||||
--secondary-foreground: var(--gem-text);
|
||||
--muted: var(--gem-surface-2);
|
||||
--muted-foreground: var(--gem-text-muted);
|
||||
--accent: var(--gem-surface-2);
|
||||
--accent-foreground: var(--gem-text);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: var(--gem-border);
|
||||
--input: var(--gem-surface);
|
||||
--ring: #4285f4;
|
||||
--radius: 0.75rem;
|
||||
--sidebar: var(--gem-surface);
|
||||
--sidebar-foreground: var(--gem-text);
|
||||
--sidebar-primary: #4285f4;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: var(--gem-surface-2);
|
||||
--sidebar-accent-foreground: var(--gem-text);
|
||||
--sidebar-border: var(--gem-border);
|
||||
--sidebar-ring: #4285f4;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: 'Google Sans', 'Roboto', 'Geist', sans-serif;
|
||||
--font-mono: 'Geist Mono', 'Roboto Mono', monospace;
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-gem-blue: var(--gem-blue);
|
||||
--color-gem-purple: var(--gem-purple);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--gem-scrollbar-thumb) transparent;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
overflow: hidden;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--gem-scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--gem-scrollbar-thumb-hover);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Geist, Geist_Mono } from 'next/font/google'
|
||||
import { Analytics } from '@vercel/analytics/next'
|
||||
import { ThemeProvider } from '@/components/theme-provider'
|
||||
import './globals.css'
|
||||
|
||||
const _geist = Geist({ subsets: ["latin"] });
|
||||
const _geistMono = Geist_Mono({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '运营大脑',
|
||||
description: '运营大脑 - 你的智能运营助手',
|
||||
generator: 'v0.app',
|
||||
icons: {
|
||||
icon: [
|
||||
{
|
||||
url: '/icon-light-32x32.png',
|
||||
media: '(prefers-color-scheme: light)',
|
||||
},
|
||||
{
|
||||
url: '/icon-dark-32x32.png',
|
||||
media: '(prefers-color-scheme: dark)',
|
||||
},
|
||||
{
|
||||
url: '/icon.svg',
|
||||
type: 'image/svg+xml',
|
||||
},
|
||||
],
|
||||
apple: '/apple-icon.png',
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<body className="font-sans antialiased">
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} storageKey="gem-theme">
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
{process.env.NODE_ENV === 'production' && <Analytics />}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { GeminiChat } from "@/components/gemini/GeminiChat";
|
||||
|
||||
export default function Page() {
|
||||
return <GeminiChat />;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
X,
|
||||
Ticket,
|
||||
ShoppingCart,
|
||||
Cloud,
|
||||
Check,
|
||||
Loader2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
interface Extension {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ElementType;
|
||||
connected: boolean;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
interface ExtensionsPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
extensions: Extension[];
|
||||
onUpdateExtension: (id: string, apiKey: string, connected: boolean) => void;
|
||||
}
|
||||
|
||||
export function ExtensionsPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
extensions,
|
||||
onUpdateExtension,
|
||||
}: ExtensionsPanelProps) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [tempKey, setTempKey] = useState("");
|
||||
const [showKey, setShowKey] = useState<Record<string, boolean>>({});
|
||||
const [connecting, setConnecting] = useState<string | null>(null);
|
||||
|
||||
const handleConnect = async (ext: Extension) => {
|
||||
if (!tempKey.trim()) return;
|
||||
|
||||
setConnecting(ext.id);
|
||||
// 模拟API验证
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
|
||||
onUpdateExtension(ext.id, tempKey, true);
|
||||
setConnecting(null);
|
||||
setEditingId(null);
|
||||
setTempKey("");
|
||||
};
|
||||
|
||||
const handleDisconnect = (ext: Extension) => {
|
||||
onUpdateExtension(ext.id, "", false);
|
||||
};
|
||||
|
||||
const handleEdit = (ext: Extension) => {
|
||||
setEditingId(ext.id);
|
||||
setTempKey(ext.apiKey);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditingId(null);
|
||||
setTempKey("");
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div className="relative w-full max-w-lg bg-[var(--gem-surface)] border border-[var(--gem-border)] rounded-2xl shadow-2xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--gem-border)]">
|
||||
<h2 className="text-lg font-semibold text-[var(--gem-text)]">扩展程序</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-full hover:bg-[var(--gem-surface-2)] text-[var(--gem-text-muted)] hover:text-[var(--gem-text)] transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-3 max-h-[60vh] overflow-y-auto">
|
||||
{extensions.map((ext) => {
|
||||
const Icon = ext.icon;
|
||||
const isEditing = editingId === ext.id;
|
||||
const isConnecting = connecting === ext.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ext.id}
|
||||
className={cn(
|
||||
"rounded-xl border transition-all duration-200",
|
||||
ext.connected
|
||||
? "bg-[var(--gem-ext-connected-bg)] border-green-800/50"
|
||||
: "bg-[var(--gem-surface-2)] border-[var(--gem-border)]"
|
||||
)}
|
||||
>
|
||||
{/* Extension header */}
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
<div
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-lg flex items-center justify-center",
|
||||
ext.connected ? "bg-green-900/50" : "bg-[var(--gem-border)]"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
size={20}
|
||||
className={ext.connected ? "text-green-400" : "text-[var(--gem-text-muted)]"}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-[var(--gem-text)]">{ext.name}</h3>
|
||||
{ext.connected && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-400">
|
||||
<Check size={12} />
|
||||
已连接
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--gem-text-muted)] truncate">
|
||||
{ext.description}
|
||||
</p>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<button
|
||||
onClick={() =>
|
||||
ext.connected ? handleDisconnect(ext) : handleEdit(ext)
|
||||
}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-medium rounded-lg transition-colors",
|
||||
ext.connected
|
||||
? "text-red-400 hover:bg-red-900/30"
|
||||
: "text-[var(--gem-blue)] hover:bg-[var(--gem-blue)]/10"
|
||||
)}
|
||||
>
|
||||
{ext.connected ? "断开" : "连接"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* API Key input */}
|
||||
{isEditing && (
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-[var(--gem-text-muted)] flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
API Key(密钥将安全存储)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showKey[ext.id] ? "text" : "password"}
|
||||
value={tempKey}
|
||||
onChange={(e) => setTempKey(e.target.value)}
|
||||
placeholder="请输入 API Key"
|
||||
className="w-full px-3 py-2 pr-10 bg-[var(--gem-surface)] border border-[var(--gem-border)] rounded-lg text-[var(--gem-text)] placeholder:text-[var(--gem-text-placeholder)] focus:outline-none focus:border-[var(--gem-blue)] text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setShowKey((prev) => ({
|
||||
...prev,
|
||||
[ext.id]: !prev[ext.id],
|
||||
}))
|
||||
}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-[var(--gem-text-muted)] hover:text-[var(--gem-text)]"
|
||||
>
|
||||
{showKey[ext.id] ? (
|
||||
<EyeOff size={16} />
|
||||
) : (
|
||||
<Eye size={16} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex-1 px-3 py-2 text-sm text-[var(--gem-text-muted)] hover:text-[var(--gem-text)] hover:bg-[var(--gem-border)] rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleConnect(ext)}
|
||||
disabled={!tempKey.trim() || isConnecting}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 text-sm font-medium rounded-lg transition-colors flex items-center justify-center gap-2",
|
||||
tempKey.trim() && !isConnecting
|
||||
? "bg-[var(--gem-blue)] text-white hover:bg-[var(--gem-blue-hover)]"
|
||||
: "bg-[var(--gem-border)] text-[var(--gem-text-placeholder)] cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
验证中...
|
||||
</>
|
||||
) : (
|
||||
"连接"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connected info */}
|
||||
{ext.connected && !isEditing && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--gem-text-muted)]">
|
||||
<span>API Key:</span>
|
||||
<code className="px-2 py-0.5 bg-[var(--gem-surface-2)] rounded text-[var(--gem-text-muted)]">
|
||||
{ext.apiKey.slice(0, 8)}****{ext.apiKey.slice(-4)}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => handleEdit(ext)}
|
||||
className="text-[var(--gem-blue)] hover:underline ml-auto"
|
||||
>
|
||||
修改
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-3 border-t border-[var(--gem-border)] bg-[var(--gem-bg)]">
|
||||
<p className="text-xs text-[var(--gem-text-placeholder)] text-center">
|
||||
连接扩展后,可在对话中查看相关系统数据摘要
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 工单摘要组件 - 显示在对话区域
|
||||
interface TicketSummaryProps {
|
||||
summary: {
|
||||
total: number;
|
||||
by_status: { pending: number; processing: number; resolved: number };
|
||||
by_priority: { P0: number; P1: number; P2: number; P3: number };
|
||||
};
|
||||
}
|
||||
|
||||
export function TicketSummary({ summary }: TicketSummaryProps) {
|
||||
const statusEntries: { key: keyof typeof summary.by_status; label: string; color: string }[] = [
|
||||
{ key: "pending", label: "待处理", color: "text-yellow-400 bg-yellow-900/30" },
|
||||
{ key: "processing", label: "处理中", color: "text-blue-400 bg-blue-900/30" },
|
||||
{ key: "resolved", label: "已解决", color: "text-green-400 bg-green-900/30" },
|
||||
];
|
||||
|
||||
const priorityEntries: { key: keyof typeof summary.by_priority; color: string }[] = [
|
||||
{ key: "P0", color: "text-red-400" },
|
||||
{ key: "P1", color: "text-orange-400" },
|
||||
{ key: "P2", color: "text-yellow-400" },
|
||||
{ key: "P3", color: "text-[var(--gem-text-muted)]" },
|
||||
];
|
||||
|
||||
if (summary.total === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 mb-4">
|
||||
<div className="bg-[var(--gem-surface)] border border-[var(--gem-border)] rounded-xl overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-[var(--gem-border)] bg-[var(--gem-surface-hover)]">
|
||||
<Ticket size={16} className="text-[var(--gem-blue)]" />
|
||||
<span className="text-sm font-medium text-[var(--gem-text)]">工单摘要</span>
|
||||
<span className="text-xs text-[var(--gem-text-muted)]">({summary.total} 条待关注)</span>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--gem-surface-2)]">
|
||||
{statusEntries.map(({ key, label, color }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-[var(--gem-surface-2)] transition-colors cursor-pointer"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded-full",
|
||||
color
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="flex-1 text-sm text-[var(--gem-text)]">
|
||||
{summary.by_status[key]} 条
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{priorityEntries.map(({ key, color }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-[var(--gem-surface-2)] transition-colors cursor-pointer"
|
||||
>
|
||||
<span className={cn("text-xs font-mono", color)}>
|
||||
{key}
|
||||
</span>
|
||||
<span className="flex-1 text-sm text-[var(--gem-text)]">
|
||||
{summary.by_priority[key]} 条
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,883 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { GeminiSidebar } from "./GeminiSidebar";
|
||||
import { GeminiTopbar } from "./GeminiTopbar";
|
||||
import { GeminiWelcome } from "./GeminiWelcome";
|
||||
import { GeminiMessage, Message } from "./GeminiMessage";
|
||||
import { GeminiInput } from "./GeminiInput";
|
||||
import { GeminiTypingIndicator } from "./GeminiTypingIndicator";
|
||||
import { ExtensionsPanel, TicketSummary } from "./ExtensionsPanel";
|
||||
import { Ticket, ShoppingCart, Cloud } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
fetchConversations,
|
||||
fetchConversation,
|
||||
fetchTicketSummary,
|
||||
streamChat,
|
||||
deleteConversation,
|
||||
type TicketSummaryData,
|
||||
type AttachmentData,
|
||||
type TraceItem,
|
||||
type WorkspaceSession,
|
||||
type ActivityNode,
|
||||
type WorkspaceCard,
|
||||
} from "@/lib/api";
|
||||
import { AgentWorkspace } from "@/components/workspace/AgentWorkspace";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
interface Extension {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ElementType;
|
||||
connected: boolean;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
const INITIAL_EXTENSIONS: Extension[] = [
|
||||
{
|
||||
id: "ticket",
|
||||
name: "工单系统",
|
||||
description: "连接工单系统,查看和管理技术支持工单",
|
||||
icon: Ticket,
|
||||
connected: false,
|
||||
apiKey: "",
|
||||
},
|
||||
{
|
||||
id: "sales",
|
||||
name: "销售系统",
|
||||
description: "连接销售 CRM,获取客户和订单数据",
|
||||
icon: ShoppingCart,
|
||||
connected: false,
|
||||
apiKey: "",
|
||||
},
|
||||
{
|
||||
id: "cloud",
|
||||
name: "云管系统",
|
||||
description: "连接云管平台,监控资源使用情况",
|
||||
icon: Cloud,
|
||||
connected: false,
|
||||
apiKey: "",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Trace helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
callId: string | undefined,
|
||||
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;
|
||||
const items = [...(m.traceItems ?? [])];
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
// Prefer precise callId match
|
||||
if (callId && items[i].callId === callId) {
|
||||
items[i] = { ...items[i], ...updates };
|
||||
break;
|
||||
}
|
||||
// Fallback: last running entry for same tool name
|
||||
if (!callId && items[i].tool === tool && items[i].itemStatus === "running") {
|
||||
items[i] = { ...items[i], ...updates };
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { ...m, traceItems: items };
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ── Workspace helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function initWorkspaceSession(
|
||||
aiMsgId: string,
|
||||
convId: string,
|
||||
title: string,
|
||||
setWS: React.Dispatch<React.SetStateAction<Map<string, WorkspaceSession>>>
|
||||
) {
|
||||
setWS(prev => {
|
||||
if (prev.has(aiMsgId)) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(aiMsgId, {
|
||||
id: aiMsgId, conversationId: convId, messageId: aiMsgId,
|
||||
title: title.slice(0, 40), status: "running", stageLabel: "正在分析...",
|
||||
timeline: [], cards: [], startedAt: Date.now(),
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function addWsActivityNode(
|
||||
aiMsgId: string,
|
||||
node: ActivityNode,
|
||||
setWS: React.Dispatch<React.SetStateAction<Map<string, WorkspaceSession>>>
|
||||
) {
|
||||
setWS(prev => {
|
||||
const s = prev.get(aiMsgId);
|
||||
if (!s) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(aiMsgId, { ...s, timeline: [...s.timeline, node] });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function updateWsActivityNode(
|
||||
aiMsgId: string,
|
||||
callId: string | undefined,
|
||||
tool: string,
|
||||
updates: Partial<ActivityNode>,
|
||||
setWS: React.Dispatch<React.SetStateAction<Map<string, WorkspaceSession>>>
|
||||
) {
|
||||
setWS(prev => {
|
||||
const s = prev.get(aiMsgId);
|
||||
if (!s) return prev;
|
||||
const timeline = s.timeline.map(n => {
|
||||
if (callId && n.callId === callId) return { ...n, ...updates };
|
||||
if (!callId && n.tool === tool && n.nodeStatus === "running") return { ...n, ...updates };
|
||||
return n;
|
||||
});
|
||||
const next = new Map(prev);
|
||||
next.set(aiMsgId, { ...s, timeline });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function addWsCard(
|
||||
aiMsgId: string,
|
||||
card: WorkspaceCard,
|
||||
setWS: React.Dispatch<React.SetStateAction<Map<string, WorkspaceSession>>>
|
||||
) {
|
||||
setWS(prev => {
|
||||
const s = prev.get(aiMsgId);
|
||||
if (!s) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(aiMsgId, { ...s, cards: [...s.cards, card] });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function mergeWsCard(
|
||||
aiMsgId: string,
|
||||
cardId: string,
|
||||
props: Record<string, unknown>,
|
||||
setWS: React.Dispatch<React.SetStateAction<Map<string, WorkspaceSession>>>
|
||||
) {
|
||||
setWS(prev => {
|
||||
const s = prev.get(aiMsgId);
|
||||
if (!s) return prev;
|
||||
const cards = s.cards.map(c => c.id === cardId ? { ...c, props: { ...c.props, ...props } } : c);
|
||||
const next = new Map(prev);
|
||||
next.set(aiMsgId, { ...s, cards });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function completeWsSession(
|
||||
aiMsgId: string,
|
||||
setWS: React.Dispatch<React.SetStateAction<Map<string, WorkspaceSession>>>
|
||||
) {
|
||||
setWS(prev => {
|
||||
const s = prev.get(aiMsgId);
|
||||
if (!s) return prev;
|
||||
const timeline = s.timeline.map(n => n.nodeStatus === "running" ? { ...n, nodeStatus: "success" as const } : n);
|
||||
const doneNode: ActivityNode = { id: `done-${Date.now()}`, type: "done", label: "已完成", nodeStatus: "success", ts: Date.now() };
|
||||
const next = new Map(prev);
|
||||
next.set(aiMsgId, { ...s, status: "completed", stageLabel: "已完成", timeline: [...timeline, doneNode], finishedAt: Date.now() });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function errorWsSession(
|
||||
aiMsgId: string,
|
||||
setWS: React.Dispatch<React.SetStateAction<Map<string, WorkspaceSession>>>
|
||||
) {
|
||||
setWS(prev => {
|
||||
const s = prev.get(aiMsgId);
|
||||
if (!s) return prev;
|
||||
const timeline = [
|
||||
...s.timeline.map(n => n.nodeStatus === "running" ? { ...n, nodeStatus: "error" as const } : n),
|
||||
{ id: `error-${Date.now()}`, type: "error" as const, label: "请求失败", nodeStatus: "error" as const, ts: Date.now() },
|
||||
];
|
||||
const next = new Map(prev);
|
||||
next.set(aiMsgId, { ...s, status: "error", stageLabel: "请求失败", timeline });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function upsertWsStatusNode(
|
||||
aiMsgId: string,
|
||||
label: string,
|
||||
setWS: React.Dispatch<React.SetStateAction<Map<string, WorkspaceSession>>>
|
||||
) {
|
||||
setWS(prev => {
|
||||
const s = prev.get(aiMsgId);
|
||||
if (!s) return prev;
|
||||
const existingIdx = s.timeline.findIndex(n => n.type === "status");
|
||||
const node: ActivityNode = {
|
||||
id: existingIdx >= 0 ? s.timeline[existingIdx].id : `status-${Date.now()}`,
|
||||
type: "status", label, nodeStatus: "info", ts: Date.now(),
|
||||
};
|
||||
const timeline = existingIdx >= 0
|
||||
? s.timeline.map((n, i) => i === existingIdx ? node : n)
|
||||
: [...s.timeline, node];
|
||||
const next = new Map(prev);
|
||||
next.set(aiMsgId, { ...s, timeline, stageLabel: label });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function saveWsToStorage(session: WorkspaceSession) {
|
||||
try { localStorage.setItem(`ws:${session.messageId}`, JSON.stringify(session)); } catch {}
|
||||
}
|
||||
|
||||
function loadWsFromStorage(messageId: string): WorkspaceSession | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(`ws:${messageId}`);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ── Main Component ────────────────────────────────────────────────────────────
|
||||
export function GeminiChat() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [activeConvId, setActiveConvId] = useState<string | null>(null);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [extensionsPanelOpen, setExtensionsPanelOpen] = useState(false);
|
||||
const [extensions, setExtensions] = useState<Extension[]>(INITIAL_EXTENSIONS);
|
||||
const [ticketSummary, setTicketSummary] = useState<TicketSummaryData | null>(null);
|
||||
const [activeTools, setActiveTools] = useState<Set<string>>(new Set());
|
||||
const [selectedModel, setSelectedModel] = useState<"flash" | "auto" | "pro">("auto");
|
||||
const [workspaceSessions, setWorkspaceSessions] = useState<Map<string, WorkspaceSession>>(new Map());
|
||||
const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Lazy-load workspace session from localStorage when activeWorkspaceId changes
|
||||
const activeWorkspace = (() => {
|
||||
if (!activeWorkspaceId) return null;
|
||||
if (workspaceSessions.has(activeWorkspaceId)) return workspaceSessions.get(activeWorkspaceId) ?? null;
|
||||
return null;
|
||||
})();
|
||||
|
||||
// Sync completed/error sessions to localStorage
|
||||
useEffect(() => {
|
||||
workspaceSessions.forEach((session) => {
|
||||
if (session.status === "completed" || session.status === "error") {
|
||||
saveWsToStorage(session);
|
||||
}
|
||||
});
|
||||
}, [workspaceSessions]);
|
||||
|
||||
// Lazy-load from localStorage when activeWorkspaceId changes and not in memory
|
||||
useEffect(() => {
|
||||
if (!activeWorkspaceId) return;
|
||||
if (workspaceSessions.has(activeWorkspaceId)) return;
|
||||
const saved = loadWsFromStorage(activeWorkspaceId);
|
||||
if (saved) {
|
||||
setWorkspaceSessions(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(activeWorkspaceId, saved);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [activeWorkspaceId, workspaceSessions]);
|
||||
|
||||
const activeConversation = conversations.find((c) => c.id === activeConvId) ?? null;
|
||||
const messages = activeConversation?.messages ?? [];
|
||||
|
||||
// Map UI model to backend model ("auto" -> "flash")
|
||||
const apiModel: "flash" | "pro" = selectedModel === "pro" ? "pro" : "flash";
|
||||
|
||||
// 检查工单系统是否已连接
|
||||
const ticketSystemConnected = extensions.find((e) => e.id === "ticket")?.connected ?? false;
|
||||
|
||||
// Load conversations on mount
|
||||
useEffect(() => {
|
||||
fetchConversations()
|
||||
.then((list) => {
|
||||
setConversations(
|
||||
list.map((c) => ({ id: c.id, title: c.title, messages: [] }))
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// silently fail — user sees empty sidebar
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load ticket summary when ticket extension connects
|
||||
useEffect(() => {
|
||||
if (ticketSystemConnected) {
|
||||
fetchTicketSummary()
|
||||
.then(setTicketSummary)
|
||||
.catch(() => setTicketSummary(null));
|
||||
} else {
|
||||
setTicketSummary(null);
|
||||
}
|
||||
}, [ticketSystemConnected]);
|
||||
|
||||
// Scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, isLoading]);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
setActiveConvId(null);
|
||||
setInputValue("");
|
||||
}, []);
|
||||
|
||||
const handleSelectConversation = useCallback(
|
||||
(id: string) => {
|
||||
setActiveConvId(id);
|
||||
setInputValue("");
|
||||
|
||||
// Load messages if not yet loaded and no stream is in progress
|
||||
const conv = conversations.find((c) => c.id === id);
|
||||
if (conv && conv.messages.length === 0 && !isLoading) {
|
||||
fetchConversation(id)
|
||||
.then((detail) => {
|
||||
const msgs: Message[] = detail.messages.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role === "human" ? "user" : "assistant",
|
||||
content: m.content,
|
||||
timestamp: new Date(m.created_at),
|
||||
}));
|
||||
// Only write history if the conversation still has no messages
|
||||
setConversations((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === id && c.messages.length === 0
|
||||
? { ...c, messages: msgs }
|
||||
: c
|
||||
)
|
||||
);
|
||||
// Preload workspace sessions for assistant messages from localStorage
|
||||
const toLoad: WorkspaceSession[] = [];
|
||||
for (const m of detail.messages) {
|
||||
if (m.role === "ai") {
|
||||
const saved = loadWsFromStorage(m.id);
|
||||
if (saved) toLoad.push(saved);
|
||||
}
|
||||
}
|
||||
if (toLoad.length > 0) {
|
||||
setWorkspaceSessions(prev => {
|
||||
const next = new Map(prev);
|
||||
for (const s of toLoad) next.set(s.messageId, s);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// silently fail
|
||||
});
|
||||
}
|
||||
},
|
||||
[conversations, isLoading]
|
||||
);
|
||||
|
||||
const handleRenameConversation = useCallback(
|
||||
(id: string, title: string) => {
|
||||
// Optimistically update locally
|
||||
setConversations((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, title } : c))
|
||||
);
|
||||
// PATCH on backend (fire-and-forget)
|
||||
fetch(`${process.env.NEXT_PUBLIC_API_URL ?? "https://soc-backend.azurewebsites.net"}/api/conversations/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title }),
|
||||
}).catch(() => {
|
||||
// silently fail — local state already updated
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDeleteConversation = useCallback(
|
||||
(id: string) => {
|
||||
setConversations((prev) => prev.filter((c) => c.id !== id));
|
||||
if (activeConvId === id) {
|
||||
setActiveConvId(null);
|
||||
}
|
||||
deleteConversation(id).catch(() => {
|
||||
// silently fail
|
||||
});
|
||||
},
|
||||
[activeConvId]
|
||||
);
|
||||
|
||||
const handleOpenExtensions = useCallback(() => {
|
||||
setExtensionsPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleUpdateExtension = useCallback(
|
||||
(id: string, apiKey: string, connected: boolean) => {
|
||||
setExtensions((prev) =>
|
||||
prev.map((ext) =>
|
||||
ext.id === id ? { ...ext, apiKey, connected } : ext
|
||||
)
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSend = useCallback(async (attachments?: AttachmentData[]) => {
|
||||
const text = inputValue.trim();
|
||||
if (!text || isLoading) return;
|
||||
setInputValue("");
|
||||
setIsLoading(true);
|
||||
|
||||
const userMsg: Message = {
|
||||
id: `msg-${Date.now()}-user`,
|
||||
role: "user",
|
||||
content: text,
|
||||
timestamp: new Date(),
|
||||
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
||||
};
|
||||
|
||||
let convId = activeConvId;
|
||||
|
||||
if (!convId) {
|
||||
// Generate a UUID client-side; backend streamChat auto-creates the conversation
|
||||
convId = crypto.randomUUID();
|
||||
const title = text.length > 50 ? text.slice(0, 50) + "…" : text;
|
||||
const newConv: Conversation = {
|
||||
id: convId,
|
||||
title,
|
||||
messages: [userMsg],
|
||||
};
|
||||
setConversations((prev) => [newConv, ...prev]);
|
||||
setActiveConvId(convId);
|
||||
} else {
|
||||
setConversations((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === convId ? { ...c, messages: [...c.messages, userMsg] } : c
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const aiMsgId = `msg-${Date.now()}-ai`;
|
||||
const streamConvId = convId;
|
||||
|
||||
abortRef.current = streamChat(
|
||||
text,
|
||||
streamConvId,
|
||||
Array.from(activeTools),
|
||||
apiModel,
|
||||
(event) => {
|
||||
if (event.type === "token" && event.content) {
|
||||
setConversations((prev) =>
|
||||
prev.map((c) => {
|
||||
if (c.id !== streamConvId) return c;
|
||||
const exists = c.messages.some((m) => m.id === aiMsgId);
|
||||
if (!exists) {
|
||||
// First token: create the assistant message (preserve accumulated traceItems)
|
||||
const traceItems = c.messages.find((m) => m.id === aiMsgId)?.traceItems ?? [];
|
||||
return {
|
||||
...c,
|
||||
messages: [
|
||||
...c.messages,
|
||||
{
|
||||
id: aiMsgId,
|
||||
role: "assistant" as const,
|
||||
content: event.content!,
|
||||
timestamp: new Date(),
|
||||
traceItems,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
// Subsequent tokens: append to existing message
|
||||
return {
|
||||
...c,
|
||||
messages: c.messages.map((m) =>
|
||||
m.id === aiMsgId
|
||||
? { ...m, content: m.content + event.content }
|
||||
: m
|
||||
),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
} else if (event.type === "status") {
|
||||
// status 事件:确保 assistant 消息占位符已存在
|
||||
setConversations((prev) =>
|
||||
prev.map((c) => {
|
||||
if (c.id !== streamConvId) return c;
|
||||
const exists = c.messages.some((m) => m.id === aiMsgId);
|
||||
if (exists) return c;
|
||||
return {
|
||||
...c,
|
||||
messages: [
|
||||
...c.messages,
|
||||
{ id: aiMsgId, role: "assistant" as const, content: "", timestamp: new Date(), traceItems: [] },
|
||||
],
|
||||
};
|
||||
})
|
||||
);
|
||||
// workspace: init session + upsert status node
|
||||
initWorkspaceSession(aiMsgId, streamConvId, text, setWorkspaceSessions);
|
||||
setActiveWorkspaceId(aiMsgId);
|
||||
upsertWsStatusNode(aiMsgId, event.message ?? "正在分析...", setWorkspaceSessions);
|
||||
|
||||
} else if (event.type === "tool_start" && event.tool) {
|
||||
const item: TraceItem = {
|
||||
id: event.call_id ?? `${event.tool}-${event.ts ?? Date.now()}`,
|
||||
callId: event.call_id,
|
||||
type: "tool_start",
|
||||
tool: event.tool,
|
||||
title: event.title ?? event.tool,
|
||||
inputSummary: event.input_summary,
|
||||
itemStatus: "running",
|
||||
startTs: event.ts ?? Date.now(),
|
||||
};
|
||||
// Ensure assistant message placeholder exists
|
||||
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: "", timestamp: new Date(), traceItems: [item] },
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...c,
|
||||
messages: c.messages.map((m) =>
|
||||
m.id === aiMsgId ? { ...m, traceItems: [...(m.traceItems ?? []), item] } : m
|
||||
),
|
||||
};
|
||||
})
|
||||
);
|
||||
// workspace: init + add timeline node
|
||||
initWorkspaceSession(aiMsgId, streamConvId, text, setWorkspaceSessions);
|
||||
setActiveWorkspaceId(aiMsgId);
|
||||
const wsNode: ActivityNode = {
|
||||
id: event.call_id ?? `${event.tool}-${Date.now()}`,
|
||||
type: "tool",
|
||||
label: event.title ?? event.tool,
|
||||
tool: event.tool,
|
||||
callId: event.call_id,
|
||||
nodeStatus: "running",
|
||||
ts: event.ts ?? Date.now(),
|
||||
};
|
||||
addWsActivityNode(aiMsgId, wsNode, setWorkspaceSessions);
|
||||
setWorkspaceSessions(prev => {
|
||||
const s = prev.get(aiMsgId);
|
||||
if (!s) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(aiMsgId, { ...s, stageLabel: `正在调用 ${event.title ?? event.tool}...` });
|
||||
return next;
|
||||
});
|
||||
|
||||
} else if (event.type === "tool_end" && event.tool) {
|
||||
updateTraceItem(streamConvId, aiMsgId, event.call_id, event.tool, {
|
||||
type: "tool_end",
|
||||
outputSummary: event.output_summary,
|
||||
itemStatus: "success",
|
||||
durationMs: event.duration_ms,
|
||||
}, setConversations);
|
||||
updateWsActivityNode(aiMsgId, event.call_id, event.tool, { nodeStatus: "success" }, setWorkspaceSessions);
|
||||
|
||||
} else if (event.type === "tool_error" && event.tool) {
|
||||
updateTraceItem(streamConvId, aiMsgId, event.call_id, event.tool, {
|
||||
type: "tool_error",
|
||||
errorSummary: event.error_summary,
|
||||
itemStatus: "error",
|
||||
durationMs: event.duration_ms,
|
||||
}, setConversations);
|
||||
updateWsActivityNode(aiMsgId, event.call_id, event.tool, { nodeStatus: "error" }, setWorkspaceSessions);
|
||||
|
||||
} else if (event.type === "workspace_card" && event.id && event.name) {
|
||||
initWorkspaceSession(aiMsgId, streamConvId, text, setWorkspaceSessions);
|
||||
if (event.merge) {
|
||||
mergeWsCard(aiMsgId, event.id, event.props ?? {}, setWorkspaceSessions);
|
||||
} else {
|
||||
const newCard: WorkspaceCard = {
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
props: event.props ?? {},
|
||||
title: event.name,
|
||||
priority: 0,
|
||||
sourceCallId: event.id,
|
||||
};
|
||||
addWsCard(aiMsgId, newCard, setWorkspaceSessions);
|
||||
}
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// on error
|
||||
setIsLoading(false);
|
||||
errorWsSession(aiMsgId, setWorkspaceSessions);
|
||||
},
|
||||
() => {
|
||||
// on done
|
||||
setIsLoading(false);
|
||||
completeWsSession(aiMsgId, setWorkspaceSessions);
|
||||
}
|
||||
);
|
||||
}, [inputValue, isLoading, activeConvId, activeTools, apiModel]);
|
||||
|
||||
const handleRegenerate = useCallback(
|
||||
async (msgId: string) => {
|
||||
if (!activeConvId || isLoading) return;
|
||||
const conv = conversations.find((c) => c.id === activeConvId);
|
||||
if (!conv) return;
|
||||
const msgIndex = conv.messages.findIndex((m) => m.id === msgId);
|
||||
if (msgIndex === -1) return;
|
||||
|
||||
// Find the preceding user message
|
||||
const userMsg = conv.messages
|
||||
.slice(0, msgIndex)
|
||||
.reverse()
|
||||
.find((m) => m.role === "user");
|
||||
if (!userMsg) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Clear the existing assistant message content for streaming
|
||||
const newAiMsgId = `msg-${Date.now()}-regen`;
|
||||
setConversations((prev) =>
|
||||
prev.map((c) => {
|
||||
if (c.id !== activeConvId) return c;
|
||||
return {
|
||||
...c,
|
||||
messages: c.messages.map((m) =>
|
||||
m.id === msgId ? { ...m, content: "", id: newAiMsgId, traceItems: [] } : m
|
||||
),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const regenConvId = activeConvId;
|
||||
|
||||
abortRef.current = streamChat(
|
||||
userMsg.content,
|
||||
regenConvId,
|
||||
Array.from(activeTools),
|
||||
apiModel,
|
||||
(event) => {
|
||||
if (event.type === "token" && event.content) {
|
||||
setConversations((prev) =>
|
||||
prev.map((c) => {
|
||||
if (c.id !== regenConvId) return c;
|
||||
return {
|
||||
...c,
|
||||
messages: c.messages.map((m) =>
|
||||
m.id === newAiMsgId
|
||||
? { ...m, content: m.content + event.content }
|
||||
: m
|
||||
),
|
||||
};
|
||||
})
|
||||
);
|
||||
} else if (event.type === "status") {
|
||||
setConversations((prev) =>
|
||||
prev.map((c) => {
|
||||
if (c.id !== regenConvId) return c;
|
||||
const exists = c.messages.some((m) => m.id === newAiMsgId);
|
||||
if (exists) return c;
|
||||
return {
|
||||
...c,
|
||||
messages: [
|
||||
...c.messages,
|
||||
{ id: newAiMsgId, role: "assistant" as const, content: "", timestamp: new Date(), traceItems: [] },
|
||||
],
|
||||
};
|
||||
})
|
||||
);
|
||||
initWorkspaceSession(newAiMsgId, regenConvId, userMsg.content, setWorkspaceSessions);
|
||||
setActiveWorkspaceId(newAiMsgId);
|
||||
upsertWsStatusNode(newAiMsgId, event.message ?? "正在分析...", setWorkspaceSessions);
|
||||
|
||||
} else if (event.type === "tool_start" && event.tool) {
|
||||
const item: TraceItem = {
|
||||
id: event.call_id ?? `${event.tool}-${event.ts ?? Date.now()}`,
|
||||
callId: event.call_id,
|
||||
type: "tool_start",
|
||||
tool: event.tool,
|
||||
title: event.title ?? event.tool,
|
||||
inputSummary: event.input_summary,
|
||||
itemStatus: "running",
|
||||
startTs: event.ts ?? Date.now(),
|
||||
};
|
||||
appendTraceItem(regenConvId, newAiMsgId, item, setConversations);
|
||||
initWorkspaceSession(newAiMsgId, regenConvId, userMsg.content, setWorkspaceSessions);
|
||||
setActiveWorkspaceId(newAiMsgId);
|
||||
const wsNode: ActivityNode = {
|
||||
id: event.call_id ?? `${event.tool}-${Date.now()}`,
|
||||
type: "tool", label: event.title ?? event.tool,
|
||||
tool: event.tool, callId: event.call_id,
|
||||
nodeStatus: "running", ts: event.ts ?? Date.now(),
|
||||
};
|
||||
addWsActivityNode(newAiMsgId, wsNode, setWorkspaceSessions);
|
||||
|
||||
} else if (event.type === "tool_end" && event.tool) {
|
||||
updateTraceItem(regenConvId, newAiMsgId, event.call_id, event.tool, {
|
||||
type: "tool_end", outputSummary: event.output_summary,
|
||||
itemStatus: "success", durationMs: event.duration_ms,
|
||||
}, setConversations);
|
||||
updateWsActivityNode(newAiMsgId, event.call_id, event.tool, { nodeStatus: "success" }, setWorkspaceSessions);
|
||||
|
||||
} else if (event.type === "tool_error" && event.tool) {
|
||||
updateTraceItem(regenConvId, newAiMsgId, event.call_id, event.tool, {
|
||||
type: "tool_error", errorSummary: event.error_summary,
|
||||
itemStatus: "error", durationMs: event.duration_ms,
|
||||
}, setConversations);
|
||||
updateWsActivityNode(newAiMsgId, event.call_id, event.tool, { nodeStatus: "error" }, setWorkspaceSessions);
|
||||
|
||||
} else if (event.type === "workspace_card" && event.id && event.name) {
|
||||
initWorkspaceSession(newAiMsgId, regenConvId, userMsg.content, setWorkspaceSessions);
|
||||
if (event.merge) {
|
||||
mergeWsCard(newAiMsgId, event.id, event.props ?? {}, setWorkspaceSessions);
|
||||
} else {
|
||||
addWsCard(newAiMsgId, { id: event.id, name: event.name, props: event.props ?? {}, title: event.name, priority: 0, sourceCallId: event.id }, setWorkspaceSessions);
|
||||
}
|
||||
}
|
||||
},
|
||||
() => {
|
||||
setIsLoading(false);
|
||||
errorWsSession(newAiMsgId, setWorkspaceSessions);
|
||||
},
|
||||
() => {
|
||||
setIsLoading(false);
|
||||
completeWsSession(newAiMsgId, setWorkspaceSessions);
|
||||
}
|
||||
);
|
||||
},
|
||||
[activeConvId, conversations, isLoading, activeTools, apiModel]
|
||||
);
|
||||
|
||||
const handleSuggestionClick = useCallback((text: string) => {
|
||||
setInputValue(text);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-[var(--gem-bg)] overflow-hidden font-sans" role="main">
|
||||
{/* Sidebar */}
|
||||
<GeminiSidebar
|
||||
isOpen={sidebarOpen}
|
||||
conversations={conversations.map(({ id, title }) => ({ id, title }))}
|
||||
activeConversationId={activeConvId}
|
||||
onNewChat={handleNewChat}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onRenameConversation={handleRenameConversation}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
onOpenExtensions={handleOpenExtensions}
|
||||
connectedExtensionsCount={extensions.filter((e) => e.connected).length}
|
||||
/>
|
||||
|
||||
{/* Main content area */}
|
||||
<div
|
||||
className="flex flex-col flex-1 min-w-0 transition-all duration-300"
|
||||
style={{ marginLeft: sidebarOpen ? 260 : 0 }}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<GeminiTopbar
|
||||
sidebarOpen={sidebarOpen}
|
||||
onToggleSidebar={() => setSidebarOpen((v) => !v)}
|
||||
/>
|
||||
|
||||
{/* Messages or welcome */}
|
||||
<div className="flex-1 overflow-y-auto pt-14 scrollbar-thin">
|
||||
{messages.length === 0 && !isLoading ? (
|
||||
<>
|
||||
<GeminiWelcome onSuggestionClick={handleSuggestionClick} />
|
||||
{ticketSystemConnected && ticketSummary && <TicketSummary summary={ticketSummary} />}
|
||||
</>
|
||||
) : (
|
||||
<div className="max-w-2xl mx-auto px-4 pt-8 pb-4">
|
||||
{ticketSystemConnected && ticketSummary && <TicketSummary summary={ticketSummary} />}
|
||||
{messages.map((msg) => {
|
||||
if (msg.role === "assistant" && msg.content === "" && isLoading) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<GeminiMessage
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
model={selectedModel}
|
||||
onRegenerate={msg.role === "assistant" ? handleRegenerate : undefined}
|
||||
onFocus={msg.role === "assistant" ? () => setActiveWorkspaceId(msg.id) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isLoading && messages.length > 0 && messages[messages.length - 1].role !== "assistant" && (
|
||||
<GeminiTypingIndicator />
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<GeminiInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={handleSend}
|
||||
isLoading={isLoading}
|
||||
activeTools={activeTools}
|
||||
onActiveToolsChange={setActiveTools}
|
||||
selectedModel={selectedModel}
|
||||
onSelectedModelChange={setSelectedModel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Agent Workspace */}
|
||||
<div className="w-[360px] flex-shrink-0 border-l border-[var(--gem-border)] bg-[var(--gem-surface)] overflow-hidden flex flex-col">
|
||||
<AgentWorkspace session={activeWorkspace} isGenerating={isLoading} />
|
||||
</div>
|
||||
|
||||
{/* Extensions Panel */}
|
||||
<ExtensionsPanel
|
||||
isOpen={extensionsPanelOpen}
|
||||
onClose={() => setExtensionsPanelOpen(false)}
|
||||
extensions={extensions}
|
||||
onUpdateExtension={handleUpdateExtension}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, KeyboardEvent, useState } from "react";
|
||||
import {
|
||||
Plus,
|
||||
Mic,
|
||||
ArrowUp,
|
||||
Box,
|
||||
Search,
|
||||
Database,
|
||||
FileText,
|
||||
Code2,
|
||||
X,
|
||||
Loader2,
|
||||
Paperclip,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { uploadAttachment, type AttachmentData } from "@/lib/api";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface Tool {
|
||||
id: string;
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface UploadingFile {
|
||||
id: string;
|
||||
file: File;
|
||||
status: "uploading" | "done" | "error";
|
||||
attachment?: AttachmentData;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface GeminiInputProps {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
onSubmit: (attachments?: AttachmentData[]) => void;
|
||||
isLoading?: boolean;
|
||||
activeTools: Set<string>;
|
||||
onActiveToolsChange: (tools: Set<string>) => void;
|
||||
selectedModel: "flash" | "auto" | "pro";
|
||||
onSelectedModelChange: (model: "flash" | "auto" | "pro") => void;
|
||||
}
|
||||
|
||||
const TOOLS: Tool[] = [
|
||||
{ id: "search", icon: Search, label: "搜索" },
|
||||
{ id: "knowledge", icon: Database, label: "内部知识库" },
|
||||
{ id: "sandbox", icon: Box, label: "沙盒" },
|
||||
{ id: "document", icon: FileText, label: "文档生成" },
|
||||
];
|
||||
|
||||
export function GeminiInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
activeTools,
|
||||
onActiveToolsChange,
|
||||
selectedModel,
|
||||
onSelectedModelChange,
|
||||
}: GeminiInputProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showTools, setShowTools] = useState(false);
|
||||
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
ta.style.height = "auto";
|
||||
ta.style.height = Math.min(ta.scrollHeight, 200) + "px";
|
||||
}, [value]);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (canSend) handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTool = (id: string) => {
|
||||
const next = new Set(activeTools);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
onActiveToolsChange(next);
|
||||
};
|
||||
|
||||
const hasUploading = uploadingFiles.some((f) => f.status === "uploading");
|
||||
const canSend = value.trim().length > 0 && !isLoading && !hasUploading;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const attachments = uploadingFiles
|
||||
.filter((f) => f.status === "done" && f.attachment)
|
||||
.map((f) => f.attachment!);
|
||||
onSubmit(attachments.length > 0 ? attachments : undefined);
|
||||
setUploadingFiles([]);
|
||||
};
|
||||
|
||||
const doUpload = async (entry: UploadingFile) => {
|
||||
try {
|
||||
const attachment = await uploadAttachment(entry.file);
|
||||
setUploadingFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === entry.id ? { ...f, status: "done", attachment } : f
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
setUploadingFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === entry.id
|
||||
? { ...f, status: "error", error: err instanceof Error ? err.message : "Upload failed" }
|
||||
: f
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const newEntries: UploadingFile[] = Array.from(files).map((file) => ({
|
||||
id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
file,
|
||||
status: "uploading" as const,
|
||||
}));
|
||||
|
||||
setUploadingFiles((prev) => [...prev, ...newEntries]);
|
||||
|
||||
// Start uploads
|
||||
newEntries.forEach((entry) => doUpload(entry));
|
||||
|
||||
// Reset file input so the same file can be selected again
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleRetryUpload = (entry: UploadingFile) => {
|
||||
setUploadingFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === entry.id ? { ...f, status: "uploading", error: undefined } : f
|
||||
)
|
||||
);
|
||||
doUpload({ ...entry, status: "uploading" });
|
||||
};
|
||||
|
||||
const handleRemoveFile = (id: string) => {
|
||||
setUploadingFiles((prev) => prev.filter((f) => f.id !== id));
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sticky bottom-0 left-0 right-0 pb-6 pt-4 bg-gradient-to-t from-[var(--gem-bg)] via-[var(--gem-bg)]/90 to-transparent pointer-events-none">
|
||||
<div className="max-w-3xl mx-auto px-4 pointer-events-auto">
|
||||
{/* Input container */}
|
||||
<div className="bg-[var(--gem-surface)] border border-[var(--gem-border)] rounded-3xl shadow-lg focus-within:border-[var(--gem-border-hover)] transition-colors duration-150">
|
||||
{/* Uploaded files preview */}
|
||||
{uploadingFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-4 pt-3 pb-1">
|
||||
{uploadingFiles.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-xs border max-w-[220px]",
|
||||
entry.status === "error"
|
||||
? "bg-red-500/10 border-red-500/30 text-red-400"
|
||||
: "bg-[var(--gem-surface-2)] border-[var(--gem-border)] text-[var(--gem-text-secondary)]"
|
||||
)}
|
||||
>
|
||||
{entry.status === "uploading" && (
|
||||
<Loader2 size={14} className="animate-spin flex-shrink-0 text-[var(--gem-blue)]" />
|
||||
)}
|
||||
{entry.status === "done" && (
|
||||
<Paperclip size={14} className="flex-shrink-0 text-[var(--gem-text-muted)]" />
|
||||
)}
|
||||
{entry.status === "error" && (
|
||||
<Paperclip size={14} className="flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{entry.file.name}
|
||||
{entry.status === "done" && entry.attachment && (
|
||||
<span className="text-[var(--gem-text-muted)] ml-1">
|
||||
{formatSize(entry.attachment.size_bytes)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{entry.status === "error" && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleRetryUpload(entry); }}
|
||||
className="flex-shrink-0 hover:opacity-80 cursor-pointer"
|
||||
aria-label="重试上传"
|
||||
title="重试"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleRemoveFile(entry.id); }}
|
||||
className="flex-shrink-0 hover:opacity-80 cursor-pointer text-[var(--gem-text-muted)]"
|
||||
aria-label="移除文件"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main input row */}
|
||||
<div className="flex items-end gap-2 px-4 py-3">
|
||||
{/* Left: File upload button */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="p-1.5 rounded-full text-[var(--gem-text-muted)] hover:text-[var(--gem-text)] hover:bg-[var(--gem-surface-2)] transition-colors duration-150 flex-shrink-0 cursor-pointer mb-0.5"
|
||||
aria-label="文件上传"
|
||||
title="上传文件"
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
aria-label="文件选择"
|
||||
/>
|
||||
|
||||
{/* Tools toggle */}
|
||||
<button
|
||||
onClick={() => setShowTools(!showTools)}
|
||||
className={cn(
|
||||
"p-1.5 rounded-full transition-colors duration-150 flex-shrink-0 cursor-pointer mb-0.5",
|
||||
showTools
|
||||
? "bg-[var(--gem-blue)]/20 text-[var(--gem-blue)]"
|
||||
: "text-[var(--gem-text-muted)] hover:text-[var(--gem-text)] hover:bg-[var(--gem-surface-2)]"
|
||||
)}
|
||||
aria-label="工具菜单"
|
||||
title="工具"
|
||||
aria-expanded={showTools}
|
||||
>
|
||||
<Box size={20} />
|
||||
</button>
|
||||
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="阐述你的图片"
|
||||
rows={1}
|
||||
className={cn(
|
||||
"flex-1 bg-transparent text-[var(--gem-text)] text-sm placeholder:text-[var(--gem-text-placeholder)] resize-none outline-none",
|
||||
"leading-relaxed py-0.5 min-h-[28px] max-h-[200px] overflow-y-auto scrollbar-thin"
|
||||
)}
|
||||
aria-label="消息输入"
|
||||
aria-multiline="true"
|
||||
/>
|
||||
|
||||
{/* Right controls */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0 mb-0.5">
|
||||
<button
|
||||
className="p-1.5 rounded-full text-[var(--gem-text-muted)] hover:text-[var(--gem-text)] hover:bg-[var(--gem-surface-2)] transition-colors duration-150 cursor-pointer"
|
||||
aria-label="语音输入"
|
||||
>
|
||||
<Mic size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => canSend && handleSubmit()}
|
||||
disabled={!canSend}
|
||||
aria-label="发送消息"
|
||||
className={cn(
|
||||
"w-9 h-9 rounded-full flex items-center justify-center transition-all duration-150",
|
||||
canSend
|
||||
? "bg-[var(--gem-blue)] text-white hover:bg-[var(--gem-blue-hover)] cursor-pointer shadow-md"
|
||||
: "bg-[var(--gem-surface-2)] text-[var(--gem-text-placeholder)] cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<ArrowUp
|
||||
size={18}
|
||||
className={cn(canSend ? "translate-y-px" : "")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom row: Model selector */}
|
||||
<div className="flex items-center justify-end px-4 pb-3 pt-0">
|
||||
<Select
|
||||
value={selectedModel}
|
||||
onValueChange={(val) =>
|
||||
onSelectedModelChange(val as "flash" | "auto" | "pro")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-24 rounded-full border border-[var(--gem-border)] bg-[var(--gem-surface-2)] text-[var(--gem-text-muted)] text-xs px-3 py-0 focus:ring-0 focus:ring-offset-0 hover:bg-[var(--gem-surface-3)] hover:text-[var(--gem-text)] transition-colors duration-150 cursor-pointer [&>svg]:opacity-60">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[var(--gem-surface)] border-[var(--gem-border)] text-[var(--gem-text)] text-xs rounded-xl min-w-[6rem]">
|
||||
<SelectItem value="auto" className="text-xs cursor-pointer focus:bg-[var(--gem-surface-2)]">Auto</SelectItem>
|
||||
<SelectItem value="flash" className="text-xs cursor-pointer focus:bg-[var(--gem-surface-2)]">Flash</SelectItem>
|
||||
<SelectItem value="pro" className="text-xs cursor-pointer focus:bg-[var(--gem-surface-2)]">Pro</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Tools panel (collapsible, below input) */}
|
||||
{showTools && (
|
||||
<div className="px-4 pb-3 pt-2 border-t border-[var(--gem-surface-2)]">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{TOOLS.map((tool) => {
|
||||
const Icon = tool.icon;
|
||||
const isActive = activeTools.has(tool.id);
|
||||
return (
|
||||
<button
|
||||
key={tool.id}
|
||||
onClick={() => toggleTool(tool.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-150 cursor-pointer whitespace-nowrap",
|
||||
isActive
|
||||
? "bg-[var(--gem-blue)] text-white border border-[var(--gem-blue)]"
|
||||
: "bg-[var(--gem-surface-2)] text-[var(--gem-text-muted)] border border-transparent hover:bg-[var(--gem-surface-3)] hover:text-[var(--gem-text)]"
|
||||
)}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span>{tool.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<p className="text-center text-[11px] text-[var(--gem-text-placeholder)] mt-2.5">
|
||||
运营大脑可能会出错,请仔细检查其回复。{" "}
|
||||
<button className="underline hover:text-[var(--gem-text-muted)] transition-colors cursor-pointer">
|
||||
你的隐私与运营大脑应用
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ThumbsUp, ThumbsDown, Copy, RefreshCw, Check, Paperclip, Download } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAttachmentDownloadUrl, type AttachmentData, type TraceItem } from "@/lib/api";
|
||||
import { TracePanel } from "./TracePanel";
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp?: Date;
|
||||
attachments?: AttachmentData[];
|
||||
traceItems?: TraceItem[];
|
||||
}
|
||||
|
||||
interface GeminiMessageProps {
|
||||
message: Message;
|
||||
model?: "flash" | "auto" | "pro";
|
||||
onRegenerate?: (id: string) => void;
|
||||
}
|
||||
|
||||
// Simple markdown-like renderer
|
||||
function renderContent(content: string) {
|
||||
const lines = content.split("\n");
|
||||
const elements: React.ReactNode[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Code block
|
||||
if (line.startsWith("```")) {
|
||||
const lang = line.slice(3).trim();
|
||||
const codeLines: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].startsWith("```")) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
elements.push(
|
||||
<pre
|
||||
key={i}
|
||||
className="bg-[var(--gem-surface)] border border-[var(--gem-border)] rounded-xl p-4 overflow-x-auto my-3 text-sm font-mono text-[var(--gem-text-secondary)]"
|
||||
>
|
||||
{lang && (
|
||||
<div className="text-xs text-[var(--gem-text-muted)] mb-2 uppercase tracking-wide">{lang}</div>
|
||||
)}
|
||||
<code>{codeLines.join("\n")}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
// H2
|
||||
else if (line.startsWith("## ")) {
|
||||
elements.push(
|
||||
<h2 key={i} className="text-lg font-semibold text-[var(--gem-text)] mt-4 mb-2">
|
||||
{line.slice(3)}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
// H3
|
||||
else if (line.startsWith("### ")) {
|
||||
elements.push(
|
||||
<h3 key={i} className="text-base font-semibold text-[var(--gem-text)] mt-3 mb-1.5">
|
||||
{line.slice(4)}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
// Bullet
|
||||
else if (line.startsWith("- ") || line.startsWith("* ")) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && (lines[i].startsWith("- ") || lines[i].startsWith("* "))) {
|
||||
items.push(lines[i].slice(2));
|
||||
i++;
|
||||
}
|
||||
elements.push(
|
||||
<ul key={i} className="list-disc list-inside space-y-1 my-2 text-[var(--gem-text-secondary)] text-sm leading-relaxed">
|
||||
{items.map((item, idx) => (
|
||||
<li key={idx} dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Numbered list
|
||||
else if (/^\d+\.\s/.test(line)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^\d+\.\s/.test(lines[i])) {
|
||||
items.push(lines[i].replace(/^\d+\.\s/, ""));
|
||||
i++;
|
||||
}
|
||||
elements.push(
|
||||
<ol key={i} className="list-decimal list-inside space-y-1 my-2 text-[var(--gem-text-secondary)] text-sm leading-relaxed">
|
||||
{items.map((item, idx) => (
|
||||
<li key={idx} dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Empty line
|
||||
else if (line.trim() === "") {
|
||||
elements.push(<div key={i} className="h-2" />);
|
||||
}
|
||||
// Paragraph
|
||||
else {
|
||||
elements.push(
|
||||
<p
|
||||
key={i}
|
||||
className="text-[var(--gem-text-secondary)] text-sm leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: inlineFormat(line) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
function inlineFormat(text: string): string {
|
||||
return text
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong style="color:var(--gem-text);font-weight:600">$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`(.+?)`/g, '<code style="background:var(--gem-surface-2);color:#a8c4f5;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-family:monospace">$1</code>');
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function AttachmentList({ attachments }: { attachments: AttachmentData[] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{attachments.map((att) => (
|
||||
<a
|
||||
key={att.id}
|
||||
href={getAttachmentDownloadUrl(att.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--gem-surface-2)] border border-[var(--gem-border)] hover:border-[var(--gem-border-hover)] transition-colors duration-150 text-xs max-w-[240px]"
|
||||
>
|
||||
<Paperclip size={14} className="text-[var(--gem-text-muted)] flex-shrink-0" />
|
||||
<span className="truncate text-[var(--gem-text-secondary)]">{att.filename}</span>
|
||||
<span className="text-[var(--gem-text-muted)] flex-shrink-0">{formatFileSize(att.size_bytes)}</span>
|
||||
<Download size={12} className="text-[var(--gem-text-muted)] flex-shrink-0" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const GemIcon = ({ size = 18 }: { size?: number }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" className="flex-shrink-0 mt-0.5">
|
||||
<defs>
|
||||
<linearGradient id="gemGradientMsg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#4285f4" />
|
||||
<stop offset="100%" stopColor="#a855f7" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M14 2C14 2 16.5 9.5 20 13C23.5 16.5 26 14 26 14C26 14 23.5 16.5 20 20C16.5 23.5 14 26 14 26C14 26 11.5 23.5 8 20C4.5 16.5 2 14 2 14C2 14 4.5 11.5 8 8C11.5 4.5 14 2 14 2Z"
|
||||
fill="url(#gemGradientMsg)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function GeminiMessage({ message, model = "auto", onRegenerate }: GeminiMessageProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [feedback, setFeedback] = useState<"up" | "down" | null>(null);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(message.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (message.role === "user") {
|
||||
return (
|
||||
<div className="flex justify-end mb-4" role="article" aria-label="Your message">
|
||||
<div className="max-w-[70%] bg-[var(--gem-surface-2)] rounded-2xl px-5 py-3.5 text-[var(--gem-text)] text-sm leading-relaxed">
|
||||
<div className="whitespace-pre-wrap">{message.content}</div>
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<AttachmentList attachments={message.attachments} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 group" role="article" aria-label="Gemini response">
|
||||
<div className="flex gap-3">
|
||||
{/* Gem icon */}
|
||||
<GemIcon />
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{message.traceItems && message.traceItems.length > 0 && (
|
||||
<>
|
||||
<TracePanel
|
||||
items={message.traceItems}
|
||||
model={model}
|
||||
/>
|
||||
{/* 分隔线,绑定 trace 与正文 */}
|
||||
{message.content && (
|
||||
<div className="my-2 border-t border-[var(--gem-border)] opacity-40" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="space-y-0.5">{renderContent(message.content)}</div>
|
||||
|
||||
{/* Attachments */}
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<AttachmentList attachments={message.attachments} />
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 mt-3 transition-opacity duration-150",
|
||||
"opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
role="toolbar"
|
||||
aria-label="Message actions"
|
||||
>
|
||||
<ActionButton
|
||||
onClick={() => setFeedback("up")}
|
||||
active={feedback === "up"}
|
||||
label="Thumbs up"
|
||||
>
|
||||
<ThumbsUp size={15} />
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
onClick={() => setFeedback("down")}
|
||||
active={feedback === "down"}
|
||||
label="Thumbs down"
|
||||
>
|
||||
<ThumbsDown size={15} />
|
||||
</ActionButton>
|
||||
<ActionButton onClick={handleCopy} label="Copy to clipboard">
|
||||
{copied ? <Check size={15} className="text-green-400" /> : <Copy size={15} />}
|
||||
</ActionButton>
|
||||
{onRegenerate && (
|
||||
<ActionButton onClick={() => onRegenerate(message.id)} label="Regenerate response">
|
||||
<RefreshCw size={15} />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
onClick,
|
||||
active,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"p-1.5 rounded-lg transition-colors duration-150 cursor-pointer",
|
||||
active
|
||||
? "text-[var(--gem-blue)] bg-[var(--gem-active-bg)]"
|
||||
: "text-[var(--gem-text-muted)] hover:text-[var(--gem-text)] hover:bg-[var(--gem-surface-2)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PencilLine,
|
||||
PuzzleIcon,
|
||||
MoreHorizontal,
|
||||
Settings,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface GeminiSidebarProps {
|
||||
isOpen: boolean;
|
||||
conversations: Conversation[];
|
||||
activeConversationId: string | null;
|
||||
onNewChat: () => void;
|
||||
onSelectConversation: (id: string) => void;
|
||||
onRenameConversation?: (id: string, title: string) => void;
|
||||
onDeleteConversation?: (id: string) => void;
|
||||
onOpenExtensions?: () => void;
|
||||
connectedExtensionsCount?: number;
|
||||
}
|
||||
|
||||
const GemIcon = () => (
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="gemGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#4285f4" />
|
||||
<stop offset="100%" stopColor="#a855f7" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M14 2C14 2 16.5 9.5 20 13C23.5 16.5 26 14 26 14C26 14 23.5 16.5 20 20C16.5 23.5 14 26 14 26C14 26 11.5 23.5 8 20C4.5 16.5 2 14 2 14C2 14 4.5 11.5 8 8C11.5 4.5 14 2 14 2Z"
|
||||
fill="url(#gemGradient)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function GeminiSidebar({
|
||||
isOpen,
|
||||
conversations,
|
||||
activeConversationId,
|
||||
onNewChat,
|
||||
onSelectConversation,
|
||||
onRenameConversation,
|
||||
onDeleteConversation,
|
||||
onOpenExtensions,
|
||||
connectedExtensionsCount = 0,
|
||||
}: GeminiSidebarProps) {
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (renamingId && renameInputRef.current) {
|
||||
renameInputRef.current.focus();
|
||||
renameInputRef.current.select();
|
||||
}
|
||||
}, [renamingId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpenId) return;
|
||||
const close = () => setMenuOpenId(null);
|
||||
document.addEventListener("click", close);
|
||||
return () => document.removeEventListener("click", close);
|
||||
}, [menuOpenId]);
|
||||
|
||||
const handleMenuClick = useCallback((e: React.MouseEvent, convId: string) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpenId((prev) => (prev === convId ? null : convId));
|
||||
}, []);
|
||||
|
||||
const handleRenameStart = useCallback((conv: Conversation) => {
|
||||
setRenamingId(conv.id);
|
||||
setRenameValue(conv.title);
|
||||
setMenuOpenId(null);
|
||||
}, []);
|
||||
|
||||
const handleRenameSubmit = useCallback((id: string) => {
|
||||
const trimmed = renameValue.trim();
|
||||
if (trimmed && onRenameConversation) {
|
||||
onRenameConversation(id, trimmed);
|
||||
}
|
||||
setRenamingId(null);
|
||||
setRenameValue("");
|
||||
}, [renameValue, onRenameConversation]);
|
||||
|
||||
const handleDelete = useCallback((e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpenId(null);
|
||||
onDeleteConversation?.(id);
|
||||
}, [onDeleteConversation]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed left-0 top-0 h-full z-20 flex flex-col transition-all duration-300 ease-in-out",
|
||||
"bg-[var(--gem-surface)] border-r border-[var(--gem-border)]",
|
||||
isOpen ? "w-[260px] opacity-100" : "w-0 opacity-0 overflow-hidden pointer-events-none"
|
||||
)}
|
||||
aria-label="Sidebar navigation"
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2.5 px-4 pt-5 pb-4">
|
||||
<GemIcon />
|
||||
<span className="text-xl font-semibold bg-gradient-to-r from-[var(--gem-blue)] to-[var(--gem-purple)] bg-clip-text text-transparent select-none">
|
||||
运营大脑
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* New Chat Button */}
|
||||
<div className="px-3 mb-4">
|
||||
<button
|
||||
onClick={onNewChat}
|
||||
className="w-full flex items-center gap-2.5 px-4 py-2.5 rounded-full border border-[var(--gem-border)] text-[var(--gem-text)] text-sm font-medium hover:bg-[var(--gem-surface-2)] transition-colors duration-150 cursor-pointer"
|
||||
aria-label="新建对话"
|
||||
>
|
||||
<PencilLine size={16} className="text-[var(--gem-text-muted)]" />
|
||||
<span>新建对话</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Recent conversations */}
|
||||
<div className="flex-1 overflow-y-auto px-2 scrollbar-thin">
|
||||
<p className="text-xs font-medium text-[var(--gem-text-muted)] px-3 mb-2 uppercase tracking-wide">
|
||||
最近的对话
|
||||
</p>
|
||||
<nav aria-label="Recent conversations">
|
||||
<ul className="space-y-0.5">
|
||||
{conversations.map((conv) => (
|
||||
<li key={conv.id} className="relative">
|
||||
{renamingId === conv.id ? (
|
||||
<div className="flex items-center px-3 py-2">
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleRenameSubmit(conv.id);
|
||||
if (e.key === "Escape") { setRenamingId(null); setRenameValue(""); }
|
||||
}}
|
||||
onBlur={() => handleRenameSubmit(conv.id)}
|
||||
className="w-full bg-[var(--gem-surface-2)] border border-[var(--gem-border-hover)] rounded-lg px-2 py-1 text-sm text-[var(--gem-text)] outline-none"
|
||||
aria-label="重命名对话"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onSelectConversation(conv.id)}
|
||||
onMouseEnter={() => setHoveredId(conv.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm text-left transition-colors duration-150 cursor-pointer group",
|
||||
activeConversationId === conv.id
|
||||
? "bg-[var(--gem-surface-2)] text-[var(--gem-text)]"
|
||||
: "text-[var(--gem-text-secondary)] hover:bg-[var(--gem-surface-2)] hover:text-[var(--gem-text)]"
|
||||
)}
|
||||
aria-current={activeConversationId === conv.id ? "page" : undefined}
|
||||
>
|
||||
<span className="truncate flex-1">{conv.title}</span>
|
||||
{(hoveredId === conv.id || menuOpenId === conv.id) && (
|
||||
<span
|
||||
onClick={(e) => handleMenuClick(e, conv.id)}
|
||||
className="flex-shrink-0 ml-1 p-0.5 rounded hover:bg-[var(--gem-surface-3)] text-[var(--gem-text-muted)] hover:text-[var(--gem-text)] transition-colors cursor-pointer"
|
||||
role="button"
|
||||
aria-label="更多选项"
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{menuOpenId === conv.id && (
|
||||
<div
|
||||
className="absolute right-2 top-full mt-1 w-36 bg-[var(--gem-surface-2)] border border-[var(--gem-border)] rounded-xl shadow-2xl z-30 py-1 overflow-hidden"
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleRenameStart(conv); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--gem-text)] hover:bg-[var(--gem-surface-3)] transition-colors cursor-pointer"
|
||||
role="menuitem"
|
||||
>
|
||||
<Pencil size={14} className="text-[var(--gem-text-muted)]" />
|
||||
<span>重命名</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleDelete(e, conv.id)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer"
|
||||
role="menuitem"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>删除</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Bottom nav */}
|
||||
<div className="border-t border-[var(--gem-border)] pt-2 pb-4 px-2">
|
||||
<nav aria-label="App navigation">
|
||||
<ul className="space-y-0.5">
|
||||
{[
|
||||
{ icon: PuzzleIcon, label: "扩展程序", onClick: onOpenExtensions },
|
||||
{ icon: Settings, label: "设置", onClick: undefined },
|
||||
].map(({ icon: Icon, label, onClick }) => (
|
||||
<li key={label}>
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm text-[var(--gem-text-muted)] hover:bg-[var(--gem-surface-2)] hover:text-[var(--gem-text)] transition-colors duration-150 cursor-pointer"
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{label}</span>
|
||||
{label === "扩展程序" && connectedExtensionsCount > 0 && (
|
||||
<span className="ml-auto text-xs bg-[var(--gem-blue)] text-white px-1.5 py-0.5 rounded-full">
|
||||
{connectedExtensionsCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* User avatar */}
|
||||
<div className="flex items-center gap-3 px-3 pt-3 mt-1 border-t border-[var(--gem-border)]">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full bg-gradient-to-br from-[var(--gem-blue)] to-[var(--gem-purple)] flex items-center justify-center text-white text-sm font-semibold flex-shrink-0"
|
||||
aria-label="用户头像"
|
||||
>
|
||||
U
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-[var(--gem-text)] font-medium truncate">用户</p>
|
||||
<p className="text-xs text-[var(--gem-text-muted)] truncate">user@gmail.com</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Menu, Settings, Sun, Moon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface GeminiTopbarProps {
|
||||
sidebarOpen: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
export function GeminiTopbar({ sidebarOpen, onToggleSidebar }: GeminiTopbarProps) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"fixed top-0 right-0 z-10 flex items-center justify-between px-4 h-14",
|
||||
"bg-[var(--gem-bg)]/80 backdrop-blur-md border-b border-[var(--gem-border)]/50",
|
||||
"transition-all duration-300",
|
||||
sidebarOpen ? "left-[260px]" : "left-0"
|
||||
)}
|
||||
role="banner"
|
||||
>
|
||||
{/* Left: hamburger */}
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="p-2 rounded-full hover:bg-[var(--gem-surface-2)] text-[var(--gem-text-muted)] hover:text-[var(--gem-text)] transition-colors duration-150 cursor-pointer"
|
||||
aria-label={sidebarOpen ? "关闭侧边栏" : "打开侧边栏"}
|
||||
aria-expanded={sidebarOpen}
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
|
||||
{/* Center: model label */}
|
||||
<span
|
||||
className="px-4 py-1.5 rounded-full border border-[var(--gem-border)] text-[var(--gem-text)] text-sm font-medium"
|
||||
aria-label="当前模型"
|
||||
>
|
||||
GPT-5.4
|
||||
</span>
|
||||
|
||||
{/* Right: theme toggle + settings + avatar */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className="p-2 rounded-full hover:bg-[var(--gem-surface-2)] text-[var(--gem-text-muted)] hover:text-[var(--gem-text)] transition-colors duration-150 cursor-pointer"
|
||||
aria-label={theme === "dark" ? "切换亮色主题" : "切换暗色主题"}
|
||||
>
|
||||
{theme === "dark" ? <Sun size={20} /> : <Moon size={20} />}
|
||||
</button>
|
||||
<button
|
||||
className="p-2 rounded-full hover:bg-[var(--gem-surface-2)] text-[var(--gem-text-muted)] hover:text-[var(--gem-text)] transition-colors duration-150 cursor-pointer"
|
||||
aria-label="设置"
|
||||
>
|
||||
<Settings size={20} />
|
||||
</button>
|
||||
<button
|
||||
className="w-8 h-8 rounded-full bg-gradient-to-br from-[var(--gem-blue)] to-[var(--gem-purple)] flex items-center justify-center text-white text-sm font-semibold ml-1 cursor-pointer hover:opacity-90 transition-opacity"
|
||||
aria-label="用户资料"
|
||||
>
|
||||
U
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
export function GeminiTypingIndicator() {
|
||||
return (
|
||||
<div className="flex gap-3 mb-6" aria-live="polite" aria-label="Gemini is thinking">
|
||||
{/* Gem icon placeholder */}
|
||||
<div className="w-[18px] h-[18px] flex-shrink-0 mt-0.5">
|
||||
<svg width="18" height="18" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="gemGradientTyping" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#4285f4" />
|
||||
<stop offset="100%" stopColor="#a855f7" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M14 2C14 2 16.5 9.5 20 13C23.5 16.5 26 14 26 14C26 14 23.5 16.5 20 20C16.5 23.5 14 26 14 26C14 26 11.5 23.5 8 20C4.5 16.5 2 14 2 14C2 14 4.5 11.5 8 8C11.5 4.5 14 2 14 2Z"
|
||||
fill="url(#gemGradientTyping)"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pt-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-[#4285f4] animate-bounce"
|
||||
style={{ animationDelay: "0ms", animationDuration: "900ms" }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-[#7c55f0] animate-bounce"
|
||||
style={{ animationDelay: "150ms", animationDuration: "900ms" }}
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-[#a855f7] animate-bounce"
|
||||
style={{ animationDelay: "300ms", animationDuration: "900ms" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BarChart3,
|
||||
FileText,
|
||||
TrendingUp,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
interface GeminiWelcomeProps {
|
||||
onSuggestionClick: (text: string) => void;
|
||||
}
|
||||
|
||||
const suggestions = [
|
||||
{
|
||||
icon: BarChart3,
|
||||
text: "帮我分析本月的用户增长数据,找出关键趋势",
|
||||
short: "数据分析",
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
text: "生成一份产品运营周报,包含核心指标和优化建议",
|
||||
short: "运营周报",
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
text: "制定下季度的用户增长策略和推广计划",
|
||||
short: "增长策略",
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
text: "分析用户留存率下降的原因,给出改进方案",
|
||||
short: "用户留存",
|
||||
},
|
||||
];
|
||||
|
||||
export function GeminiWelcome({ onSuggestionClick }: GeminiWelcomeProps) {
|
||||
const hour = new Date().getHours();
|
||||
const greeting =
|
||||
hour < 12 ? "早上好" : hour < 18 ? "下午好" : "晚上好";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 px-6 pb-32 pt-10 min-h-0">
|
||||
{/* Greeting */}
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-4xl font-semibold mb-3 leading-tight text-balance">
|
||||
<span className="bg-gradient-to-r from-[var(--gem-blue)] via-[var(--gem-mid-gradient)] to-[var(--gem-purple)] bg-clip-text text-transparent">
|
||||
{greeting},
|
||||
</span>{" "}
|
||||
<span className="text-[var(--gem-text)]">用户</span>
|
||||
</h1>
|
||||
<p className="text-[var(--gem-text-muted)] text-xl">我今天可以帮你做什么运营工作?</p>
|
||||
</div>
|
||||
|
||||
{/* Suggestion cards */}
|
||||
<div className="grid grid-cols-2 gap-3 w-full max-w-2xl">
|
||||
{suggestions.map(({ icon: Icon, text, short }) => (
|
||||
<button
|
||||
key={short}
|
||||
onClick={() => onSuggestionClick(text)}
|
||||
className="flex flex-col gap-2 p-4 rounded-2xl border border-[var(--gem-border)] bg-[var(--gem-surface)] hover:bg-[var(--gem-surface-hover)] hover:border-[var(--gem-border-hover)] transition-all duration-150 text-left cursor-pointer group"
|
||||
aria-label={`Suggestion: ${text}`}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-[var(--gem-surface-2)] flex items-center justify-center group-hover:bg-[var(--gem-surface-3)] transition-colors">
|
||||
<Icon size={16} className="text-[var(--gem-text-muted)]" />
|
||||
</div>
|
||||
<span className="text-[var(--gem-text-secondary)] text-sm leading-snug line-clamp-2">{text}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckCircle2, XCircle, ChevronDown, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import type { TraceItem } from "@/lib/api";
|
||||
|
||||
// ── 用户友好的工具描述(用于摘要行,不是调试名) ──────────────────────
|
||||
const TOOL_LABEL: Record<string, string> = {
|
||||
kb_search: "内部知识库",
|
||||
web_search: "外部信息",
|
||||
ticket_list: "工单系统",
|
||||
ticket_detail: "工单系统",
|
||||
generate_document: "文档生成",
|
||||
sandbox_run: "代码执行",
|
||||
};
|
||||
|
||||
const TOOL_ICON: Record<string, string> = {
|
||||
kb_search: "🗂️",
|
||||
web_search: "🌐",
|
||||
ticket_list: "🎫",
|
||||
ticket_detail: "🎫",
|
||||
generate_document: "📄",
|
||||
sandbox_run: "⚙️",
|
||||
};
|
||||
|
||||
// ── 摘要行文案(用户语言) ────────────────────────────────────────────
|
||||
function buildSummary(items: TraceItem[]): { text: string; running: boolean } {
|
||||
const toolItems = items.filter((i) => i.tool);
|
||||
const running = toolItems.filter((i) => i.itemStatus === "running");
|
||||
|
||||
if (running.length > 0) {
|
||||
const last = running[running.length - 1];
|
||||
const label = TOOL_LABEL[last.tool!] ?? last.title;
|
||||
return { text: `正在调用 ${label}…`, running: true };
|
||||
}
|
||||
|
||||
const finished = toolItems.filter(
|
||||
(i) => i.type === "tool_end" || i.type === "tool_error"
|
||||
);
|
||||
if (finished.length === 0) return { text: "处理中…", running: false };
|
||||
|
||||
// 去重工具标签
|
||||
const labels = [...new Set(finished.map((i) => TOOL_LABEL[i.tool!] ?? i.title))];
|
||||
const hasError = finished.some((i) => i.type === "tool_error");
|
||||
const prefix = hasError ? "已调用(部分失败)" : "已调用";
|
||||
|
||||
if (labels.length === 1) return { text: `${prefix} ${labels[0]}`, running: false };
|
||||
if (labels.length === 2) return { text: `${prefix} ${labels[0]} 与 ${labels[1]}`, running: false };
|
||||
return { text: `已完成 ${labels.length} 项工具调用`, running: false };
|
||||
}
|
||||
|
||||
// ── 单条工具行 ────────────────────────────────────────────────────────
|
||||
function ToolRow({ item }: { item: TraceItem }) {
|
||||
const icon = TOOL_ICON[item.tool ?? ""] ?? "🔧";
|
||||
const isRunning = item.itemStatus === "running";
|
||||
const isError = item.itemStatus === "error";
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 py-1.5">
|
||||
{/* 状态图标 */}
|
||||
<div className="mt-0.5 w-3.5 flex justify-center shrink-0">
|
||||
{isRunning ? (
|
||||
<Loader2 size={12} className="animate-spin text-[var(--gem-accent,#4285f4)]" />
|
||||
) : isError ? (
|
||||
<XCircle size={12} className="text-red-400" />
|
||||
) : (
|
||||
<CheckCircle2 size={12} className="text-emerald-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* 工具名行 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px]">{icon}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
isError ? "text-red-400" : "text-[var(--gem-text)]"
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 入参摘要 */}
|
||||
{item.inputSummary && (
|
||||
<p className="text-[11px] text-[var(--gem-text-muted)] mt-0.5 truncate leading-relaxed">
|
||||
{item.inputSummary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 出参摘要 / 错误摘要 */}
|
||||
{(item.outputSummary || item.errorSummary) && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-[11px] mt-0.5 truncate leading-relaxed",
|
||||
item.errorSummary
|
||||
? "text-red-400"
|
||||
: "text-[var(--gem-text-secondary)]"
|
||||
)}
|
||||
>
|
||||
{item.errorSummary ?? item.outputSummary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 耗时(次级,右对齐,最不抢眼) */}
|
||||
{item.durationMs !== undefined && (
|
||||
<span className="text-[10px] text-[var(--gem-text-muted)] mt-0.5 shrink-0 tabular-nums">
|
||||
{item.durationMs < 1000
|
||||
? `${item.durationMs}ms`
|
||||
: `${(item.durationMs / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 主组件 ────────────────────────────────────────────────────────────
|
||||
interface TracePanelProps {
|
||||
items: TraceItem[];
|
||||
model?: "flash" | "auto" | "pro";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TracePanel({ items, className }: TracePanelProps) {
|
||||
const [open, setOpen] = useState(false); // 默认折叠,无论 Auto/Pro
|
||||
|
||||
// 只展示真实工具调用,过滤掉 status 节点
|
||||
const toolItems = items.filter((i) => i.tool);
|
||||
if (toolItems.length === 0) return null;
|
||||
|
||||
const { text, running } = buildSummary(items);
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className={cn("w-full", className)}>
|
||||
{/* 摘要触发行 */}
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1.5 text-xs rounded-md px-1.5 py-1",
|
||||
"text-[var(--gem-text-muted)] hover:text-[var(--gem-text)]",
|
||||
"hover:bg-[var(--gem-surface-2)] transition-colors cursor-pointer select-none"
|
||||
)}
|
||||
>
|
||||
{running ? (
|
||||
<Loader2
|
||||
size={11}
|
||||
className="shrink-0 animate-spin text-[var(--gem-accent,#4285f4)]"
|
||||
/>
|
||||
) : (
|
||||
<CheckCircle2 size={11} className="shrink-0 text-emerald-400" />
|
||||
)}
|
||||
<span className="flex-1 text-left">{text}</span>
|
||||
{open ? (
|
||||
<ChevronDown size={11} className="shrink-0 opacity-60" />
|
||||
) : (
|
||||
<ChevronRight size={11} className="shrink-0 opacity-60" />
|
||||
)}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{/* 展开后:真实工具 trace */}
|
||||
<CollapsibleContent>
|
||||
<div className="mt-1 ml-1 pl-3 border-l border-[var(--gem-border)] space-y-0">
|
||||
{toolItems.map((item) => (
|
||||
<ToolRow key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import {
|
||||
ThemeProvider as NextThemesProvider,
|
||||
type ThemeProviderProps,
|
||||
} from 'next-themes'
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion'
|
||||
import { ChevronDownIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn('border-b last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('pt-0 pb-4', className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
@@ -1,157 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: 'outline' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-card text-card-foreground',
|
||||
destructive:
|
||||
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -1,11 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'
|
||||
|
||||
function AspectRatio({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
|
||||
}
|
||||
|
||||
export { AspectRatio }
|
||||
@@ -1,53 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn('aspect-square size-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
'bg-muted flex size-full items-center justify-center rounded-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
@@ -1,46 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'span'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -1,109 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { ChevronRight, MoreHorizontal } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn('inline-flex items-center gap-1.5', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'a'> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'a'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn('hover:text-foreground transition-colors', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn('text-foreground font-normal', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('[&>svg]:size-3.5', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('flex size-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
|
||||
vertical:
|
||||
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: 'horizontal',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'div'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -1,213 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from 'lucide-react'
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = 'label',
|
||||
buttonVariant = 'ghost',
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>['variant']
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className,
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString('default', { month: 'short' }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn('w-fit', defaultClassNames.root),
|
||||
months: cn(
|
||||
'flex gap-4 flex-col md:flex-row relative',
|
||||
defaultClassNames.months,
|
||||
),
|
||||
month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
|
||||
nav: cn(
|
||||
'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
|
||||
defaultClassNames.nav,
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
|
||||
defaultClassNames.button_previous,
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
|
||||
defaultClassNames.button_next,
|
||||
),
|
||||
month_caption: cn(
|
||||
'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
|
||||
defaultClassNames.month_caption,
|
||||
),
|
||||
dropdowns: cn(
|
||||
'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
|
||||
defaultClassNames.dropdowns,
|
||||
),
|
||||
dropdown_root: cn(
|
||||
'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
|
||||
defaultClassNames.dropdown_root,
|
||||
),
|
||||
dropdown: cn(
|
||||
'absolute bg-popover inset-0 opacity-0',
|
||||
defaultClassNames.dropdown,
|
||||
),
|
||||
caption_label: cn(
|
||||
'select-none font-medium',
|
||||
captionLayout === 'label'
|
||||
? 'text-sm'
|
||||
: 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
|
||||
defaultClassNames.caption_label,
|
||||
),
|
||||
table: 'w-full border-collapse',
|
||||
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
|
||||
defaultClassNames.weekday,
|
||||
),
|
||||
week: cn('flex w-full mt-2', defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
'select-none w-(--cell-size)',
|
||||
defaultClassNames.week_number_header,
|
||||
),
|
||||
week_number: cn(
|
||||
'text-[0.8rem] select-none text-muted-foreground',
|
||||
defaultClassNames.week_number,
|
||||
),
|
||||
day: cn(
|
||||
'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
|
||||
defaultClassNames.day,
|
||||
),
|
||||
range_start: cn(
|
||||
'rounded-l-md bg-accent',
|
||||
defaultClassNames.range_start,
|
||||
),
|
||||
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
|
||||
today: cn(
|
||||
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
|
||||
defaultClassNames.today,
|
||||
),
|
||||
outside: cn(
|
||||
'text-muted-foreground aria-selected:text-muted-foreground',
|
||||
defaultClassNames.outside,
|
||||
),
|
||||
disabled: cn(
|
||||
'text-muted-foreground opacity-50',
|
||||
defaultClassNames.disabled,
|
||||
),
|
||||
hidden: cn('invisible', defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === 'left') {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn('size-4', className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === 'right') {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn('size-4', className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70',
|
||||
defaultClassNames.day,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
@@ -1,92 +0,0 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn('leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn('px-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from 'embla-carousel-react'
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useCarousel must be used within a <Carousel />')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = 'horizontal',
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === 'horizontal' ? 'x' : 'y',
|
||||
},
|
||||
plugins,
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return
|
||||
onSelect(api)
|
||||
api.on('reInit', onSelect)
|
||||
api.on('select', onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off('select', onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn('relative', className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex',
|
||||
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
'min-w-0 shrink-0 grow-0 basis-full',
|
||||
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = 'outline',
|
||||
size = 'icon',
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
'absolute size-8 rounded-full',
|
||||
orientation === 'horizontal'
|
||||
? 'top-1/2 -left-12 -translate-y-1/2'
|
||||
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = 'outline',
|
||||
size = 'icon',
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
'absolute size-8 rounded-full',
|
||||
orientation === 'horizontal'
|
||||
? 'top-1/2 -right-12 -translate-y-1/2'
|
||||
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as RechartsPrimitive from 'recharts'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: '', dark: '.dark' } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useChart must be used within a <ChartContainer />')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>['children']
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color,
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join('\n')}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join('\n'),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = 'dot',
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<'div'> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: 'line' | 'dot' | 'dashed'
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === 'string'
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn('font-medium', labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn('font-medium', labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== 'dot'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || 'value'}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
|
||||
indicator === 'dot' && 'items-center',
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
|
||||
{
|
||||
'h-2.5 w-2.5': indicator === 'dot',
|
||||
'w-1': indicator === 'line',
|
||||
'w-0 border-[1.5px] border-dashed bg-transparent':
|
||||
indicator === 'dashed',
|
||||
'my-0.5': nestLabel && indicator === 'dashed',
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--color-bg': indicatorColor,
|
||||
'--color-border': indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 justify-between leading-none',
|
||||
nestLabel ? 'items-end' : 'items-center',
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = 'bottom',
|
||||
nameKey,
|
||||
}: React.ComponentProps<'div'> &
|
||||
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-4',
|
||||
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || 'value'}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className="[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== 'object' || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
'payload' in payload &&
|
||||
typeof payload.payload === 'object' &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === 'string'
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||
import { CheckIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -1,33 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -1,184 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Command as CommandPrimitive } from 'cmdk'
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = 'Command Palette',
|
||||
description = 'Search for a command to run...',
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn('overflow-hidden p-0', className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn('bg-border -mx-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
'text-muted-foreground ml-auto text-xs tracking-widest',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: 'default' | 'destructive'
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
'text-muted-foreground ml-auto text-xs tracking-widest',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { XIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn('text-lg leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Drawer as DrawerPrimitive } from 'vaul'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col',
|
||||
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
|
||||
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
|
||||
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm',
|
||||
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
'flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn('text-foreground font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: 'default' | 'destructive'
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
'text-muted-foreground ml-auto text-xs tracking-widest',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn(
|
||||
'flex max-w-sm flex-col items-center gap-2 text-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
'flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn('text-lg font-medium tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
'flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
'flex flex-col gap-6',
|
||||
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = 'legend',
|
||||
...props
|
||||
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
'mb-3 font-medium',
|
||||
'data-[variant=legend]:text-base',
|
||||
'data-[variant=label]:text-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
'group/field flex w-full gap-3 data-[invalid=true]:text-destructive',
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'],
|
||||
horizontal: [
|
||||
'flex-row items-center',
|
||||
'[&>[data-slot=field-label]]:flex-auto',
|
||||
'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
|
||||
],
|
||||
responsive: [
|
||||
'flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto',
|
||||
'@md/field-group:[&>[data-slot=field-label]]:flex-auto',
|
||||
'@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: 'vertical',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
|
||||
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
|
||||
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
|
||||
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
|
||||
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (errors.length === 1 && errors[0]?.message) {
|
||||
return errors[0].message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{errors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>,
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn('text-destructive text-sm font-normal', className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue,
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error('useFormField should be used within <FormField>')
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue,
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn('grid gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn('data-[error=true]:text-destructive', className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? '') : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn('text-destructive text-sm', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
@@ -1,169 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
|
||||
'h-9 has-[>textarea]:h-auto',
|
||||
|
||||
// Variants based on alignment.
|
||||
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
|
||||
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
|
||||
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
|
||||
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
|
||||
|
||||
// Focus state.
|
||||
'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
|
||||
|
||||
// Error state.
|
||||
'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
|
||||
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
'inline-start':
|
||||
'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
|
||||
'inline-end':
|
||||
'order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]',
|
||||
'block-start':
|
||||
'order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5',
|
||||
'block-end':
|
||||
'order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: 'inline-start',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = 'inline-start',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('button')) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector('input')?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
'text-sm shadow-none flex gap-2 items-center',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||
sm: 'h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5',
|
||||
'icon-xs':
|
||||
'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
|
||||
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'xs',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = 'button',
|
||||
variant = 'ghost',
|
||||
size = 'xs',
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, 'size'> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'textarea'>) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { OTPInput, OTPInputContext } from 'input-otp'
|
||||
import { MinusIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn(
|
||||
'flex items-center gap-2 has-disabled:opacity-50',
|
||||
containerClassName,
|
||||
)}
|
||||
className={cn('disabled:cursor-not-allowed', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn('flex items-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
index: number
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext)
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||
@@ -1,21 +0,0 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -1,193 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn('group/item-group flex flex-col', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn('my-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
'group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a&]:hover:bg-accent/50 [a&]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
outline: 'border-border',
|
||||
muted: 'bg-muted/50',
|
||||
},
|
||||
size: {
|
||||
default: 'p-4 gap-4 ',
|
||||
sm: 'py-3 px-4 gap-2.5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> &
|
||||
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'div'
|
||||
return (
|
||||
<Comp
|
||||
data-slot="item"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(itemVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
'size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
'flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
'flex w-fit items-center gap-2 text-sm leading-snug font-medium',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
|
||||
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn('flex items-center gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
'flex basis-full items-center justify-between gap-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
'flex basis-full items-center justify-between gap-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={cn(
|
||||
'bg-muted w-fit text-muted-foreground pointer-events-none inline-flex h-5 min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none',
|
||||
"[&_svg:not([class*='size-'])]:size-3",
|
||||
'[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd-group"
|
||||
className={cn('inline-flex items-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup }
|
||||
@@ -1,24 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user