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
4c991d55
Commit
4c991d55
authored
Aug 21, 2026
by
段廷瑞
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
pref: 智能体缺陷优化
parent
9af54c51
Changes
32
Hide whitespace changes
Inline
Side-by-side
Showing
32 changed files
with
1849 additions
and
122 deletions
+1849
-122
AgentEngineLogicTests.cs
AgentTest/AgentEngineLogicTests.cs
+121
-0
AgentTest.csproj
AgentTest/AgentTest.csproj
+67
-0
DbQueryToolTests.cs
AgentTest/DbQueryToolTests.cs
+170
-0
EmbeddingClientTests.cs
AgentTest/EmbeddingClientTests.cs
+116
-0
FailoverPolicyTests.cs
AgentTest/FailoverPolicyTests.cs
+159
-0
KbServiceSandboxTests.cs
AgentTest/KbServiceSandboxTests.cs
+141
-0
Program.cs
AgentTest/Program.cs
+167
-0
SecretProtectorTests.cs
AgentTest/SecretProtectorTests.cs
+64
-0
TestUtil.cs
AgentTest/TestUtil.cs
+59
-0
ToolPermissionTests.cs
AgentTest/ToolPermissionTests.cs
+96
-0
Ftt_Common.sln
Ftt_Common.sln
+14
-0
AgentEngine.cs
Tools/MyAgent/Agent/AgentEngine.cs
+136
-30
FailoverPolicy.cs
Tools/MyAgent/Agent/FailoverPolicy.cs
+1
-1
OpenAiCompatibleClient.cs
Tools/MyAgent/Agent/OpenAiCompatibleClient.cs
+1
-1
ToolSchema.cs
Tools/MyAgent/Agent/ToolSchema.cs
+8
-2
DbQueryTool.cs
Tools/MyAgent/Agent/Tools/DbQueryTool.cs
+71
-12
ITool.cs
Tools/MyAgent/Agent/Tools/ITool.cs
+60
-1
ImageSearchTool.cs
Tools/MyAgent/Agent/Tools/ImageSearchTool.cs
+1
-1
KbTools.cs
Tools/MyAgent/Agent/Tools/KbTools.cs
+3
-3
AgentConfig.cs
Tools/MyAgent/Config/AgentConfig.cs
+17
-0
AgentSettingsForm.cs
Tools/MyAgent/Forms/AgentSettingsForm.cs
+133
-0
EmbeddingClient.cs
Tools/MyAgent/Kb/EmbeddingClient.cs
+57
-17
KbIndex.cs
Tools/MyAgent/Kb/KbIndex.cs
+13
-1
KbService.cs
Tools/MyAgent/Kb/KbService.cs
+64
-24
ShareHelper.cs
Tools/MyAgent/ShareHelper.cs
+12
-3
BackupHelper.cs
Tools/MyAgent/Support/BackupHelper.cs
+3
-3
ChatMessage.cs
Tools/MyAgent/Support/ChatMessage.cs
+3
-0
ChatRtfCache.cs
Tools/MyAgent/Support/ChatRtfCache.cs
+1
-0
MarkdownRenderer.cs
Tools/MyAgent/Support/MarkdownRenderer.cs
+10
-0
wMyAgent.cs
Tools/MyAgent/wMyAgent.cs
+78
-22
AssemblyInfo.cs
Tools/Properties/AssemblyInfo.cs
+3
-0
Tools.csproj
Tools/Tools.csproj
+0
-1
No files found.
AgentTest/AgentEngineLogicTests.cs
0 → 100644
View file @
4c991d55
using
Newtonsoft.Json.Linq
;
using
System
;
using
System.Collections.Generic
;
using
Tools.MyAgent
;
using
Tools.MyAgent.Agent
;
namespace
AgentTest
{
/// <summary>
/// 引擎纯逻辑:token 估算、历史裁剪(token 预算)、工具结果压缩截断。
/// </summary>
public
sealed
class
AgentEngineLogicTests
:
TestRunner
{
// ── token 估算 ──
[
Test
]
public
void
EstimateTokens_Text_CjkAndAscii
()
{
// 中文 ~1 token/字;英文 ~4 字符/token
Assert
.
Equal
(
7
,
AgentEngine
.
EstimateTokens
(
"十个中文字符串"
));
Assert
.
Equal
(
1
,
AgentEngine
.
EstimateTokens
(
"abcd"
));
Assert
.
True
(
AgentEngine
.
EstimateTokens
(
"混合 abc 文本"
)
>
0
);
Assert
.
Equal
(
0
,
AgentEngine
.
EstimateTokens
(
""
));
}
[
Test
]
public
void
EstimateTokens_Messages_AddsOverhead
()
{
var
msgs
=
new
JArray
{
new
JObject
{
[
"role"
]
=
"system"
,
[
"content"
]
=
"你是一个助手"
},
// 4(开销) + 6
new
JObject
{
[
"role"
]
=
"user"
,
[
"content"
]
=
"你好"
},
// 4(开销) + 2
};
Assert
.
Equal
(
16
,
AgentEngine
.
EstimateTokens
(
msgs
));
}
// ── 历史裁剪(token 预算) ──
[
Test
]
public
void
DropOldest_UnderBudget_KeepsAll
()
{
var
list
=
new
JArray
{
new
JObject
{
[
"role"
]
=
"system"
,
[
"content"
]
=
"s"
},
new
JObject
{
[
"role"
]
=
"user"
,
[
"content"
]
=
"你好"
},
new
JObject
{
[
"role"
]
=
"assistant"
,
[
"content"
]
=
"你好"
},
};
Assert
.
Equal
(
0
,
AgentEngine
.
DropOldestUntilUnder
(
list
,
99999
),
"预算充足时不应裁剪"
);
Assert
.
Equal
(
3
,
list
.
Count
);
}
[
Test
]
public
void
DropOldest_OverBudget_DropsEarliestPair
()
{
var
list
=
new
JArray
{
new
JObject
{
[
"role"
]
=
"system"
,
[
"content"
]
=
"s"
},
new
JObject
{
[
"role"
]
=
"user"
,
[
"content"
]
=
"最早的问题一内容"
},
new
JObject
{
[
"role"
]
=
"assistant"
,
[
"content"
]
=
"最早的答复一内容"
},
new
JObject
{
[
"role"
]
=
"user"
,
[
"content"
]
=
"中间的问题二内容"
},
new
JObject
{
[
"role"
]
=
"assistant"
,
[
"content"
]
=
"中间的答复二内容"
},
new
JObject
{
[
"role"
]
=
"user"
,
[
"content"
]
=
"最近的问题"
},
};
int
dropped
=
AgentEngine
.
DropOldestUntilUnder
(
list
,
8
);
Assert
.
True
(
dropped
>=
2
,
"超预算应裁剪最早消息对"
);
Assert
.
True
(
list
.
Count
<
6
,
"应裁剪掉最早消息"
);
Assert
.
Equal
(
"user"
,
list
[
1
][
"role"
]?.
Value
<
string
>(),
"裁剪后首条应为 user(API 交替要求)"
);
}
[
Test
]
public
void
DropOldest_NeverDropsBelowThree
()
{
var
list
=
new
JArray
{
new
JObject
{
[
"role"
]
=
"system"
,
[
"content"
]
=
"s"
},
new
JObject
{
[
"role"
]
=
"user"
,
[
"content"
]
=
"u1"
},
new
JObject
{
[
"role"
]
=
"assistant"
,
[
"content"
]
=
"a1"
},
};
AgentEngine
.
DropOldestUntilUnder
(
list
,
1
);
Assert
.
True
(
list
.
Count
>=
3
,
"保底至少保留 system + 2 条"
);
}
// ── 工具结果压缩截断(#42) ──
[
Test
]
public
void
CompressToolResult_Short_Passthrough
()
{
string
s
=
"短结果"
;
Assert
.
Equal
(
s
,
AgentEngine
.
CompressToolResult
(
s
,
"kb_grep"
));
}
[
Test
]
public
void
CompressToolResult_Long_KeepsHead_WithNotice
()
{
var
sb
=
new
System
.
Text
.
StringBuilder
();
int
line
=
0
;
while
(
sb
.
Length
<
15000
)
{
sb
.
Append
(
$"第
{
line
}
行内容:"
).
Append
(
new
string
(
'x'
,
30
)).
Append
(
"\r\n"
);
line
++;
}
string
result
=
sb
.
ToString
();
Assert
.
True
(
result
.
Length
>
12000
,
"前置:构造超长结果"
);
string
compressed
=
AgentEngine
.
CompressToolResult
(
result
,
"kb_grep"
);
Assert
.
True
(
compressed
.
Length
<
result
.
Length
,
"压缩后应变短"
);
Assert
.
True
(
compressed
.
Length
<=
12000
+
400
,
"保留头部长度的上限(含提示语)"
);
Assert
.
Contains
(
compressed
,
"已截断"
,
"应带截断提示"
);
Assert
.
Contains
(
compressed
,
"kb_read"
,
"应提示可续读"
);
Assert
.
Contains
(
compressed
,
"第0行内容"
,
"应保留头部连续内容"
);
Assert
.
NotContains
(
compressed
,
$"第
{
line
-
1
}
行内容"
,
"尾部内容应被截掉(不再头尾拼接丢中间)"
);
}
[
Test
]
public
void
CompressToolResult_Empty_ReturnsEmpty
()
{
Assert
.
Equal
(
""
,
AgentEngine
.
CompressToolResult
(
""
,
"t"
));
Assert
.
Null
(
AgentEngine
.
CompressToolResult
(
null
,
"t"
));
}
}
}
AgentTest/AgentTest.csproj
0 → 100644
View file @
4c991d55
<?xml version="1.0" encoding="utf-8"?>
<Project
ToolsVersion=
"15.0"
xmlns=
"http://schemas.microsoft.com/developer/msbuild/2003"
>
<Import
Project=
"$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props"
Condition=
"Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')"
/>
<PropertyGroup>
<Configuration
Condition=
" '$(Configuration)' == '' "
>
Debug
</Configuration>
<Platform
Condition=
" '$(Platform)' == '' "
>
AnyCPU
</Platform>
<ProjectGuid>
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}
</ProjectGuid>
<OutputType>
Exe
</OutputType>
<RootNamespace>
AgentTest
</RootNamespace>
<AssemblyName>
AgentTest
</AssemblyName>
<TargetFrameworkVersion>
v4.8
</TargetFrameworkVersion>
<FileAlignment>
512
</FileAlignment>
<AutoGenerateBindingRedirects>
true
</AutoGenerateBindingRedirects>
<Deterministic>
false
</Deterministic>
<TargetFrameworkProfile
/>
</PropertyGroup>
<PropertyGroup
Condition=
" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "
>
<PlatformTarget>
AnyCPU
</PlatformTarget>
<DebugSymbols>
true
</DebugSymbols>
<DebugType>
full
</DebugType>
<Optimize>
false
</Optimize>
<OutputPath>
bin\Debug\
</OutputPath>
<DefineConstants>
DEBUG;TRACE
</DefineConstants>
<ErrorReport>
prompt
</ErrorReport>
<WarningLevel>
4
</WarningLevel>
<Prefer32Bit>
false
</Prefer32Bit>
</PropertyGroup>
<PropertyGroup
Condition=
" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "
>
<PlatformTarget>
AnyCPU
</PlatformTarget>
<DebugType>
pdbonly
</DebugType>
<Optimize>
true
</Optimize>
<OutputPath>
bin\Release\
</OutputPath>
<DefineConstants>
TRACE
</DefineConstants>
<ErrorReport>
prompt
</ErrorReport>
<WarningLevel>
4
</WarningLevel>
<Prefer32Bit>
false
</Prefer32Bit>
</PropertyGroup>
<ItemGroup>
<Reference
Include=
"Newtonsoft.Json, Version=12.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL"
>
<HintPath>
..\packages\Newtonsoft.Json.12.0.1\lib\net45\Newtonsoft.Json.dll
</HintPath>
</Reference>
<Reference
Include=
"System"
/>
<Reference
Include=
"System.Core"
/>
<Reference
Include=
"System.Drawing"
/>
<Reference
Include=
"System.Xml.Linq"
/>
<Reference
Include=
"System.Data"
/>
<Reference
Include=
"System.Xml"
/>
</ItemGroup>
<ItemGroup>
<Compile
Include=
"Program.cs"
/>
<Compile
Include=
"TestUtil.cs"
/>
<Compile
Include=
"SecretProtectorTests.cs"
/>
<Compile
Include=
"DbQueryToolTests.cs"
/>
<Compile
Include=
"AgentEngineLogicTests.cs"
/>
<Compile
Include=
"FailoverPolicyTests.cs"
/>
<Compile
Include=
"KbServiceSandboxTests.cs"
/>
<Compile
Include=
"EmbeddingClientTests.cs"
/>
<Compile
Include=
"ToolPermissionTests.cs"
/>
</ItemGroup>
<ItemGroup>
<ProjectReference
Include=
"..\Tools\Tools.csproj"
>
<Project>
{C5AB3CE9-C46A-467D-96E4-D80DD3BBB239}
</Project>
<Name>
Tools
</Name>
</ProjectReference>
</ItemGroup>
<Import
Project=
"$(MSBuildToolsPath)\Microsoft.CSharp.targets"
/>
</Project>
AgentTest/DbQueryToolTests.cs
0 → 100644
View file @
4c991d55
using
Newtonsoft.Json.Linq
;
using
System
;
using
System.Collections.Generic
;
using
Tools.MyAgent.Agent.Tools
;
namespace
AgentTest
{
/// <summary>
/// P0 连接串脱敏 + SQL 白名单:db_query 只读校验、敏感信息脱敏、连接占位符解析。
/// </summary>
public
sealed
class
DbQueryToolTests
:
TestRunner
{
private
static
ToolContext
Ctx
()
{
return
new
ToolContext
(
null
,
null
)
{
DbConnections
=
new
Dictionary
<
string
,
string
>(
StringComparer
.
OrdinalIgnoreCase
)
{
[
"erp"
]
=
"Server=10.0.0.1;Database=erp;User Id=sa;Password=SuperSecret!;Trusted_Connection=False"
,
[
"local_sqlite"
]
=
@"D:\data\test.db"
,
},
};
}
// ── SQL 白名单校验 ──
[
Test
]
public
void
Validate_Allows_SingleSelect
()
{
Assert
.
Null
(
DbQueryTool
.
Validate
(
"sqlserver"
,
"conn"
,
"SELECT * FROM t WHERE id=1"
),
"普通 SELECT 应通过"
);
}
[
Test
]
public
void
Validate_Rejects_NonSelect
()
{
Assert
.
NotNull
(
DbQueryTool
.
Validate
(
"sqlserver"
,
"conn"
,
"UPDATE t SET a=1"
),
"UPDATE 应拒绝"
);
Assert
.
NotNull
(
DbQueryTool
.
Validate
(
"sqlserver"
,
"conn"
,
"DELETE FROM t"
),
"DELETE 应拒绝"
);
Assert
.
NotNull
(
DbQueryTool
.
Validate
(
"sqlserver"
,
"conn"
,
"INSERT INTO t(a) VALUES(1)"
),
"INSERT 应拒绝"
);
Assert
.
NotNull
(
DbQueryTool
.
Validate
(
"sqlserver"
,
"conn"
,
"DROP TABLE t"
),
"DROP 应拒绝"
);
}
[
Test
]
public
void
Validate_Rejects_MultiStatement
()
{
Assert
.
NotNull
(
DbQueryTool
.
Validate
(
"sqlserver"
,
"conn"
,
"SELECT 1; SELECT 2"
),
"分号多语句应拒绝"
);
}
[
Test
]
public
void
Validate_Rejects_Comments
()
{
Assert
.
NotNull
(
DbQueryTool
.
Validate
(
"sqlserver"
,
"conn"
,
"SELECT 1 -- 注释"
),
"-- 注释应拒绝"
);
Assert
.
NotNull
(
DbQueryTool
.
Validate
(
"sqlserver"
,
"conn"
,
"SELECT /*x*/ 1"
),
"块注释应拒绝"
);
}
[
Test
]
public
void
Validate_Rejects_SelectInto
()
{
Assert
.
NotNull
(
DbQueryTool
.
Validate
(
"sqlserver"
,
"conn"
,
"SELECT * INTO newt FROM t"
),
"SELECT INTO 应拒绝"
);
}
[
Test
]
public
void
Validate_Rejects_ExecAndPragma
()
{
Assert
.
NotNull
(
DbQueryTool
.
Validate
(
"sqlite"
,
"conn"
,
"SELECT * FROM t; PRAGMA writable_schema"
),
"PRAGMA 应拒绝"
);
Assert
.
NotNull
(
DbQueryTool
.
Validate
(
"sqlserver"
,
"conn"
,
"EXEC sp_who"
),
"EXEC 应拒绝"
);
}
[
Test
]
public
void
Validate_Rejects_UnsupportedType_And_Empty
()
{
Assert
.
NotNull
(
DbQueryTool
.
Validate
(
"oracle"
,
"conn"
,
"SELECT 1"
),
"不支持的数据库类型应拒绝"
);
Assert
.
NotNull
(
DbQueryTool
.
Validate
(
"sqlserver"
,
""
,
"SELECT 1"
),
"空连接串应拒绝"
);
Assert
.
NotNull
(
DbQueryTool
.
Validate
(
"sqlserver"
,
"conn"
,
""
),
"空 SQL 应拒绝"
);
}
[
Test
]
public
void
Validate_Allows_Select_With_Keywords_In_Strings
()
{
// 关键字出现在字符串字面量中不应误杀(白名单校验剔除字面量后判定)
Assert
.
Null
(
DbQueryTool
.
Validate
(
"sqlserver"
,
"conn"
,
"SELECT * FROM t WHERE name='update'"
));
Assert
.
Null
(
DbQueryTool
.
Validate
(
"sqlserver"
,
"conn"
,
"SELECT * FROM t WHERE name='delete' OR status='begin'"
));
// 双引号字面量(MySQL 等方言)同样剔除
Assert
.
Null
(
DbQueryTool
.
Validate
(
"mysql"
,
"conn"
,
"SELECT * FROM t WHERE name=\"update\""
));
Assert
.
Null
(
DbQueryTool
.
Validate
(
"mysql"
,
"conn"
,
"SELECT * FROM t WHERE name=\"drop\""
));
Assert
.
NotNull
(
DbQueryTool
.
Validate
(
"sqlserver"
,
"conn"
,
"SELECT * FROM t WHERE name='x' AND 1=1 DELETE FROM t"
),
"字面量外的真实写关键字仍应拒绝"
);
}
// ── 敏感信息脱敏 ──
[
Test
]
public
void
MaskSensitive_Masks_PasswordAndToken
()
{
Assert
.
Equal
(
"Password=***"
,
DbQueryTool
.
MaskSensitive
(
"Password=abc123"
));
Assert
.
Equal
(
"token=***"
,
DbQueryTool
.
MaskSensitive
(
"token=xyz"
));
Assert
.
NotContains
(
DbQueryTool
.
MaskSensitive
(
"Server=a;Password=hunter2;Database=b"
),
"hunter2"
,
"密码不得残留"
);
Assert
.
NotContains
(
DbQueryTool
.
MaskSensitive
(
"Bearer abc.def.ghi"
),
"abc.def.ghi"
,
"Bearer 令牌不得残留"
);
}
[
Test
]
public
void
MaskSensitive_Empty_ReturnsEmpty
()
{
Assert
.
Equal
(
""
,
DbQueryTool
.
MaskSensitive
(
""
));
}
// ── 连接占位符解析(P0) ──
[
Test
]
public
void
ResolveConnection_By_ConnectionId
()
{
string
conn
=
null
,
err
=
null
;
var
args
=
new
JObject
{
[
"connectionId"
]
=
"erp"
};
bool
ok
=
DbQueryTool
.
ResolveConnection
(
args
,
Ctx
(),
ref
conn
,
ref
err
);
Assert
.
True
(
ok
,
"按 ID 应解析成功"
);
Assert
.
Null
(
err
);
Assert
.
Contains
(
conn
,
"SuperSecret"
,
"预存连接串应完整返回给执行层(仅执行层可见)"
);
}
[
Test
]
public
void
ResolveConnection_IdCaseInsensitive
()
{
string
conn
=
null
,
err
=
null
;
var
args
=
new
JObject
{
[
"connectionId"
]
=
"ERP"
};
Assert
.
True
(
DbQueryTool
.
ResolveConnection
(
args
,
Ctx
(),
ref
conn
,
ref
err
),
"ID 应大小写不敏感"
);
}
[
Test
]
public
void
ResolveConnection_Missing_Id_Lists_Available
()
{
string
conn
=
null
,
err
=
null
;
var
args
=
new
JObject
{
[
"connectionId"
]
=
"not_exist"
};
bool
ok
=
DbQueryTool
.
ResolveConnection
(
args
,
Ctx
(),
ref
conn
,
ref
err
);
Assert
.
False
(
ok
,
"不存在的 ID 应失败"
);
Assert
.
Contains
(
err
,
"erp"
,
"错误信息应列出可用 ID"
);
Assert
.
Contains
(
err
,
"local_sqlite"
,
"错误信息应列出可用 ID"
);
Assert
.
NotContains
(
err
,
"SuperSecret"
,
"错误信息绝不回显密码"
);
}
[
Test
]
public
void
ResolveConnection_Neither_Given_ReturnsError
()
{
string
conn
=
null
,
err
=
null
;
bool
ok
=
DbQueryTool
.
ResolveConnection
(
new
JObject
(),
Ctx
(),
ref
conn
,
ref
err
);
Assert
.
False
(
ok
);
Assert
.
Contains
(
err
,
"connectionId"
,
"应提示使用 connectionId"
);
Assert
.
NotContains
(
err
,
"SuperSecret"
,
"错误信息绝不回显密码"
);
}
[
Test
]
public
void
ResolveConnection_Inline_Fallback
()
{
string
conn
=
null
,
err
=
null
;
var
args
=
new
JObject
{
[
"connection"
]
=
"Data Source=x.db"
};
Assert
.
True
(
DbQueryTool
.
ResolveConnection
(
args
,
Ctx
(),
ref
conn
,
ref
err
),
"未给 ID 时应回退到内联连接串"
);
Assert
.
Equal
(
"Data Source=x.db"
,
conn
);
}
[
Test
]
public
void
ResolveConnection_NoStore_ReturnsHint
()
{
string
conn
=
null
,
err
=
null
;
var
empty
=
new
ToolContext
(
null
,
null
);
bool
ok
=
DbQueryTool
.
ResolveConnection
(
new
JObject
(),
empty
,
ref
conn
,
ref
err
);
Assert
.
False
(
ok
);
Assert
.
Contains
(
err
,
"未配置"
,
"无预存连接时应提示去设置中预存"
);
}
}
}
AgentTest/EmbeddingClientTests.cs
0 → 100644
View file @
4c991d55
using
System
;
using
Tools.MyAgent.Kb
;
namespace
AgentTest
{
/// <summary>
/// P1 补测试:EmbeddingClient 向量序列化/反序列化/余弦相似度(纯数学逻辑)与批次参数。
/// </summary>
public
sealed
class
EmbeddingClientTests
:
TestRunner
{
[
Test
]
public
void
Serialize_Deserialize_Roundtrip
()
{
var
v
=
new
float
[]
{
1.5f
,
-
2.25f
,
0f
,
3.125f
};
var
bytes
=
EmbeddingClient
.
Serialize
(
v
);
Assert
.
NotNull
(
bytes
);
Assert
.
Equal
(
v
.
Length
*
4
,
bytes
.
Length
,
"float LE 每元素 4 字节"
);
var
back
=
EmbeddingClient
.
Deserialize
(
bytes
);
Assert
.
NotNull
(
back
);
Assert
.
Equal
(
v
.
Length
,
back
.
Length
);
for
(
int
i
=
0
;
i
<
v
.
Length
;
i
++)
Assert
.
Equal
(
v
[
i
],
back
[
i
],
"往返后数值应一致"
);
}
[
Test
]
public
void
Deserialize_InvalidInput_ReturnsNull
()
{
Assert
.
Null
(
EmbeddingClient
.
Deserialize
(
null
));
Assert
.
Null
(
EmbeddingClient
.
Deserialize
(
new
byte
[
0
]));
Assert
.
Null
(
EmbeddingClient
.
Deserialize
(
new
byte
[
3
]),
"非 4 倍数长度应返回 null"
);
}
[
Test
]
public
void
Serialize_Null_ReturnsNull
()
{
Assert
.
Null
(
EmbeddingClient
.
Serialize
(
null
));
}
[
Test
]
public
void
Cosine_Identical_IsOne
()
{
var
v
=
new
float
[]
{
1f
,
2f
,
3f
};
Assert
.
Near
(
1.0
,
EmbeddingClient
.
Cosine
(
v
,
v
),
1e-9
,
"相同向量余弦 = 1"
);
}
[
Test
]
public
void
Cosine_Orthogonal_IsZero
()
{
var
a
=
new
float
[]
{
1f
,
0f
};
var
b
=
new
float
[]
{
0f
,
1f
};
Assert
.
Near
(
0.0
,
EmbeddingClient
.
Cosine
(
a
,
b
),
1e-9
,
"正交向量余弦 = 0"
);
}
[
Test
]
public
void
Cosine_Opposite_IsMinusOne
()
{
var
a
=
new
float
[]
{
1f
,
2f
};
var
b
=
new
float
[]
{
-
1f
,
-
2f
};
Assert
.
Near
(-
1.0
,
EmbeddingClient
.
Cosine
(
a
,
b
),
1e-9
);
}
[
Test
]
public
void
Cosine_ScaleInvariant
()
{
var
a
=
new
float
[]
{
1f
,
2f
,
3f
};
var
b
=
new
float
[]
{
10f
,
20f
,
30f
};
Assert
.
Near
(
EmbeddingClient
.
Cosine
(
a
,
a
),
EmbeddingClient
.
Cosine
(
a
,
b
),
1e-9
,
"余弦与模长无关"
);
}
[
Test
]
public
void
Cosine_MismatchedLength_IsZero
()
{
Assert
.
Equal
(
0.0
,
EmbeddingClient
.
Cosine
(
new
float
[]
{
1f
},
new
float
[]
{
1f
,
2f
}));
Assert
.
Equal
(
0.0
,
EmbeddingClient
.
Cosine
(
null
,
new
float
[]
{
1f
}));
}
[
Test
]
public
void
BatchDefaults_AreSane
()
{
var
emb
=
new
EmbeddingClient
();
Assert
.
True
(
emb
.
BatchSize
>
0
&&
emb
.
BatchSize
<=
64
,
"默认批大小应在合理范围(节流)"
);
Assert
.
True
(
emb
.
BatchDelayMs
>=
0
,
"批间延迟不应为负"
);
}
// ── IsOllama 端点判定(修复:/v1 后缀的 OpenAI 兼容地址不应走 /api/embed) ──
[
Test
]
public
void
IsOllama_Localhost_True
()
{
var
emb
=
new
EmbeddingClient
{
BaseUrl
=
"http://localhost:11434"
};
Assert
.
True
(
emb
.
IsOllama
,
"本地 Ollama 默认地址应判定为 Ollama 端点"
);
}
[
Test
]
public
void
IsOllama_LocalhostWithV1Suffix_False
()
{
var
emb
=
new
EmbeddingClient
{
BaseUrl
=
"http://localhost:11434/v1"
};
Assert
.
False
(
emb
.
IsOllama
,
"Ollama 的 OpenAI 兼容端点(/v1 后缀)应走 /embeddings"
);
}
[
Test
]
public
void
IsOllama_RemoteWithPort11434_True
()
{
var
emb
=
new
EmbeddingClient
{
BaseUrl
=
"http://192.168.1.5:11434"
};
Assert
.
True
(
emb
.
IsOllama
,
"含 11434 端口的远程地址应判定为 Ollama 端点"
);
}
[
Test
]
public
void
IsOllama_OpenAiEndpoint_False
()
{
var
emb
=
new
EmbeddingClient
{
BaseUrl
=
"https://api.openai.com/v1"
};
Assert
.
False
(
emb
.
IsOllama
,
"在线 OpenAI 兼容端点不应判定为 Ollama"
);
}
}
}
AgentTest/FailoverPolicyTests.cs
0 → 100644
View file @
4c991d55
using
System
;
using
Tools.MyAgent.Agent
;
namespace
AgentTest
{
/// <summary>
/// P1 补测试:FailoverPolicy 故障转移策略——服务故障判定、切换/恢复、配置复位。
/// </summary>
public
sealed
class
FailoverPolicyTests
:
TestRunner
{
[
Test
]
public
void
Disabled_NoFailover
()
{
var
primary
=
new
FakeClient
();
var
backup
=
new
FakeClient
();
var
policy
=
new
FailoverPolicy
(
primary
,
backup
);
policy
.
Configure
(
false
,
"http://b"
,
"bm"
,
""
);
Assert
.
False
(
policy
.
RecordFailure
(
new
FakeHttpException
(
503
)),
"未启用时不应切换"
);
Assert
.
False
(
policy
.
IsActive
);
Assert
.
Same
(
primary
,
policy
.
CurrentClient
);
}
[
Test
]
public
void
TwoConsecutiveServiceErrors_TriggerFailover
()
{
var
primary
=
new
FakeClient
();
var
backup
=
new
FakeClient
();
var
policy
=
new
FailoverPolicy
(
primary
,
backup
);
policy
.
Configure
(
true
,
"http://b"
,
"bm"
,
""
);
Assert
.
False
(
policy
.
RecordFailure
(
new
FakeHttpException
(
503
)),
"第 1 次失败不应切换"
);
Assert
.
True
(
policy
.
RecordFailure
(
new
FakeHttpException
(
503
)),
"连续第 2 次失败应切换"
);
Assert
.
True
(
policy
.
IsActive
);
Assert
.
Same
(
backup
,
policy
.
CurrentClient
,
"切换后当前客户端应为备用"
);
}
[
Test
]
public
void
Http429_IsServiceError
()
{
Assert
.
True
(
FailoverPolicy
.
IsServiceError
(
new
FakeHttpException
(
429
)),
"429 限流应视为服务故障"
);
}
[
Test
]
public
void
Http4xx_NotServiceError
()
{
Assert
.
False
(
FailoverPolicy
.
IsServiceError
(
new
FakeHttpException
(
401
)),
"401 Key 错误不应触发故障转移"
);
Assert
.
False
(
FailoverPolicy
.
IsServiceError
(
new
FakeHttpException
(
400
)),
"400 参数错误不应触发故障转移"
);
}
[
Test
]
public
void
Http5xx_IsServiceError
()
{
Assert
.
True
(
FailoverPolicy
.
IsServiceError
(
new
FakeHttpException
(
500
)));
Assert
.
True
(
FailoverPolicy
.
IsServiceError
(
new
FakeHttpException
(
502
)));
Assert
.
True
(
FailoverPolicy
.
IsServiceError
(
new
FakeHttpException
(
503
)));
}
[
Test
]
public
void
MessageBased_Timeout_IsServiceError
()
{
Assert
.
True
(
FailoverPolicy
.
IsServiceError
(
new
Exception
(
"操作超时"
)),
"超时应视为服务故障"
);
Assert
.
True
(
FailoverPolicy
.
IsServiceError
(
new
Exception
(
"The operation has timed out."
)));
Assert
.
True
(
FailoverPolicy
.
IsServiceError
(
new
Exception
(
"connection refused"
)),
"连接失败应视为服务故障"
);
Assert
.
False
(
FailoverPolicy
.
IsServiceError
(
new
Exception
(
"参数错误"
)),
"业务错误不应触发"
);
}
[
Test
]
public
void
NullException_NotServiceError
()
{
Assert
.
False
(
FailoverPolicy
.
IsServiceError
(
null
));
}
[
Test
]
public
void
NonServiceFailure_ResetsCounter
()
{
var
primary
=
new
FakeClient
();
var
backup
=
new
FakeClient
();
var
policy
=
new
FailoverPolicy
(
primary
,
backup
);
policy
.
Configure
(
true
,
"http://b"
,
"bm"
,
""
);
policy
.
RecordFailure
(
new
FakeHttpException
(
503
));
// 1 次服务失败
Assert
.
False
(
policy
.
RecordFailure
(
new
Exception
(
"参数错误"
)),
"非服务失败不应切换且应清零计数"
);
Assert
.
False
(
policy
.
RecordFailure
(
new
FakeHttpException
(
503
)),
"计数已清零:再次 1 次失败不应切换"
);
}
[
Test
]
public
void
RecordSuccess_RestoresPrimary
()
{
var
primary
=
new
FakeClient
();
var
backup
=
new
FakeClient
();
var
policy
=
new
FailoverPolicy
(
primary
,
backup
);
policy
.
Configure
(
true
,
"http://b"
,
"bm"
,
""
);
policy
.
RecordFailure
(
new
FakeHttpException
(
503
));
policy
.
RecordFailure
(
new
FakeHttpException
(
503
));
Assert
.
True
(
policy
.
IsActive
);
policy
.
RecordSuccess
();
Assert
.
False
(
policy
.
IsActive
,
"成功后应恢复主模型"
);
Assert
.
Same
(
primary
,
policy
.
CurrentClient
);
}
[
Test
]
public
void
Configure_Disabled_AfterFailover_ResetsState
()
{
var
primary
=
new
FakeClient
();
var
backup
=
new
FakeClient
();
var
policy
=
new
FailoverPolicy
(
primary
,
backup
);
policy
.
Configure
(
true
,
"http://b"
,
"bm"
,
""
);
policy
.
RecordFailure
(
new
FakeHttpException
(
503
));
policy
.
RecordFailure
(
new
FakeHttpException
(
503
));
Assert
.
True
(
policy
.
IsActive
);
policy
.
Configure
(
false
,
"http://b"
,
"bm"
,
""
);
Assert
.
False
(
policy
.
IsActive
,
"禁用后 IsActive 不应残留(避免误报主模型已恢复)"
);
Assert
.
Same
(
primary
,
policy
.
CurrentClient
);
}
[
Test
]
public
void
Configure_AppliesBackupSettings
()
{
var
backup
=
new
FakeClient
();
var
policy
=
new
FailoverPolicy
(
new
FakeClient
(),
backup
);
policy
.
Configure
(
true
,
"http://backup:11434/v1"
,
"qwen2.5"
,
"bk-key"
);
Assert
.
Equal
(
"http://backup:11434/v1"
,
backup
.
BaseUrl
);
Assert
.
Equal
(
"qwen2.5"
,
backup
.
Model
);
Assert
.
Equal
(
"bk-key"
,
backup
.
ApiKey
);
}
[
Test
]
public
void
SetPrimary_FollowsWhenNotFailedOver
()
{
var
primary
=
new
FakeClient
();
var
backup
=
new
FakeClient
();
var
policy
=
new
FailoverPolicy
(
primary
,
backup
);
policy
.
Configure
(
true
,
"http://b"
,
"bm"
,
""
);
var
newPrimary
=
new
FakeClient
();
policy
.
SetPrimary
(
newPrimary
);
Assert
.
Same
(
newPrimary
,
policy
.
CurrentClient
,
"未故障转移时 SetPrimary 应立即生效"
);
}
[
Test
]
public
void
SetPrimary_KeepsBackupWhenFailedOver
()
{
var
primary
=
new
FakeClient
();
var
backup
=
new
FakeClient
();
var
policy
=
new
FailoverPolicy
(
primary
,
backup
);
policy
.
Configure
(
true
,
"http://b"
,
"bm"
,
""
);
policy
.
RecordFailure
(
new
FakeHttpException
(
503
));
policy
.
RecordFailure
(
new
FakeHttpException
(
503
));
Assert
.
True
(
policy
.
IsActive
);
policy
.
SetPrimary
(
new
FakeClient
());
Assert
.
Same
(
backup
,
policy
.
CurrentClient
,
"故障转移期间 SetPrimary 不应打断备用状态"
);
}
}
}
AgentTest/KbServiceSandboxTests.cs
0 → 100644
View file @
4c991d55
using
System
;
using
System.IO
;
using
Tools.MyAgent.Kb
;
namespace
AgentTest
{
/// <summary>
/// P1 补测试:知识库沙箱路径校验——../ 穿越、绝对路径、越权、reparse point、模糊匹配纯逻辑。
/// </summary>
public
sealed
class
KbServiceSandboxTests
:
TestRunner
{
private
string
_root
;
private
string
_outside
;
private
KbService
_kb
;
public
override
void
SetUp
()
{
_root
=
Path
.
Combine
(
Path
.
GetTempPath
(),
"agenttest_kb_"
+
Guid
.
NewGuid
().
ToString
(
"N"
));
_outside
=
Path
.
Combine
(
Path
.
GetTempPath
(),
"agenttest_out_"
+
Guid
.
NewGuid
().
ToString
(
"N"
));
Directory
.
CreateDirectory
(
Path
.
Combine
(
_root
,
"docs"
,
"sub"
));
Directory
.
CreateDirectory
(
_outside
);
File
.
WriteAllText
(
Path
.
Combine
(
_root
,
"docs"
,
"a.md"
),
"a"
);
File
.
WriteAllText
(
Path
.
Combine
(
_outside
,
"secret.txt"
),
"secret"
);
_kb
=
new
KbService
();
_kb
.
SetRoot
(
_root
);
}
public
override
void
TearDown
()
{
try
{
Directory
.
Delete
(
_root
,
true
);
}
catch
{
}
try
{
Directory
.
Delete
(
_outside
,
true
);
}
catch
{
}
}
[
Test
]
public
void
ResolveSafe_ValidPath
()
{
string
abs
=
_kb
.
ResolveSafe
(
"docs/a.md"
);
Assert
.
Equal
(
Path
.
Combine
(
_root
,
"docs"
,
"a.md"
),
abs
);
}
[
Test
]
public
void
ResolveSafe_Traversal_Rejected
()
{
Assert
.
Throws
<
InvalidOperationException
>(()
=>
_kb
.
ResolveSafe
(
"../outside/secret.txt"
),
"../ 穿越应拒绝"
);
Assert
.
Throws
<
InvalidOperationException
>(()
=>
_kb
.
ResolveSafe
(
"docs/../../outside/secret.txt"
),
"深层穿越应拒绝"
);
}
[
Test
]
public
void
ResolveSafe_AbsolutePath_Rejected
()
{
Assert
.
Throws
<
InvalidOperationException
>(()
=>
_kb
.
ResolveSafe
(
_outside
),
"绝对路径应拒绝"
);
}
[
Test
]
public
void
ResolveSafe_Null_MeansRoot
()
{
Assert
.
Equal
(
KbService
.
NormPath
(
_root
),
_kb
.
ResolveSafe
(
null
));
}
[
Test
]
public
void
ResolveIn_Unconfigured_Throws
()
{
Assert
.
Throws
<
InvalidOperationException
>(()
=>
KbService
.
ResolveIn
(
""
,
"x"
,
"msg"
));
}
[
Test
]
public
void
IsWithin_Boundary
()
{
string
rootNorm
=
KbService
.
NormPath
(
_root
);
Assert
.
True
(
KbService
.
IsWithin
(
rootNorm
,
KbService
.
NormPath
(
Path
.
Combine
(
_root
,
"docs"
))),
"子目录应在范围内"
);
Assert
.
True
(
KbService
.
IsWithin
(
rootNorm
,
rootNorm
),
"根自身应在范围内"
);
Assert
.
False
(
KbService
.
IsWithin
(
rootNorm
,
KbService
.
NormPath
(
_outside
)),
"外部目录不应在范围内"
);
Assert
.
False
(
KbService
.
IsWithin
(
rootNorm
,
KbService
.
NormPath
(
_root
+
"_sibling"
)),
"前缀相似目录不应误判"
);
}
[
Test
]
public
void
HasReparsePointOnPath_PlainPath_False
()
{
string
target
=
Path
.
Combine
(
_root
,
"docs"
,
"a.md"
);
Assert
.
False
(
KbService
.
HasReparsePointOnPath
(
KbService
.
NormPath
(
_root
),
target
),
"普通路径不应有 reparse point"
);
}
[
Test
]
public
void
GlobToRegex_Matching
()
{
Assert
.
True
(
KbService
.
GlobToRegex
(
"*.md"
).
IsMatch
(
"report.md"
),
"*.md 应匹配 report.md"
);
Assert
.
False
(
KbService
.
GlobToRegex
(
"*.md"
).
IsMatch
(
"report.docx"
),
"*.md 不应匹配 report.docx"
);
Assert
.
True
(
KbService
.
GlobToRegex
(
"2024*"
).
IsMatch
(
"20240815.xlsx"
));
Assert
.
True
(
KbService
.
GlobToRegex
(
"a?c"
).
IsMatch
(
"abc"
));
Assert
.
False
(
KbService
.
GlobToRegex
(
"a?c"
).
IsMatch
(
"abbc"
));
}
[
Test
]
public
void
Levenshtein_Distance
()
{
Assert
.
Equal
(
0
,
KbService
.
Levenshtein
(
"abc"
,
"abc"
));
Assert
.
Equal
(
1
,
KbService
.
Levenshtein
(
"abc"
,
"abd"
));
Assert
.
Equal
(
3
,
KbService
.
Levenshtein
(
""
,
"abc"
));
Assert
.
Equal
(
3
,
KbService
.
Levenshtein
(
"abc"
,
""
));
Assert
.
True
(
KbService
.
Levenshtein
(
"销售报表"
,
"销售报单"
)
>=
1
,
"中文差异应产生编辑距离"
);
}
[
Test
]
public
void
FuzzyScore_Ordering
()
{
Assert
.
Equal
(
1.0
,
KbService
.
FuzzyScore
(
"报表"
,
"报表"
),
"完全相等 = 1.0"
);
Assert
.
True
(
KbService
.
FuzzyScore
(
"销售报表"
,
"销售报表2024.xlsx"
)
>
0.9
,
"包含应高分"
);
Assert
.
True
(
KbService
.
FuzzyScore
(
"报表"
,
"无关文件"
)
<
0.5
,
"无关应低分"
);
Assert
.
Equal
(
0.0
,
KbService
.
FuzzyScore
(
""
,
"x"
));
}
[
Test
]
public
void
SafeEnumerateFiles_SkipsMissingAnd_ReturnsAll
()
{
int
count
=
0
;
foreach
(
var
f
in
_kb
.
SafeEnumerateFiles
(
_root
))
count
++;
Assert
.
Equal
(
1
,
count
,
"临时知识库应枚举到 1 个文件"
);
}
[
Test
]
public
void
SafeEnumerateFiles_Cancellation
()
{
using
(
var
cts
=
new
System
.
Threading
.
CancellationTokenSource
())
{
cts
.
Cancel
();
bool
cancelled
=
false
;
try
{
foreach
(
var
f
in
_kb
.
SafeEnumerateFiles
(
_root
,
cts
.
Token
))
{
}
}
catch
(
OperationCanceledException
)
{
cancelled
=
true
;
}
Assert
.
True
(
cancelled
,
"已取消的令牌应使枚举立即终止"
);
}
}
}
}
AgentTest/Program.cs
0 → 100644
View file @
4c991d55
using
System
;
using
System.Collections.Generic
;
using
System.Linq
;
using
System.Reflection
;
namespace
AgentTest
{
/// <summary>
/// 极简测试运行器(零外部依赖,离线可跑):
/// 扫描 TestRunner 派生类型中带 [Test] 特性的方法,逐个执行并汇总。
/// 退出码 = 失败数(0 = 全部通过),便于 CI/批处理判断。
/// </summary>
public
static
class
Program
{
public
static
int
Main
(
string
[]
args
)
{
Console
.
OutputEncoding
=
System
.
Text
.
Encoding
.
UTF8
;
var
total
=
0
;
var
passed
=
0
;
var
failed
=
0
;
var
failures
=
new
List
<
string
>();
var
runnerTypes
=
Assembly
.
GetExecutingAssembly
().
GetTypes
()
.
Where
(
t
=>
typeof
(
TestRunner
).
IsAssignableFrom
(
t
)
&&
!
t
.
IsAbstract
)
.
OrderBy
(
t
=>
t
.
Name
);
foreach
(
var
type
in
runnerTypes
)
{
var
instance
=
(
TestRunner
)
Activator
.
CreateInstance
(
type
);
foreach
(
var
method
in
type
.
GetMethods
(
BindingFlags
.
Public
|
BindingFlags
.
Instance
|
BindingFlags
.
DeclaredOnly
))
{
if
(
method
.
GetCustomAttributes
(
typeof
(
TestAttribute
),
false
).
Length
==
0
)
continue
;
total
++;
try
{
instance
.
SetUp
();
method
.
Invoke
(
instance
,
null
);
passed
++;
Console
.
WriteLine
(
$" ✓
{
type
.
Name
}
.
{
method
.
Name
}
"
);
}
catch
(
TargetInvocationException
ex
)
{
failed
++;
var
inner
=
ex
.
InnerException
??
ex
;
failures
.
Add
(
$"
{
type
.
Name
}
.
{
method
.
Name
}
:
{
inner
.
Message
}
"
);
Console
.
WriteLine
(
$" ✗
{
type
.
Name
}
.
{
method
.
Name
}
→
{
inner
.
Message
}
"
);
}
catch
(
Exception
ex
)
{
failed
++;
failures
.
Add
(
$"
{
type
.
Name
}
.
{
method
.
Name
}
:
{
ex
.
Message
}
"
);
Console
.
WriteLine
(
$" ✗
{
type
.
Name
}
.
{
method
.
Name
}
→
{
ex
.
Message
}
"
);
}
finally
{
instance
.
TearDown
();
}
}
}
Console
.
WriteLine
();
Console
.
WriteLine
(
$"共
{
total
}
项测试:通过
{
passed
}
,失败
{
failed
}
。"
);
if
(
failed
>
0
)
{
Console
.
WriteLine
(
"失败明细:"
);
foreach
(
var
f
in
failures
)
Console
.
WriteLine
(
" - "
+
f
);
}
return
failed
;
}
}
[
AttributeUsage
(
AttributeTargets
.
Method
)]
public
sealed
class
TestAttribute
:
Attribute
{
}
/// <summary>测试用例基类(SetUp 每次用例前执行;TearDown 每次用例后执行)</summary>
public
abstract
class
TestRunner
{
public
virtual
void
SetUp
()
{
}
public
virtual
void
TearDown
()
{
}
}
/// <summary>断言工具(失败抛异常中断当前用例)</summary>
public
static
class
Assert
{
public
static
void
True
(
bool
condition
,
string
message
=
"条件不成立"
)
{
if
(!
condition
)
throw
new
Exception
(
"断言失败:"
+
message
);
}
public
static
void
False
(
bool
condition
,
string
message
=
"条件不应成立"
)
{
if
(
condition
)
throw
new
Exception
(
"断言失败:"
+
message
);
}
public
static
void
Equal
(
object
expected
,
object
actual
,
string
message
=
""
)
{
bool
eq
=
expected
==
null
?
actual
==
null
:
string
.
Equals
(
expected
.
ToString
(),
actual
?.
ToString
(),
StringComparison
.
Ordinal
);
if
(!
eq
)
throw
new
Exception
(
$"断言失败:期望 [
{
expected
}
],实际 [
{
actual
}
]
{
Msg
(
message
)}
"
);
}
public
static
void
NotNull
(
object
value
,
string
message
=
"不应为 null"
)
{
if
(
value
==
null
)
throw
new
Exception
(
"断言失败:"
+
message
);
}
/// <summary>引用相等断言(Same 语义)</summary>
public
static
void
Same
(
object
expected
,
object
actual
,
string
message
=
""
)
{
if
(!
ReferenceEquals
(
expected
,
actual
))
throw
new
Exception
(
$"断言失败:期望同一实例
{
Msg
(
message
)}
"
);
}
public
static
void
Null
(
object
value
,
string
message
=
"应为 null"
)
{
if
(
value
!=
null
)
throw
new
Exception
(
$"断言失败:
{
message
}
(实际 [
{
value
}
])"
);
}
public
static
void
Contains
(
string
haystack
,
string
needle
,
string
message
=
""
)
{
if
(
haystack
==
null
||
haystack
.
IndexOf
(
needle
,
StringComparison
.
OrdinalIgnoreCase
)
<
0
)
throw
new
Exception
(
$"断言失败:未找到子串 [
{
needle
}
]
{
Msg
(
message
)}
(原文:
{
Trunc
(
haystack
)}
)"
);
}
public
static
void
NotContains
(
string
haystack
,
string
needle
,
string
message
=
""
)
{
if
(
haystack
!=
null
&&
haystack
.
IndexOf
(
needle
,
StringComparison
.
OrdinalIgnoreCase
)
>=
0
)
throw
new
Exception
(
$"断言失败:不应包含子串 [
{
needle
}
]
{
Msg
(
message
)}
"
);
}
public
static
void
Near
(
double
expected
,
double
actual
,
double
tolerance
,
string
message
=
""
)
{
if
(
Math
.
Abs
(
expected
-
actual
)
>
tolerance
)
throw
new
Exception
(
$"断言失败:期望 ≈
{
expected
}
(容差
{
tolerance
}
),实际
{
actual
}{
Msg
(
message
)}
"
);
}
public
static
void
Throws
<
TException
>(
Action
action
,
string
message
=
""
)
where
TException
:
Exception
{
try
{
action
();
}
catch
(
TException
)
{
return
;
}
catch
(
Exception
ex
)
{
throw
new
Exception
(
$"断言失败:期望抛
{
typeof
(
TException
).
Name
}
,实际抛
{
ex
.
GetType
().
Name
}{
Msg
(
message
)}
"
);
}
throw
new
Exception
(
$"断言失败:期望抛
{
typeof
(
TException
).
Name
}
,但未抛出
{
Msg
(
message
)}
"
);
}
private
static
string
Msg
(
string
m
)
=>
string
.
IsNullOrEmpty
(
m
)
?
""
:
";"
+
m
;
private
static
string
Trunc
(
string
s
)
{
if
(
s
==
null
)
return
"(null)"
;
return
s
.
Length
<=
80
?
s
:
s
.
Substring
(
0
,
80
)
+
"…"
;
}
}
}
AgentTest/SecretProtectorTests.cs
0 → 100644
View file @
4c991d55
using
System
;
using
Tools.MyAgent.Config
;
namespace
AgentTest
{
/// <summary>
/// P0 凭据收敛:SecretProtector(DPAPI)加解密、旧版明文兼容、前缀识别。
/// </summary>
public
sealed
class
SecretProtectorTests
:
TestRunner
{
[
Test
]
public
void
Protect_AddsPrefix_And_Unprotect_Roundtrips
()
{
string
plain
=
"sk-测试密钥-abc123"
;
string
stored
=
SecretProtector
.
Protect
(
plain
);
Assert
.
True
(
SecretProtector
.
IsProtected
(
stored
),
"加密结果应带 dpapi: 前缀"
);
Assert
.
NotContains
(
stored
,
plain
,
"密文不应包含明文"
);
Assert
.
Equal
(
plain
,
SecretProtector
.
Unprotect
(
stored
),
"解密应还原原文"
);
}
[
Test
]
public
void
Protect_Empty_ReturnsEmpty
()
{
Assert
.
Equal
(
""
,
SecretProtector
.
Protect
(
""
));
Assert
.
Equal
(
""
,
SecretProtector
.
Unprotect
(
""
));
Assert
.
False
(
SecretProtector
.
IsProtected
(
""
),
"空串不应视为密文"
);
}
[
Test
]
public
void
Unprotect_Plaintext_Passthrough
()
{
// 旧版明文配置兼容:无前缀按明文原样返回
Assert
.
Equal
(
"legacy-plain"
,
SecretProtector
.
Unprotect
(
"legacy-plain"
));
}
[
Test
]
public
void
Unprotect_NonDecryptable_ReturnsEmpty
()
{
// 篡改/换机器后无法解密 → 视为未配置(返回空串),不抛异常
string
corrupted
=
SecretProtector
.
Prefix
+
"!!!not-base64!!!"
;
Assert
.
Equal
(
""
,
SecretProtector
.
Unprotect
(
corrupted
));
}
[
Test
]
public
void
SamePlain_Encrypts_DifferentlyEachTime
()
{
// DPAPI 带熵随机盐:同一明文两次加密密文不同(防密文比对)
string
a
=
SecretProtector
.
Protect
(
"secret-123"
);
string
b
=
SecretProtector
.
Protect
(
"secret-123"
);
Assert
.
False
(
string
.
Equals
(
a
,
b
,
StringComparison
.
Ordinal
),
"两次加密结果不应相同"
);
Assert
.
Equal
(
"secret-123"
,
SecretProtector
.
Unprotect
(
a
));
Assert
.
Equal
(
"secret-123"
,
SecretProtector
.
Unprotect
(
b
));
}
[
Test
]
public
void
CjkContent_Survives_Roundtrip
()
{
// 中文兼容(ECC 文件编码要求):含中文的凭据加解密不损坏
string
plain
=
"共享文件夹密码:测试¥123"
;
Assert
.
Equal
(
plain
,
SecretProtector
.
Unprotect
(
SecretProtector
.
Protect
(
plain
)));
}
}
}
AgentTest/TestUtil.cs
0 → 100644
View file @
4c991d55
using
Newtonsoft.Json.Linq
;
using
System
;
using
System.Collections.Generic
;
using
System.Threading
;
using
System.Threading.Tasks
;
using
Tools.MyAgent.Agent
;
namespace
AgentTest
{
/// <summary>可编程假客户端(FailoverPolicy 测试用):按脚本返回成功/抛指定异常</summary>
internal
sealed
class
FakeClient
:
IOpenAiChatClient
{
public
string
BaseUrl
{
get
;
set
;
}
=
"http://fake"
;
public
string
ApiKey
{
get
;
set
;
}
=
""
;
public
string
Model
{
get
;
set
;
}
=
"fake-model"
;
public
double
Temperature
{
get
;
set
;
}
=
0.3
;
public
int
MaxTokens
{
get
;
set
;
}
=
8192
;
public
bool
UseSystemProxy
{
get
;
set
;
}
/// <summary>脚本:每次 SendAsync 弹出第一个(成功返回 null 表示成功,否则抛出该异常)</summary>
public
Queue
<
Exception
>
Script
=
new
Queue
<
Exception
>();
public
int
SendCalls
;
public
int
CompleteCalls
;
public
Task
<
ChatResult
>
SendAsync
(
JArray
messages
,
JArray
tools
,
bool
enableTools
,
CancellationToken
ct
)
{
SendCalls
++;
if
(
Script
.
Count
>
0
)
{
var
ex
=
Script
.
Dequeue
();
if
(
ex
!=
null
)
throw
ex
;
}
return
Task
.
FromResult
(
new
ChatResult
{
Content
=
"ok"
});
}
public
Task
<
string
>
CompleteAsync
(
JArray
messages
,
int
maxTokens
,
CancellationToken
ct
)
{
CompleteCalls
++;
return
Task
.
FromResult
(
"ok"
);
}
public
Task
<(
bool
Ok
,
string
Message
)>
TestConnectionAsync
(
CancellationToken
ct
)
=>
Task
.
FromResult
((
true
,
"ok"
));
public
void
RebuildHttpClient
()
{
}
public
void
Cancel
()
{
}
public
void
Dispose
()
{
}
}
/// <summary>带 HTTP 状态码的异常(模拟 OpenAiCompatibleClient 的网络错误语义)</summary>
internal
sealed
class
FakeHttpException
:
Exception
{
public
FakeHttpException
(
int
statusCode
)
:
base
(
"HTTP "
+
statusCode
)
{
Data
[
"HttpStatusCode"
]
=
statusCode
;
}
}
}
AgentTest/ToolPermissionTests.cs
0 → 100644
View file @
4c991d55
using
System
;
using
System.Collections.Generic
;
using
Tools.MyAgent.Agent.Tools
;
namespace
AgentTest
{
/// <summary>
/// P3 工具级权限:角色白名单判定——空规则放行、通配符、多角色分隔、大小写不敏感。
/// </summary>
public
sealed
class
ToolPermissionTests
:
TestRunner
{
private
static
Dictionary
<
string
,
string
>
Rules
(
params
string
[]
kv
)
{
var
d
=
new
Dictionary
<
string
,
string
>(
StringComparer
.
OrdinalIgnoreCase
);
for
(
int
i
=
0
;
i
+
1
<
kv
.
Length
;
i
+=
2
)
d
[
kv
[
i
]]
=
kv
[
i
+
1
];
return
d
;
}
[
Test
]
public
void
NoRules_AllAllowed
()
{
Assert
.
True
(
ToolPermission
.
IsAllowed
(
"db_query"
,
"任何人"
,
null
));
Assert
.
True
(
ToolPermission
.
IsAllowed
(
"db_query"
,
"任何人"
,
new
Dictionary
<
string
,
string
>()));
}
[
Test
]
public
void
UnlistedTool_Allowed
()
{
var
rules
=
Rules
(
"db_query"
,
"管理员"
);
Assert
.
True
(
ToolPermission
.
IsAllowed
(
"kb_grep"
,
"普通用户"
,
rules
),
"未配置规则的工具不受限制"
);
}
[
Test
]
public
void
EmptyOrStar_Allowed
()
{
var
rules
=
Rules
(
"db_query"
,
""
,
"module_patch"
,
"*"
);
Assert
.
True
(
ToolPermission
.
IsAllowed
(
"db_query"
,
"任何人"
,
rules
),
"空规则 = 不限制"
);
Assert
.
True
(
ToolPermission
.
IsAllowed
(
"module_patch"
,
"任何人"
,
rules
),
"* = 不限制"
);
}
[
Test
]
public
void
RoleWhitelist_Matches
()
{
var
rules
=
Rules
(
"db_query"
,
"管理员,主管"
);
Assert
.
True
(
ToolPermission
.
IsAllowed
(
"db_query"
,
"管理员"
,
rules
));
Assert
.
True
(
ToolPermission
.
IsAllowed
(
"db_query"
,
"主管"
,
rules
));
Assert
.
False
(
ToolPermission
.
IsAllowed
(
"db_query"
,
"普通员工"
,
rules
),
"白名单外角色应拒绝"
);
}
[
Test
]
public
void
RuleKey_CaseInsensitive
()
{
// 配置字典可能为默认比较器(区分大小写):DB_QUERY 规则必须能拦截 db_query 的白名单外角色
var
rules
=
Rules
(
"DB_QUERY"
,
"管理员"
);
Assert
.
True
(
ToolPermission
.
IsAllowed
(
"db_query"
,
"管理员"
,
rules
),
"规则键大小写不同也应命中"
);
Assert
.
False
(
ToolPermission
.
IsAllowed
(
"db_query"
,
"普通用户"
,
rules
),
"规则键大小写不同时白名单外角色仍应拒绝"
);
}
[
Test
]
public
void
RoleMatch_CaseInsensitive
()
{
var
rules
=
Rules
(
"db_query"
,
"Admin"
);
Assert
.
True
(
ToolPermission
.
IsAllowed
(
"db_query"
,
"admin"
,
rules
));
Assert
.
True
(
ToolPermission
.
IsAllowed
(
"db_query"
,
"ADMIN"
,
rules
));
}
[
Test
]
public
void
ChineseDelimiters_Supported
()
{
var
rules
=
Rules
(
"db_query"
,
"管理员;主管、经理"
);
Assert
.
True
(
ToolPermission
.
IsAllowed
(
"db_query"
,
"主管"
,
rules
),
"中文分号分隔应生效"
);
Assert
.
True
(
ToolPermission
.
IsAllowed
(
"db_query"
,
"经理"
,
rules
),
"顿号分隔应生效"
);
}
[
Test
]
public
void
EmptyRole_WithRestrictions_Denied
()
{
var
rules
=
Rules
(
"db_query"
,
"管理员"
);
Assert
.
False
(
ToolPermission
.
IsAllowed
(
"db_query"
,
""
,
rules
),
"有白名单时匿名角色应拒绝"
);
}
[
Test
]
public
void
NullToolName_Denied
()
{
Assert
.
False
(
ToolPermission
.
IsAllowed
(
null
,
"x"
,
null
));
}
[
Test
]
public
void
WhitespaceTolerated
()
{
var
rules
=
Rules
(
"db_query"
,
" 管理员 , 主管 "
);
Assert
.
True
(
ToolPermission
.
IsAllowed
(
"db_query"
,
"主管"
,
rules
),
"规则值应容忍空格"
);
}
}
}
Ftt_Common.sln
View file @
4c991d55
...
...
@@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNetVersionChecker", "Dot
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WindowsSearch", "WindowsSearch\WindowsSearch.csproj", "{0A591ECF-134D-40FD-A8E4-57D8A70FC309}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgentTest", "AgentTest\AgentTest.csproj", "{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
...
...
@@ -139,6 +141,18 @@ Global
{0A591ECF-134D-40FD-A8E4-57D8A70FC309}.Release|x64.Build.0 = Release|x64
{0A591ECF-134D-40FD-A8E4-57D8A70FC309}.Release|x86.ActiveCfg = Release|x86
{0A591ECF-134D-40FD-A8E4-57D8A70FC309}.Release|x86.Build.0 = Release|x86
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Debug|x64.ActiveCfg = Debug|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Debug|x64.Build.0 = Debug|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Debug|x86.ActiveCfg = Debug|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Debug|x86.Build.0 = Debug|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Release|Any CPU.Build.0 = Release|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Release|x64.ActiveCfg = Release|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Release|x64.Build.0 = Release|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Release|x86.ActiveCfg = Release|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
...
...
Tools/MyAgent/Agent/AgentEngine.cs
View file @
4c991d55
...
...
@@ -105,6 +105,15 @@ namespace Tools.MyAgent.Agent
/// <summary>是否向模型下发可选/高级工具(默认只下发核心工具)</summary>
private
bool
_includeOptionalTools
;
/// <summary>工具级权限(P3):工具名 → 允许角色列表;空 = 全部可用(由 AgentConfig.ToolRoleAllow 注入)</summary>
private
Dictionary
<
string
,
string
>
_toolRoleAllow
=
new
Dictionary
<
string
,
string
>();
/// <summary>当前登录用户名(即角色,P3 工具权限判定用;由宿主注入)</summary>
public
string
CurrentUser
{
get
;
set
;
}
=
""
;
/// <summary>预存数据库连接串(ID → 连接串,P0 脱敏;由 AgentConfig.DbConnections 解密注入)</summary>
public
Dictionary
<
string
,
string
>
DbConnections
{
get
;
private
set
;
}
=
new
Dictionary
<
string
,
string
>();
/// <summary>v2.2 多模态:是否启用图片提问(附图以 image_url 内容块发送)</summary>
private
bool
_imageInputEnabled
;
...
...
@@ -120,6 +129,10 @@ namespace Tools.MyAgent.Agent
/// </summary>
private
int
_runGeneration
;
/// <summary>本轮上下文处理提示(历史省略/压缩);经 OnTextDelta 即时显示,
/// 并在最终回答前拼接保留(修复:此前被回答正文全量替换而丢失)</summary>
private
string
_contextNotice
=
""
;
public
AgentEngine
(
KbService
kb
)
{
_kb
=
kb
;
...
...
@@ -155,6 +168,9 @@ namespace Tools.MyAgent.Agent
_prompts
.
SystemPromptEnabled
=
cfg
.
SystemPromptEnabled
;
_imageInputEnabled
=
cfg
.
EnableImageInput
;
// v2.2 多模态:图片提问开关
CostEstimator
.
Configure
(
cfg
.
CustomPrices
);
// R6 费用估算:应用自定义价格表覆盖
// P0 连接串脱敏 / P3 工具权限:预存连接与角色规则随配置注入
DbConnections
=
cfg
.
DbConnections
??
new
Dictionary
<
string
,
string
>(
StringComparer
.
OrdinalIgnoreCase
);
_toolRoleAllow
=
cfg
.
ToolRoleAllow
??
new
Dictionary
<
string
,
string
>(
StringComparer
.
OrdinalIgnoreCase
);
// #31 故障转移配置
_failover
.
Configure
(
cfg
.
EnableFailover
,
cfg
.
BackupBaseUrl
,
cfg
.
BackupModel
,
cfg
.
BackupApiKey
);
...
...
@@ -172,6 +188,16 @@ namespace Tools.MyAgent.Agent
_includeOptionalTools
=
enabled
;
}
/// <summary>
/// 构建本次下发的工具 Schema:可选工具开关 + 工具级权限过滤(P3,按当前用户角色)。
/// <para>权限不足的工具不下发(模型不可见),执行侧另有兜底校验(纵深防御)。</para>
/// </summary>
private
JArray
BuildTools
()
{
return
ToolSchema
.
Build
(
_includeOptionalTools
,
name
=>
ToolPermission
.
IsAllowed
(
name
,
CurrentUser
,
_toolRoleAllow
));
}
/// <summary>
/// 运行一轮回答。history 需已包含当前用户问题(含此前对话)。
/// </summary>
...
...
@@ -186,9 +212,10 @@ namespace Tools.MyAgent.Agent
{
_toolTrace
.
Clear
();
_continueRounds
=
0
;
_contextNotice
=
""
;
_runGeneration
++;
var
messages
=
await
BuildMessagesAsync
(
history
,
_cts
.
Token
).
ConfigureAwait
(
false
);
var
tools
=
ToolSchema
.
Build
(
_includeOptionalTools
);
var
tools
=
BuildTools
(
);
var
stopwatch
=
Stopwatch
.
StartNew
();
int
toolRounds
=
0
;
var
finalAnswer
=
new
StringBuilder
();
// 跨轮累计的回答正文(UI 事件为全量替换语义)
...
...
@@ -375,6 +402,8 @@ namespace Tools.MyAgent.Agent
if
(
finalAnswer
.
Length
==
0
)
finalAnswer
.
Append
(
"(模型未返回内容)"
);
else
if
(
_contextNotice
.
Length
>
0
)
finalAnswer
.
Insert
(
0
,
_contextNotice
+
"\n\n"
);
// 上下文提示保留在最终回答开头(此前被正文覆盖)
if
(
IsTruncatedByLength
(
result
))
{
// 达到续写上限仍被截断:明确提示用户,不再静默展示半截回答
...
...
@@ -511,13 +540,19 @@ namespace Tools.MyAgent.Agent
int
dropped
=
DropOldestUntilUnder
(
list
,
budget
);
if
(
dropped
>
0
)
OnTextDelta
?.
Invoke
(
$"\n\n(对话历史较长,已自动省略最早
{
dropped
}
条消息以控制上下文)"
);
{
_contextNotice
=
$"(对话历史较长,已自动省略最早
{
dropped
}
条消息以控制上下文)"
;
OnTextDelta
?.
Invoke
(
"\n\n"
+
_contextNotice
);
}
if
(
EstimateTokens
(
list
)
>
budget
)
{
int
summarized
=
await
SummarizeEarliestAsync
(
list
,
ct
).
ConfigureAwait
(
false
);
if
(
summarized
>
0
)
OnTextDelta
?.
Invoke
(
$"\n\n(历史已自动压缩为摘要,保留最近
{
summarized
}
条消息继续回答)"
);
{
_contextNotice
=
$"(历史已自动压缩为摘要,保留最近
{
summarized
}
条消息继续回答)"
;
OnTextDelta
?.
Invoke
(
"\n\n"
+
_contextNotice
);
}
}
return
list
;
}
...
...
@@ -575,10 +610,14 @@ namespace Tools.MyAgent.Agent
return
added
>
0
?
(
JToken
)
arr
:
(
JToken
)
text
;
// 全部图片不可读/超限 → 降级纯文本
}
/// <summary>本地图片 → base64 data URL(原图上限 MaxImageBytes=4MB,避免超出 API 限制);失败返回 null</summary>
/// <summary>
/// 本地图片 → base64 data URL(P2 多模态压缩:最长边超 MaxImageEdge 时先降采样再编码,控制请求体积;
/// 原图文件大小上限 MaxImageBytes 防超 API 限制);失败返回 null。
/// </summary>
private
static
string
ImageToDataUrl
(
string
path
)
{
const
long
MaxImageBytes
=
4
*
1024
*
1024
;
const
int
MaxImageEdge
=
1024
;
if
(
string
.
IsNullOrEmpty
(
path
)
||
!
System
.
IO
.
File
.
Exists
(
path
))
return
null
;
var
fi
=
new
System
.
IO
.
FileInfo
(
path
);
if
(
fi
.
Length
<=
0
||
fi
.
Length
>
MaxImageBytes
)
return
null
;
...
...
@@ -587,8 +626,43 @@ namespace Tools.MyAgent.Agent
:
ext
==
".gif"
?
"image/gif"
:
ext
==
".bmp"
?
"image/bmp"
:
"image/png"
;
byte
[]
bytes
=
System
.
IO
.
File
.
ReadAllBytes
(
path
);
return
"data:"
+
mime
+
";base64,"
+
Convert
.
ToBase64String
(
bytes
);
try
{
using
(
var
img
=
System
.
Drawing
.
Image
.
FromFile
(
path
))
{
int
longest
=
Math
.
Max
(
img
.
Width
,
img
.
Height
);
if
(
longest
>
MaxImageEdge
)
{
// 降采样:等比缩放到最长边 MaxImageEdge(高质双三次插值),编码为 JPEG 控制体积
double
scale
=
(
double
)
MaxImageEdge
/
longest
;
int
nw
=
Math
.
Max
(
1
,
(
int
)(
img
.
Width
*
scale
));
int
nh
=
Math
.
Max
(
1
,
(
int
)(
img
.
Height
*
scale
));
using
(
var
bmp
=
new
System
.
Drawing
.
Bitmap
(
nw
,
nh
))
{
using
(
var
g
=
System
.
Drawing
.
Graphics
.
FromImage
(
bmp
))
{
g
.
InterpolationMode
=
System
.
Drawing
.
Drawing2D
.
InterpolationMode
.
HighQualityBicubic
;
g
.
SmoothingMode
=
System
.
Drawing
.
Drawing2D
.
SmoothingMode
.
HighQuality
;
g
.
PixelOffsetMode
=
System
.
Drawing
.
Drawing2D
.
PixelOffsetMode
.
HighQuality
;
g
.
DrawImage
(
img
,
0
,
0
,
nw
,
nh
);
}
using
(
var
ms
=
new
System
.
IO
.
MemoryStream
())
{
bmp
.
Save
(
ms
,
System
.
Drawing
.
Imaging
.
ImageFormat
.
Jpeg
);
return
"data:image/jpeg;base64,"
+
Convert
.
ToBase64String
(
ms
.
ToArray
());
}
}
}
// 原图已足够小:保持原样编码
byte
[]
bytes
=
System
.
IO
.
File
.
ReadAllBytes
(
path
);
return
"data:"
+
mime
+
";base64,"
+
Convert
.
ToBase64String
(
bytes
);
}
}
catch
{
return
null
;
}
}
/// <summary>
...
...
@@ -617,7 +691,7 @@ namespace Tools.MyAgent.Agent
/// 「超预算裁剪最早消息」从未生效;现改为:最早 user 在 index 1 时连同其后一条 assistant 一起移除
/// (保持首条消息为 user 的 API 交替要求),否则移除 [前一 assistant, user] 对。
/// </remarks>
private
static
int
DropOldestUntilUnder
(
JArray
list
,
int
budget
)
internal
static
int
DropOldestUntilUnder
(
JArray
list
,
int
budget
)
{
int
dropped
=
0
;
while
(
list
.
Count
>
3
&&
EstimateTokens
(
list
)
>
budget
)
...
...
@@ -766,41 +840,63 @@ namespace Tools.MyAgent.Agent
#
region
工具执行
/// <summary>
/// 在后台线程执行工具,支持:ESC 取消(立即放弃等待)与单工具超时兜底。
/// <para>同步的全盘检索(kb_grep / kb_find)无法硬中断线程,但取消/超时后引擎立即继续,
/// 用户不再有"取消无效"的等待感;残留任务执行完后自然结束,不影响后续回答。</para>
/// 在后台线程执行工具,支持:ESC 取消与单工具超时——通过把「取消令牌 + 超时令牌」传入工具上下文,
/// 让全盘扫描等长操作在枚举循环内协作式终止(P1:超时后真正结束,而非残留后台任务继续跑)。
/// <para>协作式取消之外保留硬超时兑底(ToolTimeoutSeconds + 30s):对不响应取消令牌的极端路径
/// (如超大文件解析)不再无限等待,引擎立即继续,残留任务副作用由代际抑制。</para>
/// </summary>
private
async
Task
<
string
>
ExecuteToolAsync
(
ApiToolCall
tc
,
CancellationToken
ct
)
{
int
gen
=
_runGeneration
;
try
{
var
execTask
=
Task
.
Run
(()
=>
ExecuteTool
(
tc
,
gen
));
var
timeoutTask
=
Task
.
Delay
(
TimeSpan
.
FromSeconds
(
ToolTimeoutSeconds
),
CancellationToken
.
None
);
var
cancelTask
=
Task
.
Delay
(
Timeout
.
Infinite
,
ct
);
var
done
=
await
Task
.
WhenAny
(
execTask
,
timeoutTask
,
cancelTask
).
ConfigureAwait
(
false
);
if
(
done
==
execTask
)
return
await
execTask
.
ConfigureAwait
(
false
);
if
(
ct
.
IsCancellationRequested
)
return
"已取消。"
;
return
$"错误:工具
{
tc
.
Name
}
执行超时(
{
ToolTimeoutSeconds
}
秒),已中止。"
;
}
catch
(
OperationCanceledException
)
using
(
var
toolCts
=
CancellationTokenSource
.
CreateLinkedTokenSource
(
ct
))
{
return
"已取消。"
;
toolCts
.
CancelAfter
(
TimeSpan
.
FromSeconds
(
ToolTimeoutSeconds
));
// 单工具超时 → 触发工具内部取消检查(协作式终止)
// 硬超时兑底:协作式取消未能按时结束(如解析超大文件)时不再无限等待
// (修复:独立 CTS 使 WhenAny 返回后立即取消并释放 150s 定时器,避免每次工具调用残留一个延迟任务)
using
(
var
hardCts
=
new
CancellationTokenSource
())
{
var
hardTimeout
=
Task
.
Delay
(
TimeSpan
.
FromSeconds
(
ToolTimeoutSeconds
+
30
),
hardCts
.
Token
);
try
{
var
execTask
=
Task
.
Run
(()
=>
ExecuteTool
(
tc
,
gen
,
toolCts
.
Token
));
var
done
=
await
Task
.
WhenAny
(
execTask
,
hardTimeout
).
ConfigureAwait
(
false
);
if
(
done
!=
execTask
)
{
AgentLogger
.
Log
(
$"工具
{
tc
.
Name
}
未在协作取消后按时结束,已放弃等待"
);
return
ct
.
IsCancellationRequested
?
"已取消。"
:
$"错误:工具
{
tc
.
Name
}
执行超时(
{
ToolTimeoutSeconds
}
秒),已中止。"
;
}
return
await
execTask
.
ConfigureAwait
(
false
);
}
catch
(
OperationCanceledException
)
{
if
(
ct
.
IsCancellationRequested
)
return
"已取消。"
;
return
$"错误:工具
{
tc
.
Name
}
执行超时(
{
ToolTimeoutSeconds
}
秒),已中止。"
;
}
finally
{
hardCts
.
Cancel
();
// 工具已结束:释放硬超时定时器,不再等待 150s
}
}
}
}
private
string
ExecuteTool
(
ApiToolCall
tc
,
int
gen
)
private
string
ExecuteTool
(
ApiToolCall
tc
,
int
gen
,
CancellationToken
ct
)
{
var
sw
=
System
.
Diagnostics
.
Stopwatch
.
StartNew
();
try
{
ct
.
ThrowIfCancellationRequested
();
var
tool
=
ToolRegistry
.
Find
(
tc
.
Name
);
if
(
tool
==
null
)
return
$"错误:未知工具
{
tc
.
Name
}
"
;
// P3 工具级权限(纵深防御):执行侧再校验一次,防止 Schema 过滤被绕过
if
(!
ToolPermission
.
IsAllowed
(
tc
.
Name
,
CurrentUser
,
_toolRoleAllow
))
return
$"错误:当前用户没有使用
{
tc
.
Name
}
的权限(需在设置中为该工具配置角色白名单)。"
;
JObject
args
;
try
{
...
...
@@ -811,7 +907,8 @@ namespace Tools.MyAgent.Agent
return
$"错误:工具参数解析失败:
{
tc
.
Arguments
}
"
;
}
OnToolStatus
?.
Invoke
(
tool
.
Describe
(
args
));
string
desc
=
tool
.
Describe
(
args
);
// 只计算一次:状态栏 / 日志 / 轨迹共用
OnToolStatus
?.
Invoke
(
desc
);
// 知识库检索类工具:先确保知识库可用(连接/范围校验),再执行
if
(
tool
.
RequiresKb
&&
(!
_kb
.
ConnectIfNeeded
()
||
!
_kb
.
IsSearchValid
))
...
...
@@ -819,11 +916,15 @@ namespace Tools.MyAgent.Agent
// 超时/取消后残留的后台任务:抑制迟到副作用(输出文件误报/轨迹污染)
var
ctx
=
new
ToolContext
(
_kb
,
f
=>
{
if
(
gen
==
System
.
Threading
.
Volatile
.
Read
(
ref
_runGeneration
))
OnFileProduced
?.
Invoke
(
f
);
});
f
=>
{
if
(
gen
==
System
.
Threading
.
Volatile
.
Read
(
ref
_runGeneration
))
OnFileProduced
?.
Invoke
(
f
);
})
{
CancelToken
=
ct
,
DbConnections
=
DbConnections
,
};
string
result
=
tool
.
Execute
(
args
,
ctx
);
sw
.
Stop
();
// #29 工具日志
AgentLogger
.
LogTool
(
tc
.
Name
,
tool
.
Describe
(
args
)
,
sw
.
ElapsedMilliseconds
,
SummarizeToolResult
(
result
));
AgentLogger
.
LogTool
(
tc
.
Name
,
desc
,
sw
.
ElapsedMilliseconds
,
SummarizeToolResult
(
result
));
// #42 结果压缩:超长结果压缩后回填模型,完整结果落盘供溯源
result
=
CompressToolResult
(
result
,
tc
.
Name
);
if
(
gen
==
System
.
Threading
.
Volatile
.
Read
(
ref
_runGeneration
))
...
...
@@ -833,7 +934,7 @@ namespace Tools.MyAgent.Agent
_toolTrace
.
Add
(
new
ToolTraceEntry
{
Name
=
tc
.
Name
,
Args
=
tool
.
Describe
(
args
)
??
""
,
Args
=
desc
,
DurationMs
=
sw
.
ElapsedMilliseconds
,
ResultSummary
=
SummarizeToolResult
(
result
),
});
...
...
@@ -841,6 +942,11 @@ namespace Tools.MyAgent.Agent
}
return
result
;
}
catch
(
OperationCanceledException
)
{
// 取消/超时:直接向上传播,由 ExecuteToolAsync 返回「已取消/超时」消息(不记录为工具错误)
throw
;
}
catch
(
Exception
ex
)
{
sw
.
Stop
();
...
...
Tools/MyAgent/Agent/FailoverPolicy.cs
View file @
4c991d55
...
...
@@ -102,7 +102,7 @@ namespace Tools.MyAgent.Agent
/// <para>D4:带 HTTP 状态码的 4xx(401 Key 错误、400 参数错误等)属于配置/请求问题,
/// 不触发故障转移,直接报错提示用户修正,避免掩盖真实配置错误。</para>
/// </summary>
private
static
bool
IsServiceError
(
Exception
ex
)
internal
static
bool
IsServiceError
(
Exception
ex
)
{
if
(
ex
==
null
)
return
false
;
var
msg
=
ex
.
InnerException
?.
Message
??
ex
.
Message
??
""
;
...
...
Tools/MyAgent/Agent/OpenAiCompatibleClient.cs
View file @
4c991d55
...
...
@@ -270,7 +270,7 @@ namespace Tools.MyAgent.Agent
{
// 流读取中断(网络抖动):重试完整请求。正文/思维链事件按累计全量下发,重放幂等(赋值而非追加)
if
(
attempt
>=
MaxAttempts
)
throw
;
await
RetryDelayAsync
(
attempt
,
ct
).
ConfigureAwait
(
false
);
await
RetryDelayAsync
(
attempt
,
ct
s
.
Token
).
ConfigureAwait
(
false
);
}
}
}
...
...
Tools/MyAgent/Agent/ToolSchema.cs
View file @
4c991d55
using
Newtonsoft.Json.Linq
;
using
System
;
using
Tools.MyAgent.Agent.Tools
;
namespace
Tools.MyAgent.Agent
...
...
@@ -12,13 +13,18 @@ namespace Tools.MyAgent.Agent
/// <summary>构建默认工具(核心能力)的 JSON Schema</summary>
public
static
JArray
Build
()
=>
Build
(
false
);
/// <summary>构建工具 JSON Schema;includeOptional=true 时包含高级/可选工具</summary>
public
static
JArray
Build
(
bool
includeOptional
)
/// <summary>
/// 构建工具 JSON Schema;includeOptional=true 时包含高级/可选工具;
/// allow(P3 工具级权限)非空时仅下发允许当前角色使用的工具。</summary>
public
static
JArray
Build
(
bool
includeOptional
,
Func
<
string
,
bool
>
allow
=
null
)
{
var
tools
=
includeOptional
?
ToolRegistry
.
All
:
ToolRegistry
.
DefaultTools
;
var
array
=
new
JArray
();
foreach
(
var
tool
in
tools
)
{
if
(
allow
!=
null
&&
!
allow
(
tool
.
Name
))
continue
;
// 权限不足的工具不下发(模型不可见)
array
.
Add
(
Function
(
tool
.
Name
,
tool
.
Description
,
tool
.
ParametersJson
));
}
return
array
;
}
...
...
Tools/MyAgent/Agent/Tools/DbQueryTool.cs
View file @
4c991d55
...
...
@@ -2,6 +2,7 @@
using
System
;
using
System.Data
;
using
System.IO
;
using
System.Linq
;
using
System.Text
;
using
System.Text.RegularExpressions
;
using
Tools.MyCommon
;
...
...
@@ -18,29 +19,38 @@ namespace Tools.MyAgent.Agent.Tools
{
public
string
Name
=>
"db_query"
;
public
string
Description
=>
"对业务数据库执行只读 SQL 查询(仅允许 SELECT 语句,禁止任何写操作),返回前若干行结果。用于用户要求查询/核对业务数据时。连接字符串由用户在对话中提供(sqlserver 示例:Server=主机;Database=库名;User Id=账号;Password=密码;Trusted_Connection=False;sqlite 可只填 .db 文件路径)。"
;
"对业务数据库执行只读 SQL 查询(仅允许 SELECT 语句,禁止任何写操作),返回前若干行结果。用于用户要求查询/核对业务数据时。"
+
"连接字符串两种提供方式:① connectionId 引用设置中预存的连接(推荐,密码不回显,如 sqlserver_erp);"
+
"② connection 直接提供连接串(sqlserver 示例:Server=主机;Database=库名;User Id=账号;Password=密码;Trusted_Connection=False;sqlite 可只填 .db 文件路径)。"
+
"优先使用 connectionId;连接串/密码一律不得在回答中回显。"
;
public
string
ParametersJson
=>
@"{'type':'object',
'properties':{
'type':{'type':'string','enum':['sqlserver','mysql','postgres','sqlite'],'description':'数据库类型'},
'connection':{'type':'string','description':"
+
ToolJson
.
Q
(
"连接字符串(用户提供);sqlite 可直接给数据库文件路径"
)
+
@"},
'connectionId':{'type':'string','description':"
+
ToolJson
.
Q
(
"预存连接 ID(设置中配置,如 sqlserver_erp);指定后无需再传 connection,连接串不回显"
)
+
@"},
'connection':{'type':'string','description':"
+
ToolJson
.
Q
(
"连接字符串(用户提供,仅当未指定 connectionId 时使用);sqlite 可直接给数据库文件路径"
)
+
@"},
'query':{'type':'string','description':"
+
ToolJson
.
Q
(
"只读 SELECT 查询语句(单条,不带分号)"
)
+
@"},
'maxRows':{'type':'integer','description':'最多返回行数,默认 50,最大 200','minimum':1,'maximum':200}
},
'required':['type','
connection','
query']}"
;
'required':['type','query']}"
;
public
bool
RequiresKb
=>
false
;
public
bool
IsDefault
=>
false
;
public
string
Describe
(
JObject
args
)
=>
$"查询数据库:
{
args
[
"type"
]?.
Value
<
string
>()}
"
;
public
string
Execute
(
JObject
args
,
ToolContext
ctx
)
{
string
type
=
(
args
[
"type"
]?.
Value
<
string
>()
??
""
).
Trim
().
ToLowerInvariant
();
string
connection
=
(
args
[
"connection"
]?.
Value
<
string
>()
??
""
).
Trim
();
string
query
=
args
[
"query"
]?.
Value
<
string
>()
??
""
;
int
maxRows
=
args
[
"maxRows"
]?.
Value
<
int
>()
??
50
;
if
(
maxRows
<
1
)
maxRows
=
1
;
if
(
maxRows
>
200
)
maxRows
=
200
;
string
error
=
Validate
(
type
,
connection
,
query
);
if
(
error
!=
null
)
return
error
;
// 连接串解析(P0 脱敏):connectionId 优先引用预存配置;两者都缺/无效时给出可用 ID 列表,不回显任何密码
string
connection
=
null
;
string
error
=
null
;
if
(!
ResolveConnection
(
args
,
ctx
,
ref
connection
,
ref
error
))
return
error
;
string
validateError
=
Validate
(
type
,
connection
,
query
);
if
(
validateError
!=
null
)
return
validateError
;
var
sw
=
System
.
Diagnostics
.
Stopwatch
.
StartNew
();
string
result
;
...
...
@@ -55,9 +65,11 @@ namespace Tools.MyAgent.Agent.Tools
AuditQuery
(
type
,
query
,
-
1
,
sw
.
ElapsedMilliseconds
,
ex
.
Message
);
// 业务字典(#22):查询报错(如列名/表名错误)时附带 schema 参考,帮助模型修正 SQL
string
dict
=
BusinessDictionaryHint
(
query
);
// 错误信息统一脱敏(数据库驱动异常可能包含连接串成分)
string
masked
=
MaskSensitive
(
ex
.
Message
);
if
(
dict
!=
null
)
return
$"错误:
{
ex
.
Message
}
\n\n
{
dict
}
"
;
return
$"错误:
{
ex
.
Message
}
"
;
return
$"错误:
{
masked
}
\n\n
{
dict
}
"
;
return
$"错误:
{
masked
}
"
;
}
sw
.
Stop
();
int
rows
=
ParseRowCount
(
result
);
...
...
@@ -65,6 +77,47 @@ namespace Tools.MyAgent.Agent.Tools
return
result
;
}
/// <summary>
/// 解析本次查询使用的连接串(P0 脱敏):优先 connectionId 引用预存配置;
/// 未指定/ID 不存在时回退 connection 参数;两者都缺失时返回可用 ID 列表(不含任何密码)。
/// </summary>
internal
static
bool
ResolveConnection
(
JObject
args
,
ToolContext
ctx
,
ref
string
connection
,
ref
string
error
)
{
error
=
null
;
string
id
=
(
args
?[
"connectionId"
]?.
Value
<
string
>()
??
""
).
Trim
();
string
inline
=
(
args
?[
"connection"
]?.
Value
<
string
>()
??
""
).
Trim
();
if
(
id
.
Length
>
0
)
{
if
(
ctx
?.
DbConnections
!=
null
&&
ctx
.
DbConnections
.
TryGetValue
(
id
,
out
string
stored
)
&&
!
string
.
IsNullOrEmpty
(
stored
))
{
connection
=
stored
;
return
true
;
}
error
=
$"错误:预存连接
{
id
}
不存在或为空。可用连接 ID:
{
ListConnectionIds
(
ctx
)}
"
;
return
false
;
}
if
(
inline
.
Length
>
0
)
{
connection
=
inline
;
return
true
;
}
error
=
$"错误:缺少连接信息。请使用 connectionId 引用预存连接(可用 ID:
{
ListConnectionIds
(
ctx
)}
),或直接提供 connection 连接字符串。"
;
return
false
;
}
/// <summary>可用预存连接 ID 列表(仅 ID,绝不包含连接串/密码)</summary>
internal
static
string
ListConnectionIds
(
ToolContext
ctx
)
{
if
(
ctx
?.
DbConnections
==
null
||
ctx
.
DbConnections
.
Count
==
0
)
return
"(未配置,请在设置中预存连接)"
;
return
string
.
Join
(
", "
,
ctx
.
DbConnections
.
Keys
.
OrderBy
(
k
=>
k
,
StringComparer
.
OrdinalIgnoreCase
));
}
// ── #25 审计 ──
private
static
readonly
object
AuditLock
=
new
object
();
...
...
@@ -98,7 +151,7 @@ namespace Tools.MyAgent.Agent.Tools
}
/// <summary>SQL/敏感信息脱敏(#41):连接串/密码/令牌等不回显</summary>
private
static
string
MaskSensitive
(
string
s
)
internal
static
string
MaskSensitive
(
string
s
)
{
if
(
string
.
IsNullOrEmpty
(
s
))
return
s
;
return
Regex
.
Replace
(
s
,
...
...
@@ -191,11 +244,15 @@ namespace Tools.MyAgent.Agent.Tools
private
static
readonly
Regex
SelectOnly
=
new
Regex
(
@"^\s*select\b"
,
RegexOptions
.
IgnoreCase
);
private
static
readonly
Regex
SelectInto
=
new
Regex
(
@"\bselect\b[^;]*\binto\b"
,
RegexOptions
.
IgnoreCase
);
/// <summary>字符串字面量(单引号/双引号,含 SQL 标准 '' 转义与 \' / \" 反斜杠转义)——关键字校验前剔除,避免字面量内数据词(如 name='update')误判为写操作</summary>
private
static
readonly
Regex
StringLiteral
=
new
Regex
(
@"'(?:[^'\\]|\\'|'')*'|""(?:[^""\\]|\\""|"""")*"""
,
RegexOptions
.
Compiled
);
private
static
readonly
Regex
DangerousKeyword
=
new
Regex
(
@"\b(insert|update|delete|drop|alter|create|truncate|replace|merge|exec|execute|grant|revoke|attach|detach|vacuum|reindex|call|begin|commit|rollback|savepoint|release|pragma|load_extension|copy)\b"
,
RegexOptions
.
IgnoreCase
);
private
static
string
Validate
(
string
type
,
string
connection
,
string
query
)
internal
static
string
Validate
(
string
type
,
string
connection
,
string
query
)
{
if
(
string
.
IsNullOrEmpty
(
type
))
return
"错误:缺少 type 参数(sqlserver/mysql/postgres/sqlite)。"
;
...
...
@@ -211,9 +268,11 @@ namespace Tools.MyAgent.Agent.Tools
return
"错误:仅允许单条查询,不支持分号分隔的多语句。"
;
if
(
query
.
Contains
(
"--"
)
||
query
.
Contains
(
"/*"
)
||
query
.
Contains
(
"*/"
))
return
"错误:查询中不允许包含注释(-- 或 /* */)。"
;
if
(
SelectInto
.
IsMatch
(
query
))
// 剔除字符串字面量后再做关键字检查(字面量内出现 update/delete 等词是数据,不是写操作)
string
stripped
=
StringLiteral
.
Replace
(
query
,
"''"
);
if
(
SelectInto
.
IsMatch
(
stripped
))
return
"错误:不允许 SELECT INTO(会创建表)。"
;
if
(
DangerousKeyword
.
IsMatch
(
query
))
if
(
DangerousKeyword
.
IsMatch
(
stripped
))
return
"错误:查询中包含不允许的写操作/管理关键字(仅允许只读 SELECT)。"
;
return
null
;
}
...
...
Tools/MyAgent/Agent/Tools/ITool.cs
View file @
4c991d55
...
...
@@ -2,12 +2,13 @@
using
System
;
using
System.Collections.Generic
;
using
System.Linq
;
using
System.Threading
;
using
Tools.MyAgent.Kb
;
namespace
Tools.MyAgent.Agent.Tools
{
/// <summary>
/// 工具执行上下文:知识库服务(沙箱校验)
与产出文件回调
/// 工具执行上下文:知识库服务(沙箱校验)
、产出文件回调、取消令牌与预存数据库连接串(P0 脱敏)
/// </summary>
public
sealed
class
ToolContext
{
...
...
@@ -17,10 +18,19 @@ namespace Tools.MyAgent.Agent.Tools
/// <summary>通知引擎智能体已产出文件(绝对路径),用于回答末尾展示可点击链接</summary>
public
Action
<
string
>
OnFileProduced
{
get
;
}
/// <summary>
/// 取消令牌(P1 扫描可中断):引擎在单工具超时/用户取消时触发;
/// 工具应在长循环(全盘扫描等)中检查并尽早终止。</summary>
public
CancellationToken
CancelToken
{
get
;
set
;
}
/// <summary>预存数据库连接串(ID → 连接串,已解密明文;来自 AgentConfig.DbConnections)</summary>
public
IReadOnlyDictionary
<
string
,
string
>
DbConnections
{
get
;
set
;
}
public
ToolContext
(
KbService
kb
,
Action
<
string
>
onFileProduced
)
{
Kb
=
kb
;
OnFileProduced
=
onFileProduced
;
CancelToken
=
CancellationToken
.
None
;
}
/// <summary>沙箱校验解析失败时回退为原路径</summary>
...
...
@@ -31,6 +41,55 @@ namespace Tools.MyAgent.Agent.Tools
}
}
/// <summary>
/// 工具级权限(P3):工具名 → 允许角色列表;空规则 = 全部可用;值空串/"*" = 不限制。
/// <para>纯逻辑、无状态,独立可测;当前角色取登录用户名(LoginInfo.UserName),
/// 规则匹配不分大小写,支持逗号/分号/顿号分隔多个角色。</para>
/// </summary>
public
static
class
ToolPermission
{
/// <summary>规则分隔符(逗号/中文逗号/分号/中文分号/顿号/竖线/空格)</summary>
private
static
readonly
char
[]
RuleSeparators
=
{
','
,
','
,
';'
,
';'
,
'、'
,
'|'
,
' '
};
/// <summary>
/// 判断指定角色是否允许使用某工具。
/// <para>allowMap 为 null/空,或未包含该工具名 → 允许(保持现状,不设默认拒绝);
/// 规则值为空串或 "*" → 允许;否则按分隔符拆分后与角色逐项比较(大小写不敏感)。
/// 工具名查找大小写不敏感(配置文件字典可能为默认比较器,避免 DB_QUERY 与 db_query 不匹配导致白名单静默失效)。</para>
/// </summary>
public
static
bool
IsAllowed
(
string
toolName
,
string
role
,
IReadOnlyDictionary
<
string
,
string
>
allowMap
)
{
if
(
string
.
IsNullOrEmpty
(
toolName
))
return
false
;
if
(
allowMap
==
null
||
allowMap
.
Count
==
0
)
return
true
;
// 大小写不敏感查找规则(不依赖字典比较器)
string
rule
=
null
;
bool
found
=
false
;
foreach
(
var
kv
in
allowMap
)
{
if
(
string
.
Equals
(
kv
.
Key
,
toolName
,
StringComparison
.
OrdinalIgnoreCase
))
{
rule
=
kv
.
Value
;
found
=
true
;
break
;
}
}
if
(!
found
)
return
true
;
if
(
string
.
IsNullOrWhiteSpace
(
rule
))
return
true
;
rule
=
rule
.
Trim
();
if
(
rule
==
"*"
)
return
true
;
var
roles
=
rule
.
Split
(
RuleSeparators
,
StringSplitOptions
.
RemoveEmptyEntries
);
if
(
roles
.
Length
==
0
)
return
true
;
if
(
string
.
IsNullOrEmpty
(
role
))
return
false
;
string
current
=
role
.
Trim
();
foreach
(
var
r
in
roles
)
if
(
string
.
Equals
(
r
.
Trim
(),
current
,
StringComparison
.
OrdinalIgnoreCase
))
return
true
;
return
false
;
}
}
/// <summary>
/// 可插拔工具接口:名称 / 描述 / 参数 Schema / 沙箱校验执行。
/// <para>注册表驱动下发(ToolSchema)与执行(AgentEngine),新增工具无需改动引擎。</para>
...
...
Tools/MyAgent/Agent/Tools/ImageSearchTool.cs
View file @
4c991d55
...
...
@@ -23,7 +23,7 @@ namespace Tools.MyAgent.Agent.Tools
public
string
Describe
(
JObject
args
)
=>
$"图像检索:
{
args
[
"reference"
]?.
Value
<
string
>()}
"
;
public
string
Execute
(
JObject
args
,
ToolContext
ctx
)
{
return
ctx
.
Kb
.
ImageSearch
(
args
[
"reference"
]?.
Value
<
string
>()
??
""
,
args
[
"maxResults"
]?.
Value
<
int
>()
??
10
);
return
ctx
.
Kb
.
ImageSearch
(
args
[
"reference"
]?.
Value
<
string
>()
??
""
,
args
[
"maxResults"
]?.
Value
<
int
>()
??
10
,
ctx
.
CancelToken
);
}
}
...
...
Tools/MyAgent/Agent/Tools/KbTools.cs
View file @
4c991d55
...
...
@@ -20,7 +20,7 @@ namespace Tools.MyAgent.Agent.Tools
public
string
Describe
(
JObject
args
)
=>
$"按文件名搜索:
{
args
[
"pattern"
]?.
Value
<
string
>()}
"
;
public
string
Execute
(
JObject
args
,
ToolContext
ctx
)
{
return
ctx
.
Kb
.
Find
(
args
[
"pattern"
]?.
Value
<
string
>()
??
""
,
args
[
"maxResults"
]?.
Value
<
int
>()
??
30
);
return
ctx
.
Kb
.
Find
(
args
[
"pattern"
]?.
Value
<
string
>()
??
""
,
args
[
"maxResults"
]?.
Value
<
int
>()
??
30
,
ctx
.
CancelToken
);
}
}
...
...
@@ -41,7 +41,7 @@ namespace Tools.MyAgent.Agent.Tools
public
string
Describe
(
JObject
args
)
=>
$"内容检索:
{
args
[
"query"
]?.
Value
<
string
>()}
"
;
public
string
Execute
(
JObject
args
,
ToolContext
ctx
)
{
return
ctx
.
Kb
.
Grep
(
args
[
"query"
]?.
Value
<
string
>()
??
""
,
args
[
"glob"
]?.
Value
<
string
>(),
args
[
"maxResults"
]?.
Value
<
int
>()
??
30
);
return
ctx
.
Kb
.
Grep
(
args
[
"query"
]?.
Value
<
string
>()
??
""
,
args
[
"glob"
]?.
Value
<
string
>(),
args
[
"maxResults"
]?.
Value
<
int
>()
??
30
,
ctx
.
CancelToken
);
}
}
...
...
@@ -60,7 +60,7 @@ namespace Tools.MyAgent.Agent.Tools
public
string
Describe
(
JObject
args
)
=>
$"浏览目录:
{
args
[
"path"
]?.
Value
<
string
>()
??
"."
}
"
;
public
string
Execute
(
JObject
args
,
ToolContext
ctx
)
{
return
ctx
.
Kb
.
List
(
args
[
"path"
]?.
Value
<
string
>()
??
"."
,
args
[
"recursive"
]?.
Value
<
bool
>()
??
false
);
return
ctx
.
Kb
.
List
(
args
[
"path"
]?.
Value
<
string
>()
??
"."
,
args
[
"recursive"
]?.
Value
<
bool
>()
??
false
,
ctx
.
CancelToken
);
}
}
...
...
Tools/MyAgent/Config/AgentConfig.cs
View file @
4c991d55
...
...
@@ -107,5 +107,22 @@
/// <summary>费用估算价格覆盖(R6):模型名(子串匹配)→ "输入价,输出价"(元/百万 token);留空/缺省用内置价格表</summary>
public
Dictionary
<
string
,
string
>
CustomPrices
{
get
;
set
;
}
=
new
Dictionary
<
string
,
string
>();
// ════════════════ 高级项:数据库连接预存(P0 连接串脱敏) ════════════════
/// <summary>
/// 预存数据库连接串(ID → 连接串;sqlite 可填 .db 文件路径)。
/// <para>落盘时经 DPAPI 加密(见 SecretProtector),内存中为明文;db_query 工具按 ID 引用,
/// 聊天中不回显连接串/密码。</para>
/// </summary>
public
Dictionary
<
string
,
string
>
DbConnections
{
get
;
set
;
}
=
new
Dictionary
<
string
,
string
>();
// ════════════════ 高级项:工具级权限(P3,按角色) ════════════════
/// <summary>
/// 工具级权限:工具名 → 允许使用该工具的角色列表(逗号/分号分隔,如 "db_query" = "管理员,主管")。
/// <para>空字典/缺省 = 所有登录用户可用(保持现状);值为空串或 "*" = 不限制。</para>
/// </summary>
public
Dictionary
<
string
,
string
>
ToolRoleAllow
{
get
;
set
;
}
=
new
Dictionary
<
string
,
string
>();
}
}
Tools/MyAgent/Forms/AgentSettingsForm.cs
View file @
4c991d55
...
...
@@ -82,11 +82,123 @@ namespace Tools.MyAgent.Forms
_config
=
config
??
new
AgentConfig
();
InitializeComponent
();
BuildDbConnectionsTab
();
// P0:预存数据库连接(db_query 按 ID 引用,聊天不回显密码)
BuildToolPermissionUi
();
// P3:工具级权限(按角色)输入区
BindConfig
();
// 配置值回填控件(控件创建见 Designer.cs)
ApplyProviderMode
();
// 布局联动:BaseUrl 行显隐
ApplyKbMode
();
// 布局联动:共享 账号/密码/测试连接 行显隐
}
// ══ P0:预存数据库连接选项卡(程序化创建,避免侵入设计器) ══
private
DataGridView
dgvDbConn
;
/// <summary>
/// 新增「数据库连接」选项卡:ID / 连接串 两列表格,供 db_query 按 ID 引用。
/// <para>连接串仅存内存明文,落盘经 DPAPI 加密(SaveAgentConfig);聊天中只回显 ID。</para>
/// </summary>
private
void
BuildDbConnectionsTab
()
{
var
tabDb
=
new
TabPage
{
Text
=
"数据库连接"
,
Padding
=
new
Padding
(
3
)
};
var
grp
=
new
GroupBox
{
Text
=
"预存连接(db_query 按 ID 引用)"
,
Location
=
new
Point
(
8
,
12
),
Size
=
new
Size
(
596
,
300
),
};
dgvDbConn
=
new
DataGridView
{
Location
=
new
Point
(
16
,
32
),
Size
=
new
Size
(
564
,
220
),
AllowUserToAddRows
=
true
,
AllowUserToDeleteRows
=
true
,
RowHeadersVisible
=
false
,
BackgroundColor
=
Color
.
White
,
BorderStyle
=
BorderStyle
.
FixedSingle
,
AutoSizeColumnsMode
=
DataGridViewAutoSizeColumnsMode
.
None
,
SelectionMode
=
DataGridViewSelectionMode
.
CellSelect
,
EditMode
=
DataGridViewEditMode
.
EditOnKeystrokeOrF2
,
};
dgvDbConn
.
Columns
.
Add
(
new
DataGridViewTextBoxColumn
{
HeaderText
=
"ID(引用名)"
,
Width
=
160
,
SortMode
=
DataGridViewColumnSortMode
.
NotSortable
});
dgvDbConn
.
Columns
.
Add
(
new
DataGridViewTextBoxColumn
{
HeaderText
=
"连接字符串 / sqlite 文件路径"
,
Width
=
396
,
SortMode
=
DataGridViewColumnSortMode
.
NotSortable
});
var
lblHint
=
new
Label
{
Text
=
"连接串将加密保存,聊天中仅按 ID 引用、不回显密码;sqlite 可只填 .db 文件路径(自动只读连接)。示例 ID:sqlserver_erp、mysql_local。"
,
Location
=
new
Point
(
16
,
258
),
AutoSize
=
false
,
Size
=
new
Size
(
564
,
36
),
ForeColor
=
Color
.
Gray
,
};
grp
.
Controls
.
Add
(
dgvDbConn
);
grp
.
Controls
.
Add
(
lblHint
);
tabDb
.
Controls
.
Add
(
grp
);
tabMain
.
TabPages
.
Add
(
tabDb
);
}
// ══ P3:工具级权限(按角色) ══
private
TextBox
txtToolRoles
;
/// <summary>在「其他」页可选能力组追加工具权限输入区(角色白名单,一行一规则)</summary>
private
void
BuildToolPermissionUi
()
{
var
lbl
=
new
Label
{
Text
=
"工具权限(按角色)"
,
Location
=
new
Point
(
16
,
86
),
AutoSize
=
true
,
};
txtToolRoles
=
new
TextBox
{
Location
=
new
Point
(
16
,
108
),
Size
=
new
Size
(
564
,
62
),
Multiline
=
true
,
AcceptsReturn
=
true
,
ScrollBars
=
ScrollBars
.
Vertical
,
Font
=
new
Font
(
"Consolas"
,
9F
),
};
var
hint
=
new
Label
{
Text
=
"格式:工具名=允许角色(逗号分隔),一行一个;留空 = 所有用户可用。当前角色 = 登录用户名。\r\n示例:db_query=管理员,主管;module_patch=管理员"
,
Location
=
new
Point
(
16
,
176
),
AutoSize
=
false
,
Size
=
new
Size
(
564
,
34
),
ForeColor
=
Color
.
Gray
,
};
grpOpt
.
Controls
.
Add
(
lbl
);
grpOpt
.
Controls
.
Add
(
txtToolRoles
);
grpOpt
.
Controls
.
Add
(
hint
);
grpOpt
.
Height
=
216
;
}
/// <summary>角色规则字典 → 编辑文本(排序保证稳定回显)</summary>
private
static
string
[]
ToolRoleRulesToLines
(
Dictionary
<
string
,
string
>
rules
)
{
if
(
rules
==
null
||
rules
.
Count
==
0
)
return
new
string
[
0
];
return
rules
.
OrderBy
(
kv
=>
kv
.
Key
,
StringComparer
.
OrdinalIgnoreCase
)
.
Select
(
kv
=>
$"
{
kv
.
Key
}
=
{
kv
.
Value
}
"
)
.
ToArray
();
}
/// <summary>编辑文本 → 角色规则字典(忽略空行/非法行)</summary>
private
static
Dictionary
<
string
,
string
>
ParseToolRoleRules
(
string
text
)
{
var
rules
=
new
Dictionary
<
string
,
string
>(
StringComparer
.
OrdinalIgnoreCase
);
if
(
string
.
IsNullOrWhiteSpace
(
text
))
return
rules
;
foreach
(
var
raw
in
text
.
Replace
(
"\r\n"
,
"\n"
).
Replace
(
'\r'
,
'\n'
).
Split
(
'\n'
))
{
string
line
=
(
raw
??
""
).
Trim
();
if
(
line
.
Length
==
0
||
line
.
StartsWith
(
"#"
))
continue
;
int
eq
=
line
.
IndexOf
(
'='
);
string
tool
=
(
eq
>
0
?
line
.
Substring
(
0
,
eq
)
:
line
).
Trim
();
string
roles
=
eq
>
0
?
line
.
Substring
(
eq
+
1
).
Trim
()
:
""
;
if
(
tool
.
Length
>
0
)
rules
[
tool
]
=
roles
;
}
return
rules
;
}
// ══ 配置绑定:把 AgentConfig 值回填到控件(设计器仅设默认值) ══
private
void
BindConfig
()
...
...
@@ -143,6 +255,15 @@ namespace Tools.MyAgent.Forms
chkOpt
.
Checked
=
_config
.
EnableOptionalTools
;
chkImageInput
.
Checked
=
_config
.
EnableImageInput
;
// v2.2 多模态:图片提问开关
// 其他:工具级权限(P3,角色白名单)
txtToolRoles
.
Lines
=
ToolRoleRulesToLines
(
_config
.
ToolRoleAllow
);
// 数据库连接(P0):预存连接串(内存明文态)回填表格
dgvDbConn
.
Rows
.
Clear
();
if
(
_config
.
DbConnections
!=
null
)
foreach
(
var
kv
in
_config
.
DbConnections
.
OrderBy
(
k
=>
k
.
Key
,
StringComparer
.
OrdinalIgnoreCase
))
dgvDbConn
.
Rows
.
Add
(
kv
.
Key
,
kv
.
Value
);
// 其他:故障转移
chkFail
.
Checked
=
_config
.
EnableFailover
;
txtBUrl
.
Text
=
_config
.
BackupBaseUrl
;
...
...
@@ -444,6 +565,18 @@ namespace Tools.MyAgent.Forms
// 其他:可选能力
_config
.
EnableOptionalTools
=
chkOpt
.
Checked
;
_config
.
EnableImageInput
=
chkImageInput
.
Checked
;
// v2.2 多模态:图片提问开关
// 其他:工具级权限(P3)
_config
.
ToolRoleAllow
=
ParseToolRoleRules
(
txtToolRoles
.
Text
);
// 数据库连接(P0):表格 → 字典(ID 大小写不敏感,空格清理;空 ID 行忽略)
var
conns
=
new
Dictionary
<
string
,
string
>(
StringComparer
.
OrdinalIgnoreCase
);
foreach
(
DataGridViewRow
r
in
dgvDbConn
.
Rows
)
{
if
(
r
.
IsNewRow
)
continue
;
string
id
=
(
r
.
Cells
[
0
].
Value
?.
ToString
()
??
""
).
Trim
();
if
(
id
.
Length
==
0
)
continue
;
conns
[
id
]
=
(
r
.
Cells
[
1
].
Value
?.
ToString
()
??
""
).
Trim
();
}
_config
.
DbConnections
=
conns
;
// 其他:故障转移
_config
.
EnableFailover
=
chkFail
.
Checked
;
_config
.
BackupBaseUrl
=
txtBUrl
.
Text
.
Trim
();
...
...
Tools/MyAgent/Kb/EmbeddingClient.cs
View file @
4c991d55
...
...
@@ -24,17 +24,37 @@ namespace Tools.MyAgent.Kb
public
string
ApiKey
{
get
;
set
;
}
=
""
;
public
bool
Enabled
{
get
;
set
;
}
/// <summary>是否 Ollama 端点(按 BaseUrl 判断;Ollama 走 /api/embed 批量接口)</summary>
public
bool
IsOllama
=>
BaseUrl
.
IndexOf
(
"localhost"
,
StringComparison
.
OrdinalIgnoreCase
)
>=
0
||
BaseUrl
.
IndexOf
(
"127.0.0.1"
,
StringComparison
.
OrdinalIgnoreCase
)
>=
0
||
BaseUrl
.
IndexOf
(
"11434"
,
StringComparison
.
OrdinalIgnoreCase
)
>=
0
;
/// <summary>
/// 是否 Ollama 端点(按 BaseUrl 判断;Ollama 走 /api/embed 批量接口)。
/// <para>修复:以 /v1 结尾的地址(Ollama 的 OpenAI 兼容端点,如 http://localhost:11434/v1)
/// 应走 /embeddings 而非 /api/embed,否则请求 …/v1/api/embed 返回 404 导致语义检索静默降级。</para>
/// </summary>
public
bool
IsOllama
{
get
{
var
url
=
BaseUrl
??
""
;
if
(
url
.
TrimEnd
(
'/'
).
EndsWith
(
"/v1"
,
StringComparison
.
OrdinalIgnoreCase
))
return
false
;
// OpenAI 兼容端点(含 Ollama 的 /v1 别名)
return
url
.
IndexOf
(
"localhost"
,
StringComparison
.
OrdinalIgnoreCase
)
>=
0
||
url
.
IndexOf
(
"127.0.0.1"
,
StringComparison
.
OrdinalIgnoreCase
)
>=
0
||
url
.
IndexOf
(
"11434"
,
StringComparison
.
OrdinalIgnoreCase
)
>=
0
;
}
}
/// <summary>服务可用性探测结果(避免对不可用服务反复请求)</summary>
private
volatile
bool
_probeFailed
;
private
DateTime
_probeFailedUntil
=
DateTime
.
MinValue
;
private
static
readonly
TimeSpan
ProbeCooldown
=
TimeSpan
.
FromSeconds
(
60
);
/// <summary>
/// 索引节流(P2):单批向量化文本条数上限(默认 32,避免建索引期单请求过大打满 Ollama);
/// 超出部分拆分为多批串行发送,批间留出 <see cref="BatchDelayMs"/> 间隔。</summary>
public
int
BatchSize
{
get
;
set
;
}
=
32
;
/// <summary>索引节流(P2):批次间间隔毫秒(默认 80,给本地 Ollama 留出推理间隙)</summary>
public
int
BatchDelayMs
{
get
;
set
;
}
=
80
;
public
EmbeddingClient
()
{
_http
=
new
HttpClient
...
...
@@ -51,24 +71,44 @@ namespace Tools.MyAgent.Kb
Enabled
=
enabled
;
}
/// <summary>批量计算文本向量;服务不可用/失败返回 null(保持降级可用)</summary>
/// <summary>
/// 批量计算文本向量(P2 索引节流):按 BatchSize 拆分为多批串行请求,批间延迟 BatchDelayMs;
/// 服务不可用/失败返回 null(保持降级可用)。
/// </summary>
public
async
Task
<
List
<
float
[
]>
>
EmbedManyAsync
(
List
<
string
>
texts
,
CancellationToken
ct
)
{
if
(!
Enabled
||
texts
==
null
||
texts
.
Count
==
0
)
return
null
;
if
(
_probeFailed
&&
DateTime
.
Now
<
_probeFailedUntil
)
return
null
;
try
{
return
IsOllama
?
await
EmbedOllamaAsync
(
texts
,
ct
).
ConfigureAwait
(
false
)
:
await
EmbedOpenAiAsync
(
texts
,
ct
).
ConfigureAwait
(
false
);
}
catch
(
Exception
)
int
batchSize
=
BatchSize
>
0
?
BatchSize
:
32
;
var
result
=
new
List
<
float
[
]>
(
texts
.
Count
);
for
(
int
start
=
0
;
start
<
texts
.
Count
;
start
+=
batchSize
)
{
// 不可用:冷却期内不再尝试,返回 null 触发降级
_probeFailed
=
true
;
_probeFailedUntil
=
DateTime
.
Now
.
Add
(
ProbeCooldown
);
return
null
;
ct
.
ThrowIfCancellationRequested
();
int
count
=
Math
.
Min
(
batchSize
,
texts
.
Count
-
start
);
var
chunk
=
texts
.
GetRange
(
start
,
count
);
List
<
float
[
]>
batch
;
try
{
batch
=
IsOllama
?
await
EmbedOllamaAsync
(
chunk
,
ct
).
ConfigureAwait
(
false
)
:
await
EmbedOpenAiAsync
(
chunk
,
ct
).
ConfigureAwait
(
false
);
}
catch
(
Exception
)
{
// 不可用:冷却期内不再尝试,返回 null 触发降级
_probeFailed
=
true
;
_probeFailedUntil
=
DateTime
.
Now
.
Add
(
ProbeCooldown
);
return
null
;
}
if
(
batch
==
null
||
batch
.
Count
!=
count
)
return
null
;
// 返回数量不齐视为失败(保持降级语义)
result
.
AddRange
(
batch
);
// 批间节流:避免建索引期连续请求打满本地 embedding 服务(取消时由 Task.Delay 抛出 OCE 向上传播)
if
(
start
+
batchSize
<
texts
.
Count
)
await
Task
.
Delay
(
BatchDelayMs
,
ct
).
ConfigureAwait
(
false
);
}
return
result
;
}
/// <summary>单个文本向量(查询用)</summary>
...
...
Tools/MyAgent/Kb/KbIndex.cs
View file @
4c991d55
...
...
@@ -24,7 +24,8 @@ namespace Tools.MyAgent.Kb
private
readonly
object
_lock
=
new
object
();
private
SQLiteConnection
_conn
;
private
Timer
_sweepTimer
;
private
readonly
EmbeddingClient
_emb
;
/// <summary>embedding 客户端(可被 KbService 在语义检索配置变更时替换;引用赋值原子,后台线程读取安全)</summary>
private
EmbeddingClient
_emb
;
/// <summary>事件驱动增量更新(P1):单文件变更队列 + 去抖后台处理,避免每次变更全量枚举</summary>
private
readonly
System
.
Collections
.
Concurrent
.
ConcurrentQueue
<
string
>
_pending
=
...
...
@@ -67,6 +68,15 @@ namespace Tools.MyAgent.Kb
_dbPath
=
Path
.
Combine
(
indexDir
,
"kb_"
+
StableHash
(
_root
)
+
".db"
);
}
/// <summary>
/// 替换 embedding 客户端引用(KbService 语义检索配置变更时调用;null = 禁用向量化)。
/// <para>修复:索引构造时保存的引用在配置禁用/重启用后失效,导致按旧地址/旧模型向量化。</para>
/// </summary>
public
void
SetEmbeddingClient
(
EmbeddingClient
emb
)
{
_emb
=
emb
;
}
/// <summary>启动:打开/建库 + 后台构建(幂等)</summary>
public
void
Start
()
{
...
...
@@ -548,6 +558,8 @@ namespace Tools.MyAgent.Kb
if
(!
string
.
IsNullOrEmpty
(
content
))
{
string
embedText
=
content
.
Length
<=
MaxEmbedChars
?
content
:
content
.
Substring
(
0
,
MaxEmbedChars
);
// 向量化节流由 EmbeddingClient 内部分批 + 批间延迟实现(P2),此处不截断,
// 避免“截断后因 mtime/size 相同被跳过而永不补齐向量”的缺陷
pendingEmbed
.
Add
((
rel
,
embedText
));
}
processed
++;
...
...
Tools/MyAgent/Kb/KbService.cs
View file @
4c991d55
...
...
@@ -58,10 +58,13 @@ namespace Tools.MyAgent.Kb
if
(!
enabled
)
{
_emb
=
null
;
_index
?.
SetEmbeddingClient
(
null
);
// 索引同步禁用向量化(修复:禁用后索引仍持旧实例继续向量化)
return
;
}
if
(
_emb
==
null
)
_emb
=
new
EmbeddingClient
();
_emb
.
Configure
(
baseUrl
,
model
,
apiKey
,
enabled
);
// 修复:索引持有的是构造时传入的引用;重新启用后若不替换,索引会继续用旧地址/旧模型向量化
_index
?.
SetEmbeddingClient
(
_emb
);
}
/// <summary>知识库近期新增/修改的文件(相对路径列表,7 天内,按时间倒序)</summary>
...
...
@@ -148,6 +151,8 @@ namespace Tools.MyAgent.Kb
EnsureWatcher
();
var
root
=
Root
;
var
searchRoot
=
SearchRoot
;
// P1 扫描可中断:统计扫描带超时取消(60 秒),超时后真正终止后台枚举,避免残留任务占满 IO
var
statsCts
=
new
CancellationTokenSource
(
TimeSpan
.
FromSeconds
(
60
));
Task
.
Run
(()
=>
{
int
count
=
0
;
...
...
@@ -156,7 +161,7 @@ namespace Tools.MyAgent.Kb
var
groups
=
new
Dictionary
<
string
,
int
>(
StringComparer
.
OrdinalIgnoreCase
);
try
{
foreach
(
var
file
in
SafeEnumerateFiles
(
root
))
foreach
(
var
file
in
SafeEnumerateFiles
(
root
,
statsCts
.
Token
))
{
count
++;
var
ext
=
Path
.
GetExtension
(
file
).
ToLowerInvariant
();
...
...
@@ -169,6 +174,7 @@ namespace Tools.MyAgent.Kb
.
Take
(
6
)
.
Select
(
kv
=>
$"
{
kv
.
Key
}
(
{
kv
.
Value
}
)"
));
}
catch
(
OperationCanceledException
)
{
count
=
FileCountUnknown
;
}
catch
{
count
=
FileCountUnknown
;
}
try
{
digest
=
BuildStructureDigest
(
searchRoot
);
}
catch
{
digest
=
""
;
}
_fileCount
=
count
;
...
...
@@ -176,6 +182,7 @@ namespace Tools.MyAgent.Kb
_structureDigest
=
digest
;
_fileCountStamp
=
DateTime
.
Now
;
lock
(
_statsLock
)
{
_statsRunning
=
false
;
}
try
{
statsCts
.
Dispose
();
}
catch
{
}
StatsRefreshed
?.
Invoke
();
});
}
...
...
@@ -525,7 +532,7 @@ namespace Tools.MyAgent.Kb
return
abs
;
}
private
static
string
ResolveIn
(
string
root
,
string
relPath
,
string
invalidMsg
)
internal
static
string
ResolveIn
(
string
root
,
string
relPath
,
string
invalidMsg
)
{
if
(
string
.
IsNullOrEmpty
(
root
)
||
!
Directory
.
Exists
(
root
))
throw
new
InvalidOperationException
(
invalidMsg
);
...
...
@@ -546,7 +553,7 @@ namespace Tools.MyAgent.Kb
}
/// <summary>路径归一化(去掉结尾分隔符;盘符根目录补回分隔符)</summary>
private
static
string
NormPath
(
string
path
)
internal
static
string
NormPath
(
string
path
)
{
var
s
=
Path
.
GetFullPath
(
path
).
TrimEnd
(
'\\'
,
'/'
);
if
(
s
.
Length
==
2
&&
s
[
1
]
==
':'
)
...
...
@@ -555,7 +562,7 @@ namespace Tools.MyAgent.Kb
}
/// <summary>判断 target 是否位于 rootNorm 之内(含相等)</summary>
private
static
bool
IsWithin
(
string
rootNorm
,
string
target
)
internal
static
bool
IsWithin
(
string
rootNorm
,
string
target
)
{
return
target
.
Equals
(
rootNorm
,
StringComparison
.
OrdinalIgnoreCase
)
||
target
.
StartsWith
(
rootNorm
+
"\\"
,
StringComparison
.
OrdinalIgnoreCase
);
...
...
@@ -564,7 +571,7 @@ namespace Tools.MyAgent.Kb
/// <summary>
/// 检测从根目录到目标路径之间的任一目录/文件是否为 reparse point(junction/符号链接)
/// </summary>
private
static
bool
HasReparsePointOnPath
(
string
root
,
string
target
)
internal
static
bool
HasReparsePointOnPath
(
string
root
,
string
target
)
{
if
(
target
.
Length
<=
root
.
Length
)
return
false
;
string
rel
=
target
.
Substring
(
root
.
Length
).
TrimStart
(
'\\'
,
'/'
);
...
...
@@ -591,25 +598,35 @@ namespace Tools.MyAgent.Kb
/// 递归统计知识库内文件总数(同步全量枚举,供一次性操作使用;
/// 高频 UI 路径请用 EnsureStats + FileCount 的缓存版本)。
/// </summary>
public
int
CountFiles
()
public
int
CountFiles
(
CancellationToken
ct
=
default
)
{
if
(!
IsValid
)
return
0
;
try
{
return
Directory
.
EnumerateFiles
(
Root
,
"*"
,
SearchOption
.
AllDirectories
).
Count
();
}
try
{
int
count
=
0
;
foreach
(
var
_
in
SafeEnumerateFiles
(
Root
,
ct
))
count
++;
return
count
;
}
catch
(
OperationCanceledException
)
{
return
0
;
}
catch
{
return
0
;
}
}
/// <summary>统计主要文件格式(扩展名 -> 数量,按数量降序)</summary>
public
string
GetFormatsSummary
(
int
top
=
6
)
public
string
GetFormatsSummary
(
int
top
=
6
,
CancellationToken
ct
=
default
)
{
if
(!
IsValid
)
return
""
;
var
groups
=
new
Dictionary
<
string
,
int
>(
StringComparer
.
OrdinalIgnoreCase
);
foreach
(
var
file
in
SafeEnumerateFiles
(
Root
))
try
{
var
ext
=
Path
.
GetExtension
(
file
).
ToLowerInvariant
();
if
(
string
.
IsNullOrEmpty
(
ext
))
ext
=
"(无扩展名)"
;
groups
.
TryGetValue
(
ext
,
out
var
count
);
groups
[
ext
]
=
count
+
1
;
foreach
(
var
file
in
SafeEnumerateFiles
(
Root
,
ct
))
{
var
ext
=
Path
.
GetExtension
(
file
).
ToLowerInvariant
();
if
(
string
.
IsNullOrEmpty
(
ext
))
ext
=
"(无扩展名)"
;
groups
.
TryGetValue
(
ext
,
out
var
count
);
groups
[
ext
]
=
count
+
1
;
}
}
catch
(
OperationCanceledException
)
{
return
""
;
}
return
string
.
Join
(
", "
,
groups
.
OrderByDescending
(
kv
=>
kv
.
Value
)
.
Take
(
top
)
...
...
@@ -638,7 +655,12 @@ namespace Tools.MyAgent.Kb
try
{
files
=
Directory
.
GetFiles
(
dir
);
}
catch
{
files
=
new
string
[
0
];
}
foreach
(
var
file
in
files
)
{
// 修复:文件级 reparse point(符号链接文件)同样跳过,防止全盘扫描/索引读取到知识库外部内容
// (kb_read/kb_write 的 ResolveIn 已校验最终文件,但 Grep/Find/索引的枚举路径此前未检查)
if
(
IsReparsePoint
(
file
))
continue
;
yield
return
file
;
}
}
}
...
...
@@ -685,7 +707,7 @@ namespace Tools.MyAgent.Kb
#
region
kb_list
——
浏览目录结构
/// <summary>列出目录内容(供模型使用;受搜索范围限定)</summary>
public
string
List
(
string
relPath
,
bool
recursive
)
public
string
List
(
string
relPath
,
bool
recursive
,
CancellationToken
ct
=
default
)
{
try
{
...
...
@@ -698,8 +720,8 @@ namespace Tools.MyAgent.Kb
if
(
recursive
)
{
var
subDirs
=
SafeEnumerateDirs
(
dir
).
ToList
();
var
subFiles
=
SafeEnumerateFiles
(
dir
).
ToList
();
var
subDirs
=
SafeEnumerateDirs
(
dir
,
ct
).
ToList
();
var
subFiles
=
SafeEnumerateFiles
(
dir
,
ct
).
ToList
();
sb
.
Append
(
$"目录
{
display
}
/ 递归统计:
{
subFiles
.
Count
}
个文件、
{
subDirs
.
Count
}
个子目录"
);
foreach
(
var
d
in
subDirs
)
sb
.
Append
(
$"\n[目录]
{
ToRelative
(
PathBase
,
d
).
Replace
(
'\\'
,
'/'
)}
/"
);
...
...
@@ -736,6 +758,10 @@ namespace Tools.MyAgent.Kb
return
Truncate
(
sb
.
ToString
());
}
catch
(
OperationCanceledException
)
{
throw
;
// 取消/超时向上传播,由工具层返回「已取消/超时」
}
catch
(
Exception
ex
)
{
return
$"错误:
{
ex
.
Message
}
"
;
...
...
@@ -903,7 +929,7 @@ namespace Tools.MyAgent.Kb
private
string
GrepZeroHit
(
string
query
,
string
glob
,
int
maxResults
,
CancellationToken
ct
=
default
)
{
// ① 语义检索(需 embedding 服务可用且索引含向量)
var
sem
=
SemanticSearch
(
query
,
maxResults
);
var
sem
=
SemanticSearch
(
query
,
maxResults
,
ct
);
if
(
sem
.
Count
>
0
)
{
var
lines
=
sem
.
Select
(
h
=>
$"
{
h
.
Rel
}
:
{
TrimLine
(
h
.
Snippet
,
120
)}
"
);
...
...
@@ -926,13 +952,14 @@ namespace Tools.MyAgent.Kb
/// 语义检索(#9):查询文本向量化后与索引向量做余弦 Top-K。
/// <para>依赖:配置了 embedding 服务且索引已生成向量;任一不可用返回空列表(不影响关键词检索)。</para>
/// </summary>
public
List
<(
string
Rel
,
string
Snippet
)>
SemanticSearch
(
string
query
,
int
maxResults
)
public
List
<(
string
Rel
,
string
Snippet
)>
SemanticSearch
(
string
query
,
int
maxResults
,
CancellationToken
ct
=
default
)
{
var
result
=
new
List
<(
string
,
string
)>();
if
(!
_semanticEnabled
||
string
.
IsNullOrWhiteSpace
(
query
))
return
result
;
try
{
var
qv
=
_emb
.
EmbedAsync
(
query
,
CancellationToken
.
None
).
GetAwaiter
().
GetResult
();
ct
.
ThrowIfCancellationRequested
();
var
qv
=
_emb
.
EmbedAsync
(
query
,
ct
).
GetAwaiter
().
GetResult
();
if
(
qv
==
null
)
return
result
;
var
hits
=
_index
?.
SearchSemantic
(
qv
,
maxResults
);
if
(
hits
==
null
||
hits
.
Count
==
0
)
return
result
;
...
...
@@ -944,6 +971,10 @@ namespace Tools.MyAgent.Kb
result
.
Add
((
ToRelative
(
PathBase
,
abs
).
Replace
(
'\\'
,
'/'
),
$"相似度
{
h
.
Score
*
100
:
0
}
%"
));
}
}
catch
(
OperationCanceledException
)
{
throw
;
}
catch
{
}
return
result
;
}
...
...
@@ -1018,6 +1049,10 @@ namespace Tools.MyAgent.Kb
}
return
hits
;
}
catch
(
OperationCanceledException
)
{
throw
;
// 取消/超时向上传播
}
catch
{
return
null
;
...
...
@@ -1054,7 +1089,7 @@ namespace Tools.MyAgent.Kb
}
/// <summary>模糊相似度:完全相等 1.0 → 包含 0.95/0.9 → 中文二元组重合 0.5~0.95 → 编辑距离归一化</summary>
private
static
double
FuzzyScore
(
string
query
,
string
name
)
internal
static
double
FuzzyScore
(
string
query
,
string
name
)
{
if
(
string
.
IsNullOrEmpty
(
query
)
||
string
.
IsNullOrEmpty
(
name
))
return
0
;
if
(
name
.
Equals
(
query
,
StringComparison
.
OrdinalIgnoreCase
))
return
1.0
;
...
...
@@ -1092,7 +1127,7 @@ namespace Tools.MyAgent.Kb
}
/// <summary>编辑距离(DP,行滚动数组)</summary>
private
static
int
Levenshtein
(
string
a
,
string
b
)
internal
static
int
Levenshtein
(
string
a
,
string
b
)
{
if
(
string
.
IsNullOrEmpty
(
a
))
return
b
?.
Length
??
0
;
if
(
string
.
IsNullOrEmpty
(
b
))
return
a
.
Length
;
...
...
@@ -1735,7 +1770,7 @@ namespace Tools.MyAgent.Kb
#
region
工具方法
private
static
Regex
GlobToRegex
(
string
glob
)
internal
static
Regex
GlobToRegex
(
string
glob
)
{
var
sb
=
new
StringBuilder
(
"^"
);
foreach
(
char
c
in
glob
)
...
...
@@ -1866,7 +1901,7 @@ namespace Tools.MyAgent.Kb
/// 图像检索(#38):以参考图为模板,在知识库图片目录中检索相似图,返回相似度 Top-K。
/// <para>复用平台 MyUtils.ImageSearch 感知哈希(aHash/dHash/pHash)能力,不再自带 OpenCV 实现。</para>
/// </summary>
public
string
ImageSearch
(
string
referencePath
,
int
maxResults
)
public
string
ImageSearch
(
string
referencePath
,
int
maxResults
,
CancellationToken
ct
=
default
)
{
try
{
...
...
@@ -1889,8 +1924,9 @@ namespace Tools.MyAgent.Kb
string
refP
=
Tools
.
MyUtils
.
ImageSearch
.
pHash
(
refImg
);
var
candidates
=
new
List
<(
string
Rel
,
double
Score
)>();
foreach
(
var
file
in
SafeEnumerateFiles
(
SearchRoot
))
foreach
(
var
file
in
SafeEnumerateFiles
(
SearchRoot
,
ct
))
{
ct
.
ThrowIfCancellationRequested
();
if
(!
IsImageExtension
(
Path
.
GetExtension
(
file
)))
continue
;
if
(
string
.
Equals
(
Path
.
GetFullPath
(
file
),
Path
.
GetFullPath
(
refAbs
),
StringComparison
.
OrdinalIgnoreCase
))
continue
;
try
...
...
@@ -1918,6 +1954,10 @@ namespace Tools.MyAgent.Kb
return
sb
.
ToString
();
}
}
catch
(
OperationCanceledException
)
{
throw
;
// 取消/超时向上传播
}
catch
(
Exception
ex
)
{
return
$"错误:
{
ex
.
Message
}
"
;
...
...
Tools/MyAgent/ShareHelper.cs
View file @
4c991d55
...
...
@@ -83,20 +83,27 @@ namespace Tools.MyAgent
/// 拼装 net use 参数。
/// <para>未提供账号时使用当前 Windows 身份(不传密码,避免交互询问);
/// 提供账号时总带上密码参数(可为空串),避免 net use 交互式提示输入密码。</para>
/// <para>参数内引号转义为 ""(cmd 语法),防止账号/密码含引号时破坏命令行(修复)。</para>
/// </summary>
private
static
string
BuildNetUseArgs
(
string
share
,
string
user
,
string
password
)
{
var
sb
=
new
StringBuilder
();
sb
.
Append
(
'"'
).
Append
(
share
).
Append
(
'"'
);
sb
.
Append
(
Q
(
share
)
);
if
(!
string
.
IsNullOrEmpty
(
user
))
{
sb
.
Append
(
" \""
).
Append
(
password
??
""
).
Append
(
'"'
);
sb
.
Append
(
" /user:
\""
).
Append
(
user
).
Append
(
'"'
);
sb
.
Append
(
' '
).
Append
(
Q
(
password
??
""
)
);
sb
.
Append
(
" /user:
"
).
Append
(
Q
(
user
)
);
}
sb
.
Append
(
" /persistent:no"
);
return
sb
.
ToString
();
}
/// <summary>cmd 命令行参数加引号(内部引号转义为 "")</summary>
private
static
string
Q
(
string
s
)
{
return
"\""
+
(
s
??
""
).
Replace
(
"\""
,
"\"\""
)
+
"\""
;
}
/// <summary>执行 net use 命令并等待结果(超时自动终止),返回 (退出码, 输出文本[含错误输出])</summary>
private
static
(
int
Exit
,
string
Output
)
RunNetUse
(
string
args
,
int
timeoutMs
)
{
...
...
@@ -126,6 +133,8 @@ namespace Tools.MyAgent
try
{
p
.
Kill
();
}
catch
{
}
return
(-
2
,
sb
.
ToString
());
}
p
.
WaitForExit
();
// 修复:等待异步输出流排空(BeginOutputReadLine 是异步的,
// 仅 WaitForExit(ms) 返回后输出可能尚未读完,导致错误详情缺失)
return
(
p
.
ExitCode
,
sb
.
ToString
());
}
}
...
...
Tools/MyAgent/Support/BackupHelper.cs
View file @
4c991d55
...
...
@@ -144,17 +144,17 @@ namespace Tools.MyAgent.Support
return
sb
.
ToString
();
}
/// <summary>配置文件中的 ApiKey 脱敏(仅保留前后几位)</summary>
/// <summary>配置文件中的 ApiKey 脱敏(仅保留前后几位)
;扩展至全部凭据字段(P0 凭据收敛)
</summary>
private
static
string
MaskSecrets
(
string
json
)
{
try
{
json
=
System
.
Text
.
RegularExpressions
.
Regex
.
Replace
(
json
,
@"(?i)(""
ApiKey""|ApiKey|api[_-]?key
)\s*[:=]\s*""?([^\""\r\n,}]*)""?"
,
@"(?i)(""
(ApiKey|SharePassword|EmbeddingApiKey|BackupApiKey|Password|Pwd|Secret|Token)""|api[_-]?key|password|pwd|secret
)\s*[:=]\s*""?([^\""\r\n,}]*)""?"
,
match
=>
{
string
key
=
match
.
Groups
[
1
].
Value
;
string
val
=
match
.
Groups
[
2
].
Value
.
Trim
();
string
val
=
match
.
Groups
[
3
].
Value
.
Trim
();
if
(
val
.
Length
<=
4
)
return
match
.
Value
;
string
masked
=
val
.
Substring
(
0
,
2
)
+
"***"
+
val
.
Substring
(
val
.
Length
-
2
);
return
key
+
"=\""
+
masked
+
"\""
;
...
...
Tools/MyAgent/Support/ChatMessage.cs
View file @
4c991d55
...
...
@@ -19,5 +19,8 @@ namespace Tools.MyAgent
/// <summary>附图列表(多图,v2.3):一次提问可携带多张图片;优先于 ImagePath(旧会话兼容保留单图字段)</summary>
public
List
<
string
>
Images
{
get
;
set
;
}
/// <summary>回复末尾用量标注(展示用,不参与模型上下文/历史导出):如「本回复 1284+312 tokens(约 ¥0.42)」</summary>
public
string
UsageSuffix
{
get
;
set
;
}
}
}
Tools/MyAgent/Support/ChatRtfCache.cs
View file @
4c991d55
...
...
@@ -80,6 +80,7 @@ namespace Tools.MyAgent
{
return
(
m
.
Role
??
""
)
+
"|"
+
m
.
Timestamp
.
Ticks
+
"|"
+
(
m
.
Content
??
""
)
+
"|"
+
(
m
.
ThinkingContent
??
""
)
+
"|"
+
m
.
IsStreaming
+
"|"
+
(
m
.
UsageSuffix
??
""
)
// 用量标注:完成时附加,需纳入缓存键以触发重绘
+
"|"
+
assistantName
+
"|"
+
userName
+
"|"
+
detailsExpanded
;
}
}
...
...
Tools/MyAgent/Support/MarkdownRenderer.cs
View file @
4c991d55
...
...
@@ -153,6 +153,16 @@ namespace Tools.MyAgent
sb
.
Append
(
@"\par\par "
);
}
RenderMarkdown
(
sb
,
msg
.
Content
,
expandDetails
);
// 用量标注(展示用):回答完成后在末尾追加本轮用量(灰色小字)
if
(!
string
.
IsNullOrEmpty
(
msg
.
UsageSuffix
))
{
sb
.
Append
(
@"\pard\cf"
);
sb
.
Append
(
ClBq
);
sb
.
Append
(
@"\fs18 "
);
sb
.
Append
(
Escape
(
msg
.
UsageSuffix
));
sb
.
Append
(
@"\cf"
);
sb
.
Append
(
ClText
);
sb
.
Append
(
@"\fs20\par "
);
}
}
}
...
...
Tools/MyAgent/wMyAgent.cs
View file @
4c991d55
...
...
@@ -38,6 +38,9 @@ namespace Tools.MyAgent
/// <summary>v2.3 待发送附图缩略图栏(问答区左下角悬浮;每张图右上角叉号可取消)</summary>
private
FlowLayoutPanel
_pendingBar
;
private
readonly
List
<
string
>
_producedFiles
=
new
List
<
string
>();
/// <summary>本轮回复用量基线快照(发送前记录引擎累计值;回复完成后按增量展示「本回复」用量)</summary>
private
int
_replyPromptBase
,
_replyCompletionBase
;
private
double
_replyCostBase
;
private
Point
_mouseDownPos
=
Point
.
Empty
;
private
bool
_mouseButtonDown
;
private
Cursor
_defaultChatCursor
=
Cursors
.
Arrow
;
// 问答区不支持输入:默认箭头光标(原 IBeam)
...
...
@@ -136,8 +139,8 @@ namespace Tools.MyAgent
/// <summary>Agent 配置文件:默认 Agent 目录下的 config\AgentConfig.json</summary>
private
static
string
AgentConfigFilePath
=>
Path
.
Combine
(
DefaultKbRoot
,
"config"
,
"AgentConfig.json"
);
/// <summary>读取 Agent 配置文件(默认 Agent 目录下 config 文件夹);返回时 SharePassword 已解密为明文</summary>
/// <param name="migrated">旧版配置是否需要一次性落盘迁移(明文
密码
加密 / 自动启用系统提示词)</param>
/// <summary>读取 Agent 配置文件(默认 Agent 目录下 config 文件夹);返回时 SharePassword
/ ApiKey / EmbeddingApiKey / BackupApiKey / DbConnections
已解密为明文</summary>
/// <param name="migrated">旧版配置是否需要一次性落盘迁移(明文
凭据
加密 / 自动启用系统提示词)</param>
private
AgentConfig
LoadAgentConfig
(
out
bool
migrated
)
{
migrated
=
false
;
...
...
@@ -148,9 +151,12 @@ namespace Tools.MyAgent
string
raw
=
File
.
ReadAllText
(
AgentConfigFilePath
,
System
.
Text
.
Encoding
.
UTF8
);
var
cfg
=
Newtonsoft
.
Json
.
JsonConvert
.
DeserializeObject
<
AgentConfig
>(
raw
);
if
(
cfg
==
null
)
return
null
;
// 旧版明文密码:先解密标记为待迁移(下次保存时加密落盘)
migrated
=
!
string
.
IsNullOrEmpty
(
cfg
.
SharePassword
)
&&
!
SecretProtector
.
IsProtected
(
cfg
.
SharePassword
);
// 旧版明文凭据:先解密标记为待迁移(下次保存时加密落盘)
migrated
=
(!
string
.
IsNullOrEmpty
(
cfg
.
SharePassword
)
&&
!
SecretProtector
.
IsProtected
(
cfg
.
SharePassword
))
||
(!
string
.
IsNullOrEmpty
(
cfg
.
ApiKey
)
&&
!
SecretProtector
.
IsProtected
(
cfg
.
ApiKey
))
||
(!
string
.
IsNullOrEmpty
(
cfg
.
EmbeddingApiKey
)
&&
!
SecretProtector
.
IsProtected
(
cfg
.
EmbeddingApiKey
))
||
(!
string
.
IsNullOrEmpty
(
cfg
.
BackupApiKey
)
&&
!
SecretProtector
.
IsProtected
(
cfg
.
BackupApiKey
))
||
HasPlainConnections
(
cfg
.
DbConnections
);
// 旧版配置无 SystemPromptEnabled 字段:若已填写自定义提示词则默认启用(行为兼容迁移)
try
{
...
...
@@ -163,6 +169,20 @@ namespace Tools.MyAgent
}
catch
{
}
cfg
.
SharePassword
=
SecretProtector
.
Unprotect
(
cfg
.
SharePassword
);
cfg
.
ApiKey
=
SecretProtector
.
Unprotect
(
cfg
.
ApiKey
);
cfg
.
EmbeddingApiKey
=
SecretProtector
.
Unprotect
(
cfg
.
EmbeddingApiKey
);
cfg
.
BackupApiKey
=
SecretProtector
.
Unprotect
(
cfg
.
BackupApiKey
);
if
(
cfg
.
DbConnections
!=
null
)
{
var
plain
=
new
Dictionary
<
string
,
string
>(
StringComparer
.
OrdinalIgnoreCase
);
foreach
(
var
kv
in
cfg
.
DbConnections
)
plain
[
kv
.
Key
]
=
SecretProtector
.
Unprotect
(
kv
.
Value
);
cfg
.
DbConnections
=
plain
;
}
else
{
cfg
.
DbConnections
=
new
Dictionary
<
string
,
string
>(
StringComparer
.
OrdinalIgnoreCase
);
}
return
cfg
;
}
}
...
...
@@ -170,15 +190,29 @@ namespace Tools.MyAgent
return
null
;
}
/// <summary>保存 Agent 配置文件到默认 Agent 目录下 config 文件夹(共享密码经 DPAPI 加密落盘)</summary>
/// <summary>预存连接串中是否存在未加密明文(用于旧版迁移检测)</summary>
private
static
bool
HasPlainConnections
(
Dictionary
<
string
,
string
>
conns
)
{
if
(
conns
==
null
)
return
false
;
foreach
(
var
kv
in
conns
)
if
(!
string
.
IsNullOrEmpty
(
kv
.
Value
)
&&
!
SecretProtector
.
IsProtected
(
kv
.
Value
))
return
true
;
return
false
;
}
/// <summary>保存 Agent 配置文件到默认 Agent 目录下 config 文件夹(全部凭据经 DPAPI 加密落盘:共享密码 / ApiKey / EmbeddingApiKey / BackupApiKey / 预存连接串)</summary>
private
void
SaveAgentConfig
(
AgentConfig
cfg
)
{
try
{
Directory
.
CreateDirectory
(
Path
.
Combine
(
DefaultKbRoot
,
"config"
));
var
protectedConns
=
new
Dictionary
<
string
,
string
>(
StringComparer
.
OrdinalIgnoreCase
);
if
(
cfg
.
DbConnections
!=
null
)
foreach
(
var
kv
in
cfg
.
DbConnections
)
protectedConns
[
kv
.
Key
]
=
SecretProtector
.
Protect
(
kv
.
Value
);
var
toSave
=
new
AgentConfig
{
ApiKey
=
cfg
.
ApiKey
,
ApiKey
=
SecretProtector
.
Protect
(
cfg
.
ApiKey
)
,
BaseUrl
=
cfg
.
BaseUrl
,
Model
=
cfg
.
Model
,
Provider
=
cfg
.
Provider
,
...
...
@@ -196,14 +230,16 @@ namespace Tools.MyAgent
UseSystemProxy
=
cfg
.
UseSystemProxy
,
EmbeddingBaseUrl
=
cfg
.
EmbeddingBaseUrl
,
EmbeddingModel
=
cfg
.
EmbeddingModel
,
EmbeddingApiKey
=
cfg
.
EmbeddingApiKey
,
EmbeddingApiKey
=
SecretProtector
.
Protect
(
cfg
.
EmbeddingApiKey
)
,
EnableSemanticSearch
=
cfg
.
EnableSemanticSearch
,
EnableFailover
=
cfg
.
EnableFailover
,
BackupBaseUrl
=
cfg
.
BackupBaseUrl
,
BackupModel
=
cfg
.
BackupModel
,
BackupApiKey
=
cfg
.
BackupApiKey
,
BackupApiKey
=
SecretProtector
.
Protect
(
cfg
.
BackupApiKey
)
,
EnableOptionalTools
=
cfg
.
EnableOptionalTools
,
EnableImageInput
=
cfg
.
EnableImageInput
,
DbConnections
=
protectedConns
,
ToolRoleAllow
=
cfg
.
ToolRoleAllow
??
new
Dictionary
<
string
,
string
>(),
};
File
.
WriteAllText
(
AgentConfigFilePath
,
Newtonsoft
.
Json
.
JsonConvert
.
SerializeObject
(
toSave
),
System
.
Text
.
Encoding
.
UTF8
);
}
...
...
@@ -243,6 +279,8 @@ namespace Tools.MyAgent
{
_engine
.
Configure
(
_config
);
_engine
.
SetOptionalTools
(
_config
.
EnableOptionalTools
);
// 工具级权限(P3):当前登录用户名即角色,用于 db_query/module_patch 等按角色开关
_engine
.
CurrentUser
=
string
.
IsNullOrEmpty
(
LoginInfo
.
UserName
)
?
APP
.
TEMP_USER_NAME
:
LoginInfo
.
UserName
;
// X2 插件化:扫描 Agent/plugins 目录加载第三方工具(幂等,失败仅记日志)
Tools
.
MyAgent
.
Agent
.
Tools
.
ToolRegistry
.
LoadPlugins
(
Path
.
Combine
(
DefaultKbRoot
,
"plugins"
));
// 语义检索(embedding)配置:随知识库索引构建批量向量化;服务不可用自动降级为纯关键词
...
...
@@ -394,20 +432,14 @@ namespace Tools.MyAgent
string
kbText
;
if
(
_kb
.
IsValid
)
{
_kb
.
EnsureStats
();
kbText
=
$"
{
Path
.
GetFileName
(
_kb
.
Root
.
TrimEnd
(
'\\'
))}
(
{
FormatFileCount
(
_kb
.
FileCount
)}
)"
;
kbText
=
Path
.
GetFileName
(
_kb
.
Root
.
TrimEnd
(
'\\'
));
// 状态栏仅显示知识库名,不拼接文件数量
}
else
if
(!
string
.
IsNullOrEmpty
(
_kb
.
Root
))
kbText
=
$"
{
Path
.
GetFileName
(
_kb
.
Root
.
TrimEnd
(
'\\'
))}
(不可访问)"
;
else
kbText
=
"未选知识库"
;
// D16 用量统计:会话累计 token(prompt+completion),有调用记录时展示
// R6 费用估算:累计费用 > 0 时附带估算金额(按实际服务模型与价格表)
string
usage
=
_engine
.
TotalPromptTokens
+
_engine
.
TotalCompletionTokens
>
0
?
$" · 本会话
{
_engine
.
TotalPromptTokens
}
+
{
_engine
.
TotalCompletionTokens
}
tokens"
+
(
_engine
.
TotalCost
>
0
?
$"(约
{
CostEstimator
.
Format
(
_engine
.
TotalCost
)}
)"
:
""
)
:
""
;
lblStatus
.
Text
=
$"
{
_config
.
Model
}
·
{
kbText
}{
usage
}
"
;
// 用量展示优化:状态栏不再拼接用量,用量改为附加到每条回复结尾
lblStatus
.
Text
=
$"
{
_config
.
Model
}
·
{
kbText
}
"
;
lblStatus
.
ForeColor
=
Color
.
Gray
;
}
...
...
@@ -471,6 +503,11 @@ namespace Tools.MyAgent
RefreshChat
();
txtInput
.
Focus
();
// 用量快照:记录本轮发送前的累计值,回复完成后按增量展示本轮用量
_replyPromptBase
=
_engine
.
TotalPromptTokens
;
_replyCompletionBase
=
_engine
.
TotalCompletionTokens
;
_replyCostBase
=
_engine
.
TotalCost
;
_sendCts
=
new
CancellationTokenSource
();
try
{
...
...
@@ -544,13 +581,29 @@ namespace Tools.MyAgent
last
.
IsStreaming
=
false
;
if
(
_producedFiles
.
Count
>
0
)
last
.
Content
=
AppendOutputFiles
(
last
.
Content
);
// 用量展示优化:本轮用量附加到回复结尾(相对发送前快照的增量;无调用记录不显示)
string
usage
=
BuildReplyUsageSuffix
();
if
(
usage
.
Length
>
0
)
last
.
UsageSuffix
=
usage
;
}
ResetInputState
();
RefreshChat
();
UpdateStatusIndicator
();
//
D16:刷新会话累计用量
UpdateStatusIndicator
();
//
刷新状态栏(模型/知识库信息)
SaveDraftSession
();
// D12:每轮回答完成即更新草稿(崩溃恢复)
}
/// <summary>构造本轮回复用量标注(相对发送前快照的增量;本轮无调用记录返回空串)</summary>
private
string
BuildReplyUsageSuffix
()
{
int
prompt
=
_engine
.
TotalPromptTokens
-
_replyPromptBase
;
int
completion
=
_engine
.
TotalCompletionTokens
-
_replyCompletionBase
;
if
(
prompt
+
completion
<=
0
)
return
""
;
string
text
=
$"本回复
{
prompt
}
+
{
completion
}
tokens"
;
double
cost
=
_engine
.
TotalCost
-
_replyCostBase
;
if
(
cost
>
0
)
text
+=
$"(约
{
CostEstimator
.
Format
(
cost
)}
)"
;
return
text
;
}
/// <summary>工具轨迹(#16):回答末尾追加可折叠 details,展示工具调用链(先宽后窄的检索过程)</summary>
private
void
OnToolTrace
(
System
.
Collections
.
Generic
.
IReadOnlyList
<
AgentEngine
.
ToolTraceEntry
>
trace
)
{
...
...
@@ -587,17 +640,20 @@ namespace Tools.MyAgent
var
last
=
LastAssistantMessage
();
if
(
last
!=
null
&&
last
.
IsStreaming
)
{
// 流式消息尚未成型,直接用错误文本覆盖
// 流式消息尚未成型,直接用错误文本覆盖
(用量按增量附加)
last
.
Content
=
message
;
last
.
IsStreaming
=
false
;
last
.
UsageSuffix
=
BuildReplyUsageSuffix
();
}
else
{
_messages
.
Add
(
new
ChatMessage
{
Role
=
"assistant"
,
Content
=
$"[Error:
{
message
}
]"
});
var
err
=
new
ChatMessage
{
Role
=
"assistant"
,
Content
=
$"[Error:
{
message
}
]"
};
err
.
UsageSuffix
=
BuildReplyUsageSuffix
();
_messages
.
Add
(
err
);
}
ResetInputState
();
RefreshChat
();
UpdateStatusIndicator
();
//
D16:错误结束也刷新用量
UpdateStatusIndicator
();
//
错误结束也刷新状态栏
SaveDraftSession
();
// D12:出错也保留当前对话为草稿
}
...
...
Tools/Properties/AssemblyInfo.cs
View file @
4c991d55
...
...
@@ -34,3 +34,6 @@ using System.Runtime.InteropServices;
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.72.*")]
// ECC 测试要求:向智能体单元测试工程开放 internal 成员(沙箱校验/SQL 白名单/压缩截断/故障转移/token 裁剪等纯逻辑)
[assembly: InternalsVisibleTo("AgentTest")]
Tools/Tools.csproj
View file @
4c991d55
...
...
@@ -798,7 +798,6 @@
</ItemGroup>
<ItemGroup>
<Content
Include=
"favior.ico"
/>
<Content
Include=
"prompt_design.md"
/>
<None
Include=
"Resources\tools_desc.jpg"
/>
<None
Include=
"Resources\文件夹.png"
/>
<None
Include=
"Resources\nav_plain_red.png"
/>
...
...
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