tencent-edu-assistant-connector 0.2.0 → 0.3.0
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 +1 -0
- package/dist/artifact-validate.js +65 -2
- package/dist/bridge.js +54 -14
- package/dist/config.js +1 -0
- package/dist/cos-upload.js +20 -7
- package/dist/daemon.js +15 -3
- package/dist/generation-store.js +54 -9
- package/dist/hook-text.js +12 -0
- package/dist/landed-store.js +1 -1
- package/dist/new-topic.js +19 -12
- package/dist/skill-packages.js +56 -5
- package/dist/tools.js +131 -45
- package/dist/types.js +3 -3
- package/dist/wb-files.js +12 -5
- package/package.json +1 -1
- package/plugin/.codebuddy-plugin/marketplace.json +1 -1
- package/plugin/plugins/tencent-edu-assistant/.codebuddy-plugin/plugin.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* 产物内容校验:交付前按类别校验后缀与内容,失败返回可读中文原因供模型修正后重试。
|
|
3
|
+
*
|
|
4
|
+
* quiz / flashcard 结构对齐前端解析器(learning-buddy-web):
|
|
5
|
+
* - 顶层:数组,或 `{ questions: [...] }` / `{ cards: [...] }`(topic-detail.vue `asProductList`)
|
|
6
|
+
* - quiz 每题:`stem|question`、`options`(数组 `label|key|letter` + `text|value`,或对象 `{ A: '...' }`)、`answer`
|
|
7
|
+
* (productNormalization.ts `normalizeQuizQuestions`)
|
|
8
|
+
* - flashcard 每张:`term|front|q`、`question|stem|q`、`answer|back|explanation|a`
|
|
9
|
+
* (productNormalization.ts `normalizeFlashcardCards`)
|
|
10
|
+
* 字段取值与前端一致:按候选字段顺序取第一个 truthy 值。
|
|
11
|
+
*/
|
|
12
|
+
import { fileExtOf, isSupportedSceneExt, SCENE_HTML_EXTS } from './skill-packages.js';
|
|
13
|
+
/** 固定类别产物的文件名后缀约定(scene 的格式由任务的 sceneExts 决定) */
|
|
2
14
|
export const ARTIFACT_SUFFIX_BY_KIND = Object.freeze({
|
|
3
15
|
note: '-note.md',
|
|
4
16
|
quiz: '-quiz.json',
|
|
@@ -145,7 +157,7 @@ function decodeText(content) {
|
|
|
145
157
|
/**
|
|
146
158
|
* 校验产物文件:后缀与类别一致、内容非空、按类别校验结构。不抛异常。
|
|
147
159
|
*
|
|
148
|
-
* @param kind
|
|
160
|
+
* @param kind 产物类别(固定后缀类别;scene 用 {@link validateSceneArtifact})
|
|
149
161
|
* @param fileName 文件名(大小写不敏感匹配后缀)
|
|
150
162
|
* @param content 文件内容(Buffer 按 UTF-8 解码)
|
|
151
163
|
* @returns 成功返回去 BOM 后的文本;失败返回可读中文原因
|
|
@@ -161,3 +173,54 @@ export function validateArtifact(kind, fileName, content) {
|
|
|
161
173
|
const error = checkContent(kind, text);
|
|
162
174
|
return error ? { ok: false, reason: error } : { ok: true, text };
|
|
163
175
|
}
|
|
176
|
+
/** ISO BMFF 容器(mp4 / mov / m4v):第 4~8 字节为 `ftyp` */
|
|
177
|
+
const ISO_BMFF_EXTS = Object.freeze(['mp4', 'mov', 'm4v']);
|
|
178
|
+
/** EBML 容器(webm)文件头 */
|
|
179
|
+
const EBML_MAGIC = Buffer.from([0x1a, 0x45, 0xdf, 0xa3]);
|
|
180
|
+
/**
|
|
181
|
+
* 视频文件头校验(只看容器魔数,不解析流);未知格式放行,由后端校验。
|
|
182
|
+
* @param ext 扩展名
|
|
183
|
+
* @param content 文件内容
|
|
184
|
+
*/
|
|
185
|
+
function hasVideoSignature(ext, content) {
|
|
186
|
+
if (ISO_BMFF_EXTS.includes(ext))
|
|
187
|
+
return content.length >= 8 && content.toString('latin1', 4, 8) === 'ftyp';
|
|
188
|
+
if (ext === 'webm')
|
|
189
|
+
return content.length >= 4 && content.subarray(0, 4).equals(EBML_MAGIC);
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* 渲染允许的扩展名列表,如「.html、.mp4」。
|
|
194
|
+
* @param exts 扩展名(不含点)
|
|
195
|
+
*/
|
|
196
|
+
export function formatSceneExts(exts) {
|
|
197
|
+
return exts.map((ext) => `.${ext}`).join('、');
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* 校验场景产物:扩展名须在任务允许列表内且受 MCP 支持、内容非空;HTML 类须为完整 HTML 文档(含 `<html`)。
|
|
201
|
+
* 视频等二进制产物不解码、不改写字节。不抛异常。
|
|
202
|
+
*
|
|
203
|
+
* @param fileName 实际文件名
|
|
204
|
+
* @param content 文件内容
|
|
205
|
+
* @param allowedExts 任务允许的扩展名(小写、不含点)
|
|
206
|
+
*/
|
|
207
|
+
export function validateSceneArtifact(fileName, content, allowedExts) {
|
|
208
|
+
const ext = fileExtOf(fileName);
|
|
209
|
+
if (!ext || !allowedExts.includes(ext) || !isSupportedSceneExt(ext)) {
|
|
210
|
+
const allowed = formatSceneExts(allowedExts.filter(isSupportedSceneExt));
|
|
211
|
+
return { ok: false, reason: `产物格式不符:本场景只允许 ${allowed || '(无可交付格式)'},当前为 ${truncate(fileName)}` };
|
|
212
|
+
}
|
|
213
|
+
if (content.length === 0)
|
|
214
|
+
return { ok: false, reason: '场景产物内容为空' };
|
|
215
|
+
if (!SCENE_HTML_EXTS.includes(ext)) {
|
|
216
|
+
return hasVideoSignature(ext, content)
|
|
217
|
+
? { ok: true, content }
|
|
218
|
+
: { ok: false, reason: `文件内容不是有效的 .${ext} 视频(请确认已完成渲染与编码,而不是写入了文本或占位内容)` };
|
|
219
|
+
}
|
|
220
|
+
const text = decodeText(content);
|
|
221
|
+
if (!text.trim())
|
|
222
|
+
return { ok: false, reason: '场景产物内容为空' };
|
|
223
|
+
if (!/<html/i.test(text))
|
|
224
|
+
return { ok: false, reason: '场景 HTML 需包含 <html 标签(完整 HTML 文档)' };
|
|
225
|
+
return { ok: true, content: Buffer.from(text, 'utf8') };
|
|
226
|
+
}
|
package/dist/bridge.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* | `GET /context/hook` | `text/plain; charset=utf-8` Hook 文本,无上下文且无「新建专题」任务时为空串;**副作用**:先把当前上下文专题与 `NEW_TOPIC_SCOPE` 的 filled / stopped 任务标记为 sent(无会话,兜底) |
|
|
16
16
|
* | `POST /hooks/prompt-submit` | WB `UserPromptSubmit` Hook 回调(command Hook curl 转发 stdin):`markSent(当前上下文专题 / NEW_TOPIC_SCOPE, session_id)`;**恒为 200 `{}`** |
|
|
17
17
|
* | `POST /hooks/final-stop` | WB `FinalStop` Hook 回调(command Hook curl 转发 stdin):`finalStop(session_id, final_stop_reason, 当前上下文专题 / NEW_TOPIC_SCOPE)`;**恒为 200 `{}`** |
|
|
18
|
-
* | `POST /generation` | `{ requestId, kind, topicId, createdAt }`(requestId 由服务端生成);`topicId=NEW_TOPIC_SCOPE` 为首页 AI
|
|
18
|
+
* | `POST /generation` | `{ requestId, kind, topicId, createdAt }`(requestId 由服务端生成);`topicId=NEW_TOPIC_SCOPE` 为首页 AI 生课(course)/ 精选场景(scene,须带 `sceneId` / `sceneExts` / `skillPackageUrl`),均须带 `newTopicName` |
|
|
19
19
|
* | `GET /generation/status?topicId=` | `{ tasks }`(filled / sent / running / stopped / failed,不含 delivered、token 与会话 ID) |
|
|
20
20
|
* | `GET /landed?topicId=` | `{ items }` |
|
|
21
21
|
* | `POST /landed/ack` | `{ removed }` |
|
|
@@ -48,7 +48,9 @@ const pageSchema = z.number().int().positive();
|
|
|
48
48
|
/** 文件名(上下文中的展示名 / 产物文件名) */
|
|
49
49
|
const fileNameSchema = z.string().max(255);
|
|
50
50
|
/** 生成类别 */
|
|
51
|
-
const generationKindSchema = z.enum(['note', 'quiz', 'flashcard', 'course']);
|
|
51
|
+
const generationKindSchema = z.enum(['note', 'quiz', 'flashcard', 'course', 'scene']);
|
|
52
|
+
/** 可登记在「新建专题」作用域的类别 */
|
|
53
|
+
const NEW_TOPIC_KINDS = Object.freeze(['course', 'scene']);
|
|
52
54
|
/**
|
|
53
55
|
* 判断字符串是否为 `https:` URL。
|
|
54
56
|
* @param value 待校验字符串
|
|
@@ -102,27 +104,65 @@ export const generationBodySchema = z
|
|
|
102
104
|
.transform((url) => (url === '' ? undefined : url)),
|
|
103
105
|
fileName: fileNameSchema.min(1),
|
|
104
106
|
token: tokenSchema,
|
|
105
|
-
//
|
|
107
|
+
// 「新建专题」任务(topicId = NEW_TOPIC_SCOPE)携带:新专题名称(course 为兜底名,scene 为场景标题)
|
|
106
108
|
newTopicName: z.string().trim().min(1).max(100).optional(),
|
|
109
|
+
// 仅场景任务携带:场景 ID 与允许的产物扩展名(小写、不含点)
|
|
110
|
+
sceneId: z.string().regex(/^[1-9]\d{0,19}$/, 'must be a decimal id').optional(),
|
|
111
|
+
sceneExts: z
|
|
112
|
+
.array(z.string().regex(/^[a-z0-9]{1,10}$/, 'must be a lowercase extension without dot'))
|
|
113
|
+
.min(1)
|
|
114
|
+
.max(20)
|
|
115
|
+
.optional(),
|
|
107
116
|
})
|
|
108
117
|
.superRefine((body, ctx) => {
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
118
|
+
const issue = (path, message) => {
|
|
119
|
+
ctx.addIssue({ code: 'custom', path: [path], message });
|
|
120
|
+
};
|
|
121
|
+
if (body.kind === 'scene') {
|
|
122
|
+
refineSceneBody(body, issue);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
const suffix = ARTIFACT_SUFFIX_BY_KIND[body.kind];
|
|
126
|
+
if (!body.fileName.toLowerCase().endsWith(suffix))
|
|
127
|
+
issue('fileName', `must end with ${suffix}`);
|
|
128
|
+
if (body.sceneId !== undefined)
|
|
129
|
+
issue('sceneId', 'only allowed when kind is scene');
|
|
130
|
+
if (body.sceneExts !== undefined)
|
|
131
|
+
issue('sceneExts', 'only allowed when kind is scene');
|
|
112
132
|
}
|
|
113
133
|
if (body.topicId !== NEW_TOPIC_SCOPE) {
|
|
114
|
-
if (body.newTopicName !== undefined)
|
|
115
|
-
|
|
116
|
-
}
|
|
134
|
+
if (body.newTopicName !== undefined)
|
|
135
|
+
issue('newTopicName', `only allowed when topicId is ${NEW_TOPIC_SCOPE}`);
|
|
117
136
|
return;
|
|
118
137
|
}
|
|
119
|
-
if (body.kind
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
if (body.newTopicName === undefined) {
|
|
123
|
-
ctx.addIssue({ code: 'custom', path: ['newTopicName'], message: `required when topicId is ${NEW_TOPIC_SCOPE}` });
|
|
138
|
+
if (!NEW_TOPIC_KINDS.includes(body.kind)) {
|
|
139
|
+
issue('kind', `must be ${NEW_TOPIC_KINDS.join(' or ')} when topicId is ${NEW_TOPIC_SCOPE}`);
|
|
124
140
|
}
|
|
141
|
+
if (body.newTopicName === undefined)
|
|
142
|
+
issue('newTopicName', `required when topicId is ${NEW_TOPIC_SCOPE}`);
|
|
125
143
|
});
|
|
144
|
+
/**
|
|
145
|
+
* 场景任务的请求体约束:只能登记在 NEW_TOPIC_SCOPE,必须带 sceneId / sceneExts / skillPackageUrl,
|
|
146
|
+
* 文件名扩展名在 sceneExts 内(扩展名是否受支持由 store 校验)。
|
|
147
|
+
* @param body 请求体
|
|
148
|
+
* @param issue 追加校验问题
|
|
149
|
+
*/
|
|
150
|
+
function refineSceneBody(body, issue) {
|
|
151
|
+
if (body.topicId !== NEW_TOPIC_SCOPE)
|
|
152
|
+
issue('topicId', `must be ${NEW_TOPIC_SCOPE} when kind is scene`);
|
|
153
|
+
if (body.sceneId === undefined)
|
|
154
|
+
issue('sceneId', 'required when kind is scene');
|
|
155
|
+
if (body.skillPackageUrl === undefined)
|
|
156
|
+
issue('skillPackageUrl', 'required when kind is scene');
|
|
157
|
+
if (body.sceneExts === undefined) {
|
|
158
|
+
issue('sceneExts', 'required when kind is scene');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const dot = body.fileName.lastIndexOf('.');
|
|
162
|
+
const ext = dot > 0 ? body.fileName.slice(dot + 1).toLowerCase() : '';
|
|
163
|
+
if (!body.sceneExts.includes(ext))
|
|
164
|
+
issue('fileName', 'extension must be one of sceneExts');
|
|
165
|
+
}
|
|
126
166
|
/** `GET /landed` 查询参数 */
|
|
127
167
|
export const landedQuerySchema = z.object({ topicId: topicIdSchema });
|
|
128
168
|
/** `GET /generation/status` 查询参数 */
|
package/dist/config.js
CHANGED
package/dist/cos-upload.js
CHANGED
|
@@ -7,22 +7,25 @@
|
|
|
7
7
|
* - Content-Type 按 key 后缀取规范 MIME(不带参数,对齐 STS policy 允许集合);
|
|
8
8
|
* - `topic_file/create` 是 (topic_id, file_type) 整批覆盖:先拉该类型全量 → 按 object key 去重合并 → 提交,
|
|
9
9
|
* 按 (topicId, fileType) 串行排队;
|
|
10
|
-
* - `file_type` 与类别一一对应(note / quiz / flashcard / course
|
|
10
|
+
* - `file_type` 与类别一一对应(note / quiz / flashcard / course / scene);scene 产物 key 为 `{topicId}_scene`,
|
|
11
|
+
* 后端要求 object key 位于 `lb/topic/{topicId}_scene/` 下并 HEAD 取真实大小。
|
|
11
12
|
*/
|
|
12
13
|
import COS from 'cos-nodejs-sdk-v5';
|
|
13
14
|
import { createEduAssistantApi, describeError, hasDotSegment, redactSensitive, } from './edu-assistant-api.js';
|
|
14
|
-
|
|
15
|
+
import { SCENE_ARTIFACT_MIME_BY_EXT } from './skill-packages.js';
|
|
16
|
+
/** 生成类别 → topic_file.file_type(对齐前端 useTopicProductIO / courseGeneratePersist;scene → 场景产物) */
|
|
15
17
|
const FILE_TYPE_BY_KIND = Object.freeze({
|
|
16
18
|
note: 'note',
|
|
17
19
|
quiz: 'quiz',
|
|
18
20
|
flashcard: 'flashcard',
|
|
19
21
|
course: 'course',
|
|
22
|
+
scene: 'scene',
|
|
20
23
|
});
|
|
21
24
|
/** object key 后缀 → 规范 MIME(对齐前端 cosDirectUpload SPEC_MIME_BY_EXT,不带 charset 参数) */
|
|
22
25
|
const CONTENT_TYPE_BY_EXT = Object.freeze({
|
|
23
26
|
md: 'text/markdown',
|
|
24
27
|
json: 'application/json',
|
|
25
|
-
|
|
28
|
+
...SCENE_ARTIFACT_MIME_BY_EXT,
|
|
26
29
|
});
|
|
27
30
|
/** 专题 ID 白名单(对齐前端 cosKey OWNER_ID_PATTERN) */
|
|
28
31
|
const TOPIC_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
@@ -37,8 +40,17 @@ const FALLBACK_FILE_NAME = 'file';
|
|
|
37
40
|
/** 合并基准翻页:上限 10 页 × 100 条(对齐前端) */
|
|
38
41
|
const REGISTER_PAGE_SIZE = 100;
|
|
39
42
|
const REGISTER_MAX_PAGES = 10;
|
|
40
|
-
/** COS
|
|
43
|
+
/** COS 直传最短超时(毫秒) */
|
|
41
44
|
const COS_UPLOAD_TIMEOUT_MS = 120_000;
|
|
45
|
+
/** 按最低上行速率估算大文件超时(字节 / 秒):200MB 约 13 分钟 */
|
|
46
|
+
const COS_UPLOAD_MIN_BYTES_PER_SEC = 256 * 1024;
|
|
47
|
+
/**
|
|
48
|
+
* 按文件大小估算 COS 直传超时:不低于 {@link COS_UPLOAD_TIMEOUT_MS}。
|
|
49
|
+
* @param bytes 文件字节数
|
|
50
|
+
*/
|
|
51
|
+
export function cosUploadTimeoutMs(bytes) {
|
|
52
|
+
return Math.max(COS_UPLOAD_TIMEOUT_MS, Math.ceil(bytes / COS_UPLOAD_MIN_BYTES_PER_SEC) * 1000);
|
|
53
|
+
}
|
|
42
54
|
/** 按 (topicId, fileType) 串行排队的登记任务 */
|
|
43
55
|
const registerQueues = new Map();
|
|
44
56
|
/**
|
|
@@ -127,13 +139,14 @@ function toBareCosUrl(cred) {
|
|
|
127
139
|
/**
|
|
128
140
|
* 默认 COS 客户端:cos-nodejs-sdk-v5 + 临时密钥,callback 包装为 Promise。
|
|
129
141
|
* @param cred 凭证
|
|
142
|
+
* @param options 超时等选项
|
|
130
143
|
*/
|
|
131
|
-
function createDefaultCosClient(cred) {
|
|
144
|
+
function createDefaultCosClient(cred, options) {
|
|
132
145
|
const cos = new COS({
|
|
133
146
|
SecretId: cred.secretId,
|
|
134
147
|
SecretKey: cred.secretKey,
|
|
135
148
|
SecurityToken: cred.securityToken,
|
|
136
|
-
Timeout:
|
|
149
|
+
Timeout: options.timeoutMs,
|
|
137
150
|
});
|
|
138
151
|
return {
|
|
139
152
|
putObject: (params) => new Promise((resolve, reject) => {
|
|
@@ -256,7 +269,7 @@ export async function uploadArtifactToTopic(input, deps = {}) {
|
|
|
256
269
|
const fileName = sanitizeUploadFileName(input.fileName);
|
|
257
270
|
const cred = await runStep('获取上传凭证失败', [token], async () => requireCredential(await api.getCosCredential(token, { fileName, key: `${topicId}_${fileType}` })));
|
|
258
271
|
const secrets = [token, cred.secretId, cred.secretKey, cred.securityToken];
|
|
259
|
-
await runStep('上传 COS 失败', secrets, () => putToCos(cosFactory(cred), cred, content));
|
|
272
|
+
await runStep('上传 COS 失败', secrets, () => putToCos(cosFactory(cred, { timeoutMs: cosUploadTimeoutMs(content.length) }), cred, content));
|
|
260
273
|
const cosUrl = toBareCosUrl(cred);
|
|
261
274
|
const fileExt = extractExt(fileName);
|
|
262
275
|
const records = await runStep('登记专题文件失败', secrets, () => registerMerged(api, token, topicId, fileType, {
|
package/dist/daemon.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 背景:WB 只在会话运行期间保活 stdio MCP(关闭时 stdin.end → 2s → SIGTERM → 2s → SIGKILL;
|
|
5
5
|
* 添加连接器时的探测连接 1~2 秒即断开),而页面需要端口常驻。因此:
|
|
6
|
-
* - stdio 进程启动时 {@link ensureDaemon}
|
|
6
|
+
* - stdio 进程启动时 {@link ensureDaemon}:未运行则经双 fork 拉起 `stdio.js daemon`(daemon 被系统收养,脱离 WB
|
|
7
|
+
* 进程树,见 `DAEMON_LAUNCHER_SCRIPT`),自身随后作为 follower
|
|
7
8
|
* 经 `/internal` 转发工具调用;daemon 即端口主从中的 leader,页面上下文与生成任务跨会话保留。
|
|
8
9
|
* - 版本不一致(升级后旧 daemon 仍在运行):经 `POST /internal/shutdown` 请旧 daemon 退出后拉起当前版本;
|
|
9
10
|
* 旧版本不支持该接口时保持现状(本进程作为 follower 使用旧服务)。
|
|
@@ -128,7 +129,18 @@ function openDaemonLog(home) {
|
|
|
128
129
|
}
|
|
129
130
|
}
|
|
130
131
|
/**
|
|
131
|
-
*
|
|
132
|
+
* 双 fork 中间进程脚本:以 detached 方式拉起真正的 daemon(参数为 `process.argv.slice(1)`,继承本进程的
|
|
133
|
+
* stdio 即日志文件)后立即退出,daemon 随之被系统收养(ppid=1)。
|
|
134
|
+
*
|
|
135
|
+
* 只用 `detached` 不够:它只让 daemon 脱离进程组,ppid 仍是 stdio 进程;WB 回收 CLI 进程(会话结束 /
|
|
136
|
+
* 预热池轮换)时按 ppid 遍历进程树发 SIGTERM,daemon 会被连带结束,页面上下文与已登记的生成任务随之丢失。
|
|
137
|
+
*/
|
|
138
|
+
export const DAEMON_LAUNCHER_SCRIPT = [
|
|
139
|
+
"const { spawn } = require('node:child_process');",
|
|
140
|
+
'spawn(process.execPath, process.argv.slice(1), { detached: true, stdio: \'inherit\', env: process.env, windowsHide: true }).unref();',
|
|
141
|
+
].join('\n');
|
|
142
|
+
/**
|
|
143
|
+
* 经双 fork 中间进程拉起 daemon(见 {@link DAEMON_LAUNCHER_SCRIPT}),使其脱离 WB 的进程树。
|
|
132
144
|
* @param options 参数
|
|
133
145
|
*/
|
|
134
146
|
function spawnDaemon(options) {
|
|
@@ -136,7 +148,7 @@ function spawnDaemon(options) {
|
|
|
136
148
|
const spawnImpl = options.spawnImpl ?? spawn;
|
|
137
149
|
const log = openDaemonLog(options.home);
|
|
138
150
|
try {
|
|
139
|
-
const child = spawnImpl(process.execPath, [options.entry, DAEMON_SUBCOMMAND], {
|
|
151
|
+
const child = spawnImpl(process.execPath, ['-e', DAEMON_LAUNCHER_SCRIPT, options.entry, DAEMON_SUBCOMMAND], {
|
|
140
152
|
detached: true,
|
|
141
153
|
stdio: ['ignore', log, log],
|
|
142
154
|
env: options.env,
|
package/dist/generation-store.js
CHANGED
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
* (stopReason `ended`,回复结束但仍未交付)。
|
|
16
16
|
* - `reportProgress`:模型上报进度,`filled | sent | running | stopped` → `running`(覆盖 progress);stage 为 `failed` → `failed`。
|
|
17
17
|
* - 交付成功 → `delivered`(仅未完成任务可交付)。
|
|
18
|
-
* - 「新建专题」任务:`topicId` 为保留作用域 `NEW_TOPIC_SCOPE`,仅允许 course 且必须带 `newTopicName`;
|
|
18
|
+
* - 「新建专题」任务:`topicId` 为保留作用域 `NEW_TOPIC_SCOPE`,仅允许 course / scene 且必须带 `newTopicName`;
|
|
19
19
|
* 其余专题不得携带 `newTopicName`。状态机与普通任务一致。
|
|
20
|
+
* - 场景任务(scene):只能登记在 `NEW_TOPIC_SCOPE`,必须带 `sceneId`、`sceneExts` 与技能包地址;
|
|
21
|
+
* 技能 slug 来自场景配置(格式受限),产物文件名扩展名须在 `sceneExts` 内。其余类别不得携带场景字段。
|
|
20
22
|
*
|
|
21
23
|
* 过期(各方法惰性清理):
|
|
22
24
|
* - `filled`:自 createdAt 起 `ttlMs`(默认 30 分钟);
|
|
@@ -32,10 +34,16 @@
|
|
|
32
34
|
*/
|
|
33
35
|
import { getConfig } from './config.js';
|
|
34
36
|
import { redactSensitive } from './edu-assistant-api.js';
|
|
35
|
-
import { isAllowedSkillPackageUrl, SKILL_SLUG_BY_KIND } from './skill-packages.js';
|
|
37
|
+
import { fileExtOf, isAllowedSkillPackageUrl, isSupportedSceneExt, isValidSceneSkillSlug, SKILL_SLUG_BY_KIND, } from './skill-packages.js';
|
|
36
38
|
import { NEW_TOPIC_SCOPE } from './types.js';
|
|
37
39
|
/** 合法的生成类别 */
|
|
38
|
-
const GENERATION_KINDS = Object.freeze(['note', 'quiz', 'flashcard', 'course']);
|
|
40
|
+
const GENERATION_KINDS = Object.freeze(['note', 'quiz', 'flashcard', 'course', 'scene']);
|
|
41
|
+
/** 可登记在「新建专题」作用域的类别 */
|
|
42
|
+
const NEW_TOPIC_KINDS = Object.freeze(['course', 'scene']);
|
|
43
|
+
/** 场景 ID:十进制数字串(uint64) */
|
|
44
|
+
const SCENE_ID_PATTERN = /^[1-9]\d{0,19}$/;
|
|
45
|
+
/** 场景允许扩展名个数上限 */
|
|
46
|
+
const SCENE_EXTS_MAX = 20;
|
|
39
47
|
/** 合法的生成阶段 */
|
|
40
48
|
const GENERATION_STAGES = Object.freeze([
|
|
41
49
|
'reading',
|
|
@@ -97,33 +105,66 @@ function assertValidInput(input) {
|
|
|
97
105
|
if (!GENERATION_KINDS.includes(input.kind)) {
|
|
98
106
|
throw new Error(`生成任务字段 kind 只允许:${GENERATION_KINDS.join('、')}`);
|
|
99
107
|
}
|
|
100
|
-
if (input.skillSlug !== SKILL_SLUG_BY_KIND[input.kind]) {
|
|
101
|
-
throw new Error('生成任务字段 skillSlug 与 kind 不匹配');
|
|
102
|
-
}
|
|
103
108
|
if (typeof input.params !== 'object' || input.params === null || Array.isArray(input.params)) {
|
|
104
109
|
throw new Error('生成任务字段 params 必须是对象');
|
|
105
110
|
}
|
|
106
111
|
if (input.skillPackageUrl !== undefined && typeof input.skillPackageUrl !== 'string') {
|
|
107
112
|
throw new Error('生成任务字段 skillPackageUrl 必须是字符串');
|
|
108
113
|
}
|
|
114
|
+
if (input.kind === 'scene') {
|
|
115
|
+
assertValidScene(input);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
if (input.skillSlug !== SKILL_SLUG_BY_KIND[input.kind])
|
|
119
|
+
throw new Error('生成任务字段 skillSlug 与 kind 不匹配');
|
|
120
|
+
if (input.sceneId !== undefined || input.sceneExts !== undefined) {
|
|
121
|
+
throw new Error('生成任务字段 sceneId / sceneExts 仅允许用于场景任务');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
109
124
|
if (input.skillPackageUrl !== undefined && !isAllowedSkillPackageUrl(input.skillPackageUrl, input.skillSlug)) {
|
|
110
125
|
throw new Error('生成任务字段 skillPackageUrl 不是允许的技能包地址');
|
|
111
126
|
}
|
|
112
127
|
assertValidNewTopic(input);
|
|
113
128
|
}
|
|
114
129
|
/**
|
|
115
|
-
*
|
|
130
|
+
* 校验场景任务字段:slug 格式、必须带技能包地址、场景 ID、允许扩展名(均受 MCP 支持),文件名扩展名在允许列表内。
|
|
131
|
+
* @param input 登记入参(kind 为 scene)
|
|
132
|
+
* @throws {Error} 入参非法(消息只含字段名)
|
|
133
|
+
*/
|
|
134
|
+
function assertValidScene(input) {
|
|
135
|
+
if (!isValidSceneSkillSlug(input.skillSlug))
|
|
136
|
+
throw new Error('生成任务字段 skillSlug 格式不合法');
|
|
137
|
+
if (input.skillPackageUrl === undefined)
|
|
138
|
+
throw new Error('场景任务必须携带 skillPackageUrl');
|
|
139
|
+
if (typeof input.sceneId !== 'string' || !SCENE_ID_PATTERN.test(input.sceneId)) {
|
|
140
|
+
throw new Error('生成任务字段 sceneId 必须是十进制数字串');
|
|
141
|
+
}
|
|
142
|
+
const exts = input.sceneExts;
|
|
143
|
+
const isValidExts = Array.isArray(exts) &&
|
|
144
|
+
exts.length > 0 &&
|
|
145
|
+
exts.length <= SCENE_EXTS_MAX &&
|
|
146
|
+
exts.every((ext) => typeof ext === 'string' && isSupportedSceneExt(ext));
|
|
147
|
+
if (!isValidExts)
|
|
148
|
+
throw new Error('生成任务字段 sceneExts 必须是 MCP 支持的扩展名数组');
|
|
149
|
+
if (!exts.includes(fileExtOf(input.fileName)))
|
|
150
|
+
throw new Error('生成任务字段 fileName 的扩展名不在 sceneExts 内');
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* 校验「新建专题」字段:保留作用域只允许 course / scene 且必须带名称;场景任务只能用保留作用域;
|
|
154
|
+
* 普通专题不得携带名称。
|
|
116
155
|
* @param input 登记入参
|
|
117
156
|
* @throws {Error} 入参非法(消息只含字段名)
|
|
118
157
|
*/
|
|
119
158
|
function assertValidNewTopic(input) {
|
|
120
159
|
if (input.topicId !== NEW_TOPIC_SCOPE) {
|
|
160
|
+
if (input.kind === 'scene')
|
|
161
|
+
throw new Error(`场景任务的 topicId 只允许 ${NEW_TOPIC_SCOPE}`);
|
|
121
162
|
if (input.newTopicName !== undefined)
|
|
122
163
|
throw new Error('生成任务字段 newTopicName 仅允许用于新建专题');
|
|
123
164
|
return;
|
|
124
165
|
}
|
|
125
|
-
if (input.kind
|
|
126
|
-
throw new Error(
|
|
166
|
+
if (!NEW_TOPIC_KINDS.includes(input.kind))
|
|
167
|
+
throw new Error(`新建专题任务的 kind 只允许 ${NEW_TOPIC_KINDS.join('、')}`);
|
|
127
168
|
if (!isNonEmptyString(input.newTopicName))
|
|
128
169
|
throw new Error('生成任务字段 newTopicName 必须是非空字符串');
|
|
129
170
|
}
|
|
@@ -154,6 +195,8 @@ function cloneTask(task) {
|
|
|
154
195
|
fileName: task.fileName,
|
|
155
196
|
token: task.token,
|
|
156
197
|
...(task.newTopicName === undefined ? {} : { newTopicName: task.newTopicName }),
|
|
198
|
+
...(task.sceneId === undefined ? {} : { sceneId: task.sceneId }),
|
|
199
|
+
...(task.sceneExts === undefined ? {} : { sceneExts: [...task.sceneExts] }),
|
|
157
200
|
createdAt: task.createdAt,
|
|
158
201
|
updatedAt: task.updatedAt,
|
|
159
202
|
...(task.sentAt === undefined ? {} : { sentAt: task.sentAt }),
|
|
@@ -431,6 +474,8 @@ export function createGenerationStore(options = {}) {
|
|
|
431
474
|
fileName: input.fileName,
|
|
432
475
|
token: input.token,
|
|
433
476
|
...(input.newTopicName === undefined ? {} : { newTopicName: input.newTopicName }),
|
|
477
|
+
...(input.sceneId === undefined ? {} : { sceneId: input.sceneId }),
|
|
478
|
+
...(input.sceneExts === undefined ? {} : { sceneExts: input.sceneExts }),
|
|
434
479
|
createdAt,
|
|
435
480
|
updatedAt: createdAt,
|
|
436
481
|
status: 'filled',
|
package/dist/hook-text.js
CHANGED
|
@@ -35,6 +35,7 @@ const KIND_LABELS = Object.freeze({
|
|
|
35
35
|
quiz: '测验',
|
|
36
36
|
flashcard: '闪卡',
|
|
37
37
|
course: '课程',
|
|
38
|
+
scene: '场景作品',
|
|
38
39
|
});
|
|
39
40
|
/**
|
|
40
41
|
* 各类别进度上报的差异说明(拼在通用说明之后):done / total 的含义随产物类型不同。
|
|
@@ -44,6 +45,7 @@ export const KIND_PROGRESS_HINTS = Object.freeze({
|
|
|
44
45
|
quiz: 'generating 阶段 done/total = 已完成题数/总题数(按批次上报即可)。',
|
|
45
46
|
flashcard: 'generating 阶段 done/total = 已完成卡片数/总卡片数。',
|
|
46
47
|
course: 'outlining 完成后即可确定章节数;generating 阶段每完成一章上报一次 done/total = 已完成章节/总章节。',
|
|
48
|
+
scene: 'generating 阶段按技能的实际步骤(如分镜 / 片段 / 页面)成对上报 done/total;无法计数时只报 stage 与 message。',
|
|
47
49
|
});
|
|
48
50
|
/**
|
|
49
51
|
* 把尖括号替换为全角,保留其余字符(含换行)。
|
|
@@ -253,6 +255,16 @@ function renderStoppedLine(task, requestId) {
|
|
|
253
255
|
function renderNewTopicLine(task) {
|
|
254
256
|
if (task.topicId !== NEW_TOPIC_SCOPE)
|
|
255
257
|
return '';
|
|
258
|
+
if (task.kind === 'scene') {
|
|
259
|
+
const exts = (task.sceneExts ?? []).map((ext) => `.${inline(ext)}`).join(' / ');
|
|
260
|
+
const maxMb = Math.floor(getConfig().limits.sceneDeliverFileMaxBytes / (1024 * 1024));
|
|
261
|
+
return [
|
|
262
|
+
'- 新建专题:本任务来自学习空间首页「精选场景」,没有专题资料,无需下载资料;',
|
|
263
|
+
'请按技能说明,围绕用户本条消息的需求(表单参数中的 prompt 仅供参考)生成作品;',
|
|
264
|
+
`产物只允许 ${exts || '(未声明)'} 格式,文件名以「产物文件名」为准,扩展名可按实际产物在允许格式中替换;`,
|
|
265
|
+
`视频须完成渲染编码后再交付,单个文件不超过 ${maxMb}MB;交付后 MCP 会自动新建场景专题(专题名为场景标题)并保存作品。`,
|
|
266
|
+
].join('');
|
|
267
|
+
}
|
|
256
268
|
return [
|
|
257
269
|
'- 新建专题:本任务来自学习空间首页「AI 生课」,没有专题资料,无需下载资料,',
|
|
258
270
|
'请围绕表单参数 question 中的问题生成互动课程;产物 HTML 的 title 标签会作为新专题名,请写成简洁的课程标题;',
|
package/dist/landed-store.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { getConfig } from './config.js';
|
|
10
10
|
/** 合法的生成类别 */
|
|
11
|
-
const GENERATION_KINDS = Object.freeze(['note', 'quiz', 'flashcard', 'course']);
|
|
11
|
+
const GENERATION_KINDS = Object.freeze(['note', 'quiz', 'flashcard', 'course', 'scene']);
|
|
12
12
|
/** 必须为非空字符串的通知字段 */
|
|
13
13
|
const REQUIRED_STRING_FIELDS = ['requestId', 'topicId', 'fileName'];
|
|
14
14
|
/**
|
package/dist/new-topic.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 「新建专题」交付(学习空间首页 WB 内「AI
|
|
3
|
-
* 交付时由 MCP 完成「建专题 → 上传 COS 并登记 → 置 ready
|
|
4
|
-
*
|
|
2
|
+
* 「新建专题」交付(学习空间首页 WB 内「AI 生课」/「精选场景」):任务登记在保留作用域 {@link NEW_TOPIC_SCOPE} 下,
|
|
3
|
+
* 交付时由 MCP 完成「建专题 → 上传 COS 并登记 → 置 ready」:
|
|
4
|
+
* - AI 生课:对齐前端 course-generate「发布课程」的落库结果(`topic_type=ai_lesson`、产物登记为 `course`、专题 `ready`);
|
|
5
|
+
* - 精选场景:`topic_type=scene` + `scene_id`(后端按可见性校验场景并回填分类与技能快照),产物登记为 `scene`。
|
|
5
6
|
*
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
7
|
+
* - 专题名:AI 生课优先取产物 HTML 的 `<title>`(解码实体、折叠空白、≤ 100 字),取不到用任务的 `newTopicName`;
|
|
8
|
+
* 场景直接用 `newTopicName`(场景标题)。重名(业务码 4042)时追加时间后缀重试一次。
|
|
8
9
|
* - 原子性:建专题后上传 / 置 ready 任一步失败都删除该专题(软删除,失败只记日志)再抛错,
|
|
9
10
|
* 任务保持未完成,模型可修正后重试交付(重试会重新建专题)。
|
|
10
11
|
*
|
|
@@ -78,15 +79,19 @@ function isNameConflict(error) {
|
|
|
78
79
|
return describeError(error).includes(TOPIC_NAME_CONFLICT_CODE);
|
|
79
80
|
}
|
|
80
81
|
/**
|
|
81
|
-
* 以 building
|
|
82
|
+
* 以 building 状态新建专题(ai_lesson / scene);重名时追加时间后缀重试一次。
|
|
82
83
|
* @param api 后端接口
|
|
83
84
|
* @param token 登录 token
|
|
84
85
|
* @param name 规范化后的专题名
|
|
86
|
+
* @param spec 专题类型
|
|
85
87
|
* @param now 时钟
|
|
86
88
|
*/
|
|
87
|
-
async function createBuildingTopic(api, token, name, now) {
|
|
89
|
+
async function createBuildingTopic(api, token, name, spec, now) {
|
|
90
|
+
const typeFields = spec.topicType === 'scene'
|
|
91
|
+
? { topic_type: 'scene', scene_id: spec.sceneId, status: 'building' }
|
|
92
|
+
: { topic_type: 'ai_lesson', status: 'building' };
|
|
88
93
|
const create = async (topicName) => {
|
|
89
|
-
const rsp = await api.createTopic(token, { name: topicName,
|
|
94
|
+
const rsp = await api.createTopic(token, { name: topicName, ...typeFields });
|
|
90
95
|
const id = rsp.topic?.id;
|
|
91
96
|
if (typeof id !== 'string' || !id)
|
|
92
97
|
throw new Error('新建专题失败:后端未返回专题 ID');
|
|
@@ -117,15 +122,17 @@ async function rollbackTopic(deps, token, topicId) {
|
|
|
117
122
|
}
|
|
118
123
|
}
|
|
119
124
|
/**
|
|
120
|
-
* 新建专题并落库产物:建专题(ai_lesson
|
|
121
|
-
* @param input token
|
|
125
|
+
* 新建专题并落库产物:建专题(ai_lesson / scene,building)→ 上传登记 → 置 ready;后两步失败删除专题后重抛。
|
|
126
|
+
* @param input token、专题类型、HTML、名称与上传函数
|
|
122
127
|
* @param deps 后端接口、日志与时钟
|
|
123
128
|
*/
|
|
124
129
|
export async function landToNewTopic(input, deps) {
|
|
125
|
-
const
|
|
130
|
+
const spec = input.topic ?? { topicType: 'ai_lesson' };
|
|
131
|
+
const htmlTitle = spec.topicType === 'ai_lesson' && input.html !== undefined ? extractHtmlTitle(input.html) : undefined;
|
|
132
|
+
const name = htmlTitle ?? normalizeTopicName(input.fallbackName);
|
|
126
133
|
if (!name)
|
|
127
134
|
throw new Error('新建专题失败:专题名为空');
|
|
128
|
-
const topic = await createBuildingTopic(deps.api, input.token, name, deps.now ?? Date.now);
|
|
135
|
+
const topic = await createBuildingTopic(deps.api, input.token, name, spec, deps.now ?? Date.now);
|
|
129
136
|
try {
|
|
130
137
|
const { cosUrl } = await input.upload(topic.id);
|
|
131
138
|
await deps.api.updateTopic(input.token, { id: topic.id, name: topic.name, status: 'ready', update_status: true });
|
package/dist/skill-packages.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** 固定类别 → 技能 slug(与前端常量、`skill` 表一致);scene 的 slug 来自场景配置,见 {@link isValidSceneSkillSlug} */
|
|
2
2
|
export const SKILL_SLUG_BY_KIND = Object.freeze({
|
|
3
3
|
note: 'note-generator',
|
|
4
4
|
quiz: 'quiz-generator',
|
|
@@ -10,8 +10,26 @@ export const SKILL_PACKAGE_HOSTS = Object.freeze([
|
|
|
10
10
|
'tep-app-mk-1307578727.cos.ap-guangzhou.myqcloud.com',
|
|
11
11
|
'tep-app-mk-1307578727.cos.ap-guangzhou.tencentcos.cn',
|
|
12
12
|
]);
|
|
13
|
-
/**
|
|
14
|
-
const
|
|
13
|
+
/** 技能包在桶内的根路径;其下一层目录按环境区分(skills / scene-test / scene),与后端 validateScenePackageURL 一致 */
|
|
14
|
+
const SKILL_PACKAGE_ROOT = '/learningbuddy/';
|
|
15
|
+
/** 技能包目录名:单层、非空,只允许字母数字下划线连字符(天然排除 `.` / `..` 与编码字符) */
|
|
16
|
+
const SKILL_PACKAGE_DIR_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
17
|
+
/** 场景技能 slug:字母数字开头,只允许字母数字下划线连字符,≤ 100 字 */
|
|
18
|
+
const SCENE_SKILL_SLUG_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
|
|
19
|
+
/**
|
|
20
|
+
* MCP 支持交付的场景产物扩展名 → 规范 MIME(对齐前端 cosDirectUpload SPEC_MIME_BY_EXT;不带 charset 参数)。
|
|
21
|
+
* 后端仍按场景 `output_formats` 二次校验,不在此表内的格式(如 pptx)暂不支持在 WorkBuddy 中交付。
|
|
22
|
+
*/
|
|
23
|
+
export const SCENE_ARTIFACT_MIME_BY_EXT = Object.freeze({
|
|
24
|
+
html: 'text/html',
|
|
25
|
+
htm: 'text/html',
|
|
26
|
+
mp4: 'video/mp4',
|
|
27
|
+
webm: 'video/webm',
|
|
28
|
+
mov: 'video/quicktime',
|
|
29
|
+
m4v: 'video/x-m4v',
|
|
30
|
+
});
|
|
31
|
+
/** 文本类场景产物扩展名(交付时按 UTF-8 校验 `<html` 标签) */
|
|
32
|
+
export const SCENE_HTML_EXTS = Object.freeze(['html', 'htm']);
|
|
15
33
|
/**
|
|
16
34
|
* 安全解析 URL。
|
|
17
35
|
* @param url 待解析字符串
|
|
@@ -25,11 +43,22 @@ function parseUrl(url) {
|
|
|
25
43
|
return null;
|
|
26
44
|
}
|
|
27
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* 判断 pathname 是否为 `/learningbuddy/<dir>/<slug>.zip`(`<dir>` 单层,见 {@link SKILL_PACKAGE_DIR_PATTERN})。
|
|
48
|
+
* @param pathname 规范化后的 pathname
|
|
49
|
+
* @param slug 技能 slug
|
|
50
|
+
*/
|
|
51
|
+
function isSkillPackagePath(pathname, slug) {
|
|
52
|
+
if (!pathname.startsWith(SKILL_PACKAGE_ROOT))
|
|
53
|
+
return false;
|
|
54
|
+
const segments = pathname.slice(SKILL_PACKAGE_ROOT.length).split('/');
|
|
55
|
+
return segments.length === 2 && SKILL_PACKAGE_DIR_PATTERN.test(segments[0] ?? '') && segments[1] === `${slug}.zip`;
|
|
56
|
+
}
|
|
28
57
|
/**
|
|
29
58
|
* 判断技能包地址是否为白名单桶内该 slug 的技能包。
|
|
30
59
|
*
|
|
31
60
|
* 要求:`https:`;域名与 {@link SKILL_PACKAGE_HOSTS} 精确相等(不做后缀匹配);无显式非默认端口;无用户名 / 密码;
|
|
32
|
-
* 规范化后的 pathname
|
|
61
|
+
* 规范化后的 pathname 为 `/learningbuddy/<dir>/<slug>.zip`(`<dir>` 单层非空);query 不限(签名参数);无 hash。
|
|
33
62
|
* @param url 技能包签名地址
|
|
34
63
|
* @param slug 技能 slug
|
|
35
64
|
* @returns 合法返回 `true`,否则(含解析失败)返回 `false`
|
|
@@ -43,6 +72,28 @@ export function isAllowedSkillPackageUrl(url, slug) {
|
|
|
43
72
|
parsed.port === '' &&
|
|
44
73
|
parsed.username === '' &&
|
|
45
74
|
parsed.password === '' &&
|
|
46
|
-
parsed.pathname
|
|
75
|
+
isSkillPackagePath(parsed.pathname, slug) &&
|
|
47
76
|
parsed.hash === '');
|
|
48
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* 场景技能 slug 是否合法(作为技能包文件名,须能安全拼进路径)。
|
|
80
|
+
* @param slug 技能 slug
|
|
81
|
+
*/
|
|
82
|
+
export function isValidSceneSkillSlug(slug) {
|
|
83
|
+
return SCENE_SKILL_SLUG_PATTERN.test(slug);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 场景产物扩展名是否受 MCP 支持。
|
|
87
|
+
* @param ext 小写、不含点的扩展名
|
|
88
|
+
*/
|
|
89
|
+
export function isSupportedSceneExt(ext) {
|
|
90
|
+
return Object.prototype.hasOwnProperty.call(SCENE_ARTIFACT_MIME_BY_EXT, ext);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 取文件名扩展名(小写、不含点);无扩展名返回空串。
|
|
94
|
+
* @param fileName 文件名
|
|
95
|
+
*/
|
|
96
|
+
export function fileExtOf(fileName) {
|
|
97
|
+
const idx = fileName.lastIndexOf('.');
|
|
98
|
+
return idx > 0 && idx < fileName.length - 1 ? fileName.slice(idx + 1).toLowerCase() : '';
|
|
99
|
+
}
|
package/dist/tools.js
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
10
|
import { z } from 'zod';
|
|
11
|
-
import { ARTIFACT_SUFFIX_BY_KIND, validateArtifact } from './artifact-validate.js';
|
|
12
|
-
import { DEFAULT_PORT } from './config.js';
|
|
11
|
+
import { ARTIFACT_SUFFIX_BY_KIND, validateArtifact, validateSceneArtifact } from './artifact-validate.js';
|
|
12
|
+
import { DEFAULT_PORT, getConfig } from './config.js';
|
|
13
13
|
import { contextStore as defaultContextStore } from './context-store.js';
|
|
14
14
|
import { uploadArtifactToTopic } from './cos-upload.js';
|
|
15
15
|
import { generationStore as defaultGenerationStore, isUnfinishedStatus } from './generation-store.js';
|
|
@@ -32,16 +32,36 @@ function localPort() {
|
|
|
32
32
|
return port >= 1 && port <= 65_535 ? port : DEFAULT_PORT;
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
|
-
*
|
|
35
|
+
* 未连接期间的防编造约束(所有依赖页面的工具共用)。
|
|
36
|
+
* 评测中模型拿到「无上下文」后仍自拟专家与选段成稿,因此这里把禁止项逐一点名。
|
|
37
|
+
*/
|
|
38
|
+
const NO_CONTEXT_GUARD = '未连接期间不要假设或编造专题、资料名称与数量、页码、选中文本、答疑专家或功能入口,也不要写「示范版」代替真实内容;' +
|
|
39
|
+
'用户没在对话中给出原文时,请其打开专题详情页,或直接把原文粘贴到对话里。';
|
|
40
|
+
/** 依赖页面登录态的后端查询:未连接时无法调用 */
|
|
41
|
+
const NEEDS_PAGE_TOKEN = '该查询使用学习空间页面上报的登录态,页面未连接时无法调用';
|
|
42
|
+
/** 各工具无页面上下文时的专属说明(让模型区分「未连接」与「查询结果为空」) */
|
|
43
|
+
const NO_CONTEXT_HINTS = Object.freeze({
|
|
44
|
+
get_page_context: '因此读不到用户正在阅读的资料、页码、选中文本与页面登记的生成任务。',
|
|
45
|
+
get_active_expert: '因此无法得知用户在页面上选中的答疑专家(这不等于「用户未指定专家」);需要作答时以通用学习助手身份回答,并说明尚未读取到专家。',
|
|
46
|
+
get_topic_detail: `${NEEDS_PAGE_TOKEN},拿不到专题标题与资料数量,不要估算。`,
|
|
47
|
+
list_topic_menu: `${NEEDS_PAGE_TOKEN},拿不到专题功能入口,不要自行罗列;只能按 get_connector_status 的「能做什么」概括连接器能力。`,
|
|
48
|
+
list_experts: `${NEEDS_PAGE_TOKEN},拿不到可选专家名单。`,
|
|
49
|
+
});
|
|
50
|
+
/**
|
|
51
|
+
* 无页面上下文时工具返回给模型的完整提示文本。
|
|
36
52
|
*
|
|
37
53
|
* 首次使用最常见的情形就是「还没打开专题页」:这里明确说明连接器本身是好的、端口在哪里、
|
|
38
|
-
*
|
|
54
|
+
* 打开页面后会自动连接,并指向自检工具,避免模型只回一句「没有上下文」后无法继续;
|
|
55
|
+
* 同时按工具给出专属说明与防编造约束,避免模型把「未连接」当成「结果为空」后自行补齐内容。
|
|
56
|
+
* @param tool 调用的工具名
|
|
39
57
|
*/
|
|
40
|
-
function noContextToolText() {
|
|
58
|
+
function noContextToolText(tool) {
|
|
41
59
|
return [
|
|
42
60
|
`${NO_CONTEXT_MESSAGE}。`,
|
|
61
|
+
NO_CONTEXT_HINTS[tool],
|
|
43
62
|
`连接器本身运行正常(v${readPackageVersion()},本机 127.0.0.1:${localPort()}),用户打开专题详情页后 1~2 秒内会自动连接。`,
|
|
44
|
-
|
|
63
|
+
NO_CONTEXT_GUARD,
|
|
64
|
+
'需要排查连接问题时调用 get_connector_status。',
|
|
45
65
|
].join('');
|
|
46
66
|
}
|
|
47
67
|
/** 无页面上下文错误码(follower 经 HTTP 转发时按 code 跨进程还原) */
|
|
@@ -118,6 +138,7 @@ const KIND_LABELS = Object.freeze({
|
|
|
118
138
|
quiz: '测验',
|
|
119
139
|
flashcard: '闪卡',
|
|
120
140
|
course: '课程',
|
|
141
|
+
scene: '场景作品',
|
|
121
142
|
});
|
|
122
143
|
/** 默认日志:只写 stderr */
|
|
123
144
|
const STDERR_LOGGER = {
|
|
@@ -139,7 +160,7 @@ function resolveDeps(deps) {
|
|
|
139
160
|
landedStore: deps.landedStore ?? defaultLandedStore,
|
|
140
161
|
api: () => deps.api ?? lazyApi(),
|
|
141
162
|
topicApi: () => deps.topicApi ?? lazyApi(),
|
|
142
|
-
readFile: deps.readFile ?? ((filePath) => readWbFile(filePath)),
|
|
163
|
+
readFile: deps.readFile ?? ((filePath, options) => readWbFile(filePath, { maxBytes: options?.maxBytes })),
|
|
143
164
|
validate: deps.validate ?? validateArtifact,
|
|
144
165
|
upload: deps.upload ?? ((input) => uploadArtifactToTopic(input)),
|
|
145
166
|
now: deps.now ?? Date.now,
|
|
@@ -188,33 +209,62 @@ function assertSuffix(kind, fileName) {
|
|
|
188
209
|
}
|
|
189
210
|
}
|
|
190
211
|
/**
|
|
191
|
-
*
|
|
212
|
+
* 读取并校验场景产物:按场景上限读取(可能是视频),扩展名须在任务的 sceneExts 内。
|
|
213
|
+
* @param deps 依赖
|
|
214
|
+
* @param task scene 任务
|
|
215
|
+
* @param filePath 文件绝对路径
|
|
216
|
+
*/
|
|
217
|
+
async function prepareSceneArtifact(deps, task, filePath) {
|
|
218
|
+
const file = await deps.readFile(filePath, { maxBytes: getConfig().limits.sceneDeliverFileMaxBytes });
|
|
219
|
+
const validation = validateSceneArtifact(file.fileName, file.content, task.sceneExts ?? []);
|
|
220
|
+
if (!validation.ok)
|
|
221
|
+
throw new Error(`产物校验失败:${validation.reason}`);
|
|
222
|
+
return { fileName: file.fileName, content: validation.content };
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* 读取 → 校验后缀 → 校验内容,得到待上传产物(scene 走 {@link prepareSceneArtifact})。
|
|
192
226
|
* @param deps 依赖
|
|
193
227
|
* @param task 有效任务
|
|
194
|
-
* @param
|
|
195
|
-
* @param text 校验后的文本
|
|
228
|
+
* @param filePath 文件绝对路径
|
|
196
229
|
*/
|
|
197
|
-
async function
|
|
198
|
-
const
|
|
230
|
+
async function prepareArtifact(deps, task, filePath) {
|
|
231
|
+
const kind = task.kind;
|
|
232
|
+
if (kind === 'scene')
|
|
233
|
+
return prepareSceneArtifact(deps, task, filePath);
|
|
234
|
+
const file = await deps.readFile(filePath);
|
|
235
|
+
assertSuffix(kind, file.fileName);
|
|
236
|
+
const validation = deps.validate(kind, file.fileName, file.content);
|
|
237
|
+
if (!validation.ok)
|
|
238
|
+
throw new Error(`产物校验失败:${validation.reason}`);
|
|
239
|
+
return { fileName: file.fileName, content: Buffer.from(validation.text, 'utf8'), text: validation.text };
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* 上传登记产物:普通任务上传到任务专题;新建专题任务(`NEW_TOPIC_SCOPE`)走「建专题 → 上传 → 置 ready」,
|
|
243
|
+
* course 建 ai_lesson 专题,scene 建带 scene_id 的场景专题。
|
|
244
|
+
* @param deps 依赖
|
|
245
|
+
* @param task 有效任务
|
|
246
|
+
* @param artifact 校验后的产物
|
|
247
|
+
*/
|
|
248
|
+
async function uploadToTarget(deps, task, artifact) {
|
|
249
|
+
const { fileName, content } = artifact;
|
|
199
250
|
const upload = (topicId) => deps.upload({ token: task.token, topicId, kind: task.kind, fileName, content });
|
|
200
251
|
if (task.topicId !== NEW_TOPIC_SCOPE)
|
|
201
252
|
return { cosUrl: (await upload(task.topicId)).cosUrl };
|
|
202
|
-
const
|
|
253
|
+
const topic = task.kind === 'scene'
|
|
254
|
+
? { topicType: 'scene', sceneId: task.sceneId ?? '' }
|
|
255
|
+
: { topicType: 'ai_lesson' };
|
|
256
|
+
const landed = await landToNewTopic({ token: task.token, topic, html: artifact.text, fallbackName: task.newTopicName ?? '', upload }, { api: deps.topicApi(), logger: deps.logger, now: deps.now });
|
|
203
257
|
return { cosUrl: landed.cosUrl, createdTopicId: landed.topicId, createdTopicName: landed.topicName };
|
|
204
258
|
}
|
|
205
259
|
/**
|
|
206
|
-
*
|
|
260
|
+
* 读取校验 → 上传登记(新建专题任务先建专题)→ 标记交付 → 写落库通知。
|
|
207
261
|
* @param deps 依赖
|
|
208
262
|
* @param task 有效任务
|
|
209
263
|
* @param filePath 文件绝对路径
|
|
210
264
|
*/
|
|
211
265
|
async function landArtifact(deps, task, filePath) {
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
const validation = deps.validate(task.kind, file.fileName, file.content);
|
|
215
|
-
if (!validation.ok)
|
|
216
|
-
throw new Error(`产物校验失败:${validation.reason}`);
|
|
217
|
-
const target = await uploadToTarget(deps, task, file.fileName, validation.text);
|
|
266
|
+
const artifact = await prepareArtifact(deps, task, filePath);
|
|
267
|
+
const target = await uploadToTarget(deps, task, artifact);
|
|
218
268
|
if (!deps.generationStore.markDelivered(task.requestId)) {
|
|
219
269
|
deps.logger.warn(`[deliver] ${JSON.stringify(task.requestId)} 已落库,但任务在上传期间失效`);
|
|
220
270
|
}
|
|
@@ -222,7 +272,7 @@ async function landArtifact(deps, task, filePath) {
|
|
|
222
272
|
requestId: task.requestId,
|
|
223
273
|
topicId: task.topicId,
|
|
224
274
|
kind: task.kind,
|
|
225
|
-
fileName:
|
|
275
|
+
fileName: artifact.fileName,
|
|
226
276
|
...target,
|
|
227
277
|
};
|
|
228
278
|
deps.landedStore.push({
|
|
@@ -343,17 +393,18 @@ function listHeading(title, value) {
|
|
|
343
393
|
return Array.isArray(value) ? `${title}(${value.length} 个):` : `${title}:`;
|
|
344
394
|
}
|
|
345
395
|
/**
|
|
346
|
-
*
|
|
396
|
+
* 执行工具:成功渲染文本结果;无页面上下文渲染该工具的专属提示文本(属正常状态,非工具故障);
|
|
347
397
|
* 其他失败渲染 isError 结果,不抛异常。
|
|
398
|
+
* @param tool 工具名(用于无上下文提示);不依赖页面的工具传 `null`
|
|
348
399
|
* @param run 返回成功文本
|
|
349
400
|
*/
|
|
350
|
-
async function runTool(run) {
|
|
401
|
+
async function runTool(tool, run) {
|
|
351
402
|
try {
|
|
352
403
|
return { content: [{ type: 'text', text: await run() }] };
|
|
353
404
|
}
|
|
354
405
|
catch (error) {
|
|
355
|
-
if (isNoContextError(error))
|
|
356
|
-
return { content: [{ type: 'text', text: noContextToolText() }] };
|
|
406
|
+
if (tool && isNoContextError(error))
|
|
407
|
+
return { content: [{ type: 'text', text: noContextToolText(tool) }] };
|
|
357
408
|
return { isError: true, content: [{ type: 'text', text: `调用失败:${describeError(error)}` }] };
|
|
358
409
|
}
|
|
359
410
|
}
|
|
@@ -365,7 +416,7 @@ async function runTool(run) {
|
|
|
365
416
|
*/
|
|
366
417
|
function renderPageContext(ctx, tasks) {
|
|
367
418
|
if (!ctx && tasks.length === 0)
|
|
368
|
-
return noContextToolText();
|
|
419
|
+
return noContextToolText('get_page_context');
|
|
369
420
|
return buildHookText({ context: ctx, pendingTasks: tasks, maxChars: Number.MAX_SAFE_INTEGER });
|
|
370
421
|
}
|
|
371
422
|
/**
|
|
@@ -391,7 +442,8 @@ function renderDelivered(result) {
|
|
|
391
442
|
const name = escapeAngleBrackets(result.createdTopicName ?? '');
|
|
392
443
|
return [
|
|
393
444
|
`已新建专题「${name}」(id=${escapeAngleBrackets(result.createdTopicId)})并交付${KIND_LABELS[result.kind]}「${fileName}」,`,
|
|
394
|
-
'
|
|
445
|
+
`学习空间首页会自动刷新展示。请用一句话告诉用户${result.kind === 'scene' ? '作品' : '课程'}已生成、`,
|
|
446
|
+
'可在学习空间打开该专题,不要复述文件内容。',
|
|
395
447
|
].join('');
|
|
396
448
|
}
|
|
397
449
|
return [
|
|
@@ -432,7 +484,8 @@ const STATUS_CAPABILITIES = [
|
|
|
432
484
|
'能做什么:',
|
|
433
485
|
'1. 基于当前专题资料答疑:总结、讲解、翻译、出题;',
|
|
434
486
|
'2. 按专题页的生成按钮产出笔记 / 测验 / 闪卡 / 互动课程,并自动回填专题;',
|
|
435
|
-
'3. 学习空间首页「AI
|
|
487
|
+
'3. 学习空间首页「AI 生课」:围绕一个问题生成互动课程并新建专题;',
|
|
488
|
+
'4. 学习空间首页「精选场景」:按场景技能生成网页或视频作品并新建场景专题。',
|
|
436
489
|
].join('\n');
|
|
437
490
|
/** 未连接时的下一步与排查建议 */
|
|
438
491
|
const STATUS_NEXT_STEPS = [
|
|
@@ -444,6 +497,26 @@ const STATUS_NEXT_STEPS = [
|
|
|
444
497
|
].join('\n');
|
|
445
498
|
/** 已连接时的下一步 */
|
|
446
499
|
const STATUS_CONNECTED_NEXT = '下一步:直接提问,或在学习空间专题页点击生成按钮。';
|
|
500
|
+
/** 工具清单(标注可用前提),回答「能做什么」时可直接引用工具名 */
|
|
501
|
+
const STATUS_TOOLS = [
|
|
502
|
+
'可用工具:',
|
|
503
|
+
`- ${STATUS_TOOL_NAME}:连接自检(任何时候可用);`,
|
|
504
|
+
'- get_page_context:页面上下文与待完成的生成任务(需页面已连接);',
|
|
505
|
+
'- get_active_expert / get_topic_detail / list_topic_menu / list_experts:当前专家、专题详情、专题功能入口、可选专家(需页面已连接);',
|
|
506
|
+
`- ${PROGRESS_TOOL_NAME} / ${DELIVER_TOOL_NAME}:上报生成进度、交付产物(需页面已登记生成任务)。`,
|
|
507
|
+
].join('\n');
|
|
508
|
+
/** 未连接时的数据边界 */
|
|
509
|
+
const STATUS_DISCONNECTED_LIMIT = '未连接期间:专题标题、资料数量、阅读位置、选中文本、答疑专家与专题功能入口都无法读取,不要自行罗列或编造;只能基于用户在对话中给出的内容作答。';
|
|
510
|
+
/**
|
|
511
|
+
* 无待完成任务时的说明:用户声称页面上有未完成任务时,帮助模型给出原因而非臆测任务内容。
|
|
512
|
+
* @param isConnected 页面是否已连接
|
|
513
|
+
*/
|
|
514
|
+
function noPendingTaskHint(isConnected) {
|
|
515
|
+
const reason = isConnected
|
|
516
|
+
? '任务登记后超过 30 分钟未执行会过期,同一类别再次点击生成也会覆盖旧任务'
|
|
517
|
+
: '页面未连接时连接器看不到该专题登记的任务;任务登记后超过 30 分钟未执行也会过期';
|
|
518
|
+
return `若用户说页面上有未完成的生成任务:${reason}。请用户在专题页侧栏重新点击生成,不要在没有任务的情况下自行生成或声称已交付。`;
|
|
519
|
+
}
|
|
447
520
|
/**
|
|
448
521
|
* 单行化外部来源字段:尖括号转全角、换行折叠为空格,防止伪造状态行。
|
|
449
522
|
* @param value 外部来源字符串
|
|
@@ -461,7 +534,7 @@ function renderConnectorStatus(context, tasks, problem) {
|
|
|
461
534
|
const lines = [`[连接器状态] 腾讯教育学习助手 连接器 v${readPackageVersion()} · 本机 127.0.0.1:${localPort()}`];
|
|
462
535
|
if (problem !== null) {
|
|
463
536
|
lines.push(`- 本机服务:异常(${oneLine(problem)})`, '- 页面连接:无法确认');
|
|
464
|
-
lines.push('', STATUS_CAPABILITIES, '', STATUS_NEXT_STEPS);
|
|
537
|
+
lines.push('', STATUS_CAPABILITIES, '', STATUS_TOOLS, '', STATUS_NEXT_STEPS);
|
|
465
538
|
return lines.join('\n');
|
|
466
539
|
}
|
|
467
540
|
lines.push('- 本机服务:运行中');
|
|
@@ -480,7 +553,12 @@ function renderConnectorStatus(context, tasks, problem) {
|
|
|
480
553
|
lines.push('- 页面连接:未连接(还没有收到学习空间页面的上报)');
|
|
481
554
|
}
|
|
482
555
|
lines.push(`- 待完成的生成任务:${tasks.length} 个${summarizeTaskKinds(tasks)}`);
|
|
483
|
-
|
|
556
|
+
if (tasks.length === 0)
|
|
557
|
+
lines.push(noPendingTaskHint(context !== null));
|
|
558
|
+
lines.push('', STATUS_CAPABILITIES, '', STATUS_TOOLS);
|
|
559
|
+
if (!context)
|
|
560
|
+
lines.push('', STATUS_DISCONNECTED_LIMIT);
|
|
561
|
+
lines.push('', context ? STATUS_CONNECTED_NEXT : STATUS_NEXT_STEPS);
|
|
484
562
|
return lines.join('\n');
|
|
485
563
|
}
|
|
486
564
|
/**
|
|
@@ -494,7 +572,7 @@ function registerStatusTool(server, backend) {
|
|
|
494
572
|
title: '查看连接器状态',
|
|
495
573
|
description: '查看本机连接器是否正常、是否已连上学习空间页面、当前专题与待完成生成任务,并给出下一步操作与排查建议。' +
|
|
496
574
|
'用户问「连上了吗 / 现在能做什么 / 页面怎么没反应」,或页面提示未连接时调用;本工具不需要页面上下文,任何时候都可调用。',
|
|
497
|
-
}, () => runTool(async () => {
|
|
575
|
+
}, () => runTool(null, async () => {
|
|
498
576
|
try {
|
|
499
577
|
const [context, tasks] = await Promise.all([backend.getPageContext(), backend.getPendingTasks()]);
|
|
500
578
|
return renderConnectorStatus(context, tasks, null);
|
|
@@ -505,6 +583,8 @@ function registerStatusTool(server, backend) {
|
|
|
505
583
|
}
|
|
506
584
|
}));
|
|
507
585
|
}
|
|
586
|
+
/** 依赖页面连接的工具描述后缀:让模型在调用前就知道前提,失败后不把「未连接」当成「结果为空」 */
|
|
587
|
+
const PAGE_REQUIRED_NOTE = '需要学习空间页面已连接;未连接时返回说明文本(非数据),不要据此自行补齐或编造内容。';
|
|
508
588
|
/**
|
|
509
589
|
* 注册上下文类工具:get_page_context、get_active_expert、get_connector_status。
|
|
510
590
|
* @param server MCP Server
|
|
@@ -516,7 +596,7 @@ function registerContextTools(server, backend) {
|
|
|
516
596
|
title: '读取当前页面上下文',
|
|
517
597
|
description: '读取用户在「学习空间 · 专题详情」页面的上下文:专题、全部文件清单(已签名下载地址)、阅读位置、答疑专家、选中文本,' +
|
|
518
598
|
'以及页面登记的 [待完成的生成任务](requestId、技能包、产物文件名、表单参数、进度上报与交付要求)。' +
|
|
519
|
-
'用户要求生成笔记 / 测验 / 闪卡 /
|
|
599
|
+
'用户要求生成笔记 / 测验 / 闪卡 / 课程 / 场景作品,或消息中出现学习空间任务 requestId 时,必须先调用本工具获取任务要求再执行。' +
|
|
520
600
|
'资料原文只能通过返回的签名地址下载读取,禁止在本地磁盘搜索同名文件。',
|
|
521
601
|
inputSchema: {
|
|
522
602
|
requestId: z
|
|
@@ -527,7 +607,7 @@ function registerContextTools(server, backend) {
|
|
|
527
607
|
.describe('仅当用户本条消息里出现「学习空间任务 requestId:xxx」时原样传入;用于让页面立即进入生成中。' +
|
|
528
608
|
'普通答疑 / 总结 / 闲聊不要传。'),
|
|
529
609
|
},
|
|
530
|
-
}, ({ requestId }, extra) => runTool(async () => {
|
|
610
|
+
}, ({ requestId }, extra) => runTool('get_page_context', async () => {
|
|
531
611
|
// 连接器形态没有 UserPromptSubmit Hook:由模型回传的 requestId 兜底把该任务标为「已发送」,
|
|
532
612
|
// 只影响该 requestId,未传时不改变任何状态(正常对话零影响)。
|
|
533
613
|
if (requestId !== undefined)
|
|
@@ -537,8 +617,9 @@ function registerContextTools(server, backend) {
|
|
|
537
617
|
}));
|
|
538
618
|
server.registerTool('get_active_expert', {
|
|
539
619
|
title: '读取用户指定的答疑专家',
|
|
540
|
-
description: '读取用户在学习空间页面上选中的答疑专家及其档案;若返回了专家,回答时请代入该专家身份与风格。'
|
|
541
|
-
|
|
620
|
+
description: '读取用户在学习空间页面上选中的答疑专家及其档案;若返回了专家,回答时请代入该专家身份与风格。' +
|
|
621
|
+
`只读页面上下文,${PAGE_REQUIRED_NOTE}`,
|
|
622
|
+
}, () => runTool('get_active_expert', async () => renderExpert(await backend.getActiveExpert())));
|
|
542
623
|
}
|
|
543
624
|
/**
|
|
544
625
|
* 注册后端查询类工具:get_topic_detail、list_topic_menu、list_experts。
|
|
@@ -548,7 +629,8 @@ function registerContextTools(server, backend) {
|
|
|
548
629
|
function registerQueryTools(server, backend) {
|
|
549
630
|
server.registerTool('get_topic_detail', {
|
|
550
631
|
title: '查询专题详情',
|
|
551
|
-
description: '查询学习空间专题详情(权威数据)。不传 topicId 时使用当前页面正在查看的专题。'
|
|
632
|
+
description: '查询学习空间专题详情(权威数据)。不传 topicId 时使用当前页面正在查看的专题。' +
|
|
633
|
+
`使用页面上报的登录态,${PAGE_REQUIRED_NOTE}`,
|
|
552
634
|
inputSchema: {
|
|
553
635
|
topicId: z
|
|
554
636
|
.string()
|
|
@@ -557,18 +639,20 @@ function registerQueryTools(server, backend) {
|
|
|
557
639
|
.optional()
|
|
558
640
|
.describe('专题 ID(字母、数字、下划线、连字符);省略则取当前页面的专题'),
|
|
559
641
|
},
|
|
560
|
-
}, ({ topicId }) => runTool(async () => `专题详情:\n${formatJson(await backend.getTopicDetail(topicId))}`));
|
|
642
|
+
}, ({ topicId }) => runTool('get_topic_detail', async () => `专题详情:\n${formatJson(await backend.getTopicDetail(topicId))}`));
|
|
561
643
|
server.registerTool('list_topic_menu', {
|
|
562
644
|
title: '查询专题功能入口',
|
|
563
|
-
description: '查询专题详情页的功能入口列表(资料精读 / 视频学习 / AI 生课等),用于回答「这个专题能做什么」。'
|
|
564
|
-
|
|
645
|
+
description: '查询专题详情页的功能入口列表(资料精读 / 视频学习 / AI 生课等),用于回答「这个专题能做什么」。' +
|
|
646
|
+
`使用页面上报的登录态,${PAGE_REQUIRED_NOTE}`,
|
|
647
|
+
}, () => runTool('list_topic_menu', async () => {
|
|
565
648
|
const list = await backend.listTopicMenu();
|
|
566
649
|
return `${listHeading('专题功能入口', list)}\n${formatJson(list)}`;
|
|
567
650
|
}));
|
|
568
651
|
server.registerTool('list_experts', {
|
|
569
652
|
title: '查询学科专家',
|
|
570
|
-
description: '查询学习空间可用的学科专家(自定义专家市场),用于用户想换个专家来答时查看可选项。'
|
|
571
|
-
|
|
653
|
+
description: '查询学习空间可用的学科专家(自定义专家市场),用于用户想换个专家来答时查看可选项。' +
|
|
654
|
+
`使用页面上报的登录态,${PAGE_REQUIRED_NOTE}`,
|
|
655
|
+
}, () => runTool('list_experts', async () => {
|
|
572
656
|
const experts = await backend.listExperts();
|
|
573
657
|
return `${listHeading('可用学科专家', experts)}\n${formatJson(experts)}`;
|
|
574
658
|
}));
|
|
@@ -587,12 +671,13 @@ function registerDeliverTool(server, backend) {
|
|
|
587
671
|
description: '完成页面登记的生成任务后调用:把已写入 WorkBuddy 工作目录的产物文件上传并保存到专题,页面随即展示。' +
|
|
588
672
|
'requestId 取自 [待完成的生成任务](Hook 注入或 get_page_context 返回)中的 requestId,原样传入;' +
|
|
589
673
|
'filePath 必须是产物文件的绝对路径,不要把文件内容贴进参数。' +
|
|
590
|
-
`文件名后缀须与任务类别一致:${suffixes}
|
|
674
|
+
`文件名后缀须与任务类别一致:${suffixes};` +
|
|
675
|
+
'场景作品的扩展名须在任务给出的允许格式内(如 .html / .mp4,视频须已完成渲染编码)。失败时根据错误修正后可重试。',
|
|
591
676
|
inputSchema: {
|
|
592
677
|
requestId: z.string().min(1).max(128).describe('[待完成的生成任务] 中的 requestId'),
|
|
593
678
|
filePath: z.string().min(1).max(4096).describe('产物文件的绝对路径(位于 WorkBuddy 工作目录内)'),
|
|
594
679
|
},
|
|
595
|
-
}, ({ requestId, filePath }) => runTool(async () => renderDelivered(await backend.deliverFile(requestId, filePath))));
|
|
680
|
+
}, ({ requestId, filePath }) => runTool(null, async () => renderDelivered(await backend.deliverFile(requestId, filePath))));
|
|
596
681
|
}
|
|
597
682
|
/** 进度上报工具描述 */
|
|
598
683
|
const PROGRESS_TOOL_DESCRIPTION = [
|
|
@@ -632,7 +717,7 @@ function registerProgressTool(server, backend) {
|
|
|
632
717
|
requestId: z.string().min(1).max(128).describe('[待完成的生成任务] 中的 requestId'),
|
|
633
718
|
...PROGRESS_REPORT_SHAPE,
|
|
634
719
|
},
|
|
635
|
-
}, ({ requestId, ...args }) => runTool(async () => {
|
|
720
|
+
}, ({ requestId, ...args }) => runTool(null, async () => {
|
|
636
721
|
if (args.stage === 'failed' && (args.error ?? '').trim().length === 0) {
|
|
637
722
|
throw new Error(PROGRESS_ERROR_REQUIRED_MESSAGE);
|
|
638
723
|
}
|
|
@@ -655,9 +740,10 @@ export function registerTools(server, backend) {
|
|
|
655
740
|
const SERVER_INSTRUCTIONS = [
|
|
656
741
|
'本服务连接用户在 WorkBuddy 内打开的「学习空间」专题详情页。',
|
|
657
742
|
'用户提到「这页 / 这篇 / 这个专题 / 当前资料」而对话中没有 [学习空间页面上下文] 时,调用 get_page_context;',
|
|
658
|
-
'用户要求生成笔记 / 测验 / 闪卡 /
|
|
743
|
+
'用户要求生成笔记 / 测验 / 闪卡 / 课程 / 场景作品,或消息中带有学习空间任务 requestId 而对话中没有 [待完成的生成任务] 时,先调用 get_page_context,严格按其中的技能、进度上报与交付要求执行;',
|
|
659
744
|
'调用 get_page_context 时,若用户本条消息里出现「学习空间任务 requestId:xxx」,把该 id 作为 requestId 入参原样传入(页面据此立即显示生成中),并把它作为本次回复的第一个动作,不要先输出大段文字;其它答疑 / 总结 / 闲聊场景不要传该入参;',
|
|
660
745
|
'get_page_context 返回「当前没有学习空间页面上下文」时,告诉用户先打开专题详情页,并按返回的下一步(或调用 get_connector_status)给出状态与排查建议;',
|
|
746
|
+
'页面未连接期间不要假设或编造专题、资料、页码、选中文本、答疑专家或功能入口,也不要写「示范版」代替真实内容,请用户打开专题页或把原文粘贴到对话里;',
|
|
661
747
|
`用户问「连上了吗 / 现在能做什么 / 页面怎么没反应」,或需要确认服务是否正常时,调用 ${STATUS_TOOL_NAME};它不需要页面上下文,任何时候都可调用;`,
|
|
662
748
|
'资料原文请通过上下文中的签名地址下载,不要在本地磁盘搜索同名文件;链接专题只给了网页链接时,先用联网搜索了解其公开信息,不要凭 URL 想象内容;',
|
|
663
749
|
`执行页面登记的生成任务时,各阶段调用 ${PROGRESS_TOOL_NAME} 上报进度;`,
|
package/dist/types.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* 统一引用本文件,保证字段口径一致。
|
|
6
6
|
*/
|
|
7
7
|
/**
|
|
8
|
-
* 「新建专题」保留作用域:学习空间首页 WB 内「AI
|
|
9
|
-
* 交付时由 MCP
|
|
10
|
-
*
|
|
8
|
+
* 「新建专题」保留作用域:学习空间首页 WB 内「AI 生课」/「精选场景」登记任务时以此代替专题 ID(此时专题尚不存在),
|
|
9
|
+
* 交付时由 MCP 新建专题再上传产物(course → ai_lesson 专题;scene → scene 专题)。
|
|
10
|
+
* 真实专题 ID 为 32 位十六进制,不会与之冲突。仅允许 `course` / `scene` 类别使用。
|
|
11
11
|
*/
|
|
12
12
|
export const NEW_TOPIC_SCOPE = 'new-topic';
|
package/dist/wb-files.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* - 只接受绝对路径;rootDir 与 filePath 都做 realpath,真实路径必须严格位于 rootDir 之下(root 本身不允许)。
|
|
6
6
|
* - 越界 / 不存在 / 非普通文件统一为同一条错误消息,不暴露文件是否存在,也不透出原始 errno 信息。
|
|
7
7
|
* - 防 TOCTOU:open 后基于同一 FileHandle 做 stat 与读取;open 带 `O_NOFOLLOW`(防最后一段被替换为软链)
|
|
8
|
-
* 与 `O_NONBLOCK`(防 FIFO
|
|
8
|
+
* 与 `O_NONBLOCK`(防 FIFO 阻塞);按 stat 大小读取并多读 1 字节,防 stat 后被追加。
|
|
9
9
|
*/
|
|
10
10
|
import { constants as fsConstants, promises as fs } from 'node:fs';
|
|
11
11
|
import os from 'node:os';
|
|
@@ -20,6 +20,7 @@ const WB_FILE_ERROR_CODES = Object.freeze([
|
|
|
20
20
|
'WB_FILE_FORBIDDEN',
|
|
21
21
|
'WB_FILE_TOO_LARGE',
|
|
22
22
|
'WB_FILE_NOT_ABSOLUTE',
|
|
23
|
+
'WB_FILE_CHANGED',
|
|
23
24
|
]);
|
|
24
25
|
/** 1MB 字节数 */
|
|
25
26
|
const MB = 1024 * 1024;
|
|
@@ -45,6 +46,10 @@ function tooLarge(maxBytes) {
|
|
|
45
46
|
const limit = maxBytes % MB === 0 ? `${maxBytes / MB}MB` : `${maxBytes} 字节`;
|
|
46
47
|
return createWbFileError('WB_FILE_TOO_LARGE', `文件超过 ${limit} 上限`);
|
|
47
48
|
}
|
|
49
|
+
/** 读取期间文件被追加(多为仍在写入) */
|
|
50
|
+
function changedWhileReading() {
|
|
51
|
+
return createWbFileError('WB_FILE_CHANGED', '文件在读取过程中发生变化,请确认已写入完成后重试');
|
|
52
|
+
}
|
|
48
53
|
/**
|
|
49
54
|
* 判断是否为 WB 文件读取错误(供 tools 层转换为可读文本)。
|
|
50
55
|
* @param e 任意捕获值
|
|
@@ -115,10 +120,12 @@ export async function readWbFile(filePath, options = {}) {
|
|
|
115
120
|
throw forbidden();
|
|
116
121
|
if (stat.size > maxBytes)
|
|
117
122
|
throw tooLarge(maxBytes);
|
|
118
|
-
//
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
// 按实际大小分配(大上限时避免一次性分配上限字节);多读 1 字节:stat 之后文件若被追加,仍能发现
|
|
124
|
+
const expected = stat.size;
|
|
125
|
+
const content = await orForbidden(readUpTo(handle, expected + 1));
|
|
126
|
+
if (content.length > expected) {
|
|
127
|
+
throw content.length > maxBytes ? tooLarge(maxBytes) : changedWhileReading();
|
|
128
|
+
}
|
|
122
129
|
return { realPath, fileName: path.basename(realPath), content, size: content.length };
|
|
123
130
|
}
|
|
124
131
|
finally {
|
package/package.json
CHANGED