@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 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
 
@@ -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
- return readCachedCatalog(dir, name, version);
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, error: e.message, builtAt: Date.now() }, null, 2)),
132
- fsp.writeFile(path.join(dir, 'index.json'), JSON.stringify(empty.index, null, 2)),
133
- fsp.writeFile(path.join(dir, 'components.json'), JSON.stringify(empty.components, null, 2))
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
- return { remote: name, version, packageName, ...empty };
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
- const { index, components } = buildCatalogFromReadme(info.readme, name);
139
- await fsp.mkdir(dir, { recursive: true });
140
- await Promise.all([
141
- fsp.writeFile(path.join(dir, 'meta.json'), JSON.stringify({ packageName, version: info.version || version, builtAt: Date.now() }, null, 2)),
142
- fsp.writeFile(path.join(dir, 'index.json'), JSON.stringify(index, null, 2)),
143
- fsp.writeFile(path.join(dir, 'components.json'), JSON.stringify(components, null, 2))
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 catalogs = [];
158
- for (const remote of list) {
159
- const cat = await ensureRemoteCatalog(remote, options);
160
- if (cat) {
161
- catalogs.push(cat);
162
- }
163
- }
164
- return catalogs;
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
+ };
@@ -7,19 +7,55 @@ const stripHtml = html =>
7
7
  const LIVE_COMPONENT_SPEC = `
8
8
  ## Live 组件规范(必须遵守)
9
9
 
10
- 1. 编辑对象是配置里的 content 字段:一段 JSX 表达式源码,不是完整 .js 模块,也不是 PlantUML 编码串。
11
- 2. 运行时会编译为 render(<content>);,因此 content 应是可放入 render(...) 的表达式;不要写 import / export / 外层 render(。
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 全量)、props(合并后的组件参数)
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:{ name: { type, defaultValue } },代码里用 props.xxx;scope:{ 变量名: 'moduleToken' }。若使用了当前 scope 没有的远程组件,须通过 suggestScope 工具声明 suggestedScope。
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. 涉及 UI 结构时,必须用 ASCII 线框给出 2~4 个方案(A/B/C…),简述差异,请用户选择;选定前不要宣称需求已确认。
111
- 3. 信息与布局都够了后,用短结构化小结(目标 / 改动点 / 选用布局 / 建议组件 / 是否动 props·scope),并明确提示用户点击界面上的「开始生成」按钮。
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:若使用了当前 scope 没有的远程组件,调用此工具声明 { 变量名: 'remote:Component' }。
193
+ - suggestScope:声明需要合并进编辑器「组件域」的 scope,形如 { FormInfo: 'components-core:FormInfo' }。
194
+ - suggestProps:声明需要合并进编辑器「组件参数」的 props,形如 { title: { type: 'string', defaultValue: '标题' } }。
135
195
 
136
196
  输出契约:
137
197
  1. 优先纯代码;不要追问、不要复述需求、不要解释。
138
- 2. 选型:远程 → Antd → HTML。
139
- 3. 只能使用本次 libs 键与 scope∪suggestedScope。
140
- 4. 需要官方示例时根据 description 挑选后调用 getComponentExample。`
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: '声明需要合并进编辑器的 scope(远程组件 token)',
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
- params: {
80
+ description: ENTRY_HINT,
81
+ params: shortenParams,
82
+ response: success('站点公开信息', {
57
83
  type: 'object',
58
- required: ['shorten'],
84
+ additionalProperties: true,
59
85
  properties: {
60
- shorten: { type: 'string' }
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
- params: {
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
- shorten: { type: 'string' }
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
- body: {
129
+ response: success('任务已创建', {
84
130
  type: 'object',
131
+ additionalProperties: true,
85
132
  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' }
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
- params: {
109
- type: 'object',
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
- params: {
136
- type: 'object',
137
- required: ['shorten'],
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
- params: {
193
+ description: `${ENTRY_HINT}。节点不存在返回 404,非文件返回 400`,
194
+ params: shortenParams,
195
+ query: {
156
196
  type: 'object',
157
- required: ['shorten'],
197
+ required: ['id'],
158
198
  properties: {
159
- shorten: { type: 'string' }
199
+ id: { type: 'string', description: '文件节点 ID' }
160
200
  }
161
201
  },
162
- query: {
202
+ response: success('文件内容', {
163
203
  type: 'object',
164
- required: ['id'],
204
+ additionalProperties: true,
165
205
  properties: {
166
- id: { type: 'string' }
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
- params: {
183
- type: 'object',
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
- params: {
211
- type: 'object',
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
- params: {
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
- body: {
284
+ response: success('删除成功', {
245
285
  type: 'object',
246
- required: ['shorten'],
286
+ additionalProperties: true,
247
287
  properties: {
248
- shorten: { type: 'string' }
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
- params: {
265
- type: 'object',
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
- params: {
293
- type: 'object',
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
- params: {
356
+ description: `${ENTRY_HINT}。只读节点返回 403,非文件返回 400`,
357
+ params: shortenParams,
358
+ body: {
322
359
  type: 'object',
323
- required: ['shorten'],
360
+ required: ['id'],
324
361
  properties: {
325
- shorten: { type: 'string' }
362
+ id: { type: 'string', description: '文件节点 ID' },
363
+ content: { type: 'string', description: '新的文件内容,不传保存为空字符串' }
326
364
  }
327
365
  },
328
- body: {
366
+ response: success('保存后的文件内容', {
329
367
  type: 'object',
330
- required: ['id'],
368
+ additionalProperties: true,
331
369
  properties: {
332
- id: { type: 'string' },
333
- content: { type: 'string' }
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
- params: {
350
- type: 'object',
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/remove`,
407
+ `${options.prefix}/:shorten/move`,
373
408
  {
374
409
  onRequest: withEntryAuth,
375
410
  schema: {
376
- summary: '删除节点',
377
- params: {
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: ['shorten'],
417
+ required: ['id'],
380
418
  properties: {
381
- shorten: { type: 'string' }
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 => {
@@ -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: { type: 'object', default: {} },
16
- perPage: { type: 'number', default: 20 },
17
- currentPage: { type: 'number', default: 1 }
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: { type: 'object' }
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: { type: 'object' }
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 };
@@ -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
- const catalogs = await ensureRemotesCatalogs(remotes, options);
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`);
@@ -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.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": "^5.0.220",
45
+ "ai": "^7.0.37",
46
46
  "http-errors": "^2.0.0",
47
47
  "lodash": "^4.17.21",
48
48
  "zod": "^3.25.76"