feat(ai): configurable tool-call limit (0=unlimited) + Stop button

Replace the fixed 30-round cap with a Settings value (AiMaxToolRounds,
Settings → AI Assistant). 0 = unlimited, for long automation runs left
going for hours. The chat's Send button turns into Stop while the agent
is running so any run — bounded or unlimited — can be aborted (via the
CancellationToken; the loop also checks it each round).

Docs: ARCHITECTURE gains the configurable-limit note and a generic
"pair with a self-hosted gateway = hardware assistant" usage section
(no private endpoints).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 23:54:40 +08:00
co-authored by Claude Fable 5
parent d6135237e8
commit 7ce65536f6
7 changed files with 49 additions and 12 deletions
+9 -6
View File
@@ -14,9 +14,9 @@ public sealed class AgentHost
private readonly OpenAiChatClient _client;
private readonly AiTools _tools;
private readonly JsonArray _messages = new();
// 單次 SendAsync 的工具呼叫輪數上限(防失控迴圈的保險)。硬體任務常需多輪
// (逐一操作多個 port、多次讀寫、power-cycle 等待…),故放寬到 30
private const int MaxToolRounds = 30;
// 單次 SendAsync 的工具呼叫輪數上限(防失控迴圈的保險)。由 Settings 設定,
// 0 = 無上限(自動化長跑用;執行中可按 Stop 中止,取消透過 CancellationToken
private readonly int _maxRounds;
public event Action<string>? AssistantText; // 最終文字回應
public event Action<string>? ToolActivity; // 「呼叫 serial_write …」之類過程
@@ -28,10 +28,11 @@ public sealed class AgentHost
"破壞性動作(關插座 / power-cycle)會由使用者在 GUI 確認,你只需正常呼叫工具。" +
"serial 操作前必須先 serial_attach 到 GUI 已開啟的 session。";
public AgentHost(OpenAiChatClient client, AiTools tools, string? systemPrompt)
public AgentHost(OpenAiChatClient client, AiTools tools, string? systemPrompt, int maxRounds)
{
_client = client;
_tools = tools;
_maxRounds = maxRounds;
_messages.Add(new JsonObject
{
["role"] = "system",
@@ -45,8 +46,10 @@ public sealed class AgentHost
_messages.Add(new JsonObject { ["role"] = "user", ["content"] = userText });
var tools = _tools.GetSchemas();
for (int round = 0; round < MaxToolRounds; round++)
// _maxRounds <= 0 → 無上限(自動化長跑;靠 Stop / CancellationToken 中止)
for (int round = 0; _maxRounds <= 0 || round < _maxRounds; round++)
{
ct.ThrowIfCancellationRequested();
Status?.Invoke("thinking");
var msg = await _client.CompleteAsync(_messages, tools, ct);
_messages.Add((JsonObject)msg.DeepClone());
@@ -85,7 +88,7 @@ public sealed class AgentHost
}
}
AssistantText?.Invoke("(已達工具呼叫上限,停止。請縮小問題或分步再試。)");
AssistantText?.Invoke($"(已達工具呼叫上限 {_maxRounds} 次,停止。可到 Settings → AI Assistant 調高或設 0 = 無上限,或分步再試。)");
Status?.Invoke("done");
}
}
+1
View File
@@ -188,6 +188,7 @@ public sealed class AboutView : UserControl
"The AI Chat pane now renders proper chat bubbles (your messages on the right, the AI's on the left) with full Markdown — code blocks, tables, lists and inline `code` all display nicely.",
"When you send a prompt, an animated \"…\" thinking bubble appears while the AI works and disappears the moment the reply arrives — so you always know it's running.",
"Under the hood this uses WebView2 (built into Windows 11); the model dropdown, [AI] serial tagging, and PDU confirmations all work exactly as before.",
"New setting: Max tool calls per message (Settings → AI Assistant). Set it to 0 for unlimited — handy for long automation runs you leave going — and press Stop in the chat to abort any run in progress.",
]),
new("0.6.0", new DateOnly(2026, 7, 5), "Built-in AI Assistant — drive serial & PDU in plain language",
[
+16 -5
View File
@@ -57,7 +57,7 @@ public sealed class AiChatView : UserControl
};
_input.KeyDown += (_, e) =>
{
if (e.KeyCode == Keys.Enter && !e.Shift) { e.Handled = e.SuppressKeyPress = true; OnSend(); }
if (e.KeyCode == Keys.Enter && !e.Shift && !_running) { e.Handled = e.SuppressKeyPress = true; OnSend(); }
};
var rightCol = new Panel { Dock = DockStyle.Right, Width = 178, BackColor = Theme.RailBack, Padding = new Padding(8, 0, 0, 0) };
@@ -163,7 +163,7 @@ public sealed class AiChatView : UserControl
var key = CredentialVault.Get("ETTerms/AiApiKey") ?? "";
_client = new OpenAiChatClient(s.AiBaseUrl, key, s.AiModel);
_tools = new AiTools { ConfirmAsync = ConfirmOnUiAsync };
_agent = new AgentHost(_client, _tools, s.AiSystemPrompt);
_agent = new AgentHost(_client, _tools, s.AiSystemPrompt, s.AiMaxToolRounds);
_agent.AssistantText += t => Ui(() => { HideThinking(); AddAI(t); });
_agent.ToolActivity += t => Ui(() => AddTool(t));
_agent.Status += st => Ui(() => { if (st == "thinking") ShowThinking(); });
@@ -216,20 +216,31 @@ public sealed class AiChatView : UserControl
return tcs.Task;
}
private bool _running;
private async void OnSend()
{
if (_agent == null) return;
if (_running) { _cts?.Cancel(); return; } // 執行中再按 = 中止(長跑用)
var text = _input.Text.Trim();
if (text.Length == 0) return;
_input.Clear();
AddUser(text);
ShowThinking();
_send.Enabled = false;
SetRunning(true);
_cts = new CancellationTokenSource();
try { await _agent.SendAsync(text, _cts.Token); }
catch (OperationCanceledException) { AddNote("(已取消"); }
catch (OperationCanceledException) { AddNote("(已停止"); }
catch (Exception ex) { AddError(ex.Message); }
finally { HideThinking(); _send.Enabled = true; }
finally { HideThinking(); SetRunning(false); }
}
private void SetRunning(bool running)
{
_running = running;
_send.Text = running ? "■ Stop" : "Send ⏎";
_send.FlatAppearance.BorderColor = running ? Color.FromArgb(210, 120, 120) : Theme.Accent;
}
// ── WebView2 interop(呼叫 ChatHtml 裡的 JS 函式)──
+15
View File
@@ -339,8 +339,22 @@ public sealed class SettingsView : UserControl
PlaceholderText = "(optional) override the assistant persona / system prompt"
};
var maxRounds = new NumericUpDown
{
Width = 100, Minimum = 0, Maximum = 100000, Increment = 10, Value = s.AiMaxToolRounds,
BackColor = Theme.TabBack, ForeColor = Theme.Text, BorderStyle = BorderStyle.FixedSingle
};
flow.Controls.Add(MakeRow("Base URL (with /v1)", baseUrl));
flow.Controls.Add(MakeRow("API Key", apiKey));
flow.Controls.Add(MakeRow("Max tool calls / message", maxRounds));
flow.Controls.Add(new Label
{
Text = "How many tool calls the assistant may chain per message before it stops (a runaway-loop guard).\n" +
"0 = unlimited — for long automation runs. Every round costs tokens; press Stop in the chat to abort.",
AutoSize = false, Width = 620, Height = 34,
ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 0, 0, 6)
});
flow.Controls.Add(MakeSpacer(4));
flow.Controls.Add(new Label
{
@@ -363,6 +377,7 @@ public sealed class SettingsView : UserControl
{
s.AiBaseUrl = baseUrl.Text.Trim();
s.AiSystemPrompt = sysPrompt.Text.Trim();
s.AiMaxToolRounds = (int)maxRounds.Value;
s.Save();
var key = apiKey.Text;
if (string.IsNullOrEmpty(key)) CredentialVault.Delete("ETTerms/AiApiKey");
@@ -39,6 +39,9 @@ public sealed class AppSettings
public string AiModel { get; set; } = "";
/// <summary>系統提示詞(人設);空則用內建預設。</summary>
public string AiSystemPrompt { get; set; } = "";
/// <summary>AI agent 單次訊息的工具呼叫輪數上限(防失控迴圈的保險)。
/// **0 = 無上限**(自動化長跑用;注意每輪都燒 token/費用,執行中可按 Stop 中止)。</summary>
public int AiMaxToolRounds { get; set; } = 30;
// ── Keyword highlight(終端機關鍵字標色 + 分頁警示;Settings → Highlight 分頁設定)──
public bool KeywordHighlightEnabled { get; set; } = true;