Merge branch 'main' into add-ui-elements-datasource

This commit is contained in:
PaulKoudelka committed 2026-10-20 17:37:13 +02:00
commit a654594aea
39 files changed
+1116 -470

No files matched your search

+3
View File
@@ -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
+3 -2
View File
@@ -187,8 +187,9 @@ check_one() {
local failing_since
failing_since=$(sed -nE 's/^<!-- failing-since: ([0-9TZ:-]+) -->$/\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
+24 -1
View File
@@ -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
+1
View File
@@ -7,6 +7,7 @@ on:
- reopened
- synchronize
- ready_for_review
- converted_to_draft
schedule:
- cron: "17 4 * * *"
workflow_dispatch:
+11
View File
@@ -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
+5 -3
View File
@@ -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:
+2 -2
View File
@@ -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.
@@ -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."
@@ -1556,8 +1556,39 @@ public partial class ChatComponent : MSGComponentBase
this.unstoredChat = null;
}
private async Task StartNewChat(bool useSameWorkspace = false, bool deletePreviousChat = false)
/// <summary>
/// Starts a new chat in the given workspace, on behalf of the workspace tree.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="workspaceId">The ID of the workspace.</param>
public Task StartNewChatInWorkspaceAsync(Guid workspaceId) => workspaceId == Guid.Empty
? Task.CompletedTask
: this.StartNewChat(targetWorkspaceId: workspaceId);
/// <summary>
/// Starts a new chat in place of the open one.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="useSameWorkspace">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.</param>
/// <param name="deletePreviousChat">Whether to delete the open chat first.</param>
/// <param name="targetWorkspaceId">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.</param>
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.
/// </remarks>
/// <param name="name">The name of the thread.</param>
/// <param name="workspaceId">The workspace of the thread. Without one, the thread goes to the workspace of the open chat.</param>
/// <returns>The new thread.</returns>
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(),
@@ -0,0 +1,29 @@
@using AIStudio.Settings.DataModel
@using AIStudio.Tools.PluginSystem
@inherits MSGComponentBase
<MudStack Row="true" Wrap="Wrap.Wrap">
<MudMenu EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Label="@T("Add Data Source")" Color="Color.Primary" Variant="Variant.Filled" AnchorOrigin="Origin.TopLeft" TransformOrigin="Origin.BottomLeft">
<MudMenuItem OnClick="@(() => this.AddDataSource(DataSourceType.ERI_V1))">
@T("External Data (ERI-Server v1)")
</MudMenuItem>
@*
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:
*@
<MudMenuItem OnClick="@(() => this.AddDataSource(DataSourceType.LOCAL_DIRECTORY))" Disabled="@(!this.HasEmbeddingProvider)">
@(this.HasEmbeddingProvider ? T("Local Directory") : T("Local Directory, requires an embedding provider"))
</MudMenuItem>
<MudMenuItem OnClick="@(() => this.AddDataSource(DataSourceType.LOCAL_FILE))" Disabled="@(!this.HasEmbeddingProvider)">
@(this.HasEmbeddingProvider ? T("Local File") : T("Local File, requires an embedding provider"))
</MudMenuItem>
@if (PreviewFeatures.PRE_MAILBOXES_2026.IsEnabled(this.SettingsManager))
{
@* Shown with a lock rather than left out, like the locked buttons for adding providers: *@
<MudMenuItem OnClick="@this.AddMailbox" Disabled="@(!this.MayAddMailbox || !this.HasEmbeddingProvider)" Icon="@(this.MayAddMailbox ? null : Icons.Material.Filled.Lock)">
@this.MailboxMenuText
</MudMenuItem>
}
</MudMenu>
<LockableButton Text="@T("Import Data Source")" IsLocked="@(() => !this.SettingsManager.ConfigurationData.App.CanImportConfigurationSnippet(PluginConfigurationObjectType.DATA_SOURCE))" Icon="@Icons.Material.Filled.FileDownload" OnClickAsync="@this.ImportERIDataSource" ButtonVariant="Variant.Outlined" NoMargin="true" />
</MudStack>
@@ -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;
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// Whether an embedding provider exists, which every local data source needs.
/// </summary>
private bool HasEmbeddingProvider => !EmbeddingProviderSetup.IsMissing(this.SettingsManager);
/// <remarks>
/// The rule of the organization comes first: an embedding provider would not make the mailbox possible.
/// </remarks>
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<T>(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<DataSourceERI_V1Dialog>
{
{ x => x.IsEditing, false },
{ x => x.IsImport, true },
{ x => x.DataSource, dataSource },
{ x => x.ImportedSecret, import.Secret },
};
var eriDialogReference = await this.DialogService.ShowAsync<DataSourceERI_V1Dialog>(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<DataSourceLocalFileDialog>
{
{ x => x.IsEditing, false },
};
var localFileDialogReference = await this.DialogService.ShowAsync<DataSourceLocalFileDialog>(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<DataSourceLocalDirectoryDialog>
{
{ x => x.IsEditing, false },
};
var localDirectoryDialogReference = await this.DialogService.ShowAsync<DataSourceLocalDirectoryDialog>(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<DataSourceERI_V1Dialog>
{
{ x => x.IsEditing, false },
};
var eriDialogReference = await this.DialogService.ShowAsync<DataSourceERI_V1Dialog>(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<bool>(this, Event.CONFIGURATION_CHANGED);
}
private async Task AddMailbox()
{
if (!this.MayAddMailbox || !this.HasEmbeddingProvider)
return;
var dialogParameters = new DialogParameters<DataSourceMailboxDialog>
{
{ x => x.IsEditing, false },
};
var dialogReference = await this.DialogService.ShowAsync<DataSourceMailboxDialog>(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<bool>(this, Event.CONFIGURATION_CHANGED);
}
}
@@ -1,6 +1,5 @@
@using AIStudio.Settings
@using AIStudio.Settings.DataModel
@using AIStudio.Tools.PluginSystem
@inherits MSGComponentBase
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@@ -125,30 +124,4 @@
<MudText Typo="Typo.h6" Class="mt-3">
@T("No data sources configured yet.")
</MudText>
}
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mt-3 mb-6">
<MudMenu EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Label="@T("Add Data Source")" Color="Color.Primary" Variant="Variant.Filled" AnchorOrigin="Origin.TopLeft" TransformOrigin="Origin.BottomLeft">
<MudMenuItem OnClick="@(() => this.AddDataSource(DataSourceType.ERI_V1))">
@T("External Data (ERI-Server v1)")
</MudMenuItem>
@*
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:
*@
<MudMenuItem OnClick="@(() => this.AddDataSource(DataSourceType.LOCAL_DIRECTORY))" Disabled="@(!this.HasEmbeddingProvider)">
@(this.HasEmbeddingProvider ? T("Local Directory") : T("Local Directory, requires an embedding provider"))
</MudMenuItem>
<MudMenuItem OnClick="@(() => this.AddDataSource(DataSourceType.LOCAL_FILE))" Disabled="@(!this.HasEmbeddingProvider)">
@(this.HasEmbeddingProvider ? T("Local File") : T("Local File, requires an embedding provider"))
</MudMenuItem>
@if (PreviewFeatures.PRE_MAILBOXES_2026.IsEnabled(this.SettingsManager))
{
@* Shown with a lock rather than left out, like the locked buttons for adding providers: *@
<MudMenuItem OnClick="@this.AddMailbox" Disabled="@(!this.MayAddMailbox || !this.HasEmbeddingProvider)" Icon="@(this.MayAddMailbox ? null : Icons.Material.Filled.Lock)">
@this.MailboxMenuText
</MudMenuItem>
}
</MudMenu>
<LockableButton Text="@T("Import Data Source")" IsLocked="@(() => !this.SettingsManager.ConfigurationData.App.CanImportConfigurationSnippet(PluginConfigurationObjectType.DATA_SOURCE))" Icon="@Icons.Material.Filled.FileDownload" OnClickAsync="@this.ImportERIDataSource" ButtonVariant="Variant.Outlined" NoMargin="true" />
</MudStack>
}
@@ -14,7 +14,8 @@ namespace AIStudio.Components;
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// Whether an embedding provider exists, which every local data source needs.
/// </summary>
private bool HasEmbeddingProvider => !EmbeddingProviderSetup.IsMissing(this.SettingsManager);
/// <remarks>
/// The rule of the organization comes first: an embedding provider would not make the mailbox possible.
/// </remarks>
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<bool>(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<DataSourceERI_V1Dialog>
{
{ x => x.IsEditing, false },
{ x => x.IsImport, true },
{ x => x.DataSource, dataSource },
{ x => x.ImportedSecret, import.Secret },
};
var eriDialogReference = await this.DialogService.ShowAsync<DataSourceERI_V1Dialog>(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<DataSourceLocalFileDialog>
{
{ x => x.IsEditing, false },
};
var localFileDialogReference = await this.DialogService.ShowAsync<DataSourceLocalFileDialog>(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<DataSourceLocalDirectoryDialog>
{
{ x => x.IsEditing, false },
};
var localDirectoryDialogReference = await this.DialogService.ShowAsync<DataSourceLocalDirectoryDialog>(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<DataSourceERI_V1Dialog>
{
{ x => x.IsEditing, false },
};
var eriDialogReference = await this.DialogService.ShowAsync<DataSourceERI_V1Dialog>(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<bool>(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<DataSourceMailboxDialog>
{
{ x => x.IsEditing, false },
};
var dialogReference = await this.DialogService.ShowAsync<DataSourceMailboxDialog>(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<bool>(this, Event.CONFIGURATION_CHANGED);
}
private async Task EditMailbox(DataSourceMailbox mailbox)
{
if (await MailboxEditing.EditAsync(this.DialogService, this.SettingsManager, this.DataSourceEmbeddingService, mailbox.Id))
@@ -6,5 +6,8 @@
<ExpansionPanel HeaderIcon="@AppIcons.DATABASE" HeaderText="@T("Configure Data Sources")">
<PreviewBeta ApplyInnerScrollingFix="true"/>
<DataSourceManagement/>
<div class="mt-3 mb-6">
<DataSourceAddButtons/>
</div>
</ExpansionPanel>
}
@@ -33,6 +33,17 @@ public partial class Workspaces : MSGComponentBase
[Parameter]
public EventCallback<ChatThread> CurrentChatThreadChanged { get; set; }
/// <summary>
/// Asks the page to start a new chat in the workspace with the given ID.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[Parameter]
public EventCallback<Guid> 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);
}
/// <summary>
/// Asks the chat component to start a new chat in the given workspace.
/// </summary>
/// <param name="workspacePath">The directory of the workspace.</param>
/// <remarks>
/// 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.
/// </remarks>
private async Task AddChatAsync(string workspacePath)
{
if (await MessageBus.INSTANCE.SendMessageUseFirstResult<bool, bool>(this, Event.HAS_CHAT_UNSAVED_CHANGES))
{
var dialogParameters = new DialogParameters<ConfirmDialog>
{
{ 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<ConfirmDialog>(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
@@ -1,7 +1,7 @@
@using AIStudio.Tools.PluginSystem
@inherits SettingsDialogBase
<MudDialog>
<MudDialog ContentClass="dialog-content-aligned" ActionsClass="dialog-actions-aligned">
<TitleContent>
<MudText Typo="Typo.h6" Class="d-flex align-center">
<MudIcon Icon="@Icons.Material.Filled.RateReview" Class="mr-2" />
@@ -81,15 +81,16 @@
@T("No chat templates configured yet.")
</MudText>
}
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mt-3 mb-6">
</DialogContent>
<DialogActions>
@* In the footer, adding and importing stay in reach however long the table grows: *@
<MudStack Row="true" Wrap="Wrap.Wrap">
<MudButton Variant="Variant.Filled" Color="@Color.Primary" StartIcon="@Icons.Material.Filled.AddRoad" OnClick="@this.AddChatTemplate">
@T("Add Chat Template")
</MudButton>
<LockableButton Text="@T("Import Chat Template")" IsLocked="@(() => !this.SettingsManager.ConfigurationData.App.CanImportConfigurationSnippet(PluginConfigurationObjectType.CHAT_TEMPLATE))" Icon="@Icons.Material.Filled.FileDownload" OnClickAsync="@this.ImportChatTemplate" ButtonVariant="Variant.Outlined" NoMargin="true" />
</MudStack>
</DialogContent>
<DialogActions>
<MudSpacer/>
<MudButton OnClick="@this.Close" Variant="Variant.Filled">
@T("Close")
</MudButton>
@@ -1,6 +1,6 @@
@inherits SettingsDialogBase
<MudDialog>
<MudDialog ContentClass="dialog-content-aligned" ActionsClass="dialog-actions-aligned">
<TitleContent>
<PreviewBeta/>
<MudText Typo="Typo.h6" Class="d-flex align-center">
@@ -12,6 +12,9 @@
<DataSourceManagement/>
</DialogContent>
<DialogActions>
@* In the footer, adding and importing stay in reach however long the table grows: *@
<DataSourceAddButtons/>
<MudSpacer/>
<MudButton OnClick="@this.Close" Variant="Variant.Filled">
@T("Close")
</MudButton>
@@ -1,7 +1,7 @@
@using AIStudio.Tools.PluginSystem
@inherits SettingsDialogBase
<MudDialog>
<MudDialog ContentClass="dialog-content-aligned" ActionsClass="dialog-actions-aligned">
<TitleContent>
<MudText Typo="Typo.h6" Class="d-flex align-center">
<MudIcon Icon="@Icons.Material.Filled.Person4" Class="mr-2" />
@@ -64,15 +64,16 @@
@T("No profiles configured yet.")
</MudText>
}
<MudStack Row="true" Wrap="Wrap.Wrap" Class="mt-3 mb-6">
</DialogContent>
<DialogActions>
@* In the footer, adding and importing stay in reach however long the table grows: *@
<MudStack Row="true" Wrap="Wrap.Wrap">
<MudButton Variant="Variant.Filled" Color="@Color.Primary" StartIcon="@Icons.Material.Filled.AddRoad" OnClick="@this.AddProfile">
@T("Add Profile")
</MudButton>
<LockableButton Text="@T("Import Profile")" IsLocked="@(() => !this.SettingsManager.ConfigurationData.App.CanImportConfigurationSnippet(PluginConfigurationObjectType.PROFILE))" Icon="@Icons.Material.Filled.FileDownload" OnClickAsync="@this.ImportProfile" ButtonVariant="Variant.Outlined" NoMargin="true" />
</MudStack>
</DialogContent>
<DialogActions>
<MudSpacer/>
<MudButton OnClick="@this.Close" Variant="Variant.Filled">
@T("Close")
</MudButton>
+6 -3
View File
@@ -62,7 +62,7 @@
</MudStack>
</HeaderContent>
<ChildContent>
<Workspaces @ref="this.workspaces" @bind-CurrentChatThread="@this.chatThread" @bind-SearchVisible="@this.workspaceSearchVisible"/>
<Workspaces @ref="this.workspaces" @bind-CurrentChatThread="@this.chatThread" @bind-SearchVisible="@this.workspaceSearchVisible" StartNewChatRequested="@this.StartNewChatInWorkspace"/>
</ChildContent>
</InnerScrolling>
}
@@ -88,13 +88,14 @@
</MudStack>
</HeaderContent>
<ChildContent>
<Workspaces @ref="this.workspaces" @bind-CurrentChatThread="@this.chatThread" @bind-SearchVisible="@this.workspaceSearchVisible"/>
<Workspaces @ref="this.workspaces" @bind-CurrentChatThread="@this.chatThread" @bind-SearchVisible="@this.workspaceSearchVisible" StartNewChatRequested="@this.StartNewChatInWorkspace"/>
</ChildContent>
</InnerScrolling>
}
</StartContent>
<EndContent>
<ChatComponent
@ref="this.chatComponent"
@bind-ChatThread="@this.chatThread"
@bind-Provider="@this.providerSettings"
IsAnswerBeingWrittenChanged="@(isBeingWritten => this.isAnswerBeingWritten = isBeingWritten)"
@@ -123,6 +124,7 @@
</MudPaper>
<ChatComponent
@ref="this.chatComponent"
@bind-ChatThread="@this.chatThread"
@bind-Provider="@this.providerSettings"
IsAnswerBeingWrittenChanged="@(isBeingWritten => this.isAnswerBeingWritten = isBeingWritten)"
@@ -135,6 +137,7 @@
{
// Case: Workspaces are disabled or shown in an overlay
<ChatComponent
@ref="this.chatComponent"
@bind-ChatThread="@this.chatThread"
@bind-Provider="@this.providerSettings"
IsAnswerBeingWrittenChanged="@(isBeingWritten => this.isAnswerBeingWritten = isBeingWritten)"
@@ -167,7 +170,7 @@
</MudStack>
</MudDrawerHeader>
<MudDrawerContainer Class="ml-6">
<Workspaces @ref="this.workspaces" @bind-CurrentChatThread="@this.chatThread" @bind-SearchVisible="@this.workspaceSearchVisible"/>
<Workspaces @ref="this.workspaces" @bind-CurrentChatThread="@this.chatThread" @bind-SearchVisible="@this.workspaceSearchVisible" StartNewChatRequested="@this.StartNewChatInWorkspace"/>
</MudDrawerContainer>
</MudDrawer>
}
@@ -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();
}
/// <summary>
/// Starts a new chat in a workspace, when the workspace tree asks for one.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="workspaceId">The ID of the workspace.</param>
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()
+1 -1
View File
@@ -131,7 +131,7 @@
@if (context is IAvailablePlugin shareablePlugin && CanSharePlugin(shareablePlugin))
{
<MudTooltip Text="@(this.AllowPluginSharing ? this.SharePluginTooltip : this.SharePluginLockText)">
<MudIconButton Icon="@SharePluginIcon" Size="Size.Medium" OnClick="@(() => this.SharePluginAsync(shareablePlugin))" Disabled="@(this.isSharingPlugin || !this.AllowPluginSharing)"/>
<MudIconButton Icon="@this.SharePluginIcon" Size="Size.Medium" OnClick="@(() => this.SharePluginAsync(shareablePlugin))" Disabled="@(this.isSharingPlugin || !this.AllowPluginSharing)"/>
</MudTooltip>
}
+52 -15
View File
@@ -274,14 +274,15 @@ public partial class Plugins : MSGComponentBase
private bool AllowPluginSharing => this.SettingsManager.ConfigurationData.App.AllowUserToSharePlugins;
/// <summary>
/// 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.
/// </summary>
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
}
}
/// <summary>
/// Offers to export the plugin archive after the share sheet of the operating system failed or
/// did not respond.
/// </summary>
/// <param name="issue">Why the share sheet did not open.</param>
/// <returns>True when the user wants to export the archive instead.</returns>
private async Task<bool> ConfirmExportInsteadOfSharingAsync(string issue)
{
var dialogParameters = new DialogParameters<ConfirmDialog>
{
{ 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<ConfirmDialog>(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)
@@ -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."
@@ -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."
+1 -1
View File
@@ -239,7 +239,7 @@ internal sealed class Program
builder.Services.AddSingleton<DatabaseClientProvider>();
builder.Services.AddHostedService<GlobalShortcutService>(serviceProvider => serviceProvider.GetRequiredService<GlobalShortcutService>());
builder.Services.AddHostedService<RustAvailabilityMonitorService>();
builder.Services.AddScoped<NativeShareService>();
builder.Services.AddSingleton<NativeShareService>();
builder.Services.AddScoped<PluginShareService>();
//
@@ -0,0 +1,9 @@
namespace AIStudio.Tools.Rust;
/// <summary>
/// The answer of the runtime to a request to share a file through the share sheet of the
/// operating system.
/// </summary>
/// <param name="Outcome">What became of the request.</param>
/// <param name="Issue">Why the share sheet did not open; empty when it opened.</param>
public sealed record ShareFileResponse(ShareSheetOutcome Outcome, string Issue);
@@ -0,0 +1,30 @@
namespace AIStudio.Tools.Rust;
/// <summary>
/// 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.
/// </summary>
public enum ShareSheetOutcome
{
/// <summary>
/// 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.
/// </summary>
UNKNOWN,
/// <summary>
/// The operating system opened its share sheet for the file.
/// </summary>
OPENED,
/// <summary>
/// The share sheet could not be opened, or the request was invalid.
/// </summary>
FAILED,
/// <summary>
/// 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.
/// </summary>
UNRESPONSIVE,
}
@@ -1,6 +1,40 @@
using AIStudio.Tools.Rust;
namespace AIStudio.Tools.Services;
public sealed class NativeShareService(RustService rustService)
/// <summary>
/// Hands files to the share sheet of the operating system, and remembers when that share sheet
/// failed.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class NativeShareService(RustService rustService, ILogger<NativeShareService> logger)
{
public Task<bool> Share(string filePath) => rustService.ShareFile(filePath);
}
private volatile bool isUnavailable;
/// <summary>
/// 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.
/// </summary>
public bool IsAvailable => !OperatingSystem.IsLinux() && !this.isUnavailable;
/// <summary>
/// Opens the share sheet of the operating system for a file.
/// </summary>
/// <param name="filePath">The plugin archive to share.</param>
/// <returns>What became of the request.</returns>
public async Task<ShareFileResponse> 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;
}
}
@@ -0,0 +1,33 @@
namespace AIStudio.Tools.Services;
/// <summary>
/// What became of sharing or exporting a plugin archive.
/// </summary>
public enum PluginShareOutcome
{
/// <summary>
/// The share sheet of the operating system opened for the plugin archive.
/// </summary>
SHARED,
/// <summary>
/// The plugin archive was written to the location the user chose.
/// </summary>
EXPORTED,
/// <summary>
/// The user canceled choosing a location for the export.
/// </summary>
CANCELLED,
/// <summary>
/// The plugin could not be shared or exported.
/// </summary>
FAILED,
/// <summary>
/// The share sheet of the operating system failed or did not respond. The plugin archive can
/// still be exported instead.
/// </summary>
SHARE_SHEET_UNAVAILABLE,
}
@@ -1,3 +1,10 @@
namespace AIStudio.Tools.Services;
public sealed record PluginShareResult(bool Success, string PluginName, string ArchivePath, string Issue, bool Cancelled = false);
/// <summary>
/// The result of sharing or exporting a plugin archive.
/// </summary>
/// <param name="Outcome">What became of the request.</param>
/// <param name="PluginName">The name of the plugin.</param>
/// <param name="ArchivePath">Where the archive was written; empty when there is none.</param>
/// <param name="Issue">Why the plugin could not be shared or exported; empty otherwise.</param>
public sealed record PluginShareResult(PluginShareOutcome Outcome, string PluginName, string ArchivePath, string Issue);
@@ -7,8 +7,10 @@ namespace AIStudio.Tools.Services;
public sealed class PluginShareService(NativeShareService nativeShareService, RustService rustService, SettingsManager settingsManager, ILogger<PluginShareService> 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;
/// <summary>
/// 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.
/// </summary>
public bool UsesExport => !nativeShareService.IsAvailable;
/// <summary>
/// Creates a shareable plugin archive from a local plugin and hands it over to the user.
/// The archive contains the plugin root contents, so <c>plugin.lua</c> is located at the archive root.
@@ -29,31 +37,73 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru
/// <remarks>
/// 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.
/// </remarks>
/// <param name="plugin">The local plugin to archive and share.</param>
/// <param name="token">Cancellation token for archive creation.</param>
/// <returns>The share result, including the archive path when successful.</returns>
/// <returns>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.</returns>
public async Task<PluginShareResult> 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);
}
/// <summary>
/// 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.
/// </summary>
/// <param name="plugin">The local plugin to archive and export.</param>
/// <param name="token">Cancellation token for archive creation.</param>
/// <returns>The export result, including the chosen archive path when successful.</returns>
public async Task<PluginShareResult> 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);
}
/// <summary>
/// Checks whether a plugin may leave AI Studio as an archive at all, no matter whether it is
/// shared or exported.
/// </summary>
/// <param name="plugin">The local plugin to check.</param>
/// <param name="pluginRoot">The validated plugin root directory, when the plugin may leave.</param>
/// <param name="issue">Why the plugin may not leave; empty otherwise.</param>
/// <returns>True when the plugin may be shared or exported.</returns>
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);
}
/// <summary>
/// Asks the user for a target location and writes the plugin archive to it.
/// </summary>
@@ -61,12 +111,12 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru
/// <param name="pluginRoot">The validated plugin root directory.</param>
/// <param name="token">Cancellation token for archive creation.</param>
/// <returns>The share result, including the chosen archive path when successful.</returns>
private async Task<PluginShareResult> ExportAsync(IAvailablePlugin plugin, string pluginRoot, CancellationToken token)
private async Task<PluginShareResult> 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)
{
@@ -1,9 +1,16 @@
// ReSharper disable NotAccessedPositionalProperty.Local
using AIStudio.Tools.Rust;
namespace AIStudio.Tools.Services;
public sealed partial class RustService
{
public async Task<bool> ShareFile(string filePath)
/// <summary>
/// Asks the runtime to hand a file to the share sheet of the operating system.
/// </summary>
/// <param name="filePath">The plugin archive to share.</param>
/// <returns>What became of the request. Any failure to reach the runtime counts as FAILED.</returns>
public async Task<ShareFileResponse> 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<ShareFileResponse>(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);
}
+22
View File
@@ -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;
}
@@ -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
+51
View File
@@ -0,0 +1,51 @@
using System.Text.Json;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks that the app reads every outcome which the runtime sends after a share request.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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<ShareFileResponse>($$"""{"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<ShareFileResponse>("""{"outcome":"vanished","issue":""}""", AS_THE_RUST_SERVICE_READS_IT);
Assert.That(response?.Outcome, Is.EqualTo(ShareSheetOutcome.UNKNOWN));
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

+3 -1
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

+185 -21
View File
@@ -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<String> for ShareFailure {
fn from(issue: String) -> Self {
Self {
outcome: ShareOutcome::Failed,
issue,
}
}
}
pub async fn share_file(Json(request): Json<ShareFileRequest>) -> Json<ShareFileResponse> {
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<ShareFileRequest>) -> Json<ShareFile
Ok(()) => {
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<String>) -> Json<ShareFileResponse> {
Json(ShareFileResponse {
success: false,
outcome: ShareOutcome::Failed,
issue: issue.into(),
})
}
@@ -87,20 +122,81 @@ fn failure(issue: impl Into<String>) -> Json<ShareFileResponse> {
// 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::<IStorageItem>::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::<DataPackage, ShareCompletedEventArgs>::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::<DataPackage, IInspectable>::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::<Result<(), String>>();
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::<DataTransferManager, DataRequestedEventArgs>::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::<IStorageItem>::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");
}
}