Commit 8d4b5d0b authored by 段廷瑞's avatar 段廷瑞

pref: Agent输出格式优化

parent 322b30f7
...@@ -73,3 +73,4 @@ bin ...@@ -73,3 +73,4 @@ bin
/cc /cc
/AppHome/Logs /AppHome/Logs
/AppHome/Package /AppHome/Package
/.workbuddy
No preview for this file type
...@@ -69,6 +69,27 @@ ...@@ -69,6 +69,27 @@
<member name="F:Tools.MyAgent.Agent.AgentEngine._contextTokens"> <member name="F:Tools.MyAgent.Agent.AgentEngine._contextTokens">
<summary>上下文 token 预算(由配置注入;默认 64000)</summary> <summary>上下文 token 预算(由配置注入;默认 64000)</summary>
</member> </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"> <member name="F:Tools.MyAgent.Agent.AgentEngine.MaxToolReturnChars">
<summary>工具结果回填模型的字符上限(#42:超限压缩 + 完整结果落盘)。 <summary>工具结果回填模型的字符上限(#42:超限压缩 + 完整结果落盘)。
原 4000 过小且“头+尾”压缩丢失中间内容,迫使模型对长文件分页补读、成倍消耗工具轮; 原 4000 过小且“头+尾”压缩丢失中间内容,迫使模型对长文件分页补读、成倍消耗工具轮;
...@@ -80,6 +101,18 @@ ...@@ -80,6 +101,18 @@
<member name="F:Tools.MyAgent.Agent.AgentEngine._toolTrace"> <member name="F:Tools.MyAgent.Agent.AgentEngine._toolTrace">
<summary>一轮回答的工具轨迹(按执行顺序);每轮回答前清空,完成后通过事件下发</summary> <summary>一轮回答的工具轨迹(按执行顺序);每轮回答前清空,完成后通过事件下发</summary>
</member> </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"> <member name="E:Tools.MyAgent.Agent.AgentEngine.OnToolTrace">
<summary>工具轨迹事件(一轮回答结束时触发,含全部工具调用记录)</summary> <summary>工具轨迹事件(一轮回答结束时触发,含全部工具调用记录)</summary>
</member> </member>
...@@ -95,6 +128,13 @@ ...@@ -95,6 +128,13 @@
<member name="E:Tools.MyAgent.Agent.AgentEngine.OnFileProduced"> <member name="E:Tools.MyAgent.Agent.AgentEngine.OnFileProduced">
<summary>智能体已输出/写入的文件(绝对路径),用于在回答末尾展示可点击链接</summary> <summary>智能体已输出/写入的文件(绝对路径),用于在回答末尾展示可点击链接</summary>
</member> </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"> <member name="E:Tools.MyAgent.Agent.AgentEngine.OnComplete">
<summary>一轮回答结束</summary> <summary>一轮回答结束</summary>
</member> </member>
...@@ -140,6 +180,13 @@ ...@@ -140,6 +180,13 @@
<member name="P:Tools.MyAgent.Agent.AgentEngine.ProductContext"> <member name="P:Tools.MyAgent.Agent.AgentEngine.ProductContext">
<summary>当前宿主绑定的产品上下文;返回副本,避免运行期间被外部修改。</summary> <summary>当前宿主绑定的产品上下文;返回副本,避免运行期间被外部修改。</summary>
</member> </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"> <member name="P:Tools.MyAgent.Agent.AgentEngine.ProductLineId">
<summary>当前产品线稳定标识;未绑定时为空。</summary> <summary>当前产品线稳定标识;未绑定时为空。</summary>
</member> </member>
...@@ -197,6 +244,27 @@ ...@@ -197,6 +244,27 @@
运行一轮回答。history 需已包含当前用户问题(含此前对话)。 运行一轮回答。history 需已包含当前用户问题(含此前对话)。
</summary> </summary>
</member> </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"> <member name="M:Tools.MyAgent.Agent.AgentEngine.Cancel">
<summary>取消当前回答</summary> <summary>取消当前回答</summary>
</member> </member>
...@@ -446,6 +514,212 @@ ...@@ -446,6 +514,212 @@
非流式补全:一次性返回正文(用于会话主题总结等轻量调用) 非流式补全:一次性返回正文(用于会话主题总结等轻量调用)
</summary> </summary>
</member> </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"> <member name="T:Tools.MyAgent.Agent.PromptBuilder">
<summary> <summary>
系统提示词组装器。 系统提示词组装器。
...@@ -468,6 +742,12 @@ ...@@ -468,6 +742,12 @@
<member name="P:Tools.MyAgent.Agent.PromptBuilder.DiagnosticRoots"> <member name="P:Tools.MyAgent.Agent.PromptBuilder.DiagnosticRoots">
<summary>宿主注入的诊断根目录;只用于显示根目录 ID,不显示完整机器路径。</summary> <summary>宿主注入的诊断根目录;只用于显示根目录 ID,不显示完整机器路径。</summary>
</member> </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)"> <member name="M:Tools.MyAgent.Agent.PromptBuilder.LoadLayer(System.String,System.String,System.String)">
<summary>加载提示词模板:存在且非空 → 用文件内容;缺失 → 生成默认模板;异常 → 内置回退</summary> <summary>加载提示词模板:存在且非空 → 用文件内容;缺失 → 生成默认模板;异常 → 内置回退</summary>
</member> </member>
...@@ -500,6 +780,13 @@ ...@@ -500,6 +780,13 @@
<member name="P:Tools.MyAgent.Agent.PromptBuilder.ToolRules"> <member name="P:Tools.MyAgent.Agent.PromptBuilder.ToolRules">
<summary>§3.5 工具使用约束</summary> <summary>§3.5 工具使用约束</summary>
</member> </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"> <member name="T:Tools.MyAgent.Agent.ToolSchema">
<summary> <summary>
工具定义下发(OpenAI function-calling JSON Schema)。 工具定义下发(OpenAI function-calling JSON Schema)。
...@@ -773,6 +1060,37 @@ ...@@ -773,6 +1060,37 @@
<member name="P:Tools.MyAgent.Config.AgentConfig.EnableImageInput"> <member name="P:Tools.MyAgent.Config.AgentConfig.EnableImageInput">
<summary>是否启用图片提问(多模态,v2.2):启用后粘贴截图随提问以 image_url 发送,需视觉模型支持(如 DeepSeek-VL / qwen-vl);默认启用</summary> <summary>是否启用图片提问(多模态,v2.2):启用后粘贴截图随提问以 image_url 发送,需视觉模型支持(如 DeepSeek-VL / qwen-vl);默认启用</summary>
</member> </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"> <member name="P:Tools.MyAgent.Config.AgentConfig.MaxToolRounds">
<summary>工具调用轮次上限(默认 15;超限后进入收尾回答轮,基于已收集信息汇总作答)</summary> <summary>工具调用轮次上限(默认 15;超限后进入收尾回答轮,基于已收集信息汇总作答)</summary>
</member> </member>
...@@ -931,6 +1249,12 @@ ...@@ -931,6 +1249,12 @@
<member name="M:Tools.MyAgent.Forms.AgentSettingsForm.ParseToolRoleRules(System.String)"> <member name="M:Tools.MyAgent.Forms.AgentSettingsForm.ParseToolRoleRules(System.String)">
<summary>编辑文本 → 角色规则字典(忽略空行/非法行)</summary> <summary>编辑文本 → 角色规则字典(忽略空行/非法行)</summary>
</member> </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" --> <!-- 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)"> <member name="M:Tools.MyAgent.Forms.AgentSettingsForm.SetModelSuggestions(System.Windows.Forms.ComboBox,System.Int32,System.String)">
<summary>更新模型建议;当前配置始终保留在可选项中,兼容新发布模型和旧配置。</summary> <summary>更新模型建议;当前配置始终保留在可选项中,兼容新发布模型和旧配置。</summary>
...@@ -997,6 +1321,66 @@ ...@@ -997,6 +1321,66 @@
使用代码编辑器修改此方法的内容。 使用代码编辑器修改此方法的内容。
</summary> </summary>
</member> </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"> <member name="T:Tools.MyAgent.wBitmapView">
<summary> <summary>
图片查看器:优先按图片原始尺寸展示(1:1 像素),但不超过屏幕工作区大小; 图片查看器:优先按图片原始尺寸展示(1:1 像素),但不超过屏幕工作区大小;
...@@ -1016,6 +1400,30 @@ ...@@ -1016,6 +1400,30 @@
<member name="M:Tools.MyAgent.wBitmapView.ShowImage(System.String,System.Windows.Forms.Form)"> <member name="M:Tools.MyAgent.wBitmapView.ShowImage(System.String,System.Windows.Forms.Form)">
<summary>从文件加载图片并按原始尺寸弹出查看(超屏自动限制在工作区内;内部负责释放位图)</summary> <summary>从文件加载图片并按原始尺寸弹出查看(超屏自动限制在工作区内;内部负责释放位图)</summary>
</member> </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"> <member name="T:Tools.MyAgent.Kb.DialectService">
<summary> <summary>
方言服务(轻量化内化版):扫描程序目录 Agent/dialect/(产品排查方言)与 Agent/industry/(行业知识), 方言服务(轻量化内化版):扫描程序目录 Agent/dialect/(产品排查方言)与 Agent/industry/(行业知识),
...@@ -1533,6 +1941,46 @@ ...@@ -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)"> <member name="M:Tools.MyAgent.Kb.KbService.FormatContentHits(System.String,System.Collections.Generic.List{System.ValueTuple{System.String,System.String}},System.Int32)">
<summary>索引内容命中 → 统一格式(按搜索范围过滤 + 路径显示基准 + 上下文片段)</summary> <summary>索引内容命中 → 统一格式(按搜索范围过滤 + 路径显示基准 + 上下文片段)</summary>
</member> </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)"> <member name="M:Tools.MyAgent.Kb.KbService.ReadOutline(System.String)">
<summary>文档地图(#11):提取 docx/markdown 等文档的标题层级目录(行号|层级|标题),供 kb_read outline 模式与定位精读使用</summary> <summary>文档地图(#11):提取 docx/markdown 等文档的标题层级目录(行号|层级|标题),供 kb_read outline 模式与定位精读使用</summary>
</member> </member>
...@@ -2028,6 +2476,18 @@ ...@@ -2028,6 +2476,18 @@
<member name="P:Tools.MyAgent.ChatMessage.UsageSuffix"> <member name="P:Tools.MyAgent.ChatMessage.UsageSuffix">
<summary>回复末尾用量标注(展示用,不参与模型上下文/历史导出):如「本回复 1284+312 tokens(约 ¥0.42)」</summary> <summary>回复末尾用量标注(展示用,不参与模型上下文/历史导出):如「本回复 1284+312 tokens(约 ¥0.42)」</summary>
</member> </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"> <member name="T:Tools.MyAgent.ChatRtfCache">
<summary> <summary>
聊天区 RTF 增量渲染缓存:欢迎语与每条消息按内容哈希缓存渲染结果, 聊天区 RTF 增量渲染缓存:欢迎语与每条消息按内容哈希缓存渲染结果,
...@@ -2136,12 +2596,26 @@ ...@@ -2136,12 +2596,26 @@
Markdown → RTF 渲染器(迁移自 Tools.MyCommon.MarkdownRenderer) Markdown → RTF 渲染器(迁移自 Tools.MyCommon.MarkdownRenderer)
</summary> </summary>
</member> </member>
<member name="P:Tools.MyAgent.MarkdownRenderer.ShowThinking">
<summary>
是否仍在问答区渲染思考文本(由宿主按配置注入,默认 false)。
<para>默认形态:思考只由右下旋转指示表达,不再把思维链整块铺进问答区(也避免流式每帧重绘)。
置 true 恢复旧行为,便于调试/排查模型思维链。</para>
</summary>
</member>
<member name="M:Tools.MyAgent.MarkdownRenderer.RenderWelcome(System.String)"> <member name="M:Tools.MyAgent.MarkdownRenderer.RenderWelcome(System.String)">
<summary>欢迎语块(无消息时的占位正文)</summary> <summary>欢迎语块(无消息时的占位正文)</summary>
</member> </member>
<member name="M:Tools.MyAgent.MarkdownRenderer.RenderMessageBlock(Tools.MyAgent.ChatMessage,System.String,System.String,System.Boolean)"> <member name="M:Tools.MyAgent.MarkdownRenderer.RenderMessageBlock(Tools.MyAgent.ChatMessage,System.String,System.String,System.Boolean)">
<summary>单条消息的 RTF 块(自包含 \pard…\par),供增量渲染缓存复用;expandDetails 控制 &lt;details&gt; 块展开/折叠</summary> <summary>单条消息的 RTF 块(自包含 \pard…\par),供增量渲染缓存复用;expandDetails 控制 &lt;details&gt; 块展开/折叠</summary>
</member> </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)"> <member name="M:Tools.MyAgent.MarkdownRenderer.RenderDetailsBlock(System.Text.StringBuilder,System.String,System.Collections.Generic.List{System.String},System.Boolean)">
<summary> <summary>
&lt;details&gt; 折叠块的 RTF 渲染:折叠态只显示摘要行(▶ + 点击展开提示), &lt;details&gt; 折叠块的 RTF 渲染:折叠态只显示摘要行(▶ + 点击展开提示),
...@@ -2243,12 +2717,21 @@ ...@@ -2243,12 +2717,21 @@
<member name="F:Tools.MyAgent.wMyAgent._rtfFallback"> <member name="F:Tools.MyAgent.wMyAgent._rtfFallback">
<summary>聊天区控件:RichTextBox(RichBox,RTF 渲染)</summary> <summary>聊天区控件:RichTextBox(RichBox,RTF 渲染)</summary>
</member> </member>
<member name="F:Tools.MyAgent.wMyAgent._spinner">
<summary>思考中旋转指示器(右下角悬浮;替代「思考文本铺满问答区」)</summary>
</member>
<member name="F:Tools.MyAgent.wMyAgent._pendingImages"> <member name="F:Tools.MyAgent.wMyAgent._pendingImages">
<summary>v2.3 待发送附图列表:粘贴截图后暂存,随下一轮提问发送(启用图片提问时;最多 5 张)</summary> <summary>v2.3 待发送附图列表:粘贴截图后暂存,随下一轮提问发送(启用图片提问时;最多 5 张)</summary>
</member> </member>
<member name="F:Tools.MyAgent.wMyAgent._pendingBar"> <member name="F:Tools.MyAgent.wMyAgent._pendingBar">
<summary>v2.3 待发送附图缩略图栏(问答区左下角悬浮;每张图右上角叉号可取消)</summary> <summary>v2.3 待发送附图缩略图栏(问答区左下角悬浮;每张图右上角叉号可取消)</summary>
</member> </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"> <member name="F:Tools.MyAgent.wMyAgent._replyPromptBase">
<summary>本轮回复用量基线快照(发送前记录引擎累计值;回复完成后按增量展示「本回复」用量)</summary> <summary>本轮回复用量基线快照(发送前记录引擎累计值;回复完成后按增量展示「本回复」用量)</summary>
</member> </member>
...@@ -2282,6 +2765,21 @@ ...@@ -2282,6 +2765,21 @@
<member name="F:Tools.MyAgent.wMyAgent.HttpUrlRegex"> <member name="F:Tools.MyAgent.wMyAgent.HttpUrlRegex">
<summary>匹配 http/https 链接</summary> <summary>匹配 http/https 链接</summary>
</member> </member>
<member name="F:Tools.MyAgent.wMyAgent.AnswerRefRegex">
<summary>
匹配回答「引用」节的引用行(P4):📎 &lt;知识库相对路径&gt; §&lt;章节&gt; L&lt;行号&gt; 匹配度 &lt;0.xx&gt;。
<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)"> <member name="M:Tools.MyAgent.wMyAgent.#ctor(System.String,System.String)">
<summary>兼容旧调用:创建未绑定产品上下文的聊天窗体。</summary> <summary>兼容旧调用:创建未绑定产品上下文的聊天窗体。</summary>
</member> </member>
...@@ -2296,6 +2794,21 @@ ...@@ -2296,6 +2794,21 @@
创建聊天区控件:统一使用 RichBox(RichTextBox + RTF 渲染,轻量、启动快、无浏览器依赖)。 创建聊天区控件:统一使用 RichBox(RichTextBox + RTF 渲染,轻量、启动快、无浏览器依赖)。
</summary> </summary>
</member> </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"> <member name="P:Tools.MyAgent.wMyAgent.SupportedModels">
<summary>仅支持的模型(定义统一在设置窗体 AgentSettingsForm)</summary> <summary>仅支持的模型(定义统一在设置窗体 AgentSettingsForm)</summary>
</member> </member>
...@@ -2327,6 +2840,22 @@ ...@@ -2327,6 +2840,22 @@
<member name="M:Tools.MyAgent.wMyAgent.RefreshChatRtf"> <member name="M:Tools.MyAgent.wMyAgent.RefreshChatRtf">
<summary>RichBox 模式:整段 RTF 重建(增量渲染缓存)</summary> <summary>RichBox 模式:整段 RTF 重建(增量渲染缓存)</summary>
</member> </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)"> <member name="M:Tools.MyAgent.wMyAgent.IsDetailsExpanded(System.Int32)">
<summary>指定消息的工具轨迹(&lt;details&gt;)是否展开</summary> <summary>指定消息的工具轨迹(&lt;details&gt;)是否展开</summary>
</member> </member>
...@@ -2336,6 +2865,12 @@ ...@@ -2336,6 +2865,12 @@
<member name="M:Tools.MyAgent.wMyAgent.FormatFileCount(System.Int32)"> <member name="M:Tools.MyAgent.wMyAgent.FormatFileCount(System.Int32)">
<summary>文件数显示:未知(后台统计中)时用占位文案</summary> <summary>文件数显示:未知(后台统计中)时用占位文案</summary>
</member> </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"> <member name="M:Tools.MyAgent.wMyAgent.BuildReplyUsageSuffix">
<summary>构造本轮回复用量标注(相对发送前快照的增量;本轮无调用记录返回空串)</summary> <summary>构造本轮回复用量标注(相对发送前快照的增量;本轮无调用记录返回空串)</summary>
</member> </member>
...@@ -2453,8 +2988,28 @@ ...@@ -2453,8 +2988,28 @@
<member name="M:Tools.MyAgent.wMyAgent.RefreshPendingBar"> <member name="M:Tools.MyAgent.wMyAgent.RefreshPendingBar">
<summary>刷新缩略图栏:按待发送列表重建缩略图块;空则隐藏</summary> <summary>刷新缩略图栏:按待发送列表重建缩略图块;空则隐藏</summary>
</member> </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)"> <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>
<member name="M:Tools.MyAgent.wMyAgent.DisposeThumbImages(System.Windows.Forms.Control)"> <member name="M:Tools.MyAgent.wMyAgent.DisposeThumbImages(System.Windows.Forms.Control)">
<summary>递归释放缩略图 PictureBox 的图片资源(防 GDI 对象泄漏)</summary> <summary>递归释放缩略图 PictureBox 的图片资源(防 GDI 对象泄漏)</summary>
......
...@@ -33,6 +33,29 @@ namespace Tools.MyAgent.Agent ...@@ -33,6 +33,29 @@ namespace Tools.MyAgent.Agent
/// <summary>上下文 token 预算(由配置注入;默认 64000)</summary> /// <summary>上下文 token 预算(由配置注入;默认 64000)</summary>
private int _contextTokens = AgentConfig.DefaultMaxContextTokens; 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 故障转移 // #31 故障转移
private FailoverPolicy _failover; private FailoverPolicy _failover;
private IOpenAiChatClient _backupClient; private IOpenAiChatClient _backupClient;
...@@ -55,6 +78,21 @@ namespace Tools.MyAgent.Agent ...@@ -55,6 +78,21 @@ namespace Tools.MyAgent.Agent
/// <summary>一轮回答的工具轨迹(按执行顺序);每轮回答前清空,完成后通过事件下发</summary> /// <summary>一轮回答的工具轨迹(按执行顺序);每轮回答前清空,完成后通过事件下发</summary>
private readonly List<ToolTraceEntry> _toolTrace = new List<ToolTraceEntry>(); 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> /// <summary>工具轨迹事件(一轮回答结束时触发,含全部工具调用记录)</summary>
public event Action<IReadOnlyList<ToolTraceEntry>> OnToolTrace; public event Action<IReadOnlyList<ToolTraceEntry>> OnToolTrace;
...@@ -66,6 +104,13 @@ namespace Tools.MyAgent.Agent ...@@ -66,6 +104,13 @@ namespace Tools.MyAgent.Agent
public event Action<string> OnToolStatus; public event Action<string> OnToolStatus;
/// <summary>智能体已输出/写入的文件(绝对路径),用于在回答末尾展示可点击链接</summary> /// <summary>智能体已输出/写入的文件(绝对路径),用于在回答末尾展示可点击链接</summary>
public event Action<string> OnFileProduced; public event Action<string> OnFileProduced;
/// <summary>
/// P5 衔接:本轮「本地直答」的形态与关联图片,用于宿主渲染「回答附图轨」并按形态打标。
/// <para>仅在直答路径命中知识库时触发一次(工具循环路径不触发);无图时 <c>Images</c> 为空列表、
/// 事件仍会触发(形态本身有展示价值)。</para>
/// </summary>
public event Action<AnswerAttachments> OnAnswerAttachments;
/// <summary>一轮回答结束</summary> /// <summary>一轮回答结束</summary>
public event Action OnComplete; public event Action OnComplete;
/// <summary>错误/取消</summary> /// <summary>错误/取消</summary>
...@@ -118,6 +163,16 @@ namespace Tools.MyAgent.Agent ...@@ -118,6 +163,16 @@ namespace Tools.MyAgent.Agent
/// <summary>当前宿主绑定的产品上下文;返回副本,避免运行期间被外部修改。</summary> /// <summary>当前宿主绑定的产品上下文;返回副本,避免运行期间被外部修改。</summary>
public AgentProductContext ProductContext => _productContext?.Clone(); public AgentProductContext ProductContext => _productContext?.Clone();
/// <summary>
/// 内置方言/行业知识服务(只读)。
/// <para>宿主用于解析引用行里的 <c>dialect/</c>、<c>industry/</c> 相对路径(P4 引用定位),
/// 与引擎共用同一实例与缓存,避免各自重建。</para>
/// </summary>
public DialectService Dialect
{
get { return _dialect; }
}
/// <summary>当前产品线稳定标识;未绑定时为空。</summary> /// <summary>当前产品线稳定标识;未绑定时为空。</summary>
public string ProductLineId => _productContext?.ProductLineId ?? ""; public string ProductLineId => _productContext?.ProductLineId ?? "";
...@@ -182,6 +237,7 @@ namespace Tools.MyAgent.Agent ...@@ -182,6 +237,7 @@ namespace Tools.MyAgent.Agent
? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Agent") ? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Agent")
: Path.GetFullPath(agentRoot); : Path.GetFullPath(agentRoot);
_dialect = new DialectService(root); _dialect = new DialectService(root);
_recall = new KbRecall(_kb); // P3:本地融合召回器(构造无副作用,仅持有 KbService 引用)
_prompts.DiagnosticRoots = DiagnosticRoots; _prompts.DiagnosticRoots = DiagnosticRoots;
SetProductContext(productContext); SetProductContext(productContext);
} }
...@@ -228,6 +284,21 @@ namespace Tools.MyAgent.Agent ...@@ -228,6 +284,21 @@ namespace Tools.MyAgent.Agent
_prompts.CustomLayer = cfg.CustomSystemPrompt ?? ""; _prompts.CustomLayer = cfg.CustomSystemPrompt ?? "";
_prompts.SystemPromptEnabled = cfg.SystemPromptEnabled; _prompts.SystemPromptEnabled = cfg.SystemPromptEnabled;
_imageInputEnabled = cfg.EnableImageInput; // v2.2 多模态:图片提问开关 _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 费用估算:应用自定义价格表覆盖 CostEstimator.Configure(cfg.CustomPrices); // R6 费用估算:应用自定义价格表覆盖
// P0 连接串脱敏 / P3 工具权限:预存连接与角色规则随配置注入 // P0 连接串脱敏 / P3 工具权限:预存连接与角色规则随配置注入
DbConnections = cfg.DbConnections ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); DbConnections = cfg.DbConnections ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
...@@ -276,14 +347,24 @@ namespace Tools.MyAgent.Agent ...@@ -276,14 +347,24 @@ namespace Tools.MyAgent.Agent
_contextNotice = ""; _contextNotice = "";
_runGeneration++; _runGeneration++;
_diagnosticSessionId = Guid.NewGuid().ToString("N"); _diagnosticSessionId = Guid.NewGuid().ToString("N");
_diagnosticToolHit = false;
_answerKind = AnswerKind.Knowledge;
_currentQuestion = LastUserQuestion(history);
var messages = await BuildMessagesAsync(history, _cts.Token).ConfigureAwait(false); var messages = await BuildMessagesAsync(history, _cts.Token).ConfigureAwait(false);
var tools = BuildTools(); var tools = BuildTools();
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
int toolRounds = 0; int toolRounds = 0;
var finalAnswer = new StringBuilder(); // 跨轮累计的回答正文(UI 事件为全量替换语义) var finalAnswer = new StringBuilder(); // 跨轮累计的回答正文(UI 事件为全量替换语义)
// P3 单轮直答(QuickAnswer):本地召回命中知识库时,注入证据做一次「无工具」补全即收尾,
// 跳过工具循环;未命中/异常/开关关闭 ⇒ 返回 false,行为与改造前完全一致(降级纪律)。
bool directAnswered = false;
if (_quickAnswer)
directAnswered = await TryDirectAnswerAsync(messages, finalAnswer, _cts.Token).ConfigureAwait(false);
// 工具调用循环:最多 _maxToolRounds 轮;超限/超时时做「收尾回答轮」而非直接放弃(修复「第一个问题就触发 15 轮上限、颗粒无收」) // 工具调用循环:最多 _maxToolRounds 轮;超限/超时时做「收尾回答轮」而非直接放弃(修复「第一个问题就触发 15 轮上限、颗粒无收」)
while (true) // (directAnswered=true 时整体跳过;循环体内不修改该变量,条件恒为真,等价于改造前的 while(true))
while (!directAnswered)
{ {
_cts.Token.ThrowIfCancellationRequested(); _cts.Token.ThrowIfCancellationRequested();
...@@ -382,6 +463,108 @@ namespace Tools.MyAgent.Agent ...@@ -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> /// <summary>取消当前回答</summary>
public void Cancel() public void Cancel()
{ {
...@@ -447,6 +630,10 @@ namespace Tools.MyAgent.Agent ...@@ -447,6 +630,10 @@ namespace Tools.MyAgent.Agent
/// </summary> /// </summary>
private async Task FinishAnswerAsync(JArray messages, ChatResult first, StringBuilder finalAnswer, CancellationToken ct) private async Task FinishAnswerAsync(JArray messages, ChatResult first, StringBuilder finalAnswer, CancellationToken ct)
{ {
// P3 双契约:收尾时按「是否调用过诊断类工具 / 问题是否含报错线索 / 是否绑定产品上下文」定类型。
// 放在这里而非调用点,是因为诊断工具命中是随工具轮累积的,只有收尾时才是最终值。
_answerKind = AnswerSpec.Classify(_currentQuestion, _diagnosticToolHit, _productContext != null);
var result = first; var result = first;
while (true) while (true)
{ {
...@@ -472,8 +659,18 @@ namespace Tools.MyAgent.Agent ...@@ -472,8 +659,18 @@ namespace Tools.MyAgent.Agent
if (finalAnswer.Length == 0) if (finalAnswer.Length == 0)
finalAnswer.Append("(模型未返回内容)"); finalAnswer.Append("(模型未返回内容)");
else if (_contextNotice.Length > 0) else
finalAnswer.Insert(0, _contextNotice + "\n\n"); // 上下文提示保留在最终回答开头(此前被正文覆盖) {
// 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)) if (IsTruncatedByLength(result))
{ {
// 达到续写上限仍被截断:明确提示用户,不再静默展示半截回答 // 达到续写上限仍被截断:明确提示用户,不再静默展示半截回答
...@@ -963,6 +1160,12 @@ namespace Tools.MyAgent.Agent ...@@ -963,6 +1160,12 @@ namespace Tools.MyAgent.Agent
if (tool == null) if (tool == null)
return $"错误:未知工具 {tc.Name}"; return $"错误:未知工具 {tc.Name}";
// P3 双契约判定输入:本轮只要调用过诊断类工具,回答即按「现场排查六段式」输出。
// 代际校验:超时残留的后台工具不得污染新轮的判定。
if (AnswerSpec.IsDiagnosticTool(tc.Name)
&& gen == System.Threading.Volatile.Read(ref _runGeneration))
_diagnosticToolHit = true;
// P3 工具级权限(纵深防御):执行侧再校验一次,防止 Schema 过滤被绕过 // P3 工具级权限(纵深防御):执行侧再校验一次,防止 Schema 过滤被绕过
if (!ToolPermission.IsAllowed(tc.Name, CurrentUser, _toolRoleAllow)) if (!ToolPermission.IsAllowed(tc.Name, CurrentUser, _toolRoleAllow))
return $"错误:当前用户没有使用 {tc.Name} 的权限(需在设置中为该工具配置角色白名单)。"; return $"错误:当前用户没有使用 {tc.Name} 的权限(需在设置中为该工具配置角色白名单)。";
......
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;
}
}
}
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) + "…";
}
}
}
...@@ -29,6 +29,12 @@ namespace Tools.MyAgent.Agent ...@@ -29,6 +29,12 @@ namespace Tools.MyAgent.Agent
/// <summary>宿主注入的诊断根目录;只用于显示根目录 ID,不显示完整机器路径。</summary> /// <summary>宿主注入的诊断根目录;只用于显示根目录 ID,不显示完整机器路径。</summary>
public IReadOnlyList<DiagnosticRoot> DiagnosticRoots { get; set; } 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) public string BuildSystemMessage(KbService kb)
{ {
// B2 提示词外置:静态层优先读 输出目录/prompts/*.md 模板(用户可定制), // B2 提示词外置:静态层优先读 输出目录/prompts/*.md 模板(用户可定制),
...@@ -60,6 +66,13 @@ namespace Tools.MyAgent.Agent ...@@ -60,6 +66,13 @@ namespace Tools.MyAgent.Agent
sb.AppendLine(LoadLayer(promptsDir, "security.md", SecurityLayer)); sb.AppendLine(LoadLayer(promptsDir, "security.md", SecurityLayer));
sb.AppendLine(); sb.AppendLine();
sb.AppendLine(LoadLayer(promptsDir, "tool_rules.md", ToolRules)); 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(); return sb.ToString();
} }
...@@ -292,5 +305,38 @@ namespace Tools.MyAgent.Agent ...@@ -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_write / kb_write_kb 一次只写一个文件;覆盖已有文件前先征得用户同意;追加请用 append=true。kb_write 写默认输出目录的 kb_write 子文件夹(Agent/output/kb_write/),kb_write_kb 仅在用户明确要求存入知识库时使用。
- 检索结果过多时,先用 kb_list/kb_find 缩小范围,再精读。 - 检索结果过多时,先用 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 列的表格;代码块仅用于给出命令或配置片段。
- 结尾总结段、免责声明、与问题无关的扩展建议。
- 回答长度与问题复杂度匹配:简单问题给出简短三段式即可,不刻意展开。";
} }
} }
...@@ -110,6 +110,39 @@ ...@@ -110,6 +110,39 @@
/// <summary>是否启用图片提问(多模态,v2.2):启用后粘贴截图随提问以 image_url 发送,需视觉模型支持(如 DeepSeek-VL / qwen-vl);默认启用</summary> /// <summary>是否启用图片提问(多模态,v2.2):启用后粘贴截图随提问以 image_url 发送,需视觉模型支持(如 DeepSeek-VL / qwen-vl);默认启用</summary>
public bool EnableImageInput { get; set; } = true; 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) ════════════════ // ════════════════ 高级项:策略参数(R12) ════════════════
/// <summary>工具调用轮次上限(默认 15;超限后进入收尾回答轮,基于已收集信息汇总作答)</summary> /// <summary>工具调用轮次上限(默认 15;超限后进入收尾回答轮,基于已收集信息汇总作答)</summary>
......
【输出形态契约(硬约束,仅约束输出形态,不得与安全层冲突)】
本节规定「最终回答长什么样」;安全层与事实纪律仍然最高优先级,二者冲突时以安全层与事实为准。
判断本轮属于哪一类(拿不准时按第二类即排查类输出,信息更全):
- 第一类 知识问答:查资料、查流程、问「是什么/怎么做/在哪配」等,且不涉及现场故障与代码/表/调用链定位。
- 第二类 现场排查:涉及报错、异常、失败、崩溃、超时、卡死、没反应,或需要定位到具体文件/类/表/调用链/日志。
一、知识问答类 → 只允许三节,顺序固定,节标题用加粗或三级标题,禁止增加其它章节与客套话:
- 原因:不超过 3 行,一句话点明根因或结论;不复述用户问题,不复述原文段落。
- 方案:不超过 5 步的有序列表,每步必须给出具体动作对象(菜单、按钮、字段、配置项、路径)。
- 引用:每行一条,格式固定:📎 <知识库相对路径> §<章节> L<行号> 匹配度 <0.xx>
章节、行号、匹配度三段可缺省(确实没有时省略),但**不得杜撰**路径、章节与行号。
只列本轮真正用到的来源,最多 3 条;未命中知识库时本节写「知识库中未找到相关内容」。
- 禁止出现「首先/其次/总的来说/希望对你有帮助」这类过渡与客套语。
二、现场排查类 → 沿用排查输出规范,六节顺序固定,每节不超过 3 行:
已确认事实 / 证据 / 最可能根因 / 待验证项 / 最小验证步骤 / 修复与回滚建议。
- 每一条「已确认事实」都必须能对应到某条「证据」(日志行、配置项、文件行号)。
- 证据不足时,在「待验证项」写明还缺什么,不要凭经验臆断根因。
- 不得仅凭单条异常就下定论;不得编造文件、表名、调用链或结论。
三、两类的通用禁止:
- 大段原文摘录(超过 3 行的原文引用一律改为摘要 + 引用行)。
- 超过 3 列的表格;代码块仅用于给出命令或配置片段。
- 结尾总结段、免责声明、与问题无关的扩展建议。
- 回答长度与问题复杂度匹配:简单问题给出简短三段式即可,不刻意展开。
...@@ -120,6 +120,7 @@ namespace Tools.MyAgent.Forms ...@@ -120,6 +120,7 @@ namespace Tools.MyAgent.Forms
InitializeComponent(); InitializeComponent();
BuildDbConnectionsTab(); // P0:预存数据库连接(db_query 按 ID 引用,聊天不回显密码) BuildDbConnectionsTab(); // P0:预存数据库连接(db_query 按 ID 引用,聊天不回显密码)
BuildToolPermissionUi(); // P3:工具级权限(按角色)输入区 BuildToolPermissionUi(); // P3:工具级权限(按角色)输入区
BuildAnswerShapeTab(); // 回答形态:旋转思考指示 / 本地直答 / 契约化输出 / 回答附图
BindConfig(); // 配置值回填控件(控件创建见 Designer.cs) BindConfig(); // 配置值回填控件(控件创建见 Designer.cs)
ApplyProviderMode(); // 布局联动:BaseUrl 行显隐 ApplyProviderMode(); // 布局联动:BaseUrl 行显隐
ApplyKbMode(); // 布局联动:共享 账号/密码/测试连接 行显隐 ApplyKbMode(); // 布局联动:共享 账号/密码/测试连接 行显隐
...@@ -235,6 +236,99 @@ namespace Tools.MyAgent.Forms ...@@ -235,6 +236,99 @@ namespace Tools.MyAgent.Forms
return rules; 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.01M,
Minimum = 0.45M,
Maximum = 0.95M,
Value = 0.72M,
};
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 值回填到控件(设计器仅设默认值) ══ // ══ 配置绑定:把 AgentConfig 值回填到控件(设计器仅设默认值) ══
private void BindConfig() private void BindConfig()
...@@ -296,6 +390,14 @@ namespace Tools.MyAgent.Forms ...@@ -296,6 +390,14 @@ namespace Tools.MyAgent.Forms
// 可选能力页:工具级权限(P3,角色白名单) // 可选能力页:工具级权限(P3,角色白名单)
txtToolRoles.Lines = ToolRoleRulesToLines(_config.ToolRoleAllow); 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.72M);
// 数据库连接(P0):预存连接串(内存明文态)回填表格 // 数据库连接(P0):预存连接串(内存明文态)回填表格
dgvDbConn.Rows.Clear(); dgvDbConn.Rows.Clear();
if (_config.DbConnections != null) if (_config.DbConnections != null)
...@@ -751,6 +853,13 @@ namespace Tools.MyAgent.Forms ...@@ -751,6 +853,13 @@ namespace Tools.MyAgent.Forms
_config.EnableImageInput = chkImageInput.Checked; // v2.2 多模态:图片提问开关 _config.EnableImageInput = chkImageInput.Checked; // v2.2 多模态:图片提问开关
// 可选能力页:工具级权限(P3) // 可选能力页:工具级权限(P3)
_config.ToolRoleAllow = ParseToolRoleRules(txtToolRoles.Text); _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 行忽略) // 数据库连接(P0):表格 → 字典(ID 大小写不敏感,空格清理;空 ID 行忽略)
var conns = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var conns = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (DataGridViewRow r in dgvDbConn.Rows) foreach (DataGridViewRow r in dgvDbConn.Rows)
......
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 ?? ""; }
}
}
}
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);
}
}
}
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 = "";
}
}
...@@ -1282,6 +1282,219 @@ namespace Tools.MyAgent.Kb ...@@ -1282,6 +1282,219 @@ namespace Tools.MyAgent.Kb
#endregion #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 —— 读取文件内容 #region kb_read —— 读取文件内容
/// <summary>文档地图(#11):提取 docx/markdown 等文档的标题层级目录(行号|层级|标题),供 kb_read outline 模式与定位精读使用</summary> /// <summary>文档地图(#11):提取 docx/markdown 等文档的标题层级目录(行号|层级|标题),供 kb_read outline 模式与定位精读使用</summary>
......
...@@ -22,5 +22,17 @@ namespace Tools.MyAgent ...@@ -22,5 +22,17 @@ namespace Tools.MyAgent
/// <summary>回复末尾用量标注(展示用,不参与模型上下文/历史导出):如「本回复 1284+312 tokens(约 ¥0.42)」</summary> /// <summary>回复末尾用量标注(展示用,不参与模型上下文/历史导出):如「本回复 1284+312 tokens(约 ¥0.42)」</summary>
public string UsageSuffix { get; set; } 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; }
} }
} }
...@@ -78,9 +78,14 @@ namespace Tools.MyAgent ...@@ -78,9 +78,14 @@ namespace Tools.MyAgent
private static string MessageKey(ChatMessage m, string assistantName, string userName, bool detailsExpanded) 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 + "|" return (m.Role ?? "") + "|" + m.Timestamp.Ticks + "|"
+ (m.Content ?? "") + "|" + (m.ThinkingContent ?? "") + "|" + m.IsStreaming + (m.Content ?? "") + "|" + thinking + "|" + m.IsStreaming
+ "|" + (m.UsageSuffix ?? "") // 用量标注:完成时附加,需纳入缓存键以触发重绘 + "|" + (m.UsageSuffix ?? "") // 用量标注:完成时附加,需纳入缓存键以触发重绘
+ "|" + (m.AnswerMode ?? "") // P5 回答形态标注:直答完成时附加,同上
+ "|" + (m.AnswerImages != null ? m.AnswerImages.Count : 0) // 形态标注里含图片数
+ "|" + assistantName + "|" + userName + "|" + detailsExpanded; + "|" + assistantName + "|" + userName + "|" + detailsExpanded;
} }
} }
......
...@@ -71,6 +71,13 @@ namespace Tools.MyAgent ...@@ -71,6 +71,13 @@ namespace Tools.MyAgent
"type ", "find ", "findstr ", "where ", "set ", "echo ", "cls " "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") public static string BuildChatRtf(List<ChatMessage> messages, string welcomeText, string assistantName = "Claude", string userName = "You")
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
...@@ -114,6 +121,25 @@ namespace Tools.MyAgent ...@@ -114,6 +121,25 @@ namespace Tools.MyAgent
return sb.ToString(); 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) private static void RenderMessage(StringBuilder sb, ChatMessage msg, string assistantName, string userName, bool expandDetails = false)
{ {
bool isUser = msg.Role == "user"; bool isUser = msg.Role == "user";
...@@ -127,6 +153,16 @@ namespace Tools.MyAgent ...@@ -127,6 +153,16 @@ namespace Tools.MyAgent
sb.Append(ClTs); sb.Append(ClTs);
sb.Append(@"\fs16 "); sb.Append(@"\fs16 ");
sb.Append(msg.Timestamp.ToString("HH:mm:ss")); 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"); sb.Append(@"\par");
// Content // Content
...@@ -141,7 +177,8 @@ namespace Tools.MyAgent ...@@ -141,7 +177,8 @@ namespace Tools.MyAgent
} }
else else
{ {
if (!string.IsNullOrEmpty(msg.ThinkingContent)) // 思考文本:默认不渲染(改由 wMyAgent 右下旋转指示表达);ShowThinking=true 时恢复旧行为(兜底/调试开关)
if (ShowThinking && !string.IsNullOrEmpty(msg.ThinkingContent))
{ {
sb.Append(@"\cf"); sb.Append(ClThinking); sb.Append(@"\cf"); sb.Append(ClThinking);
sb.Append(@"\i\fs18 "); sb.Append(@"\i\fs18 ");
......
...@@ -101,6 +101,8 @@ ...@@ -101,6 +101,8 @@
<Compile Include="Agent\FailoverPolicy.cs" /> <Compile Include="Agent\FailoverPolicy.cs" />
<Compile Include="Agent\IOpenAiChatClient.cs" /> <Compile Include="Agent\IOpenAiChatClient.cs" />
<Compile Include="Agent\OpenAiCompatibleClient.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\PromptBuilder.cs" />
<Compile Include="Agent\ToolSchema.cs" /> <Compile Include="Agent\ToolSchema.cs" />
<Compile Include="Agent\Tools\ITool.cs" /> <Compile Include="Agent\Tools\ITool.cs" />
...@@ -120,11 +122,28 @@ ...@@ -120,11 +122,28 @@
<Compile Include="Config\AgentConfig.cs" /> <Compile Include="Config\AgentConfig.cs" />
<Compile Include="Config\RoleTemplateStore.cs" /> <Compile Include="Config\RoleTemplateStore.cs" />
<Compile Include="Config\SecretProtector.cs" /> <Compile Include="Config\SecretProtector.cs" />
<Compile Include="Forms\AgentHistoryForm.cs"><SubType>Form</SubType></Compile> <Compile Include="Forms\AgentHistoryForm.cs">
<Compile Include="Forms\AgentHistoryForm.Designer.cs"><DependentUpon>AgentHistoryForm.cs</DependentUpon></Compile> <SubType>Form</SubType>
<Compile Include="Forms\AgentSettingsForm.cs"><SubType>Form</SubType></Compile> </Compile>
<Compile Include="Forms\AgentSettingsForm.Designer.cs"><DependentUpon>AgentSettingsForm.cs</DependentUpon></Compile> <Compile Include="Forms\AgentHistoryForm.Designer.cs">
<Compile Include="Forms\wBitmapView.cs"><SubType>Form</SubType></Compile> <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\DialectService.cs" />
<Compile Include="Kb\EmbeddingClient.cs" /> <Compile Include="Kb\EmbeddingClient.cs" />
<Compile Include="Kb\FileParsers.cs" /> <Compile Include="Kb\FileParsers.cs" />
...@@ -142,15 +161,27 @@ ...@@ -142,15 +161,27 @@
<Compile Include="Support\CostEstimator.cs" /> <Compile Include="Support\CostEstimator.cs" />
<Compile Include="Support\ImageSearch.cs" /> <Compile Include="Support\ImageSearch.cs" />
<Compile Include="Support\MarkdownRenderer.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\StorageCleanup.cs" />
<Compile Include="Support\TextEncoding.cs" /> <Compile Include="Support\TextEncoding.cs" />
<Compile Include="Support\TextPatterns.cs" /> <Compile Include="Support\TextPatterns.cs" />
<Compile Include="wMyAgent.cs"><SubType>Form</SubType></Compile> <Compile Include="wMyAgent.cs">
<Compile Include="wMyAgent.Designer.cs"><DependentUpon>wMyAgent.cs</DependentUpon></Compile> <SubType>Form</SubType>
<EmbeddedResource Include="Forms\AgentHistoryForm.resx"><DependentUpon>AgentHistoryForm.cs</DependentUpon></EmbeddedResource> </Compile>
<EmbeddedResource Include="Forms\AgentSettingsForm.resx"><DependentUpon>AgentSettingsForm.cs</DependentUpon></EmbeddedResource> <Compile Include="wMyAgent.Designer.cs">
<EmbeddedResource Include="wMyAgent.resx"><DependentUpon>wMyAgent.cs</DependentUpon></EmbeddedResource> <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"> <EmbeddedResource Include="Resources\cn_synonym.txt">
<LogicalName>Tools.Agent.Resources.cn_synonym.txt</LogicalName> <LogicalName>Tools.Agent.Resources.cn_synonym.txt</LogicalName>
</EmbeddedResource> </EmbeddedResource>
...@@ -174,6 +205,21 @@ ...@@ -174,6 +205,21 @@
<None Include="THIRD-PARTY-NOTICES.txt" /> <None Include="THIRD-PARTY-NOTICES.txt" />
<None Include="packages.config" /> <None Include="packages.config" />
</ItemGroup> </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="$(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')" /> <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
...@@ -28,6 +28,8 @@ namespace Tools.MyAgent ...@@ -28,6 +28,8 @@ namespace Tools.MyAgent
private readonly ChatRtfCache _rtfCache = new ChatRtfCache(); private readonly ChatRtfCache _rtfCache = new ChatRtfCache();
/// <summary>聊天区控件:RichTextBox(RichBox,RTF 渲染)</summary> /// <summary>聊天区控件:RichTextBox(RichBox,RTF 渲染)</summary>
private RichTextBox _rtfFallback; private RichTextBox _rtfFallback;
/// <summary>思考中旋转指示器(右下角悬浮;替代「思考文本铺满问答区」)</summary>
private Forms.ThinkingSpinner _spinner;
private AgentConfig _config = new AgentConfig(); private AgentConfig _config = new AgentConfig();
private CancellationTokenSource _sendCts; private CancellationTokenSource _sendCts;
private volatile bool _isClosing; private volatile bool _isClosing;
...@@ -38,6 +40,10 @@ namespace Tools.MyAgent ...@@ -38,6 +40,10 @@ namespace Tools.MyAgent
private readonly List<string> _pendingImages = new List<string>(); private readonly List<string> _pendingImages = new List<string>();
/// <summary>v2.3 待发送附图缩略图栏(问答区左下角悬浮;每张图右上角叉号可取消)</summary> /// <summary>v2.3 待发送附图缩略图栏(问答区左下角悬浮;每张图右上角叉号可取消)</summary>
private FlowLayoutPanel _pendingBar; 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>(); private readonly List<string> _producedFiles = new List<string>();
/// <summary>本轮回复用量基线快照(发送前记录引擎累计值;回复完成后按增量展示「本回复」用量)</summary> /// <summary>本轮回复用量基线快照(发送前记录引擎累计值;回复完成后按增量展示「本回复」用量)</summary>
private int _replyPromptBase, _replyCompletionBase; private int _replyPromptBase, _replyCompletionBase;
...@@ -89,6 +95,31 @@ namespace Tools.MyAgent ...@@ -89,6 +95,31 @@ namespace Tools.MyAgent
@"https?://[^\s\r\n<]+", @"https?://[^\s\r\n<]+",
RegexOptions.Compiled); RegexOptions.Compiled);
/// <summary>
/// 匹配回答「引用」节的引用行(P4):📎 &lt;知识库相对路径&gt; §&lt;章节&gt; L&lt;行号&gt; 匹配度 &lt;0.xx&gt;。
/// <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) public wMyAgent() : this(null, null, null)
{ {
} }
...@@ -130,6 +161,51 @@ namespace Tools.MyAgent ...@@ -130,6 +161,51 @@ namespace Tools.MyAgent
private void CreateChatControl() private void CreateChatControl()
{ {
_rtfFallback = CreateRtfFallback(); _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() private RichTextBox CreateRtfFallback()
...@@ -279,8 +355,17 @@ namespace Tools.MyAgent ...@@ -279,8 +355,17 @@ namespace Tools.MyAgent
BackupApiKey = SecretProtector.Protect(cfg.BackupApiKey), BackupApiKey = SecretProtector.Protect(cfg.BackupApiKey),
EnableOptionalTools = cfg.EnableOptionalTools, EnableOptionalTools = cfg.EnableOptionalTools,
EnableImageInput = cfg.EnableImageInput, EnableImageInput = cfg.EnableImageInput,
QuickAnswer = cfg.QuickAnswer,
ConciseAnswer = cfg.ConciseAnswer,
ShowThinking = cfg.ShowThinking,
AnswerWithImages = cfg.AnswerWithImages,
MaxAnswerImages = cfg.MaxAnswerImages,
KbHitThreshold = cfg.KbHitThreshold,
MaxToolRounds = cfg.MaxToolRounds, MaxToolRounds = cfg.MaxToolRounds,
MaxHistoryMessages = cfg.MaxHistoryMessages, MaxHistoryMessages = cfg.MaxHistoryMessages,
// 修复既有缺陷:CustomPrices(费用估算价格覆盖)此前未进白名单投影,
// 设置页保存一次即被静默清空(引擎 CostEstimator.Configure 读到空表 → 回退内置价格表)
CustomPrices = cfg.CustomPrices ?? new Dictionary<string, string>(),
DbConnections = protectedConns, DbConnections = protectedConns,
ToolRoleAllow = cfg.ToolRoleAllow ?? new Dictionary<string, string>(), ToolRoleAllow = cfg.ToolRoleAllow ?? new Dictionary<string, string>(),
}; };
...@@ -340,6 +425,8 @@ namespace Tools.MyAgent ...@@ -340,6 +425,8 @@ namespace Tools.MyAgent
} }
_engine.Configure(_config); _engine.Configure(_config);
_engine.SetOptionalTools(_config.EnableOptionalTools); _engine.SetOptionalTools(_config.EnableOptionalTools);
// 回答形态(P1):思考是否仍渲染进问答区(默认 false → 只走右下旋转指示)
MarkdownRenderer.ShowThinking = _config.ShowThinking;
// 工具级权限(P3):当前登录用户名即角色,用于 db_query/module_patch 等按角色开关 // 工具级权限(P3):当前登录用户名即角色,用于 db_query/module_patch 等按角色开关
_engine.CurrentUser = string.IsNullOrEmpty(CurrentUser) ? "访客" : CurrentUser; _engine.CurrentUser = string.IsNullOrEmpty(CurrentUser) ? "访客" : CurrentUser;
// X2 插件化:扫描 Agent/plugins 目录加载第三方工具(幂等,失败仅记日志) // X2 插件化:扫描 Agent/plugins 目录加载第三方工具(幂等,失败仅记日志)
...@@ -411,6 +498,7 @@ namespace Tools.MyAgent ...@@ -411,6 +498,7 @@ namespace Tools.MyAgent
_engine.OnToolStatus += s => PostToUi(() => OnToolStatusChanged(s)); _engine.OnToolStatus += s => PostToUi(() => OnToolStatusChanged(s));
_engine.OnFileProduced += f => PostToUi(() => OnFileProduced(f)); _engine.OnFileProduced += f => PostToUi(() => OnFileProduced(f));
_engine.OnComplete += () => PostToUi(OnAssistantComplete); _engine.OnComplete += () => PostToUi(OnAssistantComplete);
_engine.OnAnswerAttachments += a => PostToUi(() => OnAnswerAttachments(a)); // P5:回答形态 + 关联图片
_engine.OnToolTrace += trace => PostToUi(() => OnToolTrace(trace)); _engine.OnToolTrace += trace => PostToUi(() => OnToolTrace(trace));
_engine.OnError += m => PostToUi(() => OnEngineError(m)); _engine.OnError += m => PostToUi(() => OnEngineError(m));
_engine.OnStatusChanged += () => PostToUi(UpdateStatusIndicator); _engine.OnStatusChanged += () => PostToUi(UpdateStatusIndicator);
...@@ -499,6 +587,101 @@ namespace Tools.MyAgent ...@@ -499,6 +587,101 @@ namespace Tools.MyAgent
_rtfFallback.Rtf = rtf; _rtfFallback.Rtf = rtf;
_rtfFallback.Select(_rtfFallback.TextLength, 0); _rtfFallback.Select(_rtfFallback.TextLength, 0);
_rtfFallback.ScrollToCaret(); _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>指定消息的工具轨迹(&lt;details&gt;)是否展开</summary> /// <summary>指定消息的工具轨迹(&lt;details&gt;)是否展开</summary>
...@@ -589,6 +772,7 @@ namespace Tools.MyAgent ...@@ -589,6 +772,7 @@ namespace Tools.MyAgent
_messages.Add(new ChatMessage { Role = "assistant", Content = "", IsStreaming = true }); _messages.Add(new ChatMessage { Role = "assistant", Content = "", IsStreaming = true });
_sessionSaved = false; // 对话内容已变化,下次新对话需重新记录历史 _sessionSaved = false; // 对话内容已变化,下次新对话需重新记录历史
_producedFiles.Clear(); // 新一轮开始,清空上轮输出文件 _producedFiles.Clear(); // 新一轮开始,清空上轮输出文件
ClearAnswerImages(); // P5:新一轮开始清空上轮回答附图轨(本轮到图时再填充)
RefreshChat(); RefreshChat();
txtInput.Focus(); txtInput.Focus();
...@@ -627,6 +811,7 @@ namespace Tools.MyAgent ...@@ -627,6 +811,7 @@ namespace Tools.MyAgent
_expandedDetails.Clear(); _expandedDetails.Clear();
_pendingImages.Clear(); // 新对话:清空待发送附图 _pendingImages.Clear(); // 新对话:清空待发送附图
RefreshPendingBar(); RefreshPendingBar();
ClearAnswerImages(); // P5:新对话清空回答附图轨
_sessionSaved = true; _sessionSaved = true;
_rtfCache.Clear(); _rtfCache.Clear();
_engine.ResetUsage(); // D16:新对话重置会话用量统计 _engine.ResetUsage(); // D16:新对话重置会话用量统计
...@@ -640,6 +825,7 @@ namespace Tools.MyAgent ...@@ -640,6 +825,7 @@ namespace Tools.MyAgent
private void OnAssistantTextDelta(string text) private void OnAssistantTextDelta(string text)
{ {
SetThinking(false); // 三态机:正文首帧到达即隐藏旋转指示(此后由正文本身表达进度)
var last = LastAssistantMessage(); var last = LastAssistantMessage();
if (last == null) return; if (last == null) return;
last.Content = text; last.Content = text;
...@@ -648,22 +834,27 @@ namespace Tools.MyAgent ...@@ -648,22 +834,27 @@ namespace Tools.MyAgent
private void OnAssistantThinkingDelta(string thinking) private void OnAssistantThinkingDelta(string thinking)
{ {
SetThinking(true); // 三态机:收到思考即进入 Thinking(此时正文仍为空)
var last = LastAssistantMessage(); var last = LastAssistantMessage();
if (last == null) return; if (last == null) return;
last.ThinkingContent = thinking; last.ThinkingContent = thinking;
RefreshChat(); // ShowThinking=false(默认)时不重绘:思考不再渲染,重绘只会白白刷新整段 RTF
if (MarkdownRenderer.ShowThinking) RefreshChat();
} }
private void OnToolStatusChanged(string status) private void OnToolStatusChanged(string status)
{ {
var last = LastAssistantMessage(); // P1:不再覆写 last.Content —— 旧实现把「【正在执行】…」写进回答正文,
if (last == null) return; // 一旦失败/取消就永久残留在问答区(用户看到一条永远停在「正在执行」的回答)。
last.Content = $"【正在执行】{status}…"; // 进度表达交给状态栏「回复中...」+ 右下旋转指示;具体工具名保留在指示器悬停提示里,信息不丢。
RefreshChat(); SetThinking(true);
if (_spinner != null && tipScope != null && !string.IsNullOrEmpty(status))
tipScope.SetToolTip(_spinner, "正在执行:" + status);
} }
private void OnAssistantComplete() private void OnAssistantComplete()
{ {
SetThinking(false); // 三态机回到 Idle(正常完成路径)
var last = LastAssistantMessage(); var last = LastAssistantMessage();
if (last != null) if (last != null)
{ {
...@@ -681,6 +872,29 @@ namespace Tools.MyAgent ...@@ -681,6 +872,29 @@ namespace Tools.MyAgent
SaveDraftSession(); // D12:每轮回答完成即更新草稿(崩溃恢复) 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> /// <summary>构造本轮回复用量标注(相对发送前快照的增量;本轮无调用记录返回空串)</summary>
private string BuildReplyUsageSuffix() private string BuildReplyUsageSuffix()
{ {
...@@ -727,6 +941,7 @@ namespace Tools.MyAgent ...@@ -727,6 +941,7 @@ namespace Tools.MyAgent
private void OnEngineError(string message) private void OnEngineError(string message)
{ {
if (_isClosing || IsDisposed || Disposing) return; if (_isClosing || IsDisposed || Disposing) return;
SetThinking(false); // P1 兜底:取消/报错路径必须让指示器回到 Idle(否则永久转圈)
var last = LastAssistantMessage(); var last = LastAssistantMessage();
if (last != null && last.IsStreaming) if (last != null && last.IsStreaming)
{ {
...@@ -1202,6 +1417,14 @@ namespace Tools.MyAgent ...@@ -1202,6 +1417,14 @@ namespace Tools.MyAgent
_rtfFallback.Select(charIdx, 0); _rtfFallback.Select(charIdx, 0);
return; return;
} }
// 引用行(P4):**优先于** 绝对路径与折叠切换 —— 引用行里是知识库相对路径,
// 只有引用表知道它的真实落地位置(绝对路径/UNC 正则会把它当普通文本放过去)
AnswerRef answerRef;
if (TryGetAnswerRefAt(charIdx, out answerRef))
{
OpenAnswerRef(answerRef);
return;
}
// 工具轨迹折叠块:点击摘要行切换展开/折叠(对齐 WebView 的 &lt;details&gt; 交互) // 工具轨迹折叠块:点击摘要行切换展开/折叠(对齐 WebView 的 &lt;details&gt; 交互)
if (TryToggleDetailsAt(charIdx)) return; if (TryToggleDetailsAt(charIdx)) return;
string path; string path;
...@@ -1229,6 +1452,14 @@ namespace Tools.MyAgent ...@@ -1229,6 +1452,14 @@ namespace Tools.MyAgent
if (charIdx == _lastHoverChar) return; if (charIdx == _lastHoverChar) return;
_lastHoverChar = charIdx; _lastHoverChar = charIdx;
// 引用行(P4):与绝对路径同一套浅蓝高亮 + 手型反馈
AnswerRef hoverRef;
if (TryGetAnswerRefAt(charIdx, out hoverRef))
{
SetLinkHover(hoverRef.Start, hoverRef.Length);
return;
}
// 工具轨迹摘要行:手型光标提示可点击 // 工具轨迹摘要行:手型光标提示可点击
if (IsDetailsMarkerAt(charIdx)) if (IsDetailsMarkerAt(charIdx))
{ {
...@@ -1661,17 +1892,96 @@ namespace Tools.MyAgent ...@@ -1661,17 +1892,96 @@ namespace Tools.MyAgent
if (_pendingImages.Count == 0) if (_pendingImages.Count == 0)
{ {
_pendingBar.Visible = false; _pendingBar.Visible = false;
LayoutBottomBars();
return; return;
} }
foreach (var f in _pendingImages) foreach (var f in _pendingImages)
_pendingBar.Controls.Add(CreateThumb(f)); _pendingBar.Controls.Add(CreateThumb(f));
_pendingBar.Width = Math.Min(panelChat.ClientSize.Width - 8, _pendingImages.Count * 72 + 12); _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; _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) 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 }; var box = new Panel { Width = 68, Height = 64, Margin = new Padding(2), BackColor = Color.White };
try try
...@@ -1698,6 +2008,7 @@ namespace Tools.MyAgent ...@@ -1698,6 +2008,7 @@ namespace Tools.MyAgent
box.Controls.Add(pic); box.Controls.Add(pic);
} }
catch { } catch { }
if (onRemove == null) return box; // 只读:不挂 ✕
var close = new Label var close = new Label
{ {
Text = "✕", Text = "✕",
...@@ -1710,11 +2021,7 @@ namespace Tools.MyAgent ...@@ -1710,11 +2021,7 @@ namespace Tools.MyAgent
Font = new Font("微软雅黑", 8F, FontStyle.Bold), Font = new Font("微软雅黑", 8F, FontStyle.Bold),
Cursor = Cursors.Hand, Cursor = Cursors.Hand,
}; };
close.Click += (s, e) => close.Click += (s, e) => onRemove();
{
_pendingImages.Remove(file);
RefreshPendingBar();
};
box.Controls.Add(close); box.Controls.Add(close);
close.BringToFront(); close.BringToFront();
return box; return box;
...@@ -2738,6 +3045,7 @@ namespace Tools.MyAgent ...@@ -2738,6 +3045,7 @@ namespace Tools.MyAgent
_messages.Clear(); _messages.Clear();
_messages.AddRange(session.Messages); _messages.AddRange(session.Messages);
_expandedDetails.Clear(); _expandedDetails.Clear();
ClearAnswerImages(); // P5:切换会话清空回答附图轨(避免上一会话的图留在新会话上)
_sessionSaved = true; // 加载的内容已记录过历史,直接「新对话」不再重复保存 _sessionSaved = true; // 加载的内容已记录过历史,直接「新对话」不再重复保存
_rtfCache.Clear(); _rtfCache.Clear();
RefreshChat(); RefreshChat();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment