feat: add message source context enhancement

This commit is contained in:
xmanrui 2026-08-29 10:46:06 +08:00
parent 5928b33057
commit 0cb79f74e9
69 changed files with 6217 additions and 597 deletions

View file

@ -14,6 +14,11 @@ import {
validateAgentPresetId,
} from './agent-preset.mjs';
import { CONNECTION_TEST_STATE_IDENTITY } from './connection-test.mjs';
import {
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
normalizeContextEnhancementConfig,
validateContextEnhancementConfig,
} from './context-enhancement.mjs';
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
const EMPTY_DOCUMENT = Object.freeze({ version: 1, workspaces: Object.freeze({}) });
@ -68,7 +73,17 @@ function normalizeDocument(value) {
}
}
}
return { version: 1, workspaces, agentPresets };
const contextEnhancement = Object.create(null);
// Enhancement damage is isolated from the existing workspace/preset document.
if (value.contextEnhancement && typeof value.contextEnhancement === 'object'
&& !Array.isArray(value.contextEnhancement)) {
for (const [botId, config] of Object.entries(value.contextEnhancement)) {
if (/^[A-Za-z0-9_-]{1,128}$/.test(botId)) {
contextEnhancement[botId] = normalizeContextEnhancementConfig(config);
}
}
}
return { version: 1, workspaces, agentPresets, contextEnhancement };
}
export async function validateWorkspacePath(value) {
@ -99,6 +114,7 @@ export class BotWorkspaceStore {
#defaultWorkspace;
#workspaces = {};
#agentPresets = {};
#contextEnhancement = {};
#generations = new Map();
#nextGeneration = 1;
#incarnations = new Map();
@ -121,10 +137,12 @@ export class BotWorkspaceStore {
if (!normalized) throw new Error('dsh-im workspace config is invalid');
this.#workspaces = normalized.workspaces;
this.#agentPresets = normalized.agentPresets;
this.#contextEnhancement = normalized.contextEnhancement;
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
this.#workspaces = {};
this.#agentPresets = {};
this.#contextEnhancement = {};
}
this.#generations.clear();
this.#nextGeneration = 1;
@ -156,6 +174,13 @@ export class BotWorkspaceStore {
return this.#agentPresets[botIdOf(botId)] ?? null;
}
contextEnhancementFor(botId) {
const id = botIdOf(botId);
return this.has(id) && Object.hasOwn(this.#contextEnhancement, id)
? this.#contextEnhancement[id]
: DEFAULT_CONTEXT_ENHANCEMENT_CONFIG;
}
generationFor(botId) {
return this.#generations.get(botIdOf(botId)) ?? null;
}
@ -270,6 +295,24 @@ export class BotWorkspaceStore {
});
}
async setContextEnhancement(botId, value, { incarnation } = {}) {
const id = botIdOf(botId);
const expectedIncarnation = incarnation === undefined ? this.incarnationFor(id) : incarnation;
const config = validateContextEnhancementConfig(value);
return this.#enqueue(id, async () => {
if (!this.has(id) || expectedIncarnation !== this.incarnationFor(id)) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
const next = { ...this.#contextEnhancement, [id]: config };
// Messages keep the previous committed snapshot until rename succeeds.
await this.#persist(next);
this.#contextEnhancement = next;
return config;
});
}
async bindWorkspaceSession(botId, value, {
conversationKey,
sessionId,
@ -408,6 +451,14 @@ export class BotWorkspaceStore {
});
}
/** A failed retirement must reach disk before the same config ID can be rebound. */
flushPendingRemoval(botId) {
if (!this.#dirtyRemovals.has(botId)) return undefined;
return this.#enqueue(botId, async () => {
if (this.#dirtyRemovals.has(botId)) await this.#persistCurrentDocument();
});
}
async remove(botId) {
const result = await this.retireAfterConfigCommit(botId);
if (result.error) throw result.error;
@ -419,6 +470,7 @@ export class BotWorkspaceStore {
const candidates = new Set([
...Object.keys(this.#workspaces),
...Object.keys(this.#agentPresets),
...Object.keys(this.#contextEnhancement),
...this.#dirtyRemovals,
]);
for (const botId of candidates) {
@ -435,6 +487,7 @@ export class BotWorkspaceStore {
...bot,
workspace: this.workspaceFor(bot.botId),
agentPreset: this.agentPresetFor(bot.botId),
contextEnhancement: this.contextEnhancementFor(bot.botId),
}
: bot),
};
@ -464,9 +517,11 @@ export class BotWorkspaceStore {
async #retireCurrentIncarnation(id) {
const hadWorkspace = Object.hasOwn(this.#workspaces, id);
const hadPreset = Object.hasOwn(this.#agentPresets, id);
const needsCleanup = hadWorkspace || hadPreset || this.#dirtyRemovals.has(id);
const hadContextEnhancement = Object.hasOwn(this.#contextEnhancement, id);
const needsCleanup = hadWorkspace || hadPreset || hadContextEnhancement || this.#dirtyRemovals.has(id);
delete this.#workspaces[id];
delete this.#agentPresets[id];
delete this.#contextEnhancement[id];
this.#generations.delete(id);
this.#incarnations.delete(id);
if (!needsCleanup) return {
@ -496,11 +551,14 @@ export class BotWorkspaceStore {
return queued;
}
async #persist() {
async #persist(contextEnhancement = this.#contextEnhancement) {
const document = { version: 1, workspaces: this.#workspaces };
if (Object.keys(this.#agentPresets).length > 0) {
document.agentPresets = this.#agentPresets;
}
if (Object.keys(contextEnhancement).length > 0) {
document.contextEnhancement = contextEnhancement;
}
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
const temporary = `${this.#path}.tmp`;
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
@ -513,7 +571,8 @@ export class BotWorkspaceStore {
async #persistCurrentDocument() {
if (Object.keys(this.#workspaces).length > 0
|| Object.keys(this.#agentPresets).length > 0) {
|| Object.keys(this.#agentPresets).length > 0
|| Object.keys(this.#contextEnhancement).length > 0) {
await this.#persist();
return;
}
@ -569,10 +628,16 @@ function targetStatus(controller) {
return Promise.resolve(controller.status());
}
/** Observe the config store's durable removal commit without changing its API. */
/** Observe durable removals and finish failed cleanup before a same-ID config save. */
export function observeBotWorkspaceRemovals(
configStore,
{ workspaces, method = 'remove', botIdFromRemoved = (removed) => removed?.botId },
{
workspaces,
method = 'remove',
botIdFromRemoved = (removed) => removed?.botId,
saveMethod = 'save',
botIdFromSave = (config) => config?.botId,
},
) {
if (!configStore || !workspaces || typeof configStore[method] !== 'function') {
throw new TypeError('configStore removal observer dependencies are required');
@ -588,6 +653,12 @@ export function observeBotWorkspaceRemovals(
return removed;
};
}
if (property === saveMethod && typeof value === 'function') {
return (...args) => {
const cleanup = workspaces.flushPendingRemoval(botIdFromSave(args[0], args));
return cleanup ? cleanup.then(() => value.apply(target, args)) : value.apply(target, args);
};
}
return typeof value === 'function' ? value.bind(target) : value;
},
});
@ -998,6 +1069,31 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
);
});
};
const updateContextEnhancement = (botId, value, projectStatus) => {
const incarnation = workspaces.incarnationFor(botId);
const config = validateContextEnhancementConfig(value);
return withBotTransition(botId, async () => {
const snapshot = await controller.status();
if (!snapshot?.bots?.some((bot) => bot?.botId === botId)) {
const error = new Error('找不到要修改的机器人。');
error.code = 'workspace-bot-not-found';
throw error;
}
const catalog = await resolveAgentPresetCatalog(agentPresetCatalog);
const decorated = workspaces.decorateStatus(snapshot);
const updated = {
...decorated,
bots: decorated.bots.map((bot) => bot?.botId === botId
? { ...bot, contextEnhancement: config } : bot),
...(catalog ? { agentPresetCatalog: catalog } : {}),
};
// QR/status projection can fail too. Prepare the complete response before
// commit so a failed save never publishes new running settings.
const result = projectStatus ? await projectStatus(updated) : updated;
await workspaces.setContextEnhancement(botId, config, { incarnation });
return result;
});
};
const deleteWithWorkspace = (botId, invokeDelete) => withBotTransition(botId, async () => {
// Fence the old runtime without changing the durable mapping. A crash
// before the controller removes its config therefore keeps the bot's
@ -1037,6 +1133,7 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
get(target, property) {
if (property === 'updateWorkspace') return updateWorkspace;
if (property === 'updateAgentPreset') return updateAgentPreset;
if (property === 'updateContextEnhancement') return updateContextEnhancement;
const value = Reflect.get(target, property, target);
if (typeof value !== 'function') return value;
if (property === 'deleteBot') {

View file

@ -0,0 +1,135 @@
// Shared by the Host and settings UI; keep this module browser-compatible.
export const CONTEXT_ENHANCEMENT_FIELDS = Object.freeze([
'channel', 'conversationType', 'senderId', 'senderName', 'botId',
]);
export const CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH = 8_000;
export const CONTEXT_GUIDANCE_EXAMPLE = `仅依据当前消息的 <dsh_im_source> 中实际提供的字段理解来源;没有提供的字段不要猜测或补全。
conversationType是群聊时回复严肃一点,conversationType是私聊时回复一定要幽默搞笑,像周星驰的电影一样搞笑`;
// Kept as an alias for integrations that imported the original template name.
export const DEFAULT_CONTEXT_GUIDANCE = CONTEXT_GUIDANCE_EXAMPLE;
export const DEFAULT_CONTEXT_ENHANCEMENT_CONFIG = Object.freeze({
groupEnabled: false,
directEnabled: false,
fields: Object.freeze(['senderId']),
guidance: '',
});
const CONFIG_KEYS = ['groupEnabled', 'directEnabled', 'fields', 'guidance'];
const CHANNELS = new Set([
'wecom', 'weixin', 'feishu', 'dingtalk', 'qq',
'slack', 'telegram', 'discord', 'whatsapp',
]);
const SOURCE_LIMITS = { channel: 16, conversationType: 6, senderId: 256, senderName: 256, botId: 128 };
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g;
function invalidConfig(message) {
const error = new TypeError(message);
error.code = 'context-enhancement-invalid';
return error;
}
/** Validate the complete atomic save, preserving explicit empty selections/text. */
export function validateContextEnhancementConfig(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)
|| ![Object.prototype, null].includes(Object.getPrototypeOf(input))
|| Reflect.ownKeys(input).length !== CONFIG_KEYS.length
|| !CONFIG_KEYS.every((key) => Object.hasOwn(input, key))) {
throw invalidConfig('请提交完整的上下文增强设置。');
}
const { groupEnabled, directEnabled, fields, guidance } = input;
if (typeof groupEnabled !== 'boolean' || typeof directEnabled !== 'boolean') {
throw invalidConfig('群聊和私聊开关必须是布尔值。');
}
if (!Array.isArray(fields) || ![...fields].every((field) => CONTEXT_ENHANCEMENT_FIELDS.includes(field))) {
throw invalidConfig('来源字段只能选择已定义的五个字段。');
}
if (typeof guidance !== 'string' || guidance.length > CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH) {
throw invalidConfig(`增强提示词不得超过 ${CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH} 个字符。`);
}
return Object.freeze({
groupEnabled,
directEnabled,
fields: Object.freeze(CONTEXT_ENHANCEMENT_FIELDS.filter((field) => fields.includes(field))),
guidance: guidance.trim() ? guidance : '',
});
}
/** Missing or damaged enhancement settings must never break an existing bot. */
export function normalizeContextEnhancementConfig(input) {
try {
return validateContextEnhancementConfig(input);
} catch {
return DEFAULT_CONTEXT_ENHANCEMENT_CONFIG;
}
}
/** Capture before queueing. The off path reads only the applicable switch. */
export function captureContextEnhancement(provider, conversationType) {
if (conversationType !== 'group' && conversationType !== 'direct') return null;
try {
const settings = provider?.getSettings?.();
const enabledKey = conversationType === 'group' ? 'groupEnabled' : 'directEnabled';
if (settings?.[enabledKey] !== true) return null;
const config = normalizeContextEnhancementConfig(settings);
if (config[enabledKey] !== true) return null;
return Object.freeze({ config, botId: provider.botId, conversationType });
} catch {
return null;
}
}
function sourceString(value, field) {
if (field === 'senderId' && (typeof value === 'bigint' || Number.isFinite(value))) {
value = String(value);
}
if (typeof value !== 'string') return undefined;
const normalized = value.replace(CONTROL_CHARACTERS, '').trim().slice(0, SOURCE_LIMITS[field]);
if (!normalized || (field === 'channel' && !CHANNELS.has(normalized))) return undefined;
return normalized;
}
function sourceBlock(snapshot, sourceFactory) {
const { fields } = snapshot.config;
const needsSource = fields.some((field) => ['channel', 'senderId', 'senderName'].includes(field));
const source = needsSource ? sourceFactory?.() : null;
const projected = {};
for (const field of fields) {
const value = field === 'botId' || field === 'conversationType'
? snapshot[field] : source?.[field];
const normalized = sourceString(value, field);
if (normalized !== undefined) projected[field] = normalized;
}
if (Object.keys(projected).length === 0) return '';
const json = JSON.stringify(projected).replace(/[<>&]/g, (character) => ({
'<': '\\u003c', '>': '\\u003e', '&': '\\u0026',
})[character]);
return `<dsh_im_source>${json}</dsh_im_source>`;
}
function guidanceBlock(guidance) {
if (!guidance.trim()) return '';
const body = guidance.replace(/<\/?dsh_im_source_guidance\b[^>]*(?:>|$)/gi, (tag) => (
tag.replace(/</g, '&lt;').replace(/>/g, '&gt;')
));
return `<dsh_im_source_guidance>\n${body}\n</dsh_im_source_guidance>`;
}
/** Add one text prefix; never inspect sources, format or copy content when off. */
export function enhanceContextContent(content, snapshot, sourceFactory) {
if (!snapshot) return content;
try {
const blocks = [sourceBlock(snapshot, sourceFactory), guidanceBlock(snapshot.config.guidance)]
.filter(Boolean);
if (blocks.length === 0) return content;
const prefix = blocks.join('\n\n');
if (typeof content === 'string') return `${prefix}\n\n${content}`;
if (Array.isArray(content)) return [{ type: 'text', text: prefix }, ...content];
return content;
} catch {
// Only enhancement errors are isolated; the caller's original flow proceeds.
return content;
}
}

View file

@ -1,4 +1,5 @@
import { t } from './i18n.mjs';
import { captureContextEnhancement, enhanceContextContent } from './context-enhancement.mjs';
import { runWorkspaceCommand } from './workspace-command.mjs';
import { runCompactCommand } from './compact-command.mjs';
import { isHistoryCommand, runHistoryCommand } from './history-command.mjs';
@ -127,6 +128,7 @@ export class TextHarnessBridge {
#bot;
#harness;
#state;
#contextEnhancement;
#status;
#logger;
#replyTimeoutMs;
@ -134,7 +136,8 @@ export class TextHarnessBridge {
#queues = new Map();
#pendingInteractions = new Map();
#interactionKeys = new Map();
#acceptedMessageIds = new Set();
// Keep the accepted configuration through the existing queue/reply lifecycle.
#acceptedMessageIds = new Map();
#approvalTasks = new Set();
#commandTasks = new Set();
#approvals;
@ -145,6 +148,7 @@ export class TextHarnessBridge {
bot,
harness,
state,
contextEnhancement,
status = createTextBridgeStatus(),
logger = console,
replyTimeoutMs = 600_000,
@ -157,6 +161,7 @@ export class TextHarnessBridge {
this.#bot = bot;
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#status = status;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
@ -171,7 +176,7 @@ export class TextHarnessBridge {
return structuredClone(this.#status);
}
accept(message) {
accept(message, { contextSnapshot } = {}) {
if (this.#signal?.aborted) return Promise.resolve();
const conversationId = cleanText(message?.conversationId);
const kind = message?.kind === 'group' ? 'group' : 'direct';
@ -182,7 +187,9 @@ export class TextHarnessBridge {
|| this.#state.hasSeen(messageId) || this.#acceptedMessageIds.has(messageId)) {
return Promise.resolve();
}
this.#acceptedMessageIds.add(messageId);
this.#acceptedMessageIds.set(messageId, contextSnapshot === undefined
? captureContextEnhancement(this.#contextEnhancement, message?.kind)
: contextSnapshot);
const statusReaction = beginStatusReaction({
adapter: this.#bot,
target: normalized.kind === 'direct' || normalized.addressed === true
@ -614,9 +621,17 @@ export class TextHarnessBridge {
);
}
}
const content = hasImages
let content = hasImages
? await promptContentForMessage(message, { signal: this.#signal })
: undefined;
const snapshot = this.#acceptedMessageIds.get(messageId);
if (snapshot) {
content = enhanceContextContent(content ?? text, snapshot, () => ({
channel: this.#descriptor.key,
senderId,
senderName: message.contextSource?.()?.senderName,
}));
}
const { answer, artifacts = [] } = await askInWorkspaceSession({
harness: this.#harness,
state: this.#state,