1
0

useDataPicker.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. import { ref, h, createApp, Teleport, type Directive, type App } from 'vue';
  2. import Antd from 'ant-design-vue';
  3. import { fetchTunnelInterpretation, fetchDeviceInterpretation } from './api';
  4. // [审计-第四轮 M2] 重放自我方 fix/base-agent-token(M1/M2/M3):formatClickError 需要识别 StreamError 的 status
  5. import { StreamError } from '/@/views/ventAI/manageAssistent/api/client';
  6. import type { TodoItem, ToolCallRecord, SSERawEvent } from './types';
  7. import DataPickerModal from './components/DataPickerModal.vue';
  8. import aiCloseIcon from '@/assets/images/ventAI/dataPicker/AI-close.svg';
  9. import aiOpenIcon from '@/assets/images/ventAI/dataPicker/AI-open.svg';
  10. import { getActions } from '/@/qiankun/state';
  11. // ─── 全局开关:控制悬停模式浮动按钮是否启用 ─────────────────────────────────
  12. const STORAGE_KEY = 'dataPickerHoverEnabled';
  13. const actions = getActions();
  14. export const dataPickerHoverEnabled = ref(localStorage.getItem(STORAGE_KEY) !== 'false');
  15. /** 记录所有活跃的悬停实例,以便全局开关关闭时立即隐藏按钮 */
  16. const allHoverInstances = new Set<HoverInstance>();
  17. export function setDataPickerHoverEnabled(val: boolean) {
  18. dataPickerHoverEnabled.value = val;
  19. localStorage.setItem(STORAGE_KEY, String(val));
  20. if (!val) {
  21. // 关闭时立即隐藏所有浮动按钮并清除定时器
  22. allHoverInstances.forEach((inst) => {
  23. inst.cancelLeave();
  24. inst.clearHideTimer();
  25. const btn = (inst as any)._btn as HTMLElement;
  26. if (btn) {
  27. btn.style.display = 'none';
  28. btn.style.transform = 'scale(0.9)';
  29. btn.style.opacity = '0';
  30. }
  31. // 重新挂载 mousemove 监听,以便开启后能正常触发
  32. inst.attachMove();
  33. });
  34. }
  35. actions.setGlobalState({ dataPickerHoverEnabled: val });
  36. }
  37. // ─── 按钮 DOM 构建(悬停模式专用)──────────────────────────────────────────
  38. const BUTTON_CLASS = 'di-floating-btn';
  39. function createButtonEl(): HTMLElement {
  40. const btn = document.createElement('div');
  41. btn.className = BUTTON_CLASS;
  42. btn.title = '数据解读';
  43. const img = document.createElement('img');
  44. img.src = aiCloseIcon;
  45. img.width = 24;
  46. img.height = 24;
  47. img.alt = '数据解读';
  48. btn.appendChild(img);
  49. Object.assign(btn.style, {
  50. position: 'fixed',
  51. zIndex: '99999',
  52. padding: '4px 6px 8px 6px',
  53. background: '#0E4F8D',
  54. borderRadius: '8px',
  55. cursor: 'pointer',
  56. display: 'none',
  57. pointerEvents: 'auto',
  58. transition: 'transform 0.15s ease-out, opacity 0.15s ease-out, border 0.15s ease-out',
  59. transform: 'scale(0.9)',
  60. opacity: '0',
  61. border: '2px solid transparent',
  62. boxSizing: 'border-box',
  63. });
  64. btn.addEventListener('mouseenter', () => {
  65. const owner = (btn as any).__di_owner;
  66. if (owner) {
  67. owner.cancelLeave();
  68. owner.clearHideTimer();
  69. }
  70. img.src = aiOpenIcon;
  71. btn.style.border = '2px solid #1e90ff';
  72. });
  73. btn.addEventListener('mouseleave', () => {
  74. const owner = (btn as any).__di_owner;
  75. if (owner) owner.startHideTimer();
  76. img.src = aiCloseIcon;
  77. btn.style.border = '2px solid transparent';
  78. });
  79. btn.addEventListener('mousedown', () => {
  80. img.src = aiOpenIcon;
  81. btn.style.border = '2px solid #1e90ff';
  82. });
  83. btn.addEventListener('mouseup', () => {
  84. img.src = aiOpenIcon;
  85. btn.style.border = '2px solid #1e90ff';
  86. });
  87. return btn;
  88. }
  89. // ─── 悬停触发器实例 ────────────────────────────────────────────────────────
  90. const PAUSE_DELAY = 400;
  91. const HIDE_DELAY = 3000;
  92. function createHoverInstance(btn: HTMLElement) {
  93. let pauseTimer: ReturnType<typeof setTimeout> | null = null;
  94. let hideTimer: ReturnType<typeof setTimeout> | null = null;
  95. let leaveTimer: ReturnType<typeof setTimeout> | null = null;
  96. let visible = false;
  97. let raw: any = null;
  98. let hostEl: HTMLElement | null = null;
  99. function setPosition(x: number, y: number) {
  100. btn.style.left = x + 'px';
  101. btn.style.top = y + 'px';
  102. }
  103. function detachMove() {
  104. if (hostEl && inst.onMove) hostEl.removeEventListener('mousemove', inst.onMove);
  105. }
  106. function attachMove() {
  107. if (hostEl && inst.onMove) hostEl.addEventListener('mousemove', inst.onMove);
  108. }
  109. function show() {
  110. clearPauseTimer();
  111. detachMove();
  112. visible = true;
  113. btn.style.display = 'block';
  114. requestAnimationFrame(() => {
  115. btn.style.transform = 'scale(1)';
  116. btn.style.opacity = '1';
  117. });
  118. startHideTimer();
  119. }
  120. function hide() {
  121. visible = false;
  122. attachMove();
  123. btn.style.display = 'none';
  124. btn.style.transform = 'scale(0.9)';
  125. btn.style.opacity = '0';
  126. }
  127. function clearPauseTimer() {
  128. if (pauseTimer !== null) {
  129. clearTimeout(pauseTimer);
  130. pauseTimer = null;
  131. }
  132. }
  133. function clearHideTimer() {
  134. if (hideTimer !== null) {
  135. clearTimeout(hideTimer);
  136. hideTimer = null;
  137. }
  138. }
  139. function startHideTimer() {
  140. clearHideTimer();
  141. hideTimer = setTimeout(hide, HIDE_DELAY);
  142. }
  143. const inst = {
  144. get visible() {
  145. return visible;
  146. },
  147. get raw() {
  148. return raw;
  149. },
  150. setHost(el: HTMLElement) {
  151. hostEl = el;
  152. },
  153. onEnter(e: MouseEvent) {
  154. if (!dataPickerHoverEnabled.value) return;
  155. clearHideTimer();
  156. if (!visible) {
  157. setPosition(e.clientX - 36, e.clientY - 34);
  158. clearPauseTimer();
  159. pauseTimer = setTimeout(show, PAUSE_DELAY);
  160. } else {
  161. startHideTimer();
  162. }
  163. },
  164. onMove(e: MouseEvent) {
  165. if (!dataPickerHoverEnabled.value) return;
  166. setPosition(e.clientX - 36, e.clientY - 34);
  167. clearPauseTimer();
  168. pauseTimer = setTimeout(show, PAUSE_DELAY);
  169. },
  170. onLeave() {
  171. clearPauseTimer();
  172. clearHideTimer();
  173. leaveTimer = setTimeout(hide, 150);
  174. },
  175. cancelLeave() {
  176. if (leaveTimer !== null) {
  177. clearTimeout(leaveTimer);
  178. leaveTimer = null;
  179. }
  180. },
  181. setRaw(val: any) {
  182. raw = val;
  183. },
  184. clearHideTimer,
  185. startHideTimer,
  186. attachMove,
  187. destroy() {
  188. clearPauseTimer();
  189. clearHideTimer();
  190. if (leaveTimer !== null) {
  191. clearTimeout(leaveTimer);
  192. leaveTimer = null;
  193. }
  194. btn.remove();
  195. },
  196. };
  197. return inst;
  198. }
  199. type HoverInstance = ReturnType<typeof createHoverInstance>;
  200. // ─── 钩子主函数 ─────────────────────────────────────────────────────────────
  201. export interface DataPickerOptions {
  202. /** hover: 悬停出现浮动按钮;click: 点击元素直接触发 */
  203. mode?: 'hover' | 'click';
  204. }
  205. export function useDataPicker(
  206. mapParams: (
  207. item: any
  208. ) => { tun_id: string; tun_name: string; mode?: string } | { device_id: string; device_name: string; device_type: string; mode?: string },
  209. options: DataPickerOptions = {}
  210. ) {
  211. console.log('useDataPicker', mapParams);
  212. const { mode = 'hover' } = options;
  213. const loading = ref(false);
  214. const streamingText = ref('');
  215. const error = ref<string | null>(null);
  216. const thinking = ref(false);
  217. const todos = ref<TodoItem[]>([]);
  218. const executingTools = ref<string[]>([]);
  219. const toolCalls = ref<ToolCallRecord[]>([]);
  220. const latestMessage = ref('');
  221. const modalVisible = ref(false);
  222. const itemName = ref('');
  223. // 弹框容器 + 独立 Vue 应用
  224. const modalContainer = document.createElement('div');
  225. Object.assign(modalContainer.style, {
  226. position: 'fixed',
  227. top: '0',
  228. left: '0',
  229. width: '100%',
  230. height: '100%',
  231. pointerEvents: 'none',
  232. zIndex: '1000',
  233. });
  234. document.body.appendChild(modalContainer);
  235. let modalApp: App | null = null;
  236. function mountModal() {
  237. modalApp = createApp({
  238. setup: () => ({
  239. visible: modalVisible,
  240. loading,
  241. streamingText,
  242. error,
  243. thinking,
  244. todos,
  245. executingTools,
  246. toolCalls,
  247. latestMessage,
  248. itemName,
  249. onClose: () => {
  250. modalVisible.value = false;
  251. streamingText.value = '';
  252. error.value = null;
  253. thinking.value = false;
  254. todos.value = [];
  255. executingTools.value = [];
  256. toolCalls.value = [];
  257. latestMessage.value = '';
  258. },
  259. }),
  260. render() {
  261. return h(Teleport, { to: 'body' }, [
  262. h(DataPickerModal, {
  263. visible: this.visible,
  264. loading: this.loading,
  265. streamingText: this.streamingText,
  266. error: this.error,
  267. thinking: this.thinking,
  268. todos: this.todos,
  269. executingTools: this.executingTools,
  270. toolCalls: this.toolCalls,
  271. latestMessage: this.latestMessage,
  272. itemName: this.itemName,
  273. onClose: this.onClose,
  274. }),
  275. ]);
  276. },
  277. });
  278. modalApp.use(Antd);
  279. modalApp.mount(modalContainer);
  280. }
  281. mountModal();
  282. // SSE 事件处理
  283. function handleEvent(event: SSERawEvent) {
  284. switch (event.type) {
  285. case 'thinking':
  286. thinking.value = true;
  287. break;
  288. case 'executing':
  289. thinking.value = false;
  290. executingTools.value = event.tools || [];
  291. if (event.message) latestMessage.value = event.message;
  292. break;
  293. case 'tool_call':
  294. executingTools.value = [];
  295. toolCalls.value = [...toolCalls.value, { tool: event.tool || '', source: event.source || 'main', status: 'call' as const }];
  296. if (event.message) latestMessage.value = event.message;
  297. break;
  298. case 'tool_result':
  299. if (toolCalls.value.length > 0) {
  300. const last = toolCalls.value[toolCalls.value.length - 1];
  301. if (last.tool === event.tool && last.status === 'call') last.status = 'result';
  302. }
  303. if (event.message) latestMessage.value = event.message;
  304. break;
  305. case 'updated_todo_list':
  306. thinking.value = false;
  307. todos.value = event.todos || [];
  308. break;
  309. case 'token':
  310. thinking.value = false;
  311. if (event.content) streamingText.value += event.content;
  312. break;
  313. }
  314. }
  315. /**
  316. * 点击解读失败时,把「结构化错误」转成用户能看懂的中文文案。
  317. *
  318. * [审计-第四轮 M2] 重放自我方 fix/base-agent-token(M1/M2/M3):
  319. * 背景:TfAgents 对失败请求返回结构化错误体,形如
  320. * HTTP 429 → { "detail": { "code": "...", "message": "今日额度已用完…", "retry_after": 900 } }
  321. * HTTP 413 → { "detail": { "code": "...", "message": "…" } }
  322. * HTTP 404 → { "detail": { "code": "...", "message": "…" } }
  323. * 原行为:只有一行 `error.value = e?.message || '请求失败'`,429/413/404 只能显示 `HTTP错误: xxx`。
  324. * 现行为:复用 manageAssistent/api/client.ts 的 throwStreamHttpError(不新写一套解析器)
  325. * + 本函数的兜底文案,得到可读提示。
  326. * 为什么:429 带 retryAfter 换算分钟、413/404 服务端无文案时用本地兜底。
  327. * 保守策略:只对「确认是 StreamError」的情况改写文案;普通 Error 一律走 fallback,
  328. * 保证行为不回归(例如网络中断、JSON 解析失败等仍显示原始 message)。
  329. */
  330. function formatClickError(e: any): string {
  331. const fallback = e?.message || '请求失败';
  332. // 429:额度用完 / 并发闸 / 每用户任务上限,服务端会给 retry_after(秒)
  333. if (e instanceof StreamError && e.status === 429) {
  334. if (typeof e.retryAfter === 'number') {
  335. const minutes = Math.ceil(e.retryAfter / 60);
  336. // 不足 1 分钟时不显示「0 分钟」
  337. return `${fallback}(约${minutes < 1 ? '不到 1 分钟' : `${minutes}分钟`}后恢复)`;
  338. }
  339. return `${fallback}(请稍后再试)`;
  340. }
  341. // 413:上传体积超限;仅当服务端没给出可读文案时才用本地兜底
  342. if (e instanceof StreamError && e.status === 413 && (!e.message || e.message === 'HTTP错误: 413')) {
  343. return '附件体积超过上限,请减小文件后重试';
  344. }
  345. // 404:会话不存在或无权访问(TfAgents 的会话归属校验)
  346. if (e instanceof StreamError && e.status === 404 && (!e.message || e.message === 'HTTP错误: 404')) {
  347. return '会话不存在或无权访问';
  348. }
  349. return fallback;
  350. }
  351. // 点击触发
  352. function handleClick(item: any) {
  353. modalVisible.value = true;
  354. loading.value = true;
  355. error.value = null;
  356. streamingText.value = '';
  357. thinking.value = false;
  358. todos.value = [];
  359. executingTools.value = [];
  360. toolCalls.value = [];
  361. latestMessage.value = '';
  362. const params = mapParams(item);
  363. itemName.value = 'tun_name' in params ? params.tun_name : params.device_name;
  364. const request = 'tun_id' in params ? fetchTunnelInterpretation(params, handleEvent) : fetchDeviceInterpretation(params, handleEvent);
  365. request
  366. .catch((e: any) => {
  367. // [审计-第四轮 M2] 重放自我方 fix/base-agent-token(M1/M2/M3):结构化错误 → 可读文案
  368. error.value = formatClickError(e);
  369. })
  370. .finally(() => {
  371. loading.value = false;
  372. thinking.value = false;
  373. executingTools.value = [];
  374. });
  375. }
  376. // ── 悬停模式:按钮容器 + 触发器 ──
  377. let btnContainer: HTMLDivElement | null = null;
  378. const hoverMap = new Map<Element, HoverInstance>();
  379. if (mode === 'hover') {
  380. btnContainer = document.createElement('div');
  381. Object.assign(btnContainer.style, { position: 'fixed', top: '0', left: '0', zIndex: '99999', pointerEvents: 'none' });
  382. document.body.appendChild(btnContainer);
  383. }
  384. function createHoverTrigger(): HoverInstance {
  385. const btn = createButtonEl();
  386. btnContainer!.appendChild(btn);
  387. const inst = createHoverInstance(btn);
  388. (btn as any).__di_owner = inst;
  389. (inst as any)._btn = btn;
  390. allHoverInstances.add(inst);
  391. btn.addEventListener('click', (e) => {
  392. e.stopPropagation();
  393. const item = (inst as any).raw;
  394. if (item) handleClick(item);
  395. });
  396. return inst;
  397. }
  398. // ── 点击模式:元素映射 ──
  399. const clickRawMap = new Map<Element, any>();
  400. const clickHandlerMap = new Map<Element, (e: Event) => void>();
  401. // ── Vue 指令 ──
  402. const directive: Directive = {
  403. mounted(el, binding) {
  404. if (mode === 'hover') {
  405. const inst = createHoverTrigger();
  406. inst.setHost(el);
  407. inst.setRaw(binding.value);
  408. hoverMap.set(el, inst);
  409. el.addEventListener('mouseenter', inst.onEnter);
  410. el.addEventListener('mousemove', inst.onMove);
  411. el.addEventListener('mouseleave', inst.onLeave);
  412. } else {
  413. clickRawMap.set(el, binding.value);
  414. const handler = (e: Event) => {
  415. e.stopPropagation();
  416. const item = clickRawMap.get(el);
  417. if (item) handleClick(item);
  418. };
  419. clickHandlerMap.set(el, handler);
  420. el.addEventListener('click', handler);
  421. }
  422. },
  423. updated(el, binding) {
  424. if (mode === 'hover') {
  425. const inst = hoverMap.get(el);
  426. if (inst) inst.setRaw(binding.value);
  427. } else {
  428. clickRawMap.set(el, binding.value);
  429. }
  430. },
  431. unmounted(el) {
  432. if (mode === 'hover') {
  433. const inst = hoverMap.get(el);
  434. if (inst) {
  435. allHoverInstances.delete(inst);
  436. inst.destroy();
  437. hoverMap.delete(el);
  438. }
  439. } else {
  440. const handler = clickHandlerMap.get(el);
  441. if (handler) {
  442. el.removeEventListener('click', handler);
  443. clickHandlerMap.delete(el);
  444. }
  445. clickRawMap.delete(el);
  446. }
  447. },
  448. };
  449. function cleanup() {
  450. if (mode === 'hover') {
  451. hoverMap.forEach((inst) => inst.destroy());
  452. hoverMap.clear();
  453. if (btnContainer) {
  454. btnContainer.remove();
  455. btnContainer = null;
  456. }
  457. } else {
  458. clickRawMap.clear();
  459. clickHandlerMap.forEach((handler, el) => el.removeEventListener('click', handler));
  460. clickHandlerMap.clear();
  461. }
  462. if (modalApp) {
  463. modalApp.unmount();
  464. modalApp = null;
  465. }
  466. modalContainer.remove();
  467. }
  468. return { directive, cleanup, loading, streamingText, error, thinking, todos, executingTools, toolCalls, latestMessage, modalVisible };
  469. }