Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
F
Ftt_Common
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
Ftt_Common
Commits
2ecc48c8
Commit
2ecc48c8
authored
Oct 20, 2026
by
段廷瑞
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
pref: Agent交互优化
parent
2f8390cb
Changes
11
Hide whitespace changes
Inline
Side-by-side
Showing
11 changed files
with
416 additions
and
47 deletions
+416
-47
.gitignore
.gitignore
+2
-0
Tools.Agent.dll
AppHome/Tools.Agent.dll
+0
-0
Tools.Agent.xml
AppHome/Tools.Agent.xml
+63
-5
AgentEngine.cs
Tools.Agent/Agent/AgentEngine.cs
+56
-13
KbRecall.cs
Tools.Agent/Agent/Answer/KbRecall.cs
+91
-7
PromptBuilder.cs
Tools.Agent/Agent/PromptBuilder.cs
+11
-1
DialectTools.cs
Tools.Agent/Agent/Tools/DialectTools.cs
+39
-5
answer_spec.md
Tools.Agent/Defaults/Agent/prompts/answer_spec.md
+10
-0
ThinkingSpinner.cs
Tools.Agent/Forms/ThinkingSpinner.cs
+48
-3
MarkdownRenderer.cs
Tools.Agent/Support/MarkdownRenderer.cs
+6
-0
wMyAgent.cs
Tools.Agent/wMyAgent.cs
+90
-13
No files found.
.gitignore
View file @
2ecc48c8
...
...
@@ -75,3 +75,5 @@ bin
/AppHome/Package
/.workbuddy
/temp
/AppHome/_backup
/Tools.Agent.ProbeKbPref
AppHome/Tools.Agent.dll
View file @
2ecc48c8
No preview for this file type
AppHome/Tools.Agent.xml
View file @
2ecc48c8
...
...
@@ -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>
...
...
Tools.Agent/Agent/AgentEngine.cs
View file @
2ecc48c8
...
...
@@ -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 故障转移 ──
...
...
Tools.Agent/Agent/Answer/KbRecall.cs
View file @
2ecc48c8
...
...
@@ -104,6 +104,23 @@ namespace Tools.MyAgent.Agent
@"列出|列表|清单|目录|都有什么|有哪些|有什么内容|多少条|全部条目"
,
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>
private
const
int
MaxHits
=
3
;
...
...
@@ -254,6 +271,18 @@ namespace Tools.MyAgent.Agent
return
Finalize
(
new
KbRecallResult
{
Mode
=
"model"
,
Query
=
question
,
Keywords
=
keywords
.
Take
(
used
).
ToList
()
},
keywords
,
question
,
ct
);
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
{
Query
=
question
,
...
...
@@ -329,17 +358,20 @@ namespace Tools.MyAgent.Agent
catch
{
return
;
}
// 双保险:Search 承诺除取消外不抛
if
(
hits
==
null
||
hits
.
Count
==
0
)
return
;
if
(!
localMiss
)
// F2:目录兜底条目(IsFallback,"最新 N 条")只服务列表意图;普通问题的证据链
// 不收零命中兜底 —— 与问题无关的最新条目进证据只会把回答带偏(真机案例 2026-10-19)。
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
;
}
// 本地未召回:远程条目升级为 mixed 证据(首条 Primary,其余 Others)。
// Others 必须先清空 —— 此处 result.Others 可能还残留本地低分命中(<0.45,未达证据线),
// 它们已随 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
.
Others
.
Clear
();
result
.
Others
.
AddRange
(
remote
.
Skip
(
1
));
...
...
@@ -368,7 +400,13 @@ namespace Tools.MyAgent.Agent
{
if
(
r
==
null
||
r
.
Primary
==
null
)
return
""
;
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
(
"召回关键词:"
+
string
.
Join
(
"、"
,
r
.
Keywords
));
sb
.
AppendLine
(
"最高匹配度:"
+
r
.
Primary
.
Score
.
ToString
(
"0.00"
)
+
"("
+
(
r
.
Mode
==
"kb"
?
"证据充分"
:
"部分命中,可结合通用知识补充并标注"
)
+
")"
);
...
...
@@ -381,7 +419,10 @@ namespace Tools.MyAgent.Agent
sb
.
AppendLine
(
"【引用行输出要求】"
);
sb
.
AppendLine
(
"- 引用节每行一条,格式固定:📎 <知识库相对路径> §<章节> L<行号> 匹配度 <0.xx>"
);
sb
.
AppendLine
(
"- 只列本轮真正用到的来源(最多 3 条),路径/章节/行号不得杜撰;无行号时省略 “L<行号>”。"
);
sb
.
AppendLine
(
"- 证据未覆盖的内容明确写「知识库中未找到相关内容」,不得凭记忆补造文件、表名或结论。"
);
if
(
partial
)
sb
.
AppendLine
(
"- 证据未覆盖的部分可结合通用知识与系统提示中的排查方法论(如五步法)补充,并在该内容后标注「(模型补充)」;仍不得凭记忆补造文件、表名或结论。"
);
else
sb
.
AppendLine
(
"- 证据未覆盖的内容明确写「知识库中未找到相关内容」,不得凭记忆补造文件、表名或结论。"
);
return
sb
.
ToString
();
}
...
...
@@ -609,6 +650,31 @@ namespace Tools.MyAgent.Agent
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
)
{
if
(
string
.
IsNullOrEmpty
(
joined
))
return
""
;
...
...
@@ -677,7 +743,7 @@ namespace Tools.MyAgent.Agent
int
triCap
=
result
.
Count
==
0
?
5
:
4
;
// 无 ASCII 标识时给中文多留额度
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
;
if
(
seen
.
Add
(
w
))
result
.
Add
(
w
);
if
(
result
.
Count
>=
triCap
||
result
.
Count
>=
8
)
break
;
...
...
@@ -685,13 +751,31 @@ namespace Tools.MyAgent.Agent
// 中文二元组补足
foreach
(
var
w
in
bi
)
{
if
(
CjkStopBigrams
.
Contains
(
w
))
continue
;
if
(
IsJunkBigram
(
w
))
continue
;
if
(
seen
.
Add
(
w
))
result
.
Add
(
w
);
if
(
result
.
Count
>=
8
)
break
;
}
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>
private
static
void
CollectToken
(
string
token
,
List
<
string
>
ascii
,
List
<
string
>
tri
,
List
<
string
>
bi
)
{
...
...
Tools.Agent/Agent/PromptBuilder.cs
View file @
2ecc48c8
...
...
@@ -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. 模型自答:两级均未命中 → 用通用知识作答,引用节写「知识库与内置方言均未命中,以下为模型自答,欢迎反馈」;
禁止把任何一级中噪声命中的无关文档充当方案主体或列入引用。
- 主体一致性:命中内容必须与问题主体直接相关;宽泛关键词的噪声命中一律不采信。"
;
}
}
Tools.Agent/Agent/Tools/DialectTools.cs
View file @
2ecc48c8
...
...
@@ -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
;
}
for
each
(
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
}
"
;
}
}
...
...
Tools.Agent/Defaults/Agent/prompts/answer_spec.md
View file @
2ecc48c8
...
...
@@ -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.
模型自答:两级均未命中 → 用通用知识作答,引用节写「知识库与内置方言均未命中,以下为模型自答,欢迎反馈」;
禁止把任何一级中噪声命中的无关文档充当方案主体或列入引用。
-
主体一致性:命中内容必须与问题主体直接相关;宽泛关键词的噪声命中一律不采信。
Tools.Agent/Forms/ThinkingSpinner.cs
View file @
2ecc48c8
...
...
@@ -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
)
...
...
Tools.Agent/Support/MarkdownRenderer.cs
View file @
2ecc48c8
...
...
@@ -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
(
"* "
))
{
...
...
Tools.Agent/wMyAgent.cs
View file @
2ecc48c8
...
...
@@ -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
()
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment