@thincoder/core 0.10.4 → 0.10.6
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/CHANGELOG.md +39 -1
- package/README.md +1 -1
- package/advisor/compaction.mjs +11 -6
- package/advisor/run.mjs +4 -4
- package/agent/assemble.mjs +1 -1
- package/agent/chat-call.mjs +1 -1
- package/agent/child-marks.mjs +2 -2
- package/agent/digest-account.mjs +5 -4
- package/agent/dispatch-gates.mjs +8 -0
- package/agent/dispatch.mjs +39 -18
- package/agent/family-tools.mjs +22 -11
- package/agent/helpers.mjs +4 -3
- package/agent/light-round.mjs +66 -0
- package/agent/post-turn.mjs +8 -4
- package/agent/run-stages.mjs +5 -3
- package/agent/run-start.mjs +3 -2
- package/agent/setup-reminders.mjs +1 -1
- package/agent/setup.mjs +18 -3
- package/agent/subagent-event.mjs +1 -1
- package/agent/suspension.mjs +8 -5
- package/agent/timers.mjs +56 -5
- package/agent/turn-loop.mjs +5 -3
- package/agent/write-gate.mjs +7 -7
- package/agent-tools/advisor-async.mjs +2 -2
- package/agent-tools/advisor-settle.mjs +2 -2
- package/agent-tools/advisor.mjs +1 -1
- package/agent-tools/async-discard.mjs +34 -0
- package/agent-tools/async-settle.mjs +3 -2
- package/agent-tools/audit-block.mjs +1 -1
- package/agent-tools/bash-async.mjs +1 -1
- package/agent-tools/batch-lifecycle.mjs +35 -14
- package/agent-tools/batch-paths.mjs +4 -4
- package/agent-tools/batch-skeleton.mjs +29 -11
- package/agent-tools/batch.mjs +28 -15
- package/agent-tools/browser-async.mjs +198 -0
- package/agent-tools/checkpoint.mjs +1 -1
- package/agent-tools/design-slots.mjs +232 -0
- package/agent-tools/digest-budget.mjs +1 -1
- package/agent-tools/manifest.mjs +193 -0
- package/agent-tools/parent-channel.mjs +7 -4
- package/agent-tools/review-facts.mjs +13 -13
- package/agent-tools/settings.mjs +1 -1
- package/agent-tools/skill.mjs +1 -1
- package/agent-tools/subagent-async.mjs +21 -12
- package/agent-tools/subagent-scheduler.mjs +8 -5
- package/agent-tools/subagent-spawn.mjs +29 -55
- package/agent-tools/subagent.mjs +21 -8
- package/agent-tools/timer.mjs +186 -12
- package/agent-tools/zero-write-watch.mjs +2 -2
- package/agent-tools.mjs +2 -1
- package/agent.mjs +1 -1
- package/attachments.mjs +1 -1
- package/browser/actions.mjs +163 -0
- package/browser/cdp.mjs +161 -0
- package/browser/clipboard.mjs +142 -0
- package/browser/input-actions.mjs +197 -0
- package/browser/input.mjs +256 -0
- package/browser/launch.mjs +143 -0
- package/browser/queue.mjs +171 -0
- package/browser/session.mjs +494 -0
- package/browser/snapshot.mjs +270 -0
- package/config-io.mjs +18 -13
- package/config-migrate.mjs +13 -29
- package/config-presets.mjs +33 -29
- package/config.mjs +68 -25
- package/context-degrade.mjs +1 -1
- package/context-echo.mjs +1 -1
- package/context-push.mjs +2 -2
- package/context.mjs +1 -1
- package/conventions.mjs +2 -2
- package/declaration.mjs +18 -2
- package/file-links.mjs +1 -1
- package/file-refs.mjs +1 -1
- package/history-window.mjs +1 -1
- package/i18n.mjs +2 -0
- package/ledger-cmd.mjs +62 -16
- package/ledger-db.mjs +6 -0
- package/ledger-surface.mjs +49 -5
- package/ledger-tools.mjs +16 -8
- package/ledger.mjs +9 -16
- package/log.mjs +1 -1
- package/manifest-discovery.mjs +1 -1
- package/manifest-schema.mjs +1 -1
- package/mcp/transport-stdio.mjs +1 -1
- package/mcp.mjs +1 -1
- package/memory/code-index.mjs +1 -1
- package/memory/code-search.mjs +1 -1
- package/memory/code-sync.mjs +1 -1
- package/memory/docs.mjs +6 -2
- package/memory/file-list.mjs +1 -1
- package/memory/file-walk.mjs +17 -5
- package/memory/maintain.mjs +410 -0
- package/memory/memory-tool.mjs +21 -6
- package/memory/schema.mjs +19 -7
- package/memory/sweep.mjs +8 -5
- package/memory/sync-tail.mjs +16 -10
- package/model-ref.mjs +16 -13
- package/model-specs.mjs +21 -9
- package/package.json +2 -1
- package/peer-instances.mjs +1 -1
- package/permission.mjs +33 -0
- package/process-probe.mjs +2 -2
- package/prompts/advisor-design.md +4 -2
- package/prompts/advisor-round1.md +1 -1
- package/prompts/advisor-round2.md +1 -1
- package/prompts/advisor-round3.md +1 -1
- package/prompts/common.md +36 -2
- package/prompts/consult-base.md +1 -1
- package/prompts/discipline-engineering.md +32 -15
- package/prompts/discipline-normal.md +46 -2
- package/prompts/persona-eng-designer.md +6 -6
- package/prompts/persona-engineering.md +14 -6
- package/prompts/persona-normal.md +2 -0
- package/prompts/subagent-base.md +3 -0
- package/provider/core.mjs +6 -0
- package/provider/errors.mjs +5 -3
- package/provider/google.mjs +9 -8
- package/provider/list-models.mjs +3 -2
- package/provider/sse.mjs +3 -0
- package/provider-flows.mjs +14 -15
- package/proxy-chunked.mjs +102 -0
- package/proxy-target.mjs +54 -0
- package/proxy-transport.mjs +298 -0
- package/proxy.mjs +17 -248
- package/session-gc.mjs +2 -2
- package/session-lifecycle.mjs +18 -14
- package/session-slot-claims.mjs +2 -2
- package/session-slots-manifest.mjs +1 -1
- package/session-slots.mjs +3 -3
- package/team-ledger.mjs +282 -0
- package/team.mjs +223 -0
- package/tool-docs/bash.md +1 -0
- package/tool-docs/batch.md +1 -1
- package/tool-docs/browser.md +48 -0
- package/tool-docs/ledger.md +3 -3
- package/tool-docs/manifest.md +16 -0
- package/tool-docs/memory.md +1 -0
- package/tool-docs/notify_parent.md +5 -0
- package/tool-docs/process.md +4 -4
- package/tool-docs/question.md +1 -0
- package/tool-docs/subagent.md +4 -3
- package/tool-docs/team_ledger.md +9 -0
- package/tool-docs/timer.md +6 -0
- package/tool-docs/wait_for.md +1 -0
- package/tools/browser.mjs +161 -0
- package/tools/git.mjs +1 -1
- package/tools/index.mjs +3 -0
- package/tools/ops.mjs +26 -10
- package/tools/repomap.mjs +1 -1
- package/tools/write-path.mjs +2 -2
- package/traces/trace-cleanup.mjs +1 -1
- package/traces/trace-store.mjs +1 -1
- package/vision-reader.mjs +10 -16
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser/snapshot.mjs — 页面侧快照脚本 ∥ 几何 ∥ 点 / 聚焦表达式 ∥ 结果归一 ∥ 引用表 ∥ 紧凑渲染 ∥ 页摘
|
|
3
|
+
* (BROWSER-TOOL.md §2.3 ∥ §2.7)。
|
|
4
|
+
*
|
|
5
|
+
* 页面侧脚本一律经 `Runtime.evaluate` **一次性执行、不驻留**(§3.3);每条表达式带一个块注释标识
|
|
6
|
+
* 前缀(`thincoder-browser:<种类>`)——回执/调试可辨出处,单元测试假传输按此前缀派发。
|
|
7
|
+
* 引用表:`key = tag + "|" + selector`,跨快照复用原号(KD-1 / F-BT4「微变不全失效」)。
|
|
8
|
+
* 几何(F-BT14):每条记录携 `geo`(视口 CSS 像素)与 `inViewport`;`[outside]` 仅标视口外行(KD-15)。
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_MAX = 100
|
|
11
|
+
export const MAX_ELEMENTS = 200
|
|
12
|
+
export const MAX_SNAPSHOT_CHARS = 20_000
|
|
13
|
+
export const COMPACT_ROWS = 30 // 失败回执的紧凑清单行数
|
|
14
|
+
|
|
15
|
+
const mark = (kind) => `/*thincoder-browser:${kind}*/`
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 页面侧快照函数源串(`(opts) => 记录集`)。
|
|
19
|
+
* 走交互元素(§2.3):`a[href]` / `button` / `input`(非 hidden) / `select` / `textarea` /
|
|
20
|
+
* `[role=button]` / `[onclick]` / `[tabindex]`;`selector` = 稳定寻址链(`#id` → `[data-testid]`
|
|
21
|
+
* → `[name]` → `[href]` → 结构路径);password 输入**不取 value**(N-BT4)。
|
|
22
|
+
* 几何随记录(§2.7):`geo = {x,y,w,h}` 取整 + `inViewport`(非零面积 ∩ 视口)。
|
|
23
|
+
*/
|
|
24
|
+
export const SNAPSHOT_EXPR = String.raw`function (opts) {
|
|
25
|
+
var SEL = 'a[href],button,input:not([type=hidden]),select,textarea,[role=button],[onclick],[tabindex]'
|
|
26
|
+
var norm = function (s) { return String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, 60) }
|
|
27
|
+
var esc = function (s) {
|
|
28
|
+
try { return (globalThis.CSS && CSS.escape) ? CSS.escape(String(s)) : String(s).replace(/[^a-zA-Z0-9_-]/g, function (c) { return '\\' + c }) }
|
|
29
|
+
catch (e) { return String(s) }
|
|
30
|
+
}
|
|
31
|
+
var unique = function (sel) { try { return document.querySelectorAll(sel).length === 1 } catch (e) { return false } }
|
|
32
|
+
var pathOf = function (el) {
|
|
33
|
+
var parts = []
|
|
34
|
+
var cur = el
|
|
35
|
+
while (cur && cur.nodeType === 1 && cur !== document.documentElement) {
|
|
36
|
+
if (cur.id && unique('#' + esc(cur.id))) { parts.unshift('#' + esc(cur.id)); break }
|
|
37
|
+
var tag = cur.tagName.toLowerCase()
|
|
38
|
+
var parent = cur.parentElement
|
|
39
|
+
if (!parent) { parts.unshift(tag); break }
|
|
40
|
+
var same = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === cur.tagName })
|
|
41
|
+
parts.unshift(same.length > 1 ? tag + ':nth-of-type(' + (same.indexOf(cur) + 1) + ')' : tag)
|
|
42
|
+
cur = parent
|
|
43
|
+
}
|
|
44
|
+
return parts.join(' > ')
|
|
45
|
+
}
|
|
46
|
+
var selectorOf = function (el) {
|
|
47
|
+
if (el.id && unique('#' + esc(el.id))) return '#' + esc(el.id)
|
|
48
|
+
var testid = el.getAttribute('data-testid')
|
|
49
|
+
if (testid && unique('[data-testid="' + testid + '"]')) return '[data-testid="' + testid + '"]'
|
|
50
|
+
var name = el.getAttribute('name')
|
|
51
|
+
if (name && unique('[name="' + name + '"]')) return '[name="' + name + '"]'
|
|
52
|
+
var href = el.getAttribute('href')
|
|
53
|
+
if (href && el.tagName === 'A' && unique('[href="' + href + '"]')) return '[href="' + href + '"]'
|
|
54
|
+
return pathOf(el)
|
|
55
|
+
}
|
|
56
|
+
var roleOf = function (el, tag) {
|
|
57
|
+
var t = String(el.getAttribute('type') || '').toLowerCase()
|
|
58
|
+
if (tag === 'a') return 'link'
|
|
59
|
+
if (tag === 'select') return 'select'
|
|
60
|
+
if (tag === 'textarea') return 'textbox'
|
|
61
|
+
if (tag === 'button') return 'button'
|
|
62
|
+
if (tag === 'input') {
|
|
63
|
+
if (t === 'password') return 'password'
|
|
64
|
+
if (t === 'checkbox') return 'checkbox'
|
|
65
|
+
if (t === 'radio') return 'radio'
|
|
66
|
+
if (t === 'submit' || t === 'button' || t === 'reset' || t === 'image') return 'button'
|
|
67
|
+
return 'textbox'
|
|
68
|
+
}
|
|
69
|
+
if (el.getAttribute('role') === 'button' || el.hasAttribute('onclick')) return 'button'
|
|
70
|
+
return 'other'
|
|
71
|
+
}
|
|
72
|
+
var nameOf = function (el) {
|
|
73
|
+
// 可及名取序照 §2.3:aria-label ∥ 文本 ∥ placeholder ∥ title
|
|
74
|
+
return norm(el.getAttribute('aria-label') || el.innerText || el.textContent || el.getAttribute('placeholder') || el.getAttribute('title') || '')
|
|
75
|
+
}
|
|
76
|
+
var rectOf = function (el) {
|
|
77
|
+
var r = el.getBoundingClientRect()
|
|
78
|
+
return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) }
|
|
79
|
+
}
|
|
80
|
+
var inViewportOf = function (g) {
|
|
81
|
+
return g.w > 0 && g.h > 0 && g.x < innerWidth && g.y < innerHeight && g.x + g.w > 0 && g.y + g.h > 0
|
|
82
|
+
}
|
|
83
|
+
var root = (opts && opts.selector) ? document.querySelector(opts.selector) : document
|
|
84
|
+
if (!root) return { missing: true, url: location.href, title: document.title, elements: [], total: 0 }
|
|
85
|
+
var nodes = []
|
|
86
|
+
if (root.nodeType === 1 && root.matches && root.matches(SEL)) nodes.push(root)
|
|
87
|
+
var found = root.querySelectorAll ? root.querySelectorAll(SEL) : []
|
|
88
|
+
for (var i = 0; i < found.length; i++) nodes.push(found[i])
|
|
89
|
+
var max = Math.max(1, (opts && opts.max) ? opts.max : 100)
|
|
90
|
+
var elements = []
|
|
91
|
+
for (var j = 0; j < nodes.length && elements.length < max; j++) {
|
|
92
|
+
var el = nodes[j]
|
|
93
|
+
var tag = el.tagName.toLowerCase()
|
|
94
|
+
var role = roleOf(el, tag)
|
|
95
|
+
var geo = rectOf(el)
|
|
96
|
+
var rec = {
|
|
97
|
+
tag: tag,
|
|
98
|
+
role: role,
|
|
99
|
+
name: nameOf(el),
|
|
100
|
+
selector: selectorOf(el),
|
|
101
|
+
disabled: Boolean(el.disabled) || el.getAttribute('aria-disabled') === 'true',
|
|
102
|
+
geo: geo,
|
|
103
|
+
inViewport: inViewportOf(geo),
|
|
104
|
+
}
|
|
105
|
+
if (role === 'textbox' || role === 'select') rec.value = norm(el.value)
|
|
106
|
+
elements.push(rec)
|
|
107
|
+
}
|
|
108
|
+
return { url: location.href, title: document.title, total: nodes.length, elements: elements }
|
|
109
|
+
}`
|
|
110
|
+
|
|
111
|
+
/** 快照表达式(页面侧一次执行)。 */
|
|
112
|
+
export function snapshotExpression({ selector = null, max = DEFAULT_MAX } = {}) {
|
|
113
|
+
return `${mark("snapshot")}(${SNAPSHOT_EXPR})(${JSON.stringify({ selector, max })})`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 当前页信息表达式(URL ∥ title ∥ readyState)。 */
|
|
117
|
+
export function pageInfoExpression() {
|
|
118
|
+
return `${mark("pageinfo")}(function () { return { url: location.href, title: document.title, readyState: document.readyState } })()`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 点击表达式(寻址 + tag 一致校验 + disabled 判;返回 `{found, disabled, tag?}`)。 */
|
|
122
|
+
export function clickExpression(selector, tag) {
|
|
123
|
+
return `${mark("click")}(function () {
|
|
124
|
+
var el = document.querySelector(${JSON.stringify(selector)})
|
|
125
|
+
if (!el) return { found: false }
|
|
126
|
+
var tag = el.tagName.toLowerCase()
|
|
127
|
+
if (tag !== ${JSON.stringify(tag)}) return { found: false, tag: tag }
|
|
128
|
+
if (el.disabled) return { found: true, disabled: true }
|
|
129
|
+
el.scrollIntoView({ block: 'center' })
|
|
130
|
+
el.click()
|
|
131
|
+
return { found: true, disabled: false }
|
|
132
|
+
})()`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 输入表达式(原生 value setter + input/change 事件;回执判据 = `password` 标记)。 */
|
|
136
|
+
export function typeExpression(selector, tag, text, clear) {
|
|
137
|
+
return `${mark("type")}(function () {
|
|
138
|
+
var el = document.querySelector(${JSON.stringify(selector)})
|
|
139
|
+
if (!el) return { found: false }
|
|
140
|
+
var tag = el.tagName.toLowerCase()
|
|
141
|
+
if (tag !== ${JSON.stringify(tag)}) return { found: false, tag: tag }
|
|
142
|
+
if (tag !== 'input' && tag !== 'textarea') return { found: true, disabled: false, password: false, fillable: false }
|
|
143
|
+
var password = tag === 'input' && String(el.getAttribute('type') || '').toLowerCase() === 'password'
|
|
144
|
+
if (el.disabled) return { found: true, disabled: true, password: password }
|
|
145
|
+
var proto = tag === 'textarea' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
|
|
146
|
+
var desc = Object.getOwnPropertyDescriptor(proto, 'value')
|
|
147
|
+
var next = ${clear ? "true" : "false"} ? ${JSON.stringify(text)} : String(el.value == null ? '' : el.value) + ${JSON.stringify(text)}
|
|
148
|
+
if (desc && desc.set) desc.set.call(el, next); else el.value = next
|
|
149
|
+
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
150
|
+
el.dispatchEvent(new Event('change', { bubbles: true }))
|
|
151
|
+
return { found: true, disabled: false, password: password }
|
|
152
|
+
})()`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 点表达式(ref → 真点——§2.7):`scrollIntoView({block:'center'})` → 重测 → 返回中心点与 `{found, tag?}`。 */
|
|
156
|
+
export function pointExpression(selector, tag) {
|
|
157
|
+
return `${mark("point")}(function () {
|
|
158
|
+
var el = document.querySelector(${JSON.stringify(selector)})
|
|
159
|
+
if (!el) return { found: false }
|
|
160
|
+
var tag = el.tagName.toLowerCase()
|
|
161
|
+
if (tag !== ${JSON.stringify(tag)}) return { found: false, tag: tag }
|
|
162
|
+
el.scrollIntoView({ block: 'center' })
|
|
163
|
+
var r = el.getBoundingClientRect()
|
|
164
|
+
return { found: true, x: r.left + r.width / 2, y: r.top + r.height / 2 }
|
|
165
|
+
})()`
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 聚焦表达式(§2.7):`scrollIntoView` + `focus()` → `{found, tag?, focused}`;不可聚焦 ⇒ `focused:false`。 */
|
|
169
|
+
export function focusExpression(selector, tag) {
|
|
170
|
+
return `${mark("focus")}(function () {
|
|
171
|
+
var el = document.querySelector(${JSON.stringify(selector)})
|
|
172
|
+
if (!el) return { found: false }
|
|
173
|
+
var tag = el.tagName.toLowerCase()
|
|
174
|
+
if (tag !== ${JSON.stringify(tag)}) return { found: false, tag: tag }
|
|
175
|
+
el.scrollIntoView({ block: 'center' })
|
|
176
|
+
try { el.focus() } catch (e) { /* 不可聚焦元素(div 等)——focused 读回自证 */ }
|
|
177
|
+
return { found: true, focused: document.activeElement === el }
|
|
178
|
+
})()`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 等待谓词表达式(selector ∥ text ∥ url——networkIdle 走核侧事件计数)。 */
|
|
182
|
+
export function waitExpression(kind, value) {
|
|
183
|
+
const expr = kind === "selector" ? `!!document.querySelector(${JSON.stringify(value)})`
|
|
184
|
+
: kind === "text" ? `String((document.body && document.body.innerText) || '').includes(${JSON.stringify(value)})`
|
|
185
|
+
: `location.href.includes(${JSON.stringify(value)})`
|
|
186
|
+
return `${mark("wait")}(function () { return ${expr} })()`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 页面侧结果 → 形状归一的记录集(不可信输入:缺字段/异型一律归一,不抛)。 */
|
|
190
|
+
export function normalizeSnapshot(raw) {
|
|
191
|
+
const r = raw && typeof raw === "object" ? raw : {}
|
|
192
|
+
const num = (v) => (Number.isFinite(Number(v)) ? Math.round(Number(v)) : 0)
|
|
193
|
+
const elements = (Array.isArray(r.elements) ? r.elements : [])
|
|
194
|
+
.filter((e) => e && typeof e === "object" && e.selector)
|
|
195
|
+
.map((e) => {
|
|
196
|
+
const rec = {
|
|
197
|
+
tag: String(e.tag ?? "").toLowerCase(),
|
|
198
|
+
role: String(e.role ?? "other"),
|
|
199
|
+
name: String(e.name ?? ""),
|
|
200
|
+
selector: String(e.selector),
|
|
201
|
+
disabled: e.disabled === true,
|
|
202
|
+
}
|
|
203
|
+
if (e.value !== undefined && e.value !== null && String(e.value) !== "") rec.value = String(e.value)
|
|
204
|
+
if (e.geo && typeof e.geo === "object") {
|
|
205
|
+
rec.geo = { x: num(e.geo.x), y: num(e.geo.y), w: num(e.geo.w), h: num(e.geo.h) }
|
|
206
|
+
}
|
|
207
|
+
if (e.inViewport === false) rec.inViewport = false // 仅视口外落标记(在屏行文法零动——KD-15)
|
|
208
|
+
return rec
|
|
209
|
+
})
|
|
210
|
+
return {
|
|
211
|
+
url: String(r.url ?? ""),
|
|
212
|
+
title: String(r.title ?? ""),
|
|
213
|
+
elements,
|
|
214
|
+
total: Number.isInteger(r.total) ? Math.max(r.total, elements.length) : elements.length,
|
|
215
|
+
missing: r.missing === true,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 核侧引用表(会话级——`byRef` 反查、`byKey` 复用、`counter` 会话内单调)。 */
|
|
220
|
+
export function createRefTable() {
|
|
221
|
+
return { byRef: new Map(), byKey: new Map(), counter: 1 }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 记录集 → 引用行(key 已在表 ⇒ 复用原号;未见 ⇒ 新号 + `isNew`)。 */
|
|
225
|
+
export function assignRefs(table, elements) {
|
|
226
|
+
const rows = []
|
|
227
|
+
for (const el of elements) {
|
|
228
|
+
const key = `${el.tag}|${el.selector}`
|
|
229
|
+
let entry = table.byKey.get(key)
|
|
230
|
+
const isNew = !entry
|
|
231
|
+
if (isNew) {
|
|
232
|
+
entry = { ref: `e${table.counter++}`, key, selector: el.selector, tag: el.tag, name: el.name }
|
|
233
|
+
table.byKey.set(key, entry)
|
|
234
|
+
table.byRef.set(entry.ref, entry)
|
|
235
|
+
} else {
|
|
236
|
+
entry.name = el.name // 名随页面更新;号不变(F-BT4)
|
|
237
|
+
}
|
|
238
|
+
rows.push({ ...el, ref: entry.ref, isNew })
|
|
239
|
+
}
|
|
240
|
+
return rows
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 结果行文法:`<ref> <role> "<name>"` + `[value=…]`(password 除外)+ `[new]` + `[disabled]` + `[outside]`。 */
|
|
244
|
+
export function rowLine(row) {
|
|
245
|
+
const marks = []
|
|
246
|
+
if (row.role !== "password" && row.value !== undefined && String(row.value) !== "") marks.push(`[value="${row.value}"]`)
|
|
247
|
+
if (row.isNew) marks.push("[new]")
|
|
248
|
+
if (row.disabled) marks.push("[disabled]")
|
|
249
|
+
if (row.inViewport === false) marks.push("[outside]")
|
|
250
|
+
return `${row.ref} ${row.role} "${row.name}"${marks.length ? ` ${marks.join(" ")}` : ""}`
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 页摘行(内容与出处同行——N-BT2 来源标注)。 */
|
|
254
|
+
export function pageDigest(url, title) {
|
|
255
|
+
return `[page] ${url} — "${title}"`
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 紧凑渲染:页摘 ∥ 计数行 ∥ 元素行 ∥ 截断标记;总文本 ≤ 20,000 字符(N-BT3)。 */
|
|
259
|
+
export function renderSnapshot({ url, title, rows = [], total = rows.length }) {
|
|
260
|
+
const lines = [pageDigest(url, title), `[${total} interactive elements (${rows.filter((r) => r.isNew).length} new)]`]
|
|
261
|
+
for (const row of rows) lines.push(rowLine(row))
|
|
262
|
+
if (total > rows.length) lines.push(`[truncated: showing ${rows.length} of ${total} — pass selector to narrow]`)
|
|
263
|
+
return truncateText(lines.join("\n"), MAX_SNAPSHOT_CHARS, "pass selector to narrow")
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 截断(`truncate` 同款标记形态——`tools/shared.mjs:190-193`)。 */
|
|
267
|
+
export function truncateText(text, max, note = "narrow the request") {
|
|
268
|
+
if (text.length <= max) return text
|
|
269
|
+
return `${text.slice(0, max)}\n[... truncated: ${text.length - max} chars omitted — ${note}]`
|
|
270
|
+
}
|
package/config-io.mjs
CHANGED
|
@@ -141,7 +141,7 @@ const warnedContext = new Set()
|
|
|
141
141
|
/**
|
|
142
142
|
* Resolve providers list + active name from disk(VSC `config-io.mjs` 逐字随迁)。BaseURL
|
|
143
143
|
* trailing slashes normalized. `activeProvider` 语义 = config.defaultModel 的渠道;缺失/失效
|
|
144
|
-
* → 回退首 provider。渠道单值 `model`
|
|
144
|
+
* → 回退首 provider。渠道单值 `model` 一律删除(2026-10-09 清除批——渠道不携模型,零回落)。
|
|
145
145
|
*/
|
|
146
146
|
export function resolveProviders() {
|
|
147
147
|
const raw = loadRaw()
|
|
@@ -150,8 +150,8 @@ export function resolveProviders() {
|
|
|
150
150
|
: []
|
|
151
151
|
for (const p of providers) {
|
|
152
152
|
if (typeof p.baseURL === "string") p.baseURL = p.baseURL.replace(/\/+$/, "")
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
// 2026-10-09 清除批(PROVIDER.md §6.16 M3):渠道不携模型——一律删,不落任何回退
|
|
154
|
+
delete p.model
|
|
155
155
|
// PROVIDER.md §15 D-C1: providers[].context must be a positive integer (K units).
|
|
156
156
|
if (p.context != null) {
|
|
157
157
|
const n = Number(p.context)
|
|
@@ -218,9 +218,11 @@ export function removeProviderKeyFromConfig(name) {
|
|
|
218
218
|
|
|
219
219
|
const FORMATS = ["openai", "anthropic", "google"]
|
|
220
220
|
|
|
221
|
-
/** 新增渠道。payload: { preset?: name, custom?: { name, baseURL,
|
|
221
|
+
/** 新增渠道。payload: { preset?: name, custom?: { name, baseURL, format }, key?, proxy? }
|
|
222
|
+
* `proxy === true` ⇒ 同批落 `proxy: true`(缺 ∥ 非真 ⇒ 零键——与行面 `handleSetProviderProxy` 同判据)。
|
|
223
|
+
* 2026-10-09 清除批:custom 不携 `model`——必填判 ∥ 落键双退(渠道不携模型——R3)。
|
|
222
224
|
* 返回错误串或 null。 */
|
|
223
|
-
export function addProviderEntry({ preset, custom, key } = {}) {
|
|
225
|
+
export function addProviderEntry({ preset, custom, key, proxy } = {}) {
|
|
224
226
|
let providers
|
|
225
227
|
try {
|
|
226
228
|
({ providers } = resolveProviders())
|
|
@@ -240,22 +242,25 @@ export function addProviderEntry({ preset, custom, key } = {}) {
|
|
|
240
242
|
if (existing.has(name) || PROVIDER_PRESETS[name]) return `Name "${name}" is already in use`
|
|
241
243
|
const baseURL = (custom.baseURL || "").trim().replace(/\/+$/, "")
|
|
242
244
|
if (!baseURL) return "Base URL is required"
|
|
243
|
-
const model = (custom.model || "").trim()
|
|
244
|
-
if (!model) return "Model is required"
|
|
245
245
|
const format = (custom.format || "openai").trim()
|
|
246
246
|
if (!FORMATS.includes(format)) return `Unknown API format: ${format} (expected ${FORMATS.join("/")})`
|
|
247
|
-
entry = { name, baseURL
|
|
247
|
+
entry = { name, baseURL } // 2026-10-09 清除批:渠道不携模型——单值 model 双退(R3 零读写)
|
|
248
248
|
if (format !== "openai") entry.format = format
|
|
249
249
|
} else {
|
|
250
250
|
return "Add provider needs a preset or a custom config"
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
253
|
+
// #1027(三端对齐批):走 proxy 旗——仅真值落键(运行期 = 逐渠 `proxy: true` ∧ 代理 `uri` 在案——无全局闸;2026-10-08)
|
|
254
|
+
if (proxy === true) entry.proxy = true
|
|
255
|
+
|
|
256
|
+
// #1062(PROVIDER.md §6.23):落条与置钥合为**一次**写——单写原子(不被两段写撕开 ⇒ 半状态
|
|
257
|
+
// 不可达);F5b 冲突 ⇒ 提示串且零落盘(条目 ∥ 钥俱不落)。
|
|
256
258
|
const k = (key || "").trim()
|
|
257
|
-
|
|
258
|
-
|
|
259
|
+
const r = persistRaw((raw) => {
|
|
260
|
+
(raw.providers ??= []).push(entry)
|
|
261
|
+
if (k) entry.apiKey = k
|
|
262
|
+
})
|
|
263
|
+
return conflictError(r)
|
|
259
264
|
}
|
|
260
265
|
|
|
261
266
|
/** 移除渠道。激活渠道受保护(CLI parity)。返回错误串或 null。 */
|
package/config-migrate.mjs
CHANGED
|
@@ -5,17 +5,14 @@
|
|
|
5
5
|
* the caller (CLI: config.mjs loadConfig; VSC: config-io.mjs loadRaw — same rule, 双端各自
|
|
6
6
|
* 独立实现——不做同步依赖).
|
|
7
7
|
*
|
|
8
|
-
* 目标形态 C
|
|
8
|
+
* 目标形态 C(2026-10-09 清除批:渠道单值模型退场——M7 v2):渠道**不携模型**(`providers[].model` /
|
|
9
|
+
* `models[]` 一律删)+ 顶层 `defaultModel` 复合(新会话起点)。
|
|
9
10
|
* 老形态迁移(v2):
|
|
10
|
-
* - A:`
|
|
11
|
-
*
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
* - 混合/垃圾:非数组 `p.models` / 非字符串(空串)`p.model` → 删除(清理)
|
|
15
|
-
* - 顺序:先构造/读取有效 `defaultModel` 值(含老 active* 转换),再按它给各渠道播种 `p.model`
|
|
16
|
-
* - 幂等:无老字段(无 `models` / 无 active* / 无垃圾值)→ 返回 false 不动;slot 旧字段
|
|
11
|
+
* - A:`activeProvider/activeModel` → `defaultModel` 复合(`activeModel` 优先;AP 不存在/AM 空 → 首渠道回退)
|
|
12
|
+
* - B:`providers[].models[]` / `providers[].model` → 一律删除(清理)
|
|
13
|
+
* - 顺序:先构造/读取有效 `defaultModel` 值(含老 active* 转换),再清各渠道 `model` / `models` 键
|
|
14
|
+
* - 幂等:无老字段(无 `models` / 无 `model` / 无 active*)→ 返回 false 不动;slot 旧字段
|
|
17
15
|
* (会话文件内)不迁移——读侧容忍
|
|
18
|
-
* - 空结果合法:渠道无模型来源 → `p.model` 不设(模型选择经 `/models` 拉取候选——M8/M9)
|
|
19
16
|
*
|
|
20
17
|
* ── 核内合并(CORE-UNIFICATION §2.5 #79 · CONFIG 组「取并集」)──
|
|
21
18
|
* ② `migrateCore`(VS Code 旧设置 / 密钥库迁移遍——VSC 侧逐字随迁 + 依赖面收核为注入参数):
|
|
@@ -46,25 +43,12 @@ export function migrateLegacyModelFields(raw) {
|
|
|
46
43
|
changed = true
|
|
47
44
|
}
|
|
48
45
|
|
|
49
|
-
// ── ②
|
|
50
|
-
|
|
51
|
-
const dmSep = dm ? dm.indexOf(":") : -1
|
|
52
|
-
const dmProvider = dmSep > 0 ? dm.slice(0, dmSep) : null
|
|
53
|
-
const dmModel = dmSep > 0 && dmSep < dm.length - 1 ? dm.slice(dmSep + 1) : null
|
|
46
|
+
// ── ② 渠道模型键清除(2026-10-09 清除批——M7 v2:渠道不携模型,`model` / `models` 一律删;
|
|
47
|
+
// 幂等 = 无键零改)──
|
|
54
48
|
for (const p of providers) {
|
|
55
49
|
if (!p || typeof p !== "object") continue
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
? dmModel
|
|
59
|
-
: (typeof p.model === "string" && p.model.trim() ? p.model : seedModel(p))
|
|
60
|
-
if (next == null) delete p.model
|
|
61
|
-
else p.model = next
|
|
62
|
-
delete p.models
|
|
63
|
-
changed = true
|
|
64
|
-
continue
|
|
65
|
-
}
|
|
66
|
-
if (p.models !== undefined) { delete p.models; changed = true } // 非数组 models —— 垃圾清理
|
|
67
|
-
if (p.model !== undefined && !(typeof p.model === "string" && p.model.trim())) { delete p.model; changed = true }
|
|
50
|
+
if (p.models !== undefined) { delete p.models; changed = true }
|
|
51
|
+
if (p.model !== undefined) { delete p.model; changed = true }
|
|
68
52
|
}
|
|
69
53
|
return changed
|
|
70
54
|
}
|
|
@@ -93,7 +77,7 @@ function seedModel(p) {
|
|
|
93
77
|
*
|
|
94
78
|
* Never overwrites an apiKey already present in config.json (the CLI may have written it first).
|
|
95
79
|
* Builtin preset names missing from providers[] are created from PROVIDER_PRESETS; unknown
|
|
96
|
-
* names are only created when baseURL
|
|
80
|
+
* names are only created when baseURL metadata is recoverable (渠道不携模型——2026-10-09 清除批).
|
|
97
81
|
*/
|
|
98
82
|
export async function migrateCore(deps) {
|
|
99
83
|
const { secrets, flags, legacySettings, clearLegacySettings } = deps
|
|
@@ -122,8 +106,8 @@ export async function migrateCore(deps) {
|
|
|
122
106
|
entry = presetToEntry(name)
|
|
123
107
|
} else {
|
|
124
108
|
const meta = legacyMeta[name]
|
|
125
|
-
if (!meta?.baseURL
|
|
126
|
-
entry = { name, baseURL: meta.baseURL
|
|
109
|
+
if (!meta?.baseURL) return // orphan key, no way to reconstruct(渠道不携模型——2026-10-09 清除批:门只认 baseURL,model 零读零落)
|
|
110
|
+
entry = { name, baseURL: meta.baseURL }
|
|
127
111
|
// PROVIDER.md §15 D-C4: migrate-settings is the same source — a legacy
|
|
128
112
|
// settings entry carrying context (K units) rides along into config.json.
|
|
129
113
|
if (meta.context != null) entry.context = meta.context
|
package/config-presets.mjs
CHANGED
|
@@ -8,52 +8,56 @@
|
|
|
8
8
|
* - `presetToEntry` = 取 VSC 侧独有导出(CLI 侧对应实现在
|
|
9
9
|
* `cli/setup-wizard.mjs`,同字节语义)。
|
|
10
10
|
*
|
|
11
|
-
* 预先声明(
|
|
12
|
-
*
|
|
11
|
+
* 预先声明(2026-10-09 清除批:渠道单值 `model` 退场——PROVIDER.md §6.16 M3):预置**不携带**
|
|
12
|
+
* 默认模型(新装种子 = 零播种);模型身份唯二 = 顶层 `defaultModel` 复合串 ∥ 会话槽选定;
|
|
13
|
+
* 候选清单字段亦不预置——运行期从 provider 拉取(`GET /models`)。
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
/** Built-in provider presets: shared by /provider add <preset> and first-run wizard. */
|
|
16
17
|
export const PROVIDER_PRESETS = {
|
|
17
|
-
deepseek: { baseURL: "https://api.deepseek.com",
|
|
18
|
-
kimi: { baseURL: "https://api.moonshot.cn/v1",
|
|
19
|
-
"kimi-code": { baseURL: "https://api.kimi.com/coding/v1",
|
|
20
|
-
glm: { baseURL: "https://open.bigmodel.cn/api/paas/v4",
|
|
21
|
-
"glm-code": { baseURL: "https://open.bigmodel.cn/api/coding/paas/v4",
|
|
22
|
-
qwen: { baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
23
|
-
qwenplan: { baseURL: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
|
|
24
|
-
mimo: { baseURL: "https://api.xiaomimimo.com/v1",
|
|
25
|
-
mimoplan: { baseURL: "https://token-plan-cn.xiaomimimo.com/v1",
|
|
26
|
-
minimax: { baseURL: "https://api.minimaxi.com/v1",
|
|
27
|
-
openai: { baseURL: "https://api.openai.com/v1",
|
|
28
|
-
claude: { baseURL: "https://api.anthropic.com/v1",
|
|
29
|
-
gemini: { baseURL: "https://generativelanguage.googleapis.com/v1beta",
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
18
|
+
deepseek: { baseURL: "https://api.deepseek.com", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 384_000, desc: "DeepSeek" },
|
|
19
|
+
kimi: { baseURL: "https://api.moonshot.cn/v1", thinking: null, reasoningEffort: "max", maxTokens: 131072, desc: "Kimi / Moonshot" },
|
|
20
|
+
"kimi-code": { baseURL: "https://api.kimi.com/coding/v1", thinking: null, reasoningEffort: "max", maxTokens: 131072, desc: "Kimi For Coding (platform.kimi.com — sk-kimi- keys; NOT interchangeable with Moonshot)" },
|
|
21
|
+
glm: { baseURL: "https://open.bigmodel.cn/api/paas/v4", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 128000, desc: "Zhipu GLM" },
|
|
22
|
+
"glm-code": { baseURL: "https://open.bigmodel.cn/api/coding/paas/v4", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 128000, desc: "Zhipu GLM Coding Plan (coding endpoint — same key as GLM; server-forced thinking)" },
|
|
23
|
+
qwen: { baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", reasoningEffort: "high", maxTokens: 131072, desc: "Qwen / Alibaba" },
|
|
24
|
+
qwenplan: { baseURL: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", reasoningEffort: "high", maxTokens: 131072, desc: "Qwen Token Plan (百炼套餐)" },
|
|
25
|
+
mimo: { baseURL: "https://api.xiaomimimo.com/v1", thinking: { type: "enabled" }, maxTokens: 131072, desc: "MiMo (Xiaomi)" },
|
|
26
|
+
mimoplan: { baseURL: "https://token-plan-cn.xiaomimimo.com/v1", thinking: { type: "enabled" }, maxTokens: 131072, desc: "MiMo Token Plan (小米套餐 — tp- keys; 与按量付费 sk- 密钥不通用)" },
|
|
27
|
+
minimax: { baseURL: "https://api.minimaxi.com/v1", thinking: { type: "adaptive" }, maxTokens: 128000, chatPath: "/text/chatcompletion_v2", desc: "MiniMax" },
|
|
28
|
+
openai: { baseURL: "https://api.openai.com/v1", desc: "OpenAI" },
|
|
29
|
+
claude: { baseURL: "https://api.anthropic.com/v1", format: "anthropic", maxTokens: 8192, desc: "Claude (Anthropic)" },
|
|
30
|
+
gemini: { baseURL: "https://generativelanguage.googleapis.com/v1beta", format: "google", maxTokens: 8192, desc: "Gemini (Google)" },
|
|
31
|
+
// Gemini OpenAI 兼容端点(Google 官方——2026-10-09 server 预设批 · 台账 #1128):与 `gemini`(原生
|
|
32
|
+
// `format:"google"`)同 key、两形态并存——本键 = OpenAI `/chat/completions` 侧(PROVIDER.md §6.11)。
|
|
33
|
+
// **待验(无 key——未实拉 `/models`;接入后按实拉复核)**;`thinking` / `reasoningEffort` / `maxTokens` 不设 = 不发(载荷面未测——D-13)。
|
|
34
|
+
"gemini-openai": { baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", desc: "Gemini (Google) — OpenAI-compatible side (same key as gemini)" },
|
|
35
|
+
grok: { baseURL: "https://api.x.ai/v1", maxTokens: 64_000, desc: "Grok (xAI)" },
|
|
36
|
+
mistral: { baseURL: "https://api.mistral.ai/v1", maxTokens: 32_000, desc: "Mistral" },
|
|
37
|
+
volcengine: { baseURL: "https://ark.cn-beijing.volces.com/api/v3", maxTokens: 131072, desc: "Volcengine Ark (豆包)" },
|
|
38
|
+
hunyuan: { baseURL: "https://api.hunyuan.cloud.tencent.com/v1", maxTokens: 32_000, desc: "Hunyuan (腾讯混元)" },
|
|
34
39
|
// TokenHub = Tencent MaaS 聚合网关(另一主机;`hy3` 在本规格表有行)。`thinking` / `reasoningEffort` /
|
|
35
40
|
// `maxTokens` 一律**不设 = 不发**(两新渠道的 thinking 载荷与 max_tokens 行为未测 —— MODEL-SPECS §9.6 D-13)。
|
|
36
|
-
tokenhub: { baseURL: "https://tokenhub.tencentmaas.com/v1",
|
|
41
|
+
tokenhub: { baseURL: "https://tokenhub.tencentmaas.com/v1", desc: "Tencent TokenHub (腾讯混元网关)" },
|
|
37
42
|
// Huawei Cloud MaaS(ModelArts Studio)——OpenAI 兼容 `…/openai/v1`(区域端点变体走自定义渠道
|
|
38
|
-
// 自助路径——PROVIDER.md §6.21
|
|
39
|
-
// QS 示例模型 · 官方 API 样例 `model` 逐字)· **待验(无 key——未实拉 /models;接入后按实拉取值复核)**;
|
|
43
|
+
// 自助路径——PROVIDER.md §6.21)。**待验(无 key——未实拉 /models;接入后按实拉取值复核)**;
|
|
40
44
|
// `thinking` / `reasoningEffort` / `maxTokens` 不设 = 不发(载荷面未测——D-13)。
|
|
41
|
-
huawei: { baseURL: "https://api.modelarts-maas.com/openai/v1",
|
|
42
|
-
siliconflow: { baseURL: "https://api.siliconflow.cn/v1",
|
|
43
|
-
openrouter: { baseURL: "https://openrouter.ai/api/v1",
|
|
44
|
-
groq: { baseURL: "https://api.groq.com/openai/v1",
|
|
45
|
+
huawei: { baseURL: "https://api.modelarts-maas.com/openai/v1", desc: "Huawei Cloud ModelArts Studio (华为云 MaaS)" },
|
|
46
|
+
siliconflow: { baseURL: "https://api.siliconflow.cn/v1", maxTokens: 32_000, desc: "SiliconFlow (硅基流动)" },
|
|
47
|
+
openrouter: { baseURL: "https://openrouter.ai/api/v1", maxTokens: 32_000, desc: "OpenRouter" },
|
|
48
|
+
groq: { baseURL: "https://api.groq.com/openai/v1", maxTokens: 32_000, desc: "Groq" },
|
|
45
49
|
// OpenCode Go 订阅网关(opencode.ai/zen 取 sk- key ∥ 无 OAuth)——官方档(go.mdx)双协议混装 ⇒ 拆双预设
|
|
46
50
|
// (PROVIDER.md §6.11):本键 = OpenAI 兼容 `/chat/completions` 侧;`opencode-go-anthropic` = Anthropic
|
|
47
51
|
// `/messages` 侧(同 key——模型按侧选)。字段集 = 最小面:`thinking` / `reasoningEffort` 不设 = 不发
|
|
48
52
|
// (D-13 同口径);`maxTokens` 仅 anthropic 侧设 `65536`(该 transport `max_tokens` 必发——不设即落规格
|
|
49
53
|
// 行值 131072,超渠道口径)。**待验(无 key——未实拉 `/models` ∕ 载荷未实测;接入后按实拉复核)**;
|
|
50
54
|
// `x-opencode-session` 头 = 可选遥测——**不发**(亦不动 `providers[].headers` 面)。
|
|
51
|
-
"opencode-go": { baseURL: "https://opencode.ai/zen/go/v1",
|
|
52
|
-
"opencode-go-anthropic": { baseURL: "https://opencode.ai/zen/go/v1",
|
|
55
|
+
"opencode-go": { baseURL: "https://opencode.ai/zen/go/v1", desc: "OpenCode Go subscription (sk- key from opencode.ai/zen; OpenAI-compatible models — MiniMax/Qwen via opencode-go-anthropic)" },
|
|
56
|
+
"opencode-go-anthropic": { baseURL: "https://opencode.ai/zen/go/v1", format: "anthropic", maxTokens: 65536, desc: "OpenCode Go — Anthropic Messages side (same sk- key as opencode-go; MiniMax/Qwen models)" },
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
/** Build the stored provider entry from a preset — strip the display field, keep the rest
|
|
56
|
-
*
|
|
60
|
+
* (2026-10-09 清除批:不产 `model` 键——渠道不携模型;候选清单不再由预设播种)。 */
|
|
57
61
|
export function presetToEntry(name) {
|
|
58
62
|
const preset = PROVIDER_PRESETS[name]
|
|
59
63
|
if (!preset) return null
|