Commit 0020a375 authored by 段廷瑞's avatar 段廷瑞

feat: Agent日志查看增加数据库链接配置;优化对话窗口交互

parent 2ecc48c8
...@@ -77,3 +77,4 @@ bin ...@@ -77,3 +77,4 @@ bin
/temp /temp
/AppHome/_backup /AppHome/_backup
/Tools.Agent.ProbeKbPref /Tools.Agent.ProbeKbPref
/working
...@@ -138,6 +138,25 @@ namespace AgentTest ...@@ -138,6 +138,25 @@ namespace AgentTest
} }
} }
[Test]
public void FileCount_RefreshesWhenScopeChanges()
{
File.WriteAllText(Path.Combine(_root, "root.md"), "root");
File.WriteAllText(Path.Combine(_root, "docs", "b.md"), "b");
using (var refreshed = new System.Threading.ManualResetEventSlim(false))
{
_kb.StatsRefreshed += () => refreshed.Set();
_kb.EnsureStats();
Assert.True(refreshed.Wait(5000), "根目录统计应在后台完成");
Assert.Equal(3, _kb.FileCount, "根目录文件数应包含所有知识库文件");
refreshed.Reset();
_kb.SetScope(Path.Combine(_root, "docs"));
Assert.True(refreshed.Wait(5000), "切换范围后应重新统计");
Assert.Equal(2, _kb.FileCount, "切换范围后文件数应只包含当前目录");
}
}
[Test] [Test]
public void SetScope_OutsideRoot_FallsBackToRoot() public void SetScope_OutsideRoot_FallsBackToRoot()
{ {
......
No preview for this file type
...@@ -59,6 +59,13 @@ ...@@ -59,6 +59,13 @@
RunDbConnection 已解密为明文。供无上下文的工具模块(Activator 无参创建)自取上报配置;失败返回 null。 RunDbConnection 已解密为明文。供无上下文的工具模块(Activator 无参创建)自取上报配置;失败返回 null。
</summary> </summary>
</member> </member>
<member name="M:Tools.MyAgent.AgentRuntime.SaveLocalRunDbConfig(System.String,System.String,System.Boolean)">
<summary>
把对话上报连接(Agent 日志窗口"连接配置"保存)写回本机 AgentConfig.json。
<para>以 JObject 原文补丁方式只改 RunDb* 三个字段,**不触碰其他配置**(避免全量投影覆盖宿主注入项);
连接串经 <see cref="T:Tools.MyAgent.Config.SecretProtector"/> DPAPI 加密落盘,密码不落明文。返回是否成功。</para>
</summary>
</member>
<member name="T:Tools.MyAgent.AgentProductContext"> <member name="T:Tools.MyAgent.AgentProductContext">
<summary> <summary>
宿主声明的产品上下文。产品身份来自注册表,项目路径由宿主按权限注入。 宿主声明的产品上下文。产品身份来自注册表,项目路径由宿主按权限注入。
...@@ -1581,6 +1588,12 @@ ...@@ -1581,6 +1588,12 @@
<para>控件布局由 AgentRunLogForm.Designer.cs(设计器)实现;本文件仅含逻辑。</para> <para>控件布局由 AgentRunLogForm.Designer.cs(设计器)实现;本文件仅含逻辑。</para>
</summary> </summary>
</member> </member>
<member name="F:Tools.MyAgent.Forms.AgentRunLogForm._connection">
<summary>当前上报连接(明文,窗口内可经"连接配置"切换)。</summary>
</member>
<member name="F:Tools.MyAgent.Forms.AgentRunLogForm._table">
<summary>当前日志表名。</summary>
</member>
<member name="F:Tools.MyAgent.Forms.AgentRunLogForm._currentRunId"> <member name="F:Tools.MyAgent.Forms.AgentRunLogForm._currentRunId">
<summary>当前选中行的 RunId(详情二次查询用)。</summary> <summary>当前选中行的 RunId(详情二次查询用)。</summary>
</member> </member>
...@@ -1610,6 +1623,12 @@ ...@@ -1610,6 +1623,12 @@
RichEdit 静默解析失败 ⇒ 空白)。 RichEdit 静默解析失败 ⇒ 空白)。
</summary> </summary>
</member> </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)"> <member name="M:Tools.MyAgent.Forms.AgentRunLogForm.Q(System.String)">
<summary>表名标识(白名单已在 Reporter/配置层校验;此处再防御)。</summary> <summary>表名标识(白名单已在 Reporter/配置层校验;此处再防御)。</summary>
</member> </member>
...@@ -1630,6 +1649,51 @@ ...@@ -1630,6 +1649,51 @@
使用代码编辑器修改此方法的内容。 使用代码编辑器修改此方法的内容。
</summary> </summary>
</member> </member>
<member name="T:Tools.MyAgent.Forms.AgentDbConfigForm">
<summary>
Agent 日志窗口的数据库连接配置对话框(2026-10-19):IP / 数据库 / 账号 / 密码 / 表名,
支持测试连接;确定后由 AgentRunLogForm 经 <see cref="M:Tools.MyAgent.AgentRuntime.SaveLocalRunDbConfig(System.String,System.String,System.Boolean)"/>
写回 AgentConfig.json(连接串 DPAPI 加密落盘,密码不落明文)。
</summary>
</member>
<member name="F:Tools.MyAgent.Forms.AgentDbConfigForm._existing">
<summary>现有连接串(预填用;可能为空)。</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentDbConfigForm.Prefill">
<summary>用现有连接串预填各输入框(解析失败保持空白,不阻断)。</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentDbConfigForm.OnDbDropDown(System.Object,System.EventArgs)">
<summary>
数据库下拉展开时按当前 IP/账号/密码拉取实例库列表(SELECT name FROM sys.databases);
拉取失败静默保留已输入值(可手填)。每次展开都刷新,改过连接信息后无需重开对话框。
</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentDbConfigForm.BuildConnectionString">
<summary>按输入拼装连接串(SqlConnectionStringBuilder 负责转义)。</summary>
</member>
<member name="P:Tools.MyAgent.Forms.AgentDbConfigForm.Table">
<summary>表名(含校验,非法字符在保存侧再防御一层)。</summary>
</member>
<member name="P:Tools.MyAgent.Forms.AgentDbConfigForm.EnableReporting">
<summary>是否同时启用对话上报(默认勾选,避免"测试通过但不落库"的旧坑)。</summary>
</member>
<member name="F:Tools.MyAgent.Forms.AgentDbConfigForm.components">
<summary>
必需的设计器变量。
</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentDbConfigForm.Dispose(System.Boolean)">
<summary>
清理所有正在使用的资源。
</summary>
<param name="disposing">如果应释放托管资源,为 true;否则为 false。</param>
</member>
<member name="M:Tools.MyAgent.Forms.AgentDbConfigForm.InitializeComponent">
<summary>
设计器支持所需的方法 - 不要修改
使用代码编辑器修改此方法的内容。
</summary>
</member>
<member name="T:Tools.MyAgent.Forms.DocLineViewer"> <member name="T:Tools.MyAgent.Forms.DocLineViewer">
<summary> <summary>
文档行定位查看器(P4 引用行点击):只读展示文本类文件,并定位高亮到指定行。 文档行定位查看器(P4 引用行点击):只读展示文本类文件,并定位高亮到指定行。
...@@ -3664,6 +3728,9 @@ ...@@ -3664,6 +3728,9 @@
<member name="P:Tools.MyAgent.wMyAgent.DefaultKbRoot"> <member name="P:Tools.MyAgent.wMyAgent.DefaultKbRoot">
<summary>默认知识库路径:程序输出目录下的 Agent 文件夹(未设置知识库时生效)</summary> <summary>默认知识库路径:程序输出目录下的 Agent 文件夹(未设置知识库时生效)</summary>
</member> </member>
<member name="P:Tools.MyAgent.wMyAgent.ProductFixedScope">
<summary>产品上下文真正固定的搜索子目录;旧宿主把根目录作为范围时视为未固定。</summary>
</member>
<member name="P:Tools.MyAgent.wMyAgent.AgentConfigFilePath"> <member name="P:Tools.MyAgent.wMyAgent.AgentConfigFilePath">
<summary>Agent 配置文件:默认 Agent 目录下的 config\AgentConfig.json</summary> <summary>Agent 配置文件:默认 Agent 目录下的 config\AgentConfig.json</summary>
</member> </member>
...@@ -3683,12 +3750,21 @@ ...@@ -3683,12 +3750,21 @@
<member name="M:Tools.MyAgent.wMyAgent.WorkingDirForHistory"> <member name="M:Tools.MyAgent.wMyAgent.WorkingDirForHistory">
<summary>历史记录固定保存到默认 Agent 目录(与所选知识库无关)</summary> <summary>历史记录固定保存到默认 Agent 目录(与所选知识库无关)</summary>
</member> </member>
<member name="M:Tools.MyAgent.wMyAgent.ApplyWindowModePadding">
<summary>独立窗口增加 10px 内边距;嵌入宿主 Tab 时保持原有布局。</summary>
</member>
<member name="M:Tools.MyAgent.wMyAgent.WireRtfEvents"> <member name="M:Tools.MyAgent.wMyAgent.WireRtfEvents">
<summary>RichTextBox 的点击/悬停交互绑定(启动时调用;新实例不会重复绑定)</summary> <summary>RichTextBox 的点击/悬停交互绑定(启动时调用;新实例不会重复绑定)</summary>
</member> </member>
<member name="M:Tools.MyAgent.wMyAgent.RefreshChatRtf"> <member name="M:Tools.MyAgent.wMyAgent.RefreshChatRtf">
<summary>RichBox 模式:整段 RTF 重建(增量渲染缓存)</summary> <summary>RichBox 模式:整段 RTF 重建(增量渲染缓存)</summary>
</member> </member>
<member name="M:Tools.MyAgent.wMyAgent.IsChatAtBottom(System.Int32@)">
<summary>判断聊天区重绘前是否已经位于底部;用户上滚查看历史时保持其视口。</summary>
</member>
<member name="M:Tools.MyAgent.wMyAgent.RestoreChatScroll(System.Int32)">
<summary>恢复聊天区重绘前的首行位置,避免后台刷新把用户拉回回答末尾。</summary>
</member>
<member name="M:Tools.MyAgent.wMyAgent.RebuildAnswerRefs"> <member name="M:Tools.MyAgent.wMyAgent.RebuildAnswerRefs">
<summary> <summary>
重建引用行表(P4):从渲染文本中扫出 📎 引用行并解析 路径/章节/行号。 重建引用行表(P4):从渲染文本中扫出 📎 引用行并解析 路径/章节/行号。
......
...@@ -134,6 +134,29 @@ namespace Tools.MyAgent ...@@ -134,6 +134,29 @@ namespace Tools.MyAgent
catch { return null; } catch { return null; }
} }
/// <summary>
/// 把对话上报连接(Agent 日志窗口"连接配置"保存)写回本机 AgentConfig.json。
/// <para>以 JObject 原文补丁方式只改 RunDb* 三个字段,**不触碰其他配置**(避免全量投影覆盖宿主注入项);
/// 连接串经 <see cref="Config.SecretProtector"/> DPAPI 加密落盘,密码不落明文。返回是否成功。</para>
/// </summary>
public static bool SaveLocalRunDbConfig(string plainConnection, string table, bool enabled)
{
try
{
string path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Agent", "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();
root["RunDbEnabled"] = enabled;
root["RunDbConnection"] = Config.SecretProtector.Protect(plainConnection ?? "");
root["RunDbTable"] = string.IsNullOrWhiteSpace(table) ? "Agent_RunLog" : table.Trim();
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path));
File.WriteAllText(path, root.ToString(Newtonsoft.Json.Formatting.Indented), System.Text.Encoding.UTF8);
return true;
}
catch { return false; }
}
private void EnsureDefaultResources() private void EnsureDefaultResources()
{ {
Directory.CreateDirectory(DataRoot); Directory.CreateDirectory(DataRoot);
......
namespace Tools.MyAgent.Forms
{
partial class AgentDbConfigForm
{
/// <summary>
/// 必需的设计器变量。
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// 清理所有正在使用的资源。
/// </summary>
/// <param name="disposing">如果应释放托管资源,为 true;否则为 false。</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows 窗体设计器生成的代码
/// <summary>
/// 设计器支持所需的方法 - 不要修改
/// 使用代码编辑器修改此方法的内容。
/// </summary>
private void InitializeComponent()
{
this.lblServer = new System.Windows.Forms.Label();
this.txtServer = new System.Windows.Forms.TextBox();
this.lblUser = new System.Windows.Forms.Label();
this.txtUser = new System.Windows.Forms.TextBox();
this.lblPwd = new System.Windows.Forms.Label();
this.txtPwd = new System.Windows.Forms.TextBox();
this.lblDb = new System.Windows.Forms.Label();
this.cmbDb = new System.Windows.Forms.ComboBox();
this.lblTable = new System.Windows.Forms.Label();
this.txtTable = new System.Windows.Forms.TextBox();
this.chkEnable = new System.Windows.Forms.CheckBox();
this.btnTest = new System.Windows.Forms.Button();
this.btnOK = new System.Windows.Forms.Button();
this.btnCancel = new System.Windows.Forms.Button();
this.SuspendLayout();
//
// lblServer
//
this.lblServer.Location = new System.Drawing.Point(16, 21);
this.lblServer.Name = "lblServer";
this.lblServer.Size = new System.Drawing.Size(90, 23);
this.lblServer.Text = "服务器 IP";
this.lblServer.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
//
// txtServer
//
this.txtServer.Location = new System.Drawing.Point(112, 18);
this.txtServer.Name = "txtServer";
this.txtServer.Size = new System.Drawing.Size(300, 23);
this.txtServer.TabIndex = 0;
//
// lblUser
//
this.lblUser.Location = new System.Drawing.Point(16, 55);
this.lblUser.Name = "lblUser";
this.lblUser.Size = new System.Drawing.Size(90, 23);
this.lblUser.Text = "账号";
this.lblUser.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
//
// txtUser
//
this.txtUser.Location = new System.Drawing.Point(112, 52);
this.txtUser.Name = "txtUser";
this.txtUser.Size = new System.Drawing.Size(300, 23);
this.txtUser.TabIndex = 1;
//
// lblPwd
//
this.lblPwd.Location = new System.Drawing.Point(16, 89);
this.lblPwd.Name = "lblPwd";
this.lblPwd.Size = new System.Drawing.Size(90, 23);
this.lblPwd.Text = "密码";
this.lblPwd.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
//
// txtPwd
//
this.txtPwd.Location = new System.Drawing.Point(112, 86);
this.txtPwd.Name = "txtPwd";
this.txtPwd.Size = new System.Drawing.Size(300, 23);
this.txtPwd.TabIndex = 2;
this.txtPwd.UseSystemPasswordChar = true;
//
// lblDb
//
this.lblDb.Location = new System.Drawing.Point(16, 123);
this.lblDb.Name = "lblDb";
this.lblDb.Size = new System.Drawing.Size(90, 23);
this.lblDb.Text = "数据库";
this.lblDb.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
//
// cmbDb(可编辑下拉:填好 IP/账号/密码后展开即拉取库列表)
//
this.cmbDb.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDown;
this.cmbDb.Location = new System.Drawing.Point(112, 120);
this.cmbDb.Name = "cmbDb";
this.cmbDb.Size = new System.Drawing.Size(300, 25);
this.cmbDb.TabIndex = 3;
this.cmbDb.DropDown += new System.EventHandler(this.OnDbDropDown);
//
// lblTable
//
this.lblTable.Location = new System.Drawing.Point(16, 157);
this.lblTable.Name = "lblTable";
this.lblTable.Size = new System.Drawing.Size(90, 23);
this.lblTable.Text = "日志表名";
this.lblTable.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
//
// txtTable
//
this.txtTable.Location = new System.Drawing.Point(112, 154);
this.txtTable.Name = "txtTable";
this.txtTable.Size = new System.Drawing.Size(300, 23);
this.txtTable.TabIndex = 4;
//
// chkEnable
//
this.chkEnable.Checked = true;
this.chkEnable.CheckState = System.Windows.Forms.CheckState.Checked;
this.chkEnable.Location = new System.Drawing.Point(112, 186);
this.chkEnable.Name = "chkEnable";
this.chkEnable.Size = new System.Drawing.Size(300, 24);
this.chkEnable.TabIndex = 5;
this.chkEnable.Text = "同时启用对话上报(Agent 执行后记录到该库)";
this.chkEnable.UseVisualStyleBackColor = true;
//
// btnTest
//
this.btnTest.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
this.btnTest.Location = new System.Drawing.Point(112, 222);
this.btnTest.Name = "btnTest";
this.btnTest.Size = new System.Drawing.Size(88, 28);
this.btnTest.TabIndex = 6;
this.btnTest.Text = "测试连接";
this.btnTest.UseVisualStyleBackColor = true;
this.btnTest.Click += new System.EventHandler(this.OnTestClick);
//
// btnOK
//
this.btnOK.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(0)))), ((int)(((byte)(120)))), ((int)(((byte)(212)))));
this.btnOK.DialogResult = System.Windows.Forms.DialogResult.None;
this.btnOK.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
this.btnOK.ForeColor = System.Drawing.Color.White;
this.btnOK.Location = new System.Drawing.Point(244, 222);
this.btnOK.Name = "btnOK";
this.btnOK.Size = new System.Drawing.Size(80, 28);
this.btnOK.TabIndex = 7;
this.btnOK.Text = "确定";
this.btnOK.UseVisualStyleBackColor = false;
this.btnOK.Click += new System.EventHandler(this.OnOkClick);
//
// btnCancel
//
this.btnCancel.DialogResult = System.Windows.Forms.DialogResult.Cancel;
this.btnCancel.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
this.btnCancel.Location = new System.Drawing.Point(332, 222);
this.btnCancel.Name = "btnCancel";
this.btnCancel.Size = new System.Drawing.Size(80, 28);
this.btnCancel.TabIndex = 8;
this.btnCancel.Text = "取消";
this.btnCancel.UseVisualStyleBackColor = true;
//
// AgentDbConfigForm
//
this.AcceptButton = this.btnOK;
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 17F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.CancelButton = this.btnCancel;
this.ClientSize = new System.Drawing.Size(428, 266);
this.Controls.Add(this.lblServer);
this.Controls.Add(this.txtServer);
this.Controls.Add(this.lblUser);
this.Controls.Add(this.txtUser);
this.Controls.Add(this.lblPwd);
this.Controls.Add(this.txtPwd);
this.Controls.Add(this.lblDb);
this.Controls.Add(this.cmbDb);
this.Controls.Add(this.lblTable);
this.Controls.Add(this.txtTable);
this.Controls.Add(this.chkEnable);
this.Controls.Add(this.btnTest);
this.Controls.Add(this.btnOK);
this.Controls.Add(this.btnCancel);
this.Font = new System.Drawing.Font("Microsoft YaHei", 9F);
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
this.MaximizeBox = false;
this.MinimizeBox = false;
this.Name = "AgentDbConfigForm";
this.ShowInTaskbar = false;
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "Agent日志 - 数据库连接配置";
this.ResumeLayout(false);
this.PerformLayout();
}
#endregion
private System.Windows.Forms.Label lblServer;
private System.Windows.Forms.TextBox txtServer;
private System.Windows.Forms.Label lblUser;
private System.Windows.Forms.TextBox txtUser;
private System.Windows.Forms.Label lblPwd;
private System.Windows.Forms.TextBox txtPwd;
private System.Windows.Forms.Label lblDb;
private System.Windows.Forms.ComboBox cmbDb;
private System.Windows.Forms.Label lblTable;
private System.Windows.Forms.TextBox txtTable;
private System.Windows.Forms.CheckBox chkEnable;
private System.Windows.Forms.Button btnTest;
private System.Windows.Forms.Button btnOK;
private System.Windows.Forms.Button btnCancel;
}
}
using System;
using System.Collections.Generic;
using System.Data.SqlClient;
using System.Windows.Forms;
namespace Tools.MyAgent.Forms
{
/// <summary>
/// Agent 日志窗口的数据库连接配置对话框(2026-10-19):IP / 数据库 / 账号 / 密码 / 表名,
/// 支持测试连接;确定后由 AgentRunLogForm 经 <see cref="AgentRuntime.SaveLocalRunDbConfig"/>
/// 写回 AgentConfig.json(连接串 DPAPI 加密落盘,密码不落明文)。
/// </summary>
public partial class AgentDbConfigForm : Form
{
/// <summary>现有连接串(预填用;可能为空)。</summary>
private readonly string _existing;
public AgentDbConfigForm(string existingConnection, string table)
{
_existing = existingConnection ?? "";
InitializeComponent();
Prefill();
txtTable.Text = string.IsNullOrWhiteSpace(table) ? "Agent_RunLog" : table.Trim();
}
/// <summary>用现有连接串预填各输入框(解析失败保持空白,不阻断)。</summary>
private void Prefill()
{
if (_existing.Trim().Length == 0) return;
try
{
var b = new SqlConnectionStringBuilder(_existing);
txtServer.Text = b.DataSource ?? "";
txtUser.Text = b.UserID ?? "";
txtPwd.Text = b.Password ?? "";
cmbDb.Text = b.InitialCatalog ?? "";
}
catch { /* 旧连接串解析失败 → 留空白让用户重填 */ }
}
/// <summary>
/// 数据库下拉展开时按当前 IP/账号/密码拉取实例库列表(SELECT name FROM sys.databases);
/// 拉取失败静默保留已输入值(可手填)。每次展开都刷新,改过连接信息后无需重开对话框。
/// </summary>
private void OnDbDropDown(object sender, EventArgs e)
{
try
{
if (txtServer.Text.Trim().Length == 0) return;
string current = cmbDb.Text; // 保留手填值
var names = new List<string>();
using (var conn = new SqlConnection(BuildConnectionString()))
using (var cmd = new SqlCommand(
"SELECT name FROM sys.databases WHERE state = 0 ORDER BY name", conn))
{
conn.Open();
using (var reader = cmd.ExecuteReader())
while (reader.Read())
names.Add(Convert.ToString(reader["name"]));
}
cmbDb.Items.Clear();
cmbDb.Items.AddRange(names.ToArray());
cmbDb.Text = current;
}
catch { /* 连不上/权限不足 → 下拉为空,用户仍可手填 */ }
}
/// <summary>按输入拼装连接串(SqlConnectionStringBuilder 负责转义)。</summary>
public string BuildConnectionString()
{
return new SqlConnectionStringBuilder
{
DataSource = txtServer.Text.Trim(),
InitialCatalog = cmbDb.Text.Trim(),
UserID = txtUser.Text.Trim(),
Password = txtPwd.Text,
ConnectTimeout = 5,
}.ConnectionString;
}
/// <summary>表名(含校验,非法字符在保存侧再防御一层)。</summary>
public string Table => txtTable.Text.Trim();
/// <summary>是否同时启用对话上报(默认勾选,避免"测试通过但不落库"的旧坑)。</summary>
public bool EnableReporting => chkEnable.Checked;
private void OnTestClick(object sender, EventArgs e)
{
try
{
if (txtServer.Text.Trim().Length == 0 || cmbDb.Text.Trim().Length == 0)
{
MessageBox.Show(this, "请先填写服务器与数据库。", "测试连接",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
Cursor = Cursors.WaitCursor;
using (var conn = new SqlConnection(BuildConnectionString()))
using (var cmd = new SqlCommand("SELECT 1", conn))
{
conn.Open();
cmd.ExecuteScalar();
}
Cursor = Cursors.Default;
MessageBox.Show(this, "连接成功。", "测试连接", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
Cursor = Cursors.Default;
MessageBox.Show(this, "连接失败:" + ex.Message, "测试连接",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void OnOkClick(object sender, EventArgs e)
{
if (txtServer.Text.Trim().Length == 0 || cmbDb.Text.Trim().Length == 0)
{
MessageBox.Show(this, "服务器与数据库为必填项。", "保存",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
DialogResult = DialogResult.OK;
}
}
}
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
...@@ -28,7 +28,7 @@ ...@@ -28,7 +28,7 @@
/// </summary> /// </summary>
private void InitializeComponent() private void InitializeComponent()
{ {
this.panelTop = new System.Windows.Forms.Panel(); this.panelTop = new System.Windows.Forms.FlowLayoutPanel();
this.lblFrom = new System.Windows.Forms.Label(); this.lblFrom = new System.Windows.Forms.Label();
this.dtpFrom = new System.Windows.Forms.DateTimePicker(); this.dtpFrom = new System.Windows.Forms.DateTimePicker();
this.lblTo = new System.Windows.Forms.Label(); this.lblTo = new System.Windows.Forms.Label();
...@@ -39,6 +39,7 @@ ...@@ -39,6 +39,7 @@
this.cmbOutcome = new System.Windows.Forms.ComboBox(); this.cmbOutcome = new System.Windows.Forms.ComboBox();
this.btnRefresh = new System.Windows.Forms.Button(); this.btnRefresh = new System.Windows.Forms.Button();
this.btnExport = 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.splitMain = new System.Windows.Forms.SplitContainer();
this.dgv = new System.Windows.Forms.DataGridView(); this.dgv = new System.Windows.Forms.DataGridView();
this.tabDetails = new System.Windows.Forms.TabControl(); this.tabDetails = new System.Windows.Forms.TabControl();
...@@ -60,8 +61,10 @@ ...@@ -60,8 +61,10 @@
this.statusStrip.SuspendLayout(); this.statusStrip.SuspendLayout();
this.SuspendLayout(); this.SuspendLayout();
// //
// panelTop // panelTop(FlowLayoutPanel:控件从左到右自动排列,嵌入窄宿主时自动换行,永不重叠)
// //
this.panelTop.AutoSize = true;
this.panelTop.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink;
this.panelTop.Controls.Add(this.lblFrom); this.panelTop.Controls.Add(this.lblFrom);
this.panelTop.Controls.Add(this.dtpFrom); this.panelTop.Controls.Add(this.dtpFrom);
this.panelTop.Controls.Add(this.lblTo); this.panelTop.Controls.Add(this.lblTo);
...@@ -72,17 +75,17 @@ ...@@ -72,17 +75,17 @@
this.panelTop.Controls.Add(this.cmbOutcome); this.panelTop.Controls.Add(this.cmbOutcome);
this.panelTop.Controls.Add(this.btnRefresh); this.panelTop.Controls.Add(this.btnRefresh);
this.panelTop.Controls.Add(this.btnExport); this.panelTop.Controls.Add(this.btnExport);
this.panelTop.Controls.Add(this.btnConn);
this.panelTop.Dock = System.Windows.Forms.DockStyle.Top; this.panelTop.Dock = System.Windows.Forms.DockStyle.Top;
this.panelTop.Location = new System.Drawing.Point(0, 0); this.panelTop.Location = new System.Drawing.Point(0, 0);
this.panelTop.Name = "panelTop"; this.panelTop.Name = "panelTop";
this.panelTop.Padding = new System.Windows.Forms.Padding(8, 6, 8, 4); this.panelTop.Padding = new System.Windows.Forms.Padding(8, 2, 8, 4);
this.panelTop.Size = new System.Drawing.Size(1024, 46);
this.panelTop.TabIndex = 0; this.panelTop.TabIndex = 0;
// //
// lblFrom // lblFrom
// //
this.lblFrom.AutoSize = true; this.lblFrom.AutoSize = true;
this.lblFrom.Location = new System.Drawing.Point(11, 15); this.lblFrom.Margin = new System.Windows.Forms.Padding(3, 10, 2, 0);
this.lblFrom.Name = "lblFrom"; this.lblFrom.Name = "lblFrom";
this.lblFrom.Text = "开始"; this.lblFrom.Text = "开始";
// //
...@@ -97,7 +100,7 @@ ...@@ -97,7 +100,7 @@
// lblTo // lblTo
// //
this.lblTo.AutoSize = true; this.lblTo.AutoSize = true;
this.lblTo.Location = new System.Drawing.Point(159, 15); this.lblTo.Margin = new System.Windows.Forms.Padding(6, 10, 2, 0);
this.lblTo.Name = "lblTo"; this.lblTo.Name = "lblTo";
this.lblTo.Text = "结束"; this.lblTo.Text = "结束";
// //
...@@ -112,7 +115,7 @@ ...@@ -112,7 +115,7 @@
// lblUser // lblUser
// //
this.lblUser.AutoSize = true; this.lblUser.AutoSize = true;
this.lblUser.Location = new System.Drawing.Point(309, 15); this.lblUser.Margin = new System.Windows.Forms.Padding(12, 10, 2, 0);
this.lblUser.Name = "lblUser"; this.lblUser.Name = "lblUser";
this.lblUser.Text = "用户"; this.lblUser.Text = "用户";
// //
...@@ -127,7 +130,7 @@ ...@@ -127,7 +130,7 @@
// lblOutcome // lblOutcome
// //
this.lblOutcome.AutoSize = true; this.lblOutcome.AutoSize = true;
this.lblOutcome.Location = new System.Drawing.Point(491, 15); this.lblOutcome.Margin = new System.Windows.Forms.Padding(12, 10, 2, 0);
this.lblOutcome.Name = "lblOutcome"; this.lblOutcome.Name = "lblOutcome";
this.lblOutcome.Text = "结果"; this.lblOutcome.Text = "结果";
// //
...@@ -147,7 +150,7 @@ ...@@ -147,7 +150,7 @@
this.btnRefresh.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(0)))), ((int)(((byte)(120)))), ((int)(((byte)(212))))); this.btnRefresh.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(0)))), ((int)(((byte)(120)))), ((int)(((byte)(212)))));
this.btnRefresh.FlatStyle = System.Windows.Forms.FlatStyle.Flat; this.btnRefresh.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
this.btnRefresh.ForeColor = System.Drawing.Color.White; this.btnRefresh.ForeColor = System.Drawing.Color.White;
this.btnRefresh.Location = new System.Drawing.Point(619, 9); this.btnRefresh.Margin = new System.Windows.Forms.Padding(12, 4, 3, 0);
this.btnRefresh.Name = "btnRefresh"; this.btnRefresh.Name = "btnRefresh";
this.btnRefresh.Size = new System.Drawing.Size(76, 28); this.btnRefresh.Size = new System.Drawing.Size(76, 28);
this.btnRefresh.Text = "刷新"; this.btnRefresh.Text = "刷新";
...@@ -157,13 +160,23 @@ ...@@ -157,13 +160,23 @@
// btnExport // btnExport
// //
this.btnExport.FlatStyle = System.Windows.Forms.FlatStyle.Flat; this.btnExport.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
this.btnExport.Location = new System.Drawing.Point(703, 9); this.btnExport.Margin = new System.Windows.Forms.Padding(6, 4, 3, 0);
this.btnExport.Name = "btnExport"; this.btnExport.Name = "btnExport";
this.btnExport.Size = new System.Drawing.Size(76, 28); this.btnExport.Size = new System.Drawing.Size(76, 28);
this.btnExport.Text = "导出CSV"; this.btnExport.Text = "导出CSV";
this.btnExport.UseVisualStyleBackColor = true; this.btnExport.UseVisualStyleBackColor = true;
this.btnExport.Click += new System.EventHandler(this.OnExportCsvClick); 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 // splitMain
// //
this.splitMain.Dock = System.Windows.Forms.DockStyle.Fill; this.splitMain.Dock = System.Windows.Forms.DockStyle.Fill;
...@@ -320,6 +333,7 @@ ...@@ -320,6 +333,7 @@
private System.Windows.Forms.ComboBox cmbOutcome; private System.Windows.Forms.ComboBox cmbOutcome;
private System.Windows.Forms.Button btnRefresh; private System.Windows.Forms.Button btnRefresh;
private System.Windows.Forms.Button btnExport; private System.Windows.Forms.Button btnExport;
private System.Windows.Forms.Button btnConn;
private System.Windows.Forms.SplitContainer splitMain; private System.Windows.Forms.SplitContainer splitMain;
private System.Windows.Forms.DataGridView dgv; private System.Windows.Forms.DataGridView dgv;
private System.Windows.Forms.TabControl tabDetails; private System.Windows.Forms.TabControl tabDetails;
......
...@@ -21,8 +21,10 @@ namespace Tools.MyAgent.Forms ...@@ -21,8 +21,10 @@ namespace Tools.MyAgent.Forms
{ {
private const int MaxRows = 500; private const int MaxRows = 500;
private readonly string _connection; /// <summary>当前上报连接(明文,窗口内可经"连接配置"切换)。</summary>
private readonly string _table; private string _connection;
/// <summary>当前日志表名。</summary>
private string _table;
/// <summary>当前选中行的 RunId(详情二次查询用)。</summary> /// <summary>当前选中行的 RunId(详情二次查询用)。</summary>
private string _currentRunId; private string _currentRunId;
...@@ -295,6 +297,36 @@ namespace Tools.MyAgent.Forms ...@@ -295,6 +297,36 @@ namespace Tools.MyAgent.Forms
txtMetrics.Text = metrics; 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) private void OnExportCsvClick(object sender, EventArgs e)
......
...@@ -116,6 +116,7 @@ namespace Tools.MyAgent.Kb ...@@ -116,6 +116,7 @@ namespace Tools.MyAgent.Kb
private volatile int _fileCount = FileCountUnknown; private volatile int _fileCount = FileCountUnknown;
private volatile string _formatsSummary = ""; private volatile string _formatsSummary = "";
private volatile string _structureDigest = ""; private volatile string _structureDigest = "";
private volatile string _statsScope = "";
private DateTime _fileCountStamp = DateTime.MinValue; private DateTime _fileCountStamp = DateTime.MinValue;
private readonly object _statsLock = new object(); private readonly object _statsLock = new object();
private bool _statsRunning; private bool _statsRunning;
...@@ -146,7 +147,9 @@ namespace Tools.MyAgent.Kb ...@@ -146,7 +147,9 @@ namespace Tools.MyAgent.Kb
_structureDigest = ""; _structureDigest = "";
return; return;
} }
if (_fileCount >= 0 && DateTime.Now - _fileCountStamp < StatsTtl) if (_fileCount >= 0
&& string.Equals(_statsScope, SearchRoot ?? "", StringComparison.OrdinalIgnoreCase)
&& DateTime.Now - _fileCountStamp < StatsTtl)
return; return;
lock (_statsLock) lock (_statsLock)
...@@ -156,7 +159,6 @@ namespace Tools.MyAgent.Kb ...@@ -156,7 +159,6 @@ namespace Tools.MyAgent.Kb
} }
EnsureWatcher(); EnsureWatcher();
var root = Root;
var searchRoot = SearchRoot; var searchRoot = SearchRoot;
// P1 扫描可中断:统计扫描带超时取消(60 秒),超时后真正终止后台枚举,避免残留任务占满 IO // P1 扫描可中断:统计扫描带超时取消(60 秒),超时后真正终止后台枚举,避免残留任务占满 IO
var statsCts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); var statsCts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
...@@ -168,7 +170,7 @@ namespace Tools.MyAgent.Kb ...@@ -168,7 +170,7 @@ namespace Tools.MyAgent.Kb
var groups = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); var groups = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
try try
{ {
foreach (var file in SafeEnumerateFiles(root, statsCts.Token)) foreach (var file in SafeEnumerateFiles(searchRoot, statsCts.Token))
{ {
count++; count++;
var ext = Path.GetExtension(file).ToLowerInvariant(); var ext = Path.GetExtension(file).ToLowerInvariant();
...@@ -184,12 +186,19 @@ namespace Tools.MyAgent.Kb ...@@ -184,12 +186,19 @@ namespace Tools.MyAgent.Kb
catch (OperationCanceledException) { count = FileCountUnknown; } catch (OperationCanceledException) { count = FileCountUnknown; }
catch { count = FileCountUnknown; } catch { count = FileCountUnknown; }
try { digest = BuildStructureDigest(searchRoot, 30, 1000, IsConfiguredFileAllowed); } catch { digest = ""; } try { digest = BuildStructureDigest(searchRoot, 30, 1000, IsConfiguredFileAllowed); } catch { digest = ""; }
bool scopeChanged = !string.Equals(SearchRoot, searchRoot, StringComparison.OrdinalIgnoreCase);
lock (_statsLock) { _statsRunning = false; }
try { statsCts.Dispose(); } catch { }
if (scopeChanged)
{
EnsureStats();
return;
}
_fileCount = count; _fileCount = count;
_formatsSummary = formats; _formatsSummary = formats;
_structureDigest = digest; _structureDigest = digest;
_statsScope = searchRoot ?? "";
_fileCountStamp = DateTime.Now; _fileCountStamp = DateTime.Now;
lock (_statsLock) { _statsRunning = false; }
try { statsCts.Dispose(); } catch { }
StatsRefreshed?.Invoke(); StatsRefreshed?.Invoke();
}); });
} }
...@@ -221,6 +230,10 @@ namespace Tools.MyAgent.Kb ...@@ -221,6 +230,10 @@ namespace Tools.MyAgent.Kb
private void InvalidateStats() private void InvalidateStats()
{ {
_fileCount = FileCountUnknown; _fileCount = FileCountUnknown;
_fileCountStamp = DateTime.MinValue;
_statsScope = "";
_formatsSummary = "";
_structureDigest = "";
EnsureStats(); EnsureStats();
// 索引不再全量失效:变更由 FileSystemWatcher → KbIndex 单文件增量处理(P1), // 索引不再全量失效:变更由 FileSystemWatcher → KbIndex 单文件增量处理(P1),
// KbIndex 内部 15 分钟兜底重扫负责补齐丢事件场景 // KbIndex 内部 15 分钟兜底重扫负责补齐丢事件场景
...@@ -241,6 +254,7 @@ namespace Tools.MyAgent.Kb ...@@ -241,6 +254,7 @@ namespace Tools.MyAgent.Kb
_statsWatcher = null; _statsWatcher = null;
_fileCount = FileCountUnknown; _fileCount = FileCountUnknown;
_fileCountStamp = DateTime.MinValue; _fileCountStamp = DateTime.MinValue;
_statsScope = "";
_formatsSummary = ""; _formatsSummary = "";
_structureDigest = ""; _structureDigest = "";
} }
...@@ -382,26 +396,21 @@ namespace Tools.MyAgent.Kb ...@@ -382,26 +396,21 @@ namespace Tools.MyAgent.Kb
/// <summary>设置搜索范围;空表示整个知识库根目录。越界或非法路径会回退为整个知识库,避免越权访问。</summary> /// <summary>设置搜索范围;空表示整个知识库根目录。越界或非法路径会回退为整个知识库,避免越权访问。</summary>
public void SetScope(string path) public void SetScope(string path)
{ {
if (string.IsNullOrWhiteSpace(path)) string next = "";
if (!string.IsNullOrWhiteSpace(path))
{ {
Scope = ""; try
return;
}
try
{
string scope = NormPath(path);
if (string.IsNullOrEmpty(Root) || !IsWithin(NormPath(Root), scope))
{ {
Scope = ""; string scope = NormPath(path);
return; if (!string.IsNullOrEmpty(Root) && IsWithin(NormPath(Root), scope))
next = scope;
} }
Scope = scope; catch { }
}
catch
{
Scope = "";
} }
if (string.Equals(Scope, next, StringComparison.OrdinalIgnoreCase)) return;
Scope = next;
InvalidateStats();
} }
/// <summary>实际检索根目录:限定目录优先,否则为知识库根目录</summary> /// <summary>实际检索根目录:限定目录优先,否则为知识库根目录</summary>
...@@ -678,7 +687,7 @@ namespace Tools.MyAgent.Kb ...@@ -678,7 +687,7 @@ namespace Tools.MyAgent.Kb
try try
{ {
int count = 0; int count = 0;
foreach (var _ in SafeEnumerateFiles(Root, ct)) count++; foreach (var _ in SafeEnumerateFiles(SearchRoot, ct)) count++;
return count; return count;
} }
catch (OperationCanceledException) { return 0; } catch (OperationCanceledException) { return 0; }
...@@ -692,7 +701,7 @@ namespace Tools.MyAgent.Kb ...@@ -692,7 +701,7 @@ namespace Tools.MyAgent.Kb
var groups = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); var groups = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
try try
{ {
foreach (var file in SafeEnumerateFiles(Root, ct)) foreach (var file in SafeEnumerateFiles(SearchRoot, ct))
{ {
var ext = Path.GetExtension(file).ToLowerInvariant(); var ext = Path.GetExtension(file).ToLowerInvariant();
if (string.IsNullOrEmpty(ext)) ext = "(无扩展名)"; if (string.IsNullOrEmpty(ext)) ext = "(无扩展名)";
......
...@@ -147,6 +147,12 @@ ...@@ -147,6 +147,12 @@
<Compile Include="Forms\AgentRunLogForm.Designer.cs"> <Compile Include="Forms\AgentRunLogForm.Designer.cs">
<DependentUpon>AgentRunLogForm.cs</DependentUpon> <DependentUpon>AgentRunLogForm.cs</DependentUpon>
</Compile> </Compile>
<Compile Include="Forms\AgentDbConfigForm.cs">
<SubType>Form</SubType>
</Compile>
<Compile Include="Forms\AgentDbConfigForm.Designer.cs">
<DependentUpon>AgentDbConfigForm.cs</DependentUpon>
</Compile>
<Compile Include="Forms\DocLineViewer.cs"> <Compile Include="Forms\DocLineViewer.cs">
<SubType>Form</SubType> <SubType>Form</SubType>
</Compile> </Compile>
...@@ -197,6 +203,9 @@ ...@@ -197,6 +203,9 @@
<EmbeddedResource Include="Forms\AgentRunLogForm.resx"> <EmbeddedResource Include="Forms\AgentRunLogForm.resx">
<DependentUpon>AgentRunLogForm.cs</DependentUpon> <DependentUpon>AgentRunLogForm.cs</DependentUpon>
</EmbeddedResource> </EmbeddedResource>
<EmbeddedResource Include="Forms\AgentDbConfigForm.resx">
<DependentUpon>AgentDbConfigForm.cs</DependentUpon>
</EmbeddedResource>
<EmbeddedResource Include="Forms\AgentSettingsForm.resx"> <EmbeddedResource Include="Forms\AgentSettingsForm.resx">
<DependentUpon>AgentSettingsForm.cs</DependentUpon> <DependentUpon>AgentSettingsForm.cs</DependentUpon>
</EmbeddedResource> </EmbeddedResource>
......
...@@ -3,6 +3,7 @@ using System.Collections.Generic; ...@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Drawing; using System.Drawing;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
...@@ -26,6 +27,7 @@ namespace Tools.MyAgent ...@@ -26,6 +27,7 @@ namespace Tools.MyAgent
private readonly AgentEngine _engine; private readonly AgentEngine _engine;
/// <summary>聊天区增量渲染缓存:流式更新只重绘变化的消息块</summary> /// <summary>聊天区增量渲染缓存:流式更新只重绘变化的消息块</summary>
private readonly ChatRtfCache _rtfCache = new ChatRtfCache(); private readonly ChatRtfCache _rtfCache = new ChatRtfCache();
private string _lastRenderedChatRtf = "";
/// <summary>聊天区控件:RichTextBox(RichBox,RTF 渲染)</summary> /// <summary>聊天区控件:RichTextBox(RichBox,RTF 渲染)</summary>
private RichTextBox _rtfFallback; private RichTextBox _rtfFallback;
/// <summary>思考中旋转指示器(右下角悬浮;替代「思考文本铺满问答区」)</summary> /// <summary>思考中旋转指示器(右下角悬浮;替代「思考文本铺满问答区」)</summary>
...@@ -342,6 +344,22 @@ namespace Tools.MyAgent ...@@ -342,6 +344,22 @@ namespace Tools.MyAgent
/// <summary>默认知识库路径:程序输出目录下的 Agent 文件夹(未设置知识库时生效)</summary> /// <summary>默认知识库路径:程序输出目录下的 Agent 文件夹(未设置知识库时生效)</summary>
private string DefaultKbRoot => _agentRoot; private string DefaultKbRoot => _agentRoot;
/// <summary>产品上下文真正固定的搜索子目录;旧宿主把根目录作为范围时视为未固定。</summary>
private string ProductFixedScope
{
get
{
if (_productContext == null
|| string.IsNullOrEmpty(_productContext.KnowledgeBaseRoot)
|| string.IsNullOrEmpty(_productContext.KnowledgeBaseScope))
return "";
return IsPathUnder(_productContext.KnowledgeBaseRoot, _productContext.KnowledgeBaseScope)
&& IsPathUnder(_productContext.KnowledgeBaseScope, _productContext.KnowledgeBaseRoot)
? ""
: _productContext.KnowledgeBaseScope;
}
}
/// <summary>Agent 配置文件:默认 Agent 目录下的 config\AgentConfig.json</summary> /// <summary>Agent 配置文件:默认 Agent 目录下的 config\AgentConfig.json</summary>
private string AgentConfigFilePath => Path.Combine(DefaultKbRoot, "config", "AgentConfig.json"); private string AgentConfigFilePath => Path.Combine(DefaultKbRoot, "config", "AgentConfig.json");
...@@ -493,8 +511,8 @@ namespace Tools.MyAgent ...@@ -493,8 +511,8 @@ namespace Tools.MyAgent
_config.ProjectId = _productContext.ProjectId; _config.ProjectId = _productContext.ProjectId;
if (!string.IsNullOrEmpty(_productContext.KnowledgeBaseRoot)) if (!string.IsNullOrEmpty(_productContext.KnowledgeBaseRoot))
_config.KnowledgeBasePath = _productContext.KnowledgeBaseRoot; _config.KnowledgeBasePath = _productContext.KnowledgeBaseRoot;
if (!string.IsNullOrEmpty(_productContext.KnowledgeBaseScope)) if (!string.IsNullOrEmpty(ProductFixedScope))
_config.SearchScope = _productContext.KnowledgeBaseScope; _config.SearchScope = ProductFixedScope;
} }
// 旧版配置一次性迁移(明文密码加密 / 自动启用系统提示词),落盘持久化 // 旧版配置一次性迁移(明文密码加密 / 自动启用系统提示词),落盘持久化
if (migrated) if (migrated)
...@@ -543,8 +561,8 @@ namespace Tools.MyAgent ...@@ -543,8 +561,8 @@ namespace Tools.MyAgent
_config.ProjectId = _productContext.ProjectId; _config.ProjectId = _productContext.ProjectId;
if (!string.IsNullOrEmpty(_productContext.KnowledgeBaseRoot)) if (!string.IsNullOrEmpty(_productContext.KnowledgeBaseRoot))
_config.KnowledgeBasePath = _productContext.KnowledgeBaseRoot; _config.KnowledgeBasePath = _productContext.KnowledgeBaseRoot;
if (!string.IsNullOrEmpty(_productContext.KnowledgeBaseScope)) if (!string.IsNullOrEmpty(ProductFixedScope))
_config.SearchScope = _productContext.KnowledgeBaseScope; _config.SearchScope = ProductFixedScope;
} }
_engine.Configure(_config); _engine.Configure(_config);
_engine.SetOptionalTools(_config.EnableOptionalTools); _engine.SetOptionalTools(_config.EnableOptionalTools);
...@@ -646,6 +664,7 @@ namespace Tools.MyAgent ...@@ -646,6 +664,7 @@ namespace Tools.MyAgent
Load += (s, e) => Load += (s, e) =>
{ {
ApplyWindowModePadding();
_defaultChatCursor = _rtfFallback.Cursor; _defaultChatCursor = _rtfFallback.Cursor;
// 打开智能体时默认焦点落到输入框。 // 打开智能体时默认焦点落到输入框。
// 本窗体嵌入首页 TabPage(TopLevel=false),Load 时父窗体尚未显示/激活, // 本窗体嵌入首页 TabPage(TopLevel=false),Load 时父窗体尚未显示/激活,
...@@ -663,6 +682,12 @@ namespace Tools.MyAgent ...@@ -663,6 +682,12 @@ namespace Tools.MyAgent
VisibleChanged += (s, e) => FocusInput(); VisibleChanged += (s, e) => FocusInput();
} }
/// <summary>独立窗口增加 10px 内边距;嵌入宿主 Tab 时保持原有布局。</summary>
private void ApplyWindowModePadding()
{
Padding = TopLevel && Parent == null ? new Padding(10) : Padding.Empty;
}
/// <summary>RichTextBox 的点击/悬停交互绑定(启动时调用;新实例不会重复绑定)</summary> /// <summary>RichTextBox 的点击/悬停交互绑定(启动时调用;新实例不会重复绑定)</summary>
private void WireRtfEvents() private void WireRtfEvents()
{ {
...@@ -680,10 +705,12 @@ namespace Tools.MyAgent ...@@ -680,10 +705,12 @@ namespace Tools.MyAgent
private string BuildWelcomeText() private string BuildWelcomeText()
{ {
_kb.EnsureStats(); // 后台统计,不阻塞 UI;结果经 StatsRefreshed 回刷 _kb.EnsureStats(); // 后台统计,不阻塞 UI;结果经 StatsRefreshed 回刷
string kbInfo = _kb.IsValid string displayRoot = string.IsNullOrEmpty(_kb.Scope) ? _kb.Root : _kb.Scope;
? $"{_kb.Root}({FormatFileCount(_kb.FileCount)})" bool displayValid = !string.IsNullOrEmpty(displayRoot) && Directory.Exists(displayRoot);
: !string.IsNullOrEmpty(_kb.Root) string kbInfo = displayValid
? $"{_kb.Root}(当前不可访问,请检查共享连接)" ? $"{displayRoot}({FormatFileCount(_kb.FileCount)})"
: !string.IsNullOrEmpty(displayRoot)
? $"{displayRoot}(当前不可访问,请检查共享连接)"
: "未配置(点击「设置」选择文件夹)"; : "未配置(点击「设置」选择文件夹)";
var lines = new System.Collections.Generic.List<string> var lines = new System.Collections.Generic.List<string>
...@@ -707,15 +734,61 @@ namespace Tools.MyAgent ...@@ -707,15 +734,61 @@ namespace Tools.MyAgent
private void RefreshChatRtf() private void RefreshChatRtf()
{ {
if (_rtfFallback == null) return; if (_rtfFallback == null) return;
bool followBottom = IsChatAtBottom(out int firstVisibleLine);
string rtf = _rtfCache.Build(_messages, BuildWelcomeText(), "助手", CurrentUserName(), IsDetailsExpanded); string rtf = _rtfCache.Build(_messages, BuildWelcomeText(), "助手", CurrentUserName(), IsDetailsExpanded);
if (string.Equals(_lastRenderedChatRtf, rtf, StringComparison.Ordinal))
{
PositionThinkingSpinner();
return;
}
_lastRenderedChatRtf = rtf;
_rtfFallback.Rtf = rtf; _rtfFallback.Rtf = rtf;
_rtfFallback.Select(_rtfFallback.TextLength, 0); if (followBottom)
_rtfFallback.ScrollToCaret(); {
_rtfFallback.Select(_rtfFallback.TextLength, 0);
_rtfFallback.ScrollToCaret();
}
else
{
RestoreChatScroll(firstVisibleLine);
}
_answerImageSeq = _rtfCache.LastImageSequence; // 缩略图点击映射随本次 RTF 重建(v1.3.5) _answerImageSeq = _rtfCache.LastImageSequence; // 缩略图点击映射随本次 RTF 重建(v1.3.5)
RebuildAnswerRefs(); // 引用行表随重绘重建(位置随重绘失效);流式期间跳过 RebuildAnswerRefs(); // 引用行表随重绘重建(位置随重绘失效);流式期间跳过
PositionThinkingSpinner(); // v1.3.7:重绘/滚动到底后重算锚点(标题下方) PositionThinkingSpinner(); // v1.3.7:重绘/滚动到底后重算锚点(标题下方)
} }
/// <summary>判断聊天区重绘前是否已经位于底部;用户上滚查看历史时保持其视口。</summary>
private bool IsChatAtBottom(out int firstVisibleLine)
{
firstVisibleLine = 0;
if (_rtfFallback == null || !_rtfFallback.IsHandleCreated || _rtfFallback.TextLength == 0)
return true;
firstVisibleLine = SendMessage(_rtfFallback.Handle, EM_GETFIRSTVISIBLELINE, IntPtr.Zero, IntPtr.Zero).ToInt32();
int bottomChar = _rtfFallback.GetCharIndexFromPosition(
new Point(1, Math.Max(1, _rtfFallback.ClientSize.Height - 2)));
int bottomLine = _rtfFallback.GetLineFromCharIndex(Math.Max(0, bottomChar));
int lineCount = SendMessage(_rtfFallback.Handle, EM_GETLINECOUNT, IntPtr.Zero, IntPtr.Zero).ToInt32();
return bottomLine >= lineCount - 2;
}
/// <summary>恢复聊天区重绘前的首行位置,避免后台刷新把用户拉回回答末尾。</summary>
private void RestoreChatScroll(int firstVisibleLine)
{
if (_rtfFallback == null || !_rtfFallback.IsHandleCreated) return;
_rtfFallback.Select(0, 0);
int currentFirst = SendMessage(_rtfFallback.Handle, EM_GETFIRSTVISIBLELINE, IntPtr.Zero, IntPtr.Zero).ToInt32();
SendMessage(_rtfFallback.Handle, EM_LINESCROLL, IntPtr.Zero,
new IntPtr(firstVisibleLine - currentFirst));
}
private const int EM_GETFIRSTVISIBLELINE = 0x00CE;
private const int EM_LINESCROLL = 0x00B6;
private const int EM_GETLINECOUNT = 0x00BA;
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
/// <summary> /// <summary>
/// 重建引用行表(P4):从渲染文本中扫出 📎 引用行并解析 路径/章节/行号。 /// 重建引用行表(P4):从渲染文本中扫出 📎 引用行并解析 路径/章节/行号。
/// <para>时序纪律:① 每次重绘后重建,并与悬停缓存一起复位;② 流式期间不扫描(每帧全文正则是 O(n) 白耗), /// <para>时序纪律:① 每次重绘后重建,并与悬停缓存一起复位;② 流式期间不扫描(每帧全文正则是 O(n) 白耗),
...@@ -1013,12 +1086,12 @@ namespace Tools.MyAgent ...@@ -1013,12 +1086,12 @@ namespace Tools.MyAgent
return; return;
} }
string kbText; string kbText;
if (_kb.IsValid) string displayRoot = _kb.SearchRoot;
if (!string.IsNullOrEmpty(displayRoot))
{ {
kbText = Path.GetFileName(_kb.Root.TrimEnd('\\')); // 状态栏仅显示知识库名,不拼接文件数量 string name = Path.GetFileName(displayRoot.TrimEnd('\\', '/'));
kbText = Directory.Exists(displayRoot) ? name : $"{name}(不可访问)";
} }
else if (!string.IsNullOrEmpty(_kb.Root))
kbText = $"{Path.GetFileName(_kb.Root.TrimEnd('\\'))}(不可访问)";
else else
kbText = "未选知识库"; kbText = "未选知识库";
// 用量展示优化:状态栏不再拼接用量,用量改为附加到每条回复结尾 // 用量展示优化:状态栏不再拼接用量,用量改为附加到每条回复结尾
...@@ -2719,26 +2792,48 @@ namespace Tools.MyAgent ...@@ -2719,26 +2792,48 @@ namespace Tools.MyAgent
foreach (var f in folders) foreach (var f in folders)
cmbScope.Items.Add(f); cmbScope.Items.Add(f);
// 当前范围不是下拉项之一(历史遗留任意目录)→ 归一化为「全部」并持久化 // 当前范围必须仍位于知识库根目录且目录可访问;否则归一化为「全部」。
string scope = _config.SearchScope ?? ""; string scope = _config.SearchScope ?? "";
int idx = 0; int idx = 0;
if (!string.IsNullOrEmpty(scope) && !string.IsNullOrEmpty(root)) if (!string.IsNullOrEmpty(scope) && !string.IsNullOrEmpty(root))
{ {
try try
{ {
string rootNorm = Path.GetFullPath(root).TrimEnd('\\', '/');
string scopeNorm = Path.GetFullPath(scope).TrimEnd('\\', '/'); string scopeNorm = Path.GetFullPath(scope).TrimEnd('\\', '/');
bool matched = false; if (string.Equals(scopeNorm, rootNorm, StringComparison.OrdinalIgnoreCase)
for (int i = 0; i < folders.Count; i++) || !IsPathUnder(rootNorm, scopeNorm)
|| !Directory.Exists(scopeNorm))
{
scope = "";
}
else
{ {
string itemNorm = Path.GetFullPath(Path.Combine(root, folders[i])).TrimEnd('\\', '/'); bool matched = false;
if (string.Equals(scopeNorm, itemNorm, StringComparison.OrdinalIgnoreCase)) for (int i = 0; i < folders.Count; i++)
{ {
string itemNorm = Path.GetFullPath(Path.Combine(rootNorm, folders[i])).TrimEnd('\\', '/');
if (!string.Equals(scopeNorm, itemNorm, StringComparison.OrdinalIgnoreCase)) continue;
idx = i + 1; idx = i + 1;
matched = true; matched = true;
break; break;
} }
// 保留合法的深层目录,即使它不在默认的一/二级候选列表中。
if (!matched)
{
string relative = scopeNorm.Substring(rootNorm.Length).TrimStart('\\', '/').Replace('\\', '/');
if (!string.IsNullOrEmpty(relative))
{
cmbScope.Items.Add(relative);
idx = cmbScope.Items.Count - 1;
}
else
{
scope = "";
}
}
} }
if (!matched) scope = "";
} }
catch { scope = ""; } catch { scope = ""; }
if (!string.Equals(scope, _config.SearchScope, StringComparison.Ordinal)) if (!string.Equals(scope, _config.SearchScope, StringComparison.Ordinal))
......
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