diff --git a/README.md b/README.md index a3c2224..e97bbaf 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ WebCode 是一个基于 `Blazor Server + .NET 10` 的 AI 工作平台,目标 - 在 Web 端创建和管理 AI 会话 - 为不同会话绑定独立工作区 +- 发现并导入当前系统账户下已有的 `Claude Code`、`Codex`、`OpenCode` 会话 +- 基于原始 CLI transcript 恢复当前会话历史,而不只依赖 WebCode 自己记录的消息 - 在飞书中通过机器人和卡片完成会话、项目、目录等操作 - 为不同用户配置各自的 CLI 环境、飞书机器人、可访问目录和工具权限 - 在手机、平板和桌面浏览器中持续使用同一套工作流 @@ -50,6 +52,15 @@ WebCode 是一个基于 `Blazor Server + .NET 10` 的 AI 工作平台,目标 - 支持文件浏览、预览、上传、复制路径等工作区操作 - 支持历史会话切换、关闭、隔离和清理 - 可与项目管理联动,从项目直接创建会话 +- 支持导入当前系统账户下已有的外部 CLI 会话,并在 WebCode 中继续使用 +- 支持从原始 CLI transcript 读取会话历史,用于恢复和回放已有上下文 + +外部 CLI 会话导入当前具备这些约束: + +- 仅扫描当前操作系统账户下可访问的本地会话 +- 仅显示工作区位于允许目录/白名单中的会话 +- 同一个 `(ToolId, CliThreadId)` 只能被一个 WebCode 用户占用 +- Web 端与飞书端都支持分页浏览、导入并切换到这些会话 ### 3. 多用户与权限控制 @@ -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) @@ -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` 配置,请以实际监听端口为准。 ## 首次初始化建议 @@ -155,6 +176,7 @@ dotnet run --project WebCodeCli 4. 验证工作区根目录和存储目录 5. 如需飞书集成,再配置飞书机器人参数 6. 如需多用户,进入管理界面配置用户、目录白名单和工具权限 +7. 如需恢复现有 CLI 工作上下文,可在 Web 端或飞书端导入本地外部会话 初始化向导界面示意: @@ -222,6 +244,7 @@ CLI 工具配置支持通过界面和配置文件两种方式维护。 - 为每个用户设置独立的白名单目录 - 为每个用户限制可用 CLI 工具 - 为每个用户配置自己的飞书机器人 +- 为需要接续工作的用户开放“导入外部 CLI 会话”能力,但仍限制在各自白名单目录内 - 避免把数据库文件提交到 Git - 明确区分“共享默认配置”和“用户覆盖配置” diff --git a/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs b/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs index 8a46e0c..e1414c9 100644 --- a/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs +++ b/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs @@ -1,15 +1,100 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using SqlSugar; using WebCodeCli.Domain.Common.Options; using WebCodeCli.Domain.Domain.Model; using WebCodeCli.Domain.Domain.Service; using WebCodeCli.Domain.Domain.Service.Adapters; +using WebCodeCli.Domain.Model; +using WebCodeCli.Domain.Repositories.Base.ChatSession; namespace WebCodeCli.Domain.Tests; public class CliExecutorServiceTests { + [Fact] + public void GetCliThreadId_WhenPersistedThreadIdMissing_RecoversImportedCodexThreadIdFromTitle() + { + const string sessionId = "session-imported"; + const string cliThreadId = "019d1338-0c3f-7eb3-ae2b-e4617eb7d24e"; + + var repository = new StubChatSessionRepository( + [ + new ChatSessionEntity + { + SessionId = sessionId, + Username = "luhaiyan", + ToolId = "codex", + Title = $"[Codex] {cliThreadId}", + WorkspacePath = @"D:\VSWorkshop\OpenDify", + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now + } + ]); + var serviceProvider = new NullServiceProvider( + repository, + new StubSessionOutputService()); + + var service = new CliExecutorService( + NullLogger.Instance, + Options.Create(new CliToolsOption + { + TempWorkspaceRoot = Path.Combine(Path.GetTempPath(), "WebCodeCli.Tests", Guid.NewGuid().ToString("N")), + Tools = [] + }), + NullLogger.Instance, + serviceProvider, + new StubChatSessionService(), + new StubCliAdapterFactory()); + + var resolvedThreadId = service.GetCliThreadId(sessionId); + + Assert.Equal(cliThreadId, resolvedThreadId); + Assert.Equal(cliThreadId, repository.LastUpdatedCliThreadId); + Assert.Equal(cliThreadId, repository.GetById(sessionId).CliThreadId); + } + + [Fact] + public void GetCliThreadId_WhenImportedTitleIsNotARealThreadId_DoesNotRecoverWrongValue() + { + const string sessionId = "session-imported-friendly-title"; + + var repository = new StubChatSessionRepository( + [ + new ChatSessionEntity + { + SessionId = sessionId, + Username = "luhaiyan", + ToolId = "codex", + Title = "[Codex] 修复 OpenDify 登录问题", + WorkspacePath = @"D:\VSWorkshop\OpenDify", + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now + } + ]); + var serviceProvider = new NullServiceProvider( + repository, + new StubSessionOutputService()); + + var service = new CliExecutorService( + NullLogger.Instance, + Options.Create(new CliToolsOption + { + TempWorkspaceRoot = Path.Combine(Path.GetTempPath(), "WebCodeCli.Tests", Guid.NewGuid().ToString("N")), + Tools = [] + }), + NullLogger.Instance, + serviceProvider, + new StubChatSessionService(), + new StubCliAdapterFactory()); + + var resolvedThreadId = service.GetCliThreadId(sessionId); + + Assert.Null(resolvedThreadId); + Assert.Null(repository.LastUpdatedCliThreadId); + } + [Fact] public async Task ExecuteStreamAsync_WhenProcessTimesOut_ReturnsTimeoutChunkInsteadOfThrowing() { @@ -71,7 +156,24 @@ private sealed class StubCliAdapterFactory : ICliAdapterFactory public IEnumerable GetAllAdapters() => []; } - private sealed class NullServiceProvider : IServiceProvider, IServiceScopeFactory, IServiceScope + private sealed class StubSessionOutputService : ISessionOutputService + { + public Task GetBySessionIdAsync(string sessionId) + => Task.FromResult(new OutputPanelState + { + SessionId = sessionId, + ActiveThreadId = string.Empty + }); + + public Task SaveAsync(OutputPanelState state) => Task.FromResult(true); + + public Task DeleteBySessionIdAsync(string sessionId) => Task.FromResult(true); + } + + private sealed class NullServiceProvider( + IChatSessionRepository? chatSessionRepository = null, + ISessionOutputService? sessionOutputService = null) + : IServiceProvider, IServiceScopeFactory, IServiceScope { public object? GetService(Type serviceType) { @@ -80,6 +182,16 @@ private sealed class NullServiceProvider : IServiceProvider, IServiceScopeFactor return this; } + if (serviceType == typeof(IChatSessionRepository)) + { + return chatSessionRepository; + } + + if (serviceType == typeof(ISessionOutputService)) + { + return sessionOutputService; + } + return null; } @@ -91,4 +203,87 @@ public void Dispose() { } } + + private sealed class StubChatSessionRepository(IEnumerable sessions) : IChatSessionRepository + { + private readonly List _sessions = sessions.ToList(); + + public string? LastUpdatedCliThreadId { get; private set; } + + public SqlSugarScope GetDB() => throw new NotSupportedException(); + public List GetList() => _sessions.ToList(); + public Task> GetListAsync() => Task.FromResult(GetList()); + public List GetList(System.Linq.Expressions.Expression> whereExpression) => _sessions.AsQueryable().Where(whereExpression).ToList(); + public Task> GetListAsync(System.Linq.Expressions.Expression> whereExpression) => Task.FromResult(GetList(whereExpression)); + public int Count(System.Linq.Expressions.Expression> whereExpression) => _sessions.AsQueryable().Count(whereExpression); + public Task CountAsync(System.Linq.Expressions.Expression> whereExpression) => Task.FromResult(Count(whereExpression)); + public PageList GetPageList(System.Linq.Expressions.Expression> whereExpression, PageModel page) => throw new NotSupportedException(); + public PageList

GetPageList

(System.Linq.Expressions.Expression> whereExpression, PageModel page) => throw new NotSupportedException(); + public Task> GetPageListAsync(System.Linq.Expressions.Expression> whereExpression, PageModel page) => throw new NotSupportedException(); + public Task> GetPageListAsync

(System.Linq.Expressions.Expression> whereExpression, PageModel page) => throw new NotSupportedException(); + public PageList GetPageList(System.Linq.Expressions.Expression> whereExpression, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, OrderByType orderByType = OrderByType.Asc) => throw new NotSupportedException(); + public Task> GetPageListAsync(System.Linq.Expressions.Expression> whereExpression, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, OrderByType orderByType = OrderByType.Asc) => throw new NotSupportedException(); + public PageList

GetPageList

(System.Linq.Expressions.Expression> whereExpression, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, OrderByType orderByType = OrderByType.Asc) => throw new NotSupportedException(); + public Task> GetPageListAsync

