tencent-edu-assistant-connector 0.1.4 → 0.2.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 +2 -1
- package/dist/config.js +2 -2
- package/dist/context-store.js +13 -0
- package/dist/hook-text.js +27 -3
- package/dist/tools.js +136 -7
- 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
|
@@ -39,9 +39,10 @@ npx -y tencent-edu-assistant-connector@latest install
|
|
|
39
39
|
|
|
40
40
|
## 常见问题
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
**页面提示「未连接到腾讯教育学习助手」**
|
|
43
43
|
|
|
44
44
|
确认 WorkBuddy「连接器」中 「腾讯教育学习助手」已启用;刚安装时等首次下载完成,或在 WorkBuddy 中发送任意消息后刷新页面。
|
|
45
|
+
也可以在 WorkBuddy 里直接问「学习空间连上了吗?」,AI 会调用连接器的 `get_connector_status` 自检,给出本机服务、页面连接状态与排查步骤。
|
|
45
46
|
|
|
46
47
|
连接器会在本机启动一个后台服务持有 3101 端口(关闭 WorkBuddy 会话后仍保留,页面关闭且 30 分钟无使用后自动退出),日志在 `~/.tencent-edu-assistant/daemon.log`。
|
|
47
48
|
|
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
|
/** 一分钟(毫秒) */
|
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/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
|
*/
|
|
@@ -109,12 +109,35 @@ function filesIntroSelected(count) {
|
|
|
109
109
|
'以下均为已签名地址,请直接用 curl / WebFetch 下载读取,禁止在本地磁盘搜索同名文件。',
|
|
110
110
|
].join('');
|
|
111
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* 链接专题的「学习网页」段;非链接专题为空串。
|
|
114
|
+
*
|
|
115
|
+
* 链接专题没有上传文件,链接本身就是唯一资料,因此必须显式给出链接与「不要凭 URL 想象内容」的约束,
|
|
116
|
+
* 否则模型只会看到专题标题,容易编造网页内容。
|
|
117
|
+
* @param webLink 链接专题绑定的网页链接
|
|
118
|
+
*/
|
|
119
|
+
function renderWebLink(webLink) {
|
|
120
|
+
if (!webLink)
|
|
121
|
+
return '';
|
|
122
|
+
const title = (webLink.title ?? '').trim();
|
|
123
|
+
// 页面占位标题(「未命名专题-xxx」)不是网页真实标题,不能作为内容线索
|
|
124
|
+
const named = title.length > 0 && !title.startsWith('未命名专题') ? `《${inline(title)}》` : '';
|
|
125
|
+
const platform = webLink.platform === 'bilibili' ? 'B 站视频' : webLink.platform === 'youtube' ? 'YouTube 视频' : '网页';
|
|
126
|
+
return [
|
|
127
|
+
`[学习网页] 本专题是链接专题,资料是${platform}${named}:${inline(webLink.url)}`,
|
|
128
|
+
'需要网页内容时先用联网搜索了解其公开信息(标题、简介、章节或字幕要点),不要凭 URL / 编号想象内容;',
|
|
129
|
+
'确实取不到内容时如实告诉用户,并请用户把要讲的关键文字粘贴到对话里。',
|
|
130
|
+
].join('');
|
|
131
|
+
}
|
|
112
132
|
/**
|
|
113
133
|
* 文件清单:源文件一组在前,产物一组在后(组内保持原顺序)。
|
|
114
134
|
* @param files 上下文文件(已按当前文件范围取好)
|
|
115
135
|
* @param fileScope 文件范围;`selected` 时使用「仅使用勾选文件」的说明行
|
|
136
|
+
* @param hasWebLink 是否为链接专题:没有文件时省略清单,避免「暂无可用文件」与链接段自相矛盾
|
|
116
137
|
*/
|
|
117
|
-
function renderFiles(files, fileScope) {
|
|
138
|
+
function renderFiles(files, fileScope, hasWebLink) {
|
|
139
|
+
if (files.length === 0 && hasWebLink)
|
|
140
|
+
return '';
|
|
118
141
|
const sources = files.filter((file) => !isProductFile(file));
|
|
119
142
|
const products = files.filter(isProductFile);
|
|
120
143
|
const lines = [fileScope === 'selected' ? filesIntroSelected(files.length) : FILES_INTRO];
|
|
@@ -342,7 +365,8 @@ export function buildHookText(input) {
|
|
|
342
365
|
renderReading(context.reading),
|
|
343
366
|
renderSelection(context.selection),
|
|
344
367
|
renderExpert(context.expert),
|
|
345
|
-
|
|
368
|
+
renderWebLink(context.webLink),
|
|
369
|
+
renderFiles(context.files, context.fileScope, context.webLink !== undefined),
|
|
346
370
|
]);
|
|
347
371
|
return composeWithinBudget(renderHeader(context), body, pendingTasks, maxChars);
|
|
348
372
|
}
|
package/dist/tools.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
import { ARTIFACT_SUFFIX_BY_KIND, validateArtifact } from './artifact-validate.js';
|
|
12
|
+
import { DEFAULT_PORT } from './config.js';
|
|
12
13
|
import { contextStore as defaultContextStore } from './context-store.js';
|
|
13
14
|
import { uploadArtifactToTopic } from './cos-upload.js';
|
|
14
15
|
import { generationStore as defaultGenerationStore, isUnfinishedStatus } from './generation-store.js';
|
|
@@ -22,8 +23,27 @@ import { readPackageVersion } from './version.js';
|
|
|
22
23
|
import { isWbFileError, readWbFile } from './wb-files.js';
|
|
23
24
|
/** 无页面上下文时的提示 */
|
|
24
25
|
export const NO_CONTEXT_MESSAGE = '当前没有学习空间页面上下文,请让用户先在 WorkBuddy 中打开专题详情页';
|
|
25
|
-
/**
|
|
26
|
-
const
|
|
26
|
+
/** 连接状态工具名(无需页面上下文即可调用,用于自检与排查) */
|
|
27
|
+
const STATUS_TOOL_NAME = 'get_connector_status';
|
|
28
|
+
/** 本机服务端口(`LB_TOPIC_MCP_PORT` 与 stdio 入口同源;非法或缺省时用默认端口) */
|
|
29
|
+
function localPort() {
|
|
30
|
+
const raw = process.env.LB_TOPIC_MCP_PORT?.trim() ?? '';
|
|
31
|
+
const port = /^\d+$/.test(raw) ? Number(raw) : Number.NaN;
|
|
32
|
+
return port >= 1 && port <= 65_535 ? port : DEFAULT_PORT;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 无页面上下文时工具返回给模型的完整提示文本(get_page_context 与查询类工具同源)。
|
|
36
|
+
*
|
|
37
|
+
* 首次使用最常见的情形就是「还没打开专题页」:这里明确说明连接器本身是好的、端口在哪里、
|
|
38
|
+
* 打开页面后会自动连接,并指向自检工具,避免模型只回一句「没有上下文」后无法继续。
|
|
39
|
+
*/
|
|
40
|
+
function noContextToolText() {
|
|
41
|
+
return [
|
|
42
|
+
`${NO_CONTEXT_MESSAGE}。`,
|
|
43
|
+
`连接器本身运行正常(v${readPackageVersion()},本机 127.0.0.1:${localPort()}),用户打开专题详情页后 1~2 秒内会自动连接。`,
|
|
44
|
+
'在此之前只能基于用户在对话中描述的内容作答;需要排查连接问题时调用 get_connector_status。',
|
|
45
|
+
].join('');
|
|
46
|
+
}
|
|
27
47
|
/** 无页面上下文错误码(follower 经 HTTP 转发时按 code 跨进程还原) */
|
|
28
48
|
export const NO_CONTEXT_ERROR_CODE = 'LB_NO_PAGE_CONTEXT';
|
|
29
49
|
/** 创建无页面上下文错误(消息为 {@link NO_CONTEXT_MESSAGE}) */
|
|
@@ -333,7 +353,7 @@ async function runTool(run) {
|
|
|
333
353
|
}
|
|
334
354
|
catch (error) {
|
|
335
355
|
if (isNoContextError(error))
|
|
336
|
-
return { content: [{ type: 'text', text:
|
|
356
|
+
return { content: [{ type: 'text', text: noContextToolText() }] };
|
|
337
357
|
return { isError: true, content: [{ type: 'text', text: `调用失败:${describeError(error)}` }] };
|
|
338
358
|
}
|
|
339
359
|
}
|
|
@@ -345,7 +365,7 @@ async function runTool(run) {
|
|
|
345
365
|
*/
|
|
346
366
|
function renderPageContext(ctx, tasks) {
|
|
347
367
|
if (!ctx && tasks.length === 0)
|
|
348
|
-
return
|
|
368
|
+
return noContextToolText();
|
|
349
369
|
return buildHookText({ context: ctx, pendingTasks: tasks, maxChars: Number.MAX_SAFE_INTEGER });
|
|
350
370
|
}
|
|
351
371
|
/**
|
|
@@ -380,11 +400,118 @@ function renderDelivered(result) {
|
|
|
380
400
|
].join('');
|
|
381
401
|
}
|
|
382
402
|
/**
|
|
383
|
-
*
|
|
403
|
+
* 相对时间文案(用于状态里的「N 秒前更新」)。
|
|
404
|
+
* @param elapsedMs 距今毫秒数
|
|
405
|
+
*/
|
|
406
|
+
function formatElapsed(elapsedMs) {
|
|
407
|
+
if (!Number.isFinite(elapsedMs) || elapsedMs < 1000)
|
|
408
|
+
return '刚刚';
|
|
409
|
+
const seconds = Math.floor(elapsedMs / 1000);
|
|
410
|
+
if (seconds < 60)
|
|
411
|
+
return `${seconds} 秒前`;
|
|
412
|
+
const minutes = Math.floor(seconds / 60);
|
|
413
|
+
if (minutes < 60)
|
|
414
|
+
return `${minutes} 分钟前`;
|
|
415
|
+
return `${Math.floor(minutes / 60)} 小时前`;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* 待完成任务按类别汇总,如「(笔记 1、测验 2)」;无任务时为空串。
|
|
419
|
+
* @param tasks 待完成任务
|
|
420
|
+
*/
|
|
421
|
+
function summarizeTaskKinds(tasks) {
|
|
422
|
+
const counts = new Map();
|
|
423
|
+
for (const task of tasks)
|
|
424
|
+
counts.set(task.kind, (counts.get(task.kind) ?? 0) + 1);
|
|
425
|
+
if (counts.size === 0)
|
|
426
|
+
return '';
|
|
427
|
+
const parts = [...counts].map(([kind, count]) => `${KIND_LABELS[kind]} ${count}`);
|
|
428
|
+
return `(${parts.join('、')})`;
|
|
429
|
+
}
|
|
430
|
+
/** 已连接 / 未连接都适用的能力说明 */
|
|
431
|
+
const STATUS_CAPABILITIES = [
|
|
432
|
+
'能做什么:',
|
|
433
|
+
'1. 基于当前专题资料答疑:总结、讲解、翻译、出题;',
|
|
434
|
+
'2. 按专题页的生成按钮产出笔记 / 测验 / 闪卡 / 互动课程,并自动回填专题;',
|
|
435
|
+
'3. 学习空间首页「AI 生课」:围绕一个问题生成互动课程并新建专题。',
|
|
436
|
+
].join('\n');
|
|
437
|
+
/** 未连接时的下一步与排查建议 */
|
|
438
|
+
const STATUS_NEXT_STEPS = [
|
|
439
|
+
'下一步:',
|
|
440
|
+
'1. 在 WorkBuddy 中打开「腾讯教育学习助手 · 学习空间」,进入任意专题详情页;',
|
|
441
|
+
'2. 页面会在 1~2 秒内自动连接,再调用本工具会显示「已连接」;',
|
|
442
|
+
'3. 连接后直接提问,或在专题页点击生成按钮。',
|
|
443
|
+
'排查:确认 WorkBuddy「连接器」中「腾讯教育学习助手」已启用;首次安装需等下载完成;发送任意消息后刷新页面;提示端口被占用时,关闭占用该端口的程序即可自动恢复。',
|
|
444
|
+
].join('\n');
|
|
445
|
+
/** 已连接时的下一步 */
|
|
446
|
+
const STATUS_CONNECTED_NEXT = '下一步:直接提问,或在学习空间专题页点击生成按钮。';
|
|
447
|
+
/**
|
|
448
|
+
* 单行化外部来源字段:尖括号转全角、换行折叠为空格,防止伪造状态行。
|
|
449
|
+
* @param value 外部来源字符串
|
|
450
|
+
*/
|
|
451
|
+
function oneLine(value) {
|
|
452
|
+
return escapeAngleBrackets(value).replace(/\s*[\r\n]+\s*/g, ' ');
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* 渲染连接状态(始终可读,不依赖页面上下文):本机服务、页面连接、待完成任务、能力与下一步。
|
|
456
|
+
* @param context 当前页面上下文;`null` 表示未连接
|
|
457
|
+
* @param tasks 待完成的生成任务
|
|
458
|
+
* @param problem 本机服务异常信息;`null` 表示服务正常
|
|
459
|
+
*/
|
|
460
|
+
function renderConnectorStatus(context, tasks, problem) {
|
|
461
|
+
const lines = [`[连接器状态] 腾讯教育学习助手 连接器 v${readPackageVersion()} · 本机 127.0.0.1:${localPort()}`];
|
|
462
|
+
if (problem !== null) {
|
|
463
|
+
lines.push(`- 本机服务:异常(${oneLine(problem)})`, '- 页面连接:无法确认');
|
|
464
|
+
lines.push('', STATUS_CAPABILITIES, '', STATUS_NEXT_STEPS);
|
|
465
|
+
return lines.join('\n');
|
|
466
|
+
}
|
|
467
|
+
lines.push('- 本机服务:运行中');
|
|
468
|
+
if (context) {
|
|
469
|
+
const details = [`专题《${oneLine(context.topicTitle) || '未命名'}》(id=${oneLine(context.topicId)})`];
|
|
470
|
+
if (context.webLink)
|
|
471
|
+
details.push(`学习网页 ${oneLine(context.webLink.url)}`);
|
|
472
|
+
else
|
|
473
|
+
details.push(`资料 ${context.files.length} 份`);
|
|
474
|
+
if (context.expert)
|
|
475
|
+
details.push(`答疑专家:${oneLine(context.expert.name)}`);
|
|
476
|
+
details.push(`${formatElapsed(Date.now() - context.updatedAt)}更新`);
|
|
477
|
+
lines.push(`- 页面连接:已连接 · ${details.join(' · ')}`);
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
lines.push('- 页面连接:未连接(还没有收到学习空间页面的上报)');
|
|
481
|
+
}
|
|
482
|
+
lines.push(`- 待完成的生成任务:${tasks.length} 个${summarizeTaskKinds(tasks)}`);
|
|
483
|
+
lines.push('', STATUS_CAPABILITIES, '', context ? STATUS_CONNECTED_NEXT : STATUS_NEXT_STEPS);
|
|
484
|
+
return lines.join('\n');
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* 注册自检工具:get_connector_status。
|
|
488
|
+
* 不依赖页面上下文与后端接口,服务异常(如端口被占用)时也返回可读状态。
|
|
489
|
+
* @param server MCP Server
|
|
490
|
+
* @param backend 执行层
|
|
491
|
+
*/
|
|
492
|
+
function registerStatusTool(server, backend) {
|
|
493
|
+
server.registerTool(STATUS_TOOL_NAME, {
|
|
494
|
+
title: '查看连接器状态',
|
|
495
|
+
description: '查看本机连接器是否正常、是否已连上学习空间页面、当前专题与待完成生成任务,并给出下一步操作与排查建议。' +
|
|
496
|
+
'用户问「连上了吗 / 现在能做什么 / 页面怎么没反应」,或页面提示未连接时调用;本工具不需要页面上下文,任何时候都可调用。',
|
|
497
|
+
}, () => runTool(async () => {
|
|
498
|
+
try {
|
|
499
|
+
const [context, tasks] = await Promise.all([backend.getPageContext(), backend.getPendingTasks()]);
|
|
500
|
+
return renderConnectorStatus(context, tasks, null);
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
// 自检工具本身不应失败:把服务异常渲染成状态,便于用户按提示处理
|
|
504
|
+
return renderConnectorStatus(null, [], describeError(error));
|
|
505
|
+
}
|
|
506
|
+
}));
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* 注册上下文类工具:get_page_context、get_active_expert、get_connector_status。
|
|
384
510
|
* @param server MCP Server
|
|
385
511
|
* @param backend 执行层
|
|
386
512
|
*/
|
|
387
513
|
function registerContextTools(server, backend) {
|
|
514
|
+
registerStatusTool(server, backend);
|
|
388
515
|
server.registerTool('get_page_context', {
|
|
389
516
|
title: '读取当前页面上下文',
|
|
390
517
|
description: '读取用户在「学习空间 · 专题详情」页面的上下文:专题、全部文件清单(已签名下载地址)、阅读位置、答疑专家、选中文本,' +
|
|
@@ -514,7 +641,7 @@ function registerProgressTool(server, backend) {
|
|
|
514
641
|
}));
|
|
515
642
|
}
|
|
516
643
|
/**
|
|
517
|
-
* 注册全部
|
|
644
|
+
* 注册全部 8 个工具。
|
|
518
645
|
* @param server MCP Server
|
|
519
646
|
* @param backend 执行层
|
|
520
647
|
*/
|
|
@@ -530,7 +657,9 @@ const SERVER_INSTRUCTIONS = [
|
|
|
530
657
|
'用户提到「这页 / 这篇 / 这个专题 / 当前资料」而对话中没有 [学习空间页面上下文] 时,调用 get_page_context;',
|
|
531
658
|
'用户要求生成笔记 / 测验 / 闪卡 / 课程,或消息中带有学习空间任务 requestId 而对话中没有 [待完成的生成任务] 时,先调用 get_page_context,严格按其中的技能、进度上报与交付要求执行;',
|
|
532
659
|
'调用 get_page_context 时,若用户本条消息里出现「学习空间任务 requestId:xxx」,把该 id 作为 requestId 入参原样传入(页面据此立即显示生成中),并把它作为本次回复的第一个动作,不要先输出大段文字;其它答疑 / 总结 / 闲聊场景不要传该入参;',
|
|
533
|
-
'
|
|
660
|
+
'get_page_context 返回「当前没有学习空间页面上下文」时,告诉用户先打开专题详情页,并按返回的下一步(或调用 get_connector_status)给出状态与排查建议;',
|
|
661
|
+
`用户问「连上了吗 / 现在能做什么 / 页面怎么没反应」,或需要确认服务是否正常时,调用 ${STATUS_TOOL_NAME};它不需要页面上下文,任何时候都可调用;`,
|
|
662
|
+
'资料原文请通过上下文中的签名地址下载,不要在本地磁盘搜索同名文件;链接专题只给了网页链接时,先用联网搜索了解其公开信息,不要凭 URL 想象内容;',
|
|
534
663
|
`执行页面登记的生成任务时,各阶段调用 ${PROGRESS_TOOL_NAME} 上报进度;`,
|
|
535
664
|
`完成页面登记的生成任务后,调用 ${DELIVER_TOOL_NAME} 交付产物文件。`,
|
|
536
665
|
].join('\n');
|
package/package.json
CHANGED