- 新增 list_workflows / save_workflow 工具:归档式创建与覆盖, 归档时完整结构校验、覆盖前备份可恢复、name 与目录名一致性检查 - 新增内置技能 workflow-authoring:WORKFLOW.md 编写指南 + init/validate 脚本 + 节点详解与三个完整示例,skill_hints 挂载 - deactivate_workflow 挪工具循环特判层并广播摘牌快照, 修复模型自主退出后 slash 菜单与快捷卡片残留 - 工作流卡片消失改为退出动画:store exiting 状态保留快照播完动画 再清空,QuickDock hasContent 动画期间保持展开 - 运行期 workflow 通知 visibility 修正为 compact(原误走用户消息渲染) - 激活/退出通知文案中性化,不向模型透露内部操作路径 - 修复空闲对话刷新后输入栏 token 圆环无数值(bootstrapRoute 补拉取) - 修复 slash 激活工作流后输入栏 "/" 残留(跳转新对话前落盘草稿) - 侧边栏工作流导航图标放大至 20px
279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
import { defineStore } from 'pinia';
|
||
import { useFileStore } from './file';
|
||
import { useSubAgentStore } from './subAgent';
|
||
import { useBackgroundCommandStore } from './backgroundCommand';
|
||
import { useWorkflowStore } from './workflow';
|
||
|
||
/**
|
||
* 快捷窗口(Quick Dock)状态
|
||
* 对话区右侧占位列:工作流 / 待办 / 子智能体 / 后台指令 / 文件记录 五个窗口。
|
||
* 本 store 管理:文件记录列表、详情面板目标、文件预览目标、全局 ⋯ 菜单。
|
||
*/
|
||
|
||
export interface EditedFileEntry {
|
||
path: string;
|
||
op?: string;
|
||
ts?: string;
|
||
}
|
||
|
||
/**
|
||
* 「上次页面离开时 dock 是否有内容」的本地缓存(与主题缓存同理)。
|
||
* 进入页面时真实内容(文件记录/待办/子智能体/后台指令)全部异步到达,
|
||
* 首帧只能按 hasContent=false 渲染 0 宽,数据到达后整列插入导致布局跳变。
|
||
* 用缓存做首帧乐观展开,等首批数据到齐(settleInitialContent)后切回真实状态;
|
||
* 缓存与真实不符时在初始无过渡窗口内瞬间纠正,用户无感知。
|
||
*/
|
||
const QUICK_DOCK_HAD_CONTENT_STORAGE_KEY = 'agents_quick_dock_had_content';
|
||
|
||
const loadCachedHadContent = (): boolean => {
|
||
if (typeof window === 'undefined' || !window.localStorage) {
|
||
return false;
|
||
}
|
||
try {
|
||
return window.localStorage.getItem(QUICK_DOCK_HAD_CONTENT_STORAGE_KEY) === '1';
|
||
} catch {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
export const persistQuickDockHadContent = (hadContent: boolean) => {
|
||
if (typeof window === 'undefined' || !window.localStorage) {
|
||
return;
|
||
}
|
||
try {
|
||
window.localStorage.setItem(QUICK_DOCK_HAD_CONTENT_STORAGE_KEY, hadContent ? '1' : '0');
|
||
} catch (error) {
|
||
console.warn('写入快捷窗口内容状态缓存失败:', error);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 「每个对话各自」的 dock 内容状态缓存(LRU,上限 50 条)。
|
||
* 切换对话时目标对话的回填是异步的,用该缓存同步假定目标对话的 dock 状态:
|
||
* 有内容则切过去即展开(无动画),回填到齐后切回真实状态,不符时瞬间纠正。
|
||
*/
|
||
const QUICK_DOCK_CONV_CONTENT_STORAGE_KEY = 'agents_quick_dock_conv_content';
|
||
const CONV_CONTENT_CACHE_LIMIT = 50;
|
||
|
||
const loadConvContentMap = (): Record<string, boolean> => {
|
||
if (typeof window === 'undefined' || !window.localStorage) {
|
||
return {};
|
||
}
|
||
try {
|
||
const raw = window.localStorage.getItem(QUICK_DOCK_CONV_CONTENT_STORAGE_KEY);
|
||
const parsed = raw ? JSON.parse(raw) : {};
|
||
return parsed && typeof parsed === 'object' ? (parsed as Record<string, boolean>) : {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
};
|
||
|
||
export const loadQuickDockConvContent = (conversationId: string): boolean => {
|
||
return loadConvContentMap()[conversationId] === true;
|
||
};
|
||
|
||
export const persistQuickDockConvContent = (conversationId: string, hadContent: boolean) => {
|
||
if (typeof window === 'undefined' || !window.localStorage || !conversationId) {
|
||
return;
|
||
}
|
||
try {
|
||
const map = loadConvContentMap();
|
||
// 删除重插:刷新插入序,让 Object.keys 顺序近似 LRU
|
||
delete map[conversationId];
|
||
map[conversationId] = hadContent;
|
||
const keys = Object.keys(map);
|
||
if (keys.length > CONV_CONTENT_CACHE_LIMIT) {
|
||
for (const staleKey of keys.slice(0, keys.length - CONV_CONTENT_CACHE_LIMIT)) {
|
||
delete map[staleKey];
|
||
}
|
||
}
|
||
window.localStorage.setItem(QUICK_DOCK_CONV_CONTENT_STORAGE_KEY, JSON.stringify(map));
|
||
} catch (error) {
|
||
console.warn('写入快捷窗口对话内容缓存失败:', error);
|
||
}
|
||
};
|
||
|
||
export interface QuickDockDetailTarget {
|
||
kind: 'agent' | 'cmd';
|
||
id: string;
|
||
}
|
||
|
||
export interface QuickDockMenuState {
|
||
/** runner = 子智能体/后台指令条目菜单(强制关闭);file = 文件条目菜单 */
|
||
type: 'runner' | 'file';
|
||
/** 仅 runner:区分子智能体 / 后台指令 */
|
||
kind?: 'agent' | 'cmd';
|
||
/** runner: task_id / command_id;file: 相对路径 */
|
||
key: string;
|
||
/** fixed 定位坐标 */
|
||
left: number;
|
||
top: number;
|
||
/** 菜单与触发按钮右对齐(runner 的 ⋯ 在行右侧) */
|
||
alignRight: boolean;
|
||
}
|
||
|
||
interface QuickDockState {
|
||
editedFiles: EditedFileEntry[];
|
||
/** true = 来自任务期实时事件(播动画);false = 来自加载/bootstrap(静态呈现) */
|
||
editedFilesLive: boolean;
|
||
detail: QuickDockDetailTarget | null;
|
||
previewPath: string | null;
|
||
menu: QuickDockMenuState | null;
|
||
/** 用户通过顶部悬浮按钮手动收起(与「无内容自动收起」相互独立) */
|
||
userCollapsed: boolean;
|
||
/** 乐观假定的内容状态(来源:上次离开的全局缓存 / 目标对话的按对话缓存) */
|
||
assumedContent: boolean;
|
||
/** 乐观掩码是否生效(三态):生效时 hasContent 完全由 assumedContent 决定——
|
||
* 包括「假定无内容」:切到缓存为空的对话时立即掩盖旧对话被刻意保留的真实内容,
|
||
* 使 dock 与对话区在同一时刻切换(否则只能等 bootstrap 末尾回填才折叠,慢一拍)。
|
||
* 真实数据到齐(settleInitialContent)后关闭,回退为纯真实状态 */
|
||
assumedActive: boolean;
|
||
/** 文件记录回填同步序号:每次 setEditedFiles +1(bootstrap 必经,空数组也调;
|
||
* /new 场景 app watcher 也会清空调用)。初始加载/切换对话的无过渡窗口
|
||
* 通过比较序号识别「新一轮内容已回填」 */
|
||
filesSyncSeq: number;
|
||
}
|
||
|
||
/**
|
||
* 初始乐观掩码的内容假定值。落在空对话路由(/ 或 /new)时全局缓存不适用——
|
||
* 空对话真实内容必为空,按缓存乐观展开只会呈现「展开空白几秒后收回」;
|
||
* 落在具体对话路径时保持「首帧按上次离开状态展开」的防跳变优化。
|
||
*/
|
||
const loadInitialAssumedContent = (): boolean => {
|
||
if (typeof window !== 'undefined') {
|
||
const path = window.location.pathname.replace(/\/+$/, '') || '/';
|
||
if (path === '/' || path === '/new') {
|
||
return false;
|
||
}
|
||
}
|
||
return loadCachedHadContent();
|
||
};
|
||
|
||
export const useQuickDockStore = defineStore('quickDock', {
|
||
state: (): QuickDockState => ({
|
||
editedFiles: [],
|
||
editedFilesLive: false,
|
||
detail: null,
|
||
previewPath: null,
|
||
menu: null,
|
||
userCollapsed: false,
|
||
assumedContent: loadInitialAssumedContent(),
|
||
assumedActive: loadInitialAssumedContent(),
|
||
filesSyncSeq: 0
|
||
}),
|
||
getters: {
|
||
/** 五个窗口(工作流/待办/子智能体/后台指令/文件记录)任一有内容 */
|
||
hasContent(state): boolean {
|
||
const fileStore = useFileStore();
|
||
const subAgentStore = useSubAgentStore();
|
||
const bgStore = useBackgroundCommandStore();
|
||
const workflowStore = useWorkflowStore();
|
||
const todoCount = fileStore.todoList?.tasks?.length || 0;
|
||
// 工作流是「实时状态」(激活/完成/停用即刻变化),不是回填内容,不参与乐观掩码——
|
||
// 否则切换对话时 assumedContent=false 会把刚激活的工作流掩盖掉(/new 激活闪烁根因)。
|
||
// 切对话时旧工作流残留走「保留至回填覆盖」策略,与其余窗口一致。
|
||
const restReal =
|
||
todoCount > 0 ||
|
||
subAgentStore.subAgents.length > 0 ||
|
||
bgStore.commands.length > 0 ||
|
||
state.editedFiles.length > 0;
|
||
// 乐观掩码生效期间(初始加载/切换对话的内容未到齐窗口)以假定状态为准;
|
||
// 掩码关闭后纯真实状态。
|
||
// workflowStore.exiting:退出动画播放期间保持容器展开,否则容器 300ms
|
||
// 收起会吞掉窗口自身的 240ms 退出动画(动画播完 finishExit 后才真正清空)
|
||
return (
|
||
workflowStore.isActive ||
|
||
workflowStore.exiting ||
|
||
(state.assumedActive ? state.assumedContent : restReal)
|
||
);
|
||
},
|
||
/** 实际处于展开态:有内容且未被用户手动收起 */
|
||
expanded(state): boolean {
|
||
return this.hasContent && !state.userCollapsed;
|
||
}
|
||
},
|
||
actions: {
|
||
setEditedFiles(list: EditedFileEntry[] | null | undefined, live = false) {
|
||
this.filesSyncSeq += 1;
|
||
this.editedFilesLive = live;
|
||
if (!Array.isArray(list)) {
|
||
this.editedFiles = [];
|
||
return;
|
||
}
|
||
const seen = new Set<string>();
|
||
const normalized: EditedFileEntry[] = [];
|
||
for (const item of list) {
|
||
if (!item || typeof item.path !== 'string' || !item.path) {
|
||
continue;
|
||
}
|
||
if (seen.has(item.path)) {
|
||
continue;
|
||
}
|
||
seen.add(item.path);
|
||
normalized.push({ path: item.path, op: item.op, ts: item.ts });
|
||
}
|
||
this.editedFiles = normalized;
|
||
// 列表移除正在预览的文件时,同步关闭预览
|
||
if (this.previewPath && !seen.has(this.previewPath)) {
|
||
this.previewPath = null;
|
||
}
|
||
},
|
||
openDetail(kind: 'agent' | 'cmd', id: string) {
|
||
if (!id) {
|
||
return;
|
||
}
|
||
// 再点同一条目 = 收起
|
||
if (this.detail && this.detail.kind === kind && this.detail.id === id) {
|
||
this.detail = null;
|
||
return;
|
||
}
|
||
this.detail = { kind, id };
|
||
},
|
||
closeDetail() {
|
||
this.detail = null;
|
||
},
|
||
openPreview(path: string) {
|
||
if (!path) {
|
||
return;
|
||
}
|
||
// 再点同一文件 = 收起
|
||
if (this.previewPath === path) {
|
||
this.previewPath = null;
|
||
return;
|
||
}
|
||
this.previewPath = path;
|
||
},
|
||
closePreview() {
|
||
this.previewPath = null;
|
||
},
|
||
openMenu(menu: QuickDockMenuState) {
|
||
// 同一按钮再点 = 收起
|
||
if (this.menu && this.menu.type === menu.type && this.menu.key === menu.key) {
|
||
this.menu = null;
|
||
return;
|
||
}
|
||
this.menu = menu;
|
||
},
|
||
closeMenu() {
|
||
this.menu = null;
|
||
},
|
||
/** 切换对话 / 隐藏快捷窗口时重置全部瞬态 */
|
||
resetTransient() {
|
||
this.detail = null;
|
||
this.previewPath = null;
|
||
this.menu = null;
|
||
},
|
||
/** 真实内容数据到齐(无过渡窗口关闭时调用,幂等):关闭乐观掩码,此后 hasContent 只看真实状态 */
|
||
settleInitialContent() {
|
||
this.assumedActive = false;
|
||
this.assumedContent = false;
|
||
},
|
||
/** 切换对话时按缓存乐观假定目标对话的 dock 内容状态(无缓存/无 id → 假定无内容)
|
||
* 并开启掩码:与对话区同刻切换 dock 的收起/展开,不等待异步回填 */
|
||
assumeContentForConversation(conversationId: string | null) {
|
||
this.assumedContent = conversationId ? loadQuickDockConvContent(conversationId) : false;
|
||
this.assumedActive = true;
|
||
}
|
||
}
|
||
});
|