Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
F
Ftt_Common
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
Ftt_Common
Commits
67a4bda7
Commit
67a4bda7
authored
Sep 18, 2026
by
段廷瑞
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: Agent问答交互功能优化
parent
8d4b5d0b
Changes
12
Hide whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
744 additions
and
225 deletions
+744
-225
Tools.Agent.dll
AppHome/Tools.Agent.dll
+0
-0
Tools.Agent.xml
AppHome/Tools.Agent.xml
+135
-30
AgentEngine.cs
Tools.Agent/Agent/AgentEngine.cs
+1
-1
KbRecall.cs
Tools.Agent/Agent/Answer/KbRecall.cs
+25
-4
AgentConfig.cs
Tools.Agent/Config/AgentConfig.cs
+4
-2
AgentSettingsForm.cs
Tools.Agent/Forms/AgentSettingsForm.cs
+3
-108
KbService.cs
Tools.Agent/Kb/KbService.cs
+61
-0
ChatRtfCache.cs
Tools.Agent/Support/ChatRtfCache.cs
+25
-0
MarkdownRenderer.cs
Tools.Agent/Support/MarkdownRenderer.cs
+260
-1
RichEditObjects.cs
Tools.Agent/Support/RichEditObjects.cs
+95
-0
Tools.Agent.csproj
Tools.Agent/Tools.Agent.csproj
+1
-0
wMyAgent.cs
Tools.Agent/wMyAgent.cs
+134
-79
No files found.
AppHome/Tools.Agent.dll
View file @
67a4bda7
No preview for this file type
AppHome/Tools.Agent.xml
View file @
67a4bda7
...
...
@@ -587,7 +587,7 @@
<summary>
是否采集关联图片
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecall.MaxImages"
>
<summary>
关联图片上限
</summary>
<summary>
关联图片上限
(内部默认 16;引擎 Configure 按 AgentConfig.MaxAnswerImages 注入)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.KbRecall.MaxHits"
>
<summary>
纳入证据链的命中上限(主命中 + 其余)
</summary>
...
...
@@ -635,9 +635,10 @@
</member>
<member
name=
"M:Tools.MyAgent.Agent.KbRecall.CollectImages(Tools.MyAgent.Agent.KbHit,System.String[],System.Int32)"
>
<summary>
采集关联图片(
三条规则合并去重,最多 max 张,全部过沙箱
校验):
采集关联图片(
四条规则合并去重,最多 max 张,全部过
校验):
① 命中段落内的 markdown 图片引用;② 命中行 ±20 行内的图片引用;
③ 同目录与 images/图片/assets/imgs 下与文档名或命中关键词有字面交集的图片。
③ 同目录与 images/图片/assets/imgs 下与文档名或命中关键词有字面交集的图片;
④ docx 内嵌位图(v1.3):Word 图片在 zip 包内(word/media/),文本提取与同目录规则都够不着 ⇒ 解包提取。
</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)"
>
...
...
@@ -1082,7 +1083,7 @@
<summary>
回答附图(P5):本轮回答命中文档关联的图片以缩略图栏展示,点击放大(复用 wBitmapView)
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.MaxAnswerImages"
>
<summary>
回答附图上限(张,1~
8
)
</summary>
<summary>
回答附图上限(张,1~
16;暂定 16,无设置页,手改配置文件可调
)
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.KbHitThreshold"
>
<summary>
...
...
@@ -1249,12 +1250,6 @@
<member
name=
"M:Tools.MyAgent.Forms.AgentSettingsForm.ParseToolRoleRules(System.String)"
>
<summary>
编辑文本 → 角色规则字典(忽略空行/非法行)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Forms.AgentSettingsForm.BuildAnswerShapeTab"
>
<summary>
新增「回答形态」选项卡(程序化创建,避免侵入设计器):
思考呈现方式、本地召回直答、契约化输出、回答附图与直答阈值。
</summary>
</member>
<!-- Badly formed XML comment ignored for member "M:Tools.MyAgent.Forms.AgentSettingsForm.PopulateRoles" -->
<member
name=
"M:Tools.MyAgent.Forms.AgentSettingsForm.SetModelSuggestions(System.Windows.Forms.ComboBox,System.Int32,System.String)"
>
<summary>
更新模型建议;当前配置始终保留在可选项中,兼容新发布模型和旧配置。
</summary>
...
...
@@ -2030,6 +2025,18 @@
<member
name=
"M:Tools.MyAgent.Kb.KbService.ResolveSheet(NPOI.SS.UserModel.IWorkbook,System.String)"
>
<summary>
按表名或 1 基序号定位工作表;空 sheet 参数 = 第一个工作表
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Kb.KbService.ExtractDocxImages(System.String,System.Int32)"
>
<summary>
提取 docx 内嵌位图到本地缓存并返回绝对路径(v1.3:Word 内嵌图片进入回答附图轨)。
<para>
docx 是 zip 包,图片在 word/media/* 下,正文提取(
<see
cref=
"M:Tools.MyAgent.Kb.KbService.ReadDocx(System.String)"
/>
)不含图片引用,
故附图采集必须显式解包。提取落点 {OutputRoot}/kb_media/{md5(路径)}/{安全文件名} ——
在本机数据目录而**不在知识库内**(共享/只读知识库可安全使用);同名文件已存在直接复用(幂等)。
只返回白名单位图扩展(emf/wmf 矢量图、>10MB 巨图跳过);返回顺序即包内文档序(近似正文出现序)。
</para>
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Kb.KbService.StableHash64(System.String)"
>
<summary>
路径 → 16 进制短哈希(缓存目录键;MD5 足够,非安全用途)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Kb.KbService.ReadPdf(System.String)"
>
<summary>
提取 PDF 全文(PdfPig net45,支持文本层和中文字体映射)。
...
...
@@ -2494,6 +2501,13 @@
流式增量只重建变化的那一条消息块,历史块直接复用,避免长对话 O(n²) 全量重绘。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.ChatRtfCache.LastImageSequence"
>
<summary>
最近一次 Build 的缩略图序列(v1.3.5):全部消息成功内联成 \pict 的图片绝对路径按渲染顺序拼接。
与 IRichEditOle 对象表(文档序,wMyAgent.RebuildAnswerRefs 读取)按下标一一对应,
供「点击缩略图放大」映射。命中缓存的块沿用上次记录。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.ChatRtfCache.Clear"
>
<summary>
清空缓存(新对话/加载历史时调用)
</summary>
</member>
...
...
@@ -2596,6 +2610,13 @@
Markdown → RTF 渲染器(迁移自 Tools.MyCommon.MarkdownRenderer)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.MarkdownRenderer.AnswerRefPattern"
>
<summary>
回答引用行(P4)解析:📎
<
知识库相对路径
>
§
<
章节
>
L
<
行号
>
匹配度
<
0.xx
>
。
<para>
命名组编号 path=1 / sec=2 / line=3 / score=4 —— 与 wMyAgent 点击命中侧的下标读取保持一致(复用同一个正则对象,避免两处漂移)。
章节 / 行号 / 匹配度三段均可缺省;路径惰性匹配 + 扩展名锚点,允许中文路径含空格。
</para>
</summary>
</member>
<member
name=
"P:Tools.MyAgent.MarkdownRenderer.ShowThinking"
>
<summary>
是否仍在问答区渲染思考文本(由宿主按配置注入,默认 false)。
...
...
@@ -2616,6 +2637,51 @@
未标注(走原工具循环)或未知取值返回空串。附带关联图片数,便于对上下方附图轨属于哪一轮。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.MarkdownRenderer.ThumbHeight"
>
<summary>
内嵌缩略图显示高(96dpi 基线像素;twips = px × 15)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.MarkdownRenderer.AppendAnswerImages(System.Text.StringBuilder,System.Collections.Generic.List{System.String},System.Boolean)"
>
<summary>
把回答附图渲染进消息流:可选「参考图片」小节标题 + 缩略图(RTF \pict,DIB 内联)。
<para>
v1.3.5:不再输出 📎 文字链接行 —— 缩略图本身可点击:wMyAgent 经 IRichEditOle 对象表
(REOBJECT.cp)命中缩略图后直接调 wBitmapView 放大;成功内联的图片按渲染顺序记入
<see
cref=
"P:Tools.MyAgent.MarkdownRenderer.ImageCollector"
/>
(ChatRtfCache 读取后与对象表按下标一一对应)。
</para>
<para>
压缩失败/文件缺失的图降级为文件名占位(不产生 \pict、不进映射表),不影响整块渲染。
</para>
</summary>
<param
name=
"withHeader"
>
true 时先输出「参考图片」小节标题(v1.3.3:图片块插在引用节之前时使用)
</param>
</member>
<member
name=
"P:Tools.MyAgent.MarkdownRenderer.ImageCollector"
>
<summary>
渲染期间收集成功内联成 \pict 的图片绝对路径(渲染顺序)。由 ChatRtfCache 在块重渲染前置空、
渲染后读取;命中缓存不重渲染时沿用上次记录(v1.3.5 缩略图点击映射用)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.MarkdownRenderer.ThumbMarker"
>
<summary>
缩略图命中标记(v1.3.5b):每张成功内联的 \pict 后紧跟一个零宽空格 U+200B(fs2 隐身)。
实证 \pict 对象字符在 Text 里是 0x20 槽位(与真实空格不可区分),且真机 IRichEditOle 对象表
未给出命中 —— 标记是 Text 流中唯一可按序数定位缩略图的纯托管通道。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.MarkdownRenderer.IndexOfReferenceSection(System.String)"
>
<summary>
在回答正文里定位「引用」小节标题行的行首下标(v1.3.3:图片块插到它之前)。
<para>
模式镜像
<c>
AnswerSpec.HasLineSection
</c>
(容忍 ### / ** / 全角冒号写法);引用节是回答的收尾小节,
故取**最后一个**命中以跳过正文中偶发的「引用xxx」措辞。未找到返回 -1(调用方兜底为末尾追加)。
</para>
</summary>
</member>
<member
name=
"M:Tools.MyAgent.MarkdownRenderer.AppendInlineThumb(System.Text.StringBuilder,System.String)"
>
<summary>
单张内嵌缩略图:等比缩到 ≤72px 高,转 24 位 DIB 以 RTF
<c>
\pict\dibitmap
</c>
十六进制内联。
返回 true 表示成功产出 \pict(进点击映射表);失败降级为文件名占位并返回 false。
<para>
(v1.3.4 实测:本机 RichEdit 对 \pngblip/\jpegblip 及 EMF-in-wmetafile8 一律静默丢弃 —— 丢弃后残留的
空段落正是"图片链接前空行"的真身;\dibitmap 与 16 位 WMF 均被接受,取实现最简的 DIB。
picw/pich 单位为百分之一毫米,picwgoal/pichgoal 为 twips;hex 每 64 字节换行,与原生方言一致。)
</para>
</summary>
</member>
<member
name=
"M:Tools.MyAgent.MarkdownRenderer.BuildDib(System.Drawing.Bitmap,System.Int32,System.Int32)"
>
<summary>
Bitmap → 24 位 DIB(BITMAPINFOHEADER + 自底向上像素行,行宽 4 字节对齐);GDI+ 无 WMF/DIB 导出,手工构造
</summary>
</member>
<member
name=
"M:Tools.MyAgent.MarkdownRenderer.RenderDetailsBlock(System.Text.StringBuilder,System.String,System.Collections.Generic.List{System.String},System.Boolean)"
>
<summary>
<
details
>
折叠块的 RTF 渲染:折叠态只显示摘要行(▶ + 点击展开提示),
...
...
@@ -2626,6 +2692,18 @@
<member
name=
"M:Tools.MyAgent.MarkdownRenderer.BuildCmdHyperlink(System.String)"
>
<summary>
命令渲染为蓝色下划线文本(同上,不使用 HYPERLINK 字段)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.MarkdownRenderer.RenderAnswerRefLine(System.Text.StringBuilder,System.String)"
>
<summary>
回答引用行(P4)分段渲染:📎 + 路径(蓝色下划线,可点击定位) + 章节/行号(绿色) + 匹配度(灰色)。
<para>
分隔符(空格 / § / L / “匹配度”字样)跟随其后相邻段着色,避免出现“黑 § + 绿章节”的割裂观感。
全部文本走 Escape(D1 代理对处理)—— EscapeRtf 逐字符转义会丢弃 📎。
</para>
</summary>
</member>
<member
name=
"M:Tools.MyAgent.MarkdownRenderer.AppendRefSegment(System.Text.StringBuilder,System.String,System.Int32@,System.Text.RegularExpressions.Group,System.Int32,System.Boolean)"
>
<summary>
渲染引用行的一段:段前空隙(分隔符)与本段同色,段体按 color 着色;下划线仅作用于段体(不含分隔符)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.MarkdownRenderer.AppendEscaped(System.Text.StringBuilder,System.String)"
>
<summary>
逐字符转义;非 BMP 代理对(4 字节 emoji)切 Segoe UI Emoji 字体并以 \uN 双转义组合渲染(D1),
...
...
@@ -2651,6 +2729,24 @@
RichEdit 在获得焦点 / 鼠标按下 / 按键移动光标时会重新显示插入符,故在这些消息处理后再次隐藏。
</para>
</summary>
</member>
<member
name=
"T:Tools.MyAgent.RichEditObjects"
>
<summary>
RichEdit 内联对象位置表(v1.3.5 缩略图点击):经 EM_GETOLEINTERFACE 取 IRichEditOle,
逐个读取 REOBJECT.cp —— \pict 在 RichEdit20W 文本流中占一个对象字符,cp 即它在 Text 里的下标;
对象按文档序返回,与渲染序列(ChatRtfCache.LastImageSequence)按下标一一对应。
<para>
背景:实测 \pict 的对象字符在 RichTextBox.Text 里表现为 0x20(与真实空格无法区分),文本扫描不可行;
本互操作是唯一可靠的命中通道。任何异常/可疑数据都返回空表(功能静默降级为不可点,不影响其余交互)。
</para>
</summary>
</member>
<member
name=
"F:Tools.MyAgent.RichEditObjects.ReoGetObjNoInterfaces"
>
<summary>
REO_GETOBJ_NO_INTERFACES:只取结构体(cp/clsid),不取 COM 接口指针
</summary>
</member>
<member
name=
"F:Tools.MyAgent.RichEditObjects.MaxObjects"
>
<summary>
对象数上限(超限视为互操作异常数据,整体弃用)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.RichEditObjects.GetObjectCharPositions(System.Windows.Forms.RichTextBox)"
>
<summary>
返回控件里全部内联对象(\pict 缩略图)的字符位置(文档序);异常或数据可疑时返回空表。
</summary>
</member>
<member
name=
"T:Tools.MyAgent.StorageCleanup"
>
<summary>
存储自动清理(评估缺陷 #1):日志与中间产物按保留期自动清理,防止长期运行磁盘无限膨胀。
...
...
@@ -2726,12 +2822,6 @@
<member
name=
"F:Tools.MyAgent.wMyAgent._pendingBar"
>
<summary>
v2.3 待发送附图缩略图栏(问答区左下角悬浮;每张图右上角叉号可取消)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.wMyAgent._answerImages"
>
<summary>
P5 回答附图列表:本轮直答命中的知识库关联图片;仅在直答命中知识库时非空
</summary>
</member>
<member
name=
"F:Tools.MyAgent.wMyAgent._answerImageBar"
>
<summary>
P5 回答附图轨(问答区左下角悬浮,纵向叠在待发送附图栏之上;只读展示,无删除按钮)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.wMyAgent._replyPromptBase"
>
<summary>
本轮回复用量基线快照(发送前记录引擎累计值;回复完成后按增量展示「本回复」用量)
</summary>
</member>
...
...
@@ -2769,6 +2859,7 @@
<summary>
匹配回答「引用」节的引用行(P4):📎
<
知识库相对路径
>
§
<
章节
>
L
<
行号
>
匹配度
<
0.xx
>
。
<para>
章节 / 行号 / 匹配度三段均可缺省(正则容错);路径用惰性匹配 + 扩展名锚点,允许路径含空格(中文知识库常见)。
</para>
<para>
与渲染侧(MarkdownRenderer.RenderAnswerRefLine)共用同一个正则对象,避免点击命中与着色分段漂移。
</para>
</summary>
</member>
<member
name=
"T:Tools.MyAgent.wMyAgent.AnswerRef"
>
...
...
@@ -2780,6 +2871,16 @@
流式期间不扫描(完成时算一次),避免每帧全文正则扫描的 O(n) 白耗。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.wMyAgent._answerImageSeq"
>
<summary>
缩略图点击映射(v1.3.5):渲染序列里成功内联成 \pict 的图片绝对路径(渲染顺序),
来源 ChatRtfCache.LastImageSequence,与命中位置表按下标一一对应
</summary>
</member>
<member
name=
"F:Tools.MyAgent.wMyAgent._thumbMarkers"
>
<summary>
主命中通道(v1.3.5b):Text 流里零宽标记 U+200B 的位置(与 \pict 一一配对,随 RebuildAnswerRefs 重建)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.wMyAgent._pictPositions"
>
<summary>
备用命中通道:IRichEditOle 对象表 cp(真机未命中过,保留作降级证据);流式期间不刷新(禁用点击)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.#ctor(System.String,System.String)"
>
<summary>
兼容旧调用:创建未绑定产品上下文的聊天窗体。
</summary>
</member>
...
...
@@ -2850,6 +2951,19 @@
<member
name=
"M:Tools.MyAgent.wMyAgent.TryGetAnswerRefAt(System.Int32,Tools.MyAgent.wMyAgent.AnswerRef@)"
>
<summary>
命中:鼠标位置是否落在某条引用行内(P4)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.TryGetAnswerImageAt(System.Int32,System.String@)"
>
<summary>
命中:charIdx 附近是否是缩略图(v1.3.5b)。点在图上时 GetCharIndexFromPosition 可能返回
图片两侧的相邻字符(对象锚点在图左上角、标记在图右侧),故取距离最近的命中位(半径 ±2 个字符位)。
主通道为零宽标记序数;标记缺失时回退 IRichEditOle 对象表序数。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.NearestPosition(System.Collections.Generic.List{System.Int32},System.Int32,System.Int32@)"
>
<summary>
位置表中距 charIdx 最近者(半径 ±2),命中返回其下标
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.OpenAnswerImage(System.String)"
>
<summary>
打开缩略图对应的大图(v1.3.5):直接走内置图片查看器
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.OpenAnswerRef(Tools.MyAgent.wMyAgent.AnswerRef)"
>
<summary>
打开引用行(P4):知识库相对路径 → 内置行定位查看器(文本类)或默认程序(docx/xlsx/pdf 等);
...
...
@@ -2868,7 +2982,8 @@
<member
name=
"M:Tools.MyAgent.wMyAgent.OnAnswerAttachments(Tools.MyAgent.Agent.AgentEngine.AnswerAttachments)"
>
<summary>
P5:本地直答的形态与关联图片到达(早于
<see
cref=
"M:Tools.MyAgent.wMyAgent.OnAssistantComplete"
/>
,因引擎在直答路径内先发本事件再收尾)。
<para>
形态记在助手消息上(会话可持久化、渲染成顶部标注),图片同时进入附图轨。
</para>
<para>
形态与图片都记在助手消息上(会话可持久化);v1.3.2 起图片不再走悬浮轨,
而是随
<see
cref=
"M:Tools.MyAgent.wMyAgent.RefreshChat"
/>
内嵌渲染在回答正文下方(缓存键含图数 ⇒ 到图自动重绘)。
</para>
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.BuildReplyUsageSuffix"
>
...
...
@@ -2988,20 +3103,10 @@
<member
name=
"M:Tools.MyAgent.wMyAgent.RefreshPendingBar"
>
<summary>
刷新缩略图栏:按待发送列表重建缩略图块;空则隐藏
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.EnsureAnswerImageBar"
>
<summary>
创建回答附图轨(左下角悬浮;懒创建)。只读展示,故不加删除按钮。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.RefreshAnswerImageBar"
>
<summary>
刷新回答附图轨:按本轮回答附图重建缩略图块;空则隐藏
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.ClearAnswerImages"
>
<summary>
清空回答附图轨(新对话 / 切换会话 / 发出新一轮问题时调用)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.LayoutBottomBars"
>
<summary>
底部两条悬浮栏纵向叠加:待发送附图栏贴底(左下角),回答附图轨叠在其**上方**。
<para>
两者都用 Anchor=Bottom|Left + 手工 Location:若让新栏 Dock=Bottom,会与贴底栏落在同一像素带。
待发送栏出现/消失时必须整体重排,否则回答附图轨会被压在下面看不见。
</para>
底部悬浮栏定位:待发送附图栏贴底(左下角)。
<para>
(v1.3.2 前此处还有回答附图轨的纵向叠加;回答附图内嵌后仅剩待发送栏一层。)
</para>
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.CreateThumb(System.String)"
>
...
...
@@ -3009,7 +3114,7 @@
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.CreateThumb(System.String,System.Action)"
>
<summary>
单个缩略图块:图片预览(Zoom)+ 可选右上角 ✕ 移除按钮;点击缩略图按原始尺寸放大查看。
</summary>
<param
name=
"onRemove"
>
✕ 的回调;传
<c>
null
</c>
表示只读展示
(如回答附图轨)
,此时不创建 ✕ 按钮
</param>
<param
name=
"onRemove"
>
✕ 的回调;传
<c>
null
</c>
表示只读展示,此时不创建 ✕ 按钮
</param>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.DisposeThumbImages(System.Windows.Forms.Control)"
>
<summary>
递归释放缩略图 PictureBox 的图片资源(防 GDI 对象泄漏)
</summary>
...
...
Tools.Agent/Agent/AgentEngine.cs
View file @
67a4bda7
...
...
@@ -297,7 +297,7 @@ namespace Tools.MyAgent.Agent
?
cfg
.
KbHitThreshold
:
0.72
;
_recall
.
ImagesEnabled
=
cfg
.
AnswerWithImages
;
// 关掉附图即不采图(省掉目录扫描)
_recall
.
MaxImages
=
cfg
.
MaxAnswerImages
>
0
?
Math
.
Min
(
cfg
.
MaxAnswerImages
,
1
2
)
:
4
;
_recall
.
MaxImages
=
cfg
.
MaxAnswerImages
>
0
?
Math
.
Min
(
cfg
.
MaxAnswerImages
,
1
6
)
:
16
;
CostEstimator
.
Configure
(
cfg
.
CustomPrices
);
// R6 费用估算:应用自定义价格表覆盖
// P0 连接串脱敏 / P3 工具权限:预存连接与角色规则随配置注入
...
...
Tools.Agent/Agent/Answer/KbRecall.cs
View file @
67a4bda7
...
...
@@ -84,8 +84,8 @@ namespace Tools.MyAgent.Agent
/// <summary>是否采集关联图片</summary>
public
bool
ImagesEnabled
=
true
;
/// <summary>关联图片上限</summary>
public
int
MaxImages
=
4
;
/// <summary>关联图片上限
(内部默认 16;引擎 Configure 按 AgentConfig.MaxAnswerImages 注入)
</summary>
public
int
MaxImages
=
16
;
/// <summary>纳入证据链的命中上限(主命中 + 其余)</summary>
private
const
int
MaxHits
=
3
;
...
...
@@ -404,9 +404,10 @@ namespace Tools.MyAgent.Agent
}
/// <summary>
/// 采集关联图片(
三条规则合并去重,最多 max 张,全部过沙箱
校验):
/// 采集关联图片(
四条规则合并去重,最多 max 张,全部过
校验):
/// ① 命中段落内的 markdown 图片引用;② 命中行 ±20 行内的图片引用;
/// ③ 同目录与 images/图片/assets/imgs 下与文档名或命中关键词有字面交集的图片。
/// ③ 同目录与 images/图片/assets/imgs 下与文档名或命中关键词有字面交集的图片;
/// ④ docx 内嵌位图(v1.3):Word 图片在 zip 包内(word/media/),文本提取与同目录规则都够不着 ⇒ 解包提取。
/// </summary>
private
void
CollectImages
(
KbHit
hit
,
string
[]
lines
,
int
max
)
{
...
...
@@ -459,6 +460,26 @@ namespace Tools.MyAgent.Agent
}
catch
{
}
}
// ④ Word 内嵌位图(v1.3):docx 的图片在 zip 包内(word/media/),①②③ 均够不着 ⇒ 显式解包。
// 提取落点在本机缓存(OutputRoot/kb_media/),不在知识库沙箱内 —— 路径由我们生成、可信,
// 故不走 AddImageAbs 的知识库根校验(那里会因「不在 KB 根下」把缓存图全部拒掉),只做存在/扩展/去重。
try
{
if
(
hit
.
Images
.
Count
<
max
&&
string
.
Equals
(
Path
.
GetExtension
(
hit
.
AbsPath
??
""
),
".docx"
,
StringComparison
.
OrdinalIgnoreCase
))
{
var
embedded
=
_kb
.
ExtractDocxImages
(
hit
.
AbsPath
,
max
-
hit
.
Images
.
Count
);
foreach
(
var
p
in
embedded
)
{
if
(
hit
.
Images
.
Count
>=
max
)
break
;
if
(
string
.
IsNullOrEmpty
(
p
)
||
!
File
.
Exists
(
p
))
continue
;
if
(!
seen
.
Add
(
p
))
continue
;
// 扩展白名单已在 KbService 侧校验
hit
.
Images
.
Add
(
p
);
}
}
}
catch
{
}
}
/// <summary>命中段落里的图片链接 → 绝对路径(相对文档目录解析),校验通过后加入</summary>
...
...
Tools.Agent/Config/AgentConfig.cs
View file @
67a4bda7
...
...
@@ -111,6 +111,8 @@
public
bool
EnableImageInput
{
get
;
set
;
}
=
true
;
// ════════════════ 高级项:回答形态(旋转指示 / 本地直答 / 契约化输出 / 附图) ════════════════
// 说明(v1.2):本组不提供设置页 —— 均为内部默认值(降低设置页认知负担);
// 保留可序列化属性,供手改配置文件微调,或将来需要时再挂 UI。
/// <summary>
/// 本地召回直答(回答形态改造 P3):本轮问题在知识库命中达阈值时,走「单轮直答」通道(不下发工具),
...
...
@@ -133,8 +135,8 @@
/// <summary>回答附图(P5):本轮回答命中文档关联的图片以缩略图栏展示,点击放大(复用 wBitmapView)</summary>
public
bool
AnswerWithImages
{
get
;
set
;
}
=
true
;
/// <summary>回答附图上限(张,1~
8
)</summary>
public
int
MaxAnswerImages
{
get
;
set
;
}
=
4
;
/// <summary>回答附图上限(张,1~
16;暂定 16,无设置页,手改配置文件可调
)</summary>
public
int
MaxAnswerImages
{
get
;
set
;
}
=
16
;
/// <summary>
/// 本地直答阈值(0.45~0.95):融合匹配度 ≥ 该值走单轮直答(mode=kb);
...
...
Tools.Agent/Forms/AgentSettingsForm.cs
View file @
67a4bda7
...
...
@@ -120,7 +120,7 @@ namespace Tools.MyAgent.Forms
InitializeComponent
();
BuildDbConnectionsTab
();
// P0:预存数据库连接(db_query 按 ID 引用,聊天不回显密码)
BuildToolPermissionUi
();
// P3:工具级权限(按角色)输入区
BuildAnswerShapeTab
();
// 回答形态:旋转思考指示 / 本地直答 / 契约化输出 / 回答附图
// 回答形态不再提供设置页(v1.2):全部内部默认(见 AgentConfig 注释),降低设置页认知负担
BindConfig
();
// 配置值回填控件(控件创建见 Designer.cs)
ApplyProviderMode
();
// 布局联动:BaseUrl 行显隐
ApplyKbMode
();
// 布局联动:共享 账号/密码/测试连接 行显隐
...
...
@@ -236,99 +236,6 @@ namespace Tools.MyAgent.Forms
return
rules
;
}
// ══ 回答形态:旋转思考指示 / 本地召回直答 / 契约化输出 / 回答附图 ══
private
CheckBox
chkQuickAnswer
;
private
CheckBox
chkConciseAnswer
;
private
CheckBox
chkShowThinking
;
private
CheckBox
chkAnswerImages
;
private
NumericUpDown
numMaxAnswerImages
;
private
NumericUpDown
numKbHitThreshold
;
/// <summary>
/// 新增「回答形态」选项卡(程序化创建,避免侵入设计器):
/// 思考呈现方式、本地召回直答、契约化输出、回答附图与直答阈值。
/// </summary>
private
void
BuildAnswerShapeTab
()
{
var
tabShape
=
new
TabPage
{
Text
=
"回答形态"
,
Padding
=
new
Padding
(
3
)
};
var
grp
=
new
GroupBox
{
Text
=
"回答形态(思考指示 / 本地直答 / 契约化输出 / 附图)"
,
Location
=
new
Point
(
8
,
12
),
Size
=
new
Size
(
596
,
324
),
};
chkQuickAnswer
=
new
CheckBox
{
Text
=
"本地召回直答:知识库命中达阈值时单轮直答(跳过工具循环);召回异常自动降级"
,
Location
=
new
Point
(
16
,
28
),
AutoSize
=
true
,
};
chkConciseAnswer
=
new
CheckBox
{
Text
=
"契约化输出:知识问答三段式(原因/方案/引用),排查类六段式"
,
Location
=
new
Point
(
16
,
56
),
AutoSize
=
true
,
};
chkShowThinking
=
new
CheckBox
{
Text
=
"仍在问答区渲染思考文本(默认关闭:仅右下旋转指示;调试用)"
,
Location
=
new
Point
(
16
,
84
),
AutoSize
=
true
,
};
chkAnswerImages
=
new
CheckBox
{
Text
=
"回答附图:命中文档关联的图片以缩略图栏展示(点击放大)"
,
Location
=
new
Point
(
16
,
112
),
AutoSize
=
true
,
};
var
lblThreshold
=
new
Label
{
Text
=
"直答阈值"
,
Location
=
new
Point
(
16
,
150
),
AutoSize
=
true
};
numKbHitThreshold
=
new
NumericUpDown
{
Location
=
new
Point
(
96
,
146
),
Size
=
new
Size
(
70
,
23
),
DecimalPlaces
=
2
,
Increment
=
0.01
M
,
Minimum
=
0.45
M
,
Maximum
=
0.95
M
,
Value
=
0.72
M
,
};
var
lblMaxImages
=
new
Label
{
Text
=
"附图上限"
,
Location
=
new
Point
(
196
,
150
),
AutoSize
=
true
};
numMaxAnswerImages
=
new
NumericUpDown
{
Location
=
new
Point
(
276
,
146
),
Size
=
new
Size
(
70
,
23
),
Minimum
=
1
,
Maximum
=
8
,
Value
=
4
,
};
var
hint
=
new
Label
{
Text
=
"直答阈值:[阈值, 0.95] 直接以证据作答;[0.45, 阈值) 标注「部分命中」并允许模型补充;"
+
"低于 0.45 视为未召回,回退原多轮工具循环。\r\n"
+
"关闭「本地召回直答」后行为与改造前完全一致(多轮工具循环 + 原回答形态)。"
,
Location
=
new
Point
(
16
,
186
),
AutoSize
=
false
,
Size
=
new
Size
(
564
,
60
),
ForeColor
=
Color
.
Gray
,
};
grp
.
Controls
.
Add
(
chkQuickAnswer
);
grp
.
Controls
.
Add
(
chkConciseAnswer
);
grp
.
Controls
.
Add
(
chkShowThinking
);
grp
.
Controls
.
Add
(
chkAnswerImages
);
grp
.
Controls
.
Add
(
lblThreshold
);
grp
.
Controls
.
Add
(
numKbHitThreshold
);
grp
.
Controls
.
Add
(
lblMaxImages
);
grp
.
Controls
.
Add
(
numMaxAnswerImages
);
grp
.
Controls
.
Add
(
hint
);
tabShape
.
Controls
.
Add
(
grp
);
tabMain
.
TabPages
.
Add
(
tabShape
);
}
// ══ 配置绑定:把 AgentConfig 值回填到控件(设计器仅设默认值) ══
private
void
BindConfig
()
...
...
@@ -390,13 +297,7 @@ namespace Tools.MyAgent.Forms
// 可选能力页:工具级权限(P3,角色白名单)
txtToolRoles
.
Lines
=
ToolRoleRulesToLines
(
_config
.
ToolRoleAllow
);
// 回答形态页:思考指示 / 本地直答 / 契约化输出 / 附图 / 阈值
chkQuickAnswer
.
Checked
=
_config
.
QuickAnswer
;
chkConciseAnswer
.
Checked
=
_config
.
ConciseAnswer
;
chkShowThinking
.
Checked
=
_config
.
ShowThinking
;
chkAnswerImages
.
Checked
=
_config
.
AnswerWithImages
;
numMaxAnswerImages
.
Value
=
ClampToControl
(
_config
.
MaxAnswerImages
,
numMaxAnswerImages
,
4
);
numKbHitThreshold
.
Value
=
ClampDecimalToControl
(
_config
.
KbHitThreshold
,
numKbHitThreshold
,
0.72
M
);
// 回答形态组不绑定控件(无设置页):AgentConfig 保持内部默认/配置文件值
// 数据库连接(P0):预存连接串(内存明文态)回填表格
dgvDbConn
.
Rows
.
Clear
();
...
...
@@ -853,13 +754,7 @@ namespace Tools.MyAgent.Forms
_config
.
EnableImageInput
=
chkImageInput
.
Checked
;
// v2.2 多模态:图片提问开关
// 可选能力页:工具级权限(P3)
_config
.
ToolRoleAllow
=
ParseToolRoleRules
(
txtToolRoles
.
Text
);
// 回答形态页:思考指示 / 本地直答 / 契约化输出 / 附图 / 阈值
_config
.
QuickAnswer
=
chkQuickAnswer
.
Checked
;
_config
.
ConciseAnswer
=
chkConciseAnswer
.
Checked
;
_config
.
ShowThinking
=
chkShowThinking
.
Checked
;
_config
.
AnswerWithImages
=
chkAnswerImages
.
Checked
;
_config
.
MaxAnswerImages
=
(
int
)
numMaxAnswerImages
.
Value
;
_config
.
KbHitThreshold
=
(
double
)
numKbHitThreshold
.
Value
;
// 回答形态组不落盘(无设置页):AgentConfig 保持内部默认/配置文件值,投影由 wMyAgent.SaveAgentConfig 负责
// 数据库连接(P0):表格 → 字典(ID 大小写不敏感,空格清理;空 ID 行忽略)
var
conns
=
new
Dictionary
<
string
,
string
>(
StringComparer
.
OrdinalIgnoreCase
);
foreach
(
DataGridViewRow
r
in
dgvDbConn
.
Rows
)
...
...
Tools.Agent/Kb/KbService.cs
View file @
67a4bda7
...
...
@@ -1871,6 +1871,67 @@ namespace Tools.MyAgent.Kb
return
sb
.
ToString
();
}
/// <summary>
/// 提取 docx 内嵌位图到本地缓存并返回绝对路径(v1.3:Word 内嵌图片进入回答附图轨)。
/// <para>docx 是 zip 包,图片在 word/media/* 下,正文提取(<see cref="ReadDocx"/>)不含图片引用,
/// 故附图采集必须显式解包。提取落点 {OutputRoot}/kb_media/{md5(路径)}/{安全文件名} ——
/// 在本机数据目录而**不在知识库内**(共享/只读知识库可安全使用);同名文件已存在直接复用(幂等)。
/// 只返回白名单位图扩展(emf/wmf 矢量图、>10MB 巨图跳过);返回顺序即包内文档序(近似正文出现序)。</para>
/// </summary>
public
List
<
string
>
ExtractDocxImages
(
string
docxPath
,
int
max
)
{
var
result
=
new
List
<
string
>();
if
(
max
<=
0
||
string
.
IsNullOrEmpty
(
docxPath
)
||
!
File
.
Exists
(
docxPath
))
return
result
;
if
(
string
.
IsNullOrEmpty
(
OutputRoot
)
||
!
IsOutputValid
)
return
result
;
try
{
string
cacheDir
=
Path
.
Combine
(
OutputRoot
,
"kb_media"
,
StableHash64
(
NormPath
(
docxPath
)).
Substring
(
0
,
12
));
// 12 位短哈希:caption 可读;本地缓存 48bit 碰撞可忽略
Directory
.
CreateDirectory
(
cacheDir
);
using
(
var
zip
=
ZipFile
.
OpenRead
(
docxPath
))
{
foreach
(
var
entry
in
zip
.
Entries
)
{
if
(
result
.
Count
>=
max
)
break
;
string
fullName
=
entry
.
FullName
??
""
;
// 只认 word/media/ 下的资源(文档正文的内嵌图;页眉页脚/批注图也在此目录,一并可接受)
if
(
fullName
.
IndexOf
(
"word/media/"
,
StringComparison
.
OrdinalIgnoreCase
)
<
0
)
continue
;
string
name
=
Path
.
GetFileName
(
fullName
.
Replace
(
'\\'
,
'/'
))
??
""
;
// 安全文件名:ZipArchiveEntry 名可含路径段/特殊字符,只取末段并拒绝 .. 与非法字符
if
(
name
.
Length
==
0
||
name
.
IndexOf
(
".."
,
StringComparison
.
Ordinal
)
>=
0
||
name
.
IndexOfAny
(
Path
.
GetInvalidFileNameChars
())
>=
0
)
continue
;
string
ext
=
Path
.
GetExtension
(
name
).
ToLowerInvariant
();
if
(!
IsImageExtension
(
ext
))
continue
;
// emf/wmf 等矢量图不进附图轨
if
(
entry
.
Length
>
10
*
1024
*
1024
)
continue
;
// >10MB 巨图跳过(缩略图加载不值)
string
abs
=
Path
.
Combine
(
cacheDir
,
name
);
if
(!
File
.
Exists
(
abs
)
||
new
FileInfo
(
abs
).
Length
!=
entry
.
Length
)
{
try
{
entry
.
ExtractToFile
(
abs
,
true
);
}
catch
{
continue
;
}
}
if
(
File
.
Exists
(
abs
))
result
.
Add
(
abs
);
}
}
}
catch
(
Exception
ex
)
{
try
{
AgentLogger
.
Log
(
"提取 docx 内嵌图失败:"
+
ex
.
Message
);
}
catch
{
}
}
return
result
;
}
/// <summary>路径 → 16 进制短哈希(缓存目录键;MD5 足够,非安全用途)</summary>
private
static
string
StableHash64
(
string
s
)
{
using
(
var
md5
=
System
.
Security
.
Cryptography
.
MD5
.
Create
())
{
var
bytes
=
md5
.
ComputeHash
(
Encoding
.
UTF8
.
GetBytes
(
s
??
""
));
var
sb
=
new
StringBuilder
(
32
);
foreach
(
byte
b
in
bytes
)
sb
.
Append
(
b
.
ToString
(
"x2"
));
return
sb
.
ToString
();
}
}
/// <summary>
/// 提取 PDF 全文(PdfPig net45,支持文本层和中文字体映射)。
/// 扫描版(无文字层)PDF 返回 null,由调用方提示 OCR。
...
...
Tools.Agent/Support/ChatRtfCache.cs
View file @
67a4bda7
...
...
@@ -16,6 +16,14 @@ namespace Tools.MyAgent
private
string
_userName
=
""
;
private
readonly
List
<
string
>
_keys
=
new
List
<
string
>();
private
readonly
List
<
string
>
_blocks
=
new
List
<
string
>();
private
readonly
List
<
List
<
string
>>
_msgImages
=
new
List
<
List
<
string
>>();
/// <summary>
/// 最近一次 Build 的缩略图序列(v1.3.5):全部消息成功内联成 \pict 的图片绝对路径按渲染顺序拼接。
/// 与 IRichEditOle 对象表(文档序,wMyAgent.RebuildAnswerRefs 读取)按下标一一对应,
/// 供「点击缩略图放大」映射。命中缓存的块沿用上次记录。
/// </summary>
public
List
<
string
>
LastImageSequence
{
get
;
private
set
;
}
public
string
Build
(
List
<
ChatMessage
>
messages
,
string
welcomeText
,
string
assistantName
,
string
userName
,
Func
<
int
,
bool
>
isDetailsExpanded
=
null
)
{
...
...
@@ -37,6 +45,7 @@ namespace Tools.MyAgent
{
_keys
.
Add
(
null
);
_blocks
.
Add
(
null
);
_msgImages
.
Add
(
null
);
}
var
sb
=
new
StringBuilder
(
4096
);
...
...
@@ -53,7 +62,11 @@ namespace Tools.MyAgent
string
key
=
MessageKey
(
messages
[
i
],
_assistantName
,
_userName
,
expanded
);
if
(!
string
.
Equals
(
_keys
[
i
],
key
,
StringComparison
.
Ordinal
))
{
// v1.3.5:重渲染该块时借 MarkdownRenderer.ImageCollector 记录成功内联的图片(渲染顺序)
MarkdownRenderer
.
ImageCollector
=
new
List
<
string
>();
_blocks
[
i
]
=
MarkdownRenderer
.
RenderMessageBlock
(
messages
[
i
],
_assistantName
,
_userName
,
expanded
);
_msgImages
[
i
]
=
MarkdownRenderer
.
ImageCollector
??
new
List
<
string
>();
MarkdownRenderer
.
ImageCollector
=
null
;
_keys
[
i
]
=
key
;
}
sb
.
Append
(
_blocks
[
i
]);
...
...
@@ -61,6 +74,16 @@ namespace Tools.MyAgent
sb
.
Append
(
@"\par"
);
}
}
// 缩略图点击映射(v1.3.5):文档内 \pict 按消息顺序排列,与 _msgImages 拼接顺序一致
var
seq
=
new
List
<
string
>();
for
(
int
i
=
0
;
i
<
count
;
i
++)
{
var
imgs
=
i
<
_msgImages
.
Count
?
_msgImages
[
i
]
:
null
;
if
(
imgs
!=
null
)
seq
.
AddRange
(
imgs
);
}
LastImageSequence
=
seq
;
sb
.
Append
(
MarkdownRenderer
.
RtfSuffix
);
return
sb
.
ToString
();
}
...
...
@@ -70,6 +93,8 @@ namespace Tools.MyAgent
{
_keys
.
Clear
();
_blocks
.
Clear
();
_msgImages
.
Clear
();
LastImageSequence
=
new
List
<
string
>();
_welcomeKey
=
""
;
_welcomeRtf
=
""
;
_assistantName
=
""
;
...
...
Tools.Agent/Support/MarkdownRenderer.cs
View file @
67a4bda7
using
System
;
using
System.Collections.Generic
;
using
System.Drawing
;
using
System.Drawing.Imaging
;
using
System.IO
;
using
System.Text
;
using
System.Text.RegularExpressions
;
...
...
@@ -29,6 +31,8 @@ namespace Tools.MyAgent
@";\red128\green128\blue160"
+
// 9: thinking purple-gray
@";\red0\green102\blue204"
+
// 10: hyperlink blue
@";\red150\green150\blue150"
+
// 11: deleted link gray
@";\red22\green138\blue74"
+
// 12: 引用行章节(绿)
@";\red130\green130\blue130"
+
// 13: 引用行匹配度(灰)
@"}"
+
@"\viewkind4\uc1\pard\f0\fs20"
;
...
...
@@ -38,6 +42,16 @@ namespace Tools.MyAgent
private
static
readonly
Regex
OrderedListCapture
=
new
Regex
(
@"^(\d+)\.\s(.*)"
,
RegexOptions
.
Compiled
);
private
static
readonly
Regex
LinkPattern
=
new
Regex
(
@"^\[([^\]]+)\]\(([^)]+)\)"
,
RegexOptions
.
Compiled
);
/// <summary>
/// 回答引用行(P4)解析:📎 <知识库相对路径> §<章节> L<行号> 匹配度 <0.xx>。
/// <para>命名组编号 path=1 / sec=2 / line=3 / score=4 —— 与 wMyAgent 点击命中侧的下标读取保持一致(复用同一个正则对象,避免两处漂移)。
/// 章节 / 行号 / 匹配度三段均可缺省;路径惰性匹配 + 扩展名锚点,允许中文路径含空格。</para>
/// </summary>
internal
static
readonly
Regex
AnswerRefPattern
=
new
Regex
(
@"📎\s*(?<path>.+?\.(?:md|markdown|txt|log|csv|tsv|json|xml|cs|sql|config|docx?|xlsx?|pdf|pptx?|png|jpe?g|bmp|gif|tiff?|webp))"
+
@"(?:\s*§\s*(?<sec>\S+))?(?:\s*L(?<line>\d+))?(?:\s*匹配度\s*(?<score>[\d.]+))?"
,
RegexOptions
.
Compiled
);
private
const
int
ClUser
=
1
;
private
const
int
ClTs
=
2
;
private
const
int
ClClaude
=
3
;
...
...
@@ -49,6 +63,8 @@ namespace Tools.MyAgent
private
const
int
ClThinking
=
9
;
private
const
int
ClLink
=
10
;
private
const
int
ClDeleted
=
11
;
private
const
int
ClRefSection
=
12
;
// 引用行:章节 / 行号(绿)
private
const
int
ClRefScore
=
13
;
// 引用行:匹配度(灰)
// Matches Windows absolute paths: C:\..., D:\... with optional file extension(D6:与 wMyAgent 命中检测共用 TextPatterns)
private
static
readonly
Regex
FilePathPattern
=
TextPatterns
.
WindowsFilePath
;
...
...
@@ -188,7 +204,29 @@ namespace Tools.MyAgent
sb
.
Append
(
@"\i0\fs20\cf"
);
sb
.
Append
(
ClText
);
sb
.
Append
(
@"\par\par "
);
}
RenderMarkdown
(
sb
,
msg
.
Content
,
expandDetails
);
// v1.3.2 回答附图内嵌(替代悬浮附图轨);v1.3.3 图片块前移:插到「引用」小节之前,
// 作为「参考图片」小节罗列(先图后引用,阅读顺序连贯);找不到引用节时仍兜底追加在末尾。
// v1.3.3b 修复:此前整段 RenderMarkdown 未随分支重构删除,命中图片时内容重复渲染一遍。
if
(
msg
.
AnswerImages
!=
null
&&
msg
.
AnswerImages
.
Count
>
0
)
{
int
refIdx
=
IndexOfReferenceSection
(
msg
.
Content
);
if
(
refIdx
>=
0
)
{
// TrimEnd:吞掉「引用」标题前的空行(避免渲染成参考图片标题上方的空行)
RenderMarkdown
(
sb
,
msg
.
Content
.
Substring
(
0
,
refIdx
).
TrimEnd
(),
expandDetails
);
AppendAnswerImages
(
sb
,
msg
.
AnswerImages
,
true
);
RenderMarkdown
(
sb
,
msg
.
Content
.
Substring
(
refIdx
),
expandDetails
);
}
else
{
RenderMarkdown
(
sb
,
msg
.
Content
,
expandDetails
);
AppendAnswerImages
(
sb
,
msg
.
AnswerImages
,
false
);
}
}
else
{
RenderMarkdown
(
sb
,
msg
.
Content
,
expandDetails
);
}
// 用量标注(展示用):回答完成后在末尾追加本轮用量(灰色小字)
if
(!
string
.
IsNullOrEmpty
(
msg
.
UsageSuffix
))
...
...
@@ -202,6 +240,167 @@ namespace Tools.MyAgent
}
}
// ── 回答附图内嵌(v1.3.2)──
/// <summary>内嵌缩略图显示高(96dpi 基线像素;twips = px × 15)</summary>
private
const
int
ThumbHeight
=
72
;
private
const
int
ThumbWidthMax
=
160
;
// v1.3.3c:原 ThumbsPerLine(每行手工几张)已移除 —— 手工 \line 与自动折行叠加会产生空行,折行交给控件
/// <summary>
/// 把回答附图渲染进消息流:可选「参考图片」小节标题 + 缩略图(RTF \pict,DIB 内联)。
/// <para>v1.3.5:不再输出 📎 文字链接行 —— 缩略图本身可点击:wMyAgent 经 IRichEditOle 对象表
/// (REOBJECT.cp)命中缩略图后直接调 wBitmapView 放大;成功内联的图片按渲染顺序记入
/// <see cref="ImageCollector"/>(ChatRtfCache 读取后与对象表按下标一一对应)。</para>
/// <para>压缩失败/文件缺失的图降级为文件名占位(不产生 \pict、不进映射表),不影响整块渲染。</para>
/// </summary>
/// <param name="withHeader">true 时先输出「参考图片」小节标题(v1.3.3:图片块插在引用节之前时使用)</param>
private
static
void
AppendAnswerImages
(
StringBuilder
sb
,
List
<
string
>
images
,
bool
withHeader
)
{
// v1.3.4 布局:与上一章节之间留一个空行;标题与图片流各自成段(\par 边界)。
if
(
withHeader
)
{
sb
.
Append
(
@"\par\pard\f0\fs20\b "
);
sb
.
Append
(
Escape
(
"参考图片"
));
sb
.
Append
(
@"\b0\par "
);
}
else
{
sb
.
Append
(
@"\par\pard\f0\fs20 "
);
}
for
(
int
i
=
0
;
i
<
images
.
Count
;
i
++)
{
if
(
AppendInlineThumb
(
sb
,
images
[
i
]))
{
if
(
ImageCollector
!=
null
)
ImageCollector
.
Add
(
images
[
i
]);
// 零宽标记与 \pict 一一配对(fs2 + 集合内声明,组结束即恢复字号;\uc1 缺省下 '?' 为 ANSI 回退被跳过)
sb
.
Append
(
@"{\fs2\u8203 ?}"
);
}
if
(
i
<
images
.
Count
-
1
)
sb
.
Append
(
' '
);
// 折行点(也兼作相邻图片的视觉间距)
}
sb
.
Append
(
@"\par"
);
}
/// <summary>
/// 渲染期间收集成功内联成 \pict 的图片绝对路径(渲染顺序)。由 ChatRtfCache 在块重渲染前置空、
/// 渲染后读取;命中缓存不重渲染时沿用上次记录(v1.3.5 缩略图点击映射用)。
/// </summary>
public
static
List
<
string
>
ImageCollector
{
get
;
set
;
}
/// <summary>
/// 缩略图命中标记(v1.3.5b):每张成功内联的 \pict 后紧跟一个零宽空格 U+200B(fs2 隐身)。
/// 实证 \pict 对象字符在 Text 里是 0x20 槽位(与真实空格不可区分),且真机 IRichEditOle 对象表
/// 未给出命中 —— 标记是 Text 流中唯一可按序数定位缩略图的纯托管通道。
/// </summary>
internal
const
char
ThumbMarker
=
'\
u200B
'
;
/// <summary>
/// 在回答正文里定位「引用」小节标题行的行首下标(v1.3.3:图片块插到它之前)。
/// <para>模式镜像 <c>AnswerSpec.HasLineSection</c>(容忍 ### / ** / 全角冒号写法);引用节是回答的收尾小节,
/// 故取**最后一个**命中以跳过正文中偶发的「引用xxx」措辞。未找到返回 -1(调用方兜底为末尾追加)。</para>
/// </summary>
private
static
int
IndexOfReferenceSection
(
string
content
)
{
if
(
string
.
IsNullOrEmpty
(
content
))
return
-
1
;
try
{
Match
last
=
null
;
foreach
(
Match
m
in
Regex
.
Matches
(
content
,
@"(?:^|\n)[ \t]*(?:#{1,6}[ \t]*)?\**[ \t]*引用[ \t]*\**[ \t]*[::]?[ \t]*"
,
RegexOptions
.
Multiline
))
last
=
m
;
if
(
last
==
null
)
return
-
1
;
return
last
.
Index
+
(
last
.
Value
.
StartsWith
(
"\n"
,
StringComparison
.
Ordinal
)
?
1
:
0
);
}
catch
{
return
-
1
;
}
}
/// <summary>
/// 单张内嵌缩略图:等比缩到 ≤72px 高,转 24 位 DIB 以 RTF <c>\pict\dibitmap</c> 十六进制内联。
/// 返回 true 表示成功产出 \pict(进点击映射表);失败降级为文件名占位并返回 false。
/// <para>(v1.3.4 实测:本机 RichEdit 对 \pngblip/\jpegblip 及 EMF-in-wmetafile8 一律静默丢弃 —— 丢弃后残留的
/// 空段落正是"图片链接前空行"的真身;\dibitmap 与 16 位 WMF 均被接受,取实现最简的 DIB。
/// picw/pich 单位为百分之一毫米,picwgoal/pichgoal 为 twips;hex 每 64 字节换行,与原生方言一致。)</para>
/// </summary>
private
static
bool
AppendInlineThumb
(
StringBuilder
sb
,
string
file
)
{
try
{
int
tw
,
th
;
byte
[]
dib
;
using
(
var
src
=
Image
.
FromFile
(
file
))
{
double
scale
=
Math
.
Min
((
double
)
ThumbHeight
/
Math
.
Max
(
1
,
src
.
Height
),
1.0
);
tw
=
Math
.
Min
(
ThumbWidthMax
,
Math
.
Max
(
24
,
(
int
)
Math
.
Round
(
src
.
Width
*
scale
)));
th
=
Math
.
Max
(
24
,
(
int
)
Math
.
Round
(
src
.
Height
*
((
double
)
tw
/
Math
.
Max
(
1
,
src
.
Width
))));
if
(
th
>
ThumbHeight
)
{
th
=
ThumbHeight
;
tw
=
Math
.
Max
(
24
,
(
int
)
Math
.
Round
(
src
.
Width
*
((
double
)
th
/
Math
.
Max
(
1
,
src
.
Height
))));
}
using
(
var
bmp
=
new
Bitmap
(
tw
,
th
))
{
using
(
var
g
=
Graphics
.
FromImage
(
bmp
))
{
g
.
InterpolationMode
=
System
.
Drawing
.
Drawing2D
.
InterpolationMode
.
HighQualityBicubic
;
g
.
Clear
(
Color
.
White
);
g
.
DrawImage
(
src
,
0
,
0
,
tw
,
th
);
}
dib
=
BuildDib
(
bmp
,
tw
,
th
);
}
}
int
wHmm
=
(
int
)
Math
.
Round
(
tw
*
2540.0
/
96.0
);
// px → 百分之一毫米(96dpi 基线)
int
hHmm
=
(
int
)
Math
.
Round
(
th
*
2540.0
/
96.0
);
int
twips
=
15
;
// 1440 twips / 96 dpi = 15
sb
.
Append
(
@"{\pict\dibitmap\picw"
).
Append
(
wHmm
).
Append
(
@"\pich"
).
Append
(
hHmm
)
.
Append
(
@"\picwgoal"
).
Append
(
tw
*
twips
).
Append
(
@"\pichgoal"
).
Append
(
th
*
twips
).
Append
(
' '
);
for
(
int
i
=
0
;
i
<
dib
.
Length
;
i
++)
{
sb
.
Append
(
dib
[
i
].
ToString
(
"x2"
));
if
((
i
&
63
)
==
63
&&
i
<
dib
.
Length
-
1
)
sb
.
Append
(
"\r\n"
);
// 每 64 字节换行(原生方言习惯)
}
sb
.
Append
(
'}'
);
return
true
;
}
catch
{
try
{
sb
.
Append
(
Escape
(
Path
.
GetFileName
(
file
)));
}
catch
{
}
// 占位(间距由调用方图间空格提供)
return
false
;
}
}
/// <summary>Bitmap → 24 位 DIB(BITMAPINFOHEADER + 自底向上像素行,行宽 4 字节对齐);GDI+ 无 WMF/DIB 导出,手工构造</summary>
private
static
byte
[]
BuildDib
(
Bitmap
bmp
,
int
w
,
int
h
)
{
int
stride
=
(
w
*
3
+
3
)
&
~
3
;
var
dib
=
new
byte
[
40
+
stride
*
h
];
PutU32
(
dib
,
0
,
40
);
// biSize
PutU32
(
dib
,
4
,
(
uint
)
w
);
// biWidth
PutU32
(
dib
,
8
,
(
uint
)
h
);
// biHeight(正值 = 自底向上)
PutU16
(
dib
,
12
,
1
);
// biPlanes
PutU16
(
dib
,
14
,
24
);
// biBitCount
PutU32
(
dib
,
16
,
0
);
// biCompression = BI_RGB
PutU32
(
dib
,
20
,
(
uint
)(
stride
*
h
));
// biXPels/biYPels/biClrUsed/biClrImportant 保持 0
var
rect
=
new
System
.
Drawing
.
Rectangle
(
0
,
0
,
w
,
h
);
var
data
=
bmp
.
LockBits
(
rect
,
System
.
Drawing
.
Imaging
.
ImageLockMode
.
ReadOnly
,
System
.
Drawing
.
Imaging
.
PixelFormat
.
Format24bppRgb
);
try
{
var
row
=
new
byte
[
stride
];
for
(
int
y
=
0
;
y
<
h
;
y
++)
{
long
src
=
data
.
Scan0
.
ToInt64
()
+
(
h
-
1
-
y
)
*
data
.
Stride
;
// DIB 行序颠倒
System
.
Runtime
.
InteropServices
.
Marshal
.
Copy
(
new
IntPtr
(
src
),
row
,
0
,
stride
);
Buffer
.
BlockCopy
(
row
,
0
,
dib
,
40
+
y
*
stride
,
stride
);
}
}
finally
{
bmp
.
UnlockBits
(
data
);
}
return
dib
;
}
private
static
void
PutU16
(
byte
[]
b
,
int
off
,
int
v
)
{
b
[
off
]
=
(
byte
)
v
;
b
[
off
+
1
]
=
(
byte
)(
v
>>
8
);
}
private
static
void
PutU32
(
byte
[]
b
,
int
off
,
uint
v
)
{
b
[
off
]
=
(
byte
)
v
;
b
[
off
+
1
]
=
(
byte
)(
v
>>
8
);
b
[
off
+
2
]
=
(
byte
)(
v
>>
16
);
b
[
off
+
3
]
=
(
byte
)(
v
>>
24
);
}
private
static
void
RenderMarkdown
(
StringBuilder
sb
,
string
markdown
,
bool
expandDetails
=
false
)
{
if
(
string
.
IsNullOrEmpty
(
markdown
))
...
...
@@ -335,6 +534,13 @@ namespace Tools.MyAgent
{
sb
.
Append
(
@"\brdrb\brdrs\brdrw10\brsp20 \par\pard "
);
}
else
if
(
trimmed
.
StartsWith
(
"📎"
))
{
// 回答引用行(P4 契约输出):三段分色渲染 —— 路径蓝色下划线(可点击)、章节/行号绿色、匹配度灰色。
// 悬停高亮与点击命中仍由 wMyAgent(SetLinkHover / TryGetAnswerRefAt)按整行负责,渲染改动不影响文本长度。
// 不走 RenderInline:引用行内嵌的行内代码/路径分支会把颜色中途重置回正文色,链接样式会断裂。
RenderAnswerRefLine
(
sb
,
trimmed
);
}
else
{
RenderInline
(
sb
,
line
);
...
...
@@ -433,6 +639,59 @@ namespace Tools.MyAgent
return
$@"\cf
{
ClLink
}
\ul
{
escapedCmd
}
\ul0\cf
{
ClCodeFg
}
"
;
}
/// <summary>
/// 回答引用行(P4)分段渲染:📎 + 路径(蓝色下划线,可点击定位) + 章节/行号(绿色) + 匹配度(灰色)。
/// <para>分隔符(空格 / § / L / “匹配度”字样)跟随其后相邻段着色,避免出现“黑 § + 绿章节”的割裂观感。
/// 全部文本走 Escape(D1 代理对处理)—— EscapeRtf 逐字符转义会丢弃 📎。</para>
/// </summary>
private
static
void
RenderAnswerRefLine
(
StringBuilder
sb
,
string
line
)
{
var
m
=
AnswerRefPattern
.
Match
(
line
);
var
gPath
=
m
.
Groups
[
"path"
];
if
(!
m
.
Success
||
gPath
==
null
||
!
gPath
.
Success
)
{
// 兜底(路径缺失 / 格式异常):整行按可点击链接渲染,行为同旧版
sb
.
Append
(
@"\cf"
);
sb
.
Append
(
ClLink
);
sb
.
Append
(
@"\ul "
);
sb
.
Append
(
Escape
(
line
));
sb
.
Append
(
@"\ul0\cf"
);
sb
.
Append
(
ClText
);
sb
.
Append
(
@"\par "
);
return
;
}
int
pos
=
0
;
AppendRefSegment
(
sb
,
line
,
ref
pos
,
gPath
,
ClLink
,
true
);
// 路径:蓝色 + 下划线
AppendRefSegment
(
sb
,
line
,
ref
pos
,
m
.
Groups
[
"sec"
],
ClRefSection
,
false
);
// 章节:绿色
AppendRefSegment
(
sb
,
line
,
ref
pos
,
m
.
Groups
[
"line"
],
ClRefSection
,
false
);
// 行号:绿色(定位信息,与章节同组)
AppendRefSegment
(
sb
,
line
,
ref
pos
,
m
.
Groups
[
"score"
],
ClRefScore
,
false
);
// 匹配度:灰色
if
(
pos
<
line
.
Length
)
// 行尾残余(模型偶尔追加的补充说明):正文色
{
sb
.
Append
(
@"\cf"
);
sb
.
Append
(
ClText
);
sb
.
Append
(
Escape
(
line
.
Substring
(
pos
)));
}
sb
.
Append
(
@"\cf"
);
sb
.
Append
(
ClText
);
sb
.
Append
(
@"\par "
);
}
/// <summary>
/// 渲染引用行的一段:段前空隙(分隔符)与本段同色,段体按 color 着色;下划线仅作用于段体(不含分隔符)。
/// </summary>
private
static
void
AppendRefSegment
(
StringBuilder
sb
,
string
line
,
ref
int
pos
,
Group
g
,
int
color
,
bool
underline
)
{
if
(
g
==
null
||
!
g
.
Success
||
g
.
Length
==
0
)
return
;
if
(
g
.
Index
>
pos
)
{
sb
.
Append
(
@"\cf"
);
sb
.
Append
(
color
);
sb
.
Append
(
Escape
(
line
.
Substring
(
pos
,
g
.
Index
-
pos
)));
}
sb
.
Append
(
@"\cf"
);
sb
.
Append
(
color
);
if
(
underline
)
sb
.
Append
(
@"\ul "
);
sb
.
Append
(
Escape
(
g
.
Value
));
if
(
underline
)
sb
.
Append
(
@"\ul0 "
);
pos
=
g
.
Index
+
g
.
Length
;
}
private
static
bool
IsLikelyCommand
(
string
text
)
{
if
(
string
.
IsNullOrWhiteSpace
(
text
)
||
text
.
Length
<
3
)
return
false
;
...
...
Tools.Agent/Support/RichEditObjects.cs
0 → 100644
View file @
67a4bda7
using
System
;
using
System.Collections.Generic
;
using
System.Runtime.InteropServices
;
using
System.Windows.Forms
;
namespace
Tools.MyAgent
{
/// <summary>
/// RichEdit 内联对象位置表(v1.3.5 缩略图点击):经 EM_GETOLEINTERFACE 取 IRichEditOle,
/// 逐个读取 REOBJECT.cp —— \pict 在 RichEdit20W 文本流中占一个对象字符,cp 即它在 Text 里的下标;
/// 对象按文档序返回,与渲染序列(ChatRtfCache.LastImageSequence)按下标一一对应。
/// <para>背景:实测 \pict 的对象字符在 RichTextBox.Text 里表现为 0x20(与真实空格无法区分),文本扫描不可行;
/// 本互操作是唯一可靠的命中通道。任何异常/可疑数据都返回空表(功能静默降级为不可点,不影响其余交互)。</para>
/// </summary>
internal
static
class
RichEditObjects
{
private
const
int
EmGetOleInterface
=
0x043C
;
// WM_USER + 60
/// <summary>REO_GETOBJ_NO_INTERFACES:只取结构体(cp/clsid),不取 COM 接口指针</summary>
private
const
int
ReoGetObjNoInterfaces
=
0x0
;
/// <summary>对象数上限(超限视为互操作异常数据,整体弃用)</summary>
private
const
int
MaxObjects
=
4096
;
[
StructLayout
(
LayoutKind
.
Sequential
)]
private
struct
ReObject
{
public
int
cbObject
;
public
int
cp
;
public
Guid
clsid
;
public
IntPtr
oleobj
;
public
IntPtr
stg
;
public
int
dvAspect
;
public
int
dwFlags
;
public
IntPtr
dwUser
;
}
[
ComImport
,
Guid
(
"00020D00-0000-0000-C000-000000000046"
),
InterfaceType
(
ComInterfaceType
.
InterfaceIsIUnknown
)]
private
interface
IRichEditOle
{
// vtable 顺序与 richole.h 严格一致;.NET interop 允许只声明用到的前缀方法
[
PreserveSig
]
int
GetClientSite
(
out
IntPtr
callback
);
[
PreserveSig
]
int
GetObjectCount
();
[
PreserveSig
]
int
GetObject
(
int
iob
,
ref
ReObject
reo
,
int
flags
);
}
[
DllImport
(
"user32.dll"
)]
private
static
extern
IntPtr
SendMessage
(
IntPtr
hWnd
,
int
msg
,
int
wParam
,
out
IntPtr
lParam
);
/// <summary>返回控件里全部内联对象(\pict 缩略图)的字符位置(文档序);异常或数据可疑时返回空表。</summary>
internal
static
List
<
int
>
GetObjectCharPositions
(
RichTextBox
rtb
)
{
var
positions
=
new
List
<
int
>();
try
{
if
(
rtb
==
null
||
!
rtb
.
IsHandleCreated
)
return
positions
;
IntPtr
punk
;
SendMessage
(
rtb
.
Handle
,
EmGetOleInterface
,
0
,
out
punk
);
if
(
punk
==
IntPtr
.
Zero
)
return
positions
;
try
{
var
ole
=
(
IRichEditOle
)
Marshal
.
GetObjectForIUnknown
(
punk
);
try
{
int
count
=
ole
.
GetObjectCount
();
if
(
count
<
0
||
count
>
MaxObjects
)
return
positions
;
for
(
int
i
=
0
;
i
<
count
;
i
++)
{
var
reo
=
new
ReObject
();
reo
.
cbObject
=
Marshal
.
SizeOf
(
typeof
(
ReObject
));
if
(
ole
.
GetObject
(
i
,
ref
reo
,
ReoGetObjNoInterfaces
)
!=
0
)
break
;
positions
.
Add
(
reo
.
cp
);
}
}
finally
{
Marshal
.
ReleaseComObject
(
ole
);
}
}
finally
{
Marshal
.
Release
(
punk
);
}
// 完整性校验:位置须严格递增且落在文本范围内,否则视为互操作异常整体弃用
int
textLen
=
rtb
.
TextLength
;
for
(
int
i
=
0
;
i
<
positions
.
Count
;
i
++)
{
if
(
positions
[
i
]
<
0
||
positions
[
i
]
>=
textLen
||
(
i
>
0
&&
positions
[
i
]
<=
positions
[
i
-
1
]))
{
positions
.
Clear
();
break
;
}
}
}
catch
{
positions
.
Clear
();
}
return
positions
;
}
}
}
Tools.Agent/Tools.Agent.csproj
View file @
67a4bda7
...
...
@@ -164,6 +164,7 @@
<Compile
Include=
"Support\NoCaretRichTextBox.cs"
>
<SubType>
Component
</SubType>
</Compile>
<Compile
Include=
"Support\RichEditObjects.cs"
/>
<Compile
Include=
"Support\StorageCleanup.cs"
/>
<Compile
Include=
"Support\TextEncoding.cs"
/>
<Compile
Include=
"Support\TextPatterns.cs"
/>
...
...
Tools.Agent/wMyAgent.cs
View file @
67a4bda7
...
...
@@ -40,10 +40,7 @@ namespace Tools.MyAgent
private
readonly
List
<
string
>
_pendingImages
=
new
List
<
string
>();
/// <summary>v2.3 待发送附图缩略图栏(问答区左下角悬浮;每张图右上角叉号可取消)</summary>
private
FlowLayoutPanel
_pendingBar
;
/// <summary>P5 回答附图列表:本轮直答命中的知识库关联图片;仅在直答命中知识库时非空</summary>
private
readonly
List
<
string
>
_answerImages
=
new
List
<
string
>();
/// <summary>P5 回答附图轨(问答区左下角悬浮,纵向叠在待发送附图栏之上;只读展示,无删除按钮)</summary>
private
FlowLayoutPanel
_answerImageBar
;
// (v1.3.2)P5 回答附图列表/悬浮轨字段已移除:附图改记在 ChatMessage.AnswerImages 上、内嵌渲染进消息流
private
readonly
List
<
string
>
_producedFiles
=
new
List
<
string
>();
/// <summary>本轮回复用量基线快照(发送前记录引擎累计值;回复完成后按增量展示「本回复」用量)</summary>
private
int
_replyPromptBase
,
_replyCompletionBase
;
...
...
@@ -98,11 +95,9 @@ namespace Tools.MyAgent
/// <summary>
/// 匹配回答「引用」节的引用行(P4):📎 <知识库相对路径> §<章节> L<行号> 匹配度 <0.xx>。
/// <para>章节 / 行号 / 匹配度三段均可缺省(正则容错);路径用惰性匹配 + 扩展名锚点,允许路径含空格(中文知识库常见)。</para>
/// <para>与渲染侧(MarkdownRenderer.RenderAnswerRefLine)共用同一个正则对象,避免点击命中与着色分段漂移。</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
);
private
static
readonly
Regex
AnswerRefRegex
=
MarkdownRenderer
.
AnswerRefPattern
;
/// <summary>回答引用行(P4):渲染文本中的位置 + 解析出的 相对路径 / 章节 / 行号,供点击定位</summary>
private
sealed
class
AnswerRef
...
...
@@ -120,6 +115,16 @@ namespace Tools.MyAgent
/// </summary>
private
readonly
List
<
AnswerRef
>
_answerRefs
=
new
List
<
AnswerRef
>();
/// <summary>缩略图点击映射(v1.3.5):渲染序列里成功内联成 \pict 的图片绝对路径(渲染顺序),
/// 来源 ChatRtfCache.LastImageSequence,与命中位置表按下标一一对应</summary>
private
List
<
string
>
_answerImageSeq
=
new
List
<
string
>();
/// <summary>主命中通道(v1.3.5b):Text 流里零宽标记 U+200B 的位置(与 \pict 一一配对,随 RebuildAnswerRefs 重建)</summary>
private
List
<
int
>
_thumbMarkers
=
new
List
<
int
>();
/// <summary>备用命中通道:IRichEditOle 对象表 cp(真机未命中过,保留作降级证据);流式期间不刷新(禁用点击)</summary>
private
List
<
int
>
_pictPositions
=
new
List
<
int
>();
public
wMyAgent
()
:
this
(
null
,
null
,
null
)
{
}
...
...
@@ -163,7 +168,7 @@ namespace Tools.MyAgent
_rtfFallback
=
CreateRtfFallback
();
EnsureThinkingSpinner
();
EnsurePendingBar
();
// v2.3 待发送附图栏
EnsureAnswerImageBar
();
// P5 回答附图轨(纵向叠在待发送栏之上
)
// (v1.3.2)回答附图不再用悬浮轨:内嵌渲染进消息流(MarkdownRenderer.AppendAnswerImages
)
}
/// <summary>
...
...
@@ -180,7 +185,7 @@ namespace Tools.MyAgent
panelChat
.
Resize
+=
(
s
,
e
)
=>
{
PositionThinkingSpinner
();
LayoutBottomBars
();
//
P5:底部两条悬浮栏(回答附图 /
待发送附图)随尺寸重排
LayoutBottomBars
();
//
底部悬浮栏(
待发送附图)随尺寸重排
};
}
...
...
@@ -587,6 +592,7 @@ namespace Tools.MyAgent
_rtfFallback
.
Rtf
=
rtf
;
_rtfFallback
.
Select
(
_rtfFallback
.
TextLength
,
0
);
_rtfFallback
.
ScrollToCaret
();
_answerImageSeq
=
_rtfCache
.
LastImageSequence
;
// 缩略图点击映射随本次 RTF 重建(v1.3.5)
RebuildAnswerRefs
();
// 引用行表随重绘重建(位置随重绘失效);流式期间跳过
}
...
...
@@ -598,6 +604,8 @@ namespace Tools.MyAgent
private
void
RebuildAnswerRefs
()
{
_answerRefs
.
Clear
();
_thumbMarkers
.
Clear
();
// v1.3.5b:缩略图标记与引用行同生命周期(流式早退时保持为空 = 禁用点击)
_pictPositions
.
Clear
();
_hoverStart
=
-
1
;
_hoverLength
=
0
;
_hoverOrigBack
=
Color
.
Empty
;
...
...
@@ -609,6 +617,15 @@ namespace Tools.MyAgent
string
text
=
_rtfFallback
.
Text
;
if
(
string
.
IsNullOrEmpty
(
text
))
return
;
// 缩略图命中通道(v1.3.5b):① 主 —— 零宽标记 U+200B(与成功内联的 \pict 一一配对,
// Text 中按序数映射);② 备 —— IRichEditOle 对象表 cp(对象文档序)。两表都随本次重绘重建。
_thumbMarkers
.
Clear
();
for
(
int
i
=
text
.
IndexOf
(
MarkdownRenderer
.
ThumbMarker
);
i
>=
0
;
i
=
text
.
IndexOf
(
MarkdownRenderer
.
ThumbMarker
,
i
+
1
))
_thumbMarkers
.
Add
(
i
);
_pictPositions
=
RichEditObjects
.
GetObjectCharPositions
(
_rtfFallback
);
foreach
(
Match
m
in
AnswerRefRegex
.
Matches
(
text
))
{
string
rel
=
(
m
.
Groups
[
1
].
Value
??
""
).
Trim
().
Replace
(
'\\'
,
'/'
);
...
...
@@ -640,6 +657,56 @@ namespace Tools.MyAgent
return
false
;
}
/// <summary>
/// 命中:charIdx 附近是否是缩略图(v1.3.5b)。点在图上时 GetCharIndexFromPosition 可能返回
/// 图片两侧的相邻字符(对象锚点在图左上角、标记在图右侧),故取距离最近的命中位(半径 ±2 个字符位)。
/// 主通道为零宽标记序数;标记缺失时回退 IRichEditOle 对象表序数。
/// </summary>
private
bool
TryGetAnswerImageAt
(
int
charIdx
,
out
string
imageFile
)
{
imageFile
=
null
;
if
(
charIdx
<
0
||
_answerImageSeq
==
null
||
_answerImageSeq
.
Count
==
0
)
return
false
;
if
(
NearestPosition
(
_thumbMarkers
,
charIdx
,
out
int
ordinal
)
||
NearestPosition
(
_pictPositions
,
charIdx
,
out
ordinal
))
{
if
(
ordinal
<
_answerImageSeq
.
Count
)
{
imageFile
=
_answerImageSeq
[
ordinal
];
return
!
string
.
IsNullOrEmpty
(
imageFile
);
}
}
return
false
;
}
/// <summary>位置表中距 charIdx 最近者(半径 ±2),命中返回其下标</summary>
private
static
bool
NearestPosition
(
List
<
int
>
positions
,
int
charIdx
,
out
int
index
)
{
index
=
-
1
;
if
(
positions
==
null
||
positions
.
Count
==
0
)
return
false
;
int
best
=
-
1
,
bestDist
=
3
;
for
(
int
i
=
0
;
i
<
positions
.
Count
;
i
++)
{
int
d
=
Math
.
Abs
(
positions
[
i
]
-
charIdx
);
if
(
d
<
bestDist
)
{
bestDist
=
d
;
best
=
i
;
}
}
if
(
best
<
0
)
return
false
;
index
=
best
;
return
true
;
}
/// <summary>打开缩略图对应的大图(v1.3.5):直接走内置图片查看器</summary>
private
void
OpenAnswerImage
(
string
file
)
{
if
(
string
.
IsNullOrEmpty
(
file
))
return
;
if
(!
File
.
Exists
(
file
))
{
AgentUtils
.
Toast
(
"图片文件不存在:"
+
Path
.
GetFileName
(
file
),
3000
);
return
;
}
try
{
wBitmapView
.
ShowImage
(
file
,
this
);
}
catch
(
Exception
ex
)
{
AgentUtils
.
Toast
(
"查看图片失败:"
+
ex
.
Message
,
4000
);
}
}
/// <summary>
/// 打开引用行(P4):知识库相对路径 → 内置行定位查看器(文本类)或默认程序(docx/xlsx/pdf 等);
/// 「dialect/」「industry/」开头的引用走内置方言根目录解析;均已过沙箱校验,越界直接提示。
...
...
@@ -651,7 +718,24 @@ namespace Tools.MyAgent
bool
dialect
=
false
;
try
{
if
(
r
.
Rel
.
StartsWith
(
"dialect/"
,
StringComparison
.
OrdinalIgnoreCase
)
if
(
r
.
Rel
.
StartsWith
(
"kb_media/"
,
StringComparison
.
OrdinalIgnoreCase
))
{
// kb_media/{12位目录}/{文件名}:回答附图缓存(OutputRoot 下,v1.3.3 起引用行可引用;缩略图点击自 v1.3.5 走 TryGetAnswerImageAt)。
// 组合后必须仍在 OutputRoot 内(防引用文本被改出目录穿越),再走内置查看器放大。
string
root
=
_kb
.
OutputRoot
??
""
;
if
(
root
.
Length
==
0
)
throw
new
InvalidOperationException
(
"输出目录不可用"
);
string
combined
=
Path
.
GetFullPath
(
Path
.
Combine
(
root
,
r
.
Rel
.
Replace
(
'/'
,
'\\'
)));
if
(!
combined
.
StartsWith
(
Path
.
GetFullPath
(
root
).
TrimEnd
(
'\\'
,
'/'
)
+
"\\"
,
StringComparison
.
OrdinalIgnoreCase
))
throw
new
InvalidOperationException
(
"路径越界"
);
abs
=
combined
;
if
(
KbService
.
IsImageExtension
(
Path
.
GetExtension
(
abs
).
ToLowerInvariant
()))
{
try
{
wBitmapView
.
ShowImage
(
abs
,
this
);
}
// wBitmapView 声明在根命名空间 Tools.MyAgent(文件虽在 Forms/ 目录)
catch
(
Exception
ex
)
{
AgentUtils
.
Toast
(
"查看图片失败:"
+
ex
.
Message
,
4000
);
}
return
;
}
}
else
if
(
r
.
Rel
.
StartsWith
(
"dialect/"
,
StringComparison
.
OrdinalIgnoreCase
)
||
r
.
Rel
.
StartsWith
(
"industry/"
,
StringComparison
.
OrdinalIgnoreCase
))
{
var
svc
=
_engine
.
Dialect
;
...
...
@@ -772,7 +856,7 @@ namespace Tools.MyAgent
_messages
.
Add
(
new
ChatMessage
{
Role
=
"assistant"
,
Content
=
""
,
IsStreaming
=
true
});
_sessionSaved
=
false
;
// 对话内容已变化,下次新对话需重新记录历史
_producedFiles
.
Clear
();
// 新一轮开始,清空上轮输出文件
ClearAnswerImages
();
// P5:新一轮开始清空上轮回答附图轨(本轮到图时再填充)
// (v1.3.2)回答附图内嵌在消息上,历史保留,不再按轮清空
RefreshChat
();
txtInput
.
Focus
();
...
...
@@ -811,7 +895,6 @@ namespace Tools.MyAgent
_expandedDetails
.
Clear
();
_pendingImages
.
Clear
();
// 新对话:清空待发送附图
RefreshPendingBar
();
ClearAnswerImages
();
// P5:新对话清空回答附图轨
_sessionSaved
=
true
;
_rtfCache
.
Clear
();
_engine
.
ResetUsage
();
// D16:新对话重置会话用量统计
...
...
@@ -874,7 +957,8 @@ namespace Tools.MyAgent
/// <summary>
/// P5:本地直答的形态与关联图片到达(早于 <see cref="OnAssistantComplete"/>,因引擎在直答路径内先发本事件再收尾)。
/// <para>形态记在助手消息上(会话可持久化、渲染成顶部标注),图片同时进入附图轨。</para>
/// <para>形态与图片都记在助手消息上(会话可持久化);v1.3.2 起图片不再走悬浮轨,
/// 而是随 <see cref="RefreshChat"/> 内嵌渲染在回答正文下方(缓存键含图数 ⇒ 到图自动重绘)。</para>
/// </summary>
private
void
OnAnswerAttachments
(
AgentEngine
.
AnswerAttachments
payload
)
{
...
...
@@ -886,13 +970,8 @@ namespace Tools.MyAgent
last
.
AnswerImages
=
payload
.
Images
!=
null
&&
payload
.
Images
.
Count
>
0
?
new
List
<
string
>(
payload
.
Images
)
:
null
;
// 空 → null:与旧会话缺字段形态一致,渲染走同一分支
RefreshChat
();
}
_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>
...
...
@@ -1408,6 +1487,22 @@ namespace Tools.MyAgent
return
;
int
charIdx
=
_rtfFallback
.
GetCharIndexFromPosition
(
e
.
Location
);
// 诊断(v1.3.5b 临时):Ctrl+点击 → 上报缩略图命中链路三要素(序列数/标记数/对象数/命中位置)
// 注意:MouseEventArgs 无 Control 属性(KeyEventArgs 才有),鼠标事件取修饰键用静态 Control.ModifierKeys
if
((
Control
.
ModifierKeys
&
Keys
.
Control
)
!=
0
)
{
AgentUtils
.
Toast
(
string
.
Format
(
"[诊断] seq={0} 标记={1} 对象={2} charIdx={3}"
,
_answerImageSeq
.
Count
,
_thumbMarkers
.
Count
,
_pictPositions
.
Count
,
charIdx
),
8000
);
return
;
}
// 缩略图(v1.3.5):\pict 内联对象直接点击放大 —— 须在 IsClickOnChar 之前判定:
// 对象字符锚点在图片左上角,点在图片中下部时 IsClickOnChar 的窄字符判定会把它当"空白点击"漏掉
string
thumbImage
;
if
(
TryGetAnswerImageAt
(
charIdx
,
out
thumbImage
))
{
OpenAnswerImage
(
thumbImage
);
return
;
}
// 空白点击(行尾右侧/最后一行下方)GetCharIndexFromPosition 会返回行尾/末尾字符索引,
// 导致误命中链接:先校验点击点确实落在字符上,否则视为空白点击 → 仅取消链接/文本选中
if
(!
IsClickOnChar
(
e
.
Location
,
charIdx
))
...
...
@@ -1437,6 +1532,18 @@ namespace Tools.MyAgent
if
(
_mouseButtonDown
)
return
;
// 拖拽选择中,不切换悬停效果
int
charIdx
=
_rtfFallback
.
GetCharIndexFromPosition
(
e
.
Location
);
// 缩略图悬停(v1.3.5):与点击同因,须在 IsClickOnChar 闸门之前判定(对象锚点会窄字符误判为空白)
string
hoverImg
;
if
(
TryGetAnswerImageAt
(
charIdx
,
out
hoverImg
))
{
if
(
charIdx
!=
_lastHoverChar
)
{
_lastHoverChar
=
charIdx
;
ClearLinkHover
();
_rtfFallback
.
Cursor
=
Cursors
.
Hand
;
// 手型光标提示可点击放大
}
return
;
}
// 空白处悬停(行尾右侧/最后一行下方)同样会返回行尾/末尾字符索引:校验不通过按空白处理,
// 且不缓存索引,保证光标移回链接时能重新命中
if
(!
IsClickOnChar
(
e
.
Location
,
charIdx
))
...
...
@@ -1902,71 +2009,19 @@ namespace Tools.MyAgent
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>刷新回答附图轨:按本轮回答附图重建缩略图块;空则隐藏</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
();
}
// (v1.3.2)回答附图已改为内嵌渲染进消息流(MarkdownRenderer.AppendAnswerImages),
// 原左下角悬浮附图轨(EnsureAnswerImageBar / RefreshAnswerImageBar / ClearAnswerImages)整体移除:
// 悬浮面板会盖住问答区底部正文(引用行所在处),且视觉突兀。
/// <summary>
/// 底部两条悬浮栏纵向叠加:待发送附图栏贴底(左下角),回答附图轨叠在其**上方**。
/// <para>两者都用 Anchor=Bottom|Left + 手工 Location:若让新栏 Dock=Bottom,会与贴底栏落在同一像素带。
/// 待发送栏出现/消失时必须整体重排,否则回答附图轨会被压在下面看不见。</para>
/// 底部悬浮栏定位:待发送附图栏贴底(左下角)。
/// <para>(v1.3.2 前此处还有回答附图轨的纵向叠加;回答附图内嵌后仅剩待发送栏一层。)</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>
...
...
@@ -1980,7 +2035,7 @@ namespace Tools.MyAgent
}
/// <summary>单个缩略图块:图片预览(Zoom)+ 可选右上角 ✕ 移除按钮;点击缩略图按原始尺寸放大查看。</summary>
/// <param name="onRemove">✕ 的回调;传 <c>null</c> 表示只读展示
(如回答附图轨)
,此时不创建 ✕ 按钮</param>
/// <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
};
...
...
@@ -3045,7 +3100,7 @@ namespace Tools.MyAgent
_messages
.
Clear
();
_messages
.
AddRange
(
session
.
Messages
);
_expandedDetails
.
Clear
();
ClearAnswerImages
();
// P5:切换会话清空回答附图轨(避免上一会话的图留在新会话上)
// (v1.3.2)回答附图随会话消息走,切会话自然切换,无需清空
_sessionSaved
=
true
;
// 加载的内容已记录过历史,直接「新对话」不再重复保存
_rtfCache
.
Clear
();
RefreshChat
();
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment