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:
2026-02-28 10:36:50 +00:00
parent 1122ab27dd
commit 7edbbee471
43 changed files with 7164 additions and 83 deletions

24
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
web/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1381
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
web/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.25"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vue-tsc": "^3.1.5"
}
}

1
web/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

7
web/src/App.vue Normal file
View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
import AppLayout from './components/AppLayout.vue'
</script>
<template>
<AppLayout />
</template>

57
web/src/api.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { Project, Workflow, PlanStep, Comment } from './types'
const BASE = '/api'
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!res.ok) {
const text = await res.text()
throw new Error(`API error ${res.status}: ${text}`)
}
return res.json()
}
export const api = {
listProjects: () => request<Project[]>('/projects'),
createProject: (name: string, description = '') =>
request<Project>('/projects', {
method: 'POST',
body: JSON.stringify({ name, description }),
}),
getProject: (id: string) => request<Project | null>(`/projects/${id}`),
updateProject: (id: string, data: { name?: string; description?: string }) =>
request<Project | null>(`/projects/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
deleteProject: (id: string) =>
request<boolean>(`/projects/${id}`, { method: 'DELETE' }),
listWorkflows: (projectId: string) =>
request<Workflow[]>(`/projects/${projectId}/workflows`),
createWorkflow: (projectId: string, requirement: string) =>
request<Workflow>(`/projects/${projectId}/workflows`, {
method: 'POST',
body: JSON.stringify({ requirement }),
}),
listSteps: (workflowId: string) =>
request<PlanStep[]>(`/workflows/${workflowId}/steps`),
listComments: (workflowId: string) =>
request<Comment[]>(`/workflows/${workflowId}/comments`),
createComment: (workflowId: string, content: string) =>
request<Comment>(`/workflows/${workflowId}/comments`, {
method: 'POST',
body: JSON.stringify({ content }),
}),
}

1
web/src/assets/vue.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

5
web/src/main.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')

44
web/src/style.css Normal file
View File

@@ -0,0 +1,44 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--sidebar-width: 240px;
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-tertiary: #0f3460;
--bg-card: #1e2a4a;
--text-primary: #e0e0e0;
--text-secondary: #a0a0b0;
--accent: #4fc3f7;
--accent-hover: #29b6f6;
--border: #2a3a5e;
--success: #66bb6a;
--warning: #ffa726;
--error: #ef5350;
--pending: #78909c;
}
html, body, #app {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
}
button {
cursor: pointer;
border: none;
border-radius: 6px;
padding: 8px 16px;
font-size: 14px;
font-family: inherit;
transition: background 0.2s;
}
textarea {
font-family: inherit;
font-size: 14px;
}

31
web/src/types.ts Normal file
View File

@@ -0,0 +1,31 @@
export interface Project {
id: string
name: string
description: string
created_at: string
updated_at: string
}
export interface Workflow {
id: string
project_id: string
requirement: string
status: 'pending' | 'planning' | 'executing' | 'done' | 'failed'
created_at: string
}
export interface PlanStep {
id: string
workflow_id: string
step_order: number
description: string
status: 'pending' | 'running' | 'done' | 'failed'
output: string
}
export interface Comment {
id: string
workflow_id: string
content: string
created_at: string
}

69
web/src/ws.ts Normal file
View File

@@ -0,0 +1,69 @@
export interface WsPlanUpdate {
type: 'PlanUpdate'
workflow_id: string
steps: { order: number; description: string; command: string }[]
}
export interface WsStepStatusUpdate {
type: 'StepStatusUpdate'
step_id: string
status: string
output: string
}
export interface WsWorkflowStatusUpdate {
type: 'WorkflowStatusUpdate'
workflow_id: string
status: string
}
export interface WsError {
type: 'Error'
message: string
}
export type WsMessage = WsPlanUpdate | WsStepStatusUpdate | WsWorkflowStatusUpdate | WsError
export type WsHandler = (msg: WsMessage) => void
export function connectWs(projectId: string, onMessage: WsHandler): { close: () => void } {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = `${proto}//${location.host}/ws/${projectId}`
let ws: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let closed = false
function connect() {
if (closed) return
ws = new WebSocket(url)
ws.onmessage = (e) => {
try {
const msg: WsMessage = JSON.parse(e.data)
onMessage(msg)
} catch {
// ignore malformed messages
}
}
ws.onclose = () => {
if (!closed) {
reconnectTimer = setTimeout(connect, 2000)
}
}
ws.onerror = () => {
ws?.close()
}
}
connect()
return {
close() {
closed = true
if (reconnectTimer) clearTimeout(reconnectTimer)
ws?.close()
},
}
}

16
web/tsconfig.app.json Normal file
View File

@@ -0,0 +1,16 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
web/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

15
web/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': 'http://localhost:3000',
'/ws': {
target: 'ws://localhost:3000',
ws: true,
},
},
},
})