Skip to content

Commit 7bcb2ba

Browse files
authored
Merge pull request #3 from sml2/main
CLI
2 parents 4590df6 + 59ece2d commit 7bcb2ba

10 files changed

Lines changed: 446 additions & 217 deletions

File tree

ToDoList.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# 待办事项列表
2+
---
3+
- [ ] 项目改名为**SharpClaw**,同时遵循.NET生态中的规范,符合C#命名习惯,同时类比OpenClaw、ZeroClaw、PicoClaw等项目,让人一看名字就知道是一个Claw的项目。
4+
- [ ] slnx加入类库,默认VS IDE支持(现在要Restore Build),在最新IDE中做到,【0异常 0警告 0消息】的编译状态。
5+
- [ ] MainAgent在作为单例在注册在DI中复用,探究运行时热切换不同UI模式的可行性。
6+
- [ ] 遵循NET最佳实践,DI管控各Services组件生命周期,合理使用Singleton、Scoped、Transient。
7+
- [ ] 引入ORM类库,方便用户持久化对话数据,并且可以在不同会话之间共享数据。
8+
- [ ] 规范日志Logging
9+
- [ ] 配置Options
10+
- [ ] 各Agent选择不同IChatClient实现,合理匹配不同Model的能力。
11+
- [ ] 增加Token计数功能,方便用户了解每次对话的Token使用情况。.
12+
- [X] 增加PowerShell Core(pwsh.exe / 7.x)回退PowerShell(powershell.exe / 5.1)的功能
13+
14+
---
15+
## TUI模式已知问题或改进方向
16+
- [ ] 复制粘贴功能不完善
17+
- [ ] 输入 **/** 后,提示词移动光标或键盘选中后没响应补全行为
18+
- [ ] 方向上键没法重复输入上次任务
19+
- [ ] 配置后重启才生效(工厂模式下直接注入对应的新IChatClient)
20+
- [ ] 配置其他UI时,多余CheckBox带来误导(让人误以为可以多UI共存的问题)
21+
- [ ] 实现共存 或者 [ ] 消除误导
22+
---
23+
24+
## 新增纯CLI兼容模式的Fallback支持
25+
- [X] 简单实现,意图是快速开发核心业务,规避其他UI配置实现复杂,并且更通用的环境下使用
26+
- [X] 调整默认颜色输出,在CLI模式下使用不同的颜色区分用户输入和模型输出,提升可读性。
27+
- [ ] 实现Config指令,允许用户在CLI模式下动态调整配置参数,如切换模型、调整颜色等。
28+
- [ ] 日志显示(借用Title输出 或者 BeginRuning时输出内容 )。

sharpclaw.slnx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
<Solution>
2-
<Project Path="sharpclaw/sharpclaw.csproj" />
2+
<Project Path="sharpclaw/sharpclaw.csproj" DisplayName="SharpClaw" />
33
</Solution>
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Authors: sml2 <admin@sml2.com>
2+
// Co-Authors: Claude Sonnet 4.6 <claude@anthropic.com>
3+
4+
using sharpclaw.Abstractions;
5+
using System.Threading.Channels;
6+
7+
namespace sharpclaw.Channels.Cli;
8+
9+
/// <summary>
10+
/// IChatIO 的纯 CLI 实现。
11+
/// 使用标准 Console I/O,无需 Terminal.Gui,适合快速开发和通用环境。
12+
/// </summary>
13+
public sealed class CliChatIO : IChatIO
14+
{
15+
private CancellationTokenSource? _aiCts;
16+
private readonly CancellationTokenSource _stopCts = new();
17+
private readonly Channel<string> _inputChannel = Channel.CreateUnbounded<string>();
18+
private readonly Thread _inputThread;
19+
private static readonly bool SupportsColor = !Console.IsOutputRedirected;
20+
21+
private static void SetColor(ConsoleColor color)
22+
{
23+
if (SupportsColor) Console.ForegroundColor = color;
24+
}
25+
26+
private static void ResetColor()
27+
{
28+
if (SupportsColor) Console.ResetColor();
29+
}
30+
31+
public CliChatIO()
32+
{
33+
// 后台线程持续读取 Console 输入,以便支持 CancellationToken
34+
_inputThread = new Thread(ReadInputLoop) { IsBackground = true };
35+
_inputThread.Start();
36+
}
37+
38+
private void ReadInputLoop()
39+
{
40+
while (!_stopCts.IsCancellationRequested)
41+
{
42+
var line = Console.ReadLine();
43+
if (line is null)
44+
{
45+
// stdin 关闭(管道/重定向结束)
46+
_inputChannel.Writer.TryComplete();
47+
break;
48+
}
49+
_inputChannel.Writer.TryWrite(line);
50+
}
51+
}
52+
53+
/// <inheritdoc/>
54+
public Task WaitForReadyAsync() => Task.CompletedTask;
55+
56+
/// <inheritdoc/>
57+
public async Task<string> ReadInputAsync(CancellationToken cancellationToken = default)
58+
{
59+
ResetColor();
60+
SetColor(ConsoleColor.Cyan);
61+
Console.Write("> ");
62+
ResetColor();
63+
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
64+
cancellationToken, _stopCts.Token);
65+
return await _inputChannel.Reader.ReadAsync(linked.Token);
66+
}
67+
68+
/// <inheritdoc/>
69+
public Task<CommandResult> HandleCommandAsync(string input)
70+
{
71+
var trimmed = input.Trim();
72+
if (trimmed is "/exit" or "/quit")
73+
{
74+
RequestStop();
75+
return Task.FromResult(CommandResult.Exit);
76+
}
77+
78+
if (trimmed is "/help")
79+
{
80+
SetColor(ConsoleColor.DarkGray);
81+
Console.WriteLine("""
82+
内置指令:
83+
/help 显示此帮助信息
84+
/exit 退出程序
85+
/quit 退出程序
86+
""");
87+
ResetColor();
88+
return Task.FromResult(CommandResult.Handled);
89+
}
90+
91+
return Task.FromResult(CommandResult.NotACommand);
92+
}
93+
94+
/// <inheritdoc/>
95+
public void EchoUserInput(string input)
96+
{
97+
// CLI 模式下用户已看到自己的输入,无需回显
98+
}
99+
100+
/// <inheritdoc/>
101+
public void BeginAiResponse()
102+
{
103+
SetColor(ConsoleColor.Green);
104+
Console.Write("\nAI: ");
105+
}
106+
107+
/// <inheritdoc/>
108+
public void AppendChat(string text)
109+
{
110+
Console.Write(text);
111+
}
112+
113+
/// <inheritdoc/>
114+
public void AppendChatLine(string text)
115+
{
116+
Console.WriteLine(text);
117+
}
118+
119+
/// <inheritdoc/>
120+
public void ShowRunning()
121+
{
122+
SetColor(ConsoleColor.DarkYellow);
123+
Console.Write("\n思考中...");
124+
ResetColor();
125+
}
126+
127+
/// <inheritdoc/>
128+
public CancellationToken GetAiCancellationToken()
129+
{
130+
_aiCts?.Dispose();
131+
_aiCts = new CancellationTokenSource();
132+
return _aiCts.Token;
133+
}
134+
135+
/// <inheritdoc/>
136+
public void RequestStop()
137+
{
138+
_stopCts.Cancel();
139+
_inputChannel.Writer.TryComplete();
140+
}
141+
}

sharpclaw/Commands/ProcessCommands.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ public string CommandPowershell(
2424
true, workingDirectory, 0);
2525
}
2626

27+
[Description("Execute Windows PowerShell (powershell.exe / 5.1) commands. MUST use '-Command' or '-File' explicitly.")]
28+
public string CommandWindowsPowershell(
29+
[Description("Arguments to pass to powershell.exe. Example: [\"-Command\", \"Get-ChildItem -Path C:\\\\\"] or [\"-File\", \"./script.ps1\"]\"")] string[] args,
30+
[Description("Working directory (optional, absolute path)")] string workingDirectory = "")
31+
{
32+
return RunProcess("powershell", args ?? Array.Empty<string>(),
33+
"powershell " + string.Join(" ", args ?? Array.Empty<string>()),
34+
true, workingDirectory, 0);
35+
}
36+
2737
[Description("Execute Bash commands")]
2838
public string CommandBash(
2939
[Description("Arguments to pass to Bash")] string[] args,

sharpclaw/Core/AgentBootstrap.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ public static BootstrapResult Initialize()
6565

6666
if (OperatingSystem.IsWindows())
6767
{
68-
commandSkillDelegates.Add(processCommands.CommandPowershell);
68+
commandSkillDelegates.Add(IsCommandAvailable("pwsh")
69+
? processCommands.CommandPowershell
70+
: processCommands.CommandWindowsPowershell);
6971
}
7072
else
7173
{
@@ -88,4 +90,12 @@ public static BootstrapResult Initialize()
8890

8991
return new BootstrapResult(config, taskManager, [.. commandSkills, .. skillTools], memoryStore, agentContext);
9092
}
93+
94+
private static bool IsCommandAvailable(string command)
95+
{
96+
var ext = OperatingSystem.IsWindows() ? ".exe" : "";
97+
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
98+
return pathEnv.Split(Path.PathSeparator)
99+
.Any(dir => File.Exists(Path.Combine(dir, command + ext)));
100+
}
91101
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using System.Text.Json;
2+
using Microsoft.EntityFrameworkCore;
3+
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
4+
5+
namespace sharpclaw.Memory;
6+
7+
/// <summary>
8+
/// EF Core DbContext,映射 memories SQLite 表。
9+
/// 使用 EnsureCreated 替代 Migrations,与现有 schema 完全兼容。
10+
/// </summary>
11+
internal sealed class MemoryDbContext : DbContext
12+
{
13+
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false };
14+
15+
public DbSet<MemoryRecord> Memories => Set<MemoryRecord>();
16+
17+
public MemoryDbContext(DbContextOptions<MemoryDbContext> options) : base(options) { }
18+
19+
protected override void OnModelCreating(ModelBuilder modelBuilder)
20+
{
21+
var keywordsConverter = new ValueConverter<List<string>, string>(
22+
v => JsonSerializer.Serialize(v, JsonOptions),
23+
v => JsonSerializer.Deserialize<List<string>>(v, JsonOptions) ?? new List<string>());
24+
25+
// DateTimeOffset 以 ISO 8601 ("O") 格式存储,与原有 schema 保持兼容
26+
var dateTimeConverter = new ValueConverter<DateTimeOffset, string>(
27+
v => v.ToString("O"),
28+
v => DateTimeOffset.Parse(v));
29+
30+
modelBuilder.Entity<MemoryRecord>(e =>
31+
{
32+
e.ToTable("memories");
33+
e.HasKey(m => m.Id);
34+
e.Property(m => m.Id).HasColumnName("id");
35+
e.Property(m => m.Category).HasColumnName("category").IsRequired();
36+
e.Property(m => m.Importance).HasColumnName("importance");
37+
e.Property(m => m.Content).HasColumnName("content").IsRequired();
38+
e.Property(m => m.Keywords)
39+
.HasColumnName("keywords")
40+
.IsRequired()
41+
.HasConversion(keywordsConverter);
42+
e.Property(m => m.CreatedAt)
43+
.HasColumnName("created_at")
44+
.HasConversion(dateTimeConverter);
45+
e.Property(m => m.Embedding)
46+
.HasColumnName("embedding")
47+
.IsRequired();
48+
});
49+
}
50+
51+
/// <summary>
52+
/// 根据数据库文件路径构建 DbContextOptions。
53+
/// </summary>
54+
public static DbContextOptions<MemoryDbContext> BuildOptions(string dbPath)
55+
{
56+
return new DbContextOptionsBuilder<MemoryDbContext>()
57+
.UseSqlite($"Data Source={dbPath}")
58+
.Options;
59+
}
60+
}

sharpclaw/Memory/MemoryRecord.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
namespace sharpclaw.Memory;
2+
3+
/// <summary>
4+
/// EF Core 数据库实体,映射 memories 表。
5+
/// 包含向量嵌入字段,与领域对象 <see cref="MemoryEntry"/> 分离。
6+
/// </summary>
7+
internal sealed class MemoryRecord
8+
{
9+
public string Id { get; set; } = Guid.NewGuid().ToString("N");
10+
public string Category { get; set; } = "fact";
11+
public int Importance { get; set; } = 5;
12+
public string Content { get; set; } = "";
13+
public List<string> Keywords { get; set; } = [];
14+
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
15+
16+
/// <summary>嵌入向量,以 BLOB 形式存储(float[] 按字节序列化)</summary>
17+
public byte[] Embedding { get; set; } = [];
18+
19+
/// <summary>从领域对象转换</summary>
20+
public static MemoryRecord FromEntry(MemoryEntry entry, float[] embedding) => new()
21+
{
22+
Id = entry.Id,
23+
Category = entry.Category,
24+
Importance = entry.Importance,
25+
Content = entry.Content,
26+
Keywords = entry.Keywords,
27+
CreatedAt = entry.CreatedAt,
28+
Embedding = FloatArrayToBytes(embedding),
29+
};
30+
31+
/// <summary>转换为领域对象</summary>
32+
public MemoryEntry ToEntry() => new()
33+
{
34+
Id = Id,
35+
Category = Category,
36+
Importance = Importance,
37+
Content = Content,
38+
Keywords = Keywords,
39+
CreatedAt = CreatedAt,
40+
};
41+
42+
public static byte[] FloatArrayToBytes(float[] vector)
43+
{
44+
var bytes = new byte[vector.Length * sizeof(float)];
45+
Buffer.BlockCopy(vector, 0, bytes, 0, bytes.Length);
46+
return bytes;
47+
}
48+
49+
public static float[] BytesToFloatArray(byte[] bytes)
50+
{
51+
var result = new float[bytes.Length / sizeof(float)];
52+
Buffer.BlockCopy(bytes, 0, result, 0, bytes.Length);
53+
return result;
54+
}
55+
}

0 commit comments

Comments
 (0)