Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ WebCode 是一个基于 `Blazor Server + .NET 10` 的 AI 工作平台,目标

- 在 Web 端创建和管理 AI 会话
- 为不同会话绑定独立工作区
- 发现并导入当前系统账户下已有的 `Claude Code`、`Codex`、`OpenCode` 会话
- 基于原始 CLI transcript 恢复当前会话历史,而不只依赖 WebCode 自己记录的消息
- 在飞书中通过机器人和卡片完成会话、项目、目录等操作
- 为不同用户配置各自的 CLI 环境、飞书机器人、可访问目录和工具权限
- 在手机、平板和桌面浏览器中持续使用同一套工作流
Expand Down Expand Up @@ -50,6 +52,15 @@ WebCode 是一个基于 `Blazor Server + .NET 10` 的 AI 工作平台,目标
- 支持文件浏览、预览、上传、复制路径等工作区操作
- 支持历史会话切换、关闭、隔离和清理
- 可与项目管理联动,从项目直接创建会话
- 支持导入当前系统账户下已有的外部 CLI 会话,并在 WebCode 中继续使用
- 支持从原始 CLI transcript 读取会话历史,用于恢复和回放已有上下文

外部 CLI 会话导入当前具备这些约束:

- 仅扫描当前操作系统账户下可访问的本地会话
- 仅显示工作区位于允许目录/白名单中的会话
- 同一个 `(ToolId, CliThreadId)` 只能被一个 WebCode 用户占用
- Web 端与飞书端都支持分页浏览、导入并切换到这些会话

### 3. 多用户与权限控制

Expand All @@ -70,11 +81,19 @@ WebCode 是一个基于 `Blazor Server + .NET 10` 的 AI 工作平台,目标

- 用户绑定自己的飞书机器人
- 会话管理卡片
- 导入本地 CLI 会话卡片
- 项目管理卡片
- 白名单目录浏览
- 项目克隆 / 拉取 / 分支切换
- 支持查看当前会话的原生 CLI 历史消息
- 会话完成后的普通文本提醒

飞书侧与外部会话相关的典型能力包括:

- 按工具筛选并导入 `Claude Code` / `Codex` / `OpenCode` 的本地会话
- 在切换会话后自动回显对应 CLI 原生历史,便于快速接续上下文
- 通过内置命令 `/history` 获取当前会话对应 CLI transcript 中的历史消息

适配代码主要位于:

- [WebCodeCli.Domain/Domain/Service/Channels](./WebCodeCli.Domain/Domain/Service/Channels)
Expand Down Expand Up @@ -143,7 +162,9 @@ dotnet run --project WebCodeCli

默认访问地址:

- `http://localhost:5000`
- `http://localhost:6001`

如果你修改了根目录 [appsettings.json](./appsettings.json) 或 [WebCodeCli/appsettings.json](./WebCodeCli/appsettings.json) 中的 `urls` 配置,请以实际监听端口为准。

## 首次初始化建议

Expand All @@ -155,6 +176,7 @@ dotnet run --project WebCodeCli
4. 验证工作区根目录和存储目录
5. 如需飞书集成,再配置飞书机器人参数
6. 如需多用户,进入管理界面配置用户、目录白名单和工具权限
7. 如需恢复现有 CLI 工作上下文,可在 Web 端或飞书端导入本地外部会话

初始化向导界面示意:

Expand Down Expand Up @@ -222,6 +244,7 @@ CLI 工具配置支持通过界面和配置文件两种方式维护。
- 为每个用户设置独立的白名单目录
- 为每个用户限制可用 CLI 工具
- 为每个用户配置自己的飞书机器人
- 为需要接续工作的用户开放“导入外部 CLI 会话”能力,但仍限制在各自白名单目录内
- 避免把数据库文件提交到 Git
- 明确区分“共享默认配置”和“用户覆盖配置”

Expand Down
197 changes: 196 additions & 1 deletion WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs

Large diffs are not rendered by default.

278 changes: 278 additions & 0 deletions WebCodeCli.Domain.Tests/CliToolEnvironmentServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
using AntSK.Domain.Repositories.Base;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using WebCodeCli.Domain.Common.Options;
using WebCodeCli.Domain.Domain.Model;
using WebCodeCli.Domain.Domain.Service;
using WebCodeCli.Domain.Repositories.Base.CliToolEnv;
using WebCodeCli.Domain.Repositories.Base.UserCliToolEnv;

namespace WebCodeCli.Domain.Tests;

public class CliToolEnvironmentServiceTests
{
[Fact]
public async Task GetEnvironmentVariablesAsync_WhenUserOverrideIsEmpty_RemovesInheritedValue()
{
const string toolId = "codex";
const string username = "alice";

var service = CreateService(
new CliToolsOption
{
Tools =
[
new CliToolConfig
{
Id = toolId,
EnvironmentVariables = new Dictionary<string, string>
{
["BASE_KEY"] = "base-value",
["REMOVE_ME"] = "tool-value"
}
}
]
},
new FakeCliToolEnvironmentVariableRepository(new Dictionary<string, Dictionary<string, string>>
{
[toolId] = new(StringComparer.OrdinalIgnoreCase)
{
["REMOVE_ME"] = "shared-value",
["SHARED_KEY"] = "shared-only"
}
}),
new FakeUserCliToolEnvironmentVariableRepository(new Dictionary<string, Dictionary<string, Dictionary<string, string>>>(StringComparer.OrdinalIgnoreCase)
{
[username] = new(StringComparer.OrdinalIgnoreCase)
{
[toolId] = new(StringComparer.OrdinalIgnoreCase)
{
["REMOVE_ME"] = string.Empty,
["USER_KEY"] = "user-value"
}
}
}),
new FakeUserContextService(username));

var result = await service.GetEnvironmentVariablesAsync(toolId, username);

Assert.Equal("base-value", result["BASE_KEY"]);
Assert.Equal("shared-only", result["SHARED_KEY"]);
Assert.Equal("user-value", result["USER_KEY"]);
Assert.False(result.ContainsKey("REMOVE_ME"));
}

[Fact]
public async Task SaveEnvironmentVariablesAsync_WhenInheritedVariableIsRemoved_PersistsDeletionMarker()
{
const string toolId = "claude-code";
const string username = "alice";

var sharedRepository = new FakeCliToolEnvironmentVariableRepository(new Dictionary<string, Dictionary<string, string>>
{
[toolId] = new(StringComparer.OrdinalIgnoreCase)
{
["SHARED_KEY"] = "shared-value"
}
});
var userRepository = new FakeUserCliToolEnvironmentVariableRepository();
var service = CreateService(
new CliToolsOption
{
Tools =
[
new CliToolConfig
{
Id = toolId,
EnvironmentVariables = new Dictionary<string, string>
{
["DEFAULT_KEY"] = "default-value",
["KEEP_KEY"] = "default-keep"
}
}
]
},
sharedRepository,
userRepository,
new FakeUserContextService(username));

var success = await service.SaveEnvironmentVariablesAsync(toolId, new Dictionary<string, string>
{
["KEEP_KEY"] = "custom-value",
["USER_KEY"] = "user-value"
}, username);

Assert.True(success);

var persisted = await userRepository.GetEnvironmentVariablesAsync(username, toolId);
Assert.Equal(string.Empty, persisted["DEFAULT_KEY"]);
Assert.Equal(string.Empty, persisted["SHARED_KEY"]);
Assert.Equal("custom-value", persisted["KEEP_KEY"]);
Assert.Equal("user-value", persisted["USER_KEY"]);

var effective = await service.GetEnvironmentVariablesAsync(toolId, username);
Assert.False(effective.ContainsKey("DEFAULT_KEY"));
Assert.False(effective.ContainsKey("SHARED_KEY"));
Assert.Equal("custom-value", effective["KEEP_KEY"]);
Assert.Equal("user-value", effective["USER_KEY"]);
}

[Fact]
public async Task GetEnvironmentVariablesAsync_WhenExplicitUsernameProvided_UsesExplicitUsernameInsteadOfContext()
{
const string toolId = "codex";
var service = CreateService(
new CliToolsOption(),
new FakeCliToolEnvironmentVariableRepository(),
new FakeUserCliToolEnvironmentVariableRepository(new Dictionary<string, Dictionary<string, Dictionary<string, string>>>(StringComparer.OrdinalIgnoreCase)
{
["luhaiyan"] = new(StringComparer.OrdinalIgnoreCase)
{
[toolId] = new(StringComparer.OrdinalIgnoreCase)
{
["API_KEY"] = "luhaiyan-key"
}
},
["test"] = new(StringComparer.OrdinalIgnoreCase)
{
[toolId] = new(StringComparer.OrdinalIgnoreCase)
{
["API_KEY"] = "test-key"
}
}
}),
new FakeUserContextService("luhaiyan"));

var result = await service.GetEnvironmentVariablesAsync(toolId, "test");

Assert.Equal("test-key", result["API_KEY"]);
}

private static CliToolEnvironmentService CreateService(
CliToolsOption options,
ICliToolEnvironmentVariableRepository repository,
IUserCliToolEnvironmentVariableRepository userRepository,
IUserContextService userContextService)
{
return new CliToolEnvironmentService(
NullLogger<CliToolEnvironmentService>.Instance,
Options.Create(options),
repository,
new FakeCliToolEnvProfileRepository(),
userRepository,
userContextService);
}

private sealed class FakeUserContextService(string username) : IUserContextService
{
private string _username = username;

public string GetCurrentUsername() => _username;

public string GetCurrentRole() => "user";

public bool IsAuthenticated() => true;

public void SetCurrentUsername(string username)
{
_username = username;
}
}

private sealed class FakeCliToolEnvironmentVariableRepository : Repository<CliToolEnvironmentVariable>, ICliToolEnvironmentVariableRepository
{
private readonly Dictionary<string, Dictionary<string, string>> _storage;

public FakeCliToolEnvironmentVariableRepository(Dictionary<string, Dictionary<string, string>>? storage = null)
{
_storage = storage ?? new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
}

public Task<Dictionary<string, string>> GetEnvironmentVariablesByToolIdAsync(string toolId)
{
if (_storage.TryGetValue(toolId, out var envVars))
{
return Task.FromResult(new Dictionary<string, string>(envVars, StringComparer.OrdinalIgnoreCase));
}

return Task.FromResult(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase));
}

public Task<bool> SaveEnvironmentVariablesAsync(string toolId, Dictionary<string, string> envVars)
{
_storage[toolId] = new Dictionary<string, string>(envVars, StringComparer.OrdinalIgnoreCase);
return Task.FromResult(true);
}

public Task<bool> DeleteByToolIdAsync(string toolId)
{
_storage.Remove(toolId);
return Task.FromResult(true);
}
}

