made-bridge 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -2
  3. package/builtin/plugins/README.md +22 -0
  4. package/builtin/themes/README.md +64 -0
  5. package/builtin/themes/sunset/theme.json +19 -0
  6. package/config.example.json +20 -0
  7. package/demo-project/README.md +5 -0
  8. package/demo-project/index.mjs +14 -0
  9. package/package.json +48 -4
  10. package/public/app.js +1028 -0
  11. package/public/assistants.html +67 -0
  12. package/public/bean-icon.png +0 -0
  13. package/public/i18n-messages.js +967 -0
  14. package/public/i18n-static.js +615 -0
  15. package/public/i18n.js +50 -0
  16. package/public/index.html +149 -0
  17. package/public/made-icon.png +0 -0
  18. package/public/plugins.html +64 -0
  19. package/public/plugins.js +335 -0
  20. package/public/provider-icon.js +77 -0
  21. package/public/styles.css +445 -0
  22. package/public/tasks.html +85 -0
  23. package/src/agent-icons.mjs +138 -0
  24. package/src/agents/codex.mjs +94 -0
  25. package/src/agents/cursor.mjs +31 -0
  26. package/src/agents/events.mjs +92 -0
  27. package/src/agents/index.mjs +7 -0
  28. package/src/agents/qoder.mjs +34 -0
  29. package/src/agents/shared.mjs +191 -0
  30. package/src/agents/workbuddy.mjs +121 -0
  31. package/src/board-link.mjs +266 -0
  32. package/src/config.mjs +196 -0
  33. package/src/doctor.mjs +146 -0
  34. package/src/i18n.mjs +257 -0
  35. package/src/installed.mjs +291 -0
  36. package/src/jobs.mjs +369 -0
  37. package/src/language-settings.mjs +38 -0
  38. package/src/model-settings.mjs +63 -0
  39. package/src/openai-key.mjs +47 -0
  40. package/src/oray.mjs +243 -0
  41. package/src/pairing.mjs +203 -0
  42. package/src/plugin-package.mjs +132 -0
  43. package/src/plugin-runtime.mjs +169 -0
  44. package/src/plugins/contracts.d.ts +184 -0
  45. package/src/plugins/enhance.mjs +156 -0
  46. package/src/plugins/registry.mjs +146 -0
  47. package/src/providers.mjs +75 -0
  48. package/src/server.mjs +950 -0
  49. package/src/sessions.mjs +134 -0
  50. package/src/speech/index.mjs +10 -0
  51. package/src/speech/openai.mjs +48 -0
  52. package/src/speech/whisper.mjs +59 -0
  53. package/src/speech-settings.mjs +209 -0
  54. package/src/transcribe.mjs +85 -0
  55. package/src/tunnel.mjs +531 -0
  56. package/src/usb-receiver.mjs +664 -0
  57. package/src/voice-settings.mjs +69 -0
