Commit 67a4bda7 authored by 段廷瑞's avatar 段廷瑞

feat: Agent问答交互功能优化

parent 8d4b5d0b
No preview for this file type
......@@ -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)解析:📎 &lt;知识库相对路径&gt; §&lt;章节&gt; L&lt;行号&gt; 匹配度 &lt;0.xx&gt;。
<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>
&lt;details&gt; 折叠块的 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):📎 &lt;知识库相对路径&gt; §&lt;章节&gt; L&lt;行号&gt; 匹配度 &lt;0.xx&gt;。
<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>
......
......@@ -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, 12) : 4;
_recall.MaxImages = cfg.MaxAnswerImages > 0 ? Math.Min(cfg.MaxAnswerImages, 16) : 16;
CostEstimator.Configure(cfg.CustomPrices); // R6 费用估算:应用自定义价格表覆盖
// P0 连接串脱敏 / P3 工具权限:预存连接与角色规则随配置注入
......
......@@ -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>
......
......@@ -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);
......
......@@ -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.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 值回填到控件(设计器仅设默认值) ══
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.72M);
// 回答形态组不绑定控件(无设置页):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)
......
......@@ -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。
......
......@@ -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 = "";
......
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)解析:📎 &lt;知识库相对路径&gt; §&lt;章节&gt; L&lt;行号&gt; 匹配度 &lt;0.xx&gt;。
/// <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;
......
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;
}
}
}
......@@ -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" />
......
......@@ -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):📎 &lt;知识库相对路径&gt; §&lt;章节&gt; L&lt;行号&gt; 匹配度 &lt;0.xx&gt;。
/// <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();
......
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