From 25f6c2a11454988ec2b6176dc809c0f636ebd260 Mon Sep 17 00:00:00 2001 From: "haiyan.lu" Date: Sun, 22 Mar 2026 16:31:40 +0800 Subject: [PATCH 1/5] feat(session): import external CLI sessions --- .../FeishuCardActionServiceTests.cs | 42 + .../Common/Extensions/DatabaseInitializer.cs | 10 + .../Model/Channels/FeishuHelpCardAction.cs | 12 + .../Domain/Model/ExternalCliSessionSummary.cs | 64 + .../Domain/Model/SessionHistory.cs | 5 + .../Channels/FeishuCardActionService.cs | 311 ++++- .../Domain/Service/CliExecutorService.cs | 108 +- .../Service/ExternalCliSessionService.cs | 1092 +++++++++++++++++ .../Domain/Service/SessionHistoryManager.cs | 4 + .../Base/ChatSession/ChatSessionEntity.cs | 6 + .../Base/ChatSession/ChatSessionRepository.cs | 75 ++ .../ChatSession/IChatSessionRepository.cs | 20 + .../EnvironmentVariableConfigModal.razor | 10 +- .../ExternalCliSessionImportModal.razor | 325 +++++ WebCodeCli/Controllers/SessionController.cs | 56 + WebCodeCli/Pages/CodeAssistant.razor | 21 + WebCodeCli/Pages/CodeAssistant.razor.cs | 49 +- WebCodeCli/Pages/CodeAssistantMobile.razor | 22 + WebCodeCli/Pages/CodeAssistantMobile.razor.cs | 35 +- 19 files changed, 2258 insertions(+), 9 deletions(-) create mode 100644 WebCodeCli.Domain/Domain/Model/ExternalCliSessionSummary.cs create mode 100644 WebCodeCli.Domain/Domain/Service/ExternalCliSessionService.cs create mode 100644 WebCodeCli/Components/ExternalCliSessionImportModal.razor diff --git a/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs b/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs index d9219c1..e56a38e 100644 --- a/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs +++ b/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs @@ -1183,6 +1183,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 diff --git a/WebCodeCli.Domain/Common/Extensions/DatabaseInitializer.cs b/WebCodeCli.Domain/Common/Extensions/DatabaseInitializer.cs index 474ec98..54e846e 100644 --- a/WebCodeCli.Domain/Common/Extensions/DatabaseInitializer.cs +++ b/WebCodeCli.Domain/Common/Extensions/DatabaseInitializer.cs @@ -228,6 +228,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/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..fe0ba26 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, 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": @@ -1857,6 +1869,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 +1954,284 @@ private async Task HandleOpenSessionManagerAsync(s } } + private async Task HandleDiscoverExternalCliSessionsAsync( + string? chatKey, + string? chatId, + string? toolId, + 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); + var discovered = await externalService.DiscoverAsync(username, normalizedToolId, maxCount: 20); + + var elements = new List(); + + elements.Add(new + { + tag = "div", + text = new + { + tag = "lark_md", + content = $"## 📥 导入本地 CLI 会话\n当前找到 **{discovered.Count}** 个可导入会话。\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") + }) + { + elements.Add(new + { + tag = "button", + text = new { tag = "plain_text", content = label }, + type = string.IsNullOrWhiteSpace(value) ? "primary" : "default", + behaviors = new[] + { + new + { + type = "callback", + value = new + { + action = "discover_external_cli_sessions", + chat_key = actualChatKey, + tool_id = value + } + } + } + }); + } + + 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 discovered.Take(10)) + { + 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" }); + } + } + + 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/CliExecutorService.cs b/WebCodeCli.Domain/Domain/Service/CliExecutorService.cs index 2121531..7b1945d 100644 --- a/WebCodeCli.Domain/Domain/Service/CliExecutorService.cs +++ b/WebCodeCli.Domain/Domain/Service/CliExecutorService.cs @@ -175,11 +175,54 @@ 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)) + { + 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 +234,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 +1197,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 +1247,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/ExternalCliSessionService.cs b/WebCodeCli.Domain/Domain/Service/ExternalCliSessionService.cs new file mode 100644 index 0000000..c511b89 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/ExternalCliSessionService.cs @@ -0,0 +1,1092 @@ +using System.Diagnostics; +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 sealed 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(); + } + + 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 = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + 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 = 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 = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + 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 + if (list.Count == 0) + { + 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()) + .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 = File.ReadAllText(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"); + + 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, 200)) + .ToList(); + } + catch + { + return list; + } + + foreach (var file in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + + var line = ReadFirstNonEmptyLine(file, maxLines: 10); + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + try + { + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + + var sessionId = GetString(root, "sessionId", "session_id") ?? Path.GetFileNameWithoutExtension(file); + var cwd = GetString(root, "cwd", "projectPath", "project_path"); + var updatedAt = GetDateTime(root, "timestamp") ?? File.GetLastWriteTime(file); + + if (string.IsNullOrWhiteSpace(sessionId)) + { + continue; + } + + list.Add(new ExternalCliSessionSummary + { + ToolId = "claude-code", + ToolName = "Claude Code", + CliThreadId = sessionId, + WorkspacePath = string.IsNullOrWhiteSpace(cwd) ? null : cwd, + UpdatedAt = updatedAt + }); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "解析 Claude session jsonl 失败,忽略: {File}", file); + } + } + + return list; + } + + 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? 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) + "..."; + } +} 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/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/Components/EnvironmentVariableConfigModal.razor b/WebCodeCli/Components/EnvironmentVariableConfigModal.razor index 0c67592..bc7f08a 100644 --- a/WebCodeCli/Components/EnvironmentVariableConfigModal.razor +++ b/WebCodeCli/Components/EnvironmentVariableConfigModal.razor @@ -156,6 +156,7 @@ private bool _isVisible = false; private bool _isLoading = false; private CliToolConfig? _selectedTool; + private string? _username; private List _envVars = new(); // 本地化相关 @@ -173,9 +174,10 @@ await LoadTranslationsAsync(); } - public async Task ShowAsync(CliToolConfig tool) + public async Task ShowAsync(CliToolConfig tool, string? username = null) { _selectedTool = tool; + _username = string.IsNullOrWhiteSpace(username) ? null : username.Trim(); _isVisible = true; _isLoading = true; StateHasChanged(); @@ -184,7 +186,7 @@ { _currentLanguage = await L.GetCurrentLanguageAsync(); await LoadTranslationsAsync(); - var envVars = await CliToolEnvironmentService.GetEnvironmentVariablesAsync(tool.Id); + var envVars = await CliToolEnvironmentService.GetEnvironmentVariablesAsync(tool.Id, _username); _envVars = envVars.Select(kvp => new EnvVarItem { Key = kvp.Key, Value = kvp.Value }).ToList(); } catch (Exception ex) @@ -249,7 +251,7 @@ .Where(ev => !string.IsNullOrWhiteSpace(ev.Key) && !string.IsNullOrWhiteSpace(ev.Value)) .ToDictionary(ev => ev.Key.Trim(), ev => ev.Value!.Trim()); - var success = await CliToolEnvironmentService.SaveEnvironmentVariablesAsync(_selectedTool.Id, validEnvVars); + var success = await CliToolEnvironmentService.SaveEnvironmentVariablesAsync(_selectedTool.Id, validEnvVars, _username); if (success) { @@ -281,7 +283,7 @@ try { - var defaultEnvVars = await CliToolEnvironmentService.ResetToDefaultAsync(_selectedTool.Id); + var defaultEnvVars = await CliToolEnvironmentService.ResetToDefaultAsync(_selectedTool.Id, _username); _envVars = defaultEnvVars.Select(kvp => new EnvVarItem { Key = kvp.Key, Value = kvp.Value }).ToList(); Console.WriteLine("已重置为默认配置"); } diff --git a/WebCodeCli/Components/ExternalCliSessionImportModal.razor b/WebCodeCli/Components/ExternalCliSessionImportModal.razor new file mode 100644 index 0000000..ee567bc --- /dev/null +++ b/WebCodeCli/Components/ExternalCliSessionImportModal.razor @@ -0,0 +1,325 @@ +@using WebCodeCli.Domain.Domain.Model +@using WebCodeCli.Domain.Domain.Service +@inject IExternalCliSessionService ExternalCliSessionService + +
+ @if (_isVisible) + { +
+
+
+
+
+ + + +
+
+

导入本地 CLI 会话

+

+ 仅显示当前 Windows 账户下、且工作区在允许目录内的会话。 +

+
+
+ +
+ +
+
+
+ 工具 + +
+ +
+ 数量 + +
+ +
+ + +
+ + @if (!string.IsNullOrWhiteSpace(_errorMessage)) + { +
+ @_errorMessage +
+ } + + @if (_isLoading) + { +
+
+
+ } + else if (_sessions.Count == 0) + { +
+ 未发现可导入的会话。 +
+ } + else + { +
+ @foreach (var item in _sessions) + { + var importingKey = $"{item.ToolId}::{item.CliThreadId}"; + var isImporting = _importingKeys.Contains(importingKey); + +
+
+
+
+ + @(!string.IsNullOrWhiteSpace(item.ToolName) ? item.ToolName : item.ToolId) + + @if (item.AlreadyImported && !string.IsNullOrWhiteSpace(item.ImportedSessionId)) + { + + 已导入 + + } +
+ +
+ @(!string.IsNullOrWhiteSpace(item.Title) ? item.Title : item.CliThreadId) +
+ +
+ 工作区: @item.WorkspacePath +
+ +
+ 最近更新: @FormatDateTime(item.UpdatedAt) +
+
+ +
+ @if (item.AlreadyImported && !string.IsNullOrWhiteSpace(item.ImportedSessionId)) + { + + } + else + { + + } + + +
+
+
+ } +
+ } +
+ +
+
+ 用户: @_username +
+ +
+
+
+ } +
+ +@code { + [Parameter] public EventCallback OnOpenSession { get; set; } + [Parameter] public EventCallback OnImported { get; set; } + + private bool _isVisible; + private bool _isLoading; + private string _username = string.Empty; + private string _toolFilter = string.Empty; + private int _maxCount = 20; + + private string? _errorMessage; + private List _sessions = new(); + private HashSet _importingKeys = new(StringComparer.OrdinalIgnoreCase); + + public async Task ShowAsync(string username) + { + _username = username?.Trim() ?? string.Empty; + _toolFilter = string.Empty; + _maxCount = 20; + _isVisible = true; + _errorMessage = null; + _sessions = new List(); + _importingKeys.Clear(); + + await ReloadAsync(); + } + + private void OnBackgroundClick() + { + Close(); + } + + private void Close() + { + _isVisible = false; + _errorMessage = null; + _importingKeys.Clear(); + StateHasChanged(); + } + + private async Task ReloadAsync() + { + if (string.IsNullOrWhiteSpace(_username)) + { + _errorMessage = "用户名为空,无法发现会话。"; + StateHasChanged(); + return; + } + + _isLoading = true; + _errorMessage = null; + StateHasChanged(); + + try + { + var toolId = string.IsNullOrWhiteSpace(_toolFilter) ? null : _toolFilter; + var maxCount = _maxCount <= 0 ? 20 : Math.Clamp(_maxCount, 5, 100); + _sessions = await ExternalCliSessionService.DiscoverAsync(_username, toolId, maxCount); + } + catch (Exception ex) + { + _errorMessage = $"发现会话失败: {ex.Message}"; + _sessions = new List(); + } + finally + { + _isLoading = false; + StateHasChanged(); + } + } + + private async Task ImportAsync(ExternalCliSessionSummary item) + { + if (item == null) + { + return; + } + + var importingKey = $"{item.ToolId}::{item.CliThreadId}"; + if (_importingKeys.Contains(importingKey)) + { + return; + } + + _importingKeys.Add(importingKey); + _errorMessage = null; + StateHasChanged(); + + try + { + var request = new ImportExternalCliSessionRequest + { + ToolId = item.ToolId, + CliThreadId = item.CliThreadId, + Title = item.Title, + WorkspacePath = item.WorkspacePath + }; + + var result = await ExternalCliSessionService.ImportAsync(_username, request); + if (!result.Success) + { + _errorMessage = result.ErrorMessage ?? "导入失败,请稍后重试。"; + return; + } + + item.AlreadyImported = true; + item.ImportedSessionId = result.SessionId; + + if (!string.IsNullOrWhiteSpace(result.SessionId)) + { + await OnImported.InvokeAsync(result.SessionId); + } + } + catch (Exception ex) + { + _errorMessage = $"导入失败: {ex.Message}"; + } + finally + { + _importingKeys.Remove(importingKey); + StateHasChanged(); + } + } + + private async Task OpenImportedSessionAsync(string sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + return; + } + + await OnOpenSession.InvokeAsync(sessionId); + Close(); + } + + private static string FormatDateTime(DateTime? time) + { + if (!time.HasValue) + { + return "-"; + } + + return time.Value.ToString("yyyy-MM-dd HH:mm"); + } +} + diff --git a/WebCodeCli/Controllers/SessionController.cs b/WebCodeCli/Controllers/SessionController.cs index 95204ab..26b9701 100644 --- a/WebCodeCli/Controllers/SessionController.cs +++ b/WebCodeCli/Controllers/SessionController.cs @@ -17,17 +17,20 @@ public class SessionController : ControllerBase private readonly ISessionHistoryManager _sessionHistoryManager; private readonly ISessionOutputService _sessionOutputService; private readonly ISessionDirectoryService _sessionDirectoryService; + private readonly IExternalCliSessionService _externalCliSessionService; private readonly ILogger _logger; public SessionController( ISessionHistoryManager sessionHistoryManager, ISessionOutputService sessionOutputService, ISessionDirectoryService sessionDirectoryService, + IExternalCliSessionService externalCliSessionService, ILogger logger) { _sessionHistoryManager = sessionHistoryManager; _sessionOutputService = sessionOutputService; _sessionDirectoryService = sessionDirectoryService; + _externalCliSessionService = externalCliSessionService; _logger = logger; } @@ -295,6 +298,59 @@ public async Task GetSessionWorkspace(string sessionId) } #endregion + + #region 外部 CLI 会话发现/导入 + + /// + /// 发现当前操作系统账户下的外部 CLI 会话(仅标记当前 Web 用户是否已导入) + /// + [HttpGet("discovered")] + public async Task>> DiscoverExternalSessions( + [FromQuery] string? toolId = null, + [FromQuery] int maxCount = 20) + { + try + { + var username = GetCurrentUsername(); + var result = await _externalCliSessionService.DiscoverAsync(username, toolId, maxCount); + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "发现外部 CLI 会话失败"); + return StatusCode(500, new { Error = "发现外部 CLI 会话失败" }); + } + } + + /// + /// 导入外部 CLI 会话为 WebCode 会话 + /// + [HttpPost("import")] + public async Task> ImportExternalSession([FromBody] ImportExternalCliSessionRequest request) + { + try + { + var username = GetCurrentUsername(); + var result = await _externalCliSessionService.ImportAsync(username, request); + if (!result.Success) + { + return BadRequest(result); + } + + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "导入外部 CLI 会话失败"); + return StatusCode(500, new ImportExternalCliSessionResult + { + Success = false, + ErrorMessage = "导入外部 CLI 会话失败" + }); + } + } + + #endregion } /// diff --git a/WebCodeCli/Pages/CodeAssistant.razor b/WebCodeCli/Pages/CodeAssistant.razor index 6e943be..7afc955 100644 --- a/WebCodeCli/Pages/CodeAssistant.razor +++ b/WebCodeCli/Pages/CodeAssistant.razor @@ -448,6 +448,11 @@ + + + @* 代码预览模态框 (已应用Tailwind样式) *@ @@ -680,6 +685,22 @@ + + + @if (_isAdmin) { + + @if (_isAdmin) {