diff --git a/crates/df-ai/src/router.rs b/crates/df-ai/src/router.rs index f6aa16e..c3d9ae8 100644 --- a/crates/df-ai/src/router.rs +++ b/crates/df-ai/src/router.rs @@ -131,6 +131,22 @@ pub fn select_model_id(req: &TaskRequirements, pool: &[ModelConfig]) -> Option bool { + if let Some(m) = pool.iter().find(|m| m.model_id == model_id) { + return !m.modalities.contains(&Modality::Text); + } + let lower = model_id.to_lowercase(); + const IMAGE_PATTERNS: &[&str] = &[ + "dall-e", "gpt-image", "imagen", "flux", "stable-diffusion", + "sensenova-u1", "u1-fast", "midjourney", "sd-", "sdxl", + ]; + IMAGE_PATTERNS.iter().any(|p| lower.contains(p)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/commands/ai/compress.rs b/src-tauri/src/commands/ai/compress.rs index d44397b..e38649a 100644 --- a/src-tauri/src/commands/ai/compress.rs +++ b/src-tauri/src/commands/ai/compress.rs @@ -14,7 +14,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider}; // 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。 // 池空/无匹配兜底 default_model(与 title.rs 一致,行为可预期)。 use df_ai::router::{ - select_model_id, Modality, TaskRequirements, + is_likely_image_model, select_model_id, Modality, TaskRequirements, }; use df_storage::models::AiProviderRecord; @@ -81,6 +81,13 @@ pub(crate) async fn compress_via_llm( }; let model = select_model_id(&compress_req, &provider_config.model_configs) .unwrap_or_else(|| provider_config.default_model.clone()); + // 防御:选中/兜底模型是图像模型时返 Err(走关键词摘要兜底,避免文本任务 404 刷屏)。 + if is_likely_image_model(&model, &provider_config.model_configs) { + return Err(format!( + "压缩模型 {} 是图像模型,文本任务不可用(检查 provider default_model 应为文本模型)", + model + )); + } // 四段式 system prompt + active 消息原文,喂 complete() 非流式出摘要。 let mut prompt = vec![ChatMessage::system(compress_prompt(lang))]; diff --git a/src-tauri/src/commands/ai/knowledge_inject.rs b/src-tauri/src/commands/ai/knowledge_inject.rs index 55ea199..2398907 100644 --- a/src-tauri/src/commands/ai/knowledge_inject.rs +++ b/src-tauri/src/commands/ai/knowledge_inject.rs @@ -10,7 +10,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole}; // - 嵌入: TaskRequirements(Capability::Embedding,无工具)— 在 embedding provider 的池中选, // 池空兜底 config.embedding_model(行为不变,现有 KnowledgeConfig 配置即生效)。 use df_ai::router::{ - select_model_id, Modality, TaskRequirements, + is_likely_image_model, select_model_id, Modality, TaskRequirements, }; use df_storage::crud::{AiConversationRepo, AiMessageRepo, KnowledgeRepo}; use df_storage::db::Database; @@ -682,6 +682,14 @@ async fn extract_knowledge_from_conversation( }; let extract_model = select_model_id(&extract_req, &provider_config.model_configs) .unwrap_or_else(|| provider_config.default_model.clone()); + // 防御:选中/兜底模型是图像模型时跳过 LLM 提取(避免文本任务 404 刷屏,治 default_model 配错为图像模型)。 + if is_likely_image_model(&extract_model, &provider_config.model_configs) { + tracing::warn!( + "知识提取跳过:模型 {} 是图像模型,文本任务不可用(检查 provider default_model 应为文本模型)", + extract_model + ); + return Ok(0); + } let request = CompletionRequest { model: extract_model, messages: extract_messages, diff --git a/src-tauri/src/commands/ai/title.rs b/src-tauri/src/commands/ai/title.rs index a8d1b0c..478f218 100644 --- a/src-tauri/src/commands/ai/title.rs +++ b/src-tauri/src/commands/ai/title.rs @@ -10,7 +10,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole}; // 标题是短文本摘要任务;池空/无匹配兜底 default_model(行为不变)。 // 注:路由已解耦(B-260618-03),原 Lite/Low 智力成本约束已删除,纯 weight 选模型。 use df_ai::router::{ - select_model_id, Modality, TaskRequirements, + is_likely_image_model, select_model_id, Modality, TaskRequirements, }; use df_storage::crud::AiConversationRepo; use df_storage::db::Database; @@ -112,6 +112,15 @@ pub(crate) async fn ensure_conversation_title( }; let title_model = select_model_id(&title_req, &provider_config.model_configs) .unwrap_or_else(|| provider_config.default_model.clone()); + // 防御:选中/兜底模型是图像模型时跳过 LLM(避免文本任务 404 刷屏,治 default_model 配错为图像模型)。 + // 跳过则保留上方 extract_title 兜底,不锁定差标题。 + if is_likely_image_model(&title_model, &provider_config.model_configs) { + tracing::warn!( + "标题 LLM 跳过:模型 {} 是图像模型,文本任务不可用(检查 provider default_model 应为文本模型)", + title_model + ); + return; + } // B-260617-17: LLM 调用包 20s 超时,防信号量/网络挂起致后台 task 永久堆积; // 超时或返回 None 均保留上方已落的 extract 兜底,不覆盖。 let title = match tokio::time::timeout( diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue index f8eca98..cbbd74e 100644 --- a/src/components/ai/MessageList.vue +++ b/src/components/ai/MessageList.vue @@ -1080,6 +1080,7 @@ defineExpose({ gap: 8px; align-items: flex-start; flex-direction: row-reverse; /* 头像在右:DOM 仍 avatar 在先,视觉反转成【气泡】【头像】靠右 */ + margin-top: -3px; /* user 与上方消息间距略收(全局 gap 14 → 视觉 11),更紧凑 */ } .ai-msg-ai { position: relative; @@ -1087,6 +1088,9 @@ defineExpose({ gap: 8px; align-items: flex-start; } +/* token 详情 popover 展开时,提升本消息 z-index 盖住后续消息气泡 + (后续 .ai-msg-user 也是 relative 同级 context,DOM 在后会遮挡 popover) */ +.ai-msg-ai:has(.ai-token-popover) { z-index: 30; } .ai-msg-content { flex: 1; min-width: 0; diff --git a/src/views/ProjectDetail.vue b/src/views/ProjectDetail.vue index fc4910c..e169bc6 100644 --- a/src/views/ProjectDetail.vue +++ b/src/views/ProjectDetail.vue @@ -295,8 +295,24 @@

{{ $t('projectDetail.taskListTitle') }}

{{ $t('projectDetail.taskCount', { n: projectTasks.length }) }} +
+ +
-
+
{{ task.title }} {{ $t(taskStatusLabel(task.status)) }} @@ -572,6 +588,30 @@ async function loadProjectTasks() { } // taskStatusLabel / taskStatusClass 由 ../constants/project 提供 +// 任务状态多选过滤 +const taskStatusFilters = computed(() => [ + { key: 'todo', icon: '📝', label: t('tasks.statusFilter.todo') }, + { key: 'in_progress', icon: '🔨', label: t('tasks.statusFilter.in_progress') }, + { key: 'in_review', icon: '👀', label: t('tasks.statusFilter.in_review') }, + { key: 'testing', icon: '🧪', label: t('tasks.statusFilter.testing') }, + { key: 'done', icon: '✅', label: t('tasks.statusFilter.done') }, + { key: 'blocked', icon: '🚫', label: t('tasks.statusFilter.blocked') }, + { key: 'cancelled', icon: '🗑️', label: t('tasks.statusFilter.cancelled') }, +]) +const activeTaskStatuses = ref([]) +function toggleTaskStatus(key: string) { + const idx = activeTaskStatuses.value.indexOf(key) + if (idx >= 0) { + activeTaskStatuses.value.splice(idx, 1) + } else { + activeTaskStatuses.value.push(key) + } +} +const filteredProjectTasks = computed(() => { + if (activeTaskStatuses.value.length === 0) return projectTasks.value + return projectTasks.value.filter(t => activeTaskStatuses.value.includes(t.status)) +}) + // ── 工程列表(概览内 CRUD)── // 多工程(monorepo/多模块)在概览内增删改 + 扫描;复用 moduleApi + ConfirmDialog(删除确认) // + arco Message(反馈)。切到文件 Tab 时 FileExplorer 因 v-if 重挂载自动重拉,无需手动同步。 @@ -1330,6 +1370,36 @@ onUnmounted(() => { /* .panel / .panel-header 基础样式已收敛至全局 components.css(DRY 收口 B-260619), 此处仅保留本组件特有 .task-count。 */ .task-count { font-size: 12px; color: var(--df-text-dim); } +.task-filter-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 8px; +} +.task-filter-chips .filter-chip { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 8px; + border: 0.5px solid var(--df-border); + border-radius: 10px; + background: transparent; + color: var(--df-text-dim); + font-size: 10px; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} +.task-filter-chips .filter-chip:hover { + background: var(--df-bg-card); + color: var(--df-text); + border-color: var(--df-accent); +} +.task-filter-chips .filter-chip.is-active { + background: var(--df-accent-soft); + color: var(--df-accent); + border-color: var(--df-accent); +} /* A2[PD-P1-3]:工作流日志「全局事件」标注(次要小字标签) */ .global-event-tag { @@ -1346,7 +1416,13 @@ onUnmounted(() => { .task-card { padding: 14px 0; border-bottom: 0.5px solid var(--df-border); + cursor: pointer; + transition: background 0.15s; + margin: 0 -12px; + padding: 10px 12px; + border-radius: var(--df-radius); } +.task-card:hover { background: var(--df-bg-card-hover); } .task-card:last-child { border-bottom: none; } .task-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; } .task-title { font-size: 14px; font-weight: 500; color: var(--df-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } diff --git a/src/views/Tasks.vue b/src/views/Tasks.vue index c9c2502..9520f8e 100644 --- a/src/views/Tasks.vue +++ b/src/views/Tasks.vue @@ -31,11 +31,17 @@
{{ $t('tasks.filter.status') }} - + +
{{ $t('tasks.sort.label') }} @@ -311,7 +317,17 @@ const { toast, showToast } = useToast() // 列表筛选/分页状态持久化到 localStorage(key 前缀 'tasks.'), // 刷新页面后保留用户上次选择。collapsedGroups 单独走 df-tasks-collapsed(沿用既有实现)。 const activeProject = usePersistedRef('tasks.activeProject', 'all') -const activeStatus = usePersistedRef('tasks.activeStatus', 'all') +const activeStatuses = usePersistedRef('tasks.activeStatuses', []) + +// 状态多选切换 +function toggleStatus(key: string) { + const idx = activeStatuses.value.indexOf(key) + if (idx >= 0) { + activeStatuses.value.splice(idx, 1) + } else { + activeStatuses.value.push(key) + } +} const sortBy = usePersistedRef('tasks.sortBy', 'updated_at') const searchKeyword = usePersistedRef('tasks.searchKeyword', '') const loading = ref(false) @@ -526,8 +542,12 @@ function parentPct(row: TaskRow): number { const filteredGroups = computed(() => { // F-260805 D5:store.tasks 是一次性拉取的筛选全量,前端已组装树(taskRows),直接按项目分组 + // 状态多选:未选(全)或选中状态匹配 + const statusFilter = (row: TaskRow) => + activeStatuses.value.length === 0 || activeStatuses.value.includes(row.task.status) const groupMap = new Map() for (const row of taskRows.value) { + if (!statusFilter(row)) continue if (!groupMap.has(row.task.project_id)) { groupMap.set(row.task.project_id, []) } @@ -548,11 +568,10 @@ const filteredGroups = computed(() => { function buildTaskQuery(): TaskQuery | undefined { const projAll = activeProject.value === 'all' - const statusAll = activeStatus.value === 'all' const kw = searchKeyword.value.trim() const query: TaskQuery = { order_by: sortBy.value } if (!projAll) query.project_id = activeProject.value - if (!statusAll) query.status = activeStatus.value + // 状态多选走前端过滤(后端 status 单值,不给后端传使全量拉取,前端 filteredGroups 过滤) if (kw) query.keyword = kw // F-260805 D5:一次拉当前筛选全量(limit 放大 500 钳制上限,offset 恒 0), // 树形需要完整父子关系,分页会割裂父/子;全量已在前端,父进度也前端计算 @@ -608,8 +627,8 @@ watch(activeProject, () => { store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false }) }) -watch(activeStatus, () => { - store.setActiveTaskStatus(activeStatus.value) +watch(activeStatuses, () => { + store.setActiveTaskStatus(activeStatuses.value.length === 0 ? 'all' : activeStatuses.value.join(',')) loading.value = true store.loadTasks(buildTaskQuery()).finally(() => { loading.value = false }) }) @@ -632,7 +651,7 @@ function onKeydown(e: KeyboardEvent) { onMounted(async () => { store.setActiveTaskProject(activeProject.value) - store.setActiveTaskStatus(activeStatus.value) + store.setActiveTaskStatus(activeStatuses.value.length === 0 ? 'all' : activeStatuses.value.join(',')) store.setActiveTaskKeyword(searchKeyword.value.trim() || undefined) loading.value = true try { @@ -705,6 +724,35 @@ onUnmounted(() => { transition: border-color 0.15s; } .filter-select:hover { border-color: var(--df-accent); } +.filter-chip-group { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.filter-chip { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 8px; + border: 0.5px solid var(--df-border); + border-radius: 10px; + background: transparent; + color: var(--df-text-dim); + font-size: 11px; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} +.filter-chip:hover { + background: var(--df-bg-card); + color: var(--df-text); + border-color: var(--df-accent); +} +.filter-chip.is-active { + background: var(--df-accent-soft); + color: var(--df-accent); + border-color: var(--df-accent); +} .filter-count { margin-left: auto; font-size: 12px;