@aiti-ai/aiti-models-import 0.0.0-stage → 0.1.9

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 aiti-ai contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,273 @@
1
+ /**
2
+ * check-client —— 扩展 client .mjs/.js 的 TS 残留 tripwire(零依赖,纯 node)。
3
+ *
4
+ * 背景:aiti-plan-review 事故(docs/aiti-ext-client-hardening-plan.md §1)——
5
+ * `useState<any>(null)` 解析为合法 JS 比较表达式 `useState < any > (null)`,
6
+ * node --check / TS 解析器 / babel 全不报错,只有运行时才炸。纯语法检查
7
+ * 原理性无效,只能启发式规则 + 渲染期错误边界兜底。
8
+ *
9
+ * 用法:
10
+ * CLI node check-client.mjs <file|dir>... FAIL → exit 1
11
+ * 自测 node check-client.mjs --selftest fixtures 全过 → exit 0
12
+ * API lintClientSource(src) → violations[] { line, level, rule, text }
13
+ * lintPath(path) → { files, violations }(文件或目录,递归)
14
+ * checkClientDir(dir,{log}) FAIL → throw(deploy 脚本用)
15
+ *
16
+ * 词法:规则匹配前先剥离注释/字符串/模板字面量(${} 插值内代码保留检查)——
17
+ * aiti-editor 的 Monaco TS stub 模板串、JSDoc @return 都是合法内容,不剥全误报。
18
+ * 已知极限:regex 字面量含引号等 lexer 歧义允许漏报(tripwire 定位非编译器)。
19
+ *
20
+ * 豁免:目标行或其上一行含 `check-client:allow` 即跳过该行。
21
+ * 权威源:本文件;aiti-extman 部署副本由其 tools/deploy.mjs 拷贝(改动两处同步)。
22
+ */
23
+ import { readdirSync, readFileSync, statSync } from "node:fs";
24
+ import { join, resolve } from "node:path";
25
+
26
+ // ── 规则(分级)──────────────────────────────────────────────────────────────
27
+
28
+ const RULES = [
29
+ // FAIL:合法 JS 但运行时必炸 / 纯 TS 语法
30
+ { level: "FAIL", re: /\b(useState|useRef|useMemo|useCallback|useReducer|useImperativeHandle)\s*</, why: "React hook 泛型(运行时必炸)" },
31
+ // 注:含「any/unknown/string」字样的规则用字符串构 RegExp——本文件自身也
32
+ // 会被扫,正则字面量里的 (?:any 会被自己命中(词法器不处理 regex 字面量),
33
+ // 字符串字面量则会被词法遮蔽。
34
+ { level: "FAIL", re: new RegExp("\\bas\\s+(?:" + "any|unknown|const)\\b"), why: "as 断言" },
35
+ { level: "FAIL", re: new RegExp(":\\s*(?:" + "any|unknown)\\b"), why: ": any/: unknown 注解" },
36
+ { level: "FAIL", re: /\b(?:import|export)\s+type\s/, why: "import/export type" },
37
+ { level: "FAIL", re: /\b(?:interface|enum)\s+[A-Za-z_$]/, why: "interface/enum 声明" },
38
+ // warn:疑似残留,人工确认,不阻断
39
+ { level: "warn", re: new RegExp(":\\s*(?:" + "string|number|boolean|void)\\b(?!\\s*[._\\w])"), why: "疑似类型注解" },
40
+ { level: "warn", re: /\bsatisfies\s/, why: "satisfies" },
41
+ { level: "warn", re: /\b(?:Record|Partial|Readonly|Omit|Pick)\s*</, why: "TS 工具类型" },
42
+ ];
43
+
44
+ // ── 词法剥离:注释/字符串/模板字面量 → 空格(保留换行,行号不漂移)─────────
45
+ export function stripLiterals(src) {
46
+ const out = src.split("");
47
+ const n = src.length;
48
+ const mask = (from, to) => {
49
+ for (let k = from; k < to && k < n; k++) if (out[k] !== "\n") out[k] = " ";
50
+ };
51
+ let mode = "code";
52
+ let depth = 0;
53
+ const stack = []; // 模板 ${ 插值进入时的 brace 深度(支持嵌套模板)
54
+ let i = 0;
55
+ while (i < n) {
56
+ const c = src[i];
57
+ const d = i + 1 < n ? src[i + 1] : "";
58
+ if (mode === "code") {
59
+ if (c === "/" && d === "/") {
60
+ const j = src.indexOf("\n", i);
61
+ const end = j === -1 ? n : j;
62
+ mask(i, end);
63
+ i = end;
64
+ } else if (c === "/" && d === "*") {
65
+ const j = src.indexOf("*/", i + 2);
66
+ const end = j === -1 ? n : j + 2;
67
+ mask(i, end);
68
+ i = end;
69
+ } else if (c === "'" || c === '"') {
70
+ let j = i + 1;
71
+ while (j < n && src[j] !== c && src[j] !== "\n") j += src[j] === "\\" ? 2 : 1;
72
+ if (j < n && src[j] === c) {
73
+ mask(i, j + 1);
74
+ i = j + 1;
75
+ } else {
76
+ // 未闭合到行尾 → 可能是 regex 字面量歧义,只遮开引号,按代码继续
77
+ out[i] = " ";
78
+ i++;
79
+ }
80
+ } else if (c === "`") {
81
+ mode = "tpl";
82
+ out[i] = " ";
83
+ i++;
84
+ } else if (c === "{") {
85
+ depth++;
86
+ i++;
87
+ } else if (c === "}") {
88
+ if (stack.length && depth === stack[stack.length - 1]) {
89
+ stack.pop();
90
+ mode = "tpl"; // 插值结束回模板
91
+ out[i] = " ";
92
+ } else {
93
+ depth = Math.max(0, depth - 1);
94
+ }
95
+ i++;
96
+ } else i++;
97
+ } else if (mode === "tpl") {
98
+ if (c === "\\") {
99
+ mask(i, Math.min(i + 2, n));
100
+ i += 2;
101
+ } else if (c === "`") {
102
+ mode = "code";
103
+ out[i] = " ";
104
+ i++;
105
+ } else if (c === "$" && d === "{") {
106
+ stack.push(depth);
107
+ mode = "code";
108
+ mask(i, i + 2);
109
+ i += 2;
110
+ } else {
111
+ if (c !== "\n") out[i] = " ";
112
+ i++;
113
+ }
114
+ } else {
115
+ // 不可达:line/block/字符串均在 code 分支内联处理
116
+ i++;
117
+ }
118
+ }
119
+ return out.join("");
120
+ }
121
+
122
+ // ── 单源检查 ────────────────────────────────────────────────────────────────
123
+ export function lintClientSource(src) {
124
+ const masked = stripLiterals(src);
125
+ const origLines = src.split("\n");
126
+ const maskedLines = masked.split("\n");
127
+ const violations = [];
128
+ for (let i = 0; i < maskedLines.length; i++) {
129
+ const prev = i > 0 ? origLines[i - 1] : "";
130
+ if (origLines[i].includes("check-client:allow") || prev.includes("check-client:allow")) continue;
131
+ for (const { level, re, why } of RULES) {
132
+ if (re.test(maskedLines[i])) {
133
+ violations.push({ line: i + 1, level, rule: why, text: origLines[i].trim().slice(0, 90) });
134
+ }
135
+ }
136
+ }
137
+ return violations;
138
+ }
139
+
140
+ // ── 文件/目录检查 ───────────────────────────────────────────────────────────
141
+ const SKIP_DIR = new Set(["node_modules", ".git"]);
142
+ /** 超过此体积视为 vendored bundle(如 Monaco ts.worker,合法含 interface 字样),不查。 */
143
+ const MAX_BYTES = 512 * 1024;
144
+ const isClientFile = (name) => /\.(mjs|js|cjs)$/.test(name) && !/\.min\./.test(name) && !/\.test\./.test(name);
145
+
146
+ export function lintPath(p, out = { files: 0, violations: [] }) {
147
+ const st = statSync(p);
148
+ if (st.isDirectory()) {
149
+ for (const name of readdirSync(p)) {
150
+ if (SKIP_DIR.has(name)) continue;
151
+ lintPath(join(p, name), out);
152
+ }
153
+ return out;
154
+ }
155
+ const base = p.split("/").pop();
156
+ if (!isClientFile(base)) return out;
157
+ if (st.size > MAX_BYTES) return out; // vendored bundle 豁免
158
+ out.files++;
159
+ for (const v of lintClientSource(readFileSync(p, "utf8"))) out.violations.push({ file: p, ...v });
160
+ return out;
161
+ }
162
+
163
+ export function formatViolations(violations) {
164
+ return violations
165
+ .map((v) => ` ${v.level} ${v.file}:${v.line} [${v.rule}] ${v.text}`)
166
+ .join("\n");
167
+ }
168
+
169
+ /** deploy 脚本用:检查目录,FAIL 抛错(消息含违规清单),warn 只打日志不阻断。 */
170
+ export function checkClientDir(dir, { log = console.log } = {}) {
171
+ const { files, violations } = lintPath(resolve(dir));
172
+ const fails = violations.filter((v) => v.level === "FAIL");
173
+ const warns = violations.filter((v) => v.level === "warn");
174
+ if (warns.length) log(`[check-client] ${warns.length} 处疑似 TS 残留(不阻断,建议人工确认):\n${formatViolations(warns)}`);
175
+ if (fails.length) {
176
+ throw new Error(
177
+ `client 检查未通过:${files} 个文件中 ${fails.length} 处 TS 残留(.mjs 里是合法 JS,语法检查不报,运行时必炸)。\n` +
178
+ formatViolations(fails) +
179
+ `\n修复或在该行加注释 // check-client:allow 豁免。详见 docs/aiti-ext-client-hardening-plan.md`,
180
+ );
181
+ }
182
+ if (files) log(`[check-client] ${files} 个 client 文件检查通过`);
183
+ return { files, fails: fails.length, warns: warns.length };
184
+ }
185
+
186
+ /**
187
+ * extman 安装用:扫整个扩展目录(含第三方,不遵循 client/ 约定)。
188
+ * 在 SKIP_DIR 之上额外跳过构建产物/vendored 目录(Monaco ts.worker、语言包等
189
+ * 合法包含 TS 字样,词法器对 minified 代码不提供保证);体积上限同上。
190
+ */
191
+ export function lintExtensionDir(dir) {
192
+ const out = { files: 0, violations: [] };
193
+ const walk = (p) => {
194
+ const st = statSync(p);
195
+ if (st.isDirectory()) {
196
+ for (const name of readdirSync(p)) {
197
+ if (SKIP_DIR.has(name) || VENDORED_DIRS.has(name)) continue;
198
+ walk(join(p, name));
199
+ }
200
+ return;
201
+ }
202
+ const base = p.split("/").pop();
203
+ if (!isClientFile(base) || st.size > MAX_BYTES) return;
204
+ out.files++;
205
+ for (const v of lintClientSource(readFileSync(p, "utf8"))) out.violations.push({ file: p, ...v });
206
+ };
207
+ walk(resolve(dir));
208
+ return out;
209
+ }
210
+ const VENDORED_DIRS = new Set(["vendor", "monaco", "dist", "build", "assets", "static", "bundles", "chunks"]);
211
+
212
+ // ── CLI ─────────────────────────────────────────────────────────────────────
213
+ function selftest() {
214
+ const MUST_FAIL = [
215
+ "const [st, setSt] = useState<any>(null);",
216
+ "const r = useRef<number>(null);",
217
+ "const x = v as any;",
218
+ "function f(a: unknown) {}",
219
+ 'import type { Foo } from "./x";',
220
+ "interface Foo { a: string }",
221
+ "enum Color { Red }",
222
+ "const y = v as const;",
223
+ ];
224
+ const MUST_WARN = [
225
+ "const opts = { mode: string };",
226
+ "x satisfies Y;",
227
+ "const m: Record<string, any> = {};",
228
+ ];
229
+ const MUST_PASS = [
230
+ 'const REACT_TYPE_STUB = `\ndeclare module "react" {\n export function useState<T>(init: T): [T];\n export const Fragment: any;\n}\n`;',
231
+ "/** @return {{chars1: string, chars2: string}} */",
232
+ "const [s] = useState(null);",
233
+ "useState<any>(null); // check-client:allow",
234
+ "// check-client:allow\nuseState<never>(0);",
235
+ "const cmp = a < b && c > (d);",
236
+ "if (a // 注释里 as any 不算\n ) run();",
237
+ ];
238
+ let bad = 0;
239
+ const check = (src, expect, desc) => {
240
+ const v = lintClientSource(src);
241
+ const fails = v.filter((x) => x.level === "FAIL");
242
+ const ok = expect === "FAIL" ? fails.length > 0 : expect === "PASS" ? v.length === 0 : v.length > 0 && fails.length === 0;
243
+ if (!ok) {
244
+ bad++;
245
+ console.log(`SELFTEST FAIL [${desc}] 期望 ${expect},实际 ${JSON.stringify(v)}`);
246
+ }
247
+ };
248
+ MUST_FAIL.forEach((s) => check(s, "FAIL", s.slice(0, 40)));
249
+ MUST_WARN.forEach((s) => check(s, "WARN", s.slice(0, 40)));
250
+ MUST_PASS.forEach((s) => check(s, "PASS", s.replace(/\n/g, "⏎").slice(0, 40)));
251
+ if (bad) {
252
+ console.log(`selftest: ${bad} 处未过`);
253
+ process.exit(1);
254
+ }
255
+ console.log("selftest: 全部通过(8 FAIL / 3 WARN / 7 PASS)");
256
+ }
257
+
258
+ if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
259
+ if (process.argv.includes("--selftest")) {
260
+ selftest();
261
+ } else if (process.argv.length > 2) {
262
+ const all = { files: 0, violations: [] };
263
+ for (const p of process.argv.slice(2)) lintPath(resolve(p), all);
264
+ const fails = all.violations.filter((v) => v.level === "FAIL");
265
+ const warns = all.violations.filter((v) => v.level === "warn");
266
+ if (all.violations.length) console.log(formatViolations(all.violations));
267
+ console.log(`\n${all.files} 个文件:FAIL=${fails.length} warn=${warns.length}`);
268
+ process.exit(fails.length ? 1 : 0);
269
+ } else {
270
+ console.log("用法:node check-client.mjs <file|dir>... | --selftest");
271
+ process.exit(2);
272
+ }
273
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * aiti-models-import · client 入口
3
+ *
4
+ * register(ctx):
5
+ * 1. toolbar 按钮(icons/import.svg,手动打开导入弹窗)
6
+ * 2. dialogs 插槽常驻条目(ImportDialogSlot,显隐模块内状态)
7
+ * 3. 首启自动弹:register 时 fetch /status 一次(事件驱动,无轮询)——
8
+ * firstRun(无 completedAt/dismissedAt)且存在可用来源 → 打开弹窗(只打扰一次)
9
+ */
10
+ import { __setCtx } from "./host-shim";
11
+ import { ImportDialogSlot, setDialogOpen } from "./import-app";
12
+
13
+ export default function register(ctx: Record<string, unknown>) {
14
+ __setCtx(ctx); // 必须在任何 JSX 渲染前
15
+
16
+ function ImportButton() {
17
+ return (
18
+ <button type="button" title="从其他 Agent 导入模型配置(codex / zcode / opencode…)"
19
+ onClick={() => setDialogOpen(true)}
20
+ style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 36, height: 36, padding: 0, background: "none", borderTop: "none", borderRight: "1px solid var(--border)", color: "var(--text-muted)", cursor: "pointer", fontSize: 15 }}>
21
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
22
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
23
+ <path d="m7 10 5 5 5-5" />
24
+ <path d="M12 15V3" />
25
+ </svg>
26
+ </button>
27
+ );
28
+ }
29
+
30
+ ctx.registerItems?.("toolbar", [{ id: "aiti-models-import-open", render: ImportButton, meta: { label: "导入模型配置", icon: "icons/import.svg" } }]);
31
+ ctx.registerItems?.("dialogs", [{ id: "aiti-models-import-dialog", render: ImportDialogSlot, meta: { label: "模型配置导入" } }]);
32
+
33
+ // 跨扩展打开服务(quickstart 引导卡「从 Agent 导入…」消费;ext-services 拍板正路)。
34
+ // 消费方探测式软依赖:本扩展缺席/旧版无服务 → 对方不渲染入口。
35
+ try {
36
+ ctx.registerExtService?.("aiti-models-import.open", { open: () => setDialogOpen(true) });
37
+ } catch { /* ignore */ }
38
+
39
+ // 首启判定:每页面加载至多一次 /status;firstRun 且有可用来源 → 自动弹(关过/导过即不再弹)
40
+ try {
41
+ const base = typeof (ctx as any).apiUrl === "function" ? (ctx as any).apiUrl("/api/extensions/aiti-models-import/status") : "/api/extensions/aiti-models-import/status";
42
+ fetch(base, { cache: "no-store" })
43
+ .then((r) => r.json())
44
+ .then((st) => {
45
+ if (st && st.ok && st.firstRun && Array.isArray(st.sources) && st.sources.some((s: any) => s.available)) {
46
+ setDialogOpen(true);
47
+ }
48
+ })
49
+ .catch(() => { /* 状态不可得就不弹(手动入口仍在) */ });
50
+ } catch { /* 极端环境防御 */ }
51
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * host-shim —— JSX client bundle 的宿主桥(共享模板通用版)。
3
+ *
4
+ * 为什么需要它:浏览器不认识 JSX,扩展 client 要写 JSX 必须经过 esbuild 预编译
5
+ * (vite lib mode)。esbuild 的 JSX 自动运行时(jsx: "automatic")会生成
6
+ * `jsx(type, props)` 调用,默认指向 React。但 pi-web 的扩展 client 不是 React——
7
+ * 宿主经 register(ctx) 传入的是自己的渲染原语(h / useState / useEffect / apiUrl…)。
8
+ *
9
+ * 所以这里用 vite 的 resolve.alias 把 react / react/jsx-runtime 重定向到本 shim:
10
+ * 组件里 import react 的 hooks → 本 shim 的 createElement/useState/… → 委托 ctx。
11
+ * 产物里零 React 引用,宿主保持单渲染实例,npm 生态组件(只要用标准 react hooks)
12
+ * 也能直接打包进来用。
13
+ *
14
+ * 使用:entry.tsx 的 register(ctx) 里第一件事调 `__setCtx(ctx)`(任何 JSX 渲染前)。
15
+ * 需要的字段(apiUrl/t/useRef/useMemo…)按需在这里加一行委托即可。
16
+ */
17
+
18
+ // 模块级 ctx(entry.tsx 的 register 时设置;组件渲染时读取)
19
+ let _ctx: Record<string, unknown> | null = null;
20
+ export function __setCtx(ctx: Record<string, unknown>): void { _ctx = ctx; }
21
+ function c<T = (...args: never[]) => unknown>(name: string): T {
22
+ return (_ctx?.[name] as T) ?? (() => undefined as never);
23
+ }
24
+
25
+ // ---------- react 兼容导出(esbuild alias react → 本 shim) ----------
26
+ // React 的 Fragment 类型是 Symbol.for("react.fragment")——传给宿主 h 即生成真 Fragment。
27
+ export const Fragment = (typeof Symbol !== "undefined" && Symbol.for) ? Symbol.for("react.fragment") : 0xacb;
28
+ export function createElement(...args: unknown[]): unknown {
29
+ return c("h").call(null, ...args);
30
+ }
31
+ // 自动运行时 jsx(type, props, key)——key 是独立第三参;宿主 h() = React.createElement
32
+ // 的第三参是 **children**。直接透传会把 key 当子元素(元素 key 丢失 + key 值覆盖
33
+ // props.children 成为真实内容)。因此必须把 key 合并回 props。
34
+ function mergeKey(args: unknown[]): unknown[] {
35
+ const [type, props, key, ...rest] = args;
36
+ if (props && typeof props === "object") {
37
+ // key 一律合并进 props,createElement 恒收 (type, config) 两参:
38
+ // 有 key → config.key 由 React 读为元素 key;key 为 undefined/null → 也必须剥掉第三参——
39
+ // 否则 createElement(type, config, undefined) 按 legacy 语义(第三参=children)用 undefined
40
+ // 覆盖 props.children,元素内容整体丢失(实证:aiti-editor ⋯ 菜单 key=undefined 的项全部空白)。
41
+ const merged: Record<string, unknown> = { ...(props as Record<string, unknown>) };
42
+ if (key !== undefined && key !== null) merged.key = key;
43
+ return [type, merged, ...rest];
44
+ }
45
+ return args;
46
+ }
47
+ export const jsx = (...args: unknown[]) => createElement(...mergeKey(args));
48
+ export const jsxs = (...args: unknown[]) => {
49
+ // React 自动运行时语义:jsxs(静态多子元素)创建时会对每个子元素调用
50
+ // validateChildKeys → 标记 _store.validated = 1("已校验,勿再告警")。宿主 h()
51
+ // 是 React.createElement,但它只创建元素、不做这个标记——不补的话,
52
+ // `<div><A/><B/></div>` 这类“静态兄弟元素”(JSX 里无需写 key,React 自动运行时
53
+ // 本应抑制该警告)会被 React 误报 “Each child in a list should have a unique key”
54
+ // (dev-only,按 owner 去重一次)。值必须是 1(2 是另一特殊标记,仍会告警)。
55
+ const props = (args as [unknown, { children?: unknown } | undefined, ...unknown[]])[1];
56
+ const children = props?.children;
57
+ if (Array.isArray(children)) {
58
+ for (const c of children) {
59
+ if (c && typeof c === "object" && (c as { _store?: { validated?: number } })._store) {
60
+ (c as { _store: { validated: number } })._store.validated = 1;
61
+ }
62
+ }
63
+ }
64
+ return createElement(...mergeKey(args));
65
+ };
66
+
67
+ // ---------- React hooks 委托(按需增删) ----------
68
+ export const useState = <T>(init: T | (() => T)) => c<(...a: unknown[]) => [T, (v: T | ((prev: T) => T)) => void]>("useState")(init);
69
+ export const useEffect = (fn: () => unknown, deps?: unknown[]) => c("useEffect")(fn, deps);
70
+ export const useRef = <T>(v: T) => c<(...a: unknown[]) => { current: T }>("useRef")(v);
71
+ export const useCallback = <T extends (...args: never[]) => unknown>(fn: T, deps?: unknown[]) => c<(...a: unknown[]) => T>("useCallback")(fn, deps);
72
+ export const useMemo = <T>(fn: () => T, deps?: unknown[]) => c<(...a: unknown[]) => T>("useMemo")(fn, deps);
73
+
74
+ // ---------- 宿主工具委托(按需增删) ----------
75
+ /** apiUrl(p):把相对路径拼成当前 basePath 下的完整 API URL(多实例 /dev 前缀安全)。 */
76
+ export function apiUrl(p: string): string {
77
+ const fn = _ctx?.apiUrl as ((p: string) => string) | undefined;
78
+ return typeof fn === "function" ? fn(p) : p;
79
+ }
80
+ /** t(key, params?):扩展 i18n 翻译(index.ts ui.i18n 里的词条)。 */
81
+ export function t(key: string, params?: Record<string, unknown>): string {
82
+ const fn = _ctx?.t as ((key: string, params?: Record<string, unknown>) => string) | undefined;
83
+ return typeof fn === "function" ? fn(key, params) : key;
84
+ }
85
+ /** 跨扩展客户端服务读取(ctx.getExtService;提供方懒加载/缺席 → undefined,消费方必须降级)。 */
86
+ export function extSvc<T = unknown>(key: string): T | undefined {
87
+ return (_ctx as any)?.getExtService?.(key) as T | undefined;
88
+ }