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
......@@ -30,7 +30,7 @@ namespace Tools.MyAgent.Agent
// #31 故障转移
private FailoverPolicy _failover;
private OpenAiCompatibleClient _backupClient;
private IOpenAiChatClient _backupClient;
/// <summary>工具结果回填模型的字符上限(#42:超限压缩 + 完整结果落盘)。
/// 原 4000 过小且“头+尾”压缩丢失中间内容,迫使模型对长文件分页补读、成倍消耗工具轮;
......@@ -70,11 +70,31 @@ namespace Tools.MyAgent.Agent
public bool IsRunning { get; private set; }
/// <summary>历史消息条数上限(安全网;真正的上下文控制由 token 预算完成)</summary>
private const int MaxHistoryMessages = 60;
/// <summary>本会话累计 prompt tokens(D16 用量统计;由 SendAndLogAsync 累加,NewChat 时重置)</summary>
public int TotalPromptTokens { get; private set; }
/// <summary>工具调用轮次上限:防止模型反复调工具死循环烧 token / 卡 UI</summary>
private const int MaxToolRounds = 15;
/// <summary>本会话累计 completion tokens(D16)</summary>
public int TotalCompletionTokens { get; private set; }
/// <summary>本会话累计估算费用(元,R6;按实际服务模型与价格表累计,未收录模型不计费)</summary>
public double TotalCost { get; private set; }
/// <summary>最近一次实际服务模型(R6:故障转移时备用模型按备用价格计费)</summary>
private string _lastServedModel = "";
/// <summary>重置会话用量统计(新对话/加载历史时调用)</summary>
public void ResetUsage()
{
TotalPromptTokens = 0;
TotalCompletionTokens = 0;
TotalCost = 0;
}
/// <summary>历史消息条数上限(安全网;真正的上下文控制由 token 预算完成)——R12 可配置(默认 60)</summary>
private int _maxHistoryMessages = 60;
/// <summary>工具调用轮次上限:防止模型反复调工具死循环烧 token / 卡 UI——R12 可配置(默认 15)</summary>
private int _maxToolRounds = 15;
/// <summary>回答被输出上限截断(finish_reason=length)时的自动续写轮次上限,防止无限续写烧 token</summary>
private const int MaxContinuationRounds = 3;
......@@ -94,6 +114,12 @@ namespace Tools.MyAgent.Agent
/// <summary>单个工具执行超时(秒):全盘检索等同步操作无法硬中断,超时后放弃等待</summary>
private const int ToolTimeoutSeconds = 120;
/// <summary>
/// 运行代际:每次 RunAsync 自增。工具超时后残留的后台任务继续执行,
/// 其迟到副作用(OnFileProduced / 工具轨迹)据此抑制,避免跨轮污染。
/// </summary>
private int _runGeneration;
public AgentEngine(KbService kb)
{
_kb = kb;
......@@ -105,29 +131,39 @@ namespace Tools.MyAgent.Agent
public void Configure(AgentConfig cfg)
{
if (cfg == null) return;
bool proxyChanged = _client.UseSystemProxy != cfg.UseSystemProxy;
if (proxyChanged && !IsRunning)
// 代理开关:主/备用客户端统一按配置设置;非运行状态下重建 HttpClient
// (处理器在构造时绑定代理。修复:此前仅重建主客户端,备用客户端永远使用旧代理)
bool primaryProxyChanged = _client.UseSystemProxy != cfg.UseSystemProxy;
bool backupProxyChanged = _backupClient.UseSystemProxy != cfg.UseSystemProxy;
_client.UseSystemProxy = cfg.UseSystemProxy;
_backupClient.UseSystemProxy = cfg.UseSystemProxy;
if (!IsRunning)
{
// 代理开关变化:重建 HttpClient(处理器在构造时绑定代理)
try { _client.Dispose(); } catch { }
_client = new OpenAiCompatibleClient { UseSystemProxy = cfg.UseSystemProxy };
// 同步故障转移策略的主客户端引用,避免其持有已释放的旧实例(修复:提问报“无法访问已释放的对象 HttpClient”)
_failover.SetPrimary(_client);
if (primaryProxyChanged) _client.RebuildHttpClient();
if (backupProxyChanged) _backupClient.RebuildHttpClient();
}
_client.UseSystemProxy = cfg.UseSystemProxy;
_client.BaseUrl = string.IsNullOrEmpty(cfg.BaseUrl) ? "https://api.deepseek.com" : cfg.BaseUrl;
_client.ApiKey = cfg.ApiKey ?? "";
_client.Model = string.IsNullOrEmpty(cfg.Model) ? "deepseek-v4-flash" : cfg.Model;
_client.Temperature = cfg.Temperature;
_client.MaxTokens = cfg.MaxTokens > 0 ? cfg.MaxTokens : 8192;
_contextTokens = cfg.MaxContextTokens > 0 ? cfg.MaxContextTokens : 32000;
// R12:策略参数(越界回退默认,防止手改配置文件导致异常行为)
_maxToolRounds = ClampStrategy(cfg.MaxToolRounds, 1, 50, 15);
_maxHistoryMessages = ClampStrategy(cfg.MaxHistoryMessages, 3, 200, 60);
_prompts.CustomLayer = cfg.CustomSystemPrompt ?? "";
_prompts.SystemPromptEnabled = cfg.SystemPromptEnabled;
_imageInputEnabled = cfg.EnableImageInput; // v2.2 多模态:图片提问开关
CostEstimator.Configure(cfg.CustomPrices); // R6 费用估算:应用自定义价格表覆盖
// #31 故障转移配置
_failover.Configure(cfg.EnableFailover, cfg.BackupBaseUrl, cfg.BackupModel, cfg.BackupApiKey);
_backupClient.UseSystemProxy = cfg.UseSystemProxy;
}
/// <summary>策略参数取值钳制(R12:配置越界回退默认)</summary>
private static int ClampStrategy(int value, int min, int max, int fallback)
{
return value >= min && value <= max ? value : fallback;
}
/// <summary>配置是否启用可选/高级工具</summary>
......@@ -150,13 +186,14 @@ namespace Tools.MyAgent.Agent
{
_toolTrace.Clear();
_continueRounds = 0;
_runGeneration++;
var messages = await BuildMessagesAsync(history, _cts.Token).ConfigureAwait(false);
var tools = ToolSchema.Build(_includeOptionalTools);
var stopwatch = Stopwatch.StartNew();
int toolRounds = 0;
var finalAnswer = new StringBuilder(); // 跨轮累计的回答正文(UI 事件为全量替换语义)
// 工具调用循环:最多 MaxToolRounds 轮;超限/超时时做「收尾回答轮」而非直接放弃(修复「第一个问题就触发 15 轮上限、颗粒无收」)
// 工具调用循环:最多 _maxToolRounds 轮;超限/超时时做「收尾回答轮」而非直接放弃(修复「第一个问题就触发 15 轮上限、颗粒无收」)
while (true)
{
_cts.Token.ThrowIfCancellationRequested();
......@@ -212,14 +249,14 @@ namespace Tools.MyAgent.Agent
// 达到工具调用上限 / 总时长上限:不再静默丢弃已收集的信息,注入收尾指令再做一轮
// 「无工具收尾回答轮」,让模型基于已收集的信息汇总作答(修复「第一个问题触发 15 轮上限颗粒无收」)
toolRounds++;
bool roundsUp = toolRounds >= MaxToolRounds;
bool roundsUp = toolRounds >= _maxToolRounds;
bool timeUp = stopwatch.Elapsed > MaxRunDuration;
if (roundsUp || timeUp)
{
AgentLogger.Log(roundsUp
? $"工具调用轮次达到上限({MaxToolRounds} 轮),启动收尾回答轮"
? $"工具调用轮次达到上限({_maxToolRounds} 轮),启动收尾回答轮"
: "单次回答总时长达到上限,启动收尾回答轮");
OnTextDelta?.Invoke($"\n\n({(roundsUp ? $"已达到工具调用轮次上限({MaxToolRounds} 轮)" : "已达到单次回答总时长上限")},正在基于已收集的信息汇总回答…)");
OnTextDelta?.Invoke($"\n\n({(roundsUp ? $"已达到工具调用轮次上限({_maxToolRounds} 轮)" : "已达到单次回答总时长上限")},正在基于已收集的信息汇总回答…)");
messages.Add(new JObject { ["role"] = "user", ["content"] = FinalizePrompt });
var final = await SendAndLogAsync(messages, null, _cts.Token).ConfigureAwait(false);
if (final.ReasoningContent.Length > 0)
......@@ -291,8 +328,16 @@ namespace Tools.MyAgent.Agent
{
var start = DateTime.UtcNow;
var result = await SendWithFailoverAsync(messages, tools, ct).ConfigureAwait(false);
// #29 运行日志:每次调用记录 model / token / 耗时 / 工具
AgentLogger.LogCall(_client.Model, result.PromptTokens, result.CompletionTokens,
// D16 用量统计:会话累计(usage 缺失的响应跳过,不计入)
if (result.PromptTokens >= 0) TotalPromptTokens += result.PromptTokens;
if (result.CompletionTokens >= 0) TotalCompletionTokens += result.CompletionTokens;
// R6 费用估算:按实际服务模型与用量累计(未收录模型/无用量不计费)
if (result.PromptTokens > 0 || result.CompletionTokens > 0)
TotalCost += CostEstimator.Estimate(_lastServedModel, result.PromptTokens, result.CompletionTokens);
// #29 运行日志:每次调用记录 model / token / 耗时 / 工具(model 取实际服务模型:故障转移时备用模型)
AgentLogger.LogCall(
string.IsNullOrEmpty(_lastServedModel) ? _client.Model : _lastServedModel,
result.PromptTokens, result.CompletionTokens,
(long)(DateTime.UtcNow - start).TotalMilliseconds,
string.Join(";", result.ToolCalls.Select(tc => tc.Name + "(" + Clip(tc.Arguments, 40) + ")")),
result.FinishReason);
......@@ -347,8 +392,11 @@ namespace Tools.MyAgent.Agent
try
{
var r = await _failover.CurrentClient.SendAsync(messages, tools, true, ct).ConfigureAwait(false);
_lastServedModel = _failover.CurrentClient.Model; // R6:备用回答时按备用模型价格计费
// 先取切换状态再记成功:RecordSuccess 会立即复位 IsActive(否则“已恢复”提示永远不触发)
bool wasOnBackup = _failover.IsActive;
_failover.RecordSuccess();
if (_failover.IsActive)
if (wasOnBackup)
{
OnTextDelta?.Invoke("\n\n(主模型已恢复,切回主模型继续回答)");
}
......@@ -360,6 +408,7 @@ namespace Tools.MyAgent.Agent
{
OnTextDelta?.Invoke("\n\n(主模型服务异常,已自动切换至备用模型继续回答)");
AgentLogger.Log("FAILOVER 切换至备用模型:" + _failover.CurrentClient.Model);
_lastServedModel = _failover.CurrentClient.Model; // R6:切换后按备用模型价格计费
return await _failover.CurrentClient.SendAsync(messages, tools, true, ct).ConfigureAwait(false);
}
throw;
......@@ -442,7 +491,7 @@ namespace Tools.MyAgent.Agent
// 只保留最近 N 条 user / assistant(最终正文),跳过流式占位;真正的上下文控制交给 token 预算
var tail = history == null
? new List<ChatMessage>()
: history.Skip(Math.Max(0, history.Count - MaxHistoryMessages)).ToList();
: TakeTail(history, _maxHistoryMessages);
foreach (var m in tail)
{
......@@ -473,6 +522,21 @@ namespace Tools.MyAgent.Agent
return list;
}
/// <summary>
/// 截取最近 N 条消息;若截断后首条为 assistant(部分 OpenAI 兼容 API 拒绝首条非 user 消息),
/// 向前多取一条(交替布局下即为对应的 user 消息),保证请求以 user 开头。
/// </summary>
private static List<ChatMessage> TakeTail(IReadOnlyList<ChatMessage> history, int maxMessages)
{
int skip = Math.Max(0, history.Count - maxMessages);
if (skip > 0 && skip < history.Count
&& string.Equals(history[skip].Role, "assistant", StringComparison.OrdinalIgnoreCase))
{
skip--;
}
return history.Skip(skip).ToList();
}
/// <summary>
/// 组装用户消息 content:启用图片提问(v2.2)且消息带有效附图时,
/// 返回多模态内容块数组(text + 多个 image_url,base64 data URL);否则返回纯文本。
......@@ -547,15 +611,20 @@ namespace Tools.MyAgent.Agent
return content.Value<string>();
}
/// <summary>从消息列表头部(跳过 system)裁剪最早 user/assistant 对,直到预算内或只剩 3 条</summary>
/// <summary>从消息列表头部(跳过 system)裁剪最早消息,直到预算内或只剩 3 条。</summary>
/// <remarks>
/// 修复:原实现 firstUser 小于等于 1 即 break,而正常布局(system+user+...)下最早 user 恒在 index 1,
/// 「超预算裁剪最早消息」从未生效;现改为:最早 user 在 index 1 时连同其后一条 assistant 一起移除
/// (保持首条消息为 user 的 API 交替要求),否则移除 [前一 assistant, user] 对。
/// </remarks>
private static int DropOldestUntilUnder(JArray list, int budget)
{
int dropped = 0;
while (list.Count > 3 && EstimateTokens(list) > budget)
{
// 找到最早一个 user 消息
// 找到最早的 user 消息(跳过 system;不动最后一条消息——通常是当前问题)
int firstUser = -1;
for (int i = 1; i < list.Count; i++)
for (int i = 1; i < list.Count - 1; i++)
{
if (string.Equals(list[i]["role"]?.Value<string>(), "user", StringComparison.OrdinalIgnoreCase))
{
......@@ -563,24 +632,44 @@ namespace Tools.MyAgent.Agent
break;
}
}
if (firstUser <= 1) break; // 只剩 system + 最近一对,不再裁
if (firstUser < 0) break; // 无可裁剪的 user
// 连同其上方的 assistant 消息(如果存在)一起移除
int start = (firstUser > 1 &&
int start, count;
if (firstUser > 1 &&
string.Equals(list[firstUser - 1]["role"]?.Value<string>(), "assistant", StringComparison.OrdinalIgnoreCase))
? firstUser - 1
: firstUser;
int count = (start == firstUser - 1) ? 2 : 1;
for (int i = 0; i < count; i++)
{
start = firstUser - 1;
count = 2; // 移除 [前一 assistant, user] 对
}
else if (firstUser == 1 && list.Count > 3 &&
string.Equals(list[2]?["role"]?.Value<string>(), "assistant", StringComparison.OrdinalIgnoreCase))
{
start = 1;
count = 2; // 移除 [user, 后一 assistant] 对,保持首条为 user
}
else
{
break; // 其余情况不动(首条 user 无配对等),交由 LLM 摘要压缩兜底
}
if (list.Count - count < 3) break; // 保底:至少保留 system + 2 条
for (int k = 0; k < count; k++)
list.RemoveAt(start);
dropped += count;
}
return dropped;
}
/// <summary>
/// 历史摘要缓存(#12):头部消息未变化时复用上次压缩结果,避免每轮重复调用 LLM
/// </summary>
private string _summarySource = ""; // 上次压缩的原始输入文本
private string _summaryCache = ""; // 上次压缩结果
/// <summary>
/// 把列表中最早的一段(最多 6 条)user/assistant 消息压缩为一条摘要消息(LLM 异步),
/// 返回压缩后保留的消息条数(0 = 未压缩成功)。
/// <para>头部消息与上次压缩一致时直接复用缓存摘要(不重复调用 LLM,会话内追加式增长下头部不变)。</para>
/// </summary>
private async Task<int> SummarizeEarliestAsync(JArray list, CancellationToken ct)
{
......@@ -602,6 +691,13 @@ namespace Tools.MyAgent.Agent
if (earliestText.Length == 0) return 0;
string summary;
if (string.Equals(earliestText, _summarySource, StringComparison.Ordinal)
&& !string.IsNullOrEmpty(_summaryCache))
{
summary = _summaryCache; // 头部消息未变化:复用上次摘要,避免重复 LLM 调用
}
else
{
try
{
var prompt = new JArray
......@@ -618,6 +714,9 @@ namespace Tools.MyAgent.Agent
summary = (summary ?? "").Trim();
if (summary.Length == 0) return 0;
if (summary.Length > 800) summary = summary.Substring(0, 800);
_summarySource = earliestText;
_summaryCache = summary;
}
for (int i = 1; i <= take; i++)
list.RemoveAt(1);
......@@ -673,9 +772,10 @@ namespace Tools.MyAgent.Agent
/// </summary>
private async Task<string> ExecuteToolAsync(ApiToolCall tc, CancellationToken ct)
{
int gen = _runGeneration;
try
{
var execTask = Task.Run(() => ExecuteTool(tc));
var execTask = Task.Run(() => ExecuteTool(tc, gen));
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(ToolTimeoutSeconds), CancellationToken.None);
var cancelTask = Task.Delay(Timeout.Infinite, ct);
var done = await Task.WhenAny(execTask, timeoutTask, cancelTask).ConfigureAwait(false);
......@@ -692,7 +792,7 @@ namespace Tools.MyAgent.Agent
}
}
private string ExecuteTool(ApiToolCall tc)
private string ExecuteTool(ApiToolCall tc, int gen)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
try
......@@ -717,13 +817,17 @@ namespace Tools.MyAgent.Agent
if (tool.RequiresKb && (!_kb.ConnectIfNeeded() || !_kb.IsSearchValid))
return "错误:搜索范围不可用(知识库目录不存在/共享未连接/限定目录不可访问)。本地路径请在设置中选择;共享路径请检查服务器与共享名(如 " + @"\\服务器\共享名" + ")及账号密码配置。";
var ctx = new ToolContext(_kb, f => OnFileProduced?.Invoke(f));
// 超时/取消后残留的后台任务:抑制迟到副作用(输出文件误报/轨迹污染)
var ctx = new ToolContext(_kb,
f => { if (gen == System.Threading.Volatile.Read(ref _runGeneration)) OnFileProduced?.Invoke(f); });
string result = tool.Execute(args, ctx);
sw.Stop();
// #29 工具日志
AgentLogger.LogTool(tc.Name, tool.Describe(args), sw.ElapsedMilliseconds, SummarizeToolResult(result));
// #42 结果压缩:超长结果压缩后回填模型,完整结果落盘供溯源
result = CompressToolResult(result, tc.Name);
if (gen == System.Threading.Volatile.Read(ref _runGeneration))
{
lock (_toolTrace)
{
_toolTrace.Add(new ToolTraceEntry
......@@ -734,11 +838,14 @@ namespace Tools.MyAgent.Agent
ResultSummary = SummarizeToolResult(result),
});
}
}
return result;
}
catch (Exception ex)
{
sw.Stop();
if (gen == System.Threading.Volatile.Read(ref _runGeneration))
{
lock (_toolTrace)
{
_toolTrace.Add(new ToolTraceEntry
......@@ -749,6 +856,7 @@ namespace Tools.MyAgent.Agent
ResultSummary = "异常:" + ex.Message,
});
}
}
return $"错误:{ex.Message}";
}
}
......@@ -798,7 +906,7 @@ namespace Tools.MyAgent.Agent
if (ex is ObjectDisposedException || msg.Contains("已释放") || msg.Contains("disposed"))
return "内部组件已释放(客户端可能被重建),请重试;若持续出现请重启智能体页面。";
if (msg.Contains("401")) return "API Key 无效或未配置,请在「设置」中检查。";
if (msg.Contains("402")) return "账户余额不足,请到 DeepSeek 平台充值。";
if (msg.Contains("402")) return "账户余额不足,请到模型服务商平台充值或检查额度。";
if (msg.Contains("429")) return "请求过于频繁,请稍后再试。";
if (msg.Contains("413")) return "请求内容过长(上下文超出模型上限):已自动裁剪历史消息重试仍失败。请精简输入内容或开启新对话。";
if (msg.Contains("400"))
......
......@@ -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();
......
......@@ -38,7 +38,9 @@ namespace Tools.MyAgent.Agent
/// </summary>
public sealed class OpenAiCompatibleClient : IOpenAiChatClient, IDisposable
{
private readonly HttpClient _http;
private readonly object _httpLock = new object();
private HttpClient _http;
private bool _useSystemProxy = true;
public string BaseUrl { get; set; } = "https://api.deepseek.com";
public string ApiKey { get; set; } = "";
......@@ -47,8 +49,37 @@ namespace Tools.MyAgent.Agent
public int MaxTokens { get; set; } = 8192;
/// <summary>是否使用系统代理(默认 true;公司内网常用)。
/// 优先读取 HTTPS_PROXY / HTTP_PROXY 环境变量,否则用系统 IE 代理设置。</summary>
public bool UseSystemProxy { get; set; } = true;
/// 优先读取 HTTPS_PROXY / HTTP_PROXY 环境变量,否则用系统 IE 代理设置。
/// 修复:原实现属性为死字段(handler 在构造时绑定,开关从不生效);
/// 现由调用方在非运行状态调用 <see cref="RebuildHttpClient"/> 按当前值重建。</summary>
public bool UseSystemProxy
{
get => _useSystemProxy;
set => _useSystemProxy = value;
}
/// <summary>当前 HttpClient(线程安全读取)</summary>
private HttpClient Http
{
get { lock (_httpLock) return _http; }
}
/// <summary>
/// 按当前 UseSystemProxy 重建 HttpClient(处理器在构造时绑定代理)。
/// <para>调用时机:引擎配置变更且非运行状态(进行中请求会因旧实例释放而失败)。</para>
/// </summary>
public void RebuildHttpClient()
{
lock (_httpLock)
{
var old = _http;
_http = new HttpClient(CreateHandler(_useSystemProxy))
{
Timeout = TimeSpan.FromSeconds(180),
};
try { old?.Dispose(); } catch { }
}
}
/// <summary>最大尝试次数(1 次初始 + 2 次重试)</summary>
private const int MaxAttempts = 3;
......@@ -61,20 +92,41 @@ namespace Tools.MyAgent.Agent
/// <summary>推理内容增量(deepseek-reasoner 等思维链模型)</summary>
public event Action<string> OnReasoningDelta;
private CancellationTokenSource _cts;
private readonly object _ctsLock = new object();
private readonly HashSet<CancellationTokenSource> _active = new HashSet<CancellationTokenSource>();
public OpenAiCompatibleClient()
{
_http = new HttpClient(CreateHandler())
_http = new HttpClient(CreateHandler(_useSystemProxy))
{
Timeout = TimeSpan.FromSeconds(180),
};
}
/// <summary>构造 HttpClientHandler:按配置挂系统代理 / 环境变量代理;直连则禁用代理</summary>
private static HttpMessageHandler CreateHandler()
/// <summary>登记活动请求 CTS(D5:并发请求互不干扰)</summary>
private void RegisterCts(CancellationTokenSource cts)
{
lock (_ctsLock) _active.Add(cts);
}
/// <summary>注销并释放请求 CTS(请求结束/失败时调用)</summary>
private void UnregisterCts(CancellationTokenSource cts)
{
lock (_ctsLock) _active.Remove(cts);
try { cts.Dispose(); } catch { }
}
/// <summary>构造 HttpClientHandler:useSystemProxy=false 显式直连(不读环境变量/系统代理,
/// 避免公司代理拦截本地/内网服务);true 时按配置挂系统代理 / 环境变量代理</summary>
private static HttpMessageHandler CreateHandler(bool useSystemProxy)
{
var handler = new HttpClientHandler { UseProxy = true };
if (!useSystemProxy)
{
// 显式关闭代理:直连
handler.UseProxy = false;
return handler;
}
try
{
var proxy = ResolveProxy();
......@@ -158,14 +210,11 @@ namespace Tools.MyAgent.Agent
body["tool_choice"] = "auto";
}
// 每个请求使用独立的 CTS(避免并发请求互相取消)
// 每个请求使用独立的 CTS 并登记到活动集合(D5:取消/释放只影响本请求,Cancel() 全量中断)
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
RegisterCts(cts);
try
{
// 保存当前请求的 CTS,供 Cancel() 中断
try { _cts?.Dispose(); } catch { }
_cts = cts;
int contextTrimCount = 0;
bool maxTokensReduced = false;
bool temperatureRemoved = false;
......@@ -227,7 +276,7 @@ namespace Tools.MyAgent.Agent
}
finally
{
try { cts.Dispose(); } catch { }
UnregisterCts(cts);
}
}
......@@ -242,6 +291,8 @@ namespace Tools.MyAgent.Agent
/// <summary>
/// 裁剪 messages 中最早的一对 user/assistant 消息(保留 system 与最新内容),返回是否有可裁剪内容。
/// <para>用于 413 上下文超限时的自动降级:丢失的只是最早对话,当前轮次消息完整保留。</para>
/// <para>修复:被移除的 assistant 若带 tool_calls,其后的 tool 消息引用已不存在的 tool_call_id,
/// 会残留孤立 tool 消息导致下一次请求 400——移除时一并清理。</para>
/// </summary>
private static bool TrimOldestPair(JArray messages)
{
......@@ -255,10 +306,18 @@ namespace Tools.MyAgent.Agent
: i;
for (int k = start; k <= i; k++)
messages.RemoveAt(start);
// 清理孤立 tool 消息(紧随被移除 assistant(tool_calls) 之后)
while (start < messages.Count &&
string.Equals(messages[start]["role"]?.Value<string>(), "tool", StringComparison.OrdinalIgnoreCase))
messages.RemoveAt(start);
return true;
}
}
messages.RemoveAt(1);
// 兜底路径同样清理孤立 tool 消息
while (1 < messages.Count &&
string.Equals(messages[1]["role"]?.Value<string>(), "tool", StringComparison.OrdinalIgnoreCase))
messages.RemoveAt(1);
return messages.Count > 1;
}
......@@ -269,7 +328,7 @@ namespace Tools.MyAgent.Agent
req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + ApiKey);
req.Content = new StringContent(body.ToString(Formatting.None), Encoding.UTF8, "application/json");
using (var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false))
using (var resp = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false))
{
if (!resp.IsSuccessStatusCode)
{
......@@ -303,10 +362,14 @@ namespace Tools.MyAgent.Agent
return o is int i ? i : 0;
}
/// <summary>取消当前请求(中断流式读取,返回已取消)</summary>
/// <summary>取消全部活动请求(中断流式读取,返回已取消)</summary>
public void Cancel()
{
try { _cts?.Cancel(); } catch { }
lock (_ctsLock)
{
foreach (var cts in _active)
try { cts.Cancel(); } catch { }
}
}
/// <summary>连接测试:发送最小补全请求,返回(是否成功,信息)</summary>
......@@ -328,7 +391,7 @@ namespace Tools.MyAgent.Agent
req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + ApiKey);
req.Content = new StringContent(body.ToString(Formatting.None), Encoding.UTF8, "application/json");
var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cts.Token).ConfigureAwait(false);
var resp = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cts.Token).ConfigureAwait(false);
string json = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
......@@ -359,19 +422,23 @@ namespace Tools.MyAgent.Agent
["model"] = Model,
["messages"] = messages,
["stream"] = false,
["temperature"] = 0.3,
["max_tokens"] = maxTokens,
};
// 修复:推理模型(deepseek-reasoner 等)不支持 temperature,省略避免 400
// (此前硬编码 0.3,导致会话标题生成/历史摘要压缩对推理模型静默失败)
if (!IsReasoningModel(Model))
body["temperature"] = 0.3;
try { _cts?.Dispose(); } catch { }
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_cts.CancelAfter(20000);
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(20000);
RegisterCts(cts);
try
{
var req = new HttpRequestMessage(HttpMethod.Post, BaseUrl.TrimEnd('/') + "/chat/completions");
req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + ApiKey);
req.Content = new StringContent(body.ToString(Formatting.None), Encoding.UTF8, "application/json");
using (var resp = await _http.SendAsync(req, _cts.Token).ConfigureAwait(false))
using (var resp = await Http.SendAsync(req, cts.Token).ConfigureAwait(false))
{
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
......@@ -379,6 +446,11 @@ namespace Tools.MyAgent.Agent
return obj["choices"]?[0]?["message"]?["content"]?.Value<string>() ?? "";
}
}
finally
{
UnregisterCts(cts);
}
}
#region SSE 解析
......@@ -506,8 +578,17 @@ namespace Tools.MyAgent.Agent
public void Dispose()
{
try { _cts?.Dispose(); } catch { }
_http?.Dispose();
lock (_ctsLock)
{
foreach (var cts in _active)
try { cts.Cancel(); } catch { }
_active.Clear();
}
lock (_httpLock)
{
try { _http?.Dispose(); } catch { }
_http = null;
}
}
}
}
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