Commit 41a89411 authored by 段廷瑞's avatar 段廷瑞

feat: 支持分发模式(服务端统一配置apikey和知识库,其他用户自动拉取)

parent 0020a375
......@@ -78,3 +78,5 @@ bin
/AppHome/_backup
/Tools.Agent.ProbeKbPref
/working
/Tools.Agent/Defaults/Agent/config
/Tools.Agent.ProbeCenter
No preview for this file type
......@@ -19,6 +19,18 @@
<member name="P:Tools.MyAgent.AgentRuntimeOptions.ProductContext">
<summary>宿主固定的产品上下文;空值表示未绑定产品线。</summary>
</member>
<member name="P:Tools.MyAgent.AgentRuntimeOptions.Mode">
<summary>
运行模式(config_center_plan.md v2.2):Custom=自定义(现状零改动);Distribution=分发(配置中心)。
缺省 null = 按 &lt;程序目录&gt;/Agent/config/center.json 是否存在自动判定;显式传入时以入参为准。
</summary>
</member>
<member name="P:Tools.MyAgent.AgentRuntimeOptions.IsAdmin">
<summary>
是否管理员(config_center_plan.md v2.2):宿主登录体系已知时直接传入,优先于中心 AdminRoles 名单;
缺省 null = 分发模式下按中心名单比对 CurrentUser(自定义模式恒按可设置处理)。
</summary>
</member>
<member name="T:Tools.MyAgent.AgentRuntime">
<summary>其他 .NET Framework 项目引用 Tools.Agent 后使用的稳定入口。</summary>
</member>
......@@ -31,6 +43,15 @@
<member name="P:Tools.MyAgent.AgentRuntime.ProductLineId">
<summary>当前产品线稳定标识;未绑定时为空。</summary>
</member>
<member name="P:Tools.MyAgent.AgentRuntime.Center">
<summary>配置中心解析结果(v2.2);自定义模式下仅承载 Mode/IsAdmin,其余字段为空。</summary>
</member>
<member name="P:Tools.MyAgent.AgentRuntime.CenterMode">
<summary>生效模式(入参优先 / center.json 自动判定)。</summary>
</member>
<member name="P:Tools.MyAgent.AgentRuntime.CenterIsAdmin">
<summary>当前用户是否管理员(宿主入参 &gt; 中心名单;自定义模式恒 true = 现状人人可设置)。</summary>
</member>
<member name="P:Tools.MyAgent.AgentRuntime.DataRoot">
<summary>实际数据根目录。</summary>
</member>
......@@ -1468,6 +1489,13 @@
<member name="M:Tools.MyAgent.Forms.AgentSettingsForm.#ctor">
<summary>仅设计器使用(WinForms 设计器要求无参构造);运行时请使用带参构造。</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentSettingsForm.#ctor(Tools.MyAgent.Config.AgentConfig,System.String,System.Boolean)">
<summary>
带参构造。
<para><paramref name="focusRunDbTab"/>(2026-10-22):分发模式未配置数据库连接时,
管理员打开设置自动跳到「高级 → 对话上报」页,引导先完成配置。</para>
</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentSettingsForm.BuildRemoteKbUi">
<summary>
远程知识库条目接口(next_pref_plan v4.1):追加到「高级 → 语义检索」二级页底部。
......@@ -1489,6 +1517,12 @@
<member name="M:Tools.MyAgent.Forms.AgentSettingsForm.OnTestRunDb(System.Object,System.EventArgs)">
<summary>测试连接:Open + SELECT 1,成功再预演建表(无建表权限时提示具体错误,不落任何数据)。</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentSettingsForm.OnViewRunLog(System.Object,System.EventArgs)">
<summary>
查看上报日志(2026-10-22):用当前页面填写的连接串/表名直开 <see cref="T:Tools.MyAgent.Forms.AgentRunLogForm"/>
(未保存的修改也能即时预览);空连接串时提示先填写。窗口内只读,不再提供连接配置。
</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentSettingsForm.BuildToolPermissionUi">
<summary>在「可选能力」页追加工具权限输入区(角色白名单,一行一规则)</summary>
</member>
......@@ -1583,13 +1617,13 @@
<summary>
Agent 日志查看窗口(agent_analysis_plan.md v1.3 / Q5 决议):只读查询对话上报记录(Agent_RunLog),
左列表 + 右详情(两个 Tab:「问答」= 提问+回答聊天式同页渲染;「指标」= 运行明细),支持日期 / 用户 / 结果过滤与 CSV 导出。
<para>**独立窗口**:由宿主"实施工具"菜单直接打开(<see cref="M:Tools.MyAgent.AgentRuntime.CreateRunLogForm(System.String,System.String)"/>),
无任何聊天命令唤出;窗口侧永不写库。</para>
<para>**入口(2026-10-22)**:设置页「高级 → 对话上报 → 查看上报日志」按钮打开(连接取页面当前填写值);
原实施工具菜单入口与窗口内"连接配置"按钮已移除——连接统一在设置页管理。</para>
<para>控件布局由 AgentRunLogForm.Designer.cs(设计器)实现;本文件仅含逻辑。</para>
</summary>
</member>
<member name="F:Tools.MyAgent.Forms.AgentRunLogForm._connection">
<summary>当前上报连接(明文,窗口内可经"连接配置"切换)。</summary>
<summary>当前上报连接(明文,由设置页「对话上报」传入,窗口内只读)。</summary>
</member>
<member name="F:Tools.MyAgent.Forms.AgentRunLogForm._table">
<summary>当前日志表名。</summary>
......@@ -1599,7 +1633,7 @@
</member>
<member name="M:Tools.MyAgent.Forms.AgentRunLogForm.#ctor">
<summary>
模块系统入口(Activator 无参创建,实施工具菜单直接打开):自读本机默认 Agent 配置取上报连接;
模块系统入口(Activator 无参创建兜底):自读本机默认 Agent 配置取上报连接;
取不到 = 未配置(查询时提示先在设置页配置)。VS 设计器实例化时同样走此路径(读不到配置也不抛异常)。
</summary>
</member>
......@@ -1623,12 +1657,6 @@
RichEdit 静默解析失败 ⇒ 空白)。
</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentRunLogForm.OnConnConfigClick(System.Object,System.EventArgs)">
<summary>
"连接配置":IP → 账号 → 密码 → 库(下拉拉取 sys.databases);
确定后写回 AgentConfig.json(DPAPI 加密)并刷新本窗口的连接与列表。
</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentRunLogForm.Q(System.String)">
<summary>表名标识(白名单已在 Reporter/配置层校验;此处再防御)。</summary>
</member>
......@@ -2863,147 +2891,165 @@
<member name="E:Tools.MyAgent.AgentTelemetry.OnToolSpan">
<summary>一次工具调用完成(实时事件,替代 <c>OnToolStatus</c> 的字符串)。</summary>
</member>
<member name="T:Tools.MyAgent.HealthItem">
<summary>健康检查结果项。</summary>
<member name="T:Tools.MyAgent.Support.AgentRunMode">
<summary>Agent 运行模式(config_center_plan.md v2.2)。</summary>
</member>
<member name="F:Tools.MyAgent.HealthItem.Id">
<summary>检查项标识(稳定,便于聚合与对比)。</summary>
<member name="F:Tools.MyAgent.Support.AgentRunMode.Custom">
<summary>自定义模式(现状):本地 AgentConfig.json,人人可设置,零改动。</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 name="F:Tools.MyAgent.Support.AgentRunMode.Distribution">
<summary>分发模式:配置中心(SQL Server),管理员写透、普通用户拉取。</summary>
</member>
<member name="F:Tools.MyAgent.HealthItem.Detail">
<summary>判定依据或处置建议。</summary>
</member>
<member name="T:Tools.MyAgent.AgentHealth">
<member name="T:Tools.MyAgent.Support.CenterResolution">
<summary>
依赖健康检查(9 项)。按需执行,不定时轮询;单项异常只影响该项。
<para>输出:<c>Logs/health.json</c> + 聊天窗 <c>/diag</c> 的表格 + Admin 看板 ⑤Tab。</para>
配置中心解析结果(config_center_plan.md v2.0–v2.3)。
<para>承载中心库拉取/快照/缺省三级来源合并后的配置,由 <see cref="M:Tools.MyAgent.Support.AgentCenterClient.Fetch(System.String,System.Nullable{Tools.MyAgent.Support.AgentRunMode},System.Nullable{System.Boolean},System.String,System.String)"/> 产出;
通过 <see cref="M:Tools.MyAgent.Support.CenterResolution.ApplyTo(Tools.MyAgent.Config.AgentConfig)"/> 覆盖到 AgentConfig(字段级"非空才覆盖")。</para>
</summary>
</member>
<member name="F:Tools.MyAgent.AgentHealth.LogsWarnBytes">
<summary>日志目录超该大小时告警(防止观测自身撑爆磁盘)。</summary>
<member name="P:Tools.MyAgent.Support.CenterResolution.Mode">
<summary>生效模式(Custom = 仅承载模式与角色,其余字段为空)。</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})">
<member name="P:Tools.MyAgent.Support.CenterResolution.IsAdmin">
<summary>是否管理员:宿主入参优先,否则中心 AdminRoles 名单比对;Custom 模式恒 true(现状人人可设置)。</summary>
</member>
<member name="P:Tools.MyAgent.Support.CenterResolution.Source">
<summary>配置来源:custom / db / snapshot / none。</summary>
</member>
<member name="P:Tools.MyAgent.Support.CenterResolution.MissingItems">
<summary>缺项清单(仅分发模式计算):ApiKey / BaseUrl / Model。KB 缺省不进清单(v2.3:默认 {CLIENT_ROOT} 静默兜底)。</summary>
</member>
<member name="M:Tools.MyAgent.Support.CenterResolution.ApplyTo(Tools.MyAgent.Config.AgentConfig)">
<summary>把本分辨率结果覆盖到 AgentConfig(字段级"非空才覆盖";中心未配置项回落本地)。</summary>
</member>
<member name="T:Tools.MyAgent.Support.AgentCenterClient">
<summary>
执行检查(不含模型服务连通性——它是异步的,由调用方 <see cref="M:Tools.MyAgent.AgentHealth.ModelService(System.Boolean,System.String)"/> 单独追加)。
配置中心客户端(config_center_plan.md P0 读侧)。
<para>职责:模式判定(宿主入参优先 / center.json 自动判定兜底)→ 拉取中心四表 →
{CLIENT_ROOT} 展开 → 角色判定 → 缺项计算 → 快照读写(离线降级)。</para>
<para>红线(与 AgentTelemetry 同规):本类所有 public 方法体首句必须是 try —— 任何异常不上抛、不阻塞启动。</para>
<para>安全:ApiKey 等敏感列 AES-256(密钥内嵌派生,防库备份肉眼泄露);快照文件只存密文。</para>
</summary>
</member>
<member name="M:Tools.MyAgent.AgentHealth.ModelService(System.Boolean,System.String)">
<summary>模型服务连通性项(由调用方用 <c>TestConnectionAsync</c> 结果构造)。</summary>
<member name="F:Tools.MyAgent.Support.AgentCenterClient.ClientRootPlaceholder">
<summary>{CLIENT_ROOT} 占位符(v2.4 路径统一规范:仅本地文件夹生效;共享文件夹 UNC 原样存储)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentHealth.CheckTelemetrySelf">
<summary>观测自身:日志目录是否膨胀(自查项,防止观测把磁盘撑爆)。</summary>
<member name="F:Tools.MyAgent.Support.AgentCenterClient.SecretPrefix">
<summary>AES 密文前缀。</summary>
</member>
<member name="M:Tools.MyAgent.AgentHealth.ToJson(Tools.MyAgent.HealthItem[])">
<summary>序列化为 JSON(<c>Logs/health.json</c>)。</summary>
<member name="M:Tools.MyAgent.Support.AgentCenterClient.CenterJsonPath(System.String)">
<summary>center.json 固定位置:&lt;程序目录&gt;/Agent/config/center.json(随分发包内置)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentHealth.Save(Tools.MyAgent.HealthItem[])">
<summary>落盘到 <c>Logs/health.json</c>(覆盖写);失败不影响调用方。</summary>
<member name="M:Tools.MyAgent.Support.AgentCenterClient.DetectMode(System.Nullable{Tools.MyAgent.Support.AgentRunMode},System.String)">
<summary>
模式判定(v2.2):宿主入参显式传入时以入参为准(显式优于隐式);
未传时自动判定 center.json 是否存在(覆盖独立运行场景)。任何异常回退 Custom。
</summary>
</member>
<member name="M:Tools.MyAgent.AgentHealth.RenderMarkdown(Tools.MyAgent.HealthItem[])">
<summary>渲染为聊天窗 Markdown(分组表格;<c>/diag</c> 用)。</summary>
<member name="M:Tools.MyAgent.Support.AgentCenterClient.Fetch(System.String,System.Nullable{Tools.MyAgent.Support.AgentRunMode},System.Nullable{System.Boolean},System.String,System.String)">
<summary>
拉取并构建配置中心解析结果。任何失败均不抛出;分发模式下按
中心库 → 本地快照 → 缺省 三级来源降级。
</summary>
</member>
<member name="T:Tools.MyAgent.TelemetryStore">
<member name="M:Tools.MyAgent.Support.AgentCenterClient.ExpandClientRoot(System.String,System.String)">
<summary>把模板中的 {CLIENT_ROOT} 替换为程序根目录(去尾 \);不含占位符原样返回(UNC 共享路径天然走此分支);空模板返回空串。</summary>
</member>
<member name="M:Tools.MyAgent.Support.AgentCenterClient.FoldClientRoot(System.String,System.String)">
<summary>
可观测性只读聚合层(Admin 看板数据源)。与写侧 <see cref="T:Tools.MyAgent.AgentTelemetry"/> 分离:
写侧不因看板需求膨胀,看板想怎么查是看板的事。
<para>护栏:只读最近 N 天 / 单文件最多 N 行 / 坏行跳过并计数 / 全程不允许抛异常。</para>
把本机**本地**绝对路径反折回 {CLIENT_ROOT} 占位符(管理员保存用,P1)。
<para>v2.4 例外:共享文件夹(UNC 路径 \\server\share\…)保持原有模式——各终端访问同一网络路径,
无需按本机展开,原样存储原样使用;程序目录本身在 UNC 上时同样不折叠。</para>
</summary>
</member>
<member name="T:Tools.MyAgent.TelemetryStore.RunRow">
<summary>一行运行记录(看板表格的行模型)。</summary>
<member name="M:Tools.MyAgent.Support.AgentCenterClient.ProtectSecret(System.String)">
<summary>AES-256-CBC 加密(随机 IV 前置,base64,"aes:" 前缀);空串原样返回;失败回退明文。</summary>
</member>
<member name="F:Tools.MyAgent.TelemetryStore.RunRow.Tools">
<summary>工具 span 明细(下钻用;受 <see cref="P:Tools.MyAgent.TelemetryStore.AllowDetailed"/> 约束)。</summary>
<member name="M:Tools.MyAgent.Support.AgentCenterClient.UnprotectSecret(System.String)">
<summary>解密 "aes:" 密文;非本前缀视为明文原样返回(兼容);解密失败返回空串(视为未配置)。</summary>
</member>
<member name="T:Tools.MyAgent.TelemetryStore.ToolSpanView">
<summary>一条工具 span(下钻展示)。</summary>
<member name="M:Tools.MyAgent.Support.AgentCenterClient.WithShortTimeout(System.String)">
<summary>连接串附加启动短超时(幂等;已有 Connect Timeout 则不动)。</summary>
</member>
<member name="T:Tools.MyAgent.TelemetryStore.ToolAgg">
<summary>按工具名聚合。</summary>
<member name="M:Tools.MyAgent.Support.AgentCenterClient.TestCenterConnection(System.String)">
<summary>
测试中心连接(设置页「测试连接」/ P2 /diag 用):Open + 幂等建表。
<para>返回错误消息,成功返回 null(不抛异常——遵守本类 public 不上抛红线;不写任何数据行)。</para>
</summary>
</member>
<member name="T:Tools.MyAgent.TelemetryStore.DailyAgg">
<summary>按天聚合。</summary>
<member name="M:Tools.MyAgent.Support.AgentCenterClient.SaveFromConfig(System.String,Tools.MyAgent.Config.AgentConfig)">
<summary>
管理员保存写透(P1):把 AgentConfig 的中心管辖区字段 Upsert 到中心库,路径反折 {CLIENT_ROOT}。
<para>中心管辖区 = 模型(Provider/BaseUrl/Model/ApiKey/生成参数/远程KB)+
知识库(根/范围/方式/共享凭据)+ Global(AdminRoles/RunDb/遥测)。</para>
<para>返回错误消息,成功返回 null。</para>
</summary>
</member>
<member name="T:Tools.MyAgent.TelemetryStore.ModeAgg">
<summary>召回通道分布(直答率来源)。</summary>
<member name="M:Tools.MyAgent.Support.AgentCenterClient.SaveCenter(System.String,Tools.MyAgent.Support.AgentCenterClient.CenterModelRow,Tools.MyAgent.Support.AgentCenterClient.CenterKbRow,Tools.MyAgent.Support.AgentCenterClient.CenterGlobalRow)">
<summary>Upsert 三表(模型/KB 写 IsDefault=1 行,Global 单行);缺表自动创建。返回错误消息,成功返回 null。</summary>
</member>
<member name="P:Tools.MyAgent.TelemetryStore.AllowDetailed">
<summary>是否允许展示详细字段(参数摘要等)。由宿主按角色判定后注入。</summary>
<member name="M:Tools.MyAgent.Support.AgentCenterClient.EnsureCenterTablesOn(System.Data.SqlClient.SqlConnection)">
<summary>幂等建表(四表;IF OBJECT_ID NULL 才建,管理员首次发布即完成中心库初始化)。</summary>
</member>
<member name="P:Tools.MyAgent.TelemetryStore.BadLines">
<summary>坏行计数(>5% 时看板顶部提示日志疑似损坏)。</summary>
<member name="M:Tools.MyAgent.Support.AgentCenterClient.ExecOrNull(System.Data.SqlClient.SqlConnection,System.String)">
<summary>对已打开连接执行单条 SQL,失败静默(建表/索引等幂等语句用)。</summary>
</member>
<member name="P:Tools.MyAgent.TelemetryStore.ReadLines">
<summary>已读取行数。</summary>
<member name="M:Tools.MyAgent.Support.AgentCenterClient.ReadBootstrap(System.String)">
<summary>读 center.json 的 Connection 字段;文件缺失/无连接串返回空。</summary>
</member>
<member name="M:Tools.MyAgent.TelemetryStore.LoadRuns">
<summary>读取最近 N 天的运行记录(倒序:最新在前)。</summary>
<member name="T:Tools.MyAgent.Support.AgentCenterClient.CenterPull">
<summary>中心库原始拉取结果(快照序列化载体)。</summary>
</member>
<member name="M:Tools.MyAgent.TelemetryStore.ReadTailLines(System.String,System.Int32)">
<summary>读文件末尾 N 行(环形缓冲,避免大文件整体入内存)。</summary>
<member name="M:Tools.MyAgent.Support.AgentCenterClient.PullFromDb(System.String,System.String)">
<summary>拉取中心四表(分配 → 默认 → Global);库不可达/缺表返回 null(由快照兜底)。解析优先级:分配行 &gt; 默认行。</summary>
</member>
<member name="M:Tools.MyAgent.TelemetryStore.AggregateTools(Tools.MyAgent.TelemetryStore.RunRow[])">
<summary>按工具名聚合(调用数 / 失败率 / P50 / P95 / 超时 / 截断)。</summary>
<member name="M:Tools.MyAgent.Support.AgentCenterClient.WriteSnapshot(System.String,Tools.MyAgent.Support.AgentCenterClient.CenterPull)">
<summary>写快照(只存原始行数据,敏感列保持密文);失败静默。</summary>
</member>
<member name="M:Tools.MyAgent.TelemetryStore.AggregateDaily(Tools.MyAgent.TelemetryStore.RunRow[],System.Int32)">
<summary>按天聚合。</summary>
<member name="M:Tools.MyAgent.Support.AgentCenterClient.ReadSnapshot(System.String)">
<summary>读快照;文件缺失/损坏返回 null。</summary>
</member>
<member name="M:Tools.MyAgent.TelemetryStore.AggregateModes(Tools.MyAgent.TelemetryStore.RunRow[])">
<summary>召回通道分布。</summary>
<member name="M:Tools.MyAgent.Support.AgentCenterClient.Build(Tools.MyAgent.Support.AgentCenterClient.CenterPull,System.String,Tools.MyAgent.Support.AgentRunMode,System.Nullable{System.Boolean},System.String,System.String)">
<summary>由原始拉取结果构建分辨率:角色判定 + {CLIENT_ROOT} 展开 + 缺项计算(v2.3:KB 缺省不进缺项)。</summary>
</member>
<member name="M:Tools.MyAgent.TelemetryStore.AggregateErrors(Tools.MyAgent.TelemetryStore.RunRow[],System.Int32)">
<summary>错误类型分布(Top N)。</summary>
<member name="M:Tools.MyAgent.Support.AgentCenterClient.MatchAdminRole(System.String,System.String)">
<summary>管理员名单比对(逗号/分号分隔,中英文皆可;忽略大小写与首尾空白)。</summary>
</member>
<member name="M:Tools.MyAgent.TelemetryStore.RecentErrors(Tools.MyAgent.TelemetryStore.RunRow[],System.Int32)">
<summary>最近 N 条错误(看板 ⑤Tab)。</summary>
<member name="M:Tools.MyAgent.Support.AgentCenterClient.NormalizeRoot(System.String)">
<summary>程序根目录归一:去尾 \。</summary>
</member>
<member name="M:Tools.MyAgent.TelemetryStore.Hourly(Tools.MyAgent.TelemetryStore.RunRow[],System.Int64[]@)">
<summary>24 小时分布:按小时统计成功/失败(概览柱状图)。</summary>
<member name="T:Tools.MyAgent.Support.KbProbeResult">
<summary>知识库根目录探测结论(config_center_plan.md v2.3)。</summary>
</member>
<member name="T:Tools.MyAgent.AgentUtils">
<summary>
智能体专用小工具(迁移自 Tools.MyUtils.Utils 中智能体依赖的方法)
</summary>
<member name="P:Tools.MyAgent.Support.KbProbeResult.Template">
<summary>原始模板(空 = 中心未下发 KB 配置)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentUtils.AsyncUI(System.Windows.Forms.Control,System.Action)">
<summary>
异步UI扩展(替代原 Common.Extensions.AsyncUI):线程安全地回到控件所在 UI 线程执行
</summary>
<member name="P:Tools.MyAgent.Support.KbProbeResult.Resolved">
<summary>最终采用的绝对路径({CLIENT_ROOT} 已展开;UNC 共享路径原样)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentUtils.SelectFolder">
<summary>
选择文件夹
</summary>
<returns></returns>
<member name="P:Tools.MyAgent.Support.KbProbeResult.Exists">
<summary>目录是否存在。</summary>
</member>
<member name="M:Tools.MyAgent.AgentUtils.PickFolder(System.String)">
<summary>
选择文件夹(支持直接输入网络共享 UNC 路径,如 \\服务器\共享名)。
</summary>
<param name="initialPath">预填路径</param>
<returns>选择的路径;取消时返回空串</returns>
<member name="P:Tools.MyAgent.Support.KbProbeResult.IsEmpty">
<summary>存在但为空目录(允许,首次分发场景;/diag 呈现)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentUtils.PromptShareCredentials(System.String)">
<summary>
弹出共享文件夹访问凭据输入框(账号/密码,可留空以使用当前 Windows 身份)。
</summary>
<member name="P:Tools.MyAgent.Support.KbProbeResult.Conclusion">
<summary>结论:default_ok(未配置→宿主程序根目录) / ok / fallback_ok(已回退 BaseDirectory\Agent) / unavailable。</summary>
</member>
<member name="M:Tools.MyAgent.AgentUtils.PromptText(System.String,System.String,System.String)">
<summary>
单行文本输入框(用于「存知识库」路径、会话重命名等场景)
</summary>
<member name="P:Tools.MyAgent.Support.KbProbeResult.Usable">
<summary>是否可用(Exists 且非 unavailable 类结论)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentUtils.Toast(System.String,System.Int32)">
<member name="T:Tools.MyAgent.Support.AgentKbProbe">
<summary>
Toast提示(自动关闭)
默认知识库探测器(config_center_plan.md v2.3/v2.4)。
<para>规则:模板空(中心未下发)→ 默认 <c>{CLIENT_ROOT}</c>(宿主程序根目录,静默兜底,不提示);
已配置但解析后不存在 → 回退 <c>BaseDirectory\Agent</c>;仍不存在 → unavailable(提示"目录不可用")。
v2.4:占位符仅本地文件夹生效——UNC 共享模板不含占位符、原样解析。</para>
<para>红线:本类所有 public 方法体首句必须是 try —— 任何异常不上抛、不阻塞启动。</para>
</summary>
<param name="msg">消息</param>
<param name="durationMs">显示时长(毫秒),默认2秒</param>
</member>
<member name="M:Tools.MyAgent.Support.AgentKbProbe.Probe(System.String,System.String)">
<summary>未配置模板时 KB 解析(v2.3):默认 {CLIENT_ROOT} = 宿主程序根目录。</summary>
</member>
<member name="T:Tools.MyAgent.Support.BackupHelper">
<summary>
......@@ -3245,6 +3291,152 @@
<para>采样边界处不完整序列视为合法(避免误伤);0x80/0xFF 等非法字节判定为二进制。</para>
</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},Tools.MyAgent.Support.CenterResolution)">
<summary>
执行检查(不含模型服务连通性——它是异步的,由调用方 <see cref="M:Tools.MyAgent.AgentHealth.ModelService(System.Boolean,System.String)"/> 单独追加)。
<para>v2.2 新增第 10 项 <c>config_center</c>:传入配置中心解析结果即输出该项(null = 自定义模式)。</para>
</summary>
</member>
<member name="M:Tools.MyAgent.AgentHealth.CheckConfigCenter(Tools.MyAgent.Support.CenterResolution)">
<summary>配置中心项(P2):模式 / 来源 / 角色 / KB 探测结论 / 缺项清单。</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 中智能体依赖的方法)
</summary>
</member>
<member name="M:Tools.MyAgent.AgentUtils.AsyncUI(System.Windows.Forms.Control,System.Action)">
<summary>
异步UI扩展(替代原 Common.Extensions.AsyncUI):线程安全地回到控件所在 UI 线程执行
</summary>
</member>
<member name="M:Tools.MyAgent.AgentUtils.SelectFolder">
<summary>
选择文件夹
</summary>
<returns></returns>
</member>
<member name="M:Tools.MyAgent.AgentUtils.PickFolder(System.String)">
<summary>
选择文件夹(支持直接输入网络共享 UNC 路径,如 \\服务器\共享名)。
</summary>
<param name="initialPath">预填路径</param>
<returns>选择的路径;取消时返回空串</returns>
</member>
<member name="M:Tools.MyAgent.AgentUtils.PromptShareCredentials(System.String)">
<summary>
弹出共享文件夹访问凭据输入框(账号/密码,可留空以使用当前 Windows 身份)。
</summary>
</member>
<member name="M:Tools.MyAgent.AgentUtils.PromptText(System.String,System.String,System.String)">
<summary>
单行文本输入框(用于「存知识库」路径、会话重命名等场景)
</summary>
</member>
<member name="M:Tools.MyAgent.AgentUtils.Toast(System.String,System.Int32)">
<summary>
Toast提示(自动关闭)
</summary>
<param name="msg">消息</param>
<param name="durationMs">显示时长(毫秒),默认2秒</param>
</member>
<member name="T:Tools.MyAgent.ChatMessage">
<summary>
对话消息(迁移自 Tools.MyCommon)
......@@ -3683,6 +3875,12 @@
<member name="M:Tools.MyAgent.wMyAgent.#ctor(System.String,System.String,Tools.MyAgent.AgentProductContext,System.Collections.Generic.IReadOnlyList{Tools.MyAgent.Diagnostics.DiagnosticRoot})">
<summary>创建可嵌入的智能体窗体,并允许宿主指定独立诊断根目录。</summary>
</member>
<member name="M:Tools.MyAgent.wMyAgent.#ctor(System.String,System.String,Tools.MyAgent.AgentProductContext,System.Collections.Generic.IReadOnlyList{Tools.MyAgent.Diagnostics.DiagnosticRoot},Tools.MyAgent.Support.CenterResolution)">
<summary>
创建可嵌入的智能体窗体(v2.2 完整形态):center 为宿主(AgentRuntime)已拉取的配置中心解析结果;
传 null(独立实例化)时按 center.json 是否存在自动判定模式并自行拉取。
</summary>
</member>
<member name="M:Tools.MyAgent.wMyAgent.CreateChatControl">
<summary>
创建聊天区控件:统一使用 RichBox(RichTextBox + RTF 渲染,轻量、启动快、无浏览器依赖)。
......@@ -3725,8 +3923,15 @@
<member name="P:Tools.MyAgent.wMyAgent.ProviderPresets">
<summary>提供商预设(定义统一在设置窗体 AgentSettingsForm)</summary>
</member>
<member name="P:Tools.MyAgent.wMyAgent.DefaultKbRoot">
<member name="F:Tools.MyAgent.wMyAgent._center">
<summary>默认知识库路径:程序输出目录下的 Agent 文件夹(未设置知识库时生效)</summary>
<summary>配置中心解析结果(config_center_plan.md v2.2);Custom 模式仅承载模式与角色。保存成功后可被刷新(非只读)。</summary>
</member>
<member name="P:Tools.MyAgent.wMyAgent.IsDistribution">
<summary>当前是否分发模式(配置中心生效)。</summary>
</member>
<member name="P:Tools.MyAgent.wMyAgent.CanOpenSettings">
<summary>当前用户是否可打开设置:自定义模式恒可(现状);分发模式仅管理员。</summary>
</member>
<member name="P:Tools.MyAgent.wMyAgent.ProductFixedScope">
<summary>产品上下文真正固定的搜索子目录;旧宿主把根目录作为范围时视为未固定。</summary>
......
......@@ -9,6 +9,7 @@ using System.Threading;
using System.Windows.Forms;
using Tools.MyAgent;
using Tools.MyAgent.Diagnostics;
using Tools.MyAgent.Support;
namespace Tools.MyAgent.Host
{
......@@ -47,6 +48,8 @@ namespace Tools.MyAgent.Host
{
DataRoot = options.DataRoot,
CurrentUser = options.User,
Mode = options.Mode,
IsAdmin = options.IsAdmin,
ProductContext = product,
};
......@@ -197,6 +200,8 @@ namespace Tools.MyAgent.Host
public string LogRoot = "";
public string ConfigRoot = "";
public string DeploymentRoot = "";
public AgentRunMode? Mode;
public bool? IsAdmin;
public string DataRoot;
public int ParentPid;
public bool ValidateOnly;
......@@ -206,7 +211,7 @@ namespace Tools.MyAgent.Host
"Tools.Agent.Host.exe --protocol 1 --product <pdm|erp|mes> --user <user> "
+ "--data-root <path> [--project-root <path>] [--kb-root <path>] "
+ "[--kb-scope <path>] [--log-root <path>] [--config-root <path>] [--deployment-root <path>] "
+ "[--parent-pid <pid>]";
+ "[--mode <custom|distribution>] [--is-admin <true|false>] [--parent-pid <pid>]";
public static bool TryParse(string[] args, out LaunchOptions options, out string error)
{
......@@ -272,6 +277,30 @@ namespace Tools.MyAgent.Host
values.TryGetValue("log-root", out options.LogRoot);
values.TryGetValue("config-root", out options.ConfigRoot);
values.TryGetValue("deployment-root", out options.DeploymentRoot);
if (values.TryGetValue("mode", out value) && !string.IsNullOrWhiteSpace(value))
{
if (value.Equals("custom", StringComparison.OrdinalIgnoreCase))
options.Mode = AgentRunMode.Custom;
else if (value.Equals("distribution", StringComparison.OrdinalIgnoreCase))
options.Mode = AgentRunMode.Distribution;
else
{
error = "mode 必须是 custom 或 distribution。";
return false;
}
}
if (values.TryGetValue("is-admin", out value))
{
bool isAdmin;
if (value == "1") isAdmin = true;
else if (value == "0") isAdmin = false;
else if (!bool.TryParse(value, out isAdmin))
{
error = "is-admin 必须是 true、false、1 或 0。";
return false;
}
options.IsAdmin = isAdmin;
}
if (values.TryGetValue("parent-pid", out value) && !int.TryParse(value, out options.ParentPid))
{
error = "parent-pid 不是有效整数。";
......
......@@ -237,7 +237,7 @@ namespace Tools.MyAgent.Agent
_backupClient = new OpenAiCompatibleClient();
_failover = new FailoverPolicy(_client, _backupClient);
string root = string.IsNullOrEmpty(agentRoot)
? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Agent")
? global::Tools.MyAgent.Support.AgentCenterClient.AgentRootPath(AppDomain.CurrentDomain.BaseDirectory)
: Path.GetFullPath(agentRoot);
_dialect = new DialectService(root);
_recall = new KbRecall(_kb); // P3:本地融合召回器(构造无副作用,仅持有 KbService 引用)
......
......@@ -150,7 +150,7 @@ namespace Tools.MyAgent.Agent.Tools
{
try
{
string root = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Agent");
string root = global::Tools.MyAgent.Support.AgentCenterClient.AgentRootPath(AppDomain.CurrentDomain.BaseDirectory);
string cfgPath = Path.Combine(root, "config", "AgentConfig.json");
if (File.Exists(cfgPath))
{
......
......@@ -23,6 +23,18 @@ namespace Tools.MyAgent
/// <summary>宿主固定的产品上下文;空值表示未绑定产品线。</summary>
public AgentProductContext ProductContext { get; set; }
/// <summary>
/// 运行模式(config_center_plan.md v2.2):Custom=自定义(现状零改动);Distribution=分发(配置中心)。
/// 缺省 null = 按 &lt;程序目录&gt;/Agent/config/center.json 是否存在自动判定;显式传入时以入参为准。
/// </summary>
public Support.AgentRunMode? Mode { get; set; }
/// <summary>
/// 是否管理员(config_center_plan.md v2.2):宿主登录体系已知时直接传入,优先于中心 AdminRoles 名单;
/// 缺省 null = 分发模式下按中心名单比对 CurrentUser(自定义模式恒按可设置处理)。
/// </summary>
public bool? IsAdmin { get; set; }
}
/// <summary>其他 .NET Framework 项目引用 Tools.Agent 后使用的稳定入口。</summary>
......@@ -31,6 +43,7 @@ namespace Tools.MyAgent
private readonly AgentRuntimeOptions _options;
private readonly AgentProductContext _productContext;
private readonly IReadOnlyList<DiagnosticRoot> _diagnosticRoots;
private readonly Support.CenterResolution _center;
/// <summary>当前运行时诊断根目录快照;返回独立副本,避免外部修改。</summary>
public IReadOnlyList<DiagnosticRoot> DiagnosticRoots
......@@ -62,18 +75,35 @@ namespace Tools.MyAgent
context.DiagnosticRoots = new[] { new DiagnosticRoot { Id = "source", Path = context.ProjectRoot, Kind = "source" } };
}
_productContext = ProductContextValidator.CreateSnapshot(context);
// 配置中心(config_center_plan.md P0):模式判定 + 分发模式拉取(内部三级降级,任何异常不上抛)
_center = Support.AgentCenterClient.Fetch(
AppDomain.CurrentDomain.BaseDirectory,
_options.Mode,
_options.IsAdmin,
_options.CurrentUser,
ProductLineId);
}
/// <summary>配置中心解析结果(v2.2);自定义模式下仅承载 Mode/IsAdmin,其余字段为空。</summary>
public Support.CenterResolution Center => _center;
/// <summary>生效模式(入参优先 / center.json 自动判定)。</summary>
public Support.AgentRunMode CenterMode => _center != null ? _center.Mode : Support.AgentRunMode.Custom;
/// <summary>当前用户是否管理员(宿主入参 &gt; 中心名单;自定义模式恒 true = 现状人人可设置)。</summary>
public bool CenterIsAdmin => _center != null ? _center.IsAdmin : true;
/// <summary>实际数据根目录。</summary>
public string DataRoot => string.IsNullOrEmpty(_options.DataRoot)
? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Agent")
? Support.AgentCenterClient.AgentRootPath(AppDomain.CurrentDomain.BaseDirectory)
: Path.GetFullPath(_options.DataRoot);
/// <summary>创建完整聊天窗体,可作为顶级窗体显示或嵌入宿主容器。</summary>
public wMyAgent CreateChatForm()
{
EnsureDefaultResources();
return new wMyAgent(DataRoot, _options.CurrentUser, ProductContext, _diagnosticRoots);
return new wMyAgent(DataRoot, _options.CurrentUser, ProductContext, _diagnosticRoots, _center);
}
/// <summary>创建已配置输出目录的知识库服务,供无 UI 宿主自行设置知识库根目录。</summary>
......@@ -124,7 +154,8 @@ namespace Tools.MyAgent
{
try
{
string path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Agent", "config", "AgentConfig.json");
Support.AgentCenterClient.MigrateLegacyLayout(AppDomain.CurrentDomain.BaseDirectory);
string path = Path.Combine(Support.AgentCenterClient.AgentRootPath(AppDomain.CurrentDomain.BaseDirectory), "config", "AgentConfig.json");
if (!File.Exists(path)) return null;
var cfg = Newtonsoft.Json.JsonConvert.DeserializeObject<Config.AgentConfig>(
File.ReadAllText(path, System.Text.Encoding.UTF8));
......@@ -143,7 +174,8 @@ namespace Tools.MyAgent
{
try
{
string path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Agent", "config", "AgentConfig.json");
Support.AgentCenterClient.MigrateLegacyLayout(AppDomain.CurrentDomain.BaseDirectory);
string path = Path.Combine(Support.AgentCenterClient.AgentRootPath(AppDomain.CurrentDomain.BaseDirectory), "config", "AgentConfig.json");
var root = File.Exists(path)
? Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path, System.Text.Encoding.UTF8))
: new Newtonsoft.Json.Linq.JObject();
......@@ -160,7 +192,7 @@ namespace Tools.MyAgent
private void EnsureDefaultResources()
{
Directory.CreateDirectory(DataRoot);
string defaults = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Agent");
string defaults = Support.AgentCenterClient.AgentRootPath(AppDomain.CurrentDomain.BaseDirectory);
if (string.Equals(Path.GetFullPath(defaults).TrimEnd('\\'), DataRoot.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase)
|| !Directory.Exists(defaults)) return;
CopyMissing(Path.Combine(defaults, "prompts"), Path.Combine(DataRoot, "prompts"));
......
......@@ -521,7 +521,7 @@ namespace Tools.MyAgent.Forms
{
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 agentRoot = global::Tools.MyAgent.Support.AgentCenterClient.AgentRootPath(AppDomain.CurrentDomain.BaseDirectory);
string path = BackupHelper.BackupAgent(agentRoot, null, dlg.FileName);
MessageBox.Show(this, string.IsNullOrEmpty(path) ? "导出失败" : "已导出:" + path,
"诊断包", MessageBoxButtons.OK, MessageBoxIcon.Information);
......
......@@ -39,7 +39,6 @@
this.cmbOutcome = new System.Windows.Forms.ComboBox();
this.btnRefresh = new System.Windows.Forms.Button();
this.btnExport = new System.Windows.Forms.Button();
this.btnConn = new System.Windows.Forms.Button();
this.splitMain = new System.Windows.Forms.SplitContainer();
this.dgv = new System.Windows.Forms.DataGridView();
this.tabDetails = new System.Windows.Forms.TabControl();
......@@ -75,7 +74,6 @@
this.panelTop.Controls.Add(this.cmbOutcome);
this.panelTop.Controls.Add(this.btnRefresh);
this.panelTop.Controls.Add(this.btnExport);
this.panelTop.Controls.Add(this.btnConn);
this.panelTop.Dock = System.Windows.Forms.DockStyle.Top;
this.panelTop.Location = new System.Drawing.Point(0, 0);
this.panelTop.Name = "panelTop";
......@@ -167,16 +165,6 @@
this.btnExport.UseVisualStyleBackColor = true;
this.btnExport.Click += new System.EventHandler(this.OnExportCsvClick);
//
// btnConn(窗口内数据库连接配置:IP/账号/密码/库下拉)
//
this.btnConn.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
this.btnConn.Margin = new System.Windows.Forms.Padding(6, 4, 3, 0);
this.btnConn.Name = "btnConn";
this.btnConn.Size = new System.Drawing.Size(88, 28);
this.btnConn.Text = "连接配置";
this.btnConn.UseVisualStyleBackColor = true;
this.btnConn.Click += new System.EventHandler(this.OnConnConfigClick);
//
// splitMain
//
this.splitMain.Dock = System.Windows.Forms.DockStyle.Fill;
......@@ -333,7 +321,6 @@
private System.Windows.Forms.ComboBox cmbOutcome;
private System.Windows.Forms.Button btnRefresh;
private System.Windows.Forms.Button btnExport;
private System.Windows.Forms.Button btnConn;
private System.Windows.Forms.SplitContainer splitMain;
private System.Windows.Forms.DataGridView dgv;
private System.Windows.Forms.TabControl tabDetails;
......
......@@ -13,15 +13,15 @@ namespace Tools.MyAgent.Forms
/// <summary>
/// Agent 日志查看窗口(agent_analysis_plan.md v1.3 / Q5 决议):只读查询对话上报记录(Agent_RunLog),
/// 左列表 + 右详情(两个 Tab:「问答」= 提问+回答聊天式同页渲染;「指标」= 运行明细),支持日期 / 用户 / 结果过滤与 CSV 导出。
/// <para>**独立窗口**:由宿主"实施工具"菜单直接打开(<see cref="AgentRuntime.CreateRunLogForm"/>),
/// 无任何聊天命令唤出;窗口侧永不写库。</para>
/// <para>**入口(2026-10-22)**:设置页「高级 → 对话上报 → 查看上报日志」按钮打开(连接取页面当前填写值);
/// 原实施工具菜单入口与窗口内"连接配置"按钮已移除——连接统一在设置页管理。</para>
/// <para>控件布局由 AgentRunLogForm.Designer.cs(设计器)实现;本文件仅含逻辑。</para>
/// </summary>
public partial class AgentRunLogForm : Form
{
private const int MaxRows = 500;
/// <summary>当前上报连接(明文,窗口内可经"连接配置"切换)。</summary>
/// <summary>当前上报连接(明文,由设置页「对话上报」传入,窗口内只读)。</summary>
private string _connection;
/// <summary>当前日志表名。</summary>
private string _table;
......@@ -30,7 +30,7 @@ namespace Tools.MyAgent.Forms
private string _currentRunId;
/// <summary>
/// 模块系统入口(Activator 无参创建,实施工具菜单直接打开):自读本机默认 Agent 配置取上报连接;
/// 模块系统入口(Activator 无参创建兜底):自读本机默认 Agent 配置取上报连接;
/// 取不到 = 未配置(查询时提示先在设置页配置)。VS 设计器实例化时同样走此路径(读不到配置也不抛异常)。
/// </summary>
public AgentRunLogForm()
......@@ -297,36 +297,6 @@ namespace Tools.MyAgent.Forms
txtMetrics.Text = metrics;
}
// ══════════════ 连接配置(窗口内直配,2026-10-19) ══════════════
/// <summary>
/// "连接配置":IP → 账号 → 密码 → 库(下拉拉取 sys.databases);
/// 确定后写回 AgentConfig.json(DPAPI 加密)并刷新本窗口的连接与列表。
/// </summary>
private void OnConnConfigClick(object sender, EventArgs e)
{
using (var dlg = new AgentDbConfigForm(_connection, _table))
{
if (dlg.ShowDialog(this) != DialogResult.OK) return;
string plain = dlg.BuildConnectionString();
string table = dlg.Table;
if (!AgentRuntime.SaveLocalRunDbConfig(plain, table, dlg.EnableReporting))
{
AgentUtils.Toast("保存到配置文件失败(AgentConfig.json 不可写)", 4000);
return;
}
_connection = plain; // 本窗口立即生效,无需重开
_table = table;
_currentRunId = null;
SetDetailText("(已切换连接,请选择记录)", "(已切换连接)");
LoadUsers();
RefreshList();
AgentUtils.Toast(dlg.EnableReporting
? "已保存并启用对话上报;引擎侧在下次应用配置(重启客户端或设置页确定)后生效"
: "已保存连接;对话上报保持关闭(仅本窗口查询用)", 4000);
}
}
// ══════════════ 导出 ══════════════
private void OnExportCsvClick(object sender, EventArgs e)
......
......@@ -110,11 +110,16 @@ namespace Tools.MyAgent.Forms
{
}
public AgentSettingsForm(AgentConfig config, string agentRoot)
/// <summary>
/// 带参构造。
/// <para><paramref name="focusRunDbTab"/>(2026-10-22):分发模式未配置数据库连接时,
/// 管理员打开设置自动跳到「高级 → 对话上报」页,引导先完成配置。</para>
/// </summary>
public AgentSettingsForm(AgentConfig config, string agentRoot, bool focusRunDbTab = false)
{
_config = config ?? new AgentConfig();
_agentRoot = string.IsNullOrEmpty(agentRoot)
? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Agent")
? global::Tools.MyAgent.Support.AgentCenterClient.AgentRootPath(AppDomain.CurrentDomain.BaseDirectory)
: Path.GetFullPath(agentRoot);
InitializeComponent();
......@@ -127,6 +132,11 @@ namespace Tools.MyAgent.Forms
BindConfig(); // 配置值回填控件(控件创建见 Designer.cs)
ApplyProviderMode(); // 布局联动:BaseUrl 行显隐
ApplyKbMode(); // 布局联动:共享 账号/密码/测试连接 行显隐
if (focusRunDbTab && _tabRunDb != null)
{
tabMain.SelectedTab = tabAdv; // 外层先定位「高级」页
tabAdvSub.SelectedTab = _tabRunDb; // 内层定位「对话上报」
}
}
// ══ v4.1:远程知识库条目接口(程序化创建,避免侵入设计器) ══
......@@ -217,6 +227,8 @@ namespace Tools.MyAgent.Forms
private TextBox txtRunDbConn;
private TextBox txtRunDbTable;
private Button btnTestRunDb;
private Button btnViewRunLog;
private TabPage _tabRunDb; // 分发模式未配置连接时自动跳转目标(2026-10-22)
/// <summary>
/// 高级配置新增二级 Tab「对话上报」:每轮对话完成后自动上报一条记录到 SQL Server
......@@ -255,9 +267,16 @@ namespace Tools.MyAgent.Forms
Size = new Size(96, 26),
};
btnTestRunDb.Click += new EventHandler(OnTestRunDb);
btnViewRunLog = new Button
{
Text = "查看上报日志",
Location = new Point(372, 136),
Size = new Size(110, 26),
};
btnViewRunLog.Click += new EventHandler(OnViewRunLog);
var hint = new Label
{
Text = "提问与回答原文会存入该库,用于统计分析与体验优化。查看入口:实施工具 → Agent日志。\r\n示例:Server=主机;Database=库名;User Id=账号;Password=密码;Trusted_Connection=False",
Text = "提问与回答原文会存入该库,用于统计分析与体验优化。点击「查看上报日志」可查询已上报记录。\r\n示例:Server=主机;Database=库名;User Id=账号;Password=密码;Trusted_Connection=False",
Location = new Point(12, 172),
AutoSize = false,
Size = new Size(556, 40),
......@@ -270,7 +289,9 @@ namespace Tools.MyAgent.Forms
tabRunDb.Controls.Add(lblTable);
tabRunDb.Controls.Add(txtRunDbTable);
tabRunDb.Controls.Add(btnTestRunDb);
tabRunDb.Controls.Add(btnViewRunLog);
tabRunDb.Controls.Add(hint);
_tabRunDb = tabRunDb;
tabAdvSub.TabPages.Add(tabRunDb);
}
......@@ -305,6 +326,25 @@ namespace Tools.MyAgent.Forms
}
}
/// <summary>
/// 查看上报日志(2026-10-22):用当前页面填写的连接串/表名直开 <see cref="AgentRunLogForm"/>
/// (未保存的修改也能即时预览);空连接串时提示先填写。窗口内只读,不再提供连接配置。
/// </summary>
private void OnViewRunLog(object sender, EventArgs e)
{
string conn = txtRunDbConn.Text.Trim();
if (conn.Length == 0)
{
AgentUtils.Toast("请先填写连接串");
return;
}
string table = string.IsNullOrWhiteSpace(txtRunDbTable.Text) ? "Agent_RunLog" : txtRunDbTable.Text.Trim();
using (var dlg = new AgentRunLogForm(conn, table))
{
dlg.ShowDialog(this);
}
}
// ══ P3:工具级权限(按角色) ══
private TextBox txtToolRoles;
......
......@@ -64,7 +64,7 @@ namespace Tools.MyAgent.Kb.Remote
private static string CacheDir
{
get { return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Agent", "cache", "remote_kb"); }
get { return Path.Combine(global::Tools.MyAgent.Support.AgentCenterClient.AgentRootPath(AppDomain.CurrentDomain.BaseDirectory), "cache", "remote_kb"); }
}
/// <summary>一个远程源(解析自 URL 模板;无效模板在解析期静默丢弃)</summary>
......
using System;
using System.Collections.Generic;
using System.Data.SqlClient;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Tools.MyAgent.Config;
namespace Tools.MyAgent.Support
{
/// <summary>Agent 运行模式(config_center_plan.md v2.2)。</summary>
public enum AgentRunMode
{
/// <summary>自定义模式(现状):本地 AgentConfig.json,人人可设置,零改动。</summary>
Custom = 0,
/// <summary>分发模式:配置中心(SQL Server),管理员写透、普通用户拉取。</summary>
Distribution = 1,
}
/// <summary>
/// 配置中心解析结果(config_center_plan.md v2.0–v2.3)。
/// <para>承载中心库拉取/快照/缺省三级来源合并后的配置,由 <see cref="AgentCenterClient.Fetch"/> 产出;
/// 通过 <see cref="ApplyTo"/> 覆盖到 AgentConfig(字段级"非空才覆盖")。</para>
/// </summary>
public sealed class CenterResolution
{
/// <summary>生效模式(Custom = 仅承载模式与角色,其余字段为空)。</summary>
public AgentRunMode Mode { get; set; }
/// <summary>是否管理员:宿主入参优先,否则中心 AdminRoles 名单比对;Custom 模式恒 true(现状人人可设置)。</summary>
public bool IsAdmin { get; set; }
/// <summary>配置来源:custom / db / snapshot / none。</summary>
public string Source { get; set; } = "none";
// ── 模型(Agent_Center_Model) ──
public string Provider { get; set; }
public string BaseUrl { get; set; }
public string ModelName { get; set; }
public string ApiKey { get; set; }
public double? Temperature { get; set; }
public int? MaxTokens { get; set; }
public int? MaxContextTokens { get; set; }
public string RemoteKbJson { get; set; }
// ── 知识库(Agent_Center_Kb,{CLIENT_ROOT} 已展开) ──
public string KbRoot { get; set; }
public string SearchScope { get; set; }
public bool KbConfigured { get; set; }
public string KbMode { get; set; }
public string ShareUser { get; set; }
public string SharePassword { get; set; }
public KbProbeResult KbProbe { get; set; }
// ── 全局(Agent_Center_Global) ──
public string AdminRoles { get; set; }
public bool? RunDbEnabled { get; set; }
public string RunDbConnection { get; set; }
public string RunDbTable { get; set; }
public bool? TelemetryEnabled { get; set; }
public string ObservabilityLevel { get; set; }
/// <summary>缺项清单(仅分发模式计算):ApiKey / BaseUrl / Model。KB 缺省不进清单(v2.3:默认 {CLIENT_ROOT} 静默兜底)。</summary>
public List<string> MissingItems { get; } = new List<string>();
/// <summary>把本分辨率结果覆盖到 AgentConfig(字段级"非空才覆盖";中心未配置项回落本地)。</summary>
public void ApplyTo(AgentConfig cfg)
{
try
{
if (cfg == null) return;
if (NotBlack(Provider)) cfg.Provider = Provider;
if (NotBlack(BaseUrl)) cfg.BaseUrl = BaseUrl;
if (NotBlack(ModelName)) cfg.Model = ModelName;
if (NotBlack(ApiKey)) cfg.ApiKey = ApiKey;
if (Temperature.HasValue) cfg.Temperature = Temperature.Value;
if (MaxTokens.HasValue && MaxTokens.Value > 0) cfg.MaxTokens = MaxTokens.Value;
if (MaxContextTokens.HasValue && MaxContextTokens.Value > 0) cfg.MaxContextTokens = MaxContextTokens.Value;
if (NotBlack(RemoteKbJson))
{
try
{
var list = JsonConvert.DeserializeObject<List<string>>(RemoteKbJson);
if (list != null) cfg.RemoteKbEntries = list;
}
catch { }
}
if (NotBlack(KbRoot)) cfg.KnowledgeBasePath = KbRoot;
if (NotBlack(SearchScope)) cfg.SearchScope = SearchScope;
if (NotBlack(KbMode)) cfg.KbMode = KbMode;
if (NotBlack(ShareUser)) cfg.ShareUser = ShareUser;
if (NotBlack(SharePassword)) cfg.SharePassword = SharePassword;
if (NotBlack(AdminRoles)) cfg.AdminRoles = AdminRoles;
if (RunDbEnabled.HasValue) cfg.RunDbEnabled = RunDbEnabled.Value;
if (NotBlack(RunDbConnection)) cfg.RunDbConnection = RunDbConnection;
if (NotBlack(RunDbTable)) cfg.RunDbTable = RunDbTable;
if (TelemetryEnabled.HasValue) cfg.TelemetryEnabled = TelemetryEnabled.Value;
if (NotBlack(ObservabilityLevel)) cfg.ObservabilityLevel = ObservabilityLevel;
}
catch { }
}
internal static bool NotBlack(string s) { return !string.IsNullOrWhiteSpace(s); }
}
/// <summary>
/// 配置中心客户端(config_center_plan.md P0 读侧)。
/// <para>职责:模式判定(宿主入参优先 / center.json 自动判定兜底)→ 拉取中心四表 →
/// {CLIENT_ROOT} 展开 → 角色判定 → 缺项计算 → 快照读写(离线降级)。</para>
/// <para>红线(与 AgentTelemetry 同规):本类所有 public 方法体首句必须是 try —— 任何异常不上抛、不阻塞启动。</para>
/// <para>安全:ApiKey 等敏感列 AES-256(密钥内嵌派生,防库备份肉眼泄露);快照文件只存密文。</para>
/// </summary>
public static class AgentCenterClient
{
// ══════════════ 常量 ══════════════
/// <summary>{CLIENT_ROOT} 占位符(v2.4 路径统一规范:仅本地文件夹生效;共享文件夹 UNC 原样存储)。</summary>
public const string ClientRootPlaceholder = "{CLIENT_ROOT}";
/// <summary>AES 密文前缀。</summary>
public const string SecretPrefix = "aes:";
private const string SnapshotFileName = "center_cache.json";
private const string ConnectTimeoutToken = "Connect Timeout";
// ══════════════ 模式判定 ══════════════
/// <summary>解析 Agent 数据根目录:程序目录本身已是 Agent 时不再重复追加 Agent。</summary>
public static string AgentRootPath(string baseDirectory)
{
try
{
string root = NormalizeRoot(baseDirectory);
return string.Equals(Path.GetFileName(root), "Agent", StringComparison.OrdinalIgnoreCase)
? root
: Path.Combine(root, "Agent");
}
catch { return "Agent"; }
}
/// <summary>
/// 将旧版程序目录下的 Agent\Agent\config 配置迁移到外层 Agent\config。
/// <para>目标文件存在时保留目标配置,并将旧文件改名为 .legacy 备份;不会直接删除旧配置。</para>
/// </summary>
public static void MigrateLegacyLayout(string baseDirectory)
{
try
{
string root = AgentRootPath(baseDirectory);
string legacyRoot = Path.Combine(root, "Agent");
if (string.Equals(root.TrimEnd('\\'), legacyRoot.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase)) return;
string source = Path.Combine(legacyRoot, "config");
if (!Directory.Exists(source)) return;
string target = Path.Combine(root, "config");
Directory.CreateDirectory(target);
foreach (string file in Directory.GetFiles(source))
MoveOrPreserveLegacyFile(file, Path.Combine(target, Path.GetFileName(file)));
}
catch { }
}
private static void MoveOrPreserveLegacyFile(string source, string target)
{
try
{
if (!File.Exists(source)) return;
if (!File.Exists(target))
{
File.Move(source, target);
return;
}
string backup = target + ".legacy-" + DateTime.Now.ToString("yyyyMMddHHmmssfff");
int index = 1;
while (File.Exists(backup))
backup = target + ".legacy-" + DateTime.Now.ToString("yyyyMMddHHmmssfff") + "-" + index++;
File.Move(source, backup);
}
catch { }
}
/// <summary>center.json 固定位置:Agent 数据根目录下的 config\center.json。</summary>
public static string CenterJsonPath(string baseDirectory)
{
try
{
return Path.Combine(AgentRootPath(baseDirectory), "config", "center.json");
}
catch { return Path.Combine("Agent", "config", "center.json"); }
}
/// <summary>
/// 模式判定(v2.2):宿主入参显式传入时以入参为准(显式优于隐式);
/// 未传时自动判定 center.json 是否存在(覆盖独立运行场景)。任何异常回退 Custom。
/// </summary>
public static AgentRunMode DetectMode(AgentRunMode? explicitMode, string baseDirectory)
{
try
{
if (explicitMode.HasValue) return explicitMode.Value;
return File.Exists(CenterJsonPath(baseDirectory)) ? AgentRunMode.Distribution : AgentRunMode.Custom;
}
catch
{
return explicitMode.HasValue ? explicitMode.Value : AgentRunMode.Custom;
}
}
// ══════════════ 主入口 ══════════════
/// <summary>
/// 拉取并构建配置中心解析结果。任何失败均不抛出;分发模式下按
/// 中心库 → 本地快照 → 缺省 三级来源降级。
/// <para>中心引导连接优先读取 center.json;缺失时兼容使用本机已保存的 RunDbConnection,
/// 以支持首次配置时用“对话上报”连接完成中心库引导。</para>
/// </summary>
public static CenterResolution Fetch(string baseDirectory, AgentRunMode? explicitMode, bool? explicitIsAdmin,
string currentUser, string productLineId)
{
try
{
MigrateLegacyLayout(baseDirectory);
AgentRunMode mode = DetectMode(explicitMode, baseDirectory);
if (mode == AgentRunMode.Custom)
{
// 自定义模式:现状行为,人人可设置;不读中心、不写快照
return new CenterResolution { Mode = AgentRunMode.Custom, IsAdmin = explicitIsAdmin ?? true, Source = "custom" };
}
CenterPull pull = null;
string source = "none";
string connection = ReadBootstrap(baseDirectory);
if (!NotBlack(connection))
{
connection = ReadLocalRunDbConnection(baseDirectory);
if (NotBlack(connection)) EnsureBootstrapFile(baseDirectory, connection);
}
if (NotBlack(connection))
{
pull = PullFromDb(connection, productLineId);
if (pull != null)
{
source = "db";
WriteSnapshot(baseDirectory, pull);
}
}
if (pull == null)
{
pull = ReadSnapshot(baseDirectory);
if (pull != null) source = "snapshot";
}
return Build(pull, source, mode, explicitIsAdmin, currentUser, baseDirectory);
}
catch
{
var fb = new CenterResolution { Mode = AgentRunMode.Distribution, Source = "none" };
fb.IsAdmin = explicitIsAdmin ?? false;
fb.MissingItems.Add("ApiKey");
fb.MissingItems.Add("BaseUrl");
fb.MissingItems.Add("Model");
return fb;
}
}
// ══════════════ {CLIENT_ROOT} 展开 / 反折(v2.4:仅本地文件夹生效,UNC 共享保持原样) ══════════════
/// <summary>把模板中的 {CLIENT_ROOT} 替换为程序根目录(去尾 \);不含占位符原样返回(UNC 共享路径天然走此分支);空模板返回空串。</summary>
public static string ExpandClientRoot(string template, string baseDirectory)
{
try
{
if (string.IsNullOrEmpty(template)) return "";
string root = NormalizeRoot(baseDirectory);
return template.Replace(ClientRootPlaceholder, root);
}
catch { return template ?? ""; }
}
/// <summary>
/// 把本机**本地**绝对路径反折回 {CLIENT_ROOT} 占位符(管理员保存用,P1)。
/// <para>v2.4 例外:共享文件夹(UNC 路径 \\server\share\…)保持原有模式——各终端访问同一网络路径,
/// 无需按本机展开,原样存储原样使用;程序目录本身在 UNC 上时同样不折叠。</para>
/// </summary>
public static string FoldClientRoot(string path, string baseDirectory)
{
try
{
if (string.IsNullOrEmpty(path)) return path ?? "";
if (path.StartsWith("\\\\") || path.StartsWith("//")) return path; // UNC 共享:原样
string root = NormalizeRoot(baseDirectory);
if (root.StartsWith("\\\\") || root.StartsWith("//")) return path; // 程序目录在共享上:不折叠
if (path.StartsWith(root, StringComparison.OrdinalIgnoreCase))
return ClientRootPlaceholder + path.Substring(root.Length);
return path;
}
catch { return path ?? ""; }
}
// ══════════════ 敏感列 AES-256 ══════════════
private static byte[] _aesKey;
private static byte[] GetAesKey()
{
if (_aesKey == null)
{
using (var sha = new SHA256CryptoServiceProvider())
_aesKey = sha.ComputeHash(Encoding.UTF8.GetBytes("EasyWork.Agent.Center.v1|Ftt"));
}
return _aesKey;
}
/// <summary>AES-256-CBC 加密(随机 IV 前置,base64,"aes:" 前缀);空串原样返回;失败回退明文。</summary>
public static string ProtectSecret(string plain)
{
try
{
if (string.IsNullOrEmpty(plain)) return plain ?? "";
using (var aes = new AesCryptoServiceProvider())
{
aes.Key = GetAesKey();
aes.GenerateIV();
using (var enc = aes.CreateEncryptor())
{
byte[] data = Encoding.UTF8.GetBytes(plain);
byte[] cipher = enc.TransformFinalBlock(data, 0, data.Length);
byte[] output = new byte[aes.IV.Length + cipher.Length];
Buffer.BlockCopy(aes.IV, 0, output, 0, aes.IV.Length);
Buffer.BlockCopy(cipher, 0, output, aes.IV.Length, cipher.Length);
return SecretPrefix + Convert.ToBase64String(output);
}
}
}
catch { return plain ?? ""; }
}
/// <summary>解密 "aes:" 密文;非本前缀视为明文原样返回(兼容);解密失败返回空串(视为未配置)。</summary>
public static string UnprotectSecret(string stored)
{
try
{
if (string.IsNullOrEmpty(stored)) return stored ?? "";
if (!stored.StartsWith(SecretPrefix, StringComparison.Ordinal)) return stored;
byte[] all = Convert.FromBase64String(stored.Substring(SecretPrefix.Length));
using (var aes = new AesCryptoServiceProvider())
{
aes.Key = GetAesKey();
int ivLen = aes.BlockSize / 8;
byte[] iv = new byte[ivLen];
byte[] cipher = new byte[all.Length - ivLen];
Buffer.BlockCopy(all, 0, iv, 0, ivLen);
Buffer.BlockCopy(all, ivLen, cipher, 0, cipher.Length);
aes.IV = iv;
using (var dec = aes.CreateDecryptor())
{
byte[] plain = dec.TransformFinalBlock(cipher, 0, cipher.Length);
return Encoding.UTF8.GetString(plain);
}
}
}
catch { return ""; }
}
// ══════════════ 写侧(P1:管理员保存写透) ══════════════
/// <summary>连接串附加启动短超时(幂等;已有 Connect Timeout 则不动)。</summary>
private static string WithShortTimeout(string connection)
{
try
{
if (string.IsNullOrEmpty(connection)) return connection;
if (connection.IndexOf(ConnectTimeoutToken, StringComparison.OrdinalIgnoreCase) >= 0) return connection;
return connection + ";Connect Timeout=3";
}
catch { return connection; }
}
/// <summary>
/// 测试中心连接(设置页「测试连接」/ P2 /diag 用):Open + 幂等建表。
/// <para>返回错误消息,成功返回 null(不抛异常——遵守本类 public 不上抛红线;不写任何数据行)。</para>
/// </summary>
public static string TestCenterConnection(string connection)
{
try
{
if (!NotBlack(connection)) return "连接串为空";
using (var conn = new SqlConnection(WithShortTimeout(connection)))
{
conn.Open();
EnsureCenterTablesOn(conn);
}
return null;
}
catch (Exception ex) { return ex.Message; }
}
/// <summary>
/// 管理员保存写透(P1):把 AgentConfig 的中心管辖区字段 Upsert 到中心库,路径反折 {CLIENT_ROOT}。
/// <para>中心管辖区 = 模型(Provider/BaseUrl/Model/ApiKey/生成参数/远程KB)+
/// 知识库(根/范围/方式/共享凭据)+ Global(AdminRoles/RunDb/遥测)。</para>
/// <para>返回错误消息,成功返回 null。</para>
/// </summary>
public static string SaveFromConfig(string baseDirectory, AgentConfig cfg)
{
try
{
if (cfg == null) return "配置对象为空";
string connection = ReadBootstrap(baseDirectory);
if (!NotBlack(connection)) connection = SecretProtector.Unprotect(cfg.RunDbConnection);
if (!NotBlack(connection)) connection = ReadLocalRunDbConnection(baseDirectory);
if (!NotBlack(ReadBootstrap(baseDirectory))) EnsureBootstrapFile(baseDirectory, connection);
if (!NotBlack(connection)) return "未找到中心库连接(Agent/config/center.json 或本地 RunDbConnection)";
var model = new CenterModelRow
{
Name = "默认",
IsDefault = true,
Provider = cfg.Provider ?? "",
BaseUrl = cfg.BaseUrl ?? "",
Model = cfg.Model ?? "",
ApiKeyEnc = ProtectSecret(cfg.ApiKey ?? ""),
Temperature = cfg.Temperature,
MaxTokens = cfg.MaxTokens,
MaxContextTokens = cfg.MaxContextTokens,
RemoteKbJson = JsonConvert.SerializeObject(cfg.RemoteKbEntries ?? new List<string>()),
};
var kb = new CenterKbRow
{
Name = "默认",
IsDefault = true,
KbRootTpl = FoldClientRoot(cfg.KnowledgeBasePath ?? "", baseDirectory),
SearchScopeTpl = FoldClientRoot(cfg.SearchScope ?? "", baseDirectory),
KbMode = cfg.KbMode ?? "",
ShareUser = cfg.ShareUser ?? "",
SharePwdEnc = ProtectSecret(cfg.SharePassword ?? ""),
};
var global = new CenterGlobalRow
{
AdminRoles = cfg.AdminRoles ?? "",
RunDbEnabled = cfg.RunDbEnabled,
RunDbConnection = ProtectSecret(cfg.RunDbConnection ?? ""),
RunDbTable = cfg.RunDbTable ?? "Agent_RunLog",
TelemetryEnabled = cfg.TelemetryEnabled,
ObservabilityLevel = cfg.ObservabilityLevel ?? "basic",
};
return SaveCenter(connection, model, kb, global);
}
catch (Exception ex) { return ex.Message; }
}
/// <summary>Upsert 三表(模型/KB 写 IsDefault=1 行,Global 单行);缺表自动创建。返回错误消息,成功返回 null。</summary>
public static string SaveCenter(string connection, CenterModelRow model, CenterKbRow kb, CenterGlobalRow global)
{
try
{
if (!NotBlack(connection)) return "连接串为空";
using (var conn = new SqlConnection(WithShortTimeout(connection)))
{
conn.Open();
EnsureCenterTablesOn(conn);
string updatedBy = (Environment.UserName ?? "") + "@" + (Environment.MachineName ?? "");
if (model != null) UpsertModel(conn, model, updatedBy);
if (kb != null) UpsertKb(conn, kb, updatedBy);
if (global != null) UpsertGlobal(conn, global, updatedBy);
}
return null;
}
catch (Exception ex) { return ex.Message; }
}
/// <summary>幂等建表(四表;IF OBJECT_ID NULL 才建,管理员首次发布即完成中心库初始化)。</summary>
private static void EnsureCenterTablesOn(SqlConnection conn)
{
ExecOrNull(conn,
"IF OBJECT_ID(N'dbo." + TModel + "', N'U') IS NULL " +
"CREATE TABLE dbo." + TModel + " (" +
"Id INT IDENTITY(1,1) PRIMARY KEY, " +
"Name NVARCHAR(64) NOT NULL UNIQUE, " +
"IsDefault BIT NOT NULL DEFAULT 0, " +
"Provider NVARCHAR(32) NOT NULL DEFAULT '', " +
"BaseUrl NVARCHAR(256) NOT NULL DEFAULT '', " +
"Model NVARCHAR(128) NOT NULL DEFAULT '', " +
"ApiKeyEnc NVARCHAR(MAX) NULL, " +
"Temperature FLOAT NOT NULL DEFAULT 0.3, " +
"MaxTokens INT NOT NULL DEFAULT 16384, " +
"MaxContextTokens INT NOT NULL DEFAULT 64000, " +
"RemoteKbJson NVARCHAR(MAX) NULL, " +
"UpdatedAt DATETIME2(3) NOT NULL DEFAULT SYSDATETIME(), " +
"UpdatedBy NVARCHAR(64) NOT NULL DEFAULT '');");
ExecOrNull(conn,
"IF OBJECT_ID(N'dbo." + TKb + "', N'U') IS NULL " +
"CREATE TABLE dbo." + TKb + " (" +
"Id INT IDENTITY(1,1) PRIMARY KEY, " +
"Name NVARCHAR(64) NOT NULL UNIQUE, " +
"IsDefault BIT NOT NULL DEFAULT 0, " +
"KbRootTpl NVARCHAR(512) NOT NULL DEFAULT '', " +
"SearchScopeTpl NVARCHAR(512) NULL, " +
"KbMode VARCHAR(8) NOT NULL DEFAULT '', " +
"ShareUser NVARCHAR(64) NOT NULL DEFAULT '', " +
"SharePwdEnc NVARCHAR(MAX) NULL, " +
"UpdatedAt DATETIME2(3) NOT NULL DEFAULT SYSDATETIME(), " +
"UpdatedBy NVARCHAR(64) NOT NULL DEFAULT '');");
ExecOrNull(conn,
"IF OBJECT_ID(N'dbo." + TAssign + "', N'U') IS NULL " +
"CREATE TABLE dbo." + TAssign + " (" +
"Id INT IDENTITY(1,1) PRIMARY KEY, " +
"MachineName NVARCHAR(64) NOT NULL, " +
"ProductLineId NVARCHAR(64) NOT NULL DEFAULT '', " +
"ModelId INT NULL, " +
"KbId INT NULL, " +
"Remark NVARCHAR(256) NOT NULL DEFAULT '', " +
"UpdatedAt DATETIME2(3) NOT NULL DEFAULT SYSDATETIME(), " +
"CONSTRAINT UX_" + TAssign + " UNIQUE (MachineName, ProductLineId));");
ExecOrNull(conn,
"IF OBJECT_ID(N'dbo." + TGlobal + "', N'U') IS NULL " +
"CREATE TABLE dbo." + TGlobal + " (" +
"Id INT IDENTITY(1,1) PRIMARY KEY, " +
"AdminRoles NVARCHAR(256) NOT NULL DEFAULT '', " +
"RunDbEnabled BIT NOT NULL DEFAULT 0, " +
"RunDbConnection NVARCHAR(MAX) NULL, " +
"RunDbTable NVARCHAR(64) NOT NULL DEFAULT 'Agent_RunLog', " +
"TelemetryEnabled BIT NOT NULL DEFAULT 1, " +
"ObservabilityLevel VARCHAR(8) NOT NULL DEFAULT 'basic', " +
"UpdatedAt DATETIME2(3) NOT NULL DEFAULT SYSDATETIME(), " +
"UpdatedBy NVARCHAR(64) NOT NULL DEFAULT '');");
}
/// <summary>对已打开连接执行单条 SQL,失败静默(建表/索引等幂等语句用)。</summary>
private static void ExecOrNull(SqlConnection conn, string sql)
{
try
{
if (string.IsNullOrEmpty(sql)) return;
using (var cmd = new SqlCommand(sql, conn)) cmd.ExecuteNonQuery();
}
catch { }
}
private static void UpsertModel(SqlConnection conn, CenterModelRow m, string updatedBy)
{
try
{
using (var cmd = new SqlCommand(
"UPDATE " + TModel + " SET " +
"Provider = @Provider, BaseUrl = @BaseUrl, Model = @Model, ApiKeyEnc = @ApiKeyEnc, " +
"Temperature = @Temperature, MaxTokens = @MaxTokens, MaxContextTokens = @MaxContextTokens, " +
"RemoteKbJson = @RemoteKbJson, UpdatedAt = SYSDATETIME(), UpdatedBy = @UpdatedBy " +
"WHERE IsDefault = 1; " +
"IF @@ROWCOUNT = 0 INSERT INTO " + TModel + " " +
"(Name, IsDefault, Provider, BaseUrl, Model, ApiKeyEnc, Temperature, MaxTokens, MaxContextTokens, RemoteKbJson, UpdatedBy) " +
"VALUES (@Name, 1, @Provider, @BaseUrl, @Model, @ApiKeyEnc, @Temperature, @MaxTokens, @MaxContextTokens, @RemoteKbJson, @UpdatedBy);", conn))
{
BindModel(cmd, m, updatedBy);
cmd.ExecuteNonQuery();
}
}
catch { }
}
private static void BindModel(SqlCommand cmd, CenterModelRow m, string updatedBy)
{
cmd.Parameters.AddWithValue("@Name", (object)(m.Name ?? "默认") ?? "默认");
cmd.Parameters.AddWithValue("@Provider", m.Provider ?? "");
cmd.Parameters.AddWithValue("@BaseUrl", m.BaseUrl ?? "");
cmd.Parameters.AddWithValue("@Model", m.Model ?? "");
cmd.Parameters.AddWithValue("@ApiKeyEnc", (object)(m.ApiKeyEnc ?? "") ?? "");
cmd.Parameters.AddWithValue("@Temperature", m.Temperature.HasValue ? m.Temperature.Value : 0.3);
cmd.Parameters.AddWithValue("@MaxTokens", m.MaxTokens.HasValue ? m.MaxTokens.Value : 16384);
cmd.Parameters.AddWithValue("@MaxContextTokens", m.MaxContextTokens.HasValue ? m.MaxContextTokens.Value : 64000);
cmd.Parameters.AddWithValue("@RemoteKbJson", (object)(m.RemoteKbJson ?? "") ?? "");
cmd.Parameters.AddWithValue("@UpdatedBy", updatedBy ?? "");
}
private static void UpsertKb(SqlConnection conn, CenterKbRow k, string updatedBy)
{
try
{
using (var cmd = new SqlCommand(
"UPDATE " + TKb + " SET " +
"KbRootTpl = @KbRootTpl, SearchScopeTpl = @SearchScopeTpl, KbMode = @KbMode, " +
"ShareUser = @ShareUser, SharePwdEnc = @SharePwdEnc, UpdatedAt = SYSDATETIME(), UpdatedBy = @UpdatedBy " +
"WHERE IsDefault = 1; " +
"IF @@ROWCOUNT = 0 INSERT INTO " + TKb + " " +
"(Name, IsDefault, KbRootTpl, SearchScopeTpl, KbMode, ShareUser, SharePwdEnc, UpdatedBy) " +
"VALUES (@Name, 1, @KbRootTpl, @SearchScopeTpl, @KbMode, @ShareUser, @SharePwdEnc, @UpdatedBy);", conn))
{
cmd.Parameters.AddWithValue("@Name", (object)(k.Name ?? "默认") ?? "默认");
cmd.Parameters.AddWithValue("@KbRootTpl", k.KbRootTpl ?? "");
cmd.Parameters.AddWithValue("@SearchScopeTpl", (object)(k.SearchScopeTpl ?? "") ?? "");
cmd.Parameters.AddWithValue("@KbMode", (object)(k.KbMode ?? "") ?? "");
cmd.Parameters.AddWithValue("@ShareUser", k.ShareUser ?? "");
cmd.Parameters.AddWithValue("@SharePwdEnc", (object)(k.SharePwdEnc ?? "") ?? "");
cmd.Parameters.AddWithValue("@UpdatedBy", updatedBy ?? "");
cmd.ExecuteNonQuery();
}
}
catch { }
}
private static void UpsertGlobal(SqlConnection conn, CenterGlobalRow g, string updatedBy)
{
try
{
using (var cmd = new SqlCommand(
"IF EXISTS (SELECT 1 FROM " + TGlobal + ") " +
"UPDATE " + TGlobal + " SET " +
"AdminRoles = @AdminRoles, RunDbEnabled = @RunDbEnabled, RunDbConnection = @RunDbConnection, " +
"RunDbTable = @RunDbTable, TelemetryEnabled = @TelemetryEnabled, ObservabilityLevel = @ObservabilityLevel, " +
"UpdatedAt = SYSDATETIME(), UpdatedBy = @UpdatedBy; " +
"ELSE INSERT INTO " + TGlobal + " " +
"(AdminRoles, RunDbEnabled, RunDbConnection, RunDbTable, TelemetryEnabled, ObservabilityLevel, UpdatedBy) " +
"VALUES (@AdminRoles, @RunDbEnabled, @RunDbConnection, @RunDbTable, @TelemetryEnabled, @ObservabilityLevel, @UpdatedBy);", conn))
{
cmd.Parameters.AddWithValue("@AdminRoles", g.AdminRoles ?? "");
cmd.Parameters.AddWithValue("@RunDbEnabled", g.RunDbEnabled ?? false);
cmd.Parameters.AddWithValue("@RunDbConnection", (object)(g.RunDbConnection ?? "") ?? "");
cmd.Parameters.AddWithValue("@RunDbTable", g.RunDbTable ?? "Agent_RunLog");
cmd.Parameters.AddWithValue("@TelemetryEnabled", g.TelemetryEnabled ?? true);
cmd.Parameters.AddWithValue("@ObservabilityLevel", g.ObservabilityLevel ?? "basic");
cmd.Parameters.AddWithValue("@UpdatedBy", updatedBy ?? "");
cmd.ExecuteNonQuery();
}
}
catch { }
}
// ══════════════ 引导配置 ══════════════
/// <summary>读 center.json 的 Connection 字段;文件缺失/无连接串返回空。</summary>
private static string ReadBootstrap(string baseDirectory)
{
try
{
string path = CenterJsonPath(baseDirectory);
if (!File.Exists(path)) return "";
var obj = JObject.Parse(File.ReadAllText(path, Encoding.UTF8));
string conn = (string)obj["Connection"];
return (conn ?? "").Trim();
}
catch { return ""; }
}
/// <summary>
/// 读取本地 AgentConfig 中已保存的 RunDbConnection,作为中心库连接的首次引导回退。
/// <para>RunDbConnection 与配置中心按方案共用同一数据库;密文仅在当前用户上下文中解密。</para>
/// </summary>
private static string ReadLocalRunDbConnection(string baseDirectory)
{
try
{
string path = Path.Combine(AgentRootPath(baseDirectory), "config", "AgentConfig.json");
if (!File.Exists(path)) return "";
var cfg = JsonConvert.DeserializeObject<AgentConfig>(File.ReadAllText(path, Encoding.UTF8));
return cfg == null ? "" : (SecretProtector.Unprotect(cfg.RunDbConnection) ?? "").Trim();
}
catch { return ""; }
}
/// <summary>
/// center.json 缺失且已有中心库连接时创建引导文件;已有文件不覆盖。
/// <para>只有拿到非空连接串才创建,避免普通自定义实例因空文件被误判为分发模式。</para>
/// </summary>
private static void EnsureBootstrapFile(string baseDirectory, string connection)
{
try
{
if (!NotBlack(connection)) return;
string path = CenterJsonPath(baseDirectory);
if (File.Exists(path)) return;
string directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
var root = new JObject
{
["Connection"] = connection.Trim(),
["TablePrefix"] = "Agent_Center",
};
string temp = path + ".tmp";
File.WriteAllText(temp, root.ToString(Formatting.Indented), Encoding.UTF8);
if (File.Exists(path))
{
File.Delete(temp);
return;
}
File.Move(temp, path);
}
catch
{
try
{
string temp = CenterJsonPath(baseDirectory) + ".tmp";
if (File.Exists(temp)) File.Delete(temp);
}
catch { }
}
}
// ══════════════ 中心库拉取 ══════════════
private const string TModel = "Agent_Center_Model";
private const string TKb = "Agent_Center_Kb";
private const string TAssign = "Agent_Center_Assign";
private const string TGlobal = "Agent_Center_Global";
/// <summary>中心库原始拉取结果(快照序列化载体)。</summary>
public sealed class CenterPull
{
public string FetchedAt { get; set; }
public CenterModelRow Model { get; set; }
public CenterKbRow Kb { get; set; }
public CenterGlobalRow Global { get; set; }
}
public sealed class CenterModelRow
{
public int Id { get; set; }
public string Name { get; set; }
public bool IsDefault { get; set; }
public string Provider { get; set; }
public string BaseUrl { get; set; }
public string Model { get; set; }
public string ApiKeyEnc { get; set; }
public double? Temperature { get; set; }
public int? MaxTokens { get; set; }
public int? MaxContextTokens { get; set; }
public string RemoteKbJson { get; set; }
}
public sealed class CenterKbRow
{
public int Id { get; set; }
public string Name { get; set; }
public bool IsDefault { get; set; }
public string KbRootTpl { get; set; }
public string SearchScopeTpl { get; set; }
public string KbMode { get; set; }
public string ShareUser { get; set; }
public string SharePwdEnc { get; set; }
}
public sealed class CenterGlobalRow
{
public string AdminRoles { get; set; }
public bool? RunDbEnabled { get; set; }
public string RunDbConnection { get; set; }
public string RunDbTable { get; set; }
public bool? TelemetryEnabled { get; set; }
public string ObservabilityLevel { get; set; }
}
/// <summary>拉取中心四表(分配 → 默认 → Global);库不可达/缺表返回 null(由快照兜底)。解析优先级:分配行 &gt; 默认行。</summary>
private static CenterPull PullFromDb(string connection, string productLineId)
{
try
{
string connStr = connection;
if (connStr.IndexOf(ConnectTimeoutToken, StringComparison.OrdinalIgnoreCase) < 0)
connStr = connStr + ";Connect Timeout=3"; // 启动链路短超时(方案 §1:≤3s,不阻塞)
using (var conn = new SqlConnection(connStr))
{
conn.Open();
var pull = new CenterPull { FetchedAt = DateTime.UtcNow.ToString("o") };
string assignModelId = null, assignKbId = null;
try
{
using (var cmd = new SqlCommand(
"SELECT ModelId, KbId FROM " + TAssign + " WHERE MachineName = @m AND ProductLineId = @p", conn))
{
cmd.Parameters.AddWithValue("@m", Environment.MachineName ?? "");
cmd.Parameters.AddWithValue("@p", productLineId ?? "");
using (var r = cmd.ExecuteReader())
{
if (r.Read())
{
assignModelId = S(r, "ModelId");
assignKbId = S(r, "KbId");
}
}
}
}
catch { } // 分配表缺失/无行 = 用默认行
pull.Model = ReadModelRow(conn, assignModelId);
pull.Kb = ReadKbRow(conn, assignKbId);
pull.Global = ReadGlobalRow(conn);
return pull;
}
}
catch { return null; }
}
private static CenterModelRow ReadModelRow(SqlConnection conn, string assignId)
{
try
{
if (NotBlack(assignId))
{
var row = QueryModelRow(conn, "SELECT * FROM " + TModel + " WHERE Id = @id", assignId);
if (row != null) return row; // 分配行命中
}
return QueryModelRow(conn, "SELECT TOP 1 * FROM " + TModel + " WHERE IsDefault = 1", null);
}
catch { return null; }
}
private static CenterModelRow QueryModelRow(SqlConnection conn, string sql, string id)
{
try
{
using (var cmd = new SqlCommand(sql, conn))
{
if (id != null) cmd.Parameters.AddWithValue("@id", id);
using (var r = cmd.ExecuteReader())
{
if (!r.Read()) return null;
return new CenterModelRow
{
Id = I2(r, "Id"),
Name = S(r, "Name"),
IsDefault = B2(r, "IsDefault"),
Provider = S(r, "Provider"),
BaseUrl = S(r, "BaseUrl"),
Model = S(r, "Model"),
ApiKeyEnc = S(r, "ApiKeyEnc"),
Temperature = D(r, "Temperature"),
MaxTokens = I(r, "MaxTokens"),
MaxContextTokens = I(r, "MaxContextTokens"),
RemoteKbJson = S(r, "RemoteKbJson"),
};
}
}
}
catch { return null; }
}
private static CenterKbRow ReadKbRow(SqlConnection conn, string assignId)
{
try
{
if (NotBlack(assignId))
{
var row = QueryKbRow(conn, "SELECT * FROM " + TKb + " WHERE Id = @id", assignId);
if (row != null) return row;
}
return QueryKbRow(conn, "SELECT TOP 1 * FROM " + TKb + " WHERE IsDefault = 1", null);
}
catch { return null; }
}
private static CenterKbRow QueryKbRow(SqlConnection conn, string sql, string id)
{
try
{
using (var cmd = new SqlCommand(sql, conn))
{
if (id != null) cmd.Parameters.AddWithValue("@id", id);
using (var r = cmd.ExecuteReader())
{
if (!r.Read()) return null;
return new CenterKbRow
{
Id = I2(r, "Id"),
Name = S(r, "Name"),
IsDefault = B2(r, "IsDefault"),
KbRootTpl = S(r, "KbRootTpl"),
SearchScopeTpl = S(r, "SearchScopeTpl"),
KbMode = S(r, "KbMode"),
ShareUser = S(r, "ShareUser"),
SharePwdEnc = S(r, "SharePwdEnc"),
};
}
}
}
catch { return null; }
}
private static CenterGlobalRow ReadGlobalRow(SqlConnection conn)
{
try
{
using (var cmd = new SqlCommand("SELECT TOP 1 * FROM " + TGlobal, conn))
using (var r = cmd.ExecuteReader())
{
if (!r.Read()) return null;
return new CenterGlobalRow
{
AdminRoles = S(r, "AdminRoles"),
RunDbEnabled = B(r, "RunDbEnabled"),
RunDbConnection = S(r, "RunDbConnection"),
RunDbTable = S(r, "RunDbTable"),
TelemetryEnabled = B(r, "TelemetryEnabled"),
ObservabilityLevel = S(r, "ObservabilityLevel"),
};
}
}
catch { return null; }
}
// ══════════════ 快照(离线降级) ══════════════
private static string SnapshotPath(string baseDirectory)
{
try
{
return Path.Combine(AgentRootPath(baseDirectory), "config", SnapshotFileName);
}
catch { return SnapshotFileName; }
}
/// <summary>写快照(只存原始行数据,敏感列保持密文);失败静默。</summary>
private static void WriteSnapshot(string baseDirectory, CenterPull pull)
{
try
{
if (pull == null) return;
string path = SnapshotPath(baseDirectory);
Directory.CreateDirectory(Path.GetDirectoryName(path));
File.WriteAllText(path, JsonConvert.SerializeObject(pull, Formatting.Indented), Encoding.UTF8);
}
catch { }
}
/// <summary>读快照;文件缺失/损坏返回 null。</summary>
private static CenterPull ReadSnapshot(string baseDirectory)
{
try
{
string path = SnapshotPath(baseDirectory);
if (!File.Exists(path)) return null;
return JsonConvert.DeserializeObject<CenterPull>(File.ReadAllText(path, Encoding.UTF8));
}
catch { return null; }
}
// ══════════════ 解析构建 ══════════════
/// <summary>由原始拉取结果构建分辨率:角色判定 + {CLIENT_ROOT} 展开 + 缺项计算(v2.3:KB 缺省不进缺项)。</summary>
private static CenterResolution Build(CenterPull pull, string source, AgentRunMode mode,
bool? explicitIsAdmin, string currentUser, string baseDirectory)
{
try
{
var r = new CenterResolution { Mode = mode, Source = source };
var g = pull != null ? pull.Global : null;
// 角色判定:宿主入参 > 中心 AdminRoles 名单;Custom 模式恒 true(现状)
bool isAdmin;
if (explicitIsAdmin.HasValue) isAdmin = explicitIsAdmin.Value;
else isAdmin = MatchAdminRole(g != null ? g.AdminRoles : null, currentUser);
if (mode == AgentRunMode.Custom) isAdmin = explicitIsAdmin ?? true;
r.IsAdmin = isAdmin;
r.AdminRoles = g != null ? g.AdminRoles : null;
if (g != null)
{
r.RunDbEnabled = g.RunDbEnabled;
r.RunDbConnection = UnprotectSecret(g.RunDbConnection);
r.RunDbTable = g.RunDbTable;
r.TelemetryEnabled = g.TelemetryEnabled;
r.ObservabilityLevel = g.ObservabilityLevel;
}
var m = pull != null ? pull.Model : null;
if (m != null)
{
r.Provider = m.Provider;
r.BaseUrl = m.BaseUrl;
r.ModelName = m.Model;
r.ApiKey = UnprotectSecret(m.ApiKeyEnc);
r.Temperature = m.Temperature;
r.MaxTokens = m.MaxTokens;
r.MaxContextTokens = m.MaxContextTokens;
r.RemoteKbJson = m.RemoteKbJson;
}
// 缺项计算(仅分发模式;v2.3 KB 缺省不进缺项清单)
if (mode == AgentRunMode.Distribution)
{
AddMissing(r.MissingItems, "ApiKey", m != null ? r.ApiKey : null);
AddMissing(r.MissingItems, "BaseUrl", m != null ? m.BaseUrl : null);
AddMissing(r.MissingItems, "Model", m != null ? m.Model : null);
}
// 知识库:{CLIENT_ROOT} 展开 + 探测(缺省 → 宿主程序根目录,AgentKbProbe 内实现)
var kb = pull != null ? pull.Kb : null;
string tpl = kb != null ? kb.KbRootTpl : null;
r.KbConfigured = NotBlack(tpl);
r.KbProbe = AgentKbProbe.Probe(tpl, baseDirectory);
r.KbRoot = r.KbProbe.Resolved;
if (kb != null)
{
r.SearchScope = AgentCenterClient.ExpandClientRoot(kb.SearchScopeTpl, baseDirectory);
r.KbMode = kb.KbMode;
r.ShareUser = kb.ShareUser;
r.SharePassword = UnprotectSecret(kb.SharePwdEnc);
}
return r;
}
catch
{
var fb = new CenterResolution { Mode = mode, Source = "none" };
fb.IsAdmin = explicitIsAdmin ?? false;
return fb;
}
}
/// <summary>管理员名单比对(逗号/分号分隔,中英文皆可;忽略大小写与首尾空白)。</summary>
private static bool MatchAdminRole(string roles, string currentUser)
{
try
{
if (string.IsNullOrWhiteSpace(roles) || string.IsNullOrWhiteSpace(currentUser)) return false;
foreach (string part in roles.Split(new[] { ',', ';', ',', ';' }, StringSplitOptions.RemoveEmptyEntries))
{
if (string.Equals(part.Trim(), currentUser.Trim(), StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
catch { return false; }
}
private static void AddMissing(List<string> list, string name, string value)
{
try
{
if (string.IsNullOrWhiteSpace(value) && !list.Contains(name)) list.Add(name);
}
catch { }
}
// ══════════════ 工具 ══════════════
/// <summary>程序根目录归一:去尾 \。</summary>
public static string NormalizeRoot(string baseDirectory)
{
string root = string.IsNullOrEmpty(baseDirectory) ? AppDomain.CurrentDomain.BaseDirectory : baseDirectory;
return root.TrimEnd('\\');
}
private static bool NotBlack(string s) { return !string.IsNullOrWhiteSpace(s); }
// ── SqlDataReader 列安全读取(列缺失/类型不符返回空,兼容表结构演进) ──
private static string S(SqlDataReader r, string col)
{
try
{
int i = r.GetOrdinal(col);
if (r.IsDBNull(i)) return "";
object v = r.GetValue(i);
return v == null ? "" : Convert.ToString(v, System.Globalization.CultureInfo.InvariantCulture);
}
catch { return ""; }
}
private static int? I(SqlDataReader r, string col)
{
try
{
int i = r.GetOrdinal(col);
if (r.IsDBNull(i)) return null;
return Convert.ToInt32(r.GetValue(i), System.Globalization.CultureInfo.InvariantCulture);
}
catch { return null; }
}
private static int I2(SqlDataReader r, string col)
{
try { return I(r, col) ?? 0; }
catch { return 0; }
}
private static double? D(SqlDataReader r, string col)
{
try
{
int i = r.GetOrdinal(col);
if (r.IsDBNull(i)) return null;
return Convert.ToDouble(r.GetValue(i), System.Globalization.CultureInfo.InvariantCulture);
}
catch { return null; }
}
private static bool? B(SqlDataReader r, string col)
{
try
{
int i = r.GetOrdinal(col);
if (r.IsDBNull(i)) return null;
return Convert.ToBoolean(r.GetValue(i), System.Globalization.CultureInfo.InvariantCulture);
}
catch { return null; }
}
private static bool B2(SqlDataReader r, string col)
{
try { return B(r, col) ?? false; }
catch { return false; }
}
}
}
......@@ -39,8 +39,10 @@ namespace Tools.MyAgent
/// <summary>
/// 执行检查(不含模型服务连通性——它是异步的,由调用方 <see cref="ModelService"/> 单独追加)。
/// <para>v2.2 新增第 10 项 <c>config_center</c>:传入配置中心解析结果即输出该项(null = 自定义模式)。</para>
/// </summary>
public static HealthItem[] Check(AgentConfig cfg, KbService kb, IReadOnlyList<DiagnosticRoot> roots)
public static HealthItem[] Check(AgentConfig cfg, KbService kb, IReadOnlyList<DiagnosticRoot> roots,
Support.CenterResolution center = null)
{
var list = new List<HealthItem>();
try
......@@ -53,11 +55,43 @@ namespace Tools.MyAgent
list.Add(CheckSemantic(cfg));
list.Add(CheckDiagnosticRoots(roots));
list.Add(CheckTelemetrySelf());
list.Add(CheckConfigCenter(center));
}
catch { }
return list.ToArray();
}
/// <summary>配置中心项(P2):模式 / 来源 / 角色 / KB 探测结论 / 缺项清单。</summary>
private static HealthItem CheckConfigCenter(Support.CenterResolution center)
{
try
{
if (center == null || center.Mode != Support.AgentRunMode.Distribution)
return Item("config_center", "配置中心", "ok", "自定义模式(本地配置)");
var parts = new List<string>
{
"来源=" + (center.Source == "snapshot" ? "本地快照" : center.Source == "db" ? "中心库" : "缺省"),
"角色=" + (center.IsAdmin ? "管理员" : "普通用户"),
};
if (center.KbProbe != null)
{
string kb = "KB=" + center.KbProbe.Conclusion + "(" + center.KbProbe.Resolved
+ (center.KbProbe.IsEmpty ? ",空库" : "") + ")";
parts.Add(kb);
}
string detail = string.Join(";", parts.ToArray());
if (center.MissingItems != null && center.MissingItems.Count > 0)
return Item("config_center", "配置中心", "fail",
detail + ";缺项:" + string.Join("、", center.MissingItems.ToArray()) + "(请联系管理员)");
if (center.Source == "none")
return Item("config_center", "配置中心", "fail", detail + "(中心库不可达且无本地快照)");
if (center.Source == "snapshot")
return Item("config_center", "配置中心", "warn", detail + "(中心库不可达,使用本地快照)");
return Item("config_center", "配置中心", "ok", detail);
}
catch (Exception ex) { return Item("config_center", "配置中心", "warn", ex.Message); }
}
/// <summary>模型服务连通性项(由调用方用 <c>TestConnectionAsync</c> 结果构造)。</summary>
public static HealthItem ModelService(bool ok, string message)
{
......
using System;
using System.IO;
namespace Tools.MyAgent.Support
{
/// <summary>知识库根目录探测结论(config_center_plan.md v2.3)。</summary>
public sealed class KbProbeResult
{
/// <summary>原始模板(空 = 中心未下发 KB 配置)。</summary>
public string Template { get; set; }
/// <summary>最终采用的绝对路径({CLIENT_ROOT} 已展开;UNC 共享路径原样)。</summary>
public string Resolved { get; set; }
/// <summary>目录是否存在。</summary>
public bool Exists { get; set; }
/// <summary>存在但为空目录(允许,首次分发场景;/diag 呈现)。</summary>
public bool IsEmpty { get; set; }
/// <summary>结论:default_ok(未配置→宿主程序根目录) / ok / fallback_ok(已回退 BaseDirectory\Agent) / unavailable。</summary>
public string Conclusion { get; set; }
/// <summary>是否可用(Exists 且非 unavailable 类结论)。</summary>
public bool Usable { get { return Exists; } }
}
/// <summary>
/// 默认知识库探测器(config_center_plan.md v2.3/v2.4)。
/// <para>规则:模板空(中心未下发)→ 默认 <c>{CLIENT_ROOT}</c>(宿主程序根目录,静默兜底,不提示);
/// 已配置但解析后不存在 → 回退 <c>BaseDirectory\Agent</c>;仍不存在 → unavailable(提示"目录不可用")。
/// v2.4:占位符仅本地文件夹生效——UNC 共享模板不含占位符、原样解析。</para>
/// <para>红线:本类所有 public 方法体首句必须是 try —— 任何异常不上抛、不阻塞启动。</para>
/// </summary>
public static class AgentKbProbe
{
/// <summary>未配置模板时 KB 解析(v2.3):默认 {CLIENT_ROOT} = 宿主程序根目录。</summary>
public static KbProbeResult Probe(string template, string baseDirectory)
{
try
{
string root = AgentCenterClient.NormalizeRoot(baseDirectory);
var result = new KbProbeResult { Template = template ?? "" };
string requested = string.IsNullOrWhiteSpace(template)
? root
: AgentCenterClient.ExpandClientRoot(template, root);
result.Resolved = requested;
if (Directory.Exists(requested))
{
result.Exists = true;
result.IsEmpty = new DirectoryInfo(requested).GetFileSystemInfos().Length == 0;
result.Conclusion = string.IsNullOrWhiteSpace(template) ? "default_ok" : "ok";
return result;
}
// 已配置但不存在 → 回退 BaseDirectory\Agent(方案 §1.3)
string fallback = Path.Combine(root, "Agent");
if (Directory.Exists(fallback))
{
result.Exists = true;
result.IsEmpty = new DirectoryInfo(fallback).GetFileSystemInfos().Length == 0;
result.Resolved = fallback;
result.Conclusion = "fallback_ok";
return result;
}
result.Conclusion = "unavailable";
return result;
}
catch
{
return new KbProbeResult
{
Template = template ?? "",
Resolved = AgentCenterClient.NormalizeRoot(baseDirectory),
Conclusion = "unavailable",
};
}
}
}
}
......@@ -174,7 +174,9 @@
<Compile Include="ShareHelper.cs" />
<Compile Include="Support\AgentLogger.cs" />
<Compile Include="Support\AgentTelemetry.cs" />
<Compile Include="Support\AgentCenterClient.cs" />
<Compile Include="Support\AgentHealth.cs" />
<Compile Include="Support\AgentKbProbe.cs" />
<Compile Include="Support\TelemetryStore.cs" />
<Compile Include="Support\AgentUtils.cs" />
<Compile Include="Support\BackupHelper.cs" />
......@@ -230,6 +232,7 @@
</ItemGroup>
<ItemGroup>
<None Include="build\Ftt.Tools.Agent.targets" />
<None Include="Defaults\Agent\config\center.sample.json" />
<None Include="Ftt.Tools.Agent.nuspec" />
<None Include="README.md" />
<None Include="THIRD-PARTY-NOTICES.txt" />
......
......@@ -160,12 +160,26 @@ namespace Tools.MyAgent
/// <summary>创建可嵌入的智能体窗体,并允许宿主指定独立诊断根目录。</summary>
public wMyAgent(string agentRoot, string currentUser, AgentProductContext productContext,
IReadOnlyList<DiagnosticRoot> diagnosticRoots)
: this(agentRoot, currentUser, productContext, diagnosticRoots, null)
{
}
/// <summary>
/// 创建可嵌入的智能体窗体(v2.2 完整形态):center 为宿主(AgentRuntime)已拉取的配置中心解析结果;
/// 传 null(独立实例化)时按 center.json 是否存在自动判定模式并自行拉取。
/// </summary>
public wMyAgent(string agentRoot, string currentUser, AgentProductContext productContext,
IReadOnlyList<DiagnosticRoot> diagnosticRoots, Support.CenterResolution center)
{
_productContext = ProductContextValidator.CreateSnapshot(productContext);
_agentRoot = string.IsNullOrEmpty(agentRoot)
? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Agent")
? Support.AgentCenterClient.AgentRootPath(AppDomain.CurrentDomain.BaseDirectory)
: Path.GetFullPath(agentRoot);
_currentUser = string.IsNullOrEmpty(currentUser) ? "访客" : currentUser;
// 配置中心(v2.2):优先用宿主传入的解析结果;否则自行判定 + 拉取(内部三级降级,任何异常不上抛)
_center = center ?? Support.AgentCenterClient.Fetch(
AppDomain.CurrentDomain.BaseDirectory, null, null,
_currentUser, _productContext != null ? _productContext.ProductLineId : null);
InitializeComponent();
CreateChatControl();
CreateCommandAutocomplete();
......@@ -174,6 +188,7 @@ namespace Tools.MyAgent
ApplyConfig();
RunStartupHealthCheck(); // HealthCheckOnStart=true 时后台自检落 health.json(P1-4:原为死配置)
WireEvents();
lblSettings.Visible = CanOpenSettings; // 分发模式普通用户隐藏设置入口(v2.2)
RefreshChat();
UpdateStatusIndicator();
}
......@@ -342,6 +357,15 @@ namespace Tools.MyAgent
private static (string Name, string BaseUrl, string Model)[] ProviderPresets => Forms.AgentSettingsForm.ProviderPresets;
/// <summary>默认知识库路径:程序输出目录下的 Agent 文件夹(未设置知识库时生效)</summary>
/// <summary>配置中心解析结果(config_center_plan.md v2.2);Custom 模式仅承载模式与角色。保存成功后可被刷新(非只读)。</summary>
private Support.CenterResolution _center;
/// <summary>当前是否分发模式(配置中心生效)。</summary>
private bool IsDistribution => _center != null && _center.Mode == Support.AgentRunMode.Distribution;
/// <summary>当前用户是否可打开设置:自定义模式恒可(现状);分发模式仅管理员。</summary>
private bool CanOpenSettings => !IsDistribution || (_center != null && _center.IsAdmin);
private string DefaultKbRoot => _agentRoot;
/// <summary>产品上下文真正固定的搜索子目录;旧宿主把根目录作为范围时视为未固定。</summary>
......@@ -505,6 +529,22 @@ namespace Tools.MyAgent
{
// 统一使用默认 Agent 目录下 config 文件夹的 AgentConfig.json(新位置)
_config = LoadAgentConfig(out bool migrated) ?? new AgentConfig();
// 配置中心(v2.2):分发模式下本地退出中心管辖区——先清空再由中心覆盖;
// 宿主注入(下方 productContext 块)随后仍最高优先级。自定义模式零改动。
if (IsDistribution)
{
_config.Provider = "";
_config.BaseUrl = "";
_config.Model = "";
_config.ApiKey = "";
_config.RemoteKbEntries = new List<string>();
_config.KnowledgeBasePath = "";
_config.SearchScope = "";
_config.KbMode = "";
_config.ShareUser = "";
_config.SharePassword = "";
_center.ApplyTo(_config); // 字段级"非空才覆盖";KB 缺省时中心已解析为 {CLIENT_ROOT}
}
if (_productContext != null)
{
_config.ProductLineId = _productContext.ProductLineId;
......@@ -548,7 +588,7 @@ namespace Tools.MyAgent
var roots = _engine != null ? _engine.DiagnosticRoots : null;
System.Threading.Tasks.Task.Run(() =>
{
try { AgentHealth.Save(AgentHealth.Check(cfg, kb, roots)); }
try { AgentHealth.Save(AgentHealth.Check(cfg, kb, roots, _center)); }
catch { }
});
}
......@@ -719,7 +759,9 @@ namespace Tools.MyAgent
$"知识库:{kbInfo}。输入问题即可检索回答;输入 / 呼出命令菜单。",
};
if (string.IsNullOrEmpty(_config.ApiKey))
lines.Add("API Key 未配置(点击「设置」填入后可开始回答)。");
lines.Add(IsDistribution
? "API Key 未配置,请联系管理员!" // 分发模式:无设置入口(v2.0)
: "API Key 未配置(点击「设置」填入后可开始回答)。");
return string.Join("\n", lines);
}
......@@ -1117,6 +1159,15 @@ namespace Tools.MyAgent
SendMessage();
}
private List<string> GetMissingEffectiveConfig()
{
var missing = new List<string>();
if (string.IsNullOrWhiteSpace(_config?.ApiKey)) missing.Add("ApiKey");
if (string.IsNullOrWhiteSpace(_config?.BaseUrl)) missing.Add("BaseUrl");
if (string.IsNullOrWhiteSpace(_config?.Model)) missing.Add("Model");
return missing;
}
private async void SendMessage()
{
string text = txtInput.Text.Trim();
......@@ -1135,6 +1186,17 @@ namespace Tools.MyAgent
return;
}
if (IsDistribution)
{
// 以当前会话实际生效的配置判断;管理员刚保存但中心同步失败时,
// 本机配置仍应立即可用,不能继续使用启动时的旧中心缺项快照拦截。
var missing = GetMissingEffectiveConfig();
if (missing.Count > 0)
{
AddSystemMessage("以下配置缺失,请联系管理员:" + string.Join("、", missing.ToArray()));
return;
}
}
if (string.IsNullOrEmpty(_config.ApiKey))
{
AddSystemMessage("请先在「设置」中配置 DeepSeek API Key。");
......@@ -2888,7 +2950,15 @@ namespace Tools.MyAgent
private void ShowSettingsPopup()
{
using (var frm = new Forms.AgentSettingsForm(_config, DefaultKbRoot))
// 分发模式(v2.2):普通用户无设置入口(按钮已隐藏,此处兜底 /settings 命令)
if (!CanOpenSettings)
{
AddSystemMessage("当前为分发模式,配置由管理员统一管理;如需修改请联系管理员。");
return;
}
// 分发模式且未配置数据库连接(2026-10-22):管理员打开设置自动跳到「对话上报」页引导配置
bool focusRunDb = IsDistribution && string.IsNullOrEmpty(_config.RunDbConnection);
using (var frm = new Forms.AgentSettingsForm(_config, DefaultKbRoot, focusRunDb))
{
if (frm.ShowDialog(this) != DialogResult.OK) return;
......@@ -2896,9 +2966,35 @@ namespace Tools.MyAgent
if (!string.IsNullOrEmpty(_config.SearchScope) && !IsPathUnder(_config.KnowledgeBasePath, _config.SearchScope))
_config.SearchScope = "";
// 分发模式(P1):中心管辖区字段写透数据库(缺表自建、本地路径反折 {CLIENT_ROOT},UNC 共享原样),其余终端下次启动生效
if (IsDistribution)
{
// 先落本地配置,使首次设置的 RunDbConnection 可作为中心库连接引导;
// 中心同步成功后仍会在下方再次保存最终生效配置。
SaveConfig();
string err = Support.AgentCenterClient.SaveFromConfig(AppDomain.CurrentDomain.BaseDirectory, _config);
if (err != null)
{
// 中心写失败:本机已生效但不下发;不刷新快照(避免把失败态固化)
AddSystemMessage("❌ 配置已在本机生效,但同步配置中心失败:" + err + "(其他终端不会收到)");
}
else
{
var fresh = Support.AgentCenterClient.Fetch(
AppDomain.CurrentDomain.BaseDirectory, Support.AgentRunMode.Distribution,
_center.IsAdmin, _currentUser, _productContext != null ? _productContext.ProductLineId : null);
if (fresh != null)
{
_center = fresh; // 刷新解析结果(同时更新本地快照)
_center.ApplyTo(_config);
}
AddSystemMessage("✅ 配置已保存并同步到配置中心(其余终端下次启动生效)。");
}
}
ApplyKbRoot(_config.KnowledgeBasePath);
ApplyConfig();
SaveConfig();
SaveConfig(); // 本地持久化(分发模式下中心管辖区字段以中心库为准,本地仅承载非管辖区/兜底)
RefreshScopeCombo();
RefreshChat();
UpdateStatusIndicator();
......@@ -3119,7 +3215,7 @@ namespace Tools.MyAgent
private async void RunDiagnostics()
{
var items = new System.Collections.Generic.List<HealthItem>(
AgentHealth.Check(_config, _kb, _engine.DiagnosticRoots)); // 诊断根由引擎持有(窗体未另存字段)
AgentHealth.Check(_config, _kb, _engine.DiagnosticRoots, _center)); // 诊断根由引擎持有(窗体未另存字段)
bool ok = false;
string msg = "";
try
......
......@@ -49,7 +49,6 @@ namespace Tools
this.CreateModule(Tools_imp, typeof(UI.wEmptyTable), "清空表", "tr:输入主表、自动清空关联表的工具"),
this.CreateModule(Tools_imp, typeof(UI.wExcelExportSetting), "Excel导出设置", "tr:Excel导出配置图形化版本"),
this.CreateModule(Tools_imp, typeof(UI.wLogViewer), "日志查看器", "tr:可以方便的查看系统日志"),
this.CreateModule(Tools_imp, typeof(Tools.MyAgent.Forms.AgentRunLogForm), "Agent日志", "tr:查看AI对话上报记录(按用户提问统计分析)"),
this.CreateModule(Tools_imp, typeof(UI.wTextFormatter), "格式化工具", "tr:SQL和JSON文本格式化工具"),
this.CreateModule(Tools_imp, typeof(UI.wTextDiff), "文本对比工具", "tr:行级文本差异对比,支持左右/上下布局"),
this.CreateModule(Tools_imp, typeof(DotNetVersionChecker.FrmVersion), "系统检测", "lxj:检测操作系统运行环境"),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment