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