diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d98884c10..011b5678b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,6 +4,9 @@ https://github.com/MindWorkAI/AI-Studio/blob/main/CONTRIBUTING.md Please also enable "Allow edits by maintainers" for this pull request. An automated check verifies this option together with the required statements below. + +Open this pull request as a draft while you are still working on it, and mark it ready for review +once it is done: that is our signal to take over. The automated check waits until then. --> ## Summary diff --git a/.github/scripts/contribution-gate.sh b/.github/scripts/contribution-gate.sh index 16d56cbf6..58a60819c 100644 --- a/.github/scripts/contribution-gate.sh +++ b/.github/scripts/contribution-gate.sh @@ -187,8 +187,9 @@ check_one() { local failing_since failing_since=$(sed -nE 's/^$/\1/p' <<< "$comment_body" | head -n 1) - # A pull request which a maintainer reopened gets a new grace period. - if [ "$EVENT_ACTION" = "reopened" ]; then + # A pull request which a maintainer reopened, or which its author marked ready for review again, + # gets a new grace period. Otherwise, the period would run on while the pull request is a draft. + if [ "$EVENT_ACTION" = "reopened" ] || [ "$EVENT_ACTION" = "ready_for_review" ]; then failing_since="" fi diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 7eae7d31a..a3281141e 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -5,12 +5,34 @@ on: - main tags: - "v*.*.*" + + # Changes which touch only these files start neither a build nor the quality gate: none of them is + # compiled or shipped, so there is nothing to check. GitHub never applies path filters to tags, so + # a release always builds. The list names each file and folder instead of matching all Markdown + # files: the changelogs below wwwroot are Markdown as well, and the app embeds them. + paths-ignore: &documentation_only + - "README.md" + - "CONTRIBUTING.md" + - "CODE_OF_CONDUCT.md" + - "SECURITY.md" + - "CITATION.cff" + - "**/AGENTS.md" + - "**/CLAUDE.md" + - ".junie/**" + - "documentation/**" + - "media/**" + - "tests/**/*.md" + - ".github/ISSUE_TEMPLATE/**" + - ".github/DISCUSSION_TEMPLATE/**" + - ".github/pull_request_template.md" + - ".github/FUNDING.yml" pull_request: types: - opened - labeled - synchronize - reopened + paths-ignore: *documentation_only concurrency: group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && (github.event.action != 'labeled' || github.event.label.name == 'run-pipeline') && github.event.pull_request.number || github.run_id }} @@ -746,7 +768,8 @@ jobs: # The quality gate. Deliberately without an `if` on build_enabled: a pull request only builds # when somebody sets the run-pipeline label, and a gate which is closed exactly while nobody is # looking is not a gate. It is cheap for the same reason it is unconditional -- one platform, no - # Tauri bundle, no signing, no artifacts. + # Tauri bundle, no signing, no artifacts. Only changes which touch nothing but documentation skip + # it, together with the whole workflow (see paths-ignore at the top): there is nothing to check. # verify: name: Verify diff --git a/.github/workflows/contribution-gate.yml b/.github/workflows/contribution-gate.yml index 9e2cc8db0..b87809e45 100644 --- a/.github/workflows/contribution-gate.yml +++ b/.github/workflows/contribution-gate.yml @@ -7,6 +7,7 @@ on: - reopened - synchronize - ready_for_review + - converted_to_draft schedule: - cron: "17 4 * * *" workflow_dispatch: diff --git a/AGENTS.md b/AGENTS.md index 3c080b1cb..2aaca8a34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,17 @@ single area to its guide instead. A new guide is an `AGENTS.md` plus a `CLAUDE.m `@AGENTS.md`, listed in the table above. Never place one below `app/MindWork AI Studio/wwwroot/` or `app/MindWork AI Studio/Plugins/`: both are embedded into the app and shipped to every user. +## Pull requests + +Every pull request stays a draft while work on it continues. Marking it ready for review tells the core +team that it is finished from its author's side, and that the maintainers may now review, change, and +merge it. This holds for the core team and for external contributors alike. + +- Open pull requests as drafts, also when the developer asks you to open one. +- Leave marking a pull request ready for review to the developer, once they have reviewed the changes. +- When a pull request which is ready for review needs more work from its author, convert it back to a + draft first, with the developer's consent. + ## Working for an external contributor When you work for someone outside the core team, `CONTRIBUTING.md` applies in addition to this file. In diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4fc56983..d19235f8f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,7 @@ Thank you for considering a contribution to AI Studio. This document explains ho - We build AI Studio with AI coding agents, and we recommend that you do the same. You remain responsible for every line you submit. - Before you build a new feature or change something fundamental, ask in a short proposal whether it fits our plans. Small fixes need no proposal. - Fill in the pull request template and tick the required boxes yourself. +- Open your pull request as a draft while you are still working on it, and mark it ready for review once it is done. That is our signal to take over. - Allow edits by maintainers. Pull requests without this option are closed. - We treat pull requests as proposals: we will change yours to fit the product, and we may close it. - You license your contribution under the MIT License. We ship it as part of AI Studio under the project license or under the MIT License. @@ -65,13 +66,14 @@ Bug fixes, small improvements, and corrections to the documentation need no prop - **Fill in the pull request template.** Its required checkboxes are statements you make personally, so tick them yourself. - **Allow edits by maintainers.** We revise pull requests with our own agents and push the changes directly to your branch, so this option is mandatory. GitHub offers it only for forks owned by a personal account. If your fork belongs to an organization, please open your pull request from a personal fork instead. -- An automated check verifies both points. When something is missing, it leaves a comment explaining what to fix. Pull requests which still fail the check after seven days are closed. +- An automated check verifies both points once your pull request is ready for review. When something is missing, it leaves a comment explaining what to fix. Pull requests which still fail the check after seven days are closed. +- **Mark your pull request ready for review when it is done.** Keep it a draft as long as you are still working on it. Once you click "Ready for review", we consider it finished from your side, and we may review, change, and merge it at any time. If you want to change something afterward, convert it back to a draft first, or tell us in a comment. - Keep each pull request to one topic. -- GitHub limits you to three open pull requests at a time; drafts do not count. When you have more in the pipeline, wait until one of them is merged or closed. +- GitHub limits you to three open pull requests at a time, drafts included. When you have more in the pipeline, wait until one of them is merged or closed. ### What happens after you submit -We understand pull requests as proposals. We review each one against our product vision and our codebase, and we usually change it before merging: sometimes a little, sometimes a lot. We do this with our own agents, directly on your branch. When we merge, all commits are squashed into one, and you remain credited as its author or co-author in the git history. +We understand pull requests as proposals. Once yours is ready for review, we review it against our product vision and our codebase, and we usually change it before merging: sometimes a little, sometimes a lot. We do this with our own agents, directly on your branch. When we merge, all commits are squashed into one, and you remain credited as its author or co-author in the git history. Please keep in mind: diff --git a/README.md b/README.md index 17634d986..f58bfe28b 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ Are you new here? [Read here](#what-is-ai-studio) what AI Studio is. ## What is AI Studio? -![MindWork AI Studio - Home](documentation/mindworks_homepage.png) -![MindWork AI Studio - Assistants](documentation/mindworks_assistants_complete.png) +![MindWork AI Studio - Home](documentation/AI_Studio_main.png) +![MindWork AI Studio - Assistants](documentation/mindworks_assistants_complete_4px.png) MindWork AI Studio is a free desktop app for macOS, Windows, and Linux. It provides a unified user interface for interaction with Large Language Models (LLM). AI Studio also offers so-called assistants, where prompting is not necessary. You can think of AI Studio like an email program: you bring your own API key for the LLM of your choice and can then use these AI systems with AI Studio. Whether you want to use Google Gemini, OpenAI GPT-6, or even your own local AI models. diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index ad43719e7..2912666cb 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -4024,6 +4024,48 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T813925103"] = "The followin -- On this computer, the data folder lies inside the configuration folder. A backup of the configuration folder therefore takes the data folder along. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T91194331"] = "On this computer, the data folder lies inside the configuration folder. A backup of the configuration folder therefore takes the data folder along." +-- Local File, requires an embedding provider +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T1317002733"] = "Local File, requires an embedding provider" + +-- Add Local Directory as Data Source +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T1454193397"] = "Add Local Directory as Data Source" + +-- Local File +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T1687345358"] = "Local File" + +-- Mailbox (IMAP), requires an embedding provider +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T2363290424"] = "Mailbox (IMAP), requires an embedding provider" + +-- Mailbox (IMAP), not allowed by your organization +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T308474155"] = "Mailbox (IMAP), not allowed by your organization" + +-- Add Mailbox +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T3173750212"] = "Add Mailbox" + +-- Add Data Source +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T3387511033"] = "Add Data Source" + +-- Add Local File as Data Source +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T3500365052"] = "Add Local File as Data Source" + +-- Local Directory, requires an embedding provider +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T3698039736"] = "Local Directory, requires an embedding provider" + +-- Import Data Source +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T381231449"] = "Import Data Source" + +-- Mailbox (IMAP) +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T3859543867"] = "Mailbox (IMAP)" + +-- Add ERI v1 Data Source +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T590005498"] = "Add ERI v1 Data Source" + +-- External Data (ERI-Server v1) +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T774473996"] = "External Data (ERI-Server v1)" + +-- Local Directory +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T926703547"] = "Local Directory" + -- Yes, please send my data to the external embedding provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T1159107763"] = "Yes, please send my data to the external embedding provider" @@ -4081,15 +4123,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1208397349"] = "Au -- Edit Local Directory Data Source UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1215599168"] = "Edit Local Directory Data Source" --- Local File, requires an embedding provider -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1317002733"] = "Local File, requires an embedding provider" - -- Refresh UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T135637716"] = "Refresh" --- Add Local Directory as Data Source -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1454193397"] = "Add Local Directory as Data Source" - -- Delete UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1469573738"] = "Delete" @@ -4105,9 +4141,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1592527757"] = "Ca -- External (ERI) UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1652430727"] = "External (ERI)" --- Local File -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1687345358"] = "Local File" - -- Delete Data Source UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1849107431"] = "Delete Data Source" @@ -4120,9 +4153,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2146756020"] = "Lo -- Edit ERI v1 Data Source UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T221059217"] = "Edit ERI v1 Data Source" --- Mailbox (IMAP), requires an embedding provider -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2363290424"] = "Mailbox (IMAP), requires an embedding provider" - -- Edit Local File Data Source UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2453292893"] = "Edit Local File Data Source" @@ -4159,12 +4189,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2940206485"] = "Yo -- This data source is managed by your organization. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3031462878"] = "This data source is managed by your organization." --- Mailbox (IMAP), not allowed by your organization -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T308474155"] = "Mailbox (IMAP), not allowed by your organization" - --- Add Mailbox -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3173750212"] = "Add Mailbox" - -- Delete Mailbox UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3264675802"] = "Delete Mailbox" @@ -4174,18 +4198,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3267849393"] = "Ed -- Are you sure you want to delete the data source '{0}' of type '{1}'? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3337072977"] = "Are you sure you want to delete the data source '{0}' of type '{1}'?" --- Add Data Source -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3387511033"] = "Add Data Source" - -- Unknown UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3424652889"] = "Unknown" -- Close UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3448155331"] = "Close" --- Add Local File as Data Source -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3500365052"] = "Add Local File as Data Source" - -- Type UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3512062061"] = "Type" @@ -4201,21 +4219,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3595669127"] = "Ex -- Local data sources refresh when files change. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3687976654"] = "Local data sources refresh when files change." --- Local Directory, requires an embedding provider -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3698039736"] = "Local Directory, requires an embedding provider" - -- Not available UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3706935413"] = "Not available" --- Import Data Source -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T381231449"] = "Import Data Source" - -- Export ERI Data Source UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3831281036"] = "Export ERI Data Source" --- Mailbox (IMAP) -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3859543867"] = "Mailbox (IMAP)" - -- Actions UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3865031940"] = "Actions" @@ -4228,15 +4237,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T4108252513"] = "Wa -- Information UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T4256323669"] = "Information" --- Add ERI v1 Data Source -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T590005498"] = "Add ERI v1 Data Source" - -- Cannot export this ERI data source because no enterprise encryption secret is configured. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T750361472"] = "Cannot export this ERI data source because no enterprise encryption secret is configured." --- External Data (ERI-Server v1) -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T774473996"] = "External Data (ERI-Server v1)" - -- Cannot export this ERI data source because no authentication secret is configured. The issue was: {0} UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T782820095"] = "Cannot export this ERI data source because no authentication secret is configured. The issue was: {0}" @@ -4249,9 +4252,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T854231603"] = "Loc -- The password of this mailbox could not be deleted from the operating system, so the mailbox was kept. The issue was: {0} UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T87980240"] = "The password of this mailbox could not be deleted from the operating system, so the mailbox was kept. The issue was: {0}" --- Local Directory -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T926703547"] = "Local Directory" - -- Import Embedding UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMISSINGEMBEDDINGWARNING::T1681763001"] = "Import Embedding" @@ -5887,9 +5887,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1874060138"] = "Add chat" -- No chats found UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1886517101"] = "No chats found" --- Create Chat -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1939006681"] = "Create Chat" - -- Please name your workspace: UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T201482774"] = "Please name your workspace:" @@ -5899,9 +5896,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2133593288"] = "Are you sure -- Are you sure you want to delete the workspace '{0}'? This will also delete {1} chat(s) in this workspace. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2151341762"] = "Are you sure you want to delete the workspace '{0}'? This will also delete {1} chat(s) in this workspace." --- Are you sure you want to create a another chat? All unsaved changes will be lost. -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2237618267"] = "Are you sure you want to create a another chat? All unsaved changes will be lost." - -- Please enter a chat name. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2301651387"] = "Please enter a chat name." @@ -11191,9 +11185,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2057806005"] = "Enable plugin" -- Plugins UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2222816203"] = "Plugins" +-- {0} Do you want to save the plugin archive to a location of your choice instead? Until you restart AI Studio, the plugin page offers this export instead of sharing. +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2430906247"] = "{0} Do you want to save the plugin archive to a location of your choice instead? Until you restart AI Studio, the plugin page offers this export instead of sharing." + -- The tile '{0}' has been updated. UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2443911707"] = "The tile '{0}' has been updated." +-- Share dialog not available +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2445896850"] = "Share dialog not available" + -- Edit Assistant Plugin UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2477579768"] = "Edit Assistant Plugin" @@ -14125,6 +14125,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2221093487"] = -- Unexpected error: {0} UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2350673880"] = "Unexpected error: {0}" +-- The share dialog of your operating system did not respond. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T254952756"] = "The share dialog of your operating system did not respond." + -- The plugin has no local directory. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T3284289028"] = "The plugin has no local directory." diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index 28d82fb62..04834d572 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -1556,8 +1556,39 @@ public partial class ChatComponent : MSGComponentBase this.unstoredChat = null; } - private async Task StartNewChat(bool useSameWorkspace = false, bool deletePreviousChat = false) + /// + /// Starts a new chat in the given workspace, on behalf of the workspace tree. + /// + /// + /// The chat starts just like one from the button for a new chat in the workspace: the user is + /// asked about unsaved changes, and the chat gets its chat template with the data sources, tools, + /// and example conversation the template asks for. + /// + /// The ID of the workspace. + public Task StartNewChatInWorkspaceAsync(Guid workspaceId) => workspaceId == Guid.Empty + ? Task.CompletedTask + : this.StartNewChat(targetWorkspaceId: workspaceId); + + /// + /// Starts a new chat in place of the open one. + /// + /// + /// Every way to start a new chat leads here: the buttons of the toolbar, a change of the chat + /// template, and the workspace tree. So every new chat gets its chat template with the data + /// sources, tools, and example conversation, its profile, and its provider in the same way, and + /// the user is asked about unsaved changes in the same way. + /// + /// True starts the new chat with a thread right away, in the workspace of the open chat. False starts it outside any workspace, and its thread comes with the first message. + /// Whether to delete the open chat first. + /// The workspace for the new chat, when the workspace tree asks for one. When given, the new chat gets a thread in that workspace, whatever useSameWorkspace says. + private async Task StartNewChat(bool useSameWorkspace = false, bool deletePreviousChat = false, Guid? targetWorkspaceId = null) { + // + // Where the new chat goes is decided right away, when it is asked for. Deleting the open chat + // below takes it out of view, and with it the workspace this component remembers for the header: + // + var workspaceOfNewChat = targetWorkspaceId ?? this.currentWorkspaceId; + // // Want the user to manage the chat storage manually? In that case, we have to ask the user // about possible data loss: @@ -1643,40 +1674,53 @@ public partial class ChatComponent : MSGComponentBase } // - // Reset the chat thread or create a new one: + // Reset the chat thread or create a new one. A target workspace always means a new chat in + // that workspace, no matter what useSameWorkspace says: // - if (!useSameWorkspace) + var startsOutsideWorkspaces = !useSameWorkspace && targetWorkspaceId is null; + if (startsOutsideWorkspaces) { // - // When the user wants to start a new chat outside the current workspace, - // we have to reset the workspace id and the workspace name. Also, we have - // to reset the chat thread: + // When the user wants to start a new chat outside the current workspace, we have to + // reset the chat thread. The workspace id and the workspace name follow below: // this.ChatThread = null; - this.ClearWorkspaceHeaderState(); } else { // - // When the user wants to start a new chat in the same workspace, we have to - // reset the chat thread only. The workspace id and the workspace name remain - // the same. The new chat reaches the disk only once there is something to keep: + // When the user wants to start a new chat in a workspace, the same one or the one the + // workspace tree asked for, we have to reset the chat thread only. The new chat reaches + // the disk only once there is something to keep. The header follows once the parent + // hands the new thread back to us: // - this.ChatThread = this.NewChatThread(string.Empty); + this.ChatThread = this.NewChatThread(string.Empty, workspaceOfNewChat); this.unstoredChat = this.ChatThread; } + // + // The parent hears about the new thread right away, before anything below waits. The thread + // is a parameter of this component, and the parent hands over its own whenever it renders, + // e.g., after it received the click on the workspace tree, or after it showed another + // workspace name. Until the parent knows the new thread, such a render brings back the chat + // being left, while the composer already holds what the chat template asks for. Marking the + // thread as loaded first keeps the parameter update from taking it for another chat being + // opened, which would clear the composer: + // + this.MarkCurrentChatAsLoadedParameter(); + await this.ChatThreadChanged.InvokeAsync(this.ChatThread); + + // Clearing the workspace name renders the parent as well, so it comes only now: + if (startsOutsideWorkspaces) + this.ClearWorkspaceHeaderState(); + this.ComposerState.ApplyTemplate(this.currentChatTemplate); // Now, the chat starts with what its template asks for, and with the chat defaults wherever // that template says nothing: await this.ApplyChatTemplateToolSelectionAsync(); this.ApplyAutomaticDataSourceOptions(); - - // Notify the parent component about the change: await this.SyncForegroundChatAsync(); - this.MarkCurrentChatAsLoadedParameter(); - await this.ChatThreadChanged.InvokeAsync(this.ChatThread); } private async Task MoveChatToWorkspace() @@ -2118,8 +2162,9 @@ public partial class ChatComponent : MSGComponentBase /// a workspace replaces them right afterward with those its chat template asks for. /// /// The name of the thread. + /// The workspace of the thread. Without one, the thread goes to the workspace of the open chat. /// The new thread. - private ChatThread NewChatThread(string name) => new() + private ChatThread NewChatThread(string name, Guid? workspaceId = null) => new() { DataSourceOptions = this.earlyDataSourceOptions, IncludeDateTime = true, @@ -2128,7 +2173,7 @@ public partial class ChatComponent : MSGComponentBase SelectedChatTemplate = this.currentChatTemplate.Id, SelectedToolIds = [..this.selectedToolIds], SystemPrompt = SystemPrompts.DEFAULT, - WorkspaceId = this.currentWorkspaceId, + WorkspaceId = workspaceId ?? this.currentWorkspaceId, ChatId = Guid.NewGuid(), Name = name, Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(block => block.DeepClone()).ToList(), diff --git a/app/MindWork AI Studio/Components/DataSourceAddButtons.razor b/app/MindWork AI Studio/Components/DataSourceAddButtons.razor new file mode 100644 index 000000000..9ac120ee0 --- /dev/null +++ b/app/MindWork AI Studio/Components/DataSourceAddButtons.razor @@ -0,0 +1,29 @@ +@using AIStudio.Settings.DataModel +@using AIStudio.Tools.PluginSystem +@inherits MSGComponentBase + + + + + @T("External Data (ERI-Server v1)") + + @* + Without an embedding provider, the dialogs of the local data sources could never be saved. + They stay listed with the reason, so nobody concludes that AI Studio cannot read local files: + *@ + + @(this.HasEmbeddingProvider ? T("Local Directory") : T("Local Directory, requires an embedding provider")) + + + @(this.HasEmbeddingProvider ? T("Local File") : T("Local File, requires an embedding provider")) + + @if (PreviewFeatures.PRE_MAILBOXES_2026.IsEnabled(this.SettingsManager)) + { + @* Shown with a lock rather than left out, like the locked buttons for adding providers: *@ + + @this.MailboxMenuText + + } + + + \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/DataSourceAddButtons.razor.cs b/app/MindWork AI Studio/Components/DataSourceAddButtons.razor.cs new file mode 100644 index 000000000..65a072a75 --- /dev/null +++ b/app/MindWork AI Studio/Components/DataSourceAddButtons.razor.cs @@ -0,0 +1,209 @@ +using AIStudio.Dialogs; +using AIStudio.Settings; +using AIStudio.Settings.DataModel; +using AIStudio.Tools.PluginSystem; +using AIStudio.Tools.Services; + +using Microsoft.AspNetCore.Components; + +using DialogOptions = AIStudio.Dialogs.DialogOptions; + +namespace AIStudio.Components; + +/// +/// Adds and imports data sources and mailboxes. It is kept apart from DataSourceManagement, so that the +/// data source settings dialog can place these buttons in its footer, where they stay in reach however +/// long the table of data sources grows. The data source panel in the app settings places them below the table. +/// +public partial class DataSourceAddButtons : MSGComponentBase +{ + [Inject] + private DataSourceEmbeddingService DataSourceEmbeddingService { get; init; } = null!; + + [Inject] + private IDialogService DialogService { get; init; } = null!; + + [Inject] + private RustService RustService { get; init; } = null!; + + private bool MayAddMailbox => this.SettingsManager.ConfigurationData.App.AllowUserToAddMailbox; + + /// + /// Whether an embedding provider exists, which every local data source needs. + /// + private bool HasEmbeddingProvider => !EmbeddingProviderSetup.IsMissing(this.SettingsManager); + + /// + /// The rule of the organization comes first: an embedding provider would not make the mailbox possible. + /// + private string MailboxMenuText => !this.MayAddMailbox + ? T("Mailbox (IMAP), not allowed by your organization") + : this.HasEmbeddingProvider + ? T("Mailbox (IMAP)") + : T("Mailbox (IMAP), requires an embedding provider"); + + #region Overrides of ComponentBase + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + this.ApplyFilters([], [ Event.CONFIGURATION_CHANGED ]); + } + + #endregion + + #region Overrides of MSGComponentBase + + protected override Task ProcessIncomingMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default + { + switch (triggeredEvent) + { + // The embedding providers and the rules of the organization decide what the buttons offer: + case Event.CONFIGURATION_CHANGED: + this.StateHasChanged(); + break; + } + + return Task.CompletedTask; + } + + #endregion + + private async Task ImportERIDataSource() + { + var import = await ConfigurationSnippetImportDialog.ShowAsync(this.DialogService, PluginConfigurationObjectType.DATA_SOURCE, T("Import Data Source")); + if (import?.ConfigurationObject is not DataSourceERI_V1 dataSource) + return; + + var eriDialogParameters = new DialogParameters + { + { x => x.IsEditing, false }, + { x => x.IsImport, true }, + { x => x.DataSource, dataSource }, + { x => x.ImportedSecret, import.Secret }, + }; + + var eriDialogReference = await this.DialogService.ShowAsync(T("Add ERI v1 Data Source"), eriDialogParameters, DialogOptions.FULLSCREEN); + var eriDialogResult = await eriDialogReference.Result; + if (eriDialogResult is null || eriDialogResult.Canceled) + return; + + // + // An organization can lock the import while the user is still filling in the form, e.g., + // with a new version of its configuration plugin. Hence, the permission is checked once + // more before the imported data source is stored: + // + if (!this.SettingsManager.ConfigurationData.App.CanImportConfigurationSnippet(PluginConfigurationObjectType.DATA_SOURCE)) + return; + + var eriDataSource = (DataSourceERI_V1)eriDialogResult.Data!; + await this.StoreAddedDataSource(eriDataSource with { Num = this.SettingsManager.ConfigurationData.NextDataSourceNum++ }); + } + + private async Task AddDataSource(DataSourceType type) + { + // The menu offers no local data source without an embedding provider, but the last one may have gone since it was drawn: + if (type is DataSourceType.LOCAL_FILE or DataSourceType.LOCAL_DIRECTORY && !this.HasEmbeddingProvider) + return; + + IDataSource? addedDataSource = null; + switch (type) + { + case DataSourceType.LOCAL_FILE: + var localFileDialogParameters = new DialogParameters + { + { x => x.IsEditing, false }, + }; + + var localFileDialogReference = await this.DialogService.ShowAsync(T("Add Local File as Data Source"), localFileDialogParameters, DialogOptions.FULLSCREEN); + var localFileDialogResult = await localFileDialogReference.Result; + if (localFileDialogResult is null || localFileDialogResult.Canceled) + return; + + var localFile = (DataSourceLocalFile)localFileDialogResult.Data!; + localFile = localFile with { Num = this.SettingsManager.ConfigurationData.NextDataSourceNum++ }; + addedDataSource = localFile; + break; + + case DataSourceType.LOCAL_DIRECTORY: + var localDirectoryDialogParameters = new DialogParameters + { + { x => x.IsEditing, false }, + }; + + var localDirectoryDialogReference = await this.DialogService.ShowAsync(T("Add Local Directory as Data Source"), localDirectoryDialogParameters, DialogOptions.FULLSCREEN); + var localDirectoryDialogResult = await localDirectoryDialogReference.Result; + if (localDirectoryDialogResult is null || localDirectoryDialogResult.Canceled) + return; + + var localDirectory = (DataSourceLocalDirectory)localDirectoryDialogResult.Data!; + localDirectory = localDirectory with { Num = this.SettingsManager.ConfigurationData.NextDataSourceNum++ }; + addedDataSource = localDirectory; + break; + + case DataSourceType.ERI_V1: + var eriDialogParameters = new DialogParameters + { + { x => x.IsEditing, false }, + }; + + var eriDialogReference = await this.DialogService.ShowAsync(T("Add ERI v1 Data Source"), eriDialogParameters, DialogOptions.FULLSCREEN); + var eriDialogResult = await eriDialogReference.Result; + if (eriDialogResult is null || eriDialogResult.Canceled) + return; + + var eriDataSource = (DataSourceERI_V1)eriDialogResult.Data!; + eriDataSource = eriDataSource with { Num = this.SettingsManager.ConfigurationData.NextDataSourceNum++ }; + addedDataSource = eriDataSource; + break; + } + + if(addedDataSource is null) + return; + + await this.StoreAddedDataSource(addedDataSource); + } + + private async Task StoreAddedDataSource(IDataSource addedDataSource) + { + this.SettingsManager.ConfigurationData.DataSources.Add(addedDataSource); + await this.SettingsManager.StoreSettings(); + await this.DataSourceEmbeddingService.QueueDataSourceAsync(addedDataSource); + await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); + } + + private async Task AddMailbox() + { + if (!this.MayAddMailbox || !this.HasEmbeddingProvider) + return; + + var dialogParameters = new DialogParameters + { + { x => x.IsEditing, false }, + }; + + var dialogReference = await this.DialogService.ShowAsync(T("Add Mailbox"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult is null || dialogResult.Canceled) + return; + + var mailbox = (DataSourceMailbox)dialogResult.Data!; + + // + // The dialog refuses to add a mailbox once the organization rules them out, but the rule + // may change while it stores the password. Then the password goes again, as far as possible: + // + if (!this.MayAddMailbox) + { + await this.RustService.DeleteSecret(mailbox, SecretStoreType.DATA_SOURCE); + return; + } + + mailbox = mailbox with { Num = this.SettingsManager.ConfigurationData.NextDataSourceNum++ }; + + this.SettingsManager.ConfigurationData.Mailboxes.Add(mailbox); + await this.SettingsManager.StoreSettings(); + await this.DataSourceEmbeddingService.QueueDataSourceAsync(mailbox); + await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/DataSourceManagement.razor b/app/MindWork AI Studio/Components/DataSourceManagement.razor index 0bc7c8f02..23b3ad424 100644 --- a/app/MindWork AI Studio/Components/DataSourceManagement.razor +++ b/app/MindWork AI Studio/Components/DataSourceManagement.razor @@ -1,6 +1,5 @@ @using AIStudio.Settings @using AIStudio.Settings.DataModel -@using AIStudio.Tools.PluginSystem @inherits MSGComponentBase @@ -125,30 +124,4 @@ @T("No data sources configured yet.") -} - - - - - @T("External Data (ERI-Server v1)") - - @* - Without an embedding provider, the dialogs of the local data sources could never be saved. - They stay listed with the reason, so nobody concludes that AI Studio cannot read local files: - *@ - - @(this.HasEmbeddingProvider ? T("Local Directory") : T("Local Directory, requires an embedding provider")) - - - @(this.HasEmbeddingProvider ? T("Local File") : T("Local File, requires an embedding provider")) - - @if (PreviewFeatures.PRE_MAILBOXES_2026.IsEnabled(this.SettingsManager)) - { - @* Shown with a lock rather than left out, like the locked buttons for adding providers: *@ - - @this.MailboxMenuText - - } - - - \ No newline at end of file +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/DataSourceManagement.razor.cs b/app/MindWork AI Studio/Components/DataSourceManagement.razor.cs index 2e52f73c9..1ddecec32 100644 --- a/app/MindWork AI Studio/Components/DataSourceManagement.razor.cs +++ b/app/MindWork AI Studio/Components/DataSourceManagement.razor.cs @@ -14,7 +14,8 @@ namespace AIStudio.Components; /// /// Manages the configured data sources. Used by the data source settings dialog, which the chat -/// opens, and by the data source panel in the app settings. +/// opens, and by the data source panel in the app settings. Adding and importing a data source is +/// up to DataSourceAddButtons, which each of them places on its own. /// public partial class DataSourceManagement : MSGComponentBase { @@ -38,22 +39,6 @@ public partial class DataSourceManagement : MSGComponentBase private bool AreMailboxesEnabled => PreviewFeatures.PRE_MAILBOXES_2026.IsEnabled(this.SettingsManager); - private bool MayAddMailbox => this.SettingsManager.ConfigurationData.App.AllowUserToAddMailbox; - - /// - /// Whether an embedding provider exists, which every local data source needs. - /// - private bool HasEmbeddingProvider => !EmbeddingProviderSetup.IsMissing(this.SettingsManager); - - /// - /// The rule of the organization comes first: an embedding provider would not make the mailbox possible. - /// - private string MailboxMenuText => !this.MayAddMailbox - ? T("Mailbox (IMAP), not allowed by your organization") - : this.HasEmbeddingProvider - ? T("Mailbox (IMAP)") - : T("Mailbox (IMAP), requires an embedding provider"); - #region Overrides of ComponentBase protected override async Task OnInitializedAsync() @@ -189,109 +174,6 @@ public partial class DataSourceManagement : MSGComponentBase await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } - private async Task ImportERIDataSource() - { - var import = await ConfigurationSnippetImportDialog.ShowAsync(this.DialogService, PluginConfigurationObjectType.DATA_SOURCE, T("Import Data Source")); - if (import?.ConfigurationObject is not DataSourceERI_V1 dataSource) - return; - - var eriDialogParameters = new DialogParameters - { - { x => x.IsEditing, false }, - { x => x.IsImport, true }, - { x => x.DataSource, dataSource }, - { x => x.ImportedSecret, import.Secret }, - }; - - var eriDialogReference = await this.DialogService.ShowAsync(T("Add ERI v1 Data Source"), eriDialogParameters, DialogOptions.FULLSCREEN); - var eriDialogResult = await eriDialogReference.Result; - if (eriDialogResult is null || eriDialogResult.Canceled) - return; - - // - // An organization can lock the import while the user is still filling in the form, e.g., - // with a new version of its configuration plugin. Hence, the permission is checked once - // more before the imported data source is stored: - // - if (!this.SettingsManager.ConfigurationData.App.CanImportConfigurationSnippet(PluginConfigurationObjectType.DATA_SOURCE)) - return; - - var eriDataSource = (DataSourceERI_V1)eriDialogResult.Data!; - await this.StoreAddedDataSource(eriDataSource with { Num = this.SettingsManager.ConfigurationData.NextDataSourceNum++ }); - } - - private async Task AddDataSource(DataSourceType type) - { - // The menu offers no local data source without an embedding provider, but the last one may have gone since it was drawn: - if (type is DataSourceType.LOCAL_FILE or DataSourceType.LOCAL_DIRECTORY && !this.HasEmbeddingProvider) - return; - - IDataSource? addedDataSource = null; - switch (type) - { - case DataSourceType.LOCAL_FILE: - var localFileDialogParameters = new DialogParameters - { - { x => x.IsEditing, false }, - }; - - var localFileDialogReference = await this.DialogService.ShowAsync(T("Add Local File as Data Source"), localFileDialogParameters, DialogOptions.FULLSCREEN); - var localFileDialogResult = await localFileDialogReference.Result; - if (localFileDialogResult is null || localFileDialogResult.Canceled) - return; - - var localFile = (DataSourceLocalFile)localFileDialogResult.Data!; - localFile = localFile with { Num = this.SettingsManager.ConfigurationData.NextDataSourceNum++ }; - addedDataSource = localFile; - break; - - case DataSourceType.LOCAL_DIRECTORY: - var localDirectoryDialogParameters = new DialogParameters - { - { x => x.IsEditing, false }, - }; - - var localDirectoryDialogReference = await this.DialogService.ShowAsync(T("Add Local Directory as Data Source"), localDirectoryDialogParameters, DialogOptions.FULLSCREEN); - var localDirectoryDialogResult = await localDirectoryDialogReference.Result; - if (localDirectoryDialogResult is null || localDirectoryDialogResult.Canceled) - return; - - var localDirectory = (DataSourceLocalDirectory)localDirectoryDialogResult.Data!; - localDirectory = localDirectory with { Num = this.SettingsManager.ConfigurationData.NextDataSourceNum++ }; - addedDataSource = localDirectory; - break; - - case DataSourceType.ERI_V1: - var eriDialogParameters = new DialogParameters - { - { x => x.IsEditing, false }, - }; - - var eriDialogReference = await this.DialogService.ShowAsync(T("Add ERI v1 Data Source"), eriDialogParameters, DialogOptions.FULLSCREEN); - var eriDialogResult = await eriDialogReference.Result; - if (eriDialogResult is null || eriDialogResult.Canceled) - return; - - var eriDataSource = (DataSourceERI_V1)eriDialogResult.Data!; - eriDataSource = eriDataSource with { Num = this.SettingsManager.ConfigurationData.NextDataSourceNum++ }; - addedDataSource = eriDataSource; - break; - } - - if(addedDataSource is null) - return; - - await this.StoreAddedDataSource(addedDataSource); - } - - private async Task StoreAddedDataSource(IDataSource addedDataSource) - { - this.SettingsManager.ConfigurationData.DataSources.Add(addedDataSource); - await this.SettingsManager.StoreSettings(); - await this.DataSourceEmbeddingService.QueueDataSourceAsync(addedDataSource); - await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); - } - private async Task ExportDataSource(IDataSourceBase dataSource) { if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings) @@ -537,41 +419,6 @@ public partial class DataSourceManagement : MSGComponentBase _ => Task.CompletedTask, }; - private async Task AddMailbox() - { - if (!this.MayAddMailbox || !this.HasEmbeddingProvider) - return; - - var dialogParameters = new DialogParameters - { - { x => x.IsEditing, false }, - }; - - var dialogReference = await this.DialogService.ShowAsync(T("Add Mailbox"), dialogParameters, DialogOptions.FULLSCREEN); - var dialogResult = await dialogReference.Result; - if (dialogResult is null || dialogResult.Canceled) - return; - - var mailbox = (DataSourceMailbox)dialogResult.Data!; - - // - // The dialog refuses to add a mailbox once the organization rules them out, but the rule - // may change while it stores the password. Then the password goes again, as far as possible: - // - if (!this.MayAddMailbox) - { - await this.RustService.DeleteSecret(mailbox, SecretStoreType.DATA_SOURCE); - return; - } - - mailbox = mailbox with { Num = this.SettingsManager.ConfigurationData.NextDataSourceNum++ }; - - this.SettingsManager.ConfigurationData.Mailboxes.Add(mailbox); - await this.SettingsManager.StoreSettings(); - await this.DataSourceEmbeddingService.QueueDataSourceAsync(mailbox); - await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); - } - private async Task EditMailbox(DataSourceMailbox mailbox) { if (await MailboxEditing.EditAsync(this.DialogService, this.SettingsManager, this.DataSourceEmbeddingService, mailbox.Id)) diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelDataSources.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelDataSources.razor index 9d0e92f01..fd68f1f32 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelDataSources.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelDataSources.razor @@ -6,5 +6,8 @@ +
+ +
} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Workspaces.razor.cs b/app/MindWork AI Studio/Components/Workspaces.razor.cs index 7099ef654..552e05641 100644 --- a/app/MindWork AI Studio/Components/Workspaces.razor.cs +++ b/app/MindWork AI Studio/Components/Workspaces.razor.cs @@ -33,6 +33,17 @@ public partial class Workspaces : MSGComponentBase [Parameter] public EventCallback CurrentChatThreadChanged { get; set; } + /// + /// Asks the page to start a new chat in the workspace with the given ID. + /// + /// + /// The page hands the request to its chat component. A message on the bus would not do: the bus + /// also serves the chat components of windows which were reloaded long ago, and each of them would + /// start a chat of its own, storing what it still holds on its way. + /// + [Parameter] + public EventCallback StartNewChatRequested { get; set; } + [Parameter] public bool ExpandRootNodes { get; set; } = true; @@ -1040,36 +1051,20 @@ public partial class Workspaces : MSGComponentBase await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread); } + /// + /// Asks the chat component to start a new chat in the given workspace. + /// + /// The directory of the workspace. + /// + /// The chat component starts the chat just like its own button for a new chat in the workspace: + /// it asks about unsaved changes, applies the chat template with its data sources, tools, and + /// example conversation, and stores the chat only once it has content. Until then, the chat is + /// missing from the tree. + /// private async Task AddChatAsync(string workspacePath) { - if (await MessageBus.INSTANCE.SendMessageUseFirstResult(this, Event.HAS_CHAT_UNSAVED_CHANGES)) - { - var dialogParameters = new DialogParameters - { - { x => x.Message, T("Are you sure you want to create a another chat? All unsaved changes will be lost.") }, - }; - - var dialogReference = await this.DialogService.ShowAsync(T("Create Chat"), dialogParameters, DialogOptions.FULLSCREEN); - var dialogResult = await dialogReference.Result; - if (dialogResult is null || dialogResult.Canceled) - return; - } - var workspaceId = Guid.Parse(Path.GetFileName(workspacePath)); - var chat = new ChatThread - { - WorkspaceId = workspaceId, - ChatId = Guid.NewGuid(), - Name = string.Empty, - SystemPrompt = SystemPrompts.DEFAULT, - Blocks = [], - }; - - var chatPath = Path.Join(workspacePath, chat.ChatId.ToString()); - - await WorkspaceBehaviour.StoreChatAsync(chat); - await this.LoadChatAsync(chatPath, switchToChat: true); - await this.LoadTreeItemsAsync(startPrefetch: false); + await this.StartNewChatRequested.InvokeAsync(workspaceId); } #region Overrides of MSGComponentBase diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor index 843be5427..f08096118 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogChatTemplate.razor @@ -1,7 +1,7 @@ @using AIStudio.Tools.PluginSystem @inherits SettingsDialogBase - + @@ -81,15 +81,16 @@ @T("No chat templates configured yet.") } - - + + + @* In the footer, adding and importing stay in reach however long the table grows: *@ + @T("Add Chat Template") - - + @T("Close") diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor index b278e1151..78432a189 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogDataSources.razor @@ -1,6 +1,6 @@ @inherits SettingsDialogBase - + @@ -12,6 +12,9 @@ + @* In the footer, adding and importing stay in reach however long the table grows: *@ + + @T("Close") diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogProfiles.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogProfiles.razor index b7d66a437..d453b802c 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogProfiles.razor +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogProfiles.razor @@ -1,7 +1,7 @@ @using AIStudio.Tools.PluginSystem @inherits SettingsDialogBase - + @@ -64,15 +64,16 @@ @T("No profiles configured yet.") } - - + + + @* In the footer, adding and importing stay in reach however long the table grows: *@ + @T("Add Profile") - - + @T("Close") diff --git a/app/MindWork AI Studio/Pages/Chat.razor b/app/MindWork AI Studio/Pages/Chat.razor index b9db7708b..e6f675084 100644 --- a/app/MindWork AI Studio/Pages/Chat.razor +++ b/app/MindWork AI Studio/Pages/Chat.razor @@ -62,7 +62,7 @@ - + } @@ -88,13 +88,14 @@ - + } - + } diff --git a/app/MindWork AI Studio/Pages/Chat.razor.cs b/app/MindWork AI Studio/Pages/Chat.razor.cs index 9c13a4188..91875eb9c 100644 --- a/app/MindWork AI Studio/Pages/Chat.razor.cs +++ b/app/MindWork AI Studio/Pages/Chat.razor.cs @@ -37,6 +37,7 @@ public partial class Chat : MSGComponentBase private bool workspaceSearchVisible; private string currentWorkspaceName = string.Empty; private Workspaces? workspaces; + private ChatComponent? chatComponent; private double splitterPosition = 30; private bool skipRenderAfterSplitterChange; private readonly ChatComposerState composerState = new(); @@ -167,6 +168,23 @@ public partial class Chat : MSGComponentBase await this.workspaces.ToggleSearchAsync(); } + /// + /// Starts a new chat in a workspace, when the workspace tree asks for one. + /// + /// + /// The page reads its reference to the chat component only now, when the user clicked. The chat + /// component is created anew whenever the workspace sidebar is shown or hidden, so a reference + /// taken earlier could point to one which is gone. + /// + /// The ID of the workspace. + private async Task StartNewChatInWorkspace(Guid workspaceId) + { + if (this.chatComponent is null) + return; + + await this.chatComponent.StartNewChatInWorkspaceAsync(workspaceId); + } + #region Overrides of MSGComponentBase protected override void DisposeResources() diff --git a/app/MindWork AI Studio/Pages/Plugins.razor b/app/MindWork AI Studio/Pages/Plugins.razor index dfb91ddaa..6f92e9309 100644 --- a/app/MindWork AI Studio/Pages/Plugins.razor +++ b/app/MindWork AI Studio/Pages/Plugins.razor @@ -131,7 +131,7 @@ @if (context is IAvailablePlugin shareablePlugin && CanSharePlugin(shareablePlugin)) { - + } diff --git a/app/MindWork AI Studio/Pages/Plugins.razor.cs b/app/MindWork AI Studio/Pages/Plugins.razor.cs index cde1857ab..81d707398 100644 --- a/app/MindWork AI Studio/Pages/Plugins.razor.cs +++ b/app/MindWork AI Studio/Pages/Plugins.razor.cs @@ -274,14 +274,15 @@ public partial class Plugins : MSGComponentBase private bool AllowPluginSharing => this.SettingsManager.ConfigurationData.App.AllowUserToSharePlugins; /// - /// Linux has no native share sheet, hence the plugin archive is exported to a location of the - /// user's choice there. The action must be labeled accordingly. + /// Where no native share sheet can be used, the plugin archive is exported to a location of the + /// user's choice instead: on Linux, which has none, and once the share sheet failed or did not + /// respond, until AI Studio restarts. The action must be labeled accordingly. /// - private static string SharePluginIcon => OperatingSystem.IsLinux() ? Icons.Material.Filled.FileDownload : Icons.Material.Filled.IosShare; + private string SharePluginIcon => this.PluginShareService.UsesExport ? Icons.Material.Filled.FileDownload : Icons.Material.Filled.IosShare; - private string SharePluginTooltip => OperatingSystem.IsLinux() ? this.T("Export plugin archive") : this.T("Share plugin archive"); + private string SharePluginTooltip => this.PluginShareService.UsesExport ? this.T("Export plugin archive") : this.T("Share plugin archive"); - private string SharePluginLockText => OperatingSystem.IsLinux() ? this.T("Your organization has disabled exporting plugins.") : this.T("Your organization has disabled sharing plugins."); + private string SharePluginLockText => this.PluginShareService.UsesExport ? this.T("Your organization has disabled exporting plugins.") : this.T("Your organization has disabled sharing plugins."); private async Task OpenAssistantPluginEditorDialogAsync(IAvailablePlugin plugin) { @@ -370,20 +371,38 @@ public partial class Plugins : MSGComponentBase try { + // A failed share sheet switches to the export, so we remember what this click does: + var isExport = this.PluginShareService.UsesExport; var shareResult = await this.PluginShareService.ShareAsync(plugin, CancellationToken.None); - if (shareResult.Cancelled) - return; - - if (!shareResult.Success) + if (shareResult.Outcome is PluginShareOutcome.SHARE_SHEET_UNAVAILABLE) { - LOG.LogError($"Sharing the plugin '{shareResult.PluginName}' from archive '{shareResult.ArchivePath}' failed with Issue: '{shareResult.Issue}'."); - await this.MessageBus.SendError(new(Icons.Material.Filled.ReportProblem, OperatingSystem.IsLinux() ? T("An error occurred while exporting the plugin.") : T("An error occurred while sharing the plugin."))); - return; + // The runtime and NativeShareService already logged why the share sheet did not open: + if (!await this.ConfirmExportInsteadOfSharingAsync(shareResult.Issue)) + return; + + isExport = true; + shareResult = await this.PluginShareService.ExportAsync(plugin, CancellationToken.None); } - // On Linux, the user chose the target location, so we confirm where the archive was stored: - if (OperatingSystem.IsLinux()) - await this.MessageBus.SendSuccess(new(Icons.Material.Filled.FileDownload, string.Format(T("The plugin archive was exported to '{0}'."), shareResult.ArchivePath))); + switch (shareResult.Outcome) + { + // On export, the user chose the target location, so we confirm where the archive was stored: + case PluginShareOutcome.EXPORTED: + await this.MessageBus.SendSuccess(new(Icons.Material.Filled.FileDownload, string.Format(T("The plugin archive was exported to '{0}'."), shareResult.ArchivePath))); + break; + + case PluginShareOutcome.FAILED: + LOG.LogError($"Sharing the plugin '{shareResult.PluginName}' from archive '{shareResult.ArchivePath}' failed with Issue: '{shareResult.Issue}'."); + await this.MessageBus.SendError(new(Icons.Material.Filled.ReportProblem, isExport ? T("An error occurred while exporting the plugin.") : T("An error occurred while sharing the plugin."))); + break; + + // The share sheet takes it from here, and a canceled export needs no message. The + // export never reports an unavailable share sheet: + case PluginShareOutcome.SHARED: + case PluginShareOutcome.CANCELLED: + case PluginShareOutcome.SHARE_SHEET_UNAVAILABLE: + break; + } } finally { @@ -392,6 +411,24 @@ public partial class Plugins : MSGComponentBase } } + /// + /// Offers to export the plugin archive after the share sheet of the operating system failed or + /// did not respond. + /// + /// Why the share sheet did not open. + /// True when the user wants to export the archive instead. + private async Task ConfirmExportInsteadOfSharingAsync(string issue) + { + var dialogParameters = new DialogParameters + { + { x => x.Message, string.Format(this.T("{0} Do you want to save the plugin archive to a location of your choice instead? Until you restart AI Studio, the plugin page offers this export instead of sharing."), issue) }, + }; + + var dialogReference = await this.DialogService.ShowAsync(this.T("Share dialog not available"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + return dialogResult is not null && !dialogResult.Canceled; + } + private async Task ImportAssistantPluginAsync() { if (this.isImportingAssistantPlugin) diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index fadfad87b..75e792b56 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -4026,6 +4026,48 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T813925103"] = "Das Folgende -- On this computer, the data folder lies inside the configuration folder. A backup of the configuration folder therefore takes the data folder along. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T91194331"] = "Auf diesem Rechner befindet sich der Datenordner im Konfigurationsordner. Bei einer Sicherung des Konfigurationsordners wird daher auch der Datenordner mitgesichert." +-- Local File, requires an embedding provider +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T1317002733"] = "Lokale Datei, erfordert einen Einbettungsanbieter" + +-- Add Local Directory as Data Source +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T1454193397"] = "Lokalen Ordner als Datenquelle hinzufügen" + +-- Local File +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T1687345358"] = "Lokale Datei" + +-- Mailbox (IMAP), requires an embedding provider +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T2363290424"] = "Postfach (IMAP), erfordert einen Einbettungsanbieter" + +-- Mailbox (IMAP), not allowed by your organization +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T308474155"] = "Postfach (IMAP), von Ihrer Organisation nicht erlaubt" + +-- Add Mailbox +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T3173750212"] = "Postfach hinzufügen" + +-- Add Data Source +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T3387511033"] = "Datenquelle hinzufügen" + +-- Add Local File as Data Source +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T3500365052"] = "Lokale Datei als Datenquelle hinzufügen" + +-- Local Directory, requires an embedding provider +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T3698039736"] = "Lokaler Ordner, erfordert einen Einbettungsanbieter" + +-- Import Data Source +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T381231449"] = "Datenquelle importieren" + +-- Mailbox (IMAP) +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T3859543867"] = "Postfach (IMAP)" + +-- Add ERI v1 Data Source +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T590005498"] = "ERI-v1-Datenquelle hinzufügen" + +-- External Data (ERI-Server v1) +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T774473996"] = "Externe Daten (ERI-Server v1)" + +-- Local Directory +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T926703547"] = "Lokaler Ordner" + -- Yes, please send my data to the external embedding provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T1159107763"] = "Ja, bitte senden Sie meine Daten an den externen Einbettungsanbieter" @@ -4083,15 +4125,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1208397349"] = "Au -- Edit Local Directory Data Source UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1215599168"] = "Datenquelle bearbeiten: Lokaler Ordner" --- Local File, requires an embedding provider -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1317002733"] = "Lokale Datei, erfordert einen Einbettungsanbieter" - -- Refresh UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T135637716"] = "Aktualisieren" --- Add Local Directory as Data Source -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1454193397"] = "Lokalen Ordner als Datenquelle hinzufügen" - -- Delete UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1469573738"] = "Löschen" @@ -4107,9 +4143,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1592527757"] = "Di -- External (ERI) UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1652430727"] = "Extern (ERI)" --- Local File -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1687345358"] = "Lokale Datei" - -- Delete Data Source UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1849107431"] = "Datenquelle löschen" @@ -4122,9 +4155,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2146756020"] = "In -- Edit ERI v1 Data Source UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T221059217"] = "ERI v1 Datenquelle bearbeiten" --- Mailbox (IMAP), requires an embedding provider -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2363290424"] = "Postfach (IMAP), erfordert einen Einbettungsanbieter" - -- Edit Local File Data Source UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2453292893"] = "Datenquelle bearbeiten: Lokale Datei" @@ -4161,12 +4191,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2940206485"] = "Si -- This data source is managed by your organization. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3031462878"] = "Diese Datenquelle wird von Ihrer Organisation verwaltet." --- Mailbox (IMAP), not allowed by your organization -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T308474155"] = "Postfach (IMAP), von Ihrer Organisation nicht erlaubt" - --- Add Mailbox -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3173750212"] = "Postfach hinzufügen" - -- Delete Mailbox UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3264675802"] = "Postfach löschen" @@ -4176,18 +4200,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3267849393"] = "Be -- Are you sure you want to delete the data source '{0}' of type '{1}'? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3337072977"] = "Möchten Sie die Datenquelle „{0}“ vom Typ „{1}“ wirklich löschen?" --- Add Data Source -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3387511033"] = "Datenquelle hinzufügen" - -- Unknown UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3424652889"] = "Unbekannt" -- Close UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3448155331"] = "Schließen" --- Add Local File as Data Source -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3500365052"] = "Lokale Datei als Datenquelle hinzufügen" - -- Type UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3512062061"] = "Typ" @@ -4203,21 +4221,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3595669127"] = "Zu -- Local data sources refresh when files change. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3687976654"] = "Lokale Datenquellen werden aktualisiert, wenn sich Dateien ändern." --- Local Directory, requires an embedding provider -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3698039736"] = "Lokaler Ordner, erfordert einen Einbettungsanbieter" - -- Not available UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3706935413"] = "Nicht verfügbar" --- Import Data Source -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T381231449"] = "Datenquelle importieren" - -- Export ERI Data Source UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3831281036"] = "ERI-Datenquelle exportieren" --- Mailbox (IMAP) -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3859543867"] = "Postfach (IMAP)" - -- Actions UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3865031940"] = "Aktionen" @@ -4230,15 +4239,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T4108252513"] = "Wa -- Information UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T4256323669"] = "Information" --- Add ERI v1 Data Source -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T590005498"] = "ERI-v1-Datenquelle hinzufügen" - -- Cannot export this ERI data source because no enterprise encryption secret is configured. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T750361472"] = "Diese ERI-Datenquelle kann nicht exportiert werden, da kein Geheimnis für die Verschlüsselung des Unternehmens konfiguriert ist." --- External Data (ERI-Server v1) -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T774473996"] = "Externe Daten (ERI-Server v1)" - -- Cannot export this ERI data source because no authentication secret is configured. The issue was: {0} UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T782820095"] = "Diese ERI-Datenquelle kann nicht exportiert werden, da kein Geheimnis für die Authentifizierung konfiguriert ist. Das Problem war: {0}" @@ -4251,9 +4254,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T854231603"] = "Lok -- The password of this mailbox could not be deleted from the operating system, so the mailbox was kept. The issue was: {0} UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T87980240"] = "Das Passwort für dieses Postfach konnte nicht aus dem Betriebssystem gelöscht werden. Daher wurde das Postfach beibehalten. Das Problem war: {0}" --- Local Directory -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T926703547"] = "Lokaler Ordner" - -- Import Embedding UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMISSINGEMBEDDINGWARNING::T1681763001"] = "Einbettung importieren" @@ -5889,9 +5889,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1874060138"] = "Chat hinzuf -- No chats found UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1886517101"] = "Keine Chats gefunden" --- Create Chat -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1939006681"] = "Chat erstellen" - -- Please name your workspace: UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T201482774"] = "Bitte benennen Sie Ihren Arbeitsbereich:" @@ -5901,9 +5898,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2133593288"] = "Möchten Sie -- Are you sure you want to delete the workspace '{0}'? This will also delete {1} chat(s) in this workspace. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2151341762"] = "Möchten Sie den Arbeitsbereich „{0}“ wirklich löschen? Dadurch werden auch {1} Chat(s) in diesem Arbeitsbereich gelöscht." --- Are you sure you want to create a another chat? All unsaved changes will be lost. -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2237618267"] = "Möchten Sie wirklich einen neuen Chat erstellen? Alle nicht gespeicherten Änderungen gehen verloren." - -- Please enter a chat name. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2301651387"] = "Bitte geben Sie einen Namen für diesen Chat ein." @@ -11193,9 +11187,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2057806005"] = "Plugin aktivieren" -- Plugins UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2222816203"] = "Plugins" +-- {0} Do you want to save the plugin archive to a location of your choice instead? Until you restart AI Studio, the plugin page offers this export instead of sharing. +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2430906247"] = "{0} Möchten Sie das Plugin-Archiv stattdessen an einem Ort Ihrer Wahl speichern? Bis zum Neustart von AI Studio bietet die Plugin-Seite diesen Export anstelle des Teilens an." + -- The tile '{0}' has been updated. UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2443911707"] = "Die Kachel „{0}“ wurde aktualisiert." +-- Share dialog not available +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2445896850"] = "Dialog zum Teilen nicht verfügbar" + -- Edit Assistant Plugin UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2477579768"] = "Plugin für „Assistent bearbeiten“" @@ -14127,6 +14127,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2221093487"] = -- Unexpected error: {0} UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2350673880"] = "Unerwarteter Fehler: {0}" +-- The share dialog of your operating system did not respond. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T254952756"] = "Der systemeigene Dialog zum Teilen hat nicht reagiert." + -- The plugin has no local directory. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T3284289028"] = "Das Plugin hat kein lokales Verzeichnis." diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index bff824567..57f623253 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -4026,6 +4026,48 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T813925103"] = "The followin -- On this computer, the data folder lies inside the configuration folder. A backup of the configuration folder therefore takes the data folder along. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T91194331"] = "On this computer, the data folder lies inside the configuration folder. A backup of the configuration folder therefore takes the data folder along." +-- Local File, requires an embedding provider +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T1317002733"] = "Local File, requires an embedding provider" + +-- Add Local Directory as Data Source +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T1454193397"] = "Add Local Directory as Data Source" + +-- Local File +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T1687345358"] = "Local File" + +-- Mailbox (IMAP), requires an embedding provider +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T2363290424"] = "Mailbox (IMAP), requires an embedding provider" + +-- Mailbox (IMAP), not allowed by your organization +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T308474155"] = "Mailbox (IMAP), not allowed by your organization" + +-- Add Mailbox +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T3173750212"] = "Add Mailbox" + +-- Add Data Source +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T3387511033"] = "Add Data Source" + +-- Add Local File as Data Source +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T3500365052"] = "Add Local File as Data Source" + +-- Local Directory, requires an embedding provider +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T3698039736"] = "Local Directory, requires an embedding provider" + +-- Import Data Source +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T381231449"] = "Import Data Source" + +-- Mailbox (IMAP) +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T3859543867"] = "Mailbox (IMAP)" + +-- Add ERI v1 Data Source +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T590005498"] = "Add ERI v1 Data Source" + +-- External Data (ERI-Server v1) +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T774473996"] = "External Data (ERI-Server v1)" + +-- Local Directory +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T926703547"] = "Local Directory" + -- Yes, please send my data to the external embedding provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCECLOUDEMBEDDINGWARNING::T1159107763"] = "Yes, please send my data to the external embedding provider" @@ -4083,15 +4125,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1208397349"] = "Au -- Edit Local Directory Data Source UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1215599168"] = "Edit Local Directory Data Source" --- Local File, requires an embedding provider -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1317002733"] = "Local File, requires an embedding provider" - -- Refresh UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T135637716"] = "Refresh" --- Add Local Directory as Data Source -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1454193397"] = "Add Local Directory as Data Source" - -- Delete UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1469573738"] = "Delete" @@ -4107,9 +4143,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1592527757"] = "Ca -- External (ERI) UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1652430727"] = "External (ERI)" --- Local File -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1687345358"] = "Local File" - -- Delete Data Source UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1849107431"] = "Delete Data Source" @@ -4122,9 +4155,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2146756020"] = "Lo -- Edit ERI v1 Data Source UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T221059217"] = "Edit ERI v1 Data Source" --- Mailbox (IMAP), requires an embedding provider -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2363290424"] = "Mailbox (IMAP), requires an embedding provider" - -- Edit Local File Data Source UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2453292893"] = "Edit Local File Data Source" @@ -4161,12 +4191,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2940206485"] = "Yo -- This data source is managed by your organization. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3031462878"] = "This data source is managed by your organization." --- Mailbox (IMAP), not allowed by your organization -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T308474155"] = "Mailbox (IMAP), not allowed by your organization" - --- Add Mailbox -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3173750212"] = "Add Mailbox" - -- Delete Mailbox UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3264675802"] = "Delete Mailbox" @@ -4176,18 +4200,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3267849393"] = "Ed -- Are you sure you want to delete the data source '{0}' of type '{1}'? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3337072977"] = "Are you sure you want to delete the data source '{0}' of type '{1}'?" --- Add Data Source -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3387511033"] = "Add Data Source" - -- Unknown UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3424652889"] = "Unknown" -- Close UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3448155331"] = "Close" --- Add Local File as Data Source -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3500365052"] = "Add Local File as Data Source" - -- Type UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3512062061"] = "Type" @@ -4203,21 +4221,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3595669127"] = "Ex -- Local data sources refresh when files change. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3687976654"] = "Local data sources refresh when files change." --- Local Directory, requires an embedding provider -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3698039736"] = "Local Directory, requires an embedding provider" - -- Not available UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3706935413"] = "Not available" --- Import Data Source -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T381231449"] = "Import Data Source" - -- Export ERI Data Source UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3831281036"] = "Export ERI Data Source" --- Mailbox (IMAP) -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3859543867"] = "Mailbox (IMAP)" - -- Actions UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T3865031940"] = "Actions" @@ -4230,15 +4239,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T4108252513"] = "Wa -- Information UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T4256323669"] = "Information" --- Add ERI v1 Data Source -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T590005498"] = "Add ERI v1 Data Source" - -- Cannot export this ERI data source because no enterprise encryption secret is configured. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T750361472"] = "Cannot export this ERI data source because no enterprise encryption secret is configured." --- External Data (ERI-Server v1) -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T774473996"] = "External Data (ERI-Server v1)" - -- Cannot export this ERI data source because no authentication secret is configured. The issue was: {0} UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T782820095"] = "Cannot export this ERI data source because no authentication secret is configured. The issue was: {0}" @@ -4251,9 +4254,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T854231603"] = "Loc -- The password of this mailbox could not be deleted from the operating system, so the mailbox was kept. The issue was: {0} UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T87980240"] = "The password of this mailbox could not be deleted from the operating system, so the mailbox was kept. The issue was: {0}" --- Local Directory -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T926703547"] = "Local Directory" - -- Import Embedding UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMISSINGEMBEDDINGWARNING::T1681763001"] = "Import Embedding" @@ -5889,9 +5889,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1874060138"] = "Add chat" -- No chats found UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1886517101"] = "No chats found" --- Create Chat -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1939006681"] = "Create Chat" - -- Please name your workspace: UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T201482774"] = "Please name your workspace:" @@ -5901,9 +5898,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2133593288"] = "Are you sure -- Are you sure you want to delete the workspace '{0}'? This will also delete {1} chat(s) in this workspace. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2151341762"] = "Are you sure you want to delete the workspace '{0}'? This will also delete {1} chat(s) in this workspace." --- Are you sure you want to create a another chat? All unsaved changes will be lost. -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2237618267"] = "Are you sure you want to create a another chat? All unsaved changes will be lost." - -- Please enter a chat name. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2301651387"] = "Please enter a chat name." @@ -11193,9 +11187,15 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2057806005"] = "Enable plugin" -- Plugins UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2222816203"] = "Plugins" +-- {0} Do you want to save the plugin archive to a location of your choice instead? Until you restart AI Studio, the plugin page offers this export instead of sharing. +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2430906247"] = "{0} Do you want to save the plugin archive to a location of your choice instead? Until you restart AI Studio, the plugin page offers this export instead of sharing." + -- The tile '{0}' has been updated. UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2443911707"] = "The tile '{0}' has been updated." +-- Share dialog not available +UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2445896850"] = "Share dialog not available" + -- Edit Assistant Plugin UI_TEXT_CONTENT["AISTUDIO::PAGES::PLUGINS::T2477579768"] = "Edit Assistant Plugin" @@ -14127,6 +14127,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2221093487"] = -- Unexpected error: {0} UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T2350673880"] = "Unexpected error: {0}" +-- The share dialog of your operating system did not respond. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T254952756"] = "The share dialog of your operating system did not respond." + -- The plugin has no local directory. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PLUGINSHARESERVICE::T3284289028"] = "The plugin has no local directory." diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index 686b8c18b..39700b223 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -239,7 +239,7 @@ internal sealed class Program builder.Services.AddSingleton(); builder.Services.AddHostedService(serviceProvider => serviceProvider.GetRequiredService()); builder.Services.AddHostedService(); - builder.Services.AddScoped(); + builder.Services.AddSingleton(); builder.Services.AddScoped(); // diff --git a/app/MindWork AI Studio/Tools/Rust/ShareFileResponse.cs b/app/MindWork AI Studio/Tools/Rust/ShareFileResponse.cs new file mode 100644 index 000000000..300807b26 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/ShareFileResponse.cs @@ -0,0 +1,9 @@ +namespace AIStudio.Tools.Rust; + +/// +/// The answer of the runtime to a request to share a file through the share sheet of the +/// operating system. +/// +/// What became of the request. +/// Why the share sheet did not open; empty when it opened. +public sealed record ShareFileResponse(ShareSheetOutcome Outcome, string Issue); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Rust/ShareSheetOutcome.cs b/app/MindWork AI Studio/Tools/Rust/ShareSheetOutcome.cs new file mode 100644 index 000000000..b7546f732 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/ShareSheetOutcome.cs @@ -0,0 +1,30 @@ +namespace AIStudio.Tools.Rust; + +/// +/// What became of a request to open the share sheet of the operating system. Keep in sync with +/// ShareOutcome in runtime/src/share_sheet.rs. +/// +public enum ShareSheetOutcome +{ + /// + /// The runtime sent an outcome the app does not know, or none at all. The app reads this as a + /// failure, never as an opened share sheet. + /// + UNKNOWN, + + /// + /// The operating system opened its share sheet for the file. + /// + OPENED, + + /// + /// The share sheet could not be opened, or the request was invalid. + /// + FAILED, + + /// + /// Windows accepted the share request, but its share sheet never asked for the file. Windows + /// reports no error then, and the share sheet simply does not appear. + /// + UNRESPONSIVE, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/NativeShareService.cs b/app/MindWork AI Studio/Tools/Services/NativeShareService.cs index aa42f5ee6..4db5cd980 100644 --- a/app/MindWork AI Studio/Tools/Services/NativeShareService.cs +++ b/app/MindWork AI Studio/Tools/Services/NativeShareService.cs @@ -1,6 +1,40 @@ +using AIStudio.Tools.Rust; + namespace AIStudio.Tools.Services; -public sealed class NativeShareService(RustService rustService) +/// +/// Hands files to the share sheet of the operating system, and remembers when that share sheet +/// failed. +/// +/// +/// A share sheet which hung once tends to stay broken: on Windows, it then accepts every further +/// request without ever appearing, so the share button seems to do nothing at all. Once the share +/// sheet failed or did not respond, AI Studio therefore exports plugin archives instead, until it +/// restarts. This service is a singleton, so that the decision holds for every window. +/// +public sealed class NativeShareService(RustService rustService, ILogger logger) { - public Task Share(string filePath) => rustService.ShareFile(filePath); -} + private volatile bool isUnavailable; + + /// + /// Whether the share sheet of the operating system can be used. Linux has none, and on Windows + /// and macOS it counts as unavailable once it failed or did not respond. + /// + public bool IsAvailable => !OperatingSystem.IsLinux() && !this.isUnavailable; + + /// + /// Opens the share sheet of the operating system for a file. + /// + /// The plugin archive to share. + /// What became of the request. + public async Task Share(string filePath) + { + var response = await rustService.ShareFile(filePath); + if (response.Outcome is ShareSheetOutcome.OPENED || this.isUnavailable) + return response; + + this.isUnavailable = true; + logger.LogWarning("The native share sheet did not open ({Outcome}). Until AI Studio restarts, plugin archives are exported instead.", response.Outcome); + return response; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/PluginShareOutcome.cs b/app/MindWork AI Studio/Tools/Services/PluginShareOutcome.cs new file mode 100644 index 000000000..243e2fb5c --- /dev/null +++ b/app/MindWork AI Studio/Tools/Services/PluginShareOutcome.cs @@ -0,0 +1,33 @@ +namespace AIStudio.Tools.Services; + +/// +/// What became of sharing or exporting a plugin archive. +/// +public enum PluginShareOutcome +{ + /// + /// The share sheet of the operating system opened for the plugin archive. + /// + SHARED, + + /// + /// The plugin archive was written to the location the user chose. + /// + EXPORTED, + + /// + /// The user canceled choosing a location for the export. + /// + CANCELLED, + + /// + /// The plugin could not be shared or exported. + /// + FAILED, + + /// + /// The share sheet of the operating system failed or did not respond. The plugin archive can + /// still be exported instead. + /// + SHARE_SHEET_UNAVAILABLE, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/PluginShareResult.cs b/app/MindWork AI Studio/Tools/Services/PluginShareResult.cs index dcf1ada81..a6a881849 100644 --- a/app/MindWork AI Studio/Tools/Services/PluginShareResult.cs +++ b/app/MindWork AI Studio/Tools/Services/PluginShareResult.cs @@ -1,3 +1,10 @@ namespace AIStudio.Tools.Services; -public sealed record PluginShareResult(bool Success, string PluginName, string ArchivePath, string Issue, bool Cancelled = false); \ No newline at end of file +/// +/// The result of sharing or exporting a plugin archive. +/// +/// What became of the request. +/// The name of the plugin. +/// Where the archive was written; empty when there is none. +/// Why the plugin could not be shared or exported; empty otherwise. +public sealed record PluginShareResult(PluginShareOutcome Outcome, string PluginName, string ArchivePath, string Issue); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/PluginShareService.cs b/app/MindWork AI Studio/Tools/Services/PluginShareService.cs index 1c5f2c05e..cd0025de8 100644 --- a/app/MindWork AI Studio/Tools/Services/PluginShareService.cs +++ b/app/MindWork AI Studio/Tools/Services/PluginShareService.cs @@ -7,8 +7,10 @@ namespace AIStudio.Tools.Services; public sealed class PluginShareService(NativeShareService nativeShareService, RustService rustService, SettingsManager settingsManager, ILogger logger) { - private static PluginShareResult ShareError(IAvailablePlugin plugin, string issue) => new(false, plugin.Name, string.Empty, issue); - + private static PluginShareResult ShareError(IAvailablePlugin plugin, string issue) => new(PluginShareOutcome.FAILED, plugin.Name, string.Empty, issue); + + private static PluginShareResult ShareSheetUnavailable(IAvailablePlugin plugin, string issue) => new(PluginShareOutcome.SHARE_SHEET_UNAVAILABLE, plugin.Name, string.Empty, issue); + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginShareService).Namespace, nameof(PluginShareService)); private const string PLUGIN_FILE_NAME = "plugin.lua"; @@ -22,6 +24,12 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru private const int TEMPORARY_ARCHIVE_RETENTION_HOURS = 24; private const int FILE_NAME_PREFIX_MAX_LEN = 80; + /// + /// Whether sharing a plugin exports its archive to a location of the user's choice instead of + /// handing it to the share sheet of the operating system. + /// + public bool UsesExport => !nativeShareService.IsAvailable; + /// /// Creates a shareable plugin archive from a local plugin and hands it over to the user. /// The archive contains the plugin root contents, so plugin.lua is located at the archive root. @@ -29,31 +37,73 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru /// /// On Windows and macOS, the archive is created in a temporary directory and handed over to the /// native share sheet. Linux has no such share sheet, since the XDG desktop portals do not provide - /// a share interface. Thus, the archive is exported to a location of the user's choice there. + /// a share interface. Thus, the archive is exported to a location of the user's choice there. The + /// same happens on Windows and macOS once the share sheet failed or did not respond, until AI + /// Studio restarts. /// /// The local plugin to archive and share. /// Cancellation token for archive creation. - /// The share result, including the archive path when successful. + /// The share result, including the archive path when successful. When the share sheet + /// failed or did not respond, the outcome is SHARE_SHEET_UNAVAILABLE, and ExportAsync can still + /// export the plugin. public async Task ShareAsync(IAvailablePlugin plugin, CancellationToken token) { - if (plugin.IsInternal) - return ShareError(plugin, TB("Internal plugins cannot be shared.")); - - if (plugin.IsManagedByConfigServer) - return ShareError(plugin, TB("Config Server managed plugins cannot be shared.")); - - if (!settingsManager.ConfigurationData.App.AllowUserToSharePlugins) - return ShareError(plugin, TB("Your organization has disabled sharing plugins.")); - - if (!TryGetPluginRoot(plugin, out var pluginRoot, out var issue)) + if (!this.TryGetShareablePluginRoot(plugin, out var pluginRoot, out var issue)) return ShareError(plugin, issue); - if (OperatingSystem.IsLinux()) - return await this.ExportAsync(plugin, pluginRoot, token); + if (this.UsesExport) + return await this.ExportArchiveAsync(plugin, pluginRoot, token); return await this.ShareViaNativeSheetAsync(plugin, pluginRoot, token); } + /// + /// Exports a plugin archive to a location of the user's choice, even where a share sheet would + /// be available. This is the way out after the share sheet failed or did not respond. + /// + /// The local plugin to archive and export. + /// Cancellation token for archive creation. + /// The export result, including the chosen archive path when successful. + public async Task ExportAsync(IAvailablePlugin plugin, CancellationToken token) + { + if (!this.TryGetShareablePluginRoot(plugin, out var pluginRoot, out var issue)) + return ShareError(plugin, issue); + + return await this.ExportArchiveAsync(plugin, pluginRoot, token); + } + + /// + /// Checks whether a plugin may leave AI Studio as an archive at all, no matter whether it is + /// shared or exported. + /// + /// The local plugin to check. + /// The validated plugin root directory, when the plugin may leave. + /// Why the plugin may not leave; empty otherwise. + /// True when the plugin may be shared or exported. + private bool TryGetShareablePluginRoot(IAvailablePlugin plugin, out string pluginRoot, out string issue) + { + pluginRoot = string.Empty; + if (plugin.IsInternal) + { + issue = TB("Internal plugins cannot be shared."); + return false; + } + + if (plugin.IsManagedByConfigServer) + { + issue = TB("Config Server managed plugins cannot be shared."); + return false; + } + + if (!settingsManager.ConfigurationData.App.AllowUserToSharePlugins) + { + issue = TB("Your organization has disabled sharing plugins."); + return false; + } + + return TryGetPluginRoot(plugin, out pluginRoot, out issue); + } + /// /// Asks the user for a target location and writes the plugin archive to it. /// @@ -61,12 +111,12 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru /// The validated plugin root directory. /// Cancellation token for archive creation. /// The share result, including the chosen archive path when successful. - private async Task ExportAsync(IAvailablePlugin plugin, string pluginRoot, CancellationToken token) + private async Task ExportArchiveAsync(IAvailablePlugin plugin, string pluginRoot, CancellationToken token) { var suggestedFileName = $"{CreateSafeFileNamePrefix(plugin.Name)}{PluginArchive.PLUGIN_FILE_EXTENSION}"; var saveResponse = await rustService.SaveFile(TB("Export plugin archive"), [FileTypes.PLUGIN_ARCHIVE], suggestedFileName); if (saveResponse.UserCancelled) - return new(false, plugin.Name, string.Empty, string.Empty, true); + return new(PluginShareOutcome.CANCELLED, plugin.Name, string.Empty, string.Empty); var archivePath = saveResponse.SaveFilePath; try @@ -88,7 +138,7 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru }, token); logger.LogInformation("Exported plugin '{PluginName}' ({PluginId}) to the archive '{ArchivePath}'.", plugin.Name, plugin.Id, archivePath); - return new(true, plugin.Name, archivePath, string.Empty); + return new(PluginShareOutcome.EXPORTED, plugin.Name, archivePath, string.Empty); } catch (OperationCanceledException) { @@ -128,14 +178,28 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru }, token); token.ThrowIfCancellationRequested(); - if (!await nativeShareService.Share(archivePath)) + var shareResponse = await nativeShareService.Share(archivePath); + switch (shareResponse.Outcome) { - this.TryDeleteArchive(archivePath); - return ShareError(plugin, TB("The native share dialog could not be opened.")); + case ShareSheetOutcome.OPENED: + break; + + // + // A share sheet which did not respond may still appear later and ask for the archive. + // We keep it for that case; the clean-up removes it once it expired: + // + case ShareSheetOutcome.UNRESPONSIVE: + return ShareSheetUnavailable(plugin, TB("The share dialog of your operating system did not respond.")); + + case ShareSheetOutcome.FAILED: + case ShareSheetOutcome.UNKNOWN: + default: + this.TryDeleteArchive(archivePath); + return ShareSheetUnavailable(plugin, TB("The native share dialog could not be opened.")); } logger.LogInformation("Created plugin archive '{ArchivePath}' for plugin '{PluginName}' ({PluginId}).", archivePath, plugin.Name, plugin.Id); - return new(true, plugin.Name, archivePath, string.Empty); + return new(PluginShareOutcome.SHARED, plugin.Name, archivePath, string.Empty); } catch (OperationCanceledException) { diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Share.cs b/app/MindWork AI Studio/Tools/Services/RustService.Share.cs index 6234fe105..a59dfdb43 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Share.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Share.cs @@ -1,9 +1,16 @@ // ReSharper disable NotAccessedPositionalProperty.Local +using AIStudio.Tools.Rust; + namespace AIStudio.Tools.Services; public sealed partial class RustService { - public async Task ShareFile(string filePath) + /// + /// Asks the runtime to hand a file to the share sheet of the operating system. + /// + /// The plugin archive to share. + /// What became of the request. Any failure to reach the runtime counts as FAILED. + public async Task ShareFile(string filePath) { try { @@ -11,34 +18,37 @@ public sealed partial class RustService if (!response.IsSuccessStatusCode) { this.logger?.LogError($"The Rust runtime rejected the share request: {response.StatusCode}."); - return false; + return new(ShareSheetOutcome.FAILED, $"The Rust runtime rejected the share request: {response.StatusCode}."); } var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); - if (result?.Success == true) - return true; + if (result is null) + { + this.logger?.LogError("The Rust runtime answered the share request without a result."); + return new(ShareSheetOutcome.FAILED, "The Rust runtime answered the share request without a result."); + } - this.logger?.LogError($"The native share sheet could not be opened: {result?.Issue ?? "Unknown error"}"); - return false; + if (result.Outcome is not ShareSheetOutcome.OPENED) + this.logger?.LogError($"The native share sheet did not open ({result.Outcome}): {result.Issue}"); + + return result; } catch (HttpRequestException exception) { this.logger?.LogWarning(exception, "Failed to reach the Rust runtime share endpoint."); - return false; + return new(ShareSheetOutcome.FAILED, exception.Message); } catch (TaskCanceledException exception) { this.logger?.LogWarning(exception, "Timed out while reaching the Rust runtime share endpoint."); - return false; + return new(ShareSheetOutcome.FAILED, exception.Message); } catch (Exception exception) { this.logger?.LogError(exception, "Failed to process the Rust runtime share response."); - return false; + return new(ShareSheetOutcome.FAILED, exception.Message); } } private sealed record ShareFileRequest(string FilePath); - - private sealed record ShareFileResponse(bool Success, string Issue); } \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/app.css b/app/MindWork AI Studio/wwwroot/app.css index ac9c586b9..ecbe9c2d1 100644 --- a/app/MindWork AI Studio/wwwroot/app.css +++ b/app/MindWork AI Studio/wwwroot/app.css @@ -538,3 +538,25 @@ tr:has(> .provider-group-header) .mud-icon-button { overflow-wrap: anywhere; overflow: hidden !important; } + +/* + * Dialogs whose footer carries buttons on the left as well, such as Add and Import next to Close. + * MudBlazor pads the content with 24px to the left and right, but the footer with only 8px, so the + * buttons stood out of the column of the table above them. The footer takes over the 24px. On the + * right, the content also gives up the room of its scrollbar as soon as the table grows long, and + * how wide that is depends on the operating system and its settings, down to nothing for scrollbars + * which only show while scrolling. So no fixed padding fits: the content reserves that room for good, + * and the footer reserves the very same, which scrollbar-gutter only does for an element that clips + * its overflow. The popovers of menus and tooltips live elsewhere, so the footer clips no more than + * the outermost blur of a button shadow. A web view without scrollbar-gutter keeps the old offset on + * the right only. Both rules name the MudBlazor classes to outweigh their specificity. + */ +.mud-dialog .mud-dialog-content.dialog-content-aligned { + scrollbar-gutter: stable; +} + +.mud-dialog .mud-dialog-actions.dialog-actions-aligned { + padding: 8px 24px; + overflow: hidden; + scrollbar-gutter: stable; +} diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md index 27d384570..53102ac85 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md @@ -44,7 +44,9 @@ - Improved the organization-wide management of the app settings. IT departments can now also set whether AI Studio saves energy while the AI writes its answers, which helps on virtual desktops, for example. - Improved attaching files to a chat. You can now attach any file that contains readable text, for example, program code or configuration files of a kind AI Studio did not know before. - Improved adding a local data source when you have not configured an embedding provider yet. Until now, you only found out when saving the dialog, after filling in everything else. The data source settings now tell you right away and let you add an embedding provider right there, without losing what you entered. +- Improved the dialogs for managing your data sources, profiles, and chat templates, which you open, for example, from a chat. The buttons for adding and importing now stay at the bottom of the dialog, so you no longer need to scroll past a long list to reach them. Thanks, Paul Koudelka (`PaulKoudelka`), for this contribution. - Changed the banner on the start page to a fresh design that shows what AI Studio can do today. +- Changed when a chat you add to a workspace in the workspace list appears there. Like any other new chat, it now appears once there is something to keep, for example, your first message, instead of right away. - Fixed a warning about the confidence level that stayed in the dialog for adding a local data source, even after you chose an embedding provider that meets it. The dialog now checks again whenever you change either of them, so a warning also appears when you switch to a provider that falls short. - Fixed the list of data sources squeezing their names into a very narrow column when you opened it from a chat or from the background embeddings page, so the names were hard to read. - Fixed two confidence levels sharing the same name in the German user interface. Moderate is called "Mäßig" again, and Medium stays "Mittel". @@ -75,6 +77,9 @@ - Fixed the workspace list no longer showing the chats of your workspaces after you moved a chat to another workspace. - Fixed a chat you moved from the disappearing chats into a workspace still being listed among the disappearing chats. - Fixed a new chat not appearing in the workspace list while AI Studio transcribed an audio or video file you had attached to it. +- Fixed a chat you added to a workspace in the workspace list missing what your chat template preselects, such as data sources, tools, and the example conversation. The chat now starts with all of it, just like a chat you start with the button for a new chat in the same workspace. +- Fixed starting a new chat sometimes keeping the chat you had open, with only the text and attachments of your chat template added to your message. This happened when you had chosen to always use the default chat provider for new chats and your chat template selects tools. +- Fixed the button "Delete this chat & start a new one" turning the new chat into a disappearing chat when the deleted chat belonged to a workspace. The new chat now stays in the workspace of the deleted chat. - Fixed a deleted workspace coming back as "Unnamed workspace" when one of its chats was still open or being answered. AI Studio now deletes a workspace only when none of its chats is being answered or transcribed, and it closes the chat you had open in it. - Fixed a chat you started by attaching an audio or video file getting a German name, regardless of the language you use AI Studio in. - Fixed AI Studio sometimes warning about unsaved changes when you left a chat in which you had only selected a chat template, without writing anything yourself. @@ -94,6 +99,7 @@ - Fixed AI Studio removing harmless lines from files such as text or Markdown files, mistaking them for suspicious instructions (prompt injection). - Fixed AI Studio not checking the transcripts of audio and video files for suspicious instructions (prompt injection) in the Batch Processing assistant and when you load such a file into a field of an assistant. AI Studio now checks these transcripts like all other documents before the AI sees them, even very long ones. - Fixed AI Studio deleting the API keys you entered yourself for the providers of your organization when your organization changed the secret that protects its configuration. These keys now stay, so you no longer need to enter them again. +- Fixed sharing plugins when the share dialog of Windows or macOS does not open or stops responding. AI Studio now notices this and offers to save the plugin archive to a location of your choice instead. Until you restart AI Studio, the plugin page then offers this export instead of sharing. - Updated the code contributions on the supporters page, which now thank everyone who has contributed code to AI Studio so far. - Upgraded several libraries to improve security. - Upgraded to Rust v1.99.0 diff --git a/app/Tests/Tools/ShareFileResponseTests.cs b/app/Tests/Tools/ShareFileResponseTests.cs new file mode 100644 index 000000000..83bb70ce9 --- /dev/null +++ b/app/Tests/Tools/ShareFileResponseTests.cs @@ -0,0 +1,51 @@ +using System.Text.Json; + +using AIStudio.Tools.Rust; +using AIStudio.Tools.Services; + +namespace AIStudio.Tests.Tools; + +/// +/// Checks that the app reads every outcome which the runtime sends after a share request. +/// +/// +/// ShareFileResponse in runtime/src/share_sheet.rs names the outcome in snake case. A name which the +/// app does not know would not fail anywhere: the enum converter falls back to the first member, and +/// a share sheet which did not respond would go unnoticed again. The options mirror what RustService +/// deserializes with. +/// +[TestFixture] +public sealed class ShareFileResponseTests +{ + private static readonly JsonSerializerOptions AS_THE_RUST_SERVICE_READS_IT = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + Converters = + { + new RustEnumConverter(), + }, + }; + + [TestCase("opened", ShareSheetOutcome.OPENED)] + [TestCase("failed", ShareSheetOutcome.FAILED)] + [TestCase("unresponsive", ShareSheetOutcome.UNRESPONSIVE)] + public void TheAppReadsEveryOutcomeOfTheRuntime(string outcome, ShareSheetOutcome expectedOutcome) + { + var response = JsonSerializer.Deserialize($$"""{"outcome":"{{outcome}}","issue":"The main window is not available."}""", AS_THE_RUST_SERVICE_READS_IT); + + Assert.That(response, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(response!.Outcome, Is.EqualTo(expectedOutcome)); + Assert.That(response.Issue, Is.EqualTo("The main window is not available.")); + }); + } + + [Test] + public void AnUnknownOutcomeNeverCountsAsAnOpenedShareSheet() + { + var response = JsonSerializer.Deserialize("""{"outcome":"vanished","issue":""}""", AS_THE_RUST_SERVICE_READS_IT); + + Assert.That(response?.Outcome, Is.EqualTo(ShareSheetOutcome.UNKNOWN)); + } +} \ No newline at end of file diff --git a/documentation/AI_Studio_main.png b/documentation/AI_Studio_main.png new file mode 100644 index 000000000..ecbe628a8 Binary files /dev/null and b/documentation/AI_Studio_main.png differ diff --git a/documentation/Build.md b/documentation/Build.md index 9b7050811..e918a0939 100644 --- a/documentation/Build.md +++ b/documentation/Build.md @@ -32,7 +32,7 @@ One command checks that what you are about to build is sound: It runs the .NET tests, the Rust tests, Clippy (`cargo clippy --all-targets -- -D warnings`), and a report on the pages the model rules were written from. Every check runs, even after one of them has failed, so that a single run tells you everything that is wrong instead of the first thing. -`dotnet run build` runs the gate first and stops when it does not pass. For the quick loop while you are working on something, use `dotnet run build --skip-verify`, and let the gate run before you open a pull request. The same command runs in our GitHub workflow as the `verify` job, on every pull request — including those without the `run-pipeline` label, because a gate which is closed exactly while nobody is looking is not a gate. +`dotnet run build` runs the gate first and stops when it does not pass. For the quick loop while you are working on something, use `dotnet run build --skip-verify`, and let the gate run before you open a pull request. The same command runs in our GitHub workflow as the `verify` job, on every pull request which changes more than the documentation — including those without the `run-pipeline` label, because a gate which is closed exactly while nobody is looking is not a gate. Two notes: - The Rust half of the gate needs the .NET sidecar (see "One-time mandatory steps" above). While that file is missing, the gate skips the Rust tests and Clippy and says so rather than failing, because the command which produces the sidecar is `dotnet run build` itself. @@ -73,6 +73,8 @@ When a pull request carries the `run-pipeline` label, our GitHub workflow builds When a maintainer wants signed test builds for their own work, they push the branch to this repository instead of their fork. Pushes to `main` only check that every platform still builds; they are neither signed nor uploaded. +Changes which touch only the documentation, such as the README or the files in `documentation/`, start no build at all: neither in a pull request, not even with the `run-pipeline` label, nor after the merge into `main`. The workflow lists these files under `paths-ignore`. Release tags always build. + ## Create a release In order to create a release: 1. To create a new release, you need to be a maintainer of the repository—see step 8. diff --git a/documentation/mindworks_assistants_complete_4px.png b/documentation/mindworks_assistants_complete_4px.png new file mode 100644 index 000000000..bade2f82d Binary files /dev/null and b/documentation/mindworks_assistants_complete_4px.png differ diff --git a/runtime/src/share_sheet.rs b/runtime/src/share_sheet.rs index 649d92081..92c3aa874 100644 --- a/runtime/src/share_sheet.rs +++ b/runtime/src/share_sheet.rs @@ -16,12 +16,44 @@ pub struct ShareFileRequest { file_path: String, } +/// What became of a share request. Keep in sync with ShareSheetOutcome on the .NET side. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ShareOutcome { + /// The operating system opened its share UI for the file. + Opened, + + /// The share UI could not be opened, or the request was invalid. + Failed, + + /// Windows accepted the share request, but its share UI never asked for the file. Windows + /// reports no error then, and the share UI simply does not appear. + #[cfg(windows)] + Unresponsive, +} + #[derive(Serialize)] pub struct ShareFileResponse { - success: bool, + outcome: ShareOutcome, issue: String, } +/// Why a share request did not open the share UI. +struct ShareFailure { + outcome: ShareOutcome, + issue: String, +} + +/// Most failures are plain errors of the platform APIs, hence a text alone counts as a failure. +impl From for ShareFailure { + fn from(issue: String) -> Self { + Self { + outcome: ShareOutcome::Failed, + issue, + } + } +} + pub async fn share_file(Json(request): Json) -> Json { let path = PathBuf::from(request.file_path.trim()); if path.as_os_str().is_empty() { @@ -49,14 +81,17 @@ pub async fn share_file(Json(request): Json) -> Json { info!(Source = "Share sheet"; "Opened the native share UI."); Json(ShareFileResponse { - success: true, + outcome: ShareOutcome::Opened, issue: String::new(), }) } - Err(issue) => { - error!(Source = "Share sheet"; "{issue}"); - failure(issue) + Err(share_failure) => { + error!(Source = "Share sheet"; "{}", share_failure.issue); + Json(ShareFileResponse { + outcome: share_failure.outcome, + issue: share_failure.issue, + }) } } } @@ -79,7 +114,7 @@ fn is_shareable_archive(path: &Path) -> bool { fn failure(issue: impl Into) -> Json { Json(ShareFileResponse { - success: false, + outcome: ShareOutcome::Failed, issue: issue.into(), }) } @@ -87,20 +122,81 @@ fn failure(issue: impl Into) -> Json { // Linux has no native share sheet: the XDG desktop portals do not provide a share interface. The // app exports the file through the save dialog instead, hence this endpoint is not used on Linux: #[cfg(target_os = "linux")] -async fn share_file_on_platform(_path: PathBuf) -> Result<(), String> { - Err(String::from("The native share sheet is not available on Linux.")) +async fn share_file_on_platform(_path: PathBuf) -> Result<(), ShareFailure> { + Err(String::from("The native share sheet is not available on Linux.").into()) } #[cfg(windows)] -async fn share_file_on_platform(path: PathBuf) -> Result<(), String> { +async fn share_file_on_platform(path: PathBuf) -> Result<(), ShareFailure> { use std::cell::RefCell; - use windows::ApplicationModel::DataTransfer::{DataRequestedEventArgs, DataTransferManager}; + use std::time::{Duration, Instant}; + use log::{debug, warn}; + use windows::ApplicationModel::DataTransfer::{DataPackage, DataRequestedEventArgs, DataTransferManager, ShareCompletedEventArgs}; use windows::Foundation::TypedEventHandler; use windows::Storage::{IStorageItem, StorageFile}; use windows::Win32::UI::Shell::IDataTransferManagerInterop; - use windows::core::{factory, HSTRING, Interface}; + use windows::core::{factory, IInspectable, HSTRING, Interface}; use windows_collections::IIterable; + /// How long Windows waits for the shared data. The documentation of DataRequest.GetDeferral + /// names 200 ms, after which the share operation times out. + const DATA_PACKAGE_DEADLINE: Duration = Duration::from_millis(200); + + /// How long the share UI may take to ask for the shared file after Windows accepted the share + /// request. A working share UI asks right away, before it lists any target. The limit is + /// generous on purpose: we adjust it once the log shows how long slow computers take. + const DATA_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + + /// Hands the shared file to Windows, once its share UI asks for it. + fn provide_shared_file(arguments: &DataRequestedEventArgs, path: &str) -> windows::core::Result<()> { + let request = arguments.Request()?; + let lookup_started = Instant::now(); + let file = StorageFile::GetFileFromPathAsync(&HSTRING::from(path))?.get()?; + let lookup_duration = lookup_started.elapsed(); + if lookup_duration > DATA_PACKAGE_DEADLINE { + warn!(Source = "Share sheet"; "Looking up the shared file took {} ms, but Windows allows only {} ms to provide the data.", lookup_duration.as_millis(), DATA_PACKAGE_DEADLINE.as_millis()); + } + + let file: IStorageItem = file.cast()?; + let items = IIterable::::from(vec![Some(file)]); + let data = request.Data()?; + data.Properties()?.SetTitle(&HSTRING::from("MindWork AI Studio"))?; + data.SetStorageItemsReadOnly(&items)?; + observe_share_outcome(&data); + Ok(()) + } + + /// Logs how the share ends. Windows reports this for each data package, and each share gets a + /// package of its own, so these handlers never stack up. Nothing depends on these events: they + /// only tell us in the log what the user saw. + fn observe_share_outcome(data: &DataPackage) { + let completed = TypedEventHandler::::new(|_, arguments| { + let target_app = arguments.as_ref() + .and_then(|arguments| arguments.ShareTarget().ok()) + .and_then(|target| target.AppUserModelId().ok()) + .map(|app_id| app_id.to_string()) + .unwrap_or_default(); + + info!(Source = "Share sheet"; "The Windows share sheet shared the file with '{target_app}'."); + Ok(()) + }); + + // Windows offers this event since version 1703: + if let Err(error) = data.ShareCompleted(&completed) { + debug!(Source = "Share sheet"; "Cannot observe whether the share completes: {error}"); + } + + let canceled = TypedEventHandler::::new(|_, _| { + info!(Source = "Share sheet"; "The Windows share sheet closed without sharing the file."); + Ok(()) + }); + + // Windows offers this event since version 2004: + if let Err(error) = data.ShareCanceled(&canceled) { + debug!(Source = "Share sheet"; "Cannot observe whether the share gets canceled: {error}"); + } + } + // The DataTransferManager belongs to the window, not to a single share. Registering a handler // for every share would stack them up, and each stale handler keeps pointing at the archive of // its own share, which gets cleaned up after a while. We therefore remember the registration @@ -115,6 +211,7 @@ async fn share_file_on_platform(path: PathBuf) -> Result<(), String> { let ui_window = window.clone(); let path = path.to_string_lossy().to_string(); let (sender, receiver) = tokio::sync::oneshot::channel(); + let (data_requested_sender, data_requested_receiver) = tokio::sync::oneshot::channel::>(); window.run_on_main_thread(move || { let result = (|| -> Result<(), String> { @@ -123,18 +220,39 @@ async fn share_file_on_platform(path: PathBuf) -> Result<(), String> { .map_err(|error| format!("Failed to access the Windows share service: {error}"))?; let manager: DataTransferManager = unsafe { interop.GetForWindow(hwnd) } .map_err(|error| format!("Failed to create the Windows share request: {error}"))?; + + // + // When its share UI really opens, Windows first asks for the data and lists the targets + // only afterward. How long that takes shows in the log whether the share UI responded at + // all. Registering the handler below takes no measurable time, so we start measuring here. + // + // The handler runs on the main thread. Like the window events in start_tauri, it must + // therefore never lock MAIN_WINDOW: it would deadlock as soon as anybody else holds + // that lock. + // + let share_started = Instant::now(); + let mut data_requested = Some(data_requested_sender); let handler = TypedEventHandler::::new(move |_, arguments| { let Some(arguments) = arguments.as_ref() else { return Ok(()); }; - let request = arguments.Request()?; - let file = StorageFile::GetFileFromPathAsync(&HSTRING::from(&path))?.get()?; - let file: IStorageItem = file.cast()?; - let items = IIterable::::from(vec![Some(file)]); - request.Data()?.Properties()?.SetTitle(&HSTRING::from("MindWork AI Studio"))?; - request.Data()?.SetStorageItemsReadOnly(&items)?; - Ok(()) + info!(Source = "Share sheet"; "Windows requested the shared file {} ms after AI Studio opened its share UI.", share_started.elapsed().as_millis()); + let result = provide_shared_file(arguments, &path); + match data_requested.take() { + // The first request answers the share call, which waits for it: + Some(sender) => { + let _ = sender.send(result.clone().map_err(|error| format!("Failed to provide the shared file to Windows: {error}"))); + }, + + // Windows asked again, or only after the share call stopped waiting. Windows + // shows a failure in its share UI, but would not log it anywhere: + None => if let Err(error) = &result { + error!(Source = "Share sheet"; "Failed to provide the shared file to Windows: {error}"); + }, + } + + result }); if let Some(previous_token) = DATA_REQUESTED_TOKEN.with(|token| token.borrow_mut().take()) { let _ = manager.RemoveDataRequested(previous_token); @@ -150,11 +268,25 @@ async fn share_file_on_platform(path: PathBuf) -> Result<(), String> { let _ = sender.send(result); }).map_err(|error| format!("Failed to schedule the Windows share sheet: {error}"))?; - receiver.await.map_err(|_| String::from("The Windows share sheet did not return a result."))? + receiver.await.map_err(|_| String::from("The Windows share sheet did not return a result."))??; + + // + // Windows accepts a share request even when its share UI hangs and never appears. Only when + // the share UI asks for the file do we know that it opened. We wait for that without blocking + // the worker, so that the other calls of the app go on meanwhile: + // + match tokio::time::timeout(DATA_REQUEST_TIMEOUT, data_requested_receiver).await { + Ok(Ok(result)) => result.map_err(ShareFailure::from), + Ok(Err(_)) => Err(String::from("The Windows share sheet discarded the share request before it asked for the file.").into()), + Err(_) => Err(ShareFailure { + outcome: ShareOutcome::Unresponsive, + issue: format!("Windows accepted the share request, but its share UI did not ask for the file within {} seconds.", DATA_REQUEST_TIMEOUT.as_secs()), + }), + } } #[cfg(target_os = "macos")] -async fn share_file_on_platform(path: PathBuf) -> Result<(), String> { +async fn share_file_on_platform(path: PathBuf) -> Result<(), ShareFailure> { use std::cell::RefCell; use objc2::rc::Retained; use objc2::runtime::AnyObject; @@ -196,5 +328,37 @@ async fn share_file_on_platform(path: PathBuf) -> Result<(), String> { let _ = sender.send(result); }).map_err(|error| format!("Failed to schedule the macOS share sheet: {error}"))?; - receiver.await.map_err(|_| String::from("The macOS share sheet did not return a result."))? + receiver.await.map_err(|_| String::from("The macOS share sheet did not return a result."))?.map_err(ShareFailure::from) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_plain_error_text_counts_as_a_failure() { + let share_failure = ShareFailure::from(String::from("The main window is not available.")); + + assert_eq!(share_failure.outcome, ShareOutcome::Failed); + assert_eq!(share_failure.issue, "The main window is not available."); + } + + /// The app reads these values as ShareSheetOutcome. A renamed value would turn every answer into + /// one the app does not know. + #[test] + fn the_response_names_the_outcome_as_the_app_reads_it() { + let opened = serde_json::to_value(ShareFileResponse { outcome: ShareOutcome::Opened, issue: String::new() }).unwrap(); + let failed = serde_json::to_value(failure("The file path is empty.").0).unwrap(); + + assert_eq!(opened, serde_json::json!({ "outcome": "opened", "issue": "" })); + assert_eq!(failed, serde_json::json!({ "outcome": "failed", "issue": "The file path is empty." })); + } + + #[cfg(windows)] + #[test] + fn an_unresponsive_share_ui_is_named_as_the_app_reads_it() { + let unresponsive = serde_json::to_value(ShareFileResponse { outcome: ShareOutcome::Unresponsive, issue: String::new() }).unwrap(); + + assert_eq!(unresponsive["outcome"], "unresponsive"); + } }