tencent-edu-assistant-connector 0.1.4 → 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 +3 -1
- package/dist/artifact-validate.js +65 -2
- package/dist/bridge.js +54 -14
- package/dist/config.js +3 -2
- package/dist/context-store.js +13 -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 +39 -3
- 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 +256 -41
- 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
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
- **基于当前专题对话**:「这一页讲的是什么?帮我总结一下」「用当前专家的口吻解释一下我选中的这段话」
|
|
8
8
|
- **一键生成学习产物**:在专题页点击生成笔记 / 测验 / 闪卡 / 互动课程,WorkBuddy 生成后自动回填到专题,页面实时显示进度
|
|
9
9
|
- **首页 AI 生课**:围绕一个问题生成互动课程,并自动创建新专题
|
|
10
|
+
- **首页精选场景**:按场景技能生成网页或视频作品(≤ 200MB),并自动创建场景专题
|
|
10
11
|
|
|
11
12
|
## 安装
|
|
12
13
|
|
|
@@ -39,9 +40,10 @@ npx -y tencent-edu-assistant-connector@latest install
|
|
|
39
40
|
|
|
40
41
|
## 常见问题
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
**页面提示「未连接到腾讯教育学习助手」**
|
|
43
44
|
|
|
44
45
|
确认 WorkBuddy「连接器」中 「腾讯教育学习助手」已启用;刚安装时等首次下载完成,或在 WorkBuddy 中发送任意消息后刷新页面。
|
|
46
|
+
也可以在 WorkBuddy 里直接问「学习空间连上了吗?」,AI 会调用连接器的 `get_connector_status` 自检,给出本机服务、页面连接状态与排查步骤。
|
|
45
47
|
|
|
46
48
|
连接器会在本机启动一个后台服务持有 3101 端口(关闭 WorkBuddy 会话后仍保留,页面关闭且 30 分钟无使用后自动退出),日志在 `~/.tencent-edu-assistant/daemon.log`。
|
|
47
49
|
|
|
@@ -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
|
@@ -31,8 +31,8 @@ export const DEFAULT_ALLOWED_ORIGINS = Object.freeze([
|
|
|
31
31
|
'http://localhost:8089',
|
|
32
32
|
'http://127.0.0.1:8089',
|
|
33
33
|
]);
|
|
34
|
-
/**
|
|
35
|
-
const DEFAULT_PORT = 3101;
|
|
34
|
+
/** 默认监听端口(`LB_TOPIC_MCP_PORT` / `PORT` 未设置时使用) */
|
|
35
|
+
export const DEFAULT_PORT = 3101;
|
|
36
36
|
/** 合法端口上限 */
|
|
37
37
|
const MAX_PORT = 65535;
|
|
38
38
|
/** 一分钟(毫秒) */
|
|
@@ -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
|
/** 后端接口请求超时(毫秒) */
|
package/dist/context-store.js
CHANGED
|
@@ -43,6 +43,17 @@ function copyReading(reading) {
|
|
|
43
43
|
function copyExpert(expert) {
|
|
44
44
|
return expert.profile === undefined ? { name: expert.name } : { name: expert.name, profile: expert.profile };
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* 复制网页链接(只保留已知字段)。
|
|
48
|
+
* @param webLink 链接专题的网页链接
|
|
49
|
+
*/
|
|
50
|
+
function copyWebLink(webLink) {
|
|
51
|
+
return {
|
|
52
|
+
url: webLink.url,
|
|
53
|
+
...(webLink.title === undefined ? {} : { title: webLink.title }),
|
|
54
|
+
...(webLink.platform === undefined ? {} : { platform: webLink.platform }),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
46
57
|
/**
|
|
47
58
|
* 复制选区(只保留已知字段),可选截断文本。
|
|
48
59
|
* @param selection 选区
|
|
@@ -104,6 +115,7 @@ export function createContextStore(options = {}) {
|
|
|
104
115
|
? {}
|
|
105
116
|
: { selection: copySelection(ctx.selection, selectionMaxChars()) }),
|
|
106
117
|
...(ctx.expert === undefined ? {} : { expert: copyExpert(ctx.expert) }),
|
|
118
|
+
...(ctx.webLink === undefined ? {} : { webLink: copyWebLink(ctx.webLink) }),
|
|
107
119
|
token: ctx.token,
|
|
108
120
|
updatedAt: now(),
|
|
109
121
|
};
|
|
@@ -143,6 +155,7 @@ export function createContextStore(options = {}) {
|
|
|
143
155
|
...(reading === undefined ? {} : { reading }),
|
|
144
156
|
...(selection === undefined ? {} : { selection }),
|
|
145
157
|
...(current.expert === undefined ? {} : { expert: copyExpert(current.expert) }),
|
|
158
|
+
...(current.webLink === undefined ? {} : { webLink: copyWebLink(current.webLink) }),
|
|
146
159
|
updatedAt: current.updatedAt,
|
|
147
160
|
};
|
|
148
161
|
},
|
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
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* - 不输出 token(`GenerationTask.token` 不参与渲染)。
|
|
10
10
|
* - 所有外部来源字符串的尖括号替换为全角,防止伪造 `</system-reminder>` 等标签;单行字段的换行折叠为空格。
|
|
11
11
|
* - 总长不超过 `maxChars`。截断优先级(先保留 → 先截断):头部(专题信息)与未完成任务段始终保留;
|
|
12
|
-
* 正文段按「阅读位置 → 选区 → 专家 → 文件清单」排列,只从正文尾部截断,因此签名地址很长的文件清单最先被截断,
|
|
12
|
+
* 正文段按「阅读位置 → 选区 → 专家 → 学习网页 → 文件清单」排列,只从正文尾部截断,因此签名地址很长的文件清单最先被截断,
|
|
13
13
|
* 清单说明行提示模型可调用 `get_page_context` 获取全部文件。头部与任务段本身超长时,先把各任务的表单参数
|
|
14
14
|
* 压缩到 {@link PARAMS_COMPACT_MAX_CHARS} 字(requestId / 进度上报 / 交付要求行完整保留)再重试;仍超长才退化为整体截断。
|
|
15
15
|
*/
|
|
@@ -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
|
* 把尖括号替换为全角,保留其余字符(含换行)。
|
|
@@ -109,12 +111,35 @@ function filesIntroSelected(count) {
|
|
|
109
111
|
'以下均为已签名地址,请直接用 curl / WebFetch 下载读取,禁止在本地磁盘搜索同名文件。',
|
|
110
112
|
].join('');
|
|
111
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* 链接专题的「学习网页」段;非链接专题为空串。
|
|
116
|
+
*
|
|
117
|
+
* 链接专题没有上传文件,链接本身就是唯一资料,因此必须显式给出链接与「不要凭 URL 想象内容」的约束,
|
|
118
|
+
* 否则模型只会看到专题标题,容易编造网页内容。
|
|
119
|
+
* @param webLink 链接专题绑定的网页链接
|
|
120
|
+
*/
|
|
121
|
+
function renderWebLink(webLink) {
|
|
122
|
+
if (!webLink)
|
|
123
|
+
return '';
|
|
124
|
+
const title = (webLink.title ?? '').trim();
|
|
125
|
+
// 页面占位标题(「未命名专题-xxx」)不是网页真实标题,不能作为内容线索
|
|
126
|
+
const named = title.length > 0 && !title.startsWith('未命名专题') ? `《${inline(title)}》` : '';
|
|
127
|
+
const platform = webLink.platform === 'bilibili' ? 'B 站视频' : webLink.platform === 'youtube' ? 'YouTube 视频' : '网页';
|
|
128
|
+
return [
|
|
129
|
+
`[学习网页] 本专题是链接专题,资料是${platform}${named}:${inline(webLink.url)}`,
|
|
130
|
+
'需要网页内容时先用联网搜索了解其公开信息(标题、简介、章节或字幕要点),不要凭 URL / 编号想象内容;',
|
|
131
|
+
'确实取不到内容时如实告诉用户,并请用户把要讲的关键文字粘贴到对话里。',
|
|
132
|
+
].join('');
|
|
133
|
+
}
|
|
112
134
|
/**
|
|
113
135
|
* 文件清单:源文件一组在前,产物一组在后(组内保持原顺序)。
|
|
114
136
|
* @param files 上下文文件(已按当前文件范围取好)
|
|
115
137
|
* @param fileScope 文件范围;`selected` 时使用「仅使用勾选文件」的说明行
|
|
138
|
+
* @param hasWebLink 是否为链接专题:没有文件时省略清单,避免「暂无可用文件」与链接段自相矛盾
|
|
116
139
|
*/
|
|
117
|
-
function renderFiles(files, fileScope) {
|
|
140
|
+
function renderFiles(files, fileScope, hasWebLink) {
|
|
141
|
+
if (files.length === 0 && hasWebLink)
|
|
142
|
+
return '';
|
|
118
143
|
const sources = files.filter((file) => !isProductFile(file));
|
|
119
144
|
const products = files.filter(isProductFile);
|
|
120
145
|
const lines = [fileScope === 'selected' ? filesIntroSelected(files.length) : FILES_INTRO];
|
|
@@ -230,6 +255,16 @@ function renderStoppedLine(task, requestId) {
|
|
|
230
255
|
function renderNewTopicLine(task) {
|
|
231
256
|
if (task.topicId !== NEW_TOPIC_SCOPE)
|
|
232
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
|
+
}
|
|
233
268
|
return [
|
|
234
269
|
'- 新建专题:本任务来自学习空间首页「AI 生课」,没有专题资料,无需下载资料,',
|
|
235
270
|
'请围绕表单参数 question 中的问题生成互动课程;产物 HTML 的 title 标签会作为新专题名,请写成简洁的课程标题;',
|
|
@@ -342,7 +377,8 @@ export function buildHookText(input) {
|
|
|
342
377
|
renderReading(context.reading),
|
|
343
378
|
renderSelection(context.selection),
|
|
344
379
|
renderExpert(context.expert),
|
|
345
|
-
|
|
380
|
+
renderWebLink(context.webLink),
|
|
381
|
+
renderFiles(context.files, context.fileScope, context.webLink !== undefined),
|
|
346
382
|
]);
|
|
347
383
|
return composeWithinBudget(renderHeader(context), body, pendingTasks, maxChars);
|
|
348
384
|
}
|
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
|
/**
|