优化: 图像模型兜底防御+任务UI(状态多选+跳详情)+token popover遮挡

This commit is contained in:
lxy
2026-08-10 13:46:26 +08:00
parent 256af4e736
commit 9407f821b9
7 changed files with 182 additions and 14 deletions
+16
View File
@@ -131,6 +131,22 @@ pub fn select_model_id(req: &TaskRequirements, pool: &[ModelConfig]) -> Option<S
ModelRouter::select(req, pool).map(|m| m.model_id.clone())
}
/// 判断 model_id 是否为图像生成模型(文本任务不应使用)。
/// 用于 select 返回 None 兜底 default_model 时的防御:避免标题/压缩等文本任务
/// 误用图像模型(如 sensenova-u1-fast/dall-e)致 LLM 404 刷屏(治 default_model 配错为图像模型)。
/// 判断:pool 有该模型 → 校验模态是否含 Text(最可靠);pool 无 → 名字启发式(已知图像模型模式)。
pub fn is_likely_image_model(model_id: &str, pool: &[ModelConfig]) -> 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::*;
+8 -1
View File
@@ -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))];
@@ -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,
+10 -1
View File
@@ -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(
+4
View File
@@ -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;
+77 -1
View File
@@ -295,8 +295,24 @@
<h2>{{ $t('projectDetail.taskListTitle') }}</h2>
<span class="task-count">{{ $t('projectDetail.taskCount', { n: projectTasks.length }) }}</span>
</div>
<div class="task-filter-chips">
<button
v-for="s in taskStatusFilters"
:key="s.key"
class="filter-chip"
:class="{ 'is-active': activeTaskStatuses.length === 0 || activeTaskStatuses.includes(s.key) }"
@click="toggleTaskStatus(s.key)"
>
{{ s.icon }} {{ s.label }}
</button>
</div>
<div class="task-list">
<div class="task-card" v-for="task in projectTasks" :key="task.id">
<div
class="task-card"
v-for="task in filteredProjectTasks"
:key="task.id"
@click="router.push('/tasks/' + task.id)"
>
<div class="task-top">
<span class="task-title">{{ task.title }}</span>
<span class="task-status" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
@@ -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<string[]>([])
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; }
+58 -10
View File
@@ -31,11 +31,17 @@
</div>
<div class="filter-group">
<span class="filter-label">{{ $t('tasks.filter.status') }}</span>
<select v-model="activeStatus" class="filter-select">
<option v-for="s in statusFilters" :key="s.key" :value="s.key">
<div class="filter-chip-group">
<button
v-for="s in statusFilters"
:key="s.key"
class="filter-chip"
:class="{ 'is-active': activeStatuses.length === 0 || activeStatuses.includes(s.key) }"
@click="toggleStatus(s.key)"
>
{{ s.icon }} {{ s.label }}
</option>
</select>
</button>
</div>
</div>
<div class="filter-group">
<span class="filter-label">{{ $t('tasks.sort.label') }}</span>
@@ -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<string[]>('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<string, TaskRow[]>()
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;