@jayyuen66/dsh-ocr-review 0.1.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/LICENSE +21 -0
- package/README.md +233 -0
- package/client.js +1041 -0
- package/cordis.patch.yml +15 -0
- package/host.js +2936 -0
- package/icon.svg +1 -0
- package/package.json +112 -0
- package/scripts/get-cred.mjs +182 -0
package/client.js
ADDED
|
@@ -0,0 +1,1041 @@
|
|
|
1
|
+
// GENERATED by buildClient() in build-client.mjs — edit src/client-entry.ts, then run `node build-client.mjs`.
|
|
2
|
+
window.__ModuleLoader__.load({
|
|
3
|
+
id: '@jayyuen66/dsh-ocr-review',
|
|
4
|
+
factory: (require) => {
|
|
5
|
+
var module = { exports: {} };
|
|
6
|
+
var exports = module.exports;
|
|
7
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
8
|
+
let react = require("react");
|
|
9
|
+
//#region src/ui-messages.ts
|
|
10
|
+
const UI_MESSAGES = {
|
|
11
|
+
zh: {
|
|
12
|
+
cardTitle: "open-code-review 代码审查",
|
|
13
|
+
cardDescription: "ocr 工具配置:选择 provider/model 应用到 OCR(key 走 api_key_cmd 动态读取,不落明文)+ 审查参数设置",
|
|
14
|
+
statusReadOnly: "当前作用域只读",
|
|
15
|
+
statusDirty: "有未保存的修改,点「保存」生效",
|
|
16
|
+
statusClean: "无未保存的修改",
|
|
17
|
+
save: "保存",
|
|
18
|
+
saving: "保存中…",
|
|
19
|
+
revert: "撤销",
|
|
20
|
+
saveFailed: "保存失败:",
|
|
21
|
+
loadProvidersFailed: "加载 providers 失败:{error}(端点未就绪?重启 dsh 后重试)",
|
|
22
|
+
loadingProviders: "加载 providers…",
|
|
23
|
+
noKeySuffix: "(未配置 key)",
|
|
24
|
+
noModel: "(无模型)",
|
|
25
|
+
providerHint: "来自 dsh 配置服务的 llm-pi-ai 命名空间(无 API key 的已禁用)",
|
|
26
|
+
applyToOcr: "应用到 OCR 配置",
|
|
27
|
+
applying: "应用中…",
|
|
28
|
+
testConnection: "测试连接",
|
|
29
|
+
migrateKeys: "迁移明文 key",
|
|
30
|
+
currentConfig: "当前配置:{provider}{modelPart}{keyPart}",
|
|
31
|
+
modelPart: " / {model}",
|
|
32
|
+
dynamicKey: "(动态 key)",
|
|
33
|
+
plainKeyWarning: "(⚠ 明文 key,建议迁移)",
|
|
34
|
+
noProvider: "当前未配置 provider",
|
|
35
|
+
noReason: "宿主未给出原因(重启 dsh 或重试可恢复)",
|
|
36
|
+
readProvidersFailed: "未能从 dsh 配置服务读取 provider 列表:{reason}",
|
|
37
|
+
applied: "已应用:{provider} / {model}\n{test}",
|
|
38
|
+
notVerified: "(未自动验证,可点「测试连接」手动验证)",
|
|
39
|
+
llmTestPassed: "✓ ocr llm test 通过",
|
|
40
|
+
llmTestFailed: "✗ ocr llm test 失败:",
|
|
41
|
+
pickProviderModel: "请先选择 provider 与 model",
|
|
42
|
+
applyFailed: "应用失败",
|
|
43
|
+
requestFailed: "请求失败:",
|
|
44
|
+
migratedPrefix: "已迁移明文 key → api_key_cmd:",
|
|
45
|
+
noPlainKey: "没有需要迁移的明文 key",
|
|
46
|
+
skippedPrefix: "\n跳过(settings 中无对应 apiKeyEnv):",
|
|
47
|
+
migrateFailed: "迁移失败",
|
|
48
|
+
effortLabel: "审查深度 (effort)",
|
|
49
|
+
effortHint: "low=1 轮快速反馈 / medium=2 轮默认 / high=3 轮深查(越深越贵)",
|
|
50
|
+
effortLow: "low — 快速",
|
|
51
|
+
effortMedium: "medium — 均衡(默认)",
|
|
52
|
+
effortHigh: "high — 深入",
|
|
53
|
+
languageLabel: "评审语言",
|
|
54
|
+
languageHint: "OCR 评论输出语言(写入 config.language)",
|
|
55
|
+
autoVerifyLabel: "应用后自动验证",
|
|
56
|
+
autoVerifyHint: "选择 provider/model 后自动跑 ocr llm test",
|
|
57
|
+
onLabel: "开启",
|
|
58
|
+
offLabel: "关闭",
|
|
59
|
+
maxCommentsLabel: "摘要评论条数 (maxComments)",
|
|
60
|
+
maxCommentsHint: "返回给模型的 top 评论条数;0 = 全部(含未锚定行,输出大)(默认 12)",
|
|
61
|
+
timeoutLabel: "运行超时 (timeoutMinutes)",
|
|
62
|
+
timeoutHint: "前台墙钟超时(分钟);0 = 宿主上限(shell.maxTimeoutMs,当前 60 分钟);更长的评审用工具 wait=false 后台模式(不设宿主 deadline,2 小时后兜底回收)(默认 0)",
|
|
63
|
+
configPathLabel: "OCR 配置文件 (ocrConfigPath)",
|
|
64
|
+
configPathHint: "外部 open-code-review CLI 自己的配置位置;留空 = 按当前用户主目录下的 ~/.opencodereview/config.json",
|
|
65
|
+
settingsLoading: "设置加载中,稍候可改",
|
|
66
|
+
keyDynamicHint: "key 走 get-cred.mjs 动态读取,OCR 配置不留明文",
|
|
67
|
+
keyScriptMissing: "⚠ get-cred 脚本缺失,api_key_cmd 无法工作"
|
|
68
|
+
},
|
|
69
|
+
en: {
|
|
70
|
+
cardTitle: "open-code-review code review",
|
|
71
|
+
cardDescription: "ocr tool setup: pick a provider/model and apply it to OCR (keys are read dynamically via api_key_cmd, never stored in plain text) + review parameters",
|
|
72
|
+
statusReadOnly: "This scope is read-only",
|
|
73
|
+
statusDirty: "Unsaved changes — press Save to apply",
|
|
74
|
+
statusClean: "No unsaved changes",
|
|
75
|
+
save: "Save",
|
|
76
|
+
saving: "Saving…",
|
|
77
|
+
revert: "Revert",
|
|
78
|
+
saveFailed: "save failed: ",
|
|
79
|
+
loadProvidersFailed: "failed to load providers: {error} (endpoint not ready? restart dsh and retry)",
|
|
80
|
+
loadingProviders: "loading providers…",
|
|
81
|
+
noKeySuffix: " (no key configured)",
|
|
82
|
+
noModel: " (no model)",
|
|
83
|
+
providerHint: "From the llm-pi-ai namespace of the dsh settings service (entries without an API key are disabled)",
|
|
84
|
+
applyToOcr: "Apply to OCR config",
|
|
85
|
+
applying: "applying…",
|
|
86
|
+
testConnection: "Test connection",
|
|
87
|
+
migrateKeys: "Migrate plain-text keys",
|
|
88
|
+
currentConfig: "Current config: {provider}{modelPart}{keyPart}",
|
|
89
|
+
modelPart: " / {model}",
|
|
90
|
+
dynamicKey: " (dynamic key)",
|
|
91
|
+
plainKeyWarning: " (⚠ plain-text key, migration recommended)",
|
|
92
|
+
noProvider: "No provider configured yet",
|
|
93
|
+
noReason: "the host gave no reason (restart dsh or retry to recover)",
|
|
94
|
+
readProvidersFailed: "Could not read the provider list from the dsh settings service: {reason}",
|
|
95
|
+
applied: "Applied: {provider} / {model}\n{test}",
|
|
96
|
+
notVerified: "(not verified automatically — use “Test connection” to check manually)",
|
|
97
|
+
llmTestPassed: "✓ ocr llm test passed",
|
|
98
|
+
llmTestFailed: "✗ ocr llm test failed: ",
|
|
99
|
+
pickProviderModel: "Pick a provider and a model first",
|
|
100
|
+
applyFailed: "apply failed",
|
|
101
|
+
requestFailed: "request failed: ",
|
|
102
|
+
migratedPrefix: "Migrated plain-text keys → api_key_cmd: ",
|
|
103
|
+
noPlainKey: "no plain-text keys to migrate",
|
|
104
|
+
skippedPrefix: "\nSkipped (no apiKeyEnv in settings): ",
|
|
105
|
+
migrateFailed: "migration failed",
|
|
106
|
+
effortLabel: "Review depth (effort)",
|
|
107
|
+
effortHint: "low = 1 pass quick feedback / medium = 2 passes default / high = 3 passes deep review (deeper costs more)",
|
|
108
|
+
effortLow: "low — quick",
|
|
109
|
+
effortMedium: "medium — balanced (default)",
|
|
110
|
+
effortHigh: "high — thorough",
|
|
111
|
+
languageLabel: "Review language",
|
|
112
|
+
languageHint: "Output language of OCR comments (written into config.language)",
|
|
113
|
+
autoVerifyLabel: "Verify after applying",
|
|
114
|
+
autoVerifyHint: "Run `ocr llm test` automatically after picking a provider/model",
|
|
115
|
+
onLabel: "On",
|
|
116
|
+
offLabel: "Off",
|
|
117
|
+
maxCommentsLabel: "Summary comment cap (maxComments)",
|
|
118
|
+
maxCommentsHint: "Top comments returned to the model; 0 = all (including unanchored lines, large output) (default 12)",
|
|
119
|
+
timeoutLabel: "Run timeout (timeoutMinutes)",
|
|
120
|
+
timeoutHint: "Foreground wall-clock timeout in minutes; 0 = host cap (shell.maxTimeoutMs, currently 60 min); for longer reviews use the tool's wait=false background mode (no host deadline, reclaimed by a 2-hour backstop) (default 0)",
|
|
121
|
+
configPathLabel: "OCR config file (ocrConfigPath)",
|
|
122
|
+
configPathHint: "Where the external open-code-review CLI keeps its own config; empty = ~/.opencodereview/config.json under the current home directory",
|
|
123
|
+
settingsLoading: "settings still loading — editable in a moment",
|
|
124
|
+
keyDynamicHint: "Keys are read dynamically via get-cred.mjs; OCR config keeps no plain text",
|
|
125
|
+
keyScriptMissing: "⚠ get-cred script missing, api_key_cmd cannot work"
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region node_modules/.pnpm/@jayyuen66+dsh-plugin-shared@0.1.0_@deepseek-ai+cordis@4.0.4_eslint-plugin-sonarjs@4.2._a5b0027b68963132c312048d042510c0/node_modules/@jayyuen66/dsh-plugin-shared/dist/record.js
|
|
130
|
+
/** 对象类型守卫:非 `null` 的 `object` 且非数组(`Record` 索引签名投影的基础)。 */
|
|
131
|
+
function isRecord(value) {
|
|
132
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
133
|
+
}
|
|
134
|
+
/** 从 `unknown` 投影单个字段;非 `Record` → `undefined`。 */
|
|
135
|
+
function fieldOf(value, key) {
|
|
136
|
+
return isRecord(value) ? value[key] : void 0;
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/client-entry.ts
|
|
140
|
+
const NS = "ocr-review";
|
|
141
|
+
/**
|
|
142
|
+
* `plugins.bundle.config` 是 keyed 槽,宿主派发的 entryKey 就是 bundle 的 **npm 包名**:
|
|
143
|
+
* · installed dsh-client-ui-plugin-manager/lib/client.js:1821
|
|
144
|
+
* `renderSlot("plugins.bundle.config", { view: "page" }, { entryKey: pkg.name })`;
|
|
145
|
+
* · 同文件 :2698 `configured: ledger.bundles.has(openPkg.name)`(决定这一节画不画);
|
|
146
|
+
* · installed dsh-client-ui-renderer/lib/client.js:1154
|
|
147
|
+
* `entriesOfSlot(slotKey).find((e) => e.options.key === opts?.entryKey)` 逐字相等;
|
|
148
|
+
* · 契约 installed dsh-client-ui-plugin-manager/lib/types/client/slot-contract.d.ts:96-100
|
|
149
|
+
* 「keyed by the bundle's package name」;官方占位者
|
|
150
|
+
* dsh-experimental-client-ui-voice-input/lib/client.js:5659-5661 同样写包名。
|
|
151
|
+
* 写成裸条目 id(`ocr-review`)匹配不到任何 entry → 整张卡在 bundle 页面上**静默不渲染**
|
|
152
|
+
* (不是回落、不是报错)。值 = 本包 package.json 的 name,且必须出现在 profile 的
|
|
153
|
+
* `dsh.profile.bundles` 里;test/build-client.test.ts 从
|
|
154
|
+
* `~/.dsh/profiles/web/package.json` 解析校验,不在此处硬抄一份到测试。
|
|
155
|
+
* 设置命名空间**不跟着改**:下面 `ctx.configForms.get(NS)` 仍吃裸条目 id(installed
|
|
156
|
+
* dsh-client-ui-settings/lib/client.js:1309-1315 把入参原样当 settings 命名空间用)。
|
|
157
|
+
*/
|
|
158
|
+
const BUNDLE_PKG = "@jayyuen66/dsh-ocr-review";
|
|
159
|
+
const PROVIDERS_URL = "/_dsh/ocr-review/providers";
|
|
160
|
+
const SELECT_URL = "/_dsh/ocr-review/select";
|
|
161
|
+
const MIGRATE_URL = "/_dsh/ocr-review/migrate";
|
|
162
|
+
const TEST_URL = "/_dsh/ocr-review/test";
|
|
163
|
+
/** 写端点 CSRF 头名(与 host.ts OCR_CSRF_HEADER 同源,改名要两端一起改)。 */
|
|
164
|
+
const CSRF_HEADER = "x-ocr-csrf";
|
|
165
|
+
/** 未知值 → 安全字符串(非字符串给 '')。 */
|
|
166
|
+
function strOf(value) {
|
|
167
|
+
return typeof value === "string" ? value : "";
|
|
168
|
+
}
|
|
169
|
+
/** 未知值 → 安全数字(非 number 给 fallback)。 */
|
|
170
|
+
function numOf(value, fallback) {
|
|
171
|
+
return typeof value === "number" ? value : fallback;
|
|
172
|
+
}
|
|
173
|
+
/** 错误摘要(非 Error 抛出也要可读;catch 分支共用)。 */
|
|
174
|
+
function errorSummary(error) {
|
|
175
|
+
return String(error instanceof Error ? error.message : error);
|
|
176
|
+
}
|
|
177
|
+
function modelOf(raw) {
|
|
178
|
+
return {
|
|
179
|
+
id: strOf(fieldOf(raw, "id")),
|
|
180
|
+
name: strOf(fieldOf(raw, "name"))
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function providerOf(raw) {
|
|
184
|
+
const modelsArr = fieldOf(raw, "models");
|
|
185
|
+
const models = Array.isArray(modelsArr) ? modelsArr.map((item) => modelOf(item)) : [];
|
|
186
|
+
const apiKeyEnvRaw = fieldOf(raw, "apiKeyEnv");
|
|
187
|
+
return {
|
|
188
|
+
name: strOf(fieldOf(raw, "name")),
|
|
189
|
+
displayName: strOf(fieldOf(raw, "displayName")),
|
|
190
|
+
apiKeyEnv: typeof apiKeyEnvRaw === "string" ? apiKeyEnvRaw : null,
|
|
191
|
+
baseURL: strOf(fieldOf(raw, "baseURL")),
|
|
192
|
+
models,
|
|
193
|
+
hasKey: fieldOf(raw, "hasKey") === true
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function providersPayloadOf(json) {
|
|
197
|
+
const providersArr = fieldOf(json, "providers");
|
|
198
|
+
const providers = Array.isArray(providersArr) ? providersArr.map((item) => providerOf(item)) : [];
|
|
199
|
+
const currentRaw = fieldOf(json, "current");
|
|
200
|
+
const current = {
|
|
201
|
+
provider: strOf(fieldOf(currentRaw, "provider")),
|
|
202
|
+
model: strOf(fieldOf(currentRaw, "model")),
|
|
203
|
+
keyIsDynamic: fieldOf(currentRaw, "keyIsDynamic") === true,
|
|
204
|
+
url: strOf(fieldOf(currentRaw, "url"))
|
|
205
|
+
};
|
|
206
|
+
const sourceRaw = fieldOf(json, "source");
|
|
207
|
+
return {
|
|
208
|
+
providers,
|
|
209
|
+
current,
|
|
210
|
+
getCredReady: fieldOf(json, "getCredReady") === true,
|
|
211
|
+
source: {
|
|
212
|
+
status: strOf(fieldOf(sourceRaw, "status")),
|
|
213
|
+
message: strOf(fieldOf(sourceRaw, "message"))
|
|
214
|
+
},
|
|
215
|
+
csrf: strOf(fieldOf(json, "csrf"))
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/** 三行表单控件(数字/文本/下拉)共用的类名 = CARD_CSS 里 `.ocr-select{…}` 那条规则。
|
|
219
|
+
* 抽出来是因为两处 input 与一处 select 必须同宽同边框:写三遍字面量时改一处漏两处,
|
|
220
|
+
* 症状是设置卡里某一行的控件比其它行窄。 */
|
|
221
|
+
const FORM_CONTROL_CLASS = "ocr-select";
|
|
222
|
+
const CARD_CSS = [
|
|
223
|
+
".ocr-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}",
|
|
224
|
+
".ocr-card:hover{border-color:var(--dsw-alias-label-dimmed)}",
|
|
225
|
+
".ocr-card-open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}",
|
|
226
|
+
".ocr-card-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:transparent;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}",
|
|
227
|
+
".ocr-card-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}",
|
|
228
|
+
".ocr-card-head{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}",
|
|
229
|
+
".ocr-card-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}",
|
|
230
|
+
".ocr-card-desc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}",
|
|
231
|
+
".ocr-card-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}",
|
|
232
|
+
".ocr-card-chevron-open{transform:rotate(180deg)}",
|
|
233
|
+
".ocr-card-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:8px 0 12px;font-size:13px;line-height:1.5}",
|
|
234
|
+
".ocr-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 0}",
|
|
235
|
+
".ocr-hint{font-size:12px;color:var(--dsw-alias-label-tertiary,#8a8f99);margin-top:2px}",
|
|
236
|
+
".ocr-select{flex:none;width:min(46%,240px);box-sizing:border-box;font:inherit;font-size:12px;color:var(--dsw-alias-label-primary,inherit);background:var(--dsw-alias-bg-layer-1,transparent);border:1px solid var(--dsw-alias-border-l2,#d8dbe2);border-radius:8px;padding:5px 8px}",
|
|
237
|
+
".ocr-btn{appearance:auto;font:inherit;font-size:12px;cursor:pointer;border-radius:6px;padding:4px 12px;border:1px solid var(--dsw-alias-border-l2,#d8dbe2);background:var(--dsw-alias-bg-layer-1,transparent);color:var(--dsw-alias-label-primary,inherit)}",
|
|
238
|
+
".ocr-btn:disabled{opacity:.5;cursor:not-allowed}",
|
|
239
|
+
".ocr-btn-primary{background:var(--dsw-alias-brand-primary,#2f6fed);border-color:var(--dsw-alias-brand-primary,#2f6fed);color:var(--dsw-alias-label-primary-foreground,#fff)}",
|
|
240
|
+
".ocr-status{font-size:12px;margin-top:6px;white-space:pre-wrap;word-break:break-word}",
|
|
241
|
+
".ocr-err{color:#c4483f}",
|
|
242
|
+
".ocr-ok{color:var(--dsw-alias-label-primary,inherit)}",
|
|
243
|
+
".ocr-muted{color:var(--dsw-alias-label-tertiary,#8a8f99)}"
|
|
244
|
+
].join("\n");
|
|
245
|
+
function cardStore(scope) {
|
|
246
|
+
let cachedSnap = null;
|
|
247
|
+
let cachedView = null;
|
|
248
|
+
const EMPTY_SNAPSHOT = {
|
|
249
|
+
status: "loading",
|
|
250
|
+
writable: false,
|
|
251
|
+
value: {}
|
|
252
|
+
};
|
|
253
|
+
return {
|
|
254
|
+
getSnapshot() {
|
|
255
|
+
const snap = scope.getSnapshot();
|
|
256
|
+
if (snap !== cachedSnap) {
|
|
257
|
+
cachedSnap = snap;
|
|
258
|
+
cachedView = {
|
|
259
|
+
status: snap.status,
|
|
260
|
+
writable: snap.writable,
|
|
261
|
+
value: snap.value ?? {}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
return cachedView ?? EMPTY_SNAPSHOT;
|
|
265
|
+
},
|
|
266
|
+
subscribe(listener) {
|
|
267
|
+
return scope.subscribe(listener);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
/** touched 层与快照的差异字段(值语义比较;undefined 与缺失等价)。 */
|
|
272
|
+
function diffTouched(touched, value) {
|
|
273
|
+
const out = [];
|
|
274
|
+
for (const key of Object.keys(touched)) if (JSON.stringify(touched[key] ?? null) !== JSON.stringify(value[key] ?? null)) out.push(key);
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
function SaveBar(props) {
|
|
278
|
+
const { t } = props;
|
|
279
|
+
const disabled = !props.writable || props.busy;
|
|
280
|
+
const btnStyle = {
|
|
281
|
+
appearance: "auto",
|
|
282
|
+
font: "inherit",
|
|
283
|
+
fontSize: "12px",
|
|
284
|
+
cursor: disabled || !props.dirty ? "not-allowed" : "pointer",
|
|
285
|
+
borderRadius: "6px",
|
|
286
|
+
padding: "4px 12px",
|
|
287
|
+
border: "1px solid var(--dsw-alias-border-l2,#d8dbe2)",
|
|
288
|
+
background: "var(--dsw-alias-bg-layer-1,transparent)",
|
|
289
|
+
color: "var(--dsw-alias-label-primary,inherit)",
|
|
290
|
+
opacity: disabled ? "0.5" : "1"
|
|
291
|
+
};
|
|
292
|
+
const saveBtn = (0, react.createElement)("button", {
|
|
293
|
+
type: "button",
|
|
294
|
+
"data-field": "save",
|
|
295
|
+
disabled: disabled || !props.dirty,
|
|
296
|
+
onClick: props.onSave,
|
|
297
|
+
style: {
|
|
298
|
+
...btnStyle,
|
|
299
|
+
background: "var(--dsw-alias-brand-primary,#2f6fed)",
|
|
300
|
+
borderColor: "var(--dsw-alias-brand-primary,#2f6fed)",
|
|
301
|
+
color: "var(--dsw-alias-label-primary-foreground,#fff)"
|
|
302
|
+
}
|
|
303
|
+
}, props.busy ? t("saving") : t("save"));
|
|
304
|
+
const discardBtn = (0, react.createElement)("button", {
|
|
305
|
+
type: "button",
|
|
306
|
+
"data-field": "discard",
|
|
307
|
+
disabled: disabled || !props.dirty,
|
|
308
|
+
onClick: props.onDiscard,
|
|
309
|
+
style: btnStyle
|
|
310
|
+
}, t("revert"));
|
|
311
|
+
const dirtyText = props.dirty ? t("statusDirty") : t("statusClean");
|
|
312
|
+
let statusNode;
|
|
313
|
+
if (props.error !== null) statusNode = (0, react.createElement)("span", { style: {
|
|
314
|
+
fontSize: 12,
|
|
315
|
+
color: "#c4483f"
|
|
316
|
+
} }, props.error);
|
|
317
|
+
else if (props.writable) statusNode = (0, react.createElement)("span", { style: {
|
|
318
|
+
fontSize: 12,
|
|
319
|
+
color: "var(--dsw-alias-label-tertiary,#8a8f99)"
|
|
320
|
+
} }, dirtyText);
|
|
321
|
+
else statusNode = (0, react.createElement)("span", { style: {
|
|
322
|
+
fontSize: 12,
|
|
323
|
+
color: "var(--dsw-alias-label-tertiary,#8a8f99)"
|
|
324
|
+
} }, t("statusReadOnly"));
|
|
325
|
+
return (0, react.createElement)("div", { style: {
|
|
326
|
+
display: "flex",
|
|
327
|
+
gap: 8,
|
|
328
|
+
alignItems: "center",
|
|
329
|
+
padding: "10px 0 2px",
|
|
330
|
+
borderTop: "1px dashed var(--dsw-alias-border-l2,#d8dbe2)",
|
|
331
|
+
marginTop: 6,
|
|
332
|
+
flexWrap: "wrap"
|
|
333
|
+
} }, saveBtn, discardBtn, statusNode);
|
|
334
|
+
}
|
|
335
|
+
/** 设置卡外壳:复刻同款自研卡片(边框圆角卡片 + 可折叠 header + chevron)。 */
|
|
336
|
+
function OcrPluginCard(props) {
|
|
337
|
+
const { title, description, children } = props;
|
|
338
|
+
const [open, setOpen] = (0, react.useState)(false);
|
|
339
|
+
const pathKey = "d";
|
|
340
|
+
const headRow = (0, react.createElement)("span", { className: "ocr-card-head" }, (0, react.createElement)("span", { className: "ocr-card-name" }, title), (0, react.createElement)("span", { className: "ocr-card-desc" }, description));
|
|
341
|
+
const chevron = (0, react.createElement)("svg", {
|
|
342
|
+
width: 14,
|
|
343
|
+
height: 14,
|
|
344
|
+
viewBox: "0 0 14 14",
|
|
345
|
+
"aria-hidden": true,
|
|
346
|
+
className: `ocr-card-chevron${open ? " ocr-card-chevron-open" : ""}`
|
|
347
|
+
}, (0, react.createElement)("path", {
|
|
348
|
+
[pathKey]: "M3 5l4 4 4-4",
|
|
349
|
+
fill: "none",
|
|
350
|
+
stroke: "currentColor",
|
|
351
|
+
strokeWidth: 1.5,
|
|
352
|
+
strokeLinecap: "round",
|
|
353
|
+
strokeLinejoin: "round"
|
|
354
|
+
}));
|
|
355
|
+
const header = (0, react.createElement)("button", {
|
|
356
|
+
type: "button",
|
|
357
|
+
className: "ocr-card-header",
|
|
358
|
+
"aria-expanded": open,
|
|
359
|
+
onClick: () => {
|
|
360
|
+
setOpen(!open);
|
|
361
|
+
}
|
|
362
|
+
}, headRow, chevron);
|
|
363
|
+
const body = open ? (0, react.createElement)("div", { className: "ocr-card-body" }, children) : null;
|
|
364
|
+
return (0, react.createElement)("li", { className: `ocr-card${open ? " ocr-card-open" : ""}` }, header, body);
|
|
365
|
+
}
|
|
366
|
+
/** 整数数字输入行(保存条模式:合法输入上报草稿,不直写)。 */
|
|
367
|
+
function NumberRow(props) {
|
|
368
|
+
const { label, hint, value, min, max, disabled, onCommit } = props;
|
|
369
|
+
const [draft, setDraft] = (0, react.useState)(String(value));
|
|
370
|
+
(0, react.useEffect)(() => {
|
|
371
|
+
setDraft(String(value));
|
|
372
|
+
}, [value]);
|
|
373
|
+
const stage = () => {
|
|
374
|
+
const parsed = Number(draft);
|
|
375
|
+
if (Number.isInteger(parsed) && parsed >= min && (max === void 0 || parsed <= max)) onCommit(parsed);
|
|
376
|
+
};
|
|
377
|
+
return (0, react.createElement)("div", { className: "ocr-row" }, (0, react.createElement)("div", { style: {
|
|
378
|
+
minWidth: 0,
|
|
379
|
+
marginRight: 12
|
|
380
|
+
} }, (0, react.createElement)("div", {}, label), (0, react.createElement)("div", { className: "ocr-hint" }, hint)), (0, react.createElement)("input", {
|
|
381
|
+
type: "number",
|
|
382
|
+
"data-field": props.field,
|
|
383
|
+
value: draft,
|
|
384
|
+
min,
|
|
385
|
+
max,
|
|
386
|
+
disabled,
|
|
387
|
+
"aria-label": label,
|
|
388
|
+
onChange: (ev) => {
|
|
389
|
+
setDraft(ev.target.value);
|
|
390
|
+
stage();
|
|
391
|
+
},
|
|
392
|
+
onKeyDown: (ev) => {
|
|
393
|
+
if (ev.key === "Enter") {
|
|
394
|
+
ev.preventDefault();
|
|
395
|
+
stage();
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
className: FORM_CONTROL_CLASS
|
|
399
|
+
}));
|
|
400
|
+
}
|
|
401
|
+
/** 文本输入行(保存条模式:改动只上报草稿)。空串是合法取值(= 用宿主默认位置),
|
|
402
|
+
* 所以不像 NumberRow 那样要求「解析成功」才上报。 */
|
|
403
|
+
function TextRow(props) {
|
|
404
|
+
const { label, hint, value, placeholder, disabled, onCommit } = props;
|
|
405
|
+
const [draft, setDraft] = (0, react.useState)(value);
|
|
406
|
+
(0, react.useEffect)(() => {
|
|
407
|
+
setDraft(value);
|
|
408
|
+
}, [value]);
|
|
409
|
+
const stage = () => {
|
|
410
|
+
onCommit(draft.trim());
|
|
411
|
+
};
|
|
412
|
+
return (0, react.createElement)("div", { className: "ocr-row" }, (0, react.createElement)("div", { style: {
|
|
413
|
+
minWidth: 0,
|
|
414
|
+
marginRight: 12
|
|
415
|
+
} }, (0, react.createElement)("div", {}, label), (0, react.createElement)("div", { className: "ocr-hint" }, hint)), (0, react.createElement)("input", {
|
|
416
|
+
type: "text",
|
|
417
|
+
"data-field": props.field,
|
|
418
|
+
value: draft,
|
|
419
|
+
placeholder,
|
|
420
|
+
disabled,
|
|
421
|
+
"aria-label": label,
|
|
422
|
+
className: FORM_CONTROL_CLASS,
|
|
423
|
+
style: { width: "min(60%,320px)" },
|
|
424
|
+
onChange: (ev) => {
|
|
425
|
+
setDraft(ev.target.value);
|
|
426
|
+
stage();
|
|
427
|
+
},
|
|
428
|
+
onKeyDown: (ev) => {
|
|
429
|
+
if (ev.key === "Enter") {
|
|
430
|
+
ev.preventDefault();
|
|
431
|
+
stage();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}));
|
|
435
|
+
}
|
|
436
|
+
function SelectRow(props) {
|
|
437
|
+
const current = typeof props.current === "string" ? props.current : "";
|
|
438
|
+
const options = props.options.map((option) => (0, react.createElement)("option", {
|
|
439
|
+
key: option.value,
|
|
440
|
+
value: option.value,
|
|
441
|
+
disabled: option.disabled === true
|
|
442
|
+
}, option.label));
|
|
443
|
+
const labelBlock = (0, react.createElement)("div", { style: {
|
|
444
|
+
minWidth: 0,
|
|
445
|
+
marginRight: 12
|
|
446
|
+
} }, (0, react.createElement)("div", {}, props.label), (0, react.createElement)("div", { className: "ocr-hint" }, props.hint));
|
|
447
|
+
const select = (0, react.createElement)("select", {
|
|
448
|
+
className: FORM_CONTROL_CLASS,
|
|
449
|
+
"aria-label": props.label,
|
|
450
|
+
value: current,
|
|
451
|
+
disabled: props.disabled,
|
|
452
|
+
onChange: (ev) => {
|
|
453
|
+
props.onChange(props.field, ev.target.value);
|
|
454
|
+
}
|
|
455
|
+
}, options);
|
|
456
|
+
return (0, react.createElement)("div", { className: "ocr-row" }, labelBlock, select);
|
|
457
|
+
}
|
|
458
|
+
/** provider 下拉项文本(没有 key 的加后缀并被禁用)。提成模块级函数是为了不把
|
|
459
|
+
* 带插值的 t() 嵌进 createElement(unicorn/max-nested-calls 上限 3)。 */
|
|
460
|
+
function providerOptionLabel(t, provider) {
|
|
461
|
+
return `${provider.displayName}${provider.hasKey ? "" : t("noKeySuffix")}`;
|
|
462
|
+
}
|
|
463
|
+
/** 当前 OCR 配置那一行(官方 Translate 的 {name} 插值)。 */
|
|
464
|
+
function currentConfigText(t, current) {
|
|
465
|
+
return t("currentConfig", {
|
|
466
|
+
provider: current.provider,
|
|
467
|
+
modelPart: current.model === "" ? "" : t("modelPart", { model: current.model }),
|
|
468
|
+
keyPart: t(current.keyIsDynamic ? "dynamicKey" : "plainKeyWarning")
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
/** 数据齐(source.status === "ready")那一支:provider/model 两行 + 三个动作按钮 +
|
|
472
|
+
* 当前配置与 key 两行提示。拆出来是因为这一支画的是整块表单,与上面三条「一行提示」
|
|
473
|
+
* 的降级支本就不是同一件事。 */
|
|
474
|
+
function buildReadyProviderSection(deps, data) {
|
|
475
|
+
const { t } = deps;
|
|
476
|
+
const providerOptions = data.providers.map((provider) => ({
|
|
477
|
+
value: provider.name,
|
|
478
|
+
label: providerOptionLabel(t, provider),
|
|
479
|
+
disabled: !provider.hasKey
|
|
480
|
+
}));
|
|
481
|
+
const fallbackModelName = deps.effectiveModel === "" ? t("noModel") : deps.effectiveModel;
|
|
482
|
+
const modelOptions = (deps.modelOptions.length > 0 ? deps.modelOptions : [{
|
|
483
|
+
id: deps.effectiveModel,
|
|
484
|
+
name: fallbackModelName
|
|
485
|
+
}]).map((model) => ({
|
|
486
|
+
value: model.id,
|
|
487
|
+
label: model.name
|
|
488
|
+
}));
|
|
489
|
+
const providerRow = (0, react.createElement)(SelectRow, {
|
|
490
|
+
label: "Provider",
|
|
491
|
+
hint: t("providerHint"),
|
|
492
|
+
field: "ocrProvider",
|
|
493
|
+
current: deps.effectiveProvider,
|
|
494
|
+
disabled: data.providers.length === 0,
|
|
495
|
+
options: providerOptions,
|
|
496
|
+
onChange: (field, value) => {
|
|
497
|
+
deps.onPickProvider(value);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
const modelRow = (0, react.createElement)(SelectRow, {
|
|
501
|
+
label: "Model",
|
|
502
|
+
hint: deps.chosenProvider ? deps.chosenProvider.baseURL : "",
|
|
503
|
+
field: "ocrModel",
|
|
504
|
+
current: deps.effectiveModel,
|
|
505
|
+
disabled: !deps.chosenProvider || deps.modelOptions.length === 0,
|
|
506
|
+
options: modelOptions,
|
|
507
|
+
onChange: (field, value) => {
|
|
508
|
+
deps.onPickModel(value);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
const applyBtn = (0, react.createElement)("button", {
|
|
512
|
+
className: "ocr-btn ocr-btn-primary",
|
|
513
|
+
type: "button",
|
|
514
|
+
disabled: deps.busy,
|
|
515
|
+
onClick: deps.onApply,
|
|
516
|
+
"data-field": "apply"
|
|
517
|
+
}, deps.busy ? t("applying") : t("applyToOcr"));
|
|
518
|
+
const testBtn = (0, react.createElement)("button", {
|
|
519
|
+
className: "ocr-btn",
|
|
520
|
+
type: "button",
|
|
521
|
+
disabled: deps.busy,
|
|
522
|
+
onClick: deps.onTest,
|
|
523
|
+
"data-field": "test"
|
|
524
|
+
}, t("testConnection"));
|
|
525
|
+
const migrateBtn = (0, react.createElement)("button", {
|
|
526
|
+
className: "ocr-btn",
|
|
527
|
+
type: "button",
|
|
528
|
+
disabled: deps.busy,
|
|
529
|
+
onClick: deps.onMigrate,
|
|
530
|
+
"data-field": "migrate"
|
|
531
|
+
}, t("migrateKeys"));
|
|
532
|
+
const actionRow = (0, react.createElement)("div", {
|
|
533
|
+
className: "ocr-row",
|
|
534
|
+
style: {
|
|
535
|
+
justifyContent: "flex-start",
|
|
536
|
+
gap: 8
|
|
537
|
+
}
|
|
538
|
+
}, applyBtn, testBtn, migrateBtn);
|
|
539
|
+
const cur = data.current;
|
|
540
|
+
const currentHint = cur.provider === "" ? t("noProvider") : currentConfigText(t, cur);
|
|
541
|
+
const currentInfo = (0, react.createElement)("div", { className: "ocr-hint" }, currentHint);
|
|
542
|
+
const keyInfo = (0, react.createElement)("div", { className: "ocr-hint" }, deps.keyHint);
|
|
543
|
+
return (0, react.createElement)("div", {}, providerRow, modelRow, actionRow, currentInfo, keyInfo);
|
|
544
|
+
}
|
|
545
|
+
/** Provider / Model 选择与应用区:三条降级各出一行提示(数据未就绪/读取失败/
|
|
546
|
+
* 数据源不可用),只有 source.status === "ready" 才交给 buildReadyProviderSection 画表单。 */
|
|
547
|
+
function buildProviderSection(deps) {
|
|
548
|
+
const { t } = deps;
|
|
549
|
+
if (deps.fetchError !== null) {
|
|
550
|
+
const reason = t("loadProvidersFailed", { error: deps.fetchError });
|
|
551
|
+
return (0, react.createElement)("div", { className: "ocr-status ocr-err" }, reason);
|
|
552
|
+
}
|
|
553
|
+
if (deps.data === null) return (0, react.createElement)("div", { className: "ocr-status ocr-muted" }, t("loadingProviders"));
|
|
554
|
+
if (deps.data.source.status !== "ready") {
|
|
555
|
+
const sourceMessage = deps.data.source.message;
|
|
556
|
+
const line = t("readProvidersFailed", { reason: sourceMessage === "" ? t("noReason") : sourceMessage });
|
|
557
|
+
return (0, react.createElement)("div", { className: "ocr-status ocr-err" }, line);
|
|
558
|
+
}
|
|
559
|
+
return buildReadyProviderSection(deps, deps.data);
|
|
560
|
+
}
|
|
561
|
+
/** provider 预选:显式选择 > 当前 OCR 配置(仍存在)> 第一个有 key 的 provider。 */
|
|
562
|
+
function resolveBackend(providerList, current, selProvider) {
|
|
563
|
+
const currentStillExists = current !== void 0 && providerList.some((provider) => provider.name === current.provider);
|
|
564
|
+
const effectiveProvider = selProvider || (currentStillExists ? current.provider : "") || (providerList.find((provider) => provider.hasKey)?.name ?? "");
|
|
565
|
+
const chosenProvider = providerList.find((provider) => provider.name === effectiveProvider);
|
|
566
|
+
return {
|
|
567
|
+
effectiveProvider,
|
|
568
|
+
chosenProvider,
|
|
569
|
+
currentModelValid: currentStillExists && current.provider === chosenProvider?.name && typeof current.model === "string" && current.model.length > 0 && chosenProvider.models.some((model) => model.id === current.model)
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
/** model 预选:selModel 优先,其次当前配置的有效 model,最后首个 model。 */
|
|
573
|
+
function resolveModel(chosenProvider, current, currentModelValid, selModel) {
|
|
574
|
+
return {
|
|
575
|
+
effectiveModel: selModel || (currentModelValid && current !== void 0 ? current.model : chosenProvider?.models[0]?.id ?? ""),
|
|
576
|
+
modelOptions: chosenProvider?.models ?? []
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
function resolveSelection(providerList, current, selProvider, selModel) {
|
|
580
|
+
const backend = resolveBackend(providerList, current, selProvider);
|
|
581
|
+
const model = resolveModel(backend.chosenProvider, current, backend.currentModelValid, selModel);
|
|
582
|
+
return {
|
|
583
|
+
effectiveProvider: backend.effectiveProvider,
|
|
584
|
+
effectiveModel: model.effectiveModel,
|
|
585
|
+
chosenProvider: backend.chosenProvider,
|
|
586
|
+
modelOptions: model.modelOptions
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
/** 设置行(effort/language/autoVerify + 保存条)。 */
|
|
590
|
+
function buildSettingsSection(deps, writable, setField) {
|
|
591
|
+
const { t } = deps;
|
|
592
|
+
const effortRow = (0, react.createElement)(SelectRow, {
|
|
593
|
+
label: t("effortLabel"),
|
|
594
|
+
hint: t("effortHint"),
|
|
595
|
+
field: "effort",
|
|
596
|
+
current: deps.effortValue,
|
|
597
|
+
disabled: !writable,
|
|
598
|
+
options: [
|
|
599
|
+
{
|
|
600
|
+
value: "low",
|
|
601
|
+
label: t("effortLow")
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
value: "medium",
|
|
605
|
+
label: t("effortMedium")
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
value: "high",
|
|
609
|
+
label: t("effortHigh")
|
|
610
|
+
}
|
|
611
|
+
],
|
|
612
|
+
onChange: (field, draft) => {
|
|
613
|
+
setField(field, draft);
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
const languageRow = (0, react.createElement)(SelectRow, {
|
|
617
|
+
label: t("languageLabel"),
|
|
618
|
+
hint: t("languageHint"),
|
|
619
|
+
field: "language",
|
|
620
|
+
current: deps.languageValue,
|
|
621
|
+
disabled: !writable,
|
|
622
|
+
options: [{
|
|
623
|
+
value: "中文",
|
|
624
|
+
label: "中文"
|
|
625
|
+
}, {
|
|
626
|
+
value: "English",
|
|
627
|
+
label: "English"
|
|
628
|
+
}],
|
|
629
|
+
onChange: (field, draft) => {
|
|
630
|
+
setField(field, draft);
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
const verifyRow = (0, react.createElement)(SelectRow, {
|
|
634
|
+
label: t("autoVerifyLabel"),
|
|
635
|
+
hint: t("autoVerifyHint"),
|
|
636
|
+
field: "autoVerify",
|
|
637
|
+
current: deps.autoVerifyValue === true ? "on" : "off",
|
|
638
|
+
disabled: !writable,
|
|
639
|
+
options: [{
|
|
640
|
+
value: "on",
|
|
641
|
+
label: t("onLabel")
|
|
642
|
+
}, {
|
|
643
|
+
value: "off",
|
|
644
|
+
label: t("offLabel")
|
|
645
|
+
}],
|
|
646
|
+
onChange: (field, draft) => {
|
|
647
|
+
setField(field, draft === "on");
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
const maxCommentsRow = (0, react.createElement)(NumberRow, {
|
|
651
|
+
label: t("maxCommentsLabel"),
|
|
652
|
+
hint: t("maxCommentsHint"),
|
|
653
|
+
field: "maxComments",
|
|
654
|
+
value: deps.maxCommentsValue,
|
|
655
|
+
min: 0,
|
|
656
|
+
disabled: !writable,
|
|
657
|
+
onCommit: (num) => {
|
|
658
|
+
setField("maxComments", num);
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
const timeoutRow = (0, react.createElement)(NumberRow, {
|
|
662
|
+
label: t("timeoutLabel"),
|
|
663
|
+
hint: t("timeoutHint"),
|
|
664
|
+
field: "timeoutMinutes",
|
|
665
|
+
value: deps.timeoutMinutesValue,
|
|
666
|
+
min: 0,
|
|
667
|
+
disabled: !writable,
|
|
668
|
+
onCommit: (num) => {
|
|
669
|
+
setField("timeoutMinutes", num);
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
const configPathRow = (0, react.createElement)(TextRow, {
|
|
673
|
+
label: t("configPathLabel"),
|
|
674
|
+
hint: t("configPathHint"),
|
|
675
|
+
field: "ocrConfigPath",
|
|
676
|
+
value: deps.ocrConfigPathValue,
|
|
677
|
+
placeholder: "~/.opencodereview/config.json",
|
|
678
|
+
disabled: !writable,
|
|
679
|
+
onCommit: (text) => {
|
|
680
|
+
setField("ocrConfigPath", text);
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
const saveRow = (0, react.createElement)(SaveBar, {
|
|
684
|
+
t,
|
|
685
|
+
dirty: deps.dirty,
|
|
686
|
+
writable,
|
|
687
|
+
busy: deps.settingsBusy,
|
|
688
|
+
error: deps.saveError,
|
|
689
|
+
onSave: deps.onSave,
|
|
690
|
+
onDiscard: deps.onDiscard
|
|
691
|
+
});
|
|
692
|
+
const statusHintNode = deps.statusHint !== null && deps.statusHint !== "" ? (0, react.createElement)("div", { className: "ocr-hint" }, deps.statusHint) : null;
|
|
693
|
+
return (0, react.createElement)("div", { style: { padding: "2px 0" } }, statusHintNode, effortRow, languageRow, verifyRow, maxCommentsRow, timeoutRow, configPathRow, saveRow);
|
|
694
|
+
}
|
|
695
|
+
/** 读 providers 端点(卡片唯一的 GET):失败只落 fetchError 文本,不抛穿渲染。 */
|
|
696
|
+
async function refreshProviders(sinks) {
|
|
697
|
+
sinks.setFetchError(null);
|
|
698
|
+
try {
|
|
699
|
+
const res = await fetch(PROVIDERS_URL, { headers: { accept: "application/json" } });
|
|
700
|
+
if (!res.ok) {
|
|
701
|
+
const detail = strOf(fieldOf(await res.json().catch(() => ({})), "error"));
|
|
702
|
+
throw new Error(detail === "" ? `HTTP ${res.status}` : detail);
|
|
703
|
+
}
|
|
704
|
+
const payload = providersPayloadOf(await res.json());
|
|
705
|
+
sinks.setData(payload);
|
|
706
|
+
return payload;
|
|
707
|
+
} catch (error) {
|
|
708
|
+
sinks.setFetchError(String(error instanceof Error ? error.message : error));
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* 写端点统一出口:回填宿主下发的 x-ocr-csrf token(宿主只接受带 token 的
|
|
714
|
+
* POST,sec-fetch-site 挡不住本地进程)。宿主每次 apply 会换 token,页面开着
|
|
715
|
+
* 时它可能已过期 → 403 就重取 token 重试一次,避免卡片变成「点什么都没反应」。
|
|
716
|
+
*/
|
|
717
|
+
async function postWithCsrf(url, body, deps) {
|
|
718
|
+
const request = (token) => fetch(url, {
|
|
719
|
+
method: "POST",
|
|
720
|
+
headers: {
|
|
721
|
+
[CSRF_HEADER]: token,
|
|
722
|
+
...body === void 0 ? {} : { "content-type": "application/json" }
|
|
723
|
+
},
|
|
724
|
+
...body === void 0 ? {} : { body }
|
|
725
|
+
});
|
|
726
|
+
const first = await request(deps.csrf);
|
|
727
|
+
if (first.status !== 403) return first;
|
|
728
|
+
return await request((await deps.refresh())?.csrf ?? "");
|
|
729
|
+
}
|
|
730
|
+
/** POST select:把选中的 provider/model 写进 OCR 配置,回执里按 autoVerify 决定是否带 llm test。 */
|
|
731
|
+
async function runApplySelection(deps) {
|
|
732
|
+
const { t, effectiveProvider, effectiveModel } = deps;
|
|
733
|
+
if (!effectiveProvider || !effectiveModel) {
|
|
734
|
+
deps.setActionMsg({
|
|
735
|
+
ok: false,
|
|
736
|
+
text: t("pickProviderModel")
|
|
737
|
+
});
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
deps.setBusy(true);
|
|
741
|
+
deps.setActionMsg(null);
|
|
742
|
+
try {
|
|
743
|
+
const selJson = await (await deps.post(SELECT_URL, JSON.stringify({
|
|
744
|
+
provider: effectiveProvider,
|
|
745
|
+
model: effectiveModel
|
|
746
|
+
}))).json();
|
|
747
|
+
if (fieldOf(selJson, "ok") === true) {
|
|
748
|
+
let testText;
|
|
749
|
+
const llmTestRaw = fieldOf(selJson, "llmTest");
|
|
750
|
+
if (isRecord(llmTestRaw)) {
|
|
751
|
+
const testOutput = strOf(fieldOf(llmTestRaw, "output")).slice(0, 400);
|
|
752
|
+
testText = fieldOf(llmTestRaw, "ok") === true ? t("llmTestPassed") : `${t("llmTestFailed")}${testOutput}`;
|
|
753
|
+
} else testText = t("notVerified");
|
|
754
|
+
const appliedText = t("applied", {
|
|
755
|
+
provider: effectiveProvider,
|
|
756
|
+
model: effectiveModel,
|
|
757
|
+
test: testText
|
|
758
|
+
});
|
|
759
|
+
deps.setActionMsg({
|
|
760
|
+
ok: true,
|
|
761
|
+
text: appliedText
|
|
762
|
+
});
|
|
763
|
+
deps.refresh();
|
|
764
|
+
} else {
|
|
765
|
+
const failure = strOf(fieldOf(selJson, "error"));
|
|
766
|
+
deps.setActionMsg({
|
|
767
|
+
ok: false,
|
|
768
|
+
text: failure === "" ? t("applyFailed") : failure
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
} catch (error) {
|
|
772
|
+
const failedText = `${t("requestFailed")}${errorSummary(error)}`;
|
|
773
|
+
deps.setActionMsg({
|
|
774
|
+
ok: false,
|
|
775
|
+
text: failedText
|
|
776
|
+
});
|
|
777
|
+
} finally {
|
|
778
|
+
deps.setBusy(false);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
/** POST migrate:明文 api_key 一键迁到 api_key_cmd,回执把迁移/跳过两个名单拼成一行。 */
|
|
782
|
+
async function runMigrateKeys(deps) {
|
|
783
|
+
const { t } = deps;
|
|
784
|
+
deps.setBusy(true);
|
|
785
|
+
deps.setActionMsg(null);
|
|
786
|
+
try {
|
|
787
|
+
const json = await (await deps.post(MIGRATE_URL)).json();
|
|
788
|
+
const migratedVal = fieldOf(json, "migrated");
|
|
789
|
+
const skippedVal = fieldOf(json, "skippedUnknown");
|
|
790
|
+
const migrated = Array.isArray(migratedVal) ? migratedVal.filter((item) => typeof item === "string") : [];
|
|
791
|
+
const skipped = Array.isArray(skippedVal) ? skippedVal.filter((item) => typeof item === "string") : [];
|
|
792
|
+
const ok = fieldOf(json, "ok") === true;
|
|
793
|
+
const head = ok ? (migrated.length > 0 ? t("migratedPrefix") + migrated.join(", ") : t("noPlainKey")) + (skipped.length > 0 ? t("skippedPrefix") + skipped.join(", ") : "") : t("migrateFailed");
|
|
794
|
+
deps.setActionMsg({
|
|
795
|
+
ok,
|
|
796
|
+
text: head
|
|
797
|
+
});
|
|
798
|
+
deps.refresh();
|
|
799
|
+
} catch (error) {
|
|
800
|
+
const failedText = `${t("requestFailed")}${errorSummary(error)}`;
|
|
801
|
+
deps.setActionMsg({
|
|
802
|
+
ok: false,
|
|
803
|
+
text: failedText
|
|
804
|
+
});
|
|
805
|
+
} finally {
|
|
806
|
+
deps.setBusy(false);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
/** POST test:手动触发 ocr llm test,输出原样回显在动作条上。 */
|
|
810
|
+
async function runTestConnection(deps) {
|
|
811
|
+
const { t } = deps;
|
|
812
|
+
deps.setBusy(true);
|
|
813
|
+
deps.setActionMsg(null);
|
|
814
|
+
try {
|
|
815
|
+
const json = await (await deps.post(TEST_URL)).json();
|
|
816
|
+
const ok = fieldOf(json, "ok") === true;
|
|
817
|
+
const testOutput = strOf(fieldOf(json, "output"));
|
|
818
|
+
deps.setActionMsg({
|
|
819
|
+
ok,
|
|
820
|
+
text: (ok ? t("llmTestPassed") : t("llmTestFailed")) + testOutput
|
|
821
|
+
});
|
|
822
|
+
} catch (error) {
|
|
823
|
+
const failedText = `${t("requestFailed")}${errorSummary(error)}`;
|
|
824
|
+
deps.setActionMsg({
|
|
825
|
+
ok: false,
|
|
826
|
+
text: failedText
|
|
827
|
+
});
|
|
828
|
+
} finally {
|
|
829
|
+
deps.setBusy(false);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
/** 只写差异字段:touched 值为 undefined → 写 null(= 清回 schema 默认)。 */
|
|
833
|
+
async function saveSettingsDraft(deps) {
|
|
834
|
+
const { t, value, touched } = deps;
|
|
835
|
+
const keys = diffTouched(touched, value);
|
|
836
|
+
if (keys.length === 0) return;
|
|
837
|
+
deps.setSettingsBusy(true);
|
|
838
|
+
deps.setSaveError(null);
|
|
839
|
+
const ops = keys.map(async (field) => {
|
|
840
|
+
const draft = touched[field];
|
|
841
|
+
await deps.set(field, draft === void 0 ? null : draft);
|
|
842
|
+
});
|
|
843
|
+
try {
|
|
844
|
+
await Promise.all(ops);
|
|
845
|
+
deps.setSettingsBusy(false);
|
|
846
|
+
deps.setTouched({});
|
|
847
|
+
} catch (error) {
|
|
848
|
+
deps.setSettingsBusy(false);
|
|
849
|
+
deps.setSaveError(`${t("saveFailed")}${errorSummary(error)}`);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
function draftFieldsOf(touched, value) {
|
|
853
|
+
const eff = (field, def) => field in touched ? touched[field] : value[field] ?? def;
|
|
854
|
+
return {
|
|
855
|
+
effortValue: eff("effort", "medium"),
|
|
856
|
+
languageValue: eff("language", "中文"),
|
|
857
|
+
autoVerifyValue: eff("autoVerify", true),
|
|
858
|
+
maxCommentsValue: numOf(eff("maxComments", 12), 12),
|
|
859
|
+
timeoutMinutesValue: numOf(eff("timeoutMinutes", 0), 0),
|
|
860
|
+
ocrConfigPathValue: strOf(eff("ocrConfigPath", ""))
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
function cardHints(t, ready, status, data) {
|
|
864
|
+
let statusHint;
|
|
865
|
+
if (ready) statusHint = null;
|
|
866
|
+
else statusHint = status === "loading" ? t("settingsLoading") : null;
|
|
867
|
+
let keyHint;
|
|
868
|
+
if (data) keyHint = data.getCredReady ? t("keyDynamicHint") : t("keyScriptMissing");
|
|
869
|
+
else keyHint = "";
|
|
870
|
+
return {
|
|
871
|
+
keyHint,
|
|
872
|
+
statusHint
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
/** Provider 区 = provider/model 表单 + 最近一次动作的回执条。 */
|
|
876
|
+
function buildProviderPanel(deps) {
|
|
877
|
+
const { actionMsg } = deps;
|
|
878
|
+
const providerSection = buildProviderSection(deps);
|
|
879
|
+
const actionMsgNode = actionMsg ? (0, react.createElement)("div", { className: `ocr-status ${actionMsg.ok ? "ocr-ok" : "ocr-err"}` }, actionMsg.text) : null;
|
|
880
|
+
return (0, react.createElement)("div", { style: { padding: "2px 0" } }, providerSection, actionMsgNode);
|
|
881
|
+
}
|
|
882
|
+
function OcrCard(props) {
|
|
883
|
+
const { t } = props;
|
|
884
|
+
const snap = props.useCard((state) => state);
|
|
885
|
+
const { value } = snap;
|
|
886
|
+
const ready = snap.status === "ready";
|
|
887
|
+
const writable = snap.writable && ready;
|
|
888
|
+
const [data, setData] = (0, react.useState)(null);
|
|
889
|
+
const [fetchError, setFetchError] = (0, react.useState)(null);
|
|
890
|
+
const [busy, setBusy] = (0, react.useState)(false);
|
|
891
|
+
const [actionMsg, setActionMsg] = (0, react.useState)(null);
|
|
892
|
+
const refresh = async () => refreshProviders({
|
|
893
|
+
setData,
|
|
894
|
+
setFetchError
|
|
895
|
+
});
|
|
896
|
+
(0, react.useEffect)(() => {
|
|
897
|
+
refresh();
|
|
898
|
+
}, []);
|
|
899
|
+
const post = async (url, body) => postWithCsrf(url, body, {
|
|
900
|
+
csrf: data?.csrf ?? "",
|
|
901
|
+
refresh
|
|
902
|
+
});
|
|
903
|
+
const [selProvider, setSelProvider] = (0, react.useState)("");
|
|
904
|
+
const [selModel, setSelModel] = (0, react.useState)("");
|
|
905
|
+
const { effectiveProvider, effectiveModel, chosenProvider, modelOptions } = resolveSelection(data?.providers ?? [], data?.current, selProvider, selModel);
|
|
906
|
+
const providerList = data?.providers ?? [];
|
|
907
|
+
function pickProvider(name) {
|
|
908
|
+
setSelProvider(name);
|
|
909
|
+
const first = providerList.find((entry) => entry.name === name)?.models[0]?.id ?? "";
|
|
910
|
+
setSelModel(first);
|
|
911
|
+
}
|
|
912
|
+
const actions = {
|
|
913
|
+
t,
|
|
914
|
+
post,
|
|
915
|
+
refresh,
|
|
916
|
+
setBusy,
|
|
917
|
+
setActionMsg,
|
|
918
|
+
effectiveProvider,
|
|
919
|
+
effectiveModel
|
|
920
|
+
};
|
|
921
|
+
const [touched, setTouched] = (0, react.useState)({});
|
|
922
|
+
const [settingsBusy, setSettingsBusy] = (0, react.useState)(false);
|
|
923
|
+
const [saveError, setSaveError] = (0, react.useState)(null);
|
|
924
|
+
const dirty = diffTouched(touched, value).length > 0;
|
|
925
|
+
const setField = (field, draft) => {
|
|
926
|
+
setTouched((prev) => ({
|
|
927
|
+
...prev,
|
|
928
|
+
[field]: draft
|
|
929
|
+
}));
|
|
930
|
+
};
|
|
931
|
+
const discard = () => {
|
|
932
|
+
setTouched({});
|
|
933
|
+
setSaveError(null);
|
|
934
|
+
};
|
|
935
|
+
const deps = {
|
|
936
|
+
t,
|
|
937
|
+
data,
|
|
938
|
+
fetchError,
|
|
939
|
+
busy,
|
|
940
|
+
actionMsg,
|
|
941
|
+
ready,
|
|
942
|
+
writable,
|
|
943
|
+
effectiveProvider,
|
|
944
|
+
effectiveModel,
|
|
945
|
+
chosenProvider,
|
|
946
|
+
modelOptions,
|
|
947
|
+
...cardHints(t, ready, snap.status, data),
|
|
948
|
+
...draftFieldsOf(touched, value),
|
|
949
|
+
dirty,
|
|
950
|
+
settingsBusy,
|
|
951
|
+
saveError,
|
|
952
|
+
onPickProvider: pickProvider,
|
|
953
|
+
onPickModel: setSelModel,
|
|
954
|
+
onApply: () => {
|
|
955
|
+
runApplySelection(actions);
|
|
956
|
+
},
|
|
957
|
+
onTest: () => {
|
|
958
|
+
runTestConnection(actions);
|
|
959
|
+
},
|
|
960
|
+
onMigrate: () => {
|
|
961
|
+
runMigrateKeys(actions);
|
|
962
|
+
},
|
|
963
|
+
onField: setField,
|
|
964
|
+
onSave: () => {
|
|
965
|
+
saveSettingsDraft({
|
|
966
|
+
t,
|
|
967
|
+
value,
|
|
968
|
+
touched,
|
|
969
|
+
setTouched,
|
|
970
|
+
setSettingsBusy,
|
|
971
|
+
setSaveError,
|
|
972
|
+
set: props.set
|
|
973
|
+
});
|
|
974
|
+
},
|
|
975
|
+
onDiscard: discard
|
|
976
|
+
};
|
|
977
|
+
const providerPanel = buildProviderPanel(deps);
|
|
978
|
+
const settingsSection = buildSettingsSection(deps, writable, setField);
|
|
979
|
+
const headTitle = t("cardTitle");
|
|
980
|
+
const headDesc = t("cardDescription");
|
|
981
|
+
return (0, react.createElement)(OcrPluginCard, {
|
|
982
|
+
title: headTitle,
|
|
983
|
+
description: headDesc
|
|
984
|
+
}, providerPanel, settingsSection);
|
|
985
|
+
}
|
|
986
|
+
const inject = [
|
|
987
|
+
"slots",
|
|
988
|
+
"configForms",
|
|
989
|
+
"locale"
|
|
990
|
+
];
|
|
991
|
+
function apply(ctx) {
|
|
992
|
+
if (globalThis.ocrReviewCardApplied === true) return;
|
|
993
|
+
globalThis.ocrReviewCardApplied = true;
|
|
994
|
+
ctx.effect(() => () => {
|
|
995
|
+
globalThis.ocrReviewCardApplied = void 0;
|
|
996
|
+
}, "ocr-review-card: apply claim");
|
|
997
|
+
ctx.effect(() => {
|
|
998
|
+
const tag = document.createElement("style");
|
|
999
|
+
tag.id = "ocr-review-card-css";
|
|
1000
|
+
tag.textContent = CARD_CSS;
|
|
1001
|
+
document.head.append(tag);
|
|
1002
|
+
return () => {
|
|
1003
|
+
tag.remove();
|
|
1004
|
+
};
|
|
1005
|
+
}, "ocr-review-card: styles");
|
|
1006
|
+
const scope = ctx.configForms.get(NS);
|
|
1007
|
+
const store = cardStore(scope);
|
|
1008
|
+
ctx.effect(() => ctx.locale.register(NS, UI_MESSAGES), "ocr-review-card: locale dictionaries");
|
|
1009
|
+
const t = ctx.locale.bind(NS);
|
|
1010
|
+
ctx.slots.inject("plugins.bundle.config", () => {
|
|
1011
|
+
return ctx.slots.register({
|
|
1012
|
+
name: "plugins.bundle.config",
|
|
1013
|
+
key: BUNDLE_PKG,
|
|
1014
|
+
inject: () => ({
|
|
1015
|
+
t,
|
|
1016
|
+
hooks: { card: store },
|
|
1017
|
+
set: async (field, value) => {
|
|
1018
|
+
try {
|
|
1019
|
+
await scope.set(field, value);
|
|
1020
|
+
} catch (error) {
|
|
1021
|
+
console.error(`[ocr-review-card] set ${field} failed:`, error);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
})
|
|
1025
|
+
}, OcrCard);
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
//#endregion
|
|
1029
|
+
exports.OcrCard = OcrCard;
|
|
1030
|
+
exports.SaveBar = SaveBar;
|
|
1031
|
+
exports.apply = apply;
|
|
1032
|
+
exports.buildProviderSection = buildProviderSection;
|
|
1033
|
+
exports.buildSettingsSection = buildSettingsSection;
|
|
1034
|
+
exports.cardStore = cardStore;
|
|
1035
|
+
exports.currentConfigText = currentConfigText;
|
|
1036
|
+
exports.diffTouched = diffTouched;
|
|
1037
|
+
exports.inject = inject;
|
|
1038
|
+
exports.providerOptionLabel = providerOptionLabel;
|
|
1039
|
+
return module.exports;
|
|
1040
|
+
},
|
|
1041
|
+
});
|