Compare commits

..

25 Commits

Author SHA1 Message Date
john 1b9c65fec1 docs: record rebased image-quota fix SHA
Memind CI / Test, build, and release guards (push) Successful in 4m40s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 21:46:57 +08:00
john ab08839a54 docs: register fix/image-quota-migration-backfill branch disposition
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 21:46:33 +08:00
john 8cf1c772a7 fix(billing): backfill finite image quota after free-plan rebuild
Expired paid users were rebuilt as free with period_images_limit=0, which the quota system treats as unlimited. Write the catalog quota on rebuild and repair existing finite plans on schema ensure.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 21:46:33 +08:00
john 41f245c88a fix(wechat): rotate image_url-poisoned sessions for report follow-ups
Memind CI / Test, build, and release guards (push) Has been cancelled
Goose cannot persist historical image scrub (PUT 405), so DeepSeek rejects
image_url on the next turn. Reattach the recent report image and rotate to a
fresh session instead of treating the follow-up as a missing page source.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 21:45:02 +08:00
john fe2d5d7aa1 fix(release-gate): map index.html to the UI impact domain
Memind CI / Test, build, and release guards (push) Successful in 7m50s
Unblock Core+Impact selection for H5 shell title changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 09:46:34 +08:00
john 443544d1f7 fix(h5): show TKMind as the user-facing product name
Memind CI / Test, build, and release guards (push) Has been cancelled
Keep the chat chrome, page title, and related copy aligned with the existing TKMind brand.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 09:42:50 +08:00
john 36d3a91f8d test(chat): widen intent-router timeout fallback window
Memind CI / Test, build, and release guards (push) Successful in 5m32s
CI failed on a 5ms/20ms race that let the LLM reply win before the timeout fallback. Give the abort path more slack so the guard stays deterministic.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 16:15:16 +08:00
john 4e789663a1 feat(seo): index public online pages without confirmation
Memind CI / Test, build, and release guards (push) Failing after 5m19s
Drop the user_confirmed_at gate so public, online, unexpired pages can enter sitemap/llms and receive SEO/GEO tags. Defaults now enable all discovery switches; stored all-off config is still preserved until admin saves.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 15:51:48 +08:00
john 8a88cd53e8 fix(h5-chat): resolve logged-in blank page from canSubmit TDZ
Memind CI / Test, build, and release guards (push) Failing after 4m28s
Move canSubmit above sendButtonLabel so authenticated ChatPanel render no longer throws before initialization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 11:33:26 +08:00
john baf89c5d04 docs: register 2026-08-12 dev and related branch dispositions
Memind CI / Test, build, and release guards (push) Successful in 4m10s
Record closure for achievements showcase, WeChat menu rename, runtime profile packaging, SEO/GEO admin catalog, and achievements delivery fix branches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 10:26:49 +08:00
john 19dacc0e5d docs: register feature/h5-queued-chat-submit branch disposition
Memind CI / Test, build, and release guards (push) Successful in 4m21s
Record audit trail for the H5 queued chat submit fix merged at 558c4ef.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 09:21:12 +08:00
john 558c4ef2ee fix(h5-chat): queue main-session submits while agent is busy
Memind CI / Test, build, and release guards (push) Successful in 4m1s
Prevent silent drops when users send follow-up instructions during waiting or streaming by showing the message immediately, notifying them it is queued, and auto-flushing after the composer returns to idle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 08:51:30 +08:00
john 53b0d2c62f fix(mindspace): release edited public pages and fix achievements dates
Memind CI / Test, build, and release guards (push) Successful in 3m37s
Finish now releases static HTML delivery contracts in a finally block so
re-edited pages are not stuck at HTTP 409, and M成果 groups pages by
Asia/Shanghai calendar dates to avoid duplicate day headings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 06:41:40 +08:00
john fdc234e5d0 feat(admin): add SEO/GEO publication catalog with crawler stats
Memind CI / Test, build, and release guards (push) Successful in 3m38s
Expose a DB-backed catalog of all online publications with SEO, GEO, total,
and bot view counts from h5_publication_views for the memind_adm dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:41:39 +08:00
john 6542a43495 fix(release): bundle memind-runtime-profile for WeChat menu scripts
Memind CI / Test, build, and release guards (push) Successful in 5m55s
load-env.mjs depends on memind-runtime-profile.mjs; include it in portal
runtime packaging so wechat-mp-menu.mjs works on 103 without manual scp.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:23:54 +08:00
john e9ea55cf5e chore(wechat): rename service account menu label TKMind to Memind
Memind CI / Test, build, and release guards (push) Successful in 5m30s
Align the first WeChat custom menu button with the Memind product name before direct 103 release.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:03:51 +08:00
john 421e204711 feat(mindspace): add achievements showcase with paginated management
Memind CI / Test, build, and release guards (push) Successful in 3m28s
Ship the M成果 page with list/delete UX, fix deletePage for incomplete page records, route /space/achievements, and add M成果 under the WeChat M空间 menu.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 15:21:42 +08:00
john e9cecf8733 docs: register feature/subscription-usage-records branch disposition
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 13:58:29 +08:00
john dedf8833d3 Merge branch 'feature/subscription-usage-records'
Memind CI / Test, build, and release guards (push) Successful in 3m41s
Record subscription-covered token usage in usage records.
2026-08-12 13:57:10 +08:00
john 753c1eec23 fix(scheduled-task): forward worker env to sandbox MCP
Memind CI / Test, build, and release guards (push) Successful in 6m3s
goosed stdio MCPs do not inherit Portal env, so scheduled_task_create
always reported worker disabled even when H5_REMINDER_WORKER_ENABLED=1.
Forward worker flags in sandboxMcpEnvs and add a regression test.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 20:49:36 +08:00
john 62fbea0936 docs: 登记 feature/md-admin-domain 分支闭环
Memind CI / Test, build, and release guards (push) Successful in 4m37s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 17:41:20 +08:00
john 871688550e docs(analytics): 管理后台域名改为 md.tkmind.cn
Memind CI / Test, build, and release guards (push) Has been cancelled
同步 Analytics 发布 runbook 与 admin 默认 PUBLIC_HOST,与 memind_adm 生产入口一致。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 17:38:31 +08:00
john d570856cbd docs: register feature/analytics-release-docs branch disposition
Memind CI / Test, build, and release guards (push) Failing after 1m46s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 16:33:19 +08:00
john 8348ce3625 docs: add analytics release runbook and 105 Umami topology
Memind CI / Test, build, and release guards (push) Has been cancelled
Cross-repo publish order for memind-analytics, Portal, and memind_adm;
update platform and 105 operations docs for systemd artifact deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 16:21:57 +08:00
john 6b586e0951 feat(billing): record subscription-covered token usage in usage records
Memind CI / Test, build, and release guards (pull_request) Successful in 4m8s
Write h5_usage_records when plan quota fully covers a session bill so
WeChat and H5 consumption stays visible in admin usage history.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 18:51:11 +08:00
77 changed files with 3302 additions and 222 deletions
+3 -3
View File
@@ -215,7 +215,7 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
# 登录仍在主域完成;后台凭 H5_COOKIE_DOMAIN=.tkmind.cn 共享的会话 Cookie 鉴权(生产务必设置)。
ADMIN_PORT=8082
ADMIN_HOST=127.0.0.1
ADMIN_PUBLIC_HOST=gadm.tkmind.cn
ADMIN_PUBLIC_HOST=md.tkmind.cn
# 本进程挂载哪些控制台(可拆分架构):admin=平台超管, ops=广场运营。
# 默认两者合一;将来拆成两个进程时,A 实例设 admin、B 实例设 ops,路由代码无需改动。
@@ -223,9 +223,9 @@ ADMIN_PUBLIC_HOST=gadm.tkmind.cn
# 每控制台独立网络护栏(逗号分隔;留空=放开。支持精确 IP 与 CIDR)。
# 超管面建议收得最紧(仅内网/VPN),广场运营可放宽给审核团队网段。
# ADMIN_API_ALLOWED_HOSTS=gadm.tkmind.cn
# ADMIN_API_ALLOWED_HOSTS=md.tkmind.cn
# ADMIN_API_IP_ALLOWLIST=10.0.0.0/8,127.0.0.1
# OPS_API_ALLOWED_HOSTS=gadm.tkmind.cn
# OPS_API_ALLOWED_HOSTS=md.tkmind.cn
# OPS_API_IP_ALLOWLIST=
# 多用户系统(MySQL 配置后启用;未配置时回退到 H5_ACCESS_PASSWORD)
+2 -2
View File
@@ -101,14 +101,14 @@ Cursor 额外加载:`.cursor/rules/mindspace-publish-chat-finish-guards.mdc`
## 仓库惯例
- 本地开发:`pnpm dev`(见 [README.md](README.md))
- 访问统计:**仅 Umami**;Rybbit 已退役,见 [docs/analytics-platform.md](docs/analytics-platform.md) 与 [docs/local-analytics.md](docs/local-analytics.md)
- 访问统计:**仅 Umami**;Rybbit 已退役,见 [docs/analytics-platform.md](docs/analytics-platform.md)、[docs/local-analytics.md](docs/local-analytics.md)、**[docs/analytics-release-runbook.md](docs/analytics-release-runbook.md)**(105 / 103 发布顺序)
- Page Data API:见 [docs/page-data-api-usage.md](docs/page-data-api-usage.md);改动相关路径后执行 `npm run verify:page-data`
- 生产隔离:[docs/service-isolation-runbook.md](docs/service-isolation-runbook.md)
- 发版须 Git commit,禁止本机直 `rsync` 到 `103/105`
## 必读:管理后台 UI 边界(memind_adm 5174)
**平台管理后台的前端 UI 只能在独立仓库 `memind_adm`(本地 5174 / 生产 gadm)开发,禁止在 Memind 本仓库的 `ops/`(约 3002)新增管理功能。**
**平台管理后台的前端 UI 只能在独立仓库 `memind_adm`(本地 5174 / 生产 [md.tkmind.cn](https://md.tkmind.cn))开发,禁止在 Memind 本仓库的 `ops/`(约 3002)新增管理功能。**
| 层级 | 位置 | 说明 |
|------|------|------|
+7 -7
View File
@@ -15,10 +15,10 @@ test('parseList splits, trims, and drops empties', () => {
});
test('hostAllowed: empty list allows all, otherwise matches hostname ignoring port/case', () => {
assert.equal(hostAllowed('gadm.tkmind.cn:8082', []), true);
assert.equal(hostAllowed('GADM.tkmind.cn', ['gadm.tkmind.cn']), true);
assert.equal(hostAllowed('evil.example.com', ['gadm.tkmind.cn']), false);
assert.equal(hostAllowed('gadm.tkmind.cn:443', ['gadm.tkmind.cn', 'localhost']), true);
assert.equal(hostAllowed('md.tkmind.cn:8082', []), true);
assert.equal(hostAllowed('MD.tkmind.cn', ['md.tkmind.cn']), true);
assert.equal(hostAllowed('evil.example.com', ['md.tkmind.cn']), false);
assert.equal(hostAllowed('md.tkmind.cn:443', ['md.tkmind.cn', 'localhost']), true);
});
test('ipMatches: exact and IPv4-mapped IPv6', () => {
@@ -48,7 +48,7 @@ test('buildNetworkGuard returns null when nothing configured', () => {
test('buildNetworkGuard denies on host then ip, allows when both pass', () => {
const guard = buildNetworkGuard({
allowedHosts: ['gadm.tkmind.cn'],
allowedHosts: ['md.tkmind.cn'],
ipAllowlist: ['10.0.0.0/8'],
onDeny: (_res, _req, reason) => `deny:${reason}`,
});
@@ -60,8 +60,8 @@ test('buildNetworkGuard denies on host then ip, allows when both pass', () => {
};
assert.equal(guard(mk('evil.com', '10.1.1.1'), {}, next), 'deny:host');
assert.equal(guard(mk('gadm.tkmind.cn', '8.8.8.8'), {}, next), 'deny:ip');
assert.equal(guard(mk('md.tkmind.cn', '8.8.8.8'), {}, next), 'deny:ip');
assert.equal(nexted, false);
guard(mk('gadm.tkmind.cn', '10.1.1.1'), {}, next);
guard(mk('md.tkmind.cn', '10.1.1.1'), {}, next);
assert.equal(nexted, true);
});
+2 -2
View File
@@ -5,7 +5,7 @@
// Serves the platform super-admin API (/admin-api/*) and the plaza operations
// console API (/api/ops/v1/*) on its own port, isolated from the public
// user-facing server (server.mjs). In production this is fronted by
// gadm.tkmind.cn; in dev the ops SPA (:3002) proxies to it directly.
// md.tkmind.cn; in dev the ops SPA (:3002) proxies to it directly.
//
// It trusts the shared session cookie (H5_COOKIE_DOMAIN=.tkmind.cn), so login
// continues to happen on the main app domain — this process only reads the
@@ -43,7 +43,7 @@ loadEnvFile(path.join(__dirname, '.env'));
const PORT = Number(process.env.ADMIN_PORT ?? 8082);
const HOST = process.env.ADMIN_HOST ?? '127.0.0.1';
const PUBLIC_HOST = process.env.ADMIN_PUBLIC_HOST ?? 'gadm.tkmind.cn';
const PUBLIC_HOST = process.env.ADMIN_PUBLIC_HOST ?? 'md.tkmind.cn';
const getToken = (req) => parseCookies(req.get('cookie'))[USER_COOKIE];
+30
View File
@@ -1000,6 +1000,36 @@ export async function ensurePlanCatalogSchema(pool) {
[nextTokens, now, planType, previousTokens],
);
}
// Repair active subscriptions that inherited DEFAULT 0 (unlimited) from the
// pre-image-quota free-plan backfill, but whose catalog quota is finite.
// True unlimited plans (catalog period_images = 0) are left unchanged.
await pool.query(
`INSERT INTO h5_image_quota_ledger
(id, user_id, delta, balance_after, reason, ref_id, operator_id, note, created_at)
SELECT UUID(), s.user_id, p.period_images,
GREATEST(0, p.period_images + s.period_images_bonus - s.period_images_used),
'plan_change', s.id, NULL,
'回填套餐默认图片额度(补建漏写)', ?
FROM h5_subscriptions s
INNER JOIN h5_plan_catalog p ON p.plan_type = s.plan_type
WHERE s.status = 'active'
AND s.expires_at > ?
AND s.period_images_limit = 0
AND p.period_images > 0`,
[now, now],
);
await pool.query(
`UPDATE h5_subscriptions s
INNER JOIN h5_plan_catalog p ON p.plan_type = s.plan_type
SET s.period_images_limit = p.period_images,
s.updated_at = ?
WHERE s.status = 'active'
AND s.expires_at > ?
AND s.period_images_limit = 0
AND p.period_images > 0`,
[now, now],
);
}
export function createPlanCatalogService(pool) {
+14 -2
View File
@@ -74,12 +74,24 @@ describe('ensurePlanCatalogSchema', () => {
await ensurePlanCatalogSchema(pool);
const planUpdates = queries.filter(({ sql }) => sql.includes('UPDATE h5_plan_catalog'));
const subUpdates = queries.filter(({ sql }) => sql.includes('UPDATE h5_subscriptions'));
const tokenSubUpdates = queries.filter(({ sql }) => (
sql.includes('UPDATE h5_subscriptions') && sql.includes('period_tokens_limit')
));
const imageSubUpdates = queries.filter(({ sql }) => (
sql.includes('UPDATE h5_subscriptions s') && sql.includes('period_images_limit')
));
const imageLedgerInserts = queries.filter(({ sql }) => (
sql.includes('INSERT INTO h5_image_quota_ledger') && sql.includes('补建漏写')
));
assert.ok(planUpdates.some(({ params }) => params[0] === 450_000 && params[2] === 'free' && params[3] === 150_000));
assert.ok(planUpdates.some(({ params }) => params[0] === 3_600_000 && params[2] === 'lite' && params[3] === 1_200_000));
assert.ok(planUpdates.some(({ params }) => params[0] === 13_500_000 && params[2] === 'standard' && params[3] === 4_500_000));
assert.equal(subUpdates.length, 3);
assert.equal(tokenSubUpdates.length, 3);
assert.equal(imageSubUpdates.length, 1);
assert.equal(imageLedgerInserts.length, 1);
assert.match(imageSubUpdates[0].sql, /p\.period_images > 0/);
assert.match(imageSubUpdates[0].sql, /s\.period_images_limit = 0/);
});
});
+8
View File
@@ -606,6 +606,14 @@ function sandboxMcpEnvs(sandboxMcp, mcpTools) {
envs[key] = String(value).trim();
}
}
// goosed stdio MCPs do not inherit Portal env; forward worker flags so
// scheduled_task_create can report the same enablement as server.mjs.
for (const key of ['H5_REMINDER_WORKER_ENABLED', 'H5_SCHEDULED_TASK_WORKER_ENABLED']) {
const value = process.env[key];
if (value != null && String(value).trim() !== '') {
envs[key] = String(value).trim();
}
}
if (mcpTools.includes('generate_image')) {
if (sandboxMcp.agentApiBaseUrl) {
envs.MINDSPACE_AGENT_API_BASE_URL = sandboxMcp.agentApiBaseUrl;
+25
View File
@@ -507,6 +507,31 @@ test('static_publish with sandboxMcp uses stdio sandbox-fs extension instead of
assert.ok(!allTools.includes('shell'), 'shell should not be exposed');
});
test('sandboxMcpEnvs forwards scheduled task worker flags from portal env', () => {
const previousReminder = process.env.H5_REMINDER_WORKER_ENABLED;
const previousScheduled = process.env.H5_SCHEDULED_TASK_WORKER_ENABLED;
process.env.H5_REMINDER_WORKER_ENABLED = '1';
delete process.env.H5_SCHEDULED_TASK_WORKER_ENABLED;
try {
const caps = { ...DEFAULT_USER_CAPABILITIES, static_publish: true };
const policy = buildAgentExtensionPolicy(caps, {
sandboxMcp: {
serverPath: '/opt/portal/mindspace-sandbox-mcp.mjs',
sandboxRoot: '/opt/h5/MindSpace/abc123',
},
});
const sandboxExt = policy.extensionOverrides.find((ext) => ext.name === 'sandbox-fs');
assert.ok(sandboxExt);
assert.equal(sandboxExt.envs.H5_REMINDER_WORKER_ENABLED, '1');
assert.equal(sandboxExt.envs.H5_SCHEDULED_TASK_WORKER_ENABLED, undefined);
} finally {
if (previousReminder === undefined) delete process.env.H5_REMINDER_WORKER_ENABLED;
else process.env.H5_REMINDER_WORKER_ENABLED = previousReminder;
if (previousScheduled === undefined) delete process.env.H5_SCHEDULED_TASK_WORKER_ENABLED;
else process.env.H5_SCHEDULED_TASK_WORKER_ENABLED = previousScheduled;
}
});
test('sandboxMcp emits a scoped logical workspace token only for a bound session', () => {
const secret =
'mindspace-mcp-policy-secret-1234';
+43
View File
@@ -72,6 +72,49 @@ export function shouldScheduleMissingActiveRequestGrace({
return allowMissingGrace && !agentRunPending;
}
export const QUEUED_CHAT_SUBMIT_NOTICE =
'已收到你的消息,将在当前任务完成后自动继续执行。';
/**
* @param {string | undefined | null} chatState
* @returns {boolean}
*/
export function isChatSubmitBusy(chatState) {
return (
chatState === 'streaming' ||
chatState === 'loading' ||
chatState === 'connecting' ||
chatState === 'waiting'
);
}
/**
* @param {number | undefined | null} queueLength
* @returns {string}
*/
export function buildQueuedChatSubmitNotice(queueLength = 1) {
const length = Math.max(1, Number(queueLength) || 1);
if (length <= 1) return QUEUED_CHAT_SUBMIT_NOTICE;
return `已收到你的消息,当前还有 ${length} 条待执行,将在任务完成后按顺序继续。`;
}
/**
* @param {{ chatState?: string; agentRunPending?: boolean; pendingTool?: boolean; queueLength?: number }} input
* @returns {boolean}
*/
export function canFlushQueuedChatSubmit({
chatState = 'idle',
agentRunPending = false,
pendingTool = false,
queueLength = 0,
} = {}) {
if (!queueLength || queueLength <= 0) return false;
if (isChatSubmitBusy(chatState)) return false;
if (agentRunPending) return false;
if (pendingTool) return false;
return true;
}
/**
* Only transport uncertainty may continue a run after submit fails. A
* deterministic gateway error already has a terminal outcome and must return
+49
View File
@@ -1,6 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildQueuedChatSubmitNotice,
canFlushQueuedChatSubmit,
isChatSubmitBusy,
reconcileSessionEventRequestContext,
resolvePostAgentRunChatState,
shouldIgnoreZeroActivityFinish,
@@ -145,6 +148,52 @@ test('missing ActiveRequests cannot unlock while the Portal agent-run is pending
);
});
test('isChatSubmitBusy covers active composer states', () => {
assert.equal(isChatSubmitBusy('idle'), false);
assert.equal(isChatSubmitBusy('error'), false);
assert.equal(isChatSubmitBusy('waiting'), true);
assert.equal(isChatSubmitBusy('streaming'), true);
assert.equal(isChatSubmitBusy('connecting'), true);
});
test('buildQueuedChatSubmitNotice reflects queue depth', () => {
assert.match(buildQueuedChatSubmitNotice(1), /当前任务完成后/);
assert.match(buildQueuedChatSubmitNotice(2), /2 条待执行/);
});
test('canFlushQueuedChatSubmit waits for idle composer without pending tool or run', () => {
assert.equal(
canFlushQueuedChatSubmit({
chatState: 'idle',
queueLength: 1,
}),
true,
);
assert.equal(
canFlushQueuedChatSubmit({
chatState: 'waiting',
queueLength: 1,
}),
false,
);
assert.equal(
canFlushQueuedChatSubmit({
chatState: 'idle',
agentRunPending: true,
queueLength: 1,
}),
false,
);
assert.equal(
canFlushQueuedChatSubmit({
chatState: 'idle',
pendingTool: true,
queueLength: 1,
}),
false,
);
});
test('reconcileSessionEventRequestContext adopts Goose request id while agent-run gate waits', () => {
assert.deepEqual(
reconcileSessionEventRequestContext({
+46
View File
@@ -75,6 +75,52 @@ function stripAgentImageText(text) {
.trim();
}
function stripImageUrlLines(text) {
return String(text ?? '').replace(IMAGE_URL_LINES_RE, '').trim();
}
/**
* After Qwen VL analysis, Goose must receive text only. DeepSeek rejects
* `image_url` parts, and native Goose may expand metadata.imageUrls into them.
* Keep archived/preview URLs for UI history; leave the VL note in the text.
*/
export function detachCurrentTurnImagesForTextProvider(message, canonicalImageUrls = []) {
if (!message) return message;
const metadata =
message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata)
? { ...message.metadata }
: {};
const urls = (Array.isArray(canonicalImageUrls) && canonicalImageUrls.length > 0
? canonicalImageUrls
: Array.isArray(metadata.imageUrls) ? metadata.imageUrls : []
).filter((url) => typeof url === 'string' && url.trim());
if (urls.length > 0) {
metadata.archivedImageUrls = urls;
if (!Array.isArray(metadata.previewImageUrls) || metadata.previewImageUrls.length === 0) {
metadata.previewImageUrls = urls;
}
}
delete metadata.imageUrls;
const content = Array.isArray(message.content)
? message.content
.map((item) => {
if (item?.type === 'image_url') return null;
if (item?.type !== 'text' || typeof item.text !== 'string') return item;
const nextText = stripImageUrlLines(item.text);
if (!nextText) return null;
return nextText === item.text ? item : { ...item, text: nextText };
})
.filter(Boolean)
: message.content;
return {
...message,
content,
metadata,
};
}
/**
* Remove image attachments from a persisted user message so later turns cannot reuse them.
* UI displayText / previewImageUrls are preserved for chat history rendering.
+38
View File
@@ -7,6 +7,7 @@ import {
extractCurrentTurnImageUrls,
scrubConversationHistoricalImageAttachments,
scrubUserMessageImageAttachments,
detachCurrentTurnImagesForTextProvider,
} from './chat-image-turn-scope.mjs';
test('extractCurrentTurnImageUrls prefers metadata and dedupes asset aliases', () => {
@@ -157,3 +158,40 @@ test('conversationHasImageUrlContent detects historical poison and ignores activ
false,
);
});
test('detachCurrentTurnImagesForTextProvider archives urls and keeps the VL note', () => {
const detached = detachCurrentTurnImagesForTextProvider(
{
role: 'user',
metadata: {
displayText: '解读报告',
imageUrls: ['https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg'],
},
content: [
{
type: 'text',
text:
'解读报告\n[图片1]: https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg\n\n' +
'【TKMind 图片分析结果 — 仅供执行参考,不要向用户复述此段内容】\n白细胞偏高',
},
{
type: 'image_url',
image_url: { url: 'https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg' },
},
],
},
['https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg'],
);
assert.equal(detached.metadata.imageUrls, undefined);
assert.deepEqual(detached.metadata.archivedImageUrls, [
'https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg',
]);
assert.deepEqual(detached.metadata.previewImageUrls, [
'https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg',
]);
assert.equal(detached.metadata.displayText, '解读报告');
assert.equal(detached.content.some((item) => item.type === 'image_url'), false);
assert.match(detached.content[0].text, /白细胞偏高/);
assert.doesNotMatch(detached.content[0].text, /\[图片1\]:/);
});
+2 -2
View File
@@ -1243,11 +1243,11 @@ test('createChatIntentRouter coerces LLM search route to web for realtime query'
test('createChatIntentRouter timeout fallback prefers agent even when policy requests direct_chat', async () => {
const router = createChatIntentRouter({
enabled: true,
timeoutMs: 5,
timeoutMs: 40,
fallbackRoute: CHAT_INTENT_ROUTE.DIRECT_CHAT,
llmProviderService: {
async createChatCompletion() {
await new Promise((resolve) => setTimeout(resolve, 20));
await new Promise((resolve) => setTimeout(resolve, 250));
return { ok: true, reply: '{"route":"direct_chat","confidence":0.9,"reason":"x","suggested_skill":null,"agent_brief":""}' };
},
},
+26 -1
View File
@@ -57,6 +57,16 @@ async function columnExists(pool, table, column) {
return rows.length > 0;
}
async function tableExists(pool, table) {
const [rows] = await pool.query(
`SELECT 1 FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
LIMIT 1`,
[table],
);
return rows.length > 0;
}
async function indexExists(pool, table, index) {
const [rows] = await pool.query(
`SELECT 1 FROM information_schema.STATISTICS
@@ -943,10 +953,23 @@ export async function migrateSchema(pool) {
// Back-fill free subscriptions for existing users who pre-date the subscription system.
// Idempotent: only inserts where no active subscription exists.
// period_images_limit defaults to 0 (= unlimited). Always write the catalog
// quota when the column exists, otherwise expired paid users become unlimited
// on the next Portal/Plaza start.
const hasImageLimit = await columnExists(pool, 'h5_subscriptions', 'period_images_limit');
const hasCatalog = await tableExists(pool, 'h5_plan_catalog');
const tokenSelect = hasCatalog ? 'COALESCE(c.period_tokens, 150000)' : '150000';
const imageSelect = hasCatalog ? 'COALESCE(c.period_images, 10)' : '10';
const catalogJoin = hasCatalog ? "LEFT JOIN h5_plan_catalog c ON c.plan_type = 'free'" : '';
const imageCols = hasImageLimit
? 'period_images_limit, period_images_used, period_images_bonus,'
: '';
const imageVals = hasImageLimit ? `${imageSelect}, 0, 0,` : '';
await pool.query(`
INSERT INTO h5_subscriptions
(id, user_id, plan_type, status,
period_tokens_limit, period_tokens_used,
${imageCols}
period_start, period_end, expires_at, overage_rate,
operator_id, note, created_at, updated_at)
SELECT
@@ -954,7 +977,8 @@ export async function migrateSchema(pool) {
u.id,
'free',
'active',
150000, 0,
${tokenSelect}, 0,
${imageVals}
UNIX_TIMESTAMP() * 1000,
(UNIX_TIMESTAMP() + 30 * 86400) * 1000,
(UNIX_TIMESTAMP() + 30 * 86400) * 1000,
@@ -964,6 +988,7 @@ export async function migrateSchema(pool) {
UNIX_TIMESTAMP() * 1000,
UNIX_TIMESTAMP() * 1000
FROM h5_users u
${catalogJoin}
WHERE NOT EXISTS (
SELECT 1 FROM h5_subscriptions s
WHERE s.user_id = u.id AND s.status = 'active'
+25
View File
@@ -11,6 +11,7 @@
| 定位 | 云侧入口 / 代理 / 历史链路,不承担可写业务数据主存储 |
| H5 运行目录 | `/root/tkmind_go/ui/h5` |
| Plaza 运行目录 | `/root/tkmind_go/ui/plaza` |
| **Umami Analytics** | `/opt/memind-analytics`(systemd + 原生 artifact,见下节) |
| 数据真身 | 在 `103`(MindSpace、users、data 等),见 [103/105 升级记录](103-105-upgrade-runbook-2026-06-26.md) |
本地开发仓库是 `/Users/john/Project/Memind`;生产 runtime 发布目标是 `103`。
@@ -56,6 +57,29 @@
**不要**使用已停用的 `rsync_to_server.sh` 或直接 rsync 到 `105` 作为常规发布手段(见 `PRODUCTION_RELEASE_RULES.md`)。
## 105 Umami Analytics(`/opt/memind-analytics`)
Memind 访问统计(Umami)独立部署在 105,**不是** Portal H5 目录的一部分。
| 项 | 说明 |
|----|------|
| 公网 | `https://fxi.tkmind.cn` |
| 进程 | `systemctl status memind-analytics-umami` |
| 数据库 | Docker Compose 仅 `db`,`127.0.0.1:55432` |
| 发布 | 在 **memind-analytics** 仓库执行 `bash scripts/release-umami-105.sh --yes` |
| 文档 | memind-analytics `deploy/105-runbook.md`;Memind [analytics-release-runbook.md](analytics-release-runbook.md) |
**禁止** SSH 到 105 手改 `/opt/memind-analytics/live` 内文件或 overlay;变更必须来自 memind-analytics Git 发布。
验收:
```bash
ssh root@120.26.184.105 '
systemctl is-active memind-analytics-umami
curl -sf http://127.0.0.1:3100/api/heartbeat
'
```
## 105 Portal runtime 发布
`105` 上当前 H5 由 `systemd` 服务 `goose-h5` 运行,真实目录是:
@@ -196,5 +220,6 @@ ssh root@120.26.184.105 "sed -i \"s/M广场/M发现/\" /root/tkmind_go/ui/h5/scr
## 相关文档
- [生产 / 测试 / 预览隔离规程](service-isolation-runbook.md)
- [Analytics / SEO·GEO 发布 Runbook](analytics-release-runbook.md)
- [Portal 无源码迁移说明](no-source-portal-migration.md)
- [103/105 一次性升级实施记录](103-105-upgrade-runbook-2026-06-26.md)
+13
View File
@@ -35,3 +35,16 @@ Memind 公开页与生成页的真实用户访问统计 **仅使用 Umami**(`m
| **Memind** | tracker 注入、discovery 分类、Umami 代理 |
| **memind-analytics** | Umami 定制部署、`memind-pages` API(overlay 在 `deploy/umami/overlay/`) |
| **memind_adm** | Analytics 配置、SEO/GEO 看板、Umami SSO |
## 生产发布
跨仓库顺序与命令见 **[analytics-release-runbook.md](analytics-release-runbook.md)**。
| 步骤 | 仓库 | 命令 |
|------|------|------|
| 1 | memind-analytics | `bash scripts/release-umami-105.sh --yes` |
| 2 | Memind(103 Portal) | `bash scripts/release-portal-runtime-prod.sh --yes` |
| 3 | memind_adm(103 · md.tkmind.cn) | `bash scripts/release-prod.sh --yes` |
105 Umami 细节见 memind-analytics 仓库 `deploy/105-runbook.md`。
md.tkmind.cn Analytics 环境变量见 memind_adm `docs/analytics-deploy.md`。
+131
View File
@@ -0,0 +1,131 @@
# Analytics / SEO·GEO 生产发布 Runbook
跨仓库发布顺序与命令速查。统计平台说明见 [analytics-platform.md](analytics-platform.md)。
## 架构(2026-08 起)
```text
公开页 / H5
-> Memind /analytics/* 代理
-> 105 Umami(systemd + 原生 artifact,127.0.0.1:3100)
-> PostgreSQL(仅 db 容器,127.0.0.1:55432)
memind_adm SEO/GEO 看板
-> /admin-api/analytics/seo-geo-pages
-> 105 Umami memind-pages API(overlay)
```
**已废弃:** Rybbit;105 上 Docker 编译 Umami 镜像;Docker 跑 Umami 应用容器(`docker-umami` profile 仅遗留)。
## 推荐发布顺序
| 顺序 | 仓库 | 何时必须发 | 命令 |
|------|------|------------|------|
| 1 | **memind-analytics** | 改了 `deploy/umami/overlay/`、`memind-pages` API | `bash scripts/release-umami-105.sh --yes` |
| 2 | **Memind** | 改了 discovery 埋点、Umami 代理、公开页 delivery | `bash scripts/release-portal-runtime-prod.sh` |
| 3 | **memind_adm** | 改了 SEO/GEO 看板、Analytics 配置页、Umami 代理 | `bash scripts/release-prod.sh --yes` |
只改 overlay / API 时,发 **105** 即可;只改 adm 看板 UI 时,发 **103 md.tkmind.cn(memind_adm)** 即可。
Memind discovery 字段(`discovery_channel` 等)未上线前,adm 看板可能暂无 SEO/GEO 分列数据。
## 1. 105 Umami(memind-analytics)
工作目录:本机 `memind-analytics` 仓库(或 worktree `memind-analytics-release-main`)。
```bash
bash scripts/release-umami-105.sh --yes
```
脚本会:
1. 打包部署文件上传到 `105:/opt/memind-analytics/incoming/`
2. **在 105 原生** `pnpm install`(复用 `node_modules` 缓存)+ `pnpm run build-docker`
3. 产出 artifact → `/opt/memind-analytics/releases/umami-<release_id>/`
4. `backup/backup.sh` 备份 PostgreSQL
5. 切换 `/opt/memind-analytics/live` symlink,`systemctl restart memind-analytics-umami`
### 监控(SSH 断开不影响构建)
```bash
ssh root@120.26.184.105 tail -f /opt/memind-analytics/.build/build-<release_id>.log
# 或
ssh root@120.26.184.105 tail -f /opt/memind-analytics/.build/release-<release_id>.log
```
### 验收
```bash
ssh root@120.26.184.105 '
systemctl is-active memind-analytics-umami
curl -sf http://127.0.0.1:3100/api/heartbeat
readlink /opt/memind-analytics/live
'
```
公网:`https://fxi.tkmind.cn/api/heartbeat`
详细路径、回滚与 nginx 见 **memind-analytics** 仓库 [`deploy/105-runbook.md`](https://git.tkmind.cn/tkmind/memind-analytics/src/branch/main/deploy/105-runbook.md)。
## 2. 103 Portal(Memind)
```bash
cd /Users/john/Project/Memind
bash scripts/check-release-ready.sh # 发布闸门
bash scripts/release-portal-runtime-prod.sh --yes
```
与 Analytics 相关的 Memind 改动通常包括:
- `mindspace-analytics-discovery.mjs` — SEO/GEO 分类
- `mindspace-analytics.mjs` — 事件维度
- Portal `/analytics/*` 反代
本地环境变量见 [local-analytics.md](local-analytics.md)。
## 3. 103 管理后台(memind_adm)
```bash
cd /Users/john/Project/memind_adm
bash scripts/release-prod.sh --yes
```
### 生产 `.env`(Analytics 相关)
```env
# SEO/GEO 看板拉 Umami memind-pages API 所需(105 SSO-only 时用 MEMIND_UMAMI_SSO_SECRET)
MEMIND_UMAMI_SSO_SECRET=<与 105 .env 一致>
UMAMI_SSO_USERNAME=admin
UMAMI_URL=https://fxi.tkmind.cn # 可选,默认 http://127.0.0.1:3100
UMAMI_WEBSITE_ID=<Memind 公共页面 Website ID>
H5_PUBLIC_BASE_URL=https://md.tkmind.cn
```
103 需能访问 105 Umami(内网或配置的 `UMAMI_URL`)。
### 验收
1. [md.tkmind.cn/analytics](https://md.tkmind.cn/analytics) → **Analytics 配置** → 「打开 Umami 分析后台」可 SSO 登录
2. [md.tkmind.cn/analytics/seo-geo](https://md.tkmind.cn/analytics/seo-geo) → **SEO / GEO 流量** → 表格加载无 SSO/凭证错误
3. 抽样对比 Umami「所有生成页面明细」与 adm 看板列口径一致
详见 memind_adm [`docs/analytics-deploy.md`](../memind_adm/docs/analytics-deploy.md)。
## 常见问题
| 现象 | 处理 |
|------|------|
| 105 构建 `check-db` 失败 | 应使用 `build-docker`(已写入 `build-umami-artifact.sh`),勿改回 `pnpm build` |
| `Umami database is not running` | 使用 `backup/backup.sh`,不要用根目录旧版 `backup.sh` |
| 105 构建很慢 | 首次 install+compile 约 20–40 分钟;后续 overlay 小改可复用 `node_modules` |
| adm SEO/GEO 看板空 | 确认 Memind discovery 已发布且有真实 SEO/GEO 流量 |
| Rybbit 相关报错 | 已退役,删除遗留 env/文档引用,见 [analytics-platform.md](analytics-platform.md) |
## 相关文档
| 文档 | 内容 |
|------|------|
| [analytics-platform.md](analytics-platform.md) | 平台边界、字段口径、Rybbit 退役 |
| [local-analytics.md](local-analytics.md) | 本地 Umami 与 Memind env |
| [105-server-operations.md](105-server-operations.md) | 105 运维边界(含 `/opt/memind-analytics`) |
| memind-analytics `README.md` | Umami 仓库运维与 overlay |
| memind_adm `docs/analytics-deploy.md` | md.tkmind.cn Analytics / SEO·GEO 配置 |
+224
View File
@@ -3,6 +3,25 @@
本文件记录已经完成迁移、但仍可能因为 Git 拓扑或遗留 worktree 被误判为“尚未进入 `main`”的分支。
它是分支复用、合并、cherry-pick 和清理前的必查清单。
## `fix/image-quota-migration-backfill`
**状态:禁止再次引用。改动已提交并将随本分支合入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
审计日期:2026-08-14
代码修复:`8cf1c772`
`origin/main` 对应提交:本登记与代码修复一并快进合入 main
### 原始用途
- 补建免费套餐时写入套餐目录图片额度,避免 `period_images_limit = 0` 被当成无限
- 启动时回填「上限为 0、但目录额度有限」的有效订阅
- 记录 2026-08-14 柯彤无限图片额度事故
### 验证摘要
- `node --test billing-subscription.test.mjs`:27 passed
- 生产已回填 33 条误标无限的有效免费订阅;柯彤剩余 6 / 总计 10
## 处置规则
- 标记为“禁止再次引用”的分支只允许用于只读历史追溯。
@@ -13,6 +32,33 @@
- 远端功能分支默认在闭环后删除;发布和审计依据使用 `main` commit、tag、CI 记录或 runtime manifest,不使用功能分支名。
- 这类保留分支必须在本文件登记清楚:分支名、最终 HEAD、进入 `origin/main` 的提交、验证摘要,以及“禁止再次引用”的状态。
## `feature/md-admin-domain`(Memind + memind_adm)
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
审计日期:2026-08-11
分支 HEAD:`8716885`(Memind)、`0da7f04`(memind_adm)
`origin/main` 对应提交:同上
### 原始用途
- 管理后台公网域名从 `gadm.tkmind.cn` 更正为 `md.tkmind.cn`
- 文档与 nginx 示例明确 **105 反代 → 103 memind_adm**(5174/8085)
- memind_adm 默认 `VITE_BASE_PATH=/`,使 `/analytics/seo-geo` 在根路径可访问
### 生产发布
| 仓库 | release_id |
|------|------------|
| memind_adm | `20260810-173922-0da7f04` |
| 105 nginx | `md.tkmind.cn.conf` 已 reload |
### 验证摘要
- `https://md.tkmind.cn/analytics/seo-geo` → 200
- 103 `8085/health` → ok;5174 `base=/`
- `/ops/analytics/seo-geo` → 301 到 `/analytics/seo-geo`
## `feature/fix-rybbit-publication-tracking`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
@@ -454,3 +500,181 @@ Portal,避免在线修改稳定 `.env`,并确保稳定 8081 与其他用户
- 保留本地分支名用于审计追溯。
- **不要** merge、cherry-pick 或从该分支构建 runtime/artifact。
- 103 生产依据 `44121df` / release manifest `20260805-095542-44121df`。
## `feature/analytics-release-docs`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
审计日期:2026-08-11
分支 HEAD:`8348ce3`
`origin/main` 对应提交:`8348ce3`
### 原始用途
补充 Analytics / SEO·GEO 跨仓库发布文档:105 Umami 原生 artifact + systemd 拓扑、发布顺序、Memind 与 memind_adm 链接。
### 验证摘要
- 文档-only;无 runtime 变更
- memind-analytics 文档已 push 至 `main` @ `24f67b0`
- memind_adm 文档已 merge 至 `main` @ `a67d5a1`
### 最终处置
- 保留本地分支名用于审计追溯。
- **不要** merge、cherry-pick 或从该分支继续开发。
- 后续 Analytics 发布以 `docs/analytics-release-runbook.md` 为准。
## `feature/subscription-usage-records`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
审计日期:2026-08-12
分支 HEAD:`6b586e0`
`origin/main` 对应提交:`dedf883`(merge)
### 原始用途
订阅额度全额覆盖 token 消耗时,仍写入 usage record,便于审计与对账。
### 验证摘要
- `node --test user-auth.test.mjs`:19/19 通过
### 最终处置
- 保留本地分支名用于审计追溯。
- **不要** merge、cherry-pick 或从该分支继续开发。
## `feature/2026-08-12-dev`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
审计日期:2026-08-13
分支 HEAD:`421e204`
`origin/main` 对应提交:`421e204`
### 原始用途
MindSpace 成果展示:分页管理、统计聚合、Achievements 面板 UI 与 Portal API。
### 验证摘要
- Git 祖先检查确认分支 HEAD 已进入 `origin/main`。
- `node --test mindspace-page-achievement-list.test.mjs mindspace-page-achievement-stats.test.mjs`:5/5 通过。
### 最终处置
- 保留本地分支名用于审计追溯。
- **不要** merge、cherry-pick 或从该分支继续开发。
- 后续交付修复见 `fix/achievements-delivery-release`(已进入 `origin/main` @ `53b0d2c`)。
## `feature/wechat-memind-menu`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
审计日期:2026-08-13
分支 HEAD:`e9ea55c`
`origin/main` 对应提交:`e9ea55c`
### 原始用途
微信公众号自定义菜单文案由 TKMind 更名为 Memind(`scripts/wechat-mp-menu.mjs`)。
### 验证摘要
- Git 祖先检查确认分支 HEAD 已进入 `origin/main`。
### 最终处置
- 保留本地分支名用于审计追溯。
- **不要** merge、cherry-pick 或从该分支继续开发。
## `fix/runtime-profile-packaging`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
审计日期:2026-08-13
分支 HEAD:`6542a43`
`origin/main` 对应提交:`6542a43`
### 原始用途
Portal runtime 打包纳入 `memind-runtime-profile`,修复 WeChat 菜单脚本在 105/103 发布链路中缺少 runtime profile 的问题。
### 验证摘要
- Git 祖先检查确认分支 HEAD 已进入 `origin/main`。
### 最终处置
- 保留本地分支名用于审计追溯。
- **不要** merge、cherry-pick 或从该分支继续开发。
## `feature/seo-geo-admin-catalog`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
审计日期:2026-08-13
分支 HEAD:`fdc234e`
`origin/main` 对应提交:`fdc234e`
### 原始用途
管理后台 SEO/GEO 收录目录与爬虫统计(`mindspace-seo-geo-admin-catalog.mjs`);管理 UI 在 memind_adm 5174,Memind 侧为共享业务模块与 API。
### 验证摘要
- Git 祖先检查确认分支 HEAD 已进入 `origin/main`。
- `node --test mindspace-seo-geo-admin-catalog.test.mjs`:3/3 通过。
### 最终处置
- 保留本地分支名用于审计追溯。
- **不要** merge、cherry-pick 或从该分支继续开发。
## `fix/achievements-delivery-release`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
审计日期:2026-08-13
分支 HEAD:`53b0d2c`
`origin/main` 对应提交:`53b0d2c`
### 原始用途
成果展示交付跟进:Finish 时在 `finally` 释放静态 HTML 交付契约,避免重编辑页面卡在 HTTP 409;成果面板按 Asia/Shanghai 日历日分组,消除重复日期标题。
### 验证摘要
- Git 祖先检查确认分支 HEAD 已进入 `origin/main`。
- `node --test mindspace-delivery-contract.test.mjs`:3/3 通过。
### 最终处置
- 保留本地分支名用于审计追溯。
- **不要** merge、cherry-pick 或从该分支继续开发。
## `feature/h5-queued-chat-submit`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
审计日期:2026-08-13
分支 HEAD:`558c4ef`
`origin/main` 对应提交:`558c4ef`
### 原始用途
H5 主会话在 waiting/streaming/connecting/loading 时静默丢弃后续消息;改为立即展示用户指令、提示排队,并在 composer 回到 idle 后自动提交。
### 验证摘要
- `node --test chat-agent-run-gate.test.mjs`:13/13 通过
- `node scripts/run-memind-tests.mjs --mode changed`:通过
- `node scripts/run-memind-tests.mjs --mode guards`:通过
### 最终处置
- 保留本地分支名用于审计追溯。
- **不要** merge、cherry-pick 或从该分支继续开发。
@@ -0,0 +1,43 @@
# 2026-08-14 过期后补建免费套餐把图片额度写成无限
## 现象
生产管理后台用户详情里,部分普通用户的图片额度显示「无限」,输入框和「保存额度」被禁用。典型用户:柯彤(`wx_1ugymxba`),当时有效套餐为免费版,备注「系统迁移补建免费套餐」,`period_images_limit = 0`,已用 4 张。
后台把 `period_images_limit === 0` 视为无限,用户详情和 `setImageQuota` 都拒绝调整。这些用户可以无限制调用 `image_make`。
## 根因
1. `initSchema` / `migrateSchema` 在用户没有有效订阅时会补建免费套餐,INSERT 只写了 token 额度,没有写 `period_images_limit`。
2. 该列 `DEFAULT 0`,而计费把 `0` 设计成「无限」,不是「没有额度」。
3. 付费套餐过期后,Portal / Plaza 下次启动会给该用户补建免费套餐,于是变成无限生图。
生产套餐目录里免费版是 10 张/周期,不是无限。事故发生时有 33 条有效免费订阅命中该补建备注且上限为 0;另有 86 条正常免费订阅上限为 10。
## 修复
- 补建免费套餐时写入套餐目录的 `period_images`(无目录时回退 10)。
- `ensurePlanCatalogSchema` 启动时把「有效订阅上限为 0,但目录额度 > 0」的记录回填为目录值,并记一条 `plan_change` 流水。真正无限套餐(目录 `period_images = 0`)不改。
- 生产已对这 33 条有效订阅做一次数据回填。代码修复需随 Memind 发布后才会阻止再次补建漏写。
## 排查入口
```sql
SELECT u.username, u.display_name, s.plan_type, s.note,
s.period_images_limit, s.period_images_used, s.period_images_bonus
FROM h5_subscriptions s
JOIN h5_users u ON u.id = s.user_id
WHERE s.status = 'active'
AND s.expires_at > UNIX_TIMESTAMP() * 1000
AND s.period_images_limit = 0;
SELECT plan_type, name, period_images FROM h5_plan_catalog;
```
管理后台:用户详情「图片生成额度」显示无限且无法保存,同时计费里该用户是免费套餐、备注含「系统迁移补建」。
## 影响边界与回滚
- 目录里本身就是无限的套餐(`period_images = 0`)不会被回填。
- 已用量超过目录额度的用户回填后剩余为 0,不能再继续按无限额度生图。
- 回滚代码不会自动把已回填的上限改回 0;如需恢复无限,要显式改订阅或目录。
+3
View File
@@ -35,4 +35,7 @@ MEMIND_PRODUCT_ANALYTICS_WEBSITE_ID=<product-website-id>
- **memind_adm → Analytics 配置**:Umami Website ID、密钥、SSO 入口
- **memind_adm → SEO / GEO 流量**:按页监控搜索引擎 / 生成式引擎来源访问
生产发布与环境变量见 memind_adm [`docs/analytics-deploy.md`](../../memind_adm/docs/analytics-deploy.md)。
跨仓库发布顺序见 [analytics-release-runbook.md](analytics-release-runbook.md)。
Analytics 服务 fail-open:未配置或 Umami 不可用时,页面生成与交付不受影响。勿向终端用户暴露 Umami 控制台。
+4 -4
View File
@@ -205,15 +205,15 @@ Plaza 本地开发说明见 [plaza-local.md](./plaza-local.md)。生产发布、
## MindSpace SEO / GEO(本地联调)
默认关闭,与生产一致。在 **memind_adm**(见上表)打开 **MindSpace 配置**,保存 **SEO / GEO** 卡片中的总开关与子开关。
默认开启。在 **memind_adm**(见上表)打开 **MindSpace 配置**,可按需关闭 **SEO / GEO** 卡片中的总开关与子开关。
| 开关 | 作用 |
|------|------|
| 总开关 `seoGeo.enabled` | 关闭时交付链与改前一致;开启后按收录策略注入 meta / JSON-LD |
| 总开关 `seoGeo.enabled` | 关闭时不注入 meta / JSON-LD;开启后按收录策略注入 |
| `seo.sitemap` / `seo.robots` / `geo.llms` | 控制 `GET /sitemap.xml`、`/robots.txt`、`/llms.txt` |
| `seo.baiduPush` | 用户确认公开 MindSpace 页或 Plaza 发帖后推送百度 |
| `seo.baiduPush` | 公开 MindSpace 页或 Plaza 发帖后推送百度 |
可索引页条件:`public` + `online` + `user_confirmed_at` 非空。私有页、未确认公开、embed 预览一律 `noindex`。
可索引页条件:`public` + `online` + 未过期。密码、登录可见、owner_only、已过期、embed 预览一律 `noindex`。
本地验证:
+4 -4
View File
@@ -4,11 +4,11 @@
## 保护内容
1. **收录策略硬规则**:仅 `access_mode=public` 且 `status=online` 且 `user_confirmed_at` 非空的发布页允许 SEO/GEO 注入与 sitemap/llms 收录。
2. **私有页强制 noindex**:密码、登录可见、owner_only、未确认公开、工作区预览直链等必须注入 `noindex, nofollow` 并设置 `X-Robots-Tag`。
3. **总开关默认关闭**:`mindspace_config.seo_geo_config.enabled=false` 时,交付链行为与未上线前一致。
1. **收录策略硬规则**:仅 `access_mode=public` 且 `status=online` 且未过期的发布页允许 SEO/GEO 注入与 sitemap/llms 收录。不再要求 `user_confirmed_at`。
2. **私有页强制 noindex**:密码、登录可见、owner_only、已过期、工作区预览直链等必须注入 `noindex, nofollow` 并设置 `X-Robots-Tag`。
3. **总开关默认开启**:`mindspace_config.seo_geo_config` 缺省为全开;库内已保存的旧值仍以数据库为准,需在 memind_adm MindSpace 配置页保存后才会改写生产。
4. **配置来源**:memind_adm MindSpace 配置页 → `PATCH /admin-api/mindspace/config` → Portal `loadMindSpaceConfigCached()`。
5. **百度推送**:仅在 admin 开启 `seo.baiduPush` 且页面可索引时触发;MindSpace 确认公开与 Plaza 发帖共用该开关。
5. **百度推送**:仅在 admin 开启 `seo.baiduPush` 且页面可索引时触发;公开页发布与 Plaza 发帖共用该开关。
## 必跑验证
+2
View File
@@ -21,6 +21,8 @@
- `105` 与 `103` 的关键差异是:
`105` 为 Linux 环境,**不要携带本机 Mac 的 `node_modules`**;应在 `105` 切换前本机执行 `npm install --omit=dev --no-package-lock`
Umami Analytics(105 `/opt/memind-analytics`)是**独立发布链**,不走 Portal runtime 脚本。见 [analytics-release-runbook.md](analytics-release-runbook.md)。
## 唯一入口
```bash
+1 -1
View File
@@ -9,7 +9,7 @@
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#0f1419" />
<title>Memind</title>
<title>TKMind</title>
</head>
<body>
<div id="root"></div>
+16
View File
@@ -62,6 +62,22 @@ function matchHostRule(hostname, rules = []) {
return '';
}
export function classifyReferrerHost(hostname) {
const host = normalizeHostname(hostname);
if (!host) {
return { discovery_channel: 'direct', discovery_source: 'direct' };
}
const geoSource = matchHostRule(host, GEO_HOST_RULES);
if (geoSource) {
return { discovery_channel: 'geo', discovery_source: geoSource };
}
const seoSource = matchHostRule(host, SEO_HOST_RULES);
if (seoSource) {
return { discovery_channel: 'seo', discovery_source: seoSource };
}
return { discovery_channel: 'referral', discovery_source: 'other' };
}
function normalizeDiscoveryChannel(value) {
const channel = String(value ?? '').trim().toLowerCase();
return channel === 'seo' || channel === 'geo' ? channel : '';
+8 -8
View File
@@ -59,18 +59,18 @@ function parseJsonObject(value, fallback = {}) {
export function defaultSeoGeoConfig() {
return {
enabled: false,
enabled: true,
seo: {
enabled: false,
enabled: true,
canonical: true,
sitemap: false,
robotsTxt: false,
baiduPush: false,
sitemap: true,
robotsTxt: true,
baiduPush: true,
},
geo: {
enabled: false,
jsonLd: false,
llmsTxt: false,
enabled: true,
jsonLd: true,
llmsTxt: true,
},
};
}
+38 -1
View File
@@ -9,6 +9,18 @@ import {
updateMindSpaceConfig,
} from './mindspace-config.mjs';
test('defaultSeoGeoConfig enables all discovery switches', () => {
const config = defaultSeoGeoConfig();
assert.equal(config.enabled, true);
assert.equal(config.seo.enabled, true);
assert.equal(config.seo.sitemap, true);
assert.equal(config.seo.robotsTxt, true);
assert.equal(config.seo.baiduPush, true);
assert.equal(config.geo.enabled, true);
assert.equal(config.geo.jsonLd, true);
assert.equal(config.geo.llmsTxt, true);
});
test('defaultMindSpaceConfig falls back to env or 5', () => {
assert.equal(defaultMindSpaceConfig({ MINDSPACE_FREE_PUBLIC_PAGE_LIMIT: '8' }).publicPageLimit, 8);
assert.equal(defaultMindSpaceConfig({ MINDSPACE_FREE_PUBLIC_PAGE_LIMIT: '0' }).publicPageLimit, 5);
@@ -24,7 +36,7 @@ test('normalizeSeoGeoConfig merges nested switches', () => {
assert.equal(config.enabled, true);
assert.equal(config.seo.sitemap, true);
assert.equal(config.geo.llmsTxt, true);
assert.equal(config.seo.baiduPush, false);
assert.equal(config.seo.baiduPush, true);
});
test('ensureMindSpaceConfig seeds the default row only when empty', async () => {
@@ -45,6 +57,31 @@ test('ensureMindSpaceConfig seeds the default row only when empty', async () =>
assert.deepEqual(calls[2].params.slice(0, 3), ['public_page_limit', '9', '公开页面数量上限']);
});
test('loadMindSpaceConfig keeps stored all-off seoGeo instead of new defaults', async () => {
const pool = {
async query(sql) {
if (sql.includes('FROM mindspace_config')) {
return [[
{
key: 'seo_geo_config',
value: JSON.stringify({
enabled: false,
seo: { enabled: false, canonical: true, sitemap: false, robotsTxt: false, baiduPush: false },
geo: { enabled: false, jsonLd: false, llmsTxt: false },
}),
},
]];
}
return [[]];
},
};
const config = await loadMindSpaceConfig(pool);
assert.equal(config.seoGeo.enabled, false);
assert.equal(config.seoGeo.seo.sitemap, false);
assert.equal(config.seoGeo.geo.jsonLd, false);
});
test('loadMindSpaceConfig merges stored config with fallback', async () => {
const pool = {
async query(sql) {
+31
View File
@@ -46,3 +46,34 @@ export async function markPageDeliveryContractReady({ pool, userId, relativePath
);
return Number(result?.affectedRows ?? 0) > 0;
}
export async function releaseMaterializedPageDeliveryContracts({
pool,
userId,
relativePaths = [],
allowPgRequired = false,
} = {}) {
if (!pool || !userId) return [];
const released = [];
for (const rawPath of relativePaths) {
const workspaceRelativePath = normalizeDeliveryRelativePath(rawPath);
if (!workspaceRelativePath) continue;
const contract = await getPageDeliveryContract({
pool,
userId,
relativePath: workspaceRelativePath,
});
if (!contract || contract.status === 'ready') continue;
if (contract.data_mode === 'pg_required' && !allowPgRequired) continue;
if (
await markPageDeliveryContractReady({
pool,
userId,
relativePath: workspaceRelativePath,
})
) {
released.push(workspaceRelativePath);
}
}
return released;
}
+41
View File
@@ -5,6 +5,7 @@ import {
markPageDeliveryContractReady,
normalizeDeliveryRelativePath,
preparePageDeliveryContract,
releaseMaterializedPageDeliveryContracts,
} from './mindspace-delivery-contract.mjs';
test('normalizes only safe public HTML delivery paths', () => {
@@ -40,3 +41,43 @@ test('contract lifecycle writes preparing then ready against the same route key'
assert.equal(await markPageDeliveryContractReady({ pool, userId: 'user-1', relativePath: 'public/form.html' }), true);
assert.ok(calls.some((call) => call.sql.includes("status = 'ready'")));
});
test('releaseMaterializedPageDeliveryContracts skips pg_required until allowed', async () => {
const calls = [];
const pool = {
async query(sql, params) {
calls.push({ sql, params });
if (sql.includes('SELECT id, data_mode')) {
const path = params?.[1];
if (path === 'public/form.html') {
return [[{ id: 'c1', data_mode: 'pg_required', status: 'preparing' }]];
}
if (path === 'public/report.html') {
return [[{ id: 'c2', data_mode: 'static', status: 'preparing' }]];
}
return [[]];
}
if (sql.includes("status = 'ready'")) return [{ affectedRows: 1 }];
return [{ affectedRows: 0 }];
},
};
assert.deepEqual(
await releaseMaterializedPageDeliveryContracts({
pool,
userId: 'user-1',
relativePaths: ['public/form.html', 'public/report.html'],
allowPgRequired: false,
}),
['public/report.html'],
);
assert.deepEqual(
await releaseMaterializedPageDeliveryContracts({
pool,
userId: 'user-1',
relativePaths: ['public/form.html'],
allowPgRequired: true,
}),
['public/form.html'],
);
});
+1 -2
View File
@@ -40,7 +40,6 @@ export function isPublicationIndexable(publication, { now = Date.now() } = {}) {
if (!snapshot) return false;
if (snapshot.status !== 'online') return false;
if (snapshot.accessMode !== INDEXABLE_ACCESS_MODE) return false;
if (snapshot.userConfirmedAt == null) return false;
if (snapshot.expiresAt != null && snapshot.expiresAt <= now) return false;
return true;
}
@@ -74,7 +73,7 @@ export function resolveIndexPolicy({
return {
mode: 'indexable',
injectNoindex: false,
reason: 'public_confirmed',
reason: 'public_online',
seoEnabled: Boolean(seoGeoConfig.seo?.enabled),
geoEnabled: Boolean(seoGeoConfig.geo?.enabled),
canonicalEnabled: seoGeoConfig.seo?.canonical !== false,
+8 -1
View File
@@ -17,6 +17,13 @@ test('isPublicationIndexable accepts confirmed public online publications', () =
assert.equal(isPublicationIndexable(indexablePublication), true);
});
test('isPublicationIndexable accepts public online pages without confirmation', () => {
assert.equal(
isPublicationIndexable({ ...indexablePublication, userConfirmedAt: null }),
true,
);
});
test('isPublicationIndexable rejects private access modes', () => {
for (const accessMode of ['password', 'private_link', 'login_required', 'owner_only']) {
assert.equal(
@@ -44,7 +51,7 @@ test('resolveIndexPolicy noindexes non-public publications when enabled', () =>
assert.equal(policy.injectNoindex, true);
});
test('resolveIndexPolicy marks confirmed public pages indexable', () => {
test('resolveIndexPolicy marks public online pages indexable', () => {
const policy = resolveIndexPolicy({
seoGeoConfig: {
enabled: true,
+53
View File
@@ -0,0 +1,53 @@
import { enrichPagesWithAchievementStats } from './mindspace-page-achievement-stats.mjs';
export const DEFAULT_ACHIEVEMENT_LIMIT = 20;
export function parseAchievementQuery(query = {}) {
const limit = Number.parseInt(String(query.limit ?? ''), 10);
const offset = Number.parseInt(String(query.offset ?? ''), 10);
const includeStatsRaw = String(query.include_stats ?? '0').trim().toLowerCase();
const includeStats = includeStatsRaw === '1' || includeStatsRaw === 'true';
return {
status: typeof query.status === 'string' ? query.status : undefined,
categoryCode: typeof query.category_code === 'string' ? query.category_code : undefined,
limit: Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 100) : DEFAULT_ACHIEVEMENT_LIMIT,
offset: Number.isFinite(offset) ? Math.max(offset, 0) : 0,
includeStats,
};
}
export async function listAchievementPagesForUser({
pages,
userId,
query = {},
pool = null,
logger = console,
} = {}) {
if (!pages || typeof pages.listPages !== 'function') {
throw new Error('MindSpace page service unavailable');
}
const parsed = parseAchievementQuery(query);
const result = await pages.listPages(userId, {
status: parsed.status,
categoryCode: parsed.categoryCode,
limit: parsed.limit,
offset: parsed.offset,
});
const items = await enrichPagesWithAchievementStats(result.items, {
ownerUserId: userId,
pool,
logger,
skipUmami: !parsed.includeStats,
});
return {
items,
total: result.total,
limit: result.limit,
offset: result.offset,
hasMore: Boolean(result.hasMore ?? result.offset + result.items.length < result.total),
};
}
+11
View File
@@ -0,0 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseAchievementQuery } from './mindspace-page-achievement-list.mjs';
test('parseAchievementQuery defaults to fast paginated listing', () => {
const parsed = parseAchievementQuery({});
assert.equal(parsed.limit, 20);
assert.equal(parsed.offset, 0);
assert.equal(parsed.includeStats, false);
});
+275
View File
@@ -0,0 +1,275 @@
import crypto from 'node:crypto';
import {
pseudonymizeAnalyticsId,
resolveAnalyticsIdentity,
resolveMindSpaceAnalyticsConfig,
} from './mindspace-analytics.mjs';
const TOKEN_TTL_MS = 55 * 60 * 1000;
let cachedToken = '';
let cachedTokenAt = 0;
let cachedBaseUrl = '';
function normalizeBaseUrl(value = '') {
return String(value ?? '').trim().replace(/\/$/, '');
}
function resolveUmamiCredentials(env = process.env, analyticsConfig = resolveMindSpaceAnalyticsConfig(env)) {
const baseUrl = normalizeBaseUrl(analyticsConfig?.analyticsUrl || env.MEMIND_ANALYTICS_URL || 'http://127.0.0.1:3100');
const username = String(env.UMAMI_SSO_USERNAME || env.UMAMI_ADMIN_USERNAME || 'admin').trim();
const password = String(env.UMAMI_ADMIN_PASSWORD || '').trim();
const sharedSecret = String(env.MEMIND_UMAMI_SSO_SECRET || '').trim();
const websiteId = String(analyticsConfig?.websiteId || env.MEMIND_ANALYTICS_WEBSITE_ID || '').trim();
return { baseUrl, username, password, sharedSecret, websiteId };
}
async function loginUmamiViaSso({ baseUrl, username, sharedSecret }) {
const encoded = Buffer.from(
JSON.stringify({
username,
exp: Math.floor(Date.now() / 1000) + 60,
nonce: crypto.randomUUID(),
}),
).toString('base64url');
const signature = crypto.createHmac('sha256', sharedSecret).update(encoded).digest('base64url');
const ticket = `${encoded}.${signature}`;
const response = await fetch(`${baseUrl}/api/auth/memind?ticket=${encodeURIComponent(ticket)}`, {
signal: AbortSignal.timeout(10_000),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body?.error?.message || body?.message || `Umami SSO 登录失败 (${response.status})`);
}
const token = String(body?.token ?? '').trim();
if (!token) throw new Error('Umami SSO 登录响应缺少 token');
return token;
}
async function loginUmami(credentials) {
if (credentials.sharedSecret) {
return loginUmamiViaSso(credentials);
}
if (!credentials.password) {
throw new Error('未配置 Umami 凭据');
}
const response = await fetch(`${credentials.baseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: credentials.username, password: credentials.password }),
signal: AbortSignal.timeout(10_000),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body?.message || `Umami 登录失败 (${response.status})`);
}
const token = String(body?.token ?? '').trim();
if (!token) throw new Error('Umami 登录响应缺少 token');
return token;
}
async function getUmamiAuthToken(credentials) {
const now = Date.now();
if (cachedToken && cachedBaseUrl === credentials.baseUrl && now - cachedTokenAt < TOKEN_TTL_MS) {
return cachedToken;
}
cachedToken = await loginUmami(credentials);
cachedTokenAt = now;
cachedBaseUrl = credentials.baseUrl;
return cachedToken;
}
function pathnameFromUrl(value) {
const raw = String(value ?? '').trim();
if (!raw) return '';
try {
return new URL(raw, 'https://m.tkmind.cn').pathname.replace(/\/$/, '') || '/';
} catch {
return raw.startsWith('/') ? raw.replace(/\/$/, '') || '/' : '';
}
}
function buildPageMatchKeys(page, userId) {
const keys = new Set();
if (page?.id) keys.add(String(page.id));
const workspacePath = String(page?.workspaceRelativePath ?? '').trim().replace(/^\/+/, '');
if (workspacePath) {
keys.add(`/MindSpace/${userId}/${workspacePath}`.replace(/\/$/, ''));
}
for (const candidate of [page?.publicationUrl, page?.workspacePublicUrl]) {
const pathname = pathnameFromUrl(candidate);
if (pathname) keys.add(pathname);
}
return keys;
}
export function mergeAchievementStatsForPage(page, umamiByPath, userId) {
const publicationViews = Number(page?.viewCount ?? 0);
const keys = buildPageMatchKeys(page, userId);
let umamiViews = 0;
let umamiClicks = 0;
let matched = false;
for (const key of keys) {
const stats = umamiByPath.get(key);
if (!stats) continue;
matched = true;
umamiViews = Math.max(umamiViews, Number(stats.views ?? 0));
umamiClicks = Math.max(umamiClicks, Number(stats.clicks ?? 0));
}
const viewCount = matched ? Math.max(publicationViews, umamiViews) : publicationViews;
const clickCount = matched ? umamiClicks : null;
let statsSource = 'none';
if (matched && (umamiViews > 0 || umamiClicks > 0)) statsSource = 'umami';
else if (publicationViews > 0) statsSource = 'publication';
return {
...page,
viewCount,
clickCount,
statsSource,
};
}
export async function fetchUmamiPageStatsByPath({
ownerUserId,
env = process.env,
analyticsConfig = resolveMindSpaceAnalyticsConfig(env),
logger = console,
} = {}) {
if (!analyticsConfig?.enabled || !ownerUserId) return new Map();
const credentials = resolveUmamiCredentials(env, analyticsConfig);
if (!credentials.websiteId) return new Map();
if (!credentials.sharedSecret && !credentials.password) return new Map();
const ownerIdentity = resolveAnalyticsIdentity(ownerUserId, analyticsConfig);
if (!ownerIdentity) return new Map();
try {
const token = await getUmamiAuthToken(credentials);
const params = new URLSearchParams();
params.set('page', '1');
params.set('pageSize', '100');
params.set('sortBy', 'generatedAt');
params.set('sortOrder', 'desc');
const endAt = Date.now();
const startAt = endAt - 365 * 24 * 60 * 60 * 1000;
params.set('startAt', String(startAt));
params.set('endAt', String(endAt));
const response = await fetch(
`${credentials.baseUrl}/api/websites/${encodeURIComponent(credentials.websiteId)}/memind-pages?${params.toString()}`,
{
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
signal: AbortSignal.timeout(15_000),
},
);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body?.message || `Umami memind-pages 请求失败 (${response.status})`);
}
const rows = Array.isArray(body?.data) ? body.data : [];
const map = new Map();
for (const row of rows) {
if (String(row?.ownerId ?? '') !== ownerIdentity) continue;
const urlPath = pathnameFromUrl(row?.urlPath);
const pageUrlPath = pathnameFromUrl(row?.pageUrl);
const stats = {
views: Number(row?.views ?? 0),
clicks: Number(row?.clicks ?? 0),
};
if (urlPath) map.set(urlPath, stats);
if (pageUrlPath) map.set(pageUrlPath, stats);
}
return map;
} catch (error) {
logger?.warn?.(
'[MindSpace] Umami achievement stats unavailable:',
error instanceof Error ? error.message : error,
);
return new Map();
}
}
export async function loadPublicationStatsByPageIds(pool, userId, pageIds) {
if (!pool || !userId || !Array.isArray(pageIds) || pageIds.length === 0) {
return new Map();
}
const ids = [...new Set(pageIds.map((id) => String(id ?? '').trim()).filter(Boolean))];
if (ids.length === 0) return new Map();
const placeholders = ids.map(() => '?').join(',');
const [rows] = await pool.query(
`SELECT page_id,
SUM(view_count) AS total_view_count,
SUBSTRING_INDEX(
GROUP_CONCAT(id ORDER BY COALESCE(published_at, 0) DESC, updated_at DESC),
',',
1
) AS latest_publish_id
FROM h5_publish_records
WHERE user_id = ? AND page_id IN (${placeholders})
GROUP BY page_id`,
[userId, ...ids],
);
const map = new Map();
for (const row of rows) {
map.set(String(row.page_id), {
viewCount: Number(row.total_view_count ?? 0),
publicationId: row.latest_publish_id ?? null,
});
}
return map;
}
function applyPublicationStats(pages, pubStatsByPageId) {
if (!pubStatsByPageId?.size) return pages;
return pages.map((page) => {
const pubStats = pubStatsByPageId.get(String(page?.id ?? ''));
if (!pubStats) return page;
const existingViews = Number(page?.viewCount ?? 0);
const mergedViews = Math.max(existingViews, pubStats.viewCount);
if (mergedViews === existingViews && (page?.publicationId || !pubStats.publicationId)) {
return page;
}
return {
...page,
viewCount: mergedViews,
...(page?.publicationId ? {} : pubStats.publicationId ? { publicationId: pubStats.publicationId } : {}),
...(mergedViews > 0 && page?.statsSource === 'none' ? { statsSource: 'publication' } : {}),
};
});
}
export async function enrichPagesWithAchievementStats(pages, {
ownerUserId,
env = process.env,
analyticsConfig = resolveMindSpaceAnalyticsConfig(env),
pool = null,
logger = console,
skipUmami = false,
} = {}) {
const pageIds = pages.map((page) => page?.id).filter(Boolean);
const pubStatsByPageId = await loadPublicationStatsByPageIds(pool, ownerUserId, pageIds);
const pagesWithPublicationStats = applyPublicationStats(pages, pubStatsByPageId);
if (skipUmami) {
return pagesWithPublicationStats.map((page) =>
mergeAchievementStatsForPage(page, new Map(), ownerUserId),
);
}
const umamiByPath = await fetchUmamiPageStatsByPath({
ownerUserId,
env,
analyticsConfig,
logger,
});
return pagesWithPublicationStats.map((page) => mergeAchievementStatsForPage(page, umamiByPath, ownerUserId));
}
export const achievementStatsInternals = {
buildPageMatchKeys,
mergeAchievementStatsForPage,
pathnameFromUrl,
pseudonymizeAnalyticsId,
};
+63
View File
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
achievementStatsInternals,
mergeAchievementStatsForPage,
} from './mindspace-page-achievement-stats.mjs';
const { buildPageMatchKeys, mergeAchievementStatsForPage: mergePage, pathnameFromUrl } =
achievementStatsInternals;
test('buildPageMatchKeys includes workspace and publication paths', () => {
const keys = buildPageMatchKeys(
{
id: 'page-1',
workspaceRelativePath: 'public/demo.html',
publicationUrl: 'https://m.tkmind.cn/u/john/pages/demo',
},
'user-1',
);
assert.ok(keys.has('page-1'));
assert.ok(keys.has('/MindSpace/user-1/public/demo.html'));
assert.ok(keys.has('/u/john/pages/demo'));
});
test('mergeAchievementStatsForPage prefers umami metrics when matched', () => {
const umamiByPath = new Map([
['/MindSpace/user-1/public/demo.html', { views: 12, clicks: 3 }],
]);
const merged = mergePage(
{
id: 'page-1',
title: 'Demo',
workspaceRelativePath: 'public/demo.html',
viewCount: 5,
},
umamiByPath,
'user-1',
);
assert.equal(merged.viewCount, 12);
assert.equal(merged.clickCount, 3);
assert.equal(merged.statsSource, 'umami');
});
test('mergeAchievementStatsForPage keeps publication views without umami match', () => {
const merged = mergeAchievementStatsForPage(
{
id: 'page-2',
title: 'Draft',
viewCount: 7,
},
new Map(),
'user-2',
);
assert.equal(merged.viewCount, 7);
assert.equal(merged.clickCount, null);
assert.equal(merged.statsSource, 'publication');
});
test('pathnameFromUrl normalizes absolute and relative urls', () => {
assert.equal(pathnameFromUrl('https://m.tkmind.cn/u/john/pages/demo/'), '/u/john/pages/demo');
assert.equal(pathnameFromUrl('/MindSpace/u/public/page.html'), '/MindSpace/u/public/page.html');
});
+73 -8
View File
@@ -135,6 +135,12 @@ function pageResponse(row, { includeContent = true, h5Root = null, env = process
status: row.status,
visibility: row.visibility,
publicationAccessMode: row.pub_access_mode ?? null,
publicationId: row.pub_id ?? null,
publicationStatus: row.pub_status ?? null,
viewCount: asNumber(row.total_view_count ?? row.pub_view_count ?? 0),
clickCount: null,
statsSource: asNumber(row.total_view_count ?? row.pub_view_count ?? 0) > 0 ? 'publication' : 'none',
publishedAt: row.pub_published_at != null ? asNumber(row.pub_published_at) : null,
publicationUrl: workspacePublicUrl ?? row.pub_public_url ?? null,
workspaceRelativePath: workspaceRelativePath ?? null,
workspacePublicUrl,
@@ -727,21 +733,42 @@ export function createPageService(pool, options = {}) {
clauses.push(`c.category_code = ?`);
params.push(filters.categoryCode);
}
if (Number.isFinite(filters.createdAfter)) {
clauses.push(`p.created_at >= ?`);
params.push(filters.createdAfter);
}
if (Number.isFinite(filters.createdBefore)) {
clauses.push(`p.created_at < ?`);
params.push(filters.createdBefore);
}
const where = clauses.join(' AND ');
const { limit, offset } = normalizeListPageFilters(filters);
const queryParams = [userId, ...params];
const baseFrom = `FROM h5_page_records p
JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'`;
LEFT JOIN (
SELECT page_id,
SUM(view_count) AS total_view_count,
MAX(published_at) AS last_published_at,
SUBSTRING_INDEX(GROUP_CONCAT(id ORDER BY COALESCE(published_at, 0) DESC, updated_at DESC), ',', 1) AS latest_publish_id
FROM h5_publish_records
WHERE user_id = ?
GROUP BY page_id
) pub_stats ON pub_stats.page_id = p.id
LEFT JOIN h5_publish_records pr ON pr.id = COALESCE(p.current_publish_id, pub_stats.latest_publish_id)`;
const [[countRows], [rows]] = await Promise.all([
pool.query(`SELECT COUNT(*) AS total ${baseFrom} WHERE ${where}`, params),
pool.query(`SELECT COUNT(*) AS total ${baseFrom} WHERE ${where}`, queryParams),
pool.query(
`SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url
`SELECT p.*, c.category_code, pv.version_no,
pr.id AS pub_id, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url,
pr.view_count AS pub_view_count, pr.published_at AS pub_published_at, pr.status AS pub_status,
COALESCE(pub_stats.total_view_count, 0) AS total_view_count
${baseFrom}
WHERE ${where}
ORDER BY p.created_at DESC, p.updated_at DESC
LIMIT ? OFFSET ?`,
[...params, limit, offset],
[...queryParams, limit, offset],
),
]);
const total = asNumber(countRows[0]?.total);
@@ -754,6 +781,28 @@ export function createPageService(pool, options = {}) {
};
};
const listPageCreatedDateBuckets = async (userId, { limit = 3 } = {}) => {
const safeLimit = Math.min(Math.max(Number(limit) || 3, 1), 31);
const [rows] = await pool.query(
`SELECT DATE(FROM_UNIXTIME(p.created_at / 1000)) AS date_key,
MIN(p.created_at) AS min_created_at,
MAX(p.created_at) AS max_created_at,
COUNT(*) AS page_count
FROM h5_page_records p
WHERE p.user_id = ? AND p.status <> 'deleted'
GROUP BY date_key
ORDER BY date_key DESC
LIMIT ?`,
[userId, safeLimit],
);
return rows.map((row) => ({
dateKey: String(row.date_key),
minCreatedAt: asNumber(row.min_created_at),
maxCreatedAt: asNumber(row.max_created_at),
pageCount: asNumber(row.page_count),
}));
};
async function getPage(userId, pageId) {
const [rows] = await pool.query(
`SELECT p.*, c.category_code, pv.version_no, av.storage_key, pv.source_snapshot_json
@@ -1241,13 +1290,27 @@ export function createPageService(pool, options = {}) {
const deletePage = async (userId, pageId, options = {}) => {
const removeFromPlaza = Boolean(options.removeFromPlaza);
const page = await getPage(userId, pageId);
const [metaRows] = await pool.query(
`SELECT p.id, p.title, p.page_type, p.space_id, p.source_asset_id
FROM h5_page_records p
WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted'
LIMIT 1`,
[pageId, userId],
);
const pageMeta = metaRows[0];
if (!pageMeta) throw pageError('页面不存在', 'page_not_found');
const pageDetail = await getPage(userId, pageId).catch(() => null);
const contentFormat =
pageDetail?.contentFormat ?? (pageMeta.page_type === 'html' ? 'html' : 'markdown');
const pageContent = pageDetail?.content ?? '';
let workspaceHtmlRelativePath = null;
try {
const [rows] = await pool.query(
`SELECT pv.source_snapshot_json
FROM h5_page_records p
JOIN h5_page_versions pv ON pv.id = p.current_version_id
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted'
LIMIT 1`,
[pageId, userId],
@@ -1258,7 +1321,7 @@ export function createPageService(pool, options = {}) {
workspaceHtmlRelativePath = null;
}
const embeddedAssetIds =
page.contentFormat === 'html' ? extractAssetIdsFromHtml(page.content ?? '') : [];
contentFormat === 'html' ? extractAssetIdsFromHtml(pageContent) : [];
const conn = await pool.getConnection();
let result;
@@ -1333,7 +1396,7 @@ export function createPageService(pool, options = {}) {
const purgeResult = await purgeWorkspacePageArtifacts({
publishDir: workspacePublishDir,
htmlRelativePath: workspaceHtmlRelativePath,
html: page.content ?? '',
html: pageContent,
}).catch((error) => {
console.warn('[MindSpace] workspace purge failed:', error?.message ?? error);
return { removed: [], skipped: [] };
@@ -1494,6 +1557,7 @@ export function createPageService(pool, options = {}) {
redactPage,
createRedactedCopy: redactPage,
listPages,
listPageCreatedDateBuckets,
findPageBySourceAsset,
findPageBySourceMessage,
findPageByRelativePath,
@@ -1602,6 +1666,7 @@ export const pageInternals = {
normalizePageInput,
normalizeListPageFilters,
parseJsonColumn,
pageResponse,
renderContent,
renderPreviewHtml,
renderHtmlPreview,
+106
View File
@@ -88,6 +88,45 @@ test('normalizeListPageFilters clamps limit and offset', () => {
});
});
test('pageResponse includes publication metrics when publish row is present', () => {
const response = pageInternals.pageResponse(
{
id: 'page-1',
user_id: 'user-1',
category_id: 'cat-1',
category_code: 'public',
source_session_id: null,
source_message_id: null,
source_asset_id: null,
title: '成果页面',
summary: '摘要',
page_type: 'html',
template_id: 'static-html',
status: 'published',
visibility: 'public',
current_version_id: 'ver-1',
version_no: 3,
pub_access_mode: 'public',
pub_public_url: 'https://m.tkmind.cn/u/john/pages/demo',
pub_id: 'pub-1',
pub_status: 'online',
pub_view_count: 128,
pub_published_at: 1_700_000_000_000,
created_at: 1_699_000_000_000,
updated_at: 1_700_100_000_000,
},
{ includeContent: false },
);
assert.equal(response.publicationId, 'pub-1');
assert.equal(response.publicationStatus, 'online');
assert.equal(response.viewCount, 128);
assert.equal(response.clickCount, null);
assert.equal(response.statsSource, 'publication');
assert.equal(response.publishedAt, 1_700_000_000_000);
assert.equal(response.publicationUrl, 'https://m.tkmind.cn/u/john/pages/demo');
});
test('preview rendering escapes active HTML and emits a restrictive CSP', () => {
const html = pageInternals.renderPreviewHtml({
title: '<script>alert(1)</script>',
@@ -295,3 +334,70 @@ test('getPage falls back to workspace html when storage asset is missing', async
assert.equal(page.title, 'Page Demo');
assert.match(page.content, /<h1>Body<\/h1>/);
});
test('deletePage succeeds when page content asset chain is incomplete', async () => {
const userId = 'user-1';
const pageId = 'page-broken';
let deletedPage = false;
const conn = {
async beginTransaction() {},
async commit() {},
async rollback() {},
async release() {},
query: async (sql) => {
const normalized = String(sql);
if (normalized.includes('FOR UPDATE') && normalized.includes('h5_page_records')) {
return [[{ id: pageId, space_id: 'space-1', title: 'test', source_asset_id: null }]];
}
if (normalized.includes('FROM h5_page_versions pv')) {
return [[]];
}
if (normalized.includes('FROM h5_publish_records') && normalized.includes("status = 'online'")) {
return [[]];
}
if (normalized.includes('FROM h5_agent_jobs')) {
return [[{ job_count: 0 }]];
}
if (normalized.includes("SET status = 'deleted'") && normalized.includes('h5_page_records')) {
deletedPage = true;
return [{ affectedRows: 1 }];
}
if (normalized.includes('UPDATE h5_agent_jobs')) {
return [{ affectedRows: 0 }];
}
return [[]];
},
};
const pool = {
getConnection: async () => conn,
query: async (sql) => {
const normalized = String(sql);
if (
normalized.includes('FROM h5_page_records p') &&
normalized.includes('page_type')
) {
return [[{
id: pageId,
title: 'test',
page_type: 'markdown',
space_id: 'space-1',
source_asset_id: null,
}]];
}
if (normalized.includes('JOIN h5_asset_versions')) {
return [[]];
}
if (normalized.includes('source_snapshot_json')) {
return [[{ source_snapshot_json: null }]];
}
return [[]];
},
};
const pageService = createPageService(pool, { storageRoot: '/tmp/unused', h5Root: null });
const result = await pageService.deletePage(userId, pageId);
assert.equal(result.deleted, true);
assert.equal(result.pageId, pageId);
assert.equal(result.title, 'test');
assert.equal(deletedPage, true);
});
+1 -2
View File
@@ -32,7 +32,6 @@ export async function listIndexablePublications(
LEFT JOIN h5_page_records p ON p.id = pr.page_id
WHERE pr.status = 'online'
AND pr.access_mode = 'public'
AND pr.user_confirmed_at IS NOT NULL
AND (pr.expires_at IS NULL OR pr.expires_at > ?)
ORDER BY pr.published_at DESC
LIMIT ? OFFSET ?`,
@@ -153,7 +152,7 @@ export function renderRobotsTxt({
export function renderLlmsTxt(entries, { origin = '' } = {}) {
const header = [
'# TKMind MindSpace public pages',
'# Only confirmed public publications are listed here.',
'# Public online publications are listed here.',
'',
];
const body = entries
+243
View File
@@ -0,0 +1,243 @@
import { classifyReferrerHost } from './mindspace-analytics-discovery.mjs';
import { isPublicationIndexable, normalizePublicationSnapshot } from './mindspace-index-policy.mjs';
const DEFAULT_PUBLIC_HOST = 'm.tkmind.cn';
const SORT_COLUMNS = new Set([
'publishedAt',
'pageTitle',
'ownerLabel',
'totalViews',
'seoViews',
'geoViews',
'crawlerViews',
'lifetimeViewCount',
]);
function asPositiveInt(value, fallback) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 1) return fallback;
return Math.floor(parsed);
}
function normalizeSortBy(value, fallback = 'publishedAt') {
const sortBy = String(value ?? '').trim();
return SORT_COLUMNS.has(sortBy) ? sortBy : fallback;
}
function normalizeSortOrder(value, fallback = 'desc') {
return String(value ?? '').trim().toLowerCase() === 'asc' ? 'asc' : fallback;
}
export function resolveAdminCatalogPublicUrl(publicUrl, { publicHost = DEFAULT_PUBLIC_HOST } = {}) {
const raw = String(publicUrl ?? '').trim();
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) return raw.split('#')[0];
if (raw.startsWith('/')) return `https://${publicHost}${raw}`.split('#')[0];
return raw.split('#')[0];
}
function compareRows(a, b, sortBy, sortOrder) {
const direction = sortOrder === 'asc' ? 1 : -1;
const left = a[sortBy];
const right = b[sortBy];
if (typeof left === 'string' || typeof right === 'string') {
return String(left ?? '').localeCompare(String(right ?? ''), 'zh-CN') * direction;
}
return ((Number(left) || 0) - (Number(right) || 0)) * direction;
}
function aggregateViewStats(rows = []) {
const stats = {
totalViews: 0,
seoViews: 0,
geoViews: 0,
crawlerViews: 0,
};
for (const row of rows) {
const views = Number(row.views) || 0;
if (views <= 0) continue;
stats.totalViews += views;
if (String(row.device_type ?? '') === 'bot') {
stats.crawlerViews += views;
continue;
}
const discovery = classifyReferrerHost(row.referrer_host);
if (discovery.discovery_channel === 'seo') stats.seoViews += views;
if (discovery.discovery_channel === 'geo') stats.geoViews += views;
}
return stats;
}
export async function listAdminSeoGeoPublicationCatalog(
pool,
{
page = 1,
pageSize = 20,
sortBy = 'publishedAt',
sortOrder = 'desc',
startAt = null,
endAt = null,
search = '',
publicHost = DEFAULT_PUBLIC_HOST,
now = Date.now(),
} = {},
) {
if (!pool) {
return { data: [], total: 0, page: 1, pageSize: 20, totals: emptyTotals() };
}
const safePage = asPositiveInt(page, 1);
const safePageSize = Math.min(asPositiveInt(pageSize, 20), 100);
const normalizedSortBy = normalizeSortBy(sortBy);
const normalizedSortOrder = normalizeSortOrder(sortOrder);
const searchTerm = String(search ?? '').trim();
const rangeStart = Number(startAt);
const rangeEnd = Number(endAt);
const hasRange = Number.isFinite(rangeStart) && Number.isFinite(rangeEnd) && rangeEnd >= rangeStart;
const params = [];
let whereSql = `WHERE pr.status = 'online'`;
if (searchTerm) {
whereSql += ` AND (
p.title LIKE ?
OR u.username LIKE ?
OR u.display_name LIKE ?
OR pr.public_url LIKE ?
)`;
const like = `%${searchTerm}%`;
params.push(like, like, like, like);
}
const [countRows] = await pool.query(
`SELECT COUNT(*) AS total
FROM h5_publish_records pr
JOIN h5_page_records p ON p.id = pr.page_id
JOIN h5_users u ON u.id = pr.user_id
${whereSql}`,
params,
);
const total = Number(countRows[0]?.total ?? 0);
const [publicationRows] = await pool.query(
`SELECT
pr.id AS publicationId,
pr.page_id AS pageId,
pr.user_id AS userId,
pr.public_url AS publicUrl,
pr.access_mode AS accessMode,
pr.status,
pr.user_confirmed_at AS userConfirmedAt,
pr.expires_at AS expiresAt,
pr.published_at AS publishedAt,
pr.view_count AS lifetimeViewCount,
p.title AS pageTitle,
p.summary AS pageSummary,
u.username,
u.display_name AS displayName
FROM h5_publish_records pr
JOIN h5_page_records p ON p.id = pr.page_id
JOIN h5_users u ON u.id = pr.user_id
${whereSql}`,
params,
);
const publicationIds = publicationRows.map((row) => row.publicationId);
const statsByPublication = new Map();
if (publicationIds.length > 0 && hasRange) {
const placeholders = publicationIds.map(() => '?').join(', ');
const [viewRows] = await pool.query(
`SELECT publish_id AS publicationId,
device_type,
referrer_host,
COUNT(*) AS views
FROM h5_publication_views
WHERE publish_id IN (${placeholders})
AND viewed_at >= ?
AND viewed_at <= ?
GROUP BY publish_id, device_type, referrer_host`,
[...publicationIds, rangeStart, rangeEnd],
);
for (const row of viewRows) {
const key = String(row.publicationId);
const bucket = statsByPublication.get(key) ?? [];
bucket.push(row);
statsByPublication.set(key, bucket);
}
}
const rows = publicationRows.map((row) => {
const publication = normalizePublicationSnapshot({
id: row.publicationId,
pageId: row.pageId,
publicUrl: row.publicUrl,
accessMode: row.accessMode,
status: row.status,
userConfirmedAt: row.userConfirmedAt,
expiresAt: row.expiresAt,
title: row.pageTitle,
summary: row.pageSummary,
});
const stats = hasRange
? aggregateViewStats(statsByPublication.get(String(row.publicationId)) ?? [])
: {
totalViews: 0,
seoViews: 0,
geoViews: 0,
crawlerViews: 0,
};
const ownerLabel =
String(row.displayName ?? '').trim() ||
String(row.username ?? '').trim() ||
'未命名用户';
return {
publicationId: row.publicationId,
pageId: row.pageId,
userId: row.userId,
ownerLabel,
username: String(row.username ?? '').trim(),
pageTitle: String(row.pageTitle ?? '').trim() || '未命名页面',
pageSummary: String(row.pageSummary ?? '').trim(),
publicUrl: resolveAdminCatalogPublicUrl(row.publicUrl, { publicHost }),
accessMode: row.accessMode,
indexable: isPublicationIndexable(publication, { now }),
publishedAt: Number(row.publishedAt) || null,
lifetimeViewCount: Number(row.lifetimeViewCount) || 0,
...stats,
};
});
rows.sort((a, b) => {
const primary = compareRows(a, b, normalizedSortBy, normalizedSortOrder);
if (primary !== 0) return primary;
return compareRows(a, b, 'pageTitle', 'asc');
});
const offset = (safePage - 1) * safePageSize;
const pageRows = rows.slice(offset, offset + safePageSize);
const totals = rows.reduce(
(acc, row) => ({
totalViews: acc.totalViews + row.totalViews,
seoViews: acc.seoViews + row.seoViews,
geoViews: acc.geoViews + row.geoViews,
crawlerViews: acc.crawlerViews + row.crawlerViews,
}),
emptyTotals(),
);
return {
data: pageRows,
total,
page: safePage,
pageSize: safePageSize,
totals,
};
}
function emptyTotals() {
return {
totalViews: 0,
seoViews: 0,
geoViews: 0,
crawlerViews: 0,
};
}
+113
View File
@@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { classifyReferrerHost } from './mindspace-analytics-discovery.mjs';
import {
listAdminSeoGeoPublicationCatalog,
resolveAdminCatalogPublicUrl,
} from './mindspace-seo-geo-admin-catalog.mjs';
test('classifyReferrerHost maps search and ai referrers', () => {
assert.deepEqual(classifyReferrerHost('www.google.com'), {
discovery_channel: 'seo',
discovery_source: 'google',
});
assert.deepEqual(classifyReferrerHost('chatgpt.com'), {
discovery_channel: 'geo',
discovery_source: 'chatgpt',
});
});
test('resolveAdminCatalogPublicUrl normalizes relative public urls', () => {
assert.equal(
resolveAdminCatalogPublicUrl('/u/john/pages/demo'),
'https://m.tkmind.cn/u/john/pages/demo',
);
});
test('listAdminSeoGeoPublicationCatalog merges zero-traffic pages with view stats', async () => {
const calls = [];
const pool = {
async query(sql, params = []) {
calls.push({ sql, params });
const normalized = String(sql).replace(/\s+/g, ' ').trim();
if (normalized.startsWith('SELECT COUNT(*) AS total')) {
return [[{ total: 2 }]];
}
if (normalized.includes('FROM h5_publish_records pr') && normalized.includes('JOIN h5_users')) {
return [[
{
publicationId: 'pub-1',
pageId: 'page-1',
userId: 'user-1',
publicUrl: '/u/john/pages/demo',
accessMode: 'public',
status: 'online',
userConfirmedAt: 1000,
expiresAt: null,
publishedAt: 2000,
lifetimeViewCount: 12,
pageTitle: 'Demo',
pageSummary: 'Summary',
username: 'john',
displayName: 'John',
},
{
publicationId: 'pub-2',
pageId: 'page-2',
userId: 'user-2',
publicUrl: '/u/jane/pages/empty',
accessMode: 'public',
status: 'online',
userConfirmedAt: null,
expiresAt: null,
publishedAt: 1500,
lifetimeViewCount: 0,
pageTitle: 'Empty',
pageSummary: '',
username: 'jane',
displayName: 'Jane',
},
]];
}
if (normalized.includes('FROM h5_publication_views')) {
return [[
{
publicationId: 'pub-1',
device_type: 'desktop',
referrer_host: 'www.google.com',
views: 3,
},
{
publicationId: 'pub-1',
device_type: 'bot',
referrer_host: null,
views: 2,
},
]];
}
return [[]];
},
};
const result = await listAdminSeoGeoPublicationCatalog(pool, {
page: 1,
pageSize: 10,
startAt: 1,
endAt: 9_999_999_999_999,
sortBy: 'totalViews',
sortOrder: 'desc',
});
assert.equal(result.total, 2);
assert.equal(result.data.length, 2);
assert.equal(result.data[0].publicationId, 'pub-1');
assert.equal(result.data[0].seoViews, 3);
assert.equal(result.data[0].crawlerViews, 2);
assert.equal(result.data[0].totalViews, 5);
assert.equal(result.data[0].indexable, true);
assert.equal(result.data[1].publicationId, 'pub-2');
assert.equal(result.data[1].totalViews, 0);
assert.equal(result.data[1].indexable, true);
assert.match(result.data[1].publicUrl, /^https:\/\/m\.tkmind\.cn\//);
});
+2 -2
View File
@@ -24,7 +24,7 @@ test('decorateMindSpaceSeoGeoHtml noindexes private publications', () => {
assert.match(result.html, /noindex, nofollow/);
});
test('decorateMindSpaceSeoGeoHtml injects seo and geo for public pages', () => {
test('decorateMindSpaceSeoGeoHtml injects seo and geo for unconfirmed public pages', () => {
const result = decorateMindSpaceSeoGeoHtml(
'<html><head><title>Demo</title><meta name="description" content="摘要"></head><body></body></html>',
{
@@ -32,7 +32,7 @@ test('decorateMindSpaceSeoGeoHtml injects seo and geo for public pages', () => {
publication: {
status: 'online',
accessMode: 'public',
userConfirmedAt: Date.now(),
userConfirmedAt: null,
publicUrl: '/u/john/pages/demo',
},
context: {
+1
View File
@@ -81,6 +81,7 @@ export const MINDSPACE_SERVER_ADAPTER_BINDINGS = Object.freeze({
'getDeletePreview',
'getPage',
'listPages',
'listPageCreatedDateBuckets',
'listVersions',
'localizePrivateResources',
'redactPage',
@@ -47,7 +47,7 @@ if [[ -n "${EXPLICIT_H5_USERS_ROOT}" ]]; then
fi
export NODE_ENV=production
export MINDSPACE_MEMIND_ROOT="${MINDSPACE_MEMIND_ROOT:-${ROOT}/memind-source}"
export MINDSPACE_MEMIND_ROOT="${MINDSPACE_MEMIND_ROOT:-${MEMIND_ROOT}}"
export MINDSPACE_SERVICE_H5_ROOT="${MINDSPACE_SERVICE_H5_ROOT:-${MEMIND_ROOT}}"
export MINDSPACE_STORAGE_ROOT="${MINDSPACE_STORAGE_ROOT:-${ROOT}/data/mindspace}"
export H5_USERS_ROOT="${H5_USERS_ROOT:-${ROOT}/users}"
+1 -1
View File
@@ -2,7 +2,7 @@
> **重要:本目录不再接受新的管理后台功能开发。**
>
> 所有平台管理后台 UI(用户、计费、图片额度、策略、模型中心等)必须在独立仓库 **`memind_adm`** 中实现,本地开发端口 **5174**(生产 gadm),API 端口 **8085**。
> 所有平台管理后台 UI(用户、计费、图片额度、策略、模型中心等)必须在独立仓库 **`memind_adm`** 中实现,本地开发端口 **5174**(生产 [md.tkmind.cn](https://md.tkmind.cn)),API 端口 **8085**。
>
> 本目录(Memind `ops/`,本地约 **3002**)仅保留 **Plaza 运营**相关页面(审核、精选、创作者等)及历史遗留的超级管理路由;**不得**在此新增页面、导航项或 API 客户端。
+1
View File
@@ -21,6 +21,7 @@ export const REQUIRED_PORTAL_RUNTIME_PATHS = Object.freeze([
'scripts/goosed-canary.compose.yml',
'scripts/check-mindspace-public-links.mjs',
'scripts/load-env.mjs',
'scripts/memind-runtime-profile.mjs',
'scripts/wechat-mp-menu.mjs',
'scripts/memind-portal-tunnel.sh',
]);
+1 -1
View File
@@ -88,7 +88,7 @@ const IMPACT_RULES = Object.freeze([
{ groups: ['AUTH'], pattern: /(?:auth|access-policy|account|user-permission)/i },
{ groups: ['CFG'], pattern: /(?:config|provider|model-catalog|orchestrator|analytics|disclosure)/i },
{ groups: ['CFG'], pattern: /(?:^|\/)\.env(?:\.example)?$/i },
{ groups: ['UI'], pattern: /^(?:src\/|public\/)|\.(?:css|scss|tsx|vue)$/i },
{ groups: ['UI'], pattern: /^(?:src\/|public\/|index\.html$)|\.(?:css|scss|tsx|vue)$/i },
]);
const GROUP_DEPENDENCIES = Object.freeze({
+11
View File
@@ -17,6 +17,17 @@ test('docs-only changes use the compact core gate', async () => {
assert.equal(selection.unmapped_paths.length, 0);
});
test('H5 shell index.html maps to the UI domain', async () => {
const catalog = await loadScenarioCatalog();
const selection = selectImpactScenarios({
catalog,
changedPaths: ['index.html'],
});
assert.equal(selection.strategy, 'impact');
assert.deepEqual(selection.impact_groups, ['UI']);
assert.equal(selection.unmapped_paths.length, 0);
});
test('domain changes select the domain and dependency closure', async () => {
const catalog = await loadScenarioCatalog();
const selection = selectImpactScenarios({
+16 -16
View File
@@ -1,7 +1,7 @@
import crypto from 'node:crypto';
import path from 'node:path';
import { buildChatSkillPrompt, SCHEDULED_TASK_AUTOMATION_SKILL_NAME } from './chat-skills.mjs';
import { markPageDeliveryContractReady } from './mindspace-delivery-contract.mjs';
import { releaseMaterializedPageDeliveryContracts } from './mindspace-delivery-contract.mjs';
import {
collectOwnPublicHtmlRelativePaths,
materializeMissingPublicHtmlWrites,
@@ -126,29 +126,29 @@ export async function finalizeScheduledTaskPageDelivery({
const [rows] = await pool.query(
`SELECT workspace_relative_path
FROM h5_page_delivery_contracts
WHERE user_id = ? AND request_id = ? AND status = 'preparing'`,
[userId, sessionId],
WHERE user_id = ? AND status = 'preparing'`,
[userId],
);
for (const row of rows ?? []) {
if (row?.workspace_relative_path) relativePaths.add(row.workspace_relative_path);
}
}
const readyPaths = [];
for (const relativePath of relativePaths) {
const ready = await markPageDeliveryContractReady({
pool,
const readyPaths = await releaseMaterializedPageDeliveryContracts({
pool,
userId,
relativePaths: [...relativePaths],
allowPgRequired: true,
}).catch((error) => {
logger.warn?.('[ScheduledTask] release delivery contracts failed:', error);
return [];
});
for (const relativePath of readyPaths) {
logger.info?.('[ScheduledTask] delivery contract ready', {
userId,
sessionId,
relativePath,
}).catch(() => false);
if (ready) readyPaths.push(relativePath);
else {
logger.warn?.('[ScheduledTask] delivery contract not ready', {
userId,
sessionId,
relativePath,
});
}
});
}
return readyPaths;
}
+4
View File
@@ -447,6 +447,10 @@ async function writeMetadata() {
path.join(root, 'scripts', 'load-env.mjs'),
path.join(runtimeRoot, 'scripts', 'load-env.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'memind-runtime-profile.mjs'),
path.join(runtimeRoot, 'scripts', 'memind-runtime-profile.mjs'),
);
await fs.copyFile(
path.join(root, 'scripts', 'wechat-mp-menu.mjs'),
path.join(runtimeRoot, 'scripts', 'wechat-mp-menu.mjs'),
+1 -1
View File
@@ -92,7 +92,7 @@ fi
verify_runtime_artifact() {
local missing=0
for required in server.mjs mindspace-sandbox-mcp.mjs tkmind-excel-mcp.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/load-env.mjs scripts/wechat-mp-menu.mjs; do
for required in server.mjs mindspace-sandbox-mcp.mjs tkmind-excel-mcp.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/load-env.mjs scripts/memind-runtime-profile.mjs scripts/wechat-mp-menu.mjs; do
if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then
echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2
missing=1
+1 -1
View File
@@ -177,7 +177,7 @@ fi
verify_runtime_artifact() {
local missing=0
for required in server.mjs memind-canary-proxy.mjs deepseek-no-think-proxy.mjs wechat-mp.bundle.mjs mindspace-sandbox-mcp.mjs tkmind-search-mcp.mjs tkmind-excel-mcp.mjs mindspace-public-links.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/run-memind-portal-candidate.sh scripts/run-memind-canary-proxy-prod.sh scripts/run-deepseek-compat-proxy-candidate.sh scripts/goosed-canary.compose.yml scripts/check-mindspace-public-links.mjs scripts/load-env.mjs scripts/wechat-mp-menu.mjs scripts/memind-portal-tunnel.sh; do
for required in server.mjs memind-canary-proxy.mjs deepseek-no-think-proxy.mjs wechat-mp.bundle.mjs mindspace-sandbox-mcp.mjs tkmind-search-mcp.mjs tkmind-excel-mcp.mjs mindspace-public-links.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/run-memind-portal-candidate.sh scripts/run-memind-canary-proxy-prod.sh scripts/run-deepseek-compat-proxy-candidate.sh scripts/goosed-canary.compose.yml scripts/check-mindspace-public-links.mjs scripts/load-env.mjs scripts/memind-runtime-profile.mjs scripts/wechat-mp-menu.mjs scripts/memind-portal-tunnel.sh; do
if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then
echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2
missing=1
+12 -2
View File
@@ -26,9 +26,19 @@ const MENU = {
url: 'https://m.tkmind.cn',
},
{
type: 'view',
name: 'M空间',
url: 'https://m.tkmind.cn/space',
sub_button: [
{
type: 'view',
name: '空间首页',
url: 'https://m.tkmind.cn/space',
},
{
type: 'view',
name: 'M成果',
url: 'https://m.tkmind.cn/space/achievements',
},
],
},
{
type: 'view',
+1
View File
@@ -1334,6 +1334,7 @@ attachPortalMindSpacePageCoreRoutes(api, {
getMindSpacePageLiveEdit: () => mindSpacePageLiveEdit,
getMindSpacePageEditSession: () => mindSpacePageEditSession,
getUserAuth: () => userAuth,
getAuthPool: () => authPool,
syncUserGeneratedPages,
ownsAgentSession,
ensureMindSpaceEnabled,
@@ -1,3 +1,5 @@
import { listAchievementPagesForUser } from '../mindspace-page-achievement-list.mjs';
function assertRouter(api) {
if (
!api ||
@@ -72,6 +74,7 @@ export function attachPortalMindSpacePageCoreRoutes(
getMindSpacePageLiveEdit = () => null,
getMindSpacePageEditSession = () => null,
getUserAuth = () => null,
getAuthPool = () => null,
syncUserGeneratedPages,
ownsAgentSession,
ensureMindSpaceEnabled,
@@ -161,6 +164,35 @@ export function attachPortalMindSpacePageCoreRoutes(
}
});
api.get('/mindspace/v1/pages/achievements', async (req, res) => {
const pages = getMindSpacePages();
if (!pages) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
schedulePageListSync(req.currentUser.id);
const result = await listAchievementPagesForUser({
pages,
userId: req.currentUser.id,
query: req.query,
pool: getAuthPool?.() ?? null,
logger,
});
return res.json({
data: result.items,
page: {
total: result.total,
limit: result.limit,
offset: result.offset,
has_more: result.hasMore,
next_cursor: null,
},
});
} catch (error) {
return handleMindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages/:pageId', async (req, res) => {
const pages = getMindSpacePages();
if (!pages) {
@@ -102,6 +102,7 @@ test('page core module preserves route inventory and order', () => {
assert.deepEqual([...api.routes.keys()], [
'POST /mindspace/v1/pages',
'GET /mindspace/v1/pages',
'GET /mindspace/v1/pages/achievements',
'GET /mindspace/v1/pages/:pageId',
'DELETE /mindspace/v1/pages/:pageId',
'GET /mindspace/v1/pages/:pageId/delete-preview',
@@ -522,3 +523,31 @@ test('page core routes preserve unavailable and mapped error responses', async (
assert.equal(failureRes.statusCode, 418);
assert.equal(failure.calls.routeErrors[0].error.message, 'list failed');
});
test('achievements route uses listPages and enriches stats before page detail route', async () => {
const api = createRouterRecorder();
const setup = createDependencies({
getMindSpacePages: () => ({
async listPages(userId, input) {
return {
items: [{ id: 'page-1', title: 'Demo', viewCount: 3, clickCount: null, statsSource: 'publication' }],
total: 1,
limit: input.limit ?? 20,
offset: input.offset ?? 0,
hasMore: false,
};
},
}),
getAuthPool: () => null,
});
attachPortalMindSpacePageCoreRoutes(api, setup.dependencies);
const res = createResponseRecorder();
await api.routes.get('GET /mindspace/v1/pages/achievements')(
createRequest({ query: { limit: '20', offset: '0' } }),
res,
);
assert.equal(res.statusCode, 200);
assert.equal(res.body.data[0].viewCount, 3);
assert.equal(res.body.page.total, 1);
});
+21 -5
View File
@@ -13,6 +13,7 @@ import {
import {
markPageDeliveryContractReady,
preparePageDeliveryContract,
releaseMaterializedPageDeliveryContracts,
} from '../mindspace-delivery-contract.mjs';
import { maybeRepairH5HtmlAfterFinish } from '../mindspace-h5-html-finish-guard.mjs';
import { maybeRepairPageDataAfterFinish } from '../mindspace-page-data-finish-guard.mjs';
@@ -79,6 +80,8 @@ export function attachPortalSessionRoutes(
maybeRepairPageDataAfterFinish,
markPageDeliveryContractReadyFn =
markPageDeliveryContractReady,
releaseMaterializedPageDeliveryContractsFn =
releaseMaterializedPageDeliveryContracts,
finishDeliveryRetryDelaysMs = [250, 1_000],
finishDeliveryRetryWaitFn = (delayMs) =>
new Promise((resolve) =>
@@ -477,6 +480,8 @@ export function attachPortalSessionRoutes(
// workspace from DB-backed assets only.
const finalizeAfterFinishOnce = async (sid, uid) => {
beginSessionPageDelivery(sid);
let releaseCandidatePaths = [];
let allowPgRequiredRelease = false;
try {
const apiFetchFn = async (pathname, init) => {
const target = await tkmindProxy.resolveTarget(sid);
@@ -616,6 +621,8 @@ export function attachPortalSessionRoutes(
...deliveryContractWrites.keys(),
]),
].sort();
releaseCandidatePaths = publicHtmlRelativePaths;
allowPgRequiredRelease = htmlReady && pageDataReady;
const pgRequired = [
...(Array.isArray(messages) ? messages : []),
].some(
@@ -660,11 +667,6 @@ export function attachPortalSessionRoutes(
pgRequired,
});
}
await markPageDeliveryContractReadyFn({
pool: authPool,
userId: uid,
relativePath,
}).catch(() => false);
}
}
const memoryV2 = getMemoryV2();
@@ -685,6 +687,20 @@ export function attachPortalSessionRoutes(
});
}
} finally {
if (releaseCandidatePaths.length > 0) {
await releaseMaterializedPageDeliveryContractsFn({
pool: authPool,
userId: uid,
relativePaths: releaseCandidatePaths,
allowPgRequired: allowPgRequiredRelease,
}).catch((error) => {
logger.warn(
`[MindSpace] delivery contract release failed for session ${sid}: ${
error instanceof Error ? error.message : error
}`,
);
});
}
endSessionPageDelivery(sid);
}
};
+95 -3
View File
@@ -112,7 +112,7 @@ function createDependencies(overrides = {}) {
return { sessionId, hooks };
},
};
return {
const setup = {
calls,
proxy,
dependencies: {
@@ -203,6 +203,30 @@ function createDependencies(overrides = {}) {
...overrides,
},
};
if (
!Object.prototype.hasOwnProperty.call(
overrides,
'releaseMaterializedPageDeliveryContractsFn',
)
) {
setup.dependencies.releaseMaterializedPageDeliveryContractsFn =
async (input) => {
const released = [];
for (const relativePath of input.relativePaths ?? []) {
if (
await setup.dependencies.markPageDeliveryContractReadyFn({
pool: input.pool,
userId: input.userId,
relativePath,
})
) {
released.push(relativePath);
}
}
return released;
};
}
return setup;
}
test('session module preserves route inventory and order', () => {
@@ -709,8 +733,8 @@ test('Finish hook preserves refresh, sync, delivery readiness, memory, and lock
'prepare-page-data',
'repair-page-data',
'prepare-contract',
'ready',
'memory',
'ready',
],
);
assert.equal(
@@ -957,5 +981,73 @@ test('Finish retries when a delivery guard is initially not ready', async () =>
assert.equal(pageDataChecks, 2);
assert.deepEqual(setup.calls.begin, ['session-1', 'session-1']);
assert.deepEqual(setup.calls.end, ['session-1', 'session-1']);
assert.deepEqual(readyPaths, ['public/survey.html']);
assert.deepEqual(readyPaths, ['public/survey.html', 'public/survey.html']);
});
test('Finish finally releases static HTML contracts when delivery guards fail', async () => {
let hooks = null;
const releasedPaths = [];
const setup = createDependencies({
finishDeliveryRetryDelaysMs: [],
getAuthPool: () => ({ id: 'pool' }),
getTkmindProxy: () => ({
async resolveTarget(sessionId) {
return `target:${sessionId}`;
},
async apiFetchTo() {
return createUpstream({
body: {
id: 'session-1',
conversation: [
{
id: 'user-1',
role: 'user',
content: 'update page',
metadata: { userVisible: true },
},
],
},
});
},
proxySessionEvents(_req, _res, _sessionId, receivedHooks) {
hooks = receivedHooks;
},
}),
getMindSpacePublicFinish: () => ({
async syncAfterFinish() {
return {
publicHtmlRelativePaths: ['public/daily-news-0813.html'],
docxSync: { missing: [] },
};
},
async preparePageDataAfterFinish() {
return {
autoBind: { bound: [], skipped: [], errors: [] },
evaluation: { structuralPageData: false, relevantFiles: [] },
};
},
}),
async maybeRepairH5HtmlAfterFinishFn() {
return { skipped: 'limit' };
},
async releaseMaterializedPageDeliveryContractsFn(input) {
releasedPaths.push(...(input.relativePaths ?? []));
return input.relativePaths ?? [];
},
});
const api = createRouterRecorder();
attachPortalSessionRoutes(api, setup.dependencies);
await api.routes.get('GET /sessions/:sessionId/events')(
createRequest(),
createResponseRecorder(),
() => {},
);
await assert.rejects(
() => hooks.onAfterFinish('session-1', 'user-1'),
/page delivery guards are not ready/,
);
assert.deepEqual(releasedPaths, ['public/daily-news-0813.html']);
assert.deepEqual(setup.calls.end, ['session-1']);
});
+1 -1
View File
@@ -146,7 +146,7 @@ npm run check:mindspace-cover
## SEO / GEO 元数据(推荐)
平台在 memind_adm **MindSpace 配置 → SEO / GEO** 开启后,会对**已确认公开**(`access_mode=public` 且用户已确认)的发布页自动注入 canonical、结构化数据等。Agent 仍应写好基础 meta,以提升搜索与 AI 引用质量:
平台在 memind_adm **MindSpace 配置 → SEO / GEO** 开启后,会对**公开在线**(`access_mode=public` 且 `status=online`)的发布页自动注入 canonical、结构化数据等。Agent 仍应写好基础 meta,以提升搜索与 AI 引用质量:
```html
<title>页面真实主题</title>
+1
View File
@@ -54,6 +54,7 @@ export function normalizeProductRoute(pathname: string, search = '') {
export function resolveProductRouteName(pathname: string) {
if (pathname === '/') return 'chat';
if (pathname === '/space') return 'mindspace_home';
if (pathname === '/space/achievements') return 'mindspace_achievements';
if (pathname.startsWith('/space/page/')) return 'mindspace_page';
if (pathname.startsWith('/feedback/')) return 'feedback_detail';
if (pathname === '/feedback') return 'feedback';
+2
View File
@@ -88,7 +88,9 @@ export {
getMindSpacePage,
getMindSpacePageDeletePreview,
getMindSpacePageLiveRevision,
isMindSpacePageNotFoundError,
listMindSpacePages,
listMindSpaceAchievementPages,
openMindSpaceDraftPreviewWindow,
regenerateMindSpacePageThumbnail,
rewriteMindSpacePageDownloadLinks,
+28
View File
@@ -29,6 +29,14 @@ export async function getMindSpacePageDeletePreview(
return result.data;
}
export function isMindSpacePageNotFoundError(err: unknown): boolean {
return (
err instanceof ApiError &&
err.status === 404 &&
(err.code === 'page_not_found' || err.message === '页面不存在')
);
}
export async function deleteMindSpacePage(
pageId: string,
options?: { removeFromPlaza?: boolean },
@@ -61,6 +69,26 @@ export async function listMindSpacePages(options?: {
return { items: result.data, page: result.page ?? {} };
}
export async function listMindSpaceAchievementPages(options?: {
status?: string;
limit?: number;
offset?: number;
categoryCode?: string;
includeStats?: boolean;
}): Promise<{ items: MindSpacePage[]; page: MindSpaceListPage }> {
const params = new URLSearchParams();
if (options?.status) params.set('status', options.status);
if (options?.limit != null) params.set('limit', String(options.limit));
if (options?.offset != null) params.set('offset', String(options.offset));
if (options?.categoryCode) params.set('category_code', options.categoryCode);
if (options?.includeStats) params.set('include_stats', '1');
const query = params.toString() ? `?${params.toString()}` : '';
const result = await apiFetch<{ data: MindSpacePage[]; page?: MindSpaceListPage }>(
`/mindspace/v1/pages/achievements${query}`,
);
return { items: result.data, page: result.page ?? {} };
}
export async function getMindSpacePage(pageId: string): Promise<MindSpacePage> {
const result = await apiFetch<{ data: MindSpacePage }>(
`/mindspace/v1/pages/${encodeURIComponent(pageId)}`,
+16 -16
View File
@@ -492,6 +492,7 @@ export function ChatPanel({
chatState === 'connecting' ||
chatState === 'waiting';
const offlineBlocked = !online;
const inputBlocked = !!pendingTool || chatState === 'error' || offlineBlocked;
const publishSkillName = user?.publishSkillName ?? 'static-page-publish';
const hasPublishSkill = grantedSkills?.includes(publishSkillName) ?? false;
const hasPageDataCollectSkill = grantedSkills?.includes('page-data-collect') ?? false;
@@ -520,14 +521,15 @@ export function ChatPanel({
: chatState === 'waiting'
? '请求已提交…'
: null;
const canSubmit = input.trim() || pendingImages.length > 0 || pendingFiles.length > 0;
const sendButtonLabel =
uploadingImage || uploadingFile
? '上传中…'
: chatState === 'connecting'
? '连接中…'
: chatState === 'waiting'
? '提交中…'
: null;
: busy && canSubmit
? '排队发送'
: null;
const applyTemplatePrefill = useCallback((prompt: string, skillId: string, label: string) => {
pendingSkillRef.current = skillId;
@@ -577,8 +579,7 @@ export function ChatPanel({
setVoiceNotice('已识别,可编辑后发送');
};
const voiceDisabled = busy || !!pendingTool || chatState === 'error' || offlineBlocked;
const canSubmit = input.trim() || pendingImages.length > 0 || pendingFiles.length > 0;
const voiceDisabled = inputBlocked || uploadingImage || uploadingFile;
const uploadDisabled = !canUpload || busy || uploadingImage || uploadingFile || offlineBlocked || !!pendingTool;
const revokePendingImage = (item: PendingChatImage) => {
@@ -719,7 +720,7 @@ export function ChatPanel({
skillIdOverride ?? pendingSkillRef.current ?? templateSelection?.skillId ?? undefined;
pendingSkillRef.current = null;
setActiveTemplatePrefill(null);
if ((!trimmed && pendingImages.length === 0 && pendingFiles.length === 0) || voiceDisabled) return;
if ((!trimmed && pendingImages.length === 0 && pendingFiles.length === 0) || inputBlocked) return;
if (pendingImages.length > 0 && !onUploadImage) {
setImageError('当前会话暂不支持图片发送');
return;
@@ -1462,16 +1463,15 @@ export function ChatPanel({
<button type="button" className="danger-btn chat-input-action" onClick={() => void onStop()}>
停止
</button>
) : (
<button
type="button"
className={`send-btn chat-input-action${showHomeWelcome ? ' chat-input-action-home' : ''}`}
disabled={!canSubmit || voiceDisabled || uploadingImage || uploadingFile}
onClick={() => void handleSubmit()}
>
{sendButtonLabel ?? '发送'}
</button>
)}
) : null}
<button
type="button"
className={`send-btn chat-input-action${showHomeWelcome ? ' chat-input-action-home' : ''}`}
disabled={!canSubmit || inputBlocked || uploadingImage || uploadingFile}
onClick={() => void handleSubmit()}
>
{sendButtonLabel ?? '发送'}
</button>
</div>
{compact && onClose && (
<button type="button" className="space-chat-panel-dismiss" onClick={onClose}>
+2 -1
View File
@@ -8,6 +8,7 @@ import {
CHAT_IMAGE_MAX_SIDE,
compressImageForUpload,
} from '../utils/imageUpload';
import { APP_DISPLAY_NAME } from '../utils/appBrand';
import { FeedbackPageHeader } from './FeedbackPageHeader';
import { VoiceInputButton } from './VoiceInputButton';
@@ -277,7 +278,7 @@ export function FeedbackSubmitView({
>
<div className="feedback-board-toolbar">
<div className="feedback-board-toolbar-copy">
<p className="feedback-submit-eyebrow">帮助我们改进 Memind</p>
<p className="feedback-submit-eyebrow">帮助我们改进 {APP_DISPLAY_NAME}</p>
<h1>提交 Bug 或需求</h1>
<p className="feedback-submit-desc feedback-board-desc">
你可以用文字描述、上传截图,或点击麦克风口述问题。我们会自动附带当前页面与设备信息,便于定位问题。
@@ -0,0 +1,299 @@
import { useMemo } from 'react';
import type { MindSpacePage } from '../types';
const PAGE_STATUS_LABELS: Record<string, string> = {
draft: '草稿',
reviewing: '待检查',
risk_found: '发现风险',
ready: '可发布',
published: '已公开',
protected: '受保护',
expired: '已过期',
offline: '已下线',
};
function formatDateTime(timestamp: number) {
return new Date(timestamp).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
const ACHIEVEMENTS_DATE_TIMEZONE = 'Asia/Shanghai';
function localCreatedDateKey(timestamp: number) {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: ACHIEVEMENTS_DATE_TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(new Date(timestamp));
const year = parts.find((part) => part.type === 'year')?.value ?? '0000';
const month = parts.find((part) => part.type === 'month')?.value ?? '01';
const day = parts.find((part) => part.type === 'day')?.value ?? '01';
return `${year}-${month}-${day}`;
}
function formatDateHeading(timestamp: number) {
return new Date(timestamp).toLocaleDateString('zh-CN', {
timeZone: ACHIEVEMENTS_DATE_TIMEZONE,
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'short',
});
}
function formatMetric(value: number | null | undefined) {
if (value == null) return '—';
return value.toLocaleString('zh-CN');
}
function formatClickMetric(page: MindSpacePage) {
if (page.clickCount == null) return '—';
return formatMetric(page.clickCount);
}
function groupPagesByCreatedDate(pages: MindSpacePage[]) {
const groups = new Map<string, MindSpacePage[]>();
for (const page of pages) {
const key = localCreatedDateKey(page.createdAt);
const bucket = groups.get(key);
if (bucket) bucket.push(page);
else groups.set(key, [page]);
}
return [...groups.entries()]
.sort(([left], [right]) => right.localeCompare(left))
.map(([dateKey, items]) => ({
dateKey,
heading: formatDateHeading(items[0]?.createdAt ?? Date.parse(`${dateKey}T12:00:00+08:00`)),
items,
}));
}
type MindSpaceAchievementsPanelProps = {
items: MindSpacePage[];
total: number;
loading: boolean;
pageIndex: number;
pageSize: number;
selectedIds: string[];
deleting: boolean;
deletingPageId?: string | null;
onBack: () => void;
onOpenPage: (page: MindSpacePage) => void;
onShowPage: (pageId: string) => void;
onTogglePage: (pageId: string, checked: boolean) => void;
onToggleDateGroup: (pageIds: string[], checked: boolean) => void;
onToggleAll: () => void;
onClearSelection: () => void;
onDeleteSelected: () => void;
onDeletePage: (page: MindSpacePage) => void;
onPrevPage: () => void;
onNextPage: () => void;
onFirstPage: () => void;
};
export function MindSpaceAchievementsPanel({
items,
total,
loading,
pageIndex,
pageSize,
selectedIds,
deleting,
deletingPageId,
onBack,
onOpenPage,
onShowPage,
onTogglePage,
onToggleDateGroup,
onToggleAll,
onClearSelection,
onDeleteSelected,
onDeletePage,
onPrevPage,
onNextPage,
onFirstPage,
}: MindSpaceAchievementsPanelProps) {
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const grouped = useMemo(() => groupPagesByCreatedDate(items), [items]);
const allVisibleSelected =
items.length > 0 && items.every((page) => selectedSet.has(page.id));
const pageCount = Math.max(1, Math.ceil(total / pageSize));
return (
<section className="mindspace-independent-page mindspace-achievements">
<div className="mindspace-section-heading">
<div>
<button type="button" className="mindspace-inline-back" onClick={onBack}>
返回空间首页
</button>
<p className="mindspace-eyebrow">MY ACHIEVEMENTS</p>
<h2>我的成果展示</h2>
</div>
<span>{total} 个页面</span>
</div>
<p className="mindspace-achievements-intro">
按创建日期汇总你制作过的页面,每页 {pageSize} 条。浏览量来自公开页访问统计;点击量需开启 Umami 后才会显示。
</p>
{items.length > 0 && (
<div className="mindspace-asset-bulkbar mindspace-achievements-bulkbar">
<button type="button" onClick={onToggleAll} disabled={deleting || items.length === 0}>
{allVisibleSelected ? '取消全选' : '全选本页'}
</button>
<span>已选 {selectedIds.length} 项</span>
{selectedIds.length > 0 && (
<button type="button" onClick={onClearSelection} disabled={deleting}>
清空
</button>
)}
<button
type="button"
className="mindspace-asset-bulk-delete"
onClick={onDeleteSelected}
disabled={selectedIds.length === 0 || deleting}
>
{deleting ? '删除中…' : '删除选中'}
</button>
{total > pageSize && pageIndex > 0 && (
<button
type="button"
className="mindspace-achievements-back-page"
disabled={loading}
onClick={onFirstPage}
>
返回
</button>
)}
</div>
)}
{loading && items.length === 0 ? (
<div className="mindspace-state">正在加载成果列表…</div>
) : total > 0 ? (
<>
<div className="mindspace-achievements-groups">
{grouped.map((group) => {
const groupIds = group.items.map((page) => page.id);
const groupSelected =
groupIds.length > 0 && groupIds.every((id) => selectedSet.has(id));
return (
<section className="mindspace-achievements-group" key={group.dateKey}>
<div className="mindspace-achievements-group-heading">
<h3>{group.heading}</h3>
<label className="mindspace-achievements-group-select">
<input
type="checkbox"
checked={groupSelected}
disabled={deleting}
onChange={(event) =>
onToggleDateGroup(groupIds, event.currentTarget.checked)
}
/>
<span>{groupSelected ? '取消全选本日' : '全选本日'}</span>
</label>
</div>
<div className="mindspace-achievements-table-wrap">
<table className="mindspace-achievements-table">
<thead>
<tr>
<th scope="col" className="mindspace-achievements-col-select">
选择
</th>
<th scope="col">标题</th>
<th scope="col">状态</th>
<th scope="col">创建时间</th>
<th scope="col">最后修改</th>
<th scope="col">浏览量</th>
<th scope="col">点击量</th>
<th scope="col">操作</th>
</tr>
</thead>
<tbody>
{group.items.map((page) => (
<tr key={page.id}>
<td className="mindspace-achievements-col-select">
<input
type="checkbox"
checked={selectedSet.has(page.id)}
disabled={deleting}
aria-label={`选择 ${page.title}`}
onChange={(event) =>
onTogglePage(page.id, event.currentTarget.checked)
}
/>
</td>
<td className="mindspace-achievements-title">
<button
type="button"
className="mindspace-achievements-title-link"
onClick={() => void onOpenPage(page)}
>
{page.title}
</button>
<button
type="button"
className="mindspace-achievements-detail-link"
onClick={() => onShowPage(page.id)}
>
详情
</button>
</td>
<td>{PAGE_STATUS_LABELS[page.status] ?? page.status}</td>
<td>{formatDateTime(page.createdAt)}</td>
<td>{formatDateTime(page.updatedAt)}</td>
<td>{formatMetric(page.viewCount ?? 0)}</td>
<td>{formatClickMetric(page)}</td>
<td>
<button
type="button"
className="mindspace-achievements-delete-link"
disabled={deleting || deletingPageId === page.id}
onClick={() => onDeletePage(page)}
>
{deletingPageId === page.id ? '删除中…' : '删除'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
})}
</div>
{total > pageSize && (
<div className="mindspace-pagination mindspace-achievements-pagination">
<button type="button" disabled={pageIndex <= 0 || loading} onClick={onPrevPage}>
上一页
</button>
<span>
第 {pageIndex + 1} / {pageCount} 页
</span>
<button
type="button"
disabled={loading || (pageIndex + 1) * pageSize >= total}
onClick={onNextPage}
>
下一页
</button>
</div>
)}
</>
) : (
<div className="mindspace-recent-work-empty">
<p>上传资料或从聊天保存页面后,你的成果会按日期集中展示在这里。</p>
</div>
)}
</section>
);
}
+215 -18
View File
@@ -15,10 +15,12 @@ import {
getMindSpacePage,
getMindSpacePageDeletePreview,
ignoreMindSpaceScheduleReminder,
isMindSpacePageNotFoundError,
listMindSpaceAgentJobs,
listMindSpaceAssets,
listMindSpaceCleanupItems,
listMindSpacePages,
listMindSpaceAchievementPages,
retryMindSpaceAgentJob,
runMindSpaceAgentJob,
runMindSpaceCleanup,
@@ -55,6 +57,7 @@ import {
} from '../utils/mindspaceCards';
import { PREVIEW_ASSETS, PREVIEW_PAGES, PREVIEW_SPACE } from '../dev/mindspacePreviewData';
import { MindSpaceFeedCard } from './MindSpaceFeedCard';
import { MindSpaceAchievementsPanel } from './MindSpaceAchievementsPanel';
import { MindSpacePageDetail } from './MindSpacePageDetail';
import { MindSpaceModal } from './MindSpaceModal';
import { MindSpaceDeletePlazaOption } from './MindSpaceDeletePlazaOption';
@@ -83,6 +86,7 @@ const CATEGORY_ACTIONS: Partial<Record<MindSpaceCategory['code'], string>> = {
const AGENT_JOBS_PAGE_SIZE = 10;
const RECENT_PAGES_PAGE_SIZE = 6;
const ALL_PAGES_PAGE_SIZE = 15;
const ACHIEVEMENTS_PAGE_SIZE = 20;
function sortPagesByCreatedAt(pages: MindSpacePage[]) {
return [...pages].sort((left, right) => {
@@ -499,6 +503,7 @@ function canGenerateWithAgent(asset: MindSpaceAsset) {
type MindSpaceRouteSync = {
pushHome: () => void;
pushAchievements: () => void;
pushCategory: (code: MindSpaceSaveCategory | MindSpaceCategory['code']) => void;
pushPage: (pageId: string) => void;
};
@@ -528,6 +533,7 @@ export function MindSpaceView({
previewMode = false,
initialPageId,
initialCategoryCode,
initialAchievementsOpen = false,
onBack,
onLogout,
onOpenFeedback,
@@ -537,6 +543,7 @@ export function MindSpaceView({
previewMode?: boolean;
initialPageId?: string | null;
initialCategoryCode?: MindSpaceSaveCategory | null;
initialAchievementsOpen?: boolean;
onBack: () => void;
onLogout: () => void;
onOpenFeedback?: () => void;
@@ -589,6 +596,13 @@ export function MindSpaceView({
const [allPagesTotal, setAllPagesTotal] = useState(0);
const [allPagesPageIndex, setAllPagesPageIndex] = useState(0);
const [allPagesLoading, setAllPagesLoading] = useState(false);
const [achievementsOpen, setAchievementsOpen] = useState(initialAchievementsOpen);
const [achievementsItems, setAchievementsItems] = useState<MindSpacePage[]>([]);
const [achievementsTotal, setAchievementsTotal] = useState(0);
const [achievementsPageIndex, setAchievementsPageIndex] = useState(0);
const [achievementsLoading, setAchievementsLoading] = useState(false);
const [selectedAchievementPageIds, setSelectedAchievementPageIds] = useState<string[]>([]);
const [bulkDeletingAchievements, setBulkDeletingAchievements] = useState(false);
const [selectedAllPageIds, setSelectedAllPageIds] = useState<string[]>([]);
const [bulkDeletingAllPages, setBulkDeletingAllPages] = useState(false);
const [selectedScheduleReminderIds, setSelectedScheduleReminderIds] = useState<string[]>([]);
@@ -605,7 +619,7 @@ export function MindSpaceView({
const [pageDeleteRemoveFromPlaza, setPageDeleteRemoveFromPlaza] = useState(false);
const [deletingPageId, setDeletingPageId] = useState<string | null>(null);
const [bulkPageDeleteOpen, setBulkPageDeleteOpen] = useState(false);
const [bulkPageDeleteSource, setBulkPageDeleteSource] = useState<'all' | 'draft'>('all');
const [bulkPageDeleteSource, setBulkPageDeleteSource] = useState<'all' | 'draft' | 'achievements'>('all');
const [bulkPageDeletePreviews, setBulkPageDeletePreviews] = useState<
MindSpacePageDeletePreview[] | null
>(null);
@@ -649,11 +663,11 @@ export function MindSpaceView({
}, [selectedPageId]);
useEffect(() => {
if (!allPagesOpen && !selectedPageId) return;
if (!allPagesOpen && !achievementsOpen && !selectedPageId) return;
window.requestAnimationFrame(() => {
window.scrollTo({ top: 0, left: 0 });
});
}, [allPagesOpen, selectedPageId]);
}, [allPagesOpen, achievementsOpen, selectedPageId]);
useEffect(() => {
if (!selectedPageId || !session?.id || previewMode || pageFullscreenPreviewOpen) return;
@@ -802,8 +816,30 @@ export function MindSpaceView({
setPageDeleteTarget(null);
setPageDeletePreview(null);
setPageDeleteRemoveFromPlaza(false);
setSelectedAchievementPageIds((ids) => ids.filter((id) => id !== pageDeleteTarget.id));
setAchievementsItems((items) => items.filter((page) => page.id !== pageDeleteTarget.id));
setAchievementsTotal((total) => Math.max(0, total - 1));
await refreshMindSpaceSnapshot({ quota: result.quota, freedBytes: result.freedBytes });
if (achievementsOpen) {
await loadAchievementsPage(achievementsPageIndex);
}
} catch (err) {
if (isMindSpacePageNotFoundError(err)) {
if (selectedPageId === pageDeleteTarget.id) {
closePage();
}
setPageDeleteTarget(null);
setPageDeletePreview(null);
setPageDeleteRemoveFromPlaza(false);
setSelectedAchievementPageIds((ids) => ids.filter((id) => id !== pageDeleteTarget.id));
setAchievementsItems((items) => items.filter((page) => page.id !== pageDeleteTarget.id));
setAchievementsTotal((total) => Math.max(0, total - 1));
await refreshMindSpaceSnapshot();
if (achievementsOpen) {
await loadAchievementsPage(achievementsPageIndex);
}
return;
}
setError(err instanceof Error ? err.message : '删除页面失败');
} finally {
setDeletingPageId(null);
@@ -959,12 +995,58 @@ export function MindSpaceView({
setSelectedPageId(null);
setNewPageOpen(false);
setAgentJobsPanelOpen(false);
setAchievementsOpen(false);
setAllPagesOpen(true);
setSelectedAllPageIds([]);
routeSync?.pushHome();
void loadAllPagesPage(0);
};
const loadAchievementsPage = async (pageIndex: number) => {
if (previewMode) {
setAchievementsItems(PREVIEW_PAGES);
setAchievementsTotal(PREVIEW_PAGES.length);
setAchievementsPageIndex(0);
setSelectedAchievementPageIds([]);
return;
}
setAchievementsLoading(true);
setError(null);
try {
const { items, page } = await listMindSpaceAchievementPages({
limit: ACHIEVEMENTS_PAGE_SIZE,
offset: pageIndex * ACHIEVEMENTS_PAGE_SIZE,
includeStats: false,
});
setAchievementsItems(items);
setAchievementsPageIndex(pageIndex);
setAchievementsTotal(page.total ?? items.length);
setSelectedAchievementPageIds([]);
} catch (err) {
setError(err instanceof Error ? err.message : '成果列表加载失败');
} finally {
setAchievementsLoading(false);
}
};
const openAchievementsPanel = () => {
setSelectedCategory(null);
setSelectedPageId(null);
setNewPageOpen(false);
setAgentJobsPanelOpen(false);
setAllPagesOpen(false);
setAchievementsOpen(true);
setSelectedAchievementPageIds([]);
routeSync?.pushAchievements();
void loadAchievementsPage(0);
};
const closeAchievementsPanel = () => {
setAchievementsOpen(false);
setSelectedAchievementPageIds([]);
routeSync?.pushHome();
};
const refreshAgentJobsSummary = async () => {
if (previewMode) return;
const { page } = await listMindSpaceAgentJobs({ limit: 1, offset: 0 });
@@ -999,6 +1081,7 @@ export function MindSpaceView({
setSelectedPageId(null);
setNewPageOpen(false);
setAllPagesOpen(false);
setAchievementsOpen(false);
setAgentJobsPanelOpen(true);
routeSync?.pushHome();
void loadAgentJobsPage(0);
@@ -1066,6 +1149,9 @@ export function MindSpaceView({
if (allPagesOpen) {
await loadAllPagesPage(allPagesPageIndex);
}
if (achievementsOpen) {
await loadAchievementsPage(achievementsPageIndex);
}
} catch {
// ignore background list refresh errors
}
@@ -1114,7 +1200,7 @@ export function MindSpaceView({
useEffect(() => {
if (previewMode) return;
const refreshSchedule = () => {
if (!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen) {
if (!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen && !achievementsOpen) {
void refreshSpaceQuietly();
}
};
@@ -1130,6 +1216,7 @@ export function MindSpaceView({
newPageOpen,
agentJobsPanelOpen,
allPagesOpen,
achievementsOpen,
]);
useEffect(() => {
@@ -1137,10 +1224,21 @@ export function MindSpaceView({
}, [initialPageId]);
useEffect(() => {
if (!space || !initialCategoryCode || initialCategoryCode === 'draft') return;
if (!space || !initialCategoryCode || initialCategoryCode === 'draft' || initialAchievementsOpen) return;
const category = space.categories.find((item) => item.code === initialCategoryCode);
if (category) void openCategory(category);
}, [space, initialCategoryCode]);
}, [space, initialCategoryCode, initialAchievementsOpen]);
useEffect(() => {
if (!initialAchievementsOpen || previewMode) return;
setSelectedCategory(null);
setSelectedPageId(null);
setNewPageOpen(false);
setAgentJobsPanelOpen(false);
setAllPagesOpen(false);
setAchievementsOpen(true);
void loadAchievementsPage(0);
}, [initialAchievementsOpen, previewMode]);
useEffect(() => {
if (previewMode || !agentJob || !['queued', 'running'].includes(agentJob.status)) return;
@@ -1167,6 +1265,7 @@ export function MindSpaceView({
const openCategory = async (category: MindSpaceCategory) => {
setAgentJobsPanelOpen(false);
setAllPagesOpen(false);
setAchievementsOpen(false);
setSelectedCategory(category);
setSelectedPageId(null);
setNewPageOpen(false);
@@ -1586,13 +1685,13 @@ export function MindSpaceView({
}, [bulkPageDeletePreviews]);
const closeBulkPageDeleteDialog = () => {
if (bulkDeletingAllPages || bulkDeletingDraftPages) return;
if (bulkDeletingAllPages || bulkDeletingDraftPages || bulkDeletingAchievements) return;
setBulkPageDeleteOpen(false);
setBulkPageDeletePreviews(null);
setBulkPageDeleteRemoveFromPlaza(false);
};
const startBulkPageDelete = async (pageIds: string[], source: 'all' | 'draft') => {
const startBulkPageDelete = async (pageIds: string[], source: 'all' | 'draft' | 'achievements') => {
if (pageIds.length === 0) return;
if (previewMode) {
setError('预览模式下无法删除页面');
@@ -1632,7 +1731,11 @@ export function MindSpaceView({
if (!bulkPageDeletePreviews || bulkPageDeletePreviews.length === 0) return;
const deletingIds = bulkPageDeletePreviews.map((preview) => preview.page.id);
const setBulkDeleting =
bulkPageDeleteSource === 'all' ? setBulkDeletingAllPages : setBulkDeletingDraftPages;
bulkPageDeleteSource === 'all'
? setBulkDeletingAllPages
: bulkPageDeleteSource === 'draft'
? setBulkDeletingDraftPages
: setBulkDeletingAchievements;
setBulkDeleting(true);
setError(null);
const failedIds: string[] = [];
@@ -1645,6 +1748,10 @@ export function MindSpaceView({
});
if (selectedPageId === preview.page.id) closePage();
} catch (err) {
if (isMindSpacePageNotFoundError(err)) {
if (selectedPageId === preview.page.id) closePage();
continue;
}
failedIds.push(preview.page.id);
failedNames.push(preview.page.title);
console.error('[MindSpace] delete page failed:', preview.page.id, err);
@@ -1653,8 +1760,10 @@ export function MindSpaceView({
const deletedIds = deletingIds.filter((id) => !failedIds.includes(id));
if (bulkPageDeleteSource === 'all') {
setSelectedAllPageIds((ids) => ids.filter((id) => failedIds.includes(id)));
} else {
} else if (bulkPageDeleteSource === 'draft') {
setSelectedDraftPageIds((ids) => ids.filter((id) => failedIds.includes(id)));
} else {
setSelectedAchievementPageIds((ids) => ids.filter((id) => failedIds.includes(id)));
}
setBulkPageDeleteOpen(false);
setBulkPageDeletePreviews(null);
@@ -1668,6 +1777,12 @@ export function MindSpaceView({
: allPagesPageIndex;
await loadAllPagesPage(nextPageIndex);
}
if (bulkPageDeleteSource === 'achievements' && deletedIds.length > 0) {
setAchievementsItems((items) => items.filter((page) => !deletedIds.includes(page.id)));
setAchievementsTotal((total) => Math.max(0, total - deletedIds.length));
setSelectedAchievementPageIds((ids) => ids.filter((id) => !deletedIds.includes(id)));
await loadAchievementsPage(achievementsPageIndex);
}
if (failedNames.length > 0) {
setError(`有 ${failedNames.length} 个页面删除失败:${failedNames.slice(0, 3).join('、')}`);
}
@@ -1698,6 +1813,45 @@ export function MindSpaceView({
void startBulkPageDelete([...selectedAllPageIds], 'all');
};
const selectedAchievementPageSet = useMemo(
() => new Set(selectedAchievementPageIds),
[selectedAchievementPageIds],
);
const toggleAchievementPageSelected = (pageId: string, checked: boolean) => {
setSelectedAchievementPageIds((ids) =>
checked ? (ids.includes(pageId) ? ids : [...ids, pageId]) : ids.filter((id) => id !== pageId),
);
};
const toggleAchievementDateGroupSelected = (pageIds: string[], checked: boolean) => {
setSelectedAchievementPageIds((ids) => {
if (!checked) {
const remove = new Set(pageIds);
return ids.filter((id) => !remove.has(id));
}
return [...new Set([...ids, ...pageIds])];
});
};
const toggleAllAchievementPagesSelected = () => {
const allSelected =
achievementsItems.length > 0 &&
achievementsItems.every((page) => selectedAchievementPageSet.has(page.id));
if (allSelected) {
const visibleIds = new Set(achievementsItems.map((page) => page.id));
setSelectedAchievementPageIds((ids) => ids.filter((id) => !visibleIds.has(id)));
return;
}
setSelectedAchievementPageIds((ids) => [
...new Set([...ids, ...achievementsItems.map((page) => page.id)]),
]);
};
const removeSelectedAchievementPages = () => {
void startBulkPageDelete([...selectedAchievementPageIds], 'achievements');
};
const allPagesPageCount = Math.max(1, Math.ceil(allPagesTotal / ALL_PAGES_PAGE_SIZE));
const previewAsset = useMemo(
@@ -1926,7 +2080,13 @@ export function MindSpaceView({
);
const showHomeModules =
space && !selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen;
space &&
!selectedCategory &&
!selectedPageId &&
!newPageOpen &&
!agentJobsPanelOpen &&
!allPagesOpen &&
!achievementsOpen;
const rawUpcomingReminders = previewMode ? [] : space?.schedule?.upcomingReminders ?? [];
const scheduleReminders = previewMode
? PREVIEW_SCHEDULE_REMINDERS.map((item) => ({ ...item }))
@@ -2066,7 +2226,7 @@ export function MindSpaceView({
</header>
<main className="mindspace-content">
{!allPagesOpen && (
{!allPagesOpen && !achievementsOpen && (
<section className="mindspace-hero">
<div className="mindspace-hero-left">
<p className="mindspace-eyebrow">MINDSPACE</p>
@@ -2088,6 +2248,7 @@ export function MindSpaceView({
setSelectedCategory(null);
setSelectedPageId(null);
setAllPagesOpen(false);
setAchievementsOpen(false);
setNewPageOpen(true);
}}
>
@@ -2362,7 +2523,7 @@ export function MindSpaceView({
<section className="mindspace-page-reader">
<header className="mindspace-page-reader-bar">
<button type="button" className="mindspace-inline-back" onClick={closePage}>
{allPagesOpen ? '返回全部页面' : '返回空间首页'}
{allPagesOpen ? '返回全部页面' : achievementsOpen ? '返回成果展示' : '返回空间首页'}
</button>
<span>站内浏览</span>
</header>
@@ -2444,6 +2605,29 @@ export function MindSpaceView({
</button>
</div>
</section>
) : achievementsOpen ? (
<MindSpaceAchievementsPanel
items={achievementsItems}
total={achievementsTotal}
loading={achievementsLoading}
pageIndex={achievementsPageIndex}
pageSize={ACHIEVEMENTS_PAGE_SIZE}
selectedIds={selectedAchievementPageIds}
deleting={bulkDeletingAchievements}
deletingPageId={deletingPageId}
onBack={closeAchievementsPanel}
onOpenPage={(page) => void openPageViewInNewTab(page)}
onShowPage={(pageId) => showPage(pageId)}
onTogglePage={toggleAchievementPageSelected}
onToggleDateGroup={toggleAchievementDateGroupSelected}
onToggleAll={toggleAllAchievementPagesSelected}
onClearSelection={() => setSelectedAchievementPageIds([])}
onDeleteSelected={() => void removeSelectedAchievementPages()}
onDeletePage={(page) => void openPageDeleteDialog(page)}
onPrevPage={() => void loadAchievementsPage(achievementsPageIndex - 1)}
onNextPage={() => void loadAchievementsPage(achievementsPageIndex + 1)}
onFirstPage={() => void loadAchievementsPage(0)}
/>
) : allPagesOpen ? (
<section className="mindspace-independent-page mindspace-all-pages">
<div className="mindspace-section-heading">
@@ -3023,6 +3207,17 @@ export function MindSpaceView({
))}
</div>
<div className="mindspace-grid-secondary">
<article className="mindspace-card mindspace-card-compact mindspace-card-achievements">
<div className="mindspace-card-top">
<span className="mindspace-card-code">SHOWCASE</span>
<span>{recentPagesTotal} 项</span>
</div>
<h3>我的成果展示</h3>
<p>按日期查看页面标题、创建/修改时间与访问数据。</p>
<button type="button" onClick={openAchievementsPanel}>
查看成果
</button>
</article>
{space.categories
.filter((category) => category.code === 'draft')
.map((category) => (
@@ -3048,7 +3243,7 @@ export function MindSpaceView({
</section>
)}
{!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen && (
{!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen && !achievementsOpen && (
<section className="mindspace-section mindspace-recent-work">
<div className="mindspace-section-heading">
<div>
@@ -3433,7 +3628,7 @@ export function MindSpaceView({
eyebrow="DELETE PAGES"
className="mindspace-delete-dialog"
disableClose={
bulkPageDeleteLoading || bulkDeletingAllPages || bulkDeletingDraftPages
bulkPageDeleteLoading || bulkDeletingAllPages || bulkDeletingDraftPages || bulkDeletingAchievements
}
>
{bulkPageDeleteLoading && <div className="mindspace-state">正在分析连带内容…</div>}
@@ -3460,7 +3655,7 @@ export function MindSpaceView({
<button
type="button"
onClick={closeBulkPageDeleteDialog}
disabled={bulkDeletingAllPages || bulkDeletingDraftPages}
disabled={bulkDeletingAllPages || bulkDeletingDraftPages || bulkDeletingAchievements}
>
取消
</button>
@@ -3468,9 +3663,11 @@ export function MindSpaceView({
type="button"
className="mindspace-primary"
onClick={() => void confirmBulkPageDelete()}
disabled={bulkDeletingAllPages || bulkDeletingDraftPages}
disabled={bulkDeletingAllPages || bulkDeletingDraftPages || bulkDeletingAchievements}
>
{bulkDeletingAllPages || bulkDeletingDraftPages ? '删除中…' : '确认删除'}
{bulkDeletingAllPages || bulkDeletingDraftPages || bulkDeletingAchievements
? '删除中…'
: '确认删除'}
</button>
</div>
</>
+4
View File
@@ -111,6 +111,8 @@ export const PREVIEW_PAGES: MindSpacePage[] = [
currentVersionId: 'page-1-v2',
versionNo: 2,
contentFormat: 'markdown',
viewCount: 86,
clickCount: 5,
createdAt: Date.now() - 172_800_000,
updatedAt: Date.now() - 43_200_000,
},
@@ -130,6 +132,8 @@ export const PREVIEW_PAGES: MindSpacePage[] = [
currentVersionId: 'page-2-v1',
versionNo: 1,
contentFormat: 'html',
viewCount: 12,
clickCount: 2,
createdAt: Date.now() - 259_200_000,
updatedAt: Date.now() - 86_400_000,
},
+173 -81
View File
@@ -58,6 +58,9 @@ import {
buildAutoChatSkillPrefix,
} from '../../chat-skills.mjs';
import {
buildQueuedChatSubmitNotice,
canFlushQueuedChatSubmit,
isChatSubmitBusy,
reconcileSessionEventRequestContext,
resolvePostAgentRunChatState,
shouldIgnoreZeroActivityFinish,
@@ -89,6 +92,24 @@ import {
touchSession,
} from '../utils/sessions';
type ChatSubmitOptions = {
mindspaceContext?: MindSpaceChatContext;
messageId?: string;
forceDeepReasoning?: boolean;
pgRequired?: boolean;
imageGenerationMode?: ImageGenerationMode;
selectedChatSkill?: string;
fileAttachments?: ChatFileAttachment[];
goalRunId?: string;
};
type PendingChatSubmitEntry = {
userMessage: Message;
options?: ChatSubmitOptions;
normalizedImageUrls: string[];
normalizedFileAttachments: ChatFileAttachment[];
};
const INSUFFICIENT_BALANCE_NOTICE = '余额不足,请充值后继续使用';
const REPLY_RECOVERY_SYNC_DELAYS_MS = [1500, 5000, 12000];
const FINISH_SYNC_RETRY_DELAYS_MS = [500, 1500, 3000];
@@ -400,11 +421,18 @@ export function useTKMindChat(
const onUserUpdateRef = useRef(onUserUpdate);
const chatImageCategoryIdRef = useRef<string | null>(null);
const chatFileCategoryIdRef = useRef<string | null>(null);
const pendingSubmitQueueRef = useRef<PendingChatSubmitEntry[]>([]);
const flushingPendingSubmitRef = useRef(false);
const pendingToolRef = useRef<ToolConfirmation | null>(null);
useEffect(() => {
chatStateRef.current = chatState;
}, [chatState]);
useEffect(() => {
pendingToolRef.current = pendingTool;
}, [pendingTool]);
const clearActiveRequestMissingTimer = useCallback(() => {
if (!activeRequestMissingTimerRef.current) return;
window.clearTimeout(activeRequestMissingTimerRef.current);
@@ -1175,6 +1203,8 @@ export function useTKMindChat(
unsubscribeRef.current = null;
subscribedSessionIdRef.current = null;
clearActiveRequestMissingTimer();
pendingSubmitQueueRef.current = [];
flushingPendingSubmitRef.current = false;
setError(null);
setPendingTool(null);
agentRunPendingRef.current = false;
@@ -1478,84 +1508,23 @@ export function useTKMindChat(
[session, chatState, connectSession, resetSessionView, sessions],
);
const submit = useCallback(
const executeAgentSubmit = useCallback(
async (
text: string,
options?: {
mindspaceContext?: MindSpaceChatContext;
messageId?: string;
forceDeepReasoning?: boolean;
pgRequired?: boolean;
imageGenerationMode?: ImageGenerationMode;
selectedChatSkill?: string;
fileAttachments?: ChatFileAttachment[];
goalRunId?: string;
},
imageUrls?: string[],
previewImageUrls?: string[],
userMessage: Message,
options: ChatSubmitOptions | undefined,
normalizedImageUrls: string[],
normalizedFileAttachments: ChatFileAttachment[],
) => {
const normalizedImageUrls = (imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim());
const normalizedPreviewImageUrls = (previewImageUrls ?? []).filter(
(value) => typeof value === 'string' && value.trim(),
);
const normalizedFileAttachments = (options?.fileAttachments ?? []).filter(
(item) => item?.downloadUrl?.trim() && item?.filename?.trim(),
);
if (!text.trim() && normalizedImageUrls.length === 0 && normalizedFileAttachments.length === 0) return;
// Use the ref here (not the React state) so that rapid back-to-back calls in the
// same render cycle are blocked even before the state update has been re-rendered.
if (
chatStateRef.current === 'streaming' ||
chatStateRef.current === 'loading' ||
chatStateRef.current === 'connecting' ||
chatStateRef.current === 'waiting'
) return;
const trimmed = text.trim();
const mindspacePrefix = options?.mindspaceContext
? buildContextPrefix(options.mindspaceContext)
: '';
const userPrefix = buildUserAddressPrefix(userRef.current);
const skillPrefix = buildAutoChatSkillPrefix(trimmed, grantedSkills ?? []);
const pgContractPrefix = options?.pgRequired
? '[交付约束:用户已明确要求使用专属 PostgreSQL 数据空间。若生成页面,必须按 page-data-collect 完成建表、dataset、policy 和 workspace page 绑定;禁止 localStorage、SQLite、静态 JSON 或内存持久化。所有验证通过前不得回复已发布或给出页面链接。]\n'
: '';
const agentPrefix = `${userPrefix}${mindspacePrefix}${skillPrefix}${pgContractPrefix}`;
const priorMessageCount = messagesRef.current.length;
const userMessage = buildUserMessage(trimmed, {
id: options?.messageId,
agentText: `${agentPrefix}${trimmed}`,
displayText: trimmed,
imageUrls: normalizedImageUrls,
previewImageUrls: normalizedPreviewImageUrls,
fileAttachments: normalizedFileAttachments,
});
userMessage.metadata = {
...userMessage.metadata,
memindRun: {
...(userMessage.metadata?.memindRun && typeof userMessage.metadata.memindRun === 'object'
? userMessage.metadata.memindRun
: {}),
sessionMessageCount: priorMessageCount,
...(options?.pgRequired ? { pgRequired: true } : {}),
imageGenerationMode: options?.imageGenerationMode ?? 'auto',
...(options?.selectedChatSkill ? { selectedChatSkill: options.selectedChatSkill } : {}),
},
};
const trimmed = getDisplayText(userMessage).trim();
const requestId = crypto.randomUUID();
const submitToken = connectTokenRef.current;
activeRequestId.current = requestId;
messagesRef.current = [...messagesRef.current, userMessage];
messageHistoryLoadedCountRef.current = messagesRef.current.length;
setMessages(messagesRef.current);
setChatState('waiting');
// Immediately reflect in the ref so any synchronous re-entry is blocked before
// the next React render cycle runs the useEffect that normally syncs this ref.
chatStateRef.current = 'waiting';
setError(null);
setPendingTool(null);
let activeSessionId = session?.id ?? null;
let activeSessionId = sessionRef.current?.id ?? null;
if (activeSessionId) {
setSessions((prev) => touchSession(prev, activeSessionId!, 1));
@@ -1634,14 +1603,15 @@ export function useTKMindChat(
});
if (submitToken !== connectTokenRef.current) return;
agentRunPendingRef.current = false;
activeSessionId = finishedRun.sessionId;
activeSessionId = finishedRun.sessionId ?? activeSessionId;
if (!activeSessionId) {
throw new Error('后台任务已提交,但未返回会话');
}
if (normalizedImageUrls.length > 0 || normalizedFileAttachments.length > 0) {
void claimMindSpaceConversationUploads(activeSessionId, userMessage.id).catch(() => {});
}
if (!session?.id || session.id !== activeSessionId) {
const currentSessionId = sessionRef.current?.id ?? null;
if (!currentSessionId || currentSessionId !== activeSessionId) {
const nextSession: Session = {
id: activeSessionId,
name: 'New Chat',
@@ -1700,10 +1670,6 @@ export function useTKMindChat(
const nextChatState = resolvePostAgentRunChatState({
chatState: chatStateRef.current,
finishedViaPortalDirectChat,
// The agent-run result is authoritative even when the immediate
// session snapshot has not yet carried portal-direct metadata.
// Without this, a completed Page Data task can re-enter streaming
// and leave the Stop button attached to no active request.
agentRunSucceeded: finishedRun.status === 'succeeded',
});
if (nextChatState === 'idle') {
@@ -1735,9 +1701,6 @@ export function useTKMindChat(
errorCode(err),
)
) {
// Goose may report its session concurrency guard as a failed run
// message instead of an HTTP 409. Reattach to the session stream
// and reconcile the snapshot; do not strand the composer in error.
subscribeToSession(activeSessionId);
setChatState('streaming');
setError(null);
@@ -1745,7 +1708,9 @@ export function useTKMindChat(
return;
}
agentRunPendingRef.current = false;
if (session && activeSessionId) setSessions((prev) => touchSession(prev, activeSessionId!, -1));
if (sessionRef.current && activeSessionId) {
setSessions((prev) => touchSession(prev, activeSessionId!, -1));
}
if (err instanceof ApiError && err.status === 402) {
notifyInsufficientBalance();
} else {
@@ -1758,18 +1723,143 @@ export function useTKMindChat(
},
[
notifyInsufficientBalance,
session,
grantedSkills,
clearActiveRequestMissingTimer,
subscribeToSession,
scheduleReplyRecoverySync,
ensureProvider,
loadProjectMemory,
refreshSessions,
syncSessionMessages,
],
);
const flushPendingSubmitQueue = useCallback(async () => {
if (flushingPendingSubmitRef.current) return;
if (
!canFlushQueuedChatSubmit({
chatState: chatStateRef.current,
agentRunPending: agentRunPendingRef.current,
pendingTool: Boolean(pendingToolRef.current),
queueLength: pendingSubmitQueueRef.current.length,
})
) {
return;
}
const next = pendingSubmitQueueRef.current.shift();
if (!next) return;
flushingPendingSubmitRef.current = true;
try {
await executeAgentSubmit(
next.userMessage,
next.options,
next.normalizedImageUrls,
next.normalizedFileAttachments,
);
} finally {
flushingPendingSubmitRef.current = false;
if (
canFlushQueuedChatSubmit({
chatState: chatStateRef.current,
agentRunPending: agentRunPendingRef.current,
pendingTool: Boolean(pendingToolRef.current),
queueLength: pendingSubmitQueueRef.current.length,
})
) {
void flushPendingSubmitQueue();
}
}
}, [executeAgentSubmit]);
useEffect(() => {
if (
!canFlushQueuedChatSubmit({
chatState,
agentRunPending: agentRunPendingRef.current,
pendingTool: Boolean(pendingTool),
queueLength: pendingSubmitQueueRef.current.length,
})
) {
return;
}
void flushPendingSubmitQueue();
}, [chatState, pendingTool, flushPendingSubmitQueue]);
const submit = useCallback(
async (
text: string,
options?: ChatSubmitOptions,
imageUrls?: string[],
previewImageUrls?: string[],
) => {
const normalizedImageUrls = (imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim());
const normalizedPreviewImageUrls = (previewImageUrls ?? []).filter(
(value) => typeof value === 'string' && value.trim(),
);
const normalizedFileAttachments = (options?.fileAttachments ?? []).filter(
(item) => item?.downloadUrl?.trim() && item?.filename?.trim(),
);
if (!text.trim() && normalizedImageUrls.length === 0 && normalizedFileAttachments.length === 0) return;
const trimmed = text.trim();
const mindspacePrefix = options?.mindspaceContext
? buildContextPrefix(options.mindspaceContext)
: '';
const userPrefix = buildUserAddressPrefix(userRef.current);
const skillPrefix = buildAutoChatSkillPrefix(trimmed, grantedSkills ?? []);
const pgContractPrefix = options?.pgRequired
? '[交付约束:用户已明确要求使用专属 PostgreSQL 数据空间。若生成页面,必须按 page-data-collect 完成建表、dataset、policy 和 workspace page 绑定;禁止 localStorage、SQLite、静态 JSON 或内存持久化。所有验证通过前不得回复已发布或给出页面链接。]\n'
: '';
const agentPrefix = `${userPrefix}${mindspacePrefix}${skillPrefix}${pgContractPrefix}`;
const priorMessageCount = messagesRef.current.length;
const userMessage = buildUserMessage(trimmed, {
id: options?.messageId,
agentText: `${agentPrefix}${trimmed}`,
displayText: trimmed,
imageUrls: normalizedImageUrls,
previewImageUrls: normalizedPreviewImageUrls,
fileAttachments: normalizedFileAttachments,
});
userMessage.metadata = {
...userMessage.metadata,
memindRun: {
...(userMessage.metadata?.memindRun && typeof userMessage.metadata.memindRun === 'object'
? userMessage.metadata.memindRun
: {}),
sessionMessageCount: priorMessageCount,
...(options?.pgRequired ? { pgRequired: true } : {}),
imageGenerationMode: options?.imageGenerationMode ?? 'auto',
...(options?.selectedChatSkill ? { selectedChatSkill: options.selectedChatSkill } : {}),
},
};
if (isChatSubmitBusy(chatStateRef.current)) {
pendingSubmitQueueRef.current.push({
userMessage,
options,
normalizedImageUrls,
normalizedFileAttachments,
});
messagesRef.current = [...messagesRef.current, userMessage];
messageHistoryLoadedCountRef.current = messagesRef.current.length;
setMessages(messagesRef.current);
setNotice(buildQueuedChatSubmitNotice(pendingSubmitQueueRef.current.length));
return;
}
messagesRef.current = [...messagesRef.current, userMessage];
messageHistoryLoadedCountRef.current = messagesRef.current.length;
setMessages(messagesRef.current);
await executeAgentSubmit(
userMessage,
options,
normalizedImageUrls,
normalizedFileAttachments,
);
},
[executeAgentSubmit, grantedSkills],
);
const stop = useCallback(async () => {
if (!session || !activeRequestId.current) return;
try {
@@ -1894,6 +1984,8 @@ export function useTKMindChat(
clearStoredSessionId(userRef.current?.id);
agentRunPendingRef.current = false;
activeRequestId.current = null;
pendingSubmitQueueRef.current = [];
flushingPendingSubmitRef.current = false;
messagesRef.current = [];
messageHistoryLoadedCountRef.current = 0;
messageHistoryTotalRef.current = 0;
+206
View File
@@ -7964,6 +7964,212 @@ body,
margin-bottom: 12px;
}
.mindspace-achievements-intro {
margin: 0 0 18px;
color: rgba(24, 33, 29, 0.72);
font-size: 14px;
line-height: 1.6;
}
.mindspace-achievements-bulkbar {
margin-bottom: 14px;
}
.mindspace-achievements-bulkbar .mindspace-achievements-back-page {
margin-left: auto;
padding: 5px 11px;
border: 1px solid rgba(47, 111, 87, 0.18);
border-radius: 999px;
color: #2f6f57;
background: rgba(235, 248, 240, 0.72);
font-size: 12px;
font-weight: 600;
line-height: 1.4;
}
.mindspace-achievements-bulkbar .mindspace-achievements-back-page:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.mindspace-achievements-pagination {
gap: 10px;
margin-top: 16px;
}
.mindspace-achievements-pagination button {
padding: 5px 11px;
border: 1px solid rgba(24, 33, 29, 0.1);
border-radius: 999px;
background: rgba(255, 252, 244, 0.92);
color: #2f6f57;
font-size: 12px;
font-weight: 600;
line-height: 1.4;
}
.mindspace-achievements-pagination span {
font-size: 12px;
}
.mindspace-achievements-groups {
display: flex;
flex-direction: column;
gap: 22px;
}
.mindspace-achievements-group h3 {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #18211d;
}
.mindspace-achievements-group-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.mindspace-achievements-group-select {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: rgba(24, 33, 29, 0.72);
cursor: pointer;
user-select: none;
}
.mindspace-achievements-group-select input {
width: 14px;
height: 14px;
accent-color: #2f6f57;
cursor: pointer;
}
.mindspace-achievements-col-select {
width: 52px;
text-align: center;
}
.mindspace-achievements-col-select input {
width: 14px;
height: 14px;
accent-color: #2f6f57;
cursor: pointer;
}
.mindspace-achievements-delete-link {
padding: 0;
border: 0;
background: transparent;
color: #9b3d2f;
font-size: 12px;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.mindspace-achievements-delete-link:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.mindspace-achievements-load-more {
display: flex;
justify-content: center;
padding: 18px 0 8px;
color: rgba(24, 33, 29, 0.62);
font-size: 13px;
}
.mindspace-achievements-load-more button {
padding: 8px 14px;
border: 1px solid rgba(47, 111, 87, 0.24);
border-radius: 999px;
background: rgba(235, 248, 240, 0.8);
color: #1f4f3d;
cursor: pointer;
}
.mindspace-achievements-table-wrap {
overflow-x: auto;
border: 1px solid rgba(24, 33, 29, 0.08);
border-radius: 16px;
background: rgba(255, 252, 244, 0.92);
}
.mindspace-achievements-table {
width: 100%;
min-width: 760px;
border-collapse: collapse;
font-size: 13px;
}
.mindspace-achievements-table th,
.mindspace-achievements-table td {
padding: 12px 14px;
border-bottom: 1px solid rgba(24, 33, 29, 0.08);
text-align: left;
vertical-align: top;
}
.mindspace-achievements-table th {
color: rgba(24, 33, 29, 0.62);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
background: rgba(245, 240, 229, 0.72);
}
.mindspace-achievements-table tbody tr:last-child td {
border-bottom: none;
}
.mindspace-achievements-title {
min-width: 220px;
}
.mindspace-achievements-title-link {
display: inline;
padding: 0;
border: 0;
background: transparent;
color: #2f6f57;
font: inherit;
font-weight: 700;
cursor: pointer;
text-align: left;
text-decoration: underline;
text-underline-offset: 2px;
}
.mindspace-achievements-title-link:hover {
color: #1f4f3d;
}
.mindspace-achievements-detail-link {
display: inline-block;
margin-left: 10px;
padding: 0;
border: 0;
background: transparent;
color: rgba(24, 33, 29, 0.56);
font-size: 12px;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.mindspace-card-achievements {
border-color: rgba(47, 111, 87, 0.18);
background: linear-gradient(180deg, rgba(235, 248, 240, 0.92), rgba(255, 252, 244, 0.96));
}
.mindspace-all-pages .mindspace-feed-card-wrap {
display: flex;
flex-direction: column;
+3
View File
@@ -20,6 +20,7 @@ export function MindSpaceRoute({
const location = useLocation();
const [searchParams] = useSearchParams();
const pageMatch = matchPath('/space/page/:pageId', location.pathname);
const achievementsMatch = matchPath('/space/achievements', location.pathname);
const pageId = pageMatch?.params.pageId ?? null;
const categoryCode = parseCategory(searchParams.get('category'));
@@ -28,11 +29,13 @@ export function MindSpaceRoute({
user={user}
initialPageId={pageId}
initialCategoryCode={categoryCode}
initialAchievementsOpen={Boolean(achievementsMatch)}
onBack={() => navigate('/')}
onLogout={onLogout}
onOpenFeedback={() => navigate('/feedback')}
routeSync={{
pushHome: () => navigate('/space'),
pushAchievements: () => navigate('/space/achievements'),
pushCategory: (code) => navigate(`/space?category=${code}`),
pushPage: (id) => navigate(`/space/page/${id}`),
}}
+6
View File
@@ -870,6 +870,12 @@ export type MindSpacePage = {
status: 'draft' | 'reviewing' | 'risk_found' | 'ready' | 'published' | 'protected' | 'expired' | 'offline';
visibility: 'private' | 'internal' | 'public';
publicationAccessMode?: 'public' | 'password' | 'private_link' | 'time_limited' | 'login_required' | 'owner_only' | null;
publicationId?: string | null;
publicationStatus?: string | null;
viewCount?: number;
clickCount?: number | null;
statsSource?: 'none' | 'publication' | 'umami';
publishedAt?: number | null;
publicationUrl?: string | null;
workspaceRelativePath?: string | null;
workspacePublicUrl?: string | null;
+1 -1
View File
@@ -1,5 +1,5 @@
/** User-facing product name shown in nav, login, and page chrome. */
export const APP_DISPLAY_NAME = 'Memind';
export const APP_DISPLAY_NAME = 'TKMind';
/** Branded tagline suffix used on welcome and marketing surfaces. */
export const APP_DISPLAY_TAGLINE = `${APP_DISPLAY_NAME} 智趣`;
+3 -2
View File
@@ -12,9 +12,10 @@ export const SYSTEM_DISCLOSURE_MODE = Object.freeze({
});
export const DEFAULT_SYSTEM_DISCLOSURE_REFUSAL =
'出于安全与隐私考虑,我不能提供 TKMind、Memind 或 MindSpace 的内部技术实现信息。我可以继续帮助你了解公开功能、使用方法、服务边界,或者讨论不针对本系统的一般性技术原理。';
'出于安全与隐私考虑,我不能提供 TKMind 或 MindSpace 的内部技术实现信息。我可以继续帮助你了解公开功能、使用方法、服务边界,或者讨论不针对本系统的一般性技术原理。';
const DEFAULT_PRODUCT_NAMES = Object.freeze([
'tikmind',
'tkmind',
'memind',
'mindspace',
@@ -42,7 +43,7 @@ const TECHNICAL_CATEGORY_PATTERNS = Object.freeze({
/(?:底层|内部|系统|技术|整体|服务|运行时|agent)\s*(?:架构|设计|实现|原理|机制)/iu,
/(?:架构|设计|实现|原理|机制)\s*(?:图|说明|细节|文档|方案|是什|怎么|如何)/iu,
/(?:技术栈|开发语言|编程语言|前端框架|后端框架|开源组件|内部组件|依赖版本|运行框架)/iu,
/(?:tkmind|memind|mindspace).{0,24}(?:关系|区别|协作|调用链|怎么工作|如何工作)/iu,
/(?:tikmind|tkmind|memind|mindspace).{0,24}(?:关系|区别|协作|调用链|怎么工作|如何工作)/iu,
/\b(?:architecture|internals?|implementation|system design|runtime design)\b/iu,
],
model_and_routing: [
+8 -1
View File
@@ -111,12 +111,14 @@ test('buildVisionPayload sends all current-turn images to Qwen in order', async
assert.equal(analyzedImages.length, 2);
assert.deepEqual(analyzedImages.map((item) => item.rawUrl), imageUrls);
assert.deepEqual(result?.userMessage?.metadata?.imageUrls, [
assert.equal(result?.userMessage?.metadata?.imageUrls, undefined);
assert.deepEqual(result?.userMessage?.metadata?.archivedImageUrls, [
'/MindSpace/user-1/public/wechat-mp/first.jpg',
'/MindSpace/user-1/public/wechat-mp/second.jpg',
]);
assert.match(result?.userMessage?.content?.[0]?.text ?? '', /本轮用户仅上传 2 张图片/);
assert.match(result?.userMessage?.content?.[0]?.text ?? '', /图片2/);
assert.doesNotMatch(result?.userMessage?.content?.[0]?.text ?? '', /\[图片\d+\]:/);
});
test('buildVisionPayload does not mark billable usage when vision analysis fails', async () => {
@@ -171,4 +173,9 @@ test('buildVisionPayload strips image_url content parts for text-only Goose prov
false,
);
assert.match(result?.userMessage?.content?.[0]?.text ?? '', /蓝色方块/);
assert.equal(result?.userMessage?.metadata?.imageUrls, undefined);
assert.deepEqual(result?.userMessage?.metadata?.archivedImageUrls, [
'/api/mindspace/v1/assets/asset-7/download?inline=1',
]);
assert.doesNotMatch(result?.userMessage?.content?.[0]?.text ?? '', /\[图片\d+\]:/);
});
+12 -8
View File
@@ -35,6 +35,7 @@ import { extractAttachmentText } from './mindspace-attachment-text.mjs';
import {
buildCurrentTurnImageScopeNote,
conversationHasImageUrlContent,
detachCurrentTurnImagesForTextProvider,
extractCurrentTurnImageUrls,
scrubConversationHistoricalImageAttachments,
} from './chat-image-turn-scope.mjs';
@@ -1004,15 +1005,18 @@ export async function buildVisionPayload({
.filter((url) => typeof url === 'string' && url.trim());
return {
userMessage: {
...userMessage,
content: updatedContent,
metadata: {
...(userMessage.metadata ?? {}),
...(originalDisplayText ? { displayText: originalDisplayText } : {}),
...(canonicalImageUrls.length ? { imageUrls: canonicalImageUrls } : {}),
userMessage: detachCurrentTurnImagesForTextProvider(
{
...userMessage,
content: updatedContent,
metadata: {
...(userMessage.metadata ?? {}),
...(originalDisplayText ? { displayText: originalDisplayText } : {}),
...(canonicalImageUrls.length ? { imageUrls: canonicalImageUrls } : {}),
},
},
},
canonicalImageUrls,
),
billableImageCount: visionDescription ? 1 : 0,
};
}
+2
View File
@@ -1707,6 +1707,8 @@ test('submitSessionReplyForUser applies the shared Qwen vision preprocessing pat
const forwardedText = replyBodies[0]?.user_message?.content?.[0]?.text ?? '';
assert.match(forwardedText, /Qwen VL 图片描述/);
assert.match(forwardedText, /一件蓝色产品/);
assert.equal(replyBodies[0]?.user_message?.metadata?.imageUrls, undefined);
assert.ok(Array.isArray(replyBodies[0]?.user_message?.metadata?.archivedImageUrls));
});
});
+26 -10
View File
@@ -1088,6 +1088,12 @@ export function isWechatPageDataTask(text) {
return isPageDataIntent(text) || isPageDataDevIntent(text);
}
export function isWechatHistoricalImageSessionError(message) {
return /historical_image_session_update_unsupported|unknown variant [`']?image_url/i.test(
String(message ?? '').trim(),
);
}
export function isRecoverableWechatAgentSessionError(message) {
const normalized = String(message ?? '').trim();
if (!normalized) return false;
@@ -1095,9 +1101,7 @@ export function isRecoverableWechatAgentSessionError(message) {
if (/wechat_page_fresh_thumbnail_required:/i.test(normalized)) return true;
if (/403|404|not found|无权访问/i.test(normalized)) return true;
if (/tool_calls|tool_call_id|insufficient tool messages/i.test(normalized)) return true;
if (/historical_image_session_update_unsupported|unknown variant [`']?image_url/i.test(normalized)) {
return true;
}
if (isWechatHistoricalImageSessionError(normalized)) return true;
if (/session already has an active request|active request.*cancel/i.test(normalized)) return true;
if (/wechat_agent_incomplete_reply/i.test(normalized)) return true;
return false;
@@ -1712,7 +1716,13 @@ export function createWechatMpService({
const attachRecentMediaForFollowup = (openid, intent, mediaAnalysisEnabled) => {
if (!mediaAnalysisEnabled || intent?.msgType !== 'text' || intent?.media?.publicUrl) return;
const text = String(intent?.agentText ?? '');
if (!/(?:刚才|之前|上一|这张|这份|图片|图像|照片|文件|文档|表格|excel|word)/iu.test(text)) return;
if (
!/(?:刚才|之前|上一|这张|这份|图片|图像|照片|文件|文档|表格|excel|word|报告|解读|化验|检验|做成页面|做个页面|做页面|生成页面)/iu.test(
text,
)
) {
return;
}
const key = String(openid ?? '').trim();
const recent = recentMediaByOpenid.get(key);
if (
@@ -3099,8 +3109,13 @@ export function createWechatMpService({
const mayBeStaleSession =
sessionId
&& (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message));
const historicalImageError = isWechatHistoricalImageSessionError(message);
if (mayBeStaleSession) {
if (sessionPageContinuation && isWechatPageContinuationRepairableError(message)) {
if (
sessionPageContinuation
&& !historicalImageError
&& isWechatPageContinuationRepairableError(message)
) {
const text = buildPagePublishFailureText();
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
logger.warn?.('WeChat MP page continuation repair route clear failed:', clearErr);
@@ -3116,7 +3131,7 @@ export function createWechatMpService({
repairError.wechatAgentSessionId = sessionId;
throw repairError;
}
if (sessionPageContinuation) {
if (sessionPageContinuation && !historicalImageError) {
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
logger.warn?.('WeChat MP contextual follow-up route clear failed:', clearErr);
});
@@ -3144,17 +3159,18 @@ export function createWechatMpService({
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
const retryId = crypto.randomUUID();
const retryStartedAt = Date.now();
const retryPageContinuation = sessionPageContinuation && !historicalImageError;
const retryPrompt =
wechatIntent.kind === 'page.generate'
? buildPageGenerateAgentPrompt(intent, {
wantsDocx: wechatIntent.wantsDocx,
imagePolicy,
preferImmediateContext: sessionPageContinuation,
carriedSessionContent,
preferImmediateContext: retryPageContinuation,
carriedSessionContent: retryPageContinuation ? carriedSessionContent : '',
})
: buildWechatAgentPrompt(intent, {
imagePolicy,
preferSessionPageContinuation: sessionPageContinuation,
preferSessionPageContinuation: retryPageContinuation,
});
const reply = await executeSessionReply(
(pathname, init) => fetchForSession(sessionId, pathname, init),
@@ -3171,7 +3187,7 @@ export function createWechatMpService({
userId: user.userId,
sessionId,
userMessage,
preserveAgentPrompt: sessionPageContinuation,
preserveAgentPrompt: retryPageContinuation,
}),
submitReply: submitSessionReply
? ({ requestId: replyRequestId, userMessage }) =>
+238
View File
@@ -13,6 +13,7 @@ import {
findRecoverableWechatAgentErrorInReply,
isRecoverableWechatAgentSessionError,
isWechatAgentApiErrorText,
isWechatHistoricalImageSessionError,
sanitizeWechatAgentOutboundText,
loadWechatMpConfig,
maybeAttachPublishedHtmlLink,
@@ -385,6 +386,13 @@ test('WeChat session page continuation covers retry, edit, and poem edits', () =
shouldDeliverWechatHtmlArtifacts(poemEdit, { agentText: '把诗里第三段改长一点' }),
true,
);
assert.equal(
isWechatSessionPageContinuation(
classifyWechatIntent({ msgType: 'text', agentText: '解读详细报告,做成页面' }),
'解读详细报告,做成页面',
),
false,
);
});
test('WeChat immediate-context page skips fresh thumbnail requirement', () => {
@@ -3477,6 +3485,16 @@ test('isRecoverableWechatAgentSessionError detects poisoned tool_calls history',
isRecoverableWechatAgentSessionError('historical_image_session_update_unsupported:405'),
true,
);
assert.equal(
isWechatHistoricalImageSessionError('historical_image_session_update_unsupported:405'),
true,
);
assert.equal(
isWechatHistoricalImageSessionError(
'Request failed: Bad request (400): messages[74]: unknown variant `image_url`, expected `text`',
),
true,
);
assert.equal(isRecoverableWechatAgentSessionError('network timeout'), false);
});
@@ -3572,6 +3590,113 @@ test('wechat mp rotates and retries when historical image isolation is unsupport
assert.equal(sentPayloads[0].text.content, '新会话已恢复,可以继续。');
});
test('wechat mp rotates page continuation instead of dropping image_url session errors', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const submittedSessions = [];
const sentPayloads = [];
let activeSessionId = 'session-1';
let routeCleared = false;
const poem = `《临江仙·秋思》${'昨夜西风凋碧树,独上高楼,望尽天涯路。'.repeat(3)}`;
const service = createBoundWechatService({
token,
startAgentSession: async () => ({ id: 'session-2' }),
userAuth: {
async getWechatAgentRoute() {
return routeCleared ? null : { agentSessionId: activeSessionId, status: 'active' };
},
async clearWechatAgentRoute() {
routeCleared = true;
},
async upsertWechatAgentRoute({ agentSessionId }) {
activeSessionId = agentSessionId;
routeCleared = false;
},
},
submitSessionReply: async ({ sessionId, options }) => {
submittedSessions.push(sessionId);
assert.equal(options?.requireHistoricalImageIsolation, true);
if (sessionId === 'session-1') {
throw new Error(
'Ran into this error: Request failed: Bad request (400): Failed to deserialize the JSON body into the target type: messages[74]: unknown variant `image_url`, expected `text`',
);
}
return { ok: true };
},
sessionApiFetch: async (sessionId, pathname) => {
if (pathname === `/sessions/${sessionId}`) {
return new Response(JSON.stringify({
conversation: sessionId === 'session-1'
? [{ role: 'assistant', content: [{ type: 'text', text: poem }] }]
: [],
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (pathname === `/sessions/${sessionId}/events`) {
if (sessionId === 'session-1') {
return new Response('', {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
});
}
return new Response(
[
'data: {"type":"Message","request_id":"req-page-retry","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"新会话已恢复,可以继续。"}]}}\n\n',
'data: {"type":"Finish","request_id":"req-page-retry","token_state":{"inputTokens":1,"outputTokens":2}}\n\n',
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
throw new Error(`unexpected api path: ${sessionId} ${pathname}`);
},
wechatFetch: async (url, init = {}) => {
if (String(url).includes('/cgi-bin/stable_token')) {
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (String(url).includes('/cgi-bin/message/custom/send')) {
sentPayloads.push(JSON.parse(init.body));
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected wechat url: ${url}`);
},
});
const originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = (() => {
const ids = ['req-page-first', 'req-page-retry'];
return () => ids.shift() ?? 'req-page-retry';
})();
try {
const result = await service.handleInboundMessage(
inboundXml({ content: '把刚才的诗做成页面' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await result.task;
} finally {
crypto.randomUUID = originalRandomUuid;
}
assert.deepEqual(submittedSessions, ['session-1', 'session-2']);
assert.equal(activeSessionId, 'session-2');
assert.equal(
sentPayloads.some((payload) => /没能可靠确认/.test(String(payload?.text?.content ?? ''))),
false,
);
});
test('findRecoverableWechatAgentErrorInReply scans all assistant messages', () => {
const toolCallsError =
"Request failed: Bad request (400): An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message).";
@@ -5318,6 +5443,119 @@ test('wechat mp serializes image and follow-up text and reattaches recent image'
assert.equal(submitCalls[1].userMessage.metadata.msgType, 'text');
});
test('wechat mp reattaches recent image for report interpretation follow-up', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const testUserId = 'test-user-report-followup';
const submitCalls = [];
let eventCall = 0;
let releaseFirst = null;
const service = createBoundWechatService({
token,
config: {
mediaAnalysisGrayUsers: [testUserId],
},
userAuth: {
async findWechatUserByOpenid() {
return { userId: testUserId, status: 'active', nickname: '唐' };
},
},
sessionApiFetch: async (_sessionId, pathname) => {
if (pathname === '/sessions/session-1/events') {
eventCall += 1;
if (eventCall === 1) {
return new Response(
new ReadableStream({
start(controller) {
releaseFirst = () => {
controller.enqueue(
new TextEncoder().encode(
'data: {"type":"Message","message":{"id":"assistant-image","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"图片已识别。"}]}}\n\n' +
'data: {"type":"Finish"}\n\n',
),
);
controller.close();
};
},
}),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
return new Response(
[
'data: {"type":"Message","message":{"id":"assistant-followup","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已解读报告。"}]}}\n\n',
'data: {"type":"Finish"}\n\n',
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
throw new Error(`unexpected api path: ${pathname}`);
},
submitSessionReply: async (input) => {
submitCalls.push(input);
return { ok: true };
},
wechatFetch: async (url) => {
if (String(url).includes('/cgi-bin/stable_token')) {
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (String(url).includes('/cgi-bin/media/get')) {
return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), {
status: 200,
headers: { 'Content-Type': 'image/png' },
});
}
if (String(url).includes('/cgi-bin/message/custom/send')) {
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected wechat url: ${url}`);
},
});
try {
const imageResult = await service.handleInboundMessage(
inboundXml({
msgType: 'image',
content: '',
extraFields: { MediaId: 'media-report', PicUrl: 'https://wx.example.com/report.png' },
}),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
while (submitCalls.length === 0) await new Promise((resolve) => setTimeout(resolve, 0));
const followupResult = await service.handleInboundMessage(
inboundXml({ msgType: 'text', content: '解读详细报告,做成页面' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(submitCalls.length, 1);
releaseFirst();
await imageResult.task;
await followupResult.task;
} finally {
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
}
assert.equal(submitCalls.length, 2);
assert.deepEqual(
submitCalls[1].userMessage.metadata.imageUrls,
submitCalls[0].userMessage.metadata.imageUrls,
);
assert.equal(submitCalls[1].userMessage.metadata.msgType, 'text');
assert.equal(submitCalls[1].userMessage.metadata.displayText, '解读详细报告,做成页面');
});
test('wechat mp groups consecutive images for one follow-up and clears the consumed batch', async () => {
const token = 'token';
const timestamp = '1710000000';