(System.Linq.Expressions.Expression> whereExpression, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, OrderByType orderByType = OrderByType.Asc) => throw new NotSupportedException(); + public PageList GetPageList(List conditionalList, PageModel page) => throw new NotSupportedException(); + public Task> GetPageListAsync(List conditionalList, PageModel page) => throw new NotSupportedException(); + public PageList GetPageList(List conditionalList, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, OrderByType orderByType = OrderByType.Asc) => throw new NotSupportedException(); + public Task> GetPageListAsync(List conditionalList, PageModel page, System.Linq.Expressions.Expression> orderByExpression = null, OrderByType orderByType = OrderByType.Asc) => throw new NotSupportedException(); + public ChatSessionEntity GetById(dynamic id) => _sessions.First(x => string.Equals(x.SessionId, id?.ToString(), StringComparison.OrdinalIgnoreCase)); + public Task GetByIdAsync(dynamic id) => Task.FromResult(GetById(id)); + public ChatSessionEntity GetSingle(System.Linq.Expressions.Expression> whereExpression) => _sessions.AsQueryable().Single(whereExpression); + public Task GetSingleAsync(System.Linq.Expressions.Expression> whereExpression) => Task.FromResult(GetSingle(whereExpression)); + public ChatSessionEntity GetFirst(System.Linq.Expressions.Expression> whereExpression) => _sessions.AsQueryable().First(whereExpression); + public Task GetFirstAsync(System.Linq.Expressions.Expression> whereExpression) => Task.FromResult(GetFirst(whereExpression)); + public bool Insert(ChatSessionEntity obj) { _sessions.Add(obj); return true; } + public Task InsertAsync(ChatSessionEntity obj) => Task.FromResult(Insert(obj)); + public bool InsertRange(List objs) { _sessions.AddRange(objs); return true; } + public Task InsertRangeAsync(List objs) => Task.FromResult(InsertRange(objs)); + public int InsertReturnIdentity(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task InsertReturnIdentityAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public long InsertReturnBigIdentity(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task InsertReturnBigIdentityAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public bool DeleteByIds(dynamic[] ids) => throw new NotSupportedException(); + public Task DeleteByIdsAsync(dynamic[] ids) => throw new NotSupportedException(); + public bool Delete(dynamic id) => throw new NotSupportedException(); + public Task DeleteAsync(dynamic id) => throw new NotSupportedException(); + public bool Delete(ChatSessionEntity obj) => _sessions.Remove(obj); + public Task DeleteAsync(ChatSessionEntity obj) => Task.FromResult(Delete(obj)); + public bool Delete(System.Linq.Expressions.Expression> whereExpression) => throw new NotSupportedException(); + public Task DeleteAsync(System.Linq.Expressions.Expression> whereExpression) => throw new NotSupportedException(); + public bool Update(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task UpdateAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public bool UpdateRange(List objs) => throw new NotSupportedException(); + public bool InsertOrUpdate(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task InsertOrUpdateAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task UpdateRangeAsync(List objs) => throw new NotSupportedException(); + public bool IsAny(System.Linq.Expressions.Expression> whereExpression) => _sessions.AsQueryable().Any(whereExpression); + public Task IsAnyAsync(System.Linq.Expressions.Expression> whereExpression) => Task.FromResult(IsAny(whereExpression)); + public Task> GetByUsernameAsync(string username) => Task.FromResult(_sessions.Where(x => string.Equals(x.Username, username, StringComparison.OrdinalIgnoreCase)).ToList()); + public Task GetByIdAndUsernameAsync(string sessionId, string username) => Task.FromResult(_sessions.FirstOrDefault(x => string.Equals(x.SessionId, sessionId, StringComparison.OrdinalIgnoreCase) && string.Equals(x.Username, username, StringComparison.OrdinalIgnoreCase))); + public Task DeleteByIdAndUsernameAsync(string sessionId, string username) => Task.FromResult(_sessions.RemoveAll(x => string.Equals(x.SessionId, sessionId, StringComparison.OrdinalIgnoreCase) && string.Equals(x.Username, username, StringComparison.OrdinalIgnoreCase)) > 0); + public Task> GetByUsernameOrderByUpdatedAtAsync(string username) => Task.FromResult(_sessions.Where(x => string.Equals(x.Username, username, StringComparison.OrdinalIgnoreCase)).OrderByDescending(x => x.UpdatedAt).ToList()); + public Task GetByUsernameToolAndCliThreadIdAsync(string username, string toolId, string cliThreadId) => Task.FromResult(_sessions.FirstOrDefault(x => string.Equals(x.Username, username, StringComparison.OrdinalIgnoreCase) && string.Equals(x.ToolId, toolId, StringComparison.OrdinalIgnoreCase) && string.Equals(x.CliThreadId, cliThreadId, StringComparison.OrdinalIgnoreCase))); + public Task GetByToolAndCliThreadIdAsync(string toolId, string cliThreadId) => Task.FromResult(_sessions.FirstOrDefault(x => string.Equals(x.ToolId, toolId, StringComparison.OrdinalIgnoreCase) && string.Equals(x.CliThreadId, cliThreadId, StringComparison.OrdinalIgnoreCase))); + public Task UpdateCliThreadIdAsync(string sessionId, string cliThreadId) + { + var session = _sessions.FirstOrDefault(x => string.Equals(x.SessionId, sessionId, StringComparison.OrdinalIgnoreCase)); + if (session == null) + { + return Task.FromResult(false); + } + + session.CliThreadId = cliThreadId; + session.UpdatedAt = DateTime.Now; + LastUpdatedCliThreadId = cliThreadId; + return Task.FromResult(true); + } + + public Task UpdateWorkspaceBindingAsync(string sessionId, string? workspacePath, bool isCustomWorkspace) => Task.FromResult(true); + public Task> GetByFeishuChatKeyAsync(string feishuChatKey) => Task.FromResult(new List()); + public Task GetActiveByFeishuChatKeyAsync(string feishuChatKey) => Task.FromResult(null); + public Task SetActiveSessionAsync(string feishuChatKey, string sessionId) => Task.FromResult(true); + public Task CloseFeishuSessionAsync(string feishuChatKey, string sessionId) => Task.FromResult(true); + public Task CreateFeishuSessionAsync(string feishuChatKey, string username, string? workspacePath = null, string? toolId = null) => Task.FromResult(Guid.NewGuid().ToString("N")); + } } diff --git a/WebCodeCli.Domain.Tests/CliToolEnvironmentServiceTests.cs b/WebCodeCli.Domain.Tests/CliToolEnvironmentServiceTests.cs new file mode 100644 index 0000000..3da9315 --- /dev/null +++ b/WebCodeCli.Domain.Tests/CliToolEnvironmentServiceTests.cs @@ -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 + { + ["BASE_KEY"] = "base-value", + ["REMOVE_ME"] = "tool-value" + } + } + ] + }, + new FakeCliToolEnvironmentVariableRepository(new Dictionary> + { + [toolId] = new(StringComparer.OrdinalIgnoreCase) + { + ["REMOVE_ME"] = "shared-value", + ["SHARED_KEY"] = "shared-only" + } + }), + new FakeUserCliToolEnvironmentVariableRepository(new Dictionary>>(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> + { + [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 + { + ["DEFAULT_KEY"] = "default-value", + ["KEEP_KEY"] = "default-keep" + } + } + ] + }, + sharedRepository, + userRepository, + new FakeUserContextService(username)); + + var success = await service.SaveEnvironmentVariablesAsync(toolId, new Dictionary + { + ["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>>(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.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, ICliToolEnvironmentVariableRepository + { + private readonly Dictionary> _storage; + + public FakeCliToolEnvironmentVariableRepository(Dictionary>? storage = null) + { + _storage = storage ?? new Dictionary>(StringComparer.OrdinalIgnoreCase); + } + + public Task> GetEnvironmentVariablesByToolIdAsync(string toolId) + { + if (_storage.TryGetValue(toolId, out var envVars)) + { + return Task.FromResult(new Dictionary(envVars, StringComparer.OrdinalIgnoreCase)); + } + + return Task.FromResult(new Dictionary(StringComparer.OrdinalIgnoreCase)); + } + + public Task SaveEnvironmentVariablesAsync(string toolId, Dictionary envVars) + { + _storage[toolId] = new Dictionary(envVars, StringComparer.OrdinalIgnoreCase); + return Task.FromResult(true); + } + + public Task DeleteByToolIdAsync(string toolId) + { + _storage.Remove(toolId); + return Task.FromResult(true); + } + } + + private sealed class FakeUserCliToolEnvironmentVariableRepository : Repository, IUserCliToolEnvironmentVariableRepository + { + private readonly Dictionary>> _storage; + + public FakeUserCliToolEnvironmentVariableRepository(Dictionary>>? storage = null) + { + _storage = storage ?? new Dictionary>>(StringComparer.OrdinalIgnoreCase); + } + + public Task> GetEnvironmentVariablesAsync(string username, string toolId) + { + if (_storage.TryGetValue(username, out var toolMap) && + toolMap.TryGetValue(toolId, out var envVars)) + { + return Task.FromResult(new Dictionary(envVars, StringComparer.OrdinalIgnoreCase)); + } + + return Task.FromResult(new Dictionary(StringComparer.OrdinalIgnoreCase)); + } + + public Task SaveEnvironmentVariablesAsync(string username, string toolId, Dictionary envVars) + { + if (!_storage.TryGetValue(username, out var toolMap)) + { + toolMap = new Dictionary>(StringComparer.OrdinalIgnoreCase); + _storage[username] = toolMap; + } + + toolMap[toolId] = new Dictionary(envVars, StringComparer.OrdinalIgnoreCase); + return Task.FromResult(true); + } + + public Task 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, ICliToolEnvProfileRepository + { + public Task> GetProfilesByToolIdAsync(string toolId) + => Task.FromResult(new List()); + + public Task GetActiveProfileAsync(string toolId) + => Task.FromResult(null); + + public Task ActivateProfileAsync(string toolId, int profileId) + => Task.FromResult(true); + + public Task DeactivateAllProfilesAsync(string toolId) + => Task.FromResult(true); + + public Task DeleteProfileAsync(string toolId, int profileId) + => Task.FromResult(true); + } +} diff --git a/WebCodeCli.Domain.Tests/ExternalCliSessionHistoryServiceTests.cs b/WebCodeCli.Domain.Tests/ExternalCliSessionHistoryServiceTests.cs new file mode 100644 index 0000000..f169c77 --- /dev/null +++ b/WebCodeCli.Domain.Tests/ExternalCliSessionHistoryServiceTests.cs @@ -0,0 +1,318 @@ +using Microsoft.Extensions.Logging.Abstractions; +using WebCodeCli.Domain.Domain.Service; + +namespace WebCodeCli.Domain.Tests; + +public class ExternalCliSessionHistoryServiceTests +{ + [Fact] + public async Task GetRecentMessagesAsync_ForCodex_ReadsUserAndAssistantMessagesFromRolloutFile() + { + using var sandbox = new HistoryTestSandbox(); + var rolloutPath = sandbox.WriteFile( + Path.Combine("codex", "rollout-codex-thread-1.jsonl"), + """ + {"timestamp":"2026-03-23T01:00:00Z","type":"session_meta","payload":{"id":"codex-thread-1","cwd":"D:\\repo"}} + {"timestamp":"2026-03-23T01:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"用户提问"}]}} + {"timestamp":"2026-03-23T01:00:02Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"第一行"},{"type":"output_text","text":"第二行"}]}} + {"timestamp":"2026-03-23T01:00:03Z","type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"忽略我"}]}} + """); + + var service = new TestExternalCliSessionHistoryService( + codexSessionsRootPath: Path.GetDirectoryName(rolloutPath)!); + + var messages = await service.GetRecentMessagesAsync("codex", "codex-thread-1"); + + Assert.Collection( + messages, + message => + { + Assert.Equal("user", message.Role); + Assert.Equal("用户提问", message.Content); + }, + message => + { + Assert.Equal("assistant", message.Role); + Assert.Equal("第一行\n第二行", message.Content); + }); + } + + [Fact] + public async Task GetRecentMessagesAsync_ForCodex_CanReadRolloutFileWhileItIsOpenedForWriting() + { + using var sandbox = new HistoryTestSandbox(); + var rolloutPath = sandbox.WriteFile( + Path.Combine("codex", "rollout-codex-thread-locked.jsonl"), + """ + {"timestamp":"2026-03-23T01:00:00Z","type":"session_meta","payload":{"id":"codex-thread-locked","cwd":"D:\\repo"}} + {"timestamp":"2026-03-23T01:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"用户提问"}]}} + {"timestamp":"2026-03-23T01:00:02Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"助手回复"}]}} + """); + + using var lockStream = new FileStream( + rolloutPath, + FileMode.Open, + FileAccess.ReadWrite, + FileShare.ReadWrite); + + var service = new TestExternalCliSessionHistoryService( + codexSessionsRootPath: Path.GetDirectoryName(rolloutPath)!); + + var messages = await service.GetRecentMessagesAsync("codex", "codex-thread-locked"); + + Assert.Collection( + messages, + message => + { + Assert.Equal("user", message.Role); + Assert.Equal("用户提问", message.Content); + }, + message => + { + Assert.Equal("assistant", message.Role); + Assert.Equal("助手回复", message.Content); + }); + } + + [Fact] + public async Task GetRecentMessagesAsync_ForClaudeCode_ReadsMessagesFromTranscriptFile() + { + using var sandbox = new HistoryTestSandbox(); + var projectsRoot = sandbox.CreateDirectory(Path.Combine("claude", "projects")); + var transcriptPath = sandbox.WriteFile( + Path.Combine("claude", "projects", "sample-project", "claude-session-1.jsonl"), + """ + {"type":"progress","timestamp":"2026-03-23T01:00:00Z","sessionId":"claude-session-1","cwd":"D:\\repo"} + {"type":"user","timestamp":"2026-03-23T01:00:01Z","sessionId":"claude-session-1","message":{"role":"user","content":"用户问题"}} + {"type":"assistant","timestamp":"2026-03-23T01:00:02Z","sessionId":"claude-session-1","message":{"type":"message","role":"assistant","content":[{"type":"thinking","thinking":"跳过思考"},{"type":"text","text":"助手回复"}]}} + {"type":"assistant","timestamp":"2026-03-23T01:00:03Z","sessionId":"claude-session-1","message":{"type":"message","role":"assistant","content":[{"type":"tool_use","name":"Read"}]}} + """); + + sandbox.WriteFile( + Path.Combine("claude", "projects", "sample-project", "sessions-index.json"), + $$""" + { + "version": 1, + "entries": [ + { + "sessionId": "claude-session-1", + "fullPath": "{{transcriptPath.Replace("\\", "\\\\")}}", + "projectPath": "D:\\repo", + "modified": "2026-03-23T01:00:03Z" + } + ] + } + """); + + var service = new TestExternalCliSessionHistoryService( + claudeProjectsRootPath: projectsRoot); + + var messages = await service.GetRecentMessagesAsync("claude-code", "claude-session-1"); + + Assert.Collection( + messages, + message => + { + Assert.Equal("user", message.Role); + Assert.Equal("用户问题", message.Content); + }, + message => + { + Assert.Equal("assistant", message.Role); + Assert.Equal("助手回复", message.Content); + }); + } + + [Fact] + public async Task GetRecentMessagesAsync_ForClaudeCode_CanReadTranscriptWhenSessionsIndexFileIsOpenedForWriting() + { + using var sandbox = new HistoryTestSandbox(); + var projectsRoot = sandbox.CreateDirectory(Path.Combine("claude", "projects")); + var transcriptPath = sandbox.WriteFile( + Path.Combine("claude", "projects", "sample-project", "claude-session-lock.jsonl"), + """ + {"type":"user","timestamp":"2026-03-23T01:00:01Z","sessionId":"claude-session-lock","message":{"role":"user","content":"用户问题"}} + {"type":"assistant","timestamp":"2026-03-23T01:00:02Z","sessionId":"claude-session-lock","message":{"type":"message","role":"assistant","content":[{"type":"text","text":"助手回复"}]}} + """); + + var indexPath = sandbox.WriteFile( + Path.Combine("claude", "projects", "sample-project", "sessions-index.json"), + $$""" + { + "version": 1, + "entries": [ + { + "sessionId": "claude-session-lock", + "fullPath": "{{transcriptPath.Replace("\\", "\\\\")}}", + "projectPath": "D:\\repo", + "modified": "2026-03-23T01:00:03Z" + } + ] + } + """); + + using var lockStream = new FileStream( + indexPath, + FileMode.Open, + FileAccess.ReadWrite, + FileShare.ReadWrite); + + var service = new TestExternalCliSessionHistoryService( + claudeProjectsRootPath: projectsRoot); + + var messages = await service.GetRecentMessagesAsync("claude-code", "claude-session-lock"); + + Assert.Collection( + messages, + message => + { + Assert.Equal("user", message.Role); + Assert.Equal("用户问题", message.Content); + }, + message => + { + Assert.Equal("assistant", message.Role); + Assert.Equal("助手回复", message.Content); + }); + } + + [Fact] + public async Task GetRecentMessagesAsync_ForOpenCode_ParsesExportedSessionJson() + { + const string exportedJson = + """ + { + "info": { + "id": "ses_test_1" + }, + "messages": [ + { + "info": { + "role": "user", + "time": { + "created": 1768196424497 + } + }, + "parts": [ + { + "type": "text", + "text": "你好" + } + ] + }, + { + "info": { + "role": "assistant", + "time": { + "created": 1768196424512 + } + }, + "parts": [ + { + "type": "reasoning", + "text": "跳过思考" + }, + { + "type": "text", + "text": "世界" + } + ] + } + ] + } + """; + + var service = new TestExternalCliSessionHistoryService( + processHandler: (fileName, arguments, _) => + { + Assert.Equal("opencode", fileName); + Assert.Contains("export ses_test_1", arguments, StringComparison.Ordinal); + return Task.FromResult((0, exportedJson, string.Empty)); + }); + + var messages = await service.GetRecentMessagesAsync("opencode", "ses_test_1"); + + Assert.Collection( + messages, + message => + { + Assert.Equal("user", message.Role); + Assert.Equal("你好", message.Content); + }, + message => + { + Assert.Equal("assistant", message.Role); + Assert.Equal("世界", message.Content); + }); + } + + private sealed class TestExternalCliSessionHistoryService : ExternalCliSessionHistoryService + { + private readonly string? _codexSessionsRootPath; + private readonly string? _claudeProjectsRootPath; + private readonly Func>? _processHandler; + + public TestExternalCliSessionHistoryService( + string? codexSessionsRootPath = null, + string? claudeProjectsRootPath = null, + Func>? processHandler = null) + : base(NullLogger.Instance) + { + _codexSessionsRootPath = codexSessionsRootPath; + _claudeProjectsRootPath = claudeProjectsRootPath; + _processHandler = processHandler; + } + + protected override string? GetCodexSessionsRootPath() => _codexSessionsRootPath; + + protected override string? GetClaudeProjectsRootPath() => _claudeProjectsRootPath; + + protected override Task<(int ExitCode, string Stdout, string Stderr)> RunProcessAsync( + string fileName, + string arguments, + TimeSpan timeout, + CancellationToken cancellationToken) + { + if (_processHandler == null) + { + throw new NotSupportedException("Test process handler was not configured."); + } + + return _processHandler(fileName, arguments, cancellationToken); + } + } + + private sealed class HistoryTestSandbox : IDisposable + { + public HistoryTestSandbox() + { + RootPath = Path.Combine(Path.GetTempPath(), "WebCodeCli.Domain.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(RootPath); + } + + public string RootPath { get; } + + public string CreateDirectory(string relativePath) + { + var fullPath = Path.Combine(RootPath, relativePath); + Directory.CreateDirectory(fullPath); + return fullPath; + } + + public string WriteFile(string relativePath, string content) + { + var fullPath = Path.Combine(RootPath, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, content.Replace("\r\n", "\n")); + return fullPath; + } + + public void Dispose() + { + if (Directory.Exists(RootPath)) + { + Directory.Delete(RootPath, recursive: true); + } + } + } +} diff --git a/WebCodeCli.Domain.Tests/ExternalCliSessionServiceTests.cs b/WebCodeCli.Domain.Tests/ExternalCliSessionServiceTests.cs new file mode 100644 index 0000000..9d76b2c --- /dev/null +++ b/WebCodeCli.Domain.Tests/ExternalCliSessionServiceTests.cs @@ -0,0 +1,218 @@ +using System.Linq.Expressions; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using SqlSugar; +using WebCodeCli.Domain.Domain.Model; +using WebCodeCli.Domain.Domain.Service; +using WebCodeCli.Domain.Model; +using WebCodeCli.Domain.Repositories.Base.ChatSession; + +namespace WebCodeCli.Domain.Tests; + +public class ExternalCliSessionServiceTests +{ + [Fact] + public async Task DiscoverAsync_ForClaudeCode_MergesJsonlWhenIndexExists_AndKeepsLatestSessionVisible() + { + using var sandbox = new ExternalCliSessionSandbox(); + + var workspaceRoot = sandbox.CreateDirectory("workspaces"); + var indexedWorkspace = sandbox.CreateDirectory(Path.Combine("workspaces", "indexed")); + var latestWorkspace = sandbox.CreateDirectory(Path.Combine("workspaces", "latest")); + var userProfile = sandbox.CreateDirectory("userprofile"); + + var indexedProjectPath = sandbox.CreateDirectory(Path.Combine("userprofile", ".claude", "projects", "indexed-project")); + var indexedSessionId = Guid.NewGuid().ToString(); + sandbox.WriteFile( + Path.Combine("userprofile", ".claude", "projects", "indexed-project", "sessions-index.json"), + JsonSerializer.Serialize(new + { + version = 1, + entries = new[] + { + new + { + sessionId = indexedSessionId, + projectPath = indexedWorkspace, + modified = "2026-03-23T00:01:00Z" + } + } + })); + + var latestSessionId = Guid.NewGuid().ToString(); + var latestTranscriptPath = sandbox.WriteFile( + Path.Combine("userprofile", ".claude", "projects", "latest-project", $"{latestSessionId}.jsonl"), + string.Join( + "\n", + JsonSerializer.Serialize(new + { + type = "queue-operation", + operation = "enqueue", + timestamp = "2026-03-23T01:41:25.091Z", + sessionId = latestSessionId, + content = "test" + }), + JsonSerializer.Serialize(new + { + type = "progress", + timestamp = "2026-03-23T01:40:49.184Z", + sessionId = latestSessionId, + cwd = latestWorkspace + }))); + + File.SetLastWriteTime(latestTranscriptPath, new DateTime(2026, 3, 23, 10, 6, 33)); + Directory.SetLastWriteTime(Path.GetDirectoryName(latestTranscriptPath)!, new DateTime(2026, 3, 23, 9, 54, 45)); + Directory.SetLastWriteTime(indexedProjectPath, new DateTime(2026, 3, 23, 8, 0, 0)); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Workspace:AllowedRoots:0"] = workspaceRoot + }) + .Build(); + + var service = new TestExternalCliSessionService( + userProfile, + new StubChatSessionRepository([]), + new StubUserWorkspacePolicyService(), + configuration); + + var discovered = await service.DiscoverAsync("luhaiyan", "claude-code", maxCount: 100); + + Assert.Equal(2, discovered.Count); + Assert.Equal(latestSessionId, discovered[0].CliThreadId); + Assert.Equal(latestWorkspace, discovered[0].WorkspacePath); + Assert.Equal(indexedSessionId, discovered[1].CliThreadId); + Assert.Equal(indexedWorkspace, discovered[1].WorkspacePath); + } + + private sealed class TestExternalCliSessionService( + string userProfilePath, + IChatSessionRepository chatSessionRepository, + IUserWorkspacePolicyService userWorkspacePolicyService, + IConfiguration configuration) + : ExternalCliSessionService( + NullLogger.Instance, + chatSessionRepository, + userWorkspacePolicyService, + configuration) + { + protected override string? GetUserProfilePath() => userProfilePath; + } + + private sealed class StubUserWorkspacePolicyService : IUserWorkspacePolicyService + { + public Task> GetAllowedDirectoriesAsync(string username) + => Task.FromResult(new List()); + + public Task IsPathAllowedAsync(string username, string directoryPath) + => Task.FromResult(true); + + public Task SaveAllowedDirectoriesAsync(string username, IEnumerable allowedDirectories) + => Task.FromResult(true); + } + + private sealed class StubChatSessionRepository(IEnumerable sessions) : IChatSessionRepository + { + private readonly List _sessions = sessions.ToList(); + + public SqlSugarScope GetDB() => throw new NotSupportedException(); + public List GetList() => _sessions.ToList(); + public Task> GetListAsync() => Task.FromResult(GetList()); + public List GetList(Expression> whereExpression) => _sessions.AsQueryable().Where(whereExpression).ToList(); + public Task> GetListAsync(Expression> whereExpression) => Task.FromResult(GetList(whereExpression)); + public int Count(Expression> whereExpression) => _sessions.AsQueryable().Count(whereExpression); + public Task CountAsync(Expression> whereExpression) => Task.FromResult(Count(whereExpression)); + public PageList GetPageList(Expression> whereExpression, PageModel page) => throw new NotSupportedException(); + public PageList

GetPageList

(Expression> whereExpression, PageModel page) => throw new NotSupportedException(); + public Task> GetPageListAsync(Expression> whereExpression, PageModel page) => throw new NotSupportedException(); + public Task> GetPageListAsync

(Expression> whereExpression, PageModel page) => throw new NotSupportedException(); + public PageList GetPageList(Expression> whereExpression, PageModel page, Expression> orderByExpression = null, OrderByType orderByType = OrderByType.Asc) => throw new NotSupportedException(); + public Task> GetPageListAsync(Expression> whereExpression, PageModel page, Expression> orderByExpression = null, OrderByType orderByType = OrderByType.Asc) => throw new NotSupportedException(); + public PageList

GetPageList

(Expression> whereExpression, PageModel page, Expression> orderByExpression = null, OrderByType orderByType = OrderByType.Asc) => throw new NotSupportedException(); + public Task> GetPageListAsync

(Expression> whereExpression, PageModel page, Expression> orderByExpression = null, OrderByType orderByType = OrderByType.Asc) => throw new NotSupportedException(); + public PageList GetPageList(List conditionalList, PageModel page) => throw new NotSupportedException(); + public Task> GetPageListAsync(List conditionalList, PageModel page) => throw new NotSupportedException(); + public PageList GetPageList(List conditionalList, PageModel page, Expression> orderByExpression = null, OrderByType orderByType = OrderByType.Asc) => throw new NotSupportedException(); + public Task> GetPageListAsync(List conditionalList, PageModel page, Expression> orderByExpression = null, OrderByType orderByType = OrderByType.Asc) => throw new NotSupportedException(); + public ChatSessionEntity GetById(dynamic id) => _sessions.First(x => string.Equals(x.SessionId, id?.ToString(), StringComparison.OrdinalIgnoreCase)); + public Task GetByIdAsync(dynamic id) => Task.FromResult(_sessions.FirstOrDefault(x => string.Equals(x.SessionId, id?.ToString(), StringComparison.OrdinalIgnoreCase)))!; + public ChatSessionEntity GetSingle(Expression> whereExpression) => _sessions.AsQueryable().Single(whereExpression); + public Task GetSingleAsync(Expression> whereExpression) => Task.FromResult(GetSingle(whereExpression)); + public ChatSessionEntity GetFirst(Expression> whereExpression) => _sessions.AsQueryable().First(whereExpression); + public Task GetFirstAsync(Expression> whereExpression) => Task.FromResult(GetFirst(whereExpression)); + public bool Insert(ChatSessionEntity obj) { _sessions.Add(obj); return true; } + public Task InsertAsync(ChatSessionEntity obj) => Task.FromResult(Insert(obj)); + public bool InsertRange(List objs) { _sessions.AddRange(objs); return true; } + public Task InsertRangeAsync(List objs) => Task.FromResult(InsertRange(objs)); + public int InsertReturnIdentity(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task InsertReturnIdentityAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public long InsertReturnBigIdentity(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task InsertReturnBigIdentityAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public bool DeleteByIds(dynamic[] ids) => throw new NotSupportedException(); + public Task DeleteByIdsAsync(dynamic[] ids) => throw new NotSupportedException(); + public bool Delete(dynamic id) => throw new NotSupportedException(); + public Task DeleteAsync(dynamic id) => throw new NotSupportedException(); + public bool Delete(ChatSessionEntity obj) => _sessions.Remove(obj); + public Task DeleteAsync(ChatSessionEntity obj) => Task.FromResult(Delete(obj)); + public bool Delete(Expression> whereExpression) => throw new NotSupportedException(); + public Task DeleteAsync(Expression> whereExpression) => throw new NotSupportedException(); + public bool Update(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task UpdateAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public bool UpdateRange(List objs) => throw new NotSupportedException(); + public bool InsertOrUpdate(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task InsertOrUpdateAsync(ChatSessionEntity obj) => throw new NotSupportedException(); + public Task UpdateRangeAsync(List objs) => throw new NotSupportedException(); + public bool IsAny(Expression> whereExpression) => _sessions.AsQueryable().Any(whereExpression); + public Task IsAnyAsync(Expression> whereExpression) => Task.FromResult(IsAny(whereExpression)); + public List GetByUsername(string username) => _sessions.Where(x => string.Equals(x.Username, username, StringComparison.OrdinalIgnoreCase)).ToList(); + public Task> GetByUsernameAsync(string username) => Task.FromResult(GetByUsername(username)); + public Task GetByIdAndUsernameAsync(string sessionId, string username) => Task.FromResult(_sessions.FirstOrDefault(x => string.Equals(x.SessionId, sessionId, StringComparison.OrdinalIgnoreCase) && string.Equals(x.Username, username, StringComparison.OrdinalIgnoreCase))); + public Task DeleteByIdAndUsernameAsync(string sessionId, string username) => Task.FromResult(_sessions.RemoveAll(x => string.Equals(x.SessionId, sessionId, StringComparison.OrdinalIgnoreCase) && string.Equals(x.Username, username, StringComparison.OrdinalIgnoreCase)) > 0); + public Task> GetByUsernameOrderByUpdatedAtAsync(string username) => Task.FromResult(_sessions.Where(x => string.Equals(x.Username, username, StringComparison.OrdinalIgnoreCase)).OrderByDescending(x => x.UpdatedAt).ToList()); + public Task GetByUsernameToolAndCliThreadIdAsync(string username, string toolId, string cliThreadId) => Task.FromResult(_sessions.FirstOrDefault(x => string.Equals(x.Username, username, StringComparison.OrdinalIgnoreCase) && string.Equals(x.ToolId, toolId, StringComparison.OrdinalIgnoreCase) && string.Equals(x.CliThreadId, cliThreadId, StringComparison.OrdinalIgnoreCase))); + public Task GetByToolAndCliThreadIdAsync(string toolId, string cliThreadId) => Task.FromResult(_sessions.FirstOrDefault(x => string.Equals(x.ToolId, toolId, StringComparison.OrdinalIgnoreCase) && string.Equals(x.CliThreadId, cliThreadId, StringComparison.OrdinalIgnoreCase))); + public Task UpdateCliThreadIdAsync(string sessionId, string cliThreadId) => Task.FromResult(true); + public Task UpdateWorkspaceBindingAsync(string sessionId, string? workspacePath, bool isCustomWorkspace) => Task.FromResult(true); + public Task> GetByFeishuChatKeyAsync(string feishuChatKey) => Task.FromResult(_sessions.Where(x => string.Equals(x.FeishuChatKey, feishuChatKey, StringComparison.OrdinalIgnoreCase)).ToList()); + public Task GetActiveByFeishuChatKeyAsync(string feishuChatKey) => Task.FromResult(_sessions.FirstOrDefault(x => string.Equals(x.FeishuChatKey, feishuChatKey, StringComparison.OrdinalIgnoreCase) && x.IsFeishuActive)); + public Task SetActiveSessionAsync(string feishuChatKey, string sessionId) => Task.FromResult(true); + public Task CloseFeishuSessionAsync(string feishuChatKey, string sessionId) => Task.FromResult(true); + public Task CreateFeishuSessionAsync(string feishuChatKey, string username, string? workspacePath = null, string? toolId = null) => Task.FromResult(Guid.NewGuid().ToString("N")); + } + + private sealed class ExternalCliSessionSandbox : IDisposable + { + public ExternalCliSessionSandbox() + { + RootPath = Path.Combine(Path.GetTempPath(), "WebCodeCli.Domain.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(RootPath); + } + + public string RootPath { get; } + + public string CreateDirectory(string relativePath) + { + var fullPath = Path.Combine(RootPath, relativePath); + Directory.CreateDirectory(fullPath); + return fullPath; + } + + public string WriteFile(string relativePath, string content) + { + var fullPath = Path.Combine(RootPath, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, content.Replace("\r\n", "\n")); + return fullPath; + } + + public void Dispose() + { + if (Directory.Exists(RootPath)) + { + Directory.Delete(RootPath, recursive: true); + } + } + } +} diff --git a/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs b/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs index d9219c1..40ce220 100644 --- a/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs +++ b/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs @@ -57,6 +57,60 @@ public async Task HandleCardActionAsync_ExecuteCommand_WithoutActiveSession_Retu Assert.False(cliExecutor.WasExecuted); } + [Fact] + public async Task HandleCardActionAsync_ExecuteCommand_HistoryCommand_SendsExternalCliHistoryWithoutExecutingCli() + { + const string chatId = "oc_current_chat"; + const string sessionId = "session-history"; + + var cliExecutor = new RecordingCliExecutorService(); + var feishuChannel = new StubFeishuChannelService(sessionId); + var historyService = new StubExternalCliSessionHistoryService( + [ + new ExternalCliHistoryMessage { Role = "user", Content = "你好" }, + new ExternalCliHistoryMessage { Role = "assistant", Content = "世界" } + ]); + + var sessionRepository = new StubChatSessionRepository( + [ + new ChatSessionEntity + { + SessionId = sessionId, + Username = "luhaiyan", + ToolId = "codex", + CliThreadId = "codex-thread-1", + WorkspacePath = @"D:\repo", + FeishuChatKey = chatId, + IsWorkspaceValid = true, + IsFeishuActive = true, + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now + } + ]); + + var service = CreateService( + cliExecutor, + feishuChannel, + new TestServiceProvider( + chatSessionRepository: sessionRepository, + externalCliSessionHistoryService: historyService)); + + var response = await service.HandleCardActionAsync( + """{"action":"execute_command"}""", + chatId: chatId, + inputValues: "/history"); + + var sent = await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); + + Assert.Equal(CardActionTriggerResponseDto.ToastSuffix.ToastType.Info, response.Toast?.Type); + Assert.Contains("CLI 会话历史", response.Toast?.Content); + Assert.False(cliExecutor.WasExecuted); + Assert.Equal("codex", historyService.LastToolId); + Assert.Equal("codex-thread-1", historyService.LastCliThreadId); + Assert.Contains("你好", sent.Content); + Assert.Contains("世界", sent.Content); + } + [Fact] public async Task HandleCardActionAsync_BrowseCurrentSessionDirectory_ReturnsDirectoryCard() { @@ -239,6 +293,120 @@ public async Task HandleCardActionAsync_OpenSessionManager_FallsBackToSessionRep } } + [Fact] + public async Task HandleCardActionAsync_SwitchSession_SendsExternalCliHistory() + { + const string chatId = "oc_workspace_chat"; + const string sessionId = "session-switch-history"; + + var cliExecutor = new RecordingCliExecutorService(); + var feishuChannel = new StubFeishuChannelService(null); + var historyService = new StubExternalCliSessionHistoryService( + [ + new ExternalCliHistoryMessage + { + Role = "assistant", + Content = "这是 CLI 原生历史", + CreatedAt = new DateTime(2026, 3, 23, 9, 0, 0, DateTimeKind.Local) + } + ]); + + var sessionRepository = new StubChatSessionRepository( + [ + new ChatSessionEntity + { + SessionId = sessionId, + Username = "luhaiyan", + ToolId = "claude-code", + CliThreadId = "claude-session-1", + WorkspacePath = @"D:\repo", + FeishuChatKey = chatId, + IsWorkspaceValid = true, + IsFeishuActive = false, + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now + } + ]); + + var service = CreateService( + cliExecutor, + feishuChannel, + new TestServiceProvider( + chatSessionRepository: sessionRepository, + externalCliSessionHistoryService: historyService)); + + var response = await service.HandleCardActionAsync( + """{"action":"switch_session","chat_key":"oc_workspace_chat","session_id":"session-switch-history"}""", + chatId: chatId); + + var sent = await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); + + Assert.Equal(CardActionTriggerResponseDto.ToastSuffix.ToastType.Success, response.Toast?.Type); + Assert.Equal("claude-code", historyService.LastToolId); + Assert.Equal("claude-session-1", historyService.LastCliThreadId); + Assert.Contains("这是 CLI 原生历史", sent.Content); + Assert.DoesNotContain("暂无历史消息", sent.Content); + } + + [Fact] + public async Task HandleCardActionAsync_DiscoverExternalCliSessions_ShowsFullCountAndPagination() + { + const string chatId = "oc_external_cli_chat"; + + var discovered = Enumerable.Range(1, 285) + .Select(index => new ExternalCliSessionSummary + { + ToolId = "claude-code", + ToolName = "Claude Code", + CliThreadId = $"claude-session-{index:D3}", + Title = $"Claude 会话 {index:D3}", + WorkspacePath = $@"D:\VSWorkshop\allowed\workspace-{index:D3}", + UpdatedAt = new DateTime(2026, 3, 23, 10, 0, 0).AddMinutes(-index) + }) + .ToList(); + + var cliExecutor = new RecordingCliExecutorService(); + var feishuChannel = new StubFeishuChannelService(null); + var serviceProvider = new TestServiceProvider( + externalCliSessionService: new StubExternalCliSessionService(discovered)); + var service = CreateService(cliExecutor, feishuChannel, serviceProvider); + + var response = await service.HandleCardActionAsync( + """{"action":"discover_external_cli_sessions","chat_key":"oc_external_cli_chat","tool_id":"claude-code"}""", + chatId: chatId, + operatorUserId: "ou_test_user"); + + var payload = SerializeResponse(response); + using var document = JsonDocument.Parse(payload); + var summaryContent = document.RootElement + .GetProperty("card") + .GetProperty("data") + .GetProperty("body") + .GetProperty("elements")[0] + .GetProperty("text") + .GetProperty("content") + .GetString(); + + Assert.NotNull(summaryContent); + Assert.Contains("当前找到 **285** 个可导入会话", summaryContent); + Assert.Contains("当前第 **1/29** 页", summaryContent); + var elementContents = document.RootElement + .GetProperty("card") + .GetProperty("data") + .GetProperty("body") + .GetProperty("elements") + .EnumerateArray() + .Where(element => element.TryGetProperty("text", out _)) + .Select(element => element.GetProperty("text")) + .Where(text => text.TryGetProperty("content", out _)) + .Select(text => text.GetProperty("content").GetString()) + .Where(content => !string.IsNullOrWhiteSpace(content)) + .ToList(); + + Assert.Contains(elementContents, content => content!.Contains("Claude 会话 001", StringComparison.Ordinal)); + Assert.Contains("\"page\":1", payload); + } + [Fact] public async Task HandleCardActionAsync_CloseSession_WithMissingWorkspace_ClosesImmediately() { @@ -957,6 +1125,57 @@ public string CreateNewSession(FeishuIncomingMessage message, string? customWork } } + private sealed class StubExternalCliSessionHistoryService(IEnumerable messages) + : IExternalCliSessionHistoryService + { + private readonly List _messages = messages.ToList(); + + public string? LastToolId { get; private set; } + + public string? LastCliThreadId { get; private set; } + + public Task> GetRecentMessagesAsync( + string toolId, + string cliThreadId, + int maxCount = 20, + CancellationToken cancellationToken = default) + { + LastToolId = toolId; + LastCliThreadId = cliThreadId; + return Task.FromResult(_messages.Take(maxCount).ToList()); + } + } + + private sealed class StubExternalCliSessionService(IEnumerable sessions) + : IExternalCliSessionService + { + private readonly List _sessions = sessions.ToList(); + + public Task> DiscoverAsync( + string username, + string? toolId = null, + int maxCount = 20, + CancellationToken cancellationToken = default) + { + var query = _sessions.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(toolId)) + { + query = query.Where(x => string.Equals(x.ToolId, toolId, StringComparison.OrdinalIgnoreCase)); + } + + return Task.FromResult(query.Take(maxCount).ToList()); + } + + public Task ImportAsync( + string username, + ImportExternalCliSessionRequest request, + string? feishuChatKey = null, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + } + private sealed class TestServiceProvider : IServiceProvider, IServiceScopeFactory, IServiceScope { private readonly StubFeishuUserBindingService _bindingService = new(); @@ -965,15 +1184,21 @@ private sealed class TestServiceProvider : IServiceProvider, IServiceScopeFactor private readonly TestUserContextService _userContextService; private readonly TestProjectService _projectService; private readonly IChatSessionRepository _chatSessionRepository; + private readonly IExternalCliSessionHistoryService _externalCliSessionHistoryService; + private readonly IExternalCliSessionService _externalCliSessionService; public TestServiceProvider( TestUserContextService? userContextService = null, TestProjectService? projectService = null, - IChatSessionRepository? chatSessionRepository = null) + IChatSessionRepository? chatSessionRepository = null, + IExternalCliSessionHistoryService? externalCliSessionHistoryService = null, + IExternalCliSessionService? externalCliSessionService = null) { _userContextService = userContextService ?? new TestUserContextService(); _projectService = projectService ?? new TestProjectService(_userContextService); _chatSessionRepository = chatSessionRepository ?? new StubChatSessionRepository([]); + _externalCliSessionHistoryService = externalCliSessionHistoryService ?? new StubExternalCliSessionHistoryService([]); + _externalCliSessionService = externalCliSessionService ?? new StubExternalCliSessionService([]); } public object? GetService(Type serviceType) @@ -1013,6 +1238,16 @@ public TestServiceProvider( return _chatSessionRepository; } + if (serviceType == typeof(IExternalCliSessionHistoryService)) + { + return _externalCliSessionHistoryService; + } + + if (serviceType == typeof(IExternalCliSessionService)) + { + return _externalCliSessionService; + } + return null; } @@ -1183,6 +1418,48 @@ public Task> GetByUsernameOrderByUpdatedAtAsync(string u .ToList()); } + public Task GetByUsernameToolAndCliThreadIdAsync(string username, string toolId, string cliThreadId) + { + return Task.FromResult(_sessions.FirstOrDefault(x => + string.Equals(x.Username, username, StringComparison.OrdinalIgnoreCase) && + string.Equals(x.ToolId, toolId, StringComparison.OrdinalIgnoreCase) && + string.Equals(x.CliThreadId, cliThreadId, StringComparison.OrdinalIgnoreCase))); + } + + public Task GetByToolAndCliThreadIdAsync(string toolId, string cliThreadId) + { + return Task.FromResult(_sessions.FirstOrDefault(x => + string.Equals(x.ToolId, toolId, StringComparison.OrdinalIgnoreCase) && + string.Equals(x.CliThreadId, cliThreadId, StringComparison.OrdinalIgnoreCase))); + } + + public Task UpdateCliThreadIdAsync(string sessionId, string cliThreadId) + { + var session = _sessions.FirstOrDefault(x => string.Equals(x.SessionId, sessionId, StringComparison.OrdinalIgnoreCase)); + if (session == null) + { + return Task.FromResult(false); + } + + session.CliThreadId = cliThreadId; + session.UpdatedAt = DateTime.Now; + return Task.FromResult(true); + } + + public Task UpdateWorkspaceBindingAsync(string sessionId, string? workspacePath, bool isCustomWorkspace) + { + var session = _sessions.FirstOrDefault(x => string.Equals(x.SessionId, sessionId, StringComparison.OrdinalIgnoreCase)); + if (session == null) + { + return Task.FromResult(false); + } + + session.WorkspacePath = workspacePath; + session.IsCustomWorkspace = isCustomWorkspace; + session.UpdatedAt = DateTime.Now; + return Task.FromResult(true); + } + public Task> GetByFeishuChatKeyAsync(string feishuChatKey) { return Task.FromResult(_sessions @@ -1246,6 +1523,12 @@ public Task SaveAsync(UserFeishuBotConfigEntity c public Task FindConflictingUsernameByAppIdAsync(string username, string? appId) => Task.FromResult(null); + public Task> GetAutoStartCandidatesAsync() + => Task.FromResult(new List()); + + public Task UpdateRuntimePreferenceAsync(string username, bool autoStartEnabled, DateTime? lastStartedAt = null) + => Task.FromResult(true); + public FeishuOptions GetSharedDefaults() => DefaultOptions; public Task GetEffectiveOptionsAsync(string? username) diff --git a/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs b/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs index e944a94..44a8b7a 100644 --- a/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs +++ b/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs @@ -199,6 +199,12 @@ private sealed class StubUserFeishuBotConfigService : IUserFeishuBotConfigServic public Task FindConflictingUsernameByAppIdAsync(string username, string? appId) => Task.FromResult(null); + public Task> GetAutoStartCandidatesAsync() + => Task.FromResult(new List()); + + public Task UpdateRuntimePreferenceAsync(string username, bool autoStartEnabled, DateTime? lastStartedAt = null) + => Task.FromResult(true); + public FeishuOptions GetSharedDefaults() => new() { Enabled = true, diff --git a/WebCodeCli.Domain.Tests/FeishuCommandServiceTests.cs b/WebCodeCli.Domain.Tests/FeishuCommandServiceTests.cs new file mode 100644 index 0000000..be9dd33 --- /dev/null +++ b/WebCodeCli.Domain.Tests/FeishuCommandServiceTests.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.Logging.Abstractions; +using WebCodeCli.Domain.Domain.Service; +using WebCodeCli.Domain.Domain.Service.Channels; + +namespace WebCodeCli.Domain.Tests; + +public class FeishuCommandServiceTests +{ + [Theory] + [InlineData("claude-code")] + [InlineData("codex")] + [InlineData("opencode")] + public async Task GetCommandsAsync_IncludesHistoryBuiltInCommand(string toolId) + { + var service = new FeishuCommandService( + NullLogger.Instance, + new CommandScannerService()); + + var commands = await service.GetCommandsAsync(toolId); + + var historyCommand = Assert.Single(commands.Where(command => command.Id == "history")); + Assert.Equal("/history", historyCommand.Name); + Assert.Equal("/history", historyCommand.ExecuteText); + } +} diff --git a/WebCodeCli.Domain.Tests/UserContextServiceTests.cs b/WebCodeCli.Domain.Tests/UserContextServiceTests.cs new file mode 100644 index 0000000..9ffbeaf --- /dev/null +++ b/WebCodeCli.Domain.Tests/UserContextServiceTests.cs @@ -0,0 +1,38 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using WebCodeCli.Domain.Domain.Service; + +namespace WebCodeCli.Domain.Tests; + +public class UserContextServiceTests +{ + [Fact] + public void GetCurrentUsername_WhenAuthenticatedClaimExists_PrefersClaimOverOverride() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["App:DefaultUsername"] = "default-user" + }) + .Build(); + + var httpContext = new DefaultHttpContext(); + httpContext.User = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(ClaimTypes.Name, "test-user") + ], "Cookies")); + + var accessor = new HttpContextAccessor + { + HttpContext = httpContext + }; + + var service = new UserContextService(configuration, accessor); + service.SetCurrentUsername("stale-user"); + + var username = service.GetCurrentUsername(); + + Assert.Equal("test-user", username); + } +} diff --git a/WebCodeCli.Domain.Tests/UserFeishuBotRuntimeServiceTests.cs b/WebCodeCli.Domain.Tests/UserFeishuBotRuntimeServiceTests.cs new file mode 100644 index 0000000..8774f5f --- /dev/null +++ b/WebCodeCli.Domain.Tests/UserFeishuBotRuntimeServiceTests.cs @@ -0,0 +1,281 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Service; +using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; + +namespace WebCodeCli.Domain.Tests; + +public class UserFeishuBotRuntimeServiceTests +{ + [Fact] + public async Task StartAsync_PersistsAutoStartAndLastStartedAt() + { + var configService = new InMemoryUserFeishuBotConfigService(); + configService.Store(new UserFeishuBotConfigEntity + { + Username = "alice", + IsEnabled = true, + AppId = "cli_alice", + AppSecret = "secret" + }); + + var runtimeService = CreateService(configService); + runtimeService.EnqueueHostedService(new TestHostedService()); + + var status = await runtimeService.StartAsync("alice"); + var stored = await configService.GetByUsernameAsync("alice"); + + Assert.Equal(UserFeishuBotRuntimeState.Connected, status.State); + Assert.True(status.ShouldAutoStart); + Assert.NotNull(status.LastStartedAt); + Assert.NotNull(stored); + Assert.True(stored!.AutoStartEnabled); + Assert.NotNull(stored.LastStartedAt); + } + + [Fact] + public async Task HostedServiceStartAsync_RestoresRememberedBots() + { + var configService = new InMemoryUserFeishuBotConfigService(); + configService.Store(new UserFeishuBotConfigEntity + { + Username = "alice", + IsEnabled = true, + AutoStartEnabled = true, + AppId = "cli_alice", + AppSecret = "secret", + LastStartedAt = DateTime.Now.AddMinutes(-5) + }); + + var runtimeService = CreateService(configService); + runtimeService.EnqueueHostedService(new TestHostedService()); + + await ((IHostedService)runtimeService).StartAsync(CancellationToken.None); + var status = await runtimeService.GetStatusAsync("alice"); + + Assert.Equal(1, runtimeService.CreateRuntimeEntryCallCount); + Assert.Equal(UserFeishuBotRuntimeState.Connected, status.State); + Assert.True(status.ShouldAutoStart); + } + + [Fact] + public async Task StopAsync_ClearsRememberedState_ButHostStopPreservesIt() + { + var configService = new InMemoryUserFeishuBotConfigService(); + configService.Store(new UserFeishuBotConfigEntity + { + Username = "alice", + IsEnabled = true, + AppId = "cli_alice", + AppSecret = "secret" + }); + + var runtimeService = CreateService(configService); + runtimeService.EnqueueHostedService(new TestHostedService()); + + await runtimeService.StartAsync("alice"); + var manualStopStatus = await runtimeService.StopAsync("alice"); + var afterManualStop = await configService.GetByUsernameAsync("alice"); + + Assert.Equal(UserFeishuBotRuntimeState.Stopped, manualStopStatus.State); + Assert.False(manualStopStatus.ShouldAutoStart); + Assert.NotNull(afterManualStop); + Assert.False(afterManualStop!.AutoStartEnabled); + + runtimeService.EnqueueHostedService(new TestHostedService()); + await runtimeService.StartAsync("alice"); + await ((IHostedService)runtimeService).StopAsync(CancellationToken.None); + var afterHostStop = await configService.GetByUsernameAsync("alice"); + + Assert.NotNull(afterHostStop); + Assert.True(afterHostStop!.AutoStartEnabled); + } + + private static TestableUserFeishuBotRuntimeService CreateService(InMemoryUserFeishuBotConfigService configService) + { + var scopeFactory = new TestScopeFactory(configService); + return new TestableUserFeishuBotRuntimeService( + new ServiceCollection().BuildServiceProvider(), + scopeFactory, + NullLogger.Instance); + } + + private sealed class TestableUserFeishuBotRuntimeService : UserFeishuBotRuntimeService + { + private readonly Queue _hostedServices = new(); + + public TestableUserFeishuBotRuntimeService( + IServiceProvider rootServiceProvider, + IServiceScopeFactory scopeFactory, + ILogger logger) + : base(rootServiceProvider, scopeFactory, logger) + { + } + + public int CreateRuntimeEntryCallCount { get; private set; } + + public void EnqueueHostedService(IHostedService hostedService) + { + _hostedServices.Enqueue(hostedService); + } + + protected override RuntimeEntry CreateRuntimeEntry(FeishuOptions options) + { + CreateRuntimeEntryCallCount++; + var provider = new ServiceCollection().BuildServiceProvider(); + var hostedService = _hostedServices.Count > 0 + ? _hostedServices.Dequeue() + : new TestHostedService(); + return new RuntimeEntry(options.AppId, provider, hostedService); + } + } + + private sealed class TestScopeFactory(InMemoryUserFeishuBotConfigService configService) : IServiceScopeFactory, IServiceScope, IServiceProvider + { + public IServiceScope CreateScope() => this; + + public IServiceProvider ServiceProvider => this; + + public object? GetService(Type serviceType) + { + if (serviceType == typeof(IUserFeishuBotConfigService)) + { + return configService; + } + + if (serviceType == typeof(IServiceScopeFactory)) + { + return this; + } + + return null; + } + + public void Dispose() + { + } + } + + private sealed class InMemoryUserFeishuBotConfigService : IUserFeishuBotConfigService + { + private readonly Dictionary _configs = new(StringComparer.OrdinalIgnoreCase); + + public void Store(UserFeishuBotConfigEntity entity) + { + _configs[entity.Username] = Clone(entity); + } + + public Task GetByUsernameAsync(string username) + { + return Task.FromResult(_configs.TryGetValue(username, out var entity) ? Clone(entity) : null); + } + + public Task GetByAppIdAsync(string appId) + { + var entity = _configs.Values.FirstOrDefault(x => string.Equals(x.AppId, appId, StringComparison.OrdinalIgnoreCase)); + return Task.FromResult(entity == null ? null : Clone(entity)); + } + + public Task SaveAsync(UserFeishuBotConfigEntity config) + { + Store(config); + return Task.FromResult(UserFeishuBotConfigSaveResult.Saved()); + } + + public Task DeleteAsync(string username) + { + return Task.FromResult(_configs.Remove(username)); + } + + public Task FindConflictingUsernameByAppIdAsync(string username, string? appId) + { + return Task.FromResult(null); + } + + public Task> GetAutoStartCandidatesAsync() + { + var list = _configs.Values + .Where(x => x.AutoStartEnabled) + .Select(Clone) + .ToList(); + return Task.FromResult(list); + } + + public Task UpdateRuntimePreferenceAsync(string username, bool autoStartEnabled, DateTime? lastStartedAt = null) + { + if (!_configs.TryGetValue(username, out var entity)) + { + return Task.FromResult(false); + } + + entity.AutoStartEnabled = autoStartEnabled; + if (lastStartedAt.HasValue) + { + entity.LastStartedAt = lastStartedAt.Value; + } + + entity.UpdatedAt = DateTime.Now; + return Task.FromResult(true); + } + + public FeishuOptions GetSharedDefaults() => new() + { + Enabled = true, + DefaultCardTitle = "AI助手", + ThinkingMessage = "思考中..." + }; + + public Task GetEffectiveOptionsAsync(string? username) + { + var config = string.IsNullOrWhiteSpace(username) + ? null + : _configs.TryGetValue(username, out var entity) ? entity : null; + var effective = UserFeishuBotOptionsFactory.CreateEffectiveOptions(GetSharedDefaults(), config); + return Task.FromResult(effective ?? GetSharedDefaults()); + } + + public Task GetEffectiveOptionsByAppIdAsync(string? appId) + { + if (string.IsNullOrWhiteSpace(appId)) + { + return Task.FromResult(null); + } + + var entity = _configs.Values.FirstOrDefault(x => string.Equals(x.AppId, appId, StringComparison.OrdinalIgnoreCase)); + return Task.FromResult(UserFeishuBotOptionsFactory.CreateEffectiveOptions(GetSharedDefaults(), entity)); + } + + private static UserFeishuBotConfigEntity Clone(UserFeishuBotConfigEntity entity) + { + return new UserFeishuBotConfigEntity + { + Id = entity.Id, + Username = entity.Username, + IsEnabled = entity.IsEnabled, + AutoStartEnabled = entity.AutoStartEnabled, + AppId = entity.AppId, + AppSecret = entity.AppSecret, + EncryptKey = entity.EncryptKey, + VerificationToken = entity.VerificationToken, + DefaultCardTitle = entity.DefaultCardTitle, + ThinkingMessage = entity.ThinkingMessage, + HttpTimeoutSeconds = entity.HttpTimeoutSeconds, + StreamingThrottleMs = entity.StreamingThrottleMs, + LastStartedAt = entity.LastStartedAt, + CreatedAt = entity.CreatedAt, + UpdatedAt = entity.UpdatedAt + }; + } + } + + private sealed class TestHostedService : BackgroundService + { + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.Delay(Timeout.Infinite, stoppingToken); + } + } +} diff --git a/WebCodeCli.Domain/Common/CliThreadIdRecoveryHelper.cs b/WebCodeCli.Domain/Common/CliThreadIdRecoveryHelper.cs new file mode 100644 index 0000000..62c7a53 --- /dev/null +++ b/WebCodeCli.Domain/Common/CliThreadIdRecoveryHelper.cs @@ -0,0 +1,67 @@ +namespace WebCodeCli.Domain.Common; + +internal static class CliThreadIdRecoveryHelper +{ + public static string? TryRecoverFromImportedTitle(string? toolId, string? title) + { + if (string.IsNullOrWhiteSpace(title)) + { + return null; + } + + var normalizedToolId = NormalizeToolId(toolId); + var prefix = normalizedToolId switch + { + "codex" => "[Codex] ", + "claude-code" => "[Claude Code] ", + "opencode" => "[OpenCode] ", + _ => null + }; + + if (string.IsNullOrWhiteSpace(prefix) || !title.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var candidate = title[prefix.Length..].Trim(); + if (string.IsNullOrWhiteSpace(candidate) || candidate.EndsWith("...", StringComparison.Ordinal)) + { + return null; + } + + return IsLikelyCliThreadId(normalizedToolId, candidate) + ? candidate + : null; + } + + private static string NormalizeToolId(string? toolId) + { + if (string.IsNullOrWhiteSpace(toolId)) + { + return string.Empty; + } + + if (toolId.Equals("claude", StringComparison.OrdinalIgnoreCase)) + { + return "claude-code"; + } + + if (toolId.Equals("opencode-cli", StringComparison.OrdinalIgnoreCase)) + { + return "opencode"; + } + + return toolId.Trim().ToLowerInvariant(); + } + + private static bool IsLikelyCliThreadId(string normalizedToolId, string candidate) + { + if (Guid.TryParse(candidate, out _)) + { + return true; + } + + return normalizedToolId == "opencode" + && candidate.StartsWith("ses_", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/WebCodeCli.Domain/Common/Extensions/DatabaseInitializer.cs b/WebCodeCli.Domain/Common/Extensions/DatabaseInitializer.cs index 474ec98..818b07a 100644 --- a/WebCodeCli.Domain/Common/Extensions/DatabaseInitializer.cs +++ b/WebCodeCli.Domain/Common/Extensions/DatabaseInitializer.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using SqlSugar; +using WebCodeCli.Domain.Common; using WebCodeCli.Domain.Common.Map; using WebCodeCli.Domain.Domain.Model; using WebCodeCli.Domain.Repositories.Base.SessionShare; @@ -205,6 +206,8 @@ public static void InitializeChatSessionTables(this SqlSugarScope db, ILogger? l db.CodeFirst.InitTables(); logger?.LogInformation("聊天会话相关表初始化成功"); + + EnsureChatSessionSchema(db, logger); // 创建索引 InitializeChatSessionIndexes(db, logger); @@ -216,6 +219,76 @@ public static void InitializeChatSessionTables(this SqlSugarScope db, ILogger? l } } + ///

+ /// 确保 ChatSession 表的增量字段和兼容数据已就位 + /// + private static void EnsureChatSessionSchema(SqlSugarScope db, ILogger? logger = null) + { + try + { + EnsureColumnIfNotExists(db, "ChatSession", "CliThreadId", "varchar(256) NULL", logger); + BackfillCliThreadIdsFromImportedTitles(db, logger); + } + catch (Exception ex) + { + logger?.LogWarning(ex, "校正 ChatSession 表结构时发生警告"); + } + } + + private static void EnsureColumnIfNotExists( + SqlSugarScope db, + string tableName, + string columnName, + string columnDefinition, + ILogger? logger = null) + { + var pragmaSql = $"PRAGMA table_info(\"{tableName}\")"; + var tableInfo = db.Ado.GetDataTable(pragmaSql); + var columnExists = tableInfo.Rows.Cast() + .Any(row => string.Equals(row["name"]?.ToString(), columnName, StringComparison.OrdinalIgnoreCase)); + + if (columnExists) + { + return; + } + + db.Ado.ExecuteCommand($"ALTER TABLE {tableName} ADD COLUMN {columnName} {columnDefinition}"); + logger?.LogInformation("已为表 {TableName} 补充列 {ColumnName}", tableName, columnName); + } + + private static void BackfillCliThreadIdsFromImportedTitles(SqlSugarScope db, ILogger? logger = null) + { + var sessions = db.Queryable() + .Where(x => x.CliThreadId == null || x.CliThreadId == string.Empty) + .Select(x => new ChatSessionEntity + { + SessionId = x.SessionId, + ToolId = x.ToolId, + Title = x.Title + }) + .ToList(); + + var recoveredCount = 0; + foreach (var session in sessions) + { + var cliThreadId = CliThreadIdRecoveryHelper.TryRecoverFromImportedTitle(session.ToolId, session.Title); + if (string.IsNullOrWhiteSpace(cliThreadId)) + { + continue; + } + + recoveredCount += db.Updateable() + .SetColumns(x => x.CliThreadId == cliThreadId) + .Where(x => x.SessionId == session.SessionId) + .ExecuteCommand(); + } + + if (recoveredCount > 0) + { + logger?.LogInformation("已从导入标题回填 {Count} 条 ChatSession.CliThreadId", recoveredCount); + } + } + /// /// 为聊天会话相关表创建索引 /// @@ -228,6 +301,16 @@ private static void InitializeChatSessionIndexes(SqlSugarScope db, ILogger? logg // ChatSession: Username + UpdatedAt 索引(会话列表排序) CreateIndexIfNotExists(db, "ChatSession", "IX_ChatSession_Username_UpdatedAt", new[] { "Username", "UpdatedAt" }, logger); + + // ChatSession: Username + ToolId + CliThreadId 索引(用于恢复外部/CLI会话时快速查找) + // 说明:CliThreadId 允许为空;SQLite 对 NULL 的唯一性处理较友好,但这里先用普通索引即可。 + CreateIndexIfNotExists(db, "ChatSession", "IX_ChatSession_Username_ToolId_CliThreadId", + new[] { "Username", "ToolId", "CliThreadId" }, logger); + + // ChatSession: ToolId + CliThreadId 唯一索引(跨用户,确保一个外部 CLI 会话只能被一个用户占用) + // 说明:CliThreadId 允许为空;SQLite UNIQUE INDEX 允许多个 NULL,不影响旧数据。 + CreateIndexIfNotExists(db, "ChatSession", "IX_ChatSession_ToolId_CliThreadId", + new[] { "ToolId", "CliThreadId" }, logger, isUnique: true); // ChatMessage: SessionId 索引(按会话查询消息) CreateIndexIfNotExists(db, "ChatMessage", "IX_ChatMessage_SessionId", diff --git a/WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs b/WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs index 3cab614..d8fed91 100644 --- a/WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs +++ b/WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs @@ -81,6 +81,18 @@ public class FeishuHelpCardAction [JsonPropertyName("tool_id")] public string? ToolId { get; set; } + /// + /// 外部 CLI 会话/线程 ID(导入外部会话时使用) + /// + [JsonPropertyName("cli_thread_id")] + public string? CliThreadId { get; set; } + + /// + /// 外部会话标题(导入外部会话时使用) + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + /// /// 当前浏览的目录路径(相对于会话工作区根目录) /// diff --git a/WebCodeCli.Domain/Domain/Model/ExternalCliHistoryMessage.cs b/WebCodeCli.Domain/Domain/Model/ExternalCliHistoryMessage.cs new file mode 100644 index 0000000..1efbd5d --- /dev/null +++ b/WebCodeCli.Domain/Domain/Model/ExternalCliHistoryMessage.cs @@ -0,0 +1,27 @@ +namespace WebCodeCli.Domain.Domain.Model; + +/// +/// 外部 CLI 会话历史消息 +/// +public class ExternalCliHistoryMessage +{ + /// + /// 消息角色,通常为 user / assistant + /// + public string Role { get; set; } = string.Empty; + + /// + /// 解析后的纯文本内容 + /// + public string Content { get; set; } = string.Empty; + + /// + /// 创建时间(如果可解析) + /// + public DateTime? CreatedAt { get; set; } + + /// + /// 原始消息类型(可选) + /// + public string? RawType { get; set; } +} diff --git a/WebCodeCli.Domain/Domain/Model/ExternalCliSessionSummary.cs b/WebCodeCli.Domain/Domain/Model/ExternalCliSessionSummary.cs new file mode 100644 index 0000000..17c85d8 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Model/ExternalCliSessionSummary.cs @@ -0,0 +1,64 @@ +namespace WebCodeCli.Domain.Domain.Model; + +/// +/// 外部 CLI 会话摘要(用于发现/导入/恢复) +/// +public sealed class ExternalCliSessionSummary +{ + /// + /// 工具 ID(codex / claude-code / opencode) + /// + public string ToolId { get; set; } = string.Empty; + + /// + /// 工具名称(用于展示) + /// + public string ToolName { get; set; } = string.Empty; + + /// + /// CLI 侧的会话/线程 ID(用于 resume) + /// + public string CliThreadId { get; set; } = string.Empty; + + /// + /// 会话标题(如果可解析到) + /// + public string? Title { get; set; } + + /// + /// 会话工作目录(如果可解析到) + /// + public string? WorkspacePath { get; set; } + + /// + /// 最后更新时间(如果可解析到) + /// + public DateTime? UpdatedAt { get; set; } + + /// + /// 是否已被当前 Web 用户导入为 WebCode 会话 + /// + public bool AlreadyImported { get; set; } + + /// + /// 已导入的 WebCode 会话 ID(若已导入) + /// + public string? ImportedSessionId { get; set; } +} + +public sealed class ImportExternalCliSessionRequest +{ + public string ToolId { get; set; } = string.Empty; + public string CliThreadId { get; set; } = string.Empty; + public string? Title { get; set; } + public string? WorkspacePath { get; set; } +} + +public sealed class ImportExternalCliSessionResult +{ + public bool Success { get; set; } + public bool AlreadyExists { get; set; } + public string? SessionId { get; set; } + public string? ErrorMessage { get; set; } +} + diff --git a/WebCodeCli.Domain/Domain/Model/SessionHistory.cs b/WebCodeCli.Domain/Domain/Model/SessionHistory.cs index f2326f8..4f90706 100644 --- a/WebCodeCli.Domain/Domain/Model/SessionHistory.cs +++ b/WebCodeCli.Domain/Domain/Model/SessionHistory.cs @@ -34,6 +34,11 @@ public class SessionHistory /// 选中的工具ID /// public string ToolId { get; set; } = string.Empty; + + /// + /// CLI 会话/线程 ID(用于恢复外部 CLI 会话,例如 codex thread、claude resume、opencode session) + /// + public string? CliThreadId { get; set; } /// /// 消息列表 diff --git a/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs b/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs index b8b9fcf..39b9162 100644 --- a/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs +++ b/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs @@ -156,7 +156,19 @@ public async Task HandleCardActionAsync( case "bind_web_user": return await HandleBindWebUserAsync(formValueElement, chatId, operatorUserId, appId); case "open_session_manager": - return await HandleOpenSessionManagerAsync(chatId, operatorUserId); + return await HandleOpenSessionManagerAsync(action.ChatKey ?? chatId, operatorUserId); + case "discover_external_cli_sessions": + return await HandleDiscoverExternalCliSessionsAsync(action.ChatKey ?? chatId, chatId, action.ToolId, action.Page, operatorUserId); + case "import_external_cli_session": + return await HandleImportExternalCliSessionAsync( + action.ChatKey ?? chatId, + chatId, + action.ToolId, + action.CliThreadId, + action.Title, + action.WorkspacePath, + operatorUserId, + appId); case "open_project_manager": return await HandleOpenProjectManagerAsync(action.ChatKey ?? chatId, operatorUserId); case "show_create_project_form": @@ -333,6 +345,26 @@ private async Task HandleExecuteCommandAsync( return response; } + if (IsHistoryCommand(commandInput)) + { + var historyToast = _cardBuilder.BuildCardActionToastOnlyResponse("📜 正在读取当前 CLI 会话历史...", "info"); + + _ = Task.Run(async () => + { + try + { + var username = ResolveFeishuUsername(chatId.ToLowerInvariant(), operatorUserId); + await SendExternalCliHistoryAsync(currentSessionId, actualChatKey, username, appId); + } + catch (Exception ex) + { + _logger.LogError(ex, "❌ [FeishuHelp] 读取当前 CLI 会话历史失败"); + } + }); + + return historyToast; + } + // 立即返回 toast 响应 var toastResponse = _cardBuilder.BuildCardActionToastOnlyResponse("🚀 开始执行命令...", "info"); @@ -788,48 +820,7 @@ private async Task HandleSwitchSessionAsync(string { try { - _logger.LogInformation("🔍 [会话历史] 开始获取会话 {SessionId} 历史消息", sessionId); - - // 获取最近10条消息,按时间正序排列(最早在上,最新在下) - var messages = _chatSessionService.GetMessages(sessionId) - .OrderBy(m => m.CreatedAt) - .TakeLast(10) - .ToList(); - - _logger.LogInformation("🔍 [会话历史] 找到 {Count} 条历史消息", messages.Count); - - var contentBuilder = new System.Text.StringBuilder(); - contentBuilder.AppendLine($"## 📜 会话历史 `{sessionId[..8]}`"); - contentBuilder.AppendLine($"⏱️ 最后活跃: {lastActiveTime:yyyy-MM-dd HH:mm}"); - contentBuilder.AppendLine($"📂 工作目录: `{workspacePath}`"); - contentBuilder.AppendLine($"🛠️ CLI 工具: `{toolLabel}`"); - contentBuilder.AppendLine(); - contentBuilder.AppendLine("---"); - contentBuilder.AppendLine(); - - if (messages.Count == 0) - { - contentBuilder.AppendLine("ℹ️ 该会话暂无历史消息"); - } - else - { - foreach (var msg in messages) - { - var role = msg.Role == "user" ? "👤 用户" : "🤖 AI助手"; - contentBuilder.AppendLine($"### {role} `{msg.CreatedAt:HH:mm}`"); - contentBuilder.AppendLine(msg.Content); - contentBuilder.AppendLine(); - contentBuilder.AppendLine("---"); - contentBuilder.AppendLine(); - } - } - - _logger.LogInformation("🔍 [会话历史] 内容构建完成,长度: {Length}", contentBuilder.Length); - - // 直接发送Markdown内容,系统会自动包装成卡片 - _logger.LogInformation("🔍 [会话历史] 开始发送消息到聊天 {ChatId}", actualChatKey); - var messageId = await _feishuChannel.SendMessageAsync(actualChatKey, contentBuilder.ToString(), username, appId); - _logger.LogInformation("✅ [会话历史] 已发送会话 {SessionId} 历史到聊天 {ChatId}, MessageId={MessageId}", sessionId, actualChatKey, messageId); + await SendExternalCliHistoryAsync(sessionId, actualChatKey, username, appId, lastActiveTime, workspacePath, toolLabel); } catch (Exception ex) { @@ -852,6 +843,132 @@ private async Task HandleSwitchSessionAsync(string return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 会话不存在,切换失败", "error"); } + private async Task SendExternalCliHistoryAsync( + string sessionId, + string chatId, + string username, + string? appId, + DateTime? lastActiveTime = null, + string? workspacePath = null, + string? toolLabel = null) + { + using var scope = _serviceProvider.CreateScope(); + var detailRepo = scope.ServiceProvider.GetRequiredService(); + var historyService = scope.ServiceProvider.GetRequiredService(); + + var sessionEntity = await detailRepo.GetByIdAsync(sessionId); + if (sessionEntity == null) + { + await _feishuChannel.SendMessageAsync(chatId, $"❌ 会话 {sessionId[..Math.Min(8, sessionId.Length)]} 不存在,无法读取历史消息", username, appId); + return; + } + + var normalizedToolId = NormalizeToolId(sessionEntity.ToolId) ?? ResolveDefaultToolId(); + var cliThreadId = _cliExecutor.GetCliThreadId(sessionId)?.Trim() + ?? sessionEntity.CliThreadId?.Trim(); + if (string.IsNullOrWhiteSpace(cliThreadId)) + { + await _feishuChannel.SendMessageAsync(chatId, "⚠️ 当前会话尚未绑定 CLI 原生会话 ID,暂时无法读取历史消息。请先在该会话中执行一次 CLI 对话。", username, appId); + return; + } + + _logger.LogInformation( + "🔍 [会话历史] 开始获取外部 CLI 会话历史: SessionId={SessionId}, ToolId={ToolId}, CliThreadId={CliThreadId}", + sessionId, + normalizedToolId, + cliThreadId); + + var messages = await historyService.GetRecentMessagesAsync(normalizedToolId, cliThreadId, maxCount: 10); + var content = BuildExternalCliHistoryText( + sessionId, + toolLabel ?? GetToolDisplayName(sessionEntity.ToolId), + workspacePath ?? GetSessionWorkspaceDisplay(sessionId), + lastActiveTime ?? _feishuChannel.GetSessionLastActiveTime(sessionId), + messages); + + var messageId = await _feishuChannel.SendMessageAsync(chatId, content, username, appId); + _logger.LogInformation( + "✅ [会话历史] 已发送外部 CLI 会话历史: SessionId={SessionId}, ChatId={ChatId}, MessageId={MessageId}, Count={Count}", + sessionId, + chatId, + messageId, + messages.Count); + } + + private static string BuildExternalCliHistoryText( + string sessionId, + string toolLabel, + string workspacePath, + DateTime? lastActiveTime, + IReadOnlyList messages) + { + var builder = new StringBuilder(); + builder.AppendLine($"当前 CLI 会话历史 {sessionId[..Math.Min(8, sessionId.Length)]}"); + builder.AppendLine($"CLI 工具: {toolLabel}"); + builder.AppendLine($"工作目录: {workspacePath}"); + if (lastActiveTime.HasValue) + { + builder.AppendLine($"最后活跃: {lastActiveTime:yyyy-MM-dd HH:mm}"); + } + + builder.AppendLine(); + + if (messages.Count == 0) + { + builder.AppendLine("该 CLI 会话暂无可解析的历史消息。"); + return builder.ToString().TrimEnd(); + } + + foreach (var message in messages.TakeLast(10)) + { + var roleLabel = string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase) + ? "用户" + : "助手"; + + if (message.CreatedAt.HasValue) + { + builder.AppendLine($"[{roleLabel}] {message.CreatedAt:HH:mm}"); + } + else + { + builder.AppendLine($"[{roleLabel}]"); + } + + builder.AppendLine(TrimHistoryContent(message.Content, 1200)); + builder.AppendLine(); + } + + return builder.ToString().TrimEnd(); + } + + private static string TrimHistoryContent(string? content, int maxLength) + { + if (string.IsNullOrWhiteSpace(content)) + { + return string.Empty; + } + + var normalized = content.Replace("\r\n", "\n").Trim(); + if (normalized.Length <= maxLength) + { + return normalized; + } + + return normalized[..maxLength] + "\n..."; + } + + private static bool IsHistoryCommand(string? commandInput) + { + if (string.IsNullOrWhiteSpace(commandInput)) + { + return false; + } + + var trimmed = commandInput.Trim(); + return string.Equals(trimmed, "/history", StringComparison.OrdinalIgnoreCase) + || trimmed.StartsWith("/history ", StringComparison.OrdinalIgnoreCase); + } + /// /// 处理旧卡片中的切换工具动作 /// @@ -1857,6 +1974,25 @@ private async Task HandleOpenSessionManagerAsync(s } }); + elements.Add(new + { + tag = "button", + text = new { tag = "plain_text", content = "📥 导入本地会话" }, + type = "default", + behaviors = new[] + { + new + { + type = "callback", + value = new + { + action = "discover_external_cli_sessions", + chat_key = chatKey + } + } + } + }); + // 添加底部操作按钮 elements.Add(new { @@ -1923,6 +2059,359 @@ private async Task HandleOpenSessionManagerAsync(s } } + private async Task HandleDiscoverExternalCliSessionsAsync( + string? chatKey, + string? chatId, + string? toolId, + int? page, + string? operatorUserId) + { + if (string.IsNullOrWhiteSpace(chatKey) && string.IsNullOrWhiteSpace(chatId)) + { + return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 参数错误,无法发现本地会话", "error"); + } + + try + { + var actualChatKey = NormalizeChatKey(string.IsNullOrWhiteSpace(chatKey) ? chatId! : chatKey!); + var username = ResolveFeishuUsername(actualChatKey, operatorUserId); + if (string.IsNullOrWhiteSpace(username)) + { + return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 请先绑定 Web 用户,再导入本地会话", "error"); + } + + using var scope = _serviceProvider.CreateScope(); + var externalService = scope.ServiceProvider.GetRequiredService(); + + var normalizedToolId = string.IsNullOrWhiteSpace(toolId) ? null : NormalizeToolId(toolId); + // 留出足够窗口,避免外部 CLI 会话总数增长后再次被硬截断。 + const int discoverMaxCount = 1000; + const int pageSize = 10; + var discovered = await externalService.DiscoverAsync(username, normalizedToolId, maxCount: discoverMaxCount); + var totalPages = Math.Max(1, (int)Math.Ceiling(discovered.Count / (double)pageSize)); + var safePageIndex = Math.Clamp(page ?? 0, 0, totalPages - 1); + var pageItems = discovered + .Skip(safePageIndex * pageSize) + .Take(pageSize) + .ToList(); + + var elements = new List(); + + elements.Add(new + { + tag = "div", + text = new + { + tag = "lark_md", + content = $"## 📥 导入本地 CLI 会话\n当前找到 **{discovered.Count}** 个可导入会话。\n当前第 **{safePageIndex + 1}/{totalPages}** 页,每页 **{pageSize}** 条。\n导入后会将该会话绑定到当前聊天,并切换为活跃会话。" + } + }); + + elements.Add(new { tag = "hr" }); + + // 工具筛选按钮 + elements.Add(new + { + tag = "div", + text = new { tag = "plain_text", content = "筛选工具:" } + }); + + foreach (var (label, value) in new[] + { + ("全部", (string?)null), + ("OpenCode", "opencode"), + ("Codex", "codex"), + ("Claude Code", "claude-code") + }) + { + var isSelected = string.IsNullOrWhiteSpace(value) + ? string.IsNullOrWhiteSpace(normalizedToolId) + : string.Equals(value, normalizedToolId, StringComparison.OrdinalIgnoreCase); + elements.Add(new + { + tag = "button", + text = new { tag = "plain_text", content = label }, + type = isSelected ? "primary" : "default", + behaviors = new[] + { + new + { + type = "callback", + value = new + { + action = "discover_external_cli_sessions", + chat_key = actualChatKey, + tool_id = value, + page = 0 + } + } + } + }); + } + + elements.Add(new { tag = "hr" }); + + var currentSessionId = _feishuChannel.GetCurrentSession(actualChatKey, username); + if (discovered.Count == 0) + { + elements.Add(new + { + tag = "div", + text = new { tag = "plain_text", content = "未发现可导入的本地会话。请确认:\n1) 该工具已在本机运行并产生会话记录\n2) 会话工作区在允许目录内" } + }); + } + else + { + foreach (var item in pageItems) + { + var toolLabel = GetToolDisplayName(item.ToolId); + var updatedText = item.UpdatedAt.HasValue ? item.UpdatedAt.Value.ToString("yyyy-MM-dd HH:mm") : "-"; + var title = string.IsNullOrWhiteSpace(item.Title) ? item.CliThreadId : item.Title!; + var info = $"**[{toolLabel}] {title}**\n📂 {item.WorkspacePath}\n⏱️ {updatedText}"; + + elements.Add(new + { + tag = "div", + text = new { tag = "lark_md", content = info } + }); + + if (item.AlreadyImported && !string.IsNullOrWhiteSpace(item.ImportedSessionId)) + { + var isCurrent = string.Equals(item.ImportedSessionId, currentSessionId, StringComparison.OrdinalIgnoreCase); + elements.Add(new + { + tag = "button", + text = new { tag = "plain_text", content = isCurrent ? "当前" : "切换到该会话" }, + type = isCurrent ? "default" : "primary", + behaviors = new[] + { + new + { + type = "callback", + value = new + { + action = "switch_session", + session_id = item.ImportedSessionId, + chat_key = actualChatKey + } + } + } + }); + } + else + { + elements.Add(new + { + tag = "button", + text = new { tag = "plain_text", content = "导入并切换" }, + type = "primary", + behaviors = new[] + { + new + { + type = "callback", + value = new + { + action = "import_external_cli_session", + chat_key = actualChatKey, + tool_id = item.ToolId, + cli_thread_id = item.CliThreadId, + title = item.Title, + workspace_path = item.WorkspacePath + } + } + } + }); + } + + elements.Add(new { tag = "hr" }); + } + } + + if (discovered.Count > 0 && totalPages > 1) + { + elements.Add(new + { + tag = "div", + text = new + { + tag = "plain_text", + content = $"分页:第 {safePageIndex + 1}/{totalPages} 页" + } + }); + + if (safePageIndex > 0) + { + elements.Add(new + { + tag = "button", + text = new { tag = "plain_text", content = "⬅️ 上一页" }, + type = "default", + behaviors = new[] + { + new + { + type = "callback", + value = new + { + action = "discover_external_cli_sessions", + chat_key = actualChatKey, + tool_id = normalizedToolId, + page = safePageIndex - 1 + } + } + } + }); + } + + if (safePageIndex + 1 < totalPages) + { + elements.Add(new + { + tag = "button", + text = new { tag = "plain_text", content = "➡️ 下一页" }, + type = "default", + behaviors = new[] + { + new + { + type = "callback", + value = new + { + action = "discover_external_cli_sessions", + chat_key = actualChatKey, + tool_id = normalizedToolId, + page = safePageIndex + 1 + } + } + } + }); + } + } + + elements.Add(new + { + tag = "button", + text = new { tag = "plain_text", content = "🔙 返回会话管理" }, + type = "default", + behaviors = new[] + { + new + { + type = "callback", + value = new + { + action = "open_session_manager", + chat_key = actualChatKey + } + } + } + }); + + var card = new ElementsCardV2Dto + { + Header = new ElementsCardV2Dto.HeaderSuffix + { + Template = "indigo", + Title = new HeaderTitleElement { Content = "📥 导入本地会话" } + }, + Config = new ElementsCardV2Dto.ConfigSuffix + { + EnableForward = true, + UpdateMulti = true + }, + Body = new ElementsCardV2Dto.BodySuffix + { + Elements = elements.ToArray() + } + }; + + return _cardBuilder.BuildCardActionResponseV2(card, string.Empty); + } + catch (Exception ex) + { + _logger.LogError(ex, "处理发现外部 CLI 会话失败"); + return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 发现本地会话失败,请稍后重试。", "error"); + } + } + + private async Task HandleImportExternalCliSessionAsync( + string? chatKey, + string? chatId, + string? toolId, + string? cliThreadId, + string? title, + string? workspacePath, + string? operatorUserId, + string? appId) + { + if (string.IsNullOrWhiteSpace(chatKey) && string.IsNullOrWhiteSpace(chatId)) + { + return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 参数错误,导入失败", "error"); + } + + var actualChatKey = NormalizeChatKey(string.IsNullOrWhiteSpace(chatKey) ? chatId! : chatKey!); + var username = ResolveFeishuUsername(actualChatKey, operatorUserId); + if (string.IsNullOrWhiteSpace(username)) + { + return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 请先绑定 Web 用户,再导入本地会话", "error"); + } + + if (string.IsNullOrWhiteSpace(toolId) || string.IsNullOrWhiteSpace(cliThreadId) || string.IsNullOrWhiteSpace(workspacePath)) + { + return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 参数不完整,导入失败", "error"); + } + + try + { + using var scope = _serviceProvider.CreateScope(); + var externalService = scope.ServiceProvider.GetRequiredService(); + + var request = new ImportExternalCliSessionRequest + { + ToolId = toolId, + CliThreadId = cliThreadId, + Title = title, + WorkspacePath = workspacePath + }; + + var result = await externalService.ImportAsync(username, request, feishuChatKey: actualChatKey); + if (!result.Success) + { + return _cardBuilder.BuildCardActionToastOnlyResponse($"❌ 导入失败: {result.ErrorMessage}", "error"); + } + + // 发送普通文本提示(卡片 toast 不会提醒) + try + { + var shortId = string.IsNullOrWhiteSpace(result.SessionId) ? "-" : result.SessionId[..8]; + var toolLabel = GetToolDisplayName(toolId); + await _feishuChannel.SendMessageAsync( + actualChatKey, + $"✅ 已完成:已导入并切换到会话 {shortId}...\n🛠️ CLI 工具: {toolLabel}", + username, + appId); + } + catch (Exception sendEx) + { + _logger.LogDebug(sendEx, "[Feishu] 发送导入完成提示失败(可忽略)"); + } + + var response = await HandleOpenSessionManagerAsync(actualChatKey, operatorUserId); + response.Toast = new CardActionTriggerResponseDto.ToastSuffix + { + Content = "✅ 已导入本地会话,并切换为当前会话", + Type = CardActionTriggerResponseDto.ToastSuffix.ToastType.Success + }; + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "导入外部 CLI 会话失败"); + return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 导入失败,请稍后重试。", "error"); + } + } + public async Task BuildProjectManagerCardAsync(string chatId, string? operatorUserId) { var actualChatKey = NormalizeChatKey(chatId); diff --git a/WebCodeCli.Domain/Domain/Service/Channels/FeishuCommandService.cs b/WebCodeCli.Domain/Domain/Service/Channels/FeishuCommandService.cs index e0b8336..f8838cd 100644 --- a/WebCodeCli.Domain/Domain/Service/Channels/FeishuCommandService.cs +++ b/WebCodeCli.Domain/Domain/Service/Channels/FeishuCommandService.cs @@ -240,12 +240,14 @@ private static IEnumerable GetToolBuiltInCommands(string toolId) { "claude-code" => new[] { + BuildToolBuiltIn(toolId, "history", "/history", "查看当前 CLI 会话的最近历史消息"), BuildToolBuiltIn(toolId, "init", "/init", "初始化当前项目的上下文与约定"), BuildToolBuiltIn(toolId, "clear", "/clear", "清空当前会话上下文"), BuildToolBuiltIn(toolId, "compact", "/compact", "压缩当前会话上下文,减少 token 占用") }, "codex" => new[] { + BuildToolBuiltIn(toolId, "history", "/history", "查看当前 CLI 会话的最近历史消息"), BuildToolBuiltIn(toolId, "init", "/init", "初始化当前项目的上下文与约定"), BuildToolBuiltIn(toolId, "help", "/help", "显示 Codex 交互命令帮助"), BuildToolBuiltIn(toolId, "status", "/status", "查看当前会话状态和工作区信息"), @@ -255,6 +257,7 @@ private static IEnumerable GetToolBuiltInCommands(string toolId) }, "opencode" => new[] { + BuildToolBuiltIn(toolId, "history", "/history", "查看当前 CLI 会话的最近历史消息"), BuildToolBuiltIn(toolId, "init", "/init", "为当前项目初始化 OpenCode 工作上下文"), BuildToolBuiltIn(toolId, "help", "/help", "显示 OpenCode 交互命令帮助"), BuildToolBuiltIn(toolId, "undo", "/undo", "回滚上一条交互产生的变更"), diff --git a/WebCodeCli.Domain/Domain/Service/CliExecutorService.cs b/WebCodeCli.Domain/Domain/Service/CliExecutorService.cs index 2121531..c00cda7 100644 --- a/WebCodeCli.Domain/Domain/Service/CliExecutorService.cs +++ b/WebCodeCli.Domain/Domain/Service/CliExecutorService.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using WebCodeCli.Domain.Common; using WebCodeCli.Domain.Common.Extensions; using WebCodeCli.Domain.Common.Options; using WebCodeCli.Domain.Domain.Model; @@ -175,11 +176,73 @@ public bool SupportsStreamParsing(CliToolConfig tool) public string? GetCliThreadId(string sessionId) { + if (string.IsNullOrWhiteSpace(sessionId)) + { + return null; + } + lock (_cliSessionLock) { - _cliThreadIds.TryGetValue(sessionId, out var threadId); - return threadId; + if (_cliThreadIds.TryGetValue(sessionId, out var cached) && !string.IsNullOrWhiteSpace(cached)) + { + return cached; + } + } + + // 缓存未命中时,从数据库回退(ChatSession.CliThreadId / SessionOutput.ActiveThreadId) + try + { + using var scope = _serviceProvider.CreateScope(); + + var sessionRepo = scope.ServiceProvider.GetService(); + var session = sessionRepo?.GetByIdAsync(sessionId).GetAwaiter().GetResult(); + var threadId = session?.CliThreadId; + + if (string.IsNullOrWhiteSpace(threadId)) + { + var outputService = scope.ServiceProvider.GetService(); + var output = outputService?.GetBySessionIdAsync(sessionId).GetAwaiter().GetResult(); + if (!string.IsNullOrWhiteSpace(output?.ActiveThreadId)) + { + threadId = output.ActiveThreadId; + } + } + + if (string.IsNullOrWhiteSpace(threadId) && session != null) + { + threadId = CliThreadIdRecoveryHelper.TryRecoverFromImportedTitle(session.ToolId, session.Title); + if (!string.IsNullOrWhiteSpace(threadId)) + { + _logger.LogInformation("从导入标题恢复会话 {SessionId} 的 CLI ThreadId: {ThreadId}", sessionId, threadId); + + lock (_cliSessionLock) + { + _cliThreadIds[sessionId] = threadId; + } + + if (sessionRepo != null) + { + _ = sessionRepo.UpdateCliThreadIdAsync(sessionId, threadId).GetAwaiter().GetResult(); + } + } + } + + if (!string.IsNullOrWhiteSpace(threadId)) + { + lock (_cliSessionLock) + { + _cliThreadIds[sessionId] = threadId; + } + + return threadId; + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "从数据库回退 CLI ThreadId 失败: {SessionId}", sessionId); } + + return null; } public void SetCliThreadId(string sessionId, string threadId) @@ -191,6 +254,18 @@ public void SetCliThreadId(string sessionId, string threadId) _cliThreadIds[sessionId] = threadId; _logger.LogInformation("设置会话 {SessionId} 的CLI线程ID: {ThreadId}", sessionId, threadId); } + + // 最佳努力:持久化到数据库,保证服务重启/页面刷新后仍可恢复会话 + try + { + using var scope = _serviceProvider.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + _ = repo.UpdateCliThreadIdAsync(sessionId, threadId).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "持久化 CLI ThreadId 失败: {SessionId}", sessionId); + } } #endregion @@ -1142,6 +1217,39 @@ private string GetOrCreateSessionWorkspace(string sessionId) return existingWorkspace; } + // 优先使用数据库中已绑定的工作目录(适用于:自定义目录 / 外部会话导入 / 进程重启后恢复) + try + { + using var scope = _serviceProvider.CreateScope(); + var chatSessionRepository = scope.ServiceProvider.GetService(); + var session = chatSessionRepository?.GetByIdAsync(sessionId).GetAwaiter().GetResult(); + + if (session != null) + { + if (!string.IsNullOrWhiteSpace(session.WorkspacePath) && Directory.Exists(session.WorkspacePath)) + { + _sessionWorkspaces[sessionId] = session.WorkspacePath; + return session.WorkspacePath; + } + + // 自定义目录但不存在时,避免悄悄创建临时目录导致误用 + if (session.IsCustomWorkspace && !string.IsNullOrWhiteSpace(session.WorkspacePath)) + { + throw new InvalidOperationException( + $"会话 {sessionId} 工作目录不存在或已被清理,请重新创建会话"); + } + } + } + catch (InvalidOperationException) + { + // 自定义目录不存在时:直接失败,避免静默创建临时目录导致误用 + throw; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "从数据库恢复会话工作目录失败,将创建临时目录: {SessionId}", sessionId); + } + // 创建新的会话工作目录 var workspaceRoot = GetEffectiveWorkspaceRoot(); var workspacePath = Path.Combine(workspaceRoot, sessionId); @@ -1159,6 +1267,22 @@ private string GetOrCreateSessionWorkspace(string sessionId) // 在工作目录中创建一个标记文件,记录创建时间 var markerFile = Path.Combine(workspacePath, ".workspace_info"); File.WriteAllText(markerFile, $"Created: {DateTime.UtcNow:O}\nSessionId: {sessionId}"); + + // 最佳努力:把新创建的临时目录绑定写回数据库,避免后续 GetSessionWorkspacePath 查询不到 + try + { + using var scope = _serviceProvider.CreateScope(); + var chatSessionRepository = scope.ServiceProvider.GetService(); + if (chatSessionRepository != null) + { + _ = chatSessionRepository.UpdateWorkspaceBindingAsync(sessionId, workspacePath, isCustomWorkspace: false) + .GetAwaiter().GetResult(); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "写回会话工作目录绑定失败(可忽略): {SessionId}", sessionId); + } return workspacePath; } diff --git a/WebCodeCli.Domain/Domain/Service/CliToolEnvironmentService.cs b/WebCodeCli.Domain/Domain/Service/CliToolEnvironmentService.cs index 59fbb94..07b4f62 100644 --- a/WebCodeCli.Domain/Domain/Service/CliToolEnvironmentService.cs +++ b/WebCodeCli.Domain/Domain/Service/CliToolEnvironmentService.cs @@ -16,49 +16,48 @@ namespace WebCodeCli.Domain.Domain.Service; public interface ICliToolEnvironmentService { /// - /// 获取指定工具的环境变量配置(优先使用激活方案,其次数据库默认配置,最后 appsettings) + /// 获取指定工具的环境变量配置。 + /// 优先级:激活方案 > 数据库默认配置 > appsettings,然后叠加当前用户覆盖。 /// Task> GetEnvironmentVariablesAsync(string toolId, string? username = null); /// - /// 保存指定工具的默认环境变量配置到数据库 + /// 保存指定工具的用户环境变量配置到数据库。 /// Task SaveEnvironmentVariablesAsync(string toolId, Dictionary envVars, string? username = null); /// - /// 删除指定工具的环境变量配置 + /// 删除指定工具的用户环境变量配置。 /// Task DeleteEnvironmentVariablesAsync(string toolId, string? username = null); /// - /// 重置为appsettings中的默认配置 + /// 重置为继承默认配置。 /// - Task> ResetToDefaultAsync(string toolId); - - // ── 配置方案(多套 AI 环境变量) ── + Task> ResetToDefaultAsync(string toolId, string? username = null); /// - /// 获取指定工具的所有配置方案 + /// 获取指定工具的所有配置方案。 /// Task> GetProfilesAsync(string toolId); /// - /// 保存(新建或更新)一个配置方案 + /// 保存(新建或更新)一个配置方案。 /// Task SaveProfileAsync(string toolId, int profileId, string profileName, Dictionary envVars); /// - /// 激活指定配置方案(将其设为当前生效方案) + /// 激活指定配置方案(将其设为当前生效方案)。 /// Task ActivateProfileAsync(string toolId, int profileId); /// - /// 取消所有方案激活,回退到默认配置 + /// 取消所有方案激活,回退到默认配置。 /// Task DeactivateProfilesAsync(string toolId); /// - /// 删除指定配置方案 + /// 删除指定配置方案。 /// Task DeleteProfileAsync(string toolId, int profileId); } @@ -73,66 +72,40 @@ public class CliToolEnvironmentService : ICliToolEnvironmentService private readonly CliToolsOption _options; private readonly ICliToolEnvironmentVariableRepository _repository; private readonly ICliToolEnvProfileRepository _profileRepository; + private readonly IUserCliToolEnvironmentVariableRepository _userRepository; + private readonly IUserContextService _userContextService; public CliToolEnvironmentService( ILogger logger, IOptions options, ICliToolEnvironmentVariableRepository repository, - ICliToolEnvProfileRepository profileRepository) + ICliToolEnvProfileRepository profileRepository, + IUserCliToolEnvironmentVariableRepository userRepository, + IUserContextService userContextService) { _logger = logger; _options = options.Value; _repository = repository; _profileRepository = profileRepository; + _userRepository = userRepository; + _userContextService = userContextService; } /// - /// 获取指定工具的环境变量配置 - /// 优先级:激活的配置方案 > 数据库默认配置 > appsettings 配置 + /// 获取指定工具的环境变量配置。 + /// 优先级:激活方案 > 数据库默认配置 > appsettings,然后叠加当前用户覆盖。 /// public async Task> GetEnvironmentVariablesAsync(string toolId, string? username = null) { try { - // 1. 优先使用激活的配置方案 - var activeProfile = await _profileRepository.GetActiveProfileAsync(toolId); - if (activeProfile != null && !string.IsNullOrWhiteSpace(activeProfile.EnvVarsJson)) - { - try - { - _logger.LogInformation("从激活方案 [{ProfileName}] 加载工具 {ToolId} 的环境变量配置", activeProfile.ProfileName, toolId); - var profileVars = JsonSerializer.Deserialize>(activeProfile.EnvVarsJson) ?? new(); - return profileVars - .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value)) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - } - catch (JsonException jsonEx) - { - _logger.LogWarning(jsonEx, - "激活方案 [{ProfileName}] 的环境变量 JSON 无效,忽略该方案并回退到数据库和配置文件。ToolId: {ToolId}", - activeProfile.ProfileName, - toolId); - } - } + var result = await GetInheritedEnvironmentVariablesAsync(toolId); - // 2. 从数据库默认配置读取 - var dbEnvVars = await _repository.GetEnvironmentVariablesByToolIdAsync(toolId); - if (dbEnvVars.Any()) - { - _logger.LogInformation("从数据库加载工具 {ToolId} 的环境变量配置", toolId); - return dbEnvVars - .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value)) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); - } - - // 3. 从 appsettings 读取 - var tool = _options.Tools.FirstOrDefault(t => t.Id == toolId); - if (tool?.EnvironmentVariables != null && tool.EnvironmentVariables.Any()) + var resolvedUsername = ResolveUsername(username); + if (!string.IsNullOrWhiteSpace(resolvedUsername)) { - _logger.LogInformation("从配置文件加载工具 {ToolId} 的环境变量配置", toolId); - return tool.EnvironmentVariables - .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value)) - .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + var userEnvVars = await _userRepository.GetEnvironmentVariablesAsync(resolvedUsername, toolId); + ApplyOverrides(result, userEnvVars); } _logger.LogInformation("获取工具 {ToolId} 的环境变量配置,用户={Username},最终 {Count} 个", toolId, resolvedUsername, result.Count); @@ -146,7 +119,7 @@ public async Task> GetEnvironmentVariablesAsync(strin } /// - /// 保存指定工具的环境变量配置到数据库 + /// 保存指定工具的用户环境变量配置到数据库。 /// public async Task SaveEnvironmentVariablesAsync(string toolId, Dictionary envVars, string? username = null) { @@ -158,11 +131,21 @@ public async Task SaveEnvironmentVariablesAsync(string toolId, Dictionary< return false; } - var result = await _userRepository.SaveEnvironmentVariablesAsync(resolvedUsername, toolId, envVars); + var normalizedEnvVars = NormalizeEnvVars(envVars, keepEmptyValues: false); + var inheritedEnvVars = await GetInheritedEnvironmentVariablesAsync(toolId); + var persistedEnvVars = BuildPersistedUserEnvVars(normalizedEnvVars, inheritedEnvVars); + + var result = await _userRepository.SaveEnvironmentVariablesAsync(resolvedUsername, toolId, persistedEnvVars); if (result) { - _logger.LogInformation("成功保存工具 {ToolId} 的用户环境变量配置,用户={Username}", toolId, resolvedUsername); + _logger.LogInformation( + "成功保存工具 {ToolId} 的用户环境变量配置,用户={Username},提交 {SubmittedCount} 个,落库 {PersistedCount} 个", + toolId, + resolvedUsername, + normalizedEnvVars.Count, + persistedEnvVars.Count); } + return result; } catch (Exception ex) @@ -173,7 +156,7 @@ public async Task SaveEnvironmentVariablesAsync(string toolId, Dictionary< } /// - /// 删除指定工具的环境变量配置 + /// 删除指定工具的用户环境变量配置。 /// public async Task DeleteEnvironmentVariablesAsync(string toolId, string? username = null) { @@ -190,6 +173,7 @@ public async Task DeleteEnvironmentVariablesAsync(string toolId, string? u { _logger.LogInformation("成功删除工具 {ToolId} 的用户环境变量配置,用户={Username}", toolId, resolvedUsername); } + return result; } catch (Exception ex) @@ -200,7 +184,7 @@ public async Task DeleteEnvironmentVariablesAsync(string toolId, string? u } /// - /// 重置为appsettings中的默认配置 + /// 重置为继承默认配置。 /// public async Task> ResetToDefaultAsync(string toolId, string? username = null) { @@ -216,10 +200,8 @@ public async Task> ResetToDefaultAsync(string toolId, } } - // ── 配置方案(多套 AI 环境变量) ── - /// - /// 获取指定工具的所有配置方案 + /// 获取指定工具的所有配置方案。 /// public async Task> GetProfilesAsync(string toolId) { @@ -235,7 +217,7 @@ public async Task> GetProfilesAsync(string toolId) } /// - /// 保存(新建或更新)一个配置方案 + /// 保存(新建或更新)一个配置方案。 /// public async Task SaveProfileAsync(string toolId, int profileId, string profileName, Dictionary envVars) { @@ -245,7 +227,6 @@ public async Task> GetProfilesAsync(string toolId) if (profileId <= 0) { - // 新建方案 var newProfile = new CliToolEnvProfile { ToolId = toolId, @@ -255,27 +236,26 @@ public async Task> GetProfilesAsync(string toolId) CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; + var newId = await _profileRepository.InsertReturnIdentityAsync(newProfile); newProfile.Id = newId; _logger.LogInformation("成功新建工具 {ToolId} 的配置方案 [{ProfileName}]", toolId, profileName); return newProfile; } - else + + var existing = await _profileRepository.GetByIdAsync(profileId); + if (existing == null || existing.ToolId != toolId) { - // 更新已有方案 - var existing = await _profileRepository.GetByIdAsync(profileId); - if (existing == null || existing.ToolId != toolId) - { - _logger.LogWarning("未找到工具 {ToolId} 的配置方案 {ProfileId}", toolId, profileId); - return null; - } - existing.ProfileName = profileName; - existing.EnvVarsJson = envVarsJson; - existing.UpdatedAt = DateTime.Now; - await _profileRepository.UpdateAsync(existing); - _logger.LogInformation("成功更新工具 {ToolId} 的配置方案 [{ProfileName}]", toolId, profileName); - return existing; + _logger.LogWarning("未找到工具 {ToolId} 的配置方案 {ProfileId}", toolId, profileId); + return null; } + + existing.ProfileName = profileName; + existing.EnvVarsJson = envVarsJson; + existing.UpdatedAt = DateTime.Now; + await _profileRepository.UpdateAsync(existing); + _logger.LogInformation("成功更新工具 {ToolId} 的配置方案 [{ProfileName}]", toolId, profileName); + return existing; } catch (Exception ex) { @@ -285,7 +265,7 @@ public async Task> GetProfilesAsync(string toolId) } /// - /// 激活指定配置方案 + /// 激活指定配置方案。 /// public async Task ActivateProfileAsync(string toolId, int profileId) { @@ -296,6 +276,7 @@ public async Task ActivateProfileAsync(string toolId, int profileId) { _logger.LogInformation("成功激活工具 {ToolId} 的配置方案 {ProfileId}", toolId, profileId); } + return result; } catch (Exception ex) @@ -306,7 +287,7 @@ public async Task ActivateProfileAsync(string toolId, int profileId) } /// - /// 取消所有方案激活,回退到默认配置 + /// 取消所有方案激活,回退到默认配置。 /// public async Task DeactivateProfilesAsync(string toolId) { @@ -317,6 +298,7 @@ public async Task DeactivateProfilesAsync(string toolId) { _logger.LogInformation("已取消工具 {ToolId} 的所有方案激活状态", toolId); } + return result; } catch (Exception ex) @@ -327,7 +309,7 @@ public async Task DeactivateProfilesAsync(string toolId) } /// - /// 删除指定配置方案 + /// 删除指定配置方案。 /// public async Task DeleteProfileAsync(string toolId, int profileId) { @@ -338,6 +320,7 @@ public async Task DeleteProfileAsync(string toolId, int profileId) { _logger.LogInformation("成功删除工具 {ToolId} 的配置方案 {ProfileId}", toolId, profileId); } + return result; } catch (Exception ex) @@ -346,4 +329,105 @@ public async Task DeleteProfileAsync(string toolId, int profileId) return false; } } + + private string ResolveUsername(string? username) + { + return string.IsNullOrWhiteSpace(username) + ? _userContextService.GetCurrentUsername() + : username.Trim(); + } + + private async Task> GetInheritedEnvironmentVariablesAsync(string toolId) + { + var activeProfile = await _profileRepository.GetActiveProfileAsync(toolId); + if (activeProfile != null && !string.IsNullOrWhiteSpace(activeProfile.EnvVarsJson)) + { + try + { + _logger.LogInformation("从激活方案 [{ProfileName}] 加载工具 {ToolId} 的环境变量配置", activeProfile.ProfileName, toolId); + var profileVars = JsonSerializer.Deserialize>(activeProfile.EnvVarsJson) ?? new(); + return NormalizeEnvVars(profileVars, keepEmptyValues: false); + } + catch (JsonException jsonEx) + { + _logger.LogWarning( + jsonEx, + "激活方案 [{ProfileName}] 的环境变量 JSON 无效,忽略该方案并回退到数据库和配置文件。ToolId: {ToolId}", + activeProfile.ProfileName, + toolId); + } + } + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var tool = _options.Tools.FirstOrDefault(t => t.Id == toolId); + if (tool?.EnvironmentVariables != null) + { + ApplyOverrides(result, tool.EnvironmentVariables); + } + + var dbEnvVars = await _repository.GetEnvironmentVariablesByToolIdAsync(toolId); + ApplyOverrides(result, dbEnvVars); + return result; + } + + private static Dictionary NormalizeEnvVars(Dictionary envVars, bool keepEmptyValues) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in envVars) + { + var key = kvp.Key?.Trim(); + if (string.IsNullOrWhiteSpace(key)) + { + continue; + } + + var value = kvp.Value?.Trim() ?? string.Empty; + if (!keepEmptyValues && string.IsNullOrWhiteSpace(value)) + { + continue; + } + + result[key] = value; + } + + return result; + } + + private static Dictionary BuildPersistedUserEnvVars( + Dictionary requestedEnvVars, + Dictionary inheritedEnvVars) + { + var result = new Dictionary(requestedEnvVars, StringComparer.OrdinalIgnoreCase); + foreach (var inheritedKey in inheritedEnvVars.Keys) + { + if (!requestedEnvVars.ContainsKey(inheritedKey)) + { + // 空字符串表示显式移除继承的环境变量,避免默认值在下次读取时再次合并回来。 + result[inheritedKey] = string.Empty; + } + } + + return result; + } + + private static void ApplyOverrides(Dictionary target, IEnumerable> envVars) + { + foreach (var kvp in envVars) + { + var key = kvp.Key?.Trim(); + if (string.IsNullOrWhiteSpace(key)) + { + continue; + } + + var value = kvp.Value?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(value)) + { + target.Remove(key); + continue; + } + + target[key] = value; + } + } } diff --git a/WebCodeCli.Domain/Domain/Service/ExternalCliSessionHistoryService.cs b/WebCodeCli.Domain/Domain/Service/ExternalCliSessionHistoryService.cs new file mode 100644 index 0000000..0399597 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/ExternalCliSessionHistoryService.cs @@ -0,0 +1,733 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using WebCodeCli.Domain.Common.Extensions; +using WebCodeCli.Domain.Domain.Model; + +namespace WebCodeCli.Domain.Domain.Service; + +public interface IExternalCliSessionHistoryService +{ + /// + /// 读取当前系统账户下外部 CLI 原生会话的最近历史消息 + /// + Task> GetRecentMessagesAsync( + string toolId, + string cliThreadId, + int maxCount = 20, + CancellationToken cancellationToken = default); +} + +[ServiceDescription(typeof(IExternalCliSessionHistoryService), ServiceLifetime.Scoped)] +public class ExternalCliSessionHistoryService : IExternalCliSessionHistoryService +{ + private readonly ILogger _logger; + + public ExternalCliSessionHistoryService(ILogger logger) + { + _logger = logger; + } + + public async Task> GetRecentMessagesAsync( + string toolId, + string cliThreadId, + int maxCount = 20, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(toolId) || string.IsNullOrWhiteSpace(cliThreadId)) + { + return []; + } + + var normalizedToolId = NormalizeToolId(toolId); + var normalizedThreadId = cliThreadId.Trim(); + var effectiveMaxCount = maxCount <= 0 ? 20 : maxCount; + + try + { + var messages = normalizedToolId switch + { + "codex" => await GetCodexMessagesAsync(normalizedThreadId, effectiveMaxCount, cancellationToken), + "claude-code" => await GetClaudeCodeMessagesAsync(normalizedThreadId, effectiveMaxCount, cancellationToken), + "opencode" => await GetOpenCodeMessagesAsync(normalizedThreadId, effectiveMaxCount, cancellationToken), + _ => [] + }; + + return messages + .Where(message => !string.IsNullOrWhiteSpace(message.Role) && !string.IsNullOrWhiteSpace(message.Content)) + .OrderBy(message => message.CreatedAt ?? DateTime.MinValue) + .TakeLast(effectiveMaxCount) + .ToList(); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "读取外部 CLI 原生历史失败: ToolId={ToolId}, CliThreadId={CliThreadId}", + normalizedToolId, + normalizedThreadId); + return []; + } + } + + protected virtual string? GetCodexSessionsRootPath() + { + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return string.IsNullOrWhiteSpace(userProfile) + ? null + : Path.Combine(userProfile, ".codex", "sessions"); + } + + protected virtual string? GetClaudeProjectsRootPath() + { + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return string.IsNullOrWhiteSpace(userProfile) + ? null + : Path.Combine(userProfile, ".claude", "projects"); + } + + protected virtual async Task<(int ExitCode, string Stdout, string Stderr)> RunProcessAsync( + string fileName, + string arguments, + TimeSpan timeout, + CancellationToken cancellationToken) + { + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + process.Start(); + + var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); + var waitForExitTask = process.WaitForExitAsync(cancellationToken); + + var completedTask = await Task.WhenAny(waitForExitTask, Task.Delay(timeout, cancellationToken)); + if (completedTask != waitForExitTask) + { + try + { + process.Kill(entireProcessTree: true); + } + catch + { + // ignore + } + + return (-1, string.Empty, $"Process timeout after {timeout.TotalSeconds:F0}s"); + } + + var stdout = await stdoutTask; + var stderr = await stderrTask; + return (process.ExitCode, stdout, stderr); + } + + private async Task> GetCodexMessagesAsync( + string cliThreadId, + int maxCount, + CancellationToken cancellationToken) + { + var filePath = FindCodexRolloutFile(cliThreadId, cancellationToken); + if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + { + return []; + } + + return await ParseCodexRolloutFileAsync(filePath, maxCount, cancellationToken); + } + + private async Task> GetClaudeCodeMessagesAsync( + string cliThreadId, + int maxCount, + CancellationToken cancellationToken) + { + var transcriptPath = FindClaudeTranscriptFile(cliThreadId, cancellationToken); + if (string.IsNullOrWhiteSpace(transcriptPath) || !File.Exists(transcriptPath)) + { + return []; + } + + return await ParseClaudeTranscriptFileAsync(transcriptPath, maxCount, cancellationToken); + } + + private async Task> GetOpenCodeMessagesAsync( + string cliThreadId, + int maxCount, + CancellationToken cancellationToken) + { + var escapedSessionId = cliThreadId.Replace("\"", "\\\"", StringComparison.Ordinal); + var (exitCode, stdout, stderr) = await RunProcessAsync( + "opencode", + $"export {escapedSessionId}", + TimeSpan.FromSeconds(15), + cancellationToken); + + if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout)) + { + _logger.LogDebug( + "OpenCode export 失败: ExitCode={ExitCode}, CliThreadId={CliThreadId}, Stderr={Stderr}", + exitCode, + cliThreadId, + Truncate(stderr, 400)); + return []; + } + + return ParseOpenCodeExport(stdout, maxCount); + } + + private string? FindCodexRolloutFile(string cliThreadId, CancellationToken cancellationToken) + { + var sessionsRoot = GetCodexSessionsRootPath(); + if (string.IsNullOrWhiteSpace(sessionsRoot) || !Directory.Exists(sessionsRoot)) + { + return null; + } + + try + { + var directCandidates = Directory + .EnumerateFiles(sessionsRoot, $"*{cliThreadId}*.jsonl", SearchOption.AllDirectories) + .OrderByDescending(File.GetLastWriteTimeUtc) + .ToList(); + + if (directCandidates.Count > 0) + { + return directCandidates[0]; + } + + foreach (var file in Directory.EnumerateFiles(sessionsRoot, "rollout-*.jsonl", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var firstLine = ReadFirstNonEmptyLine(file, maxLines: 3); + if (string.IsNullOrWhiteSpace(firstLine)) + { + continue; + } + + try + { + using var document = JsonDocument.Parse(firstLine); + var root = document.RootElement; + if (!TryGetProperty(root, "payload", out var payload) || payload.ValueKind != JsonValueKind.Object) + { + continue; + } + + var sessionId = GetString(payload, "id"); + if (string.Equals(sessionId, cliThreadId, StringComparison.OrdinalIgnoreCase)) + { + return file; + } + } + catch + { + // ignore broken lines + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "定位 Codex rollout 文件失败"); + } + + return null; + } + + private string? FindClaudeTranscriptFile(string cliThreadId, CancellationToken cancellationToken) + { + var projectsRoot = GetClaudeProjectsRootPath(); + if (string.IsNullOrWhiteSpace(projectsRoot) || !Directory.Exists(projectsRoot)) + { + return null; + } + + try + { + foreach (var indexFile in Directory.EnumerateFiles(projectsRoot, "sessions-index.json", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var json = ReadAllTextShared(indexFile); + if (string.IsNullOrWhiteSpace(json)) + { + continue; + } + + using var document = JsonDocument.Parse(json); + if (!TryGetProperty(document.RootElement, "entries", out var entries) || entries.ValueKind != JsonValueKind.Array) + { + continue; + } + + foreach (var entry in entries.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) + { + continue; + } + + var sessionId = GetString(entry, "sessionId", "session_id"); + if (!string.Equals(sessionId, cliThreadId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var fullPath = GetString(entry, "fullPath", "full_path"); + if (!string.IsNullOrWhiteSpace(fullPath) && File.Exists(fullPath)) + { + return fullPath; + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "读取 Claude sessions-index.json 失败,继续尝试直接定位 transcript: {File}", indexFile); + } + } + + var transcriptCandidates = Directory + .EnumerateFiles(projectsRoot, $"{cliThreadId}.jsonl", SearchOption.AllDirectories) + .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}subagents{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(File.GetLastWriteTimeUtc) + .ToList(); + + return transcriptCandidates.FirstOrDefault(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "定位 Claude Code transcript 文件失败"); + return null; + } + } + + private async Task> ParseCodexRolloutFileAsync( + string filePath, + int maxCount, + CancellationToken cancellationToken) + { + var messages = new List(); + + await foreach (var line in ReadLinesAsync(filePath, cancellationToken)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + try + { + using var document = JsonDocument.Parse(line); + var root = document.RootElement; + if (!string.Equals(GetString(root, "type"), "response_item", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!TryGetProperty(root, "payload", out var payload) || payload.ValueKind != JsonValueKind.Object) + { + continue; + } + + if (!string.Equals(GetString(payload, "type"), "message", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var role = GetString(payload, "role"); + if (!IsSupportedRole(role)) + { + continue; + } + + var content = ExtractCodexMessageContent(payload); + if (string.IsNullOrWhiteSpace(content)) + { + continue; + } + + messages.Add(new ExternalCliHistoryMessage + { + Role = role!, + Content = content, + CreatedAt = GetDateTime(root, "timestamp") ?? GetDateTime(payload, "timestamp"), + RawType = "codex.message" + }); + } + catch (JsonException) + { + // ignore bad lines + } + } + + return messages.TakeLast(maxCount).ToList(); + } + + private async Task> ParseClaudeTranscriptFileAsync( + string filePath, + int maxCount, + CancellationToken cancellationToken) + { + var messages = new List(); + + await foreach (var line in ReadLinesAsync(filePath, cancellationToken)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + try + { + using var document = JsonDocument.Parse(line); + var root = document.RootElement; + var recordType = GetString(root, "type"); + if (!IsSupportedRole(recordType)) + { + continue; + } + + if (!TryGetProperty(root, "message", out var messageElement) || messageElement.ValueKind != JsonValueKind.Object) + { + continue; + } + + var role = GetString(messageElement, "role") ?? recordType; + if (!IsSupportedRole(role)) + { + continue; + } + + var content = ExtractClaudeMessageContent(messageElement); + if (string.IsNullOrWhiteSpace(content)) + { + continue; + } + + messages.Add(new ExternalCliHistoryMessage + { + Role = role!, + Content = content, + CreatedAt = GetDateTime(root, "timestamp"), + RawType = $"claude.{recordType}" + }); + } + catch (JsonException) + { + // ignore bad lines + } + } + + return messages.TakeLast(maxCount).ToList(); + } + + private List ParseOpenCodeExport(string json, int maxCount) + { + var messages = new List(); + + try + { + using var document = JsonDocument.Parse(json); + if (!TryGetProperty(document.RootElement, "messages", out var items) || items.ValueKind != JsonValueKind.Array) + { + return messages; + } + + foreach (var item in items.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) + { + continue; + } + + if (!TryGetProperty(item, "info", out var info) || info.ValueKind != JsonValueKind.Object) + { + continue; + } + + var role = GetString(info, "role"); + if (!IsSupportedRole(role)) + { + continue; + } + + if (!TryGetProperty(item, "parts", out var parts) || parts.ValueKind != JsonValueKind.Array) + { + continue; + } + + var content = ExtractTextParts(parts, "text"); + if (string.IsNullOrWhiteSpace(content)) + { + continue; + } + + DateTime? createdAt = null; + if (TryGetProperty(info, "time", out var timeElement) && timeElement.ValueKind == JsonValueKind.Object) + { + createdAt = GetDateTime(timeElement, "created"); + } + + messages.Add(new ExternalCliHistoryMessage + { + Role = role!, + Content = content, + CreatedAt = createdAt, + RawType = "opencode.message" + }); + } + } + catch (JsonException ex) + { + _logger.LogDebug(ex, "解析 OpenCode export JSON 失败"); + } + + return messages.TakeLast(maxCount).ToList(); + } + + private static string? NormalizeToolId(string? toolId) + { + if (string.IsNullOrWhiteSpace(toolId)) + { + return null; + } + + if (toolId.Equals("claude", StringComparison.OrdinalIgnoreCase)) + { + return "claude-code"; + } + + if (toolId.Equals("opencode-cli", StringComparison.OrdinalIgnoreCase)) + { + return "opencode"; + } + + return toolId.Trim(); + } + + private static bool IsSupportedRole(string? role) + { + return string.Equals(role, "user", StringComparison.OrdinalIgnoreCase) + || string.Equals(role, "assistant", StringComparison.OrdinalIgnoreCase); + } + + private static string ExtractCodexMessageContent(JsonElement payload) + { + if (!TryGetProperty(payload, "content", out var contentElement)) + { + return string.Empty; + } + + if (contentElement.ValueKind == JsonValueKind.String) + { + return contentElement.GetString() ?? string.Empty; + } + + if (contentElement.ValueKind != JsonValueKind.Array) + { + return string.Empty; + } + + return ExtractTextParts(contentElement, "input_text", "output_text", "text"); + } + + private static string ExtractClaudeMessageContent(JsonElement messageElement) + { + if (!TryGetProperty(messageElement, "content", out var contentElement)) + { + return string.Empty; + } + + if (contentElement.ValueKind == JsonValueKind.String) + { + return contentElement.GetString() ?? string.Empty; + } + + if (contentElement.ValueKind != JsonValueKind.Array) + { + return string.Empty; + } + + return ExtractTextParts(contentElement, "text"); + } + + private static string ExtractTextParts(JsonElement items, params string[] supportedPartTypes) + { + if (items.ValueKind != JsonValueKind.Array) + { + return string.Empty; + } + + var texts = new List(); + foreach (var item in items.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + var textValue = item.GetString(); + if (!string.IsNullOrWhiteSpace(textValue)) + { + texts.Add(textValue.Trim()); + } + + continue; + } + + if (item.ValueKind != JsonValueKind.Object) + { + continue; + } + + var type = GetString(item, "type"); + if (string.IsNullOrWhiteSpace(type) + || !supportedPartTypes.Any(candidate => string.Equals(candidate, type, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + var text = GetString(item, "text", "content"); + if (!string.IsNullOrWhiteSpace(text)) + { + texts.Add(text.Trim()); + } + } + + return string.Join("\n", texts.Where(text => !string.IsNullOrWhiteSpace(text))); + } + + private static string? ReadFirstNonEmptyLine(string filePath, int maxLines) + { + using var stream = OpenSharedReadStream(filePath); + using var reader = new StreamReader(stream); + for (var index = 0; index < maxLines && !reader.EndOfStream; index++) + { + var line = reader.ReadLine(); + if (!string.IsNullOrWhiteSpace(line)) + { + return line; + } + } + + return null; + } + + private static string ReadAllTextShared(string filePath) + { + using var stream = OpenSharedReadStream(filePath); + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + return reader.ReadToEnd(); + } + + private static async IAsyncEnumerable ReadLinesAsync( + string filePath, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + using var stream = OpenSharedReadStream(filePath); + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + while (!reader.EndOfStream) + { + cancellationToken.ThrowIfCancellationRequested(); + var line = await reader.ReadLineAsync(cancellationToken); + if (line != null) + { + yield return line; + } + } + } + + private static FileStream OpenSharedReadStream(string filePath) + { + return new FileStream( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + } + + private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement value) + { + foreach (var property in element.EnumerateObject()) + { + if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + + value = default; + return false; + } + + private static string? GetString(JsonElement element, params string[] propertyNames) + { + foreach (var propertyName in propertyNames) + { + if (!TryGetProperty(element, propertyName, out var value)) + { + continue; + } + + if (value.ValueKind == JsonValueKind.String) + { + return value.GetString(); + } + + if (value.ValueKind == JsonValueKind.Number || value.ValueKind == JsonValueKind.True || value.ValueKind == JsonValueKind.False) + { + return value.ToString(); + } + } + + return null; + } + + private static DateTime? GetDateTime(JsonElement element, params string[] propertyNames) + { + foreach (var propertyName in propertyNames) + { + if (!TryGetProperty(element, propertyName, out var value)) + { + continue; + } + + if (value.ValueKind == JsonValueKind.String && DateTime.TryParse(value.GetString(), out var parsedDateTime)) + { + return parsedDateTime; + } + + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var unixValue)) + { + if (unixValue > 10_000_000_000) + { + return DateTimeOffset.FromUnixTimeMilliseconds(unixValue).LocalDateTime; + } + + return DateTimeOffset.FromUnixTimeSeconds(unixValue).LocalDateTime; + } + } + + return null; + } + + private static string Truncate(string? value, int maxLength) + { + if (string.IsNullOrWhiteSpace(value) || value.Length <= maxLength) + { + return value ?? string.Empty; + } + + return value[..maxLength] + "..."; + } +} diff --git a/WebCodeCli.Domain/Domain/Service/ExternalCliSessionService.cs b/WebCodeCli.Domain/Domain/Service/ExternalCliSessionService.cs new file mode 100644 index 0000000..8351061 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/ExternalCliSessionService.cs @@ -0,0 +1,1254 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using WebCodeCli.Domain.Common.Extensions; +using WebCodeCli.Domain.Domain.Model; +using WebCodeCli.Domain.Repositories.Base.ChatSession; + +namespace WebCodeCli.Domain.Domain.Service; + +public interface IExternalCliSessionService +{ + /// + /// 发现当前操作系统账户下的外部 CLI 会话(并标记当前 Web 用户是否已导入) + /// + Task> DiscoverAsync( + string username, + string? toolId = null, + int maxCount = 20, + CancellationToken cancellationToken = default); + + /// + /// 导入外部 CLI 会话为 WebCode 会话 + /// + Task ImportAsync( + string username, + ImportExternalCliSessionRequest request, + string? feishuChatKey = null, + CancellationToken cancellationToken = default); +} + +[ServiceDescription(typeof(IExternalCliSessionService), ServiceLifetime.Scoped)] +public class ExternalCliSessionService : IExternalCliSessionService +{ + private readonly ILogger _logger; + private readonly IChatSessionRepository _chatSessionRepository; + private readonly IUserWorkspacePolicyService _userWorkspacePolicyService; + private readonly string[] _globalAllowedRoots; + + public ExternalCliSessionService( + ILogger logger, + IChatSessionRepository chatSessionRepository, + IUserWorkspacePolicyService userWorkspacePolicyService, + IConfiguration configuration) + { + _logger = logger; + _chatSessionRepository = chatSessionRepository; + _userWorkspacePolicyService = userWorkspacePolicyService; + _globalAllowedRoots = (configuration.GetSection("Workspace:AllowedRoots").Get() ?? Array.Empty()) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(NormalizeWorkspacePath) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(path => path!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public async Task> DiscoverAsync( + string username, + string? toolId = null, + int maxCount = 20, + CancellationToken cancellationToken = default) + { + if (maxCount <= 0) + { + maxCount = 20; + } + + var normalizedToolId = NormalizeToolId(toolId); + + var toolsToScan = new List(); + if (string.IsNullOrWhiteSpace(normalizedToolId)) + { + toolsToScan.Add("opencode"); + toolsToScan.Add("codex"); + toolsToScan.Add("claude-code"); + } + else + { + toolsToScan.Add(normalizedToolId); + } + + var discovered = new List(); + foreach (var t in toolsToScan.Distinct(StringComparer.OrdinalIgnoreCase)) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (string.Equals(t, "opencode", StringComparison.OrdinalIgnoreCase)) + { + discovered.AddRange(await DiscoverOpenCodeSessionsAsync(maxCount, cancellationToken)); + continue; + } + + if (string.Equals(t, "codex", StringComparison.OrdinalIgnoreCase)) + { + discovered.AddRange(await DiscoverCodexSessionsFromDiskAsync(maxCount, cancellationToken)); + continue; + } + + if (string.Equals(t, "claude-code", StringComparison.OrdinalIgnoreCase)) + { + discovered.AddRange(await DiscoverClaudeCodeSessionsFromDiskAsync(maxCount, cancellationToken)); + continue; + } + } + + // 标记已导入(仅对当前 Web 用户) + Dictionary importedMap = new(StringComparer.OrdinalIgnoreCase); + try + { + var imported = await _chatSessionRepository.GetByUsernameOrderByUpdatedAtAsync(username); + importedMap = imported + .Where(s => !string.IsNullOrWhiteSpace(s.ToolId) && !string.IsNullOrWhiteSpace(s.CliThreadId)) + .GroupBy(s => BuildImportKey(NormalizeToolId(s.ToolId), s.CliThreadId!), StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().SessionId, StringComparer.OrdinalIgnoreCase); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "标记外部会话已导入状态失败,忽略"); + } + + // 发现阶段直接过滤: + // 1) 工作区不存在/为空:不显示 + // 2) 不在白名单:不显示 + // 3) (ToolId, CliThreadId) 已被其他用户占用:不显示 + var (effectiveRoots, _, rootsLoadError) = await GetEffectiveAllowedRootsForUserAsync(username, cancellationToken); + if (!string.IsNullOrWhiteSpace(rootsLoadError)) + { + _logger.LogDebug("加载用户白名单/允许根目录失败: {Error}", rootsLoadError); + return new List(); + } + + var filtered = new List(); + foreach (var item in discovered) + { + cancellationToken.ThrowIfCancellationRequested(); + + item.ToolId = NormalizeToolId(item.ToolId); + item.CliThreadId = item.CliThreadId?.Trim() ?? string.Empty; + + if (string.IsNullOrWhiteSpace(item.ToolId) || string.IsNullOrWhiteSpace(item.CliThreadId)) + { + continue; + } + + var workspacePath = NormalizeWorkspacePath(item.WorkspacePath); + if (string.IsNullOrWhiteSpace(workspacePath) || !Directory.Exists(workspacePath)) + { + continue; + } + + if (effectiveRoots.Length > 0 && !effectiveRoots.Any(root => IsPathWithinRoot(root, workspacePath))) + { + continue; + } + + item.WorkspacePath = workspacePath; + + var importKey = BuildImportKey(item.ToolId, item.CliThreadId); + if (importedMap.TryGetValue(importKey, out var importedSessionId)) + { + item.AlreadyImported = true; + item.ImportedSessionId = importedSessionId; + filtered.Add(item); + continue; + } + + try + { + var occupied = await _chatSessionRepository.GetByToolAndCliThreadIdAsync(item.ToolId, item.CliThreadId); + if (occupied != null) + { + if (string.Equals(occupied.Username, username, StringComparison.OrdinalIgnoreCase)) + { + item.AlreadyImported = true; + item.ImportedSessionId = occupied.SessionId; + filtered.Add(item); + } + + // 已被其他用户占用时:发现阶段直接不显示,避免泄露 + continue; + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "检查外部会话占用失败,忽略该条外部会话记录"); + continue; + } + + filtered.Add(item); + } + + return filtered + .OrderByDescending(x => x.UpdatedAt ?? DateTime.MinValue) + .Take(maxCount) + .ToList(); + } + + protected virtual string? GetUserProfilePath() + { + return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } + + public async Task ImportAsync( + string username, + ImportExternalCliSessionRequest request, + string? feishuChatKey = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(username)) + { + return new ImportExternalCliSessionResult + { + Success = false, + ErrorMessage = "用户名不能为空。" + }; + } + + var toolId = NormalizeToolId(request.ToolId); + if (string.IsNullOrWhiteSpace(toolId)) + { + return new ImportExternalCliSessionResult + { + Success = false, + ErrorMessage = "ToolId 不能为空。" + }; + } + + if (string.IsNullOrWhiteSpace(request.CliThreadId)) + { + return new ImportExternalCliSessionResult + { + Success = false, + ErrorMessage = "CliThreadId 不能为空。" + }; + } + + var cliThreadId = request.CliThreadId.Trim(); + + // 幂等:同一个用户 + tool + threadId 只导入一次 + var existing = await _chatSessionRepository.GetByUsernameToolAndCliThreadIdAsync( + username, + toolId, + cliThreadId); + + if (existing != null) + { + return new ImportExternalCliSessionResult + { + Success = true, + AlreadyExists = true, + SessionId = existing.SessionId + }; + } + + // 跨用户占用检查:同一个 (ToolId, CliThreadId) 只能属于一个 WebCode 用户 + var occupied = await _chatSessionRepository.GetByToolAndCliThreadIdAsync(toolId, cliThreadId); + if (occupied != null) + { + if (string.Equals(occupied.Username, username, StringComparison.OrdinalIgnoreCase)) + { + // 同一用户重复导入:按幂等处理 + return new ImportExternalCliSessionResult + { + Success = true, + AlreadyExists = true, + SessionId = occupied.SessionId + }; + } + + return new ImportExternalCliSessionResult + { + Success = false, + ErrorMessage = "导入失败:该外部会话已被其他用户占用。" + }; + } + + var now = DateTime.Now; + var normalizedChatKey = string.IsNullOrWhiteSpace(feishuChatKey) ? null : feishuChatKey.Trim().ToLowerInvariant(); + var title = BuildImportedTitle(toolId, request.Title, cliThreadId); + var workspacePath = NormalizeWorkspacePath(request.WorkspacePath); + if (string.IsNullOrWhiteSpace(workspacePath)) + { + return new ImportExternalCliSessionResult + { + Success = false, + ErrorMessage = "导入失败:WorkspacePath 不能为空(用于白名单校验)。" + }; + } + + // 白名单/允许范围校验(导入阶段必须再校验一次,防止绕过发现接口) + var (workspaceAllowed, deniedReason) = await ValidateWorkspacePathAsync(username, workspacePath, cancellationToken); + if (!workspaceAllowed) + { + return new ImportExternalCliSessionResult + { + Success = false, + ErrorMessage = deniedReason ?? "导入失败:工作区路径不在允许范围内。" + }; + } + + if (!Directory.Exists(workspacePath)) + { + return new ImportExternalCliSessionResult + { + Success = false, + ErrorMessage = "导入失败:工作区目录不存在或已被删除。" + }; + } + + var isWorkspaceValid = !string.IsNullOrWhiteSpace(workspacePath) && Directory.Exists(workspacePath); + var isCustomWorkspace = !string.IsNullOrWhiteSpace(workspacePath); + + string sessionId; + if (!string.IsNullOrWhiteSpace(normalizedChatKey)) + { + // 飞书导入:创建会话并设为活跃(复用仓储事务逻辑) + sessionId = await _chatSessionRepository.CreateFeishuSessionAsync(normalizedChatKey, username, workspacePath, toolId); + + var entity = await _chatSessionRepository.GetByIdAsync(sessionId); + if (entity == null) + { + return new ImportExternalCliSessionResult + { + Success = false, + ErrorMessage = "导入失败:创建会话后无法读取会话记录。" + }; + } + + entity.Title = title; + entity.ToolId = toolId; + entity.CliThreadId = cliThreadId; + entity.WorkspacePath = workspacePath; + entity.IsCustomWorkspace = isCustomWorkspace; + entity.IsWorkspaceValid = isWorkspaceValid; + entity.UpdatedAt = now; + + await _chatSessionRepository.InsertOrUpdateAsync(entity); + } + else + { + sessionId = Guid.NewGuid().ToString(); + var entity = new ChatSessionEntity + { + SessionId = sessionId, + Username = username, + Title = title, + ToolId = toolId, + CliThreadId = cliThreadId, + WorkspacePath = workspacePath, + IsCustomWorkspace = isCustomWorkspace, + IsWorkspaceValid = isWorkspaceValid, + CreatedAt = now, + UpdatedAt = now + }; + + var success = await _chatSessionRepository.InsertOrUpdateAsync(entity); + if (!success) + { + return new ImportExternalCliSessionResult + { + Success = false, + ErrorMessage = "导入失败:写入会话记录失败。" + }; + } + } + + return new ImportExternalCliSessionResult + { + Success = true, + AlreadyExists = false, + SessionId = sessionId + }; + } + + private async Task> DiscoverOpenCodeSessionsAsync(int maxCount, CancellationToken cancellationToken) + { + var list = new List(); + + // 尽量使用 CLI 自身的 session list 输出,避免 OS/版本差异 + var (exitCode, stdout, stderr) = await RunProcessAsync( + fileName: "opencode", + arguments: $"session list --format json -n {maxCount}", + timeout: TimeSpan.FromSeconds(8), + cancellationToken: cancellationToken); + + if (exitCode != 0) + { + // opencode 未安装时通常是:exitCode=127 或 stderr 提示 not found + _logger.LogDebug("OpenCode session list 失败: ExitCode={ExitCode}, Stderr={Stderr}", exitCode, Truncate(stderr, 500)); + return list; + } + + if (string.IsNullOrWhiteSpace(stdout)) + { + return list; + } + + try + { + using var doc = JsonDocument.Parse(stdout); + var root = doc.RootElement; + + JsonElement sessions; + if (root.ValueKind == JsonValueKind.Array) + { + sessions = root; + } + else if (TryGetProperty(root, "sessions", out var sessionsEl) && sessionsEl.ValueKind == JsonValueKind.Array) + { + sessions = sessionsEl; + } + else if (TryGetProperty(root, "data", out var dataEl) && dataEl.ValueKind == JsonValueKind.Array) + { + sessions = dataEl; + } + else + { + return list; + } + + foreach (var item in sessions.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) + { + continue; + } + + var id = GetString(item, "id", "sessionID", "sessionId", "session_id"); + if (string.IsNullOrWhiteSpace(id)) + { + continue; + } + + var title = GetString(item, "title", "name"); + + // cwd / project path + string? workspacePath = GetString(item, "cwd", "path", "directory", "projectPath", "project_path"); + if (string.IsNullOrWhiteSpace(workspacePath) && TryGetProperty(item, "project", out var projectEl)) + { + if (projectEl.ValueKind == JsonValueKind.String) + { + workspacePath = projectEl.GetString(); + } + else if (projectEl.ValueKind == JsonValueKind.Object) + { + workspacePath = GetString(projectEl, "path", "cwd", "directory"); + } + } + + var updatedAt = GetDateTime(item, "updatedAt", "updated_at", "updated", "lastUpdatedAt", "last_updated_at", "timestamp"); + + list.Add(new ExternalCliSessionSummary + { + ToolId = "opencode", + ToolName = "OpenCode", + CliThreadId = id, + Title = title, + WorkspacePath = string.IsNullOrWhiteSpace(workspacePath) ? null : workspacePath, + UpdatedAt = updatedAt + }); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "解析 OpenCode session list JSON 失败,忽略"); + } + + return list; + } + + private Task> DiscoverCodexSessionsFromDiskAsync(int maxCount, CancellationToken cancellationToken) + { + var list = new List(); + + try + { + var userProfile = GetUserProfilePath(); + if (string.IsNullOrWhiteSpace(userProfile)) + { + return Task.FromResult(list); + } + + var sessionsRoot = Path.Combine(userProfile, ".codex", "sessions"); + if (!Directory.Exists(sessionsRoot)) + { + return Task.FromResult(list); + } + + var files = Directory + .EnumerateFiles(sessionsRoot, "rollout-*.jsonl", SearchOption.AllDirectories) + .Select(p => new FileInfo(p)) + .OrderByDescending(f => f.LastWriteTimeUtc) + .Take(Math.Max(maxCount * 10, 50)) + .ToList(); + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var fi in files) + { + cancellationToken.ThrowIfCancellationRequested(); + + var line = ReadFirstNonEmptyLine(fi.FullName, maxLines: 3); + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + try + { + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + + var payload = root; + if (TryGetProperty(root, "payload", out var payloadEl) && payloadEl.ValueKind == JsonValueKind.Object) + { + payload = payloadEl; + } + + var id = GetString(payload, "id") ?? GetString(root, "id"); + if (string.IsNullOrWhiteSpace(id)) + { + continue; + } + + if (!seen.Add(id)) + { + continue; + } + + var cwd = GetString(payload, "cwd", "workspacePath", "workspace_path", "workspace"); + var title = GetString(payload, "title", "name", "firstPrompt", "first_prompt"); + var updatedAt = GetMostRecentDateTime( + GetDateTime(payload, "timestamp") ?? + GetDateTime(root, "timestamp"), + fi.LastWriteTime); + + list.Add(new ExternalCliSessionSummary + { + ToolId = "codex", + ToolName = "Codex", + CliThreadId = id, + Title = SanitizeTitle(title), + WorkspacePath = string.IsNullOrWhiteSpace(cwd) ? null : cwd, + UpdatedAt = updatedAt + }); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "解析 Codex rollout JSON 失败,忽略: {File}", fi.FullName); + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "发现 Codex 外部会话失败,忽略"); + } + + return Task.FromResult(list); + } + + private Task> DiscoverClaudeCodeSessionsFromDiskAsync(int maxCount, CancellationToken cancellationToken) + { + var list = new List(); + + try + { + var userProfile = GetUserProfilePath(); + if (string.IsNullOrWhiteSpace(userProfile)) + { + return Task.FromResult(list); + } + + var projectsRoot = Path.Combine(userProfile, ".claude", "projects"); + if (!Directory.Exists(projectsRoot)) + { + return Task.FromResult(list); + } + + // 优先使用 sessions-index.json(更快,不需要解析巨大的 session jsonl 文件) + list.AddRange(DiscoverClaudeCodeSessionsFromIndex(projectsRoot, cancellationToken)); + + // 额外补扫最近的会话 jsonl。新版本 Claude Code 并不总会更新 sessions-index.json, + // 仅依赖索引会漏掉最新会话。 + list.AddRange(DiscoverClaudeCodeSessionsFromJsonlFiles(projectsRoot, maxCount, cancellationToken)); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "发现 Claude Code 外部会话失败,忽略"); + } + + // 去重 + var dedup = list + .Where(x => !string.IsNullOrWhiteSpace(x.CliThreadId)) + .GroupBy(x => x.CliThreadId, StringComparer.OrdinalIgnoreCase) + .Select(g => g.OrderByDescending(x => x.UpdatedAt ?? DateTime.MinValue).First()) + .OrderByDescending(x => x.UpdatedAt ?? DateTime.MinValue) + .ToList(); + + return Task.FromResult(dedup); + } + + private List DiscoverClaudeCodeSessionsFromIndex(string projectsRoot, CancellationToken cancellationToken) + { + var list = new List(); + + IEnumerable indexFiles; + try + { + indexFiles = Directory.EnumerateFiles(projectsRoot, "sessions-index.json", SearchOption.AllDirectories); + } + catch + { + return list; + } + + foreach (var indexFile in indexFiles) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var json = ReadAllTextShared(indexFile); + if (string.IsNullOrWhiteSpace(json)) + { + continue; + } + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + if (!TryGetProperty(root, "entries", out var entries) || entries.ValueKind != JsonValueKind.Array) + { + continue; + } + + foreach (var entry in entries.EnumerateArray()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (entry.ValueKind != JsonValueKind.Object) + { + continue; + } + + var sessionId = GetString(entry, "sessionId", "session_id"); + if (string.IsNullOrWhiteSpace(sessionId)) + { + continue; + } + + var cwd = GetString(entry, "projectPath", "project_path", "cwd"); + var title = GetString(entry, "firstPrompt", "first_prompt", "title", "name"); + var updatedAt = GetDateTime(entry, "modified", "updatedAt", "updated_at", "fileMtime", "file_mtime", "created"); + var transcriptPath = GetString(entry, "fullPath", "full_path", "filePath", "file_path"); + if (!string.IsNullOrWhiteSpace(transcriptPath) && File.Exists(transcriptPath)) + { + updatedAt = GetMostRecentDateTime(updatedAt, File.GetLastWriteTime(transcriptPath)); + } + + list.Add(new ExternalCliSessionSummary + { + ToolId = "claude-code", + ToolName = "Claude Code", + CliThreadId = sessionId, + Title = SanitizeTitle(title), + WorkspacePath = string.IsNullOrWhiteSpace(cwd) ? null : cwd, + UpdatedAt = updatedAt + }); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "解析 Claude sessions-index.json 失败,忽略: {File}", indexFile); + } + } + + return list; + } + + private List DiscoverClaudeCodeSessionsFromJsonlFiles(string projectsRoot, int maxCount, CancellationToken cancellationToken) + { + var list = new List(); + + IEnumerable candidates; + try + { + candidates = Directory.EnumerateFiles(projectsRoot, "*.jsonl", SearchOption.AllDirectories) + .Where(IsClaudeSessionTranscriptFile) + .OrderByDescending(File.GetLastWriteTimeUtc) + .Take(Math.Max(maxCount * 20, 500)) + .ToList(); + } + catch + { + return list; + } + + foreach (var file in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var metadata = ReadClaudeCodeSessionMetadata(file, maxLines: 40); + if (metadata == null) + { + continue; + } + + var sessionId = metadata.SessionId ?? Path.GetFileNameWithoutExtension(file); + if (string.IsNullOrWhiteSpace(sessionId)) + { + continue; + } + + list.Add(new ExternalCliSessionSummary + { + ToolId = "claude-code", + ToolName = "Claude Code", + CliThreadId = sessionId, + Title = SanitizeTitle(metadata.Title), + WorkspacePath = string.IsNullOrWhiteSpace(metadata.WorkspacePath) ? null : metadata.WorkspacePath, + UpdatedAt = GetMostRecentDateTime(metadata.UpdatedAt, File.GetLastWriteTime(file)) + }); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "解析 Claude session jsonl 失败,忽略: {File}", file); + } + } + + return list; + } + + private static ClaudeCodeSessionMetadata? ReadClaudeCodeSessionMetadata(string filePath, int maxLines) + { + try + { + using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + + string? sessionId = null; + string? workspacePath = null; + string? title = null; + DateTime? updatedAt = null; + + for (var i = 0; i < Math.Max(maxLines, 1); i++) + { + var line = reader.ReadLine(); + if (line == null) + { + break; + } + + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + try + { + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + + sessionId ??= GetString(root, "sessionId", "session_id"); + workspacePath ??= GetString(root, "cwd", "projectPath", "project_path") + ?? GetChildObjectString(root, "data", "cwd", "projectPath", "project_path"); + title ??= ExtractClaudeCodeSessionTitle(root); + updatedAt = GetMostRecentDateTime(GetDateTime(root, "timestamp"), updatedAt); + } + catch + { + // ignore malformed lines and keep scanning subsequent entries + } + + if (!string.IsNullOrWhiteSpace(sessionId) && !string.IsNullOrWhiteSpace(workspacePath) && !string.IsNullOrWhiteSpace(title)) + { + break; + } + } + + if (string.IsNullOrWhiteSpace(sessionId) && + string.IsNullOrWhiteSpace(workspacePath) && + string.IsNullOrWhiteSpace(title) && + !updatedAt.HasValue) + { + return null; + } + + return new ClaudeCodeSessionMetadata + { + SessionId = sessionId, + WorkspacePath = workspacePath, + Title = title, + UpdatedAt = updatedAt + }; + } + catch + { + return null; + } + } + + private static bool IsClaudeSessionTranscriptFile(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + return false; + } + + var lower = filePath.Replace('/', '\\').ToLowerInvariant(); + if (lower.Contains("\\subagents\\", StringComparison.OrdinalIgnoreCase) || + lower.Contains("\\tool-results\\", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var name = Path.GetFileNameWithoutExtension(filePath); + return Guid.TryParse(name, out _); + } + + private static string? ReadFirstNonEmptyLine(string filePath, int maxLines) + { + try + { + using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + + for (var i = 0; i < Math.Max(maxLines, 1); i++) + { + var line = reader.ReadLine(); + if (line == null) + { + break; + } + + if (!string.IsNullOrWhiteSpace(line)) + { + return line; + } + } + } + catch + { + // ignore + } + + return null; + } + + private static string ReadAllTextShared(string filePath) + { + using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + return reader.ReadToEnd(); + } + + private static DateTime GetMostRecentDateTime(DateTime? parsedTime, DateTime fallbackTime) + { + if (!parsedTime.HasValue) + { + return fallbackTime; + } + + return parsedTime.Value >= fallbackTime ? parsedTime.Value : fallbackTime; + } + + private static DateTime? GetMostRecentDateTime(DateTime? parsedTime, DateTime? fallbackTime) + { + if (!parsedTime.HasValue) + { + return fallbackTime; + } + + if (!fallbackTime.HasValue) + { + return parsedTime.Value; + } + + return parsedTime.Value >= fallbackTime.Value ? parsedTime.Value : fallbackTime.Value; + } + + private static string? GetChildObjectString(JsonElement element, string childPropertyName, params string[] names) + { + if (!TryGetProperty(element, childPropertyName, out var child) || child.ValueKind != JsonValueKind.Object) + { + return null; + } + + return GetString(child, names); + } + + private static string? ExtractClaudeCodeSessionTitle(JsonElement root) + { + var directTitle = GetString(root, "title", "name", "content"); + if (!string.IsNullOrWhiteSpace(directTitle)) + { + return directTitle; + } + + if (TryGetProperty(root, "message", out var messageElement) && messageElement.ValueKind == JsonValueKind.Object) + { + var messageContent = GetString(messageElement, "content"); + if (!string.IsNullOrWhiteSpace(messageContent)) + { + return messageContent; + } + + if (TryGetProperty(messageElement, "content", out var contentElement) && contentElement.ValueKind == JsonValueKind.Array) + { + foreach (var item in contentElement.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) + { + continue; + } + + var text = GetString(item, "text", "thinking"); + if (!string.IsNullOrWhiteSpace(text)) + { + return text; + } + } + } + } + + return null; + } + + private static string? NormalizeWorkspacePath(string? workspacePath) + { + if (string.IsNullOrWhiteSpace(workspacePath)) + { + return null; + } + + try + { + return Path.GetFullPath(workspacePath.Trim()) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + catch + { + return workspacePath.Trim(); + } + } + + private async Task IsWorkspacePathAllowedAsync(string username, string workspacePath, CancellationToken cancellationToken) + { + var (allowed, _) = await ValidateWorkspacePathAsync(username, workspacePath, cancellationToken); + return allowed; + } + + private async Task<(bool Allowed, string? DeniedReason)> ValidateWorkspacePathAsync( + string username, + string workspacePath, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (string.IsNullOrWhiteSpace(username)) + { + return (false, "导入失败:用户名为空。"); + } + + if (string.IsNullOrWhiteSpace(workspacePath)) + { + return (false, "导入失败:WorkspacePath 不能为空。"); + } + + var normalizedTarget = NormalizeWorkspacePath(workspacePath); + if (string.IsNullOrWhiteSpace(normalizedTarget)) + { + return (false, "导入失败:WorkspacePath 无效。"); + } + + var (effectiveRoots, userRoots, rootsLoadError) = + await GetEffectiveAllowedRootsForUserAsync(username, cancellationToken); + if (!string.IsNullOrWhiteSpace(rootsLoadError)) + { + return (false, "导入失败:读取用户白名单目录失败。"); + } + + // 若配置了 roots,则必须位于 roots 内;若未配置,则放行(保持与现有逻辑一致) + if (effectiveRoots.Length > 0 && !effectiveRoots.Any(root => IsPathWithinRoot(root, normalizedTarget))) + { + if (userRoots.Length > 0) + { + return (false, "导入失败:工作区路径不在该用户白名单目录内。"); + } + + return (false, "导入失败:工作区路径不在系统允许范围 (Workspace:AllowedRoots) 内。"); + } + + return (true, null); + } + + private async Task<(string[] EffectiveRoots, string[] UserRoots, string? Error)> GetEffectiveAllowedRootsForUserAsync( + string username, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (string.IsNullOrWhiteSpace(username)) + { + return (Array.Empty(), Array.Empty(), "Username is empty."); + } + + try + { + var userRoots = (await _userWorkspacePolicyService.GetAllowedDirectoriesAsync(username)) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(NormalizeWorkspacePath) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(path => path!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var effectiveRoots = userRoots.Length > 0 ? userRoots : _globalAllowedRoots; + return (effectiveRoots, userRoots, null); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "读取用户白名单目录失败,按不允许处理"); + return (Array.Empty(), Array.Empty(), ex.Message); + } + } + + private static bool IsPathWithinRoot(string rootPath, string targetPath) + { + try + { + var normalizedRoot = Path.GetFullPath(rootPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var normalizedTarget = Path.GetFullPath(targetPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + if (string.Equals(normalizedRoot, normalizedTarget, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return normalizedTarget.StartsWith(normalizedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || + normalizedTarget.StartsWith(normalizedRoot + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + + private static string BuildImportKey(string toolId, string cliThreadId) + { + return $"{NormalizeToolId(toolId)}::{cliThreadId.Trim()}"; + } + + private static string? SanitizeTitle(string? title) + { + if (string.IsNullOrWhiteSpace(title)) + { + return null; + } + + // 去掉控制字符(例如 sessions-index.json 里可能包含 \b) + var cleaned = new string(title.Where(c => !char.IsControl(c)).ToArray()).Trim(); + return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned; + } + + private static string NormalizeToolId(string? toolId) + { + if (string.IsNullOrWhiteSpace(toolId)) + { + return string.Empty; + } + + if (toolId.Equals("claude", StringComparison.OrdinalIgnoreCase)) + { + return "claude-code"; + } + + if (toolId.Equals("opencode-cli", StringComparison.OrdinalIgnoreCase)) + { + return "opencode"; + } + + return toolId.Trim().ToLowerInvariant(); + } + + private static string BuildImportedTitle(string toolId, string? title, string cliThreadId) + { + var toolName = toolId switch + { + "codex" => "Codex", + "claude-code" => "Claude Code", + "opencode" => "OpenCode", + _ => toolId + }; + + var baseTitle = string.IsNullOrWhiteSpace(title) ? cliThreadId : title.Trim(); + if (baseTitle.Length > 80) + { + baseTitle = baseTitle.Substring(0, 80) + "..."; + } + + return $"[{toolName}] {baseTitle}"; + } + + private static async Task<(int ExitCode, string Stdout, string Stderr)> RunProcessAsync( + string fileName, + string arguments, + TimeSpan timeout, + CancellationToken cancellationToken) + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = new Process { StartInfo = startInfo }; + if (!process.Start()) + { + return (-1, string.Empty, "Process start failed."); + } + + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeout); + + await process.WaitForExitAsync(timeoutCts.Token); + + var stdout = await stdoutTask; + var stderr = await stderrTask; + return (process.ExitCode, stdout, stderr); + } + catch (OperationCanceledException) + { + return (-2, string.Empty, "Timeout."); + } + catch (Exception ex) + { + return (-1, string.Empty, ex.Message); + } + } + + private static bool TryGetProperty(JsonElement element, string name, out JsonElement value) + { + // JsonElement 属性名大小写敏感,这里统一尝试一组常见变体 + if (element.ValueKind == JsonValueKind.Object) + { + if (element.TryGetProperty(name, out value)) + { + return true; + } + + var alt = char.IsUpper(name[0]) + ? char.ToLowerInvariant(name[0]) + name.Substring(1) + : char.ToUpperInvariant(name[0]) + name.Substring(1); + if (element.TryGetProperty(alt, out value)) + { + return true; + } + } + + value = default; + return false; + } + + private static string? GetString(JsonElement element, params string[] names) + { + foreach (var name in names) + { + if (TryGetProperty(element, name, out var value)) + { + if (value.ValueKind == JsonValueKind.String) + { + return value.GetString(); + } + if (value.ValueKind == JsonValueKind.Number) + { + return value.GetRawText(); + } + } + } + + return null; + } + + private static DateTime? GetDateTime(JsonElement element, params string[] names) + { + foreach (var name in names) + { + if (!TryGetProperty(element, name, out var value)) + { + continue; + } + + if (value.ValueKind == JsonValueKind.String) + { + var s = value.GetString(); + if (DateTime.TryParse(s, out var dt)) + { + return dt; + } + } + + if (value.ValueKind == JsonValueKind.Number) + { + if (value.TryGetInt64(out var n)) + { + // heuristic: 13 位以上当作毫秒 + if (n >= 1_000_000_000_000) + { + return DateTimeOffset.FromUnixTimeMilliseconds(n).LocalDateTime; + } + if (n >= 1_000_000_000) + { + return DateTimeOffset.FromUnixTimeSeconds(n).LocalDateTime; + } + } + } + } + + return null; + } + + private static string Truncate(string? text, int maxLen) + { + if (string.IsNullOrEmpty(text)) + { + return string.Empty; + } + if (text.Length <= maxLen) + { + return text; + } + return text.Substring(0, maxLen) + "..."; + } + + private sealed class ClaudeCodeSessionMetadata + { + public string? SessionId { get; init; } + public string? WorkspacePath { get; init; } + public string? Title { get; init; } + public DateTime? UpdatedAt { get; init; } + } +} diff --git a/WebCodeCli.Domain/Domain/Service/IUserFeishuBotConfigService.cs b/WebCodeCli.Domain/Domain/Service/IUserFeishuBotConfigService.cs index 8677544..4f30a90 100644 --- a/WebCodeCli.Domain/Domain/Service/IUserFeishuBotConfigService.cs +++ b/WebCodeCli.Domain/Domain/Service/IUserFeishuBotConfigService.cs @@ -10,6 +10,8 @@ public interface IUserFeishuBotConfigService Task SaveAsync(UserFeishuBotConfigEntity config); Task DeleteAsync(string username); Task FindConflictingUsernameByAppIdAsync(string username, string? appId); + Task> GetAutoStartCandidatesAsync(); + Task UpdateRuntimePreferenceAsync(string username, bool autoStartEnabled, DateTime? lastStartedAt = null); FeishuOptions GetSharedDefaults(); Task GetEffectiveOptionsAsync(string? username); Task GetEffectiveOptionsByAppIdAsync(string? appId); diff --git a/WebCodeCli.Domain/Domain/Service/SessionHistoryManager.cs b/WebCodeCli.Domain/Domain/Service/SessionHistoryManager.cs index 780b886..be4b913 100644 --- a/WebCodeCli.Domain/Domain/Service/SessionHistoryManager.cs +++ b/WebCodeCli.Domain/Domain/Service/SessionHistoryManager.cs @@ -483,9 +483,11 @@ private SessionHistory MapToSessionHistory(ChatSessionEntity entity, List new ChatMessage { @@ -505,9 +507,11 @@ private ChatSessionEntity MapToSessionEntity(SessionHistory session, string user Title = session.Title, WorkspacePath = session.WorkspacePath, ToolId = session.ToolId, + CliThreadId = session.CliThreadId, CreatedAt = session.CreatedAt, UpdatedAt = session.UpdatedAt, IsWorkspaceValid = session.IsWorkspaceValid, + IsCustomWorkspace = session.IsCustomWorkspace, ProjectId = session.ProjectId }; } diff --git a/WebCodeCli.Domain/Domain/Service/UserContextService.cs b/WebCodeCli.Domain/Domain/Service/UserContextService.cs index a0fa9f2..982806b 100644 --- a/WebCodeCli.Domain/Domain/Service/UserContextService.cs +++ b/WebCodeCli.Domain/Domain/Service/UserContextService.cs @@ -31,16 +31,10 @@ public UserContextService(IConfiguration configuration, IHttpContextAccessor htt /// /// 获取当前用户名 - /// 优先级:1. 覆盖值 2. 配置文件 3. 默认值 + /// 优先级:1. 已认证用户 2. 覆盖值 3. 配置文件 4. 默认值 /// public string GetCurrentUsername() { - // 如果有覆盖值,优先使用 - if (!string.IsNullOrWhiteSpace(_overrideUsername)) - { - return _overrideUsername; - } - var claimsUsername = _httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated == true ? _httpContextAccessor.HttpContext.User.Identity?.Name : null; @@ -49,6 +43,12 @@ public string GetCurrentUsername() { return claimsUsername; } + + // 如果有覆盖值,优先于配置默认值,但不应覆盖已认证用户。 + if (!string.IsNullOrWhiteSpace(_overrideUsername)) + { + return _overrideUsername; + } // 从配置读取,默认为 "default" var configUsername = _configuration["App:DefaultUsername"]; diff --git a/WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs b/WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs index 162fa38..8341bcb 100644 --- a/WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs +++ b/WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs @@ -112,6 +112,38 @@ public async Task DeleteAsync(string username) return FeishuBotAppIdOwnershipHelper.FindConflictingUsername(normalizedUsername, normalizedAppId, configs); } + public async Task> GetAutoStartCandidatesAsync() + { + var configs = await _repository.GetListAsync(x => x.AutoStartEnabled && x.IsEnabled && x.AppId != null && x.AppSecret != null); + return configs + .Where(UserFeishuBotOptionsFactory.HasUsableCredentials) + .ToList(); + } + + public async Task UpdateRuntimePreferenceAsync(string username, bool autoStartEnabled, DateTime? lastStartedAt = null) + { + var normalizedUsername = NormalizeValue(username); + if (normalizedUsername == null) + { + return false; + } + + var existing = await _repository.GetByUsernameAsync(normalizedUsername); + if (existing == null) + { + return false; + } + + existing.AutoStartEnabled = autoStartEnabled; + if (lastStartedAt.HasValue) + { + existing.LastStartedAt = lastStartedAt.Value; + } + + existing.UpdatedAt = DateTime.Now; + return await _repository.UpdateAsync(existing); + } + public async Task GetEffectiveOptionsAsync(string? username) { var effective = GetSharedDefaults(); diff --git a/WebCodeCli.Domain/Domain/Service/UserFeishuBotRuntimeService.cs b/WebCodeCli.Domain/Domain/Service/UserFeishuBotRuntimeService.cs index 2be848d..df11f74 100644 --- a/WebCodeCli.Domain/Domain/Service/UserFeishuBotRuntimeService.cs +++ b/WebCodeCli.Domain/Domain/Service/UserFeishuBotRuntimeService.cs @@ -13,7 +13,7 @@ namespace WebCodeCli.Domain.Domain.Service; [ServiceDescription(typeof(IUserFeishuBotRuntimeService), ServiceLifetime.Singleton)] -public sealed class UserFeishuBotRuntimeService : IUserFeishuBotRuntimeService, IHostedService, IDisposable +public class UserFeishuBotRuntimeService : IUserFeishuBotRuntimeService, IHostedService, IDisposable { private readonly IServiceProvider _rootServiceProvider; private readonly IServiceScopeFactory _scopeFactory; @@ -39,7 +39,7 @@ public async Task GetStatusAsync(string username, Ca var normalizedUsername = NormalizeUsername(username); if (normalizedUsername == null) { - return CreateStatus(string.Empty, null, UserFeishuBotRuntimeState.NotConfigured, false, false, "用户名不能为空。"); + return CreateStatus(string.Empty, null, UserFeishuBotRuntimeState.NotConfigured, false, false, false, "用户名不能为空。"); } await _mutex.WaitAsync(cancellationToken); @@ -60,7 +60,7 @@ public async Task StartAsync(string username, Cancel var normalizedUsername = NormalizeUsername(username); if (normalizedUsername == null) { - return CreateStatus(string.Empty, null, UserFeishuBotRuntimeState.NotConfigured, false, false, "用户名不能为空。"); + return CreateStatus(string.Empty, null, UserFeishuBotRuntimeState.NotConfigured, false, false, false, "用户名不能为空。"); } await _mutex.WaitAsync(cancellationToken); @@ -73,6 +73,7 @@ public async Task StartAsync(string username, Cancel var config = await configService.GetByUsernameAsync(normalizedUsername); var defaults = configService.GetSharedDefaults(); var options = UserFeishuBotOptionsFactory.CreateEffectiveOptions(defaults, config); + var shouldAutoStart = config?.AutoStartEnabled == true; if (!defaults.Enabled) { @@ -82,8 +83,10 @@ public async Task StartAsync(string username, Cancel UserFeishuBotRuntimeState.Failed, UserFeishuBotOptionsFactory.HasUsableCredentials(config), false, + shouldAutoStart, "系统已禁用飞书渠道,当前无法启动机器人。"); disabledStatus.LastError = disabledStatus.Message; + disabledStatus.LastStartedAt = config?.LastStartedAt; _statusCache[normalizedUsername] = disabledStatus; return disabledStatus; } @@ -98,7 +101,9 @@ public async Task StartAsync(string username, Cancel UserFeishuBotRuntimeState.NotConfigured, UserFeishuBotOptionsFactory.HasUsableCredentials(config), false, + shouldAutoStart, "当前用户尚未配置可启动的飞书机器人。"); + status.LastStartedAt = config?.LastStartedAt; _statusCache[normalizedUsername] = status; return status; } @@ -112,22 +117,31 @@ public async Task StartAsync(string username, Cancel UserFeishuBotRuntimeState.Failed, true, false, + shouldAutoStart, $"AppId {options.AppId} 已被用户 {ownerUsername} 启动。"); status.LastError = status.Message; + status.LastStartedAt = config?.LastStartedAt; _statusCache[normalizedUsername] = status; return status; } + var startedAt = DateTime.Now; + await TryPersistRuntimePreferenceAsync(configService, normalizedUsername, autoStartEnabled: true, lastStartedAt: startedAt); + if (config != null) + { + config.AutoStartEnabled = true; + config.LastStartedAt = startedAt; + } + var startingStatus = CreateStatus( normalizedUsername, options.AppId, UserFeishuBotRuntimeState.Starting, true, false, + true, $"正在启动飞书机器人 {options.AppId}。"); - startingStatus.LastStartedAt = _statusCache.TryGetValue(normalizedUsername, out var cachedBeforeStart) - ? cachedBeforeStart.LastStartedAt - : null; + startingStatus.LastStartedAt = startedAt; _statusCache[normalizedUsername] = startingStatus; RuntimeEntry entry; @@ -145,8 +159,10 @@ public async Task StartAsync(string username, Cancel UserFeishuBotRuntimeState.Failed, true, true, + true, $"初始化飞书机器人失败: {ex.Message}"); failedStatus.LastError = failedStatus.Message; + failedStatus.LastStartedAt = startedAt; _statusCache[normalizedUsername] = failedStatus; _logger.LogError(ex, "初始化飞书机器人失败: user={Username}, appId={AppId}", normalizedUsername, options.AppId); return failedStatus; @@ -174,8 +190,10 @@ public async Task StartAsync(string username, Cancel UserFeishuBotRuntimeState.Failed, true, true, + true, $"启动飞书机器人失败: {ex.Message}"); failedStatus.LastError = failedStatus.Message; + failedStatus.LastStartedAt = startedAt; _statusCache[normalizedUsername] = failedStatus; _logger.LogError(ex, "启动飞书机器人失败: user={Username}, appId={AppId}", normalizedUsername, options.AppId); return failedStatus; @@ -187,8 +205,9 @@ public async Task StartAsync(string username, Cancel UserFeishuBotRuntimeState.Connected, true, false, + true, $"飞书机器人 {options.AppId} 已连接。"); - connectedStatus.LastStartedAt = DateTime.Now; + connectedStatus.LastStartedAt = startedAt; _statusCache[normalizedUsername] = connectedStatus; _logger.LogInformation("飞书机器人已启动: user={Username}, appId={AppId}", normalizedUsername, options.AppId); return connectedStatus; @@ -207,13 +226,13 @@ public async Task StopAsync(string username, Cancell var normalizedUsername = NormalizeUsername(username); if (normalizedUsername == null) { - return CreateStatus(string.Empty, null, UserFeishuBotRuntimeState.NotConfigured, false, false, "用户名不能为空。"); + return CreateStatus(string.Empty, null, UserFeishuBotRuntimeState.NotConfigured, false, false, false, "用户名不能为空。"); } await _mutex.WaitAsync(cancellationToken); try { - return await StopInternalAsync(normalizedUsername, cancellationToken); + return await StopInternalAsync(normalizedUsername, updateRememberedState: true, cancellationToken); } finally { @@ -223,7 +242,26 @@ public async Task StopAsync(string username, Cancell async Task IHostedService.StartAsync(CancellationToken cancellationToken) { - await Task.CompletedTask; + using var scope = _scopeFactory.CreateScope(); + var configService = scope.ServiceProvider.GetRequiredService(); + var autoStartConfigs = await configService.GetAutoStartCandidatesAsync(); + + foreach (var config in autoStartConfigs) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + try + { + await StartAsync(config.Username, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "恢复飞书机器人运行状态失败: user={Username}, appId={AppId}", config.Username, config.AppId); + } + } } async Task IHostedService.StopAsync(CancellationToken cancellationToken) @@ -242,7 +280,15 @@ async Task IHostedService.StopAsync(CancellationToken cancellationToken) foreach (var username in usernames) { - await StopAsync(username, cancellationToken); + await _mutex.WaitAsync(cancellationToken); + try + { + await StopInternalAsync(username, updateRememberedState: false, cancellationToken); + } + finally + { + _mutex.Release(); + } } } @@ -265,17 +311,30 @@ public void Dispose() _usernameByAppId.Clear(); } - private async Task StopInternalAsync(string username, CancellationToken cancellationToken) + private async Task StopInternalAsync(string username, bool updateRememberedState, CancellationToken cancellationToken) { RefreshCompletedRuntime_NoLock(username); + using var scope = _scopeFactory.CreateScope(); + var configService = scope.ServiceProvider.GetRequiredService(); + if (!_entriesByUsername.TryGetValue(username, out var entry)) { - var config = await GetConfigAsync(username, cancellationToken); + var config = await configService.GetByUsernameAsync(username); + if (updateRememberedState) + { + await TryPersistRuntimePreferenceAsync(configService, username, autoStartEnabled: false); + if (config != null) + { + config.AutoStartEnabled = false; + } + } + var status = BuildStatus_NoLock(username, config); if (status.State == UserFeishuBotRuntimeState.Connected || status.State == UserFeishuBotRuntimeState.Starting) { status.State = UserFeishuBotRuntimeState.Stopped; + status.Message = ResolveStoppedMessage(status.IsConfigured, status.ShouldAutoStart); } _statusCache[username] = status; @@ -288,6 +347,7 @@ private async Task StopInternalAsync(string username UserFeishuBotRuntimeState.Stopping, true, false, + updateRememberedState ? false : _statusCache.TryGetValue(username, out var cachedBeforeStopping) && cachedBeforeStopping.ShouldAutoStart, $"正在停止飞书机器人 {entry.AppId}。"); stoppingStatus.LastStartedAt = _statusCache.TryGetValue(username, out var cachedBeforeStop) ? cachedBeforeStop.LastStartedAt @@ -305,24 +365,35 @@ private async Task StopInternalAsync(string username CleanupEntry_NoLock(username, disposeEntry: true); - var currentConfig = await GetConfigAsync(username, cancellationToken); + var currentConfig = await configService.GetByUsernameAsync(username); + if (updateRememberedState) + { + await TryPersistRuntimePreferenceAsync(configService, username, autoStartEnabled: false); + if (currentConfig != null) + { + currentConfig.AutoStartEnabled = false; + } + } + var canStart = UserFeishuBotOptionsFactory.HasUsableCredentials(currentConfig); + var shouldAutoStart = currentConfig?.AutoStartEnabled == true; var stoppedStatus = CreateStatus( username, currentConfig?.AppId ?? entry.AppId, canStart ? UserFeishuBotRuntimeState.Stopped : UserFeishuBotRuntimeState.NotConfigured, canStart, canStart, - canStart ? "飞书机器人已停止,可手动重新启动。" : "当前用户未配置可启动的飞书机器人。"); + shouldAutoStart, + ResolveStoppedMessage(canStart, shouldAutoStart)); stoppedStatus.LastStartedAt = _statusCache.TryGetValue(username, out var cachedAfterStop) ? cachedAfterStop.LastStartedAt - : null; + : currentConfig?.LastStartedAt; _statusCache[username] = stoppedStatus; _logger.LogInformation("飞书机器人已停止: user={Username}, appId={AppId}", username, stoppedStatus.AppId); return stoppedStatus; } - private RuntimeEntry CreateRuntimeEntry(FeishuOptions options) + protected virtual RuntimeEntry CreateRuntimeEntry(FeishuOptions options) { var services = new ServiceCollection(); services.AddOptions(); @@ -367,20 +438,25 @@ private UserFeishuBotRuntimeStatus BuildStatus_NoLock( cached.Username = username; cached.AppId = config?.AppId ?? cached.AppId; cached.IsConfigured = UserFeishuBotOptionsFactory.HasUsableCredentials(config); + cached.ShouldAutoStart = config?.AutoStartEnabled == true; cached.CanStart = cached.State is not UserFeishuBotRuntimeState.Starting and not UserFeishuBotRuntimeState.Connected and not UserFeishuBotRuntimeState.Stopping && UserFeishuBotOptionsFactory.HasUsableCredentials(config); + cached.LastStartedAt = config?.LastStartedAt ?? cached.LastStartedAt; cached.UpdatedAt = DateTime.Now; return CloneStatus(cached); } var isConfigured = UserFeishuBotOptionsFactory.HasUsableCredentials(config); - return CreateStatus( + var status = CreateStatus( username, config?.AppId, isConfigured ? UserFeishuBotRuntimeState.Stopped : UserFeishuBotRuntimeState.NotConfigured, isConfigured, isConfigured, - isConfigured ? "已配置飞书机器人,点击启动后建立连接。" : "当前用户尚未配置可启动的飞书机器人。"); + config?.AutoStartEnabled == true, + ResolveIdleMessage(isConfigured, config?.AutoStartEnabled == true)); + status.LastStartedAt = config?.LastStartedAt; + return status; } private void RefreshCompletedRuntime_NoLock(string username) @@ -392,6 +468,9 @@ private void RefreshCompletedRuntime_NoLock(string username) if (entry.ExecuteTask.IsFaulted) { + var previousStatus = _statusCache.TryGetValue(username, out var cachedBeforeFailure) + ? cachedBeforeFailure + : null; CleanupEntry_NoLock(username, disposeEntry: true); var failedStatus = CreateStatus( username, @@ -399,17 +478,19 @@ private void RefreshCompletedRuntime_NoLock(string username) UserFeishuBotRuntimeState.Failed, true, true, + previousStatus?.ShouldAutoStart == true, $"飞书机器人连接已中断: {entry.ExecuteTask.Exception?.GetBaseException().Message}"); failedStatus.LastError = entry.ExecuteTask.Exception?.GetBaseException().Message; - failedStatus.LastStartedAt = _statusCache.TryGetValue(username, out var cachedBeforeFailure) - ? cachedBeforeFailure.LastStartedAt - : null; + failedStatus.LastStartedAt = previousStatus?.LastStartedAt; _statusCache[username] = failedStatus; return; } if (entry.ExecuteTask.IsCanceled || entry.ExecuteTask.IsCompleted) { + var previousStatus = _statusCache.TryGetValue(username, out var cachedBeforeStop) + ? cachedBeforeStop + : null; CleanupEntry_NoLock(username, disposeEntry: true); var stoppedStatus = CreateStatus( username, @@ -417,10 +498,9 @@ private void RefreshCompletedRuntime_NoLock(string username) UserFeishuBotRuntimeState.Stopped, true, true, - "飞书机器人连接已停止。"); - stoppedStatus.LastStartedAt = _statusCache.TryGetValue(username, out var cachedBeforeStop) - ? cachedBeforeStop.LastStartedAt - : null; + previousStatus?.ShouldAutoStart == true, + ResolveStoppedMessage(isConfigured: true, previousStatus?.ShouldAutoStart == true)); + stoppedStatus.LastStartedAt = previousStatus?.LastStartedAt; _statusCache[username] = stoppedStatus; } } @@ -437,17 +517,21 @@ private void OnRuntimeCompleted(string username, string appId, Task task) CleanupEntry_NoLock(username, disposeEntry: true); + var previousStatus = _statusCache.TryGetValue(username, out var cached) + ? cached + : null; var failedStatus = CreateStatus( username, appId, task.IsFaulted ? UserFeishuBotRuntimeState.Failed : UserFeishuBotRuntimeState.Stopped, true, true, + previousStatus?.ShouldAutoStart == true, task.IsFaulted ? $"飞书机器人连接已中断: {task.Exception?.GetBaseException().Message}" - : "飞书机器人连接已停止。"); + : ResolveStoppedMessage(isConfigured: true, previousStatus?.ShouldAutoStart == true)); failedStatus.LastError = task.IsFaulted ? task.Exception?.GetBaseException().Message : null; - failedStatus.LastStartedAt = _statusCache.TryGetValue(username, out var cached) ? cached.LastStartedAt : null; + failedStatus.LastStartedAt = previousStatus?.LastStartedAt; _statusCache[username] = failedStatus; } finally @@ -488,6 +572,7 @@ private static UserFeishuBotRuntimeStatus CloneStatus(UserFeishuBotRuntimeStatus State = status.State, IsConfigured = status.IsConfigured, CanStart = status.CanStart, + ShouldAutoStart = status.ShouldAutoStart, Message = status.Message, LastError = status.LastError, LastStartedAt = status.LastStartedAt, @@ -501,6 +586,7 @@ private static UserFeishuBotRuntimeStatus CreateStatus( UserFeishuBotRuntimeState state, bool isConfigured, bool canStart, + bool shouldAutoStart, string message) { return new UserFeishuBotRuntimeStatus @@ -510,12 +596,53 @@ private static UserFeishuBotRuntimeStatus CreateStatus( State = state, IsConfigured = isConfigured, CanStart = canStart, + ShouldAutoStart = shouldAutoStart, Message = message, UpdatedAt = DateTime.Now }; } - private sealed class RuntimeEntry : IDisposable + private async Task TryPersistRuntimePreferenceAsync( + IUserFeishuBotConfigService configService, + string username, + bool autoStartEnabled, + DateTime? lastStartedAt = null) + { + var updated = await configService.UpdateRuntimePreferenceAsync(username, autoStartEnabled, lastStartedAt); + if (!updated) + { + _logger.LogWarning( + "更新飞书机器人持久化状态失败: user={Username}, autoStartEnabled={AutoStartEnabled}", + username, + autoStartEnabled); + } + } + + private static string ResolveIdleMessage(bool isConfigured, bool shouldAutoStart) + { + if (!isConfigured) + { + return "当前用户尚未配置可启动的飞书机器人。"; + } + + return shouldAutoStart + ? "已记录为开启状态,服务启动时会自动恢复连接。" + : "已配置飞书机器人,点击启动后建立连接。"; + } + + private static string ResolveStoppedMessage(bool isConfigured, bool shouldAutoStart) + { + if (!isConfigured) + { + return "当前用户未配置可启动的飞书机器人。"; + } + + return shouldAutoStart + ? "飞书机器人已停止,服务重启后会自动恢复连接。" + : "飞书机器人已停止,可手动重新启动。"; + } + + protected sealed class RuntimeEntry : IDisposable { public RuntimeEntry(string appId, ServiceProvider serviceProvider, IHostedService hostedService) { diff --git a/WebCodeCli.Domain/Domain/Service/UserFeishuBotRuntimeStatus.cs b/WebCodeCli.Domain/Domain/Service/UserFeishuBotRuntimeStatus.cs index e91500d..db6a985 100644 --- a/WebCodeCli.Domain/Domain/Service/UserFeishuBotRuntimeStatus.cs +++ b/WebCodeCli.Domain/Domain/Service/UserFeishuBotRuntimeStatus.cs @@ -7,6 +7,7 @@ public sealed class UserFeishuBotRuntimeStatus public UserFeishuBotRuntimeState State { get; set; } = UserFeishuBotRuntimeState.NotConfigured; public bool IsConfigured { get; set; } public bool CanStart { get; set; } + public bool ShouldAutoStart { get; set; } public string? Message { get; set; } public string? LastError { get; set; } public DateTime? LastStartedAt { get; set; } diff --git a/WebCodeCli.Domain/Repositories/Base/ChatSession/ChatSessionEntity.cs b/WebCodeCli.Domain/Repositories/Base/ChatSession/ChatSessionEntity.cs index f81807f..82a1b83 100644 --- a/WebCodeCli.Domain/Repositories/Base/ChatSession/ChatSessionEntity.cs +++ b/WebCodeCli.Domain/Repositories/Base/ChatSession/ChatSessionEntity.cs @@ -38,6 +38,12 @@ public class ChatSessionEntity /// [SugarColumn(Length = 64, IsNullable = true)] public string? ToolId { get; set; } + + /// + /// CLI 会话/线程 ID(用于恢复外部 CLI 会话,例如 codex thread、claude resume、opencode session) + /// + [SugarColumn(Length = 256, IsNullable = true)] + public string? CliThreadId { get; set; } /// /// 创建时间 diff --git a/WebCodeCli.Domain/Repositories/Base/ChatSession/ChatSessionRepository.cs b/WebCodeCli.Domain/Repositories/Base/ChatSession/ChatSessionRepository.cs index 83cb9e1..9dd210d 100644 --- a/WebCodeCli.Domain/Repositories/Base/ChatSession/ChatSessionRepository.cs +++ b/WebCodeCli.Domain/Repositories/Base/ChatSession/ChatSessionRepository.cs @@ -1,6 +1,7 @@ using AntSK.Domain.Repositories.Base; using Microsoft.Extensions.DependencyInjection; using SqlSugar; +using System.IO; using WebCodeCli.Domain.Common.Extensions; namespace WebCodeCli.Domain.Repositories.Base.ChatSession; @@ -50,6 +51,80 @@ public async Task> GetByUsernameOrderByUpdatedAtAsync(st .ToListAsync(); } + /// + /// 根据用户名 + 工具 + CLI ThreadId 查找会话(用于外部会话导入/去重) + /// + public async Task GetByUsernameToolAndCliThreadIdAsync(string username, string toolId, string cliThreadId) + { + if (string.IsNullOrWhiteSpace(username) || + string.IsNullOrWhiteSpace(toolId) || + string.IsNullOrWhiteSpace(cliThreadId)) + { + return null; + } + + return await GetDB().Queryable() + .Where(x => x.Username == username && x.ToolId == toolId && x.CliThreadId == cliThreadId) + .FirstAsync(); + } + + /// + /// 根据工具 + CLI ThreadId 查找会话(跨用户,用于“一个外部会话只能被一个用户占用”约束) + /// + public async Task GetByToolAndCliThreadIdAsync(string toolId, string cliThreadId) + { + if (string.IsNullOrWhiteSpace(toolId) || string.IsNullOrWhiteSpace(cliThreadId)) + { + return null; + } + + return await GetDB().Queryable() + .Where(x => x.ToolId == toolId && x.CliThreadId == cliThreadId) + .FirstAsync(); + } + + /// + /// 更新会话的 CLI ThreadId(用于恢复) + /// + public async Task UpdateCliThreadIdAsync(string sessionId, string cliThreadId) + { + if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(cliThreadId)) + { + return false; + } + + var rows = await GetDB().Updateable() + .SetColumns(x => x.CliThreadId == cliThreadId) + .SetColumns(x => x.UpdatedAt == DateTime.Now) + .Where(x => x.SessionId == sessionId) + .ExecuteCommandAsync(); + + return rows > 0; + } + + /// + /// 更新会话的工作区绑定(仅更新数据库字段,不创建目录) + /// + public async Task UpdateWorkspaceBindingAsync(string sessionId, string? workspacePath, bool isCustomWorkspace) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + return false; + } + + var isValid = !string.IsNullOrWhiteSpace(workspacePath) && Directory.Exists(workspacePath); + + var rows = await GetDB().Updateable() + .SetColumns(x => x.WorkspacePath == workspacePath) + .SetColumns(x => x.IsCustomWorkspace == isCustomWorkspace) + .SetColumns(x => x.IsWorkspaceValid == isValid) + .SetColumns(x => x.UpdatedAt == DateTime.Now) + .Where(x => x.SessionId == sessionId) + .ExecuteCommandAsync(); + + return rows > 0; + } + /// /// 根据飞书ChatKey获取所有会话 /// diff --git a/WebCodeCli.Domain/Repositories/Base/ChatSession/IChatSessionRepository.cs b/WebCodeCli.Domain/Repositories/Base/ChatSession/IChatSessionRepository.cs index 99aa670..6853334 100644 --- a/WebCodeCli.Domain/Repositories/Base/ChatSession/IChatSessionRepository.cs +++ b/WebCodeCli.Domain/Repositories/Base/ChatSession/IChatSessionRepository.cs @@ -27,6 +27,26 @@ public interface IChatSessionRepository : IRepository /// Task> GetByUsernameOrderByUpdatedAtAsync(string username); + /// + /// 根据用户名 + 工具 + CLI ThreadId 查找会话(用于外部会话导入/去重) + /// + Task GetByUsernameToolAndCliThreadIdAsync(string username, string toolId, string cliThreadId); + + /// + /// 根据工具 + CLI ThreadId 查找会话(跨用户,用于“一个外部会话只能被一个用户占用”约束) + /// + Task GetByToolAndCliThreadIdAsync(string toolId, string cliThreadId); + + /// + /// 更新会话的 CLI ThreadId(用于恢复) + /// + Task UpdateCliThreadIdAsync(string sessionId, string cliThreadId); + + /// + /// 更新会话的工作区绑定(仅更新数据库字段,不创建目录) + /// + Task UpdateWorkspaceBindingAsync(string sessionId, string? workspacePath, bool isCustomWorkspace); + /// /// 根据飞书ChatKey获取所有会话 /// diff --git a/WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs b/WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs index 8f9cac2..61b73a8 100644 --- a/WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs +++ b/WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs @@ -15,6 +15,9 @@ public class UserFeishuBotConfigEntity [SugarColumn(IsNullable = false)] public bool IsEnabled { get; set; } = true; + [SugarColumn(IsNullable = false)] + public bool AutoStartEnabled { get; set; } + [SugarColumn(Length = 128, IsNullable = true)] public string? AppId { get; set; } @@ -39,6 +42,9 @@ public class UserFeishuBotConfigEntity [SugarColumn(IsNullable = true)] public int? StreamingThrottleMs { get; set; } + [SugarColumn(IsNullable = true)] + public DateTime? LastStartedAt { get; set; } + [SugarColumn(IsNullable = false)] public DateTime CreatedAt { get; set; } = DateTime.Now; diff --git a/WebCodeCli/Components/AdminUserManagementModal.razor b/WebCodeCli/Components/AdminUserManagementModal.razor index 9353bd3..8fa5cf3 100644 --- a/WebCodeCli/Components/AdminUserManagementModal.razor +++ b/WebCodeCli/Components/AdminUserManagementModal.razor @@ -203,6 +203,7 @@
@_feishuBotStatus.LastError
}
@Tx("adminUserManagement.feishuLastStarted", "最近启动时间", "Last started"):@GetMetaValue(_feishuBotStatus.LastStartedAt)
+
@Tx("adminUserManagement.feishuRestartBehavior", "服务重启后自动恢复", "Restore after restart"):@(_feishuBotStatus.ShouldAutoStart ? Tx("adminUserManagement.feishuRestartOn", "开启", "On") : Tx("adminUserManagement.feishuRestartOff", "停止", "Off"))