From b0f5d6a51c554cd3a9ca40defc43aeb31066dda8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 17 Jun 2026 16:39:39 -0700 Subject: [PATCH] Extract memind_adm admin server, add local dev tooling, and remove image-generation. Split platform admin and ops APIs into standalone admin-server.mjs with network guards; simplify billing to RMB token pricing, refactor user auth, and add rsync deploy plus local-test scripts and docs. Co-authored-by: Cursor --- .env.example | 79 +-- .gitignore | 1 + README.md | 12 + admin-bootstrap.mjs | 90 +++ admin-guard.mjs | 78 +++ admin-guard.test.mjs | 67 ++ admin-routes.mjs | 484 ++++++++++++++ admin-server.mjs | 189 ++++++ billing-recharge.mjs | 8 +- billing.mjs | 24 +- billing.test.mjs | 43 +- capabilities.mjs | 59 +- capabilities.test.mjs | 36 ++ chat-skills.mjs | 10 - chat-skills.test.mjs | 1 - db.mjs | 6 - docs/g2-load-balancing.md | 86 +++ docs/local-dev.md | 32 + image-generation.mjs | 340 ---------- image-generation.test.mjs | 68 -- llm-providers.mjs | 2 +- llm-providers.test.mjs | 2 +- mindspace-assets.mjs | 13 +- mindspace-chat-save.mjs | 215 +------ mindspace-chat-save.test.mjs | 75 --- mindspace-pages.mjs | 7 +- mindspace-sandbox-mcp.mjs | 213 +++++++ mindspace-thumbnails.mjs | 55 +- mindspace-thumbnails.test.mjs | 37 -- ops/src/App.tsx | 52 +- ops/src/api/admin.ts | 207 ++++++ ops/src/components/AdminLayout.tsx | 38 ++ ops/src/components/OpsLayout.tsx | 20 +- ops/src/components/RequireAdmin.tsx | 31 + ops/src/components/RequireOps.tsx | 42 +- ops/src/index.css | 17 + ops/src/lib/auth.tsx | 30 + ops/src/main.tsx | 5 +- ops/src/pages/admin/BillingPage.tsx | 181 ++++++ ops/src/pages/admin/LlmPage.tsx | 354 +++++++++++ ops/src/pages/admin/SummaryPage.tsx | 55 ++ ops/src/pages/admin/UsersPage.tsx | 389 ++++++++++++ ops/vite.config.ts | 12 +- package-lock.json | 173 ++--- package.json | 6 +- pnpm-lock.yaml | 91 --- pnpm-workspace.yaml | 2 - public/hello-john.html | 159 +++++ regenerate-page-goose-test.mjs | 278 +++++++++ rsync_to_server.sh | 484 ++++++++++++++ schema.sql | 1 - scripts/.rsync-exclude-105 | 54 +- scripts/.rsync-exclude-studio | 56 ++ scripts/check-local-test.mjs | 95 +++ scripts/dev.mjs | 149 +---- scripts/enrich-plaza-posts.mjs | 16 +- scripts/fix-local-goose-relay.mjs | 130 ++++ scripts/g2-lb.Caddyfile | 12 +- scripts/local-test-config.mjs | 83 +++ scripts/local-test-dns-server.mjs | 108 ++++ scripts/local-test-proxy.mjs | 168 +++++ scripts/local-test-tls.mjs | 65 ++ scripts/memind-fwd-tunnel.sh | 5 + scripts/open-local-test.mjs | 12 + scripts/plaza-rich-content.mjs | 226 +------ scripts/regenerate-page-goose-test.mjs | 286 +++++++++ scripts/run-memind-portal-prod.sh | 2 +- scripts/run-plaza-prod.sh | 2 +- scripts/seed-plaza-covers.mjs | 116 +--- scripts/seed-plaza-demo.mjs | 5 +- scripts/setup-local-test-dns.mjs | 120 ++++ scripts/setup-local-test.mjs | 51 ++ scripts/start-plaza-prod.mjs | 23 +- scripts/sync-to-105.sh | 8 +- server.mjs | 589 ++---------------- skills-registry.mjs | 2 - skills-registry.test.mjs | 2 - skills/image-designer/SKILL.md | 127 ---- src/App.tsx | 53 +- src/api/client.ts | 35 +- src/components/ChatPanel.tsx | 16 +- src/components/ChatSkillIcons.tsx | 12 - src/components/MindSpacePageDetail.tsx | 92 +-- .../MindSpacePageFullscreenPreview.tsx | 28 +- src/components/PageSaveDialog.tsx | 48 +- src/components/PageSavePreviewPanel.tsx | 4 +- src/components/RechargeModal.tsx | 7 - src/components/SpaceChatPanel.tsx | 18 - src/context/AppSessionContext.tsx | 29 - src/hooks/usePageEditSubChat.ts | 7 - src/hooks/useTKMindChat.ts | 51 +- src/index.css | 40 +- src/types.ts | 15 - src/utils/billing.ts | 17 - src/utils/chatSkills.ts | 3 +- user-auth.mjs | 410 ++++++------ user-publish.mjs | 43 +- vite.config.ts | 5 +- 98 files changed, 5394 insertions(+), 3010 deletions(-) create mode 100644 admin-bootstrap.mjs create mode 100644 admin-guard.mjs create mode 100644 admin-guard.test.mjs create mode 100644 admin-routes.mjs create mode 100644 admin-server.mjs create mode 100644 docs/g2-load-balancing.md create mode 100644 docs/local-dev.md delete mode 100644 image-generation.mjs delete mode 100644 image-generation.test.mjs create mode 100644 mindspace-sandbox-mcp.mjs create mode 100644 ops/src/api/admin.ts create mode 100644 ops/src/components/AdminLayout.tsx create mode 100644 ops/src/components/RequireAdmin.tsx create mode 100644 ops/src/lib/auth.tsx create mode 100644 ops/src/pages/admin/BillingPage.tsx create mode 100644 ops/src/pages/admin/LlmPage.tsx create mode 100644 ops/src/pages/admin/SummaryPage.tsx create mode 100644 ops/src/pages/admin/UsersPage.tsx delete mode 100644 pnpm-workspace.yaml create mode 100644 public/hello-john.html create mode 100644 regenerate-page-goose-test.mjs create mode 100755 rsync_to_server.sh create mode 100644 scripts/.rsync-exclude-studio create mode 100644 scripts/check-local-test.mjs create mode 100644 scripts/fix-local-goose-relay.mjs create mode 100644 scripts/local-test-config.mjs create mode 100644 scripts/local-test-dns-server.mjs create mode 100644 scripts/local-test-proxy.mjs create mode 100644 scripts/local-test-tls.mjs create mode 100755 scripts/memind-fwd-tunnel.sh create mode 100644 scripts/open-local-test.mjs create mode 100644 scripts/regenerate-page-goose-test.mjs create mode 100644 scripts/setup-local-test-dns.mjs create mode 100644 scripts/setup-local-test.mjs delete mode 100644 skills/image-designer/SKILL.md delete mode 100644 src/context/AppSessionContext.tsx delete mode 100644 src/utils/billing.ts diff --git a/.env.example b/.env.example index d2c98cb..632dca0 100644 --- a/.env.example +++ b/.env.example @@ -1,23 +1,32 @@ # TKMind H5 环境变量(复制为 .env 后按需修改) # -# 本地开发(默认只做本地,不自动发布生产): -# 1. pnpm dev # 热更新 (http://127.0.0.1:5173) -# 2. pnpm run test:local # 生产构建本地跑 (http://127.0.0.1:8081) -# 3. pnpm run test:fruit-john-e2e # john 水果主题:预览图 + 可点击链接 + 我的空间 -# 4. node scripts/mindspace-rebuild-thumbnails.mjs # 批量重建预览图(可选) -# -# 发布生产:仅当明确要求「发布生产」时再执行: -# pnpm run deploy:105 或 pnpm run deploy:prod 或 ./rsync_to_server.sh -# 105 配置见 deploy/h5-105.env.example +# 本地开发(127.0.0.1 + 端口,见 docs/local-dev.md) +# pnpm dev +# MindSpace: http://127.0.0.1:5173/?preview=mindspace # # 后端代理(server.mjs / vite dev proxy 共用) TKMIND_API_TARGET=https://127.0.0.1:18006 TKMIND_SERVER__SECRET_KEY=local-dev-secret H5_PORT=8081 -# Agent 生成链接用(本地开发建议 8081;生产再改为 https://goo.tkmind.cn) -H5_PUBLIC_BASE_URL=http://127.0.0.1:8081 -# MindSpace 与 Plaza 跨子域共享登录(如 g2.tkmind.cn + plaza.tkmind.cn) -# H5_COOKIE_DOMAIN=.tkmind.cn +H5_PUBLIC_BASE_URL=http://127.0.0.1:5173 + +# ===== memind_adm(独立后台服务)===== +# 平台超管 /admin-api/* 与广场运营 /api/ops/v1/* 已从公网服务拆出,独立进程运行。 +# 登录仍在主域完成;后台凭 H5_COOKIE_DOMAIN=.tkmind.cn 共享的会话 Cookie 鉴权(生产务必设置)。 +ADMIN_PORT=8082 +ADMIN_HOST=127.0.0.1 +ADMIN_PUBLIC_HOST=gadm.tkmind.cn + +# 本进程挂载哪些控制台(可拆分架构):admin=平台超管, ops=广场运营。 +# 默认两者合一;将来拆成两个进程时,A 实例设 admin、B 实例设 ops,路由代码无需改动。 +# ADMIN_CONSOLES=admin,ops + +# 每控制台独立网络护栏(逗号分隔;留空=放开。支持精确 IP 与 CIDR)。 +# 超管面建议收得最紧(仅内网/VPN),广场运营可放宽给审核团队网段。 +# ADMIN_API_ALLOWED_HOSTS=gadm.tkmind.cn +# ADMIN_API_IP_ALLOWLIST=10.0.0.0/8,127.0.0.1 +# OPS_API_ALLOWED_HOSTS=gadm.tkmind.cn +# OPS_API_IP_ALLOWLIST= # 多用户系统(MySQL 配置后启用;未配置时回退到 H5_ACCESS_PASSWORD) DATABASE_URL=mysql://boot:password@localhost:3306/tkmind @@ -32,37 +41,36 @@ H5_ADMIN_PASSWORD=change-me-admin # LLM Key 数据库加密(可选;默认派生自 TKMIND_SERVER__SECRET_KEY) # H5_SETTINGS_ENCRYPTION_KEY=change-me-encryption-key # 启动时自动导入 Relay(若不存在同名配置) -# H5_RELAY_BOOTSTRAP_NAME=Relay Buyer Ollama +# H5_RELAY_BOOTSTRAP_NAME=Local Goose Relay DeepSeek # H5_RELAY_BOOTSTRAP_URL=http://127.0.0.1:18300/relay/buyer/v1/chat/completions # H5_RELAY_BOOTSTRAP_API_KEY=your-bearer-token -# H5_RELAY_BOOTSTRAP_MODEL=qwen2.5:3b -# H5_RELAY_BOOTSTRAP_MODELS=qwen2.5:3b,llama3.2:1b -# H5_RELAY_BOOTSTRAP_PROVIDER=ollama +# H5_RELAY_BOOTSTRAP_MODEL=deepseek-chat +# H5_RELAY_BOOTSTRAP_MODELS=deepseek-chat,deepseek-reasoner +# H5_RELAY_BOOTSTRAP_PROVIDER=deepseek +# 一键对齐本地 goosed + Goose Desktop relay:node scripts/fix-local-goose-relay.mjs # 本地 Ollama fallback:DeepSeek 额度不足,或 andu Relay 500(agent 请求体含 tools 超过 ~8.5KB)时自动切换 # Local Ollama fallback — creditsExhausted or relay 500 (nginx body limit with tool payloads). # H5_LOCAL_LLM_FALLBACK=0 # H5_LOCAL_LLM_URL=http://127.0.0.1:11434/v1/chat/completions -# H5_LOCAL_LLM_MODEL=qwen2.5-coder:7b +# H5_LOCAL_LLM_MODEL=qwen2.5:3b # H5_LOCAL_LLM_API_KEY=ollama # H5_LOCAL_LLM_NAME=Local Ollama 7B H5_ACCESS_PASSWORD=change-me # 计费(Phase 2,金额单位均为人民币分) -# 默认:DeepSeek V4 Flash 公开价 × 1(随 H5_USD_CNY_RATE 换算);可用下方变量覆盖 +# 默认按 Token 单价扣费(人民币结算);勿开启美元成本换算除非明确需要 # H5_USE_BACKEND_COST=0 -# H5_USD_CNY_RATE=7.2 -# 汇率 7.2 时默认约:输入 0.101 分/1k(¥0.00101/1k)、输出 0.202 分/1k(¥0.00202/1k) -# H5_BILL_INPUT_CENTS_PER_1K=0.101 -# H5_BILL_OUTPUT_CENTS_PER_1K=0.202 +# H5_BILL_INPUT_CENTS_PER_1K=2 # 输入 Token:2 分/1k(¥0.02/1k) +# H5_BILL_OUTPUT_CENTS_PER_1K=6 # 输出 Token:6 分/1k(¥0.06/1k) # H5_MIN_BILL_CENTS=1 # 仅调试:H5_USE_BACKEND_COST=1 + H5_USD_CNY_RATE=7.2(上游 USD × 汇率 → 人民币分) # 用户自助充值(微信支付) # H5_RECHARGE_TIERS_CENTS=500,1000,3000,5000,10000,20000 # H5_MIN_RECHARGE_CENTS=500 -# H5_RECHARGE_ORDER_TTL_MS=180000 -# H5_RECHARGE_MAX_PENDING=5 +# H5_RECHARGE_ORDER_TTL_MS=900000 +# H5_RECHARGE_MAX_PENDING=3 # H5_RECHARGE_DAILY_LIMIT_CENTS=200000 # H5_WECHAT_PAY_ENABLED=1 # H5_WECHAT_APP_ID=wx... @@ -112,17 +120,6 @@ H5_ACCESS_PASSWORD=change-me # H5_ASR_MAX_BYTES=5242880 # H5_ASR_TIMEOUT_MS=45000 -# AI 图片生成(image-designer 技能;OpenAI 兼容 /v1/images/generations) -# H5_IMAGE_GEN_ENABLED=1 -# H5_IMAGE_GEN_API_URL=https://api.openai.com/v1/images/generations -# H5_IMAGE_GEN_API_KEY=sk-... -# H5_IMAGE_GEN_MODEL=dall-e-3 -# H5_IMAGE_GEN_DEFAULT_SIZE=1024x1024 -# H5_IMAGE_GEN_DEFAULT_STYLE=vivid -# H5_IMAGE_GEN_DEFAULT_QUALITY=standard -# H5_IMAGE_GEN_BILL_CENTS=30 -# 未配置 H5_IMAGE_GEN_API_* 时,回退使用管理后台已选 LLM 的 API Key(需支持 images/generations) - # 前端构建时注入(Vite,需 VITE_ 前缀) # 工作目录:新建会话时使用,必填 VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind @@ -139,15 +136,7 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind # Plaza 发现广场(本机为主,105 已停用) # PLAZA_AUTO_APPROVE=true -# 文档:docs/plaza-local.md -# 首次:sudo pnpm setup:plaza-dns # 仅纯本地 HTTPS 时需要;走 Tunnel 请用 setup:plaza-dns:remove -# 日常:pnpm dev:plaza | 浏览器:pnpm open:plaza | 诊断:pnpm check:plaza -# 公网 plaza.tkmind.cn 经 Cloudflare Tunnel → 本机 :3001(需 dev:plaza 常驻) -# PLAZA_LOCAL_HOST=plaza.tkmind.cn -# PLAZA_PUBLIC_BASE=https://plaza.tkmind.cn -# VITE_PLAZA_BASE=https://plaza.tkmind.cn -# CLOUDFLARE_TUNNEL_CONFIG=/Users/john/Project/ollama/cloudflare/config.yml -# CLOUDFLARE_TUNNEL_NAME=ollama-tkmind +# 文档:docs/plaza-local.md | 本地:http://127.0.0.1:3001/plaza # MindSpace(默认启用;子功能需按需开启) # MINDSPACE_ENABLED=false diff --git a/.gitignore b/.gitignore index 578e1e3..4116b8f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ users/ .DS_Store .idea/ .vscode/ +.aider* diff --git a/README.md b/README.md index 5f445bc..4524762 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,16 @@ cp .env.example .env # 按需填写 pnpm dev # MindSpace + Plaza + Ops ``` +| 服务 | 地址 | +|------|------| +| MindSpace H5 | http://127.0.0.1:5173/?preview=mindspace | +| Plaza | http://127.0.0.1:3001/plaza | +| Ops 审核后台 | http://127.0.0.1:3002/ops/ | +| API / Portal | http://127.0.0.1:8081 | +| memind_adm | http://127.0.0.1:8082 | + +详见 [docs/local-dev.md](docs/local-dev.md)。 + 仅 Plaza: ```bash @@ -40,6 +50,7 @@ pnpm open:plaza # 浏览器打开 https://plaza.tkmind.cn/plaza | 命令 | 说明 | |------|------| | `pnpm dev` | 全栈开发 | +| `pnpm open:local-test` | 浏览器打开 H5 | | `pnpm start:plaza` | Plaza 正式模式 + Portal | | `pnpm dev:plaza` | Plaza 开发模式 + Portal | | `pnpm open:plaza` | 浏览器强制本地解析 | @@ -48,4 +59,5 @@ pnpm open:plaza # 浏览器打开 https://plaza.tkmind.cn/plaza ## 文档 +- [本地开发(test.*.tkmind.cn)](docs/local-dev.md) - [Plaza 本机部署与 Tunnel](docs/plaza-local.md) diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs new file mode 100644 index 0000000..f16a7ba --- /dev/null +++ b/admin-bootstrap.mjs @@ -0,0 +1,90 @@ +// admin-bootstrap.mjs +// +// Service container for the standalone memind_adm process. +// +// Builds only the domain services the back-office routes need (user-auth, LLM +// providers, and the minimal plaza graph behind the ops console). It deliberately +// does NOT start any user-facing daemons — no thumbnail watcher, no asset-sync +// watcher, no plaza hot-score scheduler, no goosed auto-sync. Those belong to the +// public server. Side-effecting actions (e.g. push selected LLM key to goosed) +// stay behind explicit admin routes. +// +// The same factory functions imported here are the ones server.mjs uses, so the +// domain logic has a single source of truth; only the wiring differs. +import path from 'node:path'; +import { createDbPool, isDatabaseConfigured } from './db.mjs'; +import { createUserAuth } from './user-auth.mjs'; +import { createLlmProviderService } from './llm-providers.mjs'; +import { createPlazaPostService, formatPostRow } from './plaza-posts.mjs'; +import { createPlazaInteractionService } from './plaza-interactions.mjs'; +import { createPlazaOpsService } from './plaza-ops.mjs'; +import { createNoopPlazaRedis } from './plaza-redis.mjs'; +import { ensureAlgorithmConfig, loadAlgorithmConfig } from './plaza-algorithm.mjs'; + +const noop = () => {}; + +/** + * Build the admin service container. + * @param {object} [env] + * @param {string} [env.h5Root] Repo root used for workspace path resolution. + * @param {string} [env.usersRoot] User workspace root. + * @param {string} [env.apiTarget] Goosed/relay API target. + * @param {string} [env.apiSecret] Goosed/relay API secret. + * @param {number} [env.defaultSignupBalanceCents] + * @param {boolean} [env.ensureAdminUser] Ensure an admin account exists on boot (default true). + * @returns {Promise<{pool, userAuth, llmProviderService, plazaPosts, plazaOps}>} + */ +export async function createAdminServices(env = {}) { + if (!isDatabaseConfigured()) { + throw new Error('admin-server requires a database (set DATABASE_URL or MYSQL_* env)'); + } + + const h5Root = env.h5Root ?? process.cwd(); + const usersRoot = env.usersRoot ?? process.env.H5_USERS_ROOT ?? path.join(h5Root, 'users'); + const apiTarget = env.apiTarget ?? process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006'; + const apiSecret = env.apiSecret ?? process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret'; + const defaultSignupBalanceCents = Number( + env.defaultSignupBalanceCents ?? process.env.H5_SIGNUP_BALANCE_CENTS ?? 500, + ); + + const pool = createDbPool(); + + // --- plaza graph (review queue, reports, featured, analytics, creators) --- + const plazaRedis = createNoopPlazaRedis(); + await ensureAlgorithmConfig(pool); + const algorithmConfig = await loadAlgorithmConfig(pool); + + const plazaInteractions = createPlazaInteractionService(pool, { formatPostRow, plazaRedis }); + + let plazaOps = null; + const plazaPosts = createPlazaPostService(pool, { + loadViewerReactions: (viewerId, postIds) => + plazaInteractions.loadViewerReactions(viewerId, postIds), + plazaRedis, + algorithmConfig, + onPostPublished: noop, + loadFeaturedPosts: async (viewerId) => { + if (!plazaOps) return { homepage_banner: [], trending: [], category_top: {} }; + return plazaOps.loadActiveFeaturedPosts(viewerId); + }, + }); + plazaOps = createPlazaOpsService(pool, { + formatPostRow, + reviewPost: (...args) => plazaPosts.reviewPost(...args), + invalidateFeedCaches: () => plazaRedis?.invalidateFeedCaches?.(), + }); + + // --- platform super-admin services --- + const userAuth = createUserAuth(pool, { + usersRoot, + h5Root, + defaultSignupBalanceCents, + }); + if (env.ensureAdminUser !== false) { + await userAuth.ensureAdminUser(); + } + + const llmProviderService = createLlmProviderService(pool, { apiTarget, apiSecret }); + + return { pool, userAuth, llmProviderService, plazaPosts, plazaOps }; +} diff --git a/admin-guard.mjs b/admin-guard.mjs new file mode 100644 index 0000000..3fe252b --- /dev/null +++ b/admin-guard.mjs @@ -0,0 +1,78 @@ +// admin-guard.mjs +// +// Pure, testable network-guard helpers for the memind_adm consoles. +// +// Each back-office console (platform super-admin, plaza operations) can be +// pinned to an allowlist of request hosts and/or client IPs, independently. +// This lets the super-admin surface be locked down harder than moderation +// while both still live in one process — and the same knobs keep working +// unchanged once the consoles are split into separate processes. + +export function parseList(value) { + return String(value ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +export function hostAllowed(reqHost, allowedHosts) { + if (!allowedHosts.length) return true; + const hostname = String(reqHost ?? '').split(':')[0].toLowerCase(); + return allowedHosts.some((h) => h.toLowerCase() === hostname); +} + +function ipv4ToInt(ip) { + const parts = ip.split('.'); + if (parts.length !== 4) return null; + let acc = 0; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return null; + const n = Number(part); + if (n < 0 || n > 255) return null; + acc = (acc * 256 + n) >>> 0; + } + return acc >>> 0; +} + +// Strip an IPv4-mapped IPv6 prefix so "::ffff:127.0.0.1" matches "127.0.0.1". +function normalizeIp(ip) { + return String(ip ?? '').replace(/^::ffff:/i, ''); +} + +export function ipMatches(clientIp, rule) { + const ip = normalizeIp(clientIp); + if (rule.includes('/')) { + const [base, bitsStr] = rule.split('/'); + const bits = Number(bitsStr); + const ipInt = ipv4ToInt(ip); + const baseInt = ipv4ToInt(base); + if (ipInt === null || baseInt === null) return false; + if (!Number.isInteger(bits) || bits < 0 || bits > 32) return false; + if (bits === 0) return true; + const mask = bits === 32 ? 0xffffffff : (~((1 << (32 - bits)) - 1)) >>> 0; + return (ipInt & mask) === (baseInt & mask); + } + return ip === normalizeIp(rule); +} + +export function ipAllowed(clientIp, ipAllowlist) { + if (!ipAllowlist.length) return true; + return ipAllowlist.some((rule) => ipMatches(clientIp, rule)); +} + +/** + * Build an Express middleware enforcing host + IP allowlists for one console. + * Returns null when neither list is configured (no-op — common in dev). + * @param {object} opts + * @param {string[]} [opts.allowedHosts] + * @param {string[]} [opts.ipAllowlist] + * @param {(res, req, reason: 'host'|'ip') => unknown} opts.onDeny + */ +export function buildNetworkGuard({ allowedHosts = [], ipAllowlist = [], onDeny }) { + if (!allowedHosts.length && !ipAllowlist.length) return null; + return (req, res, next) => { + if (!hostAllowed(req.get('host'), allowedHosts)) return onDeny(res, req, 'host'); + if (!ipAllowed(req.ip, ipAllowlist)) return onDeny(res, req, 'ip'); + return next(); + }; +} diff --git a/admin-guard.test.mjs b/admin-guard.test.mjs new file mode 100644 index 0000000..5d51077 --- /dev/null +++ b/admin-guard.test.mjs @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + parseList, + hostAllowed, + ipMatches, + ipAllowed, + buildNetworkGuard, +} from './admin-guard.mjs'; + +test('parseList splits, trims, and drops empties', () => { + assert.deepEqual(parseList('a, b ,,c'), ['a', 'b', 'c']); + assert.deepEqual(parseList(''), []); + assert.deepEqual(parseList(undefined), []); +}); + +test('hostAllowed: empty list allows all, otherwise matches hostname ignoring port/case', () => { + assert.equal(hostAllowed('gadm.tkmind.cn:8082', []), true); + assert.equal(hostAllowed('GADM.tkmind.cn', ['gadm.tkmind.cn']), true); + assert.equal(hostAllowed('evil.example.com', ['gadm.tkmind.cn']), false); + assert.equal(hostAllowed('gadm.tkmind.cn:443', ['gadm.tkmind.cn', 'localhost']), true); +}); + +test('ipMatches: exact and IPv4-mapped IPv6', () => { + assert.equal(ipMatches('127.0.0.1', '127.0.0.1'), true); + assert.equal(ipMatches('::ffff:127.0.0.1', '127.0.0.1'), true); + assert.equal(ipMatches('10.0.0.5', '10.0.0.6'), false); +}); + +test('ipMatches: CIDR ranges', () => { + assert.equal(ipMatches('10.1.2.3', '10.0.0.0/8'), true); + assert.equal(ipMatches('11.1.2.3', '10.0.0.0/8'), false); + assert.equal(ipMatches('192.168.1.50', '192.168.1.0/24'), true); + assert.equal(ipMatches('192.168.2.50', '192.168.1.0/24'), false); + assert.equal(ipMatches('203.0.113.9', '0.0.0.0/0'), true); + assert.equal(ipMatches('not-an-ip', '10.0.0.0/8'), false); +}); + +test('ipAllowed: empty list allows all', () => { + assert.equal(ipAllowed('1.2.3.4', []), true); + assert.equal(ipAllowed('1.2.3.4', ['10.0.0.0/8']), false); + assert.equal(ipAllowed('10.9.9.9', ['10.0.0.0/8', '127.0.0.1']), true); +}); + +test('buildNetworkGuard returns null when nothing configured', () => { + assert.equal(buildNetworkGuard({ onDeny: () => {} }), null); +}); + +test('buildNetworkGuard denies on host then ip, allows when both pass', () => { + const guard = buildNetworkGuard({ + allowedHosts: ['gadm.tkmind.cn'], + ipAllowlist: ['10.0.0.0/8'], + onDeny: (_res, _req, reason) => `deny:${reason}`, + }); + const mk = (host, ip) => ({ get: () => host, ip }); + + let nexted = false; + const next = () => { + nexted = true; + }; + + assert.equal(guard(mk('evil.com', '10.1.1.1'), {}, next), 'deny:host'); + assert.equal(guard(mk('gadm.tkmind.cn', '8.8.8.8'), {}, next), 'deny:ip'); + assert.equal(nexted, false); + guard(mk('gadm.tkmind.cn', '10.1.1.1'), {}, next); + assert.equal(nexted, true); +}); diff --git a/admin-routes.mjs b/admin-routes.mjs new file mode 100644 index 0000000..e17499a --- /dev/null +++ b/admin-routes.mjs @@ -0,0 +1,484 @@ +// admin-routes.mjs +// +// Back-office HTTP route factories for the standalone memind_adm service. +// +// Route logic lives here exactly once. The public server (server.mjs) no longer +// serves these surfaces; admin-server.mjs is the sole consumer. Services are +// injected so the same router can be wired against any container (tests, future +// read-replica pool, etc.). +// +// createAdminApi -> /admin-api/* platform super-admin (role === 'admin') +// createOpsApi -> /api/ops/v1/* plaza operations console (ops_role) +import express from 'express'; +import { sendData, sendError } from './api-response.mjs'; +import { hasOpsRole } from './plaza-ops.mjs'; +import { mapPlazaError } from './plaza-posts.mjs'; + +function plazaRouteError(res, req, error) { + const status = mapPlazaError(error); + const code = error?.code ?? 'internal_error'; + const message = error instanceof Error ? error.message : 'Plaza 请求失败'; + return sendError(res, req, status, code, message, error?.details); +} + +/** + * Platform super-admin API (role === 'admin'). + * @param {object} deps + * @param {import('express').RequestHandler} deps.jsonBody JSON body parser middleware. + * @param {(req: import('express').Request) => (string|undefined)} deps.getToken Extract the user session token from a request. + * @param {Promise} [deps.ready] Optional bootstrap gate awaited before the first request resolves auth. + * @param {object} deps.userAuth + * @param {object|null} deps.llmProviderService + * @param {object|null} deps.plazaPosts + * @param {object|null} deps.plazaOps + */ +export function createAdminApi({ jsonBody, getToken, ready, userAuth, llmProviderService, plazaPosts, plazaOps }) { + function requireAdmin(req, res, next) { + if (!req.currentUser || req.currentUser.role !== 'admin') { + res.status(403).json({ message: '需要管理员权限' }); + return; + } + next(); + } + + const adminApi = express.Router(); + adminApi.use(jsonBody); + + adminApi.use(async (req, res, next) => { + if (ready) await ready; + if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); + const me = await userAuth.getMe(getToken(req)); + if (!me) return res.status(401).json({ message: '未登录' }); + req.currentUser = me; + next(); + }); + + adminApi.get('/users', requireAdmin, async (req, res) => { + const { page, pageSize, search, role, status } = req.query; + const result = await userAuth.listUsers({ page, pageSize, search, role, status }); + res.json(result); + }); + + adminApi.get('/summary', requireAdmin, async (_req, res) => { + const summary = await userAuth.getAdminSummary(); + let llm = null; + if (llmProviderService) { + const keys = await llmProviderService.listKeys(); + const global = await llmProviderService.getGlobalSettings(); + const selected = keys.find((key) => key.isSelected); + llm = { + keyCount: keys.length, + selectedKeyName: selected?.name ?? null, + globalModel: global?.model ?? null, + }; + } + res.json({ summary: { ...summary, llm } }); + }); + + adminApi.post('/users', requireAdmin, async (req, res) => { + const result = await userAuth.createUser(req.body ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.status(201).json({ user: result.user }); + }); + + adminApi.patch('/users/:userId', requireAdmin, async (req, res) => { + const result = await userAuth.updateUser(req.params.userId, req.body ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json({ user: result.user }); + }); + + adminApi.post('/users/:userId/recharge', requireAdmin, async (req, res) => { + const amountCents = Number(req.body?.amountCents); + const note = typeof req.body?.note === 'string' ? req.body.note : ''; + const result = await userAuth.recharge( + req.params.userId, + amountCents, + req.currentUser.id, + note, + ); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json({ user: result.user }); + }); + + adminApi.get('/usage', requireAdmin, async (req, res) => { + const userId = typeof req.query.userId === 'string' ? req.query.userId : null; + const { page, pageSize } = req.query; + const result = await userAuth.listUsageRecords({ userId, page, pageSize }); + res.json(result); + }); + + adminApi.get('/capabilities/catalog', requireAdmin, (_req, res) => { + res.json({ catalog: userAuth.capabilityCatalog }); + }); + + adminApi.get('/capabilities/role/:role', requireAdmin, async (req, res) => { + const role = req.params.role === 'admin' ? 'admin' : 'user'; + if (role === 'admin') { + return res.json({ + role, + capabilities: Object.fromEntries( + userAuth.capabilityCatalog.map((item) => [item.key, true]), + ), + unrestricted: true, + }); + } + res.json(await userAuth.getRoleCapabilities('user')); + }); + + adminApi.put('/capabilities/role/:role', requireAdmin, async (req, res) => { + const role = req.params.role === 'admin' ? 'admin' : 'user'; + const result = await userAuth.setRoleCapabilities(role, req.body?.capabilities ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.get('/users/:userId/capabilities', requireAdmin, async (req, res) => { + const result = await userAuth.getUserCapabilities(req.params.userId); + if (!result.ok) return res.status(404).json({ message: result.message }); + res.json(result); + }); + + adminApi.put('/users/:userId/capabilities', requireAdmin, async (req, res) => { + const result = await userAuth.setUserCapabilities( + req.params.userId, + req.body?.capabilities ?? {}, + ); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.delete('/users/:userId/capabilities', requireAdmin, async (req, res) => { + const result = await userAuth.clearUserCapabilityOverrides(req.params.userId); + if (!result.ok) return res.status(404).json({ message: result.message }); + res.json(result); + }); + + adminApi.get('/policies/catalog', requireAdmin, (_req, res) => { + res.json({ catalog: userAuth.policyCatalog }); + }); + + adminApi.get('/policies/role/:role', requireAdmin, async (req, res) => { + const role = req.params.role === 'admin' ? 'admin' : 'user'; + if (role === 'admin') { + return res.json({ role, policies: {}, unrestricted: true }); + } + res.json(await userAuth.getRolePolicies('user')); + }); + + adminApi.put('/policies/role/:role', requireAdmin, async (req, res) => { + const role = req.params.role === 'admin' ? 'admin' : 'user'; + const result = await userAuth.setRolePolicies(role, req.body?.policies ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.get('/users/:userId/policies', requireAdmin, async (req, res) => { + const result = await userAuth.getUserPolicies(req.params.userId); + if (!result.ok) return res.status(404).json({ message: result.message }); + res.json(result); + }); + + adminApi.put('/users/:userId/policies', requireAdmin, async (req, res) => { + const result = await userAuth.setUserPolicies(req.params.userId, req.body?.policies ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.delete('/users/:userId/policies', requireAdmin, async (req, res) => { + const result = await userAuth.clearUserPolicyOverrides(req.params.userId); + if (!result.ok) return res.status(404).json({ message: result.message }); + res.json(result); + }); + + adminApi.get('/skills/catalog', requireAdmin, (_req, res) => { + res.json({ catalog: userAuth.skillCatalog }); + }); + + adminApi.get('/skills/role/:role', requireAdmin, async (req, res) => { + const role = req.params.role === 'admin' ? 'admin' : 'user'; + if (role === 'admin') { + return res.json({ + role, + skills: Object.fromEntries(userAuth.skillCatalog.map((item) => [item.name, true])), + }); + } + res.json(await userAuth.getRoleSkills('user')); + }); + + adminApi.put('/skills/role/:role', requireAdmin, async (req, res) => { + const role = req.params.role === 'admin' ? 'admin' : 'user'; + const result = await userAuth.setRoleSkills(role, req.body?.skills ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.get('/users/:userId/skills', requireAdmin, async (req, res) => { + const result = await userAuth.getUserSkills(req.params.userId); + if (!result.ok) return res.status(404).json({ message: result.message }); + res.json(result); + }); + + adminApi.put('/users/:userId/skills', requireAdmin, async (req, res) => { + const result = await userAuth.setUserSkills(req.params.userId, req.body?.skills ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.delete('/users/:userId/skills', requireAdmin, async (req, res) => { + const result = await userAuth.clearUserSkillOverrides(req.params.userId); + if (!result.ok) return res.status(404).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; + const result = await userAuth.listBillingLedger({ userId, page, pageSize }); + res.json(result); + }); + + adminApi.get('/llm-providers/catalog', requireAdmin, (_req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + res.json({ catalog: llmProviderService.catalog }); + }); + + adminApi.get('/llm-providers/keys', requireAdmin, async (_req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const keys = await llmProviderService.listKeys(); + res.json({ keys }); + }); + + adminApi.post('/llm-providers/keys', requireAdmin, async (req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const result = await llmProviderService.createKey(req.body ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.status(201).json({ key: result.key }); + }); + + adminApi.patch('/llm-providers/keys/:keyId', requireAdmin, async (req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const result = await llmProviderService.updateKey(req.params.keyId, req.body ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json({ key: result.key }); + }); + + adminApi.post('/llm-providers/keys/:keyId/select', requireAdmin, async (req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const result = await llmProviderService.selectKey(req.params.keyId); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json({ key: result.key }); + }); + + adminApi.delete('/llm-providers/keys/:keyId', requireAdmin, async (req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const result = await llmProviderService.deleteKey(req.params.keyId); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json({ ok: true }); + }); + + adminApi.post('/llm-providers/sync', requireAdmin, async (_req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + try { + const result = await llmProviderService.syncSelectedToGoosed(); + res.json(result); + } catch (err) { + res.status(500).json({ + message: err instanceof Error ? err.message : '同步失败', + }); + } + }); + + adminApi.get('/llm-providers/global', requireAdmin, async (_req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const global = await llmProviderService.getGlobalSettings(); + res.json({ global }); + }); + + adminApi.put('/llm-providers/global', requireAdmin, async (req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const result = await llmProviderService.setGlobalModel(req.body?.model); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.post('/llm-providers/test', requireAdmin, async (req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + try { + const result = await llmProviderService.testDraft(req.body ?? {}); + res.json(result); + } catch (err) { + res.status(500).json({ + ok: false, + message: err instanceof Error ? err.message : '联通测试失败', + }); + } + }); + + adminApi.post('/llm-providers/keys/:keyId/test', requireAdmin, async (req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + try { + const result = await llmProviderService.testKey(req.params.keyId, req.body?.model); + res.json(result); + } catch (err) { + res.status(500).json({ + ok: false, + message: err instanceof Error ? err.message : '联通测试失败', + }); + } + }); + + adminApi.get('/plaza/pending', requireAdmin, async (_req, res) => { + if (!plazaPosts) return res.status(503).json({ message: 'Plaza 未启用' }); + const posts = await plazaPosts.listPendingPosts(); + res.json({ data: { posts } }); + }); + + adminApi.post('/plaza/posts/:id/review', requireAdmin, async (req, res) => { + if (!plazaPosts) return res.status(503).json({ message: 'Plaza 未启用' }); + try { + const action = String(req.body?.action ?? 'approve'); + const reason = req.body?.reason ?? null; + const result = + plazaOps && req.currentUser?.id + ? await plazaOps.reviewPostAsOps(req.currentUser.id, req.params.id, action, { reason }) + : await plazaPosts.reviewPost(req.params.id, action, { reason }); + res.json({ data: result }); + } catch (error) { + const status = mapPlazaError(error); + res.status(status).json({ + error: { code: error?.code ?? 'review_failed', message: error.message }, + }); + } + }); + + return adminApi; +} + +/** + * Plaza operations console API (ops_role: reviewer < editor < ops_admin). + * @param {object} deps + * @param {import('express').RequestHandler} deps.jsonBody + * @param {object|null} deps.plazaOps + */ +export function createOpsApi({ jsonBody, plazaOps }) { + function makeRequireOps(minRole = 'reviewer') { + return async (req, res, next) => { + if (!plazaOps) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); + if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); + const role = await plazaOps.loadOperatorRole(req.currentUser.id); + if (!hasOpsRole(role, minRole)) { + return sendError(res, req, 403, 'OPS_PERMISSION_DENIED', '无运营权限'); + } + req.opsRole = role; + return next(); + }; + } + + const opsApi = express.Router(); + opsApi.use(jsonBody); + opsApi.use(makeRequireOps('reviewer')); + + opsApi.get('/review/queue', async (req, res) => { + try { + const queue = await plazaOps.listReviewQueue({ + status: req.query.status ?? 'pending_review', + cursor: req.query.cursor ?? null, + limit: req.query.limit, + keyword: req.query.keyword ?? null, + }); + return sendData(res, req, queue); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + opsApi.post('/review/posts/:id', async (req, res) => { + try { + const result = await plazaOps.reviewPostAsOps(req.currentUser.id, req.params.id, req.body?.action, { + reason: req.body?.reason ?? null, + }); + return sendData(res, req, { post: result }); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + opsApi.post('/review/batch', makeRequireOps('editor'), async (req, res) => { + try { + const result = await plazaOps.batchReviewPosts(req.currentUser.id, req.body ?? {}); + return sendData(res, req, result); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + opsApi.get('/reports', async (req, res) => { + try { + return sendData(res, req, await plazaOps.listReports({ status: req.query.status ?? 'pending' })); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + opsApi.post('/reports/:id/process', async (req, res) => { + try { + const result = await plazaOps.processReport(req.currentUser.id, req.params.id, req.body ?? {}); + return sendData(res, req, { report: result }); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + opsApi.get('/featured', makeRequireOps('editor'), async (req, res) => { + try { + return sendData(res, req, await plazaOps.listFeatured()); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + opsApi.post('/featured', makeRequireOps('editor'), async (req, res) => { + try { + const result = await plazaOps.setFeatured(req.currentUser.id, req.body ?? {}); + return sendData(res, req, { featured: result }, 201); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + opsApi.delete('/featured/:id', makeRequireOps('editor'), async (req, res) => { + try { + const result = await plazaOps.removeFeatured(req.currentUser.id, req.params.id); + return sendData(res, req, { featured: result }); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + opsApi.get('/analytics/overview', makeRequireOps('ops_admin'), async (req, res) => { + try { + return sendData(res, req, await plazaOps.getAnalyticsOverview()); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + opsApi.get('/creators', async (req, res) => { + try { + return sendData(res, req, await plazaOps.listCreators({ keyword: req.query.keyword ?? null })); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + opsApi.patch('/creators/:userId', async (req, res) => { + try { + const result = await plazaOps.updateCreator(req.currentUser.id, req.params.userId, req.body ?? {}); + return sendData(res, req, { creator: result }); + } catch (error) { + return plazaRouteError(res, req, error); + } + }); + + return opsApi; +} diff --git a/admin-server.mjs b/admin-server.mjs new file mode 100644 index 0000000..5a0ebf2 --- /dev/null +++ b/admin-server.mjs @@ -0,0 +1,189 @@ +// admin-server.mjs +// +// Standalone memind_adm service — the back-office process. +// +// Serves the platform super-admin API (/admin-api/*) and the plaza operations +// console API (/api/ops/v1/*) on its own port, isolated from the public +// user-facing server (server.mjs). In production this is fronted by +// gadm.tkmind.cn; in dev the ops SPA (:3002) proxies to it directly. +// +// It trusts the shared session cookie (H5_COOKIE_DOMAIN=.tkmind.cn), so login +// continues to happen on the main app domain — this process only reads the +// session and authorizes admin/ops roles. No daemons, no public traffic. +import express from 'express'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseCookies } from './auth.mjs'; +import { USER_COOKIE } from './user-auth.mjs'; +import { createAdminServices } from './admin-bootstrap.mjs'; +import { createAdminApi, createOpsApi } from './admin-routes.mjs'; +import { buildNetworkGuard, parseList } from './admin-guard.mjs'; +import { sendError } from './api-response.mjs'; +import { isLocalDevHostname } from './scripts/local-test-config.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Mirror server.mjs env loading so the admin service can run standalone. +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(); + const value = trimmed.slice(eq + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} + +loadEnvFile(path.join(__dirname, '../../.env.local')); +loadEnvFile(path.join(__dirname, '.env')); + +const PORT = Number(process.env.ADMIN_PORT ?? 8082); +const HOST = process.env.ADMIN_HOST ?? '127.0.0.1'; +const PUBLIC_HOST = process.env.ADMIN_PUBLIC_HOST ?? 'gadm.tkmind.cn'; + +const getToken = (req) => parseCookies(req.get('cookie'))[USER_COOKIE]; + +// Resolve the shared session cookie into req.currentUser, else 401. +function resolveSessionUser(userAuth) { + return async (req, res, next) => { + const token = getToken(req); + const me = token ? await userAuth.getMe(token) : null; + if (!me) return res.status(401).json({ message: '未授权,请重新登录' }); + req.currentUser = me; + next(); + }; +} + +// ---- Console registry ---------------------------------------------------- +// Each back-office console is an independently mountable unit. A process serves +// whichever subset ADMIN_CONSOLES names (default: all). Splitting into separate +// processes later is purely a config change — run one instance with +// ADMIN_CONSOLES=admin and another with ADMIN_CONSOLES=ops; no code changes. +const CONSOLES = { + admin: { + label: 'platform super-admin', + mountPath: '/admin-api', + routeHint: '/admin-api/*', + envPrefix: 'ADMIN_API', // ADMIN_API_ALLOWED_HOSTS / ADMIN_API_IP_ALLOWLIST + build: ({ jsonBody, services }) => + // The admin router gates currentUser itself, then requireAdmin per route. + createAdminApi({ + jsonBody, + getToken, + userAuth: services.userAuth, + llmProviderService: services.llmProviderService, + plazaPosts: services.plazaPosts, + plazaOps: services.plazaOps, + }), + }, + ops: { + label: 'plaza operations console', + mountPath: '/api', + routeHint: '/api/ops/v1/*', + envPrefix: 'OPS_API', // OPS_API_ALLOWED_HOSTS / OPS_API_IP_ALLOWLIST + build: ({ jsonBody, services }) => { + const router = express.Router(); + router.use(resolveSessionUser(services.userAuth)); + router.use('/ops/v1', createOpsApi({ jsonBody, plazaOps: services.plazaOps })); + return router; + }, + }, +}; + +function csrfOriginCheck(req, res, next) { + if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next(); + const host = req.get('host'); + const origin = req.get('origin'); + const referer = req.get('referer'); + if (!origin && !referer) return next(); + const requestHostname = (host ?? '').split(':')[0]; + const allowed = [origin, referer].some((value) => { + if (!value) return false; + try { + const source = new URL(value); + if (source.host === host) return true; + if (source.hostname === PUBLIC_HOST) return true; + return isLocalDevHostname(requestHostname) && isLocalDevHostname(source.hostname); + } catch { + return false; + } + }); + if (!allowed) { + return sendError(res, req, 403, 'csrf_failed', '来源校验失败'); + } + return next(); +} + +function resolveEnabledConsoles() { + const enabled = parseList(process.env.ADMIN_CONSOLES || 'admin,ops'); + if (!enabled.length) throw new Error('ADMIN_CONSOLES resolved to an empty set'); + const unknown = enabled.filter((key) => !CONSOLES[key]); + if (unknown.length) { + throw new Error( + `unknown ADMIN_CONSOLES: ${unknown.join(', ')} (valid: ${Object.keys(CONSOLES).join(', ')})`, + ); + } + return enabled; +} + +async function main() { + const enabled = resolveEnabledConsoles(); + const services = await createAdminServices({ h5Root: __dirname }); + + const app = express(); + app.set('trust proxy', true); + app.disable('x-powered-by'); + + const jsonBody = express.json({ limit: '1mb' }); + const ctx = { jsonBody, services }; + + app.get('/healthz', (_req, res) => + res.json({ ok: true, service: 'memind_adm', consoles: enabled }), + ); + + const summary = []; + for (const key of enabled) { + const console_ = CONSOLES[key]; + const allowedHosts = parseList(process.env[`${console_.envPrefix}_ALLOWED_HOSTS`]); + const ipAllowlist = parseList(process.env[`${console_.envPrefix}_IP_ALLOWLIST`]); + const guard = buildNetworkGuard({ + allowedHosts, + ipAllowlist, + onDeny: (res, req, reason) => + sendError( + res, + req, + 403, + 'forbidden_network', + reason === 'host' ? '主机不在白名单' : 'IP 不在白名单', + ), + }); + + app.use(console_.mountPath, csrfOriginCheck); + if (guard) app.use(console_.mountPath, guard); + app.use(console_.mountPath, console_.build(ctx)); + + summary.push({ key, console_, allowedHosts, ipAllowlist, guarded: Boolean(guard) }); + } + + app.use((req, res) => res.status(404).json({ message: 'not found' })); + + app.listen(PORT, HOST, () => { + console.log(`memind_adm @ http://${HOST}:${PORT} (public host: ${PUBLIC_HOST})`); + for (const { console_, allowedHosts, ipAllowlist, guarded } of summary) { + const fence = guarded + ? ` [hosts: ${allowedHosts.join(',') || '*'} | ip: ${ipAllowlist.join(',') || '*'}]` + : ''; + console.log(` ${console_.routeHint.padEnd(16)} ${console_.label}${fence}`); + } + }); +} + +main().catch((err) => { + console.error('admin-server failed to start:', err); + process.exit(1); +}); diff --git a/billing-recharge.mjs b/billing-recharge.mjs index 82c09dd..ad38956 100644 --- a/billing-recharge.mjs +++ b/billing-recharge.mjs @@ -1,5 +1,4 @@ import crypto from 'node:crypto'; -import { loadBillingConfig } from './billing.mjs'; export function loadRechargeConfig() { const tiers = (process.env.H5_RECHARGE_TIERS_CENTS ?? '500,1000,3000,5000,10000,20000') @@ -9,8 +8,8 @@ export function loadRechargeConfig() { return { tiersCents: tiers.length ? tiers : [500, 1000, 3000, 5000, 10000, 20000], minRechargeCents: Number(process.env.H5_MIN_RECHARGE_CENTS ?? 500), - orderTtlMs: Number(process.env.H5_RECHARGE_ORDER_TTL_MS ?? 3 * 60 * 1000), - maxPendingOrders: Number(process.env.H5_RECHARGE_MAX_PENDING ?? 5), + orderTtlMs: Number(process.env.H5_RECHARGE_ORDER_TTL_MS ?? 15 * 60 * 1000), + maxPendingOrders: Number(process.env.H5_RECHARGE_MAX_PENDING ?? 3), dailyLimitCents: Number(process.env.H5_RECHARGE_DAILY_LIMIT_CENTS ?? 200_000), }; } @@ -105,14 +104,11 @@ export function createRechargeService(pool, { userAuth, wechatPay, config = load const getBillingConfig = async (userId) => { const user = await userAuth.getUserById(userId); - const billing = loadBillingConfig(); return { wechatEnabled: Boolean(wechatPay?.enabled), tiersCents: config.tiersCents, minRechargeCents: config.minRechargeCents, balanceCents: user ? Number(user.balance_cents ?? 0) : 0, - inputCentsPer1k: billing.inputCentsPer1k, - outputCentsPer1k: billing.outputCentsPer1k, }; }; diff --git a/billing.mjs b/billing.mjs index 375b764..1a0572f 100644 --- a/billing.mjs +++ b/billing.mjs @@ -1,29 +1,11 @@ -// DeepSeek V4 Flash 公开价($/1M tokens,cache-miss 输入);见 api-docs.deepseek.com -export const DEEPSEEK_FLASH_INPUT_USD_PER_1M = 0.14; -export const DEEPSEEK_FLASH_OUTPUT_USD_PER_1M = 0.28; -export const DEEPSEEK_BILLING_MARKUP = 1; - -function deepSeekFlashCentsPer1k(usdPer1M, usdCnyRate) { - return (usdPer1M / 1000) * usdCnyRate * 100 * DEEPSEEK_BILLING_MARKUP; -} - export function loadBillingConfig() { // 默认按人民币分(CNY cents)计费;仅当 H5_USE_BACKEND_COST=1 时才用上游 USD 成本换算。 const useBackendCost = process.env.H5_USE_BACKEND_COST === '1'; - const usdCnyRate = Number(process.env.H5_USD_CNY_RATE ?? 7.2); - const defaultInputCentsPer1k = deepSeekFlashCentsPer1k( - DEEPSEEK_FLASH_INPUT_USD_PER_1M, - usdCnyRate, - ); - const defaultOutputCentsPer1k = deepSeekFlashCentsPer1k( - DEEPSEEK_FLASH_OUTPUT_USD_PER_1M, - usdCnyRate, - ); return { useBackendCost, - usdCnyRate, - inputCentsPer1k: Number(process.env.H5_BILL_INPUT_CENTS_PER_1K ?? defaultInputCentsPer1k), - outputCentsPer1k: Number(process.env.H5_BILL_OUTPUT_CENTS_PER_1K ?? defaultOutputCentsPer1k), + usdCnyRate: Number(process.env.H5_USD_CNY_RATE ?? 7.2), + inputCentsPer1k: Number(process.env.H5_BILL_INPUT_CENTS_PER_1K ?? 2), + outputCentsPer1k: Number(process.env.H5_BILL_OUTPUT_CENTS_PER_1K ?? 6), minBillCents: Number(process.env.H5_MIN_BILL_CENTS ?? 1), }; } diff --git a/billing.test.mjs b/billing.test.mjs index 2206a54..5279949 100644 --- a/billing.test.mjs +++ b/billing.test.mjs @@ -1,10 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { - computeDeltaCostCents, - loadBillingConfig, - normalizeTokenState, -} from './billing.mjs'; +import { computeDeltaCostCents, normalizeTokenState } from './billing.mjs'; const config = { useBackendCost: false, @@ -14,43 +10,6 @@ const config = { minBillCents: 1, }; -test('loadBillingConfig defaults to DeepSeek Flash × 1 at USD/CNY 7.2', () => { - const savedInput = process.env.H5_BILL_INPUT_CENTS_PER_1K; - const savedOutput = process.env.H5_BILL_OUTPUT_CENTS_PER_1K; - const savedRate = process.env.H5_USD_CNY_RATE; - delete process.env.H5_BILL_INPUT_CENTS_PER_1K; - delete process.env.H5_BILL_OUTPUT_CENTS_PER_1K; - process.env.H5_USD_CNY_RATE = '7.2'; - try { - const billing = loadBillingConfig(); - assert.ok(Math.abs(billing.inputCentsPer1k - 0.1008) < 1e-9); - assert.ok(Math.abs(billing.outputCentsPer1k - 0.2016) < 1e-9); - } finally { - if (savedInput == null) delete process.env.H5_BILL_INPUT_CENTS_PER_1K; - else process.env.H5_BILL_INPUT_CENTS_PER_1K = savedInput; - if (savedOutput == null) delete process.env.H5_BILL_OUTPUT_CENTS_PER_1K; - else process.env.H5_BILL_OUTPUT_CENTS_PER_1K = savedOutput; - if (savedRate == null) delete process.env.H5_USD_CNY_RATE; - else process.env.H5_USD_CNY_RATE = savedRate; - } -}); - -test('computeDeltaCostCents bills DeepSeek Flash × 1 defaults with min charge', () => { - const billing = { - useBackendCost: false, - usdCnyRate: 7.2, - inputCentsPer1k: 0.1008, - outputCentsPer1k: 0.2016, - minBillCents: 1, - }; - const previous = { lastInputTokens: 0, lastOutputTokens: 0 }; - const current = normalizeTokenState({ - accumulatedInputTokens: 1000, - accumulatedOutputTokens: 500, - }); - assert.equal(computeDeltaCostCents(previous, current, billing), 1); -}); - test('normalizeTokenState reads camelCase fields', () => { const state = normalizeTokenState({ inputTokens: 10, diff --git a/capabilities.mjs b/capabilities.mjs index c68e6ac..8e113a8 100644 --- a/capabilities.mjs +++ b/capabilities.mjs @@ -194,9 +194,20 @@ export function sandboxDeveloperTools(capabilities) { return tools; } +/** + * Tools exposed by the sandbox MCP server (mindspace-sandbox-mcp.mjs) for static_publish users. + * read_file is always included because edit_file requires reading first. + */ +export function sandboxMcpTools(capabilities) { + const tools = ['read_file', 'write_file', 'edit_file', 'create_dir']; + if (capabilities.shell || capabilities.code_browse) tools.push('list_dir'); + return tools; +} + export function developerToolsFromPolicy(sessionPolicy) { const developer = sessionPolicy?.extensionOverrides?.find((ext) => ext.name === 'developer'); - return developer?.available_tools ?? []; + const sandboxFs = sessionPolicy?.extensionOverrides?.find((ext) => ext.name === 'sandbox-fs'); + return (sandboxFs ?? developer)?.available_tools ?? []; } function mergeDeveloperTools(capabilities) { @@ -211,10 +222,14 @@ function mergeDeveloperTools(capabilities) { /** * Build goose agent/start extension_overrides from resolved capability flags. * Returns null when the caller should use server defaults (admin / unrestricted). + * + * sandboxMcp: { serverPath, sandboxRoot, nodeExecPath? } + * When provided for a static_publish user, the built-in developer extension is replaced + * by a sandboxed stdio MCP that enforces filesystem boundaries at the OS level. */ export function buildAgentExtensionPolicy( capabilities, - { unrestricted = false, policies = null } = {}, + { unrestricted = false, policies = null, sandboxMcp = null } = {}, ) { if (unrestricted) { return { extensionOverrides: null, enableContextMemory: true, gooseMode: 'auto' }; @@ -222,9 +237,39 @@ export function buildAgentExtensionPolicy( const extensions = []; if (capabilities.static_publish) { - const sandboxTools = sandboxDeveloperTools(capabilities); - if (sandboxTools.length > 0) { - extensions.push(makeExtension('platform', 'developer', sandboxTools)); + if (sandboxMcp?.serverPath && sandboxMcp?.sandboxRoot) { + // Sandboxed stdio MCP: enforces SANDBOX_ROOT at the OS level. + // Replaces the built-in developer extension so path traversal is impossible. + const mcpTools = sandboxMcpTools(capabilities); + if (mcpTools.length > 0) { + extensions.push({ + type: 'stdio', + name: 'sandbox-fs', + description: '工作区沙箱文件系统(路径限制在用户工作区内)', + display_name: 'sandbox-fs', + bundled: false, + cmd: sandboxMcp.nodeExecPath ?? process.execPath, + // sandboxRoot passed as argv[2] so it works even if goosed doesn't forward envs + args: [sandboxMcp.serverPath, sandboxMcp.sandboxRoot], + // envs (goosed field name) as belt-and-suspenders backup + envs: { + SANDBOX_ROOT: sandboxMcp.sandboxRoot, + ALLOWED_TOOLS: mcpTools.join(','), + }, + available_tools: mcpTools, + }); + } + // Keep developer extension only for image reading when applicable. + if (capabilities.image_read) { + extensions.push(makeExtension('platform', 'developer', ['read_image'])); + } + } else { + // Fallback when sandbox MCP is not configured: use built-in developer extension. + // This is the legacy path — file operations are NOT boundary-enforced. + const sandboxTools = sandboxDeveloperTools(capabilities); + if (sandboxTools.length > 0) { + extensions.push(makeExtension('platform', 'developer', sandboxTools)); + } } extensions.push(makeExtension('platform', 'skills', [])); extensions.push(makeExtension('platform', 'summon', ['load_skill'])); @@ -301,7 +346,9 @@ export function buildPageEditAgentPolicy(basePolicy) { } const baseDeveloper = basePolicy?.extensionOverrides?.find((ext) => ext.name === 'developer'); - const canShell = baseDeveloper?.available_tools?.includes('shell') ?? false; + const canShell = + baseDeveloper?.available_tools?.includes('shell') || + basePolicy?.capabilities?.shell === true; const extensions = canShell ? [makeExtension('platform', 'developer', ['shell'])] : []; return { diff --git a/capabilities.test.mjs b/capabilities.test.mjs index a3b7404..8526a60 100644 --- a/capabilities.test.mjs +++ b/capabilities.test.mjs @@ -8,6 +8,7 @@ import { DEFAULT_USER_CAPABILITIES, normalizeCapabilityPatch, sandboxDeveloperTools, + sandboxMcpTools, } from './capabilities.mjs'; import { applyPoliciesToCapabilities } from './policies.mjs'; @@ -118,3 +119,38 @@ test('buildPageEditAgentPolicy without shell keeps empty developer tools', () => const narrowed = buildPageEditAgentPolicy(base); assert.deepEqual(narrowed.extensionOverrides, []); }); + +test('sandboxMcpTools returns correct tool list based on capabilities', () => { + const base = { ...DEFAULT_USER_CAPABILITIES, static_publish: true }; + assert.deepEqual(sandboxMcpTools(base), ['read_file', 'write_file', 'edit_file', 'create_dir']); + + const withBrowse = { ...base, code_browse: true }; + assert.ok(sandboxMcpTools(withBrowse).includes('list_dir')); + + const withShell = { ...base, shell: true }; + assert.ok(sandboxMcpTools(withShell).includes('list_dir')); +}); + +test('static_publish with sandboxMcp uses stdio sandbox-fs extension instead of developer', () => { + const caps = { ...DEFAULT_USER_CAPABILITIES, static_publish: true }; + const sandboxMcp = { serverPath: '/opt/h5/mindspace-sandbox-mcp.mjs', sandboxRoot: '/opt/h5/MindSpace/abc123' }; + const policy = buildAgentExtensionPolicy(caps, { sandboxMcp }); + + const sandboxExt = policy.extensionOverrides.find((ext) => ext.name === 'sandbox-fs'); + assert.ok(sandboxExt, 'sandbox-fs extension should be present'); + assert.equal(sandboxExt.type, 'stdio'); + assert.equal(sandboxExt.envs.SANDBOX_ROOT, '/opt/h5/MindSpace/abc123'); + assert.equal(sandboxExt.args[1], '/opt/h5/MindSpace/abc123'); // also passed as argv[2] + assert.ok(sandboxExt.available_tools.includes('write_file')); + assert.ok(sandboxExt.available_tools.includes('read_file')); + + // built-in developer extension should only remain for read_image (image_read: true by default) + const developer = policy.extensionOverrides.find((ext) => ext.name === 'developer'); + assert.ok(developer, 'developer should remain for read_image'); + assert.deepEqual(developer.available_tools, ['read_image']); + + // no full developer write/edit/shell in extensions + const allTools = policy.extensionOverrides.flatMap((e) => e.available_tools ?? []); + assert.ok(!allTools.includes('write'), 'built-in write should not be exposed'); + assert.ok(!allTools.includes('shell'), 'shell should not be exposed'); +}); diff --git a/chat-skills.mjs b/chat-skills.mjs index ed55612..d1eb240 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -60,14 +60,6 @@ export const CHAT_SKILL_DEFINITIONS = [ requiresPublish: true, promptKey: 'generate-page', }, - { - id: 'generate-image', - label: '生成图片', - icon: 'image', - skillName: 'image-designer', - prefillOnly: true, - promptKey: 'generate-image', - }, ]; export function buildChatSkillPrompt(promptKey, skillName) { @@ -86,8 +78,6 @@ export function buildChatSkillPrompt(promptKey, skillName) { return '请深入分析以下内容,给出结构化解读:背景、关键发现、风险或机会、以及建议下一步:'; case 'generate-page': return `请使用 ${skillName ?? PUBLISH_SKILL_NAME} 技能:在我的专属 MindSpace 发布目录生成静态 HTML 页面,并给出可公网访问的完整链接。`; - case 'generate-image': - return `请使用 ${skillName ?? 'image-designer'} 技能:以高级视觉设计师身份,根据我的需求生成高质量图片,保存到工作区并给出可点击预览链接。我的需求是:`; default: return ''; } diff --git a/chat-skills.test.mjs b/chat-skills.test.mjs index aeb133f..ba399af 100644 --- a/chat-skills.test.mjs +++ b/chat-skills.test.mjs @@ -31,7 +31,6 @@ test('filterChatSkills shows generate-page when publish is allowed', () => { test('buildChatSkillPrompt includes skill name for platform skills', () => { assert.match(buildChatSkillPrompt('web', 'web'), /请使用 web 技能/); assert.match(buildChatSkillPrompt('generate-page'), /static-page-publish/); - assert.match(buildChatSkillPrompt('generate-image', 'image-designer'), /image-designer/); }); test('prefillOnly is set for open-ended chat skills', () => { diff --git a/db.mjs b/db.mjs index 184c240..0c11c60 100644 --- a/db.mjs +++ b/db.mjs @@ -255,12 +255,6 @@ export async function migrateSchema(pool) { `ALTER TABLE h5_payment_orders MODIFY pay_mode ENUM('native', 'h5', 'jsapi') NOT NULL DEFAULT 'native'`, ); - - if (!(await indexExists(pool, 'h5_usage_records', 'idx_h5_usage_created'))) { - await pool.query( - `ALTER TABLE h5_usage_records ADD KEY idx_h5_usage_created (created_at)`, - ); - } } export async function initSchema(pool) { diff --git a/docs/g2-load-balancing.md b/docs/g2-load-balancing.md new file mode 100644 index 0000000..e92ec82 --- /dev/null +++ b/docs/g2-load-balancing.md @@ -0,0 +1,86 @@ +# g2.tkmind.cn 负载均衡(Studio + 105) + +> 2026-06-17 上线。把 g2 H5 的请求按权重分到两台机器:Studio(主)和 105(灰度副)。 +> goose **只在 Studio 跑一份**,105 是无状态前端孪生,通过 Tailscale 把请求代理回 Studio 的 goosed。 + +## 流量拓扑 + +``` +用户 → Cloudflare(橙云) → Tunnel 1d8cda42 → Studio Caddy(:8090, g2-lb) + ├─ 权重 19 → Studio 本机 portal 127.0.0.1:8081 + └─ 权重 1 → [SSH正向隧道 127.0.0.1:18080] → 105 portal :8080 + │ + 105 / Studio 两个 portal ── 都连 ──→ Studio goosed 100.99.38.66:18006(唯一实例) + 发布页文件 ── 都读 ──→ Studio canonical /Users/john/Project/Memind/MindSpace + (105 经反向隧道 + rclone 挂载到本地同路径) +``` + +- **为什么用 Caddy 加权而不是 Cloudflare LB**:账号未开通 Cloudflare Load Balancing 付费插件(建 pool/monitor 报 `Access Failed`)。Caddy 加权反代免费、可精确控权重,并自带 active 健康检查实现自动摘除。 +- **为什么 105 经 SSH 隧道接入**:Caddy 一个 `reverse_proxy` 不能混用 http(本地 :8081)和 https(105 公网 :443)上游。用 Studio→105 正向隧道把 105 暴露成本地 HTTP 端口,两个上游就都是本地 http,同协议。 + +## 调灰度比例(最常用) + +编辑 **Studio** 上的 `~/Project/Memind/scripts/g2-lb.Caddyfile`,改 `lb_policy weighted_round_robin` 后面两个权重(第一个=Studio :8081,第二个=105 :18080): + +| 配置 | Studio | 105 | +|------|--------|-----| +| `weighted_round_robin 19 1` | 95% | 5%(默认上线值) | +| `weighted_round_robin 9 1` | 90% | 10% | +| `weighted_round_robin 4 1` | 80% | 20% | +| `weighted_round_robin 1 1` | 50% | 50% | + +改完**零停机生效**: + +```bash +ssh studio # 或 ssh 100.99.38.66 +cd ~/Project/Memind/scripts +caddy validate --config g2-lb.Caddyfile # 可选,先校验 +caddy reload --config g2-lb.Caddyfile # 零停机热重载 +``` + +> `caddy` 路径若不在 PATH:用 `/opt/homebrew/bin/caddy`。 + +## 一键回滚(把流量全部收回 Studio) + +把 `to` 那行的 `127.0.0.1:18080` 删掉(只留 `127.0.0.1:8081`),或直接恢复备份,然后 reload: + +```bash +cd ~/Project/Memind/scripts +ls g2-lb.Caddyfile.bak-* # 找最近备份 +cp g2-lb.Caddyfile.bak-XXXX g2-lb.Caddyfile +caddy reload --config g2-lb.Caddyfile +``` + +## 验证 / 观测 + +每个 g2 响应都带 `X-Memind-Upstream` 头,标记实际命中的上游(`127.0.0.1:8081`=Studio,`127.0.0.1:18080`=105): + +```bash +# 打 N 次看分流比例 +for i in $(seq 1 40); do + curl -s -D - -o /dev/null https://g2.tkmind.cn/api/status \ + | grep -i x-memind-upstream | awk '{print $2}' | tr -d '\r' +done | sort | uniq -c + +# 看 Caddy 两个上游健康状态 +curl -s http://127.0.0.1:2019/reverse_proxy/upstreams # 在 Studio 上执行 +``` + +active 健康检查会每 10s 打一次各上游的 `/api/status`,连不上或非 200 就自动摘除该上游、流量全转到健康节点;恢复后自动加回。 + +## 涉及的组件与配置位置 + +| 组件 | 位置 | 作用 | +|------|------|------| +| g2-lb Caddy | Studio `scripts/g2-lb.Caddyfile`(LaunchAgent `cn.tkmind.g2-lb`,:8090) | 加权反代 + 健康检查 | +| 正向隧道 | Studio `scripts/memind-fwd-tunnel.sh`(LaunchAgent `cn.tkmind.memind-fwd-tunnel`) | Studio `127.0.0.1:18080` → 105 `:8080` | +| 反向隧道 | Studio `scripts/memind-mac-tunnel.sh`(LaunchAgent `cn.tkmind.memind-tunnel`) | 105 经此挂载 Studio 的 MindSpace 文件 | +| 105 portal | 105 systemd `goose-h5`(:8080),`/root/tkmind_go/ui/h5/.env` | 无状态前端,`TKMIND_API_TARGET=https://100.99.38.66:18006` | +| 105 文件挂载 | 105 systemd `.mount` → `/mnt/memind-shared`(rclone over 反向隧道) | 发布页/工作区共享只读 | + +## 关键约束(改动前必读) + +- **105 永不跑 goose**。goosed 单实例只在 Studio;session 存 Studio 本地 SQLite,文件操作也在 Studio 本地。105 只是代理 + serve。 +- 105 的 portal 必须设 `MEMIND_WORKSPACE_MAINTENANCE=0`、`MINDSPACE_AGENT_JOBS_ENABLED=false`——工作区维护守护(缩略图/资产同步 watcher)只该在 Studio 跑,否则 105 对 rclone 挂载树做递归 fs.watch 会占满 libuv 线程池导致 portal 启动卡死。 +- 105 必须与 Studio 跑**同一份代码**(用 `sync-to-105.sh` 从 Studio 同步)和**相同的 `TKMIND_SERVER__SECRET_KEY`**(否则连不上 goosed / 解不开共享 RDS 里的加密设置)。 +- 调权重只动 `g2-lb.Caddyfile`,不要动 105 的 systemd 单元或 .env。 diff --git a/docs/local-dev.md b/docs/local-dev.md new file mode 100644 index 0000000..7de25f6 --- /dev/null +++ b/docs/local-dev.md @@ -0,0 +1,32 @@ +# 本地开发 + +`pnpm dev` 启动后,用 **127.0.0.1 + 端口** 访问: + +| 服务 | 地址 | +|------|------| +| MindSpace H5 | http://127.0.0.1:5173/?preview=mindspace | +| Plaza | http://127.0.0.1:3001/plaza | +| Ops 审核后台 | http://127.0.0.1:3002/ops/ | +| API / Portal | http://127.0.0.1:8081 | +| memind_adm | http://127.0.0.1:8082 | + +```bash +pnpm install +cp .env.example .env +PLAZA_APP_DIR=/path/to/plaza pnpm dev +pnpm open:local-test # 浏览器打开 H5 +``` + +## 环境变量 + +| 变量 | 默认 | 说明 | +|------|------|------| +| `H5_PORT` | 8081 | Portal / API | +| `VITE_PORT` | 5173 | MindSpace 前端 | +| `PLAZA_PORT` | 3001 | Plaza | +| `OPS_PORT` | 3002 | Ops SPA | +| `ADMIN_PORT` | 8082 | memind_adm | +| `H5_PUBLIC_BASE_URL` | http://127.0.0.1:5173 | 公开链接基址 | +| `PLAZA_APP_DIR` | ../tkmind_go/ui/plaza | Plaza 源码路径 | + +Plaza 公网部署见 [plaza-local.md](./plaza-local.md)。 diff --git a/image-generation.mjs b/image-generation.mjs deleted file mode 100644 index 0803eea..0000000 --- a/image-generation.mjs +++ /dev/null @@ -1,340 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { Agent, fetch as undiciFetch } from 'undici'; -import { - decryptSecret, - normalizeApiUrl, - resolveChatCompletionsUrl, -} from './llm-providers.mjs'; -import { buildPublicUrl, resolvePublishKey } from './user-publish.mjs'; -import { isPathInsideUserWorkspace, resolveUserWorkspaceRoot } from './user-space.mjs'; - -const insecureDispatcher = new Agent({ - connect: { rejectUnauthorized: false }, -}); - -const ALLOWED_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']); -const ALLOWED_SIZES = new Set(['1024x1024', '1024x1792', '1792x1024', '512x512', '768x768']); -const ALLOWED_STYLES = new Set(['vivid', 'natural']); -const ALLOWED_QUALITIES = new Set(['standard', 'hd']); - -export function loadImageGenConfig(env = process.env) { - return { - enabled: env.H5_IMAGE_GEN_ENABLED !== '0', - apiUrl: String(env.H5_IMAGE_GEN_API_URL ?? '').trim(), - apiKey: String(env.H5_IMAGE_GEN_API_KEY ?? '').trim(), - model: String(env.H5_IMAGE_GEN_MODEL ?? 'dall-e-3').trim() || 'dall-e-3', - defaultSize: String(env.H5_IMAGE_GEN_DEFAULT_SIZE ?? '1024x1024').trim() || '1024x1024', - defaultStyle: String(env.H5_IMAGE_GEN_DEFAULT_STYLE ?? 'vivid').trim() || 'vivid', - defaultQuality: String(env.H5_IMAGE_GEN_DEFAULT_QUALITY ?? 'standard').trim() || 'standard', - billCents: Number(env.H5_IMAGE_GEN_BILL_CENTS ?? 30), - }; -} - -export function resolveImagesGenerationsUrl(apiUrl) { - const configured = normalizeApiUrl(apiUrl); - if (!configured) return ''; - if (configured.endsWith('/images/generations')) return configured; - if (configured.endsWith('/chat/completions')) { - return configured.replace(/\/chat\/completions$/, '/images/generations'); - } - if (configured.endsWith('/v1')) return `${configured}/images/generations`; - return `${configured}/v1/images/generations`; -} - -function resolveEncryptionKey(explicitKey) { - return ( - explicitKey ?? - process.env.H5_SETTINGS_ENCRYPTION_KEY ?? - process.env.TKMIND_SERVER__SECRET_KEY ?? - 'local-dev-secret' - ); -} - -function slugifyFilename(value) { - return String(value ?? '') - .normalize('NFKC') - .trim() - .toLowerCase() - .replace(/[^\p{L}\p{N}]+/gu, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 48); -} - -export function normalizeImageOutputPath(rawPath, prompt) { - const cleaned = String(rawPath ?? '') - .normalize('NFKC') - .trim() - .replace(/^\.?\/+/, '') - .replace(/\\/g, '/'); - const fallback = `assets/${slugifyFilename(prompt) || 'generated-image'}.png`; - const relative = cleaned || fallback; - if ( - !relative || - relative.includes('..') || - relative.startsWith('/') || - relative.includes('\0') - ) { - throw Object.assign(new Error('输出路径无效'), { code: 'invalid_output_path' }); - } - const ext = path.extname(relative).toLowerCase(); - if (!ALLOWED_EXTENSIONS.has(ext)) { - throw Object.assign(new Error('仅支持 png / jpg / webp 输出'), { code: 'invalid_output_path' }); - } - return relative; -} - -export function resolveWorkspaceOutputPath(workspaceRoot, relativePath) { - const absolute = path.resolve(workspaceRoot, relativePath); - if (!isPathInsideUserWorkspace(workspaceRoot, absolute)) { - throw Object.assign(new Error('输出路径必须位于当前用户工作区内'), { code: 'invalid_output_path' }); - } - return absolute; -} - -export function composeDesignerPrompt({ prompt, styleNotes, aspectRatio, brandColors, subject }) { - const parts = [ - 'Professional commercial visual design, high-end art direction, polished composition, clean lighting, production-ready quality.', - ]; - if (subject) parts.push(`Subject: ${subject}.`); - if (styleNotes) parts.push(`Style direction: ${styleNotes}.`); - if (aspectRatio) parts.push(`Aspect ratio intent: ${aspectRatio}.`); - if (brandColors) parts.push(`Brand palette: ${brandColors}.`); - parts.push(`Creative brief: ${String(prompt ?? '').trim()}`); - parts.push('Avoid text overlays, watermarks, logos, distorted anatomy, blurry details, or cluttered backgrounds unless explicitly requested.'); - return parts.join(' '); -} - -function parseImagePayload(data) { - const item = data?.data?.[0]; - if (!item) return null; - if (typeof item.b64_json === 'string' && item.b64_json.trim()) { - return { kind: 'base64', value: item.b64_json.trim(), revisedPrompt: item.revised_prompt ?? null }; - } - if (typeof item.url === 'string' && item.url.trim()) { - return { kind: 'url', value: item.url.trim(), revisedPrompt: item.revised_prompt ?? null }; - } - return null; -} - -async function loadProviderCredentials(pool, encryptionKey) { - const [rows] = await pool.query( - `SELECT api_url, api_key_ciphertext, api_key_iv, api_key_tag, relay_provider - FROM h5_llm_provider_keys - WHERE is_selected = 1 AND status = 'active' - LIMIT 1`, - ); - const row = rows[0]; - if (!row) { - throw Object.assign(new Error('请先在管理后台配置并启用 LLM'), { code: 'image_gen_not_configured' }); - } - let apiKey; - try { - apiKey = decryptSecret( - { - ciphertext: row.api_key_ciphertext, - iv: row.api_key_iv, - tag: row.api_key_tag, - }, - resolveEncryptionKey(encryptionKey), - ); - } catch { - throw Object.assign(new Error('LLM 密钥解密失败,请重新配置'), { code: 'image_gen_not_configured' }); - } - return { - apiUrl: normalizeApiUrl(row.api_url), - apiKey, - relayProvider: row.relay_provider ?? null, - }; -} - -async function resolveProvider(pool, encryptionKey) { - const config = loadImageGenConfig(); - if (config.apiUrl && config.apiKey) { - return { - url: resolveImagesGenerationsUrl(config.apiUrl), - apiKey: config.apiKey, - model: config.model, - relayProvider: null, - }; - } - const provider = await loadProviderCredentials(pool, encryptionKey); - const chatUrl = resolveChatCompletionsUrl(provider.apiUrl); - return { - url: resolveImagesGenerationsUrl(chatUrl || provider.apiUrl), - apiKey: provider.apiKey, - model: config.model, - relayProvider: provider.relayProvider, - }; -} - -async function downloadImageBuffer(payload) { - if (payload.kind === 'base64') { - return Buffer.from(payload.value, 'base64'); - } - const response = await undiciFetch(payload.value, { - dispatcher: payload.value.startsWith('https://') ? insecureDispatcher : undefined, - }); - if (!response.ok) { - throw Object.assign(new Error(`下载生成图片失败 (${response.status})`), { code: 'image_gen_failed' }); - } - const arrayBuffer = await response.arrayBuffer(); - return Buffer.from(arrayBuffer); -} - -function mimeTypeForPath(relativePath) { - switch (path.extname(relativePath).toLowerCase()) { - case '.jpg': - case '.jpeg': - return 'image/jpeg'; - case '.webp': - return 'image/webp'; - default: - return 'image/png'; - } -} - -export function buildImageGenerateCurlExample({ h5ApiBase, sessionId = '<当前会话ID>' } = {}) { - const base = String(h5ApiBase ?? 'http://127.0.0.1:8081').replace(/\/$/, ''); - return [ - '```bash', - `curl -sS -X POST '${base}/api/agent/generate_image' \\`, - " -H 'Content-Type: application/json' \\", - ` -d '{"session_id":"${sessionId}","prompt":"A premium travel poster for Malaysia, cinematic sunset, editorial photography","output_path":"assets/malaysia-hero.png","style_notes":"modern editorial, warm palette","aspect_ratio":"3:4 portrait"}'`, - '```', - ].join('\n'); -} - -export function createImageGenerationService({ - pool, - h5Root, - encryptionKey, - resolveUserIdForAgentSession, - publicBaseUrl, -}) { - async function generateForAgentSession(input = {}) { - const config = loadImageGenConfig(); - if (!config.enabled) { - throw Object.assign(new Error('图片生成未启用'), { code: 'image_gen_unavailable' }); - } - - const sessionId = String(input.sessionId ?? input.session_id ?? '').trim(); - const prompt = String(input.prompt ?? '').trim(); - if (!sessionId) { - throw Object.assign(new Error('缺少 session_id'), { code: 'invalid_request' }); - } - if (!prompt) { - throw Object.assign(new Error('缺少 prompt'), { code: 'invalid_request' }); - } - if (prompt.length > 4000) { - throw Object.assign(new Error('prompt 过长'), { code: 'invalid_request' }); - } - - const userId = await resolveUserIdForAgentSession(sessionId); - if (!userId) { - throw Object.assign(new Error('无效的 Agent 会话'), { code: 'forbidden' }); - } - - const user = { id: userId }; - - const relativePath = normalizeImageOutputPath(input.outputPath ?? input.output_path, prompt); - const workspaceRoot = resolveUserWorkspaceRoot(h5Root, user); - const absolutePath = resolveWorkspaceOutputPath(workspaceRoot, relativePath); - - const size = ALLOWED_SIZES.has(String(input.size ?? '').trim()) - ? String(input.size).trim() - : config.defaultSize; - const style = ALLOWED_STYLES.has(String(input.style ?? '').trim()) - ? String(input.style).trim() - : config.defaultStyle; - const quality = ALLOWED_QUALITIES.has(String(input.quality ?? '').trim()) - ? String(input.quality).trim() - : config.defaultQuality; - - const designerPrompt = composeDesignerPrompt({ - prompt, - styleNotes: input.styleNotes ?? input.style_notes, - aspectRatio: input.aspectRatio ?? input.aspect_ratio, - brandColors: input.brandColors ?? input.brand_colors, - subject: input.subject, - }); - - const provider = await resolveProvider(pool, encryptionKey); - if (!provider.url) { - throw Object.assign(new Error('图片生成 API 地址无效'), { code: 'image_gen_not_configured' }); - } - - let upstream; - try { - upstream = await undiciFetch(provider.url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${provider.apiKey}`, - }, - body: JSON.stringify({ - model: provider.model, - prompt: designerPrompt, - n: 1, - size, - style, - quality, - response_format: 'b64_json', - ...(provider.relayProvider ? { provider: provider.relayProvider } : {}), - }), - dispatcher: provider.url.startsWith('https://') ? insecureDispatcher : undefined, - }); - } catch (error) { - throw Object.assign( - new Error(`图片生成失败:${error instanceof Error ? error.message : '网络错误'}`), - { code: 'image_gen_failed' }, - ); - } - - const text = await upstream.text().catch(() => ''); - if (!upstream.ok) { - throw Object.assign(new Error(`图片生成失败 (${upstream.status})`), { - code: 'image_gen_failed', - details: text.slice(0, 500), - }); - } - - let data; - try { - data = JSON.parse(text); - } catch { - throw Object.assign(new Error('图片生成响应不是 JSON'), { code: 'image_gen_failed' }); - } - - const payload = parseImagePayload(data); - if (!payload) { - throw Object.assign(new Error('图片生成 API 未返回图片'), { code: 'image_gen_failed' }); - } - - const buffer = await downloadImageBuffer(payload); - if (buffer.length === 0) { - throw Object.assign(new Error('图片内容为空'), { code: 'image_gen_failed' }); - } - - await fs.mkdir(path.dirname(absolutePath), { recursive: true }); - await fs.writeFile(absolutePath, buffer); - - const publishKey = resolvePublishKey(user); - const publicUrl = buildPublicUrl(publicBaseUrl, publishKey, relativePath); - - return { - relativePath, - absolutePath, - publicUrl, - mimeType: mimeTypeForPath(relativePath), - size, - style, - quality, - revisedPrompt: payload.revisedPrompt, - prompt, - designerPrompt, - }; - } - - return { generateForAgentSession }; -} diff --git a/image-generation.test.mjs b/image-generation.test.mjs deleted file mode 100644 index 6c2df4f..0000000 --- a/image-generation.test.mjs +++ /dev/null @@ -1,68 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - buildImageGenerateCurlExample, - composeDesignerPrompt, - loadImageGenConfig, - normalizeImageOutputPath, - resolveImagesGenerationsUrl, -} from './image-generation.mjs'; - -test('resolveImagesGenerationsUrl derives from chat completions base', () => { - assert.equal( - resolveImagesGenerationsUrl('https://api.openai.com/v1/chat/completions'), - 'https://api.openai.com/v1/images/generations', - ); - assert.equal( - resolveImagesGenerationsUrl('https://api.openai.com/v1/images/generations'), - 'https://api.openai.com/v1/images/generations', - ); -}); - -test('normalizeImageOutputPath defaults to assets slug png', () => { - assert.equal( - normalizeImageOutputPath('', '马来西亚旅行海报'), - 'assets/马来西亚旅行海报.png', - ); - assert.equal(normalizeImageOutputPath('public/hero.webp', 'x'), 'public/hero.webp'); -}); - -test('normalizeImageOutputPath rejects traversal and unsupported extensions', () => { - assert.throws( - () => normalizeImageOutputPath('../secret.png', 'x'), - (error) => error.code === 'invalid_output_path', - ); - assert.throws( - () => normalizeImageOutputPath('assets/hero.svg', 'x'), - (error) => error.code === 'invalid_output_path', - ); -}); - -test('composeDesignerPrompt merges creative brief and guardrails', () => { - const prompt = composeDesignerPrompt({ - prompt: 'premium coffee brand poster', - styleNotes: 'minimal, warm beige palette', - aspectRatio: '3:4', - brandColors: '#c8a27d', - subject: 'latte art', - }); - assert.match(prompt, /premium coffee brand poster/); - assert.match(prompt, /minimal, warm beige palette/); - assert.match(prompt, /Avoid text overlays/); -}); - -test('buildImageGenerateCurlExample includes agent endpoint', () => { - const example = buildImageGenerateCurlExample({ - h5ApiBase: 'http://127.0.0.1:8081', - sessionId: 'sess-1', - }); - assert.match(example, /\/api\/agent\/generate_image/); - assert.match(example, /sess-1/); -}); - -test('loadImageGenConfig defaults', () => { - const config = loadImageGenConfig({}); - assert.equal(config.enabled, true); - assert.equal(config.model, 'dall-e-3'); - assert.equal(config.defaultSize, '1024x1024'); -}); diff --git a/llm-providers.mjs b/llm-providers.mjs index 836f94a..bdee02f 100644 --- a/llm-providers.mjs +++ b/llm-providers.mjs @@ -141,7 +141,7 @@ export const LOCAL_LLM_FALLBACK = { apiUrl: process.env.H5_LOCAL_LLM_URL ?? 'http://127.0.0.1:11434/v1/chat/completions', apiKey: process.env.H5_LOCAL_LLM_API_KEY ?? 'ollama', - model: process.env.H5_LOCAL_LLM_MODEL ?? 'qwen2.5-coder:7b', + model: process.env.H5_LOCAL_LLM_MODEL ?? 'qwen2.5:3b', }; let cachedLocalFallbackProviderId = null; diff --git a/llm-providers.test.mjs b/llm-providers.test.mjs index 46793ce..0aa17e6 100644 --- a/llm-providers.test.mjs +++ b/llm-providers.test.mjs @@ -351,7 +351,7 @@ test('syncProfileToGoosed tolerates missing /config/upsert endpoint', async () = }); test('LOCAL_LLM_FALLBACK defaults to local ollama 7b', () => { - assert.equal(LOCAL_LLM_FALLBACK.model, 'qwen2.5-coder:7b'); + assert.equal(LOCAL_LLM_FALLBACK.model, 'qwen2.5:3b'); assert.match(LOCAL_LLM_FALLBACK.apiUrl, /11434/); assert.equal(LOCAL_LLM_FALLBACK.enabled, true); }); diff --git a/mindspace-assets.mjs b/mindspace-assets.mjs index 1bc85d5..bbc1f70 100644 --- a/mindspace-assets.mjs +++ b/mindspace-assets.mjs @@ -8,7 +8,7 @@ import { ensureHtmlThumbnail, scheduleHtmlThumbnail, } from './mindspace-thumbnails.mjs'; -import { mirrorAssetToZone, removeZoneMirror, resolveUserWorkspaceRoot, resolveZoneFilePath } from './user-space.mjs'; +import { mirrorAssetToZone, removeZoneMirror, resolveUserWorkspaceRoot } from './user-space.mjs'; import { canPreviewAsset, renderAssetPreviewHtml } from './mindspace-asset-preview.mjs'; import { createWorkspaceAssetSync } from './mindspace-workspace-sync.mjs'; @@ -828,21 +828,10 @@ export function createAssetService(pool, options = {}) { [assetId], ); const html = await fs.readFile(assetPath, 'utf8'); - const contentBaseDir = - asset.sourceType === 'workspace' && h5Root - ? path.dirname( - resolveZoneFilePath( - resolveUserWorkspaceRoot(h5Root, { id: userId }), - asset.categoryCode, - asset.filename, - ), - ) - : undefined; return ensureHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), html, { title: asset.displayName, subtitle: asset.categoryCode?.toUpperCase?.() ?? 'HTML', contentStorageKey: versions[0]?.storage_key, - contentBaseDir, }); }; diff --git a/mindspace-chat-save.mjs b/mindspace-chat-save.mjs index 88f1dcf..46e8618 100644 --- a/mindspace-chat-save.mjs +++ b/mindspace-chat-save.mjs @@ -1,11 +1,6 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { - migrateUserPublishDir, - PUBLISH_ROOT_DIR, - PUBLISH_KEY_UUID, - resolveLegacyPublishDir, -} from './user-publish.mjs'; +import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID } from './user-publish.mjs'; import { workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs'; const URL_PATTERN = @@ -28,10 +23,11 @@ export function extractStaticPageLinks(content, { userId, username } = {}) { for (const match of text.matchAll(URL_PATTERN)) { const owner = decodePathSegment(match[1]).toLowerCase(); const relativePath = decodePathSegment(match[2]); - const ownedByUser = - (normalizedUserId && owner === normalizedUserId) || - (normalizedUsername && owner === normalizedUsername); - if ((normalizedUserId || normalizedUsername) && !ownedByUser) continue; + if (normalizedUserId) { + if (owner !== normalizedUserId) continue; + } else if (normalizedUsername && owner !== normalizedUsername) { + continue; + } const key = `${owner}/${relativePath}`; if (seen.has(key)) continue; seen.add(key); @@ -118,7 +114,7 @@ async function walkPublishHtmlByBasename(publishRoot, basename, maxDepth = 6, de return null; } -async function findPublishHtmlInRoot(publishRoot, relativePath, { readFile = fs.readFile } = {}) { +export async function findPublishHtml(h5Root, userId, relativePath) { const normalized = String(relativePath ?? '').replace(/^\/+/, ''); const basename = path.basename(normalized); const candidates = [ @@ -128,59 +124,22 @@ async function findPublishHtmlInRoot(publishRoot, relativePath, { readFile = fs. ].filter((value, index, list) => value && list.indexOf(value) === index); for (const candidate of candidates) { - const absolute = path.resolve(publishRoot, candidate); - if (absolute !== publishRoot && !absolute.startsWith(`${publishRoot}${path.sep}`)) continue; - if (!absolute.toLowerCase().endsWith('.html')) continue; try { - const content = await readFile(absolute, 'utf8'); - if (!content.trim()) continue; - const resolvedRelativePath = path.relative(publishRoot, absolute).split(path.sep).join('/'); - return { - absolute, - content, - relativePath: resolvedRelativePath, - filename: path.basename(resolvedRelativePath), - }; + return await readPublishHtml(h5Root, userId, candidate); } catch { // try next candidate } } + const publishRoot = path.resolve( + h5Root, + PUBLISH_ROOT_DIR, + String(userId ?? '').trim().toLowerCase(), + ); const absolute = await walkPublishHtmlByBasename(publishRoot, basename); if (absolute) { const resolvedRelativePath = path.relative(publishRoot, absolute).split(path.sep).join('/'); - const content = await readFile(absolute, 'utf8'); - if (!content.trim()) { - throw Object.assign(new Error('页面内容为空'), { code: 'empty_page_content' }); - } - return { - absolute, - content, - relativePath: resolvedRelativePath, - filename: path.basename(resolvedRelativePath), - }; - } - - return null; -} - -export async function findPublishHtml(h5Root, userId, relativePath, { username } = {}) { - const key = String(userId ?? '').trim().toLowerCase(); - if (username && key) { - migrateUserPublishDir(h5Root, { id: key, username }); - } - - const publishRoot = path.resolve(h5Root, PUBLISH_ROOT_DIR, key); - const primary = await findPublishHtmlInRoot(publishRoot, relativePath); - if (primary) return primary; - - const legacyRoot = username ? resolveLegacyPublishDir(h5Root, { username }) : null; - if (legacyRoot && path.resolve(legacyRoot) !== publishRoot) { - const legacy = await findPublishHtmlInRoot(legacyRoot, relativePath); - if (legacy) { - migrateUserPublishDir(h5Root, { id: key, username }); - return readPublishHtml(h5Root, userId, legacy.relativePath); - } + return readPublishHtml(h5Root, userId, resolvedRelativePath); } throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' }); @@ -256,9 +215,8 @@ function dedupeDuplicatedFilename(filename) { if (half >= 2 && base.slice(0, half) === base.slice(half)) { base = base.slice(0, half); } - base = base.replace(/(.{4,}?)\1+/g, '$1'); + base = base.replace(/(.+?)\1+/g, '$1'); base = base.replace(/-v-v(\d+)/g, '-v$1'); - base = base.replace(/-v(\d)\1+$/g, '-v$1'); return `${base}.html`; } @@ -330,123 +288,23 @@ export function analyzeChatMessageForSave({ }; } -function uniqueRelativePaths(relativePath) { - const basename = path.basename(String(relativePath ?? '')); - return [ - String(relativePath ?? '').replace(/^\/+/, ''), - String(relativePath ?? '') - .replace(/^\/+/, '') - .replace(/^public\//, ''), - `public/${basename}`, - basename, - ].filter((value, index, list) => value && list.indexOf(value) === index); -} - -function buildPublishHtmlFetchUrls({ userId, link, publicBaseUrl }) { - const urls = new Set(); - const base = String(publicBaseUrl ?? '').replace(/\/$/, ''); - if (base && link) { - for (const rel of uniqueRelativePaths(link.relativePath)) { - const asset = buildWorkspaceAssetUrl(userId, rel); - if (asset) urls.add(`${base}${asset}`); - } - } - if (link?.publicUrl) urls.add(link.publicUrl); - return [...urls]; -} - -async function mirrorFetchedPublishHtml(h5Root, userId, relativePath, content, { username } = {}) { - try { - if (username) { - migrateUserPublishDir(h5Root, { id: userId, username }); - } - const absolute = resolvePublishHtmlAbsolutePath(h5Root, userId, relativePath); - await fs.mkdir(path.dirname(absolute), { recursive: true }); - await fs.writeFile(absolute, content, 'utf8'); - return absolute; - } catch { - return null; - } -} - -export async function fetchPublishHtmlFallback( - analysis, - { publicBaseUrl, fetchImpl = fetch } = {}, -) { - if (analysis.contentMode !== 'static_html' || !analysis.selectedLink) return null; - const candidatePaths = uniqueRelativePaths(analysis.selectedLink.relativePath); - const urls = buildPublishHtmlFetchUrls({ - userId: analysis.userId, - link: analysis.selectedLink, - publicBaseUrl, - }); - for (const url of urls) { - try { - const response = await fetchImpl(url, { redirect: 'follow' }); - if (!response.ok) continue; - const contentType = response.headers.get('content-type') ?? ''; - if (contentType.includes('json')) continue; - const content = await response.text(); - if (!content.trim() || !/ { - const content = 'https://goo.tkmind.cn/MindSpace/john/report.html'; - const links = extractStaticPageLinks(content, { userId: USER_ID, username: 'john' }); - assert.equal(links.length, 1); - assert.equal(links[0].relativePath, 'report.html'); -}); - test('analyzeChatMessageForSave prefers static html mode', () => { const analysis = analyzeChatMessageForSave({ content: `链接 https://g2.tkmind.cn/MindSpace/${USER_ID}/report.html`, @@ -105,42 +96,6 @@ test('buildChatSavePreviewUrls use local api routes', () => { ); }); -test('findPublishHtml resolves html stored only in legacy username directory', async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-chat-save-legacy-')); - const legacyDir = path.join(root, 'MindSpace', 'john', 'public'); - await fs.mkdir(legacyDir, { recursive: true }); - await fs.writeFile( - path.join(legacyDir, 'legacy-only.html'), - 'Legacylegacy', - 'utf8', - ); - const loaded = await findPublishHtml(root, USER_ID, 'public/legacy-only.html', { username: 'john' }); - assert.equal(loaded.relativePath, 'public/legacy-only.html'); - assert.match(loaded.content, /Legacy/); - const migrated = path.join(root, 'MindSpace', USER_ID, 'public', 'legacy-only.html'); - assert.ok(await fs.stat(migrated).then(() => true).catch(() => false)); -}); - -test('resolveChatSaveAnalysis resolves uuid link when html exists only in legacy username dir', async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-chat-save-uuid-link-')); - const legacyDir = path.join(root, 'MindSpace', 'john', 'public'); - await fs.mkdir(legacyDir, { recursive: true }); - await fs.writeFile( - path.join(legacyDir, 'report.html'), - 'Reportok', - 'utf8', - ); - const content = `链接 https://g2.tkmind.cn/MindSpace/${USER_ID}/public/report.html`; - const result = await resolveChatSaveAnalysis({ - content, - userId: USER_ID, - username: 'john', - h5Root: root, - }); - assert.equal(result.analysis.contentMode, 'static_html'); - assert.equal(result.resolvedHtml?.relativePath, 'public/report.html'); -}); - test('findPublishHtml resolves nested public html by basename', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-chat-save-')); const publishDir = path.join(root, 'MindSpace', USER_ID, 'public'); @@ -161,11 +116,6 @@ test('injectHtmlBaseHref adds base tag for relative assets', () => { assert.match(next, / - `; - const key = 'users/u1/pages/p1/thumbnail.svg'; - const cached = buildFeedThumbnailSvg(extractCoverSignals(html, { title: 'OA 报告' })); - assert.equal(thumbnailHasEmbeddedPhoto(cached), false); - await fs.mkdir(path.dirname(path.join(root, key)), { recursive: true }); - await fs.writeFile(path.join(root, key), cached, 'utf8'); - - const svg = await ensureHtmlThumbnail(root, key, html, { - title: 'OA 报告', - contentBaseDir: htmlDir, - }); - assert.equal(thumbnailHasEmbeddedPhoto(svg), true); - assert.match(svg, /href="data:image\/png;base64,/); -}); diff --git a/ops/src/App.tsx b/ops/src/App.tsx index 1e12085..189bb85 100644 --- a/ops/src/App.tsx +++ b/ops/src/App.tsx @@ -1,25 +1,53 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { OpsLayout } from './components/OpsLayout'; import { RequireOps } from './components/RequireOps'; +import { RequireAdmin } from './components/RequireAdmin'; +import { AdminLayout } from './components/AdminLayout'; import { AnalyticsPage } from './pages/AnalyticsPage'; import { CreatorsPage } from './pages/CreatorsPage'; import { FeaturedPage } from './pages/FeaturedPage'; import { ReportsPage } from './pages/ReportsPage'; import { ReviewPage } from './pages/ReviewPage'; +import { SummaryPage } from './pages/admin/SummaryPage'; +import { UsersPage } from './pages/admin/UsersPage'; +import { LlmPage } from './pages/admin/LlmPage'; +import { BillingPage } from './pages/admin/BillingPage'; export function App() { return ( - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - - - + + {/* Plaza ops console */} + + + + } + > + } /> + } /> + } /> + } /> + } /> + + + {/* Super-admin console */} + + + + } + > + } /> + } /> + } /> + } /> + } /> + + + } /> + ); } diff --git a/ops/src/api/admin.ts b/ops/src/api/admin.ts new file mode 100644 index 0000000..f8f98c0 --- /dev/null +++ b/ops/src/api/admin.ts @@ -0,0 +1,207 @@ +async function adminFetch(path: string, init?: RequestInit): Promise { + const response = await fetch(path, { + ...init, + credentials: 'include', + headers: { + Accept: 'application/json', + ...(init?.body ? { 'Content-Type': 'application/json' } : {}), + ...(init?.headers ?? {}), + }, + }); + const payload = (await response.json().catch(() => ({}))) as { + data?: T; + message?: string; + error?: { code: string; message: string }; + }; + if (!response.ok) { + throw new Error(payload?.error?.message ?? payload?.message ?? `请求失败 (${response.status})`); + } + return (payload.data !== undefined ? payload.data : payload) as T; +} + +export type AdminUser = { + id: string; + username: string; + slug: string; + email: string; + displayName: string; + role: string; + status: string; + balanceCents: number; + createdAt: string; +}; + +export type AdminSummary = { + totalUsers: number; + activeUsers: number; + totalBalanceCents: number; + llm?: { + keyCount: number; + selectedKeyName: string | null; + globalModel: string | null; + }; +}; + +export type LlmKey = { + id: string; + name: string; + provider: string; + model?: string; + models?: string[]; + isSelected: boolean; + createdAt?: string; +}; + +export type LedgerEntry = { + id: string; + userId: string; + username?: string; + type: string; + amountCents: number; + note: string; + createdAt: string; +}; + +export type UsageRecord = { + id: string; + userId: string; + username?: string; + provider: string; + model: string; + inputTokens: number; + outputTokens: number; + costCents: number; + createdAt: string; +}; + +// ─── Summary ────────────────────────────────────────────────────────────────── + +export async function fetchAdminSummary() { + return adminFetch<{ summary: AdminSummary }>('/admin-api/summary'); +} + +// ─── Users ──────────────────────────────────────────────────────────────────── + +export async function fetchAdminUsers(params: { + page?: number; + pageSize?: number; + search?: string; + role?: string; + status?: string; +}) { + const q = new URLSearchParams(); + if (params.page) q.set('page', String(params.page)); + if (params.pageSize) q.set('pageSize', String(params.pageSize)); + if (params.search) q.set('search', params.search); + if (params.role) q.set('role', params.role); + if (params.status) q.set('status', params.status); + return adminFetch<{ users: AdminUser[]; total: number; page: number; pageSize: number; totalPages: number }>( + `/admin-api/users?${q}`, + ); +} + +export async function createAdminUser(body: { + username: string; + password: string; + displayName?: string; + role?: string; +}) { + return adminFetch<{ user: AdminUser }>('/admin-api/users', { + method: 'POST', + body: JSON.stringify(body), + }); +} + +export async function patchAdminUser( + userId: string, + patch: { role?: string; status?: string; displayName?: string; password?: string }, +) { + return adminFetch<{ user: AdminUser }>(`/admin-api/users/${userId}`, { + method: 'PATCH', + body: JSON.stringify(patch), + }); +} + +export async function rechargeUser(userId: string, amountCents: number, note = '') { + return adminFetch<{ user: AdminUser }>(`/admin-api/users/${userId}/recharge`, { + method: 'POST', + body: JSON.stringify({ amountCents, note }), + }); +} + +// ─── Billing ────────────────────────────────────────────────────────────────── + +export async function fetchAdminLedger(params: { page?: number; pageSize?: number } = {}) { + const q = new URLSearchParams(); + if (params.page) q.set('page', String(params.page)); + if (params.pageSize) q.set('pageSize', String(params.pageSize)); + return adminFetch<{ entries: LedgerEntry[]; total: number; page: number; totalPages: number }>( + `/admin-api/ledger?${q}`, + ); +} + +export async function fetchAdminUsage(params: { page?: number; pageSize?: number } = {}) { + const q = new URLSearchParams(); + if (params.page) q.set('page', String(params.page)); + if (params.pageSize) q.set('pageSize', String(params.pageSize)); + return adminFetch<{ records: UsageRecord[]; total: number; page: number; totalPages: number }>( + `/admin-api/usage?${q}`, + ); +} + +// ─── LLM Providers ─────────────────────────────────────────────────────────── + +export async function fetchLlmKeys() { + return adminFetch<{ keys: LlmKey[] }>('/admin-api/llm-providers/keys'); +} + +export async function createLlmKey(body: { + name: string; + provider: string; + apiKey: string; + model?: string; + models?: string; + baseUrl?: string; +}) { + return adminFetch<{ key: LlmKey }>('/admin-api/llm-providers/keys', { + method: 'POST', + body: JSON.stringify(body), + }); +} + +export async function patchLlmKey(keyId: string, patch: { name?: string; apiKey?: string; model?: string }) { + return adminFetch<{ key: LlmKey }>(`/admin-api/llm-providers/keys/${keyId}`, { + method: 'PATCH', + body: JSON.stringify(patch), + }); +} + +export async function selectLlmKey(keyId: string) { + return adminFetch(`/admin-api/llm-providers/keys/${keyId}/select`, { method: 'POST' }); +} + +export async function deleteLlmKey(keyId: string) { + return adminFetch(`/admin-api/llm-providers/keys/${keyId}`, { method: 'DELETE' }); +} + +export async function testLlmKey(keyId: string) { + return adminFetch<{ ok: boolean; model?: string; error?: string }>( + `/admin-api/llm-providers/keys/${keyId}/test`, + { method: 'POST' }, + ); +} + +export async function fetchLlmGlobal() { + return adminFetch<{ settings: { model: string | null } }>('/admin-api/llm-providers/global'); +} + +export async function putLlmGlobal(model: string) { + return adminFetch('/admin-api/llm-providers/global', { + method: 'PUT', + body: JSON.stringify({ model }), + }); +} + +export async function syncLlmProviders() { + return adminFetch<{ synced: number }>('/admin-api/llm-providers/sync', { method: 'POST' }); +} diff --git a/ops/src/components/AdminLayout.tsx b/ops/src/components/AdminLayout.tsx new file mode 100644 index 0000000..63c237f --- /dev/null +++ b/ops/src/components/AdminLayout.tsx @@ -0,0 +1,38 @@ +import { NavLink, Outlet } from 'react-router-dom'; + +const links = [ + { to: '/admin', label: '概览', end: true }, + { to: '/admin/users', label: '用户管理' }, + { to: '/admin/llm', label: 'LLM 配置' }, + { to: '/admin/billing', label: '账单记录' }, +]; + +export function AdminLayout() { + return ( +
+
+

