PageSourceSearch

https://magalia.wiki/magalia-assistant.js

js magalia.wiki collected 2026-10-07 16:32:40 UTC 74,281 bytes, 1,329 lines download raw bytes

1/* =====================================================================
2   magalia.wiki — Study Assistant  (pilot: §0 Happy Latin)
3   ---------------------------------------------------------------------
4   A self-contained, drop-in BYOK ("bring your own key") AI panel.
5   Three tabbed modes — Tutor / Chat / Tasks — switchable between
6   Qwen and DeepSeek.
7
8   DROP-IN USAGE
9     Add ONE line before </body> on any page:
10         <script src="magalia-assistant.js" defer></script>
11     No other change to the host page is required. The widget injects
12     its own styles, never touches page globals, and stores everything
13     under the  mw_ai_*  localStorage namespace.
14
15   IMPORTANT — why this needs a "route"
16     The DeepSeek API and the DashScope (Qwen) API do NOT send CORS
17     headers, so a browser cannot call them directly from a static
18     page. Two working routes are provided:
19       1. "OpenRouter"  — openrouter.ai DOES allow browser calls.
20                          One key, no server. Easiest. (default)
21       2. "Direct+relay"— call api.deepseek.com / dashscope through a
22                          tiny stateless CORS relay you host once
23                          (see RELAY-WORKER.js in the report). The
24                          relay holds NO key; your key passes through.
25
26   All endpoints + model IDs live in CONFIG below — edit in one place.
27   © 2026 Wu Ching-Yuan — magalia.wiki
28   ===================================================================== */
29(function () {
30  'use strict';
31  if (window.__mwAssistant) return;            // guard against double-load
32  window.__mwAssistant = true;
33
34  /* Cross-frame guard (search-unification #6). When this page is embedded as an
35     iframe inside a magalia shell (explore.html, discover.html …),
36     the SHELL already shows the launcher — a second one stacked inside the frame
37     is the "two Study-assistant buttons" bug. So in a framed context we still run
38     the whole module (the LLM bridge window.__mwAssistantLLM + window.MagaliaAssistant
39     must stay defined — Symbolon's Synthesise reads the key from here) but suppress
40     the floating launcher button. A page can force the button with
41     <body data-mw-force-widget>. */
42  var FRAMED = false;
43  try { FRAMED = (window.top !== window.self); } catch (e) { FRAMED = true; }  // cross-origin top → treat as framed
44  /* One-stop integration (2026-08-30): when the search portal is on the page, the portal's
45     Search/Ask bar (⌘K, the single FAB) is the ONE entry point for both search and assistant
46     queries — its "Ask" row and Shift-↵ call MagaliaAssistant.ask(), which opens this panel.
47     So the assistant's own idle pill is suppressed wherever the portal exists (or lazy-loaded
48     us); the module, panel, and LLM bridge stay fully live. Force the pill back per page with
49     <body data-mw-force-widget>. */
50  var PORTAL_ON_PAGE = false;
51  try {
52    PORTAL_ON_PAGE = !!(window.MagaliaPortal ||
53      document.querySelector('script[src*="magalia-portal"]'));
54  } catch (e) {}
55  // Tools (2026-09-19): where the fold module is on the page, the assistant is one entry in its
56  // Tools sheet and draws no floating button
57  var TOOLS_ON_PAGE = false;
58  try { TOOLS_ON_PAGE = !!document.querySelector('script[src*="magalia-fold"]'); } catch (e) {}
59  var SHOW_LAUNCHER = (!FRAMED && !PORTAL_ON_PAGE && !TOOLS_ON_PAGE) ||
60    (document.body && document.body.hasAttribute('data-mw-force-widget'));
61
62  /* ================================================================
63     CONFIG  —  the only block you should normally need to edit
64     ================================================================ */
65  var CONFIG = {
66    /* OpenRouter route: works directly from the browser (CORS-enabled) */
67    openrouter: {
68      endpoint: 'https://openrouter.ai/api/v1/chat/completions',
69      models: {                       // OpenRouter model slugs (one OpenRouter key covers all;
70        qwen:      'qwen/qwen3.7-plus',           // override any of these via the model-ID box (S.mid)
71        deepseek:  'deepseek/deepseek-v4-flash',
72        kimi:      'moonshotai/kimi-k2.6',
73        anthropic: 'anthropic/claude-sonnet-4.6', // Claude (Sonnet 4.6) via OpenRouter
74        openai:    'openai/gpt-4.1-mini',         // OpenAI via OpenRouter
75        glm:       'z-ai/glm-5.2'                  // GLM-5.2 (Zhipu/Z.ai) — passed the citation veto-eval (0.06)
76      }
77    },
78    /* Shared-key route (platform P3, route C): a server-side endpoint that holds
79       ONE key (magalia-assist-worker.js) and answers with no per-user key. Used by
80       the Reading Room / concierge via <body data-mwai-route="shared">. The model is
81       decided server-side (Qwen3.7-plus per the P2 eval); the client sends no key. */
82    shared: {
83      endpoint: ''   // set to your deployed magalia-assist-worker.js URL, or per-page
84                     // via <body data-mwai-shared="https://...workers.dev">
85    },
86    /* Direct route: native APIs, reached THROUGH your CORS relay.
87       {relay} is replaced with the relay base URL from Settings. */
88    direct: {
89      qwen: {
90        upstream: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions',
91        model:    'qwen-plus'
92      },
93      deepseek: {
94        upstream: 'https://api.deepseek.com/chat/completions',
95        model:    'deepseek-v4-flash'   // legacy 'deepseek-chat' alias retired 2026-07-24
96      },
97      kimi: {
98        upstream: 'https://api.moonshot.ai/v1/chat/completions',
99        model:    'kimi-k2.6'
100      },
101      openai: {                          // native OpenAI — same /chat/completions shape as the others
102        upstream: 'https://api.openai.com/v1/chat/completions',
103        model:    'gpt-4.1-mini'
104      },
105      glm: {                             // GLM-5.2 native via Z.ai's OpenAI-compatible path (reached through the relay)
106        upstream: 'https://api.z.ai/api/openai/v1/chat/completions',
107        model:    'glm-5.2'
108      }
109      /* Anthropic native uses the /v1/messages format (system + content-blocks + x-api-key),
110         NOT /chat/completions — so it is offered via the OpenRouter route above, which speaks
111         the uniform format. A native-Anthropic Direct adapter is a follow-up if wanted. */
112    },
113    maxContextChars: 7000,            // how much page text to feed the Tutor
114    temperature: 0.4,
115    maxTokens: 1400
116  };
117
118  /* ================================================================
119     STATE  —  persisted under the  mw_ai_*  localStorage namespace
120     ================================================================ */
121  var LS = {
122    get: function (k, d) { try { var v = localStorage.getItem('mw_ai_' + k); return v === null ? d : v; } catch (e) { return d; } },
123    set: function (k, v) { try { localStorage.setItem('mw_ai_' + k, v); } catch (e) {} }
124  };
125  var S = {
126    route:  LS.get('route', 'openrouter'),     // 'openrouter' | 'direct'
127    model:  LS.get('model', 'qwen'),           // 'qwen' | 'deepseek'
128    relay:  LS.get('relay', ''),
129    lang:   LS.get('lang', ''),                // 'en' | 'zh'  (auto if '')
130    key_openrouter: LS.get('key_openrouter', ''),
131    key_qwen:       LS.get('key_qwen', ''),
132    key_deepseek:   LS.get('key_deepseek', ''),
133    key_kimi:       LS.get('key_kimi', ''),
134    key_openai:     LS.get('key_openai', ''),
135    key_glm:        LS.get('key_glm', ''),
136    mid: LS.get('mid', ''),                    // optional model-ID override
137    folded: LS.get('folded', '') === '1',      // panel collapsed-to-edge but still "open"
138    sharedEndpoint: ''                         // resolved below for the shared route
139  };
140  /* Shared-key route opt-in (platform P3): a page declares
141       <body data-mwai-route="shared" data-mwai-shared="https://...workers.dev">
142     to use the server-side shared key (no per-user key). The page attribute wins;
143     else CONFIG.shared.endpoint. When active it overrides any saved BYOK route. */
144  (function () {
145    try {
146      var b = document.body;
147      var wantShared = b && b.getAttribute('data-mwai-route') === 'shared';
148      var ep = (b && b.getAttribute('data-mwai-shared')) || CONFIG.shared.endpoint || '';
149      if (wantShared && ep) { S.route = 'shared'; S.sharedEndpoint = normalizeRelay(ep); }
150    } catch (e) {}
151  })();
152  /* language: explicit setting, else page hint, else browser */
153  if (!S.lang) {
154    var bc = document.body ? document.body.className : '';
155    if (/lang-zh/.test(bc) || /^zh/i.test(document.documentElement.lang) ||
156        /^zh/i.test(navigator.language || '')) S.lang = 'zh';
157    else S.lang = 'en';
158  }
159
160  /* in-memory conversation (not persisted). One unified surface: 'chat'. */
161  var convo = { chat: [] };
162  var activeTab = 'chat';
163  var busy = false, abortCtl = null;
164
165  /* ================================================================
166     i18n
167     ================================================================ */
168  var T = {
169    en: {
170      launch: 'Study assistant', title: 'Magalia Assistant',
171      chatPh: 'Ask me anything…',
172      send: 'Send', stop: 'Stop', settings: 'Settings', close: 'Close',
173      fold: 'Collapse to edge', unfold: 'Expand assistant',
174      welcomeChat: 'Your study assistant. Ask about the Latin on this page, request a quiz or flashcards, parse a sentence, or ask me to point you anywhere on the site. Select text first and I’ll focus on it.',
175      t_quiz: 'Quiz me (5 Q)', t_cards: 'Make flashcards',
176      t_sum: 'Summarise page', t_parse: 'Parse a sentence',
177      needKey: 'No API key set. Open Settings (⚙) to add one.',
178      trackHead: 'Two ways to use this',
179      trackNoKey: 'No-key concierge — free, no setup: search every corpus and navigate the whole site (the ⌘K bar + the connectivity map).',
180      trackByok: 'Bring-your-own-key assistant — add a key below to unlock chat, the tutor, page tools and the connectivity-aware Site Navigator.',
181      trackOn: 'active now',
182      route: 'Route', model: 'Model',
183      r_openrouter: 'OpenRouter — no server needed',
184      r_direct: 'Direct API — needs a relay',
185      apikey: 'API key', relayUrl: 'CORS relay URL',
186      midLabel: 'Model ID override (optional)',
187      lang: 'Language', save: 'Saved ✓',
188      keyHintOR: 'One OpenRouter key (openrouter.ai/keys) covers all models.',
189      keyHintQ: 'DashScope key for Qwen (Alibaba Cloud Model Studio).',
190      keyHintD: 'DeepSeek key (platform.deepseek.com).',
191      keyHintK: 'Moonshot key for Kimi (platform.moonshot.ai).',
192      keyHintO: 'OpenAI key (platform.openai.com) on the Direct route; on OpenRouter, your OpenRouter key.',
193      keyHintG: 'Z.ai key for GLM (z.ai) on the Direct route; on OpenRouter, your OpenRouter key.',
194      relayHint: 'Base URL of your CORS relay. Required for the Direct route — DeepSeek/DashScope block direct browser calls.',
195      err: 'Error', corsErr: 'Network/CORS error. In Direct mode, check your relay URL is deployed and correct — or switch Route to OpenRouter.',
196      or401: 'OpenRouter rejected the key. On the OpenRouter route the key must be an OpenRouter key — it starts with "sk-or-v1-…" and comes from openrouter.ai/keys. A native DeepSeek or Qwen key will NOT work here. Paste the key alone — no "Bearer", no quotes. To use a native DeepSeek/Qwen key instead, switch Route to "Direct API".',
197      direct401: 'The API rejected the key. Check the native key is correct for the selected model, and that your relay URL is right.',
198      selUsed: 'Using your selected passage.'
199    },
200    zh: {
201      launch: '学习助手', title: 'Magalia 助手',
202      chatPh: '随便问我…',
203      send: '发送', stop: '停止', settings: '设置', close: '关闭',
204      fold: '收起到边缘', unfold: '展开助手',
205      welcomeChat: '你的学习助手。可询问本页拉丁文、生成测验或单词卡、解析句子,或让我带你前往站内任意页面。先选中文本,我会聚焦那段。',
206      t_quiz: '出5题测验', t_cards: '生成单词卡',
207      t_sum: '总结本页', t_parse: '解析一个句子',
208      needKey: '尚未设置 API 密钥。请打开设置(⚙)。',
209      trackHead: '两种使用方式',
210      trackNoKey: '免密钥导览 — 免费、无需设置:检索全部语料,并导航整站(⌘K 检索栏与连线图)。',
211      trackByok: '自带密钥助手 — 在下方填入密钥即可解锁对话、导师、页面工具与连通性站点导航。',
212      trackOn: '当前启用',
213      route: '通道', model: '模型',
214      r_openrouter: 'OpenRouter — 无需服务器',
215      r_direct: '原生 API — 需中转',
216      apikey: 'API 密钥', relayUrl: 'CORS 中转地址',
217      midLabel: '模型 ID 覆盖(可选)',
218      lang: '语言', save: '已保存 ✓',
219      keyHintOR: '一个 OpenRouter 密钥(openrouter.ai/keys)即可用于全部模型。',
220      keyHintQ: '通义千问(阿里云百炼)的 DashScope 密钥。',
221      keyHintD: 'DeepSeek 密钥(platform.deepseek.com)。',
222      keyHintK: 'Kimi(月之暗面 platform.moonshot.ai)密钥。',
223      keyHintO: '原生通道用 OpenAI 密钥(platform.openai.com);OpenRouter 通道用 OpenRouter 密钥。',
224      keyHintG: '原生通道用 Z.ai 的 GLM 密钥(z.ai);OpenRouter 通道用 OpenRouter 密钥。',
225      relayHint: 'CORS 中转服务的基址。原生通道必填 — DeepSeek/DashScope 禁止浏览器直连。',
226      err: '错误', corsErr: '网络/CORS 错误。原生通道请检查中转地址是否已部署且正确 — 或将通道改为 OpenRouter。',
227      or401: 'OpenRouter 拒绝了该密钥。OpenRouter 通道只接受 OpenRouter 密钥(以 “sk-or-v1-…” 开头,来自 openrouter.ai/keys)。DeepSeek 或通义千问的原生密钥在此无效。请只粘贴密钥本身 — 不要 “Bearer”、不要引号。若要使用原生 DeepSeek/Qwen 密钥,请把通道切换为“原生 API”。',
228      direct401: 'API 拒绝了该密钥。请检查所选模型对应的原生密钥是否正确,以及中转地址是否正确。',
229      selUsed: '已采用你选中的段落。'
230    }
231  };
232  function t(k) { return (T[S.lang] || T.en)[k] || k; }
233
234  /* ================================================================
235     STYLES  —  scoped under .mwai-root, light + dark
236     ================================================================ */
237  var CSS = ''
238   + '.mwai-root{--mw-bg:#fbf8f0;--mw-fg:#1a1612;--mw-soft:#5a554a;--mw-mut:#8a8475;'
239   + '--mw-line:#d8cfc4;--mw-tint:#f0e8d4;--mw-acc:#7a4d2a;--mw-acc2:#2a4d7a;'
240   + '--mw-card:#fff;--mw-user:#eadfca;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}'
241   + '@media (prefers-color-scheme: dark) and (min-width: 99999px){.mwai-root{--mw-bg:#232017;--mw-fg:#ece7da;'
242   + '--mw-soft:#c0bbac;--mw-mut:#948e7c;--mw-line:#3a3528;--mw-tint:#2a2620;'
243   + '--mw-acc:#d4a486;--mw-acc2:#86b4d4;--mw-card:#1a1814;--mw-user:#33301f;}}'
244   + '.mwai-root *{box-sizing:border-box;}'
245   /* launcher — a pill FAB in the BOTTOM-RIGHT corner (clears the top header + the page content); the
246      search/finder FAB stacks just above it so both AI affordances live together in one corner. */
247   + '.mwai-launch{position:fixed;right:1rem;bottom:1rem;z-index:2147482000;'
248   + 'display:inline-flex;align-items:center;gap:.4rem;padding:.6rem .9rem;'
249   + 'background:var(--mw-acc);color:#fff;border:none;border-radius:999px;cursor:pointer;'
250   + 'font:750 .82rem/1.2 system-ui;box-shadow:0 6px 20px rgba(0,0,0,.30);}'
251   + '.mwai-launch:hover{filter:brightness(1.07);}'
252   + '.mwai-launch .mwai-spark{font-size:1rem;}'
253   + '@media (max-width:480px){.mwai-launch{right:.7rem;bottom:.7rem;}}'
254   /* panel — opens from the RIGHT edge to match the bottom-right launcher */
255   + '.mwai-panel{position:fixed;right:0;top:1.1rem;bottom:1.1rem;z-index:2147483000;'
256   + 'width:392px;max-width:calc(100vw - 16px);height:auto;'
257   + 'display:flex;flex-direction:column;background:var(--mw-bg);color:var(--mw-fg);'
258   + 'border:1px solid var(--mw-line);border-right:none;border-radius:14px 0 0 14px;overflow:hidden;'
259   + 'box-shadow:-10px 12px 44px rgba(0,0,0,.30);}'
260   + '@media (max-width:480px){.mwai-panel{right:0;left:0;top:0;bottom:0;width:100vw;height:100vh;border-radius:0;border-right:none;}}'
261   + '.mwai-head{display:flex;align-items:center;gap:.5rem;padding:.6rem .7rem;'
262   + 'background:var(--mw-acc);color:#fff;}'
263   + '.mwai-htitle{font-weight:700;font-size:.92rem;flex:1;display:flex;align-items:center;gap:.4rem;}'
264   + '.mwai-model{font-size:.74rem;border:none;border-radius:6px;padding:.25rem .4rem;'
265   + 'background:rgba(255,255,255,.22);color:#fff;cursor:pointer;}'
266   + '.mwai-model option{color:#000;}'
267   + '.mwai-ic{background:rgba(255,255,255,.16);color:#fff;border:none;width:26px;height:26px;'
268   + 'border-radius:6px;cursor:pointer;font-size:.85rem;line-height:1;}'
269   + '.mwai-ic:hover{background:rgba(255,255,255,.32);}'
270   /* body */
271   + '.mwai-body{flex:1;overflow-y:auto;padding:.85rem;}'
272   + '.mwai-pane{display:flex;flex-direction:column;gap:.6rem;}'
273   + '.mwai-intro{font-size:.8rem;color:var(--mw-soft);line-height:1.55;'
274   + 'background:var(--mw-tint);border:1px solid var(--mw-line);border-radius:9px;padding:.6rem .7rem;}'
275   + '.mwai-msg{font-size:.86rem;line-height:1.6;border-radius:10px;padding:.55rem .7rem;max-width:92%;'
276   + 'white-space:normal;word-wrap:break-word;}'
277   + '.mwai-msg.u{align-self:flex-end;background:var(--mw-user);color:var(--mw-fg);}'
278   + '.mwai-msg.a{align-self:flex-start;background:var(--mw-card);border:1px solid var(--mw-line);}'
279   + '.mwai-msg.e{align-self:stretch;background:#fcecec;color:#a02020;border:1px solid #e7b7b7;font-size:.8rem;white-space:pre-wrap;}'
280   + '@media (prefers-color-scheme: dark) and (min-width: 99999px){.mwai-msg.e{background:#3a2020;color:#ffb4b4;border-color:#5a2a2a;}}'
281   + '.mwai-msg p{margin:.3rem 0;}.mwai-msg p:first-child{margin-top:0;}.mwai-msg p:last-child{margin-bottom:0;}'
282   + '.mwai-msg ul,.mwai-msg ol{margin:.3rem 0;padding-left:1.2rem;}.mwai-msg li{margin:.15rem 0;}'
283   + '.mwai-msg code{background:var(--mw-tint);padding:.05rem .3rem;border-radius:4px;'
284   + 'font-family:ui-monospace,Menlo,monospace;font-size:.85em;}'
285   + '.mwai-msg h3,.mwai-msg h4{margin:.45rem 0 .2rem;font-size:.9rem;}'
286   + '.mwai-msg pre{background:var(--mw-tint);padding:.5rem;border-radius:7px;overflow-x:auto;}'
287   + '.mwai-cur::after{content:"▍";opacity:.6;animation:mwbl 1s steps(2) infinite;}'
288   + '@keyframes mwbl{0%,100%{opacity:.15;}50%{opacity:.7;}}'
289   /* task buttons */
290   + '.mwai-taskbar{display:flex;flex-wrap:wrap;gap:.4rem;}'
291   + '.mwai-taskbar button{flex:1 1 46%;padding:.45rem .3rem;font-size:.77rem;font-weight:600;'
292   + 'background:var(--mw-card);color:var(--mw-acc);border:1px solid var(--mw-line);'
293   + 'border-radius:8px;cursor:pointer;}'
294   + '.mwai-taskbar button:hover{background:var(--mw-tint);}'
295   /* settings */
296   + '.mwai-set label{display:block;font-size:.75rem;font-weight:700;margin:.7rem 0 .2rem;color:var(--mw-soft);}'
297   + '.mwai-set input,.mwai-set select{width:100%;padding:.42rem .5rem;font-size:.82rem;'
298   + 'border:1px solid var(--mw-line);border-radius:7px;background:var(--mw-card);color:var(--mw-fg);}'
299   + '.mwai-set .hint{font-size:.71rem;color:var(--mw-mut);margin-top:.25rem;line-height:1.5;}'
300   + '.mwai-track{border:1px solid var(--mw-line);border-radius:8px;padding:.5rem .6rem;margin-bottom:.7rem;background:var(--mw-card);}'
301   + '.mwai-track-h{font-size:.74rem;font-weight:600;color:var(--mw-soft);margin-bottom:.4rem;text-transform:uppercase;letter-spacing:.04em;}'
302   + '.mwai-track-row{font-size:.74rem;color:var(--mw-mut);line-height:1.5;padding:.18rem 0;}'
303   + '.mwai-track-row.on{color:var(--mw-soft);}'
304   + '.mwai-tk-dot{color:var(--mw-acc);font-weight:700;}'
305   + '.mwai-tk-on{color:var(--mw-acc);font-style:normal;font-weight:600;}'
306   + '.mwai-seg{display:flex;gap:.3rem;}'
307   + '.mwai-seg button{flex:1;padding:.4rem;font-size:.78rem;border:1px solid var(--mw-line);'
308   + 'background:var(--mw-card);color:var(--mw-soft);border-radius:7px;cursor:pointer;}'
309   + '.mwai-seg button.on{background:var(--mw-acc);color:#fff;border-color:var(--mw-acc);}'
310   + '.mwai-saved{font-size:.74rem;color:#2d7d46;margin-top:.6rem;height:1rem;}'
311   /* input */
312   + '.mwai-input{display:flex;gap:.4rem;padding:.6rem;border-top:1px solid var(--mw-line);'
313   + 'background:var(--mw-bg);}'
314   + '.mwai-input textarea{flex:1;resize:none;height:38px;max-height:120px;
314padding:.45rem .55rem;'
315   + 'font-size:.84rem;font-family:inherit;border:1px solid var(--mw-line);border-radius:8px;'
316   + 'background:var(--mw-card);color:var(--mw-fg);}'
317   + '.mwai-send{padding:0 .9rem;background:var(--mw-acc);color:#fff;border:none;border-radius:8px;'
318   + 'cursor:pointer;font-size:.82rem;font-weight:600;}'
319   + '.mwai-send.stop{background:#a23;}'
320   /* links inside assistant messages (LLM markdown links + linkify) */
321   + '.mwai-msg a,.mwai-lnk{color:var(--mw-acc2);font-weight:600;text-decoration:none;'
322   + 'border-bottom:1px solid rgba(42,77,122,.4);}'
323   + '.mwai-msg a:hover{border-bottom-color:var(--mw-acc2);}'
324   /* predictive navigation strip (no-key) — chips above the input on the Chat tab */
325   + '.mwai-suggest{border-top:1px solid var(--mw-line);background:var(--mw-tint);padding:.5rem .6rem;}'
326   + '.mwai-sg-h{font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;'
327   + 'color:var(--mw-mut);margin-bottom:.35rem;}'
328   + '.mwai-sg-row{display:flex;flex-wrap:wrap;gap:.35rem;}'
329   + '.mwai-chip{display:inline-block;font-size:.76rem;font-weight:600;text-decoration:none;'
330   + 'color:var(--mw-acc);background:var(--mw-card);border:1px solid var(--mw-line);border-radius:999px;'
331   + 'padding:.28rem .6rem;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}'
332   + '.mwai-chip:hover{background:var(--mw-acc);color:#fff;border-color:var(--mw-acc);}'
333   + '.mwai-chip-search{color:var(--mw-acc2);}'
334   + '.mwai-sg-inmsg{margin:.45rem 0;}'
335   + '.mwai-hide{display:none !important;}'
336   /* folded — collapse the panel to a thin vertical tab pinned to the LEFT edge */
337   + '.mwai-panel.mwai-folded{width:38px;min-width:38px;height:auto;min-height:160px;max-height:72vh;'
338   + 'left:0;top:50%;bottom:auto;transform:translateY(-50%);border-radius:0 10px 10px 0;cursor:pointer;'
339   + 'box-shadow:4px 4px 18px rgba(0,0,0,.28);}'
340   + '.mwai-panel.mwai-folded>.mwai-head,.mwai-panel.mwai-folded>.mwai-body,'
341   + '.mwai-panel.mwai-folded>.mwai-taskbar,.mwai-panel.mwai-folded>.mwai-suggest,'
342   + '.mwai-panel.mwai-folded>.mwai-input{display:none !important;}'
343   + '.mwai-foldedtab{display:none;align-items:center;justify-content:center;gap:.5rem;'
344   + 'writing-mode:vertical-rl;text-orientation:mixed;padding:.9rem .2rem;height:100%;'
345   + 'background:var(--mw-acc);color:#fff;font-size:.78rem;font-weight:600;'
346   + 'border-radius:0 10px 10px 0;cursor:pointer;}'
347   + '.mwai-panel.mwai-folded .mwai-foldedtab{display:flex;}'
348   + '.mwai-ic.mwai-fold{font-size:1rem;}'
349   + '@media (max-width:480px){.mwai-panel.mwai-folded{width:38px;height:auto;}}';
350
351  /* ================================================================
352     BUILD DOM
353     ================================================================ */
354  var root = document.createElement('div');
355  root.className = 'mwai-root';
356  var style = document.createElement('style');
357  style.textContent = CSS;
358  root.appendChild(style);
359
360  root.insertAdjacentHTML('beforeend',
361    '<button class="mwai-launch" type="button">'
362    + '<span class="mwai-spark">✦</span><span class="mwai-launch-tx"></span></button>'
363    + '<div class="mwai-panel mwai-hide" role="dialog" aria-label="Magalia Assistant">'
364    +   '<div class="mwai-head">'
365    +     '<div class="mwai-htitle"><span>✦</span><span class="mwai-tx-title"></span></div>'
366    +     '<select class="mwai-model" title="Model">'
367    +       '<option value="qwen">Qwen</option><option value="deepseek">DeepSeek</option><option value="anthropic">Claude</option><option value="openai">OpenAI</option><option value="kimi">Kimi</option><option value="glm">GLM-5.2</option></select>'
368    +     '<button class="mwai-ic mwai-fold" type="button" title="Collapse">⟩</button>'
369    +     '<button class="mwai-ic mwai-gear" type="button" title="Settings">⚙</button>'
370    +     '<button class="mwai-ic mwai-x" type="button" title="Close">✕</button>'
371    +   '</div>'
372    +   '<div class="mwai-body">'
373    +     '<div class="mwai-pane mwai-p-chat"></div>'
374    +     '<div class="mwai-pane mwai-set mwai-p-set mwai-hide"></div>'
375    +   '</div>'
376    +   '<div class="mwai-taskbar"></div>'
377    +   '<div class="mwai-suggest mwai-hide"></div>'
378    +   '<div class="mwai-input">'
379    +     '<textarea rows="1"></textarea>'
380    +     '<button class="mwai-send" type="button"></button>'
381    +   '</div>'
382    +   '<div class="mwai-foldedtab"><span>✦</span><span class="mwai-foldedtab-tx"></span></div>'
383    + '</div>');
384
385  (document.body || document.documentElement).appendChild(root);
386
387  /* element refs */
388  var $ = function (s) { return root.querySelector(s); };
389  var elLaunch = $('.mwai-launch'), elPanel = $('.mwai-panel'),
390      elModel = $('.mwai-model'), elTaskbar = $('.mwai-taskbar'),
391      elInput = $('.mwai-input textarea'), elSend = $('.mwai-send'),
392      panes = { chat: $('.mwai-p-chat'), set: $('.mwai-p-set') };
393  // iframe context (#6): keep the whole module (LLM bridge stays live) but hide the
394  // duplicate floating button — the shell already shows one.
395  if (!SHOW_LAUNCHER) elLaunch.classList.add('mwai-hide');
396  var elSuggest = $('.mwai-suggest');
397  var elFold = $('.mwai-fold'), elFoldedTab = $('.mwai-foldedtab');
398
399  /* ================================================================
400     PREDICTIVE NAVIGATION  (no key) — bridges MagaliaPortal.suggest()
401     into the Chat tab: predict the pages/families/entries the user is
402     reaching for and render them as clickable chips. Deterministic, instant,
403     works with no API key. Silently inert if the portal isn't on the page.
404     ================================================================ */
405  function navItems(s) {            // flatten a suggest() bundle → {label, sub, url}[]
406    var out = [];
407    (s.entries || []).slice(0, 2).forEach(function (e) {
408      out.push({ label: '◆ ' + ((e.label || {}).en || ''), url: e.target, sub: e.source || '' }); });
409    (s.dests || []).slice(0, 4).forEach(function (d) {
410      out.push({ label: (d.title || {}).en || '', url: d.url, sub: (d.does || {}).en || '' }); });
411    (s.pages || []).slice(0, 3).forEach(function (p) {
412      out.push({ label: (p.title || {}).en || '', url: p.url, sub: (p.desc || '').slice(0, 70) }); });
413    (s.editions || []).slice(0, 2).forEach(function (e) {
414      out.push({ label: e.title, url: e.path, sub: 'edition' }); });
415    var seen = {}, fin = [];
416    out.forEach(function (i) { if (i.url && i.label && !seen[i.url]) { seen[i.url] = 1; fin.push(i); } });
417    return fin.slice(0, 6);
418  }
419  function chipRow(items) {
420    return items.map(function (i) {
421      return '<a class="mwai-chip" href="' + esc(i.url) + '" title="' + esc(i.sub || '') + '">'
422        + esc(i.label) + '</a>';
423    }).join('');
424  }
425  function renderSuggest(items, heading) {
426    if (!items || !items.length) { elSuggest.classList.add('mwai-hide'); elSuggest.innerHTML = ''; return; }
427    elSuggest.innerHTML = '<div class="mwai-sg-h">' + esc(heading || '') + '</div>'
428      + '<div class="mwai-sg-row">' + chipRow(items) + '</div>';
429    if (activeTab === 'chat') elSuggest.classList.remove('mwai-hide');
430  }
431  /* strip question words / stopwords so a natural-language query ("where can I edit
432     epidoc xml") still routes on its content words ("edit epidoc xml") — the portal's
433     router requires every token to match, so function words would otherwise zero it out. */
434  var STOP = /^(where|can|i|how|do|does|did|what|whats|which|who|whose|the|a|an|to|find|for|me|my|is|are|am|on|in|of|want|wanna|looking|look|page|pages|show|get|about|please|help|with|see|go|navigate|here|there|this|that|some|any|tell|need|would|like|search|that's|its|it's)$/i;
435  function cleanQ(q) {
436    q = (q || '').trim(); if (!q) return q;
437    var toks = q.replace(/[?.!,;:"'’“”()]/g, ' ').split(/\s+/).filter(Boolean);
438    var kept = toks.filter(function (t) { return !STOP.test(t); });
439    return kept.length ? kept.join(' ') : q;
440  }
441  /* robust predictor: try the cleaned query (all-token AND); if it yields nothing,
442     fall back to an OR over its content tokens, so a phrase like "restore a damaged
443     inscription" still surfaces pages for "restore" and "inscription" separately. */
444  function predictItems(q) {
445    if (!window.MagaliaPortal || !MagaliaPortal.suggest) return Promise.resolve([]);
446    var cq = cleanQ(q);
447    return MagaliaPortal.suggest(cq).then(function (s) {
448      var items = navItems(s);
449      if (items.length || !cq) return items;
450      var toks = cq.split(/\s+/).filter(function (t) { return t.length > 2; });
451      if (toks.length < 2) return items;
452      return Promise.all(toks.map(function (t) { return MagaliaPortal.suggest(t); })).then(function (arr) {
453        var m = { entries: [], dests: [], pages: [], editions: [] };
454        arr.forEach(function (s2) { ['entries', 'dests', 'pages', 'editions'].forEach(function (k) {
455          m[k] = m[k].concat(s2[k] || []); }); });
456        return navItems(m);
457      });
458    });
459  }
460  var predictT = null, lastPredictQ = null;
461  function predict(q) {
462    if (!window.MagaliaPortal || !MagaliaPortal.suggest) { elSuggest.classList.add('mwai-hide'); return; }
463    q = (q || '').trim();
464    if (q === lastPredictQ) return;
465    lastPredictQ = q;
466    predictItems(q).then(function (items) {
467      if ((elInput.value || '').trim() !== q) return;            // user moved on
468      renderSuggest(items, q ? (S.lang === 'zh' ? '相关页面 · 点击直达' : 'Suggested pages — click to jump')
469                             : (S.lang === 'zh' ? '从这里开始' : 'Start here'));
470    });
471  }
472  function predictDebounced(q) { clearTimeout(predictT); predictT = setTimeout(function () { predict(q); }, 160); }
473
474  /* follow-up: after a keyed LLM Chat answer lands, append a deterministic "Related
475     pages" chip row beneath it — guaranteed clickable navigation alongside the prose
476     (the LLM's own markdown links now render too, but this never depends on the model). */
477  function appendNavChips(node, query, tab) {
478    if (tab !== 'chat' || !node || !window.MagaliaPortal || !MagaliaPortal.suggest) return;
479    predictItems(query).then(function (items) {
480      if (!items || !items.length || !node.parentNode) return;
481      var h = document.createElement('div');
482      h.className = 'mwai-sg-h'; h.style.marginTop = '.55rem';
483      h.textContent = (S.lang === 'zh' ? '相关页面' : 'Related pages');
484      var row = document.createElement('div');
485      row.className = 'mwai-sg-row mwai-sg-inmsg';
486      row.innerHTML = chipRow(items);
487      node.appendChild(h); node.appendChild(row);
488      try { panes[tab].parentNode.scrollTop = panes[tab].parentNode.scrollHeight; } catch (e) {}
489    });
490  }
491
492  /* H.2 follow-up chips — when answering inside the Symbolon workbench, the model may end its
493     reply with a machine-readable <followups>…</followups> block (one JSON object per line:
494     {label,kw,place,from,to,mode}). We strip that block from the visible prose and render each as
495     a button that re-runs the search via window.__symbolonRunQuery. No-op off the workbench. */
496  function extractFollowups(text) {
497    if (!text) return { clean: text, items: [] };
498    var m = /<followups>([\s\S]*?)<\/followups>/i.exec(text);
499    if (!m) return { clean: text, items: [] };
500    var items = [];
501    m[1].split('\n').forEach(function (ln) {
502      ln = ln.trim(); if (!ln || ln.charAt(0) !== '{') return;
503      try {
504        var o = JSON.parse(ln), it = {};
505        if (o.label) it.label = String(o.label).slice(0, 70);
506        if (o.kw != null) it.kw = String(o.kw);
507        if (o.place != null) it.place = String(o.place);
508        if (o.from != null && o.from !== '') it.from = o.from;
509        if (o.to != null && o.to !== '') it.to = o.to;
510        if (o.mode) it.mode = String(o.mode);
511        if (it.label || it.kw || it.place) items.push(it);
512      } catch (e) {}
513    });
514    return { clean: text.replace(m[0], '').trim(), items: items.slice(0, 6) };
515  }
516  function appendSymbolonChips(node, items) {
517    if (!items || !items.length || typeof window.__symbolonRunQuery !== 'function' || !node) return;
518    var h = document.createElement('div');
519    h.className = 'mwai-sg-h'; h.style.marginTop = '.55rem';
520    h.textContent = (S.lang === 'zh' ? '继续检索 · 点击运行' : 'Follow-up searches — click to run');
521    var row = document.createElement('div');
522    row.className = 'mwai-sg-row mwai-sg-inmsg';
523    items.forEach(function (it) {
524      var b = document.createElement('button');
525      b.type = 'button'; b.className = 'mwai-chip';
526      b.textContent = it.label || it.kw || it.place || 'search';
527      var bits = [it.kw && ('“' + it.kw + '”'), it.place, (it.from != null || it.to != null)
528        && ((it.from != null ? it.from : '…') + '–' + (it.to != null ? it.to : '…')), it.mode]
529        .filter(Boolean).join(' · ');
530      if (bits) b.title = bits;
531      b.addEventListener('click', function () { try { window.__symbolonRunQuery(it); } catch (e) {} });
532      row.appendChild(b);
533    });
534    node.appendChild(h); node.appendChild(row);
535    try { panes[activeTab].parentNode.scrollTop = panes[activeTab].parentNode.scrollHeight; } catch (e) {}
536  }
537
538  /* ================================================================
539     RENDER static labels
540     ================================================================ */
541  function paintLabels() {
542    $('.mwai-launch-tx').textContent = t('launch');
543    $('.mwai-tx-title').textContent = t('title');
544    elModel.value = S.model;
545    elSend.textContent = busy ? t('stop') : t('send');
546    elInput.placeholder = t('chatPh');
547    var ft = $('.mwai-foldedtab-tx'); if (ft) ft.textContent = t('launch');
548    if (elFold) { elFold.textContent = S.folded ? '⟨' : '⟩';
549      elFold.title = S.folded ? t('unfold') : t('fold'); }
550  }
551
552  /* ---- minimal, safe markdown -> HTML ---- */
553  function esc(s) { return s.replace(/[&<>]/g, function (c) {
554    return { '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]; }); }
555  function md(src) {
556    var lines = esc(src).split('\n'), out = [], list = null;
557    function closeList() { if (list) { out.push('</' + list + '>'); list = null; } }
558    for (var i = 0; i < lines.length; i++) {
559      var ln = lines[i];
560      var h = ln.match(/^(#{2,4})\s+(.*)/);
561      if (h) { closeList(); out.push('<h' + (h[1].length) + '>' + h[2] + '</h' + (h[1].length) + '>'); continue; }
562      var ol = ln.match(/^\s*\d+[.)]\s+(.*)/);
563      var ul = ln.match(/^\s*[-*•]\s+(.*)/);
564      if (ol) { if (list !== 'ol') { closeList(); list = 'ol'; out.push('<ol>'); } out.push('<li>' + ol[1] + '</li>'); continue; }
565      if (ul) { if (list !== 'ul') { closeList(); list = 'ul'; out.push('<ul>'); } out.push('<li>' + ul[1] + '</li>'); continue; }
566      closeList();
567      if (ln.trim() === '') { out.push(''); continue; }
568      out.push('<p>' + ln + '</p>');
569    }
570    closeList();
571    return out.join('\n')
572      .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g, '<a href="$2" class="mwai-lnk">$1</a>')
573      .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
574      .replace(/`([^`]+)`/g, '<code>$1</code>')
575      .replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
576  }
577
578  /* ---- message bubbles ---- */
579  function bubble(pane, role, text) {
580    var d = document.createElement('div');
581    d.className = 'mwai-msg ' + (role === 'user' ? 'u' : role === 'error' ? 'e' : 'a');
582    if (role === 'assistant') {
583      d.innerHTML = md(text || '');
584      if (window.MagaliaRAG && MagaliaRAG.linkify) d.innerHTML = MagaliaRAG.linkify(d.innerHTML);
585    }
586    else d.textContent = text || '';
587    panes[pane].appendChild(d);
588    panes[pane].parentNode.scrollTop = panes[pane].parentNode.scrollHeight;
589    return d;
590  }
591  function renderPane(tab) {
592    var p = panes[tab]; p.innerHTML = '';
593    var intro = document.createElement('div');
594    intro.className = 'mwai-intro';
595    intro.textContent = t('welcomeChat');
596    p.appendChild(intro);
597    convo[tab].forEach(function (m) {
598      if (m.role === 'system') return;
599      bubble(tab, m.role, m.content);
600    });
601  }
602
603  /* ================================================================
604     PAGE CONTEXT  (for the Tutor tab)
605     ================================================================ */
606  function pageContext() {
607    var host = document.querySelector('main') || document.body;
608    var txt = (host.innerText || '').replace(/\s+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
609    if (txt.length > CONFIG.maxContextChars)
610      txt = txt.slice(0, CONFIG.maxContextChars) + '\n[…truncated]';
611    return txt;
612  }
613  function selectionText() {
614    try { return (window.getSelection().toString() || '').trim(); } catch (e) { return ''; }
615  }
616
617  /* Curated epigraphy knowledge pools, if knowledge-loader.js is present and
618     the page declares a data-mwai-subject. Empty string otherwise, so the
619     assistant behaves identically on pages with no knowledge grounding. */
620  function knowledgeBlock() {
621    try {
622      if (window.MagaliaKnowledge && window.MagaliaKnowledge.getGrounding) {
623        var g = window.MagaliaKnowledge.getGrounding();
624        if (g) return '\n\n<knowledge_pools>\n' + g + '\n</knowledge_pools>';
625      }
626    } catch (e) {}
627    return '';
628  }
629
630  /* Per-subject system-prompt persona from knowledge-loader.js; '' if none. */
631  function profileText() {
632    try {
633      if (window.MagaliaKnowledge && window.MagaliaKnowledge.getProfile) {
634        return window.MagaliaKnowledge.getProfile() || '';
635      }
636    } catch (e) {}
637    return '';
638  }
639
640  var SYS = {
641    chat: function () {
642      var prof = profileText();
643      var lang = (S.lang === 'zh' ? 'Chinese unless asked otherwise.' : 'the user’s language.');
644      /* On landing pages the portal installs a navigator PROFILE (routing-only); honor it so
645         the Chat tab — the concierge "Ask" target — recommends destinations instead of
646         answering content questions. On content pages there is no profile → general chat. */
647      var head = prof
648        ? prof + ' Reply in ' + lang
649        : 'You are a helpful, concise assistant embedded on magalia.wiki, a classics & digital-'
650          + 'humanities teaching site. Answer clearly, grounded in the cited sources and page content '
651          + 'provided. Do NOT assert specific epigraphic facts — inscription readings, restorations, '
652          + 'datings, attributions, or ids — from your own training (it is measurably unreliable for '
653          + 'ancient languages); those come only from the site’s cited sources and specialist tools. '
654          + 'If the sources do not support an answer, say so plainly rather than guessing. '
655          + 'You can ALSO act as a Latin tutor for the text on THIS page — translate, parse sentences '
656          + 'word-by-word, explain grammar, and build study aids (quizzes, flashcards, summaries) — when asked. '
657          + 'For navigation, link only relative "/"-paths (e.g. /greek-grammar/grammar-pairings.html); never '
658          + 'prepend a host or invent a URL — the site is magalia.wiki, never .xyz. Reply in ' + lang;
659      return head + '\n\n<page_content>\n' + pageContext() + '\n</page_content>' + knowledgeBlock();
660    }
661  };
662
663  /* ================================================================
664     API CALL  (OpenAI-compatible /chat/completions, streaming)
665     ================================================================ */
666  function currentKey() {
667    if (S.route === 'shared') return 'shared';   // no per-user key; the worker holds it
668    if (S.route === 'openrouter') return S.key_openrouter;
669    if (!CONFIG.direct[S.model]) return S.key_openrouter;  // no native route (e.g. anthropic) → via OpenRouter
670    if (S.model === 'qwen') return S.key_qwen;
671    if (S.model === 'kimi') return S.key_kimi;
672    if (S.model === 'openai') return S.key_openai;
673    if (S.model === 'glm') return S.key_glm;
674    return S.key_deepseek;
675  }
676  /* Normalise a user-entered CORS relay URL. A relay pasted WITHOUT a scheme
677     (e.g. "magalia-relay.example.workers.dev") is not an absolute URL, so
678     fetch() resolves it RELATIVE to the current page → the POST lands on
679     magalia.wiki's nginx, which answers 405 (the "405 Not Allowed · nginx"
680     symptom). Prepend https:// when no scheme is present, and strip trailing
681     slashes. workers.dev / relays are always https, so this is safe. */
682  function normalizeRelay(u) {
683    u = (u || '').trim().replace(/\/+$/, '');
684    if (u && !/^https?:\/\//i.test(u)) u = 'https://' + u;
685    return u;
686  }
687  function resolveCall() {
688    /* -> { url, headers, model } */
689    var key = currentKey();
690    if (S.route === 'shared') {
691      /* server-side shared key: no Authorization; the worker picks the model */
692      return {
693        url: S.sharedEndpoint,
694        model: S.mid || CONFIG.shared.model || 'shared',
695        headers: { 'Content-Type': 'application/json' }
696      };
697    }
698    /* OpenRouter route — OR a Direct-route model with no native endpoint (e.g. anthropic),
699       which falls back to OpenRouter's uniform /chat/completions format. */
700    if (S.route === 'openrouter' || !CONFIG.direct[S.model]) {
701      return {
702        url: CONFIG.openrouter.endpoint,
703        model: S.mid || CONFIG.openrouter.models[S.model],
704        headers: {
705          'Content-Type': 'application/json',
706          'Authorization': 'Bearer ' + key,
707          'HTTP-Referer': location.origin || 'https://magalia.wiki',
708          'X-Title': 'magalia.wiki'
709        }
710      };
711    }
712    /* direct route -> through relay */
713    var d = CONFIG.direct[S.model];
714    var relay = normalizeRelay(S.relay);
715    return {
716      url: relay + (relay.indexOf('?') >= 0 ? '&' : '?') + 'url=' + encodeURIComponent(d.upstream),
717      model: S.mid || d.model,
718      headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + key }
719    };
720  }
721
722  /* G2 bridge: a single non-streamed completion with tool definitions — the agent
723     layer (js/magalia-agent.js) drives multi-call loops through this. */
724  function completeChat(messages, tools, signal) {
725    var key = currentKey();
726    if (!key) return Promise.reject(new Error(t('needKey')));
727    if (S.route === 'direct' && !S.relay) return Promise.reject(new Error(t('relayHint')));
728    var call = resolveCall();
729    var body = { model: call.model, messages: messages,
730                 temperature: CONFIG.temperature, max_tokens: CONFIG.maxTokens };
731    if (tools && tools.length) body.tools = tools;
732    return fetch(call.url, { method: 'POST', headers: call.headers, signal: signal,
733                             body: JSON.stringify(body) })
734      .then(function (r) {
735        if (!r.ok) return r.text().then(function (tx) {
736          // same relay-misconfig detector as streamChat: an nginx/Apache HTML page
737          // (not JSON) means the Direct-route request never reached the CORS relay.
738          var looksLikeServerPage = /<html|nginx|Not Allowed|cloudflare|apache/i.test(tx) && tx.indexOf('{') !== 0;
739          if (S.route === 'direct' && looksLikeServerPage)
740            throw new Error('[' + r.status + '] Your CORS relay URL is not serving the worker — the reply came from a plain web server, not the relay. Redeploy assistant-relay-worker.js (Cloudflare Workers) and paste its exact https:// URL, or switch Route to "OpenRouter". Relay set to: ' + (S.relay || '(none)'));
741          throw new Error('HTTP ' + r.status + ' ' + tx.slice(0, 160));
742        });
743        return r.json();
744      })
745      .then(function (d) { return (d.choices && d.choices[0] && d.choices[0].message) || {}; });
746  }
747  window.__mwAssistantLLM = { complete: completeChat, lang: function () { return S.lang; },
748                              hasKey: function () { return !!currentKey(); } };
749
750  function streamChat(messages, onToken, onDone, onError) {
751    var key = currentKey();
752    if (!key) { onError(t('needKey')); return; }
753    if (S.route === 'direct' && !S.relay) { onError(t('relayHint')); return; }
754    var call = resolveCall();
755    abortCtl = new AbortController();
756    fetch(call.url, {
757      method: 'POST',
758      headers: call.headers,
759      signal: abortCtl.signal,
760      body: JSON.stringify({
761        model: call.model,
762        messages: messages,
763        temperature: CONFIG.temperature,
764        max_tokens: CONFIG.maxTokens,
765        stream: true
766      })
767    }).then(function (res) {
768      if (!res.ok) {
769        return res.text().then(function (tx) {
770          // Relay-misconfig detector: on the Direct route the request goes through your CORS relay.
771          // If the reply is an nginx/Apache HTML error page (not JSON), the relay URL is NOT pointing
772          // at the deployed Cloudflare Worker — that's the cause, not the key. (Seen: "405 Not Allowed
773          // — nginx/1.26.3".) Give an actionable message instead of dumping the HTML.
774          var looksLikeServerPage = /<html|nginx|Not Allowed|cloudflare|apache/i.test(tx) && tx.indexOf('{') !== 0;
775          if (S.route === 'direct' && looksLikeServerPage) {
776            onError('[' + res.status + '] Your CORS relay URL is not serving the worker — the reply came from a plain web server, not the relay. Redeploy assistant-relay-worker.js (Cloudflare Workers) and paste its exact URL, OR switch Route to "OpenRouter" (no relay needed). Relay set to: ' + (S.relay || '(none)'));
777            return;
778          }
779          var msg = tx;
780          try { msg = JSON.parse(tx).error.message || tx; } catch (e) {}
781          if (res.status === 401 || res.status === 403)
782            msg += '\n\n' + (S.route === 'openrouter' ? t('or401') : t('direct401'));
783          onError('[' + res.status + '] ' + msg);
784        });
785      }
786      var reader = res.body.getReader(), dec = new TextDecoder(), buf = '';
787      function pump() {
788        return reader.read().then(function (r) {
789          if (r.done) { onDone(); return; }
790          buf += dec.decode(r.value, { stream: true });
791          var parts = buf.split('\n');
792          buf = parts.pop();
793          parts.forEach(function (line) {
794            line = line.trim();
795            if (!line || line.indexOf('data:') !== 0) return;
796            var payload = line.slice(5).trim();
797            if (payload === '[DONE]') return;
798            try {
799              var j = JSON.parse(payload);
800              var delta = j.choices && j.choices[0] && j.choices[0].delta;
801              if (delta && delta.content) onToken(delta.content);
802            } catch (e) {}
803          });
804          return pump();
805        });
806      }
807      return pump();
808    }).catch(function (err) {
809      if (err && err.name === 'AbortError') { onDone(); return; }
810      onError(t('corsErr'));
811    });
812  }
813
814  /* ================================================================
815     SEND FLOW
816     ================================================================ */
817  function send(text) {
818    if (busy) return;
819    text = (text || '').trim();
820    if (!text) return;
821    /* a workflow preset is armed: the typed text is its input, not a chat turn */
822    if (pendingWorkflow && window.MagaliaWorkflows) {
823      var wfId = pendingWorkflow; pendingWorkflow = null;
824      runWorkflow(wfId, text);
825      return;
826    }
827    var tab = activeTab;
828    if (!convo[tab].length || convo[tab][0].role !== 'system')
829      convo[tab].unshift({ role: 'system', content: SYS[tab]() });
830    else
831      convo[tab][0].content = SYS[tab]();           // refresh page context
832
833    var shown = text, payloadText = text;
834    if (tab === 'chat') {
835      var sel = selectionText();
836      if (sel && sel.length > 4 && text.indexOf(sel.slice(0, 20)) < 0) {
837        payloadText = 'Selected passage:\n«' + sel + '»\n\nMy question: ' + text;
838        shown = text + '\n— ' + t('selUsed');
839      }
840    }
841    bubble(tab, 'user', shown);
842    convo[tab].push({ role: 'user', content: payloadText });
843    elInput.value = '';
844    autoGrow();
845
846    /* NO-KEY navigation fallback (Chat tab): rather than erroring with "set a key",
847       answer with deterministic page suggestions from the portal router — so the
848       chat guides the user to real pages even with no API key configured. */
849    var hasKey = window.__mwAssistantLLM && __mwAssistantLLM.hasKey && __mwAssistantLLM.hasKey();
850    if (tab === 'chat' && !hasKey && window.MagaliaPortal && MagaliaPortal.suggest) {
851      var nq = text;
852      predictItems(nq).then(function (items) {
853        var lead = items.length
854          ? (S.lang === 'zh' ? '最相关的页面(无需密钥,直接点击):' : 'The most relevant pages (no key needed — click to go):')
855          : (S.lang === 'zh' ? '未找到直接匹配,试试全站检索:' : 'No direct match — try the full search:');
856        var html = '<p>' + esc(lead) + '</p><div class="mwai-sg-row mwai-sg-inmsg">' + chipRow(items)
857          + '<a class="mwai-chip mwai-chip-search" href="/matrix-hub/discover.html?q=' + encodeURIComponent(nq) + '">'
858          + esc(S.lang === 'zh' ? '全站检索 ↗' : 'Search all corpora ↗') + '</a></div>'
859          + '<p style="font-size:.74rem;color:var(--mw-mut)">'
860          + esc(S.lang === 'zh' ? '想要 AI 解答?打开设置(⚙)添加密钥。' : 'Want AI answers? Add a key in Settings (⚙).') + '</p>';
861        var d = bubble(tab, 'assistant', '');
862        d.innerHTML = html;
863        convo[tab].push({ role: 'assistant', content: lead });
864        predict('');                                  // reset the live strip to the family quick-nav
865      });
866      return;
867    }
868
869    busy = true; elSend.classList.add('stop'); elSend.textContent = t('stop');
870    var node = bubble(tab, 'assistant', '');
871    node.classList.add('mwai-cur');
872    var acc = '';
873    function runChat() {
874    streamChat(convo[tab],
875      function (tok) { acc += tok; node.innerHTML = md(acc.split(/<followups/i)[0]);
876        node.classList.add('mwai-cur');
877        panes[tab].parentNode.scrollTop = panes[tab].parentNode.scrollHeight; },
878      function () {
879        node.classList.remove('mwai-cur');
880        if (acc) {
881          var fu = extractFollowups(acc);
882          node.innerHTML = md(fu.clean);
883          if (window.MagaliaRAG && MagaliaRAG.linkify) node.innerHTML = MagaliaRAG.linkify(node.innerHTML);
884          convo[tab].push({ role: 'assistant', content: fu.clean });
885          appendNavChips(node, text, tab);
886          appendSymbolonChips(node, fu.items);
887        }
888        else node.remove();
889        busy = false; abortCtl = null;
890        elSend.classList.remove('stop'); elSend.textContent = t('send');
891      },
892      function (msg) {
893        node.remove();
894        bubble(tab, 'error', t('err') + ': ' + msg);
895        busy = false; abortCtl = null;
896        elSend.classList.remove('stop'); elSend.textContent = t('send');
897      });
898    }
899    /* H.2 — Symbolon results awareness: if the workbench has exposed a result set, inject the
900       structured results + (capped) on-site full text as a system message before ANY branch
901       (agent / RAG / plain), so the chat + tasks can read results, work on the full text,
902       summarise, and suggest follow-up searches. No-op on pages without a result set. */
903    function proceed() {
904    /* RAG grounding: retrieve cited sources and inject them as a system
905       message (after the base system prompt) before calling the model. */
906    if (window.MagaliaAgent && MagaliaAgent.active && MagaliaAgent.active()) {
907      abortCtl = new AbortController();
908      MagaliaAgent.run(convo[tab].slice(), {
909        signal: abortCtl.signal,
910        onTrace: function (html) { node.innerHTML = html; node.classList.add('mwai-cur');
911          panes[tab].parentNode.scrollTop = panes[tab].parentNode.scrollHeight; }
912      }).then(function (res) {
913        node.classList.remove('mwai-cur');
914        var afu = extractFollowups(res.answer || '');
915        var body = md(afu.clean);
916        if (window.MagaliaRAG && MagaliaRAG.linkify) body = MagaliaRAG.linkify(body);
917        node.innerHTML = (res.traceHTML || '') + body;
918        if (res.answer) { convo[tab].push({ role: 'assistant', content: afu.clean }); appendNavChips(node, text, tab); appendSymbolonChips(node, afu.items); }
919        busy = false; elSend.classList.remove('stop'); elSend.textContent = t('send');
920      }, function (err) {
921        node.classList.remove('mwai-cur');
922        node.innerHTML = '<span style="color:#8b1a1a">' + esc(t('err')) + ': ' + esc(err && err.message || String(err)) + '</span>';
923        busy = false; elSend.classList.remove('stop'); elSend.textContent = t('send');
924      });
925    } else if (window.MagaliaRAG && MagaliaRAG.enabled) {
926      MagaliaRAG.augment(payloadText).then(function (ctx) {
927        if (ctx) convo[tab].splice(1, 0, { role: 'system', content: ctx });
928        runChat();
929      }, runChat);
930    } else { runChat(); }
931    }  /* end proceed() */
932    if (window.__symbolonResultsContext) {
933      try {
934        Promise.resolve(window.__symbolonResultsContext()).then(function (ctx) {
935          if (ctx) convo[tab].splice(1, 0, { role: 'system', content:
936            'The user is working in the Symbolon search workbench. Here are the CURRENT search '
937            + 'results (and the on-site full text of the top readable ones). Ground every answer in '
938            + 'THESE results — cite items by their id; never invent readings, datings, or ids '
939            + '(specialist-only facts). When asked, summarise/analyse across the set, or suggest '
940            + 'concrete follow-up searches (keyword · place · date range · date-axis).\n\n'
941            + 'To offer clickable follow-up searches, END your reply with a block:\n'
942            + '<followups>\n'
943            + '{"label":"short label","kw":"keyword","place":"place","from":-300,"to":-100,"mode":"produced"}\n'
944            + '</followups>\n'
945            + 'One JSON object per line (1–4 lines); every field optional except label; omit fields you '
946            + 'do not want to set; mode is "produced" or "about". Only include this block when a '
947            + 'follow-up search would genuinely help; otherwise omit it entirely.\n\n' + ctx });
948          proceed();
949        }, proceed);
950      } catch (e) { proceed(); }
951    } else { proceed(); }
952  }
953
954  /* ================================================================
955     TASKS
956     ================================================================ */
957  /* G3: workflow presets (from MagaliaWorkflows via the chained knowledge seam).
958     Click arms the workflow; the next send() runs the deterministic tool
959     sequence and renders its trace + cited synthesis. */
960  var pendingWorkflow = null;
961  function runWorkflow(id, text) {
962    if (busy) return;
963    var tab = 'chat';
964    bubble(tab, 'user', text);
965    elInput.value = ''; autoGrow();
966    busy = true; elSend.classList.add('stop'); elSend.textContent = t('stop');
967    var node = bubble(tab, 'assistant', '');
968    node.classList.add('mwai-cur');
969    abortCtl = new AbortController();
970    MagaliaWorkflows.run(id, text, {
971      signal: abortCtl.signal,
972      onTrace: function (html) { node.innerHTML = html;
973        panes[tab].parentNode.scrollTop = panes[tab].parentNode.scrollHeight; }
974    }).then(function (res) {
975      node.classList.remove('mwai-cur');
976      var body = md(res.answer || '');
977      if (window.MagaliaRAG && MagaliaRAG.linkify) body = MagaliaRAG.linkify(body);
978      node.innerHTML = (res.traceHTML || '') + body;
979      if (res.answer) {
980        convo[tab].push({ role: 'user', content: text });
981        convo[tab].push({ role: 'assistant', content: res.answer });
982      }
983      busy = false; abortCtl = null;
984      elSend.classList.remove('stop'); elSend.textContent = t('send');
985    }, function (err) {
986      node.remove();
987      bubble(tab, 'error', t('err') + ': ' + (err && err.message || String(err)));
988      busy = false; abortCtl = null;
989      elSend.classList.remove('stop'); elSend.textContent = t('send');
990    });
991  }
992
993  var TASKS = {
994    quiz:  { en: 'Create a 5-question quiz on the Latin content of this page — mix comprehension, '
995                 + 'grammar, and vocabulary. Number the questions. Do NOT reveal answers; end by inviting '
996                 + 'me to ask for the answer key.',
997             zh: '针对本页拉丁文内容出5道测验题,'
998                 + '混合阅读理解、语法与词汇。题目'
999                 + '编号。先不公布答案,结尾邀请我'
1000                 + '索取答案。' },
1001    cards: { en: 'Extract the 12 most important Latin vocabulary items from this page. For each give: '
1002                 + 'Latin headword with principal parts (or genitive + gender for nouns) — English gloss '
1003                 + '— Chinese gloss. Present as a clean numbered list.',
1004             zh: '从本页提取最重要的12个拉丁词汇。'
1005                 + '每个给出:拉丁词头及主要变位'
1006                 + '(名词给属格与性)— 英文释义 '
1007                 + '— 中文释义。用清晰的编号列表。' },
1008    sum:   { en: 'Summarise this page in about 150 words: which Latin text/episode it c
1008overs, the key '
1009                 + 'grammar points it teaches, and the learning goals.',
1010             zh: '用约150字总结本页:涵盖哪段拉丁'
1011                 + '文/战役、所教关键语法点与学习'
1012                 + '目标。' },
1013    parse: { en: 'Choose the single most instructive Latin sentence on this page and give a full parse: '
1014                 + 'word-by-word morphology, the syntax of each clause, then a literal translation and an '
1015                 + 'idiomatic one.',
1016             zh: '选出本页最具教学价值的一个拉丁'
1017                 + '句,做完整解析:逐词形态、各'
1018                 + '从句语法,再给出直译与意译。' }
1019  };
1020  /* Page-supplied presets (e.g. js/magalia-research.js on edition pages) win over
1021     the Latin-course defaults. Shape: [{label:{en,zh}, prompt:{en,zh}}, …]. */
1022  function externalPresets() {
1023    try {
1024      if (window.MagaliaKnowledge && window.MagaliaKnowledge.getTaskPresets) {
1025        var ext = window.MagaliaKnowledge.getTaskPresets();
1026        if (ext && ext.length) return ext;
1027      }
1028    } catch (e) {}
1029    return null;
1030  }
1031  /* Workflow chips come straight from MagaliaWorkflows — NOT only via the
1032     knowledge seam, because later defer layers (knowledge-loader, research)
1033     replace window.MagaliaKnowledge and would silently drop chained presets.
1034     Dedup against any that did survive the chain. */
1035  function workflowPresets(existing) {
1036    var out = [];
1037    try {
1038      if (window.MagaliaWorkflows && window.__mwAssistantLLM) {
1039        var have = {};
1040        (existing || []).forEach(function (p) { if (p.workflow) have[p.workflow] = 1; });
1041        MagaliaWorkflows.available().forEach(function (w) {
1042          if (!have[w.id]) out.push({ label: w.label, workflow: w.id, hint: w.hint });
1043        });
1044      }
1045    } catch (e) {}
1046    return out;
1047  }
1048  function buildTaskbar() {
1049    elTaskbar.innerHTML = '';
1050    var ext = externalPresets();
1051    var wf = workflowPresets(ext);
1052    function addPreset(p) {
1053      var b = document.createElement('button');
1054      b.type = 'button';
1055      b.textContent = (p.label && (p.label[S.lang] || p.label.en)) || '?';
1056      b.addEventListener('click', function () {
1057        if (busy) return;
1058        if (p.workflow) {
1059          var txt = elInput.value.trim();
1060          if (txt && window.MagaliaWorkflows) { pendingWorkflow = null; runWorkflow(p.workflow, txt); return; }
1061          pendingWorkflow = p.workflow;
1062          elInput.placeholder = (p.hint && (p.hint[S.lang] || p.hint.en)) || '';
1063          elInput.focus();
1064          return;
1065        }
1066        send((p.prompt && (p.prompt[S.lang] || p.prompt.en)) || '');
1067      });
1068      elTaskbar.appendChild(b);
1069    }
1070    // Always offer the page-context quick actions (the former Tutor/Tasks defaults),
1071    // then any page-specific external presets, then workflow presets.
1072    [['quiz', 't_quiz'], ['cards', 't_cards'], ['sum', 't_sum'], ['parse', 't_parse']]
1073      .forEach(function (pair) {
1074        var b = document.createElement('button');
1075        b.type = 'button'; b.textContent = t(pair[1]);
1076        b.addEventListener('click', function () {
1077          if (busy) return;
1078          send(TASKS[pair[0]][S.lang] || TASKS[pair[0]].en);
1079        });
1080        elTaskbar.appendChild(b);
1081      });
1082    if (ext) ext.forEach(addPreset);
1083    wf.forEach(addPreset);
1084  }
1085
1086  /* ================================================================
1087     SETTINGS PANE
1088     ================================================================ */
1089  function buildSettings() {
1090    var p = panes.set;
1091    p.innerHTML = ''
1092      + '<div class="mwai-track">'
1093      +   '<div class="mwai-track-h">' + t('trackHead') + '</div>'
1094      +   '<div class="mwai-track-row mwai-tk-nokey"><span class="mwai-tk-dot"></span> ' + t('trackNoKey') + ' <em class="mwai-tk-on"></em></div>'
1095      +   '<div class="mwai-track-row mwai-tk-byok"><span class="mwai-tk-dot"></span> ' + t('trackByok') + ' <em class="mwai-tk-on"></em></div>'
1096      + '</div>'
1097      + '<label>' + t('route') + '</label>'
1098      + '<div class="mwai-seg mwai-seg-route">'
1099      +   '<button data-r="openrouter">
1099' + t('r_openrouter') + '</button>'
1100      +   '<button data-r="direct">' + t('r_direct') + '</button>'
1101      + '</div>'
1102      + '<label class="mwai-l-key">' + t('apikey') + '</label>'
1103      + '<input type="password" class="mwai-in-key" autocomplete="off" placeholder="sk-…">'
1104      + '<div class="hint mwai-key-hint"></div>'
1105      + '<div class="mwai-relay-wrap">'
1106      +   '<label>' + t('relayUrl') + '</label>'
1107      +   '<input type="text" class="mwai-in-relay" placeholder="https://your-relay.workers.dev">'
1108      +   '<div class="hint">' + t('relayHint') + '</div>'
1109      + '</div>'
1110      + '<label>' + t('midLabel') + '</label>'
1111      + '<input type="text" class="mwai-in-mid" placeholder="(use default)">'
1112      + '<label>' + t('lang') + '</label>'
1113      + '<div class="mwai-seg mwai-seg-lang">'
1114      +   '<button data-l="en">English</button><button data-l="zh">中文</button>'
1115      + '</div>'
1116      + '<div class="mwai-saved"></div>';
1117
1118    var inKey = p.querySelector('.mwai-in-key'),
1119        inRelay = p.querySelector('.mwai-in-relay'),
1120        inMid = p.querySelector('.mwai-in-mid'),
1121        keyHint = p.querySelector('.mwai-key-hint'),
1122        relayWrap = p.querySelector('.mwai-relay-wrap'),
1123        saved = p.querySelector('.mwai-saved');
1124
1125    function flash() { saved.textContent = t('save'); clearTimeout(flash._t);
1126      flash._t = setTimeout(function () { saved.textContent = ''; }, 1400); }
1127
1128    function paintTrack() {           // live no-key⇄BYOK indicator on the settings banner
1129      var byok = !!currentKey();
1130      var rN = p.querySelector('.mwai-tk-nokey'), rB = p.querySelector('.mwai-tk-byok');
1131      if (!rN || !rB) return;
1132      rN.classList.toggle('on', !byok); rB.classList.toggle('on', byok);
1133      rN.querySelector('.mwai-tk-dot').textContent = byok ? '○' : '●';
1134      rB.querySelector('.mwai-tk-dot').textContent = byok ? '●' : '○';
1135      rN.querySelector('.mwai-tk-on').textContent = byok ? '' : '(' + t('trackOn') + ')';
1136      rB.querySelector('.mwai-tk-on').textContent = byok ? '(' + t('trackOn') + ')' : '';
1137    }
1138    function syncSettings() {
1139      paintTrack();
1140      p.querySelectorAll('.mwai-seg-route button').forEach(function (b) {
1141        b.classList.toggle('on', b.dataset.r === S.route); });
1142      p.querySelectorAll('.mwai-seg-lang button').forEach(function (b) {
1143        b.classList.toggle('on', b.dataset.l === S.lang); });
1144      relayWrap.style.display = S.route === 'direct' ? '' : 'none';
1145      inRelay.value = S.relay;
1146      inMid.value = S.mid;
1147      if (S.route === 'openrouter' || !CONFIG.direct[S.model]) { inKey.value = S.key_openrouter; keyHint.textContent = t('keyHintOR'); }
1148      else if (S.model === 'qwen') { inKey.value = S.key_qwen; keyHint.textContent = t('keyHintQ'); }
1149      else if (S.model === 'kimi') { inKey.value = S.key_kimi; keyHint.textContent = t('keyHintK'); }
1150      else if (S.model === 'openai') { inKey.value = S.key_openai; keyHint.textContent = t('keyHintO'); }
1151      else if (S.model === 'glm') { inKey.value = S.key_glm; keyHint.textContent = t('keyHintG'); }
1152      else { inKey.value = S.key_deepseek; keyHint.textContent = t('keyHintD'); }
1153    }
1154    syncSettings();
1155    buildSettings._sync = syncSettings;
1156
1157    p.querySelectorAll('.mwai-seg-route button').forEach(function (b) {
1158      b.addEventListener('click', function () {
1159        S.route = b.dataset.r; LS.set('route', S.route); syncSettings(); flash(); });
1160    });
1161    p.querySelectorAll('.mwai-seg-lang button').forEach(function (b) {
1162      b.addEventListener('click', function () {
1163        S.lang = b.dataset.l; LS.set('lang', S.lang);
1164        paintLabels(); buildTaskbar(); buildSettings();
1165        renderPane('chat');
1166        openTab('set'); });
1167    });
1168    inKey.addEventListener('input', function () {
1169      /* sanitise: drop surrounding quotes, a stray "Bearer " prefix, and
1170         any whitespace/newlines a paste may have introduced */
1171      var v = inKey.value.trim()
1172        .replace(/^['"]+|['"]+$/g, '')
1173        .replace(/^Bearer\s+/i, '')
1174        .replace(/\s+/g, '');
1175      if (v !== inKey.value) inKey.value = v;
1176      if (S.route === 'openrouter' || !CONFIG.direct[S.model]) { S.key_openrouter = v; LS.set('key_openrouter', v); }
1177      else if (S.model === 'qwen') { S.key_qwen = v; LS.set('key_qwen', v); }
1178      else if (S.model === 'kimi') { S.key_kimi = v; LS.set('key_kimi', v); }
1179      else if (S.model === 'openai') { S.key_openai = v; LS.set('key_openai', v); }
1180      else if (S.model === 'glm') { S.key_glm = v; LS.set('key_glm', v); }
1181      else { S.key_deepseek = v; LS.set('key_deepseek', v); }
1182      paintTrack();           // flip the track indicator live (without resetting inKey.value)
1183      flash();
1184    });
1185    inRelay.addEventListener('input', function () {
1186      S.relay = normalizeRelay(inRelay.value); LS.set('relay', S.relay); flash(); });
1187    inMid.addEventListener('input', function () {
1188      S.mid = inMid.value.trim(); LS.set('mid', S.mid); flash(); });
1189  }
1190
1191  /* ================================================================
1192     TAB / PANEL CONTROL
1193     ================================================================ */
1194  function openTab(tab) {                              // unified surface: chat ⇄ settings
1195    activeTab = tab === 'set' ? activeTab : 'chat';
1196    panes.chat.classList.toggle('mwai-hide', tab === 'set');
1197    panes.set.classList.toggle('mwai-hide', tab !== 'set');
1198    elTaskbar.classList.toggle('mwai-hide', tab === 'set');   // quick-action chips on chat only
1199    elSuggest.classList.toggle('mwai-hide', tab === 'set');   // predictive strip on chat only
1200    if (tab !== 'set') { lastPredictQ = null; predict(elInput.value); }
1201    $('.mwai-input').classList.toggle('mwai-hide', tab === 'set');
1202    paintLabels();
1203  }
1204  function togglePanel(open) {
1205    var show = open === undefined ? elPanel.classList.contains('mwai-hide') : open;
1206    elPanel.classList.toggle('mwai-hide', !show);
1207    // Keep the launcher hidden in a framed context even after the panel closes (#6):
1208    // the shell owns the one launcher. "show || !SHOW_LAUNCHER" makes suppression sticky.
1209    elLaunch.classList.toggle('mwai-hide', show || !SHOW_LAUNCHER);
1210    if (show && !togglePanel._init) {
1211      togglePanel._init = true;
1212      buildSettings(); buildTaskbar();
1213      renderPane('chat');
1214      openTab('chat');
1215    }
1216    if (show) elPanel.classList.toggle('mwai-folded', S.folded);   // restore persisted fold
1217  }
1218  /* fold = collapse to a thin right-edge tab (conversation stays in memory); distinct
1219     from full-close (the ✕). Persisted in localStorage (mw_ai_folded). */
1220  function setFolded(folded) {
1221    S.folded = !!folded;
1222    LS.set('folded', S.folded ? '1' : '');
1223    elPanel.classList.toggle('mwai-folded', S.folded);
1224    if (elFold) { elFold.textContent = S.folded ? '⟨' : '⟩';
1225      elFold.title = S.folded ? t('unfold') : t('fold'); }
1226    if (!S.folded) { try { elInput.focus(); } catch (e) {} }
1227  }
1228
1229  /* ================================================================
1230     EVENTS
1231     ================================================================ */
1232  function autoGrow() {
1233    elInput.style.height = '38px';
1234    elInput.style.height = Math.min(elInput.scrollHeight, 120) + 'px';
1235  }
1236  elLaunch.addEventListener('click', function () { togglePanel(true); });
1237  $('.mwai-x').addEventListener('click', function () { togglePanel(false); });
1238  elFold.addEventListener('click', function (e) { e.stopPropagation(); setFolded(true); });
1239  elFoldedTab.addEventListener('click', function () { setFolded(false); });
1240  $('.mwai-gear').addEventListener('click', function () {
1241    openTab(panes.set.classList.contains('mwai-hide') ? 'set' : activeTab);
1242  });
1243  elModel.addEventListener('change', function () {
1244    S.model = elModel.value; LS.set('model', S.model);
1245    if (buildSettings._sync) buildSettings._sync();
1246  });
1247  elSend.addEventListener('click', function () {
1248    if (busy) { if (abortCtl) abortCtl.abort(); return; }
1249    send(elInput.value);
1250  });
1251  elInput.addEventListener('input', function () { autoGrow(); if (activeTab === 'chat') predictDebounced(elInput.value); });
1252  elInput.addEventListener('keydown', function (e) {
1253    if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(elInput.value); }
1254  });
1255  document.addEventListener('keydown', function (e) {
1256    if (e.key !== 'Escape' || elPanel.classList.contains('mwai-hide')) return;
1257    if (S.folded) setFolded(false);            // Escape unfolds first, then closes
1258    else togglePanel(false);
1259  });
1260
1261  /* ================================================================
1262     PUBLIC API — the concierge "Ask" lane hands a query here (Phase 2).
1263     ask(query) opens the panel, switches to the Chat tab (which routes via
1264     the navigator PROFILE on landing pages), seeds + sends the query. If no
1265     key/route is configured, the panel still opens so the user sees the ⚙
1266     settings prompt (graceful degradation — no silent failure).
1267     ================================================================ */
1268  /* ── Tier-C disambiguation (v2): map a natural-language query to ONE existing cited
1269     entry, or to nothing. The model is a SELECTOR, never an author — it picks an id from
1270     a fixed candidate list (all real, cited entries) and the caller renders that entry's
1271     own deterministic card. This preserves route-cite-never-invent: no definition is ever
1272     generated. Returns Promise<{idx:Number, why:String} | null>. Rejects → caller degrades
1273     to deterministic-only. Requires a key (BYOK or shared route); silent no-op otherwise. */
1274  var INTERPRET_PROFILE =
1275    'You match a visitor\'s question to ONE entry in a fixed numbered list, or to nothing.\n' +
1276    'Rules:\n' +
1277    '- Pick the single entry whose term best ANSWERS or NAMES what the question is about.\n' +
1278    '- If no entry clearly fits, return null. Do not guess.\n' +
1279    '- You may NOT write, define, translate, or explain anything. You only select.\n' +
1280    '- "why" is the CONNECTION in <=10 words (e.g. "freeing slaves = manumission"), never a definition.\n' +
1281    'Output STRICT JSON on one line, nothing else: {"id": <number or null>, "why": "<<=10 words>"}';
1282
1283  function interpret(query, candidates) {
1284    var LLM = window.__mwAssistantLLM;
1285    query = (query || '').trim();
1286    if (!LLM || !LLM.hasKey || !LLM.hasKey() || !query || !candidates || !candidates.length) {
1287      return Promise.resolve(null);
1288    }
1289    var list = candidates.slice(0, 12).map(function (c) {
1290      return '#' + c.idx + ' [' + c.type + '] ' + String(c.label || '').slice(0, 60)
1291           + (c.gloss ? ' — ' + String(c.gloss).slice(0, 80) : '');
1292    }).join('\n');
1293    var valid = {}; candidates.forEach(function (c) { valid[c.idx] = 1; });
1294    var messages = [
1295      { role: 'system', content: INTERPRET_PROFILE },
1296      { role: 'user', content: 'Question: "' + query + '"\n\nEntries:\n' + list + '\n\nReturn JSON.' }
1297    ];
1298    var ctl = (typeof AbortController !== 'undefined') ? new AbortController() : null;
1299    if (ctl) setTimeout(function () { try { ctl.abort(); } catch (e) {} }, 12000);  // hard cap
1300    return LLM.complete(messages, null, ctl && ctl.signal).then(function (msg) {
1301      var txt = (msg && msg.content) || '';
1302      var m = txt.match(/\{[\s\S]*\}/);                 // first JSON object in the reply
1303      if (!m) return null;
1304      var o; try { o = JSON.parse(m[0]); } catch (e) { return null; }
1305      if (o == null || o.id == null) return null;
1306      var idx = Number(o.id);
1307      if (!valid[idx]) return null;                     // model must pick from the supplied set
1308      return { idx: idx, why: String(o.why || '').slice(0, 80) };
1309    }).catch(function () { return null; });
1310  }
1311
1312  (window.MagaliaTools = window.MagaliaTools || { q: [], register: function (e) { this.q.push(e); } })
1313    .register({ id: 'assistant', order: 50, label: 'Assistant', zh: '助手', run: function () { togglePanel(true); setFolded(false); } });
1314  window.MagaliaAssistant = {
1315    open: function () { togglePanel(true); setFolded(false); },   // explicit open → never leave it folded
1316    interpret: interpret,
1317    ask: function (query, opts) {
1318      togglePanel(true);
1319      setFolded(false);                              // a programmatic query must be visible, not behind the edge tab
1320      openTab('chat');
1321      query = (query || '').trim();
1322      if (!query) { try { elInput.focus(); } catch (e) {} return; }
1323      var ctx = opts && opts.context ? String(opts.context).slice(0, 400) : '';
1324      send(ctx ? ('Context: ' + ctx + '\n\n' + query) : query);
1325    }
1326  };
1327
1328  paintLabels();
1329})();

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.