feat(todos): 添加待办事项功能,重构相关组件和样式

This commit is contained in:
Wenjie Zhang 2026-04-01 03:17:15 +08:00
parent da6b9691af
commit b1a1838801
7 changed files with 316 additions and 150 deletions

View File

@ -258,13 +258,13 @@ config_json.context + runtime ids -> context_schema instance
## 5. `capabilities` 的作用
`capabilities` 用于声明前端能力开关,控制 UI 组件显示,不等同于 Context。
`capabilities` 用于声明前端可直接从 Agent 静态元数据判断的能力开关,控制上传入口、文件面板等固定 UI不等同于 Context,也不适合表达运行中才会出现的状态
示例:
```python
class MyAgent(BaseAgent):
capabilities = ["file_upload", "files", "todo"]
capabilities = ["file_upload", "files"]
```
当前常见能力包括:
@ -273,9 +273,10 @@ class MyAgent(BaseAgent):
| --- | --- |
| `file_upload` | 启用上传入口 |
| `files` | 启用文件面板 |
| `todo` | 启用待办能力 |
它解决的是“页面上显示什么”,而不是“运行时如何配置模型和工具”。
像 todo 这类运行态信息,不建议再放进 `capabilities`。Yuxi 当前会直接从 LangGraph state 中提取 `agent_state.todos`,前端按运行时是否真的存在任务来决定是否展示待办入口与状态卡片。
它解决的是“Agent 先天支持什么固定入口”,而不是“运行时当前产生了什么状态”。
## 6. 开发建议

View File

@ -106,6 +106,34 @@
--bg-sider: var(--main-5);
--color-text: var(--c-black);
--light-98: rgba(255, 255, 255, 0.98);
--light-95: rgba(255, 255, 255, 0.95);
--light-90: rgba(255, 255, 255, 0.9);
--light-85: rgba(255, 255, 255, 0.85);
--light-80: rgba(255, 255, 255, 0.8);
--light-75: rgba(255, 255, 255, 0.75);
--light-70: rgba(255, 255, 255, 0.7);
--light-50: rgba(255, 255, 255, 0.5);
--light-25: rgba(255, 255, 255, 0.25);
--light-10: rgba(255, 255, 255, 0.1);
--light-5: rgba(255, 255, 255, 0.05);
--light-0: rgba(255, 255, 255, 0);
--dark-98: rgba(0, 0, 0, 0.98);
--dark-95: rgba(0, 0, 0, 0.95);
--dark-90: rgba(0, 0, 0, 0.9);
--dark-85: rgba(0, 0, 0, 0.85);
--dark-80: rgba(0, 0, 0, 0.8);
--dark-75: rgba(0, 0, 0, 0.75);
--dark-70: rgba(0, 0, 0, 0.7);
--dark-50: rgba(0, 0, 0, 0.5);
--dark-25: rgba(0, 0, 0, 0.25);
--dark-10: rgba(0, 0, 0, 0.1);
--dark-5: rgba(0, 0, 0, 0.05);
--dark-0: rgba(0, 0, 0, 0);
/* Shadow System - 阴影系统 */
--shadow-0: rgba(0, 0, 0, 0.02);
--shadow-1: rgba(0, 0, 0, 0.05);

View File

@ -98,6 +98,33 @@
--bg-sider: #141414;
--color-text: #ffffff;
--dark-98: rgba(255, 255, 255, 0.98);
--dark-95: rgba(255, 255, 255, 0.95);
--dark-90: rgba(255, 255, 255, 0.9);
--dark-85: rgba(255, 255, 255, 0.85);
--dark-80: rgba(255, 255, 255, 0.8);
--dark-75: rgba(255, 255, 255, 0.75);
--dark-70: rgba(255, 255, 255, 0.7);
--dark-50: rgba(255, 255, 255, 0.5);
--dark-25: rgba(255, 255, 255, 0.25);
--dark-10: rgba(255, 255, 255, 0.1);
--dark-5: rgba(255, 255, 255, 0.05);
--dark-0: rgba(255, 255, 255, 0);
--light-98: rgba(0, 0, 0, 0.98);
--light-95: rgba(0, 0, 0, 0.95);
--light-90: rgba(0, 0, 0, 0.9);
--light-85: rgba(0, 0, 0, 0.85);
--light-80: rgba(0, 0, 0, 0.8);
--light-75: rgba(0, 0, 0, 0.75);
--light-70: rgba(0, 0, 0, 0.7);
--light-50: rgba(0, 0, 0, 0.5);
--light-25: rgba(0, 0, 0, 0.25);
--light-10: rgba(0, 0, 0, 0.1);
--light-5: rgba(0, 0, 0, 0.05);
--light-0: rgba(0, 0, 0, 0);
/* Ant Design 兼容变量 - 深色模式 */
--color-bg-container: #1f1f1f;
--color-bg-elevated: #262626;

