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

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>