mirror of
https://github.com/laurent22/joplin.git
synced 2026-11-06 16:52:17 +00:00
Compare commits
12
Commits
server-v3.7.4
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d428762a21 | ||
|
|
ffbf55ec21 | ||
|
|
b7dd23ca8f | ||
|
|
2604fa339d | ||
|
|
85dc37a521 | ||
|
|
479921f6e7 | ||
|
|
504453d0d3 | ||
|
|
9bccaee2a2 | ||
|
|
97ed78d486 | ||
|
|
93a561d9f2 | ||
|
|
e5ed69ad01 | ||
|
|
d730509c28 |
No files matched your search
@@ -1664,8 +1664,10 @@ packages/lib/services/ai/providers/JoplinCloud.js
|
||||
packages/lib/services/ai/providers/OpenAiCompatible.test.js
|
||||
packages/lib/services/ai/providers/OpenAiCompatible.js
|
||||
packages/lib/services/ai/providers/TestProvider.js
|
||||
packages/lib/services/ai/providers/openAiResponses.js
|
||||
packages/lib/services/ai/testing/TestEmbeddingProvider.test.js
|
||||
packages/lib/services/ai/testing/TestEmbeddingProvider.js
|
||||
packages/lib/services/ai/testing/mockOpenAiServer.js
|
||||
packages/lib/services/ai/tools/ToolIndex.js
|
||||
packages/lib/services/ai/tools/buildEditorTools.js
|
||||
packages/lib/services/ai/tools/global/createNote.js
|
||||
@@ -1694,6 +1696,7 @@ packages/lib/services/ai/types.js
|
||||
packages/lib/services/ai/utils/extractReasoning.test.js
|
||||
packages/lib/services/ai/utils/extractReasoning.js
|
||||
packages/lib/services/ai/utils/findFencedBlock.js
|
||||
packages/lib/services/ai/utils/parseToolArguments.js
|
||||
packages/lib/services/commands/MenuUtils.js
|
||||
packages/lib/services/commands/ToolbarButtonUtils.test.js
|
||||
packages/lib/services/commands/ToolbarButtonUtils.js
|
||||
|
||||
@@ -1690,8 +1690,10 @@ packages/lib/services/ai/providers/JoplinCloud.js
|
||||
packages/lib/services/ai/providers/OpenAiCompatible.test.js
|
||||
packages/lib/services/ai/providers/OpenAiCompatible.js
|
||||
packages/lib/services/ai/providers/TestProvider.js
|
||||
packages/lib/services/ai/providers/openAiResponses.js
|
||||
packages/lib/services/ai/testing/TestEmbeddingProvider.test.js
|
||||
packages/lib/services/ai/testing/TestEmbeddingProvider.js
|
||||
packages/lib/services/ai/testing/mockOpenAiServer.js
|
||||
packages/lib/services/ai/tools/ToolIndex.js
|
||||
packages/lib/services/ai/tools/buildEditorTools.js
|
||||
packages/lib/services/ai/tools/global/createNote.js
|
||||
@@ -1720,6 +1722,7 @@ packages/lib/services/ai/types.js
|
||||
packages/lib/services/ai/utils/extractReasoning.test.js
|
||||
packages/lib/services/ai/utils/extractReasoning.js
|
||||
packages/lib/services/ai/utils/findFencedBlock.js
|
||||
packages/lib/services/ai/utils/parseToolArguments.js
|
||||
packages/lib/services/commands/MenuUtils.js
|
||||
packages/lib/services/commands/ToolbarButtonUtils.test.js
|
||||
packages/lib/services/commands/ToolbarButtonUtils.js
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Wed, 21 Oct 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Wed, 21 Oct 2026 00:00:00 GMT</pubDate><item><title><![CDATA[How we protect your privacy with Joplin Cloud AI]]></title><description><![CDATA[<p>Joplin Cloud AI features work on your notes. Notes are often personal, so we've set up the AI service so that your content goes to as few places as possible and stays there for as short a time as possible.</p>
|
||||
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Mon, 02 Nov 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Mon, 02 Nov 2026 00:00:00 GMT</pubDate><item><title><![CDATA[A full security review of Joplin Server and Joplin Cloud]]></title><description><![CDATA[<p>October 2026 has been nicknamed "Black October": within weeks, critical vulnerabilities were disclosed in widely used open source software such as PHP, Node.js and GitLab, many of them found with the help of AI and exploited within a day. The same tools that help defenders find bugs now help attackers find them, and Joplin's code is public. The responsible thing to do was to get there first.</p>
|
||||
<h2>The review<a name="the-review" href="#the-review" class="heading-anchor">🔗</a></h2>
|
||||
<p>Anthropic gave Joplin access to the Defense Access tier of its Cyber Verification Program, which lets open source maintainers use Claude's full security capabilities on software they maintain. We used it for a complete review of Joplin Server, which also powers Joplin Cloud: every route of the server, and every flow end to end, from signing in to sharing, publishing and email-to-note. Each finding was verified independently, and each fix was reviewed again before release.</p>
|
||||
<p>No critical issues were found, but there were a number of high and medium severity ones, and many smaller ones. We have fixed more than 50 of them. Joplin Cloud is already updated, and the fixes are now in a new Joplin Server release. If you use Joplin Cloud, there's nothing you need to do.</p>
|
||||
<h2>What was addressed<a name="what-was-addressed" href="#what-was-addressed" class="heading-anchor">🔗</a></h2>
|
||||
<p>Most fixes concern <strong>account security</strong>: signing in, password resets, email changes and two-factor authentication were all tightened. Changing your password now shuts out anyone who had access to your account, and the server no longer reveals which email addresses have an account. Password guessing is now limited per account, without locking out the real owner.</p>
|
||||
<p>Others concern <strong>sharing and publishing</strong>. The server trusted shared content a little too much, so someone you shared a notebook with could in some cases affect notes outside the share. It now checks that shared content really belongs where it claims, and published pages show only what was meant to be published.</p>
|
||||
<p>The rest make the server more <strong>robust</strong>, so that oversized requests or specially crafted content can't slow it down for everyone, and improve <strong>LDAP and SAML</strong> sign-in for self-hosted servers.</p>
|
||||
<h2>If you self-host<a name="if-you-self-host" href="#if-you-self-host" class="heading-anchor">🔗</a></h2>
|
||||
<p>Please update to <strong>version 3.7.4</strong> or greater. Note these changes:</p>
|
||||
<ul>
|
||||
<li>With <strong>LDAP</strong>, an account matching a directory entry is managed by the directory after its first LDAP sign-in, and its local password stops working.</li>
|
||||
<li>Sign-up is disabled when <strong>SAML or LDAP</strong> is enabled, and the SAML Audience must match your server's entity ID.</li>
|
||||
<li>Email confirmation is only required for sharing when your server can send emails.</li>
|
||||
<li>Your reverse proxy must set the <code>X-Real-IP</code> header for rate limits to work.</li>
|
||||
</ul>
|
||||
<p>Details are in the changelog and the security advisory. Thanks to Anthropic for making this review possible, and to everyone who reports security issues responsibly.</p>
|
||||
]]></description><link>https://joplinapp.org/news/20261102-joplin-server-security-review</link><guid isPermaLink="false">20261102-joplin-server-security-review</guid><pubDate>Mon, 02 Nov 2026 00:00:00 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[How we protect your privacy with Joplin Cloud AI]]></title><description><![CDATA[<p>Joplin Cloud AI features work on your notes. Notes are often personal, so we've set up the AI service so that your content goes to as few places as possible and stays there for as short a time as possible.</p>
|
||||
<p><strong>A European AI provider, processing in Europe.</strong> Joplin Cloud AI uses models from Mistral AI, a French company. We send requests to Mistral's EU regional endpoint, which processes data only in data centres in the European Union and EFTA countries. Your notes are not sent to the United States for processing.</p>
|
||||
<p><strong>Zero Data Retention.</strong> Mistral has enabled Zero Data Retention on our account. Once a response has been generated, Mistral doesn't store or log the content of the request or the response. Only basic billing information, such as how many tokens were used, is kept.</p>
|
||||
<p><strong>No training on your notes.</strong> Your content isn't used to train or improve AI models. Mistral's commercial terms rule this out for our account, and training is switched off in our account settings.</p>
|
||||
@@ -543,25 +560,4 @@ sys 0m38.013s</p>
|
||||
<li>Android: <a href="https://joplinapp.org/help/about/changelog/android/">https://joplinapp.org/help/about/changelog/android/</a></li>
|
||||
<li>iOS: <a href="https://joplinapp.org/help/about/changelog/ios/">https://joplinapp.org/help/about/changelog/ios/</a></li>
|
||||
</ul>
|
||||
]]></description><link>https://joplinapp.org/news/20231204-release-2-13</link><guid isPermaLink="false">20231204-release-2-13</guid><pubDate>Mon, 04 Dec 2023 00:00:00 GMT</pubDate><twitter-text>What's new in Joplin 2.13</twitter-text></item><item><title><![CDATA[Working in the shadows with white-hat hackers]]></title><description><![CDATA[<p>The majority of Joplin's development is carried out in the public domain. This includes the discussion of issues on GitHub, as well as the submission of pull requests and related discussions. The transparency of these processes allows for collaborative problem-solving and shared insights.</p>
|
||||
<p>However, there is one aspect that operates behind closed doors, and for good reason: addressing cybersecurity vulnerabilities. It is imperative that these issues remain undisclosed until they have been resolved. Once a solution is implemented, it is usually accompanied by discreet commits and a message in the changelog to signify the progress made.</p>
|
||||
<p>Typically, the process begins with an email from a security researcher. They provide valuable insights, such as a specially crafted note that triggers a bug, or an API call, along with an explanation of how the application's security can be circumvented. We examine the vulnerability, create a fix, and create automated test units to prevent any accidental reintroduction of the vulnerability in future code updates. An example of such a commit is: <a href="https://github.com/laurent22/joplin/commit/9e90d9016daf79b5414646a93fd369aedb035071">9e90d9016daf79b5414646a93fd369aedb035071</a></p>
|
||||
<p>We then share our fix with the researcher for validation. Additionally, we often apply the fix to previous versions of Joplin, depending on the severity of the vulnerability.</p>
|
||||
<p>The contribution of security researchers in this regard is immeasurable. They employ their ingenuity to identify inventive methods of bypassing existing security measures and often discover subtle flaws in the code that might otherwise go unnoticed.</p>
|
||||
<p>We would like to express our sincere gratitude to the security researchers who have assisted us throughout the years in identifying and rectifying security vulnerabilities!</p>
|
||||
<ul>
|
||||
<li><a href="https://github.com/a1ise">@Alise</a></li>
|
||||
<li>@hexodotsh</li>
|
||||
<li><a href="https://github.com/ly1g3">@ly1g3</a></li>
|
||||
<li><a href="https://twitter.com/maple3142">@maple3142</a></li>
|
||||
<li>Ademar Nowasky Junior</li>
|
||||
<li><a href="mailto:ben@mayhem.sg">Benjamin Harris</a></li>
|
||||
<li><a href="https://github.com/JavierOlmedo">Javier Olmedo</a></li>
|
||||
<li><a href="https://twitter.com/newfolderj">Jubair Rehman Yousafzai</a></li>
|
||||
<li>lin@UCCU Hacker</li>
|
||||
<li><a href="https://github.com/personalizedrefrigerator">personalizedrefrigerator</a></li>
|
||||
<li><a href="https://twitter.com/fhlipZero">Phil Holbrook</a></li>
|
||||
<li><a href="https://ryotak.net/">RyotaK</a></li>
|
||||
<li><a href="https://twitter.com/YNizry">Yaniv Nizry</a></li>
|
||||
</ul>
|
||||
]]></description><link>https://joplinapp.org/news/20231023-white-hat-hackers</link><guid isPermaLink="false">20231023-white-hat-hackers</guid><pubDate>Mon, 23 Oct 2023 00:00:00 GMT</pubDate><twitter-text>Working in the shadows with white-hat hackers</twitter-text></item></channel></rss>
|
||||
]]></description><link>https://joplinapp.org/news/20231204-release-2-13</link><guid isPermaLink="false">20231204-release-2-13</guid><pubDate>Mon, 04 Dec 2023 00:00:00 GMT</pubDate><twitter-text>What's new in Joplin 2.13</twitter-text></item></channel></rss>
|
||||
@@ -6,6 +6,7 @@ import Note from '../../models/Note';
|
||||
import Tag from '../../models/Tag';
|
||||
import { NoteEntity } from '../database/types';
|
||||
import { NoteContext, ToolDefinition } from './tools/types';
|
||||
import mockOpenAiServer, { jsonResponse } from './testing/mockOpenAiServer';
|
||||
|
||||
const runChatForNote = (note: NoteEntity, history: ChatMessage[], message: string) => {
|
||||
const getContext = async () => {
|
||||
@@ -117,4 +118,30 @@ describe('noteChat.tools', () => {
|
||||
expect(infoMessage).toBeTruthy();
|
||||
expect(infoMessage.content).toContain('Tool `manage_tags` is disabled in Joplin\'s settings');
|
||||
});
|
||||
|
||||
test('should keep the model reasoning between the requests of a tool loop', async () => {
|
||||
const baseUrl = 'http://localhost:1234/v1';
|
||||
Setting.setValue('ai.chat.providerType', 'openai-compatible');
|
||||
Setting.setValue('ai.chat.baseUrl', baseUrl);
|
||||
Setting.setValue('ai.chat.model', 'test-model');
|
||||
|
||||
const reasoningItem = { type: 'reasoning', id: 'rs_1', summary: [{ type: 'summary_text', text: 'Thinking...' }], encrypted_content: 'encrypted' };
|
||||
const functionCallItem = { type: 'function_call', id: 'fc_1', call_id: 'call_1', name: 'editor_readNoteBody', arguments: '{}' };
|
||||
const requests = mockOpenAiServer(baseUrl, {
|
||||
'/responses': () => {
|
||||
const output = requests.length === 1 ? [reasoningItem, functionCallItem] : [{ type: 'message', content: [{ type: 'output_text', text: 'Done' }] }];
|
||||
return jsonResponse({ status: 'completed', output });
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const note = await Note.save({ title: 'test' });
|
||||
await runChatForNote(note, [{ role: ChatRole.User, content: 'testing' }], 'Read the note');
|
||||
} finally {
|
||||
jest.restoreAllMocks();
|
||||
}
|
||||
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests[1].body.input).toEqual(expect.arrayContaining([reasoningItem, functionCallItem]));
|
||||
});
|
||||
});
|
||||
@@ -203,6 +203,7 @@ const stepNoteChat = async ({
|
||||
role: ChatRole.Assistant,
|
||||
content: chatResult.text ?? '',
|
||||
toolCalls: allowTools ? chatResult.toolCalls : [],
|
||||
responsesState: allowTools ? chatResult.responsesState : undefined,
|
||||
});
|
||||
onHistoryChanged([...messages]);
|
||||
|
||||
|
||||
@@ -85,6 +85,22 @@ describe('ai/providers/JoplinCloud', () => {
|
||||
expect(error.message).toMatch(/reached your AI usage budget/i);
|
||||
});
|
||||
|
||||
it('should only use the Chat Completions API', async () => {
|
||||
const urls: string[] = [];
|
||||
const mock = mockFetch((request) => {
|
||||
urls.push(request.url);
|
||||
return new Response(request.url.endsWith('ai/chat/completions') ? '{ "choices": [] }' : '{ }', { status: 200 });
|
||||
});
|
||||
try {
|
||||
await new JoplinCloudProvider().chat([]);
|
||||
} finally {
|
||||
mock.reset();
|
||||
}
|
||||
|
||||
expect(urls.filter(url => url.endsWith('ai/chat/completions'))).toHaveLength(1);
|
||||
expect(urls.filter(url => url.includes('responses'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should retry rate-limited requests', async () => {
|
||||
const { mock, requestCount } = mockRateLimitedChatApi();
|
||||
try {
|
||||
|
||||
@@ -87,6 +87,10 @@ export default class JoplinCloudProvider extends OpenAiCompatibleProvider {
|
||||
});
|
||||
}
|
||||
|
||||
protected override canTryResponsesApi() {
|
||||
return false;
|
||||
}
|
||||
|
||||
private async api() {
|
||||
// Fetch the API from the registry, rather than caching the API locally.
|
||||
// This avoids authentication drift if the user changes the sync target credentials.
|
||||
|
||||
@@ -1,21 +1,51 @@
|
||||
import { mockFetch, setupDatabaseAndSynchronizer, switchClient } from '../../../testing/test-utils';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '../../../testing/test-utils';
|
||||
import OpenAiCompatibleProvider from './OpenAiCompatible';
|
||||
import { ChatRole } from '../types';
|
||||
import { ChatMessage, ChatResult, ChatRole } from '../types';
|
||||
import { ToolImageResponse } from '../tools/types';
|
||||
import mockOpenAiServer, { jsonResponse } from '../testing/mockOpenAiServer';
|
||||
|
||||
const baseUrl = 'http://localhost:1234/v1';
|
||||
|
||||
const mockChatResponse = (message: Record<string, unknown>, finishReason: string|undefined) => {
|
||||
return mockFetch(() => {
|
||||
return new Response(JSON.stringify({
|
||||
return mockOpenAiServer(baseUrl, {
|
||||
'/chat/completions': () => jsonResponse({
|
||||
choices: [{ message, finish_reason: finishReason }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 20 },
|
||||
}), { status: 200 });
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const newProvider = () => {
|
||||
const responsesBody = (output: unknown[], extra: Record<string, unknown> = {}) => {
|
||||
return {
|
||||
status: 'completed',
|
||||
output,
|
||||
usage: { input_tokens: 10, output_tokens: 20 },
|
||||
...extra,
|
||||
};
|
||||
};
|
||||
|
||||
const mockResponsesOutput = (output: unknown[], extra: Record<string, unknown> = {}) => {
|
||||
return mockOpenAiServer(baseUrl, {
|
||||
'/responses': () => jsonResponse(responsesBody(output, extra)),
|
||||
});
|
||||
};
|
||||
|
||||
const messageItem = (text: string) => ({ type: 'message', role: 'assistant', content: [{ type: 'output_text', text }] });
|
||||
const reasoningItem = { type: 'reasoning', id: 'rs_1', summary: [{ type: 'summary_text', text: 'Thinking...' }], encrypted_content: 'encrypted' };
|
||||
const reasoningContentItem = { type: 'reasoning', id: 'rs_1', summary: [] as unknown[], content: [{ type: 'reasoning_text', text: 'Thinking...' }] };
|
||||
const functionCallItem = { type: 'function_call', id: 'fc_1', call_id: 'call_1', name: 'get_weather', arguments: '{"city":"Paris"}' };
|
||||
|
||||
const weatherTool = {
|
||||
id: 'get_weather',
|
||||
description: 'Gets the weather',
|
||||
inputSchema: { type: 'object', properties: { city: { type: 'string' } } },
|
||||
};
|
||||
|
||||
const newProvider = (model = 'test-model', apiKey = 'test-key') => {
|
||||
return new OpenAiCompatibleProvider({
|
||||
baseUrl: 'http://localhost:1234/v1',
|
||||
apiKey: 'test-key',
|
||||
model: 'test-model',
|
||||
baseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
classification: 'local',
|
||||
});
|
||||
};
|
||||
@@ -24,6 +54,22 @@ const chat = async () => {
|
||||
return newProvider().chat([{ role: ChatRole.User, content: 'Hello' }]);
|
||||
};
|
||||
|
||||
const toolLoopMessages = (firstResult: ChatResult, toolOutput: ChatMessage['content']): ChatMessage[] => {
|
||||
return [
|
||||
{ role: ChatRole.User, content: 'Weather in Paris?' },
|
||||
{ role: ChatRole.Assistant, content: firstResult.text, toolCalls: firstResult.toolCalls, responsesState: firstResult.responsesState },
|
||||
{
|
||||
role: ChatRole.Tool,
|
||||
content: toolOutput,
|
||||
toolName: 'get_weather',
|
||||
toolCallId: 'call_1',
|
||||
isError: false,
|
||||
userDescription: '',
|
||||
isEdit: false,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
describe('ai/providers/OpenAiCompatible', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -31,6 +77,10 @@ describe('ai/providers/OpenAiCompatible', () => {
|
||||
await switchClient(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test.each([
|
||||
['length', 'length'],
|
||||
['max_tokens', 'length'],
|
||||
@@ -61,4 +111,188 @@ describe('ai/providers/OpenAiCompatible', () => {
|
||||
expect(result.reasoningText).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use the Responses API when the server supports it', async () => {
|
||||
const requests = mockResponsesOutput([reasoningItem, messageItem('Hi!')]);
|
||||
const provider = newProvider();
|
||||
await provider.chat([{ role: ChatRole.User, content: 'Hello' }]);
|
||||
const result = await provider.chat([{ role: ChatRole.User, content: 'Hello' }]);
|
||||
|
||||
expect(requests.map(request => request.path)).toEqual(['/responses', '/responses']);
|
||||
expect(result).toMatchObject({
|
||||
text: 'Hi!',
|
||||
toolCalls: [],
|
||||
usage: { inputTokens: 10, outputTokens: 20 },
|
||||
finishReason: 'stop',
|
||||
reasoningText: 'Thinking...',
|
||||
responsesState: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
['content', [reasoningContentItem, messageItem('Hi!')], 'Hi!'],
|
||||
['content without a message', [reasoningContentItem], ''],
|
||||
])('should read the reasoning text from %s', async (_label, output, expectedText) => {
|
||||
mockResponsesOutput(output);
|
||||
const result = await chat();
|
||||
expect(result.text).toBe(expectedText);
|
||||
expect(result.reasoningText).toBe('Thinking...');
|
||||
});
|
||||
|
||||
test.each([
|
||||
['returns 404', () => new Response('404 page not found', { status: 404 })],
|
||||
// Older LM Studio versions answer unknown endpoints like this
|
||||
['returns 200 without output', () => jsonResponse({ error: 'Unexpected endpoint or method. (POST /v1/responses)' })],
|
||||
])('should fall back to Chat Completions once when the Responses API %s', async (_label, responsesHandler) => {
|
||||
const requests = mockOpenAiServer(baseUrl, {
|
||||
'/responses': responsesHandler,
|
||||
'/chat/completions': () => jsonResponse({ choices: [{ message: { content: 'OK' }, finish_reason: 'stop' }] }),
|
||||
});
|
||||
const provider = newProvider();
|
||||
await provider.chat([{ role: ChatRole.User, content: 'Hello' }]);
|
||||
const result = await provider.chat([{ role: ChatRole.User, content: 'Hello' }]);
|
||||
|
||||
expect(requests.map(request => request.path)).toEqual(['/responses', '/chat/completions', '/chat/completions']);
|
||||
expect(result.text).toBe('OK');
|
||||
});
|
||||
|
||||
it('should try the Responses API again when the fallback also fails', async () => {
|
||||
let modelInstalled = false;
|
||||
const modelNotFound = () => jsonResponse({ error: { message: 'model \'test-model\' not found' } }, 404);
|
||||
const requests = mockOpenAiServer(baseUrl, {
|
||||
'/responses': () => modelInstalled ? jsonResponse(responsesBody([messageItem('Hi!')])) : modelNotFound(),
|
||||
'/chat/completions': modelNotFound,
|
||||
});
|
||||
const provider = newProvider();
|
||||
await expect(provider.chat([{ role: ChatRole.User, content: 'Hello' }])).rejects.toThrow('AI provider returned 404: model \'test-model\' not found');
|
||||
|
||||
modelInstalled = true;
|
||||
const result = await provider.chat([{ role: ChatRole.User, content: 'Hello' }]);
|
||||
|
||||
expect(requests.map(request => request.path)).toEqual(['/responses', '/chat/completions', '/responses']);
|
||||
expect(result.text).toBe('Hi!');
|
||||
});
|
||||
|
||||
test.each([400, 401])('should not fall back to Chat Completions on error %s', async (status) => {
|
||||
const requests = mockOpenAiServer(baseUrl, {
|
||||
'/responses': () => jsonResponse({ error: { message: 'Something failed' } }, status),
|
||||
'/chat/completions': () => jsonResponse({ choices: [{ message: { content: 'OK' } }] }),
|
||||
});
|
||||
await expect(chat()).rejects.toThrow(`AI provider returned ${status}: Something failed`);
|
||||
expect(requests.map(request => request.path)).toEqual(['/responses']);
|
||||
});
|
||||
|
||||
it('should not fall back to Chat Completions after the Responses API has worked', async () => {
|
||||
let responsesCallCount = 0;
|
||||
const requests = mockOpenAiServer(baseUrl, {
|
||||
'/responses': () => {
|
||||
responsesCallCount++;
|
||||
return responsesCallCount === 1 ? jsonResponse(responsesBody([messageItem('Hi!')])) : new Response('', { status: 404 });
|
||||
},
|
||||
'/chat/completions': () => jsonResponse({ choices: [{ message: { content: 'OK' } }] }),
|
||||
});
|
||||
const provider = newProvider();
|
||||
await provider.chat([{ role: ChatRole.User, content: 'Hello' }]);
|
||||
await expect(provider.chat([{ role: ChatRole.User, content: 'Hello' }])).rejects.toThrow('AI provider returned 404');
|
||||
expect(requests.map(request => request.path)).toEqual(['/responses', '/responses']);
|
||||
});
|
||||
|
||||
it('should map the request options to the Responses API', async () => {
|
||||
const requests = mockResponsesOutput([messageItem('{"answer":"Paris"}')]);
|
||||
await newProvider().chat([
|
||||
{ role: ChatRole.System, content: 'Be brief' },
|
||||
{ role: ChatRole.User, content: 'Capital of France?' },
|
||||
], {
|
||||
maxTokens: 100,
|
||||
tools: [weatherTool],
|
||||
responseFormat: { type: 'json_schema', json_schema: { name: 'answer', strict: true, schema: { type: 'object' } } },
|
||||
});
|
||||
|
||||
expect(requests[0].body).toEqual({
|
||||
model: 'test-model',
|
||||
input: [
|
||||
{ role: 'system', content: 'Be brief' },
|
||||
{ role: 'user', content: 'Capital of France?' },
|
||||
],
|
||||
stream: false,
|
||||
store: false,
|
||||
include: ['reasoning.encrypted_content'],
|
||||
max_output_tokens: 100,
|
||||
text: { format: { type: 'json_schema', name: 'answer', strict: true, schema: { type: 'object' } } },
|
||||
tools: [{ type: 'function', name: 'get_weather', description: 'Gets the weather', parameters: weatherTool.inputSchema }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should retry without the schema when the model rejects structured output', async () => {
|
||||
const requests = mockOpenAiServer(baseUrl, {
|
||||
'/responses': body => body.text
|
||||
? jsonResponse({ error: { message: 'Invalid parameter: \'text.format\' of type \'json_schema\' is not supported with this model.' } }, 400)
|
||||
: jsonResponse(responsesBody([messageItem('Hi!')])),
|
||||
});
|
||||
const result = await newProvider().chat([{ role: ChatRole.User, content: 'Hello' }], {
|
||||
responseFormat: { type: 'json_schema', json_schema: { name: 'answer', strict: true, schema: { type: 'object' } } },
|
||||
});
|
||||
|
||||
expect(requests.map(request => request.path)).toEqual(['/responses', '/responses']);
|
||||
expect(requests[1].body.text).toBeUndefined();
|
||||
expect(result.text).toBe('Hi!');
|
||||
});
|
||||
|
||||
test.each([
|
||||
[{ status: 'completed' }, 'stop'],
|
||||
[{ status: 'incomplete', incomplete_details: { reason: 'max_output_tokens' } }, 'length'],
|
||||
[{ status: 'incomplete', incomplete_details: { reason: 'content_filter' } }, 'other'],
|
||||
])('should map Responses API status %j to finish reason %s', async (status, expected) => {
|
||||
mockResponsesOutput([messageItem('Hi!')], status);
|
||||
const result = await chat();
|
||||
expect(result.finishReason).toBe(expected);
|
||||
});
|
||||
|
||||
it('should send the output items back in their original order to the same model and API key only', async () => {
|
||||
const textItem = messageItem('Let me check.');
|
||||
const requests = mockResponsesOutput([reasoningItem, textItem, functionCallItem]);
|
||||
const provider = newProvider();
|
||||
const firstResult = await provider.chat([{ role: ChatRole.User, content: 'Weather in Paris?' }], { tools: [weatherTool] });
|
||||
|
||||
expect(firstResult.toolCalls).toEqual([{ toolName: 'get_weather', callId: 'call_1', arguments: { city: 'Paris' }, parseError: null }]);
|
||||
expect(firstResult.finishReason).toBe('tool_calls');
|
||||
|
||||
const messages = toolLoopMessages(firstResult, 'Sunny');
|
||||
await provider.chat(messages, { tools: [weatherTool] });
|
||||
const toolOutputItem = { type: 'function_call_output', call_id: 'call_1', output: 'Sunny' };
|
||||
expect(requests[1].body.input).toEqual([
|
||||
{ role: 'user', content: 'Weather in Paris?' },
|
||||
reasoningItem,
|
||||
textItem,
|
||||
functionCallItem,
|
||||
toolOutputItem,
|
||||
]);
|
||||
|
||||
await newProvider('other-model').chat(messages, { tools: [weatherTool] });
|
||||
await newProvider('test-model', 'other-key').chat(messages, { tools: [weatherTool] });
|
||||
expect(requests).toHaveLength(4);
|
||||
for (const request of requests.slice(2)) {
|
||||
expect(request.body.input).toEqual([
|
||||
{ role: 'user', content: 'Weather in Paris?' },
|
||||
{ role: 'assistant', content: 'Let me check.' },
|
||||
{ type: 'function_call', call_id: 'call_1', name: 'get_weather', arguments: '{"city":"Paris"}' },
|
||||
toolOutputItem,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should send image tool results as images', async () => {
|
||||
const requests = mockResponsesOutput([functionCallItem]);
|
||||
const provider = newProvider();
|
||||
const firstResult = await provider.chat([{ role: ChatRole.User, content: 'Weather in Paris?' }], { tools: [weatherTool] });
|
||||
|
||||
const dataUrl = 'data:image/png;base64,AAAA';
|
||||
const image = new ToolImageResponse({ dataUrl, mimeType: 'image/png', id: '0123456789abcdef0123456789abcdef' });
|
||||
await provider.chat(toolLoopMessages(firstResult, image), { tools: [weatherTool] });
|
||||
expect(requests[1].body.input).toContainEqual({
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_1',
|
||||
output: [{ type: 'input_image', image_url: dataUrl }],
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -6,6 +6,9 @@ import { ChatFinishReason, ChatMessage, ChatOptions, ChatResult, ChatToolCall, P
|
||||
import ChatProviderBase from './ChatProviderBase';
|
||||
import { ToolSpec } from '../tools/types';
|
||||
import extractReasoning from '../utils/extractReasoning';
|
||||
import parseToolArguments from '../utils/parseToolArguments';
|
||||
import { fromResponsesJson, ResponsesApiResponse, toResponsesBody } from './openAiResponses';
|
||||
const md5 = require('md5');
|
||||
|
||||
const logger = Logger.create('OpenAiCompatibleProvider');
|
||||
|
||||
@@ -118,19 +121,6 @@ const convertMessage = (message: ChatMessage) => {
|
||||
}
|
||||
};
|
||||
|
||||
const describeJsonParseFailure = (rawContent: string) => {
|
||||
const parseError = ['Failed to parse JSON.'];
|
||||
|
||||
// With certain providers (e.g. Joplin Cloud), long messages are truncated. Include this information in the error message so that
|
||||
// the model knows to retry with a shorter message:
|
||||
const suggestedRetryLengthLimit = 1000;
|
||||
if (rawContent.startsWith('{') && rawContent.length > suggestedRetryLengthLimit) {
|
||||
parseError.push(`It's likely that the tool call JSON is too long. Please try again with a message shorter than ${suggestedRetryLengthLimit} characters.`);
|
||||
}
|
||||
|
||||
return parseError.join(' ');
|
||||
};
|
||||
|
||||
export interface ChatRequestOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
@@ -140,6 +130,8 @@ export interface OpenAiChatResponse {
|
||||
json: OpenAiResponse;
|
||||
}
|
||||
|
||||
type ChatApi = 'responses' | 'chat-completions';
|
||||
|
||||
export default class OpenAiCompatibleProvider extends ChatProviderBase {
|
||||
|
||||
public id = 'openai-compatible';
|
||||
@@ -147,6 +139,7 @@ export default class OpenAiCompatibleProvider extends ChatProviderBase {
|
||||
private baseUrl_: string;
|
||||
private apiKey_: string;
|
||||
private model_: string;
|
||||
private api_: ChatApi|null = null;
|
||||
|
||||
public constructor(options: Options) {
|
||||
super();
|
||||
@@ -156,9 +149,72 @@ export default class OpenAiCompatibleProvider extends ChatProviderBase {
|
||||
this.classification = options.classification;
|
||||
}
|
||||
|
||||
protected canTryResponsesApi() {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected async doChat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResult> {
|
||||
if (!this.model_) throw new JoplinError('OpenAI-compatible provider has no model configured', 'aiProviderNotConfigured');
|
||||
|
||||
if (this.canTryResponsesApi() && this.api_ !== 'chat-completions') {
|
||||
const result = await this.doResponsesChat(messages, options);
|
||||
if (result) {
|
||||
this.api_ = 'responses';
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
const chatCompletionsResult = await this.doChatCompletions(messages, options);
|
||||
// Some servers like Ollama also return 404 when the model is not found so we only
|
||||
// remember the fallback after /chat/completions works
|
||||
this.api_ = 'chat-completions';
|
||||
return chatCompletionsResult;
|
||||
}
|
||||
|
||||
private async doResponsesChat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResult|null> {
|
||||
// Encrypted reasoning only works with the account that created it. The key is
|
||||
// hashed because the replay key is saved with the chat history
|
||||
const replayKey = `${this.baseUrl_}|${this.model_}|${md5(this.apiKey_)}`;
|
||||
const body = toResponsesBody(this.model_, messages, options, replayKey);
|
||||
let { status, json } = await this.sendResponsesRequest(body, { signal: options?.signal });
|
||||
|
||||
if (status === 404 && this.api_ === null) {
|
||||
logger.info(`${this.baseUrl_}/responses returned 404. Using /chat/completions instead.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Same compatibility retry as `response_format` in doChatCompletions
|
||||
if (status === 400 && 'text' in body && /json_schema|response_format/i.test(json?.error?.message ?? '')) {
|
||||
logger.warn(`Model ${this.model_} rejected text.format; retrying without structured output schema.`);
|
||||
delete body.text;
|
||||
({ status, json } = await this.sendResponsesRequest(body, { signal: options?.signal }));
|
||||
}
|
||||
|
||||
if (status >= 400) {
|
||||
const detail = json?.error?.message ? `: ${json.error.message}` : '';
|
||||
throw new JoplinError(`AI provider returned ${status}${detail}`, status);
|
||||
}
|
||||
|
||||
if (!Array.isArray(json?.output)) {
|
||||
// Older LM Studio versions return 200 with an error body for unknown endpoints
|
||||
if (this.api_ === null) {
|
||||
logger.info(`${this.baseUrl_}/responses returned ${status} with no output. Using /chat/completions instead.`);
|
||||
return null;
|
||||
}
|
||||
throw this.badResponseShapeError();
|
||||
}
|
||||
|
||||
return fromResponsesJson(json, replayKey);
|
||||
}
|
||||
|
||||
private badResponseShapeError() {
|
||||
return new JoplinError(
|
||||
`AI provider returned an unexpected response shape. The base URL is likely wrong — for OpenAI, Ollama, and LM Studio the URL must end with "/v1" (got ${this.baseUrl_}).`,
|
||||
'aiProviderBadResponse',
|
||||
);
|
||||
}
|
||||
|
||||
private async doChatCompletions(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResult> {
|
||||
const body: Record<string, unknown> = {
|
||||
model: this.model_,
|
||||
messages: messages.flatMap((message): unknown[] => convertMessage(message)),
|
||||
@@ -210,12 +266,7 @@ export default class OpenAiCompatibleProvider extends ChatProviderBase {
|
||||
// is wrong (e.g. user forgot the `/v1` suffix on a local server) — many
|
||||
// such servers reply 200 with an empty body rather than 404. Surface
|
||||
// this rather than returning an empty string the caller can't diagnose.
|
||||
if (!Array.isArray(json.choices)) {
|
||||
throw new JoplinError(
|
||||
`AI provider returned an unexpected response shape. The base URL is likely wrong — for OpenAI, Ollama, and LM Studio the URL must end with "/v1" (got ${this.baseUrl_}).`,
|
||||
'aiProviderBadResponse',
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(json.choices)) throw this.badResponseShapeError();
|
||||
|
||||
const choice = json.choices?.[0];
|
||||
const responseMessage = choice?.message;
|
||||
@@ -229,16 +280,7 @@ export default class OpenAiCompatibleProvider extends ChatProviderBase {
|
||||
const toolCalls: ChatToolCall[] = (responseMessage?.tool_calls ?? []).map(call => {
|
||||
if (!call.function) return null;
|
||||
|
||||
let args;
|
||||
let parseError: string|null = null;
|
||||
const argumentString = call.function.arguments;
|
||||
try {
|
||||
args = JSON.parse(argumentString);
|
||||
} catch (error) {
|
||||
args = {};
|
||||
parseError = describeJsonParseFailure(argumentString);
|
||||
logger.error('JSON parse failed', error, parseError);
|
||||
}
|
||||
const { args, parseError } = parseToolArguments(call.function.arguments);
|
||||
|
||||
return {
|
||||
toolName: call.function.name,
|
||||
@@ -257,29 +299,44 @@ export default class OpenAiCompatibleProvider extends ChatProviderBase {
|
||||
};
|
||||
}
|
||||
|
||||
protected async sendChatRequest(body: Record<string, unknown>, options: ChatRequestOptions): Promise<OpenAiChatResponse> {
|
||||
private async post(path: string, 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`, {
|
||||
const response = await shim.fetch(`${this.baseUrl_}${path}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
return { status: response.status, text: await response.text() };
|
||||
}
|
||||
|
||||
protected async sendChatRequest(body: Record<string, unknown>, options: ChatRequestOptions): Promise<OpenAiChatResponse> {
|
||||
const { status, text } = await this.post('/chat/completions', body, options);
|
||||
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);
|
||||
throw new JoplinError(`AI provider returned non-JSON response: ${text.slice(0, 200)}`, status);
|
||||
}
|
||||
return {
|
||||
response: { status: response.status },
|
||||
response: { status },
|
||||
json,
|
||||
};
|
||||
}
|
||||
|
||||
private async sendResponsesRequest(body: Record<string, unknown>, options: ChatRequestOptions) {
|
||||
const { status, text } = await this.post('/responses', body, options);
|
||||
let json: ResponsesApiResponse|null = null;
|
||||
try {
|
||||
json = JSON.parse(text) as ResponsesApiResponse;
|
||||
} catch {
|
||||
if (status !== 404) throw new JoplinError(`AI provider returned non-JSON response: ${text.slice(0, 200)}`, status);
|
||||
}
|
||||
return { status, json };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { ChatFinishReason, ChatMessage, ChatOptions, ChatResult, ChatRole, ChatToolCall } from '../types';
|
||||
import { ToolSpec } from '../tools/types';
|
||||
import extractReasoning from '../utils/extractReasoning';
|
||||
import parseToolArguments from '../utils/parseToolArguments';
|
||||
|
||||
interface ResponsesContentPart {
|
||||
type: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface ResponsesOutputItem {
|
||||
type: string;
|
||||
content?: ResponsesContentPart[];
|
||||
summary?: ResponsesContentPart[];
|
||||
call_id?: string;
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
}
|
||||
|
||||
export interface ResponsesApiResponse {
|
||||
output?: ResponsesOutputItem[];
|
||||
status?: string;
|
||||
incomplete_details?: { reason?: string }|null;
|
||||
usage?: { input_tokens?: number; output_tokens?: number };
|
||||
error?: { message?: string }|null;
|
||||
}
|
||||
|
||||
const convertTool = (tool: ToolSpec) => {
|
||||
return {
|
||||
type: 'function',
|
||||
name: tool.id,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema,
|
||||
};
|
||||
};
|
||||
|
||||
const convertMessage = (message: ChatMessage, replayKey: string): unknown[] => {
|
||||
if (message.role === ChatRole.Tool) {
|
||||
const content = message.content;
|
||||
return [{
|
||||
type: 'function_call_output',
|
||||
call_id: message.toolCallId,
|
||||
output: typeof content === 'string' ? content : [{ type: 'input_image', image_url: content.dataUrl }],
|
||||
}];
|
||||
}
|
||||
|
||||
if (message.responsesState?.key === replayKey) {
|
||||
return message.responsesState.items;
|
||||
}
|
||||
|
||||
const textItems = message.content || message.role !== ChatRole.Assistant ? [{ role: message.role, content: message.content }] : [];
|
||||
|
||||
return [
|
||||
...textItems,
|
||||
...(message.toolCalls ?? []).map(call => ({
|
||||
type: 'function_call',
|
||||
call_id: call.callId,
|
||||
name: call.toolName,
|
||||
arguments: JSON.stringify(call.arguments),
|
||||
})),
|
||||
];
|
||||
};
|
||||
|
||||
export const toResponsesBody = (model: string, messages: ChatMessage[], options: ChatOptions|undefined, replayKey: string) => {
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
input: messages.flatMap(message => convertMessage(message, replayKey)),
|
||||
stream: false,
|
||||
store: false,
|
||||
include: ['reasoning.encrypted_content'],
|
||||
};
|
||||
if (options?.temperature !== undefined) body.temperature = options.temperature;
|
||||
if (options?.maxTokens !== undefined) body.max_output_tokens = options.maxTokens;
|
||||
if (options?.responseFormat !== undefined) body.text = { format: { type: 'json_schema', ...options.responseFormat.json_schema } };
|
||||
if (options?.tools?.length) body.tools = options.tools.map(convertTool);
|
||||
return body;
|
||||
};
|
||||
|
||||
const toChatFinishReason = (json: ResponsesApiResponse, hasToolCalls: boolean): ChatFinishReason|undefined => {
|
||||
if (json.status === 'incomplete') return json.incomplete_details?.reason === 'max_output_tokens' ? 'length' : 'other';
|
||||
if (hasToolCalls) return 'tool_calls';
|
||||
if (json.status === 'completed') return 'stop';
|
||||
return json.status ? 'other' : undefined;
|
||||
};
|
||||
|
||||
const partsText = (parts: ResponsesContentPart[], type: string) => {
|
||||
return parts.filter(part => part.type === type).map(part => part.text ?? '');
|
||||
};
|
||||
|
||||
export const fromResponsesJson = (json: ResponsesApiResponse, replayKey: string): ChatResult => {
|
||||
const output = json.output ?? [];
|
||||
|
||||
const rawText = partsText(output.filter(item => item.type === 'message').flatMap(item => item.content ?? []), 'output_text').join('');
|
||||
const { text, reasoning: inlineReasoning } = extractReasoning(rawText);
|
||||
const reasoningItems = output.filter(item => item.type === 'reasoning');
|
||||
const summaryText = partsText(reasoningItems.flatMap(item => item.summary ?? []), 'summary_text').join('\n\n');
|
||||
// LM Studio sends the reasoning in `content` instead of `summary`
|
||||
const reasoningContentText = partsText(reasoningItems.flatMap(item => item.content ?? []), 'reasoning_text').join('\n\n');
|
||||
|
||||
const toolCalls: ChatToolCall[] = output.filter(item => item.type === 'function_call').map(call => {
|
||||
const { args, parseError } = parseToolArguments(call.arguments ?? '');
|
||||
return {
|
||||
toolName: call.name,
|
||||
callId: call.call_id,
|
||||
arguments: args,
|
||||
parseError,
|
||||
};
|
||||
});
|
||||
|
||||
const replayItems = output.filter(item => item.type === 'reasoning' || item.type === 'message' || item.type === 'function_call');
|
||||
|
||||
return {
|
||||
text,
|
||||
toolCalls,
|
||||
usage: {
|
||||
inputTokens: json.usage?.input_tokens ?? 0,
|
||||
outputTokens: json.usage?.output_tokens ?? 0,
|
||||
},
|
||||
finishReason: toChatFinishReason(json, toolCalls.length > 0),
|
||||
reasoningText: summaryText || reasoningContentText || inlineReasoning || undefined,
|
||||
responsesState: toolCalls.length ? { key: replayKey, items: replayItems } : undefined,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import shim from '../../../shim';
|
||||
|
||||
type RequestBody = Record<string, unknown>;
|
||||
type RequestHandler = (body: RequestBody)=> Response;
|
||||
|
||||
export interface MockServerRequest {
|
||||
path: string;
|
||||
body: RequestBody;
|
||||
}
|
||||
|
||||
export const jsonResponse = (json: unknown, status = 200) => {
|
||||
return new Response(JSON.stringify(json), { status });
|
||||
};
|
||||
|
||||
const mockOpenAiServer = (baseUrl: string, handlers: Record<string, RequestHandler>) => {
|
||||
const requests: MockServerRequest[] = [];
|
||||
jest.spyOn(shim, 'fetch').mockImplementation(async (url, options) => {
|
||||
const path = url.substring(baseUrl.length);
|
||||
const body: RequestBody = JSON.parse(options.body);
|
||||
requests.push({ path, body });
|
||||
const handler = handlers[path];
|
||||
return handler ? handler(body) : new Response('404 page not found', { status: 404 });
|
||||
});
|
||||
return requests;
|
||||
};
|
||||
|
||||
export default mockOpenAiServer;
|
||||
@@ -15,6 +15,7 @@ export interface ChatStandardMessage extends ChatBaseMessage {
|
||||
content: string;
|
||||
hide?: boolean;
|
||||
toolCalls?: ChatToolCall[];
|
||||
responsesState?: ResponsesReplayState;
|
||||
}
|
||||
|
||||
export interface ChatToolMessage extends ChatBaseMessage {
|
||||
@@ -67,6 +68,11 @@ export interface ChatToolCall {
|
||||
parseError: string|null;
|
||||
}
|
||||
|
||||
export interface ResponsesReplayState {
|
||||
key: string;
|
||||
items: unknown[];
|
||||
}
|
||||
|
||||
export type ChatFinishReason = 'stop' | 'length' | 'tool_calls' | 'other';
|
||||
|
||||
export interface ChatResult {
|
||||
@@ -79,6 +85,7 @@ export interface ChatResult {
|
||||
// Model reasoning, from a dedicated provider field or extracted from inline
|
||||
// markers. Kept separate so it doesn't pollute the assistant reply.
|
||||
reasoningText?: string;
|
||||
responsesState?: ResponsesReplayState;
|
||||
// Joplin Cloud degradation / budget signals. Populated only by the
|
||||
// joplin-cloud provider; other providers leave them undefined. Consumed
|
||||
// internally to drive the aiStatus Redux slice — plugins receive only
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
const logger = Logger.create('ai/parseToolArguments');
|
||||
|
||||
const describeJsonParseFailure = (rawContent: string) => {
|
||||
const parseError = ['Failed to parse JSON.'];
|
||||
|
||||
// With certain providers (e.g. Joplin Cloud), long messages are truncated. Include this information in the error message so that
|
||||
// the model knows to retry with a shorter message:
|
||||
const suggestedRetryLengthLimit = 1000;
|
||||
if (rawContent.startsWith('{') && rawContent.length > suggestedRetryLengthLimit) {
|
||||
parseError.push(`It's likely that the tool call JSON is too long. Please try again with a message shorter than ${suggestedRetryLengthLimit} characters.`);
|
||||
}
|
||||
|
||||
return parseError.join(' ');
|
||||
};
|
||||
|
||||
const parseToolArguments = (argumentString: string) => {
|
||||
try {
|
||||
const args: Record<string, unknown> = JSON.parse(argumentString);
|
||||
return { args, parseError: null as string|null };
|
||||
} catch (error) {
|
||||
const parseError = describeJsonParseFailure(argumentString);
|
||||
logger.error('JSON parse failed', error, parseError);
|
||||
return { args: {}, parseError };
|
||||
}
|
||||
};
|
||||
|
||||
export default parseToolArguments;
|
||||
@@ -44,7 +44,7 @@
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "6.1.2",
|
||||
"@fortawesome/free-solid-svg-icons": "6.1.2",
|
||||
"@fortawesome/react-fontawesome": "3.3.1",
|
||||
"@fortawesome/react-fontawesome": "3.4.0",
|
||||
"@joplin/lib": "~3.7",
|
||||
"async-mutex": "0.5.0",
|
||||
"pdfjs-dist": "2.16.105",
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"jest": "29.7.0",
|
||||
"jest-expect-message": "1.1.3",
|
||||
"jsdom": "26.1.0",
|
||||
"node-mocks-http": "1.17.2",
|
||||
"node-mocks-http": "1.18.1",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1365,7 +1365,7 @@ msgid ""
|
||||
"Cannot share encrypted notebook with recipient %s because they have not enabled end-to-end "
|
||||
"encryption. They may do so from the screen Configuration > Encryption."
|
||||
msgstr ""
|
||||
"Ne eblas kunhavigi ĉifritan notlibron kun ricevonto %s, ĉar tiu ne ebligis ĉifradon de fino al "
|
||||
"Ne eblas kunhavigi ĉifritan notlibron kun ricevonto %s, ĉar tiu ne ŝaltis ĉifradon de fino al "
|
||||
"fino. Tion eblas fari en la ekrano Agordo > Ĉifrado."
|
||||
|
||||
#: packages/app-mobile/components/NoteEditor/SearchPanel.tsx:340
|
||||
@@ -1437,7 +1437,7 @@ msgstr "Kontroli ĝisdatigojn"
|
||||
|
||||
#: packages/app-desktop/gui/MenuBar.tsx:598 packages/app-desktop/gui/MenuBar.tsx:923
|
||||
msgid "Check for updates..."
|
||||
msgstr "Kontrolu ĝisdatigojn..."
|
||||
msgstr "Kontroli ĝisdatigojn..."
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx:357
|
||||
#: packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx:488
|
||||
@@ -1504,7 +1504,7 @@ msgid ""
|
||||
"Click \"%s\" to restore the note. It will be copied in the notebook named \"%s\". The current "
|
||||
"version of the note will not be replaced or modified."
|
||||
msgstr ""
|
||||
"Alklaku \"%s\" por restarigi la noton. Ĝi estos kopiita en la notlibron nomatan \"%s\". La nuna "
|
||||
"Alklaku \"%s\" por restaŭri la noton. Ĝi estos kopiita en la notlibron nomatan \"%s\". La nuna "
|
||||
"versio de la noto ne estos anstataŭigita aŭ modifita."
|
||||
|
||||
#: packages/app-mobile/components/voiceTyping/AudioRecordingBanner.tsx:239
|
||||
@@ -2441,7 +2441,7 @@ msgstr "Malŝalti la servon Web Clipper"
|
||||
#: packages/app-mobile/components/screens/encryption-config.tsx:384
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1845
|
||||
msgid "Disabled"
|
||||
msgstr "Malebligita"
|
||||
msgstr "Malŝaltita"
|
||||
|
||||
#: packages/app-mobile/components/screens/encryption-config.tsx:353
|
||||
msgid "Disabled keys"
|
||||
@@ -2539,7 +2539,7 @@ msgid ""
|
||||
"data! To enable encryption, please enter your password below."
|
||||
msgstr ""
|
||||
"Ne perdu la pasvorton, ĉar pro sekurecaj kialoj ĉi tio estos la *nura* maniero malĉifri la "
|
||||
"datumojn! Por ebligi ĉifradon, bonvolu enigi vian pasvorton sube."
|
||||
"datumojn! Por ŝalti ĉifradon, bonvolu enigi vian pasvorton sube."
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:2499
|
||||
msgid "Document scanner: Title template"
|
||||
@@ -2659,7 +2659,7 @@ msgstr "Dropbox"
|
||||
|
||||
#: packages/app-desktop/gui/Root.tsx:164
|
||||
msgid "Dropbox Login"
|
||||
msgstr "Dropbox-salutnomo"
|
||||
msgstr "Ensaluto al Dropbox"
|
||||
|
||||
#: packages/app-desktop/gui/NoteListHeader/utils/getColumnTitle.ts:14
|
||||
msgid "Due"
|
||||
@@ -2839,7 +2839,7 @@ msgstr "Malplenigi rubujon"
|
||||
#: packages/app-mobile/components/biometrics/BiometricPopup.tsx:87
|
||||
#: packages/app-mobile/components/screens/encryption-config.tsx:228
|
||||
msgid "Enable"
|
||||
msgstr "Ebligi"
|
||||
msgstr "Ŝalti"
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:175
|
||||
#, python-format
|
||||
@@ -2903,7 +2903,7 @@ msgstr "Permesi file://-URL-ojn por bildoj kaj videoj"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1499
|
||||
msgid "Enable footnotes"
|
||||
msgstr "Ebligi piednotojn"
|
||||
msgstr "Ŝalti piednotojn"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1491
|
||||
msgid "Enable Fountain syntax support"
|
||||
@@ -2943,7 +2943,7 @@ msgstr "Ŝalti la plurtabelan Markdown-etendaĵon"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:2144
|
||||
msgid "Enable note history"
|
||||
msgstr "Ebligi nothistorion"
|
||||
msgstr "Ŝalti nothistorion"
|
||||
|
||||
#: packages/lib/models/Setting.ts:1415
|
||||
msgid "Enable or disable plugins"
|
||||
@@ -2960,7 +2960,7 @@ msgstr "Ŝalti subtenon por kromprogramoj"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1486
|
||||
msgid "Enable soft breaks"
|
||||
msgstr "Ŝalti molajn linisalton"
|
||||
msgstr "Ŝalti molajn linisaltojn"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1927
|
||||
msgid "Enable spell checking in Markdown editor"
|
||||
@@ -2995,7 +2995,7 @@ msgstr "Ŝalti la servon Web Clipper"
|
||||
#: packages/app-mobile/components/screens/ConfigScreen/plugins/PluginInfoModal.tsx:93
|
||||
#: packages/app-mobile/components/screens/encryption-config.tsx:384
|
||||
msgid "Enabled"
|
||||
msgstr "Ebligita"
|
||||
msgstr "Ŝaltita"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1127
|
||||
msgid "Enables Markdown list continuation, auto-closing HTML tags, and other markup autocompletions."
|
||||
@@ -3183,7 +3183,7 @@ msgstr "Evernote-eksportdosieroj (dosierujo, kiel Markdown)"
|
||||
|
||||
#: packages/app-cli/app/command-exit.ts:11
|
||||
msgid "Exits the application."
|
||||
msgstr "Eliri la aplikaĵon."
|
||||
msgstr "Eliras el la aplikaĵo."
|
||||
|
||||
#: packages/app-mobile/components/side-menu-content.tsx:219
|
||||
#, python-format
|
||||
@@ -3455,7 +3455,7 @@ msgstr "Trovita: %d."
|
||||
#: packages/app-mobile/utils/getVersionInfoText.ts:47
|
||||
#, python-format
|
||||
msgid "FTS enabled: %d"
|
||||
msgstr "FTS ebligita: %d"
|
||||
msgstr "FTS ŝaltita: %d"
|
||||
|
||||
#: packages/app-desktop/checkForUpdates.ts:116
|
||||
msgid "Full changelog"
|
||||
@@ -3620,7 +3620,7 @@ msgstr "Kaŝas averton"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.ts:14
|
||||
msgid "Highlight"
|
||||
msgstr "Emfazi"
|
||||
msgstr "Elstarigi"
|
||||
|
||||
#: packages/server/src/services/MustacheService.ts:149
|
||||
#: packages/server/src/services/MustacheService.ts:282
|
||||
@@ -4015,12 +4015,12 @@ msgstr "Nevalida %s: %s."
|
||||
#: packages/app-cli/app/cli-utils.ts:199
|
||||
#, python-format
|
||||
msgid "Invalid answer: %s"
|
||||
msgstr "Malvalida respondo: %s"
|
||||
msgstr "Nevalida respondo: %s"
|
||||
|
||||
#: packages/app-cli/app/command-tag.ts:94
|
||||
#, python-format
|
||||
msgid "Invalid command: \"%s\""
|
||||
msgstr "Malvalida komando: \"%s\""
|
||||
msgstr "Nevalida komando: \"%s\""
|
||||
|
||||
#: packages/app-desktop/gui/NotePropertiesDialog.tsx:364
|
||||
msgid "Invalid format. E.g.: 48.8581372, 2.2926735"
|
||||
@@ -4275,7 +4275,7 @@ msgstr "Ŝlosiloj kiuj bezonas ĝisdatigon"
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/WhiteboardSurface.tsx:494
|
||||
#: packages/editor/ProseMirror/plugins/imagePlugin.ts:265
|
||||
msgid "Label"
|
||||
msgstr "Etikedo"
|
||||
msgstr "Surskribo"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1885
|
||||
msgid "Landscape"
|
||||
@@ -4576,7 +4576,7 @@ msgstr "Markdown-redaktilo"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1968
|
||||
msgid "Markdown editor: Highlight active line"
|
||||
msgstr "Markdown-redaktilo: emfazi aktivan linion"
|
||||
msgstr "Markdown-redaktilo: elstarigi aktivan linion"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1947
|
||||
msgid "Markdown editor: Interactive table editing"
|
||||
@@ -4584,7 +4584,7 @@ msgstr "Markdown-redaktilo: interaga tabelredaktado"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1957
|
||||
msgid "Markdown editor: Render images"
|
||||
msgstr "Markdown-redaktilo: bildigi bildojn"
|
||||
msgstr "Markdown-redaktilo: montri bildojn"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:1937
|
||||
msgid "Markdown editor: Render markup in editor"
|
||||
@@ -5314,7 +5314,7 @@ msgstr "OCR: Vakigi kaŝmemoron kaj reelŝuti lingvajn datumdosierojn"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:573
|
||||
msgid "OCR: Enable optical character recognition"
|
||||
msgstr "OCR: Ebligi optikan signorekonon"
|
||||
msgstr "OCR: Ŝalti optikan signorekonon"
|
||||
|
||||
#: packages/lib/models/settings/builtInMetadata.ts:613
|
||||
msgid "OCR: Language data URL or path"
|
||||
@@ -5391,7 +5391,7 @@ msgstr "OneDrive"
|
||||
|
||||
#: packages/app-desktop/gui/Root.tsx:163
|
||||
msgid "OneDrive Login"
|
||||
msgstr "OneDrive-salutnomo"
|
||||
msgstr "Ensaluto al OneDrive"
|
||||
|
||||
#: packages/lib/services/interop/InteropService.ts:159
|
||||
msgid "OneNote Notebook"
|
||||
@@ -6187,11 +6187,11 @@ msgstr "Forigi %s el kunhavigo"
|
||||
#: packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx:164
|
||||
#, python-format
|
||||
msgid "Remove tag \"%s\" from all notes?"
|
||||
msgstr "Forigi etikedon \"%s\" el ĉiuj notoj?"
|
||||
msgstr "Ĉu forigi etikedon \"%s\" el ĉiuj notoj?"
|
||||
|
||||
#: packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx:169
|
||||
msgid "Remove this search from the sidebar?"
|
||||
msgstr "Forigi ĉi tiun serĉon el la flankbreto?"
|
||||
msgstr "Ĉu forigi ĉi tiun serĉon el la flankbreto?"
|
||||
|
||||
#: packages/lib/services/ai/tools/buildEditorTools.ts:51
|
||||
#, python-format
|
||||
@@ -6379,11 +6379,11 @@ msgstr "Restarigi defaŭltojn"
|
||||
|
||||
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreNote.ts:10
|
||||
msgid "Restore note"
|
||||
msgstr "Restarigi noton"
|
||||
msgstr "Restaŭri noton"
|
||||
|
||||
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreFolder.ts:9
|
||||
msgid "Restore notebook"
|
||||
msgstr "Restarigi notlibron"
|
||||
msgstr "Restaŭri notlibron"
|
||||
|
||||
#: packages/app-cli/app/command-restore.ts:12
|
||||
msgid "Restore the items matching <pattern> from the trash."
|
||||
@@ -6391,7 +6391,7 @@ msgstr "Restarigas la erojn kongruantajn kun <pattern> el la rubujo."
|
||||
|
||||
#: packages/lib/services/trash/index.ts:88
|
||||
msgid "Restored items"
|
||||
msgstr "Restarigitaj eroj"
|
||||
msgstr "Restaŭritaj eroj"
|
||||
|
||||
#: packages/lib/services/RevisionService.ts:264
|
||||
msgid "Restored Notes"
|
||||
@@ -7229,7 +7229,7 @@ msgstr "Ŝanĝi al antaŭa fotilo"
|
||||
|
||||
#: packages/app-desktop/gui/utils/NoteListUtils.ts:89
|
||||
msgid "Switch to note type"
|
||||
msgstr "Ŝalti al nota tipo"
|
||||
msgstr "Ŝanĝi al nota tipo"
|
||||
|
||||
#: packages/app-desktop/commands/switchProfile1.ts:7 packages/app-desktop/commands/switchProfile2.ts:7
|
||||
#: packages/app-desktop/commands/switchProfile3.ts:7
|
||||
@@ -7252,7 +7252,7 @@ msgstr "Ŝanĝi al la nova redaktilo"
|
||||
|
||||
#: packages/app-desktop/gui/utils/NoteListUtils.ts:98
|
||||
msgid "Switch to to-do type"
|
||||
msgstr "Ŝalti al taska tipo"
|
||||
msgstr "Ŝanĝi al taska tipo"
|
||||
|
||||
#: packages/app-desktop/gui/ChatPanel/ChatPanel.tsx:152
|
||||
#, python-format
|
||||
@@ -8153,7 +8153,7 @@ msgstr "Por forigi etikedon, forigu ĝin de la asociitaj notoj."
|
||||
#: packages/lib/services/ReportService.ts:367
|
||||
#, python-format
|
||||
msgid "To delete: %d"
|
||||
msgstr "Forigi: %d"
|
||||
msgstr "Forigotaj: %d"
|
||||
|
||||
#: packages/app-cli/app/command-help.ts:82
|
||||
msgid "To enter command line mode, press \":\""
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,7 @@
|
||||
---
|
||||
forum_url: https://discourse.joplinapp.org/t/51403
|
||||
---
|
||||
|
||||
# A full security review of Joplin Server and Joplin Cloud
|
||||
|
||||
October 2026 has been nicknamed "Black October": within weeks, critical vulnerabilities were disclosed in widely used open source software such as PHP, Node.js and GitLab, many of them found with the help of AI and exploited within a day. The same tools that help defenders find bugs now help attackers find them, and Joplin's code is public. The responsible thing to do was to get there first.
|
||||
|
||||
@@ -11552,13 +11552,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@fortawesome/react-fontawesome@npm:3.3.1":
|
||||
version: 3.3.1
|
||||
resolution: "@fortawesome/react-fontawesome@npm:3.3.1"
|
||||
"@fortawesome/react-fontawesome@npm:3.4.0":
|
||||
version: 3.4.0
|
||||
resolution: "@fortawesome/react-fontawesome@npm:3.4.0"
|
||||
peerDependencies:
|
||||
"@fortawesome/fontawesome-svg-core": ~6 || ~7
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
checksum: 10/e099a5721099f1cf59d61fa21ce0ad6473be782470781574aa3c21136263f44d067e15dae4a4e273876ca3b3f16df6a099f93a4ed9c0a717223dffa66bb57da7
|
||||
checksum: 10/64e57e04a7cd98352503edc8fa890420e75d8953262363579388b718656716c10ef2652c62af76edda4d1d1b35205c80ba68af7dc7b32ee4bf0d4c7093afc69e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -13058,7 +13058,7 @@ __metadata:
|
||||
dependencies:
|
||||
"@fortawesome/fontawesome-svg-core": "npm:6.1.2"
|
||||
"@fortawesome/free-solid-svg-icons": "npm:6.1.2"
|
||||
"@fortawesome/react-fontawesome": "npm:3.3.1"
|
||||
"@fortawesome/react-fontawesome": "npm:3.4.0"
|
||||
"@joplin/lib": "npm:~3.7"
|
||||
"@types/jest": "npm:29.5.14"
|
||||
"@types/pdfjs-dist": "npm:2.10.378"
|
||||
@@ -13224,7 +13224,7 @@ __metadata:
|
||||
markdown-it: "npm:13.0.2"
|
||||
mustache: "npm:4.2.0"
|
||||
node-cron: "npm:4.5.0"
|
||||
node-mocks-http: "npm:1.17.2"
|
||||
node-mocks-http: "npm:1.18.1"
|
||||
nodemailer: "npm:6.10.1"
|
||||
nodemon: "npm:3.1.14"
|
||||
otplib: "npm:12.0.1"
|
||||
@@ -44584,9 +44584,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-mocks-http@npm:1.17.2":
|
||||
version: 1.17.2
|
||||
resolution: "node-mocks-http@npm:1.17.2"
|
||||
"node-mocks-http@npm:1.18.1":
|
||||
version: 1.18.1
|
||||
resolution: "node-mocks-http@npm:1.18.1"
|
||||
dependencies:
|
||||
accepts: "npm:^1.3.7"
|
||||
content-disposition: "npm:^0.5.3"
|
||||
@@ -44595,7 +44595,6 @@ __metadata:
|
||||
merge-descriptors: "npm:^1.0.1"
|
||||
methods: "npm:^1.1.2"
|
||||
mime: "npm:^1.3.4"
|
||||
parseurl: "npm:^1.3.3"
|
||||
range-parser: "npm:^1.2.0"
|
||||
type-is: "npm:^1.6.18"
|
||||
peerDependencies:
|
||||
@@ -44606,7 +44605,7 @@ __metadata:
|
||||
optional: true
|
||||
"@types/node":
|
||||
optional: true
|
||||
checksum: 10/3ab97b5a7b2dba0032fc5090983e4ca4be0597f1c645b34dff99d465feb17ef1ee7ea4829a9b4303495399c1ac0f12f8964b878e0dda43cda798bb97254ad571
|
||||
checksum: 10/7a7062de9b16c1ff8e90cd2a824fd74ab4e0c1552837ead87d54e4bc821324c1287a84aee6394d6c229c4fdc64bd2c249ce3d17b9e8e102d0f5142fe8e332958
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -46464,7 +46463,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"parseurl@npm:^1.3.2, parseurl@npm:^1.3.3, parseurl@npm:~1.3.2, parseurl@npm:~1.3.3":
|
||||
"parseurl@npm:^1.3.2, parseurl@npm:~1.3.2, parseurl@npm:~1.3.3":
|
||||
version: 1.3.3
|
||||
resolution: "parseurl@npm:1.3.3"
|
||||
checksum: 10/407cee8e0a3a4c5cd472559bca8b6a45b82c124e9a4703302326e9ab60fc1081442ada4e02628efef1eb16197ddc7f8822f5a91fd7d7c86b51f530aedb17dfa2
|
||||
|
||||
Reference in new issue
Block a user