@kne/fastify-live-components-site 0.1.1 → 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/README.md +2 -1
- package/libs/ai/catalog.js +53 -28
- package/libs/ai/extract-props.js +61 -0
- package/libs/ai/prompts.js +80 -16
- package/libs/ai/run.js +58 -3
- package/libs/controllers/entry.js +28 -0
- package/libs/services/ai.js +8 -2
- package/libs/services/node.js +55 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -111,6 +111,7 @@ npm i --save @kne/fastify-live-components-site
|
|
|
111
111
|
messages: [{ role: 'user'|'assistant', content: string }],
|
|
112
112
|
content?: string,
|
|
113
113
|
scope?: object,
|
|
114
|
+
props?: object,
|
|
114
115
|
libs?: string[],
|
|
115
116
|
remotes?: [{ name, version, packageName? }],
|
|
116
117
|
selection?: { startLine, endLine, code, label }
|
|
@@ -120,7 +121,7 @@ npm i --save @kne/fastify-live-components-site
|
|
|
120
121
|
返回 `{ streamToken }`。随后 `GET {host}/ai/stream?token=` 以 SSE 推送:
|
|
121
122
|
|
|
122
123
|
- `{ text, done: false }` 增量文本
|
|
123
|
-
- `{ done: true, suggestedScope? }`
|
|
124
|
+
- `{ done: true, suggestedScope?, suggestedProps? }` 结束(生成阶段可通过工具声明组件域 / 组件参数)
|
|
124
125
|
|
|
125
126
|
前端从 `@kne/remote-loader` preset 收集 `remotes`,并从当前环境合并 `libs` 键名一并上传。服务端按「库名/版本」缓存组件文档索引(`load-npm-info` + `md-doc`)。
|
|
126
127
|
|
package/libs/ai/catalog.js
CHANGED
|
@@ -59,6 +59,7 @@ const readCachedCatalog = async (dir, remoteName, version) => {
|
|
|
59
59
|
remote: remoteName,
|
|
60
60
|
version,
|
|
61
61
|
packageName: meta.packageName,
|
|
62
|
+
meta,
|
|
62
63
|
index,
|
|
63
64
|
components
|
|
64
65
|
};
|
|
@@ -110,10 +111,24 @@ const ensureRemoteCatalog = async (remote, options) => {
|
|
|
110
111
|
|
|
111
112
|
const dir = getCacheDir(options, name, version);
|
|
112
113
|
if (isCacheComplete(dir)) {
|
|
113
|
-
|
|
114
|
+
const cached = await readCachedCatalog(dir, name, version);
|
|
115
|
+
if ((cached.index || []).length || Object.keys(cached.components || {}).length) {
|
|
116
|
+
return cached;
|
|
117
|
+
}
|
|
114
118
|
}
|
|
115
119
|
|
|
116
120
|
const packageName = resolvePackageName(remote, options);
|
|
121
|
+
const writeEmpty = async error => {
|
|
122
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
123
|
+
const empty = { index: [], components: {} };
|
|
124
|
+
await Promise.all([
|
|
125
|
+
fsp.writeFile(path.join(dir, 'meta.json'), JSON.stringify({ packageName, version, error, builtAt: Date.now() }, null, 2)),
|
|
126
|
+
fsp.writeFile(path.join(dir, 'index.json'), JSON.stringify(empty.index, null, 2)),
|
|
127
|
+
fsp.writeFile(path.join(dir, 'components.json'), JSON.stringify(empty.components, null, 2))
|
|
128
|
+
]);
|
|
129
|
+
return { remote: name, version, packageName, error, ...empty };
|
|
130
|
+
};
|
|
131
|
+
|
|
117
132
|
const loadOpts = {};
|
|
118
133
|
if (options.ai?.npmRegistry) {
|
|
119
134
|
loadOpts.registry = options.ai.npmRegistry;
|
|
@@ -125,43 +140,53 @@ const ensureRemoteCatalog = async (remote, options) => {
|
|
|
125
140
|
info = await loadNpmInfo(pkgSpec, loadOpts);
|
|
126
141
|
} catch (e) {
|
|
127
142
|
console.warn(`[ai-catalog] load-npm-info failed for ${packageName}@${version}: ${e.message}`);
|
|
143
|
+
return writeEmpty(e.message);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const { index, components } = buildCatalogFromReadme(info.readme, name);
|
|
128
148
|
await fsp.mkdir(dir, { recursive: true });
|
|
129
|
-
const empty = { index: [], components: {} };
|
|
130
149
|
await Promise.all([
|
|
131
|
-
fsp.writeFile(path.join(dir, 'meta.json'), JSON.stringify({ packageName, version
|
|
132
|
-
fsp.writeFile(path.join(dir, 'index.json'), JSON.stringify(
|
|
133
|
-
fsp.writeFile(path.join(dir, 'components.json'), JSON.stringify(
|
|
150
|
+
fsp.writeFile(path.join(dir, 'meta.json'), JSON.stringify({ packageName, version: info.version || version, builtAt: Date.now() }, null, 2)),
|
|
151
|
+
fsp.writeFile(path.join(dir, 'index.json'), JSON.stringify(index, null, 2)),
|
|
152
|
+
fsp.writeFile(path.join(dir, 'components.json'), JSON.stringify(components, null, 2))
|
|
134
153
|
]);
|
|
135
|
-
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
remote: name,
|
|
157
|
+
version: info.version || version,
|
|
158
|
+
packageName,
|
|
159
|
+
index,
|
|
160
|
+
components
|
|
161
|
+
};
|
|
162
|
+
} catch (e) {
|
|
163
|
+
console.warn(`[ai-catalog] build failed for ${packageName}@${version}: ${e.message}`);
|
|
164
|
+
return writeEmpty(e.message);
|
|
136
165
|
}
|
|
166
|
+
};
|
|
137
167
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
168
|
+
const withTimeout = (promise, ms, label) => {
|
|
169
|
+
let timer;
|
|
170
|
+
return Promise.race([
|
|
171
|
+
Promise.resolve(promise).finally(() => clearTimeout(timer)),
|
|
172
|
+
new Promise((_, reject) => {
|
|
173
|
+
timer = setTimeout(() => reject(new Error(`${label} timeout after ${ms}ms`)), ms);
|
|
174
|
+
})
|
|
144
175
|
]);
|
|
145
|
-
|
|
146
|
-
return {
|
|
147
|
-
remote: name,
|
|
148
|
-
version: info.version || version,
|
|
149
|
-
packageName,
|
|
150
|
-
index,
|
|
151
|
-
components
|
|
152
|
-
};
|
|
153
176
|
};
|
|
154
177
|
|
|
155
178
|
const ensureRemotesCatalogs = async (remotes, options) => {
|
|
156
179
|
const list = Array.isArray(remotes) ? remotes : [];
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
180
|
+
const timeoutMs = Number(options.ai?.catalogTimeoutMs) > 0 ? Number(options.ai.catalogTimeoutMs) : 20000;
|
|
181
|
+
const results = await Promise.all(
|
|
182
|
+
list.map(remote =>
|
|
183
|
+
withTimeout(ensureRemoteCatalog(remote, options), timeoutMs, remote?.name || 'remote').catch(e => {
|
|
184
|
+
console.warn(`[ai-catalog] ${remote?.name || '?'}: ${e.message}`);
|
|
185
|
+
return null;
|
|
186
|
+
})
|
|
187
|
+
)
|
|
188
|
+
);
|
|
189
|
+
return results.filter(Boolean);
|
|
165
190
|
};
|
|
166
191
|
|
|
167
192
|
const getExampleCode = (catalogs, { remote, version, component, exampleId }) => {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const escapeRegExp = value => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2
|
+
|
|
3
|
+
const inferPropDef = (code, name) => {
|
|
4
|
+
if (/^on[A-Z]/.test(name)) {
|
|
5
|
+
return { type: 'function', defaultValue: '()=>null' };
|
|
6
|
+
}
|
|
7
|
+
const access = `props(?:\\?\\.)?\\.${escapeRegExp(name)}`;
|
|
8
|
+
const orString = code.match(new RegExp(`${access}\\s*(?:\\|\\||\\?\\?)\\s*['"\`]([^'"\`]*)['"\`]`));
|
|
9
|
+
if (orString) {
|
|
10
|
+
return { type: 'string', defaultValue: orString[1] };
|
|
11
|
+
}
|
|
12
|
+
const orNum = code.match(new RegExp(`${access}\\s*(?:\\|\\||\\?\\?)\\s*(-?\\d+(?:\\.\\d+)?)`));
|
|
13
|
+
if (orNum) {
|
|
14
|
+
return { type: 'number', defaultValue: orNum[1] };
|
|
15
|
+
}
|
|
16
|
+
const orBool = code.match(new RegExp(`${access}\\s*(?:\\|\\||\\?\\?)\\s*(true|false)\\b`));
|
|
17
|
+
if (orBool) {
|
|
18
|
+
return { type: 'boolean', defaultValue: orBool[1] };
|
|
19
|
+
}
|
|
20
|
+
if (new RegExp(`${access}\\s*(?:\\|\\||\\?\\?)\\s*\\[`).test(code)) {
|
|
21
|
+
return { type: 'array', defaultValue: '[]' };
|
|
22
|
+
}
|
|
23
|
+
if (new RegExp(`${access}\\s*(?:\\|\\||\\?\\?)\\s*\\{`).test(code)) {
|
|
24
|
+
return { type: 'object', defaultValue: '{}' };
|
|
25
|
+
}
|
|
26
|
+
return { type: 'string', defaultValue: '' };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** 从生成代码中扫描 props.xxx,补齐 suggestProps 可能漏掉的声明 */
|
|
30
|
+
const extractPropsFromCode = (code, existingProps = {}) => {
|
|
31
|
+
const text = String(code || '');
|
|
32
|
+
if (!text.trim()) {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
// 去掉 markdown 围栏外壳再扫,避免漏检
|
|
36
|
+
const stripped = text.replace(/^```(?:jsx|javascript|js|tsx|ts)?\s*/i, '').replace(/```\s*$/i, '');
|
|
37
|
+
const names = new Set();
|
|
38
|
+
const re = /props(?:\?\.)?\.([A-Za-z_$][\w$]*)|props\[\s*['"`]([^'"`]+)['"`]\s*\]/g;
|
|
39
|
+
let match;
|
|
40
|
+
while ((match = re.exec(stripped))) {
|
|
41
|
+
const name = match[1] || match[2];
|
|
42
|
+
if (name) {
|
|
43
|
+
names.add(name);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const result = {};
|
|
47
|
+
names.forEach(name => {
|
|
48
|
+
if (existingProps && existingProps[name]) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
result[name] = inferPropDef(stripped, name);
|
|
52
|
+
});
|
|
53
|
+
return result;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const mergeSuggestedProps = (code, suggestedProps, existingProps = {}) => Object.assign({}, extractPropsFromCode(code, existingProps), suggestedProps && typeof suggestedProps === 'object' ? suggestedProps : {});
|
|
57
|
+
|
|
58
|
+
module.exports = {
|
|
59
|
+
extractPropsFromCode,
|
|
60
|
+
mergeSuggestedProps
|
|
61
|
+
};
|
package/libs/ai/prompts.js
CHANGED
|
@@ -7,19 +7,55 @@ const stripHtml = html =>
|
|
|
7
7
|
const LIVE_COMPONENT_SPEC = `
|
|
8
8
|
## Live 组件规范(必须遵守)
|
|
9
9
|
|
|
10
|
-
1. 编辑对象是配置里的 content 字段:一段 JSX
|
|
11
|
-
2. 运行时会编译为 render(<content>)
|
|
10
|
+
1. 编辑对象是配置里的 content 字段:一段 **JSX 表达式**源码,不是完整 .js 模块,也不是 PlantUML 编码串。
|
|
11
|
+
2. 运行时会编译为 \`render(<content>);\`,因此 content **只能是可放入 JSX 子节点位置的表达式**(通常是一个根 JSX 元素,或用 \`<>...</>\` / \`(...)\` 包裹的表达式)。
|
|
12
|
+
- **禁止**写 \`import\` / \`export\` / 外层 \`render(\`。
|
|
13
|
+
- **禁止**在 content 里写 JS 语句:\`const\` / \`let\` / \`var\` / \`function\` / \`class\` / \`return\` / \`if\` 语句块等。
|
|
14
|
+
- **禁止**伪代码块:\`<>{ const x = 1; return (<div/>); }</>\` 或 IIFE \`(()=>{...})()\`(除非整段仍是纯表达式且无语句;默认不要用)。
|
|
15
|
+
- 需要临时数据时,把数组/对象 **内联**在 JSX 属性里,例如 \`options={[{ label: '男', value: '男' }]}\`。
|
|
12
16
|
3. 运行时可用标识:
|
|
13
|
-
- React、Antd(antd
|
|
17
|
+
- React、Antd(antd 全量,但表单场景有额外禁令,见第 8 条)、props(合并后的组件参数)
|
|
14
18
|
- scope 声明的变量名(如 FormInfo ← components-core:FormInfo),另有 PureGlobal
|
|
15
19
|
- libs:仅限当前请求告知的 libKeys,禁止使用未列出的 lib 标识符
|
|
16
20
|
4. 组件选用优先级(强制):
|
|
17
21
|
1) 远程组件优先(本次 remotes 索引 / 当前 scope 中已有)
|
|
18
|
-
2) 其次 Antd
|
|
22
|
+
2) 其次 Antd.*(**表单除外,见第 8 条**)
|
|
19
23
|
3) 最后原生 HTML;禁止用 HTML 重复实现已有远程或 Antd 能力
|
|
20
|
-
5. props
|
|
24
|
+
5. props 与 scope(生成时必须显式声明,不能只写在 content 里假设已存在):
|
|
25
|
+
- props 形态:\`{ name: { type: 'string'|'number'|'boolean'|'array'|'object'|'function', defaultValue } }\`,代码里用 \`props.xxx\`。
|
|
26
|
+
- scope 形态:\`{ 变量名: 'moduleToken' }\`(如 \`FormInfo: 'components-core:FormInfo'\`)。
|
|
27
|
+
- 使用了当前 scope 没有的远程组件 → **必须**调用 \`suggestScope\`。
|
|
28
|
+
- content 使用了 \`props.xxx\` 且当前 props 没有该字段 → **必须**调用 \`suggestProps\` 补齐声明(含 type 与合理 defaultValue;function 类型 defaultValue 用 \`'()=>null'\`)。
|
|
21
29
|
6. 使用 FormInfo 时字段须落在 <FormInfo.Form> 上下文中(已有则保持;缺失可包一层)。
|
|
22
30
|
7. 保持现有代码风格;不引入 npm 包名式 import。文档示例中的 _FormInfo / @components/FormInfo 在 Live 中应写成 scope 变量 FormInfo + token components-core:FormInfo。
|
|
31
|
+
8. **表单强制规范(最高优先级,覆盖第 4 条的 Antd 选型)**:
|
|
32
|
+
- 凡涉及表单(录入、校验、提交、表单项布局),**必须**用 \`components-core:FormInfo\` 实现,并确保 scope 含 \`FormInfo: 'components-core:FormInfo'\`(可用 suggestScope)。
|
|
33
|
+
- **禁止**使用 \`Antd.Form\` / \`Antd.Form.Item\` / \`Antd.Form.List\` 以及把 \`Antd.Input\` / \`Antd.Select\` / \`Antd.DatePicker\` 等当作表单字段直接拼表单。
|
|
34
|
+
- **禁止** \`const { Input } = FormInfo.fields\` 这类解构(属于 JS 语句,Live 无法运行)。字段必须写成 \`FormInfo.fields.Input\` / \`FormInfo.fields.Select\` 等。
|
|
35
|
+
- 标准写法示例(注意整段都是 JSX,无 const/return):
|
|
36
|
+
\`\`\`jsx
|
|
37
|
+
<FormInfo.Form data={{}}>
|
|
38
|
+
<FormInfo
|
|
39
|
+
title="基础信息"
|
|
40
|
+
list={[
|
|
41
|
+
<FormInfo.fields.Input name="name" label="姓名" rule="REQ" placeholder="请输入姓名" />,
|
|
42
|
+
<FormInfo.fields.Select
|
|
43
|
+
name="gender"
|
|
44
|
+
label="性别"
|
|
45
|
+
rule="REQ"
|
|
46
|
+
options={[
|
|
47
|
+
{ label: '男', value: '男' },
|
|
48
|
+
{ label: '女', value: '女' }
|
|
49
|
+
]}
|
|
50
|
+
placeholder="请选择性别"
|
|
51
|
+
/>
|
|
52
|
+
]}
|
|
53
|
+
/>
|
|
54
|
+
<FormInfo.SubmitButton>提交</FormInfo.SubmitButton>
|
|
55
|
+
</FormInfo.Form>
|
|
56
|
+
\`\`\`
|
|
57
|
+
- 字段必须带 FormInfo 字段契约:\`name\`、\`label\`,校验用 \`rule\` 字符串(如 \`REQ\`、\`REQ EMAIL\`、\`LEN-3-50\`),需要自定义规则时走 FormInfo.Form 的 \`rules\` / \`interceptors\`,不要用 Antd rules。
|
|
58
|
+
- **自定义字段类型**:不得手写 Antd Form.Item 包装。必须符合 FormInfo 字段规范——优先复用 \`FormInfo.fields.*\`;若确需自定义,只能用表达式方式挂到已有字段能力上,且仍出现在 \`FormInfo\` 的 \`list\` 与 \`FormInfo.Form\` 上下文中(不要在 content 里声明新组件函数)。
|
|
23
59
|
`.trim();
|
|
24
60
|
|
|
25
61
|
const buildLibsSection = libKeys => {
|
|
@@ -91,6 +127,10 @@ const collectHitComponentNames = ({ messages = [], scope = {}, catalogs = [] })
|
|
|
91
127
|
}
|
|
92
128
|
});
|
|
93
129
|
});
|
|
130
|
+
// 表单相关需求强制带上 FormInfo 文档命中,便于注入 L2
|
|
131
|
+
if (/表单|校验|FormInfo|Form\.Item|录入|提交|字段/.test(text)) {
|
|
132
|
+
hits.add('FormInfo');
|
|
133
|
+
}
|
|
94
134
|
return Array.from(hits);
|
|
95
135
|
};
|
|
96
136
|
|
|
@@ -106,38 +146,62 @@ const buildRefineSystemPrompt = ctx => {
|
|
|
106
146
|
detail,
|
|
107
147
|
hasSelection ? `当前讨论范围限定在选中组件:${selectionLabel || '已选中片段'}。` : '当前讨论整份 content。',
|
|
108
148
|
`行为规则:
|
|
109
|
-
1. 推荐组件时遵守:远程 → Antd → HTML。
|
|
110
|
-
2.
|
|
111
|
-
3.
|
|
112
|
-
4.
|
|
113
|
-
5.
|
|
149
|
+
1. 推荐组件时遵守:远程 → Antd → HTML;但**凡表单必须推荐 FormInfo**,禁止建议 Antd.Form / Form.Item。
|
|
150
|
+
2. 涉及表单时提醒:字段写 FormInfo.fields.*(禁止解构 const),options 等数据内联在 JSX 属性;必须是纯 JSX 表达式;scope 需 FormInfo。
|
|
151
|
+
3. 涉及 UI 结构时,必须用 **HTML 线框示意**给出 2~4 个方案(A/B/C…),简述差异,请用户选择;**禁止 ASCII 字符画**。HTML 必须放在 Markdown 的 \`\`\`html 围栏中(前端会渲染为真实 DOM,不要指望当代码展示)。示例外层可用 class="ui-mock" 或 "layout-mock"。回复整体使用 Markdown。选定前不要宣称需求已确认。
|
|
152
|
+
4. 信息与布局都够了后,用短结构化小结(目标 / 改动点 / 选用布局 / 建议组件 / 是否动 props·scope),并明确提示用户点击界面上的「开始生成」按钮。
|
|
153
|
+
5. 不要暗示会自动写代码或自行进入生成阶段。
|
|
154
|
+
6. 不要输出完整组件实现(可运行 JSX)。`
|
|
114
155
|
]
|
|
115
156
|
.filter(Boolean)
|
|
116
157
|
.join('\n\n');
|
|
117
158
|
};
|
|
118
159
|
|
|
160
|
+
const buildCurrentPropsSection = props => {
|
|
161
|
+
const keys = props && typeof props === 'object' ? Object.keys(props) : [];
|
|
162
|
+
if (!keys.length) {
|
|
163
|
+
return '当前组件参数 props:(空)若 content 使用 props.xxx,必须 suggestProps 声明。';
|
|
164
|
+
}
|
|
165
|
+
return `当前组件参数 props:\n${JSON.stringify(props, null, 2)}\n若新增 props.xxx,必须 suggestProps 补齐未声明字段。`;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const buildCurrentScopeSection = scope => {
|
|
169
|
+
const keys = scope && typeof scope === 'object' ? Object.keys(scope) : [];
|
|
170
|
+
if (!keys.length) {
|
|
171
|
+
return '当前组件域 scope:(空)若使用远程组件,必须 suggestScope 声明。';
|
|
172
|
+
}
|
|
173
|
+
return `当前组件域 scope:\n${JSON.stringify(scope, null, 2)}\n若使用未列出的远程组件,必须 suggestScope 补齐。`;
|
|
174
|
+
};
|
|
175
|
+
|
|
119
176
|
const buildGenerateSystemPrompt = ctx => {
|
|
120
|
-
const { libKeys = [], catalogs = [], messages = [], scope = {}, hasSelection, selectionLabel } = ctx;
|
|
177
|
+
const { libKeys = [], catalogs = [], messages = [], scope = {}, props = {}, hasSelection, selectionLabel } = ctx;
|
|
121
178
|
const hits = collectHitComponentNames({ messages, scope, catalogs });
|
|
122
179
|
// generate:对命中组件 + 索引前列组件提供详情;若无命中则不塞过多
|
|
123
180
|
const detailNames = hits.length ? hits : (catalogs[0]?.index || []).slice(0, 5).map(i => i.name);
|
|
124
181
|
const detail = buildComponentDetailSection(catalogs, detailNames);
|
|
125
182
|
return [
|
|
126
|
-
'你是 Live content
|
|
183
|
+
'你是 Live content 代码生成器。根据已完善的需求对话,一次产出可直接写入编辑器的源码,并同步声明所需的 scope / props。',
|
|
127
184
|
LIVE_COMPONENT_SPEC,
|
|
128
185
|
buildLibsSection(libKeys),
|
|
186
|
+
buildCurrentScopeSection(scope),
|
|
187
|
+
buildCurrentPropsSection(props),
|
|
129
188
|
buildCatalogIndexSection(catalogs),
|
|
130
189
|
detail,
|
|
131
190
|
hasSelection ? `只输出替换后的选中片段源码(选中:${selectionLabel || ''})。不要输出整文件。` : '输出替换后的整份 content 源码。',
|
|
132
191
|
`工具:
|
|
133
192
|
- getComponentExample:按示例 id 读取完整示例 code(先看示例目录的 description 再决定是否拉取)。
|
|
134
|
-
- suggestScope
|
|
193
|
+
- suggestScope:声明需要合并进编辑器「组件域」的 scope,形如 { FormInfo: 'components-core:FormInfo' }。
|
|
194
|
+
- suggestProps:声明需要合并进编辑器「组件参数」的 props,形如 { title: { type: 'string', defaultValue: '标题' } }。
|
|
135
195
|
|
|
136
196
|
输出契约:
|
|
137
197
|
1. 优先纯代码;不要追问、不要复述需求、不要解释。
|
|
138
|
-
2.
|
|
139
|
-
3.
|
|
140
|
-
4.
|
|
198
|
+
2. **输出必须是纯 JSX 表达式**(可被 \`render(<content>);\` 包裹);禁止 const/let/return/函数声明,禁止 \`<>{ const ...; return (...); }</>\`。
|
|
199
|
+
3. 选型:远程 → Antd → HTML;**表单必须 FormInfo,禁止 Antd.Form / Form.Item**;字段用 \`FormInfo.fields.Xxx\`,options 内联在 JSX 属性中。
|
|
200
|
+
4. 只能使用本次 libs 键与 scope∪suggestedScope;用到 FormInfo 时必须 suggestScope(若 scope 尚无)。
|
|
201
|
+
5. content 中凡出现 props.xxx,必须已在当前 props 或本次 suggestProps 中声明;不要虚构未声明参数。
|
|
202
|
+
6. 自定义表单字段优先复用 FormInfo.fields;禁止在 content 里新声明组件函数。
|
|
203
|
+
7. 需要官方示例时根据 description 挑选后调用 getComponentExample(优先 FormInfo);仿写时把示例里的 \`const { Input } = fields\` 改成 \`FormInfo.fields.Input\`。
|
|
204
|
+
8. **工具调用结束后必须再输出完整 JSX 正文**;禁止只调 suggestScope/suggestProps/getComponentExample 而不写代码。不要包 Markdown 说明;若使用代码围栏,仅允许一个 \`\`\`jsx 围栏且围栏内是完整可运行 JSX。`
|
|
141
205
|
]
|
|
142
206
|
.filter(Boolean)
|
|
143
207
|
.join('\n\n');
|
package/libs/ai/run.js
CHANGED
|
@@ -2,6 +2,7 @@ const { streamText, tool, stepCountIs } = require('ai');
|
|
|
2
2
|
const { z } = require('zod');
|
|
3
3
|
const { buildRefineSystemPrompt, buildGenerateSystemPrompt } = require('./prompts');
|
|
4
4
|
const { getExampleCode } = require('./catalog');
|
|
5
|
+
const { mergeSuggestedProps } = require('./extract-props');
|
|
5
6
|
|
|
6
7
|
const writeSse = (reply, data) => {
|
|
7
8
|
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
@@ -20,6 +21,9 @@ const buildUserMessages = job => {
|
|
|
20
21
|
if (job.scope && Object.keys(job.scope).length) {
|
|
21
22
|
parts.push(`[当前 scope]\n${JSON.stringify(job.scope, null, 2)}`);
|
|
22
23
|
}
|
|
24
|
+
if (job.props && Object.keys(job.props).length) {
|
|
25
|
+
parts.push(`[当前 props]\n${JSON.stringify(job.props, null, 2)}`);
|
|
26
|
+
}
|
|
23
27
|
if (parts.length) {
|
|
24
28
|
messages.push({ role: 'user', content: parts.join('\n\n') });
|
|
25
29
|
}
|
|
@@ -34,6 +38,11 @@ const buildUserMessages = job => {
|
|
|
34
38
|
return messages;
|
|
35
39
|
};
|
|
36
40
|
|
|
41
|
+
const propDefSchema = z.object({
|
|
42
|
+
type: z.enum(['string', 'number', 'boolean', 'array', 'object', 'function']),
|
|
43
|
+
defaultValue: z.any().optional()
|
|
44
|
+
});
|
|
45
|
+
|
|
37
46
|
const runAiStream = async (job, reply) => {
|
|
38
47
|
const model = await job.getAIModel();
|
|
39
48
|
if (!model) {
|
|
@@ -49,11 +58,14 @@ const runAiStream = async (job, reply) => {
|
|
|
49
58
|
libKeys: job.libs || [],
|
|
50
59
|
catalogs: job.catalogs || [],
|
|
51
60
|
messages: job.messages || [],
|
|
52
|
-
scope: job.scope || {}
|
|
61
|
+
scope: job.scope || {},
|
|
62
|
+
props: job.props || {}
|
|
53
63
|
};
|
|
54
64
|
|
|
55
65
|
const system = job.mode === 'generate' ? buildGenerateSystemPrompt(ctx) : buildRefineSystemPrompt(ctx);
|
|
56
66
|
let suggestedScope = {};
|
|
67
|
+
let suggestedProps = {};
|
|
68
|
+
let streamedText = '';
|
|
57
69
|
|
|
58
70
|
const exampleTool = tool({
|
|
59
71
|
description: job.mode === 'generate' ? '按示例 id 读取远程组件文档中的完整示例代码' : '可选:查看某示例代码以便更准确描述需求(refine 阶段仍不要输出业务实现代码)',
|
|
@@ -73,7 +85,7 @@ const runAiStream = async (job, reply) => {
|
|
|
73
85
|
? {
|
|
74
86
|
getComponentExample: exampleTool,
|
|
75
87
|
suggestScope: tool({
|
|
76
|
-
description: '
|
|
88
|
+
description: '声明需要合并进编辑器「组件域」的 scope(远程组件 token)',
|
|
77
89
|
inputSchema: z.object({
|
|
78
90
|
scope: z.record(z.string())
|
|
79
91
|
}),
|
|
@@ -81,6 +93,16 @@ const runAiStream = async (job, reply) => {
|
|
|
81
93
|
suggestedScope = Object.assign({}, suggestedScope, scope || {});
|
|
82
94
|
return { ok: true, suggestedScope };
|
|
83
95
|
}
|
|
96
|
+
}),
|
|
97
|
+
suggestProps: tool({
|
|
98
|
+
description: '声明需要合并进编辑器「组件参数」的 props;content 使用 props.xxx 时必须调用。形如 { title: { type: "string", defaultValue: "标题" } }',
|
|
99
|
+
inputSchema: z.object({
|
|
100
|
+
props: z.record(propDefSchema)
|
|
101
|
+
}),
|
|
102
|
+
execute: async ({ props }) => {
|
|
103
|
+
suggestedProps = Object.assign({}, suggestedProps, props || {});
|
|
104
|
+
return { ok: true, suggestedProps };
|
|
105
|
+
}
|
|
84
106
|
})
|
|
85
107
|
}
|
|
86
108
|
: {
|
|
@@ -97,17 +119,50 @@ const runAiStream = async (job, reply) => {
|
|
|
97
119
|
|
|
98
120
|
for await (const part of result.fullStream) {
|
|
99
121
|
if (part.type === 'text-delta') {
|
|
100
|
-
const text = part.text || '';
|
|
122
|
+
const text = part.text || part.delta || part.textDelta || '';
|
|
101
123
|
if (text) {
|
|
124
|
+
streamedText += text;
|
|
102
125
|
writeSse(reply, { text, done: false });
|
|
103
126
|
}
|
|
104
127
|
}
|
|
105
128
|
}
|
|
106
129
|
|
|
130
|
+
let finalText = streamedText;
|
|
131
|
+
try {
|
|
132
|
+
const fromResult = await result.text;
|
|
133
|
+
if (typeof fromResult === 'string' && fromResult) {
|
|
134
|
+
if (!streamedText) {
|
|
135
|
+
finalText = fromResult;
|
|
136
|
+
writeSse(reply, { text: fromResult, done: false });
|
|
137
|
+
} else if (fromResult.length > streamedText.length && fromResult.startsWith(streamedText)) {
|
|
138
|
+
const rest = fromResult.slice(streamedText.length);
|
|
139
|
+
finalText = fromResult;
|
|
140
|
+
if (rest) {
|
|
141
|
+
writeSse(reply, { text: rest, done: false });
|
|
142
|
+
}
|
|
143
|
+
} else if (!streamedText.trim() && fromResult.trim()) {
|
|
144
|
+
finalText = fromResult;
|
|
145
|
+
writeSse(reply, { text: fromResult, done: false });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} catch (e) {
|
|
149
|
+
// ignore text aggregation errors; streamedText may still be enough
|
|
150
|
+
}
|
|
151
|
+
|
|
107
152
|
const donePayload = { done: true };
|
|
153
|
+
if (!streamedText.trim() && finalText.trim()) {
|
|
154
|
+
donePayload.text = finalText;
|
|
155
|
+
}
|
|
108
156
|
if (Object.keys(suggestedScope).length) {
|
|
109
157
|
donePayload.suggestedScope = suggestedScope;
|
|
110
158
|
}
|
|
159
|
+
// 即使模型未调 suggestProps,也从生成代码扫描 props.xxx 补齐声明
|
|
160
|
+
if (job.mode === 'generate' && finalText.trim()) {
|
|
161
|
+
suggestedProps = mergeSuggestedProps(finalText, suggestedProps, job.props || {});
|
|
162
|
+
}
|
|
163
|
+
if (Object.keys(suggestedProps).length) {
|
|
164
|
+
donePayload.suggestedProps = suggestedProps;
|
|
165
|
+
}
|
|
111
166
|
writeSse(reply, donePayload);
|
|
112
167
|
};
|
|
113
168
|
|
|
@@ -368,6 +368,34 @@ module.exports = fp(async (fastify, options) => {
|
|
|
368
368
|
}
|
|
369
369
|
);
|
|
370
370
|
|
|
371
|
+
fastify.post(
|
|
372
|
+
`${options.prefix}/:shorten/move`,
|
|
373
|
+
{
|
|
374
|
+
onRequest: withEntryAuth,
|
|
375
|
+
schema: {
|
|
376
|
+
summary: '移动文件或文件夹到目标目录',
|
|
377
|
+
params: {
|
|
378
|
+
type: 'object',
|
|
379
|
+
required: ['shorten'],
|
|
380
|
+
properties: {
|
|
381
|
+
shorten: { type: 'string' }
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
body: {
|
|
385
|
+
type: 'object',
|
|
386
|
+
required: ['id'],
|
|
387
|
+
properties: {
|
|
388
|
+
id: { type: 'string' },
|
|
389
|
+
parentId: { type: ['string', 'null'] }
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
async request => {
|
|
395
|
+
return services.node.move(request.authenticatePayload, request.body);
|
|
396
|
+
}
|
|
397
|
+
);
|
|
398
|
+
|
|
371
399
|
fastify.post(
|
|
372
400
|
`${options.prefix}/:shorten/remove`,
|
|
373
401
|
{
|
package/libs/services/ai.js
CHANGED
|
@@ -36,6 +36,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
36
36
|
const remotes = Array.isArray(body.remotes) ? body.remotes : [];
|
|
37
37
|
const libs = Array.isArray(body.libs) ? body.libs.map(String) : [];
|
|
38
38
|
const scope = body.scope && typeof body.scope === 'object' ? body.scope : {};
|
|
39
|
+
const props = body.props && typeof body.props === 'object' ? body.props : {};
|
|
39
40
|
const content = typeof body.content === 'string' ? body.content : '';
|
|
40
41
|
const selection = body.selection && typeof body.selection === 'object' ? body.selection : null;
|
|
41
42
|
|
|
@@ -43,16 +44,18 @@ module.exports = fp(async (fastify, options) => {
|
|
|
43
44
|
throw new BadRequest('messages 不能为空');
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
// 不在 start 阻塞拉文档索引(易触发网关/代理 504);在 stream 阶段再确保
|
|
47
48
|
const streamToken = createStreamJob({
|
|
48
49
|
mode,
|
|
49
50
|
messages,
|
|
50
51
|
remotes,
|
|
51
52
|
libs,
|
|
52
53
|
scope,
|
|
54
|
+
props,
|
|
53
55
|
content,
|
|
54
56
|
selection,
|
|
55
|
-
catalogs,
|
|
57
|
+
catalogs: null,
|
|
58
|
+
catalogOptions: options,
|
|
56
59
|
getAIModel: options.ai.getAIModel
|
|
57
60
|
});
|
|
58
61
|
|
|
@@ -80,6 +83,9 @@ module.exports = fp(async (fastify, options) => {
|
|
|
80
83
|
});
|
|
81
84
|
|
|
82
85
|
try {
|
|
86
|
+
if (!job.catalogs) {
|
|
87
|
+
job.catalogs = await ensureRemotesCatalogs(job.remotes, job.catalogOptions || options);
|
|
88
|
+
}
|
|
83
89
|
await runAiStream(job, reply);
|
|
84
90
|
} catch (e) {
|
|
85
91
|
reply.raw.write(`data: ${JSON.stringify({ text: e.message || 'AI 流式失败', done: true })}\n\n`);
|
package/libs/services/node.js
CHANGED
|
@@ -227,6 +227,60 @@ module.exports = fp(async (fastify, options) => {
|
|
|
227
227
|
};
|
|
228
228
|
};
|
|
229
229
|
|
|
230
|
+
const isDescendantOf = async (siteId, ancestorId, maybeDescendantId) => {
|
|
231
|
+
let currentId = toId(maybeDescendantId);
|
|
232
|
+
const visited = new Set();
|
|
233
|
+
while (currentId) {
|
|
234
|
+
if (currentId === toId(ancestorId)) {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
if (visited.has(currentId)) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
visited.add(currentId);
|
|
241
|
+
const current = await models.node.findOne({
|
|
242
|
+
where: { id: currentId, siteId },
|
|
243
|
+
attributes: ['parentId']
|
|
244
|
+
});
|
|
245
|
+
currentId = toId(current?.parentId);
|
|
246
|
+
}
|
|
247
|
+
return false;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const move = async (authenticatePayload, { id, parentId }) => {
|
|
251
|
+
const siteId = getSiteId(authenticatePayload);
|
|
252
|
+
const node = await findNode(siteId, id);
|
|
253
|
+
assertWritable(node);
|
|
254
|
+
|
|
255
|
+
const targetParentId = toId(parentId);
|
|
256
|
+
const currentParentId = toId(node.parentId);
|
|
257
|
+
if (targetParentId === currentParentId) {
|
|
258
|
+
throw new BadRequest('INVALID_MOVE_TARGET');
|
|
259
|
+
}
|
|
260
|
+
if (targetParentId && targetParentId === toId(node.id)) {
|
|
261
|
+
throw new BadRequest('INVALID_MOVE_TARGET');
|
|
262
|
+
}
|
|
263
|
+
if (node.type === 'directory' && targetParentId) {
|
|
264
|
+
const looping = await isDescendantOf(siteId, node.id, targetParentId);
|
|
265
|
+
if (looping) {
|
|
266
|
+
throw new BadRequest('INVALID_MOVE_TARGET');
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
await resolveParent(siteId, targetParentId);
|
|
271
|
+
await assertUniqueSiblingName(siteId, targetParentId, node.name, node.id);
|
|
272
|
+
|
|
273
|
+
node.parentId = targetParentId;
|
|
274
|
+
await node.save();
|
|
275
|
+
return {
|
|
276
|
+
id: String(node.id),
|
|
277
|
+
name: node.name,
|
|
278
|
+
type: node.type,
|
|
279
|
+
permission: node.permission,
|
|
280
|
+
parentId: targetParentId
|
|
281
|
+
};
|
|
282
|
+
};
|
|
283
|
+
|
|
230
284
|
const remove = async (authenticatePayload, { ids }) => {
|
|
231
285
|
const siteId = getSiteId(authenticatePayload);
|
|
232
286
|
const idList = Array.isArray(ids) ? ids : [ids];
|
|
@@ -272,6 +326,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
272
326
|
create,
|
|
273
327
|
save,
|
|
274
328
|
rename,
|
|
329
|
+
move,
|
|
275
330
|
remove
|
|
276
331
|
}
|
|
277
332
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kne/fastify-live-components-site",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Fastify 插件:为 LiveComponentEditor 提供远程站点文件树与短链访问管理",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@kne/load-npm-info": "^0.1.8",
|
|
44
44
|
"@kne/md-doc": "^0.1.24",
|
|
45
|
-
"ai": "^
|
|
45
|
+
"ai": "^7.0.37",
|
|
46
46
|
"http-errors": "^2.0.0",
|
|
47
47
|
"lodash": "^4.17.21",
|
|
48
48
|
"zod": "^3.25.76"
|