mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 06:30:45 +08:00
fix: preserve session titles without context enhancement
This commit is contained in:
parent
e8c9817598
commit
d044f1b0aa
17 changed files with 527 additions and 223 deletions
|
|
@ -996,13 +996,16 @@ export class DingtalkHarnessBridge {
|
|||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
let contextEnhanced = false;
|
||||
if (snapshot) {
|
||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
||||
const originalContent = content ?? text;
|
||||
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||
channel: 'dingtalk',
|
||||
senderId: sender,
|
||||
senderName: message.senderNick,
|
||||
conversationTitle: message.conversationTitle,
|
||||
}));
|
||||
contextEnhanced = content !== originalContent;
|
||||
}
|
||||
if (typeof this.#api.createAiCard === 'function'
|
||||
&& typeof this.#api.updateAiCard === 'function'
|
||||
|
|
@ -1021,7 +1024,9 @@ export class DingtalkHarnessBridge {
|
|||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
...(content !== undefined ? { content } : { text }),
|
||||
text,
|
||||
content,
|
||||
contextEnhanced,
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: {
|
||||
|
|
|
|||
|
|
@ -3293,11 +3293,14 @@ export class FeishuHarnessBridge {
|
|||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
let contextEnhanced = false;
|
||||
if (snapshot) {
|
||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
||||
const originalContent = content ?? text;
|
||||
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||
channel: 'feishu',
|
||||
senderId: senderOpenId(event),
|
||||
}));
|
||||
contextEnhanced = content !== originalContent;
|
||||
}
|
||||
if (!this.#channel?.stream) {
|
||||
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
|
|
@ -3306,6 +3309,7 @@ export class FeishuHarnessBridge {
|
|||
key,
|
||||
text,
|
||||
content,
|
||||
contextEnhanced,
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: this.#interactionAskOptions(event, key, message.files),
|
||||
|
|
@ -3375,6 +3379,7 @@ export class FeishuHarnessBridge {
|
|||
key,
|
||||
text,
|
||||
content,
|
||||
contextEnhanced,
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions,
|
||||
|
|
@ -3438,6 +3443,7 @@ export class FeishuHarnessBridge {
|
|||
key,
|
||||
text,
|
||||
content,
|
||||
contextEnhanced,
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: this.#interactionAskOptions(event, key, message.files),
|
||||
|
|
|
|||
|
|
@ -840,12 +840,15 @@ export class QqHarnessBridge {
|
|||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
let contextEnhanced = false;
|
||||
if (snapshot) {
|
||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
||||
const originalContent = content ?? text;
|
||||
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||
channel: 'qq',
|
||||
senderId: sender,
|
||||
senderName: message.kind === 'group' ? message.senderName : undefined,
|
||||
}));
|
||||
contextEnhanced = content !== originalContent;
|
||||
}
|
||||
// QQ stream_messages can acknowledge a final frame without rendering it in
|
||||
// some C2C clients. Standard Markdown delivery is the reliable reply path.
|
||||
|
|
@ -860,7 +863,9 @@ export class QqHarnessBridge {
|
|||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
...(content !== undefined ? { content } : { text }),
|
||||
text,
|
||||
content,
|
||||
contextEnhanced,
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: {
|
||||
|
|
|
|||
|
|
@ -1211,6 +1211,11 @@ export function createBotWorkspaceScope(
|
|||
readHistory(...args) {
|
||||
return invokeCurrentSession('readSessionHistory', args, 'history read');
|
||||
},
|
||||
...(typeof target.renameSession === 'function' ? {
|
||||
renameTitle(...args) {
|
||||
return invokeStartedSessionMutation('renameSession', args, 'title rename');
|
||||
},
|
||||
} : {}),
|
||||
selectModel(...args) {
|
||||
return invokeCurrentSession('selectSessionModel', args, 'model selection');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -911,6 +911,12 @@ export class HarnessClient {
|
|||
return created.sessionId;
|
||||
}
|
||||
|
||||
async renameSession(sessionId, title, options = {}) {
|
||||
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
||||
if (typeof title !== 'string' || !title.trim()) throw new TypeError('session title is required');
|
||||
return this.rpc('session.rename', { sessionId, title }, 30_000, options);
|
||||
}
|
||||
|
||||
async executeCommand(sessionId, line, options = {}) {
|
||||
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
||||
if (typeof line !== 'string' || !line) throw new TypeError('command line is required');
|
||||
|
|
|
|||
74
src/channels/shared/session-title.mjs
Normal file
74
src/channels/shared/session-title.mjs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
const DEFAULT_MAX_TITLE_BYTES = 60;
|
||||
|
||||
const OSC_SEQUENCE = /(?:\u001b\]|\u009d)(?:(?!\u0007|\u001b\\)[\s\S])*(?:\u0007|\u001b\\|$)/gu;
|
||||
const CSI_SEQUENCE = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/gu;
|
||||
const ESC_SEQUENCE = /\u001b[@-_]/gu;
|
||||
const CONTROL_CHARACTER = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/gu;
|
||||
const DIRECTIONAL_CONTROL = /[\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff]/gu;
|
||||
const INJECTED_CONTEXT_PREFIX = /^(?:<dsh_im_source>[\s\S]*?<\/dsh_im_source>\s*)?(?:<dsh_im_source_guidance>[\s\S]*?<\/dsh_im_source_guidance>\s*)?/u;
|
||||
const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
||||
|
||||
function cleanTitleText(input) {
|
||||
return input
|
||||
.replace(OSC_SEQUENCE, '')
|
||||
.replace(CSI_SEQUENCE, '')
|
||||
.replace(ESC_SEQUENCE, '')
|
||||
.replace(CONTROL_CHARACTER, '')
|
||||
.replace(DIRECTIONAL_CONTROL, '')
|
||||
.replace(/\s+/gu, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function truncateTitle(input, maxBytes = DEFAULT_MAX_TITLE_BYTES) {
|
||||
if (Buffer.byteLength(input, 'utf8') <= maxBytes) return input;
|
||||
const suffix = '…';
|
||||
const budget = maxBytes - Buffer.byteLength(suffix, 'utf8');
|
||||
let output = '';
|
||||
for (const { segment } of SEGMENTER.segment(input)) {
|
||||
if (Buffer.byteLength(output + segment, 'utf8') > budget) break;
|
||||
output += segment;
|
||||
}
|
||||
return `${output.trimEnd()}${suffix}`;
|
||||
}
|
||||
|
||||
function contentText(content) {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
||||
.map((block) => block.text)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function fileDisplayName(file) {
|
||||
const value = file?.name ?? file?.filename ?? file?.fileName;
|
||||
if (typeof value !== 'string') return '';
|
||||
return value
|
||||
.replaceAll('\\', '/')
|
||||
.split('/')
|
||||
.at(-1)
|
||||
?.replace(/[\u0000-\u001f\u007f]/gu, '')
|
||||
.trim() ?? '';
|
||||
}
|
||||
|
||||
/** Build a deterministic title from the unenhanced first user message. */
|
||||
export function initialSessionTitle({ text, content, files } = {}) {
|
||||
const original = typeof text === 'string' ? cleanTitleText(text) : '';
|
||||
if (original) return truncateTitle(original);
|
||||
|
||||
// Structured image prompts may only expose their default text through content.
|
||||
// Strip only the leading blocks inserted by dsh-im; matching tags later in user
|
||||
// content remain ordinary user text.
|
||||
const visibleContent = cleanTitleText(
|
||||
contentText(content).replace(INJECTED_CONTEXT_PREFIX, ''),
|
||||
);
|
||||
if (visibleContent) return truncateTitle(visibleContent);
|
||||
|
||||
const fileName = Array.isArray(files)
|
||||
? files.map(fileDisplayName).find(Boolean)
|
||||
: '';
|
||||
const cleanFileName = fileName ? cleanTitleText(fileName) : '';
|
||||
return cleanFileName ? truncateTitle(cleanFileName) : null;
|
||||
}
|
||||
|
||||
export const INITIAL_SESSION_TITLE_MAX_BYTES = DEFAULT_MAX_TITLE_BYTES;
|
||||
|
|
@ -673,13 +673,16 @@ export class TextHarnessBridge {
|
|||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
let contextEnhanced = false;
|
||||
if (snapshot) {
|
||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
||||
const originalContent = content ?? text;
|
||||
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||
channel: this.#descriptor.key,
|
||||
senderId,
|
||||
senderName: message.contextSource?.()?.senderName,
|
||||
conversationTitle: message.contextSource?.()?.conversationTitle,
|
||||
}));
|
||||
contextEnhanced = content !== originalContent;
|
||||
}
|
||||
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
harness: this.#harness,
|
||||
|
|
@ -687,6 +690,7 @@ export class TextHarnessBridge {
|
|||
key: conversationKey,
|
||||
text,
|
||||
content,
|
||||
contextEnhanced,
|
||||
createOptions: this.#signal ? { signal: this.#signal } : undefined,
|
||||
existsOptions: this.#signal ? { signal: this.#signal } : undefined,
|
||||
askOptions: {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { withSessionBindingLock } from './session-binding-lock.mjs';
|
||||
import { initialSessionTitle } from './session-title.mjs';
|
||||
|
||||
export const WORKSPACE_SESSION_STALE = 'workspace-session-stale';
|
||||
|
||||
|
|
@ -6,7 +7,7 @@ function workspaceSession(harness, sessionId) {
|
|||
if (typeof harness.workspaceSession === 'function') {
|
||||
return harness.workspaceSession(sessionId);
|
||||
}
|
||||
return Object.freeze({
|
||||
const session = {
|
||||
sessionId,
|
||||
sessionExists: (...args) => harness.sessionExists(sessionId, ...args),
|
||||
models: (...args) => harness.getSessionModels(sessionId, ...args),
|
||||
|
|
@ -16,7 +17,11 @@ function workspaceSession(harness, sessionId) {
|
|||
stopActiveTurn: (...args) => harness.stopActiveTurn(sessionId, ...args),
|
||||
steerActiveTurn: (...args) => harness.steerActiveTurn(sessionId, ...args),
|
||||
ask: (...args) => harness.ask(sessionId, ...args),
|
||||
});
|
||||
};
|
||||
if (typeof harness.renameSession === 'function') {
|
||||
session.renameTitle = (...args) => harness.renameSession(sessionId, ...args);
|
||||
}
|
||||
return Object.freeze(session);
|
||||
}
|
||||
|
||||
async function sessionExists(session, options) {
|
||||
|
|
@ -42,10 +47,21 @@ export async function askInWorkspaceSession({
|
|||
key,
|
||||
text,
|
||||
content,
|
||||
contextEnhanced = false,
|
||||
createOptions,
|
||||
existsOptions,
|
||||
askOptions,
|
||||
}) {
|
||||
const initialTitle = contextEnhanced
|
||||
? initialSessionTitle({
|
||||
text,
|
||||
content,
|
||||
files: typeof askOptions === 'object' ? askOptions?.files : undefined,
|
||||
})
|
||||
: null;
|
||||
const renameSignal = createOptions?.signal
|
||||
?? (typeof askOptions === 'object' ? askOptions?.signal : undefined);
|
||||
const renameOptions = renameSignal ? { signal: renameSignal } : undefined;
|
||||
while (true) {
|
||||
try {
|
||||
const binding = await withSessionBindingLock(state, key, async () => {
|
||||
|
|
@ -55,6 +71,16 @@ export async function askInWorkspaceSession({
|
|||
sessionId = await createSession(harness, createOptions);
|
||||
if (await state.setSession(key, sessionId) === false) return null;
|
||||
session = workspaceSession(harness, sessionId);
|
||||
if (initialTitle && typeof session.renameTitle === 'function') {
|
||||
try {
|
||||
await session.renameTitle(initialTitle, renameOptions);
|
||||
} catch (error) {
|
||||
if (error?.code === WORKSPACE_SESSION_STALE || renameOptions?.signal?.aborted) {
|
||||
throw error;
|
||||
}
|
||||
console.warn('[dsh-im] unable to set the initial Session title:', error?.message ?? error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { sessionId, session };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1007,11 +1007,14 @@ export class WecomHarnessBridge {
|
|||
? await promptContentForMessage(message, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
let contextEnhanced = false;
|
||||
if (snapshot) {
|
||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
||||
const originalContent = content ?? text;
|
||||
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||
channel: 'wecom',
|
||||
senderId,
|
||||
}));
|
||||
contextEnhanced = content !== originalContent;
|
||||
}
|
||||
await this.#state.markSeen(messageId);
|
||||
promptRecorded = true;
|
||||
|
|
@ -1021,6 +1024,7 @@ export class WecomHarnessBridge {
|
|||
key,
|
||||
text,
|
||||
content,
|
||||
contextEnhanced,
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: {
|
||||
|
|
|
|||
|
|
@ -705,11 +705,14 @@ export class WeixinHarnessBridge {
|
|||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||
: undefined;
|
||||
const snapshot = this.#acceptedMessageIds.get(messageId);
|
||||
let contextEnhanced = false;
|
||||
if (snapshot) {
|
||||
content = enhanceContextContent(content ?? text, snapshot, () => ({
|
||||
const originalContent = content ?? text;
|
||||
content = enhanceContextContent(originalContent, snapshot, () => ({
|
||||
channel: 'weixin',
|
||||
senderId: sender,
|
||||
}));
|
||||
contextEnhanced = content !== originalContent;
|
||||
}
|
||||
await this.#state.markSeen(messageId);
|
||||
promptRecorded = true;
|
||||
|
|
@ -717,7 +720,9 @@ export class WeixinHarnessBridge {
|
|||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
key,
|
||||
...(content !== undefined ? { content } : { text }),
|
||||
text,
|
||||
content,
|
||||
contextEnhanced,
|
||||
createOptions: { signal: this.#signal },
|
||||
existsOptions: { signal: this.#signal },
|
||||
askOptions: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue