diff --git a/.env.example b/.env.example index 7ce631e..5295b38 100644 --- a/.env.example +++ b/.env.example @@ -200,7 +200,8 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173 # 新增 goosed 调用须走 tkmind-proxy;MindSpace 例外见 goosed-proxy-boundary.mjs allowlist # H5 public HTML 假交付 guard(docs/pending-fixes/h5-public-html-fake-delivery-guard-20260706.md) -# 默认 0:仅 warn;设 1 时 Finish 后检测缺失 public/*.html / assets/* 并自动 session repair(每 session 1 次) +# 生产默认 0:仅 warn;设 1 时 Finish 后检测缺失 public/*.html / assets/* / 页面模板漂移并自动 session repair(每 session 1 次) +# pnpm dev(local / split-service profile)未显式配置时默认 1;可在 .env 设 MEMIND_H5_HTML_FINISH_GUARD=0 关闭 # MEMIND_H5_HTML_FINISH_GUARD=0 # H5 run SSE replay(Patch 4b,仅本地/测试分支;默认关,未合并 main / 未上 103) @@ -403,6 +404,18 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind # MINDSPACE_AGENT_JOBS_ENABLED=true # MINDSPACE_INTERNAL_AGENT_SECRET=local-dev-secret # MINDSPACE_FREE_PUBLIC_PAGE_LIMIT=10 +# MINDSPACE_SEO_GEO_ENABLED=false +# MINDSPACE_SEO_ENABLED=false +# MINDSPACE_SEO_CANONICAL=true +# MINDSPACE_SEO_SITEMAP=false +# MINDSPACE_SEO_ROBOTS_TXT=false +# MINDSPACE_SEO_BAIDU_PUSH=false +# MINDSPACE_GEO_ENABLED=false +# MINDSPACE_GEO_JSONLD=false +# MINDSPACE_GEO_LLMSTXT=false +# MINDSPACE_BAIDU_PUSH_TOKEN= +# MINDSPACE_BAIDU_SITE=m.tkmind.cn +# PLAZA_BAIDU_PUSH_TOKEN= # image_make 独立生图服务。只有 URL、Token 与 memind_adm 资产能力开关同时配置后才会调用。 # IMAGE_MAKE_BASE_URL=http://127.0.0.1:8080 # IMAGE_MAKE_TOKEN=dev-token diff --git a/AGENTS.md b/AGENTS.md index a455ed9..ab737b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,7 @@ bash scripts/check-release-ready.sh | Page Data 数据集注册、绑定与交付验收 | [docs/regression-guards/page-data-delivery-contract.md](docs/regression-guards/page-data-delivery-contract.md) | | H5 SSE 断线续播、Portal/Goose 游标映射与 Finish 终态恢复 | [docs/regression-guards/h5-session-stream-replay.md](docs/regression-guards/h5-session-stream-replay.md) | | Memory V2 候选表初始化与生命周期灰度作用域 | [docs/regression-guards/memory-v2-candidate-and-lifecycle.md](docs/regression-guards/memory-v2-candidate-and-lifecycle.md) | +| MindSpace SEO/GEO 收录策略与私有页 noindex | [docs/regression-guards/mindspace-seo-geo.md](docs/regression-guards/mindspace-seo-geo.md) | 索引:[docs/regression-guards/README.md](docs/regression-guards/README.md) @@ -61,6 +62,8 @@ npm run verify:mindspace-publish-guards npm run verify:mindspace-publish-guards:full npm run verify:mindspace-page-sync-guards npm run verify:h5-session-patches +npm run verify:seo-geo +npm run verify:seo-discovery ``` 发版脚本(`scripts/release-portal-runtime-prod.sh`)当前在不跳过测试时会执行相关 verify;生产 `103` 发布禁止使用 `--skip-tests`,并且仍须通过完整生产发布守门员。 @@ -75,6 +78,7 @@ npm run verify:h5-session-patches - `mindspace-pages.mjs` - storage 缺失时 HTML 页回退读 workspace `relative_path` - `mindspace-page-sync-service.mjs` + `server.mjs` - remote 模式也必须 sync public HTML - `session-stream.mjs` + `session-stream-store.mjs` + `tkmind-proxy.mjs` - Portal replay ID 不得直接作为 Goose `Last-Event-ID` +- `mindspace-index-policy.mjs` + `mindspace-seo-geo-delivery.mjs` - 私有页必须 noindex;仅 confirmed public 可收录 代码内搜索 `REGRESSION GUARD` 可定位所有内联说明。 diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs index 7e519d8..8b9ccb0 100644 --- a/admin-bootstrap.mjs +++ b/admin-bootstrap.mjs @@ -17,6 +17,10 @@ import { createSubscriptionService, ensurePlanCatalogSchema, } from './billing-subscription.mjs'; +import { + createPageTemplateCatalogService, + ensureTemplateCatalogSchema, +} from './page-template-catalog.mjs'; import { createDbPool, ensureAssetGatewaySchema, isDatabaseConfigured } from './db.mjs'; import { createUserAuth } from './user-auth.mjs'; import { createLlmProviderService } from './llm-providers.mjs'; @@ -74,6 +78,7 @@ export async function createAdminServices(env = {}) { // optional control-plane tables here instead of requiring the public boot path. await ensureAssetGatewaySchema(pool); await ensurePlanCatalogSchema(pool); + await ensureTemplateCatalogSchema(pool); // --- plaza graph (review queue, reports, featured, analytics, creators) --- const plazaRedis = createNoopPlazaRedis(); @@ -112,6 +117,11 @@ export async function createAdminServices(env = {}) { defaultSignupBalanceCents, subscriptionService, }); + const templateCatalogService = createPageTemplateCatalogService(pool, { + userAuth, + h5Root, + }); + await templateCatalogService.ensureReady(); if (env.ensureAdminUser !== false) { await userAuth.ensureAdminUser(); } @@ -195,5 +205,6 @@ export async function createAdminServices(env = {}) { plazaOps, wechatAdmin, subscriptionService, + templateCatalogService, }; } diff --git a/admin-routes.mjs b/admin-routes.mjs index 8250c16..1d46534 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -67,6 +67,7 @@ export function createAdminApi({ wechatAdmin, wechatIntentRouterConfigService, subscriptionService, + templateCatalogService, }) { function requireAdmin(req, res, next) { if (!req.currentUser || req.currentUser.role !== 'admin') { @@ -780,6 +781,31 @@ export function createAdminApi({ res.json(result); }); + adminApi.get('/template-catalog', requireAdmin, async (_req, res) => { + if (!templateCatalogService?.adminListCatalog) { + return res.status(503).json({ message: '模板商城未启用' }); + } + res.json({ items: await templateCatalogService.adminListCatalog() }); + }); + + adminApi.put('/template-catalog/:skillName', requireAdmin, async (req, res) => { + if (!templateCatalogService?.adminUpsertCatalog) { + return res.status(503).json({ message: '模板商城未启用' }); + } + const result = await templateCatalogService.adminUpsertCatalog(req.params.skillName, req.body ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.post('/users/:userId/template-grants/:skillName', requireAdmin, async (req, res) => { + if (!templateCatalogService?.adminGrantTemplate) { + return res.status(503).json({ message: '模板商城未启用' }); + } + const result = await templateCatalogService.adminGrantTemplate(req.params.userId, req.params.skillName); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + adminApi.get('/ledger', requireAdmin, async (req, res) => { const userId = typeof req.query.userId === 'string' ? req.query.userId : null; const { page, pageSize } = req.query; diff --git a/admin-server.mjs b/admin-server.mjs index 713119f..784639b 100644 --- a/admin-server.mjs +++ b/admin-server.mjs @@ -93,6 +93,7 @@ const CONSOLES = { wechatAdmin: services.wechatAdmin, wechatIntentRouterConfigService: services.wechatIntentRouterConfigService, subscriptionService: services.subscriptionService, + templateCatalogService: services.templateCatalogService, }), }, ops: { diff --git a/agent-run-routes.mjs b/agent-run-routes.mjs index 486d0ca..1e6ab29 100644 --- a/agent-run-routes.mjs +++ b/agent-run-routes.mjs @@ -8,6 +8,10 @@ import { extractAiderDevelopmentTask, isPageDataIntent, } from './chat-skills.mjs'; +import { + buildPageTemplateMandatoryInstruction, + isPageTemplateSkill, +} from './page-template-enforcement.mjs'; import { createSessionAccess } from './session-broker.mjs'; import { extractRunFromStreamEvent, @@ -100,6 +104,44 @@ function rewriteAiderPageDataInstruction(userMessage, taskText) { return { ...userMessage, content }; } +export function enforcePageTemplateRuntime(userMessage, { h5Root = process.cwd() } = {}) { + const skillName = selectedChatSkill(userMessage); + if (!isPageTemplateSkill(skillName)) { + return { userMessage, pageTemplateSkill: null }; + } + const message = userMessage && typeof userMessage === 'object' && !Array.isArray(userMessage) + ? { ...userMessage } + : { value: userMessage }; + const metadata = message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata) + ? { ...message.metadata } + : {}; + const runMetadata = metadata.memindRun && typeof metadata.memindRun === 'object' && !Array.isArray(metadata.memindRun) + ? { ...metadata.memindRun } + : {}; + metadata.memindRun = { + ...runMetadata, + selectedChatSkill: skillName, + pageTemplateSkill: skillName, + pageTemplateStrict: true, + }; + const mandatory = buildPageTemplateMandatoryInstruction(skillName, h5Root); + const content = Array.isArray(message.content) + ? message.content.map((item) => { + if (item?.type !== 'text') return item; + const text = String(item.text ?? '').trim(); + if (!text || text.startsWith('【强制页面模板:')) return item; + return { + ...item, + text: `${mandatory}\n\n${text}`, + }; + }) + : message.content; + return { + userMessage: { ...message, metadata, content }, + pageTemplateSkill: skillName, + }; +} + export function enforceSelectedSkillRuntime(userMessage, { rawToolMode = 'chat', taskType = null, @@ -145,6 +187,7 @@ export function createPostAgentRunsHandler({ codeRunPolicyService = null, goalRunService = null, chatIntentRouter = null, + templateCatalogService = null, env = process.env, codeRunsEnabled = envFlag(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED), codeRunUserIds = parseUserIdSet(process.env.MEMIND_AGENT_CODE_RUNS_USER_IDS), @@ -196,6 +239,13 @@ export function createPostAgentRunsHandler({ response.status(400).json({ message: '缺少 user_message' }); return; } + const pageTemplateRuntime = enforcePageTemplateRuntime(userMessage, { h5Root: process.cwd() }); + userMessage = pageTemplateRuntime.userMessage; + if (pageTemplateRuntime.pageTemplateSkill && templateCatalogService?.recordTemplateUsage) { + void templateCatalogService + .recordTemplateUsage(request.currentUser.id, pageTemplateRuntime.pageTemplateSkill) + .catch(() => {}); + } const selectedSkillRuntime = enforceSelectedSkillRuntime(userMessage, { rawToolMode, taskType, diff --git a/billing-recharge.mjs b/billing-recharge.mjs index ad38956..7167864 100644 --- a/billing-recharge.mjs +++ b/billing-recharge.mjs @@ -50,10 +50,15 @@ function mapOrderRow(row) { createdAt: Number(row.created_at), codeUrl: row.code_url ?? null, h5Url: row.h5_url ?? null, + orderPurpose: row.order_purpose ?? 'recharge', + orderPurposeRef: row.order_purpose_ref ?? null, }; } -export function createRechargeService(pool, { userAuth, wechatPay, config = loadRechargeConfig() } = {}) { +export function createRechargeService( + pool, + { userAuth, wechatPay, templateCatalogService = null, config = loadRechargeConfig() } = {}, +) { const expireStaleOrders = async (userId) => { const now = Date.now(); await pool.query( @@ -275,6 +280,37 @@ export function createRechargeService(pool, { userAuth, wechatPay, config = load return { ok: false, message: '订单状态不可支付' }; } + if (locked.orderPurpose === 'template_purchase') { + if (!templateCatalogService?.fulfillWechatPurchaseLocked) { + await conn.rollback(); + return { ok: false, message: '模板购买服务未启用' }; + } + const templateResult = await templateCatalogService.fulfillWechatPurchaseLocked({ + conn, + order: locked, + transaction, + now, + }); + if (!templateResult.ok) { + await conn.rollback(); + return templateResult; + } + await conn.query( + `UPDATE h5_payment_orders + SET status = 'paid', provider_txn = ?, paid_at = ?, updated_at = ? + WHERE id = ?`, + [transaction.transaction_id ?? transaction.out_trade_no ?? null, now, now, locked.id], + ); + await conn.commit(); + return { + ok: true, + user: await userAuth.getUserById(locked.userId), + balanceCents: templateResult.balanceCents, + grantedSkills: templateResult.grantedSkills, + skillName: templateResult.skillName, + }; + } + const recharge = await userAuth.recharge(locked.userId, locked.amountCents, null, '用户自助充值', { paymentOrderId: locked.id, conn, diff --git a/billing-recharge.test.mjs b/billing-recharge.test.mjs index fc80fdd..f571f40 100644 --- a/billing-recharge.test.mjs +++ b/billing-recharge.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { buildInsufficientBalancePayload, + createRechargeService, isAllowedRechargeAmount, loadRechargeConfig, } from './billing-recharge.mjs'; @@ -32,3 +33,83 @@ test('buildInsufficientBalancePayload includes recharge hints', () => { assert.equal(payload.balanceCents, 0); assert.deepEqual(payload.suggestedTiers, [500, 1000]); }); + +test('fulfillOrder routes template_purchase to template catalog service', async () => { + const orderRow = { + id: 'order-tpl-1', + user_id: 'user-1', + amount_cents: 990, + channel: 'wechat', + status: 'pending', + pay_mode: 'native', + out_trade_no: 'TPTEST001', + provider_txn: null, + paid_at: null, + expire_at: Date.now() + 60_000, + created_at: Date.now(), + code_url: null, + h5_url: null, + order_purpose: 'template_purchase', + order_purpose_ref: 'page-template-travel', + }; + const pool = { + async getConnection() { + const conn = { + async beginTransaction() {}, + async commit() {}, + async rollback() {}, + async release() {}, + async query(sql, params = []) { + const text = String(sql); + if (text.includes('FROM h5_payment_orders WHERE id = ? FOR UPDATE')) { + return [[orderRow], []]; + } + if (text.includes('UPDATE h5_payment_orders') && text.includes("status = 'paid'")) { + orderRow.status = 'paid'; + return [{ affectedRows: 1 }, []]; + } + throw new Error(`Unhandled SQL: ${text.slice(0, 100)}`); + }, + }; + return conn; + }, + }; + let templateFulfillArgs = null; + const templateCatalogService = { + async fulfillWechatPurchaseLocked(args) { + templateFulfillArgs = args; + return { + ok: true, + skillName: 'page-template-travel', + grantedSkills: ['page-template-travel'], + balanceCents: 1200, + }; + }, + }; + const userAuth = { + async getUserById(userId) { + return { id: userId, balance_cents: 1200 }; + }, + async recharge() { + throw new Error('recharge should not run for template_purchase'); + }, + }; + const service = createRechargeService(pool, { userAuth, templateCatalogService }); + const result = await service.fulfillOrder( + { + id: orderRow.id, + userId: orderRow.user_id, + amountCents: orderRow.amount_cents, + status: 'pending', + orderPurpose: 'template_purchase', + orderPurposeRef: 'page-template-travel', + }, + { trade_state: 'SUCCESS', amount: { total: 990 }, transaction_id: 'wx-txn-1' }, + ); + assert.equal(result.ok, true); + assert.equal(result.skillName, 'page-template-travel'); + assert.deepEqual(result.grantedSkills, ['page-template-travel']); + assert.equal(result.balanceCents, 1200); + assert.ok(templateFulfillArgs); + assert.equal(orderRow.status, 'paid'); +}); diff --git a/chat-skills.mjs b/chat-skills.mjs index e9885b6..a3da0d3 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -1,12 +1,31 @@ // Keep browser-safe: do not import user-publish.mjs (uses node:fs/path/url). import { shouldUseScheduledTaskAutomation } from './scheduled-task-intent.mjs'; +const PAGE_TEMPLATE_PROMPT_SUFFIX = + '【硬性要求】必须先 load_skill 并 read_file 官方 templates/*.html,仅替换 {{PLACEHOLDER}};' + + '禁止修改 + + +
+
+
{{HERO_EYEBROW}}
+

{{HERO_HEADLINE}}

+

{{HERO_SUBTITLE}}

+
+
+
{{MAIN_CONTENT_HTML}}
+ ${FOOTER} + +`, + + 'page-template-campaign/templates/campaign-cta.html': ` + + + + + {{PAGE_TITLE}} + + + + + +
+
+
{{PRODUCT_NAME}}
+

{{HERO_HEADLINE}}

+

{{HERO_SUBTITLE}}

+ {{CTA_LABEL}} +
+
{{PRODUCT_NAME}}
+
+
{{SELLING_POINTS_HTML}}
+
{{OFFER_HTML}}
+
{{CTA_LABEL}}
+ ${FOOTER} + +`, + + 'page-template-survey/templates/survey-basic.html': ` + + + + + {{PAGE_TITLE}} + + + + + +
+
+

{{SURVEY_HEADLINE}}

+
{{SURVEY_INTRO_HTML}}
+
+ {{FORM_FIELDS_HTML}} + +
+
+
+
+ ${FOOTER} + + + +`, + + 'page-template-restaurant/templates/restaurant-menu.html': ` + + + + + {{PAGE_TITLE}} + + + + + +
+
{{VENUE_TAG}}
+

{{VENUE_NAME}}

+

{{VENUE_SUBTITLE}}

+
+
{{MENU_SECTIONS_HTML}}
+ ${FOOTER} + +`, + + 'page-template-portfolio/templates/portfolio-grid.html': ` + + + + + {{PAGE_TITLE}} + + + + + +
+

{{CREATOR_NAME}}

+

{{CREATOR_TAGLINE}}

+
+
{{PROJECT_CARDS_HTML}}
+ ${FOOTER} + +`, +}; + +for (const [rel, html] of Object.entries(upgraded)) { + const [skill, ...rest] = rel.split('/'); + writeSkillFile(skill, rest.join('/'), html); +} + +console.log(`Upgraded ${Object.keys(upgraded).length} templates (batch 1/3)`); + +const upgraded2 = { + 'page-template-event/templates/event-invite.html': ` + + + + + {{PAGE_TITLE}} + + + + + +
+

{{EVENT_DATE}} · {{EVENT_CITY}}

+

{{EVENT_TITLE}}

+

{{EVENT_INTRO}}

+
{{AGENDA_HTML}}
+
+ ${FOOTER} + +`, + + 'page-template-course/templates/course-landing.html': ` + + + + + {{PAGE_TITLE}} + + + + + +

{{COURSE_TITLE}}

{{COURSE_SUBTITLE}}

{{CTA_LABEL}}
+
{{SYLLABUS_HTML}}
+ ${FOOTER} + +`, + + 'page-template-realestate/templates/realestate-listing.html': ` + + + + + {{PAGE_TITLE}} + + + + + +
+
+

{{LISTING_TITLE}}

+

{{LISTING_LOCATION}}

+ {{SPECS_TABLE_HTML}}
+
{{HIGHLIGHTS_HTML}}
+
+ ${FOOTER} + +`, + + 'page-template-blog/templates/blog-article.html': ` + + + + + {{PAGE_TITLE}} + + + + + +
+

{{ARTICLE_DATE}} · {{ARTICLE_AUTHOR}}

+

{{ARTICLE_TITLE}}

+
{{ARTICLE_BODY_HTML}}
+
+ ${FOOTER} + +`, + + 'page-template-resume/templates/resume-profile.html': ` + + + + + {{PAGE_TITLE}} + + + + + +
+
+

{{FULL_NAME}}

+

{{JOB_TITLE}}

+

{{SUMMARY}}

+
+
{{EXPERIENCE_HTML}}
+
{{SKILLS_HTML}}
+
+ ${FOOTER} + +`, + + 'page-template-wedding/templates/wedding-invite.html': ` + + + + + {{PAGE_TITLE}} + + + + + +
+

诚挚邀请

+

{{COUPLE_NAMES}}

+

{{WEDDING_DATE}}

+

{{WEDDING_VENUE}}

+
{{DETAILS_HTML}}
+
+ ${FOOTER} + +`, + + 'page-template-fitness/templates/fitness-club.html': ` + + + + + {{PAGE_TITLE}} + + + + + +

{{CLUB_NAME}}

{{CLUB_SLOGAN}}

+
{{SCHEDULE_HTML}}
+ ${FOOTER} + +`, + + 'page-template-product-grid/templates/product-grid.html': ` + + + + + {{PAGE_TITLE}} + + + + + +

{{COLLECTION_TITLE}}

{{COLLECTION_SUBTITLE}}

+
{{PRODUCT_GRID_HTML}}
+ ${FOOTER} + +`, +}; + +for (const [rel, html] of Object.entries(upgraded2)) { + const [skill, ...rest] = rel.split('/'); + writeSkillFile(skill, rest.join('/'), html); +} + +console.log(`Upgraded ${Object.keys(upgraded2).length} templates (batch 2/3)`); + +const newTemplates = { + 'page-template-saas': { + templateFile: 'saas-landing.html', + label: 'SaaS 产品页', + description: '渐变 Hero + 功能特性 + 定价区,适合 B2B 软件落地页', + sortOrder: 140, + priceCents: 1290, + previewSvg: 'saas-landing', + html: ` + + + + {{PAGE_TITLE}} + + + + +
+ {{PRODUCT_NAME}} +

{{HERO_HEADLINE}}

+

{{HERO_SUBTITLE}}

+ {{CTA_LABEL}} +
{{PRODUCT_NAME}}
+
+
{{FEATURES_HTML}}
+
{{PRICING_HTML}}
+ ${FOOTER} + +`, + }, + + 'page-template-hotel': { + templateFile: 'hotel-retreat.html', + label: '酒店民宿', + description: '大图 Hero + 设施亮点 + 房型卡片,适合精品酒店与民宿', + sortOrder: 150, + priceCents: 1190, + previewSvg: 'hotel-retreat', + html: ` + + + + {{PAGE_TITLE}} + + + + +
+
{{HOTEL_NAME}}

{{HOTEL_TAGLINE}}

+
+
+
{{AMENITIES_HTML}}
+
{{ROOMS_HTML}}
+ {{BOOKING_CTA_LABEL}} +
+ ${FOOTER} + +`, + }, + + 'page-template-agency': { + templateFile: 'agency-studio.html', + label: '创意机构', + description: '极简大标题 + 服务列表 + 案例展示,适合设计/广告工作室', + sortOrder: 160, + priceCents: 1090, + previewSvg: 'agency-studio', + html: ` + + + + {{PAGE_TITLE}} + + + + +

{{AGENCY_NAME}}

{{AGENCY_TAGLINE}}

+
+
{{SERVICES_HTML}}
+
{{SHOWCASE_HTML}}
+
{{CONTACT_HTML}}
+
+ ${FOOTER} + +`, + }, + + 'page-template-photography': { + templateFile: 'photo-gallery.html', + label: '摄影画廊', + description: '全屏 Hero + 瀑布流作品区,适合摄影师与视觉艺术家', + sortOrder: 170, + priceCents: 990, + previewSvg: 'photo-gallery', + html: ` + + + + {{PAGE_TITLE}} + + + + +

{{PHOTOGRAPHER_NAME}}

{{PHOTOGRAPHER_BIO}}

+
+ ${FOOTER} + +`, + }, + + 'page-template-podcast': { + templateFile: 'podcast-show.html', + label: '播客节目', + description: '封面 Hero + 单集列表 + 订阅引导,适合播客与音频栏目', + sortOrder: 180, + priceCents: 890, + previewSvg: 'podcast-show', + html: ` + + + + {{PAGE_TITLE}} + + + + +
+ {{SHOW_NAME}} +

{{SHOW_NAME}}

{{SHOW_TAGLINE}}

+
+
+
{{EPISODES_HTML}}
+
{{SUBSCRIBE_HTML}}
+
+ ${FOOTER} + +`, + }, + + 'page-template-charity': { + templateFile: 'charity-cause.html', + label: '公益募捐', + description: '情感 Hero + 项目故事 + 捐赠 CTA,适合公益与 CSR 活动', + sortOrder: 190, + priceCents: 0, + previewSvg: 'charity-cause', + html: ` + + + + {{PAGE_TITLE}} + + + + +

{{CAUSE_TITLE}}

{{CAUSE_SUBTITLE}}

+
+
{{IMPACT_HTML}}
+
{{STORY_HTML}}
+ +
+ ${FOOTER} + +`, + }, + + 'page-template-startup': { + templateFile: 'startup-pitch.html', + label: '创业融资', + description: 'Pitch Deck 风格单页:愿景 + 数据指标 + 团队 + CTA', + sortOrder: 200, + priceCents: 1490, + previewSvg: 'startup-pitch', + html: ` + + + + {{PAGE_TITLE}} + + + + +
+

{{STARTUP_NAME}}

+

{{PITCH_HEADLINE}}

+

{{PITCH_SUBTITLE}}

+ {{CTA_LABEL}} +
+
+
{{METRICS_HTML}}
+
{{VISION_HTML}}
+
{{TEAM_HTML}}
+
+ ${FOOTER} + +`, + }, +}; + +function skillYaml(name, meta) { + return `name: ${name} +version: 1.0.0 +label: ${meta.label} +description: ${meta.description} +catalog: + label: ${meta.label} + description: ${meta.description} + previewImage: /assets/template-previews/${meta.previewSvg}.svg + priceCents: ${meta.priceCents} + billingMode: ${meta.priceCents > 0 ? 'one_time' : 'free'} + sortOrder: ${meta.sortOrder} +trigger: + keywords: + - ${meta.label} +router: + promptKey: ${name} + priority: 25 +executors: + - goose +`; +} + +function skillMd(name, meta, templateFile) { + return `--- +name: ${name} +description: ${meta.description} +--- + +# ${meta.label} + +1. load_skill → \`${name}\` +2. read_file → \`.agents/skills/${name}/templates/${templateFile}\` +3. 替换全部占位符后 write_file → \`public/*.html\` +4. 遵守 static-page-publish 回复 Markdown 链接 +`; +} + +function previewSvg(title, subtitle, color1, color2) { + return ` + + + + ${subtitle} + ${title} +`; +} + +const previewColors = { + 'saas-landing': ['#312e81', '#0f172a'], + 'hotel-retreat': ['#78350f', '#1c1917'], + 'agency-studio': ['#f5f5f5', '#e5e5e5'], + 'photo-gallery': ['#171717', '#0a0a0a'], + 'podcast-show': ['#4c1d95', '#0f0f14'], + 'charity-cause': ['#fef3c7', '#fde68a'], + 'startup-pitch': ['#0c4a6e', '#030712'], +}; + +for (const [skillName, meta] of Object.entries(newTemplates)) { + writeSkillFile(skillName, `templates/${meta.templateFile}`, meta.html); + writeSkillFile(skillName, 'skill.yaml', skillYaml(skillName, meta)); + writeSkillFile(skillName, 'SKILL.md', skillMd(skillName, meta, meta.templateFile)); + const [c1, c2] = previewColors[meta.previewSvg]; + writePreview(`${meta.previewSvg}.svg`, previewSvg(meta.label, 'Premium Template', c1, c2)); +} + +console.log(`Created ${Object.keys(newTemplates).length} new template skills (batch 3/3)`); +console.log('Done. Update chat-skills.mjs, skills-registry.mjs, page-template-catalog.mjs demos, and test count.'); diff --git a/scripts/check-mindspace-cover.mjs b/scripts/check-mindspace-cover.mjs index 287ed24..b984492 100644 --- a/scripts/check-mindspace-cover.mjs +++ b/scripts/check-mindspace-cover.mjs @@ -26,6 +26,7 @@ function parseArgs(argv) { let root = path.join(repoRoot, DEFAULT_PUBLISH_ROOT); let userId = null; let file = null; + let seo = false; for (let i = 2; i < argv.length; i += 1) { if (argv[i] === '--root' && argv[i + 1]) { root = path.resolve(argv[i + 1]); @@ -36,12 +37,14 @@ function parseArgs(argv) { } else if (argv[i] === '--file' && argv[i + 1]) { file = path.resolve(argv[i + 1]); i += 1; + } else if (argv[i] === '--seo') { + seo = true; } else if (argv[i] === '--help' || argv[i] === '-h') { - console.log(`Usage: node scripts/check-mindspace-cover.mjs [--root ${DEFAULT_PUBLISH_ROOT}] [--user ] [--file ]`); + console.log(`Usage: node scripts/check-mindspace-cover.mjs [--root ${DEFAULT_PUBLISH_ROOT}] [--user ] [--file ] [--seo]`); process.exit(0); } } - return { root, userId, file }; + return { root, userId, file, seo }; } function hasCoverImageHint(html) { @@ -106,6 +109,23 @@ function auditCoverHtml(htmlPath, html) { return issues; } +function auditSeoHtml(html) { + const issues = []; + const title = String(html).match(/]*>([^<]+)<\/title>/i)?.[1]?.replace(/\s+/g, ' ').trim() ?? ''; + if (!title || title.length < 4) { + issues.push({ level: 'warn', code: 'weak_title', message: '缺少或过短的 ,SEO/GEO 质量会下降' }); + } else if (/^(页面|我的页面|未命名|demo|test|MindSpace 页面)$/i.test(title)) { + issues.push({ level: 'warn', code: 'generic_title', message: `title 过于泛化:${title}` }); + } + if (!/<h1\b/i.test(html)) { + issues.push({ level: 'warn', code: 'missing_h1', message: '缺少 <h1>,搜索引擎与 AI 抽取质量会下降' }); + } + if (!hasShareDescription(html)) { + issues.push({ level: 'warn', code: 'seo_missing_description', message: '缺少 description,SEO/GEO 摘要来源不足' }); + } + return issues; +} + function collectHtmlFiles(root, userId) { const files = []; const walk = (dir) => { @@ -135,13 +155,14 @@ function collectHtmlFiles(root, userId) { return files; } -const { root, userId, file } = parseArgs(process.argv); +const { root, userId, file, seo } = parseArgs(process.argv); const targets = file ? [file] : collectHtmlFiles(root, userId); const results = []; for (const htmlPath of targets) { const html = fs.readFileSync(htmlPath, 'utf8'); const issues = auditCoverHtml(htmlPath, html); + if (seo) issues.push(...auditSeoHtml(html)); if (issues.length) { results.push({ htmlPath, issues }); } diff --git a/scripts/dev-core-daemon.mjs b/scripts/dev-core-daemon.mjs index 9303c9c..c6962fa 100644 --- a/scripts/dev-core-daemon.mjs +++ b/scripts/dev-core-daemon.mjs @@ -17,16 +17,6 @@ import { const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); const opsDir = path.join(root, 'ops'); const nodeBin = process.execPath; -const portalPort = Number(process.env.H5_PORT ?? 8081); -const vitePort = Number(process.env.VITE_PORT ?? 5173); -const plazaPort = Number(process.env.PLAZA_PORT ?? 3001); -const opsPort = Number(process.env.OPS_PORT ?? 3002); -const adminPort = Number(process.env.ADMIN_PORT ?? 8082); -const portalUrl = `http://127.0.0.1:${portalPort}`; -const adminUrl = `http://127.0.0.1:${adminPort}`; -const plazaPublicBase = ( - process.env.PLAZA_PUBLIC_BASE ?? `http://127.0.0.1:${plazaPort}` -).replace(/\/$/, ''); const logFile = process.env.MEMIND_DEV_LOG ?? path.join(os.homedir(), 'Library/Logs/memind-dev.log'); @@ -40,10 +30,23 @@ function log(message) { } } +// Load .env before reading ports — otherwise ADMIN_PORT defaults to 8082 and +// split-service MindSpace (also on 8082) gets killed by freePort(). loadMemindEnvFiles(root); applyMemindRuntimeProfile({ rootDir: root }); log(`Runtime profile: ${describeMemindRuntimeProfile()}`); +const portalPort = Number(process.env.H5_PORT ?? 8081); +const vitePort = Number(process.env.VITE_PORT ?? 5173); +const plazaPort = Number(process.env.PLAZA_PORT ?? 3001); +const opsPort = Number(process.env.OPS_PORT ?? 3002); +const adminPort = Number(process.env.ADMIN_PORT ?? 8082); +const portalUrl = `http://127.0.0.1:${portalPort}`; +const adminUrl = `http://127.0.0.1:${adminPort}`; +const plazaPublicBase = ( + process.env.PLAZA_PUBLIC_BASE ?? `http://127.0.0.1:${plazaPort}` +).replace(/\/$/, ''); + function freePort(port) { try { execSync(`lsof -ti TCP:${port} -sTCP:LISTEN | xargs kill -9`, { stdio: 'ignore' }); diff --git a/scripts/memind-runtime-profile.mjs b/scripts/memind-runtime-profile.mjs index 5d7e744..c58ef9e 100644 --- a/scripts/memind-runtime-profile.mjs +++ b/scripts/memind-runtime-profile.mjs @@ -69,6 +69,9 @@ export function applyMemindRuntimeProfile({ if (!env.MINDSPACE_STORAGE_ROOT) { env.MINDSPACE_STORAGE_ROOT = path.join(path.resolve(rootDir), 'data', 'mindspace'); } + if (!env.MEMIND_H5_HTML_FINISH_GUARD) { + env.MEMIND_H5_HTML_FINISH_GUARD = '1'; + } return { profile: resolvedProfile, mindspaceAdapter: 'local', @@ -90,6 +93,9 @@ export function applyMemindRuntimeProfile({ if (!env.MINDSPACE_STORAGE_ROOT) { env.MINDSPACE_STORAGE_ROOT = '/Users/john/MindSpace/data/mindspace'; } + if (!env.MEMIND_H5_HTML_FINISH_GUARD) { + env.MEMIND_H5_HTML_FINISH_GUARD = '1'; + } return { profile: resolvedProfile, mindspaceAdapter: 'remote', diff --git a/scripts/memind-runtime-profile.test.mjs b/scripts/memind-runtime-profile.test.mjs index 6e7b73d..ab4767c 100644 --- a/scripts/memind-runtime-profile.test.mjs +++ b/scripts/memind-runtime-profile.test.mjs @@ -29,6 +29,7 @@ test('applyMemindRuntimeProfile local forces monolith adapter and repo storage', assert.equal(result.profile, 'local'); assert.equal(env.MINDSPACE_SERVER_ADAPTER, 'local'); assert.match(env.MINDSPACE_STORAGE_ROOT, /\/tmp\/memind\/data\/mindspace$/); + assert.equal(env.MEMIND_H5_HTML_FINISH_GUARD, '1'); }); test('applyMemindRuntimeProfile split-service expands auth token reference', () => { @@ -39,6 +40,7 @@ test('applyMemindRuntimeProfile split-service expands auth token reference', () applyMemindRuntimeProfile({ env, profile: 'split-service' }); assert.equal(env.MINDSPACE_SERVER_ADAPTER, 'remote'); assert.equal(env.MINDSPACE_REMOTE_AUTH_TOKEN, 'local-dev-secret'); + assert.equal(env.MEMIND_H5_HTML_FINISH_GUARD, '1'); }); test('applyMemindRuntimeProfile production does not override adapter', () => { @@ -51,4 +53,11 @@ test('applyMemindRuntimeProfile production does not override adapter', () => { assert.equal(result.profile, 'production'); assert.equal(env.MINDSPACE_SERVER_ADAPTER, 'remote'); assert.equal(env.MINDSPACE_REMOTE_AUTH_TOKEN, 'prod-token'); + assert.equal(env.MEMIND_H5_HTML_FINISH_GUARD, undefined); +}); + +test('applyMemindRuntimeProfile local respects explicit finish guard override', () => { + const env = { MEMIND_H5_HTML_FINISH_GUARD: '0' }; + applyMemindRuntimeProfile({ rootDir: '/tmp/memind', env, profile: 'local' }); + assert.equal(env.MEMIND_H5_HTML_FINISH_GUARD, '0'); }); diff --git a/scripts/verify-portal-access-policy.mjs b/scripts/verify-portal-access-policy.mjs index aebfb36..a96961a 100644 --- a/scripts/verify-portal-access-policy.mjs +++ b/scripts/verify-portal-access-policy.mjs @@ -10,6 +10,9 @@ const routeSamples = [ { path: '/status', method: 'GET' }, { path: '/runtime/status', method: 'GET' }, { path: '/config/blocked-words', method: 'GET' }, + { path: '/robots.txt', method: 'GET' }, + { path: '/sitemap.xml', method: 'GET' }, + { path: '/llms.txt', method: 'GET' }, { path: '/internal/agent/jobs/job-1/claim', method: 'POST' }, { path: '/internal/deep-search/llm', method: 'POST' }, { path: '/agent/mindspace_page_patch', method: 'POST' }, diff --git a/scripts/verify-seo-discovery.mjs b/scripts/verify-seo-discovery.mjs new file mode 100644 index 0000000..6f4331e --- /dev/null +++ b/scripts/verify-seo-discovery.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import express from 'express'; +import { attachPortalSeoDiscoveryRoutes } from '../server/portal-seo-discovery-routes.mjs'; +import { createMindspaceSeoDiscoveryService } from '../mindspace-seo-discovery-service.mjs'; + +const enabledConfig = { + seoGeo: { + enabled: true, + seo: { enabled: true, sitemap: true, robotsTxt: true }, + geo: { enabled: true, llmsTxt: true, jsonLd: true }, + }, +}; + +const discovery = createMindspaceSeoDiscoveryService({ + query: async () => [ + [ + { + id: 'pub-1', + page_id: 'page-1', + public_url: '/u/john/pages/demo', + access_mode: 'public', + status: 'online', + user_confirmed_at: Date.now(), + expires_at: null, + updated_at: Date.now(), + published_at: Date.now(), + title: 'Demo', + summary: '演示页面', + }, + ], + ], +}); + +const app = express(); +attachPortalSeoDiscoveryRoutes(app, { + getAuthPool: () => ({}), + getMindSpaceConfig: async () => enabledConfig, + getSeoDiscoveryService: () => discovery, + resolveRequestOrigin: () => 'https://m.tkmind.cn', +}); + +const server = app.listen(0); +try { + const { port } = server.address(); + const base = `http://127.0.0.1:${port}`; + + const robots = await fetch(`${base}/robots.txt`); + assert.equal(robots.status, 200); + const robotsBody = await robots.text(); + assert.match(robotsBody, /Sitemap: https:\/\/m\.tkmind\.cn\/sitemap\.xml/); + + const sitemap = await fetch(`${base}/sitemap.xml`); + assert.equal(sitemap.status, 200); + const sitemapBody = await sitemap.text(); + assert.match(sitemapBody, /\/u\/john\/pages\/demo/); + + const llms = await fetch(`${base}/llms.txt`); + assert.equal(llms.status, 200); + const llmsBody = await llms.text(); + assert.match(llmsBody, /演示页面/); + + console.log('verify-seo-discovery: ok'); +} finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); +} diff --git a/scripts/verify-template-catalog-e2e.mjs b/scripts/verify-template-catalog-e2e.mjs new file mode 100644 index 0000000..353087d --- /dev/null +++ b/scripts/verify-template-catalog-e2e.mjs @@ -0,0 +1,211 @@ +#!/usr/bin/env node +/** + * E2E: Portal template catalog balance purchase + WeChat checkout creation. + * Usage: node scripts/verify-template-catalog-e2e.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadH5Environment } from './load-env.mjs'; +import { createReporter, loginViaApi } from './scenario-test-lib.mjs'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const root = path.join(scriptDir, '..'); + +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (!(key in process.env)) process.env[key] = value; + } +} + +loadH5Environment(scriptDir); +loadEnvFile(path.join(root, '../memind_adm/.env')); + +const portalBase = process.env.PORTAL_API_BASE ?? `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`; +const adminBase = process.env.ADM_DEV_BACKEND ?? 'http://127.0.0.1:8085'; +const adminUser = process.env.H5_ADMIN_USERNAME?.trim() || 'admin'; +const adminPass = + process.env.H5_ADMIN_PASSWORD?.trim() || + process.env.ADMIN_PASSWORD?.trim() || + ''; +const portalUser = + process.env.TEMPLATE_E2E_USERNAME?.trim() || + process.env.MEMIND_E2E_USERNAME?.trim() || + 'john4'; +const portalPass = + process.env.TEMPLATE_E2E_PASSWORD?.trim() || + process.env.MEMIND_E2E_PASSWORD?.trim() || + process.env.JOHN4_PASSWORD?.trim() || + '888888'; + +const PURCHASE_SKILL = process.env.TEMPLATE_E2E_SKILL?.trim() || 'page-template-travel'; + +async function api(base, method, urlPath, { body, cookie } = {}) { + const res = await fetch(`${base}${urlPath}`, { + method, + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(cookie ? { Cookie: cookie } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = { raw: text.slice(0, 400) }; + } + return { status: res.status, json, text }; +} + +async function main() { + const reporter = createReporter(); + console.log(`==> Portal ${portalBase}`); + console.log(`==> Admin ${adminBase}`); + console.log(`==> 购买目标 skill: ${PURCHASE_SKILL}`); + + if (!adminPass) { + throw new Error('缺少 H5_ADMIN_PASSWORD(memind_adm/.env)'); + } + + const adminAuth = await loginViaApi(adminBase, { username: adminUser, password: adminPass }, reporter); + + let portalAuth; + try { + portalAuth = await loginViaApi( + portalBase, + { username: portalUser, password: portalPass }, + { + pass: (label, detail) => reporter.pass(`Portal ${label}`, detail), + fail: (label, detail) => reporter.fail(`Portal ${label}`, detail), + checks: reporter.checks, + issues: reporter.issues, + }, + ); + reporter.pass('Portal 测试账号', portalUser); + } catch (err) { + throw new Error(`Portal 测试账号 ${portalUser} 登录失败: ${err.message}`); + } + + const rechargeRes = await api(adminBase, 'POST', `/admin-api/users/${portalAuth.user.id}/recharge`, { + cookie: adminAuth.cookie, + body: { amountCents: 5000, note: 'template-catalog-e2e' }, + }); + if (rechargeRes.status === 200) { + reporter.pass('Admin 充值', `+5000 分 → ${portalUser}`); + } else { + reporter.fail('Admin 充值', `HTTP ${rechargeRes.status}: ${JSON.stringify(rechargeRes.json)}`); + } + + const catalogRes = await api(portalBase, 'GET', '/api/mindspace/v1/template-catalog', { + cookie: portalAuth.cookie, + }); + if (catalogRes.status !== 200) { + throw new Error(`catalog HTTP ${catalogRes.status}: ${JSON.stringify(catalogRes.json)}`); + } + const items = catalogRes.json?.data?.items ?? []; + reporter.pass('模板列表', `${items.length} 项`); + + const target = items.find((item) => item.skillName === PURCHASE_SKILL); + if (!target) { + throw new Error(`缺少 ${PURCHASE_SKILL}`); + } + + const pickCheckoutSkill = () => { + const notOwned = items.filter((item) => !item.owned && item.priceCents > 0); + return notOwned[0]?.skillName ?? PURCHASE_SKILL; + }; + + if (target.owned) { + reporter.pass('余额购买', `跳过:${PURCHASE_SKILL} 已拥有`); + } else { + const meBefore = await api(portalBase, 'GET', '/auth/me', { cookie: portalAuth.cookie }); + const balanceBefore = Number(meBefore.json?.user?.balance_cents ?? 0); + reporter.pass('购买前余额', `${balanceBefore} 分`); + + const purchaseRes = await api( + portalBase, + 'POST', + `/api/mindspace/v1/template-catalog/${encodeURIComponent(PURCHASE_SKILL)}/purchase`, + { cookie: portalAuth.cookie }, + ); + if (purchaseRes.status === 402) { + reporter.fail('余额购买', `402 余额不足: ${JSON.stringify(purchaseRes.json)}`); + } else if (purchaseRes.status !== 200) { + reporter.fail('余额购买', `HTTP ${purchaseRes.status}: ${JSON.stringify(purchaseRes.json)}`); + } else { + const result = purchaseRes.json?.data ?? purchaseRes.json; + reporter.pass( + '余额购买', + `price=${result.priceCents ?? target.priceCents} balance=${result.balanceCents ?? '?'}`, + ); + const skills = result.grantedSkills ?? []; + if (skills.includes(PURCHASE_SKILL)) { + reporter.pass('skill 开通', PURCHASE_SKILL); + } else { + reporter.fail('skill 开通', `grantedSkills 未含 ${PURCHASE_SKILL}: ${JSON.stringify(skills)}`); + } + } + } + + const wechatSkill = pickCheckoutSkill(); + + const checkoutRes = await api( + portalBase, + 'POST', + `/api/mindspace/v1/template-catalog/${encodeURIComponent(wechatSkill)}/checkout`, + { + cookie: portalAuth.cookie, + body: { payScene: 'native' }, + }, + ); + const checkout = checkoutRes.json?.data ?? checkoutRes.json; + if (checkoutRes.status === 200 && checkout?.ok && checkout?.order) { + reporter.pass( + '微信 checkout', + `${wechatSkill} pending=${checkout.order.status} mode=${checkout.order.payMode}`, + ); + if (checkout.order.codeUrl || checkout.order.jsapiParams || checkout.order.h5Url) { + reporter.pass('微信支付参数', checkout.order.codeUrl ? 'native codeUrl' : checkout.order.payMode); + } + } else if (checkoutRes.status === 400 && String(checkout?.error?.message ?? checkout?.message ?? '').includes('已拥有')) { + reporter.pass('微信 checkout', `${wechatSkill} 已拥有,跳过`); + } else if ( + checkoutRes.status === 400 && + String(checkout?.error?.message ?? checkout?.message ?? '').includes('微信支付尚未配置') + ) { + reporter.pass('微信 checkout', '微信支付未配置(本地预期可接受)'); + } else { + reporter.fail('微信 checkout', `HTTP ${checkoutRes.status}: ${JSON.stringify(checkoutRes.json)}`); + } + + const previewRes = await api( + portalBase, + 'GET', + `/api/mindspace/v1/template-catalog/page-template-travel/preview`, + { cookie: portalAuth.cookie }, + ); + if (previewRes.status === 200 && previewRes.text.includes('周末去哪玩')) { + reporter.pass('预览 HTML', 'travel 示例文案正确'); + } else { + reporter.fail('预览 HTML', `HTTP ${previewRes.status}`); + } + + process.exitCode = reporter.summary(); +} + +main().catch((err) => { + console.error('\nE2E 失败:', err.message); + process.exit(1); +}); diff --git a/scripts/verify-template-catalog-portal.mjs b/scripts/verify-template-catalog-portal.mjs new file mode 100644 index 0000000..868606f --- /dev/null +++ b/scripts/verify-template-catalog-portal.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +/** + * Local smoke test for Portal template catalog API (Memind 8081). + * Usage: node scripts/verify-template-catalog-portal.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadH5Environment } from './load-env.mjs'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const root = path.join(scriptDir, '..'); + +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (!(key in process.env)) process.env[key] = value; + } +} + +loadH5Environment(scriptDir); +loadEnvFile(path.join(root, '../memind_adm/.env')); + +const base = process.env.PORTAL_API_BASE ?? `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`; + +const username = + process.env.VERIFY_PORTAL_USERNAME?.trim() || + process.env.H5_TEST_USERNAME?.trim() || + process.env.MEMIND_E2E_USERNAME?.trim() || + process.env.H5_ADMIN_USERNAME?.trim() || + 'admin'; +const password = + process.env.VERIFY_PORTAL_PASSWORD?.trim() || + process.env.H5_TEST_PASSWORD?.trim() || + process.env.MEMIND_E2E_PASSWORD?.trim() || + process.env.JOHN_PASSWORD?.trim() || + process.env.VERIFY_PASSWORD?.trim() || + process.env.H5_ADMIN_PASSWORD?.trim() || + process.env.ADMIN_PASSWORD?.trim() || + ''; + +async function request(method, urlPath, { body, cookie } = {}) { + const res = await fetch(`${base}${urlPath}`, { + method, + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(cookie ? { Cookie: cookie } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = { raw: text.slice(0, 300) }; + } + return { status: res.status, json, text, setCookie: res.headers.getSetCookie?.() ?? [] }; +} + +function cookieFromSetCookie(setCookie) { + return setCookie.map((c) => c.split(';')[0]).join('; '); +} + +function assertOk(cond, msg) { + if (!cond) throw new Error(msg); +} + +async function main() { + console.log(`==> Portal API base: ${base}`); + + if (!password) { + console.error('缺少 Portal 登录密码(VERIFY_PORTAL_PASSWORD / H5_ADMIN_PASSWORD / memind_adm/.env)'); + process.exit(1); + } + + const login = await request('POST', '/auth/login', { + body: { username, password }, + }); + assertOk(login.status === 200, `登录失败 HTTP ${login.status}: ${JSON.stringify(login.json)}`); + const cookie = cookieFromSetCookie(login.setCookie); + assertOk(cookie, '登录未返回 session cookie'); + console.log(`✓ Portal 登录 (${username})`); + + const catalog = await request('GET', '/api/mindspace/v1/template-catalog', { cookie }); + assertOk(catalog.status === 200, `catalog HTTP ${catalog.status}: ${JSON.stringify(catalog.json)}`); + const items = catalog.json?.data?.items ?? catalog.json?.items ?? []; + assertOk(Array.isArray(items), 'catalog items 应为数组'); + assertOk(items.length >= 13, `应至少 13 个模板,实际 ${items.length}`); + const travel = items.find((item) => item.skillName === 'page-template-travel'); + assertOk(travel, '缺少 page-template-travel'); + console.log( + `✓ GET template-catalog (${items.length} 项,travel owned=${Boolean(travel.owned)} price=${travel.priceCents})`, + ); + + const preview = await request('GET', '/api/mindspace/v1/template-catalog/page-template-travel/preview', { + cookie, + }); + assertOk(preview.status === 200, `preview HTTP ${preview.status}`); + assertOk(String(preview.text).includes('周末去哪玩'), '预览 HTML 应含示例文案'); + console.log('✓ GET template preview HTML'); + + if (travel.owned) { + console.log('• travel 已拥有,跳过余额购买测试'); + } else if (travel.priceCents > 0) { + const purchase = await request('POST', '/api/mindspace/v1/template-catalog/page-template-travel/purchase', { + cookie, + }); + if (purchase.status === 402) { + console.log('• 余额不足 (402),purchase 路由正常'); + } else { + assertOk(purchase.status === 200, `purchase HTTP ${purchase.status}: ${JSON.stringify(purchase.json)}`); + console.log('✓ POST balance purchase 成功'); + } + } + + console.log('\n全部 Portal template-catalog API 联调通过。'); +} + +main().catch((err) => { + console.error('\n联调失败:', err.message); + process.exit(1); +}); diff --git a/server.mjs b/server.mjs index 4d8afb1..8529198 100644 --- a/server.mjs +++ b/server.mjs @@ -54,6 +54,7 @@ import { attachPortalMindSpaceChatShareRoutes } from './server/portal-mindspace- import { attachPortalMindSpacePageCoreRoutes } from './server/portal-mindspace-page-core-routes.mjs'; import { attachPortalMindSpacePagePublishRoutes } from './server/portal-mindspace-page-publish-routes.mjs'; import { attachPortalMindSpaceSpaceRoutes } from './server/portal-mindspace-space-routes.mjs'; +import { attachPortalTemplateCatalogRoutes } from './server/portal-template-catalog-routes.mjs'; import { attachPortalPlazaDiscoveryRoutes } from './server/portal-plaza-discovery-routes.mjs'; import { attachPortalPlazaRoutes } from './server/portal-plaza-routes.mjs'; import { attachPortalPublicationRoutes } from './server/portal-publication-routes.mjs'; @@ -65,12 +66,15 @@ import { import { attachPortalRuntimeRoutes } from './server/portal-runtime-routes.mjs'; import { attachPortalStaticDeliveryRoutes } from './server/portal-static-delivery-routes.mjs'; import { createPortalWorkspacePublicationDelivery } from './server/portal-workspace-publication-delivery.mjs'; +import { attachPortalSeoDiscoveryRoutes } from './server/portal-seo-discovery-routes.mjs'; import { createPortalAuthSessionHelpers } from './server/portal-auth-session-helpers.mjs'; import { createPortalSessionCoordinator } from './server/portal-session-coordinator.mjs'; import { attachPortalSessionRoutes } from './server/portal-session-routes.mjs'; import { attachPortalUserMemoryRoutes } from './server/portal-user-memory-routes.mjs'; import { attachPortalGoalRunRoutes } from './server/portal-goal-run-routes.mjs'; import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs'; +import { loadMindSpaceConfigCached } from './mindspace-config.mjs'; +import { createMindspaceSeoDiscoveryService } from './mindspace-seo-discovery-service.mjs'; import { normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; import { buildMindSpacePublicUrlForUser, @@ -249,6 +253,7 @@ if (ACCESS_PASSWORD && !isDatabaseConfigured()) { } let userAuth = null; +let templateCatalogService = null; let sessionAccess = null; let sessionStreamStore = null; let tkmindProxy = null; @@ -420,6 +425,7 @@ async function bootstrapUserAuth() { wechatOAuthService = authServices.wechatOAuthService; rechargeService = authServices.rechargeService; + templateCatalogService = authServices.templateCatalogService; const agentServices = await bootstrapPortalAgentServices({ pool, @@ -866,6 +872,13 @@ attachPortalMindSpaceSpaceRoutes(api, { handleMindSpaceError: mindSpaceError, }); +attachPortalTemplateCatalogRoutes(api, { + getTemplateCatalog: () => templateCatalogService, + ensureMindSpaceEnabled, + sendData, + sendError, +}); + function resolvePlazaPostUrlForRequest(postId, req) { const host = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(':')[0]; const base = resolvePlazaPublicBase({ @@ -1385,6 +1398,7 @@ attachPortalAgentRuntimeRoutes(api, { getAgentRunGateway: () => agentRunGateway, getGoalRunService: () => goalRunService, getChatIntentRouter: () => chatIntentRouter, + getTemplateCatalog: () => templateCatalogService, getSessionAccess: () => sessionAccess, getMindSpaceAssetAgent: () => mindSpaceAssetAgent, getCodeRunPolicyService: () => agentCodeRunPolicyService, @@ -1517,6 +1531,10 @@ const { serveUserPublishFile } = mindSpaceWorkspacePublicationDelivery, resolveRequestOrigin, removeQueryParam, + getMindSpaceConfig: () => + authPool + ? loadMindSpaceConfigCached(authPool) + : Promise.resolve({ seoGeo: { enabled: false } }), logger: console, }); // Express routing is case-insensitive by default, so the lowercase /mindspace API @@ -1536,8 +1554,30 @@ const sendPublishedPage = getAuthPool: () => authPool, getUserAuth: () => userAuth, getMindSpacePages: () => mindSpacePages, + getMindSpaceConfig: () => + authPool + ? loadMindSpaceConfigCached(authPool) + : Promise.resolve({ seoGeo: { enabled: false } }), }); +let mindspaceSeoDiscoveryService = null; +function getMindspaceSeoDiscoveryService() { + if (!authPool) return null; + if (!mindspaceSeoDiscoveryService) { + mindspaceSeoDiscoveryService = createMindspaceSeoDiscoveryService(authPool); + } + return mindspaceSeoDiscoveryService; +} +attachPortalSeoDiscoveryRoutes(app, { + getAuthPool: () => authPool, + getMindSpaceConfig: () => + authPool + ? loadMindSpaceConfigCached(authPool) + : Promise.resolve({ seoGeo: { enabled: false } }), + getSeoDiscoveryService: getMindspaceSeoDiscoveryService, + resolveRequestOrigin, +}); + attachPortalPublicationRoutes({ app, urlencodedBody: express.urlencoded({ diff --git a/server/portal-access-policy.mjs b/server/portal-access-policy.mjs index e6150bd..193a718 100644 --- a/server/portal-access-policy.mjs +++ b/server/portal-access-policy.mjs @@ -42,6 +42,13 @@ export const PORTAL_STATIC_ACCESS_RULES = Object.freeze([ legacyDirectBypass: true, paths: ['/config/blocked-words'], }), + freezeRule({ + id: 'seo-discovery', + accessClass: PORTAL_ACCESS_CLASS.PUBLIC, + legacyDirectBypass: true, + methods: ['GET'], + paths: ['/robots.txt', '/sitemap.xml', '/llms.txt'], + }), freezeRule({ id: 'internal-agent', accessClass: PORTAL_ACCESS_CLASS.INTERNAL, diff --git a/server/portal-agent-runtime-routes.mjs b/server/portal-agent-runtime-routes.mjs index f3a75af..0efbb54 100644 --- a/server/portal-agent-runtime-routes.mjs +++ b/server/portal-agent-runtime-routes.mjs @@ -38,6 +38,7 @@ export function attachPortalAgentRuntimeRoutes( getAgentRunGateway = () => null, getGoalRunService = () => null, getChatIntentRouter = () => null, + getTemplateCatalog = () => null, getSessionAccess = () => null, getMindSpaceAssetAgent = () => null, getCodeRunPolicyService = () => null, @@ -134,6 +135,7 @@ export function attachPortalAgentRuntimeRoutes( codeRunPolicyService: getCodeRunPolicyService(), goalRunService: getGoalRunService(), chatIntentRouter: getChatIntentRouter(), + templateCatalogService: getTemplateCatalog(), }), ], req, diff --git a/server/portal-auth-services-bootstrap.mjs b/server/portal-auth-services-bootstrap.mjs index 937a37e..057e84a 100644 --- a/server/portal-auth-services-bootstrap.mjs +++ b/server/portal-auth-services-bootstrap.mjs @@ -6,6 +6,10 @@ import { ensurePlanCatalogSchema, } from '../billing-subscription.mjs'; import { resolveNormalizedRouterDecisionMode } from '../chat-intent-router.mjs'; +import { + createPageTemplateCatalogService, + ensureTemplateCatalogSchema, +} from '../page-template-catalog.mjs'; import { createSessionAccess, isSessionBrokerEnabled, @@ -37,7 +41,9 @@ export async function bootstrapPortalAuthServices({ env = process.env, logger = console, ensurePlanCatalogSchemaFn = ensurePlanCatalogSchema, + ensureTemplateCatalogSchemaFn = ensureTemplateCatalogSchema, createPlanCatalogServiceFn = createPlanCatalogService, + createPageTemplateCatalogServiceFn = createPageTemplateCatalogService, createSubscriptionServiceFn = createSubscriptionService, createUserAuthFn = createUserAuth, createUserDataSpaceServiceFn = createUserDataSpaceService, @@ -69,6 +75,7 @@ export async function bootstrapPortalAuthServices({ } await ensurePlanCatalogSchemaFn(pool); + await ensureTemplateCatalogSchemaFn(pool); const planCatalogService = createPlanCatalogServiceFn(pool); const subscriptionService = @@ -143,16 +150,23 @@ export async function bootstrapPortalAuthServices({ const wechatPayClient = createWechatPayClientFn( loadWechatPayConfigFn(), ); + const templateCatalogService = createPageTemplateCatalogServiceFn(pool, { + userAuth, + h5Root, + wechatPay: wechatPayClient, + }); + await templateCatalogService.ensureReady(); + const rechargeService = createRechargeServiceFn(pool, { + userAuth, + wechatPay: wechatPayClient, + templateCatalogService, + }); const wechatOAuthService = createWechatOAuthServiceFn( pool, loadWechatOAuthConfigFn(), { userAuth }, ); - const rechargeService = createRechargeServiceFn(pool, { - userAuth, - wechatPay: wechatPayClient, - }); if (wechatPayClient.enabled) { logger.log( `WeChat Pay recharge enabled (${wechatPayClient.apiVersion ?? 'unknown'})`, @@ -173,5 +187,6 @@ export async function bootstrapPortalAuthServices({ wechatPayClient, wechatOAuthService, rechargeService, + templateCatalogService, }; } diff --git a/server/portal-domain-services-bootstrap.mjs b/server/portal-domain-services-bootstrap.mjs index 2e2873c..9b5247a 100644 --- a/server/portal-domain-services-bootstrap.mjs +++ b/server/portal-domain-services-bootstrap.mjs @@ -1,4 +1,4 @@ -import { ensureMindSpaceConfig, loadMindSpaceConfig } from '../mindspace-config.mjs'; +import { ensureMindSpaceConfig, loadMindSpaceConfig, loadMindSpaceConfigCached } from '../mindspace-config.mjs'; import { resolveMindSpaceRybbitConfig } from '../mindspace-rybbit.mjs'; import { createMindSearchConfigService } from '../mindsearch-config.mjs'; import { createMindSpaceService } from '../mindspace.mjs'; @@ -47,6 +47,7 @@ export async function bootstrapPortalDomainServices({ createMindSearchConfigService, ensureMindSpaceConfigFn = ensureMindSpaceConfig, loadMindSpaceConfigFn = loadMindSpaceConfig, + loadMindSpaceConfigCachedFn = loadMindSpaceConfigCached, createScheduleServiceFn = createScheduleService, createScheduledTaskServiceFn = createScheduledTaskService, createPageDataServiceFn = createPageDataService, @@ -249,8 +250,14 @@ export async function bootstrapPortalDomainServices({ plazaRedis, algorithmConfig: plazaAlgorithmConfig, recommendService: plazaRecommend, - onPostPublished: (postId) => - plazaSeo?.notifyPostPublished(postId), + onPostPublished: async (postId) => { + if (!plazaSeo) return { pushed: false, reason: 'plaza_unavailable' }; + const config = await loadMindSpaceConfigCachedFn(pool, { env }).catch(() => null); + if (!config?.seoGeo?.enabled || !config?.seoGeo.seo?.baiduPush) { + return { pushed: false, reason: 'disabled' }; + } + return plazaSeo.notifyPostPublished(postId); + }, loadFeaturedPosts: async (viewerId) => { if (!plazaOps) { return { diff --git a/server/portal-domain-services-bootstrap.test.mjs b/server/portal-domain-services-bootstrap.test.mjs index b745a81..6d1916d 100644 --- a/server/portal-domain-services-bootstrap.test.mjs +++ b/server/portal-domain-services-bootstrap.test.mjs @@ -115,6 +115,20 @@ function createBootstrapSetup(overrides = {}) { websiteId: 'website-1', idSecret: 'secret-1', }, + seoGeo: { + enabled: true, + seo: { enabled: true, baiduPush: true }, + geo: { enabled: false }, + }, + }; + }, + async loadMindSpaceConfigCachedFn() { + calls.push({ kind: 'load-mindspace-config-cached' }); + return { + seoGeo: { + enabled: true, + seo: { enabled: true, baiduPush: true }, + }, }; }, createScheduleServiceFn(receivedPool, options) { @@ -396,7 +410,7 @@ test('domain bootstrap preserves directory and Plaza cross-service callbacks', a ), { viewerId: 'viewer-1' }, ); - setup.captured.plazaPostOptions.onPostPublished('post-1'); + await setup.captured.plazaPostOptions.onPostPublished('post-1'); await setup.captured.plazaPostOptions.loadViewerReactions( 'viewer-1', ['post-1'], diff --git a/server/portal-published-page-delivery.mjs b/server/portal-published-page-delivery.mjs index 7e5b67b..14d306f 100644 --- a/server/portal-published-page-delivery.mjs +++ b/server/portal-published-page-delivery.mjs @@ -45,6 +45,13 @@ import { removeQueryParam, resolveRequestOrigin, } from './portal-publication-shell.mjs'; +import { + decorateMindSpaceSeoGeoHtml, + applySeoGeoResponseHeaders, +} from '../mindspace-seo-geo-delivery.mjs'; +import { + loadMindSpaceConfigCached, +} from '../mindspace-config.mjs'; async function decoratePublicationHtmlAnalytics( html, @@ -138,6 +145,7 @@ export function createPortalPublishedPageDelivery({ getAuthPool = () => null, getUserAuth = () => null, getMindSpacePages = () => null, + getMindSpaceConfig = loadMindSpaceConfigCached, logger = console, } = {}) { if ( @@ -194,6 +202,24 @@ export function createPortalPublishedPageDelivery({ pageUrl.lastIndexOf('/') + 1, ) : ''; + const mindSpaceConfig = getAuthPool() + ? await getMindSpaceConfig(getAuthPool()).catch(() => ({ seoGeo: { enabled: false } })) + : { seoGeo: { enabled: false } }; + const seoGeoConfig = mindSpaceConfig?.seoGeo ?? null; + const publicationSnapshot = result?.publication ?? null; + const applySeoGeo = (htmlInput, { innerHtml = null, meta = {} } = {}) => + decorateMindSpaceSeoGeoHtml(htmlInput, { + seoGeoConfig, + publication: publicationSnapshot, + embed, + context: { + origin, + pageUrl, + pageDirUrl, + meta, + }, + innerHtml, + }); const wechatShare = !embed && isWechatUserAgent( @@ -241,6 +267,17 @@ export function createPortalPublishedPageDelivery({ // Never block delivery on share metadata. } } + let robotsHeader = null; + if (!embed) { + try { + const shareMeta = extractShareMetaFromPageHtml(html); + const seoGeoResult = applySeoGeo(html, { meta: shareMeta }); + html = seoGeoResult.html; + robotsHeader = seoGeoResult.robotsHeader; + } catch { + // Never block delivery on SEO/GEO metadata. + } + } const isFullHtml = /^\s*<!doctype html/i.test(html) || /^\s*<html[\s>]/i.test(html); @@ -356,6 +393,20 @@ export function createPortalPublishedPageDelivery({ getMindSpacePages, logger, }); + if (!embed) { + try { + const shareMeta = extractShareMetaFromPageHtml(html); + const shellSeoGeo = applySeoGeo(shellHtml, { + innerHtml: html, + meta: shareMeta, + }); + shellHtml = shellSeoGeo.html; + robotsHeader = shellSeoGeo.robotsHeader ?? robotsHeader; + } catch { + // Keep the share shell usable. + } + } + applySeoGeoResponseHeaders(res, robotsHeader); res.set( 'Content-Type', 'text/html; charset=utf-8', @@ -375,6 +426,7 @@ export function createPortalPublishedPageDelivery({ ); return res.send(shellHtml); } + applySeoGeoResponseHeaders(res, robotsHeader); res.set( 'Content-Type', 'text/html; charset=utf-8', diff --git a/server/portal-seo-discovery-routes.mjs b/server/portal-seo-discovery-routes.mjs new file mode 100644 index 0000000..57114c0 --- /dev/null +++ b/server/portal-seo-discovery-routes.mjs @@ -0,0 +1,84 @@ +function assertRouter(api) { + if (!api || typeof api.get !== 'function') { + throw new Error('attachPortalSeoDiscoveryRoutes requires an Express-compatible router'); + } +} + +export function attachPortalSeoDiscoveryRoutes( + app, + { + getAuthPool = () => null, + getMindSpaceConfig = async () => ({ seoGeo: { enabled: false } }), + getSeoDiscoveryService = () => null, + resolveRequestOrigin = () => '', + } = {}, +) { + assertRouter(app); + + app.get('/robots.txt', async (req, res) => { + const config = await getMindSpaceConfig(); + const seoGeo = config?.seoGeo ?? {}; + if (!seoGeo.enabled || !seoGeo.seo?.robotsTxt) { + return res.type('text/plain; charset=utf-8').send('User-agent: *\nDisallow: /\n'); + } + const service = getSeoDiscoveryService(); + if (!service) { + return res.status(503).type('text/plain; charset=utf-8').send('SEO discovery unavailable\n'); + } + const origin = resolveRequestOrigin(req); + return res + .type('text/plain; charset=utf-8') + .set('Cache-Control', 'public, max-age=300') + .send( + service.renderRobotsTxt({ + origin, + sitemapEnabled: Boolean(seoGeo.seo?.sitemap), + llmsTxtEnabled: Boolean(seoGeo.geo?.llmsTxt), + }), + ); + }); + + app.get('/sitemap.xml', async (req, res) => { + const config = await getMindSpaceConfig(); + const seoGeo = config?.seoGeo ?? {}; + if (!seoGeo.enabled || !seoGeo.seo?.enabled || !seoGeo.seo?.sitemap) { + return res.status(404).type('text/plain; charset=utf-8').send('Not Found\n'); + } + const pool = getAuthPool(); + const service = getSeoDiscoveryService(); + if (!pool || !service) { + return res.status(503).type('text/plain; charset=utf-8').send('SEO discovery unavailable\n'); + } + const origin = resolveRequestOrigin(req); + const entries = await service.listIndexablePublications(pool, { + limit: req.query.limit, + offset: req.query.offset, + }); + return res + .type('application/xml; charset=utf-8') + .set('Cache-Control', 'public, max-age=300') + .send(service.renderSitemapXml(entries, { origin })); + }); + + app.get('/llms.txt', async (req, res) => { + const config = await getMindSpaceConfig(); + const seoGeo = config?.seoGeo ?? {}; + if (!seoGeo.enabled || !seoGeo.geo?.enabled || !seoGeo.geo?.llmsTxt) { + return res.status(404).type('text/plain; charset=utf-8').send('Not Found\n'); + } + const pool = getAuthPool(); + const service = getSeoDiscoveryService(); + if (!pool || !service) { + return res.status(503).type('text/plain; charset=utf-8').send('GEO discovery unavailable\n'); + } + const origin = resolveRequestOrigin(req); + const entries = await service.listIndexablePublications(pool, { + limit: req.query.limit, + offset: req.query.offset, + }); + return res + .type('text/plain; charset=utf-8') + .set('Cache-Control', 'public, max-age=300') + .send(service.renderLlmsTxt(entries, { origin })); + }); +} diff --git a/server/portal-seo-discovery-routes.test.mjs b/server/portal-seo-discovery-routes.test.mjs new file mode 100644 index 0000000..d75741b --- /dev/null +++ b/server/portal-seo-discovery-routes.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import express from 'express'; +import { attachPortalSeoDiscoveryRoutes } from './portal-seo-discovery-routes.mjs'; + +function createApp(deps) { + const app = express(); + attachPortalSeoDiscoveryRoutes(app, deps); + return app; +} + +async function getText(app, path) { + const server = app.listen(0); + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}${path}`); + const body = await response.text(); + return { status: response.status, body }; + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +} + +test('GET /robots.txt returns closed policy when disabled', async () => { + const app = createApp({ + getMindSpaceConfig: async () => ({ seoGeo: { enabled: false } }), + }); + const result = await getText(app, '/robots.txt'); + assert.equal(result.status, 200); + assert.match(result.body, /Disallow: \//); +}); + +test('GET /sitemap.xml returns 404 when sitemap disabled', async () => { + const app = createApp({ + getMindSpaceConfig: async () => ({ + seoGeo: { enabled: true, seo: { enabled: true, sitemap: false } }, + }), + }); + const result = await getText(app, '/sitemap.xml'); + assert.equal(result.status, 404); +}); + +test('GET /sitemap.xml renders xml when enabled', async () => { + const app = createApp({ + getAuthPool: () => ({}), + getMindSpaceConfig: async () => ({ + seoGeo: { enabled: true, seo: { enabled: true, sitemap: true } }, + }), + getSeoDiscoveryService: () => ({ + listIndexablePublications: async () => [ + { publicUrl: '/u/john/pages/demo', updatedAt: Date.now() }, + ], + renderSitemapXml: (entries, ctx) => + `<urlset origin="${ctx.origin}">${entries.length}</urlset>`, + }), + resolveRequestOrigin: () => 'https://m.tkmind.cn', + }); + const result = await getText(app, '/sitemap.xml'); + assert.equal(result.status, 200); + assert.match(result.body, /origin="https:\/\/m\.tkmind\.cn">1<\/urlset>/); +}); diff --git a/server/portal-template-catalog-routes.mjs b/server/portal-template-catalog-routes.mjs new file mode 100644 index 0000000..b956be6 --- /dev/null +++ b/server/portal-template-catalog-routes.mjs @@ -0,0 +1,125 @@ +function assertRouter(api) { + if (!api || typeof api.get !== 'function' || typeof api.post !== 'function') { + throw new Error('attachPortalTemplateCatalogRoutes requires an Express-compatible router'); + } +} + +export function attachPortalTemplateCatalogRoutes( + api, + { + getTemplateCatalog = () => null, + ensureMindSpaceEnabled = () => false, + sendData = (res, _req, data) => res.json({ data }), + sendError = (res, _req, status, code, message) => + res.status(status).json({ error: { code, message } }), + } = {}, +) { + assertRouter(api); + + api.get('/mindspace/v1/template-catalog', async (req, res) => { + const catalog = getTemplateCatalog(); + if (!catalog || !ensureMindSpaceEnabled(res, req)) return; + try { + const items = await catalog.listCatalogForUser(req.currentUser.id); + return sendData(res, req, { items }); + } catch (error) { + const message = error instanceof Error ? error.message : '读取模板商城失败'; + return sendError(res, req, 500, 'template_catalog_error', message); + } + }); + + api.get('/mindspace/v1/template-catalog/mine', async (req, res) => { + const catalog = getTemplateCatalog(); + if (!catalog || !ensureMindSpaceEnabled(res, req)) return; + try { + const items = await catalog.listMine(req.currentUser.id); + return sendData(res, req, { items }); + } catch (error) { + const message = error instanceof Error ? error.message : '读取已购模板失败'; + return sendError(res, req, 500, 'template_catalog_error', message); + } + }); + + api.post('/mindspace/v1/template-catalog/:skillName/purchase', async (req, res) => { + const catalog = getTemplateCatalog(); + if (!catalog || !ensureMindSpaceEnabled(res, req)) return; + try { + const result = await catalog.purchaseWithBalance(req.currentUser.id, req.params.skillName); + if (!result.ok) { + if (result.code === 'INSUFFICIENT_BALANCE') { + return res.status(402).json({ + error: { + code: result.code, + message: result.message, + }, + details: { + balanceCents: result.balanceCents, + minRechargeCents: result.minRechargeCents, + suggestedTiers: result.suggestedTiers, + }, + }); + } + return sendError(res, req, 400, 'template_purchase_failed', result.message); + } + return sendData(res, req, result); + } catch (error) { + const message = error instanceof Error ? error.message : '购买模板失败'; + return sendError(res, req, 500, 'template_purchase_error', message); + } + }); + + api.post('/mindspace/v1/template-catalog/:skillName/favorite', async (req, res) => { + const catalog = getTemplateCatalog(); + if (!catalog || !ensureMindSpaceEnabled(res, req)) return; + try { + const result = await catalog.toggleTemplateFavorite(req.currentUser.id, req.params.skillName); + if (!result.ok) { + return sendError(res, req, 400, 'template_favorite_failed', result.message); + } + return sendData(res, req, result); + } catch (error) { + const message = error instanceof Error ? error.message : '收藏操作失败'; + return sendError(res, req, 500, 'template_favorite_error', message); + } + }); + + api.get('/mindspace/v1/template-catalog/:skillName/preview', async (req, res) => { + const catalog = getTemplateCatalog(); + if (!catalog || !ensureMindSpaceEnabled(res, req)) return; + try { + const result = await catalog.getTemplatePreviewHtml(req.params.skillName); + if (!result.ok) { + return sendError(res, req, 404, 'template_preview_not_found', result.message); + } + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Cache-Control', 'public, max-age=300'); + return res.send(result.html); + } catch (error) { + const message = error instanceof Error ? error.message : '读取模板预览失败'; + return sendError(res, req, 500, 'template_preview_error', message); + } + }); + + api.post('/mindspace/v1/template-catalog/:skillName/checkout', async (req, res) => { + const catalog = getTemplateCatalog(); + if (!catalog || !ensureMindSpaceEnabled(res, req)) return; + try { + const payScene = ['native', 'h5', 'jsapi'].includes(req.body?.payScene) + ? req.body.payScene + : 'native'; + const result = await catalog.createWechatCheckout({ + userId: req.currentUser.id, + skillName: req.params.skillName, + payScene, + clientIp: req.ip, + }); + if (!result.ok) { + return sendError(res, req, 400, 'template_checkout_failed', result.message); + } + return sendData(res, req, result); + } catch (error) { + const message = error instanceof Error ? error.message : '创建模板支付订单失败'; + return sendError(res, req, 500, 'template_checkout_error', message); + } + }); +} diff --git a/server/portal-template-catalog-routes.test.mjs b/server/portal-template-catalog-routes.test.mjs new file mode 100644 index 0000000..006984e --- /dev/null +++ b/server/portal-template-catalog-routes.test.mjs @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import express from 'express'; +import test from 'node:test'; +import { attachPortalTemplateCatalogRoutes } from './portal-template-catalog-routes.mjs'; + +function createApp({ catalog, enabled = true }) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.currentUser = { id: 'user-1' }; + next(); + }); + attachPortalTemplateCatalogRoutes(app, { + getTemplateCatalog: () => catalog, + ensureMindSpaceEnabled: (res) => { + if (!enabled) { + res.status(503).json({ error: { code: 'disabled', message: 'disabled' } }); + return false; + } + return true; + }, + }); + return app; +} + +async function request(app, method, url, body) { + const server = app.listen(0); + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}${url}`, { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + const json = await response.json(); + return { status: response.status, json }; + } finally { + await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); + } +} + +test('GET /mindspace/v1/template-catalog returns catalog items', async () => { + const app = createApp({ + catalog: { + async listCatalogForUser(userId) { + assert.equal(userId, 'user-1'); + return [{ skillName: 'page-template-travel', owned: false, priceCents: 990 }]; + }, + }, + }); + const { status, json } = await request(app, 'GET', '/mindspace/v1/template-catalog'); + assert.equal(status, 200); + assert.equal(json.data.items[0].skillName, 'page-template-travel'); +}); + +test('GET template preview returns html', async () => { + const app = createApp({ + catalog: { + async getTemplatePreviewHtml(skillName) { + assert.equal(skillName, 'page-template-travel'); + return { ok: true, html: '<!DOCTYPE html><title>preview' }; + }, + }, + }); + const server = app.listen(0); + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/mindspace/v1/template-catalog/page-template-travel/preview`); + const html = await response.text(); + assert.equal(response.status, 200); + assert.match(html, /preview/); + assert.match(response.headers.get('content-type') ?? '', /text\/html/); + } finally { + await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); + } +}); + +test('POST purchase returns 402 on insufficient balance', async () => { + const app = createApp({ + catalog: { + async purchaseWithBalance(userId, skillName) { + assert.equal(userId, 'user-1'); + assert.equal(skillName, 'page-template-travel'); + return { + ok: false, + code: 'INSUFFICIENT_BALANCE', + message: '余额不足', + balanceCents: 100, + minRechargeCents: 500, + suggestedTiers: [500], + }; + }, + }, + }); + const { status, json } = await request( + app, + 'POST', + '/mindspace/v1/template-catalog/page-template-travel/purchase', + ); + assert.equal(status, 402); + assert.equal(json.error.code, 'INSUFFICIENT_BALANCE'); +}); diff --git a/server/portal-workspace-publication-delivery.mjs b/server/portal-workspace-publication-delivery.mjs index 748ed69..8af7393 100644 --- a/server/portal-workspace-publication-delivery.mjs +++ b/server/portal-workspace-publication-delivery.mjs @@ -40,6 +40,15 @@ import { isLongImageDownloadRequest, } from '../mindspace-long-image.mjs'; import { isWechatUserAgent } from '../wechat-oauth.mjs'; +import { + applySeoGeoResponseHeaders, +} from '../mindspace-seo-geo-delivery.mjs'; +import { + loadMindSpaceConfigCached, +} from '../mindspace-config.mjs'; +import { + resolvePublicationIndexSnapshot, +} from '../mindspace-seo-discovery-service.mjs'; function deliveryNotFoundMessage(reason) { if (reason === 'missing_owner_dir' || reason === 'missing_owner') { @@ -65,6 +74,8 @@ export function createPortalWorkspacePublicationDelivery({ logger = console, resolvePageDataContext = resolveMindSpacePageDataContext, + getMindSpaceConfig = loadMindSpaceConfigCached, + resolvePublicationSnapshot = resolvePublicationIndexSnapshot, } = {}) { if ( typeof resolveRequestOrigin !== @@ -273,6 +284,20 @@ export function createPortalWorkspacePublicationDelivery({ '', config: rybbitConfig, }); + const mindSpaceConfig = getAuthPool() + ? await getMindSpaceConfig(getAuthPool()).catch(() => ({ seoGeo: { enabled: false } })) + : { seoGeo: { enabled: false } }; + let publicationSnapshot = null; + if (mindSpaceConfig?.seoGeo?.enabled && getAuthPool()) { + publicationSnapshot = await resolvePublicationSnapshot(getAuthPool(), { + publicationId: + pageDataContext?.publicationId ?? + pageDataContext?.publication_id ?? + null, + pageId: pageDataContext?.pageId ?? null, + userId: delivery.ownerId ?? null, + }).catch(() => null); + } const decorated = decorateMindSpacePublishedHtml({ html, @@ -288,11 +313,14 @@ export function createPortalWorkspacePublicationDelivery({ injectPublicFileShareButton, publishedPageCsp, isWechatUserAgent, + seoGeoConfig: mindSpaceConfig?.seoGeo ?? null, + publicationSnapshot, }); html = decorated.html; if (decorated.allowEmbedFrame) { allowPlazaEmbedFrame(res); } + applySeoGeoResponseHeaders(res, decorated.robotsHeader); res.set( 'Content-Security-Policy', decorated.csp, diff --git a/skills-registry.mjs b/skills-registry.mjs index e6df4aa..52e0cd0 100644 --- a/skills-registry.mjs +++ b/skills-registry.mjs @@ -10,6 +10,12 @@ import { const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export const PAGE_TEMPLATE_SKILL_PREFIX = 'page-template-'; + +export function isPageTemplateSkill(name) { + return String(name ?? '').startsWith(PAGE_TEMPLATE_SKILL_PREFIX); +} + export const PAGE_DATA_COLLECT_SKILL_NAME = 'page-data-collect'; export const EXCEL_ANALYST_SKILL_NAME = 'excel-analyst'; export const IMAGE_GENERATION_SKILL_NAME = 'image-generation'; @@ -32,6 +38,26 @@ export const DEFAULT_USER_SKILLS = { [PAGE_DATA_COLLECT_SKILL_NAME]: true, [AIDER_DEVELOPMENT_SKILL_NAME]: false, [PUBLISH_SKILL_NAME]: false, + 'page-template-travel': false, + 'page-template-campaign': false, + 'page-template-survey': false, + 'page-template-restaurant': false, + 'page-template-portfolio': false, + 'page-template-event': false, + 'page-template-course': false, + 'page-template-realestate': false, + 'page-template-blog': false, + 'page-template-resume': false, + 'page-template-wedding': false, + 'page-template-fitness': false, + 'page-template-product-grid': false, + 'page-template-saas': false, + 'page-template-hotel': false, + 'page-template-agency': false, + 'page-template-photography': false, + 'page-template-podcast': false, + 'page-template-charity': false, + 'page-template-startup': false, }; /** Optional presets for admin role configuration (creator / developer). */ diff --git a/skills-registry.test.mjs b/skills-registry.test.mjs index 88012a6..4596c16 100644 --- a/skills-registry.test.mjs +++ b/skills-registry.test.mjs @@ -78,6 +78,9 @@ test('DEFAULT_USER_SKILLS enables common platform skills', () => { assert.equal(DEFAULT_USER_SKILLS['image-generation'], true); assert.equal(DEFAULT_USER_SKILLS['page-data-collect'], true); assert.equal(DEFAULT_USER_SKILLS['static-page-publish'], false); + assert.equal(DEFAULT_USER_SKILLS['page-template-travel'], false); + assert.equal(DEFAULT_USER_SKILLS['page-template-campaign'], false); + assert.equal(DEFAULT_USER_SKILLS['page-template-survey'], false); assert.equal(DEFAULT_USER_SKILLS['excel-analyst'], false); assert.equal(DEFAULT_USER_SKILLS['aider-development'], false); assert.equal(USER_ROLE_SKILL_PRESETS.developer['aider-development'], true); diff --git a/skills/page-template-agency/SKILL.md b/skills/page-template-agency/SKILL.md new file mode 100644 index 0000000..e58cd7d --- /dev/null +++ b/skills/page-template-agency/SKILL.md @@ -0,0 +1,11 @@ +--- +name: page-template-agency +description: 极简大标题 + 服务列表 + 案例展示,适合设计/广告工作室 +--- + +# 创意机构 + +1. load_skill → `page-template-agency` +2. read_file → `.agents/skills/page-template-agency/templates/agency-studio.html` +3. 替换全部占位符后 write_file → `public/*.html` +4. 遵守 static-page-publish 回复 Markdown 链接 diff --git a/skills/page-template-agency/skill.yaml b/skills/page-template-agency/skill.yaml new file mode 100644 index 0000000..b10af7e --- /dev/null +++ b/skills/page-template-agency/skill.yaml @@ -0,0 +1,19 @@ +name: page-template-agency +version: 1.0.0 +label: 创意机构 +description: 极简大标题 + 服务列表 + 案例展示,适合设计/广告工作室 +catalog: + label: 创意机构 + description: 极简大标题 + 服务列表 + 案例展示,适合设计/广告工作室 + previewImage: /assets/template-previews/agency-studio.svg + priceCents: 1090 + billingMode: one_time + sortOrder: 160 +trigger: + keywords: + - 创意机构 +router: + promptKey: page-template-agency + priority: 25 +executors: + - goose diff --git a/skills/page-template-agency/templates/agency-studio.html b/skills/page-template-agency/templates/agency-studio.html new file mode 100644 index 0000000..e204379 --- /dev/null +++ b/skills/page-template-agency/templates/agency-studio.html @@ -0,0 +1,31 @@ + + + + + {{PAGE_TITLE}} + + + + +

{{AGENCY_NAME}}

{{AGENCY_TAGLINE}}

+
+
{{SERVICES_HTML}}
+
{{SHOWCASE_HTML}}
+
{{CONTACT_HTML}}
+
+

TKMind · 智趣

+ + diff --git a/skills/page-template-blog/SKILL.md b/skills/page-template-blog/SKILL.md new file mode 100644 index 0000000..b349a56 --- /dev/null +++ b/skills/page-template-blog/SKILL.md @@ -0,0 +1,11 @@ +--- +name: page-template-blog +description: 标题区 + 正文排版 + 引用块,适用于专栏、洞察、复盘文 +--- + +# 博客长文模板 + +1. load_skill → `page-template-blog` +2. read_file → `.agents/skills/page-template-blog/templates/blog-article.html` +3. 替换占位符后 write_file → `public/*.html` +4. 遵守 static-page-publish 回复 Markdown 链接 diff --git a/skills/page-template-blog/skill.yaml b/skills/page-template-blog/skill.yaml new file mode 100644 index 0000000..8edcb74 --- /dev/null +++ b/skills/page-template-blog/skill.yaml @@ -0,0 +1,20 @@ +name: page-template-blog +version: 1.0.0 +label: 博客长文模板 +description: 标题区 + 正文排版 + 引用块,适用于专栏、洞察、复盘文 +catalog: + label: 博客长文模板 + description: 标题区 + 正文排版 + 引用块,适用于专栏、洞察、复盘文 + previewImage: /assets/template-previews/blog-article.svg + priceCents: 790 + billingMode: one_time + sortOrder: 90 +trigger: + keywords: + - 博客模板 + - 长文 +router: + promptKey: page-template-blog + priority: 25 +executors: + - goose diff --git a/skills/page-template-blog/templates/blog-article.html b/skills/page-template-blog/templates/blog-article.html new file mode 100644 index 0000000..02f23fb --- /dev/null +++ b/skills/page-template-blog/templates/blog-article.html @@ -0,0 +1,30 @@ + + + + + + {{PAGE_TITLE}} + + + + + +
+

