PageSourceSearch

https://img.mobiletrain.org/widget_v16.js?v=21.8

js mobiletrain.org collected 2026-09-28 06:40:22 UTC 65,731 bytes, 1,501 lines download raw bytes

1/**
2 * Smart-KF 智能客服组件 v16.0
3 * ⚠️⚠️⚠️ 生产禁区:全站客服 UI 组件 ⚠️⚠️⚠️
4 * 全站 163 站统一从 img.mobiletrain.org/widget_v16.js 加载(单一源,2026-08-16 起)
5 * 改此文件后必须: ① 同步到 /data/www/cmstop/public/img/widget_v16.js
6 *                ② 改 smart-kf-global.js 的 ?v= 版本号 ③ 刷 CDN
7 * ⛔ 严禁: 直接改各站目录下的副本(已清理,只有 img 一份在服务)
8
9
10 * 
11 * v21.8 更新: 主动弹出降频 — 前3次10秒不变 / 4-6次60秒 / 7-9次120秒 / 10次起300秒;AI接手(用户已说话)后停止自动弹出
12 * v15.7 更新: 达内风格优化 — 消息内【重点】自动红色高亮 + 动态快捷按钮(零基础/想提升/在职/在校/待业)
13 * v15.6.3 更新: 同步后端 v5.5,修正姓名和城市识别
14 * v15.6.2 更新: 发送按钮 3 秒锁(_sendGuard),解决 Enter + click 同时触发导致的消息双发
15 * v15.6 更新: 多层前端去重 — 5秒全局时间窗 + 30秒最近消息去重 + 快捷按钮冷却,
16 *             配合后端请求级去重,解决流式失败降级XHR导致的重复消息问题
17 * v15.5 更新: Nudge分级策略 — 没手机号→推销+在吗穿插 / 有手机号没课程→引导补充课程 / 全有了→不主动推
18 * v15.4 更新: Nudge降频 2分钟间隔/单条推送/已留联停
19 * v15.3 更新: 对话策略大改(定心丸/收尾/一次一问)+ 发送按钮去限制
20 * v15.2 更新: Nudge穿插真人提醒 — 业务+在吗+业务+还在吗+业务 交替
21 * v15.1 更新: 去掉侧边栏Logo + 初心至善/匠心育人两行居中 + 后端随机2-5秒回复延迟
22 * v15.0 更新: 双栏头部蓝色区块高度对齐 + 输入区扩大(整体协调) + 右侧品牌区补齐Logo
23 * v14.0 更新: 窗口加宽560px + AI头像放大36px+彩色边框环 + 清脆叮咚提示音
24 * v13.0 更新: 双栏布局 — 左侧聊天 + 右侧推广侧边栏,辨识度大幅提升
25 * v12.0 更新: 弹窗动画+头像气泡+提示音+多轮nudge完整循环
26 * v8.0  更新: 达内风格nudge主动推送 + 城市站绝对URL兼容 + 0.5s快速弹出
27 * 
28 * 核心导向:引导留联系方式 -> 延迟推送/补推企微群
29 * 
30 * 功能:
31 * - 右下角悬浮客服按钮 + 点击弹出聊天窗口
32 * - 双栏布局(左聊右推):左边370px聊天区 + 右边190px推广侧边栏
33 * - 右侧边栏:课程海报、热门方向快捷入口、免费试听CTA
34 * - 自动弹出(0.5秒,渐进式关闭间隔)
35 * - 弹窗slideUp弹性动画,不再静态出现
36 * - DeepSeek AI 智能对话 + SSE 流式逐字显示
37 * - AI消息前带36px大头像 + 彩色边框环,辨识度极高
38 * - 新消息清脆"叮咚"提示音(Web Audio API)
39 * - 留资表单(姓名+电话+课程方向+状态)
40 * - 多轮主动推送(nudge):5轮消息完整循环,不重置索引
41 * - 静默推送触发(用户不说话10秒后自动触发后端推送检查)
42 * - 传递着陆页/当前页给后端用于精准推送
43 * - 城市子域兼容:支持 window.__SMART_KF_OVERRIDE__ 覆盖apiBase/avatarUrl
44 * - 移动端响应式
45 */
46(function(window, document) {
47  'use strict';
48
49  // ===== 防重复加载保护(v21.4)=====
50  // 页面上可能同时存在旧加载器(ydtswt_pc_yd.js)和新加载器(smart-kf-global.js)
51  // 多实例会导致 session_id 分裂,AI 无法保持对话上下文
52  if (window.__SMART_KF_LOADED__) {
53    console.log('[Smart-KF] 已加载,跳过重复初始化');
54    return;
55  }
56  window.__SMART_KF_LOADED__ = true;
57
58  // ==================== 配置 ====================
59  var CONFIG = {
60    siteId: 'qfedu',
61    siteName: '千锋教育',
62    apiBase: '/test/smart-kf/api',
63    apiToken: 'sk_71bbc60d438c4c64aaa5b278',
64    avatarUrl: '/test/smart-kf/avatar.png',
65    streamEnabled: true,
66
67    autoPopup: {
68      enabled: true,
69      delay: 500,
70      // v21.8 降频策略:前3次维持原频率(10秒) → 4-6次60秒 → 7-9次120秒 → 10次起300秒
71      intervals: [
72        { maxCount: 3,  interval: 10 },
73        { maxCount: 6,  interval: 60 },
74        { maxCount: 9,  interval: 120 },
75        { maxCount: Infinity, interval: 300 }
76      ]
77    },
78
79    agent: {
80      name: '芊芊学姐',
81      avatarImg: true,
82      // v15.7: 达内风格首条问候 — 价值→行动,重点用【】标注(前端自动高亮)
83      greeting: '发送【手机号+姓名】可以申请免费试听名额!\n【课程大纲+学习路线图+就业指南】也可以先领一份看看~'
84    },
85
86    // 版本号 — 动态加载 version.json,回退值与 version.json 同步
87    currentVersion: '21.0',
88    versionJsonUrl: '/test/smart-kf/version.json',
89
90    // ===== 右侧推广侧边栏(v13 新增) =====
91    sidebar: {
92      enabled: true,
93      // 顶部品牌区 — logo+Slogan,高度与左侧头部对齐
94      poster: {
95        logoUrl: 'https://www.qfedu.com/wzt/new-logo-white.png',
96        title: '初心至善 匠心育人'
97      },
98      // 热门课程快捷入口(点击直接发起对话)
99      courses: [
100        { label: 'Java开发(全链路/JavaEE)', trigger: '我想了解Java开发课程' },
101        { label: 'Python+大数据分析', trigger: '我想了解Python大数据分析课程' },
102        { label: 'Web前端(HTML5/全栈/鸿蒙)', trigger: '我想了解Web前端课程' },
103        { label: 'AI人工智能(大模型/AIGC)', trigger: '我想了解AI人工智能课程' },
104        { label: '云计算/运维(Linux/DevOps)', trigger: '我想了解云计算运维课程' },
105        { label: '网络安全', trigger: '我想了解网络安全课程' },
106        { label: 'C++/物联网/嵌入式', trigger: '我想了解C++物联网嵌入式课程' },
107        { label: 'UI/UE设计(UID/视效设计)', trigger: '我想了解UI/UE设计课程' },
108        { label: '软件测试(含车载测试)', trigger: '我想了解软件测试课程' },
109        { label: '短视频+直播电商', trigger: '我想了解短视频直播电商课程' },
110        { label: '游戏开发/VR', trigger: '我想了解游戏开发课程' },
111        { label: '智能职场办公', trigger: '我想了解智能职场办公课程' },
112        { label: '新能源运维工程师', trigger: '我想了解新能源运维课程' },
113        { label: '无人机驾驶执照', trigger: '我想了解无人机驾驶课程' },
114        { label: '认证考试(PMP/红帽/工信)', trigger: '我想了解认证考试课程' }
115      ],
116      // 每次点击最小间隔(防止连续快速点击消耗资源)
117      courseClickCooldown: 5000,
118      // 底部CTA
119      cta: {
120        text: '点我留言',
121        action: 'leadForm'  // 'openChat' 或 'leadForm'
122      }
123    },
124
125    // ===== 信息引擎 v20: 用户未说话时,每60秒循环推送预设提醒 =====
126    // 用户一旦说话 → 信息引擎停止,AI接管
127    infoEngine: {
128      enabled: true,
129      interval: 60000,       // 每条间隔60秒
130      messages: [
131        '嗨~想了解什么课程?直接问就行~',
132        '方便留个手机号吗?学姐帮你安排试听~',
133        '还在吗?直接留个电话,顾问会针对您的情况详细介绍~',
134        '别犹豫啦~留个手机号就行,后面的我们来搞定!'
135      ]
136    },
137
138    quickQuestions: [
139      '我想咨询课程',
140      '学费多少钱?',
141      '校区在哪里?'
142    ],
143
144    statusButtons: [
145      { label: '我在读', value: '在读' },
146      { label: '我在职', value: '在职' },
147      { label: '我待业', value: '待业' }
148    ],
149
150    periodicPushInterval: 300000,  // 对话框开着时每5分钟检查一次(毫秒)
151    storagePrefix: 'smart_kf_'
152  };
153
154  // ===== 外部配置覆盖(页面传入 window.__SMART_KF_OVERRIDE__)=====
155  if (window.__SMART_KF_OVERRIDE__) {
156    var override = window.__SMART_KF_OVERRIDE__;
157    if (override.apiBase) CONFIG.apiBase = override.apiBase;
158    if (override.apiToken) CONFIG.apiToken = override.apiToken;
159    if (override.avatarUrl) CONFIG.avatarUrl = override.avatarUrl;
160    if (override.siteId) CONFIG.siteId = override.siteId;
161    if (override.siteName) CONFIG.siteName = override.siteName;
162  }
163
164  // ==================== 状态 ====================
165  var STATE = {
166    open: false,
167    minimized: false,
168    messages: [],
169    sessionId: null,
170    closeCount: 0,
171    autoPopupTimer: null,
172    reopenTimer: null,
173    leadSubmitted: false,
174    periodicPushTimer: null,  // 对话框开着时每5分钟补推检查
175    infoEngineTimer: null,    // 信息引擎定时器
176    infoEngineIdx: 0,         // 信息引擎当前推送消息索引
177    userHasSpoken: false,     // 用户是否已说话(说话后信息引擎停止)
178    lastCourseClick: 0,   // 课程按钮防抖
179    audioCtx: null,
180    userInfo: {
181      name: '',
182      phone: '',
183      course: '',
184      status: '',
185      source: '',
186      landingPage: (function() { try { return document.referrer || '直接访问'; } catch(e) { return '直接访问'; } })(),
187      currentPage: (function() { try { return window.location.href; } catch(e) { return ''; } })(),
188      visitTime: new Date().toISOString()
189    }
190  };
191
192  // ==================== 工具函数 ====================
193  function el(tag, attrs, children) {
194    var e = document.createElement(tag);
195    if (attrs) {
196      for (var k in attrs) {
197        if (k === 'className') e.className = attrs[k];
198        else if (k === 'innerHTML') e.innerHTML = attrs[k];
199        else if (k === 'style' && typeof attrs[k] === 'object') {
200          for (var sk in attrs[k]) e.style[sk] = attrs[k][sk];
201        }
202        else if (k.indexOf('on') === 0) {
203          e.addEventListener(k.slice(2).toLowerCase(), attrs[k]);
204        }
205        else e.setAttribute(k, attrs[k]);
206      }
207    }
208    if (children) {
209      if (typeof children === 'string') e.textContent = children;
210      else if (Array.isArray(children)) children.forEach(function(c) { if (c) e.appendChild(c); });
211      else e.appendChild(children);
212    }
213    return e;
214  }
215
216  function makeAvatar(size) {
217    return el('img', { src: CONFIG.avatarUrl, style: { width: size + 'px', height: size + 'px', borderRadius: '50%', objectFit: 'cover' } });
218  }
219
220  // ===== v14: 带彩色边框环的头像(消息中使用) =====
221  function makeAvatarRings(size) {
222    // 外层环 + 内层头像,辨识度更高
223    var wrapper = el('span', { style: {
224      display: 'inline-flex', alignItems: 'flex-end', position: 'relative',
225      width: (size + 6) + 'px', height: (size + 6) + 'px', flexShrink: '0'
226    }});
227    // 外环
228    var ring = el('span', { style: {
229      position: 'absolute', top: '0', left: '0', right: '0', bottom: '0',
230      borderRadius: '50%', background: 'linear-gradient(135deg, #ef5350 0%, #e53935 50%, #e53935 100%)',
231      padding: '3px'
232    }});
233    // 白色内圈让头像浮出
234    var inner = el('span', { style: {
235      display: 'block', width: '100%', height: '100%',
236      borderRadius: '50%', background: '#fff',
237      overflow: 'hidden'
238    }});
239    inner.appendChild(el('img', { src: CONFIG.avatarUrl, style: {
240      width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover'
241    }}));
242    ring.appendChild(inner);
243    wrapper.appendChild(ring);
244    return wrapper;
245  }
246
247  function storageGet(key) {
248    try {
249      return localStorage.getItem(CONFIG.storagePrefix + key) || '';
250    } catch(e) { return ''; }
251  }
252  function storageSet(key, value) {
253    try {
254      localStorage.setItem(CONFIG.storagePrefix + key, value);
255    } catch(e) {}
256  }
257
258  // ==================== 提示音 v14: 清脆"叮咚"(Web Audio API) ====================
259  function playNotificationSound() {
260    try {
261      if (!STATE.audioCtx) {
262        STATE.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
263      }
264      var ctx = STATE.audioCtx;
265      var now = ctx.currentTime;
266
267      // 第一声"叮" — 清脆短促
268      var o1 = ctx.createOscillator();
269      var g1 = ctx.createGain();
270      o1.type = 'sine';
271      o1.frequency.setValueAtTime(1047, now);        // C6 — 清亮高音
272      o1.frequency.exponentialRampToValueAtTime(880, now + 0.08);
273      g1.gain.setValueAtTime(0.18, now);
274      g1.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
275      o1.connect(g1);
276      g1.connect(ctx.destination);
277      o1.start(now);
278      o1.stop(now + 0.15);
279
280      // 第二声"咚" — 略低、稍长、更有质感
281      var o2 = ctx.createOscillator();
282      var g2 = ctx.createGain();
283      o2.type = 'sine';
284      o2.frequency.setValueAtTime(784, now + 0.12);  // G5
285      o2.frequency.exponentialRampToValueAtTime(659, now + 0.32);
286      g2.gain.setValueAtTime(0.14, now + 0.12);
287      g2.gain.exponentialRampToValueAtTime(0.001, now + 0.40);
288      o2.connect(g2);
289      g2.connect(ctx.destination);
290      o2.start(now + 0.12);
291      o2.stop(now + 0.42);
292
293      // 泛音 — 增加"铃铛"感
294      var o3 = ctx.createOscillator();
295      var g3 = ctx.createGain();
296      o3.type = 'triangle';
297      o3.frequency.setValueAtTime(1568, now);        // G6 泛音
298      g3.gain.setValueAtTime(0.06, now);
299      g3.gain.exponentialRampToValueAtTime(0.001, now + 0.08);
300      o3.connect(g3);
301      g3.connect(ctx.destination);
302      o3.start(now);
303      o3.stop(now + 0.10);
304
305    } catch(e) {}
306  }
307
308  // ==================== 信息引擎 v20 ====================
309  // 用户从未说话时,每60秒循环推送预设提醒消息
310  // 用户一旦说话 → 信息引擎立刻停止,AI接管
311  // ==================== 信息引擎 v20 ====================
312  // 用户从未说话时,每60秒循环推送预设提醒消息
313  // 用户一旦说话 → 信息引擎立刻停止,AI接管
314
315  function startInfoEngine() {
316    if (!CONFIG.infoEngine.enabled) return;
317    if (STATE.userHasSpoken) return;  // 用户已说话,不启动
318    stopInfoEngine();
319
320    STATE.infoEngineTimer = setTimeout(function() {
321      if (!STATE.open || STATE.userHasSpoken) return;  // 窗口关了或用户已说话就不推
322      var msgs = CONFIG.infoEngine.messages;
323      var msg = msgs[STATE.infoEngineIdx % msgs.length];
324      if (msg) {
325        addMessage('agent', msg);
326        playNotificationSound();
327      }
328      STATE.infoEngineIdx++;
329      startInfoEngine();  // 递归启动下一条
330    }, CONFIG.infoEngine.interval);
331  }
332
333  function stopInfoEngine() {
334    if (STATE.infoEngineTimer) {
335      clearTimeout(STATE.infoEngineTimer);
336      STATE.infoEngineTimer = null;
337    }
338  }
339
340  // ==================== DOM 构建 ====================
341  var $root, $btn, $window, $body, $input, $sendBtn;
342  // v14 尺寸: 窗口560px / 左370px / 右190px
343  var WIN_WIDTH = 620;
344  var LEFT_WIDTH = 420;
345  var RIGHT_WIDTH = 200;
346  var AVATAR_SIZE = 36;  // 消息中AI头像尺寸(v14放大)
347
348  function injectStyles() {
349    var css = [
350      // 根容器 & 按钮
351      '#smart-kf-root { position: fixed; right: 20px; bottom: 20px; z-index: 99999; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #333; }',
352      '#smart-kf-btn { width: 56px; height: 56px; border-radius: 50%; background: #e53935; box-shadow: 0 4px 16px rgba(229,57,53,0.35); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: transform 0.2s, box-shadow 0.2s; position: relative; overflow: hidden; }',
353      '#smart-kf-btn:hover { transform: scale(1.05); box-shadow: 0 6px 20px rgba(229,57,53,0.45); }',
354      '#smart-kf-btn.pulse { animation: skf-pulse 0.6s ease-in-out; }',
355      '@keyframes skf-pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.12); } }',
356      '#smart-kf-btn svg { width: 26px;
356 height: 26px; fill: #fff; }',
357      '#smart-kf-btn .skf-close-svg { display: none; }',
358      '#smart-kf-btn.open .skf-chat-svg { display: none; }',
359      '#smart-kf-btn.open .skf-close-svg { display: block; }',
360      /* 红色脉冲动画 - 持续吸引注意(box-shadow 呼吸) */
361      '@keyframes skf-pulse-breath { 0%,100% { box-shadow: 0 4px 16px rgba(229,57,53,0.4), 0 0 0 0 rgba(229,57,53,0.5); } 70% { box-shadow: 0 4px 16px rgba(229,57,53,0.2), 0 0 0 14px rgba(229,57,53,0); } }',
362      '#smart-kf-btn { animation: skf-pulse-breath 2.5s infinite; }',
363      '#smart-kf-btn.open { animation: none; }',
364      '#smart-kf-btn.open { background: #666; }',
365
366      // ============ v14: 窗口加宽至560px ============
367      '#smart-kf-window { position: fixed; right: 20px; bottom: 90px; width: ' + WIN_WIDTH + 'px; max-height: 620px; background: #fff; border-radius: 16px; box-shadow: 0 8px 32px rgba(0,0,0,0.15); display: none; flex-direction: column; overflow: hidden; opacity: 0; transform: translateY(30px) scale(0.95); }',
368      '#smart-kf-window.open { display: flex; animation: skf-slideUp 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; }',
369      '@keyframes skf-slideUp { from { opacity: 0; transform: translateY(30px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } }',
370
371      // 双栏主行
372      '#smart-kf-main-row { display: flex; flex: 1; min-height: 0; overflow: hidden; }',
373
374      // 左侧聊天区 370px(v14加宽)— v15: 去掉右边框,让蓝色头部与右侧品牌区连成整体
375      '#smart-kf-left { width: ' + LEFT_WIDTH + 'px; flex-shrink: 0; display: flex; flex-direction: column; overflow: hidden; }',
376
377      // 头部
378      '#smart-kf-header { padding: 13px 18px; background: linear-gradient(135deg, #e53935, #b71c1c); color: #fff; display: flex; align-items: center; gap: 12px; flex-shrink: 0; }',
379      '#smart-kf-header .skf-agent-avatar { width: 42px; height: 42px; border-radius: 50%; object-fit: cover; border: 2.5px solid rgba(255,255,255,0.35); }',
380      '#smart-kf-header .skf-agent-name { font-size: 16px; font-weight: 600; }',
381      '#smart-kf-header .skf-agent-status { font-size: 11px; opacity: 0.85; line-height: 1.3; }',
382      '#smart-kf-close { margin-left: auto; cursor: pointer; font-size: 20px; opacity: 0.75; background: none; border: none; color: #fff; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 50%; }',
383      '#smart-kf-close:hover { opacity: 1; background: rgba(255,255,255,0.15); }',
384
385      // 聊天消息区 — 窗口620px → body 370px保持协调
386      '#smart-kf-body { flex: 1; overflow-y: auto; padding: 14px 18px; background: #f7f8fa; display: flex; flex-direction: column; gap: 10px; max-height: 370px; }',
387      '.skf-message { max-width: 90%; word-wrap: break-word; animation: skf-fadeIn 0.3s ease; }',
388      '@keyframes skf-fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }',
389      '.skf-msg-agent { align-self: flex-start; display: flex; align-items: center; gap: 8px; }',
390      '.skf-avatar-rings { display: inline-flex; align-items: center; flex-shrink: 0; }',
391      '.skf-msg-user { align-self: flex-end; }',
392      '.skf-bubble { padding: 11px 15px; border-radius: 16px; font-size: 14.5px; line-height: 1.55; }',
393      '.skf-msg-agent .skf-bubble { background: #fff; border: 1px solid #e8eaed; border-bottom-left-radius: 4px; color: #333; }',
394      '.skf-msg-user .skf-bubble { background: #e53935; color: #fff; border-bottom-right-radius: 4px; }',
395      '.skf-msg-typing .skf-bubble { padding: 10px 16px; }',
396      '.skf-typing-dots { display: flex; gap: 4px; }',
397      '.skf-typing-dots span { width: 7px; height: 7px; border-radius: 50%; background: #bbb; animation: skf-typing 1.4s infinite; }',
398      '.skf-typing-dots span:nth-child(2) { animation-delay: 0.2s; }',
399      '.skf-typing-dots span:nth-child(3) { animation-delay: 0.4s; }',
400      '@keyframes skf-typing { 0%,60%,100% { transform: translateY(0); opacity: 0.5; } 30% { transform: translateY(-4px); opacity: 1; } }',
401      '.skf-stream-cursor { display: inline; opacity: 1; animation: skf-blink 0.5s infinite; color: #e53935; }',
402      '@keyframes skf-blink { 0%,100% { opacity: 1; } 50% { opacity: 0; } }',
403
404      // 快捷问题 & 留资按钮
405      '#smart-kf-quick { display: flex; gap: 7px; flex-wrap: wrap; padding: 0 18px 8px; flex-shrink: 0; }',
406      '.skf-quick-btn { padding: 8px; background: #fff; border: 1px solid #ef9a9a; border-radius: 18px; font-size: 12.5px; color: #e53935; cursor: pointer; white-space: nowrap; transition: all 0.15s; justify-content: center; display: flex; flex: 1; min-width: 0; text-align: center; }',
407      '.skf-quick-btn:hover { background: #fce4ec; border-color: #e53935; }',
408      // v15.7: 消息内高亮样式(红=价值锚点/行动,蓝=信息提示)
409      '.skf-highlight-red { color: #e53935; font-weight: 600; }',
410      '.skf-highlight-blue { color: #1565c0; font-weight: 600; }',
411      // v15.7: 消息下方快捷按钮行(达内风格,紧跟消息气泡)
412      '.skf-msg-options { display: flex; gap: 8px; padding: 0 0 6px 54px; flex-wrap: wrap; }',
413      '.skf-msg-opt-btn { padding: 5px 14px; background: #fce4ec; border: 1px solid #ef9a9a; border-radius: 14px; font-size: 12.5px; color: #e53935; cursor: pointer; white-space: nowrap; transition: all 0.15s; }',
414      '.skf-msg-opt-btn:hover { background: #f8bbd0; border-color: #e53935; }',
415      '#smart-kf-lead-btn { display: block; margin: 4px 18px 8px; padding: 9px 14px; background: #fff; border: 1.5px dashed #e53935; border-radius: 12px; font-size: 13px; color: #e53935; cursor: pointer; text-align: center; transition: all 0.15s; flex-shrink: 0; }',
416      '#smart-kf-lead-btn:hover { background: #fce4ec; }',
417      '#smart-kf-lead-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255,255,255,0.98); z-index: 10; display: none; flex-direction: column; padding: 24px 20px; overflow-y: auto; }',
418      '#smart-kf-lead-overlay.open { display: flex; }',
419      '.skf-lead-title { font-size: 17px; font-weight: 600; text-align: center; margin-bottom: 20px; color: #e53935; }',
420      '.skf-lead-input { width: 100%; border: 1px solid #ef9a9a; border-radius: 10px; padding: 11px 14px; font-size: 14px; margin-bottom: 12px; outline: none; box-sizing: border-box; transition: border-color 0.2s; }',
421      '.skf-lead-input:focus { border-color: #e53935; }',
422      '.skf-lead-select { width: 100%; border: 1px solid #ef9a9a; border-radius: 10px; padding: 11px 14px; font-size: 14px; margin-bottom: 12px; outline: none; background: #fff; box-sizing: border-box; appearance: auto; }',
423      '.skf-lead-select:focus { border-color: #e53935; }',
424      '.skf-lead-textarea { width: 100%; border: 1px solid #ef9a9a; border-radius: 10px; padding: 11px 14px; font-size: 14px; margin-bottom: 12px; outline: none; resize: vertical; min-height: 70px; font-family: inherit; box-sizing: border-box; transition: border-color 0.2s; }',
425      '.skf-lead-textarea:focus { border-color: #e53935; }',
426      '.skf-lead-submit { width: 100%; padding: 12px; background: #e53935; color: #fff; border: none; border-radius: 10px; font-size: 15px; font-weight: 600; cursor: pointer; margin-top: 6px; transition: background 0.2s; }',
427      '.skf-lead-submit:hover { background: #c62828; }',
428      '.skf-lead-back { width: 100%; padding: 10px; background: none; border: none; color: #999; font-size: 13px; cursor: pointer; margin-top: 8px; }',
429
430      // 底部输入区 — 输入框加大至56px(用户体验,太小用着难受)
431      '#smart-kf-footer { padding: 13px 18px 15px; background: #fff; border-top: 1px solid #eee; display: flex; gap: 10px; align-items: center; flex-shrink: 0; }',
432      '#smart-kf-input { flex: 1; border: 1px solid #ef9a9a; border-radius: 22px; outline: none; font-size: 14px; resize: none; height: 56px; line-height: 24px; font-family: inherit; background: #f7f8fa; transition: border-color 0.2s; padding: 14px 16px; }',
433      '#smart-kf-input:focus { border-color: #e53935; background: #fff; }',
434      '#smart-kf-send { width: 44px; height: 44px; border-radius: 50%; background: #e53935; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: background 0.2s; }',
435      '#smart-kf-send:hover { background: #c62828; }',
436
437      '#smart-kf-send svg { width: 22px; height: 22px; fill: #fff; }',
438
439      // ============ v14: 右侧推广侧边栏 190px ============
440      '#smart-kf-right { width: ' + RIGHT_WIDTH + 'px; flex-shrink: 0; display: flex; flex-direction: column; background: #fff5f5; overflow: hidden; }',
441
442      // 顶部品牌区 — 竖排两行文字居中(初心至善 / 匠心育人),无Logo
443      '.skf-sidebar-poster { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 13px 10px; background: linear-gradient(135deg, #e53935, #b71c1c); color: #fff; flex-shrink: 0; cursor: pointer; transition: opacity 0.2s; min-height: 73px; box-sizing: border-box; }',
444      '.skf-sidebar-poster:hover { opacity: 0.92; }',
445      '.skf-sidebar-poster-text { font-size: 14px; font-weight: 600; letter-spacing: 2px; line-height: 1.6; }',
446
447      // 课程入口 — 可滚动,确保15门课程全部可访问
448      '.skf-sidebar-section { padding: 6px 12px 10px; flex: 1; overflow-y: auto; min-height: 0; }',
449      '.skf-sidebar-label { font-size: 12px; font-weight: 600; color: #e53935; margin-bottom: 6px; padding: 4px 2px 6px; position: sticky; top: 0; background: #fff5f5; z-index: 1; letter-spacing: 0.5px; }',
450      '.skf-sidebar-course { display: block; width: 100%; padding: 8px 10px; margin-bottom: 4px; background: #fff; border: 1px solid #ffcdd2; border-radius: 8px; font-size: 12px; color: #444; cursor: pointer; text-align: left; transition: all 0.15s; line-height: 1.35; }',
451      '.skf-sidebar-course:hover { background: #fce4ec; border-color: #e53935; color: #e53935; }',
452
453      // 底部CTA
454      '.skf-sidebar-cta { padding: 12px 12px 12px; margin-top: auto; flex-shrink: 0; }',
455      '.skf-sidebar-cta-btn { display: block; width: 100%; padding: 11px 10px; background: linear-gradient(135deg, #e53935, #c62828); color: #fff; border: none; border-radius: 10px; font-size: 13.5px; font-weight: 600; cursor: pointer; text-align: center; transition: all 0.2s; box-shadow: 0 2px 8px rgba(229,57,53,0.3); }',
456      '.skf-sidebar-cta-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(229,57,53,0.4); }',
457
458      // 响应式: 移动端隐藏侧边栏
459      '@media (max-width: 640px) { ' +
460        '#smart-kf-window { right: 0; bottom: 0; width: 100%; max-height: 100%; height: 100%; border-radius: 0; } ' +
461        '#smart-kf-left { width: 100%;
461 border-right: none; } ' +
462        '#smart-kf-right { display: none; } ' +
463        '#smart-kf-body { max-height: none; flex: 1; } ' +
464      '}',
465
466      // v21.0: 版本角标 — 聊天窗口右下角,动态读取 window.SMART_KF_VERSION
467      '#skf-version-badge { position: absolute; bottom: 2px; right: 6px; font-size: 10px; color: rgba(0,0,0,0.25); pointer-events: none; z-index: 1; transition: color 0.2s; }',
468      '#smart-kf-window:hover #skf-version-badge { color: rgba(0,0,0,0.5); }'
469    ].join('\n');
470
471    var style = document.createElement('style');
472    style.textContent = css;
473    document.head.appendChild(style);
474  }
475
476  function buildSidebar() {
477    var sidebar = CONFIG.sidebar;
478    if (!sidebar.enabled) return null;
479
480    var children = [];
481
482    // === 顶部品牌区(初心至善 匠心育人 居中,无Logo) ===
483    var posterChildren = [
484      el('span', { className: 'skf-sidebar-poster-text' }, '初心至善'),
485      el('span', { className: 'skf-sidebar-poster-text' }, '匠心育人')
486    ];
487
488    var $poster = el('div', {
489      className: 'skf-sidebar-poster',
490      onClick: function() {
491        if (STATE.open) showLeadForm();
492      }
493    }, posterChildren);
494    children.push($poster);
495
496    // === 热门课程 ===
497    var courseSection = [el('div', { className: 'skf-sidebar-label' }, '热门课程')];
498    sidebar.courses.forEach(function(c) {
499      courseSection.push(el('button', {
500        className: 'skf-sidebar-course',
501        onClick: function() {
502          var now = Date.now();
503          var cooldown = sidebar.courseClickCooldown || 5000;
504          if (STATE.lastCourseClick && now - STATE.lastCourseClick < cooldown) return;
505          STATE.lastCourseClick = now;
506          stopInfoEngine();
507          sendUserMessage(c.trigger);
508        }
509      }, c.label));
510    });
511
512    children.push(el('div', { className: 'skf-sidebar-section' }, courseSection));
513
514    // === 底部CTA ===
515    var $cta = el('button', {
516      className: 'skf-sidebar-cta-btn',
517      onClick: function() {
518        if (sidebar.cta.action === 'leadForm' || sidebar.cta.action === 'openChat') {
519          showLeadForm();
520        }
521      }
522    }, sidebar.cta.text);
523    children.push(el('div', { className: 'skf-sidebar-cta' }, [$cta]));
524
525    return el('div', { id: 'smart-kf-right' }, children);
526  }
527
528  function buildWidget() {
529    $root = el('div', { id: 'smart-kf-root' });
530
531    $btn = el('div', { id: 'smart-kf-btn', onClick: toggleWindow }, [
532      el('span', { className: 'skf-chat-svg', innerHTML: '<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z"/><path d="M7 9h10v2H7zm0 3h7v2H7z"/></svg>' }),
533      el('span', { className: 'skf-close-svg', innerHTML: '<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>' })
534    ]);
535
536    $window = el('div', { id: 'smart-kf-window' });
537
538    // ===== 头部 =====
539    var $header = el('div', { id: 'smart-kf-header' }, [
540      CONFIG.agent.avatarImg ? el('img', { className: 'skf-agent-avatar', src: CONFIG.avatarUrl, alt: '' }) : el('span', { style: { fontSize: '28px' } }, ''),
541      el('div', {}, [
542        el('div', { className: 'skf-agent-name' }, CONFIG.agent.name),
543        el('div', { className: 'skf-agent-status' }, '千锋教育智能客服正在为您服务')
544      ]),
545      el('button', { id: 'smart-kf-close', onClick: closeWindow }, '\u00d7')
546    ]);
547
548    // ===== 聊天消息区 =====
549    $body = el('div', { id: 'smart-kf-body' });
550
551    // ===== 快捷问题 =====
552    var $quick = el('div', { id: 'smart-kf-quick' });
553    CONFIG.quickQuestions.forEach(function(q) {
554      $quick.appendChild(el('button', { className: 'skf-quick-btn', onClick: function() { onQuickClick(q); } }, q));
555    });
556
557    // ===== 输入区 =====
558    var $footer = el('div', { id: 'smart-kf-footer' }, [
559      el('textarea', { id: 'smart-kf-input', placeholder: '输入您的问题...', rows: 1, onkeydown: function(e) {
560        if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
561      }}),
562      el('button', { id: 'smart-kf-send', onClick: sendMessage, innerHTML: '<svg viewBox="0 0 24 24">
562<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>' })
563    ]);
564
565    // ===== 留资覆盖层 =====
566    var $leadOverlay = el('div', { id: 'smart-kf-lead-overlay' });
567
568    // ===== 左侧聊天区 =====
569    var $left = el('div', { id: 'smart-kf-left' });
570    $left.appendChild($header);
571    $left.appendChild($body);
572    $left.appendChild($quick);
573    $left.appendChild($footer);
574
575    // ===== v14: 双栏主行 =====
576    var $mainRow = el('div', { id: 'smart-kf-main-row' });
577    $mainRow.appendChild($left);
578
579    // 右侧推广侧边栏
580    var $right = buildSidebar();
581    if ($right) $mainRow.appendChild($right);
582
583    $window.appendChild($mainRow);
584    $window.appendChild($leadOverlay);
585
586    // v21.0: 版本角标 — 动态读取 window.SMART_KF_VERSION,回退 CONFIG.currentVersion
587    var _vBadge = el('span', { id: 'skf-version-badge' });
588    _vBadge.textContent = 'SKF v' + (window.SMART_KF_VERSION || CONFIG.currentVersion);
589    $window.appendChild(_vBadge);
590
591    $root.appendChild($btn);
592    $root.appendChild($window);
593    document.body.appendChild($root);
594
595    $input = document.getElementById('smart-kf-input');
596    $sendBtn = document.getElementById('smart-kf-send');
597  }
598
599  // ==================== 消息处理 ====================
600  function addMessage(type, text) {
601    STATE.messages.push({ type: type, text: text, time: new Date().toISOString() });
602    renderMessage(type, text);
603    scrollToBottom();
604
605    if (type === 'agent') {
606      playNotificationSound();
607    }
608  }
609
610  function renderMessage(type, text) {
611    var children = [];
612    
613    if (type === 'agent') {
614      // v14: 使用带彩色边框环的大头像
615      children.push(makeAvatarRings(AVATAR_SIZE));
616    }
617
618    // v15.7: 消息内容高亮 — 将【...】替换为带样式的span
619    var highlighted = text
620      .replace(/\n/g, '<br>')
621      // 【红色高亮】→ 行动号召/价值锚点
622      .replace(/【(.*?)】/g, '<span class="skf-highlight-red">$1</span>');
623
624    children.push(el('div', { className: 'skf-bubble', innerHTML: highlighted }));
625
626    var $msg = el('div', { className: 'skf-message skf-msg-' + type }, children);
627    $body.appendChild($msg);
628
629    // v15.7: 动态快捷按钮 — AI 回复下方显示可选按钮(达内风格)
630    if (type === 'agent' && text) {
631      var options = detectQuickOptions(text);
632      if (options.length > 0) {
633        var $opts = el('div', { className: 'skf-msg-options' });
634        options.forEach(function(opt) {
635          $opts.appendChild(el('button', {
636            className: 'skf-msg-opt-btn',
637            onClick: function() { sendUserMessage(opt); }
638          }, opt));
639        });
640        $body.appendChild($opts);
641      }
642    }
643  }
644
645  // v15.7: 根据AI回复内容,自动匹配应显示的快捷选项按钮
646  function detectQuickOptions(text) {
647    var t = text;
648    // 状态询问 → 在职/在校/待业
649    if (/在职|在校|待业|工作状态|目前状态/i.test(t) && /\?|?/.test(t)) {
650      return ['在职', '在校', '待业'];
651    }
652    // 基础询问 → 零基础/想提升
653    if (/零基础|0基础|基础|接触|提升|转行|兴趣/i.test(t) && /\?|?/.test(t)) {
654      return ['零基础', '想提升'];
655    }
656    // 课程方向 → 常见方向快捷入口
657    if (/想学.*课程|哪个方向|课程方向|感兴趣|什么技术/i.test(t) && /\?|?/.test(t)) {
658      return ['Java', 'Python', '前端', 'AI人工智能', '测试'];
659    }
660    // 学习目的 → 转行/提升/兴趣
661    if (/转行|提升|兴趣|目的|想.*学/i.test(t) && /\?|?/.test(t)) {
662      return ['想转行', '想提升', '感兴趣'];
663    }
664    return [];
665  }
666
667  var $typingMsg = null;
668  function addTyping() {
669    if ($typingMsg) return;
670    // v14: 打字指示器也带大头像环
671    var children = [
672      makeAvatarRings(AVATAR_SIZE),
673      el('div', { className: 'skf-bubble', innerHTML: '<div class="skf-typing-dots"><span></span><span></span><span></span></div>' })
674    ];
675    $typingMsg = el('div', { className: 'skf-message skf-msg-agent skf-msg-typing' }, children);
676    $body.appendChild($typingMsg);
677    scrollToBottom();
678  }
679
680  function removeTyping() {
681    if ($typingMsg) { $typingMsg.remove(); $typingMsg = null; }
682  }
683
684  function scrollToBottom() {
685    setTimeout(function() { $body.scrollTop = $body.scrollHeight; }, 50);
686  }
687
688var _lastSentText = '';     // 内容去重:记录上次发送的文本
689var _lastSentTime = 0;      // 内容去重:上次发送时间戳
690var _quickCooldown = 3000;  // 快捷按钮冷却:3秒内同一条快捷问题不可重复触发
691var _lastQuickClick = 0;    // 上次快捷按钮点击时间
692
693function sendMessage() {
694    var text = $input.value.trim();
695    if (!text) return;
696    $input.value = '';
697    sendUserMessage(text);
698}
699
700function onQuickClick(text) {
701    var now = Date.now();
702    if (now - _lastQuickClick < _quickCooldown) {
703        console.log('[Smart-KF] 快捷按钮冷却中,忽略点击: ' + text);
704        return;
705    }
706    _lastQuickClick = now;
707    sendUserMessage(text);
708}
709
710  // ==================== SSE 流式调用 ====================
711  var $streamBubble = null;
712  var $streamContainer = null;
713
714  function callAPIStream(message, onFirstToken, onToken, onDone) {
715    if (!STATE.sessionId) {
716      STATE.sessionId = storageGet('session_id') || '';
717    }
718
719    var _finished = false;  // 防止重复调用 finishStream
720
721    fetch(CONFIG.apiBase + '/chat/stream', {
722      method: 'POST',
723      headers: {
724        'Content-Type': 'application/json',
725        'X-API-Token': CONFIG.apiToken
726      },
727      body: JSON.stringify({
728        message: message,
729        session_id: STATE.sessionId,
730        source: STATE.userInfo.source || detectSource(),
731        landing_page: STATE.userInfo.landingPage,
732        current_page: STATE.userInfo.currentPage
733      })
734    }).then(function(response) {
735      if (!response.ok) throw new Error('HTTP ' + response.status);
736
737      var reader = response.body.getReader();
738      var decoder = new TextDecoder();
739      var buffer = '';
740      var fullReply = '';
741      var firstToken = true;
742      var doneData = null;
743      // v21.7: 首token 12秒无回复 -> 提示网络繁忙(避免一直"思考中")
744      var _firstTokenTimer = setTimeout(function() {
745        if (firstToken && !_finished) {
746          try { reader.cancel(); } catch(e) {}
747          finishStream(new Error('timeout'), '', null);
748        }
749      }, 12000);
750
751      function processChunk() {
752        reader.read().then(function(result) {
753          if (result.done) {
754            if (!_finished) {
755              _finished = true;
756              finishStream(null, fullReply, doneData);
757            }
758            return;
759          }
760
761          buffer += decoder.decode(result.value, {stream: true});
762          var lines = buffer.split('\n');
763          buffer = lines.pop() || '';
764
765          for (var i = 0; i < lines.length; i++) {
766            var line = lines[i].trim();
767            if (!line || !line.startsWith('data: ')) continue;
768
769            try {
770              var data = JSON.parse(line.slice(6));
771              if (data.session_id) {
772                STATE.sessionId = data.session_id;
773                storageSet('session_id', data.session_id);
774              }
775              if (data.token) {
776                if (firstToken) {
777                  firstToken = false;
778                  clearTimeout(_firstTokenTimer);
779                  onFirstToken();
780                }
781                fullReply += data.token;
782                onToken(data.token, fullReply);
783              }
784              if (data.done) {
785                doneData = data;
786              }
787              if (data.error) {
788                if (!_finished) {
789                  _finished = true;
790                  finishStream(new Error(data.error), fullReply, null);
791                }
792                return;
793              }
794            } catch(e) {}
795          }
796
797          processChunk();
798        }).catch(function(err) {
799          if (!_finished) {
800            _finished = true;
801            finishStream(err, fullReply, null);
802          }
803        });
804      }
805
806      processChunk();
807    }).catch(function(err) {
808      if (!_finished) {
809        _finished = true;
810        finishStream(err, '', null);
811      }
812    });
813
814    function finishStream(err, fullReply, doneExtra) {
815      if (_finished && err) return;  // 已经通过其他路径finish过,不再重复
816      _finished = true;
817
818      if ($streamBubble) {
819        var cursor = $streamBubble.querySelector('.skf-stream-cursor');
820        if (cursor) cursor.remove();
821        $streamBubble = null;
822        $streamContainer = null;
823      }
824
825      onDone(err, fullReply, doneExtra);
826    }
827  }
828
829  // v19.2: 创建流式输出气泡(从 onFirstToken 中独立出来,便于前端延迟控制)
830  function showStreamBubble() {
831    var $msg = el('div', { className: 'skf-message skf-msg-agent' });
832    // v14: 流式消息也带大头像环
833    $msg.appendChild(makeAvatarRings(AVATAR_SIZE));
834    $streamContainer = el('div', {});
835    $streamBubble = el('div', { className: 'skf-bubble' });
836    $streamBubble.innerHTML = '<span class="skf-stream-cursor">|</span>';
837    $streamContainer.appendChild($streamBubble);
838    $msg.appendChild($streamContainer);
839    $body.appendChild($msg);
840    scrollToBottom();
841  }
842
843  function sendUserMessage(text) {
844    // 去重:相同文本5秒内不重复发送(覆盖输入框+快捷按钮+侧边栏课程)
845    var now = Date.now();
846    if (text === _lastSentText && (now - _lastSentTime) < 5000) {
847      console.log('[Smart-KF] 重复消息已拦截(5秒时间窗): ' + text);
848      return;
849    }
850
851    // 用户说话了 → 信息引擎停止,AI接管
852    STATE.userHasSpoken = true;
853    stopInfoEngine();
854
855    _lastSentText = text;
856    _lastSentTime = now;
857
858    addMessage('user', text);
859    addTyping();
860
861    if (CONFIG.streamEnabled && window.fetch && window.ReadableStream) {
862      // 原子化传输:只走SSE,失败不降级XHR(杜绝双发)
863      callAPIStream(text,
864        function() {
865          // 首字到达 → 立即显示流式气泡,不做人为延迟
866          removeTyping();
867          showStreamBubble();
868        },
869        function(token, fullText) {
870          if ($streamBubble) {
871            $streamBubble.innerHTML = fullText.replace(/\n/g, '<br>') + '<span class="skf-stream-cursor">|</span>';
872            scrollToBottom();
873          }
874        },
875        function(err, reply, extraData) {
876          if (err) {
877            removeTyping();
878            if (reply && reply.length > 0) {
879              // 流式中断但已收到部分回复 → 显示已有内容
880              addMessage('agent', reply);
881              console.log('[Smart-KF] 流式中断,已显示部分回复 (' + reply.length + '字)');
882            } else {
883              // 完全失败 → 友好提示,用户可手动重发
884              addMessage('agent', '网络波动,请重新发送您的问题~');
885              console.log('[Smart-KF] 流式失败:', err.message || err);
886            }
887            return;
888          }
889
890          // SSE成功 → 正常处理回复
891          STATE.messages.push({ type: 'agent', text: reply, time: new Date().toISOString() });
892          if (extraData) {
893            STATE.sessionId = extraData.session_id;
894            storageSet('session_id', extraData.session_id);
895            if (extraData.user_info) {
896              STATE.userInfo.name = extraData.user_info.name || STATE.userInfo.name;
897              STATE.userInfo.phone = extraData.user_info.phone || STATE.userInfo.phone;
898              STATE.userInfo.course = extraData.user_info.course || STATE.userInfo.course;
899              STATE.userInfo.status = extraData.user_info.status || STATE.userInfo.status;
900            }
901          }
902          playNotificationSound();
903        }
904      );
905    } else {
906      // 非SSE浏览器 → 只走XHR
907      callAPIXHR(text, function(err, reply) {
908        removeTyping();
909        var finalReply = (err || !reply) ? getFallbackReply(text) : reply;
910        addMessage('agent', finalReply);
911      });
912    }
913  }
914
915  // ==================== XHR 降级调用 ====================
916  function callAPIXHR(message, callback) {
917    if (!STATE.sessionId) {
918      STATE.sessionId = storageGet('session_id') || '';
919    }
920
921    var xhr = new XMLHttpRequest();
922    xhr.open('POST', CONFIG.apiBase + '/chat', true);
923    xhr.setRequestHeader('Content-Type', 'application/json');
924    xhr.setRequestHeader('X-API-Token', CONFIG.apiToken);
925    xhr.timeout = 30000;
926
927    xhr.onload = function() {
928      if (xhr.status === 200) {
929        try {
930          var data = JSON.parse(xhr.responseText);
931          STATE.sessionId = data.session_id;
932          storageSet('session_id', data.session_id);
933
934          if (data.user_info) {
935            STATE.userInfo.name = data.user_info.name || STATE.userInfo.name;
936            STATE.userInfo.phone = data.user_info.phone || STATE.userInfo.phone;
937            STATE.userInfo.course = data.user_info.course || STATE.userInfo.course;
938            STATE.userInfo.status = data.user_info.status || STATE.userInfo.status;
939          }
940
941          callback(null, data.reply);
942        } catch(e) {
943          callback(e, null);
944        }
945      } else {
946        callback(new Error('HTTP ' + xhr.status), null);
947      }
948    };
949
950    xhr.onerror = function() { callback(new Error('网络错误'), null); };
951    xhr.ontimeout = function() { callback(new Error('请求超时'), null); };
952
953    xhr.send(JSON.stringify({
954      message: message,
955      session_id: STATE.sessionId,
956      source: STATE.userInfo.source || detectSource(),
957      landing_page: STATE.userInfo.landingPage,
958      current_page: STATE.userInfo.currentPage
959    }));
960  }
961
962  // ==================== 补推触发 ====================
963  // 策略:
964  //   1. 用户关闭对话框时检查是否有新对话,有则补推
965  //   2. 对话框保持开启时,每5分钟轮询一次,有新对话则补推
966  //   两种情况都由后端 has_new_messages 判断,无新消息不推
967
968  function startPeriodicPushTimer() {
969    clearPeriodicPushTimer();
970    STATE.periodicPushTimer = setInterval(function() {
971      if (STATE.open && STATE.sessionId) {
972        triggerPushCheck();
973      }
974    }, CONFIG.periodicPushInterval);
975  }
976
977  function clearPeriodicPushTimer() {
978    if (STATE.periodicPushTimer) {
979      clearInterval(STATE.periodicPushTimer);
980      STATE.periodicPushTimer = null;
981    }
982  }
983
984  // triggerPushCheck(onClose)
985  // onClose=true: 用户关闭对话框时触发,允许 force(绕过等待期)
986  // onClose=false/undefined: 定时检查,严格判断是否有新消息
987  function triggerPushCheck(onClose) {
988    if (!STATE.sessionId) return;
989
990    var xhr = new XMLHttpRequest();
991    xhr.open('POST', CONFIG.apiBase + '/trigger-push', true);
992    xhr.setRequestHeader('Content-Type', 'application/json');
993    xhr.setRequestHeader('X-API-Token', CONFIG.apiToken);
994    xhr.timeout = 10000;
995
996    xhr.onload = function() {
997      if (xhr.status === 200) {
998        try {
999          var data = JSON.parse(xhr.responseText);
1000          if (data.pushed) {
1001            console.log('[Smart-KF] 补推已触发 (onClose=' + (onClose ? 'true' : 'false') + ')');
1002          }
1003        } catch(e) {}
1004      }
1005    };
1006
1007    xhr.send(JSON.stringify({
1008      session_id: STATE.sessionId,
1009      on_close: !!onClose
1010    }));
1011  }
1012
1013  // ==================== 降级回复 ====================
1014  function getFallbackReply(text) {
1015    var t = text.toLowerCase();
1016
1017    if (t.indexOf('课程') !== -1 || t.indexOf('培训') !== -1) {
1018      return '我们有前端、Java、Python等多个IT方向课程。您对哪个感兴趣?方便留个电话,让顾问给您详细介绍~';
1019    }
1020    if (t.indexOf('学费') !== -1 || t.indexOf('价格') !== -1 || t.indexOf('多少钱') !== -1) {
1021      return '学费一般在几千到3万之间,编程类大概1.5-2.5万,UI设计大概1-2万,具体看方向和班型。方便留个手机号吗?我让顾问给您发详细报价单~';
1022    }
1023    if (t.indexOf('校区') !== -1 || t.indexOf('地址') !== -1 || t.indexOf('在哪') !== -1) {
1024      return '我们在全国多个城市都有校区。您在哪个城市?方便留个电话让当地校区顾问联系您~';
1025    }
1026    if (t.indexOf('就业') !== -1 || t.indexOf('工作') !== -1) {
1027      return '我们提供简历指导、面试辅导、企业内推等就业服务。方便留个电话,让就业老师帮您分析一下~';
1028    }
1029    if (t.indexOf('在读') !== -1 || t.indexOf('在职') !== -1 || t.indexOf('待业') !== -1) {
1030      return '收到!方便留个电话吗?让课程顾问针对您的情况做个详细的学习规划~';
1031    }
1032    if (t.indexOf('你好') !== -1 || t.indexOf('hi') !== -1) {
1033      return '你好~请问想了解什么课程呢?方便的话留个电话,让顾问给您详细介绍~';
1034    }
1035    return '感谢您的咨询!方便留个电话吗?让课程顾问直接联系您详细介绍~';
1036  }
1037
1038  // ==================== 留资表单 ====================
1039  function showLeadForm() {
1040    var $overlay = document.getElementById('smart-kf-lead-overlay');
1041    if (!$overlay || $overlay.classList.contains('open')) return;
1042
1043    $window.style.minHeight = '520px';
1044    $overlay.innerHTML = '';
1045
1046    var title = el('div', { className: 'skf-lead-title' }, '预约免费试听');
1047
1048    var $nameInput = el('input', { className: 'skf-lead-input', placeholder: '您的姓名 *', value: STATE.userInfo.name });
1049    var $phoneInput = el('input', { className: 'skf-lead-input', placeholder: '手机号码 *', value: STATE.userInfo.phone, type: 'tel', maxlength: 11 });
1050
1051    var courses = ['', 'Java开发(全链路/JavaEE)', 'Python+大数据分析', 'Web前端(HTML5/全栈/鸿蒙)', 'AI人工智能(大模型/AIGC/机器视觉)', '云计算/运维(Linux/DevOps)', '网络安全', 'C++/物联网/嵌入式', 'UI/UE设计(UID/视效设计)', '软件测试(含车载测试)', '短视频+直播电商(新媒体运营)', '游戏开发/VR', '智能职场办公(B端产品)', '新能源运维工程师', '无人机驾驶执照', '认证考试(PMP/红帽/工信人才)', '其他IT方向'];
1052    var $courseSelect = el('select', { className: 'skf-lead-select' });
1053    courses.forEach(function(c, i) {
1054      $courseSelect.appendChild(el('option', { value: i === 0 ? '' : c, selected: c === STATE.userInfo.course ? 'selected' : undefined }, c || '请选择咨询方向'));
1055    });
1056
1057    var statuses = ['', '在读', '在职', '待业'];
1058    var $statusSelect = el('select', { className: 'skf-lead-select' });
1059    statuses.forEach(function(s) {
1060      $statusSelect.appendChild(el('option', { value: s, selected: s === STATE.userInfo.status ? 'selected' : undefined }, s || '请选择当前状态'));
1061    });
1062
1063    // 新增:留言框
1064    var $messageInput = el('textarea', { className: 'skf-lead-textarea', placeholder: '留言(选填),例如:想了解开班时间、学费、上课方式等...', rows: 2 });
1065
1066    var $submit = el('button', { className: 'skf-lead-submit' }, '提交预约');
1067
1068    $submit.addEventListener('click', function() {
1069      if ($submit.disabled) return;
1070      submitLeadForm($nameInput, $phoneInput, $courseSelect, $statusSelect, $messageInput, $submit);
1071    });
1072
1073    var $back = el('button', { className: 'skf-lead-back', onClick: hideLeadForm }, '返回对话');
1074
1075    $overlay.appendChild(title);
1076    $overlay.appendChild($nameInput);
1077    $overlay.appendChild($phoneInput);
1078    $overlay.appendChild($courseSelect);
1079    $overlay.appendChild($statusSelect);
1080    $overlay.appendChild($messageInput);
1081    $overlay.appendChild($submit);
1082    $overlay.appendChild($back);
1083
1084    $overlay.classList.add('open');
1085  }
1086
1087  function hideLeadForm() {
1088    var $overlay = document.getElementById('smart-kf-lead-overlay');
1089    if ($overlay) $overlay.classList.remove('open');
1090    $window.style.minHeight = '';
1091  }
1092
1093  function submitLeadForm($nameInput, $phoneInput, $courseSelect, $statusSelect, $messageInput, $submitBtn) {
1094    var name = $nameInput.value.trim();
1095    var phone = $phoneInput.value.trim();
1096    var course = $courseSelect.value;
1097    var status = $statusSelect.value;
1098    var message = $messageInput ? $messageInput.value.trim() : '';
1099
1100    if (!name) { alert('请输入您的姓名'); return; }
1101    if (!phone) { alert('请输入手机号码'); return; }
1102    if (!/^1[3-9]\d{9}$/.test(phone)) { alert('请输入正确的手机号码'); return; }
1103
1104    STATE.userInfo.name = name;
1105    STATE.userInfo.phone = phone;
1106    if (course) STATE.userInfo.course = course;
1107    if (status) STATE.userInfo.status = status;
1108    STATE.userInfo.source = detectSource();
1109    STATE.userInfo.landingPage = document.referrer || '直接访问';
1110    STATE.userInfo.currentPage = window.location.href;
1111
1112    $submitBtn.disabled = true;
1113    $submitBtn.textContent = '提交中...';
1114
1115    var xhr = new XMLHttpRequest();
1116    xhr.open('POST', CONFIG.apiBase + '/submit-lead', true);
1117    xhr.setRequestHeader('Content-Type', 'application/json');
1118    xhr.setRequestHeader('X-API-Token', CONFIG.apiToken);
1119    xhr.timeout = 15000;
1120
1121    xhr.onload = function() {
1122      $submitBtn.disabled = false;
1123      $submitBtn.textContent = '提交';
1124
1125      if (xhr.status === 200) {
1126        try {
1127          var data = JSON.parse(xhr.responseText);
1128          hideLeadForm();
1129
1130          if (data.success) {
1131            STATE.leadSubmitted = true;
1132
1133            var confirmMsg = '已收到您的预约!<br><br>';
1134            if (name) confirmMsg += '姓名:<b>' + name + '</b><br>';
1135            if (phone) confirmMsg += '电话:<b>' + phone + '</b><br>';
1136            if (course) confirmMsg += '咨询课程:<b>' + course + '</b><br>';
1137            if (status) confirmMsg += '当前状态:<b>' + status + '</b><br>';
1138            if (message) confirmMsg += '留言:<b>' + message + '</b><br>';
1139            confirmMsg += '<br>课程顾问会尽快与您联系,请保持手机畅通。';
1140
1141            addMessage('agent', confirmMsg);
1142
1143            var missing = [];
1144            if (!course) missing.push('想咨询的课程方向');
1145            if (!status) missing.push('当前的状态(在读/在职/待业)');
1146            if (missing.length > 0) {
1147              setTimeout(function() {
1148                addMessage('agent', '对了,您方便说一下<b>' + missing.join('、') + '</b>吗?这样顾问联系您的时候可以更有针对性~');
1149              }, 1500);
1150            }
1151          } else {
1152            alert('提交失败:' + (data.message || '请稍后重试'));
1153          }
1154        } catch(e) {
1155          alert('提交失败,请稍后重试');
1156        }
1157      } else {
1158        alert('提交失败(' + xhr.status + '),请稍后重试');
1159      }
1160    };
1161
1162    xhr.onerror = function() {
1163      $submitBtn.disabled = false;
1164      $submitBtn.textContent = '提交';
1165      alert('网络错误,请稍后重试');
1166    };
1167
1168    xhr.send(JSON.stringify({
1169      name: name,
1170      phone: phone,
1171      course: course,
1172      status: status,
1173      message: message,
1174      source: STATE.userInfo.source,
1175      landing_page: STATE.userInfo.landingPage,
1176      current_page: STATE.userInfo.currentPage,
1177      session_id: STATE.sessionId
1178    }));
1179  }
1180
1181  // ==================== 来源识别 ====================
1182  function extractSearchKeyword(ref) {
1183    try {
1184      var url = new URL(ref);
1185      var host = url.hostname;
1186      var kw = '';
1187      if (host.indexOf('baidu.com') !== -1) {
1188        kw = url.searchParams.get('wd') || url.searchParams.get('word') || '';
1189      } else if (host.indexOf('google.com') !== -1 || host.indexOf('google.co') !== -1) {
1190        kw = url.searchParams.get('q') || '';
1191      } else if (host.indexOf('sogou.com') !== -1) {
1192        kw = url.searchParams.get('query') || '';
1193      } else if (host.indexOf('bing.com') !== -1) {
1194        kw = url.searchParams.get('q') || '';
1195      } else if (host.indexOf('360.cn') !== -1 || host.indexOf('so.com') !== -1) {
1196        kw = url.searchParams.get('q') || '';
1197      } else if (host.indexOf('sm.cn') !== -1) {
1198        kw = url.searchParams.get('q') || '';
1199      } else if (host.indexOf('zhihu.com') !== -1) {
1200        kw = url.searchParams.get('q') || url.searchParams.get('search') || '';
1201      }
1202      if (kw) {
1203        try { return decodeURIComponent(kw); } catch(e2) { return kw; }
1204      }
1205    } catch(e) {}
1206    return '';
1207  }
1208
1209  function detectSource() {
1210    var ref = document.referrer || '';
1211    var params = new URLSearchParams(window.location.search);
1212
1213    if (ref.indexOf('baidu.com') !== -1) {
1214      var kw = extractSearchKeyword(ref);
1215      return kw ? '百度搜索(' + kw + ')' : '百度搜索';
1216    }
1217    if (ref.indexOf('google.com') !== -1) {
1218      var gkw = extractSearchKeyword(ref);
1219      return gkw ? 'Google搜索(' + gkw + ')' : 'Google搜索';
1220    }
1221    if (ref.indexOf('sogou.com') !== -1) {
1222      var skw = extractSearchKeyword(ref);
1223      return skw ? '搜狗搜索(' + skw + ')' : '搜狗搜索';
1224    }
1225    if (ref.indexOf('360.cn') !== -1 || ref.indexOf('so.com') !== -1) {
1226      var kw360 = extractSearchKeyword(ref);
1227      return kw360 ? '360搜索(' + kw360 + ')' : '360搜索';
1228    }
1229    if (ref.indexOf('bing.com') !== -1) {
1230      var kwbing = extractSearchKeyword(ref);
1231      return kwbing ? 'Bing搜索(' + kwbing + ')' : 'Bing搜索';
1232    }
1233    if (ref.indexOf('sm.cn') !== -1) {
1234      var kwsm = extractSearchKeyword(ref);
1235      return kwsm ? '神马搜索(' + kwsm + ')' : '神马搜索';
1236    }
1237    if (ref.indexOf('zhihu.com') !== -1) {
1238      var kwzh = extractSearchKeyword(ref);
1239      return kwzh ? '知乎搜索(' + kwzh + ')' : '知乎搜索';
1240    }
1241    if (params.get('utm_source')) return params.get('utm_source');
1242    if (params.get('source')) return params.get('source');
1243    if (params.get('bd_vid')) return '百度推广';
1244    if (ref) {
1245      try { return '外部链接(' + (new URL(ref)).hostname.replace('www.', '') + ')'; }
1246      catch(e) { return '外部链接'; }
1247    }
1248    return '直接访问';
1249  }
1250
1251  // ==================== 窗口控制 ====================
1252  function openWindow() {
1253    STATE.open = true;
1254    $window.classList.add('open');
1255    $btn.classList.add('open');
1256
1257    if (STATE.messages.length === 0) {
1258      addMessage('agent', CONFIG.agent.greeting);
1259    }
1260    scrollToBottom();
1261
1262    // 用户未说话时启动信息引擎
1263    if (!STATE.userHasSpoken) {
1264      startInfoEngine();
1265    }
1266    startPeriodicPushTimer();  // 对话框开着时每5分钟检查一次
1267  }
1268
1269  function closeWindow() {
1270    STATE.open = false;
1271    $window.classList.remove('open');
1272    $btn.classList.remove('open');
1273    clearPeriodicPushTimer();
1274    stopInfoEngine();
1275
1276    STATE.closeCount++;
1277    storageSet('close_count', STATE.closeCount);
1278
1279    // 关闭时触发一次补推检查(仅当有新消息时后端才会真正推送)
1280    if (STATE.sessionId) {
1281      triggerPushCheck(true);  // onClose=true,允许 force 绕过等待期
1282    }
1283
1284    scheduleReopen();
1285  }
1286
1287  function toggleWindow() {
1288    if (STATE.open) closeWindow();
1289    else openWindow();
1290  }
1291
1292  function scheduleReopen() {
1293    if (!CONFIG.autoPopup.enabled) return;
1294    if (STATE.userHasSpoken) return;   // v21.8: AI 已接手(用户已说话),停止自动弹出
1295    if (STATE.reopenTimer) clearTimeout(STATE.reopenTimer);
1296
1297    var delay = 300;
1298    for (var i = 0; i < CONFIG.autoPopup.intervals.length; i++) {
1299      if (STATE.closeCount <= CONFIG.autoPopup.intervals[i].maxCount) {
1300        delay = CONFIG.autoPopup.intervals[i].interval * 1000;
1301        break;
1302      }
1303    }
1304
1305    STATE.reopenTimer = setTimeout(function() {
1306      if (!STATE.open) openWindow();
1307    }, delay);
1308  }
1309
1310  function loadCloseCount() {
1311    var count = storageGet('close_count');
1312    if (count) STATE.closeCount = parseInt(count) || 0;
1313  }
1314
1315  // ==================== 自动弹出 ====================
1316  function initAutoPopup() {
1317    if (!CONFIG.autoPopup.enabled) return;
1318    loadCloseCount();
1319
1320    STATE.autoPopupTimer = setTimeout(function() {
1321      if (!STATE.open) {
1322        openWindow();
1323        $btn.classList.add('pulse');
1324        setTimeout(function() { $btn.classList.remove('pulse'); }, 600);
1325      }
1326    }, CONFIG.autoPopup.delay);
1327  }
1328
1329  // ==================== 对外 API ====================
1330  window.SmartKF = {
1331    open: openWindow,
1332    close: closeWindow,
1333    toggle: toggleWindow,
1334    showLead: showLeadForm,
1335    sendMessage: sendUserMessage,
1336    getMessages: function() { return STATE.messages; },
1337    getUserInfo: function() { return STATE.userInfo; },
1338    getState: function() { return STATE; },
1339    config: CONFIG
1340  };
1341
1342  // ==================== 会话恢复(跨页面/刷新) ====================
1343  function restoreSession() {
1344    var savedSessionId = storageGet('session_id');
1345    if (!savedSessionId) return;
1346
1347    STATE.sessionId = savedSessionId;
1348
1349    // 向后端请求恢复历史消息
1350    fetch(CONFIG.apiBase + '/session', {
1351      method: 'POST',
1352      headers: {
1353        'Content-Type': 'application/json',
1354        'X-API-Token': CONFIG.apiToken
1355      },
1356      body: JSON.stringify({ session_id: savedSessionId })
1357    }).then(function(r) { return r.json(); })
1358      .then(function(data) {
1359        if (!data.history || data.history.length === 0) return;
1360
1361        // 防御:如果用户已经在恢复期间发了新消息,不再覆盖
1362        if (STATE.messages.length > 0) return;
1363
1364        var history = data.history;
1365        var hasUserMessage = false;
1366        for (var i = 0; i < history.length; i++) {
1367          var h = history[i];
1368          var type = (h.role === 'assistant') ? 'agent' : 'user';
1369          var text = h.content || '';
1370          STATE.messages.push({ type: type, text: text, time: new Date().toISOString() });
1371          renderMessage(type, text);
1372          if (type === 'user') hasUserMessage = true;
1373        }
1374        scrollToBottom();
1375
1376        // 如果历史中有用户消息,标记为用户已说话,避免信息引擎重新启动
1377        if (hasUserMessage) {
1378          STATE.userHasSpoken = true;
1379          stopInfoEngine();
1380        }
1381
1382        // 恢复已收集的用户信息
1383        if (data.user_info) {
1384          if (data.user_info.name) STATE.userInfo.name = data.user_info.name;
1385          if (data.user_info.phone) STATE.userInfo.phone = data.user_info.phone;
1386          if (data.user_info.course) STATE.userInfo.course = data.user_info.course;
1387          if (data.user_info.status) STATE.userInfo.status = data.user_info.status;
1388        }
1389
1390        console.log('Smart-KF: 恢复 ' + history.length + ' 条历史消息 | session=' + savedSessionId);
1391      })
1392      .catch(function(e) {
1393        console.warn('Smart-KF: 会话恢复失败,使用全新会话', e.message);
1394        STATE.sessionId = null;
1395        storageSet('session_id', '');
1396      });
1397  }
1398
1399  // ==================== 加载版本号 ====================
1400  function _loadVersion() {
1401    // 已有值则跳过(防止重复请求)
1402    if (window.SMART_KF_VERSION) return;
1403
1404    // 确定 version.json URL:优先用 CONFIG.versionJsonUrl,否则根据 widget 脚本路径推算
1405    var vUrl = CONFIG.versionJsonUrl;
1406    if (!vUrl) {
1407      // 根据 widget 脚本的 src 推算:widget_v16.js 所在目录下的 version.json
1408      var scriptEl = document.getElementById('smart-kf-widget-script');
1409      if (scriptEl && scriptEl.src) {
1410        vUrl = scriptEl.src.replace(/widget_v16\.js(\?.*)?$/, 'version.json');
1411      }
1412    }
1413
1414    if (!vUrl) return; // 无法确定路径,跳过
1415
1416    // 添加缓存破坏参数,确保每次拿到最新版本
1417    var cacheBust = vUrl.indexOf('?') > -1 ? '&_t=' : '?_t=';
1418    cacheBust += Date.now();
1419
1420    try {
1421      var xhr = new XMLHttpRequest();
1422      xhr.open('GET', vUrl + cacheBust, true);
1423      xhr.timeout = 5000;
1424      xhr.onreadystatechange = function() {
1425        if (xhr.readyState === 4 && xhr.status === 200) {
1426          try {
1427            var data = JSON.parse(xhr.responseText);
1428            if (data && data.version) {
1429              window.SMART_KF_VERSION = data.version;
1430              console.log('[Smart-KF] 版本号已加载: v' + data.version);
1431              // 立即刷新角标
1432              var badge = document.getElementById('skf-version-badge');
1433              if (badge) {
1434                badge.textContent = 'SKF v' + data.version;
1435              }
1436            }
1437          } catch(e) {
1438            console.warn('[Smart-KF] version.json 解析失败:', e.message);
1439            window.SMART_KF_VERSION = CONFIG.currentVersion;
1440          }
1441        } else if (xhr.readyState === 4) {
1442          // 加载失败,使用回退值
1443          window.SMART_KF_VERSION = CONFIG.currentVersion;
1444          console.warn('[Smart-KF] version.json 加载失败(status=' + xhr.status + '), 使用回退版本 v' + CONFIG.currentVersion);
1445        }
1446      };
1447      xhr.send();
1448    } catch(e) {
1449      window.SMART_KF_VERSION = CONFIG.currentVersion;
1450    }
1451  }
1452
1453  // ==================== 初始化 ====================
1454  function init() {
1455    injectStyles();
1456    buildWidget();
1457
1458    if (!STATE.userInfo.source) {
1459      STATE.userInfo.source = detectSource();
1460    }
1461    if (!STATE.userInfo.landingPage || STATE.userInfo.landingPage === '直接访问') {
1462      STATE.userInfo.landingPage = document.referrer || '直接访问';
1463    }
1464    STATE.userInfo.currentPage = window.location.href;
1465
1466    // 尝试恢复跨页面/刷新的聊天记录
1467    restoreSession();
1468
1469    // 加载 version.json 并设置 window.SMART_KF_VERSION
1470    _loadVersion();
1471
1472    // 版本角标 — 监听 window.SMART_KF_VERSION 变化,动态刷新
1473    var _vBadge = document.getElementById('skf-version-badge');
1474    if (_vBadge) {
1475      // 立即刷新一次(回退值为 CONFIG.currentVersion)
1476      _vBadge.textContent = 'SKF v' + (window.SMART_KF_VERSION || CONFIG.currentVersion);
1477      // 每2秒检查版本变化(version.json 异步加载后自动刷新角标)
1478      var _vCheckTimer = setInterval(function() {
1479        var v = window.SMART_KF_VERSION || CONFIG.currentVersion;
1480        if (_vBadge.textContent !== 'SKF v' + v) {
1481          _vBadge.textContent = 'SKF v' + v;
1482          console.log('[Smart-KF] 版本角标已更新: v' + v);
1483        }
1484      }, 2000);
1485      // 10秒后停止检查(version.json 应已加载完毕)
1486      setTimeout(function() { clearInterval(_vCheckTimer); }, 10000);
1487    }
1488
1489    initAutoPopup();
1490
1491    console.log('Smart-KF v20.0 已就绪 | 双引擎架构: 信息引擎(60秒轮推) + AI自由对话 | 版本角标动态读取');
1492    console.log('流式: ' + (CONFIG.streamEnabled && window.fetch && window.ReadableStream ? '启用' : '降级XHR') + ' | 自动弹出: ' + CONFIG.autoPopup.delay/1000 + '秒 | 信息引擎: ' + (CONFIG.infoEngine.enabled ? '启用' : '关闭'));
1493  }
1494
1495  if (document.readyState === 'loading') {
1496    document.addEventListener('DOMContentLoaded', init);
1497  } else {
1498    init();
1499  }
1500
1501})(window, document);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.