超级管理后台

+

用户、计费与 LLM 配置

+
+ + +
+ ); +} diff --git a/ops/src/components/OpsLayout.tsx b/ops/src/components/OpsLayout.tsx index 3707b42..e18f245 100644 --- a/ops/src/components/OpsLayout.tsx +++ b/ops/src/components/OpsLayout.tsx @@ -1,7 +1,8 @@ import { NavLink, Outlet } from 'react-router-dom'; +import { useAuth } from '../lib/auth'; -const links = [ - { to: '/', label: '审核队列' }, +const opsLinks = [ + { to: '/', label: '审核队列', end: true }, { to: '/reports', label: '举报处理' }, { to: '/featured', label: '精选管理' }, { to: '/creators', label: '创作者' }, @@ -9,6 +10,8 @@ const links = [ ]; export function OpsLayout() { + const { user } = useAuth(); + return (
@@ -16,16 +19,25 @@ export function OpsLayout() {

内容审核、精选与数据概览

diff --git a/ops/src/components/RequireAdmin.tsx b/ops/src/components/RequireAdmin.tsx new file mode 100644 index 0000000..13d93b5 --- /dev/null +++ b/ops/src/components/RequireAdmin.tsx @@ -0,0 +1,31 @@ +import { useAuth } from '../lib/auth'; +import { mindSpaceLoginUrl } from '../lib/site'; + +export function RequireAdmin({ children }: { children: React.ReactNode }) { + const { loading, user } = useAuth(); + + if (loading) return

检查权限…

; + + if (!user) { + return ( +
+

需要登录

+

请先在 MindSpace 登录后再访问管理后台。

+ + 前往登录 + +
+ ); + } + + if (user.role !== 'admin') { + return ( +
+

权限不足

+

超级管理后台需要 role = admin,当前账号 ({user.username}) 无此权限。

+
+ ); + } + + return children; +} diff --git a/ops/src/components/RequireOps.tsx b/ops/src/components/RequireOps.tsx index 2631b07..165f020 100644 --- a/ops/src/components/RequireOps.tsx +++ b/ops/src/components/RequireOps.tsx @@ -1,45 +1,48 @@ -import { useEffect, useState } from 'react'; -import { fetchAuthStatus, fetchReviewQueue } from '../api/client'; +import { useAuth } from '../lib/auth'; import { mindSpaceLoginUrl } from '../lib/site'; +import { fetchReviewQueue } from '../api/client'; +import { useEffect, useState } from 'react'; export function RequireOps({ children }: { children: React.ReactNode }) { - const [state, setState] = useState<'loading' | 'ok' | 'denied' | 'forbidden'>('loading'); + const { loading: authLoading, user } = useAuth(); + const [state, setState] = useState<'loading' | 'ok' | 'forbidden'>('loading'); const [message, setMessage] = useState(null); useEffect(() => { + if (authLoading) return; + if (!user) { setState('forbidden'); setMessage(null); return; } + + // Admins bypass ops-role check. + if (user.role === 'admin') { setState('ok'); return; } + void (async () => { try { - const auth = await fetchAuthStatus(); - if (!auth.authenticated) { - setState('denied'); - return; - } await fetchReviewQueue('status=pending_review&limit=1'); setState('ok'); } catch (err) { - const text = err instanceof Error ? err.message : '无运营权限'; - if (text.includes('未授权') || text.includes('登录')) { - setState('denied'); - } else { - setMessage(text); - setState('forbidden'); - } + setMessage(err instanceof Error ? err.message : '无运营权限'); + setState('forbidden'); } })(); - }, []); + }, [authLoading, user]); - if (state === 'loading') return

检查登录态…

; - if (state === 'denied') { + if (authLoading || state === 'loading') return

检查登录态…

; + + if (!user) { return (

需要登录

-

请先在 MindSpace 登录,并确保账号已分配 ops_role(reviewer / editor / ops_admin)。

+

请先在 MindSpace 登录(须与 Ops 使用同一域名,例如都用 127.0.0.1 或都用 *.localhost)。

+

+ Ops 地址:http://127.0.0.1:3002/ops/ +

前往登录
); } + if (state === 'forbidden') { return (
@@ -51,5 +54,6 @@ export function RequireOps({ children }: { children: React.ReactNode }) {
); } + return children; } diff --git a/ops/src/index.css b/ops/src/index.css index d399d18..8f096ae 100644 --- a/ops/src/index.css +++ b/ops/src/index.css @@ -88,3 +88,20 @@ textarea { background: #2f6f57; color: white; } + +input, +select, +textarea { + border: 1px solid #d6d0c3; + border-radius: 8px; + padding: 8px 12px; + background: #fffdf7; + width: 100%; +} + +input:focus, +select:focus, +textarea:focus { + outline: 2px solid #2f6f57; + outline-offset: 1px; +} diff --git a/ops/src/lib/auth.tsx b/ops/src/lib/auth.tsx new file mode 100644 index 0000000..483e121 --- /dev/null +++ b/ops/src/lib/auth.tsx @@ -0,0 +1,30 @@ +import { createContext, useContext, useEffect, useState } from 'react'; + +export type AuthUser = { + id: string; + username: string; + displayName: string; + role: string; + status?: string; +}; + +type AuthState = { loading: boolean; user: AuthUser | null }; + +const AuthContext = createContext({ loading: true, user: null }); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [state, setState] = useState({ loading: true, user: null }); + + useEffect(() => { + void fetch('/auth/status', { credentials: 'include' }) + .then((r) => r.json()) + .then((auth: { authenticated: boolean; user?: AuthUser }) => { + setState({ loading: false, user: auth.authenticated ? (auth.user ?? null) : null }); + }) + .catch(() => setState({ loading: false, user: null })); + }, []); + + return {children}; +} + +export const useAuth = () => useContext(AuthContext); diff --git a/ops/src/main.tsx b/ops/src/main.tsx index 9d652a2..fb04a8e 100644 --- a/ops/src/main.tsx +++ b/ops/src/main.tsx @@ -1,13 +1,16 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; +import { AuthProvider } from './lib/auth'; import { App } from './App'; import './index.css'; createRoot(document.getElementById('root')!).render( - + + + , ); diff --git a/ops/src/pages/admin/BillingPage.tsx b/ops/src/pages/admin/BillingPage.tsx new file mode 100644 index 0000000..ae2a945 --- /dev/null +++ b/ops/src/pages/admin/BillingPage.tsx @@ -0,0 +1,181 @@ +import { useEffect, useState } from 'react'; +import { fetchAdminLedger, fetchAdminUsage, type LedgerEntry, type UsageRecord } from '../../api/admin'; + +type Tab = 'ledger' | 'usage'; + +function yuan(cents: number) { + return `¥${(cents / 100).toFixed(2)}`; +} + +function fmtTime(ts: string) { + return new Date(ts).toLocaleString('zh-CN', { hour12: false }); +} + +export function BillingPage() { + const [tab, setTab] = useState('ledger'); + + return ( +
+
+ + +
+ {tab === 'ledger' ? : } +
+ ); +} + +function LedgerTab() { + const [entries, setEntries] = useState([]); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [total, setTotal] = useState(0); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const load = async (p: number) => { + setLoading(true); + setError(null); + try { + const result = await fetchAdminLedger({ page: p, pageSize: 30 }); + setEntries(result.entries); + setTotal(result.total); + setTotalPages(result.totalPages); + setPage(p); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { void load(1); }, []); + + return ( + <> + {error ?

{error}

: null} +
+ + + + {['时间', '用户', '类型', '金额', '备注'].map((h) => ( + + ))} + + + + {entries.map((e) => ( + + + + + + + + ))} + {entries.length === 0 && !loading ? ( + + ) : null} + +
{h}
{fmtTime(e.createdAt)}{e.username ?? e.userId}{e.type}= 0 ? '#2f6f57' : '#b42318', + fontVariantNumeric: 'tabular-nums', + }} + > + {e.amountCents >= 0 ? '+' : ''}{yuan(e.amountCents)} + {e.note || '—'}
暂无记录
+
+ + + ); +} + +function UsageTab() { + const [records, setRecords] = useState([]); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [total, setTotal] = useState(0); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const load = async (p: number) => { + setLoading(true); + setError(null); + try { + const result = await fetchAdminUsage({ page: p, pageSize: 30 }); + setRecords(result.records); + setTotal(result.total); + setTotalPages(result.totalPages); + setPage(p); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { void load(1); }, []); + + return ( + <> + {error ?

{error}

: null} +
+ + + + {['时间', '用户', 'Provider', '模型', '输入', '输出', '费用'].map((h) => ( + + ))} + + + + {records.map((r) => ( + + + + + + + + + + ))} + {records.length === 0 && !loading ? ( + + ) : null} + +
{h}
{fmtTime(r.createdAt)}{r.username ?? r.userId}{r.provider}{r.model}{r.inputTokens.toLocaleString()}{r.outputTokens.toLocaleString()}{yuan(r.costCents)}
暂无记录
+
+ + + ); +} + +function Pagination({ + page, totalPages, total, unit, onGo, +}: { + page: number; totalPages: number; total: number; unit: string; onGo: (p: number) => void; +}) { + if (totalPages <= 1) return null; + return ( +
+ + 第 {page} / {totalPages} 页(共 {total} {unit}) + +
+ ); +} diff --git a/ops/src/pages/admin/LlmPage.tsx b/ops/src/pages/admin/LlmPage.tsx new file mode 100644 index 0000000..27eac78 --- /dev/null +++ b/ops/src/pages/admin/LlmPage.tsx @@ -0,0 +1,354 @@ +import { useEffect, useState } from 'react'; +import { + fetchLlmKeys, + fetchLlmGlobal, + createLlmKey, + patchLlmKey, + deleteLlmKey, + selectLlmKey, + testLlmKey, + putLlmGlobal, + syncLlmProviders, + type LlmKey, +} from '../../api/admin'; + +export function LlmPage() { + const [keys, setKeys] = useState([]); + const [globalModel, setGlobalModel] = useState(''); + const [globalModelInput, setGlobalModelInput] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [testResults, setTestResults] = useState>({}); + const [showCreate, setShowCreate] = useState(false); + const [editKey, setEditKey] = useState(null); + + const load = async () => { + setError(null); + try { + const [keysResult, globalResult] = await Promise.all([fetchLlmKeys(), fetchLlmGlobal()]); + setKeys(keysResult.keys); + const model = globalResult.settings?.model ?? ''; + setGlobalModel(model); + setGlobalModelInput(model); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } + }; + + useEffect(() => { void load(); }, []); + + const handleSelect = async (keyId: string) => { + setBusy(true); + try { + await selectLlmKey(keyId); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : '操作失败'); + } finally { + setBusy(false); + } + }; + + const handleDelete = async (key: LlmKey) => { + if (!window.confirm(`确认删除密钥「${key.name}」?`)) return; + setBusy(true); + try { + await deleteLlmKey(key.id); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : '操作失败'); + } finally { + setBusy(false); + } + }; + + const handleTest = async (key: LlmKey) => { + setTestResults((prev) => ({ ...prev, [key.id]: { ok: false, msg: '测试中…' } })); + try { + const result = await testLlmKey(key.id); + setTestResults((prev) => ({ + ...prev, + [key.id]: { ok: result.ok, msg: result.ok ? `OK (${result.model ?? ''})` : (result.error ?? '失败') }, + })); + } catch (err) { + setTestResults((prev) => ({ + ...prev, + [key.id]: { ok: false, msg: err instanceof Error ? err.message : '连接失败' }, + })); + } + }; + + const handleSaveGlobal = async () => { + setBusy(true); + try { + await putLlmGlobal(globalModelInput.trim()); + setGlobalModel(globalModelInput.trim()); + } catch (err) { + setError(err instanceof Error ? err.message : '保存失败'); + } finally { + setBusy(false); + } + }; + + const handleSync = async () => { + setBusy(true); + try { + const result = await syncLlmProviders(); + alert(`同步完成,更新 ${result.synced} 条`); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : '同步失败'); + } finally { + setBusy(false); + } + }; + + return ( +
+ {error ?

{error}

: null} + + {/* Global model */} +
+

全局模型设置

+
+ setGlobalModelInput(e.target.value)} + placeholder="如 deepseek-chat" + style={{ flex: '1 1 200px', padding: '8px 12px', borderRadius: 999, border: '1px solid #d6d0c3' }} + /> + +
+ {globalModel ? ( +

当前:{globalModel}

+ ) : ( +

未设置(使用选中密钥自带的 model)

+ )} +
+ + {/* Keys list */} +
+
+

API 密钥({keys.length})

+
+ + +
+
+ + {keys.length === 0 ? ( +

暂无配置密钥

+ ) : ( + keys.map((key) => ( +
+
+
+ {key.name} + {key.isSelected ? ( + ✓ 当前选中 + ) : null} +

+ {key.provider} {key.model ? `· ${key.model}` : ''} + {key.models?.length ? ` · ${key.models.join(', ')}` : ''} +

+
+
+ {!key.isSelected ? ( + + ) : null} + + + +
+
+ {testResults[key.id] ? ( +

+ {testResults[key.id].msg} +

+ ) : null} +
+ )) + )} +
+ + {showCreate ? ( + setShowCreate(false)} + onSuccess={() => { setShowCreate(false); void load(); }} + /> + ) : null} + + {editKey ? ( + setEditKey(null)} + onSuccess={() => { setEditKey(null); void load(); }} + /> + ) : null} +
+ ); +} + +function CreateKeyModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) { + const [name, setName] = useState(''); + const [provider, setProvider] = useState('openai'); + const [apiKey, setApiKey] = useState(''); + const [model, setModel] = useState(''); + const [baseUrl, setBaseUrl] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async () => { + if (!name.trim() || !apiKey.trim()) { setError('名称和 API Key 必填'); return; } + setBusy(true); + try { + await createLlmKey({ name: name.trim(), provider, apiKey, model: model.trim() || undefined, baseUrl: baseUrl.trim() || undefined }); + onSuccess(); + } catch (err) { + setError(err instanceof Error ? err.message : '创建失败'); + } finally { + setBusy(false); + } + }; + + return ( + +
+ setName(e.target.value)} placeholder="DeepSeek Default" /> + + + + setApiKey(e.target.value)} type="password" placeholder="sk-..." /> + setModel(e.target.value)} placeholder="deepseek-chat" /> + setBaseUrl(e.target.value)} placeholder="https://api.deepseek.com/v1" /> + {error ?

{error}

: null} + void handleSubmit()} busy={busy} /> +
+
+ ); +} + +function EditKeyModal({ llmKey, onClose, onSuccess }: { llmKey: LlmKey; onClose: () => void; onSuccess: () => void }) { + const [name, setName] = useState(llmKey.name); + const [apiKey, setApiKey] = useState(''); + const [model, setModel] = useState(llmKey.model ?? ''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async () => { + setBusy(true); + try { + const patch: Record = {}; + if (name !== llmKey.name) patch.name = name.trim(); + if (apiKey) patch.apiKey = apiKey; + if (model !== (llmKey.model ?? '')) patch.model = model.trim(); + if (Object.keys(patch).length === 0) { onClose(); return; } + await patchLlmKey(llmKey.id, patch); + onSuccess(); + } catch (err) { + setError(err instanceof Error ? err.message : '更新失败'); + } finally { + setBusy(false); + } + }; + + return ( + +
+ setName(e.target.value)} /> + setApiKey(e.target.value)} type="password" placeholder="留空不修改" /> + setModel(e.target.value)} /> + {error ?

{error}

: null} + void handleSubmit()} busy={busy} /> +
+
+ ); +} + +function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) { + return ( +
e.target === e.currentTarget && onClose()} + > +
+

{title}

+ {children} +
+
+ ); +} + +function LlmField({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +function ModalActions({ onClose, onSubmit, busy }: { onClose: () => void; onSubmit: () => void; busy: boolean }) { + return ( +
+ + +
+ ); +} diff --git a/ops/src/pages/admin/SummaryPage.tsx b/ops/src/pages/admin/SummaryPage.tsx new file mode 100644 index 0000000..c608a7b --- /dev/null +++ b/ops/src/pages/admin/SummaryPage.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import { fetchAdminSummary, type AdminSummary } from '../../api/admin'; + +function yuan(cents: number) { + return `¥${(cents / 100).toFixed(2)}`; +} + +export function SummaryPage() { + const [summary, setSummary] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + void fetchAdminSummary() + .then((r) => setSummary(r.summary)) + .catch((err) => setError(err instanceof Error ? err.message : '加载失败')); + }, []); + + if (error) return

{error}

; + if (!summary) return

加载中…

; + + return ( +
+
+
+

总用户

+ {summary.totalUsers} +
+
+

活跃用户

+ {summary.activeUsers} +
+
+

平台余额总计

+ {yuan(summary.totalBalanceCents)} +
+
+ + {summary.llm ? ( +
+

LLM 状态

+

已配置密钥:{summary.llm.keyCount} 条

+

当前选中:{summary.llm.selectedKeyName ?? '(无)'}

+

全局模型:{summary.llm.globalModel ?? '(未设置)'}

+
+ ) : ( +
+

LLM 服务未启用

+
+ )} +
+ ); +} diff --git a/ops/src/pages/admin/UsersPage.tsx b/ops/src/pages/admin/UsersPage.tsx new file mode 100644 index 0000000..05a8396 --- /dev/null +++ b/ops/src/pages/admin/UsersPage.tsx @@ -0,0 +1,389 @@ +import { useEffect, useRef, useState } from 'react'; +import { + fetchAdminUsers, + patchAdminUser, + rechargeUser, + createAdminUser, + type AdminUser, +} from '../../api/admin'; + +type EditTarget = { user: AdminUser; mode: 'edit' | 'recharge' | 'create' }; + +const PAGE_SIZE = 20; + +export function UsersPage() { + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [search, setSearch] = useState(''); + const [roleFilter, setRoleFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [editTarget, setEditTarget] = useState(null); + + const load = async (p = page) => { + setLoading(true); + setError(null); + try { + const result = await fetchAdminUsers({ + page: p, + pageSize: PAGE_SIZE, + search: search.trim() || undefined, + role: roleFilter || undefined, + status: statusFilter || undefined, + }); + setUsers(result.users); + setTotal(result.total); + setTotalPages(result.totalPages); + setPage(p); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(1); + }, []); + + const handleSearch = () => void load(1); + + return ( +
+ {/* Filters */} +
+ setSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + style={{ flex: '1 1 200px', padding: '8px 12px', borderRadius: 999, border: '1px solid #d6d0c3' }} + /> + + + + + 共 {total} 个用户 +
+ + {error ?

{error}

: null} + + {/* Table */} +
+ + + + {['用户名', '昵称', '角色', '状态', '余额', '注册时间', '操作'].map((h) => ( + + ))} + + + + {users.map((u) => ( + + + + + + + + + + ))} + {users.length === 0 && !loading ? ( + + + + ) : null} + +
+ {h} +
+ {u.username} + {u.displayName} + + {u.role} + + + + {u.status} + + {yuan(u.balanceCents)} + {u.createdAt ? new Date(u.createdAt).toLocaleDateString('zh-CN') : '—'} + +
+ + +
+
+ 暂无用户 +
+
+ + {/* Pagination */} + {totalPages > 1 ? ( +
+ + + 第 {page} / {totalPages} 页 + + +
+ ) : null} + + {/* Modal */} + {editTarget ? ( + setEditTarget(null)} + onSuccess={() => { + setEditTarget(null); + void load(page); + }} + /> + ) : null} +
+ ); +} + +function EditModal({ + target, + onClose, + onSuccess, +}: { + target: EditTarget; + onClose: () => void; + onSuccess: () => void; +}) { + const { user, mode } = target; + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + // edit fields + const [role, setRole] = useState(user.role); + const [status, setStatus] = useState(user.status ?? 'active'); + const [displayName, setDisplayName] = useState(user.displayName); + const [password, setPassword] = useState(''); + + // create fields + const [newUsername, setNewUsername] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [newDisplayName, setNewDisplayName] = useState(''); + const [newRole, setNewRole] = useState('user'); + + // recharge fields + const [amountYuan, setAmountYuan] = useState(''); + const [note, setNote] = useState(''); + + const dialogRef = useRef(null); + + const handleSubmit = async () => { + setBusy(true); + setError(null); + try { + if (mode === 'create') { + if (!newUsername.trim() || !newPassword.trim()) { + setError('用户名和密码必填'); + return; + } + await createAdminUser({ username: newUsername.trim(), password: newPassword, displayName: newDisplayName.trim() || undefined, role: newRole }); + } else if (mode === 'edit') { + const patch: Record = {}; + if (role !== user.role) patch.role = role; + if (status !== user.status) patch.status = status; + if (displayName !== user.displayName) patch.displayName = displayName; + if (password) patch.password = password; + if (Object.keys(patch).length === 0) { onClose(); return; } + await patchAdminUser(user.id, patch); + } else { + const cents = Math.round(parseFloat(amountYuan) * 100); + if (!cents || cents <= 0) { setError('请输入有效金额'); return; } + await rechargeUser(user.id, cents, note.trim()); + } + onSuccess(); + } catch (err) { + setError(err instanceof Error ? err.message : '操作失败'); + } finally { + setBusy(false); + } + }; + + const titles: Record = { + edit: `编辑用户:${user.username}`, + recharge: `充值:${user.username}(当前 ${yuan(user.balanceCents)})`, + create: '新建用户', + }; + + return ( +
e.target === e.currentTarget && onClose()} + > +
+

