Compare commits
5 Commits
zhujw
...
28b85e1422
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28b85e1422 | ||
|
|
ccfba711e7 | ||
|
|
43f7cca0c2 | ||
|
|
cdea59af92 | ||
|
|
48c6f84239 |
116
API_Gateway_Rules.md
Normal file
116
API_Gateway_Rules.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Safe OS UI - API Gateway 路由转发规则文档
|
||||
|
||||
本系统包含多个独立的后端AI服务(规划系统、开发平台、质量门禁等)。为了使前端统一指向单一入口并简化跨域和请求分发配置,建议使用 API Gateway 进行统一路由和负载均衡。
|
||||
|
||||
以下是当前前端所有与后端服务交互的接口清单以及转发规则。Gateway 服务需要解析相应的路径前缀(Ingress Prefix),将请求 Rewrite 后转发至对应的内部后端服务。
|
||||
|
||||
---
|
||||
|
||||
## 整体架构与入口规则
|
||||
|
||||
- **统一入口 URL**:`http://<api-gateway-host>:<port>` (前端将所有 API 请求指向此地址)
|
||||
- **核心路由匹配策略**:基于 URL前缀 进行正则匹配或前缀匹配。
|
||||
|
||||
| 业务模块 | Ingress Prefix (外部路径) | Forward Target (内部服务地址) | Rewrite Rule (路径重写规则) |
|
||||
| --- | --- | --- | --- |
|
||||
| **Planning System** | `/planning-api/*` | `http://localhost:8090` | `/planning-api/(.*)` ➔ `/api/$1` |
|
||||
| **DevOps System** | `/devops-api/*` | `http://localhost:8000` | `/devops-api/(.*)` ➔ `/$1` |
|
||||
| **Quality Gate System** | `/quality-api/*` | `http://localhost:5000` | `/quality-api/(.*)` ➔ `/api/$1` |
|
||||
|
||||
---
|
||||
|
||||
## 1. 规划子系统接口 (Planning System)
|
||||
**底层服务**: `http://localhost:8090`
|
||||
前缀重写规则:请求至统一入口 `.../planning-api/X` 会被转发至内部的 `.../api/X`。
|
||||
|
||||
| 方法 | 外部暴露路径 (Gateway URL) | 内部转发路径 (Target URL) | 接口描述 |
|
||||
| --- | --- | --- | --- |
|
||||
| `POST` | `/planning-api/chat/stream` | `/api/chat/stream` | 铁三角 Agent:发送大系统规划/史诗业务需求内容(SSE流式数据返回) |
|
||||
| `POST` | `/planning-api/upload` | `/api/upload` | 文档上传:支持用户上传附件作为系统知识或输入来源 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 研发自动引擎接口 (DevOps System)
|
||||
**底层服务**: `http://localhost:8000`
|
||||
前缀重写规则:请求至统一入口 `.../devops-api/X` 会被去掉前缀,转为后端的 `.../X`。该系统重度依赖**SSE (Server-Sent Events) 流式连接**,请在Gateway配置中确保不会缓冲或阻断流数据。
|
||||
|
||||
| 方法 | 外部暴露路径 (Gateway URL) | 内部转发路径 (Target URL) | 接口描述 |
|
||||
| --- | --- | --- | --- |
|
||||
| `POST` | `/devops-api/session/start` | `/session/start` | 新建研发自动化任务会话 (Session) |
|
||||
| `POST` | `/devops-api/session/{sessionId}/clarify` | `/session/{sessionId}/clarify` | 基于需求说明进行需求澄清和补充 |
|
||||
| `GET` | `/devops-api/session/{sessionId}/pm/stream` | `/session/{sessionId}/pm/stream` | PM角色执行需求细化分析流程 (SSE流) |
|
||||
| `GET` | `/devops-api/session/{sessionId}/pm/refine/stream` | `/session/{sessionId}/pm/refine/stream` | 追加PM反馈,继续流式提炼需求 (SSE流) |
|
||||
| `GET` | `/devops-api/session/{sessionId}/qa/stream` | `/session/{sessionId}/qa/stream` | QA角色基于需求生成测试用例并执行评估 (SSE流) |
|
||||
| `GET` | `/devops-api/session/{sessionId}/qa/refine/stream` | `/session/{sessionId}/qa/refine/stream` | 追加QA用例反馈并更新测试集 (SSE流) |
|
||||
| `GET` | `/devops-api/session/{sessionId}/dev/stream` | `/session/{sessionId}/dev/stream` | 核心编码过程:依据需求、架构与测试生成最终业务/Java等工程代码 (SSE流) |
|
||||
| `POST` | `/devops-api/session/{sessionId}/test/run` | `/session/{sessionId}/test/run` | 运行集成编译与单元/端到端测试并获取结果 |
|
||||
| `GET` | `/devops-api/session/{sessionId}/test/fix/stream` | `/session/{sessionId}/test/fix/stream` | 针对测试错误进行的AI代码自动修复 (SSE流) |
|
||||
|
||||
*注意: `{sessionId}` 属于路径层级中的动态参数,具体网关转发时使用泛类型或通配符放行。*
|
||||
|
||||
---
|
||||
|
||||
## 3. 代码质量门禁接口 (Quality Gate)
|
||||
**底层服务**: `http://localhost:5000`
|
||||
前缀重写规则:请求至统一入口 `.../quality-api/X` 会被转发至后端内部的 `.../api/X`。
|
||||
|
||||
| 方法 | 外部暴露路径 (Gateway URL) | 内部转发路径 (Target URL) | 接口描述 |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET` | `/quality-api/prs` | `/api/prs` | 获取 PR 扫描工单列表(支持多种查询参数) |
|
||||
| `GET` | `/quality-api/prs/history` | `/api/prs/history` | 获取 PR 处理历史,用于趋势看板 (e.g., `?limit=15`) |
|
||||
| `GET` | `/quality-api/prs/{prId}` | `/api/prs/{prId}` | 获取某一条指定 PR 扫描记录的详细执行状态及摘要 |
|
||||
| `GET` | `/quality-api/prs/{prId}/files` | `/api/prs/{prId}/files` | 拉取这条 PR 的被影响/改动的文件目录结构 |
|
||||
| `GET` | `/quality-api/prs/{prId}/file` | `/api/prs/{prId}/file` | 获取PR里指定文件的 Diff 对象(包含行内审查反馈),附带 `?path=xxx` 查询参数 |
|
||||
| `POST`| `/quality-api/prs/{prId}/merge` | `/api/prs/{prId}/merge` | 在门禁界面确认问题修缮无误后,合并该次 PR |
|
||||
| `POST`| `/quality-api/prs/{prId}/close` | `/api/prs/{prId}/close` | 门禁审查不通过,拒绝/关闭此条扫描和合并申请 |
|
||||
|
||||
---
|
||||
|
||||
## Nginx Gateway 配置示例 (参考)
|
||||
|
||||
如果网关选用 Nginx,可快速参考如下配置来完成上述 Rewrite 与 Proxy:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name api-gateway.safe-os.local;
|
||||
|
||||
# 1. Planning API 转发
|
||||
location /planning-api/ {
|
||||
# 截取 /planning-api/ 后的内容,拼接到 /api/
|
||||
rewrite ^/planning-api/(.*)$ /api/$1 break;
|
||||
proxy_pass http://localhost:8090;
|
||||
|
||||
# 启用流连接所需的头信息
|
||||
proxy_set_header Connection '';
|
||||
proxy_http_version 1.1;
|
||||
chunked_transfer_encoding off;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
# 2. DevOps API 转发
|
||||
location /devops-api/ {
|
||||
# 截取 /devops-api/ 后的内容,直接拼接
|
||||
rewrite ^/devops-api/(.*)$ /$1 break;
|
||||
proxy_pass http://localhost:8000;
|
||||
|
||||
# 支撑 SSE (Server-Sent Events) 的流配置
|
||||
proxy_set_header Connection '';
|
||||
proxy_http_version 1.1;
|
||||
chunked_transfer_encoding off;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 3600s; # 考虑大模型生成代码时间较长
|
||||
}
|
||||
|
||||
# 3. Quality Gate API 转发
|
||||
location /quality-api/ {
|
||||
# 截取 /quality-api/ 后的内容,拼接到 /api/
|
||||
rewrite ^/quality-api/(.*)$ /api/$1 break;
|
||||
proxy_pass http://localhost:5000;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
以上文档即针对前端当前服务现状所整理的 API Gateway 路由规划规范,您可以直接根据此规范开发 API Gateway 或调整 Nginx 等负载均衡器的配置。
|
||||
140
Artifact_SplitScreen_Design.md
Normal file
140
Artifact_SplitScreen_Design.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# Vibe Coding Design Docs: Workspace/Artifact Split-Screen Pattern
|
||||
|
||||
## 1. Context & Objective
|
||||
The goal is to implement a UI/UX pattern similar to **Claude Artifacts** or **Gemini Deep Research**. When a specific complex task is triggered (e.g., "Project Planning Skill"), the single-column chat interface should smoothly transition into a split-screen layout:
|
||||
- **Left Panel (35%)**: Conversational context, CoT (Chain of Thought) traces, tool calls, and user input.
|
||||
- **Right Panel (65%)**: A dedicated "Workspace" or "Artifact" rendering area to display long-form content (Markdown, code, diagrams) generated by the Agent's skills.
|
||||
|
||||
Crucially, this system must support **Reflexion/Iterative generation**. The user can comment on the generated artifact in the left panel, and the agent should update the artifact in the right panel based on the feedback.
|
||||
|
||||
---
|
||||
|
||||
## 2. Frontend Implementation Guide (React + Vite + Tailwind)
|
||||
|
||||
### 2.1 State Management (State & Types)
|
||||
Extend the existing frontend state to track the workspace status and content.
|
||||
|
||||
```typescript
|
||||
// 1. Extend the StreamEvent type to support UI control and artifact streaming
|
||||
type StreamEvent = {
|
||||
type:
|
||||
| "thought"
|
||||
| "tool_call"
|
||||
| "tool_result"
|
||||
| "message" // Standard chat message
|
||||
| "error"
|
||||
| "workspace_start" // Trigger right panel open
|
||||
| "workspace_delta" // Streaming text for the right panel
|
||||
| "workspace_end"; // Streaming completed
|
||||
content: string;
|
||||
step?: number;
|
||||
tool_name?: string;
|
||||
workspace_title?: string; // Optional title for the artifact
|
||||
};
|
||||
|
||||
// 2. Add Workspace State (Can be added to useReducer or a separate useState)
|
||||
type WorkspaceState = {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
content: string;
|
||||
isGenerating: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
### 2.2 SSE Parsing Logic
|
||||
Modify the `onEvent` handler inside `streamChat` to intercept `workspace_*` events.
|
||||
- When `workspace_start` arrives: Set `workspace.isOpen = true`, clear previous content, set `isGenerating = true`.
|
||||
- When `workspace_delta` arrives: Append text to `workspace.content`. Do **not** append this text to the left-panel chat history to avoid redundancy.
|
||||
- When `workspace_end` arrives: Set `isGenerating = false`.
|
||||
|
||||
### 2.3 Layout & UI Re-architecture
|
||||
Refactor the root `<div>` of `PlanningAgent.tsx` to handle dynamic flex layouts. Use Tailwind's transition utilities for smooth scaling.
|
||||
|
||||
```tsx
|
||||
<div className="flex h-full w-full overflow-hidden bg-surface">
|
||||
|
||||
{/* Left Panel: Chat & Controls */}
|
||||
<div
|
||||
className={`flex flex-col h-full transition-all duration-300 ease-in-out ${
|
||||
workspace.isOpen ? 'w-[35%] border-r border-border min-w-[350px]' : 'w-full max-w-5xl mx-auto'
|
||||
}`}
|
||||
>
|
||||
{/* Existing Message List & Input Area */}
|
||||
</div>
|
||||
|
||||
{/* Right Panel: Workspace / Deep Research Output */}
|
||||
{workspace.isOpen && (
|
||||
<div className="w-[65%] h-full flex flex-col bg-surface-muted transition-opacity duration-300 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="h-14 border-b border-border flex items-center px-6 justify-between bg-white">
|
||||
<h3 className="font-semibold text-txt flex items-center gap-2">
|
||||
<IconDocument /> {workspace.title || 'Project Planning Document'}
|
||||
</h3>
|
||||
{workspace.isGenerating && (
|
||||
<span className="text-sm text-magenta animate-pulse flex items-center gap-1">
|
||||
<Spinner /> Generating...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Markdown Content Area */}
|
||||
<div className="flex-1 overflow-y-auto p-8 prose prose-slate max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
{workspace.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend Implementation Guide (Agent / ReAct Loop)
|
||||
|
||||
The backend agent requires structural changes to understand the "Artifact" concept, emit correct SSE events, and maintain the artifact in its memory for iterative edits.
|
||||
|
||||
### 3.1 Tool / Skill Definition
|
||||
When defining the `Project Planning Skill` for the LLM, clearly state its output behavior so the LLM knows *when* to use it.
|
||||
- **Tool Description**: `use_planning_workspace`: "Invoke this tool to generate, structure, or update a major project planning document. The output will be rendered in a dedicated UI workspace."
|
||||
|
||||
### 3.2 Context Injection (Memory for Reflexion)
|
||||
To allow the user to say *"extend the testing phase to 2 weeks"*, the LLM **must know what is currently in the right panel**.
|
||||
- **Before sending the prompt to the LLM**, query the database/session for the current Artifact state.
|
||||
- **Prompt Assembly**:
|
||||
```text
|
||||
[System Prompt / ReAct Instructions]
|
||||
...
|
||||
|
||||
[Current Workspace Artifact (if exists)]
|
||||
<workspace>
|
||||
# Project Plan
|
||||
1. Dev Phase: 1 week
|
||||
2. Testing Phase: 1 week
|
||||
</workspace>
|
||||
|
||||
[Chat History]
|
||||
User: extend the testing phase to 2 weeks.
|
||||
```
|
||||
|
||||
### 3.3 Streaming Control (Hijacking the Stream)
|
||||
Within the ReAct execution loop, when the Agent decides to execute the `Project Planning Skill`:
|
||||
1. The Backend normally streams `thought` or `tool_call` events.
|
||||
2. Upon entering the specific Skill execution, the backend emits `{"type": "workspace_start", "workspace_title": "Update: Project Plan"}`.
|
||||
3. As the LLM (or a sub-agent) generates the Markdown schema, the backend maps these tokens to `workspace_delta` events and flushes them to the frontend.
|
||||
4. (CRITICAL) Do **not** send these tokens as `message` or `final` chat events. The chat bubble should only say something like: *"I have updated the project plan in the workspace area."*
|
||||
5. Save the final generated Markdown text into the session memory as the `Current Artifact` for future context injection.
|
||||
|
||||
---
|
||||
|
||||
## 4. Work Flow Summary (For LLM context generation)
|
||||
|
||||
1. `User` sends prompt: "Plan the new feature".
|
||||
2. `Agent` thinks (`type: thought`), decides to use `Project Planning Skill` (`type: tool_call`).
|
||||
3. `Agent` emits `{"type": "workspace_start"}`.
|
||||
4. `Frontend` expands right panel (65% width).
|
||||
5. `Agent` streams `{"type": "workspace_delta", "content": "..."}`.
|
||||
6. `Frontend` live-renders Markdown in the right panel.
|
||||
7. `Agent` finishes, saves artifact to backend session.
|
||||
8. `User` reads right panel, types in left panel: "Change point 2".
|
||||
9. `Agent` receives Left Panel history + Right Panel Artifact Content.
|
||||
10. `Agent` updates document, streaming new `workspace_delta`. Frontend live-updates the right panel.
|
||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
# Stage 1: Build
|
||||
FROM node:20-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve with Nginx
|
||||
FROM nginx:alpine
|
||||
# Copy build output to nginx folder
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
# Custom nginx config to handle SPA routing if needed
|
||||
RUN echo 'server { \
|
||||
listen 80; \
|
||||
location / { \
|
||||
root /usr/share/nginx/html; \
|
||||
index index.html; \
|
||||
try_files $uri $uri/ /index.html; \
|
||||
} \
|
||||
}' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
29
build_and_push.sh
Executable file
29
build_and_push.sh
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Configuration
|
||||
REGISTRY="dcr-by1jwyxk44.71826370.xyz"
|
||||
IMAGE_NAME="safe-os-ui"
|
||||
TAG="latest"
|
||||
FULL_IMAGE_NAME="$REGISTRY/$IMAGE_NAME:$TAG"
|
||||
|
||||
echo "Step 1: Building Docker image..."
|
||||
docker build -t $IMAGE_NAME:$TAG .
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Docker build failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Step 2: Tagging image for registry..."
|
||||
docker tag $IMAGE_NAME:$TAG $FULL_IMAGE_NAME
|
||||
|
||||
echo "Step 3: Pushing image to $REGISTRY..."
|
||||
# Note: You might need to run 'docker login $REGISTRY' beforehand
|
||||
docker push $FULL_IMAGE_NAME
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Success: Image has been pushed to $FULL_IMAGE_NAME"
|
||||
else
|
||||
echo "Error: Docker push failed."
|
||||
exit 1
|
||||
fi
|
||||
45
deploy/docker-compose.yml
Normal file
45
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,45 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Frontend Service
|
||||
safe-os-ui:
|
||||
image: dcr-by1jwyxk44.71826370.xyz/safe-os-ui:latest
|
||||
container_name: safe-os-ui
|
||||
restart: always
|
||||
networks:
|
||||
- npm_network
|
||||
|
||||
# Backend: Planning Agent
|
||||
planning-agent:
|
||||
image: dcr-by1jwyxk44.71826370.xyz/planning-agent:latest
|
||||
container_name: planning-agent
|
||||
restart: always
|
||||
environment:
|
||||
- PORT=8090
|
||||
networks:
|
||||
- npm_network
|
||||
|
||||
# Backend: DevOps Agent
|
||||
devops-agent:
|
||||
image: dcr-by1jwyxk44.71826370.xyz/devops-agent:latest
|
||||
container_name: devops-agent
|
||||
restart: always
|
||||
environment:
|
||||
- PORT=8000
|
||||
networks:
|
||||
- npm_network
|
||||
|
||||
# Backend: Quality Gate
|
||||
quality-gate:
|
||||
image: dcr-by1jwyxk44.71826370.xyz/quality-gate:latest
|
||||
container_name: quality-gate
|
||||
restart: always
|
||||
environment:
|
||||
- PORT=5000
|
||||
networks:
|
||||
- npm_network
|
||||
|
||||
networks:
|
||||
npm_network:
|
||||
external: true
|
||||
name: proxy-net # 已更新为实际的 Docker 网络名称
|
||||
@@ -3,7 +3,7 @@ import Layout from "./Layout";
|
||||
import PlanningAgent from "./pages/PlanningAgent";
|
||||
import DevOpsAgent from "./pages/DevOpsAgent";
|
||||
import QualityGate from "./pages/QualityGate";
|
||||
import TaskExecutor from "./pages/TaskExecutor";
|
||||
|
||||
import Home from "./pages/Home";
|
||||
|
||||
export default function App() {
|
||||
@@ -18,7 +18,6 @@ export default function App() {
|
||||
<Route path="/quality/dashboard" element={<QualityGate view="dashboard" />} />
|
||||
<Route path="/quality/pr-list" element={<QualityGate view="pr-list" />} />
|
||||
<Route path="/quality/settings" element={<QualityGate view="settings" />} />
|
||||
<Route path="/task" element={<TaskExecutor />} />
|
||||
<Route path="*" element={<Navigate to="/planning" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -9,7 +9,6 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
"/quality/dashboard": "质量门控 (Quality Gate Dashboard)",
|
||||
"/quality/pr-list": "合并请求审查 (PR List)",
|
||||
"/quality/settings": "质量设置",
|
||||
"/task": "任务执行器 (Task Executor)",
|
||||
};
|
||||
|
||||
export default function Layout() {
|
||||
@@ -20,7 +19,15 @@ export default function Layout() {
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<div className="bg-[#f7f7f9] flex h-screen w-screen overflow-hidden text-txt font-sans">
|
||||
<div className="bg-[#f7f7f9] flex h-screen w-screen overflow-hidden text-txt font-sans relative">
|
||||
{/* Demo Badge */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none z-50 select-none opacity-[0.08] flex flex-col items-center">
|
||||
<div className="border-[8px] border-magenta px-10 py-4 transform -rotate-12 flex flex-col items-center">
|
||||
<span className="text-8xl font-black text-magenta tracking-widest">DEMO ONLY</span>
|
||||
<span className="text-3xl font-bold text-magenta mt-2">TECHNICAL SHARING 2026</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 侧边栏导航 */}
|
||||
<aside className="w-64 bg-white border-r border-border flex flex-col z-10 shrink-0">
|
||||
<div className="h-16 flex items-center px-6 border-b border-border">
|
||||
@@ -83,19 +90,6 @@ export default function Layout() {
|
||||
<i className="fa-solid fa-shield-halved w-4 text-center shrink-0"></i>
|
||||
<span>质量门控</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/task"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-semibold transition-all duration-150 ${
|
||||
isActive
|
||||
? "bg-orange-500/10 text-orange-500"
|
||||
: "text-txt hover:bg-surface-muted hover:text-orange-500"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<i className="fa-solid fa-list-check w-4 text-center shrink-0"></i>
|
||||
<span>任务执行器</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
<div className="text-[10px] font-bold text-txt-muted uppercase tracking-widest mt-8 mb-3 px-1">设置与支持</div>
|
||||
@@ -116,6 +110,12 @@ export default function Layout() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Demo Badge */}
|
||||
<div className="mx-4 my-4 p-3 border-2 border-dashed border-magenta/20 rounded-xl flex flex-col items-center justify-center text-center bg-magenta/5 rotate-1">
|
||||
<p className="text-[10px] font-black text-magenta uppercase tracking-tighter opacity-70">Tech Sharing</p>
|
||||
<p className="text-[13px] font-black text-magenta -mt-1 uppercase tracking-[0.1em] italic">DEMO ONLY</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 主内容区 */}
|
||||
|
||||
@@ -12,7 +12,4 @@ export const API = {
|
||||
|
||||
/** 质量门禁 Agent — PR scanning & code review */
|
||||
quality: "/quality-api",
|
||||
|
||||
/** SDLC Task Executor — multi-agent software delivery */
|
||||
sdlc: "/api/v1/sdlc",
|
||||
} as const;
|
||||
|
||||
197
src/doc/WebUI_Stream_API_前端对接说明.md
Normal file
197
src/doc/WebUI_Stream_API_前端对接说明.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# WebUI `/api/chat/stream` 前端对接说明
|
||||
|
||||
本文档用于指导前端项目接入 LaodingBot 的流式聊天接口,并可直接作为提示词输入给 LLM,批量改造其他前端代码。
|
||||
|
||||
## 1. 接口总览
|
||||
|
||||
- 方法: `POST`
|
||||
- 路径: `/api/chat/stream`
|
||||
- 请求头: `Content-Type: application/json`
|
||||
- 响应类型: `text/event-stream`
|
||||
- 协议: SSE (Server-Sent Events)
|
||||
|
||||
说明: 该接口为单次请求、多次推送。后端会持续推送 `data: <json>\n\n` 格式的事件。
|
||||
|
||||
## 2. 请求体
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "请帮我分析当前目录",
|
||||
"session_id": "sess_abc",
|
||||
"user_id": "user_001"
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
- `text` (string, required): 用户输入文本,去除空白后不能为空。
|
||||
- `session_id` (string, optional): 会话 ID,不传时后端自动生成。
|
||||
- `user_id` (string, optional): 用户 ID,不传时后端自动生成。
|
||||
|
||||
兼容字段:
|
||||
|
||||
- `sessionId` 等价于 `session_id`
|
||||
- `userId` 等价于 `user_id`
|
||||
|
||||
## 3. SSE 事件格式
|
||||
|
||||
每条 SSE 消息只包含 `data` 字段,内容是 JSON:
|
||||
|
||||
```text
|
||||
data: {"type":"thought","content":"我先判断是否需要调用工具","step":1}
|
||||
|
||||
data: {"type":"tool_call","content":"{\"input\":\"pwd\"}","step":1,"tool_name":"shell"}
|
||||
|
||||
data: {"type":"tool_result","content":"C:/Project/MyProject","step":1,"tool_name":"shell"}
|
||||
|
||||
data: {"type":"final","content":"当前目录是 C:/Project/MyProject","step":2}
|
||||
|
||||
```
|
||||
|
||||
事件字段:
|
||||
|
||||
- `type` (string): 事件类型
|
||||
- `content` (string): 事件文本内容
|
||||
- `step` (number, optional): ReAct 步骤编号
|
||||
- `tool_name` (string, optional): 工具名
|
||||
|
||||
事件类型:
|
||||
|
||||
- `thought`: LLM 思考片段(可选透传)
|
||||
- `tool_call`: 工具调用请求(可选透传)
|
||||
- `tool_result`: 工具执行结果(可选透传)
|
||||
- `final`: 最终回答
|
||||
- `error`: 错误信息
|
||||
|
||||
说明:
|
||||
|
||||
- 默认情况下(`WEBUI_EXPOSE_REASONING=false`),WebUI 只向前端返回 `final` 和 `error`。
|
||||
- 当设置 `WEBUI_EXPOSE_REASONING=true` 时,WebUI 会额外透传 `thought`、`tool_call`、`tool_result` 事件。
|
||||
- 无论是否透传推理事件,`final` 都会分段累计推送,以便前端实现打字机效果。
|
||||
|
||||
## 4. 连接生命周期
|
||||
|
||||
- 正常结束: 收到 `type=final` 后结束渲染,连接可由浏览器自然关闭。
|
||||
- 异常结束: 收到 `type=error`,前端应显示错误并结束当前轮次。
|
||||
- 网络中断: 前端应允许用户重试,并保留已收到的事件记录。
|
||||
|
||||
## 5. 前端渲染建议
|
||||
|
||||
推荐前端仅处理两类结果:
|
||||
|
||||
- 答案区: 显示最后一个 `final`
|
||||
- 错误区: 显示 `error`
|
||||
|
||||
如果开启了 `WEBUI_EXPOSE_REASONING=true`,建议额外提供“思考面板”:
|
||||
|
||||
- 思考区: 渲染 `thought`
|
||||
- 工具区: 渲染 `tool_call` / `tool_result`
|
||||
|
||||
建议状态机:
|
||||
|
||||
- `idle`: 初始状态
|
||||
- `streaming`: 请求中且持续接收事件
|
||||
- `done`: 收到 `final`
|
||||
- `failed`: 收到 `error` 或请求异常
|
||||
|
||||
## 6. TypeScript 对接示例 (fetch + ReadableStream)
|
||||
|
||||
```ts
|
||||
type StreamEventType = 'thought' | 'tool_call' | 'tool_result' | 'final' | 'error';
|
||||
|
||||
interface StreamEvent {
|
||||
type: StreamEventType;
|
||||
content: string;
|
||||
step?: number;
|
||||
tool_name?: string;
|
||||
}
|
||||
|
||||
export async function streamChat(
|
||||
payload: { text: string; session_id?: string; user_id?: string },
|
||||
onEvent: (event: StreamEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const resp = await fetch('/api/chat/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP ${resp.status}`);
|
||||
}
|
||||
if (!resp.body) {
|
||||
throw new Error('ReadableStream is not available');
|
||||
}
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// SSE message delimiter: blank line
|
||||
let idx = buffer.indexOf('\n\n');
|
||||
while (idx >= 0) {
|
||||
const chunk = buffer.slice(0, idx).trim();
|
||||
buffer = buffer.slice(idx + 2);
|
||||
|
||||
for (const line of chunk.split('\n')) {
|
||||
const text = line.trim();
|
||||
if (!text.startsWith('data:')) continue;
|
||||
const raw = text.slice(5).trim();
|
||||
if (!raw) continue;
|
||||
|
||||
const event = JSON.parse(raw) as StreamEvent;
|
||||
onEvent(event);
|
||||
}
|
||||
|
||||
idx = buffer.indexOf('\n\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 给 LLM 的改造任务提示词模板
|
||||
|
||||
将下面模板发给 LLM,可用于自动改造其他前端项目:
|
||||
|
||||
```text
|
||||
你要改造一个前端项目的聊天页面,把非流式接口 `/api/chat` 改为流式接口 `/api/chat/stream`。
|
||||
|
||||
后端协议约束:
|
||||
1) 请求方法 POST,Content-Type=application/json
|
||||
2) 请求体: { text, session_id?, user_id? }
|
||||
3) 响应是 SSE 文本流,事件格式为 `data: <json>\n\n`
|
||||
4) JSON 结构:
|
||||
- type: thought | tool_call | tool_result | final | error
|
||||
- content: string
|
||||
- step?: number
|
||||
- tool_name?: string
|
||||
5) 收到 final 视为本轮完成;收到 error 视为失败
|
||||
6) 默认不要假设前端一定会收到 thought/tool_call/tool_result;仅当 `WEBUI_EXPOSE_REASONING=true` 才会透传
|
||||
|
||||
你的改造要求:
|
||||
1) 保留现有 UI 风格和组件结构,不做无关重构
|
||||
2) 新增流式读取逻辑,支持中途取消 (AbortController)
|
||||
3) 将事件按 step 渲染到消息区
|
||||
4) 兼容旧会话字段命名 (session_id / sessionId, user_id / userId)
|
||||
5) 增加错误态与重试按钮
|
||||
6) 不破坏原有上传、历史消息和输入框行为
|
||||
7) 输出改动文件列表 + 每个文件的关键变更说明
|
||||
|
||||
请直接给出可运行代码补丁。
|
||||
```
|
||||
|
||||
## 8. 调试清单
|
||||
|
||||
- 检查响应头是否为 `text/event-stream`
|
||||
- 检查每条事件是否以 `data:` 开头并以空行结尾
|
||||
- 确认 `final` 和 `error` 都能正确结束当前轮次
|
||||
- 验证弱网下不会丢失已收到的事件
|
||||
- 验证用户快速连续提问时,旧流可被取消
|
||||
@@ -1,6 +1,26 @@
|
||||
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
|
||||
import { API } from "../config";
|
||||
|
||||
/* ─── Steps & Labels ─── */
|
||||
const STEPS = [
|
||||
{ title: "需求", icon: "💬" },
|
||||
{ title: "产品分析", icon: "📋" },
|
||||
{ title: "测试用例", icon: "🧪" },
|
||||
{ title: "开发代码", icon: "💻" },
|
||||
{ title: "测试执行", icon: "▶" },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
clarifying: "澄清中",
|
||||
pm_ready: "就绪",
|
||||
pm_done: "产品分析完成",
|
||||
qa_ready: "产品分析完成",
|
||||
qa_done: "QA 完成",
|
||||
dev_ready: "QA 完成",
|
||||
dev_done: "代码就绪",
|
||||
test_done: "测试完成",
|
||||
};
|
||||
|
||||
/* ─── Types ─── */
|
||||
type Step = 0 | 1 | 2 | 3 | 4;
|
||||
type ClarifyMsg = { role: "user" | "assistant"; content: string };
|
||||
@@ -200,13 +220,6 @@ function parseDevStream(text: string): DevStreamResult {
|
||||
}
|
||||
|
||||
/* ── 步骤标签 ── */
|
||||
const STEPS = [
|
||||
{ title: "Requirements", icon: "💬" },
|
||||
{ title: "PM Analysis", icon: "📋" },
|
||||
{ title: "QA Cases", icon: "🧪" },
|
||||
{ title: "Dev Code", icon: "💻" },
|
||||
{ title: "Test Run", icon: "▶" },
|
||||
];
|
||||
|
||||
const STATUS_STEP: Record<string, number> = {
|
||||
clarifying: 0, pm_ready: 0,
|
||||
@@ -214,13 +227,7 @@ const STATUS_STEP: Record<string, number> = {
|
||||
qa_done: 2, dev_ready: 2,
|
||||
dev_done: 3, test_done: 4,
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
clarifying: "Clarifying", pm_ready: "Ready",
|
||||
pm_done: "PM Done", qa_ready: "PM Done",
|
||||
qa_done: "QA Done", dev_ready: "QA Done",
|
||||
dev_done: "Code Ready", test_done: "Tests Done",
|
||||
};
|
||||
|
||||
|
||||
const TYPE_EMOJI: Record<string, string> = {
|
||||
"Functional": "🧩", "Performance": "⚡", "Security": "🔒",
|
||||
@@ -228,9 +235,12 @@ const TYPE_EMOJI: Record<string, string> = {
|
||||
};
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
"功能测试": "Functional", "性能测试": "Performance",
|
||||
"安全测试": "Security", "边界测试": "Boundary",
|
||||
"异常测试": "Exception", "集成测试": "Integration",
|
||||
"Functional": "功能测试", "Performance": "性能测试",
|
||||
"Security": "安全测试", "Boundary": "边界测试",
|
||||
"Exception": "异常测试", "Integration": "集成测试",
|
||||
"功能测试": "功能测试", "性能测试": "性能测试",
|
||||
"安全测试": "安全测试", "边界测试": "边界测试",
|
||||
"异常测试": "异常测试", "集成测试": "集成测试",
|
||||
};
|
||||
|
||||
/* ━━━━━━━━━━━━━━━━━━━━━ Component ━━━━━━━━━━━━━━━━━━━━━ */
|
||||
@@ -548,22 +558,22 @@ export default function DevOpsAgent() {
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="flex items-center justify-center w-10 h-10 text-xl rounded-2xl bg-magenta/10 shrink-0">📋</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-txt mb-0.5">Enter Requirements</h2>
|
||||
<p className="text-sm text-txt-muted">Describe your product requirements. AI will complete analysis → test cases → code generation automatically.</p>
|
||||
<h2 className="text-base font-bold text-txt mb-0.5">输入需求</h2>
|
||||
<p className="text-sm text-txt-muted">描述你的产品需求,AI 会自动完成分析 → 用例 → 代码生成。</p>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
className="input-field min-h-[160px] resize-y mb-5"
|
||||
value={requirement}
|
||||
onChange={(e) => setRequirement(e.target.value)}
|
||||
placeholder="e.g. Implement a user login feature supporting username/password, log all login events, target 1000 QPS concurrency…"
|
||||
placeholder="例如:实现用户登录,支持用户名/密码登录并记录登录事件,目标并发 1000 QPS…"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-txt-muted">{requirement.length > 0 ? `${requirement.length} chars` : ""}</span>
|
||||
<button className="btn-magenta" onClick={handleStart} disabled={loading || !requirement.trim()}>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2"><span className="w-3.5 h-3.5 border-2 border-white/40 border-t-white rounded-full animate-spin inline-block"></span>Analyzing…</span>
|
||||
) : "Start AI Analysis →"}
|
||||
<span className="flex items-center gap-2"><span className="w-3.5 h-3.5 border-2 border-white/40 border-t-white rounded-full animate-spin inline-block"></span>分析中…</span>
|
||||
) : "开始 AI 分析 →"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -578,7 +588,7 @@ export default function DevOpsAgent() {
|
||||
</div>
|
||||
|
||||
<div className="p-4 mb-5 text-sm bg-[#f9f9f9] border border-border rounded-xl">
|
||||
<span className="block mb-1.5 text-[11px] font-bold text-txt-muted uppercase tracking-wide">Original Requirement</span>
|
||||
<span className="block mb-1.5 text-[11px] font-bold text-txt-muted uppercase tracking-wide">原始需求</span>
|
||||
<p className="leading-relaxed text-txt">{rawRequirement}</p>
|
||||
</div>
|
||||
|
||||
@@ -604,17 +614,17 @@ export default function DevOpsAgent() {
|
||||
|
||||
{status === "clarifying" && (
|
||||
<div className="pt-4 mt-2 border-t border-border">
|
||||
<p className="mb-2 text-xs font-semibold tracking-wide uppercase text-txt-muted">Reply to AI</p>
|
||||
<p className="mb-2 text-xs font-semibold tracking-wide uppercase text-txt-muted">回复 AI</p>
|
||||
<textarea
|
||||
className="input-field min-h-[80px] resize-y mb-3"
|
||||
value={clarifyInput}
|
||||
onChange={(e) => setClarifyInput(e.target.value)}
|
||||
placeholder="Enter your additional details…"
|
||||
placeholder="输入补充说明…"
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button className="btn-magenta" onClick={handleClarify} disabled={loading}>
|
||||
{loading ? <span className="flex items-center gap-2"><span className="w-3.5 h-3.5 border-2 border-white/40 border-t-white rounded-full animate-spin inline-block"></span>Sending…</span> : "Send"}
|
||||
{loading ? <span className="flex items-center gap-2"><span className="w-3.5 h-3.5 border-2 border-white/40 border-t-white rounded-full animate-spin inline-block"></span>发送中…</span> : "发送"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -623,11 +633,11 @@ export default function DevOpsAgent() {
|
||||
{status !== "clarifying" && status !== "" && (
|
||||
<div className="pt-4 mt-2 border-t border-border">
|
||||
<div className="flex items-center gap-2 px-4 py-3 mb-4 text-sm text-green-700 border border-green-200 bg-green-50 rounded-xl">
|
||||
<span>✅</span><span>Requirements confirmed. Ready to start PM analysis.</span>
|
||||
<span>✅</span><span>需求已确认,可开始产品分析。</span>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button className="btn-magenta" onClick={handlePmRun} disabled={loading || streaming}>
|
||||
{loading ? "Analyzing…" : "Start PM Analysis →"}
|
||||
{loading ? "分析中…" : "开始产品分析 →"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -641,12 +651,12 @@ export default function DevOpsAgent() {
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 text-base rounded-xl bg-orange-50">📋</div>
|
||||
<h2 className="text-base font-bold">PM Analysis</h2>
|
||||
<h2 className="text-base font-bold">产品分析</h2>
|
||||
</div>
|
||||
{streaming && (
|
||||
<span className="flex items-center gap-1.5 text-xs font-semibold text-magenta bg-magenta/5 px-3 py-1 rounded-full">
|
||||
<span className="w-1.5 h-1.5 bg-magenta rounded-full animate-pulse inline-block"></span>
|
||||
Analyzing… {streamText.length} chars
|
||||
分析中… {streamText.length} 字符
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -655,10 +665,10 @@ export default function DevOpsAgent() {
|
||||
{streaming && pmStreamParsed && (
|
||||
<div ref={pmStreamRef} className="rounded-xl border border-border bg-[#fafafa] p-4 mb-4 space-y-3 max-h-[55vh] overflow-y-auto">
|
||||
{([
|
||||
["functional_requirements", "🔧", "Functional Requirements", "bg-blue-50 text-blue-700"],
|
||||
["non_functional_requirements", "⚙️", "Non-Functional Requirements", "bg-purple-50 text-purple-700"],
|
||||
["acceptance_criteria", "✅", "Acceptance Criteria", "bg-green-50 text-green-700"],
|
||||
["edge_cases", "🚧", "Edge Cases", "bg-amber-50 text-amber-700"],
|
||||
["functional_requirements", "🔧", "功能需求", "bg-blue-50 text-blue-700"],
|
||||
["non_functional_requirements", "⚙️", "非功能需求", "bg-purple-50 text-purple-700"],
|
||||
["acceptance_criteria", "✅", "验收准则", "bg-green-50 text-green-700"],
|
||||
["edge_cases", "🚧", "边界情况", "bg-amber-50 text-amber-700"],
|
||||
] as const).map(([key, icon, label, color]) => {
|
||||
const sec = pmStreamParsed[key];
|
||||
if (!sec.items.length && pmStreamParsed.currentSection !== key) return null;
|
||||
@@ -677,8 +687,8 @@ export default function DevOpsAgent() {
|
||||
);
|
||||
})}
|
||||
{(pmStreamParsed.summary.value || pmStreamParsed.currentSection === "summary") && (
|
||||
<div className={`transition-opacity ${ pmStreamParsed.currentSection === "summary" ? "opacity-100" : "opacity-50" }`}>
|
||||
<div className="inline-flex items-center gap-1 text-[11px] font-bold px-2 py-0.5 rounded-md mb-2 bg-gray-100 text-gray-600">📌 Summary</div>
|
||||
<div className={`transition-opacity ${ pmStreamParsed.currentSection === "summary" ? "opacity-100" : "opacity-50" }`}>
|
||||
<div className="inline-flex items-center gap-1 text-[11px] font-bold px-2 py-0.5 rounded-md mb-2 bg-gray-100 text-gray-600">📌 摘要</div>
|
||||
<p className="text-sm leading-relaxed">{pmStreamParsed.summary.value}{pmStreamParsed.summary.active && <span className="text-magenta animate-pulse">|</span>}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,26 +14,58 @@ type ChatMessage = {
|
||||
};
|
||||
|
||||
type StreamEvent = {
|
||||
type: "thought" | "tool_call" | "tool_result" | "final" | "error";
|
||||
type: "thought" | "tool_call" | "tool_result" | "final" | "error" | "workspace_start" | "workspace_delta" | "workspace_end";
|
||||
content: string;
|
||||
step?: number;
|
||||
tool_name?: string;
|
||||
workspace_title?: string;
|
||||
};
|
||||
|
||||
type WorkspaceState = {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
content: string;
|
||||
isGenerating: boolean;
|
||||
};
|
||||
|
||||
const WORKSPACE_INIT: WorkspaceState = {
|
||||
isOpen: false,
|
||||
title: "",
|
||||
content: "",
|
||||
isGenerating: false,
|
||||
};
|
||||
|
||||
type HistorySession = {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
summary: string;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
type State = {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
messages: ChatMessage[];
|
||||
reasoning: Record<string, string>; // Map message ID to its reasoning/traces
|
||||
chatting: boolean;
|
||||
historySessions: HistorySession[];
|
||||
workspace: WorkspaceState;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "add-msg"; msg: ChatMessage }
|
||||
| { type: "update-msg"; id: string; patch: Partial<ChatMessage> }
|
||||
| { type: "update-reasoning"; id: string; content: string }
|
||||
| { type: "set-chatting"; v: boolean }
|
||||
| { type: "set-error"; v?: string }
|
||||
| { type: "reset"; sessionId: string; userId: string };
|
||||
| { type: "set-history"; sessions: HistorySession[] }
|
||||
| { type: "load-session"; sessionId: string; messages: ChatMessage[] }
|
||||
| { type: "reset"; sessionId: string; userId: string }
|
||||
| { type: "ws-start"; title: string }
|
||||
| { type: "ws-delta"; content: string }
|
||||
| { type: "ws-end" }
|
||||
| { type: "ws-toggle"; open: boolean };
|
||||
|
||||
/* ─── Helpers ─── */
|
||||
function uid() {
|
||||
@@ -45,6 +77,19 @@ function makeId(prefix: string) {
|
||||
return `${prefix}_${uid()}`;
|
||||
}
|
||||
|
||||
function getStoredHistory(): HistorySession[] {
|
||||
try {
|
||||
const raw = localStorage.getItem("safeos.planning.history");
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveStoredHistory(sessions: HistorySession[]) {
|
||||
localStorage.setItem("safeos.planning.history", JSON.stringify(sessions));
|
||||
}
|
||||
|
||||
function getSession() {
|
||||
try {
|
||||
const raw = localStorage.getItem("safeos.planning.session");
|
||||
@@ -71,10 +116,27 @@ function reducer(state: State, action: Action): State {
|
||||
m.id === action.id ? { ...m, ...action.patch } : m,
|
||||
),
|
||||
};
|
||||
case "update-reasoning":
|
||||
return {
|
||||
...state,
|
||||
reasoning: { ...state.reasoning, [action.id]: action.content },
|
||||
};
|
||||
case "set-chatting":
|
||||
return { ...state, chatting: action.v };
|
||||
case "set-error":
|
||||
return { ...state, error: action.v };
|
||||
case "set-history":
|
||||
saveStoredHistory(action.sessions);
|
||||
return { ...state, historySessions: action.sessions };
|
||||
case "load-session":
|
||||
return {
|
||||
...state,
|
||||
sessionId: action.sessionId,
|
||||
messages: action.messages,
|
||||
reasoning: {},
|
||||
chatting: false,
|
||||
error: undefined,
|
||||
};
|
||||
case "reset": {
|
||||
localStorage.setItem(
|
||||
"safeos.planning.session",
|
||||
@@ -84,10 +146,33 @@ function reducer(state: State, action: Action): State {
|
||||
sessionId: action.sessionId,
|
||||
userId: action.userId,
|
||||
messages: [],
|
||||
reasoning: {},
|
||||
chatting: false,
|
||||
historySessions: state.historySessions,
|
||||
workspace: WORKSPACE_INIT,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
case "ws-start":
|
||||
return {
|
||||
...state,
|
||||
workspace: { isOpen: true, title: action.title || "New Artifact", content: "", isGenerating: true },
|
||||
};
|
||||
case "ws-delta":
|
||||
return {
|
||||
...state,
|
||||
workspace: { ...state.workspace, content: state.workspace.content + action.content },
|
||||
};
|
||||
case "ws-end":
|
||||
return {
|
||||
...state,
|
||||
workspace: { ...state.workspace, isGenerating: false },
|
||||
};
|
||||
case "ws-toggle":
|
||||
return {
|
||||
...state,
|
||||
workspace: { ...state.workspace, isOpen: action.open },
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -101,7 +186,7 @@ async function streamChat(
|
||||
) {
|
||||
const res = await fetch(`${API.planning}/chat/stream`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", "Accept": "text/event-stream" },
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
});
|
||||
@@ -117,11 +202,14 @@ async function streamChat(
|
||||
while (idx >= 0) {
|
||||
const pkt = buf.slice(0, idx);
|
||||
buf = buf.slice(idx + 2);
|
||||
for (const line of pkt.split("\n")) {
|
||||
// Split by literal newline, keeping in mind \r\n vs \n
|
||||
const lines = pkt.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (!t.startsWith("data:")) continue;
|
||||
try {
|
||||
onEvent(JSON.parse(t.slice(5).trim()));
|
||||
const data = JSON.parse(t.slice(5).trim());
|
||||
onEvent(data);
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
@@ -162,12 +250,17 @@ export default function PlanningAgent() {
|
||||
sessionId: sess.current.sessionId,
|
||||
userId: sess.current.userId,
|
||||
messages: [],
|
||||
reasoning: {},
|
||||
chatting: false,
|
||||
historySessions: getStoredHistory(),
|
||||
workspace: WORKSPACE_INIT,
|
||||
});
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const [files, setFiles] = useState<{ id: string; name: string }[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -179,6 +272,32 @@ export default function PlanningAgent() {
|
||||
return () => abortRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
/* Load session */
|
||||
const loadHistorySession = async (sessionId: string) => {
|
||||
if (loadingHistory) return;
|
||||
setLoadingHistory(true);
|
||||
try {
|
||||
const res = await fetch(`${API.planning}/history?session_id=${sessionId}&limit=100`);
|
||||
if (!res.ok) throw new Error(`Load history failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
const messages: ChatMessage[] = data.map((item: any) => ({
|
||||
id: item.ID.toString(),
|
||||
role: item.Role.toLowerCase(),
|
||||
content: item.Content,
|
||||
ts: new Date(item.CreatedAt).getTime(),
|
||||
status: "sent"
|
||||
}));
|
||||
|
||||
dispatch({ type: "load-session", sessionId, messages });
|
||||
setIsHistoryOpen(false);
|
||||
} catch (err) {
|
||||
dispatch({ type: "set-error", v: `加载历史失败: ${(err as Error).message}` });
|
||||
} finally {
|
||||
setLoadingHistory(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* Send message */
|
||||
const send = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
@@ -208,8 +327,19 @@ export default function PlanningAgent() {
|
||||
file_ids: files.map((f) => f.id),
|
||||
},
|
||||
(evt) => {
|
||||
if (evt.type === "final") {
|
||||
finalText = evt.content;
|
||||
if (evt.type === "workspace_start") {
|
||||
dispatch({ type: "ws-start", title: evt.workspace_title || "New Artifact" });
|
||||
} else if (evt.type === "workspace_delta") {
|
||||
dispatch({ type: "ws-delta", content: evt.content });
|
||||
} else if (evt.type === "workspace_end") {
|
||||
dispatch({ type: "ws-end" });
|
||||
} else if (evt.type === "final") {
|
||||
finalText += evt.content;
|
||||
dispatch({
|
||||
type: "update-msg",
|
||||
id: assistMsg.id,
|
||||
patch: { content: finalText },
|
||||
});
|
||||
} else if (evt.type === "error") {
|
||||
traces.push(`[error] ${evt.content}`);
|
||||
} else {
|
||||
@@ -218,15 +348,13 @@ export default function PlanningAgent() {
|
||||
traces.push(`${prefix}${evt.type}${tool}: ${evt.content}`);
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
if (traces.length) parts.push(traces.join("\n"));
|
||||
if (finalText) parts.push(finalText);
|
||||
|
||||
dispatch({
|
||||
type: "update-msg",
|
||||
id: assistMsg.id,
|
||||
patch: { content: parts.join("\n\n") || "思考中..." },
|
||||
});
|
||||
if (traces.length) {
|
||||
dispatch({
|
||||
type: "update-reasoning",
|
||||
id: assistMsg.id,
|
||||
content: traces.join("\n"),
|
||||
});
|
||||
}
|
||||
},
|
||||
ctrl.signal,
|
||||
);
|
||||
@@ -264,23 +392,121 @@ export default function PlanningAgent() {
|
||||
|
||||
/* Reset */
|
||||
const handleReset = () => {
|
||||
if (state.messages.length > 0) {
|
||||
const lastMsg = state.messages[state.messages.length - 1];
|
||||
const summary = lastMsg.role === "assistant"
|
||||
? lastMsg.content.slice(0, 50) + (lastMsg.content.length > 50 ? "..." : "")
|
||||
: (state.messages[state.messages.length - 2]?.content.slice(0, 50) || "Empty session") + "...";
|
||||
|
||||
const newHistorySession: HistorySession = {
|
||||
id: uid(),
|
||||
sessionId: state.sessionId,
|
||||
summary: summary || "新会话",
|
||||
ts: Date.now(),
|
||||
};
|
||||
|
||||
const updatedHistory = [newHistorySession, ...state.historySessions.filter(s => s.sessionId !== state.sessionId)];
|
||||
dispatch({ type: "set-history", sessions: updatedHistory });
|
||||
}
|
||||
|
||||
abortRef.current?.abort();
|
||||
const fresh = { sessionId: makeId("sess"), userId: makeId("user") };
|
||||
dispatch({ type: "reset", ...fresh });
|
||||
setFiles([]);
|
||||
};
|
||||
|
||||
const wsHasContent = !state.workspace.isOpen && state.workspace.content.length > 0;
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="h-full relative overflow-hidden flex">
|
||||
{/* ─── Sidebar Overlay ─── */}
|
||||
{isHistoryOpen && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black/20 z-40 transition-opacity duration-300"
|
||||
onClick={() => setIsHistoryOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`absolute top-0 left-0 h-full w-1/4 bg-white border-r border-border z-50 transform transition-transform duration-300 ease-in-out shadow-xl ${
|
||||
isHistoryOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h3 className="text-lg font-bold">历史对话</h3>
|
||||
<button
|
||||
onClick={() => setIsHistoryOpen(false)}
|
||||
className="p-2 hover:bg-surface-muted rounded-full transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{state.historySessions.length === 0 ? (
|
||||
<div className="text-center py-10 text-txt-muted text-sm italic">
|
||||
暂无历史记录
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
{state.historySessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => loadHistorySession(session.sessionId)}
|
||||
className={`p-4 rounded-xl border border-border cursor-pointer transition-all hover:bg-surface-muted hover:border-magenta group ${
|
||||
state.sessionId === session.sessionId ? 'bg-magenta/5 border-magenta ring-1 ring-magenta' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<span className="text-[0.7rem] font-mono text-txt-muted truncate max-w-[120px]">
|
||||
ID: {session.sessionId.slice(0, 8)}...
|
||||
</span>
|
||||
<span className="text-[0.65rem] text-txt-muted">
|
||||
{new Date(session.ts).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-txt leading-snug line-clamp-2 group-hover:text-magenta transition-colors">
|
||||
{session.summary}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{loadingHistory && (
|
||||
<div className="absolute inset-0 bg-white/60 flex items-center justify-center z-[60]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-magenta"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Main Chat ─── */}
|
||||
<div className="h-full flex flex-col min-w-0">
|
||||
<div className="flex-1 overflow-y-auto px-[72px] py-8">
|
||||
<div className={`h-full flex flex-col min-w-0 transition-all duration-300 ease-in-out ${
|
||||
state.workspace.isOpen ? 'w-[35%] border-r border-border' : 'w-full'
|
||||
}`}>
|
||||
<div className={`flex-1 overflow-y-auto py-8 ${
|
||||
state.workspace.isOpen ? 'px-4' : 'px-[72px]'
|
||||
}`}>
|
||||
{/* History Toggle Button */}
|
||||
<button
|
||||
onClick={() => setIsHistoryOpen(true)}
|
||||
className="absolute top-6 left-6 p-2 bg-white border border-border rounded-lg shadow-sm hover:bg-surface-muted transition-colors z-30 group"
|
||||
title="查看历史记录"
|
||||
>
|
||||
<svg className="w-5 h-5 text-txt-muted group-hover:text-magenta" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
{state.messages.length === 0 && (
|
||||
<div className="text-center py-20 card">
|
||||
<h2 className="text-2xl font-extrabold mb-2">Planning Council Agent</h2>
|
||||
<h2 className={`font-extrabold mb-2 ${state.workspace.isOpen ? 'text-lg' : 'text-2xl'}`}>规划委员会代理</h2>
|
||||
<p className="text-txt-muted text-sm">
|
||||
Share a high-level Epic and the agent will plan through PM, Architect, and RTE perspectives.
|
||||
提供一个高层 Epic,代理将从产品、架构与 RTE 视角进行规划。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -288,29 +514,43 @@ export default function PlanningAgent() {
|
||||
{state.messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`max-w-[78%] px-6 py-5 text-[0.95rem] leading-relaxed whitespace-pre-wrap rounded-2xl shadow-[0_2px_14px_rgba(0,0,0,0.04)] ${
|
||||
className={`${state.workspace.isOpen ? 'max-w-full' : 'max-w-[78%]'} px-6 py-5 text-[0.95rem] leading-relaxed rounded-2xl shadow-[0_2px_14px_rgba(0,0,0,0.04)] ${
|
||||
msg.role === "user"
|
||||
? "self-end mr-1 bg-surface-muted text-txt border border-border"
|
||||
? "self-end mr-1 bg-surface-muted text-txt border border-border whitespace-pre-wrap"
|
||||
: "self-start bg-white border border-border border-l-4 border-l-magenta"
|
||||
}`}
|
||||
>
|
||||
{msg.role === "assistant" && (
|
||||
<span className="badge mb-3 block w-fit">SYSTEM / RE-ACT LOOP</span>
|
||||
<span className="badge mb-3 block w-fit">系统 / 迭代闭环</span>
|
||||
)}
|
||||
{msg.role === "assistant" && state.reasoning[msg.id] && (
|
||||
<details
|
||||
className="mb-4 bg-surface-muted/50 rounded-lg border border-border overflow-hidden"
|
||||
open={!msg.content}
|
||||
>
|
||||
<summary className="px-4 py-2 text-xs font-medium text-txt-muted cursor-pointer hover:bg-border/20 transition-colors list-none flex items-center gap-2">
|
||||
<span className={`w-1.5 h-1.5 rounded-full bg-magenta ${!msg.content ? 'animate-pulse' : ''}`}></span>
|
||||
查看思考过程
|
||||
</summary>
|
||||
<div className="px-4 py-3 bg-white/50 text-[0.8rem] font-mono whitespace-pre-wrap border-t border-border text-txt-muted">
|
||||
{state.reasoning[msg.id]}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{msg.role === "assistant" ? (
|
||||
<div className="prose prose-sm max-w-none prose-p:my-1 prose-pre:bg-gray-800 prose-pre:text-gray-100 prose-code:text-magenta prose-code:bg-magenta/10 prose-code:px-1 prose-code:rounded prose-li:my-0">
|
||||
<div className="prose prose-sm max-w-none prose-p:my-0.5 prose-pre:bg-gray-800 prose-pre:text-gray-100 prose-code:text-magenta prose-code:bg-magenta/10 prose-code:px-1 prose-code:rounded prose-li:my-0">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
>
|
||||
{msg.content || "Thinking..."}
|
||||
{msg.content || (state.chatting ? "思考中..." : "")}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
msg.content || "Thinking..."
|
||||
)}
|
||||
{msg.status === "failed" && (
|
||||
<span className="text-red-500 text-xs block mt-2">Failed to send</span>
|
||||
<span className="text-red-500 text-xs block mt-2">发送失败</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -318,9 +558,24 @@ export default function PlanningAgent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workspace recall button */}
|
||||
{wsHasContent && (
|
||||
<div className={`${state.workspace.isOpen ? 'mx-4' : 'mx-[72px]'} mb-2`}>
|
||||
<button
|
||||
onClick={() => dispatch({ type: "ws-toggle", open: true })}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-magenta/5 border border-magenta/30 rounded-xl text-sm text-magenta hover:bg-magenta/10 transition-colors w-full justify-center"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
查看文档输出 — {state.workspace.title}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{state.error && (
|
||||
<div className="mx-[72px] mb-2 px-4 py-2 bg-red-50 text-red-700 text-sm flex justify-between items-center">
|
||||
<div className={`${state.workspace.isOpen ? 'mx-4' : 'mx-[72px]'} mb-2 px-4 py-2 bg-red-50 text-red-700 text-sm flex justify-between items-center`}>
|
||||
{state.error}
|
||||
<button
|
||||
className="text-red-400 hover:text-red-600 font-bold"
|
||||
@@ -332,21 +587,43 @@ export default function PlanningAgent() {
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<div className="px-10 pb-6 pt-4 border-t border-border">
|
||||
<div className={`pb-6 pt-4 border-t border-border ${state.workspace.isOpen ? 'px-4' : 'px-10'}`}>
|
||||
{/* File upload row */}
|
||||
<div className="flex items-center gap-3 mb-3 text-sm">
|
||||
<label className="btn-outline text-xs px-3 py-1.5 cursor-pointer">
|
||||
{uploading ? "Uploading..." : "Attach File"}
|
||||
{uploading ? "上传中..." : "附加文件"}
|
||||
<input type="file" className="hidden" onChange={handleUpload} disabled={uploading} />
|
||||
</label>
|
||||
{files.map((f) => (
|
||||
<span key={f.id} className="badge text-[0.7rem]">{f.name}</span>
|
||||
))}
|
||||
<button
|
||||
className="ml-auto text-xs text-txt-muted hover:text-magenta"
|
||||
className="ml-auto text-xs text-txt-muted hover:text-magenta flex items-center gap-1.5"
|
||||
onClick={() => setInput(`# Epic: Q3 智能云端电池预测性维护系统 (BMS-AI)
|
||||
|
||||
## 业务背景
|
||||
目前我们接入云端的 50 万台新能源车辆中,电池因单体压差过大导致的突发性故障率有上升趋势,造成了较高的售后索赔成本和用户客诉。我们需要在 Q3 PI (Program Increment) 落地一套“电池预测性维护系统”。
|
||||
|
||||
## 核心目标
|
||||
1. **数据实时采集**:云端需要能够高频接收车辆上报的电池运行遥测数据(包含单体电压、温度、充放电电流等)。
|
||||
2. **云端 AI 分析**:利用云端的 AI 异常检测模型,对电池数据进行实时流式计算,预测未来 7 天内可能发生故障的电池包。
|
||||
3. **多端预警联动**:一旦云端判定存在高风险,需立即向车机端(座舱屏幕)和车主的手机 App 同时下发告警弹窗,并生成一条云端售后维修建议工单。
|
||||
|
||||
## 约束与期望
|
||||
- **合规性**:采集的车辆 VIN 码和位置信息必须符合个人信息保护法(PIPL)的数据脱敏要求。
|
||||
- **性能**:云端必须能扛住 50 万台车同时在线高频上报数据的并发压力。告警下发延迟不能超过 3 秒。`)}
|
||||
title="快速输入: BMS-AI 预测性维护 Epic 示例"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
快捷指令
|
||||
</button>
|
||||
<button
|
||||
className="text-xs text-txt-muted hover:text-magenta"
|
||||
onClick={handleReset}
|
||||
>
|
||||
Reset Session
|
||||
重置会话
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -361,15 +638,58 @@ export default function PlanningAgent() {
|
||||
send();
|
||||
}
|
||||
}}
|
||||
placeholder="Describe your Epic or provide guidance..."
|
||||
placeholder="描述你的 Epic 或提供指引..."
|
||||
disabled={state.chatting}
|
||||
/>
|
||||
<button className="btn-magenta" onClick={send} disabled={state.chatting}>
|
||||
{state.chatting ? "Planning..." : "Send"}
|
||||
{state.chatting ? "规划中..." : "发送"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Right Panel: Workspace ─── */}
|
||||
{state.workspace.isOpen && (
|
||||
<div className="w-[65%] h-full flex flex-col bg-white border-l border-border">
|
||||
{/* Workspace Header */}
|
||||
<div className="h-14 border-b border-border flex items-center px-6 justify-between bg-white shrink-0">
|
||||
<h3 className="font-semibold text-txt flex items-center gap-2 truncate">
|
||||
<svg className="w-5 h-5 text-magenta shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{state.workspace.title}
|
||||
{state.workspace.isGenerating && (
|
||||
<span className="text-sm text-magenta animate-pulse flex items-center gap-1 ml-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-magenta animate-pulse" />
|
||||
生成中...
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => dispatch({ type: "ws-toggle", open: false })}
|
||||
className="p-2 hover:bg-surface-muted rounded-full transition-colors shrink-0"
|
||||
title="关闭面板"
|
||||
>
|
||||
<svg className="w-5 h-5 text-txt-muted hover:text-txt" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/* Workspace Content */}
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<div className="prose prose-sm max-w-none prose-p:my-1 prose-pre:bg-gray-800 prose-pre:text-gray-100 prose-code:text-magenta prose-code:bg-magenta/10 prose-code:px-1 prose-code:rounded prose-li:my-0 prose-headings:text-txt">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
{state.workspace.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
{state.workspace.isGenerating && !state.workspace.content && (
|
||||
<div className="flex items-center justify-center py-20 text-txt-muted">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-magenta" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -381,14 +381,14 @@ export default function QualityGate({ view }: QualityGateProps) {
|
||||
|
||||
{view === "dashboard" && (
|
||||
<>
|
||||
<h2 className="text-xl font-extrabold mb-6">Overview</h2>
|
||||
<h2 className="text-xl font-extrabold mb-6">总览</h2>
|
||||
|
||||
<div className="grid grid-cols-4 gap-5 mb-8">
|
||||
{[
|
||||
{ label: "Open PRs", value: stats.pending, color: "text-yellow-600", bg: "bg-yellow-50" },
|
||||
{ label: "Merged", value: stats.passed, color: "text-green-600", bg: "bg-green-50" },
|
||||
{ label: "Rejected", value: stats.rejected, color: "text-red-600", bg: "bg-red-50" },
|
||||
{ label: "Total Issues", value: stats.totalIssues, color: "text-magenta", bg: "bg-magenta-50" },
|
||||
{ label: "打开 PR", value: stats.pending, color: "text-yellow-600", bg: "bg-yellow-50" },
|
||||
{ label: "已合并", value: stats.passed, color: "text-green-600", bg: "bg-green-50" },
|
||||
{ label: "已拒绝", value: stats.rejected, color: "text-red-600", bg: "bg-red-50" },
|
||||
{ label: "问题总数", value: stats.totalIssues, color: "text-magenta", bg: "bg-magenta-50" },
|
||||
].map((s) => (
|
||||
<div key={s.label} className={`${s.bg} p-6 border border-border rounded-2xl shadow-[0_4px_16px_rgba(0,0,0,0.04)]`}>
|
||||
<div className={`text-3xl font-extrabold ${s.color}`}>{s.value}</div>
|
||||
@@ -401,7 +401,7 @@ export default function QualityGate({ view }: QualityGateProps) {
|
||||
<ProblemTrendChart history={trendHistory} loading={trendLoading} />
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-extrabold mb-4">Recent Pull Requests</h3>
|
||||
<h3 className="text-lg font-extrabold mb-4">近期 Pull Request</h3>
|
||||
<PRTable prs={prs.slice(0, 8)} loading={loading} onView={(pr) => openPRDetail(pr.id)} />
|
||||
</>
|
||||
)}
|
||||
@@ -409,7 +409,7 @@ export default function QualityGate({ view }: QualityGateProps) {
|
||||
{view === "pr-list" && (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-extrabold">Pull Request List</h2>
|
||||
<h2 className="text-xl font-extrabold">Pull Request 列表</h2>
|
||||
<div className="flex gap-2">
|
||||
{(["all", "open", "merged", "closed"] as const).map((f) => (
|
||||
<button
|
||||
@@ -421,11 +421,11 @@ export default function QualityGate({ view }: QualityGateProps) {
|
||||
: "border-border text-txt-muted hover:border-magenta"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "All" : f === "open" ? "Open" : f === "merged" ? "Merged" : "Closed"}
|
||||
{f === "all" ? "全部" : f === "open" ? "打开" : f === "merged" ? "已合并" : "已关闭"}
|
||||
</button>
|
||||
))}
|
||||
<button className="btn-outline text-xs px-3 py-1.5" onClick={fetchPRs}>
|
||||
Refresh
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -435,21 +435,21 @@ export default function QualityGate({ view }: QualityGateProps) {
|
||||
|
||||
{view === "settings" && (
|
||||
<div className="max-w-xl">
|
||||
<h2 className="text-xl font-extrabold mb-6">Settings</h2>
|
||||
<h2 className="text-xl font-extrabold mb-6">设置</h2>
|
||||
<div className="card mb-4">
|
||||
<h3 className="font-bold mb-2">Webhook Configuration</h3>
|
||||
<p className="text-sm text-txt-muted mb-3">Add this URL to your Gitea repository webhook settings:</p>
|
||||
<h3 className="font-bold mb-2">Webhook 配置</h3>
|
||||
<p className="text-sm text-txt-muted mb-3">将此 URL 添加到 Gitea 仓库的 webhook 设置:</p>
|
||||
<code className="block bg-surface-muted p-3 text-sm font-mono break-all rounded-lg">
|
||||
POST {window.location.origin}/quality-api/webhook/gitea
|
||||
</code>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3 className="font-bold mb-2">Quick Notes</h3>
|
||||
<h3 className="font-bold mb-2">快速说明</h3>
|
||||
<ul className="text-sm text-txt-muted list-disc list-inside space-y-1">
|
||||
<li>Supports Gitea Push and Pull Request events</li>
|
||||
<li>Runs Pylint, Flake8, ESLint, and Bandit automatically</li>
|
||||
<li>Optional AI review using DeepSeek-V3</li>
|
||||
<li>Scan summary can be pushed to Feishu channels</li>
|
||||
<li>支持 Gitea Push 与 Pull Request 事件</li>
|
||||
<li>自动运行 Pylint、Flake8、ESLint 与 Bandit</li>
|
||||
<li>可选 AI 审查(DeepSeek-V3)</li>
|
||||
<li>扫描摘要可推送到飞书通知</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -477,7 +477,7 @@ export default function QualityGate({ view }: QualityGateProps) {
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="w-[260px] shrink-0 border-r border-border overflow-y-auto p-4 bg-surface-muted/35">
|
||||
<h3 className="text-xs font-bold text-txt-muted uppercase mb-3">Changed Files</h3>
|
||||
<h3 className="text-xs font-bold text-txt-muted uppercase mb-3">变更文件</h3>
|
||||
{changedFiles.map((f) => (
|
||||
<button
|
||||
key={f.filename}
|
||||
@@ -502,7 +502,7 @@ export default function QualityGate({ view }: QualityGateProps) {
|
||||
{/* 代码区:完整代码 + 每行右侧缺陷标注,一行一个滚动条(参考 index copy.html) */}
|
||||
<div className="flex-1 min-h-0 min-w-0 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 min-h-0 overflow-auto bg-[#1e1e1e] font-mono text-[13px] leading-[1.5]">
|
||||
{fileContent ? (
|
||||
{fileContent ? (
|
||||
<div className="min-w-min">
|
||||
{(fileContent.content ?? "").split("\n").map((line, i) => {
|
||||
const lineNum = i + 1;
|
||||
@@ -573,7 +573,7 @@ export default function QualityGate({ view }: QualityGateProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full min-h-[200px] text-[#6c757d] text-sm">
|
||||
{selectedFile ? "Loading file..." : "Select a file to inspect"}
|
||||
{selectedFile ? "加载文件..." : "请选择要检查的文件"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -582,21 +582,21 @@ export default function QualityGate({ view }: QualityGateProps) {
|
||||
|
||||
<div className="flex justify-end gap-3 px-6 py-4 border-t border-border shrink-0">
|
||||
<button className="btn-outline" onClick={() => setModalOpen(false)}>
|
||||
Close
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
className="bg-red-600 text-white font-bold text-sm px-6 py-2.5 border-none cursor-pointer hover:opacity-90 disabled:opacity-50 rounded-xl"
|
||||
onClick={handleClose}
|
||||
disabled={loading || selectedPR.state !== "open"}
|
||||
>
|
||||
Reject
|
||||
拒绝
|
||||
</button>
|
||||
<button
|
||||
className="btn-magenta"
|
||||
onClick={handleMerge}
|
||||
disabled={loading || selectedPR.state !== "open"}
|
||||
>
|
||||
Approve & Merge
|
||||
批准并合并
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -616,27 +616,27 @@ function PRTable({
|
||||
onView: (pr: PRScan) => void;
|
||||
}) {
|
||||
if (loading && prs.length === 0) {
|
||||
return <div className="text-center py-8 text-txt-muted text-sm">Loading...</div>;
|
||||
return <div className="text-center py-8 text-txt-muted text-sm">加载中...</div>;
|
||||
}
|
||||
|
||||
if (prs.length === 0) {
|
||||
return <div className="text-center py-8 text-txt-muted text-sm">No PR records found</div>;
|
||||
return <div className="text-center py-8 text-txt-muted text-sm">暂无 PR 记录</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-2xl border border-border bg-white shadow-[0_6px_22px_rgba(0,0,0,0.04)]">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-surface-muted text-left">
|
||||
<tr className="bg-surface-muted text-left">
|
||||
<th className="px-4 py-3 border-b border-border font-bold">PR#</th>
|
||||
<th className="px-4 py-3 border-b border-border font-bold">Title</th>
|
||||
<th className="px-4 py-3 border-b border-border font-bold">Repository</th>
|
||||
<th className="px-4 py-3 border-b border-border font-bold">Author</th>
|
||||
<th className="px-4 py-3 border-b border-border font-bold">Branch</th>
|
||||
<th className="px-4 py-3 border-b border-border font-bold">State</th>
|
||||
<th className="px-4 py-3 border-b border-border font-bold">Issues</th>
|
||||
<th className="px-4 py-3 border-b border-border font-bold">Created</th>
|
||||
<th className="px-4 py-3 border-b border-border font-bold">Action</th>
|
||||
<th className="px-4 py-3 border-b border-border font-bold">标题</th>
|
||||
<th className="px-4 py-3 border-b border-border font-bold">仓库</th>
|
||||
<th className="px-4 py-3 border-b border-border font-bold">作者</th>
|
||||
<th className="px-4 py-3 border-b border-border font-bold">分支</th>
|
||||
<th className="px-4 py-3 border-b border-border font-bold">状态</th>
|
||||
<th className="px-4 py-3 border-b border-border font-bold">问题数</th>
|
||||
<th className="px-4 py-3 border-b border-border font-bold">创建时间</th>
|
||||
<th className="px-4 py-3 border-b border-border font-bold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -657,7 +657,7 @@ function PRTable({
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}`}>
|
||||
{pr.state === "open" ? "Open" : pr.state === "merged" ? "Merged" : "Closed"}
|
||||
{pr.state === "open" ? "打开" : pr.state === "merged" ? "已合并" : "已关闭"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -666,14 +666,14 @@ function PRTable({
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-txt-muted">
|
||||
{new Date(pr.created_at).toLocaleString("en-US")}
|
||||
{new Date(pr.created_at).toLocaleString("zh-CN")}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
className="text-magenta font-bold text-xs hover:underline"
|
||||
onClick={() => onView(pr)}
|
||||
>
|
||||
Inspect →
|
||||
查看 →
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,740 +0,0 @@
|
||||
import { useCallback, useEffect, useReducer, useRef } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
|
||||
/* ─── Types ─── */
|
||||
type Stage = {
|
||||
id: string;
|
||||
name: string;
|
||||
agent: string;
|
||||
status: "pending" | "processing" | "completed";
|
||||
};
|
||||
|
||||
type LogEntry = {
|
||||
timestamp: string;
|
||||
event: string;
|
||||
source: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type Result = {
|
||||
title: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
expanded: boolean;
|
||||
};
|
||||
|
||||
type PollResponse = {
|
||||
events: Array<{
|
||||
event: string;
|
||||
data: any;
|
||||
}>;
|
||||
has_more: boolean;
|
||||
status: "processing" | "completed" | "failed";
|
||||
};
|
||||
|
||||
type State = {
|
||||
requirement: string;
|
||||
taskId: string | null;
|
||||
isProcessing: boolean;
|
||||
connectionStatus: "connecting" | "connected" | "disconnected";
|
||||
stages: Stage[];
|
||||
logs: LogEntry[];
|
||||
results: Result[];
|
||||
showCopyToast: boolean;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "set-requirement"; value: string }
|
||||
| { type: "set-task-id"; value: string }
|
||||
| { type: "set-processing"; value: boolean }
|
||||
| { type: "set-connection"; status: "connecting" | "connected" | "disconnected" }
|
||||
| { type: "init-stages" }
|
||||
| { type: "update-stage"; stageId: string; status: "pending" | "processing" | "completed" }
|
||||
| { type: "add-log"; log: LogEntry }
|
||||
| { type: "add-result"; result: Result }
|
||||
| { type: "toggle-result"; index: number }
|
||||
| { type: "clear-logs" }
|
||||
| { type: "show-copy-toast"; show: boolean }
|
||||
| { type: "reset" };
|
||||
|
||||
/* ─── Helpers ─── */
|
||||
function formatTime(ts: number) {
|
||||
return new Date(ts).toLocaleTimeString("zh-CN");
|
||||
}
|
||||
|
||||
function formatDate(timestamp: string | number) {
|
||||
if (!timestamp) return "";
|
||||
try {
|
||||
return new Date(timestamp).toLocaleString("zh-CN");
|
||||
} catch {
|
||||
return String(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "set-requirement":
|
||||
return { ...state, requirement: action.value };
|
||||
case "set-task-id":
|
||||
return { ...state, taskId: action.value };
|
||||
case "set-processing":
|
||||
return { ...state, isProcessing: action.value };
|
||||
case "set-connection":
|
||||
return { ...state, connectionStatus: action.status };
|
||||
case "init-stages":
|
||||
return {
|
||||
...state,
|
||||
stages: [
|
||||
{ id: "pm", name: "需求分析", agent: "PM Agent", status: "pending" },
|
||||
{ id: "qa", name: "测试设计", agent: "QA Agent", status: "pending" },
|
||||
{ id: "dev", name: "代码实现", agent: "Dev Agent", status: "pending" },
|
||||
{ id: "final", name: "交付完成", agent: "Orchestrator", status: "pending" },
|
||||
],
|
||||
};
|
||||
case "update-stage":
|
||||
return {
|
||||
...state,
|
||||
stages: state.stages.map((s) =>
|
||||
s.id === action.stageId ? { ...s, status: action.status } : s
|
||||
),
|
||||
};
|
||||
case "add-log":
|
||||
return {
|
||||
...state,
|
||||
logs: [...state.logs, action.log].slice(-100),
|
||||
};
|
||||
case "add-result":
|
||||
return {
|
||||
...state,
|
||||
results: [...state.results, action.result],
|
||||
};
|
||||
case "toggle-result":
|
||||
return {
|
||||
...state,
|
||||
results: state.results.map((r, i) =>
|
||||
i === action.index ? { ...r, expanded: !r.expanded } : r
|
||||
),
|
||||
};
|
||||
case "clear-logs":
|
||||
return { ...state, logs: [] };
|
||||
case "show-copy-toast":
|
||||
return { ...state, showCopyToast: action.show };
|
||||
case "reset":
|
||||
return {
|
||||
...state,
|
||||
taskId: null,
|
||||
isProcessing: false,
|
||||
connectionStatus: "disconnected",
|
||||
stages: [],
|
||||
logs: [],
|
||||
results: [],
|
||||
showCopyToast: false,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
requirement: "",
|
||||
taskId: null,
|
||||
isProcessing: false,
|
||||
connectionStatus: "disconnected",
|
||||
stages: [],
|
||||
logs: [],
|
||||
results: [],
|
||||
showCopyToast: false,
|
||||
};
|
||||
|
||||
/* ─── API Functions ─── */
|
||||
async function startSDLC(requirement: string) {
|
||||
const res = await fetch("/task-api/v1/sdlc/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ requirement }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
return (await res.json()) as { task_id: string };
|
||||
}
|
||||
|
||||
async function pollEvents(taskId: string, lastIndex: number): Promise<PollResponse> {
|
||||
const res = await fetch(`/task-api/v1/sdlc/poll/${taskId}?last_index=${lastIndex}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/* ─── Copy to Clipboard ─── */
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ━━━━━━━━━━━━━━━━━━━━━━━ Component ━━━━━━━━━━━━━━━━━━━━━━━ */
|
||||
export default function TaskExecutor() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const pollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const pollCountRef = useRef(0);
|
||||
const lastIndexRef = useRef(0);
|
||||
const maxPolls = 600; // 最多轮询 600 次 (10 分钟)
|
||||
|
||||
const canStart = state.requirement.trim().length >= 10 && !state.isProcessing;
|
||||
|
||||
const connectionStatusClass = {
|
||||
connecting: "bg-yellow-500 animate-pulse",
|
||||
connected: "bg-green-500",
|
||||
disconnected: "bg-red-500",
|
||||
}[state.connectionStatus];
|
||||
|
||||
const connectionStatusText = {
|
||||
connecting: "连接中...",
|
||||
connected: "已连接",
|
||||
disconnected: "未连接",
|
||||
}[state.connectionStatus];
|
||||
|
||||
const getStageIconClass = (status: Stage["status"]) => {
|
||||
const map = {
|
||||
pending: "bg-gray-400",
|
||||
processing: "bg-blue-500",
|
||||
completed: "bg-green-500",
|
||||
};
|
||||
return map[status] || map.pending;
|
||||
};
|
||||
|
||||
const getStageBadgeClass = (status: Stage["status"]) => {
|
||||
const map = {
|
||||
pending: "bg-gray-100 text-gray-800",
|
||||
processing: "bg-blue-100 text-blue-800",
|
||||
completed: "bg-green-100 text-green-800",
|
||||
};
|
||||
return map[status] || map.pending;
|
||||
};
|
||||
|
||||
const getStageStatusText = (status: Stage["status"]) => {
|
||||
const map = {
|
||||
pending: "等待中",
|
||||
processing: "进行中",
|
||||
completed: "已完成",
|
||||
};
|
||||
return map[status] || status;
|
||||
};
|
||||
|
||||
const getLogLevelClass = (event: string) => {
|
||||
const map: Record<string, string> = {
|
||||
pm_start: "text-blue-400",
|
||||
pm_complete: "text-green-400",
|
||||
qa_start: "text-blue-400",
|
||||
qa_complete: "text-green-400",
|
||||
dev_start: "text-blue-400",
|
||||
dev_complete: "text-green-400",
|
||||
final_result: "text-purple-400",
|
||||
error: "text-red-400",
|
||||
system: "text-yellow-400",
|
||||
task_started: "text-white",
|
||||
};
|
||||
return map[event] || "text-gray-400";
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理单个事件
|
||||
*/
|
||||
const handleEvent = useCallback(
|
||||
(eventType: string, data: any) => {
|
||||
switch (eventType) {
|
||||
case "task_started":
|
||||
dispatch({
|
||||
type: "add-log",
|
||||
log: {
|
||||
timestamp: formatTime(Date.now()),
|
||||
event: "task_started",
|
||||
source: "System",
|
||||
message: data.message || "任务已启动",
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
case "pm_start":
|
||||
dispatch({ type: "update-stage", stageId: "pm", status: "processing" });
|
||||
dispatch({
|
||||
type: "add-log",
|
||||
log: {
|
||||
timestamp: formatTime(Date.now()),
|
||||
event: "pm_start",
|
||||
source: "PM Agent",
|
||||
message: "开始需求分析...",
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
case "pm_complete":
|
||||
dispatch({ type: "update-stage", stageId: "pm", status: "completed" });
|
||||
dispatch({
|
||||
type: "add-log",
|
||||
log: {
|
||||
timestamp: formatTime(Date.now()),
|
||||
event: "pm_complete",
|
||||
source: "PM Agent",
|
||||
message: "需求分析完成",
|
||||
},
|
||||
});
|
||||
if (data.content && data.timestamp) {
|
||||
dispatch({
|
||||
type: "add-result",
|
||||
result: {
|
||||
title: "📋 软件需求规格说明书 (SRS)",
|
||||
content: data.content,
|
||||
timestamp: data.timestamp,
|
||||
expanded: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "qa_start":
|
||||
dispatch({ type: "update-stage", stageId: "qa", status: "processing" });
|
||||
dispatch({
|
||||
type: "add-log",
|
||||
log: {
|
||||
timestamp: formatTime(Date.now()),
|
||||
event: "qa_start",
|
||||
source: "QA Agent",
|
||||
message: "开始测试用例设计...",
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
case "qa_complete":
|
||||
dispatch({ type: "update-stage", stageId: "qa", status: "completed" });
|
||||
dispatch({
|
||||
type: "add-log",
|
||||
log: {
|
||||
timestamp: formatTime(Date.now()),
|
||||
event: "qa_complete",
|
||||
source: "QA Agent",
|
||||
message: "测试用例设计完成",
|
||||
},
|
||||
});
|
||||
if (data.content && data.timestamp) {
|
||||
dispatch({
|
||||
type: "add-result",
|
||||
result: {
|
||||
title: "🧪 测试方案与用例",
|
||||
content: data.content,
|
||||
timestamp: data.timestamp,
|
||||
expanded: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "dev_start":
|
||||
dispatch({ type: "update-stage", stageId: "dev", status: "processing" });
|
||||
dispatch({
|
||||
type: "add-log",
|
||||
log: {
|
||||
timestamp: formatTime(Date.now()),
|
||||
event: "dev_start",
|
||||
source: "Dev Agent",
|
||||
message: "开始代码实现...",
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
case "dev_complete":
|
||||
dispatch({ type: "update-stage", stageId: "dev", status: "completed" });
|
||||
dispatch({
|
||||
type: "add-log",
|
||||
log: {
|
||||
timestamp: formatTime(Date.now()),
|
||||
event: "dev_complete",
|
||||
source: "Dev Agent",
|
||||
message: "代码实现完成",
|
||||
},
|
||||
});
|
||||
if (data.content && data.timestamp) {
|
||||
dispatch({
|
||||
type: "add-result",
|
||||
result: {
|
||||
title: "💻 代码实现",
|
||||
content: data.content,
|
||||
timestamp: data.timestamp,
|
||||
expanded: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "final_result":
|
||||
dispatch({ type: "update-stage", stageId: "final", status: "completed" });
|
||||
dispatch({
|
||||
type: "add-log",
|
||||
log: {
|
||||
timestamp: formatTime(Date.now()),
|
||||
event: "final_result",
|
||||
source: "System",
|
||||
message: "SDLC 流程完成",
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
case "error":
|
||||
dispatch({
|
||||
type: "add-log",
|
||||
log: {
|
||||
timestamp: formatTime(Date.now()),
|
||||
event: "error",
|
||||
source: "Error",
|
||||
message: data.error || "未知错误",
|
||||
},
|
||||
});
|
||||
alert(`执行错误:${data.error}`);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* 轮询函数
|
||||
*/
|
||||
const poll = useCallback(
|
||||
(taskId: string) => {
|
||||
if (pollCountRef.current >= maxPolls) {
|
||||
dispatch({
|
||||
type: "add-log",
|
||||
log: {
|
||||
timestamp: formatTime(Date.now()),
|
||||
event: "system",
|
||||
source: "POLL",
|
||||
message: "轮询超时",
|
||||
},
|
||||
});
|
||||
dispatch({ type: "set-processing", value: false });
|
||||
dispatch({ type: "set-connection", status: "disconnected" });
|
||||
return;
|
||||
}
|
||||
|
||||
pollEvents(taskId, lastIndexRef.current)
|
||||
.then(({ events, has_more, status }) => {
|
||||
// 处理新事件
|
||||
events.forEach((event) => {
|
||||
lastIndexRef.current++;
|
||||
handleEvent(event.event, event.data);
|
||||
});
|
||||
|
||||
// 检查是否继续轮询
|
||||
if (status === "completed" || status === "failed") {
|
||||
dispatch({ type: "set-processing", value: false });
|
||||
dispatch({ type: "set-connection", status: "disconnected" });
|
||||
dispatch({
|
||||
type: "add-log",
|
||||
log: {
|
||||
timestamp: formatTime(Date.now()),
|
||||
event: "system",
|
||||
source: "POLL",
|
||||
message: `任务完成,状态:${status}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (has_more || events.length > 0) {
|
||||
pollCountRef.current++;
|
||||
pollTimeoutRef.current = setTimeout(() => poll(taskId), 500);
|
||||
} else if (status === "processing") {
|
||||
pollCountRef.current++;
|
||||
pollTimeoutRef.current = setTimeout(() => poll(taskId), 1000);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("轮询失败:", err);
|
||||
dispatch({
|
||||
type: "add-log",
|
||||
log: {
|
||||
timestamp: formatTime(Date.now()),
|
||||
event: "error",
|
||||
source: "POLL",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
pollCountRef.current++;
|
||||
pollTimeoutRef.current = setTimeout(() => poll(taskId), 2000);
|
||||
});
|
||||
},
|
||||
[handleEvent],
|
||||
);
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
if (!canStart) return;
|
||||
|
||||
dispatch({ type: "reset" });
|
||||
dispatch({ type: "set-processing", value: true });
|
||||
|
||||
try {
|
||||
const data = await startSDLC(state.requirement);
|
||||
dispatch({ type: "set-task-id", value: data.task_id });
|
||||
dispatch({
|
||||
type: "add-log",
|
||||
log: {
|
||||
timestamp: formatTime(Date.now()),
|
||||
event: "system",
|
||||
source: "System",
|
||||
message: `Task ID: ${data.task_id}`,
|
||||
},
|
||||
});
|
||||
|
||||
dispatch({ type: "init-stages" });
|
||||
|
||||
// 开始轮询
|
||||
dispatch({ type: "set-connection", status: "connecting" });
|
||||
dispatch({
|
||||
type: "add-log",
|
||||
log: {
|
||||
timestamp: formatTime(Date.now()),
|
||||
event: "system",
|
||||
source: "POLL",
|
||||
message: `开始轮询任务:${data.task_id}`,
|
||||
},
|
||||
});
|
||||
|
||||
lastIndexRef.current = 0;
|
||||
pollCountRef.current = 0;
|
||||
setTimeout(() => poll(data.task_id), 500);
|
||||
} catch (err) {
|
||||
dispatch({
|
||||
type: "add-log",
|
||||
log: {
|
||||
timestamp: formatTime(Date.now()),
|
||||
event: "error",
|
||||
source: "Error",
|
||||
message: (err as Error).message,
|
||||
},
|
||||
});
|
||||
dispatch({ type: "set-processing", value: false });
|
||||
alert(`启动失败:${(err as Error).message}`);
|
||||
}
|
||||
}, [canStart, state.requirement, poll]);
|
||||
|
||||
const handleCopy = useCallback(async (content: string) => {
|
||||
const success = await copyToClipboard(content);
|
||||
if (success) {
|
||||
dispatch({ type: "show-copy-toast", show: true });
|
||||
setTimeout(() => {
|
||||
dispatch({ type: "show-copy-toast", show: false });
|
||||
}, 2000);
|
||||
} else {
|
||||
alert("复制失败,请手动复制");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 清理轮询定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollTimeoutRef.current) {
|
||||
clearTimeout(pollTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-8 w-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h1 className="text-xl font-bold text-gray-900">SDLC Agent Demo</h1>
|
||||
<p className="text-sm text-gray-500">多智能体端到端软件交付协同系统</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center text-sm">
|
||||
<span className={`inline-block w-2 h-2 rounded-full mr-2 ${connectionStatusClass}`}></span>
|
||||
<span className="text-gray-600">{connectionStatusText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto px-4 py-8 sm:px-6 lg:px-8">
|
||||
{/* Requirement Input */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">1. 输入软件需求</h2>
|
||||
<div className="space-y-4">
|
||||
<textarea
|
||||
value={state.requirement}
|
||||
onChange={(e) => dispatch({ type: "set-requirement", value: e.target.value })}
|
||||
rows={5}
|
||||
placeholder={`请输入您的软件需求描述,例如:\n开发一个用户管理系统,支持用户的增删改查功能,需要包含以下特性:\n- 用户注册和登录\n- 用户信息管理\n- 角色权限控制\n- 操作日志记录`}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
disabled={state.isProcessing}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
当前任务 ID: {state.taskId || "无"}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={!canStart}
|
||||
className={`px-6 py-2.5 rounded-lg font-medium text-white transition-all duration-200 ${
|
||||
canStart
|
||||
? "bg-blue-600 hover:bg-blue-700 shadow-md hover:shadow-lg"
|
||||
: "bg-gray-400 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
{state.isProcessing ? "执行中..." : "开始执行"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Stages */}
|
||||
{state.stages.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">2. 执行进度</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{state.stages.map((stage, index) => (
|
||||
<div
|
||||
key={stage.id}
|
||||
className={`rounded-lg border-2 p-4 bg-white transition-all duration-300 ${
|
||||
stage.status === "processing"
|
||||
? "border-blue-500 transform scale-[1.02] shadow-[0_10px_25px_rgba(59,130,246,0.3)] animate-pulse"
|
||||
: stage.status === "completed"
|
||||
? "border-green-500 bg-gradient-to-br from-green-50 to-white"
|
||||
: "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center mb-2">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center mr-3 ${getStageIconClass(stage.status)}`}
|
||||
>
|
||||
<span className="text-white font-bold text-sm">{index + 1}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{stage.name}</h3>
|
||||
<p className="text-xs text-gray-500">{stage.agent}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStageBadgeClass(stage.status)}`}
|
||||
>
|
||||
{getStageStatusText(stage.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Real-time Logs */}
|
||||
{state.logs.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">3. 实时日志</h2>
|
||||
<button
|
||||
onClick={() => dispatch({ type: "clear-logs" })}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
清空日志
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-gray-900 rounded-lg p-4 h-64 overflow-y-auto font-mono text-sm">
|
||||
{state.logs.map((log, index) => (
|
||||
<div key={index} className="mb-1">
|
||||
<span className="text-gray-500">{log.timestamp}</span>
|
||||
<span className={`ml-2 ${getLogLevelClass(log.event)}`}>[{log.event}]</span>
|
||||
<span className="text-gray-300 ml-2">{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{state.results.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">4. 输出结果</h2>
|
||||
|
||||
{state.results.map((result, index) => (
|
||||
<div key={index} className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div
|
||||
onClick={() => dispatch({ type: "toggle-result", index })}
|
||||
className="px-6 py-4 bg-gray-50 border-b border-gray-200 cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className={`w-5 h-5 mr-2 text-gray-500 transform transition-transform ${
|
||||
result.expanded ? "rotate-90" : ""
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<h3 className="font-semibold text-gray-900">{result.title}</h3>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-gray-500">{formatDate(result.timestamp)}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopy(result.content);
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.expanded && (
|
||||
<div className="p-6">
|
||||
<div className="markdown-body prose prose-sm max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
{result.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-white border-t border-gray-200 mt-12">
|
||||
<div className="max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
基于 CrewAI + Qwen3.5-flash + FastAPI(Polling) 构建 | Bosch Demo
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Copy Toast */}
|
||||
{state.showCopyToast && (
|
||||
<div className="fixed bottom-4 right-4 bg-green-600 text-white px-6 py-3 rounded-lg shadow-lg transition-opacity duration-300">
|
||||
✓ 已复制到剪贴板
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
temp_output.txt
Normal file
198
temp_output.txt
Normal file
@@ -0,0 +1,198 @@
|
||||
abortRef.current?.abort();
|
||||
const fresh = { sessionId: makeId("sess"), userId: makeId("user") };
|
||||
dispatch({ type: "reset", ...fresh });
|
||||
setFiles([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full relative overflow-hidden">
|
||||
{/* ─── Sidebar Overlay ─── */}
|
||||
{isHistoryOpen && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black/20 z-40 transition-opacity duration-300"
|
||||
onClick={() => setIsHistoryOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`absolute top-0 left-0 h-full w-1/4 bg-white border-r border-border z-50 transform transition-transform duration-300 ease-in-out shadow-xl ${
|
||||
isHistoryOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h3 className="text-lg font-bold">历史对话</h3>
|
||||
<button
|
||||
onClick={() => setIsHistoryOpen(false)}
|
||||
className="p-2 hover:bg-surface-muted rounded-full transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{state.historySessions.length === 0 ? (
|
||||
<div className="text-center py-10 text-txt-muted text-sm italic">
|
||||
暂无历史记录
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
{state.historySessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => loadHistorySession(session.sessionId)}
|
||||
className={`p-4 rounded-xl border border-border cursor-pointer transition-all hover:bg-surface-muted hover:border-magenta group ${
|
||||
state.sessionId === session.sessionId ? 'bg-magenta/5 border-magenta ring-1 ring-magenta' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<span className="text-[0.7rem] font-mono text-txt-muted truncate max-w-[120px]">
|
||||
ID: {session.sessionId.slice(0, 8)}...
|
||||
</span>
|
||||
<span className="text-[0.65rem] text-txt-muted">
|
||||
{new Date(session.ts).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-txt leading-snug line-clamp-2 group-hover:text-magenta transition-colors">
|
||||
{session.summary}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{loadingHistory && (
|
||||
<div className="absolute inset-0 bg-white/60 flex items-center justify-center z-[60]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-magenta"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Main Chat ─── */}
|
||||
<div className="h-full flex flex-col min-w-0">
|
||||
<div className="flex-1 overflow-y-auto px-[72px] py-8">
|
||||
{/* History Toggle Button */}
|
||||
<button
|
||||
onClick={() => setIsHistoryOpen(true)}
|
||||
className="absolute top-6 left-6 p-2 bg-white border border-border rounded-lg shadow-sm hover:bg-surface-muted transition-colors z-30 group"
|
||||
title="查看历史记录"
|
||||
>
|
||||
<svg className="w-5 h-5 text-txt-muted group-hover:text-magenta" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
{state.messages.length === 0 && (
|
||||
<div className="text-center py-20 card">
|
||||
<h2 className="text-2xl font-extrabold mb-2">规划委员会代理</h2>
|
||||
<p className="text-txt-muted text-sm">
|
||||
提供一个高层 Epic,代理将从产品、架构与 RTE 视角进行规划。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`max-w-[78%] px-6 py-5 text-[0.95rem] leading-relaxed rounded-2xl shadow-[0_2px_14px_rgba(0,0,0,0.04)] ${
|
||||
msg.role === "user"
|
||||
? "self-end mr-1 bg-surface-muted text-txt border border-border whitespace-pre-wrap"
|
||||
: "self-start bg-white border border-border border-l-4 border-l-magenta"
|
||||
}`}
|
||||
>
|
||||
{msg.role === "assistant" && (
|
||||
<span className="badge mb-3 block w-fit">系统 / 迭代闭环</span>
|
||||
)}
|
||||
{msg.role === "assistant" && state.reasoning[msg.id] && (
|
||||
<details
|
||||
className="mb-4 bg-surface-muted/50 rounded-lg border border-border overflow-hidden"
|
||||
open={!msg.content}
|
||||
>
|
||||
<summary className="px-4 py-2 text-xs font-medium text-txt-muted cursor-pointer hover:bg-border/20 transition-colors list-none flex items-center gap-2">
|
||||
<span className={`w-1.5 h-1.5 rounded-full bg-magenta ${!msg.content ? 'animate-pulse' : ''}`}></span>
|
||||
查看思考过程
|
||||
</summary>
|
||||
<div className="px-4 py-3 bg-white/50 text-[0.8rem] font-mono whitespace-pre-wrap border-t border-border text-txt-muted">
|
||||
{state.reasoning[msg.id]}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{msg.role === "assistant" ? (
|
||||
<div className="prose prose-sm max-w-none prose-p:my-0.5 prose-pre:bg-gray-800 prose-pre:text-gray-100 prose-code:text-magenta prose-code:bg-magenta/10 prose-code:px-1 prose-code:rounded prose-li:my-0">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
>
|
||||
{msg.content || (state.chatting ? "思考中..." : "")}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
msg.content || "Thinking..."
|
||||
)}
|
||||
{msg.status === "failed" && (
|
||||
<span className="text-red-500 text-xs block mt-2">发送失败</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{state.error && (
|
||||
<div className="mx-[72px] mb-2 px-4 py-2 bg-red-50 text-red-700 text-sm flex justify-between items-center">
|
||||
{state.error}
|
||||
<button
|
||||
className="text-red-400 hover:text-red-600 font-bold"
|
||||
onClick={() => dispatch({ type: "set-error" })}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<div className="px-10 pb-6 pt-4 border-t border-border">
|
||||
{/* File upload row */}
|
||||
<div className="flex items-center gap-3 mb-3 text-sm">
|
||||
<label className="btn-outline text-xs px-3 py-1.5 cursor-pointer">
|
||||
{uploading ? "上传中..." : "附加文件"}
|
||||
<input type="file" className="hidden" onChange={handleUpload} disabled={uploading} />
|
||||
</label>
|
||||
{files.map((f) => (
|
||||
<span key={f.id} className="badge text-[0.7rem]">{f.name}</span>
|
||||
))}
|
||||
<button
|
||||
className="ml-auto text-xs text-txt-muted hover:text-magenta"
|
||||
onClick={handleReset}
|
||||
>
|
||||
重置会话
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 items-end">
|
||||
<textarea
|
||||
className="input-field flex-1 min-h-[88px] max-h-56 resize-y"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
placeholder="描述你的 Epic 或提供指引..."
|
||||
disabled={state.chatting}
|
||||
/>
|
||||
<button className="btn-magenta" onClick={send} disabled={state.chatting}>
|
||||
{state.chatting ? "规划中..." : "发送"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,13 +36,13 @@
|
||||
<i class="fa-solid fa-house w-6"></i> 总览控制台
|
||||
</a>
|
||||
<a href="planning.html" class="flex items-center px-4 py-3 text-gray-600 hover:bg-gray-50 hover:text-planning rounded-lg font-medium transition-colors">
|
||||
<i class="fa-solid fa-chess-knight w-6"></i> 战略规划 (Planning)
|
||||
<i class="fa-solid fa-chess-knight w-6"></i> 战略规划
|
||||
</a>
|
||||
<a href="devops.html" class="flex items-center px-4 py-3 bg-green-50 text-devops rounded-lg font-medium">
|
||||
<i class="fa-solid fa-code-branch w-6"></i> 开发运维 (DevOps)
|
||||
<i class="fa-solid fa-code-branch w-6"></i> 开发运维
|
||||
</a>
|
||||
<a href="quality.html" class="flex items-center px-4 py-3 text-gray-600 hover:bg-gray-50 hover:text-quality rounded-lg font-medium transition-colors">
|
||||
<i class="fa-solid fa-shield-halved w-6"></i> 质量门控 (Quality Gate)
|
||||
<i class="fa-solid fa-shield-halved w-6"></i> 质量门控
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -86,7 +86,7 @@
|
||||
<div class="w-1/3 border-r border-gray-200 bg-gray-50 flex flex-col">
|
||||
<div class="flex-1 p-4 overflow-y-auto space-y-4 text-sm">
|
||||
<div class="bg-white p-3 rounded-lg border shadow-sm">
|
||||
<p class="text-gray-800 font-semibold mb-1"><i class="fa-solid fa-robot text-devops mr-1"></i> Developer Agent</p>
|
||||
<p class="text-gray-800 font-semibold mb-1"><i class="fa-solid fa-robot text-devops mr-1"></i> 开发代理</p>
|
||||
<p class="text-gray-600">我已经根据您的 "登录功能" 需求,生成了用户控制器的代码框架和对应的单元测试(Jest)。</p>
|
||||
<p class="text-gray-600 mt-2">测试用例涵盖了 5 种边界情况,您可以查看右侧代码。</p>
|
||||
</div>
|
||||
|
||||
@@ -57,13 +57,13 @@
|
||||
<i class="fa-solid fa-house w-6"></i> 总览控制台
|
||||
</a>
|
||||
<a href="planning.html" class="flex items-center px-4 py-3 text-gray-600 hover:bg-gray-50 hover:text-planning rounded-lg font-medium transition-colors">
|
||||
<i class="fa-solid fa-chess-knight w-6"></i> 战略规划 (Planning)
|
||||
<i class="fa-solid fa-chess-knight w-6"></i> 战略规划
|
||||
</a>
|
||||
<a href="devops.html" class="flex items-center px-4 py-3 text-gray-600 hover:bg-gray-50 hover:text-devops rounded-lg font-medium transition-colors">
|
||||
<i class="fa-solid fa-code-branch w-6"></i> 开发运维 (DevOps)
|
||||
<i class="fa-solid fa-code-branch w-6"></i> 开发运维
|
||||
</a>
|
||||
<a href="quality.html" class="flex items-center px-4 py-3 text-gray-600 hover:bg-gray-50 hover:text-quality rounded-lg font-medium transition-colors">
|
||||
<i class="fa-solid fa-shield-halved w-6"></i> 质量门控 (Quality Gate)
|
||||
<i class="fa-solid fa-shield-halved w-6"></i> 质量门控
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -36,13 +36,13 @@
|
||||
<i class="fa-solid fa-house w-6"></i> 总览控制台
|
||||
</a>
|
||||
<a href="planning.html" class="flex items-center px-4 py-3 bg-blue-50 text-planning rounded-lg font-medium">
|
||||
<i class="fa-solid fa-chess-knight w-6"></i> 战略规划 (Planning)
|
||||
<i class="fa-solid fa-chess-knight w-6"></i> 战略规划
|
||||
</a>
|
||||
<a href="devops.html" class="flex items-center px-4 py-3 text-gray-600 hover:bg-gray-50 hover:text-devops rounded-lg font-medium transition-colors">
|
||||
<i class="fa-solid fa-code-branch w-6"></i> 开发运维 (DevOps)
|
||||
<i class="fa-solid fa-code-branch w-6"></i> 开发运维
|
||||
</a>
|
||||
<a href="quality.html" class="flex items-center px-4 py-3 text-gray-600 hover:bg-gray-50 hover:text-quality rounded-lg font-medium transition-colors">
|
||||
<i class="fa-solid fa-shield-halved w-6"></i> 质量门控 (Quality Gate)
|
||||
<i class="fa-solid fa-shield-halved w-6"></i> 质量门控
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -36,13 +36,13 @@
|
||||
<i class="fa-solid fa-house w-6"></i> 总览控制台
|
||||
</a>
|
||||
<a href="planning.html" class="flex items-center px-4 py-3 text-gray-600 hover:bg-gray-50 hover:text-planning rounded-lg font-medium transition-colors">
|
||||
<i class="fa-solid fa-chess-knight w-6"></i> 战略规划 (Planning)
|
||||
<i class="fa-solid fa-chess-knight w-6"></i> 战略规划
|
||||
</a>
|
||||
<a href="devops.html" class="flex items-center px-4 py-3 text-gray-600 hover:bg-gray-50 hover:text-devops rounded-lg font-medium transition-colors">
|
||||
<i class="fa-solid fa-code-branch w-6"></i> 开发运维 (DevOps)
|
||||
<i class="fa-solid fa-code-branch w-6"></i> 开发运维
|
||||
</a>
|
||||
<a href="quality.html" class="flex items-center px-4 py-3 bg-purple-50 text-quality rounded-lg font-medium">
|
||||
<i class="fa-solid fa-shield-halved w-6"></i> 质量门控 (Quality Gate)
|
||||
<i class="fa-solid fa-shield-halved w-6"></i> 质量门控
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -50,7 +50,7 @@
|
||||
|
||||
<main class="flex-1 flex flex-col overflow-y-auto">
|
||||
<header class="h-16 bg-white shadow-sm flex items-center px-8 border-b-4 border-quality sticky top-0 z-10">
|
||||
<h1 class="text-xl font-semibold text-gray-800">质量门控 (Quality Gate)Dashboard</h1>
|
||||
<h1 class="text-xl font-semibold text-gray-800">质量门控 大盘</h1>
|
||||
</header>
|
||||
|
||||
<div class="p-8 max-w-7xl mx-auto w-full">
|
||||
@@ -59,7 +59,7 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="bg-white p-4 rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="text-gray-500 mb-1 text-sm font-semibold">质量状态</div>
|
||||
<div class="text-2xl font-bold text-green-500">通过 (Passed)</div>
|
||||
<div class="text-2xl font-bold text-green-500">通过</div>
|
||||
</div>
|
||||
<div class="bg-white p-4 rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="text-gray-500 mb-1 text-sm font-semibold">代码覆盖率</div>
|
||||
@@ -78,7 +78,7 @@
|
||||
<!-- PR 列表 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-8">
|
||||
<div class="p-4 border-b border-gray-200 bg-gray-50 flex justify-between items-center">
|
||||
<h2 class="font-bold text-gray-800">合并请求审查 (PR List)</h2>
|
||||
<h2 class="font-bold text-gray-800">合并请求审查</h2>
|
||||
<span class="text-sm text-gray-500">共 3 个待处理</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200">
|
||||
|
||||
@@ -1,685 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SDLC Agent Demo - 多智能体软件交付协同系统</title>
|
||||
|
||||
<!-- TailwindCSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- Vue 3 -->
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
|
||||
<!-- Highlight.js 代码高亮 - 使用浅色主题 -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
|
||||
<!-- Marked Markdown 解析 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
|
||||
<style>
|
||||
/* 自定义样式 */
|
||||
.stage-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stage-card.active {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 10px 25px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.stage-card.completed {
|
||||
border-color: #10B981;
|
||||
background: linear-gradient(135deg, #ECFDF5 0%, #FFFFFF 100%);
|
||||
}
|
||||
|
||||
.stage-card.processing {
|
||||
border-color: #3B82F6;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4); }
|
||||
50% { box-shadow: 0 0 0 10px rgba(59, 130, 246, 0); }
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
background: #f6f8fa;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin: 0.5rem 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #e1e4e8;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: #24292e;
|
||||
}
|
||||
|
||||
.markdown-body h1, .markdown-body h2, .markdown-body h3 {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-body ul, .markdown-body ol {
|
||||
padding-left: 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* SSE 连接状态指示器 */
|
||||
.connection-status {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.connection-connected {
|
||||
background-color: #10B981;
|
||||
}
|
||||
|
||||
.connection-disconnected {
|
||||
background-color: #EF4444;
|
||||
}
|
||||
|
||||
.connection-connecting {
|
||||
background-color: #F59E0B;
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<div id="app" class="min-h-screen">
|
||||
<!-- 顶部导航栏 -->
|
||||
<header class="bg-white shadow-sm border-b border-gray-200">
|
||||
<div class="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-8 w-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h1 class="text-xl font-bold text-gray-900">SDLC Agent Demo</h1>
|
||||
<p class="text-sm text-gray-500">多智能体端到端软件交付协同系统</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center text-sm">
|
||||
<span :class="['connection-status', connectionStatusClass]"></span>
|
||||
<span class="text-gray-600">{{ connectionStatusText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="max-w-7xl mx-auto px-4 py-8 sm:px-6 lg:px-8">
|
||||
<!-- 需求输入区 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">1. 输入软件需求</h2>
|
||||
<div class="space-y-4">
|
||||
<textarea
|
||||
v-model="requirement"
|
||||
rows="5"
|
||||
placeholder="请输入您的软件需求描述,例如: 开发一个用户管理系统,支持用户的增删改查功能,需要包含以下特性: - 用户注册和登录 - 用户信息管理 - 角色权限控制 - 操作日志记录"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
:disabled="isProcessing"
|
||||
></textarea>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-gray-500">
|
||||
当前任务 ID: {{ taskId || '无' }}
|
||||
</p>
|
||||
<button
|
||||
@click="startSDLCProcess"
|
||||
:disabled="!canStart || isProcessing"
|
||||
:class="[
|
||||
'px-6 py-2.5 rounded-lg font-medium text-white transition-all duration-200',
|
||||
canStart && !isProcessing
|
||||
? 'bg-blue-600 hover:bg-blue-700 shadow-md hover:shadow-lg'
|
||||
: 'bg-gray-400 cursor-not-allowed'
|
||||
]"
|
||||
>
|
||||
{{ isProcessing ? '执行中...' : '开始执行' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度展示区 -->
|
||||
<div class="mb-6" v-show="stages.length > 0">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">2. 执行进度</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="(stage, index) in stages"
|
||||
:key="stage.id"
|
||||
:class="[
|
||||
'stage-card rounded-lg border-2 p-4 bg-white',
|
||||
{ 'active': stage.status === 'processing' },
|
||||
{ 'completed': stage.status === 'completed' },
|
||||
{ 'processing': stage.status === 'processing' }
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center mb-2">
|
||||
<div
|
||||
:class="[
|
||||
'w-8 h-8 rounded-full flex items-center justify-center mr-3',
|
||||
getStageIconClass(stage.status)
|
||||
]"
|
||||
>
|
||||
<span class="text-white font-bold text-sm">{{ index + 1 }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900">{{ stage.name }}</h3>
|
||||
<p class="text-xs text-gray-500">{{ stage.agent }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<span :class="[
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
getStageBadgeClass(stage.status)
|
||||
]">
|
||||
{{ getStageStatusText(stage.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 实时日志区 -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6" v-show="logs.length > 0">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">3. 实时日志</h2>
|
||||
<button
|
||||
@click="clearLogs"
|
||||
class="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
清空日志
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-gray-900 rounded-lg p-4 h-64 overflow-y-auto font-mono text-sm">
|
||||
<div v-for="(log, index) in logs" :key="index" class="mb-1">
|
||||
<span class="text-gray-500">{{ log.timestamp }}</span>
|
||||
<span :class="getLogLevelClass(log.event)" class="ml-2">[{{ log.event }}]</span>
|
||||
<span class="text-gray-300 ml-2">{{ log.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结果展示区 -->
|
||||
<div class="space-y-6" v-show="results.length > 0">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900">4. 输出结果</h2>
|
||||
<button
|
||||
@click="downloadResults"
|
||||
:disabled="!isCompleted"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-lg font-medium text-white transition-all duration-200 flex items-center',
|
||||
isCompleted
|
||||
? 'bg-green-600 hover:bg-green-700 shadow-md'
|
||||
: 'bg-gray-400 cursor-not-allowed'
|
||||
]"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
{{ isCompleted ? '打包下载结果' : '执行完成后下载' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-for="(result, index) in results" :key="index" class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div
|
||||
@click="result.expanded = !result.expanded"
|
||||
class="px-6 py-4 bg-gray-50 border-b border-gray-200 cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<svg
|
||||
:class="['w-5 h-5 mr-2 text-gray-500 transform transition-transform', result.expanded ? 'rotate-90' : '']"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
<h3 class="font-semibold text-gray-900">{{ result.title }}</h3>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-xs text-gray-500">{{ formatDate(result.timestamp) }}</span>
|
||||
<button
|
||||
@click.stop="copyToClipboard(result.content)"
|
||||
class="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="result.expanded" class="p-6">
|
||||
<div class="markdown-body" v-html="renderMarkdown(result.content)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<footer class="bg-white border-t border-gray-200 mt-12">
|
||||
<div class="max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
|
||||
<p class="text-center text-sm text-gray-500">
|
||||
基于 CrewAI + Qwen3.5-flash + FastAPI(SSE) 构建 | Bosch Demo
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- 复制成功提示 -->
|
||||
<div v-if="showCopyToast" class="fixed bottom-4 right-4 bg-green-600 text-white px-6 py-3 rounded-lg shadow-lg transition-opacity duration-300">
|
||||
✓ 已复制到剪贴板
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp } = Vue;
|
||||
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
// 需求输入
|
||||
requirement: '',
|
||||
|
||||
// 任务管理
|
||||
taskId: null,
|
||||
isProcessing: false,
|
||||
|
||||
// SSE 连接
|
||||
eventSource: null,
|
||||
connectionStatus: 'disconnected', // 'connecting', 'connected', 'disconnected'
|
||||
|
||||
// 阶段定义
|
||||
stages: [],
|
||||
|
||||
// 实时日志
|
||||
logs: [],
|
||||
|
||||
// 结果数据
|
||||
results: [],
|
||||
|
||||
// UI 状态
|
||||
showCopyToast: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
canStart() {
|
||||
return this.requirement.trim().length >= 10 && !this.isProcessing;
|
||||
},
|
||||
|
||||
connectionStatusClass() {
|
||||
const statusMap = {
|
||||
'connecting': 'connection-connecting',
|
||||
'connected': 'connection-connected',
|
||||
'disconnected': 'connection-disconnected'
|
||||
};
|
||||
return statusMap[this.connectionStatus];
|
||||
},
|
||||
|
||||
connectionStatusText() {
|
||||
const textMap = {
|
||||
'connecting': '连接中...',
|
||||
'connected': '已连接',
|
||||
'disconnected': '未连接'
|
||||
};
|
||||
return textMap[this.connectionStatus];
|
||||
},
|
||||
|
||||
isCompleted() {
|
||||
return this.stages.length > 0 &&
|
||||
this.stages.every(s => s.status === 'completed');
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* 下载打包结果
|
||||
*/
|
||||
downloadResults() {
|
||||
if (!this.taskId || !this.isCompleted) return;
|
||||
|
||||
const url = `/api/v1/sdlc/download/${this.taskId}`;
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
|
||||
/**
|
||||
* 启动 SDLC 流程
|
||||
*/
|
||||
async startSDLCProcess() {
|
||||
if (!this.canStart) return;
|
||||
|
||||
try {
|
||||
this.isProcessing = true;
|
||||
this.stages = [];
|
||||
this.logs = [];
|
||||
this.results = [];
|
||||
|
||||
// 调用 API 启动任务
|
||||
const response = await fetch('/api/v1/sdlc/start', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
requirement: this.requirement
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.taskId = data.task_id;
|
||||
this.addLog('system', '任务已启动', `Task ID: ${data.task_id}`);
|
||||
|
||||
// 初始化阶段
|
||||
this.initStages();
|
||||
|
||||
// 开始轮询任务事件
|
||||
this.connectPolling(data.task_id);
|
||||
|
||||
} catch (error) {
|
||||
console.error('启动失败:', error);
|
||||
this.addLog('error', '启动失败', error.message);
|
||||
this.isProcessing = false;
|
||||
alert(`启动失败:${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 初始化阶段
|
||||
*/
|
||||
initStages() {
|
||||
this.stages = [
|
||||
{ id: 'pm', name: '需求分析', agent: 'PM Agent', status: 'pending' },
|
||||
{ id: 'qa', name: '测试设计', agent: 'QA Agent', status: 'pending' },
|
||||
{ id: 'dev', name: '代码实现', agent: 'Dev Agent', status: 'pending' },
|
||||
{ id: 'final', name: '交付完成', agent: 'Orchestrator', status: 'pending' }
|
||||
];
|
||||
},
|
||||
|
||||
/**
|
||||
* 轮询任务事件(替代 SSE)
|
||||
*/
|
||||
connectPolling(taskId) {
|
||||
this.connectionStatus = 'connecting';
|
||||
this.addLog('system', 'POLL', `开始轮询任务:${taskId}`);
|
||||
|
||||
let lastIndex = 0;
|
||||
let pollCount = 0;
|
||||
const maxPolls = 600; // 最多轮询 600 次 (10 分钟)
|
||||
|
||||
const poll = () => {
|
||||
if (pollCount >= maxPolls) {
|
||||
this.addLog('system', 'POLL', '轮询超时');
|
||||
this.isProcessing = false;
|
||||
this.connectionStatus = 'disconnected';
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/v1/sdlc/poll/${taskId}?last_index=${lastIndex}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
const { events, has_more, status } = data;
|
||||
|
||||
// 处理新事件
|
||||
events.forEach(event => {
|
||||
lastIndex++;
|
||||
this.handleEvent(event);
|
||||
});
|
||||
|
||||
// 检查是否继续轮询
|
||||
if (status === 'completed' || status === 'failed') {
|
||||
this.isProcessing = false;
|
||||
this.connectionStatus = 'disconnected';
|
||||
this.addLog('system', 'POLL', `任务完成,状态:${status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (has_more || events.length > 0) {
|
||||
pollCount++;
|
||||
setTimeout(poll, 500); // 每 500ms 轮询一次
|
||||
} else if (status === 'processing') {
|
||||
pollCount++;
|
||||
setTimeout(poll, 1000); // 无新事件时 1 秒后再试
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('轮询失败:', err);
|
||||
this.addLog('error', 'POLL', err.message);
|
||||
pollCount++;
|
||||
setTimeout(poll, 2000);
|
||||
});
|
||||
};
|
||||
|
||||
// 开始轮询
|
||||
setTimeout(poll, 500);
|
||||
},
|
||||
|
||||
/**
|
||||
* 处理单个事件
|
||||
*/
|
||||
handleEvent(event) {
|
||||
const eventType = event.event;
|
||||
const data = event.data;
|
||||
|
||||
switch(eventType) {
|
||||
case 'task_started':
|
||||
this.addLog('task_started', 'System', data.message || '任务已启动');
|
||||
break;
|
||||
|
||||
case 'pm_start':
|
||||
this.updateStageStatus('pm', 'processing');
|
||||
this.addLog('pm_start', 'PM Agent', '开始需求分析...');
|
||||
break;
|
||||
|
||||
case 'pm_complete':
|
||||
this.updateStageStatus('pm', 'completed');
|
||||
this.addLog('pm_complete', 'PM Agent', '需求分析完成');
|
||||
this.addResult('📋 软件需求规格说明书 (SRS)', data.content, data.timestamp);
|
||||
break;
|
||||
|
||||
case 'qa_start':
|
||||
this.updateStageStatus('qa', 'processing');
|
||||
this.addLog('qa_start', 'QA Agent', '开始测试用例设计...');
|
||||
break;
|
||||
|
||||
case 'qa_complete':
|
||||
this.updateStageStatus('qa', 'completed');
|
||||
this.addLog('qa_complete', 'QA Agent', '测试用例设计完成');
|
||||
this.addResult('🧪 测试方案与用例', data.content, data.timestamp);
|
||||
break;
|
||||
|
||||
case 'dev_start':
|
||||
this.updateStageStatus('dev', 'processing');
|
||||
this.addLog('dev_start', 'Dev Agent', '开始代码实现...');
|
||||
break;
|
||||
|
||||
case 'dev_complete':
|
||||
this.updateStageStatus('dev', 'completed');
|
||||
this.addLog('dev_complete', 'Dev Agent', '代码实现完成');
|
||||
this.addResult('💻 代码实现', data.content, data.timestamp);
|
||||
break;
|
||||
|
||||
case 'final_result':
|
||||
this.updateStageStatus('final', 'completed');
|
||||
this.addLog('final_result', 'System', 'SDLC 流程完成');
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
this.addLog('error', 'Error', data.error || '未知错误');
|
||||
alert(`执行错误:${data.error}`);
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新阶段状态
|
||||
*/
|
||||
updateStageStatus(stageId, status) {
|
||||
const stage = this.stages.find(s => s.id === stageId);
|
||||
if (stage) {
|
||||
stage.status = status;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加日志
|
||||
*/
|
||||
addLog(event, source, message) {
|
||||
this.logs.push({
|
||||
timestamp: new Date().toLocaleTimeString('zh-CN'),
|
||||
event,
|
||||
source,
|
||||
message
|
||||
});
|
||||
// 保持最新 100 条日志
|
||||
if (this.logs.length > 100) {
|
||||
this.logs.shift();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加结果
|
||||
*/
|
||||
addResult(title, content, timestamp) {
|
||||
this.results.push({
|
||||
title,
|
||||
content,
|
||||
timestamp,
|
||||
expanded: true
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 清空日志
|
||||
*/
|
||||
clearLogs() {
|
||||
this.logs = [];
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取阶段图标样式
|
||||
*/
|
||||
getStageIconClass(status) {
|
||||
const classMap = {
|
||||
'pending': 'bg-gray-400',
|
||||
'processing': 'bg-blue-500',
|
||||
'completed': 'bg-green-500'
|
||||
};
|
||||
return classMap[status] || classMap['pending'];
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取阶段徽章样式
|
||||
*/
|
||||
getStageBadgeClass(status) {
|
||||
const classMap = {
|
||||
'pending': 'bg-gray-100 text-gray-800',
|
||||
'processing': 'bg-blue-100 text-blue-800',
|
||||
'completed': 'bg-green-100 text-green-800'
|
||||
};
|
||||
return classMap[status] || classMap['pending'];
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取阶段状态文本
|
||||
*/
|
||||
getStageStatusText(status) {
|
||||
const textMap = {
|
||||
'pending': '等待中',
|
||||
'processing': '进行中',
|
||||
'completed': '已完成'
|
||||
};
|
||||
return textMap[status] || status;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取日志级别样式
|
||||
*/
|
||||
getLogLevelClass(event) {
|
||||
const classMap = {
|
||||
'pm_start': 'text-blue-400',
|
||||
'pm_complete': 'text-green-400',
|
||||
'qa_start': 'text-blue-400',
|
||||
'qa_complete': 'text-green-400',
|
||||
'dev_start': 'text-blue-400',
|
||||
'dev_complete': 'text-green-400',
|
||||
'final_result': 'text-purple-400',
|
||||
'error': 'text-red-400',
|
||||
'system': 'text-yellow-400'
|
||||
};
|
||||
return classMap[event] || 'text-gray-400';
|
||||
},
|
||||
|
||||
/**
|
||||
* 渲染 Markdown
|
||||
*/
|
||||
renderMarkdown(content) {
|
||||
if (!content) return '';
|
||||
return marked.parse(content);
|
||||
},
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
formatDate(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
try {
|
||||
return new Date(timestamp).toLocaleString('zh-CN');
|
||||
} catch {
|
||||
return timestamp;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 复制到剪贴板
|
||||
*/
|
||||
async copyToClipboard(content) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
this.showCopyToast = true;
|
||||
setTimeout(() => {
|
||||
this.showCopyToast = false;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err);
|
||||
alert('复制失败,请手动复制');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
// 清理 SSE 连接
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
}
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -23,11 +23,6 @@ export default defineConfig(({ mode }) => {
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/quality-api/, "/api"),
|
||||
},
|
||||
"/task-api": {
|
||||
target: env.VITE_QUALITY_API_BASE || "http://localhost:8080",
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/task-api/, "/api"),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user