Files
memind/chat-skills.mjs
T

379 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Keep browser-safe: do not import user-publish.mjs (uses node:fs/path/url).
const PUBLISH_SKILL_NAME = 'static-page-publish';
export const PAGE_DATA_COLLECT_SKILL_NAME = 'page-data-collect';
export const SKILL_ROUTER_V2_ENV = 'TKMIND_SKILL_ROUTER_V2';
const WEB_INTENT_PATTERNS = [
/(?:今天|今日|最新|热点|热搜|新闻|头条|头条新闻|最新消息|发生了什么|最近发生)/u,
/(?:latest|breaking)\s+news/i,
/what(?:'s| is)?\s+happening/i,
];
const WEB_NEWS_INTENT_PATTERNS = [
/(?:今天|今日|最新|热点|热搜|新闻|头条|头条新闻|热榜|热议|舆情|突发)/u,
/(?:today|latest|breaking|trending)\s+(?:news|stories|headlines|topics)/i,
/(?:what(?:'s| is)?\s+the\s+latest|what(?:'s| is)?\s+trending)/i,
];
const PAGE_GENERATION_INTENT_PATTERNS = [
/(?:生成|做|制作|创建|设计|写|出)(?:一个|一份|个)?(?:H5|h5|HTML|html|网页|页面|活动页|宣传页|落地页|分享页)/u,
/(?:帮我|给我).*(?:H5|h5|HTML|html|网页|页面|活动页|宣传页|落地页|分享页)/u,
/(?:publish|create|generate|make|build|design).*(?:html|web\s?page|landing\s?page|h5)/i,
// 隐式页面需求:主题/攻略 + 页面,无显式动词(如「苏州攻略页面」)
/(?:攻略|指南|游记|手册|简介|介绍|展示).{0,8}(?:页面|网页|H5|h5|html)/u,
/[^\s,。!?]{2,20}(?:攻略|指南).{0,4}(?:页面|网页)/u,
];
const PRODUCT_CAMPAIGN_INTENT_PATTERNS = [
/(?:商品|购买|下单|种草|带货|促销|上架|SKU|店铺|电商)/u,
/(?:https?:\/\/|taobao|tmall|jd\.com|商品链接|购买链接|购买按钮)/iu,
];
const PAGE_DATA_INTENT_PATTERNS = [
/(?:问卷|调查|签到表|签到登记|签到收集|投票|意见反馈|数据上报)/u,
/(?:报名表|报名登记|在线报名|收集报名)/u,
/(?:作业登记|作业打卡|作业记录|每日作业|打卡登记|台账|记录表)/u,
/(?:表单|数据采集|数据交互|存数据|保存提交|提交记录)/u,
/(?:后台|管理入口|管理后台).{0,20}(?:查看|记录|数据|提交)/u,
/(?:密码|口令).{0,12}(?:查看|后台|管理|进入)/u,
/(?:sqlite|数据库|page[\s-]?data)/i,
/(?:每个用户|各用户).{0,12}(?:提交|记录)/u,
/(?:记账|账本|收支|流水|日记|习惯打卡|每日打卡).{0,40}(?:页面|记录|管理|汇总|统计)/u,
/(?:管理页面|管理页).{0,30}(?:查看|记录|汇总|统计|明细|删除|修改)/u,
/(?:页面|网页|H5|h5).{0,80}(?:每天|每日|新增|添加|填写|记录).{0,80}(?:所有记录|历史记录|管理|汇总|统计)/u,
];
const INTERACTIVE_PAGE_DATA_SUBJECT_PATTERN = /(?:便签|便利贴|备忘录|待办|清单)/u;
const INTERACTIVE_PAGE_DATA_SURFACE_PATTERN = /(?:应用|app|页面|网页|H5|h5|HTML|html|时间轴|看板)/u;
const INTERACTIVE_PAGE_DATA_FEATURE_PATTERNS = [
/(?:写|填写|提交|保存)/u,
/(?:添加|新增|创建)/u,
/(?:编辑|修改)/u,
/(?:删除|完成)/u,
/(?:筛选|统计|汇总|管理)/u,
/(?:提醒时间|优先级|分类标签|时间轴)/u,
];
function isInteractivePageDataIntent(text) {
if (!INTERACTIVE_PAGE_DATA_SUBJECT_PATTERN.test(text)) return false;
const featureCount = INTERACTIVE_PAGE_DATA_FEATURE_PATTERNS.reduce(
(count, pattern) => count + (pattern.test(text) ? 1 : 0),
0,
);
return INTERACTIVE_PAGE_DATA_SURFACE_PATTERN.test(text) || featureCount >= 2;
}
export function isPageDataIntent(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
return PAGE_DATA_INTENT_PATTERNS.some((pattern) => pattern.test(normalized))
|| isInteractivePageDataIntent(normalized);
}
export function isPageGenerationIntent(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
return PAGE_GENERATION_INTENT_PATTERNS.some((pattern) => pattern.test(normalized));
}
export function isProductCampaignIntent(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
return PRODUCT_CAMPAIGN_INTENT_PATTERNS.some((pattern) => pattern.test(normalized));
}
export const CHAT_SKILL_DEFINITIONS = [
{
id: 'web-search',
label: '查资料',
icon: 'web',
skillName: 'web',
requiresSkill: 'web',
prefillOnly: true,
promptKey: 'web',
},
{
id: 'code-search',
label: '搜代码',
icon: 'search',
skillName: 'search',
requiresSkill: 'search',
prefillOnly: true,
promptKey: 'search',
},
{
id: 'service-integration-smoke',
label: '联调测试',
icon: 'analyze',
skillName: 'service-integration-smoke',
requiresSkill: 'service-integration-smoke',
prefillOnly: true,
promptKey: 'service-integration-smoke',
},
{
id: 'form-collect',
label: '表单收集',
icon: 'form',
skillName: 'form-builder',
requiresSkill: 'form-builder',
prefillOnly: true,
promptKey: 'form-builder',
},
{
id: 'page-data-collect',
label: '数据页面',
icon: 'form',
skillName: PAGE_DATA_COLLECT_SKILL_NAME,
requiresSkill: PAGE_DATA_COLLECT_SKILL_NAME,
requiresPublish: true,
promptKey: 'page-data-collect',
},
{
id: 'table-view',
label: '数据表格',
icon: 'table',
skillName: 'table-viewer',
requiresSkill: 'table-viewer',
prefillOnly: true,
promptKey: 'table-viewer',
},
{
id: 'product-campaign-page',
label: '商品页',
icon: 'page',
skillName: 'product-campaign-page',
requiresSkill: 'product-campaign-page',
promptKey: 'product-campaign-page',
},
{
id: 'summarize',
label: '总结内容',
icon: 'summary',
prefillOnly: true,
promptKey: 'summarize',
},
{
id: 'analyze',
label: '深度分析',
icon: 'analyze',
prefillOnly: true,
promptKey: 'analyze',
},
{
id: 'generate-page',
label: '生成页面',
icon: 'page',
skillName: PUBLISH_SKILL_NAME,
requiresPublish: true,
promptKey: 'generate-page',
},
];
export function buildChatSkillPrompt(promptKey, skillName) {
switch (promptKey) {
case 'web':
return `请使用 ${skillName ?? 'web'} 技能:帮我搜索并查阅相关资料(优先官方文档),并给出中文摘要与来源链接。我的问题是:`;
case 'search':
return `请使用 ${skillName ?? 'search'} 技能:帮我在工作区中查找代码或文件。我要找的是:`;
case 'form-builder':
return `请使用 ${skillName ?? 'form-builder'} 技能:请用交互式表单收集以下场景所需的结构化字段(字段不超过 8 个):`;
case 'page-data-collect':
return (
`请使用 ${skillName ?? PAGE_DATA_COLLECT_SKILL_NAME} 技能:在 MindSpace 页面中实现可提交、可持久化的数据收集(问卷/报名/台账等),必须使用 Page Data API。` +
'先匹配技能内能力分支(默认 A:匿名前台 public insert + 独立后台 password read,口令默认 88888888);“帮我做/创建”本身就是创建授权,展示简短方案摘要后必须同一轮继续执行,只有互斥需求或非法口令才追问。' +
'流程:load_skill → private_data_execute 建表 → private_data_register_dataset → write_file/edit_file 写 public/*.html(含 /assets/page-data-client.js)→ private_data_bind_workspace_page 发布并写策略。' +
'当前 session 的 private_data_* 已绑定当前用户专属 PostgreSQL schema;所有需保存或再次读取的数据必须走 PG + Page Data API。禁止 localStorage、sessionStorage、IndexedDB、SQLite、静态 JSON/JS 或内存持久化 fallback;禁止自建 Express/独立端口(如 8899)、禁止 HTML 硬编码 127.0.0.1 API、禁止连续空转不调用工具。HTML 视觉规范参照 static-page-publish。完成后只返回 workspaceUrl/MindSpace/<用户ID>/public/...),禁止给用户 /u/用户名/pages/... 链接,并说明后台入口与口令。'
);
case 'service-integration-smoke':
return `请使用 ${skillName ?? 'service-integration-smoke'} 技能:按标准联调流程检查当前服务,覆盖身份、普通聊天、记忆读取,以及我本轮明确要求验证的技能/发布链路,并输出通过项、失败项、待确认项:`;
case 'table-viewer':
return `请使用 ${skillName ?? 'table-viewer'} 技能:请把以下数据整理成可排序、可筛选的交互式表格:`;
case 'product-campaign-page':
return (
`请使用 ${skillName ?? 'product-campaign-page'} 技能:帮我制作一个商品宣传 / 活动页。` +
'请先询问商品链接、页面主题、是否有商品图片或品牌素材;信息足够后,生成包含购买跳转按钮的页面方案。' +
'如果我确认要发布成 H5 页面,请继续使用 static-page-publish 技能生成可访问链接。'
);
case 'summarize':
return '请总结以下内容,提炼核心结论、重点信息和可执行建议(条理清晰、中文输出):';
case 'analyze':
return '请深入分析以下内容,给出结构化解读:背景、关键发现、风险或机会、以及建议下一步:';
case 'generate-page':
return (
`请使用 ${skillName ?? PUBLISH_SKILL_NAME} 技能:在我的专属 MindSpace 发布目录生成 HTML 页面,并给出可公网访问的完整链接。` +
'默认只生成 HTML:必须先 load_skill,再用 write_file/edit_file 写入 public/页面.html;允许图表、动画等动态效果,但脚本必须适合沙箱发布,优先使用平台已支持的本地/可信库,禁止 javascript:、onxxx 内联事件、未知第三方脚本和跨用户私有资源。' +
'完成前必须验证文件已写入、页面安全检查/发布状态可用、链接返回 200 且正文包含关键数据;验证失败时不要宣称“已完成”,应修复后再回复 Markdown 可点击公网链接 `[页面标题](URL)`。' +
'禁止只给本地路径(如 hello/index.html),禁止询问是否还要发布,除非写文件工具实际失败。' +
'只有用户明确要求 Word / docx 下载时,才先 `load_skill` → `docx-generate`,生成 `public/*.docx` 并确认文件已存在,再写 HTML 并用同目录相对路径链接该文档。' +
'只有用户明确要求长图下载时,才先 `load_skill` → `long-image-download`,调用 `generate_long_image` 生成 `public/*.long.png` 并确认文件已存在,再给长图预览链接和 `?download=long-image` 下载链接。不要在没有明确需求时强制生成伴生文件。'
);
default:
return '';
}
}
function buildWebNewsSkillPrompt(skillName) {
return `请使用 ${skillName ?? 'web'} 技能:先搜索今天/最新相关的新闻与热点,优先一手来源和权威媒体,整理 3-5 条最相关结果,按时间或热度排序;然后给出中文摘要、关键信息、事件背景和来源链接。我的问题是:`;
}
export { buildWebNewsSkillPrompt };
export function filterChatSkills(options, ctx) {
return options.filter((skill) => {
if (skill.requiresPublish && !ctx.canPublish) {
const pageDataGranted =
skill.skillName === PAGE_DATA_COLLECT_SKILL_NAME &&
ctx.grantedSkills?.includes(PAGE_DATA_COLLECT_SKILL_NAME);
if (!pageDataGranted) return false;
}
if (skill.requiresSkill && !ctx.grantedSkills?.includes(skill.requiresSkill)) return false;
return true;
});
}
const PROMPT_KEY_BY_SKILL_NAME = new Map(
CHAT_SKILL_DEFINITIONS.filter((item) => item.skillName && item.promptKey).map((item) => [
item.skillName,
item.promptKey,
]),
);
/** Opt-in Skill Router v2. Default off unless TKMIND_SKILL_ROUTER_V2=1 or options.skillRouterV2=true. */
export function isSkillRouterV2Enabled(explicit) {
if (explicit === true) return true;
if (explicit === false) return false;
const raw =
typeof process !== 'undefined' && process?.env ? process.env[SKILL_ROUTER_V2_ENV] : undefined;
return /^(1|true|yes)$/i.test(String(raw ?? ''));
}
/** Build manifest routes from platform catalog entries that declare trigger.keywords. */
export function buildManifestRoutesFromCatalog(catalog = []) {
return catalog
.filter((item) => Array.isArray(item?.manifest?.trigger?.keywords) && item.manifest.trigger.keywords.length > 0)
.map((item) => ({
skillName: item.name,
keywords: item.manifest.trigger.keywords,
priority: Number(item.manifest?.router?.priority ?? 0),
promptKey:
item.manifest?.router?.promptKey ??
PROMPT_KEY_BY_SKILL_NAME.get(item.name) ??
null,
promptVariant: item.manifest?.router?.promptVariant ?? null,
}))
.sort((a, b) => b.priority - a.priority || a.skillName.localeCompare(b.skillName));
}
export function matchManifestSkillRoute(text, routes = [], grantedSkills = []) {
const normalized = String(text ?? '').trim().toLowerCase();
if (!normalized || !Array.isArray(routes) || routes.length === 0) return null;
const granted = new Set(grantedSkills);
for (const route of routes) {
if (!route?.skillName || !granted.has(route.skillName)) continue;
const keywords = Array.isArray(route.keywords) ? route.keywords : [];
if (keywords.some((keyword) => normalized.includes(String(keyword).trim().toLowerCase()))) {
return route;
}
}
return null;
}
function buildManifestSkillPrompt(route) {
if (!route) return '';
if (route.promptVariant === 'web-news') {
return buildWebNewsSkillPrompt(route.skillName);
}
if (route.promptKey) {
return buildChatSkillPrompt(route.promptKey, route.skillName);
}
return `请使用 ${route.skillName} 技能:`;
}
function buildLegacyAutoChatSkillPrefix(trimmed, grantedSkills) {
if (
grantedSkills.includes(PAGE_DATA_COLLECT_SKILL_NAME) &&
isPageDataIntent(trimmed)
) {
return buildChatSkillPrompt('page-data-collect', PAGE_DATA_COLLECT_SKILL_NAME);
}
if (grantedSkills.includes(PUBLISH_SKILL_NAME) && isPageGenerationIntent(trimmed)) {
return buildChatSkillPrompt('generate-page', PUBLISH_SKILL_NAME);
}
if (!grantedSkills.includes('web')) return '';
if (WEB_NEWS_INTENT_PATTERNS.some((pattern) => pattern.test(trimmed))) {
return buildWebNewsSkillPrompt('web');
}
if (!WEB_INTENT_PATTERNS.some((pattern) => pattern.test(trimmed))) return '';
return buildChatSkillPrompt('web', 'web');
}
export function buildAutoChatSkillPrefixOptions(publicConfig) {
if (!publicConfig?.routerV2Enabled || !publicConfig?.manifestRoutingEnabled) {
return {};
}
return {
skillRouterV2: true,
manifestRoutes: publicConfig.manifestRoutes ?? [],
};
}
export function buildAutoChatSkillPrefix(text, grantedSkills = [], options = {}) {
const trimmed = String(text ?? '').trim();
if (!trimmed) return '';
if (
isSkillRouterV2Enabled(options.skillRouterV2) &&
Array.isArray(options.manifestRoutes) &&
options.manifestRoutes.length > 0
) {
const matched = matchManifestSkillRoute(trimmed, options.manifestRoutes, grantedSkills);
if (matched) {
return buildManifestSkillPrompt(matched);
}
}
return buildLegacyAutoChatSkillPrefix(trimmed, grantedSkills);
}
export function stripKnownChatSkillPrompt(text) {
let next = String(text ?? '').trimStart();
const candidates = [];
for (const definition of CHAT_SKILL_DEFINITIONS) {
if (!definition.promptKey) continue;
candidates.push(buildChatSkillPrompt(definition.promptKey, definition.skillName));
}
candidates.push(buildWebNewsSkillPrompt('web'));
candidates.sort((a, b) => b.length - a.length);
for (const prompt of candidates) {
if (prompt && next.startsWith(prompt)) {
next = next.slice(prompt.length);
break;
}
}
return next.trim();
}
export function hasExplicitChatSkillPrompt(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
if (/请使用\s+[\w-]+\s+技能[:]/u.test(normalized)) return true;
return stripKnownChatSkillPrompt(normalized) !== normalized;
}
export function extractSelectedChatSkillName(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return null;
const match = normalized.match(/请使用\s+([\w-]+)\s+技能[:]/u);
if (match?.[1]) return match[1];
for (const definition of CHAT_SKILL_DEFINITIONS) {
if (!definition.promptKey) continue;
const prompt = buildChatSkillPrompt(definition.promptKey, definition.skillName);
if (prompt && normalized.includes(prompt)) {
return definition.skillName ?? definition.id;
}
}
const newsPrompt = buildWebNewsSkillPrompt('web');
if (newsPrompt && normalized.includes(newsPrompt)) return 'web';
return null;
}