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 CHANGED
@@ -7,6 +7,7 @@
7
7
  - **基于当前专题对话**:「这一页讲的是什么?帮我总结一下」「用当前专家的口吻解释一下我选中的这段话」
8
8
  - **一键生成学习产物**:在专题页点击生成笔记 / 测验 / 闪卡 / 互动课程,WorkBuddy 生成后自动回填到专题,页面实时显示进度
9
9
  - **首页 AI 生课**:围绕一个问题生成互动课程,并自动创建新专题
10
+ - **首页精选场景**:按场景技能生成网页或视频作品(≤ 200MB),并自动创建场景专题
10
11
 
11
12
  ## 安装
12
13
 
@@ -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 生课(仅 course,须带 `newTopicName`) |
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
- // 仅「新建专题」任务(topicId = NEW_TOPIC_SCOPE)携带:新专题兜底名称
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 suffix = ARTIFACT_SUFFIX_BY_KIND[body.kind];
110
- if (!body.fileName.toLowerCase().endsWith(suffix)) {
111
- ctx.addIssue({ code: 'custom', path: ['fileName'], message: `must end with ${suffix}` });
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
- ctx.addIssue({ code: 'custom', path: ['newTopicName'], message: `only allowed when topicId is ${NEW_TOPIC_SCOPE}` });
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 !== 'course') {
120
- ctx.addIssue({ code: 'custom', path: ['kind'], message: `must be course when topicId is ${NEW_TOPIC_SCOPE}` });
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
@@ -48,6 +48,7 @@ const TTL = Object.freeze({
48
48
  const LIMITS = Object.freeze({
49
49
  hookTextMaxChars: 8000,
50
50
  deliverFileMaxBytes: 10 * 1024 * 1024,
51
+ sceneDeliverFileMaxBytes: 200 * 1024 * 1024,
51
52
  selectionMaxChars: 2000,
52
53
  });
53
54
  /** 后端接口请求超时(毫秒) */
@@ -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
- /** 生成类别 → topic_file.file_type(对齐前端 useTopicProductIO / courseGeneratePersist) */
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
- html: 'text/html',
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: COS_UPLOAD_TIMEOUT_MS,
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}:未运行则以 detached 方式拉起 `stdio.js daemon`,自身随后作为 follower
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
- * 以 detached 方式拉起 daemon(脱离当前进程组,WB 结束会话时不会被一并结束)。
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,
@@ -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
- * 校验「新建专题」字段:保留作用域只允许 course 且必须带兜底名称;普通专题不得携带兜底名称。
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 !== 'course')
126
- throw new Error('新建专题任务的 kind 只允许 course');
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 标签会作为新专题名,请写成简洁的课程标题;',
@@ -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 生课」):任务登记在保留作用域 {@link NEW_TOPIC_SCOPE} 下,
3
- * 交付时由 MCP 完成「建专题 → 上传 COS 并登记 → 置 ready」,对齐前端 course-generate「发布课程」的落库结果
4
- * (`topic_type=ai_lesson`、产物登记为 `course` 类型、专题状态 `ready`)。
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
- * - 专题名:优先取产物 HTML 的 `<title>`(解码实体、折叠空白、≤ 100 字),取不到用任务的 `newTopicName`;
7
- * 重名(业务码 4042)时追加时间后缀重试一次。
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 状态新建 ai_lesson 专题;重名时追加时间后缀重试一次。
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, topic_type: 'ai_lesson', status: 'building' });
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, building)→ 上传登记 → 置 ready;后两步失败删除专题后重抛。
121
- * @param input token、HTML、兜底名与上传函数
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 name = extractHtmlTitle(input.html) ?? normalizeTopicName(input.fallbackName);
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 });
@@ -1,4 +1,4 @@
1
- /** 生成类别 → 技能 slug(与前端常量、`skill` 表一致) */
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 SKILL_PACKAGE_PATH_PREFIX = '/learningbuddy/skills/';
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 恰为 `/learningbuddy/skills/<slug>.zip`;query 不限(签名参数);无 hash。
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 === `${SKILL_PACKAGE_PATH_PREFIX}${slug}.zip` &&
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
- * 无页面上下文时工具返回给模型的完整提示文本(get_page_context 与查询类工具同源)。
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
- '在此之前只能基于用户在对话中描述的内容作答;需要排查连接问题时调用 get_connector_status。',
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
- * 上传登记产物:普通任务上传到任务专题;新建专题任务(`NEW_TOPIC_SCOPE`)走「建专题 → 上传 → 置 ready」。
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 fileName 实际文件名
195
- * @param text 校验后的文本
228
+ * @param filePath 文件绝对路径
196
229
  */
197
- async function uploadToTarget(deps, task, fileName, text) {
198
- const content = Buffer.from(text, 'utf8');
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 landed = await landToNewTopic({ token: task.token, html: text, fallbackName: task.newTopicName ?? '', upload }, { api: deps.topicApi(), logger: deps.logger, now: deps.now });
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 file = await deps.readFile(filePath);
213
- assertSuffix(task.kind, file.fileName);
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: file.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
- lines.push('', STATUS_CAPABILITIES, '', context ? STATUS_CONNECTED_NEXT : STATUS_NEXT_STEPS);
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
- '用户要求生成笔记 / 测验 / 闪卡 / 课程,或消息中出现学习空间任务 requestId 时,必须先调用本工具获取任务要求再执行。' +
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
- }, () => runTool(async () => renderExpert(await backend.getActiveExpert())));
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
- }, () => runTool(async () => {
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
- }, () => runTool(async () => {
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
- '用户要求生成笔记 / 测验 / 闪卡 / 课程,或消息中带有学习空间任务 requestId 而对话中没有 [待完成的生成任务] 时,先调用 get_page_context,严格按其中的技能、进度上报与交付要求执行;',
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 生课」登记任务时以此代替专题 ID(此时专题尚不存在),
9
- * 交付时由 MCP 新建 ai_lesson 专题再上传产物。真实专题 ID 为 32 位十六进制,不会与之冲突。
10
- * 仅允许 `course` 类别使用。
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 阻塞);读取最多 `maxBytes + 1` 字节,防 stat 后被追加。
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
- // 多读 1 字节:stat 之后文件若被追加,仍能发现超限
119
- const content = await orForbidden(readUpTo(handle, maxBytes + 1));
120
- if (content.length > maxBytes)
121
- throw tooLarge(maxBytes);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tencent-edu-assistant-connector",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "腾讯教育学习助手 · 专题 MCP Server:为 WorkBuddy 会话提供专题页面上下文、业务查询与产物落库能力",
6
6
  "bin": {
@@ -6,7 +6,7 @@
6
6
  "name": "tencent-edu-assistant",
7
7
  "source": "./plugins/tencent-edu-assistant",
8
8
  "description": "把学习空间专题页的专题 / 资料 / 阅读位置 / 专家上下文提供给 WorkBuddy 会话,并支持生成产物回填专题",
9
- "version": "0.2.0"
9
+ "version": "0.3.0"
10
10
  }
11
11
  ]
12
12
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tencent-edu-assistant",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "把学习空间专题页的专题 / 资料 / 阅读位置 / 专家上下文提供给 WorkBuddy 会话,并支持生成产物回填专题",
5
5
  "author": {
6
6
  "name": "tencent"