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:
24
web/.gitignore
vendored
Normal file
24
web/.gitignore
vendored
Normal 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
5
web/README.md
Normal 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
13
web/index.html
Normal 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
1381
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
web/package.json
Normal file
22
web/package.json
Normal 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
1
web/public/vite.svg
Normal 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
7
web/src/App.vue
Normal 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
57
web/src/api.ts
Normal 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
1
web/src/assets/vue.svg
Normal 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 |
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>
|
||||
5
web/src/main.ts
Normal file
5
web/src/main.ts
Normal 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
44
web/src/style.css
Normal 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
31
web/src/types.ts
Normal 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
69
web/src/ws.ts
Normal 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
16
web/tsconfig.app.json
Normal 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
7
web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
web/tsconfig.node.json
Normal file
26
web/tsconfig.node.json
Normal 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
15
web/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user