Tori: AI agent workflow manager - initial implementation
Rust (Axum) + Vue 3 + SQLite. Features: - Project CRUD REST API with proper error handling - Per-project agent loop (mpsc + broadcast channels) - LLM-driven plan generation and replan on user feedback - SSH command execution with status streaming - WebSocket real-time updates to frontend - Four-zone UI: requirement, plan (left), execution (right), comment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
88
web/src/components/AppLayout.vue
Normal file
88
web/src/components/AppLayout.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import Sidebar from './Sidebar.vue'
|
||||
import WorkflowView from './WorkflowView.vue'
|
||||
import { api } from '../api'
|
||||
import type { Project } from '../types'
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const selectedProjectId = ref('')
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
projects.value = await api.listProjects()
|
||||
const first = projects.value[0]
|
||||
if (first) {
|
||||
selectedProjectId.value = first.id
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
})
|
||||
|
||||
function onSelectProject(id: string) {
|
||||
selectedProjectId.value = id
|
||||
}
|
||||
|
||||
async function onCreateProject() {
|
||||
const name = prompt('项目名称')
|
||||
if (!name) return
|
||||
try {
|
||||
const project = await api.createProject(name)
|
||||
projects.value.unshift(project)
|
||||
selectedProjectId.value = project.id
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<Sidebar
|
||||
:projects="projects"
|
||||
:selectedId="selectedProjectId"
|
||||
@select="onSelectProject"
|
||||
@create="onCreateProject"
|
||||
/>
|
||||
<main class="main-content">
|
||||
<div v-if="error" class="error-banner">{{ error }}</div>
|
||||
<div v-if="!selectedProjectId" class="empty-state">
|
||||
选择或创建一个项目开始
|
||||
</div>
|
||||
<WorkflowView v-else :projectId="selectedProjectId" :key="selectedProjectId" />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: var(--error);
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
111
web/src/components/CommentSection.vue
Normal file
111
web/src/components/CommentSection.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { Comment } from '../types'
|
||||
|
||||
defineProps<{
|
||||
comments: Comment[]
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [text: string]
|
||||
}>()
|
||||
|
||||
const input = ref('')
|
||||
|
||||
function submit() {
|
||||
const text = input.value.trim()
|
||||
if (!text) return
|
||||
emit('submit', text)
|
||||
input.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="comment-section">
|
||||
<div class="comments-display" v-if="comments.length">
|
||||
<div v-for="comment in comments" :key="comment.id" class="comment-item">
|
||||
<span class="comment-time">{{ new Date(comment.created_at).toLocaleTimeString() }}</span>
|
||||
<span class="comment-text">{{ comment.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment-input">
|
||||
<textarea
|
||||
v-model="input"
|
||||
placeholder="输入反馈或调整指令... (Ctrl+Enter 发送)"
|
||||
rows="5"
|
||||
@keydown.ctrl.enter="submit"
|
||||
/>
|
||||
<button class="btn-send" :disabled="disabled" @click="submit">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.comment-section {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comments-display {
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.comment-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.comment-input textarea {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
resize: none;
|
||||
min-height: 100px;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.comment-input textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-send {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
font-weight: 600;
|
||||
padding: 8px 20px;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.btn-send:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
</style>
|
||||
152
web/src/components/ExecutionSection.vue
Normal file
152
web/src/components/ExecutionSection.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { PlanStep } from '../types'
|
||||
|
||||
defineProps<{
|
||||
steps: PlanStep[]
|
||||
}>()
|
||||
|
||||
const expandedSteps = ref<Set<string>>(new Set())
|
||||
|
||||
function toggleStep(id: string) {
|
||||
if (expandedSteps.value.has(id)) {
|
||||
expandedSteps.value.delete(id)
|
||||
} else {
|
||||
expandedSteps.value.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
switch (status) {
|
||||
case 'done': return '完成'
|
||||
case 'running': return '执行中'
|
||||
case 'failed': return '失败'
|
||||
default: return '等待'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="execution-section">
|
||||
<div class="section-header">
|
||||
<h2>执行</h2>
|
||||
</div>
|
||||
<div class="exec-list">
|
||||
<div
|
||||
v-for="step in steps"
|
||||
:key="step.id"
|
||||
class="exec-item"
|
||||
:class="step.status"
|
||||
>
|
||||
<div class="exec-header" @click="toggleStep(step.id)">
|
||||
<span class="exec-toggle">{{ expandedSteps.has(step.id) ? '▾' : '▸' }}</span>
|
||||
<span class="exec-order">步骤 {{ step.step_order }}</span>
|
||||
<span class="exec-status" :class="step.status">{{ statusLabel(step.status) }}</span>
|
||||
</div>
|
||||
<div v-if="expandedSteps.has(step.id) && step.output" class="exec-output">
|
||||
<pre>{{ step.output }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!steps.length" class="empty-state">
|
||||
计划生成后,执行进度将显示在这里
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.execution-section {
|
||||
flex: 1;
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.exec-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.exec-item {
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.exec-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.exec-header:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.exec-toggle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.exec-order {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.exec-status {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.exec-status.done { background: var(--success); color: #000; }
|
||||
.exec-status.running { background: var(--accent); color: #000; }
|
||||
.exec-status.failed { background: var(--error); color: #fff; }
|
||||
.exec-status.pending { background: var(--pending); color: #fff; }
|
||||
|
||||
.exec-output {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.exec-output pre {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
112
web/src/components/PlanSection.vue
Normal file
112
web/src/components/PlanSection.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import type { PlanStep } from '../types'
|
||||
|
||||
defineProps<{
|
||||
steps: PlanStep[]
|
||||
}>()
|
||||
|
||||
function statusIcon(status: string) {
|
||||
switch (status) {
|
||||
case 'done': return '✓'
|
||||
case 'running': return '⟳'
|
||||
case 'failed': return '✗'
|
||||
default: return '○'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plan-section">
|
||||
<div class="section-header">
|
||||
<h2>Plan</h2>
|
||||
</div>
|
||||
<div class="steps-list">
|
||||
<div
|
||||
v-for="step in steps"
|
||||
:key="step.id"
|
||||
class="step-item"
|
||||
:class="step.status"
|
||||
>
|
||||
<span class="step-icon">{{ statusIcon(step.status) }}</span>
|
||||
<span class="step-order">{{ step.step_order }}.</span>
|
||||
<span class="step-desc">{{ step.description }}</span>
|
||||
</div>
|
||||
<div v-if="!steps.length" class="empty-state">
|
||||
提交需求后,AI 将在这里生成计划
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plan-section {
|
||||
flex: 1;
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.steps-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.step-item.done { border-left: 3px solid var(--success); }
|
||||
.step-item.running { border-left: 3px solid var(--accent); background: rgba(79, 195, 247, 0.08); }
|
||||
.step-item.failed { border-left: 3px solid var(--error); }
|
||||
.step-item.pending { border-left: 3px solid var(--pending); opacity: 0.7; }
|
||||
|
||||
.step-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.step-item.done .step-icon { color: var(--success); }
|
||||
.step-item.running .step-icon { color: var(--accent); }
|
||||
.step-item.failed .step-icon { color: var(--error); }
|
||||
|
||||
.step-order {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
120
web/src/components/RequirementSection.vue
Normal file
120
web/src/components/RequirementSection.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
requirement: string
|
||||
status: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [text: string]
|
||||
}>()
|
||||
|
||||
const input = ref('')
|
||||
const editing = ref(!props.requirement)
|
||||
|
||||
function submit() {
|
||||
const text = input.value.trim()
|
||||
if (!text) return
|
||||
emit('submit', text)
|
||||
editing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="requirement-section">
|
||||
<div class="section-header">
|
||||
<h2>需求</h2>
|
||||
<span v-if="status !== 'pending'" class="status-badge" :class="status">
|
||||
{{ status }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!editing && requirement" class="requirement-display" @dblclick="editing = true">
|
||||
{{ requirement }}
|
||||
</div>
|
||||
<div v-else class="requirement-input">
|
||||
<textarea
|
||||
v-model="input"
|
||||
placeholder="描述你的需求..."
|
||||
rows="3"
|
||||
@keydown.ctrl.enter="submit"
|
||||
/>
|
||||
<button class="btn-submit" @click="submit">提交需求</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.requirement-section {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.planning { background: var(--warning); color: #000; }
|
||||
.status-badge.executing { background: var(--accent); color: #000; }
|
||||
.status-badge.done { background: var(--success); color: #000; }
|
||||
.status-badge.failed { background: var(--error); color: #fff; }
|
||||
|
||||
.requirement-display {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.requirement-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.requirement-input textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.requirement-input textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
align-self: flex-end;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
</style>
|
||||
108
web/src/components/Sidebar.vue
Normal file
108
web/src/components/Sidebar.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import type { Project } from '../types'
|
||||
|
||||
defineProps<{
|
||||
projects: Project[]
|
||||
selectedId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [id: string]
|
||||
create: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1 class="logo">Tori</h1>
|
||||
<button class="btn-new" @click="emit('create')">+ 新项目</button>
|
||||
</div>
|
||||
<nav class="project-list">
|
||||
<div
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
class="project-item"
|
||||
:class="{ active: project.id === selectedId }"
|
||||
@click="emit('select', project.id)"
|
||||
>
|
||||
<span class="project-name">{{ project.name }}</span>
|
||||
<span class="project-time">{{ new Date(project.updated_at).toLocaleDateString() }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.btn-new {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px dashed var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-new:hover {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.project-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.project-item {
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.project-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.project-item.active {
|
||||
background: var(--bg-tertiary);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.project-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
165
web/src/components/WorkflowView.vue
Normal file
165
web/src/components/WorkflowView.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import RequirementSection from './RequirementSection.vue'
|
||||
import PlanSection from './PlanSection.vue'
|
||||
import ExecutionSection from './ExecutionSection.vue'
|
||||
import CommentSection from './CommentSection.vue'
|
||||
import { api } from '../api'
|
||||
import { connectWs } from '../ws'
|
||||
import type { Workflow, PlanStep, Comment } from '../types'
|
||||
import type { WsMessage } from '../ws'
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string
|
||||
}>()
|
||||
|
||||
const workflow = ref<Workflow | null>(null)
|
||||
const steps = ref<PlanStep[]>([])
|
||||
const comments = ref<Comment[]>([])
|
||||
const error = ref('')
|
||||
|
||||
let wsConn: { close: () => void } | null = null
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const workflows = await api.listWorkflows(props.projectId)
|
||||
const latest = workflows[0]
|
||||
if (latest) {
|
||||
workflow.value = latest
|
||||
const [s, c] = await Promise.all([
|
||||
api.listSteps(latest.id),
|
||||
api.listComments(latest.id),
|
||||
])
|
||||
steps.value = s
|
||||
comments.value = c
|
||||
} else {
|
||||
workflow.value = null
|
||||
steps.value = []
|
||||
comments.value = []
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
function handleWsMessage(msg: WsMessage) {
|
||||
switch (msg.type) {
|
||||
case 'PlanUpdate':
|
||||
if (workflow.value && msg.workflow_id === workflow.value.id) {
|
||||
// Reload steps from API to get full DB records
|
||||
api.listSteps(workflow.value.id).then(s => { steps.value = s })
|
||||
}
|
||||
break
|
||||
case 'StepStatusUpdate': {
|
||||
const idx = steps.value.findIndex(s => s.id === msg.step_id)
|
||||
const existing = steps.value[idx]
|
||||
if (existing) {
|
||||
steps.value[idx] = { ...existing, status: msg.status as PlanStep['status'], output: msg.output }
|
||||
} else {
|
||||
// New step, reload
|
||||
if (workflow.value) {
|
||||
api.listSteps(workflow.value.id).then(s => { steps.value = s })
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'WorkflowStatusUpdate':
|
||||
if (workflow.value && msg.workflow_id === workflow.value.id) {
|
||||
workflow.value = { ...workflow.value, status: msg.status as any }
|
||||
}
|
||||
break
|
||||
case 'Error':
|
||||
error.value = msg.message
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function setupWs() {
|
||||
wsConn?.close()
|
||||
wsConn = connectWs(props.projectId, handleWsMessage)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
setupWs()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
wsConn?.close()
|
||||
})
|
||||
|
||||
watch(() => props.projectId, () => {
|
||||
loadData()
|
||||
setupWs()
|
||||
})
|
||||
|
||||
async function onSubmitRequirement(text: string) {
|
||||
try {
|
||||
const wf = await api.createWorkflow(props.projectId, text)
|
||||
workflow.value = wf
|
||||
steps.value = []
|
||||
comments.value = []
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmitComment(text: string) {
|
||||
if (!workflow.value) return
|
||||
try {
|
||||
const comment = await api.createComment(workflow.value.id, text)
|
||||
comments.value.push(comment)
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workflow-view">
|
||||
<div v-if="error" class="error-msg" @click="error = ''">{{ error }}</div>
|
||||
<RequirementSection
|
||||
:requirement="workflow?.requirement || ''"
|
||||
:status="workflow?.status || 'pending'"
|
||||
@submit="onSubmitRequirement"
|
||||
/>
|
||||
<div class="plan-exec-row">
|
||||
<PlanSection :steps="steps" />
|
||||
<ExecutionSection :steps="steps" />
|
||||
</div>
|
||||
<CommentSection
|
||||
:comments="comments"
|
||||
:disabled="!workflow"
|
||||
@submit="onSubmitComment"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workflow-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-exec-row {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
background: rgba(239, 83, 80, 0.15);
|
||||
border: 1px solid var(--error);
|
||||
color: var(--error);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user