Commit 06b9d21a authored by 段廷瑞's avatar 段廷瑞

feat: 支持远程知识库(飞天兔大脑)

parent 12f2ed0d
......@@ -74,3 +74,4 @@ bin
/AppHome/Logs
/AppHome/Package
/.workbuddy
/temp
No preview for this file type
......@@ -601,6 +601,15 @@
<member name="F:Tools.MyAgent.Agent.KbRecall.MaxImages">
<summary>关联图片上限(内部默认 16;引擎 Configure 按 AgentConfig.MaxAnswerImages 注入)</summary>
</member>
<member name="F:Tools.MyAgent.Agent.KbRecall._remoteSources">
<summary>远程知识库源(解析自 AgentConfig.RemoteKbEntries;null/空 = 关闭)</summary>
</member>
<member name="M:Tools.MyAgent.Agent.KbRecall.ConfigureRemote(System.Collections.Generic.IEnumerable{System.String})">
<summary>注入远程知识库条目接口 URL 模板(引擎 Configure 期调用;无效模板静默丢弃)</summary>
</member>
<member name="F:Tools.MyAgent.Agent.KbRecall.RemoteListIntent">
<summary>列表意图:命中则走远程「目录模式」(返回全部条目标题清单),而非逐词检索</summary>
</member>
<member name="F:Tools.MyAgent.Agent.KbRecall.MaxHits">
<summary>纳入证据链的命中上限(主命中 + 其余)</summary>
</member>
......@@ -639,6 +648,19 @@
<param name="question">用户原始问题</param>
<param name="ct">取消令牌:全盘扫描兜底路径据此支持 ESC 取消(长库扫描不阻塞取消)</param>
</member>
<member name="M:Tools.MyAgent.Agent.KbRecall.Finalize(Tools.MyAgent.Agent.KbRecallResult,System.Collections.Generic.List{System.String},System.String,System.Threading.CancellationToken)">
<summary>统一出口:本地结果 + 远程条目补充(所有 return 路径都经过,保证 model 时远程兜底同样生效)。</summary>
</member>
<member name="M:Tools.MyAgent.Agent.KbRecall.AppendRemoteEvidence(Tools.MyAgent.Agent.KbRecallResult,System.Collections.Generic.List{System.String},System.String,System.Threading.CancellationToken)">
<summary>
远程知识库条目补充(v4.1):本地召回完成后,按本地关键词逐词并行查询远端条目接口。
<para>· 本地已召回(kb/mixed)→ 远程命中并入 <see cref="F:Tools.MyAgent.Agent.KbRecallResult.Others"/>;</para>
<para>· 本地未召回(model)→ 远程命中(含"最新条目"目录兜底)作为证据走 mixed(Reason 标注来源),
让"远程知识库都有什么 / 本地没有的问题"也能得到回答;</para>
<para>· 纪律:失败静默;远程命中不参与本地融合打分;AbsPath 留空 —— 不 Enrich、不进图片轨,
引用行走 AnswerRefPattern 兜底分支(零正则改动)。</para>
</summary>
</member>
<member name="M:Tools.MyAgent.Agent.KbRecall.BuildEvidenceMessage(Tools.MyAgent.Agent.KbRecallResult)">
<summary>命中 → 证据消息(作为一条 user 消息追加在问题之后,契约要求模型只据证据作答)</summary>
</member>
......@@ -1031,6 +1053,14 @@
<member name="P:Tools.MyAgent.Config.AgentConfig.SharePassword">
<summary>共享文件夹访问密码(可选;落盘时经 DPAPI 加密(见 SecretProtector),内存中为明文)</summary>
</member>
<member name="P:Tools.MyAgent.Config.AgentConfig.RemoteKbEntries">
<summary>
远程知识库条目接口 URL 模板数组(v4.1 外挂知识库)。
<para>每条模板必须含 <c>{q}</c> 占位符;可选 <c>{filter}</c>(服务端 source 等值过滤)/<c>{page}</c>/<c>{n}</c>。</para>
<para>**默认内置超级大脑条目接口**(开箱即用);设置页清空并保存 = 永久关闭。
检索策略固定为「逐词并行 + 本地合并打分」(见 next_pref_plan.md §3),其余参数为代码内常量。</para>
</summary>
</member>
<member name="P:Tools.MyAgent.Config.AgentConfig.Temperature">
<summary>采样温度</summary>
</member>
......@@ -1138,8 +1168,11 @@
<member name="P:Tools.MyAgent.Config.AgentConfig.RunsKeepDays">
<summary>runs_*.jsonl 保留天数(默认 14)。</summary>
</member>
<member name="P:Tools.MyAgent.Config.AgentConfig.ShowRunSummary">
<summary>回复尾注是否带本轮摘要(耗时/工具数/通道);默认关闭,避免打扰。</summary>
<member name="P:Tools.MyAgent.Config.AgentConfig.RunSummaryEnabled">
<summary>
回复尾注是否带本轮摘要(耗时/工具数/通道);默认开启(真机反馈:回答结尾应有用量信息)。
<para>v4.4 由 ShowRunSummary 改名而来:旧配置文件中的 false 值不再生效(一次性摆脱旧默认)。</para>
</summary>
</member>
<member name="P:Tools.MyAgent.Config.AgentConfig.AdminEnabled">
<summary>看板总开关;关闭时 /admin 提示不可用。</summary>
......@@ -1311,6 +1344,12 @@
<member name="M:Tools.MyAgent.Forms.AgentSettingsForm.#ctor">
<summary>仅设计器使用(WinForms 设计器要求无参构造);运行时请使用带参构造。</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentSettingsForm.BuildRemoteKbUi">
<summary>
远程知识库条目接口(next_pref_plan v4.1):追加到「高级 → 语义检索」二级页底部。
多行文本框,每行一条 URL 模板(须含 {q} 占位符);留空 = 功能关闭。
</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentSettingsForm.BuildDbConnectionsTab">
<summary>
新增「数据库连接」选项卡:ID / 连接串 两列表格,供 db_query 按 ID 引用。
......@@ -1332,6 +1371,9 @@
<member name="M:Tools.MyAgent.Forms.AgentSettingsForm.RefreshDashKpi">
<summary>即时 KPI(与 /stats 同源:AgentTelemetry.Snapshot)。</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentSettingsForm.Pct(System.Collections.Generic.List{System.Int64},System.Double)">
<summary>已排序列表取分位数(线性下标,与直方图口径近似)。</summary>
</member>
<member name="M:Tools.MyAgent.Forms.AgentSettingsForm.ToolRoleRulesToLines(System.Collections.Generic.Dictionary{System.String,System.String})">
<summary>角色规则字典 → 编辑文本(排序保证稳定回显)</summary>
</member>
......@@ -2174,6 +2216,68 @@
<para>复用平台 MyUtils.ImageSearch 感知哈希(aHash/dHash/pHash)能力,不再自带 OpenCV 实现。</para>
</summary>
</member>
<member name="T:Tools.MyAgent.Kb.Remote.RemoteEntryKb">
<summary>
远程知识库条目检索(next_pref_plan v4.5,本地缓存版):
全量条目**缓存到本地**(内存 + 磁盘 Agent/cache/remote_kb/),检索在本地完成(常态**零网络**、零等待)。
<para>缓存策略:首次同步拉取(page_size=100 一次请求)→ TTL 10 分钟内直接用缓存;
过期 → 先用旧缓存应答 + 后台静默刷新(不拖慢问答);网络失败 → 沿用旧缓存 + 60s 冷却。</para>
<para>检索:本地关键词对缓存条目做包含匹配 + 打分(2×title + 1×tags + 1×content,跨词累计);
列表意图走 <see cref="M:Tools.MyAgent.Kb.Remote.RemoteEntryKb.ListAll(System.Collections.Generic.List{Tools.MyAgent.Kb.Remote.RemoteEntryKb.Source},System.Threading.CancellationToken)"/>(按 source 分区的全量清单)。</para>
<para>纪律:只读、失败静默、不参与 kb/mixed/model 本地阈值判定、不落地镜像到知识库目录。</para>
</summary>
</member>
<member name="T:Tools.MyAgent.Kb.Remote.RemoteEntryKb.Source">
<summary>一个远程源(解析自 URL 模板;无效模板在解析期静默丢弃)</summary>
</member>
<member name="T:Tools.MyAgent.Kb.Remote.RemoteEntryKb.RawEntry">
<summary>缓存/传输用的原始条目</summary>
</member>
<member name="T:Tools.MyAgent.Kb.Remote.RemoteEntryKb.Hit">
<summary>合并打分后的命中(跨词累计;供证据注入)</summary>
</member>
<member name="F:Tools.MyAgent.Kb.Remote.RemoteEntryKb.Hit.IsFallback">
<summary>true = 逐词 0 命中后的「最新条目」目录兜底(非关键词命中)</summary>
</member>
<member name="M:Tools.MyAgent.Kb.Remote.RemoteEntryKb.ParseTemplates(System.Collections.Generic.IEnumerable{System.String})">
<summary>
解析 URL 模板列表(AgentConfig.RemoteKbEntries)。
无效行静默跳过:非绝对 http(s) URL、缺 {q} 占位符。
</summary>
</member>
<member name="M:Tools.MyAgent.Kb.Remote.RemoteEntryKb.Search(System.Collections.Generic.List{Tools.MyAgent.Kb.Remote.RemoteEntryKb.Source},System.Collections.Generic.IEnumerable{System.String},System.Threading.CancellationToken)">
<summary>
检索(本地缓存匹配,常态零网络):本地关键词对缓存条目做包含匹配 + 打分,返回 top <see cref="F:Tools.MyAgent.Kb.Remote.RemoteEntryKb.MaxInject"/>。
无源/无有效词/无缓存且拉取失败返回空表(不抛异常,除宿主取消外)。
</summary>
</member>
<member name="M:Tools.MyAgent.Kb.Remote.RemoteEntryKb.ListAll(System.Collections.Generic.List{Tools.MyAgent.Kb.Remote.RemoteEntryKb.Source},System.Threading.CancellationToken)">
<summary>
目录模式:取缓存全量条目(必要时触发一次同步拉取),按 source 分区输出 "#id 标题[标签]" 清单。
供「列出远程知识库内容 / 都有哪些条目」这类列表意图。失败返回 null(调用方静默降级)。
</summary>
</member>
<member name="M:Tools.MyAgent.Kb.Remote.RemoteEntryKb.FallbackList(System.Collections.Generic.List{Tools.MyAgent.Kb.Remote.RemoteEntryKb.RawEntry})">
<summary>目录兜底:0 命中时取缓存里最新的 <see cref="F:Tools.MyAgent.Kb.Remote.RemoteEntryKb.MaxInject"/> 条(服务端按 created_at 倒序返回)。</summary>
</member>
<member name="M:Tools.MyAgent.Kb.Remote.RemoteEntryKb.GetEntries(Tools.MyAgent.Kb.Remote.RemoteEntryKb.Source,System.Threading.CancellationToken)">
<summary>
取一个源的全量条目。返回 null = 无任何缓存且拉取失败(已 MarkDown 冷却)。
缓存过期时先用旧数据应答并触发后台静默刷新(不拖慢当前问答)。
</summary>
</member>
<member name="M:Tools.MyAgent.Kb.Remote.RemoteEntryKb.TriggerBackgroundRefresh(Tools.MyAgent.Kb.Remote.RemoteEntryKb.Source)">
<summary>后台静默刷新(fire-and-forget;成功后写内存+磁盘,下轮生效)。</summary>
</member>
<member name="M:Tools.MyAgent.Kb.Remote.RemoteEntryKb.BuildUrl(Tools.MyAgent.Kb.Remote.RemoteEntryKb.Source,System.String)">
<summary>模板替换:{q}/{filter}/{page}/{n}(方案 §1 占位符契约;未出现的占位符原样保留)</summary>
</member>
<member name="M:Tools.MyAgent.Kb.Remote.RemoteEntryKb.QueryAsync(Tools.MyAgent.Kb.Remote.RemoteEntryKb.Source,System.String,System.Int32,System.Threading.CancellationToken)">
<summary>拉取条目(search=word;word 空 = 全量)。返回 null = 失败/超时(失败信号)。</summary>
</member>
<member name="M:Tools.MyAgent.Kb.Remote.RemoteEntryKb.ParseEntries(Tools.MyAgent.Kb.Remote.RemoteEntryKb.Source,System.String)">
<summary>解析响应:{success,total,entries:[{id,title,content,source,tags}]};坏行跳过</summary>
</member>
<member name="T:Tools.MyAgent.Kb.SynonymIndex">
<summary>
同义词/术语扩展索引(检索 0 命中时的智能兜底)。
......@@ -3275,6 +3379,13 @@
<member name="T:Tools.MyAgent.wMyAgent.AnswerRef">
<summary>回答引用行(P4):渲染文本中的位置 + 解析出的 相对路径 / 章节 / 行号,供点击定位</summary>
</member>
<member name="F:Tools.MyAgent.wMyAgent.RemoteKbRefRegex">
<summary>
远程知识库引用行(v4.3):形如「远程·&lt;host&gt;·条目#40」「远程·&lt;host&gt;·条目目录」。
不带文件扩展名,<see cref="F:Tools.MyAgent.MarkdownRenderer.AnswerRefPattern"/> 不匹配 ⇒ 走独立正则扫描登记,
点击跳转对应知识库网页(OpenRemoteKbWeb)。
</summary>
</member>
<member name="F:Tools.MyAgent.wMyAgent._answerRefs">
<summary>
当前渲染文本中的引用行表。每次 <see cref="M:Tools.MyAgent.wMyAgent.RefreshChatRtf"/> 后清空重建(字符位置随重绘失效);
......@@ -3408,6 +3519,16 @@
「dialect/」「industry/」开头的引用走内置方言根目录解析;均已过沙箱校验,越界直接提示。
</summary>
</member>
<member name="M:Tools.MyAgent.wMyAgent.OpenRemoteKbWeb(System.String)">
<summary>
远程知识库引用行点击(v4.3):跳转对应知识库网页。
网页地址 = 接口模板的 origin(scheme://host:port,从设置页 RemoteKbEntries 按 host 匹配解析);
找不到匹配模板(配置已删源)时提示。
</summary>
</member>
<member name="M:Tools.MyAgent.wMyAgent.ResolveRemoteKbOrigin(System.String)">
<summary>按 host 匹配配置中的远程知识库模板,返回其 origin(如 http://192.168.1.108:8718/)。</summary>
</member>
<member name="M:Tools.MyAgent.wMyAgent.IsDetailsExpanded(System.Int32)">
<summary>指定消息的工具轨迹(&lt;details&gt;)是否展开</summary>
</member>
......
......@@ -301,6 +301,7 @@ namespace Tools.MyAgent.Agent
: 0.72;
_recall.ImagesEnabled = cfg.AnswerWithImages; // 关掉附图即不采图(省掉目录扫描)
_recall.MaxImages = cfg.MaxAnswerImages > 0 ? Math.Min(cfg.MaxAnswerImages, 16) : 16;
_recall.ConfigureRemote(cfg.RemoteKbEntries); // v4.1:远程知识库条目接口模板(空 = 关闭)
CostEstimator.Configure(cfg.CustomPrices); // R6 费用估算:应用自定义价格表覆盖
AgentTelemetry.Configure(cfg.TelemetryEnabled, cfg.ObservabilityLevel); // 可观测性开关与隐私级别
......@@ -542,6 +543,18 @@ namespace Tools.MyAgent.Agent
OnThinkingDelta?.Invoke(result.ReasoningContent);
await FinishAnswerAsync(messages, result, finalAnswer, ct).ConfigureAwait(false);
// v4.5b 空回答回退:直答偶发空内容(长目录证据噎住模型 / 自定义端点流异常中途断流,
// 真机实证 gpt-6-luna 直答 2.7s 断流 tokens=-1+-1)时,不能拿「(模型未返回内容)」占位收场 ——
// 回退工具循环重试。finalAnswer 里的占位必须清掉,否则工具循环的回答会接在占位后面。
if (finalAnswer.ToString().IndexOf("(模型未返回内容)", StringComparison.Ordinal) >= 0)
{
finalAnswer.Clear();
while (messages.Count > mark) messages.RemoveAt(messages.Count - 1); // 回滚证据消息
AgentLogger.Log("直答返回空内容,回退工具循环重试");
return false;
}
RaiseAnswerAttachments(recall); // P5:把命中图片交给宿主渲染附图轨(无图则不发)
return true;
}
......
......@@ -5,6 +5,7 @@ using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using Tools.MyAgent.Kb;
using Tools.MyAgent.Kb.Remote;
namespace Tools.MyAgent.Agent
{
......@@ -87,6 +88,22 @@ namespace Tools.MyAgent.Agent
/// <summary>关联图片上限(内部默认 16;引擎 Configure 按 AgentConfig.MaxAnswerImages 注入)</summary>
public int MaxImages = 16;
// ── 远程知识库条目补充(v4.1) ──
/// <summary>远程知识库源(解析自 AgentConfig.RemoteKbEntries;null/空 = 关闭)</summary>
private List<RemoteEntryKb.Source> _remoteSources;
/// <summary>注入远程知识库条目接口 URL 模板(引擎 Configure 期调用;无效模板静默丢弃)</summary>
public void ConfigureRemote(IEnumerable<string> urlTemplates)
{
_remoteSources = RemoteEntryKb.ParseTemplates(urlTemplates);
}
/// <summary>列表意图:命中则走远程「目录模式」(返回全部条目标题清单),而非逐词检索</summary>
private static readonly Regex RemoteListIntent = new Regex(
@"列出|列表|清单|目录|都有什么|有哪些|有什么内容|多少条|全部条目",
RegexOptions.Compiled);
/// <summary>纳入证据链的命中上限(主命中 + 其余)</summary>
private const int MaxHits = 3;
......@@ -213,7 +230,7 @@ namespace Tools.MyAgent.Agent
}
}
if (acc.Count == 0)
return new KbRecallResult { Mode = "model", Query = question, Keywords = keywords.Take(used).ToList() };
return Finalize(new KbRecallResult { Mode = "model", Query = question, Keywords = keywords.Take(used).ToList() }, keywords, question, ct);
}
// ── 融合分:来源基分 + 0.25×查询词覆盖率 + 0.05×额外来源数(上限 0.95) ──
......@@ -234,7 +251,7 @@ namespace Tools.MyAgent.Agent
.ThenBy(h => h.RelPath, StringComparer.OrdinalIgnoreCase)
.ToList();
if (ranked.Count == 0)
return new KbRecallResult { Mode = "model", Query = question, Keywords = keywords.Take(used).ToList() };
return Finalize(new KbRecallResult { Mode = "model", Query = question, Keywords = keywords.Take(used).ToList() }, keywords, question, ct);
var primary = ranked[0];
var result = new KbRecallResult
......@@ -253,18 +270,99 @@ namespace Tools.MyAgent.Agent
}
else
{
// 低于下界:不注入证据,回退原工具循环(保留原有检索/诊断能力,避免新通道削弱能力)
// 低于下界:本地证据不入链,回退前仍尝试远程兜底(Finalize 内统一处理)
result.Mode = "model";
return result;
return Finalize(result, keywords, question, ct);
}
// ── 只对进入证据链的命中补齐 行号/章节/正文/图片(读文件的成本只花在少量命中上) ──
Enrich(primary, MaxImages);
for (int i = 0; i < result.Others.Count; i++)
Enrich(result.Others[i], 0); // 次要命中不采图,避免附图轨被无关图片占满
// ── 远程知识库条目补充(v4.1):失败静默、不改变本地命中自身的分数 ──
return Finalize(result, keywords, question, ct);
}
/// <summary>统一出口:本地结果 + 远程条目补充(所有 return 路径都经过,保证 model 时远程兜底同样生效)。</summary>
private KbRecallResult Finalize(KbRecallResult result, List<string> keywords, string question, System.Threading.CancellationToken ct)
{
try { AppendRemoteEvidence(result, keywords, question, ct); }
catch (OperationCanceledException) { throw; } // 宿主取消:透传
catch { /* 静默:远程补充绝不破坏本地召回结果 */ }
return result;
}
/// <summary>
/// 远程知识库条目补充(v4.1):本地召回完成后,按本地关键词逐词并行查询远端条目接口。
/// <para>· 本地已召回(kb/mixed)→ 远程命中并入 <see cref="KbRecallResult.Others"/>;</para>
/// <para>· 本地未召回(model)→ 远程命中(含"最新条目"目录兜底)作为证据走 mixed(Reason 标注来源),
/// 让"远程知识库都有什么 / 本地没有的问题"也能得到回答;</para>
/// <para>· 纪律:失败静默;远程命中不参与本地融合打分;AbsPath 留空 —— 不 Enrich、不进图片轨,
/// 引用行走 AnswerRefPattern 兜底分支(零正则改动)。</para>
/// </summary>
private void AppendRemoteEvidence(KbRecallResult result, List<string> keywords, string question, System.Threading.CancellationToken ct)
{
if (_remoteSources == null || _remoteSources.Count == 0) return;
bool localMiss = result.Primary == null || result.Mode == "model";
// 列表意图("列出/都有什么/有哪些…"):走目录模式,返回全部条目标题清单(而非逐词检索的零散命中)
if (localMiss && !string.IsNullOrEmpty(question) && RemoteListIntent.IsMatch(question))
{
RemoteEntryKb.Hit dir;
try { dir = RemoteEntryKb.ListAll(_remoteSources, ct); }
catch (OperationCanceledException) { throw; }
catch { dir = null; }
if (dir != null)
{
result.Primary = ToKbHit(dir);
result.Mode = "mixed";
result.Reason = "本地知识库未命中,以下为远程知识库条目目录。";
return;
}
// 目录失败:继续走常规逐词/兜底路径
}
List<RemoteEntryKb.Hit> hits;
try { hits = RemoteEntryKb.Search(_remoteSources, keywords, ct); }
catch (OperationCanceledException) { throw; } // 宿主取消:透传(直答通道既有取消语义)
catch { return; } // 双保险:Search 承诺除取消外不抛
if (hits == null || hits.Count == 0) return;
if (!localMiss)
if (!localMiss)
{
foreach (var h in hits) result.Others.Add(ToKbHit(h));
return;
}
// 本地未召回:远程条目升级为 mixed 证据(首条 Primary,其余 Others)。
// Others 必须先清空 —— 此处 result.Others 可能还残留本地低分命中(<0.45,未达证据线),
// 它们已随 Enrich 采集了关联图片;混入会让"远程回答"挂上无关的本地图片(真机反馈 2026-10-08)。
var remote = hits.Select(ToKbHit).ToList();
result.Primary = remote[0];
result.Others.Clear();
result.Others.AddRange(remote.Skip(1));
result.Mode = "mixed";
result.Reason = "本地知识库未命中,以下引用来自远程知识库条目(可能不完全匹配,请结合判断)。";
}
private static KbHit ToKbHit(RemoteEntryKb.Hit h)
{
// Id=0 为目录模式 Hit:引用名显示「条目目录」(此前显示「条目#0·最新」有误导性)
string label = h.Id == 0
? "远程·" + h.SourceHost + "·条目目录"
: "远程·" + h.SourceHost + "·条目#" + h.Id + (h.IsFallback ? "·最新" : "");
return new KbHit
{
RelPath = label,
Snippet = "",
Paragraph = h.Text,
Score = h.Score,
HitKeywords = string.Join(",", h.HitWords),
};
}
/// <summary>命中 → 证据消息(作为一条 user 消息追加在问题之后,契约要求模型只据证据作答)</summary>
public string BuildEvidenceMessage(KbRecallResult r)
{
......
......@@ -570,6 +570,15 @@ namespace Tools.MyAgent.Agent
try { obj = JObject.Parse(data); }
catch { continue; }
// 用量(v4.4 修复):SSE 末帧 choices 为空数组、仅含 usage —— 必须在 choices 空判**之前**解析,
// 否则末帧被下方 continue 跳过,用量(含费用尾注)永远采集不到(真机反馈:回答尾部无用量)。
var usage = obj["usage"] as JObject;
if (usage != null)
{
result.PromptTokens = usage["prompt_tokens"]?.Value<int>() ?? -1;
result.CompletionTokens = usage["completion_tokens"]?.Value<int>() ?? -1;
}
var choices = obj["choices"] as JArray;
if (choices == null || choices.Count == 0) continue;
var first = choices[0] as JObject;
......@@ -578,14 +587,6 @@ namespace Tools.MyAgent.Agent
var fr = first["finish_reason"]?.Value<string>();
if (!string.IsNullOrEmpty(fr)) finishReason = fr;
// 用量(DeepSeek 在 SSE 末帧返回 usage)
var usage = obj["usage"] as JObject;
if (usage != null)
{
result.PromptTokens = usage["prompt_tokens"]?.Value<int>() ?? -1;
result.CompletionTokens = usage["completion_tokens"]?.Value<int>() ?? -1;
}
var delta = first["delta"] as JObject;
if (delta == null) continue;
......
......@@ -55,6 +55,17 @@
/// <summary>共享文件夹访问密码(可选;落盘时经 DPAPI 加密(见 SecretProtector),内存中为明文)</summary>
public string SharePassword { get; set; } = "";
/// <summary>
/// 远程知识库条目接口 URL 模板数组(v4.1 外挂知识库)。
/// <para>每条模板必须含 <c>{q}</c> 占位符;可选 <c>{filter}</c>(服务端 source 等值过滤)/<c>{page}</c>/<c>{n}</c>。</para>
/// <para>**默认内置超级大脑条目接口**(开箱即用);设置页清空并保存 = 永久关闭。
/// 检索策略固定为「逐词并行 + 本地合并打分」(见 next_pref_plan.md §3),其余参数为代码内常量。</para>
/// </summary>
public List<string> RemoteKbEntries { get; set; } = new List<string>
{
"http://192.168.1.108:8718/api/knowledge/entries?search={q}&page_size={n}&page={page}&partition={filter}",
};
// ════════════════ 高级项:生成参数 ════════════════
/// <summary>采样温度</summary>
......@@ -189,8 +200,11 @@
/// <summary>runs_*.jsonl 保留天数(默认 14)。</summary>
public int RunsKeepDays { get; set; } = 14;
/// <summary>回复尾注是否带本轮摘要(耗时/工具数/通道);默认关闭,避免打扰。</summary>
public bool ShowRunSummary { get; set; } = false;
/// <summary>
/// 回复尾注是否带本轮摘要(耗时/工具数/通道);默认开启(真机反馈:回答结尾应有用量信息)。
/// <para>v4.4 由 ShowRunSummary 改名而来:旧配置文件中的 false 值不再生效(一次性摆脱旧默认)。</para>
/// </summary>
public bool RunSummaryEnabled { get; set; } = true;
// ── Admin 看板 ──
......
......@@ -121,12 +121,49 @@ namespace Tools.MyAgent.Forms
BuildDbConnectionsTab(); // P0:预存数据库连接(db_query 按 ID 引用,聊天不回显密码)
BuildToolPermissionUi(); // P3:工具级权限(按角色)输入区
BuildDashboardTab(); // 可观测性:统计看板入口(替代隐晦的 /admin)
BuildRemoteKbUi(); // v4.1:远程知识库条目接口(高级 → 语义检索页底部)
// 回答形态不再提供设置页(v1.2):全部内部默认(见 AgentConfig 注释),降低设置页认知负担
BindConfig(); // 配置值回填控件(控件创建见 Designer.cs)
ApplyProviderMode(); // 布局联动:BaseUrl 行显隐
ApplyKbMode(); // 布局联动:共享 账号/密码/测试连接 行显隐
}
// ══ v4.1:远程知识库条目接口(程序化创建,避免侵入设计器) ══
private TextBox txtRemoteKb;
/// <summary>
/// 远程知识库条目接口(next_pref_plan v4.1):追加到「高级 → 语义检索」二级页底部。
/// 多行文本框,每行一条 URL 模板(须含 {q} 占位符);留空 = 功能关闭。
/// </summary>
private void BuildRemoteKbUi()
{
var grp = new GroupBox
{
Text = "远程知识库(HTTP 外挂 · 条目接口)",
Location = new Point(4, 142),
Size = new Size(590, 118),
TabIndex = 2,
};
var lbl = new Label
{
Text = "每行一条条目接口 URL 模板(须含 {q},可选 {filter}/{page}/{n})。\r\n默认已内置超级大脑接口;清空全部行并保存 = 关闭。服务不可达时自动冷却 60s。",
Location = new Point(10, 22),
Size = new Size(570, 34),
};
txtRemoteKb = new TextBox
{
Multiline = true,
ScrollBars = ScrollBars.Vertical,
Location = new Point(10, 58),
Size = new Size(570, 52),
TabIndex = 0,
};
grp.Controls.Add(lbl);
grp.Controls.Add(txtRemoteKb);
tabSubSem.Controls.Add(grp);
}
// ══ P0:预存数据库连接选项卡(程序化创建,避免侵入设计器) ══
private DataGridView dgvDbConn;
......@@ -225,7 +262,7 @@ namespace Tools.MyAgent.Forms
var grpKpi = new GroupBox
{
Text = "运行统计(本进程实时累计)",
Text = "运行统计(进程实时 + 近 7 天落盘,重启不丢)",
Location = new Point(8, 8),
Size = new Size(596, 172),
};
......@@ -306,7 +343,7 @@ namespace Tools.MyAgent.Forms
{
var st = Tools.MyAgent.AgentTelemetry.Snapshot();
var sb = new System.Text.StringBuilder();
sb.Append("本轮进程累计:\n");
sb.Append("本进程累计(实时):\n");
sb.Append(string.Format(" 轮数 {0}(成功 {1} · 失败 {2} · 取消 {3})\n",
st.Runs, st.Success, st.Errors, st.Cancels));
if (st.Duration.Count > 0)
......@@ -319,7 +356,43 @@ namespace Tools.MyAgent.Forms
if (tokens > 0) sb.Append(string.Format(" Token {0} + {1}\n", st.PromptTokens, st.CompletionTokens));
if (st.Cost > 0) sb.Append(" 费用 " + Tools.MyAgent.CostEstimator.Format(st.Cost) + "\n");
if (st.Dropped > 0) sb.Append(" ⚠ 丢弃 " + st.Dropped + " 条(采集跟不上)\n");
if (st.Runs == 0) sb.Append(" (本进程尚未完成任何一轮回答;多日历史见「打开完整看板」)\n");
// 近 7 天历史(runs_*.jsonl 落盘,重启不丢;与 /admin 完整看板同源)
sb.Append("\n近 7 天历史(落盘持久化):\n");
try
{
var rows = new Tools.MyAgent.TelemetryStore().LoadRuns();
if (rows.Length == 0)
sb.Append(" (暂无落盘记录;完成问答后写入 Logs\\runs_*.jsonl)\n");
else
{
int ok = 0, err = 0, cancel = 0;
long tok = 0;
double cost = 0;
var ms = new List<long>();
foreach (var r in rows)
{
if (r.Outcome == "success") ok++;
else if (r.Outcome == "error") err++;
else if (r.Outcome == "cancel") cancel++;
tok += r.PromptTokens + r.CompletionTokens;
cost += r.Cost;
if (r.TotalMs > 0) ms.Add(r.TotalMs);
}
ms.Sort();
sb.Append(string.Format(" 轮数 {0}(成功 {1} · 失败 {2} · 取消 {3})\n", rows.Length, ok, err, cancel));
if (ms.Count > 0)
sb.Append(string.Format(" 耗时 P50 {0}ms · P95 {1}ms · 最大 {2}ms\n",
Pct(ms, 0.5), Pct(ms, 0.95), ms[ms.Count - 1]));
if (tok > 0) sb.Append(" Token " + tok + "\n");
if (cost > 0) sb.Append(" 费用 " + Tools.MyAgent.CostEstimator.Format(cost) + "\n");
if (rows.Length > 0)
sb.Append(" 最新:" + rows[0].StartedAt.ToString("MM-dd HH:mm") + "(" + rows[0].Outcome + ")\n");
}
}
catch { sb.Append(" (历史记录读取失败)\n"); }
if (st.Runs == 0) sb.Append("\n(本进程尚未完成任何一轮回答;多日历史见「打开完整看板」)\n");
_lblDashKpi.Text = sb.ToString();
}
catch
......@@ -328,6 +401,16 @@ namespace Tools.MyAgent.Forms
}
}
/// <summary>已排序列表取分位数(线性下标,与直方图口径近似)。</summary>
private static long Pct(List<long> sorted, double p)
{
if (sorted == null || sorted.Count == 0) return 0;
int idx = (int)Math.Ceiling(sorted.Count * p) - 1;
if (idx < 0) idx = 0;
if (idx >= sorted.Count) idx = sorted.Count - 1;
return sorted[idx];
}
/// <summary>角色规则字典 → 编辑文本(排序保证稳定回显)</summary>
private static string[] ToolRoleRulesToLines(Dictionary<string, string> rules)
{
......@@ -406,6 +489,9 @@ namespace Tools.MyAgent.Forms
cmbEmbModel.Text = _config.EmbeddingModel;
txtEmbKey.Text = _config.EmbeddingApiKey;
// 远程知识库(v4.1):URL 模板逐行回填
txtRemoteKb.Text = string.Join(Environment.NewLine, _config.RemoteKbEntries ?? new List<string>());
// 提示词:启用开关 + 角色模板回填 + 自定义提示词
chkPromptEnabled.Checked = _config.SystemPromptEnabled;
PopulateRoles();
......@@ -865,6 +951,11 @@ namespace Tools.MyAgent.Forms
_config.EmbeddingBaseUrl = txtEmbUrl.Text.Trim();
_config.EmbeddingModel = string.IsNullOrEmpty(cmbEmbModel.Text.Trim()) ? "bge-m3" : cmbEmbModel.Text.Trim();
_config.EmbeddingApiKey = txtEmbKey.Text.Trim();
// 远程知识库(v4.1):每行一条 URL 模板,空行忽略
_config.RemoteKbEntries = (txtRemoteKb != null ? txtRemoteKb.Lines : new string[0])
.Select(l => (l ?? "").Trim())
.Where(l => l.Length > 0)
.ToList();
// 提示词:启用开关 + 角色模板 + 自定义系统提示词
_config.SystemPromptEnabled = chkPromptEnabled.Checked;
_config.RoleTemplateId = (cmbRole.SelectedItem as RoleItem)?.Id ?? "";
......
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Tools.MyAgent.Kb.Remote
{
/// <summary>
/// 远程知识库条目检索(next_pref_plan v4.5,本地缓存版):
/// 全量条目**缓存到本地**(内存 + 磁盘 Agent/cache/remote_kb/),检索在本地完成(常态**零网络**、零等待)。
/// <para>缓存策略:首次同步拉取(page_size=100 一次请求)→ TTL 10 分钟内直接用缓存;
/// 过期 → 先用旧缓存应答 + 后台静默刷新(不拖慢问答);网络失败 → 沿用旧缓存 + 60s 冷却。</para>
/// <para>检索:本地关键词对缓存条目做包含匹配 + 打分(2×title + 1×tags + 1×content,跨词累计);
/// 列表意图走 <see cref="ListAll"/>(按 source 分区的全量清单)。</para>
/// <para>纪律:只读、失败静默、不参与 kb/mixed/model 本地阈值判定、不落地镜像到知识库目录。</para>
/// </summary>
internal static class RemoteEntryKb
{
// ── 固定默认值(代码内常量,不进配置) ──
internal const int PageSize = 10; // {n}:单次返回条数(模板占位)
internal const int FullPageSize = 100; // 缓存刷新 / 目录:一次拉全量
internal const int MaxWords = 4; // 参与检索的词数上限
internal const int MinWordLength = 2; // 词长下限
internal const int MaxInject = 3; // 注入上限(条/轮)
internal const int MaxChars = 900; // 单条正文截断(实测条目 content ≈1000 字)
internal const int TimeoutMs = 3000; // 单请求超时
internal const int CacheTtlMinutes = 10; // 缓存 TTL
private static readonly HttpClient Http = CreateClient();
private static HttpClient CreateClient()
{
var c = new HttpClient();
c.Timeout = TimeSpan.FromMilliseconds(TimeoutMs + 500);
return c;
}
// ── 缓存(内存 + 磁盘)与失败冷却 ──
private static readonly object CacheLock = new object();
private static readonly Dictionary<string, List<RawEntry>> MemCache =
new Dictionary<string, List<RawEntry>>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, DateTime> FetchedAt =
new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
private static readonly HashSet<string> Refreshing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private static DateTime _downUntil = DateTime.MinValue;
private static readonly TimeSpan DownCooldown = TimeSpan.FromSeconds(60);
private static bool IsDown
{
get { lock (CacheLock) return DateTime.Now < _downUntil; }
}
private static void MarkDown()
{
lock (CacheLock) _downUntil = DateTime.Now.Add(DownCooldown);
}
private static string CacheDir
{
get { return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Agent", "cache", "remote_kb"); }
}
/// <summary>一个远程源(解析自 URL 模板;无效模板在解析期静默丢弃)</summary>
internal sealed class Source
{
public string Template = "";
public string Host = ""; // 展示用,如 192.168.1.108
public string Authority = ""; // scheme://host:port(缓存键 / 点击跳转 origin)
public string Filter = ""; // {filter}:source 等值过滤(服务端实现为 source = ?),空 = 全部
}
/// <summary>缓存/传输用的原始条目</summary>
private sealed class RawEntry
{
public int Id;
public string Host = ""; // 源 host(展示兜底:source 为空时用)
public string Title = "";
public string Content = "";
public string Tags = "";
public string Source = ""; // 服务端来源标识(即 source 等值过滤的取值;目录按此分组)
}
/// <summary>合并打分后的命中(跨词累计;供证据注入)</summary>
public sealed class Hit
{
public int Id;
public string Title = "";
public string Text = "";
public string Tags = "";
public string SourceHost = "";
public double Score; // 展示分(0.5~0.9),不参与 kb/mixed/model 判定
public List<string> HitWords = new List<string>();
/// <summary>true = 逐词 0 命中后的「最新条目」目录兜底(非关键词命中)</summary>
public bool IsFallback;
}
/// <summary>
/// 解析 URL 模板列表(AgentConfig.RemoteKbEntries)。
/// 无效行静默跳过:非绝对 http(s) URL、缺 {q} 占位符。
/// </summary>
public static List<Source> ParseTemplates(IEnumerable<string> templates)
{
var list = new List<Source>();
if (templates == null) return list;
foreach (var raw in templates)
{
var t = (raw ?? "").Trim();
if (t.Length == 0) continue;
if (t.IndexOf("{q}", StringComparison.Ordinal) < 0) continue;
Uri u;
try { u = new Uri(t.Replace("{q}", "probe")); }
catch { continue; }
if (u.Scheme != Uri.UriSchemeHttp && u.Scheme != Uri.UriSchemeHttps) continue;
list.Add(new Source
{
Template = t,
Host = u.Host,
Authority = u.GetLeftPart(UriPartial.Authority),
});
}
return list;
}
/// <summary>
/// 检索(本地缓存匹配,常态零网络):本地关键词对缓存条目做包含匹配 + 打分,返回 top <see cref="MaxInject"/>。
/// 无源/无有效词/无缓存且拉取失败返回空表(不抛异常,除宿主取消外)。
/// </summary>
public static List<Hit> Search(List<Source> sources, IEnumerable<string> keywords, CancellationToken ct)
{
var result = new List<Hit>();
if (sources == null || sources.Count == 0 || keywords == null) return result;
ct.ThrowIfCancellationRequested();
// ① 选词:保序去重,长度 ≥ MinWordLength,取前 MaxWords 个
var words = new List<string>();
foreach (var k in keywords)
{
var w = (k ?? "").Trim();
if (w.Length < MinWordLength) continue;
if (!words.Contains(w)) words.Add(w);
if (words.Count >= MaxWords) break;
}
if (words.Count == 0) return result;
// ② 取各源缓存条目(可能触发一次同步首拉)
var all = new List<RawEntry>();
foreach (var src in sources)
{
var items = GetEntries(src, ct);
if (items != null) all.AddRange(items);
}
if (all.Count == 0) return result;
// ③ 本地匹配 + 跨词累计打分(2×title + 1×tags + 1×content)
var byKey = new Dictionary<string, Hit>(StringComparer.Ordinal);
foreach (var r in all)
{
Hit cur;
var key = r.Source + "#" + r.Id; // 同源内 id 唯一;跨源用 source 前缀区分
if (!byKey.TryGetValue(key, out cur))
{
cur = new Hit
{
Id = r.Id,
Title = r.Title ?? "",
Text = Clip(r.Content, MaxChars),
Tags = r.Tags ?? "",
SourceHost = r.Source.Length > 0 ? r.Source : r.Host, // 展示用来源(即分区)
};
byKey[key] = cur;
}
foreach (var w in words)
{
if (cur.HitWords.Contains(w)) continue;
bool inTitle = cur.Title.IndexOf(w, StringComparison.OrdinalIgnoreCase) >= 0;
bool inTags = cur.Tags.IndexOf(w, StringComparison.OrdinalIgnoreCase) >= 0;
bool inContent = (r.Content ?? "").IndexOf(w, StringComparison.OrdinalIgnoreCase) >= 0;
if (!inTitle && !inTags && !inContent) continue;
cur.HitWords.Add(w);
if (inTitle) cur.Score += 2;
if (inTags) cur.Score += 1;
if (inContent) cur.Score += 1;
}
}
var matched = byKey.Values.Where(h => h.HitWords.Count > 0)
.OrderByDescending(h => h.Score).ThenBy(h => h.Id)
.Take(MaxInject).ToList();
if (matched.Count > 0)
{
foreach (var h in matched)
h.Score = Math.Min(0.9, 0.5 + 0.1 * h.HitWords.Count + 0.02 * h.Score);
return matched;
}
// ④ 全 0 命中:目录兜底(最新 3 条,供"远程知识库都有什么"类问题)
return FallbackList(all);
}
/// <summary>
/// 目录模式:取缓存全量条目(必要时触发一次同步拉取),按 source 分区输出 "#id 标题[标签]" 清单。
/// 供「列出远程知识库内容 / 都有哪些条目」这类列表意图。失败返回 null(调用方静默降级)。
/// </summary>
public static Hit ListAll(List<Source> sources, CancellationToken ct)
{
if (sources == null || sources.Count == 0) return null;
ct.ThrowIfCancellationRequested();
foreach (var src in sources)
{
var items = GetEntries(src, ct);
if (items == null || items.Count == 0) continue;
var sb = new StringBuilder();
sb.Append("远程知识库共 ").Append(items.Count)
.Append(" 条条目,按来源(分区)分组如下(格式:#id 标题[标签];如需某条详情请追问条目编号):");
foreach (var g in items.GroupBy(r => r.Source.Length > 0 ? r.Source : "未分组")
.OrderByDescending(g => g.Count()).ThenBy(g => g.Key, StringComparer.Ordinal))
{
sb.Append("\n\n【").Append(g.Key).Append("】共 ").Append(g.Count()).Append(" 条");
foreach (var r in g)
{
var title = (r.Title ?? "").Trim();
if (title.Length == 0) title = "(无标题)";
sb.Append("\n #").Append(r.Id).Append(" ").Append(title);
var tags = (r.Tags ?? "").Trim();
if (tags.Length > 0) sb.Append("(").Append(tags).Append(")");
}
}
return new Hit
{
Id = 0,
Title = "条目目录",
Text = sb.ToString(),
SourceHost = src.Host,
Score = 0.6,
IsFallback = true,
};
}
return null;
}
/// <summary>目录兜底:0 命中时取缓存里最新的 <see cref="MaxInject"/> 条(服务端按 created_at 倒序返回)。</summary>
private static List<Hit> FallbackList(List<RawEntry> all)
{
var result = new List<Hit>();
foreach (var r in all)
{
if (result.Any(h => h.Id == r.Id)) continue;
result.Add(new Hit
{
Id = r.Id,
Title = r.Title ?? "",
Text = Clip(r.Content, MaxChars),
Tags = r.Tags ?? "",
SourceHost = r.Source.Length > 0 ? r.Source : r.Host,
Score = 0.55, // 兜底基础分(低于真实关键词命中)
IsFallback = true,
});
if (result.Count >= MaxInject) break;
}
return result;
}
// ── 缓存获取:内存新鲜 → 磁盘新鲜 → stale+后台刷新 → 同步首拉 ──
/// <summary>
/// 取一个源的全量条目。返回 null = 无任何缓存且拉取失败(已 MarkDown 冷却)。
/// 缓存过期时先用旧数据应答并触发后台静默刷新(不拖慢当前问答)。
/// </summary>
private static List<RawEntry> GetEntries(Source src, CancellationToken ct)
{
string key = src.Authority;
lock (CacheLock)
{
if (MemCache.TryGetValue(key, out var mem) && FetchedAt.TryGetValue(key, out var at)
&& DateTime.Now - at < TimeSpan.FromMinutes(CacheTtlMinutes))
return mem;
}
var disk = LoadDiskCache(src);
if (disk != null && DateTime.Now - disk.Value.At < TimeSpan.FromMinutes(CacheTtlMinutes))
{
lock (CacheLock)
{
MemCache[key] = disk.Value.Items;
FetchedAt[key] = disk.Value.At;
}
return disk.Value.Items;
}
// 有旧数据(内存或磁盘,已过期):先返回旧数据应答,后台静默刷新
var stale = MemCache.ContainsKey(key) ? MemCache[key] : disk?.Items;
if (stale != null)
{
TriggerBackgroundRefresh(src);
return stale;
}
// 冷却期内不发起网络(首次拉取失败后 60s 内)
if (IsDown) return null;
// 首次:同步拉取(唯一可能慢的一次,实测全量 ≈0.2s)
ct.ThrowIfCancellationRequested();
var fresh = QueryAsync(src, "", FullPageSize, ct).ConfigureAwait(false).GetAwaiter().GetResult();
if (fresh != null && fresh.Count > 0)
{
lock (CacheLock)
{
MemCache[key] = fresh;
FetchedAt[key] = DateTime.Now;
}
SaveDiskCache(src, fresh);
return fresh;
}
MarkDown(); // 首拉失败:冷却 60s(期间直接返回 null,不拖累本地问答)
return null;
}
/// <summary>后台静默刷新(fire-and-forget;成功后写内存+磁盘,下轮生效)。</summary>
private static void TriggerBackgroundRefresh(Source src)
{
string key = src.Authority;
lock (CacheLock)
{
if (Refreshing.Contains(key)) return;
Refreshing.Add(key);
}
Task.Run(() =>
{
try
{
var fresh = QueryAsync(src, "", FullPageSize, CancellationToken.None)
.ConfigureAwait(false).GetAwaiter().GetResult();
if (fresh != null && fresh.Count > 0)
{
lock (CacheLock)
{
MemCache[key] = fresh;
FetchedAt[key] = DateTime.Now;
}
SaveDiskCache(src, fresh);
}
}
catch { }
finally
{
lock (CacheLock) Refreshing.Remove(key);
}
});
}
// ── 磁盘缓存 ──
private static string CacheFile(Source src)
{
var u = new Uri(src.Authority);
return Path.Combine(CacheDir, u.Host + "_" + u.Port + ".json");
}
private static void SaveDiskCache(Source src, List<RawEntry> items)
{
try
{
Directory.CreateDirectory(CacheDir);
var arr = new JArray(items.Select(r => new JObject
{
["id"] = r.Id,
["title"] = r.Title,
["content"] = r.Content,
["tags"] = r.Tags,
["source"] = r.Source,
}));
File.WriteAllText(CacheFile(src), arr.ToString(Newtonsoft.Json.Formatting.None), new UTF8Encoding(false)); // 无 BOM:外部工具直读友好
}
catch { }
}
private static (List<RawEntry> Items, DateTime At)? LoadDiskCache(Source src)
{
try
{
var path = CacheFile(src);
if (!File.Exists(path)) return null;
var arr = JArray.Parse(File.ReadAllText(path, Encoding.UTF8));
var items = new List<RawEntry>();
foreach (var e in arr.OfType<JObject>())
{
int id = e["id"]?.Value<int>() ?? 0;
if (id <= 0) continue;
items.Add(new RawEntry
{
Id = id,
Host = src.Host,
Title = (e["title"]?.Value<string>() ?? "").Trim(),
Content = e["content"]?.Value<string>() ?? "",
Tags = (e["tags"]?.Value<string>() ?? "").Trim(),
Source = (e["source"]?.Value<string>() ?? "").Trim(),
});
}
if (items.Count == 0) return null;
return (items, File.GetLastWriteTime(path));
}
catch { return null; }
}
// ── 网络(仅缓存刷新/首拉使用) ──
/// <summary>模板替换:{q}/{filter}/{page}/{n}(方案 §1 占位符契约;未出现的占位符原样保留)</summary>
internal static string BuildUrl(Source src, string word)
{
return BuildUrl(src, word, PageSize);
}
internal static string BuildUrl(Source src, string word, int pageSize)
{
return src.Template
.Replace("{q}", Uri.EscapeDataString(word ?? ""))
.Replace("{filter}", Uri.EscapeDataString(src.Filter ?? ""))
.Replace("{page}", "1")
.Replace("{n}", pageSize.ToString(CultureInfo.InvariantCulture));
}
/// <summary>拉取条目(search=word;word 空 = 全量)。返回 null = 失败/超时(失败信号)。</summary>
private static async Task<List<RawEntry>> QueryAsync(Source src, string word, int pageSize, CancellationToken ct)
{
try
{
string url = BuildUrl(src, word, pageSize);
using (var cts = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
cts.CancelAfter(TimeoutMs);
using (var resp = await Http.GetAsync(url, cts.Token).ConfigureAwait(false))
{
if (!resp.IsSuccessStatusCode) return null;
var json = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
return ParseEntries(src, json);
}
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } // 宿主取消:传播
catch (OperationCanceledException) { return null; } // 单请求 3s 超时:失败信号
catch { return null; } // 网络失败/解析失败:失败信号
}
/// <summary>解析响应:{success,total,entries:[{id,title,content,source,tags}]};坏行跳过</summary>
private static List<RawEntry> ParseEntries(Source src, string json)
{
var list = new List<RawEntry>();
if (string.IsNullOrEmpty(json)) return list;
JObject obj;
try { obj = JObject.Parse(json); } catch { return list; }
var arr = obj["entries"] as JArray;
if (arr == null) return list;
foreach (var e in arr.OfType<JObject>())
{
int id = e["id"]?.Value<int>() ?? 0;
if (id <= 0) continue;
list.Add(new RawEntry
{
Id = id,
Host = src.Host,
Title = (e["title"]?.Value<string>() ?? "").Trim(),
Content = e["content"]?.Value<string>() ?? "",
Tags = (e["tags"]?.Value<string>() ?? "").Trim(),
Source = (e["source"]?.Value<string>() ?? "").Trim(),
});
}
return list;
}
private static string Clip(string s, int max)
{
if (string.IsNullOrEmpty(s)) return "";
s = s.Trim();
return s.Length <= max ? s : s.Substring(0, max) + "…(截断)";
}
}
}
......@@ -156,6 +156,7 @@
<Compile Include="Kb\KbConfig.cs" />
<Compile Include="Kb\KbIndex.cs" />
<Compile Include="Kb\KbService.cs" />
<Compile Include="Kb\Remote\RemoteEntryKb.cs" />
<Compile Include="Kb\SynonymIndex.cs" />
<Compile Include="ShareHelper.cs" />
<Compile Include="Support\AgentLogger.cs" />
......
......@@ -109,6 +109,15 @@ namespace Tools.MyAgent
public int Line;
}
/// <summary>
/// 远程知识库引用行(v4.3):形如「远程·&lt;host&gt;·条目#40」「远程·&lt;host&gt;·条目目录」。
/// 不带文件扩展名,<see cref="MarkdownRenderer.AnswerRefPattern"/> 不匹配 ⇒ 走独立正则扫描登记,
/// 点击跳转对应知识库网页(OpenRemoteKbWeb)。
/// </summary>
private static readonly Regex RemoteKbRefRegex = new Regex(
@"远程·(?<host>[^·\s]+)·条目(#(?<id>\d+)|目录)",
RegexOptions.Compiled);
/// <summary>
/// 当前渲染文本中的引用行表。每次 <see cref="RefreshChatRtf"/> 后清空重建(字符位置随重绘失效);
/// 流式期间不扫描(完成时算一次),避免每帧全文正则扫描的 O(n) 白耗。
......@@ -380,12 +389,14 @@ namespace Tools.MyAgent
CustomPrices = cfg.CustomPrices ?? new Dictionary<string, string>(),
DbConnections = protectedConns,
ToolRoleAllow = cfg.ToolRoleAllow ?? new Dictionary<string, string>(),
// 远程知识库条目接口(v4.1;漏登记 ⇒ 设置页保存一次即被静默清空)
RemoteKbEntries = cfg.RemoteKbEntries ?? new List<string>(),
// 可观测性 + Admin 看板(漏登记 ⇒ 设置页保存一次即被静默清空)
TelemetryEnabled = cfg.TelemetryEnabled,
ObservabilityLevel = cfg.ObservabilityLevel,
HealthCheckOnStart = cfg.HealthCheckOnStart,
RunsKeepDays = cfg.RunsKeepDays,
ShowRunSummary = cfg.ShowRunSummary,
RunSummaryEnabled = cfg.RunSummaryEnabled, // v4.4 改名:旧配置的 ShowRunSummary=false 不再生效
AdminEnabled = cfg.AdminEnabled,
AdminRoles = cfg.AdminRoles,
DashboardDays = cfg.DashboardDays,
......@@ -673,6 +684,10 @@ namespace Tools.MyAgent
Line = line,
});
}
// 远程知识库引用行(v4.3):AnswerRefPattern 因无扩展名不匹配,独立扫描登记(点击跳知识库网页)
foreach (Match m in RemoteKbRefRegex.Matches(text))
_answerRefs.Add(new AnswerRef { Start = m.Index, Length = m.Length, Rel = m.Value });
}
catch { _answerRefs.Clear(); }
}
......@@ -797,6 +812,14 @@ namespace Tools.MyAgent
private void OpenAnswerRef(AnswerRef r)
{
if (r == null || string.IsNullOrEmpty(r.Rel)) return;
// 远程知识库引用行(v4.3):点击跳转对应知识库网页(origin 取自配置的接口模板)
if (r.Rel.StartsWith("远程·", StringComparison.Ordinal))
{
OpenRemoteKbWeb(r.Rel);
return;
}
string abs = null;
bool dialect = false;
try
......@@ -851,6 +874,50 @@ namespace Tools.MyAgent
OpenLinkTarget(abs);
}
/// <summary>
/// 远程知识库引用行点击(v4.3):跳转对应知识库网页。
/// 网页地址 = 接口模板的 origin(scheme://host:port,从设置页 RemoteKbEntries 按 host 匹配解析);
/// 找不到匹配模板(配置已删源)时提示。
/// </summary>
private void OpenRemoteKbWeb(string rel)
{
string host = null;
var m = RemoteKbRefRegex.Match(rel ?? "");
if (m.Success) host = m.Groups["host"].Value;
string origin = ResolveRemoteKbOrigin(host);
if (string.IsNullOrEmpty(origin))
{
AgentUtils.Toast("未找到该远程知识库的网页地址(请检查设置页「远程知识库」的接口模板)", 4000);
return;
}
try { System.Diagnostics.Process.Start(origin); }
catch (Exception ex) { AgentUtils.Toast("打开浏览器失败:" + ex.Message, 4000); }
}
/// <summary>按 host 匹配配置中的远程知识库模板,返回其 origin(如 http://192.168.1.108:8718/)。</summary>
private string ResolveRemoteKbOrigin(string host)
{
if (string.IsNullOrEmpty(host)) return null;
try
{
var entries = _config != null ? _config.RemoteKbEntries : null;
if (entries != null)
foreach (var t in entries)
{
var s = (t ?? "").Trim();
if (s.Length == 0) continue;
Uri u;
try { u = new Uri(s.Replace("{q}", "x")); }
catch { continue; }
if (u.Scheme != Uri.UriSchemeHttp && u.Scheme != Uri.UriSchemeHttps) continue;
if (string.Equals(u.Host, host, StringComparison.OrdinalIgnoreCase))
return u.GetLeftPart(UriPartial.Authority) + "/";
}
}
catch { }
return null;
}
/// <summary>指定消息的工具轨迹(&lt;details&gt;)是否展开</summary>
private bool IsDetailsExpanded(int messageIndex) => _expandedDetails.Contains(messageIndex);
......@@ -1066,8 +1133,8 @@ namespace Tools.MyAgent
string text = $"本回复 {prompt}+{completion} tokens";
double cost = _engine.TotalCost - _replyCostBase;
if (cost > 0) text += $"(约 {CostEstimator.Format(cost)})";
// M4:本轮摘要(耗时/工具数/通道)—— 默认关闭,避免打扰(AgentConfig.ShowRunSummary)
if (_config.ShowRunSummary && _lastRunSummary.Length > 0) text += " · " + _lastRunSummary;
// M4:本轮摘要(耗时/工具数/通道)—— v4.4 起默认开启(AgentConfig.RunSummaryEnabled)
if (_config.RunSummaryEnabled && _lastRunSummary.Length > 0) text += " · " + _lastRunSummary;
return text;
}
......
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