mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-27 05:33:37 +00:00
Merge branch 'main' into fix/transcription-dialog-api-key-not-loaded
This commit is contained in:
commit
6c5c982f92
87
.github/workflows/build-and-release.yml
vendored
87
.github/workflows/build-and-release.yml
vendored
@ -724,9 +724,94 @@ jobs:
|
||||
overwrite: true
|
||||
retention-days: ${{ env.RETENTION_INTERMEDIATE_ASSETS }}
|
||||
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
verify:
|
||||
name: Verify
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Read the toolchain versions from the metadata
|
||||
run: |
|
||||
# The .NET SDK version. The format is '9.0.205 (commit 3e1383b780)',
|
||||
# so we extract the version number alone:
|
||||
dotnet_sdk_version=$(sed -n '4p' metadata.txt | sed 's/[^0-9.]*\([0-9.]*\).*/\1/')
|
||||
|
||||
# The Rust version, written the same way:
|
||||
rust_version=$(sed -n '6p' metadata.txt | sed 's/[^0-9.]*\([0-9.]*\).*/\1/')
|
||||
|
||||
echo "DOTNET_SDK_VERSION=${dotnet_sdk_version}" >> $GITHUB_ENV
|
||||
echo "RUST_VERSION=${rust_version}" >> $GITHUB_ENV
|
||||
|
||||
echo ".NET SDK version: '${dotnet_sdk_version}'"
|
||||
echo "Rust version: '${rust_version}'"
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_SDK_VERSION }}
|
||||
cache: true
|
||||
cache-dependency-path: 'app/MindWork AI Studio/packages.lock.json'
|
||||
|
||||
- name: Cache Rust
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/git/db/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
runtime/target
|
||||
|
||||
key: verify-linux-x64-rust-${{ env.RUST_VERSION }}
|
||||
|
||||
- name: Setup Rust (stable)
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
components: clippy
|
||||
|
||||
- name: Setup dependencies (Ubuntu-specific)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libfuse2 xdg-utils gstreamer1.0-plugins-base gstreamer1.0-plugins-good
|
||||
|
||||
- name: Place stand-ins for what Tauri's build script expects
|
||||
run: |
|
||||
# Tauri's build script insists that everything the configuration lists is already there,
|
||||
# and refuses to run otherwise -- so nothing Rust compiles without it. Two of those
|
||||
# things are products of a build which has not run here: the .NET app as a sidecar, and
|
||||
# the PDF library which the build downloads into the resources. The other two resource
|
||||
# directories, notices and tokenizers, are in the repository and need nothing.
|
||||
#
|
||||
# This job never bundles anything and never starts the app; it compiles, tests and lints
|
||||
# the Rust code, and no test opens either file. Empty stand-ins are therefore enough,
|
||||
# while publishing the sidecar and downloading the library would cost minutes for files
|
||||
# nobody here reads. Should a Rust test ever need the real library, this job has to
|
||||
# deploy it the way build_main does instead of placing a stand-in.
|
||||
mkdir -p "app/MindWork AI Studio/bin/dist"
|
||||
touch "app/MindWork AI Studio/bin/dist/mindworkAIStudioServer-x86_64-unknown-linux-gnu"
|
||||
chmod +x "app/MindWork AI Studio/bin/dist/mindworkAIStudioServer-x86_64-unknown-linux-gnu"
|
||||
|
||||
mkdir -p runtime/resources/libraries
|
||||
touch runtime/resources/libraries/stand-in-for-verify.txt
|
||||
|
||||
- name: Run the quality gate
|
||||
run: |
|
||||
cd "app/Build"
|
||||
dotnet run verify
|
||||
|
||||
build_main:
|
||||
name: Build app (${{ matrix.dotnet_runtime }})
|
||||
needs: [determine_run_mode, read_metadata]
|
||||
needs: [determine_run_mode, read_metadata, verify]
|
||||
if: needs.determine_run_mode.outputs.build_enabled == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -172,3 +172,6 @@ orleans.codegen.cs
|
||||
|
||||
# Tauri generated schemas/manifests
|
||||
/runtime/gen/
|
||||
|
||||
# Ignore what a failing snapshot test leaves behind for comparison:
|
||||
/app/Tests/Models/Corpus/CapabilitySnapshot.actual.txt
|
||||
|
||||
34
AGENTS.md
34
AGENTS.md
@ -80,7 +80,21 @@ Notes:
|
||||
troubleshooting, no matter whether it came from the MCP server or from the user.
|
||||
|
||||
### Running Tests
|
||||
Currently, no automated test suite exists in the repository.
|
||||
The .NET tests live in `app/Tests`, a single NUnit project that holds the tests of every area; each
|
||||
area gets its own folder and namespace below it rather than a project of its own. Agents run them
|
||||
through the IDE for the same reason they build there:
|
||||
|
||||
```
|
||||
mcp__rider__execute_terminal_command command: "cd app/Tests && dotnet test"
|
||||
```
|
||||
|
||||
An assembly-wide `[SetUpFixture]` in `app/Tests/TestHost.cs` fills the static application state that
|
||||
the app itself only fills while starting up, `Program.LOGGER_FACTORY` above all. Types that
|
||||
initialize a static logger from it — `Settings.Provider` among them — otherwise die in their type
|
||||
initializer before the first assertion. Prefer writing new code so that it does not reach for such
|
||||
statics at all.
|
||||
|
||||
The Rust tests run with `cargo test` in `runtime/`, through the `rustrover` MCP server.
|
||||
|
||||
## Architecture Details
|
||||
|
||||
@ -141,7 +155,8 @@ Key structure:
|
||||
Plugins are written in Lua and provide:
|
||||
- **Language plugins** - I18N translations (e.g., German language pack)
|
||||
- **Configuration plugins** - Enterprise IT configurations for centrally managed providers, settings
|
||||
- **Future:** Assistant plugins for custom assistants
|
||||
- **Assistant plugins** - custom assistants and direct-chat launchers, subject to approval or a local security audit
|
||||
- **Model plugins** - what an organization's own models can do, see `documentation/Models.md`
|
||||
|
||||
**Example configuration plugin:** `app/MindWork AI Studio/Plugins/configuration/plugin.lua`
|
||||
|
||||
@ -177,6 +192,21 @@ When adding, changing, or removing model-driven tools, keep these parts in sync:
|
||||
|
||||
Tool implementations must treat model-provided arguments as untrusted input. Validate settings and arguments, protect secrets with `SensitiveTraceArgumentNames`, use `ToolExecutionBlockedException` for intentional policy blocks, and check provider confidence before returning sensitive data to the model.
|
||||
|
||||
## Model Capabilities
|
||||
|
||||
**Documentation:** `documentation/Models.md`
|
||||
|
||||
What a model can do is answered in `app/MindWork AI Studio/Models/`, through `provider.GetModelProfile()`. Never ask `ModelRegistry` directly from a component: the extension method is what adds the expert settings and what a provider's model list reported, and the registry alone answers neither.
|
||||
|
||||
When adding, changing, or removing model knowledge, keep these parts in sync:
|
||||
- `app/MindWork AI Studio/Models/<Vendor>/<Family>.cs` for the family itself. Creating the class is enough — the source generator in `app/SourceGeneratedMappings/` collects every non-abstract `ModelFamily` and `IModelHost` at compile time, so there is no registration list. Do not add reflection here; `PublishTrimmed` is on.
|
||||
- `app/Tests/Models/Corpus/` for the model IDs the family covers, marked as either unchanged or expected to change. A porting difference which nobody declared is what the corpus exists to catch.
|
||||
- `app/MindWork AI Studio/Models/Kinds/` when the change is about what kind of model something is, rather than what it can do. These are ordinary rules of the same engine.
|
||||
- `app/MindWork AI Studio/Models/Hosting/Hosts/` when a provider wraps model names or cannot pass an API through. A host unwraps and trims the transport; it states nothing about the model itself.
|
||||
- `app/MindWork AI Studio/Plugins/models/plugin.lua` when a new field can be declared by an organization, and `app/MindWork AI Studio/Plugins/configuration/plugin.lua` when it can be overridden per provider instance.
|
||||
|
||||
Rules are never tried in order: specificity is computed from the rule, and two rules of equal specificity on one name fail the test suite. State how a model reasons with `Reasoning(...)` — the three reasoning capabilities are override vocabulary and must never appear in a profile. Every family and every host has to name the page it was read from and the day somebody read it; `dotnet run verify-models` reports the ones which have gone stale.
|
||||
|
||||
## RAG (Retrieval-Augmented Generation)
|
||||
|
||||
RAG integration is currently in development (preview feature). Architecture:
|
||||
|
||||
@ -189,6 +189,8 @@ You want to know how to build MindWork AI Studio from source? [Check out the ins
|
||||
|
||||
Do you want to add or maintain model-driven tools? [Read the tool development guide here](documentation/Tools.md).
|
||||
|
||||
Do you want to teach AI Studio what a model can do? [Read the model capabilities guide here](documentation/Models.md).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@ -87,8 +87,10 @@ public sealed partial class UpdateMetadataCommands
|
||||
await new CollectI18NKeysCommand().CollectI18NKeys();
|
||||
|
||||
// Build the final release, where Rust knows the updated metadata, the .NET
|
||||
// artifacts are already in place, and .NET knows the updated web assets, etc.:
|
||||
await this.Build(offline);
|
||||
// artifacts are already in place, and .NET knows the updated web assets, etc.
|
||||
// The gate already ran in the first build; running it a second time on the same
|
||||
// sources would only add minutes:
|
||||
await this.Build(offline, skipVerify: true);
|
||||
}
|
||||
|
||||
[Command("update-metainfo", Description = "Update the AppStream metainfo entry of one release from its changelog")]
|
||||
@ -221,11 +223,21 @@ public sealed partial class UpdateMetadataCommands
|
||||
|
||||
[Command("build", Description = "Build MindWork AI Studio")]
|
||||
public async Task Build(
|
||||
[Option("offline", Description = "Skip downloads and use locally available build dependencies")] bool offline = false)
|
||||
[Option("offline", Description = "Skip downloads and use locally available build dependencies")] bool offline = false,
|
||||
[Option("skip-verify", Description = "Skip the quality gate which otherwise runs before anything is built")] bool skipVerify = false)
|
||||
{
|
||||
if(!Environment.IsWorkingDirectoryValid())
|
||||
return;
|
||||
|
||||
|
||||
//
|
||||
// The gate runs before anything is built, and the build stops when it does not pass. That
|
||||
// way the same command answers both questions a person has -- is it sound, and does it
|
||||
// build -- and answers them in that order, because building something the tests reject
|
||||
// takes minutes to produce an artifact nobody should use.
|
||||
//
|
||||
if (!skipVerify && await new VerifyCommand().Verify() is not 0)
|
||||
throw new CommandExitedException(1);
|
||||
|
||||
//
|
||||
// Build the .NET project:
|
||||
//
|
||||
@ -596,7 +608,7 @@ public sealed partial class UpdateMetadataCommands
|
||||
|
||||
// Drop any earlier entry of this version, so that the version stays unique and moves to the top.
|
||||
// We remove from the back, so that the index of the remaining matches stays valid:
|
||||
foreach (var previousRelease in ReleaseBlockRegex().Matches(metainfo).Cast<Match>().Where(match => ReleaseTagHasVersion(match.Value, appVersion)).Reverse())
|
||||
foreach (var previousRelease in ReleaseBlockRegex().Matches(metainfo).Where(match => ReleaseTagHasVersion(match.Value, appVersion)).Reverse())
|
||||
metainfo = metainfo.Remove(previousRelease.Index, previousRelease.Length);
|
||||
|
||||
var lineEnding = metainfo.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
|
||||
|
||||
92
app/Build/Commands/VerifyCommand.cs
Normal file
92
app/Build/Commands/VerifyCommand.cs
Normal file
@ -0,0 +1,92 @@
|
||||
using Build.Tools;
|
||||
|
||||
// ReSharper disable ClassNeverInstantiated.Global
|
||||
// ReSharper disable UnusedType.Global
|
||||
// ReSharper disable UnusedMember.Global
|
||||
namespace Build.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// The quality gate: one command, the same one locally and in the pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every check runs, even after one of them has failed. A gate which stops at the first failure
|
||||
/// tells you one thing per run, and the next run costs the same minutes again -- while the point of
|
||||
/// running the whole thing is to learn everything which is wrong in one go.
|
||||
/// </remarks>
|
||||
public sealed class VerifyCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// How the .NET app is named once it lies where Tauri expects it.
|
||||
/// </summary>
|
||||
private const string SIDECAR_PREFIX = "mindworkAIStudioServer-";
|
||||
|
||||
[Command("verify", Description = "Run the quality gate: .NET tests, Rust tests, Clippy, and the model sources")]
|
||||
public async Task<int> Verify()
|
||||
{
|
||||
if(!Environment.IsWorkingDirectoryValid())
|
||||
return 1;
|
||||
|
||||
Console.WriteLine("==============================");
|
||||
Console.WriteLine("- Quality gate: every check runs, so that the first failure does not hide the next ...");
|
||||
|
||||
var results = new List<(string What, int ExitCode)>
|
||||
{
|
||||
(".NET tests", await CommandRunner.RunAsync(Environment.GetTestsDirectory(), "dotnet", "test --nologo")),
|
||||
};
|
||||
|
||||
var runtimeDirectory = Environment.GetRustRuntimeDirectory();
|
||||
if (WhatTauriExpectsIsThere())
|
||||
{
|
||||
results.Add(("Rust tests", await CommandRunner.RunAsync(runtimeDirectory, "cargo", "test")));
|
||||
results.Add(("Clippy", await CommandRunner.RunAsync(runtimeDirectory, "cargo", "clippy --all-targets -- -D warnings")));
|
||||
}
|
||||
else
|
||||
{
|
||||
//
|
||||
// Tauri's build script insists that everything the configuration lists is already
|
||||
// there and refuses to run otherwise, so nothing Rust compiles until a build has
|
||||
// produced those files once. Failing here would be a trap rather than a gate: the way
|
||||
// to produce them is `dotnet run build`, and that command runs this gate first -- a
|
||||
// fresh clone would never get past it.
|
||||
//
|
||||
Console.WriteLine("- Skipping the Rust tests and Clippy: the .NET sidecar or the downloaded libraries are missing, and Tauri's build script needs both before anything Rust compiles.");
|
||||
Console.WriteLine(" Run 'dotnet run build --skip-verify' once. From then on, this part of the gate runs with the rest.");
|
||||
}
|
||||
|
||||
results.Add(("Model sources", new VerifyModelsCommand().VerifyModels()));
|
||||
|
||||
Console.WriteLine("==============================");
|
||||
Console.WriteLine("- Quality gate:");
|
||||
foreach (var (what, exitCode) in results)
|
||||
Console.WriteLine($" - {what}: {(exitCode is 0 ? "passed" : $"failed, exit code {exitCode}")}");
|
||||
|
||||
var failed = results.Count(result => result.ExitCode is not 0);
|
||||
if (failed is 0)
|
||||
{
|
||||
Console.WriteLine($"- All {results.Count} checks passed.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
Console.WriteLine($"- {failed} of {results.Count} checks failed.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a build has already produced the files Tauri's build script reads.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both are products of a build rather than of the repository: the .NET app arrives as a
|
||||
/// sidecar, and the PDF library is downloaded into the resources. The other resource
|
||||
/// directories the configuration names are in the repository and are always there.
|
||||
/// </remarks>
|
||||
/// <returns>True, when cargo can get past the build script.</returns>
|
||||
private static bool WhatTauriExpectsIsThere()
|
||||
{
|
||||
var distributionDirectory = Path.Combine(Environment.GetAIStudioDirectory(), "bin", "dist");
|
||||
if (!Directory.Exists(distributionDirectory) || !Directory.EnumerateFiles(distributionDirectory, $"{SIDECAR_PREFIX}*").Any())
|
||||
return false;
|
||||
|
||||
var librariesDirectory = Path.Combine(Environment.GetRustRuntimeDirectory(), "resources", "libraries");
|
||||
return Directory.Exists(librariesDirectory) && Directory.EnumerateFiles(librariesDirectory).Any();
|
||||
}
|
||||
}
|
||||
178
app/Build/Commands/VerifyModelsCommand.cs
Normal file
178
app/Build/Commands/VerifyModelsCommand.cs
Normal file
@ -0,0 +1,178 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
// ReSharper disable ClassNeverInstantiated.Global
|
||||
// ReSharper disable UnusedType.Global
|
||||
// ReSharper disable UnusedMember.Global
|
||||
namespace Build.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Reports how long ago somebody last read the pages the model rules were written from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything a rule set can be asked about itself is asked by the test project, against the
|
||||
/// registry as it is really built: whether two rules claim the same names with the same right,
|
||||
/// whether every family and every host names a page and a day, whether every pattern is written the
|
||||
/// way model names arrive, whether a family reaches for one of the three reasoning words, and
|
||||
/// whether a rank was set without saying what it moves past. Those belong there and not here --
|
||||
/// asking them a second time in this command would be a second implementation of the same
|
||||
/// judgement, and two implementations of one judgement drift apart.
|
||||
///
|
||||
/// The one question a test cannot ask is this one, because its answer changes with the calendar
|
||||
/// rather than with the code: a family nobody touched would turn red on some Tuesday six months
|
||||
/// after it was written. That is why it reports instead of failing, and why it is a command of its
|
||||
/// own rather than a test or an analyzer.
|
||||
///
|
||||
/// It fails on exactly one thing: when it can no longer read the sources at all. A check which
|
||||
/// quietly reads nothing reports that everything is fine.
|
||||
/// </remarks>
|
||||
public sealed partial class VerifyModelsCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// How long a page may go unread before it is worth mentioning.
|
||||
/// </summary>
|
||||
private const int DEFAULT_MONTHS = 6;
|
||||
|
||||
/// <summary>
|
||||
/// The part of a source statement which is there in every spelling of it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Counting these and comparing the count with what the pattern below actually read is how this
|
||||
/// command notices that it has gone blind, rather than reporting an empty list of old sources.
|
||||
/// </remarks>
|
||||
private const string DAY_MARKER = "new DateOnly(";
|
||||
|
||||
[Command("verify-models", Description = "Report how long ago the pages behind the model rules were read")]
|
||||
public int VerifyModels(
|
||||
[Option("months", Description = "How long a page may go unread before it is reported")] int months = DEFAULT_MONTHS)
|
||||
{
|
||||
if(!Environment.IsWorkingDirectoryValid())
|
||||
return 1;
|
||||
|
||||
if (months < 1)
|
||||
{
|
||||
Console.WriteLine("- Error: The number of months has to be at least 1.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var modelsDirectory = Path.Combine(Environment.GetAIStudioDirectory(), "Models");
|
||||
if (!Directory.Exists(modelsDirectory))
|
||||
{
|
||||
Console.WriteLine($"- Error: The models directory '{modelsDirectory}' does not exist. Either it moved, or this command looks in the wrong place.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine("==============================");
|
||||
Console.WriteLine("- Reading the sources behind the model rules ...");
|
||||
|
||||
var repository = Environment.GetRepositoryDirectory();
|
||||
var files = Directory.EnumerateFiles(modelsDirectory, "*.cs", SearchOption.AllDirectories).Order(StringComparer.Ordinal).ToArray();
|
||||
var sources = new List<ReadSource>();
|
||||
var unreadable = new List<string>();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
var place = RelativeTo(repository, file);
|
||||
var lines = File.ReadAllLines(file);
|
||||
for (var index = 0; index < lines.Length; index++)
|
||||
{
|
||||
var line = lines[index];
|
||||
var stated = CountOccurrences(line, DAY_MARKER);
|
||||
if (stated is 0)
|
||||
continue;
|
||||
|
||||
var read = SourceStatement().Matches(line);
|
||||
foreach (Match statement in read)
|
||||
sources.Add(new(place, index + 1, statement.Groups["url"].Value, new(int.Parse(statement.Groups["year"].ValueSpan), int.Parse(statement.Groups["month"].ValueSpan), int.Parse(statement.Groups["day"].ValueSpan))));
|
||||
|
||||
for (var missed = read.Count; missed < stated; missed++)
|
||||
unreadable.Add($"{place}:{index + 1}");
|
||||
}
|
||||
}
|
||||
|
||||
if (sources.Count is 0)
|
||||
{
|
||||
Console.WriteLine($"- Error: Not one source was found in the {files.Length} files under '{RelativeTo(repository, modelsDirectory)}'.");
|
||||
Console.WriteLine(" Every family and every host states one, so finding none means this command can no longer read them.");
|
||||
Console.WriteLine(" A check which reads nothing reports that everything is fine, which is why this is an error rather than an empty report.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (unreadable.Count > 0)
|
||||
{
|
||||
Console.WriteLine($"- Error: {unreadable.Count} source(s) are written in a shape this command cannot read:");
|
||||
foreach (var place in unreadable)
|
||||
Console.WriteLine($" - {place}");
|
||||
|
||||
Console.WriteLine(" A source whose day cannot be read never grows old, and would stay out of the report below without anybody noticing.");
|
||||
Console.WriteLine(""" Write it as new("<url>", new DateOnly(<year>, <month>, <day>), "<note>") on one line, or teach this command the new shape.""");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var oldest = sources.MinBy(source => source.CheckedOn);
|
||||
var newest = sources.MaxBy(source => source.CheckedOn);
|
||||
Console.WriteLine($"- Read {sources.Count} sources in {files.Length} files under '{RelativeTo(repository, modelsDirectory)}'.");
|
||||
Console.WriteLine($" - Oldest: {oldest.CheckedOn:yyyy-MM-dd}, in {oldest.Place}:{oldest.Line}");
|
||||
Console.WriteLine($" - Newest: {newest.CheckedOn:yyyy-MM-dd}, in {newest.Place}:{newest.Line}");
|
||||
|
||||
var lastAcceptableDay = DateOnly.FromDateTime(DateTime.Today).AddMonths(-months);
|
||||
var stale = sources.Where(source => source.CheckedOn < lastAcceptableDay).OrderBy(source => source.CheckedOn).ToArray();
|
||||
if (stale.Length is 0)
|
||||
{
|
||||
Console.WriteLine($"- Every source was read on {lastAcceptableDay:yyyy-MM-dd} or later, so none of them is older than {months} months.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var insideActions = string.Equals(global::System.Environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
Console.WriteLine($"- {stale.Length} source(s) have not been read since {lastAcceptableDay:yyyy-MM-dd}:");
|
||||
foreach (var source in stale)
|
||||
{
|
||||
Console.WriteLine($" - {source.Place}:{source.Line}, last read on {source.CheckedOn:yyyy-MM-dd}: {source.Url}");
|
||||
if (insideActions)
|
||||
Console.WriteLine($"::warning file={source.Place},line={source.Line}::This model source was last read on {source.CheckedOn:yyyy-MM-dd}: {source.Url}");
|
||||
}
|
||||
|
||||
Console.WriteLine("- This is a report and never a failure: a page nobody has looked at for a while is not a page which changed.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How a source statement is written, in the one spelling the whole model namespace uses.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both the family and the host state it as a target-typed new, so the type name itself is
|
||||
/// nowhere in the line. What is always there is the page, then the day.
|
||||
/// </remarks>
|
||||
[GeneratedRegex("""new\("(?<url>[^"]*)",\s*new DateOnly\((?<year>\d{4}),\s*(?<month>\d{1,2}),\s*(?<day>\d{1,2})\)""")]
|
||||
private static partial Regex SourceStatement();
|
||||
|
||||
private static int CountOccurrences(string line, string marker)
|
||||
{
|
||||
var found = 0;
|
||||
var at = line.IndexOf(marker, StringComparison.Ordinal);
|
||||
while (at >= 0)
|
||||
{
|
||||
found++;
|
||||
at = line.IndexOf(marker, at + marker.Length, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A path as GitHub reads it: relative to the checkout, with forward slashes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An annotation carrying an absolute path of somebody's machine lands nowhere, and it does so
|
||||
/// without saying that it did.
|
||||
/// </remarks>
|
||||
private static string RelativeTo(string repository, string path) => Path.GetRelativePath(repository, path).Replace('\\', '/');
|
||||
|
||||
/// <summary>
|
||||
/// One page a rule was written from, and the day somebody last read it.
|
||||
/// </summary>
|
||||
/// <param name="Place">The file it is stated in, relative to the repository.</param>
|
||||
/// <param name="Line">The line it is stated on.</param>
|
||||
/// <param name="Url">The page.</param>
|
||||
/// <param name="CheckedOn">The day somebody last read it.</param>
|
||||
private readonly record struct ReadSource(string Place, int Line, string Url, DateOnly CheckedOn);
|
||||
}
|
||||
@ -7,4 +7,6 @@ app.AddCommands<UpdateMetadataCommands>();
|
||||
app.AddCommands<UpdateWebAssetsCommand>();
|
||||
app.AddCommands<CollectI18NKeysCommand>();
|
||||
app.AddCommands<AssistantPluginHashCommand>();
|
||||
app.AddCommands<VerifyModelsCommand>();
|
||||
app.AddCommands<VerifyCommand>();
|
||||
app.Run();
|
||||
|
||||
62
app/Build/Tools/CommandRunner.cs
Normal file
62
app/Build/Tools/CommandRunner.cs
Normal file
@ -0,0 +1,62 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Build.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Runs one external tool and lets it write straight to the terminal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The output is deliberately not captured. A gate which swallows the output of a failing test run
|
||||
/// and then prints "failed" leaves the person who has to fix it with nothing to go on, while the
|
||||
/// tools it runs already say everything worth saying -- which test, which line, which lint.
|
||||
/// </remarks>
|
||||
public static class CommandRunner
|
||||
{
|
||||
/// <summary>
|
||||
/// What a tool which could not be started at all reports.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Anything but zero counts as a failure, so the exact number matters only in that it is not
|
||||
/// one a tool would plausibly return itself.
|
||||
/// </remarks>
|
||||
public const int COULD_NOT_START = 127;
|
||||
|
||||
/// <summary>
|
||||
/// Runs a tool and waits for it.
|
||||
/// </summary>
|
||||
/// <param name="workingDirectory">Where the tool should run.</param>
|
||||
/// <param name="fileName">The tool, as it is called on the PATH.</param>
|
||||
/// <param name="arguments">What to pass it.</param>
|
||||
/// <returns>The exit code of the tool, or COULD_NOT_START when it never ran.</returns>
|
||||
public static async Task<int> RunAsync(string workingDirectory, string fileName, string arguments)
|
||||
{
|
||||
Console.WriteLine($"- Running '{fileName} {arguments}' in '{workingDirectory}' ...");
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
Arguments = arguments,
|
||||
WorkingDirectory = workingDirectory,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
Console.WriteLine($"- Error: '{fileName}' did not start, and the system did not say why.");
|
||||
return COULD_NOT_START;
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync();
|
||||
return process.ExitCode;
|
||||
}
|
||||
catch (Win32Exception exception)
|
||||
{
|
||||
Console.WriteLine($"- Error: '{fileName}' could not be started: {exception.Message}");
|
||||
Console.WriteLine($" Is '{fileName}' installed and on the PATH?");
|
||||
return COULD_NOT_START;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -34,6 +34,28 @@ public static class Environment
|
||||
return Path.GetFullPath(directory);
|
||||
}
|
||||
|
||||
public static string GetTestsDirectory()
|
||||
{
|
||||
var currentDirectory = Directory.GetCurrentDirectory();
|
||||
var directory = Path.Combine(currentDirectory, "..", "Tests");
|
||||
return Path.GetFullPath(directory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The root of the git repository, which is what a path in a report is written relative to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// GitHub resolves the file of an annotation against the checkout, not against wherever a tool
|
||||
/// happened to run. An absolute path of somebody's machine would therefore land the annotation
|
||||
/// nowhere, without saying so.
|
||||
/// </remarks>
|
||||
public static string GetRepositoryDirectory()
|
||||
{
|
||||
var currentDirectory = Directory.GetCurrentDirectory();
|
||||
var directory = Path.Combine(currentDirectory, "..", "..");
|
||||
return Path.GetFullPath(directory);
|
||||
}
|
||||
|
||||
public static string GetRustRuntimeDirectory()
|
||||
{
|
||||
var currentDirectory = Directory.GetCurrentDirectory();
|
||||
|
||||
@ -10,6 +10,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedTools", "SharedTools\
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SourceGeneratedMappings", "SourceGeneratedMappings\SourceGeneratedMappings.csproj", "{4D7141D5-9C22-4D85-B748-290D15FF484C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{CD46329B-D135-4594-9A70-55D3480F8FEE}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@ -36,6 +38,10 @@ Global
|
||||
{4D7141D5-9C22-4D85-B748-290D15FF484C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4D7141D5-9C22-4D85-B748-290D15FF484C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4D7141D5-9C22-4D85-B748-290D15FF484C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{CD46329B-D135-4594-9A70-55D3480F8FEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{CD46329B-D135-4594-9A70-55D3480F8FEE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{CD46329B-D135-4594-9A70-55D3480F8FEE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{CD46329B-D135-4594-9A70-55D3480F8FEE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
EndGlobalSection
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
</MudField>
|
||||
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
|
||||
@(this.createChatLauncher
|
||||
? T("The direct chat launcher tile has no input form of its own. It opens a new chat right away, in the workspace you name below and with the provider, profile, chat template, and data sources you select there.")
|
||||
? T("The direct chat launcher tile has no input form of its own. It opens a new chat right away, with the provider, profile, chat template, and data sources you select below. Name a workspace for that chat, or leave the workspace empty to open a disappearing chat.")
|
||||
: T("The assistant asks users for input through a form and builds its own prompt from it."))
|
||||
</MudJustifiedText>
|
||||
@if (this.createChatLauncher)
|
||||
@ -39,8 +39,7 @@
|
||||
@bind-ProfileId="@this.launcherProfileId"
|
||||
@bind-ChatTemplateId="@this.launcherChatTemplateId"
|
||||
@bind-DataSourceIds="@this.launcherDataSourceIds"
|
||||
@bind-ToolIds="@this.launcherToolIds"
|
||||
ValidateWorkspaceName="@this.ValidateLauncherWorkspaceName"/>
|
||||
@bind-ToolIds="@this.launcherToolIds"/>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
|
||||
@ -375,14 +375,6 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
|
||||
return null;
|
||||
}
|
||||
|
||||
private string? ValidateLauncherWorkspaceName(string workspaceName)
|
||||
{
|
||||
if (this.createChatLauncher && string.IsNullOrWhiteSpace(workspaceName))
|
||||
return T("Please select or enter a workspace name for the chat launcher.");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task GenerateAssistantSpec()
|
||||
{
|
||||
await this.Form!.Validate();
|
||||
@ -589,7 +581,8 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
|
||||
// The description stays required for both kinds of assistant. Users who only want a tile
|
||||
// usually flip the switch before typing anything, so the Builder offers a starting point they
|
||||
// can edit or replace. The workspace is picked after that, hence the suggestion is refreshed
|
||||
// whenever the workspace changes:
|
||||
// whenever the workspace changes — including when it is cleared again, which turns the tile
|
||||
// into one that opens a disappearing chat:
|
||||
//
|
||||
private void SuggestLauncherDescription()
|
||||
{
|
||||
@ -597,8 +590,9 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
|
||||
return;
|
||||
|
||||
var suggestion = T("Create a tile that opens a preconfigured chat directly, without an input form of its own.");
|
||||
if (!string.IsNullOrWhiteSpace(this.launcherWorkspaceName))
|
||||
suggestion = $"{suggestion} {string.Format(T("Workspace: {0}"), this.launcherWorkspaceName.Trim())}";
|
||||
suggestion = string.IsNullOrWhiteSpace(this.launcherWorkspaceName)
|
||||
? $"{suggestion} {T("The chat opens as a disappearing chat, without a workspace.")}"
|
||||
: $"{suggestion} {string.Format(T("Workspace: {0}"), this.launcherWorkspaceName.Trim())}";
|
||||
|
||||
this.assistantDescription = suggestion;
|
||||
this.descriptionSuggestion = suggestion;
|
||||
|
||||
@ -2,6 +2,10 @@ namespace AIStudio.Assistants.Builder;
|
||||
|
||||
internal sealed class AssistantBuilderChatLaunchMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// The workspace the chat is created in. A model leaves this field out for a launcher that
|
||||
/// opens a chat without a workspace, which is why it stays empty rather than null.
|
||||
/// </summary>
|
||||
public string WorkspaceName { get; init; } = string.Empty;
|
||||
public string? ProviderId { get; init; }
|
||||
public string? ProfileId { get; init; }
|
||||
|
||||
@ -131,11 +131,9 @@
|
||||
"chatLaunch": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"workspace_name"
|
||||
],
|
||||
"properties": {
|
||||
"workspace_name": {
|
||||
"description": "Omit this field for a launcher that opens a chat without a workspace.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
|
||||
@ -124,7 +124,12 @@ internal sealed partial class LuaResponse
|
||||
|
||||
private static bool IsValidChatLaunchMetadata(AssistantBuilderChatLaunchMetadata? launch)
|
||||
{
|
||||
if (launch is null || string.IsNullOrWhiteSpace(launch.WorkspaceName))
|
||||
//
|
||||
// A missing workspace name describes a launcher that opens a chat without a workspace, so
|
||||
// only the launch block itself is mandatory here. Whether the name matches the plugin the
|
||||
// model wrote is decided later, by comparing both.
|
||||
//
|
||||
if (launch is null)
|
||||
return false;
|
||||
|
||||
if (!IsOptionalGuid(launch.ProviderId, allowEmpty: false) ||
|
||||
|
||||
@ -140,6 +140,7 @@ else
|
||||
var webState = this.assistantState.WebContent[webContent.Name];
|
||||
<div class="@webContent.Class" style="@GetOptionalStyle(webContent.Style)">
|
||||
<ReadWebContent @bind-Content="@webState.Content"
|
||||
@bind-URL="@webState.URL"
|
||||
ProviderSettings="@this.ProviderSettings"
|
||||
@bind-AgentIsRunning="@webState.AgentIsRunning"
|
||||
@bind-Preselect="@webState.Preselect"
|
||||
|
||||
@ -3,7 +3,8 @@ namespace AIStudio.Assistants.Dynamic;
|
||||
public sealed class WebContentState
|
||||
{
|
||||
public string Content { get; set; } = string.Empty;
|
||||
public string URL { get; set; } = string.Empty;
|
||||
public bool Preselect { get; set; }
|
||||
public bool PreselectContentCleanerAgent { get; set; }
|
||||
public bool AgentIsRunning { get; set; }
|
||||
}
|
||||
}
|
||||
@ -751,6 +751,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BUILDER::ASSISTANTBUILDER::T1428868592"]
|
||||
-- Tile title (optional)
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BUILDER::ASSISTANTBUILDER::T145442870"] = "Tile title (optional)"
|
||||
|
||||
-- The direct chat launcher tile has no input form of its own. It opens a new chat right away, with the provider, profile, chat template, and data sources you select below. Name a workspace for that chat, or leave the workspace empty to open a disappearing chat.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BUILDER::ASSISTANTBUILDER::T1455505413"] = "The direct chat launcher tile has no input form of its own. It opens a new chat right away, with the provider, profile, chat template, and data sources you select below. Name a workspace for that chat, or leave the workspace empty to open a disappearing chat."
|
||||
|
||||
-- Additional changes (Optional)
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BUILDER::ASSISTANTBUILDER::T1502888752"] = "Additional changes (Optional)"
|
||||
|
||||
@ -763,6 +766,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BUILDER::ASSISTANTBUILDER::T1517869254"]
|
||||
-- An expected user prompt, e.g. summarize this document
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BUILDER::ASSISTANTBUILDER::T1565792607"] = "An expected user prompt, e.g. summarize this document"
|
||||
|
||||
-- The chat opens as a disappearing chat, without a workspace.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BUILDER::ASSISTANTBUILDER::T1621773509"] = "The chat opens as a disappearing chat, without a workspace."
|
||||
|
||||
-- Return to the original assistant description. The current draft and the plugin preview will be discarded.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BUILDER::ASSISTANTBUILDER::T1622920412"] = "Return to the original assistant description. The current draft and the plugin preview will be discarded."
|
||||
|
||||
@ -919,9 +925,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BUILDER::ASSISTANTBUILDER::T3843866124"]
|
||||
-- Install assistant
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BUILDER::ASSISTANTBUILDER::T3863433088"] = "Install assistant"
|
||||
|
||||
-- The direct chat launcher tile has no input form of its own. It opens a new chat right away, in the workspace you name below and with the provider, profile, chat template, and data sources you select there.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BUILDER::ASSISTANTBUILDER::T395398616"] = "The direct chat launcher tile has no input form of its own. It opens a new chat right away, in the workspace you name below and with the provider, profile, chat template, and data sources you select there."
|
||||
|
||||
-- Assistant draft
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BUILDER::ASSISTANTBUILDER::T3957423852"] = "Assistant draft"
|
||||
|
||||
@ -943,9 +946,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BUILDER::ASSISTANTBUILDER::T4217647404"]
|
||||
-- Please create an assistant draft first.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BUILDER::ASSISTANTBUILDER::T4269176489"] = "Please create an assistant draft first."
|
||||
|
||||
-- Please select or enter a workspace name for the chat launcher.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BUILDER::ASSISTANTBUILDER::T4396903"] = "Please select or enter a workspace name for the chat launcher."
|
||||
|
||||
-- The assistant asks users for input through a form and builds its own prompt from it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BUILDER::ASSISTANTBUILDER::T451049798"] = "The assistant asks users for input through a form and builds its own prompt from it."
|
||||
|
||||
@ -3346,6 +3346,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T3267850764"] = "The selected mode
|
||||
-- We could load models from '{0}', but the provider did not return any usable text models.
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T3378120620"] = "We could load models from '{0}', but the provider did not return any usable text models."
|
||||
|
||||
-- Your data sources could not be used. This answer was created without them.
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T373499115"] = "Your data sources could not be used. This answer was created without them."
|
||||
|
||||
-- The local image file does not exist. Skipping the image.
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::IIMAGESOURCEEXTENSIONS::T255679918"] = "The local image file does not exist. Skipping the image."
|
||||
|
||||
@ -3571,9 +3574,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1849313532"] = "Type your
|
||||
-- Your Prompt (use selected instance '{0}', provider '{1}')
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1967611328"] = "Your Prompt (use selected instance '{0}', provider '{1}')"
|
||||
|
||||
-- approx. {0} of {1} tokens
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1992478915"] = "approx. {0} of {1} tokens"
|
||||
|
||||
-- Code
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2036185364"] = "Code"
|
||||
|
||||
-- plus {0} image(s), which is more than the {1} this model accepts
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2059172343"] = "plus {0} image(s), which is more than the {1} this model accepts"
|
||||
|
||||
-- Italic
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2377171085"] = "Italic"
|
||||
|
||||
@ -3595,15 +3604,21 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2991985411"] = "Delete th
|
||||
-- Move Chat to Workspace
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3045856778"] = "Move Chat to Workspace"
|
||||
|
||||
-- {0} tokens
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3244065777"] = "{0} tokens"
|
||||
|
||||
-- plus {0} image(s), which cannot be counted
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3619858297"] = "plus {0} image(s), which cannot be counted"
|
||||
|
||||
-- Select a provider first
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3654197869"] = "Select a provider first"
|
||||
|
||||
-- Estimated amount of tokens:
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T377990776"] = "Estimated amount of tokens:"
|
||||
|
||||
-- Start new chat in workspace '{0}'
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3928697643"] = "Start new chat in workspace '{0}'"
|
||||
|
||||
-- {0} of {1} tokens
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3996190985"] = "{0} of {1} tokens"
|
||||
|
||||
-- Start temporary chat
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T4113970938"] = "Start temporary chat"
|
||||
|
||||
@ -3619,6 +3634,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T636393754"] = "Move the c
|
||||
-- Show your workspaces
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T733672375"] = "Show your workspaces"
|
||||
|
||||
-- approx. {0} tokens
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T857715435"] = "approx. {0} tokens"
|
||||
|
||||
-- Create template from current chat
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATTEMPLATESELECTION::T1112722156"] = "Create template from current chat"
|
||||
|
||||
@ -3883,6 +3901,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T168406579"] = "AI-S
|
||||
-- AI-based data validation
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1744745490"] = "AI-based data validation"
|
||||
|
||||
-- These data sources are preselected, but cannot be used right now, either due to data privacy or confidence-level requirements, or because they are unavailable:
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1852534051"] = "These data sources are preselected, but cannot be used right now, either due to data privacy or confidence-level requirements, or because they are unavailable:"
|
||||
|
||||
-- Yes, I want to use data sources.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1975014927"] = "Yes, I want to use data sources."
|
||||
|
||||
@ -3940,6 +3961,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERFORM::T1286170698"] = "
|
||||
-- Chat provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERFORM::T1648955896"] = "Chat provider"
|
||||
|
||||
-- The tile opens its chat in this workspace and creates the workspace when it does not exist yet.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERFORM::T1797236585"] = "The tile opens its chat in this workspace and creates the workspace when it does not exist yet."
|
||||
|
||||
-- Workspace name (Optional)
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERFORM::T1873204484"] = "Workspace name (Optional)"
|
||||
|
||||
-- Use no profile
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERFORM::T2205839602"] = "Use no profile"
|
||||
|
||||
@ -3958,12 +3985,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERFORM::T2886517443"] = "
|
||||
-- Choose an existing workspace or enter a name that should be created when the launcher is opened.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERFORM::T2901190527"] = "Choose an existing workspace or enter a name that should be created when the launcher is opened."
|
||||
|
||||
-- Workspace name
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERFORM::T295876489"] = "Workspace name"
|
||||
|
||||
-- Data sources (Optional)
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERFORM::T3259309302"] = "Data sources (Optional)"
|
||||
|
||||
-- Without a workspace, the tile opens a disappearing chat: it belongs to no workspace and is deleted according to your workspace maintenance settings.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERFORM::T3611496116"] = "Without a workspace, the tile opens a disappearing chat: it belongs to no workspace and is deleted according to your workspace maintenance settings."
|
||||
|
||||
-- Use the normal chat data source defaults
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERFORM::T3898572329"] = "Use the normal chat data source defaults"
|
||||
|
||||
@ -5002,6 +5029,27 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T78
|
||||
-- Are you sure you want to delete the transcription provider '{0}'?
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T789660305"] = "Are you sure you want to delete the transcription provider '{0}'?"
|
||||
|
||||
-- Could not open the file location.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1118835751"] = "Could not open the file location."
|
||||
|
||||
-- Could not open the file location: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1455637941"] = "Could not open the file location: {0}"
|
||||
|
||||
-- Show this file in the file manager of your system
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1587653504"] = "Show this file in the file manager of your system"
|
||||
|
||||
-- Opens this document in the program your system uses for it
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3169582185"] = "Opens this document in the program your system uses for it"
|
||||
|
||||
-- Unknown error
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3461425987"] = "Unknown error"
|
||||
|
||||
-- Could not open the document.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3570758363"] = "Could not open the document."
|
||||
|
||||
-- Could not open the document: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T945417289"] = "Could not open the document: {0}"
|
||||
|
||||
-- Copy {0} to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TEXTINFOLINE::T2206391442"] = "Copy {0} to the clipboard"
|
||||
|
||||
@ -5014,6 +5062,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::THIRDPARTYCOMPONENT::T1392042694"] = "Ope
|
||||
-- License:
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::THIRDPARTYCOMPONENT::T1908172666"] = "License:"
|
||||
|
||||
-- The vendor of this model publishes no tokenizer file and counts through their API instead ({0}). AI Studio therefore estimates the token count with its built-in tokenizer.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TOKENIZERHINT::T3965340739"] = "The vendor of this model publishes no tokenizer file and counts through their API instead ({0}). AI Studio therefore estimates the token count with its built-in tokenizer."
|
||||
|
||||
-- This model uses OpenAI's {0} encoding, which does not come as a tokenizer.json file. AI Studio therefore estimates the token count with its built-in tokenizer.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TOKENIZERHINT::T466506475"] = "This model uses OpenAI's {0} encoding, which does not come as a tokenizer.json file. AI Studio therefore estimates the token count with its built-in tokenizer."
|
||||
|
||||
-- This model uses the tokenizer of {0}. Download its tokenizer.json file and select it below to count exactly instead of estimating.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TOKENIZERHINT::T924854143"] = "This model uses the tokenizer of {0}. Download its tokenizer.json file and select it below to count exactly instead of estimating."
|
||||
|
||||
-- Tool selection is hidden
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TOOLDEFAULTSCONFIGURATION::T2096103917"] = "Tool selection is hidden"
|
||||
|
||||
@ -6295,9 +6352,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEINFODIALOG::T3688254408"]
|
||||
-- the required provider confidence level
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEINFODIALOG::T818422588"] = "the required provider confidence level"
|
||||
|
||||
-- Please select or enter a workspace name for this tile.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DIRECTCHATLAUNCHERSETTINGSDIALOG::T1505747232"] = "Please select or enter a workspace name for this tile."
|
||||
|
||||
-- Resulting Lua plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DIRECTCHATLAUNCHERSETTINGSDIALOG::T1671332249"] = "Resulting Lua plugin"
|
||||
|
||||
@ -6373,6 +6427,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2199753423"] = "Image
|
||||
-- Your document is large, so we show you only its beginning. We hide the remaining {0:N0} characters here. Rest assured: the AI still receives your entire document.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2468296835"] = "Your document is large, so we show you only its beginning. We hide the remaining {0:N0} characters here. Rest assured: the AI still receives your entire document."
|
||||
|
||||
-- You can drag another file into this window. We attach it right away and show it here.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2822249202"] = "You can drag another file into this window. We attach it right away and show it here."
|
||||
|
||||
-- See how we load your file. Review the content before we process it further.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T3271853346"] = "See how we load your file. Review the content before we process it further."
|
||||
|
||||
@ -6901,15 +6958,27 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1108876344"] = "Hide Expert
|
||||
-- Failed to store the API key in the operating system. The message was: {0}. Please try again.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1122745046"] = "Failed to store the API key in the operating system. The message was: {0}. Please try again."
|
||||
|
||||
-- Where the stored numbers do not match your installation, state yours here. This matters most for self-hosted models: they run with whatever their operator configured, which the model card cannot know.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T115770087"] = "Where the stored numbers do not match your installation, state yours here. This matters most for self-hosted models: they run with whatever their operator configured, which the model card cannot know."
|
||||
|
||||
-- Per message
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1316004715"] = "Per message"
|
||||
|
||||
-- API Key
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1324664716"] = "API Key"
|
||||
|
||||
-- Create account
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1356621346"] = "Create account"
|
||||
|
||||
-- Per request
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1363121973"] = "Per request"
|
||||
|
||||
-- Failed to validate the selected tokenizer. Please try again.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1384494471"] = "Failed to validate the selected tokenizer. Please try again."
|
||||
|
||||
-- Override Model Limits
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1518445332"] = "Override Model Limits"
|
||||
|
||||
-- Load models
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T15352225"] = "Load models"
|
||||
|
||||
@ -6955,6 +7024,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2029870721"] = "The current
|
||||
-- Additional API parameters must form a JSON object.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2051143391"] = "Additional API parameters must form a JSON object."
|
||||
|
||||
-- Nobody has stated a window for this model. Left empty, the chat counts the tokens of a conversation without saying what they may grow to.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2138841031"] = "Nobody has stated a window for this model. Left empty, the chat counts the tokens of a conversation without saying what they may grow to."
|
||||
|
||||
-- Use detected model behavior: {0}.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2141072961"] = "Use detected model behavior: {0}."
|
||||
|
||||
@ -6976,6 +7048,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2439094236"] = "Failed to r
|
||||
-- Invalid tokenizer:
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2448302543"] = "Invalid tokenizer:"
|
||||
|
||||
-- Vendors state one of the two, the other, or neither. Whichever is smaller decides how many images one message may carry; an empty field states nothing.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2519267200"] = "Vendors state one of the two, the other, or neither. Whichever is smaller decides how many images one message may carry; an empty field states nothing."
|
||||
|
||||
-- Enabled
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2626085950"] = "Enabled"
|
||||
|
||||
@ -7003,6 +7078,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2842060373"] = "Instance Na
|
||||
-- On by default
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2843289040"] = "On by default"
|
||||
|
||||
-- No limit known, so AI Studio does not stop anybody from attaching more.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T2986951856"] = "No limit known, so AI Studio does not stop anybody from attaching more."
|
||||
|
||||
-- No reasoning (thinking) capability.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T301695429"] = "No reasoning (thinking) capability."
|
||||
|
||||
@ -7012,6 +7090,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3079061205"] = "Please be c
|
||||
-- Reasoning (thinking) is available and on unless additional API parameters disable it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T310420667"] = "Reasoning (thinking) is available and on unless additional API parameters disable it."
|
||||
|
||||
-- Detected: {0} tokens. Leave the field empty to use that.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T311903903"] = "Detected: {0} tokens. Leave the field empty to use that."
|
||||
|
||||
-- At most {0} images at once.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3187806707"] = "At most {0} images at once."
|
||||
|
||||
-- Disabled
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3217987877"] = "Disabled"
|
||||
|
||||
@ -7048,6 +7132,12 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3804472591"] = "Duplicate k
|
||||
-- Override Model Capabilities
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3904244586"] = "Override Model Capabilities"
|
||||
|
||||
-- Images
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T401363915"] = "Images"
|
||||
|
||||
-- Context window in tokens
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T4083607555"] = "Context window in tokens"
|
||||
|
||||
-- Currently, we cannot query the models for the selected provider and/or host. Therefore, please enter the model name manually.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T4116737656"] = "Currently, we cannot query the models for the selected provider and/or host. Therefore, please enter the model name manually."
|
||||
|
||||
@ -7201,12 +7291,21 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T900713019"] = "Canc
|
||||
-- Embeddings
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T951463987"] = "Embeddings"
|
||||
|
||||
-- Attached {0} files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T1736997462"] = "Attached {0} files."
|
||||
|
||||
-- Here you can see all attached files. Files that can no longer be found (deleted, renamed, or moved) are marked with a warning icon and a strikethrough name. You can remove any attachment using the trash can icon.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T1746160064"] = "Here you can see all attached files. Files that can no longer be found (deleted, renamed, or moved) are marked with a warning icon and a strikethrough name. You can remove any attachment using the trash can icon."
|
||||
|
||||
-- Attached {0}.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T1839536175"] = "Attached {0}."
|
||||
|
||||
-- There aren't any file attachments available right now.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T2111340711"] = "There aren't any file attachments available right now."
|
||||
|
||||
-- You can drag more files into this window to attach them right away.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T2653077974"] = "You can drag more files into this window to attach them right away."
|
||||
|
||||
-- Document Preview
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T285154968"] = "Document Preview"
|
||||
|
||||
@ -9967,6 +10066,9 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T4049517041"] = "We tried to
|
||||
-- The provider '{0}' does not know the selected model. Please select another model.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T411393889"] = "The provider '{0}' does not know the selected model. Please select another model."
|
||||
|
||||
-- The text was longer than the selected model accepts, which is {0} tokens. Please select a model which takes longer texts, or reduce the chunk size of the data source.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T479223640"] = "The text was longer than the selected model accepts, which is {0} tokens. Please select a model which takes longer texts, or reduce the chunk size of the data source."
|
||||
|
||||
-- The provider '{0}' reported an error: {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T700894460"] = "The provider '{0}' reported an error: {1}"
|
||||
|
||||
@ -10387,6 +10489,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T3267850764"] = "The sel
|
||||
-- We could load models from '{0}', but the provider did not return any usable text models.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T3378120620"] = "We could load models from '{0}', but the provider did not return any usable text models."
|
||||
|
||||
-- Your data sources could not be used. This answer was created without them.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T373499115"] = "Your data sources could not be used. This answer was created without them."
|
||||
|
||||
-- Software Development
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::ASSISTANTCATEGORYEXTENSIONS::T1025369409"] = "Software Development"
|
||||
|
||||
@ -11107,6 +11212,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTS::T1
|
||||
-- Failed to parse the UI render tree from the ASSISTANT lua table.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTS::T1318499252"] = "Failed to parse the UI render tree from the ASSISTANT lua table."
|
||||
|
||||
-- The ASSISTANT table contains a WorkspaceName for LaunchBehavior 'OPEN_TEMPORARY_CHAT'. A chat without a workspace cannot have one.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTS::T1331424201"] = "The ASSISTANT table contains a WorkspaceName for LaunchBehavior 'OPEN_TEMPORARY_CHAT'. A chat without a workspace cannot have one."
|
||||
|
||||
-- The provided ASSISTANT lua table does not contain a valid UI table.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::ASSISTANTS::PLUGINASSISTANTS::T1841068402"] = "The provided ASSISTANT lua table does not contain a valid UI table."
|
||||
|
||||
@ -11422,6 +11530,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINLANGUAGE::T4112586014"] =
|
||||
-- The field LANG_NAME does not exist or is not a valid string.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINLANGUAGE::T4204700759"] = "The field LANG_NAME does not exist or is not a valid string."
|
||||
|
||||
-- The table MODELS does not exist or is using an invalid syntax.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINMODELS::T976664425"] = "The table MODELS does not exist or is using an invalid syntax."
|
||||
|
||||
-- Artists
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T1142248183"] = "Artists"
|
||||
|
||||
@ -11464,6 +11575,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T62
|
||||
-- Software developers
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTARGETGROUPEXTENSIONS::T831424531"] = "Software developers"
|
||||
|
||||
-- Model plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTYPEEXTENSIONS::T1507522553"] = "Model plugin"
|
||||
|
||||
-- Theme plugin
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTYPEEXTENSIONS::T1682350097"] = "Theme plugin"
|
||||
|
||||
@ -11485,6 +11599,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::AUGMENTATIONPROCESSES::AUGMENTATIONONE::T
|
||||
-- This is the standard augmentation process, which uses all retrieval contexts to augment the chat thread.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::AUGMENTATIONPROCESSES::AUGMENTATIONONE::T3240406069"] = "This is the standard augmentation process, which uses all retrieval contexts to augment the chat thread."
|
||||
|
||||
-- The check of which passages fit your question failed. This answer uses all passages that were found.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::AUGMENTATIONPROCESSES::AUGMENTATIONONE::T392269104"] = "The check of which passages fit your question failed. This answer uses all passages that were found."
|
||||
|
||||
-- Automatic AI data source selection with heuristik source reduction
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::DATASOURCESELECTIONPROCESSES::AGENTICSRCSELWITHDYNHEUR::T2339257645"] = "Automatic AI data source selection with heuristik source reduction"
|
||||
|
||||
@ -12103,6 +12220,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1238078807"] = "No com
|
||||
-- Failed to store the API key due to an API issue.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1704298921"] = "Failed to store the API key due to an API issue."
|
||||
|
||||
-- The runtime document endpoint returned '{0}'.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1843760475"] = "The runtime document endpoint returned '{0}'."
|
||||
|
||||
-- The global shortcut could not be registered because of a desktop integration error.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2032590244"] = "The global shortcut could not be registered because of a desktop integration error."
|
||||
|
||||
@ -12130,6 +12250,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3351807428"] = "Succes
|
||||
-- The desktop service returned an invalid response while registering the global shortcut.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3369097283"] = "The desktop service returned an invalid response while registering the global shortcut."
|
||||
|
||||
-- The runtime document endpoint failed without details.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T353458993"] = "The runtime document endpoint failed without details."
|
||||
|
||||
-- AI Studio could not access secure storage because no default collection is configured. Open a compatible password manager, create or select a collection, unlock it, and set it as the default.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3611400673"] = "AI Studio could not access secure storage because no default collection is configured. Open a compatible password manager, create or select a collection, unlock it, and set it as the default."
|
||||
|
||||
@ -12148,6 +12271,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3929880252"] = "No sav
|
||||
-- Failed to get the secret data due to an API issue.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T4007657575"] = "Failed to get the secret data due to an API issue."
|
||||
|
||||
-- The runtime document endpoint is not available.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T541638186"] = "The runtime document endpoint is not available."
|
||||
|
||||
-- AI Studio could not access secure storage. See the log for technical details.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T624023541"] = "AI Studio could not access secure storage. See the log for technical details."
|
||||
|
||||
@ -12163,6 +12289,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T1064148123"] = "Fail
|
||||
-- Failed to install update automatically. Please try again manually.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T3709709946"] = "Failed to install update automatically. Please try again manually."
|
||||
|
||||
-- Sources
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T2730980305"] = "Sources"
|
||||
|
||||
-- Sources provided by the data providers
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T4174900468"] = "Sources provided by the data providers"
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
|
||||
@if (!this.SettingsManager.ConfigurationData.LegalCheck.HideWebContentReader)
|
||||
{
|
||||
<ReadWebContent @bind-Content="@this.inputLegalDocument" ProviderSettings="@this.ProviderSettings" @bind-AgentIsRunning="@this.isAgentRunning" @bind-Preselect="@this.showWebContentReader" @bind-PreselectContentCleanerAgent="@this.useContentCleanerAgent"/>
|
||||
<ReadWebContent @bind-Content="@this.inputLegalDocument" @bind-URL="@this.webContentURL" ProviderSettings="@this.ProviderSettings" @bind-AgentIsRunning="@this.isAgentRunning" @bind-Preselect="@this.showWebContentReader" @bind-PreselectContentCleanerAgent="@this.useContentCleanerAgent"/>
|
||||
}
|
||||
|
||||
@* Two zones, so no default target: the user has to aim at the one they mean. *@
|
||||
|
||||
@ -36,6 +36,7 @@ public partial class AssistantLegalCheck : AssistantBaseCore<SettingsDialogLegal
|
||||
{
|
||||
this.inputLegalDocument = string.Empty;
|
||||
this.inputQuestions = string.Empty;
|
||||
this.webContentURL = string.Empty;
|
||||
if (!this.MightPreselectValues())
|
||||
{
|
||||
this.showWebContentReader = false;
|
||||
@ -58,11 +59,13 @@ public partial class AssistantLegalCheck : AssistantBaseCore<SettingsDialogLegal
|
||||
private bool showWebContentReader;
|
||||
private bool useContentCleanerAgent;
|
||||
private bool isAgentRunning;
|
||||
private string webContentURL = string.Empty;
|
||||
private string inputLegalDocument = string.Empty;
|
||||
private string inputQuestions = string.Empty;
|
||||
private static readonly AssistantSessionStateKey<bool> SHOW_WEB_CONTENT_READER_STATE_KEY = new(nameof(showWebContentReader));
|
||||
private static readonly AssistantSessionStateKey<bool> USE_CONTENT_CLEANER_AGENT_STATE_KEY = new(nameof(useContentCleanerAgent));
|
||||
private static readonly AssistantSessionStateKey<bool> IS_AGENT_RUNNING_STATE_KEY = new(nameof(isAgentRunning));
|
||||
private static readonly AssistantSessionStateKey<string> WEB_CONTENT_URL_STATE_KEY = new(nameof(webContentURL));
|
||||
private static readonly AssistantSessionStateKey<string> INPUT_LEGAL_DOCUMENT_STATE_KEY = new(nameof(inputLegalDocument));
|
||||
private static readonly AssistantSessionStateKey<string> INPUT_QUESTIONS_STATE_KEY = new(nameof(inputQuestions));
|
||||
|
||||
@ -72,6 +75,7 @@ public partial class AssistantLegalCheck : AssistantBaseCore<SettingsDialogLegal
|
||||
state.Set(SHOW_WEB_CONTENT_READER_STATE_KEY, this.showWebContentReader);
|
||||
state.Set(USE_CONTENT_CLEANER_AGENT_STATE_KEY, this.useContentCleanerAgent);
|
||||
state.Set(IS_AGENT_RUNNING_STATE_KEY, this.isAgentRunning);
|
||||
state.Set(WEB_CONTENT_URL_STATE_KEY, this.webContentURL);
|
||||
state.Set(INPUT_LEGAL_DOCUMENT_STATE_KEY, this.inputLegalDocument);
|
||||
state.Set(INPUT_QUESTIONS_STATE_KEY, this.inputQuestions);
|
||||
}
|
||||
@ -82,6 +86,7 @@ public partial class AssistantLegalCheck : AssistantBaseCore<SettingsDialogLegal
|
||||
state.Restore(SHOW_WEB_CONTENT_READER_STATE_KEY, value => this.showWebContentReader = value);
|
||||
state.Restore(USE_CONTENT_CLEANER_AGENT_STATE_KEY, value => this.useContentCleanerAgent = value);
|
||||
state.Restore(IS_AGENT_RUNNING_STATE_KEY, value => this.isAgentRunning = value);
|
||||
state.Restore(WEB_CONTENT_URL_STATE_KEY, value => this.webContentURL = value);
|
||||
state.Restore(INPUT_LEGAL_DOCUMENT_STATE_KEY, value => this.inputLegalDocument = value);
|
||||
state.Restore(INPUT_QUESTIONS_STATE_KEY, value => this.inputQuestions = value);
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
|
||||
@if (!this.SettingsManager.ConfigurationData.TextSummarizer.HideWebContentReader)
|
||||
{
|
||||
<ReadWebContent @bind-Content="@this.inputText" ProviderSettings="@this.ProviderSettings" @bind-AgentIsRunning="@this.isAgentRunning" @bind-Preselect="@this.showWebContentReader" @bind-PreselectContentCleanerAgent="@this.useContentCleanerAgent"/>
|
||||
<ReadWebContent @bind-Content="@this.inputText" @bind-URL="@this.webContentURL" ProviderSettings="@this.ProviderSettings" @bind-AgentIsRunning="@this.isAgentRunning" @bind-Preselect="@this.showWebContentReader" @bind-PreselectContentCleanerAgent="@this.useContentCleanerAgent"/>
|
||||
}
|
||||
|
||||
<ReadFileContent @bind-FileContent="@this.inputText" EnableDragDrop="true" CatchAllDocuments="true"/>
|
||||
|
||||
@ -35,6 +35,7 @@ public partial class AssistantTextSummarizer : AssistantBaseCore<SettingsDialogT
|
||||
protected override void ResetForm()
|
||||
{
|
||||
this.inputText = string.Empty;
|
||||
this.webContentURL = string.Empty;
|
||||
if(!this.MightPreselectValues())
|
||||
{
|
||||
this.showWebContentReader = false;
|
||||
@ -66,6 +67,7 @@ public partial class AssistantTextSummarizer : AssistantBaseCore<SettingsDialogT
|
||||
|
||||
private bool showWebContentReader;
|
||||
private bool useContentCleanerAgent;
|
||||
private string webContentURL = string.Empty;
|
||||
private string inputText = string.Empty;
|
||||
private bool isAgentRunning;
|
||||
private CommonLanguages selectedTargetLanguage;
|
||||
@ -75,6 +77,7 @@ public partial class AssistantTextSummarizer : AssistantBaseCore<SettingsDialogT
|
||||
private string importantAspects = string.Empty;
|
||||
private static readonly AssistantSessionStateKey<bool> SHOW_WEB_CONTENT_READER_STATE_KEY = new(nameof(showWebContentReader));
|
||||
private static readonly AssistantSessionStateKey<bool> USE_CONTENT_CLEANER_AGENT_STATE_KEY = new(nameof(useContentCleanerAgent));
|
||||
private static readonly AssistantSessionStateKey<string> WEB_CONTENT_URL_STATE_KEY = new(nameof(webContentURL));
|
||||
private static readonly AssistantSessionStateKey<string> INPUT_TEXT_STATE_KEY = new(nameof(inputText));
|
||||
private static readonly AssistantSessionStateKey<bool> IS_AGENT_RUNNING_STATE_KEY = new(nameof(isAgentRunning));
|
||||
private static readonly AssistantSessionStateKey<CommonLanguages> SELECTED_TARGET_LANGUAGE_STATE_KEY = new(nameof(selectedTargetLanguage));
|
||||
@ -88,6 +91,7 @@ public partial class AssistantTextSummarizer : AssistantBaseCore<SettingsDialogT
|
||||
{
|
||||
state.Set(SHOW_WEB_CONTENT_READER_STATE_KEY, this.showWebContentReader);
|
||||
state.Set(USE_CONTENT_CLEANER_AGENT_STATE_KEY, this.useContentCleanerAgent);
|
||||
state.Set(WEB_CONTENT_URL_STATE_KEY, this.webContentURL);
|
||||
state.Set(INPUT_TEXT_STATE_KEY, this.inputText);
|
||||
state.Set(IS_AGENT_RUNNING_STATE_KEY, this.isAgentRunning);
|
||||
state.Set(SELECTED_TARGET_LANGUAGE_STATE_KEY, this.selectedTargetLanguage);
|
||||
@ -102,6 +106,7 @@ public partial class AssistantTextSummarizer : AssistantBaseCore<SettingsDialogT
|
||||
{
|
||||
state.Restore(SHOW_WEB_CONTENT_READER_STATE_KEY, value => this.showWebContentReader = value);
|
||||
state.Restore(USE_CONTENT_CLEANER_AGENT_STATE_KEY, value => this.useContentCleanerAgent = value);
|
||||
state.Restore(WEB_CONTENT_URL_STATE_KEY, value => this.webContentURL = value);
|
||||
state.Restore(INPUT_TEXT_STATE_KEY, value => this.inputText = value);
|
||||
state.Restore(IS_AGENT_RUNNING_STATE_KEY, value => this.isAgentRunning = value);
|
||||
state.Restore(SELECTED_TARGET_LANGUAGE_STATE_KEY, value => this.selectedTargetLanguage = value);
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
|
||||
@if (!this.SettingsManager.ConfigurationData.Translation.HideWebContentReader)
|
||||
{
|
||||
<ReadWebContent @bind-Content="@this.inputText" ProviderSettings="@this.ProviderSettings" @bind-AgentIsRunning="@this.isAgentRunning" @bind-Preselect="@this.showWebContentReader" @bind-PreselectContentCleanerAgent="@this.useContentCleanerAgent"/>
|
||||
<ReadWebContent @bind-Content="@this.inputText" @bind-URL="@this.webContentURL" ProviderSettings="@this.ProviderSettings" @bind-AgentIsRunning="@this.isAgentRunning" @bind-Preselect="@this.showWebContentReader" @bind-PreselectContentCleanerAgent="@this.useContentCleanerAgent"/>
|
||||
}
|
||||
|
||||
<ReadFileContent @bind-FileContent="@this.inputText" EnableDragDrop="true" CatchAllDocuments="true"/>
|
||||
|
||||
@ -47,6 +47,7 @@ public partial class AssistantTranslation : AssistantBaseCore<SettingsDialogTran
|
||||
{
|
||||
this.inputText = string.Empty;
|
||||
this.inputTextLastTranslation = string.Empty;
|
||||
this.webContentURL = string.Empty;
|
||||
if (!this.MightPreselectValues())
|
||||
{
|
||||
this.showWebContentReader = false;
|
||||
@ -76,6 +77,7 @@ public partial class AssistantTranslation : AssistantBaseCore<SettingsDialogTran
|
||||
private bool useContentCleanerAgent;
|
||||
private bool liveTranslation;
|
||||
private bool isAgentRunning;
|
||||
private string webContentURL = string.Empty;
|
||||
private string inputText = string.Empty;
|
||||
private string inputTextLastTranslation = string.Empty;
|
||||
private CommonLanguages selectedTargetLanguage;
|
||||
@ -84,6 +86,7 @@ public partial class AssistantTranslation : AssistantBaseCore<SettingsDialogTran
|
||||
private static readonly AssistantSessionStateKey<bool> USE_CONTENT_CLEANER_AGENT_STATE_KEY = new(nameof(useContentCleanerAgent));
|
||||
private static readonly AssistantSessionStateKey<bool> LIVE_TRANSLATION_STATE_KEY = new(nameof(liveTranslation));
|
||||
private static readonly AssistantSessionStateKey<bool> IS_AGENT_RUNNING_STATE_KEY = new(nameof(isAgentRunning));
|
||||
private static readonly AssistantSessionStateKey<string> WEB_CONTENT_URL_STATE_KEY = new(nameof(webContentURL));
|
||||
private static readonly AssistantSessionStateKey<string> INPUT_TEXT_STATE_KEY = new(nameof(inputText));
|
||||
private static readonly AssistantSessionStateKey<string> INPUT_TEXT_LAST_TRANSLATION_STATE_KEY = new(nameof(inputTextLastTranslation));
|
||||
private static readonly AssistantSessionStateKey<CommonLanguages> SELECTED_TARGET_LANGUAGE_STATE_KEY = new(nameof(selectedTargetLanguage));
|
||||
@ -96,6 +99,7 @@ public partial class AssistantTranslation : AssistantBaseCore<SettingsDialogTran
|
||||
state.Set(USE_CONTENT_CLEANER_AGENT_STATE_KEY, this.useContentCleanerAgent);
|
||||
state.Set(LIVE_TRANSLATION_STATE_KEY, this.liveTranslation);
|
||||
state.Set(IS_AGENT_RUNNING_STATE_KEY, this.isAgentRunning);
|
||||
state.Set(WEB_CONTENT_URL_STATE_KEY, this.webContentURL);
|
||||
state.Set(INPUT_TEXT_STATE_KEY, this.inputText);
|
||||
state.Set(INPUT_TEXT_LAST_TRANSLATION_STATE_KEY, this.inputTextLastTranslation);
|
||||
state.Set(SELECTED_TARGET_LANGUAGE_STATE_KEY, this.selectedTargetLanguage);
|
||||
@ -109,6 +113,7 @@ public partial class AssistantTranslation : AssistantBaseCore<SettingsDialogTran
|
||||
state.Restore(USE_CONTENT_CLEANER_AGENT_STATE_KEY, value => this.useContentCleanerAgent = value);
|
||||
state.Restore(LIVE_TRANSLATION_STATE_KEY, value => this.liveTranslation = value);
|
||||
state.Restore(IS_AGENT_RUNNING_STATE_KEY, value => this.isAgentRunning = value);
|
||||
state.Restore(WEB_CONTENT_URL_STATE_KEY, value => this.webContentURL = value);
|
||||
state.Restore(INPUT_TEXT_STATE_KEY, value => this.inputText = value);
|
||||
state.Restore(INPUT_TEXT_LAST_TRANSLATION_STATE_KEY, value => this.inputTextLastTranslation = value);
|
||||
state.Restore(SELECTED_TARGET_LANGUAGE_STATE_KEY, value => this.selectedTargetLanguage = value);
|
||||
|
||||
@ -267,17 +267,31 @@ internal sealed partial class VisualBriefingBuildOrchestrator
|
||||
FileTypes.IsAllowedPath(source.Path, FileTypes.IMAGE)).ToArray();
|
||||
if (imageSources.Length == 0)
|
||||
return;
|
||||
var capabilities = provider.GetModelCapabilities();
|
||||
var profile = provider.GetModelProfile();
|
||||
var acceptsImages = imageSources.Length == 1
|
||||
? capabilities.Contains(Capability.SINGLE_IMAGE_INPUT) ||
|
||||
capabilities.Contains(Capability.MULTIPLE_IMAGE_INPUT)
|
||||
: capabilities.Contains(Capability.MULTIPLE_IMAGE_INPUT);
|
||||
? profile.HasAny(Capability.SINGLE_IMAGE_INPUT | Capability.MULTIPLE_IMAGE_INPUT)
|
||||
: profile.Has(Capability.MULTIPLE_IMAGE_INPUT);
|
||||
if (!acceptsImages)
|
||||
throw new VisualBriefingBuildException(
|
||||
VisualBriefingFailureCode.MODEL_CAPABILITY_MISSING,
|
||||
VisualBriefingBuildStage.SOURCE_PREPARATION,
|
||||
"The selected model cannot process the number of source images and visual assets.",
|
||||
$"ImageCount={imageSources.Length}; SingleImage={capabilities.Contains(Capability.SINGLE_IMAGE_INPUT)}; MultipleImages={capabilities.Contains(Capability.MULTIPLE_IMAGE_INPUT)}.");
|
||||
$"ImageCount={imageSources.Length}; SingleImage={profile.Has(Capability.SINGLE_IMAGE_INPUT)}; MultipleImages={profile.Has(Capability.MULTIPLE_IMAGE_INPUT)}.");
|
||||
|
||||
//
|
||||
// And then the number, where a vendor has stated one. Without it, "takes several images"
|
||||
// is all the check above can ask, and a briefing of two hundred pictures passes it only to
|
||||
// be refused by the provider after everything has been read, uploaded and paid for.
|
||||
//
|
||||
// No limit is invented where none is documented. A model whose vendor says nothing keeps
|
||||
// the answer it has always had, which is that several means several.
|
||||
//
|
||||
if (profile.Images.MaxInOneMessage is { } allowed && imageSources.Length > allowed)
|
||||
throw new VisualBriefingBuildException(
|
||||
VisualBriefingFailureCode.MODEL_CAPABILITY_MISSING,
|
||||
VisualBriefingBuildStage.SOURCE_PREPARATION,
|
||||
$"The selected model accepts at most {allowed} images at once, and this briefing uses {imageSources.Length}.",
|
||||
$"ImageCount={imageSources.Length}; MaxPerMessage={profile.Images.MaxPerMessage}; MaxPerRequest={profile.Images.MaxPerRequest}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -146,10 +146,8 @@ public sealed record ChatThread
|
||||
/// </summary>
|
||||
public bool MayRunTools(SettingsManager settingsManager) => this.RuntimeToolsAreAssistantManaged || settingsManager.IsToolSelectionVisible(this.RuntimeComponent);
|
||||
|
||||
private bool allowProfile = true;
|
||||
|
||||
/// <summary>
|
||||
/// Prepares the system prompt for the chat thread.
|
||||
/// Prepares the system prompt for the chat thread, and remembers what it was built from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The actual system prompt depends on the selected profile. If no profile is selected,
|
||||
@ -161,7 +159,35 @@ public sealed record ChatThread
|
||||
/// <returns>The prepared system prompt.</returns>
|
||||
public string PrepareSystemPrompt(SettingsManager settingsManager, IEnumerable<ToolDefinition>? runnableToolDefinitions = null)
|
||||
{
|
||||
this.allowProfile = true;
|
||||
var prepared = this.BuildSystemPrompt(settingsManager, runnableToolDefinitions);
|
||||
|
||||
// We need a way to save the changed system prompt in our chat thread.
|
||||
// Otherwise, the chat thread will always tell us that it is using the
|
||||
// default system prompt:
|
||||
this.SystemPrompt = prepared.BasePrompt;
|
||||
LOGGER.LogInformation(prepared.Explanation);
|
||||
|
||||
return prepared.Text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Works out the system prompt without changing anything about the thread.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Split off from the preparation above so that somebody can ask how long the next request
|
||||
/// would be. Counting the tokens of a conversation has to ask the same question the request
|
||||
/// asks -- a count against the prompt a person typed, rather than against the one a chat
|
||||
/// template, a data source, a profile and the tool policy make of it, is a number about a
|
||||
/// request which is never sent.
|
||||
///
|
||||
/// Nothing here writes to the thread and nothing logs, because this runs while somebody types.
|
||||
/// </remarks>
|
||||
/// <param name="settingsManager">The settings manager instance to use.</param>
|
||||
/// <param name="runnableToolDefinitions">The tools which may run in this thread. Null when the thread runs without tools.</param>
|
||||
/// <returns>The system prompt and what building it decided.</returns>
|
||||
public PreparedSystemPrompt BuildSystemPrompt(SettingsManager settingsManager, IEnumerable<ToolDefinition>? runnableToolDefinitions = null)
|
||||
{
|
||||
var allowProfile = true;
|
||||
|
||||
//
|
||||
// Use the information from the chat template, if provided. Otherwise, use the default system prompt
|
||||
@ -186,18 +212,12 @@ public sealed record ChatThread
|
||||
else
|
||||
{
|
||||
logMessage = $"Using chat template '{chatTemplate.Name}' for chat thread '{this.Name}'.";
|
||||
this.allowProfile = chatTemplate.AllowProfileUsage;
|
||||
allowProfile = chatTemplate.AllowProfileUsage;
|
||||
systemPromptTextWithChatTemplate = chatTemplate.ToSystemPrompt();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We need a way to save the changed system prompt in our chat thread.
|
||||
// Otherwise, the chat thread will always tell us that it is using the
|
||||
// default system prompt:
|
||||
this.SystemPrompt = systemPromptTextWithChatTemplate;
|
||||
LOGGER.LogInformation(logMessage);
|
||||
|
||||
//
|
||||
// Add augmented data, if available:
|
||||
@ -214,18 +234,16 @@ public sealed record ChatThread
|
||||
false => systemPromptTextWithChatTemplate,
|
||||
};
|
||||
|
||||
if(isAugmentedDataAvailable)
|
||||
LOGGER.LogInformation("Augmented data is available for the chat thread.");
|
||||
else
|
||||
LOGGER.LogInformation("No augmented data is available for the chat thread.");
|
||||
|
||||
|
||||
logMessage = isAugmentedDataAvailable
|
||||
? $"{logMessage} Augmented data is available for the chat thread."
|
||||
: $"{logMessage} No augmented data is available for the chat thread.";
|
||||
|
||||
//
|
||||
// Add information from the profile if available and allowed:
|
||||
//
|
||||
string systemPromptText;
|
||||
logMessage = $"Using no profile for chat thread '{this.Name}'.";
|
||||
if (string.IsNullOrWhiteSpace(this.SelectedProfile) || !this.allowProfile)
|
||||
var profileNote = $"Using no profile for chat thread '{this.Name}'.";
|
||||
if (string.IsNullOrWhiteSpace(this.SelectedProfile) || !allowProfile)
|
||||
systemPromptText = systemPromptWithAugmentedData;
|
||||
else
|
||||
{
|
||||
@ -242,7 +260,7 @@ public sealed record ChatThread
|
||||
systemPromptText = systemPromptWithAugmentedData;
|
||||
else
|
||||
{
|
||||
logMessage = $"Using profile '{profile.Name}' for chat thread '{this.Name}'.";
|
||||
profileNote = $"Using profile '{profile.Name}' for chat thread '{this.Name}'.";
|
||||
systemPromptText = $"""
|
||||
{systemPromptWithAugmentedData}
|
||||
|
||||
@ -252,8 +270,6 @@ public sealed record ChatThread
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LOGGER.LogInformation(logMessage);
|
||||
|
||||
var toolPolicy = ToolSelectionRules.BuildToolPolicyPrompt(runnableToolDefinitions ?? []);
|
||||
if (!string.IsNullOrWhiteSpace(toolPolicy))
|
||||
@ -265,9 +281,10 @@ public sealed record ChatThread
|
||||
""";
|
||||
}
|
||||
|
||||
var explanation = $"{logMessage} {profileNote}";
|
||||
if(!this.IncludeDateTime)
|
||||
return systemPromptText;
|
||||
|
||||
return new(systemPromptText, systemPromptTextWithChatTemplate, allowProfile, explanation);
|
||||
|
||||
//
|
||||
// Prepend the current date and time to the system prompt:
|
||||
//
|
||||
@ -278,11 +295,13 @@ public sealed record ChatThread
|
||||
$"Today is {nowUtc:dddd, MMMM d, yyyy h:mm tt} (UTC) and {nowLocal:dddd, MMMM d, yyyy h:mm tt} (local time)."
|
||||
);
|
||||
|
||||
return $"""
|
||||
{currentDateTime}
|
||||
var withDateTime = $"""
|
||||
{currentDateTime}
|
||||
|
||||
{systemPromptText}
|
||||
""";
|
||||
{systemPromptText}
|
||||
""";
|
||||
|
||||
return new(withDateTime, systemPromptTextWithChatTemplate, allowProfile, explanation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
{
|
||||
<MudTooltip Text="@T("Number of sources")" Placement="Placement.Bottom">
|
||||
<MudBadge Content="@this.Content.Sources.Count" Color="Color.Primary" Overlap="true" BadgeClass="sources-card-header">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Link"/>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Link" Disabled="@(!this.HasSourcesToShow)" OnClick="@this.ShowSources"/>
|
||||
</MudBadge>
|
||||
</MudTooltip>
|
||||
}
|
||||
@ -223,7 +223,7 @@
|
||||
}
|
||||
@if (textContent.Sources.Count > 0)
|
||||
{
|
||||
<MudMarkdown Value="@textContent.Sources.ToMarkdown()" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.SAFE_MARKDOWN_PIPELINE" />
|
||||
<SourcesList @ref="this.sourcesList" Sources="@textContent.Sources"/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -123,6 +123,7 @@ public partial class ContentBlockComponent : MSGComponentBase
|
||||
private IReadOnlyList<MessageTable> cachedMessageTables = [];
|
||||
private char csvSeparator = ',';
|
||||
private ElementReference mathContentContainer;
|
||||
private SourcesList? sourcesList;
|
||||
private string lastMathRenderSignature = string.Empty;
|
||||
private bool hasActiveMathContainer;
|
||||
private bool isDisposed;
|
||||
@ -732,7 +733,7 @@ public partial class ContentBlockComponent : MSGComponentBase
|
||||
//
|
||||
if (format.UsesPandoc())
|
||||
await PandocExport.ToDocument(this.RustService, this.PandocAvailability, this.EffectiveExportTitle, format, this.Content);
|
||||
else if (this.Content.TryGetMarkdownText(out var markdown))
|
||||
else if (this.Content.TryGetExportMarkdown(out var markdown))
|
||||
await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, format, markdown);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException e)
|
||||
@ -815,6 +816,25 @@ public partial class ContentBlockComponent : MSGComponentBase
|
||||
this.Content.FileAttachments = [.. result];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the sources of this block stand below the answer, where the counter can take the reader.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The same condition the block itself renders the list under. While an answer is still coming
|
||||
/// in, its sources may already be known, but there is nothing on the page yet to scroll to --
|
||||
/// so the counter says it cannot do anything rather than doing nothing when clicked.
|
||||
/// </remarks>
|
||||
private bool HasSourcesToShow => this.Content is { InitialRemoteWait: false, IsStreaming: false, Sources.Count: > 0 };
|
||||
|
||||
/// <summary>
|
||||
/// Takes the reader from the source counter down to the sources themselves.
|
||||
/// </summary>
|
||||
private async Task ShowSources()
|
||||
{
|
||||
if (this.sourcesList is not null)
|
||||
await this.sourcesList.ScrollIntoViewAsync();
|
||||
}
|
||||
|
||||
protected override async ValueTask DisposeResourcesAsync()
|
||||
{
|
||||
if (this.isDisposed)
|
||||
|
||||
@ -55,6 +55,49 @@ public sealed class ContentText : IContent
|
||||
[JsonIgnore]
|
||||
public ToolRuntimeStatus ToolRuntimeStatus { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// What the tool conversation of the running request adds to it, as far as it has got.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A model which calls tools asks several times before it answers, and every one of those
|
||||
/// requests carries everything the tools returned so far -- up to three hundred thousand
|
||||
/// characters of it. None of that is in this block's text, and none of it is in the traces
|
||||
/// either: those say what happened, not what it costs. So it is kept here, where whoever
|
||||
/// counts the conversation walks past anyway.<br/><br/>
|
||||
/// Replaced as a whole, never appended to: it is written by the thread which runs the tools
|
||||
/// and read by the one which renders, and an exchange leaves the reader with a list which was
|
||||
/// true at some moment rather than with one being rewritten under it.<br/><br/>
|
||||
/// Gone when the answer is there, and never persisted. The accumulated tool conversation lives
|
||||
/// in the provider adapter, which is created for one request and dropped with it -- so the next
|
||||
/// request does not carry it, and a number which still counted it would promise a cost nobody
|
||||
/// is going to pay.
|
||||
/// </remarks>
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<string> PendingToolConversation { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Clears what the previous run of the tools left behind.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both parts at once, because both belong to one request: the traces the user reads and the
|
||||
/// payload the counting needs. They were cleared separately for exactly as long as there was
|
||||
/// only one of them.
|
||||
/// </remarks>
|
||||
public void BeginToolRun()
|
||||
{
|
||||
this.ToolInvocations.Clear();
|
||||
this.PendingToolConversation = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Says that no request is running anymore.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The traces stay -- they are what the user reads afterwards to see how the answer came
|
||||
/// about. What goes is the payload, which belonged to a request that is over.
|
||||
/// </remarks>
|
||||
public void EndToolRun() => this.PendingToolConversation = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ChatThread> CreateFromProviderAsync(IProvider provider, Model chatModel, IContent? lastUserPrompt, ChatThread? chatThread, CancellationToken token = default)
|
||||
{
|
||||
@ -85,9 +128,25 @@ public sealed class ContentText : IContent
|
||||
var rag = new AISrcSelWithRetCtxVal();
|
||||
chatThread = await rag.ProcessAsync(provider, lastUserPrompt, chatThread, token);
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
//
|
||||
// The user canceled the request. That is not an error, and it must not reach the
|
||||
// user as one. We do not rethrow here: the streaming task below observes the same
|
||||
// token and ends the request itself, which keeps its finally block intact. That
|
||||
// block is what tells the UI that the streaming is over.
|
||||
//
|
||||
LOGGER.LogInformation("The RAG process was canceled before the answer was requested.");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOGGER.LogError(e, "Skipping the RAG process due to an error.");
|
||||
|
||||
//
|
||||
// The answer is about to be created without the data the user expected it to use.
|
||||
// Without this message, that answer is indistinguishable from one that did use it:
|
||||
//
|
||||
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.Source, TB("Your data sources could not be used. This answer was created without them.")));
|
||||
}
|
||||
}
|
||||
|
||||
@ -161,7 +220,8 @@ public sealed class ContentText : IContent
|
||||
finally
|
||||
{
|
||||
this.Text = this.Text.RemoveThinkTags().Trim();
|
||||
|
||||
this.EndToolRun();
|
||||
|
||||
// Inform the UI that the streaming is done:
|
||||
await this.StreamingDone();
|
||||
}
|
||||
|
||||
197
app/MindWork AI Studio/Chat/ConversationParts.cs
Normal file
197
app/MindWork AI Studio/Chat/ConversationParts.cs
Normal file
@ -0,0 +1,197 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Tools.ToolCallingSystem;
|
||||
|
||||
namespace AIStudio.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Everything a conversation would put into the next request, sorted by how it can be counted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Collected here rather than while counting, so that what counts towards a token budget is one
|
||||
/// question with one answer which a test can ask. It follows what the message builder actually
|
||||
/// sends: the system prompt, the schema of every tool the model may call, the text of every block,
|
||||
/// and the attachments hanging off those blocks -- plus whatever is standing in the composer but
|
||||
/// has not been sent yet, because that is the part a person is deciding about while they look at
|
||||
/// the number.
|
||||
///
|
||||
/// And, while a request is running, what its tools have returned so far. That is the one part
|
||||
/// which is not about the next request but about the one in flight: it is what the model is
|
||||
/// reading at this moment, it is what fills the window while somebody watches, and it is gone
|
||||
/// again once the answer stands.
|
||||
/// </remarks>
|
||||
public sealed record ConversationParts
|
||||
{
|
||||
/// <summary>
|
||||
/// A conversation with nothing in it.
|
||||
/// </summary>
|
||||
public static readonly ConversationParts NOTHING = new();
|
||||
|
||||
/// <summary>
|
||||
/// The texts which go into the request as they are.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Texts { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The texts which belong to this moment alone.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// They cost exactly what the others cost; what sets them apart is that they will never be seen
|
||||
/// again in this shape. The sentence somebody is typing changes with the next pause, and an
|
||||
/// answer being streamed is a different text three seconds later -- so remembering what they
|
||||
/// cost fills memory with answers nobody will ask for again.
|
||||
///
|
||||
/// What a model's tools have returned so far belongs here for the same reason, although nobody
|
||||
/// is writing it: it travels with every further round of one request and with nothing after
|
||||
/// that, so it is measured while it matters and forgotten when the answer is there.
|
||||
/// </remarks>
|
||||
public IReadOnlyList<string> GrowingTexts { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The documents whose content is put into the request.
|
||||
/// </summary>
|
||||
public IReadOnlyList<FileAttachment> Documents { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// How many images travel along.
|
||||
/// </summary>
|
||||
public int Images { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Collects what a conversation would send.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Blocks without text are skipped, because the message builder skips them too: a block whose
|
||||
/// text is empty never becomes a message, whatever else hangs off it. What such a block may
|
||||
/// still carry is the tool conversation of a request which is running right now -- that one
|
||||
/// does travel, and it is read before the text is looked at.
|
||||
/// </remarks>
|
||||
/// <param name="thread">The conversation so far, or null when there is none yet.</param>
|
||||
/// <param name="systemPrompt">
|
||||
/// The system prompt as it would be sent, which is not the one a person typed: a chat template
|
||||
/// may replace it, the retrieved data of a data source is appended to it, a profile adds a
|
||||
/// paragraph, and the tool policy adds another.
|
||||
/// </param>
|
||||
/// <param name="draft">What stands in the composer.</param>
|
||||
/// <param name="draftAttachments">What is attached to the composer.</param>
|
||||
/// <param name="imagesAreSent">Whether the model takes images at all. When it does not, none are sent.</param>
|
||||
/// <param name="toolDefinitions">
|
||||
/// The tools the model may call, filtered for the provider the same way they are before
|
||||
/// sending, or null when there are none.
|
||||
/// </param>
|
||||
/// <returns>The parts of the conversation.</returns>
|
||||
public static ConversationParts Of(ChatThread? thread, string systemPrompt, string draft, IEnumerable<FileAttachment>? draftAttachments, bool imagesAreSent, IEnumerable<ToolDefinition>? toolDefinitions)
|
||||
{
|
||||
var texts = new List<string>();
|
||||
var growing = new List<string>();
|
||||
var documents = new List<FileAttachment>();
|
||||
var images = 0;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(systemPrompt))
|
||||
texts.Add(systemPrompt);
|
||||
|
||||
//
|
||||
// The tools ride along beside the messages, one schema each, in every single request of a
|
||||
// conversation. Counted with the lasting texts rather than with the growing ones: a schema
|
||||
// is the same string all session long, so measuring it once and remembering it is exactly
|
||||
// what the cache is for.
|
||||
//
|
||||
foreach (var definition in toolDefinitions ?? [])
|
||||
texts.Add(Describe(definition));
|
||||
|
||||
if (thread is not null)
|
||||
{
|
||||
//
|
||||
// Blocks hidden from the user are counted like any other. They are hidden on the screen,
|
||||
// not in the request: the message builder sends them, so they take their tokens whether
|
||||
// or not anybody can see them.
|
||||
//
|
||||
foreach (var block in thread.Blocks)
|
||||
{
|
||||
if (block.ContentType is not ContentType.TEXT || block.Content is not ContentText text)
|
||||
continue;
|
||||
|
||||
//
|
||||
// Asked before the text is, because while a model calls tools there is no text yet:
|
||||
// the answer arrives in one piece at the end, and everything in between travels as
|
||||
// the tool conversation. A block skipped for having nothing to say is exactly the
|
||||
// block whose request is growing the fastest.
|
||||
//
|
||||
growing.AddRange(text.PendingToolConversation);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(text.Text))
|
||||
continue;
|
||||
|
||||
if (text.IsStreaming)
|
||||
growing.Add(text.Text);
|
||||
else
|
||||
texts.Add(text.Text);
|
||||
|
||||
Sort(text.FileAttachments, documents, ref images);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(draft))
|
||||
growing.Add(draft);
|
||||
|
||||
if (draftAttachments is not null)
|
||||
Sort(draftAttachments, documents, ref images);
|
||||
|
||||
return new()
|
||||
{
|
||||
Texts = texts,
|
||||
GrowingTexts = growing,
|
||||
Documents = documents,
|
||||
Images = imagesAreSent ? images : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What one tool costs the request it is offered in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its name, what it tells the model it does, and the arguments it takes -- that is what the
|
||||
/// provider adapters put into the tool list of the request body. The wire shape differs
|
||||
/// between the APIs: they name the fields differently, and a strict schema is rewritten for
|
||||
/// the OpenAI ones. None of that changes the length by an amount which matters next to a
|
||||
/// conversation, and the number is reported as an estimate anyway.
|
||||
/// </remarks>
|
||||
/// <param name="definition">The tool as it was declared.</param>
|
||||
/// <returns>The text to count for it.</returns>
|
||||
private static string Describe(ToolDefinition definition)
|
||||
{
|
||||
var parameters = definition.Function.Parameters.ValueKind is JsonValueKind.Undefined
|
||||
? string.Empty
|
||||
: definition.Function.Parameters.GetRawText();
|
||||
|
||||
return $"{definition.Function.Name}{definition.Function.DescriptionForLLM}{parameters}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts attachments into the two groups they are counted in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An attachment whose file is gone is left out of both. It is not sent either: the message
|
||||
/// builder drops it and tells the person about it, so counting it would promise a request which
|
||||
/// is never made.
|
||||
/// </remarks>
|
||||
private static void Sort(IEnumerable<FileAttachment> attachments, List<FileAttachment> documents, ref int images)
|
||||
{
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
if (!attachment.Exists)
|
||||
continue;
|
||||
|
||||
switch (attachment.Type)
|
||||
{
|
||||
case FileAttachmentType.DOCUMENT:
|
||||
documents.Add(attachment);
|
||||
break;
|
||||
|
||||
case FileAttachmentType.IMAGE:
|
||||
images++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
172
app/MindWork AI Studio/Chat/ConversationTokenTracker.cs
Normal file
172
app/MindWork AI Studio/Chat/ConversationTokenTracker.cs
Normal file
@ -0,0 +1,172 @@
|
||||
namespace AIStudio.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps a number up to date which nothing announces.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A conversation is a plain list of plain objects. Nothing raises an event when a block is added,
|
||||
/// when a document is attached, or when an answer grows by another sentence -- so a number derived
|
||||
/// from all of that cannot be wired to the places which change it. It was tried: fifteen call sites,
|
||||
/// and four review rounds each found another one which was missing.
|
||||
///
|
||||
/// So the number is recomputed instead of notified. Whoever thinks something may have changed nudges
|
||||
/// this tracker, and the tracker decides when to do the work: many nudges in a row become one run, a
|
||||
/// nudge arriving during a run becomes exactly one further run, and a minimum distance keeps a burst
|
||||
/// of them from turning into a burst of counting.
|
||||
///
|
||||
/// The heartbeat is not distrust of the nudges. Attachments are read from disk every time they are
|
||||
/// sent, so a file somebody edits in another program changes what the next message costs without
|
||||
/// anything happening in AI Studio which anyone could nudge from.
|
||||
/// </remarks>
|
||||
/// <param name="recount">Does the actual work. Gets a token which ends it when the tracker goes away.</param>
|
||||
/// <param name="quietTime">
|
||||
/// How long to stay quiet after a run before honouring the next nudge. Asked again each time,
|
||||
/// because what is reasonable depends on what is going on: a person who just switched a profile is
|
||||
/// waiting for the number, while an answer being written moves it with every word and wants a
|
||||
/// slower pace than the words arrive at.
|
||||
/// </param>
|
||||
/// <param name="heartbeat">How long to wait for a nudge before running anyway.</param>
|
||||
public sealed class ConversationTokenTracker(Func<CancellationToken, Task> recount, Func<TimeSpan> quietTime, TimeSpan heartbeat) : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// How long a tracker which is going away waits for its own loop.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The loop ends on cancellation, so this is only ever reached when something it called does
|
||||
/// not. Whoever is leaving the screen must not be the one who waits for that.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan SHUTDOWN_PATIENCE = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly SemaphoreSlim wakeUp = new(0, 1);
|
||||
private readonly CancellationTokenSource stopping = new();
|
||||
|
||||
private Task? loop;
|
||||
|
||||
/// <summary>
|
||||
/// Starts the loop. Calling this twice does nothing the second time.
|
||||
/// </summary>
|
||||
public void Start() => this.loop ??= Task.Run(this.RunAsync);
|
||||
|
||||
/// <summary>
|
||||
/// Says that something may have changed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Cheap on purpose, because it is called from the render path. It says "maybe", never "yes":
|
||||
/// asking for a run which turns out to change nothing costs a few lookups, while missing one is
|
||||
/// the bug this whole class exists to make impossible.
|
||||
/// </remarks>
|
||||
public void Nudge()
|
||||
{
|
||||
//
|
||||
// One pending wake-up is all a loop can act on. A second one would only make it run again
|
||||
// with the same answer.
|
||||
//
|
||||
if (this.wakeUp.CurrentCount > 0)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
this.wakeUp.Release();
|
||||
}
|
||||
catch (SemaphoreFullException)
|
||||
{
|
||||
//
|
||||
// Two threads got past the check above at the same time. The one which won left the
|
||||
// wake-up we wanted, so there is nothing left to do here.
|
||||
//
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// The tracker is going away, and a number nobody will look at needs no update.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunAsync()
|
||||
{
|
||||
var token = this.stopping.Token;
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
//
|
||||
// Sleeps until somebody nudges -- or until the heartbeat is due, which is what the
|
||||
// timeout returning false means. Both lead to the same run, so the result is not
|
||||
// even looked at.
|
||||
//
|
||||
await this.wakeUp.WaitAsync(heartbeat, token);
|
||||
if (token.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
//
|
||||
// Deliberately without draining further wake-ups first. A nudge which arrives while
|
||||
// this run reads the conversation may well be about a change this run is already
|
||||
// seeing -- and then the extra run costs a few lookups. Draining would risk the
|
||||
// other case, where the change comes after the read and nobody asks again.
|
||||
//
|
||||
try
|
||||
{
|
||||
await recount(token);
|
||||
}
|
||||
catch (Exception) when (!token.IsCancellationRequested)
|
||||
{
|
||||
//
|
||||
// One failed run must not end the loop: a tracker which died on a single bad
|
||||
// answer would leave a stale number standing forever, which is the failure this
|
||||
// class was built to rule out. Saying what went wrong is the job of the work
|
||||
// itself, which is the only side that has a logger.
|
||||
//
|
||||
}
|
||||
|
||||
//
|
||||
// The quiet time is kept after the work, not before it: the first nudge of a burst
|
||||
// is answered at once, and the rest of the burst collapses into the single run which
|
||||
// follows this delay.
|
||||
//
|
||||
// It is also what paces a run which feeds itself. Showing a new number renders, and
|
||||
// a render nudges -- so while something changes continuously, this delay is the
|
||||
// whole cadence.
|
||||
//
|
||||
await Task.Delay(quietTime(), token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// The tracker was disposed underneath this loop, which is another way of stopping.
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#region Implementation of IAsyncDisposable
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await this.stopping.CancelAsync();
|
||||
|
||||
if (this.loop is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
//
|
||||
// Awaited rather than abandoned, so that nothing is still counting into a component
|
||||
// which is already gone. The counting itself takes the same token, so a run which
|
||||
// sits in an IPC call ends with it -- and the patience is there for the case where
|
||||
// it does not, because a chat being closed is not worth hanging on to.
|
||||
//
|
||||
await this.loop.WaitAsync(SHUTDOWN_PATIENCE);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// The loop ends on cancellation; whatever else it carries out is of no use here.
|
||||
}
|
||||
}
|
||||
|
||||
this.stopping.Dispose();
|
||||
this.wakeUp.Dispose();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
83
app/MindWork AI Studio/Chat/ConversationTokens.cs
Normal file
83
app/MindWork AI Studio/Chat/ConversationTokens.cs
Normal file
@ -0,0 +1,83 @@
|
||||
using AIStudio.Models;
|
||||
|
||||
namespace AIStudio.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// What a conversation costs, as far as the app can count it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Three separate statements, and keeping them apart is the point. How many tokens were counted is
|
||||
/// one; what the model's window is, if anybody has written it down, is the second; and how much of
|
||||
/// the conversation could not be counted at all is the third. Folding any of them into the others
|
||||
/// would turn a gap into a number somebody reads as a fact.
|
||||
/// </remarks>
|
||||
public readonly record struct ConversationTokens
|
||||
{
|
||||
/// <summary>
|
||||
/// The answer when nothing could be counted, which is what a broken tokenizer leaves behind.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately not a zero. A conversation of no tokens and a conversation nobody could measure
|
||||
/// look the same as a number and are not the same thing, so the display shows nothing at all
|
||||
/// rather than claiming an empty chat.
|
||||
/// </remarks>
|
||||
public static readonly ConversationTokens UNAVAILABLE = new();
|
||||
|
||||
/// <summary>
|
||||
/// Whether anything could be counted.
|
||||
/// </summary>
|
||||
public bool IsKnown { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// How many tokens the counted parts of the conversation take.
|
||||
/// </summary>
|
||||
public int Tokens { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the number is an estimate rather than the model's own count.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// True whenever the built-in tokenizer did the counting, which is the normal case: a model's
|
||||
/// own tokenizer is only used where somebody configured one for their provider. Two tokenizers
|
||||
/// disagree by a few percent on ordinary prose and by a lot more on code or a language they were
|
||||
/// not trained on, so the number is shown as an approximation unless we counted with the
|
||||
/// tokenizer the model itself uses.
|
||||
/// </remarks>
|
||||
public bool IsEstimate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// How much the model reads, where anybody has stated it.
|
||||
/// </summary>
|
||||
public ContextWindow Window { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// How many images travel along which nobody can count.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every vendor charges images differently -- OpenAI by tiles of the scaled image, Anthropic by
|
||||
/// its area, Google by tiles of another size -- and none of those numbers can be had from the
|
||||
/// file without decoding it first. So they are reported as a number of images instead of being
|
||||
/// guessed at, or worse, counted as the base64 text they are sent as: that text is two to three
|
||||
/// orders of magnitude longer than what any vendor charges for the picture.
|
||||
/// </remarks>
|
||||
public int UncountedImages { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// How many images the model takes, where its vendor stated a number.
|
||||
/// </summary>
|
||||
public ImageLimits ImageLimits { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether more images travel than the model is documented to accept.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Counted over the whole conversation rather than over the message being written, because that
|
||||
/// is what a request carries: every picture anybody attached is sent again with every further
|
||||
/// message, so a chat crosses this line long after the message which added the picture -- and
|
||||
/// the person who crosses it has usually forgotten that the pictures are still there.
|
||||
///
|
||||
/// False whenever nobody stated a limit, which is most models. An invented ceiling would refuse
|
||||
/// something that works.
|
||||
/// </remarks>
|
||||
public bool TooManyImages => this.ImageLimits.MaxInOneMessage is { } allowed && this.UncountedImages > allowed;
|
||||
}
|
||||
@ -24,7 +24,9 @@ public static class IContentExtensions
|
||||
/// <remarks>
|
||||
/// Only text content carries Markdown. Everything else, an image for example, has no text
|
||||
/// representation at all, which is why this reports failure instead of returning a placeholder:
|
||||
/// a caller which writes files must not put an excuse into the file it writes.
|
||||
/// a caller which writes files must not put an excuse into the file it writes. This is the text
|
||||
/// the model wrote and nothing else: whoever reads a table out of a message wants exactly that,
|
||||
/// while whoever writes a file wants the sources along with it and asks for the export reading.
|
||||
/// </remarks>
|
||||
/// <param name="content">The content to read.</param>
|
||||
/// <param name="markdown">The Markdown text, or an empty string when there is none.</param>
|
||||
@ -40,4 +42,51 @@ public static class IContentExtensions
|
||||
markdown = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads this content the way it leaves AI Studio, as a file or through the clipboard.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What the user sees is the answer together with the sources AI Studio collected for it, and
|
||||
/// that is what a document has to hold as well: an answer built on a web page a tool read, or on
|
||||
/// a document of the user, is worth little when the reader cannot tell which one it was. Those
|
||||
/// sources are not part of the text the model wrote, they hang on the content, which is why
|
||||
/// every path out of the app asks for this and not for the text alone.
|
||||
/// </remarks>
|
||||
/// <param name="content">The content to read.</param>
|
||||
/// <param name="markdown">The Markdown text including its sources, or an empty string when there is none.</param>
|
||||
/// <param name="keepPageAnchors">Whether a link into a local file may name its page. Only a
|
||||
/// format whose reader stumbles over such a link says no here; the clipboard and every text
|
||||
/// format keep the page.</param>
|
||||
/// <returns>True, when this content carries Markdown text.</returns>
|
||||
public static bool TryGetExportMarkdown(this IContent content, out string markdown, bool keepPageAnchors = true)
|
||||
{
|
||||
if (content is not ContentText text)
|
||||
{
|
||||
markdown = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
var answer = text.Text.Trim();
|
||||
var sources = text.Sources.ToExportMarkdown(keepPageAnchors);
|
||||
if (sources.Length == 0)
|
||||
{
|
||||
markdown = answer;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (answer.Length == 0)
|
||||
{
|
||||
markdown = sources;
|
||||
return true;
|
||||
}
|
||||
|
||||
//
|
||||
// The blank line is not cosmetic: it ends a paragraph, a list, a table, or a block quote, so
|
||||
// that the heading of the source list stands on its own instead of being pulled into the
|
||||
// last block of the answer.
|
||||
//
|
||||
markdown = $"{Markdown.CloseOpenCodeFence(answer)}{Environment.NewLine}{Environment.NewLine}{sources}";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -11,23 +11,25 @@ public static class ListContentBlockExtensions
|
||||
/// </summary>
|
||||
/// <param name="blocks">The list of content blocks to process.</param>
|
||||
/// <param name="roleTransformer">A function that transforms each content block into a message result asynchronously.</param>
|
||||
/// <param name="selectedProvider">The selected LLM provider.</param>
|
||||
/// <param name="selectedModel">The selected model.</param>
|
||||
/// <param name="provider">The configured provider, whose model is being written to.</param>
|
||||
/// <param name="textSubContentFactory">A factory function to create text sub-content.</param>
|
||||
/// <param name="imageSubContentFactory">A factory function to create image sub-content.</param>
|
||||
/// <returns>An asynchronous task that resolves to a list of transformed results.</returns>
|
||||
public static async Task<IList<IMessageBase>> BuildMessagesAsync(
|
||||
this List<ContentBlock> blocks,
|
||||
LLMProviders selectedProvider,
|
||||
Model selectedModel,
|
||||
AIStudio.Settings.Provider provider,
|
||||
Func<ChatRole, string> roleTransformer,
|
||||
Func<string, ISubContent> textSubContentFactory,
|
||||
Func<FileAttachmentImage, Task<ISubContent>> imageSubContentFactory)
|
||||
{
|
||||
var capabilities = selectedProvider.GetModelCapabilities(selectedModel);
|
||||
var canProcessImages = capabilities.Contains(Capability.MULTIPLE_IMAGE_INPUT) ||
|
||||
capabilities.Contains(Capability.SINGLE_IMAGE_INPUT);
|
||||
|
||||
//
|
||||
// Asked through the configured provider, so that what a person set in their expert settings
|
||||
// counts here too. It did not: this path read the automatic answer alone, so somebody who
|
||||
// switched image input on saw it work while attaching the picture and saw it ignored while
|
||||
// the message was built -- every chat round and every tool round.
|
||||
//
|
||||
var canProcessImages = provider.SupportsImageInput();
|
||||
|
||||
var messageTaskList = new List<Task<IMessageBase>>(blocks.Count);
|
||||
foreach (var block in blocks)
|
||||
{
|
||||
@ -102,8 +104,7 @@ public static class ListContentBlockExtensions
|
||||
/// Processes a list of content blocks using direct image URL format to create message results asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="blocks">The list of content blocks to process.</param>
|
||||
/// <param name="selectedProvider">The selected LLM provider.</param>
|
||||
/// <param name="selectedModel">The selected model.</param>
|
||||
/// <param name="provider">The configured provider, whose model is being written to.</param>
|
||||
/// <returns>An asynchronous task that resolves to a list of transformed message results.</returns>
|
||||
/// <remarks>
|
||||
/// Uses direct image URL format where the image data is placed directly in the image_url field:
|
||||
@ -114,10 +115,8 @@ public static class ListContentBlockExtensions
|
||||
/// </remarks>
|
||||
public static async Task<IList<IMessageBase>> BuildMessagesUsingDirectImageUrlAsync(
|
||||
this List<ContentBlock> blocks,
|
||||
LLMProviders selectedProvider,
|
||||
Model selectedModel) => await blocks.BuildMessagesAsync(
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
AIStudio.Settings.Provider provider) => await blocks.BuildMessagesAsync(
|
||||
provider,
|
||||
StandardRoleTransformer,
|
||||
StandardTextSubContentFactory,
|
||||
DirectImageSubContentFactory);
|
||||
@ -126,8 +125,7 @@ public static class ListContentBlockExtensions
|
||||
/// Processes a list of content blocks using nested image URL format to create message results asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="blocks">The list of content blocks to process.</param>
|
||||
/// <param name="selectedProvider">The selected LLM provider.</param>
|
||||
/// <param name="selectedModel">The selected model.</param>
|
||||
/// <param name="provider">The configured provider, whose model is being written to.</param>
|
||||
/// <returns>An asynchronous task that resolves to a list of transformed message results.</returns>
|
||||
/// <remarks>
|
||||
/// Uses nested image URL format where the image data is wrapped in an object:
|
||||
@ -138,10 +136,8 @@ public static class ListContentBlockExtensions
|
||||
/// </remarks>
|
||||
public static async Task<IList<IMessageBase>> BuildMessagesUsingNestedImageUrlAsync(
|
||||
this List<ContentBlock> blocks,
|
||||
LLMProviders selectedProvider,
|
||||
Model selectedModel) => await blocks.BuildMessagesAsync(
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
AIStudio.Settings.Provider provider) => await blocks.BuildMessagesAsync(
|
||||
provider,
|
||||
StandardRoleTransformer,
|
||||
StandardTextSubContentFactory,
|
||||
NestedImageSubContentFactory);
|
||||
|
||||
19
app/MindWork AI Studio/Chat/PreparedSystemPrompt.cs
Normal file
19
app/MindWork AI Studio/Chat/PreparedSystemPrompt.cs
Normal file
@ -0,0 +1,19 @@
|
||||
namespace AIStudio.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// The system prompt of a chat thread as it would be sent, together with what building it decided.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The system prompt is not the text a person typed into it. A chat template may replace it, the
|
||||
/// retrieved data of a data source is appended to it, a profile adds its own paragraph, the tool
|
||||
/// policy adds another, and the current date goes in front of everything. Whoever wants to know how
|
||||
/// long the next request is has to ask the same question the request does.
|
||||
/// </remarks>
|
||||
/// <param name="Text">The whole system prompt, as the provider receives it.</param>
|
||||
/// <param name="BasePrompt">
|
||||
/// The prompt without any of the parts added around it. The thread keeps this one, so that it can
|
||||
/// still say which prompt it was configured with rather than the assembled result.
|
||||
/// </param>
|
||||
/// <param name="ProfileIsAllowed">Whether the chat template let a profile take part.</param>
|
||||
/// <param name="Explanation">What was used, in one sentence, for the log.</param>
|
||||
public sealed record PreparedSystemPrompt(string Text, string BasePrompt, bool ProfileIsAllowed, string Explanation);
|
||||
47
app/MindWork AI Studio/Chat/TokenAmount.cs
Normal file
47
app/MindWork AI Studio/Chat/TokenAmount.cs
Normal file
@ -0,0 +1,47 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace AIStudio.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Writes a number of tokens the way a person reads it next to their input field.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A context window of a million tokens written out in full is eight characters of noise under a
|
||||
/// text field, and nobody reads the last five of them. So everything from a thousand on is
|
||||
/// shortened, and two decimals keep the resolution a person acts on: the difference between 1.20k
|
||||
/// and 1.80k is one they can see, while the last three digits of 1,234 are not.
|
||||
///
|
||||
/// The culture is passed in rather than taken from the thread. AI Studio's language is chosen in
|
||||
/// its settings and does not move the thread's culture along with it, so a German who picked German
|
||||
/// would otherwise read English separators inside a German sentence.
|
||||
/// </remarks>
|
||||
public static class TokenAmount
|
||||
{
|
||||
/// <summary>
|
||||
/// Below this, the exact number is shown.
|
||||
/// </summary>
|
||||
private const int EXACT_BELOW = 1_000;
|
||||
|
||||
/// <summary>
|
||||
/// Writes a number of tokens.
|
||||
/// </summary>
|
||||
/// <param name="tokens">The number of tokens.</param>
|
||||
/// <param name="culture">The culture whose separators the number is written with.</param>
|
||||
/// <returns>The number, shortened from a thousand on.</returns>
|
||||
public static string Format(int tokens, CultureInfo culture)
|
||||
{
|
||||
if (tokens < EXACT_BELOW)
|
||||
return tokens.ToString("N0", culture);
|
||||
|
||||
//
|
||||
// Rounded before the unit is chosen, not after. Otherwise the few hundred tokens just below
|
||||
// a million round up inside their own unit and read as "1,000.00k", which is a number
|
||||
// nobody writes.
|
||||
//
|
||||
var thousands = tokens / 1_000d;
|
||||
if (Math.Round(thousands, 2) < 1_000d)
|
||||
return $"{thousands.ToString("N2", culture)}k";
|
||||
|
||||
return $"{(tokens / 1_000_000d).ToString("N2", culture)}M";
|
||||
}
|
||||
}
|
||||
@ -220,11 +220,31 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
/// Attaches what the user dropped on the zone of this component.
|
||||
/// </summary>
|
||||
/// <param name="paths">The dropped paths, in the order the runtime delivered them.</param>
|
||||
private async Task PathsDropped(List<string> paths)
|
||||
private async Task PathsDropped(List<string> paths) => await this.AttachDroppedPathsAsync(paths);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches dropped paths and reports which files it made of them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is what the attachment dialogs call while they are open: a drop lands in the dialog the
|
||||
/// user is looking at, yet only this component knows how to turn a path into an attachment. The
|
||||
/// answer is what lets those dialogs show the result, see PathsDropped for our own zone.
|
||||
/// </remarks>
|
||||
/// <param name="paths">The dropped paths, in the order the runtime delivered them.</param>
|
||||
/// <returns>The files this call attached, in the order they were dropped.</returns>
|
||||
private async Task<IReadOnlyList<FileAttachment>> AttachDroppedPathsAsync(List<string> paths)
|
||||
{
|
||||
await this.AddFileBatchAsync(paths);
|
||||
var attached = await this.AddFileBatchAsync(paths);
|
||||
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
|
||||
await this.OnChange(this.DocumentPaths);
|
||||
|
||||
//
|
||||
// A dialog reaches us through a delegate rather than through an event callback, so nothing
|
||||
// renders this component afterwards. Without this, the number on the badge would stay at its
|
||||
// old value until something else happens to render us.
|
||||
//
|
||||
this.StateHasChanged();
|
||||
return attached;
|
||||
}
|
||||
|
||||
private async Task AddFilesManually()
|
||||
@ -255,11 +275,19 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
return;
|
||||
|
||||
var previousAttachments = this.DocumentPaths.ToHashSet();
|
||||
this.DocumentPaths = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.DocumentPaths);
|
||||
this.DocumentPaths = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.DocumentPaths, this.AttachDroppedPathsAsync, () => this.IsUnavailable);
|
||||
foreach (var removedAttachment in previousAttachments.Except(this.DocumentPaths))
|
||||
ManagedTranscriptAttachment.TryDeleteOwnedFile(removedAttachment);
|
||||
|
||||
|
||||
this.ReconcileOwnerPendingTranscripts();
|
||||
|
||||
//
|
||||
// Said out loud, like every other path in this file. Removing a file in the dialog changed
|
||||
// the attachments while whoever owns them heard nothing about it -- the chat then kept
|
||||
// showing what the message no longer carries.
|
||||
//
|
||||
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
|
||||
await this.OnChange(this.DocumentPaths);
|
||||
}
|
||||
|
||||
private async Task ClearAllFiles()
|
||||
@ -299,8 +327,18 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
this.OwnerChat.PendingMediaTranscripts.RemoveAll(attachment => !retainedPaths.Contains(attachment.FilePath));
|
||||
}
|
||||
|
||||
private async Task AddFileBatchAsync(IEnumerable<string> paths)
|
||||
/// <summary>
|
||||
/// Validates the given paths and attaches every file which passes.
|
||||
/// </summary>
|
||||
/// <param name="paths">The paths to attach, in the order they arrived.</param>
|
||||
/// <returns>
|
||||
/// The files this call attached, in the order they arrived. Media files are never among them:
|
||||
/// they go to the transcription service and become attachments only once their transcript is
|
||||
/// ready, which is long after this call has returned.
|
||||
/// </returns>
|
||||
private async Task<IReadOnlyList<FileAttachment>> AddFileBatchAsync(IEnumerable<string> paths)
|
||||
{
|
||||
var attached = new List<FileAttachment>();
|
||||
var pathList = paths.ToList();
|
||||
if (this.AllowedFileTypes is { Length: > 0 })
|
||||
{
|
||||
@ -348,18 +386,25 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
if (!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.ATTACHING_CONTENT, path, this.ValidateMediaFileTypes, this.Provider))
|
||||
continue;
|
||||
|
||||
this.DocumentPaths.Add(FileAttachment.FromPath(path));
|
||||
//
|
||||
// This counts as attached even when the set already held the file: the user just
|
||||
// dropped it, and whoever asked us wants to hear about the file they aimed at, not
|
||||
// about whether it happened to be new to us.
|
||||
//
|
||||
var attachment = FileAttachment.FromPath(path);
|
||||
this.DocumentPaths.Add(attachment);
|
||||
attached.Add(attachment);
|
||||
}
|
||||
|
||||
if (mediaPaths.Count is 0)
|
||||
return;
|
||||
return attached;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider))
|
||||
{
|
||||
await this.MessageBus.SendWarning(new(
|
||||
Icons.Material.Filled.VoiceChat,
|
||||
this.T("Media files require a configured transcription provider. Configure one in the transcription settings.")));
|
||||
return;
|
||||
return attached;
|
||||
}
|
||||
|
||||
var names = string.Join('\n', mediaPaths.Select(path => $"- {Markdown.EscapeInlineText(Path.GetFileName(path))}"));
|
||||
@ -383,7 +428,7 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult is null || dialogResult.Canceled)
|
||||
return;
|
||||
return attached;
|
||||
|
||||
if (this.OwnerChat is null)
|
||||
this.OwnerChat = await this.EnsureOwnerChatAsync(mediaPaths[0]);
|
||||
@ -400,6 +445,7 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
}
|
||||
|
||||
this.MediaTranscriptionService.TryStartAttachmentBatch(mediaPaths, this.EffectiveMediaImportTarget, this.OwnerChat);
|
||||
return attached;
|
||||
}
|
||||
|
||||
private static bool IsTranscribableMedia(string path) => FileTypes.IsAllowedPath(path, FileTypes.AUDIO) || FileTypes.IsAllowedPath(path, FileTypes.VIDEO);
|
||||
@ -413,6 +459,8 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
var dialogParameters = new DialogParameters<DocumentCheckDialog>
|
||||
{
|
||||
{ x => x.Document, fileAttachment },
|
||||
{ x => x.AttachPaths, this.AttachDroppedPathsAsync },
|
||||
{ x => x.IsAttachingUnavailable, () => this.IsUnavailable },
|
||||
};
|
||||
|
||||
await this.DialogService.ShowAsync<DocumentCheckDialog>(T("Document Preview"), dialogParameters, DialogOptions.FULLSCREEN);
|
||||
|
||||
@ -51,7 +51,6 @@
|
||||
Disabled="@this.IsInputForbidden()"
|
||||
Immediate="@true"
|
||||
OnKeyUp="@this.InputKeyEvent"
|
||||
WhenTextChangedAsync="@(_ =>this.CalculateTokenCount())"
|
||||
UserAttributes="@USER_INPUT_ATTRIBUTES"
|
||||
Class="@this.UserInputClass"
|
||||
DebounceTime="TimeSpan.FromSeconds(1)"
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
using System.Globalization;
|
||||
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Dialogs;
|
||||
using AIStudio.Provider;
|
||||
@ -54,9 +56,10 @@ public partial class ChatComponent : MSGComponentBase
|
||||
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ConversationTokenCounter ConversationTokenCounter { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
[Inject]
|
||||
private IJSRuntime JsRuntime { get; init; } = null!;
|
||||
|
||||
@ -93,11 +96,112 @@ public partial class ChatComponent : MSGComponentBase
|
||||
private Guid loadedParameterWorkspaceId = Guid.Empty;
|
||||
private Guid foregroundChatId = Guid.Empty;
|
||||
private int workspaceHeaderSyncVersion;
|
||||
private string tokenCount = "0";
|
||||
private bool HasCustomTokenizer => !string.IsNullOrWhiteSpace(this.Provider.TokenizerPath);
|
||||
private string TokenCountMessage => this.HasCustomTokenizer
|
||||
? $"{this.T("Estimated amount of tokens:")} {this.tokenCount}"
|
||||
: string.Empty;
|
||||
private ConversationTokens conversationTokens = ConversationTokens.UNAVAILABLE;
|
||||
|
||||
/// <summary>
|
||||
/// How much of the window must be used before the number starts saying so.
|
||||
/// </summary>
|
||||
private const double WINDOW_NEARLY_FULL = 0.8d;
|
||||
|
||||
/// <summary>
|
||||
/// How long the token count stays quiet after it ran, while nothing is being written.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A render is cheap to ask about and a count is not. This is what keeps a burst of renders --
|
||||
/// loading a chat touches several things in a row -- from turning into a burst of counting,
|
||||
/// while staying short enough that switching a profile moves the number right away.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan TOKEN_COUNT_QUIET_TIME = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
/// <summary>
|
||||
/// How long the token count stays quiet while an answer is being written.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An answer grows with every word, so each count finds a new number, shows it, and thereby
|
||||
/// renders -- which asks for the next count. That makes this the whole cadence while a model
|
||||
/// writes, and three seconds is the pace the chat itself keeps: the job service hands its
|
||||
/// progress to the screen no more often than that.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan TOKEN_COUNT_STREAMING_QUIET_TIME = TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <summary>
|
||||
/// How long the token count waits for a reason before counting anyway.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For what happens outside AI Studio: an attached document is read from disk every time it is
|
||||
/// sent, so somebody editing it in another program changes what the next message costs without
|
||||
/// anything here rendering.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan TOKEN_COUNT_HEARTBEAT = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes the token count whenever something might have changed.
|
||||
/// </summary>
|
||||
private ConversationTokenTracker? tokenTracker;
|
||||
|
||||
/// <summary>
|
||||
/// How long to leave the token count alone after it ran.
|
||||
/// </summary>
|
||||
private TimeSpan TokenCountQuietTime() => this.IsCurrentChatStreaming ? TOKEN_COUNT_STREAMING_QUIET_TIME : TOKEN_COUNT_QUIET_TIME;
|
||||
|
||||
/// <summary>
|
||||
/// The culture the token numbers are written in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Taken from the language plugin the user chose, not from the machine. AI Studio's language is
|
||||
/// a setting of its own, and a German who set German would otherwise read English separators
|
||||
/// inside a German sentence -- where "1,234" means something a thousand times smaller.
|
||||
/// </remarks>
|
||||
private CultureInfo currentCulture = CultureInfo.InvariantCulture;
|
||||
|
||||
/// <summary>
|
||||
/// What the helper text under the input field says about the token budget.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Four sentences rather than one built from pieces, because a translator needs to see the
|
||||
/// whole thing: which of the two numbers is the limit, and where the word for "about" belongs,
|
||||
/// are decisions no language makes the same way.
|
||||
///
|
||||
/// The images are named rather than counted. Every vendor charges a picture differently, and
|
||||
/// none of those rules can be applied without decoding the file, so the honest answer is to say
|
||||
/// how many of them the number does not include.
|
||||
/// </remarks>
|
||||
private string TokenCountMessage
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!this.conversationTokens.IsKnown)
|
||||
return string.Empty;
|
||||
|
||||
var used = TokenAmount.Format(this.conversationTokens.Tokens, this.currentCulture);
|
||||
var budget = this.conversationTokens.Window.IsKnown
|
||||
? string.Format(this.conversationTokens.IsEstimate ? this.T("approx. {0} of {1} tokens") : this.T("{0} of {1} tokens"), used, TokenAmount.Format(this.conversationTokens.Window.DefaultTokens, this.currentCulture))
|
||||
: string.Format(this.conversationTokens.IsEstimate ? this.T("approx. {0} tokens") : this.T("{0} tokens"), used);
|
||||
|
||||
if (this.conversationTokens.UncountedImages is 0)
|
||||
return budget;
|
||||
|
||||
//
|
||||
// The pictures of the whole conversation, not of the message being written: every one
|
||||
// of them is sent again with every further message, so a chat runs past the model's
|
||||
// limit long after anybody last thought about images.
|
||||
//
|
||||
var images = this.conversationTokens.TooManyImages
|
||||
? string.Format(this.T("plus {0} image(s), which is more than the {1} this model accepts"), this.conversationTokens.UncountedImages, this.conversationTokens.ImageLimits.MaxInOneMessage)
|
||||
: string.Format(this.T("plus {0} image(s), which cannot be counted"), this.conversationTokens.UncountedImages);
|
||||
|
||||
return $"{budget} {images}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes over the culture of the language the user chose for AI Studio.
|
||||
/// </summary>
|
||||
private async Task RefreshCulture()
|
||||
{
|
||||
var activeLanguagePlugin = await this.SettingsManager.GetActiveLanguagePlugin();
|
||||
this.currentCulture = CommonTools.DeriveActiveCultureOrInvariant(activeLanguagePlugin.IETFTag);
|
||||
}
|
||||
|
||||
private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner.ForChat(this.ChatThread?.ChatId ?? this.draftMediaOwnerId);
|
||||
|
||||
@ -125,6 +229,15 @@ public partial class ChatComponent : MSGComponentBase
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
|
||||
await this.RefreshCulture();
|
||||
|
||||
//
|
||||
// The number under the input field follows from the conversation, and nothing in a
|
||||
// conversation announces that it changed: blocks, attachments and the answer being written
|
||||
// are plain objects somebody mutates. So it is recomputed rather than notified.
|
||||
//
|
||||
this.tokenTracker = new(this.RecountTokensAsync, this.TokenCountQuietTime, TOKEN_COUNT_HEARTBEAT);
|
||||
this.tokenTracker.Start();
|
||||
|
||||
// Apply the filters for the message bus:
|
||||
this.ApplyFilters([], [ Event.HAS_CHAT_UNSAVED_CHANGES, Event.RESET_CHAT_STATE, Event.CHAT_STREAMING_DONE, Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED, Event.WORKSPACE_RENAMED, Event.CONFIGURATION_CHANGED ]);
|
||||
@ -374,28 +487,30 @@ public partial class ChatComponent : MSGComponentBase
|
||||
await this.inputField.FocusAsync();
|
||||
|
||||
this.previousInputForbidden = inputForbidden;
|
||||
|
||||
//
|
||||
// Everything which can move the token count also renders this component: the selections in
|
||||
// the toolbar, the attachments and the composer all travel through an event callback whose
|
||||
// receiver is this component, and the streamed answer arrives as a message which already
|
||||
// asks for a render. So this one line stands in for the fifteen call sites which used to be
|
||||
// spread over this file -- and which kept missing one.
|
||||
//
|
||||
this.tokenTracker?.Nudge();
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
var incomingChatId = this.ChatThread?.ChatId ?? Guid.Empty;
|
||||
var providerChanged = this.Provider != this.lastSeenProvider;
|
||||
if (incomingChatId != this.lastSeenChatId || this.Provider != this.lastSeenProvider)
|
||||
{
|
||||
this.lastSeenChatId = incomingChatId;
|
||||
this.lastSeenProvider = this.Provider;
|
||||
if (providerChanged)
|
||||
this.tokenCount = "0";
|
||||
|
||||
this.previousInputForbidden = true;
|
||||
}
|
||||
|
||||
await this.ApplyLoadedChatParameterAsync();
|
||||
await this.SyncForegroundChatAsync();
|
||||
if (providerChanged && this.HasCustomTokenizer)
|
||||
await this.CalculateTokenCount();
|
||||
|
||||
await this.ConsumeMediaOutcomeAsync();
|
||||
await base.OnParametersSetAsync();
|
||||
}
|
||||
@ -539,7 +654,45 @@ public partial class ChatComponent : MSGComponentBase
|
||||
|
||||
private string UserInputStyle => this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence ? this.Provider.UsedLLMProvider.GetConfidence(this.SettingsManager).SetColorStyle(this.SettingsManager) : string.Empty;
|
||||
|
||||
private string UserInputClass => this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence ? "confidence-border" : string.Empty;
|
||||
private string UserInputClass => $"{(this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence ? "confidence-border" : string.Empty)} {this.TokenBudgetClass}".Trim();
|
||||
|
||||
/// <summary>
|
||||
/// How much of the model's context window the conversation already takes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Zero whenever nobody wrote the window down. There is then nothing to be full of, and a share
|
||||
/// of an unknown total would be a number made up on the spot.
|
||||
/// </remarks>
|
||||
private double TokenBudgetFill => this.conversationTokens is { IsKnown: true, Window.IsKnown: true }
|
||||
? (double) this.conversationTokens.Tokens / this.conversationTokens.Window.DefaultTokens
|
||||
: 0d;
|
||||
|
||||
/// <summary>
|
||||
/// What the number under the input field is coloured with, if anything.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two steps rather than a gradient: below four fifths there is nothing to do about it, above
|
||||
/// it there is -- shorten the chat, start a new one, or pick a model which reads more -- and
|
||||
/// past the window the request will be refused or trimmed by the provider.
|
||||
///
|
||||
/// Images share the second step and have no first one. There is no "nearly too many pictures":
|
||||
/// either they fit or the request comes back as an error, and no number of them is worth a
|
||||
/// warning as long as it fits.
|
||||
/// </remarks>
|
||||
private string TokenBudgetClass
|
||||
{
|
||||
get
|
||||
{
|
||||
//
|
||||
// Too many pictures is the same kind of news as a full window: the request will be
|
||||
// refused, and for the same reason -- more was put in than the model takes.
|
||||
//
|
||||
if (this.conversationTokens.TooManyImages || this.TokenBudgetFill >= 1d)
|
||||
return "token-budget-exceeded";
|
||||
|
||||
return this.TokenBudgetFill >= WINDOW_NEARLY_FULL ? "token-budget-nearly-full" : string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyStandardDataSourceOptions()
|
||||
{
|
||||
@ -603,15 +756,20 @@ public partial class ChatComponent : MSGComponentBase
|
||||
private async Task ProfileWasChanged(Profile profile)
|
||||
{
|
||||
this.currentProfile = this.SettingsManager.GetProfileById(profile.Id);
|
||||
if(this.ChatThread is null)
|
||||
return;
|
||||
|
||||
this.ChatThread = this.ChatThread with
|
||||
//
|
||||
// A thread which already exists has to carry the choice. Before the first message there is
|
||||
// none, and the choice then travels in the thread a new chat is started with.
|
||||
//
|
||||
if (this.ChatThread is not null)
|
||||
{
|
||||
SelectedProfile = this.currentProfile.Id,
|
||||
};
|
||||
|
||||
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
|
||||
this.ChatThread = this.ChatThread with
|
||||
{
|
||||
SelectedProfile = this.currentProfile.Id,
|
||||
};
|
||||
|
||||
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ChatTemplateWasChanged(ChatTemplate chatTemplate)
|
||||
@ -623,10 +781,8 @@ public partial class ChatComponent : MSGComponentBase
|
||||
// Apply template's file attachments (replaces existing):
|
||||
this.ComposerState.ReplaceFileAttachments(this.currentChatTemplate.FileAttachments);
|
||||
|
||||
if(this.ChatThread is null)
|
||||
return;
|
||||
|
||||
await this.StartNewChat(true);
|
||||
if (this.ChatThread is not null)
|
||||
await this.StartNewChat(true);
|
||||
}
|
||||
|
||||
private void RefreshCurrentProfileAndChatTemplate()
|
||||
@ -726,10 +882,7 @@ public partial class ChatComponent : MSGComponentBase
|
||||
|
||||
// Was a modifier key pressed as well?
|
||||
var isModifier = keyEvent.AltKey || keyEvent.CtrlKey || keyEvent.MetaKey || keyEvent.ShiftKey;
|
||||
|
||||
if (isEnter)
|
||||
await this.CalculateTokenCount();
|
||||
|
||||
|
||||
// Depending on the user's settings, might react to shortcuts:
|
||||
switch (this.SettingsManager.ConfigurationData.Chat.ShortcutSendBehavior)
|
||||
{
|
||||
@ -774,21 +927,13 @@ public partial class ChatComponent : MSGComponentBase
|
||||
|
||||
this.RefreshCurrentProfileAndChatTemplate();
|
||||
var promptName = this.ExtractThreadName(this.ComposerState.UserInput);
|
||||
this.ChatThread = new()
|
||||
var threadName = string.IsNullOrWhiteSpace(this.ComposerState.UserInput)
|
||||
? $"Transkription: {Path.GetFileName(firstMediaPath)}"
|
||||
: promptName;
|
||||
|
||||
this.ChatThread = this.NewChatThread(threadName) with
|
||||
{
|
||||
IncludeDateTime = true,
|
||||
SelectedProvider = this.Provider.Id,
|
||||
SelectedProfile = this.currentProfile.Id,
|
||||
SelectedChatTemplate = this.currentChatTemplate.Id,
|
||||
SelectedToolIds = [..this.selectedToolIds],
|
||||
SystemPrompt = SystemPrompts.DEFAULT,
|
||||
WorkspaceId = this.currentWorkspaceId,
|
||||
ChatId = Guid.NewGuid(),
|
||||
DataSourceOptions = this.earlyDataSourceOptions,
|
||||
Name = string.IsNullOrWhiteSpace(this.ComposerState.UserInput)
|
||||
? $"Transkription: {Path.GetFileName(firstMediaPath)}"
|
||||
: promptName,
|
||||
Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(block => block.DeepClone()).ToList(),
|
||||
};
|
||||
|
||||
await WorkspaceBehaviour.StoreChatAsync(this.ChatThread);
|
||||
@ -819,21 +964,11 @@ public partial class ChatComponent : MSGComponentBase
|
||||
// Create a new chat thread if necessary:
|
||||
if (this.ChatThread is null)
|
||||
{
|
||||
this.ChatThread = new()
|
||||
this.ChatThread = this.NewChatThread(this.ExtractThreadName(this.ComposerState.UserInput)) with
|
||||
{
|
||||
IncludeDateTime = true,
|
||||
SelectedProvider = this.Provider.Id,
|
||||
SelectedProfile = this.currentProfile.Id,
|
||||
SelectedChatTemplate = this.currentChatTemplate.Id,
|
||||
SelectedToolIds = [..this.selectedToolIds],
|
||||
SystemPrompt = SystemPrompts.DEFAULT,
|
||||
WorkspaceId = this.currentWorkspaceId,
|
||||
ChatId = Guid.NewGuid(),
|
||||
DataSourceOptions = this.earlyDataSourceOptions,
|
||||
Name = this.ExtractThreadName(this.ComposerState.UserInput),
|
||||
Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(x => x.DeepClone()).ToList(),
|
||||
};
|
||||
|
||||
|
||||
this.MarkCurrentChatAsLoadedParameter();
|
||||
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
|
||||
}
|
||||
@ -890,7 +1025,16 @@ public partial class ChatComponent : MSGComponentBase
|
||||
}
|
||||
}
|
||||
else
|
||||
lastUserPrompt = this.ChatThread.Blocks.Last(x => x.Role is ChatRole.USER).Content;
|
||||
{
|
||||
//
|
||||
// Regenerating asks again with the prompt that led to this answer. A thread which never
|
||||
// carried one -- a chat template whose example conversation holds AI blocks only -- has
|
||||
// nothing to reuse here. That is no reason to fail: the thread itself is what the model
|
||||
// is given, and everything downstream already reads a missing prompt as "no data source
|
||||
// lookup, just answer again".
|
||||
//
|
||||
lastUserPrompt = this.ChatThread.Blocks.LastOrDefault(x => x.Role is ChatRole.USER)?.Content;
|
||||
}
|
||||
|
||||
//
|
||||
// Add the AI response to the thread:
|
||||
@ -916,8 +1060,7 @@ public partial class ChatComponent : MSGComponentBase
|
||||
this.ComposerState.Clear();
|
||||
|
||||
await this.inputField.BlurAsync();
|
||||
this.tokenCount = "0";
|
||||
|
||||
|
||||
// Enable the stream state for the chat component:
|
||||
this.hasUnsavedChanges = true;
|
||||
|
||||
@ -962,7 +1105,7 @@ public partial class ChatComponent : MSGComponentBase
|
||||
private void ApplyToolSelectionOfLoadedChat() =>
|
||||
this.selectedToolIds = ToolSelectionRules.NormalizeSelection(this.ChatThread?.SelectedToolIds ?? this.SettingsManager.GetDefaultToolIds(Tools.Components.CHAT));
|
||||
|
||||
private Task SelectedToolIdsChanged(HashSet<string> updatedToolIds)
|
||||
private void SelectedToolIdsChanged(HashSet<string> updatedToolIds)
|
||||
{
|
||||
this.selectedToolIds = ToolSelectionRules.NormalizeSelection(updatedToolIds);
|
||||
|
||||
@ -977,8 +1120,6 @@ public partial class ChatComponent : MSGComponentBase
|
||||
this.ChatThread.SelectedToolIds = [..this.selectedToolIds];
|
||||
this.hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task SaveThread()
|
||||
@ -1087,19 +1228,7 @@ public partial class ChatComponent : MSGComponentBase
|
||||
// reset the chat thread only. The workspace id and the workspace name remain
|
||||
// the same:
|
||||
//
|
||||
this.ChatThread = new()
|
||||
{
|
||||
IncludeDateTime = true,
|
||||
SelectedProvider = this.Provider.Id,
|
||||
SelectedProfile = this.currentProfile.Id,
|
||||
SelectedChatTemplate = this.currentChatTemplate.Id,
|
||||
SelectedToolIds = [..this.selectedToolIds],
|
||||
SystemPrompt = SystemPrompts.DEFAULT,
|
||||
WorkspaceId = this.currentWorkspaceId,
|
||||
ChatId = Guid.NewGuid(),
|
||||
Name = string.Empty,
|
||||
Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(x => x.DeepClone()).ToList(),
|
||||
};
|
||||
this.ChatThread = this.NewChatThread(string.Empty);
|
||||
}
|
||||
|
||||
this.ComposerState.ApplyTemplate(this.currentChatTemplate);
|
||||
@ -1112,7 +1241,7 @@ public partial class ChatComponent : MSGComponentBase
|
||||
this.MarkCurrentChatAsLoadedParameter();
|
||||
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
|
||||
}
|
||||
|
||||
|
||||
private async Task MoveChatToWorkspace()
|
||||
{
|
||||
if(this.ChatThread is null)
|
||||
@ -1215,7 +1344,7 @@ public partial class ChatComponent : MSGComponentBase
|
||||
this.ApplyStandardDataSourceOptions();
|
||||
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
|
||||
}
|
||||
|
||||
|
||||
private async Task SelectProviderWhenLoadingChat()
|
||||
{
|
||||
var chatProvider = this.ChatThread?.SelectedProvider;
|
||||
@ -1270,37 +1399,35 @@ public partial class ChatComponent : MSGComponentBase
|
||||
{
|
||||
if(this.ChatThread is null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
|
||||
if (block is not ContentText textBlock)
|
||||
return Task.CompletedTask;
|
||||
|
||||
|
||||
var lastBlock = this.ChatThread.Blocks.Last();
|
||||
var lastBlockContent = lastBlock.Content;
|
||||
if(lastBlockContent is null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
|
||||
this.RestoreComposerFromTextBlock(textBlock);
|
||||
this.ChatThread.Remove(block);
|
||||
this.ChatThread.Remove(lastBlockContent);
|
||||
this.hasUnsavedChanges = true;
|
||||
this.StateHasChanged();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
private Task EditLastBlock(IContent block)
|
||||
{
|
||||
if(this.ChatThread is null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
|
||||
if (block is not ContentText textBlock)
|
||||
return Task.CompletedTask;
|
||||
|
||||
|
||||
this.RestoreComposerFromTextBlock(textBlock);
|
||||
this.ChatThread.Remove(block);
|
||||
this.hasUnsavedChanges = true;
|
||||
this.StateHasChanged();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@ -1309,42 +1436,123 @@ public partial class ChatComponent : MSGComponentBase
|
||||
this.ComposerState.RestoreFromTextBlock(textBlock);
|
||||
}
|
||||
|
||||
private async Task CalculateTokenCount()
|
||||
/// <summary>
|
||||
/// Works out what the next request would take out of the model's context window.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The whole conversation, not only what is being typed. A number counting the draft alone
|
||||
/// answers a question nobody asks: what decides whether the next message fits is everything
|
||||
/// which travels with it, and in a chat of any age the draft is the smallest part of that.
|
||||
///
|
||||
/// This used to run only for providers with a tokenizer of their own, which is almost nobody,
|
||||
/// so almost nobody ever saw a number. The runtime falls back to the tokenizer shipped with AI
|
||||
/// Studio when a provider names none, so the count is available everywhere -- it is then an
|
||||
/// estimate, and it says so.
|
||||
///
|
||||
/// Read the text from the bound property rather than from the input field: the field is a
|
||||
/// component reference, which is only set once the component has rendered.
|
||||
///
|
||||
/// Called by the tracker, never directly. Whoever thinks something changed nudges it instead,
|
||||
/// and it decides when the work is worth doing.
|
||||
/// </remarks>
|
||||
/// <param name="token">Ends the count when the component goes away.</param>
|
||||
private async Task RecountTokensAsync(CancellationToken token)
|
||||
{
|
||||
if (!this.HasCustomTokenizer)
|
||||
{
|
||||
if (this.tokenCount != "0")
|
||||
{
|
||||
this.tokenCount = "0";
|
||||
this.StateHasChanged();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
var provider = AIStudio.Settings.Provider.NONE;
|
||||
var parts = ConversationParts.NOTHING;
|
||||
|
||||
//
|
||||
// Read the text from the bound property rather than from the input field: the field is a
|
||||
// component reference, which is only set once the component has rendered. Counting is also
|
||||
// triggered while parameters are set, which happens before that.
|
||||
// Collected on the render thread, counted off it. Counting may take an IPC call per text,
|
||||
// and while it runs, the background job which writes the answer appends to the very list
|
||||
// which is walked here.
|
||||
//
|
||||
var currentInput = this.UserInput;
|
||||
if (string.IsNullOrEmpty(currentInput))
|
||||
await this.InvokeAsync(() =>
|
||||
{
|
||||
this.tokenCount = "0";
|
||||
return;
|
||||
}
|
||||
//
|
||||
// Before the first message there is no thread yet, so what is measured is the one a new
|
||||
// chat would start with. A preselected profile or a chat template is already part of
|
||||
// that, and it may even bring an example conversation along -- reporting nothing for all
|
||||
// of it would tell a person their window is empty while their first message is not.
|
||||
//
|
||||
var thread = this.ChatThread ?? this.NewChatThread(string.Empty);
|
||||
var toolDefinitions = this.GetRunnableToolDefinitions();
|
||||
provider = this.Provider;
|
||||
parts = ConversationParts.Of(thread, this.BuildSystemPromptFor(thread, toolDefinitions), this.UserInput, this.ComposerState.FileAttachments, provider.SupportsImageInput(), toolDefinitions);
|
||||
});
|
||||
|
||||
var response = await this.RustService.GetTokenCount(this.Provider, currentInput);
|
||||
if (response is null)
|
||||
var counted = await this.ConversationTokenCounter.CountAsync(provider, parts, token);
|
||||
if (token.IsCancellationRequested)
|
||||
return;
|
||||
if (!response.Value.Success)
|
||||
|
||||
await this.InvokeAsync(() =>
|
||||
{
|
||||
this.Logger.LogWarning("Failed to calculate token count: reason='{Reason}'", response.Value.Message);
|
||||
return;
|
||||
}
|
||||
this.tokenCount = response.Value.TokenCount.ToString();
|
||||
this.StateHasChanged();
|
||||
if (counted == this.conversationTokens)
|
||||
return;
|
||||
|
||||
this.conversationTokens = counted;
|
||||
this.StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Works out the system prompt a thread would send.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not the prompt a person typed: a chat template may replace it, the retrieved data of a data
|
||||
/// source is appended to it, the selected profile adds a paragraph, and the policy of the
|
||||
/// selected tools adds another. Switching a profile while writing therefore moves the number,
|
||||
/// which is the whole reason this is asked rather than read off the thread.
|
||||
/// </remarks>
|
||||
/// <param name="thread">The thread to build the prompt for.</param>
|
||||
/// <param name="toolDefinitions">The tools whose policy the prompt states.</param>
|
||||
/// <returns>The system prompt as it would be sent.</returns>
|
||||
private string BuildSystemPromptFor(ChatThread thread, IReadOnlyList<ToolDefinition> toolDefinitions) => thread.BuildSystemPrompt(this.SettingsManager, toolDefinitions).Text;
|
||||
|
||||
/// <summary>
|
||||
/// The tools the next request would offer the model.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Filtered for the provider the same way they are before sending, so that a tool the provider
|
||||
/// is not trusted enough to receive does not count either.
|
||||
///
|
||||
/// Asked for once and used twice: their policy goes into the system prompt, and their schemas
|
||||
/// travel next to it in the request body. Both cost tokens, and both change the moment somebody
|
||||
/// switches a tool on.
|
||||
/// </remarks>
|
||||
/// <returns>The definitions of the selected tools.</returns>
|
||||
private IReadOnlyList<ToolDefinition> GetRunnableToolDefinitions() => this.ToolRegistry.FilterToolIdsForProvider(this.Provider, this.selectedToolIds)
|
||||
.Select(this.ToolRegistry.GetDefinition)
|
||||
.Where(definition => definition is not null)
|
||||
.Select(definition => definition!)
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// The thread a new chat starts with, as the selections made so far decide it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// In one place because three code paths used to write it out, and because the token count has
|
||||
/// to measure the same thing they build. A count against a thread assembled differently from
|
||||
/// the one which is then sent would be wrong in exactly the moment a person looks at it: before
|
||||
/// they send their first message.
|
||||
///
|
||||
/// The data source options are left out on purpose: two of the three callers set them and the
|
||||
/// third replaces them right afterwards, so this stays the part they agree on.
|
||||
/// </remarks>
|
||||
/// <param name="name">The name of the thread.</param>
|
||||
/// <returns>The new thread.</returns>
|
||||
private ChatThread NewChatThread(string name) => new()
|
||||
{
|
||||
IncludeDateTime = true,
|
||||
SelectedProvider = this.Provider.Id,
|
||||
SelectedProfile = this.currentProfile.Id,
|
||||
SelectedChatTemplate = this.currentChatTemplate.Id,
|
||||
SelectedToolIds = [..this.selectedToolIds],
|
||||
SystemPrompt = SystemPrompts.DEFAULT,
|
||||
WorkspaceId = this.currentWorkspaceId,
|
||||
ChatId = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(block => block.DeepClone()).ToList(),
|
||||
};
|
||||
|
||||
#region Overrides of MSGComponentBase
|
||||
|
||||
@ -1372,6 +1580,7 @@ public partial class ChatComponent : MSGComponentBase
|
||||
|
||||
case Event.CONFIGURATION_CHANGED:
|
||||
case Event.PLUGINS_RELOADED:
|
||||
await this.RefreshCulture();
|
||||
await this.RefreshChatSelectionsAfterConfigurationChange();
|
||||
this.StateHasChanged();
|
||||
break;
|
||||
@ -1419,6 +1628,10 @@ public partial class ChatComponent : MSGComponentBase
|
||||
protected override async ValueTask DisposeResourcesAsync()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
|
||||
|
||||
if (this.tokenTracker is not null)
|
||||
await this.tokenTracker.DisposeAsync();
|
||||
|
||||
if(this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
|
||||
{
|
||||
await this.SaveThread();
|
||||
|
||||
@ -7,11 +7,11 @@
|
||||
<MudTooltip Text="@T("Select the data you want to use here.")" Placement="Placement.Top">
|
||||
@if (this.PopoverTriggerMode is PopoverTriggerMode.ICON)
|
||||
{
|
||||
<MudIconButton Icon="@AppIcons.DATABASE" Class="@this.PopoverButtonClasses" OnClick="@(() => this.ToggleDataSourceSelection())"/>
|
||||
<MudIconButton Icon="@AppIcons.DATABASE" Class="@this.PopoverButtonClasses" OnClick="@this.ToggleDataSourceSelection"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton Variant="Variant.Filled" StartIcon="@AppIcons.DATABASE" Class="@this.PopoverButtonClasses" OnClick="@(() => this.ToggleDataSourceSelection())">
|
||||
<MudButton Variant="Variant.Filled" StartIcon="@AppIcons.DATABASE" Class="@this.PopoverButtonClasses" OnClick="@this.ToggleDataSourceSelection">
|
||||
@T("Select data")
|
||||
</MudButton>
|
||||
}
|
||||
@ -19,13 +19,13 @@
|
||||
|
||||
<MudPopover Open="@this.showDataSourceSelection" AnchorOrigin="Origin.TopLeft" TransformOrigin="Origin.BottomLeft" DropShadow="@true" Class="border-solid border-4 rounded-lg">
|
||||
<MudCard>
|
||||
<MudCardHeader>
|
||||
<MudCardHeader Class="pa-2 pb-0">
|
||||
<CardHeaderContent>
|
||||
<PreviewBeta/>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h5">
|
||||
@T("Data Source Selection")
|
||||
</MudText>
|
||||
<PreviewBeta ChipClass=""/>
|
||||
<MudSpacer/>
|
||||
<MudTooltip Text="@T("Manage your data sources")" Placement="Placement.Top">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Settings" OnClick="@this.OpenSettingsDialog"/>
|
||||
@ -33,7 +33,7 @@
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Style="min-width: 24em; max-height: 60vh; max-width: 45vw; overflow: auto;">
|
||||
<MudCardContent Class="pa-2" Style="min-width: 24em; max-height: 60vh; max-width: 45vw; overflow: auto;">
|
||||
@if (this.waitingForDataSources)
|
||||
{
|
||||
<MudSkeleton Width="30%" Height="42px;"/>
|
||||
@ -42,7 +42,7 @@
|
||||
}
|
||||
else if (this.SettingsManager.ConfigurationData.DataSources.Count == 0)
|
||||
{
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
|
||||
<MudJustifiedText Typo="Typo.body2" Class="mb-2">
|
||||
@T("You haven't configured any data sources. To grant the AI access to your data, you need to add such a source. However, if you wish to use data from your device, you first have to set up a so-called embedding. This embedding is necessary so the AI can effectively search your data, find and retrieve the correct information required for each task. In addition to local data, you can also incorporate your company's data. To do so, your company must provide the data through an ERI (External Retrieval Interface).")
|
||||
</MudJustifiedText>
|
||||
|
||||
@ -57,51 +57,51 @@
|
||||
}
|
||||
else if (this.showDataSourceSelection)
|
||||
{
|
||||
<MudTextSwitch Label="@T("Are data sources enabled?")" Value="@this.areDataSourcesEnabled" LabelOn="@T("Yes, I want to use data sources.")" LabelOff="@T("No, I don't want to use data sources.")" ValueChanged="@this.EnabledChanged"/>
|
||||
<MudTextSwitch Label="@T("Are data sources enabled?")" Value="@this.areDataSourcesEnabled" LabelOn="@T("Yes, I want to use data sources.")" LabelOff="@T("No, I don't want to use data sources.")" ValueChanged="@this.EnabledChanged" Dense="@true"/>
|
||||
@if (this.areDataSourcesEnabled)
|
||||
{
|
||||
<MudTextSwitch Label="@T("AI-based data source selection")" Value="@this.aiBasedSourceSelection" LabelOn="@T("Yes, let the AI decide which data sources are needed.")" LabelOff="@T("No, I manually decide which data source to use.")" ValueChanged="@this.AutoModeChanged"/>
|
||||
<MudTextSwitch Label="@T("AI-based data source selection")" Value="@this.aiBasedSourceSelection" LabelOn="@T("Yes, let the AI decide which data sources are needed.")" LabelOff="@T("No, I manually decide which data source to use.")" ValueChanged="@this.AutoModeChanged" Dense="@true"/>
|
||||
|
||||
@if (this.SettingsManager.ConfigurationData.AgentRetrievalContextValidation.EnableRetrievalContextValidation)
|
||||
{
|
||||
<MudTextSwitch Label="@T("AI-based data validation")" Value="@this.aiBasedValidation" LabelOn="@T("Yes, let the AI validate & filter the retrieved data.")" LabelOff="@T("No, use all data retrieved from the data sources.")" ValueChanged="@this.ValidationModeChanged"/>
|
||||
<MudTextSwitch Label="@T("AI-based data validation")" Value="@this.aiBasedValidation" LabelOn="@T("Yes, let the AI validate & filter the retrieved data.")" LabelOff="@T("No, use all data retrieved from the data sources.")" ValueChanged="@this.ValidationModeChanged" Dense="@true"/>
|
||||
}
|
||||
|
||||
@switch (this.aiBasedSourceSelection)
|
||||
{
|
||||
case true when this.availableDataSources.Count == 0:
|
||||
<MudText Typo="Typo.body1" Class="mb-3">
|
||||
<MudText Typo="Typo.body2" Class="mb-2">
|
||||
@T("Your data sources cannot be used with the selected providers due to data privacy or confidence-level requirements, or they are currently unavailable.")
|
||||
</MudText>
|
||||
break;
|
||||
|
||||
case true when this.DataSourcesAISelected.Count == 0:
|
||||
<MudText Typo="Typo.body1" Class="mb-3">
|
||||
<MudText Typo="Typo.body2" Class="mb-2">
|
||||
@T("The AI evaluates each of your inputs to determine whether and which data sources are necessary. Currently, the AI has not selected any source.")
|
||||
</MudText>
|
||||
break;
|
||||
|
||||
case false when this.availableDataSources.Count == 0:
|
||||
<MudText Typo="Typo.body1" Class="mb-3">
|
||||
<MudText Typo="Typo.body2" Class="mb-2">
|
||||
@T("Your data sources cannot be used with the selected providers due to data privacy or confidence-level requirements, or they are currently unavailable.")
|
||||
</MudText>
|
||||
break;
|
||||
|
||||
case false:
|
||||
<MudField Label="@T("Available Data Sources")" Variant="Variant.Outlined" Class="mb-3" Disabled="@this.aiBasedSourceSelection">
|
||||
<MudList T="IDataSource" SelectionMode="@this.GetListSelectionMode()" @bind-SelectedValues:get="@this.selectedDataSources" @bind-SelectedValues:set="@(x => this.SelectionChanged(x))" Style="max-height: 14em;">
|
||||
<MudField Label="@T("Available Data Sources")" Variant="Variant.Outlined" Class="mb-2" Disabled="@this.aiBasedSourceSelection">
|
||||
<MudList T="IDataSource" Dense="@true" Class="data-source-rows" SelectionMode="@this.GetListSelectionMode()" @bind-SelectedValues:get="@this.selectedDataSources" @bind-SelectedValues:set="@this.SelectionChanged" Style="max-height: 14em;">
|
||||
@foreach (var source in this.availableDataSources)
|
||||
{
|
||||
<MudListItem Value="@source">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Style="min-width: 0; width: 100%;">
|
||||
<MudText Typo="Typo.body1" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
|
||||
<MudText Typo="Typo.body2" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
|
||||
@source.Name
|
||||
</MudText>
|
||||
@if (source is IInternalDataSource internalSource)
|
||||
{
|
||||
<MudSpacer/>
|
||||
<MudTooltip Text="@internalSource.ConfidenceLevel.GetName()">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Security" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
|
||||
<MudIcon Icon="@Icons.Material.Filled.Security" Size="Size.Small" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudStack>
|
||||
@ -113,20 +113,20 @@
|
||||
|
||||
case true:
|
||||
<MudExpansionPanels MultiExpansion="@false" Class="mt-3" Style="max-height: 14em;">
|
||||
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.TouchApp" HeaderText="@T("Available Data Sources")">
|
||||
<MudList T="IDataSource" SelectionMode="MudBlazor.SelectionMode.SingleSelection" SelectedValues="@this.selectedDataSources" Style="max-height: 14em;">
|
||||
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.TouchApp" IconSize="Size.Small" HeaderTypo="Typo.subtitle1" HeaderClass="expansion-panel-header-compact" HeaderText="@T("Available Data Sources")">
|
||||
<MudList T="IDataSource" Dense="@true" Class="data-source-rows" SelectionMode="MudBlazor.SelectionMode.SingleSelection" SelectedValues="@this.selectedDataSources" Style="max-height: 14em;">
|
||||
@foreach (var source in this.availableDataSources)
|
||||
{
|
||||
<MudListItem Value="@source">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Style="min-width: 0; width: 100%;">
|
||||
<MudText Typo="Typo.body1" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
|
||||
<MudText Typo="Typo.body2" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
|
||||
@source.Name
|
||||
</MudText>
|
||||
@if (source is IInternalDataSource internalSource)
|
||||
{
|
||||
<MudSpacer/>
|
||||
<MudTooltip Text="@internalSource.ConfidenceLevel.GetName()">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Security" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
|
||||
<MudIcon Icon="@Icons.Material.Filled.Security" Size="Size.Small" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudStack>
|
||||
@ -134,21 +134,21 @@
|
||||
}
|
||||
</MudList>
|
||||
</ExpansionPanel>
|
||||
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.Filter" HeaderText="@T("AI-Selected Data Sources")">
|
||||
<MudList T="DataSourceAgentSelected" SelectionMode="MudBlazor.SelectionMode.MultiSelection" ReadOnly="@true" SelectedValues="@this.GetSelectedDataSourcesWithAI()" Style="max-height: 14em;">
|
||||
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.Filter" IconSize="Size.Small" HeaderTypo="Typo.subtitle1" HeaderClass="expansion-panel-header-compact" HeaderText="@T("AI-Selected Data Sources")">
|
||||
<MudList T="DataSourceAgentSelected" Dense="@true" Class="data-source-rows" SelectionMode="MudBlazor.SelectionMode.MultiSelection" ReadOnly="@true" SelectedValues="@this.GetSelectedDataSourcesWithAI()" Style="max-height: 14em;">
|
||||
@foreach (var source in this.DataSourcesAISelected)
|
||||
{
|
||||
<MudListItem Value="@source">
|
||||
<ChildContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Style="min-width: 0; width: 100%;">
|
||||
<MudText Typo="Typo.body1" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
|
||||
<MudText Typo="Typo.body2" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
|
||||
@source.DataSource.Name
|
||||
</MudText>
|
||||
@if (source.DataSource is IInternalDataSource internalSource)
|
||||
{
|
||||
<MudSpacer/>
|
||||
<MudTooltip Text="@internalSource.ConfidenceLevel.GetName()">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Security" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
|
||||
<MudIcon Icon="@Icons.Material.Filled.Security" Size="Size.Small" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudStack>
|
||||
@ -165,11 +165,24 @@
|
||||
</MudExpansionPanels>
|
||||
break;
|
||||
}
|
||||
|
||||
@if (!this.aiBasedSourceSelection && this.GetUnavailablePreselectedDataSources().Count > 0)
|
||||
{
|
||||
<MudJustifiedText Typo="Typo.body2" Color="Color.Warning">
|
||||
@T("These data sources are preselected, but cannot be used right now, either due to data privacy or confidence-level requirements, or because they are unavailable:")
|
||||
</MudJustifiedText>
|
||||
<ul class="unavailable-data-sources mb-3 mt-1">
|
||||
@foreach (var source in this.GetUnavailablePreselectedDataSources())
|
||||
{
|
||||
<li>@source.Name</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
}
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton Variant="Variant.Filled" OnClick="@(() => this.HideDataSourceSelection())">
|
||||
<MudButton Variant="Variant.Filled" OnClick="@this.HideDataSourceSelection">
|
||||
@T("Close")
|
||||
</MudButton>
|
||||
</MudCardActions>
|
||||
@ -187,7 +200,7 @@ else if (this.SelectionMode is DataSourceSelectionMode.CONFIGURATION_MODE)
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(this.ConfigurationHeaderMessage))
|
||||
{
|
||||
<MudText Typo="Typo.body1">
|
||||
<MudText Typo="Typo.body2">
|
||||
@this.ConfigurationHeaderMessage
|
||||
</MudText>
|
||||
}
|
||||
@ -198,19 +211,19 @@ else if (this.SelectionMode is DataSourceSelectionMode.CONFIGURATION_MODE)
|
||||
<MudTextSwitch Label="@T("AI-based data source selection")" Value="@this.aiBasedSourceSelection" LabelOn="@T("Yes, let the AI decide which data sources are needed.")" LabelOff="@T("No, I manually decide which data source to use.")" ValueChanged="@this.AutoModeChanged" Disabled="@this.IsPreselectedDataSourcesAutomaticSelectionLocked()"/>
|
||||
<MudTextSwitch Label="@T("AI-based data validation")" Value="@this.aiBasedValidation" LabelOn="@T("Yes, let the AI validate & filter the retrieved data.")" LabelOff="@T("No, use all data retrieved from the data sources.")" ValueChanged="@this.ValidationModeChanged" Disabled="@this.IsPreselectedDataSourcesAutomaticValidationLocked()"/>
|
||||
<MudField Label="@T("Available Data Sources")" Variant="Variant.Outlined" Class="mb-3" Disabled="@(this.aiBasedSourceSelection || this.IsPreselectedDataSourceIdsLocked())">
|
||||
<MudList T="IDataSource" SelectionMode="@this.GetListSelectionMode()" @bind-SelectedValues:get="@this.selectedDataSources" @bind-SelectedValues:set="@(x => this.SelectionChanged(x))" ReadOnly="@this.IsPreselectedDataSourceIdsLocked()">
|
||||
<MudList T="IDataSource" Dense="@true" Class="data-source-rows" SelectionMode="@this.GetListSelectionMode()" @bind-SelectedValues:get="@this.selectedDataSources" @bind-SelectedValues:set="@(x => this.SelectionChanged(x))" ReadOnly="@this.IsPreselectedDataSourceIdsLocked()">
|
||||
@foreach (var source in this.availableDataSources)
|
||||
{
|
||||
<MudListItem Value="@source">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Style="min-width: 0; width: 100%;">
|
||||
<MudText Typo="Typo.body1" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
|
||||
<MudText Typo="Typo.body2" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
|
||||
@source.Name
|
||||
</MudText>
|
||||
@if (source is IInternalDataSource internalSource)
|
||||
{
|
||||
<MudSpacer/>
|
||||
<MudTooltip Text="@internalSource.ConfidenceLevel.GetName()">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Security" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
|
||||
<MudIcon Icon="@Icons.Material.Filled.Security" Size="Size.Small" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudStack>
|
||||
@ -220,4 +233,4 @@ else if (this.SelectionMode is DataSourceSelectionMode.CONFIGURATION_MODE)
|
||||
</MudField>
|
||||
}
|
||||
</MudPaper>
|
||||
}
|
||||
}
|
||||
@ -181,6 +181,22 @@ public partial class DataSourceSelection : MSGComponentBase
|
||||
var preselectedDataSourceIds = this.DataSourceOptions.PreselectedDataSourceIds.ToHashSet(StringComparer.Ordinal);
|
||||
return this.GetConfiguredDataSourcesSnapshot().Where(ds => preselectedDataSourceIds.Contains(ds.Id)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects the preselected data sources which the filters removed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The list of available sources shows what survived the filters, while the preselection keeps
|
||||
/// what the user asked for. Without this, a preselected source which cannot be used right now
|
||||
/// is simply missing from that list, and nothing says so. Preselected ids without a configured
|
||||
/// source are left out: that source is gone, not unavailable.
|
||||
/// </remarks>
|
||||
/// <returns>The unusable preselected data sources, or an empty list when there are none.</returns>
|
||||
private IReadOnlyList<IDataSource> GetUnavailablePreselectedDataSources()
|
||||
{
|
||||
var availableDataSourceIds = this.availableDataSources.Select(ds => ds.Id).ToHashSet(StringComparer.Ordinal);
|
||||
return this.GetDataSourcesFromConfiguredIds().Where(ds => !availableDataSourceIds.Contains(ds.Id)).ToList();
|
||||
}
|
||||
|
||||
private async Task LoadAndApplyFilters()
|
||||
{
|
||||
@ -200,8 +216,12 @@ public partial class DataSourceSelection : MSGComponentBase
|
||||
this.waitingForDataSources = true;
|
||||
this.StateHasChanged();
|
||||
|
||||
// Load the data sources:
|
||||
var sources = await this.DataSourceService.GetDataSources(this.LLMProvider, this.DataSourceOptions, this.selectedDataSources);
|
||||
//
|
||||
// Load the data sources. We ask with the preselection rather than with the field below:
|
||||
// that field holds what was usable the last time we looked, so a source filtered out once
|
||||
// would never come back, while the RAG process keeps reading it from the preselection.
|
||||
//
|
||||
var sources = await this.DataSourceService.GetDataSources(this.LLMProvider, this.DataSourceOptions, this.GetDataSourcesFromConfiguredIds());
|
||||
if (generation != this.loadAndApplyFiltersGeneration)
|
||||
return;
|
||||
|
||||
@ -242,7 +262,16 @@ public partial class DataSourceSelection : MSGComponentBase
|
||||
private async Task SelectionChanged(IReadOnlyCollection<IDataSource>? chosenDataSources)
|
||||
{
|
||||
this.selectedDataSources = chosenDataSources ?? [];
|
||||
this.DataSourceOptions.PreselectedDataSourceIds = this.selectedDataSources.Select(ds => ds.Id).ToList();
|
||||
|
||||
//
|
||||
// The list offers only the data sources which survived the filters, so what the user picks
|
||||
// there says nothing about the preselected ones it could not show. Those are kept: dropping
|
||||
// them would undo a choice the user never revisited, and it is these ids -- not this list --
|
||||
// which the RAG process reads when an answer is created. The query has to run before the
|
||||
// assignment, because it reads what we are about to replace.
|
||||
//
|
||||
var keptDataSourceIds = this.GetUnavailablePreselectedDataSources().Select(ds => ds.Id).ToList();
|
||||
this.DataSourceOptions.PreselectedDataSourceIds = [..keptDataSourceIds, ..this.selectedDataSources.Select(ds => ds.Id)];
|
||||
|
||||
await this.OptionsChanged();
|
||||
}
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
/*
|
||||
* A plain list renders without markers and without indentation here: something in the global
|
||||
* styles takes both off. This is an enumeration of names and wants to read as one, so it states
|
||||
* marker, indentation and spacing itself. MudBlazor's Markdown styles fight the same fight for
|
||||
* their own lists, and need an !important on the display to win it -- hence the one below.
|
||||
*/
|
||||
.unavailable-data-sources {
|
||||
max-height: 10em;
|
||||
overflow-y: auto;
|
||||
overflow-wrap: anywhere;
|
||||
margin-top: 0;
|
||||
padding-left: 1.5em;
|
||||
list-style: disc outside;
|
||||
}
|
||||
|
||||
.unavailable-data-sources li {
|
||||
display: list-item !important;
|
||||
}
|
||||
@ -10,7 +10,15 @@
|
||||
</MudSelect>
|
||||
}
|
||||
|
||||
<MudTextField T="string" Text="@this.WorkspaceName" TextChanged="@this.SetWorkspaceName" Validation="@this.ValidateWorkspaceName" AdornmentIcon="@Icons.Material.Filled.CreateNewFolder" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Workspace name")" HelperText="@T("Choose an existing workspace or enter a name that should be created when the launcher is opened.")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
|
||||
<MudTextField T="string" Text="@this.WorkspaceName" TextChanged="@this.SetWorkspaceName" AdornmentIcon="@(this.OpensTemporaryChat ? Icons.Material.Filled.Timer : Icons.Material.Filled.CreateNewFolder)" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Workspace name (Optional)")" HelperText="@T("Choose an existing workspace or enter a name that should be created when the launcher is opened.")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-1" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
|
||||
|
||||
@* The tile behaves differently with and without a workspace, so the form says which of the two is
|
||||
chosen right now instead of only explaining that both are possible. *@
|
||||
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-3">
|
||||
@(this.OpensTemporaryChat
|
||||
? T("Without a workspace, the tile opens a disappearing chat: it belongs to no workspace and is deleted according to your workspace maintenance settings.")
|
||||
: T("The tile opens its chat in this workspace and creates the workspace when it does not exist yet."))
|
||||
</MudText>
|
||||
<MudSelect T="string" Value="@this.ProviderId" ValueChanged="@this.SetProviderId" Label="@T("Chat provider")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.SmartToy">
|
||||
<MudSelectItem T="string" Value="@string.Empty">@T("Use chat default")</MudSelectItem>
|
||||
@foreach (var provider in this.SettingsManager.GetConfidentProviders(Components.CHAT))
|
||||
|
||||
@ -3,8 +3,9 @@ using Microsoft.AspNetCore.Components;
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// The selection a direct chat launcher needs: the workspace its chat is created in, and the
|
||||
/// provider, profile, chat template, and data sources that chat starts with.
|
||||
/// The selection a direct chat launcher needs: the workspace its chat is created in — or no
|
||||
/// workspace, for a disappearing chat — and the provider, profile, chat template, and data sources
|
||||
/// that chat starts with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Assistant Builder uses this form to describe a launcher it is about to generate, while the
|
||||
@ -15,7 +16,8 @@ public partial class DirectChatLauncherForm : MSGComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The name of the workspace the launcher opens its chat in. The workspace is created when it
|
||||
/// does not exist yet, hence this is a free-text field and not a workspace ID.
|
||||
/// does not exist yet, hence this is a free-text field and not a workspace ID. An empty name is
|
||||
/// a choice of its own: the launcher then opens a disappearing chat.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string WorkspaceName { get; set; } = string.Empty;
|
||||
@ -75,11 +77,9 @@ public partial class DirectChatLauncherForm : MSGComponentBase
|
||||
public EventCallback<HashSet<string>> ToolIdsChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates the workspace name. The hosts differ here: the Builder requires a name only while
|
||||
/// its launcher switch is on, whereas the settings dialog always requires one.
|
||||
/// Whether the launcher currently describes a chat without a workspace.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public Func<string, string?>? ValidateWorkspaceName { get; set; }
|
||||
private bool OpensTemporaryChat => string.IsNullOrWhiteSpace(this.WorkspaceName);
|
||||
|
||||
private IReadOnlyList<WorkspaceTreeWorkspace> availableWorkspaces = [];
|
||||
|
||||
|
||||
@ -61,16 +61,20 @@ public partial class MudCopyClipboardButton : ComponentBase
|
||||
/// <summary>
|
||||
/// Copy this block's content to the clipboard.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The user copies what the card shows, and the card shows the answer together with the sources
|
||||
/// AI Studio collected for it. Pasting the answer into a mail without them would leave the
|
||||
/// reader with claims nobody is able to check.
|
||||
/// </remarks>
|
||||
private async Task CopyToClipboard(IContent? contentToCopy)
|
||||
{
|
||||
if (contentToCopy is null)
|
||||
return;
|
||||
|
||||
|
||||
switch (this.Type)
|
||||
{
|
||||
case ContentType.TEXT:
|
||||
var textContent = (ContentText) contentToCopy;
|
||||
await this.RustService.CopyText2Clipboard(textContent.Text);
|
||||
case ContentType.TEXT when contentToCopy.TryGetExportMarkdown(out var markdown):
|
||||
await this.RustService.CopyText2Clipboard(markdown);
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<MudField Label="@this.Label" Variant="Variant.Outlined" Class="mb-3" Disabled="@this.Disabled">
|
||||
<MudSwitch T="bool" Value="@this.Value" ValueChanged="@this.ValueChanged" Color="@this.Color" Validation="@this.Validation" Disabled="@this.Disabled">
|
||||
<MudField Label="@this.Label" Variant="Variant.Outlined" Class="@this.FieldClasses" Disabled="@this.Disabled">
|
||||
<MudSwitch T="bool" Size="@this.SwitchSize" Value="@this.Value" ValueChanged="@this.ValueChanged" Color="@this.Color" Validation="@this.Validation" Disabled="@this.Disabled">
|
||||
@(this.Value ? this.LabelOn : this.LabelOff)
|
||||
</MudSwitch>
|
||||
</MudField>
|
||||
@ -27,4 +27,19 @@ public partial class MudTextSwitch : ComponentBase
|
||||
|
||||
[Parameter]
|
||||
public string LabelOff { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to render this switch in its compact form.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For places which stack several of these switches above other content, such as the data source
|
||||
/// selection the chat opens from its footer. The roomy form stays the default, so that nothing
|
||||
/// changes where this was never asked for.
|
||||
/// </remarks>
|
||||
[Parameter]
|
||||
public bool Dense { get; set; }
|
||||
|
||||
private string FieldClasses => this.Dense ? "mb-2 text-switch-dense" : "mb-3";
|
||||
|
||||
private Size SwitchSize => this.Dense ? Size.Small : Size.Medium;
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
@inherits MSGComponentBase
|
||||
<MudTooltip Placement="Placement.Bottom" Arrow="@true" Class="@this.Classes">
|
||||
<ChildContent>
|
||||
<MudChip T="string" Icon="@Icons.Material.Filled.HourglassTop" Color="Color.Info" Class="mb-3">
|
||||
<MudChip T="string" Icon="@Icons.Material.Filled.HourglassTop" Color="Color.Info" Class="@this.ChipClass">
|
||||
@T("Beta")
|
||||
</MudChip>
|
||||
</ChildContent>
|
||||
|
||||
@ -7,5 +7,16 @@ public partial class PreviewBeta : MSGComponentBase
|
||||
[Parameter]
|
||||
public bool ApplyInnerScrollingFix { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional class names for the chip itself, separated by space.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default is the margin every caller relied on before this parameter existed, because the
|
||||
/// chip usually sits on a line of its own above a heading. A header which puts it beside the
|
||||
/// heading instead passes an empty value.
|
||||
/// </remarks>
|
||||
[Parameter]
|
||||
public string ChipClass { get; set; } = "mb-3";
|
||||
|
||||
private string Classes => this.ApplyInnerScrollingFix ? "InnerScrollingFix" : string.Empty;
|
||||
}
|
||||
@ -55,16 +55,16 @@ public partial class ProviderSelection : MSGComponentBase
|
||||
|
||||
private IReadOnlyList<CapabilityIcon> GetCapabilityIcons(AIStudio.Settings.Provider provider)
|
||||
{
|
||||
var capabilities = provider.GetModelCapabilities();
|
||||
var profile = provider.GetModelProfile();
|
||||
List<CapabilityIcon> capabilityIcons = [];
|
||||
|
||||
if (capabilities.Contains(Capability.AUDIO_INPUT))
|
||||
if (profile.Has(Capability.AUDIO_INPUT))
|
||||
capabilityIcons.Add(new(Icons.Material.Filled.GraphicEq, this.T("Audio input possible")));
|
||||
|
||||
if (capabilities.Contains(Capability.SINGLE_IMAGE_INPUT) || capabilities.Contains(Capability.MULTIPLE_IMAGE_INPUT))
|
||||
if (profile.HasAny(Capability.SINGLE_IMAGE_INPUT | Capability.MULTIPLE_IMAGE_INPUT))
|
||||
capabilityIcons.Add(new(Icons.Material.Filled.Image, this.T("Image input possible")));
|
||||
|
||||
if (capabilities.Contains(Capability.SPEECH_INPUT))
|
||||
if (profile.Has(Capability.SPEECH_INPUT))
|
||||
capabilityIcons.Add(new(Icons.Material.Filled.Mic, this.T("Speech input possible")));
|
||||
|
||||
var reasoningIndicatorState = provider.GetReasoningIndicatorState();
|
||||
|
||||
@ -5,8 +5,8 @@
|
||||
{
|
||||
<MudTextSwitch Label="@T("Cleanup content by using an LLM agent?")" Value="@this.PreselectContentCleanerAgent" ValueChanged="@this.UseContentCleanerAgentChanged" Validation="@this.ValidateProvider" Disabled="@this.AgentIsRunning" LabelOn="@T("The content is cleaned using an LLM agent: the main content is extracted, advertisements and other irrelevant things are attempted to be removed; relative links are attempted to be converted into absolute links so that they can be used.")" LabelOff="@T("No content cleaning")" />
|
||||
<MudStack Row="@true" AlignItems="@AlignItems.Baseline" Class="mb-3">
|
||||
<MudTextField T="string" Label="@T("URL from which to load the content")" @bind-Value="@this.providedURL" Validation="@this.ValidateURL" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Link" Placeholder="https://..." HelperText="@T("Loads the content from your URL. Does not work when the content is hidden behind a paywall.")" Variant="Variant.Outlined" Immediate="@true" Disabled="@this.AgentIsRunning"/>
|
||||
<MudButton Disabled="@(!this.IsReady || this.AgentIsRunning)" Variant="Variant.Filled" Size="Size.Large" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Download" OnClick="() => this.LoadFromWeb()">
|
||||
<MudTextField T="string" Label="@T("URL from which to load the content")" Value="@this.URL" ValueChanged="@this.URLValueChanged" Validation="@this.ValidateURL" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Link" Placeholder="https://..." HelperText="@T("Loads the content from your URL. Does not work when the content is hidden behind a paywall.")" Variant="Variant.Outlined" Immediate="@true" Disabled="@this.AgentIsRunning"/>
|
||||
<MudButton Disabled="@(!this.IsReady || this.AgentIsRunning)" Variant="Variant.Filled" Size="Size.Large" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Download" OnClick="@this.LoadFromWeb">
|
||||
@T("Fetch")
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
@ -35,7 +35,20 @@ public partial class ReadWebContent : MSGComponentBase
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<string> ContentChanged { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The URL the content is loaded from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The URL belongs to the parent, so that it is cleared when the parent resets its form and
|
||||
/// is kept when the parent stores its state.
|
||||
/// </remarks>
|
||||
[Parameter]
|
||||
public string URL { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<string> URLChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public AIStudio.Settings.Provider ProviderSettings { get; set; } = AIStudio.Settings.Provider.NONE;
|
||||
|
||||
@ -60,8 +73,6 @@ public partial class ReadWebContent : MSGComponentBase
|
||||
private readonly Process<ReadWebContentSteps> process = Process<ReadWebContentSteps>.INSTANCE;
|
||||
private ProcessStepValue processStep;
|
||||
|
||||
private string providedURL = string.Empty;
|
||||
private bool urlIsValid;
|
||||
private bool isProviderValid;
|
||||
|
||||
private AIStudio.Settings.Provider providerSettings = AIStudio.Settings.Provider.NONE;
|
||||
@ -105,7 +116,7 @@ public partial class ReadWebContent : MSGComponentBase
|
||||
// the URL, so their own network is not off limits.
|
||||
//
|
||||
var retrievedPage = await this.WebPageRetrievalService.RetrieveAsync(
|
||||
new Uri(this.providedURL),
|
||||
new Uri(this.URL),
|
||||
new WebPageRetrievalOptions
|
||||
{
|
||||
TimeoutSeconds = TIMEOUT_SECONDS,
|
||||
@ -115,14 +126,14 @@ public partial class ReadWebContent : MSGComponentBase
|
||||
this.processStep = this.process[ReadWebContentSteps.PARSING];
|
||||
this.StateHasChanged();
|
||||
markdown = retrievedPage.ExtractedPage.Markdown;
|
||||
markdown = await this.PromptInjectionGuardService.SanitizeAsync(markdown, PromptInjectionSource.WebContent(this.providedURL));
|
||||
markdown = await this.PromptInjectionGuardService.SanitizeAsync(markdown, PromptInjectionSource.WebContent(this.URL));
|
||||
|
||||
if (this.PreselectContentCleanerAgent && this.providerSettings != AIStudio.Settings.Provider.NONE)
|
||||
{
|
||||
this.AgentTextContentCleaner.ProviderSettings = this.providerSettings;
|
||||
var additionalData = new Dictionary<string, string>
|
||||
{
|
||||
{ "sourceURL", this.providedURL },
|
||||
{ "sourceURL", this.URL },
|
||||
};
|
||||
|
||||
this.processStep = this.process[ReadWebContentSteps.CLEANING];
|
||||
@ -164,8 +175,8 @@ public partial class ReadWebContent : MSGComponentBase
|
||||
// and the reasons a page cannot be read are things the user can act on: a link to a
|
||||
// PDF rather than a page, a host that does not answer, a server refusing the request.
|
||||
//
|
||||
this.Logger.LogWarning(exception, "Could not load the web content from '{ProvidedUrl}'.", this.providedURL);
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.CloudOff, string.Format(this.T("The content of '{0}' could not be loaded: {1}"), this.providedURL, exception.Message)));
|
||||
this.Logger.LogWarning(exception, "Could not load the web content from '{ProvidedUrl}'.", this.URL);
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.CloudOff, string.Format(this.T("The content of '{0}' could not be loaded: {1}"), this.URL, exception.Message)));
|
||||
}
|
||||
|
||||
this.Content = markdown;
|
||||
@ -176,16 +187,31 @@ public partial class ReadWebContent : MSGComponentBase
|
||||
{
|
||||
get
|
||||
{
|
||||
if(!this.urlIsValid)
|
||||
if(!this.UrlIsValid)
|
||||
return false;
|
||||
|
||||
|
||||
if(this.PreselectContentCleanerAgent && !this.isProviderValid)
|
||||
return false;
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the current URL can be loaded.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Asked of the current value instead of remembered from the last validation run: the parent
|
||||
/// clears the URL when it resets its form, and the form validation does not run again at that
|
||||
/// point. The fetch button would otherwise stay enabled with an empty field.
|
||||
/// </remarks>
|
||||
private bool UrlIsValid => this.ValidateURL(this.URL) is null;
|
||||
|
||||
private async Task URLValueChanged(string url)
|
||||
{
|
||||
await this.URLChanged.InvokeAsync(url);
|
||||
}
|
||||
|
||||
private async Task ShowWebContentReaderChanged(bool state)
|
||||
{
|
||||
await this.PreselectChanged.InvokeAsync(state);
|
||||
@ -211,25 +237,15 @@ public partial class ReadWebContent : MSGComponentBase
|
||||
private string? ValidateURL(string url)
|
||||
{
|
||||
if(string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
this.urlIsValid = false;
|
||||
return T("Please provide a URL to load the content from.");
|
||||
}
|
||||
|
||||
var urlParsingResult = Uri.TryCreate(url, UriKind.Absolute, out var uriResult);
|
||||
if(!urlParsingResult)
|
||||
{
|
||||
this.urlIsValid = false;
|
||||
return T("Please provide a valid URL.");
|
||||
}
|
||||
|
||||
if(uriResult is not { Scheme: "http" or "https" })
|
||||
{
|
||||
this.urlIsValid = false;
|
||||
return T("Please provide a valid HTTP or HTTPS URL.");
|
||||
}
|
||||
|
||||
this.urlIsValid = true;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -93,8 +93,6 @@
|
||||
</MudText>
|
||||
}
|
||||
|
||||
<MudButton Variant="Variant.Filled" Color="@Color.Primary" StartIcon="@Icons.Material.Filled.AddRoad" Class="mt-3 mb-6" OnClick="@this.AddEmbeddingProvider">
|
||||
@T("Add Embedding")
|
||||
</MudButton>
|
||||
<LockableButton Text="@T("Add Embedding")" IsLocked="@(() => !this.SettingsManager.ConfigurationData.App.AllowUserToAddProvider || !this.SettingsManager.ConfigurationData.App.AllowUserToAddEmbeddingProvider)" Icon="@Icons.Material.Filled.AddRoad" OnClickAsync="@this.AddEmbeddingProvider" Class="mt-3" />
|
||||
</ExpansionPanel>
|
||||
}
|
||||
|
||||
@ -78,5 +78,5 @@
|
||||
</MudText>
|
||||
}
|
||||
|
||||
<LockableButton Text="@T("Add Provider")" IsLocked="@(() => !this.SettingsManager.ConfigurationData.App.AllowUserToAddProvider)" Icon="@Icons.Material.Filled.AddRoad" OnClickAsync="@this.AddLLMProvider" Class="mt-3" />
|
||||
<LockableButton Text="@T("Add Provider")" IsLocked="@(() => !this.SettingsManager.ConfigurationData.App.AllowUserToAddProvider || !this.SettingsManager.ConfigurationData.App.AllowUserToAddLLMProvider)" Icon="@Icons.Material.Filled.AddRoad" OnClickAsync="@this.AddLLMProvider" Class="mt-3" />
|
||||
</ExpansionPanel>
|
||||
|
||||
@ -83,8 +83,6 @@
|
||||
</MudText>
|
||||
}
|
||||
|
||||
<MudButton Variant="Variant.Filled" Color="@Color.Primary" StartIcon="@Icons.Material.Filled.AddRoad" Class="mt-3 mb-6" OnClick="@this.AddTranscriptionProvider">
|
||||
@T("Add transcription provider")
|
||||
</MudButton>
|
||||
<LockableButton Text="@T("Add transcription provider")" IsLocked="@(() => !this.SettingsManager.ConfigurationData.App.AllowUserToAddProvider || !this.SettingsManager.ConfigurationData.App.AllowUserToAddTranscriptionProvider)" Icon="@Icons.Material.Filled.AddRoad" OnClickAsync="@this.AddTranscriptionProvider" Class="mt-3" />
|
||||
</ExpansionPanel>
|
||||
}
|
||||
|
||||
39
app/MindWork AI Studio/Components/SourcesList.razor
Normal file
39
app/MindWork AI Studio/Components/SourcesList.razor
Normal file
@ -0,0 +1,39 @@
|
||||
@inherits MSGComponentBase
|
||||
|
||||
@* The class is what the Markdown renderer wraps its own output in, so the headings and the list
|
||||
keep the look they had while this list was Markdown. *@
|
||||
<div @ref="this.listElement" class="mud-markdown-body">
|
||||
@foreach (var group in this.groups)
|
||||
{
|
||||
@* A level-two heading was shown as h5 while this list was Markdown, because that is what
|
||||
Markdown.DefaultConfig overrides it to. The heading keeps that size here. *@
|
||||
<MudText Typo="Typo.h5">
|
||||
@group.Heading
|
||||
</MudText>
|
||||
<ul>
|
||||
@foreach (var entry in group.Entries)
|
||||
{
|
||||
<li>
|
||||
@($"[{entry.Number}] ")
|
||||
@if (entry.Document is { } document)
|
||||
{
|
||||
<MudTooltip Text="@T("Opens this document in the program your system uses for it")" Placement="Placement.Top">
|
||||
<MudLink Typo="Typo.body1" OnClick="@(() => this.OpenDocument(document))">
|
||||
@entry.Title
|
||||
</MudLink>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="@T("Show this file in the file manager of your system")" Placement="Placement.Top">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.FolderOpen" Size="Size.Small" OnClick="@(() => this.ShowInFileManager(document))"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudLink Href="@entry.Link" Target="_blank" Typo="Typo.body1">
|
||||
@entry.Title
|
||||
</MudLink>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
164
app/MindWork AI Studio/Components/SourcesList.razor.cs
Normal file
164
app/MindWork AI Studio/Components/SourcesList.razor.cs
Normal file
@ -0,0 +1,164 @@
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Shows the sources an answer rests on, grouped and numbered the way the export is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This list used to be Markdown, which read correctly but could not be clicked where it mattered:
|
||||
/// a Markdown renderer hands every link to the browser, and the browser refuses a file address on a
|
||||
/// page it loaded over http. A source of the user's own documents therefore did nothing at all.
|
||||
/// Written out as components, an entry can hand its document to the runtime instead, together with
|
||||
/// the page the passage was found on.
|
||||
/// </remarks>
|
||||
public partial class SourcesList : MSGComponentBase
|
||||
{
|
||||
//
|
||||
// The name is about the alignment the function uses, not about the page: it brings the element
|
||||
// into view with its end at the bottom, which for a list at the end of an answer shows all of it.
|
||||
//
|
||||
private const string SCROLL_INTO_VIEW_FUNCTION = "scrollToBottom";
|
||||
|
||||
/// <summary>
|
||||
/// The sources to show.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public IList<Source> Sources { get; set; } = [];
|
||||
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private IJSRuntime JsRuntime { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<SourcesList> Logger { get; init; } = null!;
|
||||
|
||||
private readonly List<SourceEntryGroup> groups = [];
|
||||
|
||||
private ElementReference listElement;
|
||||
|
||||
/// <summary>
|
||||
/// Brings this list into view.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The counter above an answer says how many sources it rests on; this is how it takes the
|
||||
/// reader to them. The element stays here, where it is rendered, rather than being handed to
|
||||
/// whoever wants to scroll to it.
|
||||
/// </remarks>
|
||||
public async Task ScrollIntoViewAsync() => await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, SCROLL_INTO_VIEW_FUNCTION, this.listElement);
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
this.RebuildGroups();
|
||||
await base.OnParametersSetAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Reads the sources once per render instead of once per entry and render.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Where a source points is answered by looking at its link, and while an answer streams, this
|
||||
/// runs again for every chunk. The previous Markdown list was rebuilt and parsed just as often,
|
||||
/// so this is the cheaper of the two, but it is still worth doing once for the whole list.
|
||||
/// </remarks>
|
||||
private void RebuildGroups()
|
||||
{
|
||||
this.groups.Clear();
|
||||
foreach (var group in this.Sources.GroupSources())
|
||||
{
|
||||
var entries = new List<SourceEntry>(group.Sources.Count);
|
||||
foreach (var numberedSource in group.Sources)
|
||||
{
|
||||
var document = numberedSource.Source.TryGetDocumentLocation(out var location) ? location : (SourceDocumentLocation?)null;
|
||||
entries.Add(new(numberedSource.Number, numberedSource.Source.Title, numberedSource.Source.URL, document));
|
||||
}
|
||||
|
||||
this.groups.Add(new(group.Heading, entries));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a document in the program the system uses for it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Whether the program can be sent to a page is the runtime's business, and it says afterwards
|
||||
/// whether it managed to. Nothing is shown about that here: the document is open, and the title
|
||||
/// of the source names the page anyway.
|
||||
/// </remarks>
|
||||
/// <param name="document">The document to open, and the page to show.</param>
|
||||
private async Task OpenDocument(SourceDocumentLocation document)
|
||||
{
|
||||
OpenDocumentResponse response;
|
||||
try
|
||||
{
|
||||
response = await this.RustService.TryOpenDocumentInSystemViewer(document.Path, document.PageNumber);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.Logger.LogWarning(e, "Could not open a source document.");
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.Description, T("Could not open the document.")));
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.Success)
|
||||
return;
|
||||
|
||||
var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue;
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.Description, string.Format(T("Could not open the document: {0}"), issue)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the file browser of the system and selects the document in it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The second way out of the list: a document which the system opens in the wrong program, or
|
||||
/// which the user wants to move or send on instead of read, is reached from here without being
|
||||
/// opened. This is the same way out the embeddings page offers for a file it could not read.
|
||||
/// </remarks>
|
||||
/// <param name="document">The document to show.</param>
|
||||
private async Task ShowInFileManager(SourceDocumentLocation document)
|
||||
{
|
||||
OpenPathResponse response;
|
||||
try
|
||||
{
|
||||
response = await this.RustService.TryOpenPathInRuntimeFileManager(document.Path);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.Logger.LogWarning(e, "Could not show a source document in the file manager.");
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.FolderOpen, T("Could not open the file location.")));
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.Success)
|
||||
return;
|
||||
|
||||
var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue;
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.FolderOpen, string.Format(T("Could not open the file location: {0}"), issue)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One group of the list, prepared so that the markup only has to show it.
|
||||
/// </summary>
|
||||
/// <param name="Heading">The heading above the group.</param>
|
||||
/// <param name="Entries">The entries of the group, in the order they are shown.</param>
|
||||
private readonly record struct SourceEntryGroup(string Heading, IReadOnlyList<SourceEntry> Entries);
|
||||
|
||||
/// <summary>
|
||||
/// One entry of the list, prepared so that the markup only has to show it.
|
||||
/// </summary>
|
||||
/// <param name="Number">The number the source is listed under.</param>
|
||||
/// <param name="Title">The title of the source.</param>
|
||||
/// <param name="Link">The address of the source, which a web source is opened by.</param>
|
||||
/// <param name="Document">The document the source names, or null when it names none.</param>
|
||||
private readonly record struct SourceEntry(int Number, string Title, string Link, SourceDocumentLocation? Document);
|
||||
}
|
||||
6
app/MindWork AI Studio/Components/TokenizerHint.razor
Normal file
6
app/MindWork AI Studio/Components/TokenizerHint.razor
Normal file
@ -0,0 +1,6 @@
|
||||
@if (!string.IsNullOrWhiteSpace(this.Text))
|
||||
{
|
||||
<MudJustifiedText Typo="Typo.body2" Class="@this.Class">
|
||||
@this.Text
|
||||
</MudJustifiedText>
|
||||
}
|
||||
70
app/MindWork AI Studio/Components/TokenizerHint.razor.cs
Normal file
70
app/MindWork AI Studio/Components/TokenizerHint.razor.cs
Normal file
@ -0,0 +1,70 @@
|
||||
using AIStudio.Models;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Says which tokenizer a model uses, next to the field which asks for one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The field takes a tokenizer.json file and nothing else, and for a long time it said nothing about
|
||||
/// which file. That leaves two kinds of people stuck: the ones who could download the right one and
|
||||
/// do not know its name, and the ones who go looking for Anthropic's tokenizer file, which was never
|
||||
/// published.
|
||||
///
|
||||
/// One component rather than a sentence in each dialog, because both the LLM provider dialog and the
|
||||
/// embedding provider dialog ask the same question and deserve the same answer. Two copies would be
|
||||
/// two sets of translations of the same three sentences, and the second copy is the one which gets
|
||||
/// forgotten when the wording changes.
|
||||
/// </remarks>
|
||||
public partial class TokenizerHint : ComponentBase
|
||||
{
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(TokenizerHint).Namespace, nameof(TokenizerHint));
|
||||
|
||||
/// <summary>
|
||||
/// Which provider the model is served by.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public LLMProviders LLMProvider { get; set; } = LLMProviders.NONE;
|
||||
|
||||
/// <summary>
|
||||
/// The model whose tokenizer is in question.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public Model Model { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The classes of the text, so a dialog can keep its own spacing.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Class { get; set; } = "mb-3";
|
||||
|
||||
/// <summary>
|
||||
/// What there is to say, or nothing at all.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Empty for a model nobody named a tokenizer for, which is most of them. Saying "unknown"
|
||||
/// would fill the dialog with a line which helps nobody; saying nothing leaves it as it was.
|
||||
/// </remarks>
|
||||
private string Text
|
||||
{
|
||||
get
|
||||
{
|
||||
var tokenizer = this.LLMProvider.GetModelProfile(this.Model).Tokenizer;
|
||||
return tokenizer.IsKnown ? Describe(tokenizer) : string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Describe(TokenizerRef tokenizer) => tokenizer.Kind switch
|
||||
{
|
||||
TokenizerKind.HUGGING_FACE => string.Format(TB("This model uses the tokenizer of {0}. Download its tokenizer.json file and select it below to count exactly instead of estimating."), tokenizer.Id),
|
||||
TokenizerKind.TIKTOKEN => string.Format(TB("This model uses OpenAI's {0} encoding, which does not come as a tokenizer.json file. AI Studio therefore estimates the token count with its built-in tokenizer."), tokenizer.Id),
|
||||
TokenizerKind.PROVIDER_API => string.Format(TB("The vendor of this model publishes no tokenizer file and counts through their API instead ({0}). AI Studio therefore estimates the token count with its built-in tokenizer."), tokenizer.Id),
|
||||
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
<MudPopover Open="@this.showSelection" AnchorOrigin="Origin.TopLeft" TransformOrigin="Origin.BottomLeft" DropShadow="@true" Class="border-solid border-4 rounded-lg">
|
||||
<MudCard>
|
||||
<MudCardHeader>
|
||||
<MudCardHeader Class="pa-2 pb-0">
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h5">@T("Tool Selection")</MudText>
|
||||
@ -15,8 +15,8 @@
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Style="min-width: 28em; max-height: 60vh; max-width: 48vw; overflow: auto;">
|
||||
<MudText Typo="Typo.body1" Class="mb-3">
|
||||
<MudCardContent Class="pa-2" Style="min-width: 22em; max-height: 60vh; max-width: 34vw; overflow: auto;">
|
||||
<MudText Typo="Typo.body2" Class="mb-2">
|
||||
@T("Tools allow the LLM to perform targeted additional actions such as web searches or reading web pages.")
|
||||
</MudText>
|
||||
@if (!this.SupportsTools)
|
||||
@ -25,7 +25,7 @@
|
||||
}
|
||||
else if (this.Disabled)
|
||||
{
|
||||
<MudAlert Dense="@true" Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-3">
|
||||
<MudAlert Dense="@true" Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-2">
|
||||
@T("Tool changes are locked while a response is running. Your current selection is shown below and applies again from the next message once the run is finished.")
|
||||
</MudAlert>
|
||||
}
|
||||
@ -36,56 +36,66 @@
|
||||
|
||||
@if (this.SupportsTools && this.catalog.Count > 0)
|
||||
{
|
||||
@foreach (var item in this.catalog)
|
||||
{
|
||||
var isSelected = this.SelectedToolIds.Contains(item.Definition.Id);
|
||||
var isConfigured = item.ConfigurationState.IsConfigured;
|
||||
var providerConfidenceHint = this.GetProviderConfidenceHint(item);
|
||||
<MudPaper Class="pa-2 mb-2 border rounded-lg">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
@*
|
||||
Everything but the settings button switches the tool, so aiming for the
|
||||
small switch is optional. The button spans that part of the row, which
|
||||
keeps the settings button outside of it without any event plumbing.
|
||||
*@
|
||||
<MudButton Variant="Variant.Text" Color="Color.Default" Class="px-2 py-1 justify-start"
|
||||
Style="min-width:auto; text-transform:none; flex-grow:1;"
|
||||
Disabled="@this.IsRowDisabled(item)" OnClick="@(async () => await this.ToggleToolFromRow(item))">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
@*
|
||||
The switch only shows the state; the surrounding button does the switching.
|
||||
It therefore takes no pointer events at all: its label reaches past the visible
|
||||
switch and would otherwise swallow the clicks landing in that strip.
|
||||
*@
|
||||
<MudSwitch T="bool" Color="Color.Primary" Value="@isSelected" ReadOnly="@true" Disabled="@this.IsRowDisabled(item)" Style="pointer-events: none;" />
|
||||
<MudIcon Icon="@item.Implementation.Icon" Color="Color.Info" />
|
||||
@if (!item.IsActive)
|
||||
{
|
||||
<MudTooltip Text="@T("This tool has been disabled by your organization.")">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Lock" Color="Color.Error" Size="Size.Small" />
|
||||
@*
|
||||
The striping sits on this wrapper: the rows share their parent with the
|
||||
introduction and the occasional alert, which would shift the parity.
|
||||
*@
|
||||
<div class="tool-selection-rows">
|
||||
@foreach (var item in this.catalog)
|
||||
{
|
||||
var isSelected = this.SelectedToolIds.Contains(item.Definition.Id);
|
||||
var isConfigured = item.ConfigurationState.IsConfigured;
|
||||
var providerConfidenceHint = this.GetProviderConfidenceHint(item);
|
||||
<div class="tool-selection-row">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
|
||||
@*
|
||||
Everything but the settings button switches the tool, so aiming for the
|
||||
small switch is optional. The button spans that part of the row, which
|
||||
keeps the settings button outside of it without any event plumbing.
|
||||
*@
|
||||
<MudButton Variant="Variant.Text" Color="Color.Default" Class="px-1 py-0 justify-start"
|
||||
Style="min-width:auto; text-transform:none; flex-grow:1;"
|
||||
Disabled="@this.IsRowDisabled(item)" OnClick="@(async () => await this.ToggleToolFromRow(item))">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
@*
|
||||
A checkbox rather than a switch, because this row is one entry of a set the
|
||||
user picks from, not a setting of its own -- the same question the data source
|
||||
selection next to it asks, and it should not look like a different one.
|
||||
|
||||
The checkbox only shows the state; the surrounding button does the switching.
|
||||
It therefore takes no pointer events at all: its label reaches past the visible
|
||||
box and would otherwise swallow the clicks landing in that strip.
|
||||
*@
|
||||
<MudCheckBox T="bool" Size="Size.Small" Dense="@true" Color="Color.Primary" Value="@isSelected" ReadOnly="@true" Disabled="@this.IsRowDisabled(item)" Style="pointer-events: none;" />
|
||||
<MudIcon Icon="@item.Implementation.Icon" Color="Color.Info" />
|
||||
@if (!item.IsActive)
|
||||
{
|
||||
<MudTooltip Text="@T("This tool has been disabled by your organization.")">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Lock" Color="Color.Error" Size="Size.Small" />
|
||||
</MudTooltip>
|
||||
}
|
||||
<MudTooltip Text="@item.Implementation.GetDescription()">
|
||||
<MudText Typo="Typo.body1">@item.Implementation.GetDisplayName()</MudText>
|
||||
</MudTooltip>
|
||||
}
|
||||
<MudTooltip Text="@item.Implementation.GetDescription()">
|
||||
<MudText Typo="Typo.body1">@item.Implementation.GetDisplayName()</MudText>
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
</MudButton>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Settings" OnClick="@(async () => await this.OpenSettings(item.Definition.Id))" />
|
||||
</MudStack>
|
||||
@if (!isConfigured)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Warning">@(string.IsNullOrWhiteSpace(item.ConfigurationState.Message) ? T("Required settings are missing. Configure this tool before enabling it.") : item.ConfigurationState.Message)</MudText>
|
||||
}
|
||||
@if (!item.IsActive)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Warning">@T("This tool has been disabled by your organization.")</MudText>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(providerConfidenceHint))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Warning">@providerConfidenceHint</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
}
|
||||
</MudStack>
|
||||
</MudButton>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Settings" Size="Size.Small" OnClick="@(async () => await this.OpenSettings(item.Definition.Id))" />
|
||||
</MudStack>
|
||||
@if (!isConfigured)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Warning" Class="ml-2">@(string.IsNullOrWhiteSpace(item.ConfigurationState.Message) ? T("Required settings are missing. Configure this tool before enabling it.") : item.ConfigurationState.Message)</MudText>
|
||||
}
|
||||
@if (!item.IsActive)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Warning" Class="ml-2">@T("This tool has been disabled by your organization.")</MudText>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(providerConfidenceHint))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Warning" Class="ml-2">@providerConfidenceHint</MudText>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
|
||||
@ -1,22 +1,32 @@
|
||||
@namespace AIStudio.Components
|
||||
@inherits MSGComponentBase
|
||||
|
||||
@*
|
||||
The toolbar belongs to this component, not to the places which use it: MudToolBar keeps its
|
||||
minimum height even when empty. Left outside, it reserved space in the navigation bar while
|
||||
this component rendered nothing at all, which made the neighboring items float above the
|
||||
bottom edge.
|
||||
*@
|
||||
@if (this.ShouldRenderVoiceRecording)
|
||||
{
|
||||
<MudTooltip Text="@this.Tooltip">
|
||||
@if (this.isTranscribing || this.isPreparing)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Color="Color.Primary"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudToggleIconButton Toggled="@this.isRecording"
|
||||
ToggledChanged="@this.OnRecordingToggled"
|
||||
Icon="@Icons.Material.Filled.Mic"
|
||||
ToggledIcon="@Icons.Material.Filled.Stop"
|
||||
Disabled="@(!this.IsVoiceRecordingAvailable)"
|
||||
Color="Color.Primary"
|
||||
ToggledColor="Color.Error"/>
|
||||
}
|
||||
</MudTooltip>
|
||||
<MudStack AlignItems="AlignItems.Center">
|
||||
<MudToolBar WrapContent="true">
|
||||
<MudTooltip Text="@this.Tooltip">
|
||||
@if (this.isTranscribing || this.isPreparing)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Color="Color.Primary"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudToggleIconButton Toggled="@this.isRecording"
|
||||
ToggledChanged="@this.OnRecordingToggled"
|
||||
Icon="@Icons.Material.Filled.Mic"
|
||||
ToggledIcon="@Icons.Material.Filled.Stop"
|
||||
Disabled="@(!this.IsVoiceRecordingAvailable)"
|
||||
Color="Color.Primary"
|
||||
ToggledColor="Color.Error"/>
|
||||
}
|
||||
</MudTooltip>
|
||||
</MudToolBar>
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@ -32,8 +32,7 @@
|
||||
@bind-ProfileId="@this.profileId"
|
||||
@bind-ChatTemplateId="@this.chatTemplateId"
|
||||
@bind-DataSourceIds="@this.dataSourceIds"
|
||||
@bind-ToolIds="@this.toolIds"
|
||||
ValidateWorkspaceName="@this.ValidateWorkspaceName"/>
|
||||
@bind-ToolIds="@this.toolIds"/>
|
||||
</MudPaper>
|
||||
</MudForm>
|
||||
|
||||
|
||||
@ -152,7 +152,9 @@ public partial class DirectChatLauncherSettingsDialog : MSGComponentBase
|
||||
|
||||
//
|
||||
// An empty selection means "use the chat defaults" and is left out of the plugin, whereas
|
||||
// the empty GUID explicitly selects no profile or no chat template:
|
||||
// the empty GUID explicitly selects no profile or no chat template. Clearing the workspace
|
||||
// name is a change of its own: the tile then opens a disappearing chat, and the writer
|
||||
// switches the launch behavior accordingly.
|
||||
//
|
||||
return new(
|
||||
this.workspaceName.Trim(),
|
||||
@ -248,8 +250,6 @@ public partial class DirectChatLauncherSettingsDialog : MSGComponentBase
|
||||
|
||||
private string? ValidateDescription(string value) => string.IsNullOrWhiteSpace(value) ? T("Please provide a description for this tile.") : null;
|
||||
|
||||
private string? ValidateWorkspaceName(string value) => string.IsNullOrWhiteSpace(value) ? T("Please select or enter a workspace name for this tile.") : null;
|
||||
|
||||
private void Cancel() => this.MudDialog.Cancel();
|
||||
|
||||
private static Guid? ParseOptionalGuid(string value) => Guid.TryParse(value, out var parsed) ? parsed : null;
|
||||
|
||||
@ -3,20 +3,34 @@
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
@* A drop anywhere in this dialog belongs to the dialog, not to the page behind it: *@
|
||||
<PathDropZone IsArea="@true">
|
||||
<PathDropZone IsArea="@true"
|
||||
IdPrefix="document-check"
|
||||
OnPathsDropped="@this.DropCallback"
|
||||
Disabled="@this.IsZoneDisabled"
|
||||
Context="isDropTarget">
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
|
||||
@T("See how we load your file. Review the content before we process it further.")
|
||||
</MudJustifiedText>
|
||||
|
||||
@if (this.Document is null)
|
||||
|
||||
@if (this.CanAttach)
|
||||
{
|
||||
<ReadFileContent Text="@T("Load file")" FileContent="@this.FileContent" FileContentChanged="@this.ApplyLoadedFileContent" EnableDragDrop="true" CatchAllDocuments="true"/>
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
|
||||
@T("You can drag another file into this window. We attach it right away and show it here.")
|
||||
</MudJustifiedText>
|
||||
}
|
||||
|
||||
@if (this.document is null)
|
||||
{
|
||||
<ReadFileContent Text="@T("Load file")" FileContent="@this.fileContent" FileContentChanged="@this.ApplyLoadedFileContent" EnableDragDrop="true" CatchAllDocuments="true"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Keys have to be unique among siblings, no matter the component: this field and the
|
||||
tabs below both stand for the document and would otherwise collide on its path. *@
|
||||
<MudTextField
|
||||
@key="@($"file-path-{this.document.FilePath}")"
|
||||
T="string"
|
||||
Text="@this.Document.FilePath"
|
||||
Text="@this.document.FilePath"
|
||||
AdornmentIcon="@Icons.Material.Filled.FileOpen"
|
||||
Adornment="Adornment.Start"
|
||||
Immediate="@true"
|
||||
@ -29,7 +43,10 @@
|
||||
/>
|
||||
}
|
||||
|
||||
@if (!this.Document?.Exists ?? false)
|
||||
@* The frame shows where a dropped file would land. It is drawn only while this dialog can
|
||||
take one, and keeps its width in both states so that nothing jumps during a drag: *@
|
||||
<div class="@this.PreviewAreaClass(isDropTarget)">
|
||||
@if (!this.document?.Exists ?? false)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Variant="Variant.Filled" Class="my-2">
|
||||
@T("The specified file could not be found. The file have been moved, deleted, renamed, or is otherwise inaccessible.")
|
||||
@ -60,11 +77,13 @@
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" Outlined="true" PanelClass="pa-2" Class="mb-2">
|
||||
@if (this.Document?.IsImage ?? false)
|
||||
@* Keyed by the document: a switch from an image to a text file changes which panels
|
||||
exist, and a leftover active panel would point at one that is gone. *@
|
||||
<MudTabs @key="@($"preview-tabs-{this.document?.FilePath}")" Elevation="0" Rounded="true" ApplyEffectsToContainer="true" Outlined="true" PanelClass="pa-2" Class="mb-2">
|
||||
@if (this.document?.IsImage ?? false)
|
||||
{
|
||||
<MudTabPanel Text="@T("Image View")" Icon="@Icons.Material.Filled.Image">
|
||||
<MudImage ObjectFit="ObjectFit.ScaleDown" Style="width: 100%;" Src="@this.Document.FilePathAsUrl"/>
|
||||
<MudImage ObjectFit="ObjectFit.ScaleDown" Style="width: 100%;" Src="@this.document.FilePathAsUrl"/>
|
||||
</MudTabPanel>
|
||||
}
|
||||
else
|
||||
@ -102,6 +121,7 @@
|
||||
}
|
||||
</MudTabs>
|
||||
}
|
||||
</div>
|
||||
</PathDropZone>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
|
||||
@ -21,6 +21,46 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
||||
[Parameter]
|
||||
public string FileContent { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Attaches the files the user drops onto this dialog, and answers which of them it attached.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null when our caller has no list of attachments to add to, which is the case for the prompt
|
||||
/// guide preview of the Prompt Optimizer. This dialog then shows its document and nothing else,
|
||||
/// exactly as it always did.
|
||||
/// </remarks>
|
||||
[Parameter]
|
||||
public Func<List<string>, Task<IReadOnlyList<FileAttachment>>>? AttachPaths { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Decides, at the moment a drop arrives, whether attaching is possible right now.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Asked rather than passed as a value, because the answer changes while this dialog is open:
|
||||
/// dropping a media file starts a transcription, and nothing else may be attached until that
|
||||
/// one is through.
|
||||
/// </remarks>
|
||||
[Parameter]
|
||||
public Func<bool>? IsAttachingUnavailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The document we show right now. It starts out as the one we were opened with and changes
|
||||
/// whenever the user drops another file onto this dialog.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Kept in a field rather than read from the parameter: the dialog fragment is rendered again
|
||||
/// with the parameters captured when it was opened, whenever something about the dialog stack
|
||||
/// changes. That happens in the middle of a drop, because attaching may open the Pandoc dialog
|
||||
/// or ask the user about a media file -- reading the parameter would undo the switch right
|
||||
/// after it was made.
|
||||
/// </remarks>
|
||||
private FileAttachment? document;
|
||||
|
||||
/// <summary>
|
||||
/// The content of the document we show, either handed to us by our caller or read by us.
|
||||
/// </summary>
|
||||
private string fileContent = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// How many characters we show at most. Rendering a huge document costs us a large Markdown
|
||||
/// syntax tree and an equally large render tree. This dialog answers the question of how we
|
||||
@ -46,9 +86,20 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
||||
private int previewCutOffCharacters;
|
||||
|
||||
/// <summary>
|
||||
/// Ends the extraction when this dialog is gone before the file was read completely.
|
||||
/// Ends the extraction when this dialog is gone, or when another document took the place of
|
||||
/// the one being read, before that file was read completely.
|
||||
/// </summary>
|
||||
private readonly CancellationTokenSource extractionCancellation = new();
|
||||
private CancellationTokenSource extractionCancellation = new();
|
||||
|
||||
/// <summary>
|
||||
/// Numbers the loads, so that a load can tell whether it still owns this dialog.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Cancelling ends the waiting, not the code behind it: what follows every await of an
|
||||
/// abandoned load runs regardless. Without this number, its final block would clear the loading
|
||||
/// state of the load which replaced it, and the new document would never leave its skeletons.
|
||||
/// </remarks>
|
||||
private int loadGeneration;
|
||||
|
||||
/// <summary>
|
||||
/// True once this dialog was disposed. The extraction runs across awaits, so it may return
|
||||
@ -73,75 +124,194 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
//
|
||||
// Decide before the first render whether we have to read the file at all. Images are shown
|
||||
// as they are, a missing file shows its own message, and content a caller already handed
|
||||
// us is reused instead of being extracted a second time:
|
||||
//
|
||||
this.isLoadingContent =
|
||||
this.Document is not null &&
|
||||
!this.Document.IsImage &&
|
||||
this.Document.Exists &&
|
||||
string.IsNullOrWhiteSpace(this.FileContent);
|
||||
this.document = this.Document;
|
||||
this.fileContent = this.FileContent;
|
||||
|
||||
this.isLoadingContent = this.NeedsExtraction();
|
||||
this.UpdatePreview();
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && this.Document is not null)
|
||||
if (!firstRender)
|
||||
return;
|
||||
|
||||
if (this.document is null)
|
||||
{
|
||||
if (!this.isLoadingContent)
|
||||
this.Logger.LogWarning("Document check dialog opened without a valid file path.");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.LoadDocumentContentAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the document we show has to be read before we can show anything of it. Images are
|
||||
/// shown as they are, a missing file shows its own message, and content a caller already handed
|
||||
/// us is reused instead of being extracted a second time.
|
||||
/// </summary>
|
||||
private bool NeedsExtraction() =>
|
||||
this.document is not null &&
|
||||
!this.document.IsImage &&
|
||||
this.document.Exists &&
|
||||
string.IsNullOrWhiteSpace(this.fileContent);
|
||||
|
||||
/// <summary>
|
||||
/// Reads the content of the document we show and puts it into the preview.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Runs after a render, so the user sees that we are working instead of an empty document. It
|
||||
/// is called for the document this dialog was opened with, and again for every file the user
|
||||
/// drops onto it.
|
||||
/// </remarks>
|
||||
private async Task LoadDocumentContentAsync()
|
||||
{
|
||||
if (this.document is null || !this.isLoadingContent)
|
||||
return;
|
||||
|
||||
//
|
||||
// A drop may arrive while we are still reading the file before it. We number this load and
|
||||
// end the previous one, so that what is left of it recognizes that this dialog has moved on:
|
||||
//
|
||||
var generation = ++this.loadGeneration;
|
||||
var documentToLoad = this.document;
|
||||
|
||||
var previousCancellation = this.extractionCancellation;
|
||||
this.extractionCancellation = new();
|
||||
var cancellationToken = this.extractionCancellation.Token;
|
||||
|
||||
await previousCancellation.CancelAsync();
|
||||
previousCancellation.Dispose();
|
||||
|
||||
if (this.isDisposed || generation != this.loadGeneration)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var extraction = await UserFile.LoadFileData(documentToLoad.FilePath, this.RustService, this.PandocAvailability, cancellationToken);
|
||||
if (this.isDisposed || generation != this.loadGeneration)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var extraction = await UserFile.LoadFileData(this.Document.FilePath, this.RustService, this.PandocAvailability, this.extractionCancellation.Token);
|
||||
if (this.isDisposed)
|
||||
return;
|
||||
this.fileContent = extraction.Content;
|
||||
|
||||
this.FileContent = extraction.Content;
|
||||
//
|
||||
// This dialog exists so the user can check what we hand to the AI. Showing an
|
||||
// empty document when reading the file failed would answer that question wrong.
|
||||
//
|
||||
if (!extraction.HasUsableContent)
|
||||
this.loadFailureMessage = extraction.ToUserMessage(documentToLoad.FileName);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Either the user closed this dialog, or another document took the place of this one
|
||||
// while we were reading it. Nothing left to do in both cases.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.Logger.LogError(ex, "Failed to load file content from '{FilePath}'", documentToLoad.FilePath);
|
||||
if (this.isDisposed || generation != this.loadGeneration)
|
||||
return;
|
||||
|
||||
//
|
||||
// This dialog exists so the user can check what we hand to the AI. Showing an
|
||||
// empty document when reading the file failed would answer that question wrong.
|
||||
//
|
||||
if (!extraction.HasUsableContent)
|
||||
this.loadFailureMessage = extraction.ToUserMessage(this.Document.FileName);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
this.fileContent = string.Empty;
|
||||
this.loadFailureMessage = FileExtractionErrorCode.INTERNAL.ToUserMessage(documentToLoad.FileName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!this.isDisposed && generation == this.loadGeneration)
|
||||
{
|
||||
// The user closed this dialog while we were reading the file. Nothing left to do.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.Logger.LogError(ex, "Failed to load file content from '{FilePath}'", this.Document);
|
||||
this.FileContent = string.Empty;
|
||||
this.loadFailureMessage = FileExtractionErrorCode.INTERNAL.ToUserMessage(this.Document.FileName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!this.isDisposed)
|
||||
{
|
||||
this.isLoadingContent = false;
|
||||
this.UpdatePreview();
|
||||
this.StateHasChanged();
|
||||
}
|
||||
this.isLoadingContent = false;
|
||||
this.UpdatePreview();
|
||||
this.StateHasChanged();
|
||||
}
|
||||
}
|
||||
else if (firstRender)
|
||||
this.Logger.LogWarning("Document check dialog opened without a valid file path.");
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Whether a dropped file can be both attached and shown here, which decides what this dialog
|
||||
/// says and shows -- and whether it takes drops at all.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Without a document, this dialog offers a file to be loaded instead, and that field is the
|
||||
/// default target of this dialog. An area which reports a delegate claims that role for itself
|
||||
/// and would take every drop away from the field, so we stay a plain marker in that case.
|
||||
/// </remarks>
|
||||
private bool CanAttach => this.AttachPaths is not null && this.document is not null;
|
||||
|
||||
private EventCallback<List<string>> DropCallback => this.CanAttach
|
||||
? EventCallback.Factory.Create<List<string>>(this, this.PathsDropped)
|
||||
: default;
|
||||
|
||||
private bool IsZoneDisabled() => this.IsAttachingUnavailable?.Invoke() ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Marks the part of this dialog which shows the document while a file hovers over it, so it is
|
||||
/// visible where that file would land. The frame keeps its width in both states; only its color
|
||||
/// changes, or the content would jump by a few pixels with every drag.
|
||||
/// </summary>
|
||||
/// <param name="isDropTarget">Whether this dialog is the target of the drop being aimed right now.</param>
|
||||
private string PreviewAreaClass(bool isDropTarget)
|
||||
{
|
||||
if (!this.CanAttach)
|
||||
return string.Empty;
|
||||
|
||||
return isDropTarget && !this.IsZoneDisabled()
|
||||
? "border-dashed border-2 rounded-lg pa-2 mud-border-primary"
|
||||
: "border-dashed border-2 rounded-lg pa-2 mud-border-lines-default";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches what the user dropped onto this dialog and shows the first file of it.
|
||||
/// </summary>
|
||||
/// <param name="paths">The dropped paths, in the order the runtime delivered them.</param>
|
||||
private async Task PathsDropped(List<string> paths)
|
||||
{
|
||||
if (this.AttachPaths is null)
|
||||
return;
|
||||
|
||||
var attached = await this.AttachPaths(paths);
|
||||
if (this.isDisposed)
|
||||
return;
|
||||
|
||||
//
|
||||
// Nothing came of the drop: the file is of a kind we do not take, Pandoc is missing, the
|
||||
// validation refused it, or it is a media file whose transcript does not exist yet. The
|
||||
// reason is already on its way to the user, and the document they were looking at stays.
|
||||
//
|
||||
if (attached.Count is 0)
|
||||
return;
|
||||
|
||||
this.ShowDocument(attached[0]);
|
||||
|
||||
//
|
||||
// Render before reading: the skeletons of the loading state are what tells the user that
|
||||
// the preview switched at all, and reading a file may well take a moment.
|
||||
//
|
||||
this.StateHasChanged();
|
||||
await this.LoadDocumentContentAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows another document, discarding everything that belonged to the previous one.
|
||||
/// </summary>
|
||||
/// <param name="attachment">The document to show from now on.</param>
|
||||
private void ShowDocument(FileAttachment attachment)
|
||||
{
|
||||
this.document = attachment;
|
||||
this.fileContent = string.Empty;
|
||||
this.loadFailureMessage = null;
|
||||
this.isLoadingContent = this.NeedsExtraction();
|
||||
this.UpdatePreview();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the user loads a file through this dialog. We don't use a two-way binding here,
|
||||
/// since we have to refresh the preview whenever the content changes.
|
||||
/// </summary>
|
||||
/// <param name="fileContent">The content of the file the user has loaded.</param>
|
||||
private void ApplyLoadedFileContent(string fileContent)
|
||||
/// <param name="loadedContent">The content of the file the user has loaded.</param>
|
||||
private void ApplyLoadedFileContent(string loadedContent)
|
||||
{
|
||||
this.FileContent = fileContent;
|
||||
this.fileContent = loadedContent;
|
||||
this.UpdatePreview();
|
||||
}
|
||||
|
||||
@ -150,9 +320,9 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
||||
/// </summary>
|
||||
private void UpdatePreview()
|
||||
{
|
||||
if (this.FileContent.Length <= PREVIEW_CHARACTER_LIMIT)
|
||||
if (this.fileContent.Length <= PREVIEW_CHARACTER_LIMIT)
|
||||
{
|
||||
this.previewContent = this.FileContent;
|
||||
this.previewContent = this.fileContent;
|
||||
this.previewCutOffCharacters = 0;
|
||||
return;
|
||||
}
|
||||
@ -161,12 +331,12 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
||||
// We cut at the last line break before our limit. Otherwise, we might tear apart a Markdown
|
||||
// construct like a table row or a code fence in the middle of a line:
|
||||
//
|
||||
var cutIndex = this.FileContent.LastIndexOf('\n', PREVIEW_CHARACTER_LIMIT - 1) + 1;
|
||||
var cutIndex = this.fileContent.LastIndexOf('\n', PREVIEW_CHARACTER_LIMIT - 1) + 1;
|
||||
if (cutIndex < 1)
|
||||
cutIndex = PREVIEW_CHARACTER_LIMIT;
|
||||
|
||||
this.previewContent = this.FileContent[..cutIndex];
|
||||
this.previewCutOffCharacters = this.FileContent.Length - cutIndex;
|
||||
this.previewContent = this.fileContent[..cutIndex];
|
||||
this.previewCutOffCharacters = this.fileContent.Length - cutIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -177,6 +347,11 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
this.isDisposed = true;
|
||||
|
||||
//
|
||||
// Only the running load is left to end here: every load we replaced was ended and disposed
|
||||
// the moment its successor started.
|
||||
//
|
||||
this.extractionCancellation.Cancel();
|
||||
this.extractionCancellation.Dispose();
|
||||
|
||||
|
||||
@ -187,6 +187,7 @@
|
||||
Disabled="@this.IsEnterpriseConfiguration"
|
||||
Validation="@this.ValidateEmbeddingBatchSize"
|
||||
HelperText="@T("How many chunks are sent to the embedding provider at once. The default is 1.")"/>
|
||||
<TokenizerHint LLMProvider="@this.DataLLMProvider" Model="@this.DataModel"/>
|
||||
<PathDropZone IdPrefix="tokenizer" Disabled="@(() => this.IsEnterpriseConfiguration)" OnPathsDropped="@this.OnTokenizerPathsDropped">
|
||||
<MudStack Row="@true" Spacing="3" Class="mb-3" StretchItems="StretchItems.None" AlignItems="AlignItems.Center">
|
||||
<MudTextField
|
||||
|
||||
@ -242,6 +242,64 @@
|
||||
</MudSelect>
|
||||
</MudPaper>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">
|
||||
@T("Override Model Limits")
|
||||
</MudText>
|
||||
<MudJustifiedText Typo="Typo.body2" Class="mb-4">
|
||||
@T("Where the stored numbers do not match your installation, state yours here. This matters most for self-hosted models: they run with whatever their operator configured, which the model card cannot know.")
|
||||
</MudJustifiedText>
|
||||
<MudStack Class="mb-4" Spacing="2">
|
||||
<MudPaper Outlined="@true" Class="pa-3">
|
||||
<MudNumericField T="int?"
|
||||
Value="@this.capabilityOverrides.ContextWindowTokens"
|
||||
ValueChanged="@this.SetContextWindowOverride"
|
||||
Label="@T("Context window in tokens")"
|
||||
HelperText="@this.ContextWindowHelperText"
|
||||
Placeholder="@this.AutomaticContextWindowPlaceholder"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense"
|
||||
Min="1"
|
||||
HideSpinButtons="@true"
|
||||
Clearable="@true"
|
||||
Disabled="@this.IsEnterpriseConfiguration"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Straighten"
|
||||
AdornmentColor="Color.Info" />
|
||||
</MudPaper>
|
||||
<MudPaper Outlined="@true" Class="pa-3">
|
||||
<MudText Typo="Typo.body2">
|
||||
@T("Images")
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
@this.ImageLimitsEffectiveLabel
|
||||
</MudText>
|
||||
<MudStack Row="@true" Spacing="3" Wrap="Wrap.Wrap" Class="border-dashed border rounded-lg pa-3 mt-3">
|
||||
<MudNumericField T="int?"
|
||||
Value="@this.capabilityOverrides.MaxImagesPerMessage"
|
||||
ValueChanged="@this.SetMaxImagesPerMessageOverride"
|
||||
Label="@T("Per message")"
|
||||
Placeholder="@this.AutomaticImageLimitPlaceholder(this.GetAutomaticModelProfile().Images.MaxPerMessage)"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense"
|
||||
Min="0"
|
||||
Clearable="@true"
|
||||
Disabled="@this.IsEnterpriseConfiguration" />
|
||||
<MudNumericField T="int?"
|
||||
Value="@this.capabilityOverrides.MaxImagesPerRequest"
|
||||
ValueChanged="@this.SetMaxImagesPerRequestOverride"
|
||||
Label="@T("Per request")"
|
||||
Placeholder="@this.AutomaticImageLimitPlaceholder(this.GetAutomaticModelProfile().Images.MaxPerRequest)"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense"
|
||||
Min="0"
|
||||
Clearable="@true"
|
||||
Disabled="@this.IsEnterpriseConfiguration" />
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
|
||||
@T("Vendors state one of the two, the other, or neither. Whichever is smaller decides how many images one message may carry; an empty field states nothing.")
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudStack>
|
||||
<MudJustifiedText Typo="Typo.body2" Class="mb-4">
|
||||
@string.Format(T("The current model uses the {0}."), this.GetCurrentModelApiLabel())
|
||||
</MudJustifiedText>
|
||||
@ -251,6 +309,7 @@
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mt-4 mb-3">
|
||||
@T("For better token estimates, you can configure a custom tokenizer for this provider.")
|
||||
</MudJustifiedText>
|
||||
<TokenizerHint LLMProvider="@this.DataLLMProvider" Model="@this.DataModel"/>
|
||||
<PathDropZone IdPrefix="tokenizer" Disabled="@(() => this.IsEnterpriseConfiguration)" OnPathsDropped="@this.OnTokenizerPathsDropped">
|
||||
<MudStack Row="@true" Spacing="3" Class="mb-3" StretchItems="StretchItems.None" AlignItems="AlignItems.Center">
|
||||
<MudTextField
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Components;
|
||||
using AIStudio.Models;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Provider.HuggingFace;
|
||||
using AIStudio.Tools.Rust;
|
||||
@ -163,6 +165,16 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
|
||||
private bool usesLegacySystemModelFallback;
|
||||
private bool showExpertSettings;
|
||||
private ProviderCapabilityOverrides capabilityOverrides = new();
|
||||
|
||||
/// <summary>
|
||||
/// The culture the numbers of this dialog are written in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// AI Studio's language is a setting of its own and does not move the thread's culture along
|
||||
/// with it. Without this, a German who chose German would read a context window of 131,072
|
||||
/// tokens as a number a thousand times smaller.
|
||||
/// </remarks>
|
||||
private CultureInfo currentCulture = CultureInfo.InvariantCulture;
|
||||
|
||||
// We get the form reference from Blazor code to validate it manually:
|
||||
private MudForm form = null!;
|
||||
@ -226,7 +238,11 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
|
||||
{
|
||||
// Call the base initialization first so that the I18N is ready:
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
|
||||
// The numbers of the expert settings are written the way the chosen language writes them:
|
||||
var activeLanguagePlugin = await this.SettingsManager.GetActiveLanguagePlugin();
|
||||
this.currentCulture = CommonTools.DeriveActiveCultureOrInvariant(activeLanguagePlugin.IETFTag);
|
||||
|
||||
// Configure the spellchecking for the instance name input:
|
||||
this.SettingsManager.InjectSpellchecking(SPELLCHECK_ATTRIBUTES);
|
||||
|
||||
@ -663,33 +679,29 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
|
||||
if (alwaysReasoning is null && optionalReasoning is null && reasoningByDefault is null)
|
||||
return ReasoningOverrideMode.AUTOMATIC;
|
||||
|
||||
var capabilities = this.GetCurrentModelCapabilities();
|
||||
if (capabilities.Contains(Capability.ALWAYS_REASONING))
|
||||
return ReasoningOverrideMode.ALWAYS_ON;
|
||||
|
||||
if (capabilities.Contains(Capability.REASONING_BY_DEFAULT))
|
||||
return ReasoningOverrideMode.ON_BY_DEFAULT;
|
||||
|
||||
if (capabilities.Contains(Capability.OPTIONAL_REASONING))
|
||||
return ReasoningOverrideMode.CAN_BE_ENABLED;
|
||||
|
||||
return ReasoningOverrideMode.NO_REASONING;
|
||||
return ModeOf(this.GetCurrentModelProfile().Reasoning);
|
||||
}
|
||||
|
||||
private ReasoningOverrideMode GetAutomaticReasoningOverrideMode()
|
||||
private ReasoningOverrideMode GetAutomaticReasoningOverrideMode() => ModeOf(this.GetAutomaticModelProfile().Reasoning);
|
||||
|
||||
/// <summary>
|
||||
/// Which of the choices in this dialog a reasoning state is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The five entries of the list were always this one answer, only written as three flags and
|
||||
/// read back by asking for them in the right order. Now they are the same four words plus
|
||||
/// "automatic", which is the absence of a statement rather than a state a model can be in.
|
||||
/// </remarks>
|
||||
/// <param name="reasoning">How the model reasons.</param>
|
||||
/// <returns>The choice standing for it.</returns>
|
||||
private static ReasoningOverrideMode ModeOf(ReasoningSupport reasoning) => reasoning switch
|
||||
{
|
||||
var capabilities = this.GetAutomaticModelCapabilities();
|
||||
if (capabilities.Contains(Capability.ALWAYS_REASONING))
|
||||
return ReasoningOverrideMode.ALWAYS_ON;
|
||||
ReasoningSupport.ALWAYS => ReasoningOverrideMode.ALWAYS_ON,
|
||||
ReasoningSupport.ON_BY_DEFAULT => ReasoningOverrideMode.ON_BY_DEFAULT,
|
||||
ReasoningSupport.OPTIONAL => ReasoningOverrideMode.CAN_BE_ENABLED,
|
||||
|
||||
if (capabilities.Contains(Capability.REASONING_BY_DEFAULT))
|
||||
return ReasoningOverrideMode.ON_BY_DEFAULT;
|
||||
|
||||
if (capabilities.Contains(Capability.OPTIONAL_REASONING))
|
||||
return ReasoningOverrideMode.CAN_BE_ENABLED;
|
||||
|
||||
return ReasoningOverrideMode.NO_REASONING;
|
||||
}
|
||||
_ => ReasoningOverrideMode.NO_REASONING,
|
||||
};
|
||||
|
||||
private void SetReasoningOverrideMode(ReasoningOverrideMode mode)
|
||||
{
|
||||
@ -746,11 +758,7 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
|
||||
|
||||
private bool HasCapabilityOverride(Capability capability) => this.capabilityOverrides.GetOverride(capability) is not null;
|
||||
|
||||
private bool IsCapabilityEnabled(Capability capability)
|
||||
{
|
||||
var capabilities = this.GetCurrentModelCapabilities();
|
||||
return capabilities.Contains(capability);
|
||||
}
|
||||
private bool IsCapabilityEnabled(Capability capability) => this.GetCurrentModelProfile().Has(capability);
|
||||
|
||||
private string GetCapabilityEffectiveLabel(Capability capability)
|
||||
{
|
||||
@ -761,21 +769,107 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
|
||||
return isEnabled ? T("Enabled (Auto)") : T("Disabled (Auto)");
|
||||
}
|
||||
|
||||
private List<Capability> GetCurrentModelCapabilities()
|
||||
/// <summary>
|
||||
/// States how many tokens this installation reads and writes.
|
||||
/// </summary>
|
||||
/// <param name="tokens">The number of tokens, or null to go back to the automatic answer.</param>
|
||||
private void SetContextWindowOverride(int? tokens) => this.capabilityOverrides = this.capabilityOverrides with { ContextWindowTokens = tokens };
|
||||
|
||||
/// <summary>
|
||||
/// States how many images one message may carry here.
|
||||
/// </summary>
|
||||
/// <param name="images">The number of images, or null to go back to the automatic answer.</param>
|
||||
private void SetMaxImagesPerMessageOverride(int? images) => this.capabilityOverrides = this.capabilityOverrides with { MaxImagesPerMessage = images };
|
||||
|
||||
/// <summary>
|
||||
/// States how many images one request may carry here.
|
||||
/// </summary>
|
||||
/// <param name="images">The number of images, or null to go back to the automatic answer.</param>
|
||||
private void SetMaxImagesPerRequestOverride(int? images) => this.capabilityOverrides = this.capabilityOverrides with { MaxImagesPerRequest = images };
|
||||
|
||||
/// <summary>
|
||||
/// What an empty window field shows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Written without separators, unlike the number in the helper text next to it: this one stands
|
||||
/// inside the field a person types into, and what they see there has to be what they may type.
|
||||
/// </remarks>
|
||||
private string AutomaticContextWindowPlaceholder
|
||||
{
|
||||
var currentProviderSettings = this.CreateProviderSettings();
|
||||
return currentProviderSettings.GetModelCapabilities();
|
||||
get
|
||||
{
|
||||
var context = this.GetAutomaticModelProfile().Context;
|
||||
return context.IsKnown ? context.DefaultTokens.ToString(CultureInfo.InvariantCulture) : string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private List<Capability> GetAutomaticModelCapabilities() => this.DataLLMProvider.GetModelCapabilities(this.GetSelectedModel());
|
||||
/// <summary>
|
||||
/// What an empty image field shows.
|
||||
/// </summary>
|
||||
/// <param name="limit">The limit the rules worked out, if any.</param>
|
||||
/// <returns>The number, or nothing where nobody stated one.</returns>
|
||||
private string AutomaticImageLimitPlaceholder(int? limit) => limit?.ToString(CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// What the window field says below itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It names the automatic answer rather than the one in effect, because the number in effect is
|
||||
/// already in the field. What a person cannot otherwise see is what they would go back to.
|
||||
/// </remarks>
|
||||
private string ContextWindowHelperText
|
||||
{
|
||||
get
|
||||
{
|
||||
var context = this.GetAutomaticModelProfile().Context;
|
||||
return context.IsKnown
|
||||
? string.Format(T("Detected: {0} tokens. Leave the field empty to use that."), context.DefaultTokens.ToString("N0", this.currentCulture))
|
||||
: T("Nobody has stated a window for this model. Left empty, the chat counts the tokens of a conversation without saying what they may grow to.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the two image fields say above themselves.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The number in effect, not the two the person typed: which of them decides is the one thing
|
||||
/// two fields cannot show on their own, and it is the one the chat and the Visual Briefing go by.
|
||||
/// </remarks>
|
||||
private string ImageLimitsEffectiveLabel
|
||||
{
|
||||
get
|
||||
{
|
||||
var allowed = this.GetCurrentModelProfile().Images.MaxInOneMessage;
|
||||
return allowed is { } count
|
||||
? string.Format(T("At most {0} images at once."), count.ToString("N0", this.currentCulture))
|
||||
: T("No limit known, so AI Studio does not stop anybody from attaching more.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the model can do as this provider instance is configured, the person's own settings included.
|
||||
/// </summary>
|
||||
/// <returns>The profile.</returns>
|
||||
private ModelProfile GetCurrentModelProfile() => this.CreateProviderSettings().GetModelProfile();
|
||||
|
||||
/// <summary>
|
||||
/// What holds without anybody switching anything, which is what each field shows as its automatic answer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The rules, plus whatever the provider itself stated when the model list was loaded a moment
|
||||
/// ago. A self-hosted engine is the case this matters for: it reports the window it was started
|
||||
/// with, and that is the number a person gets by leaving the field below empty.
|
||||
/// </remarks>
|
||||
/// <returns>The profile.</returns>
|
||||
private ModelProfile GetAutomaticModelProfile() => this.CreateProviderSettings().GetAutomaticModelProfile();
|
||||
|
||||
private string GetCurrentModelApiLabel()
|
||||
{
|
||||
var capabilities = this.GetCurrentModelCapabilities();
|
||||
if (capabilities.Contains(Capability.RESPONSES_API))
|
||||
var profile = this.GetCurrentModelProfile();
|
||||
if (profile.Has(Capability.RESPONSES_API))
|
||||
return "Responses API";
|
||||
|
||||
if (capabilities.Contains(Capability.CHAT_COMPLETION_API))
|
||||
if (profile.Has(Capability.CHAT_COMPLETION_API))
|
||||
return "Chat Completions API";
|
||||
|
||||
return "Unknown";
|
||||
|
||||
@ -2,13 +2,26 @@
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
@* A drop anywhere in this dialog belongs to the dialog, not to the page behind it: *@
|
||||
<PathDropZone IsArea="@true"
|
||||
IdPrefix="review-attachments"
|
||||
OnPathsDropped="@this.DropCallback"
|
||||
Disabled="@this.IsZoneDisabled"
|
||||
Context="isDropTarget">
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
|
||||
@T("Here you can see all attached files. Files that can no longer be found (deleted, renamed, or moved) are marked with a warning icon and a strikethrough name. You can remove any attachment using the trash can icon.")
|
||||
</MudJustifiedText>
|
||||
|
||||
@if (this.CanAttach)
|
||||
{
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
|
||||
@T("You can drag more files into this window to attach them right away.")
|
||||
</MudJustifiedText>
|
||||
}
|
||||
|
||||
<MudDivider Class="mt-3 mb-3"/>
|
||||
|
||||
<div style="max-height: 50vh; overflow-y: auto; overflow-x: hidden; padding-right: 8px;">
|
||||
<div class="@this.AttachmentListClass(isDropTarget)" style="max-height: 50vh; overflow-y: auto; overflow-x: hidden;">
|
||||
@if (!this.DocumentPaths.Any())
|
||||
{
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mt-3">
|
||||
@ -18,7 +31,7 @@
|
||||
|
||||
@{
|
||||
var currentFolder = string.Empty;
|
||||
foreach (var fileAttachment in this.DocumentPaths)
|
||||
foreach (var fileAttachment in this.OrderedAttachments)
|
||||
{
|
||||
var folderPath = Path.GetDirectoryName(fileAttachment.FilePath);
|
||||
if (folderPath != currentFolder)
|
||||
@ -91,6 +104,7 @@
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</PathDropZone>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@this.Close" Variant="Variant.Filled" Color="Color.Primary">
|
||||
|
||||
@ -16,18 +16,123 @@ public partial class ReviewAttachmentsDialog : MSGComponentBase
|
||||
[Parameter]
|
||||
public HashSet<FileAttachment> DocumentPaths { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Attaches the files the user drops onto this dialog, and answers which of them it attached.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null when this dialog only shows attachments, which is the case for a message that was
|
||||
/// already sent: there is nothing left to attach to. Without this, the dialog behaves as it
|
||||
/// always did and swallows every drop.
|
||||
/// </remarks>
|
||||
[Parameter]
|
||||
public Func<List<string>, Task<IReadOnlyList<FileAttachment>>>? AttachPaths { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Decides, at the moment a drop arrives, whether attaching is possible right now.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Asked rather than passed as a value, because the answer changes while this dialog is open:
|
||||
/// dropping a media file here starts a transcription, and nothing else may be attached until
|
||||
/// that one is through.
|
||||
/// </remarks>
|
||||
[Parameter]
|
||||
public Func<bool>? IsAttachingUnavailable { get; set; }
|
||||
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; set; } = null!;
|
||||
|
||||
private void Close() => this.MudDialog.Close(DialogResult.Ok(this.DocumentPaths));
|
||||
|
||||
public static async Task<HashSet<FileAttachment>> OpenDialogAsync(IDialogService dialogService, params HashSet<FileAttachment> documentPaths)
|
||||
/// <summary>Whether this dialog takes files at all, which decides what it says and shows.</summary>
|
||||
private bool CanAttach => this.AttachPaths is not null;
|
||||
|
||||
private bool IsZoneDisabled() => this.IsAttachingUnavailable?.Invoke() ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the drop zone only when there is something to attach to. An area which reports a
|
||||
/// delegate claims the role of its own default target, and claiming it without being able to
|
||||
/// use it would swallow drops with no reason the user could see.
|
||||
/// </summary>
|
||||
private EventCallback<List<string>> DropCallback => this.AttachPaths is null
|
||||
? default
|
||||
: EventCallback.Factory.Create<List<string>>(this, this.PathsDropped);
|
||||
|
||||
/// <summary>
|
||||
/// Marks the list of attachments while a file hovers over this dialog, so it is visible where
|
||||
/// the file would land. The frame keeps its width in both states; only its color changes, or
|
||||
/// the list would jump by a few pixels with every drag.
|
||||
/// </summary>
|
||||
/// <param name="isDropTarget">Whether this dialog is the target of the drop being aimed right now.</param>
|
||||
private string AttachmentListClass(bool isDropTarget)
|
||||
{
|
||||
if (!this.CanAttach)
|
||||
return "pa-2";
|
||||
|
||||
return isDropTarget && !this.IsZoneDisabled()
|
||||
? "border-dashed border-2 rounded-lg pa-2 mud-border-primary"
|
||||
: "border-dashed border-2 rounded-lg pa-2 mud-border-lines-default";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The attachments, sorted by their folder and, within it, by their file name.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The list below starts a new heading whenever the folder changes from one attachment to the
|
||||
/// next, which names every folder exactly once -- but only as long as the attachments of a
|
||||
/// folder arrive together. The set behind them keeps no order of its own to guarantee that:
|
||||
/// removing one attachment already scrambles it, and one attached while this dialog is open
|
||||
/// lands at its end, giving its folder a second heading further down. Sorting here is what that
|
||||
/// list assumes anyway.
|
||||
/// </remarks>
|
||||
private IEnumerable<FileAttachment> OrderedAttachments => this.DocumentPaths
|
||||
.OrderBy(attachment => Path.GetDirectoryName(attachment.FilePath) ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(attachment => attachment.FileName, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches what the user dropped onto this dialog and answers which files that became.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every drop takes this way, the ones aimed at the document preview above this dialog
|
||||
/// included. That is why the list is refreshed here and nowhere else.
|
||||
/// </remarks>
|
||||
/// <param name="paths">The dropped paths, in the order the runtime delivered them.</param>
|
||||
/// <returns>The files which were attached, in the order they were dropped.</returns>
|
||||
private async Task<IReadOnlyList<FileAttachment>> AttachPathsAsync(List<string> paths)
|
||||
{
|
||||
if (this.AttachPaths is null)
|
||||
return [];
|
||||
|
||||
var attached = await this.AttachPaths(paths);
|
||||
this.StateHasChanged();
|
||||
|
||||
//
|
||||
// The list scrolls, so a newly attached file may well sit outside the visible part of it.
|
||||
// Saying so is cheaper than scrolling there, and the snackbar is skipped by the hit test,
|
||||
// so it never gets in the way of the next drop.
|
||||
//
|
||||
if (attached.Count > 0)
|
||||
await this.MessageBus.SendSuccess(new(Icons.Material.Filled.AttachFile, attached.Count is 1
|
||||
? string.Format(T("Attached {0}."), attached[0].FileName)
|
||||
: string.Format(T("Attached {0} files."), attached.Count)));
|
||||
|
||||
return attached;
|
||||
}
|
||||
|
||||
private async Task PathsDropped(List<string> paths) => await this.AttachPathsAsync(paths);
|
||||
|
||||
public static async Task<HashSet<FileAttachment>> OpenDialogAsync(IDialogService dialogService, HashSet<FileAttachment> documentPaths, Func<List<string>, Task<IReadOnlyList<FileAttachment>>>? attachPaths = null, Func<bool>? isAttachingUnavailable = null)
|
||||
{
|
||||
var dialogParameters = new DialogParameters<ReviewAttachmentsDialog>
|
||||
{
|
||||
{ x => x.DocumentPaths, documentPaths }
|
||||
};
|
||||
|
||||
if (attachPaths is not null)
|
||||
dialogParameters.Add(x => x.AttachPaths, attachPaths);
|
||||
|
||||
if (isAttachingUnavailable is not null)
|
||||
dialogParameters.Add(x => x.IsAttachingUnavailable, isAttachingUnavailable);
|
||||
|
||||
var dialogReference = await dialogService.ShowAsync<ReviewAttachmentsDialog>(TB("Your attached files"), dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult is null || dialogResult.Canceled)
|
||||
@ -58,6 +163,19 @@ public partial class ReviewAttachmentsDialog : MSGComponentBase
|
||||
{ x => x.Document, fileAttachment },
|
||||
};
|
||||
|
||||
//
|
||||
// Give the preview our own way of attaching, so a file dropped onto it lands in this list
|
||||
// as well. Not when we cannot attach anything ourselves: the preview would then claim every
|
||||
// drop and do nothing with it.
|
||||
//
|
||||
if (this.CanAttach)
|
||||
{
|
||||
dialogParameters.Add(x => x.AttachPaths, this.AttachPathsAsync);
|
||||
|
||||
if (this.IsAttachingUnavailable is not null)
|
||||
dialogParameters.Add(x => x.IsAttachingUnavailable, this.IsAttachingUnavailable);
|
||||
}
|
||||
|
||||
await this.DialogService.ShowAsync<DocumentCheckDialog>(T("Document Preview"), dialogParameters, DialogOptions.FULLSCREEN);
|
||||
}
|
||||
}
|
||||
@ -25,20 +25,19 @@
|
||||
|
||||
<MudSpacer/>
|
||||
|
||||
@if (this.showEmbeddingStatusIcon)
|
||||
{
|
||||
<MudNavMenu>
|
||||
<MudTooltip Text="@this.EmbeddingNavigationTooltip" Placement="Placement.Right">
|
||||
<MudNavLink Href="@this.embeddingItem.Path" Match="@(this.embeddingItem.MatchAll ? NavLinkMatch.All : NavLinkMatch.Prefix)" Icon="@this.embeddingItem.Icon" Style="@this.embeddingItem.SetColorStyle(this.SettingsManager)" Class="custom-icon-color">
|
||||
@T("Data sync")
|
||||
</MudNavLink>
|
||||
</MudTooltip>
|
||||
</MudNavMenu>
|
||||
}
|
||||
<MudStack AlignItems="AlignItems.Center" Class="pb-2">
|
||||
<MudToolBar WrapContent="true">
|
||||
<VoiceRecorder />
|
||||
</MudToolBar>
|
||||
@* The bottom area carries the gap to the window edge once, for whichever of its items are shown: *@
|
||||
<MudStack Spacing="0" Class="pb-2">
|
||||
@if (this.showEmbeddingStatusIcon)
|
||||
{
|
||||
<MudNavMenu>
|
||||
<MudTooltip Text="@this.EmbeddingNavigationTooltip" Placement="Placement.Right">
|
||||
<MudNavLink Href="@this.embeddingItem.Path" Match="@(this.embeddingItem.MatchAll ? NavLinkMatch.All : NavLinkMatch.Prefix)" Icon="@this.embeddingItem.Icon" Style="@this.embeddingItem.SetColorStyle(this.SettingsManager)" Class="custom-icon-color">
|
||||
@T("Data sync")
|
||||
</MudNavLink>
|
||||
</MudTooltip>
|
||||
</MudNavMenu>
|
||||
}
|
||||
<VoiceRecorder />
|
||||
</MudStack>
|
||||
</MudDrawer>
|
||||
</MudDrawerContainer>
|
||||
@ -64,25 +63,24 @@
|
||||
|
||||
<MudSpacer/>
|
||||
|
||||
@if (this.showEmbeddingStatusIcon)
|
||||
{
|
||||
<MudNavMenu>
|
||||
@if (this.SettingsManager.ConfigurationData.App.NavigationBehavior is NavBehavior.NEVER_EXPAND_USE_TOOLTIPS)
|
||||
{
|
||||
<MudTooltip Text="@this.EmbeddingNavigationTooltip" Placement="Placement.Right">
|
||||
@* The bottom area carries the gap to the window edge once, for whichever of its items are shown: *@
|
||||
<MudStack Spacing="0" Class="pb-2">
|
||||
@if (this.showEmbeddingStatusIcon)
|
||||
{
|
||||
<MudNavMenu>
|
||||
@if (this.SettingsManager.ConfigurationData.App.NavigationBehavior is NavBehavior.NEVER_EXPAND_USE_TOOLTIPS)
|
||||
{
|
||||
<MudTooltip Text="@this.EmbeddingNavigationTooltip" Placement="Placement.Right">
|
||||
<MudNavLink Href="@this.embeddingItem.Path" Match="@(this.embeddingItem.MatchAll ? NavLinkMatch.All : NavLinkMatch.Prefix)" Icon="@this.embeddingItem.Icon" Style="@this.embeddingItem.SetColorStyle(this.SettingsManager)" Class="custom-icon-color"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudNavLink Href="@this.embeddingItem.Path" Match="@(this.embeddingItem.MatchAll ? NavLinkMatch.All : NavLinkMatch.Prefix)" Icon="@this.embeddingItem.Icon" Style="@this.embeddingItem.SetColorStyle(this.SettingsManager)" Class="custom-icon-color"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudNavLink Href="@this.embeddingItem.Path" Match="@(this.embeddingItem.MatchAll ? NavLinkMatch.All : NavLinkMatch.Prefix)" Icon="@this.embeddingItem.Icon" Style="@this.embeddingItem.SetColorStyle(this.SettingsManager)" Class="custom-icon-color"/>
|
||||
}
|
||||
</MudNavMenu>
|
||||
}
|
||||
<MudStack AlignItems="AlignItems.Center" Class="pb-2">
|
||||
<MudToolBar WrapContent="true">
|
||||
<VoiceRecorder />
|
||||
</MudToolBar>
|
||||
}
|
||||
</MudNavMenu>
|
||||
}
|
||||
<VoiceRecorder />
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@ -82,6 +82,15 @@
|
||||
<Folder Include="Plugins\assistants\assets\" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
The test project has to reach Program.LOGGER_FACTORY. Program is internal, and it stays that
|
||||
way: it is the entry point, not API. Several types initialize a static logger field from that
|
||||
factory, so a test process must assign it before it touches any of them.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Read the meta data file -->
|
||||
<Target Name="ReadMetaData" BeforeTargets="BeforeBuild">
|
||||
<Error Text="The ../../metadata.txt file was not found!" Condition="!Exists('../../metadata.txt')" />
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
using AIStudio.Provider;
|
||||
|
||||
using static AIStudio.Provider.Capability;
|
||||
|
||||
namespace AIStudio.Models.Alibaba;
|
||||
|
||||
/// <summary>
|
||||
/// Alibaba's embedding models, which turn text into a vector and answer nothing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The previous rules answered for these with the Model Studio default and told them they call
|
||||
/// functions. The prefix is Alibaba's own: the app filters the catalog by "text-embedding-" to find
|
||||
/// them, which is also why the rule may be written that broadly -- bound to this provider, it can
|
||||
/// only ever meet the models Alibaba names that way.
|
||||
/// </remarks>
|
||||
public sealed class ModelStudioEmbeddingFamily : ModelFamily
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override ModelVendor Vendor => ModelVendor.ALIBABA;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://www.alibabacloud.com/help/en/model-studio/embedding", new DateOnly(2026, 9, 11), "Provider/AlibabaCloud/ProviderAlibabaCloud.cs adds these in GetEmbeddingModels and filters the catalog by the prefix \"text-embedding-\".");
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Declare(ModelFamilyBuilder builder) =>
|
||||
builder.Rule("text-embedding").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD)
|
||||
.Capabilities(TEXT_INPUT | EMBEDDING)
|
||||
.Kind(ModelKind.EMBEDDING);
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
using AIStudio.Provider;
|
||||
|
||||
using static AIStudio.Provider.Capability;
|
||||
|
||||
namespace AIStudio.Models.Alibaba;
|
||||
|
||||
/// <summary>
|
||||
/// QVQ, the thinking-only model which also looks at pictures.
|
||||
/// </summary>
|
||||
public sealed class ModelStudioQvqFamily : ModelFamily
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override ModelVendor Vendor => ModelVendor.ALIBABA;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://www.alibabacloud.com/help/en/model-studio/models", new DateOnly(2026, 9, 11), "Ported unchanged from the rules in ProviderExtensions.Alibaba.cs: images in, thinking which cannot be switched off, and no tools.");
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Declare(ModelFamilyBuilder builder) =>
|
||||
builder.Rule("qvq").AsSegment().OnlyOn(LLMProviders.ALIBABA_CLOUD)
|
||||
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
|
||||
.Apis(CHAT_COMPLETION_API)
|
||||
.Reasoning(ReasoningSupport.ALWAYS);
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
using AIStudio.Provider;
|
||||
|
||||
using static AIStudio.Provider.Capability;
|
||||
|
||||
namespace AIStudio.Models.Alibaba;
|
||||
|
||||
/// <summary>
|
||||
/// The Qwen models Alibaba Cloud Model Studio serves.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything called Model Studio here is bound to Alibaba Cloud, and that is the point of it.
|
||||
/// Model Studio sells commercial models -- qwen-max, qwen3.7-max, qwq-plus -- which carry the
|
||||
/// family names of the open weights without being them, and it answers differently for several
|
||||
/// names the open weights share with it. The old rules kept the two apart by having two functions;
|
||||
/// here they are kept apart by saying which provider a rule speaks for. The unbound families next
|
||||
/// to these are the open weights, which answer everywhere else.
|
||||
///
|
||||
/// The first rule is the catalog's own fallback, and it is written as a plain substring on purpose:
|
||||
/// a substring is the weakest thing a rule can be, so every other rule here beats it without anyone
|
||||
/// arranging that. It also has to be one, because "qwen2.5-72b-instruct" does not contain "qwen" as
|
||||
/// a whole name part -- the version grows straight out of the family name.
|
||||
/// </remarks>
|
||||
public sealed class ModelStudioQwenFamily : ModelFamily
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override ModelVendor Vendor => ModelVendor.ALIBABA;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://www.alibabacloud.com/help/en/model-studio/models", new DateOnly(2026, 9, 12), "Capabilities ported unchanged from ProviderExtensions.Alibaba.cs, which follow Alibaba's own list of models that call functions. Alibaba's announcement of Qwen3.8-Max states a window of up to one million tokens; the other Qwen models are served at sizes their list does not state per model.");
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Declare(ModelFamilyBuilder builder)
|
||||
{
|
||||
builder.Rule("qwen").AsSubstring().OnlyOn(LLMProviders.ALIBABA_CLOUD)
|
||||
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
|
||||
.Apis(CHAT_COMPLETION_API);
|
||||
|
||||
// Qwen 3 thinks when asked to:
|
||||
builder.Rule("qwen3").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD).Inherits()
|
||||
.Reasoning(ReasoningSupport.OPTIONAL);
|
||||
|
||||
builder.Rule("qwen3.5").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD).InheritsFrom("qwen3")
|
||||
.Capabilities(MULTIPLE_IMAGE_INPUT);
|
||||
|
||||
builder.Rule("qwen3.6").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD).InheritsFrom("qwen3")
|
||||
.Capabilities(MULTIPLE_IMAGE_INPUT | VIDEO_INPUT)
|
||||
.Reasoning(ReasoningSupport.ALWAYS);
|
||||
|
||||
//
|
||||
// Qwen 3.7 thinks unless it is told not to, and it started out reading nothing but text.
|
||||
// Vision arrived in the middle of the series, so only the June snapshot may be told that
|
||||
// it sees: the rolling max alias still answers as the May one.
|
||||
//
|
||||
builder.Rule("qwen3.7").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD).InheritsFrom("qwen3")
|
||||
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
|
||||
|
||||
builder.Rule("qwen3.7").AsPrefix().AlsoContains("preview").OnlyOn(LLMProviders.ALIBABA_CLOUD).Inherits()
|
||||
.Reasoning(ReasoningSupport.ALWAYS);
|
||||
|
||||
builder.Rule("qwen3.7").AsPrefix().AlsoContains("2026-05-17").OnlyOn(LLMProviders.ALIBABA_CLOUD).Inherits();
|
||||
|
||||
builder.Rule("qwen3.7").AsPrefix().AlsoContains("2026-06-08").OnlyOn(LLMProviders.ALIBABA_CLOUD)
|
||||
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | VIDEO_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
|
||||
.Apis(CHAT_COMPLETION_API)
|
||||
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
|
||||
|
||||
// Qwen 3.8, whose 27B checkpoint is what the tier without a size resolves to:
|
||||
builder.Rule("qwen3.8").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD).InheritsFrom("qwen3")
|
||||
.Capabilities(MULTIPLE_IMAGE_INPUT)
|
||||
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
|
||||
|
||||
builder.Rule("qwen3.8-flash").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD).Inherits()
|
||||
.Capabilities(VIDEO_INPUT);
|
||||
|
||||
//
|
||||
// Unlike the open-weight checkpoint of the same name, the Max model keeps its vision when
|
||||
// it is reached through Model Studio:
|
||||
//
|
||||
builder.Rule("qwen3.8-max").AsPrefix().OnlyOn(LLMProviders.ALIBABA_CLOUD).InheritsFrom("qwen3.8-flash")
|
||||
.Reasoning(ReasoningSupport.ALWAYS)
|
||||
.ContextWindow(1_000_000);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
using AIStudio.Provider;
|
||||
|
||||
using static AIStudio.Provider.Capability;
|
||||
|
||||
namespace AIStudio.Models.Alibaba;
|
||||
|
||||
/// <summary>
|
||||
/// The Qwen Omni models, which take everything in and answer in text or in speech.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Alibaba lists the Qwen3 Omni series among the models which call functions and leaves the older
|
||||
/// ones off that list, which is the whole difference between the two rules below.
|
||||
/// </remarks>
|
||||
public sealed class ModelStudioQwenOmniFamily : ModelFamily
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override ModelVendor Vendor => ModelVendor.ALIBABA;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://www.alibabacloud.com/help/en/model-studio/models", new DateOnly(2026, 9, 11), "Ported unchanged from the rules in ProviderExtensions.Alibaba.cs: every modality in, text and speech out, tool calling from Qwen3 on.");
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Declare(ModelFamilyBuilder builder)
|
||||
{
|
||||
builder.Rule("qwen").AsSubstring().AlsoContains("omni").OnlyOn(LLMProviders.ALIBABA_CLOUD)
|
||||
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | AUDIO_INPUT | SPEECH_INPUT | VIDEO_INPUT | TEXT_OUTPUT | SPEECH_OUTPUT)
|
||||
.Apis(CHAT_COMPLETION_API);
|
||||
|
||||
builder.Rule("qwen3").AsPrefix().AlsoContains("omni").OnlyOn(LLMProviders.ALIBABA_CLOUD).Inherits()
|
||||
.Capabilities(FUNCTION_CALLING);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
using AIStudio.Provider;
|
||||
|
||||
using static AIStudio.Provider.Capability;
|
||||
|
||||
namespace AIStudio.Models.Alibaba;
|
||||
|
||||
/// <summary>
|
||||
/// The Qwen VL models, the ones built to look at pictures.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// As with the Omni series, Alibaba names only the Qwen3 VL models as function callers and the
|
||||
/// older ones not at all.
|
||||
/// </remarks>
|
||||
public sealed class ModelStudioQwenVisionFamily : ModelFamily
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override ModelVendor Vendor => ModelVendor.ALIBABA;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://www.alibabacloud.com/help/en/model-studio/models", new DateOnly(2026, 9, 11), "Ported unchanged from the rules in ProviderExtensions.Alibaba.cs: images in, and tool calling from Qwen3 on.");
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Declare(ModelFamilyBuilder builder)
|
||||
{
|
||||
builder.Rule("qwen").AsSubstring().AlsoContains("vl").OnlyOn(LLMProviders.ALIBABA_CLOUD)
|
||||
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
|
||||
.Apis(CHAT_COMPLETION_API);
|
||||
|
||||
builder.Rule("qwen3").AsPrefix().AlsoContains("vl").OnlyOn(LLMProviders.ALIBABA_CLOUD).Inherits()
|
||||
.Capabilities(FUNCTION_CALLING);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
using AIStudio.Provider;
|
||||
|
||||
using static AIStudio.Provider.Capability;
|
||||
|
||||
namespace AIStudio.Models.Alibaba;
|
||||
|
||||
/// <summary>
|
||||
/// QwQ as Model Studio serves it, which is qwq-plus.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the contradiction the provider-bound rules were built for. What Model Studio sells under
|
||||
/// this name is a commercial thinking-only model built on Qwen 2.5; QwQ-32B, which everybody else
|
||||
/// serves, is the open-weight model. They share a family name and nothing else, and the old rules
|
||||
/// could only keep them apart by living in two different functions.
|
||||
///
|
||||
/// Neither of them appears in Alibaba's list of models which call functions, and the model card of
|
||||
/// the open weights does not mention tools at all, which is why no such ability is stated here.
|
||||
/// Anybody who knows better turns it on in the expert settings.
|
||||
/// </remarks>
|
||||
public sealed class ModelStudioQwqFamily : ModelFamily
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override ModelVendor Vendor => ModelVendor.ALIBABA;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://www.alibabacloud.com/help/en/model-studio/models", new DateOnly(2026, 9, 11), "Ported unchanged from the rules in ProviderExtensions.Alibaba.cs: text in, text out, thinking which cannot be switched off, and no tools.");
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Declare(ModelFamilyBuilder builder) =>
|
||||
builder.Rule("qwq").AsSegment().OnlyOn(LLMProviders.ALIBABA_CLOUD)
|
||||
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
|
||||
.Apis(CHAT_COMPLETION_API)
|
||||
.Reasoning(ReasoningSupport.ALWAYS);
|
||||
}
|
||||
77
app/MindWork AI Studio/Models/Alibaba/QwenFamily.cs
Normal file
77
app/MindWork AI Studio/Models/Alibaba/QwenFamily.cs
Normal file
@ -0,0 +1,77 @@
|
||||
using static AIStudio.Provider.Capability;
|
||||
|
||||
namespace AIStudio.Models.Alibaba;
|
||||
|
||||
/// <summary>
|
||||
/// Qwen as everybody except Alibaba Cloud serves it: the open weights.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The counterpart to the Model Studio families next door, and the reason those are bound to their
|
||||
/// provider. Alibaba sells commercial models under the same family names, and for several of them
|
||||
/// it promises something else than the published checkpoint does. Nothing here is bound: these
|
||||
/// rules answer wherever the weights are run, which is every gateway and every engine somebody
|
||||
/// starts on their own machine.
|
||||
///
|
||||
/// The whole line calls functions, from Qwen 2.5 on, and the Coder checkpoints are built for
|
||||
/// exactly that. Thinking is not promised by the fallback: the older generations cannot do it, and
|
||||
/// which of the newer ones think by default differs per checkpoint, so those say it one by one.
|
||||
/// </remarks>
|
||||
public sealed class QwenFamily : ModelFamily
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override ModelVendor Vendor => ModelVendor.ALIBABA;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://huggingface.co/Qwen", new DateOnly(2026, 9, 11), "Ported unchanged from the Qwen block of ProviderExtensions.OpenSource.cs, which is the one answering everywhere but Alibaba Cloud.");
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Declare(ModelFamilyBuilder builder)
|
||||
{
|
||||
//
|
||||
// A substring, because the version grows straight out of the family name: there is no name
|
||||
// part "qwen" in "qwen2.5-72b-instruct". It is also the weakest thing a rule can be, which
|
||||
// is what lets every rule below beat it without anybody arranging an order.
|
||||
//
|
||||
builder.Rule("qwen").AsSubstring()
|
||||
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
|
||||
.Apis(CHAT_COMPLETION_API);
|
||||
|
||||
// The VL checkpoints are the ones built to look at pictures:
|
||||
builder.Rule("qwen").AsSubstring().AlsoContains("vl").Inherits()
|
||||
.Capabilities(MULTIPLE_IMAGE_INPUT);
|
||||
|
||||
// Qwen 3.5 sees, and thinks when the request asks it to:
|
||||
builder.Rule("qwen3.5").AsPrefix()
|
||||
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
|
||||
.Apis(CHAT_COMPLETION_API)
|
||||
.Reasoning(ReasoningSupport.OPTIONAL);
|
||||
|
||||
builder.Rule("qwen3.6").AsPrefix().Inherits()
|
||||
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
|
||||
|
||||
//
|
||||
// The 3.8 tier without a size is the 27B checkpoint: that is what a rolling tag such as
|
||||
// "qwen3.8:latest" resolves to, so it is what the tier may promise.
|
||||
//
|
||||
builder.Rule("qwen3.8").AsPrefix().InheritsFrom("qwen3.6");
|
||||
|
||||
// Flash-Next is the published checkpoint and Flash the production model; both watch videos:
|
||||
builder.Rule("qwen3.8-flash").AsPrefix().InheritsFrom("qwen3.8")
|
||||
.Capabilities(VIDEO_INPUT);
|
||||
|
||||
//
|
||||
// Blablador writes the 27B checkpoint in two further ways, and no normalization turns
|
||||
// either into the canonical name: it separates the family from the version ("Qwen 3.8-27B
|
||||
// with DFlash on haicluster"), and its short alias drops the dot ("alias-qwen38-27b").
|
||||
//
|
||||
builder.Rule("qwen-3.8-27b").AsSegment().InheritsFrom("qwen3.8");
|
||||
|
||||
builder.Rule("qwen38-27b").AsSegment().InheritsFrom("qwen3.8");
|
||||
|
||||
// The big 3.8 checkpoint reads nothing but text, and it thinks whatever it is asked:
|
||||
builder.Rule("qwen3.8-2.4t-a95b").AsPrefix()
|
||||
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
|
||||
.Apis(CHAT_COMPLETION_API)
|
||||
.Reasoning(ReasoningSupport.ALWAYS);
|
||||
}
|
||||
}
|
||||
31
app/MindWork AI Studio/Models/Alibaba/QwqFamily.cs
Normal file
31
app/MindWork AI Studio/Models/Alibaba/QwqFamily.cs
Normal file
@ -0,0 +1,31 @@
|
||||
using static AIStudio.Provider.Capability;
|
||||
|
||||
namespace AIStudio.Models.Alibaba;
|
||||
|
||||
/// <summary>
|
||||
/// QwQ as everybody except Alibaba Cloud serves it: the open weights built on Qwen 2.5.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The other half of the contradiction the provider-bound rules exist for. What Model Studio sells
|
||||
/// as "qwq-plus" is a commercial model; QwQ-32B, which the gateways and the local engines serve, is
|
||||
/// the published checkpoint. The two share a family name and nothing else.
|
||||
///
|
||||
/// Both answer the same here, and for the same reason: neither the model card nor Alibaba's list of
|
||||
/// models which call functions mentions tools at all. Anybody who knows better says so in the
|
||||
/// expert settings.
|
||||
/// </remarks>
|
||||
public sealed class QwqFamily : ModelFamily
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override ModelVendor Vendor => ModelVendor.ALIBABA;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://huggingface.co/Qwen/QwQ-32B", new DateOnly(2026, 9, 11), "Ported unchanged from the QwQ check of ProviderExtensions.OpenSource.cs: text in, text out, thinking which cannot be switched off, and no tools.");
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Declare(ModelFamilyBuilder builder) =>
|
||||
builder.Rule("qwq").AsSegment()
|
||||
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
|
||||
.Apis(CHAT_COMPLETION_API)
|
||||
.Reasoning(ReasoningSupport.ALWAYS);
|
||||
}
|
||||
130
app/MindWork AI Studio/Models/Anthropic/ClaudeFamily.cs
Normal file
130
app/MindWork AI Studio/Models/Anthropic/ClaudeFamily.cs
Normal file
@ -0,0 +1,130 @@
|
||||
using AIStudio.Models.Matching;
|
||||
|
||||
using static AIStudio.Provider.Capability;
|
||||
|
||||
namespace AIStudio.Models.Anthropic;
|
||||
|
||||
/// <summary>
|
||||
/// Claude, all of it: the 3.x models, the 4.x models, and the 5 line.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One family, because every Claude is the same shape and always has been -- text and images in,
|
||||
/// text out, tool calling, one API. What each generation adds to that is a single sentence about
|
||||
/// thinking, and the rules below are almost nothing but those sentences.
|
||||
///
|
||||
/// The first rule is the family's own fallback, and it is a statement rather than an accident: a
|
||||
/// Claude nobody has written a rule for yet is still a Claude, and every one of them so far reads
|
||||
/// images and calls tools. It answers for whole name parts, so every rule bound to the start of a
|
||||
/// name beats it, whatever their lengths -- which is what lets it sit first and mean "unless".
|
||||
///
|
||||
/// The one thing no rule below states is how many images a Claude takes. Anthropic ties that number
|
||||
/// to the context window instead of to the model, so it is worked out afterwards rather than written
|
||||
/// on every line which sets a window.
|
||||
/// </remarks>
|
||||
public sealed class ClaudeFamily : ModelFamily
|
||||
{
|
||||
/// <summary>
|
||||
/// The window every Claude has unless its own rule states the larger one.
|
||||
/// </summary>
|
||||
private const int STANDARD_WINDOW = 200_000;
|
||||
|
||||
/// <summary>
|
||||
/// The window of the Claude models which read a million tokens.
|
||||
/// </summary>
|
||||
private const int LARGE_WINDOW = 1_000_000;
|
||||
|
||||
/// <summary>
|
||||
/// How many images one request may carry when the model has the standard window.
|
||||
/// </summary>
|
||||
private const int IMAGES_PER_REQUEST_STANDARD_WINDOW = 100;
|
||||
|
||||
/// <summary>
|
||||
/// How many images one request may carry for every other Claude.
|
||||
/// </summary>
|
||||
private const int IMAGES_PER_REQUEST_OTHERWISE = 600;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelVendor Vendor => ModelVendor.ANTHROPIC;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://platform.claude.com/docs/en/build-with-claude/context-windows", new DateOnly(2026, 9, 12), "Capabilities ported unchanged from ProviderExtensions.Anthropic.cs: one shape for all of Claude, and one sentence per generation about how it thinks. The context window page names the models with a 1M window and says every other Claude has 200k.");
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<ModelSource> FurtherSources =>
|
||||
[
|
||||
new("https://platform.claude.com/docs/en/build-with-claude/vision", new DateOnly(2026, 9, 12), "The vision page gives the image limit as a rule rather than as a number per model: 100 images per request on the API for models with a 200k-token context window, 600 per request for all other models. The 20 it also names belongs to claude.ai, not to the API."),
|
||||
new("https://platform.claude.com/docs/en/build-with-claude/token-counting", new DateOnly(2026, 9, 12), "Anthropic publishes no tokenizer file at all; they count through the /v1/messages/count_tokens endpoint instead. The same page warns that Claude 4.7 and later use a newer tokenizer, on which the same text counts roughly 30 percent higher -- so AI Studio's built-in estimate is further off for those models than for the older ones.")
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Anthropic states no image limit per model. They state a rule which reads off the context
|
||||
/// window, and this is that rule -- written once rather than repeated as a number on every line
|
||||
/// which sets a window. Two statements of one fact drift apart, and the way they drift here is
|
||||
/// silent: the next Claude with the larger window would quietly keep the smaller limit because
|
||||
/// somebody wrote one number and not the other.
|
||||
/// </remarks>
|
||||
public override ModelProfile Refine(in ModelId id, in ModelProfile selected)
|
||||
{
|
||||
if (!selected.Context.IsKnown)
|
||||
return selected;
|
||||
|
||||
var perRequest = selected.Context.DefaultTokens is STANDARD_WINDOW ? IMAGES_PER_REQUEST_STANDARD_WINDOW : IMAGES_PER_REQUEST_OTHERWISE;
|
||||
return selected with { Images = new ImageLimits(null, perRequest) };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Declare(ModelFamilyBuilder builder)
|
||||
{
|
||||
//
|
||||
// 200k is the window of every Claude which is not named on the list of the 1M ones, which is
|
||||
// how Anthropic states it themselves: the page names the exceptions and says "other Claude
|
||||
// models" for the rest. So the fallback carries it, and the generations which got the larger
|
||||
// window say so one by one below.
|
||||
//
|
||||
builder.Rule("claude").AsSegment()
|
||||
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
|
||||
.Apis(CHAT_COMPLETION_API)
|
||||
.ContextWindow(STANDARD_WINDOW)
|
||||
.Tokenizer(TokenizerKind.PROVIDER_API, "/v1/messages/count_tokens");
|
||||
|
||||
//
|
||||
// The 3.x models say nothing beyond the shape above, so nothing is written for them: the
|
||||
// previous rules had a branch for "claude-3-" which returned exactly what its fallback
|
||||
// returned. Only 3.7 differs, by being the first Claude which could be asked to think.
|
||||
//
|
||||
builder.Rule("claude-3-7").AsPrefix().InheritsFrom("claude")
|
||||
.Reasoning(ReasoningSupport.OPTIONAL);
|
||||
|
||||
// The 4.x models think when a thinking budget is given, and not otherwise:
|
||||
builder.Rule("claude-opus-4").AsPrefix().InheritsFrom("claude")
|
||||
.Reasoning(ReasoningSupport.OPTIONAL);
|
||||
|
||||
builder.Rule("claude-sonnet-4").AsPrefix().InheritsFrom("claude-opus-4");
|
||||
builder.Rule("claude-haiku-4-5").AsPrefix().InheritsFrom("claude-opus-4");
|
||||
|
||||
//
|
||||
// Where the window grew inside the 4 line. These rules say nothing but the number: Opus 4.6
|
||||
// through 4.8 and Sonnet 4.6 have the 1M window, while the 4.0, 4.1 and 4.5 models of the
|
||||
// same prefixes keep the 200k they were released with.
|
||||
//
|
||||
builder.Rule("claude-opus-4-6").AsPrefix().InheritsFrom("claude-opus-4").ContextWindow(LARGE_WINDOW);
|
||||
builder.Rule("claude-opus-4-7").AsPrefix().InheritsFrom("claude-opus-4").ContextWindow(LARGE_WINDOW);
|
||||
builder.Rule("claude-opus-4-8").AsPrefix().InheritsFrom("claude-opus-4").ContextWindow(LARGE_WINDOW);
|
||||
builder.Rule("claude-sonnet-4-6").AsPrefix().InheritsFrom("claude-opus-4").ContextWindow(LARGE_WINDOW);
|
||||
|
||||
// Opus 5 and Sonnet 5 think adaptively unless thinking is turned off:
|
||||
builder.Rule("claude-opus-5").AsPrefix().InheritsFrom("claude")
|
||||
.Reasoning(ReasoningSupport.ON_BY_DEFAULT)
|
||||
.ContextWindow(LARGE_WINDOW);
|
||||
|
||||
builder.Rule("claude-sonnet-5").AsPrefix().InheritsFrom("claude-opus-5");
|
||||
|
||||
// Fable 5 and Mythos 5 always think, and there is no switch for it:
|
||||
builder.Rule("claude-fable-5").AsPrefix().InheritsFrom("claude")
|
||||
.Reasoning(ReasoningSupport.ALWAYS)
|
||||
.ContextWindow(LARGE_WINDOW);
|
||||
|
||||
builder.Rule("claude-mythos-5").AsPrefix().InheritsFrom("claude-fable-5");
|
||||
}
|
||||
}
|
||||
41
app/MindWork AI Studio/Models/Baidu/ErnieFamily.cs
Normal file
41
app/MindWork AI Studio/Models/Baidu/ErnieFamily.cs
Normal file
@ -0,0 +1,41 @@
|
||||
using static AIStudio.Provider.Capability;
|
||||
|
||||
namespace AIStudio.Models.Baidu;
|
||||
|
||||
/// <summary>
|
||||
/// ERNIE, from Baidu.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The line calls functions and the thinking checkpoints keep the channel open whatever the request
|
||||
/// says. The vision checkpoints are the exception, and the reason this family is written down at
|
||||
/// all: they run in a thinking and a non-thinking mode, and tool calling is not documented for them.
|
||||
/// Left to the assumption they would be offered tools nobody has said they can use.
|
||||
///
|
||||
/// The vision rule wins over the thinking rule by the latter stepping aside rather than by being
|
||||
/// less specific: ERNIE ships a checkpoint which is both, and two rules claiming it with the same
|
||||
/// right would be a coin toss.
|
||||
/// </remarks>
|
||||
public sealed class ErnieFamily : ModelFamily
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override ModelVendor Vendor => ModelVendor.BAIDU;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://ernie.baidu.com/blog/", new DateOnly(2026, 9, 12), "Ported unchanged from the ERNIE block of ProviderExtensions.OpenSource.cs.");
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Declare(ModelFamilyBuilder builder)
|
||||
{
|
||||
builder.Rule("ernie").AsSubstring()
|
||||
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
|
||||
.Apis(CHAT_COMPLETION_API);
|
||||
|
||||
builder.Rule("ernie").AsSubstring().AlsoContains("thinking").NotContains("vl").Inherits()
|
||||
.Reasoning(ReasoningSupport.ALWAYS);
|
||||
|
||||
builder.Rule("ernie").AsSubstring().AlsoContains("vl")
|
||||
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
|
||||
.Apis(CHAT_COMPLETION_API)
|
||||
.Reasoning(ReasoningSupport.OPTIONAL);
|
||||
}
|
||||
}
|
||||
30
app/MindWork AI Studio/Models/Cohere/AyaFamily.cs
Normal file
30
app/MindWork AI Studio/Models/Cohere/AyaFamily.cs
Normal file
@ -0,0 +1,30 @@
|
||||
using static AIStudio.Provider.Capability;
|
||||
|
||||
namespace AIStudio.Models.Cohere;
|
||||
|
||||
/// <summary>
|
||||
/// Aya, which comes from Cohere as well and was not built for tools.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Their documentation says it in as many words, which is why these are a family of their own
|
||||
/// rather than a variant of Command: everything the Command rules state would be wrong here.
|
||||
/// </remarks>
|
||||
public sealed class AyaFamily : ModelFamily
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override ModelVendor Vendor => ModelVendor.COHERE;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://docs.cohere.com/docs/aya", new DateOnly(2026, 9, 11), "Ported unchanged from the Aya block of ProviderExtensions.OpenSource.cs.");
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Declare(ModelFamilyBuilder builder)
|
||||
{
|
||||
builder.Rule("aya-expanse").AsSubstring()
|
||||
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
|
||||
.Apis(CHAT_COMPLETION_API);
|
||||
|
||||
builder.Rule("aya-vision").AsSubstring().Inherits()
|
||||
.Capabilities(MULTIPLE_IMAGE_INPUT);
|
||||
}
|
||||
}
|
||||
41
app/MindWork AI Studio/Models/Cohere/CommandFamily.cs
Normal file
41
app/MindWork AI Studio/Models/Cohere/CommandFamily.cs
Normal file
@ -0,0 +1,41 @@
|
||||
using static AIStudio.Provider.Capability;
|
||||
|
||||
namespace AIStudio.Models.Cohere;
|
||||
|
||||
/// <summary>
|
||||
/// Command, the Cohere line built for tool use.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Most of the line calls functions, in one step and in several, so the family states it. Command A
|
||||
/// Vision is the exception Cohere names outright: tool use is not supported with it.
|
||||
/// </remarks>
|
||||
public sealed class CommandFamily : ModelFamily
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override ModelVendor Vendor => ModelVendor.COHERE;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://docs.cohere.com/docs/models", new DateOnly(2026, 9, 11), "Ported unchanged from the Command block of ProviderExtensions.OpenSource.cs.");
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Declare(ModelFamilyBuilder builder)
|
||||
{
|
||||
builder.Rule("command-a").AsSubstring()
|
||||
.Capabilities(TEXT_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
|
||||
.Apis(CHAT_COMPLETION_API);
|
||||
|
||||
builder.Rule("command-r").AsSubstring().Inherits();
|
||||
|
||||
// Command A+ sees, and thinks unless the request turns the thinking off:
|
||||
builder.Rule("command-a-plus").AsSubstring().Inherits()
|
||||
.Capabilities(MULTIPLE_IMAGE_INPUT)
|
||||
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
|
||||
|
||||
builder.Rule("command-a-reasoning").AsSubstring().InheritsFrom("command-r")
|
||||
.Reasoning(ReasoningSupport.ON_BY_DEFAULT);
|
||||
|
||||
builder.Rule("command-a-vision").AsSubstring()
|
||||
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
|
||||
.Apis(CHAT_COMPLETION_API);
|
||||
}
|
||||
}
|
||||
57
app/MindWork AI Studio/Models/ContextWindow.cs
Normal file
57
app/MindWork AI Studio/Models/ContextWindow.cs
Normal file
@ -0,0 +1,57 @@
|
||||
namespace AIStudio.Models;
|
||||
|
||||
/// <summary>
|
||||
/// How much a model can read and write in one conversation, in tokens.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two numbers, because the model cards name two. There is what the model does as it ships, and
|
||||
/// there is what an operator can raise it to by configuring the engine, usually through one of the
|
||||
/// rope-scaling settings. A self-hosted model runs at whatever its operator chose, so the second
|
||||
/// number is a ceiling, not a promise.
|
||||
///
|
||||
/// Nothing here says "unknown" with a zero. The default value of this type is unknown, which is the
|
||||
/// right answer for a model nobody has written anything about yet, and a known window can never be
|
||||
/// zero tokens wide because the factory below refuses to build one.
|
||||
/// </remarks>
|
||||
public readonly record struct ContextWindow
|
||||
{
|
||||
/// <summary>
|
||||
/// The window of a model we have no statement about.
|
||||
/// </summary>
|
||||
public static readonly ContextWindow UNKNOWN = new();
|
||||
|
||||
/// <summary>
|
||||
/// Whether anything is known about this window at all. When false, both numbers are meaningless.
|
||||
/// </summary>
|
||||
public bool IsKnown { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// What the model reads and writes without anyone configuring it.
|
||||
/// </summary>
|
||||
public int DefaultTokens { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// What an operator can raise the window to, or null when it cannot be raised or nobody knows.
|
||||
/// </summary>
|
||||
public int? RaisableToTokens { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// States a known context window.
|
||||
/// </summary>
|
||||
/// <param name="defaultTokens">What the model does as it ships. Has to be greater than zero.</param>
|
||||
/// <param name="raisableTo">What an operator can raise it to. Has to be at least the default.</param>
|
||||
/// <returns>The window.</returns>
|
||||
public static ContextWindow Of(int defaultTokens, int? raisableTo = null)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(defaultTokens);
|
||||
if (raisableTo is not null)
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(raisableTo.Value, defaultTokens);
|
||||
|
||||
return new()
|
||||
{
|
||||
IsKnown = true,
|
||||
DefaultTokens = defaultTokens,
|
||||
RaisableToTokens = raisableTo,
|
||||
};
|
||||
}
|
||||
}
|
||||
78
app/MindWork AI Studio/Models/DeepSeek/DeepSeekFamily.cs
Normal file
78
app/MindWork AI Studio/Models/DeepSeek/DeepSeekFamily.cs
Normal file
@ -0,0 +1,78 @@
|
||||
using static AIStudio.Provider.Capability;
|
||||
|
||||
namespace AIStudio.Models.DeepSeek;
|
||||
|
||||
/// <summary>
|
||||
/// DeepSeek, from V3 to V4, including R1 and the checkpoints distilled from it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One family for all of it, and bound to no provider: DeepSeek publishes its models as open
|
||||
/// weights and offers them on its own platform under the very same names, so a rule written once
|
||||
/// answers wherever the model turns up. The old code arrived at that by having its DeepSeek
|
||||
/// function call the open weights function, which is one of the loops this rebuild is undoing.
|
||||
///
|
||||
/// The distills are the case the whole priority question came from. They are Llama and Qwen
|
||||
/// checkpoints fine-tuned on R1 answers, so they carry "r1" in their name and would be read as R1
|
||||
/// itself -- which would promise the tool calling they lost together with R1's chat template. Here
|
||||
/// the rule for them is the R1 rule with one condition more, and that alone decides it.
|
||||
///
|
||||
/// Point releases behind a dot need a line of their own, as everywhere: "deepseek-v4" does not
|
||||
/// answer for "deepseek-v4.1", because a dot separates versions rather than name parts.
|
||||
/// </remarks>
|
||||
public sealed class DeepSeekFamily : ModelFamily
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override ModelVendor Vendor => ModelVendor.DEEP_SEEK;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://api-docs.deepseek.com/quick_start/pricing", new DateOnly(2026, 9, 12), "Capabilities ported unchanged from ProviderExtensions.DeepSeek.cs and the DeepSeek block of ProviderExtensions.OpenSource.cs. The pricing page states a 1M window for the V4 models; the older lines are served at different sizes depending on who serves them, so no window is stated for them.");
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Declare(ModelFamilyBuilder builder)
|
||||
{
|
||||
builder.Rule("deepseek").AsSegment()
|
||||
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
|
||||
.Apis(CHAT_COMPLETION_API);
|
||||
|
||||
// The V3 line answers directly and calls functions:
|
||||
builder.Rule("deepseek-v3").AsPrefix().Inherits()
|
||||
.Capabilities(FUNCTION_CALLING);
|
||||
|
||||
//
|
||||
// From V3.1 on there is a thinking mode which the request turns on, and V3.2 added tool
|
||||
// calling inside it. The gateways write these either as "deepseek-v3.1" or as
|
||||
// "deepseek-chat-v3.1", so the version alone is what is looked for.
|
||||
//
|
||||
builder.Rule("deepseek").AsSegment().AlsoContains("v3.1").InheritsFrom("deepseek-v3")
|
||||
.Reasoning(ReasoningSupport.OPTIONAL);
|
||||
|
||||
builder.Rule("deepseek").AsSegment().AlsoContains("v3.2").InheritsFrom("deepseek-v3")
|
||||
.Reasoning(ReasoningSupport.OPTIONAL);
|
||||
|
||||
builder.Rule("deepseek-r1").AsPrefix().InheritsFrom("deepseek-v3")
|
||||
.Reasoning(ReasoningSupport.ALWAYS);
|
||||
|
||||
// The distills kept the chat template of the model they were built from, so none of the
|
||||
// tool calling R1 itself was trained for survived:
|
||||
builder.Rule("deepseek-r1").AsPrefix().AlsoContains("distill").Inherits()
|
||||
.Removes(FUNCTION_CALLING);
|
||||
|
||||
builder.Rule("deepseek-v4").AsPrefix().InheritsFrom("deepseek-v3")
|
||||
.Reasoning(ReasoningSupport.ON_BY_DEFAULT)
|
||||
.ContextWindow(1_000_000);
|
||||
|
||||
builder.Rule("deepseek-v4").AsPrefix().AlsoContains("vision").Inherits()
|
||||
.Capabilities(MULTIPLE_IMAGE_INPUT);
|
||||
|
||||
//
|
||||
// The two aliases of DeepSeek's own platform. They name a mode rather than a model: both
|
||||
// point at the current flash model, one with thinking and one without. Exactly these
|
||||
// names and no others -- "deepseek-chat-v3.1" is a gateway's name for a version, not this
|
||||
// alias.
|
||||
//
|
||||
builder.Rule("deepseek-chat").AsExact().InheritsFrom("deepseek-v3");
|
||||
|
||||
builder.Rule("deepseek-reasoner").AsExact().InheritsFrom("deepseek-v3")
|
||||
.Reasoning(ReasoningSupport.ALWAYS);
|
||||
}
|
||||
}
|
||||
91
app/MindWork AI Studio/Models/Google/GeminiFamily.cs
Normal file
91
app/MindWork AI Studio/Models/Google/GeminiFamily.cs
Normal file
@ -0,0 +1,91 @@
|
||||
using static AIStudio.Provider.Capability;
|
||||
|
||||
namespace AIStudio.Models.Google;
|
||||
|
||||
/// <summary>
|
||||
/// The Gemini chat models.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A Gemini reads everything -- text, images, audio, speech, video -- writes text, and calls tools.
|
||||
/// That is the first rule, and it is the family's own fallback for a Gemini nobody has written a
|
||||
/// rule for yet. What the generations add to it is how they think, and the older exceptions take
|
||||
/// something away instead.
|
||||
///
|
||||
/// Every generation gets a line of its own, including the dotted ones. The dot is a version
|
||||
/// boundary rather than a name part boundary, deliberately -- it is what keeps llama3 and llama3.1
|
||||
/// apart -- so a rule for "gemini-3" does not answer for "gemini-3.1", and each has to say so
|
||||
/// itself. The previous rules searched for "gemini-3" anywhere in the name and covered unreleased
|
||||
/// versions by accident; the price of not doing that is a line per generation, and the verification
|
||||
/// run names any model of the corpus which finds no rule.
|
||||
/// </remarks>
|
||||
public sealed class GeminiFamily : ModelFamily
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override ModelVendor Vendor => ModelVendor.GOOGLE;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://ai.google.dev/gemini-api/docs/gemini-3", new DateOnly(2026, 9, 12), "Capabilities ported unchanged from ProviderExtensions.Google.cs: one shape for all of Gemini, one sentence per generation about thinking. The Gemini 3 guide states a one million token input window; the 2.5 model pages state their input limit as 1,048,576, and both numbers are written here as their page gives them.");
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<ModelSource> FurtherSources =>
|
||||
[
|
||||
new("https://ai.google.dev/gemini-api/docs/image-understanding", new DateOnly(2026, 9, 12), "States one number for the whole family: \"Gemini models support a maximum of 3,600 image files per request.\" The 20 MB it also names is a limit on the request body rather than on the number of images."),
|
||||
new("https://ai.google.dev/gemini-api/docs/tokens", new DateOnly(2026, 9, 12), "Google publishes no tokenizer file for Gemini. Counting happens through the countTokens method of the API, which returns the number of tokens of the input alone.")
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Declare(ModelFamilyBuilder builder)
|
||||
{
|
||||
//
|
||||
// Google states the image limit once, for all of Gemini, so it sits on the fallback and
|
||||
// every generation inherits it. The two rules below which do not inherit from here say
|
||||
// nothing about it: the live model looks at no still images at all, and for the 1.0 vision
|
||||
// model Google's current pages state no number any more.
|
||||
//
|
||||
builder.Rule("gemini").AsSegment()
|
||||
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | AUDIO_INPUT | SPEECH_INPUT | VIDEO_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
|
||||
.Apis(CHAT_COMPLETION_API)
|
||||
.Images(maxPerRequest: 3_600)
|
||||
.Tokenizer(TokenizerKind.PROVIDER_API, "countTokens");
|
||||
|
||||
// The one Gemini which only ever read text and images:
|
||||
builder.Rule("gemini-1.0-pro-vision").AsPrefix()
|
||||
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
|
||||
.Apis(CHAT_COMPLETION_API);
|
||||
|
||||
//
|
||||
// The live model, which belongs to a different API: it speaks back, and it is the one
|
||||
// Gemini that does not look at still images.
|
||||
//
|
||||
builder.Rule("gemini-2.0-flash-live").AsPrefix()
|
||||
.Capabilities(TEXT_INPUT | AUDIO_INPUT | SPEECH_INPUT | VIDEO_INPUT | TEXT_OUTPUT | SPEECH_OUTPUT | FUNCTION_CALLING)
|
||||
.Apis(CHAT_COMPLETION_API);
|
||||
|
||||
//
|
||||
// Google states an input limit and an output limit rather than one window. The input limit
|
||||
// is the one a conversation is measured against, because that is where the conversation
|
||||
// accumulates, so that is the number written here.
|
||||
//
|
||||
builder.Rule("gemini-2.5").AsPrefix().InheritsFrom("gemini")
|
||||
.Reasoning(ReasoningSupport.ALWAYS)
|
||||
.ContextWindow(1_048_576);
|
||||
|
||||
//
|
||||
// The one exception of the 2.5 line: it can think, but only when asked. From the 3.x line
|
||||
// on, even the Flash Lite models think at their lowest level.
|
||||
//
|
||||
builder.Rule("gemini-2.5-flash-lite").AsPrefix().InheritsFrom("gemini-2.5")
|
||||
.Reasoning(ReasoningSupport.OPTIONAL);
|
||||
|
||||
builder.Rule("gemini-3").AsPrefix().InheritsFrom("gemini")
|
||||
.Reasoning(ReasoningSupport.ALWAYS)
|
||||
.ContextWindow(1_000_000);
|
||||
|
||||
builder.Rule("gemini-3.1").AsPrefix().InheritsFrom("gemini-3");
|
||||
builder.Rule("gemini-3.7").AsPrefix().InheritsFrom("gemini-3");
|
||||
|
||||
// The two rolling aliases, which carry no version number and point at the current line:
|
||||
builder.Rule("gemini-flash-latest").AsExact().InheritsFrom("gemini-3");
|
||||
builder.Rule("gemini-pro-latest").AsExact().InheritsFrom("gemini-3");
|
||||
}
|
||||
}
|
||||
42
app/MindWork AI Studio/Models/Google/GeminiImageFamily.cs
Normal file
42
app/MindWork AI Studio/Models/Google/GeminiImageFamily.cs
Normal file
@ -0,0 +1,42 @@
|
||||
using static AIStudio.Provider.Capability;
|
||||
|
||||
namespace AIStudio.Models.Google;
|
||||
|
||||
/// <summary>
|
||||
/// The Gemini models which draw as well as write.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// They are named like every other Gemini, with a version and a size, and the only thing setting
|
||||
/// them apart is the name part "image". So the rules here are the generation rules of the chat
|
||||
/// family with that one part required on top, and requiring it is exactly what makes them win: two
|
||||
/// rules reaching equally far into a name are separated by how many conditions they carry.
|
||||
///
|
||||
/// What they can do is nearly the opposite of what their generation can. They write images, which
|
||||
/// no chat Gemini does, and they call no tools, which every chat Gemini does. Reading them as chat
|
||||
/// models of their line -- which is what happens when nobody asks about the image part first --
|
||||
/// promises tool calling that is not there.
|
||||
/// </remarks>
|
||||
public sealed class GeminiImageFamily : ModelFamily
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override ModelVendor Vendor => ModelVendor.GOOGLE;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://ai.google.dev/gemini-api/docs/image-generation", new DateOnly(2026, 9, 11), "Ported unchanged from the rules in ProviderExtensions.Google.cs: images out, no tool calling, and thinking from the 3 line on.");
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Declare(ModelFamilyBuilder builder)
|
||||
{
|
||||
builder.Rule("gemini-2.5").AsPrefix().AlsoContains("image")
|
||||
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | IMAGE_OUTPUT)
|
||||
.Apis(CHAT_COMPLETION_API);
|
||||
|
||||
// From the 3 line on they think about a complicated prompt, and it cannot be switched off:
|
||||
builder.Rule("gemini-3").AsPrefix().AlsoContains("image").Inherits()
|
||||
.Reasoning(ReasoningSupport.ALWAYS);
|
||||
|
||||
// Only the 3.1 Flash image models watch video:
|
||||
builder.Rule("gemini-3.1").AsPrefix().AlsoContains("image").Inherits()
|
||||
.Capabilities(VIDEO_INPUT);
|
||||
}
|
||||
}
|
||||
78
app/MindWork AI Studio/Models/Google/GemmaFamily.cs
Normal file
78
app/MindWork AI Studio/Models/Google/GemmaFamily.cs
Normal file
@ -0,0 +1,78 @@
|
||||
using static AIStudio.Provider.Capability;
|
||||
|
||||
namespace AIStudio.Models.Google;
|
||||
|
||||
/// <summary>
|
||||
/// Gemma, the open weights Google publishes next to Gemini.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two generations and one spelling problem. Ollama writes "gemma3:27b" and the hub writes
|
||||
/// "gemma-3-27b-it", and no normalization turns one into the other, so each statement stands twice.
|
||||
/// What it buys is that the rules never have to ask who served the model.
|
||||
///
|
||||
/// Tool calling is the line between the generations. What Google documents for Gemma 3 is writing
|
||||
/// the tool descriptions into the prompt by hand, which is a different thing from what the tools
|
||||
/// field of an OpenAI-compatible request does: the chat template has neither a tool role nor tool
|
||||
/// tokens, and Ollama refuses a request carrying tools for these models. Gemma 4 is the first with
|
||||
/// tokens of its own, and the first that thinks -- when the request opens the thinking channel.
|
||||
/// </remarks>
|
||||
public sealed class GemmaFamily : ModelFamily
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override ModelVendor Vendor => ModelVendor.GOOGLE;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://ai.google.dev/gemma/docs/core", new DateOnly(2026, 9, 11), "Ported unchanged from the Gemma block of ProviderExtensions.OpenSource.cs.");
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Declare(ModelFamilyBuilder builder)
|
||||
{
|
||||
// The early generations take text only and were not built for tools:
|
||||
builder.Rule("gemma").AsSubstring()
|
||||
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
|
||||
.Apis(CHAT_COMPLETION_API);
|
||||
|
||||
// Gemma 3 reads pictures from the 4B checkpoint upwards:
|
||||
builder.Rule("gemma3").AsSubstring()
|
||||
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT)
|
||||
.Apis(CHAT_COMPLETION_API);
|
||||
|
||||
builder.Rule("gemma-3").AsSubstring().Inherits();
|
||||
|
||||
// The 1B checkpoint is the one that does not:
|
||||
builder.Rule("gemma3").AsSubstring().AlsoContains("1b")
|
||||
.Capabilities(TEXT_INPUT | TEXT_OUTPUT)
|
||||
.Apis(CHAT_COMPLETION_API);
|
||||
|
||||
builder.Rule("gemma-3").AsSubstring().AlsoContains("1b").Inherits();
|
||||
|
||||
//
|
||||
// The 3n checkpoints listen as well. Video is not a modality of any Gemma: the model cards
|
||||
// list text, image, and audio, and mention video only as frames somebody else cut it into.
|
||||
//
|
||||
builder.Rule("gemma3n").AsSubstring()
|
||||
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | AUDIO_INPUT | TEXT_OUTPUT)
|
||||
.Apis(CHAT_COMPLETION_API);
|
||||
|
||||
builder.Rule("gemma-3n").AsSubstring().Inherits();
|
||||
|
||||
// Every checkpoint of Gemma 4 is multimodal; there is no text-only variant of it:
|
||||
builder.Rule("gemma4").AsSubstring()
|
||||
.Capabilities(TEXT_INPUT | MULTIPLE_IMAGE_INPUT | TEXT_OUTPUT | FUNCTION_CALLING)
|
||||
.Apis(CHAT_COMPLETION_API)
|
||||
.Reasoning(ReasoningSupport.OPTIONAL);
|
||||
|
||||
builder.Rule("gemma-4").AsSubstring().Inherits();
|
||||
|
||||
// Three of its checkpoints hear, and they are named one by one because that is all they
|
||||
// have in common:
|
||||
builder.Rule("gemma4").AsSubstring().AlsoContains("e2b").Inherits().Capabilities(AUDIO_INPUT);
|
||||
builder.Rule("gemma-4").AsSubstring().AlsoContains("e2b").Inherits();
|
||||
|
||||
builder.Rule("gemma4").AsSubstring().AlsoContains("e4b").Inherits();
|
||||
builder.Rule("gemma-4").AsSubstring().AlsoContains("e4b").Inherits();
|
||||
|
||||
builder.Rule("gemma4").AsSubstring().AlsoContains("12b").Inherits();
|
||||
builder.Rule("gemma-4").AsSubstring().AlsoContains("12b").Inherits();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
using AIStudio.Provider;
|
||||
|
||||
using static AIStudio.Provider.Capability;
|
||||
|
||||
namespace AIStudio.Models.Google;
|
||||
|
||||
/// <summary>
|
||||
/// Google's embedding models, which turn text into a vector and answer nothing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The previous rules answered for these with the Google default: images in, text out, tool
|
||||
/// calling. None of it is true, and the app already knows better -- it asks every provider for its
|
||||
/// embedding models through a method of its own.
|
||||
///
|
||||
/// The Gemini one needs a rule of its own for another reason: its name begins with "gemini", so
|
||||
/// without one it would be read as a chat model of the family.
|
||||
/// </remarks>
|
||||
public sealed class GoogleEmbeddingFamily : ModelFamily
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override ModelVendor Vendor => ModelVendor.GOOGLE;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://ai.google.dev/gemini-api/docs/embeddings", new DateOnly(2026, 9, 11), "The app lists these under IProvider.GetEmbeddingModels, which is where the statement that they embed comes from.");
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Declare(ModelFamilyBuilder builder)
|
||||
{
|
||||
builder.Rule("text-embedding-004").AsExact()
|
||||
.Capabilities(TEXT_INPUT | EMBEDDING)
|
||||
.Kind(ModelKind.EMBEDDING);
|
||||
|
||||
builder.Rule("gemini-embedding").AsPrefix().Inherits();
|
||||
}
|
||||
}
|
||||
32
app/MindWork AI Studio/Models/Google/ImagenFamily.cs
Normal file
32
app/MindWork AI Studio/Models/Google/ImagenFamily.cs
Normal file
@ -0,0 +1,32 @@
|
||||
using AIStudio.Provider;
|
||||
|
||||
using static AIStudio.Provider.Capability;
|
||||
|
||||
namespace AIStudio.Models.Google;
|
||||
|
||||
/// <summary>
|
||||
/// Imagen, which draws a picture from a description and does nothing else.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The previous rules had no branch for it. Its name does not contain "gemini", so it fell to the
|
||||
/// last line of the Google function and was answered as a chat model: reads images, writes text,
|
||||
/// calls functions. Not one of the three is true, and the one thing it does -- writing an image --
|
||||
/// was not said at all.
|
||||
///
|
||||
/// Whole name parts, not a substring: "imagen" also sits inside "imagenet" and "reimagined", and a
|
||||
/// chat model carrying such a word would be turned into an image generator by a careless match.
|
||||
/// </remarks>
|
||||
public sealed class ImagenFamily : ModelFamily
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override ModelVendor Vendor => ModelVendor.GOOGLE;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://ai.google.dev/gemini-api/docs/imagen", new DateOnly(2026, 9, 11), "A description goes in and an image comes out; there is no conversation and no tool calling.");
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Declare(ModelFamilyBuilder builder) =>
|
||||
builder.Rule("imagen").AsSegment()
|
||||
.Capabilities(TEXT_INPUT | IMAGE_OUTPUT)
|
||||
.Kind(ModelKind.IMAGE_GENERATION);
|
||||
}
|
||||
183
app/MindWork AI Studio/Models/Hosting/HostNaming.cs
Normal file
183
app/MindWork AI Studio/Models/Hosting/HostNaming.cs
Normal file
@ -0,0 +1,183 @@
|
||||
using AIStudio.Models.Matching;
|
||||
|
||||
namespace AIStudio.Models.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// The ways a host wraps a model name, and how to take one wrapping off again.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A wrapping is worked on the name as the provider reported it, never on the normalized one. That
|
||||
/// is not a detail: normalizing writes every separator as a hyphen, so "meta-llama/Llama-3.3-70B"
|
||||
/// and "meta-llama-llama-3.3-70b" are the same text afterwards and nobody can say where the
|
||||
/// organization ended. The slash, the colon, and the spaces are the whole evidence, and they only
|
||||
/// exist in the original.
|
||||
/// </remarks>
|
||||
public static class HostNaming
|
||||
{
|
||||
/// <summary>
|
||||
/// What separates the organization from the model on a hub.
|
||||
/// </summary>
|
||||
private const char ORGANIZATION_SEPARATOR = '/';
|
||||
|
||||
/// <summary>
|
||||
/// What separates the model from the inference provider it should be routed to.
|
||||
/// </summary>
|
||||
private const char ROUTING_SEPARATOR = ':';
|
||||
|
||||
/// <summary>
|
||||
/// Takes the organization off a hub style name.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Hubs and gateways write "organization/model", and a few hosts put a whole path in front:
|
||||
/// Fireworks answers with "accounts/fireworks/models/llama-v3p1-405b-instruct". Taking one
|
||||
/// segment at a time is what covers both without a second rule -- the caller keeps asking until
|
||||
/// nothing is left to take.
|
||||
/// </remarks>
|
||||
/// <param name="id">The name as it arrived.</param>
|
||||
/// <param name="inner">The name without its first path segment.</param>
|
||||
/// <param name="declaredVendor">Who the organization says built the model, when we recognize it.</param>
|
||||
/// <returns>True, when there was an organization to take off.</returns>
|
||||
public static bool TrySplitOrganization(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor)
|
||||
{
|
||||
inner = id;
|
||||
declaredVendor = null;
|
||||
|
||||
var separatorIndex = id.Original.IndexOf(ORGANIZATION_SEPARATOR);
|
||||
if (separatorIndex is -1)
|
||||
return false;
|
||||
|
||||
var model = id.Original[(separatorIndex + 1)..];
|
||||
if (string.IsNullOrWhiteSpace(model))
|
||||
return false;
|
||||
|
||||
inner = new(model);
|
||||
|
||||
//
|
||||
// An organization nobody recognizes says nothing rather than saying "unknown": the rules
|
||||
// may still work out who built the model from its name, and a stated vendor would stop
|
||||
// them from trying.
|
||||
//
|
||||
var vendor = VendorOfOrganization(id.Original[..separatorIndex]);
|
||||
declaredVendor = vendor is ModelVendor.UNKNOWN ? null : vendor;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the routing suffix off a name.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The suffix says where a request goes, not what the model is: "google/gemma-4-31B-it:novita"
|
||||
/// is the same model as "google/gemma-4-31B-it". Names on the hub carry no colon of their own,
|
||||
/// so the last one always starts the suffix. This is not true everywhere -- Ollama writes the
|
||||
/// variant after a colon, as in "qwen3.8:27b-mlx", and taking that off would throw away which
|
||||
/// model it is. That is why only the host which has a router asks for this.
|
||||
/// </remarks>
|
||||
/// <param name="id">The name as it arrived.</param>
|
||||
/// <param name="inner">The name without its routing suffix.</param>
|
||||
/// <returns>True, when there was a suffix to take off.</returns>
|
||||
public static bool TryStripRoutingSuffix(in ModelId id, out ModelId inner)
|
||||
{
|
||||
inner = id;
|
||||
|
||||
var separatorIndex = id.Original.LastIndexOf(ROUTING_SEPARATOR);
|
||||
if (separatorIndex is -1)
|
||||
return false;
|
||||
|
||||
var model = id.Original[..separatorIndex];
|
||||
if (string.IsNullOrWhiteSpace(model))
|
||||
return false;
|
||||
|
||||
inner = new(model);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the position in a menu off a name.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Blablador answers with the line a person would read in a list: "1 - Llama3 405 the best
|
||||
/// general model". The leading number is where the model sits in that list, and it changes
|
||||
/// whenever the operator adds one.
|
||||
///
|
||||
/// The spaces around the hyphen are what makes this safe to ask. A number followed directly by
|
||||
/// a hyphen is an ordinary part of a name -- "70b-instruct" would lose the size it is named
|
||||
/// after -- so only the spaced form counts as a menu position.
|
||||
/// </remarks>
|
||||
/// <param name="id">The name as it arrived.</param>
|
||||
/// <param name="inner">The name without its leading number.</param>
|
||||
/// <returns>True, when there was a menu position to take off.</returns>
|
||||
public static bool TryStripMenuPosition(in ModelId id, out ModelId inner)
|
||||
{
|
||||
inner = id;
|
||||
|
||||
var text = id.Original.AsSpan();
|
||||
var digits = 0;
|
||||
while (digits < text.Length && char.IsAsciiDigit(text[digits]))
|
||||
digits++;
|
||||
|
||||
if (digits is 0)
|
||||
return false;
|
||||
|
||||
var afterDigits = text[digits..];
|
||||
if (afterDigits.IsEmpty || afterDigits[0] is not ' ')
|
||||
return false;
|
||||
|
||||
var afterSpace = afterDigits.TrimStart();
|
||||
if (afterSpace.IsEmpty || afterSpace[0] is not '-')
|
||||
return false;
|
||||
|
||||
var afterHyphen = afterSpace[1..];
|
||||
if (afterHyphen.IsEmpty || afterHyphen[0] is not ' ')
|
||||
return false;
|
||||
|
||||
var model = afterHyphen.TrimStart();
|
||||
if (model.IsEmpty)
|
||||
return false;
|
||||
|
||||
inner = new(model.ToString());
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Who an organization on a hub stands for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Hubs name the organization which published the weights, which is who built the model. The
|
||||
/// spellings are theirs, not ours, which is why several of them appear twice: the same vendor
|
||||
/// publishes under one name on one hub and another name on the next. Anything not listed is
|
||||
/// somebody we have no rules for yet, and saying so is the honest answer.
|
||||
/// </remarks>
|
||||
/// <param name="organization">The organization as the host wrote it, in any casing.</param>
|
||||
/// <returns>The vendor, or unknown.</returns>
|
||||
public static ModelVendor VendorOfOrganization(string organization) => organization.ToLowerInvariant() switch
|
||||
{
|
||||
"openai" => ModelVendor.OPEN_AI,
|
||||
"anthropic" => ModelVendor.ANTHROPIC,
|
||||
"google" => ModelVendor.GOOGLE,
|
||||
"mistral" or "mistralai" => ModelVendor.MISTRAL_AI,
|
||||
"meta" or "meta-llama" => ModelVendor.META,
|
||||
"alibaba" or "qwen" => ModelVendor.ALIBABA,
|
||||
"deepseek" or "deepseek-ai" => ModelVendor.DEEP_SEEK,
|
||||
"perplexity" => ModelVendor.PERPLEXITY,
|
||||
"x-ai" or "xai" => ModelVendor.XAI,
|
||||
"microsoft" => ModelVendor.MICROSOFT,
|
||||
"nvidia" => ModelVendor.NVIDIA,
|
||||
"ibm-granite" => ModelVendor.IBM,
|
||||
"cohere" or "coherelabs" or "cohereforai" => ModelVendor.COHERE,
|
||||
"moonshot" or "moonshotai" => ModelVendor.MOONSHOT_AI,
|
||||
"tencent" or "tencent-hunyuan" => ModelVendor.TENCENT,
|
||||
"z-ai" or "zai-org" => ModelVendor.Z_AI,
|
||||
"minimax" or "minimaxai" => ModelVendor.MINIMAX,
|
||||
"ai2" or "allenai" => ModelVendor.AI2,
|
||||
"bytedance" or "bytedance-seed" => ModelVendor.BYTE_DANCE,
|
||||
"tii" or "tiiuae" => ModelVendor.TII,
|
||||
"inclusionai" => ModelVendor.INCLUSION_AI,
|
||||
"baidu" or "baidu-ernie" => ModelVendor.BAIDU,
|
||||
"huggingfacetb" => ModelVendor.HUGGING_FACE,
|
||||
"servicenow" or "servicenow-ai" => ModelVendor.SERVICE_NOW,
|
||||
"internlm" or "opengvlab" or "shanghai-ai-laboratory" => ModelVendor.SHANGHAI_AI_LAB,
|
||||
"swiss-ai" => ModelVendor.SWISS_AI,
|
||||
|
||||
_ => ModelVendor.UNKNOWN,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
using AIStudio.Provider;
|
||||
|
||||
namespace AIStudio.Models.Hosting.Hosts;
|
||||
|
||||
/// <summary>
|
||||
/// Alibaba Cloud Model Studio.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Worth knowing about this one: several names mean a different model here than they do anywhere
|
||||
/// else. "qwq" is the commercial qwq-plus on Model Studio and the open weights everywhere else.
|
||||
/// That is not settled here but in the rules, which can bind themselves to a provider -- this host
|
||||
/// exists so that they have a provider to bind to.
|
||||
/// </remarks>
|
||||
public sealed class HostAlibabaCloud : ModelHost
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override LLMProviders Provider => LLMProviders.ALIBABA_CLOUD;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://www.alibabacloud.com/help/en/model-studio/compatibility-of-openai-with-dashscope", new DateOnly(2026, 9, 11), "Models are named plainly, and the app reaches them through the OpenAI-compatible endpoint.");
|
||||
}
|
||||
15
app/MindWork AI Studio/Models/Hosting/Hosts/HostAnthropic.cs
Normal file
15
app/MindWork AI Studio/Models/Hosting/Hosts/HostAnthropic.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using AIStudio.Provider;
|
||||
|
||||
namespace AIStudio.Models.Hosting.Hosts;
|
||||
|
||||
/// <summary>
|
||||
/// Anthropic's own cloud.
|
||||
/// </summary>
|
||||
public sealed class HostAnthropic : ModelHost
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override LLMProviders Provider => LLMProviders.ANTHROPIC;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://docs.anthropic.com/en/api/messages", new DateOnly(2026, 9, 11), "Models are named plainly, and there is one API to reach them through.");
|
||||
}
|
||||
20
app/MindWork AI Studio/Models/Hosting/Hosts/HostDeepSeek.cs
Normal file
20
app/MindWork AI Studio/Models/Hosting/Hosts/HostDeepSeek.cs
Normal file
@ -0,0 +1,20 @@
|
||||
using AIStudio.Provider;
|
||||
|
||||
namespace AIStudio.Models.Hosting.Hosts;
|
||||
|
||||
/// <summary>
|
||||
/// DeepSeek's own platform.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It names its models by what they are for rather than by which checkpoint answers: "deepseek-chat"
|
||||
/// and "deepseek-reasoner" both point at whatever is current. Those are aliases, not wrappings, so
|
||||
/// there is nothing to take off -- the rules answer for the alias itself.
|
||||
/// </remarks>
|
||||
public sealed class HostDeepSeek : ModelHost
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override LLMProviders Provider => LLMProviders.DEEP_SEEK;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://api-docs.deepseek.com/", new DateOnly(2026, 9, 11), "Models are named plainly, and the app reaches them through the OpenAI-compatible endpoint.");
|
||||
}
|
||||
25
app/MindWork AI Studio/Models/Hosting/Hosts/HostFireworks.cs
Normal file
25
app/MindWork AI Studio/Models/Hosting/Hosts/HostFireworks.cs
Normal file
@ -0,0 +1,25 @@
|
||||
using AIStudio.Models.Matching;
|
||||
using AIStudio.Provider;
|
||||
|
||||
namespace AIStudio.Models.Hosting.Hosts;
|
||||
|
||||
/// <summary>
|
||||
/// Fireworks AI, which puts a whole account path in front of every model.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// "accounts/fireworks/models/llama-v3p1-405b-instruct" is three segments of path and then the
|
||||
/// model. Nothing here counts them: the same taking-off-one-segment the gateways use is asked
|
||||
/// again until there is no path left. None of the three segments names a vendor we know, so none
|
||||
/// of them claims to.
|
||||
/// </remarks>
|
||||
public sealed class HostFireworks : ModelHost
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override LLMProviders Provider => LLMProviders.FIREWORKS;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://fireworks.ai/models?show=Serverless", new DateOnly(2026, 9, 11), "Models are named \"accounts/<account>/models/<model>\", served through the OpenAI-compatible chat completion API.");
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor) => HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
|
||||
}
|
||||
26
app/MindWork AI Studio/Models/Hosting/Hosts/HostGWDG.cs
Normal file
26
app/MindWork AI Studio/Models/Hosting/Hosts/HostGWDG.cs
Normal file
@ -0,0 +1,26 @@
|
||||
using AIStudio.Provider;
|
||||
|
||||
namespace AIStudio.Models.Hosting.Hosts;
|
||||
|
||||
/// <summary>
|
||||
/// The GWDG's academic cloud, which resells commercial models next to the open weights it runs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the host the transport rule was written for. It offers Claude and GPT under the very
|
||||
/// names their vendors use -- "claude-sonnet-5", "gpt-5.5" -- so the rules recognize them and
|
||||
/// answer with everything those models can do at their vendor. Everything except the API: a request
|
||||
/// goes to Göttingen, not to San Francisco, and the Responses API is not served there.
|
||||
///
|
||||
/// The old code arrived at the same answer by having the open weights rules notice a Claude name
|
||||
/// and call the Anthropic rules, then correct the result. Here the recognizing and the correcting
|
||||
/// are two different things in two different places, which is why neither has to know about the
|
||||
/// other.
|
||||
/// </remarks>
|
||||
public sealed class HostGWDG : ModelHost
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override LLMProviders Provider => LLMProviders.GWDG;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://docs.hpc.gwdg.de/services/saia/index.html", new DateOnly(2026, 9, 11), "Open weights and resold commercial models alike are named plainly, and all of them are served through the OpenAI-compatible chat completion API.");
|
||||
}
|
||||
15
app/MindWork AI Studio/Models/Hosting/Hosts/HostGoogle.cs
Normal file
15
app/MindWork AI Studio/Models/Hosting/Hosts/HostGoogle.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using AIStudio.Provider;
|
||||
|
||||
namespace AIStudio.Models.Hosting.Hosts;
|
||||
|
||||
/// <summary>
|
||||
/// Google's own cloud.
|
||||
/// </summary>
|
||||
public sealed class HostGoogle : ModelHost
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override LLMProviders Provider => LLMProviders.GOOGLE;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://ai.google.dev/gemini-api/docs/openai", new DateOnly(2026, 9, 11), "Models are named plainly, and the app reaches them through the OpenAI-compatible endpoint.");
|
||||
}
|
||||
24
app/MindWork AI Studio/Models/Hosting/Hosts/HostGroq.cs
Normal file
24
app/MindWork AI Studio/Models/Hosting/Hosts/HostGroq.cs
Normal file
@ -0,0 +1,24 @@
|
||||
using AIStudio.Models.Matching;
|
||||
using AIStudio.Provider;
|
||||
|
||||
namespace AIStudio.Models.Hosting.Hosts;
|
||||
|
||||
/// <summary>
|
||||
/// Groq, which serves open weights and writes some of their names the way the hub does.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both spellings appear side by side in its catalog: "llama-3.3-70b-versatile" carries no
|
||||
/// organization, "moonshotai/kimi-k2-instruct" and "openai/gpt-oss-120b" do. Taking one off when
|
||||
/// there is one settles both without a rule per spelling.
|
||||
/// </remarks>
|
||||
public sealed class HostGroq : ModelHost
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override LLMProviders Provider => LLMProviders.GROQ;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ModelSource Source => new("https://console.groq.com/docs/api-reference", new DateOnly(2026, 9, 11), "Models are named either plainly or as the hub names them, and served through the OpenAI-compatible chat completion API.");
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool TryUnwrap(in ModelId id, out ModelId inner, out ModelVendor? declaredVendor) => HostNaming.TrySplitOrganization(id, out inner, out declaredVendor);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user