Commit 2ecc48c8 authored by 段廷瑞's avatar 段廷瑞

pref: Agent交互优化

parent 2f8390cb
...@@ -75,3 +75,5 @@ bin ...@@ -75,3 +75,5 @@ bin
/AppHome/Package /AppHome/Package
/.workbuddy /.workbuddy
/temp /temp
/AppHome/_backup
/Tools.Agent.ProbeKbPref
No preview for this file type
...@@ -623,6 +623,16 @@ ...@@ -623,6 +623,16 @@
<member name="F:Tools.MyAgent.Agent.KbRecall.RemoteListIntent"> <member name="F:Tools.MyAgent.Agent.KbRecall.RemoteListIntent">
<summary>列表意图:命中则走远程「目录模式」(返回全部条目标题清单),而非逐词检索</summary> <summary>列表意图:命中则走远程「目录模式」(返回全部条目标题清单),而非逐词检索</summary>
</member> </member>
<member name="F:Tools.MyAgent.Agent.KbRecall.MethodologyIntent">
<summary>
方法论/流程输出意图(F1):「输出/整理/总结…流程/步骤/规范/清单/模板/五步法」类问题,
答案在内置方言(troubleshoot-core 等)而非知识库片段,直答通道不接管(回退工具循环,
让模型按需检索/精读方言)。误报安全:跳过直答只是多走工具轮,回答质量不受损。
</summary>
</member>
<member name="M:Tools.MyAgent.Agent.KbRecall.IsMethodologyIntent(System.String)">
<summary>问题是否为「输出一份流程/规范」类方法论意图(命中则直答通道应让位)</summary>
</member>
<member name="F:Tools.MyAgent.Agent.KbRecall.MaxHits"> <member name="F:Tools.MyAgent.Agent.KbRecall.MaxHits">
<summary>纳入证据链的命中上限(主命中 + 其余)</summary> <summary>纳入证据链的命中上限(主命中 + 其余)</summary>
</member> </member>
...@@ -694,6 +704,12 @@ ...@@ -694,6 +704,12 @@
<member name="M:Tools.MyAgent.Agent.KbRecall.AddImageAbs(Tools.MyAgent.Agent.KbHit,System.String,System.String,System.Collections.Generic.HashSet{System.String},System.Int32)"> <member name="M:Tools.MyAgent.Agent.KbRecall.AddImageAbs(Tools.MyAgent.Agent.KbHit,System.String,System.String,System.Collections.Generic.HashSet{System.String},System.Int32)">
<summary>图片绝对路径校验(存在 + 在知识库内 + 图片扩展 + 去重)后加入</summary> <summary>图片绝对路径校验(存在 + 在知识库内 + 图片扩展 + 去重)后加入</summary>
</member> </member>
<member name="M:Tools.MyAgent.Agent.KbRecall.FirstAsciiTopic(System.Collections.Generic.List{System.String})">
<summary>问题关键词表中的首个纯 ASCII 主题词(型号/类名/编码,如 U6);无则返回 null</summary>
</member>
<member name="M:Tools.MyAgent.Agent.KbRecall.TopicCovered(Tools.MyAgent.Agent.KbHit,System.String)">
<summary>主题词是否被证据覆盖(命中关键词或文件/引用名含该词,忽略大小写)</summary>
</member>
<member name="M:Tools.MyAgent.Agent.KbRecall.SafeCombine(System.String)"> <member name="M:Tools.MyAgent.Agent.KbRecall.SafeCombine(System.String)">
<summary>相对路径 → 绝对路径(仅拼接,不抛异常;越界/非法返回 null)</summary> <summary>相对路径 → 绝对路径(仅拼接,不抛异常;越界/非法返回 null)</summary>
</member> </member>
...@@ -704,6 +720,13 @@ ...@@ -704,6 +720,13 @@
过滤疑问/连接类停用词,按具体程度排序去重。 过滤疑问/连接类停用词,按具体程度排序去重。
</summary> </summary>
</member> </member>
<member name="M:Tools.MyAgent.Agent.KbRecall.IsJunkBigram(System.String)">
<summary>
F4:劣质二元组判定(停用表 + 虚词/代词开头规则)。
「的基」「的基本」「我输」这类跨词边界噪声字面命中无关文档,会把证据带偏
(真机案例 2026-10-19:U6 问题被「基本」「输出」命中的 PDM 运维文档污染)。
</summary>
</member>
<member name="M:Tools.MyAgent.Agent.KbRecall.CollectToken(System.String,System.Collections.Generic.List{System.String},System.Collections.Generic.List{System.String},System.Collections.Generic.List{System.String})"> <member name="M:Tools.MyAgent.Agent.KbRecall.CollectToken(System.String,System.Collections.Generic.List{System.String},System.Collections.Generic.List{System.String},System.Collections.Generic.List{System.String})">
<summary>单个 token → ASCII / 中文三元组 / 中文二元组(混合 token 按字符类别切段)</summary> <summary>单个 token → ASCII / 中文三元组 / 中文二元组(混合 token 按字符类别切段)</summary>
</member> </member>
...@@ -981,7 +1004,17 @@ ...@@ -981,7 +1004,17 @@
</summary> </summary>
</member> </member>
<member name="T:Tools.MyAgent.Agent.Tools.DialectGrepTool"> <member name="T:Tools.MyAgent.Agent.Tools.DialectGrepTool">
<summary>d_grep:在内置方言/行业知识文件内容中搜索关键字,返回命中文件与上下文片段</summary> <summary>d_grep:在内置方言/行业知识文件内容中搜索关键字,返回命中文件、行号与匹配度(词覆盖率打分)</summary>
</member>
<member name="M:Tools.MyAgent.Agent.Tools.DialectGrepTool.ScoreLine(System.String,System.Collections.Generic.List{System.String})">
<summary>
逐词匹配度(v4.7 来源阶梯):字面词('|' 分隔段)对命中行的覆盖率打分——
全覆盖 ≈0.85、单词命中 ≈0.75、部分覆盖 0.55~0.70、复杂正则无法提词时固定 0.60。
与 answer_spec 第四节的来源阶梯阈值(≥0.70 视为命中)配套。
</summary>
</member>
<member name="M:Tools.MyAgent.Agent.Tools.DialectGrepTool.ExtractTerms(System.String)">
<summary>从正则查询里提取字面检索词('|'/空白分隔的无元字符段);提不出则返回空表</summary>
</member> </member>
<member name="T:Tools.MyAgent.Agent.Tools.DialectReadTool"> <member name="T:Tools.MyAgent.Agent.Tools.DialectReadTool">
<summary>d_read:读取内置方言/行业知识文件内容(UTF-8 文本,≤300 行/次)</summary> <summary>d_read:读取内置方言/行业知识文件内容(UTF-8 文本,≤300 行/次)</summary>
...@@ -1637,7 +1670,10 @@ ...@@ -1637,7 +1670,10 @@
</summary> </summary>
</member> </member>
<member name="F:Tools.MyAgent.Forms.ThinkingSpinner.BaseSize"> <member name="F:Tools.MyAgent.Forms.ThinkingSpinner.BaseSize">
<summary>逻辑尺寸(未按 DPI 缩放前)</summary> <summary>逻辑尺寸(未按 DPI 缩放前):弧线所在正方形边长</summary>
</member>
<member name="F:Tools.MyAgent.Forms.ThinkingSpinner.TextGap">
<summary>状态文字与弧线之间的间距(逻辑像素)</summary>
</member> </member>
<member name="F:Tools.MyAgent.Forms.ThinkingSpinner.TickMs"> <member name="F:Tools.MyAgent.Forms.ThinkingSpinner.TickMs">
<summary>动画帧间隔(毫秒)</summary> <summary>动画帧间隔(毫秒)</summary>
...@@ -1651,12 +1687,18 @@ ...@@ -1651,12 +1687,18 @@
<member name="F:Tools.MyAgent.Forms.ThinkingSpinner.AngleStep"> <member name="F:Tools.MyAgent.Forms.ThinkingSpinner.AngleStep">
<summary>每帧角度增量</summary> <summary>每帧角度增量</summary>
</member> </member>
<member name="P:Tools.MyAgent.Forms.ThinkingSpinner.StatusText">
<summary>状态文字(显示在弧线右侧;置空只显示弧线)。变化时重算控件宽度并重绘。</summary>
</member>
<member name="P:Tools.MyAgent.Forms.ThinkingSpinner.Thinking"> <member name="P:Tools.MyAgent.Forms.ThinkingSpinner.Thinking">
<summary>是否处于思考中(true:显示并旋转;false:隐藏并停止计时器)</summary> <summary>是否处于思考中(true:显示并旋转;false:隐藏并停止计时器)</summary>
</member> </member>
<member name="M:Tools.MyAgent.Forms.ThinkingSpinner.ApplyScale"> <member name="M:Tools.MyAgent.Forms.ThinkingSpinner.ApplyScale">
<summary>按当前 DPI 计算缩放并同步控件尺寸(高 DPI 下弧线不糊、占位不偏)</summary> <summary>按当前 DPI 计算缩放并同步控件尺寸(高 DPI 下弧线不糊、占位不偏)</summary>
</member> </member>
<member name="M:Tools.MyAgent.Forms.ThinkingSpinner.ResizeForStatus">
<summary>按 DPI 与状态文字实测宽度重算控件尺寸(宽 = 弧线方 + 间距 + 文字;无文字时即弧线方)</summary>
</member>
<member name="T:Tools.MyAgent.wBitmapView"> <member name="T:Tools.MyAgent.wBitmapView">
<summary> <summary>
图片查看器:优先按图片原始尺寸展示(1:1 像素),但不超过屏幕工作区大小; 图片查看器:优先按图片原始尺寸展示(1:1 像素),但不超过屏幕工作区大小;
...@@ -3584,12 +3626,28 @@ ...@@ -3584,12 +3626,28 @@
</member> </member>
<member name="M:Tools.MyAgent.wMyAgent.EnsureThinkingSpinner"> <member name="M:Tools.MyAgent.wMyAgent.EnsureThinkingSpinner">
<summary> <summary>
思考旋转指示器(右下角悬浮):思考中显示旋转弧,正文一到即隐藏。 思考指示器(v1.3.7:锚定在当前流式回答的助手标题下方,弧线右侧直接显示状态文字)。
<para>替代旧形态「把思维链文本整块渲染进问答区」;不再渲染思考文本的开关见 AgentConfig.ShowThinking。</para> <para>替代旧形态「右下角悬浮 + 悬停看状态」;不再渲染思考文本的开关见 AgentConfig.ShowThinking。</para>
</summary> </summary>
</member> </member>
<member name="M:Tools.MyAgent.wMyAgent.PositionThinkingSpinner"> <member name="M:Tools.MyAgent.wMyAgent.PositionThinkingSpinner">
<summary>把旋转指示器贴到问答区右下角(距边 12px;Dock=Fill 的 RichTextBox 之上)</summary> <summary>
把指示器锚定到当前流式回答的助手标题下方(标题行下一行左上角)。
<para>定位依据:流式助手消息的标题行「助手 + 空格 + HH:mm:ss」在 RichTextBox 文本中的字符位置,
经 GetPositionFromCharIndex 换算像素坐标后下移一行;用户滚动时随 VScroll 重算。
标题未渲染(欢迎页等)时兜底贴问答区左上角。</para>
</summary>
</member>
<member name="M:Tools.MyAgent.wMyAgent.FindStreamingTitleRange(System.String,System.Int32@)">
<summary>
在渲染文本中定位流式助手消息标题行的字符区间。
<para>匹配方式:从后往前找流式消息的时间戳(HH:mm:ss),再核对其前 ≤3 个空白之外紧邻「助手」——
名字与时间戳之间的空格数受 RTF 控制字定界符吞噬影响不固定(v1.3.7 曾按固定 2 空格匹配而全部落空),
故不能拼固定字符串 LastIndexOf。</para>
</summary>
<param name="text">渲染后的纯文本</param>
<param name="titleEnd">输出:标题行行尾下标(时间戳末字符之后,取行像素 Y 用)</param>
<returns>标题起始下标(「助」字,取行左端 X 用);未找到返回 -1</returns>
</member> </member>
<member name="M:Tools.MyAgent.wMyAgent.SetThinking(System.Boolean)"> <member name="M:Tools.MyAgent.wMyAgent.SetThinking(System.Boolean)">
<summary> <summary>
......
...@@ -394,6 +394,33 @@ namespace Tools.MyAgent.Agent ...@@ -394,6 +394,33 @@ namespace Tools.MyAgent.Agent
if (result.ToolCalls.Count == 0) if (result.ToolCalls.Count == 0)
{ {
await FinishAnswerAsync(messages, result, finalAnswer, _cts.Token).ConfigureAwait(false); await FinishAnswerAsync(messages, result, finalAnswer, _cts.Token).ConfigureAwait(false);
// v4.6 空回答回退(对齐直答通道 v4.5b):gpt-6-luna 流式断流(真机 tokens=-1+-1,
// 2026-10-20 U6 案例第 2 轮 11s 空返回)时不能拿「(模型未返回内容)」占位收场——
// 先原样重试一次(tools=null 强制文本作答);仍空则注入收尾指令再做无工具收尾轮
//(与触顶收尾同机制)。FinishAnswerAsync 不追加 assistant 消息,messages 状态干净可重发。
// v1.3.7:占位文案不再在执行中下发(FinishAnswerAsync 空返回不触发 UI),
// 判定改为 finalAnswer 是否为空;重试耗尽仍为空才在下方兜底展示占位。
int emptyRetry = 0;
while (finalAnswer.Length == 0 && emptyRetry < 2)
{
emptyRetry++;
AgentLogger.Log(emptyRetry == 1
? "纯回答轮返回空内容(疑似流式断流),原样重试"
: "纯回答轮重试仍为空,注入收尾指令做无工具收尾轮");
if (emptyRetry == 2)
messages.Add(new JObject { ["role"] = "user", ["content"] = FinalizePrompt });
var retry = await SendAndLogAsync(messages, null, _cts.Token,
emptyRetry == 1 ? "tool-round" : "finish").ConfigureAwait(false);
if (retry.ReasoningContent.Length > 0)
OnThinkingDelta?.Invoke(retry.ReasoningContent);
await FinishAnswerAsync(messages, retry, finalAnswer, _cts.Token).ConfigureAwait(false);
}
if (finalAnswer.Length == 0)
{
finalAnswer.Append("(模型未返回内容)"); // 重试耗尽仍为空:兜底告知(执行中绝不展示)
OnTextDelta?.Invoke(finalAnswer.ToString());
}
break; break;
} }
...@@ -448,6 +475,12 @@ namespace Tools.MyAgent.Agent ...@@ -448,6 +475,12 @@ namespace Tools.MyAgent.Agent
if (final.ReasoningContent.Length > 0) if (final.ReasoningContent.Length > 0)
OnThinkingDelta?.Invoke(final.ReasoningContent); OnThinkingDelta?.Invoke(final.ReasoningContent);
await FinishAnswerAsync(messages, final, finalAnswer, _cts.Token).ConfigureAwait(false); await FinishAnswerAsync(messages, final, finalAnswer, _cts.Token).ConfigureAwait(false);
// v1.3.7:收尾轮仍为空时同样兜底展示占位(FinishAnswerAsync 空返回已不触发 UI)
if (finalAnswer.Length == 0)
{
finalAnswer.Append("(模型未返回内容)");
OnTextDelta?.Invoke(finalAnswer.ToString());
}
break; break;
} }
} }
...@@ -516,6 +549,16 @@ namespace Tools.MyAgent.Agent ...@@ -516,6 +549,16 @@ namespace Tools.MyAgent.Agent
{ {
if (_recall == null || string.IsNullOrWhiteSpace(_currentQuestion)) return false; if (_recall == null || string.IsNullOrWhiteSpace(_currentQuestion)) return false;
// F1 方法论意图闸门:「输出/整理…流程/规范/五步法」类问题的答案在内置方言,
// 走工具循环让模型按需 d_read 方言文件;直答证据反而会把模型锁死在无关知识库片段上
// (真机案例 2026-10-19:U6 排查流程问题被 PDM 运维文档 + 远程最新条目劫持)。
if (KbRecall.IsMethodologyIntent(_currentQuestion))
{
AgentLogger.Log("方法论/流程输出类问题,跳过直答走工具循环");
if (_runScope != null) _runScope.Mode = "model";
return false;
}
KbRecallResult recall; KbRecallResult recall;
try try
{ {
...@@ -555,10 +598,9 @@ namespace Tools.MyAgent.Agent ...@@ -555,10 +598,9 @@ namespace Tools.MyAgent.Agent
// v4.5b 空回答回退:直答偶发空内容(长目录证据噎住模型 / 自定义端点流异常中途断流, // v4.5b 空回答回退:直答偶发空内容(长目录证据噎住模型 / 自定义端点流异常中途断流,
// 真机实证 gpt-6-luna 直答 2.7s 断流 tokens=-1+-1)时,不能拿「(模型未返回内容)」占位收场 —— // 真机实证 gpt-6-luna 直答 2.7s 断流 tokens=-1+-1)时,不能拿「(模型未返回内容)」占位收场 ——
// 回退工具循环重试。finalAnswer 里的占位必须清掉,否则工具循环的回答会接在占位后面。 // 回退工具循环重试(v1.3.7:占位不再执行中下发,按 finalAnswer 是否为空判定)。
if (finalAnswer.ToString().IndexOf("(模型未返回内容)", StringComparison.Ordinal) >= 0) if (finalAnswer.Length == 0)
{ {
finalAnswer.Clear();
while (messages.Count > mark) messages.RemoveAt(messages.Count - 1); // 回滚证据消息 while (messages.Count > mark) messages.RemoveAt(messages.Count - 1); // 回滚证据消息
AgentLogger.Log("直答返回空内容,回退工具循环重试"); AgentLogger.Log("直答返回空内容,回退工具循环重试");
return false; return false;
...@@ -730,9 +772,10 @@ namespace Tools.MyAgent.Agent ...@@ -730,9 +772,10 @@ namespace Tools.MyAgent.Agent
break; break;
} }
if (finalAnswer.Length == 0) // v1.3.7:空返回不再立即下发「(模型未返回内容)」占位 —— 此前重试期间回答区一直顶着占位文案,
finalAnswer.Append("(模型未返回内容)"); // 表现为「模型还没执行完就显示干扰内容」。这里保持 finalAnswer 为空、不触发 UI;
else // 占位只在各调用方「重试耗尽仍为空」时兜底追加。
if (finalAnswer.Length > 0)
{ {
// P3 后置兜底(只提示、不重试 —— 重试会让成本翻倍,而回答本身仍有价值): // P3 后置兜底(只提示、不重试 —— 重试会让成本翻倍,而回答本身仍有价值):
// 仅在契约层已下发时校验;缺节则顶部加一行提示,明确「已原样保留」,不静默吞掉不合规输出。 // 仅在契约层已下发时校验;缺节则顶部加一行提示,明确「已原样保留」,不静默吞掉不合规输出。
...@@ -743,7 +786,6 @@ namespace Tools.MyAgent.Agent ...@@ -743,7 +786,6 @@ namespace Tools.MyAgent.Agent
} }
if (_contextNotice.Length > 0) if (_contextNotice.Length > 0)
finalAnswer.Insert(0, _contextNotice + "\n\n"); // 上下文提示保留在最终回答开头(此前被正文覆盖) finalAnswer.Insert(0, _contextNotice + "\n\n"); // 上下文提示保留在最终回答开头(此前被正文覆盖)
}
if (IsTruncatedByLength(result)) if (IsTruncatedByLength(result))
{ {
// 达到续写上限仍被截断:明确提示用户,不再静默展示半截回答 // 达到续写上限仍被截断:明确提示用户,不再静默展示半截回答
...@@ -752,6 +794,7 @@ namespace Tools.MyAgent.Agent ...@@ -752,6 +794,7 @@ namespace Tools.MyAgent.Agent
} }
OnTextDelta?.Invoke(finalAnswer.ToString()); OnTextDelta?.Invoke(finalAnswer.ToString());
} }
}
// ── #31 故障转移 ── // ── #31 故障转移 ──
......
...@@ -104,6 +104,23 @@ namespace Tools.MyAgent.Agent ...@@ -104,6 +104,23 @@ namespace Tools.MyAgent.Agent
@"列出|列表|清单|目录|都有什么|有哪些|有什么内容|多少条|全部条目", @"列出|列表|清单|目录|都有什么|有哪些|有什么内容|多少条|全部条目",
RegexOptions.Compiled); RegexOptions.Compiled);
/// <summary>
/// 方法论/流程输出意图(F1):「输出/整理/总结…流程/步骤/规范/清单/模板/五步法」类问题,
/// 答案在内置方言(troubleshoot-core 等)而非知识库片段,直答通道不接管(回退工具循环,
/// 让模型按需检索/精读方言)。误报安全:跳过直答只是多走工具轮,回答质量不受损。
/// </summary>
private static readonly Regex MethodologyIntent = new Regex(
@"(?:输出|整理|总结|写一(?:份|个)|给我).{0,12}?(?:流程|步骤|方案|规范|清单|模板|五步法|操作手册)",
RegexOptions.Compiled);
/// <summary>问题是否为「输出一份流程/规范」类方法论意图(命中则直答通道应让位)</summary>
public static bool IsMethodologyIntent(string question)
{
if (string.IsNullOrEmpty(question)) return false;
try { return MethodologyIntent.IsMatch(question); }
catch { return false; }
}
/// <summary>纳入证据链的命中上限(主命中 + 其余)</summary> /// <summary>纳入证据链的命中上限(主命中 + 其余)</summary>
private const int MaxHits = 3; private const int MaxHits = 3;
...@@ -254,6 +271,18 @@ namespace Tools.MyAgent.Agent ...@@ -254,6 +271,18 @@ namespace Tools.MyAgent.Agent
return Finalize(new KbRecallResult { Mode = "model", Query = question, Keywords = keywords.Take(used).ToList() }, keywords, question, ct); return Finalize(new KbRecallResult { Mode = "model", Query = question, Keywords = keywords.Take(used).ToList() }, keywords, question, ct);
var primary = ranked[0]; var primary = ranked[0];
// F3 主题一致性校验:问题含 ASCII 标识词(型号/类名/编码,如 U6)时,该词必须出现在
// 主命中的命中关键词或文件名里,否则视为主题跑偏 —— 证据与问题不同主体,注入即伤害,
// 统一降级 model 回退工具循环(评审稿的 kb→mixed 分级简化为全降级:mixed 仍会直答,
// 无关证据照样锁死模型,分级没有收益)。降级后远程真命中仍可在 Finalize 内接管。
string asciiTopic = FirstAsciiTopic(keywords);
if (asciiTopic != null && !TopicCovered(primary, asciiTopic))
{
AgentLogger.Log("主题一致性未命中:主题词 " + asciiTopic + " 不在证据(" + primary.RelPath + ")中,降级走工具循环");
return Finalize(new KbRecallResult { Mode = "model", Query = question, Keywords = keywords.Take(used).ToList() }, keywords, question, ct);
}
var result = new KbRecallResult var result = new KbRecallResult
{ {
Query = question, Query = question,
...@@ -329,17 +358,20 @@ namespace Tools.MyAgent.Agent ...@@ -329,17 +358,20 @@ namespace Tools.MyAgent.Agent
catch { return; } // 双保险:Search 承诺除取消外不抛 catch { return; } // 双保险:Search 承诺除取消外不抛
if (hits == null || hits.Count == 0) return; if (hits == null || hits.Count == 0) return;
if (!localMiss) // F2:目录兜底条目(IsFallback,"最新 N 条")只服务列表意图;普通问题的证据链
// 不收零命中兜底 —— 与问题无关的最新条目进证据只会把回答带偏(真机案例 2026-10-19)。
if (!localMiss) if (!localMiss)
{ {
foreach (var h in hits) result.Others.Add(ToKbHit(h)); foreach (var h in hits)
if (!h.IsFallback) result.Others.Add(ToKbHit(h));
return; return;
} }
// 本地未召回:远程条目升级为 mixed 证据(首条 Primary,其余 Others)。 // 本地未召回:远程条目升级为 mixed 证据(首条 Primary,其余 Others)。
// Others 必须先清空 —— 此处 result.Others 可能还残留本地低分命中(<0.45,未达证据线), // Others 必须先清空 —— 此处 result.Others 可能还残留本地低分命中(<0.45,未达证据线),
// 它们已随 Enrich 采集了关联图片;混入会让"远程回答"挂上无关的本地图片(真机反馈 2026-10-08)。 // 它们已随 Enrich 采集了关联图片;混入会让"远程回答"挂上无关的本地图片(真机反馈 2026-10-08)。
var remote = hits.Select(ToKbHit).ToList(); var remote = hits.Where(h => !h.IsFallback).Select(ToKbHit).ToList();
if (remote.Count == 0) return; // 全是零命中兜底 ⇒ 不升级,保持 model 走工具循环
result.Primary = remote[0]; result.Primary = remote[0];
result.Others.Clear(); result.Others.Clear();
result.Others.AddRange(remote.Skip(1)); result.Others.AddRange(remote.Skip(1));
...@@ -368,7 +400,13 @@ namespace Tools.MyAgent.Agent ...@@ -368,7 +400,13 @@ namespace Tools.MyAgent.Agent
{ {
if (r == null || r.Primary == null) return ""; if (r == null || r.Primary == null) return "";
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("【本地知识库召回证据】(系统已预检索,请只依据以下证据作答;不要再调用检索工具)"); // F5:kb(证据充分)维持"只据证据作答"的防杜撰锁定;mixed(部分命中)松绑——
// 允许模型用通用知识/系统提示中的排查方法论补齐证据缺口并标注,避免证据跑偏时
// 模型只能输出"无法确定"。直答不下发工具,故不得引导模型调用 d_read 等工具。
bool partial = !string.Equals(r.Mode, "kb", StringComparison.Ordinal);
sb.AppendLine(partial
? "【本地知识库召回证据】(系统已预检索;证据相关的内容据证据作答,不要再调用检索工具)"
: "【本地知识库召回证据】(系统已预检索,请只依据以下证据作答;不要再调用检索工具)");
sb.AppendLine("问题:" + OneLine(r.Query, 200)); sb.AppendLine("问题:" + OneLine(r.Query, 200));
sb.AppendLine("召回关键词:" + string.Join("、", r.Keywords)); sb.AppendLine("召回关键词:" + string.Join("、", r.Keywords));
sb.AppendLine("最高匹配度:" + r.Primary.Score.ToString("0.00") + "(" + (r.Mode == "kb" ? "证据充分" : "部分命中,可结合通用知识补充并标注") + ")"); sb.AppendLine("最高匹配度:" + r.Primary.Score.ToString("0.00") + "(" + (r.Mode == "kb" ? "证据充分" : "部分命中,可结合通用知识补充并标注") + ")");
...@@ -381,6 +419,9 @@ namespace Tools.MyAgent.Agent ...@@ -381,6 +419,9 @@ namespace Tools.MyAgent.Agent
sb.AppendLine("【引用行输出要求】"); sb.AppendLine("【引用行输出要求】");
sb.AppendLine("- 引用节每行一条,格式固定:📎 <知识库相对路径> §<章节> L<行号> 匹配度 <0.xx>"); sb.AppendLine("- 引用节每行一条,格式固定:📎 <知识库相对路径> §<章节> L<行号> 匹配度 <0.xx>");
sb.AppendLine("- 只列本轮真正用到的来源(最多 3 条),路径/章节/行号不得杜撰;无行号时省略 “L<行号>”。"); sb.AppendLine("- 只列本轮真正用到的来源(最多 3 条),路径/章节/行号不得杜撰;无行号时省略 “L<行号>”。");
if (partial)
sb.AppendLine("- 证据未覆盖的部分可结合通用知识与系统提示中的排查方法论(如五步法)补充,并在该内容后标注「(模型补充)」;仍不得凭记忆补造文件、表名或结论。");
else
sb.AppendLine("- 证据未覆盖的内容明确写「知识库中未找到相关内容」,不得凭记忆补造文件、表名或结论。"); sb.AppendLine("- 证据未覆盖的内容明确写「知识库中未找到相关内容」,不得凭记忆补造文件、表名或结论。");
return sb.ToString(); return sb.ToString();
} }
...@@ -609,6 +650,31 @@ namespace Tools.MyAgent.Agent ...@@ -609,6 +650,31 @@ namespace Tools.MyAgent.Agent
hit.Images.Add(abs); hit.Images.Add(abs);
} }
/// <summary>问题关键词表中的首个纯 ASCII 主题词(型号/类名/编码,如 U6);无则返回 null</summary>
private static string FirstAsciiTopic(List<string> keywords)
{
if (keywords == null) return null;
foreach (var k in keywords)
{
if (string.IsNullOrEmpty(k) || k.Length < 2) continue;
bool ascii = true;
foreach (char c in k)
if (c > 0x7F) { ascii = false; break; }
if (ascii && k.Any(char.IsLetter)) return k;
}
return null;
}
/// <summary>主题词是否被证据覆盖(命中关键词或文件/引用名含该词,忽略大小写)</summary>
private static bool TopicCovered(KbHit hit, string topic)
{
if (hit == null || string.IsNullOrEmpty(topic)) return true;
if (!string.IsNullOrEmpty(hit.HitKeywords)
&& hit.HitKeywords.IndexOf(topic, StringComparison.OrdinalIgnoreCase) >= 0) return true;
return !string.IsNullOrEmpty(hit.RelPath)
&& hit.RelPath.IndexOf(topic, StringComparison.OrdinalIgnoreCase) >= 0;
}
private static string FirstKeyword(string joined) private static string FirstKeyword(string joined)
{ {
if (string.IsNullOrEmpty(joined)) return ""; if (string.IsNullOrEmpty(joined)) return "";
...@@ -677,7 +743,7 @@ namespace Tools.MyAgent.Agent ...@@ -677,7 +743,7 @@ namespace Tools.MyAgent.Agent
int triCap = result.Count == 0 ? 5 : 4; // 无 ASCII 标识时给中文多留额度 int triCap = result.Count == 0 ? 5 : 4; // 无 ASCII 标识时给中文多留额度
foreach (var w in tri) foreach (var w in tri)
{ {
if (CjkStopBigrams.Contains(w.Substring(0, 2)) || CjkStopBigrams.Contains(w.Substring(1, 2))) if (IsJunkBigram(w.Substring(0, 2)) || IsJunkBigram(w.Substring(1, 2)))
continue; continue;
if (seen.Add(w)) result.Add(w); if (seen.Add(w)) result.Add(w);
if (result.Count >= triCap || result.Count >= 8) break; if (result.Count >= triCap || result.Count >= 8) break;
...@@ -685,13 +751,31 @@ namespace Tools.MyAgent.Agent ...@@ -685,13 +751,31 @@ namespace Tools.MyAgent.Agent
// 中文二元组补足 // 中文二元组补足
foreach (var w in bi) foreach (var w in bi)
{ {
if (CjkStopBigrams.Contains(w)) continue; if (IsJunkBigram(w)) continue;
if (seen.Add(w)) result.Add(w); if (seen.Add(w)) result.Add(w);
if (result.Count >= 8) break; if (result.Count >= 8) break;
} }
return result; return result;
} }
/// <summary>
/// F4:劣质二元组判定(停用表 + 虚词/代词开头规则)。
/// 「的基」「的基本」「我输」这类跨词边界噪声字面命中无关文档,会把证据带偏
/// (真机案例 2026-10-19:U6 问题被「基本」「输出」命中的 PDM 运维文档污染)。
/// </summary>
private static bool IsJunkBigram(string bigram)
{
if (string.IsNullOrEmpty(bigram) || bigram.Length < 2) return true;
switch (bigram[0])
{
case '的': case '了': case '是': case '个': case '们':
case '我': case '你': case '他': case '她': case '它':
case '吧': case '呢': case '吗': case '啊': case '把': case '被':
return true;
}
return CjkStopBigrams.Contains(bigram);
}
/// <summary>单个 token → ASCII / 中文三元组 / 中文二元组(混合 token 按字符类别切段)</summary> /// <summary>单个 token → ASCII / 中文三元组 / 中文二元组(混合 token 按字符类别切段)</summary>
private static void CollectToken(string token, List<string> ascii, List<string> tri, List<string> bi) private static void CollectToken(string token, List<string> ascii, List<string> tri, List<string> bi)
{ {
......
...@@ -317,6 +317,7 @@ namespace Tools.MyAgent.Agent ...@@ -317,6 +317,7 @@ namespace Tools.MyAgent.Agent
判断本轮属于哪一类(拿不准时按第二类即排查类输出,信息更全): 判断本轮属于哪一类(拿不准时按第二类即排查类输出,信息更全):
- 第一类 知识问答:查资料、查流程、问「是什么/怎么做/在哪配」等,且不涉及现场故障与代码/表/调用链定位。 - 第一类 知识问答:查资料、查流程、问「是什么/怎么做/在哪配」等,且不涉及现场故障与代码/表/调用链定位。
- 「输出/整理/总结…流程、步骤、规范、清单、模板」类方法论问题(无现场故障)一律按第一类三段式输出,知识来源按第四节的三级阶梯取用。
- 第二类 现场排查:涉及报错、异常、失败、崩溃、超时、卡死、没反应,或需要定位到具体文件/类/表/调用链/日志。 - 第二类 现场排查:涉及报错、异常、失败、崩溃、超时、卡死、没反应,或需要定位到具体文件/类/表/调用链/日志。
一、知识问答类 → 只允许三节,顺序固定,节标题用加粗或三级标题,禁止增加其它章节与客套话: 一、知识问答类 → 只允许三节,顺序固定,节标题用加粗或三级标题,禁止增加其它章节与客套话:
...@@ -337,6 +338,15 @@ namespace Tools.MyAgent.Agent ...@@ -337,6 +338,15 @@ namespace Tools.MyAgent.Agent
- 大段原文摘录(超过 3 行的原文引用一律改为摘要 + 引用行)。 - 大段原文摘录(超过 3 行的原文引用一律改为摘要 + 引用行)。
- 超过 3 列的表格;代码块仅用于给出命令或配置片段。 - 超过 3 列的表格;代码块仅用于给出命令或配置片段。
- 结尾总结段、免责声明、与问题无关的扩展建议。 - 结尾总结段、免责声明、与问题无关的扩展建议。
- 回答长度与问题复杂度匹配:简单问题给出简短三段式即可,不刻意展开。"; - 回答长度与问题复杂度匹配:简单问题给出简短三段式即可,不刻意展开。
四、知识来源三级阶梯(按匹配度决定方案主体与引用;上一级未命中或匹配度不足才落到下一级):
1. 知识库(kb_* 检索):匹配度 ≥0.72 → 以知识库为方案主体,引用节列知识库行(含匹配度);
0.45~0.72 → 部分命中,可结合下一级补充并逐条标注来源;<0.45 或按问题主体词检索明确返回「没有找到」→ 视为未命中。
2. Agent 内置知识库(d_grep/d_read,dialect/ 与 industry/):知识库未命中时检索内置方言;d_grep 匹配度 ≥0.70
或 d_read 精读确认与问题相关 → 以内置方言方法论为方案主体,引用节列 dialect/ 或 industry/ 路径与行号。
3. 模型自答:两级均未命中 → 用通用知识作答,引用节写「知识库与内置方言均未命中,以下为模型自答,欢迎反馈」;
禁止把任何一级中噪声命中的无关文档充当方案主体或列入引用。
- 主体一致性:命中内容必须与问题主体直接相关;宽泛关键词的噪声命中一律不采信。";
} }
} }
...@@ -88,13 +88,45 @@ namespace Tools.MyAgent.Agent.Tools ...@@ -88,13 +88,45 @@ namespace Tools.MyAgent.Agent.Tools
} }
} }
/// <summary>d_grep:在内置方言/行业知识文件内容中搜索关键字,返回命中文件与上下文片段</summary> /// <summary>d_grep:在内置方言/行业知识文件内容中搜索关键字,返回命中文件、行号与匹配度(词覆盖率打分)</summary>
internal sealed class DialectGrepTool : ITool internal sealed class DialectGrepTool : ITool
{ {
public string Name => "d_grep"; public string Name => "d_grep";
public string Description => public string Description =>
"在内置产品方言与行业知识文件内容中搜索关键字(限 *.md 文本),返回命中文件与上下文片段。" + "在内置产品方言与行业知识文件内容中搜索关键字(限 *.md 文本),返回命中文件、行号与匹配度。" +
"排查/业务类问题先按触发词用本工具定位,再用 d_read 精读。"; "排查/业务类问题先按触发词用本工具定位,再用 d_read 精读。";
/// <summary>
/// 逐词匹配度(v4.7 来源阶梯):字面词('|' 分隔段)对命中行的覆盖率打分——
/// 全覆盖 ≈0.85、单词命中 ≈0.75、部分覆盖 0.55~0.70、复杂正则无法提词时固定 0.60。
/// 与 answer_spec 第四节的来源阶梯阈值(≥0.70 视为命中)配套。
/// </summary>
internal static double ScoreLine(string line, List<string> terms)
{
if (terms == null || terms.Count == 0) return 0.60;
int matched = 0;
foreach (var t in terms)
if (line.IndexOf(t, StringComparison.OrdinalIgnoreCase) >= 0) matched++;
if (matched == 0) return 0.60;
return Math.Min(0.90, 0.45 + 0.30 * (double)matched / terms.Count + 0.05 * (matched - 1));
}
/// <summary>从正则查询里提取字面检索词('|'/空白分隔的无元字符段);提不出则返回空表</summary>
internal static List<string> ExtractTerms(string query)
{
var terms = new List<string>();
if (string.IsNullOrEmpty(query)) return terms;
foreach (var raw in query.Split(new[] { '|', ' ', ',', ',', ';', ';' }, StringSplitOptions.RemoveEmptyEntries))
{
string t = raw.Trim();
if (t.Length == 0 || t.Length > 30) continue;
if (t.IndexOfAny(new[] { '(', ')', '[', ']', '{', '}', '.', '*', '+', '?', '^', '$', '\\' }) >= 0) continue;
if (!terms.Contains(t, StringComparer.OrdinalIgnoreCase)) terms.Add(t);
if (terms.Count >= 5) break;
}
return terms;
}
public string ParametersJson => @"{'type':'object', public string ParametersJson => @"{'type':'object',
'properties':{ 'properties':{
'query':{'type':'string','description':" + ToolJson.Q("搜索关键字(支持正则,如 '审批不触发'、'库存(不对|多扣)')") + @"}, 'query':{'type':'string','description':" + ToolJson.Q("搜索关键字(支持正则,如 '审批不触发'、'库存(不对|多扣)')") + @"},
...@@ -117,6 +149,7 @@ namespace Tools.MyAgent.Agent.Tools ...@@ -117,6 +149,7 @@ namespace Tools.MyAgent.Agent.Tools
int max = args["maxResults"]?.Value<int>() ?? 20; int max = args["maxResults"]?.Value<int>() ?? 20;
if (max < 1) max = 1; if (max < 1) max = 1;
if (max > 30) max = 30; if (max > 30) max = 30;
var terms = ExtractTerms(query);
try try
{ {
...@@ -132,12 +165,13 @@ namespace Tools.MyAgent.Agent.Tools ...@@ -132,12 +165,13 @@ namespace Tools.MyAgent.Agent.Tools
string[] lines; string[] lines;
try { lines = File.ReadAllLines(abs, Encoding.UTF8); } try { lines = File.ReadAllLines(abs, Encoding.UTF8); }
catch { continue; } catch { continue; }
foreach (var line in lines) for (int i = 0; i < lines.Length; i++)
{ {
string line = lines[i];
if (line.Length > 300) continue; // 跳过超长行(通常是代码块) if (line.Length > 300) continue; // 跳过超长行(通常是代码块)
if (regex.IsMatch(line)) if (regex.IsMatch(line))
{ {
hits.Add($"- {rel}:{line.Trim().TrimStart('#', ' ', '>', '-', '*')}"); hits.Add($"- {rel} L{i + 1}(匹配度 {ScoreLine(line, terms):0.00}):{line.Trim().TrimStart('#', ' ', '>', '-', '*')}");
if (hits.Count >= max) break; if (hits.Count >= max) break;
} }
} }
...@@ -145,7 +179,7 @@ namespace Tools.MyAgent.Agent.Tools ...@@ -145,7 +179,7 @@ namespace Tools.MyAgent.Agent.Tools
} }
if (hits.Count == 0) if (hits.Count == 0)
return $"(内置知识中未找到「{query}」;可换关键词,或先 d_list 浏览目录结构)"; return $"(内置知识中未找到「{query}」;可换关键词,或先 d_list 浏览目录结构)";
return $"命中 {hits.Count} 条:\n" + string.Join("\n", hits); return $"命中 {hits.Count} 条(匹配度 = 字面词覆盖率打分,≥0.70 视为与查询相关):\n" + string.Join("\n", hits);
} }
catch (Exception ex) { return $"错误:{ex.Message}"; } catch (Exception ex) { return $"错误:{ex.Message}"; }
} }
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
判断本轮属于哪一类(拿不准时按第二类即排查类输出,信息更全): 判断本轮属于哪一类(拿不准时按第二类即排查类输出,信息更全):
- 第一类 知识问答:查资料、查流程、问「是什么/怎么做/在哪配」等,且不涉及现场故障与代码/表/调用链定位。 - 第一类 知识问答:查资料、查流程、问「是什么/怎么做/在哪配」等,且不涉及现场故障与代码/表/调用链定位。
- 「输出/整理/总结…流程、步骤、规范、清单、模板」类方法论问题(无现场故障)一律按第一类三段式输出,知识来源按第四节的三级阶梯取用。
- 第二类 现场排查:涉及报错、异常、失败、崩溃、超时、卡死、没反应,或需要定位到具体文件/类/表/调用链/日志。 - 第二类 现场排查:涉及报错、异常、失败、崩溃、超时、卡死、没反应,或需要定位到具体文件/类/表/调用链/日志。
一、知识问答类 → 只允许三节,顺序固定,节标题用加粗或三级标题,禁止增加其它章节与客套话: 一、知识问答类 → 只允许三节,顺序固定,节标题用加粗或三级标题,禁止增加其它章节与客套话:
...@@ -24,3 +25,12 @@ ...@@ -24,3 +25,12 @@
- 超过 3 列的表格;代码块仅用于给出命令或配置片段。 - 超过 3 列的表格;代码块仅用于给出命令或配置片段。
- 结尾总结段、免责声明、与问题无关的扩展建议。 - 结尾总结段、免责声明、与问题无关的扩展建议。
- 回答长度与问题复杂度匹配:简单问题给出简短三段式即可,不刻意展开。 - 回答长度与问题复杂度匹配:简单问题给出简短三段式即可,不刻意展开。
四、知识来源三级阶梯(按匹配度决定方案主体与引用;上一级未命中或匹配度不足才落到下一级):
1. 知识库(kb_* 检索):匹配度 ≥0.72 → 以知识库为方案主体,引用节列知识库行(含匹配度);
0.45~0.72 → 部分命中,可结合下一级补充并逐条标注来源;<0.45 或按问题主体词检索明确返回「没有找到」→ 视为未命中。
2. Agent 内置知识库(d_grep/d_read,dialect/ 与 industry/):知识库未命中时检索内置方言;d_grep 匹配度 ≥0.70
或 d_read 精读确认与问题相关 → 以内置方言方法论为方案主体,引用节列 dialect/ 或 industry/ 路径与行号。
3. 模型自答:两级均未命中 → 用通用知识作答,引用节写「知识库与内置方言均未命中,以下为模型自答,欢迎反馈」;
禁止把任何一级中噪声命中的无关文档充当方案主体或列入引用。
- 主体一致性:命中内容必须与问题主体直接相关;宽泛关键词的噪声命中一律不采信。
...@@ -12,8 +12,10 @@ namespace Tools.MyAgent.Forms ...@@ -12,8 +12,10 @@ namespace Tools.MyAgent.Forms
/// </summary> /// </summary>
public sealed class ThinkingSpinner : Control public sealed class ThinkingSpinner : Control
{ {
/// <summary>逻辑尺寸(未按 DPI 缩放前)</summary> /// <summary>逻辑尺寸(未按 DPI 缩放前):弧线所在正方形边长</summary>
private const int BaseSize = 24; private const int BaseSize = 24;
/// <summary>状态文字与弧线之间的间距(逻辑像素)</summary>
private const int TextGap = 8;
/// <summary>动画帧间隔(毫秒)</summary> /// <summary>动画帧间隔(毫秒)</summary>
private const int TickMs = 45; private const int TickMs = 45;
/// <summary>弧线张角</summary> /// <summary>弧线张角</summary>
...@@ -27,6 +29,7 @@ namespace Tools.MyAgent.Forms ...@@ -27,6 +29,7 @@ namespace Tools.MyAgent.Forms
private int _angle; private int _angle;
private bool _thinking; private bool _thinking;
private float _scale = 1f; private float _scale = 1f;
private string _status = ""; // v1.3.7:弧线右侧直接绘制状态文字(替代悬停 ToolTip)
public ThinkingSpinner() public ThinkingSpinner()
{ {
...@@ -36,6 +39,7 @@ namespace Tools.MyAgent.Forms ...@@ -36,6 +39,7 @@ namespace Tools.MyAgent.Forms
| ControlStyles.ResizeRedraw, true); | ControlStyles.ResizeRedraw, true);
// 与 panelChat / RichTextBox 一致的白底:避免半透明混色残影,也不依赖父容器重绘 // 与 panelChat / RichTextBox 一致的白底:避免半透明混色残影,也不依赖父容器重绘
BackColor = Color.White; BackColor = Color.White;
Font = new Font("微软雅黑", 9F); // 状态文字:比正文小一号的灰字
TabStop = false; TabStop = false;
Visible = false; Visible = false;
Size = new Size(BaseSize, BaseSize); Size = new Size(BaseSize, BaseSize);
...@@ -44,6 +48,20 @@ namespace Tools.MyAgent.Forms ...@@ -44,6 +48,20 @@ namespace Tools.MyAgent.Forms
_timer.Tick += OnTick; _timer.Tick += OnTick;
} }
/// <summary>状态文字(显示在弧线右侧;置空只显示弧线)。变化时重算控件宽度并重绘。</summary>
public string StatusText
{
get { return _status ?? ""; }
set
{
string v = value ?? "";
if (string.Equals(_status, v, StringComparison.Ordinal)) return;
_status = v;
ResizeForStatus();
Invalidate();
}
}
/// <summary>是否处于思考中(true:显示并旋转;false:隐藏并停止计时器)</summary> /// <summary>是否处于思考中(true:显示并旋转;false:隐藏并停止计时器)</summary>
public bool Thinking public bool Thinking
{ {
...@@ -92,7 +110,9 @@ namespace Tools.MyAgent.Forms ...@@ -92,7 +110,9 @@ namespace Tools.MyAgent.Forms
float radius = (BaseSize - 8) * scale / 2f; float radius = (BaseSize - 8) * scale / 2f;
if (radius <= 1f) return; if (radius <= 1f) return;
float cx = ClientSize.Width / 2f; // v1.3.9b:弧线固定画在左侧 BaseSize×BaseSize 方格内(此前取 ClientSize.Width/2,
// 状态文字把控件撑宽后弧线被带到中点、压进文字区)
float cx = BaseSize * scale / 2f;
float cy = ClientSize.Height / 2f; float cy = ClientSize.Height / 2f;
var rect = new RectangleF(cx - radius, cy - radius, radius * 2f, radius * 2f); var rect = new RectangleF(cx - radius, cy - radius, radius * 2f, radius * 2f);
...@@ -112,6 +132,16 @@ namespace Tools.MyAgent.Forms ...@@ -112,6 +132,16 @@ namespace Tools.MyAgent.Forms
g.DrawArc(pen, rect, start, segSweep + 0.6f); g.DrawArc(pen, rect, start, segSweep + 0.6f);
} }
} }
// v1.3.7:状态文字画在弧线右侧(垂直居中;白底上用灰色,不与链接/正文色混淆)
if (_status.Length > 0)
{
float textX = BaseSize * scale + TextGap * scale;
var layout = new RectangleF(textX, 0f, ClientSize.Width - textX, ClientSize.Height);
TextRenderer.DrawText(g, _status, Font, Rectangle.Round(layout),
Color.FromArgb(96, 96, 96), Color.White,
TextFormatFlags.NoPrefix | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine);
}
} }
/// <summary>按当前 DPI 计算缩放并同步控件尺寸(高 DPI 下弧线不糊、占位不偏)</summary> /// <summary>按当前 DPI 计算缩放并同步控件尺寸(高 DPI 下弧线不糊、占位不偏)</summary>
...@@ -123,7 +153,22 @@ namespace Tools.MyAgent.Forms ...@@ -123,7 +153,22 @@ namespace Tools.MyAgent.Forms
_scale = g.DpiX > 0 ? g.DpiX / 96f : 1f; _scale = g.DpiX > 0 ? g.DpiX / 96f : 1f;
} }
catch { _scale = 1f; } catch { _scale = 1f; }
Size = new Size(Scaled(BaseSize), Scaled(BaseSize)); ResizeForStatus();
}
/// <summary>按 DPI 与状态文字实测宽度重算控件尺寸(宽 = 弧线方 + 间距 + 文字;无文字时即弧线方)</summary>
private void ResizeForStatus()
{
float scale = _scale <= 0f ? 1f : _scale;
int w = Scaled(BaseSize);
if (_status.Length > 0)
{
Size ts = TextRenderer.MeasureText(_status, Font,
new Size(int.MaxValue, int.MaxValue),
TextFormatFlags.NoPrefix | TextFormatFlags.SingleLine);
w += Scaled(TextGap) + ts.Width;
}
Size = new Size(w, Scaled(BaseSize));
} }
private int Scaled(int v) private int Scaled(int v)
......
...@@ -410,6 +410,7 @@ namespace Tools.MyAgent ...@@ -410,6 +410,7 @@ namespace Tools.MyAgent
bool inCodeBlock = false; bool inCodeBlock = false;
bool codeHasCjk = false; // 代码块是否含 CJK(在收集阶段检测原始文本;转义后文本无法检测)(D5) bool codeHasCjk = false; // 代码块是否含 CJK(在收集阶段检测原始文本;转义后文本无法检测)(D5)
var codeBuf = new StringBuilder(); var codeBuf = new StringBuilder();
bool skipBlankAfterHeading = false; // v1.3.8:标题后紧跟的空行吞掉(模型惯用「## 标题\n\n正文」,不吞则标题与内容间多一行空白)
for (int i = 0; i < lines.Length; i++) for (int i = 0; i < lines.Length; i++)
{ {
...@@ -477,9 +478,11 @@ namespace Tools.MyAgent ...@@ -477,9 +478,11 @@ namespace Tools.MyAgent
if (string.IsNullOrWhiteSpace(line)) if (string.IsNullOrWhiteSpace(line))
{ {
if (skipBlankAfterHeading) continue; // v1.3.8:标题后的空行不产生空白行
sb.Append(@"\par "); sb.Append(@"\par ");
continue; continue;
} }
skipBlankAfterHeading = false;
string trimmed = trimmedStart; string trimmed = trimmedStart;
int indent = line.Length - trimmed.Length; int indent = line.Length - trimmed.Length;
...@@ -489,18 +492,21 @@ namespace Tools.MyAgent ...@@ -489,18 +492,21 @@ namespace Tools.MyAgent
sb.Append(@"\b\fs22 "); sb.Append(@"\b\fs22 ");
RenderInline(sb, trimmed.Substring(4)); RenderInline(sb, trimmed.Substring(4));
sb.Append(@"\b0\fs20\par "); sb.Append(@"\b0\fs20\par ");
skipBlankAfterHeading = true;
} }
else if (trimmed.StartsWith("## ")) else if (trimmed.StartsWith("## "))
{ {
sb.Append(@"\b\fs26 "); sb.Append(@"\b\fs26 ");
RenderInline(sb, trimmed.Substring(3)); RenderInline(sb, trimmed.Substring(3));
sb.Append(@"\b0\fs20\par "); sb.Append(@"\b0\fs20\par ");
skipBlankAfterHeading = true;
} }
else if (trimmed.StartsWith("# ")) else if (trimmed.StartsWith("# "))
{ {
sb.Append(@"\b\fs32 "); sb.Append(@"\b\fs32 ");
RenderInline(sb, trimmed.Substring(2)); RenderInline(sb, trimmed.Substring(2));
sb.Append(@"\b0\fs20\par "); sb.Append(@"\b0\fs20\par ");
skipBlankAfterHeading = true;
} }
else if (trimmed.StartsWith("- ") || trimmed.StartsWith("* ")) else if (trimmed.StartsWith("- ") || trimmed.StartsWith("* "))
{ {
......
...@@ -188,14 +188,16 @@ namespace Tools.MyAgent ...@@ -188,14 +188,16 @@ namespace Tools.MyAgent
} }
/// <summary> /// <summary>
/// 思考旋转指示器(右下角悬浮):思考中显示旋转弧,正文一到即隐藏。 /// 思考指示器(v1.3.7:锚定在当前流式回答的助手标题下方,弧线右侧直接显示状态文字)。
/// <para>替代旧形态「把思维链文本整块渲染进问答区」;不再渲染思考文本的开关见 AgentConfig.ShowThinking。</para> /// <para>替代旧形态「右下角悬浮 + 悬停看状态」;不再渲染思考文本的开关见 AgentConfig.ShowThinking。</para>
/// </summary> /// </summary>
private void EnsureThinkingSpinner() private void EnsureThinkingSpinner()
{ {
if (_spinner != null) return; if (_spinner != null) return;
_spinner = new Forms.ThinkingSpinner(); _spinner = new Forms.ThinkingSpinner();
panelChat.Controls.Add(_spinner); panelChat.Controls.Add(_spinner);
if (_rtfFallback != null)
_rtfFallback.VScroll += (s, e) => PositionThinkingSpinner(); // 用户手动滚动时跟随锚点
PositionThinkingSpinner(); PositionThinkingSpinner();
_spinner.BringToFront(); _spinner.BringToFront();
panelChat.Resize += (s, e) => panelChat.Resize += (s, e) =>
...@@ -205,19 +207,87 @@ namespace Tools.MyAgent ...@@ -205,19 +207,87 @@ namespace Tools.MyAgent
}; };
} }
/// <summary>把旋转指示器贴到问答区右下角(距边 12px;Dock=Fill 的 RichTextBox 之上)</summary> /// <summary>
/// 把指示器锚定到当前流式回答的助手标题下方(标题行下一行左上角)。
/// <para>定位依据:流式助手消息的标题行「助手 + 空格 + HH:mm:ss」在 RichTextBox 文本中的字符位置,
/// 经 GetPositionFromCharIndex 换算像素坐标后下移一行;用户滚动时随 VScroll 重算。
/// 标题未渲染(欢迎页等)时兜底贴问答区左上角。</para>
/// </summary>
private void PositionThinkingSpinner() private void PositionThinkingSpinner()
{ {
if (_spinner == null || panelChat == null) return; if (_spinner == null || panelChat == null || _rtfFallback == null) return;
Point anchor = Point.Empty;
int titleStart = FindStreamingTitleRange(_rtfFallback.Text, out int titleEnd);
if (titleStart >= 0)
{
// X 取标题起始字符(行左端);Y 取行尾字符(同行像素)+ 一行行高 = 下一行左上角。
// (v1.3.9b:此前 X 也取行尾字符,落在标题文本右端,表现为图标离左侧过远)
Point pe = _rtfFallback.GetPositionFromCharIndex(titleEnd);
Point ps = _rtfFallback.GetPositionFromCharIndex(titleStart);
anchor = new Point(ps.X, pe.Y + _rtfFallback.Font.Height + 2);
if (anchor.Y < 0) anchor.Y = 12; // 标题已滚出视口上沿时收进可视区
}
if (anchor.IsEmpty)
{
const int margin = 12; const int margin = 12;
_spinner.Location = new Point( anchor = new Point(margin, margin);
Math.Max(0, panelChat.ClientSize.Width - _spinner.Width - margin), }
Math.Max(0, panelChat.ClientSize.Height - _spinner.Height - margin)); // 视口钳制:标题贴近可视区下沿时锚点+行高可能被裁剪,收进可视范围
// 底部悬浮栏(待发送附图 / 回答附图)是在指示器之后加入的,默认 Z 序更高; int maxY = panelChat.ClientSize.Height - _spinner.Height - 2;
// 窄窗口下栏体可能横跨到右下角把指示器压住,故每次定位都把指示器提到最前。 if (anchor.Y > maxY) anchor.Y = Math.Max(12, maxY);
_spinner.Location = anchor;
_spinner.BringToFront(); _spinner.BringToFront();
} }
/// <summary>
/// 在渲染文本中定位流式助手消息标题行的字符区间。
/// <para>匹配方式:从后往前找流式消息的时间戳(HH:mm:ss),再核对其前 ≤3 个空白之外紧邻「助手」——
/// 名字与时间戳之间的空格数受 RTF 控制字定界符吞噬影响不固定(v1.3.7 曾按固定 2 空格匹配而全部落空),
/// 故不能拼固定字符串 LastIndexOf。</para>
/// </summary>
/// <param name="text">渲染后的纯文本</param>
/// <param name="titleEnd">输出:标题行行尾下标(时间戳末字符之后,取行像素 Y 用)</param>
/// <returns>标题起始下标(「助」字,取行左端 X 用);未找到返回 -1</returns>
private int FindStreamingTitleRange(string text, out int titleEnd)
{
titleEnd = -1;
if (string.IsNullOrEmpty(text)) return -1;
// 取最后一条流式助手消息的时间戳(无流式消息 = 指示器本不该显示)
string ts = null;
var msgs = _messages;
if (msgs != null)
{
for (int i = msgs.Count - 1; i >= 0; i--)
{
var m = msgs[i];
if (m != null && m.IsStreaming && m.Role == "assistant")
{
ts = m.Timestamp.ToString("HH:mm:ss");
break;
}
}
}
if (ts == null) return -1;
int p = text.Length;
while ((p = text.LastIndexOf(ts, p - 1, StringComparison.Ordinal)) >= 0)
{
// 向前跳过至多 3 个空白后应命中「助手」二字
int q = p - 1, spaces = 0;
while (q >= 0 && (text[q] == ' ' || text[q] == '\t') && spaces < 3) { q--; spaces++; }
if (q >= 1 && text[q] == '手' && text[q - 1] == '助')
{
// 行尾下标 = 时间戳末字符之后(超出时钳到末字符);起始 = 「助」字下标
int end = p + ts.Length;
titleEnd = end <= text.Length - 1 ? end : text.Length - 1;
return q - 1; // 「助」字下标
}
}
return -1;
}
/// <summary> /// <summary>
/// 思考指示三态机驱动:Idle → Thinking(收到思考且尚无正文)→ Streaming(首帧正文到达即隐藏)→ Idle。 /// 思考指示三态机驱动:Idle → Thinking(收到思考且尚无正文)→ Streaming(首帧正文到达即隐藏)→ Idle。
/// <para>失败/取消路径由 OnAssistantComplete / OnEngineError 兜底置空闲,避免指示器残留。</para> /// <para>失败/取消路径由 OnAssistantComplete / OnEngineError 兜底置空闲,避免指示器残留。</para>
...@@ -226,6 +296,7 @@ namespace Tools.MyAgent ...@@ -226,6 +296,7 @@ namespace Tools.MyAgent
{ {
if (_spinner == null || _spinner.Thinking == thinking) return; if (_spinner == null || _spinner.Thinking == thinking) return;
_spinner.Thinking = thinking; _spinner.Thinking = thinking;
_spinner.StatusText = thinking ? "思考中" : ""; // v1.3.7:状态直接显示,不依赖悬停
PositionThinkingSpinner(); PositionThinkingSpinner();
} }
...@@ -642,6 +713,7 @@ namespace Tools.MyAgent ...@@ -642,6 +713,7 @@ namespace Tools.MyAgent
_rtfFallback.ScrollToCaret(); _rtfFallback.ScrollToCaret();
_answerImageSeq = _rtfCache.LastImageSequence; // 缩略图点击映射随本次 RTF 重建(v1.3.5) _answerImageSeq = _rtfCache.LastImageSequence; // 缩略图点击映射随本次 RTF 重建(v1.3.5)
RebuildAnswerRefs(); // 引用行表随重绘重建(位置随重绘失效);流式期间跳过 RebuildAnswerRefs(); // 引用行表随重绘重建(位置随重绘失效);流式期间跳过
PositionThinkingSpinner(); // v1.3.7:重绘/滚动到底后重算锚点(标题下方)
} }
/// <summary> /// <summary>
...@@ -1013,6 +1085,11 @@ namespace Tools.MyAgent ...@@ -1013,6 +1085,11 @@ namespace Tools.MyAgent
_producedFiles.Clear(); // 新一轮开始,清空上轮输出文件 _producedFiles.Clear(); // 新一轮开始,清空上轮输出文件
// (v1.3.2)回答附图内嵌在消息上,历史保留,不再按轮清空 // (v1.3.2)回答附图内嵌在消息上,历史保留,不再按轮清空
RefreshChat(); RefreshChat();
// v1.3.9:发送即进入等待态(弧线 + 「思考中」显示在助手标题下方)。
// 此前指示器是响应式触发(等首个思考增量/工具状态事件才显示),模型首 token
// 延迟期间(直答通道/普通生成,动辄数秒到数十秒)两个事件都不发 → 等待期零指示。
// 首帧正文到达(OnAssistantTextDelta)/ 完成 / 取消 / 出错路径照旧置回 Idle。
SetThinking(true);
txtInput.Focus(); txtInput.Focus();
// 用量快照:记录本轮发送前的累计值,回复完成后按增量展示本轮用量 // 用量快照:记录本轮发送前的累计值,回复完成后按增量展示本轮用量
...@@ -1084,10 +1161,10 @@ namespace Tools.MyAgent ...@@ -1084,10 +1161,10 @@ namespace Tools.MyAgent
{ {
// P1:不再覆写 last.Content —— 旧实现把「【正在执行】…」写进回答正文, // P1:不再覆写 last.Content —— 旧实现把「【正在执行】…」写进回答正文,
// 一旦失败/取消就永久残留在问答区(用户看到一条永远停在「正在执行」的回答)。 // 一旦失败/取消就永久残留在问答区(用户看到一条永远停在「正在执行」的回答)。
// 进度表达交给状态栏「回复中...」+ 右下旋转指示;具体工具名保留在指示器悬停提示里,信息不丢。 // v1.3.7:进度表达交给标题下方的指示器(弧线 + 右侧状态文字),具体工具名直接可见,不再依赖悬停。
SetThinking(true); SetThinking(true);
if (_spinner != null && tipScope != null && !string.IsNullOrEmpty(status)) if (_spinner != null && !string.IsNullOrEmpty(status))
tipScope.SetToolTip(_spinner, "正在执行:" + status); _spinner.StatusText = "正在执行:" + status;
} }
private void OnAssistantComplete() private void OnAssistantComplete()
......
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