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 { '&': '&', '<': '<', '>': '>' }[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.