mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-25 21:44:26 +00:00
Fixed assistants undoing your changes while a run you left continues (#1111)
This commit is contained in:
1 parent
31c271eb7b
commit
7f72da628e
8 files changed
+322
-6
No files matched your search
@@ -181,6 +181,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner.ForAssistant(this.assistantSessionKey);
|
||||
private Guid? assistantSessionId;
|
||||
private AssistantSessionSnapshot? pendingRenderedAssistantSessionSnapshot;
|
||||
private AssistantSessionSnapshot? lastAttachedAssistantSessionSnapshot;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the Blazor component instance has already been disposed.
|
||||
@@ -1042,13 +1043,21 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
/// <summary>
|
||||
/// Applies an assistant session snapshot to this component instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When the user leaves an assistant while it runs and comes back, this component only shows the run.
|
||||
/// The component which started it keeps working out of sight and reports every few seconds, yet its
|
||||
/// form stays as it was when the user left. So once this component has taken over a run, it takes over
|
||||
/// only what the running assistant changed since, such as the progress and the result. What the user
|
||||
/// changes here stays, just as it would in the component which started the run.
|
||||
/// </remarks>
|
||||
/// <param name="snapshot">The snapshot to attach.</param>
|
||||
/// <param name="restoreClientOnlyContent">Whether derived assistants should restore client-only UI state.</param>
|
||||
/// <returns>A task that completes after the component was refreshed.</returns>
|
||||
private async Task AttachAssistantSession(AssistantSessionSnapshot snapshot, bool restoreClientOnlyContent)
|
||||
{
|
||||
this.assistantSessionId = snapshot.SessionId;
|
||||
this.ImportAssistantSessionState(snapshot.State);
|
||||
this.ImportAssistantSessionState(snapshot.StateChangedSince(this.lastAttachedAssistantSessionSnapshot));
|
||||
this.lastAttachedAssistantSessionSnapshot = snapshot;
|
||||
this.ChatThread = snapshot.ChatThread ?? this.ChatThread;
|
||||
this.IsProcessing = snapshot.IsActive;
|
||||
|
||||
|
||||
+14
-1
@@ -217,7 +217,20 @@ public partial class SlideAssistant
|
||||
return;
|
||||
}
|
||||
|
||||
// A variant of another template means nothing for this one, nor do choices for its slides:
|
||||
this.FitChoicesToTemplate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits the color variant and the choices for the slides to the loaded template.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A variant of another template means nothing for this one, nor do choices for its slides.
|
||||
/// </remarks>
|
||||
private void FitChoicesToTemplate()
|
||||
{
|
||||
if (this.template is null)
|
||||
return;
|
||||
|
||||
if (this.variantLabel is null || !this.template.VariantLabels.Contains(this.variantLabel))
|
||||
this.variantLabel = this.template.VariantLabels.FirstOrDefault();
|
||||
|
||||
|
||||
@@ -346,6 +346,10 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
||||
/// <inheritdoc />
|
||||
protected override void RestoreCustomAssistantSessionState(AssistantSessionStateReader state)
|
||||
{
|
||||
var previousTemplatePath = this.templatePath;
|
||||
var previousSlideDeckAnswer = this.slideDeckAnswer;
|
||||
var previousPictureLibraryPath = this.pictureLibraryPath;
|
||||
|
||||
state.Restore(INPUT_TITLE_STATE_KEY, value => this.inputTitle = value);
|
||||
state.Restore(INPUT_CONTENT_STATE_KEY, value => this.inputContent = value);
|
||||
state.Restore(CUSTOM_TARGET_LANGUAGE_STATE_KEY, value => this.customTargetLanguage = value);
|
||||
@@ -377,10 +381,22 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
|
||||
state.RestoreDictionary(SLIDE_DECK_BASIS_STATE_KEY, this.slideDeckBasis);
|
||||
state.Restore(PLANNING_STEP_STATE_KEY, value => this.planningStep = value);
|
||||
|
||||
// Neither the template nor the slides are kept themselves, only what they were read from:
|
||||
this.LoadTemplate();
|
||||
this.RestoreSlideDeck();
|
||||
this.StartCountingPictureLibrary();
|
||||
//
|
||||
// Neither the template nor the slides nor the count of the library are kept themselves, only
|
||||
// what they were read from. A running session stores its state every few seconds, and the
|
||||
// template is read on the thread of the renderer, maybe from a network drive. So each one is
|
||||
// read again only when what it was read from has changed:
|
||||
//
|
||||
if (string.Equals(previousTemplatePath, this.templatePath, StringComparison.Ordinal))
|
||||
this.FitChoicesToTemplate();
|
||||
else
|
||||
this.LoadTemplate();
|
||||
|
||||
if (!string.Equals(previousSlideDeckAnswer, this.slideDeckAnswer, StringComparison.Ordinal))
|
||||
this.RestoreSlideDeck();
|
||||
|
||||
if (!string.Equals(previousPictureLibraryPath, this.pictureLibraryPath, StringComparison.Ordinal))
|
||||
this.StartCountingPictureLibrary();
|
||||
}
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
@@ -65,4 +65,23 @@ public sealed record AssistantSessionSnapshot
|
||||
/// Gets whether the session is still running or canceling.
|
||||
/// </summary>
|
||||
public bool IsActive => this.Status is AssistantSessionStatus.RUNNING or AssistantSessionStatus.CANCELING;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the state fields which are new or hold another value than in an earlier snapshot of the same run.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A component which shows a run it did not start takes over only these, so that whatever the
|
||||
/// running assistant left alone stays as the user has it in that component.
|
||||
/// </remarks>
|
||||
/// <param name="previous">The snapshot taken over last, or null for none.</param>
|
||||
/// <returns>The whole state when there is no earlier snapshot of the same run; otherwise, the new and changed fields.</returns>
|
||||
public IReadOnlyDictionary<string, IAssistantSessionSnapshotField> StateChangedSince(AssistantSessionSnapshot? previous)
|
||||
{
|
||||
if (previous is null || previous.SessionId != this.SessionId)
|
||||
return this.State;
|
||||
|
||||
return this.State
|
||||
.Where(field => !previous.State.TryGetValue(field.Key, out var previousField) || !previousField.HasSameValueAs(field.Value))
|
||||
.ToDictionary(field => field.Key, field => field.Value, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
// ReSharper disable MemberCanBePrivate.Global
|
||||
using System.Collections;
|
||||
|
||||
namespace AIStudio.Tools.AssistantSessions;
|
||||
|
||||
/// <summary>
|
||||
@@ -42,4 +44,37 @@ public sealed record AssistantSessionSnapshotField<T> : IAssistantSessionSnapsho
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasSameValueAs(IAssistantSessionSnapshotField other) => other is AssistantSessionSnapshotField<T> otherField && AreSame(this.Value, otherField.Value);
|
||||
|
||||
private static bool AreSame(object? value, object? otherValue)
|
||||
{
|
||||
if (ReferenceEquals(value, otherValue))
|
||||
return true;
|
||||
|
||||
if (value is null || otherValue is null)
|
||||
return false;
|
||||
|
||||
// Nobody can change a string or a copied value type behind our back:
|
||||
if (value is string || value.GetType().IsValueType)
|
||||
return value.Equals(otherValue);
|
||||
|
||||
//
|
||||
// The writer copies lists, sets, and dictionaries for every snapshot, so these never share a
|
||||
// reference. Their items, though, are those of the assistant. Any other object may change
|
||||
// without a new reference, e.g., a record holding a list, so only its reference tells:
|
||||
//
|
||||
if (value is IDictionary dictionary && otherValue is IDictionary otherDictionary)
|
||||
return dictionary.Count == otherDictionary.Count && dictionary.Keys.Cast<object>().All(key => otherDictionary.Contains(key) && AreSame(dictionary[key], otherDictionary[key]));
|
||||
|
||||
if (value is IEnumerable items && otherValue is IEnumerable otherItems)
|
||||
{
|
||||
var itemList = items.Cast<object?>().ToList();
|
||||
var otherItemList = otherItems.Cast<object?>().ToList();
|
||||
return itemList.Count == otherItemList.Count && itemList.Zip(otherItemList).All(pair => AreSame(pair.First, pair.Second));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -17,4 +17,17 @@ public interface IAssistantSessionSnapshotField
|
||||
/// <param name="value">The typed value when reading succeeds.</param>
|
||||
/// <returns><c>true</c> when the captured value matches <typeparamref name="T"/>; otherwise, <c>false</c>.</returns>
|
||||
bool TryRead<T>(out T? value);
|
||||
|
||||
/// <summary>
|
||||
/// Tells whether another field provably holds the same value as this one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The same means: the same captured type, and the same reference, an equal string or value of a
|
||||
/// value type, or a collection whose items are the same by this rule in turn. Everything else counts
|
||||
/// as changed. A field wrongly taken for changed is only restored once more, while a field wrongly
|
||||
/// taken for the same would lose an update.
|
||||
/// </remarks>
|
||||
/// <param name="other">The field to compare with.</param>
|
||||
/// <returns><c>true</c> when both fields provably hold the same value; otherwise, <c>false</c>.</returns>
|
||||
bool HasSameValueAs(IAssistantSessionSnapshotField other);
|
||||
}
|
||||
@@ -130,6 +130,7 @@
|
||||
- Fixed photos taken with a phone appearing rotated in the Visual Briefing assistant when it optimizes images.
|
||||
- Fixed AI Studio sometimes discarding the answer of the AI when it chose your data sources, checked the passages it found, or checked an assistant plugin for security issues, because the AI wrote its answer in a slightly different form than expected.
|
||||
- Fixed assistants doing nothing at all when you started them without selecting a provider. They now ask you to select one, together with anything else that is still missing.
|
||||
- Fixed an assistant undoing your changes every few seconds when you left it during a run and came back before it finished. Your changes now stay, and the result still appears when the assistant is done. In the Slide Assistant, this also ends short freezes while it plans.
|
||||
- Updated the code contributions on the supporters page, which now thank everyone who has contributed code to AI Studio so far.
|
||||
- Upgraded several libraries to improve security.
|
||||
- Upgraded to Rust v1.99.1
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
using AIStudio.Tools.AssistantSessions;
|
||||
|
||||
namespace AIStudio.Tests.Tools.AssistantSessions;
|
||||
|
||||
/// <summary>
|
||||
/// Checks which state fields a component takes over from a run it did not start.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When the user leaves an assistant while it runs and comes back, the new component only shows the run,
|
||||
/// while the old one keeps working out of sight. The form of the old one no longer changes, so the new
|
||||
/// component must take over only what the run changed: otherwise, every report of the run undoes what
|
||||
/// the user changed meanwhile. Wrongly taking a field for changed only restores it once more, while
|
||||
/// wrongly taking it for the same loses an update, so whatever is not provably the same counts as changed.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class AssistantSessionSnapshotTests
|
||||
{
|
||||
private static readonly AssistantSessionStateKey<string> TEXT_KEY = new("Text");
|
||||
private static readonly AssistantSessionStateKey<int> NUMBER_KEY = new("Number");
|
||||
private static readonly AssistantSessionStateKey<AssistantSessionStatus> STATUS_KEY = new("Status");
|
||||
private static readonly AssistantSessionStateKey<string?> OPTIONAL_TEXT_KEY = new("OptionalText");
|
||||
private static readonly AssistantSessionStateKey<List<string>> LINES_KEY = new("Lines");
|
||||
private static readonly AssistantSessionStateKey<HashSet<Note>> NOTES_KEY = new("Notes");
|
||||
private static readonly AssistantSessionStateKey<Dictionary<string, int>> COUNTS_KEY = new("Counts");
|
||||
private static readonly AssistantSessionStateKey<Note> NOTE_KEY = new("Note");
|
||||
private static readonly AssistantSessionStateKey<int> NUMBER_AS_TEXT_KEY = new("Text");
|
||||
|
||||
private static readonly Guid SESSION_ID = Guid.NewGuid();
|
||||
|
||||
private sealed record Note(string Text);
|
||||
|
||||
[Test]
|
||||
public void WithoutAnEarlierSnapshotTheWholeStateIsTakenOver()
|
||||
{
|
||||
var snapshot = SnapshotOf(SESSION_ID, state =>
|
||||
{
|
||||
state.Set(TEXT_KEY, "title");
|
||||
state.Set(NUMBER_KEY, 3);
|
||||
});
|
||||
|
||||
var changed = snapshot.StateChangedSince(null);
|
||||
|
||||
Assert.That(changed.Keys, Is.EquivalentTo(new[] { TEXT_KEY.Name, NUMBER_KEY.Name }), "A component which attaches for the first time has nothing yet, so it takes over everything.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AfterAnotherRunTheWholeStateIsTakenOver()
|
||||
{
|
||||
var previous = SnapshotOf(Guid.NewGuid(), state => state.Set(TEXT_KEY, "title"));
|
||||
var snapshot = SnapshotOf(SESSION_ID, state => state.Set(TEXT_KEY, "title"));
|
||||
|
||||
var changed = snapshot.StateChangedSince(previous);
|
||||
|
||||
Assert.That(changed.Keys, Is.EquivalentTo(new[] { TEXT_KEY.Name }), "What the component took over from another run says nothing about this one.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UnchangedValuesAreLeftOut()
|
||||
{
|
||||
var previous = SnapshotOf(SESSION_ID, state =>
|
||||
{
|
||||
state.Set(TEXT_KEY, "title");
|
||||
state.Set(NUMBER_KEY, 3);
|
||||
state.Set(STATUS_KEY, AssistantSessionStatus.RUNNING);
|
||||
});
|
||||
|
||||
var snapshot = SnapshotOf(SESSION_ID, state =>
|
||||
{
|
||||
state.Set(TEXT_KEY, new string("title".ToCharArray()));
|
||||
state.Set(NUMBER_KEY, 3);
|
||||
state.Set(STATUS_KEY, AssistantSessionStatus.RUNNING);
|
||||
});
|
||||
|
||||
var changed = snapshot.StateChangedSince(previous);
|
||||
|
||||
Assert.That(changed, Is.Empty, "The run left these fields alone, so the user may have changed them, and the component must keep that.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ChangedAndNewValuesAreTakenOver()
|
||||
{
|
||||
var previous = SnapshotOf(SESSION_ID, state =>
|
||||
{
|
||||
state.Set(TEXT_KEY, "title");
|
||||
state.Set(NUMBER_KEY, 3);
|
||||
state.Set(STATUS_KEY, AssistantSessionStatus.RUNNING);
|
||||
});
|
||||
|
||||
var snapshot = SnapshotOf(SESSION_ID, state =>
|
||||
{
|
||||
state.Set(TEXT_KEY, "title");
|
||||
state.Set(NUMBER_KEY, 4);
|
||||
state.Set(STATUS_KEY, AssistantSessionStatus.COMPLETED);
|
||||
state.Set(OPTIONAL_TEXT_KEY, "result");
|
||||
});
|
||||
|
||||
var changed = snapshot.StateChangedSince(previous);
|
||||
|
||||
Assert.That(changed.Keys, Is.EquivalentTo(new[] { NUMBER_KEY.Name, STATUS_KEY.Name, OPTIONAL_TEXT_KEY.Name }), "Progress and results come from the run, so they must arrive.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CopiedCollectionsWithTheSameItemsAreLeftOut()
|
||||
{
|
||||
var note = new Note("first");
|
||||
List<string> lines = ["a", "b"];
|
||||
HashSet<Note> notes = [note];
|
||||
var counts = new Dictionary<string, int> { ["a"] = 1, ["b"] = 2 };
|
||||
|
||||
var previous = SnapshotOf(SESSION_ID, state =>
|
||||
{
|
||||
state.SetList(LINES_KEY, lines);
|
||||
state.SetHashSet(NOTES_KEY, notes);
|
||||
state.SetDictionary(COUNTS_KEY, counts);
|
||||
});
|
||||
|
||||
var snapshot = SnapshotOf(SESSION_ID, state =>
|
||||
{
|
||||
state.SetList(LINES_KEY, lines);
|
||||
state.SetHashSet(NOTES_KEY, notes);
|
||||
state.SetDictionary(COUNTS_KEY, counts);
|
||||
});
|
||||
|
||||
var changed = snapshot.StateChangedSince(previous);
|
||||
|
||||
Assert.That(changed, Is.Empty, "The writer copies collections for every snapshot, so a new copy with the same items is no change.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CollectionsWithOtherItemsAreTakenOver()
|
||||
{
|
||||
var previous = SnapshotOf(SESSION_ID, state =>
|
||||
{
|
||||
state.SetList(LINES_KEY, ["a", "b"]);
|
||||
state.SetHashSet(NOTES_KEY, [new Note("first")]);
|
||||
state.SetDictionary(COUNTS_KEY, new Dictionary<string, int> { ["a"] = 1 });
|
||||
});
|
||||
|
||||
var snapshot = SnapshotOf(SESSION_ID, state =>
|
||||
{
|
||||
state.SetList(LINES_KEY, ["a", "c"]);
|
||||
state.SetHashSet(NOTES_KEY, [new Note("first"), new Note("second")]);
|
||||
state.SetDictionary(COUNTS_KEY, new Dictionary<string, int> { ["b"] = 1 });
|
||||
});
|
||||
|
||||
var changed = snapshot.StateChangedSince(previous);
|
||||
|
||||
Assert.That(changed.Keys, Is.EquivalentTo(new[] { LINES_KEY.Name, NOTES_KEY.Name, COUNTS_KEY.Name }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NullIsTheSameOnlyAsNull()
|
||||
{
|
||||
var previous = SnapshotOf(SESSION_ID, state =>
|
||||
{
|
||||
state.Set(OPTIONAL_TEXT_KEY, null);
|
||||
state.Set(TEXT_KEY, "title");
|
||||
});
|
||||
|
||||
var snapshot = SnapshotOf(SESSION_ID, state =>
|
||||
{
|
||||
state.Set(OPTIONAL_TEXT_KEY, null);
|
||||
state.Set(TEXT_KEY, null!);
|
||||
});
|
||||
|
||||
var changed = snapshot.StateChangedSince(previous);
|
||||
|
||||
Assert.That(changed.Keys, Is.EquivalentTo(new[] { TEXT_KEY.Name }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AValueOfAnotherTypeIsTakenOver()
|
||||
{
|
||||
var previous = SnapshotOf(SESSION_ID, state => state.Set(TEXT_KEY, "3"));
|
||||
var snapshot = SnapshotOf(SESSION_ID, state => state.Set(NUMBER_AS_TEXT_KEY, 3));
|
||||
|
||||
var changed = snapshot.StateChangedSince(previous);
|
||||
|
||||
Assert.That(changed.Keys, Is.EquivalentTo(new[] { NUMBER_AS_TEXT_KEY.Name }), "A field captured with another type is another value, whatever it looks like.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnObjectIsTheSameOnlyAsItself()
|
||||
{
|
||||
var note = new Note("first");
|
||||
var previous = SnapshotOf(SESSION_ID, state => state.Set(NOTE_KEY, note));
|
||||
var sameNote = SnapshotOf(SESSION_ID, state => state.Set(NOTE_KEY, note));
|
||||
var equalNote = SnapshotOf(SESSION_ID, state => state.Set(NOTE_KEY, new Note(note.Text)));
|
||||
|
||||
Assert.That(sameNote.StateChangedSince(previous), Is.Empty, "The run still holds the same object.");
|
||||
Assert.That(equalNote.StateChangedSince(previous).Keys, Is.EquivalentTo(new[] { NOTE_KEY.Name }), "An object may change without a new reference, so only its reference proves that it is the same.");
|
||||
}
|
||||
|
||||
private static AssistantSessionSnapshot SnapshotOf(Guid sessionId, Action<AssistantSessionStateWriter> capture)
|
||||
{
|
||||
var state = new AssistantSessionStateWriter();
|
||||
capture(state);
|
||||
|
||||
return new()
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Key = new(AIStudio.Tools.Components.SLIDE_BUILDER_ASSISTANT, "test"),
|
||||
Title = "Test",
|
||||
Status = AssistantSessionStatus.RUNNING,
|
||||
StartedAt = DateTimeOffset.MinValue,
|
||||
UpdatedAt = DateTimeOffset.MinValue,
|
||||
State = state.ToDictionary(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user