From 2509fc09d168a0a107378ccd0f33c95e0a183be9 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sat, 12 Sep 2026 19:41:28 +0200 Subject: [PATCH] Say when a chat carries more images than the model takes --- .../Assistants/I18N/allTexts.lua | 3 + .../Chat/ConversationTokens.cs | 19 ++++ .../Components/ChatComponent.razor.cs | 32 +++++-- .../plugin.lua | 3 + .../plugin.lua | 3 + .../Services/ConversationTokenCounter.cs | 1 + app/Tests/Chat/ConversationTokensTests.cs | 96 +++++++++++++++++++ 7 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 app/Tests/Chat/ConversationTokensTests.cs diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 00ff36d1..8ec0f6b5 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -3577,6 +3577,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1992478915"] = "approx. { -- 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" diff --git a/app/MindWork AI Studio/Chat/ConversationTokens.cs b/app/MindWork AI Studio/Chat/ConversationTokens.cs index 838950ff..79836cfa 100644 --- a/app/MindWork AI Studio/Chat/ConversationTokens.cs +++ b/app/MindWork AI Studio/Chat/ConversationTokens.cs @@ -61,4 +61,23 @@ public readonly record struct ConversationTokens /// orders of magnitude longer than what any vendor charges for the picture. /// public int UncountedImages { get; init; } + + /// + /// How many images the model takes, where its vendor stated a number. + /// + public ImageLimits ImageLimits { get; init; } + + /// + /// Whether more images travel than the model is documented to accept. + /// + /// + /// 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. + /// + public bool TooManyImages => this.ImageLimits.MaxInOneMessage is { } allowed && this.UncountedImages > allowed; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index b43799e2..7c50940c 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -181,7 +181,16 @@ public partial class ChatComponent : MSGComponentBase if (this.conversationTokens.UncountedImages is 0) return budget; - return $"{budget} {string.Format(this.T("plus {0} image(s), which cannot be counted"), this.conversationTokens.UncountedImages)}"; + // + // 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}"; } } @@ -665,14 +674,25 @@ public partial class ChatComponent : MSGComponentBase /// 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. /// - private string TokenBudgetClass => this.TokenBudgetFill switch + private string TokenBudgetClass { - >= 1d => "token-budget-exceeded", - >= WINDOW_NEARLY_FULL => "token-budget-nearly-full", + 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"; - _ => string.Empty, - }; + return this.TokenBudgetFill >= WINDOW_NEARLY_FULL ? "token-budget-nearly-full" : string.Empty; + } + } private void ApplyStandardDataSourceOptions() { diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index c259fe5c..2454166f 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -3579,6 +3579,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1992478915"] = "ca. {0} v -- 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} Bild(er), also mehr als die {1}, die dieses Modell akzeptiert" + -- Italic UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2377171085"] = "Kursiv" diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 8e5d53f9..85789b27 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -3579,6 +3579,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1992478915"] = "approx. { -- 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" diff --git a/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs b/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs index fa491863..675d1ab1 100644 --- a/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs +++ b/app/MindWork AI Studio/Tools/Services/ConversationTokenCounter.cs @@ -122,6 +122,7 @@ public sealed class ConversationTokenCounter(RustService rustService, ILogger +/// Checks what the chat says about the images a conversation carries. +/// +/// +/// The visual briefing refuses a build with too many pictures, because that build is expensive and +/// fails late. A chat cannot refuse anything: the pictures are already in the conversation, and +/// taking them back out means deleting messages. So the chat says so instead, and what is checked +/// here is that it says so at the right moment -- and, more importantly, that it stays quiet when +/// nobody wrote a limit down. +/// +[TestFixture] +public sealed class ConversationTokensTests +{ + [TestCase(1, 100, false)] + [TestCase(100, 100, false, Description = "Exactly the limit still fits. It is a maximum, not a threshold.")] + [TestCase(101, 100, true)] + [TestCase(3_601, 3_600, true)] + public void TooManyPicturesIsAQuestionOfTheNumberTheVendorStated(int images, int allowed, bool tooMany) + { + var counted = new ConversationTokens + { + IsKnown = true, + UncountedImages = images, + ImageLimits = new ImageLimits(null, allowed), + }; + + Assert.That(counted.TooManyImages, Is.EqualTo(tooMany)); + } + + [Test] + public void WithoutAStatedLimitThereIsNoSuchThingAsTooMany() + { + // + // The common case. Most models are served at whatever their operator configured, and an app + // which warned about the seventh picture would be inventing a ceiling nobody wrote. + // + var counted = new ConversationTokens + { + IsKnown = true, + UncountedImages = 500, + ImageLimits = ImageLimits.UNKNOWN, + }; + + Assert.That(counted.TooManyImages, Is.False); + } + + [Test] + public void TheSmallerOfTwoStatedLimitsIsTheOneWhichDecides() + { + // + // A message is part of a request, so a conversation which fits the request limit can still + // be too much for one message. Both are compared against the same number of pictures, + // because a chat sends all of them in one message. + // + var counted = new ConversationTokens + { + IsKnown = true, + UncountedImages = 20, + ImageLimits = new ImageLimits(8, 100), + }; + + Assert.Multiple(() => + { + Assert.That(counted.TooManyImages, Is.True); + Assert.That(counted.ImageLimits.MaxInOneMessage, Is.EqualTo(8)); + }); + } + + [Test] + public void AConversationWithoutPicturesNeverComplainsAboutThem() + { + var counted = new ConversationTokens + { + IsKnown = true, + UncountedImages = 0, + ImageLimits = new ImageLimits(null, 0), + }; + + Assert.That(counted.TooManyImages, Is.False, "Not even against a model which takes none at all."); + } + + [Test] + public void AnUnavailableCountClaimsNothingAboutPictures() + { + // + // Nothing could be counted, so nothing is known -- including how many pictures travel. A + // warning built on that would be made up. + // + Assert.That(ConversationTokens.UNAVAILABLE.TooManyImages, Is.False); + } +} \ No newline at end of file