Commit 3b9cf83e authored by 段廷瑞's avatar 段廷瑞

feat: 智能体底层加固

parent 23830cb4
......@@ -66,3 +66,4 @@ bin
/AppHome/AgentChat
/AppHome/Agent/history
/AppHome/Agent
/AppHome/Agent/config/AgentConfig.json
{"ApiKey":"sk-bfaffa679dac4f7e9b2f128ee022022d","BaseUrl":"https://api.deepseek.com","Model":"deepseek-v4-flash","KnowledgeBasePath":"D:\\working\\docs\\个人文档","Temperature":0.3,"MaxTokens":2048}
\ No newline at end of file
This diff is collapsed.
......@@ -41,6 +41,7 @@ namespace Tools.MyAgent.Agent
if (!enabled && _failedOver)
{
RestorePrimary();
_failedOver = false; // 修复:复位备用状态标记,避免禁用后 IsActive 残留(误报“主模型已恢复”)
}
_consecutiveFailures = 0;
}
......@@ -96,12 +97,25 @@ namespace Tools.MyAgent.Agent
_current = _primary;
}
/// <summary>
/// 是否服务类故障(触发故障转移):限流(429)/服务端错误(5xx)/超时/连接失败。
/// <para>D4:带 HTTP 状态码的 4xx(401 Key 错误、400 参数错误等)属于配置/请求问题,
/// 不触发故障转移,直接报错提示用户修正,避免掩盖真实配置错误。</para>
/// </summary>
private static bool IsServiceError(Exception ex)
{
var msg = ex?.InnerException?.Message ?? ex?.Message ?? "";
if (ex == null) return false;
var msg = ex.InnerException?.Message ?? ex.Message ?? "";
// 带状态码的 HTTP 失败:仅 429 / 5xx 视为服务故障;其余 4xx 不是
if (ex is System.Net.Http.HttpRequestException)
{
if (ex.Data?["HttpStatusCode"] is int status)
return status == 429 || status >= 500;
return true; // 无状态码 = 未收到响应(连接层失败),视为服务故障
}
return msg.Contains("429") || msg.Contains("500") || msg.Contains("502") || msg.Contains("503")
|| msg.Contains("504") || msg.Contains("超时") || msg.Contains("timeout") || msg.Contains("timed out")
|| ex is System.Net.Http.HttpRequestException
|| msg.Contains("connection") || msg.Contains("连接");
}
}
......
......@@ -26,6 +26,9 @@ namespace Tools.MyAgent.Agent
/// <summary>连接测试</summary>
Task<(bool Ok, string Message)> TestConnectionAsync(CancellationToken ct);
/// <summary>按当前 UseSystemProxy 重建 HttpClient(代理开关变化时由调用方在非运行状态调用)</summary>
void RebuildHttpClient();
/// <summary>取消当前请求</summary>
void Cancel();
......
namespace Tools.MyAgent.Config
{
using System.Collections.Generic;
/// <summary>
/// 自研智能体配置(保存于 Agent/config/AgentConfig.json)。
/// <para>属性分两层:**核心项**(主设置页可见,首次配置只需这几项)与
......@@ -92,5 +94,18 @@
/// <summary>是否启用图片提问(多模态,v2.2):启用后粘贴截图随提问以 image_url 发送,需视觉模型支持(如 DeepSeek-VL / qwen-vl);默认启用</summary>
public bool EnableImageInput { get; set; } = true;
// ════════════════ 高级项:策略参数(R12) ════════════════
/// <summary>工具调用轮次上限(默认 15;超限后进入收尾回答轮,基于已收集信息汇总作答)</summary>
public int MaxToolRounds { get; set; } = 15;
/// <summary>历史消息条数上限(默认 60;安全网,真正的上下文控制由 token 预算完成)</summary>
public int MaxHistoryMessages { get; set; } = 60;
// ════════════════ 高级项:费用估算 ════════════════
/// <summary>费用估算价格覆盖(R6):模型名(子串匹配)→ "输入价,输出价"(元/百万 token);留空/缺省用内置价格表</summary>
public Dictionary<string, string> CustomPrices { get; set; } = new Dictionary<string, string>();
}
}
......@@ -97,6 +97,11 @@ namespace Tools.MyAgent.Forms
})
{
if (dlg.ShowDialog(this) != DialogResult.OK) return;
// D13:导出覆盖确认
if (File.Exists(dlg.FileName) &&
MessageBox.Show(this, $"导出文件已存在,是否覆盖?\n\n{dlg.FileName}", "确认覆盖",
MessageBoxButtons.YesNo, MessageBoxIcon.Warning) != DialogResult.Yes)
return;
var saved = HistoryManager.ExportSessionMarkdown(summary.FilePath, dlg.FileName);
if (saved != null)
AgentUtils.Toast($"已导出:{saved}", 3000);
......
......@@ -63,6 +63,12 @@
this.numContext = new System.Windows.Forms.NumericUpDown();
this.lblContextHint = new System.Windows.Forms.Label();
this.chkProxy = new System.Windows.Forms.CheckBox();
this.grpStrategy = new System.Windows.Forms.GroupBox();
this.lblToolRounds = new System.Windows.Forms.Label();
this.numToolRounds = new System.Windows.Forms.NumericUpDown();
this.lblHistory = new System.Windows.Forms.Label();
this.numHistory = new System.Windows.Forms.NumericUpDown();
this.lblStrategyHint = new System.Windows.Forms.Label();
this.grpSem = new System.Windows.Forms.GroupBox();
this.chkSem = new System.Windows.Forms.CheckBox();
this.lblEmbUrl = new System.Windows.Forms.Label();
......@@ -103,6 +109,9 @@
((System.ComponentModel.ISupportInitialize)(this.numTemp)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.numMaxTokens)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.numContext)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.numToolRounds)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.numHistory)).BeginInit();
this.grpStrategy.SuspendLayout();
this.grpSem.SuspendLayout();
this.tabPrompt.SuspendLayout();
this.grpPrompt.SuspendLayout();
......@@ -371,6 +380,7 @@
//
this.tabAdv.AutoScroll = true;
this.tabAdv.Controls.Add(this.grpGen);
this.tabAdv.Controls.Add(this.grpStrategy);
this.tabAdv.Controls.Add(this.grpSem);
this.tabAdv.Location = new System.Drawing.Point(4, 26);
this.tabAdv.Name = "tabAdv";
......@@ -624,6 +634,97 @@
this.lblEmbHint.TabIndex = 7;
this.lblEmbHint.Text = "(在线服务需填 API Key;本地 Ollama 留空)";
//
// grpStrategy
//
this.grpStrategy.Controls.Add(this.lblStrategyHint);
this.grpStrategy.Controls.Add(this.lblToolRounds);
this.grpStrategy.Controls.Add(this.numToolRounds);
this.grpStrategy.Controls.Add(this.lblHistory);
this.grpStrategy.Controls.Add(this.numHistory);
this.grpStrategy.Location = new System.Drawing.Point(8, 284);
this.grpStrategy.Name = "grpStrategy";
this.grpStrategy.Size = new System.Drawing.Size(596, 92);
this.grpStrategy.TabIndex = 2;
this.grpStrategy.TabStop = false;
this.grpStrategy.Text = "策略参数";
//
// lblToolRounds
//
this.lblToolRounds.AutoSize = true;
this.lblToolRounds.Location = new System.Drawing.Point(16, 32);
this.lblToolRounds.Name = "lblToolRounds";
this.lblToolRounds.Size = new System.Drawing.Size(70, 17);
this.lblToolRounds.TabIndex = 0;
this.lblToolRounds.Text = "工具轮上限";
//
// numToolRounds
//
this.numToolRounds.Location = new System.Drawing.Point(92, 28);
this.numToolRounds.Maximum = new decimal(new int[] {
50,
0,
0,
0});
this.numToolRounds.Minimum = new decimal(new int[] {
1,
0,
0,
0});
this.numToolRounds.Name = "numToolRounds";
this.numToolRounds.Size = new System.Drawing.Size(70, 23);
this.numToolRounds.TabIndex = 1;
this.numToolRounds.Value = new decimal(new int[] {
15,
0,
0,
0});
//
// lblHistory
//
this.lblHistory.AutoSize = true;
this.lblHistory.Location = new System.Drawing.Point(303, 32);
this.lblHistory.Name = "lblHistory";
this.lblHistory.Size = new System.Drawing.Size(70, 17);
this.lblHistory.TabIndex = 2;
this.lblHistory.Text = "历史消息数";
//
// numHistory
//
this.numHistory.Increment = new decimal(new int[] {
5,
0,
0,
0});
this.numHistory.Location = new System.Drawing.Point(383, 28);
this.numHistory.Maximum = new decimal(new int[] {
200,
0,
0,
0});
this.numHistory.Minimum = new decimal(new int[] {
3,
0,
0,
0});
this.numHistory.Name = "numHistory";
this.numHistory.Size = new System.Drawing.Size(70, 23);
this.numHistory.TabIndex = 3;
this.numHistory.Value = new decimal(new int[] {
60,
0,
0,
0});
//
// lblStrategyHint
//
this.lblStrategyHint.AutoSize = true;
this.lblStrategyHint.ForeColor = System.Drawing.Color.Gray;
this.lblStrategyHint.Location = new System.Drawing.Point(16, 64);
this.lblStrategyHint.MaximumSize = new System.Drawing.Size(564, 0); // 限制宽度自动换行,避免说明超出界面产生横向滚动条
this.lblStrategyHint.Name = "lblStrategyHint";
this.lblStrategyHint.TabIndex = 8;
this.lblStrategyHint.Text = "(轮 = 单次回答中工具调用的轮数上限;条 = 发送给模型的历史消息条数上限)";
//
// tabPrompt
//
this.tabPrompt.AutoScroll = true;
......@@ -888,6 +989,10 @@
((System.ComponentModel.ISupportInitialize)(this.numTemp)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.numMaxTokens)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.numContext)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.numToolRounds)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.numHistory)).EndInit();
this.grpStrategy.ResumeLayout(false);
this.grpStrategy.PerformLayout();
this.grpSem.ResumeLayout(false);
this.grpSem.PerformLayout();
this.tabPrompt.ResumeLayout(false);
......@@ -940,6 +1045,12 @@
private System.Windows.Forms.NumericUpDown numContext;
private System.Windows.Forms.Label lblContextHint;
private System.Windows.Forms.CheckBox chkProxy;
private System.Windows.Forms.GroupBox grpStrategy;
private System.Windows.Forms.Label lblToolRounds;
private System.Windows.Forms.NumericUpDown numToolRounds;
private System.Windows.Forms.Label lblHistory;
private System.Windows.Forms.NumericUpDown numHistory;
private System.Windows.Forms.Label lblStrategyHint;
private System.Windows.Forms.GroupBox grpSem;
private System.Windows.Forms.CheckBox chkSem;
private System.Windows.Forms.Label lblEmbUrl;
......
......@@ -125,6 +125,10 @@ namespace Tools.MyAgent.Forms
numContext.Value = _config.MaxContextTokens > 0 ? _config.MaxContextTokens : 32000;
chkProxy.Checked = _config.UseSystemProxy;
// 高级配置:策略参数(R12;越界回退默认,避免 NumericUpDown 赋值抛异常)
numToolRounds.Value = ClampToControl(_config.MaxToolRounds, numToolRounds, 15);
numHistory.Value = ClampToControl(_config.MaxHistoryMessages, numHistory, 60);
// 高级配置:语义检索
chkSem.Checked = _config.EnableSemanticSearch;
txtEmbUrl.Text = _config.EmbeddingBaseUrl;
......@@ -399,6 +403,13 @@ namespace Tools.MyAgent.Forms
}
}
/// <summary>配置值回填 NumericUpDown 时做范围钳制(越界/非法回退默认,避免赋值抛 ArgumentOutOfRangeException)</summary>
private static decimal ClampToControl(int value, NumericUpDown ctrl, int fallback)
{
if (value < (int)ctrl.Minimum || value > (int)ctrl.Maximum) value = fallback;
return value;
}
/// <summary>保存:一次性回写全部选项卡改动(取消则放弃全部改动)</summary>
private void OnSave(object sender, EventArgs e)
{
......@@ -418,6 +429,9 @@ namespace Tools.MyAgent.Forms
_config.MaxTokens = (int)numMaxTokens.Value;
_config.MaxContextTokens = (int)numContext.Value;
_config.UseSystemProxy = chkProxy.Checked;
// 高级配置:策略参数(R12)
_config.MaxToolRounds = (int)numToolRounds.Value;
_config.MaxHistoryMessages = (int)numHistory.Value;
// 高级配置:语义检索
_config.EnableSemanticSearch = chkSem.Checked;
_config.EmbeddingBaseUrl = txtEmbUrl.Text.Trim();
......
......@@ -55,36 +55,12 @@ namespace Tools.MyAgent.Support
}
}
/// <summary>导出当前会话为 Markdown(复用 HistoryManager.ExportSessionMarkdown)</summary>
/// <summary>导出当前会话为 Markdown(D6:统一复用 HistoryManager.ExportSessionMarkdown 核心逻辑)</summary>
public static string ExportSession(ChatSession session, string outputPath)
{
try
{
if (session?.Messages == null) return null;
var sb = new StringBuilder();
sb.AppendLine($"# {session.Title ?? "会话"}");
sb.AppendLine();
sb.AppendLine($"> 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}");
sb.AppendLine($"> 会话创建:{session.CreatedAt:yyyy-MM-dd HH:mm}");
sb.AppendLine();
foreach (var m in session.Messages)
{
string role = string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase) ? "用户" : "助手";
sb.AppendLine($"## {role}({m.Timestamp:HH:mm})");
sb.AppendLine();
if (!string.IsNullOrEmpty(m.ThinkingContent))
{
sb.AppendLine($"> 思考:{m.ThinkingContent.Replace("\r\n", " ").Replace('\r', ' ').Replace('\n', ' ')}");
sb.AppendLine();
}
sb.AppendLine(m.Content ?? "");
sb.AppendLine();
sb.AppendLine("---");
sb.AppendLine();
}
Directory.CreateDirectory(Path.GetDirectoryName(outputPath));
File.WriteAllText(outputPath, sb.ToString(), new UTF8Encoding(false));
return outputPath;
return HistoryManager.ExportSessionMarkdown(session, outputPath);
}
catch
{
......
......@@ -67,6 +67,51 @@ namespace Tools.MyAgent
return idx >= 0 ? fileName.Substring(idx + 1) : fileName;
}
// ── D12 会话草稿(崩溃恢复):固定 _draft.json,不进入历史列表,下次启动自动恢复 ──
/// <summary>草稿文件名(固定名,历史列表不展示)</summary>
public const string DraftFileName = "_draft";
/// <summary>草稿文件路径</summary>
public static string DraftFilePath(string historyDir)
{
return Path.Combine(historyDir, DraftFileName + ".json");
}
/// <summary>保存会话草稿(覆盖式,供崩溃恢复;不产生历史条目)</summary>
public static void SaveDraft(string historyDir, List<ChatMessage> messages)
{
try
{
SaveSession(historyDir, messages, DraftFileName);
}
catch { }
}
/// <summary>读取会话草稿;不存在/损坏返回 null</summary>
public static List<ChatMessage> LoadDraft(string historyDir)
{
try
{
string path = DraftFilePath(historyDir);
if (!File.Exists(path)) return null;
var session = LoadSession(path);
return session?.Messages != null && session.Messages.Count > 0 ? session.Messages : null;
}
catch { return null; }
}
/// <summary>删除会话草稿(新对话/恢复成功后调用)</summary>
public static void DeleteDraft(string historyDir)
{
try
{
string path = DraftFilePath(historyDir);
if (File.Exists(path)) File.Delete(path);
}
catch { }
}
/// <summary>更新会话文件的标题(智能体生成主题后回写)</summary>
public static void UpdateTitle(string filePath, string title)
{
......@@ -96,6 +141,9 @@ namespace Tools.MyAgent
var files = Directory.GetFiles(historyDir, "*.json");
foreach (var file in files)
{
// D12:草稿文件是崩溃恢复用的临时会话,不进入历史列表
if (string.Equals(Path.GetFileNameWithoutExtension(file), HistoryManager.DraftFileName, StringComparison.OrdinalIgnoreCase))
continue;
try
{
var session = LoadSession(file);
......@@ -181,6 +229,13 @@ namespace Tools.MyAgent
public static string ExportSessionMarkdown(string filePath, string outPath)
{
var session = LoadSession(filePath);
if (session?.Messages == null) return null;
return ExportSessionMarkdown(session, outPath);
}
/// <summary>会话对象 → Markdown 报告(导出核心逻辑;文件路径版与 BackupHelper 共用,D6 去重)</summary>
public static string ExportSessionMarkdown(ChatSession session, string outPath)
{
if (session?.Messages == null) return null;
var sb = new System.Text.StringBuilder();
sb.AppendLine($"# {session.Title ?? "会话"}");
......
using System;
using System.Collections.Generic;
namespace Tools.MyAgent
{
/// <summary>
/// 会话费用估算(R6):内置常见模型价格表(元/百万 token),按模型名子串匹配(最长匹配优先);
/// 支持通过 AgentConfig.CustomPrices 覆盖/新增价格(配置化)。未收录模型返回 0,不产生误导性费用展示。
/// </summary>
public static class CostEstimator
{
/// <summary>价格条目:每百万 token 价格(元,人民币)</summary>
public sealed class Price
{
public readonly double InputPer1M; // 每百万输入 token 价格(元)
public readonly double OutputPer1M; // 每百万输出 token 价格(元)
public Price(double input, double output)
{
InputPer1M = input;
OutputPer1M = output;
}
}
// 内置价格表(元/百万 token,按 2026-08 常见公开价整理,近似值;可经 CustomPrices 覆盖)。
// 匹配规则:模型名包含 Key 即命中,多个命中取最长 Key(避免 gpt-4o 误配 gpt-4o-mini)。
private static readonly (string Key, double In, double Out)[] BuiltinTable =
{
("deepseek-reasoner", 4.0, 16.0),
("deepseek-chat", 2.0, 8.0),
("deepseek-v4-flash", 1.0, 4.0),
("claude-3-5-sonnet", 21.6, 108.0),
("claude-3-5-haiku", 2.9, 14.4),
("gpt-4o-mini", 1.1, 4.4),
("gpt-4o", 18.0, 72.0),
("gpt-4-turbo", 7.2, 28.8),
("gpt-4", 21.6, 86.4),
("gpt-3.5-turbo", 3.6, 14.4),
("qwen-vl", 2.0, 8.0),
("qwen-max", 2.4, 9.6),
("qwen-plus", 0.8, 2.0),
("qwen-turbo", 0.3, 0.6),
("glm-4-flash", 0.0, 0.0),
("glm-4", 1.0, 1.0),
("moonshot", 2.0, 12.0),
("kimi", 2.0, 12.0),
("ernie", 0.8, 2.0),
("doubao", 0.8, 2.0),
// 本地/开源模型:免费(Ollama、llama 系列、qwen 开源版、embedding 模型)
("ollama", 0.0, 0.0),
("llama", 0.0, 0.0),
("qwen2.5", 0.0, 0.0),
("bge-m3", 0.0, 0.0),
};
private static readonly object _lock = new object();
private static Dictionary<string, Price> _custom = new Dictionary<string, Price>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 配置自定义价格覆盖(AgentConfig.CustomPrices):键 = 模型名子串,值 = "输入价,输出价"(元/百万 token)。
/// 自定义表优先于内置表(同为最长子串匹配);传入 null/空集合清除覆盖。
/// </summary>
public static void Configure(IDictionary<string, string> customPrices)
{
lock (_lock)
{
_custom = new Dictionary<string, Price>(StringComparer.OrdinalIgnoreCase);
if (customPrices == null) return;
foreach (var kv in customPrices)
{
if (string.IsNullOrWhiteSpace(kv.Key)) continue;
var parts = (kv.Value ?? "").Split(',');
double input, output;
if (parts.Length >= 2
&& double.TryParse(parts[0].Trim(), out input)
&& double.TryParse(parts[1].Trim(), out output)
&& input >= 0 && output >= 0)
{
_custom[kv.Key.Trim()] = new Price(input, output);
}
}
}
}
/// <summary>估算一次调用费用(元);模型未收录或无用量返回 0</summary>
public static double Estimate(string model, int promptTokens, int completionTokens)
{
if (promptTokens <= 0 && completionTokens <= 0) return 0;
var p = FindPrice(model);
if (p == null) return 0;
return Math.Max(0, promptTokens) / 1e6 * p.InputPer1M
+ Math.Max(0, completionTokens) / 1e6 * p.OutputPer1M;
}
/// <summary>费用文本:0/未收录显示 "—",小额保留 4 位小数,常规保留 2 位</summary>
public static string Format(double cost)
{
if (cost <= 0) return "—";
return cost < 0.01 ? $"¥{cost:0.####}" : $"¥{cost:0.##}";
}
/// <summary>匹配价格条目:自定义表优先,其次内置表;均为最长子串匹配;未收录返回 null</summary>
internal static Price FindPrice(string model)
{
if (string.IsNullOrEmpty(model)) return null;
// 自定义表(最长子串匹配)
Price bestCustom = null;
int bestLen = -1;
lock (_lock)
{
foreach (var kv in _custom)
{
if (model.IndexOf(kv.Key, StringComparison.OrdinalIgnoreCase) >= 0 && kv.Key.Length > bestLen)
{
bestLen = kv.Key.Length;
bestCustom = kv.Value;
}
}
}
if (bestCustom != null) return bestCustom;
// 内置表(最长子串匹配)
string bestKey = null;
bestLen = -1;
foreach (var t in BuiltinTable)
{
if (model.IndexOf(t.Key, StringComparison.OrdinalIgnoreCase) >= 0 && t.Key.Length > bestLen)
{
bestLen = t.Key.Length;
bestKey = t.Key;
}
}
if (bestKey == null) return null;
foreach (var t in BuiltinTable)
if (t.Key == bestKey) return new Price(t.In, t.Out);
return null;
}
}
}
......@@ -49,15 +49,11 @@ namespace Tools.MyAgent
private const int ClLink = 10;
private const int ClDeleted = 11;
// Matches Windows absolute paths: C:\..., D:\... with optional file extension
private static readonly Regex FilePathPattern = new Regex(
@"\b([A-Za-z]:[\\/](?:[^\s\\/:*?""<>|]+[\\/])*[^\s\\/:*?""<>|]+\.[a-zA-Z0-9]{1,10})\b",
RegexOptions.Compiled);
// Matches Windows absolute paths: C:\..., D:\... with optional file extension(D6:与 wMyAgent 命中检测共用 TextPatterns)
private static readonly Regex FilePathPattern = TextPatterns.WindowsFilePath;
// Matches UNC network paths: \\server\share\...(到空白/引号/常见句读标点为止)
private static readonly Regex UncFilePathPattern = new Regex(
@"\\\\[^\s<\"",。;:、!?))】》>]+",
RegexOptions.Compiled);
// Matches UNC network paths: \\server\share\...(D6:共用 TextPatterns)
private static readonly Regex UncFilePathPattern = TextPatterns.UncFilePath;
// Matches inline code that looks like a shell command
private static readonly Regex CommandPattern = new Regex(
......
using System;
using System.IO;
namespace Tools.MyAgent
{
/// <summary>
/// 存储自动清理(评估缺陷 #1):日志与中间产物按保留期自动清理,防止长期运行磁盘无限膨胀。
/// <para>口径:运行日志 / 检索空白 / 查询审计 / 工具结果 30 天;kb_view_image 中间产物 7 天;
/// _patch 补丁暂存 30 天;kb_write / doc_export 正式产出 90 天。
/// 会话历史为用户数据,保留历史弹窗的手动「清理 30 天前」按钮,不自动删除。</para>
/// </summary>
public static class StorageCleanup
{
/// <summary>日志类文件保留天数(agent 日志 / search_misses / db 审计 / tool_results)</summary>
public const int LogKeepDays = 30;
/// <summary>中间产物保留天数(kb_view_image 扫描件图片,可再生)</summary>
public const int ImageKeepDays = 7;
/// <summary>补丁暂存保留天数(_patch)</summary>
public const int PatchKeepDays = 30;
/// <summary>正式产出保留天数(kb_write / doc_export)</summary>
public const int OutputKeepDays = 90;
/// <summary>
/// 执行清理(启动 / 配置变更时调用;内部容错,单项失败不影响其他)。
/// </summary>
/// <param name="agentRoot">默认 Agent 目录(output 位于其下);日志目录按程序基目录 Logs 计算</param>
/// <param name="logsRoot">日志根目录(默认 程序基目录/Logs;测试可注入临时目录)</param>
public static void Run(string agentRoot, string logsRoot = null)
{
if (logsRoot == null)
logsRoot = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
if (string.IsNullOrEmpty(logsRoot)) return;
var now = DateTime.Now;
int removed = 0;
try
{
// 1. Logs 根目录:运行日志 / 检索空白 / 查询审计
if (Directory.Exists(logsRoot))
{
removed += CleanDir(logsRoot, "agent_*.log", LogKeepDays, now);
removed += CleanDir(logsRoot, "search_misses.csv", LogKeepDays, now);
removed += CleanDir(logsRoot, "db_query_audit_*.csv", LogKeepDays, now);
// 2. 工具结果落盘(超长工具结果溯源文件)
string toolResults = Path.Combine(logsRoot, "tool_results");
if (Directory.Exists(toolResults))
removed += CleanDir(toolResults, "*.txt", LogKeepDays, now);
}
// 3. 输出目录:中间产物短保留,正式产出长保留
if (!string.IsNullOrEmpty(agentRoot))
{
string output = Path.Combine(agentRoot, "output");
if (Directory.Exists(output))
{
removed += CleanDir(Path.Combine(output, "kb_view_image"), "*.*", ImageKeepDays, now);
removed += CleanDir(Path.Combine(output, "temp"), "*.*", ImageKeepDays, now); // 截图粘贴中间产物(可再生,与 kb_view_image 同口径)
removed += CleanDir(Path.Combine(output, "_patch"), "*.*", PatchKeepDays, now);
removed += CleanDir(Path.Combine(output, "kb_write"), "*.*", OutputKeepDays, now);
removed += CleanDir(Path.Combine(output, "doc_export"), "*.*", OutputKeepDays, now);
}
}
}
catch { /* 清理失败不影响启动 */ }
if (removed > 0)
AgentLogger.Log($"存储自动清理完成:删除 {removed} 个过期文件(日志 {LogKeepDays} 天 / 中间产物 {ImageKeepDays}~{PatchKeepDays} 天 / 正式产出 {OutputKeepDays} 天)");
}
/// <summary>删除目录内超过保留期的文件;返回删除数(单项失败跳过)</summary>
private static int CleanDir(string dir, string pattern, int keepDays, DateTime now)
{
if (!Directory.Exists(dir)) return 0;
int removed = 0;
try
{
foreach (var f in Directory.GetFiles(dir, pattern))
{
try
{
var fi = new FileInfo(f);
if ((now - fi.LastWriteTime).TotalDays > keepDays)
{
File.Delete(f);
removed++;
}
}
catch { }
}
}
catch { }
return removed;
}
}
}
using System.Text.RegularExpressions;
namespace Tools.MyAgent
{
/// <summary>
/// 智能体文本模式共享定义(D6 去重):MarkdownRenderer(RTF 渲染着色)与 wMyAgent(点击/悬停命中检测)
/// 共用同一组正则,避免两处定义漂移导致「渲染为链接但点不中 / 能点中但未着色」。
/// </summary>
internal static class TextPatterns
{
/// <summary>Windows 绝对路径(带扩展名),如 C:\...\file.txt</summary>
public static readonly Regex WindowsFilePath = new Regex(
@"\b([A-Za-z]:[\\/](?:[^\s\\/:*?""<>|]+[\\/])*[^\s\\/:*?""<>|]+\.[a-zA-Z0-9]{1,10})\b",
RegexOptions.Compiled);
/// <summary>UNC 网络路径 \\server\share\...(到空白/引号/常见句读标点为止)</summary>
public static readonly Regex UncFilePath = new Regex(
@"\\\\[^\s<\"",。;:、!?))】》>]+",
RegexOptions.Compiled);
/// <summary>http/https 链接</summary>
public static readonly Regex HttpUrl = new Regex(
@"https?://[^\s\r\n<]+",
RegexOptions.Compiled);
/// <summary>
/// 提示词注入标记(评估#4 参数级硬校验):写入内容命中这些语句时告警+留痕。
/// 覆盖中英文常见指令注入句式(“忽略以上规则/你是管理员/system prompt/ignore previous”等)。
/// </summary>
public static readonly Regex PromptInjectionMarker = new Regex(
@"(?i)(忽略以上|忽略之前|无视以上|无视之前|以上规则|你是管理员|你现在是|系统提示词|系统指令|系统提示|ignore\s+(all\s+)?(previous|prior|above)|disregard\s+(all\s+)?(previous|prior|above)|you\s+are\s+now|act\s+as\s+an?\s+admin|system\s*prompt)",
RegexOptions.Compiled);
/// <summary>检测文本中的提示词注入标记,返回首个命中;无命中返回 null(评估#4)</summary>
public static string FindInjectionMarker(string text)
{
if (string.IsNullOrEmpty(text)) return null;
var m = PromptInjectionMarker.Match(text);
return m.Success ? m.Value : null;
}
}
}
......@@ -250,6 +250,8 @@ namespace Tools.MyAgent
// 输出目录固定为默认 Agent 文件夹(与所选知识库无关),确保其存在
try { Directory.CreateDirectory(DefaultKbRoot); } catch { }
_kb.SetOutputRoot(DefaultKbRoot);
// 评估#1:存储自动清理(日志 30 天 / 中间产物 7~30 天 / 正式产出 90 天;后台执行不阻塞启动)
try { System.Threading.Tasks.Task.Run(() => StorageCleanup.Run(DefaultKbRoot)); } catch { }
_kb.EnsureIndexStarted(); // 后台构建知识库检索索引(含 docx/xlsx 内容),检索提速
RefreshScopeCombo(); // 先归一化搜索范围(命中下拉项/归一化为全部),再应用
_kb.SetScope(_config.SearchScope);
......@@ -311,6 +313,8 @@ namespace Tools.MyAgent
// 父窗体激活会夺焦,故在 Shown / Activated / VisibleChanged 中再次确保焦点。
ActiveControl = txtInput;
FocusInput();
// D12:崩溃恢复——上次未保存的会话草稿载入聊天区
RestoreDraftOnStart();
// 知识库文件数:启动时再触发一次后台统计(窗体句柄已就绪,可回刷欢迎语/状态栏)
_kb.EnsureStats();
UpdateStatusIndicator();
......@@ -397,7 +401,13 @@ namespace Tools.MyAgent
kbText = $"{Path.GetFileName(_kb.Root.TrimEnd('\\'))}(不可访问)";
else
kbText = "未选知识库";
lblStatus.Text = $"{_config.Model} · {kbText}";
// D16 用量统计:会话累计 token(prompt+completion),有调用记录时展示
// R6 费用估算:累计费用 > 0 时附带估算金额(按实际服务模型与价格表)
string usage = _engine.TotalPromptTokens + _engine.TotalCompletionTokens > 0
? $" · 本会话 {_engine.TotalPromptTokens}+{_engine.TotalCompletionTokens} tokens"
+ (_engine.TotalCost > 0 ? $"(约 {CostEstimator.Format(_engine.TotalCost)})" : "")
: "";
lblStatus.Text = $"{_config.Model} · {kbText}{usage}";
lblStatus.ForeColor = Color.Gray;
}
......@@ -493,8 +503,11 @@ namespace Tools.MyAgent
RefreshPendingBar();
_sessionSaved = true;
_rtfCache.Clear();
_engine.ResetUsage(); // D16:新对话重置会话用量统计
HistoryManager.DeleteDraft(_historyDir); // D12:已存为历史,删除草稿
ResetInputState();
RefreshChat();
UpdateStatusIndicator();
}
// ── 引擎事件处理 ─────────────────────────────────
......@@ -534,6 +547,8 @@ namespace Tools.MyAgent
}
ResetInputState();
RefreshChat();
UpdateStatusIndicator(); // D16:刷新会话累计用量
SaveDraftSession(); // D12:每轮回答完成即更新草稿(崩溃恢复)
}
/// <summary>工具轨迹(#16):回答末尾追加可折叠 details,展示工具调用链(先宽后窄的检索过程)</summary>
......@@ -582,6 +597,8 @@ namespace Tools.MyAgent
}
ResetInputState();
RefreshChat();
UpdateStatusIndicator(); // D16:错误结束也刷新用量
SaveDraftSession(); // D12:出错也保留当前对话为草稿
}
private ChatMessage LastAssistantMessage()
......@@ -2417,6 +2434,41 @@ namespace Tools.MyAgent
SaveCurrentSession(false);
}
/// <summary>
/// 保存会话草稿(D12):覆盖写 _draft.json,供崩溃/异常退出后下次启动恢复;不产生历史条目。
/// 在每轮回答完成/出错后调用,草稿始终是最新状态。
/// </summary>
private void SaveDraftSession()
{
if (_messages.Count == 0 || _engine.IsRunning) return;
_historyDir = HistoryManager.EnsureHistoryDir(WorkingDirForHistory());
HistoryManager.SaveDraft(_historyDir, _messages);
}
/// <summary>
/// 启动时恢复上次未保存的会话草稿(D12):存在草稿则载入聊天区并删除草稿文件。
/// 在窗体 Load(句柄就绪)后调用。
/// </summary>
private void RestoreDraftOnStart()
{
try
{
_historyDir = HistoryManager.EnsureHistoryDir(WorkingDirForHistory());
var draft = HistoryManager.LoadDraft(_historyDir);
if (draft == null || draft.Count == 0) return;
_messages.Clear();
_messages.AddRange(draft);
_rtfCache.Clear();
// 修复:恢复的草稿立即落为历史(否则删除草稿后若用户未继续对话直接关闭,
// 会话既不在历史中也无草稿,整段对话永久丢失)
SaveCurrentSession(false);
HistoryManager.DeleteDraft(_historyDir);
RefreshChat();
AddSystemMessage("(已恢复上次未保存的对话)");
}
catch { }
}
private async void FireAndForgetRenameTopic(string historyDir, string date, string fileName, List<ChatMessage> snapshot)
{
try
......
......@@ -206,10 +206,13 @@
<Compile Include="MyAgent\Support\ChatMessage.cs" />
<Compile Include="MyAgent\Support\ChatSession.cs" />
<Compile Include="MyAgent\Support\ChatRtfCache.cs" />
<Compile Include="MyAgent\Support\CostEstimator.cs" />
<Compile Include="MyAgent\Support\MarkdownRenderer.cs" />
<Compile Include="MyAgent\Support\NoCaretRichTextBox.cs">
<SubType>Component</SubType>
</Compile>
<Compile Include="MyAgent\Support\StorageCleanup.cs" />
<Compile Include="MyAgent\Support\TextPatterns.cs" />
<Compile Include="MyAgent\wMyAgent.cs">
<SubType>Form</SubType>
</Compile>
......
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