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

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

parent c6d90d06
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Threading;
using Tools.MyAgent;
using Tools.MyAgent.Agent;
using Tools.MyAgent.Kb;
namespace AgentTest
{
......@@ -11,6 +13,20 @@ namespace AgentTest
/// </summary>
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 估算 ──
[Test]
......
......@@ -32,6 +32,26 @@ namespace AgentTest
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]
public void OrderedList_StillRendered()
{
......
......@@ -26,6 +26,7 @@ namespace Tools.MyAgent.Agent
private readonly DialectService _dialect; // 内置方言/行业知识(程序目录 Agent/dialect + Agent/industry)
private AgentProductContext _productContext;
private CancellationTokenSource _cts;
private int _disposeStarted;
/// <summary>上下文 token 预算(由配置注入;默认 32000)</summary>
private int _contextTokens = 32000;
......@@ -72,6 +73,8 @@ namespace Tools.MyAgent.Agent
public bool IsRunning { get; private set; }
private bool IsDisposed => Volatile.Read(ref _disposeStarted) != 0;
/// <summary>本会话累计 prompt tokens(D16 用量统计;由 SendAndLogAsync 累加,NewChat 时重置)</summary>
public int TotalPromptTokens { get; private set; }
......@@ -235,7 +238,7 @@ namespace Tools.MyAgent.Agent
/// </summary>
public async Task RunAsync(IReadOnlyList<ChatMessage> history, CancellationToken ct)
{
if (IsRunning) return;
if (IsDisposed || IsRunning) return;
IsRunning = true;
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
OnStatusChanged?.Invoke();
......@@ -325,16 +328,21 @@ namespace Tools.MyAgent.Agent
}
}
if (!IsDisposed)
{
OnComplete?.Invoke();
if (_toolTrace.Count > 0)
OnToolTrace?.Invoke(_toolTrace.ToArray());
}
}
catch (OperationCanceledException)
{
if (!IsDisposed)
OnError?.Invoke("已取消。");
}
catch (Exception ex)
{
if (!IsDisposed)
OnError?.Invoke(DescribeError(ex));
}
finally
......@@ -342,6 +350,7 @@ namespace Tools.MyAgent.Agent
IsRunning = false;
try { _cts?.Dispose(); } catch { }
_cts = null;
if (!IsDisposed)
OnStatusChanged?.Invoke();
}
}
......@@ -1072,6 +1081,7 @@ namespace Tools.MyAgent.Agent
public void Dispose()
{
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) return;
Cancel();
try { _client.Dispose(); } catch { }
try { _backupClient?.Dispose(); } catch { }
......
......@@ -77,26 +77,43 @@ namespace Tools.MyAgent.Kb
_emb = emb;
}
/// <summary>启动:打开/建库 + 后台构建(幂等)</summary>
/// <summary>启动索引:只在后台打开 SQLite 并开始构建,调用方不会被建库 IO 阻塞。</summary>
public void Start()
{
lock (_lock)
{
if (_started) return;
_started = true;
}
Task.Run(StartCore);
}
private void StartCore()
{
try
{
_cts.Token.ThrowIfCancellationRequested();
lock (_lock)
{
if (_cts.IsCancellationRequested) return;
OpenDb();
}
if (_cts.IsCancellationRequested || _conn == null) return;
_sweepTimer = new Timer(_ => Invalidate(), null, SweepInterval, SweepInterval);
Invalidate();
}
catch (OperationCanceledException)
{
// 关闭窗口时取消尚未完成的后台初始化。
}
catch (Exception ex)
{
lock (_lock)
{
_conn = null;
OnStatus?.Invoke("索引不可用:" + ex.Message);
return;
}
OnStatus?.Invoke("索引不可用:" + ex.Message);
}
_sweepTimer = new Timer(_ => Invalidate(), null, SweepInterval, SweepInterval);
Invalidate();
}
/// <summary>标记变更,调度后台增量重建(带去抖;构建中则完成后自动续跑)</summary>
......
......@@ -410,6 +410,19 @@ namespace Tools.MyAgent.Kb
/// <summary>搜索范围是否有效(目录存在)</summary>
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>
/// 启动后台检索索引(幂等):索引目录固定为输出目录下 index 文件夹,与所选知识库无关。
/// <para>索引覆盖整个知识库根目录;搜索范围限定时在查询侧过滤,索引本身始终按根目录构建。</para>
......
......@@ -145,8 +145,7 @@ namespace Tools.MyAgent
{
sb.Append(@"\cf"); sb.Append(ClThinking);
sb.Append(@"\i\fs18 ");
sb.Append(EscapeChar('▸'));
sb.Append(" Thinking...");
sb.Append("> Thinking...");
sb.Append(@"\par ");
sb.Append(Escape(msg.ThinkingContent));
sb.Append(@"\i0\fs20\cf"); sb.Append(ClText);
......
......@@ -208,7 +208,7 @@ namespace Tools.MyAgent
//
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 17F);
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.panelToolbar);
this.Controls.Add(this.panelBottom);
......@@ -216,6 +216,7 @@ namespace Tools.MyAgent
this.KeyPreview = true;
this.Margin = new System.Windows.Forms.Padding(5, 6, 5, 6);
this.Name = "wMyAgent";
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
this.Text = "智能助手";
this.panelBottom.ResumeLayout(false);
this.panelBottom.PerformLayout();
......
......@@ -29,6 +29,8 @@ namespace Tools.MyAgent
private RichTextBox _rtfFallback;
private AgentConfig _config = new AgentConfig();
private CancellationTokenSource _sendCts;
private volatile bool _isClosing;
private int _disposeStarted;
private string _historyDir;
private bool _sessionSaved;
/// <summary>v2.3 待发送附图列表:粘贴截图后暂存,随下一轮提问发送(启用图片提问时;最多 5 张)</summary>
......@@ -143,6 +145,8 @@ namespace Tools.MyAgent
/// </summary>
partial void OnFormDisposing()
{
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) return;
_isClosing = true;
SaveUnsavedSession();
try { _cmdNameFont?.Dispose(); } catch { }
try { _cmdDescFont?.Dispose(); } catch { }
......@@ -339,7 +343,8 @@ namespace Tools.MyAgent
_kb.SetOutputRoot(DefaultKbRoot);
// 评估#1:存储自动清理(日志 30 天 / 中间产物 7~30 天 / 正式产出 90 天;后台执行不阻塞启动)
try { System.Threading.Tasks.Task.Run(() => StorageCleanup.Run(DefaultKbRoot)); } catch { }
_kb.EnsureIndexStarted(); // 后台构建知识库检索索引(含 docx/xlsx 内容),检索提速
// 大知识库不在窗口初始化阶段创建 SQLite 索引,避免打开业务引用时被 IO/SQLite 锁住。
// 小知识库由统计完成事件按需后台启动,实际搜索也会触发惰性启动。
RefreshScopeCombo(); // 先归一化搜索范围(命中下拉项/归一化为全部),再应用
_kb.SetScope(_config.SearchScope);
_historyDir = HistoryManager.EnsureHistoryDir(WorkingDirForHistory());
......@@ -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()
{
lblNewChat.LinkClicked += (s, e) => NewChat();
......@@ -371,25 +398,30 @@ namespace Tools.MyAgent
// RichBox 模式的点击/悬停交互(路径点击、details 折叠切换)
WireRtfEvents();
_engine.OnTextDelta += t => this.AsyncUI(() => OnAssistantTextDelta(t));
_engine.OnThinkingDelta += t => this.AsyncUI(() => OnAssistantThinkingDelta(t));
_engine.OnToolStatus += s => this.AsyncUI(() => OnToolStatusChanged(s));
_engine.OnFileProduced += f => this.AsyncUI(() => OnFileProduced(f));
_engine.OnComplete += () => this.AsyncUI(OnAssistantComplete);
_engine.OnToolTrace += trace => this.AsyncUI(() => OnToolTrace(trace));
_engine.OnError += m => this.AsyncUI(() => OnEngineError(m));
_engine.OnStatusChanged += () => this.AsyncUI(UpdateStatusIndicator);
_engine.OnTextDelta += t => PostToUi(() => OnAssistantTextDelta(t));
_engine.OnThinkingDelta += t => PostToUi(() => OnAssistantThinkingDelta(t));
_engine.OnToolStatus += s => PostToUi(() => OnToolStatusChanged(s));
_engine.OnFileProduced += f => PostToUi(() => OnFileProduced(f));
_engine.OnComplete += () => PostToUi(OnAssistantComplete);
_engine.OnToolTrace += trace => PostToUi(() => OnToolTrace(trace));
_engine.OnError += m => PostToUi(() => OnEngineError(m));
_engine.OnStatusChanged += () => PostToUi(UpdateStatusIndicator);
// 知识库文件数后台统计完成 → 回刷状态栏与欢迎语(窗体句柄未就绪时跳过,Load 时会重新触发)
_kb.StatsRefreshed += () => this.AsyncUI(() =>
_kb.StatsRefreshed += () =>
{
_kb.EnsureIndexStartedIfSmall();
PostToUi(() =>
{
if (!IsHandleCreated) return;
UpdateStatusIndicator();
RefreshChat();
});
};
FormClosing += (s, e) =>
{
SaveCurrentSession();
_isClosing = true;
SaveUnsavedSession();
};
Load += (s, e) =>
......@@ -686,6 +718,7 @@ namespace Tools.MyAgent
private void OnEngineError(string message)
{
if (_isClosing || IsDisposed || Disposing) return;
var last = LastAssistantMessage();
if (last != null && last.IsStreaming)
{
......@@ -2277,16 +2310,26 @@ namespace Tools.MyAgent
}
/// <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)
return "知识库不可用(目录不存在/共享未连接),请先选择知识库。";
// 首次实际检索才惰性启动索引;大知识库的无索引回退扫描也不占用 UI 线程。
_kb.EnsureIndexStarted();
return _kb.Grep(keyword, null, 10);
});
}
catch (Exception ex)
{
AddSystemMessage("知识库不可用(目录不存在/共享未连接),请先选择知识库。");
return;
result = "知识库检索失败:" + ex.Message;
}
_kb.EnsureIndexStarted();
var sw = System.Diagnostics.Stopwatch.StartNew();
string result = _kb.Grep(keyword, null, 10);
sw.Stop();
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