Compare commits

...
4 Commits
Author SHA1 Message Date
lxy 36bc485112 优化: 界面交互完善与设置体验
- 审批卡尊重滚动意图,等长切换回底,排队超时按钮修活
- 带图编辑提示不静默丢,删除目标回写,切换文案消歧
- 会话键盘可达+搜索态重命名,筛选计数口径对齐
- 子任务提示正名,看板加载态
- 主题跟随系统,表单脏态拦截,密钥显隐留空提示
2026-10-03 16:27:48 +08:00
lxy 31f7a9570b 优化: 会话状态键控与提示补全
- 队列清空/发送失败补提示,错误接入分类文案
- 审批超时气泡按会话归属,流守卫按会话清理
- 会话操作失败就近反馈,拖拽非法流转提示原因
2026-10-03 16:27:39 +08:00
lxy 8710a09be2 新增: 关窗托盘化一次性说明
- 首次关窗最小化到托盘时系统通知说明,标记文件防重发
2026-10-03 16:27:37 +08:00
lxy ed678262b6 修复: merge-tree 预检参数序与判定收紧
- 参数序改为共同祖先,原序致分支零差异预检失效
- 冲突判定只认真实标记,等号行不再误撞
2026-10-03 16:27:37 +08:00
55 changed files with 1459 additions and 52 deletions
+105 -7
View File
@@ -167,24 +167,35 @@ pub fn merge_branch(
plan_wt_path: &Path,
subtask_branch: &str,
) -> Result<MergeResult, WorktreeError> {
// 用旧版兼容的 merge-tree(base branch → 输出冲突树)
// --write-tree 需 Git 2.38+,改用三参数 merge-tree <base> <branch1> <branch2>
// 成功(exit 0)= 无冲突可自动合并;非0且有输出 = 冲突
let base = "HEAD";
// 预检用旧版三参形态 merge-tree <共同祖先> <ours> <theirs>
// (--write-tree 需 Git 2.38+,为兼容旧 git 不采用)
// 第 1 参必须是 merge-base:此前误传 HEAD,branch2(HEAD) 相对 base 零差异,
// trivial merge 恒判无冲突(实测真冲突场景输出为空 exit 0),预检形同虚设
let mb = Command::new("git")
.args(["merge-base", "HEAD", subtask_branch])
.current_dir(plan_wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git merge-base 失败: {}", e)))?;
// 旧式形态冲突时 exit 仍为 0,只能看 stdout 判定;只认 <<<<<<< 标记,
// 不查 =======:干净合并的 diff 上下文会带出文件内容,文档里的等号分隔行会误撞
if mb.status.success() {
let merge_base = String::from_utf8_lossy(&mb.stdout).trim().to_string();
let precheck = Command::new("git")
.args(["merge-tree", base, subtask_branch, base])
.args(["merge-tree", merge_base.as_str(), "HEAD", subtask_branch])
.current_dir(plan_wt_path)
.output()
.map_err(|e| WorktreeError::GitFailed(format!("git merge-tree 失败: {}", e)))?;
// merge-tree 输出含 conflict marker(<<<<<<<)时表示有冲突
let precheck_stdout = String::from_utf8_lossy(&precheck.stdout);
if precheck_stdout.contains("<<<<<<<") || precheck_stdout.contains("=======") {
if precheck_stdout.contains("<<<<<<<") {
return Ok(MergeResult::Conflict {
branch: subtask_branch.to_string(),
details: precheck_stdout.to_string(),
});
}
}
// merge-base 失败 = 两分支无共同祖先,无法预检,交由下方实际 merge 裁决
// 无冲突,执行实际 merge
let merge = Command::new("git")
@@ -539,4 +550,91 @@ mod tests {
// plan wt 目录也应被清理
assert!(!plan_wt.0.exists(), "plan worktree 目录应已清理");
}
#[test]
fn wt_08_precheck_hits_conflict_before_real_merge() {
let repo = setup_repo();
let root = repo.path();
std::fs::write(root.join("shared.rs"), "line1\nline2\n").unwrap();
Command::new("git").args(["add", "-A"]).current_dir(root).output().unwrap();
Command::new("git").args(["commit", "-m", "add shared"]).current_dir(root).output().unwrap();
let plan_wt = create_plan_worktree(root, "plan-prechk").unwrap();
let plan_wt_path = plan_wt.0.clone();
let plan_branch = plan_wt.1;
// A 和 B 改同一文件同一行
let a = create_worktree(root, "plan-prechk", "A", &plan_branch).unwrap();
std::fs::write(a.path.join("shared.rs"), "A_version\n").unwrap();
commit_worktree(&a.path, "A change shared").unwrap();
let b = create_worktree(root, "plan-prechk", "B", &plan_branch).unwrap();
std::fs::write(b.path.join("shared.rs"), "B_version\n").unwrap();
commit_worktree(&b.path, "B change shared").unwrap();
assert!(matches!(
merge_branch(&plan_wt_path, &a.branch).unwrap(),
MergeResult::Clean
));
// 冲突必须由 merge-tree 预检命中(details 含标记),而非实际 merge 失败兜底
// (兜底 details 来自 merge stderr,只有 CONFLICT 文案无标记)
match merge_branch(&plan_wt_path, &b.branch).unwrap() {
MergeResult::Conflict { details, .. } => {
assert!(
details.contains("<<<<<<<"),
"冲突详情应含 merge-tree 预检输出的标记,而非 merge 兜底文案: {}",
details
);
}
_ => panic!("同文件同行改应冲突"),
}
// 预检命中即返回,不应留下合并中状态
let merging = Command::new("git")
.args(["rev-parse", "-q", "--verify", "MERGE_HEAD"])
.current_dir(&plan_wt_path)
.output()
.unwrap();
assert!(!merging.status.success(), "预检命中不应留下 MERGE_HEAD");
remove_plan_worktrees(root, "plan-prechk").unwrap();
}
#[test]
fn wt_09_clean_merge_with_eq_marker_content() {
let repo = setup_repo();
let root = repo.path();
// 文件内容含等号分隔行(md 标题下划线常见),A/B 隔行修改,实际可干净合并
std::fs::write(root.join("doc.md"), "t0\n=======\nl3\nl4\nl5\n").unwrap();
Command::new("git").args(["add", "-A"]).current_dir(root).output().unwrap();
Command::new("git").args(["commit", "-m", "add doc"]).current_dir(root).output().unwrap();
let plan_wt = create_plan_worktree(root, "plan-eqmark").unwrap();
let plan_wt_path = plan_wt.0.clone();
let plan_branch = plan_wt.1;
let a = create_worktree(root, "plan-eqmark", "A", &plan_branch).unwrap();
std::fs::write(a.path.join("doc.md"), "A0\n=======\nl3\nl4\nl5\n").unwrap();
commit_worktree(&a.path, "A chg line1").unwrap();
let b = create_worktree(root, "plan-eqmark", "B", &plan_branch).unwrap();
std::fs::write(b.path.join("doc.md"), "t0\n=======\nl3\nl4\nB5\n").unwrap();
commit_worktree(&b.path, "B chg line5").unwrap();
assert!(matches!(
merge_branch(&plan_wt_path, &a.branch).unwrap(),
MergeResult::Clean
));
// B 隔行修改应 Clean:预检不得因 diff 上下文中的等号行误判冲突
assert!(matches!(
merge_branch(&plan_wt_path, &b.branch).unwrap(),
MergeResult::Clean
));
remove_plan_worktrees(root, "plan-eqmark").unwrap();
}
}
+28
View File
@@ -9,6 +9,8 @@ mod local_proxy_gateway;
mod tray;
use tauri::{Emitter, Listener, Manager};
// UX-P2 走查⑥:首次关窗托盘化提示走 OS 系统通知(应用内气泡窗口即隐看不见),需 NotificationExt
use tauri_plugin_notification::NotificationExt;
use state::AppState;
@@ -31,6 +33,11 @@ use df_tunnel::TunnelClient;
/// 关窗进行中提示哨兵:tray-hide-notice 每进程只发一次(反复关窗不重复弹气泡)。
static CLOSE_NOTICE_SENT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// 首次关窗托盘化说明的持久化标记文件名(app_data_dir 下;存在 = 历史上已提示过,不再弹)。
/// 用文件而非进程哨兵:解释一次即够,重启后不必再打扰;写入与通知成败解耦(先占坑语义),
/// 防通知通道异常(如无系统通知权限)导致每次关窗都尝试弹。
const TRAY_NOTICE_SENT_MARKER: &str = "tray-hide-notice-sent";
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// 初始化文件日志:追加到 %TEMP%/devflow-trace.log,RUST_LOG 控制级别(默认 info)
@@ -521,6 +528,9 @@ pub fn run() {
// 真正关闭,否则 close 被吞成隐藏僵尸窗,浮窗「正在关闭…」卡死、新审批推给不可见窗口。
// P1 bg 化:有 AI 会话运行中时,首次关窗 emit tray-hide-notice 提示后台仍在跑(每进程一次),
// 异步等 2 秒再 hide(同步 sleep 卡主线程渲染,气泡画不出来);无运行会话保持立即 hide 零回归。
// UX-P2 走查⑥:无会话关窗历史上零提示,用户误以为已退出。窗口随即隐藏,应用内气泡画了也
// 看不见,且前端气泡通道仅 running>0 才渲染,故走 OS 系统通知(托盘旁 toast)补位,
// 以持久化标记全生命周期只解释一次(解释性提示,与上方每进程一次的会话提醒互不替代)。
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if window.label() != "main" {
@@ -530,6 +540,24 @@ pub fn run() {
let app = window.app_handle().clone();
let active = app.state::<AppState>().conv_states.active_convs().len();
if active == 0 {
// 首次(全历史一次)关窗托盘化:弹一次系统通知说明「已最小化到托盘」
match app.path().app_data_dir() {
Ok(dir) => {
let marker = dir.join(TRAY_NOTICE_SENT_MARKER);
if !marker.exists() {
let _ = app
.notification()
.builder()
.title("DevFlow")
.body("已最小化到系统托盘,程序仍在后台运行;托盘图标可随时唤起,托盘菜单「退出」才真正结束")
.show();
let _ = std::fs::write(&marker, b"1");
}
}
Err(e) => {
tracing::warn!("[tray] app_data_dir 解析失败,跳过首次关窗托盘提示: {}", e);
}
}
let _ = window.hide(); // 无运行会话:隐藏到托盘(原行为)
return;
}
+9 -3
View File
@@ -413,9 +413,6 @@ const activeQueue = computed(() =>
store.state.queue.filter(q => q.conversationId === store.state.activeConversationId),
)
/** 队首排队是否已超 30s(仅看当前活跃会话队列) */
const queueTimedOut = computed(() => store.isQueueTimedOut(store.state.activeConversationId))
// ── V7 排队时钟:响应式 tick 驱动排队项已等待时长 ──
// 原 queueWaitSeconds 在 computed 内直接取 Date.now(),非响应式依赖:纯等待期(无 store 变更)
// 永不重算,秒数冻结;且只覆盖队首。改为有排队时起 1s interval 推进 nowTick,展示函数读
@@ -423,6 +420,15 @@ const queueTimedOut = computed(() => store.isQueueTimedOut(store.state.activeCon
const nowTick = ref(Date.now())
let queueTickTimer: ReturnType<typeof setInterval> | null = null
/** 队首排队是否已超 30s(仅看当前活跃会话队列)。
* UX-P2-A1:isQueueTimedOut 内取 Date.now() 非响应式,纯等待期(无 store 变更)本 computed
* 永不重算,超时横幅/强制发送按钮延迟到下一次无关 store 变更才出现。显式读 nowTick
* (V7 排队时钟,有排队时 1s interval 推进)建立每秒依赖,超时判定随 tick 每秒重算。 */
const queueTimedOut = computed(() => {
void nowTick.value
return store.isQueueTimedOut(store.state.activeConversationId)
})
watch(
() => activeQueue.value.length > 0,
(waiting) => {
+38 -1
View File
@@ -65,6 +65,11 @@ const emit = defineEmits<{
* 调 store.sendMessage 触发 AI 装流式。仅 fetch_url 工具会发此事件。
*/
'install-obscura': []
/**
* 审批卡到达但用户不在底部附近(上滚读历史中):不强拉滚动,
* 由父(MessageList)出「有新审批」轻提示,尊重用户阅读位置。
*/
'pending-out-of-view': []
}>()
// 根元素 ref(scrollToFirstPending 查 DOM 用)
@@ -306,6 +311,28 @@ let _unlistenPending: UnlistenFn | null = null
// 论证 workflow 回归中优:防卸载竞态——await listen 解析前组件已卸载(HMR/快速切会话)时,
// onBeforeUnmount 跑到 _unlistenPending 仍 null 跳过 unlisten → 孤儿监听器泄漏。disposed 标志兜底。
let _disposed = false
/** 距滚动容器底部 <150px 视为"在底部附近",审批卡到达才跟随滚动(宽于 useMessageScroll 的 80px 在底判定) */
const PENDING_FOLLOW_PX = 150
/** 轻提示去重阈值:审批卡短时间连发多条时 5s 内只提示一次 */
const PENDING_HINT_INTERVAL_MS = 5000
// 模块级(跨实例共享):ai-pending-arrived 广播所有 ToolCardList 实例都会收到,去重须超组件实例
let _lastPendingHintTs = 0
/** 滚动容器(.ai-messages,MessageList 持有)是否在底部附近 */
function isContainerNearBottom(el: Element): boolean {
const he = el as HTMLElement
return he.scrollHeight - he.scrollTop - he.clientHeight < PENDING_FOLLOW_PX
}
/** 用户不在底部时的轻提示:不拉滚,经父转发 toast(5s 去重防连发刷屏) */
function notifyPendingOutOfView(): void {
const now = Date.now()
if (now - _lastPendingHintTs < PENDING_HINT_INTERVAL_MS) return
_lastPendingHintTs = now
emit('pending-out-of-view')
}
onMounted(async () => {
const fn = await listen('ai-pending-arrived', () => {
// BUG-260624-01 残留优化:本实例无 pending 卡时短路返回,避免大列表 N 个 ToolCardList 实例
@@ -313,7 +340,17 @@ onMounted(async () => {
// 内 querySelector 返 null no-op 天然过滤(功能正确),此处显式 props 过滤把无 pending 实例挡在
// 进入函数前。含 pending 的实例再走 nextTick + scrollToFirstPending(展开折叠组 + scroll)。
if (!props.toolCalls.some(tc => tc.status === 'pending_approval')) return
nextTick(() => scrollToFirstPending())
nextTick(() => {
// UX 走查-对话域 B1:尊重用户滚动意图。nextTick 等审批卡渲染完再测距底距离:
// 在底部附近(含本就贴底)才跟随滚到审批卡;用户上滚读历史时不强拉,改轻提示。
// 容器找不到(极端:分离窗口 DOM 层级变化)按可滚处理,保留原展开+滚动兜底。
const container = rootEl.value?.closest('.ai-messages')
if (!container || isContainerNearBottom(container)) {
scrollToFirstPending()
} else {
notifyPendingOutOfView()
}
})
})
// 解析时若已卸载(HMR/极速 mount/unmount),立即 unlisten 刚拿到的句柄,防孤儿监听器。
if (_disposed) { fn(); return }
+9
View File
@@ -894,6 +894,15 @@ async function sendExamplePrompt(i18nKey: string) {
/** UX-09 进入编辑态:回填末条 user 内容到输入框,聚焦,光标置末 */
function startEditFocus(msg: AiMessage): void {
// UX 走查-对话域 B3:带图消息禁止进入编辑态。editMessage IPC(ai_chat_edit)只回填/替换
// content 文本,不接受 parts——编辑重发后图片静默丢失(重载后图消失),无任何提示。
// 后端契约未扩前,入口挡住并提示,杜绝静默丢数据。父已先置 editingMsgId 再调本方法,
// 故 emit cancel-edit 让父复位编辑态,状态保持一致。
if (msg.parts?.some(p => p.type === 'image')) {
emit('cancel-edit')
emit('error', { msg: t('aiChat.editImageBlocked'), type: 'warning' })
return
}
inputText.value = msg.content
nextTick(() => {
inputEl.value?.focus()
+104 -1
View File
@@ -28,6 +28,7 @@
:placeholder="t('aiChat.searchPlaceholder')"
:title="t('aiChat.searchShortcutHint')"
type="text"
@keydown="onSearchKeydown"
/>
<kbd class="ai-conv-search-kbd">{{ t('aiChat.searchShortcutHint') }}</kbd>
</div>
@@ -44,10 +45,28 @@
'ai-conv-item--archived': conv.archived,
'ai-conv-item--pinned': conv.pinned,
}"
tabindex="0"
@click="onSelectSearchResult(conv.id)"
@keydown="onItemKeydown($event, conv.id)"
>
<div class="ai-conv-item-main">
<span class="ai-conv-item-title" :title="conv.title || ''">
<!-- UX走查 P3-1: 搜索态此前无重命名入口(无 startRename 分支),与分组视图降级不一致;
双击入口对齐分组视图,菜单入口供键盘用户(P2-6) -->
<input
v-if="editingConvId === conv.id"
:ref="setEditEl"
v-model="editText"
class="ai-conv-item-edit"
@keydown="onRenameKeydown($event, conv.id)"
@blur="commitRename(conv.id)"
@click.stop
/>
<span
v-else
class="ai-conv-item-title"
:title="conv.title || t('aiChat.doubleClickToRename')"
@dblclick.stop="startRename(conv)"
>
{{ conv.title || t('aiChat.newConversation') }}
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
@@ -61,6 +80,10 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</button>
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
<button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
{{ t('aiChat.renameConversation') }}
</button>
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
@@ -106,7 +129,9 @@
:ref="(el) => setItemEl(conv.id, el)"
class="ai-conv-item"
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
tabindex="0"
@click="beginSwitch(conv.id)"
@keydown="onItemKeydown($event, conv.id)"
>
<div class="ai-conv-item-main">
<input
@@ -145,6 +170,10 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</button>
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
<button class="ai-conv-actions-opt" @click.stop="startRename(conv); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
{{ t('aiChat.renameConversation') }}
</button>
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
<svg width="12" height="12" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
@@ -176,7 +205,9 @@
:ref="(el) => setItemEl(conv.id, el)"
class="ai-conv-item ai-conv-item--archived"
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
tabindex="0"
@click="beginSwitch(conv.id)"
@keydown="onItemKeydown($event, conv.id)"
>
<div class="ai-conv-item-main">
<span class="ai-conv-item-title" :title="conv.title || ''">
@@ -216,6 +247,11 @@
</div>
</template>
</div>
<!-- UX走查 P2-7: 会话操作(rename/archive/pin)失败的就近反馈,定位侧栏顶部而非推进聊天流
(操作发起与视线焦点都在侧栏;且消息区被清空/切换后气泡可能不可见) -->
<Transition name="conv-toast">
<div v-if="convToast.visible" class="ai-conv-toast">{{ convToast.msg }}</div>
</Transition>
</div>
</transition>
</template>
@@ -243,6 +279,9 @@ import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
// V1 只读消费:跨会话待审批真相源(state.pendingApprovals 仅是活跃会话投影,后台会话挂起不进投影)
import { convPendingApprovals } from '../../composables/ai/aiShared'
// UX走查 P2-7 只读订阅:rename/archive/pin 失败通知由 useAiConversations 发布,本组件弹就近 toast
import { convOpFailNotice } from '../../composables/ai/useAiConversations'
import { useToast } from '../../composables/useToast'
import { formatRelative } from '../../utils/time'
import type { AiConversationSummary } from '../../api/types'
@@ -324,6 +363,27 @@ function onRenameKeydown(e: KeyboardEvent, convId: string) {
}
}
// UX走查 P3-1: 搜索框 Esc 清空(对齐 Tasks 搜索框行为)。同款 IME 守卫:组词中按 Esc 是
// 取消组词,keyup 阶段 isComposing 已复位拦不住,故挂 keydown
function onSearchKeydown(e: KeyboardEvent) {
if (e.isComposing || e.keyCode === 229) return
if (e.key === 'Escape') {
e.preventDefault()
store.state.searchQuery = ''
}
}
// UX走查 P2-6: 会话项键盘可达(tabindex=0)。Enter/Space 等价点击选中;仅焦点落在本项 div
// 自身时处理——焦点在内部按钮/重命名输入框时 target≠currentTarget,避免与控件自身语义双触发
function onItemKeydown(e: KeyboardEvent, convId: string) {
if (e.isComposing || e.keyCode === 229) return
if (e.target !== e.currentTarget) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
beginSwitch(convId)
}
}
// ── 会话项「更多」操作菜单(置顶/归档/删除)──
const actionsOpenId = ref<string | null>(null)
@@ -343,6 +403,20 @@ function onActionsOutsideClick(e: MouseEvent) {
actionsOpenId.value = null
}
// UX走查 P2-6: 操作菜单原仅外部点击可关,键盘用户开菜单后无 Esc 逃逸;IME 守卫防组词取消误关
function onActionsEscClose(e: KeyboardEvent) {
if (!actionsOpenId.value) return
if (e.isComposing || e.keyCode === 229) return
if (e.key === 'Escape') actionsOpenId.value = null
}
// UX走查 P2-7: rename/archive/pin 失败反馈改侧栏就近 toast(原以错误气泡推进聊天流,视线错位)。
// 每次失败发布新对象,watch 即重触发;useToast 自动重置计时与卸载清理
const { toast: convToast, showToast: showConvToast } = useToast()
watch(convOpFailNotice, (n) => {
if (n) showConvToast(t(`ai.${n.key}`, n.params ?? {}), 'error')
})
// ── 会话切换过渡态(UX走查 2026-10-03 P1):点击即预高亮目标项(乐观),IPC 返回后随
// activeConversationId 落定接管高亮;失败/过期时 finally 清 pending,高亮自动回滚到原活跃项。
// 治:大历史会话 IPC 数百 ms 期间点击项不高亮、原高亮不动、无任何进行时反馈,快速连点像失灵。
@@ -511,12 +585,15 @@ function onResizeStart(e: MouseEvent) {
onMounted(() => {
// 点击外部关闭更多操作菜单
document.addEventListener('click', onActionsOutsideClick)
// 操作菜单 Esc 关闭(P2-6,与外部点击同生命周期)
document.addEventListener('keydown', onActionsEscClose)
// V1:后台会话待审批变化兜底轮询(见 pendingCountByConv 注)
pendingPollTimer = setInterval(() => { pendingTick.value++ }, 2500)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onActionsOutsideClick)
document.removeEventListener('keydown', onActionsEscClose)
// 释放待审批徽标轮询计时器
if (pendingPollTimer) { clearInterval(pendingPollTimer); pendingPollTimer = null }
// 拖拽中途卸载兜底摘临时 document listener(原 AiChat 同段迁移)
@@ -774,6 +851,11 @@ body.ai-sidebar-resizing * {
.ai-conv-item:hover .ai-conv-item-actions {
display: flex;
}
/* UX走查 P2-6: 键盘 Tab 进会话项(项自身 tabindex=0,或焦点在项内按钮/输入框)时同样浮出
操作按钮——原仅 :hover 渲染,键盘用户永远够不到「...」菜单触发点 */
.ai-conv-item:focus-within .ai-conv-item-actions {
display: flex;
}
/* 菜单展开时强制 actions 可见(鼠标移到菜单上 item 不再 hover,防菜单消失) */
.ai-conv-item:has(.ai-conv-actions-menu) .ai-conv-item-actions {
display: flex;
@@ -853,6 +935,27 @@ body.ai-sidebar-resizing * {
color: var(--df-text-dim);
}
/* UX走查 P2-7: 会话操作失败就近 toast(仅 error 一型)。视觉对齐 AiChat .ai-toast,
但 AiChat 版是 scoped 样式渗透不进来,故自持一份;侧栏窄,max-width 收 85% */
.ai-conv-toast {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
padding: 8px 14px;
border-radius: var(--df-radius);
font-size: 12px;
z-index: 30; /* 高于操作菜单(20),菜单展开时失败反馈不被盖 */
max-width: 85%;
overflow-wrap: anywhere;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
background: var(--df-danger-bg);
color: var(--df-danger);
border: 0.5px solid var(--df-danger);
}
.conv-toast-enter-active, .conv-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
.conv-toast-enter-from, .conv-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
/* 侧边栏滑入动画 */
.sidebar-slide-enter-active { transition: width 0.2s var(--df-ease), opacity 0.15s; }
.sidebar-slide-leave-active { transition: width 0.15s var(--df-ease), opacity 0.1s; }
+12
View File
@@ -326,6 +326,12 @@ function scrollToFirstPending(): void {
}
}
/** 审批卡到达但用户不在底部(上滚读历史中):不强拉滚动,出「有新审批」轻提示。
* ToolCardList 侧已做 5s 去重,此处仅转发父 toast(单一出口)。 */
function onPendingOutOfView(): void {
emit('toast', { msg: t('aiChat.newApprovalPending'), type: 'info' })
}
/**
* 移除 deep:true,改在 watch source 中计算指纹(消息数 + toolCalls 的 id/status),
* 避免 deep 每帧全量深遍历长会话数组引起掉帧。流式 delta 只改 content 不影响指纹,
@@ -1108,6 +1114,10 @@ watch(() => store.state.activeConversationId, () => {
}
// 切会话同步清流式块级 memo 缓存(_blockCache),防旧会话已缓存块被新会话命中。
resetStreamCache()
// UX 走查-对话域 B2:切会话显式回底。仅 watch messages.length 时,两个等长消息数的会话
// 互切不触发任何滚动,scrollTop 残留旧会话位置(可能停在中段)。setFollowing(true)已声明
// 跟随意图,此处补上实际滚动;nextTick 等新会话消息渲染出真实 scrollHeight 再滚。
nextTick(() => scrollToBottom())
})
// 流式 rAF 清理:组件卸载时若仍有 pending rAF(streaming 中途切走/关窗),取消防泄漏。
@@ -1205,6 +1215,7 @@ defineExpose({
@approve="({ id, approved, decision }) => store.approveToolCall(id, approved, decision)"
@batch-approve="(decision) => store.batchApprove(decision)"
@install-obscura="onInstallObscura"
@pending-out-of-view="onPendingOutOfView"
/>
</div>
</div>
@@ -1459,6 +1470,7 @@ defineExpose({
@approve="({ id, approved, decision }) => store.approveToolCall(id, approved, decision)"
@batch-approve="(decision) => store.batchApprove(decision)"
@install-obscura="onInstallObscura"
@pending-out-of-view="onPendingOutOfView"
/>
</div>
</div>
+6 -1
View File
@@ -26,7 +26,7 @@
class="ai-provider-bar"
:class="{ 'ai-provider-bar--switchable': store.state.providers.length > 1 }"
v-if="store.state.providers.length > 0"
:title="store.state.providers.length > 1 ? $t('aiChat.clickToSwitchProvider') : ''"
:title="store.state.providers.length > 1 ? $t('aiChat.providerSwitchSetsDefault') : ''"
@click="cycleProvider"
>
<span class="provider-dot" :class="'provider-dot--' + providerStatus"></span>
@@ -536,6 +536,11 @@ async function removeGoal(i: number) {
if (next.length === 0) goalsExpanded.value = false
// API 接受 string[] 或 GoalEntry[]
await aiApi.updateConversationGoals(convId, next.map(g => g.text))
// UX 走查-对话域 B4:删除后同步回写 store 的 conv.pinned_goals(对齐 AiCompleted 回写
// useAiLifecycleEvents:126-129 的结构)。不回写则 loadGoals(:519)从 store 重读旧列表,
// 已删目标在下次 messages.length watch 触发刷新时"复活"。
const conv = store.state.conversations.find(c => c.id === convId)
if (conv) conv.pinned_goals = next
}
// 循环切换 Provider(provider bar 点击)
+14 -1
View File
@@ -42,7 +42,7 @@
</template>
<script setup lang="ts">
import { reactive, watch, onMounted, useTemplateRef } from 'vue'
import { reactive, watch, onMounted, onUnmounted, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppSettingsStore } from '@/stores/appSettings'
import SettingRow from './SettingRow.vue'
@@ -77,6 +77,14 @@ function applyTheme(theme: string) {
}
}
// 走查 P2-4:原实现只在挂载/改档时读一次 matchMedia.matches,OS 深浅色切换后应用停在旧色,
// 需重启或重选档位才跟随。挂 change 监听实时重算;非 system 档是用户手动定色,不应被 OS
// 切色打断,故回调内先判档。组件卸载必须移除监听(本 Section 随分类切换反复挂卸,不清理会叠监听器)。
const systemDarkQuery = window.matchMedia('(prefers-color-scheme: dark)')
function onSystemThemeChange() {
if (settings.theme === 'system') applyTheme('system')
}
// 即时生效反馈:SettingRow refs,key 映射对应 row
const rowTheme = useTemplateRef<InstanceType<typeof SettingRow>>('rowTheme')
const rowLanguage = useTemplateRef<InstanceType<typeof SettingRow>>('rowLanguage')
@@ -113,6 +121,11 @@ watch(() => settings.language, (val) => {
onMounted(() => {
applyTheme(settings.theme)
systemDarkQuery.addEventListener('change', onSystemThemeChange)
})
onUnmounted(() => {
systemDarkQuery.removeEventListener('change', onSystemThemeChange)
})
</script>
+13 -1
View File
@@ -92,7 +92,15 @@
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelApiKey') }}</label>
<input v-model="providerForm.apiKey" class="setting-input" type="password" :placeholder="$t('settings.phApiKey')" />
<!-- 走查 P3-9:type 恒 password 无法核对已粘贴密钥,加显隐切换 -->
<input v-model="providerForm.apiKey" class="setting-input" :type="showApiKey ? 'text' : 'password'" :placeholder="$t('settings.phApiKey')" />
<button type="button" class="btn-link" :title="$t(showApiKey ? 'settings.apiKeyHide' : 'settings.apiKeyShow')"
@click="showApiKey = !showApiKey">
{{ $t(showApiKey ? 'settings.apiKeyHide' : 'settings.apiKeyShow') }}
</button>
<!-- 走查 P3-9:编辑态留空=沿用已存密钥(后端 FR-S1),此前表单零说明,用户误以为必须重输。
仅编辑态显(新建态密钥必填,不存在"留空"语义)。 -->
<div v-if="providerForm.editId" class="form-field-hint">{{ $t('settings.apiKeyKeepHint') }}</div>
</div>
<div class="form-field">
<label class="form-label">{{ $t('settings.labelDefaultModel') }}</label>
@@ -217,6 +225,9 @@ const props = defineProps<{
const aiProviders = ref<AiProviderConfig[]>([])
// 密钥显隐切换(走查 P3-9):默认隐藏;重开表单时复位,防上一家 provider 的显钥状态延续
const showApiKey = ref(false)
function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e) }
const providerForm = reactive({
@@ -422,6 +433,7 @@ function openProviderForm(p?: AiProviderConfig) {
}
providerForm.fetching = false
providerForm.saveError = null // 重开表单清旧错误横幅
showApiKey.value = false // 密钥默认隐藏,不延续上次表单的显钥状态(防窥)
providerForm.visible = true
// L19/M32: 记初始快照(在 visible=true、字段回填完成后),isFormDirty 据此比对。
providerForm.savedSnapshot = formBusinessSnapshot()
+2 -1
View File
@@ -323,7 +323,8 @@ async function confirmSubtask() {
subtaskTitle.value = ''
subtaskDesc.value = ''
await loadChildren()
showToast(t('tasks.batch.advanceResult', { n: 1 }), 'success')
// UX走查 P2-3:新建子任务成功此前误复用批量推进文案「已推进 N 个任务」,语义误导
showToast(t('taskDetail.subtaskCreated'), 'success')
} catch (e: any) {
showToast(e?.toString() ?? t('common.unknownError'), 'error')
} finally {
+36 -7
View File
@@ -175,13 +175,8 @@ export function startApprovalTimer(
denyP.catch(e => {
console.error('[AI] 审批超时自动拒绝 IPC 未送达:', e)
})
getMessages().push({
id: `approval-timeout-${nextMsgId()}` as MessageId,
role: 'assistant',
content: t('ai.approvalTimeout', { toolName }),
isError: true,
timestamp: Date.now(),
})
// UX-P2-A3:气泡推送改经 per-conv 守卫(函数追加在本文件尾部),后台会话超时不串当前视图
pushApprovalTimeoutBubble(toolName, toolCallId)
}, timeoutMs)
_approvalTimers.set(toolCallId, { timer, startedAt: Date.now(), timeoutMs })
}
@@ -665,3 +660,37 @@ export function clearConvAgentFinished(convId: string | null | undefined): void
if (!convId) return
convAgentFinished.delete(convId)
}
// ── UX-P2-A3 审批超时错误气泡 per-conv 守卫 ──
//
// 背景:startApprovalTimer 到点把「审批超时已自动拒绝」错误气泡直接推进 getMessages()
// (当前活跃视图),后台会话 A 的审批超时,气泡落在用户正查看的会话 B 里(跨会话串扰)。
// 本守卫按审批所属 conv 过滤:仅当审批所属会话是当前活跃会话(或无法归属)才推视图气泡;
// 后台会话的超时拒绝 IPC 照常执行,只是不串视图。
// 归属反查:到点时刻从 per-conv 待审批集合(convPendingApprovals)按 toolCallId 反查 convId——
// 此时拒绝 IPC 才刚发出,审批结果事件未到,集合项必在;查不到(旧数据/已被清)保守回落为推,
// 不改变无归属场景的原行为。
export function pushApprovalTimeoutBubble(toolName: string, toolCallId: string): void {
let convId: string | null = null
for (const [k, arr] of convPendingApprovals) {
if (arr.some(p => p.id === toolCallId)) {
convId = k
break
}
}
const active = getState()?.activeConversationId ?? null
if (convId && convId !== active) return
getMessages().push({
id: `approval-timeout-${nextMsgId()}` as MessageId,
role: 'assistant',
content: t('ai.approvalTimeout', { toolName }),
isError: true,
timestamp: Date.now(),
})
}
/** 队列被清空的统一提示(UX-P2-A2:AiError/regenerate/editMessage 清队从静默改 toast,
* 清 0 条不提示;经 ai-toast 事件总线,AiChat 本地 toast 消费) */
export function notifyQueueCleared(n: number): void {
if (n > 0) void emit('ai-toast', { msg: t('ai.queueCleared', { n }), type: 'warning' })
}
+12 -5
View File
@@ -21,7 +21,7 @@
//! streaming 是 boolean 无真正非法跃迁,guard 价值在集中入口与日志而非拦截。
import { state } from '@/stores/ai'
import { convStates, getConvStreamState, setConvStreaming } from './aiShared'
import { convStates, getConvStreamState, setConvStreaming, setConvCurrentText } from './aiShared'
import { useAppSettingsStore } from '@/stores/appSettings'
/// feature flag key(appSettings KV)。关=回退散布语义(灰度/回退用)。
@@ -88,10 +88,9 @@ export function setStreaming(
* TD-260621-01 per-conv:看门狗超时携带 convId 时,经 setStreaming 仅复位该会话 streaming 态;
* 该会话的 convStates 项由各自会话收尾事件精确管理(on/off 后端均会 emit 收尾态)。
*
* 注:currentText/queue 仍是单例(F-09 域,本批不动),per-conv 超时在多会话并发下
* 会清当前累积的 currentText——但 currentText 仅活跃视图写入(useAiEvents handleEvent delta 累积),
* 而超时仅发生在该 conv 长时间无数据,此场景 currentText 多为空或已 flush,
* 实际误伤面远小于 generatingConvs 全清(队列 queue 的清理由 F-09 统一改,见 TD-260621-01 注释)。
* 注:currentText/queue 已随 UX-P2-A4 改 per-conv 键控:带 convId 的兜底只清该会话的
* 排队项与流式缓冲,不再连带清其他会话的排队消息(多会话并发下 queue 是跨会话混排数组,
* 原全清会把 B 会话排队一并丢掉);无 convId 全局兜底语义不变(全清)。
*
* @param reason 兜底来源(日志可观测)
* @param convId 关联会话 id(看门狗 per-conv 超时传;省略=全局兜底)
@@ -99,8 +98,16 @@ export function setStreaming(
export function forceResetStreaming(reason: string, convId?: string): void {
console.warn(`[AI-Guard] forceResetStreaming(卡死兜底): ${reason} (convId=${convId ?? 'none'})`)
setStreaming(false, { convId: convId ?? null, reason })
// UX-P2-A4:per-conv 键控清理——带 convId 只清该会话排队项(currentText 同步走 per-conv 键,
// state.currentText 是委派活跃会话的 accessor,后台 conv 超时原写法会误清当前视图缓冲);
// 无 convId 全局兜底保持全清(卡死恢复场景,精确性不重要)。
if (convId) {
state.queue = state.queue.filter(q => q.conversationId !== convId)
setConvCurrentText(convId, '')
} else {
state.currentText = ''
state.queue = []
}
// BUG-2026-07-17 根治:同时清理 convStates 项。
// 原实现仅清 streaming/currentText/queue,但按钮 stopBtnState 优先读 convStates
// (ChatInput.vue:243),convStates=generating 时仍显红色停止按钮。后端未发
+16 -2
View File
@@ -4,6 +4,7 @@
//! - 多处调 useAiEvents.notifyConversationChanged
//! - newConversation/switchConversation 写 appSettings 持久化活跃会话 id
import { ref } from 'vue'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
@@ -545,9 +546,22 @@ type ConvOpFailKey =
| 'renameConvFail' | 'archiveConvFail' | 'pinConvFail'
| 'newConvFail' | 'deleteConvFail' | 'switchConvFail'
/** M30:写后端 IPC 失败时推送错误气泡(对齐 loadConversations/switchConversation 的失败反馈),
* 保持侧栏已有交互不被无声吞掉(原仅向上抛,调用方 store 直调无 try → 异常进 unhandledrejection)。 */
/** UX走查 P2-7:rename/archive/pin 失败的侧栏就近通知(发布-订阅)。每次失败发布新对象,
* ConversationSidebar watch 到即弹 toast;本模块不感知展示细节(翻译/计时归订阅方)。 */
export const convOpFailNotice = ref<{
key: ConvOpFailKey
params?: Record<string, string | number>
} | null>(null)
/** M30:写后端 IPC 失败时推送反馈,保持侧栏已有交互不被无声吞掉(原仅向上抛,调用方 store
* 直调无 try → 异常进 unhandledrejection)。UX走查 P2-7 分流:rename/archive/pin 发起与
* 视线焦点都在侧栏,改发 convOpFailNotice 由侧栏弹就近 toast,不再推进聊天流(错误气泡
* 视线错位,且消息区被清空/切换后不可见);new/delete/switch 与聊天区强关联,仍走错误气泡。 */
function pushConvOpFail(key: ConvOpFailKey, params?: Record<string, string | number>) {
if (key === 'renameConvFail' || key === 'archiveConvFail' || key === 'pinConvFail') {
convOpFailNotice.value = { key, params }
return
}
state.messages.push({
id: `conv-op-fail-${nextMsgId()}`,
role: 'assistant',
+6 -2
View File
@@ -9,7 +9,7 @@ import { emit } from '@tauri-apps/api/event'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers'
import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals } from './aiShared'
import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, notifyQueueCleared } from './aiShared'
import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths } from './useAiPendingState'
import { clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
import { setStreaming } from './streamingGuard'
@@ -176,10 +176,14 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
case 'AiError': {
cleanupTerminatedConversation(event.conversation_id || '', 'AiError')
// 只清除出错会话的队列项,不误伤其他会话的排队消息
// 只清除出错会话的队列项,不误伤其他会话的排队消息;
// UX-P2-A2:清队从静默改 toast 提示条数(用户排队的消息无声消失原无任何反馈)
if (event.conversation_id) {
const dropped = state.queue.filter(q => q.conversationId === event.conversation_id).length
state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id)
notifyQueueCleared(dropped)
} else {
notifyQueueCleared(state.queue.length)
state.queue = []
}
state.messages.push({
+21 -4
View File
@@ -24,7 +24,16 @@ import { t } from '@/i18n/i18n-helpers'
import { resetStreamWatchdog, clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
import { setStreaming } from './streamingGuard'
import { startListener } from './useAiEvents'
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates, setConvCurrentText, setConvStreaming, flushCurrentText } from './aiShared'
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates, setConvCurrentText, setConvStreaming, flushCurrentText, notifyQueueCleared, classifySendError, type SendErrorKind } from './aiShared'
/// UX-P2-A5:drainQueue 失败气泡的错误分类文案映射(分类逻辑 aiShared.classifySendError,
/// 键复用 ChatInput 发送失败 toast 同一批 aiChat.sendErr* 文案,原始错误经 { msg } 细节保留)
const QUEUED_SEND_ERR_KEY: Record<SendErrorKind, string> = {
rate_limit: 'aiChat.sendErrRateLimit',
quota: 'aiChat.sendErrQuota',
network: 'aiChat.sendErrNetwork',
other: 'aiChat.toastSendFail',
}
/// 待发送队列上限(超过抛错提示用户)
const QUEUE_LIMIT = 10
@@ -163,9 +172,12 @@ async function regenerate() {
setStreaming(true, { convId: state.activeConversationId, reason: 'regenerate' })
state.currentText = ''
// 只清当前会话的排队消息,不误伤其他会话
// 只清当前会话的排队消息,不误伤其他会话;
// UX-P2-A2:清队从静默改 toast 提示条数(排队消息无声消失原无任何反馈)
const regenConvId = state.activeConversationId
const droppedQueued = state.queue.filter(q => q.conversationId === regenConvId).length
state.queue = state.queue.filter(q => q.conversationId !== regenConvId)
notifyQueueCleared(droppedQueued)
// 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback
resetStreamWatchdog(regenConvId || undefined)
@@ -236,9 +248,12 @@ async function editMessage(newMessage: string) {
setStreaming(true, { convId: state.activeConversationId, reason: 'editMessage' })
state.currentText = ''
// 只清当前会话的排队消息,不误伤其他会话
// 只清当前会话的排队消息,不误伤其他会话;
// UX-P2-A2:清队从静默改 toast 提示条数(排队消息无声消失原无任何反馈)
const editConvId = state.activeConversationId
const droppedQueued = state.queue.filter(q => q.conversationId === editConvId).length
state.queue = state.queue.filter(q => q.conversationId !== editConvId)
notifyQueueCleared(droppedQueued)
// 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback
resetStreamWatchdog(editConvId || undefined)
@@ -301,10 +316,12 @@ export function drainQueue(convId?: string | null) {
// 仅目标会话为当前视图才推错误气泡(后台会话续发失败不污染当前视图;错误消息留在队列
// 供切回后编辑/重试/取消)
if (targetId === state.activeConversationId) {
// UX-P2-A5:失败气泡接入 classifySendError 分类文案(对齐 ChatInput 发送失败 toast 四类),
// 替代原 raw 错误直出的 ai.queuedSendFailed;原始错误串经 { msg } 细节保留
state.messages.push({
id: `err-${nextMsgId()}`,
role: 'assistant',
content: t('ai.queuedSendFailed', { error: errMsg }),
content: t(QUEUED_SEND_ERR_KEY[classifySendError(errMsg)], { msg: errMsg }),
isError: true,
timestamp: Date.now(),
} as AiMessage)
+25 -4
View File
@@ -2,7 +2,7 @@ import { ref } from 'vue'
import Sortable from 'sortablejs'
import { useI18n } from 'vue-i18n'
import { taskApi } from '@/api/task'
import { TASK_STATUS_TRANSITIONS } from '@/constants/project'
import { TASK_STATUS_TRANSITIONS, taskStatusLabel } from '@/constants/project'
import { useToast } from '../useToast'
export function useTaskKanban(opts: {
@@ -15,6 +15,8 @@ export function useTaskKanban(opts: {
let s: Sortable[] = []
let _dragId: string | null = null
let _executing = false
// 最近一次被拒的流转方向:onMove 只记录不提示(避免拖拽悬停过程误报),onEnd 弹回时消费并清空
let _rejected: { from: string; to: string } | null = null
function destroy() { s.forEach(x => x.destroy()); s = [] }
@@ -30,12 +32,16 @@ export function useTaskKanban(opts: {
onMove: (e) => {
const from = (e.from as HTMLElement).dataset.status ?? ''
const to = ((e.to as HTMLElement).closest('[data-status]') as HTMLElement)?.dataset.status ?? ''
if (from === to) return true
return TASK_STATUS_TRANSITIONS[from]?.includes(to) ?? false
if (from === to) { _rejected = null; return true }
const legal = TASK_STATUS_TRANSITIONS[from]?.includes(to) ?? false
// UX走查 P2-5:非法流转只记录方向;合法流转清掉旧拒绝,避免陈旧标记污染后续 onEnd 判断
_rejected = legal ? null : { from, to }
return legal
},
onStart: (e) => {
_dragId = (e.item as HTMLElement).dataset.taskId ?? null
_executing = false
_rejected = null
},
onEnd: async (e) => {
if (_executing) return
@@ -44,7 +50,22 @@ export function useTaskKanban(opts: {
const to = ((e.to as HTMLElement).closest('[data-status]') as HTMLElement)?.dataset.status
const id = _dragId
_dragId = null
if (!to || !from || to === from) { _executing = false; return }
if (!to || !from || to === from) {
// UX走查 P2-5:非法流转被 onMove 拒绝时卡片弹回原列(落点 to===from),
// 此处补一条原因 toast,替代原先的静默弹回(原因与父卡 tooltip 同源:状态机流转表)
if (_rejected) {
showToast(
t('tasks.kanban.illegalMove', {
from: t(taskStatusLabel(_rejected.from)),
to: t(taskStatusLabel(_rejected.to)),
}),
'error',
)
_rejected = null
}
_executing = false
return
}
if (!id) { _executing = false; return }
kanbanBusy.value = true
try {
+2
View File
@@ -43,6 +43,8 @@ export default {
queueTimedOut: 'Queue timed out',
approvalNotDelivered: 'Approval operation failed to reach the backend: {error}',
queuedSendFailed: 'Failed to send queued message: {error}',
// UX-P2-A2: toast when queued messages get dropped by error/regenerate/edit (was silent)
queueCleared: 'Cleared {n} queued message(s)',
approvalTimeout: '⚰ Approval timed out, automatically rejected: {toolName}',
// conversation load/switch failure feedback (network jitter/IPC down → empty list/blank, was silent)
loadConvFail: 'Failed to load conversations. Please check your network or backend connection.',
+8
View File
@@ -16,6 +16,8 @@ export default {
unarchive: 'Unarchive',
pinConversation: 'Pin',
unpinConversation: 'Unpin',
// UX走查 P3-1/P2-6: rename entry in the "..." menu (search-mode parity + keyboard reach)
renameConversation: 'Rename',
moreActions: 'More actions',
archived: 'Archived',
archivedCount: 'Archived ({n})',
@@ -36,6 +38,8 @@ export default {
maximize: 'Maximize',
pendingApprovalCount: '{n} pending',
pendingApprovalHint: 'Click to jump to the first pending approval',
// UX walkthrough B1: approval card arrived while user scrolled up; no forced scroll, hint only (5s dedup in ToolCardList)
newApprovalPending: 'New approval pending',
restoreSidebar: 'Restore sidebar',
sidebarResizeHint: 'Drag to resize sidebar',
detachWindow: 'Detach window',
@@ -60,6 +64,8 @@ export default {
// ── Provider status ──
clickToSwitchProvider: 'Click to switch provider',
// UX walkthrough B5: topbar switching shares the same IPC as Settings "set as default" (ai_set_provider rewrites is_default), label it honestly
providerSwitchSetsDefault: 'Click to switch provider (also sets it as the global default)',
providerNotConfigured: 'No AI provider configured. Please add one in settings.',
notConfigured: 'Not configured',
@@ -164,6 +170,8 @@ export default {
editMessage: 'Edit',
editMessageFailed: 'Edit failed: {msg}',
editNotLastUser: 'Only the last user message can be edited',
// UX walkthrough B3: editMessage IPC takes no parts; editing would silently drop images, block at entry
editImageBlocked: 'This message contains images and cannot be edited (regenerating would lose them)',
placeholderEditing: 'Edit message then Enter to regenerate (Esc to cancel)',
// ── Error bubble actions (UX-03 / ) ──
+4
View File
@@ -54,6 +54,10 @@ export default {
phProviderName: 'e.g. DeepSeek, GLM-4',
phBaseUrl: 'https://api.deepseek.com',
phApiKey: 'sk-...',
// UX-P2 walkthrough: API key show/hide toggle + "leave empty = keep" note
apiKeyShow: 'Show',
apiKeyHide: 'Hide',
apiKeyKeepHint: 'Leave empty while editing to keep the saved API key unchanged',
phDefaultModel: 'deepseek-chat',
// Direction A (lightweight): defaultModel is only a "suggested starting point";
// guide users to hit "Test connection" to pull the vendor's real model list and overwrite,
+2
View File
@@ -76,6 +76,8 @@ export default {
addSubtask: '+ Add Subtask',
progressTitle: 'Progress',
addSubtaskFailed: 'Failed to create subtask: {msg}',
// Subtask created (UX review P2-3): previously reused the batch-advance wording "Advanced N tasks"
subtaskCreated: 'Subtask created',
// P2-9 subtask hint
subtaskHint: 'This is a subtask of: {title}',
// P2-7 subtask creation validation
+2
View File
@@ -85,6 +85,8 @@ export default {
emptyColumn: 'No tasks',
dragHandle: 'Drag to move',
parentNoDrag: 'Parent status is aggregated from subtasks and cannot be dragged',
// Illegal transition rejected on drag (UX review P2-5): toast the reason when the card bounces back
illegalMove: 'Cannot move: transition from "{from}" to "{to}" is not allowed',
},
// New task modal
+2
View File
@@ -43,6 +43,8 @@ export default {
queueTimedOut: '排队超时',
approvalNotDelivered: '审批操作未送达后端:{error}',
queuedSendFailed: '队列消息发送失败:{error}',
// UX-P2-A2:出错/重生成/编辑清排队消息的 toast 提示(原静默丢弃无任何反馈)
queueCleared: '已清 {n} 条排队消息',
approvalTimeout: '⚰ 审批超时,已自动拒绝:{toolName}',
// 对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈)
loadConvFail: '对话列表加载失败,请检查网络或后端连接',
+8
View File
@@ -16,6 +16,8 @@ export default {
unarchive: '取消归档',
pinConversation: '置顶',
unpinConversation: '取消置顶',
// UX走查 P3-1/P2-6: 会话「...」菜单重命名入口(搜索态无双击降级补齐 + 键盘可达的重命名路径)
renameConversation: '重命名',
moreActions: '更多操作',
archived: '已归档',
archivedCount: '已归档 ({n})',
@@ -36,6 +38,8 @@ export default {
maximize: '最大化',
pendingApprovalCount: '{n} 项待审批',
pendingApprovalHint: '点击跳转到第一个待审批项',
// UX 走查 B1:审批卡到达但用户上滚读历史,不强拉滚动,只出轻提示(ToolCardList 5s 去重)
newApprovalPending: '有新的审批待处理',
restoreSidebar: '还原侧栏',
sidebarResizeHint: '拖拽调整侧栏宽度',
detachWindow: '分离窗口',
@@ -60,6 +64,8 @@ export default {
// ── Provider 状态 ──
clickToSwitchProvider: '点击切换 Provider',
// UX 走查 B5:顶栏切换与设置页「设为默认」同一条 IPC(ai_set_provider 互斥写 is_default),文案如实标注
providerSwitchSetsDefault: '点击切换 Provider(切换即设为全局默认)',
providerNotConfigured: '未配置 AI 提供商,请在设置中添加',
notConfigured: '未配置',
@@ -165,6 +171,8 @@ export default {
editMessage: '编辑',
editMessageFailed: '编辑失败:{msg}',
editNotLastUser: '只能编辑最后一条用户消息',
// UX 走查 B3:editMessage IPC 不接 parts,编辑重发会静默丢图,入口禁止并提示
editImageBlocked: '该消息包含图片,暂不支持编辑(重新生成会丢失图片)',
placeholderEditing: '编辑消息后回车重新生成(Esc 取消)',
// ── 错误气泡操作入口(UX-03 / ) ──
+4
View File
@@ -54,6 +54,10 @@ export default {
phProviderName: '如 DeepSeek、GLM-4',
phBaseUrl: 'https://api.deepseek.com',
phApiKey: 'sk-...',
// 走查 P3-9:密钥显隐切换 + 编辑态「留空=不改」说明
apiKeyShow: '显示',
apiKeyHide: '隐藏',
apiKeyKeepHint: '编辑时留空表示沿用已保存的密钥,不会清空',
phDefaultModel: 'deepseek-chat',
// 方向 A(轻量):defaultModel 仅作"建议起点",引导用户点「测试连接」拉真实模型列表覆盖,
// 避免预设 model id 过时(DeepSeek chat 停用 / Gemini 2.5→3.5 等)误导。
+2
View File
@@ -76,6 +76,8 @@ export default {
addSubtask: '+ 添加子任务',
progressTitle: '完成进度',
addSubtaskFailed: '创建子任务失败: {msg}',
// 子任务创建成功(UX走查 P2-3):此前误复用批量推进文案「已推进 N 个任务」
subtaskCreated: '子任务已创建',
// P2-9 子任务提示
subtaskHint: '该任务为子任务,隶属于:{title}',
// P2-7 子任务创建校验
+2
View File
@@ -85,6 +85,8 @@ export default {
emptyColumn: '暂无任务',
dragHandle: '拖动以移动',
parentNoDrag: '父任务状态由子任务聚合,不能直接拖动',
// 拖拽非法流转被拒(UX走查 P2-5):卡片弹回时 toast 说明原因,不再静默
illegalMove: '无法移动:状态不允许从「{from}」流转到「{to}」',
},
// 新建任务模态
+23 -1
View File
@@ -82,6 +82,7 @@
<script setup lang="ts">
import { computed, ref, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useConfirm } from '@/composables/useConfirm'
import { useToast } from '@/composables/useToast'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
@@ -122,9 +123,30 @@ const connectionPanelRef = shallowRef<InstanceType<typeof ConnectionPanel> | nul
// 用 computed 包 useSetting,双向绑 SettingsNav v-model
// ============================================================
const appSettings = useAppSettingsStore()
// 走查 P2-7:ProviderPanel 已暴露 isFormDirty 但壳未接入,点左栏任一分类/离开路由
// 都会 v-else-if 卸载面板,未保存改动无确认直接销毁。两条出口(切分类/路由离开)
// 统一走本守卫:脏则二次确认,拒绝即原地不动(SettingsNav 是受控组件,不落值高亮自动回弹)。
// 复用 ProviderPanel 自身的 confirmDiscardUnsaved 文案,语义与表单「取消」一致。
async function confirmLeaveIfProviderDirty(): Promise<boolean> {
if (!providerPanelRef.value?.isFormDirty) return true
return await confirmDialog(ti18n('settings.confirmDiscardUnsaved'))
}
const activeCategory = computed<SettingsCategory>({
get: () => appSettings.get<SettingsCategory>('df-settings-active-category', 'appearance'),
set: (v) => { void appSettings.set('df-settings-active-category', v) },
set: (v) => {
// 点当前分类是无效切换,不弹确认(脏表单下误点自身不应被质询)
if (v === activeCategory.value) return
void confirmLeaveIfProviderDirty().then((ok) => {
if (ok) void appSettings.set('df-settings-active-category', v)
})
},
})
// 路由离开(回主页等)同样拦截:Settings 是路由组件,返回 false 阻断本次导航
onBeforeRouteLeave(async () => {
return await confirmLeaveIfProviderDirty()
})
// AI Prompt 缓存总开关(KV df-ai-anthropic-cache,默认关,与后端 prompt.rs cache_enabled 同键;
+27 -2
View File
@@ -52,7 +52,10 @@
<option value="status">{{ $t('tasks.sort.status') }}</option>
</select>
</div>
<span class="filter-count">{{ $t('common.pagination.total', { n: store.tasks.length }) }}</span>
<!-- UX走查 P2-2: 计数对齐可见行口径——列表模式按状态筛选后的可见行数(batchScopeIds,
含折叠父的子任务),看板模式各状态列恒全显仍取全量;原固定 store.tasks.length,
勾选状态芯片后计数不变与可见行数矛盾 -->
<span class="filter-count">{{ $t('common.pagination.total', { n: viewMode === 'list' ? batchScopeIds.length : store.tasks.length }) }}</span>
<!-- 列表/看板切换(持久化) -->
<div class="filter-group view-toggle">
<button class="seg-btn" :class="{ 'is-active': viewMode === 'list' }" @click="viewMode = 'list'">{{ $t('tasks.kanban.toggleList') }}</button>
@@ -271,8 +274,11 @@
<div
class="kanban-board"
ref="kanbanBoardRef"
:class="{ 'is-busy': kanban.kanbanBusy, 'is-hidden': viewMode !== 'kanban' }"
:class="{ 'is-busy': kanban.kanbanBusy, 'is-loading': viewMode === 'kanban' && loading, 'is-hidden': viewMode !== 'kanban' }"
>
<!-- 看板加载指示(UX走查 P2-4):看板常驻渲染,切项目/搜索期间旧筛选卡片仍在展示,
轻提示 + 卡片降透明度避免误读为最新数据;局部于看板容器,非全局遮罩,文案对齐列表模式 -->
<div v-if="viewMode === 'kanban' && loading" class="kanban-loading">{{ $t('common.loading') }}</div>
<div v-for="col in kanbanColumns" :key="col.status" class="kanban-col" :data-status="col.status">
<div class="kanban-col-header">
<span class="kanban-col-name">{{ $t(statusLabel(col.status)) }}</span>
@@ -1000,7 +1006,26 @@ onUnmounted(() => {
align-items: flex-start;
overflow-x: auto;
padding-bottom: 8px;
position: relative; /* 承载局部加载浮标(UX走查 P2-4) */
}
/* 加载浮标:局部于看板容器的轻提示,压在列上方居中,不阻断列布局;pointer-events none 不挡拖拽热区 */
.kanban-loading {
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
z-index: 5;
padding: 3px 14px;
border-radius: 999px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
color: var(--df-text-dim);
font-size: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
pointer-events: none;
}
/* 加载期间旧筛选卡片降透明度+禁交互,避免被误读为当前筛选的最新结果 */
.kanban-board.is-loading .task-card { opacity: 0.45; pointer-events: none; }
.kanban-board.is-busy .task-card { pointer-events: none; }
.kanban-board.is-hidden { display: none; }
.kanban-col {
@@ -0,0 +1,81 @@
# S2 子代理审批语义勘察(只读,2026-10-03)
> 结论先行:任务前提不成立。sub_agent.rs grep approval 零命中≠语义空白——审批以
> 「无通道→一律拒绝(fail-closed)」实现,命名是 deny 非 approval。**非安全债**。
## 1. ToolExecPolicy::SubAgent 分流实况
`policy` 入参 → `is_sub_agent = matches!(policy, ToolExecPolicy::SubAgent)`
(src-tauri/src/commands/ai/audit/mod.rs:474-477;策略文档 mod.rs:100-112)
| 路径 | SubAgent 行为 | 证据 |
|---|---|---|
| spawn 入口白名单 | 非 Low 工具根本不进子 tool_defs,恒剔 spawn_agent | sub_agent.rs:210-251 |
| 路径授权 NeedsAuth | 就地拒绝(sub_agent_path_denied) | mod.rs:618-622 |
| 路径黑名单 Denied | 就地拒绝(同上收集) | mod.rs:633-636, 676-682 |
| Medium/High 风险 | 就地拒绝,**在 auto_exec_mode 判定之前,all 档也不放行** | mod.rs:795-803 |
| Low + 已授权 | 正常并行执行(与主会话同路径,含缓存/去重/信任) | mod.rs:774, 972+ |
拒绝形态 = `deny_sub_agent_tool` 单点(mod.rs:117-148):结构化 envelope
`{status:rejected, reason:sub_agent_denied}` + push tool_result + emit
AiToolCallCompleted + 审计 decided_by=sub_agent_denied。
**挂起对子会话实际不能工作**:SubAgent 策略下 pending 恒 0(不 insert);
子循环断言 pending>0 即 Failed 终止(sub_agent.rs:577-587)。即便 insert 也无
人消费——try_continue_agent_loop 是主 loop 恢复器(agentic/mod.rs:1888),
子 loop 是 spawn_agent handler 内同步 for-round 循环,且子会话跑完即从
per_conv 移除(sub_agent.rs:348),无任何恢复入口。
## 2. 若复用主会话审批链路到子会话:缺口清单
- 事件路由:AiApprovalRequired 带 conversation_id,前端 per-conv 幂等收集
(useAiToolEvents.ts:60-87)本可收;但子会话不在侧栏(mod.rs:235 注释
「挂父不挂子」),审批卡挂 child conv 下无处展示。
- 批准续跑:ai_approve 批准后调 try_continue_agent_loop(conv_id)
(commands/ai/commands/chat.rs:1051)恢复主 loop;子 loop 无对应机制——
deadline/tokens/fail_window/stall 计数全在 run_sub_agent_loop_inner 栈上,
挂起即栈销毁,resume 需整套状态快照/重建。
- 拒绝终止:主会话拒绝=replace tool_result+emit AiApprovalResult
(chat.rs:1004-1034);子会话侧缺 ① 不 emit AiAgentFinished 终态卡
(sub_agent.rs:320 仅单一出口处发)② 不落库子轨迹(:342)③ 不清
per_conv 条目(:348)④ payload 固定 user_denied 无子代理语义。
- 超时:arm_approval_timeout_timers 扫 pending 表可覆盖子条目
(mod.rs:826),但超时收尾不含子循环 truncated 终态,会留孤儿。
## 3. 现状语义定性
**默认拒绝(fail-closed)**:入口钳制 + 运行期就地处决双层,零挂起零放行,
Med/High 连 all 档都拒。风险评级:**非安全债**;真正可议是产品语义债——
子代理遇需审批操作只能拒绝并提示转交主会话(envelope 文案 mod.rs:130),
无「上报父会话审批」通道。
## 4. S2 最小方案骨架
**方案A(推荐,颗粒=单代理一轮)**:维持默认拒绝,增强转交闭环。
- audit/mod.rs:deny envelope 增结构化字段 tool/args/reason(现仅 message 文案)。
- sub_agent.rs:报告协议(system_prompt 段)要求列「被拒操作清单」→ 父会话
收报告后由主 loop 决定是否发起审批。
- 前端无需改。
**方案B(真审批通道,超单轮,拆 3 批)**:
1. 挂起路径:SubAgent 下 Med/High 不 deny 改 insert pending(kind 新增
SubAgentRisk{parent_conv_id})+ emit AiApprovalRequired(conversation_id
填父 conv 复用现有弹窗);子循环 pending>0 改返新状态 AwaitingApproval,
栈上状态快照存 per_conv 新字段(conv_state.rs),不删条目。
2. resume 通道:ai_approve 识别 kind==SubAgentRisk → 批准则调新函数
resume_sub_agent_loop(从快照重建上下文续 for-round);拒绝则替换
tool_result+emit AiAgentFinished(truncated)+落库+清条目。
3. 收尾:approval_timeout 超时终态补子循环 truncated 收尾;前端 agent 卡
消费 AiApprovalResult。
- 涉及文件:audit/mod.rs、agentic/sub_agent.rs、commands/ai/commands/chat.rs、
agentic/conv_state.rs、src/composables/ai/useAiToolEvents.ts、src/api/types.ts。
- 拆批理由:子循环暂停/恢复状态快照(deadline/tokens/fail_window/stall 等
7+ 项)是硬骨头,与事件路由、前端消费耦合度低,可独立验收。
## 关键文件索引
- E:\wk-lab\devflow\src-tauri\src\commands\ai\audit\mod.rs(策略分流+deny 单点)
- E:\wk-lab\devflow\src-tauri\src\commands\ai\agentic\sub_agent.rs(白名单钳制+子循环+单一出口)
- E:\wk-lab\devflow\src-tauri\src\commands\ai\commands\chat.rs(ai_approve/ai_authorize_dir)
- E:\wk-lab\devflow\src-tauri\src\commands\ai\agentic\helpers.rs(try_continue_agent_loop)
- E:\wk-lab\devflow\src\composables\ai\useAiToolEvents.ts(前端审批事件消费)
@@ -0,0 +1,48 @@
# S4 TaskDrawer 查看会话跳转 — 只读勘察
## 1. 关联数据现状:无现成映射,audit 表可推导(零 schema 变更)
- spawn_agent 工具:src-tauri/src/commands/ai/tools/spawn_agent.rs(声明式注册 L164,handler L195)
- 父会话经 task-local CALLER_CONV 注入;子会话 id = `{parent}::agent-{n}`(agentic/sub_agent.rs:215 next_child_id,父 id 内嵌可反推)
- 子代理整轨迹落 ai_conversations,V44 origin='sub_agent' 标记(sub_agent.rs:76,452;侧栏/miniapp 按 origin 过滤,commands/conversation.rs:233)
- **推导主链 = ai_tool_executions 审计表**(V9 建,migrations.rs:1576):每笔工具执行记
conversation_id(发起会话) + tool_name + result(audit/record.rs:31 build_audit_record)
- create_task result = `{"id": task_id, "title", "status"}`(tools/task.rs:128)
- update/advance_task result = `{"id": task_id, "field", "updated"}`(task.rs:167)
- spawn_agent result 含 agent_id(即子会话 id,spawn_agent.rs:299)
- project_events.conversation_id 列存在(models.rs:173 溯源链设计)但 IPC human 路径恒 None,不可依赖
- **最小落点**:新增只读 IPC,从 ai_tool_executions 查 `tool_name IN ('create_task','update_task','advance_task')`
且 result JSON 解析后 id 字段 == task_id(精确解析,勿 LIKE 防误命中);不需要 spawn 时写映射表
## 2. TaskDrawer 现状:src/components/task/TaskDrawer.vue
- Arco Drawer 640px,props { taskId, visible },仅 Tasks.vue:390 挂载
- 数据流:watch(taskId) → load()(L246)= taskApi.get + projectApi.list + ideaApi.list(防慢响应覆盖 L257)
- **挂点:「关联信息」panel L46-83**——已有 parent/project/idea 三个 info-item + router-link 先例,
加「关联会话」info-item(v-if 有关联)最贴现状,零新 section
## 3. 会话跳转机制:无路由,面板 state + switchConversation
- AI 非路由视图:主窗口面板(state.panelOpen,v-show)或分离窗口(state.detached)
- **跳转 = `state.panelOpen = true`(useAiPanel persistUiState)+ `switchConversation(id)`**
- switchConversation(useAiConversations.ts:275)是唯一正规入口:aiApi.switchConversation → parse →
activeConversationId → 持久化 df-ai-active-conv;自带"对话不存在"新建兜底/瞬态失败气泡/过期丢弃
- 既有先例:无跨视图跳会话(仅侧栏点击 ConversationSidebar.vue:353);panelOpen=true 写点全在
useAiPanel/useAiWindow 内部 → S4 需新增入口函数
- 坑:子会话(origin 非空)不在侧栏列表,switchConversation 数据面可用但侧栏不高亮;v1 建议只列主会话
- detached 状态主窗口无面板:openConversation 里判 state.detached 时仅 switchConversation 不动面板
## 4. S4 实施骨架(单代理一轮可完成,5 文件小改)
1. 后端 IPC `list_task_conversations(task_id) -> Vec<{conversation_id, tool_name, requested_at}>`:
放 src-tauri/src/commands/ai/audit/record.rs(与 list_tool_executions:207 同文件,复用 repo 读);
返回去重按 requested_at 倒序
2. 注册:src-tauri/src/lib.rs invoke_handler(list_tool_executions 在 L475,紧邻追加)
3. 前端 API:src/api/ai.ts 加 invoke 包装
4. 跳转入口:src/composables/ai/useAiConversations.ts export `openConversation(id)`:
if (!state.detached) { state.panelOpen = true; persistUiState() } + await switchConversation(id)
5. TaskDrawer.vue 关联信息 panel 加 info-item:load() 并行加 listTaskConversations,点击 router 语义改为
openConversation(非 router-link)
6. i18n:taskDetail.relatedConversations(zh/en,src/i18n)
- 颗粒评估:后端 1+2 一体、前端 3-6 机械接线,单代理一轮内自证完成;唯一语义决策=result 精确解析
与 detached 分支,已在骨架内定死
@@ -0,0 +1,66 @@
# UX 走查 — 会话管理与任务/项目管理域(2026-10-03)
> 只读勘察,零代码改动。范围:ConversationSidebar / AppSidebar 集成 / Tasks / TaskDrawer / TaskBatchBar / GitChanges / ProjectCard + 相关 store/composable。
> 已规避防重复清单:审批徽标、12 截图样式、UX token、removeGoal 回写、任务列表重复渲染+全选框裁切(shot-3)、标题截断(shot-4)。
> 排序:严重度 P1 → P2 → P3。
---
## P1(3 条)
### P1-1 新建任务失败完全静默:错误条被模态遮罩盖住,弹窗不关无任何反馈
- 位置:`src/views/Tasks.vue:827-848`(confirmCreate)+ `src/styles/global.css:201-208`
- 链路:store.createTask 失败 → runWithCatch 置 state.error(`src/stores/project/tasks.ts:42`)→ `if (!r) return` 弹窗保持打开。错误横幅渲染在页面流内(z-index:auto),而 `.modal-overlay` 是 `position:fixed; inset:0; z-index:100` 全屏遮罩 → 横幅在遮罩之下不可见。
- 用户视角:点「确认」→ 按钮短暂 loading → 恢复,无任何提示,像"点了没反应";只有关掉弹窗才能看到错误条。
- 修向:失败时在 modal 内联展示错误(或失败 toast),不要只依赖页面级 error-banner。
### P1-2 会话切换无即时反馈与加载态,快速连点像失灵
- 位置:`src/components/ai/ConversationSidebar.vue:42,107`(高亮绑 `activeConversationId`)+ `src/composables/ai/useAiConversations.ts:275-360`
- 链路:switchConversation 在 IPC 返回且 parse 成功后才写 `state.activeConversationId`(L360,注释明言防错配)。期间:侧栏点击项不高亮、原高亮不动、消息区无 loading 指示(AiChat 全文只有 isCompressing 一个加载态)。大历史会话 IPC 数百 ms 起,用户连点多个会话时全程无视觉响应,直到最后一个完成瞬间跳变。
- 竞态本身已由 `_latestSwitchId` token + 切换窗口缓冲治理(良好),缺的是**进行时反馈**。
- 修向:点击即挂 pending 态(半透明/spinner 于目标项),或消息区顶部 loading 条。
### P1-3 状态多选筛选 + 数据变更联动刷新:查询语义冲突致列表被冲空
- 位置:`src/views/Tasks.vue:795-807`(buildTaskQuery 不传 status)+ `src/stores/project.ts:70-80`(buildActiveTaskQuery 把 status 下沉后端)+ `Tasks.vue:858`(setActiveTaskStatus 传逗号拼接多选)
- 链路:视图内刷新走 buildTaskQuery(status 纯前端过滤);但 df-data-changed 事件联动刷新走 buildActiveTaskQuery,把 `status="todo,done"` 这样的**多选拼接串**传给后端(注释自证"后端 status 单值")→ 后端按单值精确匹配返回空集 → 用户勾着状态芯片时,任何外部任务变更(AI 工具建任务/他视图推进)都让可见行突然清空,与筛选芯片状态直接矛盾。
- 修向:buildActiveTaskQuery 不传 status(对齐视图口径),或后端支持多值。
## P2(7 条)
### P2-1 状态芯片点击触发冗余服务端重载 + 整列表闪屏
- `Tasks.vue:857-861`:watch(activeStatuses) 调 loadTasks(buildTaskQuery()),但 status 不进 query → 拉回完全相同数据;`loading=true` 时列表区整个替换为一行"加载中"文本(L75,无 spinner)。纯客户端筛选却付出全量重载+闪屏代价。
### P2-2 筛选计数与可见行数不符
- `Tasks.vue:55`:`filter-count` 显示 `store.tasks.length`(未过前端 status 过滤);勾选状态芯片后计数不变,与实际可见行数矛盾(同 P1-3 的双口径根因相邻)。
### P2-3 TaskDrawer 新建子任务成功提示文案错位
- `src/components/task/TaskDrawer.vue:326`:子任务创建成功 toast 复用 `tasks.batch.advanceResult`("已推进 1 个任务"),语义误导——用户刚做的是"新建"不是"推进"。
### P2-4 看板模式无加载态,展示陈旧筛选数据
- `Tasks.vue:270-302`:loading 分支只存在于列表模式(.task-groups 内 L75);看板切项目/搜索期间继续渲染上一筛选的任务卡,无任何"加载中"指示,新旧数据瞬间替换易误读。
### P2-5 看板拖拽非法流转静默弹回,无原因反馈
- `src/composables/task/useTaskKanban.ts:30-34`:onMove 返回 false 卡片弹回;仅父卡有 title tooltip(tasks.kanban.parentNoDrag),非父卡因状态机不允许(如 done→todo 之外的路径)被拒时无 toast/提示,用户不知道"为什么拖不过去"。
### P2-6 键盘可用性:两域列表项全部不可键盘达
- 会话项/时间分组头(ConversationSidebar L88,102)、任务行/分组头(Tasks L88,98)、看板卡、GitChanges 文件行/提交行、ProjectCard 整卡均为 div+@click,无 tabindex/role/keydown;会话项「...」操作菜单 hover 才 display:flex(CSS L730-738),键盘用户永久够不到置顶/归档/删除。批量栏、快捷菜单、会话菜单均无 Esc 关闭(仅外部点击)。
### P2-7 会话操作失败反馈位置错配
- `useAiConversations.ts:550-559`(pushConvOpFail):rename/archive/pin 失败以错误气泡推进**聊天消息流**,而操作发生在侧栏——视线焦点不在聊天区;若消息区被清空/切换后气泡也可能看不到。侧栏内联轻提示更对位。
## P3(7 条)
- **P3-1** 搜索态功能降级:搜索结果平铺列表不支持双击重命名(ConversationSidebar L36-82 无 startRename 分支);搜索框无 Esc 清空(Tasks 搜索框有 `@keyup.esc`,不一致)。
- **P3-2** 首次进入"暂无会话"误导:`conversations.length===0` 即渲染空态(L214),loadConversations IPC 未返回前无 loading 区分,一闪而过的假空态。
- **P3-3** quickDelete 异常路径吞错:`Tasks.vue:536` catch 仅 console.error(正常失败走 deleteTask→banner,但 throw 型异常无用户反馈)。
- **P3-4** GitChanges 点击文件双 IPC:组件内 getModuleFileDiff(L457)与 FilePreview 自身加载各拉一次 diff;`diffContent/diffLoading` 为死状态(模板未消费),最终以第二次 emit 覆盖。
- **P3-5** 批量推进"无共同目标"提示永不可见:TaskBatchBar L11 按钮 disabled 于 advanceTargets 为空,L15 的 tbb-dropdown-empty 分支永远渲染不出,用户不知按钮为何灰。
- **P3-6** ProjectCard 工程数静默 0 起显:moduleCount 异步拉取无 loading/骨架,先显 0 再跳变;descOverflow 不随窗口 resize 重测,展开按钮显隐可能失准(ProjectCard L91-100)。
- **P3-7** 看板同列拖动后 DOM 顺序与数据排序短暂不一致:onEnd 对 to===from 直接 return 不刷新(useTaskKanban L47),卡片停留位置与 store 排序脱节直至下次刷新。
## 核查良好(不登记)
- 列表竞态:loadTasks seq 守卫(tasks.ts:11)、GitChanges statusReqSeq 双校验、TaskDrawer tid 校验、switchConversation token+切换窗口缓冲——四处竞态防护完整。
- 危险操作确认:会话删除(活跃会话独立强文案)/任务删除(含级联子任务数)/批量删除/项目删除均有 ConfirmDialog,无过度确认(新建会话不确认符合真并发决策)。
- 批量边界:0 选中隐藏批量栏、全选覆盖可见范围、scope 变化 prune 幽灵选中、busy 统一禁用、选中父+子去重级联、批量结果 toast 分 success/warning/error 三态。
- 空态/错误态:GitChanges 骨架屏+主区错误/行内错误分离、看板空列虚线框、Tasks 搜索空态带新建入口,覆盖良好。
@@ -0,0 +1,54 @@
# UX 走查 — AI 对话交互域(2026-10-03,只读勘察零改动)
范围:AiChat.vue / ai/ChatInput.vue / MessageList.vue / MessageItem.vue / ApprovalOverlay / ToolCardList.vue / composables/ai/*(send/events/stream/streamRenderer/messageScroll/conversations/approval/lifecycle/pendingState)。
已排除防重复清单(草稿 per-conv、错误分类文案、队列回填、排队时钟、代码块复制、长回复折叠带量、审批徽标、pendingApprovals per-conv、看门狗 per-conv、forceConverge 守卫、removeGoal、streamingGuard 全局清 queue)。
## P1(伤可用)
1. **中文输入法组词中按 Enter 直接把半截消息发送**
- 现象:拼音/双拼组词未上屏时按 Enter 选词,消息以未完成文本发出(keydown 无 isComposing 守卫;全仓 grep 零命中)。ESC 同理:组词取消会连带清技能 chip/关浮层。
- 复现:切中文输入法 → 输入拼音 → 按 Enter → 消息发出。
- 位置:src/components/ai/ChatInput.vue:745(`if (e.key === 'Enter' && !e.shiftKey)`);同型:AiChat.vue:87 队列编辑 input、ConversationSidebar 重命名 input。
- 修法:handleKeydown 顶部加 `if (e.isComposing || e.keyCode === 229) return`。
2. **分离窗口按 Esc 意图关浮层/取消重命名,却把整个窗口关了**
- 现象:detached 模式 window 级 keydown(AiChat.vue:362-376)与输入框 Esc 消费者(关技能浮层/mention/取消 chip,ChatInput.vue:681-697;侧栏重命名)同一事件并行执行,内层不 stopPropagation → 按一次 Esc 浮层关了、窗口也进入关闭流程(输入框空时直接关窗)。
- 复现:分离窗口 → 输入 `/` 拉起技能浮层 → 按 Esc → 浮层关 + 窗口关闭。
- 位置:src/components/AiChat.vue:368-372;src/components/ai/ChatInput.vue:687-697。
- 修法:window Esc 处理器检查 e.target 是否在输入框/浮层/重命名 input 内,是则跳过关窗。
3. **出过错(AiError)的会话里,鼠标用户点不了发送**
- 现象:cleanupTerminatedConversation 对每次 AiError 写 conv_state='error'(useAiLifecycleEvents.ts:77),stopBtnState 遇 'error' 返回 'retry'(ChatInput.vue:296-297)→ 发送按钮被「停止失败重试」按钮顶替,且该按钮点击调 stopChat(对已死 loop 无意义)而非发送/重生成;无事件再到来前按钮不复位,Enter 可发但按钮语义错位。
- 复现:断网发消息 → 出错误红卡 → 观察输入区右下角按钮已不是发送。
- 位置:src/components/ai/ChatInput.vue:65-72、292-299;src/composables/ai/useAiLifecycleEvents.ts:76-79。
- 修法:'error' 态不占发送按钮位(改角标提示),或该按钮改为触发重试发送。
## P2(明显别扭)
4. **排队超时 30s 的「强制发送」按钮基本不出现**:queueTimedOut computed(AiChat.vue:411 → useAiSend.ts:376-381)只依赖 reactive queue + activeId,Date.now() 非响应式且未读 nowTick → 纯等待期永不重算,超时横幅/强制按钮延迟到下一次无关 store 变更才可能出现。修法:computed 内读 nowTick 建立每秒依赖。
5. **错误/重生成/编辑会静默丢弃该会话排队消息**:AiError 清队(useAiLifecycleEvents.ts:180-184)、regenerate/editMessage 清队(useAiSend.ts:168、241)均无 toast、不回填输入框,用户排队的消息无声消失。修法:丢弃前回填输入框或推提示气泡。
6. **审批超时自动拒绝的错误气泡跨会话串视图**:aiShared.startApprovalTimer 到点 `getMessages().push(...)`(aiShared.ts:178-184)无 convId 归属判断——后台会话 A 的审批超时,错误气泡落在当前正查看的会话 B 里。修法:按审批所属 conv 过滤,仅 active 时推视图。
7. **审批卡到达强制 scrollIntoView,强拉用户回底部**:ai-pending-arrived 广播(useAiToolEvents.ts:85 / useAiStreamingEvents.ts:121)→ ToolCardList.vue:310-317 无条件 nextTick 滚动,不看 isFollowingBottom——用户上滚读历史时被拽到审批卡。修法:非跟随底部时不自动滚,仅靠徽标提示。
8. **两个等长消息数的会话互切不回底**:MessageList.vue:226 `watch(messages.length, onContentChange)` 等长不触发,切会话后 scrollTop 残留旧位置(可能停在 B 会话中段)。修法:activeConversationId 切换时显式 scrollToBottom。
9. **编辑带图/带 mention 的末条 user 消息,图片静默丢失**:startEditFocus 只回填 content(ChatInput.vue:881-893),editMessage IPC 不带 parts/spans(useAiSend.ts:255)→ 重发请求无图、重载后图消失、chips 降级纯文本,无任何提示。修法:入口检测 parts/spans 非空时提示,或链路透传。
## P3(打磨)
10. 编辑态输入清空按 Enter 无任何反馈,静默卡在编辑态 — ChatInput.vue:762-763。修法:空文本时退出编辑态或 toast。
11. Ctrl+R 末条非 assistant 时 store.regenerate() 未 catch → unhandled rejection,无用户反馈 — AiChat.vue:335。修法:对齐 handleRetry 的 catch+toast。
12. 切会话不重置 editingQueueIdx/editingQueueText — AiChat.vue:291 watch 只取消消息编辑 → 编辑态错挂到新会话同下标队列项。修法:同 watch 内 cancelEditQueued。
13. 技能联想无匹配时 Enter 把 "/xxx" 原文当普通消息发出 — ChatInput.vue:699 以 `filteredSkills.length` 为门。修法:skillOpen 时 Enter 不落发送(提示无匹配)。
14. 队列回填直接覆盖输入框现有未发送草稿不备份 — AiChat.vue:487-495 fillDraft。修法:回填前把现有输入并入该会话草稿。
15. 在已插入 mention chip 之前的文本里增删字符,全部 span 偏移失效(渲染守卫降级纯文本),无重算/平移机制 — ChatInput.vue:397/792。修法:input 事件按光标前增量平移 spans。
16. 长回复生成完成瞬间自动折叠(>1500 字符)致跟随态视口上跳,刚流完的答案要再点一下才能读完 — MessageList.vue:774-780。修法:本轮刚完成的末条延迟折叠(仅历史加载/切会话后折叠)。
## 已核对无问题的点
- 流式 delta 上滚不强拉回(useMessageScroll 跟随意图锁存正常);回底按钮/边沿收起逻辑正常。
- 审批浮窗 focus:false 不抢焦点、dismiss 60s 冷却有响应式时钟(ApprovalOverlay L26 已修)。
- 切会话清 mention/pendingImages/pendingSkill、队列操作下标经 activeQueueRealIndex 映射,均无串扰。
- 图片粘贴/拖拽/选图的数量(6)与大小(10MB)边界及上传失败回退 base64 完整。
@@ -0,0 +1,96 @@
# UX 走查 — 设置域与全局交互 (2026-10-03)
> 只读勘察,零代码改动。路径均相对 `src/`。已排除:UX token 化、settings.css 三防线、上传端点纵排、信任目录文案重叠、Ctrl+I 徽章、9-28#17 图片上传外域。
> 复现栏仅写关键步骤;修法一句话供派工参考。
## P1 (2)
### 1. Escape 在分离窗口任意位置按下即关闭整窗(含输入中)
- 现象:ai-detached 分离窗口内,按 Esc(想关技能联想浮层/取消编辑/仅 blur)→ 整个分离窗口直接关闭。
- 根因:`components/AiChat.vue:362-368` window 级 keydown 对 Esc 无 target 守卫,`props.detached` 分支直接 `store.closeDetachedWindow()`;`components/ai/ChatInput.vue:680-695` 各 Esc 分支只 `preventDefault()` 不 `stopPropagation()`,照常冒泡到 window;`composables/ai/useAiWindow.ts:247-265` 关窗前不校验未发送内容(beforeunload 守卫对原生 close 不保证触发)。
- 复现:分离窗口 → 输入框打字 → 按 Esc → 窗口消失,输入内容丢失。
- 严重度:P1
- 修法:window Esc 分支加输入控件 target 守卫,且 ChatInput Esc 消费分支补 `stopPropagation()`。
### 2. 「测试连接」重拉模型列表静默清空用户已配 enabled/weight
- 现象:拉取模型→关掉部分模型/调权重→保存→再点「测试连接」→ 全部模型复位为 enabled + weight 50。
- 根因:`components/settings/ProviderPanel.vue:484-493` 探测合并仅保留 `probe_source==='user_set'` 的 modalities/capabilities,`enabled`/`weight` 一律取新探测值,旧记录(`old`)只用于判 isManual。
- 复现:AI 模型页 → 编辑 provider → 测试连接 → 调 weight/关模型 → 再点测试连接 → 设置全丢(无任何提示)。
- 严重度:P1
- 修法:合并时对已存在的 model_id 保留 `old.enabled`/`old.weight`,仅新增模型用探测默认值。
## P2 (6)
### 3. 全局快捷键无输入焦点守卫(Ctrl+I 及同族 Ctrl+N/R/L/B/K)
- 现象:任意输入框打字时按 Ctrl+I → AI 面板被关闭/最大化;Ctrl+B → 会话侧栏被关;Ctrl+L → 弹清空对话确认。
- 根因:`App.vue:153-158` handleKeydown 不检查 e.target;`components/AiChat.vue:321-355` Ctrl+N/R/L/B/K 同样无守卫。
- 复现:焦点在设置搜索框/聊天输入框 → 按 Ctrl+I → 面板模式跳变。
- 严重度:P2
- 修法:各全局 handler 首行判 `e.target` 为 input/textarea/contenteditable(且无修饰需求)即 return。
### 4. 主题「跟随系统」不监听系统切换
- 现象:选 system 档后,OS 深浅色切换应用不跟随,需重启或重选档位。
- 根因:`components/settings/AppearanceSection.vue:68-78` 与 `App.vue:145-148` 均只 `matchMedia(...).matches` 取值一次,全仓无 `addEventListener('change')`。
- 复现:主题=跟随系统 → Windows 切深→浅 → 应用不变。
- 严重度:P2
- 修法:applyTheme 时对 matchMedia 挂 change 监听,theme==='system' 时重算 data-theme。
### 5. 顶栏点 provider 名「切换」实为静默改写全局默认 provider
- 现象:聊天顶栏点 provider bar 循环切换(toast 仅"已切换"),但后端互斥写 DB `is_default` — 与设置页「设为默认」同一 IPC `ai_set_provider`(`src-tauri/src/commands/ai/commands/provider.rs:252-273`)。设置页默认徽标随之漂移,新会话/负载均衡默认全被改。
- 根因:`components/ai/TopBar.vue:542-554` → `composables/ai/useAiPanel.ts:178-183` setProvider 直写默认;会话级 active 与 DB default 两概念共用一条 IPC。
- 复现:双 provider → 聊天顶栏点一下切到 B → 设置页 AI 模型列表:默认徽标已移到 B。
- 严重度:P2
- 修法:拆分 active(会话级,不落 is_default)与 default 两条命令,顶栏只改 active。
### 6. 关窗即隐入托盘,首次无任何解释
- 现象:无运行会话时点主窗 X → 窗口无声消失,进程驻留托盘;用户以为已退出(托盘通知气泡只在"有会话运行"且每进程一次时出现)。
- 根因:`src-tauri/src/lib.rs:524-544` 一律 prevent_close+hide;`App.vue:227-233` tray-hide-notice 仅 running>0 才弹。
- 复现:无会话 → 点 X → 桌面无窗口、任务栏无项、无任何提示。
- 严重度:P2
- 修法:首次关窗也 emit 一次"已最小化到托盘"气泡,或设置页提供关窗行为开关(退出/托盘)。
### 7. Provider 表单脏态拦截只盖 Cancel 按钮,切分类/离开即丢
- 现象:编辑 provider(或调模型列表)后点左侧任一分类/路由离开 → 表单连同未保存修改无确认直接销毁。
- 根因:`components/settings/ProviderPanel.vue:649-658` 已暴露 isFormDirty 但注释自认"Settings.vue 未接入 nav 拦截";`views/Settings.vue:25-34` v-else-if 切 activeCategory 即卸载面板。
- 复现:AI 模型页 → 改 baseUrl 不保存 → 点「性能」→ 回来全丢。
- 严重度:P2
- 修法:Settings.vue 在切分类/卸载前查 providerPanelRef.isFormDirty 走 confirmDialog。
### 8. 池权重 debounce 单计时器跨 provider 互踩,先改的静默丢
- 现象:连续给 A、B 两个 provider 改权重(间隔<300ms),A 的保存 IPC 被 clearTimeout 吞掉,视图值与库值漂移。
- 根因:`components/settings/ProviderPanel.vue:603,631-641` `_poolTimer` 为全面板单例,A 乐观更新后计时被 B 的变更清掉。
- 复现:改 A 权重→立即改 B 权重→刷新:A 仍是旧值。
- 严重度:P2
- 修法:timer 按 provider id 建 Map,或新变更先 flush 前一待发。
## P3 (10)
### 9. API 密钥输入无显隐切换,编辑态"留空=不改"无提示
- `ProviderPanel.vue:95`(type=password 恒定无眼睛钮)、`:394`(编辑清空 apiKey 但表单无 hint)。修法:加显隐 toggle + "留空保持原密钥" hint。
### 10. 表单校验只 toast 不定位字段;测试连接禁用无原因
- `ProviderPanel.vue:534-537`/`ConnectionPanel.vue:177-186` 缺哪个字段无高亮;`ProviderPanel.vue:120` 按钮禁用态无 tooltip 说明缺 baseUrl 还是 key。修法:字段级 inline 错误 + 禁用原因 title。
### 11. 「已保存」假成功族(反馈先于持久化结果)
- `AppearanceSection.vue:9-39,86-89` markSaved 不等 set 结果;`PerformanceSection.vue:118-128` debounce IPC 失败仅 console,前面已显"已保存";`views/Settings.vue:44-50` anthropicCache 开关无任何反馈。修法:反馈挂 await/IPC 结果,失败改 error toast。
### 12. 日志级别"已保存"但运行时生效链路存疑
- `AdvancedSection.vue:229-236` 注释自认运行时是否读该值未知,UI 仍显"已保存"。修法:desc 注明生效时机或补齐热生效。
### 13. 连接表单 Cancel 无脏确认,保存无成功反馈
- `ConnectionPanel.vue:37` 直接关表单(对比 ProviderPanel L19 有确认);`:176-209` saveConn 成功无 toast(删除反有)。修法:对齐脏确认+成功 toast。
### 14. 数值输入越界不拦:temperature/并发上限
- `ProviderPanel.vue:549-552` temperature 仅判 isFinite,手输 99 照存(min/max 只限步进);`PerformanceSection.vue:112` 并发无上界。修法:保存前 clamp + 提示修正值。
### 15. i18n 残留硬编码中文 / 假环境信息
- `App.vue:230` 后台运行气泡;`src-tauri/src/tray.rs:28-29,65-70` 托盘菜单;`components/ai/TopBar.vue:487` osInfo 恒 'Windows 11 / PowerShell'(非事实)。修法:入 i18n;osInfo 用 Tauri os 插件取真值。
### 16. 双全局 toast 同位重叠
- `App.vue:317-327` df-toast 与 df-toast--info 都 fixed bottom:24px 居中,错误+托盘提示同现互相压盖。修法:合并进同一纵向堆叠容器。
### 17. 设置搜索每键强制跳分类
- `SettingsNav.vue:131-144` 输入即改 activeCategory+滚动,浏览 A 类打字被拽到 B 类。修法:输入只高亮,Enter/点击才跳转定位。
### 18. 无首次使用引导/空态引导单薄
- 全仓无 onboarding;provider 未配置仅 AI 顶栏一行红字(`i18n/zh-CN/aiChat.ts:63`),Dashboard/AiHome 无引导卡。修法:provider 为空时主界面一次性引导卡(直达设置)。
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

+158
View File
@@ -0,0 +1,158 @@
# aichat 上下文管理·信息组织 开发完善 进度
> 任务来源: 飞书引用「ai-chat agent 上下文管理信息组织 开发完善」(2026-10-02)
> 依据: 《02-架构设计/专项设计/aichat上下文管理-派工与决策单-2026-09-29.md》确定性批次 + todo.md:29-35
> 前批遗留: 12 文件未提交(中断根治),约定不提交攒批等通知——本批同样落盘不提交
> 附件: 性能设置页截图已分析,作配置项样式参照(下次对话生效标签/说明文字范式)
## 批次与状态
> [补1] 用户拍板: UX UI 样式清单(12张截图)本批集中一起修
> [补2] 同时派子代理走查代码,规划下一批次任务
| 批 | 内容 | 状态 |
|----|------|------|
| B1 | 知识检索关键词化 | ✅ 已完成+已销账(7c62d8d) |
| B2 | 视图裁剪补滚动摘要 | ✅ 已完成+已销账——9b89610(10-01)已落地,主代理独立核查(提交存在+实现+11测试+零diff),裁决接受既有实现不重构(user 角色保缓存指纹优于决策单 system 建议) |
| B3 | 存量测试 4 挂 | ✅ 已完成+已销账——df3ed55+e7cd035 已修,impl-b3 全量 cargo test --workspace 1827 passed/0 failed 复核;插曲:E 盘曾满(os error 112),代理清 target/debug/incremental 27G 后 73%,打包前留意磁盘 |
| B4 | candidate→published 晋升 | ⚠️ 半截:机制已通(auto_publish+promote 工具+补嵌入,开关默认关符合决策单保守默认);**存量 854 条 high 滞留未晋升**→impl-b4 迁移实施中;增量 confidence 分级写入不做(等 C3 拍板) |
| B5 | LLM 知识工具注册 | ✅ verify-ctx 实证已完成(knowledge_search/insert/promote 3 工具)——待销账 |
| UX | 12 张截图样式清单 | ✅ ux-layout 完成+主代理 diff 核验过:AuditLog 决策者列 min-width:88+nowrap/settings.css 三防线(gap+min-width:0+flex-shrink:0)/上传端点行纵排;vue-tsc EXIT=0 |
| U系 | ✅ ux-token 完成+主代理 diff 核验过:U3 四文件 rgba→token/color-mix 清零+U1-4 残两处 catch 补 actionError;vue-tsc EXIT=0。范围外观察:TaskBatchBar:209/211 .tbb-danger 两处 rgba 残留(下次样式收尾) |
| walk | 下一批次走查规划 | walk-next 代理进行中,产出 temp/下一批次规划-2026-10-02.md |
## 用户指令更新(2026-10-02 补3/补4)
- 高置信度自主推进;决策项查用户历史辅助决策;实在不决记录待拍板;持续反思提升编排效率与结果质量
- **流程定序:本批代码调整完成后、下一批任务前,安排换装**
- C3(晋升默认值)判定:维持决策单保守默认(auto_publish 关/检索纳入 candidate 开),列待拍板清单——非「实在不决」,是既定安排
## 会话重启后进展(2026-10-02 恢复)
- impl-b4/walk-next 双代理被重启中断;impl-b4 的 V43 迁移代码已完整落盘,主代理接管验证:cargo check+df-storage 180 测试全绿(含 2 新用例)+库副本实证 854 条晋升(967/53→113/907)+重跑幂等 0 行 → B4 已销账
- walk-next 已恢复续跑(规划文档已产出 D 线 7 项发现,三路子代理发现合并中)
- **打包曾启动已主动停掉**:walk-next 发现 D 线缺陷须换装前修——D1[P1] 上批 G2 唯一率豁免无操作(dedup 等价+分母未滤+off-by-one+测试无鉴别力)+D2-D7 六项 P2/P3 → fix-dline 代理修复中
- 门禁基线:cargo check --workspace 0 error / vue-tsc EXIT=0(V43 落地后,fix-dline 改动后需重跑)
- todo 第一批 I1/I2/G2回环/V3 四项按走查实证销账;V2 聚合层已修(V5/V6 未逐核)+V1 未修留前端批
## 待办(收口顺序)
- [x] impl-d-rust 落地核查 ✅ D1 算法按指令逐字落地(units=首现+豁免再现/区间 j+1..i 不含自身/自身写类豁免)+D3 后端穿透链(枚举字段→completed_event→emit helper→teardown_round→5 调用点,达上限传 Some("max_rounds"))已齐;D7 文案/D2/D5/D6 见第四段
- [x] impl-d-fe(D3 前端+D4) 完成 ✅ 主代理 diff 核验过:types.ts incomplete_reason?: string 契约字段+三目分流+i18n 双语(无特殊字符)+D4 守卫就位;vue-tsc EXIT=0(代理自验),统一门禁复验
- [x] rev-backend/rev-frontend/rev-crates 三路走查:**三次中断不再重派**,缺口接受(见 temp/下一批次规划-2026-10-02.md「代理线中断说明」),走查增量待下一批开工前补一轮
- [ ] 重跑统一门禁(cargo check --workspace + cargo test --workspace + vue-tsc) → 打包换装(杀进程→/S→启动验证) → 飞书回报(含 C3 待拍板)
- [ ] 下一批:批次2 后端高危(catch_unwind,先场景回放 fixture)+批次3 前端体验批,可并行
## 会话再重启核查(2026-10-02 第三段)
- 主代理逐项核查 D1-D7 落盘状态:**七项全部未落盘**(fix-dline 代理死于写码前;工作区 diff 全部来自上批「中断根治」+B 系)。D1 三缺陷原样(豁免不入/分母未滤/区间含自身)、D2 锁外 is_active、D5 尾部 `_=>false`、D6 错误串无工具名、D7 旧文案、D4 无路由守卫、D3 无 reason 字段
- 已重派五代理:impl-d-rust(D1/D2/D5/D6/D7+D3 后端字段,附推导好的目标算法与验收用例 a-d)/impl-d-fe(D3 前端消费+D4,与 Rust 侧按 incomplete_reason 契约并行零等待)/rev-backend/rev-frontend/rev-crates(走查范围+硬性排除清单,防噪音)
- D1 目标算法(主代理推导,已写入代理指令):units=首现+豁免再现(区间 j+1..i 夹写**或**自身写类),未豁免再现计 0,ratio=units/全样本;验收 [g,w]×6→false/[g]×6→true/全读轮换→true/write 异签名交替→false
## 第四段:残留代理存活实锤与收口(2026-10-02 18:0x-18:2x)
- **关键发现:重启通知≠代理死亡**。本代核查期间 helpers.rs/mod.rs/chat.rs/audit mtime 持续推进(18:07→18:17),grep 证实 impl-d-rust 残留进程**仍活着按指令清单干活**:D1 算法与本代指令逐字吻合、D3 穿透链五调用点全齐(达上限传 Some("max_rounds"))。SendMessage 通道已断(名字不可达),改观察等待+独立核查,不双写防互踩(memory: uclaw-rescue-parallel-conflict 同款教训)
- 教训沉淀:无头管道会话重启后,后台代理进程可能存活续跑;接手前先 ls mtime+grep 核对落盘内容,确认无活跃写入者再动手,有则观察等待
- 剩余待核:D7 文案/D2 锁内复查/D5 尾部 User/D6 错误串带 name/D1 鉴别力测试补充/teardown_round 参数 doc comment 编译错(mod.rs:2641)
- **残留代理全部修复实证**(18:25 复核):D7 ✅ :1155 / D6 ✅ audit:260 带 name / D2 ✅ 锁内复查双点(:2454/:2499) / D5 ✅ User=>true(:2469) / doc comment ✅ 已改 // / D1 鉴别力测试 ✅ 两个新增用例;cargo check --workspace 0 error + vue-tsc EXIT=0(本机实测)
- **跨会话协调(18:2x)**:devflow-2e(另一重启代)主动协调主导收口(test→打包→换装→回报)。本会话停手让位:TaskStop 自家后台 cargo test,移交 D1-D7 独立核查结论+磁盘空间提醒(E 盘曾满 os error 112,<15G 先清 incremental),避免双打包互踩。本会话保留:进度文件同步+下一批规划收尾(已完成:规划文档补代理线中断说明+下批启动第一步)
- **分工终态确认(devflow-2e 回执)**:它主导 test 收官+打包+换装+飞书回报(C3 按保守默认口径呈报);已实测 cargo check 43s 0 error+vue-tsc 0+helpers 47 测试全绿。本会话复核:D1 鉴别力用例 a-d 四个全在盘(:896/:909/:919/:930)+上批豁免测试 4 个(:819/:847/:858/:874),与指令验收清单一一对应。D 线收口状态=✅ 待全量 test 绿后打包
## 第七段:换装完成+六路齐发(2026-10-03,最新代)
- **换装 ✅ 完成**:tauri build 成功(devflow_0.3?_x64-setup.exe@target/release/bundle/nsis,release exe 11:31)→杀 PID9376→/S 静默安装(uninstall.exe 11:58 刷新,安装路径 %LOCALAPPDATA%\devflow 非 Programs 下)→powershell Start-Process 启动验证(PID7364 跑新版)。cmd start 在无头会话挂住,powershell 可用(经验沉淀)
- 门禁三绿后打包:cargo check 0/全量 test 0 失败(重验过,tail 截断管道退出码坑已避开)/vue-tsc 0;打包重启过一次(服务重启杀,增量编译 4m19s)
- D 线最终收尾:commands/ai/mod.rs 枚举字段 incomplete_reason 定义+stream_relay 2 行(残留代理完善,重启前落盘,cargo check 0 error 复核过)
- **六路并行第一批已派**(文件零交集):w1-compress(压缩 P0 4行+钳制+try_continue 正典)/w2-crates(V21 迁移+error 帧+Retryable+sanitize parts)/w3-mech(6 处裸 emit+死键)/w4-catchunwind(先场景回放 fixture 再实现)/w5-approval-route(后台审批放行+activeId+数组合并)/w6-watchdog(双轨迁移+resumeAttempts+forceConverge 守卫+removeGoal)
- 边界微调:forceConverge 守卫归 w6(与 3a 撞 useAiStream.ts)
- 收口链:六路回→主代理独立核查(diff 语义+编译+grep 残留)→第二批(2c 删除域+3c UX 清单)→攒批等提交通知
## 第六段:换装+下一批并行推进(2026-10-02 深夜,devflow-b6 代)
- 用户指令:换装、规划下一批、并行推进。现场核对:devflow-2e 已消失(死于门禁/打包前,无 nsis 产物),换装未发生,本代接手
- 门禁:cargo check 0 error(上代)+vue-tsc EXIT=0(本代复验)+cargo test --workspace 后台跑(打包前最后一道闸,bva53njkm)
- **打包期间不碰代码**(产物快照一致性),期间完成批次2/3 派工规划
- **批次2/3 第一批六路并行派工单**(打包完成后即发,全部文件零交集):
- W1 压缩域小修:chat.rs:1734 cid=P0(4行)+context_lifecycle protect_start 钳制(1行)+helpers try_continue 裸 emit 补 finalize(2处)
- W2 crates 域:V21 迁移进度标记+OpenAI error 帧补 default+serde Retryable 拆分(text 后 from_str)+sanitize parts 迁移
- W3 机械批:remote_bridge 6 处裸 emit 换 emit_ai_chat_event+i18n searchFilesBlocked 死键转义
- W4 核心难活:run_agentic_loop catch_unwind——**先落场景回放 fixture 再动手**(memory test-strategy-scenario-replay),只碰 agentic/mod.rs 主体
- 3a 审批路由域:useAiEvents !isCurrent 放行 ApprovalRequired/DirAuthRequired+useAiConversations 兜底置 activeId+pendingApprovals per-conv 合并+forceConverge 守卫
- 3b 看门狗域:useAiSend 三调用点迁 per-conv+_resumeAttempts 收尾清+_lastDeltas 双路径漏清+removeGoal 回写
- 第二批(第一批收口后):2c 删除域(conv delete 停 loop+删除族 6 处+审批超时主动收敛+F2 前移,碰 mod.rs 等 W4)/3c UX 体验清单(V8/R3/R4/I5/I4/V7/V4/V1+死链清理)
- 换装后飞书统一回报(含 C3 待拍板+本批三路走查增量一句话+9 方向已登记)
## 第五段:devflow-cf 本代收口(2026-10-02 深夜)
- **三路走查全部完成**(续跑成功,「三次中断不再重派」判断作废):rev-backend P0×1+P1×3+P2×5 / rev-frontend P1×2+P2×6+P3×8 / rev-crates P0×1+P1×12+P2×34。全文落 docs/05-代码审查/aichat三路走查报告-2026-10-02.md,可执行项登记 docs/todo.md「三路走查增量待办」节
- 后端 P0-1(压缩 conv_id 分裂)主代理亲核实锤;D1 鉴别力用例确认残留代理已落完整验收 a-d(:896/:909/:919/:930),我重复补的一份已删(DRY)
- 本地验证:cargo check --workspace 0 error / cargo test --lib helpers:: 48 passed 0 failed
- **协调留痕(致 devflow-2e/平行会话)**:SendMessage 双通道均不可达(代理名/pipe 均失效)。分工维持:你主导统一门禁(cargo test --workspace+vue-tsc)→打包换装→飞书统一回报;我这边文档收口已全部完成,不再动代码。打包时留意:E 盘 25G 剩余(够);死代码 warning 1 个与本批无关可顺手看
- **用户新指令(补1)已登记**:9 项后续规划方向入 docs/todo.md「后续规划方向」节+memory,先记录后开展
- 剩余链(归 devflow-2e 主导):全量 test 绿 → 打包(只打一次) → 换装(杀进程→/S→启动验证) → 飞书回报(含 C3 待拍板+本批三路走查增量一句话)
### verify-ctx 关键实证(2026-10-02)
- DB 分布: candidate 967(high 854/medium 108/null 5) / published 53(全人工旧通道);embedding 全 NULL=向量路从未生效,检索全靠 LIKE
- P1 跨会话召回(source_project): 仍未用,零消费方(C1 决策卡未拍板,本批不动)
- B4 存量晋升后复用/晋升路径会逐步补嵌入;migration 直接 SQL 不触发 854 条批量嵌入(有意)
- 不做: C1 跨会话召回(决策卡未拍板)/C2 语义检索(建议维持默认关)/P2 DB 截断(低优先)
- 门禁: cargo check --workspace 0 error / cargo test --workspace 全绿 / vue-tsc(前端批后)
## 已完成
- [x] 侦察: 决策单/todo/进度文件现状核实;B1/B4 有 10-01 执行痕迹(7c62d8d/b8416de),B2 确认未做
- [x] 派工: walk-next/verify-ctx/verify-ui/impl-b2/impl-b3 五代理并行
## 第八段:六路收口+补派完成(2026-10-03 午后,最新代)
- 六路第一批核查:W1 压缩(try_continue 正典 emit_ai_completed_once)/W2 crates(Retryable 拆分+V21 断点续迁:513 逐对话进度判定)/W3 机械(remote_bridge 正典 emit ×11)前会话已落盘;W4 catch_unwind/W6 看门狗/W5 后半未落——45s mtime 静默确认无活跃写入者后双路补派
- **W4 补齐 ✅**(w4-catchunwind 代理,一轮自证+主代理独立核查):fixture 先行 5 测试红→绿(panic_guard_tests mod.rs:2924-2965);纯函数层 panic_message:1269 + unwind_to_result:1281(AssertUnwindSafe+catch_unwind);loop 层非 move async 块薄包装:1054(外层持有 guard/tokens,panic 分支复用 teardown_round 走正典 emit_ai_completed_once);依赖声明 panic=unwind(tokio 默认)
- **W5 剩余+W6 补齐 ✅**(w5w6-frontend 代理,一轮自证+主代理独立核查):activeId 兜底(useAiConversations:302)/pendingApprovals per-conv Map 投影架构(aiShared convPendingApprovals ×8,组件零改动)/isGlobalApprovalEvent 跨会话放行(useAiEvents:50)/看门狗 10 处 legacy 调用点迁 per-conv/clearResumeAttempts 收尾三路径(useAiStream:59)/_lastDeltas 双路径补清/forceConverge per-conv 双守卫(useAiStream:90-105)+补气泡限当前视图
- 门禁:cargo test --lib **629 passed 0 failed**(624 基线+5 fixture)/vue-tsc EXIT=0/主代理双线独立复核(非代理自验)
- 遗留登记:①removeGoal 回写需动 TopBar.vue:530(代理守禁改组件约束跳过)归下一批;②streamingGuard.forceResetStreaming 全局清 queue 多会话连带清,归预排
- 后续批次预排代理已派(后台):批次4 细化+子代理线排期+时序总览 → temp/后续批次预排-2026-10-03.md
- 状态:第二批(2c 删除域+3c UX)依赖已全部解除,可即派;工作区多批未提交(本批+中断根治+子轨迹落库+六路),提交分组等用户令
## 第九段:第二批(2c+3c)四路派齐收口(2026-10-03 下午,最新代)
- 「继续」令启动第二批:2c 拆两路+3c 拆两路,四路并行文件零交集(AiChat.vue 双碰已错峰:display 收口后 input 才获授权续跑)
- **2c-core ✅**(batch2c-core,一轮自证+主代理锚点核查):conv delete 停 in-flight loop(stop_flag+notify_one+loop_epoch 三连,范式对齐 ai_chat_clear,:624-636);删除终态收敛设计裁决=**删除侧单点补发**替代逐处 patch(loop_was_alive 时 emit_ai_completed_once incomplete_reason="deleted",:686——epoch 递增使 9 处 loop 出口全部变为非 owner 被守卫压制,单点覆盖全部出口且与接管语义严格互斥);emit_ai_completed_once 升 pub(crate)(mod.rs:2361)。如实报告缺口:DB 删除失败路径无终态(极罕见,与 ai_chat_clear 既有取舍一致)
- **2c-timer ✅**(batch2c-timer,一轮自证+主代理锚点核查):审批超时主动收敛落 audit/mod.rs(ARMED_TIMEOUT_IDS:327 防重 spawn+arm_approval_timeout_timers:336+cancel_expired_approval:424 逐行镜像 approval_timeout cleanup,含 deleted_convs 命中静默退场+已处理幂等);execute_with_heartbeat 提前退出(:259 was_pending_at_entry 入口快照+三路 select! 判死)——**关键拦截**:现网审批窗口先 remove 后 execute,按任务书字面「不在 pending 即退出」会腰斩全部已批准执行,已用入口快照守卫规避;F2 心跳前移按边界跳过(需碰 mod.rs/chat.rs)
- **3c-display ✅**(batch3c-display,一轮自证+主代理锚点核查):R4 代码块复制(html 字段层装饰+事件委托+memo 缓存保 DOM 稳定)/R3 长回复折叠条带量(增量化,M1 折叠机制保留)/V1 徽标(只读消费 convPendingApprovals,2.5s tick 兜底 Map 非响应式)/死链清理(MaxRoundsCard.vue 整删+AiChat.vue 4 引用,后端零 emit 实证 grep AiMaxRoundsReached=0)/V4 登记为功能已存在(confirmDialog 已有)
- **3c-input 部分 ✅**(batch3c-input):V8 草稿 per-conv(aiShared convDrafts Map append+ChatInput watch 切换保存恢复,mention/技能/图片切会话清空防串稿)/I5 错误分类(classifySendError 四类+顺序敏感+i18n 三键)vue-tsc 绿;I4/V7 结构不符(锚点实在 AiChat.vue 队列区)已请示获授权续跑中
- **预排产出 ✅**(next-batch-planner 后台代理):temp/后续批次预排-2026-10-03.md——批次4 细化为 5 防互踩代理组(锚点全实测)/子代理线 S1-S4 排期(S2 勘察发现 sub_agent.rs 审批语义空白,若 trust 默认放行属安全债应升优先)/流式健壮性专项候选/时序表(唯一等令点 T0 提交换装);复核证实压缩 conv_id 分裂 P0 已被 W1 修
- 门禁中程:cargo test --lib **629 passed 0 failed**(Rust 两路后) ;vue-tsc 终验等 3c-input 收口
## 第十段:批次4+S1+UX深查+P1修复(2026-10-03 下午,持续推进)
- **T2 六路 ✅ 全销账**(各一轮自证+主代理独立核查):4a-A1 storage读侧四项(184 绿+2测)/4a-A2A3 五项(194 绿+8测,连带 idea_repo CAS 同族修复,宏无 CAS 的不符项经 git 历史交叉验证)/4b-B1 tool_signature 补参(539 绿,缺席不占位设计保 8 处逐字断言零破坏)/4b-B2 context 四项(⑮ off-by-one 方向修正:实际多注一轮,预排写反,按现场读码为准)/4b-B3 intent 词边界(539 绿,"history"不再误判 Chat;⑯ 勘察出盲区机理)/S1 子代理完成卡(vue-tsc 0,三态样式,per-conv 幂等防双卡)
- **T3 四路 ✅**:4b-B4 begin_turn 喂原文(降级尾缀/技能标记不再污染 WorkingContext 目标)/4c-网络域(BUF_MAX 保尾裁剪+skip_partial 吞尾,content_filter 终态,543 绿)/sanitize 盲区单点修(TOOL_MISSING_PREFIX 前缀短路只吞 orphan 的矛盾消除,含测试改写 1 处如实报告)/S2 勘察(**核心反转:非安全债**——子代理审批是 fail-closed 默认拒绝双层防线,产品语义债=无上报通道;报告 temp/S2-子代理审批勘察-2026-10-03.md)
- **S2-A ✅**(631 绿+2测):deny envelope 结构化四层(whitelist-clamp/needs-auth/path-blacklist/medium-high-policy)+可改尝试方向提示;报告协议(被拒工具收集+报告尾段落+AiAgentFinished.rejected_tools 可选字段);fail-closed 语义零变更
- **[补3] UX 深查三域 ✅**:对话域 P1×3(输入法组词回车直发/分离窗 Esc 连锁关窗/AiError 后按钮死锁)+会话任务域 P1×3(新建任务静默失败/切换无反馈/多选联动冲空)+设置域 P1×2(Esc 第二处/测试连接清空配置);三份报告在 temp/UX走查-*-2026-10-03.md
- **P1 修复三路 ✅ 全销账**:对话域(isComposing 全仓 3 文件补齐+Esc 双层防仲裁+error 态按钮回 idle)/会话任务域(弹窗内联错误+乐观切换高亮 pendingSwitchId+多选退化全量前端过滤)/设置域(测试连接保留 enabled/weight+debounce 按 provider 分键)
- **终门禁:cargo test --workspace 1871 passed 0 failed / vue-tsc EXIT=0**
- 遗留池:P2×19+P3×24(UX 三报告)/⑥ update_field 白名单旁路(无调用方)/F2 心跳前移/removeGoal 回写/streamingGuard 全局清 queue/4c 挂池项/S2-B 真审批通道(三批,依用户对产品语义拍板)
- **攒批规模已大(约 30+ 文件未提交),强建议下一步=T0 提交换装(唯一等令点)**
## 第十一段:T0 换装启动(2026-10-03 新代)
- 用户「推进」令,启动 T0 换装(提交仍攒批等通知不动)
- 打包前清理:工作区残留杂项 5 个已删(.msg-0/1/2+rewrite-msgs.sh=10-02 commit msg filter 残留,temp-post-revive.sh=换装探针残留);target/debug/incremental 9.5G 已清,E 盘 15G→25G(上次 os error 112 教训预防)
- 门禁信任第十段终验(1871 passed 0 failed/vue-tsc 0),之后零代码改动不重跑(验证降频)
- tauri build 后台打包中(bcxtmsjs9),打包期间不碰代码;完成后杀进程→/S 静默安装→powershell Start-Process 启动验证
- **换装 ✅ 全链完成**(15:24-15:31):tauri build exit 0(产物 15:24:27,10.9MB)→杀旧 PID7364→/S 静默安装(%LOCALAPPDATA%\devflow\devflow.exe 47145984 字节与产物一致)→启动验证 PID5800 存活。坑:PowerShell 命令经 bash 必须单引号包裹,双引号会吃掉 $env
- 飞书回报已发(换装完成+本批内容+待拍板 C3/S2-B+攒批等通知)。本批任务链收官,下一批=遗留池(P2×19+P3×24)择机开工
## GOAL 状态
- 当前目标:ai-chat 上下文管理信息组织开发完善——全批次收口+换装完成,待用户:①提交通知 ②C3/S2-B 拍板
## 第十一段:UX-P2批+4c+S4勘察(2026-10-03 晚,提交后第一波)
- 六笔分组提交已推送(499aa49..0b57126),攒批清零后按预排启动本波七路并行
- **UX-P2 五路 ✅**(19 项+顺带,各一轮自证+主代理独立核查):A组队列审批气泡(排队超时响应式化/清队提示/审批超时气泡 per-conv/streamingGuard 按会话清/driveQueue 失败接分类文案)+B组滚动编辑(审批卡不拉滚距底<150px 才跟随/等长切换回底/带图编辑不静默丢+提示/ removeGoal 回写/provider 切换文案消歧)+会话侧(计数口径对齐/操作失败侧栏就近反馈/键盘可达 tabindex+焦点浮菜单+菜单Esc/搜索态重命名)+任务侧(子任务toast正名/看板加载浮标/拖拽非法流转提示原因)+设置域(主题跟随OS/脏态拦接线含路由守卫/密钥显隐+留空提示/首关托盘一次性OS通知)
- 走查锚点纠偏:P2-conv 发现「筛选计数矛盾」实际在 Tasks.vue 非 Sidebar(侧栏本无此缺陷),越界一行模板改动已声明并合理
- **4c-㉑ git merge-tree ✅**:临时仓库实测裁决——现网参数序(HEAD,sub,HEAD)致 branch2 零差异预检完全失效(真冲突输出空);修为 merge-base 求祖先+只认<<<<<<< (等号会被干净diff上下文误撞);545 绿含 wt_08/wt_09 两新测试锁死
- **S4 勘察 ✅**(零代码):task→会话关联零 schema 变更可推导(审计表 result JSON 反查,禁 LIKE);跳转=panelOpen+switchConversation;骨架 5 文件单轮;报告 temp/S4-TaskDrawer跳转勘察-2026-10-03.md
- **门禁:cargo test --workspace 1873 passed 0 failed(1871+2) / vue-tsc EXIT=0**
- 攒批:本波约 25 文件未提交(等授权);P3 打磨批(24项)/S3 轨迹树/S4 实施 排下一波,均待令
+119
View File
@@ -0,0 +1,119 @@
# 下一批次规划走查(2026-10-02)
> 走查代理产出,主代理汇总核验。守 session-role-diagnose-only:仅走查+规划,未改代码。
> 批次边界:本批在修 = 上下文管理 B1-B5(walkthrough 后段全部 ✅ 已销账:B1/B4/B5=7c62d8d、B2=9b89610、B3=df3ed55+e7cd035、B4 存量晋升=migrations V43)+ 12 张截图 UX 样式清单 + U 系竞态守卫(U1-U4);工作区另有「中断根治」12+2 未提交文件(本走查对象之一)。以上全部排除,只找增量。
> 排除声明(会话重启后追加):工作区后追加的 migrations.rs V43 迁移、AuditLog/settings/GitChanges/ProjectCard/TaskBatchBar/TaskDetail/Tasks 改动 = 本批已完成的 UX 修复,不在走查范围。
> 方法:3 路并行走查代理(后端 ai 命令 / 前端 ai 组件 / df-ai+df-storage crates)+ 主代理独立走查工作区未提交改动(D 线)+ 「已修」声明抽查。一切结论以当前源码为准。
---
## 一、增量问题清单
### D 线(主代理走查:工作区未提交改动本身的正确性)
#### D1 [P1] G2 唯一率「写间隔豁免」是无操作——本批声明修复②实际未生效
- **位置**:`src-tauri/src/commands/ai/agentic/helpers.rs:290-311`(`is_repetitive_exploration` 新增 effective 段)
- **根因**:每个签名的**首现必然被 push**(`last_idx.get(sig)` 为 None → exempt=false),而后续 `sort()+dedup()` 后 effective 的去重集合 ≡ 全样本去重集合——豁免逻辑对分子零影响;分母 `total = samples.len()`(:290)又未过滤豁免样本 → `unique_ratio` 与旧算法**逐位等价**。
- **附加 off-by-one**:`samples[j + 1..=i]`(:298)含当前样本自身——写类签名的再现因**自己 is_write=true** 被自豁免(区间应为 `samples[j+1..i]`)。
- **测试无效**:新增 4 个测试在旧代码下同样通过(逐个复算:cleanup 场景 8/12=0.67、pure_wandering 3/9=0.33、adjacent 3/9=0.33、flips_verdict 走单点分支 streak=3)——测试无鉴别力,「11+1 测试过」不代表修复生效。
- **影响**:`[g,w,g,w,g,w,...]`(同 grep 签名+同写签名,如反复 patch 同参数失败重试)distinct=2/12=0.17 < 0.4 → 仍误熔断;「清理任务 write→grep 验证」唯一率误判(本批修复目标)实际依旧。
- **修复方案**:反转 push 条件——豁免的再现应计为「新的有效单元」(夹写重查=新鲜验证活动)push 入 effective,未豁免的再现不入(已被首现覆盖);分母同步改 `effective.len()`(或对偶实现:exempt 计数同时从分子分母扣)。区间改 `samples[j+1..i]`。
- **验证**:补 `[g,w,g,w,g,w,g,w,g,w,g,w]`(g/w 各同签名、写类)用例——修正后应 `false`(当前 `true` 误熔断);再跑全量 G2 测试确认死磕场景仍 `true`。
#### D2 [P2] ai_resume_interrupted 防双跑检查在锁外——双探针 TOCTOU 双 spawn
- **位置**:`src-tauri/src/commands/ai/commands/chat.rs:2443`(`is_active` 检查)→ :2477 附近 transition → :2486 `try_continue_agent_loop`
- **根因**:`is_active` 在 session 锁外读;主窗口+分离窗口(AiDetached 自有 listener+看门狗)同 conv 看门狗先后到期时,两探针都可先过 `is_active=false`;后进锁者 `conv_read` 尾部仍是 Tool(先者新 loop 尚未产出消息)→ 再次 transition(Generating→Generating 迁移失败仅 warn 不阻断)→ 再次 spawn → epoch takeover 抖动 + 短窗口双 loop 并行(可能重复执行工具轮)。
- **修复方案**:session 锁内复查 `conv_states.is_active(&conversation_id)`,命中直接返回 `"generating"`(同时把 transition 失败从「warn 不阻断」改为「返回 generating 不续跑」)。
- **验证**:代码审阅锁内复查;手测主/分离窗口对同会话同时断流双探测。
#### D3 [P2] 达上限自动完成复用「网络中断」文案——语义错位误导排障
- **位置**:`src-tauri/src/commands/ai/agentic/mod.rs:1139-1174`(teardown emit incomplete=true)→ `src/composables/ai/useAiLifecycleEvents.ts:106-112`
- **根因**:达 max_iterations 自动收尾走 `AiCompleted(incomplete=true)`,前端 incomplete 分支统一补 `ai.responseIncomplete`「⚠ 响应因**网络中断**不完整…可重新发送以获取完整回复」——用户实际是轮次耗尽自动收尾,看到的却是网络中断+重发指引。
- **修复方案**:incomplete 气泡按来源分文案(后端事件加 `incomplete_reason` 字段,或前端按 pinned_goals/max-rounds 特征分流到新 i18n key「已达最大轮次自动收尾」)。
- **验证**:跑满 max_iterations 的会话看收尾气泡文案。
#### D4 [P2] 自动续跑提示气泡不路由会话——落错视图(已知家族新实例)
- **位置**:`src/composables/ai/useAiStream.ts:164-167`(`onStreamTimeout` attempts===0 分支)
- **根因**:「正在自动恢复…」气泡无条件 `state.messages.push`(当前活跃视图),convId 为后台会话时气泡落到无关会话——与已登记 9-28 #6「错误气泡推错视图」同家族,系本批新代码引入。
- **修复方案**:push 前加 `convId === state.activeConversationId` 守卫(后台会话静默重挂即可,通知走 notify 通道)。
- **验证**:后台会话看门狗到期,观察活跃会话是否冒出无关气泡。
#### D5 [P2] 尾部是 user 消息的中断被误判「finished」——补假收敛信号
- **位置**:`src-tauri/src/commands/ai/commands/chat.rs:2454-2457`(尾部 role 匹配)
- **根因**:interrupted 判定只覆盖 `Tool` 尾与 `Assistant` 带工具调用尾;末条是 **user**(loop 在首 token 前死亡:启动段 panic/配额拦截/网络失败)时返回 `"finished"` → 前端补「响应在收尾阶段中断,已自动完成收敛。」假信号,实际什么都没生成。
- **修复方案**:尾部 `User` 也判 interrupted 走 `resumed`(续跑语义正确),或返回独立状态让前端用中性文案。
- **验证**:构造发送后首 token 前断流场景,看前端是否误报「已收敛」。
#### D6 [P3] 审批执行超时错误丢失工具名与平台提示
- **位置**:`src-tauri/src/commands/ai/audit/mod.rs:260`
- **根因**:ai_approve 旧路径超时错误带「工具名 + Windows PowerShell `;` vs `&&` 提示」,统一收敛进 execute_with_heartbeat 后错误串只剩「工具执行超时(Ns),已中止(防死锁)」——可诊断性回退。
- **修复方案**:错误串补 `name`(函数内已知)。
- **验证**:人为构造超时工具审批,看 tool_result 文本。
#### D7 [P3] 达上限 NeedAction 系统通知文案与新语义不符
- **位置**:`src-tauri/src/commands/ai/agentic/mod.rs:1150-1156`
- **根因**:通知文案「AI 循环未收敛**已暂停,等待继续或停止指令**」——该出口已改为自动完成收尾(无暂停、无需指令),文案停留在旧暂停态语义。
- **修复方案**:文案改「已达最大轮次,已自动收尾」。
- **验证**:触达上限看系统通知。
### 代理线发现(2026-10-02 深夜收口时补:三路走查已全部完成,中断说明作废)
> 原记「三次中断不再重派」已被超越:续跑成功,rev-backend/rev-frontend/rev-crates 三份完整报告已返回并落盘。
> **全文见 `docs/05-代码审查/aichat三路走查报告-2026-10-02.md`**(含批次归并建议),docs/todo.md 已登记可执行项。精华:
- **后端 P0-1**:ai_chat_compress_context conv_id 分裂(chat.rs:1734 起 cid 切 active)——压缩后台会话 100% 触发:目标 conv compressing 标志永久卡死 + active 会话被插入他人摘要(跨会话污染入库)。**主代理已亲核实锤**。存量缺陷非本批回归,批次2首位(约 4 行)。
- **crates P0-1**:V21 迁移 COUNT 幂等窗口——批次中途崩溃后重跑 COUNT>0 跳过剩余迁移但版本号照样补齐,ai_messages 永久残缺被掩盖(代理核验)。批次2 并入。
- **后端 P1×3**:remote_bridge 6 处 AiError 单路 emit(miniapp 错误全盲)/只读结果缓存无写失效(写后读回过期)/conv delete 不停 in-flight loop(zombie 复活+审批吊死,与已登记 conv 删除族同域)。
- **前端 P1×2**:后台审批/授权事件被 isCurrent 丢弃(挂死,与 V2/V1 不同源)/switchConversation 兜底分支漏置 activeId(视图/高亮/发送目标三重错配)。
- **crates P1×12**:OpenAI 流式 error 帧被 choices 必填整帧丢弃(无声截断)/serde 结构错误判 Retryable/usage 必填缺字段整响应失败/sanitize 合并丢多模态 parts(带图消息图隐身)/INSERT OR IGNORE 静默丢写/CAS 缺软删防护 等。
- **机械批**:remote_bridge 6 处+agentic 4 处裸 emit 换 emit_ai_chat_event;i18n searchFilesBlocked 死键裸 @ 转义。
- P2×45+P3 见报告;已核对无漂移面(迁移链/V43/anthropic SSE 栈)已注明免重查。
---
## 二、对既有报告「已修」声明的抽查结论
| # | 声明 | 抽查结论 | 证据(当前源码) |
|---|------|---------|-----------------|
| 1 | todo 第一批 P1「I1 停止窄窗」未修 | **漂移:已修(已提交)** | `ChatInput.vue:291-298` stopBtnState 读 `getConvState(conv)==='generating'`,注释明言补 conv_state 判定治工具执行期不可干预 |
| 2 | todo 第一批 P1「I2 编辑死链」未修 | **漂移:已修(04acc42 10-01)** | `MessageItem.vue:101-102` `canEdit` + `ai-msg-edit-btn` + emit('start-edit') |
| 3 | todo 第一批 P1「V2 审批黑洞」未修 | **漂移:聚合层已修** | `useAiToolEvents.ts:59-84` AiApprovalRequired 无 isCurrent 守卫、无条件进 pendingApprovals 且保留 conversationId(V5 条目来源显示/V6 计时器 per-conv 残留未逐项核) |
| 4 | todo 第一批 P1「V3 后台零通知」未修 | **漂移:已修(e1dd0bc)** | `notify.rs` 新模块 + `agentic/mod.rs` 6 处 notify_session_event 调用 |
| 5 | todo 第一批 P1「G2 破坏性回环熔断」未做 | **漂移:已实现(已提交)** | `helpers.rs:343+` detect_destructive_loop/normalize_loop_path/destructive_loop_sample + 采样接线注释 |
| 6 | 知识库走查「T4/T5 已修」 | **属实(行号漂移)** | begin_turn `chat.rs:810/2112`(报告写 651/1978)、sync_after_compress `context_lifecycle.rs:261`(报告写 278) |
| 7 | 09-29「R1/R2 已修(工作区待提交)」 | **属实** | `useToolCard.ts:741` TOOL_SUMMARIES;`ToolCardList.vue:131-136/162/286` touched + watch 幂等补齐 |
| 8 | 中断根治「P2-1 AiMaxRoundsReached 幽灵事件已修」 | **属实** | 全仓 grep 仅注释与 enum 定义残留,后端零 emit 点 |
| 9 | 中断根治进度「② helpers.rs 唯一率写间隔豁免已修」 | **漂移:无操作(=D1)** | `helpers.rs:290-311` 豁免对去重后分子零影响、分母未过滤;4 测试旧代码下同样通过 |
| 10 | todo 第一批「V1 awaiting_approval 徽标」 | **未修(与 todo 一致)** | `ConversationSidebar.vue` 无 approval/awaiting 相关代码 |
**抽查总结**:10 项声明中 6 项「报告说未修但实际已修」(todo.md 第一批 I1/I2/V2/V3/G2 回环 未销账),1 项「本批声明已修但实际无效」(D1),3 项属实。建议下一批开工前先做 todo.md 第一批销账核对,避免重复派工。
---
## 三、下一批次建议拆批方案
### 批次 1:新代码收口批(小,先行)
- **内容**:D1 唯一率豁免真实现 + 测试补鉴别力用例、D2 锁内复查、D5 尾部 user 分支、D3 incomplete 文案分流、D4 气泡路由、D6/D7 文案。
- **文件**:`helpers.rs` / `agentic/mod.rs` / `commands/chat.rs` / `useAiStream.ts` / `useAiLifecycleEvents.ts` / `i18n en+zh ai.ts` —— **与当前工作区 12+2 未提交文件完全重叠**。
- **约束**:必须并入当前「中断根治」批一起收尾(等用户通知一并提交),或紧随提交后第一个小批;**不可开 worktree 并行**(同文件互踩)。
### 批次 2:后端登记高危批
- **内容**:todo 2026-10-02 静默死亡 4 项(P0 run_agentic_loop 包 catch_unwind / P1 conv 删除类 6 处补终态事件 / P2 审批超时主动收敛 / F2 心跳兜底前移)+ 本走查后端代理新发现(待代理返回后并入)。
- **文件**:`agentic/mod.rs` / `stream_relay.rs` / `approval_timeout.rs` / `commands/chat.rs`。
- **约束**:与批次 1 共享 mod.rs/chat.rs → **等批次 1 提交后开工**;catch_unwind 属核心 loop 改动,按 memory `test-strategy-scenario-replay` 先落场景回放 fixture 再动手。
### 批次 3:前端体验批
- **内容**:09-29 第二批 P2(V8 草稿 per-conv / R3 长回复带量 / R4 代码块复制 / I5+9-28#7 错误分类 / I4+9-28#4 队列回填 / V7 排队时钟 / V4 浮窗二次确认)+ pendingMaxRounds/MaxRoundsCard 死链清理(后端已零 emit)+ 本走查前端代理新发现。
- **文件**:`composables/ai/*`、`components/ai/*`、`ChatInput.vue`。
- **约束**:与批次 2 无文件交集可并行;useAiStream/useAiLifecycleEvents 与批次 1 有少量交集 → 批次 1 先行或本批避开这两文件。9-28#17(图片上传外部域名)涉开源边界,单独决策不进批。
### 并行性总览
- 批次 2(后端)与批次 3(前端)可并行;批次 1 必须先行收口(同文件)。
- U 系(U1-U4,非 ai 页面)与 B1-B5(知识链路)与本规划三批文件零交集,可多线并行。
- todo.md 第一批销账核对(第二部分 #1-#5)随任意批次顺手完成,纯文档操作。
---
## 附:走查覆盖说明
- D 线(主代理):工作区 12+2 文件全量 diff 逐块审读 + 周边链路(guard.rs epoch 语义、try_continue_agent_loop、execute_with_heartbeat、useAiLifecycleEvents incomplete 分支)交叉核验。
- 代理线:rev-backend(src-tauri/src/commands/ai 静默失败/竞态/锁)、rev-frontend(composables/ai + components/ai 事件/残留/泄漏)、rev-crates(df-ai/df-storage 一致性/错误传播)——发现清单见上方「代理发现」段(会话重启后重派,结果合并中)。
+56
View File
@@ -0,0 +1,56 @@
# 下一批次规划(2026-10-03)
> 前置:批次2/3 第一批六路在跑(w1 压缩/w2 crates/w3 机械/w4 catch_unwind/w5 审批路由/w6 看门狗);提交攒批等用户令。
> 本规划=第二批细化 + 批次4 轮廓 + 里程碑映射 + 待拍板清单。来源:三路走查报告(docs/05-代码审查/aichat三路走查报告-2026-10-02.md)+ todo 历史登记 + memory 专项债。
## 一、第二批(第一批核查销账后启动)
### 2c 删除域(后端,等 w4 完成后派——共享 agentic/mod.rs)
- conv delete 停 in-flight loop:conversation.rs:608-640 删除时锁内置 stop_flag+notify+bump epoch(对齐 ai_chat_clear),消 zombie 复活+审批吊死大半
- conv 删除族 6 处终态事件:agentic/mod.rs:417/:1316/:1379/:1437/:1593/:1423 + stream_relay.rs:520-525(MidStream 保文 conv-deleted 跳过 teardown 最刺眼)——收敛为 emit 带 conv_id 的 AiCompleted(incomplete) 或等价终态
- 审批超时主动收敛:approval_timeout.rs 从「下次交互才 cleanup」改 spawn 定时任务到点主动 cleanup+emit
- F2 心跳兜底前移:loop spawn 自带心跳 stale 判死+emit AiCompleted(现仅用户点停止时运行)
- 附带:execute_with_heartbeat 等待循环加「conv 已删/不在 pending 即提前退出」
- 颗粒:一个代理(同域串联),单轮自证 cargo check+相关测试
### 3c UX 体验批(前端,等 w5/w6 收口后派——V1 依赖审批数组合并语义)
- V8 草稿 per-conv / R3 长回复带量 / R4 代码块复制 / I5+9-28#7 错误分类(429/额度/网络分文案)/ I4+9-28#4 队列回填 / V7 排队时钟 / V4 浮窗二次确认
- V1 awaiting_approval 徽标(ConversationSidebar,消费 w5 合并后的 pendingApprovals)
- pendingMaxRounds/MaxRoundsCard 死链清理(后端已零 emit)
- 颗粒:按组件域拆 2 代理(输入域:ChatInput/草稿/队列/时钟;展示域:复制/带量/分类/徽标/死链),前端文件互斥已核
## 二、批次4(第二批后,crates P2 清债+专项债预备)
### 4a df-storage 清债(走查报告 P2 ①-⑨)
from_row 吞列错误为默认值 / 手写 BEGIN/COMMIT 改 guard.transaction() / insert_checkpoint OR IGNORE / limit=0 语义统一 / 父任务软删校验 / 模块删除先清依赖边 / CAS 补 deleted_at IS NULL(与 P1-9 同批)/ 未知 status 静默变默认+回写放大加 warn
### 4b df-ai 清债(P2 ⑪-⑳,⑲ 提优先)
tool_signature 漂移补参(read_file 缺 search/grep 缺 offset 等——与 G2 签名熔断质量直接相关,先做)/ intent 子串加词边界 / restore_from_messages 重置 topic marker / URL 图 token 地板 / sync_after_compress 吞解析失败补 warn / build_injection off-by-one / TOOL_MISSING_PREFIX 盲区
### 4c 编排/网络 latent(㉑-㉝,多为测试接线态,按启动需要挑)
git_worktree merge-tree 预检失效 / coordinator panic 缺项 / retry 位移溢出+预算口径 / sse_parser BUF_MAX 截头 / content_filter 无终态 等
### 专项债候选(大活,各自立项设计先行)
- ai-router/sanitize 重构(router 路由弱/sanitize step4 orphan)——memory 既有登记
- run_agentic_loop 拆分(w4 落地后评估职责边界)
- df-mcp 多进程 3P0(update 无 CAS/GUI 无感知/审计不落盘)
## 三、里程碑映射(用户 9 方向 × 批次)
| 方向 | 承载批次 |
|------|---------|
| 1 ai-chat 核心+上下文管理 | 本批+批次4 持续线;B 路线(并行多轮/决策能力)单独立项 |
| 2 知识/技能/记忆管控 | C1 跨会话召回(待拍板)+C3 晋升默认值(待拍板)+记忆空壳定案后续 |
| 7 UX 体验 | 3c+后续 UX 小批持续 |
| 3 miniapp | 立项级:真机联调+UI+token 生产级(跨端三层已闭环) |
| 4 飞书 bot / 6 企微 | 立项级:渠道接入深化,各专项设计 |
| 5 云端化部署 | 立项级:分布式协同架构设计先行 |
| 8 内置 DB/远程连接 | 立项级:与代理工具生态整合设计 |
| 9 远程设备管控 | 立项级:中继+代理 client 方案对比(内置 u-desk/devflow vs 新设计,关联 u-relay) |
## 四、待拍板清单(随回报呈报)
1. C3 知识晋升默认值:维持保守默认(auto_publish 关/检索纳入 candidate 开)——已按此运行
2. C1 跨会话召回(source_project 零消费方):立项需决策卡
3. C2 语义检索(向量路):建议维持默认关(embedding 全 NULL,先走 LIKE+candidate 检索面)
4. 9-28#17 图片上传外部域名:开源边界,单独决策
5. 大方向 3/5/6/8/9 立项顺序:用户定优先级后各自专项设计
## 五、时序总览
第一批六路(在跑)→ 核查销账 → **提交(等用户令,已排程)** → 第二批(2c+3c 并行)→ 核查 → 批次4(4a/4b 并行,4c 按需)→ 专项债/立项按用户优先级
+56
View File
@@ -0,0 +1,56 @@
# aichat 中断根治 + 样式走查 进度
> 任务来源:飞书群派工「根治三类执行中断」+ 12 张截图样式走查
> 约束:改完打包换装(静默安装),**不提交攒批等通知**
## 三类中断根因与修复
| 中断 | 根因 | 修复 | 状态 |
|------|------|------|------|
| ① 工具执行完后续回复中断 | 后端 loop 静默死亡(panic/conv删除等)无终止事件 → 心跳停 → 前端 90s 判死 → 用户点继续时后端已 Idle 双重死亡 | 前端探测式续跑:onStreamTimeout 先 ai_resume_interrupted 三分支探测(generating/resumed 静默重挂+自动续跑上限3次/finished 轻收敛) | ✅ 前后端已落盘并独立核查 |
| ② G2 重复调用误判 | 「写→grep验证」节奏下唯一率分支无写间隔豁免(与单点分支不对称);警示文案 pinned_goals 全列拼接 | helpers.rs 唯一率有效唯一数算法(夹写豁免);fetch_goal_summary 超3条截断 | ✅ 已落盘,11+1 测试过 |
| ③ 复制为图片失败 | html2canvas@1.4.1 不支持 color-mix()/oklch() | 换 html2canvas-pro ^2.5.0 + CSS.escape | ✅ 已落盘,vue-tsc 0 |
## 已完成
- [x] 前端 7 文件:useAiStream.ts(探测式续跑+per-conv看门狗)、api/ai.ts(resumeInterrupted)、i18n×2、package.json/lock、MessageList.vue(html2canvas-pro) — vue-tsc EXIT=0
- [x] Rust 代理①:chat.rs ai_resume_interrupted 三分支命令 + lib.rs 注册 + helpers.rs 唯一率写间隔豁免+4测试 — cargo check/test 独立核验过
- [x] 主代理微调:mod.rs fetch_goal_summary 目标>3条截断;i18n×2 加 streamAutoResuming;useAiStream.ts 首次自动续跑补提示气泡(防无反馈期误点继续)
- [x] 12 张截图全部读取分析(shot-10/11/12 压缩后重读完成)
- [x] 走查代理:agent loop 静默死亡分支全量走查报告已交付
## 走查报告修复分流
本批修(rust-audit-fix 代理进行中):
- P1-2 per_conv permit 入口 30s 超时(治 UI 永远转圈)
- P0-1 审批执行窗口补心跳(execute_with_heartbeat 复用或内联心跳)
- P2-1 AiMaxRoundsReached 幽灵事件修正(补发事件或删死 case,代理核查后择一)
登记待办(高风险/需设计,进 docs/todo.md):
- P0-2 run_agentic_loop 包 catch_unwind(panic 静默死亡根治,核心 loop 需谨慎)
- P1-1 conv 删除类静默退出族 6 处补终态事件
- P2-2 审批超时惰性收敛改主动任务
- 前端 AiMaxRoundsReached 死 case(随 P2-1 决策)
## 样式问题清单(12 张截图)
| 图 | 页面 | 问题 |
|----|------|------|
| shot-1 | 灵感页 | 无异常 |
| shot-2 | 知识库待处理 | 「拒绝」按钮右缘疑似裁切 |
| shot-3 | 任务队列 | 列表条目重复渲染(同任务名两条)+「全选」复选框右缘裁切 |
| shot-4 | 对话侧栏 | 标题=消息原文截断充当(大量「新对话」重名) — 观察项 |
| shot-5 | 审批历史 | 「决策者」列竖排逐字换行;「参数摘要」贴右缘截断 |
| shot-6/7 | AI 对话 | 中断 UI 实证(G2 警示 5 目标全列) — 已由源头截断修复 |
| shot-8 | 设置-安全 | 「信任所有目录」描述与开关重叠 |
| shot-9/10/11 | 设置-高级 | 「应用数据目录」标签与路径重叠;「文件上传服务端点」标签竖排逐字换行(窄列病根) |
| shot-12 | AI 面板 | G2 警示三条堆叠实证 — 已由 G2 豁免修复 |
样式修复决策:**本批不修**(用户指令聚焦中断根治,样式属新一批工作),清单待用户定夺后派工。
## 待办
- [x] 收 rust-audit-fix 代理结果并独立核查 — 三项全过:P1-2 permit 30s 超时(范式对齐 provider 先例)/P0-1 审批窗口 execute_with_heartbeat 复用(ai_approve+ai_authorize_dir,附带 run_command 流式+超时分档改善)/P2-1 删前端死 case+修后端注释(git show 365af55 证实补发案会重引卡死 bug)
- [x] 统一门禁:cargo check 0 error(1 既有 warning) + cargo test 620 过 + vue-tsc EXIT=0
- [x] 打包换装 — 本侧 bmsu6xr9e 实际完成(exit 0,产物 02:04:14,含全部功能改动与注释修正;并行会话 devflow-b9 停机重启后曾误判其死亡并接手,已 SendMessage 告知事实与其收口) → 杀进程 → /S 静默安装 EXIT=0 → 启动验证进程稳定
- [x] 飞书回报 — 本侧统一回报(见下)
@@ -0,0 +1,46 @@
# 二期池第一批:子轨迹落库 + 来源标记(2026-10-03)
> 指令源:「已确定的 自主推进」。改动已完成并全量验证,**未提交**(攒批等用户令)。
## 目标
子代理轨迹从「纯内存零落库、会话结束即蒸发」改为「出口整轨迹落库」,origin=sub_agent
标记使桌面侧栏/miniapp 列表过滤子会话——轨迹可追溯(调试金矿 + 后续轨迹树/TaskDrawer
跳转的数据地基),不污染主会话列表。
## 已完成(全部验证通过)
| 项 | 文件 | 内容 |
|---|---|---|
| V44 迁移 | crates/df-storage/src/migrations.rs | ai_conversations 加 origin TEXT 列(column_exists 幂等守卫,同 V42 模式),steps 43→44,三处版本号断言同步 |
| record 字段 | crates/df-storage/src/models.rs | AiConversationRecord.origin: Option<String>(不可变列,UPDATE 不写) |
| 落库链路 | crates/df-storage/src/crud/conversation_repo.rs | from_row 读 origin(unwrap_or(None) 兜底,同 message_id v21 模式)+ INSERT 加列 |
| 会话内存标记 | src-tauri .../ai/mod.rs | AiSession.conv_origins: HashMap<String,String>(与 deleted_convs 对称:init 写入、出口清理回收) |
| save 透传 | src-tauri .../ai/conversation.rs | save 快照 lock 段取 conv_origin → record 构建落 origin(主会话恒 None) |
| 列表过滤×2 | src-tauri .../commands/conversation.rs + remote_bridge.rs | 桌面侧栏 ai_conversation_list 与 miniapp list_conversations 同口径过滤 origin 非空 |
| 出口落库 | src-tauri .../agentic/sub_agent.rs | 统一出口 run_sub_agent_loop:判活(deleted_convs 未重打 且 per_conv 在)→ 摘拦截 → save_conversation 整轨迹持久化 → per_conv/save_locks/conv_origins 三清;启动时 conv_origins 与 deleted_convs 对称写入;模块头文档重写为新语义 |
## 验证证据
- cargo test -p df-storage:**182+12 全绿**(含 V44 全链幂等两遍测试)
- cargo check --workspace:0 error(2 个 warning 为工作区遗留改动自带:Emitter 未用导入、evaluate_ideas_batch 死代码,非本批)
- cargo test -p devflow --lib:**624 过 0 失败**
## 顺手修的遗留半成品(同文件明确 bug,主动修原则)
v21 断点续迁改造批(工作区未提交遗留)新增的两个测试从未跑绿:
1. `v21_crash_midway_resumes_missing_convs`:模拟 INSERT 3 占位符只绑 2 参数(seq 漏绑)+ 建表缺失 → 补 seq 参数 + execute_batch(V21_SQL) 前置
2. `v21_batch_boundary_crash_resumes_remaining_batch`:建表缺失 → 同法修复
3. `test_full_migration_on_fresh_db`:版本号断言写死 43 → 44
## 设计要点(为何这样落)
- **零新破坏性**:运行期仍零落库(deleted_convs 拦截不动),只改出口一次落库;主会话链路只多一次 HashMap get
- **防空壳复活**:出口判活查两处(级联删除会重打 deleted_convs / 父删除会清 per_conv),任一命中放弃落库
- **过滤双口径**:桌面 + miniapp 两处列表同 filter,前端零改动
- Truncated/Failed 轨迹同样落库(调试价值);usage/model 落 None(宁少,轨迹树批再补)
## 待办(等用户令)
- [ ] 提交(攒批授权)→ push → 换装(一字令)
- [ ] 后续批:AiAgentFinished 前端展示(前端零消费已确认)→ 子代理审批挂起 → 轨迹调用树 → TaskDrawer 查看会话跳转
+128
View File
@@ -0,0 +1,128 @@
# 后续批次预排(2026-10-03)
> 定位:批次4 从轮廓细化为可派工颗粒 + 子代理增强线排期 + 专项债/待拍板 + 时序总览。
> 第二批(2c/3c)细化不重写,见 `temp/下一批次规划-2026-10-03.md`(已解锁可立即派工)。
> 锚点均本会话 grep 实测;行号漂移处改写函数名。派工时仍以现场源码复核为准。
> 复核结论:走查报告 P0 压缩 conv_id 分裂(chat.rs:1734)已由 W1 修(现 :1740 `cid = conversation_id.clone()`);V21 断点续迁已落地(v21_crash_midway_resumes 测试在)——两项不进未修池。
## 一、批次4 细化(第二批后)
### 4a df-storage 清债(走查报告 P2 ①-⑨)
| # | 锚点 | 修法一句话 |
|---|------|-----------|
| ① | `conversation_repo.rs:20` ai_provider_from_row(:58 unwrap_or(1)/:61 unwrap_or(50)) | 列缺失降级保功能但补 warn 留痕,坏数据可观测 |
| ② | `conversation_repo.rs:269/:286` 手写 BEGIN/COMMIT | 改 `guard.transaction()`,panic 不残留打开事务 |
| ③ | `conversation_repo.rs:612` insert_checkpoint OR IGNORE | 核对 affected 行数,冲突不静默 |
| ④ | `message_repo.rs:164` `limit.max(1)` | limit=0 语义定案(返空集)+ 注释,消除 0 被钳成 1 的分裂 |
| ⑤ | `task_validation.rs:50` validate_parent_id | 父任务存在性校验补 `deleted_at IS NULL`,回收站父拒挂子 |
| ⑥ | `project_service_repo.rs:128` impl_repo! 宏 update 路径 | 宏生成 update/update_full 绕过 validate_record,对齐 :198 update_full_with_validation 收口 |
| ⑦ | `project_module_repo.rs:172 delete` / `:190 delete_by_project` | 删模块先清 module_dependencies 依赖边再删,FK 不再报错 |
| ⑧ | `task_repo.rs:800` update_full_cas(缺 deleted_at,updated_at TEXT 等值) | CAS 补 `deleted_at IS NULL`(与 P1-9 同批:project_repo/宏 crud/mod.rs:183-196) |
| ⑨ | `task_repo.rs:30` / `idea_repo.rs:107` / `project_repo.rs:114` from_db_str.unwrap_or_default | 未知 status 降级加 warn,防 update_full 回写放大 |
**颗粒评估**:单项均小(1-20 行+单测),任一项单代理一轮内自证 `cargo test -p df-storage` 合格,无需再拆。
**同文件依赖分组(防互踩硬约束)**:
- 组A1(conversation/message repo 读侧):①②③④ — 碰 conversation_repo.rs + message_repo.rs
- 组A2(task 域):⑤⑧⑨-task — 碰 task_validation.rs + task_repo.rs
- 组A3(project/service/idea 域):⑥⑦⑨-project/idea — 碰 project_service_repo.rs + project_module_repo.rs + project_repo.rs + idea_repo.rs
- 派工形态:A1 一代理,A2+A3 一代理(同 crate 串行验证),共 2 并发。⑧ 的 P1-9 连带文件(project_repo.rs:452、crud/mod.rs 宏)归 A2/A3 各自域内。
### 4b df-ai 清债(P2 ⑪-⑳,⑲ 提优先)
| # | 锚点 | 修法一句话 |
|---|------|-----------|
| ⑲ | `df-ai/src/ai_tools.rs:257` tool_signature(消费点 src-tauri helpers.rs:266 / tool_registry.rs:2641) | 签名归一化补参:read_file+search、search_files/grep+offset/limit/-i/output_mode、run_command+working_dir——合法换参不再共享签名误触 G2 熔断 |
| ⑳ | `ai_tools.rs:241` 附近 SigValKind::Json 组 | 不只用 keys[0],全键参与归一化 |
| ⑪ | `intent.rs:185/:194` "api"/"hi" 规则 + `:423` contains 匹配 | 关键词加词边界,"history" 不再判 Chat 0.9 |
| ⑫ | `context/mod.rs:308` restore_from_messages | 重放后重置 pending_topic_marker,消假话题锚点 |
| ⑬ | `context_helpers.rs:73-80` URL 图 token 地板 | 地板不被 ×0.35 稀释(1600 保底) |
| ⑭ | `context_helpers.rs:879` sync_after_compress | JSON 解析失败补 warn 不吞 |
| ⑮ | `context_helpers.rs:828` build_injection | 修脏窗口 off-by-one(少注一轮) |
| ⑯ | `context/sanitize.rs:236/:315` TOOL_MISSING_PREFIX | 前缀即「已配对」判定补边界(真 orphan 仍自愈) |
| ⑰ | df-ai manager_tests 恒零断言(报告 :401-405/:484-489) | 改造为真断言或删,测试资产去水分 |
| ⑱ | `src-tauri chat.rs:810/:2112` begin_turn 调用点 | begin_turn 喂原文而非变换后文本(调用方漂移归位) |
**颗粒评估**:单项 1-15 行+单测,单代理一轮自证 `cargo test -p df-ai` 合格。⑨⑳ 同文件必须同代理。
**同文件依赖分组**:
- 组B1(签名域):⑲⑳ — 碰 ai_tools.rs + src-tauri 两消费点回归
- 组B2(context 域):⑫⑬⑭⑮ — 碰 context/mod.rs + context_helpers.rs(可吸收 ⑰)
- 组B3(sanitize+intent 域):⑪⑯ — 碰 intent.rs + context/sanitize.rs
- 组B4:⑱ — 碰 chat.rs,**与 2c 压缩段(chat.rs:1697-1830 域)互斥,必须 2c 之后**(同文件硬约束)
- 派工形态:B1/B2/B3 三代理并行;B4 并入 2c 收尾或其后单点。
### 4c 编排/网络 latent(㉑-㉝,按启动需要挑)
推荐挑 4 项(实测锚点均在):
| # | 锚点 | 修法一句话 |
|---|------|-----------|
| ㉗ | `retry.rs:77` `1u64 << (attempt - 1)` | 改 `saturating_shl`/封顶,位移溢出 + 预算口径对齐文档 30s |
| ㉙ | `sse_parser.rs:153` BUF_MAX 截头 | 截头改保尾(保留最近完整事件边界),防 JSON 永久破损 |
| ㉜ | `openai_helpers.rs:310-311` finish_reason 判定 | content_filter 纳入终态,无 [DONE] 流也有收敛 |
| ㉑ | `git_worktree.rs:175` merge-tree 三参数形态 | 三参 merge-tree 语义复核(base,branch,base 存疑),预检失效修参数序 |
- 颗粒:各单文件单点,一代理串 2-3 个(df-ai 网络域 ㉗+㉙+㉜ 一组;㉑ git 域单独评估)单轮自证。
- 其余(㉒ coordinator JoinSet panic 缺项、㉓ 清理吞错、㉔ 路由全禁用静默回落、㉕ plan_executor 假并行、㉖ namespace 上限突破、㉘ 未知 provider 回落、㉚㉛ usage/tool_use 缺省、㉝ model_probe 误判)挂池,专项或顺带修。
## 二、子代理增强线排期(依赖序)
> 现状锚点:事件 `src-tauri .../ai/mod.rs:238` AiAgentFinished{agent_id, conversation_id=父, summary, status, rounds, tokens};出口 `agentic/sub_agent.rs:320` 统一 emit;前端全仓 grep 零消费。子轨迹落库(V44 origin 列)已完成待提交。
| 步 | 内容 | 锚点/勘察点 | 验收标准 |
|---|------|------------|---------|
| S1 | AiAgentFinished 前端展示 | 新增消费点挂 useAiEvents 族;父会话内轻量卡(状态+轮数+tokens+summary 摘要) | 父会话可见子代理完成卡;后台会话不串视图(带 conversationId 过滤);Truncated/Failed 有区分样式;vue-tsc 0 |
| S2 | 子代理审批挂起 | **勘察先行**:sub_agent.rs grep「approval」零命中——子会话工具审批现状(trust 默认放行?)须先定语义再实施;事件路由复用 AiApprovalRequired 挂子 conv_id | 子代理高危工具触发审批卡可批可拒;批准后子代理续跑、拒绝后 AiAgentFinished{status:"failed"} 优雅终止;依赖 3c pendingApprovals per-conv 合并先落 |
| S3 | 轨迹调用树 | 数据源=origin='sub_agent' 落库轨迹(V44)+ Started/Finished 事件;父会话展示子代理列表,点击进子会话只读轨迹(复用会话加载链) | 多子代理父会话树形可见;子会话消息可回看不可写;主列表不出现子会话(过滤已落地,回归验证) |
| S4 | TaskDrawer 跳转 | `src/components/task/TaskDrawer.vue`(Tasks.vue:388 挂载)加「关联会话」入口;**勘察点**:spawn_agent 是否已存 task→conv 关联字段,无则补 | 任务抽屉可跳转到关联会话并激活;无关联时入口隐藏不占位 |
依赖关系:S1 最轻可先派(建议等提交换装后,联调即跑真后端);S2 依赖 3c 审批数组合并 + 勘察结论;S3 依赖 V44 提交换装(迁移落 origin 列);S4 依赖 S3 的会话定位或独立勘察。文件域:S1/S3 前端 ai 组件族、S4 TaskDrawer.vue,互斥可并行;S2 前后端同碰,单独派。
## 三、专项债 / 待拍板(搬运 + 新观察)
**搬运(源自下一批次规划§二/§四,不重复展开)**:ai-router/sanitize 重构;run_agentic_loop 拆分(w4 已落地,职责边界评估窗口已到,可启动);df-mcp 多进程 3P0;C1/C2/C3 知识线拍板;9-28#17 图片外域开源边界。
**新观察(本轮 grep/走查增量)**:
- 流式健壮性专项候选(P1-1 error 帧+㉙+㉜+㉚/㉛ 同家族):分散修会反复开同类账,建议聚成小专项一次收口。优先级高——「无声截断/无终态」直接伤日常可用性。
- 子代理审批语义空白(S2 勘察前置项):若现状为 trust 默认放行高危工具,属安全债,S2 优先级应升。
- save_conversation needs_full_rewrite 复核(走查报告「待下批核对」):核查型半天任务,建议塞进 4a A1 组顺带核,不单开批次。
- ⑰ 测试恒零断言:成本低收益是资产保真,随 B2 顺带。
## 四、时序总览
| 时序 | 内容 | 状态 | 并行关系 |
|------|------|------|---------|
| T0 | 提交+换装(子轨迹落库+第一批六路+上下文管理批) | **等用户令** | 阻塞其后的换装依赖项(S3 迁移、S1 联调) |
| T1 | 第二批 2c(删除域)+3c(UX 两代理) | **可立即派工** | 2c/3c 互不碰可并行;2c 先于 4b-B4(chat.rs 互斥) |
| T2 | 批次4:4a(A1/A2+3 两代理)‖ 4b(B1/B2/B3 三代理)‖ 子代理线 S1 | 批次4 在第二批核查销账后 | 4a/4b 同 workspace 但不同 crate 文件域,可并行;S1(前端)与 4a/4b(后端)文件域互斥可并行 |
| T3 | 批次4 收尾:4b-B4(chat.rs:810/:2112)+ 4c 按需(网络域组/git 域) | 2c 后 | 单代理串修 |
| T4 | 子代理线 S2(审批挂起,依赖 3c)→ S3(轨迹树)→ S4(TaskDrawer) | 排队 | S2 前后端同碰单独派;S3/S4 与批次4 后续可并行 |
| T5 | 专项债/立项(run_agentic_loop 拆分评估、流式健壮性专项、大方向 3/5/6/8/9) | 用户定优先级 | 各自立项设计先行 |
原则:提交换装点全程只有 T0 一处(攒批等令);每批核查销账后再放下一批;同文件依赖(chat.rs、ai_tools.rs、conversation_repo.rs)已在组内吸收,跨批互斥仅 chat.rs(2c→B4)一处。
## 五、UX P2/P3 批与遗留池排期(2026-10-03 晚追加)
> 来源:三域 UX 走查(temp/UX走查-*-2026-10-03.md,P1×8 已当轮修复)+ 各批遗留登记。
> 经验蒸馏已沉淀:docs/01-技术文档/多代理流水线实战经验-2026-10-03.md。
### UX-P2 批(换装后即派,4 路并行文件零交集)
| 路 | 内容 | 文件域 |
|---|---|---|
| P2-chat | 对话域 6 项:排队超时 computed 非响应式/队列静默清空提示/审批超时串扰/审批卡强拉滚动/等长切换不回底/编辑丢图 | ChatInput/AiChat/aiShared/ToolCardList/MessageList |
| P2-conv | 会话侧 4 项:筛选计数矛盾/失败气泡错配/键盘可达性(菜单 Esc)/搜索态重命名 | ConversationSidebar 族 |
| P2-task | 任务侧 3 项:子任务 toast 文案错位/看板加载态/拖拽静默弹回提示 | Tasks/TaskDrawer |
| P2-set | 设置域 6 项:快捷键焦点守卫/主题跟随系统/顶栏切换语义/托盘首关提示/表单脏态拦截/密钥显隐 | App.vue/TopBar/settings 族 |
### P3 打磨批(P2 销账后,攒一批 24 项按域 2-3 路清)
### 遗留池归并
- F2 心跳前移(chat.rs/mod.rs 空窗已开,可并入任一后端批)
- removeGoal 回写(TopBar.vue:530)/streamingGuard 全局清 queue(多会话)→ 并入 P2-chat
- ⑥ update_field settings 白名单旁路(无调用方,低危)→ 4c 池
- S2-B 真审批通道(三批:挂起路径→resume 通道→超时前端)→ **等用户对产品语义拍板**
- 4c 挂池 9 项(㉒㉓㉔㉕㉖㉘㉚㉛㉝)→ 按专项/顺带修归入后续
### 更新后时序
T0 提交换装(唯一等令点)→ UX-P2 批 4 路并行 → P3 打磨批 ‖ 4c 挂池 ‖ S3 轨迹树(依赖 T0)→ S4 TaskDrawer → 专项债/S2-B 按拍板。