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)}