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