fix: use valid Zen summaries for notes
This commit is contained in:
@@ -507,13 +507,20 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsAddingToNotes(true);
|
setIsAddingToNotes(true);
|
||||||
const distilledInsight = await summarizeText(selectedText, {
|
let noteText = selectedText;
|
||||||
threshold: 0,
|
let usedSummaryFallback = false;
|
||||||
maxLength: 100,
|
try {
|
||||||
mode: 'note',
|
noteText = await summarizeText(selectedText, {
|
||||||
});
|
threshold: 0,
|
||||||
|
maxLength: 100,
|
||||||
|
mode: 'note',
|
||||||
|
});
|
||||||
|
} catch (summaryError) {
|
||||||
|
usedSummaryFallback = true;
|
||||||
|
console.warn('[AddToNotes] Summary failed, saving selected text:', summaryError);
|
||||||
|
}
|
||||||
const projectData = await getProjectNotesAndTodos(currentProjectRef);
|
const projectData = await getProjectNotesAndTodos(currentProjectRef);
|
||||||
const nextNotes = appendDistilledInsightToNotes(projectData.notes, distilledInsight);
|
const nextNotes = appendDistilledInsightToNotes(projectData.notes, noteText);
|
||||||
const saved = await saveProjectNotesAndTodos(currentProjectRef, {
|
const saved = await saveProjectNotesAndTodos(currentProjectRef, {
|
||||||
notes: nextNotes,
|
notes: nextNotes,
|
||||||
todos: projectData.todos,
|
todos: projectData.todos,
|
||||||
@@ -525,7 +532,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
window.dispatchEvent(new CustomEvent('openchamber:project-notes-updated', {
|
window.dispatchEvent(new CustomEvent('openchamber:project-notes-updated', {
|
||||||
detail: { projectId: currentProjectRef.id },
|
detail: { projectId: currentProjectRef.id },
|
||||||
}));
|
}));
|
||||||
toast.success(t('chat.textSelection.toast.addToNotesSuccess'));
|
if (usedSummaryFallback) {
|
||||||
|
toast.warning(t('chat.textSelection.toast.addToNotesSummaryFailed'));
|
||||||
|
} else {
|
||||||
|
toast.success(t('chat.textSelection.toast.addToNotesSuccess'));
|
||||||
|
}
|
||||||
hideMenu();
|
hideMenu();
|
||||||
window.getSelection()?.removeAllRanges();
|
window.getSelection()?.removeAllRanges();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ const TEMPLATE_EVENT_LABEL_KEYS = {
|
|||||||
question: 'settings.notifications.page.template.event.question',
|
question: 'settings.notifications.page.template.event.question',
|
||||||
} as const satisfies Record<NotificationTemplateEvent, string>;
|
} as const satisfies Record<NotificationTemplateEvent, string>;
|
||||||
|
|
||||||
const UTILITY_PROVIDER_ID = 'zen';
|
|
||||||
const UTILITY_PREFERRED_MODEL_ID = 'big-pickle';
|
const UTILITY_PREFERRED_MODEL_ID = 'big-pickle';
|
||||||
const UTILITY_NOT_SELECTED_VALUE = '__not_selected__';
|
const UTILITY_NOT_SELECTED_VALUE = '__not_selected__';
|
||||||
|
|
||||||
@@ -78,7 +77,6 @@ export const NotificationSettings: React.FC = () => {
|
|||||||
const setSummaryLength = useUIStore(state => state.setSummaryLength);
|
const setSummaryLength = useUIStore(state => state.setSummaryLength);
|
||||||
const maxLastMessageLength = useUIStore(state => state.maxLastMessageLength);
|
const maxLastMessageLength = useUIStore(state => state.maxLastMessageLength);
|
||||||
const setMaxLastMessageLength = useUIStore(state => state.setMaxLastMessageLength);
|
const setMaxLastMessageLength = useUIStore(state => state.setMaxLastMessageLength);
|
||||||
const providers = useConfigStore((state) => state.providers);
|
|
||||||
const settingsZenModel = useConfigStore((state) => state.settingsZenModel);
|
const settingsZenModel = useConfigStore((state) => state.settingsZenModel);
|
||||||
const setSettingsZenModel = useConfigStore((state) => state.setSettingsZenModel);
|
const setSettingsZenModel = useConfigStore((state) => state.setSettingsZenModel);
|
||||||
|
|
||||||
@@ -88,27 +86,7 @@ export const NotificationSettings: React.FC = () => {
|
|||||||
const [pushBusy, setPushBusy] = React.useState(false);
|
const [pushBusy, setPushBusy] = React.useState(false);
|
||||||
const [fetchedZenModels, setFetchedZenModels] = React.useState<Array<{ id: string; name: string }>>([]);
|
const [fetchedZenModels, setFetchedZenModels] = React.useState<Array<{ id: string; name: string }>>([]);
|
||||||
|
|
||||||
const providerZenModels = React.useMemo(() => {
|
|
||||||
const zenProvider = providers.find((provider) => provider.id === UTILITY_PROVIDER_ID);
|
|
||||||
const models = Array.isArray(zenProvider?.models) ? zenProvider.models : [];
|
|
||||||
return models
|
|
||||||
.map((model: Record<string, unknown>) => {
|
|
||||||
const id = typeof model.id === 'string' ? model.id.trim() : '';
|
|
||||||
if (!id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const name = typeof model.name === 'string' && model.name.trim().length > 0 ? model.name.trim() : id;
|
|
||||||
return { id, name };
|
|
||||||
})
|
|
||||||
.filter((model): model is { id: string; name: string } => model !== null);
|
|
||||||
}, [providers]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (providerZenModels.length > 0) {
|
|
||||||
setFetchedZenModels([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
void fetch('/api/zen/models', {
|
void fetch('/api/zen/models', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -145,11 +123,11 @@ export const NotificationSettings: React.FC = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [providerZenModels]);
|
}, []);
|
||||||
|
|
||||||
const utilityModelOptions = React.useMemo(() => {
|
const utilityModelOptions = React.useMemo(() => {
|
||||||
return providerZenModels.length > 0 ? providerZenModels : fetchedZenModels;
|
return fetchedZenModels;
|
||||||
}, [fetchedZenModels, providerZenModels]);
|
}, [fetchedZenModels]);
|
||||||
|
|
||||||
const utilitySelectedModelId = React.useMemo(() => {
|
const utilitySelectedModelId = React.useMemo(() => {
|
||||||
if (settingsZenModel && utilityModelOptions.some((model) => model.id === settingsZenModel)) {
|
if (settingsZenModel && utilityModelOptions.some((model) => model.id === settingsZenModel)) {
|
||||||
|
|||||||
@@ -1366,6 +1366,7 @@ export const dict = {
|
|||||||
'chat.textSelection.toast.noProject': 'No project found for this session',
|
'chat.textSelection.toast.noProject': 'No project found for this session',
|
||||||
'chat.textSelection.toast.addToNotesFailed': 'Failed to add to notes',
|
'chat.textSelection.toast.addToNotesFailed': 'Failed to add to notes',
|
||||||
'chat.textSelection.toast.addToNotesSuccess': 'Added distilled insight to notes',
|
'chat.textSelection.toast.addToNotesSuccess': 'Added distilled insight to notes',
|
||||||
|
'chat.textSelection.toast.addToNotesSummaryFailed': 'Could not summarize selection, added selected text to notes',
|
||||||
'chat.textSelection.actions.addToChat': 'Add to chat',
|
'chat.textSelection.actions.addToChat': 'Add to chat',
|
||||||
'chat.textSelection.actions.newSession': 'New session',
|
'chat.textSelection.actions.newSession': 'New session',
|
||||||
'chat.textSelection.actions.copy': 'Copy',
|
'chat.textSelection.actions.copy': 'Copy',
|
||||||
|
|||||||
@@ -1332,6 +1332,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"chat.textSelection.toast.noProject": "No se encontró proyecto para esta sesión",
|
"chat.textSelection.toast.noProject": "No se encontró proyecto para esta sesión",
|
||||||
"chat.textSelection.toast.addToNotesFailed": "No se pudo añadir a las notas",
|
"chat.textSelection.toast.addToNotesFailed": "No se pudo añadir a las notas",
|
||||||
"chat.textSelection.toast.addToNotesSuccess": "Se añadió la información destilada a notas",
|
"chat.textSelection.toast.addToNotesSuccess": "Se añadió la información destilada a notas",
|
||||||
|
"chat.textSelection.toast.addToNotesSummaryFailed": "No se pudo resumir la selección; se añadió el texto seleccionado a las notas",
|
||||||
"chat.textSelection.actions.addToChat": "Añadir al chat",
|
"chat.textSelection.actions.addToChat": "Añadir al chat",
|
||||||
"chat.textSelection.actions.newSession": "Nueva sesión",
|
"chat.textSelection.actions.newSession": "Nueva sesión",
|
||||||
"chat.textSelection.actions.copy": "Copiar",
|
"chat.textSelection.actions.copy": "Copiar",
|
||||||
|
|||||||
@@ -1368,6 +1368,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.textSelection.toast.noProject': '이 세션의 프로젝트를 찾을 수 없음',
|
'chat.textSelection.toast.noProject': '이 세션의 프로젝트를 찾을 수 없음',
|
||||||
'chat.textSelection.toast.addToNotesFailed': '메모 추가 실패',
|
'chat.textSelection.toast.addToNotesFailed': '메모 추가 실패',
|
||||||
'chat.textSelection.toast.addToNotesSuccess': '정리된 인사이트를 메모에 추가함',
|
'chat.textSelection.toast.addToNotesSuccess': '정리된 인사이트를 메모에 추가함',
|
||||||
|
'chat.textSelection.toast.addToNotesSummaryFailed': '선택 영역을 요약할 수 없어 선택한 텍스트를 메모에 추가함',
|
||||||
'chat.textSelection.actions.addToChat': '채팅에 추가',
|
'chat.textSelection.actions.addToChat': '채팅에 추가',
|
||||||
'chat.textSelection.actions.newSession': '새 세션',
|
'chat.textSelection.actions.newSession': '새 세션',
|
||||||
'chat.textSelection.actions.copy': '복사',
|
'chat.textSelection.actions.copy': '복사',
|
||||||
|
|||||||
@@ -1332,6 +1332,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"chat.textSelection.toast.noProject": "Não foi encontrado projeto para esta sessão",
|
"chat.textSelection.toast.noProject": "Não foi encontrado projeto para esta sessão",
|
||||||
"chat.textSelection.toast.addToNotesFailed": "Não foi possível adicionar às notas",
|
"chat.textSelection.toast.addToNotesFailed": "Não foi possível adicionar às notas",
|
||||||
"chat.textSelection.toast.addToNotesSuccess": "Informação destilada adicionada às notas",
|
"chat.textSelection.toast.addToNotesSuccess": "Informação destilada adicionada às notas",
|
||||||
|
"chat.textSelection.toast.addToNotesSummaryFailed": "Não foi possível resumir a seleção; o texto selecionado foi adicionado às notas",
|
||||||
"chat.textSelection.actions.addToChat": "Adicionar ao chat",
|
"chat.textSelection.actions.addToChat": "Adicionar ao chat",
|
||||||
"chat.textSelection.actions.newSession": "Nova sessão",
|
"chat.textSelection.actions.newSession": "Nova sessão",
|
||||||
"chat.textSelection.actions.copy": "Copiar",
|
"chat.textSelection.actions.copy": "Copiar",
|
||||||
|
|||||||
@@ -1332,6 +1332,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"chat.textSelection.toast.noProject": "Для цієї сесії не знайдено жодного проєкту",
|
"chat.textSelection.toast.noProject": "Для цієї сесії не знайдено жодного проєкту",
|
||||||
"chat.textSelection.toast.addToNotesFailed": "Не вдалося додати до нотаток",
|
"chat.textSelection.toast.addToNotesFailed": "Не вдалося додати до нотаток",
|
||||||
"chat.textSelection.toast.addToNotesSuccess": "Інсайт додано до нотаток",
|
"chat.textSelection.toast.addToNotesSuccess": "Інсайт додано до нотаток",
|
||||||
|
"chat.textSelection.toast.addToNotesSummaryFailed": "Не вдалося підсумувати виділення, виділений текст додано до нотаток",
|
||||||
"chat.textSelection.actions.addToChat": "Додати в чат",
|
"chat.textSelection.actions.addToChat": "Додати в чат",
|
||||||
"chat.textSelection.actions.newSession": "Нова сесія",
|
"chat.textSelection.actions.newSession": "Нова сесія",
|
||||||
"chat.textSelection.actions.copy": "Копіювати",
|
"chat.textSelection.actions.copy": "Копіювати",
|
||||||
|
|||||||
@@ -1332,6 +1332,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.textSelection.toast.noProject': '未找到此会话对应的项目',
|
'chat.textSelection.toast.noProject': '未找到此会话对应的项目',
|
||||||
'chat.textSelection.toast.addToNotesFailed': '添加到笔记失败',
|
'chat.textSelection.toast.addToNotesFailed': '添加到笔记失败',
|
||||||
'chat.textSelection.toast.addToNotesSuccess': '已将洞察添加到笔记',
|
'chat.textSelection.toast.addToNotesSuccess': '已将洞察添加到笔记',
|
||||||
|
'chat.textSelection.toast.addToNotesSummaryFailed': '无法总结所选内容,已将所选文本添加到笔记',
|
||||||
'chat.textSelection.actions.addToChat': '添加到聊天',
|
'chat.textSelection.actions.addToChat': '添加到聊天',
|
||||||
'chat.textSelection.actions.newSession': '新建会话',
|
'chat.textSelection.actions.newSession': '新建会话',
|
||||||
'chat.textSelection.actions.copy': '复制',
|
'chat.textSelection.actions.copy': '复制',
|
||||||
|
|||||||
@@ -52,13 +52,12 @@ export async function summarizeText(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const zenModel = store.settingsZenModel;
|
|
||||||
const response = await fetch(resolveSummarizeUrl(), {
|
const response = await fetch(resolveSummarizeUrl(), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ text, threshold, maxLength, mode, ...(zenModel ? { zenModel } : {}) }),
|
body: JSON.stringify({ text, threshold, maxLength, mode }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -210,17 +210,31 @@ const fetchFreeZenModels = async (): Promise<Array<{ id: string; owned_by?: stri
|
|||||||
return cachedZenModels.models;
|
return cachedZenModels.models;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(ZEN_MODELS_URL, {
|
const signal = AbortSignal.timeout(8_000);
|
||||||
headers: { Accept: 'application/json' },
|
const [response, metadataResponse] = await Promise.all([
|
||||||
signal: AbortSignal.timeout(8_000),
|
fetch(ZEN_MODELS_URL, {
|
||||||
});
|
headers: { Accept: 'application/json' },
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
fetch('https://models.dev/api.json', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`zen models request failed (${response.status})`);
|
throw new Error(`zen models request failed (${response.status})`);
|
||||||
}
|
}
|
||||||
|
if (!metadataResponse.ok) {
|
||||||
|
throw new Error(`models.dev request failed (${metadataResponse.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
const rawPayload = await response.json().catch(() => null);
|
const rawPayload = await response.json().catch(() => null);
|
||||||
|
const rawMetadata = await metadataResponse.json().catch(() => null);
|
||||||
const payload = asObject(rawPayload);
|
const payload = asObject(rawPayload);
|
||||||
|
const metadata = asObject(rawMetadata);
|
||||||
|
const metadataProvider = asObject(metadata?.opencode);
|
||||||
|
const metadataModels = asObject(metadataProvider?.models);
|
||||||
const rows = Array.isArray(payload?.data) ? payload.data : [];
|
const rows = Array.isArray(payload?.data) ? payload.data : [];
|
||||||
const models = rows
|
const models = rows
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
@@ -230,7 +244,9 @@ const fetchFreeZenModels = async (): Promise<Array<{ id: string; owned_by?: stri
|
|||||||
const ownedBy = typeof (entry as { owned_by?: unknown })?.owned_by === 'string'
|
const ownedBy = typeof (entry as { owned_by?: unknown })?.owned_by === 'string'
|
||||||
? (entry as { owned_by: string }).owned_by
|
? (entry as { owned_by: string }).owned_by
|
||||||
: undefined;
|
: undefined;
|
||||||
if (!id || !id.endsWith('-free')) return null;
|
const metadataModel = asObject(metadataModels?.[id]);
|
||||||
|
const cost = asObject(metadataModel?.cost);
|
||||||
|
if (!id || cost?.input !== 0 || cost?.output !== 0) return null;
|
||||||
return ownedBy ? { id, owned_by: ownedBy } : { id };
|
return ownedBy ? { id, owned_by: ownedBy } : { id };
|
||||||
})
|
})
|
||||||
.filter((entry): entry is { id: string; owned_by?: string } => entry !== null);
|
.filter((entry): entry is { id: string; owned_by?: string } => entry !== null);
|
||||||
|
|||||||
@@ -71,18 +71,36 @@ export const createNotificationTemplateRuntime = (deps) => {
|
|||||||
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||||
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null;
|
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://opencode.ai/zen/v1/models', {
|
const [zenResponse, metadataResponse] = await Promise.all([
|
||||||
signal: controller?.signal,
|
fetch('https://opencode.ai/zen/v1/models', {
|
||||||
headers: { Accept: 'application/json' },
|
signal: controller?.signal,
|
||||||
});
|
headers: { Accept: 'application/json' },
|
||||||
if (!response.ok) {
|
}),
|
||||||
throw new Error(`zen/v1/models responded with status ${response.status}`);
|
fetch('https://models.dev/api.json', {
|
||||||
|
signal: controller?.signal,
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (!zenResponse.ok) {
|
||||||
|
throw new Error(`zen/v1/models responded with status ${zenResponse.status}`);
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
if (!metadataResponse.ok) {
|
||||||
|
throw new Error(`models.dev responded with status ${metadataResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await zenResponse.json();
|
||||||
|
const metadata = await metadataResponse.json();
|
||||||
|
const metadataModels = metadata?.opencode?.models && typeof metadata.opencode.models === 'object'
|
||||||
|
? metadata.opencode.models
|
||||||
|
: {};
|
||||||
const allModels = Array.isArray(data?.data) ? data.data : [];
|
const allModels = Array.isArray(data?.data) ? data.data : [];
|
||||||
const freeModels = allModels
|
const freeModels = allModels
|
||||||
.filter((model) => typeof model?.id === 'string' && model.id.endsWith('-free'))
|
.filter((model) => {
|
||||||
.map((model) => ({ id: model.id, owned_by: model.owned_by }));
|
const id = typeof model?.id === 'string' ? model.id.trim() : '';
|
||||||
|
const cost = id ? metadataModels[id]?.cost : null;
|
||||||
|
return id && cost?.input === 0 && cost?.output === 0;
|
||||||
|
})
|
||||||
|
.map((model) => ({ id: model.id.trim(), owned_by: model.owned_by }));
|
||||||
|
|
||||||
cachedZenModels = { models: freeModels };
|
cachedZenModels = { models: freeModels };
|
||||||
cachedZenModelsTimestamp = Date.now();
|
cachedZenModelsTimestamp = Date.now();
|
||||||
@@ -93,16 +111,35 @@ export const createNotificationTemplateRuntime = (deps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resolveZenModel = async (override) => {
|
const resolveZenModel = async (override) => {
|
||||||
if (typeof override === 'string' && override.trim().length > 0) {
|
const overrideModel = typeof override === 'string' ? override.trim() : '';
|
||||||
return override.trim();
|
let settingsModel = '';
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const settings = await readSettingsFromDisk();
|
const settings = await readSettingsFromDisk();
|
||||||
if (typeof settings?.zenModel === 'string' && settings.zenModel.trim().length > 0) {
|
if (typeof settings?.zenModel === 'string' && settings.zenModel.trim().length > 0) {
|
||||||
return settings.zenModel.trim();
|
settingsModel = settings.zenModel.trim();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const candidate = overrideModel || settingsModel;
|
||||||
|
try {
|
||||||
|
const models = await fetchFreeZenModels();
|
||||||
|
const modelIds = models.map((model) => model.id);
|
||||||
|
if (candidate && modelIds.includes(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
if (modelIds.includes(ZEN_DEFAULT_MODEL)) {
|
||||||
|
return ZEN_DEFAULT_MODEL;
|
||||||
|
}
|
||||||
|
if (modelIds.length > 0) {
|
||||||
|
return modelIds[0];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (candidate) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return validatedZenFallback || ZEN_DEFAULT_MODEL;
|
return validatedZenFallback || ZEN_DEFAULT_MODEL;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { createNotificationTemplateRuntime } from './template-runtime.js';
|
||||||
|
|
||||||
|
const createRuntime = (settings = {}) => createNotificationTemplateRuntime({
|
||||||
|
readSettingsFromDisk: async () => settings,
|
||||||
|
persistSettings: vi.fn(async () => {}),
|
||||||
|
buildOpenCodeUrl: (path) => path,
|
||||||
|
getOpenCodeAuthHeaders: () => ({}),
|
||||||
|
resolveGitBinaryForSpawn: () => 'git',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('notification template runtime zen models', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses zen models with zero-cost metadata as selectable', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async (url) => {
|
||||||
|
if (String(url).includes('models.dev')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
opencode: {
|
||||||
|
models: {
|
||||||
|
'big-pickle': { cost: { input: 0, output: 0 } },
|
||||||
|
'gpt-5-nano': { cost: { input: 0, output: 0 } },
|
||||||
|
'gpt-5.5': { cost: { input: 5, output: 30 } },
|
||||||
|
'hy3-preview-free': { cost: { input: 0, output: 0 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [
|
||||||
|
{ id: 'big-pickle', owned_by: 'opencode' },
|
||||||
|
{ id: 'gpt-5-nano', owned_by: 'opencode' },
|
||||||
|
{ id: 'gpt-5.5', owned_by: 'opencode' },
|
||||||
|
{ id: 'hy3-preview-free', owned_by: 'opencode' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
const runtime = createRuntime();
|
||||||
|
const models = await runtime.fetchFreeZenModels();
|
||||||
|
|
||||||
|
expect(models.map((model) => model.id)).toEqual([
|
||||||
|
'big-pickle',
|
||||||
|
'gpt-5-nano',
|
||||||
|
'hy3-preview-free',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a valid unauthenticated model when stored zen model is stale', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async (url) => {
|
||||||
|
if (String(url).includes('models.dev')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
opencode: {
|
||||||
|
models: {
|
||||||
|
'big-pickle': { cost: { input: 0, output: 0 } },
|
||||||
|
'gpt-5-nano': { cost: { input: 0, output: 0 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: [
|
||||||
|
{ id: 'big-pickle', owned_by: 'opencode' },
|
||||||
|
{ id: 'gpt-5-nano', owned_by: 'opencode' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
const runtime = createRuntime({ zenModel: 'trinity-large-preview-free' });
|
||||||
|
|
||||||
|
await expect(runtime.resolveZenModel()).resolves.toBe('gpt-5-nano');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -131,6 +131,42 @@ function extractZenOutputText(data) {
|
|||||||
return text || null;
|
return text || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractZenChatCompletionText(data) {
|
||||||
|
if (!data || typeof data !== 'object') return null;
|
||||||
|
const choices = data.choices;
|
||||||
|
if (!Array.isArray(choices)) return null;
|
||||||
|
|
||||||
|
const choice = choices.find((item) => item && typeof item === 'object');
|
||||||
|
const content = choice?.message?.content;
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
const text = content.trim();
|
||||||
|
return text || null;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(content)) return null;
|
||||||
|
|
||||||
|
const text = content
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === 'string') return item;
|
||||||
|
if (item && typeof item === 'object' && typeof item.text === 'string') return item.text;
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
.trim();
|
||||||
|
return text || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getZenCompletionEndpoint(model) {
|
||||||
|
if (typeof model !== 'string') return 'responses';
|
||||||
|
if (
|
||||||
|
model.startsWith('gpt-')
|
||||||
|
|| model.startsWith('claude-')
|
||||||
|
|| model.startsWith('gemini-')
|
||||||
|
) {
|
||||||
|
return 'responses';
|
||||||
|
}
|
||||||
|
return 'chat/completions';
|
||||||
|
}
|
||||||
|
|
||||||
function distillNoteFallback(text, maxLength) {
|
function distillNoteFallback(text, maxLength) {
|
||||||
const sanitized = sanitizeForNote(text);
|
const sanitized = sanitizeForNote(text);
|
||||||
if (!sanitized) return '';
|
if (!sanitized) return '';
|
||||||
@@ -176,15 +212,23 @@ export async function summarizeText({ text, threshold = 200, maxLength = 500, ze
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const prompt = buildSummarizationPrompt(maxLength, mode);
|
const prompt = buildSummarizationPrompt(maxLength, mode);
|
||||||
const response = await fetch('https://opencode.ai/zen/v1/responses', {
|
const model = zenModel || 'gpt-5-nano';
|
||||||
|
const endpoint = getZenCompletionEndpoint(model);
|
||||||
|
const response = await fetch(`https://opencode.ai/zen/v1/${endpoint}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(endpoint === 'responses'
|
||||||
model: zenModel || 'gpt-5-nano',
|
? {
|
||||||
input: [{ role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` }],
|
model,
|
||||||
stream: false,
|
input: [{ role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` }],
|
||||||
reasoning: { effort: 'low' },
|
stream: false,
|
||||||
}),
|
reasoning: { effort: 'low' },
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
model,
|
||||||
|
messages: [{ role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` }],
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,7 +243,9 @@ export async function summarizeText({ text, threshold = 200, maxLength = 500, ze
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const summary = extractZenOutputText(data);
|
const summary = endpoint === 'responses'
|
||||||
|
? extractZenOutputText(data)
|
||||||
|
: extractZenChatCompletionText(data);
|
||||||
|
|
||||||
if (summary) {
|
if (summary) {
|
||||||
const sanitized = sanitizeByMode(summary, mode);
|
const sanitized = sanitizeByMode(summary, mode);
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { summarizeText } from './summarization.js';
|
||||||
|
|
||||||
|
describe('text summarization zen requests', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses responses endpoint for gpt models', async () => {
|
||||||
|
const fetchMock = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
output: [{
|
||||||
|
type: 'message',
|
||||||
|
content: [{ type: 'output_text', text: 'Short summary' }],
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const result = await summarizeText({
|
||||||
|
text: 'Long text '.repeat(30),
|
||||||
|
threshold: 0,
|
||||||
|
maxLength: 100,
|
||||||
|
zenModel: 'gpt-5-nano',
|
||||||
|
mode: 'notification',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'https://opencode.ai/zen/v1/responses',
|
||||||
|
expect.objectContaining({
|
||||||
|
body: expect.stringContaining('"input"'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.summary).toBe('Short summary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses chat completions endpoint for openai-compatible zen models', async () => {
|
||||||
|
const fetchMock = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
choices: [{ message: { content: 'Chat summary' } }],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const result = await summarizeText({
|
||||||
|
text: 'Long text '.repeat(30),
|
||||||
|
threshold: 0,
|
||||||
|
maxLength: 100,
|
||||||
|
zenModel: 'big-pickle',
|
||||||
|
mode: 'notification',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'https://opencode.ai/zen/v1/chat/completions',
|
||||||
|
expect.objectContaining({
|
||||||
|
body: expect.stringContaining('"messages"'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.summary).toBe('Chat summary');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { normalizeCustomOpenAIBaseURL } from './base-url.js';
|
import { normalizeCustomOpenAIBaseURL } from './base-url.js';
|
||||||
import { summarizeText, sanitizeForTTS, sanitizeForNote } from '../text/summarization.js';
|
import { summarizeText, sanitizeForTTS, sanitizeForNote, sanitizeForNotification } from '../text/summarization.js';
|
||||||
|
|
||||||
export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
|
export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
|
||||||
let ttsModulePromise = null;
|
let ttsModulePromise = null;
|
||||||
@@ -124,7 +124,7 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sumZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined);
|
const sumZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined);
|
||||||
const result = await summarizeText({
|
let result = await summarizeText({
|
||||||
text,
|
text,
|
||||||
threshold,
|
threshold,
|
||||||
maxLength,
|
maxLength,
|
||||||
@@ -132,6 +132,27 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
|
|||||||
mode: typeof mode === 'string' ? mode : 'tts',
|
mode: typeof mode === 'string' ? mode : 'tts',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (mode === 'note' && !result.summarized) {
|
||||||
|
const notificationResult = await summarizeText({
|
||||||
|
text,
|
||||||
|
threshold,
|
||||||
|
maxLength,
|
||||||
|
zenModel: sumZenModel,
|
||||||
|
mode: 'notification',
|
||||||
|
});
|
||||||
|
if (notificationResult.summarized && notificationResult.summary) {
|
||||||
|
result = {
|
||||||
|
...notificationResult,
|
||||||
|
summary: sanitizeForNote(sanitizeForNotification(notificationResult.summary)),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return res.status(502).json({
|
||||||
|
error: 'Note summarization failed',
|
||||||
|
reason: notificationResult.reason || result.reason || 'No distilled result from model',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return res.json(result);
|
return res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Summarize] Error:', error);
|
console.error('[Summarize] Error:', error);
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
|
||||||
|
import { registerTtsRoutes } from './routes.js';
|
||||||
|
|
||||||
|
const createApp = () => {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
registerTtsRoutes(app, {
|
||||||
|
resolveZenModel: async () => 'gpt-5-nano',
|
||||||
|
sayTTSCapability: null,
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('tts routes', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries note summarization with notification mode before failing', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
json: async () => ({ error: 'unavailable' }),
|
||||||
|
})));
|
||||||
|
|
||||||
|
const response = await request(createApp())
|
||||||
|
.post('/api/text/summarize')
|
||||||
|
.send({
|
||||||
|
text: 'First sentence. Second sentence with the useful insight.',
|
||||||
|
threshold: 0,
|
||||||
|
maxLength: 100,
|
||||||
|
mode: 'note',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(502);
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
error: 'Note summarization failed',
|
||||||
|
reason: 'zen API returned 503',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses notification summarizer result when note mode falls back', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
json: async () => ({ error: 'unavailable' }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
output: [{
|
||||||
|
type: 'message',
|
||||||
|
content: [{ type: 'output_text', text: '**Keep provider state stable** during streaming.' }],
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await request(createApp())
|
||||||
|
.post('/api/text/summarize')
|
||||||
|
.send({
|
||||||
|
text: 'First sentence. Preserve provider state references during streaming to avoid wide rerenders.',
|
||||||
|
threshold: 0,
|
||||||
|
maxLength: 100,
|
||||||
|
mode: 'note',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
summary: 'Keep provider state stable during streaming.',
|
||||||
|
summarized: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps notification fallback behavior', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
json: async () => ({ error: 'unavailable' }),
|
||||||
|
})));
|
||||||
|
|
||||||
|
const response = await request(createApp())
|
||||||
|
.post('/api/text/summarize')
|
||||||
|
.send({
|
||||||
|
text: 'Notification text that should fall back cleanly.',
|
||||||
|
threshold: 0,
|
||||||
|
maxLength: 100,
|
||||||
|
mode: 'notification',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
summary: 'Notification text that should fall back cleanly.',
|
||||||
|
summarized: false,
|
||||||
|
reason: 'zen API returned 503',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user