Agent loop state machine refactor, unified LLM interface, and UI improvements

- Rewrite agent loop as Planning→Executing(N)→Completed state machine with
  per-step context isolation to prevent token explosion
- Split tools and prompts by phase (planning vs execution)
- Add advance_step/save_memo tools for step transitions and cross-step memory
- Unify LLM interface: remove duplicate types, single chat_with_tools path
- Add UTF-8 safe truncation (truncate_str) to prevent panics on Chinese text
- Extract CreateForm component, add auto-scroll to execution log
- Add report generation with app access URL, non-blocking title generation
- Add timer system, file serving, app proxy, exec module
- Update Dockerfile with uv, deployment config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 22:35:33 +00:00
parent e2d5a6a7eb
commit 2df4e12d30
31 changed files with 3924 additions and 571 deletions

1125
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
"marked": "^17.0.3",
"mermaid": "^11.12.3",
"vue": "^3.5.25"
},
"devDependencies": {

View File

@@ -1,4 +1,4 @@
import type { Project, Workflow, PlanStep, Comment } from './types'
import type { Project, Workflow, PlanStep, Comment, Timer } from './types'
const BASE = '/api'
@@ -54,4 +54,25 @@ export const api = {
method: 'POST',
body: JSON.stringify({ content }),
}),
getReport: (workflowId: string) =>
request<{ report: string }>(`/workflows/${workflowId}/report`),
listTimers: (projectId: string) =>
request<Timer[]>(`/projects/${projectId}/timers`),
createTimer: (projectId: string, data: { name: string; interval_secs: number; requirement: string }) =>
request<Timer>(`/projects/${projectId}/timers`, {
method: 'POST',
body: JSON.stringify(data),
}),
updateTimer: (timerId: string, data: { name?: string; interval_secs?: number; requirement?: string; enabled?: boolean }) =>
request<Timer>(`/timers/${timerId}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
deleteTimer: (timerId: string) =>
request<void>(`/timers/${timerId}`, { method: 'DELETE' }),
}

View File

@@ -1,62 +1,122 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted, computed } from 'vue'
import Sidebar from './Sidebar.vue'
import WorkflowView from './WorkflowView.vue'
import ReportView from './ReportView.vue'
import CreateForm from './CreateForm.vue'
import { api } from '../api'
import type { Project } from '../types'
const projects = ref<Project[]>([])
const selectedProjectId = ref('')
const reportWorkflowId = ref('')
const error = ref('')
const creating = ref(false)
const isReportPage = computed(() => !!reportWorkflowId.value)
function parseUrl(): { projectId: string; reportId: string } {
const reportMatch = location.pathname.match(/^\/report\/([^/]+)/)
if (reportMatch) return { projectId: '', reportId: reportMatch[1] ?? '' }
const projectMatch = location.pathname.match(/^\/projects\/([^/]+)/)
return { projectId: projectMatch?.[1] ?? '', reportId: '' }
}
function onPopState() {
const { projectId, reportId } = parseUrl()
selectedProjectId.value = projectId
reportWorkflowId.value = reportId
}
onMounted(async () => {
try {
projects.value = await api.listProjects()
const first = projects.value[0]
if (first) {
selectedProjectId.value = first.id
const { projectId, reportId } = parseUrl()
if (reportId) {
reportWorkflowId.value = reportId
} else if (projectId && projects.value.some(p => p.id === projectId)) {
selectedProjectId.value = projectId
} else if (projects.value[0]) {
selectedProjectId.value = projects.value[0].id
history.replaceState(null, '', `/projects/${projects.value[0].id}`)
}
} catch (e: any) {
error.value = e.message
}
window.addEventListener('popstate', onPopState)
})
onUnmounted(() => {
window.removeEventListener('popstate', onPopState)
})
function onSelectProject(id: string) {
selectedProjectId.value = id
reportWorkflowId.value = ''
creating.value = false
history.pushState(null, '', `/projects/${id}`)
}
async function onCreateProject() {
const name = prompt('项目名称')
if (!name) return
function onStartCreate() {
creating.value = true
selectedProjectId.value = ''
history.pushState(null, '', '/')
}
async function onConfirmCreate(req: string) {
try {
const project = await api.createProject(name)
const project = await api.createProject('新项目')
projects.value.unshift(project)
await api.createWorkflow(project.id, req)
creating.value = false
selectedProjectId.value = project.id
history.pushState(null, '', `/projects/${project.id}`)
} catch (e: any) {
error.value = e.message
}
}
function onProjectUpdate(projectId: string, name: string) {
const p = projects.value.find(p => p.id === projectId)
if (p) p.name = name
}
</script>
<template>
<div class="app-layout">
<div v-if="isReportPage" class="report-fullpage">
<ReportView :workflowId="reportWorkflowId" :key="reportWorkflowId" />
</div>
<div v-else class="app-layout">
<Sidebar
:projects="projects"
:selectedId="selectedProjectId"
@select="onSelectProject"
@create="onCreateProject"
@create="onStartCreate"
/>
<main class="main-content">
<div v-if="error" class="error-banner">{{ error }}</div>
<div v-if="!selectedProjectId" class="empty-state">
<div v-if="error" class="error-banner" @click="error = ''">{{ error }}</div>
<div v-if="creating" class="empty-state">
<CreateForm @submit="onConfirmCreate" @cancel="creating = false" />
</div>
<div v-else-if="!selectedProjectId" class="empty-state">
选择或创建一个项目开始
</div>
<WorkflowView v-else :projectId="selectedProjectId" :key="selectedProjectId" />
<WorkflowView
v-else
:projectId="selectedProjectId"
:key="selectedProjectId"
@projectUpdate="onProjectUpdate"
/>
</main>
</div>
</template>
<style scoped>
.report-fullpage {
height: 100vh;
overflow-y: auto;
}
.app-layout {
display: flex;
height: 100vh;
@@ -84,5 +144,6 @@ async function onCreateProject() {
color: #fff;
padding: 8px 16px;
font-size: 13px;
cursor: pointer;
}
</style>

View File

@@ -1,9 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Comment } from '../types'
defineProps<{
comments: Comment[]
const props = defineProps<{
disabled?: boolean
}>()
@@ -14,29 +12,31 @@ const emit = defineEmits<{
const input = ref('')
function submit() {
if (props.disabled) return
const text = input.value.trim()
if (!text) return
emit('submit', text)
input.value = ''
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
submit()
}
}
</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"
rows="3"
@keydown="onKeydown"
/>
<button class="btn-send" :disabled="disabled" @click="submit">发送</button>
<button class="btn-send" :disabled="disabled || !input.trim()" @click="submit">发送</button>
</div>
</div>
</template>
@@ -49,30 +49,6 @@ function submit() {
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;

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const emit = defineEmits<{
submit: [requirement: string]
cancel: []
}>()
const requirement = ref('')
const inputEl = ref<HTMLTextAreaElement>()
const examples = [
{ label: 'Todo 应用', text: '做一个 Todo List 应用:前端展示任务列表(支持添加、完成、删除),后端 FastAPI 提供增删改查 REST API数据存 SQLite。完成后用 curl 跑一遍 E2E 测试验证所有接口正常。' },
{ label: '贪吃蛇+排行榜', text: '做一个贪吃蛇游戏网站,前端用 HTML/JS后端用 FastAPI 存储排行榜,支持提交分数和查看 Top10' },
{ label: '抓取豆瓣 Top250', text: '用 Python 抓取豆瓣电影 Top250 并生成分析报告' },
]
onMounted(() => inputEl.value?.focus())
function onSubmit() {
const text = requirement.value.trim()
if (text) emit('submit', text)
}
</script>
<template>
<div class="create-form">
<h2>输入你的需求</h2>
<div class="create-examples">
<span
v-for="ex in examples"
:key="ex.label"
class="example-tag"
@click="requirement = ex.text"
>{{ ex.label }}</span>
</div>
<textarea
ref="inputEl"
v-model="requirement"
class="create-textarea"
placeholder="描述你想让 AI 做什么..."
rows="4"
@keydown.ctrl.enter="onSubmit"
@keydown.meta.enter="onSubmit"
/>
<div class="create-hint">Ctrl+Enter 提交</div>
<div class="create-actions">
<button class="btn-cancel" @click="emit('cancel')">取消</button>
<button class="btn-confirm" @click="onSubmit" :disabled="!requirement.trim()">开始</button>
</div>
</div>
</template>
<style scoped>
.create-form {
display: flex;
flex-direction: column;
gap: 12px;
width: 480px;
}
.create-form h2 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.create-examples {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.example-tag {
padding: 4px 10px;
font-size: 12px;
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
}
.example-tag:hover {
color: var(--accent);
border-color: var(--accent);
background: rgba(79, 195, 247, 0.08);
}
.create-textarea {
padding: 10px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
outline: none;
resize: vertical;
min-height: 80px;
}
.create-textarea:focus {
border-color: var(--accent);
}
.create-hint {
font-size: 12px;
color: var(--text-secondary);
text-align: right;
}
.create-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.btn-cancel {
padding: 8px 16px;
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
}
.btn-confirm {
padding: 8px 16px;
background: var(--accent);
color: var(--bg-primary);
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
}
.btn-confirm:disabled {
opacity: 0.4;
}
</style>

View File

@@ -1,11 +1,35 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { PlanStep } from '../types'
import { ref, computed, watch, nextTick } from 'vue'
import type { PlanStep, Comment } from '../types'
defineProps<{
const props = defineProps<{
steps: PlanStep[]
planSteps: PlanStep[]
comments: Comment[]
requirement: string
createdAt: string
workflowStatus: string
workflowId: string
}>()
// Map plan step id -> step_order for showing badge
const planStepOrderMap = computed(() => {
const m: Record<string, number> = {}
for (const ps of props.planSteps) {
m[ps.id] = ps.step_order
}
return m
})
const scrollContainer = ref<HTMLElement | null>(null)
const userScrolledUp = ref(false)
function onScroll() {
const el = scrollContainer.value
if (!el) return
userScrolledUp.value = el.scrollTop + el.clientHeight < el.scrollHeight - 80
}
const expandedSteps = ref<Set<string>>(new Set())
function toggleStep(id: string) {
@@ -16,6 +40,16 @@ function toggleStep(id: string) {
}
}
function formatTime(t: string): string {
if (!t) return ''
try {
const d = new Date(t.includes('T') ? t : t + 'Z')
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
} catch {
return ''
}
}
function statusLabel(status: string) {
switch (status) {
case 'done': return '完成'
@@ -24,31 +58,114 @@ function statusLabel(status: string) {
default: return '等待'
}
}
interface LogEntry {
id: string
type: 'requirement' | 'step' | 'comment' | 'report'
time: string
step?: PlanStep
text?: string
}
const logEntries = computed(() => {
const entries: LogEntry[] = []
// Requirement
if (props.requirement) {
entries.push({ id: 'req', type: 'requirement', text: props.requirement, time: props.createdAt || '0' })
}
// Steps
for (const step of props.steps) {
entries.push({ id: step.id, type: 'step', step, time: step.created_at || '' })
}
// Comments
for (const c of props.comments) {
entries.push({ id: c.id, type: 'comment', text: c.content, time: c.created_at })
}
// Sort by time, preserving order for entries without timestamps
entries.sort((a, b) => {
if (!a.time && !b.time) return 0
if (!a.time) return -1
if (!b.time) return 1
return a.time.localeCompare(b.time)
})
// Insert report links: after each contiguous block of steps that ends before a comment/requirement
if (props.workflowId && (props.workflowStatus === 'done' || props.workflowStatus === 'failed')) {
const result: LogEntry[] = []
let lastWasStep = false
for (const entry of entries) {
if (entry.type === 'step') {
lastWasStep = true
} else if (lastWasStep && (entry.type === 'comment' || entry.type === 'requirement')) {
// Insert report link before this comment/requirement
result.push({ id: `report-${result.length}`, type: 'report', time: '' })
lastWasStep = false
} else {
lastWasStep = false
}
result.push(entry)
}
// Final report link at the end if last entry was a step
if (lastWasStep) {
result.push({ id: 'report-final', type: 'report', time: '' })
}
return result
}
return entries
})
watch(logEntries, () => {
if (userScrolledUp.value) return
nextTick(() => {
const el = scrollContainer.value
if (el) el.scrollTop = el.scrollHeight
})
})
</script>
<template>
<div class="execution-section">
<div class="execution-section" ref="scrollContainer" @scroll="onScroll">
<div class="section-header">
<h2>执行</h2>
<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>
<template v-for="entry in logEntries" :key="entry.id">
<!-- User message (requirement or comment) -->
<div v-if="entry.type === 'requirement' || entry.type === 'comment'" class="log-user">
<span class="log-time" v-if="entry.time">{{ formatTime(entry.time) }}</span>
<span class="log-tag">{{ entry.type === 'requirement' ? '需求' : '反馈' }}</span>
<span class="log-text">{{ entry.text }}</span>
</div>
<div v-if="expandedSteps.has(step.id) && step.output" class="exec-output">
<pre>{{ step.output }}</pre>
<!-- Report link -->
<div v-else-if="entry.type === 'report'" class="report-link-bar">
<a :href="'/report/' + workflowId" class="report-link" target="_blank">查看报告 </a>
</div>
</div>
<div v-if="!steps.length" class="empty-state">
计划生成后执行进度将显示在这里
<!-- Step -->
<div v-else-if="entry.step" class="exec-item" :class="entry.step.status">
<div class="exec-header" @click="toggleStep(entry.step!.id)">
<span class="exec-time" v-if="entry.time">{{ formatTime(entry.time) }}</span>
<span v-if="entry.step.plan_step_id && planStepOrderMap[entry.step.plan_step_id]" class="step-badge">{{ planStepOrderMap[entry.step.plan_step_id] }}</span>
<span class="exec-toggle">{{ expandedSteps.has(entry.step!.id) ? '▾' : '▸' }}</span>
<span class="exec-desc">{{ entry.step.description }}</span>
<span class="exec-status" :class="entry.step.status">{{ statusLabel(entry.step.status) }}</span>
</div>
<div v-if="expandedSteps.has(entry.step!.id)" class="exec-detail">
<div v-if="entry.step.command" class="exec-command">
<code>$ {{ entry.step.command }}</code>
</div>
<pre v-if="entry.step.output">{{ entry.step.output }}</pre>
</div>
</div>
</template>
<div v-if="!steps.length && !requirement" class="empty-state">
提交需求后日志将显示在这里
</div>
</div>
</div>
@@ -83,6 +200,35 @@ function statusLabel(status: string) {
gap: 4px;
}
.log-user {
display: flex;
align-items: baseline;
gap: 8px;
padding: 8px 10px;
border-radius: 6px;
background: var(--bg-secondary);
font-size: 13px;
}
.log-time, .exec-time {
font-size: 10px;
color: var(--text-secondary);
flex-shrink: 0;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
opacity: 0.7;
}
.log-tag {
font-size: 11px;
font-weight: 600;
color: var(--accent);
flex-shrink: 0;
}
.log-text {
color: var(--text-primary);
}
.exec-item {
border-radius: 6px;
overflow: hidden;
@@ -100,7 +246,20 @@ function statusLabel(status: string) {
}
.exec-header:hover {
background: rgba(255, 255, 255, 0.03);
background: var(--bg-tertiary);
}
.step-badge {
font-size: 10px;
font-weight: 700;
min-width: 18px;
height: 18px;
line-height: 18px;
text-align: center;
border-radius: 9px;
background: var(--accent);
color: var(--bg-primary);
flex-shrink: 0;
}
.exec-toggle {
@@ -110,7 +269,7 @@ function statusLabel(status: string) {
flex-shrink: 0;
}
.exec-order {
.exec-desc {
color: var(--text-primary);
font-weight: 500;
flex: 1;
@@ -123,24 +282,36 @@ function statusLabel(status: string) {
font-weight: 500;
}
.exec-status.done { background: var(--success); color: #000; }
.exec-status.running { background: var(--accent); color: #000; }
.exec-status.done { background: var(--success); color: #fff; }
.exec-status.running { background: var(--accent); color: #fff; }
.exec-status.failed { background: var(--error); color: #fff; }
.exec-status.pending { background: var(--pending); color: #fff; }
.exec-output {
padding: 8px 12px;
.exec-detail {
border-top: 1px solid var(--border);
background: rgba(0, 0, 0, 0.2);
background: var(--bg-tertiary);
}
.exec-output pre {
.exec-command {
padding: 6px 12px;
border-bottom: 1px solid var(--border);
}
.exec-command code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px;
color: var(--accent);
}
.exec-detail pre {
padding: 8px 12px;
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;
margin: 0;
}
.empty-state {
@@ -149,4 +320,23 @@ function statusLabel(status: string) {
text-align: center;
padding: 24px;
}
.report-link-bar {
margin: 4px 0;
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 6px;
text-align: center;
}
.report-link {
color: var(--accent);
font-size: 13px;
font-weight: 600;
text-decoration: none;
}
.report-link:hover {
text-decoration: underline;
}
</style>

View File

@@ -1,10 +1,21 @@
<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 statusIcon(status: string) {
switch (status) {
case 'done': return '✓'
@@ -18,7 +29,7 @@ function statusIcon(status: string) {
<template>
<div class="plan-section">
<div class="section-header">
<h2>Plan</h2>
<h2>计划</h2>
</div>
<div class="steps-list">
<div
@@ -27,12 +38,18 @@ function statusIcon(status: string) {
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 class="step-header" @click="step.command ? toggleStep(step.id) : undefined">
<span class="step-icon">{{ statusIcon(step.status) }}</span>
<span class="step-order">{{ step.step_order }}.</span>
<span class="step-title">{{ step.description }}</span>
<span v-if="step.command" class="step-toggle">{{ expandedSteps.has(step.id) ? '' : '' }}</span>
</div>
<div v-if="step.command && expandedSteps.has(step.id)" class="step-detail">
{{ step.command }}
</div>
</div>
<div v-if="!steps.length" class="empty-state">
提交需求后AI 将在这里生成计划
AI 将在这里展示执行计划
</div>
</div>
</div>
@@ -68,14 +85,9 @@ function statusIcon(status: string) {
}
.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);
overflow: hidden;
}
.step-item.done { border-left: 3px solid var(--success); }
@@ -83,6 +95,24 @@ function statusIcon(status: string) {
.step-item.failed { border-left: 3px solid var(--error); }
.step-item.pending { border-left: 3px solid var(--pending); opacity: 0.7; }
.step-header {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
font-size: 13px;
line-height: 1.5;
cursor: default;
}
.step-header:has(.step-toggle) {
cursor: pointer;
}
.step-header:has(.step-toggle):hover {
background: var(--bg-tertiary);
}
.step-icon {
font-size: 14px;
flex-shrink: 0;
@@ -99,8 +129,24 @@ function statusIcon(status: string) {
flex-shrink: 0;
}
.step-desc {
.step-title {
color: var(--text-primary);
font-weight: 500;
flex: 1;
}
.step-toggle {
color: var(--text-secondary);
font-size: 11px;
flex-shrink: 0;
}
.step-detail {
padding: 6px 10px 10px 44px;
font-size: 12px;
line-height: 1.6;
color: var(--text-secondary);
border-top: 1px solid var(--border);
}
.empty-state {

View File

@@ -0,0 +1,232 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { marked } from 'marked'
import mermaid from 'mermaid'
import { api } from '../api'
const props = defineProps<{
workflowId: string
}>()
const html = ref('')
const loading = ref(true)
const error = ref('')
mermaid.initialize({
startOnLoad: false,
theme: 'default',
})
async function renderMermaid() {
await nextTick()
const els = document.querySelectorAll('.report-body pre code.language-mermaid')
for (let i = 0; i < els.length; i++) {
const el = els[i] as HTMLElement
const pre = el.parentElement!
const source = el.textContent || ''
try {
const { svg } = await mermaid.render(`mermaid-${i}`, source)
const div = document.createElement('div')
div.className = 'mermaid-chart'
div.innerHTML = svg
pre.replaceWith(div)
} catch {
// leave as code block on failure
}
}
}
onMounted(async () => {
try {
const res = await api.getReport(props.workflowId)
html.value = await marked.parse(res.report)
await renderMermaid()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
})
</script>
<template>
<div class="report-page">
<div class="report-toolbar">
<a href="/" class="back-link">&larr; 返回</a>
<span class="report-title">执行报告</span>
</div>
<div v-if="loading" class="report-loading">加载中...</div>
<div v-else-if="error" class="report-error">{{ error }}</div>
<div v-else class="report-body" v-html="html"></div>
</div>
</template>
<style scoped>
.report-page {
max-width: 860px;
margin: 0 auto;
padding: 24px 32px;
min-height: 100vh;
}
.report-toolbar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.back-link {
color: var(--accent);
text-decoration: none;
font-size: 14px;
font-weight: 500;
}
.back-link:hover {
text-decoration: underline;
}
.report-title {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.report-loading,
.report-error {
text-align: center;
padding: 48px;
color: var(--text-secondary);
font-size: 14px;
}
.report-error {
color: var(--error);
}
</style>
<style>
/* Unscoped styles for rendered markdown */
.report-body {
line-height: 1.7;
color: var(--text-primary);
font-size: 15px;
}
.report-body h1 {
font-size: 24px;
font-weight: 700;
margin: 0 0 16px;
padding-bottom: 8px;
border-bottom: 2px solid var(--border);
}
.report-body h2 {
font-size: 20px;
font-weight: 600;
margin: 24px 0 12px;
}
.report-body h3 {
font-size: 16px;
font-weight: 600;
margin: 20px 0 8px;
}
.report-body p {
margin: 0 0 12px;
}
.report-body ul,
.report-body ol {
margin: 0 0 12px;
padding-left: 24px;
}
.report-body li {
margin-bottom: 4px;
}
.report-body pre {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px 16px;
overflow-x: auto;
margin: 0 0 12px;
font-size: 13px;
line-height: 1.5;
}
.report-body code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 13px;
}
.report-body :not(pre) > code {
background: var(--bg-secondary);
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
}
.report-body table {
width: 100%;
border-collapse: collapse;
margin: 0 0 12px;
font-size: 14px;
}
.report-body th,
.report-body td {
border: 1px solid var(--border);
padding: 8px 12px;
text-align: left;
}
.report-body th {
background: var(--bg-secondary);
font-weight: 600;
}
.report-body blockquote {
border-left: 3px solid var(--accent);
padding-left: 16px;
margin: 0 0 12px;
color: var(--text-secondary);
}
.report-body a {
color: var(--accent);
text-decoration: none;
}
.report-body a:hover {
text-decoration: underline;
}
.report-body .mermaid-chart {
margin: 16px 0;
text-align: center;
}
.report-body .mermaid-chart svg {
max-width: 100%;
}
.report-body img {
max-width: 100%;
border-radius: 6px;
}
.report-body hr {
border: none;
border-top: 1px solid var(--border);
margin: 20px 0;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch } from 'vue'
const props = defineProps<{
requirement: string
@@ -13,6 +13,13 @@ const emit = defineEmits<{
const input = ref('')
const editing = ref(!props.requirement)
// 当 requirement 从外部更新(如 loadData 完成),自动退出编辑模式
watch(() => props.requirement, (val) => {
if (val && editing.value && !input.value.trim()) {
editing.value = false
}
})
function submit() {
const text = input.value.trim()
if (!text) return
@@ -29,8 +36,9 @@ function submit() {
{{ status }}
</span>
</div>
<div v-if="!editing && requirement" class="requirement-display" @dblclick="editing = true">
{{ requirement }}
<div v-if="!editing && requirement" class="requirement-display">
<span>{{ requirement }}</span>
<button class="btn-edit" @click="editing = true; input = requirement">编辑</button>
</div>
<div v-else class="requirement-input">
<textarea
@@ -80,10 +88,27 @@ function submit() {
.status-badge.failed { background: var(--error); color: #fff; }
.requirement-display {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
cursor: pointer;
}
.btn-edit {
flex-shrink: 0;
padding: 4px 10px;
font-size: 12px;
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn-edit:hover {
color: var(--accent);
border-color: var(--accent);
}
.requirement-input {

View File

@@ -0,0 +1,317 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '../api'
import type { Timer } from '../types'
const props = defineProps<{
projectId: string
}>()
const timers = ref<Timer[]>([])
const showForm = ref(false)
const formName = ref('')
const formInterval = ref(300)
const formRequirement = ref('')
const error = ref('')
onMounted(async () => {
await loadTimers()
})
async function loadTimers() {
try {
timers.value = await api.listTimers(props.projectId)
} catch (e: any) {
error.value = e.message
}
}
async function onCreate() {
const name = formName.value.trim()
const req = formRequirement.value.trim()
if (!name || !req) return
try {
const t = await api.createTimer(props.projectId, {
name,
interval_secs: formInterval.value,
requirement: req,
})
timers.value.unshift(t)
showForm.value = false
formName.value = ''
formInterval.value = 300
formRequirement.value = ''
} catch (e: any) {
error.value = e.message
}
}
async function onToggle(timer: Timer) {
try {
const updated = await api.updateTimer(timer.id, { enabled: !timer.enabled })
const idx = timers.value.findIndex(t => t.id === timer.id)
if (idx >= 0) timers.value[idx] = updated
} catch (e: any) {
error.value = e.message
}
}
async function onDelete(timer: Timer) {
try {
await api.deleteTimer(timer.id)
timers.value = timers.value.filter(t => t.id !== timer.id)
} catch (e: any) {
error.value = e.message
}
}
function formatInterval(secs: number): string {
if (secs < 3600) return `${Math.round(secs / 60)}分钟`
if (secs < 86400) return `${Math.round(secs / 3600)}小时`
return `${Math.round(secs / 86400)}`
}
</script>
<template>
<div class="timer-section">
<div class="section-header">
<h2>定时任务</h2>
<button class="btn-add" @click="showForm = !showForm">{{ showForm ? '取消' : '+ 新建' }}</button>
</div>
<div v-if="error" class="timer-error" @click="error = ''">{{ error }}</div>
<div v-if="showForm" class="timer-form">
<input v-model="formName" class="form-input" placeholder="任务名称" />
<div class="interval-row">
<label>间隔</label>
<select v-model="formInterval" class="form-select">
<option :value="60">1 分钟</option>
<option :value="300">5 分钟</option>
<option :value="600">10 分钟</option>
<option :value="1800">30 分钟</option>
<option :value="3600">1 小时</option>
<option :value="21600">6 小时</option>
<option :value="43200">12 小时</option>
<option :value="86400">1 </option>
</select>
</div>
<textarea v-model="formRequirement" class="form-textarea" placeholder="执行需求(和创建 workflow 一样)" rows="2" />
<button class="btn-create" @click="onCreate" :disabled="!formName.trim() || !formRequirement.trim()">创建</button>
</div>
<div class="timer-list">
<div v-for="timer in timers" :key="timer.id" class="timer-item" :class="{ disabled: !timer.enabled }">
<div class="timer-info">
<span class="timer-name">{{ timer.name }}</span>
<span class="timer-interval">{{ formatInterval(timer.interval_secs) }}</span>
</div>
<div class="timer-req">{{ timer.requirement }}</div>
<div class="timer-actions">
<button class="btn-toggle" :class="{ on: timer.enabled }" @click="onToggle(timer)">
{{ timer.enabled ? '已启用' : '已停用' }}
</button>
<button class="btn-delete" @click="onDelete(timer)">删除</button>
</div>
</div>
<div v-if="!timers.length && !showForm" class="empty-state">暂无定时任务</div>
</div>
</div>
</template>
<style scoped>
.timer-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 {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.section-header h2 {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-add {
font-size: 12px;
padding: 4px 10px;
background: var(--bg-secondary);
color: var(--accent);
border: 1px solid var(--border);
font-weight: 500;
}
.btn-add:hover {
background: var(--bg-tertiary);
}
.timer-error {
background: rgba(239, 83, 80, 0.15);
border: 1px solid var(--error);
color: var(--error);
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
margin-bottom: 8px;
}
.timer-form {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 10px;
padding: 10px;
background: var(--bg-secondary);
border-radius: 6px;
}
.form-input,
.form-textarea,
.form-select {
padding: 7px 10px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
outline: none;
}
.form-input:focus,
.form-textarea:focus {
border-color: var(--accent);
}
.form-textarea {
resize: vertical;
min-height: 40px;
}
.interval-row {
display: flex;
align-items: center;
gap: 8px;
}
.interval-row label {
font-size: 13px;
color: var(--text-secondary);
flex-shrink: 0;
}
.form-select {
flex: 1;
}
.btn-create {
align-self: flex-end;
padding: 6px 16px;
background: var(--accent);
color: #fff;
font-size: 13px;
font-weight: 500;
}
.btn-create:disabled {
opacity: 0.4;
}
.timer-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.timer-item {
padding: 8px 10px;
background: var(--bg-secondary);
border-radius: 6px;
}
.timer-item.disabled {
opacity: 0.5;
}
.timer-info {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.timer-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.timer-interval {
font-size: 11px;
color: var(--text-secondary);
background: var(--bg-tertiary);
padding: 1px 6px;
border-radius: 8px;
}
.timer-req {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.timer-actions {
display: flex;
gap: 6px;
}
.btn-toggle {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: var(--pending);
color: #fff;
}
.btn-toggle.on {
background: var(--success);
}
.btn-delete {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.btn-delete:hover {
background: var(--error);
color: #fff;
}
.empty-state {
color: var(--text-secondary);
font-size: 12px;
text-align: center;
padding: 12px;
}
</style>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed, 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 TimerSection from './TimerSection.vue'
import { api } from '../api'
import { connectWs } from '../ws'
import type { Workflow, PlanStep, Comment } from '../types'
@@ -13,10 +14,18 @@ const props = defineProps<{
projectId: string
}>()
const emit = defineEmits<{
projectUpdate: [projectId: string, name: string]
}>()
const workflow = ref<Workflow | null>(null)
const steps = ref<PlanStep[]>([])
const comments = ref<Comment[]>([])
const error = ref('')
const rightTab = ref<'log' | 'timers'>('log')
const planSteps = computed(() => steps.value.filter(s => s.kind === 'plan'))
const logSteps = computed(() => steps.value.filter(s => s.kind === 'log'))
let wsConn: { close: () => void } | null = null
@@ -46,7 +55,6 @@ 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
@@ -56,7 +64,6 @@ function handleWsMessage(msg: WsMessage) {
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 })
}
@@ -68,6 +75,19 @@ function handleWsMessage(msg: WsMessage) {
workflow.value = { ...workflow.value, status: msg.status as any }
}
break
case 'RequirementUpdate':
if (workflow.value && msg.workflow_id === workflow.value.id) {
workflow.value = { ...workflow.value, requirement: msg.requirement }
}
break
case 'ReportReady':
if (workflow.value && msg.workflow_id === workflow.value.id) {
workflow.value = { ...workflow.value, status: workflow.value.status }
}
break
case 'ProjectUpdate':
emit('projectUpdate', msg.project_id, msg.name)
break
case 'Error':
error.value = msg.message
break
@@ -124,11 +144,29 @@ async function onSubmitComment(text: string) {
@submit="onSubmitRequirement"
/>
<div class="plan-exec-row">
<PlanSection :steps="steps" />
<ExecutionSection :steps="steps" />
<PlanSection :steps="planSteps" />
<div class="right-panel">
<div class="tab-bar">
<button class="tab-btn" :class="{ active: rightTab === 'log' }" @click="rightTab = 'log'">日志</button>
<button class="tab-btn" :class="{ active: rightTab === 'timers' }" @click="rightTab = 'timers'">定时任务</button>
</div>
<ExecutionSection
v-show="rightTab === 'log'"
:steps="logSteps"
:planSteps="planSteps"
:comments="comments"
:requirement="workflow?.requirement || ''"
:createdAt="workflow?.created_at || ''"
:workflowStatus="workflow?.status || 'pending'"
:workflowId="workflow?.id || ''"
/>
<TimerSection
v-show="rightTab === 'timers'"
:projectId="projectId"
/>
</div>
</div>
<CommentSection
:comments="comments"
:disabled="!workflow"
@submit="onSubmitComment"
/>
@@ -162,4 +200,40 @@ async function onSubmitComment(text: string) {
font-size: 13px;
cursor: pointer;
}
.right-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.tab-bar {
display: flex;
gap: 2px;
margin-bottom: 8px;
flex-shrink: 0;
}
.tab-btn {
padding: 6px 14px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px 6px 0 0;
letter-spacing: 0.3px;
}
.tab-btn.active {
color: var(--accent);
background: var(--bg-card);
border-bottom-color: var(--bg-card);
}
.tab-btn:hover:not(.active) {
background: var(--bg-tertiary);
}
</style>

View File

@@ -6,19 +6,19 @@
: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;
--bg-primary: #ffffff;
--bg-secondary: #f7f8fa;
--bg-tertiary: #eef0f4;
--bg-card: #ffffff;
--text-primary: #1a1a2e;
--text-secondary: #6b7280;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--border: #e2e5ea;
--success: #16a34a;
--warning: #d97706;
--error: #dc2626;
--pending: #9ca3af;
}
html, body, #app {

View File

@@ -12,6 +12,7 @@ export interface Workflow {
requirement: string
status: 'pending' | 'planning' | 'executing' | 'done' | 'failed'
created_at: string
report: string
}
export interface PlanStep {
@@ -19,8 +20,12 @@ export interface PlanStep {
workflow_id: string
step_order: number
description: string
command: string
status: 'pending' | 'running' | 'done' | 'failed'
output: string
created_at: string
kind: 'plan' | 'log'
plan_step_id: string
}
export interface Comment {
@@ -29,3 +34,14 @@ export interface Comment {
content: string
created_at: string
}
export interface Timer {
id: string
project_id: string
name: string
interval_secs: number
requirement: string
enabled: boolean
last_run_at: string
created_at: string
}

View File

@@ -17,12 +17,29 @@ export interface WsWorkflowStatusUpdate {
status: string
}
export interface WsRequirementUpdate {
type: 'RequirementUpdate'
workflow_id: string
requirement: string
}
export interface WsReportReady {
type: 'ReportReady'
workflow_id: string
}
export interface WsProjectUpdate {
type: 'ProjectUpdate'
project_id: string
name: string
}
export interface WsError {
type: 'Error'
message: string
}
export type WsMessage = WsPlanUpdate | WsStepStatusUpdate | WsWorkflowStatusUpdate | WsError
export type WsMessage = WsPlanUpdate | WsStepStatusUpdate | WsWorkflowStatusUpdate | WsRequirementUpdate | WsReportReady | WsProjectUpdate | WsError
export type WsHandler = (msg: WsMessage) => void