Commit e6773c00 authored by 段廷瑞's avatar 段廷瑞

fix: markdown渲染优化;socket关闭异常处理

parent c6d90d06
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading;
using Tools.MyAgent; using Tools.MyAgent;
using Tools.MyAgent.Agent; using Tools.MyAgent.Agent;
using Tools.MyAgent.Kb;
namespace AgentTest namespace AgentTest
{ {
...@@ -11,6 +13,20 @@ namespace AgentTest ...@@ -11,6 +13,20 @@ namespace AgentTest
/// </summary> /// </summary>
public sealed class AgentEngineLogicTests : TestRunner public sealed class AgentEngineLogicTests : TestRunner
{ {
[Test]
public void Dispose_PreventsLateRunAndIsIdempotent()
{
var engine = new AgentEngine(new KbService());
int errorCount = 0;
engine.OnError += _ => errorCount++;
engine.Dispose();
engine.Dispose();
engine.RunAsync(new List<ChatMessage>(), CancellationToken.None).GetAwaiter().GetResult();
Assert.Equal(0, errorCount, "释放后的迟到请求不应再向 UI 上报网络错误");
}
// ── token 估算 ── // ── token 估算 ──
[Test] [Test]
......
...@@ -32,6 +32,26 @@ namespace AgentTest ...@@ -32,6 +32,26 @@ namespace AgentTest
Assert.Contains(rtf, @"\u26041?\u27861?\u-29766?", "正文缺失"); Assert.Contains(rtf, @"\u26041?\u27861?\u-29766?", "正文缺失");
} }
[Test]
public void ThinkingMarker_UsesFontSafeText()
{
string rtf = MarkdownRenderer.BuildChatRtf(
new List<ChatMessage>
{
new ChatMessage
{
Role = "assistant",
ThinkingContent = "正在分析",
Content = "回答",
Timestamp = DateTime.Now,
}
},
null);
Assert.Contains(rtf, "> Thinking...", "thinking 标记应使用可稳定渲染的文本");
Assert.NotContains(rtf, @"{\f2 \u9656?}", "thinking 标记不应切换到缺少字形的 Emoji 字体");
}
[Test] [Test]
public void OrderedList_StillRendered() public void OrderedList_StillRendered()
{ {
......
...@@ -26,6 +26,7 @@ namespace Tools.MyAgent.Agent ...@@ -26,6 +26,7 @@ namespace Tools.MyAgent.Agent
private readonly DialectService _dialect; // 内置方言/行业知识(程序目录 Agent/dialect + Agent/industry) private readonly DialectService _dialect; // 内置方言/行业知识(程序目录 Agent/dialect + Agent/industry)
private AgentProductContext _productContext; private AgentProductContext _productContext;
private CancellationTokenSource _cts; private CancellationTokenSource _cts;
private int _disposeStarted;
/// <summary>上下文 token 预算(由配置注入;默认 32000)</summary> /// <summary>上下文 token 预算(由配置注入;默认 32000)</summary>
private int _contextTokens = 32000; private int _contextTokens = 32000;
...@@ -72,6 +73,8 @@ namespace Tools.MyAgent.Agent ...@@ -72,6 +73,8 @@ namespace Tools.MyAgent.Agent
public bool IsRunning { get; private set; } public bool IsRunning { get; private set; }
private bool IsDisposed => Volatile.Read(ref _disposeStarted) != 0;
/// <summary>本会话累计 prompt tokens(D16 用量统计;由 SendAndLogAsync 累加,NewChat 时重置)</summary> /// <summary>本会话累计 prompt tokens(D16 用量统计;由 SendAndLogAsync 累加,NewChat 时重置)</summary>
public int TotalPromptTokens { get; private set; } public int TotalPromptTokens { get; private set; }
...@@ -235,7 +238,7 @@ namespace Tools.MyAgent.Agent ...@@ -235,7 +238,7 @@ namespace Tools.MyAgent.Agent
/// </summary> /// </summary>
public async Task RunAsync(IReadOnlyList<ChatMessage> history, CancellationToken ct) public async Task RunAsync(IReadOnlyList<ChatMessage> history, CancellationToken ct)
{ {
if (IsRunning) return; if (IsDisposed || IsRunning) return;
IsRunning = true; IsRunning = true;
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct); _cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
OnStatusChanged?.Invoke(); OnStatusChanged?.Invoke();
...@@ -325,16 +328,21 @@ namespace Tools.MyAgent.Agent ...@@ -325,16 +328,21 @@ namespace Tools.MyAgent.Agent
} }
} }
if (!IsDisposed)
{
OnComplete?.Invoke(); OnComplete?.Invoke();
if (_toolTrace.Count > 0) if (_toolTrace.Count > 0)
OnToolTrace?.Invoke(_toolTrace.ToArray()); OnToolTrace?.Invoke(_toolTrace.ToArray());
} }
}
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
if (!IsDisposed)
OnError?.Invoke("已取消。"); OnError?.Invoke("已取消。");
} }
catch (Exception ex) catch (Exception ex)
{ {
if (!IsDisposed)
OnError?.Invoke(DescribeError(ex)); OnError?.Invoke(DescribeError(ex));
} }
finally finally
...@@ -342,6 +350,7 @@ namespace Tools.MyAgent.Agent ...@@ -342,6 +350,7 @@ namespace Tools.MyAgent.Agent
IsRunning = false; IsRunning = false;
try { _cts?.Dispose(); } catch { } try { _cts?.Dispose(); } catch { }
_cts = null; _cts = null;
if (!IsDisposed)
OnStatusChanged?.Invoke(); OnStatusChanged?.Invoke();
} }
} }
...@@ -1072,6 +1081,7 @@ namespace Tools.MyAgent.Agent ...@@ -1072,6 +1081,7 @@ namespace Tools.MyAgent.Agent
public void Dispose() public void Dispose()
{ {
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) return;
Cancel(); Cancel();
try { _client.Dispose(); } catch { } try { _client.Dispose(); } catch { }
try { _backupClient?.Dispose(); } catch { } try { _backupClient?.Dispose(); } catch { }
......
...@@ -77,26 +77,43 @@ namespace Tools.MyAgent.Kb ...@@ -77,26 +77,43 @@ namespace Tools.MyAgent.Kb
_emb = emb; _emb = emb;
} }
/// <summary>启动:打开/建库 + 后台构建(幂等)</summary> /// <summary>启动索引:只在后台打开 SQLite 并开始构建,调用方不会被建库 IO 阻塞。</summary>
public void Start() public void Start()
{ {
lock (_lock) lock (_lock)
{ {
if (_started) return; if (_started) return;
_started = true; _started = true;
}
Task.Run(StartCore);
}
private void StartCore()
{
try try
{ {
_cts.Token.ThrowIfCancellationRequested();
lock (_lock)
{
if (_cts.IsCancellationRequested) return;
OpenDb(); OpenDb();
} }
if (_cts.IsCancellationRequested || _conn == null) return;
_sweepTimer = new Timer(_ => Invalidate(), null, SweepInterval, SweepInterval);
Invalidate();
}
catch (OperationCanceledException)
{
// 关闭窗口时取消尚未完成的后台初始化。
}
catch (Exception ex) catch (Exception ex)
{
lock (_lock)
{ {
_conn = null; _conn = null;
OnStatus?.Invoke("索引不可用:" + ex.Message);
return;
} }
OnStatus?.Invoke("索引不可用:" + ex.Message);
} }
_sweepTimer = new Timer(_ => Invalidate(), null, SweepInterval, SweepInterval);
Invalidate();
} }
/// <summary>标记变更,调度后台增量重建(带去抖;构建中则完成后自动续跑)</summary> /// <summary>标记变更,调度后台增量重建(带去抖;构建中则完成后自动续跑)</summary>
......
...@@ -410,6 +410,19 @@ namespace Tools.MyAgent.Kb ...@@ -410,6 +410,19 @@ namespace Tools.MyAgent.Kb
/// <summary>搜索范围是否有效(目录存在)</summary> /// <summary>搜索范围是否有效(目录存在)</summary>
public bool IsSearchValid => !string.IsNullOrEmpty(SearchRoot) && Directory.Exists(SearchRoot); public bool IsSearchValid => !string.IsNullOrEmpty(SearchRoot) && Directory.Exists(SearchRoot);
private const int AutoIndexFileThreshold = 2000;
/// <summary>
/// 仅为小型知识库自动启动索引。文件数未知或超过阈值时保持未建索引,避免打开窗口触发大规模 SQLite 建库。
/// </summary>
public void EnsureIndexStartedIfSmall(int maxFiles = AutoIndexFileThreshold)
{
if (maxFiles < 1) return;
int count = FileCount;
if (count < 0 || count > maxFiles) return;
EnsureIndexStarted();
}
/// <summary> /// <summary>
/// 启动后台检索索引(幂等):索引目录固定为输出目录下 index 文件夹,与所选知识库无关。 /// 启动后台检索索引(幂等):索引目录固定为输出目录下 index 文件夹,与所选知识库无关。
/// <para>索引覆盖整个知识库根目录;搜索范围限定时在查询侧过滤,索引本身始终按根目录构建。</para> /// <para>索引覆盖整个知识库根目录;搜索范围限定时在查询侧过滤,索引本身始终按根目录构建。</para>
......
...@@ -145,8 +145,7 @@ namespace Tools.MyAgent ...@@ -145,8 +145,7 @@ namespace Tools.MyAgent
{ {
sb.Append(@"\cf"); sb.Append(ClThinking); sb.Append(@"\cf"); sb.Append(ClThinking);
sb.Append(@"\i\fs18 "); sb.Append(@"\i\fs18 ");
sb.Append(EscapeChar('▸')); sb.Append("> Thinking...");
sb.Append(" Thinking...");
sb.Append(@"\par "); sb.Append(@"\par ");
sb.Append(Escape(msg.ThinkingContent)); sb.Append(Escape(msg.ThinkingContent));
sb.Append(@"\i0\fs20\cf"); sb.Append(ClText); sb.Append(@"\i0\fs20\cf"); sb.Append(ClText);
......
...@@ -208,7 +208,7 @@ namespace Tools.MyAgent ...@@ -208,7 +208,7 @@ namespace Tools.MyAgent
// //
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 17F); this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 17F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(817, 638); this.ClientSize = new System.Drawing.Size(1000, 600);
this.Controls.Add(this.panelChat); this.Controls.Add(this.panelChat);
this.Controls.Add(this.panelToolbar); this.Controls.Add(this.panelToolbar);
this.Controls.Add(this.panelBottom); this.Controls.Add(this.panelBottom);
...@@ -216,6 +216,7 @@ namespace Tools.MyAgent ...@@ -216,6 +216,7 @@ namespace Tools.MyAgent
this.KeyPreview = true; this.KeyPreview = true;
this.Margin = new System.Windows.Forms.Padding(5, 6, 5, 6); this.Margin = new System.Windows.Forms.Padding(5, 6, 5, 6);
this.Name = "wMyAgent"; this.Name = "wMyAgent";
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
this.Text = "智能助手"; this.Text = "智能助手";
this.panelBottom.ResumeLayout(false); this.panelBottom.ResumeLayout(false);
this.panelBottom.PerformLayout(); this.panelBottom.PerformLayout();
......
...@@ -29,6 +29,8 @@ namespace Tools.MyAgent ...@@ -29,6 +29,8 @@ namespace Tools.MyAgent
private RichTextBox _rtfFallback; private RichTextBox _rtfFallback;
private AgentConfig _config = new AgentConfig(); private AgentConfig _config = new AgentConfig();
private CancellationTokenSource _sendCts; private CancellationTokenSource _sendCts;
private volatile bool _isClosing;
private int _disposeStarted;
private string _historyDir; private string _historyDir;
private bool _sessionSaved; private bool _sessionSaved;
/// <summary>v2.3 待发送附图列表:粘贴截图后暂存,随下一轮提问发送(启用图片提问时;最多 5 张)</summary> /// <summary>v2.3 待发送附图列表:粘贴截图后暂存,随下一轮提问发送(启用图片提问时;最多 5 张)</summary>
...@@ -143,6 +145,8 @@ namespace Tools.MyAgent ...@@ -143,6 +145,8 @@ namespace Tools.MyAgent
/// </summary> /// </summary>
partial void OnFormDisposing() partial void OnFormDisposing()
{ {
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) return;
_isClosing = true;
SaveUnsavedSession(); SaveUnsavedSession();
try { _cmdNameFont?.Dispose(); } catch { } try { _cmdNameFont?.Dispose(); } catch { }
try { _cmdDescFont?.Dispose(); } catch { } try { _cmdDescFont?.Dispose(); } catch { }
...@@ -339,7 +343,8 @@ namespace Tools.MyAgent ...@@ -339,7 +343,8 @@ namespace Tools.MyAgent
_kb.SetOutputRoot(DefaultKbRoot); _kb.SetOutputRoot(DefaultKbRoot);
// 评估#1:存储自动清理(日志 30 天 / 中间产物 7~30 天 / 正式产出 90 天;后台执行不阻塞启动) // 评估#1:存储自动清理(日志 30 天 / 中间产物 7~30 天 / 正式产出 90 天;后台执行不阻塞启动)
try { System.Threading.Tasks.Task.Run(() => StorageCleanup.Run(DefaultKbRoot)); } catch { } try { System.Threading.Tasks.Task.Run(() => StorageCleanup.Run(DefaultKbRoot)); } catch { }
_kb.EnsureIndexStarted(); // 后台构建知识库检索索引(含 docx/xlsx 内容),检索提速 // 大知识库不在窗口初始化阶段创建 SQLite 索引,避免打开业务引用时被 IO/SQLite 锁住。
// 小知识库由统计完成事件按需后台启动,实际搜索也会触发惰性启动。
RefreshScopeCombo(); // 先归一化搜索范围(命中下拉项/归一化为全部),再应用 RefreshScopeCombo(); // 先归一化搜索范围(命中下拉项/归一化为全部),再应用
_kb.SetScope(_config.SearchScope); _kb.SetScope(_config.SearchScope);
_historyDir = HistoryManager.EnsureHistoryDir(WorkingDirForHistory()); _historyDir = HistoryManager.EnsureHistoryDir(WorkingDirForHistory());
...@@ -353,6 +358,28 @@ namespace Tools.MyAgent ...@@ -353,6 +358,28 @@ namespace Tools.MyAgent
// ── 事件绑定 ───────────────────────────────────── // ── 事件绑定 ─────────────────────────────────────
private void PostToUi(Action action)
{
if (action == null || _isClosing || IsDisposed || Disposing || !IsHandleCreated) return;
if (!InvokeRequired)
{
if (_isClosing || IsDisposed || Disposing) return;
action();
return;
}
try
{
BeginInvoke((MethodInvoker)delegate
{
if (_isClosing || IsDisposed || Disposing) return;
action();
});
}
catch (ObjectDisposedException) { }
catch (InvalidOperationException) { }
}
private void WireEvents() private void WireEvents()
{ {
lblNewChat.LinkClicked += (s, e) => NewChat(); lblNewChat.LinkClicked += (s, e) => NewChat();
...@@ -371,25 +398,30 @@ namespace Tools.MyAgent ...@@ -371,25 +398,30 @@ namespace Tools.MyAgent
// RichBox 模式的点击/悬停交互(路径点击、details 折叠切换) // RichBox 模式的点击/悬停交互(路径点击、details 折叠切换)
WireRtfEvents(); WireRtfEvents();
_engine.OnTextDelta += t => this.AsyncUI(() => OnAssistantTextDelta(t)); _engine.OnTextDelta += t => PostToUi(() => OnAssistantTextDelta(t));
_engine.OnThinkingDelta += t => this.AsyncUI(() => OnAssistantThinkingDelta(t)); _engine.OnThinkingDelta += t => PostToUi(() => OnAssistantThinkingDelta(t));
_engine.OnToolStatus += s => this.AsyncUI(() => OnToolStatusChanged(s)); _engine.OnToolStatus += s => PostToUi(() => OnToolStatusChanged(s));
_engine.OnFileProduced += f => this.AsyncUI(() => OnFileProduced(f)); _engine.OnFileProduced += f => PostToUi(() => OnFileProduced(f));
_engine.OnComplete += () => this.AsyncUI(OnAssistantComplete); _engine.OnComplete += () => PostToUi(OnAssistantComplete);
_engine.OnToolTrace += trace => this.AsyncUI(() => OnToolTrace(trace)); _engine.OnToolTrace += trace => PostToUi(() => OnToolTrace(trace));
_engine.OnError += m => this.AsyncUI(() => OnEngineError(m)); _engine.OnError += m => PostToUi(() => OnEngineError(m));
_engine.OnStatusChanged += () => this.AsyncUI(UpdateStatusIndicator); _engine.OnStatusChanged += () => PostToUi(UpdateStatusIndicator);
// 知识库文件数后台统计完成 → 回刷状态栏与欢迎语(窗体句柄未就绪时跳过,Load 时会重新触发) // 知识库文件数后台统计完成 → 回刷状态栏与欢迎语(窗体句柄未就绪时跳过,Load 时会重新触发)
_kb.StatsRefreshed += () => this.AsyncUI(() => _kb.StatsRefreshed += () =>
{
_kb.EnsureIndexStartedIfSmall();
PostToUi(() =>
{ {
if (!IsHandleCreated) return; if (!IsHandleCreated) return;
UpdateStatusIndicator(); UpdateStatusIndicator();
RefreshChat(); RefreshChat();
}); });
};
FormClosing += (s, e) => FormClosing += (s, e) =>
{ {
SaveCurrentSession(); _isClosing = true;
SaveUnsavedSession();
}; };
Load += (s, e) => Load += (s, e) =>
...@@ -686,6 +718,7 @@ namespace Tools.MyAgent ...@@ -686,6 +718,7 @@ namespace Tools.MyAgent
private void OnEngineError(string message) private void OnEngineError(string message)
{ {
if (_isClosing || IsDisposed || Disposing) return;
var last = LastAssistantMessage(); var last = LastAssistantMessage();
if (last != null && last.IsStreaming) if (last != null && last.IsStreaming)
{ {
...@@ -2277,16 +2310,26 @@ namespace Tools.MyAgent ...@@ -2277,16 +2310,26 @@ namespace Tools.MyAgent
} }
/// <summary>/kb <关键词>:直接检索知识库并展示结果</summary> /// <summary>/kb <关键词>:直接检索知识库并展示结果</summary>
private void RunKbQuickSearch(string keyword) private async void RunKbQuickSearch(string keyword)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
string result;
try
{
result = await Task.Run(() =>
{ {
if (!_kb.ConnectIfNeeded() || !_kb.IsSearchValid) if (!_kb.ConnectIfNeeded() || !_kb.IsSearchValid)
return "知识库不可用(目录不存在/共享未连接),请先选择知识库。";
// 首次实际检索才惰性启动索引;大知识库的无索引回退扫描也不占用 UI 线程。
_kb.EnsureIndexStarted();
return _kb.Grep(keyword, null, 10);
});
}
catch (Exception ex)
{ {
AddSystemMessage("知识库不可用(目录不存在/共享未连接),请先选择知识库。"); result = "知识库检索失败:" + ex.Message;
return;
} }
_kb.EnsureIndexStarted();
var sw = System.Diagnostics.Stopwatch.StartNew();
string result = _kb.Grep(keyword, null, 10);
sw.Stop(); sw.Stop();
AddSystemMessage($"**知识库检索:{keyword}**({sw.ElapsedMilliseconds}ms)\n\n{result}"); AddSystemMessage($"**知识库检索:{keyword}**({sw.ElapsedMilliseconds}ms)\n\n{result}");
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment