@kne/fastify-live-components-site 0.1.1 → 0.1.10
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 +183 -111
- package/libs/controllers/site.js +60 -19
- package/libs/schema/response.js +50 -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
|
|
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
const fp = require('fastify-plugin');
|
|
2
2
|
const { allowCors, handleOptions, normalizeHooks } = require('../utils/cors');
|
|
3
|
+
const { str, bool, any, list, obj, success, PERMISSION, SITE_STATUS, nodeBase, treeNode, contentShare } = require('../schema/response');
|
|
4
|
+
|
|
5
|
+
const TAG_SITE = 'Live组件站点-站点信息';
|
|
6
|
+
const TAG_AI = 'Live组件站点-AI';
|
|
7
|
+
const TAG_FILE = 'Live组件站点-文件';
|
|
8
|
+
const TAG_SHARE = 'Live组件站点-内容分享';
|
|
9
|
+
|
|
10
|
+
const ENTRY_HINT = '通过站点短码 shorten 鉴权(站点须存在且为开启状态,关闭返回 403,短码无效返回 401),允许任意 Origin 跨域';
|
|
11
|
+
|
|
12
|
+
const shortenParams = {
|
|
13
|
+
type: 'object',
|
|
14
|
+
required: ['shorten'],
|
|
15
|
+
properties: {
|
|
16
|
+
shorten: { type: 'string', description: '站点公开访问短码(不区分大小写)' }
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const PARENT_ID = '父文件夹节点 ID,为空表示根目录;父节点须为可写文件夹';
|
|
21
|
+
|
|
22
|
+
const fileNode = obj('文件节点', nodeBase);
|
|
3
23
|
|
|
4
24
|
module.exports = fp(async (fastify, options) => {
|
|
5
25
|
const { services } = fastify[options.name];
|
|
@@ -28,14 +48,17 @@ module.exports = fp(async (fastify, options) => {
|
|
|
28
48
|
{
|
|
29
49
|
onRequest: allowCors,
|
|
30
50
|
schema: {
|
|
51
|
+
tags: [TAG_SHARE],
|
|
31
52
|
summary: '通过内容短码直出已保存 content(text/plain,无 code/data;不带站点 shorten)',
|
|
53
|
+
description: '公开访问,无需站点短码;短码无效或已过期返回 401,所属站点关闭返回 403。允许任意 Origin 跨域',
|
|
32
54
|
params: {
|
|
33
55
|
type: 'object',
|
|
34
56
|
required: ['contentShorten'],
|
|
35
57
|
properties: {
|
|
36
|
-
contentShorten: { type: 'string' }
|
|
58
|
+
contentShorten: { type: 'string', description: '内容分享短码(不区分大小写)' }
|
|
37
59
|
}
|
|
38
|
-
}
|
|
60
|
+
},
|
|
61
|
+
response: success('文件已保存的内容原文,Content-Type 为 text/plain; charset=utf-8', { type: 'string' })
|
|
39
62
|
}
|
|
40
63
|
},
|
|
41
64
|
async (request, reply) => {
|
|
@@ -52,14 +75,20 @@ module.exports = fp(async (fastify, options) => {
|
|
|
52
75
|
{
|
|
53
76
|
onRequest: withEntryAuth,
|
|
54
77
|
schema: {
|
|
78
|
+
tags: [TAG_SITE],
|
|
55
79
|
summary: '站点信息(含是否支持 AI)',
|
|
56
|
-
|
|
80
|
+
description: ENTRY_HINT,
|
|
81
|
+
params: shortenParams,
|
|
82
|
+
response: success('站点公开信息', {
|
|
57
83
|
type: 'object',
|
|
58
|
-
|
|
84
|
+
additionalProperties: true,
|
|
59
85
|
properties: {
|
|
60
|
-
|
|
86
|
+
name: str('站点名称'),
|
|
87
|
+
status: str(SITE_STATUS),
|
|
88
|
+
defaultPermission: str(`新建节点默认${PERMISSION}`),
|
|
89
|
+
aiEnabled: bool('是否可用 AI 生成:插件配置了 ai.getAIModel 且站点 options.aiEnabled 不为 false')
|
|
61
90
|
}
|
|
62
|
-
}
|
|
91
|
+
})
|
|
63
92
|
}
|
|
64
93
|
},
|
|
65
94
|
async request => {
|
|
@@ -72,26 +101,38 @@ module.exports = fp(async (fastify, options) => {
|
|
|
72
101
|
{
|
|
73
102
|
onRequest: withEntryAuth,
|
|
74
103
|
schema: {
|
|
104
|
+
tags: [TAG_AI],
|
|
75
105
|
summary: '创建 AI 流式任务,返回 streamToken',
|
|
76
|
-
|
|
106
|
+
description: `${ENTRY_HINT}。站点未开启 AI 返回 403;refine 模式 messages 不能为空。任务暂存于内存,2 分钟内需用 streamToken 调用 ai/stream 消费`,
|
|
107
|
+
params: shortenParams,
|
|
108
|
+
body: {
|
|
77
109
|
type: 'object',
|
|
78
|
-
required: ['shorten'],
|
|
79
110
|
properties: {
|
|
80
|
-
|
|
111
|
+
mode: { type: 'string', description: '任务模式:generate 生成代码,其它值按 refine(完善需求对话)处理' },
|
|
112
|
+
messages: { type: 'array', description: '对话历史,元素形如 { role: user|assistant, content },其它 role 或空 content 会被忽略' },
|
|
113
|
+
content: { type: 'string', description: '编辑器当前整份源码' },
|
|
114
|
+
scope: { type: 'object', description: '编辑器当前 scope(可用变量/组件声明)' },
|
|
115
|
+
props: { description: '编辑器当前组件参数,形如 { name: { type, defaultValue } };非对象时忽略' },
|
|
116
|
+
libs: { type: 'array', description: '可用依赖库标识列表,元素会转为字符串' },
|
|
117
|
+
remotes: { type: 'array', description: '可用远程组件包列表,用于在 stream 阶段拉取组件文档索引' },
|
|
118
|
+
selection: {
|
|
119
|
+
type: 'object',
|
|
120
|
+
description: '编辑器选中片段;传入时 AI 只针对选中部分',
|
|
121
|
+
properties: {
|
|
122
|
+
code: { description: '选中片段源码' },
|
|
123
|
+
startLine: { description: '选中片段起始行' },
|
|
124
|
+
label: { description: '选中片段名称,用于提示' }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
81
127
|
}
|
|
82
128
|
},
|
|
83
|
-
|
|
129
|
+
response: success('任务已创建', {
|
|
84
130
|
type: 'object',
|
|
131
|
+
additionalProperties: true,
|
|
85
132
|
properties: {
|
|
86
|
-
|
|
87
|
-
messages: { type: 'array' },
|
|
88
|
-
content: { type: 'string' },
|
|
89
|
-
scope: { type: 'object' },
|
|
90
|
-
libs: { type: 'array' },
|
|
91
|
-
remotes: { type: 'array' },
|
|
92
|
-
selection: { type: 'object' }
|
|
133
|
+
streamToken: str('一次性流式任务令牌,传给 ai/stream 的 token')
|
|
93
134
|
}
|
|
94
|
-
}
|
|
135
|
+
})
|
|
95
136
|
}
|
|
96
137
|
},
|
|
97
138
|
async request => {
|
|
@@ -104,20 +145,19 @@ module.exports = fp(async (fastify, options) => {
|
|
|
104
145
|
{
|
|
105
146
|
onRequest: withEntryAuth,
|
|
106
147
|
schema: {
|
|
148
|
+
tags: [TAG_AI],
|
|
107
149
|
summary: 'SSE 推送 AI 流式输出',
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
required: ['shorten'],
|
|
111
|
-
properties: {
|
|
112
|
-
shorten: { type: 'string' }
|
|
113
|
-
}
|
|
114
|
-
},
|
|
150
|
+
description: `${ENTRY_HINT}。token 只能消费一次,无效或过期返回 404;成功后以 text/event-stream 推送 data: {...} 事件直到结束`,
|
|
151
|
+
params: shortenParams,
|
|
115
152
|
query: {
|
|
116
153
|
type: 'object',
|
|
117
154
|
required: ['token'],
|
|
118
155
|
properties: {
|
|
119
|
-
token: { type: 'string' }
|
|
156
|
+
token: { type: 'string', description: 'ai/start 返回的 streamToken' }
|
|
120
157
|
}
|
|
158
|
+
},
|
|
159
|
+
response: {
|
|
160
|
+
200: { description: 'text/event-stream 事件流,每条为 data: JSON;出错时推送 { text: 错误信息, done: true } 后结束' }
|
|
121
161
|
}
|
|
122
162
|
}
|
|
123
163
|
},
|
|
@@ -131,14 +171,11 @@ module.exports = fp(async (fastify, options) => {
|
|
|
131
171
|
{
|
|
132
172
|
onRequest: withEntryAuth,
|
|
133
173
|
schema: {
|
|
174
|
+
tags: [TAG_FILE],
|
|
134
175
|
summary: '获取站点文件树',
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
properties: {
|
|
139
|
-
shorten: { type: 'string' }
|
|
140
|
-
}
|
|
141
|
-
}
|
|
176
|
+
description: `${ENTRY_HINT}。同级按类型(文件夹在前)、名称排序`,
|
|
177
|
+
params: shortenParams,
|
|
178
|
+
response: success('根节点列表,文件夹带 children', { type: 'array', items: treeNode })
|
|
142
179
|
}
|
|
143
180
|
},
|
|
144
181
|
async request => {
|
|
@@ -151,21 +188,27 @@ module.exports = fp(async (fastify, options) => {
|
|
|
151
188
|
{
|
|
152
189
|
onRequest: withEntryAuth,
|
|
153
190
|
schema: {
|
|
191
|
+
tags: [TAG_FILE],
|
|
154
192
|
summary: '读取文件内容',
|
|
155
|
-
|
|
193
|
+
description: `${ENTRY_HINT}。节点不存在返回 404,非文件返回 400`,
|
|
194
|
+
params: shortenParams,
|
|
195
|
+
query: {
|
|
156
196
|
type: 'object',
|
|
157
|
-
required: ['
|
|
197
|
+
required: ['id'],
|
|
158
198
|
properties: {
|
|
159
|
-
|
|
199
|
+
id: { type: 'string', description: '文件节点 ID' }
|
|
160
200
|
}
|
|
161
201
|
},
|
|
162
|
-
|
|
202
|
+
response: success('文件内容', {
|
|
163
203
|
type: 'object',
|
|
164
|
-
|
|
204
|
+
additionalProperties: true,
|
|
165
205
|
properties: {
|
|
166
|
-
id:
|
|
206
|
+
id: any('节点 ID(字符串)'),
|
|
207
|
+
name: str('文件名'),
|
|
208
|
+
permission: str(PERMISSION),
|
|
209
|
+
content: str('文件内容,未保存过为空字符串')
|
|
167
210
|
}
|
|
168
|
-
}
|
|
211
|
+
})
|
|
169
212
|
}
|
|
170
213
|
},
|
|
171
214
|
async request => {
|
|
@@ -178,22 +221,19 @@ module.exports = fp(async (fastify, options) => {
|
|
|
178
221
|
{
|
|
179
222
|
onRequest: withEntryAuth,
|
|
180
223
|
schema: {
|
|
224
|
+
tags: [TAG_SHARE],
|
|
181
225
|
summary: '创建文件内容分享短链(可设有效期)',
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
required: ['shorten'],
|
|
185
|
-
properties: {
|
|
186
|
-
shorten: { type: 'string' }
|
|
187
|
-
}
|
|
188
|
-
},
|
|
226
|
+
description: `${ENTRY_HINT}。只能分享文件;生成的短链通过 content/:contentShorten 公开访问`,
|
|
227
|
+
params: shortenParams,
|
|
189
228
|
body: {
|
|
190
229
|
type: 'object',
|
|
191
230
|
required: ['id'],
|
|
192
231
|
properties: {
|
|
193
|
-
id: { type: 'string' },
|
|
194
|
-
expiresIn: { type: ['number', 'null'] }
|
|
232
|
+
id: { type: 'string', description: '文件节点 ID' },
|
|
233
|
+
expiresIn: { type: ['number', 'null'], description: '有效期(秒),不传、null 或不大于 0 表示永久有效' }
|
|
195
234
|
}
|
|
196
|
-
}
|
|
235
|
+
},
|
|
236
|
+
response: success('创建的分享短链', contentShare)
|
|
197
237
|
}
|
|
198
238
|
},
|
|
199
239
|
async request => {
|
|
@@ -206,21 +246,18 @@ module.exports = fp(async (fastify, options) => {
|
|
|
206
246
|
{
|
|
207
247
|
onRequest: withEntryAuth,
|
|
208
248
|
schema: {
|
|
249
|
+
tags: [TAG_SHARE],
|
|
209
250
|
summary: '列出文件已有内容分享短链',
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
required: ['shorten'],
|
|
213
|
-
properties: {
|
|
214
|
-
shorten: { type: 'string' }
|
|
215
|
-
}
|
|
216
|
-
},
|
|
251
|
+
description: `${ENTRY_HINT}。按创建时间倒序;已过期的分享会在查询时被删除并移除短码,不出现在结果中`,
|
|
252
|
+
params: shortenParams,
|
|
217
253
|
query: {
|
|
218
254
|
type: 'object',
|
|
219
255
|
required: ['id'],
|
|
220
256
|
properties: {
|
|
221
|
-
id: { type: 'string' }
|
|
257
|
+
id: { type: 'string', description: '文件节点 ID' }
|
|
222
258
|
}
|
|
223
|
-
}
|
|
259
|
+
},
|
|
260
|
+
response: success('未过期的分享短链列表', { type: 'array', items: contentShare })
|
|
224
261
|
}
|
|
225
262
|
},
|
|
226
263
|
async request => {
|
|
@@ -233,21 +270,24 @@ module.exports = fp(async (fastify, options) => {
|
|
|
233
270
|
{
|
|
234
271
|
onRequest: withEntryAuth,
|
|
235
272
|
schema: {
|
|
273
|
+
tags: [TAG_SHARE],
|
|
236
274
|
summary: '删除文件内容分享短链',
|
|
237
|
-
|
|
275
|
+
description: `${ENTRY_HINT}。删除分享记录并移除内容短码;分享不存在返回 404`,
|
|
276
|
+
params: shortenParams,
|
|
277
|
+
body: {
|
|
238
278
|
type: 'object',
|
|
239
279
|
required: ['shorten'],
|
|
240
280
|
properties: {
|
|
241
|
-
shorten: { type: 'string' }
|
|
281
|
+
shorten: { type: 'string', description: '要删除的内容分享短码(不区分大小写)' }
|
|
242
282
|
}
|
|
243
283
|
},
|
|
244
|
-
|
|
284
|
+
response: success('删除成功', {
|
|
245
285
|
type: 'object',
|
|
246
|
-
|
|
286
|
+
additionalProperties: true,
|
|
247
287
|
properties: {
|
|
248
|
-
shorten:
|
|
288
|
+
shorten: str('已删除的内容短码(大写)')
|
|
249
289
|
}
|
|
250
|
-
}
|
|
290
|
+
})
|
|
251
291
|
}
|
|
252
292
|
},
|
|
253
293
|
async request => {
|
|
@@ -260,22 +300,19 @@ module.exports = fp(async (fastify, options) => {
|
|
|
260
300
|
{
|
|
261
301
|
onRequest: withEntryAuth,
|
|
262
302
|
schema: {
|
|
303
|
+
tags: [TAG_FILE],
|
|
263
304
|
summary: '创建文件夹',
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
required: ['shorten'],
|
|
267
|
-
properties: {
|
|
268
|
-
shorten: { type: 'string' }
|
|
269
|
-
}
|
|
270
|
-
},
|
|
305
|
+
description: `${ENTRY_HINT}。同级重名返回 400 DUPLICATE_NAME;新节点权限取站点 defaultPermission`,
|
|
306
|
+
params: shortenParams,
|
|
271
307
|
body: {
|
|
272
308
|
type: 'object',
|
|
273
309
|
required: ['name'],
|
|
274
310
|
properties: {
|
|
275
|
-
parentId: { type: ['string', 'null'] },
|
|
276
|
-
name: { type: 'string' }
|
|
311
|
+
parentId: { type: ['string', 'null'], description: PARENT_ID },
|
|
312
|
+
name: { type: 'string', description: '文件夹名称,去除首尾空格后不能为空' }
|
|
277
313
|
}
|
|
278
|
-
}
|
|
314
|
+
},
|
|
315
|
+
response: success('创建的文件夹', obj('文件夹节点', Object.assign({}, nodeBase, { children: list('子节点,新建时为空数组', obj('子节点')) })))
|
|
279
316
|
}
|
|
280
317
|
},
|
|
281
318
|
async request => {
|
|
@@ -288,23 +325,20 @@ module.exports = fp(async (fastify, options) => {
|
|
|
288
325
|
{
|
|
289
326
|
onRequest: withEntryAuth,
|
|
290
327
|
schema: {
|
|
328
|
+
tags: [TAG_FILE],
|
|
291
329
|
summary: '创建文件',
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
required: ['shorten'],
|
|
295
|
-
properties: {
|
|
296
|
-
shorten: { type: 'string' }
|
|
297
|
-
}
|
|
298
|
-
},
|
|
330
|
+
description: `${ENTRY_HINT}。同级重名返回 400 DUPLICATE_NAME;新节点权限取站点 defaultPermission`,
|
|
331
|
+
params: shortenParams,
|
|
299
332
|
body: {
|
|
300
333
|
type: 'object',
|
|
301
334
|
required: ['name'],
|
|
302
335
|
properties: {
|
|
303
|
-
parentId: { type: ['string', 'null'] },
|
|
304
|
-
name: { type: 'string' },
|
|
305
|
-
content: { type: 'string' }
|
|
336
|
+
parentId: { type: ['string', 'null'], description: PARENT_ID },
|
|
337
|
+
name: { type: 'string', description: '文件名称,去除首尾空格后不能为空' },
|
|
338
|
+
content: { type: 'string', description: '初始文件内容,默认空字符串' }
|
|
306
339
|
}
|
|
307
|
-
}
|
|
340
|
+
},
|
|
341
|
+
response: success('创建的文件', fileNode)
|
|
308
342
|
}
|
|
309
343
|
},
|
|
310
344
|
async request => {
|
|
@@ -317,22 +351,26 @@ module.exports = fp(async (fastify, options) => {
|
|
|
317
351
|
{
|
|
318
352
|
onRequest: withEntryAuth,
|
|
319
353
|
schema: {
|
|
354
|
+
tags: [TAG_FILE],
|
|
320
355
|
summary: '保存文件内容',
|
|
321
|
-
|
|
356
|
+
description: `${ENTRY_HINT}。只读节点返回 403,非文件返回 400`,
|
|
357
|
+
params: shortenParams,
|
|
358
|
+
body: {
|
|
322
359
|
type: 'object',
|
|
323
|
-
required: ['
|
|
360
|
+
required: ['id'],
|
|
324
361
|
properties: {
|
|
325
|
-
|
|
362
|
+
id: { type: 'string', description: '文件节点 ID' },
|
|
363
|
+
content: { type: 'string', description: '新的文件内容,不传保存为空字符串' }
|
|
326
364
|
}
|
|
327
365
|
},
|
|
328
|
-
|
|
366
|
+
response: success('保存后的文件内容', {
|
|
329
367
|
type: 'object',
|
|
330
|
-
|
|
368
|
+
additionalProperties: true,
|
|
331
369
|
properties: {
|
|
332
|
-
id:
|
|
333
|
-
content:
|
|
370
|
+
id: any('节点 ID(字符串)'),
|
|
371
|
+
content: str('保存后的文件内容')
|
|
334
372
|
}
|
|
335
|
-
}
|
|
373
|
+
})
|
|
336
374
|
}
|
|
337
375
|
},
|
|
338
376
|
async request => {
|
|
@@ -345,22 +383,19 @@ module.exports = fp(async (fastify, options) => {
|
|
|
345
383
|
{
|
|
346
384
|
onRequest: withEntryAuth,
|
|
347
385
|
schema: {
|
|
386
|
+
tags: [TAG_FILE],
|
|
348
387
|
summary: '重命名节点',
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
required: ['shorten'],
|
|
352
|
-
properties: {
|
|
353
|
-
shorten: { type: 'string' }
|
|
354
|
-
}
|
|
355
|
-
},
|
|
388
|
+
description: `${ENTRY_HINT}。只读节点返回 403,同级重名返回 400 DUPLICATE_NAME`,
|
|
389
|
+
params: shortenParams,
|
|
356
390
|
body: {
|
|
357
391
|
type: 'object',
|
|
358
392
|
required: ['id', 'name'],
|
|
359
393
|
properties: {
|
|
360
|
-
id: { type: 'string' },
|
|
361
|
-
name: { type: 'string' }
|
|
394
|
+
id: { type: 'string', description: '文件或文件夹节点 ID' },
|
|
395
|
+
name: { type: 'string', description: '新名称,去除首尾空格后不能为空' }
|
|
362
396
|
}
|
|
363
|
-
}
|
|
397
|
+
},
|
|
398
|
+
response: success('重命名后的节点', fileNode)
|
|
364
399
|
}
|
|
365
400
|
},
|
|
366
401
|
async request => {
|
|
@@ -369,28 +404,65 @@ module.exports = fp(async (fastify, options) => {
|
|
|
369
404
|
);
|
|
370
405
|
|
|
371
406
|
fastify.post(
|
|
372
|
-
`${options.prefix}/:shorten/
|
|
407
|
+
`${options.prefix}/:shorten/move`,
|
|
373
408
|
{
|
|
374
409
|
onRequest: withEntryAuth,
|
|
375
410
|
schema: {
|
|
376
|
-
|
|
377
|
-
|
|
411
|
+
tags: [TAG_FILE],
|
|
412
|
+
summary: '移动文件或文件夹到目标目录',
|
|
413
|
+
description: `${ENTRY_HINT}。目标与当前目录相同、移动到自身或自身子目录返回 400 INVALID_MOVE_TARGET;目标目录下重名返回 400 DUPLICATE_NAME`,
|
|
414
|
+
params: shortenParams,
|
|
415
|
+
body: {
|
|
378
416
|
type: 'object',
|
|
379
|
-
required: ['
|
|
417
|
+
required: ['id'],
|
|
380
418
|
properties: {
|
|
381
|
-
|
|
419
|
+
id: { type: 'string', description: '要移动的节点 ID' },
|
|
420
|
+
parentId: { type: ['string', 'null'], description: '目标文件夹节点 ID,为空表示移动到根目录' }
|
|
382
421
|
}
|
|
383
422
|
},
|
|
423
|
+
response: success(
|
|
424
|
+
'移动后的节点',
|
|
425
|
+
obj(
|
|
426
|
+
'节点',
|
|
427
|
+
Object.assign({}, nodeBase, {
|
|
428
|
+
parentId: any('新的父节点 ID,根目录为 null')
|
|
429
|
+
})
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
async request => {
|
|
435
|
+
return services.node.move(request.authenticatePayload, request.body);
|
|
436
|
+
}
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
fastify.post(
|
|
440
|
+
`${options.prefix}/:shorten/remove`,
|
|
441
|
+
{
|
|
442
|
+
onRequest: withEntryAuth,
|
|
443
|
+
schema: {
|
|
444
|
+
tags: [TAG_FILE],
|
|
445
|
+
summary: '删除节点',
|
|
446
|
+
description: `${ENTRY_HINT}。按顺序逐个删除,遇到只读节点(403)或非空文件夹(400)即中止,已删除的不回滚;删除文件时一并删除其内容分享短链`,
|
|
447
|
+
params: shortenParams,
|
|
384
448
|
body: {
|
|
385
449
|
type: 'object',
|
|
386
450
|
required: ['ids'],
|
|
387
451
|
properties: {
|
|
388
452
|
ids: {
|
|
389
453
|
type: 'array',
|
|
390
|
-
items: { type: 'string' }
|
|
454
|
+
items: { type: 'string' },
|
|
455
|
+
description: '要删除的节点 ID 列表'
|
|
391
456
|
}
|
|
392
457
|
}
|
|
393
|
-
}
|
|
458
|
+
},
|
|
459
|
+
response: success('删除成功', {
|
|
460
|
+
type: 'object',
|
|
461
|
+
additionalProperties: true,
|
|
462
|
+
properties: {
|
|
463
|
+
ids: list('已删除的节点 ID 列表', any('节点 ID(字符串)'))
|
|
464
|
+
}
|
|
465
|
+
})
|
|
394
466
|
}
|
|
395
467
|
},
|
|
396
468
|
async request => {
|
package/libs/controllers/site.js
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
const fp = require('fastify-plugin');
|
|
2
|
+
const { num, list, success, empty, PERMISSION, SITE_STATUS, site } = require('../schema/response');
|
|
3
|
+
|
|
4
|
+
const TAG = 'Live组件站点管理后台-站点';
|
|
5
|
+
|
|
6
|
+
const siteOptions = {
|
|
7
|
+
type: 'object',
|
|
8
|
+
description: '站点扩展配置(JSON),原样保存',
|
|
9
|
+
properties: {
|
|
10
|
+
aiEnabled: { description: '是否开启 AI 生成,传 false 关闭;未传视为开启(需同时配置插件 ai.getAIModel)' }
|
|
11
|
+
}
|
|
12
|
+
};
|
|
2
13
|
|
|
3
14
|
module.exports = fp(async (fastify, options) => {
|
|
4
15
|
const { services } = fastify[options.name];
|
|
@@ -8,15 +19,33 @@ module.exports = fp(async (fastify, options) => {
|
|
|
8
19
|
{
|
|
9
20
|
onRequest: options.getAuthenticate('site'),
|
|
10
21
|
schema: {
|
|
22
|
+
tags: [TAG],
|
|
11
23
|
summary: '站点列表',
|
|
24
|
+
description: '分页查询站点,按创建时间倒序;可按状态与名称关键字筛选',
|
|
12
25
|
query: {
|
|
13
26
|
type: 'object',
|
|
14
27
|
properties: {
|
|
15
|
-
filter: {
|
|
16
|
-
|
|
17
|
-
|
|
28
|
+
filter: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
default: {},
|
|
31
|
+
description: '筛选条件,如 filter[status]=open',
|
|
32
|
+
properties: {
|
|
33
|
+
status: { description: `按状态筛选,${SITE_STATUS};其它值忽略` },
|
|
34
|
+
keyword: { description: '按站点名称模糊匹配' }
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
perPage: { type: 'number', default: 20, description: '每页条数' },
|
|
38
|
+
currentPage: { type: 'number', default: 1, description: '当前页码,从 1 开始' }
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
response: success('站点分页列表', {
|
|
42
|
+
type: 'object',
|
|
43
|
+
additionalProperties: true,
|
|
44
|
+
properties: {
|
|
45
|
+
pageData: list('当前页站点列表', site),
|
|
46
|
+
totalCount: num('符合条件的站点总数')
|
|
18
47
|
}
|
|
19
|
-
}
|
|
48
|
+
})
|
|
20
49
|
}
|
|
21
50
|
},
|
|
22
51
|
async request => {
|
|
@@ -29,14 +58,17 @@ module.exports = fp(async (fastify, options) => {
|
|
|
29
58
|
{
|
|
30
59
|
onRequest: options.getAuthenticate('site'),
|
|
31
60
|
schema: {
|
|
61
|
+
tags: [TAG],
|
|
32
62
|
summary: '站点详情',
|
|
63
|
+
description: '按 ID 获取站点信息,含公开入口地址 host;站点不存在返回 404',
|
|
33
64
|
query: {
|
|
34
65
|
type: 'object',
|
|
35
66
|
required: ['id'],
|
|
36
67
|
properties: {
|
|
37
|
-
id: { type: 'string' }
|
|
68
|
+
id: { type: 'string', description: '站点 ID' }
|
|
38
69
|
}
|
|
39
|
-
}
|
|
70
|
+
},
|
|
71
|
+
response: success('站点详情', site)
|
|
40
72
|
}
|
|
41
73
|
},
|
|
42
74
|
async request => {
|
|
@@ -49,17 +81,20 @@ module.exports = fp(async (fastify, options) => {
|
|
|
49
81
|
{
|
|
50
82
|
onRequest: options.getAuthenticate('site:create'),
|
|
51
83
|
schema: {
|
|
84
|
+
tags: [TAG],
|
|
52
85
|
summary: '创建站点并签发 shorten 链接',
|
|
86
|
+
description: '创建站点后通过 shorten 服务签发站点公开访问短码,返回带 host 的站点信息',
|
|
53
87
|
body: {
|
|
54
88
|
type: 'object',
|
|
55
89
|
required: ['name'],
|
|
56
90
|
properties: {
|
|
57
|
-
name: { type: 'string' },
|
|
58
|
-
defaultPermission: { type: 'string', enum: ['r', 'rw'] },
|
|
59
|
-
status: { type: 'string', enum: ['open', 'closed'] },
|
|
60
|
-
options:
|
|
91
|
+
name: { type: 'string', description: '站点名称,去除首尾空格后不能为空' },
|
|
92
|
+
defaultPermission: { type: 'string', enum: ['r', 'rw'], description: `新建节点默认${PERMISSION};默认 rw` },
|
|
93
|
+
status: { type: 'string', enum: ['open', 'closed'], description: `${SITE_STATUS};默认 open` },
|
|
94
|
+
options: siteOptions
|
|
61
95
|
}
|
|
62
|
-
}
|
|
96
|
+
},
|
|
97
|
+
response: success('创建后的站点', site)
|
|
63
98
|
}
|
|
64
99
|
},
|
|
65
100
|
async request => {
|
|
@@ -72,18 +107,21 @@ module.exports = fp(async (fastify, options) => {
|
|
|
72
107
|
{
|
|
73
108
|
onRequest: options.getAuthenticate('site:manage'),
|
|
74
109
|
schema: {
|
|
110
|
+
tags: [TAG],
|
|
75
111
|
summary: '修改站点(含开启/关闭)',
|
|
112
|
+
description: '只更新传入的字段;shorten、时间字段不可修改。关闭后站点公开入口与内容短链返回 403,重新开启后沿用原 host',
|
|
76
113
|
body: {
|
|
77
114
|
type: 'object',
|
|
78
115
|
required: ['id'],
|
|
79
116
|
properties: {
|
|
80
|
-
id: { type: 'string' },
|
|
81
|
-
name: { type: 'string' },
|
|
82
|
-
status: { type: 'string', enum: ['open', 'closed'] },
|
|
83
|
-
defaultPermission: { type: 'string', enum: ['r', 'rw'] },
|
|
84
|
-
options: {
|
|
117
|
+
id: { type: 'string', description: '站点 ID' },
|
|
118
|
+
name: { type: 'string', description: '站点名称,传入时去除首尾空格后不能为空' },
|
|
119
|
+
status: { type: 'string', enum: ['open', 'closed'], description: SITE_STATUS },
|
|
120
|
+
defaultPermission: { type: 'string', enum: ['r', 'rw'], description: `新建节点默认${PERMISSION}` },
|
|
121
|
+
options: Object.assign({}, siteOptions, { description: '站点扩展配置(JSON),整体覆盖原值' })
|
|
85
122
|
}
|
|
86
|
-
}
|
|
123
|
+
},
|
|
124
|
+
response: empty('保存成功')
|
|
87
125
|
}
|
|
88
126
|
},
|
|
89
127
|
async request => {
|
|
@@ -97,14 +135,17 @@ module.exports = fp(async (fastify, options) => {
|
|
|
97
135
|
{
|
|
98
136
|
onRequest: options.getAuthenticate('site:manage'),
|
|
99
137
|
schema: {
|
|
138
|
+
tags: [TAG],
|
|
100
139
|
summary: '删除站点',
|
|
140
|
+
description: '删除站点及其全部文件节点、内容分享记录,并移除站点短码与内容短码',
|
|
101
141
|
body: {
|
|
102
142
|
type: 'object',
|
|
103
143
|
required: ['id'],
|
|
104
144
|
properties: {
|
|
105
|
-
id: { type: 'string' }
|
|
145
|
+
id: { type: 'string', description: '站点 ID' }
|
|
106
146
|
}
|
|
107
|
-
}
|
|
147
|
+
},
|
|
148
|
+
response: empty('删除成功')
|
|
108
149
|
}
|
|
109
150
|
},
|
|
110
151
|
async request => {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// 响应 schema 会参与 fast-json-stringify 序列化:对象一律 additionalProperties: true 以免裁掉未声明字段;
|
|
2
|
+
// 用 nullable 而非类型数组(类型数组遇到不匹配的值会直接 500),类型不确定的字段(如 id、日期、JSON 列)只写说明不声明类型
|
|
3
|
+
const str = description => ({ type: 'string', nullable: true, description });
|
|
4
|
+
const num = description => ({ type: 'number', nullable: true, description });
|
|
5
|
+
const bool = description => ({ type: 'boolean', nullable: true, description });
|
|
6
|
+
const any = description => ({ description });
|
|
7
|
+
const list = (description, items) => ({ type: 'array', nullable: true, description, items });
|
|
8
|
+
const obj = (description, properties = {}) => ({ type: 'object', nullable: true, description, additionalProperties: true, properties });
|
|
9
|
+
|
|
10
|
+
const success = (description, schema) => ({
|
|
11
|
+
200: Object.assign({}, schema || { type: 'object' }, { description })
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const empty = (description = '操作成功,返回空对象') => success(description);
|
|
15
|
+
|
|
16
|
+
const PERMISSION = '权限:r 只读,rw 可读写';
|
|
17
|
+
const SITE_STATUS = '站点状态:open 开启,closed 关闭';
|
|
18
|
+
|
|
19
|
+
const site = obj('站点', {
|
|
20
|
+
id: any('站点 ID(字符串)'),
|
|
21
|
+
name: str('站点名称'),
|
|
22
|
+
shorten: str('站点公开访问短码'),
|
|
23
|
+
host: str('站点公开入口地址,由 getSiteHost(shorten) 生成,默认 {prefix}/{shorten}'),
|
|
24
|
+
status: str(SITE_STATUS),
|
|
25
|
+
defaultPermission: str(`新建节点默认${PERMISSION}`),
|
|
26
|
+
options: any('站点扩展配置(JSON),如 aiEnabled: false 关闭 AI'),
|
|
27
|
+
createdAt: any('创建时间'),
|
|
28
|
+
updatedAt: any('更新时间'),
|
|
29
|
+
deletedAt: any('删除时间,未删除为 null')
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const nodeBase = {
|
|
33
|
+
id: any('节点 ID(字符串)'),
|
|
34
|
+
name: str('节点名称'),
|
|
35
|
+
type: str('节点类型:file 文件,directory 文件夹'),
|
|
36
|
+
permission: str(PERMISSION)
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const treeNode = obj('文件树节点', Object.assign({}, nodeBase, { children: list('子节点列表(仅文件夹有),结构同本节点', obj('子节点')) }));
|
|
40
|
+
|
|
41
|
+
const contentShare = obj('内容分享短链', {
|
|
42
|
+
id: any('分享记录 ID(字符串)'),
|
|
43
|
+
nodeId: any('文件节点 ID(字符串)'),
|
|
44
|
+
shorten: str('内容短码(大写)'),
|
|
45
|
+
expires: any('过期时间,null 表示永久有效'),
|
|
46
|
+
url: str('内容访问地址:{getContentHost()}/content/{shorten},不带站点短码'),
|
|
47
|
+
createdAt: any('创建时间')
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
module.exports = { str, num, bool, any, list, obj, success, empty, PERMISSION, SITE_STATUS, site, nodeBase, treeNode, contentShare };
|
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.10",
|
|
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"
|