From 6ee6fd64dd92dc961728b708d2cf659b8472cc56 Mon Sep 17 00:00:00 2001 From: John Date: Mon, 15 Jun 2026 22:09:38 -0700 Subject: [PATCH] Add MindSpace page live edit, chat skills, and H5 deploy tooling. Introduce page edit sessions with draft preview and patch API, chat skill picker, user memory profile, h5ApiBase resolution, voice WAV transport, and scripts for 105/g2 deployment and Plaza local dev. Co-authored-by: Cursor --- .env.example | 15 +- asr-proxy.mjs | 31 +- asr-proxy.test.mjs | 19 + capabilities.mjs | 31 +- capabilities.test.mjs | 32 + chat-skills.mjs | 92 ++ chat-skills.test.mjs | 39 + deploy/plaza-105/goose-plaza-web.service | 20 + deploy/plaza-105/plaza-105.env.example | 15 + .../plaza.tkmind.cn.bootstrap.nginx.conf | 67 ++ deploy/plaza-105/plaza.tkmind.cn.nginx.conf | 85 ++ mindspace-agent-runner.mjs | 11 +- mindspace-agent-runner.test.mjs | 3 + mindspace-chat-context.mjs | 42 +- mindspace-cleanup.mjs | 119 +- mindspace-cleanup.test.mjs | 34 + mindspace-cover-ai.mjs | 57 +- mindspace-page-edit-session.mjs | 269 +++++ mindspace-page-live-edit.mjs | 104 ++ mindspace-page-patch.mjs | 82 ++ mindspace-page-patch.test.mjs | 38 + package.json | 8 +- policies.mjs | 1 + policies.test.mjs | 22 +- scripts/.rsync-exclude-105 | 11 + scripts/.rsync-exclude-plaza-web | 6 + scripts/check-plaza-local.mjs | 123 ++ scripts/deploy-plaza-105.sh | 165 +++ scripts/dev-plaza.mjs | 19 +- scripts/g2-h5-tunnel.sh | 15 + scripts/g2-lb.Caddyfile | 10 + scripts/g2-lb.Caddyfile.active | 10 + scripts/g2-lb.local.Caddyfile | 10 + scripts/install-g2-lb.sh | 95 ++ scripts/install-memind-mac-tunnel.sh | 40 + scripts/memind-mac-tunnel.sh | 14 + scripts/open-plaza-local.mjs | 47 + scripts/plaza-local-dns-server.mjs | 115 ++ scripts/setup-mindspace-shared-105.sh | 116 ++ scripts/setup-plaza-local-dns.mjs | 96 +- scripts/sync-local-db-to-rds.mjs | 124 ++ scripts/sync-to-105.sh | 56 + server.mjs | 262 +++- session-reconcile.mjs | 42 +- skills-registry.mjs | 22 + skills-registry.test.mjs | 9 + skills/static-page-publish/SKILL.md | 7 +- src/admin/pages/UsersPage.tsx | 4 +- src/api/client.ts | 145 ++- src/components/AuthView.tsx | 28 +- src/components/BalanceRing.tsx | 179 ++- src/components/ChatHeaderMoreMenu.tsx | 88 ++ src/components/ChatPanel.tsx | 38 +- src/components/ChatSkillIcons.tsx | 107 ++ src/components/ChatSkillPicker.tsx | 85 ++ src/components/ChatView.tsx | 113 +- src/components/HistorySidebar.tsx | 78 +- src/components/MindSpacePageDetail.tsx | 351 ++++-- .../MindSpacePageDraftPreviewFrame.tsx | 132 +++ .../MindSpacePageFullscreenPreview.tsx | 209 ++++ src/components/MindSpaceSpaceChat.tsx | 27 +- src/components/MindSpaceView.tsx | 586 +++++++-- src/components/SpaceChatPanel.tsx | 52 +- src/components/WechatDesktopLogin.tsx | 40 +- src/components/WechatQrDisplay.tsx | 76 ++ src/components/WechatScanLogin.tsx | 177 ++- src/hooks/usePageDraftHistory.ts | 117 ++ src/hooks/usePageEditSubChat.ts | 363 ++++++ src/hooks/useTKMindChat.ts | 237 +++- src/hooks/useVoiceSession.ts | 50 +- src/index.css | 686 ++++++++++- src/types.ts | 20 +- src/utils/chatSkills.ts | 47 + src/utils/h5ApiBase.ts | 7 + src/utils/mindspaceCards.ts | 8 + src/utils/mindspaceChatContext.ts | 4 + src/utils/mindspacePageChangeHighlight.ts | 155 +++ src/utils/mindspacePagePatch.ts | 14 + src/utils/sessions.ts | 57 + src/utils/wechat.ts | 16 + src/voice/asrTransport.ts | 8 +- src/voice/audioWav.ts | 90 ++ src/voice/capabilities.ts | 15 +- sse-billing.mjs | 10 +- temp/john/shanghai-economy/index.html | 490 -------- temp/john/vietnam-guide/index.html | 1049 ----------------- tkmind-proxy.mjs | 71 +- user-auth.mjs | 101 +- user-memory-profile.mjs | 178 +++ user-memory-profile.test.mjs | 89 ++ user-publish.mjs | 29 +- user-publish.test.mjs | 18 +- user-space.mjs | 4 +- wechat-oauth.mjs | 53 +- 94 files changed, 7015 insertions(+), 2136 deletions(-) create mode 100644 asr-proxy.test.mjs create mode 100644 chat-skills.mjs create mode 100644 chat-skills.test.mjs create mode 100644 deploy/plaza-105/goose-plaza-web.service create mode 100644 deploy/plaza-105/plaza-105.env.example create mode 100644 deploy/plaza-105/plaza.tkmind.cn.bootstrap.nginx.conf create mode 100644 deploy/plaza-105/plaza.tkmind.cn.nginx.conf create mode 100644 mindspace-page-edit-session.mjs create mode 100644 mindspace-page-live-edit.mjs create mode 100644 mindspace-page-patch.mjs create mode 100644 mindspace-page-patch.test.mjs create mode 100644 scripts/.rsync-exclude-105 create mode 100644 scripts/.rsync-exclude-plaza-web create mode 100644 scripts/check-plaza-local.mjs create mode 100755 scripts/deploy-plaza-105.sh create mode 100755 scripts/g2-h5-tunnel.sh create mode 100644 scripts/g2-lb.Caddyfile create mode 100644 scripts/g2-lb.Caddyfile.active create mode 100644 scripts/g2-lb.local.Caddyfile create mode 100755 scripts/install-g2-lb.sh create mode 100755 scripts/install-memind-mac-tunnel.sh create mode 100755 scripts/memind-mac-tunnel.sh create mode 100644 scripts/open-plaza-local.mjs create mode 100644 scripts/plaza-local-dns-server.mjs create mode 100755 scripts/setup-mindspace-shared-105.sh create mode 100644 scripts/sync-local-db-to-rds.mjs create mode 100755 scripts/sync-to-105.sh create mode 100644 src/components/ChatHeaderMoreMenu.tsx create mode 100644 src/components/ChatSkillIcons.tsx create mode 100644 src/components/ChatSkillPicker.tsx create mode 100644 src/components/MindSpacePageDraftPreviewFrame.tsx create mode 100644 src/components/MindSpacePageFullscreenPreview.tsx create mode 100644 src/components/WechatQrDisplay.tsx create mode 100644 src/hooks/usePageDraftHistory.ts create mode 100644 src/hooks/usePageEditSubChat.ts create mode 100644 src/utils/chatSkills.ts create mode 100644 src/utils/h5ApiBase.ts create mode 100644 src/utils/mindspacePageChangeHighlight.ts create mode 100644 src/utils/mindspacePagePatch.ts create mode 100644 src/voice/audioWav.ts delete mode 100644 temp/john/shanghai-economy/index.html delete mode 100644 temp/john/vietnam-guide/index.html create mode 100644 user-memory-profile.mjs create mode 100644 user-memory-profile.test.mjs diff --git a/.env.example b/.env.example index ba2bc0d..e0d54e8 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,7 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:8081 DATABASE_URL=mysql://boot:password@localhost:3306/tkmind # 或分别设置 MYSQL_HOST / MYSQL_PORT / MYSQL_USER / MYSQL_PASSWORD / MYSQL_DATABASE H5_USERS_ROOT=/root/tkmind_go/users -H5_SIGNUP_BALANCE_CENTS=1000 +H5_SIGNUP_BALANCE_CENTS=500 H5_ADMIN_USERNAME=admin H5_ADMIN_PASSWORD=change-me-admin # 管理员工作区(默认 users 目录的父目录;对接 goose 全权限时建议设为项目根) @@ -121,16 +121,19 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind # Plaza 发现广场(Sprint 1) # PLAZA_AUTO_APPROVE=true # 本地 Plaza(无端口,与线上一致): -# sudo pnpm setup:plaza-dns # hosts: plaza.tkmind.cn -> 127.0.0.1 +# sudo pnpm setup:plaza-dns # hosts + /etc/resolver(覆盖 Tailscale fake-ip) +# pnpm dev:plaza-dns # 本地 DNS :5533(dev:plaza 会自动启动) # pnpm dev:plaza # 仅 Plaza + Portal -# sudo pnpm dev:plaza-proxy # :80 -> :3001(另开终端,或随 pnpm dev 自动尝试) +# sudo pnpm dev:plaza-proxy # :443 -> :3001 +# 若浏览器仍走 Cloudflare:关闭「安全 DNS / Secure DNS」后重开浏览器 # PLAZA_LOCAL_HOST=plaza.tkmind.cn # PLAZA_PUBLIC_PORT=80 # PLAZA_PUBLIC_BASE=http://plaza.tkmind.cn # VITE_PLAZA_BASE=http://plaza.tkmind.cn -# 线上: -# PLAZA_PUBLIC_BASE=https://plaza.tkmind.cn -# VITE_PLAZA_BASE=https://plaza.tkmind.cn +# 105 部署(Plaza 与 H5 分离): +# pnpm deploy:105 # Memind H5 → 105(Plaza API + MindSpace + 登录) +# pnpm deploy:plaza-105 # Plaza Next.js → 105 /root/plaza/web(独立前端) +# 配置:deploy/plaza-105/plaza-105.env.example → plaza-105.env # MindSpace(默认启用;子功能需按需开启) # MINDSPACE_ENABLED=false diff --git a/asr-proxy.mjs b/asr-proxy.mjs index 58f618c..a74f91e 100644 --- a/asr-proxy.mjs +++ b/asr-proxy.mjs @@ -5,6 +5,18 @@ const ASR_TARGET = process.env.H5_ASR_TARGET ?? 'https://asr.tkmind.cn'; const ASR_MAX_BYTES = Number(process.env.H5_ASR_MAX_BYTES ?? 5 * 1024 * 1024); const ASR_TIMEOUT_MS = Number(process.env.H5_ASR_TIMEOUT_MS ?? 45_000); +export function sanitizeAsrMessage(message) { + if (!message || typeof message !== 'string') return '识别失败,请重试'; + const trimmed = message.trim(); + if (!trimmed) return '识别失败,请重试'; + if (/Failed to load audio|ffmpeg|Invalid data found when processing input|moov atom not found/i.test(trimmed)) { + return '音频格式无法识别,请重新录制'; + } + if (/timeout|timed out/i.test(trimmed)) return '识别超时,请重试'; + if (trimmed.length > 160) return `${trimmed.slice(0, 160)}…`; + return trimmed; +} + export function attachAsrRoutes(api, deps) { const multipartRaw = express.raw({ type: (req) => (req.headers['content-type'] ?? '').includes('multipart/form-data'), @@ -39,25 +51,22 @@ export function attachAsrRoutes(api, deps) { } if (!upstream.ok) { - return deps.sendError( - res, - req, - upstream.status, - 'asr_failed', - payload?.message ?? text ?? 'ASR 请求失败', - ); + const message = sanitizeAsrMessage(payload?.message ?? text ?? 'ASR 请求失败'); + console.warn('[asr] upstream HTTP error', upstream.status, message); + return deps.sendError(res, req, upstream.status, 'asr_failed', message); } if (payload?.code !== 200) { - return deps.sendError(res, req, 502, 'asr_failed', payload?.message ?? '识别失败'); + const message = sanitizeAsrMessage(payload?.message ?? '识别失败'); + console.warn('[asr] upstream business error', payload?.code, message); + return deps.sendError(res, req, 502, 'asr_failed', message); } return deps.sendData(res, req, { text: payload?.data?.text ?? '' }); } catch (err) { const message = err instanceof Error && err.name === 'AbortError' ? '识别超时,请重试' - : err instanceof Error - ? err.message - : 'ASR 请求失败'; + : sanitizeAsrMessage(err instanceof Error ? err.message : 'ASR 请求失败'); + console.warn('[asr] proxy failed', message); return deps.sendError(res, req, 502, 'asr_failed', message); } finally { clearTimeout(timer); diff --git a/asr-proxy.test.mjs b/asr-proxy.test.mjs new file mode 100644 index 0000000..cfc7154 --- /dev/null +++ b/asr-proxy.test.mjs @@ -0,0 +1,19 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { sanitizeAsrMessage } from './asr-proxy.mjs'; + +test('sanitizeAsrMessage shortens ffmpeg decode errors', () => { + const message = sanitizeAsrMessage('Failed to load audio: ffmpeg version 4.2.10 ...'); + assert.equal(message, '音频格式无法识别,请重新录制'); +}); + +test('sanitizeAsrMessage keeps concise upstream messages', () => { + assert.equal(sanitizeAsrMessage('未检测到有效语音'), '未检测到有效语音'); +}); + +test('sanitizeAsrMessage truncates long messages', () => { + const long = 'x'.repeat(200); + const message = sanitizeAsrMessage(long); + assert.ok(message.endsWith('…')); + assert.ok(message.length <= 161); +}); diff --git a/capabilities.mjs b/capabilities.mjs index 1ff5524..c68e6ac 100644 --- a/capabilities.mjs +++ b/capabilities.mjs @@ -142,12 +142,12 @@ export const DEFAULT_USER_CAPABILITIES = Object.fromEntries( filesystem: false, code_browse: false, image_read: true, - skills: false, + skills: true, subagent: false, code_sandbox: false, - chat_recall: false, + chat_recall: true, context_memory: true, - memory_store: false, + memory_store: true, extension_admin: false, apps: false, todo: false, @@ -287,6 +287,31 @@ export function buildAgentExtensionPolicy( }; } +/** + * Narrow policy for in-preview page edit sub-sessions: patch API via shell when allowed, + * otherwise reply-only patches via mindspace-page-update blocks. + */ +export function buildPageEditAgentPolicy(basePolicy) { + if (basePolicy?.unrestricted) { + return { + ...basePolicy, + enableContextMemory: false, + gooseMode: 'auto', + }; + } + + const baseDeveloper = basePolicy?.extensionOverrides?.find((ext) => ext.name === 'developer'); + const canShell = baseDeveloper?.available_tools?.includes('shell') ?? false; + const extensions = canShell ? [makeExtension('platform', 'developer', ['shell'])] : []; + + return { + ...basePolicy, + extensionOverrides: extensions, + enableContextMemory: false, + gooseMode: 'auto', + }; +} + export function normalizeCapabilityPatch(patch) { const normalized = {}; for (const [key, value] of Object.entries(patch ?? {})) { diff --git a/capabilities.test.mjs b/capabilities.test.mjs index 285c448..a3b7404 100644 --- a/capabilities.test.mjs +++ b/capabilities.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { buildAgentExtensionPolicy, + buildPageEditAgentPolicy, CAPABILITY_CATALOG, clampUserCapabilities, DEFAULT_USER_CAPABILITIES, @@ -16,6 +17,9 @@ test('default user policy blocks dangerous capabilities', () => { assert.equal(DEFAULT_USER_CAPABILITIES.extension_admin, false); assert.equal(DEFAULT_USER_CAPABILITIES.static_publish, false); assert.equal(DEFAULT_USER_CAPABILITIES.code_browse, false); + assert.equal(DEFAULT_USER_CAPABILITIES.memory_store, true); + assert.equal(DEFAULT_USER_CAPABILITIES.skills, true); + assert.equal(DEFAULT_USER_CAPABILITIES.chat_recall, true); }); test('buildAgentExtensionPolicy returns null overrides and auto mode for unrestricted users', () => { @@ -86,3 +90,31 @@ test('catalog keys are unique', () => { const keys = CAPABILITY_CATALOG.map((item) => item.key); assert.equal(keys.length, new Set(keys).size); }); + +test('buildPageEditAgentPolicy narrows tools to shell when available', () => { + const base = buildAgentExtensionPolicy({ + ...DEFAULT_USER_CAPABILITIES, + shell: true, + filesystem: true, + skills: true, + }); + const narrowed = buildPageEditAgentPolicy(base); + assert.equal(narrowed.enableContextMemory, false); + assert.equal(narrowed.gooseMode, 'auto'); + assert.deepEqual(narrowed.extensionOverrides, [ + { + type: 'platform', + name: 'developer', + description: '', + display_name: 'developer', + bundled: true, + available_tools: ['shell'], + }, + ]); +}); + +test('buildPageEditAgentPolicy without shell keeps empty developer tools', () => { + const base = buildAgentExtensionPolicy(DEFAULT_USER_CAPABILITIES); + const narrowed = buildPageEditAgentPolicy(base); + assert.deepEqual(narrowed.extensionOverrides, []); +}); diff --git a/chat-skills.mjs b/chat-skills.mjs new file mode 100644 index 0000000..d1eb240 --- /dev/null +++ b/chat-skills.mjs @@ -0,0 +1,92 @@ +// Keep browser-safe: do not import user-publish.mjs (uses node:fs/path/url). +const PUBLISH_SKILL_NAME = 'static-page-publish'; + +export const CHAT_SKILL_DEFINITIONS = [ + { + id: 'web-search', + label: '查资料', + icon: 'web', + skillName: 'web', + requiresSkill: 'web', + prefillOnly: true, + promptKey: 'web', + }, + { + id: 'code-search', + label: '搜代码', + icon: 'search', + skillName: 'search', + requiresSkill: 'search', + prefillOnly: true, + promptKey: 'search', + }, + { + id: 'form-collect', + label: '表单收集', + icon: 'form', + skillName: 'form-builder', + requiresSkill: 'form-builder', + prefillOnly: true, + promptKey: 'form-builder', + }, + { + id: 'table-view', + label: '数据表格', + icon: 'table', + skillName: 'table-viewer', + requiresSkill: 'table-viewer', + prefillOnly: true, + promptKey: 'table-viewer', + }, + { + id: 'summarize', + label: '总结内容', + icon: 'summary', + prefillOnly: true, + promptKey: 'summarize', + }, + { + id: 'analyze', + label: '深度分析', + icon: 'analyze', + prefillOnly: true, + promptKey: 'analyze', + }, + { + id: 'generate-page', + label: '生成页面', + icon: 'page', + skillName: PUBLISH_SKILL_NAME, + requiresPublish: true, + promptKey: 'generate-page', + }, +]; + +export function buildChatSkillPrompt(promptKey, skillName) { + switch (promptKey) { + case 'web': + return `请使用 ${skillName ?? 'web'} 技能:帮我搜索并查阅相关资料(优先官方文档),并给出中文摘要与来源链接。我的问题是:`; + case 'search': + return `请使用 ${skillName ?? 'search'} 技能:帮我在工作区中查找代码或文件。我要找的是:`; + case 'form-builder': + return `请使用 ${skillName ?? 'form-builder'} 技能:请用交互式表单收集以下场景所需的结构化字段(字段不超过 8 个):`; + case 'table-viewer': + return `请使用 ${skillName ?? 'table-viewer'} 技能:请把以下数据整理成可排序、可筛选的交互式表格:`; + case 'summarize': + return '请总结以下内容,提炼核心结论、重点信息和可执行建议(条理清晰、中文输出):'; + case 'analyze': + return '请深入分析以下内容,给出结构化解读:背景、关键发现、风险或机会、以及建议下一步:'; + case 'generate-page': + return `请使用 ${skillName ?? PUBLISH_SKILL_NAME} 技能:在我的专属 MindSpace 发布目录生成静态 HTML 页面,并给出可公网访问的完整链接。`; + default: + return ''; + } +} + +export function filterChatSkills(options, ctx) { + return options.filter((skill) => { + if (skill.requiresPublish && !ctx.canPublish) return false; + if (skill.requiresSkill && !ctx.grantedSkills?.includes(skill.requiresSkill)) return false; + return true; + }); +} diff --git a/chat-skills.test.mjs b/chat-skills.test.mjs new file mode 100644 index 0000000..ba399af --- /dev/null +++ b/chat-skills.test.mjs @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildChatSkillPrompt, + CHAT_SKILL_DEFINITIONS, + filterChatSkills, +} from './chat-skills.mjs'; + +test('filterChatSkills shows summarize and analyze without granted skills', () => { + const visible = filterChatSkills(CHAT_SKILL_DEFINITIONS, { canPublish: false }); + assert.ok(visible.some((item) => item.id === 'summarize')); + assert.ok(visible.some((item) => item.id === 'analyze')); + assert.equal(visible.some((item) => item.id === 'generate-page'), false); +}); + +test('filterChatSkills gates platform skills by grantedSkills', () => { + const visible = filterChatSkills(CHAT_SKILL_DEFINITIONS, { + canPublish: false, + grantedSkills: ['web', 'search'], + }); + assert.ok(visible.some((item) => item.id === 'web-search')); + assert.ok(visible.some((item) => item.id === 'code-search')); + assert.equal(visible.some((item) => item.id === 'form-collect'), false); +}); + +test('filterChatSkills shows generate-page when publish is allowed', () => { + const visible = filterChatSkills(CHAT_SKILL_DEFINITIONS, { canPublish: true }); + assert.ok(visible.some((item) => item.id === 'generate-page')); +}); + +test('buildChatSkillPrompt includes skill name for platform skills', () => { + assert.match(buildChatSkillPrompt('web', 'web'), /请使用 web 技能/); + assert.match(buildChatSkillPrompt('generate-page'), /static-page-publish/); +}); + +test('prefillOnly is set for open-ended chat skills', () => { + assert.equal(CHAT_SKILL_DEFINITIONS.find((item) => item.id === 'web-search')?.prefillOnly, true); + assert.equal(CHAT_SKILL_DEFINITIONS.find((item) => item.id === 'generate-page')?.prefillOnly, undefined); +}); diff --git a/deploy/plaza-105/goose-plaza-web.service b/deploy/plaza-105/goose-plaza-web.service new file mode 100644 index 0000000..faa86de --- /dev/null +++ b/deploy/plaza-105/goose-plaza-web.service @@ -0,0 +1,20 @@ +[Unit] +Description=Plaza Next.js (plaza.tkmind.cn) +After=network.target goose-h5.service +Wants=goose-h5.service + +[Service] +Type=simple +User=root +WorkingDirectory=/root/plaza/web +EnvironmentFile=-/root/plaza/web/.env +Environment=NODE_ENV=production +Environment=PORT=3002 +Environment=PLAZA_API_PROXY=http://127.0.0.1:8080 +Environment=PLAZA_API_BASE=http://127.0.0.1:8080 +ExecStart=/usr/bin/node node_modules/next/dist/bin/next start -p 3002 +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/plaza-105/plaza-105.env.example b/deploy/plaza-105/plaza-105.env.example new file mode 100644 index 0000000..fec2584 --- /dev/null +++ b/deploy/plaza-105/plaza-105.env.example @@ -0,0 +1,15 @@ +# Plaza 独立部署(105)— 复制为 deploy/plaza-105/plaza-105.env +PLAZA_DEPLOY_HOST=root@120.26.184.105 +PLAZA_REMOTE_DIR=/root/plaza/web +PLAZA_PROD_URL=https://plaza.tkmind.cn +PLAZA_PORT=3002 +PLAZA_SYSTEMD_SERVICE=goose-plaza-web + +# Plaza API / 登录 / 发布页仍由 H5 提供(同机 :8080) +PLAZA_API_UPSTREAM=http://127.0.0.1:8080 +H5_SYSTEMD_SERVICE=goose-h5 + +# 本地 Next.js 源码(默认 sibling 仓库) +# PLAZA_APP_DIR=/Users/john/Project/tkmind_go/ui/plaza + +# DNS:plaza.tkmind.cn A → 120.26.184.105 diff --git a/deploy/plaza-105/plaza.tkmind.cn.bootstrap.nginx.conf b/deploy/plaza-105/plaza.tkmind.cn.bootstrap.nginx.conf new file mode 100644 index 0000000..9ef6a86 --- /dev/null +++ b/deploy/plaza-105/plaza.tkmind.cn.bootstrap.nginx.conf @@ -0,0 +1,67 @@ +# Plaza bootstrap(首次部署、尚未签发证书) +# 仅 HTTP :80,用于 certbot webroot + +upstream goose_plaza_web_bootstrap { + server 127.0.0.1:3002; + keepalive 8; +} + +upstream goose_plaza_api_bootstrap { + server 127.0.0.1:8080; + keepalive 8; +} + +server { + listen 80; + listen [::]:80; + server_name plaza.tkmind.cn; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location ^~ /api/ { + proxy_pass http://goose_plaza_api_bootstrap; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120; + proxy_buffering off; + } + + location ^~ /auth/ { + proxy_pass http://goose_plaza_api_bootstrap; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + } + + location ^~ /u/ { + proxy_pass http://goose_plaza_api_bootstrap; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120; + proxy_buffering off; + } + + location / { + proxy_pass http://goose_plaza_web_bootstrap; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 120; + proxy_buffering off; + } +} diff --git a/deploy/plaza-105/plaza.tkmind.cn.nginx.conf b/deploy/plaza-105/plaza.tkmind.cn.nginx.conf new file mode 100644 index 0000000..031df0d --- /dev/null +++ b/deploy/plaza-105/plaza.tkmind.cn.nginx.conf @@ -0,0 +1,85 @@ +# Plaza 独立入口(plaza.tkmind.cn) +# 安装:/etc/nginx/conf.d/plaza.tkmind.cn.conf +# +# /plaza、/_next → goose-plaza-web :3002 +# /api、/auth、/u → goose-h5 :8080(Plaza API + 登录 + 发布页嵌入) + +upstream goose_plaza_web { + server 127.0.0.1:3002; + keepalive 16; +} + +upstream goose_plaza_api { + server 127.0.0.1:8080; + keepalive 16; +} + +server { + listen 80; + listen [::]:80; + server_name plaza.tkmind.cn; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name plaza.tkmind.cn; + + ssl_certificate /etc/letsencrypt/live/plaza.tkmind.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/plaza.tkmind.cn/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location ^~ /api/ { + proxy_pass http://goose_plaza_api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120; + proxy_buffering off; + } + + location ^~ /auth/ { + proxy_pass http://goose_plaza_api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + } + + location ^~ /u/ { + proxy_pass http://goose_plaza_api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120; + proxy_buffering off; + } + + location / { + proxy_pass http://goose_plaza_web; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 120; + proxy_buffering off; + } +} diff --git a/mindspace-agent-runner.mjs b/mindspace-agent-runner.mjs index 853c6b4..05fbd12 100644 --- a/mindspace-agent-runner.mjs +++ b/mindspace-agent-runner.mjs @@ -336,6 +336,7 @@ export function createMindSpaceAgentRunner({ const workingDir = await userAuth.resolveWorkingDir(claim.userId); const sessionPolicy = await userAuth.getAgentSessionPolicy(claim.userId); + const publishLayout = await userAuth.getUserPublishLayout(claim.userId); const startSession = await readJsonResponse( await apiFetch('/agent/start', { method: 'POST', @@ -359,7 +360,15 @@ export function createMindSpaceAgentRunner({ { workingDir, sessionPolicy, - sandboxConstraints: null, + sandboxConstraints: publishLayout?.constraints ?? null, + userContext: publishLayout + ? { + userId: claim.userId, + displayName: publishLayout.displayName, + username: publishLayout.username, + slug: publishLayout.slug, + } + : null, }, ); diff --git a/mindspace-agent-runner.test.mjs b/mindspace-agent-runner.test.mjs index d33af56..5db8a73 100644 --- a/mindspace-agent-runner.test.mjs +++ b/mindspace-agent-runner.test.mjs @@ -87,6 +87,9 @@ test('runner claims job, executes reply, bills usage, and completes job', async async getAgentSessionPolicy() { return { enableContextMemory: false, unrestricted: true }; }, + async getUserPublishLayout() { + return null; + }, async registerAgentSession(userId, sessionId) { calls.push(['registerAgentSession', userId, sessionId]); }, diff --git a/mindspace-chat-context.mjs b/mindspace-chat-context.mjs index 66491f0..623be9c 100644 --- a/mindspace-chat-context.mjs +++ b/mindspace-chat-context.mjs @@ -1,3 +1,5 @@ +import { buildMindSpacePageSaveInstructions } from './mindspace-page-patch.mjs'; + const VIEW_LABELS = { home: '空间首页', category: '分类列表', @@ -72,6 +74,10 @@ export function buildMindSpaceChatContext(input) { assets = [], focusedAsset = null, route, + agentSessionId = null, + h5ApiBase = null, + pageEditMode = false, + parentAgentSessionId = null, } = input; const pageRecord = resolvePageRecord(selectedPageId, pages, pageLive); @@ -87,6 +93,10 @@ export function buildMindSpaceChatContext(input) { view, route, ...(ownerUsername ? { ownerUsername } : {}), + ...(agentSessionId ? { agentSessionId } : {}), + ...(h5ApiBase ? { h5ApiBase } : {}), + ...(pageEditMode ? { pageEditMode: true } : {}), + ...(parentAgentSessionId ? { parentAgentSessionId } : {}), ...(category ? { category: { @@ -169,11 +179,30 @@ function buildLocationHint(context) { return '用户在 MindSpace 空间首页。指代「我的空间」时指整个个人空间。'; } -export function buildContextPrefix(context) { +export function buildPageEditSubAgentInstructions(context) { + const pageTitle = context.page?.title ?? '当前页面'; const lines = [ + '【页面编辑子 Agent(硬性)】', + `- 你是专注修改页面「${pageTitle}」的子 Agent,用户正在全屏预览中编辑。`, + '- 只做页面内容/标题/样式修改,不要跑题,不要 delegate / load_skill / 改其它文件。', + '- 每次完成一处修改后必须让预览立刻更新(见下方页面实时修改说明)。', + ]; + if (context.parentAgentSessionId) { + lines.push(`- 父对话 session:${context.parentAgentSessionId}(退出预览后摘要会合并回父对话记忆)。`); + } + lines.push(''); + return lines.join('\n'); +} + +export function buildContextPrefix(context) { + const lines = []; + if (context.pageEditMode) { + lines.push(...buildPageEditSubAgentInstructions(context).split('\n')); + } + lines.push( '[MindSpace 上下文]', `- 空间:${context.spaceName}(id: ${context.spaceId})`, - ]; + ); if (context.ownerUsername) { lines.push(`- 账号:${context.ownerUsername}`); @@ -228,6 +257,10 @@ export function buildContextPrefix(context) { lines.push(context.page.contentExcerpt); lines.push('"""'); } + lines.push(...buildMindSpacePageSaveInstructions(context.page, { + sessionId: context.agentSessionId, + h5ApiBase: context.h5ApiBase, + })); } if (context.focusedAsset) { @@ -261,6 +294,11 @@ export function buildContextPrefix(context) { } export function formatContextChip(context) { + if (context.pageEditMode && context.page) { + const version = + typeof context.page.versionNo === 'number' ? ` · v${context.page.versionNo}` : ''; + return `编辑 · ${context.page.title}${version}`; + } if (context.page) { const version = typeof context.page.versionNo === 'number' ? ` · v${context.page.versionNo}` : ''; diff --git a/mindspace-cleanup.mjs b/mindspace-cleanup.mjs index f894680..54f7e24 100644 --- a/mindspace-cleanup.mjs +++ b/mindspace-cleanup.mjs @@ -2,7 +2,13 @@ import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; -const WORKSPACE_TEMP_SKIP = new Set(['.tkmindhints', '.goosehints', '.agents', '.goose']); +const WORKSPACE_TEMP_SKIP = new Set([ + '.tkmindhints', + '.goosehints', + '.tkmind-profile.json', + '.agents', + '.goose', +]); function candidateId(kind, key) { return `${kind}:${crypto.createHash('sha256').update(key).digest('hex').slice(0, 24)}`; @@ -120,6 +126,39 @@ export function createCleanupService(pool, options = {}) { }); }); + const [staleVersions] = await pool.query( + `SELECT pv.id AS version_id, pv.page_id, pv.version_no, pv.content_asset_id, pv.bundle_asset_id, + p.title, a.size_bytes, a.display_name, pv.created_at, + COALESCE(bundle.size_bytes, 0) AS bundle_size_bytes + FROM h5_page_versions pv + JOIN h5_page_records p ON p.id = pv.page_id AND p.user_id = ? AND p.status <> 'deleted' + JOIN h5_assets a ON a.id = pv.content_asset_id AND a.user_id = ? AND a.status <> 'deleted' + LEFT JOIN h5_assets bundle + ON bundle.id = pv.bundle_asset_id AND bundle.user_id = ? AND bundle.status <> 'deleted' + WHERE pv.id <> p.current_version_id + AND NOT EXISTS ( + SELECT 1 FROM h5_publish_records pr + WHERE pr.page_version_id = pv.id AND pr.user_id = ? + ) + ORDER BY pv.created_at ASC + LIMIT 500`, + [userId, userId, userId, userId], + ); + for (const row of staleVersions) { + const contentBytes = Number(row.size_bytes ?? 0); + const bundleBytes = Number(row.bundle_size_bytes ?? 0); + candidates.push({ + id: candidateId('page_version', row.version_id), + kind: 'stale_page_version', + label: `${row.title} · v${row.version_no}`, + path: `page/${row.page_id}/version/${row.version_no}`, + sizeBytes: contentBytes + bundleBytes, + createdAt: Number(row.created_at), + detail: '页面历史版本(非当前版本)', + refId: row.version_id, + }); + } + return candidates; }; @@ -191,6 +230,84 @@ export function createCleanupService(pool, options = {}) { await fs.rm(resolved, { force: true }); freedBytes += sizeBytes; removedCount += 1; + continue; + } + + if (item.kind === 'stale_page_version') { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [rows] = await conn.query( + `SELECT pv.id, pv.page_id, pv.content_asset_id, pv.bundle_asset_id, + p.current_version_id, p.space_id + FROM h5_page_versions pv + JOIN h5_page_records p ON p.id = pv.page_id + WHERE pv.id = ? AND p.user_id = ? + LIMIT 1 FOR UPDATE`, + [item.refId, userId], + ); + const version = rows[0]; + if (!version || version.id === version.current_version_id) { + await conn.rollback(); + continue; + } + const [pubRows] = await conn.query( + `SELECT id FROM h5_publish_records WHERE page_version_id = ? AND user_id = ? LIMIT 1`, + [version.id, userId], + ); + if (pubRows.length) { + await conn.rollback(); + continue; + } + + const now = Date.now(); + let freed = 0; + const assetsToDelete = [version.content_asset_id].filter(Boolean); + if (version.bundle_asset_id) { + const [bundleUse] = await conn.query( + `SELECT COUNT(*) AS cnt FROM h5_page_versions + WHERE bundle_asset_id = ? AND id <> ?`, + [version.bundle_asset_id, version.id], + ); + if (Number(bundleUse[0]?.cnt ?? 0) === 0) { + assetsToDelete.push(version.bundle_asset_id); + } + } + + for (const assetId of assetsToDelete) { + const [assetRows] = await conn.query( + `SELECT id, size_bytes FROM h5_assets + WHERE id = ? AND user_id = ? AND status <> 'deleted' + LIMIT 1 FOR UPDATE`, + [assetId, userId], + ); + const asset = assetRows[0]; + if (!asset) continue; + await conn.query( + `UPDATE h5_assets SET status = 'deleted', deleted_at = ?, updated_at = ? + WHERE id = ? AND user_id = ?`, + [now, now, assetId, userId], + ); + freed += Number(asset.size_bytes ?? 0); + } + + await conn.query(`DELETE FROM h5_page_versions WHERE id = ?`, [version.id]); + if (freed > 0) { + await conn.query( + `UPDATE h5_user_spaces + SET used_bytes = GREATEST(0, used_bytes - ?), updated_at = ? + WHERE id = ? AND user_id = ?`, + [freed, now, version.space_id, userId], + ); + } + await conn.commit(); + freedBytes += freed; + removedCount += 1; + } catch { + await conn.rollback(); + } finally { + conn.release(); + } } } diff --git a/mindspace-cleanup.test.mjs b/mindspace-cleanup.test.mjs index 3af7226..5da847f 100644 --- a/mindspace-cleanup.test.mjs +++ b/mindspace-cleanup.test.mjs @@ -22,3 +22,37 @@ test('lists workspace temp files for cleanup', async () => { assert.equal(items[0].kind, 'workspace_temp'); assert.equal(items[0].label, 'draft.html'); }); + +test('lists stale page versions for cleanup', async () => { + const pool = { + query: async (sql, params) => { + if (sql.includes('FROM h5_upload_sessions')) return [[], []]; + if (sql.includes('FROM h5_page_versions pv')) { + return [ + [ + { + version_id: 'ver-old', + page_id: 'page-1', + version_no: 2, + content_asset_id: 'asset-old', + bundle_asset_id: null, + title: 'Demo Page', + size_bytes: 4096, + display_name: 'Demo Page · v2', + created_at: 1000, + bundle_size_bytes: 0, + }, + ], + [], + ]; + } + return [[], []]; + }, + }; + const cleanup = createCleanupService(pool, { h5Root: os.tmpdir(), storageRoot: os.tmpdir() }); + const items = await cleanup.listCandidates('user-1', 'john'); + const stale = items.filter((item) => item.kind === 'stale_page_version'); + assert.equal(stale.length, 1); + assert.equal(stale[0].sizeBytes, 4096); + assert.match(stale[0].label, /Demo Page · v2/); +}); diff --git a/mindspace-cover-ai.mjs b/mindspace-cover-ai.mjs index 6d9ea17..b1d1c72 100644 --- a/mindspace-cover-ai.mjs +++ b/mindspace-cover-ai.mjs @@ -96,14 +96,19 @@ export async function suggestCoverMetaWithAi( throw Object.assign(new Error('请先在管理后台配置并启用 LLM'), { code: 'llm_not_configured' }); } - const apiKey = decryptSecret( - { - ciphertext: row.api_key_ciphertext, - iv: row.api_key_iv, - tag: row.api_key_tag, - }, - resolveEncryptionKey(encryptionKey), - ); + 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: 'llm_not_configured' }); + } const apiUrl = normalizeApiUrl(row.api_url); const url = resolveChatCompletionsUrl(apiUrl); if (!url) { @@ -118,20 +123,28 @@ export async function suggestCoverMetaWithAi( currentCover: parseMindspaceCoverMeta(html), }); - const upstream = await undiciFetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model: row.default_model, - messages: [{ role: 'user', content: prompt }], - stream: false, - ...(row.relay_provider ? { provider: row.relay_provider } : {}), - }), - dispatcher: url.startsWith('https://') ? insecureDispatcher : undefined, - }); + let upstream; + try { + upstream = await undiciFetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: row.default_model, + messages: [{ role: 'user', content: prompt }], + stream: false, + ...(row.relay_provider ? { provider: row.relay_provider } : {}), + }), + dispatcher: url.startsWith('https://') ? insecureDispatcher : undefined, + }); + } catch (error) { + throw Object.assign( + new Error(`AI 封面生成失败:${error instanceof Error ? error.message : '网络错误'}`), + { code: 'cover_ai_failed' }, + ); + } const text = await upstream.text().catch(() => ''); if (!upstream.ok) { diff --git a/mindspace-page-edit-session.mjs b/mindspace-page-edit-session.mjs new file mode 100644 index 0000000..73adbbe --- /dev/null +++ b/mindspace-page-edit-session.mjs @@ -0,0 +1,269 @@ +import { Agent, fetch as undiciFetch } from 'undici'; +import { buildPageEditAgentPolicy } from './capabilities.mjs'; +import { buildPageEditSubAgentInstructions } from './mindspace-chat-context.mjs'; +import { buildMindSpacePageSaveInstructions } from './mindspace-page-patch.mjs'; +import { reconcileAgentSession } from './session-reconcile.mjs'; + +const insecureDispatcher = new Agent({ + connect: { rejectUnauthorized: false }, +}); + +function isHttpsTarget(target) { + return target.startsWith('https://'); +} + +function createApiFetch(apiTarget, apiSecret) { + 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 readJson(upstream) { + const text = await upstream.text(); + if (!upstream.ok) { + throw Object.assign(new Error(text || `upstream ${upstream.status}`), { + code: 'worker_unavailable', + }); + } + return text ? JSON.parse(text) : null; +} + +function buildPageEditSeedContent({ page, parentSessionId, h5ApiBase, sessionId }) { + const context = { + spaceId: 'mindspace', + spaceName: 'MindSpace', + view: 'page', + route: `/mindspace/page/${page.id}/preview`, + pageEditMode: true, + parentAgentSessionId: parentSessionId, + agentSessionId: sessionId, + h5ApiBase, + page: { + id: page.id, + title: page.title, + summary: page.summary ?? '', + status: page.status, + versionNo: page.versionNo, + templateId: page.templateId, + contentFormat: page.contentFormat ?? 'html', + }, + }; + + const lines = [ + ...buildPageEditSubAgentInstructions(context).split('\n'), + '[MindSpace 页面编辑子会话]', + `- 页面:${page.title}(id: ${page.id})`, + `- 内容格式:${page.contentFormat ?? 'html'}`, + `- 当前版本:v${page.versionNo ?? 1}`, + ...buildMindSpacePageSaveInstructions( + { + id: page.id, + contentFormat: page.contentFormat ?? 'html', + }, + { sessionId, h5ApiBase }, + ), + '请用一句话确认你已准备好修改此页面,并等待用户指令。', + ]; + return lines.join('\n'); +} + +export function buildPageEditSessionConstraints(pageTitle) { + return [ + '【页面编辑子 Agent 沙箱】', + `- 当前任务:仅修改 MindSpace 页面「${pageTitle}」`, + '- 禁止读写工作区其它文件;禁止 delegate / load_skill / 发布新页面', + '- 改页面优先 curl 调用 mindspace_page_patch;若无 shell 权限则在回复末尾输出 ```mindspace-page-update``` 块', + '- 每次改动后立即触发预览更新,不要只口头说「已修改」', + ].join('\n'); +} + +export function createPageEditSessionService({ + apiTarget, + apiSecret, + userAuth, + pageService, + pageLiveEdit, + llmProviderService, +}) { + const apiFetch = createApiFetch(apiTarget, apiSecret); + + const applySessionLlmProvider = async (sessionId) => { + if (!llmProviderService || !sessionId) return null; + try { + return await llmProviderService.applyBestProviderForSession(sessionId); + } catch { + return null; + } + }; + + return { + async forkSession({ userId, pageId, parentSessionId, h5ApiBase = null }) { + if (!userId || !pageId || !parentSessionId) { + throw Object.assign(new Error('缺少 fork 参数'), { code: 'invalid_request' }); + } + + const ownsParent = await userAuth.ownsSession(userId, parentSessionId); + if (!ownsParent) { + throw Object.assign(new Error('无权使用父会话'), { code: 'forbidden' }); + } + + const page = await pageService.getPage(userId, pageId); + if (page.contentFormat !== 'html') { + throw Object.assign(new Error('仅 HTML 页面支持编辑子会话'), { code: 'invalid_request' }); + } + + const gate = await userAuth.canUseChat(userId); + if (!gate.ok) { + throw Object.assign(new Error(gate.message || '当前用户无法使用 Agent'), { code: 'forbidden' }); + } + + const workingDir = await userAuth.resolveWorkingDir(userId); + const basePolicy = await userAuth.getAgentSessionPolicy(userId); + const sessionPolicy = buildPageEditAgentPolicy(basePolicy); + const publishLayout = await userAuth.getUserPublishLayout(userId); + + const startSession = await readJson( + await apiFetch('/agent/start', { + method: 'POST', + body: JSON.stringify({ + working_dir: workingDir, + enable_context_memory: sessionPolicy.enableContextMemory, + ...(sessionPolicy.extensionOverrides + ? { extension_overrides: sessionPolicy.extensionOverrides } + : {}), + }), + }), + ); + + const sessionId = startSession?.id; + if (!sessionId) { + throw Object.assign(new Error('Agent 会话启动失败'), { code: 'worker_unavailable' }); + } + + await userAuth.registerAgentSession(userId, sessionId); + + if (sessionPolicy.gooseMode) { + await readJson( + await apiFetch('/agent/update_session', { + method: 'POST', + body: JSON.stringify({ + session_id: sessionId, + goose_mode: sessionPolicy.gooseMode, + }), + }), + ); + } + + const sandboxConstraints = [ + publishLayout?.constraints ?? '', + buildPageEditSessionConstraints(page.title), + ] + .filter(Boolean) + .join('\n\n'); + + await reconcileAgentSession(apiFetch, sessionId, { + workingDir, + sessionPolicy, + sandboxConstraints, + userContext: publishLayout + ? { + userId, + displayName: publishLayout.displayName, + username: publishLayout.username, + slug: publishLayout.slug, + } + : null, + }); + + await applySessionLlmProvider(sessionId); + + await pageLiveEdit.bindSession({ + userId, + sessionId, + pageId, + parentSessionId, + }); + + const seed = buildPageEditSeedContent({ + page, + parentSessionId, + h5ApiBase, + sessionId, + }); + + await apiFetch('/agent/harness_remember', { + method: 'POST', + body: JSON.stringify({ + sessionId, + title: `页面编辑 · ${page.title}`, + content: seed, + }), + }); + + await apiFetch('/agent/harness_bootstrap', { + method: 'POST', + body: JSON.stringify({ sessionId, force: true }), + }); + + return { + sessionId, + pageId, + parentSessionId, + }; + }, + + async closeSession({ userId, sessionId, pageId, parentSessionId, summary = '' }) { + if (!userId || !sessionId || !pageId) { + throw Object.assign(new Error('缺少 close 参数'), { code: 'invalid_request' }); + } + + const owns = await userAuth.ownsSession(userId, sessionId); + if (!owns) { + throw Object.assign(new Error('无权关闭该子会话'), { code: 'forbidden' }); + } + + const binding = pageLiveEdit.getBinding(sessionId); + if (binding && binding.pageId !== String(pageId)) { + throw Object.assign(new Error('子会话未绑定该页面'), { code: 'page_binding_mismatch' }); + } + + pageLiveEdit.unbindSession(sessionId); + + const trimmedSummary = String(summary ?? '').trim(); + const parentId = String(parentSessionId ?? binding?.parentSessionId ?? '').trim(); + if (trimmedSummary && parentId) { + const ownsParent = await userAuth.ownsSession(userId, parentId); + if (ownsParent) { + const page = await pageService.getPage(userId, pageId).catch(() => null); + await apiFetch('/agent/harness_remember', { + method: 'POST', + body: JSON.stringify({ + sessionId: parentId, + title: `页面编辑摘要 · ${page?.title ?? pageId}`, + content: trimmedSummary, + }), + }); + } + } + + return { sessionId, pageId, merged: Boolean(trimmedSummary && parentId) }; + }, + }; +} + +export const pageEditSessionInternals = { + buildPageEditSeedContent, + buildPageEditSessionConstraints, +}; diff --git a/mindspace-page-live-edit.mjs b/mindspace-page-live-edit.mjs new file mode 100644 index 0000000..e26d018 --- /dev/null +++ b/mindspace-page-live-edit.mjs @@ -0,0 +1,104 @@ +import { mergeMindSpacePagePatch, normalizeMindSpacePagePatchInput } from './mindspace-page-patch.mjs'; + +export function createPageLiveEditService({ pageService, resolveUserIdForAgentSession }) { + /** @type {Map} */ + const sessionBindings = new Map(); + /** @type {Map} */ + const liveRevisions = new Map(); + + const bumpRevision = (pageId) => { + liveRevisions.set(pageId, (liveRevisions.get(pageId) ?? 0) + 1); + }; + + return { + bindSession({ userId, sessionId, pageId, parentSessionId = null }) { + if (!userId || !sessionId || !pageId) { + throw Object.assign(new Error('缺少绑定参数'), { code: 'invalid_request' }); + } + sessionBindings.set(String(sessionId), { + userId: String(userId), + pageId: String(pageId), + parentSessionId: parentSessionId ? String(parentSessionId) : null, + boundAt: Date.now(), + }); + return { + sessionId: String(sessionId), + pageId: String(pageId), + ...(parentSessionId ? { parentSessionId: String(parentSessionId) } : {}), + }; + }, + + unbindSession(sessionId) { + sessionBindings.delete(String(sessionId)); + }, + + getBinding(sessionId) { + return sessionBindings.get(String(sessionId)) ?? null; + }, + + getLiveRevision(pageId) { + return liveRevisions.get(String(pageId)) ?? 0; + }, + + async applyAgentPatch(input) { + const sessionId = String(input?.sessionId ?? input?.session_id ?? '').trim(); + const pageId = String(input?.pageId ?? input?.page_id ?? '').trim(); + if (!sessionId || !pageId) { + throw Object.assign(new Error('缺少 session_id 或 page_id'), { code: 'invalid_request' }); + } + + const userId = await resolveUserIdForAgentSession(sessionId); + if (!userId) { + throw Object.assign(new Error('无效的 Agent 会话'), { code: 'forbidden' }); + } + + const binding = sessionBindings.get(sessionId); + if (binding && binding.pageId !== pageId) { + throw Object.assign(new Error('当前会话未绑定该页面'), { code: 'page_binding_mismatch' }); + } + if (binding && binding.userId !== userId) { + throw Object.assign(new Error('会话与用户不匹配'), { code: 'forbidden' }); + } + + const patch = normalizeMindSpacePagePatchInput(input); + if (!patch) { + throw Object.assign(new Error('缺少 title / summary / content 变更'), { code: 'invalid_request' }); + } + + const page = await pageService.getPage(userId, pageId); + const merged = mergeMindSpacePagePatch( + { + title: page.title, + summary: page.summary ?? '', + content: page.content ?? '', + }, + patch, + ); + + const updated = await pageService.updatePage(userId, pageId, { + expectedVersion: input?.expectedVersion ?? input?.expected_version ?? page.versionNo, + title: merged.title, + summary: merged.summary, + content: merged.content, + templateId: page.templateId, + changeNote: String(input?.changeNote ?? input?.change_note ?? 'Agent 对话修改').slice(0, 255), + }); + + bumpRevision(pageId); + return { + page: updated, + liveRevision: liveRevisions.get(pageId) ?? 0, + }; + }, + + async getRevisionSnapshot(userId, pageId) { + const page = await pageService.getPage(userId, pageId); + return { + pageId: page.id, + versionNo: page.versionNo, + updatedAt: page.updatedAt, + liveRevision: liveRevisions.get(pageId) ?? 0, + }; + }, + }; +} diff --git a/mindspace-page-patch.mjs b/mindspace-page-patch.mjs new file mode 100644 index 0000000..c0c1188 --- /dev/null +++ b/mindspace-page-patch.mjs @@ -0,0 +1,82 @@ +const PATCH_BLOCK_RE = /```mindspace-page-update\s*\n([\s\S]*?)```/i; + +export function extractMindSpacePagePatch(text) { + const match = String(text ?? '').match(PATCH_BLOCK_RE); + if (!match) return null; + const raw = match[1].trim(); + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const patch = {}; + if (typeof parsed.title === 'string') patch.title = parsed.title; + if (typeof parsed.summary === 'string') patch.summary = parsed.summary; + if (typeof parsed.content === 'string') patch.content = parsed.content; + return Object.keys(patch).length > 0 ? patch : null; + } catch { + return null; + } +} + +export function mergeMindSpacePagePatch(base, patch) { + return { + title: patch.title ?? base.title, + summary: patch.summary ?? base.summary, + content: patch.content ?? base.content, + }; +} + +export function normalizeMindSpacePagePatchInput(input = {}) { + const patch = {}; + if (typeof input.title === 'string') patch.title = input.title; + if (typeof input.summary === 'string') patch.summary = input.summary; + if (typeof input.content === 'string') patch.content = input.content; + return Object.keys(patch).length > 0 ? patch : null; +} + +export function buildMindSpacePageSaveInstructions(page, options = {}) { + if (!page?.id) return []; + const h5ApiBase = String(options.h5ApiBase ?? '').replace(/\/$/, ''); + const sessionId = String(options.sessionId ?? '').trim(); + const lines = [ + '【页面实时修改(硬性)】', + `用户要求修改当前页面(id: ${page.id})时,必须让界面预览立刻更新。优先使用 API,其次使用补丁代码块。`, + ]; + + if (h5ApiBase && sessionId) { + lines.push( + '1. **优先** 每次完成一处修改后,立即用 shell 调用(可多次调用,改一点调一次):', + '```bash', + `curl -sS -X POST '${h5ApiBase}/api/agent/mindspace_page_patch' \\`, + " -H 'Content-Type: application/json' \\", + ` -d '{"session_id":"${sessionId}","page_id":"${page.id}","title":"修改后的标题"}'`, + '```', + '- 只传实际变更字段:`title` / `summary` / `content`', + '- HTML 页面改标题或正文时,`content` 传完整 HTML', + '- 禁止只改工作区里的其它 html 文件而不调此 API', + ); + } + + lines.push( + '2. **备用** 若 curl 失败,在回复末尾输出:', + '```mindspace-page-update', + JSON.stringify( + { + title: '修改后的标题(如有变更)', + summary: '修改后的摘要(如有变更)', + content: + page.contentFormat === 'html' + ? '' + : '修改后的正文(如有变更)', + }, + null, + 2, + ), + '```', + '- 只填写实际变更的字段', + '- 禁止只口头说「已修改」而不调用 API 或输出补丁块', + '', + ); + + return lines; +} diff --git a/mindspace-page-patch.test.mjs b/mindspace-page-patch.test.mjs new file mode 100644 index 0000000..cec50a7 --- /dev/null +++ b/mindspace-page-patch.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildMindSpacePageSaveInstructions, + extractMindSpacePagePatch, + mergeMindSpacePagePatch, +} from './mindspace-page-patch.mjs'; + +test('extractMindSpacePagePatch parses assistant update block', () => { + const text = `好的,标题已更新。 + +\`\`\`mindspace-page-update +{"title":"新标题","summary":"新摘要"} +\`\`\``; + assert.deepEqual(extractMindSpacePagePatch(text), { + title: '新标题', + summary: '新摘要', + }); +}); + +test('mergeMindSpacePagePatch keeps unchanged fields', () => { + assert.deepEqual( + mergeMindSpacePagePatch( + { title: '旧标题', summary: '旧摘要', content: '正文' }, + { title: '新标题' }, + ), + { title: '新标题', summary: '旧摘要', content: '正文' }, + ); +}); + +test('buildMindSpacePageSaveInstructions mentions page id and block format', () => { + const lines = buildMindSpacePageSaveInstructions({ + id: 'page-1', + contentFormat: 'markdown', + }); + assert.match(lines.join('\n'), /page-1/); + assert.match(lines.join('\n'), /mindspace-page-update/); +}); diff --git a/package.json b/package.json index ade0f18..c5748fa 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,9 @@ "dev:plaza": "node scripts/dev-plaza.mjs", "dev:plaza-proxy": "node scripts/plaza-local-proxy.mjs", "setup:plaza-dns": "node scripts/setup-plaza-local-dns.mjs", + "dev:plaza-dns": "node scripts/plaza-local-dns-server.mjs", + "open:plaza": "node scripts/open-plaza-local.mjs", + "check:plaza": "node scripts/check-plaza-local.mjs", "setup:plaza-local": "node scripts/setup-plaza-local.mjs", "setup:plaza-tls": "node scripts/plaza-local-tls.mjs", "dev:vite": "vite", @@ -19,7 +22,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 billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs capabilities.test.mjs policies.test.mjs user-publish.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-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", + "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-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", "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", @@ -31,7 +34,8 @@ "preview": "node server.mjs", "start": "pnpm run build && node server.mjs", "deploy:prod": "bash ../../deploy/deploy-h5-prod.sh", - "deploy:105": "bash ../../rsync_to_server.sh h5" + "deploy:105": "bash scripts/sync-to-105.sh", + "deploy:plaza-105": "bash scripts/deploy-plaza-105.sh" }, "dependencies": { "debug": "^4.4.3", diff --git a/policies.mjs b/policies.mjs index b028ec6..78f00a5 100644 --- a/policies.mjs +++ b/policies.mjs @@ -166,6 +166,7 @@ const USER_API_ALLOWLIST = [ { method: 'POST', pattern: /^\/agent\/resume$/ }, { method: 'GET', pattern: /^\/sessions$/ }, { method: 'GET', pattern: /^\/sessions\/[^/]+$/ }, + { method: 'DELETE', pattern: /^\/sessions\/[^/]+$/ }, { method: 'GET', pattern: /^\/sessions\/[^/]+\/events$/ }, { method: 'POST', pattern: /^\/sessions\/[^/]+\/reply$/ }, { method: 'POST', pattern: /^\/sessions\/[^/]+\/cancel$/ }, diff --git a/policies.test.mjs b/policies.test.mjs index d2c4d28..a983abd 100644 --- a/policies.test.mjs +++ b/policies.test.mjs @@ -55,6 +55,11 @@ test('api lockdown blocks extension admin routes', () => { api_lockdown: true, }); assert.equal(allowed.allowed, true); + + const deleteAllowed = evaluateProxyRequest('DELETE', '/sessions/abc', { + api_lockdown: true, + }); + assert.equal(deleteAllowed.allowed, true); }); test('api lockdown blocks native H5 APIs from agent proxy fallback', () => { @@ -78,13 +83,22 @@ test('resolvePolicies maps legacy approve modes to auto', () => { }); test('resolveAgentGooseMode uses chat when no tools are enabled', () => { - assert.equal( - resolveAgentGooseMode(DEFAULT_USER_CAPABILITIES, { goose_mode: 'auto' }), - 'chat', - ); + const chatOnly = { + ...DEFAULT_USER_CAPABILITIES, + skills: false, + chat_recall: false, + memory_store: false, + context_memory: false, + image_read: false, + }; + assert.equal(resolveAgentGooseMode(chatOnly, { goose_mode: 'auto' }), 'chat'); }); test('resolveAgentGooseMode auto-runs tools without end-user prompts', () => { + assert.equal( + resolveAgentGooseMode(DEFAULT_USER_CAPABILITIES, { goose_mode: 'chat' }), + 'auto', + ); assert.equal( resolveAgentGooseMode( { ...DEFAULT_USER_CAPABILITIES, static_publish: true }, diff --git a/scripts/.rsync-exclude-105 b/scripts/.rsync-exclude-105 new file mode 100644 index 0000000..30f1c4f --- /dev/null +++ b/scripts/.rsync-exclude-105 @@ -0,0 +1,11 @@ +node_modules +dist +.env +*.log +MindSpace +data/mindspace +data/mindspace.bak* +temp +.git +.DS_Store +users diff --git a/scripts/.rsync-exclude-plaza-web b/scripts/.rsync-exclude-plaza-web new file mode 100644 index 0000000..659a21b --- /dev/null +++ b/scripts/.rsync-exclude-plaza-web @@ -0,0 +1,6 @@ +node_modules/ +.next/ +.env +.env.local +.git/ +*.log diff --git a/scripts/check-plaza-local.mjs b/scripts/check-plaza-local.mjs new file mode 100644 index 0000000..96796f5 --- /dev/null +++ b/scripts/check-plaza-local.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/** + * Diagnose local Plaza setup and print fix hints. + */ +import fs from 'node:fs'; +import net from 'node:net'; +import dns from 'node:dns'; +import { promisify } from 'node:util'; +import https from 'node:https'; + +const host = process.env.PLAZA_LOCAL_HOST ?? 'plaza.tkmind.cn'; +const dnsPort = Number(process.env.PLAZA_LOCAL_DNS_PORT ?? 5533); +const plazaPort = Number(process.env.PLAZA_PORT ?? 3001); +const portalPort = Number(process.env.H5_PORT ?? 8081); + +const lookup = promisify(dns.lookup); +const resolve4 = promisify(dns.resolve4); + +function tcpOpen(port, bind = '127.0.0.1') { + return new Promise((resolve) => { + const s = net.connect(port, bind, () => { + s.end(); + resolve(true); + }); + s.on('error', () => resolve(false)); + s.setTimeout(2000, () => { + s.destroy(); + resolve(false); + }); + }); +} + +function httpsPlaza() { + return new Promise((resolve) => { + const req = https.request( + { + hostname: host, + port: 443, + path: '/plaza', + rejectUnauthorized: false, + timeout: 3000, + }, + (res) => resolve({ ok: res.statusCode === 200, code: res.statusCode }), + ); + req.on('error', (err) => resolve({ ok: false, error: err.message })); + req.on('timeout', () => { + req.destroy(); + resolve({ ok: false, error: 'timeout' }); + }); + req.end(); + }); +} + +const issues = []; +const ok = []; + +// hosts +try { + const hosts = fs.readFileSync('/etc/hosts', 'utf8'); + if (new RegExp(`127\\.0\\.0\\.1\\s+${host.replace('.', '\\.')}`).test(hosts)) ok.push('hosts 已指向 127.0.0.1'); + else issues.push(`执行:sudo pnpm setup:plaza-dns`); +} catch { + issues.push('无法读取 /etc/hosts'); +} + +// resolver +try { + const body = fs.readFileSync(`/etc/resolver/${host}`, 'utf8'); + if (body.includes(`port ${dnsPort}`)) ok.push(`resolver -> 127.0.0.1:${dnsPort}`); + else issues.push(`resolver 端口不对,执行:sudo pnpm setup:plaza-dns`); +} catch { + issues.push(`缺少 /etc/resolver/${host},执行:sudo pnpm setup:plaza-dns`); +} + +// services +if (await tcpOpen(dnsPort)) ok.push(`本地 DNS :${dnsPort} 运行中`); +else issues.push(`本地 DNS 未运行,执行:pnpm dev:plaza-dns(或 pnpm dev:plaza)`); + +if (await tcpOpen(plazaPort)) ok.push(`Plaza Next.js :${plazaPort} 运行中`); +else issues.push(`Plaza 未运行,执行:pnpm dev:plaza`); + +if (await tcpOpen(portalPort)) ok.push(`Portal :${portalPort} 运行中`); +else issues.push(`Portal 未运行(dev:plaza 会自动启动)`); + +if (await tcpOpen(443)) ok.push('HTTPS 代理 :443 运行中'); +else issues.push('HTTPS 代理未运行,执行:sudo pnpm dev:plaza-proxy'); + +// DNS resolution +const looked = await lookup(host, { all: true }); +const addrs = looked.map((x) => x.address); +if (addrs.every((a) => a === '127.0.0.1' || a.startsWith('::ffff:127.'))) { + ok.push(`系统 DNS lookup -> ${addrs.join(', ')}`); +} else { + issues.push(`系统 DNS 仍含非本地地址:${addrs.join(', ')}(Tailscale fake-ip)`); +} + +try { + const r4 = await resolve4(host); + if (r4.some((a) => a !== '127.0.0.1')) { + issues.push(`resolve4 -> ${r4.join(', ')}(浏览器 Secure DNS 可能走线上 Cloudflare)`); + } +} catch { + // ignore +} + +const httpsResult = await httpsPlaza(); +if (httpsResult.ok) ok.push(`终端 HTTPS ${host}/plaza -> ${httpsResult.code}`); +else issues.push(`终端 HTTPS 失败:${httpsResult.error ?? httpsResult.code}`); + +console.log('\n=== Plaza 本地诊断 ===\n'); +for (const line of ok) console.log(`✓ ${line}`); +for (const line of issues) console.log(`✗ ${line}`); + +console.log('\n--- 浏览器仍失败?---'); +console.log('Chrome/Cursor 内置浏览器默认走「安全 DNS」,会绕过 /etc/hosts 连到线上 Cloudflare。'); +console.log(''); +console.log('解决:'); +console.log(' 1. pnpm open:plaza # 用 Chrome 强制解析到本机'); +console.log(' 2. 或关闭浏览器「安全 DNS / Secure DNS」后刷新'); +console.log(' 3. 或临时访问 http://127.0.0.1:3001/plaza'); +console.log(''); + +process.exit(issues.length ? 1 : 0); diff --git a/scripts/deploy-plaza-105.sh b/scripts/deploy-plaza-105.sh new file mode 100755 index 0000000..4c02f6a --- /dev/null +++ b/scripts/deploy-plaza-105.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# Plaza 独立部署 → 105(与 Memind H5 解耦,仅共享同机 API) +# +# 架构: +# plaza.tkmind.cn +# /plaza、/_next → goose-plaza-web :3002 (/root/plaza/web) +# /api、/auth、/u → goose-h5 :8080 (/root/tkmind_go/ui/h5) +# +# 用法: +# pnpm deploy:plaza-105 +# pnpm deploy:plaza-105 -- --skip-build +# pnpm deploy:plaza-105 -- --no-restart +# +# 前置: +# 1. DNS: plaza.tkmind.cn A → 120.26.184.105 +# 2. H5 已部署:pnpm deploy:105(提供 Plaza API + 登录 + 发布页) +# 3. 可选:cp deploy/plaza-105/plaza-105.env.example deploy/plaza-105/plaza-105.env +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DEPLOY_DIR="${ROOT}/deploy/plaza-105" +DEPLOY_ENV="${DEPLOY_DIR}/plaza-105.env" +EXCLUDE="${ROOT}/scripts/.rsync-exclude-plaza-web" + +PLAZA_APP_DIR="${PLAZA_APP_DIR:-${ROOT}/../tkmind_go/ui/plaza}" +PLAZA_DEPLOY_HOST="${PLAZA_DEPLOY_HOST:-root@120.26.184.105}" +PLAZA_REMOTE_DIR="${PLAZA_REMOTE_DIR:-/root/plaza/web}" +PLAZA_PROD_URL="${PLAZA_PROD_URL:-https://plaza.tkmind.cn}" +PLAZA_PORT="${PLAZA_PORT:-3002}" +PLAZA_SYSTEMD_SERVICE="${PLAZA_SYSTEMD_SERVICE:-goose-plaza-web}" +H5_SYSTEMD_SERVICE="${H5_SYSTEMD_SERVICE:-goose-h5}" +MINDSPACE_PUBLIC_BASE="${MINDSPACE_PUBLIC_BASE:-https://g2.tkmind.cn}" + +SKIP_BUILD=0 +NO_RESTART=0 + +for arg in "$@"; do + case "$arg" in + --skip-build) SKIP_BUILD=1 ;; + --no-restart) NO_RESTART=1 ;; + -h|--help) + sed -n '2,18p' "$0" + exit 0 + ;; + *) + echo "未知参数: $arg(可用 --skip-build / --no-restart)" >&2 + exit 1 + ;; + esac +done + +if [[ -f "${DEPLOY_ENV}" ]]; then + set -a + # shellcheck disable=SC1090 + source "${DEPLOY_ENV}" + set +a +fi + +if [[ ! -f "${PLAZA_APP_DIR}/package.json" ]]; then + echo "错误: 未找到 Plaza Next.js:${PLAZA_APP_DIR}" >&2 + echo "请设置 PLAZA_APP_DIR 或确认 tkmind_go/ui/plaza 存在" >&2 + exit 1 +fi + +ssh_cmd() { + ssh -o ConnectTimeout=15 -o BatchMode=yes "${PLAZA_DEPLOY_HOST}" "$@" +} + +write_remote_env() { + ssh_cmd "mkdir -p '${PLAZA_REMOTE_DIR}' && cat > '${PLAZA_REMOTE_DIR}/.env' < 安装 Nginx 配置..." + ssh_cmd "mkdir -p /var/www/certbot" + + if ssh_cmd "test -f /etc/letsencrypt/live/plaza.tkmind.cn/fullchain.pem"; then + scp "${DEPLOY_DIR}/plaza.tkmind.cn.nginx.conf" \ + "${PLAZA_DEPLOY_HOST}:/etc/nginx/conf.d/plaza.tkmind.cn.conf" + ssh_cmd "nginx -t && systemctl reload nginx" + return + fi + + echo "==> 首次部署:启用 HTTP bootstrap..." + scp "${DEPLOY_DIR}/plaza.tkmind.cn.bootstrap.nginx.conf" \ + "${PLAZA_DEPLOY_HOST}:/etc/nginx/conf.d/plaza.tkmind.cn.conf" + ssh_cmd "nginx -t && systemctl reload nginx" + + echo "==> 申请 SSL 证书..." + ssh_cmd "certbot certonly --webroot -w /var/www/certbot -d plaza.tkmind.cn \ + --non-interactive --agree-tos -m admin@tkmind.cn || true" + + if ssh_cmd "test -f /etc/letsencrypt/live/plaza.tkmind.cn/fullchain.pem"; then + scp "${DEPLOY_DIR}/plaza.tkmind.cn.nginx.conf" \ + "${PLAZA_DEPLOY_HOST}:/etc/nginx/conf.d/plaza.tkmind.cn.conf" + else + echo "⚠ 证书未签发,暂保留 HTTP bootstrap" + fi + + ssh_cmd "nginx -t && systemctl reload nginx" +} + +install_systemd() { + echo "==> 安装 systemd (${PLAZA_SYSTEMD_SERVICE})..." + scp "${DEPLOY_DIR}/goose-plaza-web.service" \ + "${PLAZA_DEPLOY_HOST}:/etc/systemd/system/${PLAZA_SYSTEMD_SERVICE}.service" + ssh_cmd "systemctl daemon-reload && systemctl enable '${PLAZA_SYSTEMD_SERVICE}'" +} + +echo "======================================" +echo "Plaza 独立部署 → 105" +echo "时间: $(date '+%Y-%m-%d %H:%M:%S')" +echo "前端: ${PLAZA_APP_DIR}" +echo "远端: ${PLAZA_DEPLOY_HOST}:${PLAZA_REMOTE_DIR}" +echo "公网: ${PLAZA_PROD_URL}" +echo "API: goose-h5 @ :8080(需已 deploy:105)" +echo "======================================" + +echo "==> rsync Plaza Next.js..." +rsync -az --delete --exclude-from="${EXCLUDE}" \ + "${PLAZA_APP_DIR}/" \ + "${PLAZA_DEPLOY_HOST}:${PLAZA_REMOTE_DIR}/" + +write_remote_env + +if [[ "${SKIP_BUILD}" -eq 0 ]]; then + echo "==> 远端 npm install && build..." + ssh_cmd "cd '${PLAZA_REMOTE_DIR}' && npm install && npm run build" +fi + +install_systemd +install_nginx + +if [[ "${NO_RESTART}" -eq 0 ]]; then + echo "==> 重启服务..." + ssh_cmd "systemctl is-active --quiet '${H5_SYSTEMD_SERVICE}' || { + echo '⚠ ${H5_SYSTEMD_SERVICE} 未运行,Plaza API 不可用。请先 pnpm deploy:105' >&2 + exit 1 + }" + ssh_cmd "systemctl restart '${PLAZA_SYSTEMD_SERVICE}'" + sleep 2 +fi + +echo "" +echo "=== 健康检查 ===" +ssh_cmd "systemctl is-active '${PLAZA_SYSTEMD_SERVICE}' && echo '${PLAZA_SYSTEMD_SERVICE}: active'" +ssh_cmd "curl -sf 'http://127.0.0.1:8080/api/status' && echo ' ← plaza-api (goose-h5) ok'" || { + echo "✗ goose-h5 API 不可用,请执行 pnpm deploy:105" >&2 + exit 1 +} +ssh_cmd "curl -s -o /dev/null -w 'plaza-web local → %{http_code}\n' 'http://127.0.0.1:${PLAZA_PORT}/plaza'" || true +curl -s --max-time 20 -o /dev/null -w "${PLAZA_PROD_URL}/plaza → %{http_code}\n" "${PLAZA_PROD_URL}/plaza" || true + +echo "" +echo "✅ Plaza 前端已独立部署" +echo " 首页: ${PLAZA_PROD_URL}/plaza" +echo " 目录: ${PLAZA_REMOTE_DIR}" +echo " API: 仍由 goose-h5 提供(pnpm deploy:105 同步)" diff --git a/scripts/dev-plaza.mjs b/scripts/dev-plaza.mjs index 80d1cd7..a6562a6 100644 --- a/scripts/dev-plaza.mjs +++ b/scripts/dev-plaza.mjs @@ -84,6 +84,7 @@ async function waitFor(check, label, retries = 80) { let portal; let plaza; +let dnsServer; let stopping = false; function spawnChild(command, args, label, cwd = root, extraEnv = {}) { @@ -107,6 +108,7 @@ function shutdown(code = 0) { stopping = true; portal?.kill('SIGTERM'); plaza?.kill('SIGTERM'); + dnsServer?.kill('SIGTERM'); setTimeout(() => process.exit(code), 300); } @@ -118,9 +120,18 @@ if (!fs.existsSync(path.join(plazaDir, 'package.json'))) { process.exit(1); } -if (!plazaHostConfigured(plazaHost)) { +function plazaResolverConfigured(host) { + try { + const body = fs.readFileSync(`/etc/resolver/${host}`, 'utf8'); + return body.includes('tkmind-plaza-local'); + } catch { + return false; + } +} + +if (!plazaHostConfigured(plazaHost) || !plazaResolverConfigured(plazaHost)) { console.warn(''); - console.warn(`⚠ ${plazaHost} 尚未写入 /etc/hosts`); + console.warn(`⚠ ${plazaHost} 本地 DNS 未完整配置(Tailscale/Clash 会劫持解析)`); console.warn(' 请执行:sudo pnpm setup:plaza-dns'); console.warn(''); } @@ -144,8 +155,12 @@ console.log(` 公开地址 ${plazaPublicBase}/plaza`); console.log(` 内部端口 ${plazaPort}`); freePort(plazaPort); +freePort(Number(process.env.PLAZA_LOCAL_DNS_PORT ?? 5533)); try { + console.log('==> 启动 Plaza 本地 DNS(覆盖 Tailscale fake-ip)'); + dnsServer = spawnChild('node', ['scripts/plaza-local-dns-server.mjs'], 'plaza-dns'); + if (!(await portOpen(portalPort))) { console.log(`==> 启动 Portal @ ${portalUrl}`); portal = spawnChild('node', ['server.mjs'], 'portal'); diff --git a/scripts/g2-h5-tunnel.sh b/scripts/g2-h5-tunnel.sh new file mode 100755 index 0000000..3cbb5ea --- /dev/null +++ b/scripts/g2-h5-tunnel.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# 正向 SSH 隧道:本机 18080 → 105 goose-h5 :8080(g2 负载均衡 upstream) +set -euo pipefail + +HOST="${G2_TUNNEL_HOST:-ssh105}" +LOCAL_PORT="${G2_TUNNEL_LOCAL_PORT:-18080}" +REMOTE_HOST="${G2_TUNNEL_REMOTE_HOST:-127.0.0.1}" +REMOTE_PORT="${G2_TUNNEL_REMOTE_PORT:-8080}" + +exec ssh -N \ + -o ServerAliveInterval=30 \ + -o ServerAliveCountMax=3 \ + -o ExitOnForwardFailure=yes \ + -L "127.0.0.1:${LOCAL_PORT}:${REMOTE_HOST}:${REMOTE_PORT}" \ + "${HOST}" diff --git a/scripts/g2-lb.Caddyfile b/scripts/g2-lb.Caddyfile new file mode 100644 index 0000000..0629023 --- /dev/null +++ b/scripts/g2-lb.Caddyfile @@ -0,0 +1,10 @@ +# g2.tkmind.cn 本地发布:仅本机 Mac H5 (:8081),不经过 105 +:8090 { + reverse_proxy 127.0.0.1:8081 { + flush_interval -1 + transport http { + read_timeout 0 + write_timeout 0 + } + } +} diff --git a/scripts/g2-lb.Caddyfile.active b/scripts/g2-lb.Caddyfile.active new file mode 100644 index 0000000..0629023 --- /dev/null +++ b/scripts/g2-lb.Caddyfile.active @@ -0,0 +1,10 @@ +# g2.tkmind.cn 本地发布:仅本机 Mac H5 (:8081),不经过 105 +:8090 { + reverse_proxy 127.0.0.1:8081 { + flush_interval -1 + transport http { + read_timeout 0 + write_timeout 0 + } + } +} diff --git a/scripts/g2-lb.local.Caddyfile b/scripts/g2-lb.local.Caddyfile new file mode 100644 index 0000000..0629023 --- /dev/null +++ b/scripts/g2-lb.local.Caddyfile @@ -0,0 +1,10 @@ +# g2.tkmind.cn 本地发布:仅本机 Mac H5 (:8081),不经过 105 +:8090 { + reverse_proxy 127.0.0.1:8081 { + flush_interval -1 + transport http { + read_timeout 0 + write_timeout 0 + } + } +} diff --git a/scripts/install-g2-lb.sh b/scripts/install-g2-lb.sh new file mode 100755 index 0000000..861be27 --- /dev/null +++ b/scripts/install-g2-lb.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# 安装 g2.tkmind.cn 50/50 负载均衡:Caddy :8090 + SSH 隧道 + 更新 cloudflared +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TUNNEL="$ROOT/scripts/g2-h5-tunnel.sh" +CADDYFILE="$ROOT/scripts/g2-lb.Caddyfile" +CF_CONFIG="/Users/john/Project/ollama/cloudflare/config.yml" +TUNNEL_PLIST="$HOME/Library/LaunchAgents/cn.tkmind.g2-h5-tunnel.plist" +LB_PLIST="$HOME/Library/LaunchAgents/cn.tkmind.g2-lb.plist" +LOG_DIR="$HOME/Library/Logs" + +chmod +x "$TUNNEL" "$(dirname "$0")/install-g2-lb.sh" +mkdir -p "$LOG_DIR" + +cat >"$TUNNEL_PLIST" < + + + + Label + cn.tkmind.g2-h5-tunnel + ProgramArguments + + ${TUNNEL} + + RunAtLoad + + KeepAlive + + StandardOutPath + ${LOG_DIR}/g2-h5-tunnel.log + StandardErrorPath + ${LOG_DIR}/g2-h5-tunnel.log + + +EOF + +cat >"$LB_PLIST" < + + + + Label + cn.tkmind.g2-lb + ProgramArguments + + /opt/homebrew/bin/caddy + run + --config + ${CADDYFILE} + + RunAtLoad + + KeepAlive + + StandardOutPath + ${LOG_DIR}/g2-lb.log + StandardErrorPath + ${LOG_DIR}/g2-lb.log + + +EOF + +if grep -q '127.0.0.1:8090' "$CF_CONFIG"; then + echo "cloudflared 已指向 :8090,跳过 config 修改" +else + sed -i '' 's|service: http://127.0.0.1:8081|service: http://127.0.0.1:8090|' "$CF_CONFIG" + echo "已更新 $CF_CONFIG → g2 指向 :8090" +fi + +UID_NUM="$(id -u)" +GUI="gui/${UID_NUM}" + +launchctl bootout "$GUI/cn.tkmind.g2-h5-tunnel" 2>/dev/null || true +launchctl bootstrap "$GUI" "$TUNNEL_PLIST" +launchctl enable "$GUI/cn.tkmind.g2-h5-tunnel" +launchctl kickstart -k "$GUI/cn.tkmind.g2-h5-tunnel" + +launchctl bootout "$GUI/cn.tkmind.g2-lb" 2>/dev/null || true +launchctl bootstrap "$GUI" "$LB_PLIST" +launchctl enable "$GUI/cn.tkmind.g2-lb" +launchctl kickstart -k "$GUI/cn.tkmind.g2-lb" + +launchctl kickstart -k "$GUI/com.cloudflare.cloudflared" + +sleep 2 +echo "" +echo "=== g2 负载均衡状态 ===" +curl -s -o /dev/null -w "本机 upstream :8081 → %{http_code}\n" http://127.0.0.1:8081/api/status || true +curl -s -o /dev/null -w "105 upstream :18080 → %{http_code}\n" http://127.0.0.1:18080/api/status || true +curl -s -o /dev/null -w "Caddy LB :8090 → %{http_code}\n" http://127.0.0.1:8090/api/status || true +echo "" +echo "日志: ~/Library/Logs/g2-h5-tunnel.log ~/Library/Logs/g2-lb.log" +echo "外网验证: curl -sI https://g2.tkmind.cn/api/status" diff --git a/scripts/install-memind-mac-tunnel.sh b/scripts/install-memind-mac-tunnel.sh new file mode 100755 index 0000000..272abfe --- /dev/null +++ b/scripts/install-memind-mac-tunnel.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# 安装 LaunchAgent:Mac 开机/登录后自动建立 MindSpace 反向隧道 +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PLIST="$HOME/Library/LaunchAgents/cn.tkmind.memind-tunnel.plist" +TUNNEL="$ROOT/scripts/memind-mac-tunnel.sh" + +chmod +x "$TUNNEL" + +cat >"$PLIST" < + + + + Label + cn.tkmind.memind-tunnel + ProgramArguments + + ${TUNNEL} + + RunAtLoad + + KeepAlive + + StandardOutPath + ${HOME}/Library/Logs/memind-mac-tunnel.log + StandardErrorPath + ${HOME}/Library/Logs/memind-mac-tunnel.log + + +EOF + +launchctl bootout "gui/$(id -u)/cn.tkmind.memind-tunnel" 2>/dev/null || true +launchctl bootstrap "gui/$(id -u)" "$PLIST" +launchctl enable "gui/$(id -u)/cn.tkmind.memind-tunnel" +launchctl kickstart -k "gui/$(id -u)/cn.tkmind.memind-tunnel" + +echo "已安装 MindSpace 反向隧道 LaunchAgent" +echo "日志: ~/Library/Logs/memind-mac-tunnel.log" diff --git a/scripts/memind-mac-tunnel.sh b/scripts/memind-mac-tunnel.sh new file mode 100755 index 0000000..e970961 --- /dev/null +++ b/scripts/memind-mac-tunnel.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# 反向 SSH 隧道:让 105 通过 127.0.0.1:19999 访问本机 SSH(MindSpace 共享挂载依赖) +set -euo pipefail + +HOST="${MEMIND_TUNNEL_HOST:-ssh105}" +LOCAL_PORT="${MEMIND_TUNNEL_LOCAL_PORT:-22}" +REMOTE_PORT="${MEMIND_TUNNEL_REMOTE_PORT:-19999}" + +exec ssh -N \ + -o ServerAliveInterval=30 \ + -o ServerAliveCountMax=3 \ + -o ExitOnForwardFailure=yes \ + -R "127.0.0.1:${REMOTE_PORT}:127.0.0.1:${LOCAL_PORT}" \ + "${HOST}" diff --git a/scripts/open-plaza-local.mjs b/scripts/open-plaza-local.mjs new file mode 100644 index 0000000..8eed79c --- /dev/null +++ b/scripts/open-plaza-local.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * Open plaza.tkmind.cn in a browser with local host mapping. + * Chromium Secure DNS bypasses /etc/hosts — host-resolver-rules fixes that. + * + * Usage: node scripts/open-plaza-local.mjs + */ +import { spawnSync } from 'node:child_process'; + +const host = process.env.PLAZA_LOCAL_HOST ?? 'plaza.tkmind.cn'; +const url = (process.env.PLAZA_PUBLIC_BASE ?? `https://${host}`).replace(/\/$/, '') + '/plaza'; +const rules = `MAP ${host} 127.0.0.1`; + +const attempts = [ + { + label: 'Google Chrome', + cmd: 'open', + args: ['-na', 'Google Chrome', '--args', `--host-resolver-rules=${rules}`, url], + }, + { + label: 'Microsoft Edge', + cmd: 'open', + args: ['-na', 'Microsoft Edge', '--args', `--host-resolver-rules=${rules}`, url], + }, + { + label: 'Safari', + cmd: 'open', + args: ['-a', 'Safari', url], + }, +]; + +for (const attempt of attempts) { + const result = spawnSync(attempt.cmd, attempt.args, { stdio: 'ignore' }); + if (result.status === 0) { + console.log(`已在 ${attempt.label} 打开:${url}`); + if (attempt.label !== 'Safari') { + console.log('(已通过 --host-resolver-rules 强制解析到 127.0.0.1)'); + } + console.log('若提示证书不受信任,选择「继续访问」即可。'); + process.exit(0); + } +} + +console.log('未找到 Chrome/Edge/Safari。请手动:'); +console.log(` 1. 关闭浏览器「安全 DNS / Secure DNS」`); +console.log(` 2. 打开 ${url}`); +console.log(` 3. 或访问 http://127.0.0.1:3001/plaza`); diff --git a/scripts/plaza-local-dns-server.mjs b/scripts/plaza-local-dns-server.mjs new file mode 100644 index 0000000..a468f43 --- /dev/null +++ b/scripts/plaza-local-dns-server.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * Minimal DNS server for plaza.tkmind.cn -> 127.0.0.1 + * Works with /etc/resolver/plaza.tkmind.cn (macOS per-domain DNS override). + * + * Usage: + * node scripts/plaza-local-dns-server.mjs + */ +import dgram from 'node:dgram'; + +const host = process.env.PLAZA_LOCAL_HOST ?? 'plaza.tkmind.cn'; +const ip = (process.env.PLAZA_LOCAL_IP ?? '127.0.0.1').split('.').map(Number); +const port = Number(process.env.PLAZA_LOCAL_DNS_PORT ?? 5533); +const bind = process.env.PLAZA_LOCAL_DNS_BIND ?? '127.0.0.1'; + +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]), + ]); +} + +const hostEncoded = encodeName(host); + +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 name === host || name === `${host}.`; +} + +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]); // 60s + + let answer; + if (qtype === 1) { + answer = Buffer.concat([ + Buffer.from([0xc0, 0x0c]), // pointer to question name + Buffer.from([0x00, 0x01, 0x00, 0x01]), // A, IN + ttl, + Buffer.from([0x00, 0x04]), + Buffer.from(ip), + ]); + } else { + // AAAA for ::ffff:127.0.0.1 so browsers don't prefer Tailscale fake-ip v6 + answer = Buffer.concat([ + Buffer.from([0xc0, 0x0c]), + Buffer.from([0x00, 0x1c, 0x00, 0x01]), // AAAA, IN + 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); // ID + header.writeUInt16BE(0x8180, 2); // QR=1, AA=1, RD supported + header.writeUInt16BE(1, 4); // QDCOUNT + header.writeUInt16BE(1, 6); // ANCOUNT + header.writeUInt16BE(0, 8); + header.writeUInt16BE(0, 10); + + const response = Buffer.concat([header, questionSection, answer]); + server.send(response, rinfo.port, rinfo.address); +}); + +server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + console.error(`Plaza DNS 端口 ${port} 已被占用`); + process.exit(1); + } + throw err; +}); + +server.bind(port, bind, () => { + console.log(`Plaza DNS ${host} -> ${ip.join('.')} @ ${bind}:${port}`); +}); + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + server.close(() => process.exit(0)); + }); +} diff --git a/scripts/setup-mindspace-shared-105.sh b/scripts/setup-mindspace-shared-105.sh new file mode 100755 index 0000000..69289d1 --- /dev/null +++ b/scripts/setup-mindspace-shared-105.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# 在 105 上挂载本机 MindSpace(需 Mac 反向隧道 :19999 已建立) +set -euo pipefail + +H5_DIR="${H5_DIR:-/root/tkmind_go/ui/h5}" +MOUNT_ROOT="${MOUNT_ROOT:-/mnt/memind-shared}" +TUNNEL_PORT="${MEMIND_TUNNEL_REMOTE_PORT:-19999}" + +mkdir -p "${MOUNT_ROOT}/MindSpace" "${MOUNT_ROOT}/data/mindspace" +grep -q '^user_allow_other' /etc/fuse.conf 2>/dev/null || echo user_allow_other >> /etc/fuse.conf + +if ! command -v rclone >/dev/null; then + cd /tmp + curl -fsSL -o rclone.zip https://downloads.rclone.org/rclone-current-linux-amd64.zip + unzip -o rclone.zip + cp rclone-*-linux-amd64/rclone /usr/local/bin/ + chmod +x /usr/local/bin/rclone +fi + +mkdir -p /root/.config/rclone +cat >/root/.config/rclone/rclone.conf <<'EOF' +[memind-mac] +type = sftp +host = 127.0.0.1 +port = 19999 +user = john +key_file = /root/.ssh/id_ed25519 +shell_type = unix +EOF + +ssh-keyscan -p "${TUNNEL_PORT}" 127.0.0.1 >> /root/.ssh/known_hosts 2>/dev/null || true + +for i in $(seq 1 30); do + if ss -tln | grep -q ":${TUNNEL_PORT} "; then break; fi + sleep 2 +done +if ! ss -tln | grep -q ":${TUNNEL_PORT} "; then + echo "错误: 本机反向隧道 ${TUNNEL_PORT} 未就绪,请先在 Mac 运行 scripts/install-memind-mac-tunnel.sh" >&2 + exit 1 +fi + +rclone lsd memind-mac:/Users/john/Project/Memind/MindSpace >/dev/null + +if [[ -d "${H5_DIR}/MindSpace" && ! -L "${H5_DIR}/MindSpace" ]]; then + mv "${H5_DIR}/MindSpace" "${H5_DIR}/MindSpace.bak.local-$(date +%Y%m%d)" +fi +if [[ -d "${H5_DIR}/data/mindspace" && ! -L "${H5_DIR}/data/mindspace" ]]; then + mv "${H5_DIR}/data/mindspace" "${H5_DIR}/data/mindspace.bak.local-$(date +%Y%m%d)" +fi + +cat >/etc/systemd/system/memind-shared-mindspace.mount.service </dev/null || true; mkdir -p ${MOUNT_ROOT}/MindSpace' +ExecStart=/usr/local/bin/rclone mount memind-mac:/Users/john/Project/Memind/MindSpace ${MOUNT_ROOT}/MindSpace --config /root/.config/rclone/rclone.conf --allow-other --allow-non-empty --vfs-cache-mode writes --vfs-write-back 0 --dir-cache-time 10s --poll-interval 5s --attr-timeout 1s +ExecStop=/bin/fusermount -uz ${MOUNT_ROOT}/MindSpace +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + +cat >/etc/systemd/system/memind-shared-storage.mount.service </dev/null || true; mkdir -p ${MOUNT_ROOT}/data/mindspace' +ExecStart=/usr/local/bin/rclone mount memind-mac:/Users/john/Project/Memind/data/mindspace ${MOUNT_ROOT}/data/mindspace --config /root/.config/rclone/rclone.conf --allow-other --allow-non-empty --vfs-cache-mode writes --vfs-write-back 0 --dir-cache-time 10s --poll-interval 5s --attr-timeout 1s +ExecStop=/bin/fusermount -uz ${MOUNT_ROOT}/data/mindspace +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable memind-shared-mindspace.mount.service memind-shared-storage.mount.service +systemctl restart memind-shared-mindspace.mount.service +sleep 2 +systemctl restart memind-shared-storage.mount.service +sleep 2 + +ln -sfn "${MOUNT_ROOT}/MindSpace" "${H5_DIR}/MindSpace" +mkdir -p "${H5_DIR}/data" +ln -sfn "${MOUNT_ROOT}/data/mindspace" "${H5_DIR}/data/mindspace" + +ENV_FILE="${H5_DIR}/.env" +if [[ -f "${ENV_FILE}" ]]; then + if grep -q '^MINDSPACE_STORAGE_ROOT=' "${ENV_FILE}"; then + sed -i "s|^MINDSPACE_STORAGE_ROOT=.*|MINDSPACE_STORAGE_ROOT=${MOUNT_ROOT}/data/mindspace|" "${ENV_FILE}" + else + echo "MINDSPACE_STORAGE_ROOT=${MOUNT_ROOT}/data/mindspace" >>"${ENV_FILE}" + fi + if grep -q '^MEMIND_SHARED_ROOT=' "${ENV_FILE}"; then + sed -i "s|^MEMIND_SHARED_ROOT=.*|MEMIND_SHARED_ROOT=${MOUNT_ROOT}|" "${ENV_FILE}" + else + echo "MEMIND_SHARED_ROOT=${MOUNT_ROOT}" >>"${ENV_FILE}" + fi +fi + +systemctl restart goose-h5 2>/dev/null || true + +echo "✅ 105 MindSpace 已挂载本机共享目录 ${MOUNT_ROOT}" +ls -la "${H5_DIR}/MindSpace" | head -5 diff --git a/scripts/setup-plaza-local-dns.mjs b/scripts/setup-plaza-local-dns.mjs index c4c8b23..c38f1b5 100644 --- a/scripts/setup-plaza-local-dns.mjs +++ b/scripts/setup-plaza-local-dns.mjs @@ -1,22 +1,54 @@ #!/usr/bin/env node /** * Map plaza.tkmind.cn -> 127.0.0.1 for local Plaza dev. - * Requires sudo to edit /etc/hosts. + * Requires sudo to edit /etc/hosts and /etc/resolver. + * + * macOS + Tailscale/Clash fake-ip: /etc/hosts alone is not enough — browsers may + * still resolve 198.18.x.x. Per-domain resolver forces local DNS server. * * Usage: * node scripts/setup-plaza-local-dns.mjs * node scripts/setup-plaza-local-dns.mjs --remove */ import fs from 'node:fs'; +import path from 'node:path'; import { execSync } from 'node:child_process'; const HOST = process.env.PLAZA_LOCAL_HOST ?? 'plaza.tkmind.cn'; const IP = process.env.PLAZA_LOCAL_IP ?? '127.0.0.1'; +const DNS_PORT = Number(process.env.PLAZA_LOCAL_DNS_PORT ?? 5533); const MARKER = '# tkmind-plaza-local'; +const RESOLVER_MARKER = '# tkmind-plaza-local'; const LINE = `${IP} ${HOST} ${MARKER}`; const remove = process.argv.includes('--remove'); const hostsPath = '/etc/hosts'; +const resolverDir = '/etc/resolver'; +const resolverPath = path.join(resolverDir, HOST); +const resolverBody = `nameserver 127.0.0.1\nport ${DNS_PORT}\n${RESOLVER_MARKER}\n`; + +function flushDnsCache() { + try { + execSync('dscacheutil -flushcache', { stdio: 'ignore' }); + execSync('killall -HUP mDNSResponder', { stdio: 'ignore' }); + } catch { + // best effort + } +} + +function writeResolver() { + fs.mkdirSync(resolverDir, { recursive: true }); + fs.writeFileSync(resolverPath, resolverBody, 'utf8'); +} + +function removeResolver() { + try { + if (fs.existsSync(resolverPath)) fs.unlinkSync(resolverPath); + } catch { + // ignore + } +} + const current = fs.readFileSync(hostsPath, 'utf8'); const lines = current.split('\n'); const filtered = lines.filter( @@ -24,35 +56,61 @@ const filtered = lines.filter( ); if (remove) { - if (filtered.length === lines.length) { - console.log(`hosts 中未找到 ${HOST},无需移除`); - process.exit(0); - } const next = filtered.join('\n').replace(/\n?$/, '\n'); - execSync(`tee ${hostsPath}`, { input: next, stdio: ['pipe', 'inherit', 'inherit'] }); - console.log(`已移除 ${HOST} 本地解析`); + if (filtered.length !== lines.length) { + execSync(`tee ${hostsPath}`, { input: next, stdio: ['pipe', 'inherit', 'inherit'] }); + console.log(`已移除 hosts 中的 ${HOST}`); + } else { + console.log(`hosts 中未找到 ${HOST},无需移除`); + } + removeResolver(); + flushDnsCache(); + console.log('已移除 per-domain resolver'); process.exit(0); } -if (lines.some((line) => line.includes(MARKER) || new RegExp(`\\s${HOST.replace('.', '\\.')}(\\s|$)`).test(line))) { - console.log(`${HOST} 已在 /etc/hosts 中指向本地`); +const hostsReady = lines.some( + (line) => line.includes(MARKER) || new RegExp(`\\s${HOST.replace('.', '\\.')}(\\s|$)`).test(line), +); +const resolverReady = + fs.existsSync(resolverPath) && fs.readFileSync(resolverPath, 'utf8').trim() === resolverBody.trim(); + +if (hostsReady && resolverReady) { + console.log(`${HOST} 本地解析已配置(hosts + resolver)`); process.exit(0); } -const next = `${filtered.join('\n').replace(/\n?$/, '\n')}${LINE}\n`; if (process.getuid?.() !== 0) { - console.log('需要 root 权限写入 /etc/hosts,请执行:'); + console.log('需要 root 权限写入 /etc/hosts 和 /etc/resolver,请执行:'); console.log(` sudo node ${process.argv[1]}`); process.exit(1); } + +if (!hostsReady) { + const next = `${filtered.join('\n').replace(/\n?$/, '\n')}${LINE}\n`; + try { + execSync(`tee ${hostsPath}`, { input: next, stdio: ['pipe', 'inherit', 'inherit'] }); + console.log(`已添加 hosts:${LINE}`); + } catch { + console.error('写入 /etc/hosts 失败,请手动执行:'); + console.error(` sudo sh -c 'echo "${LINE}" >> /etc/hosts'`); + process.exit(1); + } +} else { + console.log(`${HOST} 已在 /etc/hosts 中指向本地`); +} + try { - execSync(`tee ${hostsPath}`, { input: next, stdio: ['pipe', 'inherit', 'inherit'] }); - console.log(`已添加:${LINE}`); - console.log('下一步:'); - console.log(' sudo pnpm setup:plaza-local # hosts + TLS 证书'); - console.log(' sudo pnpm dev:plaza-proxy # HTTPS :443 代理'); -} catch { - console.error('写入 /etc/hosts 失败,请手动执行:'); - console.error(` sudo sh -c 'echo "${LINE}" >> /etc/hosts'`); + writeResolver(); + console.log(`已添加 resolver:${resolverPath} -> 127.0.0.1:${DNS_PORT}`); +} catch (err) { + console.error('写入 /etc/resolver 失败:', err instanceof Error ? err.message : err); process.exit(1); } + +flushDnsCache(); +console.log('已刷新 DNS 缓存'); +console.log(''); +console.log('下一步:'); +console.log(' pnpm dev:plaza # 含本地 DNS + Plaza 服务'); +console.log(' sudo pnpm dev:plaza-proxy # HTTPS :443 代理'); diff --git a/scripts/sync-local-db-to-rds.mjs b/scripts/sync-local-db-to-rds.mjs new file mode 100644 index 0000000..665bed0 --- /dev/null +++ b/scripts/sync-local-db-to-rds.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +/** + * Sync local MySQL (source) → RDS (target). + * Keeps tables listed in KEEP_TARGET where RDS has richer production data. + * + * Usage: + * node scripts/sync-local-db-to-rds.mjs + * node scripts/sync-local-db-to-rds.mjs --dry-run + */ +import mysql from 'mysql2/promise'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const dryRun = process.argv.includes('--dry-run'); + +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.local')); +loadEnvFile(path.join(root, '.env')); + +const sourceUrl = + process.env.SYNC_SOURCE_DATABASE_URL ?? 'mysql://boot:password@127.0.0.1:3306/tkmind'; +const targetUrl = + process.env.SYNC_TARGET_DATABASE_URL ?? + process.env.PLAZA_PROD_DATABASE_URL ?? + 'mysql://boot:%40Abc888888@rm-uf6h1j53vtuxi78i90o.mysql.rds.aliyuncs.com:3306/goose'; + +/** RDS wins — do not overwrite from local. */ +const KEEP_TARGET = new Set(['h5_publication_views']); + +function serializeRow(row) { + const out = {}; + for (const [key, value] of Object.entries(row)) { + if (value instanceof Date) out[key] = value; + else if (value !== null && typeof value === 'object') out[key] = JSON.stringify(value); + else out[key] = value; + } + return out; +} + +async function copyTable(source, target, table) { + const [rows] = await source.query(`SELECT * FROM \`${table}\``); + if (rows.length === 0) { + if (!dryRun) await target.query(`DELETE FROM \`${table}\``).catch(() => {}); + return 0; + } + if (dryRun) return rows.length; + + await target.query('SET FOREIGN_KEY_CHECKS=0'); + await target.query(`DELETE FROM \`${table}\``); + const batchSize = 200; + let copied = 0; + for (let i = 0; i < rows.length; i += batchSize) { + const chunk = rows.slice(i, i + batchSize).map(serializeRow); + const cols = Object.keys(chunk[0]); + const placeholders = chunk.map(() => `(${cols.map(() => '?').join(', ')})`).join(', '); + const values = chunk.flatMap((row) => cols.map((col) => row[col])); + await target.query( + `INSERT INTO \`${table}\` (${cols.map((c) => `\`${c}\``).join(', ')}) VALUES ${placeholders}`, + values, + ); + copied += chunk.length; + } + await target.query('SET FOREIGN_KEY_CHECKS=1'); + return copied; +} + +async function main() { + const source = await mysql.createConnection(sourceUrl); + const target = await mysql.createConnection(targetUrl); + + const [localTables] = await source.query('SHOW TABLES'); + const [remoteTables] = await target.query('SHOW TABLES'); + const remoteSet = new Set(remoteTables.map((r) => Object.values(r)[0])); + const tables = localTables + .map((r) => Object.values(r)[0]) + .filter((t) => remoteSet.has(t)) + .sort(); + + console.log(`源库: ${sourceUrl.replace(/:[^:@/]+@/, ':***@')}`); + console.log(`目标: ${targetUrl.replace(/:[^:@/]+@/, ':***@')}`); + console.log(`保留 RDS 数据(不覆盖): ${[...KEEP_TARGET].join(', ') || '(无)'}`); + console.log(''); + + const summary = []; + for (const table of tables) { + if (KEEP_TARGET.has(table)) { + const [[r]] = await target.query(`SELECT COUNT(*) AS c FROM \`${table}\``); + summary.push({ table, action: 'keep-rds', rows: Number(r.c) }); + continue; + } + const [[l]] = await source.query(`SELECT COUNT(*) AS c FROM \`${table}\``); + const localCount = Number(l.c); + const copied = await copyTable(source, target, table); + summary.push({ table, action: dryRun ? 'would-sync' : 'synced', rows: copied, localCount }); + } + + console.log('TABLE\tACTION\tROWS'); + for (const row of summary) { + console.log(`${row.table}\t${row.action}\t${row.rows}`); + } + + await source.end(); + await target.end(); + console.log(dryRun ? '\n(dry-run,未写入)' : '\n✅ 同步完成'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/sync-to-105.sh b/scripts/sync-to-105.sh new file mode 100755 index 0000000..fb0758b --- /dev/null +++ b/scripts/sync-to-105.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Memind 本地 → 105 全量同步(代码与 dist 一致;保留远端 .env 与 MindSpace 共享挂载) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +EXCLUDE="${ROOT}/scripts/.rsync-exclude-105" +HOST="${H5_DEPLOY_HOST:-root@120.26.184.105}" +REMOTE="${H5_REMOTE_DIR:-/root/tkmind_go/ui/h5}" +SERVICE="${H5_SYSTEMD_SERVICE:-goose-h5}" + +echo "======================================" +echo "Memind → 105 同步" +echo "本地: ${ROOT}" +echo "远端: ${HOST}:${REMOTE}" +echo "时间: $(date '+%Y-%m-%d %H:%M:%S')" +echo "======================================" + +echo "==> rsync 代码 (--delete)..." +rsync -az --delete --exclude-from="${EXCLUDE}" "${ROOT}/" "${HOST}:${REMOTE}/" + +echo "==> rsync dist..." +rsync -az "${ROOT}/dist/" "${HOST}:${REMOTE}/dist/" + +echo "==> 远端 npm install..." +ssh -o BatchMode=yes "${HOST}" "cd '${REMOTE}' && npm install" + +if [[ "${SKIP_BUILD:-0}" != 1 ]]; then + echo "==> 远端 npm run build(失败不阻断,沿用已同步 dist)..." + ssh -o BatchMode=yes "${HOST}" "cd '${REMOTE}' && npm run build" || echo "⚠️ 远端 vite build 失败,已使用 rsync 的 dist/" +fi + +if [[ "${NO_RESTART:-0}" != 1 ]]; then + echo "==> 重启 ${SERVICE}..." + ssh -o BatchMode=yes "${HOST}" "systemctl restart '${SERVICE}' && sleep 3 && systemctl is-active '${SERVICE}'" +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}'")" + if [[ "${local_sum}" == "${remote_sum}" ]]; then + echo "✓ ${f}" + else + echo "✗ ${f} 不一致 (local=${local_sum} remote=${remote_sum})" >&2 + exit 1 + fi +done + +ssh -o BatchMode=yes "${HOST}" "curl -sf 'http://127.0.0.1:8080/api/status' && echo ' ← h5 ok'" || { + echo "✗ 105 H5 健康检查失败" >&2 + exit 1 +} + +echo "" +echo "✅ 本地与 105 已同步" diff --git a/server.mjs b/server.mjs index b85d079..e6c8c75 100644 --- a/server.mjs +++ b/server.mjs @@ -21,7 +21,7 @@ import { userSessionCookie, } from './user-auth.mjs'; import { createWikiAuth } from './wiki-auth.mjs'; -import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, resolvePublishDir } from './user-publish.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'; @@ -30,6 +30,8 @@ import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs'; 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 { createPageEditSessionService } from './mindspace-page-edit-session.mjs'; import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs'; import { createPublicationService } from './mindspace-publications.mjs'; import { createPlazaPostService, formatPostRow, mapPlazaError } from './plaza-posts.mjs'; @@ -151,6 +153,10 @@ app.use('/api', csrfOriginCheck); app.use('/auth', csrfOriginCheck); const jsonBody = express.json({ limit: '1mb' }); +const jsonUnlessMultipart = (req, res, next) => { + if ((req.headers['content-type'] ?? '').includes('multipart/form-data')) return next(); + return jsonBody(req, res, next); +}; const rawUploadBody = express.raw({ type: 'application/octet-stream', limit: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? 2 * 1024 * 1024), @@ -169,6 +175,8 @@ let mindSpace = null; let mindSpaceAssets = null; let mindSpaceAudit = null; let mindSpacePages = null; +let mindSpacePageLiveEdit = null; +let mindSpacePageEditSession = null; let mindSpacePublications = null; let plazaPosts = null; let plazaInteractions = null; @@ -207,6 +215,16 @@ async function bootstrapUserAuth() { storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'), }); + mindSpacePageLiveEdit = createPageLiveEditService({ + pageService: mindSpacePages, + 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'), @@ -253,7 +271,7 @@ async function bootstrapUserAuth() { userAuth = createUserAuth(pool, { usersRoot: USERS_ROOT, h5Root: __dirname, - defaultSignupBalanceCents: Number(process.env.H5_SIGNUP_BALANCE_CENTS ?? 1000), + defaultSignupBalanceCents: Number(process.env.H5_SIGNUP_BALANCE_CENTS ?? 500), }); wechatPayClient = createWechatPayClient(loadWechatPayConfig()); wechatOAuthService = createWechatOAuthService(pool, loadWechatOAuthConfig(), { userAuth }); @@ -323,6 +341,14 @@ async function bootstrapUserAuth() { userAuth, llmProviderService, }); + mindSpacePageEditSession = createPageEditSessionService({ + apiTarget: API_TARGET, + apiSecret: API_SECRET, + userAuth, + pageService: mindSpacePages, + pageLiveEdit: mindSpacePageLiveEdit, + llmProviderService, + }); console.log(`User auth enabled (MySQL), workspace root: ${USERS_ROOT}`); return true; } catch (err) { @@ -665,7 +691,7 @@ app.get('/auth/wechat/callback', async (req, res) => { .catch(() => {}); } - if (result.authMode === 'open') { + if (result.authMode === 'open' || result.authMode === 'scan') { return res.send(`微信登录

扫码登录成功,请返回电脑继续操作。

`); } @@ -1038,6 +1064,20 @@ app.get('/auth/usage', async (req, res) => { res.json({ records }); }); +app.get('/auth/billing/ledger', 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 = Math.min(Math.max(Number(req.query?.limit) || 30, 1), 100); + const entries = await userAuth.listBillingLedger({ + userId: me.id, + limit, + types: ['recharge', 'adjust', 'refund'], + }); + res.json({ entries }); +}); + app.get('/auth/billing/config', async (req, res) => { await userAuthReady; if (!userAuth || !rechargeService) { @@ -1223,12 +1263,13 @@ app.use('/wiki-api', wikiApi); // ============ TKMind API proxy ============ const api = express.Router(); -api.use(jsonBody); +api.use(jsonUnlessMultipart); api.use(async (req, res, next) => { await userAuthReady; if (req.path === '/status') return next(); if (req.path.startsWith('/internal/agent/')) return next(); + if (req.path === '/agent/mindspace_page_patch') return next(); const plazaPublic = isPlazaPublicRead(req.path, req.method); @@ -1251,6 +1292,8 @@ api.use(async (req, res, next) => { return res.status(401).json({ message: '未授权,请重新登录' }); }); +attachAsrRoutes(api, { sendError, sendData }); + api.get('/status', async (_req, res, next) => { await userAuthReady; if (userAuth && tkmindProxy) { @@ -1304,6 +1347,7 @@ api.post('/mindspace/v1/space/cleanup', async (req, res) => { const username = req.currentUser.username ?? req.currentUser.slug; const itemIds = Array.isArray(req.body?.item_ids) ? req.body.item_ids : []; const result = await mindSpaceCleanup.runCleanup(req.currentUser.id, username, itemIds); + const quota = await mindSpace.getQuota(req.currentUser.id); await mindSpaceAudit?.write({ userId: req.currentUser.id, action: 'space.cleanup', @@ -1312,7 +1356,7 @@ api.post('/mindspace/v1/space/cleanup', async (req, res) => { ip: req.ip, detail: { removedCount: result.removedCount, freedBytes: result.freedBytes }, }); - return sendData(res, req, result); + return sendData(res, req, { ...result, quota }); } catch (error) { return mindSpaceError(res, req, error); } @@ -1517,10 +1561,27 @@ function mindSpaceError(res, req, error) { agent_job_token_invalid: 401, agent_job_expired: 410, feature_disabled: 503, + cover_ai_unavailable: 503, + llm_not_configured: 503, + cover_ai_failed: 502, + cover_ai_invalid_output: 422, + thumbnail_not_supported: 422, + thumbnail_image_required: 400, + thumbnail_image_invalid: 400, + category_not_pageable: 400, + redaction_not_needed: 409, + invalid_category_code: 400, + invalid_page_path: 400, + empty_page_content: 400, + static_page_not_found: 404, + preview_not_supported: 422, }; const code = error?.code ?? 'internal_error'; const status = statusByCode[code] ?? 500; - const message = status === 500 ? 'MindSpace 服务异常' : error.message; + const message = + status === 500 && (!error?.code || code === 'internal_error') + ? 'MindSpace 服务异常' + : error?.message || 'MindSpace 服务异常'; return sendError(res, req, status, code, message, error?.details); } @@ -2294,6 +2355,7 @@ api.delete('/mindspace/v1/pages/:pageId', async (req, res) => { if (!mindSpacePages || !ensureMindSpaceEnabled(res, req)) return; try { const result = await mindSpacePages.deletePage(req.currentUser.id, req.params.pageId); + const quota = await mindSpace.getQuota(req.currentUser.id); await mindSpaceAudit?.write({ userId: req.currentUser.id, action: 'page.delete', @@ -2306,7 +2368,7 @@ api.delete('/mindspace/v1/pages/:pageId', async (req, res) => { freedBytes: result.freedBytes, }, }); - return sendData(res, req, result); + return sendData(res, req, { ...result, quota }); } catch (error) { return mindSpaceError(res, req, error); } @@ -2339,6 +2401,143 @@ api.put('/mindspace/v1/pages/:pageId', async (req, res) => { } }); +api.post('/mindspace/v1/pages/:pageId/live-edit/bind', async (req, res) => { + if (!mindSpacePageLiveEdit || !mindSpacePages) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const sessionId = String(req.body?.session_id ?? '').trim(); + if (!sessionId) { + return sendError(res, req, 400, 'invalid_request', '缺少 session_id'); + } + const owns = await userAuth.ownsSession(req.currentUser.id, sessionId); + if (!owns) { + return sendError(res, req, 403, 'forbidden', '无权绑定该 Agent 会话'); + } + await mindSpacePages.getPage(req.currentUser.id, req.params.pageId); + const parentSessionId = String(req.body?.parent_session_id ?? '').trim() || null; + return sendData( + res, + req, + mindSpacePageLiveEdit.bindSession({ + userId: req.currentUser.id, + sessionId, + pageId: req.params.pageId, + parentSessionId, + }), + ); + } catch (error) { + return mindSpaceError(res, req, error); + } +}); + +api.post('/mindspace/v1/pages/:pageId/live-edit/fork-session', async (req, res) => { + if (!mindSpacePageEditSession || !mindSpacePages) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const parentSessionId = String(req.body?.parent_session_id ?? '').trim(); + if (!parentSessionId) { + return sendError(res, req, 400, 'invalid_request', '缺少 parent_session_id'); + } + const gate = await userAuth.canUseChat(req.currentUser.id); + if (!gate.ok) { + return sendError(res, req, 402, gate.code ?? 'insufficient_balance', gate.message); + } + return sendData( + res, + req, + await mindSpacePageEditSession.forkSession({ + userId: req.currentUser.id, + pageId: req.params.pageId, + parentSessionId, + h5ApiBase: String(req.body?.h5_api_base ?? req.body?.h5ApiBase ?? '').trim() || null, + }), + ); + } catch (error) { + if (error?.code === 'forbidden') { + return sendError(res, req, 403, error.code, error.message); + } + if (error?.code === 'invalid_request') { + return sendError(res, req, 400, error.code, error.message); + } + return mindSpaceError(res, req, error); + } +}); + +api.post('/mindspace/v1/pages/:pageId/live-edit/close-session', async (req, res) => { + if (!mindSpacePageEditSession || !mindSpacePages) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const sessionId = String(req.body?.session_id ?? '').trim(); + if (!sessionId) { + return sendError(res, req, 400, 'invalid_request', '缺少 session_id'); + } + return sendData( + res, + req, + await mindSpacePageEditSession.closeSession({ + userId: req.currentUser.id, + pageId: req.params.pageId, + sessionId, + parentSessionId: String(req.body?.parent_session_id ?? '').trim() || null, + summary: String(req.body?.summary ?? ''), + }), + ); + } catch (error) { + if (error?.code === 'forbidden' || error?.code === 'page_binding_mismatch') { + return sendError(res, req, 403, error.code, error.message); + } + if (error?.code === 'invalid_request') { + return sendError(res, req, 400, error.code, error.message); + } + return mindSpaceError(res, req, error); + } +}); + +api.get('/mindspace/v1/pages/:pageId/live-edit/revision', async (req, res) => { + if (!mindSpacePageLiveEdit || !mindSpacePages) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + return sendData( + res, + req, + await mindSpacePageLiveEdit.getRevisionSnapshot(req.currentUser.id, req.params.pageId), + ); + } catch (error) { + return mindSpaceError(res, req, error); + } +}); + +api.post('/agent/mindspace_page_patch', async (req, res) => { + if (!mindSpacePageLiveEdit) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const result = await mindSpacePageLiveEdit.applyAgentPatch(req.body ?? {}); + return res.json({ + data: { + pageId: result.page.id, + title: result.page.title, + summary: result.page.summary, + versionNo: result.page.versionNo, + updatedAt: result.page.updatedAt, + liveRevision: result.liveRevision, + }, + }); + } catch (error) { + if (error?.code === 'forbidden' || error?.code === 'page_binding_mismatch') { + return sendError(res, req, 403, error.code, error.message); + } + if (error?.code === 'invalid_request') { + return sendError(res, req, 400, error.code, error.message); + } + return mindSpaceError(res, req, error); + } +}); + api.get('/mindspace/v1/pages/:pageId/thumbnail', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { @@ -2383,11 +2582,16 @@ api.post('/mindspace/v1/pages/:pageId/thumbnail/regenerate', async (req, res) => }, { suggestCoverMeta: req.body?.use_ai - ? (input) => - suggestCoverMetaWithAi(authPool, { + ? (input) => { + if (!authPool) { + throw Object.assign(new Error('LLM 服务未就绪'), { code: 'llm_not_configured' }); + } + return suggestCoverMetaWithAi(authPool, { ...input, - encryptionKey: process.env.H5_SETTINGS_ENCRYPTION_KEY ?? process.env.TKMIND_SERVER__SECRET_KEY, - }) + encryptionKey: + process.env.H5_SETTINGS_ENCRYPTION_KEY ?? process.env.TKMIND_SERVER__SECRET_KEY, + }); + } : null, }, ); @@ -2884,6 +3088,29 @@ api.get('/sessions', async (req, res, next) => { return runHandlerChain(tkmindProxy.handlers['GET /sessions'], req, res, next); }); +api.delete('/sessions/:sessionId', async (req, res, next) => { + await userAuthReady; + if (!userAuth || !tkmindProxy) return next(); + const sessionId = req.params.sessionId; + const owns = await userAuth.ownsSession(req.currentUser.id, sessionId); + if (!owns) { + return res.status(403).json({ message: '无权访问该会话' }); + } + try { + const upstream = await tkmindProxy.apiFetch(`/sessions/${encodeURIComponent(sessionId)}`, { + method: 'DELETE', + }); + if (!upstream.ok && upstream.status !== 404) { + const text = await upstream.text().catch(() => ''); + return res.status(upstream.status).send(text || '删除会话失败'); + } + await userAuth.unregisterAgentSession(req.currentUser.id, sessionId); + return res.status(204).end(); + } catch (err) { + return res.status(500).json({ message: err instanceof Error ? err.message : '删除会话失败' }); + } +}); + api.get('/sessions/:sessionId/events', async (req, res, next) => { await userAuthReady; if (!userAuth || !tkmindProxy) return next(); @@ -2895,8 +3122,6 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => { return tkmindProxy.proxySessionEvents(req, res, sessionId); }); -attachAsrRoutes(api, { sendError, sendData }); - api.use(async (req, res, next) => { await userAuthReady; if (!userAuth || !tkmindProxy) return next(); @@ -3243,6 +3468,17 @@ async function serveUserPublishFile(req, res, next) { } if (!fs.existsSync(resolvedPath)) { + if (rest.length === 1 && rest[0].toLowerCase().endsWith('.html')) { + const publicFallback = path.resolve(targetDir, PUBLIC_ZONE_DIR, rest[0]); + if ( + publicFallback.startsWith(`${resolvedRoot}${path.sep}`) && + fs.existsSync(publicFallback) && + fs.statSync(publicFallback).isFile() + ) { + res.sendFile(publicFallback); + return; + } + } res.status(404).json({ message: '文件不存在' }); return; } diff --git a/session-reconcile.mjs b/session-reconcile.mjs index b203cc8..9442119 100644 --- a/session-reconcile.mjs +++ b/session-reconcile.mjs @@ -1,5 +1,6 @@ import path from 'node:path'; import { developerToolsFromPolicy } from './capabilities.mjs'; +import { buildSessionMemoryEntries } from './user-memory-profile.mjs'; import { buildSandboxSessionConstraints } from './user-publish.mjs'; function extensionName(config) { @@ -72,7 +73,7 @@ async function readJson(upstream) { export async function reconcileAgentSession( apiFetch, sessionId, - { workingDir, sessionPolicy, sandboxConstraints = null }, + { workingDir, sessionPolicy, sandboxConstraints = null, userContext = null }, ) { if (sessionPolicy?.unrestricted) return; @@ -143,19 +144,32 @@ export async function reconcileAgentSession( }), ); - if (sandboxConstraints?.trim()) { - const sandboxText = buildSandboxSessionConstraints({ - baseConstraints: sandboxConstraints, - developerTools: developerToolsFromPolicy(sessionPolicy), - }); - await apiFetch('/agent/harness_remember', { - method: 'POST', - body: JSON.stringify({ - sessionId, - content: sandboxText, - title: 'TKMind 用户空间沙箱', - }), - }); + const sandboxText = sandboxConstraints?.trim() + ? buildSandboxSessionConstraints({ + baseConstraints: sandboxConstraints, + developerTools: developerToolsFromPolicy(sessionPolicy), + }) + : null; + + const memoryEntries = buildSessionMemoryEntries({ + workingDir, + sessionPolicy, + sandboxConstraints: sandboxText, + userContext, + }); + + if (memoryEntries.length > 0) { + for (const entry of memoryEntries) { + if (!entry.content?.trim()) continue; + await apiFetch('/agent/harness_remember', { + method: 'POST', + body: JSON.stringify({ + sessionId, + content: entry.content, + title: entry.title, + }), + }); + } await apiFetch('/agent/harness_bootstrap', { method: 'POST', body: JSON.stringify({ diff --git a/skills-registry.mjs b/skills-registry.mjs index 2295d00..c38a5e7 100644 --- a/skills-registry.mjs +++ b/skills-registry.mjs @@ -11,9 +11,31 @@ import { const __dirname = path.dirname(fileURLToPath(import.meta.url)); export const DEFAULT_USER_SKILLS = { + web: true, + search: true, + 'form-builder': true, + 'table-viewer': true, [PUBLISH_SKILL_NAME]: false, }; +/** Optional presets for admin role configuration (creator / developer). */ +export const USER_ROLE_SKILL_PRESETS = { + user: DEFAULT_USER_SKILLS, + creator: { + ...DEFAULT_USER_SKILLS, + [PUBLISH_SKILL_NAME]: true, + kanban: true, + timeline: true, + }, + developer: { + ...DEFAULT_USER_SKILLS, + git: true, + 'diff-viewer': true, + 'code-playground': true, + 'test-runner': true, + }, +}; + export function parseSkillFrontmatter(content) { const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!match) return { name: null, description: '' }; diff --git a/skills-registry.test.mjs b/skills-registry.test.mjs index 312534b..664b2a2 100644 --- a/skills-registry.test.mjs +++ b/skills-registry.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { applySkillGrantsToCapabilities, + DEFAULT_USER_SKILLS, listPlatformSkillCatalog, normalizeSkillPatch, resolveSkillMap, @@ -39,3 +40,11 @@ test('normalizeSkillPatch ignores unknown skills', () => { const catalog = listPlatformSkillCatalog(h5Root); assert.deepEqual(normalizeSkillPatch(catalog, { unknown: true }), {}); }); + +test('DEFAULT_USER_SKILLS enables common platform skills', () => { + assert.equal(DEFAULT_USER_SKILLS.web, true); + 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['static-page-publish'], false); +}); diff --git a/skills/static-page-publish/SKILL.md b/skills/static-page-publish/SKILL.md index 8badc65..aab4cdc 100644 --- a/skills/static-page-publish/SKILL.md +++ b/skills/static-page-publish/SKILL.md @@ -25,7 +25,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 ## 推荐工作流 1. 确认需求(标题、章节、视觉风格、是否需要 hero 图) -2. `write` 创建 `.html`(需要时在同目录建 `assets/` 放主图) +2. `write` 创建 `public/页面.html`(需要时在同目录或 `assets/` 放主图) 3. 在 `` 写入 **mindspace-cover**(必须与页面主题一致,见下文) 4. 保存后服务端**立即**生成 `<文件名>.thumbnail.svg`(Agent 交互阶段即生效) 5. 按「回复格式」返回**可点击**公网链接 @@ -35,14 +35,15 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 向用户交付页面时,**必须使用 Markdown 可点击链接**: ```markdown -[马来西亚旅游攻略](https://goo.tkmind.cn/MindSpace/<用户名>/malaysia-travel-guide.html) +[马来西亚旅游攻略](https://goo.tkmind.cn/MindSpace/<用户ID>/public/malaysia-travel-guide.html) ``` 要求: - **必须**使用 `[页面标题](完整URL)`,不要只给裸 URL 或「点这里」 +- 页面写入 `public/` 时,URL **必须**包含 `/public/` 路径段(与磁盘路径一致) - 标题用页面真实主题名 -- 可同时给出相对路径(如 `malaysia-travel-guide.html`) +- 可同时给出相对路径(如 `public/malaysia-travel-guide.html`) - 说明:保存即生效,无需重启 ## 信息流预览图(必须) diff --git a/src/admin/pages/UsersPage.tsx b/src/admin/pages/UsersPage.tsx index 234edbf..c1b152d 100644 --- a/src/admin/pages/UsersPage.tsx +++ b/src/admin/pages/UsersPage.tsx @@ -12,7 +12,7 @@ export function UsersPage() { password: '', displayName: '', workspaceRoot: '', - balanceCents: 1000, + balanceCents: 500, }); const handleCreate = async (e: React.FormEvent) => { @@ -32,7 +32,7 @@ export function UsersPage() { password: '', displayName: '', workspaceRoot: '', - balanceCents: 1000, + balanceCents: 500, }); setMessage('用户已创建'); await reload(); diff --git a/src/api/client.ts b/src/api/client.ts index 613d715..6ad559d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -27,6 +27,7 @@ import type { MindSpacePage, MindSpacePageDeletePreview, MindSpacePageDeleteResult, + MindSpaceQuota, MindSpaceSaveCategory, MindSpacePublication, MindSpacePublicationStats, @@ -40,6 +41,7 @@ import type { SessionEvent, SessionListResponse, UsageRecord, + BalanceUpdate, } from '../types'; const API = '/api'; @@ -223,6 +225,9 @@ export type WechatAuthConfig = { enabled: boolean; inWechat?: boolean; scanEnabled?: boolean; + openScanEnabled?: boolean; + openAppId?: string; + oauthCallbackUrl?: string; }; export type WechatPendingProfile = { @@ -286,6 +291,12 @@ export async function getMyUsage(): Promise { return result.records ?? []; } +export async function getMyBillingLedger(limit = 30): Promise { + const query = limit ? `?limit=${encodeURIComponent(String(limit))}` : ''; + const result = await portalFetch<{ entries: LedgerEntry[] }>(`/auth/billing/ledger${query}`); + return result.entries ?? []; +} + export async function getBillingConfig(): Promise { return portalFetch('/auth/billing/config'); } @@ -326,9 +337,10 @@ export async function listMindSpaceCleanupItems(): Promise<{ export async function runMindSpaceCleanup(itemIds: string[]): Promise<{ removedCount: number; freedBytes: number; + quota?: MindSpaceQuota; }> { const result = await apiFetch<{ - data: { removedCount: number; freedBytes: number }; + data: { removedCount: number; freedBytes: number; quota?: MindSpaceQuota }; }>('/mindspace/v1/space/cleanup', { method: 'POST', body: JSON.stringify({ item_ids: itemIds }), @@ -420,6 +432,74 @@ export async function getMindSpacePage(pageId: string): Promise { return result.data; } +export async function bindMindSpacePageLiveEdit( + pageId: string, + sessionId: string, + options?: { parentSessionId?: string }, +): Promise<{ sessionId: string; pageId: string; parentSessionId?: string }> { + const result = await apiFetch<{ data: { sessionId: string; pageId: string; parentSessionId?: string } }>( + `/mindspace/v1/pages/${encodeURIComponent(pageId)}/live-edit/bind`, + { + method: 'POST', + body: JSON.stringify({ + session_id: sessionId, + ...(options?.parentSessionId ? { parent_session_id: options.parentSessionId } : {}), + }), + }, + ); + return result.data; +} + +export async function forkMindSpacePageEditSession( + pageId: string, + parentSessionId: string, + h5ApiBase?: string | null, +): Promise<{ sessionId: string; pageId: string; parentSessionId: string }> { + const result = await apiFetch<{ + data: { sessionId: string; pageId: string; parentSessionId: string }; + }>(`/mindspace/v1/pages/${encodeURIComponent(pageId)}/live-edit/fork-session`, { + method: 'POST', + body: JSON.stringify({ + parent_session_id: parentSessionId, + ...(h5ApiBase ? { h5_api_base: h5ApiBase } : {}), + }), + }); + return result.data; +} + +export async function closeMindSpacePageEditSession( + pageId: string, + input: { + sessionId: string; + parentSessionId?: string | null; + summary?: string; + }, +): Promise<{ sessionId: string; pageId: string; merged: boolean }> { + const result = await apiFetch<{ + data: { sessionId: string; pageId: string; merged: boolean }; + }>(`/mindspace/v1/pages/${encodeURIComponent(pageId)}/live-edit/close-session`, { + method: 'POST', + body: JSON.stringify({ + session_id: input.sessionId, + parent_session_id: input.parentSessionId ?? undefined, + summary: input.summary ?? '', + }), + }); + return result.data; +} + +export async function getMindSpacePageLiveRevision(pageId: string): Promise<{ + pageId: string; + versionNo: number; + updatedAt: number; + liveRevision: number; +}> { + const result = await apiFetch<{ + data: { pageId: string; versionNo: number; updatedAt: number; liveRevision: number }; + }>(`/mindspace/v1/pages/${encodeURIComponent(pageId)}/live-edit/revision`); + return result.data; +} + export async function analyzeChatSave(input: { sessionId: string; messageId: string; @@ -1224,11 +1304,22 @@ export async function getWechatBindingStatus(): Promise { } } -export async function startWechatScanLogin(returnTo?: string): Promise<{ - state: string; - qrUrl: string; - expiresInMs: number; -}> { +export type WechatScanSession = + | { + mode: 'open'; + state: string; + openAppId: string; + redirectUri: string; + expiresInMs: number; + } + | { + mode: 'mp'; + state: string; + qrUrl: string; + expiresInMs: number; + }; + +export async function startWechatScanLogin(returnTo?: string): Promise { const params = new URLSearchParams(); if (returnTo) params.set('return_to', returnTo); const query = params.toString(); @@ -1242,14 +1333,33 @@ export async function startWechatScanLogin(returnTo?: string): Promise<{ } const body = (await response.json().catch(() => null)) as { message?: string; + mode?: 'open' | 'mp'; state?: string; + openAppId?: string; + redirectUri?: string; qrUrl?: string; expiresInMs?: number; }; - if (!response.ok || !body?.state || !body?.qrUrl) { + if (!response.ok || !body?.state || !body?.mode) { + throw new ApiError(response.status, body?.message ?? '无法启动扫码登录'); + } + if (body.mode === 'open') { + if (!body.openAppId || !body.redirectUri) { + throw new ApiError(response.status, body?.message ?? '无法启动扫码登录'); + } + return { + mode: 'open', + state: body.state, + openAppId: body.openAppId, + redirectUri: body.redirectUri, + expiresInMs: body.expiresInMs ?? 600000, + }; + } + if (!body.qrUrl) { throw new ApiError(response.status, body?.message ?? '无法启动扫码登录'); } return { + mode: 'mp', state: body.state, qrUrl: body.qrUrl, expiresInMs: body.expiresInMs ?? 600000, @@ -1486,12 +1596,16 @@ export async function logout(): Promise { await fetch('/auth/logout', { method: 'POST' }); } -export async function resumeSession(sessionId: string): Promise { +export async function resumeSession( + sessionId: string, + options?: { skipReconcile?: boolean }, +): Promise { const result = await apiFetch<{ session: Session }>('/agent/resume', { method: 'POST', body: JSON.stringify({ session_id: sessionId, load_model_and_extensions: true, + ...(options?.skipReconcile ? { skip_reconcile: true } : {}), }), }); return result.session; @@ -1541,6 +1655,10 @@ export async function getSession(sessionId: string): Promise { return apiFetch(`/sessions/${sessionId}`); } +export async function deleteChatSession(sessionId: string): Promise { + await apiFetch(`/sessions/${sessionId}`, { method: 'DELETE' }); +} + export async function loadSessionDetail(sessionId: string): Promise<{ session: Session; messages: Message[]; @@ -1589,7 +1707,7 @@ export async function confirmTool( type SubscribeOptions = { pauseWhenHidden?: boolean; - onBalance?: (balanceCents: number) => void; + onBalance?: (update: BalanceUpdate) => void; }; export function subscribeSessionEvents( @@ -1649,9 +1767,14 @@ export function subscribeSessionEvents( if (!data) continue; try { if (eventName === 'balance') { - const payload = JSON.parse(data) as { balanceCents?: number }; + const payload = JSON.parse(data) as BalanceUpdate & { balanceCents?: number }; if (typeof payload.balanceCents === 'number') { - onBalance?.(payload.balanceCents); + onBalance?.({ + balanceCents: payload.balanceCents, + tokensUsed: + typeof payload.tokensUsed === 'number' ? payload.tokensUsed : undefined, + lastUsage: payload.lastUsage, + }); } continue; } diff --git a/src/components/AuthView.tsx b/src/components/AuthView.tsx index 0e49636..b6384c1 100644 --- a/src/components/AuthView.tsx +++ b/src/components/AuthView.tsx @@ -12,6 +12,7 @@ import { buildWechatAuthorizeUrl, isMobileExternalBrowser, isWechatContext, + isWechatEntryUrl, readWechatAuthError, readWechatPendingToken, } from '../utils/wechat'; @@ -51,7 +52,6 @@ export function AuthView({ const [success, setSuccess] = useState(null); const [submitting, setSubmitting] = useState(false); const [wechatEnabled, setWechatEnabled] = useState(false); - const [scanEnabled, setScanEnabled] = useState(false); const [wechatContext, setWechatContext] = useState(() => isWechatContext({ search: window.location.search }), ); @@ -76,7 +76,6 @@ export function AuthView({ if (legacyMode) return; void getWechatAuthConfig().then((config) => { setWechatEnabled(config.enabled); - setScanEnabled(Boolean(config.scanEnabled)); setWechatContext((prev) => isWechatContext({ search: window.location.search, @@ -89,8 +88,30 @@ export function AuthView({ serverInWechat: config.inWechat, }), ); + + const params = new URLSearchParams(window.location.search); + if ( + config.enabled && + isWechatEntryUrl(window.location.search) && + isWechatContext({ + search: window.location.search, + serverInWechat: config.inWechat, + }) && + !params.get('wechat_pending') && + !params.get('wechat_error') + ) { + window.location.replace( + buildWechatAuthorizeUrl({ + returnTo: returnTo ?? undefined, + utmSource: params.get('utm_source') ?? params.get('from') ?? 'wechat', + utmMedium: params.get('utm_medium') ?? undefined, + utmCampaign: params.get('utm_campaign') ?? undefined, + intent: 'login', + }), + ); + } }); - }, [legacyMode]); + }, [legacyMode, returnTo]); const isLogin = mode === 'login' && !legacyMode; const showWechatOneClick = isLogin && wechatContext && wechatEnabled; @@ -310,7 +331,6 @@ export function AuthView({ {isDesktopScanMode && ( <> setPendingToken(token)} onComplete={() => window.location.reload()} diff --git a/src/components/BalanceRing.tsx b/src/components/BalanceRing.tsx index 01b313c..fb153f7 100644 --- a/src/components/BalanceRing.tsx +++ b/src/components/BalanceRing.tsx @@ -1,23 +1,86 @@ import { useEffect, useRef, useState, type CSSProperties } from 'react'; +import { getMyBillingLedger, getMyUsage } from '../api/client'; +import type { LedgerEntry, UsageRecord } from '../types'; const RING_R = 16; const CIRC = 2 * Math.PI * RING_R; +const POPOVER_WIDTH = 260; +const RECENT_USAGE_LIMIT = 5; +const RECENT_LEDGER_LIMIT = 8; function formatYuan(cents: number) { return `¥${(cents / 100).toFixed(2)}`; } +function formatTokenCount(tokens: number) { + if (tokens >= 100_000) { + return `${(tokens / 10_000).toFixed(1).replace(/\.0$/, '')} 万`; + } + if (tokens >= 10_000) { + return `${(tokens / 10_000).toFixed(2).replace(/\.?0+$/, '')} 万`; + } + if (tokens >= 1_000) { + return `${(tokens / 1_000).toFixed(1).replace(/\.0$/, '')}k`; + } + return tokens.toLocaleString('zh-CN'); +} + +function formatTokenCompact(tokens: number) { + if (tokens >= 1_000) { + return `${(tokens / 1_000).toFixed(1).replace(/\.0$/, '')}k`; + } + return String(tokens); +} + +function formatUsageTime(ts: number) { + const date = new Date(ts); + const now = new Date(); + const isToday = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate(); + const time = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); + if (isToday) return `今天 ${time}`; + return date.toLocaleString('zh-CN', { + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function formatLedgerLabel(entry: LedgerEntry) { + if (entry.note === '新用户赠送') return '新用户赠送'; + if (entry.note?.startsWith('order:')) return '微信支付充值'; + if (entry.note === '用户自助充值') return '微信支付充值'; + if (entry.note === '管理员充值') return '管理员充值'; + if (entry.type === 'refund') return '退款'; + if (entry.type === 'adjust') return entry.note?.trim() || '账户调整'; + return entry.note?.trim() || '账户充值'; +} + type BalanceRingProps = { balanceCents: number; totalCreditCents?: number; + tokensUsed?: number; onRecharge: (force?: boolean) => void; }; -export function BalanceRing({ balanceCents, totalCreditCents, onRecharge }: BalanceRingProps) { +export function BalanceRing({ + balanceCents, + totalCreditCents, + tokensUsed = 0, + onRecharge, +}: BalanceRingProps) { const [open, setOpen] = useState(false); const [popoverStyle, setPopoverStyle] = useState({}); + const [usageRecords, setUsageRecords] = useState(null); + const [usageLoading, setUsageLoading] = useState(false); + const [usageError, setUsageError] = useState(null); + const [ledgerEntries, setLedgerEntries] = useState(null); + const [ledgerLoading, setLedgerLoading] = useState(false); + const [ledgerError, setLedgerError] = useState(null); const wrapRef = useRef(null); - const popoverWidth = 220; const total = Math.max(totalCreditCents ?? balanceCents, balanceCents, 0); const spent = Math.max(0, total - balanceCents); @@ -29,6 +92,7 @@ export function BalanceRing({ balanceCents, totalCreditCents, onRecharge }: Bala const low = balanceCents > 0 && balanceCents <= 100; const empty = balanceCents <= 0; + const showRecentUsage = low || empty; useEffect(() => { if (!open) return; @@ -48,7 +112,7 @@ export function BalanceRing({ balanceCents, totalCreditCents, onRecharge }: Bala const anchor = wrapRef.current; if (!anchor) return; const rect = anchor.getBoundingClientRect(); - const width = Math.min(popoverWidth, window.innerWidth - 24); + const width = Math.min(POPOVER_WIDTH, window.innerWidth - 24); let left = rect.left + rect.width / 2 - width / 2; left = Math.max(12, Math.min(left, window.innerWidth - width - 12)); setPopoverStyle({ @@ -68,6 +132,54 @@ export function BalanceRing({ balanceCents, totalCreditCents, onRecharge }: Bala }; }, [open]); + useEffect(() => { + if (!open) return; + let cancelled = false; + setUsageLoading(true); + setUsageError(null); + setLedgerLoading(true); + setLedgerError(null); + void getMyUsage() + .then((records) => { + if (!cancelled) setUsageRecords(records); + }) + .catch((err) => { + if (!cancelled) { + setUsageError(err instanceof Error ? err.message : '加载失败'); + } + }) + .finally(() => { + if (!cancelled) setUsageLoading(false); + }); + void getMyBillingLedger(RECENT_LEDGER_LIMIT) + .then((entries) => { + if (!cancelled) setLedgerEntries(entries); + }) + .catch((err) => { + if (!cancelled) { + setLedgerError(err instanceof Error ? err.message : '加载失败'); + } + }) + .finally(() => { + if (!cancelled) setLedgerLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, tokensUsed, balanceCents]); + + useEffect(() => { + if (open) return; + setUsageRecords(null); + setUsageError(null); + setUsageLoading(false); + setLedgerEntries(null); + setLedgerError(null); + setLedgerLoading(false); + }, [open]); + + const recentUsage = (usageRecords ?? []).slice(0, RECENT_USAGE_LIMIT); + const recentLedger = (ledgerEntries ?? []).slice(0, RECENT_LEDGER_LIMIT); const centerLabel = total <= 0 ? '—' : empty ? '0%' : `${pct}%`; const ariaLabel = total > 0 ? `账户额度,剩余 ${pct}%` : '账户额度'; @@ -122,13 +234,72 @@ export function BalanceRing({ balanceCents, totalCreditCents, onRecharge }: Bala {formatYuan(spent)}
- 累计额度 + 累计充值 {formatYuan(total)}