diff --git a/src/Schuly.API/Helply/HelplyChatService.cs b/src/Schuly.API/Helply/HelplyChatService.cs index 25b5e72..aa0edcb 100644 --- a/src/Schuly.API/Helply/HelplyChatService.cs +++ b/src/Schuly.API/Helply/HelplyChatService.cs @@ -195,7 +195,7 @@ async Task Emit(HelplyEvent e) responses.Add(Response(pending, decision.Approved)); if (decision.Approved) { - started.Add(AddTool(assistant, pending.ToolCallId, pending.RunningLabel)); + started.Add(AddTool(assistant, pending.ToolCallId, pending.RunningLabel, null, HelplyToolLabels.Key(pending.ToolName), HelplyToolLabels.Args(pending.ToolName, pending.ToolLabel, null))); finishLabels[pending.ToolCallId] = (pending.DoneLabel, pending.ToolLabel); } else @@ -210,7 +210,7 @@ async Task Emit(HelplyEvent e) foreach (var tool in turn.Kind == HelplyTurnKind.Approvals ? turn.Decisions.Where(d => !d.Approved) : []) { var denied = original.Pending.First(p => p.ApprovalId == tool.ApprovalId); - await Emit(new HelplyEvent("tool_finished", new { id = denied.ToolCallId, label = denied.ToolLabel, status = HelplyToolStatus.Denied, at = assistant.Text.Length })); + await Emit(new HelplyEvent("tool_finished", new { id = denied.ToolCallId, label = denied.ToolLabel, status = HelplyToolStatus.Denied, at = assistant.Text.Length, key = HelplyToolLabels.Key(denied.ToolName), args = HelplyToolLabels.Args(denied.ToolName, denied.ToolLabel, null) })); } foreach (var tool in started) await Emit(new HelplyEvent("tool_started", ToolEvent(tool))); @@ -281,7 +281,7 @@ async Task EmitText(string safe) // The text before a call is a finished stretch: send all of it, so the step's place ("at") is where the user's text ends. await EmitText(text.Flush()); var labels = HelplyToolLabels.For(info.Name, info.Title); - var tool = AddTool(assistant, call.CallId, labels.Running, HelplyToolLabels.Detail(call.Name, call.Arguments)); + var tool = AddTool(assistant, call.CallId, labels.Running, HelplyToolLabels.Detail(call.Name, call.Arguments), HelplyToolLabels.Key(call.Name), HelplyToolLabels.Args(call.Name, info.Title, call.Arguments)); if (call.Name == "bash" && toolSet.IsLocal(call.Name)) tool.Code = new HelplyStoredCodeRun { Command = Shown(redactor, HelplyToolLabels.Text(call.Arguments, "command") ?? string.Empty, MaxShownCommandChars) }; finishLabels[call.CallId] = (labels.Done, info.Title); @@ -435,7 +435,7 @@ public static ChatMessage UserInput(string text, IEnumerable redactor.Redact(text.Length <= max ? text : text[..max] + "\n..."); /// A tool step as the user sees it. at is how much of the reply's text came before the step, so the chat can place the step between the stretches of text it came between. - private static object ToolEvent(HelplyStoredTool tool) => new { id = tool.Id, label = tool.Label, status = tool.Status, at = tool.At, detail = tool.Detail, code = tool.Code is null ? null : new { command = tool.Code.Command, output = tool.Code.Output, exitCode = tool.Code.ExitCode } }; + private static object ToolEvent(HelplyStoredTool tool) => new { id = tool.Id, label = tool.Label, status = tool.Status, at = tool.At, detail = tool.Detail, key = tool.Key, args = tool.Args, code = tool.Code is null ? null : new { command = tool.Code.Command, output = tool.Code.Output, exitCode = tool.Code.ExitCode } }; private static object HelplyDtoShape(HelplyStoredApproval a) => new { approvalId = a.ApprovalId, toolLabel = a.ToolLabel, summary = a.Summary, details = a.Details.Select(d => new { label = d.Label, value = d.Value }).ToList(), destructive = a.Destructive, arguments = a.Arguments.Select(d => new { label = d.Label, value = d.Value }).ToList(), risk = a.Risk, riskReasons = a.RiskReasons }; @@ -478,9 +478,9 @@ private async Task RestoreSessionAsync(AIAgent agent, HelplyConver return await agent.CreateSessionAsync(ct); } - private static HelplyStoredTool AddTool(HelplyStoredMessage message, string callId, string label, string? detail = null) + private static HelplyStoredTool AddTool(HelplyStoredMessage message, string callId, string label, string? detail = null, string? key = null, Dictionary? args = null) { - var tool = new HelplyStoredTool { Id = callId, Label = label, Status = HelplyToolStatus.Running, At = message.Text.Length, Detail = detail }; + var tool = new HelplyStoredTool { Id = callId, Label = label, Status = HelplyToolStatus.Running, At = message.Text.Length, Detail = detail, Key = key, Args = args }; message.Tools.Add(tool); return tool; } @@ -488,7 +488,7 @@ private static HelplyStoredTool AddTool(HelplyStoredMessage message, string call private static void AddFinishedTool(HelplyTranscript transcript, HelplyStoredApproval pending, string status) { var message = transcript.Messages.FirstOrDefault(m => m.Id == pending.MessageId); - message?.Tools.Add(new HelplyStoredTool { Id = pending.ToolCallId, Label = pending.ToolLabel, Status = status, At = message.Text.Length }); + message?.Tools.Add(new HelplyStoredTool { Id = pending.ToolCallId, Label = pending.ToolLabel, Status = status, At = message.Text.Length, Key = HelplyToolLabels.Key(pending.ToolName), Args = HelplyToolLabels.Args(pending.ToolName, pending.ToolLabel, null) }); } /// The mail domains of the user's linked Microsoft accounts: addresses on them count as inside the school. diff --git a/src/Schuly.API/Helply/HelplyToolLabels.cs b/src/Schuly.API/Helply/HelplyToolLabels.cs index 87827e8..df56d61 100644 --- a/src/Schuly.API/Helply/HelplyToolLabels.cs +++ b/src/Schuly.API/Helply/HelplyToolLabels.cs @@ -79,31 +79,68 @@ public static class HelplyToolLabels ["junkemail"] = "junk mail", }; + /// One harmless fact about a call. and are what the web app translates; is the same note as plain English for clients that do not. + public sealed record StepNote(string Key, string Value, string English); + + private static readonly string[] SearchSources = ["files", "channels", "mail", "chats", "pages"]; + + /// The key the web app translates a step by: the tool's name when it has a label of its own, otherwise "generic". + public static string Key(string toolName) => Labels.ContainsKey(toolName) ? toolName : GenericKey; + + public const string GenericKey = "generic"; + + /// The structured arguments of a step label: the notes of by key, plus the tool's title ("name") for a tool without a label of its own. Null when there is nothing to say. + public static Dictionary? Args(string toolName, string? title, IDictionary? args) + { + var result = new Dictionary(StringComparer.Ordinal); + if (!Labels.ContainsKey(toolName) && !string.IsNullOrWhiteSpace(title)) + result["name"] = title; + foreach (var note in Notes(toolName, args)) + result[note.Key] = note.Value; + return result.Count == 0 ? null : result; + } + /// - /// A short note under a step in the chat ("page 2", "unread only"), built only from arguments that say nothing personal: paging numbers, flags and well-known folder names. + /// A short note under a step in the chat ("page 2", "unread only"), built only from arguments that say nothing personal: paging numbers, flags, well-known folder names and the number of search terms and the sources searched. /// Search text, ids, names and addresses never go into it. Null when there is nothing worth saying. /// public static string? Detail(string toolName, IDictionary? args) { + var notes = Notes(toolName, args); + return notes.Count == 0 ? null : string.Join(" · ", notes.Select(n => n.English)); + } + + public static List Notes(string toolName, IDictionary? args) + { + var notes = new List(); if (args is null) - return null; - var notes = new List(); + return notes; if (Number(args, "offset") is > 0 and var offset) { if (toolName.StartsWith("read_", StringComparison.Ordinal)) - notes.Add("continued"); + notes.Add(new StepNote("continued", "true", "continued")); else if (Number(args, "limit") is > 0 and var limit) - notes.Add($"page {offset / limit + 1}"); + notes.Add(new StepNote("page", (offset / limit + 1).ToString(System.Globalization.CultureInfo.InvariantCulture), $"page {offset / limit + 1}")); else - notes.Add("more results"); + notes.Add(new StepNote("more", "true", "more results")); } if (Bool(args, "unreadOnly") == true) - notes.Add("unread only"); + notes.Add(new StepNote("unreadOnly", "true", "unread only")); if (Text(args, "folder") is { } folder && WellKnownFolders.TryGetValue(folder, out var folderName)) - notes.Add(folderName); + notes.Add(new StepNote("folder", folder.ToLowerInvariant(), folderName)); if (toolName == "list_mail" && !string.IsNullOrWhiteSpace(Text(args, "search"))) - notes.Add("with a search"); - return notes.Count == 0 ? null : string.Join(" · ", notes); + notes.Add(new StepNote("withSearch", "true", "with a search")); + if (toolName == "search") + { + var terms = Strings(args, "terms").Count(t => !string.IsNullOrWhiteSpace(t)); + if (terms > 0) + notes.Add(new StepNote("terms", terms.ToString(System.Globalization.CultureInfo.InvariantCulture), terms == 1 ? "1 term" : $"{terms} terms")); + var sources = Strings(args, "sources").Select(s => s.Trim().ToLowerInvariant()).ToHashSet(); + var named = SearchSources.Where(sources.Contains).ToList(); + if (named.Count > 0) + notes.Add(new StepNote("sources", string.Join(",", named), "in " + string.Join(", ", named))); + } + return notes; } private static long? Number(IDictionary args, string name) => args.TryGetValue(name, out var value) ? value switch diff --git a/src/Schuly.Application/Dtos/HelplyDtos.cs b/src/Schuly.Application/Dtos/HelplyDtos.cs index d84c03a..b0a94c0 100644 --- a/src/Schuly.Application/Dtos/HelplyDtos.cs +++ b/src/Schuly.Application/Dtos/HelplyDtos.cs @@ -13,7 +13,7 @@ public record HelplyCodeRunDto(string Command, string? Output, int? ExitCode); public record HelplyConversationSummaryDto(Guid Id, string Title, DateTime CreatedAt, DateTime UpdatedAt, DateTime ExpiresAt); - public record HelplyToolActivityDto(string Id, string Label, string Status, HelplyCodeRunDto? Code, int? At, string? Detail); + public record HelplyToolActivityDto(string Id, string Label, string Status, HelplyCodeRunDto? Code, int? At, string? Detail, string? Key, Dictionary? Args); public record HelplyMessageDto(Guid Id, string Role, string Text, DateTime CreatedAt, IReadOnlyList Tools, bool Failed, IReadOnlyList Attachments); @@ -32,7 +32,7 @@ public static class HelplyDtoMapper public static HelplyAttachmentDto ToDto(HelplyAttachmentInfo a) => new(a.Id, a.FileName, a.MediaType, a.SizeBytes); - public static HelplyToolActivityDto ToDto(HelplyStoredTool t) => new(t.Id, t.Label, t.Status, t.Code is null ? null : new HelplyCodeRunDto(t.Code.Command, t.Code.Output, t.Code.ExitCode), t.At, t.Detail); + public static HelplyToolActivityDto ToDto(HelplyStoredTool t) => new(t.Id, t.Label, t.Status, t.Code is null ? null : new HelplyCodeRunDto(t.Code.Command, t.Code.Output, t.Code.ExitCode), t.At, t.Detail, t.Key, t.Args); public static HelplyConversationSummaryDto ToSummary(HelplyConversation c, string title) => new(c.Id, title, c.CreatedAt, c.LastActivityAt, c.ExpiresAt); diff --git a/src/Schuly.Frontend/public/i18n/de.json b/src/Schuly.Frontend/public/i18n/de.json index e7104ca..1d5c014 100644 --- a/src/Schuly.Frontend/public/i18n/de.json +++ b/src/Schuly.Frontend/public/i18n/de.json @@ -1139,7 +1139,242 @@ "empty": "{{name}} ist leer.", "max": "Du kannst bis zu {{max}} Dateien pro Nachricht anhängen." }, - "exitCode": "Exit-Code {{code}}" + "exitCode": "Exit-Code {{code}}", + "tool": { + "bash": { + "running": "Code wird ausgeführt", + "done": "Code ausgeführt" + }, + "read_file": { + "running": "Datei wird gelesen", + "done": "Datei gelesen" + }, + "write_file": { + "running": "Arbeitsbereich-Datei wird geschrieben", + "done": "Arbeitsbereich-Datei geschrieben" + }, + "share_file": { + "running": "Datei wird geteilt", + "done": "Datei geteilt" + }, + "get_profile": { + "running": "Dein Profil wird gelesen", + "done": "Dein Profil gelesen" + }, + "list_school_accounts": { + "running": "Deine Schulkonten werden geprüft", + "done": "Deine Schulkonten geprüft" + }, + "list_grades": { + "running": "Deine Noten werden nachgeschlagen", + "done": "Deine Noten nachgeschlagen" + }, + "list_semester_reports": { + "running": "Deine Semesterzeugnisse werden nachgeschlagen", + "done": "Deine Semesterzeugnisse nachgeschlagen" + }, + "list_exams": { + "running": "Deine Prüfungen werden angeschaut", + "done": "Deine Prüfungen angeschaut" + }, + "list_subjects": { + "running": "Deine Fächer werden nachgeschlagen", + "done": "Deine Fächer nachgeschlagen" + }, + "list_agenda": { + "running": "Deine Agenda wird angeschaut", + "done": "Deine Agenda angeschaut" + }, + "list_assessments": { + "running": "Deine Tests und Abgabetermine werden nachgeschlagen", + "done": "Deine Tests und Abgabetermine nachgeschlagen" + }, + "list_absences": { + "running": "Deine Absenzen werden angeschaut", + "done": "Deine Absenzen angeschaut" + }, + "list_documents": { + "running": "Deine Dokumente werden durchgesehen", + "done": "Deine Dokumente durchgesehen" + }, + "read_document": { + "running": "Dokument wird gelesen", + "done": "Dokument gelesen" + }, + "list_mail_folders": { + "running": "Deine Mail-Ordner werden angeschaut", + "done": "Deine Mail-Ordner angeschaut" + }, + "list_mail": { + "running": "Deine Mail-Liste wird gelesen", + "done": "Deine Mail-Liste gelesen" + }, + "read_mail": { + "running": "E-Mail wird gelesen", + "done": "E-Mail gelesen" + }, + "send_mail": { + "running": "E-Mail wird gesendet", + "done": "E-Mail gesendet" + }, + "reply_mail": { + "running": "E-Mail wird beantwortet", + "done": "E-Mail beantwortet" + }, + "forward_mail": { + "running": "E-Mail wird weitergeleitet", + "done": "E-Mail weitergeleitet" + }, + "move_mail": { + "running": "E-Mail wird verschoben", + "done": "E-Mail verschoben" + }, + "flag_mail": { + "running": "E-Mail wird markiert", + "done": "E-Mail markiert" + }, + "mark_mail_read": { + "running": "Gelesen-Status einer E-Mail wird geändert", + "done": "Gelesen-Status einer E-Mail geändert" + }, + "delete_mail": { + "running": "E-Mail wird gelöscht", + "done": "E-Mail gelöscht" + }, + "list_chats": { + "running": "Deine Teams-Chats werden angeschaut", + "done": "Deine Teams-Chats angeschaut" + }, + "read_chat_messages": { + "running": "Teams-Chat wird gelesen", + "done": "Teams-Chat gelesen" + }, + "send_chat_message": { + "running": "Teams-Nachricht wird gesendet", + "done": "Teams-Nachricht gesendet" + }, + "list_drive_items": { + "running": "Dein OneDrive wird durchstöbert", + "done": "Dein OneDrive durchstöbert" + }, + "read_drive_file": { + "running": "OneDrive-Datei wird gelesen", + "done": "OneDrive-Datei gelesen" + }, + "list_teams": { + "running": "Deine Teams werden nachgeschlagen", + "done": "Deine Teams nachgeschlagen" + }, + "list_team_files": { + "running": "Dateien eines Teams werden durchstöbert", + "done": "Dateien eines Teams durchstöbert" + }, + "list_folder": { + "running": "Ordner wird durchstöbert", + "done": "Ordner durchstöbert" + }, + "list_shared_with_me": { + "running": "Mit dir geteilte Dateien werden angeschaut", + "done": "Mit dir geteilte Dateien angeschaut" + }, + "list_recent_files": { + "running": "Deine letzten Dateien werden angeschaut", + "done": "Deine letzten Dateien angeschaut" + }, + "search_files": { + "running": "Deine Dateien werden durchsucht", + "done": "Deine Dateien durchsucht" + }, + "search": { + "running": "Dateien, Notizen, Mails und Chats werden durchsucht", + "done": "Dateien, Notizen, Mails und Chats durchsucht" + }, + "list_channel_messages": { + "running": "Teams-Kanal wird gelesen", + "done": "Teams-Kanal gelesen" + }, + "search_channel_messages": { + "running": "Teams-Kanäle werden durchsucht", + "done": "Teams-Kanäle durchsucht" + }, + "list_notebooks": { + "running": "Deine OneNote-Notizbücher werden angeschaut", + "done": "Deine OneNote-Notizbücher angeschaut" + }, + "list_sections": { + "running": "Deine OneNote-Abschnitte werden angeschaut", + "done": "Deine OneNote-Abschnitte angeschaut" + }, + "list_pages": { + "running": "Deine OneNote-Seiten werden angeschaut", + "done": "Deine OneNote-Seiten angeschaut" + }, + "read_page": { + "running": "OneNote-Seite wird gelesen", + "done": "OneNote-Seite gelesen" + }, + "search_pages": { + "running": "Deine OneNote-Seiten werden durchsucht", + "done": "Deine OneNote-Seiten durchsucht" + }, + "create_page": { + "running": "OneNote-Seite wird erstellt", + "done": "OneNote-Seite erstellt" + }, + "update_page": { + "running": "OneNote-Seite wird aktualisiert", + "done": "OneNote-Seite aktualisiert" + }, + "list_calendar_events": { + "running": "Dein Kalender wird angeschaut", + "done": "Dein Kalender angeschaut" + }, + "list_reported_absences": { + "running": "Deine gemeldeten Absenzen werden angeschaut", + "done": "Deine gemeldeten Absenzen angeschaut" + }, + "list_absence_reasons": { + "running": "Die Absenzgründe werden angeschaut", + "done": "Die Absenzgründe angeschaut" + }, + "report_absence": { + "running": "Absenz wird gemeldet", + "done": "Absenz gemeldet" + }, + "withdraw_absence_report": { + "running": "Absenzmeldung wird zurückgezogen", + "done": "Absenzmeldung zurückgezogen" + }, + "generic": { + "running": "Ein Tool wird verwendet", + "done": "Ein Tool verwendet", + "runningNamed": "{{name}} wird verwendet", + "doneNamed": "{{name}} verwendet" + } + }, + "note": { + "page": "Seite {{count}}", + "continued": "fortgesetzt", + "more": "weitere Ergebnisse", + "unreadOnly": "nur ungelesene", + "withSearch": "mit Suche", + "terms": "{count, plural, one {# Suchbegriff} other {# Suchbegriffe}}", + "sources": "in {{list}}", + "folder": { + "sentitems": "gesendete Mails", + "drafts": "Entwürfe", + "deleteditems": "gelöschte Mails", + "archive": "Archiv", + "junkemail": "Junk-Mails" + }, + "source": { + "files": "Dateien", + "channels": "Kanäle", + "mail": "Mails", + "chats": "Chats", + "pages": "OneNote-Seiten" + } + } }, "mcp": { "optIn": "Ich will diese Daten mit dieser KI-App teilen. Sie bekommt, was ich erlaube, nach ihren eigenen Bedingungen und möglicherweise ausserhalb der Schweiz. Absenzgründe können Gesundheitsdaten verraten.", diff --git a/src/Schuly.Frontend/public/i18n/en.json b/src/Schuly.Frontend/public/i18n/en.json index 85ee20a..eb1f5c8 100644 --- a/src/Schuly.Frontend/public/i18n/en.json +++ b/src/Schuly.Frontend/public/i18n/en.json @@ -1139,7 +1139,242 @@ "empty": "{{name}} is empty.", "max": "You can attach up to {{max}} files per message." }, - "exitCode": "exit code {{code}}" + "exitCode": "exit code {{code}}", + "tool": { + "bash": { + "running": "Running code", + "done": "Ran code" + }, + "read_file": { + "running": "Reading a file", + "done": "Read a file" + }, + "write_file": { + "running": "Writing a workspace file", + "done": "Wrote a workspace file" + }, + "share_file": { + "running": "Sharing a file", + "done": "Shared a file" + }, + "get_profile": { + "running": "Reading your profile", + "done": "Read your profile" + }, + "list_school_accounts": { + "running": "Checking your school accounts", + "done": "Checked your school accounts" + }, + "list_grades": { + "running": "Looking up your grades", + "done": "Looked up your grades" + }, + "list_semester_reports": { + "running": "Looking up your semester reports", + "done": "Looked up your semester reports" + }, + "list_exams": { + "running": "Checking your exams", + "done": "Checked your exams" + }, + "list_subjects": { + "running": "Looking up your subjects", + "done": "Looked up your subjects" + }, + "list_agenda": { + "running": "Checking your agenda", + "done": "Checked your agenda" + }, + "list_assessments": { + "running": "Looking up your tests and deadlines", + "done": "Looked up your tests and deadlines" + }, + "list_absences": { + "running": "Checking your absences", + "done": "Checked your absences" + }, + "list_documents": { + "running": "Looking through your documents", + "done": "Looked through your documents" + }, + "read_document": { + "running": "Reading a document", + "done": "Read a document" + }, + "list_mail_folders": { + "running": "Checking your mail folders", + "done": "Checked your mail folders" + }, + "list_mail": { + "running": "Reading your mail list", + "done": "Read your mail list" + }, + "read_mail": { + "running": "Reading an email", + "done": "Read an email" + }, + "send_mail": { + "running": "Sending an email", + "done": "Sent an email" + }, + "reply_mail": { + "running": "Replying to an email", + "done": "Replied to an email" + }, + "forward_mail": { + "running": "Forwarding an email", + "done": "Forwarded an email" + }, + "move_mail": { + "running": "Moving an email", + "done": "Moved an email" + }, + "flag_mail": { + "running": "Flagging an email", + "done": "Flagged an email" + }, + "mark_mail_read": { + "running": "Updating the read status of an email", + "done": "Updated the read status of an email" + }, + "delete_mail": { + "running": "Deleting an email", + "done": "Deleted an email" + }, + "list_chats": { + "running": "Checking your Teams chats", + "done": "Checked your Teams chats" + }, + "read_chat_messages": { + "running": "Reading a Teams chat", + "done": "Read a Teams chat" + }, + "send_chat_message": { + "running": "Sending a Teams message", + "done": "Sent a Teams message" + }, + "list_drive_items": { + "running": "Browsing your OneDrive", + "done": "Browsed your OneDrive" + }, + "read_drive_file": { + "running": "Reading a OneDrive file", + "done": "Read a OneDrive file" + }, + "list_teams": { + "running": "Looking up your Teams", + "done": "Looked up your Teams" + }, + "list_team_files": { + "running": "Browsing a team's files", + "done": "Browsed a team's files" + }, + "list_folder": { + "running": "Browsing a folder", + "done": "Browsed a folder" + }, + "list_shared_with_me": { + "running": "Checking files shared with you", + "done": "Checked files shared with you" + }, + "list_recent_files": { + "running": "Checking your recent files", + "done": "Checked your recent files" + }, + "search_files": { + "running": "Searching your files", + "done": "Searched your files" + }, + "search": { + "running": "Searching your files, notes, mail and chats", + "done": "Searched your files, notes, mail and chats" + }, + "list_channel_messages": { + "running": "Reading a Teams channel", + "done": "Read a Teams channel" + }, + "search_channel_messages": { + "running": "Searching Teams channels", + "done": "Searched Teams channels" + }, + "list_notebooks": { + "running": "Checking your OneNote notebooks", + "done": "Checked your OneNote notebooks" + }, + "list_sections": { + "running": "Checking your OneNote sections", + "done": "Checked your OneNote sections" + }, + "list_pages": { + "running": "Checking your OneNote pages", + "done": "Checked your OneNote pages" + }, + "read_page": { + "running": "Reading a OneNote page", + "done": "Read a OneNote page" + }, + "search_pages": { + "running": "Searching your OneNote pages", + "done": "Searched your OneNote pages" + }, + "create_page": { + "running": "Creating a OneNote page", + "done": "Created a OneNote page" + }, + "update_page": { + "running": "Updating a OneNote page", + "done": "Updated a OneNote page" + }, + "list_calendar_events": { + "running": "Checking your calendar", + "done": "Checked your calendar" + }, + "list_reported_absences": { + "running": "Checking your reported absences", + "done": "Checked your reported absences" + }, + "list_absence_reasons": { + "running": "Checking the absence reasons", + "done": "Checked the absence reasons" + }, + "report_absence": { + "running": "Reporting an absence", + "done": "Reported an absence" + }, + "withdraw_absence_report": { + "running": "Withdrawing an absence report", + "done": "Withdrew an absence report" + }, + "generic": { + "running": "Using a tool", + "done": "Used a tool", + "runningNamed": "Using {{name}}", + "doneNamed": "Used {{name}}" + } + }, + "note": { + "page": "page {{count}}", + "continued": "continued", + "more": "more results", + "unreadOnly": "unread only", + "withSearch": "with a search", + "terms": "{count, plural, one {# term} other {# terms}}", + "sources": "in {{list}}", + "folder": { + "sentitems": "sent mail", + "drafts": "drafts", + "deleteditems": "deleted mail", + "archive": "archive", + "junkemail": "junk mail" + }, + "source": { + "files": "files", + "channels": "channels", + "mail": "mail", + "chats": "chats", + "pages": "OneNote pages" + } + } }, "mcp": { "optIn": "I want to share this data with this AI app. It gets what I allow, under its own terms and possibly outside Switzerland. Absence reasons can reveal health information.", diff --git a/src/Schuly.Frontend/public/i18n/fr.json b/src/Schuly.Frontend/public/i18n/fr.json index 312f0b8..f4e30c2 100644 --- a/src/Schuly.Frontend/public/i18n/fr.json +++ b/src/Schuly.Frontend/public/i18n/fr.json @@ -1139,7 +1139,242 @@ "empty": "{{name}} est vide.", "max": "Tu peux joindre jusqu'à {{max}} fichiers par message." }, - "exitCode": "code de sortie {{code}}" + "exitCode": "code de sortie {{code}}", + "tool": { + "bash": { + "running": "Exécution du code", + "done": "Code exécuté" + }, + "read_file": { + "running": "Lecture d'un fichier", + "done": "Fichier lu" + }, + "write_file": { + "running": "Écriture d'un fichier de l'espace de travail", + "done": "Fichier de l'espace de travail écrit" + }, + "share_file": { + "running": "Partage d'un fichier", + "done": "Fichier partagé" + }, + "get_profile": { + "running": "Lecture de ton profil", + "done": "Profil lu" + }, + "list_school_accounts": { + "running": "Vérification de tes comptes scolaires", + "done": "Comptes scolaires vérifiés" + }, + "list_grades": { + "running": "Consultation de tes notes", + "done": "Notes consultées" + }, + "list_semester_reports": { + "running": "Consultation de tes bulletins semestriels", + "done": "Bulletins semestriels consultés" + }, + "list_exams": { + "running": "Vérification de tes examens", + "done": "Examens vérifiés" + }, + "list_subjects": { + "running": "Consultation de tes branches", + "done": "Branches consultées" + }, + "list_agenda": { + "running": "Vérification de ton agenda", + "done": "Agenda vérifié" + }, + "list_assessments": { + "running": "Consultation de tes tests et délais", + "done": "Tests et délais consultés" + }, + "list_absences": { + "running": "Vérification de tes absences", + "done": "Absences vérifiées" + }, + "list_documents": { + "running": "Parcours de tes documents", + "done": "Documents parcourus" + }, + "read_document": { + "running": "Lecture d'un document", + "done": "Document lu" + }, + "list_mail_folders": { + "running": "Vérification de tes dossiers de messagerie", + "done": "Dossiers de messagerie vérifiés" + }, + "list_mail": { + "running": "Lecture de ta liste d'e-mails", + "done": "Liste d'e-mails lue" + }, + "read_mail": { + "running": "Lecture d'un e-mail", + "done": "E-mail lu" + }, + "send_mail": { + "running": "Envoi d'un e-mail", + "done": "E-mail envoyé" + }, + "reply_mail": { + "running": "Réponse à un e-mail", + "done": "Réponse à un e-mail envoyée" + }, + "forward_mail": { + "running": "Transfert d'un e-mail", + "done": "E-mail transféré" + }, + "move_mail": { + "running": "Déplacement d'un e-mail", + "done": "E-mail déplacé" + }, + "flag_mail": { + "running": "Marquage d'un e-mail", + "done": "E-mail marqué" + }, + "mark_mail_read": { + "running": "Mise à jour du statut de lecture d'un e-mail", + "done": "Statut de lecture d'un e-mail mis à jour" + }, + "delete_mail": { + "running": "Suppression d'un e-mail", + "done": "E-mail supprimé" + }, + "list_chats": { + "running": "Vérification de tes chats Teams", + "done": "Chats Teams vérifiés" + }, + "read_chat_messages": { + "running": "Lecture d'un chat Teams", + "done": "Chat Teams lu" + }, + "send_chat_message": { + "running": "Envoi d'un message Teams", + "done": "Message Teams envoyé" + }, + "list_drive_items": { + "running": "Parcours de ton OneDrive", + "done": "OneDrive parcouru" + }, + "read_drive_file": { + "running": "Lecture d'un fichier OneDrive", + "done": "Fichier OneDrive lu" + }, + "list_teams": { + "running": "Consultation de tes équipes Teams", + "done": "Équipes Teams consultées" + }, + "list_team_files": { + "running": "Parcours des fichiers d'une équipe", + "done": "Fichiers d'une équipe parcourus" + }, + "list_folder": { + "running": "Parcours d'un dossier", + "done": "Dossier parcouru" + }, + "list_shared_with_me": { + "running": "Vérification des fichiers partagés avec toi", + "done": "Fichiers partagés avec toi vérifiés" + }, + "list_recent_files": { + "running": "Vérification de tes fichiers récents", + "done": "Fichiers récents vérifiés" + }, + "search_files": { + "running": "Recherche dans tes fichiers", + "done": "Recherche dans tes fichiers terminée" + }, + "search": { + "running": "Recherche dans tes fichiers, notes, e-mails et chats", + "done": "Recherche dans tes fichiers, notes, e-mails et chats terminée" + }, + "list_channel_messages": { + "running": "Lecture d'un canal Teams", + "done": "Canal Teams lu" + }, + "search_channel_messages": { + "running": "Recherche dans les canaux Teams", + "done": "Recherche dans les canaux Teams terminée" + }, + "list_notebooks": { + "running": "Vérification de tes carnets OneNote", + "done": "Carnets OneNote vérifiés" + }, + "list_sections": { + "running": "Vérification de tes sections OneNote", + "done": "Sections OneNote vérifiées" + }, + "list_pages": { + "running": "Vérification de tes pages OneNote", + "done": "Pages OneNote vérifiées" + }, + "read_page": { + "running": "Lecture d'une page OneNote", + "done": "Page OneNote lue" + }, + "search_pages": { + "running": "Recherche dans tes pages OneNote", + "done": "Recherche dans tes pages OneNote terminée" + }, + "create_page": { + "running": "Création d'une page OneNote", + "done": "Page OneNote créée" + }, + "update_page": { + "running": "Mise à jour d'une page OneNote", + "done": "Page OneNote mise à jour" + }, + "list_calendar_events": { + "running": "Vérification de ton calendrier", + "done": "Calendrier vérifié" + }, + "list_reported_absences": { + "running": "Vérification de tes absences annoncées", + "done": "Absences annoncées vérifiées" + }, + "list_absence_reasons": { + "running": "Vérification des motifs d'absence", + "done": "Motifs d'absence vérifiés" + }, + "report_absence": { + "running": "Annonce d'une absence", + "done": "Absence annoncée" + }, + "withdraw_absence_report": { + "running": "Retrait d'une annonce d'absence", + "done": "Annonce d'absence retirée" + }, + "generic": { + "running": "Utilisation d'un outil", + "done": "Outil utilisé", + "runningNamed": "Utilisation de {{name}}", + "doneNamed": "{{name}} utilisé" + } + }, + "note": { + "page": "page {{count}}", + "continued": "suite", + "more": "autres résultats", + "unreadOnly": "non lus seulement", + "withSearch": "avec une recherche", + "terms": "{count, plural, one {# terme} other {# termes}}", + "sources": "dans {{list}}", + "folder": { + "sentitems": "e-mails envoyés", + "drafts": "brouillons", + "deleteditems": "e-mails supprimés", + "archive": "archives", + "junkemail": "courrier indésirable" + }, + "source": { + "files": "fichiers", + "channels": "canaux", + "mail": "e-mails", + "chats": "chats", + "pages": "pages OneNote" + } + } }, "mcp": { "optIn": "Je veux partager ces données avec cette app d'IA. Elle reçoit ce que j'autorise, selon ses propres conditions et peut-être hors de Suisse. Les motifs d'absence peuvent révéler des données de santé.", diff --git a/src/Schuly.Frontend/public/i18n/it.json b/src/Schuly.Frontend/public/i18n/it.json index a06c58d..39e8eb6 100644 --- a/src/Schuly.Frontend/public/i18n/it.json +++ b/src/Schuly.Frontend/public/i18n/it.json @@ -1139,7 +1139,242 @@ "empty": "{{name}} è vuoto.", "max": "Puoi allegare fino a {{max}} file per messaggio." }, - "exitCode": "codice di uscita {{code}}" + "exitCode": "codice di uscita {{code}}", + "tool": { + "bash": { + "running": "Esecuzione del codice", + "done": "Codice eseguito" + }, + "read_file": { + "running": "Lettura di un file", + "done": "File letto" + }, + "write_file": { + "running": "Scrittura di un file dell'area di lavoro", + "done": "File dell'area di lavoro scritto" + }, + "share_file": { + "running": "Condivisione di un file", + "done": "File condiviso" + }, + "get_profile": { + "running": "Lettura del tuo profilo", + "done": "Profilo letto" + }, + "list_school_accounts": { + "running": "Controllo dei tuoi account scolastici", + "done": "Account scolastici controllati" + }, + "list_grades": { + "running": "Consultazione delle tue note", + "done": "Note consultate" + }, + "list_semester_reports": { + "running": "Consultazione delle tue pagelle semestrali", + "done": "Pagelle semestrali consultate" + }, + "list_exams": { + "running": "Controllo dei tuoi esami", + "done": "Esami controllati" + }, + "list_subjects": { + "running": "Consultazione delle tue materie", + "done": "Materie consultate" + }, + "list_agenda": { + "running": "Controllo della tua agenda", + "done": "Agenda controllata" + }, + "list_assessments": { + "running": "Consultazione dei tuoi test e scadenze", + "done": "Test e scadenze consultati" + }, + "list_absences": { + "running": "Controllo delle tue assenze", + "done": "Assenze controllate" + }, + "list_documents": { + "running": "Ricerca tra i tuoi documenti", + "done": "Documenti esaminati" + }, + "read_document": { + "running": "Lettura di un documento", + "done": "Documento letto" + }, + "list_mail_folders": { + "running": "Controllo delle tue cartelle di posta", + "done": "Cartelle di posta controllate" + }, + "list_mail": { + "running": "Lettura del tuo elenco e-mail", + "done": "Elenco e-mail letto" + }, + "read_mail": { + "running": "Lettura di un'e-mail", + "done": "E-mail letta" + }, + "send_mail": { + "running": "Invio di un'e-mail", + "done": "E-mail inviata" + }, + "reply_mail": { + "running": "Risposta a un'e-mail", + "done": "Risposta a un'e-mail inviata" + }, + "forward_mail": { + "running": "Inoltro di un'e-mail", + "done": "E-mail inoltrata" + }, + "move_mail": { + "running": "Spostamento di un'e-mail", + "done": "E-mail spostata" + }, + "flag_mail": { + "running": "Contrassegno di un'e-mail", + "done": "E-mail contrassegnata" + }, + "mark_mail_read": { + "running": "Aggiornamento dello stato di lettura di un'e-mail", + "done": "Stato di lettura di un'e-mail aggiornato" + }, + "delete_mail": { + "running": "Eliminazione di un'e-mail", + "done": "E-mail eliminata" + }, + "list_chats": { + "running": "Controllo delle tue chat di Teams", + "done": "Chat di Teams controllate" + }, + "read_chat_messages": { + "running": "Lettura di una chat di Teams", + "done": "Chat di Teams letta" + }, + "send_chat_message": { + "running": "Invio di un messaggio di Teams", + "done": "Messaggio di Teams inviato" + }, + "list_drive_items": { + "running": "Esplorazione del tuo OneDrive", + "done": "OneDrive esplorato" + }, + "read_drive_file": { + "running": "Lettura di un file di OneDrive", + "done": "File di OneDrive letto" + }, + "list_teams": { + "running": "Consultazione dei tuoi team", + "done": "Team consultati" + }, + "list_team_files": { + "running": "Esplorazione dei file di un team", + "done": "File di un team esplorati" + }, + "list_folder": { + "running": "Esplorazione di una cartella", + "done": "Cartella esplorata" + }, + "list_shared_with_me": { + "running": "Controllo dei file condivisi con te", + "done": "File condivisi con te controllati" + }, + "list_recent_files": { + "running": "Controllo dei tuoi file recenti", + "done": "File recenti controllati" + }, + "search_files": { + "running": "Ricerca nei tuoi file", + "done": "Ricerca nei tuoi file completata" + }, + "search": { + "running": "Ricerca in file, note, e-mail e chat", + "done": "Ricerca in file, note, e-mail e chat completata" + }, + "list_channel_messages": { + "running": "Lettura di un canale di Teams", + "done": "Canale di Teams letto" + }, + "search_channel_messages": { + "running": "Ricerca nei canali di Teams", + "done": "Ricerca nei canali di Teams completata" + }, + "list_notebooks": { + "running": "Controllo dei tuoi blocchi appunti OneNote", + "done": "Blocchi appunti OneNote controllati" + }, + "list_sections": { + "running": "Controllo delle tue sezioni OneNote", + "done": "Sezioni OneNote controllate" + }, + "list_pages": { + "running": "Controllo delle tue pagine OneNote", + "done": "Pagine OneNote controllate" + }, + "read_page": { + "running": "Lettura di una pagina OneNote", + "done": "Pagina OneNote letta" + }, + "search_pages": { + "running": "Ricerca nelle tue pagine OneNote", + "done": "Ricerca nelle tue pagine OneNote completata" + }, + "create_page": { + "running": "Creazione di una pagina OneNote", + "done": "Pagina OneNote creata" + }, + "update_page": { + "running": "Aggiornamento di una pagina OneNote", + "done": "Pagina OneNote aggiornata" + }, + "list_calendar_events": { + "running": "Controllo del tuo calendario", + "done": "Calendario controllato" + }, + "list_reported_absences": { + "running": "Controllo delle tue assenze segnalate", + "done": "Assenze segnalate controllate" + }, + "list_absence_reasons": { + "running": "Controllo dei motivi di assenza", + "done": "Motivi di assenza controllati" + }, + "report_absence": { + "running": "Segnalazione di un'assenza", + "done": "Assenza segnalata" + }, + "withdraw_absence_report": { + "running": "Ritiro di una segnalazione di assenza", + "done": "Segnalazione di assenza ritirata" + }, + "generic": { + "running": "Uso di uno strumento", + "done": "Strumento usato", + "runningNamed": "Uso di {{name}}", + "doneNamed": "{{name}} usato" + } + }, + "note": { + "page": "pagina {{count}}", + "continued": "continuazione", + "more": "altri risultati", + "unreadOnly": "solo non lette", + "withSearch": "con una ricerca", + "terms": "{count, plural, one {# termine} other {# termini}}", + "sources": "in {{list}}", + "folder": { + "sentitems": "posta inviata", + "drafts": "bozze", + "deleteditems": "posta eliminata", + "archive": "archivio", + "junkemail": "posta indesiderata" + }, + "source": { + "files": "file", + "channels": "canali", + "mail": "e-mail", + "chats": "chat", + "pages": "pagine OneNote" + } + } }, "mcp": { "optIn": "Voglio condividere questi dati con questa app di IA. Riceve ciò che consento, secondo le sue condizioni e forse fuori dalla Svizzera. I motivi delle assenze possono rivelare dati sulla salute.", diff --git a/src/Schuly.Frontend/public/i18n/rm.json b/src/Schuly.Frontend/public/i18n/rm.json index aafa4b0..c39e511 100644 --- a/src/Schuly.Frontend/public/i18n/rm.json +++ b/src/Schuly.Frontend/public/i18n/rm.json @@ -1139,7 +1139,242 @@ "empty": "{{name}} è vid.", "max": "Ti pos agiuntar fin a {{max}} datotecas per messadi." }, - "exitCode": "code da sortida {{code}}" + "exitCode": "code da sortida {{code}}", + "tool": { + "bash": { + "running": "Il code vegn exequì", + "done": "Code exequì" + }, + "read_file": { + "running": "Ina datoteca vegn legida", + "done": "Datoteca legida" + }, + "write_file": { + "running": "Ina datoteca da l'agen da lavur vegn scritta", + "done": "Datoteca da l'agen da lavur scritta" + }, + "share_file": { + "running": "Ina datoteca vegn cundivisa", + "done": "Datoteca cundivisa" + }, + "get_profile": { + "running": "Tes profil vegn legì", + "done": "Profil legì" + }, + "list_school_accounts": { + "running": "Tes contos da scola vegnan controllads", + "done": "Contos da scola controllads" + }, + "list_grades": { + "running": "Tias notas vegnan tschertgadas", + "done": "Notas tschertgadas" + }, + "list_semester_reports": { + "running": "Tes attestats da semester vegnan tschertgads", + "done": "Attestats da semester tschertgads" + }, + "list_exams": { + "running": "Tias examens vegnan controllads", + "done": "Examens controllads" + }, + "list_subjects": { + "running": "Tes materias vegnan tschertgadas", + "done": "Materias tschertgadas" + }, + "list_agenda": { + "running": "Tia agenda vegn controllada", + "done": "Agenda controllada" + }, + "list_assessments": { + "running": "Tes tests e termins vegnan tschertgads", + "done": "Tests e termins tschertgads" + }, + "list_absences": { + "running": "Tias absenzas vegnan controlladas", + "done": "Absenzas controlladas" + }, + "list_documents": { + "running": "Tes documents vegnan sfegliads", + "done": "Documents sfegliads" + }, + "read_document": { + "running": "In document vegn legì", + "done": "Document legì" + }, + "list_mail_folders": { + "running": "Tes ordinaturs d'e-mail vegnan controllads", + "done": "Ordinaturs d'e-mail controllads" + }, + "list_mail": { + "running": "Tia glista d'e-mails vegn legida", + "done": "Glista d'e-mails legida" + }, + "read_mail": { + "running": "In e-mail vegn legì", + "done": "E-mail legì" + }, + "send_mail": { + "running": "In e-mail vegn tramess", + "done": "E-mail tramess" + }, + "reply_mail": { + "running": "In e-mail vegn respundì", + "done": "E-mail respundì" + }, + "forward_mail": { + "running": "In e-mail vegn transmess", + "done": "E-mail transmess" + }, + "move_mail": { + "running": "In e-mail vegn spustà", + "done": "E-mail spustà" + }, + "flag_mail": { + "running": "In e-mail vegn marcà", + "done": "E-mail marcà" + }, + "mark_mail_read": { + "running": "Il status legì d'in e-mail vegn actualisà", + "done": "Status legì d'in e-mail actualisà" + }, + "delete_mail": { + "running": "In e-mail vegn stizzà", + "done": "E-mail stizzà" + }, + "list_chats": { + "running": "Tes chats da Teams vegnan controllads", + "done": "Chats da Teams controllads" + }, + "read_chat_messages": { + "running": "In chat da Teams vegn legì", + "done": "Chat da Teams legì" + }, + "send_chat_message": { + "running": "In messadi da Teams vegn tramess", + "done": "Messadi da Teams tramess" + }, + "list_drive_items": { + "running": "Tes OneDrive vegn sfeglià", + "done": "OneDrive sfeglià" + }, + "read_drive_file": { + "running": "Ina datoteca da OneDrive vegn legida", + "done": "Datoteca da OneDrive legida" + }, + "list_teams": { + "running": "Tes Teams vegnan tschertgads", + "done": "Teams tschertgads" + }, + "list_team_files": { + "running": "Las datotecas d'in team vegnan sfegliadas", + "done": "Datotecas d'in team sfegliadas" + }, + "list_folder": { + "running": "In ordinatur vegn sfeglià", + "done": "Ordinatur sfeglià" + }, + "list_shared_with_me": { + "running": "Las datotecas cundivisas cun tei vegnan controlladas", + "done": "Datotecas cundivisas cun tei controlladas" + }, + "list_recent_files": { + "running": "Tias datotecas da la finala vegnan controlladas", + "done": "Datotecas da la finala controlladas" + }, + "search_files": { + "running": "Tias datotecas vegnan tschertgadas", + "done": "Datotecas tschertgadas" + }, + "search": { + "running": "Datotecas, notizias, e-mails e chats vegnan tschertgads", + "done": "Datotecas, notizias, e-mails e chats tschertgads" + }, + "list_channel_messages": { + "running": "In chanal da Teams vegn legì", + "done": "Chanal da Teams legì" + }, + "search_channel_messages": { + "running": "Ils chanals da Teams vegnan tschertgads", + "done": "Chanals da Teams tschertgads" + }, + "list_notebooks": { + "running": "Tes cudeschs da notizias OneNote vegnan controllads", + "done": "Cudeschs da notizias OneNote controllads" + }, + "list_sections": { + "running": "Tias secziuns OneNote vegnan controlladas", + "done": "Secziuns OneNote controlladas" + }, + "list_pages": { + "running": "Tias paginas OneNote vegnan controlladas", + "done": "Paginas OneNote controlladas" + }, + "read_page": { + "running": "Ina pagina OneNote vegn legida", + "done": "Pagina OneNote legida" + }, + "search_pages": { + "running": "Tias paginas OneNote vegnan tschertgadas", + "done": "Paginas OneNote tschertgadas" + }, + "create_page": { + "running": "Ina pagina OneNote vegn creada", + "done": "Pagina OneNote creada" + }, + "update_page": { + "running": "Ina pagina OneNote vegn actualisada", + "done": "Pagina OneNote actualisada" + }, + "list_calendar_events": { + "running": "Tes chalender vegn controllà", + "done": "Chalender controllà" + }, + "list_reported_absences": { + "running": "Tias absenzas annunziadas vegnan controlladas", + "done": "Absenzas annunziadas controlladas" + }, + "list_absence_reasons": { + "running": "Ils motivs d'absenza vegnan controllads", + "done": "Motivs d'absenza controllads" + }, + "report_absence": { + "running": "Ina absenza vegn annunziada", + "done": "Absenza annunziada" + }, + "withdraw_absence_report": { + "running": "Ina annunzia d'absenza vegn retratga", + "done": "Annunzia d'absenza retratga" + }, + "generic": { + "running": "In meds vegn duvrà", + "done": "Meds duvrà", + "runningNamed": "{{name}} vegn duvrà", + "doneNamed": "{{name}} duvrà" + } + }, + "note": { + "page": "pagina {{count}}", + "continued": "cuntinuà", + "more": "dapli resultats", + "unreadOnly": "mo betg legids", + "withSearch": "cun ina tschertga", + "terms": "{count, plural, one {# term} other {# termins}}", + "sources": "en {{list}}", + "folder": { + "sentitems": "e-mails tramess", + "drafts": "concepts", + "deleteditems": "e-mails stizzads", + "archive": "archiv", + "junkemail": "e-mails nungivestgads" + }, + "source": { + "files": "datotecas", + "channels": "chanals", + "mail": "e-mails", + "chats": "chats", + "pages": "paginas OneNote" + } + } }, "mcp": { "optIn": "Jau vuless partir questas datas cun questa app d'IA. Ella survegn quai che jau permet, tenor sias atgnas cundiziuns e forsa ordaifer la Svizra. Motivs d'absenza pon revelar datas da sanadad.", diff --git a/src/Schuly.Frontend/src/app/api/model/helplyToolActivityDto.ts b/src/Schuly.Frontend/src/app/api/model/helplyToolActivityDto.ts index 1ab76b7..2fb7746 100644 --- a/src/Schuly.Frontend/src/app/api/model/helplyToolActivityDto.ts +++ b/src/Schuly.Frontend/src/app/api/model/helplyToolActivityDto.ts @@ -17,5 +17,7 @@ export interface HelplyToolActivityDto { code: HelplyCodeRunDto | null; at: number | null; detail: string | null; + key: string | null; + args: { [key: string]: string; } | null; } diff --git a/src/Schuly.Frontend/src/app/core/helply-state.ts b/src/Schuly.Frontend/src/app/core/helply-state.ts index 4145880..b4957a1 100644 --- a/src/Schuly.Frontend/src/app/core/helply-state.ts +++ b/src/Schuly.Frontend/src/app/core/helply-state.ts @@ -41,7 +41,7 @@ const updateLastAssistant = (state: ChatState, change: (message: ChatMessage) => /** Adds a step or updates it in place; a finished step keeps the place and detail it started with when its event leaves them out. */ const withTool = (tools: ToolActivity[], tool: ToolActivity): ToolActivity[] => tools.some((t) => t.id === tool.id) - ? tools.map((t) => (t.id === tool.id ? { ...tool, at: tool.at ?? t.at, detail: tool.detail ?? t.detail } : t)) + ? tools.map((t) => (t.id === tool.id ? { ...tool, at: tool.at ?? t.at, detail: tool.detail ?? t.detail, key: tool.key ?? t.key, args: tool.args ?? t.args } : t)) : [...tools, tool]; const stopRunning = (tools: ToolActivity[]): ToolActivity[] => @@ -82,12 +82,12 @@ export function applyStreamEvent(state: ChatState, event: StreamEvent, now = '') case 'tool_started': return updateLastAssistant(state, (m) => ({ ...m, - tools: withTool(m.tools, { id: event.id, label: event.label, status: 'running', code: event.code, at: event.at, detail: event.detail }), + tools: withTool(m.tools, { id: event.id, label: event.label, status: 'running', code: event.code, at: event.at, detail: event.detail, key: event.key, args: event.args }), })); case 'tool_finished': return updateLastAssistant(state, (m) => ({ ...m, - tools: withTool(m.tools, { id: event.id, label: event.label, status: event.status, code: event.code, at: event.at, detail: event.detail }), + tools: withTool(m.tools, { id: event.id, label: event.label, status: event.status, code: event.code, at: event.at, detail: event.detail, key: event.key, args: event.args }), })); case 'approval_required': { if (state.approvals.some((a) => a.approvalId === event.approvalId)) return state; @@ -134,6 +134,11 @@ export function stopStreaming(state: ChatState): ChatState { const text = (value: unknown): string => (typeof value === 'string' ? value : ''); const position = (value: unknown): number | null => (typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null); const note = (value: unknown): string | null => (typeof value === 'string' && value ? value : null); +const args = (value: unknown): Record | null => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const entries = Object.entries(value).filter((e): e is [string, string] => typeof e[1] === 'string'); + return entries.length ? Object.fromEntries(entries) : null; +}; const CODES: StreamErrorCode[] = ['unavailable', 'timeout', 'rate_limited', 'too_long', 'disabled']; function parseCode(value: unknown): ToolCode | null { @@ -162,10 +167,10 @@ export function parseStreamEvent(name: string, data: string): StreamEvent | null case 'delta': return { type: 'delta', text: text(o['text']) }; case 'tool_started': - return { type: 'tool_started', id: text(o['id']), label: text(o['label']), code: parseCode(o['code']), at: position(o['at']), detail: note(o['detail']) }; + return { type: 'tool_started', id: text(o['id']), label: text(o['label']), code: parseCode(o['code']), at: position(o['at']), detail: note(o['detail']), key: note(o['key']), args: args(o['args']) }; case 'tool_finished': { const status = o['status'] === 'failed' || o['status'] === 'denied' ? o['status'] : 'done'; - return { type: 'tool_finished', id: text(o['id']), label: text(o['label']), status, code: parseCode(o['code']), at: position(o['at']), detail: note(o['detail']) }; + return { type: 'tool_finished', id: text(o['id']), label: text(o['label']), status, code: parseCode(o['code']), at: position(o['at']), detail: note(o['detail']), key: note(o['key']), args: args(o['args']) }; } case 'approval_required': return { diff --git a/src/Schuly.Frontend/src/app/core/helply-types.ts b/src/Schuly.Frontend/src/app/core/helply-types.ts index 2afc2e1..de2e109 100644 --- a/src/Schuly.Frontend/src/app/core/helply-types.ts +++ b/src/Schuly.Frontend/src/app/core/helply-types.ts @@ -46,6 +46,10 @@ export interface ToolActivity { at?: number | null; /** A short harmless note from the server, such as "page 2" or "unread only". */ detail?: string | null; + /** The stable key the label is translated by (the tool's name, or "generic"); absent on steps stored before labels followed the language. */ + key?: string | null; + /** Structured facts for that label: page, unreadOnly, folder, withSearch, terms, sources, name. */ + args?: Record | null; } export interface ApprovalDetail { @@ -85,8 +89,8 @@ export type StreamErrorCode = 'unavailable' | 'timeout' | 'rate_limited' | 'too_ export type StreamEvent = | { type: 'start'; messageId: string } | { type: 'delta'; text: string } - | { type: 'tool_started'; id: string; label: string; code: ToolCode | null; at?: number | null; detail?: string | null } - | { type: 'tool_finished'; id: string; label: string; status: Exclude; code: ToolCode | null; at?: number | null; detail?: string | null } + | { type: 'tool_started'; id: string; label: string; code: ToolCode | null; at?: number | null; detail?: string | null; key?: string | null; args?: Record | null } + | { type: 'tool_finished'; id: string; label: string; status: Exclude; code: ToolCode | null; at?: number | null; detail?: string | null; key?: string | null; args?: Record | null } | ({ type: 'approval_required' } & ApprovalRequest) | ({ type: 'file'; messageId: string } & HelplyAttachment) | { type: 'done'; messageId: string; awaitingApproval: boolean; title: string } diff --git a/src/Schuly.Frontend/src/app/pages/helply/helply-timeline.spec.ts b/src/Schuly.Frontend/src/app/pages/helply/helply-timeline.spec.ts index 72fde93..c54ea90 100644 --- a/src/Schuly.Frontend/src/app/pages/helply/helply-timeline.spec.ts +++ b/src/Schuly.Frontend/src/app/pages/helply/helply-timeline.spec.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { HelplySettings, ToolActivity } from '../../core/helply-types'; -import { buildTimeline, chainBlocks, chainSummary, runDetail, runSummary, stepIcon } from './helply-timeline'; +import { dictionaryLookup, useLookup } from '../../core/i18n'; +import de from '../../../../public/i18n/de.json'; +import en from '../../../../public/i18n/en.json'; +import rm from '../../../../public/i18n/rm.json'; +import { buildTimeline, chainBlocks, chainSummary, runDetail, runSummary, stepIcon, stepLabel, toolIcon } from './helply-timeline'; import { starterPrompts } from './helply-suggestions'; const tool = (id: string, at: number | null, change: Partial = {}): ToolActivity => ({ @@ -106,6 +110,81 @@ describe('runDetail and stepIcon', () => { }); }); +describe('translated step labels', () => { + const keyed = (id: string, key: string | null, change: Partial = {}): ToolActivity => ({ id, label: 'Read your profile', status: 'done', at: 0, key, ...change }); + + afterEach(() => useLookup('en', dictionaryLookup(en))); + + it('follows the UI language, so a German UI has no English step', () => { + useLookup('de', dictionaryLookup(de, 'de-CH')); + const timeline = buildTimeline('', [keyed('a', 'get_profile'), keyed('b', 'list_grades', { label: 'Looked up your grades' }), keyed('c', 'list_exams', { label: 'x' })]); + const steps = timeline.chain.flatMap((e) => (e.kind === 'step' ? [e.label] : [])); + expect(steps).toEqual(['Dein Profil gelesen', 'Deine Noten nachgeschlagen', 'Deine Prüfungen angeschaut']); + expect(chainSummary(timeline)).toBe('Dein Profil gelesen · Deine Noten nachgeschlagen · 3 Schritte'); + }); + + it('uses the running sentence while running, failed or denied and the done sentence when done', () => { + useLookup('de', dictionaryLookup(de, 'de-CH')); + expect(stepLabel(keyed('a', 'get_profile', { status: 'running' }))).toBe('Dein Profil wird gelesen'); + expect(stepLabel(keyed('a', 'get_profile', { status: 'failed' }))).toBe('Dein Profil wird gelesen'); + expect(stepLabel(keyed('a', 'get_profile', { status: 'done' }))).toBe('Dein Profil gelesen'); + }); + + it('translates the new tools in every language', () => { + for (const [lang, dict] of [['de', de], ['en', en], ['rm', rm]] as const) { + useLookup(lang, dictionaryLookup(dict)); + for (const key of ['search', 'list_subjects', 'list_assessments']) + expect(stepLabel(keyed('a', key, { label: 'ENGLISH-FALLBACK' }))).not.toBe('ENGLISH-FALLBACK'); + } + useLookup('rm', dictionaryLookup(rm)); + expect(stepLabel(keyed('a', 'list_subjects', { status: 'running' }))).toBe('Tes materias vegnan tschertgadas'); + }); + + it('keeps the English label for an unknown key or a step stored without one', () => { + useLookup('de', dictionaryLookup(de, 'de-CH')); + expect(stepLabel(keyed('a', 'tool_from_a_newer_server', { label: 'Doing something new' }))).toBe('Doing something new'); + expect(stepLabel(keyed('a', null, { label: 'Read your profile' }))).toBe('Read your profile'); + expect(stepLabel({ label: 'Read your profile', status: 'done' })).toBe('Read your profile'); + expect(buildTimeline('', [keyed('a', null)]).chain[0]).toMatchObject({ label: 'Read your profile', icon: 'lucideUser' }); + }); + + it('names a tool without a label of its own', () => { + useLookup('de', dictionaryLookup(de, 'de-CH')); + expect(stepLabel(keyed('a', 'generic', { args: { name: 'Stundenplan' } }))).toBe('Stundenplan verwendet'); + expect(stepLabel(keyed('a', 'generic'))).toBe('Ein Tool verwendet'); + }); + + it('translates the notes under a step and folds a run of pages', () => { + useLookup('de', dictionaryLookup(de, 'de-CH')); + const mail = (id: string, page: string) => keyed(id, 'list_mail', { args: { page, unreadOnly: 'true', folder: 'sentitems' } }); + const single = buildTimeline('', [mail('a', '2')]).chain[0]; + expect(single).toMatchObject({ kind: 'step', detail: 'Seite 2 · nur ungelesene · gesendete Mails' }); + const folded = buildTimeline('', [mail('a', '2'), mail('b', '3'), mail('c', '4')]).chain[0]; + expect(folded).toMatchObject({ count: 3, detail: '3 Seiten · nur ungelesene · gesendete Mails' }); + const repeated = buildTimeline('', [keyed('a', 'get_profile'), keyed('b', 'get_profile')]).chain[0]; + expect(repeated).toMatchObject({ label: 'Dein Profil gelesen', detail: '2 Mal' }); + }); + + it('translates search terms and sources', () => { + useLookup('de', dictionaryLookup(de, 'de-CH')); + const step = buildTimeline('', [keyed('a', 'search', { args: { terms: '3', sources: 'files,mail' } })]).chain[0]; + expect(step).toMatchObject({ label: 'Dateien, Notizen, Mails und Chats durchsucht', detail: '3 Suchbegriffe · in Dateien, Mails' }); + }); + + it('shows the English detail of a step stored without args, or whose args it cannot say', () => { + useLookup('de', dictionaryLookup(de, 'de-CH')); + expect(buildTimeline('', [keyed('a', null, { detail: 'page 2 · unread only' })]).chain[0]).toMatchObject({ detail: 'page 2 · unread only' }); + expect(buildTimeline('', [keyed('a', 'list_mail', { detail: 'page 2', args: { somethingNew: 'x' } })]).chain[0]).toMatchObject({ detail: 'page 2' }); + }); + + it('picks an icon by key', () => { + expect(toolIcon({ key: 'list_mail', label: 'x' })).toBe('lucideMail'); + expect(toolIcon({ key: 'list_assessments', label: 'x' })).toBe('lucideNotebookPen'); + expect(toolIcon({ key: 'search', label: 'x' })).toBe('lucideSearch'); + expect(toolIcon({ key: 'generic', label: 'Used Something' })).toBe('lucideWrench'); + }); +}); + describe('starterPrompts', () => { const settings = (areas: HelplySettings['permissions']['areas'], workspace = false): HelplySettings => ({ permissions: { areas, allowDestructive: false, microsoftAccountIds: null }, diff --git a/src/Schuly.Frontend/src/app/pages/helply/helply-timeline.ts b/src/Schuly.Frontend/src/app/pages/helply/helply-timeline.ts index c236773..a6bdb80 100644 --- a/src/Schuly.Frontend/src/app/pages/helply/helply-timeline.ts +++ b/src/Schuly.Frontend/src/app/pages/helply/helply-timeline.ts @@ -33,6 +33,7 @@ export interface ReplyTimeline { } const ICONS: [RegExp, string][] = [ + [/profile|school account/i, 'lucideUser'], [/\bcode\b/i, 'lucideSquareTerminal'], [/workspace file|shared a file|sharing a file/i, 'lucideFileCode'], [/mail|email/i, 'lucideMail'], @@ -44,7 +45,6 @@ const ICONS: [RegExp, string][] = [ [/agenda|calendar/i, 'lucideCalendar'], [/onedrive|document|file|folder/i, 'lucideFileText'], [/search/i, 'lucideSearch'], - [/profile|school account/i, 'lucideUser'], ]; /** The lucide icon for a step, from its friendly label; a wrench for anything else. */ @@ -52,18 +52,118 @@ export function stepIcon(label: string): string { return ICONS.find(([pattern]) => pattern.test(label))?.[1] ?? 'lucideWrench'; } +const KEY_ICONS: [RegExp, string][] = [ + [/^bash$/, 'lucideSquareTerminal'], + [/^(write|share)_file$/, 'lucideFileCode'], + [/mail/, 'lucideMail'], + [/chat|channel|^list_teams$/, 'lucideMessagesSquare'], + [/notebook|section|page/, 'lucideNotebookText'], + [/grade|semester_report|subject/, 'lucideGraduationCap'], + [/exam|assessment/, 'lucideNotebookPen'], + [/absence/, 'lucideCalendarX'], + [/agenda|calendar/, 'lucideCalendar'], + [/^search$/, 'lucideSearch'], + [/drive|document|file|folder|shared/, 'lucideFileText'], + [/profile|school_account/, 'lucideUser'], +]; + +/** The icon for a step: by its key when it has one, else from its (English) label. */ +export function toolIcon(tool: Pick): string { + if (tool.key && tool.key !== 'generic') return KEY_ICONS.find(([pattern]) => pattern.test(tool.key!))?.[1] ?? 'lucideWrench'; + return stepIcon(tool.label); +} + +/** What the language file knows under a key, or null when it does not (the lookup answers with the key itself then). */ +function known(key: string, params?: Record): string | null { + const text = t(key, params); + return text === key ? null : text; +} + +const PHASES: Record = { running: 'running', done: 'done', failed: 'running', denied: 'running' }; + +/** + * A step's label in the UI language: the server's stable key ("list_grades", or "generic" for a tool + * without a label of its own) plus its args pick the sentence from the language file. A step without a + * key (stored before labels followed the language) or with a key this app has no sentence for keeps the + * server's English label. + */ +export function stepLabel(tool: Pick): string { + if (!tool.key) return tool.label; + const phase = PHASES[tool.status]; + if (tool.key === 'generic') { + const name = tool.args?.['name']; + return (name ? known(`helply.tool.generic.${phase}Named`, { name }) : known(`helply.tool.generic.${phase}`)) ?? tool.label; + } + return known(`helply.tool.${tool.key}.${phase}`) ?? tool.label; +} + +/** One small fact under a step ("page 2", "unread only"). */ +interface Note { + /** Same id, same fact: folded runs show it once. */ + id: string; + text: string; + /** A page number: a run of these folds into "4 pages". */ + paged: boolean; +} + const PAGE = /^page \d+$/; +/** Notes of a step stored without args: the server's English detail, split into its parts. */ +function legacyNotes(detail: string | null | undefined): Note[] { + return detail ? detail.split(' · ').map((text) => ({ id: text, text, paged: PAGE.test(text) })) : []; +} + +function argNote(key: string, value: string): Note | null { + const id = `${key}:${value}`; + switch (key) { + case 'name': + return null; + case 'page': { + const text = known('helply.note.page', { count: Number(value) }); + return text ? { id, text, paged: true } : null; + } + case 'folder': { + const text = known(`helply.note.folder.${value}`); + return text ? { id, text, paged: false } : null; + } + case 'terms': { + const text = known('helply.note.terms', { count: Number(value) }); + return text ? { id, text, paged: false } : null; + } + case 'sources': { + const names = value.split(',').map((source) => known(`helply.note.source.${source}`)); + const text = names.every((n) => n) ? known('helply.note.sources', { list: names.join(', ') }) : null; + return text ? { id, text, paged: false } : null; + } + default: { + const text = known(`helply.note.${key}`); + return text ? { id, text, paged: false } : null; + } + } +} + +/** The notes of a step in the UI language; the English detail stands in when the args hold something this app cannot say. */ +function stepNotes(tool: Pick): Note[] { + const entries = Object.entries(tool.args ?? {}).filter(([key]) => key !== 'name'); + if (!entries.length) return legacyNotes(tool.detail); + const notes = entries.map(([key, value]) => argNote(key, value)); + return notes.every((n): n is Note => n !== null) ? notes : legacyNotes(tool.detail); +} + +function runNotes(runs: Note[][]): string | null { + if (runs.length === 1) return runs[0].map((n) => n.text).join(' · ') || null; + const parts = runs.flat(); + const paged = parts.some((p) => p.paged); + const others = new Map(parts.filter((p) => !p.paged).map((p) => [p.id, p.text])); + return [t(paged ? 'helply.run.pages' : 'helply.run.times', { count: runs.length }), ...others.values()].join(' · '); +} + /** * The note under a folded run: "4 pages" when it paged through results, "3 times" otherwise, plus any * other notes the steps carried ("unread only"). A single step keeps its own detail. */ export function runDetail(details: (string | null | undefined)[]): string | null { - if (details.length === 1) return details[0] || null; - const parts = details.flatMap((d) => (d ? d.split(' · ') : [])); - const paged = parts.some((p) => PAGE.test(p)); - const others = [...new Set(parts.filter((p) => !PAGE.test(p)))]; - return [t(paged ? 'helply.run.pages' : 'helply.run.times', { count: details.length }), ...others].join(' · '); + return runNotes(details.map(legacyNotes)); } /** @@ -74,7 +174,7 @@ export function runDetail(details: (string | null | undefined)[]): string | null export function buildTimeline(text: string, tools: readonly ToolActivity[]): ReplyTimeline { if (!tools.length) return { chain: [], answer: text.trim(), steps: 0 }; const chain: ChainEntry[] = []; - const details = new Map(); + const notes = new Map(); let position = 0; for (const tool of tools) { const at = Math.min(Math.max(tool.at ?? 0, position), text.length); @@ -82,26 +182,27 @@ export function buildTimeline(text: string, tools: readonly ToolActivity[]): Rep if (between) chain.push({ kind: 'text', key: `text-${position}`, text: between }); position = at; + const label = stepLabel(tool); const last = chain[chain.length - 1]; - if (last?.kind === 'step' && !last.code && !tool.code && last.label === tool.label && last.status === tool.status) { + if (last?.kind === 'step' && !last.code && !tool.code && last.label === label && last.status === tool.status) { last.count++; - details.get(last)!.push(tool.detail); + notes.get(last)!.push(stepNotes(tool)); continue; } const step: ChainStep = { kind: 'step', key: tool.id, - label: tool.label, + label, status: tool.status, detail: null, count: 1, - icon: stepIcon(tool.label), + icon: toolIcon(tool), code: tool.code ?? null, }; - details.set(step, [tool.detail]); + notes.set(step, [stepNotes(tool)]); chain.push(step); } - for (const [step, notes] of details) step.detail = runDetail(notes); + for (const [step, runs] of notes) step.detail = runNotes(runs); return { chain, answer: text.slice(position).trim(), steps: tools.length }; } diff --git a/src/Schuly.Infrastructure/Helply/HelplyTranscript.cs b/src/Schuly.Infrastructure/Helply/HelplyTranscript.cs index 09cd6ee..c300304 100644 --- a/src/Schuly.Infrastructure/Helply/HelplyTranscript.cs +++ b/src/Schuly.Infrastructure/Helply/HelplyTranscript.cs @@ -64,6 +64,10 @@ public sealed class HelplyStoredTool public int? At { get; set; } /// A short note on the step built only from harmless arguments ("page 2", "unread only"); never names, addresses or search text. public string? Detail { get; set; } + /// The stable key the web app translates the step's label by (the tool's name, or "generic"); null on steps stored before labels followed the language, which show as it is. + public string? Key { get; set; } + /// The structured arguments of that label (page number, flags, the tool's title); never names, addresses or search text. + public Dictionary? Args { get; set; } } public sealed class HelplyStoredDetail diff --git a/src/Schuly.Tests/Helply/HelplyUnitTests.cs b/src/Schuly.Tests/Helply/HelplyUnitTests.cs index 9bf1214..65505f3 100644 --- a/src/Schuly.Tests/Helply/HelplyUnitTests.cs +++ b/src/Schuly.Tests/Helply/HelplyUnitTests.cs @@ -67,6 +67,48 @@ public async Task A_step_detail_says_only_harmless_things() await Assert.That(HelplyToolLabels.Detail("list_grades", null)).IsNull(); } + [Test] + public async Task A_step_label_carries_a_stable_key_and_structured_args() + { + static Dictionary Args(string json) => System.Text.Json.JsonSerializer.Deserialize>(json)!.ToDictionary(p => p.Key, p => (object?)p.Value); + + await Assert.That(HelplyToolLabels.Key("list_grades")).IsEqualTo("list_grades"); + await Assert.That(HelplyToolLabels.Key("list_subjects")).IsEqualTo("list_subjects"); + await Assert.That(HelplyToolLabels.Key("list_assessments")).IsEqualTo("list_assessments"); + await Assert.That(HelplyToolLabels.Key("brand_new_tool")).IsEqualTo(HelplyToolLabels.GenericKey); + await Assert.That(HelplyToolLabels.Args("list_grades", null, Args("{}"))).IsNull(); + await Assert.That(HelplyToolLabels.Args("brand_new_tool", "Brand new", null)!["name"]).IsEqualTo("Brand new"); + + var mail = HelplyToolLabels.Args("list_mail", "List mail", Args("{\"offset\":20,\"limit\":20,\"unreadOnly\":true,\"folder\":\"SentItems\",\"search\":\"from:anna@example.ch\"}"))!; + await Assert.That(mail["page"]).IsEqualTo("2"); + await Assert.That(mail["unreadOnly"]).IsEqualTo("true"); + await Assert.That(mail["folder"]).IsEqualTo("sentitems"); + await Assert.That(mail["withSearch"]).IsEqualTo("true"); + await Assert.That(mail.ContainsKey("name")).IsFalse(); + await Assert.That(mail.Values.Any(v => v.Contains("anna"))).IsFalse(); + + var read = HelplyToolLabels.Args("read_mail", null, Args("{\"messageId\":\"abc\",\"offset\":4000}"))!; + await Assert.That(read["continued"]).IsEqualTo("true"); + + var search = Args("{\"query\":\"Prüfung\",\"terms\":[\"Test\",\"LK\",\" \"],\"sources\":[\"Mail\",\"files\",\"bogus\"]}"); + var searchArgs = HelplyToolLabels.Args("search", null, search)!; + await Assert.That(searchArgs["terms"]).IsEqualTo("2"); + await Assert.That(searchArgs["sources"]).IsEqualTo("files,mail"); + await Assert.That(HelplyToolLabels.Detail("search", search)).IsEqualTo("2 terms · in files, mail"); + await Assert.That(HelplyToolLabels.Args("list_assessments", null, Args("{\"offset\":100,\"limit\":100}"))!["page"]).IsEqualTo("2"); + } + + [Test] + public async Task A_stored_step_without_a_key_still_maps_to_a_dto_with_its_english_label() + { + var old = System.Text.Json.JsonSerializer.Deserialize("{\"id\":\"c1\",\"label\":\"Read your profile\",\"status\":\"done\",\"at\":3}", new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!; + var dto = Schuly.Application.Dtos.HelplyDtoMapper.ToDto(old); + + await Assert.That(dto.Label).IsEqualTo("Read your profile"); + await Assert.That(dto.Key).IsNull(); + await Assert.That(dto.Args).IsNull(); + } + [Test] public async Task Approval_summaries_describe_exactly_what_will_happen() {