mirror of
https://github.com/laurent22/joplin.git
synced 2026-10-25 02:47:28 +00:00
This commit is contained in:
1 parent
c2b04f26df
commit
d05a24ef7a
23 files changed
+1107
-431
No files matched your search
@@ -1577,6 +1577,7 @@ packages/lib/services/ai/chunker.js
|
||||
packages/lib/services/ai/classification.js
|
||||
packages/lib/services/ai/noteChat.test.js
|
||||
packages/lib/services/ai/noteChat.js
|
||||
packages/lib/services/ai/providers/Anthropic.test.js
|
||||
packages/lib/services/ai/providers/Anthropic.js
|
||||
packages/lib/services/ai/providers/ChatProviderBase.js
|
||||
packages/lib/services/ai/providers/JoplinCloud.js
|
||||
|
||||
@@ -1603,6 +1603,7 @@ packages/lib/services/ai/chunker.js
|
||||
packages/lib/services/ai/classification.js
|
||||
packages/lib/services/ai/noteChat.test.js
|
||||
packages/lib/services/ai/noteChat.js
|
||||
packages/lib/services/ai/providers/Anthropic.test.js
|
||||
packages/lib/services/ai/providers/Anthropic.js
|
||||
packages/lib/services/ai/providers/ChatProviderBase.js
|
||||
packages/lib/services/ai/providers/JoplinCloud.js
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { AppState, createAppDefaultWindowState } from './app.reducer';
|
||||
import { ChatRole, ChatToolCall, ChatToolMessage } from '@joplin/lib/services/ai/types';
|
||||
import { AiChatMessage, AppState, createAppDefaultWindowState } from './app.reducer';
|
||||
import appReducer, { createAppDefaultState } from './app.reducer';
|
||||
import { defaultWindowId } from '@joplin/lib/reducer';
|
||||
|
||||
describe('app.reducer', () => {
|
||||
|
||||
@@ -71,4 +73,63 @@ describe('app.reducer', () => {
|
||||
expect(newState.visibleDialogs).toEqual({ testDialog: true });
|
||||
});
|
||||
|
||||
it('should build AI chat history', () => {
|
||||
let state: AppState = {
|
||||
...createAppDefaultState({}),
|
||||
};
|
||||
|
||||
let idCounter = 0;
|
||||
const buildMessage = (role: ChatRole.User | ChatRole.Assistant, content: string, toolCalls: ChatToolCall[]) => ({
|
||||
id: `id-${idCounter++}`,
|
||||
role,
|
||||
text: content,
|
||||
raw: [
|
||||
{
|
||||
role,
|
||||
content: content,
|
||||
...(toolCalls.length ? { toolCalls } : {}),
|
||||
},
|
||||
],
|
||||
} satisfies AiChatMessage);
|
||||
|
||||
state = appReducer(state, {
|
||||
type: 'AI_CHAT_APPEND',
|
||||
windowId: defaultWindowId,
|
||||
message: buildMessage(ChatRole.User, 'Test', []),
|
||||
});
|
||||
state = appReducer(state, {
|
||||
type: 'AI_CHAT_APPEND',
|
||||
windowId: defaultWindowId,
|
||||
message: buildMessage(
|
||||
ChatRole.Assistant,
|
||||
'Testing',
|
||||
[{ toolName: 'testTool', callId: 'call-1', arguments: { arg: 1 } }],
|
||||
),
|
||||
});
|
||||
state = appReducer(state, {
|
||||
type: 'AI_CHAT_ADD_TOOL_RESULT',
|
||||
windowId: defaultWindowId,
|
||||
toolCall: ({
|
||||
role: ChatRole.Tool,
|
||||
toolName: 'testTool',
|
||||
toolCallId: 'call-1',
|
||||
userDescription: '',
|
||||
isError: false,
|
||||
content: 'Result',
|
||||
} satisfies ChatToolMessage),
|
||||
});
|
||||
|
||||
expect(state.aiChatMessages).toMatchObject([
|
||||
{ id: 'id-0', role: 'user', text: 'Test', raw: [{ role: 'user' }] },
|
||||
{ id: 'id-1', role: 'assistant', text: 'Testing', editsApplied: 1, editsMissed: 0 },
|
||||
]);
|
||||
|
||||
|
||||
state = appReducer(state, {
|
||||
type: 'AI_CHAT_RESET',
|
||||
windowId: defaultWindowId,
|
||||
});
|
||||
|
||||
expect(state.aiChatMessages).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import iterateItems from './gui/ResizableLayout/utils/iterateItems';
|
||||
import { LayoutItem } from './gui/ResizableLayout/utils/types';
|
||||
import validateLayout from './gui/ResizableLayout/utils/validateLayout';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { ChatMessage } from '@joplin/lib/services/ai/types';
|
||||
|
||||
const logger = Logger.create('app.reducer');
|
||||
|
||||
@@ -14,6 +15,9 @@ export interface AiChatMessage {
|
||||
text: string;
|
||||
editsApplied?: number;
|
||||
editsMissed?: number;
|
||||
|
||||
// The raw message(s) corresponding to this event
|
||||
raw: ChatMessage[];
|
||||
}
|
||||
|
||||
export interface AppStateRoute {
|
||||
@@ -289,6 +293,34 @@ export default function(state: AppState, action: any) {
|
||||
);
|
||||
break;
|
||||
|
||||
case 'AI_CHAT_ADD_TOOL_RESULT':
|
||||
newState = withWindowStateUpdated(
|
||||
state, action.windowId, 'aiChatMessages', messages => {
|
||||
let lastMessage = messages[messages.length - 1];
|
||||
if (lastMessage) {
|
||||
const toolCall = action.toolCall;
|
||||
const error = toolCall.isError;
|
||||
const editsApplied = (lastMessage.editsApplied ?? 0) + (error ? 0 : 1);
|
||||
const editsMissed = (lastMessage.editsMissed ?? 0) + (error ? 1 : 0);
|
||||
|
||||
lastMessage = {
|
||||
...lastMessage,
|
||||
editsApplied,
|
||||
editsMissed,
|
||||
raw: [
|
||||
...lastMessage.raw,
|
||||
action.toolCall,
|
||||
],
|
||||
};
|
||||
|
||||
return [...messages.slice(0, messages.length - 1), lastMessage];
|
||||
}
|
||||
|
||||
return messages;
|
||||
},
|
||||
);
|
||||
break;
|
||||
|
||||
case 'AI_CHAT_REMOVE':
|
||||
newState = withWindowStateUpdated(
|
||||
state, action.windowId, 'aiChatMessages', messages => messages.filter(m => m.id !== action.id),
|
||||
|
||||
@@ -9,10 +9,13 @@ import CommandService from '@joplin/lib/services/CommandService';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import { AiChatMessage, AppState } from '../../app.reducer';
|
||||
import { runNoteChat, ChatTurn } from '@joplin/lib/services/ai/noteChat';
|
||||
import { applyAnchorEdits } from '@joplin/lib/services/ai/applyNoteEdits';
|
||||
import { runNoteChat } from '@joplin/lib/services/ai/noteChat';
|
||||
import { chatAvailability } from '@joplin/lib/services/ai/availability';
|
||||
import { WindowIdContext } from '../NewWindowOrIFrame';
|
||||
import { ChatMessage, ChatRole, ChatToolMessage } from '@joplin/lib/services/ai/types';
|
||||
import JoplinError from '@joplin/lib/JoplinError';
|
||||
import eventManager, { EventName, ItemChangeEvent } from '@joplin/lib/eventManager';
|
||||
import { Second } from '@joplin/utils/time';
|
||||
import InlineMarkdownDisplay from '../InlineMarkdownDisplay';
|
||||
|
||||
const logger = Logger.create('ChatPanel');
|
||||
@@ -34,12 +37,58 @@ const disclosureSetting = 'ai.chat.disclosureAcknowledged';
|
||||
let nextMessageId = 0;
|
||||
const makeId = () => `m-${Date.now()}-${++nextMessageId}`;
|
||||
|
||||
const editsSummary = (applied: number, missed: number) => {
|
||||
const editsSummary = (actions: ChatMessage[], applied: number, missed: number) => {
|
||||
if (applied + missed === 0) return '';
|
||||
if (missed === 0) return _('%d edit(s) applied.', applied);
|
||||
if (missed === 0 && applied > 1) return _('%d edit(s) applied.', applied);
|
||||
if (missed === 0) {
|
||||
const toolResults = actions.filter(action => action.role === ChatRole.Tool);
|
||||
return toolResults.map(result => result.userDescription).join('\n');
|
||||
}
|
||||
return _('%d edit(s) applied, %d could not be placed automatically.', applied, missed);
|
||||
};
|
||||
|
||||
const waitForNextNoteChangeOrTimeout = (noteId: string, timeout: number) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
const listener = (event: ItemChangeEvent) => {
|
||||
if (event.itemId === noteId) {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
const onComplete = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
eventManager.off(EventName.ItemChange, listener);
|
||||
resolve();
|
||||
};
|
||||
eventManager.on(EventName.ItemChange, listener);
|
||||
|
||||
let timeoutId = setTimeout(() => {
|
||||
onComplete();
|
||||
}, timeout);
|
||||
});
|
||||
};
|
||||
|
||||
const useCancelCallback = () => {
|
||||
const abortControllerRef = useRef(new AbortController());
|
||||
|
||||
const cancelRequest = useCallback(() => {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = new AbortController();
|
||||
}, []);
|
||||
|
||||
// Cancel on Reset / unmount so an in-flight reply can detect it should
|
||||
// abort instead of landing in a cleared or destroyed conversation.
|
||||
const cancelRequestRef = useRef(cancelRequest);
|
||||
cancelRequestRef.current = cancelRequest;
|
||||
useEffect(() => () => {
|
||||
cancelRequestRef.current();
|
||||
}, []);
|
||||
|
||||
return { abortControllerRef, cancelRequest };
|
||||
};
|
||||
|
||||
// Single-window for v1: mapStateToProps hard-codes defaultWindowId and the
|
||||
// toggle writes to the app-wide layout. A second window would mirror the main.
|
||||
const ChatPanel: React.FC<Props> = (props) => {
|
||||
@@ -62,10 +111,7 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
noteIdRef.current = props.noteId;
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Bumped on Reset / unmount so an in-flight reply can detect it should
|
||||
// abort instead of landing in a cleared or destroyed conversation.
|
||||
const generationRef = useRef(0);
|
||||
useEffect(() => () => { generationRef.current++; }, []);
|
||||
const { abortControllerRef, cancelRequest } = useCancelCallback();
|
||||
|
||||
const windowId = useContext(WindowIdContext);
|
||||
|
||||
@@ -73,6 +119,10 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
dispatch({ type: 'AI_CHAT_APPEND', windowId, message });
|
||||
}, [dispatch, windowId]);
|
||||
|
||||
const addToolResult = useCallback((result: ChatToolMessage) => {
|
||||
dispatch({ type: 'AI_CHAT_ADD_TOOL_RESULT', windowId, toolCall: result });
|
||||
}, [dispatch, windowId]);
|
||||
|
||||
// Drop a separator when the active note changes mid-conversation. Skip
|
||||
// the first ever opened note (no prior context to separate from).
|
||||
useEffect(() => {
|
||||
@@ -84,6 +134,7 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
id: makeId(),
|
||||
role: 'separator',
|
||||
text: _('— now viewing: %s —', props.noteTitle || _('(untitled)')),
|
||||
raw: [],
|
||||
});
|
||||
}, [props.noteId, props.noteTitle, appendMessage]);
|
||||
|
||||
@@ -92,10 +143,10 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const conversationTurns = useMemo<ChatTurn[]>(() => {
|
||||
const conversationTurns = useMemo<ChatMessage[]>(() => {
|
||||
return messages
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
.map(m => ({ role: m.role as 'user' | 'assistant', content: m.text }));
|
||||
.map(m => m.raw)
|
||||
.flat();
|
||||
}, [messages]);
|
||||
|
||||
// Joplin Cloud is remote but the user already consented via sync setup.
|
||||
@@ -106,11 +157,13 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
const text = input.trim();
|
||||
if (!text || sending) return;
|
||||
if (!props.noteId) {
|
||||
appendMessage({ id: makeId(), role: 'error', text: _('Open a note to start chatting.') });
|
||||
appendMessage({
|
||||
id: makeId(), role: 'error', text: _('Open a note to start chatting.'), raw: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const abortController = abortControllerRef.current;
|
||||
|
||||
const startGeneration = generationRef.current;
|
||||
const noteIdAtStart = props.noteId;
|
||||
setSending(true);
|
||||
setInput('');
|
||||
@@ -118,103 +171,117 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
// Captured so we can roll it back on failure — otherwise a retry would
|
||||
// send the prior user turn as history alongside the new prompt.
|
||||
const userTurnId = makeId();
|
||||
appendMessage({ id: userTurnId, role: 'user', text });
|
||||
let hadSuccessfulResponse = false;
|
||||
appendMessage({ id: userTurnId, role: 'user', text, raw: [] });
|
||||
|
||||
try {
|
||||
const note = await Note.load(props.noteId);
|
||||
if (!note) throw new Error(`Note not found: ${props.noteId}`);
|
||||
|
||||
let selection = '';
|
||||
try {
|
||||
selection = await CommandService.instance().execute('selectedText') || '';
|
||||
} catch {
|
||||
// Editor may not be ready; treat as no selection.
|
||||
}
|
||||
const getContext = async () => {
|
||||
let selection = '';
|
||||
try {
|
||||
selection = await CommandService.instance().executeInWindow(
|
||||
'selectedText', { windowId, args: [] },
|
||||
) as string || '';
|
||||
} catch {
|
||||
// Editor may not be ready; treat as no selection.
|
||||
}
|
||||
|
||||
const reply = await runNoteChat({
|
||||
title: note.title || '',
|
||||
body: note.body || '',
|
||||
selection: selection || null,
|
||||
}, conversationTurns, text);
|
||||
const note = await Note.load(props.noteId);
|
||||
if (!note) throw new Error(`Note not found: ${props.noteId}`);
|
||||
|
||||
if (generationRef.current !== startGeneration) return;
|
||||
return {
|
||||
body: note.body,
|
||||
title: note.title,
|
||||
selection,
|
||||
};
|
||||
};
|
||||
|
||||
let editsApplied = 0;
|
||||
let editsMissed = 0;
|
||||
if (reply.edits.length > 0) {
|
||||
// Editor commands run against the focused editor, which may now
|
||||
// be a different note. Refuse rather than mutate the wrong one.
|
||||
if (noteIdRef.current !== noteIdAtStart) {
|
||||
appendMessage({
|
||||
id: makeId(),
|
||||
role: 'error',
|
||||
text: _('You switched notes while the request was running; edits were not applied. Try again.'),
|
||||
});
|
||||
} else {
|
||||
// Re-read live body: the user may have typed while the
|
||||
// request was in flight, and we don't want to overwrite that.
|
||||
const fresh = await Note.load(noteIdAtStart);
|
||||
const liveBody = fresh?.body ?? '';
|
||||
let lastHistory = [
|
||||
{ role: ChatRole.System, content: 'placeholder' },
|
||||
...conversationTurns,
|
||||
];
|
||||
|
||||
if (liveBody !== (note.body || '')) {
|
||||
const onHistoryChanged = (history: ChatMessage[]) => {
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
for (let i = lastHistory.length; i < history.length; i++) {
|
||||
const entry = history[i];
|
||||
|
||||
// User messages are added elsewhere, when the user submits the chat
|
||||
if (entry.role === ChatRole.User) continue;
|
||||
// System messages are not shown in the UI
|
||||
if (entry.role === ChatRole.System) continue;
|
||||
|
||||
hadSuccessfulResponse = true;
|
||||
|
||||
if (entry.role === ChatRole.Tool) {
|
||||
addToolResult(entry);
|
||||
} else {
|
||||
appendMessage({
|
||||
id: makeId(),
|
||||
role: 'error',
|
||||
text: _('The note changed while the request was running; edits were not applied. Try again.'),
|
||||
role: entry.role,
|
||||
text: entry.content,
|
||||
raw: [entry],
|
||||
});
|
||||
} else {
|
||||
const selectionEdits = reply.edits.filter(e => e.op === 'replaceSelection');
|
||||
const anchorEdits = reply.edits.filter(e => e.op !== 'replaceSelection');
|
||||
|
||||
for (const edit of selectionEdits) {
|
||||
if (edit.op !== 'replaceSelection') continue;
|
||||
if (!selection) {
|
||||
editsMissed++;
|
||||
continue;
|
||||
}
|
||||
await CommandService.instance().executeInWindow('replaceSelection', {
|
||||
windowId,
|
||||
args: [edit.text],
|
||||
});
|
||||
editsApplied++;
|
||||
}
|
||||
|
||||
if (anchorEdits.length > 0) {
|
||||
const cursorPos = selection ? Math.max(0, liveBody.indexOf(selection)) : 0;
|
||||
const { newBody, appliedEdits } = applyAnchorEdits(liveBody, anchorEdits, cursorPos);
|
||||
const missed = appliedEdits.filter(e => e.status !== 'applied').length;
|
||||
editsMissed += missed;
|
||||
editsApplied += appliedEdits.length - missed;
|
||||
if (newBody !== liveBody) {
|
||||
await CommandService.instance().executeInWindow('editor.setText', {
|
||||
windowId,
|
||||
args: [newBody],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (generationRef.current !== startGeneration) return;
|
||||
lastHistory = history;
|
||||
};
|
||||
|
||||
appendMessage({
|
||||
id: makeId(),
|
||||
role: 'assistant',
|
||||
text: reply.reply || _('(no message)'),
|
||||
editsApplied,
|
||||
editsMissed,
|
||||
});
|
||||
const assertSameNote = () => {
|
||||
if (noteIdAtStart !== noteIdRef.current) {
|
||||
cancelRequest();
|
||||
throw new JoplinError(
|
||||
_('You switched notes while the request was running; edits were not applied. Try again.'), 'aiNoteChanged',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
await runNoteChat(
|
||||
getContext, conversationTurns, text, {
|
||||
replaceSelection: async (text, oldText) => {
|
||||
if (text === oldText) return;
|
||||
assertSameNote();
|
||||
|
||||
const changeListener = waitForNextNoteChangeOrTimeout(props.noteId, Second);
|
||||
await CommandService.instance().executeInWindow('replaceSelection', {
|
||||
windowId,
|
||||
args: [text],
|
||||
});
|
||||
await changeListener;
|
||||
},
|
||||
updateNoteBody: async (newBody, oldBody) => {
|
||||
if (newBody === oldBody) return;
|
||||
assertSameNote();
|
||||
|
||||
const changeListener = waitForNextNoteChangeOrTimeout(props.noteId, Second);
|
||||
await CommandService.instance().executeInWindow('editor.setText', {
|
||||
windowId,
|
||||
args: [newBody],
|
||||
});
|
||||
await changeListener;
|
||||
},
|
||||
displayError: (message) => {
|
||||
appendMessage({ id: makeId(), role: 'error', text: message, raw: [] });
|
||||
},
|
||||
}, onHistoryChanged, abortController.signal,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn('Chat failed:', error);
|
||||
if (generationRef.current !== startGeneration) return;
|
||||
dispatch({ type: 'AI_CHAT_REMOVE', windowId, id: userTurnId });
|
||||
setInput(text);
|
||||
appendMessage({ id: makeId(), role: 'error', text: error.message || _('Something went wrong.') });
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
if (!hadSuccessfulResponse) {
|
||||
dispatch({ type: 'AI_CHAT_REMOVE', windowId, id: userTurnId });
|
||||
setInput(text);
|
||||
}
|
||||
appendMessage({ id: makeId(), role: 'error', text: error.message || _('Something went wrong.'), raw: [] });
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [input, sending, props.noteId, conversationTurns, windowId, appendMessage, dispatch]);
|
||||
}, [input, sending, props.noteId, conversationTurns, windowId, addToolResult, appendMessage, dispatch, cancelRequest, abortControllerRef]);
|
||||
|
||||
const handleAcknowledgeDisclosure = useCallback(() => {
|
||||
Setting.setValue(disclosureSetting, true);
|
||||
@@ -222,9 +289,10 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
}, []);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
generationRef.current++;
|
||||
cancelRequest();
|
||||
|
||||
dispatch({ type: 'AI_CHAT_RESET', windowId: windowId });
|
||||
}, [dispatch, windowId]);
|
||||
}, [dispatch, windowId, cancelRequest]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Don't send while an IME composition is in flight — Enter commits
|
||||
@@ -283,19 +351,21 @@ const ChatPanel: React.FC<Props> = (props) => {
|
||||
return <div key={m.id} className='error'>{m.text}</div>;
|
||||
}
|
||||
|
||||
const summary = m.role === 'assistant' ? editsSummary(m.raw, m.editsApplied ?? 0, m.editsMissed ?? 0) : '';
|
||||
// Always show something in the message box:
|
||||
const textContent = !m.text && !summary ? _('(no message)') : m.text;
|
||||
|
||||
const renderMarkdown = m.role === 'assistant';
|
||||
const content = renderMarkdown
|
||||
? <InlineMarkdownDisplay
|
||||
className='content'
|
||||
markdown={m.text}
|
||||
markdown={textContent}
|
||||
allowLinks={false} />
|
||||
: <div className='content'>{m.text}</div>;
|
||||
const summary = m.role === 'assistant' ? editsSummary(m.editsApplied ?? 0, m.editsMissed ?? 0) : '';
|
||||
: <div className='content'>{textContent}</div>;
|
||||
|
||||
return (
|
||||
<div key={m.id} className={`turn -${m.role}`}>
|
||||
{content}
|
||||
|
||||
{summary && (
|
||||
<div className='meta'>
|
||||
{(m.editsMissed ?? 0) > 0
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
font-size: 0.8em;
|
||||
color: var(--joplin-color-faded);
|
||||
margin-top: 4px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chat-panel > .messages > .turn > .meta > .warning {
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface ExecOptions {
|
||||
path?: string;
|
||||
source?: string;
|
||||
ignoreError?: (error: Error)=> boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
@@ -178,8 +179,10 @@ export default class JoplinServerApi {
|
||||
headers['X-API-MIN-VERSION'] = '2.6.0'; // Need server 2.6 for new lock support
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- See exec_.body above
|
||||
const fetchOptions: { headers: Record<string, string>; method: string; path?: string; body?: any } = { headers, method };
|
||||
type FetchOptions = { headers: Record<string, string>; method: string; path?: string; body?: any; signal?: AbortSignal };
|
||||
const fetchOptions: FetchOptions = { headers, method };
|
||||
if (options.path) fetchOptions.path = options.path;
|
||||
if (options.signal) fetchOptions.signal = options.signal;
|
||||
|
||||
if (body) {
|
||||
if (typeof body === 'object') {
|
||||
@@ -289,7 +292,12 @@ export default class JoplinServerApi {
|
||||
// Don't print error info for file not found (handled by the
|
||||
// driver), or lock-acquisition errors because it's handled by
|
||||
// LockHandler.
|
||||
if (![404, 'hasExclusiveLock', 'hasSyncLock'].includes(error.code) && !options?.ignoreError?.(error)) {
|
||||
if (
|
||||
![404, 'hasExclusiveLock', 'hasSyncLock'].includes(error.code)
|
||||
// Don't print errors for cancelled requests
|
||||
&& error.type !== 'aborted'
|
||||
&& !options?.ignoreError?.(error)
|
||||
) {
|
||||
logger.warn(this.requestToCurl_(url, fetchOptions));
|
||||
logger.warn('Code:', error.code);
|
||||
logger.warn(error);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { type Registry } from '../../../registry';
|
||||
import settingValidations from '../../../models/settings/settingValidations';
|
||||
import { convertValuesToFunctions } from '../../../ObjectUtils';
|
||||
import aiSettingsTransition from '../../../services/ai/aiSettingsTransition';
|
||||
import { ChatRole } from '../../../services/ai/types';
|
||||
|
||||
const logger = Logger.create('config-shared');
|
||||
|
||||
@@ -123,7 +124,7 @@ export const checkAiConfig = async (comp: ConfigScreenComponent) => {
|
||||
try {
|
||||
const { default: AiService } = await import('../../../services/ai/AiService');
|
||||
const result = await AiService.instance().chat([
|
||||
{ role: 'user', content: 'Reply with the single word OK.' },
|
||||
{ role: ChatRole.User, content: 'Reply with the single word OK.' },
|
||||
], { maxTokens: 20 });
|
||||
const text = (result.text || '').trim();
|
||||
if (!text) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Imported from dead package https://github.com/RadLikeWhoa/Countable
|
||||
//
|
||||
// 2023-06-05: Added "—" as word separator.
|
||||
// 2026-07-09: Exposed countOnce for synchronous counting.
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
@@ -281,6 +282,8 @@
|
||||
|
||||
return this
|
||||
},
|
||||
|
||||
countOnce: count,
|
||||
|
||||
/**
|
||||
* The `enabled` method checks if the live-counting functionality is bound
|
||||
|
||||
@@ -23,7 +23,7 @@ export enum EventName {
|
||||
WindowClose = 'windowClose',
|
||||
}
|
||||
|
||||
interface ItemChangeEvent {
|
||||
export interface ItemChangeEvent {
|
||||
itemType: ModelType;
|
||||
itemId: string;
|
||||
// Passing a changeId to Note.save causes that changeId to be included
|
||||
|
||||
@@ -79,7 +79,6 @@
|
||||
"image-data-uri": "2.0.1",
|
||||
"immer": "9.0.21",
|
||||
"js-yaml": "4.1.1",
|
||||
"json5": "2.2.3",
|
||||
"markdown-it": "13.0.2",
|
||||
"md5": "2.3.0",
|
||||
"md5-file": "5.0.0",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-u
|
||||
import Setting from '../../models/Setting';
|
||||
import AiService from './AiService';
|
||||
import deriveClassification from './classification';
|
||||
import { ChatRole } from './types';
|
||||
|
||||
describe('AiService', () => {
|
||||
|
||||
@@ -13,7 +14,7 @@ describe('AiService', () => {
|
||||
|
||||
it('throws when AI is disabled', async () => {
|
||||
Setting.setValue('ai.enabled', false);
|
||||
await expect(AiService.instance().chat([{ role: 'user', content: 'hi' }]))
|
||||
await expect(AiService.instance().chat([{ role: ChatRole.User, content: 'hi' }]))
|
||||
.rejects.toMatchObject({ code: 'aiDisabled' });
|
||||
});
|
||||
|
||||
@@ -24,7 +25,7 @@ describe('AiService', () => {
|
||||
Setting.setValue('ai.chat.apiKey', 'sk-test');
|
||||
Setting.setValue('ai.chat.model', 'claude-3-5-sonnet-latest');
|
||||
|
||||
await expect(AiService.instance().chat([{ role: 'user', content: 'hi' }]))
|
||||
await expect(AiService.instance().chat([{ role: ChatRole.User, content: 'hi' }]))
|
||||
.rejects.toMatchObject({ code: 'aiRemoteNotAllowed' });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { EditOp, supportedStructuredBlockTags } from './noteChat';
|
||||
import type { EditOp } from './noteChat';
|
||||
import findFencedBlock from './utils/findFencedBlock';
|
||||
|
||||
export type ApplyEditStatus = 'applied' | 'anchor-not-found' | 'invalid';
|
||||
|
||||
|
||||
// Structured-document fences where the model regenerates the whole block —
|
||||
// plain code fences (```js, ```python) are deliberately excluded.
|
||||
export const supportedStructuredBlockTags = ['jsoncanvas', 'mermaid', 'abc', 'fountain'];
|
||||
|
||||
export const structuredBlockTags = new Set<string>([...supportedStructuredBlockTags]);
|
||||
|
||||
export interface AppliedEdit {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { _internal, NoteContext } from './noteChat';
|
||||
import { applyAnchorEdits } from './applyNoteEdits';
|
||||
|
||||
const getEditOperationSchema = (note: NoteContext) => {
|
||||
const schema = _internal.responseSchema(note).json_schema.schema;
|
||||
return schema.properties.edits.items;
|
||||
};
|
||||
import { ChatRole, ChatToolCall } from './types';
|
||||
import { expectThrow } from '../../testing/test-utils';
|
||||
|
||||
describe('noteChat', () => {
|
||||
|
||||
@@ -45,26 +42,6 @@ describe('noteChat', () => {
|
||||
expect(prompt).toContain('BEGIN NOTE');
|
||||
});
|
||||
|
||||
test.each([
|
||||
true, false,
|
||||
])('responseSchema should require "op" and disallow additional properties (has selection: %b)', (selection) => {
|
||||
const schema = getEditOperationSchema({
|
||||
title: 'Note',
|
||||
body: 'some body',
|
||||
selection: selection ? 'body' : null,
|
||||
});
|
||||
|
||||
const operations = [
|
||||
...(schema.type === 'object' ? [schema] : []),
|
||||
...(schema.anyOf ?? []),
|
||||
];
|
||||
|
||||
for (const operation of operations) {
|
||||
expect(operation.required).toContain('op');
|
||||
expect(operation.additionalProperties).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
label: 'restricts ops to replaceSelection when selection present',
|
||||
@@ -93,52 +70,11 @@ describe('noteChat', () => {
|
||||
},
|
||||
expectedOperations: ['insertBefore', 'insertAfter', 'appendToNote', 'replaceRange', 'replaceFencedBlock'],
|
||||
},
|
||||
])('responseSchema is consistent with systemPrompt (case $label)', ({ note, expectedOperations }) => {
|
||||
const allOperations = new Set([
|
||||
'insertBefore',
|
||||
'insertAfter',
|
||||
'appendToNote',
|
||||
'replaceRange',
|
||||
'replaceFencedBlock',
|
||||
]);
|
||||
const expectedMissingOperations = new Set(allOperations);
|
||||
for (const operation of expectedOperations) {
|
||||
expectedMissingOperations.delete(operation);
|
||||
}
|
||||
])('toolDefinitions should include the expected operations (case $label)', ({ note, expectedOperations }) => {
|
||||
const editSchemaItems = _internal.toolDefinitions(note);
|
||||
|
||||
const prompt = _internal.systemPrompt(note);
|
||||
const editSchemaItems = getEditOperationSchema(note);
|
||||
|
||||
const allowedSchemaOperations = [
|
||||
...(editSchemaItems.properties?.op?.enum ?? []),
|
||||
...(editSchemaItems.anyOf?.map(item => item.properties.op.enum)?.flat() ?? []),
|
||||
].sort();
|
||||
const allowedSchemaOperations = editSchemaItems.map(item => item.name).sort();
|
||||
expect(allowedSchemaOperations).toEqual([...expectedOperations].sort());
|
||||
|
||||
// Prompt's operations list should be correct
|
||||
for (const operation of expectedOperations) {
|
||||
expect(prompt).toContain(operation);
|
||||
}
|
||||
for (const operation of expectedMissingOperations) {
|
||||
expect(prompt).not.toContain(operation);
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
['{"reply":"hi","edits":[]}', 'hi', 0],
|
||||
['```json\n{"reply":"hi","edits":[]}\n```', 'hi', 0],
|
||||
['{"reply":"done","edits":[{"op":"appendToNote","text":"x"}]}', 'done', 1],
|
||||
// JSON5 tolerances — trailing commas, single quotes, unquoted keys.
|
||||
// Models emit these despite instructions; the parser absorbs the drift.
|
||||
['{"reply":"done","edits":[{"op":"appendToNote","text":"x"},]}', 'done', 1],
|
||||
['{"reply":"done","edits":[{"op":"appendToNote","text":"x",}]}', 'done', 1],
|
||||
['{reply:"done",edits:[{op:"appendToNote",text:"x"}]}', 'done', 1],
|
||||
['{\'reply\':\'done\',\'edits\':[]}', 'done', 0],
|
||||
['not json at all', 'not json at all', 0],
|
||||
])('tryParseReply parses %s', (input, expectedReply, expectedEditCount) => {
|
||||
const parsed = _internal.tryParseReply(input);
|
||||
expect(parsed.reply).toBe(expectedReply);
|
||||
expect(parsed.edits.length).toBe(expectedEditCount);
|
||||
});
|
||||
|
||||
test('estimateTokens approximates char/4', () => {
|
||||
@@ -267,13 +203,14 @@ describe('noteChat', () => {
|
||||
expect(appliedEdits[0].status).toBe('invalid');
|
||||
});
|
||||
|
||||
test('systemPrompt advertises replaceFencedBlock with structured-block guidance', () => {
|
||||
const prompt = _internal.systemPrompt({ title: 'n', body: '```abc\n```', selection: null });
|
||||
expect(prompt).toContain('replaceFencedBlock');
|
||||
expect(prompt).toContain('jsoncanvas');
|
||||
test('toolDefinitions advertises replaceFencedBlock with structured-block guidance', () => {
|
||||
const tools = _internal.toolDefinitions({ title: 'n', body: '```abc\n```', selection: null });
|
||||
const toolDefinition = tools.find(tool => tool.name === 'replaceFencedBlock');
|
||||
expect(toolDefinition).toBeTruthy();
|
||||
expect(toolDefinition.description).toContain('jsoncanvas');
|
||||
// Guidance to use the op for structured blocks, appendToNote for creation.
|
||||
expect(prompt).toContain('replaceFencedBlock with the full new content');
|
||||
expect(prompt).toContain('use appendToNote');
|
||||
expect(toolDefinition.description).toContain('"text" is the new content inside the fence (no fence markers)');
|
||||
expect(toolDefinition.description).toContain('Use appendToNote');
|
||||
});
|
||||
|
||||
test('applyAnchorEdits refuses replaceRange anchor covering most of the body', () => {
|
||||
@@ -286,40 +223,69 @@ describe('noteChat', () => {
|
||||
expect(appliedEdits[0].status).toBe('invalid');
|
||||
});
|
||||
|
||||
test('tryParseReply ignores primitive top-level values', () => {
|
||||
// Bare string parses as JSON5 but isn't a reply envelope.
|
||||
expect(_internal.tryParseReply('"hello"').reply).toBe('"hello"');
|
||||
expect(_internal.tryParseReply('"hello"').edits.length).toBe(0);
|
||||
expect(_internal.tryParseReply('null').reply).toBe('null');
|
||||
expect(_internal.tryParseReply('[1,2,3]').reply).toBe('[1,2,3]');
|
||||
test('assertWithinTokenBudget should include tool calls in the token budget', async () => {
|
||||
_internal.assertWithinTokenBudget([
|
||||
{ role: ChatRole.Assistant, content: 'Testing... '.repeat(5) },
|
||||
], 100);
|
||||
|
||||
await expectThrow(async () => {
|
||||
_internal.assertWithinTokenBudget([
|
||||
{
|
||||
role: ChatRole.Assistant,
|
||||
content: 'Testing...'.repeat(5),
|
||||
toolCalls: [{
|
||||
toolName: 'replaceSelection',
|
||||
callId: 'call-1',
|
||||
arguments: { text: 'Testing... '.repeat(50) },
|
||||
}],
|
||||
},
|
||||
], 100);
|
||||
}, 'aiNoteTooLarge');
|
||||
});
|
||||
|
||||
test('enforceSelectionScope strips anchor ops when selection present, passes through otherwise', () => {
|
||||
const reply = {
|
||||
reply: 'ok',
|
||||
edits: [
|
||||
{ op: 'replaceSelection' as const, text: 'A' },
|
||||
{ op: 'appendToNote' as const, text: 'B' },
|
||||
{ op: 'insertBefore' as const, anchor: 'x', text: 'C' },
|
||||
{ op: 'replaceRange' as const, anchor: 'y', text: 'D' },
|
||||
],
|
||||
test('runTools should describe successful edit operations', async () => {
|
||||
const toolCalls: ChatToolCall[] = [
|
||||
{ toolName: 'appendToNote', callId: 'call-1', arguments: { text: 'Test.' } },
|
||||
{ toolName: 'replaceRange', callId: 'call-2', arguments: { anchor: 'Body', text: 'Updated' } },
|
||||
];
|
||||
let body = 'Body';
|
||||
const initialContext: NoteContext = {
|
||||
title: 'Test',
|
||||
body,
|
||||
selection: null,
|
||||
};
|
||||
// With selection: only replaceSelection survives.
|
||||
const scoped = _internal.enforceSelectionScope(reply, 'some selection');
|
||||
expect(scoped.edits.length).toBe(1);
|
||||
expect(scoped.edits[0].op).toBe('replaceSelection');
|
||||
// Without selection: everything passes through unchanged.
|
||||
const unscoped = _internal.enforceSelectionScope(reply, null);
|
||||
expect(unscoped.edits.length).toBe(4);
|
||||
});
|
||||
|
||||
test('tryParseReply drops malformed edits but keeps valid ones', () => {
|
||||
// Mixed array: missing op, unknown op, primitive, and one valid entry.
|
||||
const text = '{"reply":"ok","edits":["lol",{"op":"bogus"},{"op":"appendToNote","text":"good"},{}]}';
|
||||
const parsed = _internal.tryParseReply(text);
|
||||
expect(parsed.reply).toBe('ok');
|
||||
expect(parsed.edits.length).toBe(1);
|
||||
expect(parsed.edits[0].op).toBe('appendToNote');
|
||||
});
|
||||
const result = await _internal.runTools(
|
||||
{ text: 'Test...', toolCalls, usage: { inputTokens: 10, outputTokens: 300 } },
|
||||
initialContext,
|
||||
() => Promise.resolve({ ...initialContext, body }),
|
||||
{
|
||||
replaceSelection: jest.fn(),
|
||||
updateNoteBody: (newBody) => {
|
||||
body = newBody;
|
||||
return Promise.resolve();
|
||||
},
|
||||
displayError: jest.fn(),
|
||||
},
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(body).toBe('Updated\n\nTest.');
|
||||
expect(result).toMatchObject([
|
||||
{
|
||||
role: ChatRole.Tool,
|
||||
toolName: 'appendToNote',
|
||||
toolCallId: 'call-1',
|
||||
isError: false,
|
||||
userDescription: 'Added 1 word',
|
||||
},
|
||||
{
|
||||
role: ChatRole.Tool,
|
||||
toolName: 'replaceRange',
|
||||
toolCallId: 'call-2',
|
||||
isError: false,
|
||||
userDescription: 'Removed 1 word\nAdded 1 word',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import AiService from './AiService';
|
||||
import { ChatMessage } from './types';
|
||||
import { ChatMessage, ChatResult, ChatRole, ChatToolCall, ToolSpec } from './types';
|
||||
import JoplinError from '../../JoplinError';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import JSON5 from 'json5';
|
||||
import findFencedBlock from './utils/findFencedBlock';
|
||||
import { applyAnchorEdits, supportedStructuredBlockTags } from './applyNoteEdits';
|
||||
import { hasOwnProperty } from '@joplin/utils/object';
|
||||
const Countable = require('../../countable/Countable');
|
||||
import { _, _n } from '../../locale';
|
||||
|
||||
const logger = Logger.create('noteChat');
|
||||
|
||||
@@ -19,22 +22,13 @@ export type EditOp =
|
||||
| { op: 'insertAfter'; anchor: string; text: string }
|
||||
| { op: 'appendToNote'; text: string }
|
||||
| { op: 'replaceRange'; anchor: string; text: string }
|
||||
| { op: 'replaceFencedBlock'; tag: string; text: string };
|
||||
| { op: 'replaceFencedBlock'; tag: string; text: string }
|
||||
;
|
||||
|
||||
const knownOps = new Set<EditOp['op']>([
|
||||
'replaceSelection', 'insertBefore', 'insertAfter', 'appendToNote', 'replaceRange', 'replaceFencedBlock',
|
||||
]);
|
||||
|
||||
// Structured-document fences where the model regenerates the whole block —
|
||||
// plain code fences (```js, ```python) are deliberately excluded.
|
||||
export const supportedStructuredBlockTags = ['jsoncanvas', 'mermaid', 'abc', 'fountain'];
|
||||
|
||||
export interface ChatTurn {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
edits?: EditOp[];
|
||||
}
|
||||
|
||||
export interface NoteContext {
|
||||
title: string;
|
||||
body: string;
|
||||
@@ -81,49 +75,23 @@ const systemPrompt = (note: NoteContext) => {
|
||||
lines.push('--- BEGIN SELECTION ---');
|
||||
lines.push(note.selection);
|
||||
lines.push('--- END SELECTION ---');
|
||||
lines.push();
|
||||
} else {
|
||||
lines.push('Note body:');
|
||||
lines.push('--- BEGIN NOTE ---');
|
||||
lines.push(note.body);
|
||||
lines.push('--- END NOTE ---');
|
||||
lines.push();
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Reply with a single JSON object and nothing else. The object must have this shape:');
|
||||
lines.push('{');
|
||||
lines.push(' "reply": "A short message to show the user in the chat.",');
|
||||
lines.push(' "edits": []');
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
lines.push('"edits" is an array of operations to apply to the note. Leave it empty for chat-only answers (e.g. questions about the note).');
|
||||
|
||||
if (note.selection) {
|
||||
// Anchor ops would target text the model can't see in this mode,
|
||||
// inviting hallucinated anchors that mutate outside the selection.
|
||||
lines.push('Each edit must be:');
|
||||
lines.push(' { "op": "replaceSelection", "text": "..." } — replaces the selected text with "text".');
|
||||
lines.push('');
|
||||
lines.push('Do not use any other operation. The selection is the only part of the note you can modify in this turn.');
|
||||
lines.push('The selection is the only part of the note you can modify in this turn.');
|
||||
} else {
|
||||
const hasFencedBlock = hasStructuredBlock(note);
|
||||
lines.push('Each edit must be one of:');
|
||||
lines.push(' { "op": "insertBefore", "anchor": "...", "text": "..." } — inserts text immediately before the first occurrence of "anchor".');
|
||||
lines.push(' { "op": "insertAfter", "anchor": "...", "text": "..." } — inserts text immediately after the first occurrence of "anchor".');
|
||||
lines.push(' { "op": "appendToNote", "text": "..." } — appends text at the end of the note.');
|
||||
lines.push(' { "op": "replaceRange", "anchor": "...", "text": "..." } — replaces the first occurrence of "anchor" with "text".');
|
||||
if (hasFencedBlock) {
|
||||
lines.push(` { "op": "replaceFencedBlock", "tag": "...", "text": "..." } — replaces the inner content of the first \`\`\`<tag>\`\`\` fenced block. "text" is the new content inside the fence (no fence markers). Supported tags: ${supportedStructuredBlockTags.join(', ')}.`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Anchors must be exact substrings of the current note body. Keep them short but unique.');
|
||||
lines.push('');
|
||||
|
||||
const addNewFencedBlockInstructions = 'use appendToNote with the complete fenced block including the ```<tag> markers';
|
||||
if (hasFencedBlock) {
|
||||
lines.push(`To edit a structured block already in the note (${supportedStructuredBlockTags.join(' / ')}), use replaceFencedBlock with the full new content — do not try to anchor inside the block's contents. To create one that doesn't exist yet, ${addNewFencedBlockInstructions}.`);
|
||||
} else {
|
||||
lines.push(`To add a structured block to the note (${supportedStructuredBlockTags.join(' / ')}), ${addNewFencedBlockInstructions}.`);
|
||||
}
|
||||
lines.push(
|
||||
'Tool guidance:',
|
||||
'- You have access to various tools that allow updating the note. For example, if you need to add the text "test" to the end of the note, do this using the "appendToNote" tool.',
|
||||
'- Some tools call for anchors. Anchors must be exact substrings of the current note body. Keep them short but unique.',
|
||||
);
|
||||
}
|
||||
|
||||
lines.push('Preserve the user\'s existing formatting conventions, including any Joplin-specific blocks already in the note.');
|
||||
@@ -131,148 +99,379 @@ const systemPrompt = (note: NoteContext) => {
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
const responseSchema = (note: NoteContext) => {
|
||||
const editOperationSchema = note.selection ? {
|
||||
type: 'object',
|
||||
properties: {
|
||||
op: {
|
||||
type: 'string',
|
||||
enum: ['replaceSelection'],
|
||||
},
|
||||
text: { type: 'string' },
|
||||
},
|
||||
required: ['op', 'text'],
|
||||
additionalProperties: false,
|
||||
} : {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
op: { type: 'string', enum: ['insertBefore', 'insertAfter', 'replaceRange'] },
|
||||
anchor: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
},
|
||||
required: ['op', 'anchor', 'text'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
op: { type: 'string', enum: ['appendToNote'] },
|
||||
text: { type: 'string' },
|
||||
},
|
||||
required: ['op', 'text'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
...(hasStructuredBlock(note) ? [{
|
||||
type: 'object',
|
||||
properties: {
|
||||
op: { type: 'string', enum: ['replaceFencedBlock'] },
|
||||
tag: { type: 'string', enum: [...supportedStructuredBlockTags] },
|
||||
text: { type: 'string' },
|
||||
},
|
||||
required: ['op', 'tag', 'text'],
|
||||
additionalProperties: false,
|
||||
}] : []),
|
||||
],
|
||||
};
|
||||
const toolDefinitions = (note: NoteContext) => {
|
||||
const result: ToolSpec[] = [];
|
||||
|
||||
const schema = {
|
||||
name: 'NoteChatResponse',
|
||||
strict: true,
|
||||
schema: {
|
||||
if (note.selection) {
|
||||
result.push({
|
||||
name: 'replaceSelection',
|
||||
description: 'Replaces the text currently selected by the user.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string' },
|
||||
},
|
||||
required: ['text'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
result.push(
|
||||
{
|
||||
name: 'appendToNote',
|
||||
description: 'Adds text to the end of the note. Text is always added to the end of the note. This tool does not require an anchor.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string' },
|
||||
},
|
||||
required: ['text'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const anchoredSchema = (anchorDescription: string, textDescription: string) => ({
|
||||
type: 'object',
|
||||
properties: {
|
||||
reply: { type: 'string' },
|
||||
edits: {
|
||||
type: 'array',
|
||||
items: editOperationSchema,
|
||||
},
|
||||
anchor: { type: 'string', description: anchorDescription },
|
||||
text: { type: 'string', description: textDescription },
|
||||
},
|
||||
required: ['reply', 'edits'],
|
||||
required: ['anchor', 'text'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
result.push(
|
||||
{
|
||||
name: 'insertBefore',
|
||||
description: 'Insert text immediately before the first occurrence of `anchor`.',
|
||||
inputSchema: anchoredSchema(
|
||||
'Text to find',
|
||||
'What to insert just **before** the found text',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'insertAfter',
|
||||
description: 'Insert text immediately after the first occurrence of `anchor`.',
|
||||
inputSchema: anchoredSchema(
|
||||
'Text to find',
|
||||
'What to insert just **after** the found text',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'replaceRange',
|
||||
description: 'Replace the first occurrence of `anchor` with `text`.',
|
||||
inputSchema: anchoredSchema(
|
||||
'The text to replace',
|
||||
'What to replace it with',
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'json_schema' as const,
|
||||
json_schema: schema,
|
||||
};
|
||||
const hasFencedBlock = hasStructuredBlock(note);
|
||||
if (hasFencedBlock) {
|
||||
result.push({
|
||||
name: 'replaceFencedBlock',
|
||||
description: [
|
||||
'Replaces the inner content of the first ```<tag>``` fenced block.',
|
||||
`"text" is the new content inside the fence (no fence markers). Supported tags: ${supportedStructuredBlockTags.join(', ')}.`,
|
||||
'Use appendToNote to create a new fenced block.',
|
||||
].join(' '),
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tag: {
|
||||
type: 'string',
|
||||
enum: supportedStructuredBlockTags,
|
||||
},
|
||||
text: {
|
||||
type: 'string',
|
||||
description: 'The new content for the block content. Should not include the fenced block delimiters.',
|
||||
},
|
||||
},
|
||||
required: ['tag', 'text'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const estimateTokens = (text: string) => Math.ceil(text.length / charsPerToken);
|
||||
|
||||
// First-pass filter. Per-op field validation lives in applyNoteEdits.
|
||||
const sanitizeEdits = (raw: unknown): EditOp[] => {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: EditOp[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const op = (item as { op?: unknown }).op;
|
||||
if (typeof op !== 'string' || !knownOps.has(op as EditOp['op'])) continue;
|
||||
out.push(item as EditOp);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
interface Commands {
|
||||
replaceSelection: (text: string, originalText: string)=> Promise<void>;
|
||||
updateNoteBody: (body: string, originalBody: string)=> Promise<void>;
|
||||
displayError: (message: string)=> void;
|
||||
}
|
||||
|
||||
const tryParseReply = (text: string): ChatReply => {
|
||||
const trimmed = text.trim();
|
||||
|
||||
// Some models wrap JSON in ```json ... ``` fences despite instructions.
|
||||
const stripped = trimmed
|
||||
.replace(/^```(?:json)?\s*/i, '')
|
||||
.replace(/\s*```$/, '')
|
||||
.trim();
|
||||
|
||||
// JSON5 absorbs the trailing commas / unquoted keys / single quotes /
|
||||
// comments that models emit despite strict-JSON instructions.
|
||||
try {
|
||||
const parsed = JSON5.parse(stripped);
|
||||
// Primitives parse but aren't reply envelopes.
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return { reply: text, edits: [] };
|
||||
}
|
||||
const reply = typeof parsed.reply === 'string' ? parsed.reply : '';
|
||||
const edits = sanitizeEdits(parsed.edits);
|
||||
return { reply, edits };
|
||||
} catch {
|
||||
logger.warn('Failed to parse structured reply; falling back to raw text. Raw:', text);
|
||||
return { reply: text, edits: [] };
|
||||
}
|
||||
};
|
||||
type OnContext = ()=> Promise<NoteContext>;
|
||||
type OnHistoryChanged = (history: ChatMessage[])=> void;
|
||||
|
||||
export const runNoteChat = async (
|
||||
note: NoteContext,
|
||||
history: ChatTurn[],
|
||||
context: OnContext,
|
||||
history: ChatMessage[],
|
||||
userMessage: string,
|
||||
): Promise<ChatReply> => {
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: 'system', content: systemPrompt(note) },
|
||||
...history.map<ChatMessage>(t => ({ role: t.role, content: t.content })),
|
||||
{ role: 'user', content: userMessage },
|
||||
commands: Commands,
|
||||
onHistoryChanged: OnHistoryChanged,
|
||||
signal: AbortSignal,
|
||||
) => {
|
||||
history = [
|
||||
// Ensure that the system prompt is up-to-date with the context.
|
||||
// TODO: This will invalidate the prompt cache.
|
||||
// See https://developers.openai.com/api/docs/guides/prompt-caching
|
||||
{ role: ChatRole.System, content: systemPrompt(await context()) },
|
||||
...removeSystemPrompt(history),
|
||||
{ role: ChatRole.User, content: userMessage },
|
||||
];
|
||||
onHistoryChanged([...history]);
|
||||
|
||||
const hasUndeliveredToolResults = () => history[history.length - 1]?.role === ChatRole.Tool;
|
||||
do {
|
||||
history = await stepNoteChat(
|
||||
context,
|
||||
history,
|
||||
commands,
|
||||
onHistoryChanged,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
while (!signal.aborted && hasUndeliveredToolResults());
|
||||
|
||||
return history;
|
||||
};
|
||||
|
||||
const stepNoteChat = async (
|
||||
context: OnContext,
|
||||
messages: ChatMessage[],
|
||||
commands: Commands,
|
||||
onHistoryChanged: OnHistoryChanged,
|
||||
signal: AbortSignal,
|
||||
) => {
|
||||
const note = await context();
|
||||
|
||||
assertWithinTokenBudget(messages, noteBodyTokenBudget);
|
||||
const chatResult = await AiService.instance().chat(
|
||||
messages, { tools: toolDefinitions(note), signal },
|
||||
);
|
||||
|
||||
// Chat results can contain sensitive information -- only log in dev mode
|
||||
logger.debug('Received chat result', chatResult);
|
||||
messages.push({
|
||||
role: ChatRole.Assistant,
|
||||
content: chatResult.text ?? '',
|
||||
toolCalls: chatResult.toolCalls,
|
||||
});
|
||||
onHistoryChanged([...messages]);
|
||||
|
||||
messages.push(
|
||||
...await runTools(chatResult, note, context, commands, signal),
|
||||
);
|
||||
onHistoryChanged([...messages]);
|
||||
|
||||
return messages;
|
||||
};
|
||||
|
||||
const removeSystemPrompt = (history: ChatMessage[]) => {
|
||||
return history.filter(message => message.role !== 'system');
|
||||
};
|
||||
|
||||
const assertWithinTokenBudget = (history: ChatMessage[], budget: number) => {
|
||||
const estimateToolCallTokens = (toolCalls: ChatToolCall[]) => {
|
||||
let sum = 0;
|
||||
for (const toolCall of toolCalls) {
|
||||
sum += estimateTokens(JSON.stringify(toolCall.arguments));
|
||||
}
|
||||
return sum;
|
||||
};
|
||||
|
||||
// Budget the full payload — sticky history grows turn-by-turn and would
|
||||
// eventually blow the context window even if no single note is too big.
|
||||
const totalTokens = messages.reduce((sum, m) => sum + estimateTokens(m.content), 0);
|
||||
if (totalTokens > noteBodyTokenBudget) {
|
||||
let totalTokens = 0;
|
||||
for (const message of history) {
|
||||
totalTokens += estimateTokens(message.content);
|
||||
if (message.role === ChatRole.Assistant) {
|
||||
totalTokens += estimateToolCallTokens(message.toolCalls ?? []);
|
||||
}
|
||||
if (message.role === ChatRole.Tool) {
|
||||
totalTokens += estimateTokens(message.content);
|
||||
}
|
||||
}
|
||||
|
||||
if (totalTokens > budget) {
|
||||
throw new JoplinError(
|
||||
'This conversation has grown too large to send. Reset the chat, or select the part of the note you want to ask about.',
|
||||
'aiNoteTooLarge',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await AiService.instance().chat(messages, { responseFormat: responseSchema(note) });
|
||||
return enforceSelectionScope(tryParseReply(result.text), note.selection);
|
||||
};
|
||||
|
||||
// Defence-in-depth: the prompt already tells the model to only use
|
||||
// replaceSelection in this mode, but a non-compliant reply could still slip
|
||||
// anchor ops through and mutate text outside the selection.
|
||||
const enforceSelectionScope = (reply: ChatReply, selection: string | null): ChatReply => {
|
||||
if (!selection) return reply;
|
||||
return { reply: reply.reply, edits: reply.edits.filter(e => e.op === 'replaceSelection') };
|
||||
const assertNotCancelled = (signal: AbortSignal) => {
|
||||
if (signal.aborted) {
|
||||
throw new JoplinError(
|
||||
'Cancelled.',
|
||||
'aiChatCancelled',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const runTools = async (chat: ChatResult, initialContext: NoteContext, context: OnContext, commands: Commands, signal: AbortSignal) => {
|
||||
assertNotCancelled(signal);
|
||||
|
||||
let chatResponses: ChatMessage[] = [];
|
||||
|
||||
const respondSuccess = (action: ChatToolCall, message: string) => {
|
||||
chatResponses.push({
|
||||
role: ChatRole.Tool,
|
||||
toolName: action.toolName,
|
||||
toolCallId: action.callId,
|
||||
content: message,
|
||||
userDescription: message,
|
||||
isError: false,
|
||||
});
|
||||
};
|
||||
|
||||
const respondFailure = (action: ChatToolCall, reason: string) => {
|
||||
chatResponses.push({
|
||||
role: ChatRole.Tool,
|
||||
toolName: action.toolName,
|
||||
toolCallId: action.callId,
|
||||
content: `failed: ${reason}`,
|
||||
userDescription: reason,
|
||||
isError: true,
|
||||
});
|
||||
};
|
||||
|
||||
// Only replaceSelection operations allowed when there's a selection
|
||||
const currentContext = await context();
|
||||
if (currentContext.selection) {
|
||||
const expectedName = 'replaceSelection';
|
||||
const action = chat.toolCalls.find(toolCall => toolCall.toolName === expectedName);
|
||||
if (action) {
|
||||
try {
|
||||
if (!hasOwnProperty(action.arguments, 'text')) throw new JoplinError('Missing text property');
|
||||
if (typeof action.arguments.text !== 'string') throw new JoplinError('Property "text" must be a string');
|
||||
|
||||
await commands.replaceSelection(action.arguments.text, currentContext.selection);
|
||||
respondSuccess(action, describeEditOperation(toolCallToEditOperation(action)));
|
||||
} catch (error) {
|
||||
logger.error('Failed to replace selection', error);
|
||||
respondFailure(action, 'failed to replace selection');
|
||||
commands.displayError(error.message ?? String(error));
|
||||
}
|
||||
}
|
||||
|
||||
// Only one tool call is allowed in this context
|
||||
for (const toolCall of chat.toolCalls) {
|
||||
if (action === toolCall) continue;
|
||||
if (toolCall.toolName === expectedName) {
|
||||
respondFailure(toolCall, `Only a single ${expectedName} tool call is valid in this context`);
|
||||
} else {
|
||||
respondFailure(toolCall, `Only the ${expectedName} tool is valid in this context.`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const initialBody = initialContext.body;
|
||||
let body = currentContext.body;
|
||||
|
||||
if (initialBody !== body && chat.toolCalls.length > 0) {
|
||||
for (const toolCall of chat.toolCalls) {
|
||||
respondFailure(toolCall, 'body changed externally, before edit could be applied');
|
||||
}
|
||||
commands.displayError(_('The note changed while the request was running; edits were not applied. Try again.'));
|
||||
|
||||
return chatResponses;
|
||||
}
|
||||
|
||||
for (const toolCall of chat.toolCalls) {
|
||||
if (toolCall.toolName === 'replaceSelection') {
|
||||
respondFailure(toolCall, 'replaceSelection is invalid in this context');
|
||||
continue;
|
||||
}
|
||||
if (!isValidEditOp(toolCall.toolName)) {
|
||||
respondFailure(toolCall, 'tool not found');
|
||||
continue;
|
||||
}
|
||||
|
||||
const editOperation = toolCallToEditOperation(toolCall);
|
||||
const { newBody, appliedEdits } = applyAnchorEdits(body, [editOperation], 0);
|
||||
body = newBody;
|
||||
|
||||
if (appliedEdits.length !== 1) {
|
||||
throw new Error(`Invalid state: Wrong number of applied edits, ${appliedEdits.length}`);
|
||||
}
|
||||
|
||||
const edit = appliedEdits[0];
|
||||
if (edit.status === 'applied') {
|
||||
respondSuccess(toolCall, describeEditOperation(editOperation));
|
||||
} else {
|
||||
respondFailure(toolCall, edit.status);
|
||||
}
|
||||
}
|
||||
|
||||
if (body !== initialBody) {
|
||||
try {
|
||||
await commands.updateNoteBody(body, initialBody);
|
||||
} catch (error) {
|
||||
logger.error('Failed to update body', error);
|
||||
|
||||
// Replace any 'success' messages with failures, since all tools
|
||||
// currently depend on updating the body.
|
||||
chatResponses = [];
|
||||
for (const toolCall of chat.toolCalls) {
|
||||
respondFailure(toolCall, 'failed to update body');
|
||||
}
|
||||
|
||||
commands.displayError(error.message ?? String(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chatResponses;
|
||||
};
|
||||
|
||||
const isValidEditOp = (operation: string): operation is EditOp['op'] => {
|
||||
return knownOps.has(operation as EditOp['op']);
|
||||
};
|
||||
|
||||
const toolCallToEditOperation = (toolCall: ChatToolCall): EditOp => {
|
||||
const op = toolCall.toolName;
|
||||
if (!isValidEditOp(op)) {
|
||||
throw new Error(`Invalid edit operation: ${op}`);
|
||||
}
|
||||
|
||||
const args = toolCall.arguments;
|
||||
return {
|
||||
op,
|
||||
...('text' in args && typeof args.text === 'string' ? { text: args.text } : {}),
|
||||
...('anchor' in args && typeof args.anchor === 'string' ? { anchor: args.anchor } : {}),
|
||||
...('tag' in args && typeof args.tag === 'string' ? { tag: args.tag } : {}),
|
||||
} as EditOp;
|
||||
};
|
||||
|
||||
const countWords = (text: string): number => Countable.countOnce(text, { stripTags: true }).words;
|
||||
|
||||
const describeEditOperation = (editOp: EditOp) => {
|
||||
const describeWordsAdded = (count: number) => _n('Added %d word', 'Added %d words', count, count);
|
||||
const describeWordsRemoved = (count: number) => _n('Removed %d word', 'Removed %d words', count, count);
|
||||
|
||||
let actionDescription;
|
||||
if (editOp.op === 'replaceRange') {
|
||||
actionDescription = [
|
||||
describeWordsRemoved(countWords(editOp.anchor)),
|
||||
describeWordsAdded(countWords(editOp.text)),
|
||||
].join('\n');
|
||||
} else if (editOp.op === 'replaceFencedBlock') {
|
||||
actionDescription = _('Updated %s block', editOp.tag);
|
||||
} else if (editOp.op === 'replaceSelection') {
|
||||
actionDescription = _('Replaced selection');
|
||||
} else {
|
||||
actionDescription = describeWordsAdded(countWords(editOp.text));
|
||||
}
|
||||
return actionDescription;
|
||||
};
|
||||
|
||||
// Exported for tests.
|
||||
export const _internal = { systemPrompt, responseSchema, tryParseReply, estimateTokens, sanitizeEdits, enforceSelectionScope, noteBodyTokenBudget };
|
||||
export const _internal = { systemPrompt, toolDefinitions, assertWithinTokenBudget, estimateTokens, runTools, noteBodyTokenBudget };
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ChatRole } from '../types';
|
||||
import { _internal } from './Anthropic';
|
||||
|
||||
describe('ai/providers/Anthropic', () => {
|
||||
it('should make tool calls compatible with the Anthropic API', () => {
|
||||
const { turnMessages } = _internal.convertMessages([
|
||||
{ role: ChatRole.User, content: 'Do something' },
|
||||
{
|
||||
role: ChatRole.Assistant,
|
||||
content: 'okay',
|
||||
toolCalls: [
|
||||
{ toolName: 'replaceRange', callId: 'call-1', arguments: { text: 'test', anchor: 'original' } },
|
||||
{ toolName: 'replaceRange', callId: 'call-2', arguments: { text: 'test-2', anchor: 'other' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: ChatRole.Tool,
|
||||
content: 'success',
|
||||
userDescription: 'replaced',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'replaceRange',
|
||||
isError: false,
|
||||
},
|
||||
{
|
||||
role: ChatRole.Tool,
|
||||
userDescription: 'failed',
|
||||
content: 'anchor-not-found',
|
||||
toolCallId: 'call-2',
|
||||
toolName: 'replaceRange',
|
||||
isError: true,
|
||||
},
|
||||
{
|
||||
role: ChatRole.User,
|
||||
content: 'testing...',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(turnMessages).toMatchObject([
|
||||
{ role: 'user', content: 'Do something' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'okay' },
|
||||
{ type: 'tool_use', name: 'replaceRange', id: 'call-1', input: { text: 'test', anchor: 'original' } },
|
||||
{ type: 'tool_use', name: 'replaceRange', id: 'call-2', input: { text: 'test-2', anchor: 'other' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'call-1', content: 'success' },
|
||||
{ type: 'tool_result', tool_use_id: 'call-2', content: 'anchor-not-found' },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: 'testing...',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import shim from '../../../shim';
|
||||
import JoplinError from '../../../JoplinError';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { ChatMessage, ChatOptions, ChatResult, ProviderClassification } from '../types';
|
||||
import { ChatMessage, ChatOptions, ChatResult, ChatRole, ChatToolCall, ProviderClassification } from '../types';
|
||||
import ChatProviderBase from './ChatProviderBase';
|
||||
|
||||
const logger = Logger.create('AnthropicProvider');
|
||||
@@ -11,11 +11,26 @@ interface AnthropicUsage {
|
||||
output_tokens?: number;
|
||||
}
|
||||
|
||||
interface AnthropicContentBlock {
|
||||
type?: string;
|
||||
text?: string;
|
||||
interface AnthropicTextContentBlock {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface AnthropicToolUseContentBlock {
|
||||
type: 'tool_use';
|
||||
id: string;
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface AnthropicToolResultContentBlock {
|
||||
type: 'tool_result';
|
||||
tool_use_id: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
type AnthropicContentBlock = AnthropicToolUseContentBlock|AnthropicToolResultContentBlock|AnthropicTextContentBlock;
|
||||
|
||||
interface AnthropicResponse {
|
||||
content?: AnthropicContentBlock[];
|
||||
usage?: AnthropicUsage;
|
||||
@@ -27,6 +42,96 @@ interface Options {
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface AnthropicMessage {
|
||||
role: 'user'|'assistant';
|
||||
content: string|AnthropicContentBlock[];
|
||||
}
|
||||
|
||||
// Anthropic requires tool results to come immediately after their calls, with parallel tool calls in the same block. This block merges adjacent tool calls.
|
||||
const fixToolResults = (messages: AnthropicMessage[]) => {
|
||||
const result: (AnthropicMessage|null)[] = [...messages];
|
||||
|
||||
const getPrevious = (index: number) => {
|
||||
for (let i = index - 1; i >= 0; i--) {
|
||||
if (!result[i]) continue;
|
||||
return result[i];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
let previousHadToolResults = false;
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const previous = getPrevious(i);
|
||||
const message = result[i];
|
||||
if (!message) continue;
|
||||
|
||||
let hasToolResults = false;
|
||||
if (Array.isArray(message.content) && previous) {
|
||||
const toolResults = message.content.filter(item => item.type === 'tool_result');
|
||||
hasToolResults ||= toolResults.length > 0;
|
||||
if (hasToolResults && previousHadToolResults) {
|
||||
if (!Array.isArray(previous.content)) throw new Error('Invalid state: Expected previous.content to be an array');
|
||||
previous.content = [...previous.content, ...toolResults];
|
||||
message.content = message.content.filter(item => item.type !== 'tool_result');
|
||||
|
||||
// Avoid empty user messages
|
||||
if (message.content.length === 0) {
|
||||
result[i] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previousHadToolResults = hasToolResults;
|
||||
}
|
||||
|
||||
return result.filter(item => !!item);
|
||||
};
|
||||
|
||||
|
||||
const convertMessages = (messages: ChatMessage[]) => {
|
||||
const convertToolCall = (toolCall: ChatToolCall) => {
|
||||
return {
|
||||
type: 'tool_use' as const,
|
||||
name: toolCall.toolName,
|
||||
id: toolCall.callId,
|
||||
input: toolCall.arguments,
|
||||
};
|
||||
};
|
||||
|
||||
const result = messages
|
||||
.map((message): AnthropicMessage => {
|
||||
if (message.role === ChatRole.System) return null;
|
||||
if (message.role === ChatRole.Tool) {
|
||||
return {
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: message.toolCallId,
|
||||
content: message.content,
|
||||
...(message.isError ? { is_error: true } : {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
} else if (message.role === ChatRole.Assistant || message.role === ChatRole.User) {
|
||||
return {
|
||||
role: message.role,
|
||||
content: message.toolCalls ? [
|
||||
message.content ? { type: 'text' as const, text: message.content } : null,
|
||||
...message.toolCalls.map(convertToolCall),
|
||||
].filter(item => !!item) : message.content,
|
||||
};
|
||||
}
|
||||
const exhaustivenessCheck: never = message.role;
|
||||
throw new Error(`Unsupported role ${exhaustivenessCheck}`);
|
||||
})
|
||||
.filter(m => !!m);
|
||||
|
||||
const systemMessages = messages.filter(m => m.role === 'system').map(m => m.content);
|
||||
const turnMessages = fixToolResults(result);
|
||||
return { systemMessages, turnMessages };
|
||||
};
|
||||
|
||||
// Anthropic requires max_tokens. We pick a sensible default if the caller
|
||||
// doesn't supply one, so plugins don't have to remember a provider-specific
|
||||
// requirement.
|
||||
@@ -50,8 +155,7 @@ export default class AnthropicProvider extends ChatProviderBase {
|
||||
if (!this.model_) throw new JoplinError('Anthropic provider has no model configured', 'aiProviderNotConfigured');
|
||||
|
||||
// Anthropic's API separates system prompts from the messages array.
|
||||
const systemMessages = messages.filter(m => m.role === 'system').map(m => m.content);
|
||||
const turnMessages = messages.filter(m => m.role !== 'system').map(m => ({ role: m.role, content: m.content }));
|
||||
const { systemMessages, turnMessages } = convertMessages(messages);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: this.model_,
|
||||
@@ -70,6 +174,15 @@ export default class AnthropicProvider extends ChatProviderBase {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (options?.tools) {
|
||||
body.tools = options.tools.map(tool => {
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema: tool.inputSchema,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const doRequest = async () => {
|
||||
const response = await shim.fetch('https://api.anthropic.com/v1/messages', {
|
||||
@@ -80,6 +193,7 @@ export default class AnthropicProvider extends ChatProviderBase {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: options.signal,
|
||||
});
|
||||
return { response, text: await response.text() };
|
||||
};
|
||||
@@ -104,14 +218,27 @@ export default class AnthropicProvider extends ChatProviderBase {
|
||||
throw new JoplinError(`Anthropic returned ${response.status}${detail}`, response.status);
|
||||
}
|
||||
|
||||
const content = (json.content ?? [])
|
||||
.filter(b => b.type === 'text' && typeof b.text === 'string')
|
||||
.map(b => b.text)
|
||||
.join('');
|
||||
|
||||
const inputTokens = json.usage?.input_tokens ?? 0;
|
||||
const outputTokens = json.usage?.output_tokens ?? 0;
|
||||
|
||||
return { text: content, usage: { inputTokens, outputTokens } };
|
||||
const toolCalls: ChatToolCall[] = [];
|
||||
const textMessages = [];
|
||||
for (const response of json.content) {
|
||||
if (response.type === 'tool_use' && typeof response.input === 'object') {
|
||||
toolCalls.push({
|
||||
callId: response.id,
|
||||
toolName: response.name,
|
||||
arguments: response.input,
|
||||
});
|
||||
} else if (response.type === 'text' && typeof response.text === 'string') {
|
||||
textMessages.push(response.text);
|
||||
}
|
||||
}
|
||||
|
||||
return { text: textMessages.join(''), toolCalls, usage: { inputTokens, outputTokens } };
|
||||
}
|
||||
}
|
||||
|
||||
// For testing
|
||||
export const _internal = { convertMessages };
|
||||
@@ -2,8 +2,9 @@ import Setting from '../../../models/Setting';
|
||||
import JoplinError from '../../../JoplinError';
|
||||
import JoplinServerApi, { Session } from '../../../JoplinServerApi';
|
||||
import SyncTargetRegistry from '../../../SyncTargetRegistry';
|
||||
import { ChatMessage, ChatOptions, ChatResult, ProviderClassification } from '../types';
|
||||
import ChatProviderBase from './ChatProviderBase';
|
||||
import { ProviderClassification } from '../types';
|
||||
import OpenAiCompatibleProvider, { ChatRequestOptions } from './OpenAiCompatible';
|
||||
import { msleep, Second } from '@joplin/utils/time';
|
||||
|
||||
const joplinCloudSyncTarget = () => SyncTargetRegistry.nameToId('joplinCloud');
|
||||
|
||||
@@ -33,10 +34,24 @@ const mapErrorByStatus = (status: number, detail: string): JoplinError => {
|
||||
return new JoplinError(`Joplin Cloud AI returned ${status}${detail ? `: ${detail}` : ''}`, status);
|
||||
};
|
||||
|
||||
export default class JoplinCloudProvider extends ChatProviderBase {
|
||||
// If too many events are received in a short window, Joplin Cloud starts rejecting events.
|
||||
// For now, avoid more than one event every few seconds:
|
||||
const minimumTimeBetweenEvents = 3 * Second;
|
||||
|
||||
export default class JoplinCloudProvider extends OpenAiCompatibleProvider {
|
||||
|
||||
public id = 'joplin-cloud';
|
||||
public classification: ProviderClassification = 'remote';
|
||||
private lastEventTime_ = 0;
|
||||
|
||||
public constructor() {
|
||||
super({
|
||||
baseUrl: '',
|
||||
model: 'joplin-cloud',
|
||||
classification: 'remote',
|
||||
apiKey: '',
|
||||
});
|
||||
}
|
||||
|
||||
private buildApi(): JoplinServerApi {
|
||||
// Build a fresh API object per call so re-auth survives without drift.
|
||||
@@ -53,36 +68,31 @@ export default class JoplinCloudProvider extends ChatProviderBase {
|
||||
});
|
||||
}
|
||||
|
||||
protected async doChat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResult> {
|
||||
protected override async sendChatRequest(body: Record<string, unknown>, options: ChatRequestOptions) {
|
||||
if (Setting.value('sync.target') !== joplinCloudSyncTarget()) {
|
||||
throw new JoplinError('Joplin Cloud AI requires Joplin Cloud sync', 'aiJoplinCloudSyncRequired');
|
||||
}
|
||||
|
||||
const timeSinceLastEvent = Date.now() - this.lastEventTime_;
|
||||
if (timeSinceLastEvent < minimumTimeBetweenEvents) {
|
||||
await msleep(minimumTimeBetweenEvents - timeSinceLastEvent);
|
||||
}
|
||||
|
||||
const api = this.buildApi();
|
||||
// Body deliberately omits `model` — the Joplin Cloud server picks the
|
||||
// model based on quota / degraded state. Sending one would be ignored.
|
||||
const body: Record<string, unknown> = {
|
||||
messages: messages.map(m => ({ role: m.role, content: m.content })),
|
||||
};
|
||||
if (options?.maxTokens !== undefined) body.max_tokens = options.maxTokens;
|
||||
if (options?.temperature !== undefined) body.temperature = options.temperature;
|
||||
if (options?.responseFormat !== undefined) body.response_format = options.responseFormat;
|
||||
|
||||
// JoplinServerApi.exec() returns the parsed JSON object directly when
|
||||
// the response format is JSON (the default). No need to JSON.parse.
|
||||
let json: JoplinCloudResponse;
|
||||
try {
|
||||
json = await api.exec('POST', 'api/ai/chat/completions', null, body) as JoplinCloudResponse;
|
||||
json = await api.exec('POST', 'api/ai/chat/completions', null, body, null, { signal: options.signal }) as JoplinCloudResponse;
|
||||
} catch (error) {
|
||||
const status = typeof error?.code === 'number' ? error.code : 0;
|
||||
const detail = error?.message ?? '';
|
||||
throw mapErrorByStatus(status, detail);
|
||||
}
|
||||
|
||||
const content = json?.choices?.[0]?.message?.content ?? '';
|
||||
const inputTokens = json?.usage?.prompt_tokens ?? 0;
|
||||
const outputTokens = json?.usage?.completion_tokens ?? 0;
|
||||
this.lastEventTime_ = Date.now();
|
||||
|
||||
return { text: content, usage: { inputTokens, outputTokens } };
|
||||
return { response: { status: 200 }, json };
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import shim from '../../../shim';
|
||||
import JoplinError from '../../../JoplinError';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { rtrimSlashes } from '@joplin/utils/path';
|
||||
import { ChatMessage, ChatOptions, ChatResult, ProviderClassification } from '../types';
|
||||
import { ChatMessage, ChatOptions, ChatResult, ChatToolCall, ProviderClassification, ToolSpec } from '../types';
|
||||
import ChatProviderBase from './ChatProviderBase';
|
||||
|
||||
const logger = Logger.create('OpenAiCompatibleProvider');
|
||||
@@ -12,8 +12,23 @@ interface OpenAiUsage {
|
||||
completion_tokens?: number;
|
||||
}
|
||||
|
||||
interface OpenAiToolCall {
|
||||
id: string;
|
||||
index: number;
|
||||
type: 'function';
|
||||
function: {
|
||||
arguments: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface OpenAiMessage {
|
||||
content?: string;
|
||||
tool_calls?: OpenAiToolCall[];
|
||||
}
|
||||
|
||||
interface OpenAiChoice {
|
||||
message?: { content?: string };
|
||||
message?: OpenAiMessage;
|
||||
}
|
||||
|
||||
interface OpenAiResponse {
|
||||
@@ -29,6 +44,50 @@ interface Options {
|
||||
classification: ProviderClassification;
|
||||
}
|
||||
|
||||
const convertTool = (tool: ToolSpec) => {
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema,
|
||||
strict: true,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const convertMessage = (message: ChatMessage) => {
|
||||
if (message.role === 'tool') {
|
||||
return {
|
||||
role: 'tool',
|
||||
name: message.toolName,
|
||||
content: message.content,
|
||||
tool_call_id: message.toolCallId,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
...(message.toolCalls ? {
|
||||
tool_calls: message.toolCalls.map(call => {
|
||||
return {
|
||||
id: call.callId,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: call.toolName,
|
||||
arguments: JSON.stringify(call.arguments),
|
||||
},
|
||||
};
|
||||
}),
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export interface ChatRequestOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export default class OpenAiCompatibleProvider extends ChatProviderBase {
|
||||
|
||||
public id = 'openai-compatible';
|
||||
@@ -46,36 +105,20 @@ export default class OpenAiCompatibleProvider extends ChatProviderBase {
|
||||
}
|
||||
|
||||
protected async doChat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResult> {
|
||||
if (!this.baseUrl_) throw new JoplinError('OpenAI-compatible provider has no base URL configured', 'aiProviderNotConfigured');
|
||||
if (!this.model_) throw new JoplinError('OpenAI-compatible provider has no model configured', 'aiProviderNotConfigured');
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: this.model_,
|
||||
messages: messages.map(m => ({ role: m.role, content: m.content })),
|
||||
messages: messages.map(convertMessage),
|
||||
stream: false,
|
||||
};
|
||||
if (options?.temperature !== undefined) body.temperature = options.temperature;
|
||||
if (options?.maxTokens !== undefined) body.max_tokens = options.maxTokens;
|
||||
if (options?.responseFormat !== undefined) body.response_format = options.responseFormat;
|
||||
if (options?.tools !== undefined) body.tools = options.tools.map(convertTool);
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.apiKey_) headers['Authorization'] = `Bearer ${this.apiKey_}`;
|
||||
|
||||
const doFetch = async () => {
|
||||
const response = await shim.fetch(`${this.baseUrl_}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await response.text();
|
||||
let json: OpenAiResponse;
|
||||
try {
|
||||
json = JSON.parse(text) as OpenAiResponse;
|
||||
} catch {
|
||||
throw new JoplinError(`AI provider returned non-JSON response: ${text.slice(0, 200)}`, response.status);
|
||||
}
|
||||
return { response, json };
|
||||
};
|
||||
const doFetch = () => this.sendChatRequest(body, { signal: options?.signal });
|
||||
|
||||
let { response, json } = await doFetch();
|
||||
|
||||
@@ -114,12 +157,48 @@ export default class OpenAiCompatibleProvider extends ChatProviderBase {
|
||||
);
|
||||
}
|
||||
|
||||
const content = json.choices?.[0]?.message?.content ?? '';
|
||||
const responseMessage = json.choices?.[0]?.message;
|
||||
const content = responseMessage?.content ?? '';
|
||||
// Some "OpenAI-compatible" providers (notably older Ollama versions)
|
||||
// omit `usage` entirely. Default to zeros rather than throw.
|
||||
const inputTokens = json.usage?.prompt_tokens ?? 0;
|
||||
const outputTokens = json.usage?.completion_tokens ?? 0;
|
||||
|
||||
return { text: content, usage: { inputTokens, outputTokens } };
|
||||
const toolCalls: ChatToolCall[] = (responseMessage?.tool_calls ?? []).map(call => {
|
||||
if (!call.function) return null;
|
||||
return {
|
||||
toolName: call.function.name,
|
||||
callId: call.id,
|
||||
arguments: JSON.parse(call.function.arguments),
|
||||
};
|
||||
}).filter(toolCall => !!toolCall);
|
||||
|
||||
return { text: content, toolCalls, usage: { inputTokens, outputTokens } };
|
||||
}
|
||||
|
||||
protected async sendChatRequest(body: Record<string, unknown>, options: ChatRequestOptions) {
|
||||
if (!this.baseUrl_) throw new JoplinError('OpenAI-compatible provider has no base URL configured', 'aiProviderNotConfigured');
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.apiKey_) headers['Authorization'] = `Bearer ${this.apiKey_}`;
|
||||
|
||||
const response = await shim.fetch(`${this.baseUrl_}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let json: OpenAiResponse;
|
||||
try {
|
||||
json = JSON.parse(text) as OpenAiResponse;
|
||||
} catch {
|
||||
throw new JoplinError(`AI provider returned non-JSON response: ${text.slice(0, 200)}`, response.status);
|
||||
}
|
||||
return {
|
||||
response: { status: response.status },
|
||||
json,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,60 @@
|
||||
export type ChatRole = 'system' | 'user' | 'assistant';
|
||||
export enum ChatRole {
|
||||
System = 'system',
|
||||
User = 'user',
|
||||
Assistant = 'assistant',
|
||||
Tool = 'tool',
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: ChatRole;
|
||||
interface ChatBaseMessage {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ChatStandardMessage extends ChatBaseMessage {
|
||||
role: ChatRole.System | ChatRole.User | ChatRole.Assistant;
|
||||
toolCalls?: ChatToolCall[];
|
||||
}
|
||||
|
||||
export interface ChatToolMessage extends ChatBaseMessage {
|
||||
role: ChatRole.Tool;
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
isError: boolean;
|
||||
// A very brief description of the result that can be shown to the user
|
||||
userDescription: string;
|
||||
}
|
||||
|
||||
export type ChatMessage = ChatStandardMessage | ChatToolMessage;
|
||||
|
||||
export interface JsonSchema {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
required?: string[];
|
||||
description?: string;
|
||||
additionalProperties?: boolean;
|
||||
}
|
||||
|
||||
export interface ResponseFormat {
|
||||
type: 'json_schema';
|
||||
json_schema: {
|
||||
name: string;
|
||||
strict: boolean;
|
||||
schema: unknown;
|
||||
schema: JsonSchema;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolSpec {
|
||||
name: string;
|
||||
description: string;
|
||||
// Information provided by the model to the tool
|
||||
inputSchema: JsonSchema;
|
||||
}
|
||||
|
||||
export interface ChatOptions {
|
||||
temperature?: number;
|
||||
tools?: ToolSpec[];
|
||||
responseFormat?: ResponseFormat;
|
||||
maxTokens?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ChatUsage {
|
||||
@@ -25,8 +62,15 @@ export interface ChatUsage {
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
export interface ChatToolCall {
|
||||
toolName: string;
|
||||
callId: string;
|
||||
arguments: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ChatResult {
|
||||
text: string;
|
||||
toolCalls: ChatToolCall[];
|
||||
usage: ChatUsage;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import AiService from '../../ai/AiService';
|
||||
import EmbeddingIndexer from '../../ai/EmbeddingIndexer';
|
||||
import SearchService from '../../ai/SearchService';
|
||||
import { ChatRole } from '../../ai/types';
|
||||
import { AiIndexStatus, ChatMessage, ChatOptions, ChatResult, EmbeddingsPage, GetEmbeddingsOptions, SearchOptions, SearchResult } from './types';
|
||||
|
||||
/**
|
||||
@@ -53,7 +54,13 @@ export default class JoplinAi {
|
||||
* ```
|
||||
*/
|
||||
public async chat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResult> {
|
||||
const result = await AiService.instance().chat(messages, options);
|
||||
const result = await AiService.instance().chat(
|
||||
messages.map(message => ({
|
||||
role: message.role as ChatRole.System | ChatRole.User | ChatRole.Assistant,
|
||||
content: message.content,
|
||||
})),
|
||||
options,
|
||||
);
|
||||
return { text: result.text };
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface FetchOptions {
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
agent?: unknown;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
interface AttachFileToNoteOptions {
|
||||
|
||||
Reference in new issue
Block a user