Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b9c65fec1 | |||
| ab08839a54 | |||
| 8cf1c772a7 | |||
| 41f245c88a | |||
| fe2d5d7aa1 | |||
| 443544d1f7 | |||
| 36d3a91f8d | |||
| 4e789663a1 | |||
| 8a88cd53e8 | |||
| baf89c5d04 | |||
| 19dacc0e5d | |||
| 558c4ef2ee | |||
| 53b0d2c62f |
@@ -1000,6 +1000,36 @@ export async function ensurePlanCatalogSchema(pool) {
|
||||
[nextTokens, now, planType, previousTokens],
|
||||
);
|
||||
}
|
||||
|
||||
// Repair active subscriptions that inherited DEFAULT 0 (unlimited) from the
|
||||
// pre-image-quota free-plan backfill, but whose catalog quota is finite.
|
||||
// True unlimited plans (catalog period_images = 0) are left unchanged.
|
||||
await pool.query(
|
||||
`INSERT INTO h5_image_quota_ledger
|
||||
(id, user_id, delta, balance_after, reason, ref_id, operator_id, note, created_at)
|
||||
SELECT UUID(), s.user_id, p.period_images,
|
||||
GREATEST(0, p.period_images + s.period_images_bonus - s.period_images_used),
|
||||
'plan_change', s.id, NULL,
|
||||
'回填套餐默认图片额度(补建漏写)', ?
|
||||
FROM h5_subscriptions s
|
||||
INNER JOIN h5_plan_catalog p ON p.plan_type = s.plan_type
|
||||
WHERE s.status = 'active'
|
||||
AND s.expires_at > ?
|
||||
AND s.period_images_limit = 0
|
||||
AND p.period_images > 0`,
|
||||
[now, now],
|
||||
);
|
||||
await pool.query(
|
||||
`UPDATE h5_subscriptions s
|
||||
INNER JOIN h5_plan_catalog p ON p.plan_type = s.plan_type
|
||||
SET s.period_images_limit = p.period_images,
|
||||
s.updated_at = ?
|
||||
WHERE s.status = 'active'
|
||||
AND s.expires_at > ?
|
||||
AND s.period_images_limit = 0
|
||||
AND p.period_images > 0`,
|
||||
[now, now],
|
||||
);
|
||||
}
|
||||
|
||||
export function createPlanCatalogService(pool) {
|
||||
|
||||
@@ -74,12 +74,24 @@ describe('ensurePlanCatalogSchema', () => {
|
||||
await ensurePlanCatalogSchema(pool);
|
||||
|
||||
const planUpdates = queries.filter(({ sql }) => sql.includes('UPDATE h5_plan_catalog'));
|
||||
const subUpdates = queries.filter(({ sql }) => sql.includes('UPDATE h5_subscriptions'));
|
||||
const tokenSubUpdates = queries.filter(({ sql }) => (
|
||||
sql.includes('UPDATE h5_subscriptions') && sql.includes('period_tokens_limit')
|
||||
));
|
||||
const imageSubUpdates = queries.filter(({ sql }) => (
|
||||
sql.includes('UPDATE h5_subscriptions s') && sql.includes('period_images_limit')
|
||||
));
|
||||
const imageLedgerInserts = queries.filter(({ sql }) => (
|
||||
sql.includes('INSERT INTO h5_image_quota_ledger') && sql.includes('补建漏写')
|
||||
));
|
||||
|
||||
assert.ok(planUpdates.some(({ params }) => params[0] === 450_000 && params[2] === 'free' && params[3] === 150_000));
|
||||
assert.ok(planUpdates.some(({ params }) => params[0] === 3_600_000 && params[2] === 'lite' && params[3] === 1_200_000));
|
||||
assert.ok(planUpdates.some(({ params }) => params[0] === 13_500_000 && params[2] === 'standard' && params[3] === 4_500_000));
|
||||
assert.equal(subUpdates.length, 3);
|
||||
assert.equal(tokenSubUpdates.length, 3);
|
||||
assert.equal(imageSubUpdates.length, 1);
|
||||
assert.equal(imageLedgerInserts.length, 1);
|
||||
assert.match(imageSubUpdates[0].sql, /p\.period_images > 0/);
|
||||
assert.match(imageSubUpdates[0].sql, /s\.period_images_limit = 0/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -72,6 +72,49 @@ export function shouldScheduleMissingActiveRequestGrace({
|
||||
return allowMissingGrace && !agentRunPending;
|
||||
}
|
||||
|
||||
export const QUEUED_CHAT_SUBMIT_NOTICE =
|
||||
'已收到你的消息,将在当前任务完成后自动继续执行。';
|
||||
|
||||
/**
|
||||
* @param {string | undefined | null} chatState
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isChatSubmitBusy(chatState) {
|
||||
return (
|
||||
chatState === 'streaming' ||
|
||||
chatState === 'loading' ||
|
||||
chatState === 'connecting' ||
|
||||
chatState === 'waiting'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number | undefined | null} queueLength
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildQueuedChatSubmitNotice(queueLength = 1) {
|
||||
const length = Math.max(1, Number(queueLength) || 1);
|
||||
if (length <= 1) return QUEUED_CHAT_SUBMIT_NOTICE;
|
||||
return `已收到你的消息,当前还有 ${length} 条待执行,将在任务完成后按顺序继续。`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ chatState?: string; agentRunPending?: boolean; pendingTool?: boolean; queueLength?: number }} input
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function canFlushQueuedChatSubmit({
|
||||
chatState = 'idle',
|
||||
agentRunPending = false,
|
||||
pendingTool = false,
|
||||
queueLength = 0,
|
||||
} = {}) {
|
||||
if (!queueLength || queueLength <= 0) return false;
|
||||
if (isChatSubmitBusy(chatState)) return false;
|
||||
if (agentRunPending) return false;
|
||||
if (pendingTool) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only transport uncertainty may continue a run after submit fails. A
|
||||
* deterministic gateway error already has a terminal outcome and must return
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildQueuedChatSubmitNotice,
|
||||
canFlushQueuedChatSubmit,
|
||||
isChatSubmitBusy,
|
||||
reconcileSessionEventRequestContext,
|
||||
resolvePostAgentRunChatState,
|
||||
shouldIgnoreZeroActivityFinish,
|
||||
@@ -145,6 +148,52 @@ test('missing ActiveRequests cannot unlock while the Portal agent-run is pending
|
||||
);
|
||||
});
|
||||
|
||||
test('isChatSubmitBusy covers active composer states', () => {
|
||||
assert.equal(isChatSubmitBusy('idle'), false);
|
||||
assert.equal(isChatSubmitBusy('error'), false);
|
||||
assert.equal(isChatSubmitBusy('waiting'), true);
|
||||
assert.equal(isChatSubmitBusy('streaming'), true);
|
||||
assert.equal(isChatSubmitBusy('connecting'), true);
|
||||
});
|
||||
|
||||
test('buildQueuedChatSubmitNotice reflects queue depth', () => {
|
||||
assert.match(buildQueuedChatSubmitNotice(1), /当前任务完成后/);
|
||||
assert.match(buildQueuedChatSubmitNotice(2), /2 条待执行/);
|
||||
});
|
||||
|
||||
test('canFlushQueuedChatSubmit waits for idle composer without pending tool or run', () => {
|
||||
assert.equal(
|
||||
canFlushQueuedChatSubmit({
|
||||
chatState: 'idle',
|
||||
queueLength: 1,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
canFlushQueuedChatSubmit({
|
||||
chatState: 'waiting',
|
||||
queueLength: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
canFlushQueuedChatSubmit({
|
||||
chatState: 'idle',
|
||||
agentRunPending: true,
|
||||
queueLength: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
canFlushQueuedChatSubmit({
|
||||
chatState: 'idle',
|
||||
pendingTool: true,
|
||||
queueLength: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('reconcileSessionEventRequestContext adopts Goose request id while agent-run gate waits', () => {
|
||||
assert.deepEqual(
|
||||
reconcileSessionEventRequestContext({
|
||||
|
||||
@@ -75,6 +75,52 @@ function stripAgentImageText(text) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function stripImageUrlLines(text) {
|
||||
return String(text ?? '').replace(IMAGE_URL_LINES_RE, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* After Qwen VL analysis, Goose must receive text only. DeepSeek rejects
|
||||
* `image_url` parts, and native Goose may expand metadata.imageUrls into them.
|
||||
* Keep archived/preview URLs for UI history; leave the VL note in the text.
|
||||
*/
|
||||
export function detachCurrentTurnImagesForTextProvider(message, canonicalImageUrls = []) {
|
||||
if (!message) return message;
|
||||
const metadata =
|
||||
message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata)
|
||||
? { ...message.metadata }
|
||||
: {};
|
||||
const urls = (Array.isArray(canonicalImageUrls) && canonicalImageUrls.length > 0
|
||||
? canonicalImageUrls
|
||||
: Array.isArray(metadata.imageUrls) ? metadata.imageUrls : []
|
||||
).filter((url) => typeof url === 'string' && url.trim());
|
||||
if (urls.length > 0) {
|
||||
metadata.archivedImageUrls = urls;
|
||||
if (!Array.isArray(metadata.previewImageUrls) || metadata.previewImageUrls.length === 0) {
|
||||
metadata.previewImageUrls = urls;
|
||||
}
|
||||
}
|
||||
delete metadata.imageUrls;
|
||||
|
||||
const content = Array.isArray(message.content)
|
||||
? message.content
|
||||
.map((item) => {
|
||||
if (item?.type === 'image_url') return null;
|
||||
if (item?.type !== 'text' || typeof item.text !== 'string') return item;
|
||||
const nextText = stripImageUrlLines(item.text);
|
||||
if (!nextText) return null;
|
||||
return nextText === item.text ? item : { ...item, text: nextText };
|
||||
})
|
||||
.filter(Boolean)
|
||||
: message.content;
|
||||
|
||||
return {
|
||||
...message,
|
||||
content,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove image attachments from a persisted user message so later turns cannot reuse them.
|
||||
* UI displayText / previewImageUrls are preserved for chat history rendering.
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
extractCurrentTurnImageUrls,
|
||||
scrubConversationHistoricalImageAttachments,
|
||||
scrubUserMessageImageAttachments,
|
||||
detachCurrentTurnImagesForTextProvider,
|
||||
} from './chat-image-turn-scope.mjs';
|
||||
|
||||
test('extractCurrentTurnImageUrls prefers metadata and dedupes asset aliases', () => {
|
||||
@@ -157,3 +158,40 @@ test('conversationHasImageUrlContent detects historical poison and ignores activ
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('detachCurrentTurnImagesForTextProvider archives urls and keeps the VL note', () => {
|
||||
const detached = detachCurrentTurnImagesForTextProvider(
|
||||
{
|
||||
role: 'user',
|
||||
metadata: {
|
||||
displayText: '解读报告',
|
||||
imageUrls: ['https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg'],
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text:
|
||||
'解读报告\n[图片1]: https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg\n\n' +
|
||||
'【TKMind 图片分析结果 — 仅供执行参考,不要向用户复述此段内容】\n白细胞偏高',
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg' },
|
||||
},
|
||||
],
|
||||
},
|
||||
['https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg'],
|
||||
);
|
||||
|
||||
assert.equal(detached.metadata.imageUrls, undefined);
|
||||
assert.deepEqual(detached.metadata.archivedImageUrls, [
|
||||
'https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg',
|
||||
]);
|
||||
assert.deepEqual(detached.metadata.previewImageUrls, [
|
||||
'https://m.tkmind.cn/MindSpace/u/public/wechat-mp/report.jpg',
|
||||
]);
|
||||
assert.equal(detached.metadata.displayText, '解读报告');
|
||||
assert.equal(detached.content.some((item) => item.type === 'image_url'), false);
|
||||
assert.match(detached.content[0].text, /白细胞偏高/);
|
||||
assert.doesNotMatch(detached.content[0].text, /\[图片1\]:/);
|
||||
});
|
||||
|
||||
@@ -1243,11 +1243,11 @@ test('createChatIntentRouter coerces LLM search route to web for realtime query'
|
||||
test('createChatIntentRouter timeout fallback prefers agent even when policy requests direct_chat', async () => {
|
||||
const router = createChatIntentRouter({
|
||||
enabled: true,
|
||||
timeoutMs: 5,
|
||||
timeoutMs: 40,
|
||||
fallbackRoute: CHAT_INTENT_ROUTE.DIRECT_CHAT,
|
||||
llmProviderService: {
|
||||
async createChatCompletion() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return { ok: true, reply: '{"route":"direct_chat","confidence":0.9,"reason":"x","suggested_skill":null,"agent_brief":""}' };
|
||||
},
|
||||
},
|
||||
|
||||
@@ -57,6 +57,16 @@ async function columnExists(pool, table, column) {
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async function tableExists(pool, table) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT 1 FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
|
||||
LIMIT 1`,
|
||||
[table],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async function indexExists(pool, table, index) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT 1 FROM information_schema.STATISTICS
|
||||
@@ -943,10 +953,23 @@ export async function migrateSchema(pool) {
|
||||
|
||||
// Back-fill free subscriptions for existing users who pre-date the subscription system.
|
||||
// Idempotent: only inserts where no active subscription exists.
|
||||
// period_images_limit defaults to 0 (= unlimited). Always write the catalog
|
||||
// quota when the column exists, otherwise expired paid users become unlimited
|
||||
// on the next Portal/Plaza start.
|
||||
const hasImageLimit = await columnExists(pool, 'h5_subscriptions', 'period_images_limit');
|
||||
const hasCatalog = await tableExists(pool, 'h5_plan_catalog');
|
||||
const tokenSelect = hasCatalog ? 'COALESCE(c.period_tokens, 150000)' : '150000';
|
||||
const imageSelect = hasCatalog ? 'COALESCE(c.period_images, 10)' : '10';
|
||||
const catalogJoin = hasCatalog ? "LEFT JOIN h5_plan_catalog c ON c.plan_type = 'free'" : '';
|
||||
const imageCols = hasImageLimit
|
||||
? 'period_images_limit, period_images_used, period_images_bonus,'
|
||||
: '';
|
||||
const imageVals = hasImageLimit ? `${imageSelect}, 0, 0,` : '';
|
||||
await pool.query(`
|
||||
INSERT INTO h5_subscriptions
|
||||
(id, user_id, plan_type, status,
|
||||
period_tokens_limit, period_tokens_used,
|
||||
${imageCols}
|
||||
period_start, period_end, expires_at, overage_rate,
|
||||
operator_id, note, created_at, updated_at)
|
||||
SELECT
|
||||
@@ -954,7 +977,8 @@ export async function migrateSchema(pool) {
|
||||
u.id,
|
||||
'free',
|
||||
'active',
|
||||
150000, 0,
|
||||
${tokenSelect}, 0,
|
||||
${imageVals}
|
||||
UNIX_TIMESTAMP() * 1000,
|
||||
(UNIX_TIMESTAMP() + 30 * 86400) * 1000,
|
||||
(UNIX_TIMESTAMP() + 30 * 86400) * 1000,
|
||||
@@ -964,6 +988,7 @@ export async function migrateSchema(pool) {
|
||||
UNIX_TIMESTAMP() * 1000,
|
||||
UNIX_TIMESTAMP() * 1000
|
||||
FROM h5_users u
|
||||
${catalogJoin}
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM h5_subscriptions s
|
||||
WHERE s.user_id = u.id AND s.status = 'active'
|
||||
|
||||
@@ -3,6 +3,25 @@
|
||||
本文件记录已经完成迁移、但仍可能因为 Git 拓扑或遗留 worktree 被误判为“尚未进入 `main`”的分支。
|
||||
它是分支复用、合并、cherry-pick 和清理前的必查清单。
|
||||
|
||||
## `fix/image-quota-migration-backfill`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并将随本分支合入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
|
||||
|
||||
审计日期:2026-08-14
|
||||
代码修复:`8cf1c772`
|
||||
`origin/main` 对应提交:本登记与代码修复一并快进合入 main
|
||||
|
||||
### 原始用途
|
||||
|
||||
- 补建免费套餐时写入套餐目录图片额度,避免 `period_images_limit = 0` 被当成无限
|
||||
- 启动时回填「上限为 0、但目录额度有限」的有效订阅
|
||||
- 记录 2026-08-14 柯彤无限图片额度事故
|
||||
|
||||
### 验证摘要
|
||||
|
||||
- `node --test billing-subscription.test.mjs`:27 passed
|
||||
- 生产已回填 33 条误标无限的有效免费订阅;柯彤剩余 6 / 总计 10
|
||||
|
||||
## 处置规则
|
||||
|
||||
- 标记为“禁止再次引用”的分支只允许用于只读历史追溯。
|
||||
@@ -527,3 +546,135 @@ Portal,避免在线修改稳定 `.env`,并确保稳定 8081 与其他用户
|
||||
- 保留本地分支名用于审计追溯。
|
||||
- **不要** merge、cherry-pick 或从该分支继续开发。
|
||||
|
||||
## `feature/2026-08-12-dev`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
|
||||
|
||||
审计日期:2026-08-13
|
||||
分支 HEAD:`421e204`
|
||||
`origin/main` 对应提交:`421e204`
|
||||
|
||||
### 原始用途
|
||||
|
||||
MindSpace 成果展示:分页管理、统计聚合、Achievements 面板 UI 与 Portal API。
|
||||
|
||||
### 验证摘要
|
||||
|
||||
- Git 祖先检查确认分支 HEAD 已进入 `origin/main`。
|
||||
- `node --test mindspace-page-achievement-list.test.mjs mindspace-page-achievement-stats.test.mjs`:5/5 通过。
|
||||
|
||||
### 最终处置
|
||||
|
||||
- 保留本地分支名用于审计追溯。
|
||||
- **不要** merge、cherry-pick 或从该分支继续开发。
|
||||
- 后续交付修复见 `fix/achievements-delivery-release`(已进入 `origin/main` @ `53b0d2c`)。
|
||||
|
||||
## `feature/wechat-memind-menu`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
|
||||
|
||||
审计日期:2026-08-13
|
||||
分支 HEAD:`e9ea55c`
|
||||
`origin/main` 对应提交:`e9ea55c`
|
||||
|
||||
### 原始用途
|
||||
|
||||
微信公众号自定义菜单文案由 TKMind 更名为 Memind(`scripts/wechat-mp-menu.mjs`)。
|
||||
|
||||
### 验证摘要
|
||||
|
||||
- Git 祖先检查确认分支 HEAD 已进入 `origin/main`。
|
||||
|
||||
### 最终处置
|
||||
|
||||
- 保留本地分支名用于审计追溯。
|
||||
- **不要** merge、cherry-pick 或从该分支继续开发。
|
||||
|
||||
## `fix/runtime-profile-packaging`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
|
||||
|
||||
审计日期:2026-08-13
|
||||
分支 HEAD:`6542a43`
|
||||
`origin/main` 对应提交:`6542a43`
|
||||
|
||||
### 原始用途
|
||||
|
||||
Portal runtime 打包纳入 `memind-runtime-profile`,修复 WeChat 菜单脚本在 105/103 发布链路中缺少 runtime profile 的问题。
|
||||
|
||||
### 验证摘要
|
||||
|
||||
- Git 祖先检查确认分支 HEAD 已进入 `origin/main`。
|
||||
|
||||
### 最终处置
|
||||
|
||||
- 保留本地分支名用于审计追溯。
|
||||
- **不要** merge、cherry-pick 或从该分支继续开发。
|
||||
|
||||
## `feature/seo-geo-admin-catalog`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
|
||||
|
||||
审计日期:2026-08-13
|
||||
分支 HEAD:`fdc234e`
|
||||
`origin/main` 对应提交:`fdc234e`
|
||||
|
||||
### 原始用途
|
||||
|
||||
管理后台 SEO/GEO 收录目录与爬虫统计(`mindspace-seo-geo-admin-catalog.mjs`);管理 UI 在 memind_adm 5174,Memind 侧为共享业务模块与 API。
|
||||
|
||||
### 验证摘要
|
||||
|
||||
- Git 祖先检查确认分支 HEAD 已进入 `origin/main`。
|
||||
- `node --test mindspace-seo-geo-admin-catalog.test.mjs`:3/3 通过。
|
||||
|
||||
### 最终处置
|
||||
|
||||
- 保留本地分支名用于审计追溯。
|
||||
- **不要** merge、cherry-pick 或从该分支继续开发。
|
||||
|
||||
## `fix/achievements-delivery-release`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
|
||||
|
||||
审计日期:2026-08-13
|
||||
分支 HEAD:`53b0d2c`
|
||||
`origin/main` 对应提交:`53b0d2c`
|
||||
|
||||
### 原始用途
|
||||
|
||||
成果展示交付跟进:Finish 时在 `finally` 释放静态 HTML 交付契约,避免重编辑页面卡在 HTTP 409;成果面板按 Asia/Shanghai 日历日分组,消除重复日期标题。
|
||||
|
||||
### 验证摘要
|
||||
|
||||
- Git 祖先检查确认分支 HEAD 已进入 `origin/main`。
|
||||
- `node --test mindspace-delivery-contract.test.mjs`:3/3 通过。
|
||||
|
||||
### 最终处置
|
||||
|
||||
- 保留本地分支名用于审计追溯。
|
||||
- **不要** merge、cherry-pick 或从该分支继续开发。
|
||||
|
||||
## `feature/h5-queued-chat-submit`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯。**
|
||||
|
||||
审计日期:2026-08-13
|
||||
分支 HEAD:`558c4ef`
|
||||
`origin/main` 对应提交:`558c4ef`
|
||||
|
||||
### 原始用途
|
||||
|
||||
H5 主会话在 waiting/streaming/connecting/loading 时静默丢弃后续消息;改为立即展示用户指令、提示排队,并在 composer 回到 idle 后自动提交。
|
||||
|
||||
### 验证摘要
|
||||
|
||||
- `node --test chat-agent-run-gate.test.mjs`:13/13 通过
|
||||
- `node scripts/run-memind-tests.mjs --mode changed`:通过
|
||||
- `node scripts/run-memind-tests.mjs --mode guards`:通过
|
||||
|
||||
### 最终处置
|
||||
|
||||
- 保留本地分支名用于审计追溯。
|
||||
- **不要** merge、cherry-pick 或从该分支继续开发。
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# 2026-08-14 过期后补建免费套餐把图片额度写成无限
|
||||
|
||||
## 现象
|
||||
|
||||
生产管理后台用户详情里,部分普通用户的图片额度显示「无限」,输入框和「保存额度」被禁用。典型用户:柯彤(`wx_1ugymxba`),当时有效套餐为免费版,备注「系统迁移补建免费套餐」,`period_images_limit = 0`,已用 4 张。
|
||||
|
||||
后台把 `period_images_limit === 0` 视为无限,用户详情和 `setImageQuota` 都拒绝调整。这些用户可以无限制调用 `image_make`。
|
||||
|
||||
## 根因
|
||||
|
||||
1. `initSchema` / `migrateSchema` 在用户没有有效订阅时会补建免费套餐,INSERT 只写了 token 额度,没有写 `period_images_limit`。
|
||||
2. 该列 `DEFAULT 0`,而计费把 `0` 设计成「无限」,不是「没有额度」。
|
||||
3. 付费套餐过期后,Portal / Plaza 下次启动会给该用户补建免费套餐,于是变成无限生图。
|
||||
|
||||
生产套餐目录里免费版是 10 张/周期,不是无限。事故发生时有 33 条有效免费订阅命中该补建备注且上限为 0;另有 86 条正常免费订阅上限为 10。
|
||||
|
||||
## 修复
|
||||
|
||||
- 补建免费套餐时写入套餐目录的 `period_images`(无目录时回退 10)。
|
||||
- `ensurePlanCatalogSchema` 启动时把「有效订阅上限为 0,但目录额度 > 0」的记录回填为目录值,并记一条 `plan_change` 流水。真正无限套餐(目录 `period_images = 0`)不改。
|
||||
- 生产已对这 33 条有效订阅做一次数据回填。代码修复需随 Memind 发布后才会阻止再次补建漏写。
|
||||
|
||||
## 排查入口
|
||||
|
||||
```sql
|
||||
SELECT u.username, u.display_name, s.plan_type, s.note,
|
||||
s.period_images_limit, s.period_images_used, s.period_images_bonus
|
||||
FROM h5_subscriptions s
|
||||
JOIN h5_users u ON u.id = s.user_id
|
||||
WHERE s.status = 'active'
|
||||
AND s.expires_at > UNIX_TIMESTAMP() * 1000
|
||||
AND s.period_images_limit = 0;
|
||||
|
||||
SELECT plan_type, name, period_images FROM h5_plan_catalog;
|
||||
```
|
||||
|
||||
管理后台:用户详情「图片生成额度」显示无限且无法保存,同时计费里该用户是免费套餐、备注含「系统迁移补建」。
|
||||
|
||||
## 影响边界与回滚
|
||||
|
||||
- 目录里本身就是无限的套餐(`period_images = 0`)不会被回填。
|
||||
- 已用量超过目录额度的用户回填后剩余为 0,不能再继续按无限额度生图。
|
||||
- 回滚代码不会自动把已回填的上限改回 0;如需恢复无限,要显式改订阅或目录。
|
||||
+4
-4
@@ -205,15 +205,15 @@ Plaza 本地开发说明见 [plaza-local.md](./plaza-local.md)。生产发布、
|
||||
|
||||
## MindSpace SEO / GEO(本地联调)
|
||||
|
||||
默认关闭,与生产一致。在 **memind_adm**(见上表)打开 **MindSpace 配置**,保存 **SEO / GEO** 卡片中的总开关与子开关。
|
||||
默认开启。在 **memind_adm**(见上表)打开 **MindSpace 配置**,可按需关闭 **SEO / GEO** 卡片中的总开关与子开关。
|
||||
|
||||
| 开关 | 作用 |
|
||||
|------|------|
|
||||
| 总开关 `seoGeo.enabled` | 关闭时交付链与改前一致;开启后按收录策略注入 meta / JSON-LD |
|
||||
| 总开关 `seoGeo.enabled` | 关闭时不注入 meta / JSON-LD;开启后按收录策略注入 |
|
||||
| `seo.sitemap` / `seo.robots` / `geo.llms` | 控制 `GET /sitemap.xml`、`/robots.txt`、`/llms.txt` |
|
||||
| `seo.baiduPush` | 用户确认公开 MindSpace 页或 Plaza 发帖后推送百度 |
|
||||
| `seo.baiduPush` | 公开 MindSpace 页或 Plaza 发帖后推送百度 |
|
||||
|
||||
可索引页条件:`public` + `online` + `user_confirmed_at` 非空。私有页、未确认公开、embed 预览一律 `noindex`。
|
||||
可索引页条件:`public` + `online` + 未过期。密码、登录可见、owner_only、已过期、embed 预览一律 `noindex`。
|
||||
|
||||
本地验证:
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
## 保护内容
|
||||
|
||||
1. **收录策略硬规则**:仅 `access_mode=public` 且 `status=online` 且 `user_confirmed_at` 非空的发布页允许 SEO/GEO 注入与 sitemap/llms 收录。
|
||||
2. **私有页强制 noindex**:密码、登录可见、owner_only、未确认公开、工作区预览直链等必须注入 `noindex, nofollow` 并设置 `X-Robots-Tag`。
|
||||
3. **总开关默认关闭**:`mindspace_config.seo_geo_config.enabled=false` 时,交付链行为与未上线前一致。
|
||||
1. **收录策略硬规则**:仅 `access_mode=public` 且 `status=online` 且未过期的发布页允许 SEO/GEO 注入与 sitemap/llms 收录。不再要求 `user_confirmed_at`。
|
||||
2. **私有页强制 noindex**:密码、登录可见、owner_only、已过期、工作区预览直链等必须注入 `noindex, nofollow` 并设置 `X-Robots-Tag`。
|
||||
3. **总开关默认开启**:`mindspace_config.seo_geo_config` 缺省为全开;库内已保存的旧值仍以数据库为准,需在 memind_adm MindSpace 配置页保存后才会改写生产。
|
||||
4. **配置来源**:memind_adm MindSpace 配置页 → `PATCH /admin-api/mindspace/config` → Portal `loadMindSpaceConfigCached()`。
|
||||
5. **百度推送**:仅在 admin 开启 `seo.baiduPush` 且页面可索引时触发;MindSpace 确认公开与 Plaza 发帖共用该开关。
|
||||
5. **百度推送**:仅在 admin 开启 `seo.baiduPush` 且页面可索引时触发;公开页发布与 Plaza 发帖共用该开关。
|
||||
|
||||
## 必跑验证
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="theme-color" content="#0f1419" />
|
||||
<title>Memind</title>
|
||||
<title>TKMind</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -59,18 +59,18 @@ function parseJsonObject(value, fallback = {}) {
|
||||
|
||||
export function defaultSeoGeoConfig() {
|
||||
return {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
seo: {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
canonical: true,
|
||||
sitemap: false,
|
||||
robotsTxt: false,
|
||||
baiduPush: false,
|
||||
sitemap: true,
|
||||
robotsTxt: true,
|
||||
baiduPush: true,
|
||||
},
|
||||
geo: {
|
||||
enabled: false,
|
||||
jsonLd: false,
|
||||
llmsTxt: false,
|
||||
enabled: true,
|
||||
jsonLd: true,
|
||||
llmsTxt: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,18 @@ import {
|
||||
updateMindSpaceConfig,
|
||||
} from './mindspace-config.mjs';
|
||||
|
||||
test('defaultSeoGeoConfig enables all discovery switches', () => {
|
||||
const config = defaultSeoGeoConfig();
|
||||
assert.equal(config.enabled, true);
|
||||
assert.equal(config.seo.enabled, true);
|
||||
assert.equal(config.seo.sitemap, true);
|
||||
assert.equal(config.seo.robotsTxt, true);
|
||||
assert.equal(config.seo.baiduPush, true);
|
||||
assert.equal(config.geo.enabled, true);
|
||||
assert.equal(config.geo.jsonLd, true);
|
||||
assert.equal(config.geo.llmsTxt, true);
|
||||
});
|
||||
|
||||
test('defaultMindSpaceConfig falls back to env or 5', () => {
|
||||
assert.equal(defaultMindSpaceConfig({ MINDSPACE_FREE_PUBLIC_PAGE_LIMIT: '8' }).publicPageLimit, 8);
|
||||
assert.equal(defaultMindSpaceConfig({ MINDSPACE_FREE_PUBLIC_PAGE_LIMIT: '0' }).publicPageLimit, 5);
|
||||
@@ -24,7 +36,7 @@ test('normalizeSeoGeoConfig merges nested switches', () => {
|
||||
assert.equal(config.enabled, true);
|
||||
assert.equal(config.seo.sitemap, true);
|
||||
assert.equal(config.geo.llmsTxt, true);
|
||||
assert.equal(config.seo.baiduPush, false);
|
||||
assert.equal(config.seo.baiduPush, true);
|
||||
});
|
||||
|
||||
test('ensureMindSpaceConfig seeds the default row only when empty', async () => {
|
||||
@@ -45,6 +57,31 @@ test('ensureMindSpaceConfig seeds the default row only when empty', async () =>
|
||||
assert.deepEqual(calls[2].params.slice(0, 3), ['public_page_limit', '9', '公开页面数量上限']);
|
||||
});
|
||||
|
||||
test('loadMindSpaceConfig keeps stored all-off seoGeo instead of new defaults', async () => {
|
||||
const pool = {
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM mindspace_config')) {
|
||||
return [[
|
||||
{
|
||||
key: 'seo_geo_config',
|
||||
value: JSON.stringify({
|
||||
enabled: false,
|
||||
seo: { enabled: false, canonical: true, sitemap: false, robotsTxt: false, baiduPush: false },
|
||||
geo: { enabled: false, jsonLd: false, llmsTxt: false },
|
||||
}),
|
||||
},
|
||||
]];
|
||||
}
|
||||
return [[]];
|
||||
},
|
||||
};
|
||||
|
||||
const config = await loadMindSpaceConfig(pool);
|
||||
assert.equal(config.seoGeo.enabled, false);
|
||||
assert.equal(config.seoGeo.seo.sitemap, false);
|
||||
assert.equal(config.seoGeo.geo.jsonLd, false);
|
||||
});
|
||||
|
||||
test('loadMindSpaceConfig merges stored config with fallback', async () => {
|
||||
const pool = {
|
||||
async query(sql) {
|
||||
|
||||
@@ -46,3 +46,34 @@ export async function markPageDeliveryContractReady({ pool, userId, relativePath
|
||||
);
|
||||
return Number(result?.affectedRows ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function releaseMaterializedPageDeliveryContracts({
|
||||
pool,
|
||||
userId,
|
||||
relativePaths = [],
|
||||
allowPgRequired = false,
|
||||
} = {}) {
|
||||
if (!pool || !userId) return [];
|
||||
const released = [];
|
||||
for (const rawPath of relativePaths) {
|
||||
const workspaceRelativePath = normalizeDeliveryRelativePath(rawPath);
|
||||
if (!workspaceRelativePath) continue;
|
||||
const contract = await getPageDeliveryContract({
|
||||
pool,
|
||||
userId,
|
||||
relativePath: workspaceRelativePath,
|
||||
});
|
||||
if (!contract || contract.status === 'ready') continue;
|
||||
if (contract.data_mode === 'pg_required' && !allowPgRequired) continue;
|
||||
if (
|
||||
await markPageDeliveryContractReady({
|
||||
pool,
|
||||
userId,
|
||||
relativePath: workspaceRelativePath,
|
||||
})
|
||||
) {
|
||||
released.push(workspaceRelativePath);
|
||||
}
|
||||
}
|
||||
return released;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
markPageDeliveryContractReady,
|
||||
normalizeDeliveryRelativePath,
|
||||
preparePageDeliveryContract,
|
||||
releaseMaterializedPageDeliveryContracts,
|
||||
} from './mindspace-delivery-contract.mjs';
|
||||
|
||||
test('normalizes only safe public HTML delivery paths', () => {
|
||||
@@ -40,3 +41,43 @@ test('contract lifecycle writes preparing then ready against the same route key'
|
||||
assert.equal(await markPageDeliveryContractReady({ pool, userId: 'user-1', relativePath: 'public/form.html' }), true);
|
||||
assert.ok(calls.some((call) => call.sql.includes("status = 'ready'")));
|
||||
});
|
||||
|
||||
test('releaseMaterializedPageDeliveryContracts skips pg_required until allowed', async () => {
|
||||
const calls = [];
|
||||
const pool = {
|
||||
async query(sql, params) {
|
||||
calls.push({ sql, params });
|
||||
if (sql.includes('SELECT id, data_mode')) {
|
||||
const path = params?.[1];
|
||||
if (path === 'public/form.html') {
|
||||
return [[{ id: 'c1', data_mode: 'pg_required', status: 'preparing' }]];
|
||||
}
|
||||
if (path === 'public/report.html') {
|
||||
return [[{ id: 'c2', data_mode: 'static', status: 'preparing' }]];
|
||||
}
|
||||
return [[]];
|
||||
}
|
||||
if (sql.includes("status = 'ready'")) return [{ affectedRows: 1 }];
|
||||
return [{ affectedRows: 0 }];
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
await releaseMaterializedPageDeliveryContracts({
|
||||
pool,
|
||||
userId: 'user-1',
|
||||
relativePaths: ['public/form.html', 'public/report.html'],
|
||||
allowPgRequired: false,
|
||||
}),
|
||||
['public/report.html'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
await releaseMaterializedPageDeliveryContracts({
|
||||
pool,
|
||||
userId: 'user-1',
|
||||
relativePaths: ['public/form.html'],
|
||||
allowPgRequired: true,
|
||||
}),
|
||||
['public/form.html'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -40,7 +40,6 @@ export function isPublicationIndexable(publication, { now = Date.now() } = {}) {
|
||||
if (!snapshot) return false;
|
||||
if (snapshot.status !== 'online') return false;
|
||||
if (snapshot.accessMode !== INDEXABLE_ACCESS_MODE) return false;
|
||||
if (snapshot.userConfirmedAt == null) return false;
|
||||
if (snapshot.expiresAt != null && snapshot.expiresAt <= now) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -74,7 +73,7 @@ export function resolveIndexPolicy({
|
||||
return {
|
||||
mode: 'indexable',
|
||||
injectNoindex: false,
|
||||
reason: 'public_confirmed',
|
||||
reason: 'public_online',
|
||||
seoEnabled: Boolean(seoGeoConfig.seo?.enabled),
|
||||
geoEnabled: Boolean(seoGeoConfig.geo?.enabled),
|
||||
canonicalEnabled: seoGeoConfig.seo?.canonical !== false,
|
||||
|
||||
@@ -17,6 +17,13 @@ test('isPublicationIndexable accepts confirmed public online publications', () =
|
||||
assert.equal(isPublicationIndexable(indexablePublication), true);
|
||||
});
|
||||
|
||||
test('isPublicationIndexable accepts public online pages without confirmation', () => {
|
||||
assert.equal(
|
||||
isPublicationIndexable({ ...indexablePublication, userConfirmedAt: null }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('isPublicationIndexable rejects private access modes', () => {
|
||||
for (const accessMode of ['password', 'private_link', 'login_required', 'owner_only']) {
|
||||
assert.equal(
|
||||
@@ -44,7 +51,7 @@ test('resolveIndexPolicy noindexes non-public publications when enabled', () =>
|
||||
assert.equal(policy.injectNoindex, true);
|
||||
});
|
||||
|
||||
test('resolveIndexPolicy marks confirmed public pages indexable', () => {
|
||||
test('resolveIndexPolicy marks public online pages indexable', () => {
|
||||
const policy = resolveIndexPolicy({
|
||||
seoGeoConfig: {
|
||||
enabled: true,
|
||||
|
||||
@@ -32,7 +32,6 @@ export async function listIndexablePublications(
|
||||
LEFT JOIN h5_page_records p ON p.id = pr.page_id
|
||||
WHERE pr.status = 'online'
|
||||
AND pr.access_mode = 'public'
|
||||
AND pr.user_confirmed_at IS NOT NULL
|
||||
AND (pr.expires_at IS NULL OR pr.expires_at > ?)
|
||||
ORDER BY pr.published_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
@@ -153,7 +152,7 @@ export function renderRobotsTxt({
|
||||
export function renderLlmsTxt(entries, { origin = '' } = {}) {
|
||||
const header = [
|
||||
'# TKMind MindSpace public pages',
|
||||
'# Only confirmed public publications are listed here.',
|
||||
'# Public online publications are listed here.',
|
||||
'',
|
||||
];
|
||||
const body = entries
|
||||
|
||||
@@ -108,6 +108,6 @@ test('listAdminSeoGeoPublicationCatalog merges zero-traffic pages with view stat
|
||||
assert.equal(result.data[0].indexable, true);
|
||||
assert.equal(result.data[1].publicationId, 'pub-2');
|
||||
assert.equal(result.data[1].totalViews, 0);
|
||||
assert.equal(result.data[1].indexable, false);
|
||||
assert.equal(result.data[1].indexable, true);
|
||||
assert.match(result.data[1].publicUrl, /^https:\/\/m\.tkmind\.cn\//);
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ test('decorateMindSpaceSeoGeoHtml noindexes private publications', () => {
|
||||
assert.match(result.html, /noindex, nofollow/);
|
||||
});
|
||||
|
||||
test('decorateMindSpaceSeoGeoHtml injects seo and geo for public pages', () => {
|
||||
test('decorateMindSpaceSeoGeoHtml injects seo and geo for unconfirmed public pages', () => {
|
||||
const result = decorateMindSpaceSeoGeoHtml(
|
||||
'<html><head><title>Demo</title><meta name="description" content="摘要"></head><body></body></html>',
|
||||
{
|
||||
@@ -32,7 +32,7 @@ test('decorateMindSpaceSeoGeoHtml injects seo and geo for public pages', () => {
|
||||
publication: {
|
||||
status: 'online',
|
||||
accessMode: 'public',
|
||||
userConfirmedAt: Date.now(),
|
||||
userConfirmedAt: null,
|
||||
publicUrl: '/u/john/pages/demo',
|
||||
},
|
||||
context: {
|
||||
|
||||
@@ -88,7 +88,7 @@ const IMPACT_RULES = Object.freeze([
|
||||
{ groups: ['AUTH'], pattern: /(?:auth|access-policy|account|user-permission)/i },
|
||||
{ groups: ['CFG'], pattern: /(?:config|provider|model-catalog|orchestrator|analytics|disclosure)/i },
|
||||
{ groups: ['CFG'], pattern: /(?:^|\/)\.env(?:\.example)?$/i },
|
||||
{ groups: ['UI'], pattern: /^(?:src\/|public\/)|\.(?:css|scss|tsx|vue)$/i },
|
||||
{ groups: ['UI'], pattern: /^(?:src\/|public\/|index\.html$)|\.(?:css|scss|tsx|vue)$/i },
|
||||
]);
|
||||
|
||||
const GROUP_DEPENDENCIES = Object.freeze({
|
||||
|
||||
@@ -17,6 +17,17 @@ test('docs-only changes use the compact core gate', async () => {
|
||||
assert.equal(selection.unmapped_paths.length, 0);
|
||||
});
|
||||
|
||||
test('H5 shell index.html maps to the UI domain', async () => {
|
||||
const catalog = await loadScenarioCatalog();
|
||||
const selection = selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths: ['index.html'],
|
||||
});
|
||||
assert.equal(selection.strategy, 'impact');
|
||||
assert.deepEqual(selection.impact_groups, ['UI']);
|
||||
assert.equal(selection.unmapped_paths.length, 0);
|
||||
});
|
||||
|
||||
test('domain changes select the domain and dependency closure', async () => {
|
||||
const catalog = await loadScenarioCatalog();
|
||||
const selection = selectImpactScenarios({
|
||||
|
||||
+16
-16
@@ -1,7 +1,7 @@
|
||||
import crypto from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { buildChatSkillPrompt, SCHEDULED_TASK_AUTOMATION_SKILL_NAME } from './chat-skills.mjs';
|
||||
import { markPageDeliveryContractReady } from './mindspace-delivery-contract.mjs';
|
||||
import { releaseMaterializedPageDeliveryContracts } from './mindspace-delivery-contract.mjs';
|
||||
import {
|
||||
collectOwnPublicHtmlRelativePaths,
|
||||
materializeMissingPublicHtmlWrites,
|
||||
@@ -126,29 +126,29 @@ export async function finalizeScheduledTaskPageDelivery({
|
||||
const [rows] = await pool.query(
|
||||
`SELECT workspace_relative_path
|
||||
FROM h5_page_delivery_contracts
|
||||
WHERE user_id = ? AND request_id = ? AND status = 'preparing'`,
|
||||
[userId, sessionId],
|
||||
WHERE user_id = ? AND status = 'preparing'`,
|
||||
[userId],
|
||||
);
|
||||
for (const row of rows ?? []) {
|
||||
if (row?.workspace_relative_path) relativePaths.add(row.workspace_relative_path);
|
||||
}
|
||||
}
|
||||
|
||||
const readyPaths = [];
|
||||
for (const relativePath of relativePaths) {
|
||||
const ready = await markPageDeliveryContractReady({
|
||||
pool,
|
||||
const readyPaths = await releaseMaterializedPageDeliveryContracts({
|
||||
pool,
|
||||
userId,
|
||||
relativePaths: [...relativePaths],
|
||||
allowPgRequired: true,
|
||||
}).catch((error) => {
|
||||
logger.warn?.('[ScheduledTask] release delivery contracts failed:', error);
|
||||
return [];
|
||||
});
|
||||
for (const relativePath of readyPaths) {
|
||||
logger.info?.('[ScheduledTask] delivery contract ready', {
|
||||
userId,
|
||||
sessionId,
|
||||
relativePath,
|
||||
}).catch(() => false);
|
||||
if (ready) readyPaths.push(relativePath);
|
||||
else {
|
||||
logger.warn?.('[ScheduledTask] delivery contract not ready', {
|
||||
userId,
|
||||
sessionId,
|
||||
relativePath,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return readyPaths;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const MENU = {
|
||||
button: [
|
||||
{
|
||||
type: 'view',
|
||||
name: 'Memind',
|
||||
name: 'TKMind',
|
||||
url: 'https://m.tkmind.cn',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
markPageDeliveryContractReady,
|
||||
preparePageDeliveryContract,
|
||||
releaseMaterializedPageDeliveryContracts,
|
||||
} from '../mindspace-delivery-contract.mjs';
|
||||
import { maybeRepairH5HtmlAfterFinish } from '../mindspace-h5-html-finish-guard.mjs';
|
||||
import { maybeRepairPageDataAfterFinish } from '../mindspace-page-data-finish-guard.mjs';
|
||||
@@ -79,6 +80,8 @@ export function attachPortalSessionRoutes(
|
||||
maybeRepairPageDataAfterFinish,
|
||||
markPageDeliveryContractReadyFn =
|
||||
markPageDeliveryContractReady,
|
||||
releaseMaterializedPageDeliveryContractsFn =
|
||||
releaseMaterializedPageDeliveryContracts,
|
||||
finishDeliveryRetryDelaysMs = [250, 1_000],
|
||||
finishDeliveryRetryWaitFn = (delayMs) =>
|
||||
new Promise((resolve) =>
|
||||
@@ -477,6 +480,8 @@ export function attachPortalSessionRoutes(
|
||||
// workspace from DB-backed assets only.
|
||||
const finalizeAfterFinishOnce = async (sid, uid) => {
|
||||
beginSessionPageDelivery(sid);
|
||||
let releaseCandidatePaths = [];
|
||||
let allowPgRequiredRelease = false;
|
||||
try {
|
||||
const apiFetchFn = async (pathname, init) => {
|
||||
const target = await tkmindProxy.resolveTarget(sid);
|
||||
@@ -616,6 +621,8 @@ export function attachPortalSessionRoutes(
|
||||
...deliveryContractWrites.keys(),
|
||||
]),
|
||||
].sort();
|
||||
releaseCandidatePaths = publicHtmlRelativePaths;
|
||||
allowPgRequiredRelease = htmlReady && pageDataReady;
|
||||
const pgRequired = [
|
||||
...(Array.isArray(messages) ? messages : []),
|
||||
].some(
|
||||
@@ -660,11 +667,6 @@ export function attachPortalSessionRoutes(
|
||||
pgRequired,
|
||||
});
|
||||
}
|
||||
await markPageDeliveryContractReadyFn({
|
||||
pool: authPool,
|
||||
userId: uid,
|
||||
relativePath,
|
||||
}).catch(() => false);
|
||||
}
|
||||
}
|
||||
const memoryV2 = getMemoryV2();
|
||||
@@ -685,6 +687,20 @@ export function attachPortalSessionRoutes(
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (releaseCandidatePaths.length > 0) {
|
||||
await releaseMaterializedPageDeliveryContractsFn({
|
||||
pool: authPool,
|
||||
userId: uid,
|
||||
relativePaths: releaseCandidatePaths,
|
||||
allowPgRequired: allowPgRequiredRelease,
|
||||
}).catch((error) => {
|
||||
logger.warn(
|
||||
`[MindSpace] delivery contract release failed for session ${sid}: ${
|
||||
error instanceof Error ? error.message : error
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
endSessionPageDelivery(sid);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -112,7 +112,7 @@ function createDependencies(overrides = {}) {
|
||||
return { sessionId, hooks };
|
||||
},
|
||||
};
|
||||
return {
|
||||
const setup = {
|
||||
calls,
|
||||
proxy,
|
||||
dependencies: {
|
||||
@@ -203,6 +203,30 @@ function createDependencies(overrides = {}) {
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(
|
||||
overrides,
|
||||
'releaseMaterializedPageDeliveryContractsFn',
|
||||
)
|
||||
) {
|
||||
setup.dependencies.releaseMaterializedPageDeliveryContractsFn =
|
||||
async (input) => {
|
||||
const released = [];
|
||||
for (const relativePath of input.relativePaths ?? []) {
|
||||
if (
|
||||
await setup.dependencies.markPageDeliveryContractReadyFn({
|
||||
pool: input.pool,
|
||||
userId: input.userId,
|
||||
relativePath,
|
||||
})
|
||||
) {
|
||||
released.push(relativePath);
|
||||
}
|
||||
}
|
||||
return released;
|
||||
};
|
||||
}
|
||||
return setup;
|
||||
}
|
||||
|
||||
test('session module preserves route inventory and order', () => {
|
||||
@@ -709,8 +733,8 @@ test('Finish hook preserves refresh, sync, delivery readiness, memory, and lock
|
||||
'prepare-page-data',
|
||||
'repair-page-data',
|
||||
'prepare-contract',
|
||||
'ready',
|
||||
'memory',
|
||||
'ready',
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
@@ -957,5 +981,73 @@ test('Finish retries when a delivery guard is initially not ready', async () =>
|
||||
assert.equal(pageDataChecks, 2);
|
||||
assert.deepEqual(setup.calls.begin, ['session-1', 'session-1']);
|
||||
assert.deepEqual(setup.calls.end, ['session-1', 'session-1']);
|
||||
assert.deepEqual(readyPaths, ['public/survey.html']);
|
||||
assert.deepEqual(readyPaths, ['public/survey.html', 'public/survey.html']);
|
||||
});
|
||||
|
||||
test('Finish finally releases static HTML contracts when delivery guards fail', async () => {
|
||||
let hooks = null;
|
||||
const releasedPaths = [];
|
||||
const setup = createDependencies({
|
||||
finishDeliveryRetryDelaysMs: [],
|
||||
getAuthPool: () => ({ id: 'pool' }),
|
||||
getTkmindProxy: () => ({
|
||||
async resolveTarget(sessionId) {
|
||||
return `target:${sessionId}`;
|
||||
},
|
||||
async apiFetchTo() {
|
||||
return createUpstream({
|
||||
body: {
|
||||
id: 'session-1',
|
||||
conversation: [
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: 'update page',
|
||||
metadata: { userVisible: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
proxySessionEvents(_req, _res, _sessionId, receivedHooks) {
|
||||
hooks = receivedHooks;
|
||||
},
|
||||
}),
|
||||
getMindSpacePublicFinish: () => ({
|
||||
async syncAfterFinish() {
|
||||
return {
|
||||
publicHtmlRelativePaths: ['public/daily-news-0813.html'],
|
||||
docxSync: { missing: [] },
|
||||
};
|
||||
},
|
||||
async preparePageDataAfterFinish() {
|
||||
return {
|
||||
autoBind: { bound: [], skipped: [], errors: [] },
|
||||
evaluation: { structuralPageData: false, relevantFiles: [] },
|
||||
};
|
||||
},
|
||||
}),
|
||||
async maybeRepairH5HtmlAfterFinishFn() {
|
||||
return { skipped: 'limit' };
|
||||
},
|
||||
async releaseMaterializedPageDeliveryContractsFn(input) {
|
||||
releasedPaths.push(...(input.relativePaths ?? []));
|
||||
return input.relativePaths ?? [];
|
||||
},
|
||||
});
|
||||
const api = createRouterRecorder();
|
||||
attachPortalSessionRoutes(api, setup.dependencies);
|
||||
await api.routes.get('GET /sessions/:sessionId/events')(
|
||||
createRequest(),
|
||||
createResponseRecorder(),
|
||||
() => {},
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() => hooks.onAfterFinish('session-1', 'user-1'),
|
||||
/page delivery guards are not ready/,
|
||||
);
|
||||
|
||||
assert.deepEqual(releasedPaths, ['public/daily-news-0813.html']);
|
||||
assert.deepEqual(setup.calls.end, ['session-1']);
|
||||
});
|
||||
|
||||
@@ -146,7 +146,7 @@ npm run check:mindspace-cover
|
||||
|
||||
## SEO / GEO 元数据(推荐)
|
||||
|
||||
平台在 memind_adm **MindSpace 配置 → SEO / GEO** 开启后,会对**已确认公开**(`access_mode=public` 且用户已确认)的发布页自动注入 canonical、结构化数据等。Agent 仍应写好基础 meta,以提升搜索与 AI 引用质量:
|
||||
平台在 memind_adm **MindSpace 配置 → SEO / GEO** 开启后,会对**公开在线**(`access_mode=public` 且 `status=online`)的发布页自动注入 canonical、结构化数据等。Agent 仍应写好基础 meta,以提升搜索与 AI 引用质量:
|
||||
|
||||
```html
|
||||
<title>页面真实主题</title>
|
||||
|
||||
@@ -492,6 +492,7 @@ export function ChatPanel({
|
||||
chatState === 'connecting' ||
|
||||
chatState === 'waiting';
|
||||
const offlineBlocked = !online;
|
||||
const inputBlocked = !!pendingTool || chatState === 'error' || offlineBlocked;
|
||||
const publishSkillName = user?.publishSkillName ?? 'static-page-publish';
|
||||
const hasPublishSkill = grantedSkills?.includes(publishSkillName) ?? false;
|
||||
const hasPageDataCollectSkill = grantedSkills?.includes('page-data-collect') ?? false;
|
||||
@@ -520,14 +521,15 @@ export function ChatPanel({
|
||||
: chatState === 'waiting'
|
||||
? '请求已提交…'
|
||||
: null;
|
||||
const canSubmit = input.trim() || pendingImages.length > 0 || pendingFiles.length > 0;
|
||||
const sendButtonLabel =
|
||||
uploadingImage || uploadingFile
|
||||
? '上传中…'
|
||||
: chatState === 'connecting'
|
||||
? '连接中…'
|
||||
: chatState === 'waiting'
|
||||
? '提交中…'
|
||||
: null;
|
||||
: busy && canSubmit
|
||||
? '排队发送'
|
||||
: null;
|
||||
|
||||
const applyTemplatePrefill = useCallback((prompt: string, skillId: string, label: string) => {
|
||||
pendingSkillRef.current = skillId;
|
||||
@@ -577,8 +579,7 @@ export function ChatPanel({
|
||||
setVoiceNotice('已识别,可编辑后发送');
|
||||
};
|
||||
|
||||
const voiceDisabled = busy || !!pendingTool || chatState === 'error' || offlineBlocked;
|
||||
const canSubmit = input.trim() || pendingImages.length > 0 || pendingFiles.length > 0;
|
||||
const voiceDisabled = inputBlocked || uploadingImage || uploadingFile;
|
||||
const uploadDisabled = !canUpload || busy || uploadingImage || uploadingFile || offlineBlocked || !!pendingTool;
|
||||
|
||||
const revokePendingImage = (item: PendingChatImage) => {
|
||||
@@ -719,7 +720,7 @@ export function ChatPanel({
|
||||
skillIdOverride ?? pendingSkillRef.current ?? templateSelection?.skillId ?? undefined;
|
||||
pendingSkillRef.current = null;
|
||||
setActiveTemplatePrefill(null);
|
||||
if ((!trimmed && pendingImages.length === 0 && pendingFiles.length === 0) || voiceDisabled) return;
|
||||
if ((!trimmed && pendingImages.length === 0 && pendingFiles.length === 0) || inputBlocked) return;
|
||||
if (pendingImages.length > 0 && !onUploadImage) {
|
||||
setImageError('当前会话暂不支持图片发送');
|
||||
return;
|
||||
@@ -1462,16 +1463,15 @@ export function ChatPanel({
|
||||
<button type="button" className="danger-btn chat-input-action" onClick={() => void onStop()}>
|
||||
停止
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={`send-btn chat-input-action${showHomeWelcome ? ' chat-input-action-home' : ''}`}
|
||||
disabled={!canSubmit || voiceDisabled || uploadingImage || uploadingFile}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{sendButtonLabel ?? '发送'}
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`send-btn chat-input-action${showHomeWelcome ? ' chat-input-action-home' : ''}`}
|
||||
disabled={!canSubmit || inputBlocked || uploadingImage || uploadingFile}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{sendButtonLabel ?? '发送'}
|
||||
</button>
|
||||
</div>
|
||||
{compact && onClose && (
|
||||
<button type="button" className="space-chat-panel-dismiss" onClick={onClose}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CHAT_IMAGE_MAX_SIDE,
|
||||
compressImageForUpload,
|
||||
} from '../utils/imageUpload';
|
||||
import { APP_DISPLAY_NAME } from '../utils/appBrand';
|
||||
import { FeedbackPageHeader } from './FeedbackPageHeader';
|
||||
import { VoiceInputButton } from './VoiceInputButton';
|
||||
|
||||
@@ -277,7 +278,7 @@ export function FeedbackSubmitView({
|
||||
>
|
||||
<div className="feedback-board-toolbar">
|
||||
<div className="feedback-board-toolbar-copy">
|
||||
<p className="feedback-submit-eyebrow">帮助我们改进 Memind</p>
|
||||
<p className="feedback-submit-eyebrow">帮助我们改进 {APP_DISPLAY_NAME}</p>
|
||||
<h1>提交 Bug 或需求</h1>
|
||||
<p className="feedback-submit-desc feedback-board-desc">
|
||||
你可以用文字描述、上传截图,或点击麦克风口述问题。我们会自动附带当前页面与设备信息,便于定位问题。
|
||||
|
||||
@@ -23,8 +23,24 @@ function formatDateTime(timestamp: number) {
|
||||
});
|
||||
}
|
||||
|
||||
const ACHIEVEMENTS_DATE_TIMEZONE = 'Asia/Shanghai';
|
||||
|
||||
function localCreatedDateKey(timestamp: number) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: ACHIEVEMENTS_DATE_TIMEZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(new Date(timestamp));
|
||||
const year = parts.find((part) => part.type === 'year')?.value ?? '0000';
|
||||
const month = parts.find((part) => part.type === 'month')?.value ?? '01';
|
||||
const day = parts.find((part) => part.type === 'day')?.value ?? '01';
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function formatDateHeading(timestamp: number) {
|
||||
return new Date(timestamp).toLocaleDateString('zh-CN', {
|
||||
timeZone: ACHIEVEMENTS_DATE_TIMEZONE,
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
@@ -45,7 +61,7 @@ function formatClickMetric(page: MindSpacePage) {
|
||||
function groupPagesByCreatedDate(pages: MindSpacePage[]) {
|
||||
const groups = new Map<string, MindSpacePage[]>();
|
||||
for (const page of pages) {
|
||||
const key = new Date(page.createdAt).toISOString().slice(0, 10);
|
||||
const key = localCreatedDateKey(page.createdAt);
|
||||
const bucket = groups.get(key);
|
||||
if (bucket) bucket.push(page);
|
||||
else groups.set(key, [page]);
|
||||
@@ -54,7 +70,7 @@ function groupPagesByCreatedDate(pages: MindSpacePage[]) {
|
||||
.sort(([left], [right]) => right.localeCompare(left))
|
||||
.map(([dateKey, items]) => ({
|
||||
dateKey,
|
||||
heading: formatDateHeading(items[0]?.createdAt ?? Date.parse(`${dateKey}T00:00:00`)),
|
||||
heading: formatDateHeading(items[0]?.createdAt ?? Date.parse(`${dateKey}T12:00:00+08:00`)),
|
||||
items,
|
||||
}));
|
||||
}
|
||||
|
||||
+173
-81
@@ -58,6 +58,9 @@ import {
|
||||
buildAutoChatSkillPrefix,
|
||||
} from '../../chat-skills.mjs';
|
||||
import {
|
||||
buildQueuedChatSubmitNotice,
|
||||
canFlushQueuedChatSubmit,
|
||||
isChatSubmitBusy,
|
||||
reconcileSessionEventRequestContext,
|
||||
resolvePostAgentRunChatState,
|
||||
shouldIgnoreZeroActivityFinish,
|
||||
@@ -89,6 +92,24 @@ import {
|
||||
touchSession,
|
||||
} from '../utils/sessions';
|
||||
|
||||
type ChatSubmitOptions = {
|
||||
mindspaceContext?: MindSpaceChatContext;
|
||||
messageId?: string;
|
||||
forceDeepReasoning?: boolean;
|
||||
pgRequired?: boolean;
|
||||
imageGenerationMode?: ImageGenerationMode;
|
||||
selectedChatSkill?: string;
|
||||
fileAttachments?: ChatFileAttachment[];
|
||||
goalRunId?: string;
|
||||
};
|
||||
|
||||
type PendingChatSubmitEntry = {
|
||||
userMessage: Message;
|
||||
options?: ChatSubmitOptions;
|
||||
normalizedImageUrls: string[];
|
||||
normalizedFileAttachments: ChatFileAttachment[];
|
||||
};
|
||||
|
||||
const INSUFFICIENT_BALANCE_NOTICE = '余额不足,请充值后继续使用';
|
||||
const REPLY_RECOVERY_SYNC_DELAYS_MS = [1500, 5000, 12000];
|
||||
const FINISH_SYNC_RETRY_DELAYS_MS = [500, 1500, 3000];
|
||||
@@ -400,11 +421,18 @@ export function useTKMindChat(
|
||||
const onUserUpdateRef = useRef(onUserUpdate);
|
||||
const chatImageCategoryIdRef = useRef<string | null>(null);
|
||||
const chatFileCategoryIdRef = useRef<string | null>(null);
|
||||
const pendingSubmitQueueRef = useRef<PendingChatSubmitEntry[]>([]);
|
||||
const flushingPendingSubmitRef = useRef(false);
|
||||
const pendingToolRef = useRef<ToolConfirmation | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
chatStateRef.current = chatState;
|
||||
}, [chatState]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingToolRef.current = pendingTool;
|
||||
}, [pendingTool]);
|
||||
|
||||
const clearActiveRequestMissingTimer = useCallback(() => {
|
||||
if (!activeRequestMissingTimerRef.current) return;
|
||||
window.clearTimeout(activeRequestMissingTimerRef.current);
|
||||
@@ -1175,6 +1203,8 @@ export function useTKMindChat(
|
||||
unsubscribeRef.current = null;
|
||||
subscribedSessionIdRef.current = null;
|
||||
clearActiveRequestMissingTimer();
|
||||
pendingSubmitQueueRef.current = [];
|
||||
flushingPendingSubmitRef.current = false;
|
||||
setError(null);
|
||||
setPendingTool(null);
|
||||
agentRunPendingRef.current = false;
|
||||
@@ -1478,84 +1508,23 @@ export function useTKMindChat(
|
||||
[session, chatState, connectSession, resetSessionView, sessions],
|
||||
);
|
||||
|
||||
const submit = useCallback(
|
||||
const executeAgentSubmit = useCallback(
|
||||
async (
|
||||
text: string,
|
||||
options?: {
|
||||
mindspaceContext?: MindSpaceChatContext;
|
||||
messageId?: string;
|
||||
forceDeepReasoning?: boolean;
|
||||
pgRequired?: boolean;
|
||||
imageGenerationMode?: ImageGenerationMode;
|
||||
selectedChatSkill?: string;
|
||||
fileAttachments?: ChatFileAttachment[];
|
||||
goalRunId?: string;
|
||||
},
|
||||
imageUrls?: string[],
|
||||
previewImageUrls?: string[],
|
||||
userMessage: Message,
|
||||
options: ChatSubmitOptions | undefined,
|
||||
normalizedImageUrls: string[],
|
||||
normalizedFileAttachments: ChatFileAttachment[],
|
||||
) => {
|
||||
const normalizedImageUrls = (imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim());
|
||||
const normalizedPreviewImageUrls = (previewImageUrls ?? []).filter(
|
||||
(value) => typeof value === 'string' && value.trim(),
|
||||
);
|
||||
const normalizedFileAttachments = (options?.fileAttachments ?? []).filter(
|
||||
(item) => item?.downloadUrl?.trim() && item?.filename?.trim(),
|
||||
);
|
||||
if (!text.trim() && normalizedImageUrls.length === 0 && normalizedFileAttachments.length === 0) return;
|
||||
// Use the ref here (not the React state) so that rapid back-to-back calls in the
|
||||
// same render cycle are blocked even before the state update has been re-rendered.
|
||||
if (
|
||||
chatStateRef.current === 'streaming' ||
|
||||
chatStateRef.current === 'loading' ||
|
||||
chatStateRef.current === 'connecting' ||
|
||||
chatStateRef.current === 'waiting'
|
||||
) return;
|
||||
|
||||
const trimmed = text.trim();
|
||||
const mindspacePrefix = options?.mindspaceContext
|
||||
? buildContextPrefix(options.mindspaceContext)
|
||||
: '';
|
||||
const userPrefix = buildUserAddressPrefix(userRef.current);
|
||||
const skillPrefix = buildAutoChatSkillPrefix(trimmed, grantedSkills ?? []);
|
||||
const pgContractPrefix = options?.pgRequired
|
||||
? '[交付约束:用户已明确要求使用专属 PostgreSQL 数据空间。若生成页面,必须按 page-data-collect 完成建表、dataset、policy 和 workspace page 绑定;禁止 localStorage、SQLite、静态 JSON 或内存持久化。所有验证通过前不得回复已发布或给出页面链接。]\n'
|
||||
: '';
|
||||
const agentPrefix = `${userPrefix}${mindspacePrefix}${skillPrefix}${pgContractPrefix}`;
|
||||
const priorMessageCount = messagesRef.current.length;
|
||||
const userMessage = buildUserMessage(trimmed, {
|
||||
id: options?.messageId,
|
||||
agentText: `${agentPrefix}${trimmed}`,
|
||||
displayText: trimmed,
|
||||
imageUrls: normalizedImageUrls,
|
||||
previewImageUrls: normalizedPreviewImageUrls,
|
||||
fileAttachments: normalizedFileAttachments,
|
||||
});
|
||||
userMessage.metadata = {
|
||||
...userMessage.metadata,
|
||||
memindRun: {
|
||||
...(userMessage.metadata?.memindRun && typeof userMessage.metadata.memindRun === 'object'
|
||||
? userMessage.metadata.memindRun
|
||||
: {}),
|
||||
sessionMessageCount: priorMessageCount,
|
||||
...(options?.pgRequired ? { pgRequired: true } : {}),
|
||||
imageGenerationMode: options?.imageGenerationMode ?? 'auto',
|
||||
...(options?.selectedChatSkill ? { selectedChatSkill: options.selectedChatSkill } : {}),
|
||||
},
|
||||
};
|
||||
const trimmed = getDisplayText(userMessage).trim();
|
||||
const requestId = crypto.randomUUID();
|
||||
const submitToken = connectTokenRef.current;
|
||||
activeRequestId.current = requestId;
|
||||
messagesRef.current = [...messagesRef.current, userMessage];
|
||||
messageHistoryLoadedCountRef.current = messagesRef.current.length;
|
||||
setMessages(messagesRef.current);
|
||||
setChatState('waiting');
|
||||
// Immediately reflect in the ref so any synchronous re-entry is blocked before
|
||||
// the next React render cycle runs the useEffect that normally syncs this ref.
|
||||
chatStateRef.current = 'waiting';
|
||||
setError(null);
|
||||
setPendingTool(null);
|
||||
|
||||
let activeSessionId = session?.id ?? null;
|
||||
let activeSessionId = sessionRef.current?.id ?? null;
|
||||
|
||||
if (activeSessionId) {
|
||||
setSessions((prev) => touchSession(prev, activeSessionId!, 1));
|
||||
@@ -1634,14 +1603,15 @@ export function useTKMindChat(
|
||||
});
|
||||
if (submitToken !== connectTokenRef.current) return;
|
||||
agentRunPendingRef.current = false;
|
||||
activeSessionId = finishedRun.sessionId;
|
||||
activeSessionId = finishedRun.sessionId ?? activeSessionId;
|
||||
if (!activeSessionId) {
|
||||
throw new Error('后台任务已提交,但未返回会话');
|
||||
}
|
||||
if (normalizedImageUrls.length > 0 || normalizedFileAttachments.length > 0) {
|
||||
void claimMindSpaceConversationUploads(activeSessionId, userMessage.id).catch(() => {});
|
||||
}
|
||||
if (!session?.id || session.id !== activeSessionId) {
|
||||
const currentSessionId = sessionRef.current?.id ?? null;
|
||||
if (!currentSessionId || currentSessionId !== activeSessionId) {
|
||||
const nextSession: Session = {
|
||||
id: activeSessionId,
|
||||
name: 'New Chat',
|
||||
@@ -1700,10 +1670,6 @@ export function useTKMindChat(
|
||||
const nextChatState = resolvePostAgentRunChatState({
|
||||
chatState: chatStateRef.current,
|
||||
finishedViaPortalDirectChat,
|
||||
// The agent-run result is authoritative even when the immediate
|
||||
// session snapshot has not yet carried portal-direct metadata.
|
||||
// Without this, a completed Page Data task can re-enter streaming
|
||||
// and leave the Stop button attached to no active request.
|
||||
agentRunSucceeded: finishedRun.status === 'succeeded',
|
||||
});
|
||||
if (nextChatState === 'idle') {
|
||||
@@ -1735,9 +1701,6 @@ export function useTKMindChat(
|
||||
errorCode(err),
|
||||
)
|
||||
) {
|
||||
// Goose may report its session concurrency guard as a failed run
|
||||
// message instead of an HTTP 409. Reattach to the session stream
|
||||
// and reconcile the snapshot; do not strand the composer in error.
|
||||
subscribeToSession(activeSessionId);
|
||||
setChatState('streaming');
|
||||
setError(null);
|
||||
@@ -1745,7 +1708,9 @@ export function useTKMindChat(
|
||||
return;
|
||||
}
|
||||
agentRunPendingRef.current = false;
|
||||
if (session && activeSessionId) setSessions((prev) => touchSession(prev, activeSessionId!, -1));
|
||||
if (sessionRef.current && activeSessionId) {
|
||||
setSessions((prev) => touchSession(prev, activeSessionId!, -1));
|
||||
}
|
||||
if (err instanceof ApiError && err.status === 402) {
|
||||
notifyInsufficientBalance();
|
||||
} else {
|
||||
@@ -1758,18 +1723,143 @@ export function useTKMindChat(
|
||||
},
|
||||
[
|
||||
notifyInsufficientBalance,
|
||||
session,
|
||||
grantedSkills,
|
||||
clearActiveRequestMissingTimer,
|
||||
subscribeToSession,
|
||||
scheduleReplyRecoverySync,
|
||||
ensureProvider,
|
||||
loadProjectMemory,
|
||||
refreshSessions,
|
||||
syncSessionMessages,
|
||||
],
|
||||
);
|
||||
|
||||
const flushPendingSubmitQueue = useCallback(async () => {
|
||||
if (flushingPendingSubmitRef.current) return;
|
||||
if (
|
||||
!canFlushQueuedChatSubmit({
|
||||
chatState: chatStateRef.current,
|
||||
agentRunPending: agentRunPendingRef.current,
|
||||
pendingTool: Boolean(pendingToolRef.current),
|
||||
queueLength: pendingSubmitQueueRef.current.length,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = pendingSubmitQueueRef.current.shift();
|
||||
if (!next) return;
|
||||
|
||||
flushingPendingSubmitRef.current = true;
|
||||
try {
|
||||
await executeAgentSubmit(
|
||||
next.userMessage,
|
||||
next.options,
|
||||
next.normalizedImageUrls,
|
||||
next.normalizedFileAttachments,
|
||||
);
|
||||
} finally {
|
||||
flushingPendingSubmitRef.current = false;
|
||||
if (
|
||||
canFlushQueuedChatSubmit({
|
||||
chatState: chatStateRef.current,
|
||||
agentRunPending: agentRunPendingRef.current,
|
||||
pendingTool: Boolean(pendingToolRef.current),
|
||||
queueLength: pendingSubmitQueueRef.current.length,
|
||||
})
|
||||
) {
|
||||
void flushPendingSubmitQueue();
|
||||
}
|
||||
}
|
||||
}, [executeAgentSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!canFlushQueuedChatSubmit({
|
||||
chatState,
|
||||
agentRunPending: agentRunPendingRef.current,
|
||||
pendingTool: Boolean(pendingTool),
|
||||
queueLength: pendingSubmitQueueRef.current.length,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void flushPendingSubmitQueue();
|
||||
}, [chatState, pendingTool, flushPendingSubmitQueue]);
|
||||
|
||||
const submit = useCallback(
|
||||
async (
|
||||
text: string,
|
||||
options?: ChatSubmitOptions,
|
||||
imageUrls?: string[],
|
||||
previewImageUrls?: string[],
|
||||
) => {
|
||||
const normalizedImageUrls = (imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim());
|
||||
const normalizedPreviewImageUrls = (previewImageUrls ?? []).filter(
|
||||
(value) => typeof value === 'string' && value.trim(),
|
||||
);
|
||||
const normalizedFileAttachments = (options?.fileAttachments ?? []).filter(
|
||||
(item) => item?.downloadUrl?.trim() && item?.filename?.trim(),
|
||||
);
|
||||
if (!text.trim() && normalizedImageUrls.length === 0 && normalizedFileAttachments.length === 0) return;
|
||||
|
||||
const trimmed = text.trim();
|
||||
const mindspacePrefix = options?.mindspaceContext
|
||||
? buildContextPrefix(options.mindspaceContext)
|
||||
: '';
|
||||
const userPrefix = buildUserAddressPrefix(userRef.current);
|
||||
const skillPrefix = buildAutoChatSkillPrefix(trimmed, grantedSkills ?? []);
|
||||
const pgContractPrefix = options?.pgRequired
|
||||
? '[交付约束:用户已明确要求使用专属 PostgreSQL 数据空间。若生成页面,必须按 page-data-collect 完成建表、dataset、policy 和 workspace page 绑定;禁止 localStorage、SQLite、静态 JSON 或内存持久化。所有验证通过前不得回复已发布或给出页面链接。]\n'
|
||||
: '';
|
||||
const agentPrefix = `${userPrefix}${mindspacePrefix}${skillPrefix}${pgContractPrefix}`;
|
||||
const priorMessageCount = messagesRef.current.length;
|
||||
const userMessage = buildUserMessage(trimmed, {
|
||||
id: options?.messageId,
|
||||
agentText: `${agentPrefix}${trimmed}`,
|
||||
displayText: trimmed,
|
||||
imageUrls: normalizedImageUrls,
|
||||
previewImageUrls: normalizedPreviewImageUrls,
|
||||
fileAttachments: normalizedFileAttachments,
|
||||
});
|
||||
userMessage.metadata = {
|
||||
...userMessage.metadata,
|
||||
memindRun: {
|
||||
...(userMessage.metadata?.memindRun && typeof userMessage.metadata.memindRun === 'object'
|
||||
? userMessage.metadata.memindRun
|
||||
: {}),
|
||||
sessionMessageCount: priorMessageCount,
|
||||
...(options?.pgRequired ? { pgRequired: true } : {}),
|
||||
imageGenerationMode: options?.imageGenerationMode ?? 'auto',
|
||||
...(options?.selectedChatSkill ? { selectedChatSkill: options.selectedChatSkill } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
if (isChatSubmitBusy(chatStateRef.current)) {
|
||||
pendingSubmitQueueRef.current.push({
|
||||
userMessage,
|
||||
options,
|
||||
normalizedImageUrls,
|
||||
normalizedFileAttachments,
|
||||
});
|
||||
messagesRef.current = [...messagesRef.current, userMessage];
|
||||
messageHistoryLoadedCountRef.current = messagesRef.current.length;
|
||||
setMessages(messagesRef.current);
|
||||
setNotice(buildQueuedChatSubmitNotice(pendingSubmitQueueRef.current.length));
|
||||
return;
|
||||
}
|
||||
|
||||
messagesRef.current = [...messagesRef.current, userMessage];
|
||||
messageHistoryLoadedCountRef.current = messagesRef.current.length;
|
||||
setMessages(messagesRef.current);
|
||||
await executeAgentSubmit(
|
||||
userMessage,
|
||||
options,
|
||||
normalizedImageUrls,
|
||||
normalizedFileAttachments,
|
||||
);
|
||||
},
|
||||
[executeAgentSubmit, grantedSkills],
|
||||
);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
if (!session || !activeRequestId.current) return;
|
||||
try {
|
||||
@@ -1894,6 +1984,8 @@ export function useTKMindChat(
|
||||
clearStoredSessionId(userRef.current?.id);
|
||||
agentRunPendingRef.current = false;
|
||||
activeRequestId.current = null;
|
||||
pendingSubmitQueueRef.current = [];
|
||||
flushingPendingSubmitRef.current = false;
|
||||
messagesRef.current = [];
|
||||
messageHistoryLoadedCountRef.current = 0;
|
||||
messageHistoryTotalRef.current = 0;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** User-facing product name shown in nav, login, and page chrome. */
|
||||
export const APP_DISPLAY_NAME = 'Memind';
|
||||
export const APP_DISPLAY_NAME = 'TKMind';
|
||||
|
||||
/** Branded tagline suffix used on welcome and marketing surfaces. */
|
||||
export const APP_DISPLAY_TAGLINE = `${APP_DISPLAY_NAME} 智趣`;
|
||||
|
||||
@@ -12,9 +12,10 @@ export const SYSTEM_DISCLOSURE_MODE = Object.freeze({
|
||||
});
|
||||
|
||||
export const DEFAULT_SYSTEM_DISCLOSURE_REFUSAL =
|
||||
'出于安全与隐私考虑,我不能提供 TKMind、Memind 或 MindSpace 的内部技术实现信息。我可以继续帮助你了解公开功能、使用方法、服务边界,或者讨论不针对本系统的一般性技术原理。';
|
||||
'出于安全与隐私考虑,我不能提供 TKMind 或 MindSpace 的内部技术实现信息。我可以继续帮助你了解公开功能、使用方法、服务边界,或者讨论不针对本系统的一般性技术原理。';
|
||||
|
||||
const DEFAULT_PRODUCT_NAMES = Object.freeze([
|
||||
'tikmind',
|
||||
'tkmind',
|
||||
'memind',
|
||||
'mindspace',
|
||||
@@ -42,7 +43,7 @@ const TECHNICAL_CATEGORY_PATTERNS = Object.freeze({
|
||||
/(?:底层|内部|系统|技术|整体|服务|运行时|agent)\s*(?:架构|设计|实现|原理|机制)/iu,
|
||||
/(?:架构|设计|实现|原理|机制)\s*(?:图|说明|细节|文档|方案|是什|怎么|如何)/iu,
|
||||
/(?:技术栈|开发语言|编程语言|前端框架|后端框架|开源组件|内部组件|依赖版本|运行框架)/iu,
|
||||
/(?:tkmind|memind|mindspace).{0,24}(?:关系|区别|协作|调用链|怎么工作|如何工作)/iu,
|
||||
/(?:tikmind|tkmind|memind|mindspace).{0,24}(?:关系|区别|协作|调用链|怎么工作|如何工作)/iu,
|
||||
/\b(?:architecture|internals?|implementation|system design|runtime design)\b/iu,
|
||||
],
|
||||
model_and_routing: [
|
||||
|
||||
@@ -111,12 +111,14 @@ test('buildVisionPayload sends all current-turn images to Qwen in order', async
|
||||
|
||||
assert.equal(analyzedImages.length, 2);
|
||||
assert.deepEqual(analyzedImages.map((item) => item.rawUrl), imageUrls);
|
||||
assert.deepEqual(result?.userMessage?.metadata?.imageUrls, [
|
||||
assert.equal(result?.userMessage?.metadata?.imageUrls, undefined);
|
||||
assert.deepEqual(result?.userMessage?.metadata?.archivedImageUrls, [
|
||||
'/MindSpace/user-1/public/wechat-mp/first.jpg',
|
||||
'/MindSpace/user-1/public/wechat-mp/second.jpg',
|
||||
]);
|
||||
assert.match(result?.userMessage?.content?.[0]?.text ?? '', /本轮用户仅上传 2 张图片/);
|
||||
assert.match(result?.userMessage?.content?.[0]?.text ?? '', /图片2/);
|
||||
assert.doesNotMatch(result?.userMessage?.content?.[0]?.text ?? '', /\[图片\d+\]:/);
|
||||
});
|
||||
|
||||
test('buildVisionPayload does not mark billable usage when vision analysis fails', async () => {
|
||||
@@ -171,4 +173,9 @@ test('buildVisionPayload strips image_url content parts for text-only Goose prov
|
||||
false,
|
||||
);
|
||||
assert.match(result?.userMessage?.content?.[0]?.text ?? '', /蓝色方块/);
|
||||
assert.equal(result?.userMessage?.metadata?.imageUrls, undefined);
|
||||
assert.deepEqual(result?.userMessage?.metadata?.archivedImageUrls, [
|
||||
'/api/mindspace/v1/assets/asset-7/download?inline=1',
|
||||
]);
|
||||
assert.doesNotMatch(result?.userMessage?.content?.[0]?.text ?? '', /\[图片\d+\]:/);
|
||||
});
|
||||
|
||||
+12
-8
@@ -35,6 +35,7 @@ import { extractAttachmentText } from './mindspace-attachment-text.mjs';
|
||||
import {
|
||||
buildCurrentTurnImageScopeNote,
|
||||
conversationHasImageUrlContent,
|
||||
detachCurrentTurnImagesForTextProvider,
|
||||
extractCurrentTurnImageUrls,
|
||||
scrubConversationHistoricalImageAttachments,
|
||||
} from './chat-image-turn-scope.mjs';
|
||||
@@ -1004,15 +1005,18 @@ export async function buildVisionPayload({
|
||||
.filter((url) => typeof url === 'string' && url.trim());
|
||||
|
||||
return {
|
||||
userMessage: {
|
||||
...userMessage,
|
||||
content: updatedContent,
|
||||
metadata: {
|
||||
...(userMessage.metadata ?? {}),
|
||||
...(originalDisplayText ? { displayText: originalDisplayText } : {}),
|
||||
...(canonicalImageUrls.length ? { imageUrls: canonicalImageUrls } : {}),
|
||||
userMessage: detachCurrentTurnImagesForTextProvider(
|
||||
{
|
||||
...userMessage,
|
||||
content: updatedContent,
|
||||
metadata: {
|
||||
...(userMessage.metadata ?? {}),
|
||||
...(originalDisplayText ? { displayText: originalDisplayText } : {}),
|
||||
...(canonicalImageUrls.length ? { imageUrls: canonicalImageUrls } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
canonicalImageUrls,
|
||||
),
|
||||
billableImageCount: visionDescription ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1707,6 +1707,8 @@ test('submitSessionReplyForUser applies the shared Qwen vision preprocessing pat
|
||||
const forwardedText = replyBodies[0]?.user_message?.content?.[0]?.text ?? '';
|
||||
assert.match(forwardedText, /Qwen VL 图片描述/);
|
||||
assert.match(forwardedText, /一件蓝色产品/);
|
||||
assert.equal(replyBodies[0]?.user_message?.metadata?.imageUrls, undefined);
|
||||
assert.ok(Array.isArray(replyBodies[0]?.user_message?.metadata?.archivedImageUrls));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+26
-10
@@ -1088,6 +1088,12 @@ export function isWechatPageDataTask(text) {
|
||||
return isPageDataIntent(text) || isPageDataDevIntent(text);
|
||||
}
|
||||
|
||||
export function isWechatHistoricalImageSessionError(message) {
|
||||
return /historical_image_session_update_unsupported|unknown variant [`']?image_url/i.test(
|
||||
String(message ?? '').trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export function isRecoverableWechatAgentSessionError(message) {
|
||||
const normalized = String(message ?? '').trim();
|
||||
if (!normalized) return false;
|
||||
@@ -1095,9 +1101,7 @@ export function isRecoverableWechatAgentSessionError(message) {
|
||||
if (/wechat_page_fresh_thumbnail_required:/i.test(normalized)) return true;
|
||||
if (/403|404|not found|无权访问/i.test(normalized)) return true;
|
||||
if (/tool_calls|tool_call_id|insufficient tool messages/i.test(normalized)) return true;
|
||||
if (/historical_image_session_update_unsupported|unknown variant [`']?image_url/i.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (isWechatHistoricalImageSessionError(normalized)) return true;
|
||||
if (/session already has an active request|active request.*cancel/i.test(normalized)) return true;
|
||||
if (/wechat_agent_incomplete_reply/i.test(normalized)) return true;
|
||||
return false;
|
||||
@@ -1712,7 +1716,13 @@ export function createWechatMpService({
|
||||
const attachRecentMediaForFollowup = (openid, intent, mediaAnalysisEnabled) => {
|
||||
if (!mediaAnalysisEnabled || intent?.msgType !== 'text' || intent?.media?.publicUrl) return;
|
||||
const text = String(intent?.agentText ?? '');
|
||||
if (!/(?:刚才|之前|上一|这张|这份|图片|图像|照片|文件|文档|表格|excel|word)/iu.test(text)) return;
|
||||
if (
|
||||
!/(?:刚才|之前|上一|这张|这份|图片|图像|照片|文件|文档|表格|excel|word|报告|解读|化验|检验|做成页面|做个页面|做页面|生成页面)/iu.test(
|
||||
text,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const key = String(openid ?? '').trim();
|
||||
const recent = recentMediaByOpenid.get(key);
|
||||
if (
|
||||
@@ -3099,8 +3109,13 @@ export function createWechatMpService({
|
||||
const mayBeStaleSession =
|
||||
sessionId
|
||||
&& (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message));
|
||||
const historicalImageError = isWechatHistoricalImageSessionError(message);
|
||||
if (mayBeStaleSession) {
|
||||
if (sessionPageContinuation && isWechatPageContinuationRepairableError(message)) {
|
||||
if (
|
||||
sessionPageContinuation
|
||||
&& !historicalImageError
|
||||
&& isWechatPageContinuationRepairableError(message)
|
||||
) {
|
||||
const text = buildPagePublishFailureText();
|
||||
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
|
||||
logger.warn?.('WeChat MP page continuation repair route clear failed:', clearErr);
|
||||
@@ -3116,7 +3131,7 @@ export function createWechatMpService({
|
||||
repairError.wechatAgentSessionId = sessionId;
|
||||
throw repairError;
|
||||
}
|
||||
if (sessionPageContinuation) {
|
||||
if (sessionPageContinuation && !historicalImageError) {
|
||||
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
|
||||
logger.warn?.('WeChat MP contextual follow-up route clear failed:', clearErr);
|
||||
});
|
||||
@@ -3144,17 +3159,18 @@ export function createWechatMpService({
|
||||
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
|
||||
const retryId = crypto.randomUUID();
|
||||
const retryStartedAt = Date.now();
|
||||
const retryPageContinuation = sessionPageContinuation && !historicalImageError;
|
||||
const retryPrompt =
|
||||
wechatIntent.kind === 'page.generate'
|
||||
? buildPageGenerateAgentPrompt(intent, {
|
||||
wantsDocx: wechatIntent.wantsDocx,
|
||||
imagePolicy,
|
||||
preferImmediateContext: sessionPageContinuation,
|
||||
carriedSessionContent,
|
||||
preferImmediateContext: retryPageContinuation,
|
||||
carriedSessionContent: retryPageContinuation ? carriedSessionContent : '',
|
||||
})
|
||||
: buildWechatAgentPrompt(intent, {
|
||||
imagePolicy,
|
||||
preferSessionPageContinuation: sessionPageContinuation,
|
||||
preferSessionPageContinuation: retryPageContinuation,
|
||||
});
|
||||
const reply = await executeSessionReply(
|
||||
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
||||
@@ -3171,7 +3187,7 @@ export function createWechatMpService({
|
||||
userId: user.userId,
|
||||
sessionId,
|
||||
userMessage,
|
||||
preserveAgentPrompt: sessionPageContinuation,
|
||||
preserveAgentPrompt: retryPageContinuation,
|
||||
}),
|
||||
submitReply: submitSessionReply
|
||||
? ({ requestId: replyRequestId, userMessage }) =>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
findRecoverableWechatAgentErrorInReply,
|
||||
isRecoverableWechatAgentSessionError,
|
||||
isWechatAgentApiErrorText,
|
||||
isWechatHistoricalImageSessionError,
|
||||
sanitizeWechatAgentOutboundText,
|
||||
loadWechatMpConfig,
|
||||
maybeAttachPublishedHtmlLink,
|
||||
@@ -385,6 +386,13 @@ test('WeChat session page continuation covers retry, edit, and poem edits', () =
|
||||
shouldDeliverWechatHtmlArtifacts(poemEdit, { agentText: '把诗里第三段改长一点' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isWechatSessionPageContinuation(
|
||||
classifyWechatIntent({ msgType: 'text', agentText: '解读详细报告,做成页面' }),
|
||||
'解读详细报告,做成页面',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('WeChat immediate-context page skips fresh thumbnail requirement', () => {
|
||||
@@ -3477,6 +3485,16 @@ test('isRecoverableWechatAgentSessionError detects poisoned tool_calls history',
|
||||
isRecoverableWechatAgentSessionError('historical_image_session_update_unsupported:405'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isWechatHistoricalImageSessionError('historical_image_session_update_unsupported:405'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isWechatHistoricalImageSessionError(
|
||||
'Request failed: Bad request (400): messages[74]: unknown variant `image_url`, expected `text`',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(isRecoverableWechatAgentSessionError('network timeout'), false);
|
||||
});
|
||||
|
||||
@@ -3572,6 +3590,113 @@ test('wechat mp rotates and retries when historical image isolation is unsupport
|
||||
assert.equal(sentPayloads[0].text.content, '新会话已恢复,可以继续。');
|
||||
});
|
||||
|
||||
test('wechat mp rotates page continuation instead of dropping image_url session errors', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
const nonce = 'nonce';
|
||||
const submittedSessions = [];
|
||||
const sentPayloads = [];
|
||||
let activeSessionId = 'session-1';
|
||||
let routeCleared = false;
|
||||
const poem = `《临江仙·秋思》${'昨夜西风凋碧树,独上高楼,望尽天涯路。'.repeat(3)}`;
|
||||
|
||||
const service = createBoundWechatService({
|
||||
token,
|
||||
startAgentSession: async () => ({ id: 'session-2' }),
|
||||
userAuth: {
|
||||
async getWechatAgentRoute() {
|
||||
return routeCleared ? null : { agentSessionId: activeSessionId, status: 'active' };
|
||||
},
|
||||
async clearWechatAgentRoute() {
|
||||
routeCleared = true;
|
||||
},
|
||||
async upsertWechatAgentRoute({ agentSessionId }) {
|
||||
activeSessionId = agentSessionId;
|
||||
routeCleared = false;
|
||||
},
|
||||
},
|
||||
submitSessionReply: async ({ sessionId, options }) => {
|
||||
submittedSessions.push(sessionId);
|
||||
assert.equal(options?.requireHistoricalImageIsolation, true);
|
||||
if (sessionId === 'session-1') {
|
||||
throw new Error(
|
||||
'Ran into this error: Request failed: Bad request (400): Failed to deserialize the JSON body into the target type: messages[74]: unknown variant `image_url`, expected `text`',
|
||||
);
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
sessionApiFetch: async (sessionId, pathname) => {
|
||||
if (pathname === `/sessions/${sessionId}`) {
|
||||
return new Response(JSON.stringify({
|
||||
conversation: sessionId === 'session-1'
|
||||
? [{ role: 'assistant', content: [{ type: 'text', text: poem }] }]
|
||||
: [],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (pathname === `/sessions/${sessionId}/events`) {
|
||||
if (sessionId === 'session-1') {
|
||||
return new Response('', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
});
|
||||
}
|
||||
return new Response(
|
||||
[
|
||||
'data: {"type":"Message","request_id":"req-page-retry","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"新会话已恢复,可以继续。"}]}}\n\n',
|
||||
'data: {"type":"Finish","request_id":"req-page-retry","token_state":{"inputTokens":1,"outputTokens":2}}\n\n',
|
||||
].join(''),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
throw new Error(`unexpected api path: ${sessionId} ${pathname}`);
|
||||
},
|
||||
wechatFetch: async (url, init = {}) => {
|
||||
if (String(url).includes('/cgi-bin/stable_token')) {
|
||||
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (String(url).includes('/cgi-bin/message/custom/send')) {
|
||||
sentPayloads.push(JSON.parse(init.body));
|
||||
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected wechat url: ${url}`);
|
||||
},
|
||||
});
|
||||
|
||||
const originalRandomUuid = crypto.randomUUID;
|
||||
crypto.randomUUID = (() => {
|
||||
const ids = ['req-page-first', 'req-page-retry'];
|
||||
return () => ids.shift() ?? 'req-page-retry';
|
||||
})();
|
||||
try {
|
||||
const result = await service.handleInboundMessage(
|
||||
inboundXml({ content: '把刚才的诗做成页面' }),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
await result.task;
|
||||
} finally {
|
||||
crypto.randomUUID = originalRandomUuid;
|
||||
}
|
||||
|
||||
assert.deepEqual(submittedSessions, ['session-1', 'session-2']);
|
||||
assert.equal(activeSessionId, 'session-2');
|
||||
assert.equal(
|
||||
sentPayloads.some((payload) => /没能可靠确认/.test(String(payload?.text?.content ?? ''))),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('findRecoverableWechatAgentErrorInReply scans all assistant messages', () => {
|
||||
const toolCallsError =
|
||||
"Request failed: Bad request (400): An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message).";
|
||||
@@ -5318,6 +5443,119 @@ test('wechat mp serializes image and follow-up text and reattaches recent image'
|
||||
assert.equal(submitCalls[1].userMessage.metadata.msgType, 'text');
|
||||
});
|
||||
|
||||
test('wechat mp reattaches recent image for report interpretation follow-up', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
const nonce = 'nonce';
|
||||
const testUserId = 'test-user-report-followup';
|
||||
const submitCalls = [];
|
||||
let eventCall = 0;
|
||||
let releaseFirst = null;
|
||||
const service = createBoundWechatService({
|
||||
token,
|
||||
config: {
|
||||
mediaAnalysisGrayUsers: [testUserId],
|
||||
},
|
||||
userAuth: {
|
||||
async findWechatUserByOpenid() {
|
||||
return { userId: testUserId, status: 'active', nickname: '唐' };
|
||||
},
|
||||
},
|
||||
sessionApiFetch: async (_sessionId, pathname) => {
|
||||
if (pathname === '/sessions/session-1/events') {
|
||||
eventCall += 1;
|
||||
if (eventCall === 1) {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
releaseFirst = () => {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
'data: {"type":"Message","message":{"id":"assistant-image","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"图片已识别。"}]}}\n\n' +
|
||||
'data: {"type":"Finish"}\n\n',
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
};
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
[
|
||||
'data: {"type":"Message","message":{"id":"assistant-followup","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已解读报告。"}]}}\n\n',
|
||||
'data: {"type":"Finish"}\n\n',
|
||||
].join(''),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
throw new Error(`unexpected api path: ${pathname}`);
|
||||
},
|
||||
submitSessionReply: async (input) => {
|
||||
submitCalls.push(input);
|
||||
return { ok: true };
|
||||
},
|
||||
wechatFetch: async (url) => {
|
||||
if (String(url).includes('/cgi-bin/stable_token')) {
|
||||
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (String(url).includes('/cgi-bin/media/get')) {
|
||||
return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'image/png' },
|
||||
});
|
||||
}
|
||||
if (String(url).includes('/cgi-bin/message/custom/send')) {
|
||||
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected wechat url: ${url}`);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const imageResult = await service.handleInboundMessage(
|
||||
inboundXml({
|
||||
msgType: 'image',
|
||||
content: '',
|
||||
extraFields: { MediaId: 'media-report', PicUrl: 'https://wx.example.com/report.png' },
|
||||
}),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
while (submitCalls.length === 0) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const followupResult = await service.handleInboundMessage(
|
||||
inboundXml({ msgType: 'text', content: '解读详细报告,做成页面' }),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(submitCalls.length, 1);
|
||||
|
||||
releaseFirst();
|
||||
await imageResult.task;
|
||||
await followupResult.task;
|
||||
} finally {
|
||||
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
assert.equal(submitCalls.length, 2);
|
||||
assert.deepEqual(
|
||||
submitCalls[1].userMessage.metadata.imageUrls,
|
||||
submitCalls[0].userMessage.metadata.imageUrls,
|
||||
);
|
||||
assert.equal(submitCalls[1].userMessage.metadata.msgType, 'text');
|
||||
assert.equal(submitCalls[1].userMessage.metadata.displayText, '解读详细报告,做成页面');
|
||||
});
|
||||
|
||||
test('wechat mp groups consecutive images for one follow-up and clears the consumed batch', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
|
||||
Reference in New Issue
Block a user