diff --git a/Cargo.lock b/Cargo.lock index a456882b..ecadaa8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -902,6 +902,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -925,7 +935,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -938,7 +948,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -951,7 +961,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -1122,6 +1132,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "zeroize", +] + [[package]] name = "deflate64" version = "0.1.12" @@ -2655,7 +2675,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ "byteorder", + "dbus-secret-service", "log", + "security-framework 2.11.1", + "security-framework 3.7.0", "windows-sys 0.60.2", "zeroize", ] @@ -3347,18 +3370,16 @@ dependencies = [ "clap", "dirs 5.0.1", "image", - "keyring", "log", "lopdf", "memmap2", "open", "open-pdf-render", + "openaec-accounts-client", "pdfium-render", - "rand 0.8.6", "reqwest 0.12.28", "serde", "serde_json", - "sha2", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", @@ -3375,13 +3396,30 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-updater", "tokio", - "url", - "urlencoding", "whoami", "windows-sys 0.59.0", "zip 2.4.2", ] +[[package]] +name = "openaec-accounts-client" +version = "0.1.0" +source = "git+https://github.com/OpenAEC-Foundation/openaec-accounts-client?tag=v0.1.0#586a9857725d3d633a5c285049e85b5fd7105da2" +dependencies = [ + "base64 0.22.1", + "keyring", + "log", + "rand 0.8.6", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2", + "tauri", + "tauri-plugin-shell", + "tokio", + "url", +] + [[package]] name = "openssl-probe" version = "0.2.1" @@ -4357,7 +4395,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -4376,7 +4414,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni 0.22.4", "log", @@ -4385,7 +4423,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -4501,6 +4539,19 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -4508,7 +4559,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -5033,7 +5084,7 @@ checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4" dependencies = [ "bitflags 2.11.1", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics 0.25.0", "crossbeam-channel", "dbus", @@ -6166,12 +6217,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "urlpattern" version = "0.3.0" diff --git a/open-pdf-studio/js/assistant-mcp-relay.js b/open-pdf-studio/js/assistant-mcp-relay.js index 70ec4e58..deca1585 100644 --- a/open-pdf-studio/js/assistant-mcp-relay.js +++ b/open-pdf-studio/js/assistant-mcp-relay.js @@ -22,10 +22,18 @@ export function registerAssistantSubmit(fn) { _submit = fn; } /** AssistantPanel registers how to read the current conversation. */ export function registerAssistantMessages(fn) { _getMessages = fn; } -/** app_assistant_ask β€” submit a message as if the user typed it in the window. */ -export function submitAssistantMessage(text) { +/** + * app_assistant_ask β€” submit a message as if the user typed it in the window. + * + * `opts` gaat ongewijzigd door naar de send() van het paneel, zodat een + * aanroeper (AI-lint, MCP-client) ook een serveractie kan kiezen in plaats van + * een vrij chatbericht: `{ action: 'summarize' }`, `{ action: 'translate', + * language: 'English' }`, `{ useServer: false }`. Zonder opts is het gedrag + * exact als voorheen. + */ +export function submitAssistantMessage(text, opts) { if (typeof _submit !== 'function') return { ok: false, error: 'assistent nog niet gereed' }; - _submit(String(text ?? '')); + _submit(String(text ?? ''), opts || {}); return { ok: true }; } diff --git a/open-pdf-studio/js/assistant-skills.js b/open-pdf-studio/js/assistant-skills.js index 0838b0d8..421b5653 100644 --- a/open-pdf-studio/js/assistant-skills.js +++ b/open-pdf-studio/js/assistant-skills.js @@ -1,45 +1,44 @@ // OpenAEC-assistent skill set. // // Each skill is a capability the assistant can perform on the open PDF. Clicking -// a skill chip sends `invoke` as a user message; via the provider chain it reaches -// the brain (Claude Code over the MCP relay, or any AI provider) which executes -// it using the app's MCP tools. SKILLS_SYSTEM_PROMPT teaches the brain how. +// a skill chip sends its prompt as a user message; via the provider chain it +// reaches the brain (the OpenAEC AI server, Claude Code over the MCP relay, or +// any AI provider) which executes it. +// +// This module holds only what is language-INDEPENDENT: the id, the icon, and +// how the skill is routed. Every user-visible string (label, hint) and the +// prompt itself live in i18n under `common:assistant.skills.` and +// `common:assistant.prompts.`, so the panel follows the app's language. +// Hardcoding them here is what made the chips Dutch in every locale. +// +// `serverAction` maps a skill onto an action of the OpenAEC AI-server +// (POST /v1/chat: summarize | qa | translate | rewrite | explain | extract | +// chat). Skills WITH a serverAction get the extracted document text attached +// and can be answered by the server. Skills WITHOUT one (draw, detect doors) +// need the app's MCP tools to act on the drawing β€” the server cannot call +// those, so they always go to the relay/Claude. export const ASSISTANT_SKILLS = [ - { - id: 'translate', - icon: '🌐', - label: 'Vertaal', - hint: 'Vertaal de tekst van het document', - invoke: 'Vertaal de tekst van het geopende document. Is het Nederlands, vertaal dan naar het Engels; anders naar het Nederlands. Geef de vertaling overzichtelijk terug.', - }, - { - id: 'summarize', - icon: 'πŸ“', - label: 'Vat samen', - hint: 'Vat het document of de tekening samen', - invoke: 'Vat het geopende document of de tekening bondig samen: waar gaat het over, de belangrijkste onderdelen en eventuele aandachtspunten.', - }, - { - id: 'draw', - icon: '✏️', - label: 'Teken', - hint: 'Teken een element of annotatie op de tekening', - invoke: 'Teken op de tekening: ', - needsInput: true, - }, - { - id: 'detect-doors', - icon: 'πŸšͺ', - label: 'Herken deuren', - hint: 'Detecteer de deuren in de plattegrond en markeer ze', - invoke: 'Bekijk de plattegrond, herken de deuren en markeer elke deur op de tekening met een markering en een korte label.', - }, + { id: 'translate', icon: '🌐', serverAction: 'translate' }, + { id: 'summarize', icon: 'πŸ“', serverAction: 'summarize' }, + { id: 'draw', icon: '✏️', needsInput: true }, + { id: 'detect-doors', icon: 'πŸšͺ' }, ]; -export const SKILLS_SYSTEM_PROMPT = - 'Je beschikt over een vaardigheden-set en kunt ACTIES uitvoeren op het geopende PDF-document via de MCP-tools van de app:\n' + - '- Vertalen / samenvatten: gebruik app_screenshot_view (width 2000) om de pagina te bekijken en te lezen; geef het resultaat als tekst terug.\n' + - '- Tekenen: gebruik app_create_annotation. Coordinaten zijn paginapunten op 100% zoom; haal de paginamaat op met app_get_viewport_state (pageW/pageH).\n' + - '- Deuren herkennen: doe eerst app_fit_page, maak dan app_screenshot_view (width 2000), herken de deuren visueel en markeer elke deur met app_create_annotation (bijvoorbeeld een box of cloud rond de deur + een textbox-label). Reken screenshot-pixels om naar paginapunten via pageW/pageH.\n' + - 'Antwoord in het Nederlands, bondig en praktisch. Voer gevraagde acties direct uit en meld kort wat je gedaan hebt.'; +/** + * System prompt for the MCP-relay/Claude path, which drives the app's own + * tools. English because the instructions are for the model, not the user; + * the final line pins the ANSWER to the app's language. + * + * @param {string} [responseLanguage] English name of the UI language, e.g. 'Dutch'. + */ +export function skillsSystemPrompt(responseLanguage) { + return ( + 'You have a skill set and can perform ACTIONS on the open PDF document via the app\'s MCP tools:\n' + + '- Translate / summarize: use app_screenshot_view (width 2000) to look at and read the page; return the result as text.\n' + + '- Draw: use app_create_annotation. Coordinates are page points at 100% zoom; get the page size with app_get_viewport_state (pageW/pageH).\n' + + '- Detect doors: first app_fit_page, then app_screenshot_view (width 2000), recognise the doors visually and mark each one with app_create_annotation (for example a box or cloud around the door plus a textbox label). Convert screenshot pixels to page points via pageW/pageH.\n' + + 'Be concise and practical. Carry out requested actions directly and briefly report what you did.' + + (responseLanguage ? `\nAlways write your answer in ${responseLanguage}.` : '') + ); +} diff --git a/open-pdf-studio/js/i18n/locales/en/common.json b/open-pdf-studio/js/i18n/locales/en/common.json index b87167a5..be32a437 100644 --- a/open-pdf-studio/js/i18n/locales/en/common.json +++ b/open-pdf-studio/js/i18n/locales/en/common.json @@ -91,6 +91,10 @@ "minimize": "Minimize", "maximize": "Maximize", "sendFeedback": "Send Feedback", + "openaecSignIn": "Sign in with OpenAEC", + "openaecSigningIn": "Signing in via the browser…", + "openaecPortal": "Open Portal", + "openaecSignOut": "Sign out", "loading": "Loading...", "leftPanel": { "noDocumentOpen": "No document open", @@ -137,5 +141,67 @@ "bcfExportFailed": "BCF export failed.", "bcfImportFailed": "Could not read BCF file.", "bcfInvalidFile": "Invalid or unsupported BCF file.", - "bcfNoTopics": "No topics found in BCF file." + "bcfNoTopics": "No topics found in BCF file.", + "assistant": { + "title": "OpenAEC Assistant", + "greeting": "Hello! I am the **OpenAEC Assistant**. I can: 🌐 translate, πŸ“ summarize, ✏️ draw on the drawing, and πŸšͺ detect doors. Pick a skill below or ask your question.", + "workingIn": "working in", + "viaServer": "via OpenAEC AI server", + "notConnected": "not connected", + "thinking": "Thinking…", + "inputPlaceholder": "Ask something about this PDF…", + "send": "Send", + "close": "Close", + "noAnswer": "No answer received.", + "skills": { + "translate": { + "label": "Translate", + "hint": "Translate the document text" + }, + "summarize": { + "label": "Summarize", + "hint": "Summarize the document or drawing" + }, + "draw": { + "label": "Draw", + "hint": "Draw an element or annotation on the drawing" + }, + "detect-doors": { + "label": "Detect doors", + "hint": "Detect the doors in the floor plan and mark them" + } + }, + "prompts": { + "translate": "Translate the text of the open document and present the translation clearly.", + "summarize": "Summarize the open document or drawing concisely: what it is about, the main parts and any points of attention.", + "draw": "Draw on the drawing: ", + "detect-doors": "Look at the floor plan, identify the doors and mark each one on the drawing with a marker and a short label.", + "explain": "Explain this document in plain language and unpack the jargon.", + "extract": "Extract the key data from this document: names, dates, amounts, addresses and tables.", + "rewrite": "Rewrite the text of this document for better readability." + }, + "errors": { + "insufficientCredits": "⚠️ Out of credits. Top up your balance in the OpenAEC portal.", + "notSignedIn": "⚠️ Not signed in. Sign in with your OpenAEC account, top right.", + "rateLimited": "⚠️ Too many requests in a short time. Try again in a minute.", + "noDocumentText": "⚠️ This document has no text layer, so there is nothing to send.", + "offline": "⚠️ No connection to the AI service.", + "failed": "⚠️ AI call failed.", + "detail": "Detail", + "unknown": "unknown error", + "needsRelay": "⚠️ This skill acts on the drawing itself, which needs a connected MCP client (for example Claude Code). None is connected right now.", + "timeout": "⚠️ The AI service did not respond in time. It may be busy β€” try again in a moment.", + "serverDown": "⚠️ The AI service is temporarily unavailable. Try again shortly; your credits were not charged." + }, + "signInTitle": "Sign in to use the assistant", + "signInSub": "Summarizing, translating and Q&A run on your OpenAEC AI server and are billed to your credits. PDF editing keeps working offline.", + "signIn": "Sign in with OpenAEC", + "signingIn": "Signing in…" + }, + "account": { + "callbackOkTitle": "Signed in to OpenAEC", + "callbackOkBody": "You can close this window and return to Open PDF Studio.", + "callbackCancelledTitle": "Sign-in cancelled", + "callbackCancelledBody": "You can close this window." + } } diff --git a/open-pdf-studio/js/i18n/locales/en/ribbon.json b/open-pdf-studio/js/i18n/locales/en/ribbon.json index f88b673a..8750be82 100644 --- a/open-pdf-studio/js/i18n/locales/en/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/en/ribbon.json @@ -6,6 +6,7 @@ "drawing": "Drawing", "view": "View", "organize": "Edit & Combine PDFs", + "ai": "AI", "help": "Settings", "format": "Format", "arrange": "Arrange", @@ -476,5 +477,29 @@ "contrast": "Contrast", "resetAdjust": "Reset", "resetAdjustHint": "Reset grayscale, brightness and contrast" + }, + "ai": { + "assistant": "AI Assistant", + "openPanel": "Open AI Assistant", + "panel": "Panel", + "document": "Document", + "text": "Text", + "chat": "Chat", + "summarize": "Summarize", + "summarizeDoc": "Summarize document", + "explain": "Explain", + "explainDoc": "Explain this document", + "extract": "Extract", + "extractData": "Extract key data", + "translate": "Translate", + "translateDoc": "Translate document", + "rewrite": "Rewrite", + "rewriteText": "Rewrite for readability", + "ask": "Ask", + "askQuestion": "Ask a question about this document", + "openChat": "Open AI chat", + "signInToUseAI": "Sign in to use AI features", + "signInSub": "PDF editing works offline. AI features (summarize, translate, extract, chat) use your OpenAEC account.", + "recheck": "Check again" } } diff --git a/open-pdf-studio/js/i18n/locales/nl/common.json b/open-pdf-studio/js/i18n/locales/nl/common.json index fe76bac3..e459cdb7 100644 --- a/open-pdf-studio/js/i18n/locales/nl/common.json +++ b/open-pdf-studio/js/i18n/locales/nl/common.json @@ -69,6 +69,10 @@ "previousView": "Vorige weergave", "nextView": "Volgende weergave", "minimize": "Minimaliseren", + "openaecSignIn": "Inloggen bij OpenAEC", + "openaecSigningIn": "Inloggen via de browser…", + "openaecPortal": "Portaal openen", + "openaecSignOut": "Afmelden", "maximize": "Maximaliseren", "sendFeedback": "Feedback versturen", "loading": "Laden...", @@ -137,5 +141,67 @@ "bcfExportFailed": "BCF-export mislukt.", "bcfImportFailed": "BCF-bestand kon niet worden gelezen.", "bcfInvalidFile": "Ongeldig of niet-ondersteund BCF-bestand.", - "bcfNoTopics": "Geen topics gevonden in BCF-bestand." + "bcfNoTopics": "Geen topics gevonden in BCF-bestand.", + "assistant": { + "title": "OpenAEC-assistent", + "greeting": "Hallo! Ik ben de **OpenAEC-assistent**. Ik kan: 🌐 vertalen, πŸ“ samenvatten, ✏️ tekenen op de tekening, en πŸšͺ deuren herkennen. Kies hieronder een vaardigheid of stel je vraag.", + "workingIn": "werkt in", + "viaServer": "via OpenAEC AI-server", + "notConnected": "niet verbonden", + "thinking": "Denken…", + "inputPlaceholder": "Vraag iets over deze PDF…", + "send": "Versturen", + "close": "Sluiten", + "noAnswer": "Geen antwoord ontvangen.", + "skills": { + "translate": { + "label": "Vertaal", + "hint": "Vertaal de tekst van het document" + }, + "summarize": { + "label": "Vat samen", + "hint": "Vat het document of de tekening samen" + }, + "draw": { + "label": "Teken", + "hint": "Teken een element of annotatie op de tekening" + }, + "detect-doors": { + "label": "Herken deuren", + "hint": "Detecteer de deuren in de plattegrond en markeer ze" + } + }, + "prompts": { + "translate": "Vertaal de tekst van het geopende document en geef de vertaling overzichtelijk terug.", + "summarize": "Vat het geopende document of de tekening bondig samen: waar gaat het over, de belangrijkste onderdelen en eventuele aandachtspunten.", + "draw": "Teken op de tekening: ", + "detect-doors": "Bekijk de plattegrond, herken de deuren en markeer elke deur op de tekening met een markering en een korte label.", + "explain": "Leg dit document uit in eenvoudige taal en ontrafel het vakjargon.", + "extract": "Haal de belangrijkste gegevens uit dit document: namen, data, bedragen, adressen en tabellen.", + "rewrite": "Herschrijf de tekst van dit document voor betere leesbaarheid." + }, + "errors": { + "insufficientCredits": "⚠️ Geen credits meer. Vul je saldo aan in de OpenAEC-portal.", + "notSignedIn": "⚠️ Niet aangemeld. Meld je rechtsboven aan met je OpenAEC-account.", + "rateLimited": "⚠️ Te veel aanvragen kort na elkaar. Probeer het over een minuut opnieuw.", + "noDocumentText": "⚠️ Dit document heeft geen tekstlaag, er valt niets mee te sturen.", + "offline": "⚠️ Geen verbinding met de AI-dienst.", + "failed": "⚠️ AI-aanroep mislukt.", + "detail": "Detail", + "unknown": "onbekende fout", + "needsRelay": "⚠️ Deze vaardigheid werkt op de tekening zelf en heeft daarvoor een verbonden MCP-client nodig (bijvoorbeeld Claude Code). Die is nu niet verbonden.", + "timeout": "⚠️ De AI-dienst reageerde niet op tijd. Mogelijk is hij druk β€” probeer het zo nog eens.", + "serverDown": "⚠️ De AI-dienst is tijdelijk niet beschikbaar. Probeer het zo opnieuw; je credits zijn niet belast." + }, + "signInTitle": "Meld je aan om de assistent te gebruiken", + "signInSub": "Samenvatten, vertalen en vragen stellen draaien op je OpenAEC AI-server en gaan van je credits af. PDF-bewerken blijft offline werken.", + "signIn": "Aanmelden met OpenAEC", + "signingIn": "Bezig met aanmelden…" + }, + "account": { + "callbackOkTitle": "Ingelogd bij OpenAEC", + "callbackOkBody": "Je kunt dit venster sluiten en teruggaan naar Open PDF Studio.", + "callbackCancelledTitle": "Inloggen geannuleerd", + "callbackCancelledBody": "Je kunt dit venster sluiten." + } } diff --git a/open-pdf-studio/js/i18n/locales/nl/ribbon.json b/open-pdf-studio/js/i18n/locales/nl/ribbon.json index 0da0c82b..6b5495fb 100644 --- a/open-pdf-studio/js/i18n/locales/nl/ribbon.json +++ b/open-pdf-studio/js/i18n/locales/nl/ribbon.json @@ -6,6 +6,7 @@ "drawing": "Tekenen", "view": "Beeld", "organize": "PDF bewerken & samenvoegen", + "ai": "AI", "help": "Help", "format": "Opmaak", "arrange": "Schikken", @@ -476,5 +477,29 @@ "contrast": "Contrast", "resetAdjust": "Herstellen", "resetAdjustHint": "Grijswaarden, helderheid en contrast terugzetten" + }, + "ai": { + "assistant": "AI-assistent", + "openPanel": "Open de AI-assistent", + "panel": "Paneel", + "document": "Document", + "text": "Tekst", + "chat": "Chat", + "summarize": "Samenvatten", + "summarizeDoc": "Document samenvatten", + "explain": "Uitleggen", + "explainDoc": "Dit document uitleggen", + "extract": "Gegevens", + "extractData": "Belangrijke gegevens ophalen", + "translate": "Vertalen", + "translateDoc": "Document vertalen", + "rewrite": "Herschrijven", + "rewriteText": "Herschrijven voor leesbaarheid", + "ask": "Vragen", + "askQuestion": "Stel een vraag over dit document", + "openChat": "Open de AI-chat", + "signInToUseAI": "Meld je aan om AI-functies te gebruiken", + "signInSub": "PDF-bewerken werkt offline. AI-functies (samenvatten, vertalen, gegevens ophalen, chat) gebruiken je OpenAEC-account.", + "recheck": "Opnieuw controleren" } } diff --git a/open-pdf-studio/js/mcp-bridge.js b/open-pdf-studio/js/mcp-bridge.js index 6ab94517..326f329a 100644 --- a/open-pdf-studio/js/mcp-bridge.js +++ b/open-pdf-studio/js/mcp-bridge.js @@ -1815,24 +1815,25 @@ async function handleSetMeasureScale(params) { return { ok: true, measureScale: { pixelsPerUnit, unit } }; } -/** Ask the assistant's AI (Claude/Anthropic direct) β€” lets an MCP client test - * the assistant end-to-end without the chat UI. Uses the personal key set via - * the πŸ”‘ button. */ +/** Ask the OpenAEC AI-server β€” lets an MCP client test the assistant + * end-to-end without driving the chat UI. + * + * Used to call api.anthropic.com with a personal key from localStorage, set + * via a πŸ”‘ field in the panel. That field is gone (the server handles this + * now, metered against the user's credits), so nothing could set the key any + * more and this tool would always have failed. It now takes the same path as + * the panel: POST /v1/chat over ai_fetch, requiring an OpenAEC sign-in. */ async function handleAiComplete(params) { const prompt = params?.prompt; if (typeof prompt !== 'string' || !prompt) return { ok: false, error: 'missing params.prompt' }; - let key = ''; - try { key = localStorage.getItem('opds-anthropic-key') || ''; } catch { /* no localStorage */ } - // Claude (Anthropic) direct β€” uses the personal key set via the πŸ”‘ button. - if (!key) return { ok: false, error: 'geen Claude-key gezet (πŸ”‘)' }; - const r = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'x-api-key': key, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true' }, - body: JSON.stringify({ model: 'claude-sonnet-4-6', max_tokens: 512, system: params?.system || undefined, messages: [{ role: 'user', content: prompt }] }), - }); - if (!r.ok) { const tx = await r.text().catch(() => ''); return { ok: false, error: `Claude API ${r.status}: ${tx.slice(0, 200)}` }; } - const data = await r.json(); - return { ok: true, via: 'claude-direct', text: data?.content?.[0]?.text || '' }; + const { askAiServer, isSignedIn } = await import('./services/ai-client.js'); + if (!(await isSignedIn())) return { ok: false, error: 'not signed in to OpenAEC' }; + try { + const text = await askAiServer({ action: 'chat', text: prompt }); + return { ok: true, via: 'openaec-ai-server', text }; + } catch (e) { + return { ok: false, error: String(e?.message ?? e), code: e?.code ?? null }; + } } /** Accounts sign-in state β€” deactivated (cloud accounts feature removed from diff --git a/open-pdf-studio/js/services/ai-client.js b/open-pdf-studio/js/services/ai-client.js new file mode 100644 index 00000000..d280cd15 --- /dev/null +++ b/open-pdf-studio/js/services/ai-client.js @@ -0,0 +1,169 @@ +// AI-server client β€” de eigen AI-server van de gebruiker op OpenAEC Accounts. +// +// Contract (server is al gebouwd en geverifieerd): +// POST {accountsApiUrl}/v1/chat met het OIDC-access-token als bearer. +// body: { action, text, question?, language?, file_name, page_count, +// current_page, history, stream, temperature, max_tokens } +// 200 β†’ { content, cached, usage|null } +// 401 β†’ niet aangemeld / token ongeldig +// 402 β†’ geen credits meer (error_code: "INSUFFICIENT_CREDITS") +// 429 β†’ te veel aanvragen +// +// Het access-token komt NOOIT in de webview: de Rust-command `ai_fetch` +// (src-tauri/src/accounts.rs) leest het uit de OS-keyring, zet de bearer-header, +// ververst bij een 401 automatisch en geeft de JSON terug. Wij bouwen hier dus +// alleen het pad + de body β€” geen eigen fetch, geen token in JS. +// +// invoke('ai_fetch', { path: '/v1/chat', method: 'POST', body: {...} }) +// +// LET OP: `ai_fetch`, niet `accounts_fetch`. De AI-sidecar draait op een eigen +// host (`aiApiUrl`), los van de Accounts-API (`accountsApiUrl`) die alleen +// /v1/authorize en /v1/settle serveert. accounts_fetch zou /v1/chat naar de +// verkeerde host sturen. +// +// ai_fetch verwerpt met een STRING bij een niet-2xx-status, in de vorm +// `AI-server returned {status}: {body}` (or 'not signed in' without a token). +// destilleren we hieronder een getypte fout uit. + +import { invoke } from '../core/platform.js'; + +/** Acties die de server accepteert. */ +export const AI_ACTIONS = [ + 'summarize', 'qa', 'translate', 'rewrite', 'explain', 'extract', 'chat', +]; + +const CHAT_PATH = '/v1/chat'; +const DEFAULT_TEMPERATURE = 0.3; +const DEFAULT_MAX_TOKENS = 2048; + +/** + * Getypte fout van de AI-server. `code` is stabiel en bedoeld om op te + * matchen in de UI; `message` blijft het ruwe serverdetail voor de log. + * + * codes: NOT_SIGNED_IN | INSUFFICIENT_CREDITS | RATE_LIMITED | OFFLINE | + * NO_DOCUMENT_TEXT | BAD_REQUEST | SERVER + */ +export class AiServerError extends Error { + constructor(code, message, status = null) { + super(message || code); + this.name = 'AiServerError'; + this.code = code; + this.status = status; + } +} + +/** True zodra er een OpenAEC-sessie is (token in de keyring). */ +export async function isSignedIn() { + try { + return !!(await invoke('accounts_get_user')); + } catch (_) { + return false; + } +} + +/** Ruwe afwijzing van accounts_fetch β†’ getypte AiServerError. */ +function toAiError(err) { + if (err instanceof AiServerError) return err; + const raw = String(err?.message ?? err ?? '').trim(); + const status = Number((raw.match(/(?:Accounts API|AI-server) returned (\d{3})/) || [])[1]) || null; + + // Geen token in de keyring, of de refresh lukte niet β†’ opnieuw aanmelden. + if (/not signed in|no refresh token|token refresh failed|refresh rejected/i.test(raw) || status === 401) { + return new AiServerError('NOT_SIGNED_IN', raw, status); + } + if (status === 402 || /INSUFFICIENT_CREDITS/i.test(raw)) { + return new AiServerError('INSUFFICIENT_CREDITS', raw, status || 402); + } + if (status === 429) return new AiServerError('RATE_LIMITED', raw, 429); + if (/unreachable|failed to fetch|connection|econnrefused|timed out/i.test(raw)) { + return new AiServerError('OFFLINE', raw, status); + } + if (status && status >= 400 && status < 500) return new AiServerError('BAD_REQUEST', raw, status); + return new AiServerError('SERVER', raw || 'onbekende fout', status); +} + +/** + * Lage-niveau-aanroep: geeft de volledige serverrespons terug + * ({ content, cached, usage }). + * + * @param {object} req + * @param {string} req.action summarize|qa|translate|rewrite|explain|extract|chat + * @param {string} req.text PDF-tekst, of het bericht van de gebruiker bij 'chat' + * @param {string} [req.question] verplicht bij action 'qa' + * @param {string} [req.language] verplicht bij action 'translate' + * @param {string} [req.fileName] + * @param {number} [req.pageCount] + * @param {number} [req.currentPage] + * @param {Array<{role:string,content:string}>} [req.history] + * @param {number} [req.temperature] + * @param {number} [req.maxTokens] + */ +export async function chatWithAiServer({ + action = 'chat', + text, + question = null, + language = null, + fileName = null, + pageCount = null, + currentPage = null, + history = null, + responseLanguage = null, + temperature = DEFAULT_TEMPERATURE, + maxTokens = DEFAULT_MAX_TOKENS, +} = {}) { + if (!AI_ACTIONS.includes(action)) { + throw new AiServerError('BAD_REQUEST', `onbekende actie: ${action}`); + } + const body = String(text ?? '').trim(); + if (!body) throw new AiServerError('NO_DOCUMENT_TEXT', 'geen tekst om te versturen'); + if (action === 'qa' && !String(question ?? '').trim()) { + throw new AiServerError('BAD_REQUEST', "action 'qa' vereist een question"); + } + if (action === 'translate' && !String(language ?? '').trim()) { + throw new AiServerError('BAD_REQUEST', "action 'translate' vereist een language"); + } + + const payload = { + action, + text: body, + question: question || null, + language: language || null, + file_name: fileName || null, + page_count: Number.isFinite(pageCount) ? pageCount : null, + current_page: Number.isFinite(currentPage) ? currentPage : null, + history: Array.isArray(history) && history.length + ? history.map((m) => ({ role: m.role, content: String(m.content ?? '') })) + : null, + // Engelse naam van de UI-taal ('Dutch', 'German', …). De server schrijft het + // antwoord daarin, ongeacht de taal van het document. Niet te verwarren met + // `language`, de doeltaal van de vertaalactie. + response_language: responseLanguage || null, + // Streaming valt buiten deze taak β€” altijd het volledige antwoord ophalen. + stream: false, + temperature, + max_tokens: maxTokens, + }; + + let data; + try { + data = await invoke('ai_fetch', { path: CHAT_PATH, method: 'POST', body: payload }); + } catch (e) { + throw toAiError(e); + } + // invoke() geeft null buiten Tauri (browser-preview) β€” daar is er geen keyring. + if (data == null) throw new AiServerError('OFFLINE', 'AI-server alleen beschikbaar in de desktop-app'); + // parse_api_result geeft een JSON-string terug wanneer de body geen JSON was. + if (typeof data === 'string') return { content: data, cached: false, usage: null }; + return { content: data.content ?? '', cached: !!data.cached, usage: data.usage ?? null }; +} + +/** + * Zoals chatWithAiServer, maar geeft direct de antwoordtekst terug. + * Gooit een AiServerError bij 401 / 402 / 429 en andere fouten. + */ +export async function askAiServer(req) { + const res = await chatWithAiServer(req); + const content = String(res.content ?? '').trim(); + if (!content) throw new AiServerError('SERVER', 'leeg antwoord van de AI-server'); + return content; +} diff --git a/open-pdf-studio/js/services/document-text.js b/open-pdf-studio/js/services/document-text.js new file mode 100644 index 00000000..fb45c5dd --- /dev/null +++ b/open-pdf-studio/js/services/document-text.js @@ -0,0 +1,96 @@ +// Documenttekst voor de AI-assistent. +// +// Hergebruikt de bestaande Tauri-command `extract_page_text` (path + pageIndex, +// 0-based), precies zoals js/solid/stores/quantitiesStore.js (loadBuiltInText) +// en js/text/text-layer.js (createTextLayerFromRust) dat doen: het resultaat is +// een JSON-string met tekst-spans { text, fontSize, x, y, width }. + +import { invoke, isTauri } from '../core/platform.js'; +import { getActiveDocument } from '../core/state.js'; + +/** + * Harde bovengrens op wat we naar de AI-server sturen. LANGE DOCUMENTEN WORDEN + * AFGEKAPT: we lezen pagina voor pagina vanaf pagina 1 en stoppen zodra deze + * limiet bereikt is β€” de rest van het document gaat NIET mee. Een samenvatting + * van een dik bestek beschrijft dus alleen het begin ervan. + */ +export const MAX_DOC_CHARS = 40000; + +const TRUNCATION_NOTE = '\n\n[…] Document afgekapt β€” alleen het eerste deel is meegestuurd.'; + +/** Spans van één pagina β†’ leesbare tekst (regeleinde bij een nieuwe regel). */ +function spansToText(spans) { + let out = ''; + let prevY = null; + for (const s of spans || []) { + const raw = String(s?.text ?? ''); + if (!raw.trim()) continue; + const y = Number(s?.y); + const lineGap = Math.max(1, Number(s?.fontSize) || 8) * 0.5; + if (prevY !== null && Number.isFinite(y) && Math.abs(y - prevY) > lineGap) out += '\n'; + else if (out) out += ' '; + out += raw.trim(); + if (Number.isFinite(y)) prevY = y; + } + return out; +} + +/** + * Verzamelt de tekst van het actieve document, pagina voor pagina, tot + * `maxChars`. Geeft '' terug als er niets te lezen valt (geen document, geen + * bestandspad, of een pure scan zonder tekstlaag). + * + * @param {object} [opts] + * @param {number} [opts.maxChars=MAX_DOC_CHARS] + * @returns {Promise} + */ +export async function collectActiveDocumentText({ maxChars = MAX_DOC_CHARS } = {}) { + const doc = getActiveDocument(); + if (!doc?.filePath || !isTauri()) return ''; + const pageCount = doc.pdfDoc?.numPages || 1; + + const parts = []; + let total = 0; + let truncated = false; + + for (let page = 1; page <= pageCount; page++) { + let spans; + try { + const json = await invoke('extract_page_text', { path: doc.filePath, pageIndex: page - 1 }); + spans = typeof json === 'string' ? JSON.parse(json) : json; + } catch (e) { + console.warn(`[ai] extract_page_text faalde op pagina ${page}`, e); + continue; + } + const text = spansToText(spans); + if (!text) continue; + + const header = pageCount > 1 ? `--- Pagina ${page} ---\n` : ''; + let chunk = header + text; + if (total + chunk.length > maxChars) { + chunk = chunk.slice(0, Math.max(0, maxChars - total)); + truncated = true; + } + if (chunk) { parts.push(chunk); total += chunk.length; } + if (truncated || total >= maxChars) { truncated = true; break; } + } + + const out = parts.join('\n\n').trim(); + if (!out) return ''; + return truncated ? out + TRUNCATION_NOTE : out; +} + +const DUTCH_MARKERS = /\b(de|het|een|van|en|niet|met|voor|zijn|wordt|worden|tekening|bestek|gebouw|verdieping|afmeting)\b/gi; +const ENGLISH_MARKERS = /\b(the|and|of|with|for|are|is|not|drawing|building|floor|section|detail|scale)\b/gi; + +/** + * Doeltaal voor de 'Vertaal'-skill: Nederlandse tekst β†’ Engels, anders β†’ + * Nederlands (exact wat de skill-omschrijving belooft). De server vereist een + * `language` bij action 'translate', dus die keuze moet hier vallen. + */ +export function guessTranslationTarget(text) { + const sample = String(text || '').slice(0, 4000); + const nl = (sample.match(DUTCH_MARKERS) || []).length; + const en = (sample.match(ENGLISH_MARKERS) || []).length; + return nl >= en ? 'Engels' : 'Nederlands'; +} diff --git a/open-pdf-studio/js/services/signin-page-text.js b/open-pdf-studio/js/services/signin-page-text.js new file mode 100644 index 00000000..80d7f6a3 --- /dev/null +++ b/open-pdf-studio/js/services/signin-page-text.js @@ -0,0 +1,16 @@ +// Teksten voor de afsluitpagina die de browser laat zien na de OAuth-redirect. +// +// Die pagina wordt door Rust geserveerd (accounts.rs, loopback op 53682) en had +// de tekst hardcoded in het Nederlands staan β€” ook bij een Engelse UI. Rust kan +// niet bij i18next, dus geven we de vertaalde strings mee als argument. +import i18next from '../i18n/config.js'; + +export function signInPageText() { + const t = (k) => i18next.t(k, { ns: 'common' }); + return { + okTitle: t('account.callbackOkTitle'), + okBody: t('account.callbackOkBody'), + cancelledTitle: t('account.callbackCancelledTitle'), + cancelledBody: t('account.callbackCancelledBody'), + }; +} diff --git a/open-pdf-studio/js/solid/components/AssistantPanel.jsx b/open-pdf-studio/js/solid/components/AssistantPanel.jsx index 2b7db8a2..2032b830 100644 --- a/open-pdf-studio/js/solid/components/AssistantPanel.jsx +++ b/open-pdf-studio/js/solid/components/AssistantPanel.jsx @@ -1,17 +1,21 @@ -// Assistant β€” floating chat panel (bottom-right) + launcher button. Two ways to -// answer, tried in order: -// 1. Claude (Anthropic) -> direct API with a locally stored API key +// Assistant β€” floating chat panel (bottom-right) + launcher button. Three ways +// to answer, tried in order: +// 1. OpenAEC AI-server -> POST /v1/chat via de Rust-command accounts_fetch +// (token blijft in de keyring); vereist aanmelding // 2. MCP relay -> an external MCP client answers via the app server -import { createSignal, For, Show, createEffect } from 'solid-js'; +import { createSignal, For, Show, createEffect, onMount, onCleanup } from 'solid-js'; import { registerAssistantSubmit, registerAssistantMessages, enqueueAssistantQuestion, relayClientActive } from '../../assistant-mcp-relay.js'; -import { ASSISTANT_SKILLS, SKILLS_SYSTEM_PROMPT } from '../../assistant-skills.js'; +import { ASSISTANT_SKILLS, skillsSystemPrompt } from '../../assistant-skills.js'; import { getActiveDocument } from '../../core/state.js'; +import { askAiServer, AiServerError } from '../../services/ai-client.js'; +// Aanmeldstatus komt uit DEZELFDE store als de titelbalk. Had het paneel een +// eigen kopie (een lokaal signaal, ververst bij mount/openen), dan merkte het +// niet dat je zojuist via de titelbalk was ingelogd β€” twee bronnen van waarheid +// die uit elkaar liepen. +import { openaecUser, openaecSignIn, openaecLoadUser } from '../stores/openaecStore.js'; +import { collectActiveDocumentText, guessTranslationTarget } from '../../services/document-text.js'; import { useTranslation } from '../../i18n/useTranslation.js'; - -const GREETING = - 'Hallo! Ik ben de **OpenAEC-assistent**. Ik kan: 🌐 vertalen, πŸ“ samenvatten, ✏️ tekenen op de tekening, en πŸšͺ deuren herkennen. Kies hieronder een vaardigheid of stel je vraag.'; -const ANTHROPIC_KEY_LS = 'opds-anthropic-key'; -const CLAUDE_MODEL = 'claude-sonnet-4-6'; +import { LANGUAGES } from '../../i18n/config.js'; // Minimal markdown-lite rendering (bold, inline code, line breaks). The AI text // is HTML-escaped first so it can never inject markup. @@ -26,80 +30,152 @@ function renderContent(text) { .replace(/\n/g, '
'); } -function describeAiError(err) { +function describeAiError(err, t) { const raw = String(err?.message ?? err ?? '').trim(); - if (/Claude API 401|invalid x-api-key|authentication_error/i.test(raw)) { - return '⚠️ Ongeldige Claude (Anthropic) API-sleutel. Controleer de sleutel via het πŸ”‘-knopje rechtsboven in het paneel.'; + // Getypte fouten van de OpenAEC AI-server eerst β€” die verdienen een + // begrijpelijke boodschap in plaats van een ruwe dump. + const code = err?.code; + if (code === 'INSUFFICIENT_CREDITS' || /INSUFFICIENT_CREDITS|(?:Accounts API|AI-server) returned 402/i.test(raw)) { + return t('assistant.errors.insufficientCredits'); + } + if (code === 'NOT_SIGNED_IN' || /not signed in|no refresh token|(?:Accounts API|AI-server) returned 401/i.test(raw)) { + return t('assistant.errors.notSignedIn'); + } + if (code === 'RATE_LIMITED' || /(?:Accounts API|AI-server) returned 429/i.test(raw)) { + return t('assistant.errors.rateLimited'); + } + if (code === 'NO_DOCUMENT_TEXT') { + return t('assistant.errors.noDocumentText'); } - if (/Claude API 4\d\d|Claude API 5\d\d/i.test(raw)) { - return `⚠️ De Claude-API gaf een fout.\n\n_Detail: ${raw}_`; + if (/unreachable|connection|econn|refused|failed to connect|failed to fetch/i.test(raw)) { + return t('assistant.errors.offline'); } - if (/onbereikbaar|connection|econn|refused|failed to connect|timed out|failed to fetch/i.test(raw)) { - return '⚠️ Geen verbinding met de AI-dienst.'; + // Verbinding kwam tot stand maar de dienst antwoordde niet op tijd, of gaf + // een 5xx (bv. vLLM plat). Dat is iets anders dan "geen internet". + if (/timed out|timeout|operation timed out/i.test(raw)) { + return t('assistant.errors.timeout'); } - return `⚠️ AI-aanroep mislukt.\n\n_Detail: ${raw || 'onbekende fout'}_`; + if (err?.code === 'SERVER' || /returned 5\d{2}/i.test(raw)) { + return t('assistant.errors.serverDown'); + } + return `${t('assistant.errors.failed')}\n\n_${t('assistant.errors.detail')}: ${raw || t('assistant.errors.unknown')}_`; } export default function AssistantPanel() { - const { t } = useTranslation('common'); + const { t, language } = useTranslation('common'); + // English name of the active UI language, for the model's 'answer in X'. + const responseLanguage = () => + LANGUAGES.find((l) => l.code === language())?.englishName || 'English'; const [open, setOpen] = createSignal(false); - const [messages, setMessages] = createSignal([{ role: 'assistant', content: GREETING }]); + const [messages, setMessages] = createSignal([]); const [input, setInput] = createSignal(''); const [loading, setLoading] = createSignal(false); - const readKey = () => { try { return localStorage.getItem(ANTHROPIC_KEY_LS) || ''; } catch (_) { return ''; } }; - const [apiKey, setApiKey] = createSignal(readKey()); - const [showKey, setShowKey] = createSignal(false); - let messagesEnd, inputEl, keyEl; + // OpenAEC-sessie: bepaalt of de eigen AI-server als eerste provider meedoet. + // Reactief afgeleid van de store, dus in- of uitloggen via de titelbalk werkt + // meteen door in dit paneel. + const signedIn = () => !!openaecUser(); + // Luistert er een MCP-client? Als signaal, want de UI hangt ervan af: zonder + // sessie Γ©n zonder client is er geen enkele provider en heeft versturen geen + // zin. relayClientActive() zelf is een gewone functie, dus we pollen 'm licht. + const [relayUp, setRelayUp] = createSignal(relayClientActive()); + // Kan er ΓΌberhaupt iets verstuurd worden? + const canSend = () => signedIn() || relayUp(); + const [signingIn, setSigningIn] = createSignal(false); + + // Aanmelden vanuit het paneel zelf: opent de systeembrowser (accounts.rs) en + // ververst daarna de status, zodat de chips meteen verschijnen. + const doSignIn = async () => { + if (signingIn()) return; + setSigningIn(true); + // Via de store, zodat de titelbalk hetzelfde resultaat ziet. + try { await openaecSignIn(); } + catch (e) { setMessages((m) => [...m, { role: 'assistant', content: describeAiError(e, t) }]); } + finally { setSigningIn(false); } + }; + let messagesEnd, inputEl; const activeDocName = () => getActiveDocument()?.fileName || null; + // Aanmeldstatus verversen: bij mount, telkens als het paneel opengaat en vlak + // voor elke vraag (de gebruiker kan tussendoor in-/uitloggen via de titelbalk). + const refreshSignedIn = async () => { + await openaecLoadUser(); + return signedIn(); + }; + // De begroeting wordt bij mount gezet in plaats van als constante, zodat hij + // de taal volgt die op dat moment actief is. Alleen zolang er nog niets + // gezegd is: een lopend gesprek mag niet opeens van taal wisselen. + createEffect(() => { + const greeting = t('assistant.greeting'); + setMessages((m) => (m.length <= 1 ? [{ role: 'assistant', content: greeting }] : m)); + }); + + onMount(() => { + refreshSignedIn(); + // Goedkope in-memory check (een timestamp-vergelijking), geen IO. + const id = setInterval(() => setRelayUp(relayClientActive()), 3000); + onCleanup(() => clearInterval(id)); + }); + createEffect(() => { if (open()) { refreshSignedIn(); setRelayUp(relayClientActive()); } }); + createEffect(() => { messages(); queueMicrotask(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' })); }); function systemPrompt() { - return 'Je bent de OpenAEC-assistent in Open PDF Studio (een PDF-annotatie-editor). Help de gebruiker met vragen over het geopende PDF-document en algemene taken.\n\n' + SKILLS_SYSTEM_PROMPT; + return 'You are the OpenAEC assistant inside Open PDF Studio (a PDF annotation editor). ' + + 'Help the user with questions about the open PDF document and with general tasks.\n\n' + + skillsSystemPrompt(responseLanguage()); } - function saveKey() { - const v = (keyEl?.value || '').trim(); - try { - if (v) localStorage.setItem(ANTHROPIC_KEY_LS, v); - else localStorage.removeItem(ANTHROPIC_KEY_LS); - } catch (_) { /* private mode β€” ignore */ } - setApiKey(v); - setShowKey(false); - } - - async function send(explicitText) { + /** + * @param {string} [explicitText] bericht (anders het invoerveld) + * @param {object} [opts] + * @param {string} [opts.action] serveractie (summarize/translate/…). Zonder + * actie gaat een getypt bericht als 'chat'. + * @param {boolean} [opts.useServer=true] false voor skills die MCP-tools + * nodig hebben (tekenen, deuren herkennen). + */ + async function send(explicitText, opts = {}) { const text = (typeof explicitText === 'string' ? explicitText : input()).trim(); if (!text || loading()) return; - const key = apiKey(); + const action = opts.action || 'chat'; + const serverAllowed = opts.useServer !== false; setMessages((m) => [...m, { role: 'user', content: text }]); setInput(''); setLoading(true); - // Claude (Anthropic) direct call β€” the default when a personal Anthropic key - // is set via the πŸ”‘ button. - const claudeDirect = async () => { - const msgs = messages().slice(1).map((m) => ({ role: m.role, content: m.content })); - const res = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': key, - 'anthropic-version': '2023-06-01', - 'anthropic-dangerous-direct-browser-access': 'true', - }, - body: JSON.stringify({ model: CLAUDE_MODEL, max_tokens: 1024, system: systemPrompt(), messages: msgs }), - }); - if (!res.ok) { - const tx = await res.text().catch(() => ''); - throw new Error(`Claude API ${res.status}: ${tx.slice(0, 200)}`); + + // OpenAEC AI-server β€” POST /v1/chat via ai_fetch (eigen host, niet de + // Accounts-API; zie js/services/ai-client.js). Bij een skill-actie + // (samenvatten/vertalen) gaat de ECHTE documenttekst mee; voor een getypt + // bericht is `text` het bericht zelf (action 'chat'), zoals het contract wil. + const aiServer = async () => { + const doc = getActiveDocument(); + let payloadText = text; + let language = opts.language || null; + if (action !== 'chat') { + payloadText = await collectActiveDocumentText(); + if (!payloadText) throw new AiServerError('NO_DOCUMENT_TEXT', 'no text layer in this document'); + if (action === 'translate' && !language) language = guessTranslationTarget(payloadText); } - const data = await res.json(); - return data?.content?.[0]?.text || 'Geen antwoord ontvangen.'; + // Geschiedenis zonder de begroeting en zonder het zojuist toegevoegde bericht. + const history = messages().slice(1, -1).map((m) => ({ role: m.role, content: m.content })); + return await askAiServer({ + action, + text: payloadText, + question: action === 'qa' ? text : null, + language, + fileName: doc?.fileName || null, + pageCount: doc?.pdfDoc?.numPages ?? null, + currentPage: doc?.currentPage ?? null, + history: history.length ? history : null, + // De server schrijft het antwoord in de UI-taal, ook als het document + // in een andere taal is. Bij 'translate' negeert de server dit, want + // daar is de doeltaal juist het onderwerp van de vraag. + responseLanguage: responseLanguage(), + }); }; // MCP relay β€” an external MCP client (e.g. Claude Code, with working Claude @@ -114,29 +190,49 @@ export default function AssistantPanel() { return await enqueueAssistantQuestion({ prompt, system: systemPrompt(), docName }); }; - // Provider order. When a Claude Code/Desktop MCP client is connected (it - // polled recently), route to the relay FIRST so it answers instantly β€” no - // Anthropic API, no key. Otherwise: Claude key -> relay (fallback). + // Provider order. De eigen AI-server gaat voorop zodra de gebruiker is + // aangemeld (server-side credits); de MCP-relay is het vangnet en het enige + // pad dat op de tekening kan handelen (tekenen, deuren herkennen). + // Al aangemeld? Dan niet opnieuw checken β€” accounts_get_user kan een + // userinfo-round-trip kosten. Is de sessie intussen verlopen, dan geeft de + // server een 401 en valt de keten alsnog terug op de relay. + const serverReady = serverAllowed && (signedIn() || await refreshSignedIn()); + // De relay telt alleen mee als er ook echt een MCP-client luistert (recent + // gepolld). Deed hij dat onvoorwaardelijk, dan belandde de vraag in een + // wachtrij die niemand leest en bleef er 10 MINUTEN "Denken…" staan voordat + // de timeout toesloeg β€” precies wat een uitgelogde gebruiker te zien kreeg. const relayActive = relayClientActive(); const providers = []; + if (serverReady) providers.push(aiServer); if (relayActive) providers.push(mcpRelay); - if (key) providers.push(claudeDirect); - if (!relayActive) providers.push(mcpRelay); + + // Geen enkele provider beschikbaar: meteen zeggen wat eraan schort in plaats + // van een spinner tonen die nooit iets oplevert. + if (providers.length === 0) { + const why = serverAllowed ? 'assistant.errors.notSignedIn' : 'assistant.errors.needsRelay'; + setMessages((m) => [...m, { role: 'assistant', content: t(why) }]); + setLoading(false); + return; + } let answer = null; - let lastErr = null; + const errors = []; for (const provider of providers) { try { answer = await provider(); break; } - catch (e) { lastErr = e; console.warn('[assistant] provider faalde, volgende proberen:', e?.message ?? e); } + catch (e) { errors.push(e); console.warn('[assistant] provider faalde, volgende proberen:', e?.message ?? e); } } - setMessages((m) => [...m, { role: 'assistant', content: answer == null ? describeAiError(lastErr) : answer }]); + // Faalt alles, meld dan bij voorkeur de fout die de gebruiker kan oplossen + // (credits op / niet aangemeld) in plaats van de fout van de laatste fallback. + const ACTIONABLE = ['INSUFFICIENT_CREDITS', 'NOT_SIGNED_IN', 'RATE_LIMITED', 'NO_DOCUMENT_TEXT']; + const primaryErr = errors.find((e) => ACTIONABLE.includes(e?.code)) ?? errors[errors.length - 1]; + setMessages((m) => [...m, { role: 'assistant', content: answer == null ? describeAiError(primaryErr, t) : answer }]); setLoading(false); } // Expose the assistant to the in-app MCP server: an external MCP client can // drive it (app_assistant_ask) and act as its AI brain (app_assistant_pending // / app_assistant_answer). Registered once when the panel mounts. - registerAssistantSubmit((text) => { setOpen(true); send(text); }); + registerAssistantSubmit((text, opts) => { setOpen(true); send(text, opts || {}); }); registerAssistantMessages(() => messages().map((m) => ({ role: m.role, content: m.content }))); function onKeyDown(e) { @@ -146,48 +242,46 @@ export default function AssistantPanel() { // Skill set: one-click capabilities. Clicking sends the skill's instruction // through the assistant (and thus the relay to the brain), which executes it // via MCP tools. 'draw' needs the user to specify what, so it pre-fills. + // + // Skills MET een serverAction (samenvatten, vertalen) mogen naar de AI-server: + // send() haalt dan de documenttekst op en stuurt die als `text` mee β€” dat was + // de bug waardoor 'Vat samen' nooit de inhoud meestuurde. Skills ZONDER + // serverAction moeten op de tekening handelen via MCP-tools en slaan de server + // over. function runSkill(skill) { - if (skill.needsInput) { setInput(skill.invoke); inputEl?.focus(); } - else send(skill.invoke); + // De prompt komt uit i18n, dus wat de gebruiker in het gesprek ziet staan + // is in zijn eigen taal β€” net als het label op de chip. + const prompt = t(`assistant.prompts.${skill.id}`); + if (skill.needsInput) { setInput(prompt); inputEl?.focus(); return; } + if (skill.serverAction) send(prompt, { action: skill.serverAction }); + else send(prompt, { useServer: false }); } // Subtitle shows the active provider so the user knows where answers come from. - const providerLabel = () => (apiKey() ? 'via Claude' : 'niet verbonden'); + const providerLabel = () => { + if (signedIn()) return t('assistant.viaServer'); + return t('assistant.notConnected'); + }; return ( setOpen(true)}>πŸ’¬ + } >
- ✨ OpenAEC-assistent + ✨ {t('assistant.title')} - {activeDocName() ? `werkt in: ${activeDocName()} · ${providerLabel()}` : providerLabel()} + {activeDocName() ? `${t('assistant.workingIn')}: ${activeDocName()} · ${providerLabel()}` : providerLabel()}
- - +
- -
- { if (e.key === 'Enter') saveKey(); }} - /> - -
-
-
{(msg) => ( @@ -197,31 +291,53 @@ export default function AssistantPanel() { )} -
Denken…
+
{t('assistant.thinking')}
- -
- - {(skill) => } - + {/* Geen provider beschikbaar? Dan geen chips en geen invoerveld, maar + een uitleg met een aanmeldknop. Beter dan knoppen aanbieden die + gegarandeerd op een foutmelding uitlopen. */} +
+ } + > + +
+ + {(skill) => ( + + )} + +
+
+ +
+