From fde6503bdf174c1fd0df64acd1f43da70dcaa288 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 10 Aug 2026 08:06:12 +0800 Subject: [PATCH] feat(mindspace): add SEO/GEO delivery, page template catalog, and admin hooks Enable optional SEO/GEO injection and discovery routes for confirmed public pages while keeping private pages noindex. Add premium page template skills, portal catalog API, template shop UI, and Baidu push gated by memind_adm config. Co-authored-by: Cursor --- .env.example | 15 +- AGENTS.md | 4 + admin-bootstrap.mjs | 11 + admin-routes.mjs | 26 + admin-server.mjs | 1 + agent-run-routes.mjs | 50 + billing-recharge.mjs | 38 +- billing-recharge.test.mjs | 81 ++ chat-skills.mjs | 266 +++++- chat-skills.test.mjs | 25 + .../page-template-skill-design.md | 521 +++++++++++ docs/local-dev.md | 29 +- docs/regression-guards/README.md | 3 + docs/regression-guards/mindspace-seo-geo.md | 38 + mindspace-config.mjs | 109 +++ mindspace-config.test.mjs | 26 + mindspace-geo-tags.mjs | 168 ++++ mindspace-geo-tags.test.mjs | 30 + mindspace-h5-html-finish-guard.mjs | 20 + mindspace-index-policy.mjs | 87 ++ mindspace-index-policy.test.mjs | 68 ++ mindspace-public-delivery.mjs | 17 + mindspace-public-delivery.test.mjs | 32 + mindspace-public-finish-service.mjs | 1 + mindspace-publications.mjs | 14 +- mindspace-publications.test.mjs | 48 + mindspace-seo-discovery-service.mjs | 179 ++++ mindspace-seo-discovery-service.test.mjs | 33 + mindspace-seo-geo-delivery.mjs | 99 ++ mindspace-seo-geo-delivery.test.mjs | 47 + mindspace-seo-notify.mjs | 76 ++ mindspace-seo-notify.test.mjs | 86 ++ mindspace-seo-tags.mjs | 135 +++ mindspace-seo-tags.test.mjs | 36 + package.json | 5 + page-template-catalog.mjs | 682 ++++++++++++++ page-template-catalog.test.mjs | 297 ++++++ page-template-enforcement.mjs | 192 ++++ page-template-enforcement.test.mjs | 101 ++ page-template-preview-demos.mjs | 267 ++++++ .../template-previews/agency-studio.svg | 7 + .../assets/template-previews/blog-article.svg | 1 + .../assets/template-previews/campaign-cta.svg | 13 + .../template-previews/charity-cause.svg | 7 + .../template-previews/course-landing.svg | 1 + .../assets/template-previews/event-invite.svg | 1 + .../assets/template-previews/fitness-club.svg | 1 + .../template-previews/hotel-retreat.svg | 7 + .../template-previews/photo-gallery.svg | 7 + .../assets/template-previews/podcast-show.svg | 7 + .../template-previews/portfolio-grid.svg | 1 + .../assets/template-previews/product-grid.svg | 1 + .../template-previews/realestate-listing.svg | 1 + .../template-previews/restaurant-menu.svg | 1 + .../template-previews/resume-profile.svg | 1 + .../assets/template-previews/saas-landing.svg | 7 + .../template-previews/startup-pitch.svg | 7 + .../assets/template-previews/survey-basic.svg | 14 + .../assets/template-previews/travel-hero.svg | 20 + .../template-previews/wedding-invite.svg | 1 + release-gate/coverage.mjs | 10 +- schema.sql | 29 + scripts/bootstrap-premium-page-templates.mjs | 876 ++++++++++++++++++ scripts/check-mindspace-cover.mjs | 27 +- scripts/dev-core-daemon.mjs | 23 +- scripts/memind-runtime-profile.mjs | 6 + scripts/memind-runtime-profile.test.mjs | 9 + scripts/verify-portal-access-policy.mjs | 3 + scripts/verify-seo-discovery.mjs | 66 ++ scripts/verify-template-catalog-e2e.mjs | 211 +++++ scripts/verify-template-catalog-portal.mjs | 132 +++ server.mjs | 40 + server/portal-access-policy.mjs | 7 + server/portal-agent-runtime-routes.mjs | 2 + server/portal-auth-services-bootstrap.mjs | 23 +- server/portal-domain-services-bootstrap.mjs | 13 +- .../portal-domain-services-bootstrap.test.mjs | 16 +- server/portal-published-page-delivery.mjs | 52 ++ server/portal-seo-discovery-routes.mjs | 84 ++ server/portal-seo-discovery-routes.test.mjs | 61 ++ server/portal-template-catalog-routes.mjs | 125 +++ .../portal-template-catalog-routes.test.mjs | 102 ++ .../portal-workspace-publication-delivery.mjs | 28 + skills-registry.mjs | 26 + skills-registry.test.mjs | 3 + skills/page-template-agency/SKILL.md | 11 + skills/page-template-agency/skill.yaml | 19 + .../templates/agency-studio.html | 31 + skills/page-template-blog/SKILL.md | 11 + skills/page-template-blog/skill.yaml | 20 + .../templates/blog-article.html | 30 + skills/page-template-campaign/SKILL.md | 39 + skills/page-template-campaign/skill.yaml | 21 + .../templates/campaign-cta.html | 43 + skills/page-template-charity/SKILL.md | 11 + skills/page-template-charity/skill.yaml | 19 + .../templates/charity-cause.html | 30 + skills/page-template-course/SKILL.md | 11 + skills/page-template-course/skill.yaml | 20 + .../templates/course-landing.html | 30 + skills/page-template-event/SKILL.md | 11 + skills/page-template-event/skill.yaml | 20 + .../templates/event-invite.html | 30 + skills/page-template-fitness/SKILL.md | 11 + skills/page-template-fitness/skill.yaml | 20 + .../templates/fitness-club.html | 29 + skills/page-template-hotel/SKILL.md | 11 + skills/page-template-hotel/skill.yaml | 19 + .../templates/hotel-retreat.html | 35 + skills/page-template-photography/SKILL.md | 11 + skills/page-template-photography/skill.yaml | 19 + .../templates/photo-gallery.html | 28 + skills/page-template-podcast/SKILL.md | 11 + skills/page-template-podcast/skill.yaml | 19 + .../templates/podcast-show.html | 34 + skills/page-template-portfolio/SKILL.md | 11 + skills/page-template-portfolio/skill.yaml | 20 + .../templates/portfolio-grid.html | 32 + skills/page-template-product-grid/SKILL.md | 11 + skills/page-template-product-grid/skill.yaml | 20 + .../templates/product-grid.html | 29 + skills/page-template-realestate/SKILL.md | 11 + skills/page-template-realestate/skill.yaml | 20 + .../templates/realestate-listing.html | 35 + skills/page-template-restaurant/SKILL.md | 11 + skills/page-template-restaurant/skill.yaml | 21 + .../templates/restaurant-menu.html | 35 + skills/page-template-resume/SKILL.md | 11 + skills/page-template-resume/skill.yaml | 20 + .../templates/resume-profile.html | 36 + skills/page-template-saas/SKILL.md | 11 + skills/page-template-saas/skill.yaml | 19 + .../templates/saas-landing.html | 37 + skills/page-template-startup/SKILL.md | 11 + skills/page-template-startup/skill.yaml | 19 + .../templates/startup-pitch.html | 38 + skills/page-template-survey/SKILL.md | 38 + skills/page-template-survey/skill.yaml | 21 + .../templates/survey-basic.html | 55 ++ skills/page-template-travel/SKILL.md | 41 + skills/page-template-travel/skill.yaml | 21 + .../templates/travel-hero.html | 45 + skills/page-template-wedding/SKILL.md | 11 + skills/page-template-wedding/skill.yaml | 20 + .../templates/wedding-invite.html | 30 + skills/static-page-publish/SKILL.md | 37 + src/App.tsx | 4 + src/api/client.ts | 8 + src/api/page-template-catalog.ts | 73 ++ src/components/ChatPanel.tsx | 242 ++++- src/components/PageTemplateShopModal.tsx | 682 ++++++++++++++ src/components/SpaceChatPanel.tsx | 26 +- src/components/TemplatePreviewThumb.tsx | 37 + src/index.css | 787 ++++++++++++---- src/types.ts | 34 + src/utils/chatSkills.ts | 8 +- src/utils/template-shop-utils.ts | 85 ++ vite.config.ts | 4 + 158 files changed, 9194 insertions(+), 270 deletions(-) create mode 100644 docs/architecture/page-template-skill-design.md create mode 100644 docs/regression-guards/mindspace-seo-geo.md create mode 100644 mindspace-geo-tags.mjs create mode 100644 mindspace-geo-tags.test.mjs create mode 100644 mindspace-index-policy.mjs create mode 100644 mindspace-index-policy.test.mjs create mode 100644 mindspace-seo-discovery-service.mjs create mode 100644 mindspace-seo-discovery-service.test.mjs create mode 100644 mindspace-seo-geo-delivery.mjs create mode 100644 mindspace-seo-geo-delivery.test.mjs create mode 100644 mindspace-seo-notify.mjs create mode 100644 mindspace-seo-notify.test.mjs create mode 100644 mindspace-seo-tags.mjs create mode 100644 mindspace-seo-tags.test.mjs create mode 100644 page-template-catalog.mjs create mode 100644 page-template-catalog.test.mjs create mode 100644 page-template-enforcement.mjs create mode 100644 page-template-enforcement.test.mjs create mode 100644 page-template-preview-demos.mjs create mode 100644 public/assets/template-previews/agency-studio.svg create mode 100644 public/assets/template-previews/blog-article.svg create mode 100644 public/assets/template-previews/campaign-cta.svg create mode 100644 public/assets/template-previews/charity-cause.svg create mode 100644 public/assets/template-previews/course-landing.svg create mode 100644 public/assets/template-previews/event-invite.svg create mode 100644 public/assets/template-previews/fitness-club.svg create mode 100644 public/assets/template-previews/hotel-retreat.svg create mode 100644 public/assets/template-previews/photo-gallery.svg create mode 100644 public/assets/template-previews/podcast-show.svg create mode 100644 public/assets/template-previews/portfolio-grid.svg create mode 100644 public/assets/template-previews/product-grid.svg create mode 100644 public/assets/template-previews/realestate-listing.svg create mode 100644 public/assets/template-previews/restaurant-menu.svg create mode 100644 public/assets/template-previews/resume-profile.svg create mode 100644 public/assets/template-previews/saas-landing.svg create mode 100644 public/assets/template-previews/startup-pitch.svg create mode 100644 public/assets/template-previews/survey-basic.svg create mode 100644 public/assets/template-previews/travel-hero.svg create mode 100644 public/assets/template-previews/wedding-invite.svg create mode 100644 scripts/bootstrap-premium-page-templates.mjs create mode 100644 scripts/verify-seo-discovery.mjs create mode 100644 scripts/verify-template-catalog-e2e.mjs create mode 100644 scripts/verify-template-catalog-portal.mjs create mode 100644 server/portal-seo-discovery-routes.mjs create mode 100644 server/portal-seo-discovery-routes.test.mjs create mode 100644 server/portal-template-catalog-routes.mjs create mode 100644 server/portal-template-catalog-routes.test.mjs create mode 100644 skills/page-template-agency/SKILL.md create mode 100644 skills/page-template-agency/skill.yaml create mode 100644 skills/page-template-agency/templates/agency-studio.html create mode 100644 skills/page-template-blog/SKILL.md create mode 100644 skills/page-template-blog/skill.yaml create mode 100644 skills/page-template-blog/templates/blog-article.html create mode 100644 skills/page-template-campaign/SKILL.md create mode 100644 skills/page-template-campaign/skill.yaml create mode 100644 skills/page-template-campaign/templates/campaign-cta.html create mode 100644 skills/page-template-charity/SKILL.md create mode 100644 skills/page-template-charity/skill.yaml create mode 100644 skills/page-template-charity/templates/charity-cause.html create mode 100644 skills/page-template-course/SKILL.md create mode 100644 skills/page-template-course/skill.yaml create mode 100644 skills/page-template-course/templates/course-landing.html create mode 100644 skills/page-template-event/SKILL.md create mode 100644 skills/page-template-event/skill.yaml create mode 100644 skills/page-template-event/templates/event-invite.html create mode 100644 skills/page-template-fitness/SKILL.md create mode 100644 skills/page-template-fitness/skill.yaml create mode 100644 skills/page-template-fitness/templates/fitness-club.html create mode 100644 skills/page-template-hotel/SKILL.md create mode 100644 skills/page-template-hotel/skill.yaml create mode 100644 skills/page-template-hotel/templates/hotel-retreat.html create mode 100644 skills/page-template-photography/SKILL.md create mode 100644 skills/page-template-photography/skill.yaml create mode 100644 skills/page-template-photography/templates/photo-gallery.html create mode 100644 skills/page-template-podcast/SKILL.md create mode 100644 skills/page-template-podcast/skill.yaml create mode 100644 skills/page-template-podcast/templates/podcast-show.html create mode 100644 skills/page-template-portfolio/SKILL.md create mode 100644 skills/page-template-portfolio/skill.yaml create mode 100644 skills/page-template-portfolio/templates/portfolio-grid.html create mode 100644 skills/page-template-product-grid/SKILL.md create mode 100644 skills/page-template-product-grid/skill.yaml create mode 100644 skills/page-template-product-grid/templates/product-grid.html create mode 100644 skills/page-template-realestate/SKILL.md create mode 100644 skills/page-template-realestate/skill.yaml create mode 100644 skills/page-template-realestate/templates/realestate-listing.html create mode 100644 skills/page-template-restaurant/SKILL.md create mode 100644 skills/page-template-restaurant/skill.yaml create mode 100644 skills/page-template-restaurant/templates/restaurant-menu.html create mode 100644 skills/page-template-resume/SKILL.md create mode 100644 skills/page-template-resume/skill.yaml create mode 100644 skills/page-template-resume/templates/resume-profile.html create mode 100644 skills/page-template-saas/SKILL.md create mode 100644 skills/page-template-saas/skill.yaml create mode 100644 skills/page-template-saas/templates/saas-landing.html create mode 100644 skills/page-template-startup/SKILL.md create mode 100644 skills/page-template-startup/skill.yaml create mode 100644 skills/page-template-startup/templates/startup-pitch.html create mode 100644 skills/page-template-survey/SKILL.md create mode 100644 skills/page-template-survey/skill.yaml create mode 100644 skills/page-template-survey/templates/survey-basic.html create mode 100644 skills/page-template-travel/SKILL.md create mode 100644 skills/page-template-travel/skill.yaml create mode 100644 skills/page-template-travel/templates/travel-hero.html create mode 100644 skills/page-template-wedding/SKILL.md create mode 100644 skills/page-template-wedding/skill.yaml create mode 100644 skills/page-template-wedding/templates/wedding-invite.html create mode 100644 src/api/page-template-catalog.ts create mode 100644 src/components/PageTemplateShopModal.tsx create mode 100644 src/components/TemplatePreviewThumb.tsx create mode 100644 src/utils/template-shop-utils.ts 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}}
+ +
+ ${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`;禁止重写 `