Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df5bcd87b5 |
@@ -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) {
|
export function scrubConversationHistoricalImageAttachments(conversation, activeMessageId) {
|
||||||
const activeId = String(activeMessageId ?? '').trim();
|
const activeId = String(activeMessageId ?? '').trim();
|
||||||
if (!Array.isArray(conversation) || !activeId) {
|
if (!Array.isArray(conversation) || !activeId) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
buildCurrentTurnImageScopeNote,
|
buildCurrentTurnImageScopeNote,
|
||||||
conversationHasImageUrlContent,
|
|
||||||
dedupeImageUrlsByAssetKey,
|
dedupeImageUrlsByAssetKey,
|
||||||
extractCurrentTurnImageUrls,
|
extractCurrentTurnImageUrls,
|
||||||
scrubConversationHistoricalImageAttachments,
|
scrubConversationHistoricalImageAttachments,
|
||||||
@@ -114,46 +113,3 @@ test('buildCurrentTurnImageScopeNote states one independent topic per upload', (
|
|||||||
assert.match(note, /不得与历史轮次混用/);
|
assert.match(note, /不得与历史轮次混用/);
|
||||||
assert.match(note, /asset=asset-9/);
|
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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ const BLOCKED_ACTIVE_PATTERNS = [
|
|||||||
const SCRIPT_TAG_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
|
const SCRIPT_TAG_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
|
||||||
const SCRIPT_SRC_PATTERN = /\bsrc\s*=\s*(['"])([^'"]+)\1/i;
|
const SCRIPT_SRC_PATTERN = /\bsrc\s*=\s*(['"])([^'"]+)\1/i;
|
||||||
const TRUSTED_SCRIPT_SRC_PATTERNS = [
|
const TRUSTED_SCRIPT_SRC_PATTERNS = [
|
||||||
/^\/assets\/page-data-client\.js(?:[?#].*)?$/i,
|
|
||||||
/^\/assets\/chart\.umd\.min\.js(?:[?#].*)?$/i,
|
/^\/assets\/chart\.umd\.min\.js(?:[?#].*)?$/i,
|
||||||
/^https:\/\/cdn\.jsdelivr\.net\/npm\/chart\.js(?:@[^/]+)?\/dist\/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,
|
/^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']);
|
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', () => {
|
test('runBasicFileScan still blocks unsafe html active content in sandbox mode', () => {
|
||||||
const javascriptUrl = runBasicFileScan(Buffer.from('<a href="javascript:alert(1)">go</a>'), {
|
const javascriptUrl = runBasicFileScan(Buffer.from('<a href="javascript:alert(1)">go</a>'), {
|
||||||
filename: 'dashboard.html',
|
filename: 'dashboard.html',
|
||||||
|
|||||||
@@ -88,9 +88,6 @@ export function createPageDataBrowserClient({
|
|||||||
async listRows(dataset, query = {}) {
|
async listRows(dataset, query = {}) {
|
||||||
return request('GET', buildPageDataPublicPath(apiBase, pageId, 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) {
|
async getSchema(dataset) {
|
||||||
return request('GET', buildPageDataPublicPath(apiBase, pageId, dataset, 'schema'));
|
return request('GET', buildPageDataPublicPath(apiBase, pageId, dataset, 'schema'));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -106,9 +106,6 @@
|
|||||||
listRows: function (dataset, query) {
|
listRows: function (dataset, query) {
|
||||||
return request('GET', buildDataPath(apiBase, pageId, dataset), { query: 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) {
|
getSchema: function (dataset) {
|
||||||
return request('GET', buildDataPath(apiBase, pageId, dataset, 'schema'));
|
return request('GET', buildDataPath(apiBase, pageId, dataset, 'schema'));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ test('Gate, artifact, candidate routing and rollback share one compatibility con
|
|||||||
const [
|
const [
|
||||||
builder,
|
builder,
|
||||||
localStack,
|
localStack,
|
||||||
|
stableRunner,
|
||||||
candidateRunner,
|
candidateRunner,
|
||||||
compatRunner,
|
compatRunner,
|
||||||
canaryRelease,
|
canaryRelease,
|
||||||
@@ -63,6 +64,7 @@ test('Gate, artifact, candidate routing and rollback share one compatibility con
|
|||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
fs.readFile(path.join(ROOT, 'scripts', 'build-portal-runtime.mjs'), 'utf8'),
|
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, '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-memind-portal-candidate.sh'), 'utf8'),
|
||||||
fs.readFile(
|
fs.readFile(
|
||||||
path.join(ROOT, 'scripts', 'run-deepseek-compat-proxy-candidate.sh'),
|
path.join(ROOT, 'scripts', 'run-deepseek-compat-proxy-candidate.sh'),
|
||||||
@@ -77,6 +79,8 @@ test('Gate, artifact, candidate routing and rollback share one compatibility con
|
|||||||
localStack,
|
localStack,
|
||||||
/path\.join\(resolvedPortalRoot, 'deepseek-no-think-proxy\.mjs'\)/,
|
/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_DEEPSEEK_DISABLE_THINKING=1/);
|
||||||
assert.match(candidateRunner, /export MEMIND_GOOSED_HOST_GATEWAY=host\.docker\.internal/);
|
assert.match(candidateRunner, /export MEMIND_GOOSED_HOST_GATEWAY=host\.docker\.internal/);
|
||||||
assert.match(compatRunner, /source "\$\{STABLE_ROOT\}\/\.env"/);
|
assert.match(compatRunner, /source "\$\{STABLE_ROOT\}\/\.env"/);
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
#!/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));
|
||||||
@@ -26,6 +26,12 @@ fi
|
|||||||
export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-${NODE_BIN}}"
|
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}"
|
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() {
|
free_port_if_stale_memind_listener() {
|
||||||
local port="${H5_PORT:-8081}"
|
local port="${H5_PORT:-8081}"
|
||||||
local pids
|
local pids
|
||||||
|
|||||||
@@ -140,35 +140,3 @@ test('buildVisionPayload does not mark billable usage when vision analysis fails
|
|||||||
assert.equal(result?.billableImageCount, 0);
|
assert.equal(result?.billableImageCount, 0);
|
||||||
assert.doesNotMatch(result?.userMessage?.content?.[0]?.text ?? '', /Qwen VL 图片描述/);
|
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 { extractAttachmentText } from './mindspace-attachment-text.mjs';
|
||||||
import {
|
import {
|
||||||
buildCurrentTurnImageScopeNote,
|
buildCurrentTurnImageScopeNote,
|
||||||
conversationHasImageUrlContent,
|
|
||||||
extractCurrentTurnImageUrls,
|
extractCurrentTurnImageUrls,
|
||||||
scrubConversationHistoricalImageAttachments,
|
scrubConversationHistoricalImageAttachments,
|
||||||
} from './chat-image-turn-scope.mjs';
|
} from './chat-image-turn-scope.mjs';
|
||||||
@@ -975,9 +974,6 @@ export async function buildVisionPayload({
|
|||||||
'不要向用户展示 HTML 代码块。';
|
'不要向用户展示 HTML 代码块。';
|
||||||
|
|
||||||
let updatedContent = Array.isArray(userMessage?.content) ? [...userMessage.content] : [];
|
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) {
|
for (const item of imageItems) {
|
||||||
updatedContent = updatedContent.map((c) => {
|
updatedContent = updatedContent.map((c) => {
|
||||||
if (c?.type !== 'text' || typeof c.text !== 'string') return 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 };
|
return { changed: false, updated: false, status: upstream.status };
|
||||||
}
|
}
|
||||||
const session = await upstream.json().catch(() => null);
|
const session = await upstream.json().catch(() => null);
|
||||||
const conversation = Array.isArray(session?.conversation) ? session.conversation : [];
|
const { conversation, changed } = scrubConversationHistoricalImageAttachments(
|
||||||
const hasImageUrlContent = conversationHasImageUrlContent(conversation, {
|
session?.conversation ?? [],
|
||||||
excludeMessageId: activeId,
|
|
||||||
});
|
|
||||||
const { conversation: scrubbedConversation, changed } = scrubConversationHistoricalImageAttachments(
|
|
||||||
conversation,
|
|
||||||
activeId,
|
activeId,
|
||||||
);
|
);
|
||||||
// Text-only providers (DeepSeek) reject any lingering image_url parts. If Goose
|
if (!changed) return { changed: false, updated: false };
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const update = await apiFetch(
|
const update = await apiFetch(
|
||||||
target,
|
target,
|
||||||
apiSecret,
|
apiSecret,
|
||||||
`/sessions/${encodeURIComponent(sessionId)}`,
|
`/sessions/${encodeURIComponent(sessionId)}`,
|
||||||
{
|
{
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ conversation: scrubbedConversation }),
|
body: JSON.stringify({ conversation }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!update.ok) {
|
if (!update.ok) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`Historical image scrub skipped for session ${sessionId}: upstream ${update.status}`,
|
`Historical image scrub skipped for session ${sessionId}: upstream ${update.status}`,
|
||||||
);
|
);
|
||||||
return {
|
return { changed: true, updated: false, status: update.status };
|
||||||
changed: true,
|
|
||||||
updated: false,
|
|
||||||
status: update.status,
|
|
||||||
hasImageUrlContent:
|
|
||||||
hasImageUrlContent
|
|
||||||
|| conversationHasImageUrlContent(scrubbedConversation, {
|
|
||||||
excludeMessageId: activeId,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return {
|
return { changed: true, updated: true, status: update.status };
|
||||||
changed: true,
|
|
||||||
updated: true,
|
|
||||||
status: update.status,
|
|
||||||
hasImageUrlContent: conversationHasImageUrlContent(scrubbedConversation, {
|
|
||||||
excludeMessageId: activeId,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'Historical image scrub skipped:',
|
'Historical image scrub skipped:',
|
||||||
@@ -1974,17 +1938,7 @@ export function createTkmindProxy({
|
|||||||
if (!user) throw new Error('用户不存在');
|
if (!user) throw new Error('用户不存在');
|
||||||
if (requireHistoricalImageIsolation || messageHasImages(userMessage)) {
|
if (requireHistoricalImageIsolation || messageHasImages(userMessage)) {
|
||||||
const imageIsolation = await syncHistoricalImageTurnIsolation(sessionId, userMessage?.id);
|
const imageIsolation = await syncHistoricalImageTurnIsolation(sessionId, userMessage?.id);
|
||||||
const scrubUnsupported =
|
if (requireHistoricalImageIsolation && imageIsolation.changed && !imageIsolation.updated) {
|
||||||
imageIsolation.changed
|
|
||||||
&& !imageIsolation.updated
|
|
||||||
&& (
|
|
||||||
requireHistoricalImageIsolation
|
|
||||||
|| imageIsolation.hasImageUrlContent
|
|
||||||
|| Number(imageIsolation.status) === 404
|
|
||||||
|| Number(imageIsolation.status) === 405
|
|
||||||
|| imageIsolation.status === 'image_url_content_present'
|
|
||||||
);
|
|
||||||
if (scrubUnsupported) {
|
|
||||||
const error = new Error(
|
const error = new Error(
|
||||||
`historical_image_session_update_unsupported:${imageIsolation.status ?? 'unknown'}`,
|
`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 () => {
|
test('visual fallback session removes read_image while preserving the text task path', async () => {
|
||||||
await withFakeGoosedSession(async ({
|
await withFakeGoosedSession(async ({
|
||||||
apiTarget,
|
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);
|
assert.equal(fs.existsSync(htmlPath), false);
|
||||||
const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
|
const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
|
||||||
const payload = JSON.parse(sendCall[2]);
|
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/);
|
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);
|
assert.equal(started, true);
|
||||||
const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
|
const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
|
||||||
const payload = JSON.parse(sendCall[2]);
|
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, /Bad request/);
|
||||||
assert.doesNotMatch(payload.text.content, /tool_calls/);
|
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 sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
|
||||||
const payload = JSON.parse(sendCall[2]);
|
const payload = JSON.parse(sendCall[2]);
|
||||||
assert.doesNotMatch(payload.text.content, /页面都已经成功发布了|主题页面已发布/);
|
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/);
|
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.
|
return { action: 'send', artifacts: sendable };
|
||||||
// Text-only planning replies ("我先搜索…") must not mark WeChat delivery done.
|
|
||||||
return {
|
|
||||||
action: 'fail',
|
|
||||||
failureText: buildPagePublishFailureText(),
|
|
||||||
reason: 'missing_page_artifact',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,29 +218,3 @@ test('resolvePageGenerateOutcome sends when share preview meta is present', () =
|
|||||||
assert.equal(outcome.action, 'send');
|
assert.equal(outcome.action, 'send');
|
||||||
assert.equal(outcome.artifacts.length, 1);
|
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