365 lines
8.2 KiB
Markdown
365 lines
8.2 KiB
Markdown
|
|
# Phase 5:前端集成
|
|||
|
|
|
|||
|
|
日期:2026-04-04
|
|||
|
|
状态:待实施
|
|||
|
|
|
|||
|
|
依赖:Phase 4 完成
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. 本阶段目的
|
|||
|
|
|
|||
|
|
Vue 前端新增代码指挥官 UI:
|
|||
|
|
- 页面组件
|
|||
|
|
- 终端显示组件
|
|||
|
|
- WebSocket 服务
|
|||
|
|
- 路由配置
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 2. 详细任务
|
|||
|
|
|
|||
|
|
### 2.1 页面组件
|
|||
|
|
|
|||
|
|
**新文件**: `frontend/src/pages/chat/CodeCommander.vue`
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<template>
|
|||
|
|
<div class="code-commander">
|
|||
|
|
<!-- AI 提供商选择器 -->
|
|||
|
|
<div class="provider-selector">
|
|||
|
|
<div class="label">选择 AI 助手</div>
|
|||
|
|
<div class="providers">
|
|||
|
|
<button
|
|||
|
|
v-for="p in providers"
|
|||
|
|
:key="p.id"
|
|||
|
|
:class="{ active: selectedProvider === p.id }"
|
|||
|
|
@click="selectedProvider = p.id"
|
|||
|
|
>
|
|||
|
|
<img :src="p.icon" :alt="p.name" />
|
|||
|
|
{{ p.name }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 任务输入 -->
|
|||
|
|
<div class="task-input">
|
|||
|
|
<textarea
|
|||
|
|
v-model="taskPrompt"
|
|||
|
|
placeholder="描述你想让 AI 帮你做什么..."
|
|||
|
|
rows="4"
|
|||
|
|
/>
|
|||
|
|
<button @click="executeTask" :disabled="isExecuting">
|
|||
|
|
{{ isExecuting ? '执行中...' : '开始执行' }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 终端输出 -->
|
|||
|
|
<TerminalDisplay
|
|||
|
|
ref="terminalRef"
|
|||
|
|
:session-id="currentSessionId"
|
|||
|
|
@input="handleUserInput"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<!-- 交互输入框 -->
|
|||
|
|
<div v-if="isWaitingForInput" class="interactive-input">
|
|||
|
|
<span>{{ inputPrompt }}</span>
|
|||
|
|
<input v-model="userInput" @keyup.enter="sendUserInput" />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 操作按钮 -->
|
|||
|
|
<div class="actions">
|
|||
|
|
<button @click="downloadFiles" :disabled="!canDownload">
|
|||
|
|
下载文件
|
|||
|
|
</button>
|
|||
|
|
<button @click="cleanup" :disabled="!canCleanup">
|
|||
|
|
清理
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { ref, computed } from 'vue'
|
|||
|
|
import TerminalDisplay from '@/components/TerminalDisplay.vue'
|
|||
|
|
import { terminalWsService } from '@/services/terminalWs'
|
|||
|
|
|
|||
|
|
const providers = [
|
|||
|
|
{ id: 'claude', name: 'Claude', icon: '/icons/claude.png' },
|
|||
|
|
{ id: 'gemini', name: 'Gemini', icon: '/icons/gemini.png' },
|
|||
|
|
{ id: 'codex', name: 'Codex', icon: '/icons/codex.png' },
|
|||
|
|
{ id: 'opencode', name: 'OpenCode', icon: '/icons/opencode.png' },
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
const selectedProvider = ref('claude')
|
|||
|
|
const taskPrompt = ref('')
|
|||
|
|
const isExecuting = ref(false)
|
|||
|
|
const currentSessionId = ref<string | null>(null)
|
|||
|
|
const isWaitingForInput = ref(false)
|
|||
|
|
const inputPrompt = ref('')
|
|||
|
|
const userInput = ref('')
|
|||
|
|
const terminalRef = ref<InstanceType<typeof TerminalDisplay> | null>(null)
|
|||
|
|
|
|||
|
|
const canDownload = computed(() => currentSessionId.value !== null)
|
|||
|
|
const canCleanup = computed(() => currentSessionId.value !== null)
|
|||
|
|
|
|||
|
|
async function executeTask() {
|
|||
|
|
if (!taskPrompt.value.trim()) return
|
|||
|
|
|
|||
|
|
isExecuting.value = true
|
|||
|
|
currentSessionId.value = await terminalWsService.connect(selectedProvider.value)
|
|||
|
|
|
|||
|
|
// 订阅消息
|
|||
|
|
terminalWsService.onMessage((msg) => {
|
|||
|
|
if (msg.type === 'output') {
|
|||
|
|
terminalRef.value?.write(msg.data)
|
|||
|
|
} else if (msg.type === 'waiting_input') {
|
|||
|
|
isWaitingForInput.value = true
|
|||
|
|
inputPrompt.value = msg.data
|
|||
|
|
} else if (msg.type === 'complete') {
|
|||
|
|
isExecuting.value = false
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 发送任务
|
|||
|
|
await terminalWsService.sendTask(currentSessionId.value, taskPrompt.value)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleUserInput(data: string) {
|
|||
|
|
terminalWsService.sendInput(currentSessionId.value!, data)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function sendUserInput() {
|
|||
|
|
terminalWsService.sendInput(currentSessionId.value!, userInput.value)
|
|||
|
|
userInput.value = ''
|
|||
|
|
isWaitingForInput.value = false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function downloadFiles() {
|
|||
|
|
// TODO: 调用下载 API
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function cleanup() {
|
|||
|
|
if (currentSessionId.value) {
|
|||
|
|
await terminalWsService.disconnect(currentSessionId.value)
|
|||
|
|
currentSessionId.value = null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.2 终端显示组件
|
|||
|
|
|
|||
|
|
**新文件**: `frontend/src/components/TerminalDisplay.vue`
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<template>
|
|||
|
|
<div class="terminal-display" ref="containerRef">
|
|||
|
|
<div class="terminal-output" ref="outputRef"></div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { ref, onMounted, onUnmounted } from 'vue'
|
|||
|
|
import { Terminal } from 'xterm'
|
|||
|
|
import { FitAddon } from 'xterm-addon-fit'
|
|||
|
|
import 'xterm/css/xterm.css'
|
|||
|
|
|
|||
|
|
const props = defineProps<{
|
|||
|
|
sessionId: string | null
|
|||
|
|
}>()
|
|||
|
|
|
|||
|
|
const emit = defineEmits<{
|
|||
|
|
input: [data: string]
|
|||
|
|
}>()
|
|||
|
|
|
|||
|
|
const containerRef = ref<HTMLElement | null>(null)
|
|||
|
|
const outputRef = ref<HTMLElement | null>(null)
|
|||
|
|
let terminal: Terminal | null = null
|
|||
|
|
let fitAddon: FitAddon | null = null
|
|||
|
|
|
|||
|
|
onMounted(() => {
|
|||
|
|
terminal = new Terminal({
|
|||
|
|
theme: { background: '#1e1e1e' },
|
|||
|
|
cursorBlink: true,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
fitAddon = new FitAddon()
|
|||
|
|
terminal.loadAddon(fitAddon)
|
|||
|
|
|
|||
|
|
terminal.open(outputRef.value!)
|
|||
|
|
fitAddon.fit()
|
|||
|
|
|
|||
|
|
// 用户输入
|
|||
|
|
terminal.onData((data) => {
|
|||
|
|
emit('input', data)
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
onUnmounted(() => {
|
|||
|
|
terminal?.dispose()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
function write(data: string) {
|
|||
|
|
terminal?.write(data)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function clear() {
|
|||
|
|
terminal?.clear()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
defineExpose({ write, clear })
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.terminal-display {
|
|||
|
|
background: #1e1e1e;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.terminal-output {
|
|||
|
|
padding: 12px;
|
|||
|
|
min-height: 400px;
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.3 WebSocket 服务
|
|||
|
|
|
|||
|
|
**新文件**: `frontend/src/services/terminalWs.ts`
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
type MessageHandler = (msg: StreamMessage) => void
|
|||
|
|
|
|||
|
|
interface StreamMessage {
|
|||
|
|
type: 'output' | 'error' | 'status' | 'waiting_input' | 'complete'
|
|||
|
|
session_id: string
|
|||
|
|
data: string
|
|||
|
|
timestamp: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class TerminalWsService {
|
|||
|
|
private ws: WebSocket | null = null
|
|||
|
|
private sessionId: string | null = null
|
|||
|
|
private handlers: MessageHandler[] = []
|
|||
|
|
private reconnectAttempts = 0
|
|||
|
|
private maxReconnectAttempts = 5
|
|||
|
|
|
|||
|
|
async connect(provider: string): Promise<string> {
|
|||
|
|
// 创建会话
|
|||
|
|
const response = await fetch('/api/code-commander/sessions', {
|
|||
|
|
method: 'POST',
|
|||
|
|
body: JSON.stringify({ provider }),
|
|||
|
|
})
|
|||
|
|
const { session_id } = await response.json()
|
|||
|
|
|
|||
|
|
// 建立 WebSocket
|
|||
|
|
this.ws = new WebSocket(`ws://localhost:8000/ws/terminal/${session_id}`)
|
|||
|
|
|
|||
|
|
this.ws.onmessage = (event) => {
|
|||
|
|
const msg: StreamMessage = JSON.parse(event.data)
|
|||
|
|
this.handlers.forEach((h) => h(msg))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.ws.onclose = () => {
|
|||
|
|
this.attemptReconnect()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.sessionId = session_id
|
|||
|
|
return session_id
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async sendTask(sessionId: string, prompt: string) {
|
|||
|
|
await fetch(`/api/code-commander/sessions/${sessionId}/task`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
body: JSON.stringify({ prompt }),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
sendInput(sessionId: string, input: string) {
|
|||
|
|
this.ws?.send(JSON.stringify({ type: 'input', data: input }))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMessage(handler: MessageHandler) {
|
|||
|
|
this.handlers.push(handler)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
removeHandler(handler: MessageHandler) {
|
|||
|
|
this.handlers = this.handlers.filter((h) => h !== handler)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async disconnect(sessionId: string) {
|
|||
|
|
await fetch(`/api/code-commander/sessions/${sessionId}`, {
|
|||
|
|
method: 'DELETE',
|
|||
|
|
})
|
|||
|
|
this.ws?.close()
|
|||
|
|
this.ws = null
|
|||
|
|
this.sessionId = null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private async attemptReconnect() {
|
|||
|
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
this.reconnectAttempts++
|
|||
|
|
await new Promise((r) => setTimeout(r, 1000 * this.reconnectAttempts))
|
|||
|
|
// 重新连接
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const terminalWsService = new TerminalWsService()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.4 路由配置
|
|||
|
|
|
|||
|
|
**文件**: `frontend/src/router/index.ts`
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
{
|
|||
|
|
path: '/code-commander',
|
|||
|
|
name: 'CodeCommander',
|
|||
|
|
component: () => import('@/pages/chat/CodeCommander.vue'),
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 3. 核心文件清单
|
|||
|
|
|
|||
|
|
| 文件 | 操作 | 说明 |
|
|||
|
|
|------|------|------|
|
|||
|
|
| `CodeCommander.vue` | 新增 | 主页面组件 |
|
|||
|
|
| `TerminalDisplay.vue` | 新增 | 终端显示组件(xterm.js) |
|
|||
|
|
| `terminalWs.ts` | 新增 | WebSocket 服务 |
|
|||
|
|
| `router/index.ts` | 修改 | 新增路由 |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 4. 验收标准
|
|||
|
|
|
|||
|
|
- [ ] 用户可以选择 AI 提供商
|
|||
|
|
- [ ] 可以输入任务描述并执行
|
|||
|
|
- [ ] 终端实时显示 AI 输出
|
|||
|
|
- [ ] 用户可以输入交互(如 "y")
|
|||
|
|
- [ ] 可以下载和清理文件
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 5. 依赖
|
|||
|
|
|
|||
|
|
| 依赖 | 版本 | 用途 |
|
|||
|
|
|------|------|------|
|
|||
|
|
| xterm | ^5.x | 终端渲染 |
|
|||
|
|
| xterm-addon-fit | ^0.8.x | 自适应大小 |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 6. 依赖关系
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
Phase 4(流式交互)
|
|||
|
|
↓
|
|||
|
|
本阶段(前端集成)→ 端到端测试
|
|||
|
|
```
|