Commit 2f8390cb authored by 段廷瑞's avatar 段廷瑞

feat: 对话上报功能,用于对话分析与监控

parent bf290b43
No preview for this file type
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
<summary>配置、历史、索引、日志和输出的根目录。空值使用应用目录下的 Agent。</summary> <summary>配置、历史、索引、日志和输出的根目录。空值使用应用目录下的 Agent。</summary>
</member> </member>
<member name="P:Tools.MyAgent.AgentRuntimeOptions.CurrentUser"> <member name="P:Tools.MyAgent.AgentRuntimeOptions.CurrentUser">
<summary>当前用户或角色,用于聊天展示、工具权限和数据库审计。</summary> <summary>当前用户或角色,用于聊天展示、工具权限、数据库审计与对话上报记录。默认空(宿主未传即空串,展示层自行兜底"访客")。</summary>
</member> </member>
<member name="P:Tools.MyAgent.AgentRuntimeOptions.DiagnosticRoots"> <member name="P:Tools.MyAgent.AgentRuntimeOptions.DiagnosticRoots">
<summary>宿主注入的诊断根目录;为空时由产品上下文 ProjectRoot 自动生成 source 根。</summary> <summary>宿主注入的诊断根目录;为空时由产品上下文 ProjectRoot 自动生成 source 根。</summary>
...@@ -46,6 +46,19 @@ ...@@ -46,6 +46,19 @@
<member name="M:Tools.MyAgent.AgentRuntime.CreateAdminForm(System.Int32,System.Boolean,System.Boolean)"> <member name="M:Tools.MyAgent.AgentRuntime.CreateAdminForm(System.Int32,System.Boolean,System.Boolean)">
<summary>创建 Admin 看板窗体(供宿主挂到菜单/工具栏)。</summary> <summary>创建 Admin 看板窗体(供宿主挂到菜单/工具栏)。</summary>
</member> </member>
<member name="M:Tools.MyAgent.AgentRuntime.CreateRunLogForm(System.String,System.String)">
<summary>
创建 Agent 日志查看窗体(agent_analysis_plan.md v1.3 / Q5 决议):只读查询对话上报记录。
<para>由宿主"实施工具"菜单直接打开;连接串/表名由宿主从 AgentConfig 读出传入(窗口自身不读配置文件)。
连接串为空时仍返回窗体(查询时报错提示未配置)。</para>
</summary>
</member>
<member name="M:Tools.MyAgent.AgentRuntime.TryLoadLocalConfig">
<summary>
读取本机默认位置(&lt;程序目录&gt;/Agent/config/AgentConfig.json)的 Agent 配置,
RunDbConnection 已解密为明文。供无上下文的工具模块(Activator 无参创建)自取上报配置;失败返回 null。
</summary>
</member>
<member name="T:Tools.MyAgent.AgentProductContext"> <member name="T:Tools.MyAgent.AgentProductContext">
<summary> <summary>
宿主声明的产品上下文。产品身份来自注册表,项目路径由宿主按权限注入。 宿主声明的产品上下文。产品身份来自注册表,项目路径由宿主按权限注入。
...@@ -1188,6 +1201,18 @@ ...@@ -1188,6 +1201,18 @@
<member name="P:Tools.MyAgent.Config.AgentConfig.AdminAutoRefresh"> <member name="P:Tools.MyAgent.Config.AgentConfig.AdminAutoRefresh">
<summary>看板 30s 自动刷新(默认关闭,避免常驻定时器拖住宿主)。</summary> <summary>看板 30s 自动刷新(默认关闭,避免常驻定时器拖住宿主)。</summary>
</member> </member>
<member name="P:Tools.MyAgent.Config.AgentConfig.RunDbEnabled">
<summary>对话上报总开关;关闭则不订阅 OnRunCompleted、不连库。</summary>
</member>
<member name="P:Tools.MyAgent.Config.AgentConfig.RunDbConnection">
<summary>
SQL Server 连接串(库由连接串决定,缺表自动创建 Agent_RunLog)。
<para>落盘时经 DPAPI 加密(见 SecretProtector),内存中为明文;设置页「高级 → 对话上报」可配置。</para>
</summary>
</member>
<member name="P:Tools.MyAgent.Config.AgentConfig.RunDbTable">
<summary>上报目标表名(仅允许字母/数字/下划线,防注入;建表与 INSERT 均使用该名)。</summary>
</member>
<member name="T:Tools.MyAgent.Config.RoleTemplate"> <member name="T:Tools.MyAgent.Config.RoleTemplate">
<summary>角色预定义模板条目(roles.json 中的一项;Prompt 由大模型预生成)</summary> <summary>角色预定义模板条目(roles.json 中的一项;Prompt 由大模型预生成)</summary>
</member> </member>
...@@ -1251,6 +1276,63 @@ ...@@ -1251,6 +1276,63 @@
<member name="M:Tools.MyAgent.Config.SecretProtector.IsProtected(System.String)"> <member name="M:Tools.MyAgent.Config.SecretProtector.IsProtected(System.String)">
<summary>判断存储值是否为已加密密文</summary> <summary>判断存储值是否为已加密密文</summary>
</member> </member>
<member name="T:Tools.MyAgent.AgentRunDbReporter">
<summary>
对话上报出口(agent_analysis_plan.md v1.3):每轮对话完成后(success / error;cancel 不上报)
将一条运行记录写入 SQL Server 表(默认 Agent_RunLog),供按用户提问做统计分析。
<para>订阅 <see cref="E:Tools.MyAgent.AgentTelemetry.OnRunCompleted"/>,对引擎零侵入;库由连接串决定,缺表自动创建。</para>
<para>红线(与 AgentTelemetry 同规):本类所有 public 方法体首句必须是 try —— 任何异常不得上抛、
不得阻塞回答线程;DB 不可达时有界队列 + 退避重试 + 丢弃计数(agent_rundb_dropped)。</para>
</summary>
</member>
<member name="F:Tools.MyAgent.AgentRunDbReporter.QueueCapacity">
<summary>队列容量;满即丢弃并计数,绝不阻塞回答。</summary>
</member>
<member name="F:Tools.MyAgent.AgentRunDbReporter.BackoffInitialMs">
<summary>连串不可用时的重试退避:5s 起步,×2 递增,上限 300s。</summary>
</member>
<member name="F:Tools.MyAgent.AgentRunDbReporter._pending">
<summary>写失败待重试的记录(仅写线程访问,无需加锁);重试超上限即丢弃并计数,防止一条坏记录毒化管道。</summary>
</member>
<member name="F:Tools.MyAgent.AgentRunDbReporter._tableState">
<summary>建表结果缓存:1 = 已确认存在,0 = 未确认(失败/未尝试),-1 = 确认无权限等永久失败(本进程不再重试建表)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentRunDbReporter.Configure(System.Boolean,System.String,System.String)">
<summary>
应用配置并订阅 <see cref="E:Tools.MyAgent.AgentTelemetry.OnRunCompleted"/>(幂等:重复调用仅刷新参数)。
连接串为空 / 开关关闭 = 取消订阅并停线程。
</summary>
</member>
<member name="M:Tools.MyAgent.AgentRunDbReporter.RetryOrFail(Tools.MyAgent.AgentTelemetry.RunScope,System.Int32@)">
<summary>写失败处置:记录进 pending 槽退避重试;连续失败超过上限则丢弃(计数 agent_rundb_dropped)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentRunDbReporter.Insert(Tools.MyAgent.AgentTelemetry.RunScope)">
<summary>插入一条记录;false = 本条写失败(由调用方退避重试,记录留在队列外即丢弃)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentRunDbReporter.EnsureTable">
<summary>缺表自动创建(幂等)。返回 false = 当前不可写(未确认表存在)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentRunDbReporter.EnsureTableOn(System.Data.SqlClient.SqlConnection,System.String)">
<summary>对已打开的连接执行幂等建表 + 索引(异常上抛,调用方决定如何处理 —— 仅供内部与测试预演使用)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentRunDbReporter.EnsureTableForTest(System.String,System.String)">
<summary>
测试连接(设置页「对话上报 → 测试连接」用):对指定连接串执行 Open + SELECT 1 + 幂等建表预演。
<para>**返回错误消息,成功返回 null**(不抛异常 —— 遵守本类 public 不上抛红线;不写任何数据行)。</para>
</summary>
</member>
<member name="M:Tools.MyAgent.AgentRunDbReporter.ToDecimal(System.Double)">
<summary>费用:RunScope.Cost 为元(double);落库 DECIMAL(18,4),负数/异常归零。</summary>
</member>
<member name="M:Tools.MyAgent.AgentRunDbReporter.AddN(System.Data.SqlClient.SqlCommand,System.String,System.String,System.Int32)">
<summary>NVARCHAR 列专用(UserName/Model/HostName 等含中文:VarChar 参数会把中文写成 '???')。</summary>
</member>
<member name="M:Tools.MyAgent.AgentRunDbReporter.Flush">
<summary>进程退出兜底:无法补写 DB(需网络),仅清空队列避免滞留;供 ProcessExit 调用。</summary>
</member>
<member name="M:Tools.MyAgent.AgentRunDbReporter.ConfigureFromConfig(Tools.MyAgent.Config.AgentConfig)">
<summary>供无 UI 宿主从 AgentConfig 读出后调用;等价 Configure(cfg.RunDbEnabled, cfg.RunDbConnection, cfg.RunDbTable)。</summary>
</member>
<member name="T:Tools.MyAgent.Forms.AgentAdminForm"> <member name="T:Tools.MyAgent.Forms.AgentAdminForm">
<summary> <summary>
Admin 看板(管理面板):一屏看回答质量、成本、稳定性与知识缺口。 Admin 看板(管理面板):一屏看回答质量、成本、稳定性与知识缺口。
...@@ -1358,6 +1440,15 @@ ...@@ -1358,6 +1440,15 @@
<para>连接串仅存内存明文,落盘经 DPAPI 加密(SaveAgentConfig);聊天中只回显 ID。</para> <para>连接串仅存内存明文,落盘经 DPAPI 加密(SaveAgentConfig);聊天中只回显 ID。</para>
</summary> </summary>
</member> </member>
<member name="M:Tools.MyAgent.Forms.AgentSettingsForm.BuildRunDbTab">
<summary>
高级配置新增二级 Tab「对话上报」:每轮对话完成后自动上报一条记录到 SQL Server
(库由连接串决定,缺表自动创建 Agent_RunLog;cancel 不上报;提问/回答原文恒落库)。
</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentSettingsForm.OnTestRunDb(System.Object,System.EventArgs)">
<summary>测试连接:Open + SELECT 1,成功再预演建表(无建表权限时提示具体错误,不落任何数据)。</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentSettingsForm.BuildToolPermissionUi"> <member name="M:Tools.MyAgent.Forms.AgentSettingsForm.BuildToolPermissionUi">
<summary>在「可选能力」页追加工具权限输入区(角色白名单,一行一规则)</summary> <summary>在「可选能力」页追加工具权限输入区(角色白名单,一行一规则)</summary>
</member> </member>
...@@ -1448,6 +1539,64 @@ ...@@ -1448,6 +1539,64 @@
使用代码编辑器修改此方法的内容。 使用代码编辑器修改此方法的内容。
</summary> </summary>
</member> </member>
<member name="T:Tools.MyAgent.Forms.AgentRunLogForm">
<summary>
Agent 日志查看窗口(agent_analysis_plan.md v1.3 / Q5 决议):只读查询对话上报记录(Agent_RunLog),
左列表 + 右详情(两个 Tab:「问答」= 提问+回答聊天式同页渲染;「指标」= 运行明细),支持日期 / 用户 / 结果过滤与 CSV 导出。
<para>**独立窗口**:由宿主"实施工具"菜单直接打开(<see cref="M:Tools.MyAgent.AgentRuntime.CreateRunLogForm(System.String,System.String)"/>),
无任何聊天命令唤出;窗口侧永不写库。</para>
<para>控件布局由 AgentRunLogForm.Designer.cs(设计器)实现;本文件仅含逻辑。</para>
</summary>
</member>
<member name="F:Tools.MyAgent.Forms.AgentRunLogForm._currentRunId">
<summary>当前选中行的 RunId(详情二次查询用)。</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentRunLogForm.#ctor">
<summary>
模块系统入口(Activator 无参创建,实施工具菜单直接打开):自读本机默认 Agent 配置取上报连接;
取不到 = 未配置(查询时提示先在设置页配置)。VS 设计器实例化时同样走此路径(读不到配置也不抛异常)。
</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentRunLogForm.SetupGrid">
<summary>DataGridView 双缓冲(消除滚动闪烁;反射设置保护属性)。</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentRunLogForm.LoadUsers">
<summary>拉取用户下拉(全部 + DISTINCT UserName),失败降级为仅"全部"。</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentRunLogForm.FormatGrid">
<summary>列宽与表头中文本地化(AutoGenerateColumns 后按列名匹配)。</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentRunLogForm.LoadDetail(System.String)">
<summary>按 RunId 二次查询提问/回答全文(列表只取摘要,防大文本拖慢)。</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentRunLogForm.RenderTranscript(System.Windows.Forms.RichTextBox,System.String,System.String,System.DateTime)">
<summary>
问答同页渲染(2026-10-19 用户要求):一个 RichTextBox 内按聊天顺序渲染
用户提问 → 助手回答,时间戳取对话时刻。复用 <see cref="M:Tools.MyAgent.MarkdownRenderer.BuildChatRtf(System.Collections.Generic.List{Tools.MyAgent.ChatMessage},System.String,System.String,System.String)"/>
(完整 RTF 文档,与聊天窗同源;RenderMessageBlock 只返回无 {\rtf1 头的片段,直接赋 Rtf 会被
RichEdit 静默解析失败 ⇒ 空白)。
</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentRunLogForm.Q(System.String)">
<summary>表名标识(白名单已在 Reporter/配置层校验;此处再防御)。</summary>
</member>
<member name="F:Tools.MyAgent.Forms.AgentRunLogForm.components">
<summary>
必需的设计器变量。
</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentRunLogForm.Dispose(System.Boolean)">
<summary>
清理所有正在使用的资源。
</summary>
<param name="disposing">如果应释放托管资源,为 true;否则为 false。</param>
</member>
<member name="M:Tools.MyAgent.Forms.AgentRunLogForm.InitializeComponent">
<summary>
设计器支持所需的方法 - 不要修改
使用代码编辑器修改此方法的内容。
</summary>
</member>
<member name="T:Tools.MyAgent.Forms.DocLineViewer"> <member name="T:Tools.MyAgent.Forms.DocLineViewer">
<summary> <summary>
文档行定位查看器(P4 引用行点击):只读展示文本类文件,并定位高亮到指定行。 文档行定位查看器(P4 引用行点击):只读展示文本类文件,并定位高亮到指定行。
...@@ -2430,6 +2579,15 @@ ...@@ -2430,6 +2579,15 @@
<member name="F:Tools.MyAgent.AgentTelemetry.RunScope.QuestionPreview"> <member name="F:Tools.MyAgent.AgentTelemetry.RunScope.QuestionPreview">
<summary>提问前 120 字(仅 detailed 级别填充;basic 恒空 —— §9 隐私分级)。</summary> <summary>提问前 120 字(仅 detailed 级别填充;basic 恒空 —— §9 隐私分级)。</summary>
</member> </member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunScope.QuestionText">
<summary>提问原文(仅内存,[JsonIgnore] 不进 jsonl —— 供 DB 上报用,落库由 AgentRunDbReporter 决定)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunScope.AnswerText">
<summary>回答原文(仅内存,[JsonIgnore] 不进 jsonl;success 路径由引擎填充,cancel/error 留空)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunScope.UserName">
<summary>当前用户(启动参数 --user 原样透传;仅内存,[JsonIgnore] 不进 jsonl,落库列 UserName)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunScope.Model"> <member name="F:Tools.MyAgent.AgentTelemetry.RunScope.Model">
<summary>配置模型。</summary> <summary>配置模型。</summary>
</member> </member>
......
...@@ -190,7 +190,7 @@ namespace Tools.MyAgent.Host ...@@ -190,7 +190,7 @@ namespace Tools.MyAgent.Host
private sealed class LaunchOptions private sealed class LaunchOptions
{ {
public string Product; public string Product;
public string User = "访客"; public string User = ""; // 启动参数 --user;默认空(未传 = 空串,展示层兜底"访客",上报记录原样透传)
public string ProjectRoot = ""; public string ProjectRoot = "";
public string KnowledgeBaseRoot = ""; public string KnowledgeBaseRoot = "";
public string KnowledgeBaseScope = ""; public string KnowledgeBaseScope = "";
......
using System;
using System.Collections.Generic;
using System.Data.SqlClient;
using System.IO;
using System.Text;
using System.Threading;
using System.Windows.Forms;
using Newtonsoft.Json.Linq;
using Tools.MyAgent;
using Tools.MyAgent.Config;
using Tools.MyAgent.Support;
// 探针 4(2026-10-19):Agent日志回答区空白 —— RichEdit 真实渲染裁决
// STA 线程实例化 RichTextBox,用库里最新一条 AnswerText 走 BuildChatRtf 渲染,读回 .Text 验证解析结果
internal static class Program
{
private static int Main()
{
Console.OutputEncoding = Encoding.UTF8;
try
{
string cfgPath = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"..\..\..\AppHome\Agent\config\AgentConfig.json"));
string plain = SecretProtector.Unprotect(JObject.Parse(File.ReadAllText(cfgPath)).Value<string>("RunDbConnection") ?? "");
string answer = null;
using (var conn = new SqlConnection(plain))
{
conn.Open();
using (var cmd = new SqlCommand("SELECT TOP 1 ISNULL(AnswerText, N'') FROM dbo.Agent_RunLog ORDER BY Id DESC", conn))
answer = Convert.ToString(cmd.ExecuteScalar());
}
Console.WriteLine("[1] DB AnswerText: len=" + (answer == null ? -1 : answer.Length));
int textLen = -1; string shown = ""; string err = null;
var t = new Thread(() =>
{
try
{
var box = new RichTextBox { Width = 600, Height = 400 };
var handle = box.Handle; // 强制创建句柄
var msg = new ChatMessage { Role = "assistant", Content = answer };
string rtf = MarkdownRenderer.BuildChatRtf(new List<ChatMessage> { msg }, null, "助手", "用户");
Console.WriteLine("[2] RTF len=" + rtf.Length + " hasHeader=" + rtf.StartsWith("{\\rtf1"));
box.Rtf = rtf;
textLen = box.Text.Length;
shown = box.Text;
}
catch (Exception ex) { err = ex.GetType().Name + ": " + ex.Message; }
});
t.SetApartmentState(ApartmentState.STA);
t.Start();
t.Join();
if (err != null) { Console.WriteLine("[3] RENDER FAIL: " + err); return 2; }
Console.WriteLine("[3] RichEdit 解析后 box.Text len=" + textLen + " content=[" + shown + "]");
Console.WriteLine(textLen > 0 ? "PASS(渲染管线正常 ⇒ 窗口空白 = 运行的是旧 DLL 或别的断点)" : "FAIL(RichEdit 解析为空 ⇒ 渲染管线有真问题)");
return textLen > 0 ? 0 : 3;
}
catch (Exception ex) { Console.WriteLine("FAIL: " + ex.Message); return 1; }
}
}
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D}</ProjectGuid>
<OutputType>Exe</OutputType>
<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>
<LangVersion>7.3</LangVersion>
<OutputPath>..\Tools.Agent\bin\Debug\</OutputPath>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<DefineConstants>DEBUG;TRACE</DefineConstants>
</PropertyGroup>
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.Data" />
<Reference Include="System.Core" />
<Reference Include="System.Drawing" />
<Reference Include="System.Windows.Forms" />
<Reference Include="Newtonsoft.Json">
<HintPath>..\Tools.Agent\bin\Debug\Newtonsoft.Json.dll</HintPath>
</Reference>
<Reference Include="Tools.Agent">
<HintPath>..\Tools.Agent\bin\Debug\Tools.Agent.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="Program.cs" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
...@@ -305,6 +305,7 @@ namespace Tools.MyAgent.Agent ...@@ -305,6 +305,7 @@ namespace Tools.MyAgent.Agent
CostEstimator.Configure(cfg.CustomPrices); // R6 费用估算:应用自定义价格表覆盖 CostEstimator.Configure(cfg.CustomPrices); // R6 费用估算:应用自定义价格表覆盖
AgentTelemetry.Configure(cfg.TelemetryEnabled, cfg.ObservabilityLevel); // 可观测性开关与隐私级别 AgentTelemetry.Configure(cfg.TelemetryEnabled, cfg.ObservabilityLevel); // 可观测性开关与隐私级别
AgentRunDbReporter.ConfigureFromConfig(cfg); // 对话上报(agent_analysis_plan.md v1.3):连接串/表名随配置生效
// P0 连接串脱敏 / P3 工具权限:预存连接与角色规则随配置注入 // P0 连接串脱敏 / P3 工具权限:预存连接与角色规则随配置注入
DbConnections = cfg.DbConnections ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); DbConnections = cfg.DbConnections ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
_toolRoleAllow = cfg.ToolRoleAllow ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); _toolRoleAllow = cfg.ToolRoleAllow ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
...@@ -360,6 +361,9 @@ namespace Tools.MyAgent.Agent ...@@ -360,6 +361,9 @@ namespace Tools.MyAgent.Agent
if (_runScope != null) if (_runScope != null)
{ {
_runScope.RunId = _diagnosticSessionId; _runScope.RunId = _diagnosticSessionId;
_runScope.UserName = CurrentUser ?? ""; // 启动参数 --user 原样透传(空 = 空串,展示层兜底不回写)
if (!string.IsNullOrEmpty(_currentQuestion))
_runScope.QuestionText = _currentQuestion; // 仅内存([JsonIgnore]),DB 上报用
if (AgentTelemetry.IsDetailed && !string.IsNullOrEmpty(_currentQuestion)) // §9:仅 detailed 存提问前 120 字 if (AgentTelemetry.IsDetailed && !string.IsNullOrEmpty(_currentQuestion)) // §9:仅 detailed 存提问前 120 字
_runScope.QuestionPreview = _currentQuestion.Substring(0, Math.Min(120, _currentQuestion.Length)); _runScope.QuestionPreview = _currentQuestion.Substring(0, Math.Min(120, _currentQuestion.Length));
} }
...@@ -450,7 +454,12 @@ namespace Tools.MyAgent.Agent ...@@ -450,7 +454,12 @@ namespace Tools.MyAgent.Agent
if (!IsDisposed) if (!IsDisposed)
{ {
if (_runScope != null) { _runScope.ToolRounds = toolRounds; _runScope.AnswerKind = _answerKind.ToString(); } if (_runScope != null)
{
_runScope.ToolRounds = toolRounds;
_runScope.AnswerKind = _answerKind.ToString();
_runScope.AnswerText = finalAnswer.ToString(); // 仅内存([JsonIgnore]),DB 上报用
}
AgentTelemetry.EndRun(_runScope, "success", "", null); AgentTelemetry.EndRun(_runScope, "success", "", null);
OnComplete?.Invoke(); OnComplete?.Invoke();
if (_toolTrace.Count > 0) if (_toolTrace.Count > 0)
......
...@@ -15,8 +15,8 @@ namespace Tools.MyAgent ...@@ -15,8 +15,8 @@ namespace Tools.MyAgent
/// <summary>配置、历史、索引、日志和输出的根目录。空值使用应用目录下的 Agent。</summary> /// <summary>配置、历史、索引、日志和输出的根目录。空值使用应用目录下的 Agent。</summary>
public string DataRoot { get; set; } public string DataRoot { get; set; }
/// <summary>当前用户或角色,用于聊天展示、工具权限和数据库审计。</summary> /// <summary>当前用户或角色,用于聊天展示、工具权限、数据库审计与对话上报记录。默认空(宿主未传即空串,展示层自行兜底"访客")。</summary>
public string CurrentUser { get; set; } = "访客"; public string CurrentUser { get; set; } = "";
/// <summary>宿主注入的诊断根目录;为空时由产品上下文 ProjectRoot 自动生成 source 根。</summary> /// <summary>宿主注入的诊断根目录;为空时由产品上下文 ProjectRoot 自动生成 source 根。</summary>
public IReadOnlyList<DiagnosticRoot> DiagnosticRoots { get; set; } public IReadOnlyList<DiagnosticRoot> DiagnosticRoots { get; set; }
...@@ -106,6 +106,34 @@ namespace Tools.MyAgent ...@@ -106,6 +106,34 @@ namespace Tools.MyAgent
return new Tools.MyAgent.Forms.AgentAdminForm(null, days, allowDetailed, autoRefresh); return new Tools.MyAgent.Forms.AgentAdminForm(null, days, allowDetailed, autoRefresh);
} }
/// <summary>
/// 创建 Agent 日志查看窗体(agent_analysis_plan.md v1.3 / Q5 决议):只读查询对话上报记录。
/// <para>由宿主"实施工具"菜单直接打开;连接串/表名由宿主从 AgentConfig 读出传入(窗口自身不读配置文件)。
/// 连接串为空时仍返回窗体(查询时报错提示未配置)。</para>
/// </summary>
public Form CreateRunLogForm(string connection, string table = "Agent_RunLog")
{
return new Tools.MyAgent.Forms.AgentRunLogForm(connection, table);
}
/// <summary>
/// 读取本机默认位置(&lt;程序目录&gt;/Agent/config/AgentConfig.json)的 Agent 配置,
/// RunDbConnection 已解密为明文。供无上下文的工具模块(Activator 无参创建)自取上报配置;失败返回 null。
/// </summary>
public static Config.AgentConfig TryLoadLocalConfig()
{
try
{
string path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Agent", "config", "AgentConfig.json");
if (!File.Exists(path)) return null;
var cfg = Newtonsoft.Json.JsonConvert.DeserializeObject<Config.AgentConfig>(
File.ReadAllText(path, System.Text.Encoding.UTF8));
if (cfg != null) cfg.RunDbConnection = Config.SecretProtector.Unprotect(cfg.RunDbConnection);
return cfg;
}
catch { return null; }
}
private void EnsureDefaultResources() private void EnsureDefaultResources()
{ {
Directory.CreateDirectory(DataRoot); Directory.CreateDirectory(DataRoot);
......
...@@ -222,5 +222,19 @@ ...@@ -222,5 +222,19 @@
/// <summary>看板 30s 自动刷新(默认关闭,避免常驻定时器拖住宿主)。</summary> /// <summary>看板 30s 自动刷新(默认关闭,避免常驻定时器拖住宿主)。</summary>
public bool AdminAutoRefresh { get; set; } = false; public bool AdminAutoRefresh { get; set; } = false;
// ══════════════ 高级项:对话上报(每轮一条记录落 SQL Server,供统计分析;见 agent_analysis_plan.md v1.3) ══════════════
/// <summary>对话上报总开关;关闭则不订阅 OnRunCompleted、不连库。</summary>
public bool RunDbEnabled { get; set; } = false;
/// <summary>
/// SQL Server 连接串(库由连接串决定,缺表自动创建 Agent_RunLog)。
/// <para>落盘时经 DPAPI 加密(见 SecretProtector),内存中为明文;设置页「高级 → 对话上报」可配置。</para>
/// </summary>
public string RunDbConnection { get; set; } = "";
/// <summary>上报目标表名(仅允许字母/数字/下划线,防注入;建表与 INSERT 均使用该名)。</summary>
public string RunDbTable { get; set; } = "Agent_RunLog";
} }
} }
namespace Tools.MyAgent.Forms
{
partial class AgentRunLogForm
{
/// <summary>
/// 必需的设计器变量。
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// 清理所有正在使用的资源。
/// </summary>
/// <param name="disposing">如果应释放托管资源,为 true;否则为 false。</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows 窗体设计器生成的代码
/// <summary>
/// 设计器支持所需的方法 - 不要修改
/// 使用代码编辑器修改此方法的内容。
/// </summary>
private void InitializeComponent()
{
this.panelTop = new System.Windows.Forms.Panel();
this.lblFrom = new System.Windows.Forms.Label();
this.dtpFrom = new System.Windows.Forms.DateTimePicker();
this.lblTo = new System.Windows.Forms.Label();
this.dtpTo = new System.Windows.Forms.DateTimePicker();
this.lblUser = new System.Windows.Forms.Label();
this.cmbUser = new System.Windows.Forms.ComboBox();
this.lblOutcome = new System.Windows.Forms.Label();
this.cmbOutcome = new System.Windows.Forms.ComboBox();
this.btnRefresh = new System.Windows.Forms.Button();
this.btnExport = new System.Windows.Forms.Button();
this.splitMain = new System.Windows.Forms.SplitContainer();
this.dgv = new System.Windows.Forms.DataGridView();
this.tabDetails = new System.Windows.Forms.TabControl();
this.tabQA = new System.Windows.Forms.TabPage();
this.rtbDetail = new System.Windows.Forms.RichTextBox();
this.tabMetrics = new System.Windows.Forms.TabPage();
this.txtMetrics = new System.Windows.Forms.TextBox();
this.statusStrip = new System.Windows.Forms.StatusStrip();
this.lblStatus = new System.Windows.Forms.ToolStripStatusLabel();
this.panelTop.SuspendLayout();
((System.ComponentModel.ISupportInitialize)(this.splitMain)).BeginInit();
this.splitMain.Panel1.SuspendLayout();
this.splitMain.Panel2.SuspendLayout();
this.splitMain.SuspendLayout();
((System.ComponentModel.ISupportInitialize)(this.dgv)).BeginInit();
this.tabDetails.SuspendLayout();
this.tabQA.SuspendLayout();
this.tabMetrics.SuspendLayout();
this.statusStrip.SuspendLayout();
this.SuspendLayout();
//
// panelTop
//
this.panelTop.Controls.Add(this.lblFrom);
this.panelTop.Controls.Add(this.dtpFrom);
this.panelTop.Controls.Add(this.lblTo);
this.panelTop.Controls.Add(this.dtpTo);
this.panelTop.Controls.Add(this.lblUser);
this.panelTop.Controls.Add(this.cmbUser);
this.panelTop.Controls.Add(this.lblOutcome);
this.panelTop.Controls.Add(this.cmbOutcome);
this.panelTop.Controls.Add(this.btnRefresh);
this.panelTop.Controls.Add(this.btnExport);
this.panelTop.Dock = System.Windows.Forms.DockStyle.Top;
this.panelTop.Location = new System.Drawing.Point(0, 0);
this.panelTop.Name = "panelTop";
this.panelTop.Padding = new System.Windows.Forms.Padding(8, 6, 8, 4);
this.panelTop.Size = new System.Drawing.Size(1024, 46);
this.panelTop.TabIndex = 0;
//
// lblFrom
//
this.lblFrom.AutoSize = true;
this.lblFrom.Location = new System.Drawing.Point(11, 15);
this.lblFrom.Name = "lblFrom";
this.lblFrom.Text = "开始";
//
// dtpFrom
//
this.dtpFrom.Format = System.Windows.Forms.DateTimePickerFormat.Short;
this.dtpFrom.Location = new System.Drawing.Point(43, 11);
this.dtpFrom.Name = "dtpFrom";
this.dtpFrom.Size = new System.Drawing.Size(108, 23);
this.dtpFrom.Value = new System.DateTime(2026, 10, 12, 0, 0, 0, 0);
//
// lblTo
//
this.lblTo.AutoSize = true;
this.lblTo.Location = new System.Drawing.Point(159, 15);
this.lblTo.Name = "lblTo";
this.lblTo.Text = "结束";
//
// dtpTo
//
this.dtpTo.Format = System.Windows.Forms.DateTimePickerFormat.Short;
this.dtpTo.Location = new System.Drawing.Point(191, 11);
this.dtpTo.Name = "dtpTo";
this.dtpTo.Size = new System.Drawing.Size(108, 23);
this.dtpTo.Value = new System.DateTime(2026, 10, 19, 0, 0, 0, 0);
//
// lblUser
//
this.lblUser.AutoSize = true;
this.lblUser.Location = new System.Drawing.Point(309, 15);
this.lblUser.Name = "lblUser";
this.lblUser.Text = "用户";
//
// cmbUser
//
this.cmbUser.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
this.cmbUser.DropDownWidth = 140;
this.cmbUser.Location = new System.Drawing.Point(341, 11);
this.cmbUser.Name = "cmbUser";
this.cmbUser.Size = new System.Drawing.Size(140, 25);
//
// lblOutcome
//
this.lblOutcome.AutoSize = true;
this.lblOutcome.Location = new System.Drawing.Point(491, 15);
this.lblOutcome.Name = "lblOutcome";
this.lblOutcome.Text = "结果";
//
// cmbOutcome
//
this.cmbOutcome.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
this.cmbOutcome.Items.AddRange(new object[] {
"全部",
"成功",
"失败"});
this.cmbOutcome.Location = new System.Drawing.Point(523, 11);
this.cmbOutcome.Name = "cmbOutcome";
this.cmbOutcome.Size = new System.Drawing.Size(80, 25);
//
// btnRefresh
//
this.btnRefresh.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(0)))), ((int)(((byte)(120)))), ((int)(((byte)(212)))));
this.btnRefresh.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
this.btnRefresh.ForeColor = System.Drawing.Color.White;
this.btnRefresh.Location = new System.Drawing.Point(619, 9);
this.btnRefresh.Name = "btnRefresh";
this.btnRefresh.Size = new System.Drawing.Size(76, 28);
this.btnRefresh.Text = "刷新";
this.btnRefresh.UseVisualStyleBackColor = false;
this.btnRefresh.Click += new System.EventHandler(this.OnRefreshClick);
//
// btnExport
//
this.btnExport.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
this.btnExport.Location = new System.Drawing.Point(703, 9);
this.btnExport.Name = "btnExport";
this.btnExport.Size = new System.Drawing.Size(76, 28);
this.btnExport.Text = "导出CSV";
this.btnExport.UseVisualStyleBackColor = true;
this.btnExport.Click += new System.EventHandler(this.OnExportCsvClick);
//
// splitMain
//
this.splitMain.Dock = System.Windows.Forms.DockStyle.Fill;
this.splitMain.FixedPanel = System.Windows.Forms.FixedPanel.Panel2; // 嵌入窄宿主时详情区宽度稳定,列表弹性伸缩
this.splitMain.Location = new System.Drawing.Point(0, 46);
this.splitMain.Name = "splitMain";
//
// splitMain.Panel1
//
this.splitMain.Panel1.Controls.Add(this.dgv);
//
// splitMain.Panel2
//
this.splitMain.Panel2.Controls.Add(this.tabDetails);
this.splitMain.Size = new System.Drawing.Size(1024, 608);
this.splitMain.SplitterDistance = 420;
this.splitMain.TabIndex = 1;
//
// dgv
//
this.dgv.AllowUserToAddRows = false;
this.dgv.AllowUserToDeleteRows = false;
this.dgv.AllowUserToResizeRows = false;
this.dgv.BackgroundColor = System.Drawing.Color.White;
this.dgv.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
this.dgv.ColumnHeadersDefaultCellStyle.Font = new System.Drawing.Font("Microsoft YaHei", 9F, System.Drawing.FontStyle.Bold);
this.dgv.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
this.dgv.Dock = System.Windows.Forms.DockStyle.Fill;
this.dgv.Location = new System.Drawing.Point(0, 0);
this.dgv.MultiSelect = false;
this.dgv.Name = "dgv";
this.dgv.ReadOnly = true;
this.dgv.RowHeadersVisible = false;
this.dgv.RowHeadersWidth = 36;
this.dgv.RowTemplate.Height = 23;
this.dgv.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect;
this.dgv.Size = new System.Drawing.Size(420, 608);
this.dgv.TabIndex = 0;
this.dgv.CellClick += new System.Windows.Forms.DataGridViewCellEventHandler(this.OnGridCellClick);
//
// tabDetails
//
this.tabDetails.Controls.Add(this.tabQA);
this.tabDetails.Controls.Add(this.tabMetrics);
this.tabDetails.Dock = System.Windows.Forms.DockStyle.Fill;
this.tabDetails.Location = new System.Drawing.Point(0, 0);
this.tabDetails.Name = "tabDetails";
this.tabDetails.SelectedIndex = 0;
this.tabDetails.Size = new System.Drawing.Size(600, 608);
this.tabDetails.TabIndex = 0;
//
// tabQA(问答同页:用户提问 → 助手回答,聊天式渲染)
//
this.tabQA.Controls.Add(this.rtbDetail);
this.tabQA.Location = new System.Drawing.Point(4, 24);
this.tabQA.Name = "tabQA";
this.tabQA.Padding = new System.Windows.Forms.Padding(3);
this.tabQA.Size = new System.Drawing.Size(592, 580);
this.tabQA.Text = "问答";
//
// rtbDetail
//
this.rtbDetail.BorderStyle = System.Windows.Forms.BorderStyle.None;
this.rtbDetail.Dock = System.Windows.Forms.DockStyle.Fill;
this.rtbDetail.Font = new System.Drawing.Font("Microsoft YaHei", 9.5F);
this.rtbDetail.Location = new System.Drawing.Point(3, 3);
this.rtbDetail.Name = "rtbDetail";
this.rtbDetail.ReadOnly = true;
this.rtbDetail.Size = new System.Drawing.Size(586, 574);
this.rtbDetail.TabIndex = 0;
this.rtbDetail.Text = "";
//
// tabMetrics
//
this.tabMetrics.Controls.Add(this.txtMetrics);
this.tabMetrics.Location = new System.Drawing.Point(4, 24);
this.tabMetrics.Name = "tabMetrics";
this.tabMetrics.Padding = new System.Windows.Forms.Padding(3);
this.tabMetrics.Size = new System.Drawing.Size(592, 580);
this.tabMetrics.Text = "指标";
//
// txtMetrics
//
this.txtMetrics.BackColor = System.Drawing.Color.White;
this.txtMetrics.BorderStyle = System.Windows.Forms.BorderStyle.None;
this.txtMetrics.Dock = System.Windows.Forms.DockStyle.Fill;
this.txtMetrics.Font = new System.Drawing.Font("Consolas", 9.5F);
this.txtMetrics.Location = new System.Drawing.Point(3, 3);
this.txtMetrics.Multiline = true;
this.txtMetrics.Name = "txtMetrics";
this.txtMetrics.ReadOnly = true;
this.txtMetrics.ScrollBars = System.Windows.Forms.ScrollBars.Vertical;
this.txtMetrics.Size = new System.Drawing.Size(586, 574);
this.txtMetrics.TabIndex = 0;
//
// statusStrip
//
this.statusStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] {
this.lblStatus});
this.statusStrip.Location = new System.Drawing.Point(0, 654);
this.statusStrip.Name = "statusStrip";
this.statusStrip.Size = new System.Drawing.Size(1024, 22);
this.statusStrip.TabIndex = 2;
//
// lblStatus
//
this.lblStatus.Name = "lblStatus";
this.lblStatus.Size = new System.Drawing.Size(56, 17);
this.lblStatus.Text = "就绪";
//
// AgentRunLogForm
//
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 17F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(1024, 676);
this.Controls.Add(this.splitMain);
this.Controls.Add(this.statusStrip);
this.Controls.Add(this.panelTop);
this.Font = new System.Drawing.Font("Microsoft YaHei", 9F);
this.MinimizeBox = false;
this.Name = "AgentRunLogForm";
this.ShowInTaskbar = false;
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "Agent日志";
this.Load += new System.EventHandler(this.OnFormLoad);
this.panelTop.ResumeLayout(false);
this.panelTop.PerformLayout();
this.splitMain.Panel1.ResumeLayout(false);
this.splitMain.Panel2.ResumeLayout(false);
((System.ComponentModel.ISupportInitialize)(this.splitMain)).EndInit();
this.splitMain.ResumeLayout(false);
((System.ComponentModel.ISupportInitialize)(this.dgv)).EndInit();
this.tabDetails.ResumeLayout(false);
this.tabQA.ResumeLayout(false);
this.tabMetrics.ResumeLayout(false);
this.tabMetrics.PerformLayout();
this.statusStrip.ResumeLayout(false);
this.statusStrip.PerformLayout();
this.ResumeLayout(false);
this.PerformLayout();
}
#endregion
private System.Windows.Forms.Panel panelTop;
private System.Windows.Forms.Label lblFrom;
private System.Windows.Forms.DateTimePicker dtpFrom;
private System.Windows.Forms.Label lblTo;
private System.Windows.Forms.DateTimePicker dtpTo;
private System.Windows.Forms.Label lblUser;
private System.Windows.Forms.ComboBox cmbUser;
private System.Windows.Forms.Label lblOutcome;
private System.Windows.Forms.ComboBox cmbOutcome;
private System.Windows.Forms.Button btnRefresh;
private System.Windows.Forms.Button btnExport;
private System.Windows.Forms.SplitContainer splitMain;
private System.Windows.Forms.DataGridView dgv;
private System.Windows.Forms.TabControl tabDetails;
private System.Windows.Forms.TabPage tabQA;
private System.Windows.Forms.RichTextBox rtbDetail;
private System.Windows.Forms.TabPage tabMetrics;
private System.Windows.Forms.TextBox txtMetrics;
private System.Windows.Forms.StatusStrip statusStrip;
private System.Windows.Forms.ToolStripStatusLabel lblStatus;
}
}
using System;
using System.Collections.Generic;
using System.Data;
using System.Data.SqlClient;
using System.Drawing;
using System.IO;
using System.Text;
using System.Windows.Forms;
using Tools.MyAgent.Support;
namespace Tools.MyAgent.Forms
{
/// <summary>
/// Agent 日志查看窗口(agent_analysis_plan.md v1.3 / Q5 决议):只读查询对话上报记录(Agent_RunLog),
/// 左列表 + 右详情(两个 Tab:「问答」= 提问+回答聊天式同页渲染;「指标」= 运行明细),支持日期 / 用户 / 结果过滤与 CSV 导出。
/// <para>**独立窗口**:由宿主"实施工具"菜单直接打开(<see cref="AgentRuntime.CreateRunLogForm"/>),
/// 无任何聊天命令唤出;窗口侧永不写库。</para>
/// <para>控件布局由 AgentRunLogForm.Designer.cs(设计器)实现;本文件仅含逻辑。</para>
/// </summary>
public partial class AgentRunLogForm : Form
{
private const int MaxRows = 500;
private readonly string _connection;
private readonly string _table;
/// <summary>当前选中行的 RunId(详情二次查询用)。</summary>
private string _currentRunId;
/// <summary>
/// 模块系统入口(Activator 无参创建,实施工具菜单直接打开):自读本机默认 Agent 配置取上报连接;
/// 取不到 = 未配置(查询时提示先在设置页配置)。VS 设计器实例化时同样走此路径(读不到配置也不抛异常)。
/// </summary>
public AgentRunLogForm()
{
InitializeComponent();
var cfg = AgentRuntime.TryLoadLocalConfig();
_connection = cfg?.RunDbConnection ?? "";
string t = cfg?.RunDbTable;
_table = string.IsNullOrWhiteSpace(t) ? "Agent_RunLog" : t.Trim();
SetupGrid();
}
public AgentRunLogForm(string connection, string table)
{
_connection = connection ?? "";
_table = string.IsNullOrWhiteSpace(table) ? "Agent_RunLog" : table.Trim();
InitializeComponent();
SetupGrid();
}
/// <summary>DataGridView 双缓冲(消除滚动闪烁;反射设置保护属性)。</summary>
private void SetupGrid()
{
typeof(DataGridView).GetProperty("DoubleBuffered",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)
?.SetValue(dgv, true, null);
}
// ══════════════ 加载与查询 ══════════════
private void OnFormLoad(object sender, EventArgs e)
{
dtpFrom.Value = DateTime.Today.AddDays(-7);
dtpTo.Value = DateTime.Today;
LoadUsers();
RefreshList();
}
/// <summary>拉取用户下拉(全部 + DISTINCT UserName),失败降级为仅"全部"。</summary>
private void LoadUsers()
{
try
{
cmbUser.Items.Clear();
cmbUser.Items.Add("全部用户");
using (var conn = new SqlConnection(_connection))
using (var cmd = new SqlCommand(
"SELECT DISTINCT UserName FROM " + Q(_table) + " ORDER BY UserName", conn))
{
conn.Open();
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
string name = Convert.ToString(reader["UserName"]);
if (name.Length > 0) cmbUser.Items.Add(name);
}
}
}
cmbUser.SelectedIndex = 0;
}
catch { cmbUser.SelectedIndex = 0; }
}
private string OutcomeFilter()
{
switch (cmbOutcome.SelectedIndex)
{
case 1: return "success";
case 2: return "error";
default: return null;
}
}
private void RefreshList()
{
try
{
if (string.IsNullOrWhiteSpace(_connection))
{
dgv.DataSource = null;
lblStatus.Text = "未配置上报数据库:请在 设置 → 高级配置 → 对话上报 填写连接串";
return;
}
Cursor = Cursors.WaitCursor;
StringBuilder sql = new StringBuilder();
sql.Append("SELECT TOP ").Append(MaxRows)
.Append(" Id, RunId, StartedAt, UserName, Model, Mode, AnswerKind, Outcome, ErrorType, ")
.Append("DurationMs, ToolCalls, LlmCalls, PromptTokens, CompletionTokens, Cost, ")
.Append("LEFT(ISNULL(QuestionText, N''), 200) AS QuestionSummary FROM ").Append(Q(_table))
.Append(" WHERE StartedAt >= @From AND StartedAt < DATEADD(DAY, 1, @To)");
string user = cmbUser.SelectedIndex > 0 ? Convert.ToString(cmbUser.SelectedItem) : null;
if (user != null) sql.Append(" AND UserName = @User");
string outcome = OutcomeFilter();
if (outcome != null) sql.Append(" AND Outcome = @Outcome");
sql.Append(" ORDER BY StartedAt DESC");
using (var conn = new SqlConnection(_connection))
using (var cmd = new SqlCommand(sql.ToString(), conn))
{
cmd.Parameters.Add("@From", SqlDbType.DateTime2).Value = dtpFrom.Value.Date;
cmd.Parameters.Add("@To", SqlDbType.DateTime2).Value = dtpTo.Value.Date;
if (user != null) cmd.Parameters.Add("@User", SqlDbType.NVarChar, 64).Value = user;
if (outcome != null) cmd.Parameters.Add("@Outcome", SqlDbType.VarChar, 16).Value = outcome;
var table = new DataTable();
using (var adapter = new SqlDataAdapter(cmd)) adapter.Fill(table);
dgv.AutoGenerateColumns = true;
dgv.DataSource = table;
FormatGrid();
lblStatus.Text = "共 " + table.Rows.Count + " 条(最多显示 " + MaxRows + " 条)";
}
}
catch (Exception ex)
{
dgv.DataSource = null;
lblStatus.Text = "查询失败:" + ex.Message;
}
finally
{
Cursor = Cursors.Default;
}
}
/// <summary>列宽与表头中文本地化(AutoGenerateColumns 后按列名匹配)。</summary>
private void FormatGrid()
{
foreach (DataGridViewColumn col in dgv.Columns)
{
switch (col.Name)
{
case "Id": col.HeaderText = "ID"; col.Width = 48; break;
case "StartedAt": col.HeaderText = "时间"; col.Width = 116; col.DefaultCellStyle.Format = "MM-dd HH:mm"; break;
case "UserName": col.HeaderText = "用户"; col.Width = 80; break;
case "QuestionSummary": col.HeaderText = "提问摘要"; col.FillWeight = 100; col.AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill; break;
case "Outcome": col.HeaderText = "结果"; col.Width = 54; break;
case "DurationMs": col.HeaderText = "耗时ms"; col.Width = 66; break;
case "ToolCalls": col.HeaderText = "工具"; col.Width = 44; break;
case "LlmCalls": col.HeaderText = "LLM"; col.Width = 44; break;
case "Mode": col.HeaderText = "通道"; col.Width = 64; break;
case "AnswerKind": col.HeaderText = "类型"; col.Width = 64; break;
default: col.Visible = false; break; // RunId/Model/ErrorType/tokens/Cost 等进指标 Tab,不占列表
}
}
}
// ══════════════ 详情 ══════════════
private void OnGridCellClick(object sender, DataGridViewCellEventArgs e)
{
if (e.RowIndex < 0 || e.RowIndex >= dgv.Rows.Count) return;
var row = dgv.Rows[e.RowIndex];
string runId = Convert.ToString(row.Cells["RunId"].Value);
if (string.IsNullOrEmpty(runId) || runId == _currentRunId) return;
_currentRunId = runId;
LoadDetail(runId);
}
/// <summary>按 RunId 二次查询提问/回答全文(列表只取摘要,防大文本拖慢)。</summary>
private void LoadDetail(string runId)
{
try
{
Cursor = Cursors.WaitCursor;
using (var conn = new SqlConnection(_connection))
using (var cmd = new SqlCommand(
"SELECT RunId, StartedAt, UserName, Model, ServedModel, Mode, AnswerKind, Outcome, ErrorType, " +
"DurationMs, TtfbMs, ToolRounds, ToolCalls, LlmCalls, PromptTokens, CompletionTokens, Cost, FailoverCount, " +
"QuestionChars, QuestionText, AnswerChars, AnswerText, HostName FROM " + Q(_table) +
" WHERE RunId = @RunId", conn))
{
cmd.Parameters.Add("@RunId", SqlDbType.VarChar, 32).Value = runId;
conn.Open();
using (var reader = cmd.ExecuteReader())
{
if (!reader.Read())
{
SetDetailText("(记录不存在)", "(记录不存在)");
return;
}
string question = reader["QuestionText"] as string ?? "";
string answer = reader["AnswerText"] as string ?? "";
DateTime started = Convert.ToDateTime(reader["StartedAt"]);
// 问答同页(2026-10-19 用户要求):提问+回答聊天式渲染到同一 RichTextBox;指标独立 Tab
RenderTranscript(rtbDetail, question, answer, started);
txtMetrics.Text = BuildMetrics(reader);
lblStatus.Text = "RunId " + runId + " | " + Convert.ToString(reader["UserName"])
+ " | " + Convert.ToString(reader["Outcome"]);
}
}
}
catch (Exception ex)
{
SetDetailText("(读取失败)\n\n" + ex.Message, "(读取失败)");
}
finally
{
Cursor = Cursors.Default;
}
}
/// <summary>
/// 问答同页渲染(2026-10-19 用户要求):一个 RichTextBox 内按聊天顺序渲染
/// 用户提问 → 助手回答,时间戳取对话时刻。复用 <see cref="MarkdownRenderer.BuildChatRtf"/>
/// (完整 RTF 文档,与聊天窗同源;RenderMessageBlock 只返回无 {\rtf1 头的片段,直接赋 Rtf 会被
/// RichEdit 静默解析失败 ⇒ 空白)。
/// </summary>
private static void RenderTranscript(RichTextBox box, string question, string answer, DateTime startedAt)
{
try
{
var messages = new List<ChatMessage>();
if (!string.IsNullOrWhiteSpace(question))
messages.Add(new ChatMessage { Role = "user", Content = question, Timestamp = startedAt });
string body = string.IsNullOrWhiteSpace(answer)
? "(该轮无回答内容:cancel/error 轮或未记录)"
: answer;
messages.Add(new ChatMessage { Role = "assistant", Content = body, Timestamp = startedAt });
box.Rtf = MarkdownRenderer.BuildChatRtf(messages, null, "助手", "用户");
// 渲染后自检兜底:RichEdit 静默解析失败时 Text 为空 ⇒ 降级纯文本,绝不留空白
if (box.Text.Trim().Length == 0)
{
box.Clear();
box.Text = question + "\n" + answer;
}
}
catch
{
// 渲染失败降级纯文本(渲染层异常不得吞掉查看功能)
box.Clear();
box.Text = question + "\n" + answer;
}
}
private string BuildMetrics(SqlDataReader reader)
{
var sb = new StringBuilder();
sb.AppendLine("RunId " + Convert.ToString(reader["RunId"]));
sb.AppendLine("开始时间 " + Convert.ToString(reader["StartedAt"]));
sb.AppendLine("用户 " + Convert.ToString(reader["UserName"]));
sb.AppendLine("主机 " + Convert.ToString(reader["HostName"]));
sb.AppendLine("结果 " + Convert.ToString(reader["Outcome"])
+ (Convert.ToString(reader["ErrorType"]).Length > 0 ? "(" + Convert.ToString(reader["ErrorType"]) + ")" : ""));
sb.AppendLine("配置模型 " + Convert.ToString(reader["Model"]));
sb.AppendLine("服务模型 " + Convert.ToString(reader["ServedModel"]));
sb.AppendLine("回答通道 " + Convert.ToString(reader["Mode"]));
sb.AppendLine("回答类型 " + Convert.ToString(reader["AnswerKind"]));
sb.AppendLine("─────────────────────────");
sb.AppendLine("总耗时 " + Convert.ToString(reader["DurationMs"]) + " ms");
sb.AppendLine("首包延迟 " + Convert.ToString(reader["TtfbMs"]) + " ms");
sb.AppendLine("工具轮数/调用 " + Convert.ToString(reader["ToolRounds"]) + " / " + Convert.ToString(reader["ToolCalls"]));
sb.AppendLine("LLM 调用 " + Convert.ToString(reader["LlmCalls"]));
sb.AppendLine("Token 输入 " + Convert.ToString(reader["PromptTokens"]) + " / 输出 " + Convert.ToString(reader["CompletionTokens"]));
sb.AppendLine("费用 " + Convert.ToDecimal(reader["Cost"]).ToString("F4") + " 元");
sb.AppendLine("故障转移 " + Convert.ToString(reader["FailoverCount"]) + " 次");
sb.AppendLine("─────────────────────────");
sb.AppendLine("提问字符数 " + Convert.ToString(reader["QuestionChars"]));
sb.AppendLine("回答字符数 " + Convert.ToString(reader["AnswerChars"]));
return sb.ToString();
}
private void SetDetailText(string qa, string metrics)
{
rtbDetail.Clear(); rtbDetail.Text = qa;
txtMetrics.Text = metrics;
}
// ══════════════ 导出 ══════════════
private void OnExportCsvClick(object sender, EventArgs e)
{
try
{
if (dgv.DataSource is DataTable table && table.Rows.Count > 0)
{
using (var dlg = new SaveFileDialog
{
Title = "导出记录为 CSV",
Filter = "CSV (*.csv)|*.csv",
FileName = "Agent日志_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".csv",
})
{
if (dlg.ShowDialog(this) != DialogResult.OK) return;
if (File.Exists(dlg.FileName) &&
MessageBox.Show(this, $"导出文件已存在,是否覆盖?\n\n{dlg.FileName}", "确认覆盖",
MessageBoxButtons.YesNo, MessageBoxIcon.Warning) != DialogResult.Yes)
return;
ExportCsv(table, dlg.FileName);
AgentUtils.Toast("已导出:" + dlg.FileName, 3000);
}
}
else
{
AgentUtils.Toast("没有可导出的记录");
}
}
catch (Exception ex)
{
AgentUtils.Toast("导出失败:" + ex.Message, 4000);
}
}
private static void ExportCsv(DataTable table, string path)
{
var sb = new StringBuilder();
sb.AppendLine("时间,用户,提问摘要,结果,耗时ms,工具调用,LLM调用,输入Token,输出Token,费用,通道,类型,RunId");
foreach (DataRow row in table.Rows)
{
sb.AppendLine(string.Join(",",
Csv(Convert.ToString(row["StartedAt"])),
Csv(Convert.ToString(row["UserName"])),
Csv(Convert.ToString(row["QuestionSummary"])),
Csv(Convert.ToString(row["Outcome"])),
Convert.ToString(row["DurationMs"]),
Convert.ToString(row["ToolCalls"]),
Convert.ToString(row["LlmCalls"]),
Convert.ToString(row["PromptTokens"]),
Convert.ToString(row["CompletionTokens"]),
Convert.ToDecimal(row["Cost"]).ToString("F4"),
Csv(Convert.ToString(row["Mode"])),
Csv(Convert.ToString(row["AnswerKind"])),
Csv(Convert.ToString(row["RunId"]))));
}
// UTF-8 BOM:Excel 直接打开不乱码
File.WriteAllText(path, sb.ToString(), new UTF8Encoding(true));
}
private static string Csv(string value)
{
if (string.IsNullOrEmpty(value)) return "";
if (value.IndexOf(',') >= 0 || value.IndexOf('"') >= 0 || value.IndexOf('\n') >= 0)
return "\"" + value.Replace("\"", "\"\"") + "\"";
return value;
}
private void OnRefreshClick(object sender, EventArgs e)
{
_currentRunId = null;
SetDetailText("(未选中记录)", "(未选中记录)");
RefreshList();
}
/// <summary>表名标识(白名单已在 Reporter/配置层校验;此处再防御)。</summary>
private string Q(string name)
{
foreach (char c in name)
if (!(c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')))
throw new ArgumentException("非法表名:" + name);
return "[" + name + "]";
}
}
}
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
...@@ -122,6 +122,7 @@ namespace Tools.MyAgent.Forms ...@@ -122,6 +122,7 @@ namespace Tools.MyAgent.Forms
BuildToolPermissionUi(); // P3:工具级权限(按角色)输入区 BuildToolPermissionUi(); // P3:工具级权限(按角色)输入区
BuildDashboardTab(); // 可观测性:统计看板入口(替代隐晦的 /admin) BuildDashboardTab(); // 可观测性:统计看板入口(替代隐晦的 /admin)
BuildRemoteKbUi(); // v4.1:远程知识库条目接口(高级 → 语义检索页底部) BuildRemoteKbUi(); // v4.1:远程知识库条目接口(高级 → 语义检索页底部)
BuildRunDbTab(); // 对话上报(agent_analysis_plan.md v1.3):高级 → 二级 Tab「对话上报」
// 回答形态不再提供设置页(v1.2):全部内部默认(见 AgentConfig 注释),降低设置页认知负担 // 回答形态不再提供设置页(v1.2):全部内部默认(见 AgentConfig 注释),降低设置页认知负担
BindConfig(); // 配置值回填控件(控件创建见 Designer.cs) BindConfig(); // 配置值回填控件(控件创建见 Designer.cs)
ApplyProviderMode(); // 布局联动:BaseUrl 行显隐 ApplyProviderMode(); // 布局联动:BaseUrl 行显隐
...@@ -210,6 +211,100 @@ namespace Tools.MyAgent.Forms ...@@ -210,6 +211,100 @@ namespace Tools.MyAgent.Forms
tabMain.TabPages.Add(tabDb); tabMain.TabPages.Add(tabDb);
} }
// ══ 对话上报(agent_analysis_plan.md v1.3):高级 → 二级 Tab「对话上报」(程序化创建,避免侵入设计器) ══
private CheckBox chkRunDbEnabled;
private TextBox txtRunDbConn;
private TextBox txtRunDbTable;
private Button btnTestRunDb;
/// <summary>
/// 高级配置新增二级 Tab「对话上报」:每轮对话完成后自动上报一条记录到 SQL Server
/// (库由连接串决定,缺表自动创建 Agent_RunLog;cancel 不上报;提问/回答原文恒落库)。
/// </summary>
private void BuildRunDbTab()
{
var tabRunDb = new TabPage { Text = "对话上报", Padding = new Padding(3) };
chkRunDbEnabled = new CheckBox
{
Text = "启用对话上报(每轮对话完成后写入一条运行记录,客户取消的轮次不记录)",
Location = new Point(12, 12),
AutoSize = true,
};
var lblConn = new Label { Text = "SQL Server 连接串(库由连接串决定;保存后加密存储)", Location = new Point(12, 40), AutoSize = true };
txtRunDbConn = new TextBox
{
Location = new Point(12, 62),
Size = new Size(556, 46),
Multiline = true,
Font = new Font("Consolas", 9F),
};
var lblTable = new Label { Text = "目标表名(缺表自动创建;仅字母/数字/下划线)", Location = new Point(12, 116), AutoSize = true };
txtRunDbTable = new TextBox
{
Location = new Point(12, 138),
Size = new Size(240, 23),
Text = "Agent_RunLog",
Font = new Font("Consolas", 9F),
};
btnTestRunDb = new Button
{
Text = "测试连接",
Location = new Point(268, 136),
Size = new Size(96, 26),
};
btnTestRunDb.Click += new EventHandler(OnTestRunDb);
var hint = new Label
{
Text = "提问与回答原文会存入该库,用于统计分析与体验优化。查看入口:实施工具 → Agent日志。\r\n示例:Server=主机;Database=库名;User Id=账号;Password=密码;Trusted_Connection=False",
Location = new Point(12, 172),
AutoSize = false,
Size = new Size(556, 40),
ForeColor = Color.Gray,
};
tabRunDb.Controls.Add(chkRunDbEnabled);
tabRunDb.Controls.Add(lblConn);
tabRunDb.Controls.Add(txtRunDbConn);
tabRunDb.Controls.Add(lblTable);
tabRunDb.Controls.Add(txtRunDbTable);
tabRunDb.Controls.Add(btnTestRunDb);
tabRunDb.Controls.Add(hint);
tabAdvSub.TabPages.Add(tabRunDb);
}
/// <summary>测试连接:Open + SELECT 1,成功再预演建表(无建表权限时提示具体错误,不落任何数据)。</summary>
private void OnTestRunDb(object sender, EventArgs e)
{
try
{
string conn = txtRunDbConn.Text.Trim();
if (conn.Length == 0)
{
AgentUtils.Toast("请先填写连接串");
return;
}
string table = string.IsNullOrWhiteSpace(txtRunDbTable.Text) ? "Agent_RunLog" : txtRunDbTable.Text.Trim();
// 建表预演:复用 Reporter 的幂等建表(表已存在 = 无副作用;返回错误消息而非抛异常)
string error = AgentRunDbReporter.EnsureTableForTest(conn, table);
if (error == null)
{
// 2026-10-19 真机踩坑:连接串/表名已保存但「启用对话上报」未勾选 ⇒ 表里永远无记录(测试连接与开关无关)
if (!chkRunDbEnabled.Checked)
AgentUtils.Toast("连接成功,表已就绪:" + table + "\n⚠ 但「启用对话上报」未勾选,上报不会生效!", 6000);
else
AgentUtils.Toast("连接成功,建表/表已就绪:" + table, 3000);
}
else
AgentUtils.Toast("连接失败:" + error, 5000);
}
catch (Exception ex)
{
AgentUtils.Toast("连接失败:" + ex.Message, 5000);
}
}
// ══ P3:工具级权限(按角色) ══ // ══ P3:工具级权限(按角色) ══
private TextBox txtToolRoles; private TextBox txtToolRoles;
...@@ -493,6 +588,11 @@ namespace Tools.MyAgent.Forms ...@@ -493,6 +588,11 @@ namespace Tools.MyAgent.Forms
cmbEmbModel.Text = _config.EmbeddingModel; cmbEmbModel.Text = _config.EmbeddingModel;
txtEmbKey.Text = _config.EmbeddingApiKey; txtEmbKey.Text = _config.EmbeddingApiKey;
// 高级配置:对话上报(agent_analysis_plan.md v1.3)
chkRunDbEnabled.Checked = _config.RunDbEnabled;
txtRunDbConn.Text = _config.RunDbConnection ?? ""; // LoadConfig 已解密为明文
txtRunDbTable.Text = string.IsNullOrWhiteSpace(_config.RunDbTable) ? "Agent_RunLog" : _config.RunDbTable;
// 远程知识库(v4.1):URL 模板逐行回填 // 远程知识库(v4.1):URL 模板逐行回填
txtRemoteKb.Text = string.Join(Environment.NewLine, _config.RemoteKbEntries ?? new List<string>()); txtRemoteKb.Text = string.Join(Environment.NewLine, _config.RemoteKbEntries ?? new List<string>());
...@@ -955,6 +1055,10 @@ namespace Tools.MyAgent.Forms ...@@ -955,6 +1055,10 @@ namespace Tools.MyAgent.Forms
_config.EmbeddingBaseUrl = txtEmbUrl.Text.Trim(); _config.EmbeddingBaseUrl = txtEmbUrl.Text.Trim();
_config.EmbeddingModel = string.IsNullOrEmpty(cmbEmbModel.Text.Trim()) ? "bge-m3" : cmbEmbModel.Text.Trim(); _config.EmbeddingModel = string.IsNullOrEmpty(cmbEmbModel.Text.Trim()) ? "bge-m3" : cmbEmbModel.Text.Trim();
_config.EmbeddingApiKey = txtEmbKey.Text.Trim(); _config.EmbeddingApiKey = txtEmbKey.Text.Trim();
// 高级配置:对话上报(agent_analysis_plan.md v1.3;连接串加密由 wMyAgent.SaveAgentConfig 投影层处理)
_config.RunDbEnabled = chkRunDbEnabled.Checked;
_config.RunDbConnection = txtRunDbConn.Text.Trim();
_config.RunDbTable = string.IsNullOrWhiteSpace(txtRunDbTable.Text) ? "Agent_RunLog" : txtRunDbTable.Text.Trim();
// 远程知识库(v4.1):每行一条 URL 模板,空行忽略 // 远程知识库(v4.1):每行一条 URL 模板,空行忽略
_config.RemoteKbEntries = (txtRemoteKb != null ? txtRemoteKb.Lines : new string[0]) _config.RemoteKbEntries = (txtRemoteKb != null ? txtRemoteKb.Lines : new string[0])
.Select(l => (l ?? "").Trim()) .Select(l => (l ?? "").Trim())
......
using System;
using System.Collections.Concurrent;
using System.Data;
using System.Data.SqlClient;
using System.Text;
using System.Threading;
using Tools.MyAgent.Config;
namespace Tools.MyAgent
{
/// <summary>
/// 对话上报出口(agent_analysis_plan.md v1.3):每轮对话完成后(success / error;cancel 不上报)
/// 将一条运行记录写入 SQL Server 表(默认 Agent_RunLog),供按用户提问做统计分析。
/// <para>订阅 <see cref="AgentTelemetry.OnRunCompleted"/>,对引擎零侵入;库由连接串决定,缺表自动创建。</para>
/// <para>红线(与 AgentTelemetry 同规):本类所有 public 方法体首句必须是 try —— 任何异常不得上抛、
/// 不得阻塞回答线程;DB 不可达时有界队列 + 退避重试 + 丢弃计数(agent_rundb_dropped)。</para>
/// </summary>
public static class AgentRunDbReporter
{
// ══════════════ 状态 ══════════════
/// <summary>队列容量;满即丢弃并计数,绝不阻塞回答。</summary>
private const int QueueCapacity = 1000;
/// <summary>连串不可用时的重试退避:5s 起步,×2 递增,上限 300s。</summary>
private const int BackoffInitialMs = 5000;
private const int BackoffMaxMs = 300000;
private static readonly ConcurrentQueue<AgentTelemetry.RunScope> Queue
= new ConcurrentQueue<AgentTelemetry.RunScope>();
private static readonly object Lock = new object();
private static bool _configured;
private static bool _enabled;
private static string _connection = "";
private static string _table = "Agent_RunLog";
private static Thread _writerThread;
private static volatile bool _stop;
/// <summary>写失败待重试的记录(仅写线程访问,无需加锁);重试超上限即丢弃并计数,防止一条坏记录毒化管道。</summary>
private static AgentTelemetry.RunScope _pending;
private static int _pendingRetries;
private const int MaxPendingRetries = 3;
/// <summary>建表结果缓存:1 = 已确认存在,0 = 未确认(失败/未尝试),-1 = 确认无权限等永久失败(本进程不再重试建表)。</summary>
private static int _tableState;
// ══════════════ 配置 ══════════════
/// <summary>
/// 应用配置并订阅 <see cref="AgentTelemetry.OnRunCompleted"/>(幂等:重复调用仅刷新参数)。
/// 连接串为空 / 开关关闭 = 取消订阅并停线程。
/// </summary>
public static void Configure(bool enabled, string connection, string table)
{
try
{
lock (Lock)
{
_enabled = enabled && !string.IsNullOrWhiteSpace(connection);
_connection = connection ?? "";
string t = (table ?? "").Trim();
// 表名白名单:仅字母/数字/下划线,防注入;非法值回退默认
_table = t.Length > 0 && IsSafeName(t) ? t : "Agent_RunLog";
if (_enabled)
{
if (!_configured)
{
_configured = true;
AgentTelemetry.OnRunCompleted += OnRunCompleted;
AppDomain.CurrentDomain.ProcessExit += (s, e) => Flush();
}
EnsureWriter();
}
}
}
catch { }
}
private static bool IsSafeName(string name)
{
foreach (char c in name)
{
if (!(c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')))
return false;
}
return true;
}
// ══════════════ 入队(事件回调) ══════════════
private static void OnRunCompleted(AgentTelemetry.RunScope scope)
{
try
{
if (!_enabled || scope == null) return;
// Q1 决议:客户取消的轮次不上报(success / error 上报)
if (string.Equals(scope.Outcome, "cancel", StringComparison.OrdinalIgnoreCase)) return;
if (Queue.Count >= QueueCapacity)
{
AgentTelemetry.Count("agent_rundb_dropped");
return;
}
Queue.Enqueue(scope);
}
catch { }
}
// ══════════════ 写线程 ══════════════
private static void EnsureWriter()
{
try
{
if (_writerThread != null) return;
lock (Lock)
{
if (_writerThread != null) return;
_stop = false;
_writerThread = new Thread(WriterLoop);
_writerThread.IsBackground = true;
_writerThread.Name = "agent-rundb-reporter";
_writerThread.Start();
}
}
catch { }
}
private static void WriterLoop()
{
try
{
int backoff = BackoffInitialMs;
while (!_stop)
{
// 失败重试优先(pending 槽),避免 DB 抖动丢记录;超过上限丢弃并计数
AgentTelemetry.RunScope scope = _pending;
if (scope == null && !Queue.TryDequeue(out scope))
{
Thread.Sleep(200);
continue;
}
try
{
if (Insert(scope))
{
_pending = null;
_pendingRetries = 0;
AgentTelemetry.Count("agent_rundb_inserted");
backoff = BackoffInitialMs; // 成功一次即复位退避
}
else
{
AgentTelemetry.Count("agent_rundb_errors");
RetryOrFail(scope, ref backoff);
}
}
catch
{
AgentTelemetry.Count("agent_rundb_errors");
RetryOrFail(scope, ref backoff);
}
}
}
catch { }
}
/// <summary>写失败处置:记录进 pending 槽退避重试;连续失败超过上限则丢弃(计数 agent_rundb_dropped)。</summary>
private static void RetryOrFail(AgentTelemetry.RunScope scope, ref int backoff)
{
try
{
_pending = scope;
_pendingRetries++;
if (_pendingRetries > MaxPendingRetries)
{
_pending = null;
_pendingRetries = 0;
AgentTelemetry.Count("agent_rundb_dropped");
backoff = BackoffInitialMs;
return;
}
Thread.Sleep(backoff);
backoff = Math.Min(backoff * 2, BackoffMaxMs);
}
catch { }
}
/// <summary>插入一条记录;false = 本条写失败(由调用方退避重试,记录留在队列外即丢弃)。</summary>
private static bool Insert(AgentTelemetry.RunScope s)
{
try
{
if (!EnsureTable()) return false;
using (var conn = new SqlConnection(_connection))
{
conn.Open();
// 去重:RunId 唯一索引 + WHERE NOT EXISTS(防宿主重订阅重复上报;2601 撞键视为成功跳过)
using (var cmd = new SqlCommand(
"IF NOT EXISTS (SELECT 1 FROM " + Bracket(_table) + " WHERE RunId = @RunId) " +
"INSERT INTO " + Bracket(_table) + " " +
"(RunId, StartedAt, DurationMs, UserName, Model, ServedModel, Mode, AnswerKind, Outcome, ErrorType, " +
" QuestionHash, QuestionChars, QuestionText, AnswerChars, AnswerText, " +
" ToolRounds, ToolCalls, LlmCalls, PromptTokens, CompletionTokens, Cost, TtfbMs, FailoverCount, HostName) " +
"VALUES (@RunId, @StartedAt, @DurationMs, @UserName, @Model, @ServedModel, @Mode, @AnswerKind, @Outcome, @ErrorType, " +
" @QuestionHash, @QuestionChars, @QuestionText, @AnswerChars, @AnswerText, " +
" @ToolRounds, @ToolCalls, @LlmCalls, @PromptTokens, @CompletionTokens, @Cost, @TtfbMs, @FailoverCount, @HostName)", conn))
{
DateTime started;
if (!DateTime.TryParse(s.StartedAt, null, System.Globalization.DateTimeStyles.RoundtripKind, out started))
started = DateTime.UtcNow;
AddS(cmd, "@RunId", s.RunId, 32);
cmd.Parameters.Add("@StartedAt", SqlDbType.DateTime2).Value = started;
AddL(cmd, "@DurationMs", s.TotalMs);
AddN(cmd, "@UserName", s.UserName, 64);
AddN(cmd, "@Model", s.Model, 128);
AddN(cmd, "@ServedModel", s.ServedModel, 128);
AddS(cmd, "@Mode", s.Mode, 16);
AddN(cmd, "@AnswerKind", s.AnswerKind, 32);
AddS(cmd, "@Outcome", s.Outcome, 16);
AddS(cmd, "@ErrorType", s.ErrorType, 32);
AddS(cmd, "@QuestionHash", s.QuestionHash, 12);
AddI(cmd, "@QuestionChars", s.QuestionChars);
AddSN(cmd, "@QuestionText", s.QuestionText);
AddI(cmd, "@AnswerChars", s.AnswerText == null ? 0 : s.AnswerText.Length);
AddSN(cmd, "@AnswerText", s.AnswerText);
AddI(cmd, "@ToolRounds", s.ToolRounds);
AddI(cmd, "@ToolCalls", s.ToolCalls);
AddI(cmd, "@LlmCalls", s.LlmCalls);
AddI(cmd, "@PromptTokens", s.PromptTokens);
AddI(cmd, "@CompletionTokens", s.CompletionTokens);
cmd.Parameters.Add("@Cost", SqlDbType.Decimal).Value = ToDecimal(s.Cost);
AddL(cmd, "@TtfbMs", s.TtfbMs);
AddI(cmd, "@FailoverCount", s.FailoverCount);
AddN(cmd, "@HostName", Environment.MachineName, 64);
cmd.ExecuteNonQuery();
}
}
return true;
}
catch (SqlException ex) when (ex.Number == 2601 || ex.Number == 2627)
{
return true; // RunId 重复:视为已上报成功(幂等)
}
catch
{
_tableState = 0; // 下次重试建表探测(连接可能已恢复)
return false;
}
}
// ══════════════ 建表 ══════════════
/// <summary>缺表自动创建(幂等)。返回 false = 当前不可写(未确认表存在)。</summary>
private static bool EnsureTable()
{
try
{
if (_tableState == 1) return true;
if (_tableState < 0) return false; // 永久失败(无权限等),本进程不再重试
using (var conn = new SqlConnection(_connection))
{
conn.Open();
EnsureTableOn(conn, _table);
}
_tableState = 1;
return true;
}
catch (SqlException ex) when (ex.Number == 262 || ex.Number == 2714 || ex.Number == 1913)
{
// 262 = CREATE TABLE 权限不足 / 2714 = 对象已存在(并发建表)/ 1913 = 索引已存在
_tableState = ex.Number == 262 ? -1 : 1;
return _tableState == 1;
}
catch
{
_tableState = 0;
return false;
}
}
/// <summary>对已打开的连接执行幂等建表 + 索引(异常上抛,调用方决定如何处理 —— 仅供内部与测试预演使用)。</summary>
private static void EnsureTableOn(SqlConnection conn, string table)
{
using (var cmd = new SqlCommand(
"IF OBJECT_ID(N'dbo." + table + "', N'U') IS NULL " +
"CREATE TABLE dbo." + table + " (" +
"Id BIGINT IDENTITY(1,1) PRIMARY KEY, " +
"RunId VARCHAR(32) NOT NULL, " +
"StartedAt DATETIME2(3) NOT NULL, " +
"DurationMs BIGINT NOT NULL DEFAULT 0, " +
"UserName NVARCHAR(64) NOT NULL DEFAULT '', " +
"Model NVARCHAR(128) NOT NULL DEFAULT '', " +
"ServedModel NVARCHAR(128) NOT NULL DEFAULT '', " +
"Mode VARCHAR(16) NOT NULL DEFAULT '', " +
"AnswerKind NVARCHAR(32) NOT NULL DEFAULT '', " +
"Outcome VARCHAR(16) NOT NULL DEFAULT '', " +
"ErrorType VARCHAR(32) NOT NULL DEFAULT '', " +
"QuestionHash CHAR(12) NOT NULL DEFAULT '', " +
"QuestionChars INT NOT NULL DEFAULT 0, " +
"QuestionText NVARCHAR(MAX) NULL, " +
"AnswerChars INT NOT NULL DEFAULT 0, " +
"AnswerText NVARCHAR(MAX) NULL, " +
"ToolRounds INT NOT NULL DEFAULT 0, " +
"ToolCalls INT NOT NULL DEFAULT 0, " +
"LlmCalls INT NOT NULL DEFAULT 0, " +
"PromptTokens INT NOT NULL DEFAULT 0, " +
"CompletionTokens INT NOT NULL DEFAULT 0, " +
"Cost DECIMAL(18,4) NOT NULL DEFAULT 0, " +
"TtfbMs BIGINT NOT NULL DEFAULT 0, " +
"FailoverCount INT NOT NULL DEFAULT 0, " +
"ReportedAt DATETIME2(3) NOT NULL DEFAULT SYSDATETIME(), " +
"HostName NVARCHAR(64) NOT NULL DEFAULT '');", conn))
{
cmd.ExecuteNonQuery();
}
// 三个查询索引 + RunId 唯一(防重复上报);已存在则跳过
ExecOrNull(conn, "IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_" + table + "_RunId' AND object_id = OBJECT_ID(N'dbo." + table + "')) CREATE UNIQUE INDEX UX_" + table + "_RunId ON dbo." + table + "(RunId)");
ExecOrNull(conn, "IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_" + table + "_StartedAt' AND object_id = OBJECT_ID(N'dbo." + table + "')) CREATE NONCLUSTERED INDEX IX_" + table + "_StartedAt ON dbo." + table + "(StartedAt)");
ExecOrNull(conn, "IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_" + table + "_QuestionHash' AND object_id = OBJECT_ID(N'dbo." + table + "')) CREATE NONCLUSTERED INDEX IX_" + table + "_QuestionHash ON dbo." + table + "(QuestionHash)");
ExecOrNull(conn, "IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_" + table + "_UserName' AND object_id = OBJECT_ID(N'dbo." + table + "')) CREATE NONCLUSTERED INDEX IX_" + table + "_UserName ON dbo." + table + "(UserName)");
}
/// <summary>
/// 测试连接(设置页「对话上报 → 测试连接」用):对指定连接串执行 Open + SELECT 1 + 幂等建表预演。
/// <para>**返回错误消息,成功返回 null**(不抛异常 —— 遵守本类 public 不上抛红线;不写任何数据行)。</para>
/// </summary>
public static string EnsureTableForTest(string connection, string table)
{
try
{
if (string.IsNullOrWhiteSpace(connection)) return "连接串为空";
string t = (table ?? "").Trim();
if (t.Length == 0 || !IsSafeName(t)) return "表名非法(仅允许字母/数字/下划线)";
using (var conn = new SqlConnection(connection))
{
conn.Open();
using (var cmd = new SqlCommand("SELECT 1", conn)) cmd.ExecuteScalar();
EnsureTableOn(conn, t);
}
return null;
}
catch (Exception ex)
{
return ex.Message;
}
}
private static void ExecOrNull(SqlConnection conn, string sql)
{
try
{
using (var cmd = new SqlCommand(sql, conn)) cmd.ExecuteNonQuery();
}
catch { }
}
// ══════════════ 工具 ══════════════
private static string Bracket(string name) { return "[" + name + "]"; }
/// <summary>费用:RunScope.Cost 为元(double);落库 DECIMAL(18,4),负数/异常归零。</summary>
private static decimal ToDecimal(double cost)
{
try { return cost > 0 ? (decimal)Math.Min(cost, (double)decimal.MaxValue) : 0m; }
catch { return 0m; }
}
private static void AddS(SqlCommand cmd, string name, string value, int maxLen)
{
cmd.Parameters.Add(name, SqlDbType.VarChar, maxLen).Value = Trunc(value, maxLen) ?? (object)DBNull.Value;
}
/// <summary>NVARCHAR 列专用(UserName/Model/HostName 等含中文:VarChar 参数会把中文写成 '???')。</summary>
private static void AddN(SqlCommand cmd, string name, string value, int maxLen)
{
cmd.Parameters.Add(name, SqlDbType.NVarChar, maxLen).Value = Trunc(value, maxLen) ?? (object)DBNull.Value;
}
private static void AddSN(SqlCommand cmd, string name, string value)
{
if (string.IsNullOrEmpty(value)) cmd.Parameters.Add(name, SqlDbType.NVarChar).Value = DBNull.Value;
else cmd.Parameters.Add(name, SqlDbType.NVarChar).Value = value;
}
private static void AddI(SqlCommand cmd, string name, int value)
{
cmd.Parameters.Add(name, SqlDbType.Int).Value = value < 0 ? 0 : value;
}
private static void AddL(SqlCommand cmd, string name, long value)
{
cmd.Parameters.Add(name, SqlDbType.BigInt).Value = value < 0 ? 0 : value;
}
private static string Trunc(string s, int max)
{
if (string.IsNullOrEmpty(s)) return s;
return s.Length <= max ? s : s.Substring(0, max);
}
// ══════════════ 退出兜底 ══════════════
/// <summary>进程退出兜底:无法补写 DB(需网络),仅清空队列避免滞留;供 ProcessExit 调用。</summary>
public static void Flush()
{
try
{
var p = _pending; _pending = null;
if (p != null) { try { Insert(p); } catch { } }
AgentTelemetry.RunScope scope;
while (Queue.TryDequeue(out scope))
{
// 尽力补写一轮(短超时语义由 SqlConnection 默认即可;失败即放弃)
try { Insert(scope); } catch { }
}
}
catch { }
}
/// <summary>供无 UI 宿主从 AgentConfig 读出后调用;等价 Configure(cfg.RunDbEnabled, cfg.RunDbConnection, cfg.RunDbTable)。</summary>
public static void ConfigureFromConfig(AgentConfig cfg)
{
try
{
if (cfg == null) return;
Configure(cfg.RunDbEnabled, cfg.RunDbConnection, cfg.RunDbTable);
}
catch { }
}
}
}
...@@ -39,6 +39,15 @@ namespace Tools.MyAgent ...@@ -39,6 +39,15 @@ namespace Tools.MyAgent
/// <summary>提问前 120 字(仅 detailed 级别填充;basic 恒空 —— §9 隐私分级)。</summary> /// <summary>提问前 120 字(仅 detailed 级别填充;basic 恒空 —— §9 隐私分级)。</summary>
public string QuestionPreview = ""; public string QuestionPreview = "";
/// <summary>提问原文(仅内存,[JsonIgnore] 不进 jsonl —— 供 DB 上报用,落库由 AgentRunDbReporter 决定)。</summary>
[JsonIgnore] public string QuestionText = "";
/// <summary>回答原文(仅内存,[JsonIgnore] 不进 jsonl;success 路径由引擎填充,cancel/error 留空)。</summary>
[JsonIgnore] public string AnswerText = "";
/// <summary>当前用户(启动参数 --user 原样透传;仅内存,[JsonIgnore] 不进 jsonl,落库列 UserName)。</summary>
[JsonIgnore] public string UserName = "";
/// <summary>配置模型。</summary> /// <summary>配置模型。</summary>
public string Model = ""; public string Model = "";
......
...@@ -122,6 +122,7 @@ ...@@ -122,6 +122,7 @@
<Compile Include="Config\AgentConfig.cs" /> <Compile Include="Config\AgentConfig.cs" />
<Compile Include="Config\RoleTemplateStore.cs" /> <Compile Include="Config\RoleTemplateStore.cs" />
<Compile Include="Config\SecretProtector.cs" /> <Compile Include="Config\SecretProtector.cs" />
<Compile Include="Support\AgentRunDbReporter.cs" />
<Compile Include="Forms\AgentAdminForm.cs"> <Compile Include="Forms\AgentAdminForm.cs">
<SubType>Form</SubType> <SubType>Form</SubType>
</Compile> </Compile>
...@@ -140,6 +141,12 @@ ...@@ -140,6 +141,12 @@
<Compile Include="Forms\AgentSettingsForm.Designer.cs"> <Compile Include="Forms\AgentSettingsForm.Designer.cs">
<DependentUpon>AgentSettingsForm.cs</DependentUpon> <DependentUpon>AgentSettingsForm.cs</DependentUpon>
</Compile> </Compile>
<Compile Include="Forms\AgentRunLogForm.cs">
<SubType>Form</SubType>
</Compile>
<Compile Include="Forms\AgentRunLogForm.Designer.cs">
<DependentUpon>AgentRunLogForm.cs</DependentUpon>
</Compile>
<Compile Include="Forms\DocLineViewer.cs"> <Compile Include="Forms\DocLineViewer.cs">
<SubType>Form</SubType> <SubType>Form</SubType>
</Compile> </Compile>
...@@ -187,6 +194,9 @@ ...@@ -187,6 +194,9 @@
<EmbeddedResource Include="Forms\AgentHistoryForm.resx"> <EmbeddedResource Include="Forms\AgentHistoryForm.resx">
<DependentUpon>AgentHistoryForm.cs</DependentUpon> <DependentUpon>AgentHistoryForm.cs</DependentUpon>
</EmbeddedResource> </EmbeddedResource>
<EmbeddedResource Include="Forms\AgentRunLogForm.resx">
<DependentUpon>AgentRunLogForm.cs</DependentUpon>
</EmbeddedResource>
<EmbeddedResource Include="Forms\AgentSettingsForm.resx"> <EmbeddedResource Include="Forms\AgentSettingsForm.resx">
<DependentUpon>AgentSettingsForm.cs</DependentUpon> <DependentUpon>AgentSettingsForm.cs</DependentUpon>
</EmbeddedResource> </EmbeddedResource>
......
...@@ -307,6 +307,7 @@ namespace Tools.MyAgent ...@@ -307,6 +307,7 @@ namespace Tools.MyAgent
cfg.ApiKey = SecretProtector.Unprotect(cfg.ApiKey); cfg.ApiKey = SecretProtector.Unprotect(cfg.ApiKey);
cfg.EmbeddingApiKey = SecretProtector.Unprotect(cfg.EmbeddingApiKey); cfg.EmbeddingApiKey = SecretProtector.Unprotect(cfg.EmbeddingApiKey);
cfg.BackupApiKey = SecretProtector.Unprotect(cfg.BackupApiKey); cfg.BackupApiKey = SecretProtector.Unprotect(cfg.BackupApiKey);
cfg.RunDbConnection = SecretProtector.Unprotect(cfg.RunDbConnection); // 对话上报连接串(DPAPI 落盘)
if (cfg.DbConnections != null) if (cfg.DbConnections != null)
{ {
var plain = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var plain = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
...@@ -401,6 +402,10 @@ namespace Tools.MyAgent ...@@ -401,6 +402,10 @@ namespace Tools.MyAgent
AdminRoles = cfg.AdminRoles, AdminRoles = cfg.AdminRoles,
DashboardDays = cfg.DashboardDays, DashboardDays = cfg.DashboardDays,
AdminAutoRefresh = cfg.AdminAutoRefresh, AdminAutoRefresh = cfg.AdminAutoRefresh,
// 对话上报(agent_analysis_plan.md v1.3;漏登记 ⇒ 设置页保存一次即被静默清空)
RunDbEnabled = cfg.RunDbEnabled,
RunDbConnection = SecretProtector.Protect(cfg.RunDbConnection),
RunDbTable = cfg.RunDbTable,
}; };
File.WriteAllText(AgentConfigFilePath, Newtonsoft.Json.JsonConvert.SerializeObject(toSave), System.Text.Encoding.UTF8); File.WriteAllText(AgentConfigFilePath, Newtonsoft.Json.JsonConvert.SerializeObject(toSave), System.Text.Encoding.UTF8);
} }
......
...@@ -49,6 +49,7 @@ namespace Tools ...@@ -49,6 +49,7 @@ namespace Tools
this.CreateModule(Tools_imp, typeof(UI.wEmptyTable), "清空表", "tr:输入主表、自动清空关联表的工具"), this.CreateModule(Tools_imp, typeof(UI.wEmptyTable), "清空表", "tr:输入主表、自动清空关联表的工具"),
this.CreateModule(Tools_imp, typeof(UI.wExcelExportSetting), "Excel导出设置", "tr:Excel导出配置图形化版本"), this.CreateModule(Tools_imp, typeof(UI.wExcelExportSetting), "Excel导出设置", "tr:Excel导出配置图形化版本"),
this.CreateModule(Tools_imp, typeof(UI.wLogViewer), "日志查看器", "tr:可以方便的查看系统日志"), this.CreateModule(Tools_imp, typeof(UI.wLogViewer), "日志查看器", "tr:可以方便的查看系统日志"),
this.CreateModule(Tools_imp, typeof(Tools.MyAgent.Forms.AgentRunLogForm), "Agent日志", "tr:查看AI对话上报记录(按用户提问统计分析)"),
this.CreateModule(Tools_imp, typeof(UI.wTextFormatter), "格式化工具", "tr:SQL和JSON文本格式化工具"), this.CreateModule(Tools_imp, typeof(UI.wTextFormatter), "格式化工具", "tr:SQL和JSON文本格式化工具"),
this.CreateModule(Tools_imp, typeof(UI.wTextDiff), "文本对比工具", "tr:行级文本差异对比,支持左右/上下布局"), this.CreateModule(Tools_imp, typeof(UI.wTextDiff), "文本对比工具", "tr:行级文本差异对比,支持左右/上下布局"),
this.CreateModule(Tools_imp, typeof(DotNetVersionChecker.FrmVersion), "系统检测", "lxj:检测操作系统运行环境"), this.CreateModule(Tools_imp, typeof(DotNetVersionChecker.FrmVersion), "系统检测", "lxj:检测操作系统运行环境"),
......
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