Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74150a30e3 |
@@ -65,22 +65,6 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
|
||||
# MEMIND_AGENT_RUN_WORKER_ID=<optional-instance-id>
|
||||
# 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 请求路径)。
|
||||
# 仅在独立 Orchestrator 已健康、service token 已配置后,先显式打开观察 wiring,
|
||||
# 再通过 memindadm 选择 shadow。关闭 wiring 时不查询编排配置、不排队、不传用户数据。
|
||||
@@ -347,10 +331,6 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind
|
||||
# MindSearch / standalone Deep Search
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
# TKMIND_SEARCH_SEARXNG_URL=http://127.0.0.1:20080/search
|
||||
# TKMIND_SEARCH_MCP_HOST_GATEWAY=host.docker.internal
|
||||
|
||||
@@ -130,25 +130,6 @@ export function scrubUserMessageImageAttachments(message) {
|
||||
};
|
||||
}
|
||||
|
||||
export function messageContentHasImageUrl(content) {
|
||||
if (!Array.isArray(content)) return false;
|
||||
return content.some((item) => item?.type === 'image_url' && item?.image_url?.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Any persisted image_url part will break DeepSeek / other text-only providers.
|
||||
* User metadata.imageUrls alone is not enough — Goose may have expanded them into
|
||||
* content parts on assistant or user turns.
|
||||
*/
|
||||
export function conversationHasImageUrlContent(conversation, { excludeMessageId = null } = {}) {
|
||||
if (!Array.isArray(conversation)) return false;
|
||||
const excluded = String(excludeMessageId ?? '').trim();
|
||||
return conversation.some((message) => {
|
||||
if (excluded && String(message?.id ?? '').trim() === excluded) return false;
|
||||
return messageContentHasImageUrl(message?.content);
|
||||
});
|
||||
}
|
||||
|
||||
export function scrubConversationHistoricalImageAttachments(conversation, activeMessageId) {
|
||||
const activeId = String(activeMessageId ?? '').trim();
|
||||
if (!Array.isArray(conversation) || !activeId) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildCurrentTurnImageScopeNote,
|
||||
conversationHasImageUrlContent,
|
||||
dedupeImageUrlsByAssetKey,
|
||||
extractCurrentTurnImageUrls,
|
||||
scrubConversationHistoricalImageAttachments,
|
||||
@@ -114,46 +113,3 @@ test('buildCurrentTurnImageScopeNote states one independent topic per upload', (
|
||||
assert.match(note, /不得与历史轮次混用/);
|
||||
assert.match(note, /asset=asset-9/);
|
||||
});
|
||||
|
||||
test('conversationHasImageUrlContent detects historical poison and ignores active turn', () => {
|
||||
const conversation = [
|
||||
{
|
||||
id: 'assistant-old',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: '看图' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/old.png' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'user-new',
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: '这是什么' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/new.png' } },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(conversationHasImageUrlContent(conversation), true);
|
||||
assert.equal(
|
||||
conversationHasImageUrlContent(conversation, { excludeMessageId: 'user-new' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
conversationHasImageUrlContent(
|
||||
[
|
||||
{
|
||||
id: 'user-new',
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: '这是什么' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/new.png' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
{ excludeMessageId: 'user-new' },
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -164,7 +164,7 @@ test('buildAutoChatSkillPrefix routes an uploaded xlsx only when Excel Analyst i
|
||||
|
||||
test('manifest enhanced search route is opt-in and uses the MindSearch skill prompt', () => {
|
||||
const routes = [{ skillName: 'search-enhanced', promptKey: 'search-enhanced', keywords: ['最新资料'], priority: 30 }];
|
||||
assert.match(buildAutoChatSkillPrefix('请搜索最新资料:Goose MCP', ['search-enhanced'], { skillRouterV2: true, manifestRoutes: routes }), /tkmind_search/);
|
||||
assert.match(buildAutoChatSkillPrefix('请搜索最新资料:Goose MCP', ['search-enhanced'], { skillRouterV2: true, manifestRoutes: routes }), /tkmind-search/);
|
||||
assert.equal(buildAutoChatSkillPrefix('请搜索最新资料:Goose MCP', [], { skillRouterV2: true, manifestRoutes: routes }), '');
|
||||
});
|
||||
|
||||
|
||||
@@ -32,17 +32,8 @@ export function stripMindSpaceContextPrefix(text) {
|
||||
return next;
|
||||
}
|
||||
|
||||
/** MindSpace 交付链接:生产域名与本地 dev(127.0.0.1 / localhost)都必须可点击,不可被架构脱敏替换。 */
|
||||
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 ASSISTANT_DELIVERABLE_RE =
|
||||
/\[[^\]]+\]\(https?:\/\/[^)]+\)|https?:\/\/(?:m\.)?[^/\s]*tkmind\.(?:cn|ai)\/MindSpace\//i;
|
||||
|
||||
const INTERNAL_ASSISTANT_MARKERS = [
|
||||
/data-mindspace-page-tag/i,
|
||||
@@ -65,8 +56,7 @@ export const USER_FACING_ARCHITECTURE_TERM_REPLACEMENTS = Object.freeze([
|
||||
[/\b(?:stdio\s*)?mcp\b|\btkmind-search\b|\bsandbox-fs\b/gi, '工具扩展'],
|
||||
[/\bopenhands\b|\baider\b|\blitellm\b/gi, '代码助手'],
|
||||
[/\borchestrator\b|\bmemindadm\b|\bportal\b(?=\s*(?:api|server|runtime|8081|8085))/gi, '后台服务'],
|
||||
// 仅脱敏非交付用途的 localhost;MindSpace 公开页链接(…/MindSpace/…)必须保留真实 host:port。
|
||||
[/host\.docker\.internal|(?:127\.0\.0\.1|localhost):\d{4,5}(?!\/MindSpace\/)/gi, '本地服务'],
|
||||
[/host\.docker\.internal|127\.0\.0\.1:\d{4,5}/gi, '本地服务'],
|
||||
[/platform\/web|web_search|fetch_url|tkmind_search|tkmind_read/gi, '联网搜索'],
|
||||
[/\bpostgres(?:ql)?\b|\bredis\b|\bmysql\b|\bweaviate\b/gi, '数据服务'],
|
||||
]);
|
||||
|
||||
@@ -89,22 +89,3 @@ test('deriveAssistantFacingText keeps user-facing replies with public links', ()
|
||||
'页面已生成:[AI 机器人研究报告 2026](https://m.tkmind.cn/MindSpace/john/public/ai-robot-report.html)';
|
||||
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);
|
||||
});
|
||||
|
||||
test('deriveAssistantFacingText still redacts non-deliverable localhost references', () => {
|
||||
const internal =
|
||||
'Portal 后台服务监听在 127.0.0.1:8081,请稍后再试。';
|
||||
assert.equal(
|
||||
deriveAssistantFacingText(internal),
|
||||
'Portal 后台服务监听在 本地服务,请稍后再试。',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
# 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 / 路由策略。
|
||||
>
|
||||
> 原则:安装阶段不改动现有 Goose 服务,不影响当前生产/测试链路。
|
||||
|
||||
@@ -7,16 +7,6 @@ Page Data 页面只有在下面所有条件满足后才可向用户交付链接
|
||||
3. page record、online publication 与 policy 使用同一个真实 page UUID。
|
||||
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 或字段权限。
|
||||
|
||||
运行时路径必须按语义区分:
|
||||
|
||||
@@ -128,7 +128,6 @@ export function createMindSpaceLocalRuntimeServices({
|
||||
syncWorkspaceAssets,
|
||||
conversationArtifactService,
|
||||
workspacePageDeliveryService,
|
||||
getQuotaPool: () => pool,
|
||||
});
|
||||
const cleanupService = createCleanupService(pool, {
|
||||
storageRoot: runtime.storageRoot,
|
||||
|
||||
+7
-5
@@ -22,7 +22,6 @@ import { prepareHtmlPageBrandMarkers } from './mindspace-page-tag.mjs';
|
||||
import { purgeWorkspacePageArtifacts, extractAssetIdsFromHtml } from './mindspace-page-purge.mjs';
|
||||
import { ensureWorkspaceHtmlThumbnail, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
|
||||
import { upsertMindspaceCoverMeta } from './mindspace-cover-meta.mjs';
|
||||
import { assertPageWriteQuota } from './mindspace-space-quota.mjs';
|
||||
|
||||
const MAX_TITLE_LENGTH = 255;
|
||||
const MAX_SUMMARY_LENGTH = 1000;
|
||||
@@ -394,10 +393,13 @@ export function createPageService(pool, options = {}) {
|
||||
if (!space || space.status !== 'active') {
|
||||
throw pageError('用户空间不可用', 'space_unavailable');
|
||||
}
|
||||
try {
|
||||
assertPageWriteQuota(space, normalized.contentBytes);
|
||||
} catch (error) {
|
||||
throw pageError(error.message, error.code ?? 'quota_exceeded', error.details);
|
||||
const available =
|
||||
asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes);
|
||||
if (available < normalized.contentBytes) {
|
||||
throw pageError('剩余空间不足', 'quota_exceeded', {
|
||||
requiredBytes: normalized.contentBytes,
|
||||
availableBytes: Math.max(0, available),
|
||||
});
|
||||
}
|
||||
const [categories] = await conn.query(
|
||||
`SELECT id, category_code FROM h5_space_categories
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
inferWorkspaceHtmlRelativePath,
|
||||
} from './mindspace-html-download-links.mjs';
|
||||
import { createImgproxySigner } from './imgproxy-signer.mjs';
|
||||
import { assertPageWriteQuota } from './mindspace-space-quota.mjs';
|
||||
|
||||
const SCANNER_VERSION = 'mindspace-content-v1';
|
||||
const PRIVATE_ASSET_DOWNLOAD_URL_PATTERN =
|
||||
@@ -724,10 +723,15 @@ export function createPublicationService(pool, options = {}) {
|
||||
WHERE id = ? AND user_id = ? FOR UPDATE`,
|
||||
[page.space_id, userId],
|
||||
);
|
||||
try {
|
||||
assertPageWriteQuota(spaces[0], htmlBytes);
|
||||
} catch (error) {
|
||||
throw publicationError(error.message, error.code ?? 'quota_exceeded', error.details);
|
||||
const available =
|
||||
Number(spaces[0]?.quota_bytes ?? 0) -
|
||||
Number(spaces[0]?.used_bytes ?? 0) -
|
||||
Number(spaces[0]?.reserved_bytes ?? 0);
|
||||
if (available < htmlBytes) {
|
||||
throw publicationError('剩余空间不足', 'quota_exceeded', {
|
||||
requiredBytes: htmlBytes,
|
||||
availableBytes: Math.max(0, available),
|
||||
});
|
||||
}
|
||||
const [publicationUsage] = await conn.query(
|
||||
`SELECT COUNT(DISTINCT page_id) AS public_page_used,
|
||||
|
||||
@@ -7,7 +7,6 @@ const BLOCKED_ACTIVE_PATTERNS = [
|
||||
const SCRIPT_TAG_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
|
||||
const SCRIPT_SRC_PATTERN = /\bsrc\s*=\s*(['"])([^'"]+)\1/i;
|
||||
const TRUSTED_SCRIPT_SRC_PATTERNS = [
|
||||
/^\/assets\/page-data-client\.js(?:[?#].*)?$/i,
|
||||
/^\/assets\/chart\.umd\.min\.js(?:[?#].*)?$/i,
|
||||
/^https:\/\/cdn\.jsdelivr\.net\/npm\/chart\.js(?:@[^/]+)?\/dist\/chart\.(?:umd\.)?min\.js(?:[?#].*)?$/i,
|
||||
/^https:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/Chart\.js\/[^/]+\/chart\.(?:umd\.)?min\.js(?:[?#].*)?$/i,
|
||||
|
||||
@@ -35,21 +35,6 @@ test('runBasicFileScan warns for sandboxed html with inline script and trusted C
|
||||
assert.deepEqual(result.findings, ['trusted_html_active_content']);
|
||||
});
|
||||
|
||||
test('runBasicFileScan warns for sandboxed html with page-data-client and inline script', () => {
|
||||
const result = runBasicFileScan(
|
||||
Buffer.from(`<!doctype html>
|
||||
<script src="/assets/page-data-client.js"></script>
|
||||
<script>MindSpacePageData.createClient({ apiBase: "/api" });</script>`),
|
||||
{
|
||||
filename: 'survey.html',
|
||||
mimeType: 'text/html',
|
||||
htmlActiveContentPolicy: 'sandbox_warn',
|
||||
},
|
||||
);
|
||||
assert.equal(result.scanStatus, 'warned');
|
||||
assert.deepEqual(result.findings, ['trusted_html_active_content']);
|
||||
});
|
||||
|
||||
test('runBasicFileScan still blocks unsafe html active content in sandbox mode', () => {
|
||||
const javascriptUrl = runBasicFileScan(Buffer.from('<a href="javascript:alert(1)">go</a>'), {
|
||||
filename: 'dashboard.html',
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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,7 +11,6 @@ import { resolveAssetWorkspaceRelativePath } from './mindspace-workspace-path.mj
|
||||
import { assetInternals } from './mindspace-assets.mjs';
|
||||
import { runBasicFileScan } from './mindspace-scan.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_ZONE_DIR_NAMES = new Set([
|
||||
@@ -187,13 +186,10 @@ export function createWorkspaceAssetSync({
|
||||
if (!space || space.status !== 'active') {
|
||||
throw Object.assign(new Error('用户空间不可用'), { code: 'space_unavailable' });
|
||||
}
|
||||
try {
|
||||
assertPageWriteQuota(space, buffer.length);
|
||||
} catch (error) {
|
||||
throw Object.assign(new Error(error.message), {
|
||||
code: error.code ?? 'quota_exceeded',
|
||||
details: error.details,
|
||||
});
|
||||
const available =
|
||||
asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes);
|
||||
if (available < buffer.length) {
|
||||
throw Object.assign(new Error('剩余空间不足'), { code: 'quota_exceeded' });
|
||||
}
|
||||
|
||||
const detectedMimeType = assetInternals.detectMimeType(buffer, file.filename);
|
||||
@@ -350,15 +346,10 @@ export function createWorkspaceAssetSync({
|
||||
};
|
||||
}
|
||||
const sizeDelta = buffer.length - asNumber(currentAsset.size_bytes);
|
||||
if (sizeDelta > 0) {
|
||||
try {
|
||||
assertPageWriteQuota(space, sizeDelta);
|
||||
} catch (error) {
|
||||
throw Object.assign(new Error(error.message), {
|
||||
code: error.code ?? 'quota_exceeded',
|
||||
details: error.details,
|
||||
});
|
||||
}
|
||||
const available =
|
||||
asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes);
|
||||
if (sizeDelta > 0 && available < sizeDelta) {
|
||||
throw Object.assign(new Error('剩余空间不足'), { code: 'quota_exceeded' });
|
||||
}
|
||||
const versionId = currentAsset.current_version_id;
|
||||
if (!versionId) {
|
||||
|
||||
@@ -13,10 +13,6 @@ import {
|
||||
import {
|
||||
UPLOAD_ZONE_CODES,
|
||||
} from './user-space.mjs';
|
||||
import {
|
||||
assertPageWriteQuotaForUser,
|
||||
isMindSpacePageWriteRelativePath,
|
||||
} from './mindspace-space-quota.mjs';
|
||||
|
||||
function workspaceToolError(message, code) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
@@ -334,9 +330,6 @@ export function createMindSpaceWorkspaceToolService({
|
||||
maxBinaryWriteBytes = 8 * 1024 * 1024,
|
||||
maxGeneratedBinaryBytes =
|
||||
40 * 1024 * 1024,
|
||||
assertPageWriteQuotaForUserFn =
|
||||
assertPageWriteQuotaForUser,
|
||||
getQuotaPool = null,
|
||||
} = {}) {
|
||||
if (!h5Root) {
|
||||
throw new Error(
|
||||
@@ -402,23 +395,6 @@ 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 ({
|
||||
scope,
|
||||
relativePath,
|
||||
@@ -547,11 +523,6 @@ export function createMindSpaceWorkspaceToolService({
|
||||
content,
|
||||
{ relativePath },
|
||||
);
|
||||
await ensurePageWriteAllowed({
|
||||
userId: scope.userId,
|
||||
relativePath,
|
||||
requiredBytes: sizeBytes,
|
||||
});
|
||||
const target =
|
||||
await resolveWritableWorkspacePath(
|
||||
scope.workspaceRoot,
|
||||
@@ -649,12 +620,6 @@ export function createMindSpaceWorkspaceToolService({
|
||||
updated,
|
||||
{ relativePath },
|
||||
);
|
||||
const originalSizeBytes = mimeSafeByteLength(original);
|
||||
await ensurePageWriteAllowed({
|
||||
userId: scope.userId,
|
||||
relativePath,
|
||||
requiredBytes: Math.max(0, sizeBytes - originalSizeBytes),
|
||||
});
|
||||
await fs.writeFile(target, updated, 'utf8');
|
||||
const registration = await registerWrite({
|
||||
scope,
|
||||
|
||||
@@ -88,9 +88,6 @@ export function createPageDataBrowserClient({
|
||||
async listRows(dataset, query = {}) {
|
||||
return request('GET', buildPageDataPublicPath(apiBase, pageId, dataset), { query });
|
||||
},
|
||||
async readRows(dataset, query = {}) {
|
||||
return request('GET', buildPageDataPublicPath(apiBase, pageId, dataset), { query });
|
||||
},
|
||||
async getSchema(dataset) {
|
||||
return request('GET', buildPageDataPublicPath(apiBase, pageId, dataset, 'schema'));
|
||||
},
|
||||
|
||||
@@ -18,16 +18,3 @@ test('page-data-collect skill pins deletion to the public client API', () => {
|
||||
assert.match(skillText, /禁止发明 `softDeleteRows`/);
|
||||
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/);
|
||||
});
|
||||
|
||||
@@ -106,9 +106,6 @@
|
||||
listRows: function (dataset, query) {
|
||||
return request('GET', buildDataPath(apiBase, pageId, dataset), { query: query || {} });
|
||||
},
|
||||
readRows: function (dataset, query) {
|
||||
return request('GET', buildDataPath(apiBase, pageId, dataset), { query: query || {} });
|
||||
},
|
||||
getSchema: function (dataset) {
|
||||
return request('GET', buildDataPath(apiBase, pageId, dataset, 'schema'));
|
||||
},
|
||||
|
||||
@@ -56,7 +56,6 @@ test('Gate, artifact, candidate routing and rollback share one compatibility con
|
||||
const [
|
||||
builder,
|
||||
localStack,
|
||||
stableRunner,
|
||||
candidateRunner,
|
||||
compatRunner,
|
||||
canaryRelease,
|
||||
@@ -64,7 +63,6 @@ test('Gate, artifact, candidate routing and rollback share one compatibility con
|
||||
] = await Promise.all([
|
||||
fs.readFile(path.join(ROOT, 'scripts', 'build-portal-runtime.mjs'), 'utf8'),
|
||||
fs.readFile(path.join(ROOT, 'release-gate', 'local-stack.mjs'), 'utf8'),
|
||||
fs.readFile(path.join(ROOT, 'scripts', 'run-memind-portal-prod.sh'), 'utf8'),
|
||||
fs.readFile(path.join(ROOT, 'scripts', 'run-memind-portal-candidate.sh'), 'utf8'),
|
||||
fs.readFile(
|
||||
path.join(ROOT, 'scripts', 'run-deepseek-compat-proxy-candidate.sh'),
|
||||
@@ -79,8 +77,6 @@ test('Gate, artifact, candidate routing and rollback share one compatibility con
|
||||
localStack,
|
||||
/path\.join\(resolvedPortalRoot, 'deepseek-no-think-proxy\.mjs'\)/,
|
||||
);
|
||||
assert.match(stableRunner, /export MEMIND_DEEPSEEK_DISABLE_THINKING="\$\{MEMIND_DEEPSEEK_DISABLE_THINKING:-1\}"/);
|
||||
assert.match(stableRunner, /export MEMIND_GOOSED_HOST_GATEWAY="\$\{MEMIND_GOOSED_HOST_GATEWAY:-host\.docker\.internal\}"/);
|
||||
assert.match(candidateRunner, /export MEMIND_DEEPSEEK_DISABLE_THINKING=1/);
|
||||
assert.match(candidateRunner, /export MEMIND_GOOSED_HOST_GATEWAY=host\.docker\.internal/);
|
||||
assert.match(compatRunner, /source "\$\{STABLE_ROOT\}\/\.env"/);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
|
||||
import mysql from 'mysql2/promise';
|
||||
@@ -14,37 +13,6 @@ import { assertNonProductionTarget } from './safety.mjs';
|
||||
const { Client: PgClient } = pg;
|
||||
const SAFE_DB_NAME = /^[a-z][a-z0-9_]{5,62}$/;
|
||||
|
||||
const ISOLATED_GATE_REMOTE_ENV_KEYS = [
|
||||
'MINDSPACE_REMOTE_BASE_URL',
|
||||
'MINDSPACE_REMOTE_AUTH_TOKEN',
|
||||
'MINDSPACE_MCP_BASE_URL',
|
||||
'MINDSPACE_MCP_TOKEN_SECRET',
|
||||
];
|
||||
|
||||
/**
|
||||
* Release gate stacks must not inherit split-service MindSpace MCP routing from
|
||||
* the developer .env. Scoped MCP tokens would target the standalone 8082 service
|
||||
* and resolve workspace paths against the host H5 root instead of the isolated
|
||||
* gate sandbox, causing sandbox-fs write_file/publish_page ENOENT failures.
|
||||
*/
|
||||
export function sanitizeIsolatedGatePortalEnv(
|
||||
env,
|
||||
{ port, runtimeProfile = 'local' } = {},
|
||||
) {
|
||||
const sanitized = { ...env };
|
||||
for (const key of ISOLATED_GATE_REMOTE_ENV_KEYS) {
|
||||
delete sanitized[key];
|
||||
}
|
||||
sanitized.MEMIND_RUNTIME_PROFILE = runtimeProfile;
|
||||
sanitized.MINDSPACE_SERVER_ADAPTER = 'local';
|
||||
if (port != null) {
|
||||
const portalBase = `http://127.0.0.1:${port}`;
|
||||
sanitized.H5_PORTAL_BASE_URL = portalBase;
|
||||
sanitized.MINDSPACE_AGENT_API_BASE_URL = `${portalBase}/api`;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function assertLoopbackHost(host, label) {
|
||||
const normalized = String(host ?? '').toLowerCase();
|
||||
if (!['localhost', '127.0.0.1', '::1', '/tmp'].includes(normalized)) {
|
||||
@@ -205,44 +173,14 @@ export async function selectBackendLlmProvider({
|
||||
}
|
||||
}
|
||||
|
||||
export function assertGatePortAvailable(port, host = '127.0.0.1') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', (error) => {
|
||||
if (error?.code === 'EADDRINUSE') {
|
||||
reject(new Error(
|
||||
`Release gate port ${host}:${port} is already in use; stop the stale local gate process before retrying`,
|
||||
));
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
server.once('listening', () => {
|
||||
server.close((closeError) => {
|
||||
if (closeError) reject(closeError);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
server.listen(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForPortal(baseUrl, child, timeoutMs = 60_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) throw new Error(`isolated Portal exited with code ${child.exitCode}`);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/auth/status`, { signal: AbortSignal.timeout(1_000) });
|
||||
if (response.ok) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`isolated Portal exited with code ${child.exitCode} after health check`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('isolated Portal exited')) {
|
||||
throw error;
|
||||
}
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// Startup can take several seconds while the isolated schema is initialized.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
@@ -250,42 +188,6 @@ async function waitForPortal(baseUrl, child, timeoutMs = 60_000) {
|
||||
throw new Error(`isolated Portal did not become ready: ${baseUrl}`);
|
||||
}
|
||||
|
||||
export async function grantGateUserSkillsByUsername({
|
||||
targetUrl,
|
||||
username,
|
||||
skillNames,
|
||||
}) {
|
||||
assertNonProductionTarget(targetUrl, 'isolated gate database');
|
||||
const normalizedUsername = String(username ?? '').trim();
|
||||
const skills = [...new Set(
|
||||
(Array.isArray(skillNames) ? skillNames : [])
|
||||
.map((name) => String(name ?? '').trim())
|
||||
.filter(Boolean),
|
||||
)];
|
||||
if (!normalizedUsername || skills.length === 0) return { userId: null, granted: [] };
|
||||
const target = await mysql.createConnection(targetUrl);
|
||||
try {
|
||||
const [rows] = await target.query(
|
||||
'SELECT id FROM h5_users WHERE username = ? LIMIT 1',
|
||||
[normalizedUsername],
|
||||
);
|
||||
const userId = rows[0]?.id ?? null;
|
||||
if (!userId) throw new Error(`Release gate user not found for skill grant: ${normalizedUsername}`);
|
||||
const now = Date.now();
|
||||
for (const skillName of skills) {
|
||||
await target.execute(
|
||||
`INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at)
|
||||
VALUES ('user', ?, ?, 1, ?)
|
||||
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`,
|
||||
[userId, skillName, now],
|
||||
);
|
||||
}
|
||||
return { userId, granted: skills };
|
||||
} finally {
|
||||
await target.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHttpHealth(baseUrl, child, label, timeoutMs = 30_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -475,7 +377,6 @@ export async function createLocalGateStack({
|
||||
async function startPortal() {
|
||||
if (!childEnv || !baseUrl) throw new Error('isolated Portal environment is not initialized');
|
||||
if (child && child.exitCode === null) throw new Error('isolated Portal is already running');
|
||||
await assertGatePortAvailable(port);
|
||||
deepseekProxyLogFd = fs.openSync(deepseekProxyLogPath, 'a');
|
||||
deepseekProxyChild = spawn(
|
||||
process.execPath,
|
||||
@@ -507,10 +408,10 @@ export async function createLocalGateStack({
|
||||
try {
|
||||
mysqlUrl = await createMysqlDatabase(baseEnv.DATABASE_URL, mysqlDatabase);
|
||||
pgUrl = await createPgDatabase(baseEnv.MINDSPACE_USERDATA_PG_URL, pgDatabase);
|
||||
childEnv = sanitizeIsolatedGatePortalEnv(baseEnv, { port, runtimeProfile });
|
||||
childEnv = {
|
||||
...childEnv,
|
||||
...baseEnv,
|
||||
NODE_ENV: nodeEnv,
|
||||
...(runtimeProfile ? { MEMIND_RUNTIME_PROFILE: runtimeProfile } : {}),
|
||||
H5_HOST: '127.0.0.1',
|
||||
H5_PORT: String(port),
|
||||
H5_PUBLIC_BASE_URL: `http://127.0.0.1:${port}`,
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import net from 'node:net';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
assertGatePortAvailable,
|
||||
buildContainerPgConnectionString,
|
||||
buildPgConnectionString,
|
||||
makeIsolatedDatabaseName,
|
||||
sanitizeIsolatedGatePortalEnv,
|
||||
} from './local-stack.mjs';
|
||||
|
||||
test('isolated database names are bounded and identifier-safe', () => {
|
||||
@@ -44,40 +41,3 @@ test('container PostgreSQL DSN keeps credentials and host while isolating databa
|
||||
test('isolated database names reject empty or unsafe prefixes', () => {
|
||||
assert.throws(() => makeIsolatedDatabaseName('abc', '../bad'), /Unsafe/);
|
||||
});
|
||||
|
||||
test('sanitizeIsolatedGatePortalEnv drops split-service MCP routing', () => {
|
||||
const sanitized = sanitizeIsolatedGatePortalEnv({
|
||||
MINDSPACE_SERVER_ADAPTER: 'remote',
|
||||
MINDSPACE_REMOTE_BASE_URL: 'http://127.0.0.1:8082',
|
||||
MINDSPACE_REMOTE_AUTH_TOKEN: 'local-dev-secret',
|
||||
MINDSPACE_MCP_BASE_URL: 'http://127.0.0.1:8082',
|
||||
MINDSPACE_MCP_TOKEN_SECRET: 'local-dev-secret',
|
||||
MINDSPACE_AGENT_API_BASE_URL: 'http://127.0.0.1:8081/api',
|
||||
}, { port: 19087, runtimeProfile: 'local' });
|
||||
|
||||
assert.equal(sanitized.MINDSPACE_SERVER_ADAPTER, 'local');
|
||||
assert.equal(sanitized.MEMIND_RUNTIME_PROFILE, 'local');
|
||||
assert.equal(sanitized.H5_PORTAL_BASE_URL, 'http://127.0.0.1:19087');
|
||||
assert.equal(sanitized.MINDSPACE_AGENT_API_BASE_URL, 'http://127.0.0.1:19087/api');
|
||||
assert.equal(sanitized.MINDSPACE_REMOTE_BASE_URL, undefined);
|
||||
assert.equal(sanitized.MINDSPACE_MCP_BASE_URL, undefined);
|
||||
assert.equal(sanitized.MINDSPACE_MCP_TOKEN_SECRET, undefined);
|
||||
});
|
||||
|
||||
test('assertGatePortAvailable rejects occupied loopback ports', async () => {
|
||||
const server = net.createServer();
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
await assert.rejects(
|
||||
() => assertGatePortAvailable(address.port),
|
||||
/already in use/,
|
||||
);
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
await assertGatePortAvailable(address.port);
|
||||
});
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/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,22 +78,17 @@ try {
|
||||
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(
|
||||
roleDefaults.aider === expectedRoleCodeTools &&
|
||||
roleDefaults.openhands === expectedRoleCodeTools &&
|
||||
roleDefaults.aider === false &&
|
||||
roleDefaults.openhands === false &&
|
||||
sampledUsers.every((user) => user.chatHasCodeTools === false) &&
|
||||
(roleCodeToolsDefault || sampledUsers.every((user) => user.codeHasCodeTools === true)),
|
||||
sampledUsers.every((user) => user.codeHasCodeTools === true),
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok,
|
||||
checkedAt: new Date().toISOString(),
|
||||
roleDefaults,
|
||||
roleCodeToolsDefault,
|
||||
whitelistUserCount: grantsByUser.size,
|
||||
sampledUsers,
|
||||
runtimePolicy: {
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Standalone recovery: point every stable goosed target at the 18036 no-think proxy.
|
||||
* Works on bundled 103 runtime (no db.mjs / llm-providers.mjs required).
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
|
||||
const GOOSED_PROVIDER_ID = 'custom_memind_deepseek_no_think';
|
||||
const DEFAULT_MODEL = 'deepseek-v4-pro';
|
||||
const MODELS = ['deepseek-v4-pro', 'deepseek-v4-flash'];
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const insecureDispatcher = new Agent({ connect: { rejectUnauthorized: false } });
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
const env = {};
|
||||
if (!fs.existsSync(filePath)) return env;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
let value = trimmed.slice(eq + 1).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"'))
|
||||
|| (value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
const fileEnv = loadEnvFile(path.join(root, '.env'));
|
||||
const env = { ...fileEnv, ...process.env };
|
||||
const apiSecret = String(env.TKMIND_SERVER__SECRET_KEY ?? '').trim();
|
||||
const apiKey = String(env.DEEPSEEK_API_KEY ?? '').trim();
|
||||
const apiTargets = String(
|
||||
env.TKMIND_API_TARGETS
|
||||
?? env.TKMIND_API_TARGET
|
||||
?? 'https://127.0.0.1:18006',
|
||||
)
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!apiSecret) {
|
||||
console.error('missing TKMIND_SERVER__SECRET_KEY');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!apiKey) {
|
||||
console.error('missing DEEPSEEK_API_KEY');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const host = String(env.MEMIND_GOOSED_HOST_GATEWAY ?? 'host.docker.internal').trim() || 'host.docker.internal';
|
||||
const port = Number(env.MEMIND_DEEPSEEK_NO_THINK_PORT ?? 18036);
|
||||
const proxyBaseUrl = `http://${host}:${port}/v1`;
|
||||
|
||||
async function goosedFetch(apiTarget, pathname, init = {}) {
|
||||
const url = new URL(pathname, apiTarget);
|
||||
const headers = {
|
||||
...(init.headers ?? {}),
|
||||
'X-Secret-Key': apiSecret,
|
||||
};
|
||||
if (init.body && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
const dispatcher = apiTarget.startsWith('https://') ? insecureDispatcher : undefined;
|
||||
return undiciFetch(url, { ...init, headers, dispatcher });
|
||||
}
|
||||
|
||||
async function upsertConfig(apiTarget, key, value, isSecret = false) {
|
||||
const res = await goosedFetch(apiTarget, '/config/upsert', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key, value, is_secret: isSecret }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`upsert ${key} on ${apiTarget} failed: ${res.status} ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertProvider(apiTarget) {
|
||||
const body = {
|
||||
engine: 'openai',
|
||||
display_name: 'memind_deepseek_no_think',
|
||||
api_url: proxyBaseUrl,
|
||||
api_key: apiKey,
|
||||
models: MODELS,
|
||||
supports_streaming: true,
|
||||
requires_auth: true,
|
||||
preserves_thinking: false,
|
||||
};
|
||||
|
||||
let res = await goosedFetch(
|
||||
apiTarget,
|
||||
`/config/custom-providers/${encodeURIComponent(GOOSED_PROVIDER_ID)}`,
|
||||
{ method: 'PUT', body: JSON.stringify(body) },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
const missing = res.status === 404 || /provider not found/i.test(text);
|
||||
if (!missing) {
|
||||
throw new Error(`upsert provider on ${apiTarget} failed: ${res.status} ${text}`);
|
||||
}
|
||||
res = await goosedFetch(apiTarget, '/config/custom-providers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`create provider on ${apiTarget} failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
await upsertConfig(apiTarget, 'DEEPSEEK_API_KEY', apiKey, true);
|
||||
for (const [key, value] of [
|
||||
['GOOSE_PROVIDER', GOOSED_PROVIDER_ID],
|
||||
['GOOSE_MODEL', DEFAULT_MODEL],
|
||||
['TKMIND_PROVIDER', GOOSED_PROVIDER_ID],
|
||||
['TKMIND_MODEL', DEFAULT_MODEL],
|
||||
['GOOSE_THINKING_EFFORT', 'off'],
|
||||
]) {
|
||||
await upsertConfig(apiTarget, key, value, false);
|
||||
}
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const target of apiTargets) {
|
||||
await upsertProvider(target);
|
||||
results.push({ target, provider: GOOSED_PROVIDER_ID, model: DEFAULT_MODEL, proxyBaseUrl });
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ ok: true, results }, null, 2));
|
||||
@@ -1,119 +0,0 @@
|
||||
#!/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();
|
||||
}
|
||||
@@ -26,12 +26,6 @@ fi
|
||||
export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-${NODE_BIN}}"
|
||||
export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-${ROOT}/mindspace-sandbox-mcp.mjs}"
|
||||
|
||||
# DeepSeek V4 tool rounds require reasoning_content replay; stable goosed must use the
|
||||
# host no-think compat proxy (see docs/发包必看.md §4.7).
|
||||
export MEMIND_DEEPSEEK_DISABLE_THINKING="${MEMIND_DEEPSEEK_DISABLE_THINKING:-1}"
|
||||
export MEMIND_DEEPSEEK_NO_THINK_PORT="${MEMIND_DEEPSEEK_NO_THINK_PORT:-18036}"
|
||||
export MEMIND_GOOSED_HOST_GATEWAY="${MEMIND_GOOSED_HOST_GATEWAY:-host.docker.internal}"
|
||||
|
||||
free_port_if_stale_memind_listener() {
|
||||
local port="${H5_PORT:-8081}"
|
||||
local pids
|
||||
|
||||
@@ -4,7 +4,6 @@ import path from 'node:path';
|
||||
|
||||
import {
|
||||
createLocalGateStack,
|
||||
grantGateUserSkillsByUsername,
|
||||
selectBackendLlmProvider,
|
||||
seedSelectedProviderKeys,
|
||||
} from '../release-gate/local-stack.mjs';
|
||||
@@ -17,12 +16,8 @@ const scenarioIds = [
|
||||
|
||||
async function runScenario(scenarioId, port) {
|
||||
const childTimeoutMs = Math.max(
|
||||
60_000,
|
||||
Number(process.env.RELEASE_GATE_SCENARIO_PROCESS_TIMEOUT_MS ?? 900_000) || 900_000,
|
||||
);
|
||||
const stepTimeoutMs = Math.max(
|
||||
60_000,
|
||||
Number(process.env.RELEASE_GATE_SCENARIO_TIMEOUT_MS ?? 900_000) || 900_000,
|
||||
30_000,
|
||||
Number(process.env.RELEASE_GATE_SCENARIO_PROCESS_TIMEOUT_MS ?? 360_000) || 360_000,
|
||||
);
|
||||
const code = await new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
@@ -33,7 +28,9 @@ async function runScenario(scenarioId, port) {
|
||||
env: {
|
||||
...process.env,
|
||||
JOHN_PASSWORD: '888888',
|
||||
RELEASE_GATE_SCENARIO_TIMEOUT_MS: String(stepTimeoutMs),
|
||||
RELEASE_GATE_SCENARIO_TIMEOUT_MS: String(
|
||||
Number(process.env.RELEASE_GATE_SCENARIO_TIMEOUT_MS ?? 300_000) || 300_000,
|
||||
),
|
||||
},
|
||||
stdio: 'inherit',
|
||||
},
|
||||
@@ -80,18 +77,10 @@ async function register(username) {
|
||||
|
||||
try {
|
||||
await register('john2');
|
||||
await grantGateUserSkillsByUsername({
|
||||
targetUrl: stack.mysqlUrl,
|
||||
username: 'john2',
|
||||
skillNames: ['static-page-publish'],
|
||||
});
|
||||
const results = [];
|
||||
for (const scenarioId of scenarioIds) {
|
||||
results.push({
|
||||
scenarioId,
|
||||
code: await runScenario(scenarioId, new URL(stack.baseUrl).port),
|
||||
});
|
||||
}
|
||||
const results = await Promise.all(scenarioIds.map(async (scenarioId) => ({
|
||||
scenarioId,
|
||||
code: await runScenario(scenarioId, new URL(stack.baseUrl).port),
|
||||
})));
|
||||
for (const result of results) {
|
||||
console.log(`${result.code === 0 ? 'PASS' : 'FAIL'} ${result.scenarioId}`);
|
||||
}
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Direct goosed page smoke on TKMIND_API_TARGET (default https://127.0.0.1:18006).
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Agent, fetch } from 'undici';
|
||||
|
||||
import { buildChatSkillPrompt } from '../chat-skills.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile(path.join(root, '.env'));
|
||||
|
||||
const secret = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
|
||||
const base = process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006';
|
||||
const workingDir = path.resolve(
|
||||
process.argv[2] ?? path.join(root, '.release-gate/local/goosed-direct-page'),
|
||||
);
|
||||
const targetHtml = process.argv[3] ?? 'public/suzhou-goosed-direct.html';
|
||||
const provider = process.argv[4] ?? process.env.GOOSED_PAGE_TEST_PROVIDER ?? 'custom_tkmind_relay_deepseek';
|
||||
const model = process.argv[5] ?? process.env.GOOSED_PAGE_TEST_MODEL ?? 'deepseek-chat';
|
||||
const timeoutMs = Number(process.env.GOOSED_PAGE_TEST_TIMEOUT_MS ?? 600_000);
|
||||
|
||||
const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
|
||||
|
||||
async function apiFetch(pathname, init = {}) {
|
||||
const headers = {
|
||||
...(init.headers ?? {}),
|
||||
'X-Secret-Key': secret,
|
||||
};
|
||||
if (init.body && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
return fetch(`${base}${pathname}`, {
|
||||
...init,
|
||||
headers,
|
||||
dispatcher,
|
||||
});
|
||||
}
|
||||
|
||||
async function apiJson(pathname, body) {
|
||||
const response = await apiFetch(pathname, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw new Error(`${pathname} ${response.status}: ${text.slice(0, 800)}`);
|
||||
if (!text.trim()) return {};
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
function messageVisibleText(message) {
|
||||
return (message?.content ?? [])
|
||||
.filter((item) => item?.type === 'text')
|
||||
.map((item) => String(item.text ?? ''))
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function executeSessionReply(sessionId, requestId, prompt) {
|
||||
const eventsResponse = await apiFetch(`/sessions/${sessionId}/events`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
});
|
||||
if (!eventsResponse.ok || !eventsResponse.body) {
|
||||
const text = await eventsResponse.text().catch(() => '');
|
||||
throw new Error(text || '无法建立 goosed 事件流');
|
||||
}
|
||||
|
||||
const replyResponse = await apiFetch(`/sessions/${sessionId}/reply`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
request_id: requestId,
|
||||
user_message: {
|
||||
role: 'user',
|
||||
created: Date.now(),
|
||||
content: [{ type: 'text', text: prompt }],
|
||||
metadata: { userVisible: true, agentVisible: true, displayText: prompt },
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!replyResponse.ok) {
|
||||
const text = await replyResponse.text().catch(() => '');
|
||||
throw new Error(text || 'reply 失败');
|
||||
}
|
||||
replyResponse.body?.cancel?.();
|
||||
|
||||
const reader = Readable.fromWeb(eventsResponse.body);
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let messages = [];
|
||||
let finishSeen = false;
|
||||
let errorText = '';
|
||||
|
||||
const pushMessage = (list, message) => {
|
||||
const index = list.findIndex((item) => item.id === message.id);
|
||||
if (index >= 0) {
|
||||
const next = [...list];
|
||||
next[index] = message;
|
||||
return next;
|
||||
}
|
||||
return [...list, message];
|
||||
};
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for await (const chunk of reader) {
|
||||
if (Date.now() > deadline) throw new Error(`goosed 事件流超时 ${timeoutMs}ms`);
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
const frames = buffer.split('\n\n');
|
||||
buffer = frames.pop() ?? '';
|
||||
for (const frame of frames) {
|
||||
let data = '';
|
||||
for (const line of frame.split('\n')) {
|
||||
if (line.startsWith('data:')) data += line.slice(5).trim();
|
||||
}
|
||||
if (!data) continue;
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const routingId = event.chat_request_id ?? event.request_id;
|
||||
if (routingId && routingId !== requestId) continue;
|
||||
|
||||
if (event.type === 'Message' && event.message?.metadata?.userVisible !== false) {
|
||||
messages = pushMessage(messages, event.message);
|
||||
} else if (event.type === 'UpdateConversation') {
|
||||
messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible !== false);
|
||||
} else if (event.type === 'Error') {
|
||||
errorText = String(event.error ?? event.message ?? 'goose 执行失败');
|
||||
throw new Error(errorText);
|
||||
} else if (event.type === 'Finish') {
|
||||
finishSeen = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (finishSeen) break;
|
||||
}
|
||||
|
||||
const assistantTexts = messages
|
||||
.filter((item) => item.role === 'assistant')
|
||||
.map((item) => messageVisibleText(item))
|
||||
.filter(Boolean);
|
||||
return {
|
||||
finishSeen,
|
||||
combined: assistantTexts.join('\n\n').trim(),
|
||||
toolCalls: messages.flatMap((item) => (item.content ?? [])
|
||||
.filter((part) => part?.type === 'toolRequest' || part?.type === 'toolResponse')
|
||||
.map((part) => ({
|
||||
role: item.role,
|
||||
type: part.type,
|
||||
name: part.toolCall?.value?.name ?? part.toolResponse?.value?.name ?? part.name ?? null,
|
||||
}))),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(path.join(workingDir, 'public'), { recursive: true });
|
||||
console.log(`goosed base: ${base}`);
|
||||
console.log(`working_dir: ${workingDir}`);
|
||||
console.log(`target: ${targetHtml}`);
|
||||
console.log(`provider: ${provider} / ${model}`);
|
||||
|
||||
const start = await apiJson('/agent/start', { working_dir: workingDir });
|
||||
const sessionId = start.id;
|
||||
console.log(`session: ${sessionId}`);
|
||||
|
||||
await apiJson('/agent/update_provider', {
|
||||
session_id: sessionId,
|
||||
provider,
|
||||
model,
|
||||
});
|
||||
|
||||
const skillPrefix = buildChatSkillPrompt('generate-page', 'static-page-publish');
|
||||
const userText = `${skillPrefix}请帮我做一个全新的苏州一日游攻略页面,保存为 ${targetHtml},不要修改或复用已有页面,做完直接给我链接。`;
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
const reply = await executeSessionReply(sessionId, requestId, userText);
|
||||
const htmlPath = path.join(workingDir, targetHtml);
|
||||
const htmlExists = fs.existsSync(htmlPath);
|
||||
const htmlSize = htmlExists ? fs.statSync(htmlPath).size : 0;
|
||||
|
||||
console.log('\n=== result ===');
|
||||
console.log(`finish_seen: ${reply.finishSeen}`);
|
||||
console.log(`html_exists: ${htmlExists}`);
|
||||
console.log(`html_bytes: ${htmlSize}`);
|
||||
console.log(`tool_calls: ${reply.toolCalls.length}`);
|
||||
for (const call of reply.toolCalls.slice(-12)) {
|
||||
console.log(` - ${call.role} ${call.type} ${call.name ?? ''}`);
|
||||
}
|
||||
if (reply.combined) {
|
||||
console.log('\n--- assistant excerpt ---');
|
||||
console.log(reply.combined.slice(-1500));
|
||||
}
|
||||
|
||||
if (!htmlExists || htmlSize <= 0) {
|
||||
console.error('\nFAIL: goosed did not materialize target HTML in working_dir');
|
||||
process.exit(1);
|
||||
}
|
||||
const html = fs.readFileSync(htmlPath, 'utf8');
|
||||
if (!/苏州/u.test(html)) {
|
||||
console.error('\nFAIL: generated HTML missing expected keyword 苏州');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\nPASS: goosed wrote deliverable HTML on 18006');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack ?? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -130,16 +130,6 @@ load_skill → page-data-collect
|
||||
|
||||
### 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` 建表(示例):
|
||||
|
||||
```sql
|
||||
@@ -174,25 +164,6 @@ CREATE TABLE IF NOT EXISTS survey_responses (
|
||||
- PostgreSQL 连接错误应原样报告,不得把 `/tmp/.s.PGSQL.*`、`ECONNREFUSED` 或 `permission denied` 解释成“稍后会自动恢复”。
|
||||
- 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
|
||||
|
||||
- 用 `write_file` / `edit_file` 写入或更新 `public/*.html`
|
||||
@@ -263,18 +234,9 @@ await client.deleteRow('dataset_name', rowId);
|
||||
}
|
||||
```
|
||||
|
||||
返回 `pageId`(UUID)与 **`workspaceUrl` / `deliveryUrl`**(`/MindSpace/<用户ID>/public/xxx.html`)。
|
||||
返回 `pageId` 与 **`workspaceUrl`**(`/MindSpace/<用户ID>/public/xxx.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 到发布快照,但禁止先发布占位内容再补文件。
|
||||
**交付时必须优先给用户 workspaceUrl**;`/u/用户名/pages/...` 仅作补充。bind 会同步工作区 HTML 到发布快照,但禁止先发布占位内容再补文件。
|
||||
|
||||
**dataset 名称必须与 HTML 一致**:`private_data_register_dataset` 的 `name`、`private_data_bind_workspace_page` 的 `datasets` 键名、以及 HTML 里 `insertRow('...')` / `listRows('...')` 的字符串必须完全相同(例如都用 `tkmind_exp_survey`)。若不一致,提交会报「dataset 未授权 insert/read」。
|
||||
|
||||
@@ -331,28 +293,17 @@ await client.deleteRow('dataset_name', rowId);
|
||||
11. **禁止**未配置 `private_data_set_page_policy` 就让页面调用公开 API
|
||||
12. **禁止**先发布占位页(如 `<p>问卷页面</p>`)再让用户访问 `/u/.../pages/...`
|
||||
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 已传入
|
||||
1. `private_data_register_dataset` 已注册;PostgreSQL 表字段与 policy 一致(含 `deleted_at` 等)
|
||||
2. `private_data_bind_workspace_page` **已成功**,返回 UUID 形式 `pageId` 与 `deliveryUrl`
|
||||
3. `.mindspace/page-data-policies/<pageId>.json` 已写入(文件名必须是 UUID,不是 slug)
|
||||
4. 通过 Portal 打开 `deliveryUrl` 时,页面源码含 `__MINDSPACE_PAGE_DATA__`
|
||||
5. HTML 含 `page-data-client.js`;已 bind 或发布后平台会注入 pageId
|
||||
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 仅适用于「当次写完、尚未超配额」场景
|
||||
1. `__page_data_datasets` 中存在对应 dataset
|
||||
2. `.mindspace/page-data-policies/<pageId>.json` 已写入
|
||||
3. HTML 含 `page-data-client.js`;已 bind 或发布后平台会注入 pageId
|
||||
4. HTML **不含** `127.0.0.1:`、`/api/survey/`、`PLACEHOLDER_PAGE_ID`
|
||||
5. 向用户说明:访客如何提交、管理员如何用口令查看记录
|
||||
6. HTML 未调用 `softDeleteRows` / `deleteRows` 等客户端不存在的方法;删除使用 `deleteRow(dataset, rowId)`
|
||||
|
||||
## 回复格式
|
||||
|
||||
除数据能力外,优先返回 **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),便于用户在「页面数据」面板排查
|
||||
除数据能力外,优先返回 **workspaceUrl** 的 Markdown 链接 `[标题](workspaceUrl)`,并简要说明后台入口与口令(如有)。
|
||||
|
||||
@@ -29,7 +29,6 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
|
||||
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 冒充持久化。记账、日记、打卡、清单、台账、问卷、报名及其管理页都适用。
|
||||
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` 为准。
|
||||
|
||||
@@ -75,10 +74,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
|
||||
|
||||
- **必须**使用 `[页面标题](完整URL)`,不要只给裸 URL 或「点这里」
|
||||
- 页面写入 `public/` 时,URL **必须**包含 `/public/` 路径段(与磁盘路径一致)
|
||||
- 域名严格按环境拼接,**禁止**使用 `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` 说明,不要自己猜一个域名
|
||||
- 域名严格按本节模板拼接(`https://goo.tkmind.cn/MindSpace/<用户ID>/public/...`);拿不到真实前缀时,先给相对路径 `public/xxx.html` 说明,不要自己猜一个域名
|
||||
- 标题用页面真实主题名
|
||||
- 可同时给出相对路径(如 `public/malaysia-travel-guide.html`)
|
||||
- 说明:保存即生效,无需重启
|
||||
|
||||
@@ -140,35 +140,3 @@ test('buildVisionPayload does not mark billable usage when vision analysis fails
|
||||
assert.equal(result?.billableImageCount, 0);
|
||||
assert.doesNotMatch(result?.userMessage?.content?.[0]?.text ?? '', /Qwen VL 图片描述/);
|
||||
});
|
||||
|
||||
test('buildVisionPayload strips image_url content parts for text-only Goose providers', async () => {
|
||||
const result = await buildVisionPayload({
|
||||
userId: 'user-1',
|
||||
publishLayout: { publicUrl: 'https://m.tkmind.cn/MindSpace/user-1' },
|
||||
userMessage: {
|
||||
content: [
|
||||
{ type: 'text', text: '这是什么' },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: '/api/mindspace/v1/assets/asset-7/download?inline=1' },
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
imageUrls: ['/api/mindspace/v1/assets/asset-7/download?inline=1'],
|
||||
},
|
||||
},
|
||||
localFetchAsset: async () => ({
|
||||
buffer: Buffer.from('fake-image'),
|
||||
mimeType: 'image/png',
|
||||
}),
|
||||
llmProviderService: {
|
||||
analyzeImagesWithVision: async () => '蓝色方块',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
(result?.userMessage?.content ?? []).some((item) => item?.type === 'image_url'),
|
||||
false,
|
||||
);
|
||||
assert.match(result?.userMessage?.content?.[0]?.text ?? '', /蓝色方块/);
|
||||
});
|
||||
|
||||
+7
-53
@@ -34,7 +34,6 @@ import {
|
||||
import { extractAttachmentText } from './mindspace-attachment-text.mjs';
|
||||
import {
|
||||
buildCurrentTurnImageScopeNote,
|
||||
conversationHasImageUrlContent,
|
||||
extractCurrentTurnImageUrls,
|
||||
scrubConversationHistoricalImageAttachments,
|
||||
} from './chat-image-turn-scope.mjs';
|
||||
@@ -975,9 +974,6 @@ export async function buildVisionPayload({
|
||||
'不要向用户展示 HTML 代码块。';
|
||||
|
||||
let updatedContent = Array.isArray(userMessage?.content) ? [...userMessage.content] : [];
|
||||
// Text-only Goose providers cannot accept image_url parts. After VL analysis,
|
||||
// keep only text (with the injected vision note) for the agent turn.
|
||||
updatedContent = updatedContent.filter((item) => item?.type !== 'image_url');
|
||||
for (const item of imageItems) {
|
||||
updatedContent = updatedContent.map((c) => {
|
||||
if (c?.type !== 'text' || typeof c.text !== 'string') return c;
|
||||
@@ -1702,59 +1698,27 @@ export function createTkmindProxy({
|
||||
return { changed: false, updated: false, status: upstream.status };
|
||||
}
|
||||
const session = await upstream.json().catch(() => null);
|
||||
const conversation = Array.isArray(session?.conversation) ? session.conversation : [];
|
||||
const hasImageUrlContent = conversationHasImageUrlContent(conversation, {
|
||||
excludeMessageId: activeId,
|
||||
});
|
||||
const { conversation: scrubbedConversation, changed } = scrubConversationHistoricalImageAttachments(
|
||||
conversation,
|
||||
const { conversation, changed } = scrubConversationHistoricalImageAttachments(
|
||||
session?.conversation ?? [],
|
||||
activeId,
|
||||
);
|
||||
// Text-only providers (DeepSeek) reject any lingering image_url parts. If Goose
|
||||
// cannot persist a scrub (405/404), callers must rotate to a fresh session.
|
||||
if (!changed && !hasImageUrlContent) {
|
||||
return { changed: false, updated: false, hasImageUrlContent: false };
|
||||
}
|
||||
if (!changed && hasImageUrlContent) {
|
||||
return {
|
||||
changed: true,
|
||||
updated: false,
|
||||
status: 'image_url_content_present',
|
||||
hasImageUrlContent: true,
|
||||
};
|
||||
}
|
||||
if (!changed) return { changed: false, updated: false };
|
||||
const update = await apiFetch(
|
||||
target,
|
||||
apiSecret,
|
||||
`/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ conversation: scrubbedConversation }),
|
||||
body: JSON.stringify({ conversation }),
|
||||
},
|
||||
);
|
||||
if (!update.ok) {
|
||||
console.warn(
|
||||
`Historical image scrub skipped for session ${sessionId}: upstream ${update.status}`,
|
||||
);
|
||||
return {
|
||||
changed: true,
|
||||
updated: false,
|
||||
status: update.status,
|
||||
hasImageUrlContent:
|
||||
hasImageUrlContent
|
||||
|| conversationHasImageUrlContent(scrubbedConversation, {
|
||||
excludeMessageId: activeId,
|
||||
}),
|
||||
};
|
||||
return { changed: true, updated: false, status: update.status };
|
||||
}
|
||||
return {
|
||||
changed: true,
|
||||
updated: true,
|
||||
status: update.status,
|
||||
hasImageUrlContent: conversationHasImageUrlContent(scrubbedConversation, {
|
||||
excludeMessageId: activeId,
|
||||
}),
|
||||
};
|
||||
return { changed: true, updated: true, status: update.status };
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'Historical image scrub skipped:',
|
||||
@@ -1974,17 +1938,7 @@ export function createTkmindProxy({
|
||||
if (!user) throw new Error('用户不存在');
|
||||
if (requireHistoricalImageIsolation || messageHasImages(userMessage)) {
|
||||
const imageIsolation = await syncHistoricalImageTurnIsolation(sessionId, userMessage?.id);
|
||||
const scrubUnsupported =
|
||||
imageIsolation.changed
|
||||
&& !imageIsolation.updated
|
||||
&& (
|
||||
requireHistoricalImageIsolation
|
||||
|| imageIsolation.hasImageUrlContent
|
||||
|| Number(imageIsolation.status) === 404
|
||||
|| Number(imageIsolation.status) === 405
|
||||
|| imageIsolation.status === 'image_url_content_present'
|
||||
);
|
||||
if (scrubUnsupported) {
|
||||
if (requireHistoricalImageIsolation && imageIsolation.changed && !imageIsolation.updated) {
|
||||
const error = new Error(
|
||||
`historical_image_session_update_unsupported:${imageIsolation.status ?? 'unknown'}`,
|
||||
);
|
||||
|
||||
@@ -1534,58 +1534,6 @@ test('submitSessionReplyForUser fails closed when historical image scrub is unsu
|
||||
});
|
||||
});
|
||||
|
||||
test('submitSessionReplyForUser rotates when assistant history still has image_url', async () => {
|
||||
await withFakeGoosedSession(async ({ apiTarget, workingDir, replyBodies }) => {
|
||||
const proxy = createTkmindProxy({
|
||||
apiTarget,
|
||||
apiSecret: 'test-secret',
|
||||
userAuth: {
|
||||
...createMemoryTestUserAuth(workingDir),
|
||||
async ownsSession() {
|
||||
return true;
|
||||
},
|
||||
async canUseChat() {
|
||||
return { ok: true };
|
||||
},
|
||||
async getUserById() {
|
||||
return { id: 'user-1' };
|
||||
},
|
||||
async resolveUserPolicies() {
|
||||
return { unrestricted: true, policies: {} };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
proxy.submitSessionReplyForUser(
|
||||
'user-1',
|
||||
'session-1',
|
||||
'request-after-assistant-image',
|
||||
{
|
||||
id: 'message-current',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '这张图是什么' }],
|
||||
metadata: { imageUrls: ['https://example.com/new.png'] },
|
||||
},
|
||||
{ requireHistoricalImageIsolation: true },
|
||||
),
|
||||
/historical_image_session_update_unsupported:image_url_content_present/,
|
||||
);
|
||||
assert.equal(replyBodies.length, 0);
|
||||
}, {
|
||||
conversation: [
|
||||
{
|
||||
id: 'assistant-old-image',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: '我看到了图片' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/old.png' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('visual fallback session removes read_image while preserving the text task path', async () => {
|
||||
await withFakeGoosedSession(async ({
|
||||
apiTarget,
|
||||
|
||||
+5
-6
@@ -3288,8 +3288,8 @@ test('wechat mp service forwards H5 agent text when page generation produced no
|
||||
assert.equal(fs.existsSync(htmlPath), false);
|
||||
const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
|
||||
const payload = JSON.parse(sendCall[2]);
|
||||
assert.doesNotMatch(payload.text.content, /🌴 泰国简易攻略/);
|
||||
assert.match(payload.text.content, /没有按服务号页面技能真正生成成功/);
|
||||
assert.match(payload.text.content, /🌴 泰国简易攻略/);
|
||||
assert.doesNotMatch(payload.text.content, /没有按 H5 里的页面技能真正生成成功/);
|
||||
assert.doesNotMatch(payload.text.content, /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/thailand-guide\.html/);
|
||||
});
|
||||
|
||||
@@ -3631,8 +3631,7 @@ test('wechat mp service recreates dedicated session when tool_calls error arrive
|
||||
assert.equal(started, true);
|
||||
const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
|
||||
const payload = JSON.parse(sendCall[2]);
|
||||
assert.doesNotMatch(payload.text.content, /已恢复,可以继续对话/);
|
||||
assert.match(payload.text.content, /没有按服务号页面技能真正生成成功/);
|
||||
assert.match(payload.text.content, /已恢复,可以继续对话/);
|
||||
assert.doesNotMatch(payload.text.content, /Bad request/);
|
||||
assert.doesNotMatch(payload.text.content, /tool_calls/);
|
||||
});
|
||||
@@ -3966,8 +3965,8 @@ test('wechat mp service retries poisoned publish claims before forwarding H5 ret
|
||||
const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
|
||||
const payload = JSON.parse(sendCall[2]);
|
||||
assert.doesNotMatch(payload.text.content, /页面都已经成功发布了|主题页面已发布/);
|
||||
assert.doesNotMatch(payload.text.content, /🌴 夏日主题页面/);
|
||||
assert.match(payload.text.content, /没有按服务号页面技能真正生成成功/);
|
||||
assert.match(payload.text.content, /🌴 夏日主题页面/);
|
||||
assert.doesNotMatch(payload.text.content, /没有按 H5 里的页面技能真正生成成功/);
|
||||
assert.doesNotMatch(payload.text.content, /summer-breeze-journal\.html/);
|
||||
});
|
||||
|
||||
|
||||
@@ -69,11 +69,5 @@ export function resolvePageGenerateOutcome({
|
||||
};
|
||||
}
|
||||
|
||||
// Fail closed: page.generate must deliver a verified public HTML artifact.
|
||||
// Text-only planning replies ("我先搜索…") must not mark WeChat delivery done.
|
||||
return {
|
||||
action: 'fail',
|
||||
failureText: buildPagePublishFailureText(),
|
||||
reason: 'missing_page_artifact',
|
||||
};
|
||||
return { action: 'send', artifacts: sendable };
|
||||
}
|
||||
|
||||
@@ -218,29 +218,3 @@ test('resolvePageGenerateOutcome sends when share preview meta is present', () =
|
||||
assert.equal(outcome.action, 'send');
|
||||
assert.equal(outcome.artifacts.length, 1);
|
||||
});
|
||||
|
||||
test('resolvePageGenerateOutcome fails closed on planning text without html artifact', () => {
|
||||
const outcome = resolvePageGenerateOutcome({
|
||||
reply: {
|
||||
text: '找到了之前的新闻页面。让我先读取最新的页面格式作为参考,同时并行搜索今日新闻和天气。',
|
||||
},
|
||||
confirmedArtifacts: [],
|
||||
verifiedArtifacts: [],
|
||||
});
|
||||
assert.equal(outcome.action, 'fail');
|
||||
assert.equal(outcome.reason, 'missing_page_artifact');
|
||||
assert.match(outcome.failureText, /没有按服务号页面技能真正生成成功|static-page-publish/);
|
||||
});
|
||||
|
||||
test('resolvePageGenerateOutcome fails closed when reply links an old page but no artifact confirmed', () => {
|
||||
const outcome = resolvePageGenerateOutcome({
|
||||
reply: {
|
||||
text: '[每日新闻](https://m.tkmind.cn/MindSpace/u/public/daily-news-0728.html)',
|
||||
},
|
||||
confirmedArtifacts: [],
|
||||
verifiedArtifacts: [],
|
||||
replyHasPublicLinks: true,
|
||||
});
|
||||
assert.equal(outcome.action, 'fail');
|
||||
assert.equal(outcome.reason, 'missing_page_artifact');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user