@objectivex666/dsh-settings-search 1.7.2 → 1.8.0
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.
- package/README.en.md +3 -1
- package/README.md +3 -1
- package/lib/client.js +726 -31
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@objectivex666/dsh-settings-search)
|
|
6
6
|
[](./LICENSE)
|
|
7
7
|
[](https://nodejs.org)
|
|
8
|
+
[](https://awesome-dsh-plugin.com)
|
|
8
9
|
|
|
9
10
|
**English** · [中文](./README.md)
|
|
10
11
|
|
|
@@ -13,13 +14,14 @@
|
|
|
13
14
|
## Features
|
|
14
15
|
|
|
15
16
|
- 🔍 **Live search** — a search box at the top of the settings panel's left navigation filters every registered settings page (`settings.section`) and general item (`settings.general.item`) as you type.
|
|
17
|
+
- 🎯 **First-run onboarding (v1.8.0)** — a welcome modal introduces live search, pinyin/intent/AI hints, click-to-jump, and auto-indexing when the settings panel first opens; reopen it anytime from the "Settings Search" page.
|
|
16
18
|
- 📂 **In-page option search (v1.2.0)** — search beyond pages and reach the specific options inside them: Plugins-page tabs (`settings.plugins.tab`), Web UI plugin cards (`web-ui.plugin.item`), general rows, and more, each shown with a "page › option" breadcrumb.
|
|
17
19
|
- 🔤 **Pinyin association search (v1.5.0)** — type initials (`sz`) or full pinyin (`shezhi`) to surface Chinese settings items; navigate with `↑`/`↓`, confirm with `Enter`, dismiss with `Esc`, and matched text is highlighted.
|
|
18
20
|
- 🧠 **Intent search (v1.6.0)** — describe the goal in Chinese (e.g. 「太亮了」 "too bright", 「字太小」 "text too small") to surface matching settings without knowing their names, shown in a "You may want" group.
|
|
19
21
|
- 🤖 **AI-assisted search (v1.7.0)** — when nothing matches locally, your own model (OpenAI-compatible or Anthropic: Base URL / model / API key) understands your search intent and suggests settings. Off by default; configure it on the plugin's settings page.
|
|
20
22
|
- 🧾 **Logging & export (v1.7.0)** — records key plugin actions, searches, and AI requests; export, copy, or clear them from the plugin's settings page for troubleshooting. API keys are never logged.
|
|
21
23
|
- 🧭 **Click-to-jump** — selecting a result clicks the matching left-nav entry to open its section; for tab options it also opens the tab and flashes the target row. A manual-path hint appears if automatic navigation is not possible.
|
|
22
|
-
- 🌱 **
|
|
24
|
+
- 🌱 **Auto-index on startup (v1.8.0)** — the first time the settings panel opens, the plugin automatically visits every settings section, silently reads in-page options (e.g. "Enable Workshop card") into the search index, then restores your current section, so all section options are searchable without having to open each page manually. A "Importing section options…" hint shows under the search box and clears when done.
|
|
23
25
|
- 🛠 **Built-in settings page (v1.4.0)** — a "Settings Search" page in the left navigation shows the current version and checks npm for a newer release on demand; when one exists it hands you a copy-ready `dsh plugin update` command.
|
|
24
26
|
- 🧭 **View release notes (v1.7.1)** — the "Settings Search" page has a "View changelog" button next to "Check for updates"; it fetches the GitHub release notes and shows them inline, opening the release page if fetching fails.
|
|
25
27
|
- 🌐 **i18n** — built-in Chinese/English dictionaries that follow the DSH localization system and are easy to extend.
|
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@objectivex666/dsh-settings-search)
|
|
6
6
|
[](./LICENSE)
|
|
7
7
|
[](https://nodejs.org)
|
|
8
|
+
[](https://awesome-dsh-plugin.com)
|
|
8
9
|
|
|
9
10
|
**中文** · [English](./README.en.md)
|
|
10
11
|
|
|
@@ -13,13 +14,14 @@
|
|
|
13
14
|
## 功能特性
|
|
14
15
|
|
|
15
16
|
- 🔍 **实时搜索**:在设置面板左侧导航顶部嵌入搜索框,输入关键词立即过滤所有已注册的设置页(`settings.section`)与通用设置项(`settings.general.item`)。
|
|
17
|
+
- 🎯 **新用户引导(v1.8.0)**:首次打开设置面板时弹出引导弹窗,介绍实时搜索、拼音/意图/AI 联想、点击即达与自动导入索引等核心功能;可在「设置搜索」页随时重新打开。
|
|
16
18
|
- 📂 **页内选项搜索(v1.2.0)**:不只搜页面,还能直达每个设置页内部的具体选项——插件页的标签页(`settings.plugins.tab`)、Web UI 插件的卡片(`web-ui.plugin.item`)、通用设置行等,结果以「页面 › 选项」面包屑展示。
|
|
17
19
|
- 🔤 **拼音联想搜索(v1.5.0)**:输入拼音首字母(如 `sz`)或全拼(如 `shezhi`)即可联想中文设置项;支持 `↑`/`↓` 选择、`Enter` 跳转、`Esc` 关闭,命中的文字自动高亮。
|
|
18
20
|
- 🧠 **意图联想搜索(v1.6.0)**:直接描述想做的事(如「太亮了」「字太小」「忘记密码」),无需知道选项名即可联想真实设置项,结果在「猜你想找」分组展示。
|
|
19
21
|
- 🤖 **AI 联想搜索(v1.7.0)**:没有本地匹配时,用你配置的模型(OpenAI 兼容或 Anthropic:Base URL / 模型名 / API Key)理解搜索意图并推荐设置项。默认关闭,可在「设置搜索」页开启并配置。
|
|
20
22
|
- 🧾 **日志与导出(v1.7.0)**:记录插件运行、搜索与 AI 请求的关键动作,可在「设置搜索」页一键导出、复制或清空,便于排查问题。不会记录 API Key。
|
|
21
23
|
- 🧭 **点击即达**:选中结果后自动点击对应的左侧导航项打开所在分区;若是页内标签页会继续点开对应标签,并高亮闪烁目标选项行。无法自动跳转时显示手动路径提示。
|
|
22
|
-
- 🌱
|
|
24
|
+
- 🌱 **启动自动索引(v1.8.0)**:首次打开设置面板时自动遍历各设置分区,静默读取分区内选项(如「启用创意工坊卡片」)并导入搜索索引;此后分区选项无需逐页手动打开即可直接搜索,读取完成后自动恢复当前所在分区,导入过程搜索框下方会显示提示,完成后自动消失。
|
|
23
25
|
- 🛠 **自带设置页(v1.4.0)**:左侧导航新增「设置搜索」页——展示当前版本,一键检查 npm 注册表上的最新版本;有更新时直接给出复制就用的 `dsh plugin update` 命令。
|
|
24
26
|
- 🧭 **更新说明查看(v1.7.1)**:在「设置搜索」页「检查更新」旁新增「查看更新说明」按钮,可拉取 GitHub Release 更新内容并就地展示;拉取失败时自动跳转 Release 页面。
|
|
25
27
|
- 🌐 **多语言支持**:内置中英文国际化,可跟随 DSH 本地化系统扩展。
|
package/lib/client.js
CHANGED
|
@@ -30,11 +30,13 @@ window.__ModuleLoader__.load({
|
|
|
30
30
|
|
|
31
31
|
const NS = 'settings-search-v5'
|
|
32
32
|
/** Single source of truth is package.json — scripts/check.mjs asserts this matches. */
|
|
33
|
-
const PKG_VERSION = '1.
|
|
33
|
+
const PKG_VERSION = '1.8.0'
|
|
34
34
|
const NPM_NAME = '@objectivex666/dsh-settings-search'
|
|
35
35
|
const zh = {
|
|
36
36
|
title: '设置',
|
|
37
37
|
placeholder: '搜索设置…',
|
|
38
|
+
searchBtn: '搜索',
|
|
39
|
+
warming: '正在导入各分区选项…',
|
|
38
40
|
empty: '没有匹配的设置',
|
|
39
41
|
pages: '设置页',
|
|
40
42
|
options: '页内选项',
|
|
@@ -72,10 +74,30 @@ window.__ModuleLoader__.load({
|
|
|
72
74
|
aiOpenAI: 'OpenAI 兼容',
|
|
73
75
|
aiAnthropic: 'Anthropic',
|
|
74
76
|
aiBaseUrl: 'Base URL',
|
|
77
|
+
aiBaseUrlHint: '仅支持 http(s) 地址;非本机地址必须使用 https,以免泄露 API Key。',
|
|
75
78
|
aiModel: '模型名',
|
|
76
79
|
aiApiKey: 'API Key',
|
|
77
80
|
aiConfigSave: '保存配置',
|
|
78
81
|
aiConfigSaved: '已保存 ✓',
|
|
82
|
+
aiClear: '清除配置',
|
|
83
|
+
aiClearTitle: '清除 API 配置',
|
|
84
|
+
aiClearConfirm: '确定要清除 Base URL、模型名与 API Key 吗?此操作不可撤销。',
|
|
85
|
+
aiClearYes: '确认清除',
|
|
86
|
+
aiTest: '测试接口',
|
|
87
|
+
aiTesting: '测试中…',
|
|
88
|
+
aiTestOk: '连接成功 ✓',
|
|
89
|
+
aiTestFail: '连接失败',
|
|
90
|
+
aiTestErrAuth: '认证失败,请检查 API Key 是否正确',
|
|
91
|
+
aiTestErrForbidden: '无权限,请检查 API Key 是否被允许使用该模型',
|
|
92
|
+
aiTestErrNotFound: '接口地址或模型名不存在,请检查 Base URL 与模型名',
|
|
93
|
+
aiTestErrRateLimit: '请求过于频繁或额度不足,请稍后重试或检查套餐',
|
|
94
|
+
aiTestErrBadRequest: '请求参数错误,请检查模型名与接口类型是否匹配',
|
|
95
|
+
aiTestErrServer: '服务端错误,请稍后重试或检查服务状态',
|
|
96
|
+
aiTestErrTimeout: '连接超时,请检查网络与 Base URL 是否可达',
|
|
97
|
+
aiTestErrNetwork: '无法连接,请检查网络或 Base URL',
|
|
98
|
+
aiTestErrBadResponse: '响应格式异常,请确认接口类型与服务兼容',
|
|
99
|
+
aiTestErrHttp: '请求失败(HTTP 状态码错误),请检查地址与配置',
|
|
100
|
+
aiTestErrUnsafeUrl: '接口地址不安全:仅支持 http(s),非本机必须为 https,且不可包含账号密码',
|
|
79
101
|
logTitle: '日志',
|
|
80
102
|
logDesc: '记录插件运行、搜索与 AI 请求的关键动作,便于排查问题。',
|
|
81
103
|
logEmpty: '暂无日志',
|
|
@@ -85,11 +107,35 @@ window.__ModuleLoader__.load({
|
|
|
85
107
|
logCopyFail: '复制失败,请稍后重试。',
|
|
86
108
|
logClear: '清空日志',
|
|
87
109
|
logCleared: '已清空',
|
|
110
|
+
logConfirmTitle: '清空日志',
|
|
111
|
+
logConfirm: '确定要清空全部日志吗?此操作不可撤销。',
|
|
112
|
+
logConfirmYes: '确认清空',
|
|
113
|
+
logConfirmCancel: '取消',
|
|
88
114
|
logExportFail: '导出失败,请重试。',
|
|
115
|
+
wlTitle: '欢迎使用设置搜索',
|
|
116
|
+
wlSubtitle: '为 DSH 设置面板提供即时搜索与导航,几秒钟就能找到任何设置。',
|
|
117
|
+
wlFeature1Title: '实时搜索',
|
|
118
|
+
wlFeature1Desc: '在顶部搜索框输入关键词,即时过滤所有设置页与页内选项。',
|
|
119
|
+
wlFeature2Title: '拼音与意图联想',
|
|
120
|
+
wlFeature2Desc: '输入拼音首字母(如 sz)或直接描述需求(如「太亮」),也能联想设置项。',
|
|
121
|
+
wlFeature3Title: 'AI 联想(可选)',
|
|
122
|
+
wlFeature3Desc: '没有本地匹配时,可用你配置的模型理解意图并推荐设置项。',
|
|
123
|
+
wlFeature4Title: '点击即达',
|
|
124
|
+
wlFeature4Desc: '选中结果自动跳转到对应分区,并高亮闪烁目标选项。',
|
|
125
|
+
wlFeature5Title: '自带设置页',
|
|
126
|
+
wlFeature5Desc: '左侧导航的「设置搜索」页可检查更新、查看说明与导出日志。',
|
|
127
|
+
wlStart: '开始使用',
|
|
128
|
+
wlSkip: '不再显示',
|
|
129
|
+
wlOpenPanel: '打开设置搜索页',
|
|
130
|
+
wlReopen: '新用户引导',
|
|
131
|
+
wlFeature6Title: '自动导入索引',
|
|
132
|
+
wlFeature6Desc: '首次打开时自动遍历一遍各设置分区,把分区内选项一并纳入搜索,无需逐页手动打开即可直接搜到。',
|
|
89
133
|
}
|
|
90
134
|
const en = {
|
|
91
135
|
title: 'Settings',
|
|
92
136
|
placeholder: 'Search settings…',
|
|
137
|
+
searchBtn: 'Search',
|
|
138
|
+
warming: 'Importing section options…',
|
|
93
139
|
empty: 'No matching settings',
|
|
94
140
|
pages: 'Settings pages',
|
|
95
141
|
options: 'In-page options',
|
|
@@ -127,10 +173,30 @@ window.__ModuleLoader__.load({
|
|
|
127
173
|
aiOpenAI: 'OpenAI-compatible',
|
|
128
174
|
aiAnthropic: 'Anthropic',
|
|
129
175
|
aiBaseUrl: 'Base URL',
|
|
176
|
+
aiBaseUrlHint: 'Only http(s) URLs are allowed; remote hosts must use https to avoid exposing your API key.',
|
|
130
177
|
aiModel: 'Model',
|
|
131
178
|
aiApiKey: 'API Key',
|
|
132
179
|
aiConfigSave: 'Save config',
|
|
133
180
|
aiConfigSaved: 'Saved ✓',
|
|
181
|
+
aiClear: 'Clear config',
|
|
182
|
+
aiClearTitle: 'Clear API config',
|
|
183
|
+
aiClearConfirm: 'Clear Base URL, model, and API key? This cannot be undone.',
|
|
184
|
+
aiClearYes: 'Clear config',
|
|
185
|
+
aiTest: 'Test API',
|
|
186
|
+
aiTesting: 'Testing…',
|
|
187
|
+
aiTestOk: 'Connection OK ✓',
|
|
188
|
+
aiTestFail: 'Connection failed',
|
|
189
|
+
aiTestErrAuth: 'Auth failed — check that your API key is correct',
|
|
190
|
+
aiTestErrForbidden: 'Forbidden — check that the API key is allowed to use this model',
|
|
191
|
+
aiTestErrNotFound: 'Not found — check the Base URL and model name',
|
|
192
|
+
aiTestErrRateLimit: 'Rate limited or quota exceeded — try again later or check your plan',
|
|
193
|
+
aiTestErrBadRequest: 'Bad request — check the model name and API type',
|
|
194
|
+
aiTestErrServer: 'Server error — try again later or check the service status',
|
|
195
|
+
aiTestErrTimeout: 'Timed out — check the network and whether the Base URL is reachable',
|
|
196
|
+
aiTestErrNetwork: 'Cannot connect — check the network or Base URL',
|
|
197
|
+
aiTestErrBadResponse: 'Unexpected response — confirm the API type matches the service',
|
|
198
|
+
aiTestErrHttp: 'Request failed (HTTP status error) — check the URL and config',
|
|
199
|
+
aiTestErrUnsafeUrl: 'Unsafe URL — only http(s) is allowed, remote hosts must use https, and embedded credentials are not permitted',
|
|
134
200
|
logTitle: 'Logs',
|
|
135
201
|
logDesc: 'Logs key plugin actions, searches, and AI requests to help troubleshoot.',
|
|
136
202
|
logEmpty: 'No logs yet',
|
|
@@ -140,12 +206,35 @@ window.__ModuleLoader__.load({
|
|
|
140
206
|
logCopyFail: 'Copy failed. Please try again.',
|
|
141
207
|
logClear: 'Clear logs',
|
|
142
208
|
logCleared: 'Cleared',
|
|
209
|
+
logConfirmTitle: 'Clear logs',
|
|
210
|
+
logConfirm: 'Clear all logs? This cannot be undone.',
|
|
211
|
+
logConfirmYes: 'Clear logs',
|
|
212
|
+
logConfirmCancel: 'Cancel',
|
|
143
213
|
logExportFail: 'Export failed. Please try again.',
|
|
214
|
+
wlTitle: 'Welcome to Settings Search',
|
|
215
|
+
wlSubtitle: 'Instant search and navigation for the DSH settings panel — find any setting in seconds.',
|
|
216
|
+
wlFeature1Title: 'Instant search',
|
|
217
|
+
wlFeature1Desc: 'Type in the top search box to instantly filter settings pages and in-page options.',
|
|
218
|
+
wlFeature2Title: 'Pinyin & intent hints',
|
|
219
|
+
wlFeature2Desc: 'Type pinyin initials (e.g. sz) or describe what you want to do to find settings.',
|
|
220
|
+
wlFeature3Title: 'AI hints (optional)',
|
|
221
|
+
wlFeature3Desc: 'When no local match exists, your own model can understand the intent and suggest settings.',
|
|
222
|
+
wlFeature4Title: 'Click to navigate',
|
|
223
|
+
wlFeature4Desc: 'Selecting a result jumps to its section and flashes the target option.',
|
|
224
|
+
wlFeature5Title: 'Built-in panel',
|
|
225
|
+
wlFeature5Desc: 'The Settings Search page in the sidebar checks updates, shows notes, and exports logs.',
|
|
226
|
+
wlStart: 'Get started',
|
|
227
|
+
wlSkip: "Don't show again",
|
|
228
|
+
wlOpenPanel: 'Open Settings Search panel',
|
|
229
|
+
wlReopen: 'Onboarding',
|
|
230
|
+
wlFeature6Title: 'Auto-index',
|
|
231
|
+
wlFeature6Desc: 'On first open it automatically passes through every settings section, so in-page options are searchable without opening each page manually.',
|
|
144
232
|
}
|
|
145
233
|
|
|
146
234
|
/** Inner-row slots that represent specific options inside one page. */
|
|
147
235
|
const DEEP_SLOTS = [
|
|
148
236
|
{ slot: 'settings.plugins.tab', parent: 'plugins' },
|
|
237
|
+
{ slot: 'settings.plugin.item', parent: 'plugins' },
|
|
149
238
|
{ slot: 'web-ui.plugin.item', parent: 'web-ui-plugins' },
|
|
150
239
|
{ slot: 'settings.general.item', parent: 'general' },
|
|
151
240
|
]
|
|
@@ -345,6 +434,21 @@ window.__ModuleLoader__.load({
|
|
|
345
434
|
localStorage.setItem(AI_API_KEY_KEY, cfg.apiKey ?? '')
|
|
346
435
|
window.dispatchEvent(new CustomEvent(AI_CONFIG_EVENT))
|
|
347
436
|
}
|
|
437
|
+
/** First-run onboarding: remembered via localStorage so it shows once. */
|
|
438
|
+
const ONBOARD_KEY = 'dsh-settings-search:onboarded'
|
|
439
|
+
const WELCOME_EVENT = 'dsh-settings-search:welcome'
|
|
440
|
+
const welcomed = () => {
|
|
441
|
+
if (typeof localStorage === 'undefined') return true
|
|
442
|
+
return localStorage.getItem(ONBOARD_KEY) === '1'
|
|
443
|
+
}
|
|
444
|
+
const setWelcomed = () => {
|
|
445
|
+
if (typeof localStorage === 'undefined') return
|
|
446
|
+
localStorage.setItem(ONBOARD_KEY, '1')
|
|
447
|
+
window.dispatchEvent(new CustomEvent(WELCOME_EVENT, { detail: false }))
|
|
448
|
+
}
|
|
449
|
+
const openWelcome = () => {
|
|
450
|
+
window.dispatchEvent(new CustomEvent(WELCOME_EVENT, { detail: true }))
|
|
451
|
+
}
|
|
348
452
|
const extractAiKeywords = (raw) => {
|
|
349
453
|
const text = String(raw ?? '').replace(/^```(?:json)?/i, '').replace(/```$/, '').trim()
|
|
350
454
|
const start = text.indexOf('{')
|
|
@@ -360,12 +464,40 @@ window.__ModuleLoader__.load({
|
|
|
360
464
|
.slice(0, 6)
|
|
361
465
|
}
|
|
362
466
|
const AI_PROMPT = '你是设置搜索助手。只输出 JSON 对象,格式为 {"keywords":["..."]}。keywords 最多 6 个,每个最多 12 个字符,只能从用户提供的设置索引中选择或提取核心词,不要解释。'
|
|
467
|
+
const loopbackHost = (hostname) => {
|
|
468
|
+
const h = String(hostname || '').replace(/^\[|\]$/g, '').toLowerCase()
|
|
469
|
+
return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0.0.0.0'
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Normalize and validate an AI service base URL. Prevents the API key
|
|
473
|
+
* from being sent to a dangerous scheme (javascript:, data:, …), to a URL
|
|
474
|
+
* with embedded credentials, or over cleartext HTTP to a non-loopback
|
|
475
|
+
* host. Returns a normalized origin+path (no trailing slash) or ''.
|
|
476
|
+
*/
|
|
477
|
+
const safeBaseUrl = (baseUrl) => {
|
|
478
|
+
const value = String(baseUrl ?? '').trim()
|
|
479
|
+
if (!value) return ''
|
|
480
|
+
const candidate = /^[a-z][a-z0-9+.-]*:/i.test(value) ? value : `https://${value}`
|
|
481
|
+
let url
|
|
482
|
+
try { url = new URL(candidate) } catch { return '' }
|
|
483
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''
|
|
484
|
+
if (url.username || url.password) return ''
|
|
485
|
+
const isHttp = url.protocol === 'http:'
|
|
486
|
+
if (!url.hostname) return ''
|
|
487
|
+
if (isHttp && !loopbackHost(url.hostname)) return ''
|
|
488
|
+
const path = url.pathname === '/' ? '' : url.pathname
|
|
489
|
+
return url.origin + path.replace(/\/+$/, '')
|
|
490
|
+
}
|
|
363
491
|
const chatCompletionsUrl = (baseUrl) => {
|
|
364
|
-
const
|
|
492
|
+
const safe = safeBaseUrl(baseUrl)
|
|
493
|
+
if (!safe) return ''
|
|
494
|
+
const trimmed = safe.replace(/\/+$/, '')
|
|
365
495
|
return /\/chat\/completions$/i.test(trimmed) ? trimmed : `${trimmed}/chat/completions`
|
|
366
496
|
}
|
|
367
497
|
const anthropicMessagesUrl = (baseUrl) => {
|
|
368
|
-
const
|
|
498
|
+
const safe = safeBaseUrl(baseUrl)
|
|
499
|
+
if (!safe) return ''
|
|
500
|
+
const trimmed = safe.replace(/\/+$/, '')
|
|
369
501
|
if (/\/v1\/messages$/i.test(trimmed)) return trimmed
|
|
370
502
|
return /\/v1$/i.test(trimmed) ? `${trimmed}/messages` : `${trimmed}/v1/messages`
|
|
371
503
|
}
|
|
@@ -373,6 +505,8 @@ window.__ModuleLoader__.load({
|
|
|
373
505
|
const { provider, baseUrl, model, apiKey } = aiConfig()
|
|
374
506
|
if (!baseUrl || !model || !apiKey) return []
|
|
375
507
|
const isAnthropic = provider === 'anthropic'
|
|
508
|
+
const url = isAnthropic ? anthropicMessagesUrl(baseUrl) : chatCompletionsUrl(baseUrl)
|
|
509
|
+
if (!url) return []
|
|
376
510
|
const startedAt = Date.now()
|
|
377
511
|
log('info', 'AI 请求', { provider, model, query: String(query).slice(0, 50) })
|
|
378
512
|
let timer
|
|
@@ -407,7 +541,7 @@ window.__ModuleLoader__.load({
|
|
|
407
541
|
],
|
|
408
542
|
temperature: 0,
|
|
409
543
|
}
|
|
410
|
-
const response = await fetch(
|
|
544
|
+
const response = await fetch(url, {
|
|
411
545
|
method: 'POST',
|
|
412
546
|
headers,
|
|
413
547
|
signal,
|
|
@@ -443,6 +577,87 @@ window.__ModuleLoader__.load({
|
|
|
443
577
|
return keywords
|
|
444
578
|
}
|
|
445
579
|
|
|
580
|
+
/**
|
|
581
|
+
* Lightweight connectivity test for the configured model service. Sends a
|
|
582
|
+
* minimal chat request and reports whether the endpoint/auth/model work.
|
|
583
|
+
* Returns { ok, code, detail } where code is a stable error kind used to
|
|
584
|
+
* look up a localized hint in the UI.
|
|
585
|
+
*/
|
|
586
|
+
const testHttpCode = (status) => {
|
|
587
|
+
const code = Number(status)
|
|
588
|
+
if (code === 401) return 'auth'
|
|
589
|
+
if (code === 403) return 'forbidden'
|
|
590
|
+
if (code === 404) return 'not-found'
|
|
591
|
+
if (code === 408) return 'timeout'
|
|
592
|
+
if (code === 429) return 'rate-limit'
|
|
593
|
+
if (code === 400) return 'bad-request'
|
|
594
|
+
if (code >= 500 && code <= 599) return 'server'
|
|
595
|
+
return 'http'
|
|
596
|
+
}
|
|
597
|
+
const testAiService = async (cfg) => {
|
|
598
|
+
const provider = cfg.provider === 'anthropic' ? 'anthropic' : 'openai'
|
|
599
|
+
const baseUrl = String(cfg.baseUrl ?? '').trim()
|
|
600
|
+
const model = String(cfg.model ?? '').trim()
|
|
601
|
+
const apiKey = String(cfg.apiKey ?? '').trim()
|
|
602
|
+
if (!baseUrl || !model || !apiKey) return { ok: false, code: 'need-config', detail: '' }
|
|
603
|
+
const isAnthropic = provider === 'anthropic'
|
|
604
|
+
const requestUrl = isAnthropic ? anthropicMessagesUrl(baseUrl) : chatCompletionsUrl(baseUrl)
|
|
605
|
+
if (!requestUrl) return { ok: false, code: 'unsafe-url', detail: '' }
|
|
606
|
+
const startedAt = Date.now()
|
|
607
|
+
log('info', '测试 API 服务', { provider, model })
|
|
608
|
+
let timer
|
|
609
|
+
const deadline = new Promise((_, reject) => {
|
|
610
|
+
timer = setTimeout(() => {
|
|
611
|
+
const error = new Error('AI request timed out')
|
|
612
|
+
error.name = 'AI_TIMEOUT'
|
|
613
|
+
reject(error)
|
|
614
|
+
}, 12000)
|
|
615
|
+
})
|
|
616
|
+
const work = (async () => {
|
|
617
|
+
const headers = {
|
|
618
|
+
'content-type': 'application/json',
|
|
619
|
+
...(isAnthropic
|
|
620
|
+
? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true' }
|
|
621
|
+
: { authorization: `Bearer ${apiKey}` }),
|
|
622
|
+
}
|
|
623
|
+
const body = isAnthropic
|
|
624
|
+
? { model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] }
|
|
625
|
+
: { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, temperature: 0 }
|
|
626
|
+
const response = await fetch(requestUrl, {
|
|
627
|
+
method: 'POST',
|
|
628
|
+
headers,
|
|
629
|
+
body: JSON.stringify(body),
|
|
630
|
+
})
|
|
631
|
+
if (!response.ok) return { ok: false, code: testHttpCode(response.status), detail: `HTTP ${response.status}` }
|
|
632
|
+
const data = await response.json().catch(() => ({}))
|
|
633
|
+
const valid = isAnthropic
|
|
634
|
+
? (Array.isArray(data?.content) && data.content.length > 0)
|
|
635
|
+
: Boolean(data?.choices?.[0]?.message)
|
|
636
|
+
if (!valid) return { ok: false, code: 'bad-response', detail: '' }
|
|
637
|
+
return { ok: true }
|
|
638
|
+
})()
|
|
639
|
+
try {
|
|
640
|
+
const result = await Promise.race([work, deadline])
|
|
641
|
+
if (result.ok) log('info', '测试 API 成功', { duration: Date.now() - startedAt })
|
|
642
|
+
else log('error', '测试 API 失败', { code: result.code ?? 'http', detail: result.detail ?? '', duration: Date.now() - startedAt })
|
|
643
|
+
return result
|
|
644
|
+
} catch (error) {
|
|
645
|
+
if (error.name === 'AbortError') {
|
|
646
|
+
log('warn', '测试 API 取消', { duration: Date.now() - startedAt })
|
|
647
|
+
throw error
|
|
648
|
+
}
|
|
649
|
+
if (error.name === 'AI_TIMEOUT') {
|
|
650
|
+
log('error', '测试 API 超时', { duration: Date.now() - startedAt })
|
|
651
|
+
return { ok: false, code: 'timeout', detail: '' }
|
|
652
|
+
}
|
|
653
|
+
const reason = String(error?.message ?? error).slice(0, 160)
|
|
654
|
+
log('error', '测试 API 失败', { detail: reason, duration: Date.now() - startedAt })
|
|
655
|
+
return { ok: false, code: 'network', detail: reason }
|
|
656
|
+
} finally {
|
|
657
|
+
clearTimeout(timer)
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
446
661
|
const CSS = `
|
|
447
662
|
.sss-wrap { position: relative; display: flex; flex-direction: column; gap: 8px; }
|
|
448
663
|
.sss-title { font-size: 16px; font-weight: 600; line-height: 24px; color: var(--dsw-alias-label-primary); }
|
|
@@ -452,6 +667,10 @@ window.__ModuleLoader__.load({
|
|
|
452
667
|
.sss-input::placeholder { color: var(--dsw-alias-label-dimmed); }
|
|
453
668
|
.sss-clear { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; padding: 0; border: none; border-radius: 4px; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 1; cursor: pointer; }
|
|
454
669
|
.sss-clear:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
|
|
670
|
+
.sss-icon-btn { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; padding: 0; border: none; border-radius: 4px; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; }
|
|
671
|
+
.sss-icon-btn:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
|
|
672
|
+
.sss-icon-btn:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; }
|
|
673
|
+
.sss-warm-note { display: flex; align-items: center; gap: 6px; margin: 0; font-size: 12px; line-height: 16px; color: var(--dsw-alias-label-secondary); }
|
|
455
674
|
.sss-pop { position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 20; background: var(--dsw-alias-bg-overlay); border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; box-shadow: var(--dsw-shadow-lv3); padding: 6px; max-height: 320px; overflow: auto; }
|
|
456
675
|
.sss-groups { display: flex; flex-direction: column; gap: 8px; }
|
|
457
676
|
.sss-group-title { font-size: 11px; color: var(--dsw-alias-label-secondary); letter-spacing: .05em; padding: 2px 6px; }
|
|
@@ -460,7 +679,7 @@ window.__ModuleLoader__.load({
|
|
|
460
679
|
.sss-item:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
|
461
680
|
.sss-item.is-active { background: var(--dsw-alias-interactive-bg-hover); }
|
|
462
681
|
.sss-item-col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
463
|
-
.sss-item-label { color: var(--dsw-alias-label-primary); font-size: 13px; line-height: 18px;
|
|
682
|
+
.sss-item-label { color: var(--dsw-alias-label-primary); font-size: 13px; line-height: 18px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
|
|
464
683
|
.sss-item-label mark { background: transparent; color: inherit; font-weight: 600; }
|
|
465
684
|
.sss-item-path { font-size: 10px; line-height: 14px; color: var(--dsw-alias-label-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
466
685
|
.sss-item-kind { flex-shrink: 0; font-size: 10px; color: var(--dsw-alias-label-secondary); border: 1px solid var(--dsw-alias-border-l2); border-radius: 4px; padding: 1px 5px; }
|
|
@@ -491,6 +710,7 @@ window.__ModuleLoader__.load({
|
|
|
491
710
|
.sss-vr-input { border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-3); min-height: 34px; font: inherit; color: var(--dsw-alias-label-primary); border-radius: 8px; padding: 6px 12px; font-size: 13px; line-height: 1.5; width: 100%; box-sizing: border-box; }
|
|
492
711
|
.sss-vr-input:focus-visible { border-color: var(--dsw-alias-brand-primary); outline: none; }
|
|
493
712
|
.sss-vr-input::placeholder { color: var(--dsw-alias-label-dimmed); }
|
|
713
|
+
.sss-vr-input.sss-vr-mask { -webkit-text-security: disc; }
|
|
494
714
|
.sss-vr-select { cursor: pointer; }
|
|
495
715
|
.sss-vr-check { width: 16px; height: 16px; accent-color: var(--dsw-alias-brand-primary); cursor: pointer; margin: 0; }
|
|
496
716
|
.sss-vr-btn { appearance: none; font: inherit; cursor: pointer; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; padding: 5px 14px; font-size: 13px; line-height: 1.5; color: var(--dsw-alias-label-secondary); background: 0 0; }
|
|
@@ -514,6 +734,40 @@ window.__ModuleLoader__.load({
|
|
|
514
734
|
.sss-vr-link { appearance: none; font: inherit; cursor: pointer; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; padding: 5px 14px; font-size: 13px; line-height: 1.5; color: var(--dsw-alias-label-secondary); background: 0 0; text-decoration: none; display: inline-flex; align-items: center; }
|
|
515
735
|
.sss-vr-link:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
|
|
516
736
|
.sss-vr-notes { box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-layer-3); color: var(--dsw-alias-label-primary); padding: 10px 12px; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; max-height: 240px; overflow-y: auto; margin-top: 8px; }
|
|
737
|
+
.sss-wl-overlay { position: fixed; inset: 0; z-index: 999; display: flex; align-items: center; justify-content: center; padding: 20px; background: var(--dsw-alias-bg-mask, rgb(0 0 0 / 36%)); box-sizing: border-box; }
|
|
738
|
+
.sss-wl { width: 100%; max-width: 520px; max-height: min(88vh, 720px); overflow: auto; box-sizing: border-box; background: var(--dsw-alias-bg-layer-3); border: 1px solid var(--dsw-alias-border-l2); border-radius: 16px; box-shadow: var(--dsw-shadow-lv3); padding: 22px 24px 20px; }
|
|
739
|
+
.sss-wl-header { display: flex; align-items: flex-start; gap: 12px; }
|
|
740
|
+
.sss-wl-headText { flex: 1; min-width: 0; }
|
|
741
|
+
.sss-wl-title { margin: 0; color: var(--dsw-alias-label-primary); font-size: 19px; font-weight: 650; line-height: 1.4; }
|
|
742
|
+
.sss-wl-subtitle { margin: 6px 0 0; color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 1.6; }
|
|
743
|
+
.sss-wl-close { flex: none; appearance: none; cursor: pointer; width: 28px; height: 28px; padding: 0; border: none; border-radius: 7px; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 15px; line-height: 1; }
|
|
744
|
+
.sss-wl-close:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
|
|
745
|
+
.sss-wl-features { list-style: none; margin: 18px 0 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
|
746
|
+
.sss-wl-feature { display: flex; gap: 12px; align-items: flex-start; padding: 11px 12px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--dsw-alias-bg-layer-1); }
|
|
747
|
+
.sss-wl-feature-icon { flex: none; width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); font-size: 15px; line-height: 1; }
|
|
748
|
+
.sss-wl-feature-body { flex: 1; min-width: 0; }
|
|
749
|
+
.sss-wl-feature-title { color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 600; line-height: 1.5; }
|
|
750
|
+
.sss-wl-feature-desc { margin: 2px 0 0; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 1.6; }
|
|
751
|
+
.sss-wl-footer { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 20px; }
|
|
752
|
+
.sss-wl-btn { appearance: none; font: inherit; cursor: pointer; border: 1px solid var(--dsw-alias-border-l2); border-radius: 9px; padding: 7px 16px; font-size: 13px; line-height: 1.5; color: var(--dsw-alias-label-secondary); background: 0 0; }
|
|
753
|
+
.sss-wl-btn:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
|
|
754
|
+
.sss-wl-primary { background: var(--dsw-alias-label-primary); color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
|
|
755
|
+
.sss-wl-primary:hover { color: var(--dsw-alias-bg-layer-3); border-color: transparent; }
|
|
756
|
+
.sss-wl-skip { margin-left: auto; border: none; background: transparent; color: var(--dsw-alias-label-tertiary); padding: 7px 4px; }
|
|
757
|
+
.sss-wl-skip:hover { color: var(--dsw-alias-label-primary); border-color: transparent; }
|
|
758
|
+
.sss-cf-overlay { position: fixed; inset: 0; z-index: 999; display: flex; align-items: center; justify-content: center; padding: 20px; background: var(--dsw-alias-bg-mask, rgb(0 0 0 / 36%)); box-sizing: border-box; }
|
|
759
|
+
.sss-cf { width: 100%; max-width: 400px; box-sizing: border-box; background: var(--dsw-alias-bg-layer-3); border: 1px solid var(--dsw-alias-border-l2); border-radius: 14px; box-shadow: var(--dsw-shadow-lv3); padding: 20px 22px 18px; }
|
|
760
|
+
.sss-cf-header { display: flex; align-items: flex-start; gap: 12px; }
|
|
761
|
+
.sss-cf-headText { flex: 1; min-width: 0; }
|
|
762
|
+
.sss-cf-title { margin: 0; color: var(--dsw-alias-label-primary); font-size: 16px; font-weight: 650; line-height: 1.4; }
|
|
763
|
+
.sss-cf-desc { margin: 8px 0 0; color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 1.6; }
|
|
764
|
+
.sss-cf-close { flex: none; appearance: none; cursor: pointer; width: 28px; height: 28px; padding: 0; border: none; border-radius: 7px; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 15px; line-height: 1; }
|
|
765
|
+
.sss-cf-close:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
|
|
766
|
+
.sss-cf-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 18px; }
|
|
767
|
+
.sss-cf-btn { appearance: none; font: inherit; cursor: pointer; border: 1px solid var(--dsw-alias-border-l2); border-radius: 9px; padding: 7px 16px; font-size: 13px; line-height: 1.5; color: var(--dsw-alias-label-secondary); background: 0 0; }
|
|
768
|
+
.sss-cf-btn:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-dimmed); }
|
|
769
|
+
.sss-cf-danger { background: #c0392b; color: #fff; border-color: transparent; }
|
|
770
|
+
.sss-cf-danger:hover { background: #a93226; color: #fff; border-color: transparent; }
|
|
517
771
|
`
|
|
518
772
|
let stylesInstalled = false
|
|
519
773
|
function installStyles() {
|
|
@@ -558,7 +812,7 @@ window.__ModuleLoader__.load({
|
|
|
558
812
|
if (typeof o.locale === 'string') {
|
|
559
813
|
const t = callSafe(() => ctx.locale.bind(o.locale))
|
|
560
814
|
if (typeof t === 'function') {
|
|
561
|
-
for (const key of ['tab', 'title', 'card.title', 'name']) {
|
|
815
|
+
for (const key of ['tab', 'title', 'card.title', 'name', 'nav']) {
|
|
562
816
|
const value = callSafe(() => t(key))
|
|
563
817
|
if (typeof value === 'string' && value !== '' && value !== key) return value
|
|
564
818
|
}
|
|
@@ -576,6 +830,77 @@ window.__ModuleLoader__.load({
|
|
|
576
830
|
* stacked section): harvest each direct child as one row, keyed by
|
|
577
831
|
* render order ("#i") since id pairing is impossible.
|
|
578
832
|
*/
|
|
833
|
+
/** Strip a trailing description (e.g. "关闭后恢复…") from a row label. */
|
|
834
|
+
const trimDescription = (text) => {
|
|
835
|
+
const t = (text ?? '').replace(/\s+/g, ' ').trim()
|
|
836
|
+
if (t === '') return ''
|
|
837
|
+
const re = /[\s,,。;;](关闭后|开启后|启用后|禁用后|创建后|使用时|点击后|勾选后|开启时|关闭时|完成后|启用时|禁用时|开始后|停止后)/
|
|
838
|
+
const m = re.exec(t)
|
|
839
|
+
if (m) {
|
|
840
|
+
const head = t.slice(0, m.index).trim()
|
|
841
|
+
if (head.length >= 2 && head.length <= 60) return head
|
|
842
|
+
}
|
|
843
|
+
const p = t.indexOf('。')
|
|
844
|
+
if (p > 0) {
|
|
845
|
+
const head = t.slice(0, p).trim()
|
|
846
|
+
if (head.length >= 2 && head.length <= 40 && head.length < t.length - p) return head
|
|
847
|
+
}
|
|
848
|
+
return t
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const mineInnerOptions = (node) => {
|
|
852
|
+
try {
|
|
853
|
+
const seen = new Set()
|
|
854
|
+
const out = []
|
|
855
|
+
const textOf = (el) => (el.textContent ?? '').replace(/\s+/g, ' ').trim()
|
|
856
|
+
const titleOf = (el) => {
|
|
857
|
+
const t = textOf(el)
|
|
858
|
+
return t.length >= 2 && t.length <= 120 ? t : ''
|
|
859
|
+
}
|
|
860
|
+
const bestTitle = (row) => {
|
|
861
|
+
let best = ''
|
|
862
|
+
let bestLen = 1e9
|
|
863
|
+
const sels = ['h1,h2,h3,h4,h5', '[class*="title"]', '[class*="heading"]', '[class*="name"]', '[class*="label"]', 'label', 'strong', 'b']
|
|
864
|
+
for (const sel of sels) {
|
|
865
|
+
for (const el of row.querySelectorAll(sel)) {
|
|
866
|
+
if (el === row) continue
|
|
867
|
+
const t = titleOf(el)
|
|
868
|
+
if (t === '') continue
|
|
869
|
+
if (t.length < bestLen) { best = t; bestLen = t.length }
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (best !== '') return best
|
|
873
|
+
const raw = textOf(row)
|
|
874
|
+
const part = raw.split('·')[0].trim()
|
|
875
|
+
return part.length >= 2 && part.length <= 120 ? part : raw.slice(0, 120)
|
|
876
|
+
}
|
|
877
|
+
const push = (label, el) => {
|
|
878
|
+
const clean = trimDescription(label)
|
|
879
|
+
const text = textOf(el)
|
|
880
|
+
if (clean.length < 2 || el === node || seen.has(clean)) return
|
|
881
|
+
seen.add(clean)
|
|
882
|
+
out.push({ label: clean, text })
|
|
883
|
+
}
|
|
884
|
+
const ctrls = node.querySelectorAll('input[type="checkbox"], input[type="radio"], [role="switch"], [role="checkbox"], [role="radio"], button[aria-pressed], button[aria-checked]')
|
|
885
|
+
for (const ctl of ctrls) {
|
|
886
|
+
let row = ctl
|
|
887
|
+
for (let d = 0; d < 6 && row.parentElement; d += 1) {
|
|
888
|
+
row = row.parentElement
|
|
889
|
+
if (row === node) break
|
|
890
|
+
const t = textOf(row)
|
|
891
|
+
if (t.length >= 2 && t.length <= 220) break
|
|
892
|
+
}
|
|
893
|
+
push(bestTitle(row), row)
|
|
894
|
+
}
|
|
895
|
+
const heads = node.querySelectorAll('h1,h2,h3,h4,h5,label,[class*="title"],[class*="heading"],[class*="label"],[class*="name"]')
|
|
896
|
+
for (const h of heads) push(bestTitle(h) || titleOf(h), h)
|
|
897
|
+
return out.slice(0, 24)
|
|
898
|
+
} catch {
|
|
899
|
+
return []
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
|
|
579
904
|
const runHarvest = () => {
|
|
580
905
|
let grew = false
|
|
581
906
|
if (typeof document === 'undefined') return false
|
|
@@ -601,20 +926,52 @@ window.__ModuleLoader__.load({
|
|
|
601
926
|
if (map.has(key)) return
|
|
602
927
|
const title = titleFromNode(node)
|
|
603
928
|
if (title === '') return
|
|
604
|
-
const text = (node.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0,
|
|
605
|
-
|
|
929
|
+
const text = (node.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 500)
|
|
930
|
+
const record = { title, idx: i, text, inner: mineInnerOptions(node) }
|
|
931
|
+
map.set(key, record); grew = true
|
|
606
932
|
})
|
|
607
933
|
continue
|
|
608
934
|
}
|
|
609
935
|
regs.forEach((entry, i) => {
|
|
610
|
-
const id = String(entry.options?.id ?? i)
|
|
936
|
+
const id = String(entry.options?.id ?? entry.options?.key ?? i)
|
|
611
937
|
if (map.has(id)) return
|
|
612
938
|
const title = titleFromNode(nodes[i])
|
|
613
939
|
if (title === '') return
|
|
614
|
-
const text = (nodes[i].textContent ?? '').replace(/\s+/g, ' ').trim().slice(0,
|
|
615
|
-
|
|
940
|
+
const text = (nodes[i].textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 500)
|
|
941
|
+
const record = { title, idx: i, text, inner: mineInnerOptions(nodes[i]) }
|
|
942
|
+
map.set(id, record); map.set('#' + i, record); grew = true
|
|
616
943
|
})
|
|
617
944
|
}
|
|
945
|
+
// Mine the active first-level section's own inline controls
|
|
946
|
+
// (e.g. 创意工坊 → 启用创意工坊卡片), which live outside DEEP_SLOTS.
|
|
947
|
+
try {
|
|
948
|
+
const sections = readSections()
|
|
949
|
+
const activeLabel = activeSectionLabel()
|
|
950
|
+
const active = activeLabel === '' ? undefined : sections.find((s) => s.label === activeLabel)
|
|
951
|
+
if (active && active.id !== '') {
|
|
952
|
+
const containers = [...document.querySelectorAll('[data-slot="settings.section"]')]
|
|
953
|
+
const node = containers.length === 1 && containers[0].childElementCount > 0
|
|
954
|
+
? containers[0].children[0] ?? null
|
|
955
|
+
: containers[0] ?? null
|
|
956
|
+
if (node) {
|
|
957
|
+
const inner = mineInnerOptions(node)
|
|
958
|
+
const text = (node.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 500)
|
|
959
|
+
if (inner.length > 0 || text !== '') {
|
|
960
|
+
let map = harvest.get('settings.section')
|
|
961
|
+
if (map === undefined) { map = new Map(); harvest.set('settings.section', map) }
|
|
962
|
+
const rec = map.get(active.id)
|
|
963
|
+
if (!rec) {
|
|
964
|
+
map.set(active.id, { title: active.label, idx: 0, text, inner })
|
|
965
|
+
grew = true
|
|
966
|
+
} else if ((rec.inner?.length ?? 0) === 0 && inner.length > 0) {
|
|
967
|
+
rec.inner = inner
|
|
968
|
+
rec.text = text
|
|
969
|
+
grew = true
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
} catch { /* section mining is best-effort */ }
|
|
618
975
|
if (grew) harvestSeq += 1
|
|
619
976
|
return grew
|
|
620
977
|
}
|
|
@@ -622,7 +979,8 @@ window.__ModuleLoader__.load({
|
|
|
622
979
|
const titleFromNode = (node) => {
|
|
623
980
|
const pick = (el) => {
|
|
624
981
|
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim()
|
|
625
|
-
|
|
982
|
+
if (text === '' || text.length > 200) return ''
|
|
983
|
+
return trimDescription(text)
|
|
626
984
|
}
|
|
627
985
|
for (const sel of ['h1,h2,h3,h4,h5', '[class*="title"]', '[class*="heading"]', 'label', 'p']) {
|
|
628
986
|
const hit = node.querySelector(sel)
|
|
@@ -632,7 +990,7 @@ window.__ModuleLoader__.load({
|
|
|
632
990
|
if (first) {
|
|
633
991
|
for (const child of first.children) { const text = pick(child); if (text !== '') return text }
|
|
634
992
|
}
|
|
635
|
-
return pick(node).split('·')[0].trim()
|
|
993
|
+
return trimDescription(pick(node).split('·')[0].trim())
|
|
636
994
|
}
|
|
637
995
|
|
|
638
996
|
const readSections = () => sortEntries(entriesOf('settings.section'))
|
|
@@ -653,7 +1011,7 @@ window.__ModuleLoader__.load({
|
|
|
653
1011
|
for (const cfg of DEEP_SLOTS) {
|
|
654
1012
|
const mapped = harvest.get(cfg.slot)
|
|
655
1013
|
sortEntries(entriesOf(cfg.slot)).forEach((entry, i) => {
|
|
656
|
-
const id = String(entry.options?.id ?? '')
|
|
1014
|
+
const id = String(entry.options?.id ?? entry.options?.key ?? '')
|
|
657
1015
|
if (id === '') return
|
|
658
1016
|
// static label first, then id-paired harvest, then loose index-keyed harvest
|
|
659
1017
|
const hitId = mapped?.get(id)
|
|
@@ -663,6 +1021,7 @@ window.__ModuleLoader__.load({
|
|
|
663
1021
|
if (label === '') return // unmapped, unharvested — invisible until it renders once
|
|
664
1022
|
const key = cfg.slot + '#' + id
|
|
665
1023
|
if (out.some((e) => e.key === key)) return
|
|
1024
|
+
const pageLabel = parentLabel(cfg.parent)
|
|
666
1025
|
out.push({
|
|
667
1026
|
kind: 'option',
|
|
668
1027
|
key,
|
|
@@ -673,10 +1032,68 @@ window.__ModuleLoader__.load({
|
|
|
673
1032
|
hay: label + ' ' + (hitObj?.text ?? ''),
|
|
674
1033
|
rowIndex: hitObj?.idx ?? i,
|
|
675
1034
|
parentId: cfg.parent,
|
|
676
|
-
parentLabel:
|
|
1035
|
+
parentLabel: pageLabel,
|
|
677
1036
|
})
|
|
1037
|
+
if (cfg.slot === 'settings.plugin.item' && Array.isArray(hitObj?.inner)) {
|
|
1038
|
+
hitObj.inner.forEach((op, oi) => {
|
|
1039
|
+
if (op.label === '' || op.label === label) return
|
|
1040
|
+
const innerKey = key + '#o' + oi
|
|
1041
|
+
if (out.some((e) => e.key === innerKey)) return
|
|
1042
|
+
out.push({
|
|
1043
|
+
kind: 'option',
|
|
1044
|
+
key: innerKey,
|
|
1045
|
+
slot: cfg.slot,
|
|
1046
|
+
id: id + '#o' + oi,
|
|
1047
|
+
order: (entry.options?.order ?? 0) + oi / 1000,
|
|
1048
|
+
label: op.label,
|
|
1049
|
+
hay: `${op.text} ${label} ${pageLabel}`.toLowerCase(),
|
|
1050
|
+
rowIndex: hitObj?.idx ?? i,
|
|
1051
|
+
parentId: cfg.parent,
|
|
1052
|
+
parentLabel: label,
|
|
1053
|
+
pageLabel,
|
|
1054
|
+
inner: true,
|
|
1055
|
+
cardLabel: label,
|
|
1056
|
+
})
|
|
1057
|
+
})
|
|
1058
|
+
}
|
|
678
1059
|
})
|
|
679
1060
|
}
|
|
1061
|
+
// Expose inline options mined from active first-level sections.
|
|
1062
|
+
{
|
|
1063
|
+
const mapped = harvest.get('settings.section')
|
|
1064
|
+
if (mapped) {
|
|
1065
|
+
sections.forEach((sec) => {
|
|
1066
|
+
const id = sec.id
|
|
1067
|
+
if (id === '') return
|
|
1068
|
+
const rec = mapped.get(id)
|
|
1069
|
+
if (!rec || !Array.isArray(rec.inner)) return
|
|
1070
|
+
const label = sec.label
|
|
1071
|
+
if (label === '') return
|
|
1072
|
+
rec.inner.forEach((op, oi) => {
|
|
1073
|
+
if (op.label === '' || op.label === label) return
|
|
1074
|
+
const innerKey = 'settings.section#' + id + '#o' + oi
|
|
1075
|
+
if (out.some((e) => e.key === innerKey)) return
|
|
1076
|
+
const dup = out.some((e) => e.kind === 'option' && e.parentLabel === label && e.label === op.label)
|
|
1077
|
+
if (dup) return
|
|
1078
|
+
out.push({
|
|
1079
|
+
kind: 'option',
|
|
1080
|
+
key: innerKey,
|
|
1081
|
+
slot: 'settings.section',
|
|
1082
|
+
id: id + '#o' + oi,
|
|
1083
|
+
order: sec.order + oi / 1000,
|
|
1084
|
+
label: op.label,
|
|
1085
|
+
hay: `${op.text} ${label}`.toLowerCase(),
|
|
1086
|
+
rowIndex: rec.idx ?? 0,
|
|
1087
|
+
parentId: id,
|
|
1088
|
+
parentLabel: label,
|
|
1089
|
+
pageLabel: label,
|
|
1090
|
+
inner: true,
|
|
1091
|
+
cardLabel: label,
|
|
1092
|
+
})
|
|
1093
|
+
})
|
|
1094
|
+
})
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
680
1097
|
return out.sort((a, b) => a.parentLabel !== b.parentLabel
|
|
681
1098
|
? a.parentLabel.localeCompare(b.parentLabel)
|
|
682
1099
|
: a.order - b.order)
|
|
@@ -703,6 +1120,51 @@ window.__ModuleLoader__.load({
|
|
|
703
1120
|
return true
|
|
704
1121
|
}
|
|
705
1122
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
1123
|
+
/** Label of the currently-active first-level settings nav item. */
|
|
1124
|
+
const activeSectionLabel = () => {
|
|
1125
|
+
if (typeof document === 'undefined') return ''
|
|
1126
|
+
const root = document.querySelector('[role="dialog"]') ?? document.body
|
|
1127
|
+
const isTab = (b) => b.getAttribute('role') === 'tab'
|
|
1128
|
+
const buttons = [...root.querySelectorAll('button')].filter((b) => !isTab(b))
|
|
1129
|
+
const byCurrent = buttons.find((b) => b.getAttribute('aria-current') === 'true')
|
|
1130
|
+
const byClass = buttons.find((b) => /_navCell/.test(b.className || '') && /_active/.test(b.className || ''))
|
|
1131
|
+
const hit = byCurrent ?? byClass
|
|
1132
|
+
return (hit?.textContent ?? '').replace(/\s+/g, ' ').trim()
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
let warmed = false
|
|
1136
|
+
/**
|
|
1137
|
+
* One-shot background pass: visit every first-level settings section once,
|
|
1138
|
+
* let each mount, harvest its options, then restore the previously-active one.
|
|
1139
|
+
*/
|
|
1140
|
+
const warmAllSections = async (model, onStart, onDone) => {
|
|
1141
|
+
if (warmed || typeof document === 'undefined') return true
|
|
1142
|
+
const sections = model.readSections()
|
|
1143
|
+
if (sections.length === 0) return true
|
|
1144
|
+
const original = activeSectionLabel()
|
|
1145
|
+
if (original === '') return false // settings panel not open yet; retry on next mount
|
|
1146
|
+
warmed = true
|
|
1147
|
+
if (typeof onStart === 'function') onStart()
|
|
1148
|
+
let visited = 0
|
|
1149
|
+
try {
|
|
1150
|
+
for (const sec of sections.slice(0, 40)) {
|
|
1151
|
+
if (!clickNavButton(sec.label)) continue
|
|
1152
|
+
await delay(180)
|
|
1153
|
+
model.runHarvest()
|
|
1154
|
+
visited += 1
|
|
1155
|
+
}
|
|
1156
|
+
if (original !== '') {
|
|
1157
|
+
if (clickNavButton(original)) {
|
|
1158
|
+
await delay(160)
|
|
1159
|
+
model.runHarvest()
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
} finally {
|
|
1163
|
+
if (typeof onDone === 'function') onDone()
|
|
1164
|
+
}
|
|
1165
|
+
log('info', '预载设置索引', { total: sections.length, visited })
|
|
1166
|
+
return true
|
|
1167
|
+
}
|
|
706
1168
|
|
|
707
1169
|
function createNavigator(ctx) {
|
|
708
1170
|
/** Jump to a page result; falls back to showing a manual hint. */
|
|
@@ -712,7 +1174,7 @@ window.__ModuleLoader__.load({
|
|
|
712
1174
|
}
|
|
713
1175
|
/** Jump to an in-page option: open its page, select its tab, flash its row. */
|
|
714
1176
|
const jumpToOption = async (item) => {
|
|
715
|
-
const opened = await Promise.resolve(clickNavButton(item.parentLabel))
|
|
1177
|
+
const opened = await Promise.resolve(clickNavButton(item.pageLabel ?? item.parentLabel))
|
|
716
1178
|
if (opened !== true) { flashOptionRow(item); return false }
|
|
717
1179
|
await delay(160)
|
|
718
1180
|
const label = item.label
|
|
@@ -727,21 +1189,34 @@ window.__ModuleLoader__.load({
|
|
|
727
1189
|
const flashOptionRow = (item) => {
|
|
728
1190
|
try {
|
|
729
1191
|
let target = null
|
|
1192
|
+
let card = null
|
|
730
1193
|
if (typeof item.rowIndex === 'number') {
|
|
731
|
-
// wrapper shape: pick the exact rendered row by index
|
|
732
1194
|
const container = document.querySelector(`[data-slot="${item.slot}"]`)
|
|
733
|
-
|
|
734
|
-
if (
|
|
735
|
-
// index drift — fall through to textual match below
|
|
736
|
-
target = null
|
|
737
|
-
}
|
|
1195
|
+
card = container?.children?.[item.rowIndex] ?? null
|
|
1196
|
+
if (card && !(card.textContent ?? '').includes(item.label)) card = null
|
|
738
1197
|
}
|
|
739
|
-
if (!
|
|
1198
|
+
if (!card) {
|
|
740
1199
|
const nodes = [...document.querySelectorAll(`[data-slot="${item.slot}"]`)]
|
|
741
1200
|
const rows = nodes.length === 1 && nodes[0].childElementCount > 0
|
|
742
1201
|
? [...nodes[0].children]
|
|
743
1202
|
: nodes
|
|
744
|
-
|
|
1203
|
+
card = rows.find((n) => (n.textContent ?? '').includes(item.label)) ?? null
|
|
1204
|
+
}
|
|
1205
|
+
if (card) {
|
|
1206
|
+
if (item.inner) {
|
|
1207
|
+
let best = null
|
|
1208
|
+
let bestLen = Infinity
|
|
1209
|
+
for (const el of card.querySelectorAll('*')) {
|
|
1210
|
+
const t = (el.textContent ?? '')
|
|
1211
|
+
if (t.includes(item.label) && t.length < bestLen) {
|
|
1212
|
+
best = el
|
|
1213
|
+
bestLen = t.length
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
target = best ?? card
|
|
1217
|
+
} else {
|
|
1218
|
+
target = card
|
|
1219
|
+
}
|
|
745
1220
|
}
|
|
746
1221
|
if (!target) return
|
|
747
1222
|
target.scrollIntoView({ block: 'nearest' })
|
|
@@ -785,10 +1260,75 @@ window.__ModuleLoader__.load({
|
|
|
785
1260
|
}),
|
|
786
1261
|
])
|
|
787
1262
|
|
|
1263
|
+
/** Feature cards shown in the first-run welcome modal. */
|
|
1264
|
+
const WELCOME_FEATURES = [
|
|
1265
|
+
{ icon: '🔍', titleKey: 'wlFeature1Title', descKey: 'wlFeature1Desc' },
|
|
1266
|
+
{ icon: '🔤', titleKey: 'wlFeature2Title', descKey: 'wlFeature2Desc' },
|
|
1267
|
+
{ icon: '🤖', titleKey: 'wlFeature3Title', descKey: 'wlFeature3Desc' },
|
|
1268
|
+
{ icon: '🧭', titleKey: 'wlFeature4Title', descKey: 'wlFeature4Desc' },
|
|
1269
|
+
{ icon: '🧾', titleKey: 'wlFeature5Title', descKey: 'wlFeature5Desc' },
|
|
1270
|
+
{ icon: '⚡', titleKey: 'wlFeature6Title', descKey: 'wlFeature6Desc' },
|
|
1271
|
+
]
|
|
1272
|
+
|
|
1273
|
+
/** First-run onboarding modal rendered as a fixed overlay. */
|
|
1274
|
+
function Welcome({ t, onClose, onOpenPanel }) {
|
|
1275
|
+
return React.createElement('div', {
|
|
1276
|
+
className: 'sss-wl-overlay',
|
|
1277
|
+
onMouseDown: (event) => { if (event.target === event.currentTarget) onClose() },
|
|
1278
|
+
}, [
|
|
1279
|
+
React.createElement('div', { className: 'sss-wl', role: 'dialog', 'aria-modal': 'true' }, [
|
|
1280
|
+
React.createElement('div', { className: 'sss-wl-header' }, [
|
|
1281
|
+
React.createElement('div', { className: 'sss-wl-headText' }, [
|
|
1282
|
+
React.createElement('h2', { className: 'sss-wl-title' }, t('wlTitle')),
|
|
1283
|
+
React.createElement('p', { className: 'sss-wl-subtitle' }, t('wlSubtitle')),
|
|
1284
|
+
]),
|
|
1285
|
+
React.createElement('button', { className: 'sss-wl-close', type: 'button', 'aria-label': 'Close', onClick: onClose }, '✕'),
|
|
1286
|
+
]),
|
|
1287
|
+
React.createElement('ul', { className: 'sss-wl-features' },
|
|
1288
|
+
WELCOME_FEATURES.map((f) => React.createElement('li', { className: 'sss-wl-feature', key: f.titleKey }, [
|
|
1289
|
+
React.createElement('span', { className: 'sss-wl-feature-icon', 'aria-hidden': 'true' }, f.icon),
|
|
1290
|
+
React.createElement('span', { className: 'sss-wl-feature-body' }, [
|
|
1291
|
+
React.createElement('span', { className: 'sss-wl-feature-title' }, t(f.titleKey)),
|
|
1292
|
+
React.createElement('p', { className: 'sss-wl-feature-desc' }, t(f.descKey)),
|
|
1293
|
+
]),
|
|
1294
|
+
]))),
|
|
1295
|
+
React.createElement('div', { className: 'sss-wl-footer' }, [
|
|
1296
|
+
React.createElement('button', { className: 'sss-wl-btn sss-wl-primary', type: 'button', onClick: onClose }, t('wlStart')),
|
|
1297
|
+
React.createElement('button', { className: 'sss-wl-btn', type: 'button', onClick: onOpenPanel }, t('wlOpenPanel')),
|
|
1298
|
+
React.createElement('button', { className: 'sss-wl-btn sss-wl-skip', type: 'button', onClick: onClose }, t('wlSkip')),
|
|
1299
|
+
]),
|
|
1300
|
+
]),
|
|
1301
|
+
])
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/** Destructive-action confirmation modal (danger-styled confirm button). */
|
|
1305
|
+
function ConfirmModal({ t, title, desc, confirmLabel, cancelLabel, onConfirm, onCancel }) {
|
|
1306
|
+
return React.createElement('div', {
|
|
1307
|
+
className: 'sss-cf-overlay',
|
|
1308
|
+
onMouseDown: (event) => { if (event.target === event.currentTarget) onCancel() },
|
|
1309
|
+
}, [
|
|
1310
|
+
React.createElement('div', { className: 'sss-cf', role: 'dialog', 'aria-modal': 'true' }, [
|
|
1311
|
+
React.createElement('div', { className: 'sss-cf-header' }, [
|
|
1312
|
+
React.createElement('div', { className: 'sss-cf-headText' }, [
|
|
1313
|
+
React.createElement('h2', { className: 'sss-cf-title' }, title),
|
|
1314
|
+
React.createElement('p', { className: 'sss-cf-desc' }, desc),
|
|
1315
|
+
]),
|
|
1316
|
+
React.createElement('button', { className: 'sss-cf-close', type: 'button', 'aria-label': 'Close', onClick: onCancel }, '✕'),
|
|
1317
|
+
]),
|
|
1318
|
+
React.createElement('div', { className: 'sss-cf-actions' }, [
|
|
1319
|
+
React.createElement('button', { className: 'sss-cf-btn sss-cf-danger', type: 'button', onClick: onConfirm }, confirmLabel),
|
|
1320
|
+
React.createElement('button', { className: 'sss-cf-btn', type: 'button', onClick: onCancel }, cancelLabel),
|
|
1321
|
+
]),
|
|
1322
|
+
]),
|
|
1323
|
+
])
|
|
1324
|
+
}
|
|
1325
|
+
|
|
788
1326
|
function SettingsHeaderSearch() {
|
|
789
1327
|
const [query, setQuery] = React.useState('')
|
|
790
1328
|
const [open, setOpen] = React.useState(false)
|
|
1329
|
+
const [showWelcome, setShowWelcome] = React.useState(() => !welcomed())
|
|
791
1330
|
const [tick, setTick] = React.useState(0)
|
|
1331
|
+
const [warming, setWarming] = React.useState(false)
|
|
792
1332
|
const [, forceTick] = React.useReducer((x) => x + 1, 0)
|
|
793
1333
|
void tick
|
|
794
1334
|
const [aiEnabled, setAiEnabled] = React.useState(aiSearchEnabled)
|
|
@@ -803,20 +1343,47 @@ window.__ModuleLoader__.load({
|
|
|
803
1343
|
ctx.locale.subscribe(forceTick),
|
|
804
1344
|
]
|
|
805
1345
|
// Grow the index whenever the panel body re-renders (visits new pages).
|
|
1346
|
+
let warmAlive = true
|
|
1347
|
+
let warmAttempts = 0
|
|
806
1348
|
const observer = typeof MutationObserver === 'function'
|
|
807
1349
|
? new MutationObserver(() => {
|
|
808
1350
|
if (model.runHarvest()) setTick((x) => x + 1)
|
|
1351
|
+
if (!warmed && activeSectionLabel() !== '') {
|
|
1352
|
+
void warmAllSections(model, () => setWarming(true), () => setWarming(false)).then((done) => {
|
|
1353
|
+
if (!warmAlive) return
|
|
1354
|
+
if (done) setTick((x) => x + 1)
|
|
1355
|
+
})
|
|
1356
|
+
}
|
|
809
1357
|
})
|
|
810
1358
|
: null
|
|
811
1359
|
observer?.observe(document.body, { childList: true, subtree: true })
|
|
812
|
-
const
|
|
1360
|
+
const tryWarm = () => {
|
|
1361
|
+
void warmAllSections(model, () => setWarming(true), () => setWarming(false)).then((done) => {
|
|
1362
|
+
if (!warmAlive) return
|
|
1363
|
+
if (done) { setTick((x) => x + 1); return }
|
|
1364
|
+
warmAttempts += 1
|
|
1365
|
+
if (warmAttempts < 4) setTimeout(tryWarm, 700)
|
|
1366
|
+
})
|
|
1367
|
+
}
|
|
1368
|
+
const warm = setTimeout(() => {
|
|
1369
|
+
model.runHarvest()
|
|
1370
|
+
setTick((x) => x + 1)
|
|
1371
|
+
tryWarm()
|
|
1372
|
+
}, 800)
|
|
813
1373
|
return () => {
|
|
1374
|
+
warmAlive = false
|
|
814
1375
|
for (const off of offs) off()
|
|
815
1376
|
observer?.disconnect()
|
|
816
1377
|
clearTimeout(warm)
|
|
817
1378
|
}
|
|
818
1379
|
}, [])
|
|
819
1380
|
|
|
1381
|
+
React.useEffect(() => {
|
|
1382
|
+
const onWelcome = (event) => { if (event?.detail === true) setShowWelcome(true) }
|
|
1383
|
+
window.addEventListener(WELCOME_EVENT, onWelcome)
|
|
1384
|
+
return () => window.removeEventListener(WELCOME_EVENT, onWelcome)
|
|
1385
|
+
}, [])
|
|
1386
|
+
|
|
820
1387
|
React.useEffect(() => {
|
|
821
1388
|
const sync = () => setAiEnabled(aiSearchEnabled())
|
|
822
1389
|
const onConfig = () => forceTick()
|
|
@@ -930,6 +1497,7 @@ window.__ModuleLoader__.load({
|
|
|
930
1497
|
const [hint, setHint] = React.useState(null)
|
|
931
1498
|
const [active, setActive] = React.useState(0)
|
|
932
1499
|
const popRef = React.useRef(null)
|
|
1500
|
+
const inputRef = React.useRef(null)
|
|
933
1501
|
const activeIndex = Math.min(active, Math.max(results.length - 1, 0))
|
|
934
1502
|
|
|
935
1503
|
React.useEffect(() => { setActive(0) }, [query])
|
|
@@ -948,6 +1516,15 @@ window.__ModuleLoader__.load({
|
|
|
948
1516
|
})
|
|
949
1517
|
}
|
|
950
1518
|
|
|
1519
|
+
const dismissWelcome = () => {
|
|
1520
|
+
setWelcomed()
|
|
1521
|
+
setShowWelcome(false)
|
|
1522
|
+
}
|
|
1523
|
+
const openPanelFromWelcome = () => {
|
|
1524
|
+
dismissWelcome()
|
|
1525
|
+
clickNavButton(t('updn'))
|
|
1526
|
+
}
|
|
1527
|
+
|
|
951
1528
|
const mark = (text) => React.createElement('mark', { className: 'sss-hit' }, text)
|
|
952
1529
|
|
|
953
1530
|
/** Highlight the matched substring, or the pinyin-covered prefix. */
|
|
@@ -1013,12 +1590,19 @@ window.__ModuleLoader__.load({
|
|
|
1013
1590
|
return React.createElement('div', { className: 'sss-wrap' }, [
|
|
1014
1591
|
React.createElement('div', { className: 'sss-title' }, t('title')),
|
|
1015
1592
|
React.createElement('div', { className: 'sss-box' }, [
|
|
1016
|
-
|
|
1593
|
+
React.createElement('button', {
|
|
1594
|
+
className: 'sss-icon-btn',
|
|
1595
|
+
type: 'button',
|
|
1596
|
+
'aria-label': t('searchBtn'),
|
|
1597
|
+
title: t('searchBtn'),
|
|
1598
|
+
onClick: () => inputRef.current?.focus(),
|
|
1599
|
+
}, [SearchIcon]),
|
|
1017
1600
|
React.createElement('input', {
|
|
1018
1601
|
className: 'sss-input',
|
|
1019
1602
|
value: query,
|
|
1020
1603
|
placeholder: t('placeholder'),
|
|
1021
1604
|
spellCheck: false,
|
|
1605
|
+
ref: inputRef,
|
|
1022
1606
|
onKeyDown: (e) => {
|
|
1023
1607
|
if (e.key === 'ArrowDown') {
|
|
1024
1608
|
e.preventDefault()
|
|
@@ -1043,6 +1627,7 @@ window.__ModuleLoader__.load({
|
|
|
1043
1627
|
onClick: () => { setQuery(''); setHint(null); setOpen(false) },
|
|
1044
1628
|
}, '✕') : null,
|
|
1045
1629
|
]),
|
|
1630
|
+
warming ? React.createElement('p', { className: 'sss-warm-note' }, t('warming')) : null,
|
|
1046
1631
|
open && q !== '' ? React.createElement('div', { className: 'sss-pop', ref: popRef }, [
|
|
1047
1632
|
none
|
|
1048
1633
|
? (aiStatus === 'loading'
|
|
@@ -1075,6 +1660,9 @@ window.__ModuleLoader__.load({
|
|
|
1075
1660
|
hint !== null
|
|
1076
1661
|
? React.createElement('div', { className: 'sss-guide' }, t('jumpFail').replace('{path}', hint))
|
|
1077
1662
|
: null,
|
|
1663
|
+
showWelcome
|
|
1664
|
+
? React.createElement(Welcome, { t, onClose: dismissWelcome, onOpenPanel: openPanelFromWelcome })
|
|
1665
|
+
: null,
|
|
1078
1666
|
])
|
|
1079
1667
|
}
|
|
1080
1668
|
|
|
@@ -1102,11 +1690,15 @@ window.__ModuleLoader__.load({
|
|
|
1102
1690
|
const [model, setModel] = React.useState(cfg.model)
|
|
1103
1691
|
const [apiKey, setApiKey] = React.useState(cfg.apiKey)
|
|
1104
1692
|
const [aiSaved, setAiSaved] = React.useState(false)
|
|
1693
|
+
const [testState, setTestState] = React.useState('idle') // idle | loading | ok | fail
|
|
1694
|
+
const [testMsg, setTestMsg] = React.useState('')
|
|
1105
1695
|
const [, setLogVersion] = React.useState(0)
|
|
1106
1696
|
const [logCopied, setLogCopied] = React.useState(false)
|
|
1107
1697
|
const [logCopyFail, setLogCopyFail] = React.useState(false)
|
|
1108
1698
|
const [logExportFail, setLogExportFail] = React.useState(false)
|
|
1109
1699
|
const [logCleared, setLogCleared] = React.useState(false)
|
|
1700
|
+
const [confirmClear, setConfirmClear] = React.useState(false)
|
|
1701
|
+
const [confirmClearAi, setConfirmClearAi] = React.useState(false)
|
|
1110
1702
|
|
|
1111
1703
|
React.useEffect(() => {
|
|
1112
1704
|
const sync = () => setAiOn(aiSearchEnabled())
|
|
@@ -1161,6 +1753,52 @@ window.__ModuleLoader__.load({
|
|
|
1161
1753
|
log('info', '保存 AI 配置', { provider, model: model.trim() })
|
|
1162
1754
|
}
|
|
1163
1755
|
|
|
1756
|
+
const requestClearAi = () => setConfirmClearAi(true)
|
|
1757
|
+
const cancelClearAi = () => setConfirmClearAi(false)
|
|
1758
|
+
const clearAiConfig = () => {
|
|
1759
|
+
setConfirmClearAi(false)
|
|
1760
|
+
setAiConfig({ provider: 'openai', baseUrl: '', model: '', apiKey: '' })
|
|
1761
|
+
setProvider('openai')
|
|
1762
|
+
setBaseUrl('')
|
|
1763
|
+
setModel('')
|
|
1764
|
+
setApiKey('')
|
|
1765
|
+
setAiSaved(false)
|
|
1766
|
+
log('info', '清除 API 配置', {})
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
const testService = async () => {
|
|
1770
|
+
if (testState === 'loading') return
|
|
1771
|
+
if (!baseUrl.trim() || !model.trim() || !apiKey.trim()) {
|
|
1772
|
+
setTestState('fail')
|
|
1773
|
+
setTestMsg(t('aiNeedConfig'))
|
|
1774
|
+
return
|
|
1775
|
+
}
|
|
1776
|
+
setTestState('loading')
|
|
1777
|
+
setTestMsg('')
|
|
1778
|
+
const result = await testAiService({ provider, baseUrl, model, apiKey })
|
|
1779
|
+
if (result.ok) {
|
|
1780
|
+
setTestState('ok')
|
|
1781
|
+
setTestMsg(t('aiTestOk'))
|
|
1782
|
+
} else {
|
|
1783
|
+
setTestState('fail')
|
|
1784
|
+
const hintKey = {
|
|
1785
|
+
auth: 'aiTestErrAuth',
|
|
1786
|
+
forbidden: 'aiTestErrForbidden',
|
|
1787
|
+
'not-found': 'aiTestErrNotFound',
|
|
1788
|
+
'rate-limit': 'aiTestErrRateLimit',
|
|
1789
|
+
'bad-request': 'aiTestErrBadRequest',
|
|
1790
|
+
server: 'aiTestErrServer',
|
|
1791
|
+
timeout: 'aiTestErrTimeout',
|
|
1792
|
+
network: 'aiTestErrNetwork',
|
|
1793
|
+
'bad-response': 'aiTestErrBadResponse',
|
|
1794
|
+
'unsafe-url': 'aiTestErrUnsafeUrl',
|
|
1795
|
+
}[result.code] ?? 'aiTestErrHttp'
|
|
1796
|
+
const hint = t(hintKey)
|
|
1797
|
+
const detail = result.detail ? `(${result.detail})` : ''
|
|
1798
|
+
setTestMsg(`${t('aiTestFail')}:${hint}${detail}`)
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1164
1802
|
const exportLogs = () => {
|
|
1165
1803
|
setLogExportFail(false)
|
|
1166
1804
|
try {
|
|
@@ -1188,7 +1826,10 @@ window.__ModuleLoader__.load({
|
|
|
1188
1826
|
}
|
|
1189
1827
|
}
|
|
1190
1828
|
|
|
1829
|
+
const requestClear = () => setConfirmClear(true)
|
|
1830
|
+
const cancelClear = () => setConfirmClear(false)
|
|
1191
1831
|
const clearLogs = () => {
|
|
1832
|
+
setConfirmClear(false)
|
|
1192
1833
|
LOGS.length = 0
|
|
1193
1834
|
setLogVersion((v) => v + 1)
|
|
1194
1835
|
setLogCleared(true)
|
|
@@ -1204,6 +1845,11 @@ window.__ModuleLoader__.load({
|
|
|
1204
1845
|
const data = await res.json()
|
|
1205
1846
|
const ver = typeof data?.version === 'string' ? data.version : ''
|
|
1206
1847
|
if (ver === '') throw new Error('no version in registry response')
|
|
1848
|
+
// Only accept a plain semver string so it can never be interpreted
|
|
1849
|
+
// as shell syntax or injected into a URL opened via location.href.
|
|
1850
|
+
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(ver)) {
|
|
1851
|
+
throw new Error('invalid semver in registry response')
|
|
1852
|
+
}
|
|
1207
1853
|
setLatest(ver)
|
|
1208
1854
|
const newer = compareSemver(ver, PKG_VERSION) > 0
|
|
1209
1855
|
setState(newer ? 'outdated' : 'newest')
|
|
@@ -1291,7 +1937,11 @@ window.__ModuleLoader__.load({
|
|
|
1291
1937
|
]),
|
|
1292
1938
|
React.createElement('select', {
|
|
1293
1939
|
className: 'sss-vr-input sss-vr-select',
|
|
1940
|
+
name: 'dsh-settings-search-provider',
|
|
1294
1941
|
value: provider,
|
|
1942
|
+
autoComplete: 'off',
|
|
1943
|
+
'data-lpignore': 'true',
|
|
1944
|
+
'data-form-type': 'other',
|
|
1295
1945
|
onChange: (event) => setProvider(event.target.value),
|
|
1296
1946
|
}, [
|
|
1297
1947
|
React.createElement('option', { value: 'openai' }, t('aiOpenAI')),
|
|
@@ -1303,37 +1953,59 @@ window.__ModuleLoader__.load({
|
|
|
1303
1953
|
React.createElement('input', {
|
|
1304
1954
|
className: 'sss-vr-input',
|
|
1305
1955
|
type: 'url',
|
|
1956
|
+
name: 'dsh-settings-search-base-url',
|
|
1306
1957
|
value: baseUrl,
|
|
1307
1958
|
placeholder: provider === 'anthropic' ? 'https://api.anthropic.com' : 'https://api.openai.com/v1',
|
|
1308
1959
|
spellCheck: false,
|
|
1960
|
+
autoComplete: 'off',
|
|
1961
|
+
'data-lpignore': 'true',
|
|
1962
|
+
'data-1p-ignore': 'true',
|
|
1963
|
+
'data-form-type': 'other',
|
|
1309
1964
|
onChange: (event) => setBaseUrl(event.target.value),
|
|
1310
1965
|
}),
|
|
1966
|
+
React.createElement('p', { className: 'sss-vr-hint' }, t('aiBaseUrlHint')),
|
|
1311
1967
|
React.createElement('div', { className: 'sss-vr-field-head' }, [
|
|
1312
1968
|
React.createElement('label', { className: 'sss-vr-label' }, t('aiModel')),
|
|
1313
1969
|
]),
|
|
1314
1970
|
React.createElement('input', {
|
|
1315
1971
|
className: 'sss-vr-input',
|
|
1316
1972
|
type: 'text',
|
|
1973
|
+
name: 'dsh-settings-search-model',
|
|
1317
1974
|
value: model,
|
|
1318
1975
|
placeholder: provider === 'anthropic' ? 'claude-sonnet-4-20250514' : 'gpt-4o-mini',
|
|
1319
1976
|
spellCheck: false,
|
|
1977
|
+
autoComplete: 'off',
|
|
1978
|
+
'data-lpignore': 'true',
|
|
1979
|
+
'data-1p-ignore': 'true',
|
|
1980
|
+
'data-form-type': 'other',
|
|
1320
1981
|
onChange: (event) => setModel(event.target.value),
|
|
1321
1982
|
}),
|
|
1322
1983
|
React.createElement('div', { className: 'sss-vr-field-head' }, [
|
|
1323
1984
|
React.createElement('label', { className: 'sss-vr-label' }, t('aiApiKey')),
|
|
1324
1985
|
]),
|
|
1325
1986
|
React.createElement('input', {
|
|
1326
|
-
className: 'sss-vr-input',
|
|
1327
|
-
type: '
|
|
1987
|
+
className: 'sss-vr-input sss-vr-mask',
|
|
1988
|
+
type: 'text',
|
|
1989
|
+
name: 'dsh-settings-search-api-key',
|
|
1328
1990
|
value: apiKey,
|
|
1329
1991
|
placeholder: 'sk-…',
|
|
1330
1992
|
spellCheck: false,
|
|
1331
1993
|
autoComplete: 'off',
|
|
1994
|
+
'data-lpignore': 'true',
|
|
1995
|
+
'data-1p-ignore': 'true',
|
|
1996
|
+
'data-form-type': 'other',
|
|
1332
1997
|
onChange: (event) => setApiKey(event.target.value),
|
|
1333
1998
|
}),
|
|
1334
|
-
React.createElement('div', { className: 'sss-vr-actions' },
|
|
1999
|
+
React.createElement('div', { className: 'sss-vr-actions' }, [
|
|
1335
2000
|
React.createElement('button', { className: 'sss-vr-btn sss-vr-primary', type: 'button', onClick: () => { void saveAi() } },
|
|
1336
|
-
aiSaved ? t('aiConfigSaved') : t('aiConfigSave'))
|
|
2001
|
+
aiSaved ? t('aiConfigSaved') : t('aiConfigSave')),
|
|
2002
|
+
React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: testState === 'loading', onClick: () => { void testService() } },
|
|
2003
|
+
testState === 'loading' ? t('aiTesting') : t('aiTest')),
|
|
2004
|
+
React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => requestClearAi() }, t('aiClear')),
|
|
2005
|
+
]),
|
|
2006
|
+
testMsg
|
|
2007
|
+
? React.createElement('p', { className: 'sss-vr-note', style: { color: testState === 'ok' ? '#1e8e3e' : 'var(--dsw-alias-label-danger)' } }, testMsg)
|
|
2008
|
+
: null,
|
|
1337
2009
|
]),
|
|
1338
2010
|
React.createElement('div', { className: 'sss-vr-field' }, [
|
|
1339
2011
|
React.createElement('div', { className: 'sss-vr-row' }, [
|
|
@@ -1366,6 +2038,7 @@ window.__ModuleLoader__.load({
|
|
|
1366
2038
|
state === 'loading' ? t('updChecking') : t('updCheck')),
|
|
1367
2039
|
React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: notesState === 'loading', onClick: () => { void fetchNotes() } },
|
|
1368
2040
|
notesState === 'loading' ? t('updFetchingNotes') : t('updReleaseNotes')),
|
|
2041
|
+
React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => openWelcome() }, t('wlReopen')),
|
|
1369
2042
|
]),
|
|
1370
2043
|
notesState === 'loaded'
|
|
1371
2044
|
? React.createElement('div', { className: 'sss-vr-notes' }, notesBody)
|
|
@@ -1384,13 +2057,35 @@ window.__ModuleLoader__.load({
|
|
|
1384
2057
|
React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void exportLogs() } }, t('logExport')),
|
|
1385
2058
|
React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () => { void copyLogs() } },
|
|
1386
2059
|
logCopied ? t('logCopied') : t('logCopy')),
|
|
1387
|
-
React.createElement('button', { className: 'sss-vr-btn', type: 'button', onClick: () =>
|
|
2060
|
+
React.createElement('button', { className: 'sss-vr-btn', type: 'button', disabled: LOGS.length === 0, onClick: () => requestClear() },
|
|
1388
2061
|
logCleared ? t('logCleared') : t('logClear')),
|
|
1389
2062
|
]),
|
|
1390
2063
|
logCopyFail ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-label-danger)' } }, t('logCopyFail')) : null,
|
|
1391
2064
|
logExportFail ? React.createElement('p', { className: 'sss-vr-note', style: { color: 'var(--dsw-alias-label-danger)' } }, t('logExportFail')) : null,
|
|
1392
2065
|
]),
|
|
1393
2066
|
]) : null,
|
|
2067
|
+
confirmClear
|
|
2068
|
+
? React.createElement(ConfirmModal, {
|
|
2069
|
+
t,
|
|
2070
|
+
title: t('logConfirmTitle'),
|
|
2071
|
+
desc: t('logConfirm'),
|
|
2072
|
+
confirmLabel: t('logConfirmYes'),
|
|
2073
|
+
cancelLabel: t('logConfirmCancel'),
|
|
2074
|
+
onConfirm: () => { void clearLogs() },
|
|
2075
|
+
onCancel: () => cancelClear(),
|
|
2076
|
+
})
|
|
2077
|
+
: null,
|
|
2078
|
+
confirmClearAi
|
|
2079
|
+
? React.createElement(ConfirmModal, {
|
|
2080
|
+
t,
|
|
2081
|
+
title: t('aiClearTitle'),
|
|
2082
|
+
desc: t('aiClearConfirm'),
|
|
2083
|
+
confirmLabel: t('aiClearYes'),
|
|
2084
|
+
cancelLabel: t('logConfirmCancel'),
|
|
2085
|
+
onConfirm: () => { void clearAiConfig() },
|
|
2086
|
+
onCancel: () => cancelClearAi(),
|
|
2087
|
+
})
|
|
2088
|
+
: null,
|
|
1394
2089
|
])
|
|
1395
2090
|
}
|
|
1396
2091
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectivex666/dsh-settings-search",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "DSH settings panel search plugin – instantly filter setting sections and general items with navigation guidance. Installable via `dsh plugin add`.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|