View File

@ -167,6 +167,7 @@
:supports-file-upload="supportsFileUpload"
:is-panel-open="isAgentPanelOpen"
:has-active-thread="!!currentChatId"
:todos="currentTodos"
@send="handleSendOrStop"
@upload-attachment="handleAttachmentUpload"
@toggle-panel="toggleAgentPanel"
@ -222,7 +223,6 @@
:agent-id="currentThread?.agent_id || currentAgentId"
:agent-config-id="selectedAgentConfigId"
:panel-ratio="panelRatio"
:supports-todo="supportsTodo"
:is-expanded="isAgentPanelExpanded"
@refresh="handleAgentStateRefresh"
@close="toggleAgentPanel"
@ -411,11 +411,6 @@ const supportsFileUpload = computed(() => {
const capabilities = currentAgent.value.capabilities || []
return capabilities.includes('file_upload')
})
const supportsTodo = computed(() => {
if (!currentAgent.value) return false
const capabilities = currentAgent.value.capabilities || []
return capabilities.includes('todo')
})
const supportsFiles = computed(() => {
if (!currentAgent.value) return false
@ -439,13 +434,15 @@ const currentArtifacts = computed(() => {
const artifacts = currentAgentState.value?.artifacts
return Array.isArray(artifacts) ? artifacts : []
})
const currentTodos = computed(() => {
const todos = currentAgentState.value?.todos
return Array.isArray(todos) ? todos : []
})
const hasAgentStateContent = computed(() => {
const s = currentAgentState.value
if (!s && currentThreadFiles.value.length === 0) return false
const todoCount = Array.isArray(s?.todos) ? s.todos.length : 0
if (currentThreadFiles.value.length === 0) return false
const fileCount = currentThreadFiles.value.filter((item) => item?.is_dir !== true).length
return todoCount > 0 || fileCount > 0
return fileCount > 0
})
// hasAgentStateContent false true
@ -936,7 +933,6 @@ const { handleAgentResponse, handleStreamChunk } = useAgentStreamHandler({
getThreadState,
processApprovalInStream,
currentAgentId,
supportsTodo,
supportsFiles,
streamSmoother
})

View File

@ -31,21 +31,66 @@
</template>
<template #actions-left>
<div class="input-actions-left">
<!-- State Toggle Button -->
<button
v-if="hasActiveThread"
class="input-action-btn"
:class="{ active: isPanelOpen }"
@click="$emit('toggle-panel')"
title="查看工作状态"
<a-popover
v-if="showTodoEntry"
v-model:open="todoPopoverOpen"
placement="topLeft"
trigger="click"
overlay-class-name="todo-popover-overlay"
>
<FolderCode :size="18" />
<span>状态</span>
</button>
<template #content>
<div class="todo-popover-card">
<div class="todo-popover-header">
<div class="todo-popover-title-wrap">
<span class="todo-popover-title">当前任务</span>
<span class="todo-popover-summary">{{ completedTodoCount }}/{{ totalTodoCount }} 已完成</span>
</div>
<span class="todo-popover-progress">{{ todoProgress }}%</span>
</div>
<div class="todo-progress-bar">
<span class="todo-progress-bar-fill" :style="{ width: `${todoProgress}%` }"></span>
</div>
<div class="todo-popover-list">
<div v-for="(todo, index) in todos" :key="`${todo.content}-${index}`" class="todo-item">
<div class="todo-item-icon" :class="todo.status || 'unknown'">
<CheckCircleOutlined v-if="todo.status === 'completed'" />
<SyncOutlined v-else-if="todo.status === 'in_progress'" spin />
<ClockCircleOutlined v-else-if="todo.status === 'pending'" />
<CloseCircleOutlined v-else-if="todo.status === 'cancelled'" />
<QuestionCircleOutlined v-else />
</div>
<div class="todo-item-body">
<span class="todo-item-text">{{ todo.content }}</span>
<span class="todo-item-status">{{ getTodoStatusLabel(todo.status) }}</span>
</div>
</div>
</div>
</div>
</template>
<button class="input-action-btn" @click.stop>
<span class="todo-entry-icon" aria-hidden="true">
<SquareCheck :size="16" />
</span>
<span>待办</span>
</button>
</a-popover>
</div>
</template>
<template #actions-right>
<div class="input-actions-right">
<button
v-if="hasActiveThread"
class="input-action-btn"
:class="{ active: isPanelOpen }"
@click.stop="$emit('toggle-panel')"
title="查看文件"
>
<FolderCode :size="18" />
<span>文件</span>
</button>
<slot name="actions-left-extra"></slot>
</div>
</template>
@ -53,11 +98,18 @@
</template>
<script setup>
import { ref } from 'vue'
import { computed, ref, watch } from 'vue'
import MessageInputComponent from '@/components/MessageInputComponent.vue'
import ImagePreviewComponent from '@/components/ImagePreviewComponent.vue'
import AttachmentOptionsComponent from '@/components/AttachmentOptionsComponent.vue'
import { FolderCode } from 'lucide-vue-next'
import { FolderCode, SquareCheck } from 'lucide-vue-next'
import {
CheckCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
QuestionCircleOutlined,
SyncOutlined
} from '@ant-design/icons-vue'
const props = defineProps({
modelValue: { type: String, default: '' },
@ -67,7 +119,11 @@ const props = defineProps({
mention: { type: Object, default: () => null },
supportsFileUpload: { type: Boolean, default: false },
isPanelOpen: { type: Boolean, default: false },
hasActiveThread: { type: Boolean, default: true }
hasActiveThread: { type: Boolean, default: true },
todos: {
type: Array,
default: () => []
}
})
const emit = defineEmits([
@ -80,8 +136,23 @@ const emit = defineEmits([
const inputRef = ref(null)
const currentImage = ref(null)
const todoPopoverOpen = ref(false)
const placeholder = '问点什么?使用 @ 可以提及哦~'
const totalTodoCount = computed(() => props.todos.length)
const completedTodoCount = computed(() => props.todos.filter((todo) => todo?.status === 'completed').length)
const showTodoEntry = computed(() => props.hasActiveThread && totalTodoCount.value > 0)
const todoProgress = computed(() => {
if (!totalTodoCount.value) return 0
return Math.round((completedTodoCount.value / totalTodoCount.value) * 100)
})
watch(showTodoEntry, (visible) => {
if (!visible) {
todoPopoverOpen.value = false
}
})
const updateValue = (val) => {
emit('update:modelValue', val)
}
@ -110,6 +181,7 @@ const handleImageRemoved = () => {
const handleSend = () => {
emit('send', { image: currentImage.value })
currentImage.value = null
todoPopoverOpen.value = false
}
const handleKeyDown = (e) => {
@ -129,6 +201,16 @@ defineExpose({
focus: () => inputRef.value?.focus(),
closeOptions: () => inputRef.value?.closeOptions()
})
const getTodoStatusLabel = (status) => {
const labelMap = {
completed: '已完成',
in_progress: '进行中',
pending: '待处理',
cancelled: '已取消'
}
return labelMap[status] || '未知状态'
}
</script>
<style lang="less" scoped>
@ -136,6 +218,7 @@ defineExpose({
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.input-actions-right {
@ -190,6 +273,138 @@ defineExpose({
}
}
.todo-entry-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: currentColor;
}
.todo-popover-card {
width: min(300px, calc(100vw - 32px));
padding: 14px;
background:
linear-gradient(180deg, var(--gray-50) 0%, var(--gray-50) 100%);
}
.todo-popover-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.todo-popover-title-wrap {
display: flex;
flex-direction: column;
gap: 4px;
}
.todo-popover-title {
font-size: 14px;
font-weight: 600;
color: var(--gray-900);
}
.todo-popover-summary {
font-size: 12px;
color: var(--gray-500);
}
.todo-popover-progress {
font-size: 18px;
line-height: 1;
font-weight: 700;
color: var(--gray-800);
}
.todo-progress-bar {
position: relative;
width: 100%;
height: 6px;
border-radius: 999px;
background: var(--gray-100);
overflow: hidden;
margin-bottom: 12px;
}
.todo-progress-bar-fill {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--color-success-500) 0%, var(--color-success-700) 100%);
}
.todo-popover-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 260px;
overflow: auto;
padding-right: 2px;
}
.todo-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: 12px;
background: var(--light-70);
box-shadow: inset 0 0 0 1px var(--light-70);
}
.todo-item-icon {
width: 24px;
height: 24px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: var(--gray-100);
color: var(--gray-500);
&.completed {
background: var(--color-success-10);
color: var(--color-success-700);
}
&.in_progress {
background: var(--color-info-10);
color: var(--color-info-700);
}
&.pending {
background: var(--color-warning-10);
color: var(--color-warning-700);
}
&.cancelled {
background: var(--color-error-10);
color: var(--color-error-700);
}
}
.todo-item-body {
min-width: 0;
}
.todo-item-text {
font-size: 13px;
line-height: 1.45;
color: var(--gray-800);
word-break: break-word;
margin-right: 4px;
}
.todo-item-status {
font-size: 12px;
color: var(--gray-500);
}
// slot hide-text
:deep(.hide-text) {
@media (max-width: 768px) {
@ -202,5 +417,20 @@ defineExpose({
gap: 8px;
margin-bottom: 10px;
}
.todo-popover-card {
width: min(320px, calc(100vw - 24px));
padding: 12px;
}
}
</style>
<style lang="less">
.todo-popover-overlay {
.ant-popover-inner {
padding: 0;
border-radius: 12px;
overflow: hidden;
}
}
</style>

View File

@ -15,17 +15,7 @@
</div>
<div class="tabs">
<button class="tab" :class="{ active: activeTab === 'files' }" @click="activeTab = 'files'">
文件系统
</button>
<button
v-if="supportsTodo"
class="tab"
:class="{ active: activeTab === 'todos' }"
@click="activeTab = 'todos'"
>
任务 ({{ completedCount }}/{{ todos.length }})
</button>
<div class="tab active">文件系统</div>
<div class="tab-actions">
<button
class="tab-action-btn"
@ -40,32 +30,7 @@
</div>
</div>
<div class="tab-content">
<!-- Todo Display -->
<div v-if="activeTab === 'todos'" class="todo-display">
<div v-if="!todos.length" class="empty">暂无任务</div>
<div v-else class="todo-list" ref="todoListRef">
<div v-for="(todo, index) in todos" :key="index" class="todo-item">
<div class="todo-status">
<CheckCircleOutlined v-if="todo.status === 'completed'" class="icon completed" />
<SyncOutlined
v-else-if="todo.status === 'in_progress'"
class="icon in-progress"
spin
/>
<ClockCircleOutlined v-else-if="todo.status === 'pending'" class="icon pending" />
<CloseCircleOutlined v-else-if="todo.status === 'cancelled'" class="icon cancelled" />
<QuestionCircleOutlined v-else class="icon unknown" />
</div>
<a-tooltip v-if="overflowedIds.has(index)" placement="topLeft" :title="todo.content">
<span class="todo-text">{{ todo.content }}</span>
</a-tooltip>
<span v-else class="todo-text">{{ todo.content }}</span>
</div>
</div>
</div>
<!-- Files Display -->
<div v-if="activeTab === 'files'" class="files-display">
<div class="files-display">
<div v-if="!threadId" class="empty">创建对话后可查看工作区</div>
<div v-else-if="loadingFiles" class="empty">正在加载文件系统...</div>
<div v-else-if="filesystemError" class="empty error-state">
@ -161,23 +126,8 @@
</template>
<script setup>
import { computed, onMounted, onUnmounted, onUpdated, nextTick, ref, watch } from 'vue'
import {
ChevronsDownUp,
ChevronsUpDown,
Download,
FolderCode,
RefreshCw,
Trash2,
X
} from 'lucide-vue-next'
import {
CheckCircleOutlined,
SyncOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
QuestionCircleOutlined
} from '@ant-design/icons-vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { ChevronsDownUp, ChevronsUpDown, Download, FolderCode, RefreshCw, Trash2, X } from 'lucide-vue-next'
import { Modal, message } from 'ant-design-vue'
import FileTreeComponent from '@/components/FileTreeComponent.vue'
import AgentFilePreview from '@/components/AgentFilePreview.vue'
@ -213,10 +163,6 @@ const props = defineProps({
type: Number,
default: 0.35
},
supportsTodo: {
type: Boolean,
default: false
},
isExpanded: {
type: Boolean,
default: false
@ -227,7 +173,6 @@ const emit = defineEmits(['refresh', 'close', 'resize', 'resizing', 'toggle-expa
const INLINE_PREVIEW_MIN_WIDTH = 920
const panelRef = ref(null)
const activeTab = ref('files')
const modalVisible = ref(false)
const currentFile = ref(null)
const currentFilePath = ref('')
@ -242,60 +187,6 @@ const deletingPaths = ref(new Set())
const useInlinePreview = computed(() => panelWidth.value >= INLINE_PREVIEW_MIN_WIDTH)
const todos = computed(() => props.agentState?.todos || [])
const completedCount = computed(() => todos.value.filter((t) => t.status === 'completed').length)
const overflowedIds = ref(new Set())
const todoListRef = ref(null)
const checkOverflow = () => {
if (!todoListRef.value) return
const newOverflowed = new Set()
const textElements = todoListRef.value.querySelectorAll('.todo-text')
textElements.forEach((el, index) => {
if (el.scrollWidth > el.clientWidth) {
newOverflowed.add(index)
}
})
if (overflowedIds.value.size === newOverflowed.size) {
let isSame = true
for (const val of newOverflowed) {
if (!overflowedIds.value.has(val)) {
isSame = false
break
}
}
if (isSame) return
}
overflowedIds.value = newOverflowed
}
onUpdated(() => {
nextTick(checkOverflow)
})
const updateActiveTab = () => {
if (
activeTab.value === 'files' &&
dynamicTreeData.value.length === 0 &&
props.supportsTodo &&
todos.value.length > 0
) {
activeTab.value = 'todos'
}
}
watch(
[() => props.agentState?.todos],
() => {
updateActiveTab()
},
{ deep: true }
)
const buildDisplayName = (fullPath) => {
const normalized = String(fullPath || '').replace(/\/+$/, '')
if (!normalized || normalized === '/') return '/'
@ -666,8 +557,6 @@ const stopResize = (e) => {
}
onMounted(() => {
nextTick(checkOverflow)
updateActiveTab()
refreshFileSystem()
if (panelRef.value && typeof ResizeObserver !== 'undefined') {
@ -1224,4 +1113,4 @@ watch(useInlinePreview, (isInline) => {
}
}
}
</style>
</style>

View File

@ -66,7 +66,6 @@ export function useAgentStreamHandler({
getThreadState,
processApprovalInStream,
currentAgentId,
supportsTodo,
supportsFiles,
streamSmoother
}) {
@ -129,7 +128,6 @@ export function useAgentStreamHandler({
case 'agent_state':
console.log(`${debugPrefix}[agent_state_chunk]`, {
threadId,
supportsTodo: unref(supportsTodo),
supportsFiles: unref(supportsFiles),
currentAgentId: unref(currentAgentId),
hasAgentState: !!chunk.agent_state,
@ -148,7 +146,6 @@ export function useAgentStreamHandler({
} else {
console.warn(`${debugPrefix}[agent_state_skip]`, {
reason: 'empty_state',
supportsTodo: unref(supportsTodo),
supportsFiles: unref(supportsFiles),
hasAgentState: !!chunk.agent_state,
currentAgentId: unref(currentAgentId),
@ -166,10 +163,9 @@ export function useAgentStreamHandler({
threadId,
currentAgentId: unref(currentAgentId),
hasThreadAgentState: !!threadState.agentState,
supportsTodo: unref(supportsTodo),
supportsFiles: unref(supportsFiles)
})
if ((unref(supportsTodo) || unref(supportsFiles)) && threadState.agentState) {
if (unref(supportsFiles) && threadState.agentState) {
console.log(
`[AgentState|Final] ${new Date().toLocaleTimeString()}.${new Date().getMilliseconds()}`,
{
@ -213,7 +209,6 @@ export function useAgentStreamHandler({
console.log(`${debugPrefix}[stream_start]`, {
threadId,
currentAgentId: unref(currentAgentId),
supportsTodo: unref(supportsTodo),
supportsFiles: unref(supportsFiles)
})
await processStreamResponse(response, (chunk) => {