diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs index 002cf92..feeb875 100644 --- a/admin-bootstrap.mjs +++ b/admin-bootstrap.mjs @@ -18,6 +18,7 @@ import { createUserAuth } from './user-auth.mjs'; import { createLlmProviderService } from './llm-providers.mjs'; import { createAssetGatewayConfigService } from './asset-gateway.mjs'; import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs'; +import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-config.mjs'; import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs'; import { createPlazaPostService, formatPostRow } from './plaza-posts.mjs'; import { createPlazaInteractionService } from './plaza-interactions.mjs'; @@ -100,6 +101,7 @@ export async function createAdminServices(env = {}) { const llmProviderService = createLlmProviderService(pool, { apiTarget, apiSecret }); const assetGatewayConfigService = createAssetGatewayConfigService(pool, { llmProviderService }); const memoryV2ConfigService = createMemoryV2AdminConfigService(pool); + const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, { h5Root }); const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool); const adminSystemTestService = createAdminSystemTestService({ pool, @@ -138,6 +140,7 @@ export async function createAdminServices(env = {}) { llmProviderService, assetGatewayConfigService, memoryV2ConfigService, + skillRuntimeConfigService, wechatScheduleLlmConfigService, adminSystemTestService, plazaPosts, diff --git a/admin-routes.mjs b/admin-routes.mjs index ae245a8..853ae64 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -31,6 +31,7 @@ function plazaRouteError(res, req, error) { * @param {object} deps.userAuth * @param {object|null} deps.llmProviderService * @param {object|null} deps.memoryV2ConfigService + * @param {object|null} deps.skillRuntimeConfigService * @param {object|null} deps.adminSystemTestService * @param {object|null} deps.plazaPosts * @param {object|null} deps.plazaOps @@ -44,6 +45,7 @@ export function createAdminApi({ llmProviderService, assetGatewayConfigService, memoryV2ConfigService, + skillRuntimeConfigService, adminSystemTestService, plazaPosts, plazaOps, @@ -148,6 +150,40 @@ export function createAdminApi({ return res.json(result); }); + adminApi.get('/skill-runtime/config', requireAdmin, async (_req, res) => { + if (!skillRuntimeConfigService?.getAdminConfig) { + return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' }); + } + return res.json(await skillRuntimeConfigService.getAdminConfig()); + }); + + const updateSkillRuntimeConfig = async (req, res) => { + if (!skillRuntimeConfigService?.updateAdminConfig) { + return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' }); + } + const result = await skillRuntimeConfigService.updateAdminConfig(req.body?.config ?? req.body ?? {}, { + updatedBy: req.currentUser.id, + }); + return res.json(result); + }; + + adminApi.put('/skill-runtime/config', requireAdmin, updateSkillRuntimeConfig); + adminApi.patch('/skill-runtime/config', requireAdmin, updateSkillRuntimeConfig); + + adminApi.get('/skill-runtime/catalog', requireAdmin, async (_req, res) => { + if (!skillRuntimeConfigService?.listCatalogSummary) { + return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' }); + } + return res.json({ catalog: await skillRuntimeConfigService.listCatalogSummary() }); + }); + + adminApi.get('/skill-runtime/runtime', requireAdmin, async (_req, res) => { + if (!skillRuntimeConfigService?.getPublicRuntimeConfig) { + return res.status(503).json({ message: 'Skill Runtime 配置服务未启用' }); + } + return res.json(await skillRuntimeConfigService.getPublicRuntimeConfig()); + }); + adminApi.post('/system-tests/skill-validation', requireAdmin, async (req, res) => { if (!adminSystemTestService?.runSkillValidation) { return res.status(503).json({ message: '系统测试服务未启用' }); diff --git a/chat-skills.mjs b/chat-skills.mjs index 1538392..7d49846 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -1,6 +1,7 @@ // 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, @@ -37,6 +38,9 @@ const PAGE_DATA_INTENT_PATTERNS = [ /(?:密码|口令).{0,12}(?:查看|后台|管理|进入)/u, /(?:sqlite|数据库|page[\s-]?data)/i, /(?:每个用户|各用户).{0,12}(?:提交|记录)/u, + /(?:记账|账本|收支|流水|日记|习惯打卡|每日打卡).{0,40}(?:页面|记录|管理|汇总|统计)/u, + /(?:管理页面|管理页).{0,30}(?:查看|记录|汇总|统计|明细|删除|修改)/u, + /(?:页面|网页|H5|h5).{0,80}(?:每天|每日|新增|添加|填写|记录).{0,80}(?:所有记录|历史记录|管理|汇总|统计)/u, ]; export function isPageDataIntent(text) { @@ -155,7 +159,7 @@ export function buildChatSkillPrompt(promptKey, skillName) { case 'page-data-collect': return ( `请使用 ${skillName ?? PAGE_DATA_COLLECT_SKILL_NAME} 技能:在 MindSpace 页面中实现可提交、可持久化的数据收集(问卷/报名/台账等),必须使用 Page Data API。` + - '先匹配技能内能力分支(默认 A:匿名前台 public insert + 独立后台 password read,口令默认 88888888);展示方案摘要确认后再开工。' + + '先匹配技能内能力分支(默认 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 发布并写策略。' + '禁止 localStorage / 浏览器本地存储 fallback;禁止自建 Express/独立端口(如 8899)、禁止 HTML 硬编码 127.0.0.1 API、禁止连续空转不调用工具。HTML 视觉规范参照 static-page-publish。完成后只返回 workspaceUrl(/MindSpace/<用户ID>/public/...),禁止给用户 /u/用户名/pages/... 链接,并说明后台入口与口令。' ); @@ -206,9 +210,65 @@ export function filterChatSkills(options, ctx) { }); } -export function buildAutoChatSkillPrefix(text, grantedSkills = []) { - const trimmed = String(text ?? '').trim(); - if (!trimmed) return ''; +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) @@ -226,6 +286,34 @@ export function buildAutoChatSkillPrefix(text, grantedSkills = []) { 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 = []; diff --git a/skill-runtime-admin-config.mjs b/skill-runtime-admin-config.mjs new file mode 100644 index 0000000..6ee4ff5 --- /dev/null +++ b/skill-runtime-admin-config.mjs @@ -0,0 +1,181 @@ +import { SKILL_ROUTER_V2_ENV } from './chat-skills.mjs'; +import { + buildPublicSkillRuntimeConfig, + buildSkillRuntimeCatalogSummary, +} from './skill-runtime-policy.mjs'; + +const CONFIG_TABLE = 'h5_skill_runtime_config'; +const CONFIG_SCOPE = 'global'; + +function defaultConfigShape() { + return { + router: { + v2Enabled: false, + manifestRoutingEnabled: false, + }, + }; +} + +function normalizeBoolean(value, fallback = false) { + if (value == null || value === '') return fallback; + if (typeof value === 'boolean') return value; + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return fallback; +} + +function cloneConfig(config = null) { + return structuredClone?.(config ?? defaultConfigShape()) + ?? JSON.parse(JSON.stringify(config ?? defaultConfigShape())); +} + +function parseJsonLike(value, fallback) { + if (value == null || value === '') return fallback; + if (typeof value === 'string') { + try { + return JSON.parse(value); + } catch { + return fallback; + } + } + if (typeof value === 'object') return value; + return fallback; +} + +function envRouterEnabled(env = process.env) { + return /^(1|true|yes)$/i.test(String(env?.[SKILL_ROUTER_V2_ENV] ?? '')); +} + +function applyEnv(config, env = process.env) { + const next = cloneConfig(config); + if (next.router.v2Enabled === false && envRouterEnabled(env)) { + next.router.v2Enabled = true; + } + if (next.router.manifestRoutingEnabled === false && next.router.v2Enabled) { + next.router.manifestRoutingEnabled = envRouterEnabled(env); + } + return next; +} + +function mergePatch(currentConfig, patch = {}) { + const next = cloneConfig(currentConfig); + if (patch?.router && 'v2Enabled' in patch.router) { + next.router.v2Enabled = normalizeBoolean(patch.router.v2Enabled, false); + } + if (patch?.router && 'manifestRoutingEnabled' in patch.router) { + next.router.manifestRoutingEnabled = normalizeBoolean(patch.router.manifestRoutingEnabled, false); + } + if (!next.router.v2Enabled) { + next.router.manifestRoutingEnabled = false; + } + return next; +} + +async function ensureConfigTable(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} ( + config_scope VARCHAR(32) PRIMARY KEY, + config_json JSON NOT NULL, + updated_by CHAR(36) NULL, + updated_at BIGINT NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); +} + +async function loadStoredState(pool) { + await ensureConfigTable(pool); + const [rows] = await pool.query( + `SELECT config_json, updated_by, updated_at + FROM ${CONFIG_TABLE} + WHERE config_scope = ? + LIMIT 1`, + [CONFIG_SCOPE], + ); + const row = rows[0]; + if (!row) return null; + return { + config: { ...defaultConfigShape(), ...parseJsonLike(row.config_json, {}) }, + updatedAt: Number(row.updated_at ?? 0) || null, + updatedBy: row.updated_by ?? null, + }; +} + +export function createSkillRuntimeAdminConfigService(pool, { env = process.env, h5Root = process.cwd() } = {}) { + async function loadEffectiveConfig() { + const stored = await loadStoredState(pool); + if (!stored) { + const config = applyEnv(defaultConfigShape(), env); + return { + config, + updatedAt: null, + updatedBy: null, + source: envRouterEnabled(env) ? 'env' : 'default', + }; + } + return { + config: cloneConfig(stored.config), + updatedAt: stored.updatedAt, + updatedBy: stored.updatedBy, + source: 'admin-db', + }; + } + + return { + async getAdminConfig() { + const state = await loadEffectiveConfig(); + return { + config: state.config, + updatedAt: state.updatedAt, + updatedBy: state.updatedBy, + source: state.source, + }; + }, + + async updateAdminConfig(patch = {}, { updatedBy = null } = {}) { + const stored = await loadStoredState(pool); + const base = stored?.config ?? defaultConfigShape(); + const nextConfig = mergePatch(base, patch.config ?? patch); + await ensureConfigTable(pool); + const now = Date.now(); + await pool.query( + `INSERT INTO ${CONFIG_TABLE} + (config_scope, config_json, updated_by, updated_at) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + config_json = VALUES(config_json), + updated_by = VALUES(updated_by), + updated_at = VALUES(updated_at)`, + [CONFIG_SCOPE, JSON.stringify(nextConfig), updatedBy, now], + ); + return this.getAdminConfig(); + }, + + async getRuntimeState() { + const state = await loadEffectiveConfig(); + return { + source: state.source, + updatedAt: state.updatedAt, + updatedBy: state.updatedBy, + config: state.config, + }; + }, + + async getPublicRuntimeConfig() { + const state = await loadEffectiveConfig(); + return buildPublicSkillRuntimeConfig(state.config, h5Root); + }, + + listCatalogSummary() { + return buildSkillRuntimeCatalogSummary(h5Root); + }, + }; +} + +export const skillRuntimeAdminConfigInternals = { + CONFIG_SCOPE, + CONFIG_TABLE, + defaultConfigShape, + applyEnv, + mergePatch, +}; diff --git a/skill-runtime-admin-config.test.mjs b/skill-runtime-admin-config.test.mjs new file mode 100644 index 0000000..9f4799b --- /dev/null +++ b/skill-runtime-admin-config.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createSkillRuntimeAdminConfigService, + skillRuntimeAdminConfigInternals, +} from './skill-runtime-admin-config.mjs'; + +function createPool(seedRow = null) { + const state = { row: seedRow }; + return { + state, + async query(sql, params) { + if (sql.includes('CREATE TABLE')) return [[], []]; + if (sql.includes('SELECT config_json')) { + return [state.row ? [state.row] : [], []]; + } + if (sql.includes('INSERT INTO h5_skill_runtime_config')) { + state.row = { + config_json: params[1], + updated_by: params[2], + updated_at: params[3], + }; + return [[], []]; + } + throw new Error(`Unexpected query: ${sql}`); + }, + }; +} + +test('skill runtime admin config defaults to disabled router', async () => { + const service = createSkillRuntimeAdminConfigService(createPool(), { env: {} }); + const result = await service.getAdminConfig(); + assert.equal(result.config.router.v2Enabled, false); + assert.equal(result.config.router.manifestRoutingEnabled, false); +}); + +test('skill runtime admin config falls back to env when db row is absent', async () => { + const service = createSkillRuntimeAdminConfigService(createPool(), { + env: { TKMIND_SKILL_ROUTER_V2: '1' }, + }); + const result = await service.getAdminConfig(); + assert.equal(result.config.router.v2Enabled, true); + assert.equal(result.config.router.manifestRoutingEnabled, true); + assert.equal(result.source, 'env'); +}); + +test('skill runtime admin config persists router toggles', async () => { + const pool = createPool(); + const service = createSkillRuntimeAdminConfigService(pool, { env: {} }); + const updated = await service.updateAdminConfig( + { router: { v2Enabled: true, manifestRoutingEnabled: true } }, + { updatedBy: 'admin-1' }, + ); + assert.equal(updated.config.router.v2Enabled, true); + assert.equal(updated.config.router.manifestRoutingEnabled, true); + const runtime = await service.getPublicRuntimeConfig(); + assert.equal(runtime.routerV2Enabled, true); + assert.equal(runtime.manifestRoutingEnabled, true); + assert.ok(Array.isArray(runtime.manifestRoutes)); +}); + +test('skill runtime admin config keeps db authoritative over env', async () => { + const pool = createPool({ + config_json: JSON.stringify(skillRuntimeAdminConfigInternals.defaultConfigShape()), + updated_by: 'admin-1', + updated_at: 123, + }); + const service = createSkillRuntimeAdminConfigService(pool, { + env: { TKMIND_SKILL_ROUTER_V2: '1' }, + }); + const result = await service.getAdminConfig(); + assert.equal(result.config.router.v2Enabled, false); + assert.equal(result.source, 'admin-db'); +}); diff --git a/skill-runtime-policy.mjs b/skill-runtime-policy.mjs new file mode 100644 index 0000000..84bec4e --- /dev/null +++ b/skill-runtime-policy.mjs @@ -0,0 +1,30 @@ +import { buildManifestRoutesFromCatalog } from './chat-skills.mjs'; +import { listPlatformSkillCatalog } from './skills-registry.mjs'; + +export function buildPublicSkillRuntimeConfig(config, h5Root = process.cwd()) { + const routerV2Enabled = Boolean(config?.router?.v2Enabled); + const manifestRoutingEnabled = routerV2Enabled && Boolean(config?.router?.manifestRoutingEnabled); + const catalog = listPlatformSkillCatalog(h5Root); + const manifestRoutes = manifestRoutingEnabled ? buildManifestRoutesFromCatalog(catalog) : []; + return { + routerV2Enabled, + manifestRoutingEnabled, + manifestRoutes, + manifestSkillCount: catalog.filter((item) => item.manifest?.trigger?.keywords?.length).length, + }; +} + +export function buildSkillRuntimeCatalogSummary(h5Root = process.cwd()) { + const catalog = listPlatformSkillCatalog(h5Root); + return catalog.map((item) => ({ + name: item.name, + dirName: item.dirName, + description: item.description, + version: item.version ?? null, + executors: item.executors ?? ['goose'], + hasManifest: Boolean(item.manifest), + triggerKeywords: item.manifest?.trigger?.keywords ?? [], + routerPromptKey: item.manifest?.router?.promptKey ?? null, + routerPriority: item.manifest?.router?.priority ?? 0, + })); +} diff --git a/skill-runtime-policy.test.mjs b/skill-runtime-policy.test.mjs new file mode 100644 index 0000000..405febc --- /dev/null +++ b/skill-runtime-policy.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { + buildPublicSkillRuntimeConfig, + buildSkillRuntimeCatalogSummary, +} from './skill-runtime-policy.mjs'; +import { buildAutoChatSkillPrefixOptions } from './chat-skills.mjs'; + +const h5Root = path.dirname(fileURLToPath(import.meta.url)); + +test('buildPublicSkillRuntimeConfig omits manifest routes when disabled', () => { + const config = buildPublicSkillRuntimeConfig( + { router: { v2Enabled: false, manifestRoutingEnabled: false } }, + h5Root, + ); + assert.equal(config.routerV2Enabled, false); + assert.equal(config.manifestRoutingEnabled, false); + assert.deepEqual(config.manifestRoutes, []); +}); + +test('buildSkillRuntimeCatalogSummary includes product-campaign-page manifest', () => { + const catalog = buildSkillRuntimeCatalogSummary(h5Root); + const product = catalog.find((item) => item.name === 'product-campaign-page'); + assert.ok(product); + assert.equal(product.hasManifest, true); + assert.ok(product.triggerKeywords.includes('带货')); +}); + +test('buildAutoChatSkillPrefixOptions returns empty object when router is disabled', () => { + assert.deepEqual( + buildAutoChatSkillPrefixOptions({ + routerV2Enabled: false, + manifestRoutingEnabled: false, + manifestRoutes: [{ skillName: 'web', keywords: ['新闻'], priority: 1, promptKey: 'web', promptVariant: null }], + }), + {}, + ); +}); diff --git a/skills/product-campaign-page/skill.yaml b/skills/product-campaign-page/skill.yaml new file mode 100644 index 0000000..622c4c5 --- /dev/null +++ b/skills/product-campaign-page/skill.yaml @@ -0,0 +1,14 @@ +name: product-campaign-page +version: 1.0.0 +description: 商品宣传 / 活动页 +trigger: + keywords: + - 带货 + - 商品页 + - 种草 + - 电商页 +router: + promptKey: product-campaign-page + priority: 20 +executors: + - goose