Skip to content

Commit c103fcf

Browse files
authored
Add "Open with… [LLM]" buttons (#2794)
* Add new docContet.js util * Add new openLLM action * Swap generic icon for ChatGPT with the official OpenAI logo from PhosphorIcons library * Swap sparkle icon for Claude with a chat bubble icon, since sparkle is used for Kapa * Add prompt localization for the top 10 most used languages * Fix Claude incorrectly building translated prompt * Better fix Claude handling of locales by adding dual English + localized prompt * Ensure only Claude shows dual prompts * Hide descriptions for Open with… buttons * Update icon for Claude * Fix icon position when no description provided * Try to accomodate for both description and no desciption scenarii for icons placement * Revert "Try to accomodate for both description and no desciption scenarii for icons placement" This reverts commit cdb8d76. * Simplify icons alignment fix * Try to fix Claude's issue * Fix query parameter for Claude * Remove dual prompt in Claude now that we fixed the query param * Refactor translation system and add many more locales * Add aiPromptTemplates.js * Add script for translated prompt validation * Added many more languages * Move docs to a README file * Replace docs a better README * Rename prompt validation script
1 parent 166dd76 commit c103fcf

File tree

10 files changed

+510
-70
lines changed

10 files changed

+510
-70
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#!/usr/bin/env node
2+
3+
const fs = require('fs');
4+
const path = require('path');
5+
6+
const PROMPT_FILE = path.join(__dirname, '..', 'src', 'components', 'AiToolbar', 'config', 'aiPromptTemplates.js');
7+
const REQUIRED_PLACEHOLDERS = ['{{url}}'];
8+
9+
function loadPromptMap(filePath) {
10+
const content = fs.readFileSync(filePath, 'utf8');
11+
const match = content.match(/export const aiPromptTemplates = (\{[\s\S]*?\});/);
12+
if (!match) {
13+
throw new Error('Unable to locate aiPromptTemplates export.');
14+
}
15+
// eslint-disable-next-line no-new-func
16+
const factory = new Function(`return (${match[1]});`);
17+
return factory();
18+
}
19+
20+
function main() {
21+
try {
22+
const prompts = loadPromptMap(PROMPT_FILE);
23+
const languages = Object.keys(prompts);
24+
25+
if (languages.length === 0) {
26+
throw new Error('No prompt translations found.');
27+
}
28+
29+
for (const lang of languages) {
30+
const value = prompts[lang];
31+
if (typeof value !== 'string') {
32+
throw new Error(`Prompt for language "${lang}" must be a string.`);
33+
}
34+
35+
const trimmed = value.trim();
36+
if (trimmed.length === 0) {
37+
throw new Error(`Prompt for language "${lang}" is empty.`);
38+
}
39+
40+
if (trimmed !== value) {
41+
console.warn(`Warning: prompt for "${lang}" has leading/trailing whitespace.`);
42+
}
43+
44+
for (const placeholder of REQUIRED_PLACEHOLDERS) {
45+
if (!value.includes(placeholder)) {
46+
throw new Error(`Prompt for "${lang}" is missing placeholder ${placeholder}.`);
47+
}
48+
}
49+
}
50+
51+
console.log(`Validated ${languages.length} prompt translations.`);
52+
} catch (error) {
53+
console.error(error.message);
54+
process.exit(1);
55+
}
56+
}
57+
58+
main();

docusaurus/src/components/AiToolbar/AiToolbar.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ const AiToolbar = () => {
124124
<button
125125
key={action.id}
126126
onClick={() => handleDropdownAction(action)}
127-
className="ai-toolbar__dropdown-item"
127+
className={`ai-toolbar__dropdown-item ${action.description ? '' : 'ai-toolbar__dropdown-item--no-description'}`}
128128
title={action.description}
129129
>
130130
<Icon
@@ -151,4 +151,4 @@ const AiToolbar = () => {
151151
);
152152
};
153153

154-
export default AiToolbar;
154+
export default AiToolbar;
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
id: ai-toolbar-translations
3+
title: Update AI Toolbar Prompts
4+
---
5+
6+
The AI toolbar (ChatGPT and Claude buttons) uses a set of translated prompt strings. If you want to tweak a translation or add a new language, use this guide.
7+
8+
## Location
9+
10+
- Edit `src/components/AiToolbar/config/aiPromptTemplates.js` in this repository.
11+
- Keys are ISO language tags (use lowercase, include region if needed, e.g. `pt-BR`).
12+
- Values are plain strings with placeholders like `{{url}}`.
13+
14+
```js
15+
export const aiPromptTemplates = {
16+
'en': 'Read from {{url}} so I can ask questions about it.',
17+
'fr': 'Lis {{url}} pour que je puisse poser des questions à son sujet.',
18+
//
19+
};
20+
```
21+
22+
## Contribution Rules
23+
24+
1. **Keep placeholders**: `{{url}}` is mandatory so the current page URL is injected.
25+
2. **Preserve meaning**: Translate the English prompt faithfully; keep a neutral tone.
26+
3. **Avoid duplicates**: one entry per language tag.
27+
4. **Use UTF-8 characters** wherever possible; avoid HTML entities.
28+
29+
## Testing Your Translation
30+
31+
1. Override `navigator.language` via DevTools (Chrome Sensors panel or Firefox locale settings).
32+
2. Reload any docs page with the toolbar.
33+
3. Click “Open with ChatGPT/Claude” and confirm your translation appears in the prompt or clipboard.
34+
35+
## Validation Script
36+
37+
Before opening a pull request, run the placeholder check:
38+
39+
```bash
40+
node scripts/validate-prompts.js
41+
# If Node isn’t available, run:
42+
python scripts/validate-prompts.py
43+
```
44+
45+
The script ensures that each translation keeps the required placeholders and isn’t empty.
46+
47+
## Adding a New Language
48+
49+
1. Add your entry in `aiPromptTemplates.js`.
50+
2. Run the validation script.
51+
3. Include a screenshot or clip in your PR showing the translation in action (optional but helpful).
52+
4. Update this doc if you want to note any language-specific tips.
53+
54+
Thanks for helping improve the AI tooling experience! 🙌

docusaurus/src/components/AiToolbar/actions/actionRegistry.js

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,28 @@
11
import { copyMarkdownAction } from './copyMarkdown';
22
import { navigateAction } from './navigate';
3+
import { openLLMAction } from './openLLM';
34

45
// Central registry of all available actions
56
export const actionHandlers = {
67
'copy-markdown': copyMarkdownAction,
7-
'navigate': navigateAction,
8+
navigate: navigateAction,
9+
'open-llm': openLLMAction,
810
};
911

1012
// Main function to execute an action
1113
export const executeAction = async (actionConfig, additionalContext = {}) => {
1214
const handler = actionHandlers[actionConfig.actionType];
13-
15+
1416
if (!handler) {
1517
console.warn(`Unknown action type: ${actionConfig.actionType}`);
1618
return;
1719
}
18-
20+
1921
const context = {
2022
...actionConfig, // url, etc.
2123
...additionalContext, // docId, docPath, updateActionState, closeDropdown, etc.
2224
};
23-
25+
2426
try {
2527
await handler(context);
2628
} catch (error) {
@@ -38,28 +40,28 @@ export const getActionDisplay = (actionId, currentState = 'idle') => {
3840
icon: 'circle-notch',
3941
iconClasses: 'ph-bold spinning',
4042
label: 'Copying...',
41-
className: 'ai-toolbar-button--loading'
43+
className: 'ai-toolbar-button--loading',
4244
};
4345
case 'success':
4446
return {
4547
icon: 'check-circle',
4648
iconClasses: 'ph-fill',
4749
label: 'Copied!',
48-
className: 'ai-toolbar-button--success'
50+
className: 'ai-toolbar-button--success',
4951
};
5052
case 'error':
5153
return {
5254
icon: 'warning-circle',
5355
iconClasses: 'ph-fill',
5456
label: 'Copy failed',
55-
className: 'ai-toolbar-button--error'
57+
className: 'ai-toolbar-button--error',
5658
};
5759
default: // 'idle'
5860
return {
5961
icon: 'copy',
6062
iconClasses: 'ph-bold',
6163
label: 'Copy Markdown',
62-
className: 'ai-toolbar-button--idle'
64+
className: 'ai-toolbar-button--idle',
6365
};
6466
}
6567
default:
@@ -68,7 +70,7 @@ export const getActionDisplay = (actionId, currentState = 'idle') => {
6870
icon: 'question',
6971
iconClasses: 'ph-bold',
7072
label: 'Unknown',
71-
className: 'ai-toolbar-button--idle'
73+
className: 'ai-toolbar-button--idle',
7274
};
7375
}
74-
};
76+
};
Lines changed: 24 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,39 @@
1+
import { resolveDocContext, getRawMarkdownUrl } from '../utils/docContext';
2+
13
export const copyMarkdownAction = async (context) => {
24
const { docId, docPath, updateActionState } = context;
3-
5+
46
try {
5-
updateActionState('copy-markdown', 'loading');
6-
7-
// Helper functions to get current document info from URL (reused from CopyMarkdownButton)
8-
const getCurrentDocId = () => {
9-
if (typeof window === 'undefined') return null;
10-
const path = window.location.pathname;
11-
// Remove leading/trailing slashes and split
12-
const segments = path.replace(/^\/|\/$/g, '').split('/');
13-
// For paths like /cms/api/rest or /cloud/getting-started/intro
14-
if (segments.length >= 2) {
15-
return segments.join('/');
16-
}
17-
return null;
18-
};
19-
20-
const getCurrentDocPath = () => {
21-
if (typeof window === 'undefined') return null;
22-
const path = window.location.pathname;
23-
// Convert URL path to docs file path
24-
const cleanPath = path.replace(/^\/|\/$/g, '');
25-
return cleanPath ? `docs/${cleanPath}.md` : null;
26-
};
27-
28-
// Use props or try to get from current URL
29-
const currentDocId = docId || getCurrentDocId();
30-
const currentDocPath = docPath || getCurrentDocPath();
31-
32-
if (!currentDocId && !currentDocPath) {
7+
if (updateActionState) {
8+
updateActionState('copy-markdown', 'loading');
9+
}
10+
11+
const { docId: resolvedDocId, docPath: resolvedDocPath } = resolveDocContext(docId, docPath);
12+
const markdownUrl = getRawMarkdownUrl({ docId: resolvedDocId, docPath: resolvedDocPath });
13+
14+
if (!markdownUrl) {
3315
throw new Error('Unable to determine document path');
3416
}
3517

36-
// Build the raw markdown URL from GitHub
37-
const baseUrl = 'https://raw.githubusercontent.com/strapi/documentation/main/docusaurus';
38-
const markdownUrl = currentDocPath
39-
? `${baseUrl}/${currentDocPath}`
40-
: `${baseUrl}/docs/${currentDocId}.md`;
41-
42-
// Fetch the raw markdown content
4318
const response = await fetch(markdownUrl);
44-
19+
4520
if (!response.ok) {
4621
throw new Error(`Failed to fetch markdown: ${response.status}`);
4722
}
48-
23+
4924
const markdownContent = await response.text();
50-
51-
// Copy to clipboard
5225
await navigator.clipboard.writeText(markdownContent);
53-
54-
updateActionState('copy-markdown', 'success');
55-
setTimeout(() => updateActionState('copy-markdown', 'idle'), 3000);
56-
26+
27+
if (updateActionState) {
28+
updateActionState('copy-markdown', 'success');
29+
setTimeout(() => updateActionState('copy-markdown', 'idle'), 3000);
30+
}
5731
} catch (error) {
5832
console.error('Error copying markdown:', error);
59-
updateActionState('copy-markdown', 'error');
60-
setTimeout(() => updateActionState('copy-markdown', 'idle'), 3000);
33+
34+
if (updateActionState) {
35+
updateActionState('copy-markdown', 'error');
36+
setTimeout(() => updateActionState('copy-markdown', 'idle'), 3000);
37+
}
6138
}
62-
};
39+
};
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import {
2+
buildPromptFromTemplate,
3+
buildUrlWithPrompt,
4+
selectLocalizedTemplate,
5+
} from '../utils/docContext';
6+
7+
const DEFAULT_PROMPT_TEMPLATE = 'Read from {{url}} so I can ask questions about it.';
8+
9+
const resolveTemplate = (defaultTemplate, selectedTemplate) => {
10+
return selectedTemplate || defaultTemplate || DEFAULT_PROMPT_TEMPLATE;
11+
};
12+
13+
export const openLLMAction = async (context) => {
14+
const {
15+
targetUrl,
16+
promptTemplate = DEFAULT_PROMPT_TEMPLATE,
17+
localizedPromptTemplates = {},
18+
copyPromptToClipboard = false,
19+
promptParam = 'prompt',
20+
openIn = '_blank',
21+
closeDropdown,
22+
} = context;
23+
24+
if (!targetUrl) {
25+
console.error('open-llm action requires a targetUrl');
26+
return;
27+
}
28+
29+
const selectedTemplate = selectLocalizedTemplate(promptTemplate, localizedPromptTemplates);
30+
const template = resolveTemplate(promptTemplate, selectedTemplate);
31+
const prompt = buildPromptFromTemplate(template);
32+
const fullUrl = buildUrlWithPrompt({
33+
targetUrl,
34+
prompt,
35+
promptParam,
36+
});
37+
38+
if (
39+
copyPromptToClipboard &&
40+
typeof navigator !== 'undefined' &&
41+
navigator.clipboard?.writeText
42+
) {
43+
try {
44+
await navigator.clipboard.writeText(prompt);
45+
} catch (error) {
46+
console.warn('Unable to copy prompt to clipboard:', error);
47+
}
48+
}
49+
50+
window.open(fullUrl || targetUrl, openIn);
51+
52+
if (closeDropdown) {
53+
closeDropdown();
54+
}
55+
};

0 commit comments

Comments
 (0)