Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
F
Ftt_Common
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
Ftt_Common
Commits
06b9d21a
Commit
06b9d21a
authored
Oct 09, 2026
by
段廷瑞
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: 支持远程知识库(飞天兔大脑)
parent
12f2ed0d
Changes
11
Hide whitespace changes
Inline
Side-by-side
Showing
11 changed files
with
916 additions
and
22 deletions
+916
-22
.gitignore
.gitignore
+1
-0
Tools.Agent.dll
AppHome/Tools.Agent.dll
+0
-0
Tools.Agent.xml
AppHome/Tools.Agent.xml
+123
-2
AgentEngine.cs
Tools.Agent/Agent/AgentEngine.cs
+13
-0
KbRecall.cs
Tools.Agent/Agent/Answer/KbRecall.cs
+102
-4
OpenAiCompatibleClient.cs
Tools.Agent/Agent/OpenAiCompatibleClient.cs
+9
-8
AgentConfig.cs
Tools.Agent/Config/AgentConfig.cs
+16
-2
AgentSettingsForm.cs
Tools.Agent/Forms/AgentSettingsForm.cs
+94
-3
RemoteEntryKb.cs
Tools.Agent/Kb/Remote/RemoteEntryKb.cs
+487
-0
Tools.Agent.csproj
Tools.Agent/Tools.Agent.csproj
+1
-0
wMyAgent.cs
Tools.Agent/wMyAgent.cs
+70
-3
No files found.
.gitignore
View file @
06b9d21a
...
@@ -74,3 +74,4 @@ bin
...
@@ -74,3 +74,4 @@ bin
/AppHome/Logs
/AppHome/Logs
/AppHome/Package
/AppHome/Package
/.workbuddy
/.workbuddy
/temp
AppHome/Tools.Agent.dll
View file @
06b9d21a
No preview for this file type
AppHome/Tools.Agent.xml
View file @
06b9d21a
...
@@ -601,6 +601,15 @@
...
@@ -601,6 +601,15 @@
<member
name=
"F:Tools.MyAgent.Agent.KbRecall.MaxImages"
>
<member
name=
"F:Tools.MyAgent.Agent.KbRecall.MaxImages"
>
<summary>
关联图片上限(内部默认 16;引擎 Configure 按 AgentConfig.MaxAnswerImages 注入)
</summary>
<summary>
关联图片上限(内部默认 16;引擎 Configure 按 AgentConfig.MaxAnswerImages 注入)
</summary>
</member>
</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"
>
<member
name=
"F:Tools.MyAgent.Agent.KbRecall.MaxHits"
>
<summary>
纳入证据链的命中上限(主命中 + 其余)
</summary>
<summary>
纳入证据链的命中上限(主命中 + 其余)
</summary>
</member>
</member>
...
@@ -639,6 +648,19 @@
...
@@ -639,6 +648,19 @@
<param
name=
"question"
>
用户原始问题
</param>
<param
name=
"question"
>
用户原始问题
</param>
<param
name=
"ct"
>
取消令牌:全盘扫描兜底路径据此支持 ESC 取消(长库扫描不阻塞取消)
</param>
<param
name=
"ct"
>
取消令牌:全盘扫描兜底路径据此支持 ESC 取消(长库扫描不阻塞取消)
</param>
</member>
</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)"
>
<member
name=
"M:Tools.MyAgent.Agent.KbRecall.BuildEvidenceMessage(Tools.MyAgent.Agent.KbRecallResult)"
>
<summary>
命中 → 证据消息(作为一条 user 消息追加在问题之后,契约要求模型只据证据作答)
</summary>
<summary>
命中 → 证据消息(作为一条 user 消息追加在问题之后,契约要求模型只据证据作答)
</summary>
</member>
</member>
...
@@ -1031,6 +1053,14 @@
...
@@ -1031,6 +1053,14 @@
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.SharePassword"
>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.SharePassword"
>
<summary>
共享文件夹访问密码(可选;落盘时经 DPAPI 加密(见 SecretProtector),内存中为明文)
</summary>
<summary>
共享文件夹访问密码(可选;落盘时经 DPAPI 加密(见 SecretProtector),内存中为明文)
</summary>
</member>
</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"
>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.Temperature"
>
<summary>
采样温度
</summary>
<summary>
采样温度
</summary>
</member>
</member>
...
@@ -1138,8 +1168,11 @@
...
@@ -1138,8 +1168,11 @@
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.RunsKeepDays"
>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.RunsKeepDays"
>
<summary>
runs_*.jsonl 保留天数(默认 14)。
</summary>
<summary>
runs_*.jsonl 保留天数(默认 14)。
</summary>
</member>
</member>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.ShowRunSummary"
>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.RunSummaryEnabled"
>
<summary>
回复尾注是否带本轮摘要(耗时/工具数/通道);默认关闭,避免打扰。
</summary>
<summary>
回复尾注是否带本轮摘要(耗时/工具数/通道);默认开启(真机反馈:回答结尾应有用量信息)。
<para>
v4.4 由 ShowRunSummary 改名而来:旧配置文件中的 false 值不再生效(一次性摆脱旧默认)。
</para>
</summary>
</member>
</member>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.AdminEnabled"
>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.AdminEnabled"
>
<summary>
看板总开关;关闭时 /admin 提示不可用。
</summary>
<summary>
看板总开关;关闭时 /admin 提示不可用。
</summary>
...
@@ -1311,6 +1344,12 @@
...
@@ -1311,6 +1344,12 @@
<member
name=
"M:Tools.MyAgent.Forms.AgentSettingsForm.#ctor"
>
<member
name=
"M:Tools.MyAgent.Forms.AgentSettingsForm.#ctor"
>
<summary>
仅设计器使用(WinForms 设计器要求无参构造);运行时请使用带参构造。
</summary>
<summary>
仅设计器使用(WinForms 设计器要求无参构造);运行时请使用带参构造。
</summary>
</member>
</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"
>
<member
name=
"M:Tools.MyAgent.Forms.AgentSettingsForm.BuildDbConnectionsTab"
>
<summary>
<summary>
新增「数据库连接」选项卡:ID / 连接串 两列表格,供 db_query 按 ID 引用。
新增「数据库连接」选项卡:ID / 连接串 两列表格,供 db_query 按 ID 引用。
...
@@ -1332,6 +1371,9 @@
...
@@ -1332,6 +1371,9 @@
<member
name=
"M:Tools.MyAgent.Forms.AgentSettingsForm.RefreshDashKpi"
>
<member
name=
"M:Tools.MyAgent.Forms.AgentSettingsForm.RefreshDashKpi"
>
<summary>
即时 KPI(与 /stats 同源:AgentTelemetry.Snapshot)。
</summary>
<summary>
即时 KPI(与 /stats 同源:AgentTelemetry.Snapshot)。
</summary>
</member>
</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})"
>
<member
name=
"M:Tools.MyAgent.Forms.AgentSettingsForm.ToolRoleRulesToLines(System.Collections.Generic.Dictionary{System.String,System.String})"
>
<summary>
角色规则字典 → 编辑文本(排序保证稳定回显)
</summary>
<summary>
角色规则字典 → 编辑文本(排序保证稳定回显)
</summary>
</member>
</member>
...
@@ -2174,6 +2216,68 @@
...
@@ -2174,6 +2216,68 @@
<para>
复用平台 MyUtils.ImageSearch 感知哈希(aHash/dHash/pHash)能力,不再自带 OpenCV 实现。
</para>
<para>
复用平台 MyUtils.ImageSearch 感知哈希(aHash/dHash/pHash)能力,不再自带 OpenCV 实现。
</para>
</summary>
</summary>
</member>
</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"
>
<member
name=
"T:Tools.MyAgent.Kb.SynonymIndex"
>
<summary>
<summary>
同义词/术语扩展索引(检索 0 命中时的智能兜底)。
同义词/术语扩展索引(检索 0 命中时的智能兜底)。
...
@@ -3275,6 +3379,13 @@
...
@@ -3275,6 +3379,13 @@
<member
name=
"T:Tools.MyAgent.wMyAgent.AnswerRef"
>
<member
name=
"T:Tools.MyAgent.wMyAgent.AnswerRef"
>
<summary>
回答引用行(P4):渲染文本中的位置 + 解析出的 相对路径 / 章节 / 行号,供点击定位
</summary>
<summary>
回答引用行(P4):渲染文本中的位置 + 解析出的 相对路径 / 章节 / 行号,供点击定位
</summary>
</member>
</member>
<member
name=
"F:Tools.MyAgent.wMyAgent.RemoteKbRefRegex"
>
<summary>
远程知识库引用行(v4.3):形如「远程·
<
host
>
·条目#40」「远程·
<
host
>
·条目目录」。
不带文件扩展名,
<see
cref=
"F:Tools.MyAgent.MarkdownRenderer.AnswerRefPattern"
/>
不匹配 ⇒ 走独立正则扫描登记,
点击跳转对应知识库网页(OpenRemoteKbWeb)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.wMyAgent._answerRefs"
>
<member
name=
"F:Tools.MyAgent.wMyAgent._answerRefs"
>
<summary>
<summary>
当前渲染文本中的引用行表。每次
<see
cref=
"M:Tools.MyAgent.wMyAgent.RefreshChatRtf"
/>
后清空重建(字符位置随重绘失效);
当前渲染文本中的引用行表。每次
<see
cref=
"M:Tools.MyAgent.wMyAgent.RefreshChatRtf"
/>
后清空重建(字符位置随重绘失效);
...
@@ -3408,6 +3519,16 @@
...
@@ -3408,6 +3519,16 @@
「dialect/」「industry/」开头的引用走内置方言根目录解析;均已过沙箱校验,越界直接提示。
「dialect/」「industry/」开头的引用走内置方言根目录解析;均已过沙箱校验,越界直接提示。
</summary>
</summary>
</member>
</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)"
>
<member
name=
"M:Tools.MyAgent.wMyAgent.IsDetailsExpanded(System.Int32)"
>
<summary>
指定消息的工具轨迹(
<
details
>
)是否展开
</summary>
<summary>
指定消息的工具轨迹(
<
details
>
)是否展开
</summary>
</member>
</member>
...
...
Tools.Agent/Agent/AgentEngine.cs
View file @
06b9d21a
...
@@ -301,6 +301,7 @@ namespace Tools.MyAgent.Agent
...
@@ -301,6 +301,7 @@ namespace Tools.MyAgent.Agent
:
0.72
;
:
0.72
;
_recall
.
ImagesEnabled
=
cfg
.
AnswerWithImages
;
// 关掉附图即不采图(省掉目录扫描)
_recall
.
ImagesEnabled
=
cfg
.
AnswerWithImages
;
// 关掉附图即不采图(省掉目录扫描)
_recall
.
MaxImages
=
cfg
.
MaxAnswerImages
>
0
?
Math
.
Min
(
cfg
.
MaxAnswerImages
,
16
)
:
16
;
_recall
.
MaxImages
=
cfg
.
MaxAnswerImages
>
0
?
Math
.
Min
(
cfg
.
MaxAnswerImages
,
16
)
:
16
;
_recall
.
ConfigureRemote
(
cfg
.
RemoteKbEntries
);
// v4.1:远程知识库条目接口模板(空 = 关闭)
CostEstimator
.
Configure
(
cfg
.
CustomPrices
);
// R6 费用估算:应用自定义价格表覆盖
CostEstimator
.
Configure
(
cfg
.
CustomPrices
);
// R6 费用估算:应用自定义价格表覆盖
AgentTelemetry
.
Configure
(
cfg
.
TelemetryEnabled
,
cfg
.
ObservabilityLevel
);
// 可观测性开关与隐私级别
AgentTelemetry
.
Configure
(
cfg
.
TelemetryEnabled
,
cfg
.
ObservabilityLevel
);
// 可观测性开关与隐私级别
...
@@ -542,6 +543,18 @@ namespace Tools.MyAgent.Agent
...
@@ -542,6 +543,18 @@ namespace Tools.MyAgent.Agent
OnThinkingDelta
?.
Invoke
(
result
.
ReasoningContent
);
OnThinkingDelta
?.
Invoke
(
result
.
ReasoningContent
);
await
FinishAnswerAsync
(
messages
,
result
,
finalAnswer
,
ct
).
ConfigureAwait
(
false
);
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:把命中图片交给宿主渲染附图轨(无图则不发)
RaiseAnswerAttachments
(
recall
);
// P5:把命中图片交给宿主渲染附图轨(无图则不发)
return
true
;
return
true
;
}
}
...
...
Tools.Agent/Agent/Answer/KbRecall.cs
View file @
06b9d21a
...
@@ -5,6 +5,7 @@ using System.Linq;
...
@@ -5,6 +5,7 @@ using System.Linq;
using
System.Text
;
using
System.Text
;
using
System.Text.RegularExpressions
;
using
System.Text.RegularExpressions
;
using
Tools.MyAgent.Kb
;
using
Tools.MyAgent.Kb
;
using
Tools.MyAgent.Kb.Remote
;
namespace
Tools.MyAgent.Agent
namespace
Tools.MyAgent.Agent
{
{
...
@@ -87,6 +88,22 @@ namespace Tools.MyAgent.Agent
...
@@ -87,6 +88,22 @@ namespace Tools.MyAgent.Agent
/// <summary>关联图片上限(内部默认 16;引擎 Configure 按 AgentConfig.MaxAnswerImages 注入)</summary>
/// <summary>关联图片上限(内部默认 16;引擎 Configure 按 AgentConfig.MaxAnswerImages 注入)</summary>
public
int
MaxImages
=
16
;
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>
/// <summary>纳入证据链的命中上限(主命中 + 其余)</summary>
private
const
int
MaxHits
=
3
;
private
const
int
MaxHits
=
3
;
...
@@ -213,7 +230,7 @@ namespace Tools.MyAgent.Agent
...
@@ -213,7 +230,7 @@ namespace Tools.MyAgent.Agent
}
}
}
}
if
(
acc
.
Count
==
0
)
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) ──
// ── 融合分:来源基分 + 0.25×查询词覆盖率 + 0.05×额外来源数(上限 0.95) ──
...
@@ -234,7 +251,7 @@ namespace Tools.MyAgent.Agent
...
@@ -234,7 +251,7 @@ namespace Tools.MyAgent.Agent
.
ThenBy
(
h
=>
h
.
RelPath
,
StringComparer
.
OrdinalIgnoreCase
)
.
ThenBy
(
h
=>
h
.
RelPath
,
StringComparer
.
OrdinalIgnoreCase
)
.
ToList
();
.
ToList
();
if
(
ranked
.
Count
==
0
)
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
primary
=
ranked
[
0
];
var
result
=
new
KbRecallResult
var
result
=
new
KbRecallResult
...
@@ -253,18 +270,99 @@ namespace Tools.MyAgent.Agent
...
@@ -253,18 +270,99 @@ namespace Tools.MyAgent.Agent
}
}
else
else
{
{
// 低于下界:
不注入证据,回退原工具循环(保留原有检索/诊断能力,避免新通道削弱能力
)
// 低于下界:
本地证据不入链,回退前仍尝试远程兜底(Finalize 内统一处理
)
result
.
Mode
=
"model"
;
result
.
Mode
=
"model"
;
return
result
;
return
Finalize
(
result
,
keywords
,
question
,
ct
)
;
}
}
// ── 只对进入证据链的命中补齐 行号/章节/正文/图片(读文件的成本只花在少量命中上) ──
// ── 只对进入证据链的命中补齐 行号/章节/正文/图片(读文件的成本只花在少量命中上) ──
Enrich
(
primary
,
MaxImages
);
Enrich
(
primary
,
MaxImages
);
for
(
int
i
=
0
;
i
<
result
.
Others
.
Count
;
i
++)
for
(
int
i
=
0
;
i
<
result
.
Others
.
Count
;
i
++)
Enrich
(
result
.
Others
[
i
],
0
);
// 次要命中不采图,避免附图轨被无关图片占满
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
;
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>
/// <summary>命中 → 证据消息(作为一条 user 消息追加在问题之后,契约要求模型只据证据作答)</summary>
public
string
BuildEvidenceMessage
(
KbRecallResult
r
)
public
string
BuildEvidenceMessage
(
KbRecallResult
r
)
{
{
...
...
Tools.Agent/Agent/OpenAiCompatibleClient.cs
View file @
06b9d21a
...
@@ -570,6 +570,15 @@ namespace Tools.MyAgent.Agent
...
@@ -570,6 +570,15 @@ namespace Tools.MyAgent.Agent
try
{
obj
=
JObject
.
Parse
(
data
);
}
try
{
obj
=
JObject
.
Parse
(
data
);
}
catch
{
continue
;
}
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
;
var
choices
=
obj
[
"choices"
]
as
JArray
;
if
(
choices
==
null
||
choices
.
Count
==
0
)
continue
;
if
(
choices
==
null
||
choices
.
Count
==
0
)
continue
;
var
first
=
choices
[
0
]
as
JObject
;
var
first
=
choices
[
0
]
as
JObject
;
...
@@ -578,14 +587,6 @@ namespace Tools.MyAgent.Agent
...
@@ -578,14 +587,6 @@ namespace Tools.MyAgent.Agent
var
fr
=
first
[
"finish_reason"
]?.
Value
<
string
>();
var
fr
=
first
[
"finish_reason"
]?.
Value
<
string
>();
if
(!
string
.
IsNullOrEmpty
(
fr
))
finishReason
=
fr
;
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
;
var
delta
=
first
[
"delta"
]
as
JObject
;
if
(
delta
==
null
)
continue
;
if
(
delta
==
null
)
continue
;
...
...
Tools.Agent/Config/AgentConfig.cs
View file @
06b9d21a
...
@@ -55,6 +55,17 @@
...
@@ -55,6 +55,17 @@
/// <summary>共享文件夹访问密码(可选;落盘时经 DPAPI 加密(见 SecretProtector),内存中为明文)</summary>
/// <summary>共享文件夹访问密码(可选;落盘时经 DPAPI 加密(见 SecretProtector),内存中为明文)</summary>
public
string
SharePassword
{
get
;
set
;
}
=
""
;
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>
/// <summary>采样温度</summary>
...
@@ -189,8 +200,11 @@
...
@@ -189,8 +200,11 @@
/// <summary>runs_*.jsonl 保留天数(默认 14)。</summary>
/// <summary>runs_*.jsonl 保留天数(默认 14)。</summary>
public
int
RunsKeepDays
{
get
;
set
;
}
=
14
;
public
int
RunsKeepDays
{
get
;
set
;
}
=
14
;
/// <summary>回复尾注是否带本轮摘要(耗时/工具数/通道);默认关闭,避免打扰。</summary>
/// <summary>
public
bool
ShowRunSummary
{
get
;
set
;
}
=
false
;
/// 回复尾注是否带本轮摘要(耗时/工具数/通道);默认开启(真机反馈:回答结尾应有用量信息)。
/// <para>v4.4 由 ShowRunSummary 改名而来:旧配置文件中的 false 值不再生效(一次性摆脱旧默认)。</para>
/// </summary>
public
bool
RunSummaryEnabled
{
get
;
set
;
}
=
true
;
// ── Admin 看板 ──
// ── Admin 看板 ──
...
...
Tools.Agent/Forms/AgentSettingsForm.cs
View file @
06b9d21a
...
@@ -121,12 +121,49 @@ namespace Tools.MyAgent.Forms
...
@@ -121,12 +121,49 @@ namespace Tools.MyAgent.Forms
BuildDbConnectionsTab
();
// P0:预存数据库连接(db_query 按 ID 引用,聊天不回显密码)
BuildDbConnectionsTab
();
// P0:预存数据库连接(db_query 按 ID 引用,聊天不回显密码)
BuildToolPermissionUi
();
// P3:工具级权限(按角色)输入区
BuildToolPermissionUi
();
// P3:工具级权限(按角色)输入区
BuildDashboardTab
();
// 可观测性:统计看板入口(替代隐晦的 /admin)
BuildDashboardTab
();
// 可观测性:统计看板入口(替代隐晦的 /admin)
BuildRemoteKbUi
();
// v4.1:远程知识库条目接口(高级 → 语义检索页底部)
// 回答形态不再提供设置页(v1.2):全部内部默认(见 AgentConfig 注释),降低设置页认知负担
// 回答形态不再提供设置页(v1.2):全部内部默认(见 AgentConfig 注释),降低设置页认知负担
BindConfig
();
// 配置值回填控件(控件创建见 Designer.cs)
BindConfig
();
// 配置值回填控件(控件创建见 Designer.cs)
ApplyProviderMode
();
// 布局联动:BaseUrl 行显隐
ApplyProviderMode
();
// 布局联动:BaseUrl 行显隐
ApplyKbMode
();
// 布局联动:共享 账号/密码/测试连接 行显隐
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:预存数据库连接选项卡(程序化创建,避免侵入设计器) ══
// ══ P0:预存数据库连接选项卡(程序化创建,避免侵入设计器) ══
private
DataGridView
dgvDbConn
;
private
DataGridView
dgvDbConn
;
...
@@ -225,7 +262,7 @@ namespace Tools.MyAgent.Forms
...
@@ -225,7 +262,7 @@ namespace Tools.MyAgent.Forms
var
grpKpi
=
new
GroupBox
var
grpKpi
=
new
GroupBox
{
{
Text
=
"运行统计(
本进程实时累计
)"
,
Text
=
"运行统计(
进程实时 + 近 7 天落盘,重启不丢
)"
,
Location
=
new
Point
(
8
,
8
),
Location
=
new
Point
(
8
,
8
),
Size
=
new
Size
(
596
,
172
),
Size
=
new
Size
(
596
,
172
),
};
};
...
@@ -306,7 +343,7 @@ namespace Tools.MyAgent.Forms
...
@@ -306,7 +343,7 @@ namespace Tools.MyAgent.Forms
{
{
var
st
=
Tools
.
MyAgent
.
AgentTelemetry
.
Snapshot
();
var
st
=
Tools
.
MyAgent
.
AgentTelemetry
.
Snapshot
();
var
sb
=
new
System
.
Text
.
StringBuilder
();
var
sb
=
new
System
.
Text
.
StringBuilder
();
sb
.
Append
(
"本
轮进程累计
:\n"
);
sb
.
Append
(
"本
进程累计(实时)
:\n"
);
sb
.
Append
(
string
.
Format
(
" 轮数 {0}(成功 {1} · 失败 {2} · 取消 {3})\n"
,
sb
.
Append
(
string
.
Format
(
" 轮数 {0}(成功 {1} · 失败 {2} · 取消 {3})\n"
,
st
.
Runs
,
st
.
Success
,
st
.
Errors
,
st
.
Cancels
));
st
.
Runs
,
st
.
Success
,
st
.
Errors
,
st
.
Cancels
));
if
(
st
.
Duration
.
Count
>
0
)
if
(
st
.
Duration
.
Count
>
0
)
...
@@ -319,7 +356,43 @@ namespace Tools.MyAgent.Forms
...
@@ -319,7 +356,43 @@ namespace Tools.MyAgent.Forms
if
(
tokens
>
0
)
sb
.
Append
(
string
.
Format
(
" Token {0} + {1}\n"
,
st
.
PromptTokens
,
st
.
CompletionTokens
));
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
.
Cost
>
0
)
sb
.
Append
(
" 费用 "
+
Tools
.
MyAgent
.
CostEstimator
.
Format
(
st
.
Cost
)
+
"\n"
);
if
(
st
.
Dropped
>
0
)
sb
.
Append
(
" ⚠ 丢弃 "
+
st
.
Dropped
+
" 条(采集跟不上)\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
();
_lblDashKpi
.
Text
=
sb
.
ToString
();
}
}
catch
catch
...
@@ -328,6 +401,16 @@ namespace Tools.MyAgent.Forms
...
@@ -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>
/// <summary>角色规则字典 → 编辑文本(排序保证稳定回显)</summary>
private
static
string
[]
ToolRoleRulesToLines
(
Dictionary
<
string
,
string
>
rules
)
private
static
string
[]
ToolRoleRulesToLines
(
Dictionary
<
string
,
string
>
rules
)
{
{
...
@@ -406,6 +489,9 @@ namespace Tools.MyAgent.Forms
...
@@ -406,6 +489,9 @@ namespace Tools.MyAgent.Forms
cmbEmbModel
.
Text
=
_config
.
EmbeddingModel
;
cmbEmbModel
.
Text
=
_config
.
EmbeddingModel
;
txtEmbKey
.
Text
=
_config
.
EmbeddingApiKey
;
txtEmbKey
.
Text
=
_config
.
EmbeddingApiKey
;
// 远程知识库(v4.1):URL 模板逐行回填
txtRemoteKb
.
Text
=
string
.
Join
(
Environment
.
NewLine
,
_config
.
RemoteKbEntries
??
new
List
<
string
>());
// 提示词:启用开关 + 角色模板回填 + 自定义提示词
// 提示词:启用开关 + 角色模板回填 + 自定义提示词
chkPromptEnabled
.
Checked
=
_config
.
SystemPromptEnabled
;
chkPromptEnabled
.
Checked
=
_config
.
SystemPromptEnabled
;
PopulateRoles
();
PopulateRoles
();
...
@@ -865,6 +951,11 @@ namespace Tools.MyAgent.Forms
...
@@ -865,6 +951,11 @@ namespace Tools.MyAgent.Forms
_config
.
EmbeddingBaseUrl
=
txtEmbUrl
.
Text
.
Trim
();
_config
.
EmbeddingBaseUrl
=
txtEmbUrl
.
Text
.
Trim
();
_config
.
EmbeddingModel
=
string
.
IsNullOrEmpty
(
cmbEmbModel
.
Text
.
Trim
())
?
"bge-m3"
:
cmbEmbModel
.
Text
.
Trim
();
_config
.
EmbeddingModel
=
string
.
IsNullOrEmpty
(
cmbEmbModel
.
Text
.
Trim
())
?
"bge-m3"
:
cmbEmbModel
.
Text
.
Trim
();
_config
.
EmbeddingApiKey
=
txtEmbKey
.
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
.
SystemPromptEnabled
=
chkPromptEnabled
.
Checked
;
_config
.
RoleTemplateId
=
(
cmbRole
.
SelectedItem
as
RoleItem
)?.
Id
??
""
;
_config
.
RoleTemplateId
=
(
cmbRole
.
SelectedItem
as
RoleItem
)?.
Id
??
""
;
...
...
Tools.Agent/Kb/Remote/RemoteEntryKb.cs
0 → 100644
View file @
06b9d21a
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
)
+
"…(截断)"
;
}
}
}
Tools.Agent/Tools.Agent.csproj
View file @
06b9d21a
...
@@ -156,6 +156,7 @@
...
@@ -156,6 +156,7 @@
<Compile
Include=
"Kb\KbConfig.cs"
/>
<Compile
Include=
"Kb\KbConfig.cs"
/>
<Compile
Include=
"Kb\KbIndex.cs"
/>
<Compile
Include=
"Kb\KbIndex.cs"
/>
<Compile
Include=
"Kb\KbService.cs"
/>
<Compile
Include=
"Kb\KbService.cs"
/>
<Compile
Include=
"Kb\Remote\RemoteEntryKb.cs"
/>
<Compile
Include=
"Kb\SynonymIndex.cs"
/>
<Compile
Include=
"Kb\SynonymIndex.cs"
/>
<Compile
Include=
"ShareHelper.cs"
/>
<Compile
Include=
"ShareHelper.cs"
/>
<Compile
Include=
"Support\AgentLogger.cs"
/>
<Compile
Include=
"Support\AgentLogger.cs"
/>
...
...
Tools.Agent/wMyAgent.cs
View file @
06b9d21a
...
@@ -109,6 +109,15 @@ namespace Tools.MyAgent
...
@@ -109,6 +109,15 @@ namespace Tools.MyAgent
public
int
Line
;
public
int
Line
;
}
}
/// <summary>
/// 远程知识库引用行(v4.3):形如「远程·<host>·条目#40」「远程·<host>·条目目录」。
/// 不带文件扩展名,<see cref="MarkdownRenderer.AnswerRefPattern"/> 不匹配 ⇒ 走独立正则扫描登记,
/// 点击跳转对应知识库网页(OpenRemoteKbWeb)。
/// </summary>
private
static
readonly
Regex
RemoteKbRefRegex
=
new
Regex
(
@"远程·(?<host>[^·\s]+)·条目(#(?<id>\d+)|目录)"
,
RegexOptions
.
Compiled
);
/// <summary>
/// <summary>
/// 当前渲染文本中的引用行表。每次 <see cref="RefreshChatRtf"/> 后清空重建(字符位置随重绘失效);
/// 当前渲染文本中的引用行表。每次 <see cref="RefreshChatRtf"/> 后清空重建(字符位置随重绘失效);
/// 流式期间不扫描(完成时算一次),避免每帧全文正则扫描的 O(n) 白耗。
/// 流式期间不扫描(完成时算一次),避免每帧全文正则扫描的 O(n) 白耗。
...
@@ -380,12 +389,14 @@ namespace Tools.MyAgent
...
@@ -380,12 +389,14 @@ namespace Tools.MyAgent
CustomPrices
=
cfg
.
CustomPrices
??
new
Dictionary
<
string
,
string
>(),
CustomPrices
=
cfg
.
CustomPrices
??
new
Dictionary
<
string
,
string
>(),
DbConnections
=
protectedConns
,
DbConnections
=
protectedConns
,
ToolRoleAllow
=
cfg
.
ToolRoleAllow
??
new
Dictionary
<
string
,
string
>(),
ToolRoleAllow
=
cfg
.
ToolRoleAllow
??
new
Dictionary
<
string
,
string
>(),
// 远程知识库条目接口(v4.1;漏登记 ⇒ 设置页保存一次即被静默清空)
RemoteKbEntries
=
cfg
.
RemoteKbEntries
??
new
List
<
string
>(),
// 可观测性 + Admin 看板(漏登记 ⇒ 设置页保存一次即被静默清空)
// 可观测性 + Admin 看板(漏登记 ⇒ 设置页保存一次即被静默清空)
TelemetryEnabled
=
cfg
.
TelemetryEnabled
,
TelemetryEnabled
=
cfg
.
TelemetryEnabled
,
ObservabilityLevel
=
cfg
.
ObservabilityLevel
,
ObservabilityLevel
=
cfg
.
ObservabilityLevel
,
HealthCheckOnStart
=
cfg
.
HealthCheckOnStart
,
HealthCheckOnStart
=
cfg
.
HealthCheckOnStart
,
RunsKeepDays
=
cfg
.
RunsKeepDays
,
RunsKeepDays
=
cfg
.
RunsKeepDays
,
ShowRunSummary
=
cfg
.
ShowRunSummary
,
RunSummaryEnabled
=
cfg
.
RunSummaryEnabled
,
// v4.4 改名:旧配置的 ShowRunSummary=false 不再生效
AdminEnabled
=
cfg
.
AdminEnabled
,
AdminEnabled
=
cfg
.
AdminEnabled
,
AdminRoles
=
cfg
.
AdminRoles
,
AdminRoles
=
cfg
.
AdminRoles
,
DashboardDays
=
cfg
.
DashboardDays
,
DashboardDays
=
cfg
.
DashboardDays
,
...
@@ -673,6 +684,10 @@ namespace Tools.MyAgent
...
@@ -673,6 +684,10 @@ namespace Tools.MyAgent
Line
=
line
,
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
();
}
catch
{
_answerRefs
.
Clear
();
}
}
}
...
@@ -797,6 +812,14 @@ namespace Tools.MyAgent
...
@@ -797,6 +812,14 @@ namespace Tools.MyAgent
private
void
OpenAnswerRef
(
AnswerRef
r
)
private
void
OpenAnswerRef
(
AnswerRef
r
)
{
{
if
(
r
==
null
||
string
.
IsNullOrEmpty
(
r
.
Rel
))
return
;
if
(
r
==
null
||
string
.
IsNullOrEmpty
(
r
.
Rel
))
return
;
// 远程知识库引用行(v4.3):点击跳转对应知识库网页(origin 取自配置的接口模板)
if
(
r
.
Rel
.
StartsWith
(
"远程·"
,
StringComparison
.
Ordinal
))
{
OpenRemoteKbWeb
(
r
.
Rel
);
return
;
}
string
abs
=
null
;
string
abs
=
null
;
bool
dialect
=
false
;
bool
dialect
=
false
;
try
try
...
@@ -851,6 +874,50 @@ namespace Tools.MyAgent
...
@@ -851,6 +874,50 @@ namespace Tools.MyAgent
OpenLinkTarget
(
abs
);
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>指定消息的工具轨迹(<details>)是否展开</summary>
/// <summary>指定消息的工具轨迹(<details>)是否展开</summary>
private
bool
IsDetailsExpanded
(
int
messageIndex
)
=>
_expandedDetails
.
Contains
(
messageIndex
);
private
bool
IsDetailsExpanded
(
int
messageIndex
)
=>
_expandedDetails
.
Contains
(
messageIndex
);
...
@@ -1066,8 +1133,8 @@ namespace Tools.MyAgent
...
@@ -1066,8 +1133,8 @@ namespace Tools.MyAgent
string
text
=
$"本回复
{
prompt
}
+
{
completion
}
tokens"
;
string
text
=
$"本回复
{
prompt
}
+
{
completion
}
tokens"
;
double
cost
=
_engine
.
TotalCost
-
_replyCostBase
;
double
cost
=
_engine
.
TotalCost
-
_replyCostBase
;
if
(
cost
>
0
)
text
+=
$"(约
{
CostEstimator
.
Format
(
cost
)}
)"
;
if
(
cost
>
0
)
text
+=
$"(约
{
CostEstimator
.
Format
(
cost
)}
)"
;
// M4:本轮摘要(耗时/工具数/通道)——
默认关闭,避免打扰(AgentConfig.ShowRunSummary
)
// M4:本轮摘要(耗时/工具数/通道)——
v4.4 起默认开启(AgentConfig.RunSummaryEnabled
)
if
(
_config
.
ShowRunSummary
&&
_lastRunSummary
.
Length
>
0
)
text
+=
" · "
+
_lastRunSummary
;
if
(
_config
.
RunSummaryEnabled
&&
_lastRunSummary
.
Length
>
0
)
text
+=
" · "
+
_lastRunSummary
;
return
text
;
return
text
;
}
}
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment