diff --git a/.env.example b/.env.example index 6e30f03..d2c98cb 100644 --- a/.env.example +++ b/.env.example @@ -49,12 +49,12 @@ H5_ADMIN_PASSWORD=change-me-admin H5_ACCESS_PASSWORD=change-me # 计费(Phase 2,金额单位均为人民币分) -# 默认:DeepSeek V4 Flash 公开价 × 3(随 H5_USD_CNY_RATE 换算);可用下方变量覆盖 +# 默认:DeepSeek V4 Flash 公开价 × 1(随 H5_USD_CNY_RATE 换算);可用下方变量覆盖 # H5_USE_BACKEND_COST=0 # H5_USD_CNY_RATE=7.2 -# 汇率 7.2 时默认约:输入 0.302 分/1k(¥0.00302/1k)、输出 0.605 分/1k(¥0.00605/1k) -# H5_BILL_INPUT_CENTS_PER_1K=0.302 -# H5_BILL_OUTPUT_CENTS_PER_1K=0.605 +# 汇率 7.2 时默认约:输入 0.101 分/1k(¥0.00101/1k)、输出 0.202 分/1k(¥0.00202/1k) +# H5_BILL_INPUT_CENTS_PER_1K=0.101 +# H5_BILL_OUTPUT_CENTS_PER_1K=0.202 # H5_MIN_BILL_CENTS=1 # 仅调试:H5_USE_BACKEND_COST=1 + H5_USD_CNY_RATE=7.2(上游 USD × 汇率 → 人民币分) @@ -112,6 +112,17 @@ H5_ACCESS_PASSWORD=change-me # H5_ASR_MAX_BYTES=5242880 # H5_ASR_TIMEOUT_MS=45000 +# AI 图片生成(image-designer 技能;OpenAI 兼容 /v1/images/generations) +# H5_IMAGE_GEN_ENABLED=1 +# H5_IMAGE_GEN_API_URL=https://api.openai.com/v1/images/generations +# H5_IMAGE_GEN_API_KEY=sk-... +# H5_IMAGE_GEN_MODEL=dall-e-3 +# H5_IMAGE_GEN_DEFAULT_SIZE=1024x1024 +# H5_IMAGE_GEN_DEFAULT_STYLE=vivid +# H5_IMAGE_GEN_DEFAULT_QUALITY=standard +# H5_IMAGE_GEN_BILL_CENTS=30 +# 未配置 H5_IMAGE_GEN_API_* 时,回退使用管理后台已选 LLM 的 API Key(需支持 images/generations) + # 前端构建时注入(Vite,需 VITE_ 前缀) # 工作目录:新建会话时使用,必填 VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind diff --git a/billing.mjs b/billing.mjs index 6763734..375b764 100644 --- a/billing.mjs +++ b/billing.mjs @@ -1,7 +1,7 @@ // DeepSeek V4 Flash 公开价($/1M tokens,cache-miss 输入);见 api-docs.deepseek.com export const DEEPSEEK_FLASH_INPUT_USD_PER_1M = 0.14; export const DEEPSEEK_FLASH_OUTPUT_USD_PER_1M = 0.28; -export const DEEPSEEK_BILLING_MARKUP = 3; +export const DEEPSEEK_BILLING_MARKUP = 1; function deepSeekFlashCentsPer1k(usdPer1M, usdCnyRate) { return (usdPer1M / 1000) * usdCnyRate * 100 * DEEPSEEK_BILLING_MARKUP; diff --git a/billing.test.mjs b/billing.test.mjs index f3d65bc..2206a54 100644 --- a/billing.test.mjs +++ b/billing.test.mjs @@ -14,7 +14,7 @@ const config = { minBillCents: 1, }; -test('loadBillingConfig defaults to DeepSeek Flash × 3 at USD/CNY 7.2', () => { +test('loadBillingConfig defaults to DeepSeek Flash × 1 at USD/CNY 7.2', () => { const savedInput = process.env.H5_BILL_INPUT_CENTS_PER_1K; const savedOutput = process.env.H5_BILL_OUTPUT_CENTS_PER_1K; const savedRate = process.env.H5_USD_CNY_RATE; @@ -23,8 +23,8 @@ test('loadBillingConfig defaults to DeepSeek Flash × 3 at USD/CNY 7.2', () => { process.env.H5_USD_CNY_RATE = '7.2'; try { const billing = loadBillingConfig(); - assert.ok(Math.abs(billing.inputCentsPer1k - 0.3024) < 1e-9); - assert.ok(Math.abs(billing.outputCentsPer1k - 0.6048) < 1e-9); + assert.ok(Math.abs(billing.inputCentsPer1k - 0.1008) < 1e-9); + assert.ok(Math.abs(billing.outputCentsPer1k - 0.2016) < 1e-9); } finally { if (savedInput == null) delete process.env.H5_BILL_INPUT_CENTS_PER_1K; else process.env.H5_BILL_INPUT_CENTS_PER_1K = savedInput; @@ -35,12 +35,12 @@ test('loadBillingConfig defaults to DeepSeek Flash × 3 at USD/CNY 7.2', () => { } }); -test('computeDeltaCostCents bills DeepSeek Flash × 3 defaults with min charge', () => { +test('computeDeltaCostCents bills DeepSeek Flash × 1 defaults with min charge', () => { const billing = { useBackendCost: false, usdCnyRate: 7.2, - inputCentsPer1k: 0.3024, - outputCentsPer1k: 0.6048, + inputCentsPer1k: 0.1008, + outputCentsPer1k: 0.2016, minBillCents: 1, }; const previous = { lastInputTokens: 0, lastOutputTokens: 0 }; diff --git a/chat-skills.mjs b/chat-skills.mjs index d1eb240..ed55612 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -60,6 +60,14 @@ export const CHAT_SKILL_DEFINITIONS = [ requiresPublish: true, promptKey: 'generate-page', }, + { + id: 'generate-image', + label: '生成图片', + icon: 'image', + skillName: 'image-designer', + prefillOnly: true, + promptKey: 'generate-image', + }, ]; export function buildChatSkillPrompt(promptKey, skillName) { @@ -78,6 +86,8 @@ export function buildChatSkillPrompt(promptKey, skillName) { return '请深入分析以下内容,给出结构化解读:背景、关键发现、风险或机会、以及建议下一步:'; case 'generate-page': return `请使用 ${skillName ?? PUBLISH_SKILL_NAME} 技能:在我的专属 MindSpace 发布目录生成静态 HTML 页面,并给出可公网访问的完整链接。`; + case 'generate-image': + return `请使用 ${skillName ?? 'image-designer'} 技能:以高级视觉设计师身份,根据我的需求生成高质量图片,保存到工作区并给出可点击预览链接。我的需求是:`; default: return ''; } diff --git a/chat-skills.test.mjs b/chat-skills.test.mjs index ba399af..aeb133f 100644 --- a/chat-skills.test.mjs +++ b/chat-skills.test.mjs @@ -31,6 +31,7 @@ test('filterChatSkills shows generate-page when publish is allowed', () => { test('buildChatSkillPrompt includes skill name for platform skills', () => { assert.match(buildChatSkillPrompt('web', 'web'), /请使用 web 技能/); assert.match(buildChatSkillPrompt('generate-page'), /static-page-publish/); + assert.match(buildChatSkillPrompt('generate-image', 'image-designer'), /image-designer/); }); test('prefillOnly is set for open-ended chat skills', () => { diff --git a/image-generation.mjs b/image-generation.mjs new file mode 100644 index 0000000..0803eea --- /dev/null +++ b/image-generation.mjs @@ -0,0 +1,340 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { Agent, fetch as undiciFetch } from 'undici'; +import { + decryptSecret, + normalizeApiUrl, + resolveChatCompletionsUrl, +} from './llm-providers.mjs'; +import { buildPublicUrl, resolvePublishKey } from './user-publish.mjs'; +import { isPathInsideUserWorkspace, resolveUserWorkspaceRoot } from './user-space.mjs'; + +const insecureDispatcher = new Agent({ + connect: { rejectUnauthorized: false }, +}); + +const ALLOWED_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']); +const ALLOWED_SIZES = new Set(['1024x1024', '1024x1792', '1792x1024', '512x512', '768x768']); +const ALLOWED_STYLES = new Set(['vivid', 'natural']); +const ALLOWED_QUALITIES = new Set(['standard', 'hd']); + +export function loadImageGenConfig(env = process.env) { + return { + enabled: env.H5_IMAGE_GEN_ENABLED !== '0', + apiUrl: String(env.H5_IMAGE_GEN_API_URL ?? '').trim(), + apiKey: String(env.H5_IMAGE_GEN_API_KEY ?? '').trim(), + model: String(env.H5_IMAGE_GEN_MODEL ?? 'dall-e-3').trim() || 'dall-e-3', + defaultSize: String(env.H5_IMAGE_GEN_DEFAULT_SIZE ?? '1024x1024').trim() || '1024x1024', + defaultStyle: String(env.H5_IMAGE_GEN_DEFAULT_STYLE ?? 'vivid').trim() || 'vivid', + defaultQuality: String(env.H5_IMAGE_GEN_DEFAULT_QUALITY ?? 'standard').trim() || 'standard', + billCents: Number(env.H5_IMAGE_GEN_BILL_CENTS ?? 30), + }; +} + +export function resolveImagesGenerationsUrl(apiUrl) { + const configured = normalizeApiUrl(apiUrl); + if (!configured) return ''; + if (configured.endsWith('/images/generations')) return configured; + if (configured.endsWith('/chat/completions')) { + return configured.replace(/\/chat\/completions$/, '/images/generations'); + } + if (configured.endsWith('/v1')) return `${configured}/images/generations`; + return `${configured}/v1/images/generations`; +} + +function resolveEncryptionKey(explicitKey) { + return ( + explicitKey ?? + process.env.H5_SETTINGS_ENCRYPTION_KEY ?? + process.env.TKMIND_SERVER__SECRET_KEY ?? + 'local-dev-secret' + ); +} + +function slugifyFilename(value) { + return String(value ?? '') + .normalize('NFKC') + .trim() + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); +} + +export function normalizeImageOutputPath(rawPath, prompt) { + const cleaned = String(rawPath ?? '') + .normalize('NFKC') + .trim() + .replace(/^\.?\/+/, '') + .replace(/\\/g, '/'); + const fallback = `assets/${slugifyFilename(prompt) || 'generated-image'}.png`; + const relative = cleaned || fallback; + if ( + !relative || + relative.includes('..') || + relative.startsWith('/') || + relative.includes('\0') + ) { + throw Object.assign(new Error('输出路径无效'), { code: 'invalid_output_path' }); + } + const ext = path.extname(relative).toLowerCase(); + if (!ALLOWED_EXTENSIONS.has(ext)) { + throw Object.assign(new Error('仅支持 png / jpg / webp 输出'), { code: 'invalid_output_path' }); + } + return relative; +} + +export function resolveWorkspaceOutputPath(workspaceRoot, relativePath) { + const absolute = path.resolve(workspaceRoot, relativePath); + if (!isPathInsideUserWorkspace(workspaceRoot, absolute)) { + throw Object.assign(new Error('输出路径必须位于当前用户工作区内'), { code: 'invalid_output_path' }); + } + return absolute; +} + +export function composeDesignerPrompt({ prompt, styleNotes, aspectRatio, brandColors, subject }) { + const parts = [ + 'Professional commercial visual design, high-end art direction, polished composition, clean lighting, production-ready quality.', + ]; + if (subject) parts.push(`Subject: ${subject}.`); + if (styleNotes) parts.push(`Style direction: ${styleNotes}.`); + if (aspectRatio) parts.push(`Aspect ratio intent: ${aspectRatio}.`); + if (brandColors) parts.push(`Brand palette: ${brandColors}.`); + parts.push(`Creative brief: ${String(prompt ?? '').trim()}`); + parts.push('Avoid text overlays, watermarks, logos, distorted anatomy, blurry details, or cluttered backgrounds unless explicitly requested.'); + return parts.join(' '); +} + +function parseImagePayload(data) { + const item = data?.data?.[0]; + if (!item) return null; + if (typeof item.b64_json === 'string' && item.b64_json.trim()) { + return { kind: 'base64', value: item.b64_json.trim(), revisedPrompt: item.revised_prompt ?? null }; + } + if (typeof item.url === 'string' && item.url.trim()) { + return { kind: 'url', value: item.url.trim(), revisedPrompt: item.revised_prompt ?? null }; + } + return null; +} + +async function loadProviderCredentials(pool, encryptionKey) { + const [rows] = await pool.query( + `SELECT api_url, api_key_ciphertext, api_key_iv, api_key_tag, relay_provider + FROM h5_llm_provider_keys + WHERE is_selected = 1 AND status = 'active' + LIMIT 1`, + ); + const row = rows[0]; + if (!row) { + throw Object.assign(new Error('请先在管理后台配置并启用 LLM'), { code: 'image_gen_not_configured' }); + } + let apiKey; + try { + apiKey = decryptSecret( + { + ciphertext: row.api_key_ciphertext, + iv: row.api_key_iv, + tag: row.api_key_tag, + }, + resolveEncryptionKey(encryptionKey), + ); + } catch { + throw Object.assign(new Error('LLM 密钥解密失败,请重新配置'), { code: 'image_gen_not_configured' }); + } + return { + apiUrl: normalizeApiUrl(row.api_url), + apiKey, + relayProvider: row.relay_provider ?? null, + }; +} + +async function resolveProvider(pool, encryptionKey) { + const config = loadImageGenConfig(); + if (config.apiUrl && config.apiKey) { + return { + url: resolveImagesGenerationsUrl(config.apiUrl), + apiKey: config.apiKey, + model: config.model, + relayProvider: null, + }; + } + const provider = await loadProviderCredentials(pool, encryptionKey); + const chatUrl = resolveChatCompletionsUrl(provider.apiUrl); + return { + url: resolveImagesGenerationsUrl(chatUrl || provider.apiUrl), + apiKey: provider.apiKey, + model: config.model, + relayProvider: provider.relayProvider, + }; +} + +async function downloadImageBuffer(payload) { + if (payload.kind === 'base64') { + return Buffer.from(payload.value, 'base64'); + } + const response = await undiciFetch(payload.value, { + dispatcher: payload.value.startsWith('https://') ? insecureDispatcher : undefined, + }); + if (!response.ok) { + throw Object.assign(new Error(`下载生成图片失败 (${response.status})`), { code: 'image_gen_failed' }); + } + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); +} + +function mimeTypeForPath(relativePath) { + switch (path.extname(relativePath).toLowerCase()) { + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.webp': + return 'image/webp'; + default: + return 'image/png'; + } +} + +export function buildImageGenerateCurlExample({ h5ApiBase, sessionId = '<当前会话ID>' } = {}) { + const base = String(h5ApiBase ?? 'http://127.0.0.1:8081').replace(/\/$/, ''); + return [ + '```bash', + `curl -sS -X POST '${base}/api/agent/generate_image' \\`, + " -H 'Content-Type: application/json' \\", + ` -d '{"session_id":"${sessionId}","prompt":"A premium travel poster for Malaysia, cinematic sunset, editorial photography","output_path":"assets/malaysia-hero.png","style_notes":"modern editorial, warm palette","aspect_ratio":"3:4 portrait"}'`, + '```', + ].join('\n'); +} + +export function createImageGenerationService({ + pool, + h5Root, + encryptionKey, + resolveUserIdForAgentSession, + publicBaseUrl, +}) { + async function generateForAgentSession(input = {}) { + const config = loadImageGenConfig(); + if (!config.enabled) { + throw Object.assign(new Error('图片生成未启用'), { code: 'image_gen_unavailable' }); + } + + const sessionId = String(input.sessionId ?? input.session_id ?? '').trim(); + const prompt = String(input.prompt ?? '').trim(); + if (!sessionId) { + throw Object.assign(new Error('缺少 session_id'), { code: 'invalid_request' }); + } + if (!prompt) { + throw Object.assign(new Error('缺少 prompt'), { code: 'invalid_request' }); + } + if (prompt.length > 4000) { + throw Object.assign(new Error('prompt 过长'), { code: 'invalid_request' }); + } + + const userId = await resolveUserIdForAgentSession(sessionId); + if (!userId) { + throw Object.assign(new Error('无效的 Agent 会话'), { code: 'forbidden' }); + } + + const user = { id: userId }; + + const relativePath = normalizeImageOutputPath(input.outputPath ?? input.output_path, prompt); + const workspaceRoot = resolveUserWorkspaceRoot(h5Root, user); + const absolutePath = resolveWorkspaceOutputPath(workspaceRoot, relativePath); + + const size = ALLOWED_SIZES.has(String(input.size ?? '').trim()) + ? String(input.size).trim() + : config.defaultSize; + const style = ALLOWED_STYLES.has(String(input.style ?? '').trim()) + ? String(input.style).trim() + : config.defaultStyle; + const quality = ALLOWED_QUALITIES.has(String(input.quality ?? '').trim()) + ? String(input.quality).trim() + : config.defaultQuality; + + const designerPrompt = composeDesignerPrompt({ + prompt, + styleNotes: input.styleNotes ?? input.style_notes, + aspectRatio: input.aspectRatio ?? input.aspect_ratio, + brandColors: input.brandColors ?? input.brand_colors, + subject: input.subject, + }); + + const provider = await resolveProvider(pool, encryptionKey); + if (!provider.url) { + throw Object.assign(new Error('图片生成 API 地址无效'), { code: 'image_gen_not_configured' }); + } + + let upstream; + try { + upstream = await undiciFetch(provider.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${provider.apiKey}`, + }, + body: JSON.stringify({ + model: provider.model, + prompt: designerPrompt, + n: 1, + size, + style, + quality, + response_format: 'b64_json', + ...(provider.relayProvider ? { provider: provider.relayProvider } : {}), + }), + dispatcher: provider.url.startsWith('https://') ? insecureDispatcher : undefined, + }); + } catch (error) { + throw Object.assign( + new Error(`图片生成失败:${error instanceof Error ? error.message : '网络错误'}`), + { code: 'image_gen_failed' }, + ); + } + + const text = await upstream.text().catch(() => ''); + if (!upstream.ok) { + throw Object.assign(new Error(`图片生成失败 (${upstream.status})`), { + code: 'image_gen_failed', + details: text.slice(0, 500), + }); + } + + let data; + try { + data = JSON.parse(text); + } catch { + throw Object.assign(new Error('图片生成响应不是 JSON'), { code: 'image_gen_failed' }); + } + + const payload = parseImagePayload(data); + if (!payload) { + throw Object.assign(new Error('图片生成 API 未返回图片'), { code: 'image_gen_failed' }); + } + + const buffer = await downloadImageBuffer(payload); + if (buffer.length === 0) { + throw Object.assign(new Error('图片内容为空'), { code: 'image_gen_failed' }); + } + + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, buffer); + + const publishKey = resolvePublishKey(user); + const publicUrl = buildPublicUrl(publicBaseUrl, publishKey, relativePath); + + return { + relativePath, + absolutePath, + publicUrl, + mimeType: mimeTypeForPath(relativePath), + size, + style, + quality, + revisedPrompt: payload.revisedPrompt, + prompt, + designerPrompt, + }; + } + + return { generateForAgentSession }; +} diff --git a/image-generation.test.mjs b/image-generation.test.mjs new file mode 100644 index 0000000..6c2df4f --- /dev/null +++ b/image-generation.test.mjs @@ -0,0 +1,68 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildImageGenerateCurlExample, + composeDesignerPrompt, + loadImageGenConfig, + normalizeImageOutputPath, + resolveImagesGenerationsUrl, +} from './image-generation.mjs'; + +test('resolveImagesGenerationsUrl derives from chat completions base', () => { + assert.equal( + resolveImagesGenerationsUrl('https://api.openai.com/v1/chat/completions'), + 'https://api.openai.com/v1/images/generations', + ); + assert.equal( + resolveImagesGenerationsUrl('https://api.openai.com/v1/images/generations'), + 'https://api.openai.com/v1/images/generations', + ); +}); + +test('normalizeImageOutputPath defaults to assets slug png', () => { + assert.equal( + normalizeImageOutputPath('', '马来西亚旅行海报'), + 'assets/马来西亚旅行海报.png', + ); + assert.equal(normalizeImageOutputPath('public/hero.webp', 'x'), 'public/hero.webp'); +}); + +test('normalizeImageOutputPath rejects traversal and unsupported extensions', () => { + assert.throws( + () => normalizeImageOutputPath('../secret.png', 'x'), + (error) => error.code === 'invalid_output_path', + ); + assert.throws( + () => normalizeImageOutputPath('assets/hero.svg', 'x'), + (error) => error.code === 'invalid_output_path', + ); +}); + +test('composeDesignerPrompt merges creative brief and guardrails', () => { + const prompt = composeDesignerPrompt({ + prompt: 'premium coffee brand poster', + styleNotes: 'minimal, warm beige palette', + aspectRatio: '3:4', + brandColors: '#c8a27d', + subject: 'latte art', + }); + assert.match(prompt, /premium coffee brand poster/); + assert.match(prompt, /minimal, warm beige palette/); + assert.match(prompt, /Avoid text overlays/); +}); + +test('buildImageGenerateCurlExample includes agent endpoint', () => { + const example = buildImageGenerateCurlExample({ + h5ApiBase: 'http://127.0.0.1:8081', + sessionId: 'sess-1', + }); + assert.match(example, /\/api\/agent\/generate_image/); + assert.match(example, /sess-1/); +}); + +test('loadImageGenConfig defaults', () => { + const config = loadImageGenConfig({}); + assert.equal(config.enabled, true); + assert.equal(config.model, 'dall-e-3'); + assert.equal(config.defaultSize, '1024x1024'); +}); diff --git a/mindspace-chat-save.mjs b/mindspace-chat-save.mjs index 46e8618..88f1dcf 100644 --- a/mindspace-chat-save.mjs +++ b/mindspace-chat-save.mjs @@ -1,6 +1,11 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID } from './user-publish.mjs'; +import { + migrateUserPublishDir, + PUBLISH_ROOT_DIR, + PUBLISH_KEY_UUID, + resolveLegacyPublishDir, +} from './user-publish.mjs'; import { workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs'; const URL_PATTERN = @@ -23,11 +28,10 @@ export function extractStaticPageLinks(content, { userId, username } = {}) { for (const match of text.matchAll(URL_PATTERN)) { const owner = decodePathSegment(match[1]).toLowerCase(); const relativePath = decodePathSegment(match[2]); - if (normalizedUserId) { - if (owner !== normalizedUserId) continue; - } else if (normalizedUsername && owner !== normalizedUsername) { - continue; - } + const ownedByUser = + (normalizedUserId && owner === normalizedUserId) || + (normalizedUsername && owner === normalizedUsername); + if ((normalizedUserId || normalizedUsername) && !ownedByUser) continue; const key = `${owner}/${relativePath}`; if (seen.has(key)) continue; seen.add(key); @@ -114,7 +118,7 @@ async function walkPublishHtmlByBasename(publishRoot, basename, maxDepth = 6, de return null; } -export async function findPublishHtml(h5Root, userId, relativePath) { +async function findPublishHtmlInRoot(publishRoot, relativePath, { readFile = fs.readFile } = {}) { const normalized = String(relativePath ?? '').replace(/^\/+/, ''); const basename = path.basename(normalized); const candidates = [ @@ -124,22 +128,59 @@ export async function findPublishHtml(h5Root, userId, relativePath) { ].filter((value, index, list) => value && list.indexOf(value) === index); for (const candidate of candidates) { + const absolute = path.resolve(publishRoot, candidate); + if (absolute !== publishRoot && !absolute.startsWith(`${publishRoot}${path.sep}`)) continue; + if (!absolute.toLowerCase().endsWith('.html')) continue; try { - return await readPublishHtml(h5Root, userId, candidate); + const content = await readFile(absolute, 'utf8'); + if (!content.trim()) continue; + const resolvedRelativePath = path.relative(publishRoot, absolute).split(path.sep).join('/'); + return { + absolute, + content, + relativePath: resolvedRelativePath, + filename: path.basename(resolvedRelativePath), + }; } catch { // try next candidate } } - const publishRoot = path.resolve( - h5Root, - PUBLISH_ROOT_DIR, - String(userId ?? '').trim().toLowerCase(), - ); const absolute = await walkPublishHtmlByBasename(publishRoot, basename); if (absolute) { const resolvedRelativePath = path.relative(publishRoot, absolute).split(path.sep).join('/'); - return readPublishHtml(h5Root, userId, resolvedRelativePath); + const content = await readFile(absolute, 'utf8'); + if (!content.trim()) { + throw Object.assign(new Error('页面内容为空'), { code: 'empty_page_content' }); + } + return { + absolute, + content, + relativePath: resolvedRelativePath, + filename: path.basename(resolvedRelativePath), + }; + } + + return null; +} + +export async function findPublishHtml(h5Root, userId, relativePath, { username } = {}) { + const key = String(userId ?? '').trim().toLowerCase(); + if (username && key) { + migrateUserPublishDir(h5Root, { id: key, username }); + } + + const publishRoot = path.resolve(h5Root, PUBLISH_ROOT_DIR, key); + const primary = await findPublishHtmlInRoot(publishRoot, relativePath); + if (primary) return primary; + + const legacyRoot = username ? resolveLegacyPublishDir(h5Root, { username }) : null; + if (legacyRoot && path.resolve(legacyRoot) !== publishRoot) { + const legacy = await findPublishHtmlInRoot(legacyRoot, relativePath); + if (legacy) { + migrateUserPublishDir(h5Root, { id: key, username }); + return readPublishHtml(h5Root, userId, legacy.relativePath); + } } throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' }); @@ -215,8 +256,9 @@ function dedupeDuplicatedFilename(filename) { if (half >= 2 && base.slice(0, half) === base.slice(half)) { base = base.slice(0, half); } - base = base.replace(/(.+?)\1+/g, '$1'); + base = base.replace(/(.{4,}?)\1+/g, '$1'); base = base.replace(/-v-v(\d+)/g, '-v$1'); + base = base.replace(/-v(\d)\1+$/g, '-v$1'); return `${base}.html`; } @@ -288,23 +330,123 @@ export function analyzeChatMessageForSave({ }; } -export async function resolveStaticHtmlContent(analysis) { +function uniqueRelativePaths(relativePath) { + const basename = path.basename(String(relativePath ?? '')); + return [ + String(relativePath ?? '').replace(/^\/+/, ''), + String(relativePath ?? '') + .replace(/^\/+/, '') + .replace(/^public\//, ''), + `public/${basename}`, + basename, + ].filter((value, index, list) => value && list.indexOf(value) === index); +} + +function buildPublishHtmlFetchUrls({ userId, link, publicBaseUrl }) { + const urls = new Set(); + const base = String(publicBaseUrl ?? '').replace(/\/$/, ''); + if (base && link) { + for (const rel of uniqueRelativePaths(link.relativePath)) { + const asset = buildWorkspaceAssetUrl(userId, rel); + if (asset) urls.add(`${base}${asset}`); + } + } + if (link?.publicUrl) urls.add(link.publicUrl); + return [...urls]; +} + +async function mirrorFetchedPublishHtml(h5Root, userId, relativePath, content, { username } = {}) { + try { + if (username) { + migrateUserPublishDir(h5Root, { id: userId, username }); + } + const absolute = resolvePublishHtmlAbsolutePath(h5Root, userId, relativePath); + await fs.mkdir(path.dirname(absolute), { recursive: true }); + await fs.writeFile(absolute, content, 'utf8'); + return absolute; + } catch { + return null; + } +} + +export async function fetchPublishHtmlFallback( + analysis, + { publicBaseUrl, fetchImpl = fetch } = {}, +) { + if (analysis.contentMode !== 'static_html' || !analysis.selectedLink) return null; + const candidatePaths = uniqueRelativePaths(analysis.selectedLink.relativePath); + const urls = buildPublishHtmlFetchUrls({ + userId: analysis.userId, + link: analysis.selectedLink, + publicBaseUrl, + }); + for (const url of urls) { + try { + const response = await fetchImpl(url, { redirect: 'follow' }); + if (!response.ok) continue; + const contentType = response.headers.get('content-type') ?? ''; + if (contentType.includes('json')) continue; + const content = await response.text(); + if (!content.trim() || !/ { + const content = 'https://goo.tkmind.cn/MindSpace/john/report.html'; + const links = extractStaticPageLinks(content, { userId: USER_ID, username: 'john' }); + assert.equal(links.length, 1); + assert.equal(links[0].relativePath, 'report.html'); +}); + test('analyzeChatMessageForSave prefers static html mode', () => { const analysis = analyzeChatMessageForSave({ content: `链接 https://g2.tkmind.cn/MindSpace/${USER_ID}/report.html`, @@ -96,6 +105,42 @@ test('buildChatSavePreviewUrls use local api routes', () => { ); }); +test('findPublishHtml resolves html stored only in legacy username directory', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-chat-save-legacy-')); + const legacyDir = path.join(root, 'MindSpace', 'john', 'public'); + await fs.mkdir(legacyDir, { recursive: true }); + await fs.writeFile( + path.join(legacyDir, 'legacy-only.html'), + 'Legacylegacy', + 'utf8', + ); + const loaded = await findPublishHtml(root, USER_ID, 'public/legacy-only.html', { username: 'john' }); + assert.equal(loaded.relativePath, 'public/legacy-only.html'); + assert.match(loaded.content, /Legacy/); + const migrated = path.join(root, 'MindSpace', USER_ID, 'public', 'legacy-only.html'); + assert.ok(await fs.stat(migrated).then(() => true).catch(() => false)); +}); + +test('resolveChatSaveAnalysis resolves uuid link when html exists only in legacy username dir', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-chat-save-uuid-link-')); + const legacyDir = path.join(root, 'MindSpace', 'john', 'public'); + await fs.mkdir(legacyDir, { recursive: true }); + await fs.writeFile( + path.join(legacyDir, 'report.html'), + 'Reportok', + 'utf8', + ); + const content = `链接 https://g2.tkmind.cn/MindSpace/${USER_ID}/public/report.html`; + const result = await resolveChatSaveAnalysis({ + content, + userId: USER_ID, + username: 'john', + h5Root: root, + }); + assert.equal(result.analysis.contentMode, 'static_html'); + assert.equal(result.resolvedHtml?.relativePath, 'public/report.html'); +}); + test('findPublishHtml resolves nested public html by basename', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-chat-save-')); const publishDir = path.join(root, 'MindSpace', USER_ID, 'public'); @@ -116,6 +161,11 @@ test('injectHtmlBaseHref adds base tag for relative assets', () => { assert.match(next, /=6.9.0" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -802,6 +810,80 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -901,9 +983,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -918,9 +997,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -935,9 +1011,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -952,9 +1025,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -969,9 +1039,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -986,9 +1053,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1003,9 +1067,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1020,9 +1081,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1037,9 +1095,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1054,9 +1109,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1071,9 +1123,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1088,9 +1137,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1105,9 +1151,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1363,6 +1406,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argon2": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz", + "integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "cross-env": "^10.0.0", + "node-addon-api": "^8.5.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -1568,6 +1627,15 @@ "wrap-ansi": "^6.2.0" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1629,6 +1697,37 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2044,6 +2143,15 @@ "is-property": "^1.0.2" } }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2287,6 +2395,12 @@ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2538,6 +2652,26 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz", + "integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-releases": { "version": "2.0.47", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", @@ -2626,6 +2760,15 @@ "node": ">=8" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -2840,6 +2983,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/redis": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", + "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.1", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3020,6 +3180,27 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -3437,6 +3618,21 @@ "node": ">=12" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", diff --git a/package.json b/package.json index fe045d9..2f25274 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "check:plaza": "node scripts/check-plaza-local.mjs", "setup:plaza-local": "node scripts/setup-plaza-local.mjs", "setup:plaza-tunnel": "bash scripts/install-plaza-tunnel.sh", + "setup:prod-services": "bash scripts/install-prod-services.sh", "setup:plaza-tls": "node scripts/plaza-local-tls.mjs", "dev:vite": "vite", "dev:server": "node server.mjs", @@ -25,7 +26,7 @@ "seed:plaza-covers": "node scripts/seed-plaza-covers.mjs", "enrich:plaza": "node scripts/enrich-plaza-posts.mjs", "build": "vite build", - "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs", + "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs image-generation.test.mjs", "test: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", @@ -41,6 +42,7 @@ "deploy:plaza-105": "bash scripts/deploy-plaza-105.sh" }, "dependencies": { + "argon2": "^0.44.0", "debug": "^4.4.3", "express": "^4.21.2", "http-proxy-middleware": "^3.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a43a717..2cd1321 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + argon2: + specifier: ^0.44.0 + version: 0.44.0 debug: specifier: ^4.4.3 version: 4.4.3 @@ -146,6 +149,9 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -318,6 +324,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@phc/format@1.0.0': + resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} + engines: {node: '>=10'} + '@redis/bloom@1.2.0': resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} peerDependencies: @@ -541,6 +551,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + argon2@0.44.0: + resolution: {integrity: sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==} + engines: {node: '>=16.17.0'} + array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} @@ -625,6 +639,15 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -855,6 +878,9 @@ packages: is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -943,6 +969,14 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + node-addon-api@8.8.0: + resolution: {integrity: sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==} + engines: {node: ^18 || ^20 || >= 21} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-releases@2.0.47: resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} engines: {node: '>=18'} @@ -975,6 +1009,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + path-to-regexp@0.1.13: resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} @@ -1096,6 +1134,14 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -1225,6 +1271,11 @@ packages: which-module@2.0.1: resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -1372,6 +1423,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@epic-web/invariant@1.0.0': {} + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -1469,6 +1522,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@phc/format@1.0.0': {} + '@redis/bloom@1.2.0(@redis/client@1.6.1)': dependencies: '@redis/client': 1.6.1 @@ -1640,6 +1695,13 @@ snapshots: dependencies: color-convert: 2.0.1 + argon2@0.44.0: + dependencies: + '@phc/format': 1.0.0 + cross-env: 10.1.0 + node-addon-api: 8.8.0 + node-gyp-build: 4.8.4 + array-flatten@1.1.1: {} aws-ssl-profiles@1.1.2: {} @@ -1723,6 +1785,17 @@ snapshots: cookie@1.1.1: {} + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + csstype@3.2.3: {} debug@2.6.9: @@ -1980,6 +2053,8 @@ snapshots: is-property@1.0.2: {} + isexe@2.0.0: {} + js-tokens@4.0.0: {} jsesc@3.1.0: {} @@ -2045,6 +2120,10 @@ snapshots: negotiator@0.6.3: {} + node-addon-api@8.8.0: {} + + node-gyp-build@4.8.4: {} + node-releases@2.0.47: {} object-inspect@1.13.4: {} @@ -2067,6 +2146,8 @@ snapshots: path-exists@4.0.0: {} + path-key@3.1.1: {} + path-to-regexp@0.1.13: {} picocolors@1.1.1: {} @@ -2217,6 +2298,12 @@ snapshots: setprototypeof@1.2.0: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -2311,6 +2398,10 @@ snapshots: which-module@2.0.1: {} + which@2.0.2: + dependencies: + isexe: 2.0.0 + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..03d61c0 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + argon2: set this to true or false diff --git a/public/plaza-covers/business-1bt5ajr.jpg b/public/plaza-covers/business-1bt5ajr.jpg new file mode 100644 index 0000000..80de088 Binary files /dev/null and b/public/plaza-covers/business-1bt5ajr.jpg differ diff --git a/public/plaza-covers/business-1dpghh8.jpg b/public/plaza-covers/business-1dpghh8.jpg new file mode 100644 index 0000000..76d994a Binary files /dev/null and b/public/plaza-covers/business-1dpghh8.jpg differ diff --git a/public/plaza-covers/business-30-84q88u.jpg b/public/plaza-covers/business-30-84q88u.jpg new file mode 100644 index 0000000..a22930d Binary files /dev/null and b/public/plaza-covers/business-30-84q88u.jpg differ diff --git a/public/plaza-covers/business-e09bef.jpg b/public/plaza-covers/business-e09bef.jpg new file mode 100644 index 0000000..80de088 Binary files /dev/null and b/public/plaza-covers/business-e09bef.jpg differ diff --git a/public/plaza-covers/business-nfc9ph.jpg b/public/plaza-covers/business-nfc9ph.jpg new file mode 100644 index 0000000..27b08b0 Binary files /dev/null and b/public/plaza-covers/business-nfc9ph.jpg differ diff --git a/public/plaza-covers/business-sop-33nw2t.jpg b/public/plaza-covers/business-sop-33nw2t.jpg new file mode 100644 index 0000000..27b08b0 Binary files /dev/null and b/public/plaza-covers/business-sop-33nw2t.jpg differ diff --git a/public/plaza-covers/business-zfht2z.jpg b/public/plaza-covers/business-zfht2z.jpg new file mode 100644 index 0000000..2da05d3 Binary files /dev/null and b/public/plaza-covers/business-zfht2z.jpg differ diff --git a/public/plaza-covers/business-宠物洗护上门服务-sop-手册.jpg b/public/plaza-covers/business-宠物洗护上门服务-sop-手册.jpg new file mode 100644 index 0000000..27b08b0 Binary files /dev/null and b/public/plaza-covers/business-宠物洗护上门服务-sop-手册.jpg differ diff --git a/public/plaza-covers/business-日式美甲沙龙会员体系设计.jpg b/public/plaza-covers/business-日式美甲沙龙会员体系设计.jpg new file mode 100644 index 0000000..80de088 Binary files /dev/null and b/public/plaza-covers/business-日式美甲沙龙会员体系设计.jpg differ diff --git a/public/plaza-covers/business-民宿旺季定价与房源包装指南.jpg b/public/plaza-covers/business-民宿旺季定价与房源包装指南.jpg new file mode 100644 index 0000000..2da05d3 Binary files /dev/null and b/public/plaza-covers/business-民宿旺季定价与房源包装指南.jpg differ diff --git a/public/plaza-covers/business-独立书店文创周边选品策略.jpg b/public/plaza-covers/business-独立书店文创周边选品策略.jpg new file mode 100644 index 0000000..76d994a Binary files /dev/null and b/public/plaza-covers/business-独立书店文创周边选品策略.jpg differ diff --git a/public/plaza-covers/business-社区健身房私教转化漏斗优化.jpg b/public/plaza-covers/business-社区健身房私教转化漏斗优化.jpg new file mode 100644 index 0000000..27b08b0 Binary files /dev/null and b/public/plaza-covers/business-社区健身房私教转化漏斗优化.jpg differ diff --git a/public/plaza-covers/business-精酿啤酒馆开业-30-天数据复盘.jpg b/public/plaza-covers/business-精酿啤酒馆开业-30-天数据复盘.jpg new file mode 100644 index 0000000..a22930d Binary files /dev/null and b/public/plaza-covers/business-精酿啤酒馆开业-30-天数据复盘.jpg differ diff --git a/public/plaza-covers/business-轻食沙拉店午餐高峰运营方案.jpg b/public/plaza-covers/business-轻食沙拉店午餐高峰运营方案.jpg new file mode 100644 index 0000000..80de088 Binary files /dev/null and b/public/plaza-covers/business-轻食沙拉店午餐高峰运营方案.jpg differ diff --git a/public/plaza-covers/creative-17bb2q5.jpg b/public/plaza-covers/creative-17bb2q5.jpg new file mode 100644 index 0000000..3330d02 Binary files /dev/null and b/public/plaza-covers/creative-17bb2q5.jpg differ diff --git a/public/plaza-covers/creative-1a69un3.jpg b/public/plaza-covers/creative-1a69un3.jpg new file mode 100644 index 0000000..dbf1769 Binary files /dev/null and b/public/plaza-covers/creative-1a69un3.jpg differ diff --git a/public/plaza-covers/creative-1wuxajv.jpg b/public/plaza-covers/creative-1wuxajv.jpg new file mode 100644 index 0000000..1e93c59 Binary files /dev/null and b/public/plaza-covers/creative-1wuxajv.jpg differ diff --git a/public/plaza-covers/creative-ai-1m59cjr.jpg b/public/plaza-covers/creative-ai-1m59cjr.jpg new file mode 100644 index 0000000..dbf1769 Binary files /dev/null and b/public/plaza-covers/creative-ai-1m59cjr.jpg differ diff --git a/public/plaza-covers/creative-ceramics-1o5zmj5.jpg b/public/plaza-covers/creative-ceramics-1o5zmj5.jpg new file mode 100644 index 0000000..495339d Binary files /dev/null and b/public/plaza-covers/creative-ceramics-1o5zmj5.jpg differ diff --git a/public/plaza-covers/creative-ui-200-icons-12lpy7p.jpg b/public/plaza-covers/creative-ui-200-icons-12lpy7p.jpg new file mode 100644 index 0000000..3330d02 Binary files /dev/null and b/public/plaza-covers/creative-ui-200-icons-12lpy7p.jpg differ diff --git a/public/plaza-covers/creative-vis-tkrnir.jpg b/public/plaza-covers/creative-vis-tkrnir.jpg new file mode 100644 index 0000000..1e93c59 Binary files /dev/null and b/public/plaza-covers/creative-vis-tkrnir.jpg differ diff --git a/public/plaza-covers/creative-国潮茶饮品牌-vis-视觉系统.jpg b/public/plaza-covers/creative-国潮茶饮品牌-vis-视觉系统.jpg new file mode 100644 index 0000000..1e93c59 Binary files /dev/null and b/public/plaza-covers/creative-国潮茶饮品牌-vis-视觉系统.jpg differ diff --git a/public/plaza-covers/creative-城市夜景长曝光摄影系列.jpg b/public/plaza-covers/creative-城市夜景长曝光摄影系列.jpg new file mode 100644 index 0000000..3330d02 Binary files /dev/null and b/public/plaza-covers/creative-城市夜景长曝光摄影系列.jpg differ diff --git a/public/plaza-covers/creative-复古胶片人像调色预设包.jpg b/public/plaza-covers/creative-复古胶片人像调色预设包.jpg new file mode 100644 index 0000000..dbf1769 Binary files /dev/null and b/public/plaza-covers/creative-复古胶片人像调色预设包.jpg differ diff --git a/public/plaza-covers/creative-手作-ceramics-工作室品牌摄影.jpg b/public/plaza-covers/creative-手作-ceramics-工作室品牌摄影.jpg new file mode 100644 index 0000000..495339d Binary files /dev/null and b/public/plaza-covers/creative-手作-ceramics-工作室品牌摄影.jpg differ diff --git a/public/plaza-covers/creative-极简-ui-图标集-200-icons.jpg b/public/plaza-covers/creative-极简-ui-图标集-200-icons.jpg new file mode 100644 index 0000000..3330d02 Binary files /dev/null and b/public/plaza-covers/creative-极简-ui-图标集-200-icons.jpg differ diff --git a/public/plaza-covers/creative-水墨风-ai-插画实验合集.jpg b/public/plaza-covers/creative-水墨风-ai-插画实验合集.jpg new file mode 100644 index 0000000..dbf1769 Binary files /dev/null and b/public/plaza-covers/creative-水墨风-ai-插画实验合集.jpg differ diff --git a/public/plaza-covers/creative-科幻短片-归途-分镜脚本.jpg b/public/plaza-covers/creative-科幻短片-归途-分镜脚本.jpg new file mode 100644 index 0000000..1e93c59 Binary files /dev/null and b/public/plaza-covers/creative-科幻短片-归途-分镜脚本.jpg differ diff --git a/public/plaza-covers/data-analysis-1293jxp.jpg b/public/plaza-covers/data-analysis-1293jxp.jpg new file mode 100644 index 0000000..1b25426 Binary files /dev/null and b/public/plaza-covers/data-analysis-1293jxp.jpg differ diff --git a/public/plaza-covers/data-analysis-a-b-1slrtg.jpg b/public/plaza-covers/data-analysis-a-b-1slrtg.jpg new file mode 100644 index 0000000..0969588 Binary files /dev/null and b/public/plaza-covers/data-analysis-a-b-1slrtg.jpg differ diff --git a/public/plaza-covers/data-analysis-a-b-测试显著性检验指南.jpg b/public/plaza-covers/data-analysis-a-b-测试显著性检验指南.jpg new file mode 100644 index 0000000..0969588 Binary files /dev/null and b/public/plaza-covers/data-analysis-a-b-测试显著性检验指南.jpg differ diff --git a/public/plaza-covers/data-analysis-arima-vs-prophet-144eeao.jpg b/public/plaza-covers/data-analysis-arima-vs-prophet-144eeao.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/public/plaza-covers/data-analysis-arima-vs-prophet-144eeao.jpg differ diff --git a/public/plaza-covers/data-analysis-g6tcgg.jpg b/public/plaza-covers/data-analysis-g6tcgg.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/public/plaza-covers/data-analysis-g6tcgg.jpg differ diff --git a/public/plaza-covers/data-analysis-shapley-vs-markov-11a7w0y.jpg b/public/plaza-covers/data-analysis-shapley-vs-markov-11a7w0y.jpg new file mode 100644 index 0000000..a39faf3 Binary files /dev/null and b/public/plaza-covers/data-analysis-shapley-vs-markov-11a7w0y.jpg differ diff --git a/public/plaza-covers/data-analysis-sql-9sel2d.jpg b/public/plaza-covers/data-analysis-sql-9sel2d.jpg new file mode 100644 index 0000000..1b25426 Binary files /dev/null and b/public/plaza-covers/data-analysis-sql-9sel2d.jpg differ diff --git a/public/plaza-covers/data-analysis-ztijjg.jpg b/public/plaza-covers/data-analysis-ztijjg.jpg new file mode 100644 index 0000000..0969588 Binary files /dev/null and b/public/plaza-covers/data-analysis-ztijjg.jpg differ diff --git a/public/plaza-covers/data-analysis-实时大屏指标设计与-sql-模板.jpg b/public/plaza-covers/data-analysis-实时大屏指标设计与-sql-模板.jpg new file mode 100644 index 0000000..1b25426 Binary files /dev/null and b/public/plaza-covers/data-analysis-实时大屏指标设计与-sql-模板.jpg differ diff --git a/public/plaza-covers/data-analysis-数据质量监控规则库搭建.jpg b/public/plaza-covers/data-analysis-数据质量监控规则库搭建.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/public/plaza-covers/data-analysis-数据质量监控规则库搭建.jpg differ diff --git a/public/plaza-covers/data-analysis-文本情感分析-评论洞察报告.jpg b/public/plaza-covers/data-analysis-文本情感分析-评论洞察报告.jpg new file mode 100644 index 0000000..1b25426 Binary files /dev/null and b/public/plaza-covers/data-analysis-文本情感分析-评论洞察报告.jpg differ diff --git a/public/plaza-covers/data-analysis-用户行为漏斗分析实战案例.jpg b/public/plaza-covers/data-analysis-用户行为漏斗分析实战案例.jpg new file mode 100644 index 0000000..0969588 Binary files /dev/null and b/public/plaza-covers/data-analysis-用户行为漏斗分析实战案例.jpg differ diff --git a/public/plaza-covers/data-analysis-营销归因-shapley-vs-markov.jpg b/public/plaza-covers/data-analysis-营销归因-shapley-vs-markov.jpg new file mode 100644 index 0000000..a39faf3 Binary files /dev/null and b/public/plaza-covers/data-analysis-营销归因-shapley-vs-markov.jpg differ diff --git a/public/plaza-covers/data-analysis-销售预测模型-arima-vs-prophet.jpg b/public/plaza-covers/data-analysis-销售预测模型-arima-vs-prophet.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/public/plaza-covers/data-analysis-销售预测模型-arima-vs-prophet.jpg differ diff --git a/public/plaza-covers/lifestyle-100-40-l2vjup.jpg b/public/plaza-covers/lifestyle-100-40-l2vjup.jpg new file mode 100644 index 0000000..e2e61e9 Binary files /dev/null and b/public/plaza-covers/lifestyle-100-40-l2vjup.jpg differ diff --git a/public/plaza-covers/lifestyle-1o3s87z.jpg b/public/plaza-covers/lifestyle-1o3s87z.jpg new file mode 100644 index 0000000..e9f9bda Binary files /dev/null and b/public/plaza-covers/lifestyle-1o3s87z.jpg differ diff --git a/public/plaza-covers/lifestyle-1wfbcos.jpg b/public/plaza-covers/lifestyle-1wfbcos.jpg new file mode 100644 index 0000000..f73669e Binary files /dev/null and b/public/plaza-covers/lifestyle-1wfbcos.jpg differ diff --git a/public/plaza-covers/lifestyle-30-173irnz.jpg b/public/plaza-covers/lifestyle-30-173irnz.jpg new file mode 100644 index 0000000..e9f9bda Binary files /dev/null and b/public/plaza-covers/lifestyle-30-173irnz.jpg differ diff --git a/public/plaza-covers/lifestyle-30-天早睡挑战执行记录.jpg b/public/plaza-covers/lifestyle-30-天早睡挑战执行记录.jpg new file mode 100644 index 0000000..e9f9bda Binary files /dev/null and b/public/plaza-covers/lifestyle-30-天早睡挑战执行记录.jpg differ diff --git a/public/plaza-covers/lifestyle-7-1cwt66m.jpg b/public/plaza-covers/lifestyle-7-1cwt66m.jpg new file mode 100644 index 0000000..ea7b9b7 Binary files /dev/null and b/public/plaza-covers/lifestyle-7-1cwt66m.jpg differ diff --git a/public/plaza-covers/lifestyle-m33c9i.jpg b/public/plaza-covers/lifestyle-m33c9i.jpg new file mode 100644 index 0000000..ea7b9b7 Binary files /dev/null and b/public/plaza-covers/lifestyle-m33c9i.jpg differ diff --git a/public/plaza-covers/lifestyle-o90i4e.jpg b/public/plaza-covers/lifestyle-o90i4e.jpg new file mode 100644 index 0000000..ea7b9b7 Binary files /dev/null and b/public/plaza-covers/lifestyle-o90i4e.jpg differ diff --git a/public/plaza-covers/lifestyle-厨房收纳改造-小空间大利用.jpg b/public/plaza-covers/lifestyle-厨房收纳改造-小空间大利用.jpg new file mode 100644 index 0000000..f73669e Binary files /dev/null and b/public/plaza-covers/lifestyle-厨房收纳改造-小空间大利用.jpg differ diff --git a/public/plaza-covers/lifestyle-周末徒步装备清单与路线.jpg b/public/plaza-covers/lifestyle-周末徒步装备清单与路线.jpg new file mode 100644 index 0000000..ea7b9b7 Binary files /dev/null and b/public/plaza-covers/lifestyle-周末徒步装备清单与路线.jpg differ diff --git a/public/plaza-covers/lifestyle-周末窑烤面包新手日记.jpg b/public/plaza-covers/lifestyle-周末窑烤面包新手日记.jpg new file mode 100644 index 0000000..e9f9bda Binary files /dev/null and b/public/plaza-covers/lifestyle-周末窑烤面包新手日记.jpg differ diff --git a/public/plaza-covers/lifestyle-手冲咖啡入门-7-日练习.jpg b/public/plaza-covers/lifestyle-手冲咖啡入门-7-日练习.jpg new file mode 100644 index 0000000..ea7b9b7 Binary files /dev/null and b/public/plaza-covers/lifestyle-手冲咖啡入门-7-日练习.jpg differ diff --git a/public/plaza-covers/lifestyle-断舍离-衣柜-100-件到-40-件.jpg b/public/plaza-covers/lifestyle-断舍离-衣柜-100-件到-40-件.jpg new file mode 100644 index 0000000..e2e61e9 Binary files /dev/null and b/public/plaza-covers/lifestyle-断舍离-衣柜-100-件到-40-件.jpg differ diff --git a/public/plaza-covers/lifestyle-阳台花园从零搭建指南.jpg b/public/plaza-covers/lifestyle-阳台花园从零搭建指南.jpg new file mode 100644 index 0000000..ea7b9b7 Binary files /dev/null and b/public/plaza-covers/lifestyle-阳台花园从零搭建指南.jpg differ diff --git a/public/plaza-covers/other-0-1000-35irqz.jpg b/public/plaza-covers/other-0-1000-35irqz.jpg new file mode 100644 index 0000000..6d3eb2b Binary files /dev/null and b/public/plaza-covers/other-0-1000-35irqz.jpg differ diff --git a/public/plaza-covers/other-176h7rn.jpg b/public/plaza-covers/other-176h7rn.jpg new file mode 100644 index 0000000..6d3eb2b Binary files /dev/null and b/public/plaza-covers/other-176h7rn.jpg differ diff --git a/public/plaza-covers/other-187ouof.jpg b/public/plaza-covers/other-187ouof.jpg new file mode 100644 index 0000000..3c2e59b Binary files /dev/null and b/public/plaza-covers/other-187ouof.jpg differ diff --git a/public/plaza-covers/other-1nd2tz4.jpg b/public/plaza-covers/other-1nd2tz4.jpg new file mode 100644 index 0000000..5dca65c Binary files /dev/null and b/public/plaza-covers/other-1nd2tz4.jpg differ diff --git a/public/plaza-covers/other-20-1de41cz.jpg b/public/plaza-covers/other-20-1de41cz.jpg new file mode 100644 index 0000000..6d3eb2b Binary files /dev/null and b/public/plaza-covers/other-20-1de41cz.jpg differ diff --git a/public/plaza-covers/other-vr-60-1yt52pj.jpg b/public/plaza-covers/other-vr-60-1yt52pj.jpg new file mode 100644 index 0000000..3c2e59b Binary files /dev/null and b/public/plaza-covers/other-vr-60-1yt52pj.jpg differ diff --git a/public/plaza-covers/other-vr-健身-60-天体验报告.jpg b/public/plaza-covers/other-vr-健身-60-天体验报告.jpg new file mode 100644 index 0000000..3c2e59b Binary files /dev/null and b/public/plaza-covers/other-vr-健身-60-天体验报告.jpg differ diff --git a/public/plaza-covers/other-xu49pe.jpg b/public/plaza-covers/other-xu49pe.jpg new file mode 100644 index 0000000..15cbd88 Binary files /dev/null and b/public/plaza-covers/other-xu49pe.jpg differ diff --git a/public/plaza-covers/other-个人知识库搭建方法论.jpg b/public/plaza-covers/other-个人知识库搭建方法论.jpg new file mode 100644 index 0000000..3c2e59b Binary files /dev/null and b/public/plaza-covers/other-个人知识库搭建方法论.jpg differ diff --git a/public/plaza-covers/other-二手交易向可持续生活转型.jpg b/public/plaza-covers/other-二手交易向可持续生活转型.jpg new file mode 100644 index 0000000..6d3eb2b Binary files /dev/null and b/public/plaza-covers/other-二手交易向可持续生活转型.jpg differ diff --git a/public/plaza-covers/other-城市观鸟入门-常见-20-种鸟.jpg b/public/plaza-covers/other-城市观鸟入门-常见-20-种鸟.jpg new file mode 100644 index 0000000..6d3eb2b Binary files /dev/null and b/public/plaza-covers/other-城市观鸟入门-常见-20-种鸟.jpg differ diff --git a/public/plaza-covers/other-播客频道从-0-到-1000-订阅.jpg b/public/plaza-covers/other-播客频道从-0-到-1000-订阅.jpg new file mode 100644 index 0000000..6d3eb2b Binary files /dev/null and b/public/plaza-covers/other-播客频道从-0-到-1000-订阅.jpg differ diff --git a/public/plaza-covers/other-社群运营-从群聊到价值观.jpg b/public/plaza-covers/other-社群运营-从群聊到价值观.jpg new file mode 100644 index 0000000..15cbd88 Binary files /dev/null and b/public/plaza-covers/other-社群运营-从群聊到价值观.jpg differ diff --git a/public/plaza-covers/other-自由职业者财务规划入门.jpg b/public/plaza-covers/other-自由职业者财务规划入门.jpg new file mode 100644 index 0000000..5dca65c Binary files /dev/null and b/public/plaza-covers/other-自由职业者财务规划入门.jpg differ diff --git a/public/plaza-covers/study-notes-6owsyr.jpg b/public/plaza-covers/study-notes-6owsyr.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/public/plaza-covers/study-notes-6owsyr.jpg differ diff --git a/public/plaza-covers/study-notes-docker-1adiwh7.jpg b/public/plaza-covers/study-notes-docker-1adiwh7.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/public/plaza-covers/study-notes-docker-1adiwh7.jpg differ diff --git a/public/plaza-covers/study-notes-docker-容器化从零到部署.jpg b/public/plaza-covers/study-notes-docker-容器化从零到部署.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/public/plaza-covers/study-notes-docker-容器化从零到部署.jpg differ diff --git a/public/plaza-covers/study-notes-fobvok.jpg b/public/plaza-covers/study-notes-fobvok.jpg new file mode 100644 index 0000000..15cbd88 Binary files /dev/null and b/public/plaza-covers/study-notes-fobvok.jpg differ diff --git a/public/plaza-covers/study-notes-git-code-review-1avjty.jpg b/public/plaza-covers/study-notes-git-code-review-1avjty.jpg new file mode 100644 index 0000000..edd5abf Binary files /dev/null and b/public/plaza-covers/study-notes-git-code-review-1avjty.jpg differ diff --git a/public/plaza-covers/study-notes-git-团队分支规范与-code-review.jpg b/public/plaza-covers/study-notes-git-团队分支规范与-code-review.jpg new file mode 100644 index 0000000..edd5abf Binary files /dev/null and b/public/plaza-covers/study-notes-git-团队分支规范与-code-review.jpg differ diff --git a/public/plaza-covers/study-notes-pandas-1mg4mom.jpg b/public/plaza-covers/study-notes-pandas-1mg4mom.jpg new file mode 100644 index 0000000..edd5abf Binary files /dev/null and b/public/plaza-covers/study-notes-pandas-1mg4mom.jpg differ diff --git a/public/plaza-covers/study-notes-pandas-数据分析速查手册.jpg b/public/plaza-covers/study-notes-pandas-数据分析速查手册.jpg new file mode 100644 index 0000000..edd5abf Binary files /dev/null and b/public/plaza-covers/study-notes-pandas-数据分析速查手册.jpg differ diff --git a/public/plaza-covers/study-notes-pca-54364r.jpg b/public/plaza-covers/study-notes-pca-54364r.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/public/plaza-covers/study-notes-pca-54364r.jpg differ diff --git a/public/plaza-covers/study-notes-typescript-10-1sddjtb.jpg b/public/plaza-covers/study-notes-typescript-10-1sddjtb.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/public/plaza-covers/study-notes-typescript-10-1sddjtb.jpg differ diff --git a/public/plaza-covers/study-notes-typescript-类型体操-10-道经典题解析.jpg b/public/plaza-covers/study-notes-typescript-类型体操-10-道经典题解析.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/public/plaza-covers/study-notes-typescript-类型体操-10-道经典题解析.jpg differ diff --git a/public/plaza-covers/study-notes-技术文档英语阅读训练计划.jpg b/public/plaza-covers/study-notes-技术文档英语阅读训练计划.jpg new file mode 100644 index 0000000..15cbd88 Binary files /dev/null and b/public/plaza-covers/study-notes-技术文档英语阅读训练计划.jpg differ diff --git a/public/plaza-covers/study-notes-系统设计面试-分布式缓存怎么答.jpg b/public/plaza-covers/study-notes-系统设计面试-分布式缓存怎么答.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/public/plaza-covers/study-notes-系统设计面试-分布式缓存怎么答.jpg differ diff --git a/public/plaza-covers/study-notes-线性代数直觉笔记-特征值与-pca.jpg b/public/plaza-covers/study-notes-线性代数直觉笔记-特征值与-pca.jpg new file mode 100644 index 0000000..a48a853 Binary files /dev/null and b/public/plaza-covers/study-notes-线性代数直觉笔记-特征值与-pca.jpg differ diff --git a/public/plaza-covers/travel-10-ap683v.jpg b/public/plaza-covers/travel-10-ap683v.jpg new file mode 100644 index 0000000..d7437c5 Binary files /dev/null and b/public/plaza-covers/travel-10-ap683v.jpg differ diff --git a/public/plaza-covers/travel-15qb47w.jpg b/public/plaza-covers/travel-15qb47w.jpg new file mode 100644 index 0000000..a178c53 Binary files /dev/null and b/public/plaza-covers/travel-15qb47w.jpg differ diff --git a/public/plaza-covers/travel-3-1lbpfse.jpg b/public/plaza-covers/travel-3-1lbpfse.jpg new file mode 100644 index 0000000..f5e5f1f Binary files /dev/null and b/public/plaza-covers/travel-3-1lbpfse.jpg differ diff --git a/public/plaza-covers/travel-4-70oznz.jpg b/public/plaza-covers/travel-4-70oznz.jpg new file mode 100644 index 0000000..4490c59 Binary files /dev/null and b/public/plaza-covers/travel-4-70oznz.jpg differ diff --git a/public/plaza-covers/travel-48-p5s92m.jpg b/public/plaza-covers/travel-48-p5s92m.jpg new file mode 100644 index 0000000..f5e5f1f Binary files /dev/null and b/public/plaza-covers/travel-48-p5s92m.jpg differ diff --git a/public/plaza-covers/travel-6-1hwz8po.jpg b/public/plaza-covers/travel-6-1hwz8po.jpg new file mode 100644 index 0000000..a178c53 Binary files /dev/null and b/public/plaza-covers/travel-6-1hwz8po.jpg differ diff --git a/public/plaza-covers/travel-fjj9j0.jpg b/public/plaza-covers/travel-fjj9j0.jpg new file mode 100644 index 0000000..a178c53 Binary files /dev/null and b/public/plaza-covers/travel-fjj9j0.jpg differ diff --git a/public/plaza-covers/travel-云南大理丽江-6-日省钱攻略.jpg b/public/plaza-covers/travel-云南大理丽江-6-日省钱攻略.jpg new file mode 100644 index 0000000..a178c53 Binary files /dev/null and b/public/plaza-covers/travel-云南大理丽江-6-日省钱攻略.jpg differ diff --git a/public/plaza-covers/travel-冰岛环岛-10-日自驾攻略.jpg b/public/plaza-covers/travel-冰岛环岛-10-日自驾攻略.jpg new file mode 100644 index 0000000..d7437c5 Binary files /dev/null and b/public/plaza-covers/travel-冰岛环岛-10-日自驾攻略.jpg differ diff --git a/public/plaza-covers/travel-新加坡亲子-3-日轻松游.jpg b/public/plaza-covers/travel-新加坡亲子-3-日轻松游.jpg new file mode 100644 index 0000000..f5e5f1f Binary files /dev/null and b/public/plaza-covers/travel-新加坡亲子-3-日轻松游.jpg differ diff --git a/public/plaza-covers/travel-清迈慢生活-4-日深度游.jpg b/public/plaza-covers/travel-清迈慢生活-4-日深度游.jpg new file mode 100644 index 0000000..4490c59 Binary files /dev/null and b/public/plaza-covers/travel-清迈慢生活-4-日深度游.jpg differ diff --git a/public/plaza-covers/travel-西藏林芝桃花季摄影路线.jpg b/public/plaza-covers/travel-西藏林芝桃花季摄影路线.jpg new file mode 100644 index 0000000..a178c53 Binary files /dev/null and b/public/plaza-covers/travel-西藏林芝桃花季摄影路线.jpg differ diff --git a/public/plaza-covers/travel-釜山海鲜美食-48-小时.jpg b/public/plaza-covers/travel-釜山海鲜美食-48-小时.jpg new file mode 100644 index 0000000..f5e5f1f Binary files /dev/null and b/public/plaza-covers/travel-釜山海鲜美食-48-小时.jpg differ diff --git a/public/plaza-covers/travel-阿尔卑斯徒步-少女峰周边.jpg b/public/plaza-covers/travel-阿尔卑斯徒步-少女峰周边.jpg new file mode 100644 index 0000000..a178c53 Binary files /dev/null and b/public/plaza-covers/travel-阿尔卑斯徒步-少女峰周边.jpg differ diff --git a/public/plaza-covers/work-report-1y4i5h1.jpg b/public/plaza-covers/work-report-1y4i5h1.jpg new file mode 100644 index 0000000..206f41a Binary files /dev/null and b/public/plaza-covers/work-report-1y4i5h1.jpg differ diff --git a/public/plaza-covers/work-report-2025-1fz5i3.jpg b/public/plaza-covers/work-report-2025-1fz5i3.jpg new file mode 100644 index 0000000..884182f Binary files /dev/null and b/public/plaza-covers/work-report-2025-1fz5i3.jpg differ diff --git a/public/plaza-covers/work-report-72ek9j.jpg b/public/plaza-covers/work-report-72ek9j.jpg new file mode 100644 index 0000000..00bf9c0 Binary files /dev/null and b/public/plaza-covers/work-report-72ek9j.jpg differ diff --git a/public/plaza-covers/work-report-b2b-saas-9-4vrvjf.jpg b/public/plaza-covers/work-report-b2b-saas-9-4vrvjf.jpg new file mode 100644 index 0000000..884182f Binary files /dev/null and b/public/plaza-covers/work-report-b2b-saas-9-4vrvjf.jpg differ diff --git a/public/plaza-covers/work-report-b2b-saas-客户成功月报-9-月.jpg b/public/plaza-covers/work-report-b2b-saas-客户成功月报-9-月.jpg new file mode 100644 index 0000000..884182f Binary files /dev/null and b/public/plaza-covers/work-report-b2b-saas-客户成功月报-9-月.jpg differ diff --git a/public/plaza-covers/work-report-mindspace-qwuy2q.jpg b/public/plaza-covers/work-report-mindspace-qwuy2q.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/public/plaza-covers/work-report-mindspace-qwuy2q.jpg differ diff --git a/public/plaza-covers/work-report-mindspace-公开发布指南.jpg b/public/plaza-covers/work-report-mindspace-公开发布指南.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/public/plaza-covers/work-report-mindspace-公开发布指南.jpg differ diff --git a/public/plaza-covers/work-report-okr-ivflp3.jpg b/public/plaza-covers/work-report-okr-ivflp3.jpg new file mode 100644 index 0000000..00bf9c0 Binary files /dev/null and b/public/plaza-covers/work-report-okr-ivflp3.jpg differ diff --git a/public/plaza-covers/work-report-q3-1v4dgld.jpg b/public/plaza-covers/work-report-q3-1v4dgld.jpg new file mode 100644 index 0000000..509e7ba Binary files /dev/null and b/public/plaza-covers/work-report-q3-1v4dgld.jpg differ diff --git a/public/plaza-covers/work-report-sprint-42-8a7tiy.jpg b/public/plaza-covers/work-report-sprint-42-8a7tiy.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/public/plaza-covers/work-report-sprint-42-8a7tiy.jpg differ diff --git a/public/plaza-covers/work-report-产品经理年度-okr-回顾与反思.jpg b/public/plaza-covers/work-report-产品经理年度-okr-回顾与反思.jpg new file mode 100644 index 0000000..00bf9c0 Binary files /dev/null and b/public/plaza-covers/work-report-产品经理年度-okr-回顾与反思.jpg differ diff --git a/public/plaza-covers/work-report-品牌升级方案执行进度追踪.jpg b/public/plaza-covers/work-report-品牌升级方案执行进度追踪.jpg new file mode 100644 index 0000000..00bf9c0 Binary files /dev/null and b/public/plaza-covers/work-report-品牌升级方案执行进度追踪.jpg differ diff --git a/public/plaza-covers/work-report-研发团队-sprint-回顾-第-42-期.jpg b/public/plaza-covers/work-report-研发团队-sprint-回顾-第-42-期.jpg new file mode 100644 index 0000000..bdf35c2 Binary files /dev/null and b/public/plaza-covers/work-report-研发团队-sprint-回顾-第-42-期.jpg differ diff --git a/public/plaza-covers/work-report-跨部门项目协同周报-模板与实践.jpg b/public/plaza-covers/work-report-跨部门项目协同周报-模板与实践.jpg new file mode 100644 index 0000000..206f41a Binary files /dev/null and b/public/plaza-covers/work-report-跨部门项目协同周报-模板与实践.jpg differ diff --git a/public/plaza-covers/work-report-远程办公效率调研-2025-秋季.jpg b/public/plaza-covers/work-report-远程办公效率调研-2025-秋季.jpg new file mode 100644 index 0000000..884182f Binary files /dev/null and b/public/plaza-covers/work-report-远程办公效率调研-2025-秋季.jpg differ diff --git a/public/plaza-covers/work-report-销售团队-q3-业绩复盘与目标拆解.jpg b/public/plaza-covers/work-report-销售团队-q3-业绩复盘与目标拆解.jpg new file mode 100644 index 0000000..509e7ba Binary files /dev/null and b/public/plaza-covers/work-report-销售团队-q3-业绩复盘与目标拆解.jpg differ diff --git a/scripts/compensate-user-billing.mjs b/scripts/compensate-user-billing.mjs index 41775dc..b283c9e 100644 --- a/scripts/compensate-user-billing.mjs +++ b/scripts/compensate-user-billing.mjs @@ -47,7 +47,7 @@ if (!process.env.DATABASE_URL) { /** wx_mk4zzaps duplicate Finish billing (2026-06-16 session 20260616_600). */ const DUPLICATE_COST_CENTS = 1735; const DUPLICATE_TOKENS = 838567; -/** Unique bills at 2/6 分/1k vs documented DeepSeek ×3 default (0.302/0.605 分/1k). */ +/** Unique bills at 2/6 分/1k vs documented DeepSeek ×1 default (0.101/0.202 分/1k). */ const RATE_OVERCHARGE_CENTS = 1467; async function main() { diff --git a/scripts/enrich-plaza-posts.mjs b/scripts/enrich-plaza-posts.mjs index 91434aa..1ee5f8a 100644 --- a/scripts/enrich-plaza-posts.mjs +++ b/scripts/enrich-plaza-posts.mjs @@ -103,13 +103,15 @@ async function ensureBundle(conn, row, html) { async function main() { const pool = createDbPool(); const [rows] = await pool.query( - `SELECT pp.id AS post_id, pp.title, + `SELECT pp.id AS post_id, pp.title, pp.summary, pp.tags, + c.slug AS category_slug, pr.id AS publication_id, pr.page_id, pr.page_version_id, pr.user_id, pr.status AS pub_status, p.space_id, p.category_id, p.title AS page_title, pv.bundle_asset_id, (SELECT av.id FROM h5_asset_versions av WHERE av.asset_id = pv.bundle_asset_id AND av.version_no = 1 LIMIT 1) AS bundle_version_id FROM plaza_posts pp + JOIN plaza_categories c ON c.id = pp.category_id JOIN h5_publish_records pr ON pr.id = pp.publication_id JOIN h5_page_records p ON p.id = pr.page_id JOIN h5_page_versions pv ON pv.id = pr.page_version_id @@ -122,7 +124,17 @@ async function main() { try { await conn.beginTransaction(); for (const row of rows) { - const { summary, html, skipHtml } = getEnrichment(row.post_id, row.title); + let tags = []; + try { + tags = typeof row.tags === 'string' ? JSON.parse(row.tags) : row.tags ?? []; + } catch { + tags = []; + } + const { summary, html, skipHtml } = getEnrichment(row.post_id, row.title, { + categorySlug: row.category_slug, + summary: row.summary, + tags, + }); const summaryOnly = skipHtml && row.bundle_asset_id; if (!summaryOnly) { await ensureBundle(conn, row, html); diff --git a/scripts/install-prod-services.sh b/scripts/install-prod-services.sh new file mode 100644 index 0000000..131ee14 --- /dev/null +++ b/scripts/install-prod-services.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# 安装 g2.tkmind.cn + plaza.tkmind.cn 生产 LaunchAgent(登录自启 + 崩溃自动重启) +# +# 架构: +# cloudflared → g2 :8090 (Caddy) → Portal :8081 +# cloudflared → plaza :3001 (Next.js) → Portal :8081 +# +# 用法:pnpm setup:prod-services +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +LOG_DIR="${HOME}/Library/Logs" +PORTAL_PLIST="${HOME}/Library/LaunchAgents/cn.tkmind.memind-portal.plist" +PLAZA_PLIST="${HOME}/Library/LaunchAgents/cn.tkmind.plaza.plist" +G2_LB_PLIST="${HOME}/Library/LaunchAgents/cn.tkmind.g2-lb.plist" +CF_PLIST="${HOME}/Library/LaunchAgents/com.cloudflare.cloudflared.plist" +PORTAL_RUN="${ROOT}/scripts/run-memind-portal-prod.sh" +PLAZA_RUN="${ROOT}/scripts/run-plaza-prod.sh" +CADDYFILE="${ROOT}/scripts/g2-lb.Caddyfile" +UID_NUM="$(id -u)" +GUI="gui/${UID_NUM}" +PATH_ENV="/opt/homebrew/bin:/opt/homebrew/opt/node@22/bin:/usr/local/bin:/usr/bin:/bin" + +chmod +x "${PORTAL_RUN}" "${PLAZA_RUN}" "${ROOT}/scripts/install-g2-lb.sh" + +mkdir -p "${LOG_DIR}" + +cat >"${PORTAL_PLIST}" < + + + + Label + cn.tkmind.memind-portal + ProgramArguments + + ${PORTAL_RUN} + + WorkingDirectory + ${ROOT} + EnvironmentVariables + + PATH + ${PATH_ENV} + + RunAtLoad + + KeepAlive + + ThrottleInterval + 10 + StandardOutPath + ${LOG_DIR}/memind-portal.log + StandardErrorPath + ${LOG_DIR}/memind-portal.log + + +EOF + +cat >"${PLAZA_PLIST}" < + + + + Label + cn.tkmind.plaza + ProgramArguments + + ${PLAZA_RUN} + + WorkingDirectory + ${ROOT} + EnvironmentVariables + + PATH + ${PATH_ENV} + + RunAtLoad + + KeepAlive + + ThrottleInterval + 15 + StandardOutPath + ${LOG_DIR}/plaza-prod.log + StandardErrorPath + ${LOG_DIR}/plaza-prod.log + + +EOF + +if [[ ! -f "${G2_LB_PLIST}" ]]; then + cat >"${G2_LB_PLIST}" < + + + + Label + cn.tkmind.g2-lb + ProgramArguments + + /opt/homebrew/bin/caddy + run + --config + ${CADDYFILE} + + RunAtLoad + + KeepAlive + + ThrottleInterval + 10 + StandardOutPath + ${LOG_DIR}/g2-lb.log + StandardErrorPath + ${LOG_DIR}/g2-lb.log + + +EOF +fi + +install_agent() { + local label="$1" + local plist="$2" + if launchctl print "${GUI}/${label}" >/dev/null 2>&1; then + launchctl bootout "${GUI}/${label}" 2>/dev/null || true + sleep 1 + fi + launchctl bootstrap "${GUI}" "${plist}" + launchctl enable "${GUI}/${label}" + launchctl kickstart -k "${GUI}/${label}" +} + +echo "==> 释放端口 8081 / 3001(避免与手动进程冲突)…" +for port in 8081 3001; do + lsof -ti "TCP:${port}" -sTCP:LISTEN 2>/dev/null | xargs kill -9 2>/dev/null || true +done +sleep 1 + +echo "==> 安装 LaunchAgent…" +install_agent "cn.tkmind.memind-portal" "${PORTAL_PLIST}" +sleep 2 +install_agent "cn.tkmind.plaza" "${PLAZA_PLIST}" +install_agent "cn.tkmind.g2-lb" "${G2_LB_PLIST}" + +if [[ -f "${CF_PLIST}" ]]; then + install_agent "com.cloudflare.cloudflared" "${CF_PLIST}" +else + echo "⚠ 未找到 ${CF_PLIST},请确认 cloudflared LaunchAgent 已配置" >&2 +fi + +echo "==> 等待服务就绪…" +for _ in $(seq 1 60); do + portal_ok=0 + plaza_ok=0 + g2_ok=0 + curl -sf "http://127.0.0.1:8081/api/status" >/dev/null 2>&1 && portal_ok=1 + curl -sf "http://127.0.0.1:3001/plaza" >/dev/null 2>&1 && plaza_ok=1 + curl -sf "http://127.0.0.1:8090/api/status" >/dev/null 2>&1 && g2_ok=1 + if [[ "${portal_ok}" -eq 1 && "${plaza_ok}" -eq 1 && "${g2_ok}" -eq 1 ]]; then + break + fi + sleep 2 +done + +echo "" +echo "=== 生产服务状态 ===" +launchctl list | grep -E "cn.tkmind\.(memind-portal|plaza|g2-lb)|com.cloudflare.cloudflared" || true +curl -s -o /dev/null -w "Portal :8081 → %{http_code}\n" "http://127.0.0.1:8081/api/status" || true +curl -s -o /dev/null -w "g2 LB :8090 → %{http_code}\n" "http://127.0.0.1:8090/api/status" || true +curl -s -o /dev/null -w "Plaza :3001 → %{http_code}\n" "http://127.0.0.1:3001/plaza" || true +curl -s -o /dev/null -w "外网 g2 → %{http_code}\n" "https://g2.tkmind.cn/api/status" || true +curl -s -o /dev/null -w "外网 plaza → %{http_code}\n" "https://plaza.tkmind.cn/plaza" || true + +echo "" +echo "✅ 生产 LaunchAgent 已安装(登录自启 + KeepAlive)" +echo " Portal cn.tkmind.memind-portal → ~/Library/Logs/memind-portal.log" +echo " Plaza cn.tkmind.plaza → ~/Library/Logs/plaza-prod.log" +echo " g2 LB cn.tkmind.g2-lb → ~/Library/Logs/g2-lb.log" +echo " 隧道 com.cloudflare.cloudflared" +echo "" +echo "管理命令:" +echo " launchctl kickstart -k ${GUI}/cn.tkmind.memind-portal" +echo " launchctl kickstart -k ${GUI}/cn.tkmind.plaza" +echo " tail -f ~/Library/Logs/memind-portal.log ~/Library/Logs/plaza-prod.log" diff --git a/scripts/plaza-demo-posts.mjs b/scripts/plaza-demo-posts.mjs new file mode 100644 index 0000000..c018bdd --- /dev/null +++ b/scripts/plaza-demo-posts.mjs @@ -0,0 +1,578 @@ +/** 各分类补充演示帖(每类 7 条,与 seed-plaza-demo.mjs 原有条目合并使用) */ +export const EXTRA_DEMO_POSTS = [ + // work-report +7 + { + category: 'work-report', + title: '跨部门项目协同周报 · 模板与实践', + summary: '研发、产品、运营三方对齐模板,附 RACI 矩阵与风险升级机制。', + tags: ['协同', '周报', '模板'], + likes: 72, + comments: 14, + views: 2100, + author: 'john', + }, + { + category: 'work-report', + title: '销售团队 Q3 业绩复盘与目标拆解', + summary: '区域完成率、客单价变化与 Q4 冲刺计划,含 Top10 客户分析。', + tags: ['销售', '复盘', 'Q3'], + likes: 54, + comments: 9, + views: 1680, + author: 'admin', + }, + { + category: 'work-report', + title: '远程办公效率调研 · 2025 秋季', + summary: '200 人样本:专注时长、会议占比与工具偏好,给管理者的 5 条建议。', + tags: ['远程', '效率', '调研'], + likes: 98, + comments: 22, + views: 3400, + author: 'john', + }, + { + category: 'work-report', + title: '产品经理年度 OKR 回顾与反思', + summary: '目标达成率 78%,未达成项根因与下年度优先级调整思路。', + tags: ['OKR', '产品', '复盘'], + likes: 115, + comments: 19, + views: 2800, + author: 'john', + }, + { + category: 'work-report', + title: 'B2B SaaS 客户成功月报 · 9 月', + summary: 'NRR 112%、健康度红黄绿分布与 3 个流失预警案例复盘。', + tags: ['SaaS', '客户成功'], + likes: 67, + comments: 11, + views: 1950, + author: 'admin', + }, + { + category: 'work-report', + title: '研发团队 Sprint 回顾 · 第 42 期', + summary: 'Velocity 趋势、阻塞项统计与下一 Sprint 容量规划建议。', + tags: ['敏捷', 'Sprint'], + likes: 43, + comments: 8, + views: 1200, + author: 'john', + }, + { + category: 'work-report', + title: '品牌升级方案执行进度追踪', + summary: 'VI 落地、渠道物料与内部培训三节进度,附里程碑甘特图。', + tags: ['品牌', '项目管理'], + likes: 61, + comments: 10, + views: 1750, + author: 'admin', + }, + + // study-notes +7 + { + category: 'study-notes', + title: 'TypeScript 类型体操 10 道经典题解析', + summary: 'Conditional、Mapped、Template Literal 从入门到面试高频。', + tags: ['TypeScript', '类型'], + likes: 186, + comments: 34, + views: 6200, + author: 'john', + }, + { + category: 'study-notes', + title: '系统设计面试:分布式缓存怎么答', + summary: 'Cache-Aside、一致性、热点 Key 与穿透防护的标准答题框架。', + tags: ['系统设计', '面试'], + likes: 224, + comments: 41, + views: 7800, + author: 'john', + }, + { + category: 'study-notes', + title: '线性代数直觉笔记 · 特征值与 PCA', + summary: '不用公式堆:用图像旋转理解特征分解与降维直觉。', + tags: ['数学', 'PCA'], + likes: 142, + comments: 28, + views: 4100, + author: 'admin', + }, + { + category: 'study-notes', + title: 'Docker 容器化从零到部署', + summary: 'Dockerfile 最佳实践、Compose 多服务与生产环境注意事项。', + tags: ['Docker', 'DevOps'], + likes: 167, + comments: 31, + views: 5300, + author: 'john', + }, + { + category: 'study-notes', + title: 'Pandas 数据分析速查手册', + summary: '读写、分组、透视、合并与可视化 30 个常用片段,可复制即用。', + tags: ['Python', 'Pandas'], + likes: 198, + comments: 36, + views: 6500, + author: 'john', + }, + { + category: 'study-notes', + title: 'Git 团队分支规范与 Code Review', + summary: 'GitFlow vs Trunk、PR 模板与 Review 清单,适合 10 人以内团队。', + tags: ['Git', '规范'], + likes: 89, + comments: 17, + views: 2600, + author: 'admin', + }, + { + category: 'study-notes', + title: '技术文档英语阅读训练计划', + summary: '21 天计划:RFC 段落拆解、术语表与每日 15 分钟精读练习。', + tags: ['英语', '文档'], + likes: 76, + comments: 15, + views: 2200, + author: 'john', + }, + + // creative +7 + { + category: 'creative', + title: '国潮茶饮品牌 VIS 视觉系统', + summary: 'Logo 变体、杯身纹样与门店导视的统一设计语言。', + tags: ['品牌', 'VIS'], + likes: 134, + comments: 18, + views: 3800, + author: 'john', + }, + { + category: 'creative', + title: '科幻短片《归途》分镜脚本', + summary: '12 场关键镜头描述、色调参考与音效设计备忘。', + tags: ['分镜', '科幻'], + likes: 92, + comments: 14, + views: 2400, + author: 'admin', + }, + { + category: 'creative', + title: '手作 ceramics 工作室品牌摄影', + summary: '自然光布光、肌理特写与产品叙事的三组拍摄方案。', + tags: ['摄影', '陶瓷'], + likes: 108, + comments: 12, + views: 2900, + author: 'john', + }, + { + category: 'creative', + title: '城市夜景长曝光摄影系列', + summary: '车轨、霓虹反射与 30s 曝光参数对照,附后期调色流程。', + tags: ['摄影', '夜景'], + likes: 156, + comments: 24, + views: 4500, + author: 'john', + }, + { + category: 'creative', + title: '极简 UI 图标集 · 200 Icons', + summary: '2px 线宽、24×24 网格的线性图标库,含 Figma 组件规范。', + tags: ['UI', '图标'], + likes: 178, + comments: 29, + views: 5100, + author: 'admin', + }, + { + category: 'creative', + title: '水墨风 AI 插画实验合集', + summary: 'Prompt 控制笔触、留白与构图的 8 组可复现参数。', + tags: ['AI绘画', '水墨'], + likes: 143, + comments: 21, + views: 3900, + author: 'john', + }, + { + category: 'creative', + title: '复古胶片人像调色预设包', + summary: 'Kodak / Fuji 模拟 LUT + Lightroom 预设,适合户外人像。', + tags: ['调色', '胶片'], + likes: 87, + comments: 13, + views: 2100, + author: 'admin', + }, + + // business +7 + { + category: 'business', + title: '精酿啤酒馆开业 30 天数据复盘', + summary: '坪效、复购率与爆款酒款分析,第二个月优化动作清单。', + tags: ['精酿', '复盘'], + likes: 82, + comments: 12, + views: 2300, + author: 'john', + }, + { + category: 'business', + title: '日式美甲沙龙会员体系设计', + summary: '储值梯度、生日礼遇与转介绍奖励的完整规则说明。', + tags: ['美甲', '会员'], + likes: 64, + comments: 9, + views: 1700, + author: 'admin', + }, + { + category: 'business', + title: '社区健身房私教转化漏斗优化', + summary: '体验课到年卡的三步话术与异议处理,转化率 +18pp。', + tags: ['健身', '转化'], + likes: 71, + comments: 11, + views: 2000, + author: 'john', + }, + { + category: 'business', + title: '独立书店文创周边选品策略', + summary: '本地作者、手账与联名款组合,提升客单而不损品牌调性。', + tags: ['书店', '文创'], + likes: 93, + comments: 16, + views: 2600, + author: 'john', + }, + { + category: 'business', + title: '宠物洗护上门服务 SOP 手册', + summary: '预约、消毒、服务流程与客户沟通标准,可直接培训使用。', + tags: ['宠物', 'SOP'], + likes: 55, + comments: 8, + views: 1500, + author: 'admin', + }, + { + category: 'business', + title: '轻食沙拉店午餐高峰运营方案', + summary: '备料节奏、动线设计与套餐组合,12:00–13:30 翻台率提升 35%。', + tags: ['轻食', '运营'], + likes: 68, + comments: 10, + views: 1850, + author: 'john', + }, + { + category: 'business', + title: '民宿旺季定价与房源包装指南', + summary: '动态定价区间、首图拍摄与评价回复模板,适合 5 间以内体量。', + tags: ['民宿', '定价'], + likes: 79, + comments: 14, + views: 2200, + author: 'admin', + }, + + // travel +7 + { + category: 'travel', + title: '冰岛环岛 10 日自驾攻略', + summary: 'Ring Road 路线、天气应对与必看瀑布/冰川清单,含预算表。', + tags: ['冰岛', '自驾'], + likes: 267, + comments: 48, + views: 9200, + author: 'john', + }, + { + category: 'travel', + title: '清迈慢生活 4 日深度游', + summary: '古城寺庙、宁曼路咖啡与周日夜市,避开网红排队点。', + tags: ['泰国', '清迈'], + likes: 198, + comments: 35, + views: 6800, + author: 'admin', + }, + { + category: 'travel', + title: '西藏林芝桃花季摄影路线', + summary: '3 月下旬最佳机位、海拔适应与装备清单,附样片参数。', + tags: ['西藏', '摄影'], + likes: 223, + comments: 39, + views: 7500, + author: 'john', + }, + { + category: 'travel', + title: '新加坡亲子 3 日轻松游', + summary: '动物园、滨海湾与本地美食,推车友好路线与交通卡攻略。', + tags: ['新加坡', '亲子'], + likes: 156, + comments: 27, + views: 4900, + author: 'john', + }, + { + category: 'travel', + title: '云南大理丽江 6 日省钱攻略', + summary: '淡季住宿、拼车与必去小众点,人均 ¥2800 舒适玩法。', + tags: ['云南', '省钱'], + likes: 184, + comments: 33, + views: 6100, + author: 'admin', + }, + { + category: 'travel', + title: '釜山海鲜美食 48 小时', + summary: '札嘎其市场、甘川文化村与海边 Cafe,周末快闪指南。', + tags: ['韩国', '美食'], + likes: 142, + comments: 24, + views: 4300, + author: 'john', + }, + { + category: 'travel', + title: '阿尔卑斯徒步 · 少女峰周边', + summary: 'Easy / Moderate 两条路线、齿轮火车时刻与登山装备建议。', + tags: ['瑞士', '徒步'], + likes: 201, + comments: 36, + views: 6700, + author: 'admin', + }, + + // data-analysis +7 + { + category: 'data-analysis', + title: '用户行为漏斗分析实战案例', + summary: '注册→激活→付费五步漏斗,定位最大流失环节与优化假设。', + tags: ['漏斗', '行为分析'], + likes: 134, + comments: 23, + views: 3600, + author: 'john', + }, + { + category: 'data-analysis', + title: 'A/B 测试显著性检验指南', + summary: '样本量计算、p-value 误读与 Bayesian 备选方案对照。', + tags: ['AB测试', '统计'], + likes: 98, + comments: 17, + views: 2800, + author: 'admin', + }, + { + category: 'data-analysis', + title: '销售预测模型:ARIMA vs Prophet', + summary: '同一数据集两种模型对比,含 Python 代码与误差指标。', + tags: ['预测', '时序'], + likes: 87, + comments: 15, + views: 2500, + author: 'john', + }, + { + category: 'data-analysis', + title: '文本情感分析:评论洞察报告', + summary: '电商评论 NLP pipeline,主题聚类与负面词云解读。', + tags: ['NLP', '情感分析'], + likes: 76, + comments: 13, + views: 2200, + author: 'john', + }, + { + category: 'data-analysis', + title: '实时大屏指标设计与 SQL 模板', + summary: 'GMV、DAU、转化率三类大屏的指标定义与刷新策略。', + tags: ['大屏', 'SQL'], + likes: 112, + comments: 19, + views: 3100, + author: 'admin', + }, + { + category: 'data-analysis', + title: '营销归因:Shapley vs Markov', + summary: '多触点归因两种方法适用场景与结果差异案例。', + tags: ['归因', '营销'], + likes: 69, + comments: 12, + views: 1900, + author: 'john', + }, + { + category: 'data-analysis', + title: '数据质量监控规则库搭建', + summary: '空值、重复、时效性三类规则 + 告警分级与值班流程。', + tags: ['数据质量', '监控'], + likes: 83, + comments: 14, + views: 2400, + author: 'admin', + }, + + // lifestyle +7 + { + category: 'lifestyle', + title: '厨房收纳改造:小空间大利用', + summary: '垂直收纳、标签系统与动线优化,周末 4 小时完成。', + tags: ['收纳', '厨房'], + likes: 121, + comments: 19, + views: 3300, + author: 'john', + }, + { + category: 'lifestyle', + title: '30 天早睡挑战执行记录', + summary: '23:30 前入睡、屏幕宵禁与睡眠质量变化曲线。', + tags: ['睡眠', '习惯'], + likes: 88, + comments: 16, + views: 2400, + author: 'admin', + }, + { + category: 'lifestyle', + title: '周末徒步装备清单与路线', + summary: '一日轻徒步:鞋服、补给与安全装备,附 3 条城市周边路线。', + tags: ['徒步', '户外'], + likes: 102, + comments: 18, + views: 2900, + author: 'john', + }, + { + category: 'lifestyle', + title: '阳台花园从零搭建指南', + summary: '光照评估、盆器选择与前 10 种好养植物推荐。', + tags: ['园艺', '阳台'], + likes: 95, + comments: 17, + views: 2700, + author: 'john', + }, + { + category: 'lifestyle', + title: '断舍离:衣柜 100 件到 40 件', + summary: '胶囊衣橱原则、捐赠流程与 30 天不再购衣挑战记录。', + tags: ['断舍离', '极简'], + likes: 113, + comments: 22, + views: 3200, + author: 'admin', + }, + { + category: 'lifestyle', + title: '手冲咖啡入门 7 日练习', + summary: '研磨度、水温与注水节奏对照表,从酸到平衡的味觉笔记。', + tags: ['咖啡', '手冲'], + likes: 79, + comments: 14, + views: 2100, + author: 'john', + }, + { + category: 'lifestyle', + title: '周末窑烤面包新手日记', + summary: '天然酵母起种、折叠节奏与第一次成功欧包的全记录。', + tags: ['烘焙', '面包'], + likes: 91, + comments: 16, + views: 2500, + author: 'admin', + }, + + // other +7 + { + category: 'other', + title: '自由职业者财务规划入门', + summary: '现金流缓冲、税筹与保险配置,适合刚转自由职业的前 12 个月。', + tags: ['自由职业', '财务'], + likes: 68, + comments: 12, + views: 1900, + author: 'john', + }, + { + category: 'other', + title: '播客频道从 0 到 1000 订阅', + summary: '选题、剪辑流程与跨平台分发,第 8 集突破 1000 订阅复盘。', + tags: ['播客', '创作'], + likes: 74, + comments: 13, + views: 2100, + author: 'admin', + }, + { + category: 'other', + title: '城市观鸟入门:常见 20 种鸟', + summary: '公园/湿地观测点、识别特征与入门望远镜选购建议。', + tags: ['观鸟', '自然'], + likes: 52, + comments: 9, + views: 1400, + author: 'john', + }, + { + category: 'other', + title: '二手交易向可持续生活转型', + summary: '闲鱼/多抓鱼使用心得与「买前问三遍」决策清单。', + tags: ['可持续', '二手'], + likes: 61, + comments: 11, + views: 1700, + author: 'john', + }, + { + category: 'other', + title: '个人知识库搭建方法论', + summary: 'PARA + 渐进式总结,Obsidian/Notion 选型与迁移经验。', + tags: ['知识管理', 'PKM'], + likes: 129, + comments: 24, + views: 3500, + author: 'admin', + }, + { + category: 'other', + title: 'VR 健身 60 天体验报告', + summary: 'Beat Saber + Supernatural 组合,体重与体态变化数据。', + tags: ['VR', '健身'], + likes: 84, + comments: 15, + views: 2300, + author: 'john', + }, + { + category: 'other', + title: '社群运营:从群聊到价值观', + summary: '冷启动、活跃机制与冲突处理,200 人社群 6 个月实践。', + tags: ['社群', '运营'], + likes: 97, + comments: 18, + views: 2700, + author: 'admin', + }, +]; diff --git a/scripts/plaza-rich-content.mjs b/scripts/plaza-rich-content.mjs index 897096e..1628042 100644 --- a/scripts/plaza-rich-content.mjs +++ b/scripts/plaza-rich-content.mjs @@ -845,9 +845,21 @@ export const ENRICHMENTS = { }, }; -export function getEnrichment(postId, fallbackTitle) { +export function getEnrichment(postId, fallbackTitle, meta = {}) { const item = ENRICHMENTS[postId]; if (item) return item; + + const categorySlug = meta.categorySlug ?? meta.category_slug ?? ''; + const summary = meta.summary ?? ''; + const tags = meta.tags ?? []; + + if (categorySlug) { + return { + summary: summary || `${fallbackTitle} — MindSpace 精选内容`, + html: buildCategoryPage(categorySlug, fallbackTitle, summary || fallbackTitle, tags), + }; + } + return { summary: `${fallbackTitle} — MindSpace 精选`, html: buildJohnPage({ @@ -860,3 +872,215 @@ export function getEnrichment(postId, fallbackTitle) { }), }; } + +const CATEGORY_THEMES = { + 'work-report': { + tag: '职场报告', + tagEmoji: '📊', + accent: '#2d7d6a', + accent2: '#4ecdc4', + images: [IMG.growth, IMG.ai, IMG.supply, IMG.guide], + }, + 'study-notes': { + tag: '学习笔记', + tagEmoji: '📚', + accent: '#f97316', + accent2: '#fb923c', + images: [IMG.rust, IMG.llm, IMG.guide, IMG.opensource], + }, + creative: { + tag: '创意作品', + tagEmoji: '🎨', + accent: '#c084fc', + accent2: '#a855f7', + images: [IMG.cyber, IMG.music, IMG.guide], + }, + business: { + tag: '门店展示', + tagEmoji: '🏪', + accent: '#d97706', + accent2: '#fbbf24', + images: [IMG.coffee, IMG.pilates, IMG.guide], + }, + travel: { + tag: '旅行攻略', + tagEmoji: '✈️', + accent: '#2d7d6a', + accent2: '#4ecdc4', + images: [IMG.malaysia, IMG.kyoto, IMG.travel, IMG.guide], + }, + 'data-analysis': { + tag: '数据分析', + tagEmoji: '📈', + accent: '#38bdf8', + accent2: '#0ea5e9', + images: [IMG.data, IMG.growth, IMG.guide], + }, + lifestyle: { + tag: '生活记录', + tagEmoji: '🌿', + accent: '#2d7d6a', + accent2: '#4ecdc4', + images: [IMG.desk, IMG.write, IMG.guide], + }, + other: { + tag: '其他', + tagEmoji: '💡', + accent: '#34d399', + accent2: '#10b981', + images: [IMG.opensource, IMG.wish, IMG.guide], + }, +}; + +function hashTitle(title) { + let hash = 0; + for (let i = 0; i < title.length; i += 1) { + hash = (hash * 31 + title.charCodeAt(i)) >>> 0; + } + return hash; +} + +function buildHeroTitleHtml(title) { + const trimmed = String(title).trim(); + if (trimmed.length <= 6) { + return `${esc(trimmed)}`; + } + const splitAt = Math.min(8, Math.ceil(trimmed.length * 0.42)); + const head = trimmed.slice(0, splitAt); + const tail = trimmed.slice(splitAt); + return `${esc(head)}${esc(tail)}`; +} + +function buildCategorySections(categorySlug, title, summary, tags) { + const tagList = + tags.length > 0 + ? `
    ${tags.map((tag) => `
  • ${esc(tag)}
  • `).join('')}
` + : ''; + + const metricsPool = { + 'work-report': [ + { value: '92%', label: '目标完成', note: '本期 OKR' }, + { value: '3', label: '关键实验', note: '已验证假设' }, + { value: '↓18%', label: '阻塞时长', note: '较上期' }, + ], + 'study-notes': [ + { value: '15min', label: '阅读时长', note: '核心章节' }, + { value: '8', label: '要点', note: '可复用片段' }, + { value: '3', label: '练习题', note: '附参考答案' }, + ], + creative: [ + { value: '4K', label: '输出规格', note: '主视觉' }, + { value: '3', label: '延展场景', note: '社媒 / 印刷' }, + { value: '12', label: '配色', note: '品牌体系' }, + ], + business: [ + { value: '+24%', label: '转化提升', note: '活动期' }, + { value: '4.8', label: '评分', note: '5 分制' }, + { value: '86%', label: '复购意向', note: '问卷样本' }, + ], + travel: [ + { value: '5-10', label: '行程天数', note: '弹性安排' }, + { value: '¥680', label: '日均预算', note: '舒适型参考' }, + { value: '12+', label: '精选点位', note: '含美食' }, + ], + 'data-analysis': [ + { value: '3层', label: '下钻维度', note: '渠道/品类/客单' }, + { value: '99%', label: '数据覆盖', note: '核心指标' }, + { value: 'T+1', label: '刷新频率', note: '看板更新' }, + ], + lifestyle: [ + { value: '21天', label: '挑战周期', note: '习惯养成' }, + { value: '12', label: '实践要点', note: '可执行清单' }, + { value: '↓32%', label: '焦虑自评', note: '挑战前后' }, + ], + other: [ + { value: '6', label: '关键动作', note: '冷启动路径' }, + { value: '1000+', label: '读者', note: '累计触达' }, + { value: '4.6', label: '满意度', note: '反馈均值' }, + ], + }; + + const cardsPool = { + 'work-report': [ + { title: '背景与目标', desc: '明确本期业务目标与衡量口径,对齐 stakeholder 预期。', tag: '目标' }, + { title: '执行进展', desc: '按里程碑拆解完成度,标注风险与依赖项。', tag: '进展' }, + { title: '数据洞察', desc: '用 3 个核心指标讲清「发生了什么、为什么」。', tag: '洞察' }, + { title: '下一步', desc: '列出 2–3 个可验证的下一周期动作。', tag: '行动' }, + ], + 'study-notes': [ + { title: '核心概念', desc: '用一句话 + 生活类比建立直觉,再展开细节。', tag: '概念' }, + { title: '常见误区', desc: '面试与实践中高频踩坑点对照表。', tag: '避坑' }, + { title: '代码/例题', desc: '可复制片段,标注输入输出与边界条件。', tag: '实践' }, + ], + travel: [ + { title: 'Day 1–2', desc: '抵达与市区精华,控制步行强度。', tag: '行程' }, + { title: 'Day 3–4', desc: '深度体验与小众点位,预留弹性时间。', tag: '深度' }, + { title: '美食地图', desc: '早/午/晚各 2 家备选,标注人均与排队时段。', tag: '美食' }, + ], + creative: [ + { title: '概念阐述', desc: '设计意图、受众与使用场景说明。', tag: '概念' }, + { title: '视觉规范', desc: '字体、配色、网格与留白规则。', tag: '规范' }, + { title: '延展应用', desc: '海报、社媒、印刷等多场景适配方案。', tag: '延展' }, + ], + }; + + const sections = [ + { + type: 'metrics', + title: '核心亮点', + hint: title, + items: metricsPool[categorySlug] ?? metricsPool.other, + }, + ]; + + if (cardsPool[categorySlug]) { + sections.push({ + type: 'cards', + title: '内容结构', + hint: summary.slice(0, 60), + items: cardsPool[categorySlug], + }); + } + + sections.push({ + type: 'text', + title: '详细说明', + html: `

${esc(summary)}

${tagList}`, + }); + + sections.push({ + type: 'quote', + html: `「${esc(title)}」—— 由 MindSpace 创作并公开发布到 TKMind 广场。`, + }); + + return sections; +} + +export function buildCategoryPage(categorySlug, title, summary, tags = []) { + const theme = CATEGORY_THEMES[categorySlug] ?? CATEGORY_THEMES.other; + const heroImage = theme.images[hashTitle(title) % theme.images.length]; + const bgDark = categorySlug === 'creative' ? '#0f0a1a' : '#0a1628'; + + return buildJohnPage({ + title, + description: summary, + tag: theme.tag, + tagEmoji: theme.tagEmoji, + accent: theme.accent, + accent2: theme.accent2, + bgDark, + heroImage, + heroTitleHtml: buildHeroTitleHtml(title), + heroSubtitle: summary, + heroBtn: '开始阅读', + sections: buildCategorySections(categorySlug, title, summary, tags), + footer: 'TKMind Plaza · MindSpace 公开发布', + }); +} + +// legacy export — post_id keyed enrichments kept for existing seeded posts +export function getEnrichmentLegacy(postId, fallbackTitle) { + const item = ENRICHMENTS[postId]; + if (item) return item; + return getEnrichment(postId, fallbackTitle); +} diff --git a/scripts/run-memind-portal-prod.sh b/scripts/run-memind-portal-prod.sh new file mode 100755 index 0000000..ec9cfee --- /dev/null +++ b/scripts/run-memind-portal-prod.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Memind Portal 生产进程(g2.tkmind.cn / Plaza API 共用 :8081) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +if [[ -f "${ROOT}/.env" ]]; then + set -a + # shellcheck disable=SC1091 + source "${ROOT}/.env" + set +a +fi + +export NODE_ENV=production +export H5_PORT="${H5_PORT:-8081}" + +NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@22/bin/node}" +if [[ ! -x "${NODE_BIN}" ]]; then + NODE_BIN="$(command -v node)" +fi + +exec "${NODE_BIN}" server.mjs diff --git a/scripts/run-plaza-prod.sh b/scripts/run-plaza-prod.sh new file mode 100755 index 0000000..5e955f4 --- /dev/null +++ b/scripts/run-plaza-prod.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Plaza Next.js 生产进程(plaza.tkmind.cn → Cloudflare Tunnel :3001) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +if [[ -f "${ROOT}/.env" ]]; then + set -a + # shellcheck disable=SC1091 + source "${ROOT}/.env" + set +a +fi + +export NODE_ENV=production +PORTAL_PORT="${H5_PORT:-8081}" +PLAZA_PORT="${PLAZA_PORT:-3001}" + +NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@22/bin/node}" +if [[ ! -x "${NODE_BIN}" ]]; then + NODE_BIN="$(command -v node)" +fi + +echo "==> 等待 Portal :${PORTAL_PORT}…" +for _ in $(seq 1 120); do + if curl -sf "http://127.0.0.1:${PORTAL_PORT}/auth/status" >/dev/null 2>&1; then + break + fi + sleep 1 +done + +if ! curl -sf "http://127.0.0.1:${PORTAL_PORT}/auth/status" >/dev/null 2>&1; then + echo "Portal 未就绪 (:${PORTAL_PORT}),Plaza 退出" >&2 + exit 1 +fi + +exec "${NODE_BIN}" scripts/start-plaza-prod.mjs -- --skip-build --no-portal diff --git a/scripts/seed-plaza-covers.mjs b/scripts/seed-plaza-covers.mjs index 09591e8..9ae90d7 100644 --- a/scripts/seed-plaza-covers.mjs +++ b/scripts/seed-plaza-covers.mjs @@ -23,11 +23,96 @@ function loadEnvFile(filePath) { loadEnvFile(path.join(root, '../../.env.local')); loadEnvFile(path.join(root, '.env')); +const COVER_QUALITY = 'auto=format&fit=crop&w=1920&h=1200&q=90'; + +const CATEGORY_IMAGE_POOLS = { + 'work-report': [ + `https://images.unsplash.com/photo-1454165804606-c3d57bc86b40?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1460925895917-afdab827c52f?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1551288049-bebda4e38f71?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1522071820081-009f0129c71c?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1486312338219-ce68d2c6f44d?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1504384308090-c894fdcc538d?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1542744173-8e7e53415bb0?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1600880292203-757bb62b4baf?${COVER_QUALITY}`, + ], + 'study-notes': [ + `https://images.unsplash.com/photo-1456513080510-7bf3a84b82f8?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1516116216624-53e697fedbea?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1515879214367-846362d3526?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1676299085922-0a9a504459f8?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1516321318423-f06f85e504b3?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1434030216411-0b793f4b4173?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1503676260728-1c00da094a0b?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1524995998267-8169c4e50d05?${COVER_QUALITY}`, + ], + creative: [ + `https://images.unsplash.com/photo-1513364776144-60967b0f800f?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1514565131-fce0801e5785?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1511379938544-be09687a4d4b?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1541961017774-22349e4a1262?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1558591710-4b4a1ae0f04d?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1561998338-13ad7886534d?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1579783902614-a3fb3927b6a5?${COVER_QUALITY}`, + ], + business: [ + `https://images.unsplash.com/photo-1441986300917-64674bd600d8?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1518611012118-696072aa579a?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1554118811-1e0d58224f24?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1447933601403-0c6688de566e?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1556742049-0cfed4f6a45d?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1560472354-b33ff0c44a43?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1414235077428-338989a2e8c0?${COVER_QUALITY}`, + ], + travel: [ + `https://images.unsplash.com/photo-1506905925346-21bda4d32df4?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1596422846544-75c675fcd5d0?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1493976040374-85c8e577f47e?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1488646953014-85cb44e25828?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1476514525535-07fb3b4fcc5f?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1501785888041-af3ef285b470?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1528127269322-539801943592?${COVER_QUALITY}`, + ], + 'data-analysis': [ + `https://images.unsplash.com/photo-1551288049-bebda4e38f71?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1543286386-713bdd548da4?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1504868584819-f8e8b4b6d7e3?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1555949963-aa79dcee981c?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1518186285589-2f7649de83e0?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1573164713714-d95e436ab8d6?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1639322537504-6427a16b0a28?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1591696205602-2f950c417cb9?${COVER_QUALITY}`, + ], + lifestyle: [ + `https://images.unsplash.com/photo-1493663284031-b7e3aefcae8e?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1497215842964-222b430dc094?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1455390582260-0446deae2f3f?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1416879595882-3373a0480b5b?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1498805903192-2f734adc632a?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1517487881594-2787fef5ebf7?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1556911220-e15b29be8c8f?${COVER_QUALITY}`, + ], + other: [ + `https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1484480974693-6ca0a7823cce?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1516321318423-f06f85e504b3?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1529156069898-49953e39b3ac?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1517245386807-bb43f82c33c4?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1522202176988-66273c2fd55f?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1515378792601-1a587fd37b2d?${COVER_QUALITY}`, + `https://images.unsplash.com/photo-1523240795612-9a054b0db644?${COVER_QUALITY}`, + ], +}; + const COVER_SPECS = [ { match: 'Q2 产品增长复盘', file: 'work-report-growth.jpg', - url: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=1280&h=800&q=85', + url: `https://images.unsplash.com/photo-1551288049-bebda4e38f71?${COVER_QUALITY}`, }, { match: 'AI 客服落地', @@ -137,21 +222,36 @@ const CATEGORY_FALLBACKS = { other: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1280&h=800&q=85', }; -function slugify(value) { - return String(value) +function hashTitle(title) { + let hash = 0; + for (let i = 0; i < title.length; i += 1) { + hash = (hash * 31 + title.charCodeAt(i)) >>> 0; + } + return hash; +} + +function coverFileName(categorySlug, title) { + const hash = hashTitle(title).toString(36); + const asciiSlug = String(title) + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') .toLowerCase() - .replace(/[^\p{L}\p{N}]+/gu, '-') + .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') - .slice(0, 48); + .slice(0, 32); + const suffix = asciiSlug ? `${asciiSlug}-${hash}` : hash; + return `${categorySlug}-${suffix}.jpg`; } function resolveSpec(title, categorySlug) { const spec = COVER_SPECS.find((item) => title.includes(item.match)); if (spec) return spec; + const pool = CATEGORY_IMAGE_POOLS[categorySlug] ?? CATEGORY_IMAGE_POOLS.other; + const url = pool[hashTitle(title) % pool.length]; return { match: title, - file: `${categorySlug}-${slugify(title)}.jpg`, - url: CATEGORY_FALLBACKS[categorySlug] ?? CATEGORY_FALLBACKS.other, + file: coverFileName(categorySlug, title), + url, }; } @@ -208,7 +308,7 @@ const [posts] = await pool.query( let updated = 0; for (const post of posts) { const spec = resolveSpec(post.title, post.category_slug); - await downloadCover(spec, CATEGORY_FALLBACKS[post.category_slug]); + await downloadCover(spec, (CATEGORY_IMAGE_POOLS[post.category_slug] ?? CATEGORY_IMAGE_POOLS.other)[0]); const coverUrl = `${coverBase}/${spec.file}`; if (post.cover_url === coverUrl) continue; await pool.query(`UPDATE plaza_posts SET cover_url = ?, updated_at = ? WHERE id = ?`, [ diff --git a/scripts/seed-plaza-demo.mjs b/scripts/seed-plaza-demo.mjs index 57107f7..137e731 100644 --- a/scripts/seed-plaza-demo.mjs +++ b/scripts/seed-plaza-demo.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; import { createDbPool, initSchema } from '../db.mjs'; import { createPlazaPostService } from '../plaza-posts.mjs'; import { initializeDefaultSpace } from '../mindspace.mjs'; +import { EXTRA_DEMO_POSTS } from './plaza-demo-posts.mjs'; const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -25,7 +26,7 @@ function loadEnvFile(filePath) { loadEnvFile(path.join(root, '../../.env.local')); loadEnvFile(path.join(root, '.env')); -const DEMO_POSTS = [ +const BASE_DEMO_POSTS = [ { category: 'work-report', title: 'Q2 产品增长复盘:从 0 到 10 万 DAU', @@ -198,6 +199,8 @@ const DEMO_POSTS = [ }, ]; +const DEMO_POSTS = [...BASE_DEMO_POSTS, ...EXTRA_DEMO_POSTS]; + async function loadUser(pool, username) { const [rows] = await pool.query( `SELECT id, username, slug, display_name FROM h5_users WHERE username = ? LIMIT 1`, diff --git a/scripts/start-plaza-prod.mjs b/scripts/start-plaza-prod.mjs index eacf11e..451b850 100644 --- a/scripts/start-plaza-prod.mjs +++ b/scripts/start-plaza-prod.mjs @@ -21,6 +21,22 @@ const plazaPublicBase = ( ).replace(/\/$/, ''); const portalUrl = `http://127.0.0.1:${portalPort}`; const skipBuild = process.argv.includes('--skip-build'); +const noPortal = process.argv.includes('--no-portal'); + +function resolveNpmBin() { + const candidates = [ + process.env.NPM_BIN, + '/opt/homebrew/opt/node@22/bin/npm', + '/opt/homebrew/bin/npm', + '/usr/local/bin/npm', + ].filter(Boolean); + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + return 'npm'; +} + +const npmBin = resolveNpmBin(); function loadEnvFile(filePath) { if (!fs.existsSync(filePath)) return; @@ -127,6 +143,9 @@ freePort(plazaPort); try { if (!(await portOpen(portalPort))) { + if (noPortal) { + throw new Error(`Portal 未运行 (${portalUrl}),请先启动 cn.tkmind.memind-portal`); + } console.log(`==> 启动 Portal @ ${portalUrl}`); portal = spawnChild('node', ['server.mjs'], 'portal'); await waitFor(() => portOpen(portalPort), 'Portal'); @@ -136,14 +155,14 @@ try { if (!skipBuild) { console.log('==> 构建 Plaza Next.js(生产)…'); - execSync('npm run build', { cwd: plazaDir, stdio: 'inherit', env: { ...process.env, ...plazaEnv } }); + execSync(`"${npmBin}" run build`, { cwd: plazaDir, stdio: 'inherit', env: { ...process.env, ...plazaEnv }, shell: true }); } else { console.log('==> 跳过 next build(--skip-build)'); } console.log(`==> 启动 Plaza Next.js 正式服务 (0.0.0.0:${plazaPort})`); plaza = spawnChild( - 'npm', + npmBin, ['run', 'start', '--', '-H', '0.0.0.0', '-p', String(plazaPort)], 'plaza', plazaDir, diff --git a/server.mjs b/server.mjs index a84fa4f..2b799b3 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, PUBLIC_ZONE_DIR, resolvePublishDir } from './user-publish.mjs'; +import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublicBaseUrl, resolvePublishDir } from './user-publish.mjs'; import { ensureWorkspaceHtmlThumbnail, startWorkspaceThumbnailWatcher, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs'; import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs'; import { attachRequestId, sendData, sendError } from './api-response.mjs'; @@ -31,6 +31,7 @@ import { createMindSpaceService } from './mindspace.mjs'; import { createAssetService } from './mindspace-assets.mjs'; import { createPageService, pageInternals } from './mindspace-pages.mjs'; import { createPageLiveEditService } from './mindspace-page-live-edit.mjs'; +import { createImageGenerationService } from './image-generation.mjs'; import { createPageEditSessionService } from './mindspace-page-edit-session.mjs'; import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs'; import { createPublicationService } from './mindspace-publications.mjs'; @@ -54,7 +55,6 @@ import { createCleanupService } from './mindspace-cleanup.mjs'; import { createAgentJobService } from './mindspace-agent-jobs.mjs'; import { createMindSpaceAgentRunner } from './mindspace-agent-runner.mjs'; import { - analyzeChatMessageForSave, buildChatSavePreviewFrameUrl, buildChatSaveThumbnailUrl, buildWorkspaceAssetUrl, @@ -62,7 +62,6 @@ import { buildWorkspaceThumbnailUrl, injectHtmlBaseHref, resolveChatSaveAnalysis, - resolveStaticHtmlContent, } from './mindspace-chat-save.mjs'; import { generateHtmlThumbnail } from './mindspace-thumbnails.mjs'; import { scanContent } from './mindspace-content-scan.mjs'; @@ -101,6 +100,7 @@ const API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret'; const INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET ?? API_SECRET; const ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD; const USERS_ROOT = process.env.H5_USERS_ROOT ?? path.join(__dirname, 'users'); +const PUBLIC_BASE_URL = resolvePublicBaseUrl(process.env); const app = express(); app.set('trust proxy', 1); @@ -176,6 +176,7 @@ let mindSpaceAssets = null; let mindSpaceAudit = null; let mindSpacePages = null; let mindSpacePageLiveEdit = null; +let imageGeneration = null; let mindSpacePageEditSession = null; let mindSpacePublications = null; let plazaPosts = null; @@ -225,6 +226,18 @@ async function bootstrapUserAuth() { return rows[0]?.user_id ?? null; }, }); + imageGeneration = createImageGenerationService({ + pool, + h5Root: __dirname, + publicBaseUrl: PUBLIC_BASE_URL, + resolveUserIdForAgentSession: async (sessionId) => { + const [rows] = await pool.query( + `SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`, + [sessionId], + ); + return rows[0]?.user_id ?? null; + }, + }); mindSpacePublications = createPublicationService(pool, { storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'), @@ -436,7 +449,16 @@ app.post('/auth/login', jsonBody, async (req, res) => { return res.status(401).json({ message: result.message }); } setUserLoginCookies(res, req, result.token); - return res.json({ authenticated: true, user: result.user, mode: 'user' }); + const row = await userAuth.getUserById(result.user.id); + const capabilityState = row ? await userAuth.resolveUserCapabilities(row) : null; + return res.json({ + authenticated: true, + user: result.user, + mode: 'user', + capabilities: capabilityState?.capabilities, + grantedSkills: capabilityState?.grantedSkills ?? [], + unrestricted: capabilityState?.unrestricted, + }); } if (!legacyAuth) { @@ -1289,6 +1311,7 @@ api.use(async (req, res, next) => { if (req.path === '/status') return next(); if (req.path.startsWith('/internal/agent/')) return next(); if (req.path === '/agent/mindspace_page_patch') return next(); + if (req.path === '/agent/generate_image') return next(); const plazaPublic = isPlazaPublicRead(req.path, req.method); @@ -2068,6 +2091,7 @@ async function resolveChatSaveBundle(user, h5Root, input = {}) { username: user.username, h5Root, selectedLinkIndex, + publicBaseUrl: PUBLIC_BASE_URL, }); return { source, @@ -2224,12 +2248,13 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => { req.body?.session_id, req.body?.message_id, ); - const analysis = analyzeChatMessageForSave({ + const { analysis, resolvedHtml } = await resolveChatSaveAnalysis({ content: source.content, userId: req.currentUser.id, username: req.currentUser.username, h5Root: __dirname, selectedLinkIndex: Number(req.body?.selected_link_index ?? 0), + publicBaseUrl: PUBLIC_BASE_URL, }); const snapshot = { session_name: source.session.name, @@ -2237,13 +2262,9 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => { role: source.message.role, content_mode: analysis.contentMode, public_url: analysis.previewUrl, - relative_path: analysis.relativePath, + relative_path: resolvedHtml?.relativePath ?? analysis.relativePath, }; - let resolvedHtml = null; - if (analysis.contentMode === 'static_html') { - resolvedHtml = await resolveStaticHtmlContent(analysis).catch(() => null); - } const privacyScan = scanContent(resolvedHtml?.content ?? source.content); assertPrivateSaveAllowed(categoryCode, privacyScan, req.body?.acknowledged_finding_ids); @@ -2309,7 +2330,7 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => { if (analysis.contentMode === 'static_html' && resolvedHtml?.content && analysis.relativePath) { const publishDir = resolvePublishDir(__dirname, req.currentUser); - await ensureWorkspaceHtmlThumbnail(publishDir, analysis.relativePath, resolvedHtml.content, { + void ensureWorkspaceHtmlThumbnail(publishDir, analysis.relativePath, resolvedHtml.content, { title: pageInput.title, subtitle: pageInput.summary, }).catch(() => {}); @@ -2557,6 +2578,37 @@ api.post('/agent/mindspace_page_patch', async (req, res) => { } }); +api.post('/agent/generate_image', async (req, res) => { + if (!imageGeneration) { + return res.status(503).json({ message: '图片生成未启用' }); + } + try { + const result = await imageGeneration.generateForAgentSession(req.body ?? {}); + return sendData(res, req, { + relative_path: result.relativePath, + public_url: result.publicUrl, + mime_type: result.mimeType, + size: result.size, + style: result.style, + quality: result.quality, + revised_prompt: result.revisedPrompt, + prompt: result.prompt, + }); + } catch (error) { + const code = error?.code ?? 'internal_error'; + const statusByCode = { + image_gen_unavailable: 503, + image_gen_not_configured: 503, + invalid_request: 400, + invalid_output_path: 400, + forbidden: 403, + image_gen_failed: 502, + }; + const status = statusByCode[code] ?? 500; + return sendError(res, req, status, code, error?.message || '图片生成失败', error?.details); + } +}); + api.get('/mindspace/v1/pages/:pageId/thumbnail', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { diff --git a/skills-registry.mjs b/skills-registry.mjs index c38a5e7..7241689 100644 --- a/skills-registry.mjs +++ b/skills-registry.mjs @@ -15,6 +15,7 @@ export const DEFAULT_USER_SKILLS = { search: true, 'form-builder': true, 'table-viewer': true, + 'image-designer': true, [PUBLISH_SKILL_NAME]: false, }; @@ -24,6 +25,7 @@ export const USER_ROLE_SKILL_PRESETS = { creator: { ...DEFAULT_USER_SKILLS, [PUBLISH_SKILL_NAME]: true, + 'image-designer': true, kanban: true, timeline: true, }, diff --git a/skills-registry.test.mjs b/skills-registry.test.mjs index 664b2a2..ef9748b 100644 --- a/skills-registry.test.mjs +++ b/skills-registry.test.mjs @@ -15,6 +15,7 @@ const h5Root = path.dirname(fileURLToPath(import.meta.url)); test('lists static-page-publish in platform catalog', () => { const catalog = listPlatformSkillCatalog(h5Root); assert.ok(catalog.some((item) => item.name === 'static-page-publish')); + assert.ok(catalog.some((item) => item.name === 'image-designer')); }); test('granting publish skill enables static_publish capability', () => { @@ -46,5 +47,6 @@ test('DEFAULT_USER_SKILLS enables common platform skills', () => { assert.equal(DEFAULT_USER_SKILLS.search, true); assert.equal(DEFAULT_USER_SKILLS['form-builder'], true); assert.equal(DEFAULT_USER_SKILLS['table-viewer'], true); + assert.equal(DEFAULT_USER_SKILLS['image-designer'], true); assert.equal(DEFAULT_USER_SKILLS['static-page-publish'], false); }); diff --git a/skills/image-designer/SKILL.md b/skills/image-designer/SKILL.md new file mode 100644 index 0000000..1255e1e --- /dev/null +++ b/skills/image-designer/SKILL.md @@ -0,0 +1,127 @@ +--- +name: image-designer +description: 高级视觉设计师技能:根据用户需求生成高质量图片,保存到 MindSpace 工作区 +--- + +# 高级视觉设计师(AI 生图) + +你是一位 **高级视觉设计师 / 艺术指导**。你的任务不是简单「画一张图」,而是: + +- 理解用户的商业/品牌/内容目标 +- 把模糊需求翻译成可执行的视觉方案 +- 生成可直接用于页面 hero、封面、海报、产品图、社交配图的高质量图片 +- 交付可引用、可复用的文件路径与预览链接 + +## 何时使用 + +- 用户要「生成图片 / 做一张图 / 设计封面 / hero 图 / 海报 / 插画 / 产品图」 +- 用户要为静态页面、MindSpace 卡片、Plaza 帖子准备主视觉 +- 用户描述风格、主题、配色、构图,希望直接得到成品图 + +## 前提 + +1. 用户已开通 **image-designer** 技能 +2. 服务端已启用图片生成(`H5_IMAGE_GEN_ENABLED=1`,并配置 OpenAI 兼容 `/v1/images/generations`) +3. 你具备 **shell** 权限(用于调用生成 API) +4. 建议同时启用 **image_read**(生成后用 `read_image` 自检效果) + +## 设计师工作流 + +1. **澄清需求**(信息不足时最多追问 3 点) + - 用途:页面 hero / 封面 / 海报 / 产品图 / 头像 / 背景 + - 主体:人物、产品、场景、抽象概念 + - 风格:写实 / 插画 / 扁平 / 3D / 国潮 / 极简 / 电影感 + - 画幅:方图 1:1、竖版 3:4、横版 16:9 + - 品牌色、情绪、禁用元素(文字、logo、水印等) +2. **形成视觉方案**(先对用户用 2–4 句中文说明设计方向,再执行生图) +3. **调用生成 API**(每次只生成 1 张,确认满意后再批量) +4. **自检**(用 `read_image` 查看生成结果;不满意则调整 prompt 重生成) +5. **交付**(给出相对路径 + 可点击公网链接 + 使用建议) + +## 生成 API(必须) + +使用当前 Agent **session_id**,通过 shell 调用 Portal API: + +```bash +curl -sS -X POST 'http://127.0.0.1:8081/api/agent/generate_image' \ + -H 'Content-Type: application/json' \ + -d '{ + "session_id": "<当前会话ID>", + "prompt": "用户原始需求(中文或英文均可)", + "output_path": "assets/hero.png", + "style_notes": "modern editorial, warm sunset palette, premium travel mood", + "aspect_ratio": "3:4 portrait hero", + "brand_colors": "#ff6b35, #24243e", + "subject": "Malaysia travel landscape", + "size": "1024x1792", + "style": "vivid", + "quality": "hd" + }' +``` + +### 字段说明 + +| 字段 | 必填 | 说明 | +|------|------|------| +| `session_id` | 是 | 当前 Agent 会话 ID | +| `prompt` | 是 | 用户需求的自然语言描述 | +| `output_path` | 否 | 工作区相对路径,默认 `assets/.png` | +| `style_notes` | 否 | 风格/氛围/材质/镜头语言(英文更稳) | +| `aspect_ratio` | 否 | 画幅意图,如 `1:1` / `3:4` / `16:9` | +| `brand_colors` | 否 | 品牌主色 hex | +| `subject` | 否 | 画面主体 | +| `size` | 否 | `1024x1024` / `1024x1792` / `1792x1024` 等 | +| `style` | 否 | `vivid`(更饱和)或 `natural`(更自然) | +| `quality` | 否 | `standard` 或 `hd` | + +### 输出路径规范 + +- 页面 hero / 封面:`assets/-hero.png` +- 公开配图:`public/-cover.png` +- 多图系列:`assets/-01.png`、`assets/-02.png` +- **禁止**写到工作区外;仅支持 `.png` / `.jpg` / `.webp` + +## Prompt 编写原则(设计师视角) + +1. **先定用途再定风格**:海报要冲击,报告封面要克制,产品图要清晰 +2. **主体 + 场景 + 光线 + 材质 + 构图** 五要素齐全 +3. **默认不要文字**:除非用户明确要求图中文字;UI 文案应放在 HTML 里 +4. **避免侵权风格**:不要写「某某品牌/logo/明星脸/受版权保护的角色」 +5. **中文需求可保留**,但 `style_notes` / 镜头描述建议用英文以提高稳定性 +6. 不满意时优先改:**构图 > 光线 > 风格 > 细节**,不要一次改太多变量 + +## 与静态页发布协作 + +若用户最终要生成 HTML 页面: + +1. 先用本技能生成 hero 图到 `assets/` +2. 再在 `mindspace-cover` 元数据中引用,例如 `"cover":"assets/hero.png"` +3. 需要发布链接时,配合 **static-page-publish** 技能 + +## 回复格式(必须) + +交付图片时使用 Markdown 可点击链接: + +```markdown +已为你生成马来西亚旅行 hero 图: + +- 文件:`assets/malaysia-hero.png` +- 预览:[马来西亚旅行 Hero](https://goo.tkmind.cn/MindSpace/<用户ID>/assets/malaysia-hero.png) + +设计说明:电影感暖色日落、3:4 竖版构图,适合信息流封面与页面首屏。 +``` + +要求: + +- **必须**给出相对路径 +- **必须**给出完整可点击 URL(若 API 返回 `publicUrl` 则直接使用) +- 简要说明设计决策(风格、配色、构图) +- 若效果未达标,主动提出 1–2 个可执行的修改方向 + +## 禁止事项 + +- 不要声称「已生成」但未实际调用 API 或未写入文件 +- 不要用 `read_image` 读取外链来代替本地生成文件 +- 不要生成违法、色情、暴力、歧视、欺诈类内容 +- 不要把图片保存到 `users/`、项目根目录或其它用户目录 +- 不要在未确认需求时连续生成大量图片(浪费额度) diff --git a/src/App.tsx b/src/App.tsx index 5fd3443..99c311b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { AuthView } from './components/AuthView'; import { ChatView } from './components/ChatView'; import { MindSpaceView } from './components/MindSpaceView'; import { ChatProvider } from './context/ChatProvider'; +import { AppSessionProvider } from './context/AppSessionContext'; import { PREVIEW_USER } from './dev/mindspacePreviewData'; import { MindSpaceRoute } from './routes/MindSpaceRoute'; import type { CapabilityMap, PortalUser } from './types'; @@ -88,16 +89,18 @@ function AuthenticatedApp({ ); return ( - - - } - /> - - } /> - - + + + + } + /> + + } /> + + + ); } @@ -115,18 +118,32 @@ export function App() { setUnauthorizedHandler(() => { setAuthed(false); setUser(null); + setGrantedSkills(undefined); + setCapabilities(undefined); if (window.location.pathname !== '/') { navigate('/', { replace: true }); } }); - void checkAuth().then((status) => { - setLegacyMode(status.mode === 'legacy'); - setAuthed(status.authenticated); - setUser(status.user ?? null); - setCapabilities(status.capabilities); - setGrantedSkills(status.grantedSkills); - }); - return () => setUnauthorizedHandler(null); + + const syncAuth = () => + checkAuth().then((status) => { + setLegacyMode(status.mode === 'legacy'); + setAuthed(status.authenticated); + setUser(status.user ?? null); + setCapabilities(status.capabilities); + setGrantedSkills(status.grantedSkills); + }); + + void syncAuth(); + + const handleFocus = () => { + void syncAuth(); + }; + window.addEventListener('focus', handleFocus); + return () => { + window.removeEventListener('focus', handleFocus); + setUnauthorizedHandler(null); + }; }, [mindSpacePreview, navigate]); if (mindSpacePreview) { diff --git a/src/api/client.ts b/src/api/client.ts index eb340ab..81f6394 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -272,14 +272,15 @@ export async function checkAuth(): Promise { if (!response.ok) return { authenticated: false }; const status = (await response.json()) as AuthStatus; if (status.authenticated) resetUnauthorizedGuard(); - if (status.authenticated && status.mode === 'user' && !status.capabilities) { + if (status.authenticated && status.mode === 'user') { try { const me = await getMe(); return { ...status, - capabilities: me.capabilities, - grantedSkills: me.grantedSkills, - user: me.user, + user: me.user ?? status.user, + capabilities: me.capabilities ?? status.capabilities, + grantedSkills: me.grantedSkills ?? status.grantedSkills, + unrestricted: me.unrestricted ?? status.unrestricted, }; } catch { return status; diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index e24cc88..1bf5408 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { useAppSession } from '../context/AppSessionContext'; import { useNetworkStatus } from '../hooks/useNetworkStatus'; import { openAvatarPicker } from '../utils/userAvatar'; import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills'; @@ -44,6 +45,7 @@ export function ChatPanel({ onClose?: () => void; }) { const online = useNetworkStatus(); + const { capabilities: sessionCapabilities, grantedSkills: sessionGrantedSkills } = useAppSession(); const [input, setInput] = useState(''); const [pageSource, setPageSource] = useState(null); const [voiceNotice, setVoiceNotice] = useState(null); @@ -56,9 +58,14 @@ export function ChatPanel({ const busy = chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting'; const offlineBlocked = !online; const publishSkillName = user?.publishSkillName ?? 'static-page-publish'; - const hasPublishSkill = grantedSkills?.includes(publishSkillName) ?? false; - const canPublish = Boolean(capabilities?.static_publish) || hasPublishSkill; - const chatSkills = filterChatSkills(CHAT_SKILL_OPTIONS, { grantedSkills, canPublish }); + const effectiveCapabilities = capabilities ?? sessionCapabilities; + const effectiveGrantedSkills = grantedSkills ?? sessionGrantedSkills; + const hasPublishSkill = effectiveGrantedSkills?.includes(publishSkillName) ?? false; + const canPublish = Boolean(effectiveCapabilities?.static_publish) || hasPublishSkill; + const chatSkills = filterChatSkills(CHAT_SKILL_OPTIONS, { + grantedSkills: effectiveGrantedSkills, + canPublish, + }); const compact = variant === 'compact'; const handleSubmit = async () => { @@ -128,6 +135,9 @@ export function ChatPanel({ onSaved={(result) => { setPageSource(null); onPageSaved?.(result); + if (result.kind === 'page' && result.pageId) { + onClose?.(); + } }} /> )} diff --git a/src/components/ChatSkillIcons.tsx b/src/components/ChatSkillIcons.tsx index 0f91962..1b4563a 100644 --- a/src/components/ChatSkillIcons.tsx +++ b/src/components/ChatSkillIcons.tsx @@ -90,6 +90,17 @@ function AnalyzeIcon({ className }: IconProps) { ); } +function ImageIcon({ className }: IconProps) { + return ( + + ); +} + const ICONS: Record JSX.Element> = { spark: SparkIcon, page: PageIcon, @@ -99,6 +110,7 @@ const ICONS: Record JSX.Element> = { table: TableIcon, summary: SummaryIcon, analyze: AnalyzeIcon, + image: ImageIcon, }; export function ChatSkillIcon({ id, className }: { id: ChatSkillIconId; className?: string }) { diff --git a/src/components/MindSpacePageDetail.tsx b/src/components/MindSpacePageDetail.tsx index eae733e..882b009 100644 --- a/src/components/MindSpacePageDetail.tsx +++ b/src/components/MindSpacePageDetail.tsx @@ -156,6 +156,7 @@ export function MindSpacePageDetail({ const [plazaPost, setPlazaPost] = useState(null); const [pushToPlaza, setPushToPlaza] = useState(false); const [fixNotice, setFixNotice] = useState(null); + const [saving, setSaving] = useState(false); const applyPageRecord = useCallback( (next: MindSpacePage, options?: { recordHistory?: boolean }) => { @@ -468,6 +469,61 @@ export function MindSpacePageDetail({ } }, [applyPageRecord, content, markPreviewRefreshPending, page, pageId, summary, title, triggerChangeHighlight]); + const isDirty = + Boolean(page) && + (title !== page.title || + summary !== page.summary || + (content ?? '') !== (page.content ?? '') || + templateId !== page.templateId); + + const save = useCallback(async () => { + if (!page || saving) return; + if (!title.trim()) { + setError('标题不能为空'); + return; + } + if (!content.trim()) { + setError('页面内容不能为空'); + return; + } + setSaving(true); + setError(null); + try { + const updated = await updateMindSpacePage(page.id, { + expectedVersion: page.versionNo, + title: title.trim(), + summary: summary.trim(), + content, + templateId, + changeNote: `保存草稿 v${page.versionNo + 1}`, + }); + applyPageRecord(updated); + resetDraft({ + title: updated.title, + summary: updated.summary, + content: updated.content ?? '', + }); + setPreviewContent(updated.content ?? ''); + setPreviewRefreshPending(false); + setPreviewKey((value) => value + 1); + await onSaved(); + } catch (err) { + setError(err instanceof Error ? err.message : '页面保存失败'); + } finally { + setSaving(false); + } + }, [ + applyPageRecord, + content, + onSaved, + page, + resetDraft, + saving, + summary, + templateId, + title, + ]); + const applyOneClickFix = async () => { if (!page) return; setPublishing(true); @@ -520,6 +576,11 @@ export function MindSpacePageDetail({ ) { return; } + if (event.key.toLowerCase() === 's') { + event.preventDefault(); + void save(); + return; + } if (event.key.toLowerCase() === 'z' && event.shiftKey) { const next = redo(); if (next) { @@ -538,7 +599,7 @@ export function MindSpacePageDetail({ }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [redo, syncPreviewAfterHistory, undo]); + }, [redo, save, syncPreviewAfterHistory, undo]); useEffect(() => { if (!page || !onContextUpdate) return; @@ -1049,6 +1110,21 @@ export function MindSpacePageDetail({ /> )} +
+ + {isDirty + ? '有未保存的修改。保存会创建新版本,历史版本不会被覆盖。' + : '保存会创建新版本,历史版本不会被覆盖。'} + + +
@@ -1083,6 +1159,17 @@ export function MindSpacePageDetail({ > 刷新预览{previewRefreshPending ? ' · 有新修改' : ''} + {page.contentFormat === 'html' ? ( + + ) : null}
{page.contentFormat === 'html' ? ( @@ -1169,6 +1256,9 @@ export function MindSpacePageDetail({ onRefresh={() => handleManualPreviewRefresh()} refreshPending={previewRefreshPending} onContentChange={handlePreviewContentChange} + onSave={() => void save()} + saving={saving} + canSave={isDirty && Boolean(title.trim()) && Boolean(content.trim())} /> ) : null} diff --git a/src/components/MindSpacePageFullscreenPreview.tsx b/src/components/MindSpacePageFullscreenPreview.tsx index 81bad57..e26c2fd 100644 --- a/src/components/MindSpacePageFullscreenPreview.tsx +++ b/src/components/MindSpacePageFullscreenPreview.tsx @@ -16,6 +16,9 @@ export function MindSpacePageFullscreenPreview({ onRefresh, refreshPending = false, onContentChange, + onSave, + saving = false, + canSave = false, }: { pageTitle: string; title: string; @@ -30,6 +33,9 @@ export function MindSpacePageFullscreenPreview({ onRefresh: () => void; refreshPending?: boolean; onContentChange: (html: string) => void; + onSave?: () => void; + saving?: boolean; + canSave?: boolean; }) { useEffect(() => { const previousOverflow = document.body.style.overflow; @@ -51,6 +57,13 @@ export function MindSpacePageFullscreenPreview({ } const mod = event.metaKey || event.ctrlKey; + if (mod && !event.altKey && event.key.toLowerCase() === 's') { + if (onSave && canSave && !saving) { + event.preventDefault(); + onSave(); + } + return; + } if (mod && !event.altKey && event.key.toLowerCase() === 'z') { if (event.shiftKey) { if (canRedo && onRedo?.()) event.preventDefault(); @@ -66,7 +79,7 @@ export function MindSpacePageFullscreenPreview({ }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [canRedo, canUndo, onClose, onRedo, onUndo]); + }, [canRedo, canSave, canUndo, onClose, onRedo, onSave, onUndo, saving]); return (
@@ -93,7 +106,18 @@ export function MindSpacePageFullscreenPreview({ > 刷新预览{refreshPending ? ' · 有新修改' : ''} - + ) : null} +
diff --git a/src/components/PageSaveDialog.tsx b/src/components/PageSaveDialog.tsx index b6f87ea..e69e4fb 100644 --- a/src/components/PageSaveDialog.tsx +++ b/src/components/PageSaveDialog.tsx @@ -151,16 +151,43 @@ export function PageSaveDialog({ const privateNeedsAck = categoryCode === 'private' && privacyFindings.length > 0 && analysis?.privacyScan.allowed; + const resolveSaveTitle = () => + title.trim() || analysis?.suggestedTitle?.trim() || ''; + + const resolveSaveSummary = () => + summary.trim() || analysis?.suggestedSummary?.trim() || ''; + + const effectiveTitle = resolveSaveTitle(); + const canSave = + Boolean(effectiveTitle) && + !analysisLoading && + !analysisError && + !privateBlocked && + !(privateNeedsAck && !privateAcknowledged); + const save = async () => { - if (!title.trim()) return; + const saveTitle = resolveSaveTitle(); + if (!saveTitle) { + setSaveError('请填写页面标题'); + return; + } + const saveSummary = resolveSaveSummary(); + if (!title.trim() && saveTitle !== title) { + setTitle(saveTitle); + titleRef.current = saveTitle; + } + if (!summary.trim() && saveSummary) { + setSummary(saveSummary); + summaryRef.current = saveSummary; + } setSaving(true); setSaveError(null); try { const result = await saveChatMessageAsPage({ sessionId, messageId, - title: title.trim(), - summary: summary.trim(), + title: saveTitle, + summary: saveSummary, templateId: isStaticHtml ? 'static-html' : templateId, categoryCode, selectedLinkIndex, @@ -170,8 +197,12 @@ export function PageSaveDialog({ : undefined, }); if (result.kind === 'page') { + const pageId = result.page?.id; + if (!pageId) { + throw new Error('保存成功但未返回页面 ID'); + } setSaveNotice(`已保存到${CATEGORY_LABELS[categoryCode]}`); - onSaved({ kind: 'page', categoryCode: 'draft', pageId: result.page.id }); + onSaved({ kind: 'page', categoryCode: 'draft', pageId }); return; } setSaveNotice(`已保存到${CATEGORY_LABELS[result.categoryCode]}`); @@ -370,14 +401,7 @@ export function PageSaveDialog({ type="button" className="page-save-primary" onClick={() => void save()} - disabled={ - saving || - analysisLoading || - !title.trim() || - Boolean(analysisError) || - privateBlocked || - (privateNeedsAck && !privateAcknowledged) - } + disabled={saving || !canSave} > {saving ? '正在保存…' : categoryCode === 'draft' ? '保存并进入编辑' : '保存到空间'} diff --git a/src/components/PageSavePreviewPanel.tsx b/src/components/PageSavePreviewPanel.tsx index a530fd3..a2392ec 100644 --- a/src/components/PageSavePreviewPanel.tsx +++ b/src/components/PageSavePreviewPanel.tsx @@ -85,9 +85,11 @@ export function PageSavePreviewPanel({ ]); if (!analysis.hasHtmlContent) { + const hint = analysis.filename || analysis.relativePath || analysis.previewUrl; return (
- 本地未找到页面文件,请确认 Agent 已将 HTML 保存到 MindSpace 工作区。 + 本地未找到页面文件{hint ? `(${hint})` : ''}。请确认 Agent 已用 write 写入{' '} + public/页面.html,且聊天里的链接路径与实际文件一致;若页面只在公网可访问,请稍后重试或重新生成。
); } diff --git a/src/context/AppSessionContext.tsx b/src/context/AppSessionContext.tsx new file mode 100644 index 0000000..a813d8d --- /dev/null +++ b/src/context/AppSessionContext.tsx @@ -0,0 +1,29 @@ +import { createContext, useContext, type ReactNode } from 'react'; +import type { CapabilityMap } from '../types'; + +type AppSessionContextValue = { + capabilities?: CapabilityMap; + grantedSkills?: string[]; +}; + +const AppSessionContext = createContext({}); + +export function AppSessionProvider({ + capabilities, + grantedSkills, + children, +}: { + capabilities?: CapabilityMap; + grantedSkills?: string[]; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useAppSession() { + return useContext(AppSessionContext); +} diff --git a/src/hooks/useTKMindChat.ts b/src/hooks/useTKMindChat.ts index 67404fa..8338ed9 100644 --- a/src/hooks/useTKMindChat.ts +++ b/src/hooks/useTKMindChat.ts @@ -507,30 +507,43 @@ export function useTKMindChat( setChatState('loading'); setError(null); - let sessionId = readStoredSessionId(userRef.current?.id); - let staleSession = false; + let sessionId: string | null = null; let freshSession: Session | null = null; - if (sessionId) { - try { - await getSession(sessionId); - } catch (err) { - if (err instanceof ApiError && (err.status === 403 || err.status === 404 || err.status === 400)) { - clearStoredSessionId(userRef.current?.id); - sessionId = null; - staleSession = true; - } else { - throw err; - } - } + setSessionsLoading(true); + try { + const items = sortAndTrim(await listSessions()); + setSessions(items); + sessionId = items[0]?.id ?? null; + } catch { + // Fall back to stored session or create a new one when the list is unavailable. + } finally { + setSessionsLoading(false); } if (!sessionId) { - freshSession = await startSession(); - sessionId = freshSession.id; - writeStoredSessionId(userRef.current?.id, sessionId); - if (staleSession) { - setNotice('上次会话已失效,已为你新建对话'); + let staleSession = false; + sessionId = readStoredSessionId(userRef.current?.id); + if (sessionId) { + try { + await getSession(sessionId); + } catch (err) { + if (err instanceof ApiError && (err.status === 403 || err.status === 404 || err.status === 400)) { + clearStoredSessionId(userRef.current?.id); + sessionId = null; + staleSession = true; + } else { + throw err; + } + } + } + + if (!sessionId) { + freshSession = await startSession(); + sessionId = freshSession.id; + if (staleSession) { + setNotice('上次会话已失效,已为你新建对话'); + } } } diff --git a/src/index.css b/src/index.css index dce7ffd..e6acab1 100644 --- a/src/index.css +++ b/src/index.css @@ -990,6 +990,12 @@ body, background: #2f6f57; } +.page-save-actions button:disabled, +.page-save-primary:disabled { + cursor: not-allowed; + opacity: 0.48; +} + .page-save-error { color: #8b2d20; font-size: 13px; @@ -1602,6 +1608,8 @@ body, display: flex; flex-direction: column; min-width: 168px; + max-height: min(320px, 50vh); + overflow-y: auto; padding: 6px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); diff --git a/src/utils/chatSkills.ts b/src/utils/chatSkills.ts index 10ff7b0..4e67f1d 100644 --- a/src/utils/chatSkills.ts +++ b/src/utils/chatSkills.ts @@ -13,7 +13,8 @@ export type ChatSkillIconId = | 'form' | 'table' | 'summary' - | 'analyze'; + | 'analyze' + | 'image'; export type ChatSkillOption = { id: string; diff --git a/user-auth.mjs b/user-auth.mjs index eb29d8b..6539756 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -1,6 +1,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; +import argon2 from 'argon2'; import { computeDeltaCostCents, loadBillingConfig, normalizeTokenState } from './billing.mjs'; import { buildInsufficientBalancePayload, loadRechargeConfig } from './billing-recharge.mjs'; import { @@ -64,25 +65,38 @@ function hashPasswordPbkdf2(password, salt) { return crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex'); } -function hashPasswordArgon2id(password, salt) { - return crypto - .argon2Sync(PASSWORD_ALGORITHM_ARGON2ID, { - message: password, - nonce: Buffer.from(salt, 'hex'), +async function hashPasswordArgon2id(password, salt) { + if (typeof crypto.argon2Sync === 'function') { + return crypto + .argon2Sync(PASSWORD_ALGORITHM_ARGON2ID, { + message: password, + nonce: Buffer.from(salt, 'hex'), + parallelism: ARGON2_PARALLELISM, + tagLength: ARGON2_TAG_LENGTH, + memory: ARGON2_MEMORY, + passes: ARGON2_PASSES, + }) + .toString('hex'); + } + return ( + await argon2.hash(password, { + type: argon2.argon2id, + salt: Buffer.from(salt, 'hex'), + memoryCost: ARGON2_MEMORY, + timeCost: ARGON2_PASSES, parallelism: ARGON2_PARALLELISM, - tagLength: ARGON2_TAG_LENGTH, - memory: ARGON2_MEMORY, - passes: ARGON2_PASSES, + hashLength: ARGON2_TAG_LENGTH, + raw: true, }) - .toString('hex'); + ).toString('hex'); } -function createPasswordRecord(password, algorithm = PASSWORD_ALGORITHM_ARGON2ID) { +async function createPasswordRecord(password, algorithm = PASSWORD_ALGORITHM_ARGON2ID) { const salt = crypto.randomBytes(16).toString('hex'); if (algorithm === PASSWORD_ALGORITHM_ARGON2ID) { return { salt, - passwordHash: hashPasswordArgon2id(password, salt), + passwordHash: await hashPasswordArgon2id(password, salt), passwordAlgorithm: PASSWORD_ALGORITHM_ARGON2ID, }; } @@ -93,10 +107,10 @@ function createPasswordRecord(password, algorithm = PASSWORD_ALGORITHM_ARGON2ID) }; } -function verifyPassword(password, row) { +async function verifyPassword(password, row) { const algorithm = row.password_algorithm || PASSWORD_ALGORITHM_PBKDF2; if (algorithm === PASSWORD_ALGORITHM_ARGON2ID) { - return safeEqual(hashPasswordArgon2id(password, row.salt), row.password_hash); + return safeEqual(await hashPasswordArgon2id(password, row.salt), row.password_hash); } return safeEqual(hashPasswordPbkdf2(password, row.salt), row.password_hash); } @@ -337,7 +351,7 @@ export function createUserAuth(pool, options = {}) { return { ok: false, message: '请输入有效邮箱' }; } - const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password); + const { salt, passwordHash, passwordAlgorithm } = await createPasswordRecord(password); const userId = crypto.randomUUID(); const layout = await publishLayoutFor({ id: userId, username: normalized }); const workspaceRoot = layout.publishDir; @@ -433,7 +447,7 @@ export function createUserAuth(pool, options = {}) { return { ok: false, message: '用户名或密码错误' }; } - if (!verifyPassword(password, row)) { + if (!(await verifyPassword(password, row))) { const current = failure && failure.resetAt > now ? failure @@ -447,7 +461,7 @@ export function createUserAuth(pool, options = {}) { } if ((row.password_algorithm || PASSWORD_ALGORITHM_PBKDF2) !== PASSWORD_ALGORITHM_ARGON2ID) { - const nextPassword = createPasswordRecord(password); + const nextPassword = await createPasswordRecord(password); await pool.query( `UPDATE h5_users SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? @@ -490,7 +504,7 @@ export function createUserAuth(pool, options = {}) { return { ok: false, message: '账户已禁用,请联系管理员' }; } - const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password); + const { salt, passwordHash, passwordAlgorithm } = await createPasswordRecord(password); const now = Date.now(); await pool.query( `UPDATE h5_users SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? WHERE id = ?`, @@ -720,7 +734,7 @@ export function createUserAuth(pool, options = {}) { const root = isAdmin ? path.resolve(workspaceRoot || path.join(usersRoot, normalized)) : (await publishLayoutFor({ id: userId, username: normalized })).publishDir; - const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password); + const { salt, passwordHash, passwordAlgorithm } = await createPasswordRecord(password); const now = Date.now(); const conn = await pool.getConnection(); @@ -1263,7 +1277,7 @@ export function createUserAuth(pool, options = {}) { ); if (rows.length === 0) return; - const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(adminPassword); + const { salt, passwordHash, passwordAlgorithm } = await createPasswordRecord(adminPassword); const now = Date.now(); await pool.query( `UPDATE h5_users SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? WHERE id = ?`, @@ -1923,7 +1937,7 @@ export function createUserAuth(pool, options = {}) { }) => { const normalized = await generateWechatUsername(openid); const randomPassword = crypto.randomBytes(24).toString('base64url'); - const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(randomPassword); + const { salt, passwordHash, passwordAlgorithm } = await createPasswordRecord(randomPassword); const userId = crypto.randomUUID(); const layout = await publishLayoutFor({ id: userId, username: normalized }); const workspaceRoot = layout.publishDir;