Disable the active Zen summarization flow because the unauthenticated/free Zen provider is no longer available and now returns usage-limit errors for this feature.
Keep /api/text/summarize as an API-compatible stub that returns local sanitized or distilled fallback text with summarized=false, rather than attempting external model calls.
Remove notification and voice playback summary behavior from runtime paths. Notification {last_message} now always uses normalized truncated text, and TTS playback ignores historical summarize request fields.
Hide the notification summary settings and voice summarize-before-playback controls while preserving legacy persisted settings for compatibility. Also disable Zen model startup validation and make Zen model list routes return empty results.
Update module documentation and tests to describe the retired provider behavior and the remaining compatibility stubs.
350 lines
11 KiB
JavaScript
350 lines
11 KiB
JavaScript
import { summarizeText as summarizeSharedText } from '../text/summarization.js';
|
|
|
|
export const createNotificationTemplateRuntime = (deps) => {
|
|
const {
|
|
readSettingsFromDisk,
|
|
buildOpenCodeUrl,
|
|
getOpenCodeAuthHeaders,
|
|
resolveGitBinaryForSpawn,
|
|
} = deps;
|
|
|
|
const NOTIFICATION_BODY_MAX_CHARS = 1000;
|
|
const SESSION_INFO_CACHE_TTL_MS = 60 * 1000;
|
|
|
|
const cachedZenModels = { models: [] };
|
|
|
|
const sessionTitleCache = new Map();
|
|
const sessionInfoCache = new Map();
|
|
|
|
const createTimeoutSignal = (timeoutMs) => {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
return {
|
|
signal: controller.signal,
|
|
cleanup: () => clearTimeout(timer),
|
|
};
|
|
};
|
|
|
|
const formatProjectLabel = (label) => {
|
|
if (!label || typeof label !== 'string') return '';
|
|
return label
|
|
.replace(/[-_]/g, ' ')
|
|
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
};
|
|
|
|
const resolveNotificationTemplate = (template, variables) => {
|
|
if (!template || typeof template !== 'string') return '';
|
|
return template.replace(/\{(\w+)\}/g, (_match, key) => {
|
|
const value = variables[key];
|
|
if (value === undefined || value === null) return '';
|
|
return String(value);
|
|
});
|
|
};
|
|
|
|
const shouldApplyResolvedTemplateMessage = (template, resolved, variables) => {
|
|
if (!resolved) {
|
|
return false;
|
|
}
|
|
|
|
if (typeof template !== 'string') {
|
|
return true;
|
|
}
|
|
|
|
if (template.includes('{last_message}')) {
|
|
return typeof variables?.last_message === 'string' && variables.last_message.trim().length > 0;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const fetchFreeZenModels = async () => [];
|
|
|
|
const resolveZenModel = async (override) => {
|
|
const overrideModel = typeof override === 'string' ? override.trim() : '';
|
|
if (overrideModel) return overrideModel;
|
|
const settings = await readSettingsFromDisk().catch(() => ({}));
|
|
return typeof settings?.zenModel === 'string' && settings.zenModel.trim().length > 0
|
|
? settings.zenModel.trim()
|
|
: '';
|
|
};
|
|
|
|
const validateZenModelAtStartup = async () => {};
|
|
|
|
const summarizeText = async (text, targetLength, zenModel) => {
|
|
if (!text || typeof text !== 'string' || text.trim().length === 0) return text;
|
|
const result = await summarizeSharedText({
|
|
text,
|
|
threshold: 0,
|
|
maxLength: targetLength,
|
|
zenModel,
|
|
mode: 'notification',
|
|
});
|
|
return typeof result?.summary === 'string' && result.summary.trim().length > 0
|
|
? result.summary
|
|
: text;
|
|
};
|
|
|
|
const extractTextFromParts = (parts, maxLength = NOTIFICATION_BODY_MAX_CHARS) => {
|
|
if (!Array.isArray(parts) || parts.length === 0) return '';
|
|
|
|
const textParts = parts
|
|
.filter((part) => part && (part.type === 'text' || typeof part.text === 'string' || typeof part.content === 'string'))
|
|
.map((part) => part.text || part.content || '')
|
|
.filter(Boolean);
|
|
|
|
let text = textParts.length > 0 ? textParts.join('\n').trim() : '';
|
|
|
|
if (maxLength > 0 && text.length > maxLength) {
|
|
text = text.slice(0, maxLength);
|
|
}
|
|
|
|
return text;
|
|
};
|
|
|
|
const extractLastMessageText = (payload, maxLength = NOTIFICATION_BODY_MAX_CHARS) => {
|
|
const info = payload?.properties?.info;
|
|
if (!info) return '';
|
|
|
|
const parts = info.parts || payload?.properties?.parts;
|
|
const text = extractTextFromParts(parts, maxLength);
|
|
if (text) return text;
|
|
|
|
const content = info.content;
|
|
if (Array.isArray(content)) {
|
|
const textContent = content
|
|
.filter((entry) => entry && (entry.type === 'text' || typeof entry.text === 'string'))
|
|
.map((entry) => entry.text || '')
|
|
.filter(Boolean);
|
|
if (textContent.length > 0) {
|
|
let result = textContent.join('\n').trim();
|
|
if (maxLength > 0 && result.length > maxLength) {
|
|
result = result.slice(0, maxLength);
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
};
|
|
|
|
const fetchLastAssistantMessageText = async (sessionId, messageId, maxLength = NOTIFICATION_BODY_MAX_CHARS) => {
|
|
if (!sessionId) return '';
|
|
|
|
try {
|
|
const url = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, '');
|
|
const response = await fetch(`${url}?limit=5`, {
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
...getOpenCodeAuthHeaders(),
|
|
},
|
|
signal: AbortSignal.timeout(3000),
|
|
});
|
|
|
|
if (!response.ok) return '';
|
|
|
|
const messages = await response.json().catch(() => null);
|
|
if (!Array.isArray(messages)) return '';
|
|
|
|
let target = null;
|
|
if (messageId) {
|
|
target = messages.find((message) => message?.info?.id === messageId && message?.info?.role === 'assistant');
|
|
}
|
|
if (!target) {
|
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
const message = messages[i];
|
|
if (message?.info?.role === 'assistant' && message?.info?.finish === 'stop') {
|
|
target = message;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!target || !Array.isArray(target.parts)) return '';
|
|
|
|
return extractTextFromParts(target.parts, maxLength);
|
|
} catch {
|
|
return '';
|
|
}
|
|
};
|
|
|
|
const cacheSessionTitle = (sessionId, title) => {
|
|
if (typeof sessionId === 'string' && sessionId.length > 0 && typeof title === 'string' && title.length > 0) {
|
|
sessionTitleCache.set(sessionId, title);
|
|
}
|
|
};
|
|
|
|
const getCachedSessionTitle = (sessionId) => {
|
|
return sessionTitleCache.get(sessionId) ?? null;
|
|
};
|
|
|
|
const maybeCacheSessionInfoFromEvent = (payload) => {
|
|
if (!payload || typeof payload !== 'object') return;
|
|
const type = payload.type;
|
|
if (type !== 'session.updated' && type !== 'session.created') return;
|
|
const info = payload.properties?.info;
|
|
if (!info || typeof info !== 'object') return;
|
|
cacheSessionTitle(info.id, info.title);
|
|
};
|
|
|
|
const fetchSessionInfo = async (sessionId) => {
|
|
if (!sessionId) return null;
|
|
|
|
const cached = sessionInfoCache.get(sessionId);
|
|
if (cached && Date.now() - cached.at < SESSION_INFO_CACHE_TTL_MS) {
|
|
return cached.data;
|
|
}
|
|
|
|
try {
|
|
const url = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, '');
|
|
const response = await fetch(url, {
|
|
method: 'GET',
|
|
headers: { Accept: 'application/json' },
|
|
signal: AbortSignal.timeout(2000),
|
|
});
|
|
if (!response.ok) {
|
|
console.warn(`[Notification] fetchSessionInfo: ${response.status} for session ${sessionId}`);
|
|
return null;
|
|
}
|
|
const data = await response.json().catch(() => null);
|
|
if (data && typeof data === 'object') {
|
|
sessionInfoCache.set(sessionId, { data, at: Date.now() });
|
|
return data;
|
|
}
|
|
return null;
|
|
} catch (error) {
|
|
console.warn(`[Notification] fetchSessionInfo failed for ${sessionId}:`, error?.message || error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const buildTemplateVariables = async (payload, sessionId) => {
|
|
const info = payload?.properties?.info || {};
|
|
|
|
let sessionTitle = payload?.properties?.sessionTitle || payload?.properties?.session?.title || (typeof info.sessionTitle === 'string' ? info.sessionTitle : '') || '';
|
|
|
|
if (!sessionTitle && sessionId) {
|
|
const cached = getCachedSessionTitle(sessionId);
|
|
if (cached) {
|
|
sessionTitle = cached;
|
|
}
|
|
}
|
|
|
|
let sessionInfo = null;
|
|
if (!sessionTitle && sessionId) {
|
|
sessionInfo = await fetchSessionInfo(sessionId);
|
|
if (sessionInfo && typeof sessionInfo.title === 'string') {
|
|
sessionTitle = sessionInfo.title;
|
|
cacheSessionTitle(sessionId, sessionTitle);
|
|
}
|
|
}
|
|
|
|
const agentName = (() => {
|
|
const mode = typeof info.agent === 'string' && info.agent.trim().length > 0
|
|
? info.agent.trim()
|
|
: (typeof info.mode === 'string' ? info.mode.trim() : '');
|
|
if (!mode) return 'Agent';
|
|
return mode.split(/[-_\s]+/).filter(Boolean)
|
|
.map((token) => token.charAt(0).toUpperCase() + token.slice(1)).join(' ');
|
|
})();
|
|
|
|
const modelName = (() => {
|
|
const raw = typeof info.modelID === 'string' ? info.modelID.trim()
|
|
: (typeof info.model?.modelID === 'string' ? info.model.modelID.trim() : '');
|
|
if (!raw) return 'Assistant';
|
|
return raw.split(/[-_]+/).filter(Boolean)
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' ');
|
|
})();
|
|
|
|
let projectName = '';
|
|
let branch = '';
|
|
let worktreeDir = '';
|
|
|
|
const infoPath = info.path;
|
|
if (typeof infoPath?.root === 'string' && infoPath.root.length > 0) {
|
|
worktreeDir = infoPath.root;
|
|
} else if (typeof infoPath?.cwd === 'string' && infoPath.cwd.length > 0) {
|
|
worktreeDir = infoPath.cwd;
|
|
}
|
|
|
|
try {
|
|
const settings = await readSettingsFromDisk();
|
|
const projects = Array.isArray(settings.projects) ? settings.projects : [];
|
|
|
|
if (worktreeDir) {
|
|
const normalizedDir = worktreeDir.replace(/\/+$/, '');
|
|
const matchedProject = projects.find((project) => {
|
|
if (!project || typeof project.path !== 'string') return false;
|
|
return project.path.replace(/\/+$/, '') === normalizedDir;
|
|
});
|
|
if (matchedProject && typeof matchedProject.label === 'string' && matchedProject.label.trim().length > 0) {
|
|
projectName = matchedProject.label.trim();
|
|
} else {
|
|
projectName = normalizedDir.split('/').filter(Boolean).pop() || '';
|
|
}
|
|
} else {
|
|
const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : '';
|
|
const activeProject = activeId ? projects.find((project) => project && project.id === activeId) : projects[0];
|
|
if (activeProject) {
|
|
projectName = typeof activeProject.label === 'string' && activeProject.label.trim().length > 0
|
|
? activeProject.label.trim()
|
|
: typeof activeProject.path === 'string'
|
|
? activeProject.path.split('/').pop() || ''
|
|
: '';
|
|
worktreeDir = typeof activeProject.path === 'string' ? activeProject.path : '';
|
|
}
|
|
}
|
|
} catch {
|
|
if (worktreeDir && !projectName) {
|
|
projectName = worktreeDir.split('/').filter(Boolean).pop() || '';
|
|
}
|
|
}
|
|
|
|
if (worktreeDir) {
|
|
try {
|
|
const { simpleGit } = await import('simple-git');
|
|
const git = simpleGit({
|
|
baseDir: worktreeDir,
|
|
spawnOptions: { windowsHide: true },
|
|
binary: resolveGitBinaryForSpawn(),
|
|
});
|
|
branch = await Promise.race([
|
|
git.revparse(['--abbrev-ref', 'HEAD']),
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error('git timeout')), 3000)),
|
|
]).catch(() => '');
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
return {
|
|
project_name: formatProjectLabel(projectName),
|
|
worktree: worktreeDir,
|
|
branch: typeof branch === 'string' ? branch.trim() : '',
|
|
session_name: sessionTitle,
|
|
agent_name: agentName,
|
|
model_name: modelName,
|
|
last_message: '',
|
|
session_id: sessionId || '',
|
|
};
|
|
};
|
|
|
|
const getCachedZenModels = () => cachedZenModels;
|
|
|
|
return {
|
|
createTimeoutSignal,
|
|
formatProjectLabel,
|
|
resolveNotificationTemplate,
|
|
shouldApplyResolvedTemplateMessage,
|
|
fetchFreeZenModels,
|
|
resolveZenModel,
|
|
validateZenModelAtStartup,
|
|
summarizeText,
|
|
extractTextFromParts,
|
|
extractLastMessageText,
|
|
fetchLastAssistantMessageText,
|
|
maybeCacheSessionInfoFromEvent,
|
|
buildTemplateVariables,
|
|
getCachedZenModels,
|
|
};
|
|
};
|