{{ARTICLE_DATE}} · {{ARTICLE_AUTHOR}}

+

{{ARTICLE_TITLE}}

+
{{ARTICLE_BODY_HTML}}
+
+

TKMind · 智趣

+ + diff --git a/skills/page-template-campaign/SKILL.md b/skills/page-template-campaign/SKILL.md new file mode 100644 index 0000000..711a618 --- /dev/null +++ b/skills/page-template-campaign/SKILL.md @@ -0,0 +1,39 @@ +--- +name: page-template-campaign +description: 商品促销页面模板:Hero CTA + 卖点 + 活动区,适用于宣传落地页 +--- + +# 商品促销模板 + +## 何时使用 + +- 用户选择了「促销模板」或 prompt 含 `templateId=campaign-cta` +- 用户要商品宣传页、活动页、带购买按钮的落地页 + +## 工作流 + +1. `load_skill` → `page-template-campaign` +2. `read_file` → `.agents/skills/page-template-campaign/templates/campaign-cta.html` +3. 填入商品名、卖点、CTA 链接(用户提供购买链接时必填) +4. 替换全部占位符 → `write_file` → `public/.html` +5. 遵守 `static-page-publish` 与 `product-campaign-page` 的合规要求 + +## 占位符 + +| 占位符 | 说明 | +|--------|------| +| `PAGE_TITLE` | 页面标题 | +| `PAGE_DESCRIPTION` | 摘要 | +| `MINDSPACE_COVER_JSON` | cover 元数据 | +| `PRODUCT_NAME` | 商品/活动名 | +| `HERO_HEADLINE` / `HERO_SUBTITLE` | 首屏文案 | +| `CTA_URL` | 购买跳转链接 | +| `CTA_LABEL` | 按钮文案,默认「立即购买」 | +| `SELLING_POINTS_HTML` | 卖点列表 HTML | +| `OFFER_HTML` | 活动优惠区 HTML | +| `HERO_IMAGE` | 主图相对路径 | + +## 禁止 + +- 虚构价格、销量、认证信息 +- 未提供购买链接时不要写假 CTA URL diff --git a/skills/page-template-campaign/skill.yaml b/skills/page-template-campaign/skill.yaml new file mode 100644 index 0000000..434af6e --- /dev/null +++ b/skills/page-template-campaign/skill.yaml @@ -0,0 +1,21 @@ +name: page-template-campaign +version: 1.0.0 +label: 商品促销模板 +description: 首屏 CTA + 卖点区 + 活动优惠,适用于商品宣传与活动落地页 +catalog: + label: 商品促销模板 + description: 首屏 CTA + 卖点区 + 活动优惠,适用于商品宣传与活动落地页 + previewImage: /assets/template-previews/campaign-cta.svg + priceCents: 1290 + billingMode: one_time + sortOrder: 20 +trigger: + keywords: + - 促销模板 + - 商品模板 + - campaign-cta +router: + promptKey: page-template-campaign + priority: 25 +executors: + - goose diff --git a/skills/page-template-campaign/templates/campaign-cta.html b/skills/page-template-campaign/templates/campaign-cta.html new file mode 100644 index 0000000..6e0ea6c --- /dev/null +++ b/skills/page-template-campaign/templates/campaign-cta.html @@ -0,0 +1,43 @@ + + + + + + {{PAGE_TITLE}} + + + + + +
+
+
{{PRODUCT_NAME}}
+

{{HERO_HEADLINE}}

+

{{HERO_SUBTITLE}}

+ {{CTA_LABEL}} +
+
{{PRODUCT_NAME}}
+
+
{{SELLING_POINTS_HTML}}
+
{{OFFER_HTML}}
+
{{CTA_LABEL}}
+

TKMind · 智趣

+ + diff --git a/skills/page-template-charity/SKILL.md b/skills/page-template-charity/SKILL.md new file mode 100644 index 0000000..49bb325 --- /dev/null +++ b/skills/page-template-charity/SKILL.md @@ -0,0 +1,11 @@ +--- +name: page-template-charity +description: 情感 Hero + 项目故事 + 捐赠 CTA,适合公益与 CSR 活动 +--- + +# 公益募捐 + +1. load_skill → `page-template-charity` +2. read_file → `.agents/skills/page-template-charity/templates/charity-cause.html` +3. 替换全部占位符后 write_file → `public/*.html` +4. 遵守 static-page-publish 回复 Markdown 链接 diff --git a/skills/page-template-charity/skill.yaml b/skills/page-template-charity/skill.yaml new file mode 100644 index 0000000..725fb2b --- /dev/null +++ b/skills/page-template-charity/skill.yaml @@ -0,0 +1,19 @@ +name: page-template-charity +version: 1.0.0 +label: 公益募捐 +description: 情感 Hero + 项目故事 + 捐赠 CTA,适合公益与 CSR 活动 +catalog: + label: 公益募捐 + description: 情感 Hero + 项目故事 + 捐赠 CTA,适合公益与 CSR 活动 + previewImage: /assets/template-previews/charity-cause.svg + priceCents: 0 + billingMode: free + sortOrder: 190 +trigger: + keywords: + - 公益募捐 +router: + promptKey: page-template-charity + priority: 25 +executors: + - goose diff --git a/skills/page-template-charity/templates/charity-cause.html b/skills/page-template-charity/templates/charity-cause.html new file mode 100644 index 0000000..46b185b --- /dev/null +++ b/skills/page-template-charity/templates/charity-cause.html @@ -0,0 +1,30 @@ + + + + + {{PAGE_TITLE}} + + + + +

{{CAUSE_TITLE}}

{{CAUSE_SUBTITLE}}

+
+
{{IMPACT_HTML}}
+
{{STORY_HTML}}
+ +
+

TKMind · 智趣

+ + diff --git a/skills/page-template-course/SKILL.md b/skills/page-template-course/SKILL.md new file mode 100644 index 0000000..ecee1b9 --- /dev/null +++ b/skills/page-template-course/SKILL.md @@ -0,0 +1,11 @@ +--- +name: page-template-course +description: 课程大纲 + 讲师介绍 + 报名 CTA,适用于知识付费/训练营 +--- + +# 课程落地页模板 + +1. load_skill → `page-template-course` +2. read_file → `.agents/skills/page-template-course/templates/course-landing.html` +3. 替换占位符后 write_file → `public/*.html` +4. 遵守 static-page-publish 回复 Markdown 链接 diff --git a/skills/page-template-course/skill.yaml b/skills/page-template-course/skill.yaml new file mode 100644 index 0000000..b55efda --- /dev/null +++ b/skills/page-template-course/skill.yaml @@ -0,0 +1,20 @@ +name: page-template-course +version: 1.0.0 +label: 课程落地页模板 +description: 课程大纲 + 讲师介绍 + 报名 CTA,适用于知识付费/训练营 +catalog: + label: 课程落地页模板 + description: 课程大纲 + 讲师介绍 + 报名 CTA,适用于知识付费/训练营 + previewImage: /assets/template-previews/course-landing.svg + priceCents: 1390 + billingMode: one_time + sortOrder: 70 +trigger: + keywords: + - 课程模板 + - course-landing +router: + promptKey: page-template-course + priority: 25 +executors: + - goose diff --git a/skills/page-template-course/templates/course-landing.html b/skills/page-template-course/templates/course-landing.html new file mode 100644 index 0000000..f6de6cb --- /dev/null +++ b/skills/page-template-course/templates/course-landing.html @@ -0,0 +1,30 @@ + + + + + + {{PAGE_TITLE}} + + + + + +

{{COURSE_TITLE}}

{{COURSE_SUBTITLE}}

{{CTA_LABEL}}
+
{{SYLLABUS_HTML}}
+

TKMind · 智趣

+ + diff --git a/skills/page-template-event/SKILL.md b/skills/page-template-event/SKILL.md new file mode 100644 index 0000000..ed27238 --- /dev/null +++ b/skills/page-template-event/SKILL.md @@ -0,0 +1,11 @@ +--- +name: page-template-event +description: 时间地点 + 议程亮点,适用于发布会、沙龙、线下活动 +--- + +# 活动邀请模板 + +1. load_skill → `page-template-event` +2. read_file → `.agents/skills/page-template-event/templates/event-invite.html` +3. 替换占位符后 write_file → `public/*.html` +4. 遵守 static-page-publish 回复 Markdown 链接 diff --git a/skills/page-template-event/skill.yaml b/skills/page-template-event/skill.yaml new file mode 100644 index 0000000..38a5c0c --- /dev/null +++ b/skills/page-template-event/skill.yaml @@ -0,0 +1,20 @@ +name: page-template-event +version: 1.0.0 +label: 活动邀请模板 +description: 时间地点 + 议程亮点,适用于发布会、沙龙、线下活动 +catalog: + label: 活动邀请模板 + description: 时间地点 + 议程亮点,适用于发布会、沙龙、线下活动 + previewImage: /assets/template-previews/event-invite.svg + priceCents: 990 + billingMode: one_time + sortOrder: 60 +trigger: + keywords: + - 活动邀请 + - event-invite +router: + promptKey: page-template-event + priority: 25 +executors: + - goose diff --git a/skills/page-template-event/templates/event-invite.html b/skills/page-template-event/templates/event-invite.html new file mode 100644 index 0000000..1357e07 --- /dev/null +++ b/skills/page-template-event/templates/event-invite.html @@ -0,0 +1,30 @@ + + + + + + {{PAGE_TITLE}} + + + + + +
+

{{EVENT_DATE}} · {{EVENT_CITY}}

+

{{EVENT_TITLE}}

+

{{EVENT_INTRO}}

+
{{AGENDA_HTML}}
+
+

TKMind · 智趣

+ + diff --git a/skills/page-template-fitness/SKILL.md b/skills/page-template-fitness/SKILL.md new file mode 100644 index 0000000..7f21f79 --- /dev/null +++ b/skills/page-template-fitness/SKILL.md @@ -0,0 +1,11 @@ +--- +name: page-template-fitness +description: 课程表 + 教练介绍 + 预约 CTA,适用于健身房/瑜伽馆 +--- + +# 健身课程模板 + +1. load_skill → `page-template-fitness` +2. read_file → `.agents/skills/page-template-fitness/templates/fitness-club.html` +3. 替换占位符后 write_file → `public/*.html` +4. 遵守 static-page-publish 回复 Markdown 链接 diff --git a/skills/page-template-fitness/skill.yaml b/skills/page-template-fitness/skill.yaml new file mode 100644 index 0000000..9be56b3 --- /dev/null +++ b/skills/page-template-fitness/skill.yaml @@ -0,0 +1,20 @@ +name: page-template-fitness +version: 1.0.0 +label: 健身课程模板 +description: 课程表 + 教练介绍 + 预约 CTA,适用于健身房/瑜伽馆 +catalog: + label: 健身课程模板 + description: 课程表 + 教练介绍 + 预约 CTA,适用于健身房/瑜伽馆 + previewImage: /assets/template-previews/fitness-club.svg + priceCents: 1090 + billingMode: one_time + sortOrder: 120 +trigger: + keywords: + - 健身模板 + - 课程表 +router: + promptKey: page-template-fitness + priority: 25 +executors: + - goose diff --git a/skills/page-template-fitness/templates/fitness-club.html b/skills/page-template-fitness/templates/fitness-club.html new file mode 100644 index 0000000..f40ffd8 --- /dev/null +++ b/skills/page-template-fitness/templates/fitness-club.html @@ -0,0 +1,29 @@ + + + + + + {{PAGE_TITLE}} + + + + + +

{{CLUB_NAME}}

{{CLUB_SLOGAN}}

+
{{SCHEDULE_HTML}}
+

TKMind · 智趣

+ + diff --git a/skills/page-template-hotel/SKILL.md b/skills/page-template-hotel/SKILL.md new file mode 100644 index 0000000..8edb2ab --- /dev/null +++ b/skills/page-template-hotel/SKILL.md @@ -0,0 +1,11 @@ +--- +name: page-template-hotel +description: 大图 Hero + 设施亮点 + 房型卡片,适合精品酒店与民宿 +--- + +# 酒店民宿 + +1. load_skill → `page-template-hotel` +2. read_file → `.agents/skills/page-template-hotel/templates/hotel-retreat.html` +3. 替换全部占位符后 write_file → `public/*.html` +4. 遵守 static-page-publish 回复 Markdown 链接 diff --git a/skills/page-template-hotel/skill.yaml b/skills/page-template-hotel/skill.yaml new file mode 100644 index 0000000..8e8aafd --- /dev/null +++ b/skills/page-template-hotel/skill.yaml @@ -0,0 +1,19 @@ +name: page-template-hotel +version: 1.0.0 +label: 酒店民宿 +description: 大图 Hero + 设施亮点 + 房型卡片,适合精品酒店与民宿 +catalog: + label: 酒店民宿 + description: 大图 Hero + 设施亮点 + 房型卡片,适合精品酒店与民宿 + previewImage: /assets/template-previews/hotel-retreat.svg + priceCents: 1190 + billingMode: one_time + sortOrder: 150 +trigger: + keywords: + - 酒店民宿 +router: + promptKey: page-template-hotel + priority: 25 +executors: + - goose diff --git a/skills/page-template-hotel/templates/hotel-retreat.html b/skills/page-template-hotel/templates/hotel-retreat.html new file mode 100644 index 0000000..1cd8faf --- /dev/null +++ b/skills/page-template-hotel/templates/hotel-retreat.html @@ -0,0 +1,35 @@ + + + + + {{PAGE_TITLE}} + + + + +
+
{{HOTEL_NAME}}

{{HOTEL_TAGLINE}}

+
+
+
{{AMENITIES_HTML}}
+
{{ROOMS_HTML}}
+ {{BOOKING_CTA_LABEL}} +
+

TKMind · 智趣

+ + diff --git a/skills/page-template-photography/SKILL.md b/skills/page-template-photography/SKILL.md new file mode 100644 index 0000000..4947102 --- /dev/null +++ b/skills/page-template-photography/SKILL.md @@ -0,0 +1,11 @@ +--- +name: page-template-photography +description: 全屏 Hero + 瀑布流作品区,适合摄影师与视觉艺术家 +--- + +# 摄影画廊 + +1. load_skill → `page-template-photography` +2. read_file → `.agents/skills/page-template-photography/templates/photo-gallery.html` +3. 替换全部占位符后 write_file → `public/*.html` +4. 遵守 static-page-publish 回复 Markdown 链接 diff --git a/skills/page-template-photography/skill.yaml b/skills/page-template-photography/skill.yaml new file mode 100644 index 0000000..5665460 --- /dev/null +++ b/skills/page-template-photography/skill.yaml @@ -0,0 +1,19 @@ +name: page-template-photography +version: 1.0.0 +label: 摄影画廊 +description: 全屏 Hero + 瀑布流作品区,适合摄影师与视觉艺术家 +catalog: + label: 摄影画廊 + description: 全屏 Hero + 瀑布流作品区,适合摄影师与视觉艺术家 + previewImage: /assets/template-previews/photo-gallery.svg + priceCents: 990 + billingMode: one_time + sortOrder: 170 +trigger: + keywords: + - 摄影画廊 +router: + promptKey: page-template-photography + priority: 25 +executors: + - goose diff --git a/skills/page-template-photography/templates/photo-gallery.html b/skills/page-template-photography/templates/photo-gallery.html new file mode 100644 index 0000000..58c6d75 --- /dev/null +++ b/skills/page-template-photography/templates/photo-gallery.html @@ -0,0 +1,28 @@ + + + + + {{PAGE_TITLE}} + + + + +

{{PHOTOGRAPHER_NAME}}

{{PHOTOGRAPHER_BIO}}

+
+

TKMind · 智趣

+ + diff --git a/skills/page-template-podcast/SKILL.md b/skills/page-template-podcast/SKILL.md new file mode 100644 index 0000000..c5bc84d --- /dev/null +++ b/skills/page-template-podcast/SKILL.md @@ -0,0 +1,11 @@ +--- +name: page-template-podcast +description: 封面 Hero + 单集列表 + 订阅引导,适合播客与音频栏目 +--- + +# 播客节目 + +1. load_skill → `page-template-podcast` +2. read_file → `.agents/skills/page-template-podcast/templates/podcast-show.html` +3. 替换全部占位符后 write_file → `public/*.html` +4. 遵守 static-page-publish 回复 Markdown 链接 diff --git a/skills/page-template-podcast/skill.yaml b/skills/page-template-podcast/skill.yaml new file mode 100644 index 0000000..8def0b7 --- /dev/null +++ b/skills/page-template-podcast/skill.yaml @@ -0,0 +1,19 @@ +name: page-template-podcast +version: 1.0.0 +label: 播客节目 +description: 封面 Hero + 单集列表 + 订阅引导,适合播客与音频栏目 +catalog: + label: 播客节目 + description: 封面 Hero + 单集列表 + 订阅引导,适合播客与音频栏目 + previewImage: /assets/template-previews/podcast-show.svg + priceCents: 890 + billingMode: one_time + sortOrder: 180 +trigger: + keywords: + - 播客节目 +router: + promptKey: page-template-podcast + priority: 25 +executors: + - goose diff --git a/skills/page-template-podcast/templates/podcast-show.html b/skills/page-template-podcast/templates/podcast-show.html new file mode 100644 index 0000000..6333458 --- /dev/null +++ b/skills/page-template-podcast/templates/podcast-show.html @@ -0,0 +1,34 @@ + + + + + {{PAGE_TITLE}} + + + + +
+ {{SHOW_NAME}} +

{{SHOW_NAME}}

{{SHOW_TAGLINE}}

+
+
+
{{EPISODES_HTML}}
+ +
+

TKMind · 智趣

+ + diff --git a/skills/page-template-portfolio/SKILL.md b/skills/page-template-portfolio/SKILL.md new file mode 100644 index 0000000..a6c1052 --- /dev/null +++ b/skills/page-template-portfolio/SKILL.md @@ -0,0 +1,11 @@ +--- +name: page-template-portfolio +description: 网格卡片 + 项目简介,适用于设计师、摄影师、创作者 +--- + +# 作品集模板 + +1. load_skill → `page-template-portfolio` +2. read_file → `.agents/skills/page-template-portfolio/templates/portfolio-grid.html` +3. 替换占位符后 write_file → `public/*.html` +4. 遵守 static-page-publish 回复 Markdown 链接 diff --git a/skills/page-template-portfolio/skill.yaml b/skills/page-template-portfolio/skill.yaml new file mode 100644 index 0000000..a50b321 --- /dev/null +++ b/skills/page-template-portfolio/skill.yaml @@ -0,0 +1,20 @@ +name: page-template-portfolio +version: 1.0.0 +label: 作品集模板 +description: 网格卡片 + 项目简介,适用于设计师、摄影师、创作者 +catalog: + label: 作品集模板 + description: 网格卡片 + 项目简介,适用于设计师、摄影师、创作者 + previewImage: /assets/template-previews/portfolio-grid.svg + priceCents: 1190 + billingMode: one_time + sortOrder: 50 +trigger: + keywords: + - 作品集 + - portfolio +router: + promptKey: page-template-portfolio + priority: 25 +executors: + - goose diff --git a/skills/page-template-portfolio/templates/portfolio-grid.html b/skills/page-template-portfolio/templates/portfolio-grid.html new file mode 100644 index 0000000..264a3d6 --- /dev/null +++ b/skills/page-template-portfolio/templates/portfolio-grid.html @@ -0,0 +1,32 @@ + + + + + + {{PAGE_TITLE}} + + + + + +
+

{{CREATOR_NAME}}

+

{{CREATOR_TAGLINE}}

+
+
{{PROJECT_CARDS_HTML}}
+

TKMind · 智趣

+ + diff --git a/skills/page-template-product-grid/SKILL.md b/skills/page-template-product-grid/SKILL.md new file mode 100644 index 0000000..682c4e4 --- /dev/null +++ b/skills/page-template-product-grid/SKILL.md @@ -0,0 +1,11 @@ +--- +name: page-template-product-grid +description: 多 SKU 卡片网格 + 价格标签,适用于系列商品展示 +--- + +# 产品网格模板 + +1. load_skill → `page-template-product-grid` +2. read_file → `.agents/skills/page-template-product-grid/templates/product-grid.html` +3. 替换占位符后 write_file → `public/*.html` +4. 遵守 static-page-publish 回复 Markdown 链接 diff --git a/skills/page-template-product-grid/skill.yaml b/skills/page-template-product-grid/skill.yaml new file mode 100644 index 0000000..78e9278 --- /dev/null +++ b/skills/page-template-product-grid/skill.yaml @@ -0,0 +1,20 @@ +name: page-template-product-grid +version: 1.0.0 +label: 产品网格模板 +description: 多 SKU 卡片网格 + 价格标签,适用于系列商品展示 +catalog: + label: 产品网格模板 + description: 多 SKU 卡片网格 + 价格标签,适用于系列商品展示 + previewImage: /assets/template-previews/product-grid.svg + priceCents: 1190 + billingMode: one_time + sortOrder: 130 +trigger: + keywords: + - 产品网格 + - sku +router: + promptKey: page-template-product-grid + priority: 25 +executors: + - goose diff --git a/skills/page-template-product-grid/templates/product-grid.html b/skills/page-template-product-grid/templates/product-grid.html new file mode 100644 index 0000000..87b2d71 --- /dev/null +++ b/skills/page-template-product-grid/templates/product-grid.html @@ -0,0 +1,29 @@ + + + + + + {{PAGE_TITLE}} + + + + + +

{{COLLECTION_TITLE}}

{{COLLECTION_SUBTITLE}}

+
{{PRODUCT_GRID_HTML}}
+

TKMind · 智趣

+ + diff --git a/skills/page-template-realestate/SKILL.md b/skills/page-template-realestate/SKILL.md new file mode 100644 index 0000000..db2d2f5 --- /dev/null +++ b/skills/page-template-realestate/SKILL.md @@ -0,0 +1,11 @@ +--- +name: page-template-realestate +description: 大图 + 参数表 + 亮点,适用于房源、展厅、空间出租 +--- + +# 房产展示模板 + +1. load_skill → `page-template-realestate` +2. read_file → `.agents/skills/page-template-realestate/templates/realestate-listing.html` +3. 替换占位符后 write_file → `public/*.html` +4. 遵守 static-page-publish 回复 Markdown 链接 diff --git a/skills/page-template-realestate/skill.yaml b/skills/page-template-realestate/skill.yaml new file mode 100644 index 0000000..5461054 --- /dev/null +++ b/skills/page-template-realestate/skill.yaml @@ -0,0 +1,20 @@ +name: page-template-realestate +version: 1.0.0 +label: 房产展示模板 +description: 大图 + 参数表 + 亮点,适用于房源、展厅、空间出租 +catalog: + label: 房产展示模板 + description: 大图 + 参数表 + 亮点,适用于房源、展厅、空间出租 + previewImage: /assets/template-previews/realestate-listing.svg + priceCents: 1590 + billingMode: one_time + sortOrder: 80 +trigger: + keywords: + - 房产模板 + - 房源 +router: + promptKey: page-template-realestate + priority: 25 +executors: + - goose diff --git a/skills/page-template-realestate/templates/realestate-listing.html b/skills/page-template-realestate/templates/realestate-listing.html new file mode 100644 index 0000000..5a9b0d0 --- /dev/null +++ b/skills/page-template-realestate/templates/realestate-listing.html @@ -0,0 +1,35 @@ + + + + + + {{PAGE_TITLE}} + + + + + +
+
+

{{LISTING_TITLE}}

+

{{LISTING_LOCATION}}

+ {{SPECS_TABLE_HTML}}
+
{{HIGHLIGHTS_HTML}}
+
+

TKMind · 智趣

+ + diff --git a/skills/page-template-restaurant/SKILL.md b/skills/page-template-restaurant/SKILL.md new file mode 100644 index 0000000..b514921 --- /dev/null +++ b/skills/page-template-restaurant/SKILL.md @@ -0,0 +1,11 @@ +--- +name: page-template-restaurant +description: 分类菜单 + 推荐菜卡片,适用于餐厅、咖啡馆、私房菜 +--- + +# 餐饮菜单模板 + +1. load_skill → `page-template-restaurant` +2. read_file → `.agents/skills/page-template-restaurant/templates/restaurant-menu.html` +3. 替换占位符后 write_file → `public/*.html` +4. 遵守 static-page-publish 回复 Markdown 链接 diff --git a/skills/page-template-restaurant/skill.yaml b/skills/page-template-restaurant/skill.yaml new file mode 100644 index 0000000..0bcd60e --- /dev/null +++ b/skills/page-template-restaurant/skill.yaml @@ -0,0 +1,21 @@ +name: page-template-restaurant +version: 1.0.0 +label: 餐饮菜单模板 +description: 分类菜单 + 推荐菜卡片,适用于餐厅、咖啡馆、私房菜 +catalog: + label: 餐饮菜单模板 + description: 分类菜单 + 推荐菜卡片,适用于餐厅、咖啡馆、私房菜 + previewImage: /assets/template-previews/restaurant-menu.svg + priceCents: 890 + billingMode: one_time + sortOrder: 40 +trigger: + keywords: + - 餐饮模板 + - 菜单模板 + - restaurant-menu +router: + promptKey: page-template-restaurant + priority: 25 +executors: + - goose diff --git a/skills/page-template-restaurant/templates/restaurant-menu.html b/skills/page-template-restaurant/templates/restaurant-menu.html new file mode 100644 index 0000000..8fca448 --- /dev/null +++ b/skills/page-template-restaurant/templates/restaurant-menu.html @@ -0,0 +1,35 @@ + + + + + + {{PAGE_TITLE}} + + + + + +
+
{{VENUE_TAG}}
+

{{VENUE_NAME}}

+

{{VENUE_SUBTITLE}}

+
+
{{MENU_SECTIONS_HTML}}
+

TKMind · 智趣

+ + diff --git a/skills/page-template-resume/SKILL.md b/skills/page-template-resume/SKILL.md new file mode 100644 index 0000000..54dd16c --- /dev/null +++ b/skills/page-template-resume/SKILL.md @@ -0,0 +1,11 @@ +--- +name: page-template-resume +description: 头像区 + 经历时间线 + 技能标签,适用于个人主页/求职 +--- + +# 个人简历模板 + +1. load_skill → `page-template-resume` +2. read_file → `.agents/skills/page-template-resume/templates/resume-profile.html` +3. 替换占位符后 write_file → `public/*.html` +4. 遵守 static-page-publish 回复 Markdown 链接 diff --git a/skills/page-template-resume/skill.yaml b/skills/page-template-resume/skill.yaml new file mode 100644 index 0000000..909e032 --- /dev/null +++ b/skills/page-template-resume/skill.yaml @@ -0,0 +1,20 @@ +name: page-template-resume +version: 1.0.0 +label: 个人简历模板 +description: 头像区 + 经历时间线 + 技能标签,适用于个人主页/求职 +catalog: + label: 个人简历模板 + description: 头像区 + 经历时间线 + 技能标签,适用于个人主页/求职 + previewImage: /assets/template-previews/resume-profile.svg + priceCents: 690 + billingMode: one_time + sortOrder: 100 +trigger: + keywords: + - 简历模板 + - resume +router: + promptKey: page-template-resume + priority: 25 +executors: + - goose diff --git a/skills/page-template-resume/templates/resume-profile.html b/skills/page-template-resume/templates/resume-profile.html new file mode 100644 index 0000000..9a922e0 --- /dev/null +++ b/skills/page-template-resume/templates/resume-profile.html @@ -0,0 +1,36 @@ + + + + + + {{PAGE_TITLE}} + + + + + +
+
+

{{FULL_NAME}}

+

{{JOB_TITLE}}

+

{{SUMMARY}}

+
+
{{EXPERIENCE_HTML}}
+
{{SKILLS_HTML}}
+
+

TKMind · 智趣

+ + diff --git a/skills/page-template-saas/SKILL.md b/skills/page-template-saas/SKILL.md new file mode 100644 index 0000000..f84f1d1 --- /dev/null +++ b/skills/page-template-saas/SKILL.md @@ -0,0 +1,11 @@ +--- +name: page-template-saas +description: 渐变 Hero + 功能特性 + 定价区,适合 B2B 软件落地页 +--- + +# SaaS 产品页 + +1. load_skill → `page-template-saas` +2. read_file → `.agents/skills/page-template-saas/templates/saas-landing.html` +3. 替换全部占位符后 write_file → `public/*.html` +4. 遵守 static-page-publish 回复 Markdown 链接 diff --git a/skills/page-template-saas/skill.yaml b/skills/page-template-saas/skill.yaml new file mode 100644 index 0000000..42c7abf --- /dev/null +++ b/skills/page-template-saas/skill.yaml @@ -0,0 +1,19 @@ +name: page-template-saas +version: 1.0.0 +label: SaaS 产品页 +description: 渐变 Hero + 功能特性 + 定价区,适合 B2B 软件落地页 +catalog: + label: SaaS 产品页 + description: 渐变 Hero + 功能特性 + 定价区,适合 B2B 软件落地页 + previewImage: /assets/template-previews/saas-landing.svg + priceCents: 1290 + billingMode: one_time + sortOrder: 140 +trigger: + keywords: + - SaaS 产品页 +router: + promptKey: page-template-saas + priority: 25 +executors: + - goose diff --git a/skills/page-template-saas/templates/saas-landing.html b/skills/page-template-saas/templates/saas-landing.html new file mode 100644 index 0000000..93c2a0e --- /dev/null +++ b/skills/page-template-saas/templates/saas-landing.html @@ -0,0 +1,37 @@ + + + + + {{PAGE_TITLE}} + + + + +
+ {{PRODUCT_NAME}} +

{{HERO_HEADLINE}}

+

{{HERO_SUBTITLE}}

+ {{CTA_LABEL}} +
{{PRODUCT_NAME}}
+
+
{{FEATURES_HTML}}
+
{{PRICING_HTML}}
+

TKMind · 智趣

+ + diff --git a/skills/page-template-startup/SKILL.md b/skills/page-template-startup/SKILL.md new file mode 100644 index 0000000..cc7c18c --- /dev/null +++ b/skills/page-template-startup/SKILL.md @@ -0,0 +1,11 @@ +--- +name: page-template-startup +description: Pitch Deck 风格单页:愿景 + 数据指标 + 团队 + CTA +--- + +# 创业融资 + +1. load_skill → `page-template-startup` +2. read_file → `.agents/skills/page-template-startup/templates/startup-pitch.html` +3. 替换全部占位符后 write_file → `public/*.html` +4. 遵守 static-page-publish 回复 Markdown 链接 diff --git a/skills/page-template-startup/skill.yaml b/skills/page-template-startup/skill.yaml new file mode 100644 index 0000000..efbbf23 --- /dev/null +++ b/skills/page-template-startup/skill.yaml @@ -0,0 +1,19 @@ +name: page-template-startup +version: 1.0.0 +label: 创业融资 +description: Pitch Deck 风格单页:愿景 + 数据指标 + 团队 + CTA +catalog: + label: 创业融资 + description: Pitch Deck 风格单页:愿景 + 数据指标 + 团队 + CTA + previewImage: /assets/template-previews/startup-pitch.svg + priceCents: 1490 + billingMode: one_time + sortOrder: 200 +trigger: + keywords: + - 创业融资 +router: + promptKey: page-template-startup + priority: 25 +executors: + - goose diff --git a/skills/page-template-startup/templates/startup-pitch.html b/skills/page-template-startup/templates/startup-pitch.html new file mode 100644 index 0000000..436dcba --- /dev/null +++ b/skills/page-template-startup/templates/startup-pitch.html @@ -0,0 +1,38 @@ + + + + + {{PAGE_TITLE}} + + + + +
+

{{STARTUP_NAME}}

+

{{PITCH_HEADLINE}}

+

{{PITCH_SUBTITLE}}

+ {{CTA_LABEL}} +
+
+
{{METRICS_HTML}}
+
{{VISION_HTML}}
+
{{TEAM_HTML}}
+
+

TKMind · 智趣

+ + diff --git a/skills/page-template-survey/SKILL.md b/skills/page-template-survey/SKILL.md new file mode 100644 index 0000000..b08d41e --- /dev/null +++ b/skills/page-template-survey/SKILL.md @@ -0,0 +1,38 @@ +--- +name: page-template-survey +description: 问卷 Page Data 模板:前台提交壳 + 后台只读壳,必须配合 page-data-collect +--- + +# 问卷 Page Data 模板 + +## 何时使用 + +- 用户选择了「问卷模板」或需要问卷/报名/台账且希望用固定视觉壳 +- **必须**同时走 `page-data-collect`:建表 → register → bind + +## 工作流 + +1. `load_skill` → `page-data-collect`(先匹配能力分支) +2. `load_skill` → `page-template-survey` +3. 建表 / register dataset(MCP `private_data_*`) +4. `read_file` 前台模板 → 填 `FORM_FIELDS_HTML`、`DATASET_ID` 等 → `write_file` `public/-survey.html` +5. `read_file` 后台模板 → `write_file` `public/-admin.html` +6. `private_data_bind_workspace_page` + 写 policy +7. 只返回 workspaceUrl(`/MindSpace/<用户ID>/public/...`) + +## 占位符(前台 survey-basic.html) + +| 占位符 | 说明 | +|--------|------| +| `PAGE_TITLE` / `PAGE_DESCRIPTION` | 标题与摘要 | +| `MINDSPACE_COVER_JSON` | cover | +| `SURVEY_HEADLINE` | 问卷标题 | +| `SURVEY_INTRO_HTML` | 说明文案 | +| `FORM_FIELDS_HTML` | 表单字段 HTML | +| `DATASET_ID` | 已注册 dataset id | +| `SUBMIT_BUTTON_LABEL` | 提交按钮文案 | + +## 禁止 + +- 跳过 bind / policy +- 使用 localStorage 或 SQLite diff --git a/skills/page-template-survey/skill.yaml b/skills/page-template-survey/skill.yaml new file mode 100644 index 0000000..d5bea32 --- /dev/null +++ b/skills/page-template-survey/skill.yaml @@ -0,0 +1,21 @@ +name: page-template-survey +version: 1.0.0 +label: 问卷 Page Data 模板 +description: 含 page-data-client 的问卷前台壳 + 后台只读壳,需配合 page-data-collect +catalog: + label: 问卷 Page Data 模板 + description: 问卷前台 + 后台只读壳,需配合 page-data-collect 建表与 bind + previewImage: /assets/template-previews/survey-basic.svg + priceCents: 1490 + billingMode: one_time + sortOrder: 30 +trigger: + keywords: + - 问卷模板 + - 报名模板 + - survey-template +router: + promptKey: page-template-survey + priority: 25 +executors: + - goose diff --git a/skills/page-template-survey/templates/survey-basic.html b/skills/page-template-survey/templates/survey-basic.html new file mode 100644 index 0000000..6586346 --- /dev/null +++ b/skills/page-template-survey/templates/survey-basic.html @@ -0,0 +1,55 @@ + + + + + + {{PAGE_TITLE}} + + + + + +
+
+

{{SURVEY_HEADLINE}}

+
{{SURVEY_INTRO_HTML}}
+
+ {{FORM_FIELDS_HTML}} + +
+
+
+
+

TKMind · 智趣

+ + + + diff --git a/skills/page-template-travel/SKILL.md b/skills/page-template-travel/SKILL.md new file mode 100644 index 0000000..297de3f --- /dev/null +++ b/skills/page-template-travel/SKILL.md @@ -0,0 +1,41 @@ +--- +name: page-template-travel +description: 旅游 Hero 页面模板:深色沉浸布局,适用于旅游/美食/城市攻略 +--- + +# 旅游 Hero 模板 + +## 何时使用 + +- 用户选择了「旅游模板」或 prompt 含 `templateId=travel-hero` +- 用户需求为旅游攻略、城市介绍、美食指南等**纯静态展示页** + +## 工作流 + +1. `load_skill` → `page-template-travel` +2. `read_file` → `.agents/skills/page-template-travel/templates/travel-hero.html` +3. 根据用户主题生成 `MAIN_CONTENT_HTML`(章节、卡片描述等) +4. 替换全部 `{{PLACEHOLDER}}`,确认无残留 +5. `write_file` → `public/.html` +6. 按 `static-page-publish` 回复 Markdown 可点击链接 + +## 占位符 + +| 占位符 | 说明 | +|--------|------| +| `PAGE_TITLE` | 页面标题 | +| `PAGE_DESCRIPTION` | description 摘要 | +| `MINDSPACE_COVER_JSON` | mindspace-cover JSON(含 tag/accent/cover) | +| `HERO_EYEBROW` | 首屏小标签 | +| `HERO_HEADLINE` | 主标题 | +| `HERO_SUBTITLE` | 副标题 | +| `HERO_IMAGE` | hero 图相对路径,如 `assets/hero.jpg` | +| `MAIN_CONTENT_HTML` | 正文 HTML 片段 | +| `ACCENT` / `ACCENT2` | 主色,与 cover 一致 | + +## 禁止 + +- 未选模板时不要用本 skill +- 需要持久化表单/问卷时改用 `page-template-survey` + `page-data-collect` +- **禁止**重写 ` + + +
+
+
{{HERO_EYEBROW}}
+

{{HERO_HEADLINE}}

+

{{HERO_SUBTITLE}}

+
+
+
{{MAIN_CONTENT_HTML}}
+

TKMind · 智趣

+ + diff --git a/skills/page-template-wedding/SKILL.md b/skills/page-template-wedding/SKILL.md new file mode 100644 index 0000000..906db3c --- /dev/null +++ b/skills/page-template-wedding/SKILL.md @@ -0,0 +1,11 @@ +--- +name: page-template-wedding +description: 浪漫邀请函 + 时间地点 + 祝福语,适用于婚礼/庆典 +--- + +# 婚礼邀请模板 + +1. load_skill → `page-template-wedding` +2. read_file → `.agents/skills/page-template-wedding/templates/wedding-invite.html` +3. 替换占位符后 write_file → `public/*.html` +4. 遵守 static-page-publish 回复 Markdown 链接 diff --git a/skills/page-template-wedding/skill.yaml b/skills/page-template-wedding/skill.yaml new file mode 100644 index 0000000..bab0172 --- /dev/null +++ b/skills/page-template-wedding/skill.yaml @@ -0,0 +1,20 @@ +name: page-template-wedding +version: 1.0.0 +label: 婚礼邀请模板 +description: 浪漫邀请函 + 时间地点 + 祝福语,适用于婚礼/庆典 +catalog: + label: 婚礼邀请模板 + description: 浪漫邀请函 + 时间地点 + 祝福语,适用于婚礼/庆典 + previewImage: /assets/template-previews/wedding-invite.svg + priceCents: 1290 + billingMode: one_time + sortOrder: 110 +trigger: + keywords: + - 婚礼模板 + - 邀请函 +router: + promptKey: page-template-wedding + priority: 25 +executors: + - goose diff --git a/skills/page-template-wedding/templates/wedding-invite.html b/skills/page-template-wedding/templates/wedding-invite.html new file mode 100644 index 0000000..2d99e14 --- /dev/null +++ b/skills/page-template-wedding/templates/wedding-invite.html @@ -0,0 +1,30 @@ + + + + + + {{PAGE_TITLE}} + + + + + +
+

诚挚邀请

+

{{COUPLE_NAMES}}

+

{{WEDDING_DATE}}

+

{{WEDDING_VENUE}}

+
{{DETAILS_HTML}}
+
+

TKMind · 智趣

+ + diff --git a/skills/static-page-publish/SKILL.md b/skills/static-page-publish/SKILL.md index 5e14b77..33874f7 100644 --- a/skills/static-page-publish/SKILL.md +++ b/skills/static-page-publish/SKILL.md @@ -77,13 +77,24 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 - 页面写入 `public/` 时,URL **必须**包含 `/public/` 路径段(与磁盘路径一致) - 域名严格按环境拼接,**禁止**使用 `http://本地服务/...` 等占位 host: - **本地开发**:`http://127.0.0.1:8081/MindSpace/<用户ID>/public/...`(MindSpace 公开页 + Page Data API) + - **本地开发(经 5173 UI 代理)**:`http://127.0.0.1:5173/MindSpace/<用户ID>/public/...`(5173 会把 `/MindSpace` 转发到 Portal;**不是** `5173/public/...`) - **生产**:`https://m.tkmind.cn/MindSpace/<用户ID>/public/...` 或环境配置的 `H5_PUBLIC_BASE_URL` +- **禁止的错误交付链接(anti-pattern)**: + - ❌ `http://127.0.0.1:5173/public/xxx.html` — 5173 是 Vite SPA,不托管工作区 `public/`,会回退 `index.html` + - ❌ `http://127.0.0.1:8081/public/xxx.html` — 缺少 `/MindSpace/<用户ID>/` + - ❌ 把磁盘路径 `public/xxx.html` 直接拼到任意 host 后面 + - ✅ 必须包含 **`/MindSpace/<用户ID>/public/`** 完整路径段(与 Portal 路由一致) +- **HTML 内资源路径不变**:``、`mindspace-cover.cover: "assets/hero.webp"` 等**相对页面文件**的路径仍然正确,不要改成绝对 URL - 拿不到真实前缀时,先给相对路径 `public/xxx.html` 说明,不要自己猜一个域名 - 标题用页面真实主题名 - 可同时给出相对路径(如 `public/malaysia-travel-guide.html`) - 说明:保存即生效,无需重启 - 若生成了长图,同时给 `[长图预览](.../public/malaysia-travel-guide.long.png)` 和 `[下载长图](.../public/malaysia-travel-guide.html?download=long-image)` +> **page-template-* 通用**:旅游、餐饮、问卷等所有可定价模板,落盘与交付链接规则与本节相同;模板 skill 只负责 HTML 壳与占位符,**不要**把 `public/xxx.html` 直接拼到 5173 或任意 host 后面。 +> +> **用户已点击「使用模板」时(硬性)**:必须先 `load_skill` → 对应 `page-template-*`,再 `read_file templates/*.html`,**仅替换 {{PLACEHOLDER}}** 后 `write_file`;禁止重写 `