@@ -0,0 +1,149 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="theme-color" content="#faf3e7">
7
+ <title data-i18n="static.001">大盘 · 码得</title>
8
+ <link rel="icon" type="image/png" sizes="112x112" href="/made-icon.png">
9
+ <link rel="stylesheet" href="/styles.css">
10
+ </head>
11
+ <body>
12
+ <div class="page-glow" aria-hidden="true"></div>
13
+ <div class="app">
14
+ <header class="topbar">
15
+ <a class="brand" href="/" aria-label="码得大盘" data-i18n-aria-label="static.002">
16
+ <span class="brand-mark" aria-hidden="true"><img src="/made-icon.png" alt="" width="112" height="112"></span>
17
+ <span><span class="brand-name"><span data-i18n="static.003">码得</span></span><span class="brand-sub"><span data-i18n="static.004">made · CODING COMPANION</span></span></span>
18
+ </a>
19
+ <nav class="topnav" aria-label="页面导航" data-i18n-aria-label="static.005"><a class="active" aria-current="page" href="/"><span data-i18n="static.006">大盘</span></a><a href="/assistants.html"><span data-i18n="static.007">助手</span></a><a href="/tasks.html"><span data-i18n="static.008">任务</span></a><a href="/plugins.html"><span data-i18n="static.140">插件</span></a></nav>
20
+ <div class="language-picker"><label for="languageSelect" data-i18n="language.label">语言</label><select id="languageSelect" aria-label="界面语言" data-i18n-aria-label="language.label"><option value="zh-CN">简体中文</option><option value="en">English</option></select></div>
21
+ <div class="top-actions">
22
+ <span id="headerConnection" class="connection-pill"><span class="dot"></span><span><span data-i18n="static.009">正在连接</span></span></span>
23
+ <button id="refreshButton" class="icon-button" type="button" title="刷新状态" aria-label="刷新状态" data-i18n-title="static.010" data-i18n-aria-label="static.010"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 11a8 8 0 1 0-2.3 6.1"/><path d="M20 5v6h-6"/></svg></button>
24
+ </div>
25
+ </header>
26
+
27
+ <main id="top">
28
+ <section class="hero" aria-labelledby="hero-title">
29
+ <div><span class="eyebrow"><span data-i18n="static.011">ESP32-S3 · made</span></span><h1 id="hero-title"><span data-i18n="static.012">码得,</span><em><span data-i18n="static.013">代码触手可得</span></em></h1><p class="hero-desc"><span data-i18n="static.014">在这里查看码得交互、配对设备并配置语音输入。编程模型在“助手”设置,任务创建和管理在“任务”;小智继续作为独立应用运行。</span></p></div>
30
+ </section>
31
+
32
+ <div id="workspace" class="dashboard-layout">
33
+ <div class="stack">
34
+ <section id="preview" class="panel preview" aria-labelledby="preview-title">
35
+ <div class="panel-head"><div><h2 id="preview-title"><span data-i18n="static.015">码得交互预览</span></h2><p class="panel-intro"><span data-i18n="static.016">任务在设备上的显示和操作方式。</span></p></div><span class="preview-label"><span data-i18n="static.017">360 × 360 · 网页模拟</span></span></div>
36
+ <div class="board-stage">
37
+ <div class="board-shell">
38
+ <div id="boardScreen" class="board-screen" role="group" tabindex="0" aria-label="码得交互预览:左右滑动换助手,顶部上下滑动换任务" data-i18n-aria-label="static.018">
39
+ <div id="boardAnimal" class="board-animal" aria-hidden="true" hidden></div>
40
+ <button id="boardNewSession" class="board-new-session" type="button" hidden disabled title="新建任务" aria-label="新建任务" data-i18n-title="static.019" data-i18n-aria-label="static.019">+</button>
41
+ <div id="boardProviderName" class="board-provider-name"><span data-i18n="static.020">等待连接</span></div>
42
+ <span id="boardPage" class="board-page">—</span>
43
+ <div id="boardStatus" class="board-status"><span data-i18n="static.021">连接后显示助手</span></div>
44
+ <div id="boardTitle" class="board-title"></div>
45
+ <div id="boardResult" class="board-result" role="region" tabindex="0" aria-label="任务答复,可上下滚动" data-i18n-aria-label="static.022"></div>
46
+ </div>
47
+ </div>
48
+ </div>
49
+ <p class="board-footnote"><span data-i18n="static.023">左右滑动换助手,顶部上下滑动换任务;答复区上下滑动阅读。单击 BOOT 录音,静音后自动上传;双击确认,三击取消,长按返回桌面。</span></p>
50
+ </section>
51
+ <section id="pairing" class="panel info-panel" aria-labelledby="pairing-title">
52
+ <div class="panel-head pairing-head">
53
+ <div><h2 id="pairing-title"><span data-i18n="static.024">码得配对</span></h2><p class="panel-intro"><span data-i18n="static.025">USB 直连只需一条数据线;也支持同 Wi-Fi、独立接收端热点和公网地址。两端核对六位码后确认。</span></p></div>
54
+ <div class="pairing-actions"><button id="pairingShowButton" class="small-button confirm" type="button" hidden><span data-i18n="static.026">查看请求</span></button><button id="pairingOpenButton" class="small-button confirm" type="button"><span data-i18n="static.027">添加码得</span></button><button id="pairingCloseButton" class="small-button" type="button" hidden><span data-i18n="static.028">结束添加</span></button></div>
55
+ </div>
56
+ <div id="pairingState" class="pairing-state" role="status" aria-live="polite"><span data-i18n="static.029">正在读取配对状态…</span></div>
57
+ <div id="pairedDevices" class="pairing-list" aria-label="已配对设备" data-i18n-aria-label="static.030"></div>
58
+ </section>
59
+
60
+ <section id="receiver" class="panel info-panel" aria-labelledby="receiver-title">
61
+ <div class="panel-head"><div><h2 id="receiver-title"><span data-i18n="static.031">USB 连接</span></h2><p class="panel-intro"><span data-i18n="static.032">码得可通过 USB 数据线直接连接这台电脑,不用 Wi-Fi。也支持独立 ESP32-S3 / C3 接收端提供专用热点。</span></p></div></div>
62
+ <p id="receiverStatus" class="pairing-state" role="status" aria-live="polite"><span data-i18n="static.033">正在检测 USB 连接…</span></p>
63
+ <div id="receiverDirectDetails" class="receiver-details" hidden>
64
+ <div class="receiver-detail"><span><span data-i18n="static.034">直连码得</span></span><code id="receiverDeviceName"></code></div>
65
+ <div class="receiver-detail"><span><span data-i18n="static.035">设备编号</span></span><code id="receiverDeviceId"></code></div>
66
+ </div>
67
+ <div id="receiverDetails" class="receiver-details" hidden>
68
+ <div class="receiver-detail"><span><span data-i18n="static.036">热点名称</span></span><code id="receiverSsid"></code><button id="receiverCopySsid" class="small-button" type="button"><span data-i18n="static.037">复制</span></button></div>
69
+ <div class="receiver-detail"><span><span data-i18n="static.038">热点密码</span></span><code id="receiverPassword"></code><button id="receiverRevealPassword" class="small-button" type="button" aria-pressed="false"><span data-i18n="static.039">显示</span></button><button id="receiverCopyPassword" class="small-button" type="button"><span data-i18n="static.037">复制</span></button></div>
70
+ <div class="receiver-detail"><span><span data-i18n="static.040">桥接地址</span></span><code id="receiverAddress">http://192.168.4.1:8788</code><button id="receiverCopyAddress" class="small-button" type="button"><span data-i18n="static.037">复制</span></button></div>
71
+ </div>
72
+ <p id="receiverHelp" class="panel-intro receiver-help"><span data-i18n="static.041">独立接收端无需屏幕:通过 USB 插入电脑后,这里自动显示热点名称和密码。在码得内打开“齿轮 → 连接 → 接收端”,扫描并点选热点,再输入这里显示的密码并保存;无需退出到桌面设置。新固件的热点密码为 8 位数字,六位配对码随后单独核对。码得直接用 USB 插电脑时,无需填写热点信息。</span></p>
73
+ </section>
74
+
75
+ </div>
76
+ <div class="stack">
77
+ <section id="voiceSettings" class="panel info-panel" aria-labelledby="voice-settings-title">
78
+ <h2 id="voice-settings-title"><span data-i18n="static.042">语音输入设置</span></h2>
79
+ <p class="panel-intro"><span data-i18n="static.043">填写转写接口 URL、API Key 和模型名,即可使用兼容 OpenAI 音频转写接口的服务。密钥只保存在这台电脑,不写入码得。</span></p>
80
+ <p id="voiceKeyStatus" class="voice-settings-status" role="status"><span data-i18n="static.044">正在读取语音设置…</span></p>
81
+ <form id="voiceSettingsForm" class="voice-settings-form" autocomplete="off">
82
+ <div id="voicePresets" class="voice-presets"><span><span data-i18n="static.045">快速填写:</span></span><button class="small-button" type="button" data-voice-preset="openai"><span data-i18n="static.046">OpenAI</span></button><button class="small-button" type="button" data-voice-preset="siliconflow"><span data-i18n="static.047">硅基流动</span></button></div>
83
+ <div id="speechFields" class="voice-settings-form">
84
+ <div><label class="field-label" for="voiceEndpoint"><span data-i18n="static.048">转写接口 URL</span></label><input id="voiceEndpoint" class="secret-input" type="url" inputmode="url" spellcheck="false" placeholder="https://api.openai.com/v1/audio/transcriptions" required></div>
85
+ <div><label class="field-label" for="openaiKey"><span data-i18n="static.049">API Key</span></label><input id="openaiKey" class="secret-input" type="password" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="首次填写;留空表示保持已保存的密钥" data-i18n-placeholder="static.050"></div>
86
+ <div><label class="field-label" for="voiceModel"><span data-i18n="static.051">模型名</span></label><input id="voiceModel" class="secret-input" type="text" spellcheck="false" placeholder="gpt-transcribe" required></div>
87
+ </div>
88
+ <div class="voice-settings-actions"><button id="saveOpenaiKey" class="primary-button" type="submit"><span data-i18n="static.052">保存语音设置</span></button><button id="clearOpenaiKey" class="small-button" type="button"><span data-i18n="static.053">清除 API Key</span></button></div>
89
+ <p id="voiceSettingsMessage" class="voice-settings-message" role="status" aria-live="polite"></p>
90
+
91
+ </form>
92
+ <p id="voiceProtocolHelp" class="panel-intro"><span data-i18n="static.054">URL 请填写完整的音频转写地址,通常以 /v1/audio/transcriptions 结尾。仅兼容聊天接口的服务不一定支持语音转写。</span></p>
93
+ </section>
94
+
95
+ <section id="tunnel" class="panel info-panel" aria-labelledby="tunnel-title">
96
+ <div class="panel-head pairing-head">
97
+ <div><h2 id="tunnel-title"><span data-i18n="static.055">公网接入</span></h2><p class="panel-intro"><span data-i18n="static.056">码得可通过公网 HTTPS 地址连接桥接器。只有码得通信接口会通过该地址开放;电脑管理页仍只能在本机打开。</span></p></div>
98
+ <div class="pairing-actions"><button id="tunnelStartButton" class="small-button confirm" type="button"><span data-i18n="static.057">开启穿透</span></button><button id="tunnelStopButton" class="small-button" type="button" hidden><span data-i18n="static.058">关闭穿透</span></button></div>
99
+ </div>
100
+ <form id="tunnelSettingsForm" class="tunnel-settings-form" autocomplete="off">
101
+ <div><label class="field-label" for="tunnelMode"><span data-i18n="static.059">接入方式</span></label><select id="tunnelMode" class="secret-input"><option value="quick" data-i18n="static.060">Cloudflare 临时地址</option><option value="named" data-i18n="static.061">Cloudflare 固定域名</option><option value="tailscale" data-i18n="static.062">Tailscale Funnel 固定地址</option><option value="ngrok" data-i18n="static.063">ngrok</option><option value="oray" data-i18n="static.064">花生壳 HTTPS 映射</option></select></div>
102
+ <div class="tunnel-service-guide" aria-labelledby="tunnelOfficialTitle">
103
+ <div class="tunnel-service-guide-head"><strong id="tunnelOfficialTitle" data-i18n="tunnel.official.title">官方入口</strong><span id="tunnelServiceName"></span></div>
104
+ <div id="tunnelOfficialLinks" class="tunnel-official-links" role="group" aria-label="官方入口" data-i18n-aria-label="tunnel.official.title"></div>
105
+ <p id="tunnelModeHelp" class="panel-intro"></p>
106
+ </div>
107
+ <div id="tunnelNamedFields" class="tunnel-named-fields" hidden>
108
+ <div><label class="field-label" for="tunnelHostname"><span data-i18n="static.065">公网 HTTPS 地址</span></label><input id="tunnelHostname" class="secret-input" type="url" inputmode="url" spellcheck="false" placeholder="https://vibe.example.com"></div>
109
+ <div><label class="field-label" for="tunnelToken"><span data-i18n="static.066">Cloudflare Tunnel 令牌</span></label><input id="tunnelToken" class="secret-input" type="password" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="首次填写;留空表示保持已保存的令牌" data-i18n-placeholder="static.067"></div>
110
+ <p id="tunnelTokenStatus" class="panel-intro"></p>
111
+ </div>
112
+ <div id="tunnelNgrokFields" class="tunnel-named-fields" hidden>
113
+ <div><label class="field-label" for="tunnelNgrokHostname"><span data-i18n="static.068">ngrok HTTPS 地址(可选)</span></label><input id="tunnelNgrokHostname" class="secret-input" type="url" inputmode="url" spellcheck="false" placeholder="留空由 ngrok 分配;或填写已预留域名" data-i18n-placeholder="static.069"></div>
114
+ <div><label class="field-label" for="tunnelNgrokToken"><span data-i18n="static.070">ngrok Authtoken</span></label><input id="tunnelNgrokToken" class="secret-input" type="password" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="首次填写;留空保持已保存的令牌" data-i18n-placeholder="static.071"></div>
115
+ <p id="tunnelNgrokTokenStatus" class="panel-intro"></p>
116
+ </div>
117
+ <div id="tunnelOrayFields" class="tunnel-named-fields" hidden>
118
+ <div><label class="field-label" for="tunnelOrayHostname"><span data-i18n="static.072">花生壳 HTTPS 地址</span></label><input id="tunnelOrayHostname" class="secret-input" type="url" inputmode="url" spellcheck="false" placeholder="https://your-domain.vicp.fun"></div>
119
+ <div><label class="field-label" for="tunnelOrayKey"><span data-i18n="static.073">花生壳 API Key</span></label><input id="tunnelOrayKey" class="secret-input" type="password" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="首次填写;留空保持已保存的密钥" data-i18n-placeholder="static.074"></div>
120
+ <p id="tunnelOrayKeyStatus" class="panel-intro"></p>
121
+ </div>
122
+ <button id="tunnelSaveButton" class="small-button" type="submit"><span data-i18n="static.075">保存接入设置</span></button>
123
+ </form>
124
+ <p id="tunnelStatus" class="pairing-state" role="status" aria-live="polite"><span data-i18n="static.076">正在读取公网接入状态…</span></p>
125
+ <div id="tunnelAddressRow" class="tunnel-address-row" hidden><code id="tunnelAddress"></code><button id="tunnelCopyButton" class="small-button" type="button"><span data-i18n="static.077">复制地址</span></button></div>
126
+ <p class="panel-intro"><span data-i18n="static.078">公网方式均只转发码得通信接口</span> <code>http://127.0.0.1:8788</code><span data-i18n="static.079">。配对时仍须在电脑和码得核对接入地址与六位码。</span></p>
127
+ </section>
128
+
129
+ <section id="connections" class="panel info-panel" aria-labelledby="connections-title"><h2 id="connections-title"><span data-i18n="static.080">连接状态</span></h2><p class="panel-intro"><span data-i18n="static.081">电脑桥接、码得控制与独立语音能力。</span></p><div id="connectionRows" class="info-rows"></div></section>
130
+
131
+ </div>
132
+ </div>
133
+ </main>
134
+ <footer class="footer"><span><span data-i18n="static.082">码得 · 多助手桥接</span></span><span id="lastUpdated"><span data-i18n="static.083">等待首次同步</span></span></footer>
135
+ </div>
136
+ <div id="toast" class="toast" role="status" aria-live="polite"></div>
137
+ <div id="pairingOverlay" class="pairing-overlay" hidden>
138
+ <section class="pairing-dialog" role="dialog" aria-modal="true" aria-labelledby="pairingDialogTitle" aria-describedby="pairingDialogDescription">
139
+ <div class="pairing-dialog-top"><div><span class="section-kicker"><span data-i18n="static.084">设备请求连接</span></span><h2 id="pairingDialogTitle"><span data-i18n="static.085">确认码得配对</span></h2></div><button id="pairingDismissButton" class="icon-button" type="button" title="稍后处理" aria-label="稍后处理" data-i18n-title="static.086" data-i18n-aria-label="static.086">×</button></div>
140
+ <p id="pairingDialogDescription"><span data-i18n="static.087">请核对下方数字是否与码得显示的一致。只有你确认后,这台设备才能控制电脑上的编程助手。</span></p>
141
+ <p id="pairingEndpoint" class="pairing-endpoint"></p>
142
+ <strong id="pairingCode" class="pairing-code" aria-label="配对验证码" data-i18n-aria-label="static.088"><span data-i18n="static.089">000000</span></strong>
143
+ <span id="pairingRemaining" class="pairing-remaining"></span>
144
+ <div class="pairing-dialog-actions"><button id="pairingRejectButton" class="small-button" type="button"><span data-i18n="static.090">拒绝连接</span></button><button id="pairingConfirmButton" class="primary-button" type="button"><span data-i18n="static.091">数字一致,确认连接</span></button></div>
145
+ </section>
146
+ </div>
147
+ <script type="module" src="/app.js"></script>
148
+ </body>
149
+ </html>
Binary file
@@ -0,0 +1,64 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="theme-color" content="#faf3e7">
7
+ <title data-i18n="static.141">插件 · 码得</title>
8
+ <link rel="icon" type="image/png" sizes="112x112" href="/made-icon.png">
9
+ <link rel="stylesheet" href="/styles.css">
10
+ </head>
11
+ <body data-page="plugins">
12
+ <div class="page-glow" aria-hidden="true"></div>
13
+ <div class="app">
14
+ <header class="topbar">
15
+ <a class="brand" href="/" aria-label="码得大盘" data-i18n-aria-label="static.002">
16
+ <span class="brand-mark" aria-hidden="true"><img src="/made-icon.png" alt="" width="112" height="112"></span>
17
+ <span><span class="brand-name"><span data-i18n="static.003">码得</span></span><span class="brand-sub"><span data-i18n="static.004">made · CODING COMPANION</span></span></span>
18
+ </a>
19
+ <nav class="topnav" aria-label="页面导航" data-i18n-aria-label="static.005"><a href="/"><span data-i18n="static.006">大盘</span></a><a href="/assistants.html"><span data-i18n="static.007">助手</span></a><a href="/tasks.html"><span data-i18n="static.008">任务</span></a><a class="active" aria-current="page" href="/plugins.html"><span data-i18n="static.140">插件</span></a></nav>
20
+ <div class="language-picker"><label for="languageSelect" data-i18n="language.label">语言</label><select id="languageSelect" aria-label="界面语言" data-i18n-aria-label="language.label"><option value="zh-CN">简体中文</option><option value="en">English</option></select></div>
21
+ <div class="top-actions">
22
+ <span id="headerConnection" class="connection-pill"><span class="dot"></span><span><span data-i18n="static.009">正在连接</span></span></span>
23
+ <button id="refreshButton" class="icon-button" type="button" title="刷新状态" aria-label="刷新状态" data-i18n-title="static.010" data-i18n-aria-label="static.010"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 11a8 8 0 1 0-2.3 6.1"/><path d="M20 5v6h-6"/></svg></button>
24
+ </div>
25
+ </header>
26
+ <main id="top">
27
+ <section class="hero" aria-labelledby="hero-title">
28
+ <div><span class="eyebrow"><span data-i18n="static.142">PLUGINS · THEMES</span></span><h1 id="hero-title"><span data-i18n="static.143">装在这台电脑上,</span><em><span data-i18n="static.144">由你开关。</span></em></h1><p class="hero-desc"><span data-i18n="static.145">插件放在 ~/made/plugins,主题放在 ~/made/themes,桥接器每次启动自动扫描。bridge/builtin 里的内置包跟代码走,同名时 ~/made 覆盖内置。启用和停用会立即生效;替换插件文件内容后,请重启桥接器。</span></p></div>
29
+ </section>
30
+ <section id="pluginDetail" class="panel info-panel" hidden>
31
+ <button id="pluginBack" class="plugin-back" type="button" data-i18n="plug.031">返回</button>
32
+ <h2 id="pluginDetailTitle"></h2>
33
+ <p id="pluginDetailMeta" class="catalog-meta"></p>
34
+ <div id="pluginDetailActions" class="catalog-actions plugin-detail-actions"></div>
35
+ <div id="pluginDetailBody"></div>
36
+ </section>
37
+ <div id="pluginGroups" class="plugin-groups">
38
+ <section class="panel info-panel" aria-labelledby="board-plugins-title">
39
+ <h2 id="board-plugins-title"><span data-i18n="plug.001">管控插件</span></h2>
40
+ <p class="panel-intro"><span data-i18n="static.146">接收码得事件,可以下发命令。停用后不再处理这些事件。</span></p>
41
+ <div id="boardPluginList" class="plugin-tiles"></div>
42
+ </section>
43
+ <section class="panel info-panel" aria-labelledby="agent-plugins-title">
44
+ <h2 id="agent-plugins-title"><span data-i18n="plug.002">助手插件</span></h2>
45
+ <p class="panel-intro"><span data-i18n="static.147">扩展一种助手的执行方式。停用后不再出现在助手列表。</span></p>
46
+ <div id="agentPluginList" class="plugin-tiles"></div>
47
+ </section>
48
+ <section id="brokenPlugins" class="panel info-panel" aria-labelledby="broken-plugins-title" hidden>
49
+ <h2 id="broken-plugins-title"><span data-i18n="plug.023">无法加载</span></h2>
50
+ <div id="brokenPluginList" class="plugin-tiles"></div>
51
+ </section>
52
+ <section class="panel info-panel" aria-labelledby="themes-title">
53
+ <h2 id="themes-title"><span data-i18n="static.148">已安装主题</span></h2>
54
+ <p class="panel-intro"><span data-i18n="static.149">主题包都在这里管理。应用到码得后,设备解锁并连上电脑时才会换上这套外观。</span></p>
55
+ <div id="themeList" class="catalog-list"></div>
56
+ </section>
57
+ </div>
58
+ </main>
59
+ <footer class="footer"><span><span data-i18n="static.082">码得 · 多助手桥接</span></span><span id="lastUpdated"></span></footer>
60
+ </div>
61
+ <div id="toast" class="toast" role="status" aria-live="polite"></div>
62
+ <script type="module" src="/plugins.js"></script>
63
+ </body>
64
+ </html>
@@ -0,0 +1,335 @@
1
+ import { t, getLocale, setLocale, supportedLocales, applyStaticTranslations, setUiMessage } from './i18n.js';
2
+
3
+ const $ = (id) => document.getElementById(id);
4
+ let token = null;
5
+ let toastTimer;
6
+ let busy = false;
7
+ let installedPlugins = [];
8
+ let openKey = '';
9
+
10
+ function showToast(message, error = false) {
11
+ const toast = $('toast');
12
+ setUiMessage(toast, message);
13
+ toast.classList.toggle('error', error);
14
+ toast.classList.add('visible');
15
+ clearTimeout(toastTimer);
16
+ toastTimer = setTimeout(() => toast.classList.remove('visible'), 4200);
17
+ }
18
+
19
+ async function request(url, options = {}) {
20
+ const response = await fetch(url, { cache: 'no-store', ...options });
21
+ let data = {};
22
+ try { data = await response.json(); } catch { /* A non-JSON error still uses the status. */ }
23
+ if (!response.ok) throw new Error(data.error || t('app.017', { v0: response.status }));
24
+ return data;
25
+ }
26
+
27
+ async function sessionToken() {
28
+ if (token) return token;
29
+ const session = await request('/api/session');
30
+ token = session.token;
31
+ return token;
32
+ }
33
+
34
+ async function post(url, body) {
35
+ const headers = { 'Content-Type': 'application/json', 'X-Vibe-Token': await sessionToken() };
36
+ try {
37
+ return await request(url, { method: 'POST', headers, body: JSON.stringify(body || {}) });
38
+ } catch (error) {
39
+ token = null;
40
+ throw error;
41
+ }
42
+ }
43
+
44
+ function button(label, className, action) {
45
+ const node = document.createElement('button');
46
+ node.type = 'button';
47
+ node.className = className;
48
+ node.textContent = label;
49
+ node.addEventListener('click', action);
50
+ return node;
51
+ }
52
+
53
+ function meta(text) {
54
+ const node = document.createElement('p');
55
+ node.className = 'catalog-meta';
56
+ node.textContent = text;
57
+ return node;
58
+ }
59
+
60
+ function pluginKey(plugin) {
61
+ return plugin.id || plugin.file;
62
+ }
63
+
64
+ function hashKey() {
65
+ const raw = location.hash.replace(/^#/, '');
66
+ if (!raw) return '';
67
+ try { return decodeURIComponent(raw); } catch { return ''; }
68
+ }
69
+
70
+ function pluginState(plugin) {
71
+ return plugin.error ? plugin.error : plugin.enabled ? (plugin.available ? t('plug.003') : t('plug.005')) : t('plug.004');
72
+ }
73
+
74
+ function pluginSummary(plugin) {
75
+ const details = [pluginState(plugin), plugin.file];
76
+ if (plugin.events?.length) details.push(`${t('plug.024')} ${plugin.events.join('、')}`);
77
+ if (plugin.commands?.length) details.push(`${t('plug.025')} ${plugin.commands.join('、')}`);
78
+ return details.join(' · ');
79
+ }
80
+
81
+ function pluginCard(plugin) {
82
+ const card = document.createElement('article');
83
+ card.className = 'plugin-tile';
84
+ if (!plugin.enabled || plugin.error) card.classList.add('is-off');
85
+ const open = document.createElement('button');
86
+ open.type = 'button';
87
+ open.className = 'plugin-tile-open';
88
+ const title = document.createElement('span');
89
+ title.className = 'catalog-title';
90
+ title.textContent = plugin.label || plugin.file;
91
+ const subtitle = document.createElement('span');
92
+ subtitle.className = 'catalog-meta';
93
+ subtitle.textContent = pluginState(plugin);
94
+ open.append(title, subtitle);
95
+ open.addEventListener('click', () => openPlugin(plugin));
96
+ card.append(open);
97
+ if (plugin.id) {
98
+ card.append(button(plugin.enabled ? t('plug.006') : t('plug.007'), 'small-button plugin-tile-toggle', () => changePlugin(plugin, !plugin.enabled)));
99
+ }
100
+ return card;
101
+ }
102
+
103
+ function openPlugin(plugin) {
104
+ const key = pluginKey(plugin);
105
+ openKey = key;
106
+ if (hashKey() !== key) history.pushState({ plugin: key }, '', `#${encodeURIComponent(key)}`);
107
+ syncView();
108
+ window.scrollTo(0, 0);
109
+ }
110
+
111
+ function syncView() {
112
+ const plugin = installedPlugins.find((item) => pluginKey(item) === openKey);
113
+ const groups = $('pluginGroups');
114
+ const detail = $('pluginDetail');
115
+ if (!plugin) {
116
+ if (openKey && location.hash) history.replaceState(null, '', location.pathname);
117
+ openKey = '';
118
+ groups.hidden = false;
119
+ detail.hidden = true;
120
+ return;
121
+ }
122
+ groups.hidden = true;
123
+ detail.hidden = false;
124
+ renderDetail(plugin);
125
+ }
126
+
127
+ function renderDetail(plugin) {
128
+ $('pluginDetailTitle').textContent = plugin.label || plugin.file;
129
+ $('pluginDetailMeta').textContent = pluginSummary(plugin);
130
+ const actions = $('pluginDetailActions');
131
+ actions.replaceChildren();
132
+ if (plugin.id) {
133
+ actions.append(button(plugin.enabled ? t('plug.006') : t('plug.007'), 'small-button', () => changePlugin(plugin, !plugin.enabled)));
134
+ }
135
+ actions.append(button(t('plug.008'), 'small-button', () => removeInstalledPlugin(plugin)));
136
+ const body = $('pluginDetailBody');
137
+ body.replaceChildren();
138
+ if (plugin.settingsError) body.append(meta(plugin.settingsError));
139
+ else if (plugin.settingsSpec?.length) body.append(settingsForm(plugin));
140
+ else body.append(meta(t('plug.032')));
141
+ }
142
+
143
+ function fillList(id, items, emptyKey) {
144
+ const list = $(id);
145
+ list.replaceChildren();
146
+ if (!items.length) {
147
+ list.append(meta(t(emptyKey)));
148
+ return;
149
+ }
150
+ for (const item of items) list.append(pluginCard(item));
151
+ }
152
+
153
+ function renderPlugins(plugins) {
154
+ const board = [];
155
+ const agents = [];
156
+ const broken = [];
157
+ for (const plugin of plugins) {
158
+ if (plugin.kind === 'board-plugin') board.push(plugin);
159
+ else if (plugin.kind === 'coding-agent') agents.push(plugin);
160
+ else broken.push(plugin);
161
+ }
162
+ fillList('boardPluginList', board, 'plug.029');
163
+ fillList('agentPluginList', agents, 'plug.030');
164
+ const brokenSection = $('brokenPlugins');
165
+ brokenSection.hidden = broken.length === 0;
166
+ if (broken.length) fillList('brokenPluginList', broken, 'plug.023');
167
+ }
168
+
169
+ function settingsForm(plugin) {
170
+ const form = document.createElement('form');
171
+ form.className = 'voice-settings-form';
172
+ form.append(meta(t('plug.028')));
173
+ const inputs = new Map();
174
+ for (const field of plugin.settingsSpec) {
175
+ const wrap = document.createElement('div');
176
+ const label = document.createElement('label');
177
+ label.className = 'field-label';
178
+ label.textContent = field.label;
179
+ const input = document.createElement('input');
180
+ input.className = 'secret-input';
181
+ input.type = 'text';
182
+ input.value = plugin.settings?.[field.key] ?? '';
183
+ input.spellcheck = false;
184
+ if (field.help) input.title = field.help;
185
+ wrap.append(label, input);
186
+ if (field.help) wrap.append(meta(field.help));
187
+ inputs.set(field.key, input);
188
+ form.append(wrap);
189
+ }
190
+ const save = button(t('plug.026'), 'small-button confirm', () => {});
191
+ save.type = 'submit';
192
+ form.append(save);
193
+ form.addEventListener('submit', async (event) => {
194
+ event.preventDefault();
195
+ const settings = {};
196
+ for (const [key, input] of inputs) settings[key] = input.value;
197
+ try {
198
+ const data = await post('/api/plugins/settings', { id: plugin.id, settings });
199
+ applyInstalled(data);
200
+ showToast(t('plug.027'));
201
+ } catch (error) {
202
+ showToast(error.message, true);
203
+ }
204
+ });
205
+ return form;
206
+ }
207
+
208
+ function renderThemes(themes) {
209
+ const list = $('themeList');
210
+ list.replaceChildren();
211
+ if (!themes.length) {
212
+ list.append(meta(t('plug.012')));
213
+ return;
214
+ }
215
+ for (const theme of themes) {
216
+ const card = document.createElement('article');
217
+ card.className = 'catalog-card';
218
+ const title = document.createElement('h3');
219
+ title.textContent = theme.title || theme.name;
220
+ const assets = [theme.name];
221
+ if (theme.sound) assets.push(t('plug.013'));
222
+ if (theme.icon) assets.push(t('plug.014'));
223
+ if (theme.background) assets.push(t('plug.015'));
224
+ const actions = document.createElement('div');
225
+ actions.className = 'catalog-actions';
226
+ actions.append(button(t('plug.009'), 'small-button confirm', () => applyTheme(theme)));
227
+ actions.append(button(t('plug.010'), 'small-button', () => deleteTheme(theme)));
228
+ card.append(title, meta(assets.join(' · ')), actions);
229
+ list.append(card);
230
+ }
231
+ }
232
+
233
+ function applyInstalled(data) {
234
+ installedPlugins = Array.isArray(data.plugins) ? data.plugins : [];
235
+ renderPlugins(installedPlugins);
236
+ renderThemes(Array.isArray(data.themes) ? data.themes : []);
237
+ if (!openKey) openKey = hashKey();
238
+ syncView();
239
+ }
240
+
241
+ async function refresh() {
242
+ if (busy) return;
243
+ busy = true;
244
+ try {
245
+ applyInstalled(await request('/api/installed'));
246
+ const header = $('headerConnection');
247
+ header.classList.add('online');
248
+ header.querySelector('span:last-child').textContent = t('app.099');
249
+ $('lastUpdated').textContent = new Intl.DateTimeFormat(getLocale(), { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(new Date());
250
+ } catch (error) {
251
+ const header = $('headerConnection');
252
+ header.classList.remove('online');
253
+ header.querySelector('span:last-child').textContent = t('app.098');
254
+ showToast(error.message, true);
255
+ } finally {
256
+ busy = false;
257
+ }
258
+ }
259
+
260
+ async function changePlugin(plugin, enabled) {
261
+ try {
262
+ const data = await post('/api/plugins/enabled', { id: plugin.id, enabled });
263
+ applyInstalled(data);
264
+ showToast(t('plug.019'));
265
+ } catch (error) {
266
+ showToast(error.message, true);
267
+ }
268
+ }
269
+
270
+ async function removeInstalledPlugin(plugin) {
271
+ const name = plugin.label || plugin.file;
272
+ if (!window.confirm(t('plug.016', { v0: name }))) return;
273
+ try {
274
+ const data = await post('/api/plugins/remove', plugin.id ? { id: plugin.id } : { file: plugin.file });
275
+ applyInstalled(data);
276
+ showToast(t('plug.021'));
277
+ } catch (error) {
278
+ showToast(error.message, true);
279
+ }
280
+ }
281
+
282
+ async function applyTheme(theme) {
283
+ try {
284
+ const data = await post(`/api/themes/${encodeURIComponent(theme.name)}/apply`);
285
+ showToast(t('plug.018', { v0: data.applied }));
286
+ } catch (error) {
287
+ showToast(error.message, true);
288
+ }
289
+ }
290
+
291
+ async function deleteTheme(theme) {
292
+ if (!window.confirm(t('plug.017', { v0: theme.title || theme.name }))) return;
293
+ try {
294
+ const data = await post(`/api/themes/${encodeURIComponent(theme.name)}/remove`);
295
+ renderThemes(data.themes || []);
296
+ showToast(t('plug.020'));
297
+ } catch (error) {
298
+ showToast(error.message, true);
299
+ }
300
+ }
301
+
302
+ $('pluginBack').addEventListener('click', () => {
303
+ if (hashKey()) history.back();
304
+ else {
305
+ openKey = '';
306
+ syncView();
307
+ }
308
+ });
309
+ window.addEventListener('popstate', () => {
310
+ openKey = hashKey();
311
+ syncView();
312
+ });
313
+ $('refreshButton').addEventListener('click', refresh);
314
+ $('languageSelect').addEventListener('change', async (event) => {
315
+ const requested = event.target.value;
316
+ if (!supportedLocales.includes(requested)) return;
317
+ event.target.disabled = true;
318
+ try {
319
+ const response = await post('/api/settings/language', { locale: requested });
320
+ setLocale(supportedLocales.includes(response.locale) ? response.locale : requested);
321
+ applyStaticTranslations();
322
+ $('languageSelect').value = getLocale();
323
+ await refresh();
324
+ } catch (error) {
325
+ $('languageSelect').value = getLocale();
326
+ showToast(error.message || t('language.failed'), true);
327
+ } finally {
328
+ event.target.disabled = false;
329
+ }
330
+ });
331
+
332
+ applyStaticTranslations();
333
+ $('languageSelect').value = getLocale();
334
+ for (const id of ['boardPluginList', 'agentPluginList', 'themeList']) $(id).append(meta(t('plug.022')));
335
+ refresh();