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
8d4b5d0b
Commit
8d4b5d0b
authored
Sep 17, 2026
by
段廷瑞
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
pref: Agent输出格式优化
parent
322b30f7
Changes
19
Hide whitespace changes
Inline
Side-by-side
Showing
19 changed files
with
2775 additions
and
30 deletions
+2775
-30
.gitignore
.gitignore
+1
-0
Tools.Agent.dll
AppHome/Tools.Agent.dll
+0
-0
Tools.Agent.xml
AppHome/Tools.Agent.xml
+556
-1
AgentEngine.cs
Tools.Agent/Agent/AgentEngine.cs
+206
-3
AnswerSpec.cs
Tools.Agent/Agent/Answer/AnswerSpec.cs
+151
-0
KbRecall.cs
Tools.Agent/Agent/Answer/KbRecall.cs
+635
-0
PromptBuilder.cs
Tools.Agent/Agent/PromptBuilder.cs
+46
-0
AgentConfig.cs
Tools.Agent/Config/AgentConfig.cs
+33
-0
answer_spec.md
Tools.Agent/Defaults/Agent/prompts/answer_spec.md
+26
-0
AgentSettingsForm.cs
Tools.Agent/Forms/AgentSettingsForm.cs
+109
-0
DocLineViewer.cs
Tools.Agent/Forms/DocLineViewer.cs
+192
-0
ThinkingSpinner.cs
Tools.Agent/Forms/ThinkingSpinner.cs
+146
-0
ContentHit.cs
Tools.Agent/Kb/ContentHit.cs
+27
-0
KbService.cs
Tools.Agent/Kb/KbService.cs
+213
-0
ChatMessage.cs
Tools.Agent/Support/ChatMessage.cs
+12
-0
ChatRtfCache.cs
Tools.Agent/Support/ChatRtfCache.cs
+6
-1
MarkdownRenderer.cs
Tools.Agent/Support/MarkdownRenderer.cs
+38
-1
Tools.Agent.csproj
Tools.Agent/Tools.Agent.csproj
+58
-12
wMyAgent.cs
Tools.Agent/wMyAgent.cs
+320
-12
No files found.
.gitignore
View file @
8d4b5d0b
...
...
@@ -73,3 +73,4 @@ bin
/cc
/AppHome/Logs
/AppHome/Package
/.workbuddy
AppHome/Tools.Agent.dll
View file @
8d4b5d0b
No preview for this file type
AppHome/Tools.Agent.xml
View file @
8d4b5d0b
...
...
@@ -69,6 +69,27 @@
<member
name=
"F:Tools.MyAgent.Agent.AgentEngine._contextTokens"
>
<summary>
上下文 token 预算(由配置注入;默认 64000)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.AgentEngine._recall"
>
<summary>
本地融合召回器(P2 产物):无网络、无 LLM;命中知识库时用于单轮直答。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.AgentEngine._quickAnswer"
>
<summary>
直答总开关(
<c>
AgentConfig.QuickAnswer
</c>
);关闭即完全回到改造前的多轮工具循环。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.AgentEngine._conciseAnswer"
>
<summary>
契约化输出开关(
<c>
AgentConfig.ConciseAnswer
</c>
);关闭则不下发契约层、也不做后置校验。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.AgentEngine._answerWithImages"
>
<summary>
回答附图开关(
<c>
AgentConfig.AnswerWithImages
</c>
):同时门控召回期采图与附图事件。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.AgentEngine._currentQuestion"
>
<summary>
本轮问题原文(回答类型判定用;RunAsync 开头取最后一条 user 消息,截断 500 字)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.AgentEngine._diagnosticToolHit"
>
<summary>
本轮是否调用过诊断类工具(P3 双契约判定的输入之一,随 RunAsync 重置)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.AgentEngine._answerKind"
>
<summary>
本轮回答类型(P3 双契约);在 FinishAnswerAsync 开头按当前证据重算。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.AgentEngine.MaxToolReturnChars"
>
<summary>
工具结果回填模型的字符上限(#42:超限压缩 + 完整结果落盘)。
原 4000 过小且“头+尾”压缩丢失中间内容,迫使模型对长文件分页补读、成倍消耗工具轮;
...
...
@@ -80,6 +101,18 @@
<member
name=
"F:Tools.MyAgent.Agent.AgentEngine._toolTrace"
>
<summary>
一轮回答的工具轨迹(按执行顺序);每轮回答前清空,完成后通过事件下发
</summary>
</member>
<member
name=
"T:Tools.MyAgent.Agent.AgentEngine.AnswerAttachments"
>
<summary>
回答形态事件载荷(P5):一轮「本地直答」的形态与关联图片。
<para>
与
<see
cref=
"T:Tools.MyAgent.Agent.AgentEngine.ToolTraceEntry"
/>
同制,作为引擎的公开契约类型内嵌声明,避免新增文件。
</para>
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.AgentEngine.AnswerAttachments.Mode"
>
<summary>
直答形态:
<c>
kb
</c>
(证据充分直答)/
<c>
mixed
</c>
(部分命中,含模型补充);空串表示未走直答。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.AgentEngine.AnswerAttachments.Images"
>
<summary>
关联图片绝对路径(已过沙箱校验;不采图时为空的列表,不会为 null)。
</summary>
</member>
<member
name=
"E:Tools.MyAgent.Agent.AgentEngine.OnToolTrace"
>
<summary>
工具轨迹事件(一轮回答结束时触发,含全部工具调用记录)
</summary>
</member>
...
...
@@ -95,6 +128,13 @@
<member
name=
"E:Tools.MyAgent.Agent.AgentEngine.OnFileProduced"
>
<summary>
智能体已输出/写入的文件(绝对路径),用于在回答末尾展示可点击链接
</summary>
</member>
<member
name=
"E:Tools.MyAgent.Agent.AgentEngine.OnAnswerAttachments"
>
<summary>
P5 衔接:本轮「本地直答」的形态与关联图片,用于宿主渲染「回答附图轨」并按形态打标。
<para>
仅在直答路径命中知识库时触发一次(工具循环路径不触发);无图时
<c>
Images
</c>
为空列表、
事件仍会触发(形态本身有展示价值)。
</para>
</summary>
</member>
<member
name=
"E:Tools.MyAgent.Agent.AgentEngine.OnComplete"
>
<summary>
一轮回答结束
</summary>
</member>
...
...
@@ -140,6 +180,13 @@
<member
name=
"P:Tools.MyAgent.Agent.AgentEngine.ProductContext"
>
<summary>
当前宿主绑定的产品上下文;返回副本,避免运行期间被外部修改。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Agent.AgentEngine.Dialect"
>
<summary>
内置方言/行业知识服务(只读)。
<para>
宿主用于解析引用行里的
<c>
dialect/
</c>
、
<c>
industry/
</c>
相对路径(P4 引用定位),
与引擎共用同一实例与缓存,避免各自重建。
</para>
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Agent.AgentEngine.ProductLineId"
>
<summary>
当前产品线稳定标识;未绑定时为空。
</summary>
</member>
...
...
@@ -197,6 +244,27 @@
运行一轮回答。history 需已包含当前用户问题(含此前对话)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Agent.AgentEngine.TryDirectAnswerAsync(Newtonsoft.Json.Linq.JArray,System.Text.StringBuilder,System.Threading.CancellationToken)"
>
<summary>
P3 单轮直答:本地融合召回(无网络、无 LLM)命中知识库时,把证据作为一条 user 消息注入,
再以「不下发工具」的方式做一次补全并收尾 —— 回答落点与收尾轮完全一致(同一
<see
cref=
"M:Tools.MyAgent.Agent.AgentEngine.FinishAnswerAsync(Newtonsoft.Json.Linq.JArray,Tools.MyAgent.Agent.ChatResult,System.Text.StringBuilder,System.Threading.CancellationToken)"
/>
)。
</summary>
<returns>
true = 已完成本轮作答(调用方跳过工具循环);false = 静默回退原工具循环。
</returns>
<remarks>
**降级纪律**:知识库不可用、关键词为空、未召回、任何异常,一律返回 false 走原路径。
新通道只在「确定性高出证据」时接管,绝不允许因为它把原有的检索/诊断能力弄坏。
</remarks>
</member>
<member
name=
"M:Tools.MyAgent.Agent.AgentEngine.LastUserQuestion(System.Collections.Generic.IReadOnlyList{Tools.MyAgent.ChatMessage})"
>
<summary>
取历史中最后一条 user 消息正文(截断 500 字,仅供回答类型判定等本地用途)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Agent.AgentEngine.RaiseAnswerAttachments(Tools.MyAgent.Agent.KbRecallResult)"
>
<summary>
P5:把本轮直答的形态与关联图片(主命中 + 次要命中,去重)交给宿主。
<para>
只要走了直答就一定触发(形态有展示价值);未开启附图时
<c>
Images
</c>
为空列表 ——
此时召回阶段本就没采图,不做目录扫描。
</para>
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Agent.AgentEngine.Cancel"
>
<summary>
取消当前回答
</summary>
</member>
...
...
@@ -446,6 +514,212 @@
非流式补全:一次性返回正文(用于会话主题总结等轻量调用)
</summary>
</member>
<member
name=
"T:Tools.MyAgent.Agent.KbHit"
>
<summary>
一条知识库召回命中(P2):带匹配度、行号、章节、正文与关联图片,可直接生成引用行。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbHit.RelPath"
>
<summary>
知识库内相对路径('/' 分隔;与 kb_* 工具输出一致,引用行可直接点击定位)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbHit.AbsPath"
>
<summary>
绝对路径(内部使用:读正文、取行号、找同目录图片)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbHit.Line"
>
<summary>
命中行号(1 基;0 = 无法定位,如 pdf/xlsx)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbHit.Section"
>
<summary>
命中行所属最近上级标题,如 "2.3 物料计算"(无法解析时为空)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbHit.Snippet"
>
<summary>
命中文段(单行,≤140 字)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbHit.Paragraph"
>
<summary>
命中所在整节正文(喂给模型的主体;截断 4000 字)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbHit.Score"
>
<summary>
融合匹配度(0~1):来源基分 + 0.25×查询词覆盖率 + 0.05×额外来源数(上限 0.95)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbHit.HitKeywords"
>
<summary>
命中的查询关键词(',' 分隔,供证据消息与调试展示)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbHit.BaseScore"
>
<summary>
来源基分(0~1):路由基分 / 语义分 / 模糊分按关键词融合后的最大值(融合打分内部使用)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbHit.Images"
>
<summary>
关联图片绝对路径(≤MaxImages,已过沙箱校验)
</summary>
</member>
<member
name=
"T:Tools.MyAgent.Agent.KbRecallResult"
>
<summary>
一次本地召回的结果(P2)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecallResult.Mode"
>
<summary>
kb(证据充足,直答)/ mixed(部分命中,允许模型补充)/ model(未召回,回退原工具循环)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecallResult.Primary"
>
<summary>
最佳命中(Mode != model 时非空)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecallResult.Others"
>
<summary>
其余命中(按匹配度降序)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecallResult.Reason"
>
<summary>
回答顶部标注文案(部分命中时非空)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecallResult.Query"
>
<summary>
原始问题
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecallResult.Keywords"
>
<summary>
本次召回使用的关键词(供日志/轨迹)
</summary>
</member>
<member
name=
"T:Tools.MyAgent.Agent.KbRecall"
>
<summary>
本地融合召回(P2 回答形态改造的核心):把「多轮工具检索」压缩为一次本地预检索。
<para>
职责:① 从自然语言问题抽关键词(索引是短语匹配,整句必然 0 命中);
② 逐关键词调用
<see
cref=
"M:Tools.MyAgent.Kb.KbService.RecallContent(System.String,System.Int32,System.Threading.CancellationToken,System.Boolean)"
/>
,按文件融合打分(来源基分 + 查询词覆盖率 + 多路共现);
③ 补齐 行号 / 章节 / 整节正文 / 关联图片;④ 按阈值判定 kb / mixed / model。
</para>
<para>
全部为本地动作(无 LLM 调用);任何异常由调用方兜底降级,不影响原工具循环能力。
</para>
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecall.DirectThreshold"
>
<summary>
直答阈值:融合分 ≥ 该值直接以证据作答(由 AgentConfig.KbHitThreshold 注入)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecall.MixedLowerBound"
>
<summary>
部分命中下界:低于该值视为未召回(固定 0.45,不随配置变化;供引擎做阈值钳制)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecall.ImagesEnabled"
>
<summary>
是否采集关联图片
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecall.MaxImages"
>
<summary>
关联图片上限
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecall.MaxHits"
>
<summary>
纳入证据链的命中上限(主命中 + 其余)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecall.MaxKeywordsIndexed"
>
<summary>
索引可服务时的关键词上限(每个词一次 SQLite 查询,成本低)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecall.MaxKeywordsScanning"
>
<summary>
索引不可服务时的关键词上限(每个词一次全盘扫描,必须严控)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecall.FullScanKeywordQuota"
>
<summary>
不做全盘扫描兜底的关键词序位(索引已服务时,仅最具体的前 N 个词允许回退全盘扫描)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecall.PerKeywordHits"
>
<summary>
每个关键词最多取回的文件数
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecall.MaxParagraphChars"
>
<summary>
喂给模型的单条证据正文字符上限
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecall.MaxDirProbe"
>
<summary>
关联图片:同目录扫描的文件数上限
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecall.ImageLineWindow"
>
<summary>
命中行前后用于提取图片的行窗口
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecall.AsciiStopWords"
>
<summary>
ASCII 关键词噪声词(问题里的英文虚词不具备检索价值)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecall.CjkStopBigrams"
>
<summary>
中文停用二元组(问题句的疑问/连接词,检索价值低,会稀释覆盖率)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Agent.KbRecall.TryRecall(System.String,System.Threading.CancellationToken)"
>
<summary>
本地召回(无网络、无 LLM):返回 kb / mixed / model 判定结果。
<para>
知识库不可用或问题无可检索关键词时返回 null(调用方回退原工具循环)。
</para>
</summary>
<param
name=
"question"
>
用户原始问题
</param>
<param
name=
"ct"
>
取消令牌:全盘扫描兜底路径据此支持 ESC 取消(长库扫描不阻塞取消)
</param>
</member>
<member
name=
"M:Tools.MyAgent.Agent.KbRecall.BuildEvidenceMessage(Tools.MyAgent.Agent.KbRecallResult)"
>
<summary>
命中 → 证据消息(作为一条 user 消息追加在问题之后,契约要求模型只据证据作答)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Agent.KbRecall.Slice(System.String[],System.Int32,System.Int32,System.Int32)"
>
<summary>
取 [startLine, endLine](1 基,含端点)文本,截断到 maxChars
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Agent.KbRecall.CollectImages(Tools.MyAgent.Agent.KbHit,System.String[],System.Int32)"
>
<summary>
采集关联图片(三条规则合并去重,最多 max 张,全部过沙箱校验):
① 命中段落内的 markdown 图片引用;② 命中行 ±20 行内的图片引用;
③ 同目录与 images/图片/assets/imgs 下与文档名或命中关键词有字面交集的图片。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Agent.KbRecall.AddImage(Tools.MyAgent.Agent.KbHit,System.String,System.String,System.String,System.Collections.Generic.HashSet{System.String},System.Int32)"
>
<summary>
命中段落里的图片链接 → 绝对路径(相对文档目录解析),校验通过后加入
</summary>
</member>
<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.SafeCombine(System.String)"
>
<summary>
相对路径 → 绝对路径(仅拼接,不抛异常;越界/非法返回 null)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Agent.KbRecall.ExtractKeywords(System.String)"
>
<summary>
从自然语言问题抽检索关键词(索引为短语匹配,整句必然 0 命中,必须先切词):
ASCII 标识(BomVersion、U.Rule、frmEditMaterialCalculation)优先 → 中文三元组 → 中文二元组,
过滤疑问/连接类停用词,按具体程度排序去重。
</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>
<member
name=
"T:Tools.MyAgent.Agent.AnswerKind"
>
<summary>
回答类型(P3 双契约):知识问答走三段式,现场排查走六段式。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.AnswerKind.Knowledge"
>
<summary>
知识问答:原因 / 方案 / 引用
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.AnswerKind.Diagnostic"
>
<summary>
现场排查:已确认事实 / 证据 / 根因 / 待验证 / 最小验证 / 修复与回滚
</summary>
</member>
<member
name=
"T:Tools.MyAgent.Agent.AnswerSpec"
>
<summary>
输出形态契约(P3):① 判定本轮问题属于知识问答还是现场排查;② 后置校验回答是否按契约输出。
<para>
为什么是「双契约」:
<c>
PromptBuilder.BuildDiagnosticLayer
</c>
已强制排查六段式并与产品上下文绑定,
若对所有问题都下发单一三段式,两套互斥要求会同时落到模型头上(详见方案 §0.2)。
</para>
<para>
本类只做判定与校验,不生成提示词正文 —— 契约正文在
<c>
prompts/answer_spec.md
</c>
(外置可定制)+ PromptBuilder 内置回退。
</para>
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.AnswerSpec.DiagnosticTools"
>
<summary>
诊断类工具(命中即判为排查类问题,与 BuildDiagnosticLayer 的规范一致)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.AnswerSpec.DiagnosticKeywords"
>
<summary>
排查类问题关键词(报错/异常/失败这一类「现场故障」用语)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.AnswerSpec.StructuralHint"
>
<summary>
指向具体模块/表/调用链的结构性线索:文件名(x.cs / x.sql / x.dll…)、类风格标识(U.Rule、frmXxx)、
数据对象用语(表/字段/外键/存储过程/视图)。仅在绑定产品上下文时参与判定。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Agent.AnswerSpec.IsDiagnosticTool(System.String)"
>
<summary>
该工具是否属于诊断类(引擎在执行工具时记录,供回答类型判定)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Agent.AnswerSpec.Classify(System.String,System.Boolean,System.Boolean)"
>
<summary>
判定本轮回答类型。判定顺序:命中诊断类工具 → 报错类关键词 → (绑定产品上下文时)模块/表/调用链线索。
<para>
拿不准时**偏向六段式**:信息更全,且与既有排查输出规范一致。
</para>
</summary>
<param
name=
"question"
>
用户本轮问题
</param>
<param
name=
"diagnosticToolHit"
>
本轮是否调用过诊断类工具
</param>
<param
name=
"productBound"
>
是否绑定了产品上下文(未绑定时不做结构性线索判定,避免误判)
</param>
</member>
<member
name=
"M:Tools.MyAgent.Agent.AnswerSpec.Validate(System.String,Tools.MyAgent.Agent.AnswerKind)"
>
<summary>
契约校验(后置兜底):返回应加在最终回答顶部的一行提示;合规返回 null。
<para>
知识问答:必须含「原因」「方案」两节(「引用」允许为空/省略 —— 未命中知识库时不得杜撰引用)。
排查类:六节中至少命中 4 节(真实输出常把「修复与回滚」并作一节)。
</para>
<para><b>
双向放行
</b>
:判定为知识问答、但回答实际按排查六段式输出(≥4 节)时视为合规。
分类靠关键词启发,必然有偏差;若不放行,用户会看到一条「未按三段式输出」的假警告,
而这恰恰是双契约并存时最不能被接受的噪声。
</para>
<para>
只做提示、不重试:重试会把成本翻倍,而回答本身仍有价值。
</para>
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Agent.AnswerSpec.CountHits(System.String,System.String[])"
>
<summary>
命中给定小节标题的个数
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Agent.AnswerSpec.HasLineSection(System.String,System.String)"
>
<summary>
小节标题是否出现在行首(容忍
<c>
###
</c>
/
<c>
**
</c>
/ 全角冒号等写法)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Agent.AnswerSpec.ContainsSection(System.String,System.String)"
>
<summary>
小节标题是否出现(不限定行首):仅用于排查六段式的宽松校验
</summary>
</member>
<member
name=
"T:Tools.MyAgent.Agent.PromptBuilder"
>
<summary>
系统提示词组装器。
...
...
@@ -468,6 +742,12 @@
<member
name=
"P:Tools.MyAgent.Agent.PromptBuilder.DiagnosticRoots"
>
<summary>
宿主注入的诊断根目录;只用于显示根目录 ID,不显示完整机器路径。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Agent.PromptBuilder.ConciseAnswerEnabled"
>
<summary>
P3 契约化输出开关(
<c>
AgentConfig.ConciseAnswer
</c>
);关闭时不注入输出形态契约层,
回答形态回落到改造前的自由格式(与
<c>
QuickAnswer
</c>
相互独立,可单独回退)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Agent.PromptBuilder.LoadLayer(System.String,System.String,System.String)"
>
<summary>
加载提示词模板:存在且非空 → 用文件内容;缺失 → 生成默认模板;异常 → 内置回退
</summary>
</member>
...
...
@@ -500,6 +780,13 @@
<member
name=
"P:Tools.MyAgent.Agent.PromptBuilder.ToolRules"
>
<summary>
§3.5 工具使用约束
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Agent.PromptBuilder.AnswerSpecLayer"
>
<summary>
§P3 输出形态契约层(内置回退;外置同内容模板见
<c>
Defaults/Agent/prompts/answer_spec.md
</c>
)。
<para>
与外置文件**逐字一致**:文件缺失时
<see
cref=
"M:Tools.MyAgent.Agent.PromptBuilder.LoadLayer(System.String,System.String,System.String)"
/>
会把本常量写盘成默认模板,
二者若不一致,用户「重置模板」后会得到一个与内置回退不同的契约,故必须同步维护。
</para>
</summary>
</member>
<member
name=
"T:Tools.MyAgent.Agent.ToolSchema"
>
<summary>
工具定义下发(OpenAI function-calling JSON Schema)。
...
...
@@ -773,6 +1060,37 @@
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.EnableImageInput"
>
<summary>
是否启用图片提问(多模态,v2.2):启用后粘贴截图随提问以 image_url 发送,需视觉模型支持(如 DeepSeek-VL / qwen-vl);默认启用
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.QuickAnswer"
>
<summary>
本地召回直答(回答形态改造 P3):本轮问题在知识库命中达阈值时,走「单轮直答」通道(不下发工具),
不再进入多轮工具循环;召回失败/异常一律静默降级回原工具循环。关闭即完全回到改造前行为。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.ConciseAnswer"
>
<summary>
契约化输出(由 prompts/answer_spec.md 约束 + 后置正则兜底):
知识问答类走三段式(原因/方案/引用),排查类走压缩六段式。关闭则不追加契约层、不做兜底标注。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.ShowThinking"
>
<summary>
是否仍在问答区渲染思考文本。默认 false:思考只由右下角旋转指示表达,不再铺满问答区(也避免流式每帧重绘);
置 true 恢复旧行为(调试/排查模型思维链时使用)。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.AnswerWithImages"
>
<summary>
回答附图(P5):本轮回答命中文档关联的图片以缩略图栏展示,点击放大(复用 wBitmapView)
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.MaxAnswerImages"
>
<summary>
回答附图上限(张,1~8)
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.KbHitThreshold"
>
<summary>
本地直答阈值(0.45~0.95):融合匹配度 ≥ 该值走单轮直答(mode=kb);
[0.45, 该值) 走「部分命中」(mode=mixed,证据 + 允许模型补充并标注);
低于 0.45 视为未召回,**回退原工具循环**(保留原有检索/诊断能力,不因新通道削弱能力)。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.MaxToolRounds"
>
<summary>
工具调用轮次上限(默认 15;超限后进入收尾回答轮,基于已收集信息汇总作答)
</summary>
</member>
...
...
@@ -931,6 +1249,12 @@
<member
name=
"M:Tools.MyAgent.Forms.AgentSettingsForm.ParseToolRoleRules(System.String)"
>
<summary>
编辑文本 → 角色规则字典(忽略空行/非法行)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Forms.AgentSettingsForm.BuildAnswerShapeTab"
>
<summary>
新增「回答形态」选项卡(程序化创建,避免侵入设计器):
思考呈现方式、本地召回直答、契约化输出、回答附图与直答阈值。
</summary>
</member>
<!-- Badly formed XML comment ignored for member "M:Tools.MyAgent.Forms.AgentSettingsForm.PopulateRoles" -->
<member
name=
"M:Tools.MyAgent.Forms.AgentSettingsForm.SetModelSuggestions(System.Windows.Forms.ComboBox,System.Int32,System.String)"
>
<summary>
更新模型建议;当前配置始终保留在可选项中,兼容新发布模型和旧配置。
</summary>
...
...
@@ -997,6 +1321,66 @@
使用代码编辑器修改此方法的内容。
</summary>
</member>
<member
name=
"T:Tools.MyAgent.Forms.DocLineViewer"
>
<summary>
文档行定位查看器(P4 引用行点击):只读展示文本类文件,并定位高亮到指定行。
<para>
纯代码构造(无 Designer / resx 依赖,与
<see
cref=
"T:Tools.MyAgent.wBitmapView"
/>
同制);非文本格式(docx/xlsx/pdf/pptx)
由调用方改走默认程序打开。
</para>
<para>
窗口为非模态(不阻塞继续提问),Esc 关闭;高亮用选区背景色实现,焦点移开仍可见。
</para>
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Forms.DocLineViewer.ViewableExtensions"
>
<summary>
可内置定位查看的文本类扩展名(其余交默认程序)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Forms.DocLineViewer.MaxShowLines"
>
<summary>
超长文件的展示行上限(避免超大日志把 RichTextBox 拖死)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Forms.DocLineViewer.HighlightColor"
>
<summary>
命中行高亮底色
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Forms.DocLineViewer.CanView(System.String)"
>
<summary>
是否支持内置定位查看(非文本格式返回 false,调用方应改走默认程序打开)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Forms.DocLineViewer.ShowLine(System.String,System.Int32,System.Windows.Forms.Form)"
>
<summary>
打开文件并定位到指定行(line ≤ 0 或超出范围时自动夹到首/末行)。
<para>
读取失败不抛异常:窗口内显示失败原因,便于用户判断是「引用错了」还是「文件不可读」。
</para>
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Forms.DocLineViewer.GotoLine(System.Int32)"
>
<summary>
定位并高亮第 line 行(1 基),同时刷新底部提示
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Forms.DocLineViewer.ReadAllTextSafe(System.String)"
>
<summary>
读取文本(复用知识库的编码自动识别,兼容 GBK/UTF-8/UTF-16;失败返回提示文本而非抛异常)
</summary>
</member>
<member
name=
"T:Tools.MyAgent.Forms.ThinkingSpinner"
>
<summary>
思考旋转指示器(纯代码构建,无 Designer / resx 依赖)。
<para>
24×24 的小控件,Timer(45ms) 驱动一段 240° 渐变弧旋转,用于替代「把思维链文本铺满问答区」的旧形态。
</para>
<para>
用法:
<see
cref=
"P:Tools.MyAgent.Forms.ThinkingSpinner.Thinking"
/>
=true 时显示并旋转;=false 时停止计时器并隐藏(不占布局、不耗帧)。
</para>
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Forms.ThinkingSpinner.BaseSize"
>
<summary>
逻辑尺寸(未按 DPI 缩放前)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Forms.ThinkingSpinner.TickMs"
>
<summary>
动画帧间隔(毫秒)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Forms.ThinkingSpinner.SweepDegrees"
>
<summary>
弧线张角
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Forms.ThinkingSpinner.Segments"
>
<summary>
拖尾分段数(越多越平滑)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Forms.ThinkingSpinner.AngleStep"
>
<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=
"T:Tools.MyAgent.wBitmapView"
>
<summary>
图片查看器:优先按图片原始尺寸展示(1:1 像素),但不超过屏幕工作区大小;
...
...
@@ -1016,6 +1400,30 @@
<member
name=
"M:Tools.MyAgent.wBitmapView.ShowImage(System.String,System.Windows.Forms.Form)"
>
<summary>
从文件加载图片并按原始尺寸弹出查看(超屏自动限制在工作区内;内部负责释放位图)
</summary>
</member>
<member
name=
"T:Tools.MyAgent.Kb.ContentHit"
>
<summary>
结构化内容命中(P2 回答形态改造):携带匹配度与行号,供引擎侧「本地召回直答」通道打分与引用定位使用。
<para>
与 kb_grep 的既有文本返回格式互不影响 —— 后者面向模型(格式即数据),本类型面向引擎(可计算、可定位)。
</para>
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Kb.ContentHit.Rel"
>
<summary>
知识库内相对路径('/' 分隔;与 kb_* 工具输出一致,便于引用行直接落地)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Kb.ContentHit.Snippet"
>
<summary>
命中文段(单行化,≤140 字)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Kb.ContentHit.Line"
>
<summary>
命中行号(1 基;0 = 无法定位,如 pdf/pptx 等无行概念的格式)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Kb.ContentHit.Base"
>
<summary>
该来源的证据基分(0~1):index 0.60 / scan 0.70 / synonym = index×0.85 / fuzzy 取模糊分原值。
<para>
路由基分留在本层(只有本层知道命中的真实来源);上层据此叠加查询词覆盖率与多路共现加分。
</para>
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Kb.ContentHit.Source"
>
<summary>
来源:index / scan / synonym / fuzzy(供共现加分与轨迹展示)
</summary>
</member>
<member
name=
"T:Tools.MyAgent.Kb.DialectService"
>
<summary>
方言服务(轻量化内化版):扫描程序目录 Agent/dialect/(产品排查方言)与 Agent/industry/(行业知识),
...
...
@@ -1533,6 +1941,46 @@
<member
name=
"M:Tools.MyAgent.Kb.KbService.FormatContentHits(System.String,System.Collections.Generic.List{System.ValueTuple{System.String,System.String}},System.Int32)"
>
<summary>
索引内容命中 → 统一格式(按搜索范围过滤 + 路径显示基准 + 上下文片段)
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Kb.KbService.SemanticEnabled"
>
<summary>
语义检索是否已启用(P2 召回通道据此决定是否尝试向量召回)
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Kb.KbService.IsIndexServing"
>
<summary>
检索索引当前是否可服务查询(已就绪且覆盖搜索范围)。
<para>
调用方据此决定回退策略:索引不可服务时每次查询都会退化为全盘扫描,必须限制关键词数量(见 KbRecall)。
</para>
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Kb.KbService.RecallContent(System.String,System.Int32,System.Threading.CancellationToken,System.Boolean)"
>
<summary>
结构化内容召回(P2):按「内容索引 → 全盘扫描 → 同义词扩展 → 文件名模糊」逐级兜底,
返回带行号与来源基分的命中。
<para>
匹配度(融合分)由上层
<c>
KbRecall
</c>
按「来源基分 + 查询词覆盖率 + 多路共现」折算 ——
只有上层才知道全部关键词与全部来源,单次调用无法算覆盖率。
</para>
<para>
注意:索引为短语匹配(整串须连续出现),传入的应是**关键词**而非自然语言整句。
</para>
</summary>
<param
name=
"keyword"
>
检索关键词(非整句;整句会导致短语匹配 0 命中)
</param>
<param
name=
"maxResults"
>
最多命中文件数(1~20)
</param>
<param
name=
"ct"
>
取消令牌(全盘扫描路径在枚举循环内协作式终止)
</param>
<param
name=
"allowFullScan"
>
是否允许在索引 0 命中时回退全盘扫描(默认 true)。
<para>
索引已服务时全盘扫描属冗余成本(索引本身覆盖可抽取文本),上层按关键词序位只对最具体的前 2 个词放行。
</para>
</param>
</member>
<member
name=
"M:Tools.MyAgent.Kb.KbService.RecallSemantic(System.String,System.Int32,System.Threading.CancellationToken)"
>
<summary>
语义召回(P2):查询向量化 → 索引向量余弦 Top-K,返回 (相对路径, 相似度)。
<para>
依赖 embedding 服务与索引向量;任一不可用返回空列表(不影响关键词路径)。
</para>
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Kb.KbService.LocateLine(System.String,System.String)"
>
<summary>
命中关键词所在行号(1 基;0 = 无法定位:非文本格式、文件不可读或未字面命中)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Kb.KbService.RelOf(System.String)"
>
<summary>
命中路径 → 引用用相对路径(超出搜索范围 / 不在知识库允许前缀内返回 null)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Kb.KbService.OneLine(System.String)"
>
<summary>
片段单行化 + 截断(供引用行与证据消息使用)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Kb.KbService.ReadOutline(System.String)"
>
<summary>
文档地图(#11):提取 docx/markdown 等文档的标题层级目录(行号|层级|标题),供 kb_read outline 模式与定位精读使用
</summary>
</member>
...
...
@@ -2028,6 +2476,18 @@
<member
name=
"P:Tools.MyAgent.ChatMessage.UsageSuffix"
>
<summary>
回复末尾用量标注(展示用,不参与模型上下文/历史导出):如「本回复 1284+312 tokens(约 ¥0.42)」
</summary>
</member>
<member
name=
"P:Tools.MyAgent.ChatMessage.AnswerImages"
>
<summary>
回答附图(P5):本轮回答命中的知识库关联图片(本地绝对路径,已过沙箱校验)。
<para>
仅助手消息使用;旧会话缺该字段 =
<c>
null
</c>
,渲染走默认分支(无需迁移代码)。
</para>
</summary>
</member>
<member
name=
"P:Tools.MyAgent.ChatMessage.AnswerMode"
>
<summary>
本轮回答形态(P5):
<c>
kb
</c>
(知识库直答)/
<c>
mixed
</c>
(部分命中,含模型补充)。
<para>
留空表示走原工具循环(形态未标注)。
</para>
</summary>
</member>
<member
name=
"T:Tools.MyAgent.ChatRtfCache"
>
<summary>
聊天区 RTF 增量渲染缓存:欢迎语与每条消息按内容哈希缓存渲染结果,
...
...
@@ -2136,12 +2596,26 @@
Markdown → RTF 渲染器(迁移自 Tools.MyCommon.MarkdownRenderer)
</summary>
</member>
<member
name=
"P:Tools.MyAgent.MarkdownRenderer.ShowThinking"
>
<summary>
是否仍在问答区渲染思考文本(由宿主按配置注入,默认 false)。
<para>
默认形态:思考只由右下旋转指示表达,不再把思维链整块铺进问答区(也避免流式每帧重绘)。
置 true 恢复旧行为,便于调试/排查模型思维链。
</para>
</summary>
</member>
<member
name=
"M:Tools.MyAgent.MarkdownRenderer.RenderWelcome(System.String)"
>
<summary>
欢迎语块(无消息时的占位正文)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.MarkdownRenderer.RenderMessageBlock(Tools.MyAgent.ChatMessage,System.String,System.String,System.Boolean)"
>
<summary>
单条消息的 RTF 块(自包含 \pard…\par),供增量渲染缓存复用;expandDetails 控制
<
details
>
块展开/折叠
</summary>
</member>
<member
name=
"M:Tools.MyAgent.MarkdownRenderer.AnswerModeBadge(Tools.MyAgent.ChatMessage)"
>
<summary>
P5 回答形态标注文案(展示用,不进入模型上下文/历史导出):
<c>
kb
</c>
→ 知识库直答;
<c>
mixed
</c>
→ 知识库部分命中(含模型补充);
未标注(走原工具循环)或未知取值返回空串。附带关联图片数,便于对上下方附图轨属于哪一轮。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.MarkdownRenderer.RenderDetailsBlock(System.Text.StringBuilder,System.String,System.Collections.Generic.List{System.String},System.Boolean)"
>
<summary>
<
details
>
折叠块的 RTF 渲染:折叠态只显示摘要行(▶ + 点击展开提示),
...
...
@@ -2243,12 +2717,21 @@
<member
name=
"F:Tools.MyAgent.wMyAgent._rtfFallback"
>
<summary>
聊天区控件:RichTextBox(RichBox,RTF 渲染)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.wMyAgent._spinner"
>
<summary>
思考中旋转指示器(右下角悬浮;替代「思考文本铺满问答区」)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.wMyAgent._pendingImages"
>
<summary>
v2.3 待发送附图列表:粘贴截图后暂存,随下一轮提问发送(启用图片提问时;最多 5 张)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.wMyAgent._pendingBar"
>
<summary>
v2.3 待发送附图缩略图栏(问答区左下角悬浮;每张图右上角叉号可取消)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.wMyAgent._answerImages"
>
<summary>
P5 回答附图列表:本轮直答命中的知识库关联图片;仅在直答命中知识库时非空
</summary>
</member>
<member
name=
"F:Tools.MyAgent.wMyAgent._answerImageBar"
>
<summary>
P5 回答附图轨(问答区左下角悬浮,纵向叠在待发送附图栏之上;只读展示,无删除按钮)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.wMyAgent._replyPromptBase"
>
<summary>
本轮回复用量基线快照(发送前记录引擎累计值;回复完成后按增量展示「本回复」用量)
</summary>
</member>
...
...
@@ -2282,6 +2765,21 @@
<member
name=
"F:Tools.MyAgent.wMyAgent.HttpUrlRegex"
>
<summary>
匹配 http/https 链接
</summary>
</member>
<member
name=
"F:Tools.MyAgent.wMyAgent.AnswerRefRegex"
>
<summary>
匹配回答「引用」节的引用行(P4):📎
<
知识库相对路径
>
§
<
章节
>
L
<
行号
>
匹配度
<
0.xx
>
。
<para>
章节 / 行号 / 匹配度三段均可缺省(正则容错);路径用惰性匹配 + 扩展名锚点,允许路径含空格(中文知识库常见)。
</para>
</summary>
</member>
<member
name=
"T:Tools.MyAgent.wMyAgent.AnswerRef"
>
<summary>
回答引用行(P4):渲染文本中的位置 + 解析出的 相对路径 / 章节 / 行号,供点击定位
</summary>
</member>
<member
name=
"F:Tools.MyAgent.wMyAgent._answerRefs"
>
<summary>
当前渲染文本中的引用行表。每次
<see
cref=
"M:Tools.MyAgent.wMyAgent.RefreshChatRtf"
/>
后清空重建(字符位置随重绘失效);
流式期间不扫描(完成时算一次),避免每帧全文正则扫描的 O(n) 白耗。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.#ctor(System.String,System.String)"
>
<summary>
兼容旧调用:创建未绑定产品上下文的聊天窗体。
</summary>
</member>
...
...
@@ -2296,6 +2794,21 @@
创建聊天区控件:统一使用 RichBox(RichTextBox + RTF 渲染,轻量、启动快、无浏览器依赖)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.EnsureThinkingSpinner"
>
<summary>
思考旋转指示器(右下角悬浮):思考中显示旋转弧,正文一到即隐藏。
<para>
替代旧形态「把思维链文本整块渲染进问答区」;不再渲染思考文本的开关见 AgentConfig.ShowThinking。
</para>
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.PositionThinkingSpinner"
>
<summary>
把旋转指示器贴到问答区右下角(距边 12px;Dock=Fill 的 RichTextBox 之上)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.SetThinking(System.Boolean)"
>
<summary>
思考指示三态机驱动:Idle → Thinking(收到思考且尚无正文)→ Streaming(首帧正文到达即隐藏)→ Idle。
<para>
失败/取消路径由 OnAssistantComplete / OnEngineError 兜底置空闲,避免指示器残留。
</para>
</summary>
</member>
<member
name=
"P:Tools.MyAgent.wMyAgent.SupportedModels"
>
<summary>
仅支持的模型(定义统一在设置窗体 AgentSettingsForm)
</summary>
</member>
...
...
@@ -2327,6 +2840,22 @@
<member
name=
"M:Tools.MyAgent.wMyAgent.RefreshChatRtf"
>
<summary>
RichBox 模式:整段 RTF 重建(增量渲染缓存)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.RebuildAnswerRefs"
>
<summary>
重建引用行表(P4):从渲染文本中扫出 📎 引用行并解析 路径/章节/行号。
<para>
时序纪律:① 每次重绘后重建,并与悬停缓存一起复位;② 流式期间不扫描(每帧全文正则是 O(n) 白耗),
待回答完成(IsStreaming=false)的那次重绘算一次。
</para>
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.TryGetAnswerRefAt(System.Int32,Tools.MyAgent.wMyAgent.AnswerRef@)"
>
<summary>
命中:鼠标位置是否落在某条引用行内(P4)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.OpenAnswerRef(Tools.MyAgent.wMyAgent.AnswerRef)"
>
<summary>
打开引用行(P4):知识库相对路径 → 内置行定位查看器(文本类)或默认程序(docx/xlsx/pdf 等);
「dialect/」「industry/」开头的引用走内置方言根目录解析;均已过沙箱校验,越界直接提示。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.IsDetailsExpanded(System.Int32)"
>
<summary>
指定消息的工具轨迹(
<
details
>
)是否展开
</summary>
</member>
...
...
@@ -2336,6 +2865,12 @@
<member
name=
"M:Tools.MyAgent.wMyAgent.FormatFileCount(System.Int32)"
>
<summary>
文件数显示:未知(后台统计中)时用占位文案
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.OnAnswerAttachments(Tools.MyAgent.Agent.AgentEngine.AnswerAttachments)"
>
<summary>
P5:本地直答的形态与关联图片到达(早于
<see
cref=
"M:Tools.MyAgent.wMyAgent.OnAssistantComplete"
/>
,因引擎在直答路径内先发本事件再收尾)。
<para>
形态记在助手消息上(会话可持久化、渲染成顶部标注),图片同时进入附图轨。
</para>
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.BuildReplyUsageSuffix"
>
<summary>
构造本轮回复用量标注(相对发送前快照的增量;本轮无调用记录返回空串)
</summary>
</member>
...
...
@@ -2453,8 +2988,28 @@
<member
name=
"M:Tools.MyAgent.wMyAgent.RefreshPendingBar"
>
<summary>
刷新缩略图栏:按待发送列表重建缩略图块;空则隐藏
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.EnsureAnswerImageBar"
>
<summary>
创建回答附图轨(左下角悬浮;懒创建)。只读展示,故不加删除按钮。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.RefreshAnswerImageBar"
>
<summary>
刷新回答附图轨:按本轮回答附图重建缩略图块;空则隐藏
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.ClearAnswerImages"
>
<summary>
清空回答附图轨(新对话 / 切换会话 / 发出新一轮问题时调用)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.LayoutBottomBars"
>
<summary>
底部两条悬浮栏纵向叠加:待发送附图栏贴底(左下角),回答附图轨叠在其**上方**。
<para>
两者都用 Anchor=Bottom|Left + 手工 Location:若让新栏 Dock=Bottom,会与贴底栏落在同一像素带。
待发送栏出现/消失时必须整体重排,否则回答附图轨会被压在下面看不见。
</para>
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.CreateThumb(System.String)"
>
<summary>
单个缩略图块:图片预览(Zoom)+ 右上角 ✕ 取消;点击缩略图按原始尺寸放大查看
</summary>
<summary>
单个缩略图块:图片预览(Zoom)+ 右上角 ✕ 移除;点击缩略图按原始尺寸放大查看
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.CreateThumb(System.String,System.Action)"
>
<summary>
单个缩略图块:图片预览(Zoom)+ 可选右上角 ✕ 移除按钮;点击缩略图按原始尺寸放大查看。
</summary>
<param
name=
"onRemove"
>
✕ 的回调;传
<c>
null
</c>
表示只读展示(如回答附图轨),此时不创建 ✕ 按钮
</param>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.DisposeThumbImages(System.Windows.Forms.Control)"
>
<summary>
递归释放缩略图 PictureBox 的图片资源(防 GDI 对象泄漏)
</summary>
...
...
Tools.Agent/Agent/AgentEngine.cs
View file @
8d4b5d0b
...
...
@@ -33,6 +33,29 @@ namespace Tools.MyAgent.Agent
/// <summary>上下文 token 预算(由配置注入;默认 64000)</summary>
private
int
_contextTokens
=
AgentConfig
.
DefaultMaxContextTokens
;
// ── P3 单轮直答(QuickAnswer) ──
/// <summary>本地融合召回器(P2 产物):无网络、无 LLM;命中知识库时用于单轮直答。</summary>
private
readonly
KbRecall
_recall
;
/// <summary>直答总开关(<c>AgentConfig.QuickAnswer</c>);关闭即完全回到改造前的多轮工具循环。</summary>
private
bool
_quickAnswer
=
true
;
/// <summary>契约化输出开关(<c>AgentConfig.ConciseAnswer</c>);关闭则不下发契约层、也不做后置校验。</summary>
private
bool
_conciseAnswer
=
true
;
/// <summary>回答附图开关(<c>AgentConfig.AnswerWithImages</c>):同时门控召回期采图与附图事件。</summary>
private
bool
_answerWithImages
=
true
;
/// <summary>本轮问题原文(回答类型判定用;RunAsync 开头取最后一条 user 消息,截断 500 字)。</summary>
private
string
_currentQuestion
=
""
;
/// <summary>本轮是否调用过诊断类工具(P3 双契约判定的输入之一,随 RunAsync 重置)。</summary>
private
bool
_diagnosticToolHit
;
/// <summary>本轮回答类型(P3 双契约);在 FinishAnswerAsync 开头按当前证据重算。</summary>
private
AnswerKind
_answerKind
=
AnswerKind
.
Knowledge
;
// #31 故障转移
private
FailoverPolicy
_failover
;
private
IOpenAiChatClient
_backupClient
;
...
...
@@ -55,6 +78,21 @@ namespace Tools.MyAgent.Agent
/// <summary>一轮回答的工具轨迹(按执行顺序);每轮回答前清空,完成后通过事件下发</summary>
private
readonly
List
<
ToolTraceEntry
>
_toolTrace
=
new
List
<
ToolTraceEntry
>();
/// <summary>
/// 回答形态事件载荷(P5):一轮「本地直答」的形态与关联图片。
/// <para>与 <see cref="ToolTraceEntry"/> 同制,作为引擎的公开契约类型内嵌声明,避免新增文件。</para>
/// </summary>
public
sealed
class
AnswerAttachments
{
/// <summary>直答形态:<c>kb</c>(证据充分直答)/ <c>mixed</c>(部分命中,含模型补充);空串表示未走直答。</summary>
public
string
Mode
=
""
;
/// <summary>关联图片绝对路径(已过沙箱校验;不采图时为空的列表,不会为 null)。</summary>
public
List
<
string
>
Images
=
new
List
<
string
>();
public
override
string
ToString
()
=>
$"
{
Mode
}
(
{
Images
.
Count
}
图)"
;
}
/// <summary>工具轨迹事件(一轮回答结束时触发,含全部工具调用记录)</summary>
public
event
Action
<
IReadOnlyList
<
ToolTraceEntry
>>
OnToolTrace
;
...
...
@@ -66,6 +104,13 @@ namespace Tools.MyAgent.Agent
public
event
Action
<
string
>
OnToolStatus
;
/// <summary>智能体已输出/写入的文件(绝对路径),用于在回答末尾展示可点击链接</summary>
public
event
Action
<
string
>
OnFileProduced
;
/// <summary>
/// P5 衔接:本轮「本地直答」的形态与关联图片,用于宿主渲染「回答附图轨」并按形态打标。
/// <para>仅在直答路径命中知识库时触发一次(工具循环路径不触发);无图时 <c>Images</c> 为空列表、
/// 事件仍会触发(形态本身有展示价值)。</para>
/// </summary>
public
event
Action
<
AnswerAttachments
>
OnAnswerAttachments
;
/// <summary>一轮回答结束</summary>
public
event
Action
OnComplete
;
/// <summary>错误/取消</summary>
...
...
@@ -118,6 +163,16 @@ namespace Tools.MyAgent.Agent
/// <summary>当前宿主绑定的产品上下文;返回副本,避免运行期间被外部修改。</summary>
public
AgentProductContext
ProductContext
=>
_productContext
?.
Clone
();
/// <summary>
/// 内置方言/行业知识服务(只读)。
/// <para>宿主用于解析引用行里的 <c>dialect/</c>、<c>industry/</c> 相对路径(P4 引用定位),
/// 与引擎共用同一实例与缓存,避免各自重建。</para>
/// </summary>
public
DialectService
Dialect
{
get
{
return
_dialect
;
}
}
/// <summary>当前产品线稳定标识;未绑定时为空。</summary>
public
string
ProductLineId
=>
_productContext
?.
ProductLineId
??
""
;
...
...
@@ -182,6 +237,7 @@ namespace Tools.MyAgent.Agent
?
Path
.
Combine
(
AppDomain
.
CurrentDomain
.
BaseDirectory
,
"Agent"
)
:
Path
.
GetFullPath
(
agentRoot
);
_dialect
=
new
DialectService
(
root
);
_recall
=
new
KbRecall
(
_kb
);
// P3:本地融合召回器(构造无副作用,仅持有 KbService 引用)
_prompts
.
DiagnosticRoots
=
DiagnosticRoots
;
SetProductContext
(
productContext
);
}
...
...
@@ -228,6 +284,21 @@ namespace Tools.MyAgent.Agent
_prompts
.
CustomLayer
=
cfg
.
CustomSystemPrompt
??
""
;
_prompts
.
SystemPromptEnabled
=
cfg
.
SystemPromptEnabled
;
_imageInputEnabled
=
cfg
.
EnableImageInput
;
// v2.2 多模态:图片提问开关
// P3 回答形态(QuickAnswer / ConciseAnswer / 附图 / 直答阈值)
_quickAnswer
=
cfg
.
QuickAnswer
;
_conciseAnswer
=
cfg
.
ConciseAnswer
;
_answerWithImages
=
cfg
.
AnswerWithImages
;
_prompts
.
ConciseAnswerEnabled
=
cfg
.
ConciseAnswer
;
// 提示词契约层与后置校验同开关,避免「不注入却校验」
// 阈值钳制:必须严格大于 mixed 下界 0.45(否则「部分命中」区间为空、形同虚设),上限 0.99;
// 越界/非法(含手改配置文件写 0 或 >1)回退默认 0.72,与 AgentConfig 初值一致
_recall
.
DirectThreshold
=
cfg
.
KbHitThreshold
>
KbRecall
.
MixedLowerBound
&&
cfg
.
KbHitThreshold
<=
0.99
?
cfg
.
KbHitThreshold
:
0.72
;
_recall
.
ImagesEnabled
=
cfg
.
AnswerWithImages
;
// 关掉附图即不采图(省掉目录扫描)
_recall
.
MaxImages
=
cfg
.
MaxAnswerImages
>
0
?
Math
.
Min
(
cfg
.
MaxAnswerImages
,
12
)
:
4
;
CostEstimator
.
Configure
(
cfg
.
CustomPrices
);
// R6 费用估算:应用自定义价格表覆盖
// P0 连接串脱敏 / P3 工具权限:预存连接与角色规则随配置注入
DbConnections
=
cfg
.
DbConnections
??
new
Dictionary
<
string
,
string
>(
StringComparer
.
OrdinalIgnoreCase
);
...
...
@@ -276,14 +347,24 @@ namespace Tools.MyAgent.Agent
_contextNotice
=
""
;
_runGeneration
++;
_diagnosticSessionId
=
Guid
.
NewGuid
().
ToString
(
"N"
);
_diagnosticToolHit
=
false
;
_answerKind
=
AnswerKind
.
Knowledge
;
_currentQuestion
=
LastUserQuestion
(
history
);
var
messages
=
await
BuildMessagesAsync
(
history
,
_cts
.
Token
).
ConfigureAwait
(
false
);
var
tools
=
BuildTools
();
var
stopwatch
=
Stopwatch
.
StartNew
();
int
toolRounds
=
0
;
var
finalAnswer
=
new
StringBuilder
();
// 跨轮累计的回答正文(UI 事件为全量替换语义)
// P3 单轮直答(QuickAnswer):本地召回命中知识库时,注入证据做一次「无工具」补全即收尾,
// 跳过工具循环;未命中/异常/开关关闭 ⇒ 返回 false,行为与改造前完全一致(降级纪律)。
bool
directAnswered
=
false
;
if
(
_quickAnswer
)
directAnswered
=
await
TryDirectAnswerAsync
(
messages
,
finalAnswer
,
_cts
.
Token
).
ConfigureAwait
(
false
);
// 工具调用循环:最多 _maxToolRounds 轮;超限/超时时做「收尾回答轮」而非直接放弃(修复「第一个问题就触发 15 轮上限、颗粒无收」)
while
(
true
)
// (directAnswered=true 时整体跳过;循环体内不修改该变量,条件恒为真,等价于改造前的 while(true))
while
(!
directAnswered
)
{
_cts
.
Token
.
ThrowIfCancellationRequested
();
...
...
@@ -382,6 +463,108 @@ namespace Tools.MyAgent.Agent
}
}
/// <summary>
/// P3 单轮直答:本地融合召回(无网络、无 LLM)命中知识库时,把证据作为一条 user 消息注入,
/// 再以「不下发工具」的方式做一次补全并收尾 —— 回答落点与收尾轮完全一致(同一 <see cref="FinishAnswerAsync"/>)。
/// </summary>
/// <returns>true = 已完成本轮作答(调用方跳过工具循环);false = 静默回退原工具循环。</returns>
/// <remarks>
/// **降级纪律**:知识库不可用、关键词为空、未召回、任何异常,一律返回 false 走原路径。
/// 新通道只在「确定性高出证据」时接管,绝不允许因为它把原有的检索/诊断能力弄坏。
/// </remarks>
private
async
Task
<
bool
>
TryDirectAnswerAsync
(
JArray
messages
,
StringBuilder
finalAnswer
,
CancellationToken
ct
)
{
if
(
_recall
==
null
||
string
.
IsNullOrWhiteSpace
(
_currentQuestion
))
return
false
;
KbRecallResult
recall
;
try
{
recall
=
_recall
.
TryRecall
(
_currentQuestion
,
ct
);
}
catch
(
OperationCanceledException
)
{
throw
;
}
catch
(
Exception
ex
)
{
AgentLogger
.
Log
(
"本地召回异常,回退工具循环:"
+
ex
.
Message
);
return
false
;
}
if
(
recall
==
null
||
recall
.
Primary
==
null
||
recall
.
Mode
==
"model"
)
return
false
;
// 证据注入按「先记账、失败可回滚」处理:直答失败要还原 messages,
// 否则回退工具循环时会带着一条模型不该看到的证据消息,行为与原路径不一致。
int
mark
=
messages
.
Count
;
try
{
string
evidence
=
_recall
.
BuildEvidenceMessage
(
recall
);
if
(
string
.
IsNullOrEmpty
(
evidence
))
return
false
;
messages
.
Add
(
new
JObject
{
[
"role"
]
=
"user"
,
[
"content"
]
=
evidence
});
// 部分命中时先明确告知「含模型补充内容」,避免用户把补充内容当成知识库原文
if
(!
string
.
IsNullOrEmpty
(
recall
.
Reason
))
OnTextDelta
?.
Invoke
(
"\n\n"
+
recall
.
Reason
);
AgentLogger
.
Log
(
$"本地召回命中:mode=
{
recall
.
Mode
}
score=
{
recall
.
Primary
.
Score
:
0.00
}
file=
{
recall
.
Primary
.
RelPath
}
kw=
{
string
.
Join
(
"|"
,
recall
.
Keywords
)}
"
);
var
result
=
await
SendAndLogAsync
(
messages
,
null
,
ct
).
ConfigureAwait
(
false
);
if
(
result
.
ReasoningContent
.
Length
>
0
)
OnThinkingDelta
?.
Invoke
(
result
.
ReasoningContent
);
await
FinishAnswerAsync
(
messages
,
result
,
finalAnswer
,
ct
).
ConfigureAwait
(
false
);
RaiseAnswerAttachments
(
recall
);
// P5:把命中图片交给宿主渲染附图轨(无图则不发)
return
true
;
}
catch
(
OperationCanceledException
)
{
throw
;
}
catch
(
Exception
ex
)
{
while
(
messages
.
Count
>
mark
)
messages
.
RemoveAt
(
messages
.
Count
-
1
);
// 回滚证据消息
AgentLogger
.
Log
(
"单轮直答失败,回退工具循环:"
+
ex
.
Message
);
return
false
;
}
}
/// <summary>取历史中最后一条 user 消息正文(截断 500 字,仅供回答类型判定等本地用途)</summary>
private
static
string
LastUserQuestion
(
IReadOnlyList
<
ChatMessage
>
history
)
{
if
(
history
==
null
)
return
""
;
for
(
int
i
=
history
.
Count
-
1
;
i
>=
0
;
i
--)
{
var
m
=
history
[
i
];
if
(
m
==
null
||
!
string
.
Equals
(
m
.
Role
,
"user"
,
StringComparison
.
OrdinalIgnoreCase
))
continue
;
string
s
=
(
m
.
Content
??
""
).
Trim
();
if
(
s
.
Length
==
0
)
continue
;
return
Clip
(
s
,
500
);
}
return
""
;
}
/// <summary>
/// P5:把本轮直答的形态与关联图片(主命中 + 次要命中,去重)交给宿主。
/// <para>只要走了直答就一定触发(形态有展示价值);未开启附图时 <c>Images</c> 为空列表 ——
/// 此时召回阶段本就没采图,不做目录扫描。</para>
/// </summary>
private
void
RaiseAnswerAttachments
(
KbRecallResult
recall
)
{
if
(
recall
==
null
)
return
;
var
payload
=
new
AnswerAttachments
{
Mode
=
recall
.
Mode
??
""
};
if
(
_answerWithImages
)
{
try
{
var
seen
=
new
HashSet
<
string
>(
StringComparer
.
OrdinalIgnoreCase
);
if
(
recall
.
Primary
!=
null
&&
recall
.
Primary
.
Images
!=
null
)
foreach
(
var
p
in
recall
.
Primary
.
Images
)
if
(!
string
.
IsNullOrEmpty
(
p
)
&&
seen
.
Add
(
p
))
payload
.
Images
.
Add
(
p
);
if
(
recall
.
Others
!=
null
)
foreach
(
var
h
in
recall
.
Others
)
{
if
(
h
?.
Images
==
null
)
continue
;
foreach
(
var
p
in
h
.
Images
)
if
(!
string
.
IsNullOrEmpty
(
p
)
&&
seen
.
Add
(
p
))
payload
.
Images
.
Add
(
p
);
}
}
catch
{
/* 采图异常不影响回答与形态标注 */
}
}
try
{
OnAnswerAttachments
?.
Invoke
(
payload
);
}
catch
(
Exception
ex
)
{
AgentLogger
.
Log
(
"回答形态事件处理异常:"
+
ex
.
Message
);
}
}
/// <summary>取消当前回答</summary>
public
void
Cancel
()
{
...
...
@@ -447,6 +630,10 @@ namespace Tools.MyAgent.Agent
/// </summary>
private
async
Task
FinishAnswerAsync
(
JArray
messages
,
ChatResult
first
,
StringBuilder
finalAnswer
,
CancellationToken
ct
)
{
// P3 双契约:收尾时按「是否调用过诊断类工具 / 问题是否含报错线索 / 是否绑定产品上下文」定类型。
// 放在这里而非调用点,是因为诊断工具命中是随工具轮累积的,只有收尾时才是最终值。
_answerKind
=
AnswerSpec
.
Classify
(
_currentQuestion
,
_diagnosticToolHit
,
_productContext
!=
null
);
var
result
=
first
;
while
(
true
)
{
...
...
@@ -472,8 +659,18 @@ namespace Tools.MyAgent.Agent
if
(
finalAnswer
.
Length
==
0
)
finalAnswer
.
Append
(
"(模型未返回内容)"
);
else
if
(
_contextNotice
.
Length
>
0
)
finalAnswer
.
Insert
(
0
,
_contextNotice
+
"\n\n"
);
// 上下文提示保留在最终回答开头(此前被正文覆盖)
else
{
// P3 后置兜底(只提示、不重试 —— 重试会让成本翻倍,而回答本身仍有价值):
// 仅在契约层已下发时校验;缺节则顶部加一行提示,明确「已原样保留」,不静默吞掉不合规输出。
if
(
_conciseAnswer
)
{
string
warn
=
AnswerSpec
.
Validate
(
finalAnswer
.
ToString
(),
_answerKind
);
if
(!
string
.
IsNullOrEmpty
(
warn
))
finalAnswer
.
Insert
(
0
,
warn
+
"\n\n"
);
}
if
(
_contextNotice
.
Length
>
0
)
finalAnswer
.
Insert
(
0
,
_contextNotice
+
"\n\n"
);
// 上下文提示保留在最终回答开头(此前被正文覆盖)
}
if
(
IsTruncatedByLength
(
result
))
{
// 达到续写上限仍被截断:明确提示用户,不再静默展示半截回答
...
...
@@ -963,6 +1160,12 @@ namespace Tools.MyAgent.Agent
if
(
tool
==
null
)
return
$"错误:未知工具
{
tc
.
Name
}
"
;
// P3 双契约判定输入:本轮只要调用过诊断类工具,回答即按「现场排查六段式」输出。
// 代际校验:超时残留的后台工具不得污染新轮的判定。
if
(
AnswerSpec
.
IsDiagnosticTool
(
tc
.
Name
)
&&
gen
==
System
.
Threading
.
Volatile
.
Read
(
ref
_runGeneration
))
_diagnosticToolHit
=
true
;
// P3 工具级权限(纵深防御):执行侧再校验一次,防止 Schema 过滤被绕过
if
(!
ToolPermission
.
IsAllowed
(
tc
.
Name
,
CurrentUser
,
_toolRoleAllow
))
return
$"错误:当前用户没有使用
{
tc
.
Name
}
的权限(需在设置中为该工具配置角色白名单)。"
;
...
...
Tools.Agent/Agent/Answer/AnswerSpec.cs
0 → 100644
View file @
8d4b5d0b
using
System
;
using
System.Collections.Generic
;
using
System.Text.RegularExpressions
;
namespace
Tools.MyAgent.Agent
{
/// <summary>回答类型(P3 双契约):知识问答走三段式,现场排查走六段式。</summary>
public
enum
AnswerKind
{
/// <summary>知识问答:原因 / 方案 / 引用</summary>
Knowledge
=
0
,
/// <summary>现场排查:已确认事实 / 证据 / 根因 / 待验证 / 最小验证 / 修复与回滚</summary>
Diagnostic
=
1
,
}
/// <summary>
/// 输出形态契约(P3):① 判定本轮问题属于知识问答还是现场排查;② 后置校验回答是否按契约输出。
/// <para>为什么是「双契约」:<c>PromptBuilder.BuildDiagnosticLayer</c> 已强制排查六段式并与产品上下文绑定,
/// 若对所有问题都下发单一三段式,两套互斥要求会同时落到模型头上(详见方案 §0.2)。</para>
/// <para>本类只做判定与校验,不生成提示词正文 —— 契约正文在 <c>prompts/answer_spec.md</c>(外置可定制)+ PromptBuilder 内置回退。</para>
/// </summary>
public
static
class
AnswerSpec
{
/// <summary>诊断类工具(命中即判为排查类问题,与 BuildDiagnosticLayer 的规范一致)</summary>
private
static
readonly
HashSet
<
string
>
DiagnosticTools
=
new
HashSet
<
string
>(
StringComparer
.
OrdinalIgnoreCase
)
{
"log_discover"
,
"log_search"
,
"config_read"
,
"assembly_manifest"
,
"diagnose_incident"
,
};
/// <summary>排查类问题关键词(报错/异常/失败这一类「现场故障」用语)</summary>
private
static
readonly
string
[]
DiagnosticKeywords
=
{
"报错"
,
"报障"
,
"异常"
,
"失败"
,
"闪退"
,
"崩溃"
,
"无法启动"
,
"不能启动"
,
"启动不了"
,
"打不开"
,
"点不动"
,
"超时"
,
"错误码"
,
"卡死"
,
"死机"
,
"没反应"
,
"无响应"
,
"不生效"
,
"不走了"
,
"乱码"
,
"堆栈"
,
"调用链"
,
"缺少"
,
"取不到"
,
"读不到"
,
"写不进"
,
"进不去"
,
"看不到"
,
"显示不全"
,
"数据不对"
,
"对不上"
,
"不一致"
,
"error"
,
"exception"
,
"failed"
,
"timeout"
,
"crash"
,
"traceback"
,
"nullreference"
,
};
/// <summary>
/// 指向具体模块/表/调用链的结构性线索:文件名(x.cs / x.sql / x.dll…)、类风格标识(U.Rule、frmXxx)、
/// 数据对象用语(表/字段/外键/存储过程/视图)。仅在绑定产品上下文时参与判定。
/// </summary>
private
static
readonly
Regex
StructuralHint
=
new
Regex
(
@"[\w\-]+\.(?:cs|sql|dll|xml|config|log|json|exe|csproj)\b"
+
@"|\b(?:frm|uc|w)[A-Z]\w+\b"
+
@"|\b[A-Z]\w+\.[A-Z]\w+\b"
+
@"|表|字段|外键|主键|存储过程|调用链|堆栈"
,
RegexOptions
.
Compiled
);
private
static
readonly
string
[]
DiagnosticSections
=
{
"已确认事实"
,
"证据"
,
"根因"
,
"待验证"
,
"最小验证"
,
"修复"
,
};
private
static
readonly
string
[]
KnowledgeSections
=
{
"原因"
,
"方案"
,
"引用"
};
/// <summary>该工具是否属于诊断类(引擎在执行工具时记录,供回答类型判定)</summary>
public
static
bool
IsDiagnosticTool
(
string
toolName
)
{
return
!
string
.
IsNullOrEmpty
(
toolName
)
&&
DiagnosticTools
.
Contains
(
toolName
);
}
/// <summary>
/// 判定本轮回答类型。判定顺序:命中诊断类工具 → 报错类关键词 → (绑定产品上下文时)模块/表/调用链线索。
/// <para>拿不准时**偏向六段式**:信息更全,且与既有排查输出规范一致。</para>
/// </summary>
/// <param name="question">用户本轮问题</param>
/// <param name="diagnosticToolHit">本轮是否调用过诊断类工具</param>
/// <param name="productBound">是否绑定了产品上下文(未绑定时不做结构性线索判定,避免误判)</param>
public
static
AnswerKind
Classify
(
string
question
,
bool
diagnosticToolHit
,
bool
productBound
)
{
if
(
diagnosticToolHit
)
return
AnswerKind
.
Diagnostic
;
string
q
=
(
question
??
""
).
Trim
();
if
(
q
.
Length
==
0
)
return
AnswerKind
.
Knowledge
;
string
lower
=
q
.
ToLowerInvariant
();
foreach
(
var
k
in
DiagnosticKeywords
)
if
(
lower
.
IndexOf
(
k
,
StringComparison
.
Ordinal
)
>=
0
)
return
AnswerKind
.
Diagnostic
;
if
(
productBound
)
{
try
{
if
(
StructuralHint
.
IsMatch
(
q
))
return
AnswerKind
.
Diagnostic
;
}
catch
{
}
}
return
AnswerKind
.
Knowledge
;
}
/// <summary>
/// 契约校验(后置兜底):返回应加在最终回答顶部的一行提示;合规返回 null。
/// <para>知识问答:必须含「原因」「方案」两节(「引用」允许为空/省略 —— 未命中知识库时不得杜撰引用)。
/// 排查类:六节中至少命中 4 节(真实输出常把「修复与回滚」并作一节)。</para>
/// <para><b>双向放行</b>:判定为知识问答、但回答实际按排查六段式输出(≥4 节)时视为合规。
/// 分类靠关键词启发,必然有偏差;若不放行,用户会看到一条「未按三段式输出」的假警告,
/// 而这恰恰是双契约并存时最不能被接受的噪声。</para>
/// <para>只做提示、不重试:重试会把成本翻倍,而回答本身仍有价值。</para>
/// </summary>
public
static
string
Validate
(
string
content
,
AnswerKind
kind
)
{
if
(
string
.
IsNullOrWhiteSpace
(
content
))
return
null
;
if
(
content
.
IndexOf
(
"(模型未返回内容)"
,
StringComparison
.
Ordinal
)
>=
0
)
return
null
;
if
(
kind
==
AnswerKind
.
Diagnostic
)
return
CountHits
(
content
,
DiagnosticSections
)
>=
4
?
null
:
"⚠ 未按排查模板输出(已原样保留)"
;
// 知识问答:模型改用了排查六段式 ⇒ 视为合规(放行,不出假警告)
if
(
CountHits
(
content
,
DiagnosticSections
)
>=
4
)
return
null
;
foreach
(
var
s
in
KnowledgeSections
)
{
if
(
string
.
Equals
(
s
,
"引用"
,
StringComparison
.
Ordinal
))
continue
;
// 引用节允许为空(不得杜撰来源)
if
(!
HasLineSection
(
content
,
s
))
return
"⚠ 未按三段式模板输出(已原样保留)"
;
}
return
null
;
}
/// <summary>命中给定小节标题的个数</summary>
private
static
int
CountHits
(
string
content
,
string
[]
sections
)
{
int
hit
=
0
;
foreach
(
var
s
in
sections
)
if
(
ContainsSection
(
content
,
s
))
hit
++;
return
hit
;
}
/// <summary>小节标题是否出现在行首(容忍 <c>###</c> / <c>**</c> / 全角冒号等写法)</summary>
public
static
bool
HasLineSection
(
string
content
,
string
label
)
{
if
(
string
.
IsNullOrEmpty
(
content
)
||
string
.
IsNullOrEmpty
(
label
))
return
false
;
try
{
return
Regex
.
IsMatch
(
content
,
@"(?:^|\n)[ \t]*(?:#{1,6}[ \t]*)?\**[ \t]*"
+
Regex
.
Escape
(
label
)
+
@"[ \t]*\**[ \t]*[::]?"
,
RegexOptions
.
Multiline
);
}
catch
{
return
false
;
}
}
/// <summary>小节标题是否出现(不限定行首):仅用于排查六段式的宽松校验</summary>
public
static
bool
ContainsSection
(
string
content
,
string
label
)
{
return
!
string
.
IsNullOrEmpty
(
content
)
&&
!
string
.
IsNullOrEmpty
(
label
)
&&
content
.
IndexOf
(
label
,
StringComparison
.
Ordinal
)
>=
0
;
}
}
}
Tools.Agent/Agent/Answer/KbRecall.cs
0 → 100644
View file @
8d4b5d0b
using
System
;
using
System.Collections.Generic
;
using
System.IO
;
using
System.Linq
;
using
System.Text
;
using
System.Text.RegularExpressions
;
using
Tools.MyAgent.Kb
;
namespace
Tools.MyAgent.Agent
{
/// <summary>一条知识库召回命中(P2):带匹配度、行号、章节、正文与关联图片,可直接生成引用行。</summary>
public
sealed
class
KbHit
{
/// <summary>知识库内相对路径('/' 分隔;与 kb_* 工具输出一致,引用行可直接点击定位)</summary>
public
string
RelPath
=
""
;
/// <summary>绝对路径(内部使用:读正文、取行号、找同目录图片)</summary>
public
string
AbsPath
=
""
;
/// <summary>命中行号(1 基;0 = 无法定位,如 pdf/xlsx)</summary>
public
int
Line
;
/// <summary>命中行所属最近上级标题,如 "2.3 物料计算"(无法解析时为空)</summary>
public
string
Section
=
""
;
/// <summary>命中文段(单行,≤140 字)</summary>
public
string
Snippet
=
""
;
/// <summary>命中所在整节正文(喂给模型的主体;截断 4000 字)</summary>
public
string
Paragraph
=
""
;
/// <summary>融合匹配度(0~1):来源基分 + 0.25×查询词覆盖率 + 0.05×额外来源数(上限 0.95)</summary>
public
double
Score
;
/// <summary>命中的查询关键词(',' 分隔,供证据消息与调试展示)</summary>
public
string
HitKeywords
=
""
;
/// <summary>来源基分(0~1):路由基分 / 语义分 / 模糊分按关键词融合后的最大值(融合打分内部使用)</summary>
public
double
BaseScore
;
/// <summary>关联图片绝对路径(≤MaxImages,已过沙箱校验)</summary>
public
List
<
string
>
Images
=
new
List
<
string
>();
}
/// <summary>一次本地召回的结果(P2)。</summary>
public
sealed
class
KbRecallResult
{
/// <summary>kb(证据充足,直答)/ mixed(部分命中,允许模型补充)/ model(未召回,回退原工具循环)</summary>
public
string
Mode
=
"model"
;
/// <summary>最佳命中(Mode != model 时非空)</summary>
public
KbHit
Primary
;
/// <summary>其余命中(按匹配度降序)</summary>
public
List
<
KbHit
>
Others
=
new
List
<
KbHit
>();
/// <summary>回答顶部标注文案(部分命中时非空)</summary>
public
string
Reason
=
""
;
/// <summary>原始问题</summary>
public
string
Query
=
""
;
/// <summary>本次召回使用的关键词(供日志/轨迹)</summary>
public
List
<
string
>
Keywords
=
new
List
<
string
>();
}
/// <summary>
/// 本地融合召回(P2 回答形态改造的核心):把「多轮工具检索」压缩为一次本地预检索。
/// <para>职责:① 从自然语言问题抽关键词(索引是短语匹配,整句必然 0 命中);
/// ② 逐关键词调用 <see cref="KbService.RecallContent"/>,按文件融合打分(来源基分 + 查询词覆盖率 + 多路共现);
/// ③ 补齐 行号 / 章节 / 整节正文 / 关联图片;④ 按阈值判定 kb / mixed / model。</para>
/// <para>全部为本地动作(无 LLM 调用);任何异常由调用方兜底降级,不影响原工具循环能力。</para>
/// </summary>
public
sealed
class
KbRecall
{
private
readonly
KbService
_kb
;
/// <summary>直答阈值:融合分 ≥ 该值直接以证据作答(由 AgentConfig.KbHitThreshold 注入)</summary>
public
double
DirectThreshold
=
0.72
;
/// <summary>部分命中下界:低于该值视为未召回(固定 0.45,不随配置变化;供引擎做阈值钳制)</summary>
public
const
double
MixedLowerBound
=
0.45
;
/// <summary>是否采集关联图片</summary>
public
bool
ImagesEnabled
=
true
;
/// <summary>关联图片上限</summary>
public
int
MaxImages
=
4
;
/// <summary>纳入证据链的命中上限(主命中 + 其余)</summary>
private
const
int
MaxHits
=
3
;
/// <summary>索引可服务时的关键词上限(每个词一次 SQLite 查询,成本低)</summary>
private
const
int
MaxKeywordsIndexed
=
8
;
/// <summary>索引不可服务时的关键词上限(每个词一次全盘扫描,必须严控)</summary>
private
const
int
MaxKeywordsScanning
=
2
;
/// <summary>不做全盘扫描兜底的关键词序位(索引已服务时,仅最具体的前 N 个词允许回退全盘扫描)</summary>
private
const
int
FullScanKeywordQuota
=
2
;
/// <summary>每个关键词最多取回的文件数</summary>
private
const
int
PerKeywordHits
=
6
;
/// <summary>喂给模型的单条证据正文字符上限</summary>
private
const
int
MaxParagraphChars
=
4000
;
/// <summary>关联图片:同目录扫描的文件数上限</summary>
private
const
int
MaxDirProbe
=
200
;
/// <summary>命中行前后用于提取图片的行窗口</summary>
private
const
int
ImageLineWindow
=
20
;
private
static
readonly
Regex
TokenSplit
=
new
Regex
(
@"[\s,,。;;::、!!??()()\[\]【】""'“”‘’《》<>|/\\+=*~`@#$%^&]+"
,
RegexOptions
.
Compiled
);
private
static
readonly
Regex
ImageLink
=
new
Regex
(
@"!\[[^\]]*\]\(([^)\s]+)"
,
RegexOptions
.
Compiled
);
private
static
readonly
Regex
OutlineLine
=
new
Regex
(
@"^(\d+)\|(\d+)\|(.*)$"
,
RegexOptions
.
Compiled
);
/// <summary>ASCII 关键词噪声词(问题里的英文虚词不具备检索价值)</summary>
private
static
readonly
HashSet
<
string
>
AsciiStopWords
=
new
HashSet
<
string
>(
StringComparer
.
OrdinalIgnoreCase
)
{
"the"
,
"and"
,
"for"
,
"with"
,
"not"
,
"are"
,
"was"
,
"how"
,
"why"
,
"what"
,
"when"
,
"where"
,
"please"
,
"can"
,
"could"
,
"should"
,
"would"
,
"does"
,
"did"
,
"has"
,
"have"
,
"you"
,
"your"
,
"this"
,
"that"
,
"there"
,
"from"
,
"into"
,
"about"
,
"and"
,
"or"
,
"is"
,
"of"
,
"to"
,
"in"
,
"on"
,
};
/// <summary>中文停用二元组(问题句的疑问/连接词,检索价值低,会稀释覆盖率)</summary>
private
static
readonly
HashSet
<
string
>
CjkStopBigrams
=
new
HashSet
<
string
>(
StringComparer
.
Ordinal
)
{
"什么"
,
"怎么"
,
"如何"
,
"为什"
,
"什幺"
,
"是否"
,
"可以"
,
"这个"
,
"那个"
,
"请问"
,
"一下"
,
"哪些"
,
"哪一"
,
"多少"
,
"哪里"
,
"需要"
,
"应该"
,
"帮忙"
,
"谢谢"
,
"以及"
,
"还是"
,
"的话"
,
"我们"
,
"你们"
,
"他们"
,
"问题"
,
"为会"
,
"原因"
,
"为毛"
,
"咋样"
,
"怎样"
,
"咋回"
,
};
public
KbRecall
(
KbService
kb
)
{
_kb
=
kb
;
}
/// <summary>
/// 本地召回(无网络、无 LLM):返回 kb / mixed / model 判定结果。
/// <para>知识库不可用或问题无可检索关键词时返回 null(调用方回退原工具循环)。</para>
/// </summary>
/// <param name="question">用户原始问题</param>
/// <param name="ct">取消令牌:全盘扫描兜底路径据此支持 ESC 取消(长库扫描不阻塞取消)</param>
public
KbRecallResult
TryRecall
(
string
question
,
System
.
Threading
.
CancellationToken
ct
=
default
)
{
if
(
_kb
==
null
)
return
null
;
if
(
string
.
IsNullOrWhiteSpace
(
question
))
return
null
;
if
(!
_kb
.
ConnectIfNeeded
()
||
!
_kb
.
IsSearchValid
)
return
null
;
var
keywords
=
ExtractKeywords
(
question
);
if
(
keywords
.
Count
==
0
)
return
null
;
_kb
.
EnsureIndexStarted
();
// 幂等;让后续查询走索引(快)而非全盘扫描
bool
indexed
=
_kb
.
IsIndexServing
;
int
quota
=
indexed
?
MaxKeywordsIndexed
:
MaxKeywordsScanning
;
// ── 逐关键词召回并融合:key = 相对路径 ──
var
acc
=
new
Dictionary
<
string
,
KbHit
>(
StringComparer
.
OrdinalIgnoreCase
);
var
kwSets
=
new
Dictionary
<
string
,
HashSet
<
string
>>(
StringComparer
.
OrdinalIgnoreCase
);
var
srcSets
=
new
Dictionary
<
string
,
HashSet
<
string
>>(
StringComparer
.
OrdinalIgnoreCase
);
int
used
=
0
;
for
(
int
i
=
0
;
i
<
keywords
.
Count
&&
i
<
quota
;
i
++)
{
used
++;
var
hits
=
_kb
.
RecallContent
(
keywords
[
i
],
PerKeywordHits
,
ct
,
allowFullScan
:
indexed
?
i
<
FullScanKeywordQuota
:
true
);
if
(
hits
==
null
)
continue
;
foreach
(
var
h
in
hits
)
{
KbHit
cur
;
if
(!
acc
.
TryGetValue
(
h
.
Rel
,
out
cur
))
{
cur
=
new
KbHit
{
RelPath
=
h
.
Rel
};
acc
[
h
.
Rel
]
=
cur
;
kwSets
[
h
.
Rel
]
=
new
HashSet
<
string
>(
StringComparer
.
OrdinalIgnoreCase
);
srcSets
[
h
.
Rel
]
=
new
HashSet
<
string
>(
StringComparer
.
OrdinalIgnoreCase
);
}
if
(
h
.
Base
>
cur
.
BaseScore
)
cur
.
BaseScore
=
h
.
Base
;
// 同一文件取最强来源的基分
if
(
cur
.
Line
<=
0
&&
h
.
Line
>
0
)
cur
.
Line
=
h
.
Line
;
if
(
string
.
IsNullOrEmpty
(
cur
.
Snippet
)
&&
!
string
.
IsNullOrEmpty
(
h
.
Snippet
))
cur
.
Snippet
=
h
.
Snippet
;
kwSets
[
h
.
Rel
].
Add
(
keywords
[
i
]);
srcSets
[
h
.
Rel
].
Add
(
h
.
Source
??
""
);
}
}
// ── 关键词全部落空:尝试语义召回(向量);仍空则未召回 ──
if
(
acc
.
Count
==
0
)
{
if
(
_kb
.
SemanticEnabled
)
{
var
sem
=
_kb
.
RecallSemantic
(
question
,
PerKeywordHits
,
ct
);
foreach
(
var
s
in
sem
)
{
KbHit
cur
;
if
(!
acc
.
TryGetValue
(
s
.
Rel
,
out
cur
))
{
cur
=
new
KbHit
{
RelPath
=
s
.
Rel
,
Snippet
=
""
};
acc
[
s
.
Rel
]
=
cur
;
kwSets
[
s
.
Rel
]
=
new
HashSet
<
string
>(
StringComparer
.
OrdinalIgnoreCase
);
srcSets
[
s
.
Rel
]
=
new
HashSet
<
string
>(
StringComparer
.
OrdinalIgnoreCase
);
}
if
(
s
.
Score
>
cur
.
BaseScore
)
cur
.
BaseScore
=
s
.
Score
;
kwSets
[
s
.
Rel
].
Add
(
"语义"
);
srcSets
[
s
.
Rel
].
Add
(
"semantic"
);
}
}
if
(
acc
.
Count
==
0
)
return
new
KbRecallResult
{
Mode
=
"model"
,
Query
=
question
,
Keywords
=
keywords
.
Take
(
used
).
ToList
()
};
}
// ── 融合分:来源基分 + 0.25×查询词覆盖率 + 0.05×额外来源数(上限 0.95) ──
double
kwTotal
=
Math
.
Max
(
1
,
used
);
foreach
(
var
kv
in
acc
)
{
var
hit
=
kv
.
Value
;
double
coverage
=
kwSets
[
kv
.
Key
].
Count
/
kwTotal
;
int
extraRoutes
=
Math
.
Max
(
0
,
srcSets
[
kv
.
Key
].
Count
-
1
);
hit
.
Score
=
Math
.
Min
(
0.95
,
hit
.
BaseScore
+
0.25
*
coverage
+
0.05
*
extraRoutes
);
hit
.
HitKeywords
=
string
.
Join
(
","
,
kwSets
[
kv
.
Key
]);
hit
.
AbsPath
=
SafeCombine
(
hit
.
RelPath
);
}
var
ranked
=
acc
.
Values
.
Where
(
h
=>
h
.
AbsPath
!=
null
)
.
OrderByDescending
(
h
=>
h
.
Score
)
.
ThenBy
(
h
=>
h
.
RelPath
,
StringComparer
.
OrdinalIgnoreCase
)
.
ToList
();
if
(
ranked
.
Count
==
0
)
return
new
KbRecallResult
{
Mode
=
"model"
,
Query
=
question
,
Keywords
=
keywords
.
Take
(
used
).
ToList
()
};
var
primary
=
ranked
[
0
];
var
result
=
new
KbRecallResult
{
Query
=
question
,
Primary
=
primary
,
Keywords
=
keywords
.
Take
(
used
).
ToList
(),
Others
=
ranked
.
Skip
(
1
).
Take
(
MaxHits
-
1
).
ToList
(),
};
if
(
primary
.
Score
>=
DirectThreshold
)
result
.
Mode
=
"kb"
;
else
if
(
primary
.
Score
>=
MixedLowerBound
)
{
result
.
Mode
=
"mixed"
;
result
.
Reason
=
$"知识库仅部分命中(最高匹配度
{
primary
.
Score
:
0.00
}
),以下回答含模型补充内容。"
;
}
else
{
// 低于下界:不注入证据,回退原工具循环(保留原有检索/诊断能力,避免新通道削弱能力)
result
.
Mode
=
"model"
;
return
result
;
}
// ── 只对进入证据链的命中补齐 行号/章节/正文/图片(读文件的成本只花在少量命中上) ──
Enrich
(
primary
,
MaxImages
);
for
(
int
i
=
0
;
i
<
result
.
Others
.
Count
;
i
++)
Enrich
(
result
.
Others
[
i
],
0
);
// 次要命中不采图,避免附图轨被无关图片占满
return
result
;
}
/// <summary>命中 → 证据消息(作为一条 user 消息追加在问题之后,契约要求模型只据证据作答)</summary>
public
string
BuildEvidenceMessage
(
KbRecallResult
r
)
{
if
(
r
==
null
||
r
.
Primary
==
null
)
return
""
;
var
sb
=
new
StringBuilder
();
sb
.
AppendLine
(
"【本地知识库召回证据】(系统已预检索,请只依据以下证据作答;不要再调用检索工具)"
);
sb
.
AppendLine
(
"问题:"
+
OneLine
(
r
.
Query
,
200
));
sb
.
AppendLine
(
"召回关键词:"
+
string
.
Join
(
"、"
,
r
.
Keywords
));
sb
.
AppendLine
(
"最高匹配度:"
+
r
.
Primary
.
Score
.
ToString
(
"0.00"
)
+
"("
+
(
r
.
Mode
==
"kb"
?
"证据充分"
:
"部分命中,可结合通用知识补充并标注"
)
+
")"
);
sb
.
AppendLine
();
int
n
=
0
;
AppendEvidence
(
sb
,
++
n
,
r
.
Primary
);
foreach
(
var
h
in
r
.
Others
)
AppendEvidence
(
sb
,
++
n
,
h
);
sb
.
AppendLine
(
"【引用行输出要求】"
);
sb
.
AppendLine
(
"- 引用节每行一条,格式固定:📎 <知识库相对路径> §<章节> L<行号> 匹配度 <0.xx>"
);
sb
.
AppendLine
(
"- 只列本轮真正用到的来源(最多 3 条),路径/章节/行号不得杜撰;无行号时省略 “L<行号>”。"
);
sb
.
AppendLine
(
"- 证据未覆盖的内容明确写「知识库中未找到相关内容」,不得凭记忆补造文件、表名或结论。"
);
return
sb
.
ToString
();
}
private
static
void
AppendEvidence
(
StringBuilder
sb
,
int
index
,
KbHit
h
)
{
string
loc
=
h
.
RelPath
;
if
(!
string
.
IsNullOrEmpty
(
h
.
Section
))
loc
+=
" §"
+
h
.
Section
;
if
(
h
.
Line
>
0
)
loc
+=
" L"
+
h
.
Line
;
sb
.
AppendLine
(
"证据 "
+
index
+
"(匹配度 "
+
h
.
Score
.
ToString
(
"0.00"
)
+
" · "
+
loc
+
")"
);
string
body
=
!
string
.
IsNullOrEmpty
(
h
.
Paragraph
)
?
h
.
Paragraph
:
h
.
Snippet
;
if
(
string
.
IsNullOrEmpty
(
body
))
body
=
"(该文件仅文件名匹配,未取到正文)"
;
sb
.
AppendLine
(
body
);
sb
.
AppendLine
();
}
// ── 补齐 行号 / 章节 / 整节正文 / 关联图片 ──
private
void
Enrich
(
KbHit
hit
,
int
maxImages
)
{
string
text
=
null
,
outline
=
null
;
string
ext
=
""
;
try
{
ext
=
Path
.
GetExtension
(
hit
.
AbsPath
??
""
).
ToLowerInvariant
();
}
catch
{
}
try
{
if
(
ext
==
".docx"
)
{
outline
=
KbService
.
ReadDocxOutline
(
hit
.
AbsPath
);
text
=
KbService
.
ReadDocx
(
hit
.
AbsPath
);
}
else
if
(
ext
==
".pdf"
)
{
text
=
KbService
.
ReadPdf
(
hit
.
AbsPath
);
// PDF 无标题结构:行号/章节留空,正文片段仍可喂模型
}
else
{
text
=
KbService
.
ReadTextFile
(
hit
.
AbsPath
);
outline
=
KbService
.
MarkdownOutline
(
text
);
}
}
catch
{
}
// 行号:索引命中不带行号 ⇒ 用命中的第一个关键词在正文里定位
if
(
hit
.
Line
<=
0
&&
!
string
.
IsNullOrEmpty
(
text
))
{
string
kw
=
FirstKeyword
(
hit
.
HitKeywords
);
if
(!
string
.
IsNullOrEmpty
(
kw
))
hit
.
Line
=
KbService
.
LocateLine
(
hit
.
AbsPath
,
kw
);
}
string
[]
lines
=
null
;
if
(!
string
.
IsNullOrEmpty
(
text
))
lines
=
text
.
Replace
(
"\r\n"
,
"\n"
).
Replace
(
'\r'
,
'\n'
).
Split
(
'\n'
);
if
(
lines
!=
null
&&
lines
.
Length
>
0
)
{
int
target
=
hit
.
Line
>
0
?
hit
.
Line
:
1
;
int
start
=
0
,
level
=
99
,
nextStart
=
lines
.
Length
;
string
section
=
null
;
if
(!
string
.
IsNullOrEmpty
(
outline
))
{
// 命中行向上取最近标题(行号最大且 ≤ target)
foreach
(
var
ol
in
outline
.
Split
(
'\n'
))
{
var
m
=
OutlineLine
.
Match
(
ol
.
Trim
());
if
(!
m
.
Success
)
continue
;
int
ln
=
int
.
Parse
(
m
.
Groups
[
1
].
Value
);
if
(
ln
<=
target
&&
ln
>=
start
)
{
start
=
ln
;
level
=
int
.
Parse
(
m
.
Groups
[
2
].
Value
);
section
=
m
.
Groups
[
3
].
Value
.
Trim
();
}
}
// 段落终点:下一个同级或更高级标题
foreach
(
var
ol
in
outline
.
Split
(
'\n'
))
{
var
m
=
OutlineLine
.
Match
(
ol
.
Trim
());
if
(!
m
.
Success
)
continue
;
int
ln
=
int
.
Parse
(
m
.
Groups
[
1
].
Value
);
int
lv
=
int
.
Parse
(
m
.
Groups
[
2
].
Value
);
if
(
ln
>
start
&&
lv
<=
level
)
{
nextStart
=
ln
-
1
;
break
;
}
}
}
hit
.
Section
=
section
??
""
;
if
(
start
<=
0
)
{
// 无标题结构:取命中行前后各 30 行作为正文(够模型理解上下文,又不淹没证据)
start
=
Math
.
Max
(
1
,
target
-
30
);
nextStart
=
Math
.
Min
(
lines
.
Length
,
target
+
30
);
}
hit
.
Paragraph
=
Slice
(
lines
,
start
,
nextStart
,
MaxParagraphChars
);
}
else
if
(
string
.
IsNullOrEmpty
(
hit
.
Paragraph
))
{
hit
.
Paragraph
=
hit
.
Snippet
;
}
if
(
maxImages
>
0
&&
ImagesEnabled
&&
hit
.
Images
.
Count
<
maxImages
&&
lines
!=
null
)
CollectImages
(
hit
,
lines
,
maxImages
);
}
/// <summary>取 [startLine, endLine](1 基,含端点)文本,截断到 maxChars</summary>
private
static
string
Slice
(
string
[]
lines
,
int
startLine
,
int
endLine
,
int
maxChars
)
{
int
from
=
Math
.
Max
(
0
,
startLine
-
1
);
int
to
=
Math
.
Min
(
lines
.
Length
-
1
,
endLine
-
1
);
var
sb
=
new
StringBuilder
();
for
(
int
i
=
from
;
i
<=
to
;
i
++)
{
if
(
sb
.
Length
>
0
)
sb
.
Append
(
'\n'
);
sb
.
Append
(
lines
[
i
]);
if
(
sb
.
Length
>=
maxChars
)
break
;
}
string
s
=
sb
.
ToString
().
Trim
();
return
s
.
Length
<=
maxChars
?
s
:
s
.
Substring
(
0
,
maxChars
)
+
"…"
;
}
/// <summary>
/// 采集关联图片(三条规则合并去重,最多 max 张,全部过沙箱校验):
/// ① 命中段落内的 markdown 图片引用;② 命中行 ±20 行内的图片引用;
/// ③ 同目录与 images/图片/assets/imgs 下与文档名或命中关键词有字面交集的图片。
/// </summary>
private
void
CollectImages
(
KbHit
hit
,
string
[]
lines
,
int
max
)
{
var
root
=
_kb
.
Root
;
string
dir
;
try
{
dir
=
Path
.
GetDirectoryName
(
hit
.
AbsPath
);
}
catch
{
return
;
}
if
(
string
.
IsNullOrEmpty
(
dir
))
return
;
var
seen
=
new
HashSet
<
string
>(
StringComparer
.
OrdinalIgnoreCase
);
// ① 命中段落内的图片引用
foreach
(
Match
m
in
ImageLink
.
Matches
(
hit
.
Paragraph
??
""
))
AddImage
(
hit
,
m
.
Groups
[
1
].
Value
,
dir
,
root
,
seen
,
max
);
// ② 命中行 ±20 行内的图片引用
int
target
=
hit
.
Line
>
0
?
hit
.
Line
:
1
;
int
from
=
Math
.
Max
(
0
,
target
-
ImageLineWindow
-
1
);
int
to
=
Math
.
Min
(
lines
.
Length
-
1
,
target
+
ImageLineWindow
-
1
);
for
(
int
i
=
from
;
i
<=
to
&&
hit
.
Images
.
Count
<
max
;
i
++)
foreach
(
Match
m
in
ImageLink
.
Matches
(
lines
[
i
]))
AddImage
(
hit
,
m
.
Groups
[
1
].
Value
,
dir
,
root
,
seen
,
max
);
// ③ 同目录 / 常见图片子目录下的字面交集候选
if
(
hit
.
Images
.
Count
>=
max
)
return
;
string
docName
;
try
{
docName
=
Path
.
GetFileNameWithoutExtension
(
hit
.
AbsPath
);
}
catch
{
docName
=
""
;
}
string
[]
subs
=
{
""
,
"images"
,
"图片"
,
"assets"
,
"imgs"
};
foreach
(
var
sub
in
subs
)
{
if
(
hit
.
Images
.
Count
>=
max
)
break
;
string
d
=
string
.
IsNullOrEmpty
(
sub
)
?
dir
:
Path
.
Combine
(
dir
,
sub
);
try
{
if
(!
Directory
.
Exists
(
d
))
continue
;
int
probed
=
0
;
foreach
(
var
f
in
Directory
.
GetFiles
(
d
))
{
if
(
hit
.
Images
.
Count
>=
max
||
++
probed
>
MaxDirProbe
)
break
;
string
ext
=
Path
.
GetExtension
(
f
).
ToLowerInvariant
();
if
(!
KbService
.
IsImageExtension
(
ext
))
continue
;
string
name
;
try
{
name
=
Path
.
GetFileNameWithoutExtension
(
f
);
}
catch
{
continue
;
}
double
byDoc
=
string
.
IsNullOrEmpty
(
docName
)
?
0
:
KbService
.
FuzzyScore
(
docName
,
name
);
double
byKw
=
MaxKeywordScore
(
name
,
hit
.
HitKeywords
);
if
(
byDoc
<
0.3
&&
byKw
<
0.3
)
continue
;
AddImageAbs
(
hit
,
f
,
root
,
seen
,
max
);
}
}
catch
{
}
}
}
/// <summary>命中段落里的图片链接 → 绝对路径(相对文档目录解析),校验通过后加入</summary>
private
static
void
AddImage
(
KbHit
hit
,
string
link
,
string
baseDir
,
string
root
,
HashSet
<
string
>
seen
,
int
max
)
{
if
(
string
.
IsNullOrEmpty
(
link
))
return
;
link
=
link
.
Replace
(
'/'
,
'\\'
).
Trim
();
try
{
string
abs
=
Path
.
IsPathRooted
(
link
)
?
Path
.
GetFullPath
(
link
)
:
Path
.
GetFullPath
(
Path
.
Combine
(
baseDir
,
link
));
AddImageAbs
(
hit
,
abs
,
root
,
seen
,
max
);
}
catch
{
}
}
/// <summary>图片绝对路径校验(存在 + 在知识库内 + 图片扩展 + 去重)后加入</summary>
private
static
void
AddImageAbs
(
KbHit
hit
,
string
abs
,
string
root
,
HashSet
<
string
>
seen
,
int
max
)
{
if
(
hit
.
Images
.
Count
>=
max
)
return
;
if
(
string
.
IsNullOrEmpty
(
abs
)
||
string
.
IsNullOrEmpty
(
root
))
return
;
if
(!
File
.
Exists
(
abs
))
return
;
try
{
if
(!
KbService
.
IsWithin
(
KbService
.
NormPath
(
root
),
KbService
.
NormPath
(
abs
)))
return
;
// 沙箱校验
}
catch
{
return
;
}
if
(!
KbService
.
IsImageExtension
(
Path
.
GetExtension
(
abs
).
ToLowerInvariant
()))
return
;
if
(!
seen
.
Add
(
abs
))
return
;
hit
.
Images
.
Add
(
abs
);
}
private
static
string
FirstKeyword
(
string
joined
)
{
if
(
string
.
IsNullOrEmpty
(
joined
))
return
""
;
int
idx
=
joined
.
IndexOf
(
','
);
return
idx
>
0
?
joined
.
Substring
(
0
,
idx
)
:
joined
;
}
private
static
double
MaxKeywordScore
(
string
name
,
string
joinedKeywords
)
{
if
(
string
.
IsNullOrEmpty
(
name
)
||
string
.
IsNullOrEmpty
(
joinedKeywords
))
return
0
;
double
best
=
0
;
foreach
(
var
kw
in
joinedKeywords
.
Split
(
','
))
{
if
(
string
.
IsNullOrEmpty
(
kw
))
continue
;
double
s
=
KbService
.
FuzzyScore
(
kw
,
name
);
if
(
s
>
best
)
best
=
s
;
}
return
best
;
}
/// <summary>相对路径 → 绝对路径(仅拼接,不抛异常;越界/非法返回 null)</summary>
private
string
SafeCombine
(
string
rel
)
{
try
{
string
root
=
_kb
.
Root
;
if
(
string
.
IsNullOrEmpty
(
root
)
||
string
.
IsNullOrEmpty
(
rel
))
return
null
;
string
abs
=
Path
.
GetFullPath
(
Path
.
Combine
(
root
,
rel
.
Replace
(
'/'
,
'\\'
)));
return
KbService
.
IsWithin
(
KbService
.
NormPath
(
root
),
KbService
.
NormPath
(
abs
))
?
abs
:
null
;
}
catch
{
return
null
;
}
}
// ── 关键词抽取 ──
/// <summary>
/// 从自然语言问题抽检索关键词(索引为短语匹配,整句必然 0 命中,必须先切词):
/// ASCII 标识(BomVersion、U.Rule、frmEditMaterialCalculation)优先 → 中文三元组 → 中文二元组,
/// 过滤疑问/连接类停用词,按具体程度排序去重。
/// </summary>
internal
static
List
<
string
>
ExtractKeywords
(
string
question
)
{
var
ascii
=
new
List
<
string
>();
var
tri
=
new
List
<
string
>();
var
bi
=
new
List
<
string
>();
if
(
string
.
IsNullOrWhiteSpace
(
question
))
return
new
List
<
string
>();
foreach
(
var
token
in
TokenSplit
.
Split
(
question
))
{
if
(
string
.
IsNullOrEmpty
(
token
))
continue
;
if
(
AsciiStopWords
.
Contains
(
token
))
continue
;
CollectToken
(
token
,
ascii
,
tri
,
bi
);
}
var
result
=
new
List
<
string
>();
var
seen
=
new
HashSet
<
string
>(
StringComparer
.
OrdinalIgnoreCase
);
// ASCII 标识最具体:长的优先(frmEditMaterialCalculation 优于 frm)
foreach
(
var
w
in
ascii
.
OrderByDescending
(
x
=>
x
.
Length
))
{
if
(
w
.
Length
<
2
||
AsciiStopWords
.
Contains
(
w
))
continue
;
if
(
seen
.
Add
(
w
))
result
.
Add
(
w
);
if
(
result
.
Count
>=
3
)
break
;
}
// 中文三元组(更具体):首尾二元组命中停用词表即丢弃(如「如何配」→ 首二元组「如何」)
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
)))
continue
;
if
(
seen
.
Add
(
w
))
result
.
Add
(
w
);
if
(
result
.
Count
>=
triCap
||
result
.
Count
>=
8
)
break
;
}
// 中文二元组补足
foreach
(
var
w
in
bi
)
{
if
(
CjkStopBigrams
.
Contains
(
w
))
continue
;
if
(
seen
.
Add
(
w
))
result
.
Add
(
w
);
if
(
result
.
Count
>=
8
)
break
;
}
return
result
;
}
/// <summary>单个 token → ASCII / 中文三元组 / 中文二元组(混合 token 按字符类别切段)</summary>
private
static
void
CollectToken
(
string
token
,
List
<
string
>
ascii
,
List
<
string
>
tri
,
List
<
string
>
bi
)
{
var
sb
=
new
StringBuilder
();
char
cls
=
'\0'
;
foreach
(
char
c
in
token
)
{
char
k
=
IsCjk
(
c
)
?
'c'
:
(
IsWordChar
(
c
)
?
'w'
:
'\0'
);
if
(
k
==
'\0'
)
{
FlushRun
(
sb
,
cls
,
ascii
,
tri
,
bi
);
cls
=
'\0'
;
continue
;
}
if
(
k
!=
cls
)
{
FlushRun
(
sb
,
cls
,
ascii
,
tri
,
bi
);
cls
=
k
;
}
sb
.
Append
(
c
);
}
FlushRun
(
sb
,
cls
,
ascii
,
tri
,
bi
);
}
private
static
void
FlushRun
(
StringBuilder
sb
,
char
cls
,
List
<
string
>
ascii
,
List
<
string
>
tri
,
List
<
string
>
bi
)
{
if
(
cls
==
'\0'
||
sb
.
Length
==
0
)
{
sb
.
Length
=
0
;
return
;
}
string
s
=
sb
.
ToString
();
sb
.
Length
=
0
;
if
(
cls
==
'w'
)
{
s
=
s
.
Trim
(
'.'
);
if
(
s
.
Length
>=
2
&&
s
.
Any
(
c
=>
char
.
IsLetter
(
c
)))
ascii
.
Add
(
s
);
return
;
}
// CJK 段:**隔位采样**而非滑动窗口 —— 滑动窗口会让前 6 个字就把关键词额度占满,
// 长句子的尾部(往往才是检索意图,如「共享文件夹访问账号」)永远进不了关键词表。
// 三元组步长 3(词形完整且覆盖整段);二元组步长 2(对未分词的中文召回更稳)。
for
(
int
i
=
0
;
i
+
3
<=
s
.
Length
;
i
+=
3
)
tri
.
Add
(
s
.
Substring
(
i
,
3
));
for
(
int
i
=
0
;
i
+
2
<=
s
.
Length
;
i
+=
2
)
bi
.
Add
(
s
.
Substring
(
i
,
2
));
}
private
static
bool
IsCjk
(
char
c
)
{
return
(
c
>=
0x3400
&&
c
<=
0x4DBF
)
||
(
c
>=
0x4E00
&&
c
<=
0x9FFF
)
||
(
c
>=
0xF900
&&
c
<=
0xFAFF
);
}
private
static
bool
IsWordChar
(
char
c
)
{
return
char
.
IsLetterOrDigit
(
c
)
||
c
==
'_'
||
c
==
'.'
;
}
private
static
string
OneLine
(
string
s
,
int
max
)
{
if
(
string
.
IsNullOrEmpty
(
s
))
return
""
;
s
=
s
.
Replace
(
"\r"
,
" "
).
Replace
(
"\n"
,
" "
).
Trim
();
return
s
.
Length
<=
max
?
s
:
s
.
Substring
(
0
,
max
)
+
"…"
;
}
}
}
Tools.Agent/Agent/PromptBuilder.cs
View file @
8d4b5d0b
...
...
@@ -29,6 +29,12 @@ namespace Tools.MyAgent.Agent
/// <summary>宿主注入的诊断根目录;只用于显示根目录 ID,不显示完整机器路径。</summary>
public
IReadOnlyList
<
DiagnosticRoot
>
DiagnosticRoots
{
get
;
set
;
}
/// <summary>
/// P3 契约化输出开关(<c>AgentConfig.ConciseAnswer</c>);关闭时不注入输出形态契约层,
/// 回答形态回落到改造前的自由格式(与 <c>QuickAnswer</c> 相互独立,可单独回退)。
/// </summary>
public
bool
ConciseAnswerEnabled
{
get
;
set
;
}
=
true
;
public
string
BuildSystemMessage
(
KbService
kb
)
{
// B2 提示词外置:静态层优先读 输出目录/prompts/*.md 模板(用户可定制),
...
...
@@ -60,6 +66,13 @@ namespace Tools.MyAgent.Agent
sb
.
AppendLine
(
LoadLayer
(
promptsDir
,
"security.md"
,
SecurityLayer
));
sb
.
AppendLine
();
sb
.
AppendLine
(
LoadLayer
(
promptsDir
,
"tool_rules.md"
,
ToolRules
));
// P3 输出形态契约:放在最后一层(最贴近生成位置,权重最高);仅约束输出形态,
// 显式声明不得与安全层冲突,避免与 security.md 并列时被误读为同级规则。
if
(
ConciseAnswerEnabled
)
{
sb
.
AppendLine
();
sb
.
AppendLine
(
LoadLayer
(
promptsDir
,
"answer_spec.md"
,
AnswerSpecLayer
));
}
return
sb
.
ToString
();
}
...
...
@@ -292,5 +305,38 @@ namespace Tools.MyAgent.Agent
- kb_write / kb_write_kb 一次只写一个文件;覆盖已有文件前先征得用户同意;追加请用 append=true。kb_write 写默认输出目录的 kb_write 子文件夹(Agent/output/kb_write/),kb_write_kb 仅在用户明确要求存入知识库时使用。
- 检索结果过多时,先用 kb_list/kb_find 缩小范围,再精读。
- 工具返回为空或报错时,如实说明,不要假装成功。"
;
/// <summary>
/// §P3 输出形态契约层(内置回退;外置同内容模板见 <c>Defaults/Agent/prompts/answer_spec.md</c>)。
/// <para>与外置文件**逐字一致**:文件缺失时 <see cref="LoadLayer"/> 会把本常量写盘成默认模板,
/// 二者若不一致,用户「重置模板」后会得到一个与内置回退不同的契约,故必须同步维护。</para>
/// </summary>
private
static
string
AnswerSpecLayer
=>
@"【输出形态契约(硬约束,仅约束输出形态,不得与安全层冲突)】
本节规定「最终回答长什么样」;安全层与事实纪律仍然最高优先级,二者冲突时以安全层与事实为准。
判断本轮属于哪一类(拿不准时按第二类即排查类输出,信息更全):
- 第一类 知识问答:查资料、查流程、问「是什么/怎么做/在哪配」等,且不涉及现场故障与代码/表/调用链定位。
- 第二类 现场排查:涉及报错、异常、失败、崩溃、超时、卡死、没反应,或需要定位到具体文件/类/表/调用链/日志。
一、知识问答类 → 只允许三节,顺序固定,节标题用加粗或三级标题,禁止增加其它章节与客套话:
- 原因:不超过 3 行,一句话点明根因或结论;不复述用户问题,不复述原文段落。
- 方案:不超过 5 步的有序列表,每步必须给出具体动作对象(菜单、按钮、字段、配置项、路径)。
- 引用:每行一条,格式固定:📎 <知识库相对路径> §<章节> L<行号> 匹配度 <0.xx>
章节、行号、匹配度三段可缺省(确实没有时省略),但**不得杜撰**路径、章节与行号。
只列本轮真正用到的来源,最多 3 条;未命中知识库时本节写「知识库中未找到相关内容」。
- 禁止出现「首先/其次/总的来说/希望对你有帮助」这类过渡与客套语。
二、现场排查类 → 沿用排查输出规范,六节顺序固定,每节不超过 3 行:
已确认事实 / 证据 / 最可能根因 / 待验证项 / 最小验证步骤 / 修复与回滚建议。
- 每一条「已确认事实」都必须能对应到某条「证据」(日志行、配置项、文件行号)。
- 证据不足时,在「待验证项」写明还缺什么,不要凭经验臆断根因。
- 不得仅凭单条异常就下定论;不得编造文件、表名、调用链或结论。
三、两类的通用禁止:
- 大段原文摘录(超过 3 行的原文引用一律改为摘要 + 引用行)。
- 超过 3 列的表格;代码块仅用于给出命令或配置片段。
- 结尾总结段、免责声明、与问题无关的扩展建议。
- 回答长度与问题复杂度匹配:简单问题给出简短三段式即可,不刻意展开。"
;
}
}
Tools.Agent/Config/AgentConfig.cs
View file @
8d4b5d0b
...
...
@@ -110,6 +110,39 @@
/// <summary>是否启用图片提问(多模态,v2.2):启用后粘贴截图随提问以 image_url 发送,需视觉模型支持(如 DeepSeek-VL / qwen-vl);默认启用</summary>
public
bool
EnableImageInput
{
get
;
set
;
}
=
true
;
// ════════════════ 高级项:回答形态(旋转指示 / 本地直答 / 契约化输出 / 附图) ════════════════
/// <summary>
/// 本地召回直答(回答形态改造 P3):本轮问题在知识库命中达阈值时,走「单轮直答」通道(不下发工具),
/// 不再进入多轮工具循环;召回失败/异常一律静默降级回原工具循环。关闭即完全回到改造前行为。
/// </summary>
public
bool
QuickAnswer
{
get
;
set
;
}
=
true
;
/// <summary>
/// 契约化输出(由 prompts/answer_spec.md 约束 + 后置正则兜底):
/// 知识问答类走三段式(原因/方案/引用),排查类走压缩六段式。关闭则不追加契约层、不做兜底标注。
/// </summary>
public
bool
ConciseAnswer
{
get
;
set
;
}
=
true
;
/// <summary>
/// 是否仍在问答区渲染思考文本。默认 false:思考只由右下角旋转指示表达,不再铺满问答区(也避免流式每帧重绘);
/// 置 true 恢复旧行为(调试/排查模型思维链时使用)。
/// </summary>
public
bool
ShowThinking
{
get
;
set
;
}
=
false
;
/// <summary>回答附图(P5):本轮回答命中文档关联的图片以缩略图栏展示,点击放大(复用 wBitmapView)</summary>
public
bool
AnswerWithImages
{
get
;
set
;
}
=
true
;
/// <summary>回答附图上限(张,1~8)</summary>
public
int
MaxAnswerImages
{
get
;
set
;
}
=
4
;
/// <summary>
/// 本地直答阈值(0.45~0.95):融合匹配度 ≥ 该值走单轮直答(mode=kb);
/// [0.45, 该值) 走「部分命中」(mode=mixed,证据 + 允许模型补充并标注);
/// 低于 0.45 视为未召回,**回退原工具循环**(保留原有检索/诊断能力,不因新通道削弱能力)。
/// </summary>
public
double
KbHitThreshold
{
get
;
set
;
}
=
0.72
;
// ════════════════ 高级项:策略参数(R12) ════════════════
/// <summary>工具调用轮次上限(默认 15;超限后进入收尾回答轮,基于已收集信息汇总作答)</summary>
...
...
Tools.Agent/Defaults/Agent/prompts/answer_spec.md
0 → 100644
View file @
8d4b5d0b
【输出形态契约(硬约束,仅约束输出形态,不得与安全层冲突)】
本节规定「最终回答长什么样」;安全层与事实纪律仍然最高优先级,二者冲突时以安全层与事实为准。
判断本轮属于哪一类(拿不准时按第二类即排查类输出,信息更全):
-
第一类 知识问答:查资料、查流程、问「是什么/怎么做/在哪配」等,且不涉及现场故障与代码/表/调用链定位。
-
第二类 现场排查:涉及报错、异常、失败、崩溃、超时、卡死、没反应,或需要定位到具体文件/类/表/调用链/日志。
一、知识问答类 → 只允许三节,顺序固定,节标题用加粗或三级标题,禁止增加其它章节与客套话:
-
原因:不超过 3 行,一句话点明根因或结论;不复述用户问题,不复述原文段落。
-
方案:不超过 5 步的有序列表,每步必须给出具体动作对象(菜单、按钮、字段、配置项、路径)。
-
引用:每行一条,格式固定:📎
<
知识库相对路径
>
§
<
章节
>
L
<
行号
>
匹配度
<0
.
xx
>
章节、行号、匹配度三段可缺省(确实没有时省略),但
**不得杜撰**
路径、章节与行号。
只列本轮真正用到的来源,最多 3 条;未命中知识库时本节写「知识库中未找到相关内容」。
-
禁止出现「首先/其次/总的来说/希望对你有帮助」这类过渡与客套语。
二、现场排查类 → 沿用排查输出规范,六节顺序固定,每节不超过 3 行:
已确认事实 / 证据 / 最可能根因 / 待验证项 / 最小验证步骤 / 修复与回滚建议。
-
每一条「已确认事实」都必须能对应到某条「证据」(日志行、配置项、文件行号)。
-
证据不足时,在「待验证项」写明还缺什么,不要凭经验臆断根因。
-
不得仅凭单条异常就下定论;不得编造文件、表名、调用链或结论。
三、两类的通用禁止:
-
大段原文摘录(超过 3 行的原文引用一律改为摘要 + 引用行)。
-
超过 3 列的表格;代码块仅用于给出命令或配置片段。
-
结尾总结段、免责声明、与问题无关的扩展建议。
-
回答长度与问题复杂度匹配:简单问题给出简短三段式即可,不刻意展开。
Tools.Agent/Forms/AgentSettingsForm.cs
View file @
8d4b5d0b
...
...
@@ -120,6 +120,7 @@ namespace Tools.MyAgent.Forms
InitializeComponent
();
BuildDbConnectionsTab
();
// P0:预存数据库连接(db_query 按 ID 引用,聊天不回显密码)
BuildToolPermissionUi
();
// P3:工具级权限(按角色)输入区
BuildAnswerShapeTab
();
// 回答形态:旋转思考指示 / 本地直答 / 契约化输出 / 回答附图
BindConfig
();
// 配置值回填控件(控件创建见 Designer.cs)
ApplyProviderMode
();
// 布局联动:BaseUrl 行显隐
ApplyKbMode
();
// 布局联动:共享 账号/密码/测试连接 行显隐
...
...
@@ -235,6 +236,99 @@ namespace Tools.MyAgent.Forms
return
rules
;
}
// ══ 回答形态:旋转思考指示 / 本地召回直答 / 契约化输出 / 回答附图 ══
private
CheckBox
chkQuickAnswer
;
private
CheckBox
chkConciseAnswer
;
private
CheckBox
chkShowThinking
;
private
CheckBox
chkAnswerImages
;
private
NumericUpDown
numMaxAnswerImages
;
private
NumericUpDown
numKbHitThreshold
;
/// <summary>
/// 新增「回答形态」选项卡(程序化创建,避免侵入设计器):
/// 思考呈现方式、本地召回直答、契约化输出、回答附图与直答阈值。
/// </summary>
private
void
BuildAnswerShapeTab
()
{
var
tabShape
=
new
TabPage
{
Text
=
"回答形态"
,
Padding
=
new
Padding
(
3
)
};
var
grp
=
new
GroupBox
{
Text
=
"回答形态(思考指示 / 本地直答 / 契约化输出 / 附图)"
,
Location
=
new
Point
(
8
,
12
),
Size
=
new
Size
(
596
,
324
),
};
chkQuickAnswer
=
new
CheckBox
{
Text
=
"本地召回直答:知识库命中达阈值时单轮直答(跳过工具循环);召回异常自动降级"
,
Location
=
new
Point
(
16
,
28
),
AutoSize
=
true
,
};
chkConciseAnswer
=
new
CheckBox
{
Text
=
"契约化输出:知识问答三段式(原因/方案/引用),排查类六段式"
,
Location
=
new
Point
(
16
,
56
),
AutoSize
=
true
,
};
chkShowThinking
=
new
CheckBox
{
Text
=
"仍在问答区渲染思考文本(默认关闭:仅右下旋转指示;调试用)"
,
Location
=
new
Point
(
16
,
84
),
AutoSize
=
true
,
};
chkAnswerImages
=
new
CheckBox
{
Text
=
"回答附图:命中文档关联的图片以缩略图栏展示(点击放大)"
,
Location
=
new
Point
(
16
,
112
),
AutoSize
=
true
,
};
var
lblThreshold
=
new
Label
{
Text
=
"直答阈值"
,
Location
=
new
Point
(
16
,
150
),
AutoSize
=
true
};
numKbHitThreshold
=
new
NumericUpDown
{
Location
=
new
Point
(
96
,
146
),
Size
=
new
Size
(
70
,
23
),
DecimalPlaces
=
2
,
Increment
=
0.01
M
,
Minimum
=
0.45
M
,
Maximum
=
0.95
M
,
Value
=
0.72
M
,
};
var
lblMaxImages
=
new
Label
{
Text
=
"附图上限"
,
Location
=
new
Point
(
196
,
150
),
AutoSize
=
true
};
numMaxAnswerImages
=
new
NumericUpDown
{
Location
=
new
Point
(
276
,
146
),
Size
=
new
Size
(
70
,
23
),
Minimum
=
1
,
Maximum
=
8
,
Value
=
4
,
};
var
hint
=
new
Label
{
Text
=
"直答阈值:[阈值, 0.95] 直接以证据作答;[0.45, 阈值) 标注「部分命中」并允许模型补充;"
+
"低于 0.45 视为未召回,回退原多轮工具循环。\r\n"
+
"关闭「本地召回直答」后行为与改造前完全一致(多轮工具循环 + 原回答形态)。"
,
Location
=
new
Point
(
16
,
186
),
AutoSize
=
false
,
Size
=
new
Size
(
564
,
60
),
ForeColor
=
Color
.
Gray
,
};
grp
.
Controls
.
Add
(
chkQuickAnswer
);
grp
.
Controls
.
Add
(
chkConciseAnswer
);
grp
.
Controls
.
Add
(
chkShowThinking
);
grp
.
Controls
.
Add
(
chkAnswerImages
);
grp
.
Controls
.
Add
(
lblThreshold
);
grp
.
Controls
.
Add
(
numKbHitThreshold
);
grp
.
Controls
.
Add
(
lblMaxImages
);
grp
.
Controls
.
Add
(
numMaxAnswerImages
);
grp
.
Controls
.
Add
(
hint
);
tabShape
.
Controls
.
Add
(
grp
);
tabMain
.
TabPages
.
Add
(
tabShape
);
}
// ══ 配置绑定:把 AgentConfig 值回填到控件(设计器仅设默认值) ══
private
void
BindConfig
()
...
...
@@ -296,6 +390,14 @@ namespace Tools.MyAgent.Forms
// 可选能力页:工具级权限(P3,角色白名单)
txtToolRoles
.
Lines
=
ToolRoleRulesToLines
(
_config
.
ToolRoleAllow
);
// 回答形态页:思考指示 / 本地直答 / 契约化输出 / 附图 / 阈值
chkQuickAnswer
.
Checked
=
_config
.
QuickAnswer
;
chkConciseAnswer
.
Checked
=
_config
.
ConciseAnswer
;
chkShowThinking
.
Checked
=
_config
.
ShowThinking
;
chkAnswerImages
.
Checked
=
_config
.
AnswerWithImages
;
numMaxAnswerImages
.
Value
=
ClampToControl
(
_config
.
MaxAnswerImages
,
numMaxAnswerImages
,
4
);
numKbHitThreshold
.
Value
=
ClampDecimalToControl
(
_config
.
KbHitThreshold
,
numKbHitThreshold
,
0.72
M
);
// 数据库连接(P0):预存连接串(内存明文态)回填表格
dgvDbConn
.
Rows
.
Clear
();
if
(
_config
.
DbConnections
!=
null
)
...
...
@@ -751,6 +853,13 @@ namespace Tools.MyAgent.Forms
_config
.
EnableImageInput
=
chkImageInput
.
Checked
;
// v2.2 多模态:图片提问开关
// 可选能力页:工具级权限(P3)
_config
.
ToolRoleAllow
=
ParseToolRoleRules
(
txtToolRoles
.
Text
);
// 回答形态页:思考指示 / 本地直答 / 契约化输出 / 附图 / 阈值
_config
.
QuickAnswer
=
chkQuickAnswer
.
Checked
;
_config
.
ConciseAnswer
=
chkConciseAnswer
.
Checked
;
_config
.
ShowThinking
=
chkShowThinking
.
Checked
;
_config
.
AnswerWithImages
=
chkAnswerImages
.
Checked
;
_config
.
MaxAnswerImages
=
(
int
)
numMaxAnswerImages
.
Value
;
_config
.
KbHitThreshold
=
(
double
)
numKbHitThreshold
.
Value
;
// 数据库连接(P0):表格 → 字典(ID 大小写不敏感,空格清理;空 ID 行忽略)
var
conns
=
new
Dictionary
<
string
,
string
>(
StringComparer
.
OrdinalIgnoreCase
);
foreach
(
DataGridViewRow
r
in
dgvDbConn
.
Rows
)
...
...
Tools.Agent/Forms/DocLineViewer.cs
0 → 100644
View file @
8d4b5d0b
using
System
;
using
System.Drawing
;
using
System.IO
;
using
System.Text
;
using
System.Windows.Forms
;
namespace
Tools.MyAgent.Forms
{
/// <summary>
/// 文档行定位查看器(P4 引用行点击):只读展示文本类文件,并定位高亮到指定行。
/// <para>纯代码构造(无 Designer / resx 依赖,与 <see cref="wBitmapView"/> 同制);非文本格式(docx/xlsx/pdf/pptx)
/// 由调用方改走默认程序打开。</para>
/// <para>窗口为非模态(不阻塞继续提问),Esc 关闭;高亮用选区背景色实现,焦点移开仍可见。</para>
/// </summary>
public
sealed
class
DocLineViewer
:
Form
{
/// <summary>可内置定位查看的文本类扩展名(其余交默认程序)</summary>
private
static
readonly
string
[]
ViewableExtensions
=
{
".md"
,
".markdown"
,
".txt"
,
".log"
,
".csv"
,
".tsv"
,
".json"
,
".xml"
,
".yml"
,
".yaml"
,
".cs"
,
".sql"
,
".config"
,
".ini"
,
".bat"
,
".cmd"
,
".ps1"
,
".js"
,
".ts"
,
".py"
,
".html"
,
".htm"
,
".css"
,
".vue"
,
".java"
,
".cpp"
,
".h"
,
};
/// <summary>超长文件的展示行上限(避免超大日志把 RichTextBox 拖死)</summary>
private
const
int
MaxShowLines
=
20000
;
/// <summary>命中行高亮底色</summary>
private
static
readonly
Color
HighlightColor
=
Color
.
FromArgb
(
255
,
244
,
170
);
private
readonly
string
_file
;
private
readonly
RichTextBox
_box
;
private
readonly
Label
_hint
;
private
readonly
int
_totalLines
;
private
readonly
bool
_clipped
;
/// <summary>是否支持内置定位查看(非文本格式返回 false,调用方应改走默认程序打开)</summary>
public
static
bool
CanView
(
string
file
)
{
if
(
string
.
IsNullOrEmpty
(
file
))
return
false
;
string
ext
;
try
{
ext
=
Path
.
GetExtension
(
file
).
ToLowerInvariant
();
}
catch
{
return
false
;
}
foreach
(
var
e
in
ViewableExtensions
)
if
(
string
.
Equals
(
e
,
ext
,
StringComparison
.
OrdinalIgnoreCase
))
return
true
;
return
false
;
}
private
DocLineViewer
(
string
file
,
int
requestedLine
,
string
text
)
{
_file
=
file
;
this
.
Text
=
"文档定位 — "
+
SafeFileName
(
file
);
this
.
FormBorderStyle
=
FormBorderStyle
.
SizableToolWindow
;
this
.
ShowInTaskbar
=
false
;
this
.
StartPosition
=
FormStartPosition
.
CenterParent
;
this
.
ClientSize
=
new
Size
(
920
,
620
);
this
.
MinimumSize
=
new
Size
(
420
,
260
);
this
.
KeyPreview
=
true
;
this
.
Font
=
new
Font
(
"微软雅黑"
,
9F
);
var
lines
=
(
text
??
""
).
Replace
(
"\r\n"
,
"\n"
).
Replace
(
'\r'
,
'\n'
).
Split
(
'\n'
);
_totalLines
=
lines
.
Length
;
_clipped
=
lines
.
Length
>
MaxShowLines
;
string
shown
=
_clipped
?
string
.
Join
(
"\n"
,
lines
,
0
,
MaxShowLines
)
:
(
text
??
""
);
_box
=
new
RichTextBox
{
Dock
=
DockStyle
.
Fill
,
ReadOnly
=
true
,
BorderStyle
=
BorderStyle
.
None
,
BackColor
=
Color
.
White
,
Font
=
new
Font
(
"微软雅黑"
,
10F
),
WordWrap
=
true
,
ScrollBars
=
RichTextBoxScrollBars
.
Vertical
,
DetectUrls
=
false
,
HideSelection
=
false
,
// 焦点移开仍保持命中行高亮可见
TabStop
=
false
,
};
_box
.
Text
=
shown
;
_hint
=
new
Label
{
Dock
=
DockStyle
.
Bottom
,
Height
=
26
,
TextAlign
=
ContentAlignment
.
MiddleLeft
,
Padding
=
new
Padding
(
8
,
0
,
8
,
0
),
BackColor
=
Color
.
FromArgb
(
245
,
245
,
245
),
ForeColor
=
Color
.
FromArgb
(
90
,
90
,
90
),
AutoEllipsis
=
true
,
// Show() 到 GotoLine() 之间会先渲染一帧:此处先给出目标行,避免底部空条闪一下
Text
=
requestedLine
>
0
?
$"正在定位到第
{
requestedLine
}
行…"
:
""
,
};
var
tip
=
new
Label
{
Dock
=
DockStyle
.
Top
,
Height
=
24
,
TextAlign
=
ContentAlignment
.
MiddleLeft
,
Padding
=
new
Padding
(
8
,
0
,
8
,
0
),
BackColor
=
Color
.
FromArgb
(
250
,
250
,
252
),
ForeColor
=
Color
.
FromArgb
(
120
,
120
,
120
),
Text
=
"只读预览(Esc 关闭)· "
+
file
,
AutoEllipsis
=
true
,
};
this
.
Controls
.
Add
(
_box
);
this
.
Controls
.
Add
(
_hint
);
this
.
Controls
.
Add
(
tip
);
this
.
KeyDown
+=
(
s
,
e
)
=>
{
if
(
e
.
KeyCode
==
Keys
.
Escape
)
this
.
Close
();
};
this
.
FormClosed
+=
(
s
,
e
)
=>
{
try
{
if
(
_box
.
Font
!=
null
)
_box
.
Font
.
Dispose
();
}
catch
{
}
try
{
if
(
_box
!=
null
)
_box
.
Dispose
();
}
catch
{
}
};
}
/// <summary>
/// 打开文件并定位到指定行(line ≤ 0 或超出范围时自动夹到首/末行)。
/// <para>读取失败不抛异常:窗口内显示失败原因,便于用户判断是「引用错了」还是「文件不可读」。</para>
/// </summary>
public
static
DocLineViewer
ShowLine
(
string
file
,
int
line
,
Form
owner
=
null
)
{
string
text
=
ReadAllTextSafe
(
file
);
var
v
=
new
DocLineViewer
(
file
,
line
,
text
);
if
(
owner
!=
null
&&
owner
.
IsHandleCreated
&&
owner
.
Visible
)
v
.
Owner
=
owner
;
v
.
Show
();
v
.
Activate
();
v
.
GotoLine
(
line
);
return
v
;
}
/// <summary>定位并高亮第 line 行(1 基),同时刷新底部提示</summary>
private
void
GotoLine
(
int
line
)
{
try
{
int
total
=
_box
.
Lines
.
Length
;
if
(
total
<=
0
)
{
_hint
.
Text
=
"(空文件)"
;
return
;
}
int
target
=
line
<
1
?
1
:
(
line
>
total
?
total
:
line
);
int
first
=
_box
.
GetFirstCharIndexFromLine
(
target
-
1
);
if
(
first
<
0
)
first
=
0
;
_box
.
Select
(
first
,
0
);
_box
.
ScrollToCaret
();
// 先滚到行首,避免长行末尾跳位
int
len
=
0
;
try
{
len
=
_box
.
Lines
[
target
-
1
].
Length
;
}
catch
{
}
if
(
len
>
0
)
{
_box
.
Select
(
first
,
len
);
_box
.
SelectionBackColor
=
HighlightColor
;
}
_box
.
Select
(
first
,
0
);
_box
.
ScrollToCaret
();
_hint
.
Text
=
_clipped
?
$"已定位到第
{
target
}
行 · 共
{
_totalLines
}
行(仅展示前
{
MaxShowLines
}
行)"
:
$"已定位到第
{
target
}
行 · 共
{
_totalLines
}
行"
;
}
catch
(
Exception
ex
)
{
_hint
.
Text
=
"定位失败:"
+
ex
.
Message
;
}
}
/// <summary>读取文本(复用知识库的编码自动识别,兼容 GBK/UTF-8/UTF-16;失败返回提示文本而非抛异常)</summary>
private
static
string
ReadAllTextSafe
(
string
file
)
{
try
{
if
(
string
.
IsNullOrEmpty
(
file
)
||
!
File
.
Exists
(
file
))
return
"(文件不存在或不可访问:"
+
file
+
")"
;
string
text
=
Tools
.
MyAgent
.
Kb
.
KbService
.
ReadTextFile
(
file
);
if
(
text
!=
null
)
return
text
;
using
(
var
fs
=
new
FileStream
(
file
,
FileMode
.
Open
,
FileAccess
.
Read
,
FileShare
.
ReadWrite
))
using
(
var
sr
=
new
StreamReader
(
fs
,
Encoding
.
UTF8
,
true
))
return
sr
.
ReadToEnd
();
}
catch
(
Exception
ex
)
{
return
"(读取失败:"
+
ex
.
Message
+
")"
;
}
}
private
static
string
SafeFileName
(
string
file
)
{
try
{
return
Path
.
GetFileName
(
file
);
}
catch
{
return
file
??
""
;
}
}
}
}
Tools.Agent/Forms/ThinkingSpinner.cs
0 → 100644
View file @
8d4b5d0b
using
System
;
using
System.Drawing
;
using
System.Drawing.Drawing2D
;
using
System.Windows.Forms
;
namespace
Tools.MyAgent.Forms
{
/// <summary>
/// 思考旋转指示器(纯代码构建,无 Designer / resx 依赖)。
/// <para>24×24 的小控件,Timer(45ms) 驱动一段 240° 渐变弧旋转,用于替代「把思维链文本铺满问答区」的旧形态。</para>
/// <para>用法:<see cref="Thinking"/>=true 时显示并旋转;=false 时停止计时器并隐藏(不占布局、不耗帧)。</para>
/// </summary>
public
sealed
class
ThinkingSpinner
:
Control
{
/// <summary>逻辑尺寸(未按 DPI 缩放前)</summary>
private
const
int
BaseSize
=
24
;
/// <summary>动画帧间隔(毫秒)</summary>
private
const
int
TickMs
=
45
;
/// <summary>弧线张角</summary>
private
const
int
SweepDegrees
=
240
;
/// <summary>拖尾分段数(越多越平滑)</summary>
private
const
int
Segments
=
24
;
/// <summary>每帧角度增量</summary>
private
const
int
AngleStep
=
12
;
private
readonly
Timer
_timer
;
private
int
_angle
;
private
bool
_thinking
;
private
float
_scale
=
1f
;
public
ThinkingSpinner
()
{
SetStyle
(
ControlStyles
.
UserPaint
|
ControlStyles
.
AllPaintingInWmPaint
|
ControlStyles
.
OptimizedDoubleBuffer
|
ControlStyles
.
ResizeRedraw
,
true
);
// 与 panelChat / RichTextBox 一致的白底:避免半透明混色残影,也不依赖父容器重绘
BackColor
=
Color
.
White
;
TabStop
=
false
;
Visible
=
false
;
Size
=
new
Size
(
BaseSize
,
BaseSize
);
_timer
=
new
Timer
{
Interval
=
TickMs
};
_timer
.
Tick
+=
OnTick
;
}
/// <summary>是否处于思考中(true:显示并旋转;false:隐藏并停止计时器)</summary>
public
bool
Thinking
{
get
{
return
_thinking
;
}
set
{
if
(
_thinking
==
value
)
return
;
_thinking
=
value
;
if
(
value
)
{
_angle
=
0
;
Visible
=
true
;
_timer
.
Start
();
}
else
{
_timer
.
Stop
();
Visible
=
false
;
}
Invalidate
();
}
}
private
void
OnTick
(
object
sender
,
EventArgs
e
)
{
_angle
=
(
_angle
+
AngleStep
)
%
360
;
Invalidate
();
}
protected
override
void
OnHandleCreated
(
EventArgs
e
)
{
base
.
OnHandleCreated
(
e
);
ApplyScale
();
}
protected
override
void
OnPaint
(
PaintEventArgs
e
)
{
base
.
OnPaint
(
e
);
if
(!
_thinking
)
return
;
var
g
=
e
.
Graphics
;
g
.
SmoothingMode
=
SmoothingMode
.
AntiAlias
;
float
scale
=
_scale
<=
0f
?
1f
:
_scale
;
float
penWidth
=
Math
.
Max
(
1.5f
,
3f
*
scale
);
float
radius
=
(
BaseSize
-
8
)
*
scale
/
2f
;
if
(
radius
<=
1f
)
return
;
float
cx
=
ClientSize
.
Width
/
2f
;
float
cy
=
ClientSize
.
Height
/
2f
;
var
rect
=
new
RectangleF
(
cx
-
radius
,
cy
-
radius
,
radius
*
2f
,
radius
*
2f
);
// 拖尾渐隐:240° 均分 24 段,i=0 为弧头(最亮),随 i 增大透明度下降形成尾迹
float
segSweep
=
(
float
)
SweepDegrees
/
Segments
;
for
(
int
i
=
0
;
i
<
Segments
;
i
++)
{
int
alpha
=
(
int
)(
255
*
(
1.0
-
(
double
)
i
/
Segments
)
*
0.9
)
+
20
;
if
(
alpha
>
255
)
alpha
=
255
;
if
(
alpha
<
12
)
alpha
=
12
;
using
(
var
pen
=
new
Pen
(
Color
.
FromArgb
(
alpha
,
0
,
120
,
212
),
penWidth
))
{
pen
.
StartCap
=
LineCap
.
Round
;
pen
.
EndCap
=
LineCap
.
Round
;
// 从弧头向后逐段绘制;+0.6° 交叠,避免 AntiAlias 下分段间出现细缝
float
start
=
_angle
+
SweepDegrees
-
(
i
+
1
)
*
segSweep
;
g
.
DrawArc
(
pen
,
rect
,
start
,
segSweep
+
0.6f
);
}
}
}
/// <summary>按当前 DPI 计算缩放并同步控件尺寸(高 DPI 下弧线不糊、占位不偏)</summary>
private
void
ApplyScale
()
{
try
{
using
(
var
g
=
CreateGraphics
())
_scale
=
g
.
DpiX
>
0
?
g
.
DpiX
/
96f
:
1f
;
}
catch
{
_scale
=
1f
;
}
Size
=
new
Size
(
Scaled
(
BaseSize
),
Scaled
(
BaseSize
));
}
private
int
Scaled
(
int
v
)
{
float
scale
=
_scale
<=
0f
?
1f
:
_scale
;
return
(
int
)
Math
.
Ceiling
(
v
*
scale
);
}
protected
override
void
Dispose
(
bool
disposing
)
{
if
(
disposing
&&
_timer
!=
null
)
{
_timer
.
Stop
();
_timer
.
Tick
-=
OnTick
;
_timer
.
Dispose
();
}
base
.
Dispose
(
disposing
);
}
}
}
Tools.Agent/Kb/ContentHit.cs
0 → 100644
View file @
8d4b5d0b
namespace
Tools.MyAgent.Kb
{
/// <summary>
/// 结构化内容命中(P2 回答形态改造):携带匹配度与行号,供引擎侧「本地召回直答」通道打分与引用定位使用。
/// <para>与 kb_grep 的既有文本返回格式互不影响 —— 后者面向模型(格式即数据),本类型面向引擎(可计算、可定位)。</para>
/// </summary>
public
sealed
class
ContentHit
{
/// <summary>知识库内相对路径('/' 分隔;与 kb_* 工具输出一致,便于引用行直接落地)</summary>
public
string
Rel
=
""
;
/// <summary>命中文段(单行化,≤140 字)</summary>
public
string
Snippet
=
""
;
/// <summary>命中行号(1 基;0 = 无法定位,如 pdf/pptx 等无行概念的格式)</summary>
public
int
Line
;
/// <summary>
/// 该来源的证据基分(0~1):index 0.60 / scan 0.70 / synonym = index×0.85 / fuzzy 取模糊分原值。
/// <para>路由基分留在本层(只有本层知道命中的真实来源);上层据此叠加查询词覆盖率与多路共现加分。</para>
/// </summary>
public
double
Base
;
/// <summary>来源:index / scan / synonym / fuzzy(供共现加分与轨迹展示)</summary>
public
string
Source
=
""
;
}
}
Tools.Agent/Kb/KbService.cs
View file @
8d4b5d0b
...
...
@@ -1282,6 +1282,219 @@ namespace Tools.MyAgent.Kb
#
endregion
#
region
P2
结构化召回(回答形态改造:本地直答通道专用)
/// <summary>语义检索是否已启用(P2 召回通道据此决定是否尝试向量召回)</summary>
public
bool
SemanticEnabled
{
get
{
return
_semanticEnabled
;
}
}
/// <summary>
/// 检索索引当前是否可服务查询(已就绪且覆盖搜索范围)。
/// <para>调用方据此决定回退策略:索引不可服务时每次查询都会退化为全盘扫描,必须限制关键词数量(见 KbRecall)。</para>
/// </summary>
public
bool
IsIndexServing
{
get
{
try
{
return
_index
!=
null
&&
_index
.
IsReady
&&
IndexCoversScope
();
}
catch
{
return
false
;
}
}
}
/// <summary>
/// 结构化内容召回(P2):按「内容索引 → 全盘扫描 → 同义词扩展 → 文件名模糊」逐级兜底,
/// 返回带行号与来源基分的命中。
/// <para>匹配度(融合分)由上层 <c>KbRecall</c> 按「来源基分 + 查询词覆盖率 + 多路共现」折算 ——
/// 只有上层才知道全部关键词与全部来源,单次调用无法算覆盖率。</para>
/// <para>注意:索引为短语匹配(整串须连续出现),传入的应是**关键词**而非自然语言整句。</para>
/// </summary>
/// <param name="keyword">检索关键词(非整句;整句会导致短语匹配 0 命中)</param>
/// <param name="maxResults">最多命中文件数(1~20)</param>
/// <param name="ct">取消令牌(全盘扫描路径在枚举循环内协作式终止)</param>
/// <param name="allowFullScan">
/// 是否允许在索引 0 命中时回退全盘扫描(默认 true)。
/// <para>索引已服务时全盘扫描属冗余成本(索引本身覆盖可抽取文本),上层按关键词序位只对最具体的前 2 个词放行。</para>
/// </param>
public
List
<
ContentHit
>
RecallContent
(
string
keyword
,
int
maxResults
,
CancellationToken
ct
=
default
,
bool
allowFullScan
=
true
)
{
var
list
=
new
List
<
ContentHit
>();
if
(
string
.
IsNullOrWhiteSpace
(
keyword
))
return
list
;
if
(
maxResults
<
1
)
maxResults
=
1
;
if
(
maxResults
>
20
)
maxResults
=
20
;
if
(!
IsSearchValid
)
return
list
;
keyword
=
keyword
.
Trim
();
// ① 内容索引(最快;覆盖 docx/xlsx/pdf/pptx 抽取文本)
if
(
IsIndexServing
&&
!
RegexMetachar
.
IsMatch
(
keyword
))
{
List
<(
string
Path
,
string
Snippet
)>
indexed
=
null
;
try
{
indexed
=
_index
.
SearchContent
(
keyword
,
null
,
maxResults
);
}
catch
{
indexed
=
null
;
}
if
(
indexed
!=
null
&&
indexed
.
Count
>
0
)
{
foreach
(
var
h
in
indexed
)
{
string
abs
=
Path
.
Combine
(
Root
,
h
.
Path
);
string
rel
=
RelOf
(
abs
);
if
(
rel
==
null
)
continue
;
list
.
Add
(
new
ContentHit
{
Rel
=
rel
,
Snippet
=
OneLine
(
h
.
Snippet
),
Line
=
0
,
// 索引只存内容不存行号;需要行号时由上层按需定位(LocateLine)
Base
=
0.60
,
Source
=
"index"
,
});
}
if
(
list
.
Count
>
0
)
return
list
;
}
}
// ② 全盘扫描(索引不可服务/无命中时的兜底;成本高,上层需限制关键词数量)
if
(
allowFullScan
)
{
var
scanned
=
ScanContentHits
(
keyword
,
null
,
maxResults
,
ct
);
if
(
scanned
!=
null
&&
scanned
.
Count
>
0
)
{
foreach
(
var
h
in
scanned
)
{
string
abs
=
Path
.
Combine
(
Root
,
h
.
Path
);
string
rel
=
RelOf
(
abs
);
if
(
rel
==
null
)
continue
;
list
.
Add
(
new
ContentHit
{
Rel
=
rel
,
Snippet
=
OneLine
(
h
.
Snippet
),
Line
=
LocateLine
(
abs
,
keyword
),
Base
=
0.70
,
Source
=
"scan"
,
});
}
if
(
list
.
Count
>
0
)
return
list
;
}
}
// ③ 同义词/术语扩展(字面 0 命中时):非直接命中,基分按索引基分 ×0.85 折减。
// 注意:SearchWithSynonyms 返回的元组字段名为 Rel(相对 PathBase),不是 Path
var
syn
=
SearchWithSynonyms
(
keyword
,
null
,
maxResults
,
ct
);
if
(
syn
!=
null
&&
syn
.
Count
>
0
)
{
foreach
(
var
h
in
syn
)
{
string
abs
=
Path
.
Combine
(
Root
,
h
.
Rel
);
string
rel
=
RelOf
(
abs
);
if
(
rel
==
null
)
continue
;
list
.
Add
(
new
ContentHit
{
Rel
=
rel
,
Snippet
=
OneLine
(
h
.
Snippet
),
Line
=
LocateLine
(
abs
,
keyword
),
Base
=
0.60
*
0.85
,
Source
=
"synonym"
,
});
}
if
(
list
.
Count
>
0
)
return
list
;
}
// ④ 文件名模糊(最后兜底):基分取模糊分原值,仅作补充证据(不单凭文件名进入直答档)
int
scannedNames
=
0
;
foreach
(
var
file
in
SafeEnumerateFiles
(
SearchRoot
,
ct
))
{
ct
.
ThrowIfCancellationRequested
();
if
(++
scannedNames
>
2000
)
break
;
double
fuzzy
=
FuzzyScore
(
keyword
,
Path
.
GetFileNameWithoutExtension
(
file
));
if
(
fuzzy
<
0.3
)
continue
;
string
rel
=
RelOf
(
file
);
if
(
rel
==
null
)
continue
;
list
.
Add
(
new
ContentHit
{
Rel
=
rel
,
Snippet
=
""
,
Line
=
0
,
Base
=
Math
.
Min
(
fuzzy
,
0.70
),
// 封顶 0.70:文件名匹配不足以单独触发直答
Source
=
"fuzzy"
,
});
if
(
list
.
Count
>=
maxResults
)
break
;
}
return
list
;
}
/// <summary>
/// 语义召回(P2):查询向量化 → 索引向量余弦 Top-K,返回 (相对路径, 相似度)。
/// <para>依赖 embedding 服务与索引向量;任一不可用返回空列表(不影响关键词路径)。</para>
/// </summary>
public
List
<(
string
Rel
,
double
Score
)>
RecallSemantic
(
string
query
,
int
maxResults
,
CancellationToken
ct
=
default
)
{
var
result
=
new
List
<(
string
,
double
)>();
if
(!
_semanticEnabled
||
string
.
IsNullOrWhiteSpace
(
query
))
return
result
;
try
{
ct
.
ThrowIfCancellationRequested
();
var
qv
=
_emb
.
EmbedAsync
(
query
,
ct
).
GetAwaiter
().
GetResult
();
if
(
qv
==
null
)
return
result
;
var
hits
=
_index
?.
SearchSemantic
(
qv
,
maxResults
);
if
(
hits
==
null
)
return
result
;
foreach
(
var
h
in
hits
)
{
string
abs
=
Path
.
Combine
(
Root
,
h
.
Path
);
string
rel
=
RelOf
(
abs
);
if
(
rel
==
null
)
continue
;
result
.
Add
((
rel
,
h
.
Score
));
}
}
catch
(
OperationCanceledException
)
{
throw
;
}
catch
{
}
return
result
;
}
/// <summary>命中关键词所在行号(1 基;0 = 无法定位:非文本格式、文件不可读或未字面命中)</summary>
internal
static
int
LocateLine
(
string
absPath
,
string
keyword
)
{
try
{
if
(
string
.
IsNullOrEmpty
(
absPath
)
||
string
.
IsNullOrEmpty
(
keyword
))
return
0
;
string
ext
=
Path
.
GetExtension
(
absPath
).
ToLowerInvariant
();
if
(!
TextExtensions
.
Contains
(
ext
))
return
0
;
string
text
=
ReadTextFile
(
absPath
);
if
(
string
.
IsNullOrEmpty
(
text
))
return
0
;
int
idx
=
text
.
IndexOf
(
keyword
,
StringComparison
.
OrdinalIgnoreCase
);
if
(
idx
<
0
)
return
0
;
int
line
=
1
;
for
(
int
i
=
0
;
i
<
idx
;
i
++)
if
(
text
[
i
]
==
'\n'
)
line
++;
return
line
;
}
catch
{
return
0
;
}
}
/// <summary>命中路径 → 引用用相对路径(超出搜索范围 / 不在知识库允许前缀内返回 null)</summary>
private
string
RelOf
(
string
absPath
)
{
try
{
var
norm
=
NormPath
(
absPath
);
if
(!
IsWithin
(
NormPath
(
SearchRoot
),
norm
))
return
null
;
// 限定搜索范围过滤
if
(!
IsConfiguredFileAllowed
(
absPath
))
return
null
;
// 产品知识库允许/排除前缀过滤
return
ToRelative
(
PathBase
,
absPath
).
Replace
(
'\\'
,
'/'
);
}
catch
{
return
null
;
}
}
/// <summary>片段单行化 + 截断(供引用行与证据消息使用)</summary>
private
static
string
OneLine
(
string
s
)
{
if
(
string
.
IsNullOrEmpty
(
s
))
return
""
;
s
=
s
.
Replace
(
'\r'
,
' '
).
Replace
(
'\n'
,
' '
).
Trim
();
return
s
.
Length
<=
140
?
s
:
s
.
Substring
(
0
,
140
);
}
#
endregion
#
region
kb_read
——
读取文件内容
/// <summary>文档地图(#11):提取 docx/markdown 等文档的标题层级目录(行号|层级|标题),供 kb_read outline 模式与定位精读使用</summary>
...
...
Tools.Agent/Support/ChatMessage.cs
View file @
8d4b5d0b
...
...
@@ -22,5 +22,17 @@ namespace Tools.MyAgent
/// <summary>回复末尾用量标注(展示用,不参与模型上下文/历史导出):如「本回复 1284+312 tokens(约 ¥0.42)」</summary>
public
string
UsageSuffix
{
get
;
set
;
}
/// <summary>
/// 回答附图(P5):本轮回答命中的知识库关联图片(本地绝对路径,已过沙箱校验)。
/// <para>仅助手消息使用;旧会话缺该字段 = <c>null</c>,渲染走默认分支(无需迁移代码)。</para>
/// </summary>
public
List
<
string
>
AnswerImages
{
get
;
set
;
}
/// <summary>
/// 本轮回答形态(P5):<c>kb</c>(知识库直答)/ <c>mixed</c>(部分命中,含模型补充)。
/// <para>留空表示走原工具循环(形态未标注)。</para>
/// </summary>
public
string
AnswerMode
{
get
;
set
;
}
}
}
Tools.Agent/Support/ChatRtfCache.cs
View file @
8d4b5d0b
...
...
@@ -78,9 +78,14 @@ namespace Tools.MyAgent
private
static
string
MessageKey
(
ChatMessage
m
,
string
assistantName
,
string
userName
,
bool
detailsExpanded
)
{
// 思考文本仅在「仍渲染思考」(MarkdownRenderer.ShowThinking=true)时参与缓存键:
// 默认形态下思考不进问答区,若仍把它计入键,流式思考增量会每帧击穿缓存、整块重建 RTF(CPU 白耗)
string
thinking
=
MarkdownRenderer
.
ShowThinking
?
(
m
.
ThinkingContent
??
""
)
:
""
;
return
(
m
.
Role
??
""
)
+
"|"
+
m
.
Timestamp
.
Ticks
+
"|"
+
(
m
.
Content
??
""
)
+
"|"
+
(
m
.
ThinkingContent
??
""
)
+
"|"
+
m
.
IsStreaming
+
(
m
.
Content
??
""
)
+
"|"
+
thinking
+
"|"
+
m
.
IsStreaming
+
"|"
+
(
m
.
UsageSuffix
??
""
)
// 用量标注:完成时附加,需纳入缓存键以触发重绘
+
"|"
+
(
m
.
AnswerMode
??
""
)
// P5 回答形态标注:直答完成时附加,同上
+
"|"
+
(
m
.
AnswerImages
!=
null
?
m
.
AnswerImages
.
Count
:
0
)
// 形态标注里含图片数
+
"|"
+
assistantName
+
"|"
+
userName
+
"|"
+
detailsExpanded
;
}
}
...
...
Tools.Agent/Support/MarkdownRenderer.cs
View file @
8d4b5d0b
...
...
@@ -71,6 +71,13 @@ namespace Tools.MyAgent
"type "
,
"find "
,
"findstr "
,
"where "
,
"set "
,
"echo "
,
"cls "
};
/// <summary>
/// 是否仍在问答区渲染思考文本(由宿主按配置注入,默认 false)。
/// <para>默认形态:思考只由右下旋转指示表达,不再把思维链整块铺进问答区(也避免流式每帧重绘)。
/// 置 true 恢复旧行为,便于调试/排查模型思维链。</para>
/// </summary>
public
static
bool
ShowThinking
{
get
;
set
;
}
public
static
string
BuildChatRtf
(
List
<
ChatMessage
>
messages
,
string
welcomeText
,
string
assistantName
=
"Claude"
,
string
userName
=
"You"
)
{
var
sb
=
new
StringBuilder
();
...
...
@@ -114,6 +121,25 @@ namespace Tools.MyAgent
return
sb
.
ToString
();
}
/// <summary>
/// P5 回答形态标注文案(展示用,不进入模型上下文/历史导出):
/// <c>kb</c> → 知识库直答;<c>mixed</c> → 知识库部分命中(含模型补充);
/// 未标注(走原工具循环)或未知取值返回空串。附带关联图片数,便于对上下方附图轨属于哪一轮。
/// </summary>
private
static
string
AnswerModeBadge
(
ChatMessage
msg
)
{
string
mode
=
msg
.
AnswerMode
??
""
;
string
head
;
if
(
string
.
Equals
(
mode
,
"kb"
,
StringComparison
.
OrdinalIgnoreCase
))
head
=
"知识库直答"
;
else
if
(
string
.
Equals
(
mode
,
"mixed"
,
StringComparison
.
OrdinalIgnoreCase
))
head
=
"知识库部分命中(含模型补充)"
;
else
return
""
;
int
n
=
msg
.
AnswerImages
!=
null
?
msg
.
AnswerImages
.
Count
:
0
;
return
n
>
0
?
"📚 "
+
head
+
" · "
+
n
+
" 图"
:
"📚 "
+
head
;
}
private
static
void
RenderMessage
(
StringBuilder
sb
,
ChatMessage
msg
,
string
assistantName
,
string
userName
,
bool
expandDetails
=
false
)
{
bool
isUser
=
msg
.
Role
==
"user"
;
...
...
@@ -127,6 +153,16 @@ namespace Tools.MyAgent
sb
.
Append
(
ClTs
);
sb
.
Append
(
@"\fs16 "
);
sb
.
Append
(
msg
.
Timestamp
.
ToString
(
"HH:mm:ss"
));
// P5 回答形态标注:让用户一眼分清「知识库直答」与「含模型补充」,
// 与回答正文里的引用行互为印证(未走直答的轮次不显示任何标注)
string
badge
=
AnswerModeBadge
(
msg
);
if
(
badge
.
Length
>
0
)
{
sb
.
Append
(
@"\cf"
);
sb
.
Append
(
ClBq
);
sb
.
Append
(
@" · "
);
sb
.
Append
(
Escape
(
badge
));
sb
.
Append
(
@"\cf"
);
sb
.
Append
(
ClTs
);
}
sb
.
Append
(
@"\par"
);
// Content
...
...
@@ -141,7 +177,8 @@ namespace Tools.MyAgent
}
else
{
if
(!
string
.
IsNullOrEmpty
(
msg
.
ThinkingContent
))
// 思考文本:默认不渲染(改由 wMyAgent 右下旋转指示表达);ShowThinking=true 时恢复旧行为(兜底/调试开关)
if
(
ShowThinking
&&
!
string
.
IsNullOrEmpty
(
msg
.
ThinkingContent
))
{
sb
.
Append
(
@"\cf"
);
sb
.
Append
(
ClThinking
);
sb
.
Append
(
@"\i\fs18 "
);
...
...
Tools.Agent/Tools.Agent.csproj
View file @
8d4b5d0b
...
...
@@ -101,6 +101,8 @@
<Compile
Include=
"Agent\FailoverPolicy.cs"
/>
<Compile
Include=
"Agent\IOpenAiChatClient.cs"
/>
<Compile
Include=
"Agent\OpenAiCompatibleClient.cs"
/>
<Compile
Include=
"Agent\Answer\KbRecall.cs"
/>
<Compile
Include=
"Agent\Answer\AnswerSpec.cs"
/>
<Compile
Include=
"Agent\PromptBuilder.cs"
/>
<Compile
Include=
"Agent\ToolSchema.cs"
/>
<Compile
Include=
"Agent\Tools\ITool.cs"
/>
...
...
@@ -120,11 +122,28 @@
<Compile
Include=
"Config\AgentConfig.cs"
/>
<Compile
Include=
"Config\RoleTemplateStore.cs"
/>
<Compile
Include=
"Config\SecretProtector.cs"
/>
<Compile
Include=
"Forms\AgentHistoryForm.cs"
><SubType>
Form
</SubType></Compile>
<Compile
Include=
"Forms\AgentHistoryForm.Designer.cs"
><DependentUpon>
AgentHistoryForm.cs
</DependentUpon></Compile>
<Compile
Include=
"Forms\AgentSettingsForm.cs"
><SubType>
Form
</SubType></Compile>
<Compile
Include=
"Forms\AgentSettingsForm.Designer.cs"
><DependentUpon>
AgentSettingsForm.cs
</DependentUpon></Compile>
<Compile
Include=
"Forms\wBitmapView.cs"
><SubType>
Form
</SubType></Compile>
<Compile
Include=
"Forms\AgentHistoryForm.cs"
>
<SubType>
Form
</SubType>
</Compile>
<Compile
Include=
"Forms\AgentHistoryForm.Designer.cs"
>
<DependentUpon>
AgentHistoryForm.cs
</DependentUpon>
</Compile>
<Compile
Include=
"Forms\AgentSettingsForm.cs"
>
<SubType>
Form
</SubType>
</Compile>
<Compile
Include=
"Forms\AgentSettingsForm.Designer.cs"
>
<DependentUpon>
AgentSettingsForm.cs
</DependentUpon>
</Compile>
<Compile
Include=
"Forms\DocLineViewer.cs"
>
<SubType>
Form
</SubType>
</Compile>
<Compile
Include=
"Forms\ThinkingSpinner.cs"
>
<SubType>
Component
</SubType>
</Compile>
<Compile
Include=
"Forms\wBitmapView.cs"
>
<SubType>
Form
</SubType>
</Compile>
<Compile
Include=
"Kb\ContentHit.cs"
/>
<Compile
Include=
"Kb\DialectService.cs"
/>
<Compile
Include=
"Kb\EmbeddingClient.cs"
/>
<Compile
Include=
"Kb\FileParsers.cs"
/>
...
...
@@ -142,15 +161,27 @@
<Compile
Include=
"Support\CostEstimator.cs"
/>
<Compile
Include=
"Support\ImageSearch.cs"
/>
<Compile
Include=
"Support\MarkdownRenderer.cs"
/>
<Compile
Include=
"Support\NoCaretRichTextBox.cs"
><SubType>
Component
</SubType></Compile>
<Compile
Include=
"Support\NoCaretRichTextBox.cs"
>
<SubType>
Component
</SubType>
</Compile>
<Compile
Include=
"Support\StorageCleanup.cs"
/>
<Compile
Include=
"Support\TextEncoding.cs"
/>
<Compile
Include=
"Support\TextPatterns.cs"
/>
<Compile
Include=
"wMyAgent.cs"
><SubType>
Form
</SubType></Compile>
<Compile
Include=
"wMyAgent.Designer.cs"
><DependentUpon>
wMyAgent.cs
</DependentUpon></Compile>
<EmbeddedResource
Include=
"Forms\AgentHistoryForm.resx"
><DependentUpon>
AgentHistoryForm.cs
</DependentUpon></EmbeddedResource>
<EmbeddedResource
Include=
"Forms\AgentSettingsForm.resx"
><DependentUpon>
AgentSettingsForm.cs
</DependentUpon></EmbeddedResource>
<EmbeddedResource
Include=
"wMyAgent.resx"
><DependentUpon>
wMyAgent.cs
</DependentUpon></EmbeddedResource>
<Compile
Include=
"wMyAgent.cs"
>
<SubType>
Form
</SubType>
</Compile>
<Compile
Include=
"wMyAgent.Designer.cs"
>
<DependentUpon>
wMyAgent.cs
</DependentUpon>
</Compile>
<EmbeddedResource
Include=
"Forms\AgentHistoryForm.resx"
>
<DependentUpon>
AgentHistoryForm.cs
</DependentUpon>
</EmbeddedResource>
<EmbeddedResource
Include=
"Forms\AgentSettingsForm.resx"
>
<DependentUpon>
AgentSettingsForm.cs
</DependentUpon>
</EmbeddedResource>
<EmbeddedResource
Include=
"wMyAgent.resx"
>
<DependentUpon>
wMyAgent.cs
</DependentUpon>
</EmbeddedResource>
<EmbeddedResource
Include=
"Resources\cn_synonym.txt"
>
<LogicalName>
Tools.Agent.Resources.cn_synonym.txt
</LogicalName>
</EmbeddedResource>
...
...
@@ -174,6 +205,21 @@
<None
Include=
"THIRD-PARTY-NOTICES.txt"
/>
<None
Include=
"packages.config"
/>
</ItemGroup>
<ItemGroup>
<Folder
Include=
"Agent\dialect\erp\cases\"
/>
<Folder
Include=
"Agent\dialect\mes\cases\"
/>
<Folder
Include=
"Agent\dialect\pdm\cases\"
/>
<Folder
Include=
"Agent\dialect\sql-core\"
/>
<Folder
Include=
"Agent\dialect\troubleshoot-core\"
/>
<Folder
Include=
"Agent\industry\鞋业\01-品类认知\"
/>
<Folder
Include=
"Agent\industry\鞋业\02-生产工艺\"
/>
<Folder
Include=
"Agent\industry\鞋业\03-材料与BOM\"
/>
<Folder
Include=
"Agent\industry\鞋业\04-业务与贸易\"
/>
<Folder
Include=
"Agent\industry\鞋业\05-质量管理\"
/>
<Folder
Include=
"Agent\industry\鞋业\06-专业术语\"
/>
<Folder
Include=
"Agent\industry\鞋业\07-常见问题\"
/>
<Folder
Include=
"Agent\prompts\"
/>
</ItemGroup>
<Import
Project=
"$(MSBuildToolsPath)\Microsoft.CSharp.targets"
/>
<Import
Project=
"..\packages\Stub.System.Data.SQLite.Core.NetFramework.1.0.115.5\build\net45\Stub.System.Data.SQLite.Core.NetFramework.targets"
Condition=
"Exists('..\packages\Stub.System.Data.SQLite.Core.NetFramework.1.0.115.5\build\net45\Stub.System.Data.SQLite.Core.NetFramework.targets')"
/>
</Project>
</Project>
\ No newline at end of file
Tools.Agent/wMyAgent.cs
View file @
8d4b5d0b
...
...
@@ -28,6 +28,8 @@ namespace Tools.MyAgent
private
readonly
ChatRtfCache
_rtfCache
=
new
ChatRtfCache
();
/// <summary>聊天区控件:RichTextBox(RichBox,RTF 渲染)</summary>
private
RichTextBox
_rtfFallback
;
/// <summary>思考中旋转指示器(右下角悬浮;替代「思考文本铺满问答区」)</summary>
private
Forms
.
ThinkingSpinner
_spinner
;
private
AgentConfig
_config
=
new
AgentConfig
();
private
CancellationTokenSource
_sendCts
;
private
volatile
bool
_isClosing
;
...
...
@@ -38,6 +40,10 @@ namespace Tools.MyAgent
private
readonly
List
<
string
>
_pendingImages
=
new
List
<
string
>();
/// <summary>v2.3 待发送附图缩略图栏(问答区左下角悬浮;每张图右上角叉号可取消)</summary>
private
FlowLayoutPanel
_pendingBar
;
/// <summary>P5 回答附图列表:本轮直答命中的知识库关联图片;仅在直答命中知识库时非空</summary>
private
readonly
List
<
string
>
_answerImages
=
new
List
<
string
>();
/// <summary>P5 回答附图轨(问答区左下角悬浮,纵向叠在待发送附图栏之上;只读展示,无删除按钮)</summary>
private
FlowLayoutPanel
_answerImageBar
;
private
readonly
List
<
string
>
_producedFiles
=
new
List
<
string
>();
/// <summary>本轮回复用量基线快照(发送前记录引擎累计值;回复完成后按增量展示「本回复」用量)</summary>
private
int
_replyPromptBase
,
_replyCompletionBase
;
...
...
@@ -89,6 +95,31 @@ namespace Tools.MyAgent
@"https?://[^\s\r\n<]+"
,
RegexOptions
.
Compiled
);
/// <summary>
/// 匹配回答「引用」节的引用行(P4):📎 <知识库相对路径> §<章节> L<行号> 匹配度 <0.xx>。
/// <para>章节 / 行号 / 匹配度三段均可缺省(正则容错);路径用惰性匹配 + 扩展名锚点,允许路径含空格(中文知识库常见)。</para>
/// </summary>
private
static
readonly
Regex
AnswerRefRegex
=
new
Regex
(
@"📎\s*(.+?\.(?:md|markdown|txt|log|csv|tsv|json|xml|cs|sql|config|docx?|xlsx?|pdf|pptx?))"
+
@"(?:\s*§\s*(\S+))?(?:\s*L(\d+))?(?:\s*匹配度\s*([\d.]+))?"
,
RegexOptions
.
Compiled
);
/// <summary>回答引用行(P4):渲染文本中的位置 + 解析出的 相对路径 / 章节 / 行号,供点击定位</summary>
private
sealed
class
AnswerRef
{
public
int
Start
;
public
int
Length
;
public
string
Rel
;
public
string
Section
;
public
int
Line
;
}
/// <summary>
/// 当前渲染文本中的引用行表。每次 <see cref="RefreshChatRtf"/> 后清空重建(字符位置随重绘失效);
/// 流式期间不扫描(完成时算一次),避免每帧全文正则扫描的 O(n) 白耗。
/// </summary>
private
readonly
List
<
AnswerRef
>
_answerRefs
=
new
List
<
AnswerRef
>();
public
wMyAgent
()
:
this
(
null
,
null
,
null
)
{
}
...
...
@@ -130,6 +161,51 @@ namespace Tools.MyAgent
private
void
CreateChatControl
()
{
_rtfFallback
=
CreateRtfFallback
();
EnsureThinkingSpinner
();
EnsurePendingBar
();
// v2.3 待发送附图栏
EnsureAnswerImageBar
();
// P5 回答附图轨(纵向叠在待发送栏之上)
}
/// <summary>
/// 思考旋转指示器(右下角悬浮):思考中显示旋转弧,正文一到即隐藏。
/// <para>替代旧形态「把思维链文本整块渲染进问答区」;不再渲染思考文本的开关见 AgentConfig.ShowThinking。</para>
/// </summary>
private
void
EnsureThinkingSpinner
()
{
if
(
_spinner
!=
null
)
return
;
_spinner
=
new
Forms
.
ThinkingSpinner
();
panelChat
.
Controls
.
Add
(
_spinner
);
PositionThinkingSpinner
();
_spinner
.
BringToFront
();
panelChat
.
Resize
+=
(
s
,
e
)
=>
{
PositionThinkingSpinner
();
LayoutBottomBars
();
// P5:底部两条悬浮栏(回答附图 / 待发送附图)随尺寸重排
};
}
/// <summary>把旋转指示器贴到问答区右下角(距边 12px;Dock=Fill 的 RichTextBox 之上)</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 序更高;
// 窄窗口下栏体可能横跨到右下角把指示器压住,故每次定位都把指示器提到最前。
_spinner
.
BringToFront
();
}
/// <summary>
/// 思考指示三态机驱动:Idle → Thinking(收到思考且尚无正文)→ Streaming(首帧正文到达即隐藏)→ Idle。
/// <para>失败/取消路径由 OnAssistantComplete / OnEngineError 兜底置空闲,避免指示器残留。</para>
/// </summary>
private
void
SetThinking
(
bool
thinking
)
{
if
(
_spinner
==
null
||
_spinner
.
Thinking
==
thinking
)
return
;
_spinner
.
Thinking
=
thinking
;
PositionThinkingSpinner
();
}
private
RichTextBox
CreateRtfFallback
()
...
...
@@ -279,8 +355,17 @@ namespace Tools.MyAgent
BackupApiKey
=
SecretProtector
.
Protect
(
cfg
.
BackupApiKey
),
EnableOptionalTools
=
cfg
.
EnableOptionalTools
,
EnableImageInput
=
cfg
.
EnableImageInput
,
QuickAnswer
=
cfg
.
QuickAnswer
,
ConciseAnswer
=
cfg
.
ConciseAnswer
,
ShowThinking
=
cfg
.
ShowThinking
,
AnswerWithImages
=
cfg
.
AnswerWithImages
,
MaxAnswerImages
=
cfg
.
MaxAnswerImages
,
KbHitThreshold
=
cfg
.
KbHitThreshold
,
MaxToolRounds
=
cfg
.
MaxToolRounds
,
MaxHistoryMessages
=
cfg
.
MaxHistoryMessages
,
// 修复既有缺陷:CustomPrices(费用估算价格覆盖)此前未进白名单投影,
// 设置页保存一次即被静默清空(引擎 CostEstimator.Configure 读到空表 → 回退内置价格表)
CustomPrices
=
cfg
.
CustomPrices
??
new
Dictionary
<
string
,
string
>(),
DbConnections
=
protectedConns
,
ToolRoleAllow
=
cfg
.
ToolRoleAllow
??
new
Dictionary
<
string
,
string
>(),
};
...
...
@@ -340,6 +425,8 @@ namespace Tools.MyAgent
}
_engine
.
Configure
(
_config
);
_engine
.
SetOptionalTools
(
_config
.
EnableOptionalTools
);
// 回答形态(P1):思考是否仍渲染进问答区(默认 false → 只走右下旋转指示)
MarkdownRenderer
.
ShowThinking
=
_config
.
ShowThinking
;
// 工具级权限(P3):当前登录用户名即角色,用于 db_query/module_patch 等按角色开关
_engine
.
CurrentUser
=
string
.
IsNullOrEmpty
(
CurrentUser
)
?
"访客"
:
CurrentUser
;
// X2 插件化:扫描 Agent/plugins 目录加载第三方工具(幂等,失败仅记日志)
...
...
@@ -411,6 +498,7 @@ namespace Tools.MyAgent
_engine
.
OnToolStatus
+=
s
=>
PostToUi
(()
=>
OnToolStatusChanged
(
s
));
_engine
.
OnFileProduced
+=
f
=>
PostToUi
(()
=>
OnFileProduced
(
f
));
_engine
.
OnComplete
+=
()
=>
PostToUi
(
OnAssistantComplete
);
_engine
.
OnAnswerAttachments
+=
a
=>
PostToUi
(()
=>
OnAnswerAttachments
(
a
));
// P5:回答形态 + 关联图片
_engine
.
OnToolTrace
+=
trace
=>
PostToUi
(()
=>
OnToolTrace
(
trace
));
_engine
.
OnError
+=
m
=>
PostToUi
(()
=>
OnEngineError
(
m
));
_engine
.
OnStatusChanged
+=
()
=>
PostToUi
(
UpdateStatusIndicator
);
...
...
@@ -499,6 +587,101 @@ namespace Tools.MyAgent
_rtfFallback
.
Rtf
=
rtf
;
_rtfFallback
.
Select
(
_rtfFallback
.
TextLength
,
0
);
_rtfFallback
.
ScrollToCaret
();
RebuildAnswerRefs
();
// 引用行表随重绘重建(位置随重绘失效);流式期间跳过
}
/// <summary>
/// 重建引用行表(P4):从渲染文本中扫出 📎 引用行并解析 路径/章节/行号。
/// <para>时序纪律:① 每次重绘后重建,并与悬停缓存一起复位;② 流式期间不扫描(每帧全文正则是 O(n) 白耗),
/// 待回答完成(IsStreaming=false)的那次重绘算一次。</para>
/// </summary>
private
void
RebuildAnswerRefs
()
{
_answerRefs
.
Clear
();
_hoverStart
=
-
1
;
_hoverLength
=
0
;
_hoverOrigBack
=
Color
.
Empty
;
_lastHoverChar
=
-
1
;
// 重绘后字符位置全部失效,悬停缓存必须复位
try
{
for
(
int
i
=
0
;
i
<
_messages
.
Count
;
i
++)
if
(
_messages
[
i
].
IsStreaming
)
return
;
// 流式中:本轮不扫描
string
text
=
_rtfFallback
.
Text
;
if
(
string
.
IsNullOrEmpty
(
text
))
return
;
foreach
(
Match
m
in
AnswerRefRegex
.
Matches
(
text
))
{
string
rel
=
(
m
.
Groups
[
1
].
Value
??
""
).
Trim
().
Replace
(
'\\'
,
'/'
);
if
(
rel
.
Length
==
0
)
continue
;
int
line
=
0
;
if
(
m
.
Groups
[
3
].
Success
)
int
.
TryParse
(
m
.
Groups
[
3
].
Value
,
out
line
);
_answerRefs
.
Add
(
new
AnswerRef
{
Start
=
m
.
Index
,
Length
=
m
.
Length
,
Rel
=
rel
,
Section
=
m
.
Groups
[
2
].
Success
?
m
.
Groups
[
2
].
Value
:
""
,
Line
=
line
,
});
}
}
catch
{
_answerRefs
.
Clear
();
}
}
/// <summary>命中:鼠标位置是否落在某条引用行内(P4)</summary>
private
bool
TryGetAnswerRefAt
(
int
charIdx
,
out
AnswerRef
hit
)
{
hit
=
null
;
for
(
int
i
=
0
;
i
<
_answerRefs
.
Count
;
i
++)
{
var
r
=
_answerRefs
[
i
];
if
(
charIdx
>=
r
.
Start
&&
charIdx
<
r
.
Start
+
r
.
Length
)
{
hit
=
r
;
return
true
;
}
}
return
false
;
}
/// <summary>
/// 打开引用行(P4):知识库相对路径 → 内置行定位查看器(文本类)或默认程序(docx/xlsx/pdf 等);
/// 「dialect/」「industry/」开头的引用走内置方言根目录解析;均已过沙箱校验,越界直接提示。
/// </summary>
private
void
OpenAnswerRef
(
AnswerRef
r
)
{
if
(
r
==
null
||
string
.
IsNullOrEmpty
(
r
.
Rel
))
return
;
string
abs
=
null
;
bool
dialect
=
false
;
try
{
if
(
r
.
Rel
.
StartsWith
(
"dialect/"
,
StringComparison
.
OrdinalIgnoreCase
)
||
r
.
Rel
.
StartsWith
(
"industry/"
,
StringComparison
.
OrdinalIgnoreCase
))
{
var
svc
=
_engine
.
Dialect
;
abs
=
svc
==
null
?
null
:
svc
.
ResolveSafe
(
r
.
Rel
);
dialect
=
true
;
}
else
{
abs
=
_kb
.
ResolveSafe
(
r
.
Rel
);
}
}
catch
(
Exception
ex
)
{
AgentUtils
.
Toast
(
$"引用路径不可用:
{
r
.
Rel
}
\n
{
ex
.
Message
}
"
,
4000
);
return
;
}
if
(
string
.
IsNullOrEmpty
(
abs
)
||
!
File
.
Exists
(
abs
))
{
AgentUtils
.
Toast
(
dialect
?
$"内置方言中未找到该引用:
{
r
.
Rel
}
"
:
$"知识库中未找到该引用(可能已改名/移动,或超出当前搜索范围):
{
r
.
Rel
}
"
,
4000
);
return
;
}
// 文本类走内置行定位查看器(带行号才能定位);其余格式退回默认程序打开
if
(
r
.
Line
>
0
&&
Forms
.
DocLineViewer
.
CanView
(
abs
))
{
try
{
Forms
.
DocLineViewer
.
ShowLine
(
abs
,
r
.
Line
,
this
);
return
;
}
catch
(
Exception
ex
)
{
AgentUtils
.
Toast
(
"定位查看失败:"
+
ex
.
Message
,
4000
);
}
}
OpenLinkTarget
(
abs
);
}
/// <summary>指定消息的工具轨迹(<details>)是否展开</summary>
...
...
@@ -589,6 +772,7 @@ namespace Tools.MyAgent
_messages
.
Add
(
new
ChatMessage
{
Role
=
"assistant"
,
Content
=
""
,
IsStreaming
=
true
});
_sessionSaved
=
false
;
// 对话内容已变化,下次新对话需重新记录历史
_producedFiles
.
Clear
();
// 新一轮开始,清空上轮输出文件
ClearAnswerImages
();
// P5:新一轮开始清空上轮回答附图轨(本轮到图时再填充)
RefreshChat
();
txtInput
.
Focus
();
...
...
@@ -627,6 +811,7 @@ namespace Tools.MyAgent
_expandedDetails
.
Clear
();
_pendingImages
.
Clear
();
// 新对话:清空待发送附图
RefreshPendingBar
();
ClearAnswerImages
();
// P5:新对话清空回答附图轨
_sessionSaved
=
true
;
_rtfCache
.
Clear
();
_engine
.
ResetUsage
();
// D16:新对话重置会话用量统计
...
...
@@ -640,6 +825,7 @@ namespace Tools.MyAgent
private
void
OnAssistantTextDelta
(
string
text
)
{
SetThinking
(
false
);
// 三态机:正文首帧到达即隐藏旋转指示(此后由正文本身表达进度)
var
last
=
LastAssistantMessage
();
if
(
last
==
null
)
return
;
last
.
Content
=
text
;
...
...
@@ -648,22 +834,27 @@ namespace Tools.MyAgent
private
void
OnAssistantThinkingDelta
(
string
thinking
)
{
SetThinking
(
true
);
// 三态机:收到思考即进入 Thinking(此时正文仍为空)
var
last
=
LastAssistantMessage
();
if
(
last
==
null
)
return
;
last
.
ThinkingContent
=
thinking
;
RefreshChat
();
// ShowThinking=false(默认)时不重绘:思考不再渲染,重绘只会白白刷新整段 RTF
if
(
MarkdownRenderer
.
ShowThinking
)
RefreshChat
();
}
private
void
OnToolStatusChanged
(
string
status
)
{
var
last
=
LastAssistantMessage
();
if
(
last
==
null
)
return
;
last
.
Content
=
$"【正在执行】
{
status
}
…"
;
RefreshChat
();
// P1:不再覆写 last.Content —— 旧实现把「【正在执行】…」写进回答正文,
// 一旦失败/取消就永久残留在问答区(用户看到一条永远停在「正在执行」的回答)。
// 进度表达交给状态栏「回复中...」+ 右下旋转指示;具体工具名保留在指示器悬停提示里,信息不丢。
SetThinking
(
true
);
if
(
_spinner
!=
null
&&
tipScope
!=
null
&&
!
string
.
IsNullOrEmpty
(
status
))
tipScope
.
SetToolTip
(
_spinner
,
"正在执行:"
+
status
);
}
private
void
OnAssistantComplete
()
{
SetThinking
(
false
);
// 三态机回到 Idle(正常完成路径)
var
last
=
LastAssistantMessage
();
if
(
last
!=
null
)
{
...
...
@@ -681,6 +872,29 @@ namespace Tools.MyAgent
SaveDraftSession
();
// D12:每轮回答完成即更新草稿(崩溃恢复)
}
/// <summary>
/// P5:本地直答的形态与关联图片到达(早于 <see cref="OnAssistantComplete"/>,因引擎在直答路径内先发本事件再收尾)。
/// <para>形态记在助手消息上(会话可持久化、渲染成顶部标注),图片同时进入附图轨。</para>
/// </summary>
private
void
OnAnswerAttachments
(
AgentEngine
.
AnswerAttachments
payload
)
{
if
(
payload
==
null
)
return
;
var
last
=
LastAssistantMessage
();
if
(
last
!=
null
)
{
last
.
AnswerMode
=
payload
.
Mode
;
last
.
AnswerImages
=
payload
.
Images
!=
null
&&
payload
.
Images
.
Count
>
0
?
new
List
<
string
>(
payload
.
Images
)
:
null
;
// 空 → null:与旧会话缺字段形态一致,渲染走同一分支
}
_answerImages
.
Clear
();
if
(
payload
.
Images
!=
null
)
foreach
(
var
p
in
payload
.
Images
)
if
(!
string
.
IsNullOrEmpty
(
p
)
&&
!
_answerImages
.
Contains
(
p
))
_answerImages
.
Add
(
p
);
RefreshAnswerImageBar
();
}
/// <summary>构造本轮回复用量标注(相对发送前快照的增量;本轮无调用记录返回空串)</summary>
private
string
BuildReplyUsageSuffix
()
{
...
...
@@ -727,6 +941,7 @@ namespace Tools.MyAgent
private
void
OnEngineError
(
string
message
)
{
if
(
_isClosing
||
IsDisposed
||
Disposing
)
return
;
SetThinking
(
false
);
// P1 兜底:取消/报错路径必须让指示器回到 Idle(否则永久转圈)
var
last
=
LastAssistantMessage
();
if
(
last
!=
null
&&
last
.
IsStreaming
)
{
...
...
@@ -1202,6 +1417,14 @@ namespace Tools.MyAgent
_rtfFallback
.
Select
(
charIdx
,
0
);
return
;
}
// 引用行(P4):**优先于** 绝对路径与折叠切换 —— 引用行里是知识库相对路径,
// 只有引用表知道它的真实落地位置(绝对路径/UNC 正则会把它当普通文本放过去)
AnswerRef
answerRef
;
if
(
TryGetAnswerRefAt
(
charIdx
,
out
answerRef
))
{
OpenAnswerRef
(
answerRef
);
return
;
}
// 工具轨迹折叠块:点击摘要行切换展开/折叠(对齐 WebView 的 <details> 交互)
if
(
TryToggleDetailsAt
(
charIdx
))
return
;
string
path
;
...
...
@@ -1229,6 +1452,14 @@ namespace Tools.MyAgent
if
(
charIdx
==
_lastHoverChar
)
return
;
_lastHoverChar
=
charIdx
;
// 引用行(P4):与绝对路径同一套浅蓝高亮 + 手型反馈
AnswerRef
hoverRef
;
if
(
TryGetAnswerRefAt
(
charIdx
,
out
hoverRef
))
{
SetLinkHover
(
hoverRef
.
Start
,
hoverRef
.
Length
);
return
;
}
// 工具轨迹摘要行:手型光标提示可点击
if
(
IsDetailsMarkerAt
(
charIdx
))
{
...
...
@@ -1661,17 +1892,96 @@ namespace Tools.MyAgent
if
(
_pendingImages
.
Count
==
0
)
{
_pendingBar
.
Visible
=
false
;
LayoutBottomBars
();
return
;
}
foreach
(
var
f
in
_pendingImages
)
_pendingBar
.
Controls
.
Add
(
CreateThumb
(
f
));
_pendingBar
.
Width
=
Math
.
Min
(
panelChat
.
ClientSize
.
Width
-
8
,
_pendingImages
.
Count
*
72
+
12
);
_pendingBar
.
Location
=
new
Point
(
4
,
Math
.
Max
(
0
,
panelChat
.
ClientSize
.
Height
-
_pendingBar
.
Height
-
4
));
_pendingBar
.
Visible
=
true
;
LayoutBottomBars
();
}
// ── P5 回答附图轨 ──
/// <summary>创建回答附图轨(左下角悬浮;懒创建)。只读展示,故不加删除按钮。</summary>
private
void
EnsureAnswerImageBar
()
{
if
(
_answerImageBar
!=
null
)
return
;
_answerImageBar
=
new
FlowLayoutPanel
{
FlowDirection
=
FlowDirection
.
LeftToRight
,
WrapContents
=
false
,
AutoScroll
=
true
,
Height
=
72
,
Padding
=
new
Padding
(
4
),
BackColor
=
Color
.
FromArgb
(
247
,
250
,
255
),
// 与待发送栏(250,250,252)微差,便于区分「回答给出」与「自己选的」
BorderStyle
=
BorderStyle
.
FixedSingle
,
Visible
=
false
,
Anchor
=
AnchorStyles
.
Bottom
|
AnchorStyles
.
Left
,
};
panelChat
.
Controls
.
Add
(
_answerImageBar
);
_answerImageBar
.
BringToFront
();
}
/// <summary>单个缩略图块:图片预览(Zoom)+ 右上角 ✕ 取消;点击缩略图按原始尺寸放大查看</summary>
/// <summary>刷新回答附图轨:按本轮回答附图重建缩略图块;空则隐藏</summary>
private
void
RefreshAnswerImageBar
()
{
if
(
_answerImageBar
==
null
)
return
;
foreach
(
Control
c
in
_answerImageBar
.
Controls
)
DisposeThumbImages
(
c
);
_answerImageBar
.
Controls
.
Clear
();
if
(
_answerImages
.
Count
==
0
)
{
_answerImageBar
.
Visible
=
false
;
LayoutBottomBars
();
return
;
}
foreach
(
var
f
in
_answerImages
)
_answerImageBar
.
Controls
.
Add
(
CreateThumb
(
f
,
null
));
// 只读:不传移除回调 ⇒ 无 ✕ 按钮
_answerImageBar
.
Width
=
Math
.
Min
(
panelChat
.
ClientSize
.
Width
-
8
,
_answerImages
.
Count
*
72
+
12
);
_answerImageBar
.
Visible
=
true
;
LayoutBottomBars
();
}
/// <summary>清空回答附图轨(新对话 / 切换会话 / 发出新一轮问题时调用)</summary>
private
void
ClearAnswerImages
()
{
if
(
_answerImages
.
Count
==
0
)
return
;
_answerImages
.
Clear
();
RefreshAnswerImageBar
();
}
/// <summary>
/// 底部两条悬浮栏纵向叠加:待发送附图栏贴底(左下角),回答附图轨叠在其**上方**。
/// <para>两者都用 Anchor=Bottom|Left + 手工 Location:若让新栏 Dock=Bottom,会与贴底栏落在同一像素带。
/// 待发送栏出现/消失时必须整体重排,否则回答附图轨会被压在下面看不见。</para>
/// </summary>
private
void
LayoutBottomBars
()
{
if
(
panelChat
==
null
)
return
;
int
top
=
Math
.
Max
(
0
,
panelChat
.
ClientSize
.
Height
-
4
);
if
(
_pendingBar
!=
null
&&
_pendingBar
.
Visible
)
{
_pendingBar
.
Location
=
new
Point
(
4
,
Math
.
Max
(
0
,
panelChat
.
ClientSize
.
Height
-
_pendingBar
.
Height
-
4
));
top
=
Math
.
Max
(
0
,
_pendingBar
.
Location
.
Y
-
4
);
}
if
(
_answerImageBar
!=
null
&&
_answerImageBar
.
Visible
)
_answerImageBar
.
Location
=
new
Point
(
4
,
Math
.
Max
(
0
,
top
-
_answerImageBar
.
Height
));
}
/// <summary>单个缩略图块:图片预览(Zoom)+ 右上角 ✕ 移除;点击缩略图按原始尺寸放大查看</summary>
private
Control
CreateThumb
(
string
file
)
{
return
CreateThumb
(
file
,
()
=>
{
_pendingImages
.
Remove
(
file
);
RefreshPendingBar
();
});
}
/// <summary>单个缩略图块:图片预览(Zoom)+ 可选右上角 ✕ 移除按钮;点击缩略图按原始尺寸放大查看。</summary>
/// <param name="onRemove">✕ 的回调;传 <c>null</c> 表示只读展示(如回答附图轨),此时不创建 ✕ 按钮</param>
private
Control
CreateThumb
(
string
file
,
Action
onRemove
)
{
var
box
=
new
Panel
{
Width
=
68
,
Height
=
64
,
Margin
=
new
Padding
(
2
),
BackColor
=
Color
.
White
};
try
...
...
@@ -1698,6 +2008,7 @@ namespace Tools.MyAgent
box
.
Controls
.
Add
(
pic
);
}
catch
{
}
if
(
onRemove
==
null
)
return
box
;
// 只读:不挂 ✕
var
close
=
new
Label
{
Text
=
"✕"
,
...
...
@@ -1710,11 +2021,7 @@ namespace Tools.MyAgent
Font
=
new
Font
(
"微软雅黑"
,
8F
,
FontStyle
.
Bold
),
Cursor
=
Cursors
.
Hand
,
};
close
.
Click
+=
(
s
,
e
)
=>
{
_pendingImages
.
Remove
(
file
);
RefreshPendingBar
();
};
close
.
Click
+=
(
s
,
e
)
=>
onRemove
();
box
.
Controls
.
Add
(
close
);
close
.
BringToFront
();
return
box
;
...
...
@@ -2738,6 +3045,7 @@ namespace Tools.MyAgent
_messages
.
Clear
();
_messages
.
AddRange
(
session
.
Messages
);
_expandedDetails
.
Clear
();
ClearAnswerImages
();
// P5:切换会话清空回答附图轨(避免上一会话的图留在新会话上)
_sessionSaved
=
true
;
// 加载的内容已记录过历史,直接「新对话」不再重复保存
_rtfCache
.
Clear
();
RefreshChat
();
...
...
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