{titles[mode]}

+ + {mode === 'create' ? ( + <> + + setNewUsername(e.target.value)} placeholder="login username" /> + + + setNewPassword(e.target.value)} /> + + + setNewDisplayName(e.target.value)} /> + + + + + + ) : mode === 'edit' ? ( + <> + + setDisplayName(e.target.value)} /> + + + + + + + + + setPassword(e.target.value)} placeholder="留空不修改" /> + + + ) : ( + <> + + setAmountYuan(e.target.value)} + placeholder="例:5.00" + /> + + + setNote(e.target.value)} placeholder="管理员赠送" /> + + + )} + + {error ?

{error}

: null} + +
+ + +
+
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ +
+ {children} +
+
+ ); +} + +function yuan(cents: number) { + return `¥${(cents / 100).toFixed(2)}`; +} diff --git a/ops/vite.config.ts b/ops/vite.config.ts index 7b65f6f..335fa4c 100644 --- a/ops/vite.config.ts +++ b/ops/vite.config.ts @@ -1,16 +1,22 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; -const apiProxy = process.env.OPS_API_PROXY ?? 'http://127.0.0.1:8081'; +// /auth (login + session status) is served by the public portal; the ops +// console API (/api/ops/v1) lives in the standalone memind_adm service. +const authProxy = process.env.OPS_API_PROXY ?? 'http://127.0.0.1:8081'; +const adminProxy = process.env.OPS_ADMIN_PROXY ?? 'http://127.0.0.1:8082'; export default defineConfig({ base: '/ops/', plugins: [react()], server: { + host: '0.0.0.0', port: 3002, + allowedHosts: ['127.0.0.1', 'localhost', '.localhost'], proxy: { - '/api': apiProxy, - '/auth': apiProxy, + '/admin-api': adminProxy, + '/api': adminProxy, + '/auth': authProxy, }, }, build: { diff --git a/package-lock.json b/package-lock.json index 4863adf..fcc1302 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "tkmind-h5", "version": "0.1.0", "dependencies": { - "argon2": "^0.44.0", "debug": "^4.4.3", "express": "^4.21.2", "http-proxy-middleware": "^3.0.3", @@ -312,12 +311,6 @@ "node": ">=6.9.0" } }, - "node_modules/@epic-web/invariant": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", - "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", - "license": "MIT" - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -810,15 +803,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@phc/format": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", - "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/@redis/bloom": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", @@ -983,6 +967,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -997,6 +984,9 @@ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1011,6 +1001,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1025,6 +1018,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1039,6 +1035,9 @@ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1053,6 +1052,9 @@ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1067,6 +1069,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1081,6 +1086,9 @@ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1095,6 +1103,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1109,6 +1120,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1123,6 +1137,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1137,6 +1154,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1151,6 +1171,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1406,22 +1429,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/argon2": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz", - "integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@phc/format": "^1.0.0", - "cross-env": "^10.0.0", - "node-addon-api": "^8.5.0", - "node-gyp-build": "^4.8.4" - }, - "engines": { - "node": ">=16.17.0" - } - }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -1697,37 +1704,6 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, - "node_modules/cross-env": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", - "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", - "license": "MIT", - "dependencies": { - "@epic-web/invariant": "^1.0.0", - "cross-spawn": "^7.0.6" - }, - "bin": { - "cross-env": "dist/bin/cross-env.js", - "cross-env-shell": "dist/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2395,12 +2371,6 @@ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", "license": "MIT" }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2652,26 +2622,6 @@ "node": ">= 0.6" } }, - "node_modules/node-addon-api": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz", - "integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/node-releases": { "version": "2.0.47", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", @@ -2760,15 +2710,6 @@ "node": ">=8" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -3180,27 +3121,6 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -3618,21 +3538,6 @@ "node": ">=12" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", diff --git a/package.json b/package.json index 2f25274..f78831c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev:plaza": "node scripts/dev-plaza.mjs", "start:plaza": "node scripts/start-plaza-prod.mjs", "dev:plaza-proxy": "node scripts/plaza-local-proxy.mjs", + "open:local-test": "node scripts/open-local-test.mjs", "setup:plaza-dns": "node scripts/setup-plaza-local-dns.mjs", "setup:plaza-dns:remove": "node scripts/setup-plaza-local-dns.mjs --remove", "dev:plaza-dns": "node scripts/plaza-local-dns-server.mjs", @@ -15,10 +16,10 @@ "check:plaza": "node scripts/check-plaza-local.mjs", "setup:plaza-local": "node scripts/setup-plaza-local.mjs", "setup:plaza-tunnel": "bash scripts/install-plaza-tunnel.sh", - "setup:prod-services": "bash scripts/install-prod-services.sh", "setup:plaza-tls": "node scripts/plaza-local-tls.mjs", "dev:vite": "vite", "dev:server": "node server.mjs", + "dev:adm": "node admin-server.mjs", "dev:plaza-express": "node plaza-server.mjs", "dev:ops": "cd ops && npm run dev", "ops:grant": "node scripts/grant-ops-role.mjs", @@ -26,7 +27,7 @@ "seed:plaza-covers": "node scripts/seed-plaza-covers.mjs", "enrich:plaza": "node scripts/enrich-plaza-posts.mjs", "build": "vite build", - "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs image-generation.test.mjs", + "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs", "test:mindspace-e2e": "node scripts/mindspace-e2e.mjs", "test:mindspace-pages-e2e": "node scripts/mindspace-pages-e2e.mjs", "test:mindspace-publications-e2e": "node scripts/mindspace-publications-e2e.mjs", @@ -42,7 +43,6 @@ "deploy:plaza-105": "bash scripts/deploy-plaza-105.sh" }, "dependencies": { - "argon2": "^0.44.0", "debug": "^4.4.3", "express": "^4.21.2", "http-proxy-middleware": "^3.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2cd1321..a43a717 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - argon2: - specifier: ^0.44.0 - version: 0.44.0 debug: specifier: ^4.4.3 version: 4.4.3 @@ -149,9 +146,6 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} - '@epic-web/invariant@1.0.0': - resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} - '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -324,10 +318,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@phc/format@1.0.0': - resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} - engines: {node: '>=10'} - '@redis/bloom@1.2.0': resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} peerDependencies: @@ -551,10 +541,6 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - argon2@0.44.0: - resolution: {integrity: sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==} - engines: {node: '>=16.17.0'} - array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} @@ -639,15 +625,6 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} - cross-env@10.1.0: - resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} - engines: {node: '>=20'} - hasBin: true - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -878,9 +855,6 @@ packages: is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -969,14 +943,6 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} - node-addon-api@8.8.0: - resolution: {integrity: sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==} - engines: {node: ^18 || ^20 || >= 21} - - node-gyp-build@4.8.4: - resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} - hasBin: true - node-releases@2.0.47: resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} engines: {node: '>=18'} @@ -1009,10 +975,6 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - path-to-regexp@0.1.13: resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} @@ -1134,14 +1096,6 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -1271,11 +1225,6 @@ packages: which-module@2.0.1: resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -1423,8 +1372,6 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@epic-web/invariant@1.0.0': {} - '@esbuild/aix-ppc64@0.25.12': optional: true @@ -1522,8 +1469,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@phc/format@1.0.0': {} - '@redis/bloom@1.2.0(@redis/client@1.6.1)': dependencies: '@redis/client': 1.6.1 @@ -1695,13 +1640,6 @@ snapshots: dependencies: color-convert: 2.0.1 - argon2@0.44.0: - dependencies: - '@phc/format': 1.0.0 - cross-env: 10.1.0 - node-addon-api: 8.8.0 - node-gyp-build: 4.8.4 - array-flatten@1.1.1: {} aws-ssl-profiles@1.1.2: {} @@ -1785,17 +1723,6 @@ snapshots: cookie@1.1.1: {} - cross-env@10.1.0: - dependencies: - '@epic-web/invariant': 1.0.0 - cross-spawn: 7.0.6 - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - csstype@3.2.3: {} debug@2.6.9: @@ -2053,8 +1980,6 @@ snapshots: is-property@1.0.2: {} - isexe@2.0.0: {} - js-tokens@4.0.0: {} jsesc@3.1.0: {} @@ -2120,10 +2045,6 @@ snapshots: negotiator@0.6.3: {} - node-addon-api@8.8.0: {} - - node-gyp-build@4.8.4: {} - node-releases@2.0.47: {} object-inspect@1.13.4: {} @@ -2146,8 +2067,6 @@ snapshots: path-exists@4.0.0: {} - path-key@3.1.1: {} - path-to-regexp@0.1.13: {} picocolors@1.1.1: {} @@ -2298,12 +2217,6 @@ snapshots: setprototypeof@1.2.0: {} - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -2398,10 +2311,6 @@ snapshots: which-module@2.0.1: {} - which@2.0.2: - dependencies: - isexe: 2.0.0 - wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml deleted file mode 100644 index 03d61c0..0000000 --- a/pnpm-workspace.yaml +++ /dev/null @@ -1,2 +0,0 @@ -allowBuilds: - argon2: set this to true or false diff --git a/public/hello-john.html b/public/hello-john.html new file mode 100644 index 0000000..992c5f1 --- /dev/null +++ b/public/hello-john.html @@ -0,0 +1,159 @@ + + + + + + + + Hello, John! + + + +
+ + +
+
+ 👋 +

Hello!

+

Welcome, John

+ +
+ + diff --git a/regenerate-page-goose-test.mjs b/regenerate-page-goose-test.mjs new file mode 100644 index 0000000..cde27e0 --- /dev/null +++ b/regenerate-page-goose-test.mjs @@ -0,0 +1,278 @@ +#!/usr/bin/env node +/** + * 通过 goose 子会话重新生成 MindSpace 页面内容(Studio / RDS 环境测试) + * + * 用法: + * node scripts/regenerate-page-goose-test.mjs [pageId] [parentSessionId] + * + * 默认:探索世界 · TKMind(john,source_session 20260614_70) + */ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; +import { Agent, fetch as undiciFetch } from 'undici'; +import { createDbPool } from '../db.mjs'; +import { createUserAuth } from '../user-auth.mjs'; +import { createPageService } from '../mindspace-pages.mjs'; +import { createPageLiveEditService } from '../mindspace-page-live-edit.mjs'; +import { createPageEditSessionService } from '../mindspace-page-edit-session.mjs'; +import { createLlmProviderService } from '../llm-providers.mjs'; +import { extractMindSpacePagePatch } from '../mindspace-page-patch.mjs'; +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +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(); + const value = trimmed.slice(eq + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} + +loadEnvFile(path.join(root, '.env')); + +const PAGE_ID = process.argv[2] ?? '44a8d4f5-7dda-4e0c-973a-c580bf2cb82b'; +const PARENT_SESSION = process.argv[3] ?? '20260614_70'; +const USERNAME = 'john'; +const apiTarget = process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006'; +const apiSecret = process.env.TKMIND_SERVER__SECRET_KEY ?? ''; +const h5ApiBase = (process.env.H5_PUBLIC_BASE_URL ?? 'https://g2.tkmind.cn').replace(/\/$/, ''); +const insecureDispatcher = new Agent({ connect: { rejectUnauthorized: false } }); + +function isHttpsTarget(target) { + return target.startsWith('https://'); +} + +function createApiFetch() { + return async (pathname, init = {}) => { + const url = new URL(pathname, apiTarget); + const headers = { + ...(init.headers ?? {}), + 'X-Secret-Key': apiSecret, + }; + if (init.body && !headers['Content-Type']) { + headers['Content-Type'] = 'application/json'; + } + return undiciFetch(url, { + ...init, + headers, + dispatcher: isHttpsTarget(apiTarget) ? insecureDispatcher : undefined, + }); + }; +} + +async function executeSessionReply(apiFetch, sessionId, requestId, prompt) { + const eventsResponse = await apiFetch(`/sessions/${sessionId}/events`, { + method: 'GET', + headers: { Accept: 'text/event-stream' }, + }); + if (!eventsResponse.ok || !eventsResponse.body) { + const text = await eventsResponse.text().catch(() => ''); + throw new Error(text || '无法建立事件流'); + } + + const replyResponse = await apiFetch(`/sessions/${sessionId}/reply`, { + method: 'POST', + body: JSON.stringify({ + request_id: requestId, + user_message: { + role: 'user', + created: Date.now(), + content: [{ type: 'text', text: prompt }], + metadata: { userVisible: true, agentVisible: true }, + }, + }), + }); + if (!replyResponse.ok) { + const text = await replyResponse.text().catch(() => ''); + throw new Error(text || 'reply 失败'); + } + replyResponse.body?.cancel?.(); + + const reader = Readable.fromWeb(eventsResponse.body); + const decoder = new TextDecoder(); + let buffer = ''; + let messages = []; + + const pushMessage = (list, message) => { + const idx = list.findIndex((item) => item.id === message.id); + if (idx >= 0) { + const next = [...list]; + next[idx] = message; + return next; + } + return [...list, message]; + }; + + const messageVisibleText = (message) => + (message?.content ?? []) + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n') + .trim(); + + for await (const chunk of reader) { + buffer += decoder.decode(chunk, { stream: true }); + const frames = buffer.split('\n\n'); + buffer = frames.pop() ?? ''; + for (const frame of frames) { + let data = ''; + for (const line of frame.split('\n')) { + if (line.startsWith('data:')) data += line.slice(5).trim(); + } + if (!data) continue; + let event; + try { + event = JSON.parse(data); + } catch { + continue; + } + const routingId = event.chat_request_id ?? event.request_id; + if (routingId && routingId !== requestId) continue; + + if (event.type === 'Message' && event.message?.metadata?.userVisible) { + messages = pushMessage(messages, event.message); + } else if (event.type === 'UpdateConversation') { + messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible); + } else if (event.type === 'Error') { + throw new Error(event.error || 'goose 执行失败'); + } else if (event.type === 'Finish') { + const assistant = [...messages].reverse().find((item) => item.role === 'assistant'); + return { text: messageVisibleText(assistant) }; + } + } + } + throw new Error('goose 回复未完成'); +} + +async function main() { + const pool = createDbPool(); + const userAuth = createUserAuth(pool, { h5Root: root }); + const pageService = createPageService(pool, { + h5Root: root, + storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path.join(root, 'data', 'mindspace'), + }); + const pageLiveEdit = createPageLiveEditService({ + pageService, + resolveUserIdForAgentSession: async (sessionId) => { + const [rows] = await pool.query( + `SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`, + [sessionId], + ); + return rows[0]?.user_id ?? null; + }, + }); + const llmProviderService = createLlmProviderService(pool, { + apiTarget, + apiSecret, + encryptionKey: + process.env.H5_SETTINGS_ENCRYPTION_KEY ?? process.env.TKMIND_SERVER__SECRET_KEY ?? '', + }); + const pageEditSession = createPageEditSessionService({ + apiTarget, + apiSecret, + userAuth, + pageService, + pageLiveEdit, + llmProviderService, + }); + const apiFetch = createApiFetch(); + + const [userRows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [ + USERNAME, + ]); + const userId = userRows[0]?.id; + if (!userId) throw new Error(`用户 ${USERNAME} 不存在`); + + const pageBefore = await pageService.getPage(userId, PAGE_ID); + console.log('目标页面:', pageBefore.title, `(v${pageBefore.versionNo})`); + console.log('父 goose 会话:', PARENT_SESSION); + + const forked = await pageEditSession.forkSession({ + userId, + pageId: PAGE_ID, + parentSessionId: PARENT_SESSION, + h5ApiBase, + }); + console.log('✓ 子会话已 fork:', forked.sessionId); + + const prompt = `请基于当前页面 HTML,全面升级为更精美的旅行主题落地页: +- 深色沉浸式 Hero + 玻璃拟态卡片 + 渐变强调文字 +- 保留瑞士、马尔代夫、京都三个目的地,视觉更高级 +- 必须包含 mindspace-cover meta 与完整内联 CSS(CSP 安全,禁止外部 JS) +- 标题保持「探索世界 · TKMind」,并写一句更精炼的 summary + +提交方式(二选一,必须完成其一): +1. 有 shell 时 curl POST ${h5ApiBase}/api/agent/mindspace_page_patch +2. 否则在回复末尾输出 \`\`\`mindspace-page-update\`\`\` JSON(含 title/summary/content 完整 HTML)`; + + const requestId = crypto.randomUUID(); + console.log('→ 向 goose 发送重生成指令…'); + const reply = await executeSessionReply(apiFetch, forked.sessionId, requestId, prompt); + console.log('✓ goose 回复完成,末尾片段:'); + console.log(reply.text.slice(-800)); + + let pageAfter = await pageService.getPage(userId, PAGE_ID); + let changed = + pageAfter.versionNo !== pageBefore.versionNo || + pageAfter.updatedAt !== pageBefore.updatedAt || + pageAfter.content !== pageBefore.content; + + if (!changed) { + const patch = extractMindSpacePagePatch(reply.text); + if (patch) { + console.log('→ 从 mindspace-page-update 块应用补丁…'); + await pageLiveEdit.applyAgentPatch({ + sessionId: forked.sessionId, + pageId: PAGE_ID, + ...patch, + changeNote: 'goose 重生成精美旅行页', + }); + pageAfter = await pageService.getPage(userId, PAGE_ID); + changed = + pageAfter.versionNo !== pageBefore.versionNo || + pageAfter.updatedAt !== pageBefore.updatedAt || + pageAfter.content !== pageBefore.content; + } + } + + console.log('\n结果:'); + console.log(' 版本:', pageBefore.versionNo, '→', pageAfter.versionNo); + console.log(' 摘要:', pageAfter.summary?.slice(0, 120)); + console.log(' 内容已变更:', changed ? '是' : '否'); + + const [pubRows] = await pool.query( + `SELECT url_slug, status FROM h5_publish_records WHERE id = (SELECT current_publish_id FROM h5_page_records WHERE id = ?)`, + [PAGE_ID], + ); + const slug = pubRows[0]?.url_slug; + if (slug) { + console.log(' 公开预览:', `${h5ApiBase}/p/${slug}`); + } + + await pageEditSession.closeSession({ + userId, + sessionId: forked.sessionId, + pageId: PAGE_ID, + parentSessionId: PARENT_SESSION, + summary: '已通过子会话重生成旅行主题页视觉。', + }); + + await pool.end(); + if (!changed) { + console.error('\n⚠ 页面内容未检测到变更,请检查 goose 是否调用了 mindspace_page_patch'); + process.exit(1); + } + console.log('\n✅ 测试完成'); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/rsync_to_server.sh b/rsync_to_server.sh new file mode 100755 index 0000000..7bf6d64 --- /dev/null +++ b/rsync_to_server.sh @@ -0,0 +1,484 @@ +#!/usr/bin/env bash +# 一键部署:本地 dev → Studio(100) → 105 +# +# 用法: +# ./rsync_to_server.sh # 全量部署(100 先,105 后) +# ./rsync_to_server.sh --only-100 # 只更新 Studio +# ./rsync_to_server.sh --only-105 # 只触发 105 同步(Studio 代码已是最新) +# ./rsync_to_server.sh --skip-build # 跳过 vite build(纯后端改动时加速) +# ./rsync_to_server.sh --no-restart # 只同步代码,不重启服务 +# ./rsync_to_server.sh --dry-run # 预览将同步哪些文件,不做任何修改 + +set -euo pipefail + +# ── 配置 ────────────────────────────────────────────────────────────────────── +STUDIO_HOST="${STUDIO_HOST:-100.99.38.66}" +STUDIO_REMOTE="${STUDIO_REMOTE:-/Users/john/Project/Memind}" +STUDIO_NODE_BIN="${STUDIO_NODE_BIN:-/opt/homebrew/opt/node@24/bin}" +STUDIO_LAUNCHAGENT_PORTAL="cn.tkmind.memind-portal" +STUDIO_LAUNCHAGENT_PLAZA="cn.tkmind.plaza" +STUDIO_HEALTH="http://127.0.0.1:8081/api/status" +STUDIO_PLAZA_HEALTH="http://127.0.0.1:3001/plaza" +STUDIO_LOG_PORTAL="$HOME/Library/Logs/memind-portal.log" +STUDIO_LOG_PLAZA="$HOME/Library/Logs/memind-plaza.log" + +LOCAL_ROOT="$(cd "$(dirname "$0")" && pwd)" +EXCLUDE_STUDIO="${LOCAL_ROOT}/scripts/.rsync-exclude-studio" + +# ── 参数解析 ────────────────────────────────────────────────────────────────── +ONLY_100=0 +ONLY_105=0 +SKIP_BUILD=0 +NO_RESTART=0 +DRY_RUN=0 +AUTO_YES=0 + +for arg in "$@"; do + case "$arg" in + --only-100) ONLY_100=1 ;; + --only-105) ONLY_105=1 ;; + --skip-build) SKIP_BUILD=1 ;; + --no-restart) NO_RESTART=1 ;; + --dry-run) DRY_RUN=1 ;; + --yes|-y) AUTO_YES=1 ;; + -h|--help) + sed -n '2,9p' "$0" + exit 0 + ;; + *) + echo "未知参数: $arg" >&2 + echo "用法: $0 [--only-100] [--only-105] [--skip-build] [--no-restart] [--dry-run]" >&2 + exit 1 + ;; + esac +done + +if [[ "${ONLY_100}" -eq 1 && "${ONLY_105}" -eq 1 ]]; then + echo "错误: --only-100 与 --only-105 不能同时使用" >&2; exit 1 +fi + +# ── 工具函数 ────────────────────────────────────────────────────────────────── +RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'; BOLD='\033[1m'; NC='\033[0m' + +hr() { echo "──────────────────────────────────────────────────────"; } +ok() { echo -e " ${GREEN}✓${NC} $*"; } +warn() { echo -e " ${YELLOW}⚠${NC} $*"; } +fail() { echo -e " ${RED}✗${NC} $*" >&2; } +die() { fail "$*"; hr; exit 1; } + +ssh_studio() { ssh -o BatchMode=yes -o ConnectTimeout=15 "${STUDIO_HOST}" "$@"; } +ssh_105() { ssh_studio "ssh -o BatchMode=yes -o ConnectTimeout=15 ssh105 '$*'" 2>/dev/null; } + +# ══════════════════════════════════════════════════════════════════════════════ +# ① 本地 Pre-flight +# ══════════════════════════════════════════════════════════════════════════════ +preflight_local() { + echo "" + echo -e "${BOLD}[Pre-flight] 本地检查${NC}" + + # exclude 文件 + [[ -f "${EXCLUDE_STUDIO}" ]] \ + && ok "exclude 文件存在: scripts/.rsync-exclude-studio" \ + || die "缺少 ${EXCLUDE_STUDIO},无法继续" + + # 关键源码文件 + local missing=() + for f in server.mjs user-auth.mjs user-publish.mjs user-space.mjs package.json; do + [[ -f "${LOCAL_ROOT}/${f}" ]] || missing+=("${f}") + done + if [[ ${#missing[@]} -gt 0 ]]; then + die "本地缺少关键文件: ${missing[*]}" + fi + ok "关键源码文件存在" + + # WORKSPACE_MAINTENANCE patch —— 必须存在,否则 105 下次重启会卡死 + if ! grep -q 'WORKSPACE_MAINTENANCE_ENABLED' "${LOCAL_ROOT}/server.mjs"; then + die "server.mjs 缺少 WORKSPACE_MAINTENANCE_ENABLED patch!\n 此 patch 防止 105 在 rclone 挂载上启动时卡死。\n 请合并 Studio 生产版的 server.mjs patch 后再部署。" + fi + ok "server.mjs 含 WORKSPACE_MAINTENANCE patch" + + # 本地 .env 不能被意外同步(确认在 exclude 里) + if grep -q '^\.env$' "${EXCLUDE_STUDIO}"; then + ok ".env 已在 exclude 列表,不会被同步到 Studio" + else + die "exclude 文件未排除 .env!请检查 scripts/.rsync-exclude-studio" + fi + + # MindSpace 已被排除 + if grep -q '^MindSpace/' "${EXCLUDE_STUDIO}"; then + ok "MindSpace/ 已在 exclude 列表" + else + die "exclude 文件未排除 MindSpace/!" + fi +} + +# ══════════════════════════════════════════════════════════════════════════════ +# ② 远端 Pre-flight(Studio) +# ══════════════════════════════════════════════════════════════════════════════ +preflight_studio() { + echo "" + echo -e "${BOLD}[Pre-flight] Studio(${STUDIO_HOST}) 检查${NC}" + + # SSH 连通性 + if ! ssh_studio "echo ok" &>/dev/null; then + die "无法 SSH 到 Studio (${STUDIO_HOST})" + fi + ok "SSH 连通" + + # .env 存在且非空 + local env_size + env_size="$(ssh_studio "stat -f%z '${STUDIO_REMOTE}/.env' 2>/dev/null || echo 0")" + if [[ "${env_size}" -lt 10 ]]; then + die "Studio .env 不存在或为空!(size=${env_size})" + fi + ok ".env 存在(${env_size} bytes)" + + # MindSpace 有内容 + local ms_count + ms_count="$(ssh_studio "ls '${STUDIO_REMOTE}/MindSpace' | wc -l | tr -d ' '")" + if [[ "${ms_count}" -lt 1 ]]; then + die "Studio MindSpace/ 为空!部署前请确认生产数据正常。" + fi + ok "MindSpace/ 有 ${ms_count} 个条目" + + # data/ 目录存在 + if ! ssh_studio "test -d '${STUDIO_REMOTE}/data'"; then + die "Studio data/ 目录不存在!" + fi + ok "data/ 目录存在" + + # 记录 .env 快照(大小 + 修改时间),用于 post-rsync 验证 + STUDIO_ENV_STAT="$(ssh_studio "stat -f'%z %m' '${STUDIO_REMOTE}/.env'")" + STUDIO_MS_COUNT="${ms_count}" +} + +# ══════════════════════════════════════════════════════════════════════════════ +# ③ Dry-run 预览 +# ══════════════════════════════════════════════════════════════════════════════ +show_dry_run() { + echo "" + echo -e "${BOLD}[预览] 将同步以下文件变更到 Studio${NC}" + rsync -az --delete --dry-run --itemize-changes \ + --exclude-from="${EXCLUDE_STUDIO}" \ + "${LOCAL_ROOT}/" \ + "${STUDIO_HOST}:${STUDIO_REMOTE}/" \ + 2>/dev/null \ + | grep -v '^\.' \ + | head -80 \ + || true + echo "" + + if [[ "${DRY_RUN}" -eq 1 ]]; then + echo "(--dry-run 模式,未做任何实际修改)" + return 0 + fi + + if [[ "${AUTO_YES}" -eq 1 ]]; then + ok "已自动确认(--yes)" + return 0 + fi + + # 交互确认(直接读 /dev/tty,不受 stdin pipe 影响) + local confirm + read -r -p " 确认同步?[y/N] " confirm /dev/null || md5sum "${LOCAL_ROOT}/${f}" | awk '{print $1}')" + remote_sum="$(ssh_studio "openssl md5 '${STUDIO_REMOTE}/${f}' 2>/dev/null | awk '{print \$2}'")" + if [[ "${local_sum}" == "${remote_sum}" ]]; then + ok "${f}" + else + fail "${f} md5 不一致(local=${local_sum} remote=${remote_sum})" + all_ok=0 + fi + done + [[ "${all_ok}" -eq 1 ]] || die "md5 校验失败,请检查文件同步是否正常" +} + +# ══════════════════════════════════════════════════════════════════════════════ +# ⑤ Studio 重启并健康检查(Portal → Plaza 顺序) +# ══════════════════════════════════════════════════════════════════════════════ +wait_studio_healthy() { + local STUDIO_UID + STUDIO_UID="$(ssh_studio "id -u")" + + # ── Portal ────────────────────────────────────────────────────────────────── + echo "" + echo -e "${BOLD}[重启] Studio portal (cn.tkmind.memind-portal)${NC}" + ssh_studio "launchctl kickstart -k gui/${STUDIO_UID}/${STUDIO_LAUNCHAGENT_PORTAL}" >/dev/null + + echo -n " 等待 portal :8081..." + local ok_flag=0 + for i in $(seq 1 20); do + sleep 2 + if ssh_studio "curl -sf '${STUDIO_HEALTH}'" &>/dev/null; then + echo " ✓ (${i}×2s = $((i*2))s)" + ok_flag=1 + break + fi + printf "." + done + if [[ "${ok_flag}" -eq 0 ]]; then + echo "" + fail "portal 40s 内未就绪" + echo " 最近日志:" + ssh_studio "tail -30 '${STUDIO_LOG_PORTAL}' 2>/dev/null" | sed 's/^/ /' + die "Studio portal 启动失败,已中止,不会继续重启 Plaza 或同步 105" + fi + + # 日志扫描 + local portal_errors + portal_errors="$(ssh_studio "tail -60 '${STUDIO_LOG_PORTAL}' 2>/dev/null \ + | grep -iE 'error|exception|uncaught|fatal' || true")" + if [[ -n "${portal_errors}" ]]; then + warn "portal 启动日志含警告/错误(请人工确认):" + echo "${portal_errors}" | head -8 | sed 's/^/ /' + else + ok "portal 启动日志无 ERROR" + fi + + # ── Plaza ─────────────────────────────────────────────────────────────────── + # Plaza 依赖 portal 先就绪(run-plaza-prod.sh 内有等待逻辑),所以 portal 健康后才重启 + echo "" + echo -e "${BOLD}[重启] Studio plaza (cn.tkmind.plaza)${NC}" + ssh_studio "launchctl kickstart -k gui/${STUDIO_UID}/${STUDIO_LAUNCHAGENT_PLAZA}" >/dev/null + + echo -n " 等待 plaza :3001..." + ok_flag=0 + for i in $(seq 1 30); do + sleep 3 + if ssh_studio "curl -sf -o /dev/null '${STUDIO_PLAZA_HEALTH}'" &>/dev/null; then + echo " ✓ (${i}×3s = $((i*3))s)" + ok_flag=1 + break + fi + printf "." + done + if [[ "${ok_flag}" -eq 0 ]]; then + echo "" + warn "plaza 90s 内未就绪(非致命,portal 和 105 仍可用)" + echo " 最近日志:" + ssh_studio "tail -20 '${STUDIO_LOG_PLAZA}' 2>/dev/null" | sed 's/^/ /' + else + local plaza_errors + plaza_errors="$(ssh_studio "tail -40 '${STUDIO_LOG_PLAZA}' 2>/dev/null \ + | grep -iE 'error|exception|uncaught|fatal' || true")" + if [[ -n "${plaza_errors}" ]]; then + warn "plaza 启动日志含警告/错误:" + echo "${plaza_errors}" | head -8 | sed 's/^/ /' + else + ok "plaza 启动日志无 ERROR" + fi + fi +} + +# ══════════════════════════════════════════════════════════════════════════════ +# ⑥ 105 Post-sync 额外校验 +# ══════════════════════════════════════════════════════════════════════════════ +verify_105_after_sync() { + echo "" + echo -e "${BOLD}[校验] 105 部署后检查${NC}" + + # goose-h5 正在运行 + local svc_status + svc_status="$(ssh_studio "ssh ssh105 'systemctl is-active goose-h5 2>/dev/null'" 2>/dev/null || echo "unknown")" + if [[ "${svc_status}" == "active" ]]; then + ok "105 goose-h5 状态: active" + else + die "105 goose-h5 不是 active 状态(${svc_status})" + fi + + # 105 .env 关键开关仍然正确 + local maintenance_val + maintenance_val="$(ssh_studio "ssh ssh105 'grep MEMIND_WORKSPACE_MAINTENANCE /root/tkmind_go/ui/h5/.env 2>/dev/null'" 2>/dev/null || echo "")" + if echo "${maintenance_val}" | grep -q 'MEMIND_WORKSPACE_MAINTENANCE=0'; then + ok "105 .env: MEMIND_WORKSPACE_MAINTENANCE=0 ✓" + else + die "105 .env 缺少或错误设置 MEMIND_WORKSPACE_MAINTENANCE!\n 当前值: ${maintenance_val}\n 必须为 0,否则 105 重启会卡死!" + fi + + # 105 server.mjs patch 存在 + local patch_count_105 + patch_count_105="$(ssh_studio "ssh ssh105 'grep -c WORKSPACE_MAINTENANCE_ENABLED /root/tkmind_go/ui/h5/server.mjs 2>/dev/null || echo 0'" 2>/dev/null || echo "0")" + if [[ "${patch_count_105}" -ge 1 ]]; then + ok "105 server.mjs WORKSPACE_MAINTENANCE patch 存在" + else + die "105 server.mjs 缺少 WORKSPACE_MAINTENANCE patch!\n 105 重启时将对 rclone 挂载做 fs.watch,导致卡死!" + fi + + # 105 MindSpace 挂载有内容 + local ms_105 + ms_105="$(ssh_studio "ssh ssh105 'ls /mnt/memind-shared/MindSpace 2>/dev/null | wc -l | tr -d \" \"'" 2>/dev/null || echo "0")" + if [[ "${ms_105}" -gt 0 ]]; then + ok "105 MindSpace 挂载正常(${ms_105} 个条目)" + else + warn "105 /mnt/memind-shared/MindSpace 为空,请确认 rclone 挂载是否正常" + fi + + # 105 goose-h5 健康检查 + if ssh_studio "ssh ssh105 'curl -sf http://127.0.0.1:8080/api/status'" &>/dev/null; then + ok "105 goose-h5 :8080/api/status 健康" + else + die "105 goose-h5 健康检查失败" + fi + + # 105 goose-plaza-web(可选服务,不在 sync-to-105.sh 里重启,检查状态即可) + local plaza_105_status + plaza_105_status="$(ssh_studio "ssh ssh105 'systemctl is-active goose-plaza-web 2>/dev/null || echo inactive'" 2>/dev/null || echo "unknown")" + if [[ "${plaza_105_status}" == "active" ]]; then + ok "105 goose-plaza-web 状态: active" + else + warn "105 goose-plaza-web 不是 active(${plaza_105_status})" + warn "如有 plaza 代码变更,需手动重启: ssh ssh105 systemctl restart goose-plaza-web" + fi +} + +# ══════════════════════════════════════════════════════════════════════════════ +# Banner +# ══════════════════════════════════════════════════════════════════════════════ +hr +echo -e " ${BOLD}Memind 一键部署${NC} $(date '+%Y-%m-%d %H:%M:%S')" +[[ "${ONLY_105}" -eq 0 ]] && echo " 本地 → Studio: ${STUDIO_HOST}:${STUDIO_REMOTE}" +[[ "${ONLY_100}" -eq 0 ]] && echo " Studio → 105: 经 sync-to-105.sh 触发" +[[ "${SKIP_BUILD}" -eq 1 ]] && echo " 模式: --skip-build(跳过 vite build)" +[[ "${NO_RESTART}" -eq 1 ]] && echo " 模式: --no-restart(只同步代码)" +[[ "${DRY_RUN}" -eq 1 ]] && echo " 模式: --dry-run(预览,不做修改)" +hr + +# ══════════════════════════════════════════════════════════════════════════════ +# 主流程 +# ══════════════════════════════════════════════════════════════════════════════ +if [[ "${ONLY_105}" -eq 0 ]]; then + preflight_local + preflight_studio + show_dry_run + + echo "" + echo -e "${BOLD}▶ [Studio 1/4] rsync 代码...${NC}" + rsync -az --delete \ + --exclude-from="${EXCLUDE_STUDIO}" \ + "${LOCAL_ROOT}/" \ + "${STUDIO_HOST}:${STUDIO_REMOTE}/" + ok "代码同步完成" + + verify_studio_after_rsync + + echo "" + echo -e "${BOLD}▶ [Studio 2/4] npm install...${NC}" + ssh_studio "cd '${STUDIO_REMOTE}' && PATH='${STUDIO_NODE_BIN}':'\$PATH' npm install --prefer-offline 2>&1 | tail -5" + + if [[ "${SKIP_BUILD}" -eq 0 ]]; then + echo "" + echo -e "${BOLD}▶ [Studio 3/4] npm run build...${NC}" + if ! ssh_studio "cd '${STUDIO_REMOTE}' && PATH='${STUDIO_NODE_BIN}':'\$PATH' npm run build"; then + warn "vite build 失败,服务仍在运行旧产物" + if [[ "${AUTO_YES}" -eq 0 ]]; then + local confirm + read -r -p " 继续重启服务?[y/N] " confirm /dev/null; then + die "Studio portal 此刻不健康,已暂停 105 同步,请先排查 Studio 问题" + fi + ok "Studio portal 确认健康" + fi + + echo "" + echo -e "${BOLD}▶ [105] 触发 Studio → 105 同步...${NC}" + SYNC_ENV="" + [[ "${SKIP_BUILD}" -eq 1 ]] && SYNC_ENV="${SYNC_ENV} SKIP_BUILD=1" + [[ "${NO_RESTART}" -eq 1 ]] && SYNC_ENV="${SYNC_ENV} NO_RESTART=1" + ssh_studio "cd '${STUDIO_REMOTE}' && env ${SYNC_ENV} bash scripts/sync-to-105.sh" + + if [[ "${NO_RESTART}" -eq 0 ]]; then + verify_105_after_sync + fi +fi + +# ══════════════════════════════════════════════════════════════════════════════ +# 完成 +# ══════════════════════════════════════════════════════════════════════════════ +echo "" +hr +if [[ "${ONLY_105}" -eq 1 ]]; then + echo -e " ${GREEN}${BOLD}✅ 105 已更新${NC}" +elif [[ "${ONLY_100}" -eq 1 ]]; then + echo -e " ${GREEN}${BOLD}✅ Studio(100) 已更新${NC}" +else + echo -e " ${GREEN}${BOLD}✅ Studio(100) + 105 均已更新${NC}" +fi +echo " $(date '+%Y-%m-%d %H:%M:%S')" +hr diff --git a/schema.sql b/schema.sql index 72d5a0d..3b31f95 100644 --- a/schema.sql +++ b/schema.sql @@ -354,7 +354,6 @@ CREATE TABLE IF NOT EXISTS h5_usage_records ( balance_after_cents BIGINT NOT NULL, created_at BIGINT NOT NULL, KEY idx_h5_usage_user_time (user_id, created_at), - KEY idx_h5_usage_created (created_at), CONSTRAINT fk_h5_usage_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/scripts/.rsync-exclude-105 b/scripts/.rsync-exclude-105 index 30f1c4f..d4bd41e 100644 --- a/scripts/.rsync-exclude-105 +++ b/scripts/.rsync-exclude-105 @@ -1,11 +1,49 @@ -node_modules -dist +# ============================================================ +# rsync 排除规则:Studio(100) → 105 生产 +# 在 Studio 上执行,路径相对于 ~/Project/Memind +# ============================================================ + +# ── 机密配置(105 有自己的 .env,含独立 API_TARGET 等)── .env + +# ── 生产用户数据 ────────────────────────────────────────── +# MindSpace:105 经 rclone 挂载共享,不能用 rsync 覆盖 +# data/mindspace:同上,共享挂载树 +# users:105 用 /Users/john/Project/Memind/users 空目录占位 +MindSpace/ +data/ +users/ + +# ── 运行时临时文件 ──────────────────────────────────────── +temp/ +*.pid +.h5.pid *.log -MindSpace -data/mindspace -data/mindspace.bak* -temp -.git +h5.log + +# ── 构建产物(dist 单独同步;node_modules 在 105 上安装)─ +node_modules/ +ops/node_modules/ + +# ── 版本控制 ────────────────────────────────────────────── +.git/ +.gitignore .DS_Store -users + +# ── Studio 本机专用配置(105 不需要)───────────────────── +# LaunchAgent 相关脚本只在 Studio(macOS) 有意义 +scripts/install-*.sh +scripts/memind-mac-tunnel.sh +scripts/memind-fwd-tunnel.sh +# LB 由 Studio 的 Caddy 管理,105 上不需要这些文件 +scripts/g2-lb.Caddyfile +scripts/g2-lb.Caddyfile.active +scripts/g2-lb.Caddyfile.bak-* +scripts/install-g2-lb.sh +# Studio 生产备份不同步到 105 +server.mjs.bak-* +*.bak-20* + +# ── 本地开发专用 ────────────────────────────────────────── +.claude/ +.local/ diff --git a/scripts/.rsync-exclude-studio b/scripts/.rsync-exclude-studio new file mode 100644 index 0000000..0dad2cd --- /dev/null +++ b/scripts/.rsync-exclude-studio @@ -0,0 +1,56 @@ +# ============================================================ +# rsync 排除规则:本地 dev → Studio(100) 生产 +# 所有列出的路径均相对于项目根目录 +# ============================================================ + +# ── 机密配置(绝不覆盖,Studio 自己维护)────────────────── +.env + +# ── 生产用户数据(绝对不能覆盖)────────────────────────── +MindSpace/ +data/ +users/ + +# ── 运行时临时文件 ──────────────────────────────────────── +temp/ +*.pid +.h5.pid +*.log +h5.log + +# ── 构建产物(在 Studio 本地构建,不从 dev 覆盖)───────── +node_modules/ +ops/node_modules/ +dist/ +ops/dist/ + +# ── 版本控制 ────────────────────────────────────────────── +.git/ +.gitignore +.DS_Store + +# ── 本地开发专用(不上生产)────────────────────────────── +.claude/ +.local/ + +# ── Studio LaunchAgent 启动脚本(由 plist 直接引用,删除即服务无法重启)── +scripts/run-memind-portal-prod.sh +scripts/run-plaza-prod.sh +scripts/memind-fwd-tunnel.sh +scripts/install-prod-services.sh + +# ── 生产运营工具(只在 Studio 上使用,dev 无需保留)────── +scripts/compensate-user-billing.mjs +scripts/plaza-demo-posts.mjs + +# ── 生产媒体资源(运营上传,dev 仓库不维护)────────────── +public/plaza-covers/ + +# ── Studio 生产端独立管理的配置(不从 dev 覆盖)────────── +# LB 权重配置直接在 Studio 上用 caddy reload 调整 +scripts/g2-lb.Caddyfile +scripts/g2-lb.Caddyfile.active +scripts/g2-lb.Caddyfile.bak-* +# 生产备份,只在 Studio 存在 +server.mjs.bak-* +*.bak-20* diff --git a/scripts/check-local-test.mjs b/scripts/check-local-test.mjs new file mode 100644 index 0000000..ad2d677 --- /dev/null +++ b/scripts/check-local-test.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import http from 'node:http'; +import https from 'node:https'; +import { + ADMIN_HOST, + allHostsConfigured, + H5_HOST, + H5_PUBLIC_BASE, + OPS_HOST, + PLAZA_HOST, + PLAZA_PUBLIC_BASE, + PUBLIC_PORT, + USES_LOCALHOST, + publicUrl, +} from './local-test-config.mjs'; + +function checkUrl(url) { + const parsed = new URL(url); + const transport = parsed.protocol === 'https:' ? https : http; + return new Promise((resolve) => { + const req = transport.request( + { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: `${parsed.pathname}${parsed.search}`, + method: 'GET', + rejectUnauthorized: false, + headers: parsed.hostname === PLAZA_HOST ? { Host: `${PLAZA_HOST}:${process.env.PLAZA_PORT ?? 3001}` } : {}, + }, + (res) => resolve(res.statusCode ?? 0), + ); + req.on('error', () => resolve(0)); + req.setTimeout(3000, () => { + req.destroy(); + resolve(0); + }); + req.end(); + }); +} + +console.log('==> 本地开发环境诊断'); +console.log(` 模式:${USES_LOCALHOST ? '*.localhost (:8443)' : 'custom tkmind.cn domains'}`); +console.log(''); + +const issues = []; + +if (!USES_LOCALHOST && !allHostsConfigured()) { + issues.push('DNS 未配置 → sudo pnpm setup:local-test'); +} else if (USES_LOCALHOST) { + console.log('✓ *.localhost 无需 /etc/hosts'); +} else { + for (const host of [H5_HOST, ADMIN_HOST, PLAZA_HOST, OPS_HOST]) { + console.log(`✓ DNS ${host}`); + } +} + +const tlsDir = new URL('../.local/local-test-tls/', import.meta.url); +if (!fs.existsSync(new URL('./local-test.cert.pem', tlsDir))) { + issues.push('TLS 证书缺失 → pnpm setup:local-test'); +} else { + console.log('✓ TLS 证书已生成'); +} + +console.log(''); +console.log(`==> HTTPS 入口 (:${PUBLIC_PORT},pnpm dev:local-proxy)`); + +const checks = [ + ['H5', `${H5_PUBLIC_BASE}/`, 200], + ['memind_adm', `${publicUrl(ADMIN_HOST)}/healthz`, 200], + ['Plaza', `${PLAZA_PUBLIC_BASE}/plaza`, 200], + ['Ops', `${publicUrl(OPS_HOST)}/ops/`, 200], +]; + +for (const [label, url, expected] of checks) { + const code = await checkUrl(url); + if (code === expected || (label === 'Plaza' && [200, 307, 308].includes(code))) { + console.log(`✓ ${label.padEnd(12)} ${url} → ${code}`); + } else { + console.log(`✗ ${label.padEnd(12)} ${url} → ${code || '不可达'}`); + if (code === 0) { + issues.push(`${label} 不可达:pnpm dev && pnpm dev:local-proxy`); + } + } +} + +console.log(''); +if (issues.length) { + console.log('待处理:'); + for (const issue of issues) console.log(` - ${issue}`); + process.exit(1); +} + +console.log('本地开发环境正常。'); +console.log(`打开 H5:pnpm open:local-test`); diff --git a/scripts/dev.mjs b/scripts/dev.mjs index b86ae5c..65801a8 100755 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -1,8 +1,6 @@ #!/usr/bin/env node import { spawn, execSync } from 'node:child_process'; import fs from 'node:fs'; -import http from 'node:http'; -import https from 'node:https'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -13,20 +11,9 @@ 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 plazaHost = process.env.PLAZA_LOCAL_HOST ?? 'plaza.tkmind.cn'; -const plazaPublicPort = Number(process.env.PLAZA_PUBLIC_PORT ?? 443); -const plazaPublicScheme = String(process.env.PLAZA_PUBLIC_BASE ?? 'https://plaza.tkmind.cn').startsWith('http://') - ? 'http' - : 'https'; -const plazaPublicBase = ( - process.env.PLAZA_PUBLIC_BASE ?? - (plazaPublicPort === 80 || plazaPublicPort === 443 - ? `${plazaPublicScheme}://${plazaHost}` - : `${plazaPublicScheme}://${plazaHost}:${plazaPublicPort}`) -).replace(/\/$/, ''); -const plazaUrl = plazaPublicBase; -const opsUrl = `http://127.0.0.1:${opsPort}`; +const adminUrl = `http://127.0.0.1:${adminPort}`; function loadEnvFile(filePath) { if (!fs.existsSync(filePath)) return; @@ -69,8 +56,8 @@ function spawnChild(command, args, label, cwd = root, extraEnv = {}) { } let server; +let admin; let plaza; -let plazaProxy; let ops; let vite; let stopping = false; @@ -79,8 +66,8 @@ function shutdown(code = 0) { if (stopping) return; stopping = true; server?.kill('SIGTERM'); + admin?.kill('SIGTERM'); plaza?.kill('SIGTERM'); - plazaProxy?.kill('SIGTERM'); ops?.kill('SIGTERM'); vite?.kill('SIGTERM'); setTimeout(() => process.exit(code), 300); @@ -102,7 +89,11 @@ async function waitFor(url, check, label, retries = 60) { } const mindSpacePublicBase = ( - process.env.H5_PUBLIC_BASE_URL ?? `http://localhost:${vitePort}` + process.env.H5_PUBLIC_BASE_URL ?? `http://127.0.0.1:${vitePort}` +).replace(/\/$/, ''); + +const plazaPublicBase = ( + process.env.PLAZA_PUBLIC_BASE ?? `http://127.0.0.1:${plazaPort}` ).replace(/\/$/, ''); const plazaEnv = { @@ -122,6 +113,7 @@ const viteEnv = { const opsEnv = { OPS_API_PROXY: portalUrl, + OPS_ADMIN_PROXY: adminUrl, VITE_PLAZA_BASE: plazaPublicBase, VITE_MINDSPACE_BASE: mindSpacePublicBase, }; @@ -132,86 +124,9 @@ function ensurePlazaApp() { } } -function plazaPublicReachable() { - const url = new URL(`${plazaPublicBase}/plaza`); - const isHttps = url.protocol === 'https:'; - const port = url.port || (isHttps ? 443 : 80); - - return new Promise((resolve) => { - const transport = isHttps ? https : http; - const req = transport.request( - { - hostname: url.hostname, - port, - path: `${url.pathname}${url.search}`, - method: 'GET', - rejectUnauthorized: false, - }, - (res) => { - resolve(res.statusCode === 200 || res.statusCode === 307 || res.statusCode === 308); - }, - ); - req.on('error', () => resolve(false)); - req.setTimeout(3000, () => { - req.destroy(); - resolve(false); - }); - req.end(); - }); -} - -async function ensurePlazaPublicProxy() { - if (await plazaPublicReachable()) { - console.log(`==> Plaza 公开入口已可用: ${plazaPublicBase}/plaza`); - return; - } - - console.log(`==> 尝试启动 Plaza 80 端口代理 (${plazaPublicBase})...`); - const proxyScript = path.join(root, 'scripts/plaza-local-proxy.mjs'); - plazaProxy = spawn('node', [proxyScript], { - cwd: root, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - let proxyFailed = false; - plazaProxy.stdout?.on('data', (chunk) => process.stdout.write(`[plaza-proxy] ${chunk}`)); - plazaProxy.stderr?.on('data', (chunk) => { - const text = String(chunk); - process.stderr.write(`[plaza-proxy] ${text}`); - if (/EACCES|EADDRINUSE|需要 root/.test(text)) proxyFailed = true; - }); - plazaProxy.on('exit', (code) => { - if (code && code !== 0) proxyFailed = true; - }); - - for (let i = 0; i < 20; i += 1) { - if (proxyFailed) break; - if (await plazaPublicReachable()) { - console.log(`==> Plaza 公开入口已可用: ${plazaPublicBase}/plaza`); - return; - } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - console.warn(''); - console.warn(`⚠ 无法在本进程绑定 ${plazaPublicPort} 端口,${plazaPublicBase} 暂不可用`); - console.warn(' 请先:sudo pnpm setup:plaza-local'); - console.warn(' 再另开终端:sudo pnpm dev:plaza-proxy'); - console.warn(''); -} - -function plazaHostConfigured(host) { - try { - const hosts = fs.readFileSync('/etc/hosts', 'utf8'); - return new RegExp(`^\\s*127\\.0\\.0\\.1\\s+${host.replace('.', '\\.')}(\\s|$)`, 'm').test(hosts); - } catch { - return false; - } -} - -console.log(`==> 清理端口 ${portalPort} / ${vitePort} / ${plazaPort} / ${opsPort}...`); +console.log(`==> 清理端口 ${portalPort} / ${adminPort} / ${vitePort} / ${plazaPort} / ${opsPort}...`); freePort(portalPort); +freePort(adminPort); freePort(vitePort); freePort(plazaPort); freePort(opsPort); @@ -219,13 +134,6 @@ await new Promise((resolve) => setTimeout(resolve, 500)); ensurePlazaApp(); -if (!plazaHostConfigured(plazaHost)) { - console.warn(''); - console.warn(`⚠ ${plazaHost} 尚未写入 /etc/hosts,浏览器无法打开 ${plazaPublicBase}`); - console.warn(' 请执行:sudo pnpm setup:plaza-dns'); - console.warn(''); -} - console.log('==> 启动 portal (server.mjs)...'); server = spawnChild('node', ['server.mjs'], 'portal'); @@ -233,7 +141,12 @@ try { await waitFor(portalUrl, async (url) => (await fetch(`${url}/auth/status`)).ok, 'Portal'); console.log(`==> Portal 就绪: ${portalUrl}`); - console.log(`==> 启动 Plaza Next.js @ ${plazaPublicBase} (bind ${plazaHost})`); + console.log('==> 启动 memind_adm (admin-server.mjs)...'); + admin = spawnChild('node', ['admin-server.mjs'], 'admin'); + await waitFor(adminUrl, async (url) => (await fetch(`${url}/healthz`)).ok, 'Admin'); + console.log(`==> memind_adm 就绪: ${adminUrl}`); + + console.log(`==> 启动 Plaza Next.js @ ${plazaPublicBase}/plaza`); plaza = spawnChild( 'npm', ['run', 'dev', '--', '-H', '0.0.0.0', '-p', String(plazaPort)], @@ -244,28 +157,25 @@ try { await waitFor( `http://127.0.0.1:${plazaPort}`, async (url) => { - const res = await fetch(`${url}/plaza`, { - headers: { Host: `${plazaHost}:${plazaPort}` }, - }); + const res = await fetch(`${url}/plaza`); return res.ok || res.status === 307 || res.status === 308; }, 'Plaza', 80, ); - console.log(`==> Plaza 内部就绪: http://127.0.0.1:${plazaPort}/plaza`); - await ensurePlazaPublicProxy(); + console.log(`==> Plaza 就绪: http://127.0.0.1:${plazaPort}/plaza`); - console.log(`==> 启动 Ops 后台 @ ${opsUrl}/ops/`); + console.log(`==> 启动 Ops 后台 @ http://127.0.0.1:${opsPort}/ops/`); ops = spawnChild('npm', ['run', 'dev'], 'ops', opsDir, opsEnv); await waitFor( - `http://localhost:${opsPort}`, + `http://127.0.0.1:${opsPort}`, async (url) => (await fetch(`${url}/ops/`)).ok, 'Ops', 40, ); - console.log(`==> Ops 就绪: http://localhost:${opsPort}/ops/`); + console.log(`==> Ops 就绪: http://127.0.0.1:${opsPort}/ops/`); - console.log(`==> 启动 Vite @ http://localhost:${vitePort}`); + console.log(`==> 启动 Vite @ http://127.0.0.1:${vitePort}`); vite = spawnChild('npx', ['vite'], 'vite', root, viteEnv); await waitFor( `http://127.0.0.1:${vitePort}`, @@ -278,16 +188,15 @@ try { }, 'Vite', ); - console.log(`==> Vite 就绪: http://localhost:${vitePort}`); + console.log(`==> Vite 就绪: http://127.0.0.1:${vitePort}`); console.log(''); console.log('本地服务:'); - console.log(` MindSpace UI http://localhost:${vitePort}/?preview=mindspace`); - console.log(` Plaza 广场 ${plazaPublicBase}/plaza`); - console.log(` Plaza 内部 http://127.0.0.1:${plazaPort}/plaza`); - console.log(` (setup) sudo pnpm setup:plaza-local && sudo pnpm dev:plaza-proxy`); - console.log(` Ops 审核后台 http://localhost:${opsPort}/ops/`); + console.log(` MindSpace UI http://127.0.0.1:${vitePort}/?preview=mindspace`); + console.log(` Plaza 广场 http://127.0.0.1:${plazaPort}/plaza`); + console.log(` Ops 审核后台 http://127.0.0.1:${opsPort}/ops/`); console.log(` API / Portal ${portalUrl}`); + console.log(` memind_adm ${adminUrl}`); console.log(''); console.log('首次使用 Ops:先登录 MindSpace,再执行 node scripts/grant-ops-role.mjs admin ops_admin'); } catch (err) { diff --git a/scripts/enrich-plaza-posts.mjs b/scripts/enrich-plaza-posts.mjs index 1ee5f8a..91434aa 100644 --- a/scripts/enrich-plaza-posts.mjs +++ b/scripts/enrich-plaza-posts.mjs @@ -103,15 +103,13 @@ async function ensureBundle(conn, row, html) { async function main() { const pool = createDbPool(); const [rows] = await pool.query( - `SELECT pp.id AS post_id, pp.title, pp.summary, pp.tags, - c.slug AS category_slug, + `SELECT pp.id AS post_id, pp.title, pr.id AS publication_id, pr.page_id, pr.page_version_id, pr.user_id, pr.status AS pub_status, p.space_id, p.category_id, p.title AS page_title, pv.bundle_asset_id, (SELECT av.id FROM h5_asset_versions av WHERE av.asset_id = pv.bundle_asset_id AND av.version_no = 1 LIMIT 1) AS bundle_version_id FROM plaza_posts pp - JOIN plaza_categories c ON c.id = pp.category_id JOIN h5_publish_records pr ON pr.id = pp.publication_id JOIN h5_page_records p ON p.id = pr.page_id JOIN h5_page_versions pv ON pv.id = pr.page_version_id @@ -124,17 +122,7 @@ async function main() { try { await conn.beginTransaction(); for (const row of rows) { - let tags = []; - try { - tags = typeof row.tags === 'string' ? JSON.parse(row.tags) : row.tags ?? []; - } catch { - tags = []; - } - const { summary, html, skipHtml } = getEnrichment(row.post_id, row.title, { - categorySlug: row.category_slug, - summary: row.summary, - tags, - }); + const { summary, html, skipHtml } = getEnrichment(row.post_id, row.title); const summaryOnly = skipHtml && row.bundle_asset_id; if (!summaryOnly) { await ensureBundle(conn, row, html); diff --git a/scripts/fix-local-goose-relay.mjs b/scripts/fix-local-goose-relay.mjs new file mode 100644 index 0000000..fabbb1e --- /dev/null +++ b/scripts/fix-local-goose-relay.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +/** + * Point Memind LLM + local goosed (127.0.0.1:18006) at a working relay profile. + * + * Prefers ~/.config/goose/custom_providers/andu_deepseek.json (same as Goose Desktop), + * then H5_LOCAL_RELAY_* env overrides. + * + * Usage: + * node scripts/fix-local-goose-relay.mjs + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createDbPool } from '../db.mjs'; +import { + createLlmProviderService, + resolveChatCompletionsUrl, + testRelayConnection, +} from '../llm-providers.mjs'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +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(); + const value = trimmed.slice(eq + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} + +function loadGooseAnduDeepseekProfile() { + const profilePath = path.join( + os.homedir(), + '.config/goose/custom_providers/andu_deepseek.json', + ); + if (!fs.existsSync(profilePath)) return null; + const raw = JSON.parse(fs.readFileSync(profilePath, 'utf8')); + const authHeader = raw?.headers?.Authorization ?? ''; + const token = authHeader.replace(/^Bearer\s+/i, '').trim(); + const baseUrl = String(raw?.base_url ?? '').trim(); + if (!token || !baseUrl) return null; + const models = (raw?.models ?? []).map((item) => item.name).filter(Boolean); + return { + name: raw.display_name ?? 'Local Goose DeepSeek', + apiUrl: resolveChatCompletionsUrl(baseUrl), + apiKey: token, + models: models.length ? models : ['deepseek-chat'], + defaultModel: raw?.models?.[0]?.name ?? 'deepseek-chat', + relayProvider: 'deepseek', + }; +} + +loadEnvFile(path.join(root, '.env')); + +const gooseProfile = loadGooseAnduDeepseekProfile(); +const relayUrl = + process.env.H5_LOCAL_RELAY_URL ?? + gooseProfile?.apiUrl ?? + 'http://127.0.0.1:18300/relay/buyer/v1/chat/completions'; +const relayToken = + process.env.H5_LOCAL_RELAY_TOKEN ?? gooseProfile?.apiKey ?? ''; +const relayModel = process.env.H5_LOCAL_RELAY_MODEL ?? gooseProfile?.defaultModel ?? 'deepseek-chat'; +const relayModels = process.env.H5_LOCAL_RELAY_MODELS + ? process.env.H5_LOCAL_RELAY_MODELS.split(',') + .map((item) => item.trim()) + .filter(Boolean) + : (gooseProfile?.models ?? ['deepseek-chat', 'deepseek-reasoner']); +const configName = + process.env.H5_LOCAL_RELAY_NAME ?? gooseProfile?.name ?? 'Local Goose DeepSeek'; + +if (!relayToken) { + console.error('缺少 relay token:设置 H5_LOCAL_RELAY_TOKEN 或配置 ~/.config/goose/custom_providers/andu_deepseek.json'); + process.exit(1); +} + +const pool = createDbPool(); +const svc = createLlmProviderService(pool, { + apiTarget: process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006', + apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret', +}); + +const [rows] = await pool.query( + 'SELECT id FROM h5_llm_provider_keys WHERE is_selected = 1 LIMIT 1', +); +const id = rows[0]?.id; +if (!id) { + console.error('未找到已启用的 LLM 配置'); + process.exit(1); +} + +const updated = await svc.updateKey(id, { + name: configName, + apiUrl: relayUrl, + apiKey: relayToken, + models: relayModels, + defaultModel: relayModel, + relayProvider: 'deepseek', +}); + +if (!updated.ok) { + console.error('更新失败:', updated.message); + process.exit(1); +} + +const synced = await svc.syncSelectedToGoosed(); +if (!synced.ok) { + console.error('同步 goosed 失败:', synced.message); + process.exit(1); +} + +const test = await testRelayConnection({ + apiUrl: relayUrl, + apiKey: relayToken, + model: relayModel, + relayProvider: 'deepseek', +}); + +console.log('LLM 已对接本地 goosed:', process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006'); +console.log('Relay:', relayUrl); +console.log('goosed provider:', synced.providerId, 'model:', synced.model); +console.log('连通测试:', test.ok ? `OK (${test.latencyMs}ms)` : test.message); + +await pool.end(); +process.exit(test.ok ? 0 : 1); diff --git a/scripts/g2-lb.Caddyfile b/scripts/g2-lb.Caddyfile index 0629023..e8c2401 100644 --- a/scripts/g2-lb.Caddyfile +++ b/scripts/g2-lb.Caddyfile @@ -1,6 +1,14 @@ -# g2.tkmind.cn 本地发布:仅本机 Mac H5 (:8081),不经过 105 +# g2.tkmind.cn 负载均衡:Studio 本机 H5 (:8081) 主 + 105 portal (经正向隧道 :18080) 灰度副 +# 权重 19:1 ≈ 95% / 5%;active 健康检查 /api/status,挂掉自动摘除 :8090 { - reverse_proxy 127.0.0.1:8081 { + reverse_proxy { + to 127.0.0.1:8081 127.0.0.1:18080 + lb_policy weighted_round_robin 19 1 + header_down +X-Memind-Upstream {http.reverse_proxy.upstream.hostport} + health_uri /api/status + health_interval 10s + health_timeout 5s + health_status 200 flush_interval -1 transport http { read_timeout 0 diff --git a/scripts/local-test-config.mjs b/scripts/local-test-config.mjs new file mode 100644 index 0000000..4629d93 --- /dev/null +++ b/scripts/local-test-config.mjs @@ -0,0 +1,83 @@ +/** + * Shared local test domain configuration. + * + * Default: *.localhost — browsers resolve these to 127.0.0.1 without /etc/hosts + * or fighting Cloudflare wildcard DNS on *.tkmind.cn (which serves AndU publicly). + * + * h5.localhost → MindSpace H5 (Vite :5173) + * adm.localhost → memind_adm (:8082) + * pla.localhost → Plaza Next.js (:3001) + * ops.localhost → Ops console SPA (:3002) + * + * HTTPS unified entry: https://:8443 (pnpm dev:local-proxy, no sudo) + */ +import fs from 'node:fs'; + +export const LOCAL_IP = process.env.LOCAL_TEST_IP ?? '127.0.0.1'; +export const DNS_PORT = Number(process.env.LOCAL_TEST_DNS_PORT ?? 5533); +export const DNS_MARKER = 'tkmind-local-test'; + +export const H5_HOST = process.env.H5_LOCAL_HOST ?? 'h5.localhost'; +export const ADMIN_HOST = process.env.ADMIN_LOCAL_HOST ?? 'adm.localhost'; +export const PLAZA_HOST = process.env.PLAZA_LOCAL_HOST ?? 'pla.localhost'; +export const OPS_HOST = process.env.OPS_LOCAL_HOST ?? 'ops.localhost'; + +export const TEST_HOSTS = [H5_HOST, ADMIN_HOST, PLAZA_HOST, OPS_HOST]; + +export const USES_LOCALHOST = TEST_HOSTS.every((host) => host === 'localhost' || host.endsWith('.localhost')); + +export const PUBLIC_SCHEME = ( + process.env.LOCAL_TEST_SCHEME ?? + (String(process.env.LOCAL_TEST_HTTPS ?? '1') === '0' ? 'http' : 'https') +).replace(/:$/, ''); + +export const PUBLIC_PORT = Number( + process.env.LOCAL_TEST_PUBLIC_PORT ?? (USES_LOCALHOST ? 8443 : PUBLIC_SCHEME === 'https' ? 443 : 80), +); + +export function publicUrl(host, { path = '' } = {}) { + const defaultPort = PUBLIC_SCHEME === 'https' ? 443 : 80; + const portSuffix = PUBLIC_PORT === defaultPort ? '' : `:${PUBLIC_PORT}`; + const normalizedPath = path.startsWith('/') ? path : path ? `/${path}` : ''; + return `${PUBLIC_SCHEME}://${host}${portSuffix}${normalizedPath}`; +} + +export const H5_PUBLIC_BASE = (process.env.H5_PUBLIC_BASE_URL ?? publicUrl(H5_HOST)).replace(/\/$/, ''); +export const ADMIN_PUBLIC_BASE = publicUrl(ADMIN_HOST).replace(/\/$/, ''); +export const PLAZA_PUBLIC_BASE = (process.env.PLAZA_PUBLIC_BASE ?? publicUrl(PLAZA_HOST)).replace(/\/$/, ''); +export const OPS_PUBLIC_BASE = publicUrl(OPS_HOST, { path: '/ops/' }).replace(/\/$/, '') + '/'; + +export function isLocalDevHostname(hostname) { + return ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '::1' || + hostname.endsWith('.localhost') + ); +} + +export function hostConfigured(host) { + if (isLocalDevHostname(host)) return true; + try { + const hosts = fs.readFileSync('/etc/hosts', 'utf8'); + return new RegExp(`^\\s*${LOCAL_IP.replace('.', '\\.')}\\s+${host.replace('.', '\\.')}(\\s|$)`, 'm').test( + hosts, + ); + } catch { + return false; + } +} + +export function resolverConfigured(host) { + if (isLocalDevHostname(host)) return true; + try { + const body = fs.readFileSync(`/etc/resolver/${host}`, 'utf8'); + return body.includes(DNS_MARKER); + } catch { + return false; + } +} + +export function allHostsConfigured() { + return TEST_HOSTS.every((host) => hostConfigured(host) && resolverConfigured(host)); +} diff --git a/scripts/local-test-dns-server.mjs b/scripts/local-test-dns-server.mjs new file mode 100644 index 0000000..4de838a --- /dev/null +++ b/scripts/local-test-dns-server.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +/** + * Minimal DNS server for local test domains -> 127.0.0.1 + * Works with /etc/resolver/ (macOS per-domain DNS override). + */ +import dgram from 'node:dgram'; +import { DNS_PORT, LOCAL_IP, TEST_HOSTS } from './local-test-config.mjs'; + +const ip = LOCAL_IP.split('.').map(Number); +const bind = process.env.LOCAL_TEST_DNS_BIND ?? '127.0.0.1'; +const hosts = new Set(TEST_HOSTS.flatMap((host) => [host, `${host}.`])); + +function encodeName(name) { + const parts = name.split('.').filter(Boolean); + return Buffer.concat([ + ...parts.map((part) => Buffer.concat([Buffer.from([part.length]), Buffer.from(part, 'ascii')])), + Buffer.from([0]), + ]); +} + +function readQuestionName(msg, offset) { + const labels = []; + let pos = offset; + while (pos < msg.length) { + const len = msg[pos]; + if (len === 0) { + pos += 1; + break; + } + labels.push(msg.subarray(pos + 1, pos + 1 + len).toString('ascii')); + pos += 1 + len; + } + return { name: labels.join('.'), next: pos }; +} + +function questionMatches(name) { + return hosts.has(name); +} + +const server = dgram.createSocket('udp4'); + +server.on('message', (msg, rinfo) => { + if (msg.length < 12) return; + + const qdCount = msg.readUInt16BE(4); + if (qdCount !== 1) return; + + const question = readQuestionName(msg, 12); + const qtype = msg.readUInt16BE(question.next); + const qclass = msg.readUInt16BE(question.next + 2); + const questionEnd = question.next + 4; + + if (!questionMatches(question.name) || (qtype !== 1 && qtype !== 28) || qclass !== 1) { + return; + } + + const questionSection = msg.subarray(12, questionEnd); + const ttl = Buffer.from([0x00, 0x00, 0x00, 0x3c]); + + let answer; + if (qtype === 1) { + answer = Buffer.concat([ + Buffer.from([0xc0, 0x0c]), + Buffer.from([0x00, 0x01, 0x00, 0x01]), + ttl, + Buffer.from([0x00, 0x04]), + Buffer.from(ip), + ]); + } else { + answer = Buffer.concat([ + Buffer.from([0xc0, 0x0c]), + Buffer.from([0x00, 0x1c, 0x00, 0x01]), + ttl, + Buffer.from([0x00, 0x10]), + Buffer.from([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, ip[0], ip[1], ip[2], ip[3], + ]), + ]); + } + + const header = Buffer.alloc(12); + header.writeUInt16BE(msg.readUInt16BE(0), 0); + header.writeUInt16BE(0x8180, 2); + header.writeUInt16BE(1, 4); + header.writeUInt16BE(1, 6); + header.writeUInt16BE(0, 8); + header.writeUInt16BE(0, 10); + + server.send(Buffer.concat([header, questionSection, answer]), rinfo.port, rinfo.address); +}); + +server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + console.error(`Local test DNS 端口 ${DNS_PORT} 已被占用`); + process.exit(1); + } + throw err; +}); + +server.bind(DNS_PORT, bind, () => { + console.log(`Local test DNS ${TEST_HOSTS.join(', ')} -> ${LOCAL_IP} @ ${bind}:${DNS_PORT}`); +}); + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + server.close(() => process.exit(0)); + }); +} diff --git a/scripts/local-test-proxy.mjs b/scripts/local-test-proxy.mjs new file mode 100644 index 0000000..e980ba8 --- /dev/null +++ b/scripts/local-test-proxy.mjs @@ -0,0 +1,168 @@ +#!/usr/bin/env node +/** + * Unified HTTPS reverse proxy for local test domains. + * + * test.tkmind.cn -> Vite :5173 (H5 + HMR) + * testadm.tkmind.cn -> admin :8082 + * testpla.tkmind.cn -> Plaza :3001 + * testops.tkmind.cn -> Ops :3002 + * + * Requires root on macOS for :443: + * sudo pnpm dev:local-proxy + */ +import http from 'node:http'; +import https from 'node:https'; +import fs from 'node:fs'; +import { + ADMIN_HOST, + H5_HOST, + OPS_HOST, + PLAZA_HOST, + PUBLIC_PORT, + PUBLIC_SCHEME, + publicUrl, +} from './local-test-config.mjs'; +import { ensureLocalTestTls } from './local-test-tls.mjs'; + +const listenHost = process.env.LOCAL_TEST_PROXY_BIND ?? '0.0.0.0'; +const targetHost = process.env.LOCAL_TEST_PROXY_TARGET ?? '127.0.0.1'; +const vitePort = Number(process.env.VITE_PORT ?? 5173); +const adminPort = Number(process.env.ADMIN_PORT ?? 8082); +const plazaPort = Number(process.env.PLAZA_PORT ?? 3001); +const opsPort = Number(process.env.OPS_PORT ?? 3002); +const useHttps = PUBLIC_SCHEME === 'https'; + +const ROUTES = { + [H5_HOST.toLowerCase()]: { port: vitePort }, + [ADMIN_HOST.toLowerCase()]: { port: adminPort }, + [PLAZA_HOST.toLowerCase()]: { port: plazaPort, rewriteHost: true }, + [OPS_HOST.toLowerCase()]: { port: opsPort, rewriteHost: true }, +}; + +function routeFor(req) { + const hostname = String(req.headers.host ?? '').split(':')[0].toLowerCase(); + return ROUTES[hostname] ? { hostname, ...ROUTES[hostname] } : null; +} + +function proxyRequest(clientReq, clientRes) { + const route = routeFor(clientReq); + if (!route) { + clientRes.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + clientRes.end(`Unknown host: ${clientReq.headers.host ?? '(missing)'}`); + return; + } + + const headers = { ...clientReq.headers }; + if (route.rewriteHost) { + headers.host = `${route.hostname}:${route.port}`; + } + + const proxyReq = http.request( + { + hostname: targetHost, + port: route.port, + method: clientReq.method, + path: clientReq.url, + headers, + }, + (proxyRes) => { + clientRes.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); + proxyRes.pipe(clientRes); + }, + ); + + proxyReq.on('error', (err) => { + if (!clientRes.headersSent) { + clientRes.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' }); + } + clientRes.end( + `Upstream unavailable (${route.hostname} -> ${targetHost}:${route.port}): ${err.message}`, + ); + }); + + clientReq.pipe(proxyReq); +} + +function proxyUpgrade(clientReq, clientSocket, head) { + const route = routeFor(clientReq); + if (!route) { + clientSocket.destroy(); + return; + } + + const headers = { ...clientReq.headers }; + if (route.rewriteHost) { + headers.host = `${route.hostname}:${route.port}`; + } + + const proxyReq = http.request({ + hostname: targetHost, + port: route.port, + method: clientReq.method, + path: clientReq.url, + headers, + }); + + proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => { + clientSocket.write( + `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? 'Switching Protocols'}\r\n` + + Object.entries(proxyRes.headers) + .filter(([, value]) => value != null) + .map(([key, value]) => `${key}: ${value}`) + .join('\r\n') + + '\r\n\r\n', + ); + if (proxyHead.length) proxySocket.write(proxyHead); + proxySocket.pipe(clientSocket); + clientSocket.pipe(proxySocket); + }); + + proxyReq.on('error', () => clientSocket.destroy()); + proxyReq.end(head); +} + +function bindErrorHandler(server) { + server.on('error', (err) => { + if (err.code === 'EACCES') { + console.error(`无法监听 ${PUBLIC_PORT} 端口。若使用 443 需 sudo;默认 8443 无需 root。`); + process.exit(1); + } + if (err.code === 'EADDRINUSE') { + console.error(`端口 ${PUBLIC_PORT} 已被占用。`); + process.exit(1); + } + throw err; + }); +} + +let server; +if (useHttps) { + const { keyPath, certPath } = ensureLocalTestTls(); + server = https.createServer( + { + key: fs.readFileSync(keyPath), + cert: fs.readFileSync(certPath), + }, + proxyRequest, + ); +} else { + server = http.createServer(proxyRequest); +} + +server.on('upgrade', proxyUpgrade); +bindErrorHandler(server); + +server.listen(PUBLIC_PORT, listenHost, () => { + const scheme = useHttps ? 'https' : 'http'; + console.log(`Local test proxy (${scheme}:${PUBLIC_PORT})`); + console.log(` ${publicUrl(H5_HOST)} -> :${vitePort} (H5)`); + console.log(` ${publicUrl(ADMIN_HOST)} -> :${adminPort} (memind_adm)`); + console.log(` ${publicUrl(PLAZA_HOST)}/plaza -> :${plazaPort} (Plaza)`); + console.log(` ${publicUrl(OPS_HOST)}/ops/ -> :${opsPort} (Ops)`); +}); + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + server.close(() => process.exit(0)); + }); +} diff --git a/scripts/local-test-tls.mjs b/scripts/local-test-tls.mjs new file mode 100644 index 0000000..549fcd8 --- /dev/null +++ b/scripts/local-test-tls.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { H5_HOST, TEST_HOSTS } from './local-test-config.mjs'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function certReadable(keyPath, certPath) { + try { + fs.accessSync(keyPath, fs.constants.R_OK); + fs.accessSync(certPath, fs.constants.R_OK); + return true; + } catch { + return false; + } +} + +function resolveCertDir() { + const candidates = [ + path.join(root, '.local/local-test-tls'), + path.join(root, `.local/local-test-tls-${process.getuid?.() ?? 'user'}`), + ]; + for (const certDir of candidates) { + const keyPath = path.join(certDir, 'local-test.key.pem'); + const certPath = path.join(certDir, 'local-test.cert.pem'); + if (certReadable(keyPath, certPath)) { + return { certDir, keyPath, certPath }; + } + } + const certDir = path.join(root, `.local/local-test-tls-${process.getuid?.() ?? 'user'}`); + return { + certDir, + keyPath: path.join(certDir, 'local-test.key.pem'), + certPath: path.join(certDir, 'local-test.cert.pem'), + }; +} + +export function ensureLocalTestTls() { + let { certDir, keyPath, certPath } = resolveCertDir(); + if (certReadable(keyPath, certPath)) { + return { keyPath, certPath, certDir }; + } + + fs.mkdirSync(certDir, { recursive: true }); + const sans = [ + ...TEST_HOSTS.map((host) => `DNS:${host}`), + 'DNS:localhost', + 'IP:127.0.0.1', + ].join(','); + const openssl = `openssl req -x509 -newkey rsa:2048 -sha256 -days 825 -nodes \ + -keyout "${keyPath}" -out "${certPath}" \ + -subj "/CN=${H5_HOST}" \ + -addext "subjectAltName=${sans}"`; + + execSync(openssl, { stdio: 'inherit' }); + console.log(`已生成本地 TLS 证书:${certDir}`); + console.log(`SAN: ${TEST_HOSTS.join(', ')}`); + return { keyPath, certPath, certDir }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + ensureLocalTestTls(); +} diff --git a/scripts/memind-fwd-tunnel.sh b/scripts/memind-fwd-tunnel.sh new file mode 100755 index 0000000..5abcaca --- /dev/null +++ b/scripts/memind-fwd-tunnel.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Studio→105 正向隧道:本机 127.0.0.1:18080 → 105 的 127.0.0.1:8080(goose-h5 portal) +# 供 g2-lb Caddy 把一部分 g2 流量加权分到 105,同协议(HTTP) + SSH 加密私网 +set -euo pipefail +exec ssh -N -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o ExitOnForwardFailure=yes -L 127.0.0.1:18080:127.0.0.1:8080 ssh105 diff --git a/scripts/open-local-test.mjs b/scripts/open-local-test.mjs new file mode 100644 index 0000000..792bd30 --- /dev/null +++ b/scripts/open-local-test.mjs @@ -0,0 +1,12 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; + +const vitePort = Number(process.env.VITE_PORT ?? 5173); +const url = `http://127.0.0.1:${vitePort}/?preview=mindspace`; + +const result = spawnSync('open', [url], { stdio: 'ignore' }); +if (result.status === 0) { + console.log(`已打开 ${url}`); +} else { + console.log(`请手动打开:${url}`); +} diff --git a/scripts/plaza-rich-content.mjs b/scripts/plaza-rich-content.mjs index 1628042..897096e 100644 --- a/scripts/plaza-rich-content.mjs +++ b/scripts/plaza-rich-content.mjs @@ -845,21 +845,9 @@ export const ENRICHMENTS = { }, }; -export function getEnrichment(postId, fallbackTitle, meta = {}) { +export function getEnrichment(postId, fallbackTitle) { const item = ENRICHMENTS[postId]; if (item) return item; - - const categorySlug = meta.categorySlug ?? meta.category_slug ?? ''; - const summary = meta.summary ?? ''; - const tags = meta.tags ?? []; - - if (categorySlug) { - return { - summary: summary || `${fallbackTitle} — MindSpace 精选内容`, - html: buildCategoryPage(categorySlug, fallbackTitle, summary || fallbackTitle, tags), - }; - } - return { summary: `${fallbackTitle} — MindSpace 精选`, html: buildJohnPage({ @@ -872,215 +860,3 @@ export function getEnrichment(postId, fallbackTitle, meta = {}) { }), }; } - -const CATEGORY_THEMES = { - 'work-report': { - tag: '职场报告', - tagEmoji: '📊', - accent: '#2d7d6a', - accent2: '#4ecdc4', - images: [IMG.growth, IMG.ai, IMG.supply, IMG.guide], - }, - 'study-notes': { - tag: '学习笔记', - tagEmoji: '📚', - accent: '#f97316', - accent2: '#fb923c', - images: [IMG.rust, IMG.llm, IMG.guide, IMG.opensource], - }, - creative: { - tag: '创意作品', - tagEmoji: '🎨', - accent: '#c084fc', - accent2: '#a855f7', - images: [IMG.cyber, IMG.music, IMG.guide], - }, - business: { - tag: '门店展示', - tagEmoji: '🏪', - accent: '#d97706', - accent2: '#fbbf24', - images: [IMG.coffee, IMG.pilates, IMG.guide], - }, - travel: { - tag: '旅行攻略', - tagEmoji: '✈️', - accent: '#2d7d6a', - accent2: '#4ecdc4', - images: [IMG.malaysia, IMG.kyoto, IMG.travel, IMG.guide], - }, - 'data-analysis': { - tag: '数据分析', - tagEmoji: '📈', - accent: '#38bdf8', - accent2: '#0ea5e9', - images: [IMG.data, IMG.growth, IMG.guide], - }, - lifestyle: { - tag: '生活记录', - tagEmoji: '🌿', - accent: '#2d7d6a', - accent2: '#4ecdc4', - images: [IMG.desk, IMG.write, IMG.guide], - }, - other: { - tag: '其他', - tagEmoji: '💡', - accent: '#34d399', - accent2: '#10b981', - images: [IMG.opensource, IMG.wish, IMG.guide], - }, -}; - -function hashTitle(title) { - let hash = 0; - for (let i = 0; i < title.length; i += 1) { - hash = (hash * 31 + title.charCodeAt(i)) >>> 0; - } - return hash; -} - -function buildHeroTitleHtml(title) { - const trimmed = String(title).trim(); - if (trimmed.length <= 6) { - return `${esc(trimmed)}`; - } - const splitAt = Math.min(8, Math.ceil(trimmed.length * 0.42)); - const head = trimmed.slice(0, splitAt); - const tail = trimmed.slice(splitAt); - return `${esc(head)}${esc(tail)}`; -} - -function buildCategorySections(categorySlug, title, summary, tags) { - const tagList = - tags.length > 0 - ? `
    ${tags.map((tag) => `
  • ${esc(tag)}
  • `).join('')}
` - : ''; - - const metricsPool = { - 'work-report': [ - { value: '92%', label: '目标完成', note: '本期 OKR' }, - { value: '3', label: '关键实验', note: '已验证假设' }, - { value: '↓18%', label: '阻塞时长', note: '较上期' }, - ], - 'study-notes': [ - { value: '15min', label: '阅读时长', note: '核心章节' }, - { value: '8', label: '要点', note: '可复用片段' }, - { value: '3', label: '练习题', note: '附参考答案' }, - ], - creative: [ - { value: '4K', label: '输出规格', note: '主视觉' }, - { value: '3', label: '延展场景', note: '社媒 / 印刷' }, - { value: '12', label: '配色', note: '品牌体系' }, - ], - business: [ - { value: '+24%', label: '转化提升', note: '活动期' }, - { value: '4.8', label: '评分', note: '5 分制' }, - { value: '86%', label: '复购意向', note: '问卷样本' }, - ], - travel: [ - { value: '5-10', label: '行程天数', note: '弹性安排' }, - { value: '¥680', label: '日均预算', note: '舒适型参考' }, - { value: '12+', label: '精选点位', note: '含美食' }, - ], - 'data-analysis': [ - { value: '3层', label: '下钻维度', note: '渠道/品类/客单' }, - { value: '99%', label: '数据覆盖', note: '核心指标' }, - { value: 'T+1', label: '刷新频率', note: '看板更新' }, - ], - lifestyle: [ - { value: '21天', label: '挑战周期', note: '习惯养成' }, - { value: '12', label: '实践要点', note: '可执行清单' }, - { value: '↓32%', label: '焦虑自评', note: '挑战前后' }, - ], - other: [ - { value: '6', label: '关键动作', note: '冷启动路径' }, - { value: '1000+', label: '读者', note: '累计触达' }, - { value: '4.6', label: '满意度', note: '反馈均值' }, - ], - }; - - const cardsPool = { - 'work-report': [ - { title: '背景与目标', desc: '明确本期业务目标与衡量口径,对齐 stakeholder 预期。', tag: '目标' }, - { title: '执行进展', desc: '按里程碑拆解完成度,标注风险与依赖项。', tag: '进展' }, - { title: '数据洞察', desc: '用 3 个核心指标讲清「发生了什么、为什么」。', tag: '洞察' }, - { title: '下一步', desc: '列出 2–3 个可验证的下一周期动作。', tag: '行动' }, - ], - 'study-notes': [ - { title: '核心概念', desc: '用一句话 + 生活类比建立直觉,再展开细节。', tag: '概念' }, - { title: '常见误区', desc: '面试与实践中高频踩坑点对照表。', tag: '避坑' }, - { title: '代码/例题', desc: '可复制片段,标注输入输出与边界条件。', tag: '实践' }, - ], - travel: [ - { title: 'Day 1–2', desc: '抵达与市区精华,控制步行强度。', tag: '行程' }, - { title: 'Day 3–4', desc: '深度体验与小众点位,预留弹性时间。', tag: '深度' }, - { title: '美食地图', desc: '早/午/晚各 2 家备选,标注人均与排队时段。', tag: '美食' }, - ], - creative: [ - { title: '概念阐述', desc: '设计意图、受众与使用场景说明。', tag: '概念' }, - { title: '视觉规范', desc: '字体、配色、网格与留白规则。', tag: '规范' }, - { title: '延展应用', desc: '海报、社媒、印刷等多场景适配方案。', tag: '延展' }, - ], - }; - - const sections = [ - { - type: 'metrics', - title: '核心亮点', - hint: title, - items: metricsPool[categorySlug] ?? metricsPool.other, - }, - ]; - - if (cardsPool[categorySlug]) { - sections.push({ - type: 'cards', - title: '内容结构', - hint: summary.slice(0, 60), - items: cardsPool[categorySlug], - }); - } - - sections.push({ - type: 'text', - title: '详细说明', - html: `

${esc(summary)}

${tagList}`, - }); - - sections.push({ - type: 'quote', - html: `「${esc(title)}」—— 由 MindSpace 创作并公开发布到 TKMind 广场。`, - }); - - return sections; -} - -export function buildCategoryPage(categorySlug, title, summary, tags = []) { - const theme = CATEGORY_THEMES[categorySlug] ?? CATEGORY_THEMES.other; - const heroImage = theme.images[hashTitle(title) % theme.images.length]; - const bgDark = categorySlug === 'creative' ? '#0f0a1a' : '#0a1628'; - - return buildJohnPage({ - title, - description: summary, - tag: theme.tag, - tagEmoji: theme.tagEmoji, - accent: theme.accent, - accent2: theme.accent2, - bgDark, - heroImage, - heroTitleHtml: buildHeroTitleHtml(title), - heroSubtitle: summary, - heroBtn: '开始阅读', - sections: buildCategorySections(categorySlug, title, summary, tags), - footer: 'TKMind Plaza · MindSpace 公开发布', - }); -} - -// legacy export — post_id keyed enrichments kept for existing seeded posts -export function getEnrichmentLegacy(postId, fallbackTitle) { - const item = ENRICHMENTS[postId]; - if (item) return item; - return getEnrichment(postId, fallbackTitle); -} diff --git a/scripts/regenerate-page-goose-test.mjs b/scripts/regenerate-page-goose-test.mjs new file mode 100644 index 0000000..207c19e --- /dev/null +++ b/scripts/regenerate-page-goose-test.mjs @@ -0,0 +1,286 @@ +#!/usr/bin/env node +/** + * 通过 goose 子会话重新生成 MindSpace 页面内容(Studio / RDS 环境测试) + * + * 用法: + * node scripts/regenerate-page-goose-test.mjs [pageId] [parentSessionId] + * + * 默认:探索世界 · TKMind(john,source_session 20260614_70) + */ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; +import { Agent, fetch as undiciFetch } from 'undici'; +import { createDbPool } from '../db.mjs'; +import { createUserAuth } from '../user-auth.mjs'; +import { createPageService } from '../mindspace-pages.mjs'; +import { createPageLiveEditService } from '../mindspace-page-live-edit.mjs'; +import { createPageEditSessionService } from '../mindspace-page-edit-session.mjs'; +import { createLlmProviderService } from '../llm-providers.mjs'; +import { extractMindSpacePagePatch } from '../mindspace-page-patch.mjs'; +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +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(); + const value = trimmed.slice(eq + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} + +loadEnvFile(path.join(root, '.env')); + +const PAGE_ID = process.argv[2] ?? '44a8d4f5-7dda-4e0c-973a-c580bf2cb82b'; +const PARENT_SESSION = process.argv[3] ?? '20260614_70'; +const USERNAME = 'john'; +const apiTarget = process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006'; +const apiSecret = process.env.TKMIND_SERVER__SECRET_KEY ?? ''; +const h5ApiBase = (process.env.H5_PAGE_PATCH_BASE ?? 'http://127.0.0.1:8081').replace(/\/$/, ''); +const insecureDispatcher = new Agent({ connect: { rejectUnauthorized: false } }); + +function isHttpsTarget(target) { + return target.startsWith('https://'); +} + +function createApiFetch() { + return async (pathname, init = {}) => { + const url = new URL(pathname, apiTarget); + const headers = { + ...(init.headers ?? {}), + 'X-Secret-Key': apiSecret, + }; + if (init.body && !headers['Content-Type']) { + headers['Content-Type'] = 'application/json'; + } + return undiciFetch(url, { + ...init, + headers, + dispatcher: isHttpsTarget(apiTarget) ? insecureDispatcher : undefined, + }); + }; +} + +async function executeSessionReply(apiFetch, sessionId, requestId, prompt) { + const eventsResponse = await apiFetch(`/sessions/${sessionId}/events`, { + method: 'GET', + headers: { Accept: 'text/event-stream' }, + }); + if (!eventsResponse.ok || !eventsResponse.body) { + const text = await eventsResponse.text().catch(() => ''); + throw new Error(text || '无法建立事件流'); + } + + const replyResponse = await apiFetch(`/sessions/${sessionId}/reply`, { + method: 'POST', + body: JSON.stringify({ + request_id: requestId, + user_message: { + role: 'user', + created: Date.now(), + content: [{ type: 'text', text: prompt }], + metadata: { userVisible: true, agentVisible: true }, + }, + }), + }); + if (!replyResponse.ok) { + const text = await replyResponse.text().catch(() => ''); + throw new Error(text || 'reply 失败'); + } + replyResponse.body?.cancel?.(); + + const reader = Readable.fromWeb(eventsResponse.body); + const decoder = new TextDecoder(); + let buffer = ''; + let messages = []; + + const pushMessage = (list, message) => { + const idx = list.findIndex((item) => item.id === message.id); + if (idx >= 0) { + const next = [...list]; + next[idx] = message; + return next; + } + return [...list, message]; + }; + + const messageVisibleText = (message) => + (message?.content ?? []) + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('\n') + .trim(); + + for await (const chunk of reader) { + buffer += decoder.decode(chunk, { stream: true }); + const frames = buffer.split('\n\n'); + buffer = frames.pop() ?? ''; + for (const frame of frames) { + let data = ''; + for (const line of frame.split('\n')) { + if (line.startsWith('data:')) data += line.slice(5).trim(); + } + if (!data) continue; + let event; + try { + event = JSON.parse(data); + } catch { + continue; + } + const routingId = event.chat_request_id ?? event.request_id; + if (routingId && routingId !== requestId) continue; + + if (event.type === 'Message' && event.message?.metadata?.userVisible) { + messages = pushMessage(messages, event.message); + } else if (event.type === 'UpdateConversation') { + messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible); + } else if (event.type === 'Error') { + throw new Error(event.error || 'goose 执行失败'); + } else if (event.type === 'Finish') { + const assistantTexts = messages + .filter((item) => item.role === 'assistant') + .map((item) => messageVisibleText(item)) + .filter(Boolean); + const combined = assistantTexts.join('\n\n'); + const withPatch = [...assistantTexts].reverse().find((text) => text.includes('mindspace-page-update')); + return { text: withPatch ?? combined ?? messageVisibleText([...messages].reverse().find((item) => item.role === 'assistant')) }; + } + } + } + throw new Error('goose 回复未完成'); +} + +async function main() { + const pool = createDbPool(); + const userAuth = createUserAuth(pool, { h5Root: root }); + const pageService = createPageService(pool, { + h5Root: root, + storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path.join(root, 'data', 'mindspace'), + }); + const pageLiveEdit = createPageLiveEditService({ + pageService, + resolveUserIdForAgentSession: async (sessionId) => { + const [rows] = await pool.query( + `SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`, + [sessionId], + ); + return rows[0]?.user_id ?? null; + }, + }); + const llmProviderService = createLlmProviderService(pool, { + apiTarget, + apiSecret, + encryptionKey: + process.env.H5_SETTINGS_ENCRYPTION_KEY ?? process.env.TKMIND_SERVER__SECRET_KEY ?? '', + }); + const pageEditSession = createPageEditSessionService({ + apiTarget, + apiSecret, + userAuth, + pageService, + pageLiveEdit, + llmProviderService, + }); + const apiFetch = createApiFetch(); + + const [userRows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [ + USERNAME, + ]); + const userId = userRows[0]?.id; + if (!userId) throw new Error(`用户 ${USERNAME} 不存在`); + + const pageBefore = await pageService.getPage(userId, PAGE_ID); + console.log('目标页面:', pageBefore.title, `(v${pageBefore.versionNo})`); + console.log('父 goose 会话:', PARENT_SESSION); + + const forked = await pageEditSession.forkSession({ + userId, + pageId: PAGE_ID, + parentSessionId: PARENT_SESSION, + h5ApiBase, + }); + console.log('✓ 子会话已 fork:', forked.sessionId); + + const contentPreview = String(pageBefore.content ?? '').slice(0, 12000); + const prompt = `请基于下方【当前页面 HTML】全面升级为更精美的旅行主题落地页: +- 深色沉浸式 Hero + 玻璃拟态卡片 + 渐变强调文字 +- 保留瑞士、马尔代夫、京都三个目的地 +- 必须包含 mindspace-cover meta 与完整内联 CSS(CSP 安全,禁止外部 JS) +- 标题「探索世界 · TKMind」,summary 写一句精炼中文 + +**禁止**读写工作区文件。完成后**只**在回复末尾输出一个 \`\`\`mindspace-page-update\`\`\` 代码块(JSON:title、summary、content 完整 HTML)。 + +【当前页面 HTML】 +${contentPreview}`; + + const requestId = crypto.randomUUID(); + console.log('→ 向 goose 发送重生成指令…'); + const reply = await executeSessionReply(apiFetch, forked.sessionId, requestId, prompt); + console.log('✓ goose 回复完成,文本长度:', reply.text.length); + console.log(reply.text.slice(-1200)); + + let pageAfter = await pageService.getPage(userId, PAGE_ID); + let changed = + pageAfter.versionNo !== pageBefore.versionNo || + pageAfter.updatedAt !== pageBefore.updatedAt || + pageAfter.content !== pageBefore.content; + + if (!changed) { + const patch = extractMindSpacePagePatch(reply.text); + if (patch) { + console.log('→ 从 mindspace-page-update 块应用补丁…'); + await pageLiveEdit.applyAgentPatch({ + sessionId: forked.sessionId, + pageId: PAGE_ID, + ...patch, + changeNote: 'goose 重生成精美旅行页', + }); + pageAfter = await pageService.getPage(userId, PAGE_ID); + changed = + pageAfter.versionNo !== pageBefore.versionNo || + pageAfter.updatedAt !== pageBefore.updatedAt || + pageAfter.content !== pageBefore.content; + } + } + + console.log('\n结果:'); + console.log(' 版本:', pageBefore.versionNo, '→', pageAfter.versionNo); + console.log(' 摘要:', pageAfter.summary?.slice(0, 120)); + console.log(' 内容已变更:', changed ? '是' : '否'); + + const publicBase = (process.env.H5_PUBLIC_BASE_URL ?? 'https://g2.tkmind.cn').replace(/\/$/, ''); + const [pubRows] = await pool.query( + `SELECT url_slug, status FROM h5_publish_records WHERE id = (SELECT current_publish_id FROM h5_page_records WHERE id = ?)`, + [PAGE_ID], + ); + const slug = pubRows[0]?.url_slug; + if (slug) { + console.log(' 公开预览:', `${publicBase}/p/${slug}`); + } + + await pageEditSession.closeSession({ + userId, + sessionId: forked.sessionId, + pageId: PAGE_ID, + parentSessionId: PARENT_SESSION, + summary: '已通过子会话重生成旅行主题页视觉。', + }); + + await pool.end(); + if (!changed) { + console.error('\n⚠ 页面内容未检测到变更,请检查 goose 是否调用了 mindspace_page_patch'); + process.exit(1); + } + console.log('\n✅ 测试完成'); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/scripts/run-memind-portal-prod.sh b/scripts/run-memind-portal-prod.sh index ec9cfee..e1480b7 100755 --- a/scripts/run-memind-portal-prod.sh +++ b/scripts/run-memind-portal-prod.sh @@ -15,7 +15,7 @@ fi export NODE_ENV=production export H5_PORT="${H5_PORT:-8081}" -NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@22/bin/node}" +NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}" if [[ ! -x "${NODE_BIN}" ]]; then NODE_BIN="$(command -v node)" fi diff --git a/scripts/run-plaza-prod.sh b/scripts/run-plaza-prod.sh index 5e955f4..3101ff0 100755 --- a/scripts/run-plaza-prod.sh +++ b/scripts/run-plaza-prod.sh @@ -16,7 +16,7 @@ export NODE_ENV=production PORTAL_PORT="${H5_PORT:-8081}" PLAZA_PORT="${PLAZA_PORT:-3001}" -NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@22/bin/node}" +NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}" if [[ ! -x "${NODE_BIN}" ]]; then NODE_BIN="$(command -v node)" fi diff --git a/scripts/seed-plaza-covers.mjs b/scripts/seed-plaza-covers.mjs index 9ae90d7..09591e8 100644 --- a/scripts/seed-plaza-covers.mjs +++ b/scripts/seed-plaza-covers.mjs @@ -23,96 +23,11 @@ function loadEnvFile(filePath) { loadEnvFile(path.join(root, '../../.env.local')); loadEnvFile(path.join(root, '.env')); -const COVER_QUALITY = 'auto=format&fit=crop&w=1920&h=1200&q=90'; - -const CATEGORY_IMAGE_POOLS = { - 'work-report': [ - `https://images.unsplash.com/photo-1454165804606-c3d57bc86b40?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1460925895917-afdab827c52f?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1551288049-bebda4e38f71?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1522071820081-009f0129c71c?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1486312338219-ce68d2c6f44d?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1504384308090-c894fdcc538d?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1542744173-8e7e53415bb0?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1600880292203-757bb62b4baf?${COVER_QUALITY}`, - ], - 'study-notes': [ - `https://images.unsplash.com/photo-1456513080510-7bf3a84b82f8?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1516116216624-53e697fedbea?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1515879214367-846362d3526?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1676299085922-0a9a504459f8?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1516321318423-f06f85e504b3?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1434030216411-0b793f4b4173?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1503676260728-1c00da094a0b?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1524995998267-8169c4e50d05?${COVER_QUALITY}`, - ], - creative: [ - `https://images.unsplash.com/photo-1513364776144-60967b0f800f?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1514565131-fce0801e5785?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1511379938544-be09687a4d4b?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1541961017774-22349e4a1262?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1558591710-4b4a1ae0f04d?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1561998338-13ad7886534d?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1579783902614-a3fb3927b6a5?${COVER_QUALITY}`, - ], - business: [ - `https://images.unsplash.com/photo-1441986300917-64674bd600d8?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1518611012118-696072aa579a?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1554118811-1e0d58224f24?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1447933601403-0c6688de566e?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1556742049-0cfed4f6a45d?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1560472354-b33ff0c44a43?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1414235077428-338989a2e8c0?${COVER_QUALITY}`, - ], - travel: [ - `https://images.unsplash.com/photo-1506905925346-21bda4d32df4?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1596422846544-75c675fcd5d0?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1493976040374-85c8e577f47e?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1488646953014-85cb44e25828?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1476514525535-07fb3b4fcc5f?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1501785888041-af3ef285b470?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1528127269322-539801943592?${COVER_QUALITY}`, - ], - 'data-analysis': [ - `https://images.unsplash.com/photo-1551288049-bebda4e38f71?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1543286386-713bdd548da4?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1504868584819-f8e8b4b6d7e3?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1555949963-aa79dcee981c?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1518186285589-2f7649de83e0?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1573164713714-d95e436ab8d6?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1639322537504-6427a16b0a28?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1591696205602-2f950c417cb9?${COVER_QUALITY}`, - ], - lifestyle: [ - `https://images.unsplash.com/photo-1493663284031-b7e3aefcae8e?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1497215842964-222b430dc094?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1455390582260-0446deae2f3f?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1416879595882-3373a0480b5b?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1498805903192-2f734adc632a?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1517487881594-2787fef5ebf7?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1556911220-e15b29be8c8f?${COVER_QUALITY}`, - ], - other: [ - `https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1484480974693-6ca0a7823cce?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1516321318423-f06f85e504b3?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1529156069898-49953e39b3ac?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1517245386807-bb43f82c33c4?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1522202176988-66273c2fd55f?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1515378792601-1a587fd37b2d?${COVER_QUALITY}`, - `https://images.unsplash.com/photo-1523240795612-9a054b0db644?${COVER_QUALITY}`, - ], -}; - const COVER_SPECS = [ { match: 'Q2 产品增长复盘', file: 'work-report-growth.jpg', - url: `https://images.unsplash.com/photo-1551288049-bebda4e38f71?${COVER_QUALITY}`, + url: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=1280&h=800&q=85', }, { match: 'AI 客服落地', @@ -222,36 +137,21 @@ const CATEGORY_FALLBACKS = { other: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1280&h=800&q=85', }; -function hashTitle(title) { - let hash = 0; - for (let i = 0; i < title.length; i += 1) { - hash = (hash * 31 + title.charCodeAt(i)) >>> 0; - } - return hash; -} - -function coverFileName(categorySlug, title) { - const hash = hashTitle(title).toString(36); - const asciiSlug = String(title) - .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, '') +function slugify(value) { + return String(value) .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') + .replace(/[^\p{L}\p{N}]+/gu, '-') .replace(/^-+|-+$/g, '') - .slice(0, 32); - const suffix = asciiSlug ? `${asciiSlug}-${hash}` : hash; - return `${categorySlug}-${suffix}.jpg`; + .slice(0, 48); } function resolveSpec(title, categorySlug) { const spec = COVER_SPECS.find((item) => title.includes(item.match)); if (spec) return spec; - const pool = CATEGORY_IMAGE_POOLS[categorySlug] ?? CATEGORY_IMAGE_POOLS.other; - const url = pool[hashTitle(title) % pool.length]; return { match: title, - file: coverFileName(categorySlug, title), - url, + file: `${categorySlug}-${slugify(title)}.jpg`, + url: CATEGORY_FALLBACKS[categorySlug] ?? CATEGORY_FALLBACKS.other, }; } @@ -308,7 +208,7 @@ const [posts] = await pool.query( let updated = 0; for (const post of posts) { const spec = resolveSpec(post.title, post.category_slug); - await downloadCover(spec, (CATEGORY_IMAGE_POOLS[post.category_slug] ?? CATEGORY_IMAGE_POOLS.other)[0]); + await downloadCover(spec, CATEGORY_FALLBACKS[post.category_slug]); const coverUrl = `${coverBase}/${spec.file}`; if (post.cover_url === coverUrl) continue; await pool.query(`UPDATE plaza_posts SET cover_url = ?, updated_at = ? WHERE id = ?`, [ diff --git a/scripts/seed-plaza-demo.mjs b/scripts/seed-plaza-demo.mjs index 137e731..57107f7 100644 --- a/scripts/seed-plaza-demo.mjs +++ b/scripts/seed-plaza-demo.mjs @@ -6,7 +6,6 @@ import { fileURLToPath } from 'node:url'; import { createDbPool, initSchema } from '../db.mjs'; import { createPlazaPostService } from '../plaza-posts.mjs'; import { initializeDefaultSpace } from '../mindspace.mjs'; -import { EXTRA_DEMO_POSTS } from './plaza-demo-posts.mjs'; const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -26,7 +25,7 @@ function loadEnvFile(filePath) { loadEnvFile(path.join(root, '../../.env.local')); loadEnvFile(path.join(root, '.env')); -const BASE_DEMO_POSTS = [ +const DEMO_POSTS = [ { category: 'work-report', title: 'Q2 产品增长复盘:从 0 到 10 万 DAU', @@ -199,8 +198,6 @@ const BASE_DEMO_POSTS = [ }, ]; -const DEMO_POSTS = [...BASE_DEMO_POSTS, ...EXTRA_DEMO_POSTS]; - async function loadUser(pool, username) { const [rows] = await pool.query( `SELECT id, username, slug, display_name FROM h5_users WHERE username = ? LIMIT 1`, diff --git a/scripts/setup-local-test-dns.mjs b/scripts/setup-local-test-dns.mjs new file mode 100644 index 0000000..caea5d1 --- /dev/null +++ b/scripts/setup-local-test-dns.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/** + * Map local test domains -> 127.0.0.1 for dev (macOS hosts + per-domain resolver). + * + * Usage: + * sudo node scripts/setup-local-test-dns.mjs + * sudo node scripts/setup-local-test-dns.mjs --remove + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import { DNS_MARKER, DNS_PORT, LOCAL_IP, TEST_HOSTS } from './local-test-config.mjs'; + +const remove = process.argv.includes('--remove'); +const hostsPath = '/etc/hosts'; +const resolverDir = '/etc/resolver'; +const resolverBody = `nameserver 127.0.0.1\nport ${DNS_PORT}\n# ${DNS_MARKER}\n`; + +function flushDnsCache() { + try { + execSync('dscacheutil -flushcache', { stdio: 'ignore' }); + execSync('killall -HUP mDNSResponder', { stdio: 'ignore' }); + } catch { + // best effort + } +} + +function hostLine(host) { + return `${LOCAL_IP} ${host} # ${DNS_MARKER}`; +} + +function removeHostLines(lines, host) { + return lines.filter( + (line) => + !line.includes(DNS_MARKER) && + !new RegExp(`\\s${host.replace('.', '\\.')}(\\s|$)`).test(line), + ); +} + +function hostReady(lines, host) { + return lines.some( + (line) => + (line.includes(DNS_MARKER) && line.includes(host)) || + new RegExp(`\\s${host.replace('.', '\\.')}(\\s|$)`).test(line), + ); +} + +if (remove) { + if (process.getuid?.() !== 0) { + console.log('需要 root 权限,请执行:'); + console.log(` sudo node ${process.argv[1]} --remove`); + process.exit(1); + } + + let lines = fs.readFileSync(hostsPath, 'utf8').split('\n'); + for (const host of TEST_HOSTS) { + lines = removeHostLines(lines, host); + try { + if (fs.existsSync(path.join(resolverDir, host))) fs.unlinkSync(path.join(resolverDir, host)); + } catch { + // ignore + } + } + execSync(`tee ${hostsPath}`, { + input: `${lines.join('\n').replace(/\n?$/, '\n')}`, + stdio: ['pipe', 'inherit', 'inherit'], + }); + flushDnsCache(); + console.log(`已移除本地测试域名:${TEST_HOSTS.join(', ')}`); + process.exit(0); +} + +const currentLines = fs.readFileSync(hostsPath, 'utf8').split('\n'); +const allReady = TEST_HOSTS.every((host) => { + const resolverPath = path.join(resolverDir, host); + const resolverReady = + fs.existsSync(resolverPath) && fs.readFileSync(resolverPath, 'utf8').trim() === resolverBody.trim(); + return hostReady(currentLines, host) && resolverReady; +}); + +if (allReady) { + console.log(`本地测试域名已配置:${TEST_HOSTS.join(', ')}`); + process.exit(0); +} + +if (process.getuid?.() !== 0) { + console.log('需要 root 权限写入 /etc/hosts 和 /etc/resolver,请执行:'); + console.log(` sudo pnpm setup:local-dns`); + process.exit(1); +} + +let lines = currentLines; +for (const host of TEST_HOSTS) { + if (!hostReady(lines, host)) { + lines = lines.filter((line) => !line.includes(DNS_MARKER) || !line.includes(host)); + lines.push(hostLine(host)); + console.log(`已添加 hosts:${hostLine(host)}`); + } else { + console.log(`${host} 已在 /etc/hosts 中指向 ${LOCAL_IP}`); + } +} + +execSync(`tee ${hostsPath}`, { + input: `${lines.join('\n').replace(/\n?$/, '\n')}`, + stdio: ['pipe', 'inherit', 'inherit'], +}); + +fs.mkdirSync(resolverDir, { recursive: true }); +for (const host of TEST_HOSTS) { + const resolverPath = path.join(resolverDir, host); + fs.writeFileSync(resolverPath, resolverBody, 'utf8'); + console.log(`已添加 resolver:${resolverPath} -> 127.0.0.1:${DNS_PORT}`); +} + +flushDnsCache(); +console.log(''); +console.log('下一步:'); +console.log(' sudo pnpm setup:local-tls # 生成本地 HTTPS 证书'); +console.log(' pnpm dev # 启动全栈'); +console.log(' sudo pnpm dev:local-proxy # HTTPS :443 统一入口'); diff --git a/scripts/setup-local-test.mjs b/scripts/setup-local-test.mjs new file mode 100644 index 0000000..e61c106 --- /dev/null +++ b/scripts/setup-local-test.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** + * One-shot local dev TLS for *.localhost (no sudo needed). + * + * Usage: pnpm setup:local-test + */ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + ADMIN_HOST, + H5_HOST, + H5_PUBLIC_BASE, + OPS_HOST, + PLAZA_HOST, + PLAZA_PUBLIC_BASE, + publicUrl, + USES_LOCALHOST, +} from './local-test-config.mjs'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +if (!USES_LOCALHOST) { + if (process.getuid?.() !== 0) { + console.log('非 *.localhost 域名需要 root 配置 DNS,请执行:'); + console.log(' sudo pnpm setup:local-test'); + process.exit(1); + } + const dns = spawnSync('node', [path.join(root, 'scripts/setup-local-test-dns.mjs')], { + stdio: 'inherit', + env: process.env, + }); + if (dns.status !== 0) process.exit(dns.status ?? 1); +} + +const tls = spawnSync('node', [path.join(root, 'scripts/local-test-tls.mjs')], { + stdio: 'inherit', + env: process.env, +}); +if (tls.status !== 0) process.exit(tls.status ?? 1); + +console.log(''); +console.log('本地开发环境已配置。启动:'); +console.log(' pnpm dev'); +console.log(' pnpm dev:local-proxy # 若 dev 未自动拉起 HTTPS 代理'); +console.log(''); +console.log('访问:'); +console.log(` MindSpace H5 ${H5_PUBLIC_BASE}/?preview=mindspace`); +console.log(` memind_adm ${publicUrl(ADMIN_HOST)}`); +console.log(` Plaza ${PLAZA_PUBLIC_BASE}/plaza`); +console.log(` Ops 审核后台 ${publicUrl(OPS_HOST)}/ops/`); diff --git a/scripts/start-plaza-prod.mjs b/scripts/start-plaza-prod.mjs index 451b850..eacf11e 100644 --- a/scripts/start-plaza-prod.mjs +++ b/scripts/start-plaza-prod.mjs @@ -21,22 +21,6 @@ const plazaPublicBase = ( ).replace(/\/$/, ''); const portalUrl = `http://127.0.0.1:${portalPort}`; const skipBuild = process.argv.includes('--skip-build'); -const noPortal = process.argv.includes('--no-portal'); - -function resolveNpmBin() { - const candidates = [ - process.env.NPM_BIN, - '/opt/homebrew/opt/node@22/bin/npm', - '/opt/homebrew/bin/npm', - '/usr/local/bin/npm', - ].filter(Boolean); - for (const candidate of candidates) { - if (fs.existsSync(candidate)) return candidate; - } - return 'npm'; -} - -const npmBin = resolveNpmBin(); function loadEnvFile(filePath) { if (!fs.existsSync(filePath)) return; @@ -143,9 +127,6 @@ freePort(plazaPort); try { if (!(await portOpen(portalPort))) { - if (noPortal) { - throw new Error(`Portal 未运行 (${portalUrl}),请先启动 cn.tkmind.memind-portal`); - } console.log(`==> 启动 Portal @ ${portalUrl}`); portal = spawnChild('node', ['server.mjs'], 'portal'); await waitFor(() => portOpen(portalPort), 'Portal'); @@ -155,14 +136,14 @@ try { if (!skipBuild) { console.log('==> 构建 Plaza Next.js(生产)…'); - execSync(`"${npmBin}" run build`, { cwd: plazaDir, stdio: 'inherit', env: { ...process.env, ...plazaEnv }, shell: true }); + execSync('npm run build', { cwd: plazaDir, stdio: 'inherit', env: { ...process.env, ...plazaEnv } }); } else { console.log('==> 跳过 next build(--skip-build)'); } console.log(`==> 启动 Plaza Next.js 正式服务 (0.0.0.0:${plazaPort})`); plaza = spawnChild( - npmBin, + 'npm', ['run', 'start', '--', '-H', '0.0.0.0', '-p', String(plazaPort)], 'plaza', plazaDir, diff --git a/scripts/sync-to-105.sh b/scripts/sync-to-105.sh index fb0758b..03f4e0b 100755 --- a/scripts/sync-to-105.sh +++ b/scripts/sync-to-105.sh @@ -36,9 +36,11 @@ fi echo "" echo "=== 校验 ===" -for f in server.mjs user-publish.mjs user-space.mjs mindspace-page-edit-session.mjs package.json; do - local_sum="$(md5 -q "${ROOT}/${f}" 2>/dev/null || md5sum "${ROOT}/${f}" | awk '{print $1}')" - remote_sum="$(ssh -o BatchMode=yes "${HOST}" "md5sum '${REMOTE}/${f}' | awk '{print \$1}'")" +for f in server.mjs user-publish.mjs user-space.mjs mindspace-sandbox-mcp.mjs mindspace-page-edit-session.mjs package.json; do + local_sum="$(openssl md5 "${ROOT}/${f}" 2>/dev/null | awk '{print $2}' \ + || md5 -q "${ROOT}/${f}" 2>/dev/null \ + || md5sum "${ROOT}/${f}" 2>/dev/null | awk '{print $1}')" + remote_sum="$(ssh -o BatchMode=yes "${HOST}" "md5sum '${REMOTE}/${f}' 2>/dev/null | awk '{print \$1}'")" if [[ "${local_sum}" == "${remote_sum}" ]]; then echo "✓ ${f}" else diff --git a/server.mjs b/server.mjs index a859ff0..9bc11e6 100644 --- a/server.mjs +++ b/server.mjs @@ -21,7 +21,8 @@ import { userSessionCookie, } from './user-auth.mjs'; import { createWikiAuth } from './wiki-auth.mjs'; -import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublicBaseUrl, resolvePublishDir } from './user-publish.mjs'; +import { isLocalDevHostname } from './scripts/local-test-config.mjs'; +import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublishDir } from './user-publish.mjs'; import { ensureWorkspaceHtmlThumbnail, startWorkspaceThumbnailWatcher, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs'; import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs'; import { attachRequestId, sendData, sendError } from './api-response.mjs'; @@ -31,7 +32,6 @@ import { createMindSpaceService } from './mindspace.mjs'; import { createAssetService } from './mindspace-assets.mjs'; import { createPageService, pageInternals } from './mindspace-pages.mjs'; import { createPageLiveEditService } from './mindspace-page-live-edit.mjs'; -import { createImageGenerationService } from './image-generation.mjs'; import { createPageEditSessionService } from './mindspace-page-edit-session.mjs'; import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs'; import { createPublicationService } from './mindspace-publications.mjs'; @@ -45,7 +45,7 @@ import { import { createPlazaRedis, createNoopPlazaRedis } from './plaza-redis.mjs'; import { startPlazaTasks, writebackPublications } from './plaza-tasks.mjs'; import { createPlazaSeoService } from './plaza-seo.mjs'; -import { createPlazaOpsService, hasOpsRole } from './plaza-ops.mjs'; +import { createPlazaOpsService } from './plaza-ops.mjs'; import { preparePublicationHtmlForEmbed, isPlazaEmbedRequest, @@ -55,6 +55,7 @@ import { createCleanupService } from './mindspace-cleanup.mjs'; import { createAgentJobService } from './mindspace-agent-jobs.mjs'; import { createMindSpaceAgentRunner } from './mindspace-agent-runner.mjs'; import { + analyzeChatMessageForSave, buildChatSavePreviewFrameUrl, buildChatSaveThumbnailUrl, buildWorkspaceAssetUrl, @@ -62,6 +63,7 @@ import { buildWorkspaceThumbnailUrl, injectHtmlBaseHref, resolveChatSaveAnalysis, + resolveStaticHtmlContent, } from './mindspace-chat-save.mjs'; import { generateHtmlThumbnail } from './mindspace-thumbnails.mjs'; import { scanContent } from './mindspace-content-scan.mjs'; @@ -99,8 +101,10 @@ const API_TARGET = process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006'; const API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret'; const INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET ?? API_SECRET; const ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD; +// 无状态前端节点(如 105,MindSpace 经 rclone 挂载)需设 MEMIND_WORKSPACE_MAINTENANCE=0, +// 否则启动时对挂载树做 readdir/递归 fs.watch 会占满 libuv 线程池导致 boot 卡死。 +const WORKSPACE_MAINTENANCE_ENABLED = process.env.MEMIND_WORKSPACE_MAINTENANCE !== '0'; const USERS_ROOT = process.env.H5_USERS_ROOT ?? path.join(__dirname, 'users'); -const PUBLIC_BASE_URL = resolvePublicBaseUrl(process.env); const app = express(); app.set('trust proxy', 1); @@ -120,10 +124,6 @@ app.use((req, res, next) => { next(); }); -function isLoopbackHostname(hostname) { - return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; -} - function csrfOriginCheck(req, res, next) { if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next(); const host = req.get('host'); @@ -137,7 +137,7 @@ function csrfOriginCheck(req, res, next) { const source = new URL(value); if (source.host === host) return true; return ( - isLoopbackHostname(requestHostname) && isLoopbackHostname(source.hostname) + isLocalDevHostname(requestHostname) && isLocalDevHostname(source.hostname) ); } catch { return false; @@ -176,7 +176,6 @@ let mindSpaceAssets = null; let mindSpaceAudit = null; let mindSpacePages = null; let mindSpacePageLiveEdit = null; -let imageGeneration = null; let mindSpacePageEditSession = null; let mindSpacePublications = null; let plazaPosts = null; @@ -226,18 +225,6 @@ async function bootstrapUserAuth() { return rows[0]?.user_id ?? null; }, }); - imageGeneration = createImageGenerationService({ - pool, - h5Root: __dirname, - publicBaseUrl: PUBLIC_BASE_URL, - resolveUserIdForAgentSession: async (sessionId) => { - const [rows] = await pool.query( - `SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`, - [sessionId], - ); - return rows[0]?.user_id ?? null; - }, - }); mindSpacePublications = createPublicationService(pool, { storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'), @@ -311,25 +298,29 @@ async function bootstrapUserAuth() { agentJobService: mindSpaceAgentJobs, }); mindSpaceAudit = createMindSpaceAuditWriter(pool); - startWorkspaceThumbnailWatcher(path.join(__dirname, PUBLISH_ROOT_DIR)); - startWorkspaceAssetSyncWatcher({ - publishRoot: path.join(__dirname, PUBLISH_ROOT_DIR), - syncUserWorkspaceByDirKey: async (dirKey, options) => { - let userId = dirKey; - if (!PUBLISH_KEY_UUID.test(dirKey)) { - const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [ - dirKey, - ]); - userId = rows[0]?.id; - } - if (!userId) return; - await mindSpaceAssets.syncWorkspaceAssets(userId, options); - }, - }); - void mindSpaceAssets.expireStaleUploads().catch(() => {}); - setInterval(() => { - void mindSpaceAssets?.expireStaleUploads().catch(() => {}); - }, 5 * 60 * 1000).unref?.(); + if (WORKSPACE_MAINTENANCE_ENABLED) { + startWorkspaceThumbnailWatcher(path.join(__dirname, PUBLISH_ROOT_DIR)); + startWorkspaceAssetSyncWatcher({ + publishRoot: path.join(__dirname, PUBLISH_ROOT_DIR), + syncUserWorkspaceByDirKey: async (dirKey, options) => { + let userId = dirKey; + if (!PUBLISH_KEY_UUID.test(dirKey)) { + const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [ + dirKey, + ]); + userId = rows[0]?.id; + } + if (!userId) return; + await mindSpaceAssets.syncWorkspaceAssets(userId, options); + }, + }); + void mindSpaceAssets.expireStaleUploads().catch(() => {}); + setInterval(() => { + void mindSpaceAssets?.expireStaleUploads().catch(() => {}); + }, 5 * 60 * 1000).unref?.(); + } else { + console.log('Workspace maintenance daemons disabled (MEMIND_WORKSPACE_MAINTENANCE=0)'); + } await userAuth.ensureAdminUser(); llmProviderService = createLlmProviderService(pool, { apiTarget: API_TARGET, @@ -449,16 +440,7 @@ app.post('/auth/login', jsonBody, async (req, res) => { return res.status(401).json({ message: result.message }); } setUserLoginCookies(res, req, result.token); - const row = await userAuth.getUserById(result.user.id); - const capabilityState = row ? await userAuth.resolveUserCapabilities(row) : null; - return res.json({ - authenticated: true, - user: result.user, - mode: 'user', - capabilities: capabilityState?.capabilities, - grantedSkills: capabilityState?.grantedSkills ?? [], - unrestricted: capabilityState?.unrestricted, - }); + return res.json({ authenticated: true, user: result.user, mode: 'user' }); } if (!legacyAuth) { @@ -758,348 +740,13 @@ app.post('/auth/logout', async (req, res) => { res.status(204).end(); }); -// ============ Admin API ============ - -function requireAdmin(req, res, next) { - if (!req.currentUser || req.currentUser.role !== 'admin') { - res.status(403).json({ message: '需要管理员权限' }); - return; - } - next(); -} - -const adminApi = express.Router(); -adminApi.use(jsonBody); - -adminApi.use(async (req, res, next) => { - await userAuthReady; - if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - req.currentUser = me; - next(); -}); - -adminApi.get('/users', requireAdmin, async (_req, res) => { - const users = await userAuth.listUsers(); - res.json({ users }); -}); - -adminApi.get('/summary', requireAdmin, async (_req, res) => { - const summary = await userAuth.getAdminSummary(); - let llm = null; - if (llmProviderService) { - const keys = await llmProviderService.listKeys(); - const global = await llmProviderService.getGlobalSettings(); - const selected = keys.find((key) => key.isSelected); - llm = { - keyCount: keys.length, - selectedKeyName: selected?.name ?? null, - globalModel: global?.model ?? null, - }; - } - res.json({ summary: { ...summary, llm } }); -}); - -adminApi.post('/users', requireAdmin, async (req, res) => { - const result = await userAuth.createUser(req.body ?? {}); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.status(201).json({ user: result.user }); -}); - -adminApi.patch('/users/:userId', requireAdmin, async (req, res) => { - const result = await userAuth.updateUser(req.params.userId, req.body ?? {}); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json({ user: result.user }); -}); - -adminApi.post('/users/:userId/recharge', requireAdmin, async (req, res) => { - const amountCents = Number(req.body?.amountCents); - const note = typeof req.body?.note === 'string' ? req.body.note : ''; - const result = await userAuth.recharge( - req.params.userId, - amountCents, - req.currentUser.id, - note, - ); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json({ user: result.user }); -}); - -adminApi.get('/usage/summary', requireAdmin, async (req, res) => { - const userId = typeof req.query.userId === 'string' ? req.query.userId : null; - const summary = await userAuth.getUsageSummary({ userId }); - if (userId && !summary) return res.status(404).json({ message: '用户不存在' }); - res.json({ summary }); -}); - -adminApi.get('/usage', requireAdmin, async (req, res) => { - const userId = typeof req.query.userId === 'string' ? req.query.userId : null; - const limit = Number(req.query.limit ?? 20); - const records = await userAuth.listUsageRecords({ userId, limit }); - res.json({ records }); -}); - -adminApi.get('/capabilities/catalog', requireAdmin, (_req, res) => { - res.json({ catalog: userAuth.capabilityCatalog }); -}); - -adminApi.get('/capabilities/role/:role', requireAdmin, async (req, res) => { - const role = req.params.role === 'admin' ? 'admin' : 'user'; - if (role === 'admin') { - return res.json({ - role, - capabilities: Object.fromEntries( - userAuth.capabilityCatalog.map((item) => [item.key, true]), - ), - unrestricted: true, - }); - } - res.json(await userAuth.getRoleCapabilities('user')); -}); - -adminApi.put('/capabilities/role/:role', requireAdmin, async (req, res) => { - const role = req.params.role === 'admin' ? 'admin' : 'user'; - const result = await userAuth.setRoleCapabilities(role, req.body?.capabilities ?? {}); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json(result); -}); - -adminApi.get('/users/:userId/capabilities', requireAdmin, async (req, res) => { - const result = await userAuth.getUserCapabilities(req.params.userId); - if (!result.ok) return res.status(404).json({ message: result.message }); - res.json(result); -}); - -adminApi.put('/users/:userId/capabilities', requireAdmin, async (req, res) => { - const result = await userAuth.setUserCapabilities( - req.params.userId, - req.body?.capabilities ?? {}, - ); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json(result); -}); - -adminApi.delete('/users/:userId/capabilities', requireAdmin, async (req, res) => { - const result = await userAuth.clearUserCapabilityOverrides(req.params.userId); - if (!result.ok) return res.status(404).json({ message: result.message }); - res.json(result); -}); - -adminApi.get('/policies/catalog', requireAdmin, (_req, res) => { - res.json({ catalog: userAuth.policyCatalog }); -}); - -adminApi.get('/policies/role/:role', requireAdmin, async (req, res) => { - const role = req.params.role === 'admin' ? 'admin' : 'user'; - if (role === 'admin') { - return res.json({ role, policies: {}, unrestricted: true }); - } - res.json(await userAuth.getRolePolicies('user')); -}); - -adminApi.put('/policies/role/:role', requireAdmin, async (req, res) => { - const role = req.params.role === 'admin' ? 'admin' : 'user'; - const result = await userAuth.setRolePolicies(role, req.body?.policies ?? {}); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json(result); -}); - -adminApi.get('/users/:userId/policies', requireAdmin, async (req, res) => { - const result = await userAuth.getUserPolicies(req.params.userId); - if (!result.ok) return res.status(404).json({ message: result.message }); - res.json(result); -}); - -adminApi.put('/users/:userId/policies', requireAdmin, async (req, res) => { - const result = await userAuth.setUserPolicies(req.params.userId, req.body?.policies ?? {}); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json(result); -}); - -adminApi.delete('/users/:userId/policies', requireAdmin, async (req, res) => { - const result = await userAuth.clearUserPolicyOverrides(req.params.userId); - if (!result.ok) return res.status(404).json({ message: result.message }); - res.json(result); -}); - -adminApi.get('/skills/catalog', requireAdmin, (_req, res) => { - res.json({ catalog: userAuth.skillCatalog }); -}); - -adminApi.get('/skills/role/:role', requireAdmin, async (req, res) => { - const role = req.params.role === 'admin' ? 'admin' : 'user'; - if (role === 'admin') { - return res.json({ - role, - skills: Object.fromEntries(userAuth.skillCatalog.map((item) => [item.name, true])), - }); - } - res.json(await userAuth.getRoleSkills('user')); -}); - -adminApi.put('/skills/role/:role', requireAdmin, async (req, res) => { - const role = req.params.role === 'admin' ? 'admin' : 'user'; - const result = await userAuth.setRoleSkills(role, req.body?.skills ?? {}); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json(result); -}); - -adminApi.get('/users/:userId/skills', requireAdmin, async (req, res) => { - const result = await userAuth.getUserSkills(req.params.userId); - if (!result.ok) return res.status(404).json({ message: result.message }); - res.json(result); -}); - -adminApi.put('/users/:userId/skills', requireAdmin, async (req, res) => { - const result = await userAuth.setUserSkills(req.params.userId, req.body?.skills ?? {}); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json(result); -}); - -adminApi.delete('/users/:userId/skills', requireAdmin, async (req, res) => { - const result = await userAuth.clearUserSkillOverrides(req.params.userId); - if (!result.ok) return res.status(404).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 limit = Number(req.query.limit ?? 50); - const entries = await userAuth.listBillingLedger({ userId, limit }); - res.json({ entries }); -}); - -adminApi.get('/llm-providers/catalog', requireAdmin, (_req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - res.json({ catalog: llmProviderService.catalog }); -}); - -adminApi.get('/llm-providers/keys', requireAdmin, async (_req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - const keys = await llmProviderService.listKeys(); - res.json({ keys }); -}); - -adminApi.post('/llm-providers/keys', requireAdmin, async (req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - const result = await llmProviderService.createKey(req.body ?? {}); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.status(201).json({ key: result.key }); -}); - -adminApi.patch('/llm-providers/keys/:keyId', requireAdmin, async (req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - const result = await llmProviderService.updateKey(req.params.keyId, req.body ?? {}); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json({ key: result.key }); -}); - -adminApi.post('/llm-providers/keys/:keyId/select', requireAdmin, async (req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - const result = await llmProviderService.selectKey(req.params.keyId); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json({ key: result.key }); -}); - -adminApi.delete('/llm-providers/keys/:keyId', requireAdmin, async (req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - const result = await llmProviderService.deleteKey(req.params.keyId); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json({ ok: true }); -}); - -adminApi.post('/llm-providers/sync', requireAdmin, async (_req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - try { - const result = await llmProviderService.syncSelectedToGoosed(); - res.json(result); - } catch (err) { - res.status(500).json({ - message: err instanceof Error ? err.message : '同步失败', - }); - } -}); - -adminApi.get('/llm-providers/global', requireAdmin, async (_req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - const global = await llmProviderService.getGlobalSettings(); - res.json({ global }); -}); - -adminApi.put('/llm-providers/global', requireAdmin, async (req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - const result = await llmProviderService.setGlobalModel(req.body?.model); - if (!result.ok) return res.status(400).json({ message: result.message }); - res.json(result); -}); - -adminApi.post('/llm-providers/test', requireAdmin, async (req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - try { - const result = await llmProviderService.testDraft(req.body ?? {}); - res.json(result); - } catch (err) { - res.status(500).json({ - ok: false, - message: err instanceof Error ? err.message : '联通测试失败', - }); - } -}); - -adminApi.post('/llm-providers/keys/:keyId/test', requireAdmin, async (req, res) => { - if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - try { - const result = await llmProviderService.testKey(req.params.keyId, req.body?.model); - res.json(result); - } catch (err) { - res.status(500).json({ - ok: false, - message: err instanceof Error ? err.message : '联通测试失败', - }); - } -}); - -adminApi.get('/plaza/pending', requireAdmin, async (_req, res) => { - if (!plazaPosts) return res.status(503).json({ message: 'Plaza 未启用' }); - const posts = await plazaPosts.listPendingPosts(); - res.json({ data: { posts } }); -}); - -adminApi.post('/plaza/posts/:id/review', requireAdmin, async (req, res) => { - if (!plazaPosts) return res.status(503).json({ message: 'Plaza 未启用' }); - try { - const action = String(req.body?.action ?? 'approve'); - const reason = req.body?.reason ?? null; - const result = - plazaOps && req.currentUser?.id - ? await plazaOps.reviewPostAsOps(req.currentUser.id, req.params.id, action, { reason }) - : await plazaPosts.reviewPost(req.params.id, action, { reason }); - res.json({ data: result }); - } catch (error) { - const status = mapPlazaError(error); - res.status(status).json({ - error: { code: error?.code ?? 'review_failed', message: error.message }, - }); - } -}); - -app.get('/auth/usage/summary', async (req, res) => { - await userAuthReady; - if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); - const me = await userAuth.getMe(userToken(req)); - if (!me) return res.status(401).json({ message: '未登录' }); - const summary = await userAuth.getUsageSummary({ userId: me.id }); - res.json({ summary }); -}); app.get('/auth/usage', async (req, res) => { await userAuthReady; if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); - const limit = Number(req.query.limit ?? 20); - const records = await userAuth.listUsageRecords({ userId: me.id, limit }); + const records = await userAuth.listUsageRecords({ userId: me.id, limit: 30 }); res.json({ records }); }); @@ -1206,7 +853,6 @@ app.post('/webhooks/wechat-pay/notify', wechatNotifyBody, async (req, res) => { } }); -app.use('/admin-api', adminApi); // ============ Wiki API ============ @@ -1311,7 +957,6 @@ api.use(async (req, res, next) => { if (req.path === '/status') return next(); if (req.path.startsWith('/internal/agent/')) return next(); if (req.path === '/agent/mindspace_page_patch') return next(); - if (req.path === '/agent/generate_image') return next(); const plazaPublic = isPlazaPublicRead(req.path, req.method); @@ -1448,124 +1093,6 @@ function plazaClientIp(req) { return req.ip; } -function makeRequireOps(minRole = 'reviewer') { - return async (req, res, next) => { - if (!plazaOps) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); - if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); - const role = await plazaOps.loadOperatorRole(req.currentUser.id); - if (!hasOpsRole(role, minRole)) { - return sendError(res, req, 403, 'OPS_PERMISSION_DENIED', '无运营权限'); - } - req.opsRole = role; - return next(); - }; -} - -const opsApi = express.Router(); -opsApi.use(jsonBody); -opsApi.use(makeRequireOps('reviewer')); - -opsApi.get('/review/queue', async (req, res) => { - try { - const queue = await plazaOps.listReviewQueue({ - status: req.query.status ?? 'pending_review', - cursor: req.query.cursor ?? null, - limit: req.query.limit, - keyword: req.query.keyword ?? null, - }); - return sendData(res, req, queue); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -opsApi.post('/review/posts/:id', async (req, res) => { - try { - const result = await plazaOps.reviewPostAsOps(req.currentUser.id, req.params.id, req.body?.action, { - reason: req.body?.reason ?? null, - }); - return sendData(res, req, { post: result }); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -opsApi.post('/review/batch', makeRequireOps('editor'), async (req, res) => { - try { - const result = await plazaOps.batchReviewPosts(req.currentUser.id, req.body ?? {}); - return sendData(res, req, result); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -opsApi.get('/reports', async (req, res) => { - try { - return sendData(res, req, await plazaOps.listReports({ status: req.query.status ?? 'pending' })); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -opsApi.post('/reports/:id/process', async (req, res) => { - try { - const result = await plazaOps.processReport(req.currentUser.id, req.params.id, req.body ?? {}); - return sendData(res, req, { report: result }); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -opsApi.get('/featured', makeRequireOps('editor'), async (req, res) => { - try { - return sendData(res, req, await plazaOps.listFeatured()); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -opsApi.post('/featured', makeRequireOps('editor'), async (req, res) => { - try { - const result = await plazaOps.setFeatured(req.currentUser.id, req.body ?? {}); - return sendData(res, req, { featured: result }, 201); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -opsApi.delete('/featured/:id', makeRequireOps('editor'), async (req, res) => { - try { - const result = await plazaOps.removeFeatured(req.currentUser.id, req.params.id); - return sendData(res, req, { featured: result }); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -opsApi.get('/analytics/overview', makeRequireOps('ops_admin'), async (req, res) => { - try { - return sendData(res, req, await plazaOps.getAnalyticsOverview()); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -opsApi.get('/creators', async (req, res) => { - try { - return sendData(res, req, await plazaOps.listCreators({ keyword: req.query.keyword ?? null })); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); - -opsApi.patch('/creators/:userId', async (req, res) => { - try { - const result = await plazaOps.updateCreator(req.currentUser.id, req.params.userId, req.body ?? {}); - return sendData(res, req, { creator: result }); - } catch (error) { - return plazaRouteError(res, req, error); - } -}); function mindSpaceError(res, req, error) { const statusByCode = { @@ -1942,7 +1469,7 @@ api.get('/mindspace/v1/assets/:assetId/thumbnail', async (req, res) => { try { const svg = await mindSpaceAssets.renderAssetThumbnail(req.currentUser.id, req.params.assetId); res.set('Content-Type', 'image/svg+xml; charset=utf-8'); - res.set('Cache-Control', 'private, no-cache'); + res.set('Cache-Control', 'private, max-age=300'); res.setHeader('X-Request-Id', req.requestId); return res.send(svg); } catch (error) { @@ -2091,7 +1618,6 @@ async function resolveChatSaveBundle(user, h5Root, input = {}) { username: user.username, h5Root, selectedLinkIndex, - publicBaseUrl: PUBLIC_BASE_URL, }); return { source, @@ -2248,13 +1774,12 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => { req.body?.session_id, req.body?.message_id, ); - const { analysis, resolvedHtml } = await resolveChatSaveAnalysis({ + const analysis = analyzeChatMessageForSave({ content: source.content, userId: req.currentUser.id, username: req.currentUser.username, h5Root: __dirname, selectedLinkIndex: Number(req.body?.selected_link_index ?? 0), - publicBaseUrl: PUBLIC_BASE_URL, }); const snapshot = { session_name: source.session.name, @@ -2262,9 +1787,13 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => { role: source.message.role, content_mode: analysis.contentMode, public_url: analysis.previewUrl, - relative_path: resolvedHtml?.relativePath ?? analysis.relativePath, + relative_path: analysis.relativePath, }; + let resolvedHtml = null; + if (analysis.contentMode === 'static_html') { + resolvedHtml = await resolveStaticHtmlContent(analysis).catch(() => null); + } const privacyScan = scanContent(resolvedHtml?.content ?? source.content); assertPrivateSaveAllowed(categoryCode, privacyScan, req.body?.acknowledged_finding_ids); @@ -2330,7 +1859,7 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => { if (analysis.contentMode === 'static_html' && resolvedHtml?.content && analysis.relativePath) { const publishDir = resolvePublishDir(__dirname, req.currentUser); - void ensureWorkspaceHtmlThumbnail(publishDir, analysis.relativePath, resolvedHtml.content, { + await ensureWorkspaceHtmlThumbnail(publishDir, analysis.relativePath, resolvedHtml.content, { title: pageInput.title, subtitle: pageInput.summary, }).catch(() => {}); @@ -2578,43 +2107,12 @@ api.post('/agent/mindspace_page_patch', async (req, res) => { } }); -api.post('/agent/generate_image', async (req, res) => { - if (!imageGeneration) { - return res.status(503).json({ message: '图片生成未启用' }); - } - try { - const result = await imageGeneration.generateForAgentSession(req.body ?? {}); - return sendData(res, req, { - relative_path: result.relativePath, - public_url: result.publicUrl, - mime_type: result.mimeType, - size: result.size, - style: result.style, - quality: result.quality, - revised_prompt: result.revisedPrompt, - prompt: result.prompt, - }); - } catch (error) { - const code = error?.code ?? 'internal_error'; - const statusByCode = { - image_gen_unavailable: 503, - image_gen_not_configured: 503, - invalid_request: 400, - invalid_output_path: 400, - forbidden: 403, - image_gen_failed: 502, - }; - const status = statusByCode[code] ?? 500; - return sendError(res, req, status, code, error?.message || '图片生成失败', error?.details); - } -}); - api.get('/mindspace/v1/pages/:pageId/thumbnail', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { const svg = await mindSpacePages.renderThumbnail(req.currentUser.id, req.params.pageId); res.set('Content-Type', 'image/svg+xml; charset=utf-8'); - res.set('Cache-Control', 'private, no-cache'); + res.set('Cache-Control', 'private, max-age=300'); res.setHeader('X-Request-Id', req.requestId); return res.send(svg); } catch (error) { @@ -3252,7 +2750,6 @@ api.use(async (req, res, next) => { return tkmindProxy.proxyFallback(req, res); }); -api.use('/ops/v1', opsApi); api.use( createProxyMiddleware({ diff --git a/skills-registry.mjs b/skills-registry.mjs index 7241689..c38a5e7 100644 --- a/skills-registry.mjs +++ b/skills-registry.mjs @@ -15,7 +15,6 @@ export const DEFAULT_USER_SKILLS = { search: true, 'form-builder': true, 'table-viewer': true, - 'image-designer': true, [PUBLISH_SKILL_NAME]: false, }; @@ -25,7 +24,6 @@ export const USER_ROLE_SKILL_PRESETS = { creator: { ...DEFAULT_USER_SKILLS, [PUBLISH_SKILL_NAME]: true, - 'image-designer': true, kanban: true, timeline: true, }, diff --git a/skills-registry.test.mjs b/skills-registry.test.mjs index ef9748b..664b2a2 100644 --- a/skills-registry.test.mjs +++ b/skills-registry.test.mjs @@ -15,7 +15,6 @@ const h5Root = path.dirname(fileURLToPath(import.meta.url)); test('lists static-page-publish in platform catalog', () => { const catalog = listPlatformSkillCatalog(h5Root); assert.ok(catalog.some((item) => item.name === 'static-page-publish')); - assert.ok(catalog.some((item) => item.name === 'image-designer')); }); test('granting publish skill enables static_publish capability', () => { @@ -47,6 +46,5 @@ test('DEFAULT_USER_SKILLS enables common platform skills', () => { assert.equal(DEFAULT_USER_SKILLS.search, true); assert.equal(DEFAULT_USER_SKILLS['form-builder'], true); assert.equal(DEFAULT_USER_SKILLS['table-viewer'], true); - assert.equal(DEFAULT_USER_SKILLS['image-designer'], true); assert.equal(DEFAULT_USER_SKILLS['static-page-publish'], false); }); diff --git a/skills/image-designer/SKILL.md b/skills/image-designer/SKILL.md deleted file mode 100644 index 1255e1e..0000000 --- a/skills/image-designer/SKILL.md +++ /dev/null @@ -1,127 +0,0 @@ ---- -name: image-designer -description: 高级视觉设计师技能:根据用户需求生成高质量图片,保存到 MindSpace 工作区 ---- - -# 高级视觉设计师(AI 生图) - -你是一位 **高级视觉设计师 / 艺术指导**。你的任务不是简单「画一张图」,而是: - -- 理解用户的商业/品牌/内容目标 -- 把模糊需求翻译成可执行的视觉方案 -- 生成可直接用于页面 hero、封面、海报、产品图、社交配图的高质量图片 -- 交付可引用、可复用的文件路径与预览链接 - -## 何时使用 - -- 用户要「生成图片 / 做一张图 / 设计封面 / hero 图 / 海报 / 插画 / 产品图」 -- 用户要为静态页面、MindSpace 卡片、Plaza 帖子准备主视觉 -- 用户描述风格、主题、配色、构图,希望直接得到成品图 - -## 前提 - -1. 用户已开通 **image-designer** 技能 -2. 服务端已启用图片生成(`H5_IMAGE_GEN_ENABLED=1`,并配置 OpenAI 兼容 `/v1/images/generations`) -3. 你具备 **shell** 权限(用于调用生成 API) -4. 建议同时启用 **image_read**(生成后用 `read_image` 自检效果) - -## 设计师工作流 - -1. **澄清需求**(信息不足时最多追问 3 点) - - 用途:页面 hero / 封面 / 海报 / 产品图 / 头像 / 背景 - - 主体:人物、产品、场景、抽象概念 - - 风格:写实 / 插画 / 扁平 / 3D / 国潮 / 极简 / 电影感 - - 画幅:方图 1:1、竖版 3:4、横版 16:9 - - 品牌色、情绪、禁用元素(文字、logo、水印等) -2. **形成视觉方案**(先对用户用 2–4 句中文说明设计方向,再执行生图) -3. **调用生成 API**(每次只生成 1 张,确认满意后再批量) -4. **自检**(用 `read_image` 查看生成结果;不满意则调整 prompt 重生成) -5. **交付**(给出相对路径 + 可点击公网链接 + 使用建议) - -## 生成 API(必须) - -使用当前 Agent **session_id**,通过 shell 调用 Portal API: - -```bash -curl -sS -X POST 'http://127.0.0.1:8081/api/agent/generate_image' \ - -H 'Content-Type: application/json' \ - -d '{ - "session_id": "<当前会话ID>", - "prompt": "用户原始需求(中文或英文均可)", - "output_path": "assets/hero.png", - "style_notes": "modern editorial, warm sunset palette, premium travel mood", - "aspect_ratio": "3:4 portrait hero", - "brand_colors": "#ff6b35, #24243e", - "subject": "Malaysia travel landscape", - "size": "1024x1792", - "style": "vivid", - "quality": "hd" - }' -``` - -### 字段说明 - -| 字段 | 必填 | 说明 | -|------|------|------| -| `session_id` | 是 | 当前 Agent 会话 ID | -| `prompt` | 是 | 用户需求的自然语言描述 | -| `output_path` | 否 | 工作区相对路径,默认 `assets/.png` | -| `style_notes` | 否 | 风格/氛围/材质/镜头语言(英文更稳) | -| `aspect_ratio` | 否 | 画幅意图,如 `1:1` / `3:4` / `16:9` | -| `brand_colors` | 否 | 品牌主色 hex | -| `subject` | 否 | 画面主体 | -| `size` | 否 | `1024x1024` / `1024x1792` / `1792x1024` 等 | -| `style` | 否 | `vivid`(更饱和)或 `natural`(更自然) | -| `quality` | 否 | `standard` 或 `hd` | - -### 输出路径规范 - -- 页面 hero / 封面:`assets/-hero.png` -- 公开配图:`public/-cover.png` -- 多图系列:`assets/-01.png`、`assets/-02.png` -- **禁止**写到工作区外;仅支持 `.png` / `.jpg` / `.webp` - -## Prompt 编写原则(设计师视角) - -1. **先定用途再定风格**:海报要冲击,报告封面要克制,产品图要清晰 -2. **主体 + 场景 + 光线 + 材质 + 构图** 五要素齐全 -3. **默认不要文字**:除非用户明确要求图中文字;UI 文案应放在 HTML 里 -4. **避免侵权风格**:不要写「某某品牌/logo/明星脸/受版权保护的角色」 -5. **中文需求可保留**,但 `style_notes` / 镜头描述建议用英文以提高稳定性 -6. 不满意时优先改:**构图 > 光线 > 风格 > 细节**,不要一次改太多变量 - -## 与静态页发布协作 - -若用户最终要生成 HTML 页面: - -1. 先用本技能生成 hero 图到 `assets/` -2. 再在 `mindspace-cover` 元数据中引用,例如 `"cover":"assets/hero.png"` -3. 需要发布链接时,配合 **static-page-publish** 技能 - -## 回复格式(必须) - -交付图片时使用 Markdown 可点击链接: - -```markdown -已为你生成马来西亚旅行 hero 图: - -- 文件:`assets/malaysia-hero.png` -- 预览:[马来西亚旅行 Hero](https://goo.tkmind.cn/MindSpace/<用户ID>/assets/malaysia-hero.png) - -设计说明:电影感暖色日落、3:4 竖版构图,适合信息流封面与页面首屏。 -``` - -要求: - -- **必须**给出相对路径 -- **必须**给出完整可点击 URL(若 API 返回 `publicUrl` 则直接使用) -- 简要说明设计决策(风格、配色、构图) -- 若效果未达标,主动提出 1–2 个可执行的修改方向 - -## 禁止事项 - -- 不要声称「已生成」但未实际调用 API 或未写入文件 -- 不要用 `read_image` 读取外链来代替本地生成文件 -- 不要生成违法、色情、暴力、歧视、欺诈类内容 -- 不要把图片保存到 `users/`、项目根目录或其它用户目录 -- 不要在未确认需求时连续生成大量图片(浪费额度) diff --git a/src/App.tsx b/src/App.tsx index 99c311b..5fd3443 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,6 @@ import { AuthView } from './components/AuthView'; import { ChatView } from './components/ChatView'; import { MindSpaceView } from './components/MindSpaceView'; import { ChatProvider } from './context/ChatProvider'; -import { AppSessionProvider } from './context/AppSessionContext'; import { PREVIEW_USER } from './dev/mindspacePreviewData'; import { MindSpaceRoute } from './routes/MindSpaceRoute'; import type { CapabilityMap, PortalUser } from './types'; @@ -89,18 +88,16 @@ function AuthenticatedApp({ ); return ( - - - - } - /> - - } /> - - - + + + } + /> + + } /> + + ); } @@ -118,32 +115,18 @@ export function App() { setUnauthorizedHandler(() => { setAuthed(false); setUser(null); - setGrantedSkills(undefined); - setCapabilities(undefined); if (window.location.pathname !== '/') { navigate('/', { replace: true }); } }); - - const syncAuth = () => - checkAuth().then((status) => { - setLegacyMode(status.mode === 'legacy'); - setAuthed(status.authenticated); - setUser(status.user ?? null); - setCapabilities(status.capabilities); - setGrantedSkills(status.grantedSkills); - }); - - void syncAuth(); - - const handleFocus = () => { - void syncAuth(); - }; - window.addEventListener('focus', handleFocus); - return () => { - window.removeEventListener('focus', handleFocus); - setUnauthorizedHandler(null); - }; + void checkAuth().then((status) => { + setLegacyMode(status.mode === 'legacy'); + setAuthed(status.authenticated); + setUser(status.user ?? null); + setCapabilities(status.capabilities); + setGrantedSkills(status.grantedSkills); + }); + return () => setUnauthorizedHandler(null); }, [mindSpacePreview, navigate]); if (mindSpacePreview) { diff --git a/src/api/client.ts b/src/api/client.ts index 81f6394..c1020ad 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -41,7 +41,6 @@ import type { SessionEvent, SessionListResponse, UsageRecord, - UsageSummary, BalanceUpdate, } from '../types'; @@ -272,15 +271,14 @@ export async function checkAuth(): Promise { if (!response.ok) return { authenticated: false }; const status = (await response.json()) as AuthStatus; if (status.authenticated) resetUnauthorizedGuard(); - if (status.authenticated && status.mode === 'user') { + if (status.authenticated && status.mode === 'user' && !status.capabilities) { try { const me = await getMe(); return { ...status, - user: me.user ?? status.user, - capabilities: me.capabilities ?? status.capabilities, - grantedSkills: me.grantedSkills ?? status.grantedSkills, - unrestricted: me.unrestricted ?? status.unrestricted, + capabilities: me.capabilities, + grantedSkills: me.grantedSkills, + user: me.user, }; } catch { return status; @@ -301,14 +299,8 @@ export async function getMe(): Promise<{ return portalFetch('/auth/me'); } -export async function getMyUsageSummary(): Promise { - const result = await portalFetch<{ summary: UsageSummary }>('/auth/usage/summary'); - return result.summary; -} - -export async function getMyUsage(limit = 20): Promise { - const query = limit ? `?limit=${encodeURIComponent(String(limit))}` : ''; - const result = await portalFetch<{ records: UsageRecord[] }>(`/auth/usage${query}`); +export async function getMyUsage(): Promise { + const result = await portalFetch<{ records: UsageRecord[] }>('/auth/usage'); return result.records ?? []; } @@ -1018,19 +1010,8 @@ export async function getAdminDashboardSummary(): Promise return result.summary; } -export async function getAdminUsageSummary(userId?: string): Promise { - const params = new URLSearchParams(); - if (userId) params.set('userId', userId); - const query = params.toString() ? `?${params.toString()}` : ''; - const result = await portalFetch<{ summary: UsageSummary }>(`/admin-api/usage/summary${query}`); - return result.summary; -} - -export async function listAdminUsage(userId?: string, limit = 20): Promise { - const params = new URLSearchParams(); - if (userId) params.set('userId', userId); - if (limit) params.set('limit', String(limit)); - const query = params.toString() ? `?${params.toString()}` : ''; +export async function listAdminUsage(userId?: string): Promise { + const query = userId ? `?userId=${encodeURIComponent(userId)}` : ''; const result = await portalFetch<{ records: UsageRecord[] }>(`/admin-api/usage${query}`); return result.records ?? []; } diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 1bf5408..e24cc88 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -1,5 +1,4 @@ import { useEffect, useRef, useState } from 'react'; -import { useAppSession } from '../context/AppSessionContext'; import { useNetworkStatus } from '../hooks/useNetworkStatus'; import { openAvatarPicker } from '../utils/userAvatar'; import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills'; @@ -45,7 +44,6 @@ export function ChatPanel({ onClose?: () => void; }) { const online = useNetworkStatus(); - const { capabilities: sessionCapabilities, grantedSkills: sessionGrantedSkills } = useAppSession(); const [input, setInput] = useState(''); const [pageSource, setPageSource] = useState(null); const [voiceNotice, setVoiceNotice] = useState(null); @@ -58,14 +56,9 @@ export function ChatPanel({ const busy = chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting'; const offlineBlocked = !online; const publishSkillName = user?.publishSkillName ?? 'static-page-publish'; - const effectiveCapabilities = capabilities ?? sessionCapabilities; - const effectiveGrantedSkills = grantedSkills ?? sessionGrantedSkills; - const hasPublishSkill = effectiveGrantedSkills?.includes(publishSkillName) ?? false; - const canPublish = Boolean(effectiveCapabilities?.static_publish) || hasPublishSkill; - const chatSkills = filterChatSkills(CHAT_SKILL_OPTIONS, { - grantedSkills: effectiveGrantedSkills, - canPublish, - }); + const hasPublishSkill = grantedSkills?.includes(publishSkillName) ?? false; + const canPublish = Boolean(capabilities?.static_publish) || hasPublishSkill; + const chatSkills = filterChatSkills(CHAT_SKILL_OPTIONS, { grantedSkills, canPublish }); const compact = variant === 'compact'; const handleSubmit = async () => { @@ -135,9 +128,6 @@ export function ChatPanel({ onSaved={(result) => { setPageSource(null); onPageSaved?.(result); - if (result.kind === 'page' && result.pageId) { - onClose?.(); - } }} /> )} diff --git a/src/components/ChatSkillIcons.tsx b/src/components/ChatSkillIcons.tsx index 1b4563a..0f91962 100644 --- a/src/components/ChatSkillIcons.tsx +++ b/src/components/ChatSkillIcons.tsx @@ -90,17 +90,6 @@ function AnalyzeIcon({ className }: IconProps) { ); } -function ImageIcon({ className }: IconProps) { - return ( - - ); -} - const ICONS: Record JSX.Element> = { spark: SparkIcon, page: PageIcon, @@ -110,7 +99,6 @@ const ICONS: Record JSX.Element> = { table: TableIcon, summary: SummaryIcon, analyze: AnalyzeIcon, - image: ImageIcon, }; export function ChatSkillIcon({ id, className }: { id: ChatSkillIconId; className?: string }) { diff --git a/src/components/MindSpacePageDetail.tsx b/src/components/MindSpacePageDetail.tsx index 882b009..eae733e 100644 --- a/src/components/MindSpacePageDetail.tsx +++ b/src/components/MindSpacePageDetail.tsx @@ -156,7 +156,6 @@ export function MindSpacePageDetail({ const [plazaPost, setPlazaPost] = useState(null); const [pushToPlaza, setPushToPlaza] = useState(false); const [fixNotice, setFixNotice] = useState(null); - const [saving, setSaving] = useState(false); const applyPageRecord = useCallback( (next: MindSpacePage, options?: { recordHistory?: boolean }) => { @@ -469,61 +468,6 @@ export function MindSpacePageDetail({ } }, [applyPageRecord, content, markPreviewRefreshPending, page, pageId, summary, title, triggerChangeHighlight]); - const isDirty = - Boolean(page) && - (title !== page.title || - summary !== page.summary || - (content ?? '') !== (page.content ?? '') || - templateId !== page.templateId); - - const save = useCallback(async () => { - if (!page || saving) return; - if (!title.trim()) { - setError('标题不能为空'); - return; - } - if (!content.trim()) { - setError('页面内容不能为空'); - return; - } - setSaving(true); - setError(null); - try { - const updated = await updateMindSpacePage(page.id, { - expectedVersion: page.versionNo, - title: title.trim(), - summary: summary.trim(), - content, - templateId, - changeNote: `保存草稿 v${page.versionNo + 1}`, - }); - applyPageRecord(updated); - resetDraft({ - title: updated.title, - summary: updated.summary, - content: updated.content ?? '', - }); - setPreviewContent(updated.content ?? ''); - setPreviewRefreshPending(false); - setPreviewKey((value) => value + 1); - await onSaved(); - } catch (err) { - setError(err instanceof Error ? err.message : '页面保存失败'); - } finally { - setSaving(false); - } - }, [ - applyPageRecord, - content, - onSaved, - page, - resetDraft, - saving, - summary, - templateId, - title, - ]); - const applyOneClickFix = async () => { if (!page) return; setPublishing(true); @@ -576,11 +520,6 @@ export function MindSpacePageDetail({ ) { return; } - if (event.key.toLowerCase() === 's') { - event.preventDefault(); - void save(); - return; - } if (event.key.toLowerCase() === 'z' && event.shiftKey) { const next = redo(); if (next) { @@ -599,7 +538,7 @@ export function MindSpacePageDetail({ }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [redo, save, syncPreviewAfterHistory, undo]); + }, [redo, syncPreviewAfterHistory, undo]); useEffect(() => { if (!page || !onContextUpdate) return; @@ -1110,21 +1049,6 @@ export function MindSpacePageDetail({ /> )} -
- - {isDirty - ? '有未保存的修改。保存会创建新版本,历史版本不会被覆盖。' - : '保存会创建新版本,历史版本不会被覆盖。'} - - -
@@ -1159,17 +1083,6 @@ export function MindSpacePageDetail({ > 刷新预览{previewRefreshPending ? ' · 有新修改' : ''} - {page.contentFormat === 'html' ? ( - - ) : null}
{page.contentFormat === 'html' ? ( @@ -1256,9 +1169,6 @@ export function MindSpacePageDetail({ onRefresh={() => handleManualPreviewRefresh()} refreshPending={previewRefreshPending} onContentChange={handlePreviewContentChange} - onSave={() => void save()} - saving={saving} - canSave={isDirty && Boolean(title.trim()) && Boolean(content.trim())} /> ) : null} diff --git a/src/components/MindSpacePageFullscreenPreview.tsx b/src/components/MindSpacePageFullscreenPreview.tsx index e26c2fd..81bad57 100644 --- a/src/components/MindSpacePageFullscreenPreview.tsx +++ b/src/components/MindSpacePageFullscreenPreview.tsx @@ -16,9 +16,6 @@ export function MindSpacePageFullscreenPreview({ onRefresh, refreshPending = false, onContentChange, - onSave, - saving = false, - canSave = false, }: { pageTitle: string; title: string; @@ -33,9 +30,6 @@ export function MindSpacePageFullscreenPreview({ onRefresh: () => void; refreshPending?: boolean; onContentChange: (html: string) => void; - onSave?: () => void; - saving?: boolean; - canSave?: boolean; }) { useEffect(() => { const previousOverflow = document.body.style.overflow; @@ -57,13 +51,6 @@ export function MindSpacePageFullscreenPreview({ } const mod = event.metaKey || event.ctrlKey; - if (mod && !event.altKey && event.key.toLowerCase() === 's') { - if (onSave && canSave && !saving) { - event.preventDefault(); - onSave(); - } - return; - } if (mod && !event.altKey && event.key.toLowerCase() === 'z') { if (event.shiftKey) { if (canRedo && onRedo?.()) event.preventDefault(); @@ -79,7 +66,7 @@ export function MindSpacePageFullscreenPreview({ }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [canRedo, canSave, canUndo, onClose, onRedo, onSave, onUndo, saving]); + }, [canRedo, canUndo, onClose, onRedo, onUndo]); return (
@@ -106,18 +93,7 @@ export function MindSpacePageFullscreenPreview({ > 刷新预览{refreshPending ? ' · 有新修改' : ''} - {onSave ? ( - - ) : null} -
diff --git a/src/components/PageSaveDialog.tsx b/src/components/PageSaveDialog.tsx index e69e4fb..b6f87ea 100644 --- a/src/components/PageSaveDialog.tsx +++ b/src/components/PageSaveDialog.tsx @@ -151,43 +151,16 @@ export function PageSaveDialog({ const privateNeedsAck = categoryCode === 'private' && privacyFindings.length > 0 && analysis?.privacyScan.allowed; - const resolveSaveTitle = () => - title.trim() || analysis?.suggestedTitle?.trim() || ''; - - const resolveSaveSummary = () => - summary.trim() || analysis?.suggestedSummary?.trim() || ''; - - const effectiveTitle = resolveSaveTitle(); - const canSave = - Boolean(effectiveTitle) && - !analysisLoading && - !analysisError && - !privateBlocked && - !(privateNeedsAck && !privateAcknowledged); - const save = async () => { - const saveTitle = resolveSaveTitle(); - if (!saveTitle) { - setSaveError('请填写页面标题'); - return; - } - const saveSummary = resolveSaveSummary(); - if (!title.trim() && saveTitle !== title) { - setTitle(saveTitle); - titleRef.current = saveTitle; - } - if (!summary.trim() && saveSummary) { - setSummary(saveSummary); - summaryRef.current = saveSummary; - } + if (!title.trim()) return; setSaving(true); setSaveError(null); try { const result = await saveChatMessageAsPage({ sessionId, messageId, - title: saveTitle, - summary: saveSummary, + title: title.trim(), + summary: summary.trim(), templateId: isStaticHtml ? 'static-html' : templateId, categoryCode, selectedLinkIndex, @@ -197,12 +170,8 @@ export function PageSaveDialog({ : undefined, }); if (result.kind === 'page') { - const pageId = result.page?.id; - if (!pageId) { - throw new Error('保存成功但未返回页面 ID'); - } setSaveNotice(`已保存到${CATEGORY_LABELS[categoryCode]}`); - onSaved({ kind: 'page', categoryCode: 'draft', pageId }); + onSaved({ kind: 'page', categoryCode: 'draft', pageId: result.page.id }); return; } setSaveNotice(`已保存到${CATEGORY_LABELS[result.categoryCode]}`); @@ -401,7 +370,14 @@ export function PageSaveDialog({ type="button" className="page-save-primary" onClick={() => void save()} - disabled={saving || !canSave} + disabled={ + saving || + analysisLoading || + !title.trim() || + Boolean(analysisError) || + privateBlocked || + (privateNeedsAck && !privateAcknowledged) + } > {saving ? '正在保存…' : categoryCode === 'draft' ? '保存并进入编辑' : '保存到空间'} diff --git a/src/components/PageSavePreviewPanel.tsx b/src/components/PageSavePreviewPanel.tsx index a2392ec..a530fd3 100644 --- a/src/components/PageSavePreviewPanel.tsx +++ b/src/components/PageSavePreviewPanel.tsx @@ -85,11 +85,9 @@ export function PageSavePreviewPanel({ ]); if (!analysis.hasHtmlContent) { - const hint = analysis.filename || analysis.relativePath || analysis.previewUrl; return (
- 本地未找到页面文件{hint ? `(${hint})` : ''}。请确认 Agent 已用 write 写入{' '} - public/页面.html,且聊天里的链接路径与实际文件一致;若页面只在公网可访问,请稍后重试或重新生成。 + 本地未找到页面文件,请确认 Agent 已将 HTML 保存到 MindSpace 工作区。
); } diff --git a/src/components/RechargeModal.tsx b/src/components/RechargeModal.tsx index 23d006f..a489056 100644 --- a/src/components/RechargeModal.tsx +++ b/src/components/RechargeModal.tsx @@ -3,7 +3,6 @@ import { createPortal } from 'react-dom'; import QRCode from 'qrcode'; import { createRechargeOrder, getBillingConfig, getRechargeOrder } from '../api/client'; import type { BillingConfig, RechargeOrder } from '../types'; -import { formatBillingRates } from '../utils/billing'; import { invokeWechatJsapiPay, isWeChatBrowser } from '../utils/wechatPay'; function formatYuan(cents: number) { @@ -216,12 +215,6 @@ export function RechargeModal({

最低充值 {formatYuan(config?.minRechargeCents ?? 500)},用于 AI 对话按量扣费

- {config && ( -

- 当前费率:{formatBillingRates(config.inputCentsPer1k, config.outputCentsPer1k)} - 。Agent 模式含工具与记忆,实际消耗通常高于普通聊天。 -

- )}
{tiers.map((tier) => (
- {!hideOpenFullChat ? (