private sealed class FakeUserCliToolEnvironmentVariableRepository : Repository<UserCliToolEnvironmentVariableEntity>, IUserCliToolEnvironmentVariableRepository
{
private readonly Dictionary<string, Dictionary<string, Dictionary<string, string>>> _storage;

public FakeUserCliToolEnvironmentVariableRepository(Dictionary<string, Dictionary<string, Dictionary<string, string>>>? storage = null)
{
_storage = storage ?? new Dictionary<string, Dictionary<string, Dictionary<string, string>>>(StringComparer.OrdinalIgnoreCase);
}

public Task<Dictionary<string, string>> GetEnvironmentVariablesAsync(string username, string toolId)
{
if (_storage.TryGetValue(username, out var toolMap) &&
toolMap.TryGetValue(toolId, out var envVars))
{
return Task.FromResult(new Dictionary<string, string>(envVars, StringComparer.OrdinalIgnoreCase));
}

return Task.FromResult(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase));
}

public Task<bool> SaveEnvironmentVariablesAsync(string username, string toolId, Dictionary<string, string> envVars)
{
if (!_storage.TryGetValue(username, out var toolMap))
{
toolMap = new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
_storage[username] = toolMap;
}

toolMap[toolId] = new Dictionary<string, string>(envVars, StringComparer.OrdinalIgnoreCase);
return Task.FromResult(true);
}

public Task<bool> DeleteByToolIdAsync(string username, string toolId)
{
if (_storage.TryGetValue(username, out var toolMap))
{
toolMap.Remove(toolId);
if (toolMap.Count == 0)
{
_storage.Remove(username);
}
}

return Task.FromResult(true);
}
}

private sealed class FakeCliToolEnvProfileRepository : Repository<CliToolEnvProfile>, ICliToolEnvProfileRepository
{
public Task<List<CliToolEnvProfile>> GetProfilesByToolIdAsync(string toolId)
=> Task.FromResult(new List<CliToolEnvProfile>());

public Task<CliToolEnvProfile?> GetActiveProfileAsync(string toolId)
=> Task.FromResult<CliToolEnvProfile?>(null);

public Task<bool> ActivateProfileAsync(string toolId, int profileId)
=> Task.FromResult(true);

public Task<bool> DeactivateAllProfilesAsync(string toolId)
=> Task.FromResult(true);

public Task<bool> DeleteProfileAsync(string toolId, int profileId)
=> Task.FromResult(true);
}
}
Loading