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

pref: Agent交互优化

parent 2f8390cb
......@@ -75,3 +75,5 @@ bin
/AppHome/Package
/.workbuddy
/temp
/AppHome/_backup
/Tools.Agent.ProbeKbPref
No preview for this file type
......@@ -623,6 +623,16 @@
<member name="F:Tools.MyAgent.Agent.KbRecall.RemoteListIntent">
<summary>列表意图:命中则走远程「目录模式」(返回全部条目标题清单),而非逐词检索</summary>
</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">
<summary>纳入证据链的命中上限(主命中 + 其余)</summary>
</member>
......@@ -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)">
<summary>图片绝对路径校验(存在 + 在知识库内 + 图片扩展 + 去重)后加入</summary>
</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)">
<summary>相对路径 → 绝对路径(仅拼接,不抛异常;越界/非法返回 null)</summary>
</member>
......@@ -704,6 +720,13 @@
过滤疑问/连接类停用词,按具体程度排序去重。
</summary>
</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})">
<summary>单个 token → ASCII / 中文三元组 / 中文二元组(混合 token 按字符类别切段)</summary>
</member>
......@@ -981,7 +1004,17 @@
</summary>
</member>
<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 name="T:Tools.MyAgent.Agent.Tools.DialectReadTool">
<summary>d_read:读取内置方言/行业知识文件内容(UTF-8 文本,≤300 行/次)</summary>
......@@ -1637,7 +1670,10 @@
</summary>
</member>
<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 name="F:Tools.MyAgent.Forms.ThinkingSpinner.TickMs">
<summary>动画帧间隔(毫秒)</summary>
......@@ -1651,12 +1687,18 @@
<member name="F:Tools.MyAgent.Forms.ThinkingSpinner.AngleStep">
<summary>每帧角度增量</summary>
</member>
<member name="P:Tools.MyAgent.Forms.ThinkingSpinner.StatusText">
<summary>状态文字(显示在弧线右侧;置空只显示弧线)。变化时重算控件宽度并重绘。</summary>
</member>
<member name="P:Tools.MyAgent.Forms.ThinkingSpinner.Thinking">
<summary>是否处于思考中(true:显示并旋转;false:隐藏并停止计时器)</summary>
</member>
<member name="M:Tools.MyAgent.Forms.ThinkingSpinner.ApplyScale">
<summary>按当前 DPI 计算缩放并同步控件尺寸(高 DPI 下弧线不糊、占位不偏)</summary>
</member>
<member name="M:Tools.MyAgent.Forms.ThinkingSpinner.ResizeForStatus">
<summary>按 DPI 与状态文字实测宽度重算控件尺寸(宽 = 弧线方 + 间距 + 文字;无文字时即弧线方)</summary>
</member>
<member name="T:Tools.MyAgent.wBitmapView">
<summary>
图片查看器:优先按图片原始尺寸展示(1:1 像素),但不超过屏幕工作区大小;
......@@ -3584,12 +3626,28 @@
</member>
<member name="M:Tools.MyAgent.wMyAgent.EnsureThinkingSpinner">
<summary>
思考旋转指示器(右下角悬浮):思考中显示旋转弧,正文一到即隐藏。
<para>替代旧形态「把思维链文本整块渲染进问答区」;不再渲染思考文本的开关见 AgentConfig.ShowThinking。</para>
思考指示器(v1.3.7:锚定在当前流式回答的助手标题下方,弧线右侧直接显示状态文字)。
<para>替代旧形态「右下角悬浮 + 悬停看状态」;不再渲染思考文本的开关见 AgentConfig.ShowThinking。</para>
</summary>
</member>
<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 name="M:Tools.MyAgent.wMyAgent.SetThinking(System.Boolean)">
<summary>
......
......@@ -394,6 +394,33 @@ namespace Tools.MyAgent.Agent
if (result.ToolCalls.Count == 0)
{
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;
}
......@@ -448,6 +475,12 @@ namespace Tools.MyAgent.Agent
if (final.ReasoningContent.Length > 0)
OnThinkingDelta?.Invoke(final.ReasoningContent);
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;
}
}
......@@ -516,6 +549,16 @@ namespace Tools.MyAgent.Agent
{
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;
try
{
......@@ -555,10 +598,9 @@ namespace Tools.MyAgent.Agent
// v4.5b 空回答回退:直答偶发空内容(长目录证据噎住模型 / 自定义端点流异常中途断流,
// 真机实证 gpt-6-luna 直答 2.7s 断流 tokens=-1+-1)时,不能拿「(模型未返回内容)」占位收场 ——
// 回退工具循环重试。finalAnswer 里的占位必须清掉,否则工具循环的回答会接在占位后面。
if (finalAnswer.ToString().IndexOf("(模型未返回内容)", StringComparison.Ordinal) >= 0)
// 回退工具循环重试(v1.3.7:占位不再执行中下发,按 finalAnswer 是否为空判定)。
if (finalAnswer.Length == 0)
{
finalAnswer.Clear();
while (messages.Count > mark) messages.RemoveAt(messages.Count - 1); // 回滚证据消息
AgentLogger.Log("直答返回空内容,回退工具循环重试");
return false;
......@@ -730,9 +772,10 @@ namespace Tools.MyAgent.Agent
break;
}
if (finalAnswer.Length == 0)
finalAnswer.Append("(模型未返回内容)");
else
// v1.3.7:空返回不再立即下发「(模型未返回内容)」占位 —— 此前重试期间回答区一直顶着占位文案,
// 表现为「模型还没执行完就显示干扰内容」。这里保持 finalAnswer 为空、不触发 UI;
// 占位只在各调用方「重试耗尽仍为空」时兜底追加。
if (finalAnswer.Length > 0)
{
// P3 后置兜底(只提示、不重试 —— 重试会让成本翻倍,而回答本身仍有价值):
// 仅在契约层已下发时校验;缺节则顶部加一行提示,明确「已原样保留」,不静默吞掉不合规输出。
......@@ -743,14 +786,14 @@ namespace Tools.MyAgent.Agent
}
if (_contextNotice.Length > 0)
finalAnswer.Insert(0, _contextNotice + "\n\n"); // 上下文提示保留在最终回答开头(此前被正文覆盖)
if (IsTruncatedByLength(result))
{
// 达到续写上限仍被截断:明确提示用户,不再静默展示半截回答
finalAnswer.Append($"\n\n⚠️ 回答过长,已被输出上限截断(自动续写 {_continueRounds} 次后仍未完成)。请在「高级设置」中调高输出上限,或将问题拆分为多次提问。");
AgentLogger.Log($"回答经 {_continueRounds} 轮自动续写后仍被输出上限截断,已提示用户");
}
OnTextDelta?.Invoke(finalAnswer.ToString());
}
if (IsTruncatedByLength(result))
{
// 达到续写上限仍被截断:明确提示用户,不再静默展示半截回答
finalAnswer.Append($"\n\n⚠️ 回答过长,已被输出上限截断(自动续写 {_continueRounds} 次后仍未完成)。请在「高级设置」中调高输出上限,或将问题拆分为多次提问。");
AgentLogger.Log($"回答经 {_continueRounds} 轮自动续写后仍被输出上限截断,已提示用户");
}
OnTextDelta?.Invoke(finalAnswer.ToString());
}
// ── #31 故障转移 ──
......
This diff is collapsed.
......@@ -317,6 +317,7 @@ namespace Tools.MyAgent.Agent
判断本轮属于哪一类(拿不准时按第二类即排查类输出,信息更全):
- 第一类 知识问答:查资料、查流程、问「是什么/怎么做/在哪配」等,且不涉及现场故障与代码/表/调用链定位。
- 「输出/整理/总结…流程、步骤、规范、清单、模板」类方法论问题(无现场故障)一律按第一类三段式输出,知识来源按第四节的三级阶梯取用。
- 第二类 现场排查:涉及报错、异常、失败、崩溃、超时、卡死、没反应,或需要定位到具体文件/类/表/调用链/日志。
一、知识问答类 → 只允许三节,顺序固定,节标题用加粗或三级标题,禁止增加其它章节与客套话:
......@@ -337,6 +338,15 @@ namespace Tools.MyAgent.Agent
- 大段原文摘录(超过 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
}
}
/// <summary>d_grep:在内置方言/行业知识文件内容中搜索关键字,返回命中文件与上下文片段</summary>
/// <summary>d_grep:在内置方言/行业知识文件内容中搜索关键字,返回命中文件、行号与匹配度(词覆盖率打分)</summary>
internal sealed class DialectGrepTool : ITool
{
public string Name => "d_grep";
public string Description =>
"在内置产品方言与行业知识文件内容中搜索关键字(限 *.md 文本),返回命中文件与上下文片段。" +
"在内置产品方言与行业知识文件内容中搜索关键字(限 *.md 文本),返回命中文件、行号与匹配度。" +
"排查/业务类问题先按触发词用本工具定位,再用 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',
'properties':{
'query':{'type':'string','description':" + ToolJson.Q("搜索关键字(支持正则,如 '审批不触发'、'库存(不对|多扣)')") + @"},
......@@ -117,6 +149,7 @@ namespace Tools.MyAgent.Agent.Tools
int max = args["maxResults"]?.Value<int>() ?? 20;
if (max < 1) max = 1;
if (max > 30) max = 30;
var terms = ExtractTerms(query);
try
{
......@@ -132,12 +165,13 @@ namespace Tools.MyAgent.Agent.Tools
string[] lines;
try { lines = File.ReadAllLines(abs, Encoding.UTF8); }
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 (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;
}
}
......@@ -145,7 +179,7 @@ namespace Tools.MyAgent.Agent.Tools
}
if (hits.Count == 0)
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}"; }
}
......
......@@ -3,6 +3,7 @@
判断本轮属于哪一类(拿不准时按第二类即排查类输出,信息更全):
- 第一类 知识问答:查资料、查流程、问「是什么/怎么做/在哪配」等,且不涉及现场故障与代码/表/调用链定位。
- 「输出/整理/总结…流程、步骤、规范、清单、模板」类方法论问题(无现场故障)一律按第一类三段式输出,知识来源按第四节的三级阶梯取用。
- 第二类 现场排查:涉及报错、异常、失败、崩溃、超时、卡死、没反应,或需要定位到具体文件/类/表/调用链/日志。
一、知识问答类 → 只允许三节,顺序固定,节标题用加粗或三级标题,禁止增加其它章节与客套话:
......@@ -24,3 +25,12 @@
- 超过 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
/// </summary>
public sealed class ThinkingSpinner : Control
{
/// <summary>逻辑尺寸(未按 DPI 缩放前)</summary>
/// <summary>逻辑尺寸(未按 DPI 缩放前):弧线所在正方形边长</summary>
private const int BaseSize = 24;
/// <summary>状态文字与弧线之间的间距(逻辑像素)</summary>
private const int TextGap = 8;
/// <summary>动画帧间隔(毫秒)</summary>
private const int TickMs = 45;
/// <summary>弧线张角</summary>
......@@ -27,6 +29,7 @@ namespace Tools.MyAgent.Forms
private int _angle;
private bool _thinking;
private float _scale = 1f;
private string _status = ""; // v1.3.7:弧线右侧直接绘制状态文字(替代悬停 ToolTip)
public ThinkingSpinner()
{
......@@ -36,6 +39,7 @@ namespace Tools.MyAgent.Forms
| ControlStyles.ResizeRedraw, true);
// 与 panelChat / RichTextBox 一致的白底:避免半透明混色残影,也不依赖父容器重绘
BackColor = Color.White;
Font = new Font("微软雅黑", 9F); // 状态文字:比正文小一号的灰字
TabStop = false;
Visible = false;
Size = new Size(BaseSize, BaseSize);
......@@ -44,6 +48,20 @@ namespace Tools.MyAgent.Forms
_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>
public bool Thinking
{
......@@ -92,7 +110,9 @@ namespace Tools.MyAgent.Forms
float radius = (BaseSize - 8) * scale / 2f;
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;
var rect = new RectangleF(cx - radius, cy - radius, radius * 2f, radius * 2f);
......@@ -112,6 +132,16 @@ namespace Tools.MyAgent.Forms
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>
......@@ -123,7 +153,22 @@ namespace Tools.MyAgent.Forms
_scale = g.DpiX > 0 ? g.DpiX / 96f : 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)
......
......@@ -410,6 +410,7 @@ namespace Tools.MyAgent
bool inCodeBlock = false;
bool codeHasCjk = false; // 代码块是否含 CJK(在收集阶段检测原始文本;转义后文本无法检测)(D5)
var codeBuf = new StringBuilder();
bool skipBlankAfterHeading = false; // v1.3.8:标题后紧跟的空行吞掉(模型惯用「## 标题\n\n正文」,不吞则标题与内容间多一行空白)
for (int i = 0; i < lines.Length; i++)
{
......@@ -477,9 +478,11 @@ namespace Tools.MyAgent
if (string.IsNullOrWhiteSpace(line))
{
if (skipBlankAfterHeading) continue; // v1.3.8:标题后的空行不产生空白行
sb.Append(@"\par ");
continue;
}
skipBlankAfterHeading = false;
string trimmed = trimmedStart;
int indent = line.Length - trimmed.Length;
......@@ -489,18 +492,21 @@ namespace Tools.MyAgent
sb.Append(@"\b\fs22 ");
RenderInline(sb, trimmed.Substring(4));
sb.Append(@"\b0\fs20\par ");
skipBlankAfterHeading = true;
}
else if (trimmed.StartsWith("## "))
{
sb.Append(@"\b\fs26 ");
RenderInline(sb, trimmed.Substring(3));
sb.Append(@"\b0\fs20\par ");
skipBlankAfterHeading = true;
}
else if (trimmed.StartsWith("# "))
{
sb.Append(@"\b\fs32 ");
RenderInline(sb, trimmed.Substring(2));
sb.Append(@"\b0\fs20\par ");
skipBlankAfterHeading = true;
}
else if (trimmed.StartsWith("- ") || trimmed.StartsWith("* "))
{
......
......@@ -188,14 +188,16 @@ namespace Tools.MyAgent
}
/// <summary>
/// 思考旋转指示器(右下角悬浮):思考中显示旋转弧,正文一到即隐藏。
/// <para>替代旧形态「把思维链文本整块渲染进问答区」;不再渲染思考文本的开关见 AgentConfig.ShowThinking。</para>
/// 思考指示器(v1.3.7:锚定在当前流式回答的助手标题下方,弧线右侧直接显示状态文字)。
/// <para>替代旧形态「右下角悬浮 + 悬停看状态」;不再渲染思考文本的开关见 AgentConfig.ShowThinking。</para>
/// </summary>
private void EnsureThinkingSpinner()
{
if (_spinner != null) return;
_spinner = new Forms.ThinkingSpinner();
panelChat.Controls.Add(_spinner);
if (_rtfFallback != null)
_rtfFallback.VScroll += (s, e) => PositionThinkingSpinner(); // 用户手动滚动时跟随锚点
PositionThinkingSpinner();
_spinner.BringToFront();
panelChat.Resize += (s, e) =>
......@@ -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()
{
if (_spinner == null || panelChat == null) return;
const int margin = 12;
_spinner.Location = new Point(
Math.Max(0, panelChat.ClientSize.Width - _spinner.Width - margin),
Math.Max(0, panelChat.ClientSize.Height - _spinner.Height - margin));
// 底部悬浮栏(待发送附图 / 回答附图)是在指示器之后加入的,默认 Z 序更高;
// 窄窗口下栏体可能横跨到右下角把指示器压住,故每次定位都把指示器提到最前。
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;
anchor = new Point(margin, margin);
}
// 视口钳制:标题贴近可视区下沿时锚点+行高可能被裁剪,收进可视范围
int maxY = panelChat.ClientSize.Height - _spinner.Height - 2;
if (anchor.Y > maxY) anchor.Y = Math.Max(12, maxY);
_spinner.Location = anchor;
_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>
/// 思考指示三态机驱动:Idle → Thinking(收到思考且尚无正文)→ Streaming(首帧正文到达即隐藏)→ Idle。
/// <para>失败/取消路径由 OnAssistantComplete / OnEngineError 兜底置空闲,避免指示器残留。</para>
......@@ -226,6 +296,7 @@ namespace Tools.MyAgent
{
if (_spinner == null || _spinner.Thinking == thinking) return;
_spinner.Thinking = thinking;
_spinner.StatusText = thinking ? "思考中" : ""; // v1.3.7:状态直接显示,不依赖悬停
PositionThinkingSpinner();
}
......@@ -642,6 +713,7 @@ namespace Tools.MyAgent
_rtfFallback.ScrollToCaret();
_answerImageSeq = _rtfCache.LastImageSequence; // 缩略图点击映射随本次 RTF 重建(v1.3.5)
RebuildAnswerRefs(); // 引用行表随重绘重建(位置随重绘失效);流式期间跳过
PositionThinkingSpinner(); // v1.3.7:重绘/滚动到底后重算锚点(标题下方)
}
/// <summary>
......@@ -1013,6 +1085,11 @@ namespace Tools.MyAgent
_producedFiles.Clear(); // 新一轮开始,清空上轮输出文件
// (v1.3.2)回答附图内嵌在消息上,历史保留,不再按轮清空
RefreshChat();
// v1.3.9:发送即进入等待态(弧线 + 「思考中」显示在助手标题下方)。
// 此前指示器是响应式触发(等首个思考增量/工具状态事件才显示),模型首 token
// 延迟期间(直答通道/普通生成,动辄数秒到数十秒)两个事件都不发 → 等待期零指示。
// 首帧正文到达(OnAssistantTextDelta)/ 完成 / 取消 / 出错路径照旧置回 Idle。
SetThinking(true);
txtInput.Focus();
// 用量快照:记录本轮发送前的累计值,回复完成后按增量展示本轮用量
......@@ -1084,10 +1161,10 @@ namespace Tools.MyAgent
{
// P1:不再覆写 last.Content —— 旧实现把「【正在执行】…」写进回答正文,
// 一旦失败/取消就永久残留在问答区(用户看到一条永远停在「正在执行」的回答)。
// 进度表达交给状态栏「回复中...」+ 右下旋转指示;具体工具名保留在指示器悬停提示里,信息不丢。
// v1.3.7:进度表达交给标题下方的指示器(弧线 + 右侧状态文字),具体工具名直接可见,不再依赖悬停。
SetThinking(true);
if (_spinner != null && tipScope != null && !string.IsNullOrEmpty(status))
tipScope.SetToolTip(_spinner, "正在执行:" + status);
if (_spinner != null && !string.IsNullOrEmpty(status))
_spinner.StatusText = "正在执行:" + status;
}
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