@kne/fastify-live-components-site 0.1.0 → 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 CHANGED
@@ -42,6 +42,20 @@ npm i --save @kne/fastify-live-components-site
42
42
  | getContentHost | 内容短链基址(不含站点 shorten) | `() => string` | `` `${prefix}` `` → 访问路径 `` `${prefix}/content/{code}` `` |
43
43
  | getShortenServices | 注入 shorten 服务 | `() => services` | `() => fastify.shorten.services` |
44
44
  | getAuthenticate | 按功能返回鉴权中间件 | `(type) => middleware[]` | 见下表 |
45
+ | ai | AI 生成配置 | object | 见下表 |
46
+
47
+ ### ai
48
+
49
+ | 属性名 | 说明 | 类型 | 默认值 |
50
+ |-----|----|----|-----|
51
+ | getAIModel | 返回 ai-sdk LanguageModel;未配置则全站 `aiEnabled: false` | `() => LanguageModel \| Promise<LanguageModel>` | `null` |
52
+ | catalogCacheDir | 远程组件文档索引缓存目录(按库名/版本落盘) | string | 插件内 `data/ai-catalog` |
53
+ | npmRegistry | 传给 `@kne/load-npm-info` 的 registry;不传则用其默认 | string | - |
54
+ | remotePackageNames | remote 名 → npm 包名;默认 `` `@kne-components/${name}` `` | object | `{}` |
55
+
56
+ 站点 `options.aiEnabled === false` 时可关闭单站 AI(在 create/save 的 `options` 中配置)。
57
+
58
+ 提示词(refine/generate)仅插件内置,不允许外部传入。
45
59
 
46
60
  ### getAuthenticate(type)
47
61
 
@@ -72,6 +86,7 @@ npm i --save @kne/fastify-live-components-site
72
86
 
73
87
  | 方法 | 路径 | 说明 |
74
88
  |------|------|------|
89
+ | GET | `{host}/info` | `{ name, status, aiEnabled, defaultPermission }` |
75
90
  | GET | `{host}/getFolderTree` | 文件树 |
76
91
  | GET | `{host}/get?id=` | 读文件(JSON:`{ id, name, permission, content }`) |
77
92
  | GET | `{prefix}/content/{contentShorten}` | 直出已保存 content(`text/plain`,无 `{code,data}`;不带站点 shorten) |
@@ -83,6 +98,32 @@ npm i --save @kne/fastify-live-components-site
83
98
  | POST | `{host}/save` | `{ id, content }` |
84
99
  | POST | `{host}/rename` | `{ id, name }` |
85
100
  | POST | `{host}/remove` | `{ ids: string[] }` |
101
+ | POST | `{host}/ai/start` | 创建 AI 流任务;见下 |
102
+ | GET | `{host}/ai/stream?token=` | SSE 流式输出 |
103
+
104
+ ### AI(需 `options.ai.getAIModel`)
105
+
106
+ `POST {host}/ai/start` body:
107
+
108
+ ```js
109
+ {
110
+ mode: 'refine' | 'generate',
111
+ messages: [{ role: 'user'|'assistant', content: string }],
112
+ content?: string,
113
+ scope?: object,
114
+ props?: object,
115
+ libs?: string[],
116
+ remotes?: [{ name, version, packageName? }],
117
+ selection?: { startLine, endLine, code, label }
118
+ }
119
+ ```
120
+
121
+ 返回 `{ streamToken }`。随后 `GET {host}/ai/stream?token=` 以 SSE 推送:
122
+
123
+ - `{ text, done: false }` 增量文本
124
+ - `{ done: true, suggestedScope?, suggestedProps? }` 结束(生成阶段可通过工具声明组件域 / 组件参数)
125
+
126
+ 前端从 `@kne/remote-loader` preset 收集 `remotes`,并从当前环境合并 `libs` 键名一并上传。服务端按「库名/版本」缓存组件文档索引(`load-npm-info` + `md-doc`)。
86
127
 
87
128
  树节点:`{ id, name, type: 'file'|'directory', permission: 'r'|'rw', children? }`。
88
129
 
package/index.js CHANGED
@@ -17,6 +17,12 @@ module.exports = fp(
17
17
  getSiteHost: null,
18
18
  // 内容短链基址(不含站点 shorten),默认 `${prefix}` → `${prefix}/content/{code}`
19
19
  getContentHost: null,
20
+ ai: {
21
+ getAIModel: null,
22
+ catalogCacheDir: null,
23
+ npmRegistry: null,
24
+ remotePackageNames: {}
25
+ },
20
26
  getShortenServices: () => {
21
27
  if (!fastify.shorten) {
22
28
  throw new Error('fastify-shorten plugin must be registered before @kne/fastify-live-components-site, or set options.getShortenServices');
@@ -57,6 +63,15 @@ module.exports = fp(
57
63
  if (typeof options.getContentHost !== 'function') {
58
64
  options.getContentHost = () => options.prefix;
59
65
  }
66
+ options.ai = Object.assign(
67
+ {
68
+ getAIModel: null,
69
+ catalogCacheDir: null,
70
+ npmRegistry: null,
71
+ remotePackageNames: {}
72
+ },
73
+ options.ai || {}
74
+ );
60
75
 
61
76
  fastify.register(require('@kne/fastify-namespace'), {
62
77
  options,
@@ -0,0 +1,216 @@
1
+ const fs = require('node:fs');
2
+ const fsp = require('node:fs/promises');
3
+ const path = require('node:path');
4
+ const loadNpmInfo = require('@kne/load-npm-info');
5
+ const { parse: parseMdDoc } = require('@kne/md-doc');
6
+ const { stripHtml } = require('./prompts');
7
+
8
+ const DOC_MD_RE = /<!--START_SECTION:DOC_MD-->([\s\S]*?)<!--END_SECTION:DOC_MD-->/;
9
+
10
+ const extractDocMd = readme => {
11
+ if (!readme || typeof readme !== 'string') {
12
+ return '';
13
+ }
14
+ const m = readme.match(DOC_MD_RE);
15
+ return (m ? m[1] : readme).trim();
16
+ };
17
+
18
+ const splitComponentSections = docMd => {
19
+ if (!docMd) {
20
+ return [];
21
+ }
22
+ return docMd.split(/\n(?=# )/g).filter(s => s.startsWith('# '));
23
+ };
24
+
25
+ const resolvePackageName = (remote, options) => {
26
+ if (remote.packageName) {
27
+ return remote.packageName;
28
+ }
29
+ const map = options.ai?.remotePackageNames || {};
30
+ if (map[remote.name]) {
31
+ return map[remote.name];
32
+ }
33
+ return `@kne-components/${remote.name}`;
34
+ };
35
+
36
+ const getCacheDir = (options, remoteName, version) => {
37
+ const base = options.ai?.catalogCacheDir || path.resolve(__dirname, '../../data/ai-catalog');
38
+ return path.join(base, remoteName, version || 'latest');
39
+ };
40
+
41
+ const isCacheComplete = dir => {
42
+ try {
43
+ return fs.existsSync(path.join(dir, 'index.json')) && fs.existsSync(path.join(dir, 'components.json'));
44
+ } catch {
45
+ return false;
46
+ }
47
+ };
48
+
49
+ const readCachedCatalog = async (dir, remoteName, version) => {
50
+ const [index, components, meta] = await Promise.all([
51
+ fsp.readFile(path.join(dir, 'index.json'), 'utf8').then(JSON.parse),
52
+ fsp.readFile(path.join(dir, 'components.json'), 'utf8').then(JSON.parse),
53
+ fsp
54
+ .readFile(path.join(dir, 'meta.json'), 'utf8')
55
+ .then(JSON.parse)
56
+ .catch(() => ({}))
57
+ ]);
58
+ return {
59
+ remote: remoteName,
60
+ version,
61
+ packageName: meta.packageName,
62
+ meta,
63
+ index,
64
+ components
65
+ };
66
+ };
67
+
68
+ const buildCatalogFromReadme = (readme, remoteName) => {
69
+ const docMd = extractDocMd(readme);
70
+ const sections = splitComponentSections(docMd);
71
+ const index = [];
72
+ const components = {};
73
+
74
+ sections.forEach(section => {
75
+ let parsed;
76
+ try {
77
+ parsed = parseMdDoc(section);
78
+ } catch {
79
+ return;
80
+ }
81
+ const name = parsed.name || '';
82
+ if (!name) {
83
+ return;
84
+ }
85
+ const token = `${remoteName}:${name}`;
86
+ const summary = stripHtml(parsed.summary).slice(0, 400);
87
+ index.push({ name, token, summary });
88
+ components[name] = {
89
+ name,
90
+ token,
91
+ summary: parsed.summary || '',
92
+ api: parsed.api || '',
93
+ examples: (parsed.example?.list || []).map((ex, i) => ({
94
+ id: String(i),
95
+ title: ex.title || `示例${i + 1}`,
96
+ description: ex.description || '',
97
+ code: ex.code || ''
98
+ }))
99
+ };
100
+ });
101
+
102
+ return { index, components };
103
+ };
104
+
105
+ const ensureRemoteCatalog = async (remote, options) => {
106
+ const name = remote?.name;
107
+ const version = remote?.version || 'latest';
108
+ if (!name) {
109
+ return null;
110
+ }
111
+
112
+ const dir = getCacheDir(options, name, version);
113
+ if (isCacheComplete(dir)) {
114
+ const cached = await readCachedCatalog(dir, name, version);
115
+ if ((cached.index || []).length || Object.keys(cached.components || {}).length) {
116
+ return cached;
117
+ }
118
+ }
119
+
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
+
132
+ const loadOpts = {};
133
+ if (options.ai?.npmRegistry) {
134
+ loadOpts.registry = options.ai.npmRegistry;
135
+ }
136
+
137
+ let info;
138
+ try {
139
+ const pkgSpec = version && version !== 'latest' ? `${packageName}@${version}` : packageName;
140
+ info = await loadNpmInfo(pkgSpec, loadOpts);
141
+ } catch (e) {
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);
148
+ await fsp.mkdir(dir, { recursive: true });
149
+ await Promise.all([
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))
153
+ ]);
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);
165
+ }
166
+ };
167
+
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
+ })
175
+ ]);
176
+ };
177
+
178
+ const ensureRemotesCatalogs = async (remotes, options) => {
179
+ const list = Array.isArray(remotes) ? remotes : [];
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);
190
+ };
191
+
192
+ const getExampleCode = (catalogs, { remote, version, component, exampleId }) => {
193
+ const cat = (catalogs || []).find(c => c.remote === remote && (!version || c.version === version || String(c.version) === String(version))) || (catalogs || []).find(c => c.remote === remote);
194
+ if (!cat) {
195
+ return { error: `catalog not found: ${remote}` };
196
+ }
197
+ const comp = cat.components?.[component];
198
+ if (!comp) {
199
+ return { error: `component not found: ${component}` };
200
+ }
201
+ const ex = (comp.examples || []).find(e => String(e.id) === String(exampleId));
202
+ if (!ex) {
203
+ return { error: `example not found: ${exampleId}` };
204
+ }
205
+ return { title: ex.title, description: ex.description, code: ex.code || '' };
206
+ };
207
+
208
+ module.exports = {
209
+ ensureRemotesCatalogs,
210
+ ensureRemoteCatalog,
211
+ getExampleCode,
212
+ getCacheDir,
213
+ buildCatalogFromReadme,
214
+ extractDocMd,
215
+ resolvePackageName
216
+ };
@@ -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
+ };
@@ -0,0 +1,218 @@
1
+ const stripHtml = html =>
2
+ String(html || '')
3
+ .replace(/<[^>]+>/g, ' ')
4
+ .replace(/\s+/g, ' ')
5
+ .trim();
6
+
7
+ const LIVE_COMPONENT_SPEC = `
8
+ ## Live 组件规范(必须遵守)
9
+
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: '男' }]}\`。
16
+ 3. 运行时可用标识:
17
+ - React、Antd(antd 全量,但表单场景有额外禁令,见第 8 条)、props(合并后的组件参数)
18
+ - scope 声明的变量名(如 FormInfo ← components-core:FormInfo),另有 PureGlobal
19
+ - libs:仅限当前请求告知的 libKeys,禁止使用未列出的 lib 标识符
20
+ 4. 组件选用优先级(强制):
21
+ 1) 远程组件优先(本次 remotes 索引 / 当前 scope 中已有)
22
+ 2) 其次 Antd.*(**表单除外,见第 8 条**)
23
+ 3) 最后原生 HTML;禁止用 HTML 重复实现已有远程或 Antd 能力
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'\`)。
29
+ 6. 使用 FormInfo 时字段须落在 <FormInfo.Form> 上下文中(已有则保持;缺失可包一层)。
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 里声明新组件函数)。
59
+ `.trim();
60
+
61
+ const buildLibsSection = libKeys => {
62
+ const keys = Array.isArray(libKeys) ? libKeys.filter(Boolean) : [];
63
+ if (!keys.length) {
64
+ return '当前环境可用 libs:(无)请勿使用任何第三方 lib 标识符。';
65
+ }
66
+ return `当前环境可用 libs:${keys.join(', ')}。只能使用这些标识符。`;
67
+ };
68
+
69
+ const buildCatalogIndexSection = catalogs => {
70
+ if (!catalogs?.length) {
71
+ return '远程组件索引:(无可用索引)';
72
+ }
73
+ const lines = ['远程组件索引(L1):'];
74
+ catalogs.forEach(cat => {
75
+ lines.push(`### ${cat.remote}@${cat.version}`);
76
+ (cat.index || []).forEach(item => {
77
+ lines.push(`- ${item.name} | ${item.token} | ${item.summary || ''}`);
78
+ });
79
+ });
80
+ return lines.join('\n');
81
+ };
82
+
83
+ const buildComponentDetailSection = (catalogs, hitNames) => {
84
+ if (!hitNames?.length || !catalogs?.length) {
85
+ return '';
86
+ }
87
+ const nameSet = new Set(hitNames.map(n => String(n).toLowerCase()));
88
+ const blocks = [];
89
+ catalogs.forEach(cat => {
90
+ const components = cat.components || {};
91
+ Object.keys(components).forEach(name => {
92
+ if (!nameSet.has(name.toLowerCase())) {
93
+ return;
94
+ }
95
+ const c = components[name];
96
+ const examples = (c.examples || []).map(ex => `- id=${ex.id} | ${ex.title || ''} | ${ex.description || ''}`).join('\n');
97
+ blocks.push(
98
+ [
99
+ `### 组件详情 ${c.token || `${cat.remote}:${name}`}`,
100
+ stripHtml(c.summary).slice(0, 800),
101
+ c.api ? `API 摘要:${stripHtml(c.api).slice(0, 1200)}` : '',
102
+ '示例目录(仅 title/description,完整 code 请调用 getComponentExample):',
103
+ examples || '(无示例)'
104
+ ]
105
+ .filter(Boolean)
106
+ .join('\n')
107
+ );
108
+ });
109
+ });
110
+ return blocks.join('\n\n');
111
+ };
112
+
113
+ const collectHitComponentNames = ({ messages = [], scope = {}, catalogs = [] }) => {
114
+ const hits = new Set();
115
+ Object.keys(scope || {}).forEach(k => hits.add(k));
116
+ Object.values(scope || {}).forEach(token => {
117
+ const m = String(token).match(/:([^@]+)/);
118
+ if (m) {
119
+ hits.add(m[1]);
120
+ }
121
+ });
122
+ const text = (messages || []).map(m => m.content || '').join('\n');
123
+ catalogs.forEach(cat => {
124
+ (cat.index || []).forEach(item => {
125
+ if (item.name && text.includes(item.name)) {
126
+ hits.add(item.name);
127
+ }
128
+ });
129
+ });
130
+ // 表单相关需求强制带上 FormInfo 文档命中,便于注入 L2
131
+ if (/表单|校验|FormInfo|Form\.Item|录入|提交|字段/.test(text)) {
132
+ hits.add('FormInfo');
133
+ }
134
+ return Array.from(hits);
135
+ };
136
+
137
+ const buildRefineSystemPrompt = ctx => {
138
+ const { libKeys = [], catalogs = [], messages = [], scope = {}, hasSelection, selectionLabel } = ctx;
139
+ const hits = collectHitComponentNames({ messages, scope, catalogs });
140
+ const detail = buildComponentDetailSection(catalogs, hits);
141
+ return [
142
+ '你是 Live 组件需求顾问(不是通用前端架构师)。只聊需求、完善描述,禁止输出可运行业务源码或大段 JSX。',
143
+ LIVE_COMPONENT_SPEC,
144
+ buildLibsSection(libKeys),
145
+ buildCatalogIndexSection(catalogs),
146
+ detail,
147
+ hasSelection ? `当前讨论范围限定在选中组件:${selectionLabel || '已选中片段'}。` : '当前讨论整份 content。',
148
+ `行为规则:
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)。`
155
+ ]
156
+ .filter(Boolean)
157
+ .join('\n\n');
158
+ };
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
+
176
+ const buildGenerateSystemPrompt = ctx => {
177
+ const { libKeys = [], catalogs = [], messages = [], scope = {}, props = {}, hasSelection, selectionLabel } = ctx;
178
+ const hits = collectHitComponentNames({ messages, scope, catalogs });
179
+ // generate:对命中组件 + 索引前列组件提供详情;若无命中则不塞过多
180
+ const detailNames = hits.length ? hits : (catalogs[0]?.index || []).slice(0, 5).map(i => i.name);
181
+ const detail = buildComponentDetailSection(catalogs, detailNames);
182
+ return [
183
+ '你是 Live content 代码生成器。根据已完善的需求对话,一次产出可直接写入编辑器的源码,并同步声明所需的 scope / props。',
184
+ LIVE_COMPONENT_SPEC,
185
+ buildLibsSection(libKeys),
186
+ buildCurrentScopeSection(scope),
187
+ buildCurrentPropsSection(props),
188
+ buildCatalogIndexSection(catalogs),
189
+ detail,
190
+ hasSelection ? `只输出替换后的选中片段源码(选中:${selectionLabel || ''})。不要输出整文件。` : '输出替换后的整份 content 源码。',
191
+ `工具:
192
+ - getComponentExample:按示例 id 读取完整示例 code(先看示例目录的 description 再决定是否拉取)。
193
+ - suggestScope:声明需要合并进编辑器「组件域」的 scope,形如 { FormInfo: 'components-core:FormInfo' }。
194
+ - suggestProps:声明需要合并进编辑器「组件参数」的 props,形如 { title: { type: 'string', defaultValue: '标题' } }。
195
+
196
+ 输出契约:
197
+ 1. 优先纯代码;不要追问、不要复述需求、不要解释。
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。`
205
+ ]
206
+ .filter(Boolean)
207
+ .join('\n\n');
208
+ };
209
+
210
+ module.exports = {
211
+ LIVE_COMPONENT_SPEC,
212
+ stripHtml,
213
+ buildRefineSystemPrompt,
214
+ buildGenerateSystemPrompt,
215
+ collectHitComponentNames,
216
+ buildCatalogIndexSection,
217
+ buildComponentDetailSection
218
+ };
package/libs/ai/run.js ADDED
@@ -0,0 +1,172 @@
1
+ const { streamText, tool, stepCountIs } = require('ai');
2
+ const { z } = require('zod');
3
+ const { buildRefineSystemPrompt, buildGenerateSystemPrompt } = require('./prompts');
4
+ const { getExampleCode } = require('./catalog');
5
+ const { mergeSuggestedProps } = require('./extract-props');
6
+
7
+ const writeSse = (reply, data) => {
8
+ reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
9
+ };
10
+
11
+ const buildUserMessages = job => {
12
+ const messages = Array.isArray(job.messages) ? job.messages.filter(m => m && (m.role === 'user' || m.role === 'assistant') && m.content).map(m => ({ role: m.role, content: String(m.content) })) : [];
13
+
14
+ if (job.mode === 'generate') {
15
+ const parts = [];
16
+ if (job.selection?.code) {
17
+ parts.push(`[选中片段]\n${job.selection.code}`);
18
+ } else if (job.content) {
19
+ parts.push(`[当前 content]\n${job.content}`);
20
+ }
21
+ if (job.scope && Object.keys(job.scope).length) {
22
+ parts.push(`[当前 scope]\n${JSON.stringify(job.scope, null, 2)}`);
23
+ }
24
+ if (job.props && Object.keys(job.props).length) {
25
+ parts.push(`[当前 props]\n${JSON.stringify(job.props, null, 2)}`);
26
+ }
27
+ if (parts.length) {
28
+ messages.push({ role: 'user', content: parts.join('\n\n') });
29
+ }
30
+ } else if (job.selection?.code) {
31
+ messages.push({ role: 'user', content: `[当前选中片段参考]\n${job.selection.code}` });
32
+ }
33
+
34
+ if (!messages.length) {
35
+ messages.push({ role: 'user', content: job.mode === 'generate' ? '请根据对话生成代码。' : '请帮助完善需求。' });
36
+ }
37
+
38
+ return messages;
39
+ };
40
+
41
+ const propDefSchema = z.object({
42
+ type: z.enum(['string', 'number', 'boolean', 'array', 'object', 'function']),
43
+ defaultValue: z.any().optional()
44
+ });
45
+
46
+ const runAiStream = async (job, reply) => {
47
+ const model = await job.getAIModel();
48
+ if (!model) {
49
+ writeSse(reply, { text: 'AI model 未配置', done: true });
50
+ return;
51
+ }
52
+
53
+ const ctx = {
54
+ mode: job.mode,
55
+ hasSelection: !!(job.selection?.code || job.selection?.startLine),
56
+ selectionLabel: job.selection?.label,
57
+ scopeKeys: Object.keys(job.scope || {}),
58
+ libKeys: job.libs || [],
59
+ catalogs: job.catalogs || [],
60
+ messages: job.messages || [],
61
+ scope: job.scope || {},
62
+ props: job.props || {}
63
+ };
64
+
65
+ const system = job.mode === 'generate' ? buildGenerateSystemPrompt(ctx) : buildRefineSystemPrompt(ctx);
66
+ let suggestedScope = {};
67
+ let suggestedProps = {};
68
+ let streamedText = '';
69
+
70
+ const exampleTool = tool({
71
+ description: job.mode === 'generate' ? '按示例 id 读取远程组件文档中的完整示例代码' : '可选:查看某示例代码以便更准确描述需求(refine 阶段仍不要输出业务实现代码)',
72
+ inputSchema: z.object({
73
+ remote: z.string(),
74
+ version: z.string().optional(),
75
+ component: z.string(),
76
+ exampleId: z.string()
77
+ }),
78
+ execute: async ({ remote, version, component, exampleId }) => {
79
+ return getExampleCode(job.catalogs, { remote, version, component, exampleId });
80
+ }
81
+ });
82
+
83
+ const tools =
84
+ job.mode === 'generate'
85
+ ? {
86
+ getComponentExample: exampleTool,
87
+ suggestScope: tool({
88
+ description: '声明需要合并进编辑器「组件域」的 scope(远程组件 token)',
89
+ inputSchema: z.object({
90
+ scope: z.record(z.string())
91
+ }),
92
+ execute: async ({ scope }) => {
93
+ suggestedScope = Object.assign({}, suggestedScope, scope || {});
94
+ return { ok: true, suggestedScope };
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
+ }
106
+ })
107
+ }
108
+ : {
109
+ getComponentExample: exampleTool
110
+ };
111
+
112
+ const result = streamText({
113
+ model,
114
+ system,
115
+ messages: buildUserMessages(job),
116
+ tools,
117
+ stopWhen: stepCountIs(job.mode === 'generate' ? 8 : 4)
118
+ });
119
+
120
+ for await (const part of result.fullStream) {
121
+ if (part.type === 'text-delta') {
122
+ const text = part.text || part.delta || part.textDelta || '';
123
+ if (text) {
124
+ streamedText += text;
125
+ writeSse(reply, { text, done: false });
126
+ }
127
+ }
128
+ }
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
+
152
+ const donePayload = { done: true };
153
+ if (!streamedText.trim() && finalText.trim()) {
154
+ donePayload.text = finalText;
155
+ }
156
+ if (Object.keys(suggestedScope).length) {
157
+ donePayload.suggestedScope = suggestedScope;
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
+ }
166
+ writeSse(reply, donePayload);
167
+ };
168
+
169
+ module.exports = {
170
+ runAiStream,
171
+ buildUserMessages
172
+ };
@@ -0,0 +1,42 @@
1
+ const crypto = require('node:crypto');
2
+
3
+ const TTL_MS = 2 * 60 * 1000;
4
+ const store = new Map();
5
+
6
+ const cleanup = () => {
7
+ const now = Date.now();
8
+ for (const [key, value] of store.entries()) {
9
+ if (now - value.createdAt > TTL_MS) {
10
+ store.delete(key);
11
+ }
12
+ }
13
+ };
14
+
15
+ const createStreamJob = payload => {
16
+ cleanup();
17
+ const token = crypto.randomBytes(16).toString('hex');
18
+ store.set(token, { ...payload, createdAt: Date.now() });
19
+ return token;
20
+ };
21
+
22
+ const takeStreamJob = token => {
23
+ cleanup();
24
+ const job = store.get(token);
25
+ if (!job) {
26
+ return null;
27
+ }
28
+ store.delete(token);
29
+ return job;
30
+ };
31
+
32
+ const peekStreamJob = token => {
33
+ cleanup();
34
+ return store.get(token) || null;
35
+ };
36
+
37
+ module.exports = {
38
+ createStreamJob,
39
+ takeStreamJob,
40
+ peekStreamJob,
41
+ TTL_MS
42
+ };
@@ -47,6 +47,85 @@ module.exports = fp(async (fastify, options) => {
47
47
  }
48
48
  );
49
49
 
50
+ fastify.get(
51
+ `${options.prefix}/:shorten/info`,
52
+ {
53
+ onRequest: withEntryAuth,
54
+ schema: {
55
+ summary: '站点信息(含是否支持 AI)',
56
+ params: {
57
+ type: 'object',
58
+ required: ['shorten'],
59
+ properties: {
60
+ shorten: { type: 'string' }
61
+ }
62
+ }
63
+ }
64
+ },
65
+ async request => {
66
+ return services.ai.getInfo(request.authenticatePayload);
67
+ }
68
+ );
69
+
70
+ fastify.post(
71
+ `${options.prefix}/:shorten/ai/start`,
72
+ {
73
+ onRequest: withEntryAuth,
74
+ schema: {
75
+ summary: '创建 AI 流式任务,返回 streamToken',
76
+ params: {
77
+ type: 'object',
78
+ required: ['shorten'],
79
+ properties: {
80
+ shorten: { type: 'string' }
81
+ }
82
+ },
83
+ body: {
84
+ type: 'object',
85
+ properties: {
86
+ mode: { type: 'string' },
87
+ messages: { type: 'array' },
88
+ content: { type: 'string' },
89
+ scope: { type: 'object' },
90
+ libs: { type: 'array' },
91
+ remotes: { type: 'array' },
92
+ selection: { type: 'object' }
93
+ }
94
+ }
95
+ }
96
+ },
97
+ async request => {
98
+ return services.ai.start(request.authenticatePayload, request.body || {});
99
+ }
100
+ );
101
+
102
+ fastify.get(
103
+ `${options.prefix}/:shorten/ai/stream`,
104
+ {
105
+ onRequest: withEntryAuth,
106
+ schema: {
107
+ summary: 'SSE 推送 AI 流式输出',
108
+ params: {
109
+ type: 'object',
110
+ required: ['shorten'],
111
+ properties: {
112
+ shorten: { type: 'string' }
113
+ }
114
+ },
115
+ query: {
116
+ type: 'object',
117
+ required: ['token'],
118
+ properties: {
119
+ token: { type: 'string' }
120
+ }
121
+ }
122
+ }
123
+ },
124
+ async (request, reply) => {
125
+ return services.ai.stream(request.authenticatePayload, request.query, reply);
126
+ }
127
+ );
128
+
50
129
  fastify.get(
51
130
  `${options.prefix}/:shorten/getFolderTree`,
52
131
  {
@@ -289,6 +368,34 @@ module.exports = fp(async (fastify, options) => {
289
368
  }
290
369
  );
291
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
+
292
399
  fastify.post(
293
400
  `${options.prefix}/:shorten/remove`,
294
401
  {
@@ -0,0 +1,104 @@
1
+ const fp = require('fastify-plugin');
2
+ const { Forbidden, BadRequest, NotFound } = require('http-errors');
3
+ const { ensureRemotesCatalogs } = require('../ai/catalog');
4
+ const { createStreamJob, takeStreamJob } = require('../ai/stream-store');
5
+ const { runAiStream } = require('../ai/run');
6
+
7
+ module.exports = fp(async (fastify, options) => {
8
+ const isAiEnabledForSite = site => {
9
+ if (typeof options.ai?.getAIModel !== 'function') {
10
+ return false;
11
+ }
12
+ const siteOptions = site?.options && typeof site.options === 'object' ? site.options : {};
13
+ return siteOptions.aiEnabled !== false;
14
+ };
15
+
16
+ const getInfo = async authenticatePayload => {
17
+ const site = authenticatePayload.site;
18
+ return {
19
+ name: site.name,
20
+ status: site.status,
21
+ defaultPermission: site.defaultPermission,
22
+ aiEnabled: isAiEnabledForSite(site)
23
+ };
24
+ };
25
+
26
+ const start = async (authenticatePayload, body = {}) => {
27
+ if (!isAiEnabledForSite(authenticatePayload.site)) {
28
+ throw new Forbidden('站点未开启 AI 生成');
29
+ }
30
+ if (typeof options.ai?.getAIModel !== 'function') {
31
+ throw new Forbidden('AI 未配置');
32
+ }
33
+
34
+ const mode = body.mode === 'generate' ? 'generate' : 'refine';
35
+ const messages = Array.isArray(body.messages) ? body.messages : [];
36
+ const remotes = Array.isArray(body.remotes) ? body.remotes : [];
37
+ const libs = Array.isArray(body.libs) ? body.libs.map(String) : [];
38
+ const scope = body.scope && typeof body.scope === 'object' ? body.scope : {};
39
+ const props = body.props && typeof body.props === 'object' ? body.props : {};
40
+ const content = typeof body.content === 'string' ? body.content : '';
41
+ const selection = body.selection && typeof body.selection === 'object' ? body.selection : null;
42
+
43
+ if (mode === 'refine' && !messages.length) {
44
+ throw new BadRequest('messages 不能为空');
45
+ }
46
+
47
+ // 不在 start 阻塞拉文档索引(易触发网关/代理 504);在 stream 阶段再确保
48
+ const streamToken = createStreamJob({
49
+ mode,
50
+ messages,
51
+ remotes,
52
+ libs,
53
+ scope,
54
+ props,
55
+ content,
56
+ selection,
57
+ catalogs: null,
58
+ catalogOptions: options,
59
+ getAIModel: options.ai.getAIModel
60
+ });
61
+
62
+ return { streamToken };
63
+ };
64
+
65
+ const stream = async (authenticatePayload, { token }, reply) => {
66
+ if (!isAiEnabledForSite(authenticatePayload.site)) {
67
+ throw new Forbidden('站点未开启 AI 生成');
68
+ }
69
+ if (!token) {
70
+ throw new BadRequest('token 不能为空');
71
+ }
72
+ const job = takeStreamJob(token);
73
+ if (!job) {
74
+ throw new NotFound('stream token 无效或已过期');
75
+ }
76
+
77
+ reply.hijack();
78
+ reply.raw.writeHead(200, {
79
+ 'Content-Type': 'text/event-stream; charset=utf-8',
80
+ 'Cache-Control': 'no-cache, no-transform',
81
+ Connection: 'keep-alive',
82
+ 'Access-Control-Allow-Origin': '*'
83
+ });
84
+
85
+ try {
86
+ if (!job.catalogs) {
87
+ job.catalogs = await ensureRemotesCatalogs(job.remotes, job.catalogOptions || options);
88
+ }
89
+ await runAiStream(job, reply);
90
+ } catch (e) {
91
+ reply.raw.write(`data: ${JSON.stringify({ text: e.message || 'AI 流式失败', done: true })}\n\n`);
92
+ }
93
+ reply.raw.end();
94
+ };
95
+
96
+ Object.assign(fastify[options.name].services, {
97
+ ai: {
98
+ getInfo,
99
+ start,
100
+ stream,
101
+ isAiEnabledForSite
102
+ }
103
+ });
104
+ });
@@ -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.0",
3
+ "version": "0.1.9",
4
4
  "description": "Fastify 插件:为 LiveComponentEditor 提供远程站点文件树与短链访问管理",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -40,8 +40,12 @@
40
40
  },
41
41
  "homepage": "https://github.com/kne-union/fastify-live-components-site#readme",
42
42
  "dependencies": {
43
+ "@kne/load-npm-info": "^0.1.8",
44
+ "@kne/md-doc": "^0.1.24",
45
+ "ai": "^7.0.37",
43
46
  "http-errors": "^2.0.0",
44
- "lodash": "^4.17.21"
47
+ "lodash": "^4.17.21",
48
+ "zod": "^3.25.76"
45
49
  },
46
50
  "peerDependencies": {
47
51
  "@kne/fastify-account": "*",