Commit 5b9144c4 authored by 段廷瑞's avatar 段廷瑞

feat: Agent可观测性功能

parent 4230d763
No preview for this file type
......@@ -43,6 +43,9 @@
<member name="M:Tools.MyAgent.AgentRuntime.CreateEngine(Tools.MyAgent.Kb.KbService)">
<summary>创建无 UI Agent 引擎。调用方负责配置、订阅事件和释放。</summary>
</member>
<member name="M:Tools.MyAgent.AgentRuntime.CreateAdminForm(System.Int32,System.Boolean)">
<summary>创建 Admin 看板窗体(供宿主挂到菜单/工具栏)。</summary>
</member>
<member name="T:Tools.MyAgent.AgentProductContext">
<summary>
宿主声明的产品上下文。产品身份来自注册表,项目路径由宿主按权限注入。
......@@ -87,6 +90,9 @@
<member name="F:Tools.MyAgent.Agent.AgentEngine._diagnosticToolHit">
<summary>本轮是否调用过诊断类工具(P3 双契约判定的输入之一,随 RunAsync 重置)。</summary>
</member>
<member name="F:Tools.MyAgent.Agent.AgentEngine._runScope">
<summary>本轮可观测性上下文(RunId 与 <c>_diagnosticSessionId</c> 同源)。</summary>
</member>
<member name="F:Tools.MyAgent.Agent.AgentEngine._answerKind">
<summary>本轮回答类型(P3 双契约);在 FinishAnswerAsync 开头按当前证据重算。</summary>
</member>
......@@ -280,7 +286,7 @@
<member name="F:Tools.MyAgent.Agent.AgentEngine.FinalizePrompt">
<summary>工具轮次/时长达到上限时的收尾指令:禁止再调工具,基于已收集信息汇总作答</summary>
</member>
<member name="M:Tools.MyAgent.Agent.AgentEngine.SendAndLogAsync(Newtonsoft.Json.Linq.JArray,Newtonsoft.Json.Linq.JArray,System.Threading.CancellationToken)">
<member name="M:Tools.MyAgent.Agent.AgentEngine.SendAndLogAsync(Newtonsoft.Json.Linq.JArray,Newtonsoft.Json.Linq.JArray,System.Threading.CancellationToken,System.String)">
<summary>发送(带故障转移)并记录运行日志;tools=null 表示不下发工具(收尾回答轮/续写轮)</summary>
</member>
<member name="M:Tools.MyAgent.Agent.AgentEngine.FinishAnswerAsync(Newtonsoft.Json.Linq.JArray,Tools.MyAgent.Agent.ChatResult,System.Text.StringBuilder,System.Threading.CancellationToken)">
......@@ -378,6 +384,9 @@
改为保留头部 + 明确提示后,模型如需其余部分可缩小范围再取(如 kb_read 按 offset 续读)。</para>
</summary>
</member>
<member name="M:Tools.MyAgent.Agent.AgentEngine.ClassifyError(System.Exception)">
<summary>把异常归类为可聚合的 ErrorType(观测用,不参与用户提示文案)。</summary>
</member>
<member name="T:Tools.MyAgent.Agent.FailoverPolicy">
<summary>
故障转移策略:主模型连续失败时自动切换到备用模型。
......@@ -433,6 +442,9 @@
<member name="P:Tools.MyAgent.Agent.ChatResult.PromptTokens">
<summary>用量(SSE 末帧 usage;无则 -1)</summary>
</member>
<member name="P:Tools.MyAgent.Agent.ChatResult.TtfbMs">
<summary>首包延迟(ms):发出请求到收到首个有效 delta;无数据为 0(M6,向后兼容)</summary>
</member>
<member name="T:Tools.MyAgent.Agent.OpenAiCompatibleClient">
<summary>
DeepSeek(OpenAI 兼容)聊天补全客户端,支持 SSE 流式 + function calling。
......@@ -1114,6 +1126,33 @@
<para>空字典/缺省 = 所有登录用户可用(保持现状);值为空串或 "*" = 不限制。</para>
</summary>
</member>
<member name="P:Tools.MyAgent.Config.AgentConfig.TelemetryEnabled">
<summary>可观测性总开关;关闭则不写 jsonl、不建队列(纯文本日志照旧)。</summary>
</member>
<member name="P:Tools.MyAgent.Config.AgentConfig.ObservabilityLevel">
<summary>隐私级别:<c>off</c> 不采集 / <c>basic</c> 结构化字段不写原文(默认) / <c>detailed</c> 追加提问与工具返回摘要。</summary>
</member>
<member name="P:Tools.MyAgent.Config.AgentConfig.HealthCheckOnStart">
<summary>启动时执行一次依赖自检(默认关闭,仅 /diag 与看板触发)。</summary>
</member>
<member name="P:Tools.MyAgent.Config.AgentConfig.RunsKeepDays">
<summary>runs_*.jsonl 保留天数(默认 14)。</summary>
</member>
<member name="P:Tools.MyAgent.Config.AgentConfig.ShowRunSummary">
<summary>回复尾注是否带本轮摘要(耗时/工具数/通道);默认关闭,避免打扰。</summary>
</member>
<member name="P:Tools.MyAgent.Config.AgentConfig.AdminEnabled">
<summary>看板总开关;关闭时 /admin 提示不可用。</summary>
</member>
<member name="P:Tools.MyAgent.Config.AgentConfig.AdminRoles">
<summary>允许打开看板的角色(逗号分隔);空 = 不限制(保持现状,不破坏既有宿主)。</summary>
</member>
<member name="P:Tools.MyAgent.Config.AgentConfig.DashboardDays">
<summary>看板读取最近 N 天(1~30)。</summary>
</member>
<member name="P:Tools.MyAgent.Config.AgentConfig.AdminAutoRefresh">
<summary>看板 30s 自动刷新(默认关闭,避免常驻定时器拖住宿主)。</summary>
</member>
<member name="T:Tools.MyAgent.Config.RoleTemplate">
<summary>角色预定义模板条目(roles.json 中的一项;Prompt 由大模型预生成)</summary>
</member>
......@@ -1177,6 +1216,40 @@
<member name="M:Tools.MyAgent.Config.SecretProtector.IsProtected(System.String)">
<summary>判断存储值是否为已加密密文</summary>
</member>
<member name="T:Tools.MyAgent.Forms.AgentAdminForm">
<summary>
Admin 看板(管理面板):一屏看回答质量、成本、稳定性与知识缺口。
<para>纯代码构建 UI(不配 Designer/resx)——本机无 VS 设计器,手写 Designer 易漏 InitializeComponent。</para>
<para>与聊天窗 <c>/stats</c> <c>/diag</c> 互补:那两条是终端用户随口问,这里是管理者看趋势。</para>
</summary>
</member>
<member name="T:Tools.MyAgent.Forms.DashChart">
<summary>
看板自绘图表(柱状 / 横向条形)。零第三方依赖:GDI+ 直接画,约 120 行。
<para>不开 DoubleBuffered 会闪;不画网格线会看不出量级 —— 两者都保留。</para>
</summary>
</member>
<member name="F:Tools.MyAgent.Forms.DashChart.Labels">
<summary>柱标签(如小时 "00".."23" 或工具名)。</summary>
</member>
<member name="F:Tools.MyAgent.Forms.DashChart.Values">
<summary>主序列数值。</summary>
</member>
<member name="F:Tools.MyAgent.Forms.DashChart.Values2">
<summary>次序列数值(叠加,如失败数);可为空。</summary>
</member>
<member name="P:Tools.MyAgent.Forms.DashChart.Horizontal">
<summary>是否横向条形图(工具排名用)。</summary>
</member>
<member name="P:Tools.MyAgent.Forms.DashChart.EmptyText">
<summary>空数据占位文案。</summary>
</member>
<member name="P:Tools.MyAgent.Forms.DashChart.Caption">
<summary>图表标题 / 坐标轴说明;非空时在顶部绘制白底说明条(避免与最高柱重叠)。</summary>
</member>
<member name="M:Tools.MyAgent.Forms.DashChart.BarFrac(System.Double,System.Double)">
<summary>柱高比例:0 → 0;其余按 log2 倍增刻度(每翻倍增量恒定),避免小计次不可见、大计次一家独大。</summary>
</member>
<member name="T:Tools.MyAgent.Forms.AgentHistoryForm">
<summary>
历史会话弹窗(从 wMyAgent.ShowHistoryPopup 迁出):搜索 / 加载 / 删除 / 重命名 / 导出 MD / 清理。
......@@ -1244,6 +1317,18 @@
<member name="M:Tools.MyAgent.Forms.AgentSettingsForm.BuildToolPermissionUi">
<summary>在「可选能力」页追加工具权限输入区(角色白名单,一行一规则)</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentSettingsForm.BuildDashboardTab">
<summary>
新增「统计看板」选项卡:实时进程用量 + 一键打开完整看板(5 Tab)。
<para>看板本体仍是独立窗体 AgentAdminForm(1000×680 设计尺寸),此处仅做入口与即时 KPI,避免在窄设置页里硬塞大面板。</para>
</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentSettingsForm.OpenDashboard">
<summary>打开完整看板(与 /admin 同源,受 AdminEnabled 开关约束)。</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentSettingsForm.RefreshDashKpi">
<summary>即时 KPI(与 /stats 同源:AgentTelemetry.Snapshot)。</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentSettingsForm.ToolRoleRulesToLines(System.Collections.Generic.Dictionary{System.String,System.String})">
<summary>角色规则字典 → 编辑文本(排序保证稳定回显)</summary>
</member>
......@@ -1808,6 +1893,11 @@
<member name="P:Tools.MyAgent.Kb.KbService.IsSearchValid">
<summary>搜索范围是否有效(目录存在)</summary>
</member>
<member name="P:Tools.MyAgent.Kb.KbService.IndexReady">
<summary>
索引是否已就绪(未启动 / 构建中为 false)。供可观测性健康检查读取,不改变任何检索行为。
</summary>
</member>
<member name="M:Tools.MyAgent.Kb.KbService.EnsureIndexStartedIfSmall(System.Int32)">
<summary>
仅为小型知识库自动启动索引。文件数未知或超过阈值时保持未建索引,避免打开窗口触发大规模 SQLite 建库。
......@@ -2189,6 +2279,275 @@
<member name="M:Tools.MyAgent.AgentLogger.Mask(System.String)">
<summary>敏感信息脱敏(#41):手机号 / 邮箱 / API Key / 密码 / 令牌 / 长串</summary>
</member>
<member name="T:Tools.MyAgent.AgentTelemetry">
<summary>
智能体可观测性出口(Trace / Metrics)。
<para>红线:本类所有 public 方法体在最外层 try/catch —— **不得有向上传播的 throw**,
观测任何异常都不得影响正常回答。</para>
<para>与 <see cref="T:Tools.MyAgent.AgentLogger"/> 的分工:Logger 写「给人看的文本日志」;
本类写「可聚合的结构化数据」(一轮一条 RunScope + 计数器/直方图)。</para>
</summary>
</member>
<member name="T:Tools.MyAgent.AgentTelemetry.RunScope">
<summary>一轮提问的运行时容器(对应一次 <c>AgentEngine.RunAsync</c>)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunScope.RunId">
<summary>运行唯一标识(复用引擎的 <c>_diagnosticSessionId</c>)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunScope.StartedAt">
<summary>起始时间(ISO8601 UTC)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunScope.QuestionChars">
<summary>提问字符数(不存原文)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunScope.QuestionHash">
<summary>提问哈希(SHA256 前 12 位;一轮算一次,非 hot path)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunScope.Model">
<summary>配置模型。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunScope.ServedModel">
<summary>实际服务模型(故障转移时为备用模型)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunScope.Mode">
<summary>回答通道:kb / mixed / model / tool-loop。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunScope.AnswerKind">
<summary>回答类型(<c>AnswerSpec.Classify</c> 结果)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunScope.TtfbMs">
<summary>首包延迟(首次 LLM 调用的首个有效 delta)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunScope.Outcome">
<summary>success / error / cancel。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunScope.ErrorType">
<summary>auth|quota|ratelimit|network|context_length|timeout|unknown。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunScope.Completed">
<summary>是否走到自然收尾(未被取消/异常中断)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunScope.SpansTruncated">
<summary>span 达到上限被截断采集(ToolSpan ≤ 60 / LlmSpan ≤ 20)。</summary>
</member>
<member name="T:Tools.MyAgent.AgentTelemetry.ToolSpan">
<summary>一次工具调用(对 <c>AgentEngine.ToolTraceEntry</c> 的超集,不替换它)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.ToolSpan.ArgsSummary">
<summary>参数摘要(经 <see cref="M:Tools.MyAgent.AgentLogger.Mask(System.String)"/> 后截断 80 字)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.ToolSpan.StartOffsetMs">
<summary>相对本轮起点的偏移(ms)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.ToolSpan.Outcome">
<summary>ok / empty / error / timeout / cancel / denied。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.ToolSpan.Truncated">
<summary>结果被 <c>CompressToolResult</c> 截断。</summary>
</member>
<member name="T:Tools.MyAgent.AgentTelemetry.LlmSpan">
<summary>一次 LLM 请求(含工具轮 / 收尾 / 续写 / 直答 / 标题 / 摘要)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.LlmSpan.Kind">
<summary>tool-round / finish / continuation / direct / title / summarize。</summary>
</member>
<member name="T:Tools.MyAgent.AgentTelemetry.HistogramView">
<summary>直方图读数(固定桶插值,±半桶误差)。</summary>
</member>
<member name="T:Tools.MyAgent.AgentTelemetry.RunStats">
<summary>内存快照(<c>/stats</c> 与看板读这个)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunStats.Counters">
<summary>计数器全量副本(键形如 <c>agent_runs_total|outcome=success</c>)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.Buckets">
<summary>直方图桶上界(ms):50/100/200/400/800/1600/3200/6400/12800/25600/51200/+∞</summary>
</member>
<member name="P:Tools.MyAgent.AgentTelemetry.Level">
<summary>隐私级别:off / basic / detailed。</summary>
</member>
<member name="P:Tools.MyAgent.AgentTelemetry.IsDetailed">
<summary>是否写入提问原文类数据(仅 detailed)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.Configure(System.Boolean,System.String,System.String)">
<summary>
应用配置。任何非法值一律回退默认,不抛异常。
</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.BeginRun(System.Int32,System.String,System.String)">
<summary>开启一轮运行;关闭观测时返回 null(调用方须判空,用 <c>??</c> 安全调用)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.EndRun(Tools.MyAgent.AgentTelemetry.RunScope,System.String,System.String,System.Exception)">
<summary>结束一轮运行:结算耗时、累计指标、触发 <see cref="E:Tools.MyAgent.AgentTelemetry.OnRunCompleted"/>。</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.RecordTool(Tools.MyAgent.AgentTelemetry.RunScope,Tools.MyAgent.AgentTelemetry.ToolSpan)">
<summary>记录一次工具调用(<c>ExecuteTool</c> / <c>ExecuteToolAsync</c> 调用)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.RecordLlm(Tools.MyAgent.AgentTelemetry.RunScope,Tools.MyAgent.AgentTelemetry.LlmSpan)">
<summary>记录一次 LLM 请求(<c>SendAndLogAsync</c> 调用)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.Count(System.String,System.Int64)">
<summary>
计数器累加。<paramref name="name"/> 必须由埋点**硬编码**传入,
禁止外部字符串拼接(防止把路径/提问塞进维度导致维度爆炸)。
</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.Observe(System.String,System.Int64)">
<summary>观测一次耗时(ms)到固定桶直方图。</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.Histogram(System.String)">
<summary>直方图读数:P50/P95 由固定桶线性插值得到(±半桶误差)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.Snapshot">
<summary>内存快照(/stats 与看板读这个)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.Get(System.String)">
<summary>取计数器值(不存在返回 0)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.SumPrefix(System.String)">
<summary>按前缀汇总(用于无维度的总量)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.HashQuestion(System.String)">
<summary>提问哈希(SHA256 前 12 位 hex);一轮算一次,失败返回空串。</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.Summarize(System.String,System.Int32)">
<summary>参数摘要:脱敏后截断(默认 80 字)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.QueueCapacity">
<summary>队列容量;满即丢弃并计数,绝不阻塞回答。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.MaxFileBytes">
<summary>单个 jsonl 的轮转阈值(8MB)。</summary>
</member>
<member name="P:Tools.MyAgent.AgentTelemetry.LogsRoot">
<summary>观测数据目录(默认 程序基目录/Logs)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.GetRunWriter">
<summary>追加写(复用常驻 StreamWriter,避免每次 AppendAllText 的开合开销)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.NextAvailable(System.String)">
<summary>超过阈值时加 _N 后缀轮转。</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.EnqueueRun(Tools.MyAgent.AgentTelemetry.RunScope)">
<summary>一轮运行入队(满则丢弃并计数)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.FlushMetrics">
<summary>落一条指标快照(计数器 + 直方图读数)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.Flush">
<summary>把队列与缓冲落盘(进程退出 / 手动调用)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.Shutdown">
<summary>停止写线程并落盘(宿主释放 / 关闭观测时调用;进程退出另有兜底)。</summary>
</member>
<member name="E:Tools.MyAgent.AgentTelemetry.OnRunCompleted">
<summary>一轮运行完成(实时事件)。</summary>
</member>
<member name="E:Tools.MyAgent.AgentTelemetry.OnToolSpan">
<summary>一次工具调用完成(实时事件,替代 <c>OnToolStatus</c> 的字符串)。</summary>
</member>
<member name="T:Tools.MyAgent.HealthItem">
<summary>健康检查结果项。</summary>
</member>
<member name="F:Tools.MyAgent.HealthItem.Id">
<summary>检查项标识(稳定,便于聚合与对比)。</summary>
</member>
<member name="F:Tools.MyAgent.HealthItem.Name">
<summary>展示名。</summary>
</member>
<member name="F:Tools.MyAgent.HealthItem.Status">
<summary>ok / warn / fail。</summary>
</member>
<member name="F:Tools.MyAgent.HealthItem.Detail">
<summary>判定依据或处置建议。</summary>
</member>
<member name="T:Tools.MyAgent.AgentHealth">
<summary>
依赖健康检查(9 项)。按需执行,不定时轮询;单项异常只影响该项。
<para>输出:<c>Logs/health.json</c> + 聊天窗 <c>/diag</c> 的表格 + Admin 看板 ⑤Tab。</para>
</summary>
</member>
<member name="F:Tools.MyAgent.AgentHealth.LogsWarnBytes">
<summary>日志目录超该大小时告警(防止观测自身撑爆磁盘)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentHealth.Check(Tools.MyAgent.Config.AgentConfig,Tools.MyAgent.Kb.KbService,System.Collections.Generic.IReadOnlyList{Tools.MyAgent.Diagnostics.DiagnosticRoot})">
<summary>
执行检查(不含模型服务连通性——它是异步的,由调用方 <see cref="M:Tools.MyAgent.AgentHealth.ModelService(System.Boolean,System.String)"/> 单独追加)。
</summary>
</member>
<member name="M:Tools.MyAgent.AgentHealth.ModelService(System.Boolean,System.String)">
<summary>模型服务连通性项(由调用方用 <c>TestConnectionAsync</c> 结果构造)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentHealth.CheckTelemetrySelf">
<summary>观测自身:日志目录是否膨胀(自查项,防止观测把磁盘撑爆)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentHealth.ToJson(Tools.MyAgent.HealthItem[])">
<summary>序列化为 JSON(<c>Logs/health.json</c>)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentHealth.Save(Tools.MyAgent.HealthItem[])">
<summary>落盘到 <c>Logs/health.json</c>(覆盖写);失败不影响调用方。</summary>
</member>
<member name="M:Tools.MyAgent.AgentHealth.RenderMarkdown(Tools.MyAgent.HealthItem[])">
<summary>渲染为聊天窗 Markdown(分组表格;<c>/diag</c> 用)。</summary>
</member>
<member name="T:Tools.MyAgent.TelemetryStore">
<summary>
可观测性只读聚合层(Admin 看板数据源)。与写侧 <see cref="T:Tools.MyAgent.AgentTelemetry"/> 分离:
写侧不因看板需求膨胀,看板想怎么查是看板的事。
<para>护栏:只读最近 N 天 / 单文件最多 N 行 / 坏行跳过并计数 / 全程不允许抛异常。</para>
</summary>
</member>
<member name="T:Tools.MyAgent.TelemetryStore.RunRow">
<summary>一行运行记录(看板表格的行模型)。</summary>
</member>
<member name="F:Tools.MyAgent.TelemetryStore.RunRow.Tools">
<summary>工具 span 明细(下钻用;受 <see cref="P:Tools.MyAgent.TelemetryStore.AllowDetailed"/> 约束)。</summary>
</member>
<member name="T:Tools.MyAgent.TelemetryStore.ToolSpanView">
<summary>一条工具 span(下钻展示)。</summary>
</member>
<member name="T:Tools.MyAgent.TelemetryStore.ToolAgg">
<summary>按工具名聚合。</summary>
</member>
<member name="T:Tools.MyAgent.TelemetryStore.DailyAgg">
<summary>按天聚合。</summary>
</member>
<member name="T:Tools.MyAgent.TelemetryStore.ModeAgg">
<summary>召回通道分布(直答率来源)。</summary>
</member>
<member name="P:Tools.MyAgent.TelemetryStore.AllowDetailed">
<summary>是否允许展示详细字段(参数摘要等)。由宿主按角色判定后注入。</summary>
</member>
<member name="P:Tools.MyAgent.TelemetryStore.BadLines">
<summary>坏行计数(>5% 时看板顶部提示日志疑似损坏)。</summary>
</member>
<member name="P:Tools.MyAgent.TelemetryStore.ReadLines">
<summary>已读取行数。</summary>
</member>
<member name="M:Tools.MyAgent.TelemetryStore.LoadRuns">
<summary>读取最近 N 天的运行记录(倒序:最新在前)。</summary>
</member>
<member name="M:Tools.MyAgent.TelemetryStore.ReadTailLines(System.String,System.Int32)">
<summary>读文件末尾 N 行(环形缓冲,避免大文件整体入内存)。</summary>
</member>
<member name="M:Tools.MyAgent.TelemetryStore.AggregateTools(Tools.MyAgent.TelemetryStore.RunRow[])">
<summary>按工具名聚合(调用数 / 失败率 / P50 / P95 / 超时 / 截断)。</summary>
</member>
<member name="M:Tools.MyAgent.TelemetryStore.AggregateDaily(Tools.MyAgent.TelemetryStore.RunRow[],System.Int32)">
<summary>按天聚合。</summary>
</member>
<member name="M:Tools.MyAgent.TelemetryStore.AggregateModes(Tools.MyAgent.TelemetryStore.RunRow[])">
<summary>召回通道分布。</summary>
</member>
<member name="M:Tools.MyAgent.TelemetryStore.AggregateErrors(Tools.MyAgent.TelemetryStore.RunRow[],System.Int32)">
<summary>错误类型分布(Top N)。</summary>
</member>
<member name="M:Tools.MyAgent.TelemetryStore.RecentErrors(Tools.MyAgent.TelemetryStore.RunRow[],System.Int32)">
<summary>最近 N 条错误(看板 ⑤Tab)。</summary>
</member>
<member name="M:Tools.MyAgent.TelemetryStore.Hourly(Tools.MyAgent.TelemetryStore.RunRow[],System.Int64[]@)">
<summary>24 小时分布:按小时统计成功/失败(概览柱状图)。</summary>
</member>
<member name="T:Tools.MyAgent.AgentUtils">
<summary>
智能体专用小工具(迁移自 Tools.MyUtils.Utils 中智能体依赖的方法)
......@@ -2758,6 +3117,9 @@
<member name="F:Tools.MyAgent.StorageCleanup.LogKeepDays">
<summary>日志类文件保留天数(agent 日志 / search_misses / db 审计 / tool_results)</summary>
</member>
<member name="F:Tools.MyAgent.StorageCleanup.RunsKeepDays">
<summary>可观测性 runs_*.jsonl 保留天数(比纯日志短:jsonl 更占空间)</summary>
</member>
<member name="F:Tools.MyAgent.StorageCleanup.ImageKeepDays">
<summary>中间产物保留天数(kb_view_image 扫描件图片,可再生)</summary>
</member>
......@@ -2767,12 +3129,13 @@
<member name="F:Tools.MyAgent.StorageCleanup.OutputKeepDays">
<summary>正式产出保留天数(kb_write / doc_export)</summary>
</member>
<member name="M:Tools.MyAgent.StorageCleanup.Run(System.String,System.String)">
<member name="M:Tools.MyAgent.StorageCleanup.Run(System.String,System.String,System.Int32)">
<summary>
执行清理(启动 / 配置变更时调用;内部容错,单项失败不影响其他)。
</summary>
<param name="agentRoot">默认 Agent 目录(output 位于其下);日志目录按程序基目录 Logs 计算</param>
<param name="logsRoot">日志根目录(默认 程序基目录/Logs;测试可注入临时目录)</param>
<param name="runsKeepDays">可观测性 runs_*.jsonl 的保留天数(默认 <see cref="F:Tools.MyAgent.StorageCleanup.RunsKeepDays"/>)</param>
</member>
<member name="M:Tools.MyAgent.StorageCleanup.CleanDir(System.String,System.String,System.Int32,System.DateTime)">
<summary>删除目录内超过保留期的文件;返回删除数(单项失败跳过)</summary>
......@@ -3014,6 +3377,12 @@
<member name="M:Tools.MyAgent.wMyAgent.BuildReplyUsageSuffix">
<summary>构造本轮回复用量标注(相对发送前快照的增量;本轮无调用记录返回空串)</summary>
</member>
<member name="F:Tools.MyAgent.wMyAgent._lastRunSummary">
<summary>最近一轮的运行摘要(订阅 <c>AgentTelemetry.OnRunCompleted</c> 取得)</summary>
</member>
<member name="M:Tools.MyAgent.wMyAgent.OnRunCompleted(Tools.MyAgent.AgentTelemetry.RunScope)">
<summary>M4:一轮运行完成 → 生成摘要文本(供回复尾注使用)</summary>
</member>
<member name="M:Tools.MyAgent.wMyAgent.OnToolTrace(System.Collections.Generic.IReadOnlyList{Tools.MyAgent.Agent.AgentEngine.ToolTraceEntry})">
<summary>工具轨迹(#16):回答末尾追加可折叠 details,展示工具调用链(先宽后窄的检索过程)</summary>
</member>
......@@ -3222,6 +3591,25 @@
<summary>全部可用斜杠命令(命令菜单按此列表展示:命令 + 参数 + 说明)</summary>
</member>
<!-- Badly formed XML comment ignored for member "M:Tools.MyAgent.wMyAgent.RunKbQuickSearch(System.String)" -->
<member name="M:Tools.MyAgent.wMyAgent.OpenAdmin">
<summary>
/admin:打开 Admin 看板。
<para>权限:<c>AdminRoles</c> 为空 = 不限制(保持现状,不破坏既有宿主);
仅当隐私级别为 detailed 时才把参数摘要传给看板。</para>
</summary>
</member>
<member name="M:Tools.MyAgent.wMyAgent.IsAdminRole">
<summary>角色判定:与 <c>ToolRoleAllow</c> 同一套语义(当前登录用户即角色)。</summary>
</member>
<member name="M:Tools.MyAgent.wMyAgent.RunStats">
<summary>/stats:本次进程累计(多日趋势见 Admin 看板)</summary>
</member>
<member name="M:Tools.MyAgent.wMyAgent.RunDiagnostics">
<summary>/diag:依赖健康检查(9 项) + 本次进程累计用量</summary>
</member>
<member name="M:Tools.MyAgent.wMyAgent.BuildDiagUsage">
<summary>/diag 的用量段(进程内累计;多日趋势见 Admin 看板)</summary>
</member>
<!-- Badly formed XML comment ignored for member "M:Tools.MyAgent.wMyAgent.SwitchModel(System.String)" -->
<!-- Badly formed XML comment ignored for member "M:Tools.MyAgent.wMyAgent.SwitchScope(System.String)" -->
<member name="M:Tools.MyAgent.wMyAgent.BackupAgentData">
......
......@@ -53,6 +53,9 @@ namespace Tools.MyAgent.Agent
/// <summary>本轮是否调用过诊断类工具(P3 双契约判定的输入之一,随 RunAsync 重置)。</summary>
private bool _diagnosticToolHit;
/// <summary>本轮可观测性上下文(RunId 与 <c>_diagnosticSessionId</c> 同源)。</summary>
private AgentTelemetry.RunScope _runScope;
/// <summary>本轮回答类型(P3 双契约);在 FinishAnswerAsync 开头按当前证据重算。</summary>
private AnswerKind _answerKind = AnswerKind.Knowledge;
......@@ -300,6 +303,7 @@ namespace Tools.MyAgent.Agent
_recall.MaxImages = cfg.MaxAnswerImages > 0 ? Math.Min(cfg.MaxAnswerImages, 16) : 16;
CostEstimator.Configure(cfg.CustomPrices); // R6 费用估算:应用自定义价格表覆盖
AgentTelemetry.Configure(cfg.TelemetryEnabled, cfg.ObservabilityLevel); // 可观测性开关与隐私级别
// P0 连接串脱敏 / P3 工具权限:预存连接与角色规则随配置注入
DbConnections = cfg.DbConnections ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
_toolRoleAllow = cfg.ToolRoleAllow ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
......@@ -350,6 +354,8 @@ namespace Tools.MyAgent.Agent
_diagnosticToolHit = false;
_answerKind = AnswerKind.Knowledge;
_currentQuestion = LastUserQuestion(history);
_runScope = AgentTelemetry.BeginRun(_currentQuestion.Length, AgentTelemetry.HashQuestion(_currentQuestion), _client.Model);
if (_runScope != null) _runScope.RunId = _diagnosticSessionId;
var messages = await BuildMessagesAsync(history, _cts.Token).ConfigureAwait(false);
var tools = BuildTools();
var stopwatch = Stopwatch.StartNew();
......@@ -368,7 +374,7 @@ namespace Tools.MyAgent.Agent
{
_cts.Token.ThrowIfCancellationRequested();
var result = await SendAndLogAsync(messages, tools, _cts.Token).ConfigureAwait(false);
var result = await SendAndLogAsync(messages, tools, _cts.Token, "tool-round").ConfigureAwait(false);
// 思维链
if (result.ReasoningContent.Length > 0)
......@@ -428,7 +434,7 @@ namespace Tools.MyAgent.Agent
: "单次回答总时长达到上限,启动收尾回答轮");
OnTextDelta?.Invoke($"\n\n({(roundsUp ? $"已达到工具调用轮次上限({_maxToolRounds} 轮)" : "已达到单次回答总时长上限")},正在基于已收集的信息汇总回答…)");
messages.Add(new JObject { ["role"] = "user", ["content"] = FinalizePrompt });
var final = await SendAndLogAsync(messages, null, _cts.Token).ConfigureAwait(false);
var final = await SendAndLogAsync(messages, null, _cts.Token, "finish").ConfigureAwait(false);
if (final.ReasoningContent.Length > 0)
OnThinkingDelta?.Invoke(final.ReasoningContent);
await FinishAnswerAsync(messages, final, finalAnswer, _cts.Token).ConfigureAwait(false);
......@@ -438,6 +444,7 @@ namespace Tools.MyAgent.Agent
if (!IsDisposed)
{
AgentTelemetry.EndRun(_runScope, "success", "", null);
OnComplete?.Invoke();
if (_toolTrace.Count > 0)
OnToolTrace?.Invoke(_toolTrace.ToArray());
......@@ -445,17 +452,32 @@ namespace Tools.MyAgent.Agent
}
catch (OperationCanceledException)
{
AgentTelemetry.EndRun(_runScope, "cancel", "", null);
if (!IsDisposed)
{
// G2 修复:取消/异常路径此前只调 OnError,工具轨迹被丢弃 —— 失败恰恰最需要轨迹。
// ⚠ 顺序不可颠倒:宿主 OnError 会用错误文本整段覆盖 last.Content,
// 先发轨迹会被覆盖吃掉 ⇒ 必须先 OnError,再追加轨迹。
OnError?.Invoke("已取消。");
if (_toolTrace.Count > 0)
OnToolTrace?.Invoke(_toolTrace.ToArray());
}
}
catch (Exception ex)
{
AgentTelemetry.EndRun(_runScope, "error", ClassifyError(ex), ex);
if (!IsDisposed)
{
// 同取消路径:先写错误文本,再追加轨迹(顺序颠倒会被覆盖)
OnError?.Invoke(DescribeError(ex));
if (_toolTrace.Count > 0)
OnToolTrace?.Invoke(_toolTrace.ToArray());
}
}
finally
{
IsRunning = false;
_runScope = null;
try { _cts?.Dispose(); } catch { }
_cts = null;
if (!IsDisposed)
......@@ -487,6 +509,8 @@ namespace Tools.MyAgent.Agent
AgentLogger.Log("本地召回异常,回退工具循环:" + ex.Message);
return false;
}
if (_runScope != null && recall != null && !string.IsNullOrEmpty(recall.Mode))
_runScope.Mode = recall.Mode; // kb / mixed / model(后续走工具循环时 Mode 仍记为 model)
if (recall == null || recall.Primary == null || recall.Mode == "model") return false;
// 证据注入按「先记账、失败可回滚」处理:直答失败要还原 messages,
......@@ -502,7 +526,7 @@ namespace Tools.MyAgent.Agent
AgentLogger.Log($"本地召回命中:mode={recall.Mode} score={recall.Primary.Score:0.00} file={recall.Primary.RelPath} kw={string.Join("|", recall.Keywords)}");
var result = await SendAndLogAsync(messages, null, ct).ConfigureAwait(false);
var result = await SendAndLogAsync(messages, null, ct, "direct").ConfigureAwait(false);
if (result.ReasoningContent.Length > 0)
OnThinkingDelta?.Invoke(result.ReasoningContent);
......@@ -603,7 +627,7 @@ namespace Tools.MyAgent.Agent
"本次的工具调用配额已用完,禁止再发起任何工具调用。请基于以上已收集到的全部信息,直接、完整地回答用户的问题:先给结论,再展开依据与细节,引用处标注来源路径;已收集信息未覆盖的内容明确标注「未充分核实」。直接开始回答。";
/// <summary>发送(带故障转移)并记录运行日志;tools=null 表示不下发工具(收尾回答轮/续写轮)</summary>
private async Task<ChatResult> SendAndLogAsync(JArray messages, JArray tools, CancellationToken ct)
private async Task<ChatResult> SendAndLogAsync(JArray messages, JArray tools, CancellationToken ct, string kind = "tool-round")
{
var start = DateTime.UtcNow;
var result = await SendWithFailoverAsync(messages, tools, ct).ConfigureAwait(false);
......@@ -621,6 +645,20 @@ namespace Tools.MyAgent.Agent
string.Join(";", result.ToolCalls.Select(tc => tc.Name + "(" + Clip(tc.Arguments, 40) + ")")),
result.FinishReason,
_diagnosticSessionId);
// 可观测性:一次 LLM 请求 span(M6 接入 TtfbMs 前该字段恒为 0)
AgentTelemetry.RecordLlm(_runScope, new AgentTelemetry.LlmSpan
{
RunId = _diagnosticSessionId,
Kind = kind,
Model = string.IsNullOrEmpty(_lastServedModel) ? _client.Model : _lastServedModel,
DurationMs = (long)(DateTime.UtcNow - start).TotalMilliseconds,
PromptTokens = result.PromptTokens > 0 ? result.PromptTokens : 0,
CompletionTokens = result.CompletionTokens > 0 ? result.CompletionTokens : 0,
FinishReason = result.FinishReason ?? "",
Outcome = "success",
TtfbMs = result.TtfbMs, // M6:首包延迟(未启用/非流式时为 0)
});
return result;
}
......@@ -649,7 +687,7 @@ namespace Tools.MyAgent.Agent
AppendContinuationMessages(messages, result);
OnTextDelta?.Invoke(finalAnswer.ToString());
AgentLogger.Log($"回答被输出上限(max_tokens)截断,自动续写第 {_continueRounds}/{MaxContinuationRounds} 轮");
result = await SendAndLogAsync(messages, null, ct).ConfigureAwait(false);
result = await SendAndLogAsync(messages, null, ct, "continuation").ConfigureAwait(false);
if (result.ReasoningContent.Length > 0)
OnThinkingDelta?.Invoke(result.ReasoningContent);
continue;
......@@ -694,6 +732,7 @@ namespace Tools.MyAgent.Agent
_failover.RecordSuccess();
if (wasOnBackup)
{
AgentTelemetry.Count("agent_failover_total|event=restore");
OnTextDelta?.Invoke("\n\n(主模型已恢复,切回主模型继续回答)");
}
return r;
......@@ -702,6 +741,8 @@ namespace Tools.MyAgent.Agent
{
if (_failover.RecordFailure(ex))
{
if (_runScope != null) _runScope.FailoverCount++;
AgentTelemetry.Count("agent_failover_total|event=switch");
OnTextDelta?.Invoke("\n\n(主模型服务异常,已自动切换至备用模型继续回答)");
AgentLogger.Log("FAILOVER 切换至备用模型:" + _failover.CurrentClient.Model);
_lastServedModel = _failover.CurrentClient.Model; // R6:切换后按备用模型价格计费
......@@ -1130,6 +1171,15 @@ namespace Tools.MyAgent.Agent
if (done != execTask)
{
AgentLogger.Log($"工具 {tc.Name} 未在协作取消后按时结束,已放弃等待");
AgentTelemetry.RecordTool(_runScope, new AgentTelemetry.ToolSpan
{
RunId = _diagnosticSessionId,
Name = tc.Name,
ArgsSummary = AgentTelemetry.Summarize(tc.Arguments),
DurationMs = ToolTimeoutSeconds * 1000L,
Outcome = ct.IsCancellationRequested ? "cancel" : "timeout",
ErrorType = "timeout",
});
return ct.IsCancellationRequested
? "已取消。"
: $"错误:工具 {tc.Name} 执行超时({ToolTimeoutSeconds} 秒),已中止。";
......@@ -1138,7 +1188,17 @@ namespace Tools.MyAgent.Agent
}
catch (OperationCanceledException)
{
if (ct.IsCancellationRequested)
bool userCanceled = ct.IsCancellationRequested;
AgentTelemetry.RecordTool(_runScope, new AgentTelemetry.ToolSpan
{
RunId = _diagnosticSessionId,
Name = tc.Name,
ArgsSummary = AgentTelemetry.Summarize(tc.Arguments),
DurationMs = ToolTimeoutSeconds * 1000L,
Outcome = userCanceled ? "cancel" : "timeout",
ErrorType = userCanceled ? "" : "timeout",
});
if (userCanceled)
return "已取消。";
return $"错误:工具 {tc.Name} 执行超时({ToolTimeoutSeconds} 秒),已中止。";
}
......@@ -1203,6 +1263,17 @@ namespace Tools.MyAgent.Agent
sw.Stop();
// #29 工具日志
AgentLogger.LogTool(tc.Name, desc, sw.ElapsedMilliseconds, SummarizeToolResult(result), _diagnosticSessionId);
AgentTelemetry.RecordTool(_runScope, new AgentTelemetry.ToolSpan
{
RunId = _diagnosticSessionId,
Name = tc.Name,
ArgsSummary = AgentTelemetry.Summarize(desc),
StartOffsetMs = _runScope != null && _runScope.Sw != null ? _runScope.Sw.ElapsedMilliseconds - sw.ElapsedMilliseconds : 0,
DurationMs = sw.ElapsedMilliseconds,
Outcome = "ok",
ResultChars = (result ?? "").Length,
Truncated = result != null && result.Length > MaxToolReturnChars,
});
// #42 结果压缩:超长结果压缩后回填模型,完整结果落盘供溯源
result = CompressToolResult(result, tc.Name) ?? "";
if (gen == System.Threading.Volatile.Read(ref _runGeneration))
......@@ -1228,6 +1299,15 @@ namespace Tools.MyAgent.Agent
catch (Exception ex)
{
sw.Stop();
AgentTelemetry.RecordTool(_runScope, new AgentTelemetry.ToolSpan
{
RunId = _diagnosticSessionId,
Name = tc.Name,
ArgsSummary = AgentTelemetry.Summarize(tc.Arguments),
DurationMs = sw.ElapsedMilliseconds,
Outcome = "error",
ErrorType = "unknown",
});
if (gen == System.Threading.Volatile.Read(ref _runGeneration))
{
lock (_toolTrace)
......@@ -1235,7 +1315,8 @@ namespace Tools.MyAgent.Agent
_toolTrace.Add(new ToolTraceEntry
{
Name = tc.Name,
Args = tc.Name,
// 修复:此前异常分支写的是工具名,与正常分支的参数摘要不一致
Args = AgentTelemetry.Summarize(tc.Arguments),
DurationMs = sw.ElapsedMilliseconds,
ResultSummary = "异常:" + ex.Message,
});
......@@ -1283,6 +1364,19 @@ namespace Tools.MyAgent.Agent
#endregion
/// <summary>把异常归类为可聚合的 ErrorType(观测用,不参与用户提示文案)。</summary>
private static string ClassifyError(Exception ex)
{
string msg = (ex?.InnerException?.Message ?? ex?.Message ?? "").ToLowerInvariant();
if (msg.Contains("401")) return "auth";
if (msg.Contains("402")) return "quota";
if (msg.Contains("429")) return "ratelimit";
if (msg.Contains("413") || msg.Contains("context_length") || msg.Contains("上下文")) return "context_length";
if (msg.Contains("timeout") || msg.Contains("超时") || msg.Contains("timed out")) return "timeout";
if (msg.Contains("connection") || msg.Contains("连接") || msg.Contains("5")) return "network";
return "unknown";
}
private static string DescribeError(Exception ex)
{
// 鉴权 / 额度错误给出中文提示
......
......@@ -2,6 +2,7 @@
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
......@@ -30,6 +31,9 @@ namespace Tools.MyAgent.Agent
public string FinishReason { get; set; } = "";
/// <summary>用量(SSE 末帧 usage;无则 -1)</summary>
public int PromptTokens { get; set; } = -1;
/// <summary>首包延迟(ms):发出请求到收到首个有效 delta;无数据为 0(M6,向后兼容)</summary>
public long TtfbMs { get; set; }
public int CompletionTokens { get; set; } = -1;
}
......@@ -542,6 +546,8 @@ namespace Tools.MyAgent.Agent
private async Task<ChatResult> ReadSseAsync(StreamReader reader, CancellationToken ct)
{
var result = new ChatResult();
var ttfbSw = Stopwatch.StartNew();
bool ttfbSet = false;
var contentBuffer = new StringBuilder();
var reasoningBuffer = new StringBuilder();
var toolCalls = new Dictionary<int, ApiToolCall>();
......@@ -582,6 +588,13 @@ namespace Tools.MyAgent.Agent
var delta = first["delta"] as JObject;
if (delta == null) continue;
// M6:首包延迟打点(首个有效 delta,正文/思维链任一均可)
if (!ttfbSet && (delta["content"] != null || delta["reasoning_content"] != null))
{
ttfbSet = true;
result.TtfbMs = ttfbSw.ElapsedMilliseconds;
}
// deepseek-reasoner 思维链
var reasoning = delta["reasoning_content"]?.Value<string>();
if (!string.IsNullOrEmpty(reasoning))
......
using System;
using System.Windows.Forms;
using System.Collections.Generic;
using System.IO;
using System.Linq;
......@@ -99,6 +100,12 @@ namespace Tools.MyAgent
return new AgentEngine(knowledgeBase, DataRoot, ProductContext, _diagnosticRoots) { CurrentUser = _options.CurrentUser ?? "访客" };
}
/// <summary>创建 Admin 看板窗体(供宿主挂到菜单/工具栏)。</summary>
public Form CreateAdminForm(int days = 7, bool allowDetailed = false)
{
return new Tools.MyAgent.Forms.AgentAdminForm(null, days, allowDetailed);
}
private void EnsureDefaultResources()
{
Directory.CreateDirectory(DataRoot);
......
......@@ -174,5 +174,36 @@
/// <para>空字典/缺省 = 所有登录用户可用(保持现状);值为空串或 "*" = 不限制。</para>
/// </summary>
public Dictionary<string, string> ToolRoleAllow { get; set; } = new Dictionary<string, string>();
// ══════════════ 高级项:可观测性(不进设置页,内部默认 + 手改配置文件微调) ══════════════
/// <summary>可观测性总开关;关闭则不写 jsonl、不建队列(纯文本日志照旧)。</summary>
public bool TelemetryEnabled { get; set; } = true;
/// <summary>隐私级别:<c>off</c> 不采集 / <c>basic</c> 结构化字段不写原文(默认) / <c>detailed</c> 追加提问与工具返回摘要。</summary>
public string ObservabilityLevel { get; set; } = "basic";
/// <summary>启动时执行一次依赖自检(默认关闭,仅 /diag 与看板触发)。</summary>
public bool HealthCheckOnStart { get; set; } = false;
/// <summary>runs_*.jsonl 保留天数(默认 14)。</summary>
public int RunsKeepDays { get; set; } = 14;
/// <summary>回复尾注是否带本轮摘要(耗时/工具数/通道);默认关闭,避免打扰。</summary>
public bool ShowRunSummary { get; set; } = false;
// ── Admin 看板 ──
/// <summary>看板总开关;关闭时 /admin 提示不可用。</summary>
public bool AdminEnabled { get; set; } = true;
/// <summary>允许打开看板的角色(逗号分隔);空 = 不限制(保持现状,不破坏既有宿主)。</summary>
public string AdminRoles { get; set; } = "";
/// <summary>看板读取最近 N 天(1~30)。</summary>
public int DashboardDays { get; set; } = 7;
/// <summary>看板 30s 自动刷新(默认关闭,避免常驻定时器拖住宿主)。</summary>
public bool AdminAutoRefresh { get; set; } = false;
}
}
using Newtonsoft.Json.Linq;
using System;
using System.Drawing;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using Tools.MyAgent;
using Tools.MyAgent.Support; // BackupHelper(命名空间为 Tools.MyAgent.Support)
namespace Tools.MyAgent.Forms
{
/// <summary>
/// Admin 看板(管理面板):一屏看回答质量、成本、稳定性与知识缺口。
/// <para>纯代码构建 UI(不配 Designer/resx)——本机无 VS 设计器,手写 Designer 易漏 InitializeComponent。</para>
/// <para>与聊天窗 <c>/stats</c> <c>/diag</c> 互补:那两条是终端用户随口问,这里是管理者看趋势。</para>
/// </summary>
public sealed class AgentAdminForm : Form
{
private readonly TelemetryStore _store;
private readonly bool _allowDetailed;
private TelemetryStore.RunRow[] _runs = new TelemetryStore.RunRow[0];
private readonly TabControl _tabs = new TabControl();
private readonly Label _kpi = new Label();
private readonly DashChart _hourly = new DashChart();
private readonly DashChart _toolChart = new DashChart();
private readonly DataGridView _gridTime = NewGrid();
private readonly DataGridView _gridTools = NewGrid();
private readonly DataGridView _gridKb = NewGrid();
private readonly DataGridView _gridErr = NewGrid();
private readonly DataGridView _gridHealth = NewGrid();
private readonly TextBox _detail = new TextBox();
private readonly Timer _timer = new Timer();
private readonly Label _hint = new Label();
public AgentAdminForm(string logsRoot = null, int days = 7, bool allowDetailed = false)
{
_allowDetailed = allowDetailed;
_store = new TelemetryStore(logsRoot, days) { AllowDetailed = allowDetailed };
Text = "智能体运行看板";
StartPosition = FormStartPosition.CenterScreen;
Size = new Size(1000, 680);
MinimumSize = new Size(880, 560);
_tabs.Dock = DockStyle.Fill;
_tabs.TabPages.Add(OverviewPage());
_tabs.TabPages.Add(TimelinePage());
_tabs.TabPages.Add(ToolsPage());
_tabs.TabPages.Add(KbPage());
_tabs.TabPages.Add(ErrorPage());
var top = new Panel { Dock = DockStyle.Top, Height = 36, Padding = new Padding(6, 4, 6, 0) };
var btnRefresh = new Button { Text = "刷新", Width = 70, Height = 26, Left = 6, Top = 4 };
var btnCsv = new Button { Text = "导出 CSV", Width = 90, Height = 26, Left = 84, Top = 4 };
var btnMd = new Button { Text = "导出 Markdown", Width = 120, Height = 26, Left = 180, Top = 4 };
var btnHtml = new Button { Text = "导出 HTML", Width = 90, Height = 26, Left = 306, Top = 4 };
var btnPack = new Button { Text = "诊断包", Width = 80, Height = 26, Left = 402, Top = 4 };
var chkAuto = new CheckBox { Text = "30s 自动刷新", Left = 490, Top = 7, Width = 120 };
_hint.Left = 620; _hint.Top = 9; _hint.Width = 360; _hint.Height = 22;
_hint.ForeColor = Color.FromArgb(120, 120, 120);
_hint.Text = allowDetailed ? "隐私级别:detailed" : "隐私级别:basic(参数摘要已隐藏)";
top.Controls.AddRange(new Control[] { btnRefresh, btnCsv, btnMd, btnHtml, btnPack, chkAuto, _hint });
btnRefresh.Click += (s, e) => LoadData();
btnCsv.Click += (s, e) => ExportCsv();
btnMd.Click += (s, e) => ExportMarkdown();
btnHtml.Click += (s, e) => ExportHtml();
btnPack.Click += (s, e) => ExportPackage();
chkAuto.CheckedChanged += (s, e) => { _timer.Enabled = chkAuto.Checked; };
_timer.Interval = 30000;
_timer.Tick += (s, e) => LoadData();
Controls.Add(_tabs);
Controls.Add(top);
Shown += async (s, e) => await LoadAsync();
}
// ══════════════ 页面构建 ══════════════
private TabPage OverviewPage()
{
var p = new TabPage("① 概览");
_kpi.Dock = DockStyle.Top; _kpi.Height = 92; _kpi.Padding = new Padding(8);
_kpi.Font = new Font(FontFamily.GenericSansSerif, 10f);
_kpi.Text = "加载中…";
_hourly.Dock = DockStyle.Fill;
p.Controls.Add(_hourly);
p.Controls.Add(_kpi);
return p;
}
private TabPage TimelinePage()
{
var p = new TabPage("② 时间线");
_gridTime.Dock = DockStyle.Fill;
_gridTime.SelectionChanged += (s, e) => ShowRunDetail();
_detail.Dock = DockStyle.Bottom; _detail.Height = 150; _detail.Multiline = true;
_detail.ScrollBars = ScrollBars.Vertical; _detail.ReadOnly = true;
_detail.BackColor = Color.White;
p.Controls.Add(_gridTime);
p.Controls.Add(_detail);
return p;
}
private TabPage ToolsPage()
{
var p = new TabPage("③ 工具");
var split = new SplitContainer { Dock = DockStyle.Fill, Orientation = Orientation.Horizontal };
_toolChart.Dock = DockStyle.Fill;
_toolChart.Horizontal = true;
_gridTools.Dock = DockStyle.Fill;
split.Panel1.Controls.Add(_toolChart);
split.Panel2.Controls.Add(_gridTools);
split.SplitterDistance = 220;
p.Controls.Add(split);
return p;
}
private TabPage KbPage()
{
var p = new TabPage("④ 知识库");
_gridKb.Dock = DockStyle.Fill;
p.Controls.Add(_gridKb);
return p;
}
private TabPage ErrorPage()
{
var p = new TabPage("⑤ 错误与健康");
var split = new SplitContainer { Dock = DockStyle.Fill, Orientation = Orientation.Horizontal };
var left = new SplitContainer { Dock = DockStyle.Fill, Orientation = Orientation.Vertical };
_gridErr.Dock = DockStyle.Fill;
_gridHealth.Dock = DockStyle.Fill;
left.Panel1.Controls.Add(_gridErr);
left.Panel2.Controls.Add(_gridHealth);
split.Panel1.Controls.Add(left);
var lab = new Label
{
Dock = DockStyle.Fill,
Padding = new Padding(8),
Text = "健康检查数据来自 Logs/health.json —— 在聊天窗执行 /diag 可生成/刷新。",
};
split.Panel2.Controls.Add(lab);
split.SplitterDistance = 340;
p.Controls.Add(split);
return p;
}
private static DataGridView NewGrid()
{
var g = new DataGridView
{
ReadOnly = true,
AllowUserToAddRows = false,
AllowUserToDeleteRows = false,
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
BackgroundColor = Color.White,
RowHeadersVisible = false,
};
return g;
}
// ══════════════ 数据加载 ══════════════
private async Task LoadAsync()
{
try
{
_runs = await Task.Run(() => _store.LoadRuns());
Bind();
}
catch { }
}
private void LoadData()
{
try
{
_runs = _store.LoadRuns();
Bind();
}
catch { }
}
private void Bind()
{
try
{
BindOverview();
BindTimeline();
BindTools();
BindKb();
BindErrors();
BindHealth();
if (_store.ReadLines > 0 && _store.BadLines * 20 > _store.ReadLines)
_hint.Text = "⚠ 日志文件疑似损坏(坏行 " + _store.BadLines + "/" + _store.ReadLines + ")";
}
catch { }
}
private void BindOverview()
{
int runs = _runs.Length;
int ok = 0, err = 0, cancel = 0;
long tokens = 0; double cost = 0; int tools = 0, llms = 0;
foreach (var r in _runs)
{
if (r.Outcome == "success") ok++;
else if (r.Outcome == "error") err++;
else cancel++;
tokens += r.PromptTokens + r.CompletionTokens;
cost += r.Cost;
tools += r.ToolCalls;
llms += r.LlmCalls;
}
long p50 = 0, p95 = 0;
var ts = new System.Collections.Generic.List<long>();
foreach (var r in _runs) ts.Add(r.TotalMs);
ts.Sort();
if (ts.Count > 0)
{
p50 = ts[(int)Math.Ceiling(ts.Count * 0.5) - 1];
p95 = ts[(int)Math.Ceiling(ts.Count * 0.95) - 1];
}
double rate = runs == 0 ? 0 : ok * 100.0 / runs;
double perRun = runs == 0 ? 0 : (double)llms / runs;
_kpi.Text = string.Format(
"轮数 {0} 成功率 {1:F1}% 失败 {2} / 取消 {3}\n" +
"耗时 P50 {4}ms · P95 {5}ms 工具 {6} 次 · LLM {7} 次(均 {8:F1} 次/轮)\n" +
"Token {9} 费用 {10} 直答率 {11:F1}%",
runs, rate, err, cancel, p50, p95, tools, llms, perRun,
tokens, cost > 0 ? CostEstimator.Format(cost) : "—",
_store.AggregateModes(_runs).DirectRate);
long[] failed;
long[] okHours = _store.Hourly(_runs, out failed);
var labels = new string[24];
var v1 = new double[24];
var v2 = new double[24];
for (int i = 0; i < 24; i++)
{
labels[i] = i.ToString("00");
v1[i] = okHours[i];
v2[i] = failed[i];
}
_hourly.Labels = labels;
_hourly.Values = v1;
_hourly.Values2 = v2;
_hourly.EmptyText = "近 24 小时无数据";
_hourly.Caption = "近 24 小时 · 按启动小时(绿=成功 红=失败,柱顶数字=该小时轮数)";
_hourly.Invalidate();
}
private void BindTimeline()
{
_gridTime.Columns.Clear();
_gridTime.Rows.Clear();
AddCols(_gridTime, "时间", "通道", "结果", "耗时ms", "工具", "LLM", "Token", "费用", "模型", "RunId");
int n = Math.Min(200, _runs.Length);
for (int i = 0; i < n; i++)
{
var r = _runs[i];
_gridTime.Rows.Add(
r.StartedAt == DateTime.MinValue ? "-" : r.StartedAt.ToString("MM-dd HH:mm:ss"),
r.Mode, r.Outcome, r.TotalMs, r.ToolCalls, r.LlmCalls,
r.PromptTokens + r.CompletionTokens,
r.Cost > 0 ? CostEstimator.Format(r.Cost) : "—",
r.Model, r.RunId);
}
}
private void ShowRunDetail()
{
try
{
if (_gridTime.SelectedRows.Count == 0) { _detail.Text = ""; return; }
string runId = Convert.ToString(_gridTime.SelectedRows[0].Cells["RunId"].Value);
foreach (var r in _runs)
{
if (!string.Equals(r.RunId, runId, StringComparison.Ordinal)) continue;
var sb = new StringBuilder();
sb.AppendLine("RunId " + r.RunId + " · " + r.Mode + " · " + r.Outcome +
(string.IsNullOrEmpty(r.ErrorType) ? "" : " / " + r.ErrorType));
sb.AppendLine("耗时 " + r.TotalMs + "ms · LLM " + r.LlmCalls + " 次 · token " +
r.PromptTokens + "+" + r.CompletionTokens);
sb.AppendLine("— 工具时间线 —");
if (r.Tools.Count == 0) sb.AppendLine("(本轮未调用工具)");
foreach (var t in r.Tools)
sb.AppendLine(string.Format(" {0} {1}ms {2}{3}{4}",
t.Name, t.DurationMs, t.Outcome,
t.Truncated ? " [结果截断]" : "",
_allowDetailed && t.ArgsSummary.Length > 0 ? " " + t.ArgsSummary : ""));
_detail.Text = sb.ToString();
return;
}
}
catch { }
}
private void BindTools()
{
var aggs = _store.AggregateTools(_runs);
_gridTools.Columns.Clear();
_gridTools.Rows.Clear();
AddCols(_gridTools, "工具", "调用", "错误", "超时", "截断", "失败率%", "P50ms", "P95ms", "最大ms");
var names = new string[aggs.Length];
var vals = new double[aggs.Length];
for (int i = 0; i < aggs.Length; i++)
{
var a = aggs[i];
_gridTools.Rows.Add(a.Name, a.Calls, a.Errors, a.Timeouts, a.Truncated,
a.FailRate.ToString("F1"), a.P50, a.P95, a.Max);
names[i] = a.Name;
vals[i] = a.P95;
}
_toolChart.Labels = names;
_toolChart.Values = vals;
_toolChart.Values2 = new double[0];
_toolChart.EmptyText = "暂无工具调用";
_toolChart.Invalidate();
}
private void BindKb()
{
var m = _store.AggregateModes(_runs);
_gridKb.Columns.Clear();
_gridKb.Rows.Clear();
AddCols(_gridKb, "指标", "值", "说明");
_gridKb.Rows.Add("直答率", m.DirectRate.ToString("F1") + "%", "kb + mixed 占总轮数(P3 本地召回收益)");
_gridKb.Rows.Add("kb 命中直答", m.Kb, "证据充分,单轮直答");
_gridKb.Rows.Add("mixed 部分命中", m.Mixed, "证据 + 模型补充");
_gridKb.Rows.Add("model 未召回", m.Model, "知识库未命中");
_gridKb.Rows.Add("tool-loop 工具循环", m.ToolLoop, "多轮工具检索");
_gridKb.Rows.Add("检索零命中", AgentTelemetry.Get("agent_kb_zero_hit_total"), "进程内累计(知识缺口信号)");
}
private void BindErrors()
{
var errs = _store.AggregateErrors(_runs, 20);
_gridErr.Columns.Clear();
_gridErr.Rows.Clear();
AddCols(_gridErr, "错误类型", "次数");
foreach (var kv in errs) _gridErr.Rows.Add(kv.Key, kv.Value);
var recent = _store.RecentErrors(_runs, 20);
foreach (var r in recent)
_gridErr.Rows.Add("最近 · " + (r.StartedAt == DateTime.MinValue ? "-" : r.StartedAt.ToString("MM-dd HH:mm"))
+ " · " + r.ErrorType, r.Outcome);
}
private void BindHealth()
{
_gridHealth.Columns.Clear();
_gridHealth.Rows.Clear();
AddCols(_gridHealth, "状态", "检查项", "说明");
try
{
string path = Path.Combine(_store.LogsRoot, "health.json");
if (!File.Exists(path))
{
_gridHealth.Rows.Add("—", "未生成", "在聊天窗执行 /diag 后刷新");
return;
}
var o = JObject.Parse(File.ReadAllText(path, Encoding.UTF8));
var arr = o["items"] as JArray;
if (arr == null) return;
foreach (var it in arr)
{
var jo = it as JObject;
if (jo == null) continue;
string st = Str(jo, "Status");
_gridHealth.Rows.Add(st == "fail" ? "❌" : st == "warn" ? "⚠️" : "✅",
Str(jo, "Name"), Str(jo, "Detail"));
}
}
catch { }
}
private static void AddCols(DataGridView g, params string[] names)
{
foreach (var n in names) g.Columns.Add(n.Replace(" ", ""), n);
}
private static string Str(JObject o, string name)
{
var v = o[name];
return v == null ? "" : v.ToString();
}
// ══════════════ 导出 ══════════════
private DataGridView CurrentGrid()
{
switch (_tabs.SelectedIndex)
{
case 1: return _gridTime;
case 2: return _gridTools;
case 3: return _gridKb;
case 4: return _gridErr;
default: return _gridTime;
}
}
private void ExportCsv()
{
try
{
var dlg = new SaveFileDialog { Filter = "CSV 文件|*.csv", FileName = "agent_stats.csv" };
if (dlg.ShowDialog(this) != DialogResult.OK) return;
var g = CurrentGrid();
var sb = new StringBuilder();
for (int c = 0; c < g.Columns.Count; c++)
sb.Append((c > 0 ? "," : "") + "\"" + g.Columns[c].HeaderText + "\"");
sb.Append("\n");
foreach (DataGridViewRow row in g.Rows)
{
for (int c = 0; c < g.Columns.Count; c++)
{
string v = row.Cells[c].Value == null ? "" : row.Cells[c].Value.ToString();
sb.Append((c > 0 ? "," : "") + "\"" + v.Replace("\"", "'") + "\"");
}
sb.Append("\n");
}
File.WriteAllText(dlg.FileName, sb.ToString(), new UTF8Encoding(true)); // BOM:Excel 中文不乱码
MessageBox.Show(this, "已导出:" + dlg.FileName, "导出 CSV", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex) { MessageBox.Show(this, ex.Message, "导出失败", MessageBoxButtons.OK, MessageBoxIcon.Error); }
}
private void ExportMarkdown()
{
try
{
var dlg = new SaveFileDialog { Filter = "Markdown|*.md", FileName = "agent_report.md" };
if (dlg.ShowDialog(this) != DialogResult.OK) return;
var sb = new StringBuilder();
sb.Append("# 智能体运行概览\n\n生成时间:").Append(DateTime.Now.ToString("yyyy-MM-dd HH:mm")).Append("\n\n");
sb.Append("```\n").Append(_kpi.Text).Append("\n```\n\n");
sb.Append("## 工具耗时(P95)\n\n| 工具 | 调用 | 错误 | 超时 | P50ms | P95ms |\n|---|---|---|---|---|---|\n");
foreach (var a in _store.AggregateTools(_runs))
sb.Append("| ").Append(a.Name).Append(" | ").Append(a.Calls).Append(" | ").Append(a.Errors)
.Append(" | ").Append(a.Timeouts).Append(" | ").Append(a.P50).Append(" | ").Append(a.P95).Append(" |\n");
sb.Append("\n## 错误分布\n\n| 类型 | 次数 |\n|---|---|\n");
foreach (var kv in _store.AggregateErrors(_runs, 20))
sb.Append("| ").Append(kv.Key).Append(" | ").Append(kv.Value).Append(" |\n");
File.WriteAllText(dlg.FileName, sb.ToString(), new UTF8Encoding(false));
MessageBox.Show(this, "已导出:" + dlg.FileName, "导出 Markdown", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex) { MessageBox.Show(this, ex.Message, "导出失败", MessageBoxButtons.OK, MessageBoxIcon.Error); }
}
private void ExportHtml()
{
try
{
var dlg = new SaveFileDialog { Filter = "HTML|*.html", FileName = "agent_report.html" };
if (dlg.ShowDialog(this) != DialogResult.OK) return;
var sb = new StringBuilder();
sb.Append("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>智能体运行概览</title>")
.Append("<style>body{font-family:'Microsoft YaHei',sans-serif;margin:24px;color:#222}")
.Append("table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:6px 8px;text-align:left}")
.Append("th{background:#f4f6f8}pre{background:#f6f8fa;padding:12px}</style></head><body>")
.Append("<h1>智能体运行概览</h1><p>").Append(DateTime.Now.ToString("yyyy-MM-dd HH:mm")).Append("</p><pre>")
.Append(_kpi.Text).Append("</pre>");
var g = CurrentGrid();
sb.Append("<h2>明细</h2><table><tr>");
for (int c = 0; c < g.Columns.Count; c++) sb.Append("<th>").Append(g.Columns[c].HeaderText).Append("</th>");
sb.Append("</tr>");
foreach (DataGridViewRow row in g.Rows)
{
sb.Append("<tr>");
for (int c = 0; c < g.Columns.Count; c++)
sb.Append("<td>").Append(row.Cells[c].Value == null ? "" : row.Cells[c].Value.ToString()).Append("</td>");
sb.Append("</tr>");
}
sb.Append("</table></body></html>");
File.WriteAllText(dlg.FileName, sb.ToString(), new UTF8Encoding(false));
MessageBox.Show(this, "已导出:" + dlg.FileName, "导出 HTML", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex) { MessageBox.Show(this, ex.Message, "导出失败", MessageBoxButtons.OK, MessageBoxIcon.Error); }
}
private void ExportPackage()
{
try
{
var dlg = new SaveFileDialog { Filter = "ZIP|*.zip", FileName = "agent_diag.zip" };
if (dlg.ShowDialog(this) != DialogResult.OK) return;
string agentRoot = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Agent");
string path = BackupHelper.BackupAgent(agentRoot, null, dlg.FileName);
MessageBox.Show(this, string.IsNullOrEmpty(path) ? "导出失败" : "已导出:" + path,
"诊断包", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex) { MessageBox.Show(this, ex.Message, "导出失败", MessageBoxButtons.OK, MessageBoxIcon.Error); }
}
protected override void Dispose(bool disposing)
{
try
{
if (disposing)
{
_timer.Enabled = false;
_timer.Dispose();
}
}
catch { }
base.Dispose(disposing);
}
}
}
......@@ -120,6 +120,7 @@ namespace Tools.MyAgent.Forms
InitializeComponent();
BuildDbConnectionsTab(); // P0:预存数据库连接(db_query 按 ID 引用,聊天不回显密码)
BuildToolPermissionUi(); // P3:工具级权限(按角色)输入区
BuildDashboardTab(); // 可观测性:统计看板入口(替代隐晦的 /admin)
// 回答形态不再提供设置页(v1.2):全部内部默认(见 AgentConfig 注释),降低设置页认知负担
BindConfig(); // 配置值回填控件(控件创建见 Designer.cs)
ApplyProviderMode(); // 布局联动:BaseUrl 行显隐
......@@ -208,6 +209,125 @@ namespace Tools.MyAgent.Forms
grpOpt.Height = 216;
}
// ══ 可观测性:统计看板入口(程序化创建,避免侵入设计器;替代隐晦的 /admin) ══
private TabPage _dashTab;
private Label _lblDashKpi;
private Button _btnOpenDashboard;
/// <summary>
/// 新增「统计看板」选项卡:实时进程用量 + 一键打开完整看板(5 Tab)。
/// <para>看板本体仍是独立窗体 AgentAdminForm(1000×680 设计尺寸),此处仅做入口与即时 KPI,避免在窄设置页里硬塞大面板。</para>
/// </summary>
private void BuildDashboardTab()
{
_dashTab = new TabPage { Text = "统计看板", Padding = new Padding(3) };
var grpKpi = new GroupBox
{
Text = "运行统计(本进程实时累计)",
Location = new Point(8, 8),
Size = new Size(596, 172),
};
_lblDashKpi = new Label
{
Location = new Point(16, 26),
Size = new Size(564, 138),
AutoSize = false,
Font = new Font("Consolas", 9.5F),
UseMnemonic = false,
};
grpKpi.Controls.Add(_lblDashKpi);
var grpOpen = new GroupBox
{
Text = "完整看板",
Location = new Point(8, 188),
Size = new Size(596, 116),
};
_btnOpenDashboard = new Button
{
Text = "打开完整看板(多日趋势 · 工具 · 知识库 · 错误与健康)",
Location = new Point(16, 26),
Size = new Size(564, 32),
};
var lblOpen = new Label
{
Text = "完整看板含 5 个 Tab,可导出 CSV / Markdown / HTML / 诊断包。\n等价命令:在聊天窗输入 /admin。",
Location = new Point(16, 64),
Size = new Size(564, 44),
AutoSize = false,
ForeColor = Color.Gray,
};
grpOpen.Controls.Add(_btnOpenDashboard);
grpOpen.Controls.Add(lblOpen);
_dashTab.Controls.Add(grpKpi);
_dashTab.Controls.Add(grpOpen);
tabMain.TabPages.Add(_dashTab);
_btnOpenDashboard.Click += (s, e) => OpenDashboard();
// 切到该 Tab 时刷新 KPI(设置窗是模态,进来看的就是最新值)
tabMain.SelectedIndexChanged += (s, e) =>
{
if (tabMain.SelectedTab == _dashTab) RefreshDashKpi();
};
RefreshDashKpi();
}
/// <summary>打开完整看板(与 /admin 同源,受 AdminEnabled 开关约束)。</summary>
private void OpenDashboard()
{
try
{
if (!_config.AdminEnabled)
{
MessageBox.Show(this, "统计看板已关闭(配置项 AdminEnabled=false)。", "提示",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
int days = _config.DashboardDays > 0 && _config.DashboardDays <= 30 ? _config.DashboardDays : 7;
// 设置窗本身是模态对话框:用 ShowDialog(this) 保证看板置顶
using (var f = new AgentAdminForm(null, days, Tools.MyAgent.AgentTelemetry.IsDetailed))
f.ShowDialog(this);
}
catch (Exception ex)
{
MessageBox.Show(this, "打开看板失败:" + ex.Message, "错误",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
/// <summary>即时 KPI(与 /stats 同源:AgentTelemetry.Snapshot)。</summary>
private void RefreshDashKpi()
{
if (_lblDashKpi == null) return;
try
{
var st = Tools.MyAgent.AgentTelemetry.Snapshot();
var sb = new System.Text.StringBuilder();
sb.Append("本轮进程累计:\n");
sb.Append(string.Format(" 轮数 {0}(成功 {1} · 失败 {2} · 取消 {3})\n",
st.Runs, st.Success, st.Errors, st.Cancels));
if (st.Duration.Count > 0)
sb.Append(string.Format(" 耗时 P50 {0}ms · P95 {1}ms · 最大 {2}ms\n",
st.Duration.P50, st.Duration.P95, st.Duration.Max));
if (st.Ttfb.Count > 0)
sb.Append(string.Format(" 首包 P50 {0}ms · P95 {1}ms\n", st.Ttfb.P50, st.Ttfb.P95));
sb.Append(string.Format(" 工具 {0} 次 · LLM {1} 次\n", st.ToolCalls, st.LlmCalls));
long tokens = st.PromptTokens + st.CompletionTokens;
if (tokens > 0) sb.Append(string.Format(" Token {0} + {1}\n", st.PromptTokens, st.CompletionTokens));
if (st.Cost > 0) sb.Append(" 费用 " + Tools.MyAgent.CostEstimator.Format(st.Cost) + "\n");
if (st.Dropped > 0) sb.Append(" ⚠ 丢弃 " + st.Dropped + " 条(采集跟不上)\n");
if (st.Runs == 0) sb.Append(" (本进程尚未完成任何一轮回答;多日历史见「打开完整看板」)\n");
_lblDashKpi.Text = sb.ToString();
}
catch
{
_lblDashKpi.Text = "(统计暂不可用)";
}
}
/// <summary>角色规则字典 → 编辑文本(排序保证稳定回显)</summary>
private static string[] ToolRoleRulesToLines(Dictionary<string, string> rules)
{
......
using System;
using System.Drawing;
using System.Windows.Forms;
namespace Tools.MyAgent.Forms
{
/// <summary>
/// 看板自绘图表(柱状 / 横向条形)。零第三方依赖:GDI+ 直接画,约 120 行。
/// <para>不开 DoubleBuffered 会闪;不画网格线会看不出量级 —— 两者都保留。</para>
/// </summary>
public sealed class DashChart : Panel
{
/// <summary>柱标签(如小时 "00".."23" 或工具名)。</summary>
public string[] Labels = new string[0];
/// <summary>主序列数值。</summary>
public double[] Values = new double[0];
/// <summary>次序列数值(叠加,如失败数);可为空。</summary>
public double[] Values2 = new double[0];
/// <summary>是否横向条形图(工具排名用)。</summary>
public bool Horizontal { get; set; }
/// <summary>空数据占位文案。</summary>
public string EmptyText { get; set; } = "暂无数据";
/// <summary>图表标题 / 坐标轴说明;非空时在顶部绘制白底说明条(避免与最高柱重叠)。</summary>
public string Caption { get; set; } = "";
public Color BarColor { get; set; } = Color.FromArgb(29, 158, 117);
public Color BarColor2 { get; set; } = Color.FromArgb(226, 75, 74);
public DashChart()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint |
ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true);
BackColor = Color.White;
}
protected override void OnPaint(PaintEventArgs e)
{
try
{
base.OnPaint(e);
var g = e.Graphics;
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
g.Clear(BackColor);
if (Values == null || Values.Length == 0 || Labels == null || Labels.Length == 0)
{
using (var b = new SolidBrush(Color.Gray))
using (var f = new Font(FontFamily.GenericSansSerif, 9f))
g.DrawString(EmptyText, f, b, 8, Height / 2 - 8);
return;
}
int n = Math.Min(Values.Length, Labels.Length);
double max = 0;
for (int i = 0; i < n; i++)
{
if (Values[i] > max) max = Values[i];
if (Values2 != null && i < Values2.Length && Values2[i] > max) max = Values2[i];
}
if (max <= 0) max = 1;
const float baseline = 18f; // 柱底留白
const float axisTopPad = 4f; // 顶部网格留白
int topPad = string.IsNullOrEmpty(Caption) ? 0 : 16; // 说明条占用
float baseY = Height - baseline;
float avail = Height - baseline - axisTopPad - topPad;
if (avail < 10) avail = 10;
using (var gridPen = new Pen(Color.FromArgb(230, 230, 230)))
using (var textBrush = new SolidBrush(Color.FromArgb(90, 90, 90)))
using (var font = new Font(FontFamily.GenericSansSerif, 8f))
using (var capBrush = new SolidBrush(Color.FromArgb(110, 110, 110)))
using (var capFont = new Font(FontFamily.GenericSansSerif, 8.5f))
{
// 网格(4 等分)
for (int k = 1; k <= 4; k++)
{
float y = baseY - avail * k / 4f;
g.DrawLine(gridPen, 4, y, Width - 4, y);
}
// 标题 / 坐标轴说明(白底,避免与最高柱重叠)
if (topPad > 0 && !string.IsNullOrEmpty(Caption))
{
var capSize = g.MeasureString(Caption, capFont);
g.FillRectangle(Brushes.White, 2, 1, capSize.Width + 6, 14);
g.DrawString(Caption, capFont, capBrush, 4, 2);
}
if (Horizontal)
{
float rowH = (Height - 12f) / n;
for (int i = 0; i < n; i++)
{
float y = 6 + i * rowH;
float w = (float)(Values[i] / max * (Width - 130));
using (var br = new SolidBrush(BarColor))
g.FillRectangle(br, 100, y + rowH * 0.2f, Math.Max(5, w), rowH * 0.6f);
g.DrawString(Trim(Labels[i], 14), font, textBrush, 4, y + rowH * 0.25f);
g.DrawString(Fmt(Values[i]), font, textBrush, 100 + w + 4, y + rowH * 0.25f);
}
}
else
{
float slot = (Width - 12f) / n;
float bw = Math.Max(2, slot * 0.62f);
for (int i = 0; i < n; i++)
{
double frac = BarFrac(Values[i], max);
float h1 = (float)(frac * avail);
h1 = Math.Max(5, h1); // 每柱默认高度 5(0 值也可见)
float x = 6 + i * slot + (slot - bw) / 2;
using (var br = new SolidBrush(BarColor))
g.FillRectangle(br, x, baseY - h1, bw, h1);
float h2 = 0;
if (Values2 != null && i < Values2.Length && Values2[i] > 0)
{
double frac2 = BarFrac(Values2[i], max);
h2 = (float)(frac2 * avail);
h2 = Math.Max(1, h2);
using (var br = new SolidBrush(BarColor2))
g.FillRectangle(br, x, baseY - h1 - h2, bw, h2);
}
if (n <= 24)
g.DrawString(Labels[i], font, textBrush, x, baseY + 1);
// 柱顶计数值(仅非零,零柱不标以区分)
if (Values[i] > 0)
{
string tag = Fmt(Values[i]);
var tagSize = g.MeasureString(tag, font);
float ty = baseY - h1 - h2 - 12;
if (ty < topPad + 1) ty = topPad + 1;
g.DrawString(tag, font, textBrush, x + (bw - tagSize.Width) / 2, ty);
}
}
}
}
}
catch { }
}
/// <summary>柱高比例:0 → 0;其余按 log2 倍增刻度(每翻倍增量恒定),避免小计次不可见、大计次一家独大。</summary>
private static double BarFrac(double v, double max)
{
if (v <= 0) return 0;
if (max <= 1) return 1;
return Math.Log(v + 1, 2) / Math.Log(max + 1, 2);
}
private static string Trim(string s, int max)
{
if (string.IsNullOrEmpty(s)) return "";
return s.Length <= max ? s : s.Substring(0, max - 1) + "…";
}
private static string Fmt(double v)
{
if (v >= 1000) return (v / 1000.0).ToString("0.0") + "k";
return ((long)v).ToString();
}
}
}
......@@ -410,6 +410,18 @@ namespace Tools.MyAgent.Kb
/// <summary>搜索范围是否有效(目录存在)</summary>
public bool IsSearchValid => !string.IsNullOrEmpty(SearchRoot) && Directory.Exists(SearchRoot);
/// <summary>
/// 索引是否已就绪(未启动 / 构建中为 false)。供可观测性健康检查读取,不改变任何检索行为。
/// </summary>
public bool IndexReady
{
get
{
try { return _index != null && _index.IsReady; }
catch { return false; }
}
}
private const int AutoIndexFileThreshold = 2000;
/// <summary>
......
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Tools.MyAgent.Agent;
using Tools.MyAgent.Config;
using Tools.MyAgent.Diagnostics;
using Tools.MyAgent.Kb;
namespace Tools.MyAgent
{
/// <summary>健康检查结果项。</summary>
public sealed class HealthItem
{
/// <summary>检查项标识(稳定,便于聚合与对比)。</summary>
public string Id = "";
/// <summary>展示名。</summary>
public string Name = "";
/// <summary>ok / warn / fail。</summary>
public string Status = "ok";
/// <summary>判定依据或处置建议。</summary>
public string Detail = "";
public override string ToString() => $"[{Status}] {Name}:{Detail}";
}
/// <summary>
/// 依赖健康检查(9 项)。按需执行,不定时轮询;单项异常只影响该项。
/// <para>输出:<c>Logs/health.json</c> + 聊天窗 <c>/diag</c> 的表格 + Admin 看板 ⑤Tab。</para>
/// </summary>
public static class AgentHealth
{
/// <summary>日志目录超该大小时告警(防止观测自身撑爆磁盘)。</summary>
private const long LogsWarnBytes = 256L * 1024 * 1024;
/// <summary>
/// 执行检查(不含模型服务连通性——它是异步的,由调用方 <see cref="ModelService"/> 单独追加)。
/// </summary>
public static HealthItem[] Check(AgentConfig cfg, KbService kb, IReadOnlyList<DiagnosticRoot> roots)
{
var list = new List<HealthItem>();
try
{
list.Add(CheckConfig(cfg));
list.Add(CheckPrice(cfg));
list.Add(CheckKbRoot(kb));
list.Add(CheckKbScope(kb));
list.Add(CheckIndex(kb));
list.Add(CheckSemantic(cfg));
list.Add(CheckDiagnosticRoots(roots));
list.Add(CheckTelemetrySelf());
}
catch { }
return list.ToArray();
}
/// <summary>模型服务连通性项(由调用方用 <c>TestConnectionAsync</c> 结果构造)。</summary>
public static HealthItem ModelService(bool ok, string message)
{
return new HealthItem
{
Id = "model_service",
Name = "模型服务",
Status = ok ? "ok" : "fail",
Detail = string.IsNullOrEmpty(message) ? (ok ? "连通" : "未知") : message,
};
}
private static HealthItem CheckConfig(AgentConfig cfg)
{
try
{
if (cfg == null) return Item("config", "配置完整性", "fail", "配置对象为空");
var miss = new List<string>();
if (string.IsNullOrWhiteSpace(cfg.ApiKey)) miss.Add("API Key");
if (string.IsNullOrWhiteSpace(cfg.Model)) miss.Add("模型名");
if (string.IsNullOrWhiteSpace(cfg.BaseUrl)) miss.Add("API 基地址");
else if (!(cfg.BaseUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|| cfg.BaseUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)))
miss.Add("基地址格式");
return miss.Count == 0
? Item("config", "配置完整性", "ok", cfg.Model + " @ " + cfg.BaseUrl)
: Item("config", "配置完整性", "fail", "缺少:" + string.Join("、", miss));
}
catch (Exception ex) { return Item("config", "配置完整性", "warn", ex.Message); }
}
private static HealthItem CheckPrice(AgentConfig cfg)
{
try
{
if (cfg == null || string.IsNullOrEmpty(cfg.Model)) return Item("price", "费用可见性", "warn", "未配置模型");
var p = CostEstimator.FindPrice(cfg.Model);
return p == null
? Item("price", "费用可见性", "warn", "模型未收录价格表,费用显示为「—」(可用 CustomPrices 配置)")
: Item("price", "费用可见性", "ok", $"输入 ¥{p.InputPer1M}/M · 输出 ¥{p.OutputPer1M}/M");
}
catch (Exception ex) { return Item("price", "费用可见性", "warn", ex.Message); }
}
private static HealthItem CheckKbRoot(KbService kb)
{
try
{
if (kb == null) return Item("kb_root", "知识库可访问", "fail", "知识库服务未初始化");
if (kb.IsValid) return Item("kb_root", "知识库可访问", "ok", kb.Root);
if (string.IsNullOrEmpty(kb.Root)) return Item("kb_root", "知识库可访问", "warn", "未选择知识库");
return Item("kb_root", "知识库可访问", "fail",
kb.Root.StartsWith(@"\\", StringComparison.Ordinal)
? "共享路径不可访问(检查服务器/共享名/账号密码):" + kb.Root
: "本地路径不存在:" + kb.Root);
}
catch (Exception ex) { return Item("kb_root", "知识库可访问", "fail", ex.Message); }
}
private static HealthItem CheckKbScope(KbService kb)
{
try
{
if (kb == null) return Item("kb_scope", "搜索范围", "warn", "知识库服务未初始化");
if (kb.IsSearchValid) return Item("kb_scope", "搜索范围", "ok", string.IsNullOrEmpty(kb.Scope) ? "全部(未限定)" : kb.Scope);
return Item("kb_scope", "搜索范围", "warn", "限定目录不可访问:" + kb.Scope);
}
catch (Exception ex) { return Item("kb_scope", "搜索范围", "warn", ex.Message); }
}
private static HealthItem CheckIndex(KbService kb)
{
try
{
if (kb == null) return Item("kb_index", "索引状态", "warn", "知识库服务未初始化");
return kb.IndexReady
? Item("kb_index", "索引状态", "ok", "已就绪")
: Item("kb_index", "索引状态", "warn", "未就绪(首次检索会惰性构建,或构建进行中)");
}
catch (Exception ex) { return Item("kb_index", "索引状态", "warn", ex.Message); }
}
private static HealthItem CheckSemantic(AgentConfig cfg)
{
try
{
if (cfg == null || !cfg.EnableSemanticSearch) return Item("semantic", "语义检索", "ok", "已关闭");
if (string.IsNullOrWhiteSpace(cfg.EmbeddingBaseUrl)) return Item("semantic", "语义检索", "warn", "已启用但未配置 Embedding 地址");
return Item("semantic", "语义检索", "ok", cfg.EmbeddingModel + " @ " + cfg.EmbeddingBaseUrl);
}
catch (Exception ex) { return Item("semantic", "语义检索", "warn", ex.Message); }
}
private static HealthItem CheckDiagnosticRoots(IReadOnlyList<DiagnosticRoot> roots)
{
try
{
if (roots == null || roots.Count == 0) return Item("diag_roots", "诊断根目录", "ok", "未注入(按需能力)");
var bad = new List<string>();
foreach (var r in roots)
{
if (r == null || string.IsNullOrEmpty(r.Path)) continue;
if (!Directory.Exists(r.Path)) bad.Add(r.Id);
}
return bad.Count == 0
? Item("diag_roots", "诊断根目录", "ok", roots.Count + " 个均已就绪")
: Item("diag_roots", "诊断根目录", "warn", "不可访问:" + string.Join("、", bad));
}
catch (Exception ex) { return Item("diag_roots", "诊断根目录", "warn", ex.Message); }
}
/// <summary>观测自身:日志目录是否膨胀(自查项,防止观测把磁盘撑爆)。</summary>
private static HealthItem CheckTelemetrySelf()
{
try
{
string dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
if (!Directory.Exists(dir)) return Item("telemetry_self", "观测自身", "ok", "尚无日志");
long size = 0;
int count = 0;
foreach (var f in Directory.GetFiles(dir, "*.*", SearchOption.AllDirectories))
{
try { size += new FileInfo(f).Length; count++; } catch { }
}
string text = $"{count} 个文件 / {size / 1024 / 1024} MB";
return size > LogsWarnBytes
? Item("telemetry_self", "观测自身", "warn", text + "(超过告警阈值,检查保留期配置)")
: Item("telemetry_self", "观测自身", "ok", text);
}
catch (Exception ex) { return Item("telemetry_self", "观测自身", "warn", ex.Message); }
}
private static HealthItem Item(string id, string name, string status, string detail)
{
return new HealthItem { Id = id, Name = name, Status = status, Detail = detail };
}
/// <summary>序列化为 JSON(<c>Logs/health.json</c>)。</summary>
public static string ToJson(HealthItem[] items)
{
try
{
var doc = new Dictionary<string, object>
{
["ts"] = DateTime.UtcNow.ToString("o"),
["items"] = items ?? new HealthItem[0],
};
return JsonConvert.SerializeObject(doc, Formatting.Indented);
}
catch { return "{}"; }
}
/// <summary>落盘到 <c>Logs/health.json</c>(覆盖写);失败不影响调用方。</summary>
public static string Save(HealthItem[] items)
{
try
{
string dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
Directory.CreateDirectory(dir);
string path = Path.Combine(dir, "health.json");
File.WriteAllText(path, ToJson(items), new UTF8Encoding(false));
return path;
}
catch { return ""; }
}
/// <summary>渲染为聊天窗 Markdown(分组表格;<c>/diag</c> 用)。</summary>
public static string RenderMarkdown(HealthItem[] items)
{
var sb = new StringBuilder();
try
{
if (items == null || items.Length == 0) return "(健康检查无结果)";
int fail = 0, warn = 0;
foreach (var i in items)
{
if (i.Status == "fail") fail++;
else if (i.Status == "warn") warn++;
}
sb.Append("**依赖健康检查**(").Append(items.Length).Append(" 项:")
.Append(fail).Append(" 失败 / ").Append(warn).Append(" 告警)\n\n");
sb.Append("| 状态 | 检查项 | 说明 |\n|---|---|---|\n");
foreach (var i in items)
sb.Append("| ").Append(Mark(i.Status)).Append(" | ").Append(i.Name).Append(" | ")
.Append((i.Detail ?? "").Replace("|", "/").Replace("\n", " ")).Append(" |\n");
}
catch { }
return sb.ToString();
}
private static string Mark(string status)
{
if (status == "fail") return "❌";
if (status == "warn") return "⚠️";
return "✅";
}
}
}
using Newtonsoft.Json;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading;
namespace Tools.MyAgent
{
/// <summary>
/// 智能体可观测性出口(Trace / Metrics)。
/// <para>红线:本类所有 public 方法体在最外层 try/catch —— **不得有向上传播的 throw**,
/// 观测任何异常都不得影响正常回答。</para>
/// <para>与 <see cref="AgentLogger"/> 的分工:Logger 写「给人看的文本日志」;
/// 本类写「可聚合的结构化数据」(一轮一条 RunScope + 计数器/直方图)。</para>
/// </summary>
public static class AgentTelemetry
{
// ══════════════ 契约类型(内嵌,避免新增文件) ══════════════
/// <summary>一轮提问的运行时容器(对应一次 <c>AgentEngine.RunAsync</c>)。</summary>
public sealed class RunScope
{
/// <summary>运行唯一标识(复用引擎的 <c>_diagnosticSessionId</c>)。</summary>
public string RunId = "";
/// <summary>起始时间(ISO8601 UTC)。</summary>
public string StartedAt = "";
/// <summary>提问字符数(不存原文)。</summary>
public int QuestionChars;
/// <summary>提问哈希(SHA256 前 12 位;一轮算一次,非 hot path)。</summary>
public string QuestionHash = "";
/// <summary>配置模型。</summary>
public string Model = "";
/// <summary>实际服务模型(故障转移时为备用模型)。</summary>
public string ServedModel = "";
/// <summary>回答通道:kb / mixed / model / tool-loop。</summary>
public string Mode = "";
/// <summary>回答类型(<c>AnswerSpec.Classify</c> 结果)。</summary>
public string AnswerKind = "";
public int ToolRounds;
public int ToolCalls;
public int LlmCalls;
public int PromptTokens;
public int CompletionTokens;
public double Cost;
public long TotalMs;
public long ToolMs;
public long LlmMs;
/// <summary>首包延迟(首次 LLM 调用的首个有效 delta)。</summary>
public long TtfbMs;
public int FailoverCount;
public int ContinuationCount;
public int ContextTrimCount;
/// <summary>success / error / cancel。</summary>
public string Outcome = "";
/// <summary>auth|quota|ratelimit|network|context_length|timeout|unknown。</summary>
public string ErrorType = "";
/// <summary>是否走到自然收尾(未被取消/异常中断)。</summary>
public bool Completed;
/// <summary>span 达到上限被截断采集(ToolSpan ≤ 60 / LlmSpan ≤ 20)。</summary>
public bool SpansTruncated;
[JsonIgnore] public Stopwatch Sw;
[JsonIgnore] public readonly List<ToolSpan> Tools = new List<ToolSpan>();
[JsonIgnore] public readonly List<LlmSpan> Llms = new List<LlmSpan>();
public override string ToString()
{
return $"{Outcome}/{Mode} {TotalMs}ms tools={ToolCalls} llm={LlmCalls}";
}
}
/// <summary>一次工具调用(对 <c>AgentEngine.ToolTraceEntry</c> 的超集,不替换它)。</summary>
public sealed class ToolSpan
{
public string RunId = "";
public int Seq;
public int Round;
public string Name = "";
/// <summary>参数摘要(经 <see cref="AgentLogger.Mask"/> 后截断 80 字)。</summary>
public string ArgsSummary = "";
/// <summary>相对本轮起点的偏移(ms)。</summary>
public long StartOffsetMs;
public long DurationMs;
/// <summary>ok / empty / error / timeout / cancel / denied。</summary>
public string Outcome = "";
public int ResultChars;
/// <summary>结果被 <c>CompressToolResult</c> 截断。</summary>
public bool Truncated;
public string ErrorType = "";
public override string ToString() => $"{Name}({ArgsSummary}) {DurationMs}ms {Outcome}";
}
/// <summary>一次 LLM 请求(含工具轮 / 收尾 / 续写 / 直答 / 标题 / 摘要)。</summary>
public sealed class LlmSpan
{
public string RunId = "";
public int Seq;
/// <summary>tool-round / finish / continuation / direct / title / summarize。</summary>
public string Kind = "";
public string Model = "";
public long TtfbMs;
public long DurationMs;
public int PromptTokens;
public int CompletionTokens;
public string FinishReason = "";
public int RetryCount;
public string Outcome = "";
public string ErrorType = "";
public override string ToString() => $"{Kind} {Model} {DurationMs}ms {Outcome}";
}
/// <summary>直方图读数(固定桶插值,±半桶误差)。</summary>
public sealed class HistogramView
{
public long Count;
public long Sum;
public long Min;
public long Max;
public long P50;
public long P95;
public override string ToString() => $"n={Count} p50={P50} p95={P95} max={Max}";
}
/// <summary>内存快照(<c>/stats</c> 与看板读这个)。</summary>
public sealed class RunStats
{
public long Runs;
public long Success;
public long Errors;
public long Cancels;
public long ToolCalls;
public long LlmCalls;
public long PromptTokens;
public long CompletionTokens;
public double Cost;
public long Dropped;
public HistogramView Duration = new HistogramView();
public HistogramView Ttfb = new HistogramView();
/// <summary>计数器全量副本(键形如 <c>agent_runs_total|outcome=success</c>)。</summary>
public Dictionary<string, long> Counters = new Dictionary<string, long>();
}
// ══════════════ 状态 ══════════════
private static readonly object Lock = new object();
private static readonly ConcurrentDictionary<string, long> Counters = new ConcurrentDictionary<string, long>(StringComparer.OrdinalIgnoreCase);
/// <summary>直方图桶上界(ms):50/100/200/400/800/1600/3200/6400/12800/25600/51200/+∞</summary>
private static readonly long[] Buckets = { 50, 100, 200, 400, 800, 1600, 3200, 6400, 12800, 25600, 51200, long.MaxValue };
private sealed class Hist
{
public readonly long[] Counts = new long[12];
public long Sum;
public long Min = long.MaxValue;
public long Max;
public long Total;
}
private static readonly ConcurrentDictionary<string, Hist> Histograms = new ConcurrentDictionary<string, Hist>(StringComparer.OrdinalIgnoreCase);
private const int MaxToolSpans = 60;
private const int MaxLlmSpans = 20;
private static bool _enabled = true;
private static string _level = "basic"; // off|basic|detailed
public static bool Enabled => _enabled;
/// <summary>隐私级别:off / basic / detailed。</summary>
public static string Level => _level;
/// <summary>是否写入提问原文类数据(仅 detailed)。</summary>
public static bool IsDetailed => _enabled && string.Equals(_level, "detailed", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// 应用配置。任何非法值一律回退默认,不抛异常。
/// </summary>
public static void Configure(bool enabled, string level, string logsRoot = null)
{
try
{
lock (Lock)
{
_enabled = enabled;
_level = string.Equals(level, "detailed", StringComparison.OrdinalIgnoreCase) ? "detailed"
: string.Equals(level, "off", StringComparison.OrdinalIgnoreCase) ? "off"
: "basic";
if (!string.IsNullOrEmpty(logsRoot)) _logsRoot = logsRoot;
}
if (_enabled && !string.Equals(_level, "off", StringComparison.OrdinalIgnoreCase)) EnsureWriter();
}
catch { }
}
// ══════════════ Run 生命周期 ══════════════
/// <summary>开启一轮运行;关闭观测时返回 null(调用方须判空,用 <c>??</c> 安全调用)。</summary>
public static RunScope BeginRun(int questionChars, string questionHash, string model)
{
try
{
if (!_enabled) return null;
var s = new RunScope
{
RunId = Guid.NewGuid().ToString("N"), // 引擎侧会用现有 _diagnosticSessionId 覆写
StartedAt = DateTime.UtcNow.ToString("o"),
QuestionChars = questionChars,
QuestionHash = questionHash ?? "",
Model = model ?? "",
ServedModel = model ?? "",
Sw = Stopwatch.StartNew(),
};
return s;
}
catch { return null; }
}
/// <summary>结束一轮运行:结算耗时、累计指标、触发 <see cref="OnRunCompleted"/>。</summary>
public static void EndRun(RunScope s, string outcome, string errorType, Exception ex)
{
try
{
if (s == null || !_enabled) return;
if (s.Sw != null)
{
s.Sw.Stop();
s.TotalMs = s.Sw.ElapsedMilliseconds;
}
s.Outcome = string.IsNullOrEmpty(outcome) ? "unknown" : outcome;
s.ErrorType = errorType ?? "";
s.Completed = string.Equals(s.Outcome, "success", StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrEmpty(s.Mode)) s.Mode = "tool-loop";
Count("agent_runs_total");
Count("agent_runs_total|outcome=" + s.Outcome);
Count("agent_runs_by_mode|mode=" + (string.IsNullOrEmpty(s.Mode) ? "unknown" : s.Mode));
if (!string.IsNullOrEmpty(s.AnswerKind)) Count("agent_runs_by_kind|kind=" + s.AnswerKind);
if (!string.IsNullOrEmpty(s.ErrorType)) Count("agent_runs_by_error|errorType=" + s.ErrorType);
// ⚠ 不能写成 > 0:亚毫秒级的一轮会被静默丢弃,直方图样本数偏少
if (s.TotalMs >= 0) Observe("agent_run_duration_ms", s.TotalMs);
// 用量与费用(费用按「毫分」整数累计,避免计数器用浮点)
if (s.PromptTokens > 0) Count("agent_tokens_total|dir=prompt", s.PromptTokens);
if (s.CompletionTokens > 0) Count("agent_tokens_total|dir=completion", s.CompletionTokens);
if (s.Cost > 0) Count("agent_cost_total|model=" + (string.IsNullOrEmpty(s.ServedModel) ? "unknown" : s.ServedModel), (long)(s.Cost * 10000));
if (s.TtfbMs > 0) Observe("agent_ttfb_ms", s.TtfbMs);
EnqueueRun(s);
try { OnRunCompleted?.Invoke(s); } catch { }
}
catch { }
}
/// <summary>记录一次工具调用(<c>ExecuteTool</c> / <c>ExecuteToolAsync</c> 调用)。</summary>
public static void RecordTool(RunScope s, ToolSpan span)
{
try
{
if (span == null) return;
Count("agent_tool_calls_total|name=" + (span.Name ?? "unknown"));
Count("agent_tool_calls_total|name=" + (span.Name ?? "unknown") + "|outcome=" + (span.Outcome ?? "unknown"));
if (span.Truncated) Count("agent_tool_truncated_total|name=" + (span.Name ?? "unknown"));
if (span.DurationMs > 0) Observe("agent_tool_duration_ms|name=" + (span.Name ?? "unknown"), span.DurationMs);
if (s == null || !_enabled) return;
s.ToolCalls++;
s.ToolMs += span.DurationMs;
span.Seq = s.Tools.Count + 1;
if (s.Tools.Count < MaxToolSpans) s.Tools.Add(span);
else s.SpansTruncated = true;
try { OnToolSpan?.Invoke(span); } catch { }
}
catch { }
}
/// <summary>记录一次 LLM 请求(<c>SendAndLogAsync</c> 调用)。</summary>
public static void RecordLlm(RunScope s, LlmSpan span)
{
try
{
if (span == null) return;
Count("agent_llm_calls_total|kind=" + (span.Kind ?? "unknown"));
if (!string.IsNullOrEmpty(span.Outcome))
Count("agent_llm_calls_total|kind=" + (span.Kind ?? "unknown") + "|outcome=" + span.Outcome);
if (span.DurationMs > 0) Observe("agent_llm_duration_ms|kind=" + (span.Kind ?? "unknown"), span.DurationMs);
if (s == null || !_enabled) return;
s.LlmCalls++;
s.LlmMs += span.DurationMs;
s.PromptTokens += Math.Max(0, span.PromptTokens);
s.CompletionTokens += Math.Max(0, span.CompletionTokens);
if (s.TtfbMs <= 0 && span.TtfbMs > 0) s.TtfbMs = span.TtfbMs;
span.Seq = s.Llms.Count + 1;
if (s.Llms.Count < MaxLlmSpans) s.Llms.Add(span);
else s.SpansTruncated = true;
}
catch { }
}
// ══════════════ 计数器 / 直方图 ══════════════
/// <summary>
/// 计数器累加。<paramref name="name"/> 必须由埋点**硬编码**传入,
/// 禁止外部字符串拼接(防止把路径/提问塞进维度导致维度爆炸)。
/// </summary>
public static void Count(string name, long delta = 1)
{
try
{
if (string.IsNullOrEmpty(name) || !_enabled) return;
Counters.AddOrUpdate(name, delta, (k, v) => v + delta);
}
catch { }
}
/// <summary>观测一次耗时(ms)到固定桶直方图。</summary>
public static void Observe(string key, long ms)
{
try
{
if (string.IsNullOrEmpty(key) || !_enabled || ms < 0) return;
var h = Histograms.GetOrAdd(key, _ => new Hist());
lock (h)
{
h.Total++;
h.Sum += ms;
if (ms < h.Min) h.Min = ms;
if (ms > h.Max) h.Max = ms;
for (int i = 0; i < Buckets.Length; i++)
{
if (ms <= Buckets[i]) { h.Counts[i]++; break; }
}
}
}
catch { }
}
/// <summary>直方图读数:P50/P95 由固定桶线性插值得到(±半桶误差)。</summary>
public static HistogramView Histogram(string key)
{
try
{
if (string.IsNullOrEmpty(key) || !Histograms.TryGetValue(key, out var h)) return new HistogramView();
lock (h)
{
var v = new HistogramView { Count = h.Total, Sum = h.Sum, Min = h.Min == long.MaxValue ? 0 : h.Min, Max = h.Max };
if (h.Total == 0) return v;
v.P50 = Quantile(h, 0.50);
v.P95 = Quantile(h, 0.95);
return v;
}
}
catch { return new HistogramView(); }
}
private static long Quantile(Hist h, double q)
{
long target = (long)Math.Ceiling(h.Total * q);
long cum = 0;
long lo = 0;
for (int i = 0; i < Buckets.Length; i++)
{
if (h.Counts[i] == 0) continue;
if (cum + h.Counts[i] >= target)
{
// 桶内线性插值:落在本桶内的第 n 个 → lo + (hi-lo) * n/count
long hi = Buckets[i] == long.MaxValue ? h.Max : Buckets[i];
long n = target - cum;
return lo + (long)((hi - lo) * (double)n / h.Counts[i]);
}
cum += h.Counts[i];
lo = Buckets[i];
}
return h.Max;
}
/// <summary>内存快照(/stats 与看板读这个)。</summary>
public static RunStats Snapshot()
{
try
{
var st = new RunStats();
st.Duration = Histogram("agent_run_duration_ms");
st.Ttfb = Histogram("agent_ttfb_ms");
st.Runs = Get("agent_runs_total");
st.Success = Get("agent_runs_total|outcome=success");
st.Errors = Get("agent_runs_total|outcome=error");
st.Cancels = Get("agent_runs_total|outcome=cancel");
st.Dropped = Get("agent_telemetry_dropped");
st.ToolCalls = SumPrefix("agent_tool_calls_total|name=");
st.LlmCalls = SumPrefix("agent_llm_calls_total|kind=");
st.PromptTokens = Get("agent_tokens_total|dir=prompt");
st.CompletionTokens = Get("agent_tokens_total|dir=completion");
double costMilli = 0;
foreach (var kv in Counters)
{
st.Counters[kv.Key] = kv.Value;
if (kv.Key.StartsWith("agent_cost_total", StringComparison.OrdinalIgnoreCase)) costMilli += kv.Value;
}
st.Cost = costMilli / 10000.0; // 计数器以「毫分」存费用
return st;
}
catch { return new RunStats(); }
}
/// <summary>取计数器值(不存在返回 0)。</summary>
public static long Get(string name)
{
try { return Counters.TryGetValue(name, out var v) ? v : 0; }
catch { return 0; }
}
/// <summary>按前缀汇总(用于无维度的总量)。</summary>
public static long SumPrefix(string prefix)
{
try
{
long sum = 0;
foreach (var kv in Counters)
{
if (!kv.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue;
// 排除带二级维度的键(如 name=x|outcome=y),避免重复计数
string tail = kv.Key.Substring(prefix.Length);
if (tail.IndexOf('|') >= 0) continue;
sum += kv.Value;
}
return sum;
}
catch { return 0; }
}
/// <summary>提问哈希(SHA256 前 12 位 hex);一轮算一次,失败返回空串。</summary>
public static string HashQuestion(string question)
{
try
{
if (string.IsNullOrEmpty(question)) return "";
using (var sha = System.Security.Cryptography.SHA256.Create())
{
byte[] bytes = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(question));
var sb = new System.Text.StringBuilder(12);
for (int i = 0; i < 6 && i < bytes.Length; i++) sb.Append(bytes[i].ToString("x2"));
return sb.ToString();
}
}
catch { return ""; }
}
/// <summary>参数摘要:脱敏后截断(默认 80 字)。</summary>
public static string Summarize(string text, int max = 80)
{
try
{
if (string.IsNullOrEmpty(text)) return "";
string s = AgentLogger.Mask(text).Replace("\r", " ").Replace("\n", " ");
s = s.Trim();
return s.Length <= max ? s : s.Substring(0, max) + "…";
}
catch { return ""; }
}
// ══════════════════ 落盘(M2:有界队列 + 单后台写线程)══════════════════
/// <summary>队列容量;满即丢弃并计数,绝不阻塞回答。</summary>
private const int QueueCapacity = 1000;
/// <summary>单个 jsonl 的轮转阈值(8MB)。</summary>
private const long MaxFileBytes = 8L * 1024 * 1024;
private static readonly ConcurrentQueue<string> RunQueue = new ConcurrentQueue<string>();
private static readonly object _writerLock = new object();
private static Thread _writerThread;
private static StreamWriter _runWriter;
private static StreamWriter _metricWriter;
private static string _logsRoot = "";
private static string _runPath = "";
private static string _metricPath = "";
private static volatile bool _writerStop;
private static DateTime _lastMetricFlush = DateTime.UtcNow;
private static int _runsSinceFlush;
/// <summary>观测数据目录(默认 程序基目录/Logs)。</summary>
public static string LogsRoot
{
get
{
try
{
if (string.IsNullOrEmpty(_logsRoot))
_logsRoot = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
return _logsRoot;
}
catch { return "Logs"; }
}
}
private static void EnsureWriter()
{
try
{
if (_writerThread != null) return;
lock (_writerLock)
{
if (_writerThread != null) return;
Directory.CreateDirectory(LogsRoot);
_writerThread = new Thread(WriterLoop);
_writerThread.IsBackground = true;
_writerThread.Name = "agent-telemetry-writer";
_writerThread.Start();
AppDomain.CurrentDomain.ProcessExit += (s, e) => Flush();
}
}
catch { }
}
private static void WriterLoop()
{
try
{
while (!_writerStop)
{
string line;
if (RunQueue.TryDequeue(out line))
{
try
{
var w = GetRunWriter();
if (w != null) { w.Write(line); w.Write('\n'); }
}
catch { }
}
else Thread.Sleep(200);
}
Flush();
}
catch { }
}
/// <summary>追加写(复用常驻 StreamWriter,避免每次 AppendAllText 的开合开销)。</summary>
private static StreamWriter GetRunWriter()
{
try
{
Directory.CreateDirectory(LogsRoot);
if (_runWriter != null && File.Exists(_runPath) && new FileInfo(_runPath).Length < MaxFileBytes)
return _runWriter;
CloseRunWriter();
_runPath = NextAvailable("runs_" + DateTime.Now.ToString("yyyyMMdd") + ".jsonl");
var fs = new FileStream(_runPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
_runWriter = new StreamWriter(fs, new UTF8Encoding(false)) { AutoFlush = true };
return _runWriter;
}
catch { return null; }
}
private static StreamWriter GetMetricWriter()
{
try
{
Directory.CreateDirectory(LogsRoot);
if (_metricWriter != null && File.Exists(_metricPath) && new FileInfo(_metricPath).Length < MaxFileBytes)
return _metricWriter;
if (_metricWriter != null) { try { _metricWriter.Flush(); _metricWriter.Dispose(); } catch { } _metricWriter = null; }
_metricPath = NextAvailable("metrics_" + DateTime.Now.ToString("yyyyMMdd") + ".jsonl");
var fs = new FileStream(_metricPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
_metricWriter = new StreamWriter(fs, new UTF8Encoding(false)) { AutoFlush = true };
return _metricWriter;
}
catch { return null; }
}
private static void CloseRunWriter()
{
if (_runWriter == null) return;
try { _runWriter.Flush(); _runWriter.Dispose(); } catch { }
_runWriter = null;
}
/// <summary>超过阈值时加 _N 后缀轮转。</summary>
private static string NextAvailable(string fileName)
{
string p = Path.Combine(LogsRoot, fileName);
if (!File.Exists(p) || new FileInfo(p).Length < MaxFileBytes) return p;
string stem = Path.GetFileNameWithoutExtension(fileName);
string ext = Path.GetExtension(fileName);
for (int i = 1; i < 100; i++)
{
string c = Path.Combine(LogsRoot, stem + "_" + i + ext);
if (!File.Exists(c) || new FileInfo(c).Length < MaxFileBytes) return c;
}
return p;
}
/// <summary>一轮运行入队(满则丢弃并计数)。</summary>
private static void EnqueueRun(RunScope s)
{
try
{
if (s == null || !_enabled) return;
EnsureWriter(); // 兜底:即使 Configure 未被宿主调用/调用晚,首次产生数据时也启动写线程,避免队列只进不出
string line = JsonConvert.SerializeObject(s);
if (RunQueue.Count >= QueueCapacity)
{
Count("agent_telemetry_dropped");
return;
}
RunQueue.Enqueue(line);
_runsSinceFlush++;
if (_runsSinceFlush >= 20 || (DateTime.UtcNow - _lastMetricFlush).TotalMinutes >= 5)
FlushMetrics();
}
catch { }
}
/// <summary>落一条指标快照(计数器 + 直方图读数)。</summary>
public static void FlushMetrics()
{
try
{
_lastMetricFlush = DateTime.UtcNow;
_runsSinceFlush = 0;
var snap = new Dictionary<string, object>
{
["ts"] = DateTime.UtcNow.ToString("o"),
["counters"] = new Dictionary<string, long>(Counters),
["run_ms"] = Histogram("agent_run_duration_ms"),
["ttfb_ms"] = Histogram("agent_ttfb_ms"),
};
string line = JsonConvert.SerializeObject(snap);
var w = GetMetricWriter();
if (w != null) { w.Write(line); w.Write('\n'); w.Flush(); }
}
catch { }
}
/// <summary>把队列与缓冲落盘(进程退出 / 手动调用)。</summary>
public static void Flush()
{
try
{
string line;
while (RunQueue.TryDequeue(out line))
{
var w = GetRunWriter();
if (w != null) { w.Write(line); w.Write('\n'); }
}
if (_runWriter != null) _runWriter.Flush();
if (_metricWriter != null) _metricWriter.Flush();
}
catch { }
}
/// <summary>停止写线程并落盘(宿主释放 / 关闭观测时调用;进程退出另有兜底)。</summary>
public static void Shutdown()
{
try
{
_writerStop = true;
Flush();
CloseRunWriter();
if (_metricWriter != null) { try { _metricWriter.Flush(); _metricWriter.Dispose(); } catch { } _metricWriter = null; }
}
catch { }
}
/// <summary>一轮运行完成(实时事件)。</summary>
public static event Action<RunScope> OnRunCompleted;
/// <summary>一次工具调用完成(实时事件,替代 <c>OnToolStatus</c> 的字符串)。</summary>
public static event Action<ToolSpan> OnToolSpan;
}
}
......@@ -14,6 +14,9 @@ namespace Tools.MyAgent
/// <summary>日志类文件保留天数(agent 日志 / search_misses / db 审计 / tool_results)</summary>
public const int LogKeepDays = 30;
/// <summary>可观测性 runs_*.jsonl 保留天数(比纯日志短:jsonl 更占空间)</summary>
public const int RunsKeepDays = 14;
/// <summary>中间产物保留天数(kb_view_image 扫描件图片,可再生)</summary>
public const int ImageKeepDays = 7;
......@@ -28,7 +31,8 @@ namespace Tools.MyAgent
/// </summary>
/// <param name="agentRoot">默认 Agent 目录(output 位于其下);日志目录按程序基目录 Logs 计算</param>
/// <param name="logsRoot">日志根目录(默认 程序基目录/Logs;测试可注入临时目录)</param>
public static void Run(string agentRoot, string logsRoot = null)
/// <param name="runsKeepDays">可观测性 runs_*.jsonl 的保留天数(默认 <see cref="RunsKeepDays"/>)</param>
public static void Run(string agentRoot, string logsRoot = null, int runsKeepDays = RunsKeepDays)
{
if (logsRoot == null)
logsRoot = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
......@@ -43,6 +47,8 @@ namespace Tools.MyAgent
{
removed += CleanDir(logsRoot, "agent_*.log", LogKeepDays, now);
removed += CleanDir(logsRoot, "search_misses.csv", LogKeepDays, now);
removed += CleanDir(logsRoot, "runs_*.jsonl", runsKeepDays, now);
removed += CleanDir(logsRoot, "metrics_*.jsonl", LogKeepDays, now);
removed += CleanDir(logsRoot, "db_query_audit_*.csv", LogKeepDays, now);
// 2. 工具结果落盘(超长工具结果溯源文件)
string toolResults = Path.Combine(logsRoot, "tool_results");
......
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
namespace Tools.MyAgent
{
/// <summary>
/// 可观测性只读聚合层(Admin 看板数据源)。与写侧 <see cref="AgentTelemetry"/> 分离:
/// 写侧不因看板需求膨胀,看板想怎么查是看板的事。
/// <para>护栏:只读最近 N 天 / 单文件最多 N 行 / 坏行跳过并计数 / 全程不允许抛异常。</para>
/// </summary>
public sealed class TelemetryStore
{
/// <summary>一行运行记录(看板表格的行模型)。</summary>
public sealed class RunRow
{
public string RunId = "";
public DateTime StartedAt;
public string Mode = "";
public string Outcome = "";
public string ErrorType = "";
public long TotalMs;
public int ToolCalls;
public int LlmCalls;
public int PromptTokens;
public int CompletionTokens;
public double Cost;
public string Model = "";
/// <summary>工具 span 明细(下钻用;受 <see cref="TelemetryStore.AllowDetailed"/> 约束)。</summary>
public List<ToolSpanView> Tools = new List<ToolSpanView>();
}
/// <summary>一条工具 span(下钻展示)。</summary>
public sealed class ToolSpanView
{
public string Name = "";
public string ArgsSummary = "";
public long DurationMs;
public string Outcome = "";
public bool Truncated;
}
/// <summary>按工具名聚合。</summary>
public sealed class ToolAgg
{
public string Name = "";
public int Calls;
public int Errors;
public int Timeouts;
public int Truncated;
public long P50;
public long P95;
public long Max;
public double FailRate => Calls == 0 ? 0 : (Errors + Timeouts) * 100.0 / Calls;
}
/// <summary>按天聚合。</summary>
public sealed class DailyAgg
{
public string Date = "";
public int Runs;
public int Success;
public int Errors;
public long Tokens;
public double Cost;
public long P95;
}
/// <summary>召回通道分布(直答率来源)。</summary>
public sealed class ModeAgg
{
public int Kb, Mixed, Model, ToolLoop;
public int Total => Kb + Mixed + Model + ToolLoop;
public double DirectRate => Total == 0 ? 0 : (Kb + Mixed) * 100.0 / Total;
}
private readonly string _logsRoot;
private readonly int _days;
private readonly int _maxRuns;
/// <summary>是否允许展示详细字段(参数摘要等)。由宿主按角色判定后注入。</summary>
public bool AllowDetailed { get; set; }
/// <summary>坏行计数(>5% 时看板顶部提示日志疑似损坏)。</summary>
public int BadLines { get; private set; }
/// <summary>已读取行数。</summary>
public int ReadLines { get; private set; }
public TelemetryStore(string logsRoot = null, int days = 7, int maxRuns = 5000)
{
_days = days > 0 && days <= 30 ? days : 7;
_maxRuns = maxRuns > 0 && maxRuns <= 50000 ? maxRuns : 5000;
try
{
_logsRoot = string.IsNullOrEmpty(logsRoot)
? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs")
: logsRoot;
}
catch { _logsRoot = "Logs"; }
}
public string LogsRoot => _logsRoot;
/// <summary>读取最近 N 天的运行记录(倒序:最新在前)。</summary>
public RunRow[] LoadRuns()
{
var rows = new List<RunRow>();
BadLines = 0;
ReadLines = 0;
try
{
foreach (var file in EnumerateRunFiles())
{
foreach (var line in ReadTailLines(file, _maxRuns))
{
ReadLines++;
var row = Parse(line);
if (row != null) rows.Add(row);
else BadLines++;
}
}
rows.Sort((a, b) => b.StartedAt.CompareTo(a.StartedAt));
if (rows.Count > _maxRuns) rows.RemoveRange(_maxRuns, rows.Count - _maxRuns);
}
catch { }
return rows.ToArray();
}
private IEnumerable<string> EnumerateRunFiles()
{
var list = new List<string>();
try
{
if (!Directory.Exists(_logsRoot)) return list;
var today = DateTime.Now.Date;
for (int d = 0; d < _days; d++)
{
string name = "runs_" + today.AddDays(-d).ToString("yyyyMMdd") + ".jsonl";
for (int n = 0; n < 20; n++)
{
string p = Path.Combine(_logsRoot, n == 0 ? name : name.Replace(".jsonl", "_" + n + ".jsonl"));
if (File.Exists(p)) list.Add(p);
else if (n > 0) break;
}
}
}
catch { }
return list;
}
/// <summary>读文件末尾 N 行(环形缓冲,避免大文件整体入内存)。</summary>
private static IEnumerable<string> ReadTailLines(string path, int max)
{
var buf = new Queue<string>();
try
{
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (var sr = new StreamReader(fs))
{
string line;
while ((line = sr.ReadLine()) != null)
{
if (buf.Count >= max) buf.Dequeue();
buf.Enqueue(line);
}
}
}
catch { }
return buf;
}
private RunRow Parse(string line)
{
try
{
if (string.IsNullOrWhiteSpace(line)) return null;
var o = JObject.Parse(line);
var r = new RunRow
{
RunId = Str(o, "RunId"),
Mode = Str(o, "Mode"),
Outcome = Str(o, "Outcome"),
ErrorType = Str(o, "ErrorType"),
Model = Str(o, "ServedModel"),
TotalMs = Long(o, "TotalMs"),
ToolCalls = Int(o, "ToolCalls"),
LlmCalls = Int(o, "LlmCalls"),
PromptTokens = Int(o, "PromptTokens"),
CompletionTokens = Int(o, "CompletionTokens"),
Cost = Dbl(o, "Cost"),
};
DateTime dt;
if (DateTime.TryParse(Str(o, "StartedAt"), CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind, out dt))
r.StartedAt = dt.ToLocalTime();
var arr = o["Tools"] as JArray;
if (arr != null)
{
foreach (var t in arr)
{
var to = t as JObject;
if (to == null) continue;
// 权限闸门:非 detailed(或角色未命中)时剔除参数摘要
string args = AllowDetailed ? Str(to, "ArgsSummary") : "";
r.Tools.Add(new ToolSpanView
{
Name = Str(to, "Name"),
ArgsSummary = args,
DurationMs = Long(to, "DurationMs"),
Outcome = Str(to, "Outcome"),
Truncated = Bool(to, "Truncated"),
});
}
}
return r;
}
catch { return null; }
}
/// <summary>按工具名聚合(调用数 / 失败率 / P50 / P95 / 超时 / 截断)。</summary>
public ToolAgg[] AggregateTools(RunRow[] rows)
{
var map = new Dictionary<string, ToolAgg>(StringComparer.OrdinalIgnoreCase);
var times = new Dictionary<string, List<long>>(StringComparer.OrdinalIgnoreCase);
try
{
if (rows == null) return new ToolAgg[0];
foreach (var r in rows)
{
foreach (var t in r.Tools)
{
ToolAgg a;
if (!map.TryGetValue(t.Name, out a))
{
a = new ToolAgg { Name = t.Name };
map[t.Name] = a;
times[t.Name] = new List<long>();
}
a.Calls++;
if (t.Outcome == "error") a.Errors++;
if (t.Outcome == "timeout" || t.Outcome == "cancel") a.Timeouts++;
if (t.Truncated) a.Truncated++;
times[t.Name].Add(t.DurationMs);
}
}
foreach (var kv in map)
{
var ts = times[kv.Key];
ts.Sort();
kv.Value.P50 = Quantile(ts, 0.50);
kv.Value.P95 = Quantile(ts, 0.95);
kv.Value.Max = ts.Count > 0 ? ts[ts.Count - 1] : 0;
}
var list = new List<ToolAgg>(map.Values);
list.Sort((x, y) => y.P95.CompareTo(x.P95));
return list.ToArray();
}
catch { return new ToolAgg[0]; }
}
/// <summary>按天聚合。</summary>
public DailyAgg[] AggregateDaily(RunRow[] rows, int days)
{
var map = new Dictionary<string, DailyAgg>(StringComparer.OrdinalIgnoreCase);
var times = new Dictionary<string, List<long>>(StringComparer.OrdinalIgnoreCase);
try
{
if (rows == null) return new DailyAgg[0];
foreach (var r in rows)
{
string key = r.StartedAt == DateTime.MinValue ? "未知" : r.StartedAt.ToString("MM-dd");
DailyAgg d;
if (!map.TryGetValue(key, out d))
{
d = new DailyAgg { Date = key };
map[key] = d;
times[key] = new List<long>();
}
d.Runs++;
if (r.Outcome == "success") d.Success++;
if (r.Outcome == "error") d.Errors++;
d.Tokens += r.PromptTokens + r.CompletionTokens;
d.Cost += r.Cost;
times[key].Add(r.TotalMs);
}
foreach (var kv in map)
{
var ts = times[kv.Key];
ts.Sort();
kv.Value.P95 = Quantile(ts, 0.95);
}
var list = new List<DailyAgg>(map.Values);
list.Sort((a, b) => string.Compare(a.Date, b.Date, StringComparison.Ordinal));
return list.ToArray();
}
catch { return new DailyAgg[0]; }
}
/// <summary>召回通道分布。</summary>
public ModeAgg AggregateModes(RunRow[] rows)
{
var m = new ModeAgg();
try
{
if (rows == null) return m;
foreach (var r in rows)
{
if (r.Mode == "kb") m.Kb++;
else if (r.Mode == "mixed") m.Mixed++;
else if (r.Mode == "model") m.Model++;
else m.ToolLoop++;
}
}
catch { }
return m;
}
/// <summary>错误类型分布(Top N)。</summary>
public KeyValuePair<string, int>[] AggregateErrors(RunRow[] rows, int top)
{
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
try
{
if (rows == null) return new KeyValuePair<string, int>[0];
foreach (var r in rows)
{
if (r.Outcome != "error") continue;
string k = string.IsNullOrEmpty(r.ErrorType) ? "unknown" : r.ErrorType;
map[k] = map.TryGetValue(k, out var v) ? v + 1 : 1;
}
var list = new List<KeyValuePair<string, int>>(map);
list.Sort((a, b) => b.Value.CompareTo(a.Value));
if (list.Count > top) list.RemoveRange(top, list.Count - top);
return list.ToArray();
}
catch { return new KeyValuePair<string, int>[0]; }
}
/// <summary>最近 N 条错误(看板 ⑤Tab)。</summary>
public RunRow[] RecentErrors(RunRow[] rows, int count)
{
var list = new List<RunRow>();
try
{
if (rows == null) return new RunRow[0];
foreach (var r in rows)
{
if (r.Outcome == "error" || r.Outcome == "cancel") list.Add(r);
if (list.Count >= count) break;
}
}
catch { }
return list.ToArray();
}
/// <summary>24 小时分布:按小时统计成功/失败(概览柱状图)。</summary>
public long[] Hourly(RunRow[] rows, out long[] failed)
{
var ok = new long[24];
var bad = new long[24];
failed = bad;
try
{
if (rows == null) return ok;
var since = DateTime.Now.AddHours(-24);
foreach (var r in rows)
{
if (r.StartedAt == DateTime.MinValue || r.StartedAt < since) continue;
int h = r.StartedAt.Hour;
if (r.Outcome == "success") ok[h]++; else bad[h]++;
}
}
catch { }
return ok;
}
private static long Quantile(List<long> sorted, double q)
{
if (sorted.Count == 0) return 0;
int idx = (int)Math.Ceiling(sorted.Count * q) - 1;
if (idx < 0) idx = 0;
if (idx >= sorted.Count) idx = sorted.Count - 1;
return sorted[idx];
}
private static string Str(JObject o, string name)
{
var v = o[name];
return v == null ? "" : (v.Type == JTokenType.Null ? "" : v.ToString());
}
private static int Int(JObject o, string name)
{
try { return o[name] == null ? 0 : (int)o[name]; } catch { return 0; }
}
private static long Long(JObject o, string name)
{
try { return o[name] == null ? 0 : (long)o[name]; } catch { return 0; }
}
private static double Dbl(JObject o, string name)
{
try { return o[name] == null ? 0 : (double)o[name]; } catch { return 0; }
}
private static bool Bool(JObject o, string name)
{
try { return o[name] != null && (bool)o[name]; } catch { return false; }
}
}
}
<?xml version="1.0" encoding="utf-8"?>
<?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>
......@@ -122,6 +122,12 @@
<Compile Include="Config\AgentConfig.cs" />
<Compile Include="Config\RoleTemplateStore.cs" />
<Compile Include="Config\SecretProtector.cs" />
<Compile Include="Forms\AgentAdminForm.cs">
<SubType>Form</SubType>
</Compile>
<Compile Include="Forms\DashChart.cs">
<SubType>Component</SubType>
</Compile>
<Compile Include="Forms\AgentHistoryForm.cs">
<SubType>Form</SubType>
</Compile>
......@@ -153,6 +159,9 @@
<Compile Include="Kb\SynonymIndex.cs" />
<Compile Include="ShareHelper.cs" />
<Compile Include="Support\AgentLogger.cs" />
<Compile Include="Support\AgentTelemetry.cs" />
<Compile Include="Support\AgentHealth.cs" />
<Compile Include="Support\TelemetryStore.cs" />
<Compile Include="Support\AgentUtils.cs" />
<Compile Include="Support\BackupHelper.cs" />
<Compile Include="Support\ChatMessage.cs" />
......
......@@ -379,6 +379,16 @@ namespace Tools.MyAgent
CustomPrices = cfg.CustomPrices ?? new Dictionary<string, string>(),
DbConnections = protectedConns,
ToolRoleAllow = cfg.ToolRoleAllow ?? new Dictionary<string, string>(),
// 可观测性 + Admin 看板(漏登记 ⇒ 设置页保存一次即被静默清空)
TelemetryEnabled = cfg.TelemetryEnabled,
ObservabilityLevel = cfg.ObservabilityLevel,
HealthCheckOnStart = cfg.HealthCheckOnStart,
RunsKeepDays = cfg.RunsKeepDays,
ShowRunSummary = cfg.ShowRunSummary,
AdminEnabled = cfg.AdminEnabled,
AdminRoles = cfg.AdminRoles,
DashboardDays = cfg.DashboardDays,
AdminAutoRefresh = cfg.AdminAutoRefresh,
};
File.WriteAllText(AgentConfigFilePath, Newtonsoft.Json.JsonConvert.SerializeObject(toSave), System.Text.Encoding.UTF8);
}
......@@ -448,7 +458,7 @@ namespace Tools.MyAgent
try { Directory.CreateDirectory(DefaultKbRoot); } catch { }
_kb.SetOutputRoot(DefaultKbRoot);
// 评估#1:存储自动清理(日志 30 天 / 中间产物 7~30 天 / 正式产出 90 天;后台执行不阻塞启动)
try { System.Threading.Tasks.Task.Run(() => StorageCleanup.Run(DefaultKbRoot)); } catch { }
try { System.Threading.Tasks.Task.Run(() => StorageCleanup.Run(DefaultKbRoot, null, _config.RunsKeepDays)); } catch { }
// 大知识库不在窗口初始化阶段创建 SQLite 索引,避免打开业务引用时被 IO/SQLite 锁住。
// 小知识库由统计完成事件按需后台启动,实际搜索也会触发惰性启动。
RefreshScopeCombo(); // 先归一化搜索范围(命中下拉项/归一化为全部),再应用
......@@ -510,6 +520,7 @@ namespace Tools.MyAgent
_engine.OnFileProduced += f => PostToUi(() => OnFileProduced(f));
_engine.OnComplete += () => PostToUi(OnAssistantComplete);
_engine.OnAnswerAttachments += a => PostToUi(() => OnAnswerAttachments(a)); // P5:回答形态 + 关联图片
AgentTelemetry.OnRunCompleted += r => PostToUi(() => OnRunCompleted(r)); // M4:本轮运行摘要
_engine.OnToolTrace += trace => PostToUi(() => OnToolTrace(trace));
_engine.OnError += m => PostToUi(() => OnEngineError(m));
_engine.OnStatusChanged += () => PostToUi(UpdateStatusIndicator);
......@@ -1040,9 +1051,26 @@ namespace Tools.MyAgent
string text = $"本回复 {prompt}+{completion} tokens";
double cost = _engine.TotalCost - _replyCostBase;
if (cost > 0) text += $"(约 {CostEstimator.Format(cost)})";
// M4:本轮摘要(耗时/工具数/通道)—— 默认关闭,避免打扰(AgentConfig.ShowRunSummary)
if (_config.ShowRunSummary && _lastRunSummary.Length > 0) text += " · " + _lastRunSummary;
return text;
}
/// <summary>最近一轮的运行摘要(订阅 <c>AgentTelemetry.OnRunCompleted</c> 取得)</summary>
private string _lastRunSummary = "";
/// <summary>M4:一轮运行完成 → 生成摘要文本(供回复尾注使用)</summary>
private void OnRunCompleted(AgentTelemetry.RunScope r)
{
if (r == null) return;
var sb = new System.Text.StringBuilder();
sb.Append(r.TotalMs).Append("ms");
if (r.ToolCalls > 0) sb.Append(" · ").Append(r.ToolCalls).Append(" 工具");
if (!string.IsNullOrEmpty(r.Mode) && r.Mode != "tool-loop") sb.Append(" · ").Append(r.Mode).Append("命中");
if (r.FailoverCount > 0) sb.Append(" · 转移").Append(r.FailoverCount).Append("次");
_lastRunSummary = sb.ToString();
}
/// <summary>工具轨迹(#16):回答末尾追加可折叠 details,展示工具调用链(先宽后窄的检索过程)</summary>
private void OnToolTrace(System.Collections.Generic.IReadOnlyList<AgentEngine.ToolTraceEntry> trace)
{
......@@ -2691,6 +2719,15 @@ namespace Tools.MyAgent
case "/glossary":
AppendGlossary(arg);
break;
case "/admin":
OpenAdmin();
break;
case "/stats":
RunStats();
break;
case "/diag":
RunDiagnostics();
break;
case "/help":
AddSystemMessage(BuildHelpText());
break;
......@@ -2716,6 +2753,9 @@ namespace Tools.MyAgent
"- `/backup` - 一键备份:会话历史 + 配置(密钥脱敏) + 知识库摘要为 zip\n" +
"- `/restore` - 从备份 zip 恢复会话历史与配置(密钥需重新填写)\n" +
"- `/glossary 术语=标准名` - 向知识库术语表(kb-config.json)沉淀一条术语,提升召回\n" +
"- `/stats` - 本次进程用量统计(轮数/P50·P95/费用)\n" +
"- `/admin` - 打开 Admin 看板(5 个 Tab,可导出 CSV/Markdown/HTML)\n" +
"- `/diag` - 依赖健康检查(9 项)+ 本次进程用量\n" +
"- `/settings` - 打开设置(高级项在「高级设置」中)\n" +
"- `/model <模型名>` / `/scope <目录>` - 快速切换模型 / 搜索范围\n" +
"- `/workflow` - 知识库周报(可选能力,需在高级设置启用)\n" +
......@@ -2752,6 +2792,100 @@ namespace Tools.MyAgent
AddSystemMessage($"**知识库检索:{keyword}**({sw.ElapsedMilliseconds}ms)\n\n{result}");
}
/// <summary>
/// /admin:打开 Admin 看板。
/// <para>权限:<c>AdminRoles</c> 为空 = 不限制(保持现状,不破坏既有宿主);
/// 仅当隐私级别为 detailed 时才把参数摘要传给看板。</para>
/// </summary>
private void OpenAdmin()
{
if (!_config.AdminEnabled)
{
AddSystemMessage("Admin 看板已关闭(配置项 AdminEnabled=false)。");
return;
}
if (!IsAdminRole())
{
AddSystemMessage("当前角色「" + CurrentUserName() + "」无权打开 Admin 看板(配置项 AdminRoles 未包含该角色)。");
return;
}
try
{
int days = _config.DashboardDays > 0 && _config.DashboardDays <= 30 ? _config.DashboardDays : 7;
var form = new Tools.MyAgent.Forms.AgentAdminForm(null, days, AgentTelemetry.IsDetailed);
form.Show(this);
}
catch (Exception ex)
{
AddSystemMessage("打开看板失败:" + ex.Message);
}
}
/// <summary>角色判定:与 <c>ToolRoleAllow</c> 同一套语义(当前登录用户即角色)。</summary>
private bool IsAdminRole()
{
string roles = _config.AdminRoles ?? "";
if (string.IsNullOrWhiteSpace(roles)) return true; // 空 = 不限制
string user = CurrentUserName();
foreach (var r in roles.Split(new[] { ',', ';', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
if (string.Equals(r.Trim(), user, StringComparison.OrdinalIgnoreCase)) return true;
return false;
}
/// <summary>/stats:本次进程累计(多日趋势见 Admin 看板)</summary>
private void RunStats()
{
var st = AgentTelemetry.Snapshot();
var sb = new System.Text.StringBuilder();
sb.Append("**用量统计**(本次进程)\n\n");
sb.Append("- 轮数:").Append(st.Runs).Append("(成功 ").Append(st.Success)
.Append(" · 失败 ").Append(st.Errors).Append(" · 取消 ").Append(st.Cancels).Append(")\n");
if (st.Duration.Count > 0)
sb.Append("- 耗时:P50 ").Append(st.Duration.P50).Append("ms · P95 ").Append(st.Duration.P95)
.Append("ms · 最大 ").Append(st.Duration.Max).Append("ms\n");
if (st.Ttfb.Count > 0)
sb.Append("- 首包:P50 ").Append(st.Ttfb.P50).Append("ms · P95 ").Append(st.Ttfb.P95).Append("ms\n");
sb.Append("- 工具:").Append(st.ToolCalls).Append(" 次 · LLM:").Append(st.LlmCalls).Append(" 次\n");
long tokens = st.PromptTokens + st.CompletionTokens;
if (tokens > 0) sb.Append("- Token:").Append(st.PromptTokens).Append(" + ").Append(st.CompletionTokens).Append("\n");
if (st.Cost > 0) sb.Append("- 费用:").Append(CostEstimator.Format(st.Cost)).Append("\n");
if (st.Dropped > 0) sb.Append("- ⚠ 丢弃 ").Append(st.Dropped).Append(" 条(采集跟不上)\n");
if (st.Runs == 0) sb.Append("_暂无数据(本进程尚未完成任何一轮回答)_\n");
AddSystemMessage(sb.ToString());
}
/// <summary>/diag:依赖健康检查(9 项) + 本次进程累计用量</summary>
private async void RunDiagnostics()
{
var items = new System.Collections.Generic.List<HealthItem>(
AgentHealth.Check(_config, _kb, _engine.DiagnosticRoots)); // 诊断根由引擎持有(窗体未另存字段)
bool ok = false;
string msg = "";
try
{
var r = await _engine.TestConnectionAsync(System.Threading.CancellationToken.None);
ok = r.Ok;
msg = r.Message;
}
catch (Exception ex) { msg = ex.Message; }
items.Insert(1, AgentHealth.ModelService(ok, msg));
AgentHealth.Save(items.ToArray());
AddSystemMessage(AgentHealth.RenderMarkdown(items.ToArray()) + "\n" + BuildDiagUsage());
}
/// <summary>/diag 的用量段(进程内累计;多日趋势见 Admin 看板)</summary>
private string BuildDiagUsage()
{
var st = AgentTelemetry.Snapshot();
string s = "**本次进程累计**:" + st.Runs + " 轮(成功 " + st.Success + " · 失败 " + st.Errors + " · 取消 " + st.Cancels + ")";
if (st.Duration.Count > 0) s += " · P50 " + st.Duration.P50 + "ms / P95 " + st.Duration.P95 + "ms";
long tokens = st.PromptTokens + st.CompletionTokens;
if (tokens > 0) s += " · " + st.PromptTokens + "+" + st.CompletionTokens + " tokens";
if (st.Cost > 0) s += " · " + CostEstimator.Format(st.Cost);
if (st.Dropped > 0) s += " · ⚠ 丢弃 " + st.Dropped + " 条(采集跟不上)";
return s;
}
/// <summary>/model <name>:快速切换模型</summary>
private void SwitchModel(string model)
{
......
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