Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 371900bae5 |
@@ -65,6 +65,22 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
|
|||||||
# MEMIND_AGENT_RUN_WORKER_ID=<optional-instance-id>
|
# MEMIND_AGENT_RUN_WORKER_ID=<optional-instance-id>
|
||||||
# MEMIND_AGENT_RUN_WORKER_EXPECT_RUNNING=0
|
# MEMIND_AGENT_RUN_WORKER_EXPECT_RUNNING=0
|
||||||
|
|
||||||
|
# 本机 Aider / OpenHands(Colima 迁出后走 native CLI,见 memind-local-aider / memind-local-openhands)
|
||||||
|
# AIDER_BIN=/Users/john/.local/bin/aider
|
||||||
|
# GOOSE_AIDER_BIN=/Users/john/.openhands/bin/studio-aider
|
||||||
|
# OPENHANDS_BIN=/Users/john/.local/bin/openhands
|
||||||
|
# GOOSE_AIDER_BIN=/Users/john/Project/Memind/scripts/aider-goose-wrapper.sh
|
||||||
|
# GOOSE_OPENHANDS_BIN=/Users/john/Project/Memind/scripts/openhands-goose-wrapper.sh
|
||||||
|
# GOOSE_OPENHANDS_RUNNER=host
|
||||||
|
# 本地开发:普通用户 role=user 默认开启 aider/openhands;生产保持 0
|
||||||
|
# MEMIND_ROLE_CODE_TOOLS_DEFAULT=0
|
||||||
|
# MEMIND_TOOL_GATEWAY_ENABLED=0
|
||||||
|
# MEMIND_TOOL_GATEWAY_DRY_RUN=0
|
||||||
|
# MEMIND_TOOL_GATEWAY_DEFAULT_EXECUTOR=aider
|
||||||
|
# Orchestrator worker(memind-local-orchestrator/.env 同步)OpenHands 进程执行器:
|
||||||
|
# MEMIND_EXECUTOR_OPENHANDS_COMMAND=/Users/john/Project/Memind/scripts/openhands-goose-wrapper.sh
|
||||||
|
# MEMIND_EXECUTOR_WORKSPACE_ROOT=/Users/john/Project/Memind
|
||||||
|
|
||||||
# LangGraph Orchestrator Shadow(默认完全不接入 Agent Run 请求路径)。
|
# LangGraph Orchestrator Shadow(默认完全不接入 Agent Run 请求路径)。
|
||||||
# 仅在独立 Orchestrator 已健康、service token 已配置后,先显式打开观察 wiring,
|
# 仅在独立 Orchestrator 已健康、service token 已配置后,先显式打开观察 wiring,
|
||||||
# 再通过 memindadm 选择 shadow。关闭 wiring 时不查询编排配置、不排队、不传用户数据。
|
# 再通过 memindadm 选择 shadow。关闭 wiring 时不查询编排配置、不排队、不传用户数据。
|
||||||
@@ -331,6 +347,10 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind
|
|||||||
# MindSearch / standalone Deep Search
|
# MindSearch / standalone Deep Search
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Production SearXNG on 103 is bound to the host at 127.0.0.1:20080.
|
# Production SearXNG on 103 is bound to the host at 127.0.0.1:20080.
|
||||||
|
# Outbound proxy for DuckDuckGo/Yandex lives in searxng-runtime shared config:
|
||||||
|
# /Users/john/Project/searxng-runtime/shared/.env
|
||||||
|
# SEARXNG_OUTGOING_PROXY=http://127.0.0.1:1082 (Shadowrocket MacPacket)
|
||||||
|
# Template source: /Users/john/Project/searxng-prod/settings.yml.template
|
||||||
# Goosed MCP endpoints are automatically rewritten to host.docker.internal.
|
# Goosed MCP endpoints are automatically rewritten to host.docker.internal.
|
||||||
# TKMIND_SEARCH_SEARXNG_URL=http://127.0.0.1:20080/search
|
# TKMIND_SEARCH_SEARXNG_URL=http://127.0.0.1:20080/search
|
||||||
# TKMIND_SEARCH_MCP_HOST_GATEWAY=host.docker.internal
|
# TKMIND_SEARCH_MCP_HOST_GATEWAY=host.docker.internal
|
||||||
|
|||||||
@@ -32,8 +32,17 @@ export function stripMindSpaceContextPrefix(text) {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ASSISTANT_DELIVERABLE_RE =
|
/** MindSpace 交付链接:生产域名与本地 dev(127.0.0.1 / localhost)都必须可点击,不可被架构脱敏替换。 */
|
||||||
/\[[^\]]+\]\(https?:\/\/[^)]+\)|https?:\/\/(?:m\.)?[^/\s]*tkmind\.(?:cn|ai)\/MindSpace\//i;
|
export const MINDSPACE_DELIVERABLE_ORIGIN_RE =
|
||||||
|
'(?:127\\.0\\.0\\.1|localhost):\\d+|(?:m\\.)?[^/\\s]*tkmind\\.(?:cn|ai)';
|
||||||
|
|
||||||
|
const ASSISTANT_DELIVERABLE_RE = new RegExp(
|
||||||
|
[
|
||||||
|
'\\[[^\\]]+\\]\\(https?:\\/\\/[^)]+\\)',
|
||||||
|
`https?:\\/\\/${MINDSPACE_DELIVERABLE_ORIGIN_RE}\\/MindSpace\\/`,
|
||||||
|
].join('|'),
|
||||||
|
'i',
|
||||||
|
);
|
||||||
|
|
||||||
const INTERNAL_ASSISTANT_MARKERS = [
|
const INTERNAL_ASSISTANT_MARKERS = [
|
||||||
/data-mindspace-page-tag/i,
|
/data-mindspace-page-tag/i,
|
||||||
|
|||||||
@@ -80,3 +80,13 @@ test('deriveAssistantFacingText keeps user-facing replies with public links', ()
|
|||||||
'页面已生成:[AI 机器人研究报告 2026](https://m.tkmind.cn/MindSpace/john/public/ai-robot-report.html)';
|
'页面已生成:[AI 机器人研究报告 2026](https://m.tkmind.cn/MindSpace/john/public/ai-robot-report.html)';
|
||||||
assert.equal(deriveAssistantFacingText(external), external);
|
assert.equal(deriveAssistantFacingText(external), external);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('deriveAssistantFacingText keeps local MindSpace deliverable links clickable', () => {
|
||||||
|
const localMarkdown =
|
||||||
|
'日记本就绪:[打开日记本](http://127.0.0.1:8081/MindSpace/32035858-9a20-425b-89da-c118ef0779aa/public/diary.html)';
|
||||||
|
assert.equal(deriveAssistantFacingText(localMarkdown), localMarkdown);
|
||||||
|
|
||||||
|
const localBare =
|
||||||
|
'本地预览:http://127.0.0.1:5173/MindSpace/john/public/survey.html';
|
||||||
|
assert.equal(deriveAssistantFacingText(localBare), localBare);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# OpenHands 安装说明
|
# OpenHands 安装说明
|
||||||
|
|
||||||
|
> **本机 Colima 已迁出(2026-07-29)**。日常开发请优先使用
|
||||||
|
> [`/Users/john/Project/memind-local-openhands`](../memind-local-openhands/README.md)
|
||||||
|
> 与 [`memind-local-infra/MIGRATION.md`](/Users/john/Project/memind-local-infra/MIGRATION.md)。
|
||||||
|
> 下文 Colima compose 章节仅作历史参考。
|
||||||
|
|
||||||
> 目标:先把 OpenHands 独立安装并跑起来,确认可用后,再接入 Goose / 路由策略。
|
> 目标:先把 OpenHands 独立安装并跑起来,确认可用后,再接入 Goose / 路由策略。
|
||||||
>
|
>
|
||||||
> 原则:安装阶段不改动现有 Goose 服务,不影响当前生产/测试链路。
|
> 原则:安装阶段不改动现有 Goose 服务,不影响当前生产/测试链路。
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ Page Data 页面只有在下面所有条件满足后才可向用户交付链接
|
|||||||
3. page record、online publication 与 policy 使用同一个真实 page UUID。
|
3. page record、online publication 与 policy 使用同一个真实 page UUID。
|
||||||
4. 公开页完成一次受权限约束的 insert smoke;后台页完成 password auth 后的 read smoke。
|
4. 公开页完成一次受权限约束的 insert smoke;后台页完成 password auth 后的 read smoke。
|
||||||
|
|
||||||
|
## 页面空间配额(grace write)
|
||||||
|
|
||||||
|
MindSpace **页面 HTML**(`public/*.html`、`draft/*.html`)配额策略:
|
||||||
|
|
||||||
|
- **尚未超配额**但剩余空间不足:允许当次页面写完并完成 bind(grace write)。
|
||||||
|
- **已经超配额**(`used_bytes + reserved_bytes >= quota_bytes`):下一次 `write_file` / `edit_file` / bind / 发布必须直接失败,提示用户先清理空间。
|
||||||
|
- grace **不覆盖**图片/附件等非页面 HTML 资产。
|
||||||
|
|
||||||
|
Agent 不得在空间已满时交付 Page Data 链接;不得只写 slug policy 文件而不完成 `private_data_bind_workspace_page`。
|
||||||
|
|
||||||
`private_data_bind_workspace_page` 是硬门:它在创建 page / publication / policy 前,必须从 SQLite registry 派生权限。Agent 传入的策略不能凭空创建 dataset 或字段权限。
|
`private_data_bind_workspace_page` 是硬门:它在创建 page / publication / policy 前,必须从 SQLite registry 派生权限。Agent 传入的策略不能凭空创建 dataset 或字段权限。
|
||||||
|
|
||||||
运行时路径必须按语义区分:
|
运行时路径必须按语义区分:
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export function createMindSpaceLocalRuntimeServices({
|
|||||||
syncWorkspaceAssets,
|
syncWorkspaceAssets,
|
||||||
conversationArtifactService,
|
conversationArtifactService,
|
||||||
workspacePageDeliveryService,
|
workspacePageDeliveryService,
|
||||||
|
getQuotaPool: () => pool,
|
||||||
});
|
});
|
||||||
const cleanupService = createCleanupService(pool, {
|
const cleanupService = createCleanupService(pool, {
|
||||||
storageRoot: runtime.storageRoot,
|
storageRoot: runtime.storageRoot,
|
||||||
|
|||||||
+5
-7
@@ -22,6 +22,7 @@ import { prepareHtmlPageBrandMarkers } from './mindspace-page-tag.mjs';
|
|||||||
import { purgeWorkspacePageArtifacts, extractAssetIdsFromHtml } from './mindspace-page-purge.mjs';
|
import { purgeWorkspacePageArtifacts, extractAssetIdsFromHtml } from './mindspace-page-purge.mjs';
|
||||||
import { ensureWorkspaceHtmlThumbnail, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
|
import { ensureWorkspaceHtmlThumbnail, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
|
||||||
import { upsertMindspaceCoverMeta } from './mindspace-cover-meta.mjs';
|
import { upsertMindspaceCoverMeta } from './mindspace-cover-meta.mjs';
|
||||||
|
import { assertPageWriteQuota } from './mindspace-space-quota.mjs';
|
||||||
|
|
||||||
const MAX_TITLE_LENGTH = 255;
|
const MAX_TITLE_LENGTH = 255;
|
||||||
const MAX_SUMMARY_LENGTH = 1000;
|
const MAX_SUMMARY_LENGTH = 1000;
|
||||||
@@ -393,13 +394,10 @@ export function createPageService(pool, options = {}) {
|
|||||||
if (!space || space.status !== 'active') {
|
if (!space || space.status !== 'active') {
|
||||||
throw pageError('用户空间不可用', 'space_unavailable');
|
throw pageError('用户空间不可用', 'space_unavailable');
|
||||||
}
|
}
|
||||||
const available =
|
try {
|
||||||
asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes);
|
assertPageWriteQuota(space, normalized.contentBytes);
|
||||||
if (available < normalized.contentBytes) {
|
} catch (error) {
|
||||||
throw pageError('剩余空间不足', 'quota_exceeded', {
|
throw pageError(error.message, error.code ?? 'quota_exceeded', error.details);
|
||||||
requiredBytes: normalized.contentBytes,
|
|
||||||
availableBytes: Math.max(0, available),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const [categories] = await conn.query(
|
const [categories] = await conn.query(
|
||||||
`SELECT id, category_code FROM h5_space_categories
|
`SELECT id, category_code FROM h5_space_categories
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
inferWorkspaceHtmlRelativePath,
|
inferWorkspaceHtmlRelativePath,
|
||||||
} from './mindspace-html-download-links.mjs';
|
} from './mindspace-html-download-links.mjs';
|
||||||
import { createImgproxySigner } from './imgproxy-signer.mjs';
|
import { createImgproxySigner } from './imgproxy-signer.mjs';
|
||||||
|
import { assertPageWriteQuota } from './mindspace-space-quota.mjs';
|
||||||
|
|
||||||
const SCANNER_VERSION = 'mindspace-content-v1';
|
const SCANNER_VERSION = 'mindspace-content-v1';
|
||||||
const PRIVATE_ASSET_DOWNLOAD_URL_PATTERN =
|
const PRIVATE_ASSET_DOWNLOAD_URL_PATTERN =
|
||||||
@@ -723,15 +724,10 @@ export function createPublicationService(pool, options = {}) {
|
|||||||
WHERE id = ? AND user_id = ? FOR UPDATE`,
|
WHERE id = ? AND user_id = ? FOR UPDATE`,
|
||||||
[page.space_id, userId],
|
[page.space_id, userId],
|
||||||
);
|
);
|
||||||
const available =
|
try {
|
||||||
Number(spaces[0]?.quota_bytes ?? 0) -
|
assertPageWriteQuota(spaces[0], htmlBytes);
|
||||||
Number(spaces[0]?.used_bytes ?? 0) -
|
} catch (error) {
|
||||||
Number(spaces[0]?.reserved_bytes ?? 0);
|
throw publicationError(error.message, error.code ?? 'quota_exceeded', error.details);
|
||||||
if (available < htmlBytes) {
|
|
||||||
throw publicationError('剩余空间不足', 'quota_exceeded', {
|
|
||||||
requiredBytes: htmlBytes,
|
|
||||||
availableBytes: Math.max(0, available),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const [publicationUsage] = await conn.query(
|
const [publicationUsage] = await conn.query(
|
||||||
`SELECT COUNT(DISTINCT page_id) AS public_page_used,
|
`SELECT COUNT(DISTINCT page_id) AS public_page_used,
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
function asQuotaNumber(value) {
|
||||||
|
return Number(value ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeUserSpaceQuotaStats(space = {}) {
|
||||||
|
const quotaBytes = asQuotaNumber(space.quota_bytes);
|
||||||
|
const usedBytes = asQuotaNumber(space.used_bytes);
|
||||||
|
const reservedBytes = asQuotaNumber(space.reserved_bytes);
|
||||||
|
const occupiedBytes = usedBytes + reservedBytes;
|
||||||
|
const availableBytes = quotaBytes - occupiedBytes;
|
||||||
|
return {
|
||||||
|
quotaBytes,
|
||||||
|
usedBytes,
|
||||||
|
reservedBytes,
|
||||||
|
occupiedBytes,
|
||||||
|
availableBytes,
|
||||||
|
isOverQuota: occupiedBytes >= quotaBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page write grace policy:
|
||||||
|
* - Already at/above quota: block the next page write entirely.
|
||||||
|
* - Below quota but insufficient headroom: allow this write (may exceed quota).
|
||||||
|
* - Sufficient headroom: allow normally.
|
||||||
|
*/
|
||||||
|
export function evaluatePageWriteQuota(space, requiredBytes) {
|
||||||
|
const stats = computeUserSpaceQuotaStats(space);
|
||||||
|
const required = Math.max(0, asQuotaNumber(requiredBytes));
|
||||||
|
if (stats.isOverQuota) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
grace: false,
|
||||||
|
stats,
|
||||||
|
requiredBytes: required,
|
||||||
|
reason: 'over_quota',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (stats.availableBytes >= required) {
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
grace: false,
|
||||||
|
stats,
|
||||||
|
requiredBytes: required,
|
||||||
|
reason: 'sufficient',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
grace: true,
|
||||||
|
stats,
|
||||||
|
requiredBytes: required,
|
||||||
|
reason: 'grace_write',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPageWriteQuotaError(
|
||||||
|
evaluation,
|
||||||
|
{ messagePrefix = '剩余空间不足' } = {},
|
||||||
|
) {
|
||||||
|
const { stats, requiredBytes, reason } = evaluation;
|
||||||
|
const message =
|
||||||
|
reason === 'over_quota'
|
||||||
|
? '空间已满,请先清理后再创建或修改页面'
|
||||||
|
: messagePrefix;
|
||||||
|
return Object.assign(new Error(message), {
|
||||||
|
code: 'quota_exceeded',
|
||||||
|
details: {
|
||||||
|
requiredBytes,
|
||||||
|
availableBytes: Math.max(0, stats.availableBytes),
|
||||||
|
usedBytes: stats.usedBytes,
|
||||||
|
quotaBytes: stats.quotaBytes,
|
||||||
|
overQuota: reason === 'over_quota',
|
||||||
|
graceWrite: reason === 'grace_write',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertPageWriteQuota(space, requiredBytes) {
|
||||||
|
const evaluation = evaluatePageWriteQuota(space, requiredBytes);
|
||||||
|
if (!evaluation.allowed) {
|
||||||
|
throw buildPageWriteQuotaError(evaluation);
|
||||||
|
}
|
||||||
|
return evaluation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMindSpacePageWriteRelativePath(relativePath) {
|
||||||
|
const normalized = String(relativePath ?? '').replace(/\\/g, '/').trim();
|
||||||
|
if (!normalized.toLowerCase().endsWith('.html')) return false;
|
||||||
|
const zone = normalized.split('/')[0]?.toLowerCase();
|
||||||
|
return zone === 'public' || zone === 'draft';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadUserSpaceForQuota(pool, userId) {
|
||||||
|
if (!pool || !userId) return null;
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT id, quota_bytes, used_bytes, reserved_bytes, status
|
||||||
|
FROM h5_user_spaces WHERE user_id = ? LIMIT 1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertPageWriteQuotaForUser(pool, userId, requiredBytes) {
|
||||||
|
const space = await loadUserSpaceForQuota(pool, userId);
|
||||||
|
if (!space || space.status !== 'active') {
|
||||||
|
throw Object.assign(new Error('用户空间不可用'), { code: 'space_unavailable' });
|
||||||
|
}
|
||||||
|
return assertPageWriteQuota(space, requiredBytes);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
assertPageWriteQuota,
|
||||||
|
evaluatePageWriteQuota,
|
||||||
|
isMindSpacePageWriteRelativePath,
|
||||||
|
} from './mindspace-space-quota.mjs';
|
||||||
|
|
||||||
|
const baseSpace = {
|
||||||
|
quota_bytes: 100,
|
||||||
|
used_bytes: 80,
|
||||||
|
reserved_bytes: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('evaluatePageWriteQuota allows normal writes within headroom', () => {
|
||||||
|
assert.deepEqual(evaluatePageWriteQuota(baseSpace, 15).reason, 'sufficient');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('evaluatePageWriteQuota allows grace write when headroom is insufficient but not over quota', () => {
|
||||||
|
const result = evaluatePageWriteQuota(baseSpace, 30);
|
||||||
|
assert.equal(result.reason, 'grace_write');
|
||||||
|
assert.equal(result.allowed, true);
|
||||||
|
assert.equal(result.grace, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('evaluatePageWriteQuota blocks next write after quota is exhausted', () => {
|
||||||
|
const over = { quota_bytes: 100, used_bytes: 100, reserved_bytes: 0 };
|
||||||
|
const result = evaluatePageWriteQuota(over, 1);
|
||||||
|
assert.equal(result.reason, 'over_quota');
|
||||||
|
assert.equal(result.allowed, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assertPageWriteQuota throws over-quota message for subsequent writes', () => {
|
||||||
|
const over = { quota_bytes: 100, used_bytes: 101, reserved_bytes: 0 };
|
||||||
|
assert.throws(
|
||||||
|
() => assertPageWriteQuota(over, 1),
|
||||||
|
(error) =>
|
||||||
|
error.code === 'quota_exceeded' &&
|
||||||
|
error.message.includes('空间已满') &&
|
||||||
|
error.details?.overQuota === true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isMindSpacePageWriteRelativePath matches public and draft html only', () => {
|
||||||
|
assert.equal(isMindSpacePageWriteRelativePath('public/bug-tracker.html'), true);
|
||||||
|
assert.equal(isMindSpacePageWriteRelativePath('draft/report.html'), true);
|
||||||
|
assert.equal(isMindSpacePageWriteRelativePath('private/notes.html'), false);
|
||||||
|
assert.equal(isMindSpacePageWriteRelativePath('public/data.json'), false);
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import { resolveAssetWorkspaceRelativePath } from './mindspace-workspace-path.mj
|
|||||||
import { assetInternals } from './mindspace-assets.mjs';
|
import { assetInternals } from './mindspace-assets.mjs';
|
||||||
import { runBasicFileScan } from './mindspace-scan.mjs';
|
import { runBasicFileScan } from './mindspace-scan.mjs';
|
||||||
import { buildWorkspaceStorageKey } from './workspace-storage.mjs';
|
import { buildWorkspaceStorageKey } from './workspace-storage.mjs';
|
||||||
|
import { assertPageWriteQuota } from './mindspace-space-quota.mjs';
|
||||||
|
|
||||||
const SKIP_FILENAMES = new Set(['index.html', '.tkmindhints', '.goosehints', '.ls_output']);
|
const SKIP_FILENAMES = new Set(['index.html', '.tkmindhints', '.goosehints', '.ls_output']);
|
||||||
const SKIP_ZONE_DIR_NAMES = new Set([
|
const SKIP_ZONE_DIR_NAMES = new Set([
|
||||||
@@ -186,10 +187,13 @@ export function createWorkspaceAssetSync({
|
|||||||
if (!space || space.status !== 'active') {
|
if (!space || space.status !== 'active') {
|
||||||
throw Object.assign(new Error('用户空间不可用'), { code: 'space_unavailable' });
|
throw Object.assign(new Error('用户空间不可用'), { code: 'space_unavailable' });
|
||||||
}
|
}
|
||||||
const available =
|
try {
|
||||||
asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes);
|
assertPageWriteQuota(space, buffer.length);
|
||||||
if (available < buffer.length) {
|
} catch (error) {
|
||||||
throw Object.assign(new Error('剩余空间不足'), { code: 'quota_exceeded' });
|
throw Object.assign(new Error(error.message), {
|
||||||
|
code: error.code ?? 'quota_exceeded',
|
||||||
|
details: error.details,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const detectedMimeType = assetInternals.detectMimeType(buffer, file.filename);
|
const detectedMimeType = assetInternals.detectMimeType(buffer, file.filename);
|
||||||
@@ -346,10 +350,15 @@ export function createWorkspaceAssetSync({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const sizeDelta = buffer.length - asNumber(currentAsset.size_bytes);
|
const sizeDelta = buffer.length - asNumber(currentAsset.size_bytes);
|
||||||
const available =
|
if (sizeDelta > 0) {
|
||||||
asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes);
|
try {
|
||||||
if (sizeDelta > 0 && available < sizeDelta) {
|
assertPageWriteQuota(space, sizeDelta);
|
||||||
throw Object.assign(new Error('剩余空间不足'), { code: 'quota_exceeded' });
|
} catch (error) {
|
||||||
|
throw Object.assign(new Error(error.message), {
|
||||||
|
code: error.code ?? 'quota_exceeded',
|
||||||
|
details: error.details,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const versionId = currentAsset.current_version_id;
|
const versionId = currentAsset.current_version_id;
|
||||||
if (!versionId) {
|
if (!versionId) {
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
UPLOAD_ZONE_CODES,
|
UPLOAD_ZONE_CODES,
|
||||||
} from './user-space.mjs';
|
} from './user-space.mjs';
|
||||||
|
import {
|
||||||
|
assertPageWriteQuotaForUser,
|
||||||
|
isMindSpacePageWriteRelativePath,
|
||||||
|
} from './mindspace-space-quota.mjs';
|
||||||
|
|
||||||
function workspaceToolError(message, code) {
|
function workspaceToolError(message, code) {
|
||||||
return Object.assign(new Error(message), { code });
|
return Object.assign(new Error(message), { code });
|
||||||
@@ -330,6 +334,9 @@ export function createMindSpaceWorkspaceToolService({
|
|||||||
maxBinaryWriteBytes = 8 * 1024 * 1024,
|
maxBinaryWriteBytes = 8 * 1024 * 1024,
|
||||||
maxGeneratedBinaryBytes =
|
maxGeneratedBinaryBytes =
|
||||||
40 * 1024 * 1024,
|
40 * 1024 * 1024,
|
||||||
|
assertPageWriteQuotaForUserFn =
|
||||||
|
assertPageWriteQuotaForUser,
|
||||||
|
getQuotaPool = null,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (!h5Root) {
|
if (!h5Root) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -395,6 +402,23 @@ export function createMindSpaceWorkspaceToolService({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ensurePageWriteAllowed = async ({
|
||||||
|
userId,
|
||||||
|
relativePath,
|
||||||
|
requiredBytes,
|
||||||
|
}) => {
|
||||||
|
if (
|
||||||
|
!getQuotaPool ||
|
||||||
|
typeof assertPageWriteQuotaForUserFn !== 'function' ||
|
||||||
|
!isMindSpacePageWriteRelativePath(relativePath)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pool = getQuotaPool();
|
||||||
|
if (!pool) return;
|
||||||
|
await assertPageWriteQuotaForUserFn(pool, userId, requiredBytes);
|
||||||
|
};
|
||||||
|
|
||||||
const registerWrite = async ({
|
const registerWrite = async ({
|
||||||
scope,
|
scope,
|
||||||
relativePath,
|
relativePath,
|
||||||
@@ -523,6 +547,11 @@ export function createMindSpaceWorkspaceToolService({
|
|||||||
content,
|
content,
|
||||||
{ relativePath },
|
{ relativePath },
|
||||||
);
|
);
|
||||||
|
await ensurePageWriteAllowed({
|
||||||
|
userId: scope.userId,
|
||||||
|
relativePath,
|
||||||
|
requiredBytes: sizeBytes,
|
||||||
|
});
|
||||||
const target =
|
const target =
|
||||||
await resolveWritableWorkspacePath(
|
await resolveWritableWorkspacePath(
|
||||||
scope.workspaceRoot,
|
scope.workspaceRoot,
|
||||||
@@ -620,6 +649,12 @@ export function createMindSpaceWorkspaceToolService({
|
|||||||
updated,
|
updated,
|
||||||
{ relativePath },
|
{ relativePath },
|
||||||
);
|
);
|
||||||
|
const originalSizeBytes = mimeSafeByteLength(original);
|
||||||
|
await ensurePageWriteAllowed({
|
||||||
|
userId: scope.userId,
|
||||||
|
relativePath,
|
||||||
|
requiredBytes: Math.max(0, sizeBytes - originalSizeBytes),
|
||||||
|
});
|
||||||
await fs.writeFile(target, updated, 'utf8');
|
await fs.writeFile(target, updated, 'utf8');
|
||||||
const registration = await registerWrite({
|
const registration = await registerWrite({
|
||||||
scope,
|
scope,
|
||||||
|
|||||||
@@ -18,3 +18,16 @@ test('page-data-collect skill pins deletion to the public client API', () => {
|
|||||||
assert.match(skillText, /禁止发明 `softDeleteRows`/);
|
assert.match(skillText, /禁止发明 `softDeleteRows`/);
|
||||||
assert.match(skillText, /表必须包含 `deleted_at TIMESTAMPTZ`/);
|
assert.match(skillText, /表必须包含 `deleted_at TIMESTAMPTZ`/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('page-data-collect skill documents page quota grace and delivery URLs', () => {
|
||||||
|
assert.match(skillText, /本次写完、下次再拦/);
|
||||||
|
assert.match(skillText, /127\.0\.0\.1:8081\/MindSpace/);
|
||||||
|
assert.match(skillText, /禁止.*本地服务/);
|
||||||
|
assert.match(skillText, /pageId.*UUID/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('page-data-collect skill requires independent table per delivery task', () => {
|
||||||
|
assert.match(skillText, /每个独立交付任务必须独立建表/);
|
||||||
|
assert.match(skillText, /禁止复用已有表/);
|
||||||
|
assert.match(skillText, /禁止.*为新的独立页面复用已有 dataset/);
|
||||||
|
});
|
||||||
|
|||||||
Executable
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="/Users/john/Project/Memind"
|
||||||
|
ENV_SCRIPT="${ROOT_DIR}/scripts/executor-env.mjs"
|
||||||
|
AIDER_BIN_DEFAULT="/Users/john/.local/bin/aider"
|
||||||
|
AIDER_BIN="${AIDER_CLI_BIN:-${AIDER_BIN:-${AIDER_BIN_DEFAULT}}}"
|
||||||
|
NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
|
||||||
|
|
||||||
|
if [[ -z "${NODE_BIN}" ]]; then
|
||||||
|
echo "node not found in PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -x "${AIDER_BIN}" ]]; then
|
||||||
|
echo "Aider CLI not executable: ${AIDER_BIN}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
eval "$("${NODE_BIN}" "${ENV_SCRIPT}" aider --shell)"
|
||||||
|
|
||||||
|
exec "${AIDER_BIN}" "$@"
|
||||||
@@ -78,17 +78,22 @@ try {
|
|||||||
codeHasCodeTools: grantedTools.includes('aider') || grantedTools.includes('openhands'),
|
codeHasCodeTools: grantedTools.includes('aider') || grantedTools.includes('openhands'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const roleCodeToolsDefault = ['1', 'true', 'yes', 'on'].includes(
|
||||||
|
String(process.env.MEMIND_ROLE_CODE_TOOLS_DEFAULT ?? '').trim().toLowerCase(),
|
||||||
|
);
|
||||||
|
const expectedRoleCodeTools = roleCodeToolsDefault;
|
||||||
const ok = Boolean(
|
const ok = Boolean(
|
||||||
roleDefaults.aider === false &&
|
roleDefaults.aider === expectedRoleCodeTools &&
|
||||||
roleDefaults.openhands === false &&
|
roleDefaults.openhands === expectedRoleCodeTools &&
|
||||||
sampledUsers.every((user) => user.chatHasCodeTools === false) &&
|
sampledUsers.every((user) => user.chatHasCodeTools === false) &&
|
||||||
sampledUsers.every((user) => user.codeHasCodeTools === true),
|
(roleCodeToolsDefault || sampledUsers.every((user) => user.codeHasCodeTools === true)),
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(JSON.stringify({
|
console.log(JSON.stringify({
|
||||||
ok,
|
ok,
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: new Date().toISOString(),
|
||||||
roleDefaults,
|
roleDefaults,
|
||||||
|
roleCodeToolsDefault,
|
||||||
whitelistUserCount: grantsByUser.size,
|
whitelistUserCount: grantsByUser.size,
|
||||||
sampledUsers,
|
sampledUsers,
|
||||||
runtimePolicy: {
|
runtimePolicy: {
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 本机代码工具能力修复:role 默认 aider/openhands + 可选 canary 用户白名单。
|
||||||
|
* 用法: MEMIND_ENV_FILE=.env node scripts/fix-openhands-local-link.mjs
|
||||||
|
*
|
||||||
|
* MEMIND_ROLE_CODE_TOOLS_DEFAULT=1 → role=user 默认开启 aider/openhands(本地开发)
|
||||||
|
* 未设置或为 0 → role=user 默认关闭(生产基线)
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { createDbPool } from '../db.mjs';
|
||||||
|
import { applyRoleSecurityBaseline } from '../security-baseline.mjs';
|
||||||
|
|
||||||
|
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const CANARY_USER_ID = process.env.LOCAL_OPENHANDS_CANARY_USER_ID
|
||||||
|
?? '32035858-9a20-425b-89da-c118ef0779aa';
|
||||||
|
const CODE_TOOLS = ['aider', 'openhands'];
|
||||||
|
|
||||||
|
function loadEnvFile(filePath) {
|
||||||
|
if (!fs.existsSync(filePath)) return;
|
||||||
|
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
const idx = trimmed.indexOf('=');
|
||||||
|
if (idx < 0) continue;
|
||||||
|
const key = trimmed.slice(0, idx).trim();
|
||||||
|
const value = trimmed.slice(idx + 1).trim();
|
||||||
|
if (!process.env[key]) process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleCodeToolsDefaultEnabled() {
|
||||||
|
return ['1', 'true', 'yes', 'on'].includes(
|
||||||
|
String(process.env.MEMIND_ROLE_CODE_TOOLS_DEFAULT ?? '').trim().toLowerCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadEnvFile(process.env.MEMIND_ENV_FILE || path.join(root, '.env'));
|
||||||
|
loadEnvFile(path.join(root, '.env.local'));
|
||||||
|
|
||||||
|
async function readRoleDefaults(pool) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT capability_key, allowed
|
||||||
|
FROM h5_capability_grants
|
||||||
|
WHERE subject_type = 'role'
|
||||||
|
AND subject_id = 'user'
|
||||||
|
AND capability_key IN ('aider', 'openhands')
|
||||||
|
ORDER BY capability_key`,
|
||||||
|
);
|
||||||
|
return Object.fromEntries(rows.map((row) => [row.capability_key, Boolean(row.allowed)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readUserGrants(pool, userId) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT capability_key, allowed
|
||||||
|
FROM h5_capability_grants
|
||||||
|
WHERE subject_type = 'user'
|
||||||
|
AND subject_id = ?
|
||||||
|
AND capability_key IN ('aider', 'openhands')
|
||||||
|
ORDER BY capability_key`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
return Object.fromEntries(rows.map((row) => [row.capability_key, Boolean(row.allowed)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function grantRoleTools(pool, role, tools, allowed) {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const key of tools) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at)
|
||||||
|
VALUES ('role', ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE allowed = VALUES(allowed), updated_at = VALUES(updated_at)`,
|
||||||
|
[role, key, allowed ? 1 : 0, now],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function grantUserTools(pool, userId, tools) {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const key of tools) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at)
|
||||||
|
VALUES ('user', ?, ?, 1, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE allowed = VALUES(allowed), updated_at = VALUES(updated_at)`,
|
||||||
|
[userId, key, now],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleCodeToolsDefault = roleCodeToolsDefaultEnabled();
|
||||||
|
const pool = createDbPool();
|
||||||
|
try {
|
||||||
|
const beforeRole = await readRoleDefaults(pool);
|
||||||
|
const beforeUser = await readUserGrants(pool, CANARY_USER_ID);
|
||||||
|
|
||||||
|
if (roleCodeToolsDefault) {
|
||||||
|
await grantRoleTools(pool, 'user', CODE_TOOLS, true);
|
||||||
|
} else {
|
||||||
|
await applyRoleSecurityBaseline(pool, 'user');
|
||||||
|
await grantUserTools(pool, CANARY_USER_ID, CODE_TOOLS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterRole = await readRoleDefaults(pool);
|
||||||
|
const afterUser = await readUserGrants(pool, CANARY_USER_ID);
|
||||||
|
const expectedRole = roleCodeToolsDefault;
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok: afterRole.aider === expectedRole
|
||||||
|
&& afterRole.openhands === expectedRole
|
||||||
|
&& (!roleCodeToolsDefault || CODE_TOOLS.every((key) => afterRole[key] === true)),
|
||||||
|
roleCodeToolsDefault,
|
||||||
|
canaryUserId: CANARY_USER_ID,
|
||||||
|
before: { role: beforeRole, user: beforeUser },
|
||||||
|
after: { role: afterRole, user: afterUser },
|
||||||
|
}, null, 2));
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
@@ -130,6 +130,16 @@ load_skill → page-data-collect
|
|||||||
|
|
||||||
### 2. 数据层:建表 + 注册 dataset
|
### 2. 数据层:建表 + 注册 dataset
|
||||||
|
|
||||||
|
**每个独立交付任务必须独立建表、独立 dataset,禁止复用已有表。**
|
||||||
|
|
||||||
|
| 场景 | 正确做法 |
|
||||||
|
|------|----------|
|
||||||
|
| 用户要做一个**新页面 / 新功能**(即使主题相似,如第二个日记本、另一个问卷) | 新建专用表 + 新 dataset 名,例如 `safe_diary_entries`、`children_hobby_survey_20260729` |
|
||||||
|
| 用户明确说「在**这个已有页面**里加一块表单」 | 才可复用该页已 bind 的 dataset |
|
||||||
|
| Agent 在 registry 里看到同名/同主题旧表 | **不得**直接拿来给新 HTML 用;必须新建 |
|
||||||
|
|
||||||
|
命名建议:`{页面slug}_entries` / `{页面slug}_responses`,表名与 dataset 名一致。两个页面即使业务相似(日记、台账、问卷),也必须是**两套** `{table, dataset, policy}`,避免字段约束、口令策略、软删除配置互相污染。
|
||||||
|
|
||||||
用 `private_data_execute` 建表(示例):
|
用 `private_data_execute` 建表(示例):
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
@@ -164,6 +174,25 @@ CREATE TABLE IF NOT EXISTS survey_responses (
|
|||||||
- PostgreSQL 连接错误应原样报告,不得把 `/tmp/.s.PGSQL.*`、`ECONNREFUSED` 或 `permission denied` 解释成“稍后会自动恢复”。
|
- PostgreSQL 连接错误应原样报告,不得把 `/tmp/.s.PGSQL.*`、`ECONNREFUSED` 或 `permission denied` 解释成“稍后会自动恢复”。
|
||||||
- dataset 配置了 `soft_delete` 时,表必须包含 `deleted_at TIMESTAMPTZ`;配置了 `own_rows` 时,表必须包含 policy 指定的所有者字段。
|
- dataset 配置了 `soft_delete` 时,表必须包含 `deleted_at TIMESTAMPTZ`;配置了 `own_rows` 时,表必须包含 policy 指定的所有者字段。
|
||||||
|
|
||||||
|
### 用户空间配额(页面写入 grace 策略)
|
||||||
|
|
||||||
|
MindSpace **页面 HTML**(`public/*.html`、`draft/*.html`)写入采用 **「本次写完、下次再拦」**:
|
||||||
|
|
||||||
|
| 状态 | 平台行为 |
|
||||||
|
|------|----------|
|
||||||
|
| 尚未超配额,但剩余空间 < 本次页面大小 | ✅ **允许写完**(grace write;本次 bind 必须继续完成) |
|
||||||
|
| 已经超配额(used ≥ quota) | ❌ **直接拦截** `write_file` / `edit_file` / bind / 发布 |
|
||||||
|
| 空间充足 | ✅ 正常写入 |
|
||||||
|
|
||||||
|
**Agent 必须遵守:**
|
||||||
|
|
||||||
|
- grace 只保证**当次**页面 HTML 能落盘并完成 bind;**不是**可以跳过 bind 或只写 policy 文件。
|
||||||
|
- bind 失败(含 `quota_exceeded` 且已超配额、`dataset_schema_mismatch`、PG 连接错误)时,**禁止**向用户交付「已就绪」链接;必须如实报告失败原因。
|
||||||
|
- 用户空间已满时,提示其在 MindSpace「我的空间」清理旧页面/资产,或购买/扩容后再继续写新页面。
|
||||||
|
- grace **不覆盖**图片/附件等非页面 HTML 资产上传;那些仍按原有配额硬拦截。
|
||||||
|
|
||||||
|
**禁止**只写 `.mindspace/page-data-policies/<slug>.json` 或使用非 UUID 的 `pageId` 冒充 bind 成功;policy 的 `pageId` 必须来自 `private_data_bind_workspace_page` 返回的真实 UUID,且数据库中存在对应 `h5_page_records`。
|
||||||
|
|
||||||
### 3. 页面层:写 HTML
|
### 3. 页面层:写 HTML
|
||||||
|
|
||||||
- 用 `write_file` / `edit_file` 写入或更新 `public/*.html`
|
- 用 `write_file` / `edit_file` 写入或更新 `public/*.html`
|
||||||
@@ -234,9 +263,18 @@ await client.deleteRow('dataset_name', rowId);
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
返回 `pageId` 与 **`workspaceUrl`**(`/MindSpace/<用户ID>/public/xxx.html`)。
|
返回 `pageId`(UUID)与 **`workspaceUrl` / `deliveryUrl`**(`/MindSpace/<用户ID>/public/xxx.html`)。
|
||||||
|
|
||||||
**交付时必须优先给用户 workspaceUrl**;`/u/用户名/pages/...` 仅作补充。bind 会同步工作区 HTML 到发布快照,但禁止先发布占位内容再补文件。
|
**交付时必须优先给用户真实可点击 URL**:
|
||||||
|
|
||||||
|
| 环境 | 链接前缀示例 |
|
||||||
|
|------|-------------|
|
||||||
|
| **本地开发** | `http://127.0.0.1:8081/MindSpace/<用户ID>/public/xxx.html`(Page Data API 走 Portal 8081;5173 仅 UI 预览) |
|
||||||
|
| **生产** | `https://m.tkmind.cn/MindSpace/<用户ID>/public/xxx.html` |
|
||||||
|
|
||||||
|
- **禁止**交付占位 host(如 `http://本地服务/...`);聊天展示层不得把 `127.0.0.1:8081` 替换成不可点击文字。
|
||||||
|
- `/u/用户名/pages/...` 仅作补充,用户可见主链接必须是 MindSpace `/public/` 路径。
|
||||||
|
- bind 会同步工作区 HTML 到发布快照,但禁止先发布占位内容再补文件。
|
||||||
|
|
||||||
**dataset 名称必须与 HTML 一致**:`private_data_register_dataset` 的 `name`、`private_data_bind_workspace_page` 的 `datasets` 键名、以及 HTML 里 `insertRow('...')` / `listRows('...')` 的字符串必须完全相同(例如都用 `tkmind_exp_survey`)。若不一致,提交会报「dataset 未授权 insert/read」。
|
**dataset 名称必须与 HTML 一致**:`private_data_register_dataset` 的 `name`、`private_data_bind_workspace_page` 的 `datasets` 键名、以及 HTML 里 `insertRow('...')` / `listRows('...')` 的字符串必须完全相同(例如都用 `tkmind_exp_survey`)。若不一致,提交会报「dataset 未授权 insert/read」。
|
||||||
|
|
||||||
@@ -293,17 +331,28 @@ await client.deleteRow('dataset_name', rowId);
|
|||||||
11. **禁止**未配置 `private_data_set_page_policy` 就让页面调用公开 API
|
11. **禁止**未配置 `private_data_set_page_policy` 就让页面调用公开 API
|
||||||
12. **禁止**先发布占位页(如 `<p>问卷页面</p>`)再让用户访问 `/u/.../pages/...`
|
12. **禁止**先发布占位页(如 `<p>问卷页面</p>`)再让用户访问 `/u/.../pages/...`
|
||||||
13. **禁止**在 Agent 生成的 HTML/JavaScript 中使用 `localStorage` / `sessionStorage` / `IndexedDB` 保存任何数据或做 API fallback;所有需持久化的数据必须进入当前用户专属 PostgreSQL schema,禁止 SQLite、静态 JSON/JS 文件和内存 fallback
|
13. **禁止**在 Agent 生成的 HTML/JavaScript 中使用 `localStorage` / `sessionStorage` / `IndexedDB` 保存任何数据或做 API fallback;所有需持久化的数据必须进入当前用户专属 PostgreSQL schema,禁止 SQLite、静态 JSON/JS 文件和内存 fallback
|
||||||
|
14. **禁止**在空间已满(已超配额)时继续 `write_file` / bind 新页面;必须先提示清理或扩容
|
||||||
|
15. **禁止**bind 未完成或 `pageId` 非 UUID 时声称 Page Data 页面已就绪;控制台出现 `pageId 未配置` 说明 bind/发布链路未闭环
|
||||||
|
16. **禁止**为新的独立页面复用已有 dataset / 表(例如第二个日记页继续用 `diary_entries`);除非用户明确要求改同一页面上的表单
|
||||||
|
|
||||||
## 交付前自检
|
## 交付前自检
|
||||||
|
|
||||||
0. 已选定分支 A~E,方案摘要已确认;`password` 页口令 ≥8 位且 bind 已传入
|
0. 已选定分支 A~E,方案摘要已确认;`password` 页口令 ≥8 位且 bind 已传入
|
||||||
1. `__page_data_datasets` 中存在对应 dataset
|
1. `private_data_register_dataset` 已注册;PostgreSQL 表字段与 policy 一致(含 `deleted_at` 等)
|
||||||
2. `.mindspace/page-data-policies/<pageId>.json` 已写入
|
2. `private_data_bind_workspace_page` **已成功**,返回 UUID 形式 `pageId` 与 `deliveryUrl`
|
||||||
3. HTML 含 `page-data-client.js`;已 bind 或发布后平台会注入 pageId
|
3. `.mindspace/page-data-policies/<pageId>.json` 已写入(文件名必须是 UUID,不是 slug)
|
||||||
4. HTML **不含** `127.0.0.1:`、`/api/survey/`、`PLACEHOLDER_PAGE_ID`
|
4. 通过 Portal 打开 `deliveryUrl` 时,页面源码含 `__MINDSPACE_PAGE_DATA__`
|
||||||
5. 向用户说明:访客如何提交、管理员如何用口令查看记录
|
5. HTML 含 `page-data-client.js`;已 bind 或发布后平台会注入 pageId
|
||||||
6. HTML 未调用 `softDeleteRows` / `deleteRows` 等客户端不存在的方法;删除使用 `deleteRow(dataset, rowId)`
|
6. HTML **不含**硬编码 `127.0.0.1:` 作为 API 基址;不含 `/api/survey/`、`PLACEHOLDER_PAGE_ID`
|
||||||
|
7. 本地交付链接使用 `http://127.0.0.1:8081/MindSpace/...`;生产使用 `https://m.tkmind.cn/MindSpace/...`;禁止 `http://本地服务/...`
|
||||||
|
8. 向用户说明:访客如何提交、管理员如何用口令查看记录
|
||||||
|
9. HTML 未调用 `softDeleteRows` / `deleteRows` 等客户端不存在的方法;删除使用 `deleteRow(dataset, rowId)`
|
||||||
|
10. 若 bind 报 `quota_exceeded` 且 `overQuota: true`,停止交付并提示清理空间;grace 仅适用于「当次写完、尚未超配额」场景
|
||||||
|
|
||||||
## 回复格式
|
## 回复格式
|
||||||
|
|
||||||
除数据能力外,优先返回 **workspaceUrl** 的 Markdown 链接 `[标题](workspaceUrl)`,并简要说明后台入口与口令(如有)。
|
除数据能力外,优先返回 **deliveryUrl / workspaceUrl** 的 Markdown 链接 `[标题](完整URL)`,并简要说明后台入口与口令(如有)。
|
||||||
|
|
||||||
|
- 本地:`http://127.0.0.1:8081/MindSpace/<用户ID>/public/xxx.html`
|
||||||
|
- 生产:`https://m.tkmind.cn/MindSpace/<用户ID>/public/xxx.html`
|
||||||
|
- 必须附带 bind 返回的 `pageId`(UUID),便于用户在「页面数据」面板排查
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
|
|||||||
12. **微信分享必须使用 MindSpace 路径**:该路由会在服务端注入 `og:site_name=TKMind 智趣`、封面缩略图(`*.thumbnail.png`)与微信 JS-SDK 分享桥;禁止交付 `/u/用户名/pages/...` 作为用户可见链接
|
12. **微信分享必须使用 MindSpace 路径**:该路由会在服务端注入 `og:site_name=TKMind 智趣`、封面缩略图(`*.thumbnail.png`)与微信 JS-SDK 分享桥;禁止交付 `/u/用户名/pages/...` 作为用户可见链接
|
||||||
13. **只要页面接收用户输入并要求以后查看、汇总、统计、修改或删除,就不是纯静态页**。禁止使用 `localStorage` / `sessionStorage` / `IndexedDB` 保存任何数据;必须立即 `load_skill` → `page-data-collect`,通过 Page Data API 写入当前用户专属 PostgreSQL schema。禁止 SQLite、静态 JSON/JS 文件或内存 fallback 冒充持久化。记账、日记、打卡、清单、台账、问卷、报名及其管理页都适用。
|
13. **只要页面接收用户输入并要求以后查看、汇总、统计、修改或删除,就不是纯静态页**。禁止使用 `localStorage` / `sessionStorage` / `IndexedDB` 保存任何数据;必须立即 `load_skill` → `page-data-collect`,通过 Page Data API 写入当前用户专属 PostgreSQL schema。禁止 SQLite、静态 JSON/JS 文件或内存 fallback 冒充持久化。记账、日记、打卡、清单、台账、问卷、报名及其管理页都适用。
|
||||||
14. 当编排提示为“图片策略:强制生成”或用户明确要求新图片、背景图、主图、封面、插画、照片时,必须先 `load_skill` → `image-generation`,由该技能补齐提示词并调用 `sandbox-fs__generate_image`;不得要求用户自己提供专业生图参数,不得用 SVG、CSS 绘图、旧素材或网页搜索图片冒充本次生成结果。
|
14. 当编排提示为“图片策略:强制生成”或用户明确要求新图片、背景图、主图、封面、插画、照片时,必须先 `load_skill` → `image-generation`,由该技能补齐提示词并调用 `sandbox-fs__generate_image`;不得要求用户自己提供专业生图参数,不得用 SVG、CSS 绘图、旧素材或网页搜索图片冒充本次生成结果。
|
||||||
|
15. **页面 HTML 空间配额**:尚未超配额时,平台允许当次页面写完(grace write);**已超配额**后 `write_file` / `edit_file` 会被直接拦截。空间已满时提示用户清理 MindSpace 旧页面/资产后再继续。
|
||||||
|
|
||||||
详细约束以工作区内的 `.goosehints` 与 `.agents/skills/static-page-publish/SKILL.md` 为准。
|
详细约束以工作区内的 `.goosehints` 与 `.agents/skills/static-page-publish/SKILL.md` 为准。
|
||||||
|
|
||||||
@@ -74,7 +75,10 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
|
|||||||
|
|
||||||
- **必须**使用 `[页面标题](完整URL)`,不要只给裸 URL 或「点这里」
|
- **必须**使用 `[页面标题](完整URL)`,不要只给裸 URL 或「点这里」
|
||||||
- 页面写入 `public/` 时,URL **必须**包含 `/public/` 路径段(与磁盘路径一致)
|
- 页面写入 `public/` 时,URL **必须**包含 `/public/` 路径段(与磁盘路径一致)
|
||||||
- 域名严格按本节模板拼接(`https://goo.tkmind.cn/MindSpace/<用户ID>/public/...`);拿不到真实前缀时,先给相对路径 `public/xxx.html` 说明,不要自己猜一个域名
|
- 域名严格按环境拼接,**禁止**使用 `http://本地服务/...` 等占位 host:
|
||||||
|
- **本地开发**:`http://127.0.0.1:8081/MindSpace/<用户ID>/public/...`(MindSpace 公开页 + Page Data API)
|
||||||
|
- **生产**:`https://m.tkmind.cn/MindSpace/<用户ID>/public/...` 或环境配置的 `H5_PUBLIC_BASE_URL`
|
||||||
|
- 拿不到真实前缀时,先给相对路径 `public/xxx.html` 说明,不要自己猜一个域名
|
||||||
- 标题用页面真实主题名
|
- 标题用页面真实主题名
|
||||||
- 可同时给出相对路径(如 `public/malaysia-travel-guide.html`)
|
- 可同时给出相对路径(如 `public/malaysia-travel-guide.html`)
|
||||||
- 说明:保存即生效,无需重启
|
- 说明:保存即生效,无需重启
|
||||||
|
|||||||
Reference in New Issue
Block a user