diff --git a/Directory.Build.props b/Directory.Build.props index 574265b..95073d3 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 0.2.7 + 0.2.8 10.0.1 diff --git a/README.md b/README.md index ed80e86..6be66b8 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,25 @@ --- +## 功能目录 + +- [项目简介](#项目简介) +- [当前重点能力](#当前重点能力) +- [多 AI CLI 接入](#多-ai-cli-接入) +- [Web、移动端、飞书统一会话流](#web移动端飞书统一会话流) +- [多用户与权限控制](#多用户与权限控制) +- [外部会话导入与恢复](#外部会话导入与恢复) +- [Codex `/goal` 快速目标能力](#codex-goal-快速目标能力) +- [Superpowers 工作流](#superpowers-工作流) +- [Windows 安装包发布](#windows-安装包发布) +- [`cc-switch` 托管模型](#cc-switch-托管模型) +- [快速开始](#快速开始) +- [配置说明](#配置说明) +- [Windows 发布维护](#windows-发布维护) +- [项目结构](#项目结构) +- [技术栈](#技术栈) +- [常用文档](#常用文档) + ## 项目简介 WebCode 是一个基于 `Blazor Server + .NET 10` 的 AI CLI 工作平台。它不是单机聊天壳,而是把本地或服务器上的 AI CLI 包装成一个可管理、可部署、可协作、可远程访问的工作系统。 @@ -54,6 +73,47 @@ WebCode 是一个基于 `Blazor Server + .NET 10` 的 AI CLI 工作平台。它 - 支持导入本地 CLI 会话并继续在 WebCode 中使用 - 支持在移动端抽屉和飞书会话卡片里直接管理会话 +#### 功能入口矩阵 + +下表用于快速说明三类主要入口当前各自覆盖的能力范围: + +| 能力 | 桌面 Web | 移动端 | 飞书 | +|------|----------|--------|------| +| 创建、切换、关闭会话 | 支持 | 支持 | 支持 | +| 导入本地 CLI 会话 | 支持 | 支持 | 支持 | +| 会话级 Provider 同步 | 支持 | 支持 | 支持 | +| 工作区浏览与文件操作 | 支持最完整 | 支持常用操作 | 以会话/卡片操作为主 | +| 流式输出查看 | 支持最完整 | 支持 | 支持卡片流式回显 | +| `/goal` 快捷目标 | 支持 | 支持 | 支持 | +| `Superpowers` 快捷工作流 | 支持 | 支持 | 支持 | +| 多用户管理与系统配置 | 支持最完整 | 适合轻量查看/操作 | 不作为主要入口 | +| 安装与部署维护 | 主要依赖文档和本地脚本 | 不作为主要入口 | 不作为主要入口 | + +补充说明: + +- 桌面 Web 是能力最完整的主控制台,适合管理、调试、配置和重度会话操作 +- 移动端保留高频会话操作,重点是随时查看、切换、继续和触发快捷动作 +- 飞书更偏消息驱动和卡片驱动,适合在聊天上下文里直接继续会话、执行快捷动作和接收流式结果 + +#### 飞书能力清单 + +飞书当前不是“只做消息通知”,而是已经覆盖一条可直接工作的会话与卡片交互链路: + +- 支持把飞书会话绑定到 WebCode 用户和本地/服务器侧 CLI 会话 +- 支持在飞书卡片中创建、切换、关闭、继续会话 +- 支持在飞书端浏览并导入外部 CLI 会话 +- 支持在飞书卡片中触发 `/goal`、`Superpowers` 等快捷动作 +- 支持流式卡片更新,在执行过程中持续回显输出内容 +- 支持会话级 Provider 同步,确保飞书侧也遵循 `cc-switch` 当前激活状态 +- 支持 Reply TTS 相关能力,用于把回复内容进一步转换为语音或语音服务调用链 +- 支持帮助卡片、快捷入口卡片、会话管理卡片等多种交互载体 + +适合的飞书使用场景包括: + +- 在群聊或私聊里直接继续一个已有 AI CLI 会话 +- 在移动办公场景下用卡片完成高频操作,而不打开完整 Web 控制台 +- 用机器人卡片承接流式输出、快捷动作、目标设定和会话切换 + ### 多用户与权限控制 - 用户启用/禁用 @@ -71,6 +131,49 @@ WebCode 是一个基于 `Blazor Server + .NET 10` 的 AI CLI 工作平台。它 - 同一个 `(ToolId, CliThreadId)` 只能被一个 WebCode 用户占用 - Web 端与飞书端都支持分页浏览、导入并切换到这些会话 +### Codex `/goal` 快速目标能力 + +当前版本已经补齐 Codex `/goal` 相关能力,覆盖 Web 端、移动端和飞书卡片场景: + +- WebCode 会先探测当前 Codex CLI 版本与 `goals` feature 是否可用 +- 当探测结果可用时,会在会话级 `.codex/config.toml` 中自动注入 `goals = true` +- 用户可以通过快速输入或快捷动作把普通文本自动补成 `/goal ...` +- `/goal` 能力按会话生效,不要求手工长期维护全局 `config.toml` + +说明: + +- 当前要求 Codex CLI 版本不低于 `0.128.0` +- WebCode 在 Windows 下会自动解析 `codex.exe`、`codex.cmd`、`codex.bat`、`codex.ps1` 等入口,避免因包装脚本导致版本探测失败 +- 如果 Codex 本身未提供 `goals` feature,WebCode 不会强行注入该配置 + +### Superpowers 工作流 + +当前仓库已经接入 `Superpowers` 工作流增强能力,用来把普通 CLI 会话提升为“带策略和快捷动作的执行流”: + +- 支持在 Web 端、移动端和飞书卡片中触发工作流型快捷动作 +- 支持把用户输入快速包装成结构化工作流提示,而不只是原样转发给 CLI +- 支持能力探测,按当前环境决定是否展示或启用对应的工作流入口 +- 支持把 `Superpowers` 与现有 `Claude Code`、`Codex`、`OpenCode` 会话流结合使用 + +当前文档和实现已经覆盖的典型工作流包括: + +- 计划类工作流,例如 `plan`、`ralplan` +- 持续执行类工作流,例如 `ralph` +- 深度澄清类工作流,例如 `deep-interview` +- 协作/并行类工作流,例如 `team`、`ultrawork` +- 快捷动作类封装,例如面向会话的一键注入提示词和工作流命令 + +说明: + +- `Superpowers` 不是单独的 Web 页面,而是叠加在现有会话、快捷动作、飞书卡片交互之上的工作流层 +- 哪些工作流可用,取决于当前工具、环境能力探测结果以及会话上下文 +- 相关实现可参考: + - [WebCodeCli.Domain/Domain/Service/SuperpowersCapabilityService.cs](./WebCodeCli.Domain/Domain/Service/SuperpowersCapabilityService.cs) + - [WebCodeCli.Domain/Domain/Service/SuperpowersPromptBuilder.cs](./WebCodeCli.Domain/Domain/Service/SuperpowersPromptBuilder.cs) + - [WebCodeCli.Domain/Domain/Service/Channels/SuperpowersQuickActionCardHelper.cs](./WebCodeCli.Domain/Domain/Service/Channels/SuperpowersQuickActionCardHelper.cs) + - [WebCodeCli/Pages/CodeAssistant.razor.cs](./WebCodeCli/Pages/CodeAssistant.razor.cs) + - [WebCodeCli/Pages/CodeAssistantMobile.razor.cs](./WebCodeCli/Pages/CodeAssistantMobile.razor.cs) + ### Windows 安装包发布 仓库已经内置 Windows 安装包构建脚本,可直接生成: @@ -79,6 +182,7 @@ WebCode 是一个基于 `Blazor Server + .NET 10` 的 AI CLI 工作平台。它 - 便携版 `WebCode-vX.Y.Z-win-x64-portable.zip` - 校验文件 `SHA256SUMS.txt` - Release 说明 `RELEASE_NOTES.md` +- 包含 Reply TTS 服务与运行所需资源的 `tts-bundle/` ## `cc-switch` 托管模型 @@ -257,6 +361,13 @@ dotnet run --project WebCodeCli powershell -ExecutionPolicy Bypass -File .\tools\build-windows-installer.ps1 ``` +如果你要生成“包含 Reply TTS / Kokoro 能力”的本地 Windows 安装包,应该优先走这条脚本,而不是单独 `dotnet publish`。原因是安装包脚本除了发布主程序外,还会额外处理这些内容: + +- 调整发布目录里的 `appsettings.json` +- 拷贝 `tools/sherpa-kokoro-service` +- 组装 `tts-bundle` +- 生成 Inno Setup 安装包与 portable zip + 脚本会读取 [Directory.Build.props](./Directory.Build.props) 中的版本号,并在 `artifacts/windows-installer/vX.Y.Z/` 下生成: - `publish/` @@ -264,6 +375,15 @@ powershell -ExecutionPolicy Bypass -File .\tools\build-windows-installer.ps1 - `installer/WebCode-Setup-vX.Y.Z-win-x64.exe` - `SHA256SUMS.txt` - `RELEASE_NOTES.md` +- `tts-bundle/` + +在 Windows 机器上,如果默认输出目录存在旧文件锁定,或者 Inno Setup 遇到长路径问题,可以显式指定一个较短的输出目录,例如: + +```powershell +powershell -ExecutionPolicy Bypass -File .\tools\build-windows-installer.ps1 -OutputRoot D:\wci +``` + +这种方式同样会生成完整的安装版、便携版和 TTS 资源目录,适合本地快速出包。 构建机要求: @@ -311,6 +431,10 @@ WebCode/ - [DEPLOY_DOCKER.md](./DEPLOY_DOCKER.md) - [docs/QUICKSTART_CodeAssistant.md](./docs/QUICKSTART_CodeAssistant.md) - [docs/README_CodeAssistant.md](./docs/README_CodeAssistant.md) +- [docs/superpowers/plans/2026-04-30-superpowers-plan-quick-actions-implementation.md](./docs/superpowers/plans/2026-04-30-superpowers-plan-quick-actions-implementation.md) +- [docs/superpowers/specs/2026-04-30-superpowers-plan-quick-actions-design.md](./docs/superpowers/specs/2026-04-30-superpowers-plan-quick-actions-design.md) +- [docs/superpowers/plans/2026-04-24-ralph-runtime-implementation.md](./docs/superpowers/plans/2026-04-24-ralph-runtime-implementation.md) +- [docs/superpowers/specs/2026-04-24-ralph-runtime-design.md](./docs/superpowers/specs/2026-04-24-ralph-runtime-design.md) - [docs/workspace-management-guide.md](./docs/workspace-management-guide.md) - [docs/workspace-management-deployment-guide.md](./docs/workspace-management-deployment-guide.md) diff --git a/WebCodeCli.Domain.Tests/AudioTranscodeServiceTests.cs b/WebCodeCli.Domain.Tests/AudioTranscodeServiceTests.cs new file mode 100644 index 0000000..7805db9 --- /dev/null +++ b/WebCodeCli.Domain.Tests/AudioTranscodeServiceTests.cs @@ -0,0 +1,235 @@ +using Microsoft.Extensions.Options; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Service.Channels; + +namespace WebCodeCli.Domain.Tests; + +public sealed class AudioTranscodeServiceTests : IDisposable +{ + private readonly string _sandboxRoot = Path.Combine(Path.GetTempPath(), "webcode-audio-transcode-tests", Guid.NewGuid().ToString("N")); + + [Fact] + public async Task TranscodeChunkAsync_WhenFfmpegPathIsMissing_Throws() + { + Directory.CreateDirectory(_sandboxRoot); + var service = CreateService( + new FeishuReplyTtsOptions + { + TtsStorageRoot = Path.Combine(_sandboxRoot, "reply-tts") + }, + new RecordingExternalProcessRunner()); + var inputPath = CreateInputFile(); + + var error = await Assert.ThrowsAsync(() => + service.TranscodeChunkAsync("job-1", inputPath, chunkIndex: 1, TestContext.Current.CancellationToken)); + + Assert.Contains("FfmpegExecutablePath", error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task TranscodeChunkAsync_WhenTempRootIsUnavailable_Throws() + { + Directory.CreateDirectory(_sandboxRoot); + var options = new FeishuReplyTtsOptions + { + FfmpegExecutablePath = Path.Combine(_sandboxRoot, "ffmpeg.exe") + }; + var service = new AudioTranscodeService( + Options.Create(options), + new ReplyTtsStorageRootResolver( + new MutableOptionsMonitor(options), + new FakeReplyTtsHostEnvironment( + isWindows: true, + systemDriveRoot: @"C:\", + drives: + [ + new ReplyTtsDriveDescriptor(@"C:\", isReady: true, isWritable: true) + ])), + new RecordingExternalProcessRunner()); + var inputPath = CreateInputFile(); + + var error = await Assert.ThrowsAsync(() => + service.TranscodeChunkAsync("job-1", inputPath, chunkIndex: 1, TestContext.Current.CancellationToken)); + + Assert.Contains("unavailable", error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task TranscodeChunkAsync_WritesUnderResolvedTempRoot_AndInvokesExpectedFfmpegArguments() + { + Directory.CreateDirectory(_sandboxRoot); + var runner = new RecordingExternalProcessRunner(); + var ffmpegPath = Path.Combine(_sandboxRoot, "ffmpeg.exe"); + File.WriteAllText(ffmpegPath, "stub"); + + var service = CreateService( + new FeishuReplyTtsOptions + { + TtsStorageRoot = Path.Combine(_sandboxRoot, "reply-tts"), + FfmpegExecutablePath = ffmpegPath + }, + runner); + var inputPath = CreateInputFile(); + + var outputPath = await service.TranscodeChunkAsync("job-42", inputPath, chunkIndex: 1, TestContext.Current.CancellationToken); + + Assert.Equal(ffmpegPath, runner.FileName); + Assert.Contains("-y", runner.Arguments, StringComparison.Ordinal); + Assert.Contains("-i", runner.Arguments, StringComparison.Ordinal); + Assert.Contains("libopus", runner.Arguments, StringComparison.Ordinal); + Assert.Contains("-ac 1", runner.Arguments, StringComparison.Ordinal); + Assert.Contains("-ar 16000", runner.Arguments, StringComparison.Ordinal); + Assert.Equal(Path.Combine(_sandboxRoot, "reply-tts", "temp", "job-42"), runner.WorkingDirectory); + Assert.Equal(Path.Combine(_sandboxRoot, "reply-tts", "temp", "job-42", "chunk-001.opus"), outputPath); + } + + [Fact] + public async Task TranscodeChunkAsync_WhenRunnerReturnsNonZeroExit_Throws() + { + Directory.CreateDirectory(_sandboxRoot); + var ffmpegPath = Path.Combine(_sandboxRoot, "ffmpeg.exe"); + File.WriteAllText(ffmpegPath, "stub"); + var service = CreateService( + new FeishuReplyTtsOptions + { + TtsStorageRoot = Path.Combine(_sandboxRoot, "reply-tts"), + FfmpegExecutablePath = ffmpegPath + }, + new RecordingExternalProcessRunner + { + Result = new ExternalProcessResult(1, string.Empty, "ffmpeg failed") + }); + var inputPath = CreateInputFile(); + + var error = await Assert.ThrowsAsync(() => + service.TranscodeChunkAsync("job-42", inputPath, chunkIndex: 1, TestContext.Current.CancellationToken)); + + Assert.Contains("exit code 1", error.Message, StringComparison.Ordinal); + Assert.Contains("ffmpeg failed", error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task TranscodeChunkAsync_WhenConfiguredPathIsBlank_UsesBundledStorageRootFfmpeg() + { + Directory.CreateDirectory(_sandboxRoot); + var runner = new RecordingExternalProcessRunner(); + var storageRoot = Path.Combine(_sandboxRoot, "reply-tts"); + var bundledFfmpegPath = Path.Combine(storageRoot, "ffmpeg", "bin", OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg"); + Directory.CreateDirectory(Path.GetDirectoryName(bundledFfmpegPath)!); + File.WriteAllText(bundledFfmpegPath, "stub"); + + var service = CreateService( + new FeishuReplyTtsOptions + { + TtsStorageRoot = storageRoot + }, + runner); + var inputPath = CreateInputFile(); + + await service.TranscodeChunkAsync("job-42", inputPath, chunkIndex: 1, TestContext.Current.CancellationToken); + + Assert.Equal(bundledFfmpegPath, runner.FileName); + } + + [Fact] + public async Task TranscodeChunkAsync_SanitizesJobIdToStayUnderTempRoot() + { + Directory.CreateDirectory(_sandboxRoot); + var runner = new RecordingExternalProcessRunner(); + var ffmpegPath = Path.Combine(_sandboxRoot, "ffmpeg.exe"); + File.WriteAllText(ffmpegPath, "stub"); + var service = CreateService( + new FeishuReplyTtsOptions + { + TtsStorageRoot = Path.Combine(_sandboxRoot, "reply-tts"), + FfmpegExecutablePath = ffmpegPath + }, + runner); + var inputPath = CreateInputFile(); + + var outputPath = await service.TranscodeChunkAsync("..", inputPath, chunkIndex: 1, TestContext.Current.CancellationToken); + + Assert.Equal(Path.Combine(_sandboxRoot, "reply-tts", "temp", "__", "chunk-001.opus"), outputPath); + Assert.StartsWith(Path.Combine(_sandboxRoot, "reply-tts", "temp"), outputPath, StringComparison.Ordinal); + Assert.Equal(Path.Combine(_sandboxRoot, "reply-tts", "temp", "__"), runner.WorkingDirectory); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_sandboxRoot)) + { + Directory.Delete(_sandboxRoot, recursive: true); + } + } + catch + { + } + } + + private AudioTranscodeService CreateService(FeishuReplyTtsOptions options, RecordingExternalProcessRunner runner) + { + return new AudioTranscodeService( + Options.Create(options), + new ReplyTtsStorageRootResolver( + new MutableOptionsMonitor(options), + new FakeReplyTtsHostEnvironment(isWindows: false, systemDriveRoot: null, drives: [])), + runner); + } + + private string CreateInputFile() + { + var inputPath = Path.Combine(_sandboxRoot, "input.wav"); + File.WriteAllText(inputPath, "wav"); + return inputPath; + } + + private sealed class RecordingExternalProcessRunner : IExternalProcessRunner + { + public ExternalProcessResult Result { get; set; } = new(0, string.Empty, string.Empty); + + public string? FileName { get; private set; } + + public string? Arguments { get; private set; } + + public string? WorkingDirectory { get; private set; } + + public Task RunAsync( + string fileName, + string arguments, + string? workingDirectory = null, + CancellationToken cancellationToken = default) + { + FileName = fileName; + Arguments = arguments; + WorkingDirectory = workingDirectory; + return Task.FromResult(Result); + } + } + + private sealed class MutableOptionsMonitor(TOptions currentValue) : IOptionsMonitor + { + public TOptions CurrentValue { get; private set; } = currentValue; + + public TOptions Get(string? name) => CurrentValue; + + public IDisposable? OnChange(Action listener) => null; + } + + private sealed class FakeReplyTtsHostEnvironment( + bool isWindows, + string? systemDriveRoot, + IReadOnlyList drives) : IReplyTtsHostEnvironment + { + public bool IsWindows { get; } = isWindows; + + public string? SystemDriveRoot { get; } = systemDriveRoot; + + public IReadOnlyList GetFixedDrives() => drives; + + public bool DirectoryExists(string path) => Directory.Exists(path); + + public bool FileExists(string path) => File.Exists(path); + } +} diff --git a/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs b/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs index 381c5f6..5c10c61 100644 --- a/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs +++ b/WebCodeCli.Domain.Tests/CliExecutorServiceTests.cs @@ -3037,7 +3037,7 @@ public void BuildCodexConfigContent_UsesNewSchema() var configContent = (string)typeof(CliExecutorService) .GetMethod("BuildCodexConfigContent", BindingFlags.Static | BindingFlags.NonPublic)! - .Invoke(null, [envVars])!; + .Invoke(null, [envVars, false])!; Assert.Contains("model_provider = \"meteor-ai\"", configContent, StringComparison.Ordinal); Assert.Contains("disable_response_storage = true", configContent, StringComparison.Ordinal); @@ -3054,6 +3054,17 @@ public void BuildCodexConfigContent_UsesNewSchema() Assert.DoesNotContain("env_key =", configContent, StringComparison.Ordinal); } + [Fact] + public void BuildCodexConfigContent_WhenGoalsEnabled_AppendsGoalsFeatureFlag() + { + var configContent = (string)typeof(CliExecutorService) + .GetMethod("BuildCodexConfigContent", BindingFlags.Static | BindingFlags.NonPublic)! + .Invoke(null, [new Dictionary(), true])!; + + Assert.Contains("[features]", configContent, StringComparison.Ordinal); + Assert.Contains("goals = true", configContent, StringComparison.Ordinal); + } + [Fact] public void ResolveCommandPath_WhenCommandExistsOnWindowsPath_ReturnsFullPath() { diff --git a/WebCodeCli.Domain.Tests/CodexThreadProviderSyncServiceTests.cs b/WebCodeCli.Domain.Tests/CodexThreadProviderSyncServiceTests.cs index ebfe4e3..3ff3e8d 100644 --- a/WebCodeCli.Domain.Tests/CodexThreadProviderSyncServiceTests.cs +++ b/WebCodeCli.Domain.Tests/CodexThreadProviderSyncServiceTests.cs @@ -73,6 +73,60 @@ await File.WriteAllTextAsync( } } + [Fact] + public async Task SyncThreadProviderAsync_BacksUpMatchingRolloutBeforeRewrite() + { + var workspaceRoot = Path.Combine(Path.GetTempPath(), "WebCodeCli.Tests", Guid.NewGuid().ToString("N")); + var workspacePath = Path.Combine(workspaceRoot, "workspace"); + var codexRoot = Path.Combine(workspacePath, ".codex"); + var sessionsRoot = Path.Combine(codexRoot, "sessions", "2026", "04", "30"); + var matchingPath = Path.Combine(sessionsRoot, "rollout-2026-04-30T10-00-00-thread-a.jsonl"); + var falseCandidatePath = Path.Combine(sessionsRoot, "rollout-2026-04-30T10-00-01-thread-b.jsonl"); + var expectedBackupPath = Path.Combine(codexRoot, "rollout-backups", "sessions", "2026", "04", "30", "rollout-2026-04-30T10-00-00-thread-a.jsonl"); + var unexpectedBackupPath = Path.Combine(codexRoot, "rollout-backups", "sessions", "2026", "04", "30", "rollout-2026-04-30T10-00-01-thread-b.jsonl"); + const string originalMatchingContent = + """ + {"type":"session_meta","payload":{"id":"thread-a","cwd":"D:\\repo","model_provider":"old-provider"}} + {"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}} + """; + + Directory.CreateDirectory(sessionsRoot); + await File.WriteAllTextAsync(matchingPath, originalMatchingContent); + await File.WriteAllTextAsync( + falseCandidatePath, + """ + {"type":"session_meta","payload":{"id":"thread-b","cwd":"D:\\repo","model_provider":"other-provider"}} + {"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ignore"}]}} + """); + CreateStateDatabase(Path.Combine(workspacePath, ".codex", "state_5.sqlite"), "thread-a", "old-provider"); + + try + { + var service = new CodexThreadProviderSyncService(NullLogger.Instance); + + await service.SyncThreadProviderAsync(new CodexThreadProviderSyncRequest + { + SessionWorkspacePath = workspacePath, + ThreadId = "thread-a", + TargetProviderId = "new-provider" + }); + + Assert.True(File.Exists(expectedBackupPath)); + Assert.Equal( + originalMatchingContent.ReplaceLineEndings("\n"), + (await File.ReadAllTextAsync(expectedBackupPath)).ReplaceLineEndings("\n")); + Assert.False(File.Exists(unexpectedBackupPath)); + Assert.Equal("new-provider", ReadProviderIdFromFirstLine(matchingPath)); + } + finally + { + if (Directory.Exists(workspaceRoot)) + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + } + [Fact] public async Task SyncThreadProviderAsync_SeedsLocalSnapshotFromSourceWhenLocalThreadIsMissing() { diff --git a/WebCodeCli.Domain.Tests/ExternalCliHistoryTextBuilderTests.cs b/WebCodeCli.Domain.Tests/ExternalCliHistoryTextBuilderTests.cs new file mode 100644 index 0000000..c1c6a02 --- /dev/null +++ b/WebCodeCli.Domain.Tests/ExternalCliHistoryTextBuilderTests.cs @@ -0,0 +1,62 @@ +using WebCodeCli.Domain.Domain.Model; +using WebCodeCli.Domain.Domain.Service; + +namespace WebCodeCli.Domain.Tests; + +public class ExternalCliHistoryTextBuilderTests +{ + [Fact] + public void Build_IncludesCliThreadIdSourcePathAndMessages() + { + var output = ExternalCliHistoryTextBuilder.Build( + "当前 CLI 会话历史 abc12345", + [ + new ExternalCliHistoryMessage + { + Role = "user", + Content = " 第一条\r\n第二行 ", + CreatedAt = new DateTime(2026, 5, 4, 9, 30, 0) + }, + new ExternalCliHistoryMessage + { + Role = "assistant", + Content = "回复内容", + CreatedAt = new DateTime(2026, 5, 4, 9, 31, 0) + } + ], + "Codex", + @"D:\repo", + "codex-thread-1", + @"D:\repo\.codex\sessions\2026\05\04\rollout-1.jsonl"); + + Assert.Contains("当前 CLI 会话历史 abc12345", output); + Assert.True( + output.IndexOf("当前 CLI 会话历史 abc12345", StringComparison.Ordinal) < + output.IndexOf(@"历史来源: D:\repo\.codex\sessions\2026\05\04\rollout-1.jsonl", StringComparison.Ordinal)); + Assert.Contains("CLI 工具: Codex", output); + Assert.Contains(@"工作目录: D:\repo", output); + Assert.Contains("原生 Thread ID: codex-thread-1", output); + Assert.Contains(@"历史来源: D:\repo\.codex\sessions\2026\05\04\rollout-1.jsonl", output); + Assert.Contains("显示条数: 最近 2 条", output); + Assert.Contains("[用户] 09:30", output); + Assert.Contains("第一条\n第二行", output); + Assert.Contains("[助手] 09:31", output); + Assert.Contains("回复内容", output); + } + + [Fact] + public void Build_ShowsFallbacks_WhenThreadIdAndSourcePathAreMissing() + { + var output = ExternalCliHistoryTextBuilder.Build( + "当前 CLI 会话历史", + [], + "Codex", + null, + null, + null); + + Assert.Contains("原生 Thread ID: 未绑定", output); + Assert.Contains("历史来源: 未定位", output); + Assert.Contains("该 CLI 会话暂无可解析的历史消息。", output); + } +} diff --git a/WebCodeCli.Domain.Tests/ExternalCliSessionHistoryServiceTests.cs b/WebCodeCli.Domain.Tests/ExternalCliSessionHistoryServiceTests.cs index de3104a..55fbf10 100644 --- a/WebCodeCli.Domain.Tests/ExternalCliSessionHistoryServiceTests.cs +++ b/WebCodeCli.Domain.Tests/ExternalCliSessionHistoryServiceTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using WebCodeCli.Domain.Domain.Service; @@ -98,10 +99,10 @@ public async Task GetRecentMessagesAsync_ForCodex_PrefersWorkspaceScopedRolloutO var service = new TestExternalCliSessionHistoryService( codexSessionsRootPath: Path.GetDirectoryName(globalRolloutPath)!); - var messages = await service.GetRecentMessagesAsync("codex", threadId, workspacePath: workspacePath); + var history = await service.GetRecentHistoryAsync("codex", threadId, workspacePath: workspacePath); Assert.Collection( - messages, + history.Messages, message => { Assert.Equal("user", message.Role); @@ -112,6 +113,46 @@ public async Task GetRecentMessagesAsync_ForCodex_PrefersWorkspaceScopedRolloutO Assert.Equal("assistant", message.Role); Assert.Equal("workspace assistant", message.Content); }); + Assert.Contains($@"workspace\.codex\sessions\2026\04\28\rollout-2026-04-28T09-12-00-{threadId}.jsonl", history.SourcePath ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GetRecentMessagesAsync_ForCodex_LogsResolvedWorkspaceRolloutDiagnostics() + { + using var sandbox = new HistoryTestSandbox(); + const string threadId = "codex-thread-diagnostic"; + var globalRolloutPath = sandbox.WriteFile( + Path.Combine("global-codex", "sessions", "2026", "04", "23", $"rollout-2026-04-23T17-19-27-{threadId}.jsonl"), + """ + {"timestamp":"2026-03-23T01:00:00Z","type":"session_meta","payload":{"id":"codex-thread-diagnostic","cwd":"D:\\legacy","model_provider":"global-provider"}} + {"timestamp":"2026-03-23T01:00:01Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"global assistant"}]}} + """); + var workspacePath = sandbox.CreateDirectory("workspace"); + var workspaceRolloutPath = sandbox.WriteFile( + Path.Combine("workspace", ".codex", "sessions", "2026", "04", "28", $"rollout-2026-04-28T09-12-00-{threadId}.jsonl"), + """ + {"timestamp":"2026-04-28T09:12:00Z","type":"session_meta","payload":{"id":"codex-thread-diagnostic","cwd":"D:\\workspace","model_provider":"project-provider"}} + {"timestamp":"2026-04-28T09:12:01Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"workspace assistant"}]}} + """); + var logger = new RecordingLogger(); + var service = new TestExternalCliSessionHistoryService( + codexSessionsRootPath: Path.GetDirectoryName(globalRolloutPath)!, + logger: logger); + + var messages = await service.GetRecentMessagesAsync("codex", threadId, workspacePath: workspacePath); + + Assert.Collection( + messages, + message => Assert.Equal("workspace assistant", message.Content)); + + var logText = string.Join("\n", logger.Entries.Select(entry => entry.Message)); + Assert.Contains("[CodexHistory] Start resolving rollout", logText); + Assert.Contains("[CodexHistory] Rollout resolved", logText); + Assert.Contains("Scope=workspace", logText); + Assert.Contains("MatchKind=filename", logText); + Assert.Contains(workspaceRolloutPath, logText); + Assert.Contains("FirstLineThreadId=codex-thread-diagnostic", logText); + Assert.Contains("FirstLineModelProvider=project-provider", logText); } [Fact] @@ -328,8 +369,9 @@ private sealed class TestExternalCliSessionHistoryService : ExternalCliSessionHi public TestExternalCliSessionHistoryService( string? codexSessionsRootPath = null, string? claudeProjectsRootPath = null, - Func>? processHandler = null) - : base(NullLogger.Instance) + Func>? processHandler = null, + ILogger? logger = null) + : base(logger ?? NullLogger.Instance) { _codexConfigRootPath = codexSessionsRootPath; _claudeProjectsRootPath = claudeProjectsRootPath; @@ -355,6 +397,36 @@ public TestExternalCliSessionHistoryService( } } + private sealed class RecordingLogger : ILogger + { + public List Entries { get; } = []; + + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception)); + } + } + + private sealed record LogEntry(LogLevel Level, string Message, Exception? Exception); + + private sealed class NullScope : IDisposable + { + public static NullScope Instance { get; } = new(); + + public void Dispose() + { + } + } + private sealed class HistoryTestSandbox : IDisposable { public HistoryTestSandbox() diff --git a/WebCodeCli.Domain.Tests/FeishuAudioMessageServiceTests.cs b/WebCodeCli.Domain.Tests/FeishuAudioMessageServiceTests.cs new file mode 100644 index 0000000..bb85f25 --- /dev/null +++ b/WebCodeCli.Domain.Tests/FeishuAudioMessageServiceTests.cs @@ -0,0 +1,195 @@ +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Service; +using WebCodeCli.Domain.Domain.Model.Channels; +using WebCodeCli.Domain.Domain.Service.Channels; +using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; +using FeishuNetSdk.Im.Dtos; + +namespace WebCodeCli.Domain.Tests; + +public sealed class FeishuAudioMessageServiceTests +{ + [Fact] + public async Task SendAudioMessageAsync_UsesUsernameOptionsAndSendsInOrder() + { + var cardKit = new StubFeishuCardKitClient(); + var configService = new StubUserFeishuBotConfigService + { + UsernameOptions = new FeishuOptions + { + AppId = "user-app", + AppSecret = "user-secret" + } + }; + var service = new FeishuAudioMessageService(cardKit, configService); + + var messageId = await service.SendAudioMessageAsync( + "oc_chat", + @"D:\audio\chunk-001.opus", + 3200, + username: "alice", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("om_audio_success", messageId); + Assert.Equal(["upload", "send"], cardKit.CallOrder); + Assert.Equal("user-app", cardKit.LastUploadOptionsOverride?.AppId); + Assert.Equal("user-app", cardKit.LastSendOptionsOverride?.AppId); + } + + [Fact] + public async Task SendAudioMessageAsync_FallsBackToAppIdLookup_WhenUsernameIsMissing() + { + var cardKit = new StubFeishuCardKitClient(); + var configService = new StubUserFeishuBotConfigService + { + AppOptions = new FeishuOptions + { + AppId = "resolved-app", + AppSecret = "resolved-secret" + } + }; + var service = new FeishuAudioMessageService(cardKit, configService); + + await service.SendAudioMessageAsync( + "oc_chat", + @"D:\audio\chunk-001.opus", + 3200, + appId: "cli_app", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("resolved-app", cardKit.LastUploadOptionsOverride?.AppId); + Assert.Equal("resolved-app", cardKit.LastSendOptionsOverride?.AppId); + } + + [Fact] + public async Task SendAudioMessageAsync_PrefersAppIdOptions_WhenBothAppIdAndUsernameAreProvided() + { + var cardKit = new StubFeishuCardKitClient(); + var configService = new StubUserFeishuBotConfigService + { + UsernameOptions = new FeishuOptions + { + AppId = "user-app", + AppSecret = "user-secret" + }, + AppOptions = new FeishuOptions + { + AppId = "resolved-app", + AppSecret = "resolved-secret" + } + }; + var service = new FeishuAudioMessageService(cardKit, configService); + + await service.SendAudioMessageAsync( + "oc_chat", + @"D:\audio\chunk-001.opus", + 3200, + username: "alice", + appId: "cli_app", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("resolved-app", cardKit.LastUploadOptionsOverride?.AppId); + Assert.Equal("resolved-app", cardKit.LastSendOptionsOverride?.AppId); + } + + [Fact] + public async Task SendAudioMessageAsync_UsesSharedDefaults_WhenNoUsernameOrAppIdIsProvided() + { + var cardKit = new StubFeishuCardKitClient(); + var configService = new StubUserFeishuBotConfigService(); + var service = new FeishuAudioMessageService(cardKit, configService); + + await service.SendAudioMessageAsync( + "oc_chat", + @"D:\audio\chunk-001.opus", + 3200, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("shared-app", cardKit.LastUploadOptionsOverride?.AppId); + Assert.Equal("shared-app", cardKit.LastSendOptionsOverride?.AppId); + } + + private sealed class StubFeishuCardKitClient : IFeishuCardKitClient + { + public List CallOrder { get; } = []; + + public FeishuOptions? LastUploadOptionsOverride { get; private set; } + + public FeishuOptions? LastSendOptionsOverride { get; private set; } + + public Task UploadAudioFileAsync(string filePath, int durationMs, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + CallOrder.Add("upload"); + LastUploadOptionsOverride = optionsOverride; + return Task.FromResult("file_v2_123"); + } + + public Task SendAudioMessageAsync(string chatId, string fileKey, int durationMs, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + CallOrder.Add("send"); + LastSendOptionsOverride = optionsOverride; + return Task.FromResult("om_audio_success"); + } + + public Task CreateCardAsync(string initialContent, string? title = null, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); + + public Task UpdateCardAsync(string cardId, string content, int sequence, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); + + public Task SendCardMessageAsync(string chatId, string cardId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); + + public Task SendTextMessageAsync(string chatId, string content, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); + + public Task ReplyCardMessageAsync(string replyMessageId, string cardId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); + + public Task ReplyTextMessageAsync(string replyMessageId, string content, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); + + public Task CreateStreamingHandleAsync(string chatId, string? replyMessageId, string initialContent, string? title = null, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null, FeishuStreamingCardChrome? chrome = null) + => throw new NotSupportedException(); + + public Task SendRawCardAsync(string chatId, string cardJson, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); + + public Task ReplyElementsCardAsync(string replyMessageId, ElementsCardV2Dto card, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); + + public Task ReplyRawCardAsync(string replyMessageId, string cardJson, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); + } + + private sealed class StubUserFeishuBotConfigService : IUserFeishuBotConfigService + { + public FeishuOptions UsernameOptions { get; set; } = new(); + + public FeishuOptions? AppOptions { get; set; } + + public Task GetByUsernameAsync(string username) => Task.FromResult(null); + + public Task GetByAppIdAsync(string appId) => Task.FromResult(null); + + public Task SaveAsync(UserFeishuBotConfigEntity config) => Task.FromResult(UserFeishuBotConfigSaveResult.Saved()); + + public Task DeleteAsync(string username) => Task.FromResult(true); + + 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() + { + AppId = "shared-app", + AppSecret = "shared-secret" + }; + + public Task GetEffectiveOptionsAsync(string? username) => Task.FromResult(UsernameOptions); + + public Task GetEffectiveOptionsByAppIdAsync(string? appId) => Task.FromResult(AppOptions); + } +} diff --git a/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs b/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs index c10933d..d8f218f 100644 --- a/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs +++ b/WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs @@ -38,6 +38,58 @@ await service.HandleCardActionAsync( Assert.Equal(activeSessionId, usedSessionId); } + [Fact] + public async Task HandleCardActionAsync_ToggleReplyTts_DisablesReplyTtsAndRefreshesHelpCard() + { + var cliExecutor = new RecordingCliExecutorService(); + var feishuChannel = new StubFeishuChannelService(null) + { + SessionUsername = "luhaiyan" + }; + var feishuBotConfigService = new StubUserFeishuBotConfigService(); + feishuBotConfigService.Seed(new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + IsEnabled = true, + ReplyTtsEnabled = true, + ReplyTtsVoiceId = "voice-a" + }); + + var serviceProvider = new TestServiceProvider(feishuBotConfigService: feishuBotConfigService); + var service = CreateService(cliExecutor, feishuChannel, serviceProvider); + + var response = await service.HandleCardActionAsync( + """{"action":"toggle_reply_tts"}""", + chatId: "oc_tts_toggle_chat"); + + var savedConfig = await feishuBotConfigService.GetByUsernameAsync("luhaiyan"); + Assert.NotNull(savedConfig); + Assert.False(savedConfig!.ReplyTtsEnabled); + Assert.Equal("voice-a", savedConfig.ReplyTtsVoiceId); + Assert.Equal("✅ 已关闭飞书语音回复", ExtractToastContent(response)); + Assert.Contains("语音回复:关", ExtractCardContentStrings(response)); + } + + [Fact] + public async Task HandleCardActionAsync_ToggleReplyTts_ReturnsErrorToast_WhenUserConfigMissing() + { + var cliExecutor = new RecordingCliExecutorService(); + var feishuChannel = new StubFeishuChannelService(null) + { + SessionUsername = "missing-user" + }; + var feishuBotConfigService = new StubUserFeishuBotConfigService(); + var serviceProvider = new TestServiceProvider(feishuBotConfigService: feishuBotConfigService); + var service = CreateService(cliExecutor, feishuChannel, serviceProvider); + + var response = await service.HandleCardActionAsync( + """{"action":"toggle_reply_tts"}""", + chatId: "oc_tts_toggle_chat"); + + Assert.Equal("❌ 未找到当前飞书用户配置", ExtractToastContent(response)); + Assert.DoesNotContain("语音回复:", SerializeResponse(response)); + } + [Fact] public async Task HandleCardActionAsync_ExecuteCommand_ForwardsRawPromptWithoutReplyPrefixInstructions() { @@ -355,10 +407,25 @@ await service.HandleCardActionAsync( Assert.Contains($"\"session_id\":\"{activeSessionId}\"", quickInputValueJson); Assert.Contains($"\"chat_key\":\"{chatId}\"", quickInputValueJson); - Assert.Equal(2, chrome.BottomActions.Count); + var goalPrompt = Assert.Single(chrome.AdditionalBottomPrompts); + Assert.Equal(GoalQuickActionDefaults.QuickInputFieldName, goalPrompt.InputName); + Assert.Equal(GoalQuickActionDefaults.InstructionText, goalPrompt.InputLabel); + Assert.Equal(GoalQuickActionDefaults.QuickInputPlaceholder, goalPrompt.Placeholder); + + var goalInputValueJson = JsonSerializer.Serialize(goalPrompt.Value); + Assert.Contains($"\"action\":\"{FeishuHelpCardAction.SubmitGoalQuickInputAction}\"", goalInputValueJson); + Assert.Contains($"\"session_id\":\"{activeSessionId}\"", goalInputValueJson); + Assert.Contains($"\"chat_key\":\"{chatId}\"", goalInputValueJson); + + Assert.Equal(3, chrome.BottomActions.Count); + Assert.Contains(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ContinueButtonText); Assert.Contains(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecutePlanButtonText); Assert.Contains(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecuteSubagentPlanButtonText); + var continueValueJson = JsonSerializer.Serialize( + Assert.Single(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ContinueButtonText).Value); + Assert.Contains($"\"action\":\"{FeishuHelpCardAction.ContinueSuperpowersAction}\"", continueValueJson); + var executePlanValueJson = JsonSerializer.Serialize( Assert.Single(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecutePlanButtonText).Value); Assert.Contains($"\"action\":\"{FeishuHelpCardAction.ExecuteSuperpowersPlanAction}\"", executePlanValueJson); @@ -374,7 +441,7 @@ await service.HandleCardActionAsync( } [Fact] - public async Task HandleCardActionAsync_ExecuteCommand_AttachesQuickInputButHidesPlanActions_WhenPlanFilesMissing() + public async Task HandleCardActionAsync_ExecuteCommand_AttachesQuickInputAndKeepsContinueAction_WhenPlanFilesMissing() { const string chatId = "oc_current_chat"; const string activeSessionId = "session-superpowers-no-plan"; @@ -413,11 +480,12 @@ await service.HandleCardActionAsync( var chrome = cardKit.LastStreamingChrome!; Assert.NotNull(chrome.BottomPrompt); Assert.Equal(SuperpowersQuickActionDefaults.QuickInputFieldName, chrome.BottomPrompt!.InputName); - Assert.Empty(chrome.BottomActions); + var continueAction = Assert.Single(chrome.BottomActions); + Assert.Equal(SuperpowersQuickActionDefaults.ContinueButtonText, continueAction.Text); } [Fact] - public async Task HandleCardActionAsync_ExecuteCommand_AttachesQuickInputButHidesPlanActions_WhenSessionHistoryLacksSuperpowers() + public async Task HandleCardActionAsync_ExecuteCommand_AttachesQuickInputAndKeepsContinueAction_WhenSessionHistoryLacksSuperpowers() { const string chatId = "oc_current_chat"; const string activeSessionId = "session-superpowers-no-history"; @@ -453,7 +521,8 @@ await service.HandleCardActionAsync( var chrome = cardKit.LastStreamingChrome!; Assert.NotNull(chrome.BottomPrompt); Assert.Equal(SuperpowersQuickActionDefaults.QuickInputFieldName, chrome.BottomPrompt!.InputName); - Assert.Empty(chrome.BottomActions); + var continueAction = Assert.Single(chrome.BottomActions); + Assert.Equal(SuperpowersQuickActionDefaults.ContinueButtonText, continueAction.Text); } finally { @@ -495,6 +564,36 @@ public async Task HandleCardActionAsync_SubmitSuperpowersQuickInput_AutoPrefixes Assert.Empty(cliExecutor.LowInterruptionSessionIds); } + [Fact] + public async Task HandleCardActionAsync_SubmitSuperpowersQuickInput_UsesInputValuesWhenFormValueIsMissing() + { + const string chatId = "oc_current_chat"; + const string activeSessionId = "session-superpowers-quick-input-from-input"; + + var cliExecutor = new RecordingCliExecutorService + { + StandardExecutionContent = "plan completed" + }; + cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\superpowers"); + + var cardKit = new StubFeishuCardKitClient(); + var feishuChannel = new StubFeishuChannelService(activeSessionId); + var service = CreateService(cliExecutor, feishuChannel, new TestServiceProvider(), cardKit); + + var response = await service.HandleCardActionAsync( + $$"""{"action":"{{FeishuHelpCardAction.SubmitSuperpowersQuickInputAction}}","chat_key":"{{chatId}}"}""", + chatId: chatId, + inputValues: "写一个执行步骤"); + + Assert.Equal(CardActionTriggerResponseDto.ToastSuffix.ToastType.Info, response.Toast?.Type); + await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); + await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); + + Assert.Equal( + SuperpowersPromptBuilder.BuildQuickSkillPrompt("写一个执行步骤"), + Assert.Single(cliExecutor.ExecutedPrompts)); + } + [Fact] public async Task HandleCardActionAsync_SubmitSuperpowersQuickInput_DoesNotDoublePrefix() { @@ -516,15 +615,180 @@ await service.HandleCardActionAsync( chatId: chatId, formValue: new Dictionary { - [SuperpowersQuickActionDefaults.QuickInputFieldName] = "使用superpowers技能,写一个执行步骤" + [SuperpowersQuickActionDefaults.QuickInputFieldName] = "$superpowers ,使用superpowers技能,写一个执行步骤" }); await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); await feishuChannel.WaitForMessageAsync(TimeSpan.FromSeconds(3)); Assert.Equal( - SuperpowersPromptBuilder.BuildQuickSkillPrompt("使用superpowers技能,写一个执行步骤"), + SuperpowersPromptBuilder.BuildQuickSkillPrompt("$superpowers ,使用superpowers技能,写一个执行步骤"), + Assert.Single(cliExecutor.ExecutedPrompts)); + } + + [Fact] + public async Task HandleCardActionAsync_SubmitGoalQuickInput_AutoPrefixesPromptAndUsesStandardExecutionPath() + { + const string chatId = "oc_current_chat"; + const string activeSessionId = "session-goal-quick-input"; + + var cliExecutor = new RecordingCliExecutorService + { + StandardExecutionContent = "goal completed" + }; + cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\superpowers"); + + var capabilityService = new StubGoalCapabilityService + { + ProbeState = GoalCapabilityState.Available, + ProbeOutcome = GoalCapabilityProbeOutcome.Available + }; + + var feishuChannel = new StubFeishuChannelService(activeSessionId) + { + ResolvedToolId = "codex" + }; + var service = CreateService( + cliExecutor, + feishuChannel, + new TestServiceProvider(goalCapabilityService: capabilityService)); + + var response = await service.HandleCardActionAsync( + $$"""{"action":"{{FeishuHelpCardAction.SubmitGoalQuickInputAction}}","chat_key":"{{chatId}}"}""", + chatId: chatId, + formValue: new Dictionary + { + [GoalQuickActionDefaults.QuickInputFieldName] = "整理这个目标" + }); + + Assert.Equal(CardActionTriggerResponseDto.ToastSuffix.ToastType.Info, response.Toast?.Type); + await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); + + Assert.Equal( + GoalPromptBuilder.BuildGoalPrompt("整理这个目标"), + Assert.Single(cliExecutor.ExecutedPrompts)); + + var probeContext = Assert.Single(capabilityService.ProbeContexts); + Assert.Equal("codex", probeContext.ToolId); + } + + [Fact] + public async Task HandleCardActionAsync_SubmitGoalQuickInput_UsesInputValuesWhenFormValueIsMissing() + { + const string chatId = "oc_current_chat"; + const string activeSessionId = "session-goal-quick-input-from-input"; + + var cliExecutor = new RecordingCliExecutorService + { + StandardExecutionContent = "goal completed" + }; + cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\superpowers"); + + var capabilityService = new StubGoalCapabilityService + { + ProbeState = GoalCapabilityState.Available, + ProbeOutcome = GoalCapabilityProbeOutcome.Available + }; + + var feishuChannel = new StubFeishuChannelService(activeSessionId) + { + ResolvedToolId = "codex" + }; + var service = CreateService( + cliExecutor, + feishuChannel, + new TestServiceProvider(goalCapabilityService: capabilityService)); + + var response = await service.HandleCardActionAsync( + $$"""{"action":"{{FeishuHelpCardAction.SubmitGoalQuickInputAction}}","chat_key":"{{chatId}}"}""", + chatId: chatId, + inputValues: "整理这个目标"); + + Assert.Equal(CardActionTriggerResponseDto.ToastSuffix.ToastType.Info, response.Toast?.Type); + await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); + + Assert.Equal( + GoalPromptBuilder.BuildGoalPrompt("整理这个目标"), + Assert.Single(cliExecutor.ExecutedPrompts)); + } + + [Fact] + public async Task HandleCardActionAsync_SubmitGoalQuickInput_DoesNotDoublePrefix() + { + const string chatId = "oc_current_chat"; + const string activeSessionId = "session-goal-quick-input-prefixed"; + + var cliExecutor = new RecordingCliExecutorService + { + StandardExecutionContent = "goal completed" + }; + cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\superpowers"); + + var capabilityService = new StubGoalCapabilityService + { + ProbeState = GoalCapabilityState.Available, + ProbeOutcome = GoalCapabilityProbeOutcome.Available + }; + + var feishuChannel = new StubFeishuChannelService(activeSessionId) + { + ResolvedToolId = "codex" + }; + var service = CreateService( + cliExecutor, + feishuChannel, + new TestServiceProvider(goalCapabilityService: capabilityService)); + + await service.HandleCardActionAsync( + $$"""{"action":"{{FeishuHelpCardAction.SubmitGoalQuickInputAction}}","chat_key":"{{chatId}}"}""", + chatId: chatId, + formValue: new Dictionary + { + [GoalQuickActionDefaults.QuickInputFieldName] = "/goal 整理这个目标" + }); + + await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); + + Assert.Equal( + GoalPromptBuilder.BuildGoalPrompt("/goal 整理这个目标"), + Assert.Single(cliExecutor.ExecutedPrompts)); + } + + [Fact] + public async Task HandleCardActionAsync_ContinueSuperpowers_UsesFixedPromptAndSkipsCapabilityProbe() + { + const string chatId = "oc_current_chat"; + const string activeSessionId = "session-superpowers-continue"; + + var cliExecutor = new RecordingCliExecutorService + { + StandardExecutionContent = "continued" + }; + cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\superpowers"); + + var capabilityService = new StubSuperpowersCapabilityService + { + ProbeState = SuperpowersCapabilityState.Unavailable, + ProbeOutcome = SuperpowersCapabilityProbeOutcome.MissingCapability, + ProbeMessage = "missing" + }; + + var feishuChannel = new StubFeishuChannelService(activeSessionId); + var service = CreateService( + cliExecutor, + feishuChannel, + new TestServiceProvider(superpowersCapabilityService: capabilityService)); + + await service.HandleCardActionAsync( + $$"""{"action":"{{FeishuHelpCardAction.ContinueSuperpowersAction}}","chat_key":"{{chatId}}"}""", + chatId: chatId); + + await cliExecutor.WaitForExecutionAsync(TimeSpan.FromSeconds(3)); + + Assert.Equal( + SuperpowersPromptBuilder.BuildContinuePrompt(), Assert.Single(cliExecutor.ExecutedPrompts)); + Assert.Empty(capabilityService.ProbeContexts); } [Fact] @@ -694,6 +958,47 @@ public async Task HandleCardActionAsync_SuperpowersQuickAction_WhenCapabilityMis Assert.Empty(cliExecutor.ExecutedPrompts); } + [Fact] + public async Task HandleCardActionAsync_GoalQuickAction_WhenCapabilityMissing_ReturnsWarningWithoutExecuting() + { + const string chatId = "oc_current_chat"; + const string activeSessionId = "session-goal-missing-capability"; + + var cliExecutor = new RecordingCliExecutorService + { + StandardExecutionContent = "goal completed" + }; + cliExecutor.SetSessionWorkspacePath(activeSessionId, @"D:\repo\superpowers"); + + var capabilityService = new StubGoalCapabilityService + { + ProbeState = GoalCapabilityState.Unavailable, + ProbeOutcome = GoalCapabilityProbeOutcome.MissingFeature, + ProbeMessage = GoalQuickActionDefaults.CapabilityUnavailableText + }; + + var feishuChannel = new StubFeishuChannelService(activeSessionId) + { + ResolvedToolId = "codex" + }; + var service = CreateService( + cliExecutor, + feishuChannel, + new TestServiceProvider(goalCapabilityService: capabilityService)); + + var response = await service.HandleCardActionAsync( + $$"""{"action":"{{FeishuHelpCardAction.SubmitGoalQuickInputAction}}","chat_key":"{{chatId}}"}""", + chatId: chatId, + formValue: new Dictionary + { + [GoalQuickActionDefaults.QuickInputFieldName] = "整理这个目标" + }); + + Assert.Equal(CardActionTriggerResponseDto.ToastSuffix.ToastType.Warning, response.Toast?.Type); + Assert.Contains(GoalQuickActionDefaults.CapabilityUnavailableText, response.Toast?.Content, StringComparison.Ordinal); + Assert.Empty(cliExecutor.ExecutedPrompts); + } + [Fact] public async Task HandleCardActionAsync_RetrySuperpowersCapabilityDetection_WhenCapabilityAvailable_ReturnsSuccessToast() { @@ -750,7 +1055,8 @@ public async Task HandleCardActionAsync_LowInterruptionContinue_StartsNewStreami Assert.Equal([sessionId], cliExecutor.LowInterruptionSessionIds); Assert.NotNull(cardKit.LastStreamingChrome); Assert.NotNull(cardKit.LastStreamingChrome!.BottomPrompt); - Assert.Empty(cardKit.LastStreamingChrome.BottomActions); + var continueAction = Assert.Single(cardKit.LastStreamingChrome.BottomActions); + Assert.Equal(SuperpowersQuickActionDefaults.ContinueButtonText, continueAction.Text); } [Fact] @@ -788,6 +1094,156 @@ public async Task HandleCardActionAsync_LowInterruptionContinue_PassesPromptFrom Assert.Equal(["finish the remaining plan items"], cliExecutor.LowInterruptionPrompts); } + [Fact] + public async Task HandleCardActionAsync_ExecuteCommand_QueuesReplyTtsAfterSuccessfulCompletion() + { + const string chatId = "oc_reply_tts_card_chat"; + const string sessionId = "session-reply-tts-card"; + const string appId = "cli_reply_tts_bot"; + + var cliExecutor = new RecordingCliExecutorService + { + StandardExecutionContent = "card action completed" + }; + cliExecutor.SetSessionWorkspacePath(sessionId, @"D:\repo\superpowers"); + + var chatSessionService = new StubChatSessionService(); + var replyTtsOrchestrator = new RecordingReplyTtsOrchestrator(); + replyTtsOrchestrator.OnQueued = request => + { + Assert.Contains( + chatSessionService.Messages[sessionId], + message => message.Role == "assistant" && message.Content == "card action completed" && message.IsCompleted); + Assert.Equal("card action completed", request.Output); + return Task.CompletedTask; + }; + + var service = CreateService( + cliExecutor, + new StubFeishuChannelService(sessionId), + new TestServiceProvider(replyTtsOrchestrator: replyTtsOrchestrator), + chatSessionService: chatSessionService); + + await service.HandleCardActionAsync( + """{"action":"execute_command"}""", + chatId: chatId, + inputValues: "continue", + appId: appId); + + var queued = await replyTtsOrchestrator.WhenQueued.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await replyTtsOrchestrator.WhenCallbackCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(chatId, queued.ChatId); + Assert.Equal("luhaiyan", queued.Username); + Assert.Equal(appId, queued.AppId); + Assert.Equal(sessionId, queued.SessionId); + Assert.Equal("card action completed", queued.Output); + } + + [Fact] + public async Task HandleCardActionAsync_LowInterruptionContinue_QueuesReplyTtsAfterSuccessfulCompletion() + { + const string chatId = "oc_reply_tts_low_interruption_chat"; + const string sessionId = "session-reply-tts-low-interruption"; + const string appId = "cli_reply_tts_bot"; + + var cliExecutor = new RecordingCliExecutorService + { + SupportsLowInterruption = true, + LowInterruptionExecutionContent = "low interruption completed" + }; + cliExecutor.SetCliThreadId(sessionId, "thread-reply-tts-low-interruption"); + cliExecutor.SetSessionWorkspacePath(sessionId, @"D:\repo\superpowers"); + + var chatSessionService = new StubChatSessionService(); + var replyTtsOrchestrator = new RecordingReplyTtsOrchestrator(); + replyTtsOrchestrator.OnQueued = request => + { + Assert.Contains( + chatSessionService.Messages[sessionId], + message => message.Role == "assistant" && message.Content == "low interruption completed" && message.IsCompleted); + Assert.Equal("low interruption completed", request.Output); + return Task.CompletedTask; + }; + + var service = CreateService( + cliExecutor, + new StubFeishuChannelService(sessionId), + new TestServiceProvider(replyTtsOrchestrator: replyTtsOrchestrator), + chatSessionService: chatSessionService); + + await service.HandleCardActionAsync( + """{"action":"low_interruption_continue","session_id":"session-reply-tts-low-interruption","chat_key":"oc_reply_tts_low_interruption_chat","tool_id":"codex"}""", + chatId: chatId, + appId: appId); + + var queued = await replyTtsOrchestrator.WhenQueued.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await replyTtsOrchestrator.WhenCallbackCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(chatId, queued.ChatId); + Assert.Equal("luhaiyan", queued.Username); + Assert.Equal(appId, queued.AppId); + Assert.Equal(sessionId, queued.SessionId); + Assert.Equal("low interruption completed", queued.Output); + } + + [Fact] + public async Task HandleCardActionAsync_ExecuteCommand_DoesNotQueueReplyTtsWhenExecutionErrors() + { + const string sessionId = "session-reply-tts-error"; + + var cliExecutor = new RecordingCliExecutorService + { + StandardExecutionIsError = true, + StandardExecutionErrorMessage = "execution failed" + }; + cliExecutor.SetSessionWorkspacePath(sessionId, @"D:\repo\superpowers"); + + var replyTtsOrchestrator = new RecordingReplyTtsOrchestrator(); + var service = CreateService( + cliExecutor, + new StubFeishuChannelService(sessionId), + new TestServiceProvider(replyTtsOrchestrator: replyTtsOrchestrator)); + + await service.HandleCardActionAsync( + """{"action":"execute_command"}""", + chatId: "oc_reply_tts_error_chat", + inputValues: "continue"); + + await cliExecutor.WaitForExecutionCompletionAsync(TimeSpan.FromSeconds(5)); + + Assert.Empty(replyTtsOrchestrator.Requests); + } + + [Fact] + public async Task HandleCardActionAsync_LowInterruptionContinue_DoesNotQueueReplyTtsWhenExecutionErrors() + { + const string sessionId = "session-reply-tts-low-interruption-error"; + + var cliExecutor = new RecordingCliExecutorService + { + SupportsLowInterruption = true, + LowInterruptionExecutionIsError = true, + LowInterruptionExecutionErrorMessage = "low interruption failed" + }; + cliExecutor.SetCliThreadId(sessionId, "thread-reply-tts-low-interruption-error"); + cliExecutor.SetSessionWorkspacePath(sessionId, @"D:\repo\superpowers"); + + var replyTtsOrchestrator = new RecordingReplyTtsOrchestrator(); + var service = CreateService( + cliExecutor, + new StubFeishuChannelService(sessionId), + new TestServiceProvider(replyTtsOrchestrator: replyTtsOrchestrator)); + + await service.HandleCardActionAsync( + """{"action":"low_interruption_continue","session_id":"session-reply-tts-low-interruption-error","chat_key":"oc_reply_tts_low_interruption_error_chat","tool_id":"codex"}""", + chatId: "oc_reply_tts_low_interruption_error_chat"); + + await cliExecutor.WaitForLowInterruptionExecutionCompletionAsync(TimeSpan.FromSeconds(5)); + + Assert.Empty(replyTtsOrchestrator.Requests); + } + [Fact] public async Task HandleCardActionAsync_LowInterruptionContinue_WhenThreadMissing_ReturnsWarning() { @@ -1687,6 +2143,8 @@ public async Task HandleCardActionAsync_ExecuteCommand_HistoryCommand_SendsExter Assert.Equal("codex", historyService.LastToolId); Assert.Equal("codex-thread-1", historyService.LastCliThreadId); Assert.Equal(50, historyService.LastMaxCount); + Assert.Contains("原生 Thread ID: codex-thread-1", sent.Content); + Assert.Contains(@"历史来源: D:\repo\.codex\sessions\2026\05\04\rollout-history.jsonl", sent.Content); Assert.Contains("浣犲ソ", sent.Content); Assert.Contains("涓栫晫", sent.Content); } @@ -2679,6 +3137,16 @@ private static List ExtractCardContentStrings(CardActionTriggerResponseD return contents; } + private static string? ExtractToastContent(CardActionTriggerResponseDto response) + { + using var document = JsonDocument.Parse(SerializeResponse(response)); + return document.RootElement.TryGetProperty("toast", out var toastElement) + && toastElement.TryGetProperty("content", out var contentElement) + && contentElement.ValueKind == JsonValueKind.String + ? contentElement.GetString() + : null; + } + private static void CollectContentStrings(JsonElement element, List contents) { switch (element.ValueKind) @@ -2733,7 +3201,9 @@ private static FeishuCardActionService CreateService( private sealed class RecordingCliExecutorService : ICliExecutorService { private readonly TaskCompletionSource _executionStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _executionCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _lowInterruptionExecutionStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _lowInterruptionExecutionCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly Dictionary _tools = new(StringComparer.OrdinalIgnoreCase) { ["claude-code"] = new CliToolConfig { Id = "claude-code", Name = "Claude Code" }, @@ -2752,8 +3222,16 @@ private sealed class RecordingCliExecutorService : ICliExecutorService public string StandardExecutionCompletionContent { get; set; } = string.Empty; + public bool StandardExecutionIsError { get; set; } + + public string StandardExecutionErrorMessage { get; set; } = "执行失败"; + public string LowInterruptionExecutionContent { get; set; } = "continued"; + public bool LowInterruptionExecutionIsError { get; set; } + + public string LowInterruptionExecutionErrorMessage { get; set; } = "执行失败"; + public List ExecutedPrompts { get; } = new(); public List<(string SessionId, string ToolId, string Prompt)> StandardExecutionRequests { get; } = new(); @@ -2785,6 +3263,13 @@ public async Task WaitForExecutionAsync(TimeSpan timeout) return await _executionStarted.Task; } + public async Task WaitForExecutionCompletionAsync(TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + using var _ = cts.Token.Register(() => _executionCompleted.TrySetCanceled(cts.Token)); + return await _executionCompleted.Task; + } + public async Task WaitForLowInterruptionExecutionAsync(TimeSpan timeout) { using var cts = new CancellationTokenSource(timeout); @@ -2792,6 +3277,13 @@ public async Task WaitForLowInterruptionExecutionAsync(TimeSpan timeout) return await _lowInterruptionExecutionStarted.Task; } + public async Task WaitForLowInterruptionExecutionCompletionAsync(TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + using var _ = cts.Token.Register(() => _lowInterruptionExecutionCompleted.TrySetCanceled(cts.Token)); + return await _lowInterruptionExecutionCompleted.Task; + } + public ICliToolAdapter? GetAdapter(CliToolConfig tool) => null; public ICliToolAdapter? GetAdapterById(string toolId) => null; @@ -2829,27 +3321,44 @@ public async IAsyncEnumerable ExecuteStreamAsync( ExecutedPrompts.Add(userPrompt); StandardExecutionRequests.Add((sessionId, toolId, userPrompt)); _executionStarted.TrySetResult(sessionId); - - var hasTrailingCompletionChunk = StandardExecutionCompletionDelay > TimeSpan.Zero - || !string.IsNullOrEmpty(StandardExecutionCompletionContent); - yield return new StreamOutputChunk + try { - Content = StandardExecutionContent, - IsCompleted = !hasTrailingCompletionChunk - }; - - if (hasTrailingCompletionChunk) - { - if (StandardExecutionCompletionDelay > TimeSpan.Zero) + if (StandardExecutionIsError) { - await Task.Delay(StandardExecutionCompletionDelay, cancellationToken); + yield return new StreamOutputChunk + { + IsError = true, + IsCompleted = true, + ErrorMessage = StandardExecutionErrorMessage + }; + yield break; } + var hasTrailingCompletionChunk = StandardExecutionCompletionDelay > TimeSpan.Zero + || !string.IsNullOrEmpty(StandardExecutionCompletionContent); yield return new StreamOutputChunk { - Content = StandardExecutionCompletionContent, - IsCompleted = true + Content = StandardExecutionContent, + IsCompleted = !hasTrailingCompletionChunk }; + + if (hasTrailingCompletionChunk) + { + if (StandardExecutionCompletionDelay > TimeSpan.Zero) + { + await Task.Delay(StandardExecutionCompletionDelay, cancellationToken); + } + + yield return new StreamOutputChunk + { + Content = StandardExecutionCompletionContent, + IsCompleted = true + }; + } + } + finally + { + _executionCompleted.TrySetResult(sessionId); } await Task.CompletedTask; @@ -2870,11 +3379,29 @@ public async IAsyncEnumerable ExecuteLowInterruptionContinueS LowInterruptionSessionIds.Add(sessionId); LowInterruptionPrompts.Add(prompt); _lowInterruptionExecutionStarted.TrySetResult(sessionId); - yield return new StreamOutputChunk + try { - Content = LowInterruptionExecutionContent, - IsCompleted = true - }; + if (LowInterruptionExecutionIsError) + { + yield return new StreamOutputChunk + { + IsError = true, + IsCompleted = true, + ErrorMessage = LowInterruptionExecutionErrorMessage + }; + yield break; + } + + yield return new StreamOutputChunk + { + Content = LowInterruptionExecutionContent, + IsCompleted = true + }; + } + finally + { + _lowInterruptionExecutionCompleted.TrySetResult(sessionId); + } await Task.CompletedTask; } @@ -2980,6 +3507,39 @@ public List GetMessages(string sessionId) public void UpdateMessage(string sessionId, string messageId, Action updateAction) { } } + private sealed class RecordingReplyTtsOrchestrator : IReplyTtsOrchestrator + { + public List Requests { get; } = new(); + + public TaskCompletionSource WhenQueued { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource WhenCallbackCompleted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Func? OnQueued { get; set; } + + public async Task QueueCompletedReplyAsync(FeishuCompletedReplyTtsRequest request) + { + Requests.Add(request); + WhenQueued.TrySetResult(request); + if (OnQueued == null) + { + WhenCallbackCompleted.TrySetResult(request); + return; + } + + try + { + await OnQueued(request); + WhenCallbackCompleted.TrySetResult(request); + } + catch (Exception ex) + { + WhenCallbackCompleted.TrySetException(ex); + throw; + } + } + } + private sealed class StubFeishuCardKitClient : IFeishuCardKitClient { private readonly TaskCompletionSource<(string ChatId, string CardJson)> _rawCardSent = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -3139,7 +3699,20 @@ public Task ReplyRawCardAsync(string replyMessageId, string cardJson, Ca ButtonText = chrome.BottomPrompt.ButtonText, ButtonType = chrome.BottomPrompt.ButtonType, Value = chrome.BottomPrompt.Value - } + }, + AdditionalBottomPrompts = chrome.AdditionalBottomPrompts + .Select(prompt => new FeishuStreamingCardBottomPrompt + { + FormName = prompt.FormName, + InputName = prompt.InputName, + InputLabel = prompt.InputLabel, + Placeholder = prompt.Placeholder, + DefaultValue = prompt.DefaultValue, + ButtonText = prompt.ButtonText, + ButtonType = prompt.ButtonType, + Value = prompt.Value + }) + .ToList() }; } } @@ -3174,6 +3747,8 @@ private sealed class StubFeishuChannelService(string? currentSessionId) : IFeish public string ResolvedToolId { get; set; } = "claude-code"; + public string? SessionUsername { get; set; } = "luhaiyan"; + public Task SendMessageAsync(string chatId, string content, string? username = null, string? appId = null) { LastSentChatId = chatId; @@ -3225,7 +3800,7 @@ public string CreateNewSession(FeishuIncomingMessage message, string? customWork return "session-new"; } - public string? GetSessionUsername(string chatKey) => "luhaiyan"; + public string? GetSessionUsername(string chatKey) => SessionUsername; public string ResolveToolId(string chatKey, string? username = null) => ResolvedToolId; @@ -3248,6 +3823,25 @@ private sealed class StubExternalCliSessionHistoryService(IEnumerable GetRecentHistoryAsync( + string toolId, + string cliThreadId, + int maxCount = 20, + string? workspacePath = null, + CancellationToken cancellationToken = default) + { + LastToolId = toolId; + LastCliThreadId = cliThreadId; + LastMaxCount = maxCount; + return Task.FromResult(new ExternalCliHistoryResult + { + Messages = _messages.TakeLast(maxCount).ToList(), + SourcePath = SourcePath + }); + } + public Task> GetRecentMessagesAsync( string toolId, string cliThreadId, @@ -3341,7 +3935,7 @@ public Task ImportAsync( private sealed class TestServiceProvider : IServiceProvider, IServiceScopeFactory, IServiceScope { private readonly StubFeishuUserBindingService _bindingService = new(); - private readonly StubUserFeishuBotConfigService _feishuBotConfigService = new(); + private readonly StubUserFeishuBotConfigService _feishuBotConfigService; private readonly StubSessionDirectoryService _sessionDirectoryService = new(); private readonly ICcSwitchService _ccSwitchService; private readonly TestUserContextService _userContextService; @@ -3350,6 +3944,8 @@ private sealed class TestServiceProvider : IServiceProvider, IServiceScopeFactor private readonly IExternalCliSessionHistoryService _externalCliSessionHistoryService; private readonly IExternalCliSessionService _externalCliSessionService; private readonly ISuperpowersCapabilityService _superpowersCapabilityService; + private readonly IGoalCapabilityService _goalCapabilityService; + private readonly IReplyTtsOrchestrator? _replyTtsOrchestrator; public TestServiceProvider( TestUserContextService? userContextService = null, @@ -3358,8 +3954,12 @@ public TestServiceProvider( ICcSwitchService? ccSwitchService = null, IExternalCliSessionHistoryService? externalCliSessionHistoryService = null, IExternalCliSessionService? externalCliSessionService = null, - ISuperpowersCapabilityService? superpowersCapabilityService = null) + ISuperpowersCapabilityService? superpowersCapabilityService = null, + IGoalCapabilityService? goalCapabilityService = null, + IReplyTtsOrchestrator? replyTtsOrchestrator = null, + StubUserFeishuBotConfigService? feishuBotConfigService = null) { + _feishuBotConfigService = feishuBotConfigService ?? new StubUserFeishuBotConfigService(); _userContextService = userContextService ?? new TestUserContextService(); _projectService = projectService ?? new TestProjectService(_userContextService); _chatSessionRepository = chatSessionRepository ?? new StubChatSessionRepository([]); @@ -3367,6 +3967,8 @@ public TestServiceProvider( _externalCliSessionHistoryService = externalCliSessionHistoryService ?? new StubExternalCliSessionHistoryService([]); _externalCliSessionService = externalCliSessionService ?? new StubExternalCliSessionService([]); _superpowersCapabilityService = superpowersCapabilityService ?? new StubSuperpowersCapabilityService(); + _goalCapabilityService = goalCapabilityService ?? new StubGoalCapabilityService(); + _replyTtsOrchestrator = replyTtsOrchestrator; } public object? GetService(Type serviceType) @@ -3426,6 +4028,16 @@ public TestServiceProvider( return _superpowersCapabilityService; } + if (serviceType == typeof(IGoalCapabilityService)) + { + return _goalCapabilityService; + } + + if (serviceType == typeof(IReplyTtsOrchestrator)) + { + return _replyTtsOrchestrator; + } + return null; } @@ -3567,6 +4179,61 @@ public Task ProbeAsync( } } + private sealed class StubGoalCapabilityService : IGoalCapabilityService + { + public GoalCapabilityState CachedState { get; set; } = GoalCapabilityState.Unknown; + + public string? CachedMessage { get; set; } + + public GoalCapabilityState ProbeState { get; set; } = GoalCapabilityState.Available; + + public GoalCapabilityProbeOutcome ProbeOutcome { get; set; } = GoalCapabilityProbeOutcome.Available; + + public string? ProbeMessage { get; set; } + + public List ProbeContexts { get; } = new(); + + public Task GetStateAsync( + GoalCapabilityContext context, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + return Task.FromResult(new GoalCapabilitySnapshot + { + ToolId = context.ToolId, + ProviderId = context.ProviderId ?? GoalCapabilityService.UnscopedProviderId, + CacheKey = $"{context.ToolId}::{context.ProviderId ?? GoalCapabilityService.UnscopedProviderId}", + State = CachedState, + Message = CachedMessage + }); + } + + public Task ProbeAsync( + GoalCapabilityContext context, + bool forceRefresh = false, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ProbeContexts.Add(new GoalCapabilityContext + { + ToolId = context.ToolId, + ProviderId = context.ProviderId, + WorkspacePath = context.WorkspacePath + }); + + return Task.FromResult(new GoalCapabilityProbeResult + { + ToolId = context.ToolId, + ProviderId = context.ProviderId ?? GoalCapabilityService.UnscopedProviderId, + CacheKey = $"{context.ToolId}::{context.ProviderId ?? GoalCapabilityService.UnscopedProviderId}", + State = ProbeState, + Outcome = ProbeOutcome, + Message = ProbeMessage + }); + } + } + private sealed class StubChatSessionRepository(IEnumerable sessions) : IChatSessionRepository { private readonly List _sessions = sessions.ToList(); @@ -3859,14 +4526,33 @@ private sealed class StubUserFeishuBotConfigService : IUserFeishuBotConfigServic ThinkingMessage = "鎬濊€冧腑..." }; + private readonly Dictionary _configs = new(StringComparer.OrdinalIgnoreCase); + + public void Seed(UserFeishuBotConfigEntity config) + { + _configs[config.Username] = Clone(config); + } + public Task GetByUsernameAsync(string username) - => Task.FromResult(null); + { + return Task.FromResult( + _configs.TryGetValue(username, out var config) + ? Clone(config) + : null); + } public Task GetByAppIdAsync(string appId) - => Task.FromResult(null); + { + var config = _configs.Values.FirstOrDefault(item => + string.Equals(item.AppId, appId, StringComparison.OrdinalIgnoreCase)); + return Task.FromResult(config == null ? null : Clone(config)); + } public Task SaveAsync(UserFeishuBotConfigEntity config) - => throw new NotSupportedException(); + { + _configs[config.Username] = Clone(config); + return Task.FromResult(UserFeishuBotConfigSaveResult.Saved()); + } public Task DeleteAsync(string username) => Task.FromResult(true); @@ -3874,7 +4560,7 @@ public Task SaveAsync(UserFeishuBotConfigEntity c => Task.FromResult(null); public Task> GetAutoStartCandidatesAsync() - => Task.FromResult(new List()); + => Task.FromResult(_configs.Values.Select(Clone).ToList()); public Task UpdateRuntimePreferenceAsync(string username, bool autoStartEnabled, DateTime? lastStartedAt = null) => Task.FromResult(true); @@ -3895,6 +4581,30 @@ public Task GetEffectiveOptionsAsync(string? username) DefaultCardTitle = "AI鍔╂墜", ThinkingMessage = "鎬濊€冧腑..." }); + + private static UserFeishuBotConfigEntity Clone(UserFeishuBotConfigEntity config) + { + return new UserFeishuBotConfigEntity + { + Id = config.Id, + Username = config.Username, + IsEnabled = config.IsEnabled, + AutoStartEnabled = config.AutoStartEnabled, + AppId = config.AppId, + AppSecret = config.AppSecret, + EncryptKey = config.EncryptKey, + VerificationToken = config.VerificationToken, + DefaultCardTitle = config.DefaultCardTitle, + ThinkingMessage = config.ThinkingMessage, + HttpTimeoutSeconds = config.HttpTimeoutSeconds, + StreamingThrottleMs = config.StreamingThrottleMs, + ReplyTtsEnabled = config.ReplyTtsEnabled, + ReplyTtsVoiceId = config.ReplyTtsVoiceId, + LastStartedAt = config.LastStartedAt, + CreatedAt = config.CreatedAt, + UpdatedAt = config.UpdatedAt + }; + } } private sealed class StubSessionDirectoryService : ISessionDirectoryService diff --git a/WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs b/WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs index e5173c7..def81f4 100644 --- a/WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs +++ b/WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs @@ -12,6 +12,71 @@ namespace WebCodeCli.Domain.Tests; public class FeishuCardKitClientTests { + [Fact] + public async Task UploadAudioFileAsync_PostsMultipartFormDataWithDuration() + { + var tempFile = Path.GetTempFileName(); + await File.WriteAllTextAsync(tempFile, "opus", TestContext.Current.CancellationToken); + + try + { + var handler = new StubHttpMessageHandler( + [ + CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), + CreateJsonResponse("""{"code":0,"data":{"file_key":"file_v2_123"}}""") + ]); + + var client = CreateClient(handler); + + var fileKey = await client.UploadAudioFileAsync(tempFile, 3200, TestContext.Current.CancellationToken); + + Assert.Equal("file_v2_123", fileKey); + Assert.Equal( + [ + "/open-apis/auth/v3/tenant_access_token/internal", + "/open-apis/im/v1/files" + ], handler.RequestPaths); + Assert.Contains("multipart/form-data", handler.RequestContentTypes[1], StringComparison.OrdinalIgnoreCase); + Assert.Contains("name=file_type", handler.RequestBodies[1], StringComparison.OrdinalIgnoreCase); + Assert.Contains("opus", handler.RequestBodies[1], StringComparison.OrdinalIgnoreCase); + Assert.Contains("name=file_name", handler.RequestBodies[1], StringComparison.OrdinalIgnoreCase); + Assert.Contains("name=duration", handler.RequestBodies[1], StringComparison.OrdinalIgnoreCase); + Assert.Contains("3200", handler.RequestBodies[1], StringComparison.Ordinal); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public async Task SendAudioMessageAsync_SendsAudioPayload() + { + var handler = new StubHttpMessageHandler( + [ + CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), + CreateJsonResponse("""{"code":0,"data":{"message_id":"om_audio_success"}}""") + ]); + + var client = CreateClient(handler); + + var messageId = await client.SendAudioMessageAsync("oc_audio_chat", "file_v2_123", 3200, TestContext.Current.CancellationToken); + + Assert.Equal("om_audio_success", messageId); + Assert.Equal( + [ + "/open-apis/auth/v3/tenant_access_token/internal", + "/open-apis/im/v1/messages" + ], handler.RequestPaths); + + using var requestDoc = JsonDocument.Parse(handler.RequestBodies[1]); + Assert.Equal("audio", requestDoc.RootElement.GetProperty("msg_type").GetString()); + Assert.Equal("oc_audio_chat", requestDoc.RootElement.GetProperty("receive_id").GetString()); + + using var contentDoc = JsonDocument.Parse(requestDoc.RootElement.GetProperty("content").GetString()!); + Assert.Equal("file_v2_123", contentDoc.RootElement.GetProperty("file_key").GetString()); + } + [Fact] public async Task SendTextMessageAsync_SendsTextPayload() { @@ -241,6 +306,8 @@ await client.CreateStreamingHandleAsync( using var createDoc = JsonDocument.Parse(handler.RequestBodies[1]); using var cardDoc = JsonDocument.Parse(createDoc.RootElement.GetProperty("data").GetString()!); var elements = cardDoc.RootElement.GetProperty("body").GetProperty("elements"); + Assert.Equal("🟥🟥🟥 **回复内容**", elements[1].GetProperty("text").GetProperty("content").GetString()); + Assert.Equal("🟥🟥🟥 **Superpowers 工作流**", elements[3].GetProperty("text").GetProperty("content").GetString()); var bottomActionModule = elements.EnumerateArray().Last(); Assert.Equal("form", bottomActionModule.GetProperty("tag").GetString()); @@ -261,6 +328,66 @@ await client.CreateStreamingHandleAsync( Assert.Equal("low_interruption_continue", button.GetProperty("value").GetProperty("action").GetString()); } + [Fact] + public async Task CreateStreamingHandleAsync_UsesUniqueSubmitButtonNames_ForMultipleBottomPrompts() + { + var handler = new StubHttpMessageHandler( + [ + CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), + CreateJsonResponse("""{"code":0,"data":{"card_id":"card_123"}}"""), + CreateJsonResponse("""{"code":0,"data":{"message_id":"om_stream_success"}}""") + ]); + + var client = CreateClient(handler); + var chrome = new FeishuStreamingCardChrome + { + StatusMarkdown = "当前会话", + BottomPrompt = new FeishuStreamingCardBottomPrompt + { + FormName = "superpowers_quick_action_form", + InputName = "superpowers_quick_input", + InputLabel = "使用 superpowers 工作流", + Placeholder = "输入后提交", + DefaultValue = string.Empty, + ButtonText = "提交", + ButtonType = "primary", + Value = new { action = "submit_superpowers_quick_input" } + }, + AdditionalBottomPrompts = + [ + new FeishuStreamingCardBottomPrompt + { + FormName = "goal_quick_action_form", + InputName = "goal_quick_input", + InputLabel = "使用 /goal 工作流", + Placeholder = "输入后提交", + DefaultValue = string.Empty, + ButtonText = "提交", + ButtonType = "primary", + Value = new { action = "submit_goal_quick_input" } + } + ] + }; + + await client.CreateStreamingHandleAsync( + "oc_stream_chat", + null, + "still have backlog", + "AI 助手", + TestContext.Current.CancellationToken, + chrome: chrome); + + using var createDoc = JsonDocument.Parse(handler.RequestBodies[1]); + using var cardDoc = JsonDocument.Parse(createDoc.RootElement.GetProperty("data").GetString()!); + var elements = cardDoc.RootElement.GetProperty("body").GetProperty("elements"); + + var firstFormButton = elements[4].GetProperty("elements")[0].GetProperty("columns")[1].GetProperty("elements")[0]; + var secondFormButton = elements[5].GetProperty("elements")[0].GetProperty("columns")[1].GetProperty("elements")[0]; + + Assert.Equal("superpowers_quick_input_submit", firstFormButton.GetProperty("name").GetString()); + Assert.Equal("goal_quick_input_submit", secondFormButton.GetProperty("name").GetString()); + } + [Fact] public async Task CreateStreamingHandleAsync_RendersTopChipGroupsBetweenStatusAndBody() { @@ -309,7 +436,9 @@ await client.CreateStreamingHandleAsync( var elements = cardDoc.RootElement.GetProperty("body").GetProperty("elements"); Assert.Equal("div", elements[0].GetProperty("tag").GetString()); + Assert.Equal("🟥🟥🟥 **思考等级**", elements[1].GetProperty("text").GetProperty("content").GetString()); Assert.Equal("div", elements[2].GetProperty("tag").GetString()); + Assert.Equal("🟥🟥🟥 **回复内容**", elements[3].GetProperty("text").GetProperty("content").GetString()); Assert.Equal("markdown", elements[4].GetProperty("tag").GetString()); Assert.Equal("🤖 模型:`gpt-5.3-codex-spark`", elements[2].GetProperty("text").GetProperty("content").GetString()); Assert.Equal("overflow", elements[2].GetProperty("extra").GetProperty("tag").GetString()); @@ -369,16 +498,57 @@ await client.CreateStreamingHandleAsync( using var cardDoc = JsonDocument.Parse(createDoc.RootElement.GetProperty("data").GetString()!); var elements = cardDoc.RootElement.GetProperty("body").GetProperty("elements"); + Assert.Equal("🟥🟥🟥 **思考等级**", elements[1].GetProperty("text").GetProperty("content").GetString()); Assert.Equal("column_set", elements[2].GetProperty("tag").GetString()); Assert.Equal("column_set", elements[3].GetProperty("tag").GetString()); Assert.Equal(6, elements[2].GetProperty("columns").GetArrayLength()); Assert.Equal(1, elements[3].GetProperty("columns").GetArrayLength()); Assert.Equal("gpt-5.1", elements[2].GetProperty("columns")[0].GetProperty("elements")[0].GetProperty("text").GetProperty("content").GetString()); Assert.Equal("gpt-5.7", elements[3].GetProperty("columns")[0].GetProperty("elements")[0].GetProperty("text").GetProperty("content").GetString()); - Assert.Equal("hr", elements[4].GetProperty("tag").GetString()); + Assert.Equal("🟥🟥🟥 **回复内容**", elements[4].GetProperty("text").GetProperty("content").GetString()); Assert.Equal("markdown", elements[5].GetProperty("tag").GetString()); } + [Fact] + public async Task CreateStreamingHandleAsync_RendersWorkflowSectionMarkerBeforeBottomActions() + { + var handler = new StubHttpMessageHandler( + [ + CreateJsonResponse("""{"tenant_access_token":"token-123","expire":7200}"""), + CreateJsonResponse("""{"code":0,"data":{"card_id":"card_123"}}"""), + CreateJsonResponse("""{"code":0,"data":{"message_id":"om_stream_success"}}""") + ]); + + var client = CreateClient(handler); + var chrome = new FeishuStreamingCardChrome + { + StatusMarkdown = "当前会话" + }; + chrome.BottomActions.Add(new FeishuStreamingCardBottomAction + { + Text = "执行 plan", + Type = "primary", + Value = new { action = "execute_superpowers_plan", session_id = "session-1" } + }); + + await client.CreateStreamingHandleAsync( + "oc_stream_chat", + null, + "still have backlog", + "AI 助手", + TestContext.Current.CancellationToken, + chrome: chrome); + + using var createDoc = JsonDocument.Parse(handler.RequestBodies[1]); + using var cardDoc = JsonDocument.Parse(createDoc.RootElement.GetProperty("data").GetString()!); + var elements = cardDoc.RootElement.GetProperty("body").GetProperty("elements"); + + Assert.Equal("🟥🟥🟥 **回复内容**", elements[1].GetProperty("text").GetProperty("content").GetString()); + Assert.Equal("markdown", elements[2].GetProperty("tag").GetString()); + Assert.Equal("🟥🟥🟥 **Superpowers 工作流**", elements[3].GetProperty("text").GetProperty("content").GetString()); + Assert.Equal("column_set", elements[4].GetProperty("tag").GetString()); + } + [Fact] public async Task CreateStreamingHandleAsync_KeepsClientStreamingMode_WhenNoOverflowActionsExist() { @@ -515,10 +685,12 @@ private sealed class StubHttpMessageHandler(IEnumerable res public List RequestPaths { get; } = []; public List RequestBodies { get; } = []; + public List RequestContentTypes { get; } = []; protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { RequestPaths.Add(request.RequestUri!.AbsolutePath); + RequestContentTypes.Add(request.Content?.Headers.ContentType?.MediaType); RequestBodies.Add(request.Content == null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken)); diff --git a/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs b/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs index 7c036f6..ff41fb0 100644 --- a/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs +++ b/WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs @@ -407,7 +407,10 @@ await service.HandleIncomingMessageAsync(new FeishuIncomingMessage }); var call = Assert.Single(cliExecutor.ExecuteCalls); - Assert.Equal(expectedPrompt.Replace("\n", Environment.NewLine, StringComparison.Ordinal), call.Prompt); + var normalizedExpectedPrompt = expectedPrompt + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace("\n", Environment.NewLine, StringComparison.Ordinal); + Assert.Equal(normalizedExpectedPrompt, call.Prompt); Assert.Contains(chatSessionService.Messages[sessionId], message => message.Role == "user" && message.Content == call.Prompt); } finally @@ -771,6 +774,214 @@ public async Task HandleIncomingMessageAsync_WithSessionOverflowMenu_ResumesPuls } } + [Fact] + public async Task HandleIncomingMessageAsync_QueuesReplyTtsAfterSuccessfulCompletionAndAssistantPersistence() + { + var repository = CreateRepository(out var repositoryProxy); + var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy); + var cardKit = new StreamingRecordingFeishuCardKitClient(); + var chatSessionService = new RecordingChatSessionService(); + var replyTtsOrchestrator = new RecordingReplyTtsOrchestrator(); + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-reply-tts-success-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); + var cliExecutor = new PromptCapturingCliExecutor(workspacePath); + var serviceProvider = new TestServiceProvider( + repository, + sessionDirectoryService, + new StubFeishuUserBindingService(), + new StubUserFeishuBotConfigService(), + new StubUserContextService()); + + var service = new FeishuChannelService( + Options.Create(new FeishuOptions + { + Enabled = true, + AppId = "cli_test", + AppSecret = "secret" + }), + NullLogger.Instance, + cardKit, + serviceProvider, + cliExecutor, + chatSessionService, + replyTtsOrchestrator); + + try + { + var sessionId = service.CreateNewSession( + new FeishuIncomingMessage + { + ChatId = "oc_reply_tts_chat", + SenderName = "luhaiyan" + }, + workspacePath, + "codex"); + + replyTtsOrchestrator.OnQueued = request => + { + Assert.Equal("补充完成", request.Output); + Assert.Contains( + chatSessionService.Messages[sessionId], + message => message.Role == "assistant" && message.Content == "补充完成" && message.IsCompleted); + Assert.Equal("补充完成", Assert.Single(cardKit.Handles).FinalContent); + return Task.CompletedTask; + }; + + await service.HandleIncomingMessageAsync(new FeishuIncomingMessage + { + ChatId = "oc_reply_tts_chat", + SenderName = "luhaiyan", + AppId = "cli_test", + MessageId = "msg-reply-tts", + Content = "继续" + }); + + var queued = await replyTtsOrchestrator.WhenQueued.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal("oc_reply_tts_chat", queued.ChatId); + Assert.Equal("luhaiyan", queued.Username); + Assert.Equal("cli_test", queued.AppId); + Assert.Equal("补充完成", queued.Output); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task HandleIncomingMessageAsync_DoesNotQueueReplyTtsWhenExecutionErrors() + { + var repository = CreateRepository(out var repositoryProxy); + var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy); + var cardKit = new StreamingRecordingFeishuCardKitClient(); + var chatSessionService = new RecordingChatSessionService(); + var replyTtsOrchestrator = new RecordingReplyTtsOrchestrator(); + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-reply-tts-error-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); + var cliExecutor = new ErrorCliExecutor(workspacePath); + var serviceProvider = new TestServiceProvider( + repository, + sessionDirectoryService, + new StubFeishuUserBindingService(), + new StubUserFeishuBotConfigService(), + new StubUserContextService()); + + var service = new FeishuChannelService( + Options.Create(new FeishuOptions + { + Enabled = true, + AppId = "cli_test", + AppSecret = "secret" + }), + NullLogger.Instance, + cardKit, + serviceProvider, + cliExecutor, + chatSessionService, + replyTtsOrchestrator); + + try + { + service.CreateNewSession( + new FeishuIncomingMessage + { + ChatId = "oc_reply_tts_error_chat", + SenderName = "luhaiyan" + }, + workspacePath, + "codex"); + + await service.HandleIncomingMessageAsync(new FeishuIncomingMessage + { + ChatId = "oc_reply_tts_error_chat", + SenderName = "luhaiyan", + MessageId = "msg-reply-tts-error", + Content = "继续" + }); + + Assert.Empty(replyTtsOrchestrator.Requests); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + + [Fact] + public async Task HandleIncomingMessageAsync_DoesNotQueueReplyTtsForSupersededExecution() + { + var repository = CreateRepository(out var repositoryProxy); + var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy); + var cardKit = new StreamingRecordingFeishuCardKitClient(); + var chatSessionService = new RecordingChatSessionService(); + var replyTtsOrchestrator = new RecordingReplyTtsOrchestrator(); + var workspaceRoot = Path.Combine(Path.GetTempPath(), $"feishu-reply-tts-superseded-{Guid.NewGuid():N}"); + var workspacePath = Path.Combine(workspaceRoot, "superpowers"); + Directory.CreateDirectory(workspacePath); + var cliExecutor = new TakeoverCliExecutor(workspacePath); + var serviceProvider = new TestServiceProvider( + repository, + sessionDirectoryService, + new StubFeishuUserBindingService(), + new StubUserFeishuBotConfigService(), + new StubUserContextService()); + + var service = new FeishuChannelService( + Options.Create(new FeishuOptions + { + Enabled = true, + AppId = "cli_test", + AppSecret = "secret" + }), + NullLogger.Instance, + cardKit, + serviceProvider, + cliExecutor, + chatSessionService, + replyTtsOrchestrator); + + try + { + service.CreateNewSession( + new FeishuIncomingMessage + { + ChatId = "oc_reply_tts_superseded_chat", + SenderName = "luhaiyan" + }, + workspacePath, + "codex"); + + var firstTask = service.HandleIncomingMessageAsync(new FeishuIncomingMessage + { + ChatId = "oc_reply_tts_superseded_chat", + SenderName = "luhaiyan", + MessageId = "msg-reply-tts-superseded-1", + Content = "先查一下 superpowers 计划文件" + }); + + await cliExecutor.ThreadIdPersisted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + var secondTask = service.HandleIncomingMessageAsync(new FeishuIncomingMessage + { + ChatId = "oc_reply_tts_superseded_chat", + SenderName = "luhaiyan", + MessageId = "msg-reply-tts-superseded-2", + Content = @"补充:D:\MMIS\Base\Docs\superpowers" + }); + + await Task.WhenAll(firstTask, secondTask); + + var queued = Assert.Single(replyTtsOrchestrator.Requests); + Assert.Equal("补充完成", queued.Output); + } + finally + { + Directory.Delete(workspaceRoot, recursive: true); + } + } + [Fact] public async Task HandleIncomingMessageAsync_AttachesSuperpowersQuickActions_WhenPlanFilesExistAndSessionHistoryContainsSuperpowers() { @@ -856,9 +1067,24 @@ await service.HandleIncomingMessageAsync(new FeishuIncomingMessage Assert.Contains("\"chat_key\":\"oc_low_interrupt_chat\"", quickInputJson); Assert.Contains("\"tool_id\":\"codex\"", quickInputJson); - Assert.Equal(2, chrome.BottomActions.Count); + var goalPrompt = Assert.Single(chrome.AdditionalBottomPrompts); + Assert.Equal(GoalQuickActionDefaults.QuickInputFieldName, goalPrompt.InputName); + Assert.Equal(GoalQuickActionDefaults.InstructionText, goalPrompt.InputLabel); + var goalInputJson = JsonSerializer.Serialize(goalPrompt.Value); + Assert.Contains($"\"action\":\"{FeishuHelpCardAction.SubmitGoalQuickInputAction}\"", goalInputJson); + Assert.Contains($"\"session_id\":\"{sessionId}\"", goalInputJson); + Assert.Contains("\"chat_key\":\"oc_low_interrupt_chat\"", goalInputJson); + Assert.Contains("\"tool_id\":\"codex\"", goalInputJson); + + Assert.Equal(3, chrome.BottomActions.Count); + Assert.Contains(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ContinueButtonText); Assert.Contains(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecutePlanButtonText); Assert.Contains(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ExecuteSubagentPlanButtonText); + Assert.Contains( + $"\"action\":\"{FeishuHelpCardAction.ContinueSuperpowersAction}\"", + JsonSerializer.Serialize( + Assert.Single(chrome.BottomActions, action => action.Text == SuperpowersQuickActionDefaults.ContinueButtonText).Value), + StringComparison.Ordinal); Assert.Contains( $"\"action\":\"{FeishuHelpCardAction.ExecuteSuperpowersPlanAction}\"", JsonSerializer.Serialize( @@ -877,7 +1103,7 @@ await service.HandleIncomingMessageAsync(new FeishuIncomingMessage } [Fact] - public async Task HandleIncomingMessageAsync_AttachesQuickInputButHidesPlanActions_WhenWorkspaceHasNoPlanFiles() + public async Task HandleIncomingMessageAsync_AttachesQuickInputAndKeepsContinueAction_WhenWorkspaceHasNoPlanFiles() { var repository = CreateRepository(out var repositoryProxy); var sessionDirectoryService = new RecordingSessionDirectoryService(repositoryProxy); @@ -949,7 +1175,8 @@ await service.HandleIncomingMessageAsync(new FeishuIncomingMessage Assert.NotNull(handle.Chrome); Assert.NotNull(handle.Chrome!.BottomPrompt); Assert.Equal(SuperpowersQuickActionDefaults.QuickInputFieldName, handle.Chrome.BottomPrompt!.InputName); - Assert.Empty(handle.Chrome.BottomActions); + var continueAction = Assert.Single(handle.Chrome.BottomActions); + Assert.Equal(SuperpowersQuickActionDefaults.ContinueButtonText, continueAction.Text); } finally { @@ -1091,9 +1318,11 @@ private sealed class TestServiceProvider( IFeishuUserBindingService feishuUserBindingService, IUserFeishuBotConfigService userFeishuBotConfigService, IUserContextService userContextService, - ISuperpowersCapabilityService? superpowersCapabilityService = null) : IServiceProvider, IServiceScopeFactory, IServiceScope + ISuperpowersCapabilityService? superpowersCapabilityService = null, + IGoalCapabilityService? goalCapabilityService = null) : IServiceProvider, IServiceScopeFactory, IServiceScope { private readonly ISuperpowersCapabilityService _superpowersCapabilityService = superpowersCapabilityService ?? new StubSuperpowersCapabilityService(); + private readonly IGoalCapabilityService _goalCapabilityService = goalCapabilityService ?? new StubGoalCapabilityService(); public object? GetService(Type serviceType) { @@ -1132,6 +1361,11 @@ private sealed class TestServiceProvider( return _superpowersCapabilityService; } + if (serviceType == typeof(IGoalCapabilityService)) + { + return _goalCapabilityService; + } + return null; } @@ -1182,6 +1416,44 @@ public Task ProbeAsync( } } + private sealed class StubGoalCapabilityService : IGoalCapabilityService + { + public GoalCapabilityState CachedState { get; set; } = GoalCapabilityState.Unknown; + + public string? CachedMessage { get; set; } + + public Task GetStateAsync( + GoalCapabilityContext context, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new GoalCapabilitySnapshot + { + ToolId = context.ToolId, + ProviderId = context.ProviderId ?? GoalCapabilityService.UnscopedProviderId, + CacheKey = $"{context.ToolId}::{context.ProviderId ?? GoalCapabilityService.UnscopedProviderId}", + State = CachedState, + Message = CachedMessage + }); + } + + public Task ProbeAsync( + GoalCapabilityContext context, + bool forceRefresh = false, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new GoalCapabilityProbeResult + { + ToolId = context.ToolId, + ProviderId = context.ProviderId ?? GoalCapabilityService.UnscopedProviderId, + CacheKey = $"{context.ToolId}::{context.ProviderId ?? GoalCapabilityService.UnscopedProviderId}", + State = GoalCapabilityState.Available, + Outcome = GoalCapabilityProbeOutcome.Available + }); + } + } + private sealed class StubUserContextService : IUserContextService { public string GetCurrentUsername() => "luhaiyan"; @@ -1255,6 +1527,25 @@ public Task UpdateRuntimePreferenceAsync(string username, bool autoStartEn }); } + private sealed class RecordingReplyTtsOrchestrator : IReplyTtsOrchestrator + { + public List Requests { get; } = new(); + + public TaskCompletionSource WhenQueued { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Func? OnQueued { get; set; } + + public async Task QueueCompletedReplyAsync(FeishuCompletedReplyTtsRequest request) + { + Requests.Add(request); + WhenQueued.TrySetResult(request); + if (OnQueued != null) + { + await OnQueued(request); + } + } + } + private sealed class RecordingFeishuCardKitClient : IFeishuCardKitClient { public int CreateCardCallCount { get; private set; } @@ -1460,7 +1751,20 @@ public Task ReplyRawCardAsync(string replyMessageId, string cardJson, Ca ButtonText = chrome.BottomPrompt.ButtonText, ButtonType = chrome.BottomPrompt.ButtonType, Value = chrome.BottomPrompt.Value - } + }, + AdditionalBottomPrompts = chrome.AdditionalBottomPrompts + .Select(prompt => new FeishuStreamingCardBottomPrompt + { + FormName = prompt.FormName, + InputName = prompt.InputName, + InputLabel = prompt.InputLabel, + Placeholder = prompt.Placeholder, + DefaultValue = prompt.DefaultValue, + ButtonText = prompt.ButtonText, + ButtonType = prompt.ButtonType, + Value = prompt.Value + }) + .ToList() }; } } @@ -1880,6 +2184,134 @@ public void RefreshWorkspaceRootCache() } } + private sealed class ErrorCliExecutor(string workspacePath) : ICliExecutorService + { + public ICliToolAdapter? GetAdapter(CliToolConfig tool) => null; + + public ICliToolAdapter? GetAdapterById(string toolId) => null; + + public bool SupportsStreamParsing(CliToolConfig tool) => false; + + public string? GetCliThreadId(string sessionId) => null; + + public void SetCliThreadId(string sessionId, string threadId) + { + } + + public Task ResetSessionRuntimeAsync(string sessionId, bool clearCliThreadId = true, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public async IAsyncEnumerable ExecuteStreamAsync( + string sessionId, + string toolId, + string userPrompt, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + yield return new StreamOutputChunk + { + IsError = true, + IsCompleted = true, + ErrorMessage = "执行失败" + }; + + await Task.CompletedTask; + } + + public bool SupportsLowInterruptionContinue(string toolId) => false; + + public bool CanStartLowInterruptionContinue(string sessionId, string toolId) => false; + + public async IAsyncEnumerable ExecuteLowInterruptionContinueStreamAsync( + string sessionId, + string toolId, + string? prompt = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + yield return new StreamOutputChunk + { + IsError = true, + IsCompleted = true, + ErrorMessage = "not implemented in test double" + }; + + await Task.CompletedTask; + } + + public List GetAvailableTools(string? username = null) + => new() { GetTool("codex", username)! }; + + public CliToolConfig? GetTool(string toolId, string? username = null) + => string.Equals(toolId, "codex", StringComparison.OrdinalIgnoreCase) + ? new CliToolConfig + { + Id = "codex", + Name = "Codex", + Description = "Codex", + Command = "codex", + Enabled = true + } + : null; + + public bool ValidateTool(string toolId, string? username = null) => true; + + public void CleanupSessionWorkspace(string sessionId) + { + } + + public void CleanupExpiredWorkspaces() + { + } + + public string GetSessionWorkspacePath(string sessionId) => workspacePath; + + public Task> GetToolEnvironmentVariablesAsync(string toolId, string? username = null) + => Task.FromResult(new Dictionary()); + + public Task SyncSessionCcSwitchSnapshotAsync(string sessionId, string? toolId = null, CancellationToken cancellationToken = default) + => Task.FromResult(null); + + public Task SyncCodexThreadProviderAsync(string sessionId, string? toolId = null, CancellationToken cancellationToken = default) + => Task.FromResult(new CodexThreadProviderSyncResult + { + Message = "thread sync complete" + }); + + public Task SaveToolEnvironmentVariablesAsync(string toolId, Dictionary envVars, string? username = null) + => Task.FromResult(true); + + public byte[]? GetWorkspaceFile(string sessionId, string relativePath) => null; + + public byte[]? GetWorkspaceZip(string sessionId) => null; + + public Task UploadFileToWorkspaceAsync(string sessionId, string fileName, byte[] fileContent, string? relativePath = null) + => Task.FromResult(true); + + public Task CreateFolderInWorkspaceAsync(string sessionId, string folderPath) + => Task.FromResult(true); + + public Task DeleteWorkspaceItemAsync(string sessionId, string relativePath, bool isDirectory) + => Task.FromResult(true); + + public Task MoveFileInWorkspaceAsync(string sessionId, string sourcePath, string targetPath) + => Task.FromResult(true); + + public Task CopyFileInWorkspaceAsync(string sessionId, string sourcePath, string targetPath) + => Task.FromResult(true); + + public Task RenameFileInWorkspaceAsync(string sessionId, string oldPath, string newName) + => Task.FromResult(true); + + public Task BatchDeleteFilesAsync(string sessionId, List relativePaths) + => Task.FromResult(0); + + public Task InitializeSessionWorkspaceAsync(string sessionId, string? projectId = null, bool includeGit = false) + => Task.FromResult(Path.Combine(Path.GetTempPath(), sessionId)); + + public void RefreshWorkspaceRootCache() + { + } + } + private sealed class ExecutionCall { public string SessionId { get; set; } = string.Empty; diff --git a/WebCodeCli.Domain.Tests/FeishuHelpCardBuilderTests.cs b/WebCodeCli.Domain.Tests/FeishuHelpCardBuilderTests.cs index 4362228..62c693b 100644 --- a/WebCodeCli.Domain.Tests/FeishuHelpCardBuilderTests.cs +++ b/WebCodeCli.Domain.Tests/FeishuHelpCardBuilderTests.cs @@ -45,6 +45,27 @@ public void BuildCommandListCardV2_UsesCategoryButtonCallback() Assert.False(ContainsProperty(bodyDoc.RootElement, "extra")); } + [Fact] + public void BuildCommandListCard_IncludesReplyTtsToggle_WhenEnabled() + { + var cardJson = _builder.BuildCommandListCard(CreateCategories(), replyTtsEnabled: true); + using var document = JsonDocument.Parse(cardJson); + var elements = document.RootElement.GetProperty("body").GetProperty("elements"); + + Assert.True(ContainsStringValue(elements, "语音回复:开")); + Assert.True(ContainsAction(elements, "toggle_reply_tts")); + } + + [Fact] + public void BuildCommandListCardV2_IncludesReplyTtsToggle_WhenDisabled() + { + var card = _builder.BuildCommandListCardV2(CreateCategories(), replyTtsEnabled: false); + using var bodyDoc = JsonDocument.Parse(JsonSerializer.Serialize(card.Body!.Elements)); + + Assert.True(ContainsStringValue(bodyDoc.RootElement, "语音回复:关")); + Assert.True(ContainsAction(bodyDoc.RootElement, "toggle_reply_tts")); + } + [Fact] public void BuildCategoryCommandsCardV2_UsesCommandButtonCallback() { @@ -133,6 +154,29 @@ public void BuildCategoryCommandsCardV2_AppendsSuperpowersQuickActionsFooter() Assert.False(ContainsAction(bodyDoc.RootElement, FeishuHelpCardAction.ExecuteSuperpowersSubagentPlanAction)); } + [Fact] + public void BuildCommandListCard_AppendsGoalQuickActionFooter_BelowSuperpowers() + { + var cardJson = _builder.BuildCommandListCard(CreateCategories()); + using var document = JsonDocument.Parse(cardJson); + var elements = document.RootElement.GetProperty("body").GetProperty("elements"); + + Assert.True(ContainsStringValue(elements, GoalQuickActionDefaults.InstructionText)); + Assert.Equal(1, CountInputsByName(elements, GoalQuickActionDefaults.QuickInputFieldName)); + + var goalInput = GetInputByName(elements, GoalQuickActionDefaults.QuickInputFieldName); + Assert.Equal( + GoalQuickActionDefaults.QuickInputPlaceholder, + goalInput.GetProperty("placeholder").GetProperty("content").GetString()); + Assert.Equal( + FeishuHelpCardAction.SubmitGoalQuickInputAction, + goalInput.GetProperty("behaviors")[0].GetProperty("value").GetProperty("action").GetString()); + + Assert.True( + GetInputIndexByName(elements, GoalQuickActionDefaults.QuickInputFieldName) + > GetInputIndexByName(elements, SuperpowersQuickActionDefaults.QuickInputFieldName)); + } + private static JsonElement GetActionValue(JsonElement elements, string action) { if (TryGetActionValue(elements, action, out var actionValue)) @@ -363,6 +407,31 @@ private static void CountInputsByName(JsonElement element, string inputName, ref } } + private static int GetInputIndexByName(JsonElement elements, string inputName) + { + if (elements.ValueKind != JsonValueKind.Array) + { + throw new Xunit.Sdk.XunitException("Card body elements must be an array."); + } + + var index = 0; + foreach (var item in elements.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.Object + && item.TryGetProperty("tag", out var tag) + && tag.GetString() == "input" + && item.TryGetProperty("name", out var name) + && string.Equals(name.GetString(), inputName, StringComparison.Ordinal)) + { + return index; + } + + index++; + } + + throw new Xunit.Sdk.XunitException($"No input found for name '{inputName}' in card payload."); + } + private static List CreateCategories() { return diff --git a/WebCodeCli.Domain.Tests/FeishuReplyTtsPlatformServiceTests.cs b/WebCodeCli.Domain.Tests/FeishuReplyTtsPlatformServiceTests.cs new file mode 100644 index 0000000..778bfe0 --- /dev/null +++ b/WebCodeCli.Domain.Tests/FeishuReplyTtsPlatformServiceTests.cs @@ -0,0 +1,537 @@ +using Microsoft.Extensions.Options; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model.Channels; +using WebCodeCli.Domain.Domain.Service.Channels; + +namespace WebCodeCli.Domain.Tests; + +public sealed class FeishuReplyTtsPlatformServiceTests +{ + [Fact] + public async Task GetHealthAsync_WhenStorageRootResolutionFails_ReturnsUnavailable() + { + var resolver = CreateUnavailableResolver(); + var ttsClient = new StubSherpaKokoroTtsClient(); + var service = CreateService(resolver, ttsClient); + + var result = await service.GetHealthAsync(TestContext.Current.CancellationToken); + + Assert.False(result.IsAvailable); + Assert.Contains("non-system drive", result.Message, StringComparison.Ordinal); + Assert.Equal(0, ttsClient.HealthCallCount); + } + + [Fact] + public async Task GetHealthAsync_MergesResolverAvailabilityWithLocalServiceHealth() + { + var resolver = CreateAvailableResolver(); + var ttsClient = new StubSherpaKokoroTtsClient + { + HealthResult = new FeishuReplyTtsHealthStatus + { + IsAvailable = true, + ServiceStatus = "ok", + Device = "cpu", + DefaultVoiceId = "service-default" + } + }; + var service = CreateService(resolver, ttsClient); + + var result = await service.GetHealthAsync(TestContext.Current.CancellationToken); + + Assert.True(result.IsAvailable); + Assert.Equal(@"D:\reply-tts", result.StorageRoot); + Assert.Equal(@"D:\reply-tts\models", result.ModelsRoot); + Assert.Equal("ok", result.ServiceStatus); + Assert.Equal("cpu", result.Device); + Assert.Equal("service-default", result.DefaultVoiceId); + } + + [Fact] + public async Task GetHealthAsync_WhenFfmpegCannotBeResolved_ReturnsUnavailable() + { + var service = CreateService( + CreateAvailableResolver(), + new StubSherpaKokoroTtsClient + { + HealthResult = new FeishuReplyTtsHealthStatus + { + IsAvailable = true, + ServiceStatus = "ok" + } + }, + ffmpegExecutablePath: string.Empty); + + var result = await service.GetHealthAsync(TestContext.Current.CancellationToken); + + Assert.False(result.IsAvailable); + Assert.Equal("ffmpeg-unavailable", result.ServiceStatus); + Assert.Contains("ffmpeg executable is unavailable", result.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task GetHealthAsync_WhenConfiguredDefaultVoiceExists_PrefersConfiguredDefault() + { + var service = CreateService( + CreateAvailableResolver(), + new StubSherpaKokoroTtsClient + { + HealthResult = new FeishuReplyTtsHealthStatus + { + IsAvailable = true, + ServiceStatus = "ok", + DefaultVoiceId = "service-default" + } + }, + defaultVoiceId: "configured-default"); + + var result = await service.GetHealthAsync(TestContext.Current.CancellationToken); + + Assert.Equal("configured-default", result.DefaultVoiceId); + } + + [Fact] + public async Task GetHealthAsync_WhenCanceled_PropagatesCancellation() + { + var service = CreateService( + CreateAvailableResolver(), + new StubSherpaKokoroTtsClient + { + HealthException = new OperationCanceledException("canceled") + }); + + await Assert.ThrowsAsync(() => + service.GetHealthAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task EnsureServiceStartedAsync_WhenStorageAndFfmpegAreAvailable_StartsLocalServiceAndReturnsHealth() + { + var ttsClient = new StubSherpaKokoroTtsClient + { + HealthResult = new FeishuReplyTtsHealthStatus + { + IsAvailable = true, + ServiceStatus = "ok", + Device = "cpu", + DefaultVoiceId = "service-default" + } + }; + var localServiceManager = new StubReplyTtsLocalServiceManager + { + Result = new FeishuReplyTtsHealthStatus + { + IsAvailable = true, + ServiceStatus = "ok" + } + }; + var service = CreateService(CreateAvailableResolver(), ttsClient, localServiceManager: localServiceManager); + + var result = await service.EnsureServiceStartedAsync(TestContext.Current.CancellationToken); + + Assert.Equal(1, localServiceManager.EnsureStartedCallCount); + Assert.NotNull(localServiceManager.LastStorageHealth); + Assert.True(result.IsAvailable); + Assert.Equal("ok", result.ServiceStatus); + Assert.Equal("cpu", result.Device); + } + + [Fact] + public async Task EnsureServiceStartedAsync_WhenFfmpegCannotBeResolved_DoesNotStartLocalService() + { + var localServiceManager = new StubReplyTtsLocalServiceManager(); + var service = CreateService( + CreateAvailableResolver(), + new StubSherpaKokoroTtsClient(), + ffmpegExecutablePath: string.Empty, + localServiceManager: localServiceManager); + + var result = await service.EnsureServiceStartedAsync(TestContext.Current.CancellationToken); + + Assert.False(result.IsAvailable); + Assert.Equal("ffmpeg-unavailable", result.ServiceStatus); + Assert.Equal(0, localServiceManager.EnsureStartedCallCount); + } + + [Fact] + public async Task GetVoicesAsync_ReturnsRuntimeVoiceList() + { + var resolver = CreateAvailableResolver(); + var ttsClient = new StubSherpaKokoroTtsClient + { + VoicesResult = + [ + new FeishuReplyTtsVoiceOption + { + VoiceId = "voice-a", + DisplayName = "Voice A" + }, + new FeishuReplyTtsVoiceOption + { + VoiceId = "voice-b", + DisplayName = "Voice B" + } + ] + }; + var service = CreateService(resolver, ttsClient); + + var result = await service.GetVoicesAsync(TestContext.Current.CancellationToken); + + Assert.Collection( + result, + voice => Assert.Equal("voice-a", voice.VoiceId), + voice => Assert.Equal("voice-b", voice.VoiceId)); + } + + [Fact] + public async Task GetVoicesAsync_WhenLocalServiceIsUnreachable_ReturnsEmptyList() + { + var service = CreateService( + CreateAvailableResolver(), + new StubSherpaKokoroTtsClient + { + VoicesException = new HttpRequestException("connection refused") + }); + + var result = await service.GetVoicesAsync(TestContext.Current.CancellationToken); + + Assert.Empty(result); + } + + [Fact] + public async Task GetVoicesAsync_WhenCanceled_PropagatesCancellation() + { + var service = CreateService( + CreateAvailableResolver(), + new StubSherpaKokoroTtsClient + { + VoicesException = new OperationCanceledException("canceled") + }); + + await Assert.ThrowsAsync(() => + service.GetVoicesAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ResolveVoiceOrFallbackAsync_WhenSavedVoiceExists_PrefersSavedVoice() + { + var service = CreateService( + CreateAvailableResolver(), + new StubSherpaKokoroTtsClient + { + HealthResult = new FeishuReplyTtsHealthStatus + { + IsAvailable = true, + ServiceStatus = "ok" + }, + VoicesResult = + [ + new FeishuReplyTtsVoiceOption { VoiceId = "saved-voice", DisplayName = "Saved" }, + new FeishuReplyTtsVoiceOption { VoiceId = "default-voice", DisplayName = "Default" } + ] + }, + defaultVoiceId: "default-voice"); + + var result = await service.ResolveVoiceOrFallbackAsync("saved-voice", TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal("saved-voice", result.VoiceId); + Assert.False(result.UsedFallback); + } + + [Fact] + public async Task ResolveVoiceOrFallbackAsync_WhenSavedVoiceIsMissing_UsesDefaultVoice() + { + var service = CreateService( + CreateAvailableResolver(), + new StubSherpaKokoroTtsClient + { + HealthResult = new FeishuReplyTtsHealthStatus + { + IsAvailable = true, + ServiceStatus = "ok", + DefaultVoiceId = "service-default" + }, + VoicesResult = + [ + new FeishuReplyTtsVoiceOption { VoiceId = "default-zh", DisplayName = "Default" } + ] + }, + defaultVoiceId: "default-zh"); + + var result = await service.ResolveVoiceOrFallbackAsync("missing-voice", TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal("default-zh", result.VoiceId); + Assert.True(result.UsedFallback); + } + + [Fact] + public async Task ResolveVoiceOrFallbackAsync_WhenConfiguredDefaultIsBlank_UsesServiceDefaultVoice() + { + var service = CreateService( + CreateAvailableResolver(), + new StubSherpaKokoroTtsClient + { + HealthResult = new FeishuReplyTtsHealthStatus + { + IsAvailable = true, + ServiceStatus = "ok", + DefaultVoiceId = "service-default" + }, + VoicesResult = + [ + new FeishuReplyTtsVoiceOption { VoiceId = "service-default", DisplayName = "Service Default" } + ] + }); + + var result = await service.ResolveVoiceOrFallbackAsync("missing-voice", TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal("service-default", result.VoiceId); + Assert.True(result.UsedFallback); + } + + [Fact] + public async Task ResolveVoiceOrFallbackAsync_WhenNoSavedOrDefaultVoiceMatches_FailsCleanly() + { + var service = CreateService( + CreateAvailableResolver(), + new StubSherpaKokoroTtsClient + { + HealthResult = new FeishuReplyTtsHealthStatus + { + IsAvailable = true, + ServiceStatus = "ok" + }, + VoicesResult = + [ + new FeishuReplyTtsVoiceOption { VoiceId = "voice-a", DisplayName = "Voice A" } + ] + }, + defaultVoiceId: "default-zh"); + + var result = await service.ResolveVoiceOrFallbackAsync("missing-voice", TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Null(result.VoiceId); + Assert.Contains("No Feishu reply TTS voice", result.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ResolveVoiceOrFallbackAsync_WhenLocalServiceIsUnreachable_FailsCleanly() + { + var service = CreateService( + CreateAvailableResolver(), + new StubSherpaKokoroTtsClient + { + HealthResult = new FeishuReplyTtsHealthStatus + { + IsAvailable = true, + ServiceStatus = "ok" + }, + VoicesException = new HttpRequestException("connection refused") + }, + defaultVoiceId: "configured-default"); + + var result = await service.ResolveVoiceOrFallbackAsync("saved-voice", TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Null(result.VoiceId); + Assert.Equal("Feishu reply TTS voices are currently unavailable.", result.Message); + } + + [Fact] + public async Task ResolveVoiceOrFallbackAsync_WhenFfmpegCannotBeResolved_FailsBeforeSynthesizing() + { + var ttsClient = new StubSherpaKokoroTtsClient + { + VoicesResult = + [ + new FeishuReplyTtsVoiceOption { VoiceId = "voice-a", DisplayName = "Voice A" } + ] + }; + var service = CreateService( + CreateAvailableResolver(), + ttsClient, + ffmpegExecutablePath: string.Empty); + + var result = await service.ResolveVoiceOrFallbackAsync("voice-a", TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Equal(0, ttsClient.HealthCallCount); + Assert.Contains("ffmpeg executable is unavailable", result.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ResolveVoiceOrFallbackAsync_WhenCanceled_PropagatesCancellation() + { + var service = CreateService( + CreateAvailableResolver(), + new StubSherpaKokoroTtsClient + { + HealthResult = new FeishuReplyTtsHealthStatus + { + IsAvailable = true, + ServiceStatus = "ok" + }, + VoicesException = new OperationCanceledException("canceled") + }); + + await Assert.ThrowsAsync(() => + service.ResolveVoiceOrFallbackAsync("saved-voice", TestContext.Current.CancellationToken)); + } + + private static FeishuReplyTtsPlatformService CreateService( + ReplyTtsStorageRootResolver resolver, + StubSherpaKokoroTtsClient ttsClient, + string? defaultVoiceId = null, + string? ffmpegExecutablePath = "ffmpeg", + StubReplyTtsLocalServiceManager? localServiceManager = null) + { + return new FeishuReplyTtsPlatformService( + resolver, + Options.Create(new FeishuReplyTtsOptions + { + TtsDefaultVoiceId = defaultVoiceId, + FfmpegExecutablePath = ffmpegExecutablePath + }), + ttsClient, + localServiceManager ?? new StubReplyTtsLocalServiceManager()); + } + + private static ReplyTtsStorageRootResolver CreateAvailableResolver() + { + return new ReplyTtsStorageRootResolver( + new MutableOptionsMonitor(new FeishuReplyTtsOptions + { + TtsStorageRoot = @"D:\reply-tts" + }), + new FakeReplyTtsHostEnvironment( + isWindows: true, + systemDriveRoot: @"C:\", + drives: + [ + new ReplyTtsDriveDescriptor(@"D:\", isReady: true, isWritable: true) + ])); + } + + private static ReplyTtsStorageRootResolver CreateUnavailableResolver() + { + return new ReplyTtsStorageRootResolver( + new MutableOptionsMonitor(new FeishuReplyTtsOptions + { + }), + new FakeReplyTtsHostEnvironment( + isWindows: true, + systemDriveRoot: @"C:\", + drives: + [ + new ReplyTtsDriveDescriptor(@"C:\", isReady: true, isWritable: true) + ])); + } + + private sealed class StubSherpaKokoroTtsClient : ISherpaKokoroTtsClient + { + public int HealthCallCount { get; private set; } + + public FeishuReplyTtsHealthStatus HealthResult { get; set; } = new(); + + public IReadOnlyList VoicesResult { get; set; } = []; + + public Exception? HealthException { get; set; } + + public Exception? VoicesException { get; set; } + + public Task GetHealthAsync(CancellationToken cancellationToken = default) + { + HealthCallCount++; + if (HealthException is not null) + { + throw HealthException; + } + + return Task.FromResult(HealthResult); + } + + public Task> GetVoicesAsync(CancellationToken cancellationToken = default) + { + if (VoicesException is not null) + { + throw VoicesException; + } + + return Task.FromResult(VoicesResult); + } + + public Task SynthesizeAsync(string text, string voiceId, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + } + + private sealed class StubReplyTtsLocalServiceManager : IReplyTtsLocalServiceManager + { + public int EnsureStartedCallCount { get; private set; } + + public FeishuReplyTtsHealthStatus? LastStorageHealth { get; private set; } + + public FeishuReplyTtsHealthStatus Result { get; set; } = new() + { + IsAvailable = true, + ServiceStatus = "ok" + }; + + public Task EnsureStartedAsync( + FeishuReplyTtsHealthStatus storageHealth, + CancellationToken cancellationToken = default) + { + EnsureStartedCallCount++; + LastStorageHealth = storageHealth; + return Task.FromResult(Result); + } + } + + private sealed class FakeReplyTtsHostEnvironment : IReplyTtsHostEnvironment + { + private readonly IReadOnlyList _drives; + + public FakeReplyTtsHostEnvironment( + bool isWindows, + string? systemDriveRoot, + IReadOnlyList drives) + { + IsWindows = isWindows; + SystemDriveRoot = systemDriveRoot; + _drives = drives; + } + + public bool IsWindows { get; } + + public string? SystemDriveRoot { get; } + + public IReadOnlyList GetFixedDrives() + { + return _drives; + } + + public bool DirectoryExists(string path) + { + return Directory.Exists(path); + } + + public bool FileExists(string path) + { + return File.Exists(path); + } + } + + private sealed class MutableOptionsMonitor(TOptions currentValue) : IOptionsMonitor + { + public TOptions CurrentValue { get; private set; } = currentValue; + + public TOptions Get(string? name) => CurrentValue; + + public IDisposable? OnChange(Action listener) => null; + } +} diff --git a/WebCodeCli.Domain.Tests/GoalCapabilityServiceTests.cs b/WebCodeCli.Domain.Tests/GoalCapabilityServiceTests.cs new file mode 100644 index 0000000..d95e585 --- /dev/null +++ b/WebCodeCli.Domain.Tests/GoalCapabilityServiceTests.cs @@ -0,0 +1,221 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model; +using WebCodeCli.Domain.Domain.Service; + +namespace WebCodeCli.Domain.Tests; + +public sealed class GoalCapabilityServiceTests +{ + [Fact] + public async Task ProbeAsync_ReturnsUnavailable_WhenToolIsNotCodex() + { + var service = CreateService( + versionOutput: "codex-cli 0.128.0", + featureOutput: "goals under development false"); + + var result = await service.ProbeAsync(new GoalCapabilityContext + { + ToolId = "claude-code", + ProviderId = "provider-a" + }); + + Assert.Equal(GoalCapabilityState.Unavailable, result.State); + Assert.Equal(GoalCapabilityProbeOutcome.UnsupportedTool, result.Outcome); + } + + [Fact] + public async Task ProbeAsync_ReturnsUnavailable_WhenVersionIsTooLow() + { + var service = CreateService( + versionOutput: "codex-cli 0.127.0", + featureOutput: "goals under development false"); + + var result = await service.ProbeAsync(new GoalCapabilityContext + { + ToolId = "codex", + ProviderId = "provider-b" + }); + + Assert.Equal(GoalCapabilityState.Unavailable, result.State); + Assert.Equal(GoalCapabilityProbeOutcome.UnsupportedVersion, result.Outcome); + Assert.Equal("0.127.0", result.DetectedVersion); + } + + [Fact] + public async Task ProbeAsync_ReturnsUnavailable_WhenFeatureIsMissing() + { + var service = CreateService( + versionOutput: "codex-cli 0.128.0", + featureOutput: """ + multi_agent stable true + reasoning_summaries stable true + """); + + var result = await service.ProbeAsync(new GoalCapabilityContext + { + ToolId = "codex", + ProviderId = "provider-c" + }); + + Assert.Equal(GoalCapabilityState.Unavailable, result.State); + Assert.Equal(GoalCapabilityProbeOutcome.MissingFeature, result.Outcome); + Assert.Equal("0.128.0", result.DetectedVersion); + } + + [Fact] + public async Task ProbeAsync_ReturnsAvailable_WhenVersionAndFeatureMatch() + { + var service = CreateService( + versionOutput: "codex-cli 0.128.0", + featureOutput: """ + goals under development false + multi_agent stable true + """); + + var result = await service.ProbeAsync(new GoalCapabilityContext + { + ToolId = "codex", + ProviderId = "provider-d" + }); + + Assert.Equal(GoalCapabilityState.Available, result.State); + Assert.Equal(GoalCapabilityProbeOutcome.Available, result.Outcome); + Assert.Equal("0.128.0", result.DetectedVersion); + Assert.True(result.HasGoalsFeature); + } + + [Fact] + public async Task ProbeAsync_UsesCache_UntilForceRefresh() + { + var service = CreateService( + versionOutput: "codex-cli 0.127.0", + featureOutput: "goals under development false"); + var context = new GoalCapabilityContext + { + ToolId = "codex", + ProviderId = "provider-e" + }; + + var initial = await service.ProbeAsync(context); + Assert.Equal(GoalCapabilityState.Unavailable, initial.State); + + service.VersionOutput = "codex-cli 0.128.0"; + service.FeatureOutput = "goals under development false"; + + var cached = await service.ProbeAsync(context); + Assert.True(cached.FromCache); + Assert.Equal(GoalCapabilityState.Unavailable, cached.State); + + var refreshed = await service.ProbeAsync(context, forceRefresh: true); + Assert.False(refreshed.FromCache); + Assert.Equal(GoalCapabilityState.Available, refreshed.State); + } + + private static TestGoalCapabilityService CreateService(string versionOutput, string featureOutput) + { + return new TestGoalCapabilityService( + new StubCcSwitchService(), + Options.Create(new CliToolsOption + { + Tools = + [ + new CliToolConfig + { + Id = "codex", + Name = "Codex", + Command = "codex" + } + ] + }), + NullLogger.Instance) + { + VersionOutput = versionOutput, + FeatureOutput = featureOutput + }; + } + + private sealed class TestGoalCapabilityService : GoalCapabilityService + { + public TestGoalCapabilityService( + ICcSwitchService ccSwitchService, + IOptions options, + Microsoft.Extensions.Logging.ILogger logger) + : base(ccSwitchService, options, logger) + { + } + + public string VersionOutput { get; set; } = "codex-cli 0.128.0"; + + public string FeatureOutput { get; set; } = "goals under development false"; + + protected override Task RunCommandAsync( + string command, + string arguments, + string? workingDirectory, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (arguments == "--version") + { + return Task.FromResult(new CommandProbeResult(true, VersionOutput)); + } + + if (arguments == "features list") + { + return Task.FromResult(new CommandProbeResult(true, FeatureOutput)); + } + + return Task.FromResult(new CommandProbeResult(false, string.Empty)); + } + } + + private sealed class StubCcSwitchService : ICcSwitchService + { + public bool IsManagedTool(string toolId) + { + return toolId is "claude-code" or "codex" or "opencode"; + } + + public Task GetStatusAsync(CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task GetToolStatusAsync(string toolId, CancellationToken cancellationToken = default) + { + return Task.FromResult(new CcSwitchToolStatus + { + ToolId = toolId, + ActiveProviderId = $"{toolId}-provider", + IsManaged = true, + IsLaunchReady = true + }); + } + + public Task> GetToolStatusesAsync(IEnumerable toolIds, CancellationToken cancellationToken = default) + { + IReadOnlyDictionary result = toolIds + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToDictionary( + toolId => toolId, + toolId => new CcSwitchToolStatus + { + ToolId = toolId, + ActiveProviderId = $"{toolId}-provider", + IsManaged = true, + IsLaunchReady = true + }, + StringComparer.OrdinalIgnoreCase); + + return Task.FromResult(result); + } + + public Task GetModelCatalogAsync(string toolId, string? providerId = null, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + } +} diff --git a/WebCodeCli.Domain.Tests/GoalPromptBuilderTests.cs b/WebCodeCli.Domain.Tests/GoalPromptBuilderTests.cs new file mode 100644 index 0000000..23abd9d --- /dev/null +++ b/WebCodeCli.Domain.Tests/GoalPromptBuilderTests.cs @@ -0,0 +1,25 @@ +using WebCodeCli.Domain.Domain.Model; +using WebCodeCli.Domain.Domain.Service; + +namespace WebCodeCli.Domain.Tests; + +public sealed class GoalPromptBuilderTests +{ + [Theory] + [InlineData("整理这个目标", "/goal 整理这个目标")] + [InlineData("/goal 整理这个目标", "/goal 整理这个目标")] + [InlineData(" 整理这个目标 ", "/goal 整理这个目标")] + public void BuildGoalPrompt_AppliesPrefixOnlyWhenMissing(string input, string expected) + { + Assert.Equal(expected, GoalPromptBuilder.BuildGoalPrompt(input)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void BuildGoalPrompt_ReturnsNullForBlankInput(string? input) + { + Assert.Null(GoalPromptBuilder.BuildGoalPrompt(input)); + } +} diff --git a/WebCodeCli.Domain.Tests/ReplyTtsChunkerTests.cs b/WebCodeCli.Domain.Tests/ReplyTtsChunkerTests.cs new file mode 100644 index 0000000..5e17389 --- /dev/null +++ b/WebCodeCli.Domain.Tests/ReplyTtsChunkerTests.cs @@ -0,0 +1,112 @@ +using WebCodeCli.Domain.Domain.Service.Channels; + +namespace WebCodeCli.Domain.Tests; + +public sealed class ReplyTtsChunkerTests +{ + [Fact] + public void Split_WhenParagraphsFitLimit_KeepsSingleChunk() + { + var chunker = new ReplyTtsChunker(maxChars: 80); + + var chunks = chunker.Split("第一段很短。\n\n第二段也很短。"); + + Assert.Collection( + chunks, + chunk => Assert.Equal("第一段很短。\n\n第二段也很短。", chunk)); + } + + [Fact] + public void Split_WhenParagraphBoundaryFitsBetter_PrefersParagraphChunksBeforeSentenceFallback() + { + var chunker = new ReplyTtsChunker(maxChars: 10); + + var chunks = chunker.Split("第一段很短。\n\n第二段也短。"); + + Assert.Collection( + chunks, + chunk => Assert.Equal("第一段很短。", chunk), + chunk => Assert.Equal("第二段也短。", chunk)); + } + + [Fact] + public void Split_WhenParagraphExceedsLimit_SplitsOnSentenceBoundariesFirst() + { + var chunker = new ReplyTtsChunker(maxChars: 10); + + var chunks = chunker.Split("第一句很短。第二句也短。第三句也短。"); + + Assert.Collection( + chunks, + chunk => Assert.Equal("第一句很短。", chunk), + chunk => Assert.Equal("第二句也短。", chunk), + chunk => Assert.Equal("第三句也短。", chunk)); + } + + [Fact] + public void Split_WhenStructuredShortLinesFitLimit_KeepsLargerChunkForPrimaryPass() + { + var chunker = new ReplyTtsChunker(maxChars: 120); + + var chunks = chunker.Split( + """ + 顶部区域只放公共字段: + 条码 + 托盘号 + 当前库位 + 分区(可改,下拉) + """); + + Assert.Collection( + chunks, + chunk => Assert.Equal("顶部区域只放公共字段:\n条码\n托盘号\n当前库位\n分区(可改,下拉)", chunk)); + } + + [Fact] + public void SplitForRetry_WhenStructuredShortLines_GroupsAdjacentLinesIntoPairs() + { + var chunker = new ReplyTtsChunker(maxChars: 120); + + var chunks = chunker.SplitForRetry( + """ + 顶部区域只放公共字段: + 条码 + 托盘号 + 当前库位 + 分区(可改,下拉) + """); + + Assert.Collection( + chunks, + chunk => Assert.Equal("顶部区域只放公共字段:\n条码", chunk), + chunk => Assert.Equal("托盘号\n当前库位", chunk), + chunk => Assert.Equal("分区(可改,下拉)", chunk)); + } + + [Fact] + public void SplitForRetry_WhenOnlyTwoStructuredLines_RemainsAbleToSplitToSingles() + { + var chunker = new ReplyTtsChunker(maxChars: 120); + + var chunks = chunker.SplitForRetry("第一句。\n第二句。"); + + Assert.Collection( + chunks, + chunk => Assert.Equal("第一句。", chunk), + chunk => Assert.Equal("第二句。", chunk)); + } + + [Fact] + public void SplitForRetry_WhenSingleParagraphStillNeedsSmallerChunks_SplitsBySentence() + { + var chunker = new ReplyTtsChunker(maxChars: 120); + + var chunks = chunker.SplitForRetry("第一句很短。第二句也很短。第三句也很短。"); + + Assert.Collection( + chunks, + chunk => Assert.Equal("第一句很短。", chunk), + chunk => Assert.Equal("第二句也很短。", chunk), + chunk => Assert.Equal("第三句也很短。", chunk)); + } +} diff --git a/WebCodeCli.Domain.Tests/ReplyTtsOrchestratorTests.cs b/WebCodeCli.Domain.Tests/ReplyTtsOrchestratorTests.cs new file mode 100644 index 0000000..bcd6989 --- /dev/null +++ b/WebCodeCli.Domain.Tests/ReplyTtsOrchestratorTests.cs @@ -0,0 +1,619 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model.Channels; +using WebCodeCli.Domain.Domain.Service; +using WebCodeCli.Domain.Domain.Service.Channels; +using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; + +namespace WebCodeCli.Domain.Tests; + +public sealed class ReplyTtsOrchestratorTests +{ + [Fact] + public async Task QueueCompletedReplyAsync_SkipsWhenReplyTtsDisabled() + { + using var harness = new ReplyTtsOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + ReplyTtsEnabled = false + }); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest + { + ChatId = "oc-disabled-chat", + Username = "luhaiyan", + Output = "需要播报" + }); + + await WaitUntilAsync(() => harness.ConfigService.UsernameLookupCount == 1); + + Assert.Empty(harness.KokoroClient.Calls); + Assert.Empty(harness.TranscodeService.Calls); + Assert.Empty(harness.AudioService.Calls); + Assert.Equal(0, harness.CardKit.SendTextCallCount); + } + + [Theory] + [InlineData("")] + [InlineData("# **__**")] + public async Task QueueCompletedReplyAsync_SkipsWhenNormalizedOutputIsEmpty(string output) + { + using var harness = new ReplyTtsOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + ReplyTtsEnabled = true + }); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest + { + ChatId = "oc-empty-chat", + Username = "luhaiyan", + Output = output + }); + + await WaitUntilAsync(() => harness.ConfigService.UsernameLookupCount == 1); + + Assert.Empty(harness.KokoroClient.Calls); + Assert.Empty(harness.TranscodeService.Calls); + Assert.Empty(harness.AudioService.Calls); + Assert.Equal(0, harness.CardKit.SendTextCallCount); + } + + [Fact] + public async Task QueueCompletedReplyAsync_FallsBackToDefaultVoiceAndProcessesChunksInOrder() + { + using var harness = new ReplyTtsOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + ReplyTtsEnabled = true, + ReplyTtsVoiceId = "missing-voice" + }, + chunkMaxChars: 20); + + harness.PlatformService.Resolution = new FeishuReplyTtsVoiceResolutionResult + { + Success = true, + VoiceId = "platform-default", + UsedFallback = true, + Voice = new FeishuReplyTtsVoiceOption + { + VoiceId = "platform-default", + DisplayName = "Platform Default" + } + }; + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest + { + ChatId = "oc-order-chat", + Username = "luhaiyan", + Output = "first paragraph.\n\nsecond paragraph." + }); + + await WaitUntilAsync(() => harness.AudioService.Calls.Count == 2); + await WaitUntilAsync(() => harness.JobTempRoot is not null && (!Directory.Exists(harness.JobTempRoot) || !Directory.EnumerateDirectories(harness.JobTempRoot).Any())); + + Assert.Collection( + harness.Sequence, + item => Assert.Equal("synthesize:1:platform-default:first paragraph.", item), + item => Assert.Equal("transcode:1", item), + item => Assert.Equal("send:1", item), + item => Assert.Equal("synthesize:2:platform-default:second paragraph.", item), + item => Assert.Equal("transcode:2", item), + item => Assert.Equal("send:2", item)); + + Assert.All(harness.KokoroClient.Calls, call => Assert.Equal("platform-default", call.VoiceId)); + Assert.All(harness.AudioService.Calls, call => Assert.True(call.DurationMs > 0)); + Assert.Equal(0, harness.CardKit.SendTextCallCount); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenSynthesisFails_StopsAudioWithoutRetryAndSendsTextFallback() + { + using var harness = new ReplyTtsOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + ReplyTtsEnabled = true + }); + + harness.KokoroClient.FailureCondition = static _ => true; + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest + { + ChatId = "oc-synth-failure-chat", + Username = "luhaiyan", + Output = "first line.\nsecond line.\nthird line." + }); + + await WaitUntilAsync(() => harness.KokoroClient.Calls.Count >= 1); + await Task.Delay(150, TestContext.Current.CancellationToken); + + Assert.Single(harness.KokoroClient.Calls); + Assert.Empty(harness.TranscodeService.Calls); + Assert.Empty(harness.AudioService.Calls); + Assert.Equal(1, harness.CardKit.SendTextCallCount); + Assert.Equal("回复语音发送失败,已停止后续音频。", harness.CardKit.TextMessages.Single()); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenAudioSendPipelineFails_StopsRemainingAudioAndSendsTextFallback() + { + using var harness = new ReplyTtsOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + ReplyTtsEnabled = true + }, + chunkMaxChars: 20); + + harness.TranscodeService.FailChunkIndex = 2; + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest + { + ChatId = "oc-pipeline-failure-chat", + Username = "luhaiyan", + Output = "first paragraph.\n\nsecond paragraph.\n\nthird paragraph." + }); + + await WaitUntilAsync(() => harness.TranscodeService.Calls.Count == 2); + await Task.Delay(150, TestContext.Current.CancellationToken); + + Assert.Equal(2, harness.KokoroClient.Calls.Count); + Assert.Equal(2, harness.TranscodeService.Calls.Count); + Assert.Single(harness.AudioService.Calls); + Assert.Equal(1, harness.CardKit.SendTextCallCount); + Assert.Equal("回复语音发送失败,已停止后续音频。", harness.CardKit.TextMessages.Single()); + Assert.DoesNotContain(harness.Sequence, item => item == "synthesize:3:default-voice:third paragraph."); + } + + [Fact] + public async Task QueueCompletedReplyAsync_WhenSynthesisTimesOut_RetriesWithSmallerChunksBeforeFallback() + { + using var harness = new ReplyTtsOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + ReplyTtsEnabled = true + }, + chunkMaxChars: 120); + + harness.KokoroClient.FailureFactory = static text => + text.Length > 20 + ? new TaskCanceledException("simulated synth timeout") + : null; + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest + { + ChatId = "oc-timeout-retry-chat", + Username = "luhaiyan", + Output = + """ + 顶部区域只放公共字段: + 条码 + 托盘号 + 当前库位 + 分区(可改,下拉) + """ + }); + + await Task.Delay(500, TestContext.Current.CancellationToken); + + Assert.Equal(3, harness.KokoroClient.Calls.Count); + Assert.Equal(2, harness.TranscodeService.Calls.Count); + Assert.Equal(2, harness.AudioService.Calls.Count); + Assert.Equal(0, harness.CardKit.SendTextCallCount); + Assert.Collection( + harness.KokoroClient.Calls.Select(static call => call.Text), + call => Assert.Equal("顶部区域只放公共字段:\n条码托盘号当前库位分区(可改,下拉)", call), + call => Assert.Equal("顶部区域只放公共字段:", call), + call => Assert.Equal("条码托盘号当前库位分区(可改,下拉)", call)); + } + + [Fact] + public async Task QueueCompletedReplyAsync_SerializesJobsPerChat() + { + using var harness = new ReplyTtsOrchestratorHarness( + new UserFeishuBotConfigEntity + { + Username = "luhaiyan", + ReplyTtsEnabled = true + }); + + harness.AudioService.BlockFirstSend = true; + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest + { + ChatId = "oc-serialized-chat", + Username = "luhaiyan", + Output = "first reply" + }); + + await harness.AudioService.FirstSendStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + await harness.Orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest + { + ChatId = "oc-serialized-chat", + Username = "luhaiyan", + Output = "second reply" + }); + + await Task.Delay(150, TestContext.Current.CancellationToken); + + Assert.Single(harness.KokoroClient.Calls); + + harness.AudioService.ReleaseFirstSend(); + + await WaitUntilAsync(() => harness.AudioService.Calls.Count == 2); + + Assert.Collection( + harness.KokoroClient.Calls, + first => Assert.Equal("first reply", first.Text), + second => Assert.Equal("second reply", second.Text)); + } + + private static async Task WaitUntilAsync(Func condition, int timeoutMs = 5000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (DateTime.UtcNow < deadline) + { + if (condition()) + { + return; + } + + await Task.Delay(25, TestContext.Current.CancellationToken); + } + + Assert.True(condition(), "Timed out waiting for the expected condition."); + } + + private sealed class ReplyTtsOrchestratorHarness : IDisposable + { + private readonly ServiceProvider _serviceProvider; + + public ReplyTtsOrchestratorHarness(UserFeishuBotConfigEntity config, int chunkMaxChars = 1200) + { + TempRoot = Path.Combine(Path.GetTempPath(), $"reply-tts-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(TempRoot); + + ConfigService = new TrackingUserFeishuBotConfigService(config); + PlatformService = new TrackingReplyTtsPlatformService(); + KokoroClient = new TrackingSherpaKokoroTtsClient(Sequence); + TranscodeService = new TrackingAudioTranscodeService(Sequence); + AudioService = new TrackingFeishuAudioMessageService(Sequence); + CardKit = new TrackingFeishuCardKitClient(); + + var services = new ServiceCollection(); + services.AddSingleton(new ReplyTtsStorageRootResolver( + new StaticOptionsMonitor(new FeishuReplyTtsOptions + { + TtsStorageRoot = TempRoot + }), + new FakeReplyTtsHostEnvironment( + isWindows: false, + systemDriveRoot: null, + drives: []))); + services.AddSingleton(); + services.AddScoped(_ => new ReplyTtsChunker(chunkMaxChars)); + services.AddScoped(_ => PlatformService); + services.AddScoped(_ => KokoroClient); + services.AddScoped(_ => TranscodeService); + services.AddScoped(_ => AudioService); + services.AddScoped(_ => ConfigService); + services.AddScoped(_ => CardKit); + services.AddLogging(); + + _serviceProvider = services.BuildServiceProvider(); + Orchestrator = new ReplyTtsOrchestrator( + _serviceProvider, + _serviceProvider.GetRequiredService(), + NullLogger.Instance); + } + + public ConcurrentQueue Sequence { get; } = new(); + + public string TempRoot { get; } + + public string JobTempRoot => Path.Combine(TempRoot, "temp"); + + public TrackingUserFeishuBotConfigService ConfigService { get; } + + public TrackingReplyTtsPlatformService PlatformService { get; } + + public TrackingSherpaKokoroTtsClient KokoroClient { get; } + + public TrackingAudioTranscodeService TranscodeService { get; } + + public TrackingFeishuAudioMessageService AudioService { get; } + + public TrackingFeishuCardKitClient CardKit { get; } + + public ReplyTtsOrchestrator Orchestrator { get; } + + public void Dispose() + { + _serviceProvider.Dispose(); + if (Directory.Exists(TempRoot)) + { + Directory.Delete(TempRoot, recursive: true); + } + } + } + + private sealed class TrackingUserFeishuBotConfigService(UserFeishuBotConfigEntity config) : IUserFeishuBotConfigService + { + public int UsernameLookupCount { get; private set; } + + public Task GetByUsernameAsync(string username) + { + UsernameLookupCount++; + return Task.FromResult(string.Equals(username, config.Username, StringComparison.OrdinalIgnoreCase) + ? config + : null); + } + + public Task GetByAppIdAsync(string appId) + => Task.FromResult(null); + + public Task SaveAsync(UserFeishuBotConfigEntity configEntity) + => throw new NotSupportedException(); + + public Task DeleteAsync(string username) => Task.FromResult(true); + + 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, + AppId = "shared-app-id", + AppSecret = "shared-secret" + }; + + public Task GetEffectiveOptionsAsync(string? username) => Task.FromResult(GetSharedDefaults()); + + public Task GetEffectiveOptionsByAppIdAsync(string? appId) + => Task.FromResult(null); + } + + private sealed class FakeReplyTtsHostEnvironment( + bool isWindows, + string? systemDriveRoot, + IReadOnlyList drives) : IReplyTtsHostEnvironment + { + public bool IsWindows { get; } = isWindows; + + public string? SystemDriveRoot { get; } = systemDriveRoot; + + public IReadOnlyList GetFixedDrives() => drives; + + public bool DirectoryExists(string path) => Directory.Exists(path); + + public bool FileExists(string path) => File.Exists(path); + } + + private sealed class TrackingReplyTtsPlatformService : IFeishuReplyTtsPlatformService + { + public FeishuReplyTtsVoiceResolutionResult Resolution { get; set; } = new() + { + Success = true, + VoiceId = "default-voice", + Voice = new FeishuReplyTtsVoiceOption + { + VoiceId = "default-voice", + DisplayName = "Default Voice" + } + }; + + public Task GetHealthAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> GetVoicesAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task ResolveVoiceOrFallbackAsync(string? savedVoiceId, CancellationToken cancellationToken = default) + => Task.FromResult(Resolution); + + public Task EnsureServiceStartedAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + } + + private sealed class TrackingSherpaKokoroTtsClient(ConcurrentQueue sequence) : ISherpaKokoroTtsClient + { + public List Calls { get; } = new(); + + public Func? FailureCondition { get; set; } + + public Func? FailureFactory { get; set; } + + public Task GetHealthAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> GetVoicesAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task SynthesizeAsync(string text, string voiceId, CancellationToken cancellationToken = default) + { + var call = new SynthesizeCall(text, voiceId); + Calls.Add(call); + sequence.Enqueue($"synthesize:{Calls.Count}:{voiceId}:{text}"); + + var failure = FailureFactory?.Invoke(text); + if (failure != null) + { + throw failure; + } + + if (FailureCondition?.Invoke(text) == true) + { + throw new HttpRequestException("simulated synth failure"); + } + + return Task.FromResult(new MemoryStream(CreateWaveBytes(durationMs: 1000))); + } + } + + private sealed class TrackingAudioTranscodeService(ConcurrentQueue sequence) : IAudioTranscodeService + { + public int? FailChunkIndex { get; set; } + + public List Calls { get; } = new(); + + public Task TranscodeChunkAsync(string jobId, string inputWavPath, int chunkIndex, CancellationToken cancellationToken = default) + { + Calls.Add(new TranscodeCall(jobId, inputWavPath, chunkIndex)); + sequence.Enqueue($"transcode:{chunkIndex}"); + + if (FailChunkIndex == chunkIndex) + { + throw new InvalidOperationException($"chunk {chunkIndex} failed"); + } + + var outputPath = Path.Combine(Path.GetDirectoryName(inputWavPath)!, $"chunk-{chunkIndex:000}.opus"); + File.WriteAllBytes(outputPath, [1, 2, 3, 4]); + return Task.FromResult(outputPath); + } + } + + private sealed class TrackingFeishuAudioMessageService(ConcurrentQueue sequence) : IFeishuAudioMessageService + { + private readonly TaskCompletionSource _releaseFirstSend = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public bool BlockFirstSend { get; set; } + + public TaskCompletionSource FirstSendStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public List Calls { get; } = new(); + + public async Task SendAudioMessageAsync( + string chatId, + string filePath, + int durationMs, + string? username = null, + string? appId = null, + CancellationToken cancellationToken = default) + { + var call = new AudioSendCall(chatId, filePath, durationMs, username, appId); + Calls.Add(call); + sequence.Enqueue($"send:{Calls.Count}"); + + if (BlockFirstSend && Calls.Count == 1) + { + FirstSendStarted.TrySetResult(true); + await _releaseFirstSend.Task.WaitAsync(cancellationToken); + } + + return $"audio-{Calls.Count}"; + } + + public void ReleaseFirstSend() + { + _releaseFirstSend.TrySetResult(true); + } + } + + private sealed class TrackingFeishuCardKitClient : IFeishuCardKitClient + { + public int SendTextCallCount { get; private set; } + + public List TextMessages { get; } = new(); + + public Task CreateCardAsync(string initialContent, string? title = null, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); + + public Task UpdateCardAsync(string cardId, string content, int sequence, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); + + public Task SendCardMessageAsync(string chatId, string cardId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); + + public Task SendTextMessageAsync(string chatId, string content, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + { + SendTextCallCount++; + TextMessages.Add(content); + return Task.FromResult($"text-{SendTextCallCount}"); + } + + public Task ReplyCardMessageAsync(string replyMessageId, string cardId, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); + + public Task ReplyTextMessageAsync(string replyMessageId, string content, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); + + public Task UploadAudioFileAsync(string filePath, int durationMs, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); + + public Task SendAudioMessageAsync(string chatId, string fileKey, int durationMs, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); + + public Task CreateStreamingHandleAsync(string chatId, string? replyMessageId, string initialContent, string? title = null, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null, FeishuStreamingCardChrome? chrome = null) + => throw new NotSupportedException(); + + public Task SendRawCardAsync(string chatId, string cardJson, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); + + public Task ReplyElementsCardAsync(string replyMessageId, FeishuNetSdk.Im.Dtos.ElementsCardV2Dto card, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); + + public Task ReplyRawCardAsync(string replyMessageId, string cardJson, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null) + => throw new NotSupportedException(); + } + + private sealed class StaticOptionsMonitor(T currentValue) : IOptionsMonitor + { + public T CurrentValue => currentValue; + + public T Get(string? name) => currentValue; + + public IDisposable? OnChange(Action listener) => null; + } + + private sealed record SynthesizeCall(string Text, string VoiceId); + + private sealed record TranscodeCall(string JobId, string InputWavPath, int ChunkIndex); + + private sealed record AudioSendCall(string ChatId, string FilePath, int DurationMs, string? Username, string? AppId); + + private static byte[] CreateWaveBytes(int durationMs) + { + const short channels = 1; + const int sampleRate = 16000; + const short bitsPerSample = 16; + var bytesPerSample = bitsPerSample / 8; + var sampleCount = sampleRate * durationMs / 1000; + var dataSize = sampleCount * channels * bytesPerSample; + var byteRate = sampleRate * channels * bytesPerSample; + var blockAlign = (short)(channels * bytesPerSample); + + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream); + writer.Write("RIFF"u8.ToArray()); + writer.Write(36 + dataSize); + writer.Write("WAVE"u8.ToArray()); + writer.Write("fmt "u8.ToArray()); + writer.Write(16); + writer.Write((short)1); + writer.Write(channels); + writer.Write(sampleRate); + writer.Write(byteRate); + writer.Write(blockAlign); + writer.Write(bitsPerSample); + writer.Write("data"u8.ToArray()); + writer.Write(dataSize); + writer.Write(new byte[dataSize]); + writer.Flush(); + return stream.ToArray(); + } +} diff --git a/WebCodeCli.Domain.Tests/ReplyTtsSpeechTextNormalizerTests.cs b/WebCodeCli.Domain.Tests/ReplyTtsSpeechTextNormalizerTests.cs new file mode 100644 index 0000000..5d683e9 --- /dev/null +++ b/WebCodeCli.Domain.Tests/ReplyTtsSpeechTextNormalizerTests.cs @@ -0,0 +1,116 @@ +using WebCodeCli.Domain.Domain.Service.Channels; + +namespace WebCodeCli.Domain.Tests; + +public sealed class ReplyTtsSpeechTextNormalizerTests +{ + [Fact] + public void Normalize_RemovesMarkdownLinksAndCodeBlocks() + { + var normalizer = new ReplyTtsSpeechTextNormalizer(); + + var result = normalizer.Normalize( + """ + # Heading + + - **Bold** item with [docs](https://example.com/docs) + Visit https://example.com/raw for more. + + ```csharp + Console.WriteLine("hi"); + ``` + """); + + var expected = """ + Heading + Bold item with docs + Visit this link for more. + + Code snippet omitted. + """ + .ReplaceLineEndings("\n") + .Trim(); + + Assert.Equal(expected, result); + } + + [Fact] + public void Normalize_ReplacesInlineTechnicalReferencesWithFileClassAndMethodNames() + { + var normalizer = new ReplyTtsSpeechTextNormalizer(); + + var result = normalizer.Normalize( + """ + Check `Cimc.Tianda.Wms.Web/src/views/mobile/receiving/index.vue:125`. + Fields come from `lotAttr1 / lotAttr8 / attr1 / attr2 / containerAttr2`. + Run `dotnet test WebCodeCli.Domain.Tests`. + Call `WebCodeCli.Domain.Domain.Service.Channels.SherpaKokoroTtsClient.SynthesizeAsync`. + Call `GetStockByContainerCodeOrBarcode(containerCodeOrBarcode)`. + """); + + Assert.Contains("index.vue 文件", result, StringComparison.Ordinal); + Assert.Contains("若干属性字段", result, StringComparison.Ordinal); + Assert.Contains("相关命令", result, StringComparison.Ordinal); + Assert.Contains("SherpaKokoroTtsClient 类 SynthesizeAsync 方法", result, StringComparison.Ordinal); + Assert.Contains("GetStockByContainerCodeOrBarcode 方法", result, StringComparison.Ordinal); + Assert.DoesNotContain("Cimc.Tianda.Wms.Web/src/views/mobile/receiving/index.vue", result, StringComparison.Ordinal); + Assert.DoesNotContain("WebCodeCli.Domain.Domain.Service.Channels.SherpaKokoroTtsClient.SynthesizeAsync", result, StringComparison.Ordinal); + } + + [Fact] + public void Normalize_TreatsBareCodeFileNamesAsFiles() + { + var normalizer = new ReplyTtsSpeechTextNormalizer(); + + var result = normalizer.Normalize( + """ + ConfigurationPublicationControllerTests.cs、ConfigurationPublicationDeliveryInboxControllerTests.cs、 + ConfigurationPublicationDispatchAuditControllerTests.cs、ConfigurationPublicationApiIntegrationTests.cs、 + VersionGovernanceApiIntegrationTests.cs 这几组测试继续收口。 + """); + + Assert.Contains("ConfigurationPublicationControllerTests.cs 文件", result, StringComparison.Ordinal); + Assert.Contains("ConfigurationPublicationDeliveryInboxControllerTests.cs 文件", result, StringComparison.Ordinal); + Assert.Contains("ConfigurationPublicationDispatchAuditControllerTests.cs 文件", result, StringComparison.Ordinal); + Assert.Contains("ConfigurationPublicationApiIntegrationTests.cs 文件", result, StringComparison.Ordinal); + Assert.Contains("VersionGovernanceApiIntegrationTests.cs 文件", result, StringComparison.Ordinal); + Assert.DoesNotContain(".cs 方法", result, StringComparison.Ordinal); + } + + [Fact] + public void Normalize_GenericallyHandlesCodeLikeIdentifiersWithoutDomainSentenceTemplates() + { + var normalizer = new ReplyTtsSpeechTextNormalizer(); + + var result = normalizer.Normalize( + """ + 第二步,后端收货入库事务改造,在 1397 重写 MoveInStereo,调用 GetStockByContainerCodeOrBarcode,并检查 container.Type。 + 继续按 superpowers:brainstorming 收尾设计,不进代码。 + """); + + Assert.Contains("MoveInStereo 方法", result, StringComparison.Ordinal); + Assert.Contains("GetStockByContainerCodeOrBarcode 方法", result, StringComparison.Ordinal); + Assert.Contains("container 类 Type 方法", result, StringComparison.Ordinal); + Assert.Contains("superpowers 类 brainstorming 方法", result, StringComparison.Ordinal); + Assert.DoesNotContain("第二步会改造后端收货入库事务", result, StringComparison.Ordinal); + Assert.DoesNotContain("入库流程", result, StringComparison.Ordinal); + Assert.DoesNotContain("扫码查询接口", result, StringComparison.Ordinal); + } + + [Fact] + public void Normalize_DoesNotApplyReceivingPlanSpecificSentenceRewrites() + { + var normalizer = new ReplyTtsSpeechTextNormalizer(); + + var result = normalizer.Normalize( + """ + 前端收口设计页面 `Cimc.Tianda.Wms.Web/src/views/mobile/receiving/index.vue:1` 保留扫码后多记录卡片展示这个方向。 + 客户简称 整列删掉,不再出现在顶部或列表里。 + """); + + Assert.DoesNotContain("这个页面会保留扫码后的多记录卡片展示", result, StringComparison.Ordinal); + Assert.DoesNotContain("客户简称这一列会删除", result, StringComparison.Ordinal); + Assert.Contains("前端收口设计页面", result, StringComparison.Ordinal); + Assert.Contains("index.vue 文件", result, StringComparison.Ordinal); + } +} diff --git a/WebCodeCli.Domain.Tests/ReplyTtsStartupHostedServiceTests.cs b/WebCodeCli.Domain.Tests/ReplyTtsStartupHostedServiceTests.cs new file mode 100644 index 0000000..f9ef9b9 --- /dev/null +++ b/WebCodeCli.Domain.Tests/ReplyTtsStartupHostedServiceTests.cs @@ -0,0 +1,105 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using WebCodeCli.Domain.Domain.Model.Channels; +using WebCodeCli.Domain.Domain.Service.Channels; + +namespace WebCodeCli.Domain.Tests; + +public sealed class ReplyTtsStartupHostedServiceTests +{ + [Fact] + public async Task StartAsync_WhenNoReplyTtsConfigIsEnabled_DoesNotStartLocalService() + { + using var harness = new Harness(replyTtsEnabled: false); + + await harness.Service.StartAsync(TestContext.Current.CancellationToken); + + Assert.Equal(1, harness.EnablementService.CallCount); + Assert.Equal(0, harness.PlatformService.EnsureStartedCallCount); + } + + [Fact] + public async Task StartAsync_WhenAnyReplyTtsConfigIsEnabled_StartsLocalService() + { + using var harness = new Harness(replyTtsEnabled: true); + + await harness.Service.StartAsync(TestContext.Current.CancellationToken); + + Assert.Equal(1, harness.EnablementService.CallCount); + Assert.Equal(1, harness.PlatformService.EnsureStartedCallCount); + } + + private sealed class Harness : IDisposable + { + private readonly ServiceProvider _serviceProvider; + + public Harness(bool replyTtsEnabled) + { + EnablementService = new StubReplyTtsEnablementService(replyTtsEnabled); + PlatformService = new StubFeishuReplyTtsPlatformService(); + + var services = new ServiceCollection(); + services.AddScoped(_ => EnablementService); + services.AddScoped(_ => PlatformService); + _serviceProvider = services.BuildServiceProvider(); + + Service = new ReplyTtsStartupHostedService( + _serviceProvider.GetRequiredService(), + NullLogger.Instance); + } + + public StubReplyTtsEnablementService EnablementService { get; } + + public StubFeishuReplyTtsPlatformService PlatformService { get; } + + public ReplyTtsStartupHostedService Service { get; } + + public void Dispose() + { + _serviceProvider.Dispose(); + } + } + + private sealed class StubReplyTtsEnablementService(bool replyTtsEnabled) : IReplyTtsEnablementService + { + public int CallCount { get; private set; } + + public Task HasEnabledReplyTtsAsync(CancellationToken cancellationToken = default) + { + CallCount++; + return Task.FromResult(replyTtsEnabled); + } + } + + private sealed class StubFeishuReplyTtsPlatformService : IFeishuReplyTtsPlatformService + { + public int EnsureStartedCallCount { get; private set; } + + public Task GetHealthAsync(CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task> GetVoicesAsync(CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task ResolveVoiceOrFallbackAsync( + string? savedVoiceId, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task EnsureServiceStartedAsync(CancellationToken cancellationToken = default) + { + EnsureStartedCallCount++; + return Task.FromResult(new FeishuReplyTtsHealthStatus + { + IsAvailable = true, + ServiceStatus = "ok" + }); + } + } +} diff --git a/WebCodeCli.Domain.Tests/ReplyTtsStorageRootResolverTests.cs b/WebCodeCli.Domain.Tests/ReplyTtsStorageRootResolverTests.cs new file mode 100644 index 0000000..7893869 --- /dev/null +++ b/WebCodeCli.Domain.Tests/ReplyTtsStorageRootResolverTests.cs @@ -0,0 +1,349 @@ +using Microsoft.Extensions.Options; +using System.Reflection; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Service.Channels; + +namespace WebCodeCli.Domain.Tests; + +public class ReplyTtsStorageRootResolverTests +{ + [Fact] + public void Resolve_WhenExplicitStorageRootIsSet_AlwaysUsesConfiguredRoot() + { + var options = CreateMonitor(new FeishuReplyTtsOptions + { + TtsStorageRoot = @"E:\custom-kokoro" + }); + var resolver = new ReplyTtsStorageRootResolver( + options, + new FakeReplyTtsHostEnvironment( + isWindows: true, + systemDriveRoot: @"C:\", + drives: + [ + new ReplyTtsDriveDescriptor(@"C:\", isReady: true, isWritable: true) + ])); + + var result = resolver.Resolve(); + + Assert.True(result.IsAvailable); + Assert.Equal(@"E:\custom-kokoro", result.StorageRoot); + Assert.Equal(@"E:\custom-kokoro\models", result.ModelsRoot); + Assert.Equal(@"E:\custom-kokoro\cache", result.CacheRoot); + Assert.Equal(@"E:\custom-kokoro\temp", result.TempRoot); + Assert.Equal(@"E:\custom-kokoro\logs", result.LogsRoot); + Assert.Equal(@"E:\custom-kokoro\venv", result.VenvRoot); + } + + [Fact] + public void Resolve_WhenExplicitStorageRootIsWindowsSystemDrive_ReturnsUnavailable() + { + var resolver = new ReplyTtsStorageRootResolver( + CreateMonitor(new FeishuReplyTtsOptions + { + TtsStorageRoot = "C:" + }), + new FakeReplyTtsHostEnvironment( + isWindows: true, + systemDriveRoot: @"C:\", + drives: + [ + new ReplyTtsDriveDescriptor(@"C:\", isReady: true, isWritable: true) + ])); + + var result = resolver.Resolve(); + + Assert.False(result.IsAvailable); + Assert.Null(result.StorageRoot); + Assert.Contains("non-system drive", result.Message, StringComparison.Ordinal); + } + + [Fact] + public void Resolve_WhenExplicitStorageRootIsUnderWindowsSystemDrive_ReturnsUnavailable() + { + var resolver = new ReplyTtsStorageRootResolver( + CreateMonitor(new FeishuReplyTtsOptions + { + TtsStorageRoot = @"C:\WebCodeData\Kokoro" + }), + new FakeReplyTtsHostEnvironment( + isWindows: true, + systemDriveRoot: @"C:\", + drives: + [ + new ReplyTtsDriveDescriptor(@"C:\", isReady: true, isWritable: true) + ])); + + var result = resolver.Resolve(); + + Assert.False(result.IsAvailable); + Assert.Null(result.StorageRoot); + Assert.Contains("non-system drive", result.Message, StringComparison.Ordinal); + } + + [Fact] + public void Resolve_WhenWindowsAndNoExplicitRoot_PicksFirstWritableNonSystemDrive() + { + var resolver = new ReplyTtsStorageRootResolver( + CreateMonitor(new FeishuReplyTtsOptions()), + new FakeReplyTtsHostEnvironment( + isWindows: true, + systemDriveRoot: @"C:\", + drives: + [ + new ReplyTtsDriveDescriptor(@"C:\", isReady: true, isWritable: true), + new ReplyTtsDriveDescriptor(@"D:\", isReady: true, isWritable: true), + new ReplyTtsDriveDescriptor(@"E:\", isReady: true, isWritable: true) + ])); + + var result = resolver.Resolve(); + + Assert.True(result.IsAvailable); + Assert.Equal(@"D:\WebCodeData\Kokoro", result.StorageRoot); + } + + [Fact] + public void Resolve_WhenWindowsAndExistingKokoroRootIsPresent_PrefersThatDrive() + { + var resolver = new ReplyTtsStorageRootResolver( + CreateMonitor(new FeishuReplyTtsOptions()), + new FakeReplyTtsHostEnvironment( + isWindows: true, + systemDriveRoot: @"C:\", + drives: + [ + new ReplyTtsDriveDescriptor(@"C:\", isReady: true, isWritable: true), + new ReplyTtsDriveDescriptor(@"D:\", isReady: true, isWritable: true), + new ReplyTtsDriveDescriptor(@"E:\", isReady: true, isWritable: true) + ], + existingDirectories: + [ + @"E:\WebCodeData\Kokoro", + @"E:\WebCodeData\Kokoro\models" + ])); + + var result = resolver.Resolve(); + + Assert.True(result.IsAvailable); + Assert.Equal(@"E:\WebCodeData\Kokoro", result.StorageRoot); + Assert.Contains(@"E:\", result.Message, StringComparison.Ordinal); + } + + [Fact] + public void Resolve_WhenDriveOnlyHasTempResidue_DoesNotTreatItAsInstalledKokoroRoot() + { + var resolver = new ReplyTtsStorageRootResolver( + CreateMonitor(new FeishuReplyTtsOptions()), + new FakeReplyTtsHostEnvironment( + isWindows: true, + systemDriveRoot: @"C:\", + drives: + [ + new ReplyTtsDriveDescriptor(@"C:\", isReady: true, isWritable: true), + new ReplyTtsDriveDescriptor(@"D:\", isReady: true, isWritable: true), + new ReplyTtsDriveDescriptor(@"E:\", isReady: true, isWritable: true) + ], + existingDirectories: + [ + @"D:\WebCodeData\Kokoro", + @"D:\WebCodeData\Kokoro\temp", + @"E:\WebCodeData\Kokoro", + @"E:\WebCodeData\Kokoro\models" + ])); + + var result = resolver.Resolve(); + + Assert.True(result.IsAvailable); + Assert.Equal(@"E:\WebCodeData\Kokoro", result.StorageRoot); + } + + [Fact] + public void Resolve_WhenWindowsOnlyHasSystemDrive_ReturnsUnavailable() + { + var resolver = new ReplyTtsStorageRootResolver( + CreateMonitor(new FeishuReplyTtsOptions()), + new FakeReplyTtsHostEnvironment( + isWindows: true, + systemDriveRoot: @"C:\", + drives: + [ + new ReplyTtsDriveDescriptor(@"C:\", isReady: true, isWritable: true) + ])); + + var result = resolver.Resolve(); + + Assert.False(result.IsAvailable); + Assert.Null(result.StorageRoot); + Assert.Contains("C:\\", result.Message, StringComparison.Ordinal); + Assert.Contains("non-system drive", result.Message, StringComparison.Ordinal); + Assert.DoesNotContain("D:\\", result.Message, StringComparison.Ordinal); + } + + [Fact] + public void Resolve_WhenNonWindowsAndNoExplicitRoot_UsesDefaultDataPath() + { + var resolver = new ReplyTtsStorageRootResolver( + CreateMonitor(new FeishuReplyTtsOptions()), + new FakeReplyTtsHostEnvironment( + isWindows: false, + systemDriveRoot: null, + drives: [])); + + var result = resolver.Resolve(); + + Assert.True(result.IsAvailable); + Assert.Equal("/data/webcode/kokoro", result.StorageRoot); + } + + [Fact] + public void Resolve_WhenStorageRootIsResolved_HelperSubpathsStayUnderStorageRoot() + { + var resolver = new ReplyTtsStorageRootResolver( + CreateMonitor(new FeishuReplyTtsOptions + { + TtsStorageRoot = @"D:\tts-root" + }), + new FakeReplyTtsHostEnvironment( + isWindows: true, + systemDriveRoot: @"C:\", + drives: + [ + new ReplyTtsDriveDescriptor(@"D:\", isReady: true, isWritable: true) + ])); + + var result = resolver.Resolve(); + + Assert.True(result.IsAvailable); + Assert.All( + new[] + { + result.ModelsRoot, + result.CacheRoot, + result.TempRoot, + result.LogsRoot, + result.VenvRoot + }, + path => Assert.StartsWith(result.StorageRoot!, path, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Resolve_WhenOptionsChangeAfterConstruction_UsesUpdatedValues() + { + var optionsMonitor = CreateMonitor(new FeishuReplyTtsOptions()); + var resolver = new ReplyTtsStorageRootResolver( + optionsMonitor, + new FakeReplyTtsHostEnvironment( + isWindows: true, + systemDriveRoot: @"C:\", + drives: + [ + new ReplyTtsDriveDescriptor(@"C:\", isReady: true, isWritable: true), + new ReplyTtsDriveDescriptor(@"D:\", isReady: true, isWritable: true) + ])); + + var initialResult = resolver.Resolve(); + optionsMonitor.Set(new FeishuReplyTtsOptions + { + TtsStorageRoot = @"E:\override-root" + }); + var updatedResult = resolver.Resolve(); + + Assert.Equal(@"D:\WebCodeData\Kokoro", initialResult.StorageRoot); + Assert.Equal(@"E:\override-root", updatedResult.StorageRoot); + } + + [Fact] + public void ProbeTargetDirectory_UsesDisposableSandboxInsteadOfRealInstallTree() + { + var systemEnvironmentType = typeof(ReplyTtsStorageRootResolver) + .GetNestedType("SystemReplyTtsHostEnvironment", BindingFlags.NonPublic); + + Assert.NotNull(systemEnvironmentType); + + var method = systemEnvironmentType! + .GetMethod("BuildProbeTargetDirectory", BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + + var probeTargetDirectory = (string)method!.Invoke(null, [@"D:\", "probe-token"])!; + + Assert.Equal( + @"D:\.webcode-feishu-reply-tts-probe-probe-token\webcode\kokoro", + probeTargetDirectory); + Assert.False( + probeTargetDirectory.StartsWith(@"D:\webcode\kokoro", StringComparison.OrdinalIgnoreCase)); + } + + private static MutableOptionsMonitor CreateMonitor(FeishuReplyTtsOptions options) + { + return new MutableOptionsMonitor(options); + } + + private sealed class FakeReplyTtsHostEnvironment : IReplyTtsHostEnvironment + { + private readonly IReadOnlyList _drives; + private readonly HashSet _existingDirectories; + private readonly HashSet _existingFiles; + + public FakeReplyTtsHostEnvironment( + bool isWindows, + string? systemDriveRoot, + IReadOnlyList drives, + IReadOnlyList? existingDirectories = null, + IReadOnlyList? existingFiles = null) + { + IsWindows = isWindows; + SystemDriveRoot = systemDriveRoot; + _drives = drives; + _existingDirectories = new HashSet( + (existingDirectories ?? []).Select(path => path.TrimEnd('\\', '/')), + StringComparer.OrdinalIgnoreCase); + _existingFiles = new HashSet( + (existingFiles ?? []).Select(path => path.TrimEnd('\\', '/')), + StringComparer.OrdinalIgnoreCase); + } + + public bool IsWindows { get; } + + public string? SystemDriveRoot { get; } + + public IReadOnlyList GetFixedDrives() + { + return _drives; + } + + public bool DirectoryExists(string path) + { + return _existingDirectories.Contains(path.TrimEnd('\\', '/')); + } + + public bool FileExists(string path) + { + return _existingFiles.Contains(path.TrimEnd('\\', '/')); + } + } + + private sealed class MutableOptionsMonitor : IOptionsMonitor + { + private TOptions _currentValue; + + public MutableOptionsMonitor(TOptions currentValue) + { + _currentValue = currentValue; + } + + public TOptions CurrentValue => _currentValue; + + public TOptions Get(string? name) => _currentValue; + + public IDisposable? OnChange(Action listener) + { + return null; + } + + public void Set(TOptions options) + { + _currentValue = options; + } + } +} diff --git a/WebCodeCli.Domain.Tests/SherpaKokoroTtsClientTests.cs b/WebCodeCli.Domain.Tests/SherpaKokoroTtsClientTests.cs new file mode 100644 index 0000000..1dca11d --- /dev/null +++ b/WebCodeCli.Domain.Tests/SherpaKokoroTtsClientTests.cs @@ -0,0 +1,144 @@ +using System.Net; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Service.Channels; + +namespace WebCodeCli.Domain.Tests; + +public sealed class SherpaKokoroTtsClientTests +{ + [Fact] + public async Task GetHealthAsync_ParsesLocalServiceResponse() + { + var handler = new StubHttpMessageHandler( + [ + CreateJsonResponse("""{"status":"ok","device":"cpu","defaultVoiceId":"zh_female_1"}""") + ]); + + var client = CreateClient(handler); + + var result = await client.GetHealthAsync(TestContext.Current.CancellationToken); + + Assert.True(result.IsAvailable); + Assert.Equal("ok", result.ServiceStatus); + Assert.Equal("cpu", result.Device); + Assert.Equal("zh_female_1", result.DefaultVoiceId); + Assert.Equal("/health", Assert.Single(handler.RequestPaths)); + } + + [Fact] + public async Task GetVoicesAsync_ParsesVoiceList() + { + var handler = new StubHttpMessageHandler( + [ + CreateJsonResponse( + """ + { + "voices": [ + { + "voiceId": "zh_female_1", + "displayName": "Kokoro Chinese Female", + "language": "zh", + "gender": "female" + }, + { + "voiceId": "en_male_1", + "displayName": "Kokoro English Male", + "language": "en", + "gender": "male" + } + ] + } + """) + ]); + + var client = CreateClient(handler); + + var result = await client.GetVoicesAsync(TestContext.Current.CancellationToken); + + Assert.Equal(2, result.Count); + Assert.Collection( + result, + voice => + { + Assert.Equal("zh_female_1", voice.VoiceId); + Assert.Equal("Kokoro Chinese Female", voice.DisplayName); + Assert.Equal("zh", voice.Language); + Assert.Equal("female", voice.Gender); + }, + voice => + { + Assert.Equal("en_male_1", voice.VoiceId); + Assert.Equal("Kokoro English Male", voice.DisplayName); + Assert.Equal("en", voice.Language); + Assert.Equal("male", voice.Gender); + }); + Assert.Equal("/voices", Assert.Single(handler.RequestPaths)); + } + + [Fact] + public async Task GetHealthAsync_UsesDedicatedHttpClientName() + { + var handler = new StubHttpMessageHandler( + [ + CreateJsonResponse("""{"status":"ok"}""") + ]); + var factory = new StubHttpClientFactory(new HttpClient(handler)); + var client = CreateClient(handler, factory); + + await client.GetHealthAsync(TestContext.Current.CancellationToken); + + Assert.Equal("SherpaKokoroTtsClient", factory.CreatedClientName); + } + + private static SherpaKokoroTtsClient CreateClient(StubHttpMessageHandler handler, StubHttpClientFactory? factory = null) + { + return new SherpaKokoroTtsClient( + Options.Create(new FeishuReplyTtsOptions + { + TtsServiceBaseUrl = "http://127.0.0.1:5058", + TtsServiceTimeoutSeconds = 15 + }), + NullLogger.Instance, + factory ?? new StubHttpClientFactory(new HttpClient(handler))); + } + + private static HttpResponseMessage CreateJsonResponse(string json, HttpStatusCode statusCode = HttpStatusCode.OK) + { + return new HttpResponseMessage(statusCode) + { + Content = new StringContent(json) + }; + } + + private sealed class StubHttpClientFactory(HttpClient client) : IHttpClientFactory + { + public string? CreatedClientName { get; private set; } + + public HttpClient CreateClient(string name) + { + CreatedClientName = name; + return client; + } + } + + private sealed class StubHttpMessageHandler(IEnumerable responses) : HttpMessageHandler + { + private readonly Queue _responses = new(responses); + + public List RequestPaths { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestPaths.Add(request.RequestUri!.AbsolutePath); + + if (_responses.Count == 0) + { + throw new Xunit.Sdk.XunitException($"Unexpected request sent to {request.RequestUri}."); + } + + return Task.FromResult(_responses.Dequeue()); + } + } +} diff --git a/WebCodeCli.Domain.Tests/SuperpowersPromptBuilderTests.cs b/WebCodeCli.Domain.Tests/SuperpowersPromptBuilderTests.cs index 5073192..11201cd 100644 --- a/WebCodeCli.Domain.Tests/SuperpowersPromptBuilderTests.cs +++ b/WebCodeCli.Domain.Tests/SuperpowersPromptBuilderTests.cs @@ -1,14 +1,23 @@ +using WebCodeCli.Domain.Domain.Model; using WebCodeCli.Domain.Domain.Service; namespace WebCodeCli.Domain.Tests; public class SuperpowersPromptBuilderTests { + [Fact] + public void BuildContinuePrompt_ReturnsApprovedPrompt() + { + Assert.Equal( + SuperpowersQuickActionDefaults.ContinuePrompt, + SuperpowersPromptBuilder.BuildContinuePrompt()); + } + [Fact] public void BuildExecutePlanPrompt_ReturnsApprovedPrompt() { Assert.Equal( - "使用superpowers的executing-plans技能执行计划", + SuperpowersQuickActionDefaults.ExecutePlanPrompt, SuperpowersPromptBuilder.BuildExecutePlanPrompt()); } @@ -16,14 +25,14 @@ public void BuildExecutePlanPrompt_ReturnsApprovedPrompt() public void BuildSubagentExecutePlanPrompt_ReturnsApprovedCombinedPrompt() { Assert.Equal( - "使用superpowers的executing-plans技能执行计划,并且使用superpowers的subagent-driven-development技能", + SuperpowersQuickActionDefaults.ExecuteSubagentPlanPrompt, SuperpowersPromptBuilder.BuildSubagentExecutePlanPrompt()); } [Theory] - [InlineData("写一个执行步骤", "使用superpowers技能,写一个执行步骤")] - [InlineData("使用superpowers技能,写一个执行步骤", "使用superpowers技能,写一个执行步骤")] - [InlineData(" 写一个执行步骤 ", "使用superpowers技能,写一个执行步骤")] + [InlineData("写一个执行步骤", "$superpowers ,使用superpowers技能,写一个执行步骤")] + [InlineData("$superpowers ,使用superpowers技能,写一个执行步骤", "$superpowers ,使用superpowers技能,写一个执行步骤")] + [InlineData(" 写一个执行步骤 ", "$superpowers ,使用superpowers技能,写一个执行步骤")] public void BuildQuickSkillPrompt_AppliesPrefixOnlyWhenMissing(string input, string expected) { Assert.Equal(expected, SuperpowersPromptBuilder.BuildQuickSkillPrompt(input)); diff --git a/WebCodeCli.Domain.Tests/UserFeishuBotConfigServiceTests.cs b/WebCodeCli.Domain.Tests/UserFeishuBotConfigServiceTests.cs new file mode 100644 index 0000000..f9d26c4 --- /dev/null +++ b/WebCodeCli.Domain.Tests/UserFeishuBotConfigServiceTests.cs @@ -0,0 +1,258 @@ +using System.Linq.Expressions; +using Microsoft.Extensions.Options; +using SqlSugar; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Service; +using WebCodeCli.Domain.Model; +using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; + +namespace WebCodeCli.Domain.Tests; + +public class UserFeishuBotConfigServiceTests +{ + [Fact] + public async Task SaveAsync_PersistsReplyTtsFields() + { + var repository = new InMemoryUserFeishuBotConfigRepository(); + var service = CreateService(repository); + + var result = await service.SaveAsync(new UserFeishuBotConfigEntity + { + Username = "alice", + IsEnabled = true, + AppId = "cli_alice", + AppSecret = "secret", + ReplyTtsEnabled = true, + ReplyTtsVoiceId = " voice-1 " + }); + + var stored = await service.GetByUsernameAsync("alice"); + + Assert.True(result.Success); + Assert.NotNull(stored); + Assert.Equal(1, repository.InsertCallCount); + Assert.Equal(0, repository.UpdateCallCount); + Assert.True(stored!.ReplyTtsEnabled); + Assert.Equal("voice-1", stored.ReplyTtsVoiceId); + } + + [Fact] + public async Task SaveAsync_OverwritesExistingReplyTtsValues() + { + var repository = new InMemoryUserFeishuBotConfigRepository(); + repository.Store(new UserFeishuBotConfigEntity + { + Username = "alice", + IsEnabled = true, + AppId = "cli_alice", + AppSecret = "secret", + ReplyTtsEnabled = true, + ReplyTtsVoiceId = "old-voice" + }); + + var service = CreateService(repository); + + var result = await service.SaveAsync(new UserFeishuBotConfigEntity + { + Username = "alice", + IsEnabled = true, + AppId = "cli_alice", + AppSecret = "secret", + ReplyTtsEnabled = false, + ReplyTtsVoiceId = "new-voice" + }); + + var stored = await service.GetByUsernameAsync("alice"); + + Assert.True(result.Success); + Assert.NotNull(stored); + Assert.Equal(0, repository.InsertCallCount); + Assert.Equal(1, repository.UpdateCallCount); + Assert.False(stored!.ReplyTtsEnabled); + Assert.Equal("new-voice", stored.ReplyTtsVoiceId); + } + + [Fact] + public async Task SaveAsync_UpdateWithBlankReplyTtsVoiceId_ClearsPreviousVoice() + { + var repository = new InMemoryUserFeishuBotConfigRepository(); + repository.Store(new UserFeishuBotConfigEntity + { + Username = "alice", + IsEnabled = true, + AppId = "cli_alice", + AppSecret = "secret", + ReplyTtsEnabled = true, + ReplyTtsVoiceId = "old-voice" + }); + + var service = CreateService(repository); + + var result = await service.SaveAsync(new UserFeishuBotConfigEntity + { + Username = "alice", + IsEnabled = true, + AppId = "cli_alice", + AppSecret = "secret", + ReplyTtsEnabled = true, + ReplyTtsVoiceId = " " + }); + + var stored = await service.GetByUsernameAsync("alice"); + + Assert.True(result.Success); + Assert.NotNull(stored); + Assert.Equal(0, repository.InsertCallCount); + Assert.Equal(1, repository.UpdateCallCount); + Assert.True(stored!.ReplyTtsEnabled); + Assert.Null(stored.ReplyTtsVoiceId); + } + + [Fact] + public async Task SaveAsync_NormalizesBlankReplyTtsVoiceIdToNull() + { + var repository = new InMemoryUserFeishuBotConfigRepository(); + var service = CreateService(repository); + + var result = await service.SaveAsync(new UserFeishuBotConfigEntity + { + Username = "alice", + IsEnabled = true, + AppId = "cli_alice", + AppSecret = "secret", + ReplyTtsEnabled = true, + ReplyTtsVoiceId = " " + }); + + var stored = await service.GetByUsernameAsync("alice"); + + Assert.True(result.Success); + Assert.NotNull(stored); + Assert.True(stored!.ReplyTtsEnabled); + Assert.Null(stored.ReplyTtsVoiceId); + } + + private static UserFeishuBotConfigService CreateService(InMemoryUserFeishuBotConfigRepository repository) + { + return new UserFeishuBotConfigService(repository, Options.Create(new FeishuOptions())); + } + + private sealed class InMemoryUserFeishuBotConfigRepository : IUserFeishuBotConfigRepository + { + private readonly Dictionary _configs = new(StringComparer.OrdinalIgnoreCase); + + public int InsertCallCount { get; private set; } + public int UpdateCallCount { get; private set; } + + 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> GetListAsync(Expression> whereExpression) + { + var predicate = whereExpression.Compile(); + return Task.FromResult(_configs.Values.Where(predicate).Select(Clone).ToList()); + } + + public Task InsertAsync(UserFeishuBotConfigEntity obj) + { + if (_configs.ContainsKey(obj.Username)) + { + return Task.FromResult(false); + } + + InsertCallCount++; + Store(obj); + return Task.FromResult(true); + } + + public Task UpdateAsync(UserFeishuBotConfigEntity obj) + { + if (!_configs.ContainsKey(obj.Username)) + { + return Task.FromResult(false); + } + + UpdateCallCount++; + Store(obj); + return Task.FromResult(true); + } + + public SqlSugarScope GetDB() => throw new NotSupportedException(); + public List GetList() => throw new NotSupportedException(); + public Task> GetListAsync() => throw new NotSupportedException(); + public List GetList(Expression> whereExpression) => throw new NotSupportedException(); + public int Count(Expression> whereExpression) => throw new NotSupportedException(); + public Task CountAsync(Expression> whereExpression) => throw new NotSupportedException(); + 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 UserFeishuBotConfigEntity GetById(dynamic id) => throw new NotSupportedException(); + public Task GetByIdAsync(dynamic id) => throw new NotSupportedException(); + public UserFeishuBotConfigEntity GetSingle(Expression> whereExpression) => throw new NotSupportedException(); + public Task GetSingleAsync(Expression> whereExpression) => throw new NotSupportedException(); + public UserFeishuBotConfigEntity GetFirst(Expression> whereExpression) => throw new NotSupportedException(); + public Task GetFirstAsync(Expression> whereExpression) => throw new NotSupportedException(); + public bool Insert(UserFeishuBotConfigEntity obj) => throw new NotSupportedException(); + public bool InsertRange(List objs) => throw new NotSupportedException(); + public Task InsertRangeAsync(List objs) => throw new NotSupportedException(); + public int InsertReturnIdentity(UserFeishuBotConfigEntity obj) => throw new NotSupportedException(); + public Task InsertReturnIdentityAsync(UserFeishuBotConfigEntity obj) => throw new NotSupportedException(); + public long InsertReturnBigIdentity(UserFeishuBotConfigEntity obj) => throw new NotSupportedException(); + public Task InsertReturnBigIdentityAsync(UserFeishuBotConfigEntity 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(UserFeishuBotConfigEntity obj) => throw new NotSupportedException(); + public Task DeleteAsync(UserFeishuBotConfigEntity obj) => throw new NotSupportedException(); + public bool Delete(Expression> whereExpression) => throw new NotSupportedException(); + public Task DeleteAsync(Expression> whereExpression) => throw new NotSupportedException(); + public bool Update(UserFeishuBotConfigEntity obj) => throw new NotSupportedException(); + public bool UpdateRange(List objs) => throw new NotSupportedException(); + public bool InsertOrUpdate(UserFeishuBotConfigEntity obj) => throw new NotSupportedException(); + public Task InsertOrUpdateAsync(UserFeishuBotConfigEntity obj) => throw new NotSupportedException(); + public Task UpdateRangeAsync(List objs) => throw new NotSupportedException(); + public bool IsAny(Expression> whereExpression) => throw new NotSupportedException(); + public Task IsAnyAsync(Expression> whereExpression) => throw new NotSupportedException(); + + 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, + ReplyTtsEnabled = entity.ReplyTtsEnabled, + ReplyTtsVoiceId = entity.ReplyTtsVoiceId, + LastStartedAt = entity.LastStartedAt, + CreatedAt = entity.CreatedAt, + UpdatedAt = entity.UpdatedAt + }; + } + } +} diff --git a/WebCodeCli.Domain/Common/Extensions/ServiceCollectionExtensions.cs b/WebCodeCli.Domain/Common/Extensions/ServiceCollectionExtensions.cs index 90959ae..1a71fcc 100644 --- a/WebCodeCli.Domain/Common/Extensions/ServiceCollectionExtensions.cs +++ b/WebCodeCli.Domain/Common/Extensions/ServiceCollectionExtensions.cs @@ -67,9 +67,11 @@ public static IServiceCollection AddFeishuChannel( IConfiguration configuration) { var feishuSection = configuration.GetSection("Feishu"); + var feishuReplyTtsSection = configuration.GetSection("FeishuReplyTts"); // 绑定配置选项 services.Configure(feishuSection); + services.Configure(feishuReplyTtsSection); // 注册 HttpClient 工厂(用于 CardKit API 调用) services.AddHttpClient("FeishuClient") @@ -98,8 +100,10 @@ public static IServiceCollection AddFeishuChannel( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddHostedService(); services.AddHostedService(sp => (UserFeishuBotRuntimeService)sp.GetRequiredService()); return services; diff --git a/WebCodeCli.Domain/Common/Options/FeishuReplyTtsOptions.cs b/WebCodeCli.Domain/Common/Options/FeishuReplyTtsOptions.cs new file mode 100644 index 0000000..5ccedf2 --- /dev/null +++ b/WebCodeCli.Domain/Common/Options/FeishuReplyTtsOptions.cs @@ -0,0 +1,24 @@ +namespace WebCodeCli.Domain.Common.Options; + +public sealed class FeishuReplyTtsOptions +{ + public string? TtsStorageRoot { get; set; } + + public string TtsServiceBaseUrl { get; set; } = "http://127.0.0.1:5058"; + + public int TtsServiceTimeoutSeconds { get; set; } = 180; + + public string TtsPreferredDevice { get; set; } = "cpu"; + + public string? TtsDefaultVoiceId { get; set; } + + public int TtsChunkMaxChars { get; set; } = 160; + + public string? FfmpegExecutablePath { get; set; } + + public string? TtsServiceStartScriptPath { get; set; } + + public string? TtsServicePythonPath { get; set; } + + public int TtsServiceStartupTimeoutSeconds { get; set; } = 30; +} diff --git a/WebCodeCli.Domain/Domain/Model/Channels/FeishuCompletedReplyTtsRequest.cs b/WebCodeCli.Domain/Domain/Model/Channels/FeishuCompletedReplyTtsRequest.cs new file mode 100644 index 0000000..a50f56a --- /dev/null +++ b/WebCodeCli.Domain/Domain/Model/Channels/FeishuCompletedReplyTtsRequest.cs @@ -0,0 +1,14 @@ +namespace WebCodeCli.Domain.Domain.Model.Channels; + +public sealed class FeishuCompletedReplyTtsRequest +{ + public string ChatId { get; set; } = string.Empty; + + public string? SessionId { get; set; } + + public string Output { get; set; } = string.Empty; + + public string? Username { get; set; } + + public string? AppId { get; set; } +} diff --git a/WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs b/WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs index e3e003e..f93626e 100644 --- a/WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs +++ b/WebCodeCli.Domain/Domain/Model/Channels/FeishuHelpCardAction.cs @@ -9,9 +9,12 @@ namespace WebCodeCli.Domain.Domain.Model.Channels; public class FeishuHelpCardAction { public const string SubmitSuperpowersQuickInputAction = "submit_superpowers_quick_input"; + public const string SubmitGoalQuickInputAction = "submit_goal_quick_input"; + public const string ContinueSuperpowersAction = "continue_superpowers"; public const string ExecuteSuperpowersPlanAction = "execute_superpowers_plan"; public const string ExecuteSuperpowersSubagentPlanAction = "execute_superpowers_subagent_plan"; public const string RetrySuperpowersCapabilityDetectionAction = "retry_superpowers_capability_detection"; + public const string ToggleReplyTtsAction = "toggle_reply_tts"; ///

/// 动作类型 diff --git a/WebCodeCli.Domain/Domain/Model/Channels/FeishuReplyTtsHealthStatus.cs b/WebCodeCli.Domain/Domain/Model/Channels/FeishuReplyTtsHealthStatus.cs new file mode 100644 index 0000000..52ef53f --- /dev/null +++ b/WebCodeCli.Domain/Domain/Model/Channels/FeishuReplyTtsHealthStatus.cs @@ -0,0 +1,26 @@ +namespace WebCodeCli.Domain.Domain.Model.Channels; + +public sealed class FeishuReplyTtsHealthStatus +{ + public bool IsAvailable { get; set; } + + public string? StorageRoot { get; set; } + + public string Message { get; set; } = string.Empty; + + public string? ModelsRoot { get; set; } + + public string? CacheRoot { get; set; } + + public string? TempRoot { get; set; } + + public string? LogsRoot { get; set; } + + public string? VenvRoot { get; set; } + + public string? ServiceStatus { get; set; } + + public string? Device { get; set; } + + public string? DefaultVoiceId { get; set; } +} diff --git a/WebCodeCli.Domain/Domain/Model/Channels/FeishuReplyTtsVoiceOption.cs b/WebCodeCli.Domain/Domain/Model/Channels/FeishuReplyTtsVoiceOption.cs new file mode 100644 index 0000000..9524259 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Model/Channels/FeishuReplyTtsVoiceOption.cs @@ -0,0 +1,12 @@ +namespace WebCodeCli.Domain.Domain.Model.Channels; + +public sealed class FeishuReplyTtsVoiceOption +{ + public string VoiceId { get; set; } = string.Empty; + + public string DisplayName { get; set; } = string.Empty; + + public string? Language { get; set; } + + public string? Gender { get; set; } +} diff --git a/WebCodeCli.Domain/Domain/Model/Channels/FeishuStreamingCardChrome.cs b/WebCodeCli.Domain/Domain/Model/Channels/FeishuStreamingCardChrome.cs index 51acc74..6e9ae3e 100644 --- a/WebCodeCli.Domain/Domain/Model/Channels/FeishuStreamingCardChrome.cs +++ b/WebCodeCli.Domain/Domain/Model/Channels/FeishuStreamingCardChrome.cs @@ -26,6 +26,11 @@ public sealed class FeishuStreamingCardChrome /// 卡片底部少打断执行提示词表单 /// public FeishuStreamingCardBottomPrompt? BottomPrompt { get; set; } + + /// + /// 卡片底部附加提示词表单,按顺序显示在主表单之后 + /// + public List AdditionalBottomPrompts { get; set; } = []; } public sealed class FeishuStreamingCardTopChipGroup @@ -76,6 +81,24 @@ private static string WithState(string baseStatusMarkdown, string state) : $"{baseStatusMarkdown} · {state}"; } +internal static class FeishuStreamingErrorFormatter +{ + public static string AppendError(string? existingContent, string? errorMessage) + { + var normalizedError = string.IsNullOrWhiteSpace(errorMessage) + ? "执行失败" + : errorMessage.Trim(); + var formattedError = $"**错误:{normalizedError}**"; + + if (string.IsNullOrWhiteSpace(existingContent)) + { + return formattedError; + } + + return $"{existingContent.TrimEnd()}\n\n---\n\n{formattedError}"; + } +} + internal sealed class FeishuStreamingStatusPulseGate { private long _pauseUntilUtcTicks; diff --git a/WebCodeCli.Domain/Domain/Model/ExternalCliHistoryResult.cs b/WebCodeCli.Domain/Domain/Model/ExternalCliHistoryResult.cs new file mode 100644 index 0000000..00ef915 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Model/ExternalCliHistoryResult.cs @@ -0,0 +1,11 @@ +namespace WebCodeCli.Domain.Domain.Model; + +/// +/// 外部 CLI 会话历史读取结果,包含消息和来源信息。 +/// +public class ExternalCliHistoryResult +{ + public List Messages { get; set; } = []; + + public string? SourcePath { get; set; } +} diff --git a/WebCodeCli.Domain/Domain/Model/GoalQuickActionDefaults.cs b/WebCodeCli.Domain/Domain/Model/GoalQuickActionDefaults.cs new file mode 100644 index 0000000..b9d7997 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Model/GoalQuickActionDefaults.cs @@ -0,0 +1,15 @@ +namespace WebCodeCli.Domain.Domain.Model; + +public static class GoalQuickActionDefaults +{ + public const string QuickInputFieldName = "goal_quick_input"; + public const string QuickInputPlaceholder = "输入内容后回车,未写前缀时会自动补成 /goal "; + public const string InstructionText = "使用 /goal 命令,会自动补前缀:\"/goal \"。用于设置当前工作目标,让 Codex 围绕目标持续推进。"; + public const string QuickSubmitButtonText = "提交"; + public const string QuickGoalPrefix = "/goal "; + + public const string CapabilityCheckingText = "正在检查 /goal 能力..."; + public const string CapabilityUnavailableText = "当前 Codex 版本或配置不支持 /goal"; + public const string CapabilityProbeFailedText = "检查 /goal 能力失败,请重试"; + public const string CapabilityRetryButtonText = "重新检查"; +} diff --git a/WebCodeCli.Domain/Domain/Model/SuperpowersQuickActionDefaults.cs b/WebCodeCli.Domain/Domain/Model/SuperpowersQuickActionDefaults.cs index e42a7df..a821405 100644 --- a/WebCodeCli.Domain/Domain/Model/SuperpowersQuickActionDefaults.cs +++ b/WebCodeCli.Domain/Domain/Model/SuperpowersQuickActionDefaults.cs @@ -3,16 +3,18 @@ namespace WebCodeCli.Domain.Domain.Model; public static class SuperpowersQuickActionDefaults { public const string QuickInputFieldName = "superpowers_quick_input"; - public const string QuickInputPlaceholder = "输入内容后回车,未写前缀时会自动补成使用superpowers技能,"; - public const string InstructionText = "可直接输入 superpowers 指令;未填写前缀时,会自动补成“使用superpowers技能,”。"; + public const string QuickInputPlaceholder = "输入内容后回车,未写前缀时会自动补成$superpowers ,使用superpowers技能,"; + public const string InstructionText = "使用superpowers工作流,会自动补前缀:\"$superpowers ,使用superpowers技能,\""; public const string QuickSubmitButtonText = "提交"; + public const string ContinueButtonText = "继续"; public const string ExecutePlanButtonText = "执行 plan"; public const string ExecuteSubagentPlanButtonText = "子代理执行 plan"; + public const string ContinuePrompt = "continue,继续"; public const string ExecutePlanPrompt = "使用superpowers的executing-plans技能执行计划"; public const string ExecuteSubagentPlanPrompt = "使用superpowers的executing-plans技能执行计划,并且使用superpowers的subagent-driven-development技能"; - public const string QuickSkillPrefix = "使用superpowers技能,"; + public const string QuickSkillPrefix = "$superpowers ,使用superpowers技能,"; public const string CapabilityCheckingText = "正在检测当前 Provider 的 superpowers 能力..."; public const string CapabilityUnavailableText = "当前 Provider 缺少 superpowers 能力"; public const string CapabilityProbeFailedText = "检测 superpowers 能力失败,请重试"; diff --git a/WebCodeCli.Domain/Domain/Service/Channels/AudioTranscodeService.cs b/WebCodeCli.Domain/Domain/Service/Channels/AudioTranscodeService.cs new file mode 100644 index 0000000..10b8f5d --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/AudioTranscodeService.cs @@ -0,0 +1,108 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using WebCodeCli.Domain.Common.Extensions; +using WebCodeCli.Domain.Common.Options; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +[ServiceDescription(typeof(IAudioTranscodeService), ServiceLifetime.Scoped)] +public sealed class AudioTranscodeService : IAudioTranscodeService +{ + private readonly FeishuReplyTtsOptions _options; + private readonly ReplyTtsStorageRootResolver _storageRootResolver; + private readonly IExternalProcessRunner _externalProcessRunner; + + public AudioTranscodeService( + IOptions options, + ReplyTtsStorageRootResolver storageRootResolver, + IExternalProcessRunner externalProcessRunner) + { + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _storageRootResolver = storageRootResolver ?? throw new ArgumentNullException(nameof(storageRootResolver)); + _externalProcessRunner = externalProcessRunner ?? throw new ArgumentNullException(nameof(externalProcessRunner)); + } + + public async Task TranscodeChunkAsync( + string jobId, + string inputWavPath, + int chunkIndex, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(jobId)) + { + throw new ArgumentException("Job ID is required.", nameof(jobId)); + } + + if (string.IsNullOrWhiteSpace(inputWavPath)) + { + throw new ArgumentException("Input WAV path is required.", nameof(inputWavPath)); + } + + if (chunkIndex <= 0) + { + throw new ArgumentOutOfRangeException(nameof(chunkIndex), "Chunk index must be greater than zero."); + } + + var health = _storageRootResolver.Resolve(); + if (!health.IsAvailable || string.IsNullOrWhiteSpace(health.TempRoot)) + { + throw new InvalidOperationException( + string.IsNullOrWhiteSpace(health.Message) + ? "Feishu reply TTS temp storage is unavailable." + : health.Message); + } + + var ffmpegResolution = ReplyTtsFfmpegPathResolver.Resolve(_options, health); + if (!ffmpegResolution.IsAvailable || string.IsNullOrWhiteSpace(ffmpegResolution.ExecutablePath)) + { + throw new InvalidOperationException(ffmpegResolution.Message); + } + + var jobDirectory = Path.Combine(health.TempRoot, SanitizePathSegment(jobId)); + Directory.CreateDirectory(jobDirectory); + + var outputPath = Path.Combine(jobDirectory, $"chunk-{chunkIndex:000}.opus"); + var arguments = string.Join( + ' ', + "-y", + "-i", + Quote(inputWavPath), + "-acodec", + "libopus", + "-ac", + "1", + "-ar", + "16000", + Quote(outputPath)); + + var result = await _externalProcessRunner.RunAsync( + ffmpegResolution.ExecutablePath, + arguments, + jobDirectory, + cancellationToken); + + if (result.ExitCode != 0) + { + throw new InvalidOperationException( + $"ffmpeg transcode failed with exit code {result.ExitCode}: {result.StandardError}".Trim()); + } + + return outputPath; + } + + private static string Quote(string path) + { + return $"\"{path}\""; + } + + private static string SanitizePathSegment(string value) + { + var sanitized = new string(value + .Trim() + .Select(static character => char.IsLetterOrDigit(character) || character is '-' or '_' + ? character + : '_') + .ToArray()); + return string.IsNullOrWhiteSpace(sanitized) ? "reply-tts-job" : sanitized; + } +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ExternalProcessRunner.cs b/WebCodeCli.Domain/Domain/Service/Channels/ExternalProcessRunner.cs new file mode 100644 index 0000000..1a084bb --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/ExternalProcessRunner.cs @@ -0,0 +1,76 @@ +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using WebCodeCli.Domain.Common.Extensions; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +[ServiceDescription(typeof(IExternalProcessRunner), ServiceLifetime.Singleton)] +public sealed class ExternalProcessRunner : IExternalProcessRunner +{ + public async Task RunAsync( + string fileName, + string arguments, + string? workingDirectory = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + throw new ArgumentException("Executable path is required.", nameof(fileName)); + } + + var startInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments ?? string.Empty, + WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) + ? Environment.CurrentDirectory + : workingDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var process = new Process + { + StartInfo = startInfo + }; + + if (!process.Start()) + { + throw new InvalidOperationException($"Failed to start process '{fileName}'."); + } + + try + { + var standardOutputTask = process.StandardOutput.ReadToEndAsync(); + var standardErrorTask = process.StandardError.ReadToEndAsync(); + + await process.WaitForExitAsync(cancellationToken); + + return new ExternalProcessResult( + process.ExitCode, + await standardOutputTask, + await standardErrorTask); + } + catch (OperationCanceledException) + { + TryKill(process); + throw; + } + } + + private static void TryKill(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch + { + } + } +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/FeishuAudioMessageService.cs b/WebCodeCli.Domain/Domain/Service/Channels/FeishuAudioMessageService.cs new file mode 100644 index 0000000..6a77bf8 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/FeishuAudioMessageService.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.DependencyInjection; +using WebCodeCli.Domain.Common.Extensions; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Service; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +[ServiceDescription(typeof(IFeishuAudioMessageService), ServiceLifetime.Scoped)] +public sealed class FeishuAudioMessageService : IFeishuAudioMessageService +{ + private readonly IFeishuCardKitClient _feishuCardKitClient; + private readonly IUserFeishuBotConfigService _userFeishuBotConfigService; + + public FeishuAudioMessageService( + IFeishuCardKitClient feishuCardKitClient, + IUserFeishuBotConfigService userFeishuBotConfigService) + { + _feishuCardKitClient = feishuCardKitClient ?? throw new ArgumentNullException(nameof(feishuCardKitClient)); + _userFeishuBotConfigService = userFeishuBotConfigService ?? throw new ArgumentNullException(nameof(userFeishuBotConfigService)); + } + + public async Task SendAudioMessageAsync( + string chatId, + string filePath, + int durationMs, + string? username = null, + string? appId = null, + CancellationToken cancellationToken = default) + { + var effectiveOptions = await ResolveEffectiveOptionsAsync(username, appId); + var fileKey = await _feishuCardKitClient.UploadAudioFileAsync( + filePath, + durationMs, + cancellationToken, + effectiveOptions); + + return await _feishuCardKitClient.SendAudioMessageAsync( + chatId, + fileKey, + durationMs, + cancellationToken, + effectiveOptions); + } + + private async Task ResolveEffectiveOptionsAsync(string? username, string? appId) + { + if (!string.IsNullOrWhiteSpace(appId)) + { + var appOptions = await _userFeishuBotConfigService.GetEffectiveOptionsByAppIdAsync(appId); + if (appOptions != null) + { + return appOptions; + } + } + + if (!string.IsNullOrWhiteSpace(username)) + { + return await _userFeishuBotConfigService.GetEffectiveOptionsAsync(username); + } + + return _userFeishuBotConfigService.GetSharedDefaults(); + } +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs b/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs index e501c2e..a6cc3ee 100644 --- a/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs +++ b/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs @@ -150,12 +150,17 @@ public async Task HandleCardActionAsync( return await HandleSelectCommandAsync(action.CommandId, chatId); case "back_to_list": return await HandleBackToListAsync(chatId); + case FeishuHelpCardAction.ToggleReplyTtsAction: + return await HandleToggleReplyTtsAsync(chatId, operatorUserId); case "execute_command": return await HandleExecuteCommandAsync(formValueElement, action.Command, chatId, operatorUserId, inputValues, appId); case FeishuHelpCardAction.SubmitSuperpowersQuickInputAction: + case FeishuHelpCardAction.ContinueSuperpowersAction: case FeishuHelpCardAction.ExecuteSuperpowersPlanAction: case FeishuHelpCardAction.ExecuteSuperpowersSubagentPlanAction: - return await HandleSuperpowersQuickActionAsync(action, formValueElement, chatId, operatorUserId, appId); + return await HandleSuperpowersQuickActionAsync(action, formValueElement, chatId, operatorUserId, appId, inputValues); + case FeishuHelpCardAction.SubmitGoalQuickInputAction: + return await HandleGoalQuickActionAsync(action, formValueElement, chatId, operatorUserId, appId, inputValues); case FeishuHelpCardAction.RetrySuperpowersCapabilityDetectionAction: return await HandleRetrySuperpowersCapabilityDetectionAsync(action, chatId); case LowInterruptionContinueHelper.ActionName: @@ -256,8 +261,7 @@ private async Task HandleRefreshCommandsAsync(stri { var toolId = ResolveToolIdForChat(chatId); await _commandService.RefreshCommandsAsync(toolId); - var categories = await _commandService.GetCategorizedCommandsAsync(toolId); - var card = _cardBuilder.BuildCommandListCardV2(categories, showRefreshButton: false); + var card = await BuildHelpCommandListCardAsync(chatId, showRefreshButton: false); _logger.LogInformation("✅ [FeishuHelp] 返回命令列表卡片(回调响应)"); return _cardBuilder.BuildCardActionResponseV2(card, "🔄 命令列表已更新", "info"); } @@ -275,8 +279,7 @@ private async Task HandleSelectCommandAsync(string if (string.IsNullOrEmpty(commandId)) { - var categories = await _commandService.GetCategorizedCommandsAsync(ResolveToolIdForChat(chatId)); - var card = _cardBuilder.BuildCommandListCardV2(categories); + var card = await BuildHelpCommandListCardAsync(chatId); return _cardBuilder.BuildCardActionResponseV2(card, "📋 显示命令列表", "info"); } @@ -295,8 +298,7 @@ private async Task HandleSelectCommandAsync(string var command = await _commandService.GetCommandAsync(commandId, toolId); if (command == null) { - var categories = await _commandService.GetCategorizedCommandsAsync(toolId); - var card = _cardBuilder.BuildCommandListCardV2(categories); + var card = await BuildHelpCommandListCardAsync(chatId); _logger.LogWarning("❌ [FeishuHelp] 命令不存在"); return _cardBuilder.BuildCardActionResponseV2(card, "❌ 命令不存在", "warning"); } @@ -317,12 +319,47 @@ private async Task HandleBackToListAsync(string? c return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 缺少 chatId", "error"); } - var categories = await _commandService.GetCategorizedCommandsAsync(ResolveToolIdForChat(chatId)); - var card = _cardBuilder.BuildCommandListCardV2(categories); + var card = await BuildHelpCommandListCardAsync(chatId); _logger.LogInformation("📋 [FeishuHelp] 返回命令列表卡片(回调响应)"); return _cardBuilder.BuildCardActionResponseV2(card, "", "info"); } + private async Task HandleToggleReplyTtsAsync(string? chatId, string? operatorUserId) + { + if (string.IsNullOrWhiteSpace(chatId)) + { + return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 缺少 chatId", "error"); + } + + var actualChatKey = NormalizeChatKey(chatId); + var username = ResolveFeishuUsername(actualChatKey, operatorUserId); + if (string.IsNullOrWhiteSpace(username)) + { + return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 未找到当前飞书用户配置", "error"); + } + + using var scope = _serviceProvider.CreateScope(); + var userFeishuBotConfigService = scope.ServiceProvider.GetRequiredService(); + var config = await userFeishuBotConfigService.GetByUsernameAsync(username); + if (config == null) + { + return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 未找到当前飞书用户配置", "error"); + } + + config.ReplyTtsEnabled = !config.ReplyTtsEnabled; + var saveResult = await userFeishuBotConfigService.SaveAsync(config); + if (!saveResult.Success) + { + return _cardBuilder.BuildCardActionToastOnlyResponse( + $"❌ {(string.IsNullOrWhiteSpace(saveResult.ErrorMessage) ? "飞书语音回复更新失败" : saveResult.ErrorMessage)}", + "error"); + } + + var card = await BuildHelpCommandListCardAsync(chatId); + var toastMessage = config.ReplyTtsEnabled ? "✅ 已开启飞书语音回复" : "✅ 已关闭飞书语音回复"; + return _cardBuilder.BuildCardActionResponseV2(card, toastMessage, "success"); + } + /// /// 处理执行命令 /// @@ -458,7 +495,9 @@ await ExecuteCliAndStreamAsync( toolId, cliPrompt, chatId, - effectiveOptions.ThinkingMessage); + effectiveOptions.ThinkingMessage, + username, + appId); } catch (Exception ex) { @@ -474,12 +513,14 @@ private async Task HandleSuperpowersQuickActionAsy JsonElement? formValue, string? chatId, string? operatorUserId, - string? appId) + string? appId, + string? inputValues) { var prompt = action.Action switch { FeishuHelpCardAction.SubmitSuperpowersQuickInputAction => SuperpowersPromptBuilder.BuildQuickSkillPrompt( - GetFormStringValue(formValue, SuperpowersQuickActionDefaults.QuickInputFieldName)), + ResolveQuickInputValue(formValue, SuperpowersQuickActionDefaults.QuickInputFieldName, inputValues)), + FeishuHelpCardAction.ContinueSuperpowersAction => SuperpowersPromptBuilder.BuildContinuePrompt(), FeishuHelpCardAction.ExecuteSuperpowersPlanAction => SuperpowersPromptBuilder.BuildExecutePlanPrompt(), FeishuHelpCardAction.ExecuteSuperpowersSubagentPlanAction => SuperpowersPromptBuilder.BuildSubagentExecutePlanPrompt(), _ => null @@ -508,17 +549,20 @@ private async Task HandleSuperpowersQuickActionAsy } var effectiveToolId = await ResolveSessionToolIdAsync(activeSessionId, action.ToolId, targetChatKey, null); - var capabilityResult = await ProbeSuperpowersCapabilityAsync(activeSessionId, effectiveToolId, forceRefresh: false); - if (capabilityResult.State != SuperpowersCapabilityState.Available) + if (!string.Equals(action.Action, FeishuHelpCardAction.ContinueSuperpowersAction, StringComparison.Ordinal)) { - var message = string.IsNullOrWhiteSpace(capabilityResult.Message) - ? SuperpowersQuickActionDefaults.CapabilityProbeFailedText - : capabilityResult.Message!; - return _cardBuilder.BuildCardActionToastOnlyResponse( - capabilityResult.State == SuperpowersCapabilityState.Unavailable - ? $"⚠️ {message}" - : $"⚠️ {message}", - "warning"); + var capabilityResult = await ProbeSuperpowersCapabilityAsync(activeSessionId, effectiveToolId, forceRefresh: false); + if (capabilityResult.State != SuperpowersCapabilityState.Available) + { + var message = string.IsNullOrWhiteSpace(capabilityResult.Message) + ? SuperpowersQuickActionDefaults.CapabilityProbeFailedText + : capabilityResult.Message!; + return _cardBuilder.BuildCardActionToastOnlyResponse( + capabilityResult.State == SuperpowersCapabilityState.Unavailable + ? $"⚠️ {message}" + : $"⚠️ {message}", + "warning"); + } } return await HandleExecuteCommandAsync( @@ -563,6 +607,59 @@ private async Task HandleRetrySuperpowersCapabilit return _cardBuilder.BuildCardActionToastOnlyResponse($"⚠️ {message}", "warning"); } + private async Task HandleGoalQuickActionAsync( + FeishuHelpCardAction action, + JsonElement? formValue, + string? chatId, + string? operatorUserId, + string? appId, + string? inputValues) + { + var prompt = GoalPromptBuilder.BuildGoalPrompt( + ResolveQuickInputValue(formValue, GoalQuickActionDefaults.QuickInputFieldName, inputValues)); + if (string.IsNullOrWhiteSpace(prompt)) + { + return _cardBuilder.BuildCardActionToastOnlyResponse("⚠️ 请输入目标", "warning"); + } + + var targetChatKey = action.ChatKey ?? chatId; + if (string.IsNullOrWhiteSpace(targetChatKey)) + { + return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 缺少必要参数", "error"); + } + + var activeSessionId = action.SessionId ?? _feishuChannel.GetCurrentSession(NormalizeChatKey(targetChatKey)); + if (!string.IsNullOrWhiteSpace(activeSessionId) && _feishuChannel.IsSessionExecutionActive(activeSessionId)) + { + return _cardBuilder.BuildCardActionToastOnlyResponse("⚠️ 当前会话已有任务在执行,请等待完成后再试", "warning"); + } + + if (string.IsNullOrWhiteSpace(activeSessionId)) + { + return _cardBuilder.BuildCardActionToastOnlyResponse("❌ 缺少当前会话,无法执行 /goal 快捷操作", "error"); + } + + var effectiveToolId = await ResolveSessionToolIdAsync(activeSessionId, action.ToolId, targetChatKey, null); + var capabilityResult = await ProbeGoalCapabilityAsync(activeSessionId, effectiveToolId, forceRefresh: false); + if (capabilityResult.State != GoalCapabilityState.Available) + { + var message = string.IsNullOrWhiteSpace(capabilityResult.Message) + ? GoalQuickActionDefaults.CapabilityProbeFailedText + : capabilityResult.Message!; + return _cardBuilder.BuildCardActionToastOnlyResponse($"⚠️ {message}", "warning"); + } + + return await HandleExecuteCommandAsync( + formValue: null, + commandFromAction: prompt, + chatId: targetChatKey, + operatorUserId: operatorUserId, + inputValues: null, + appId: appId, + preferredSessionId: activeSessionId, + preferredToolId: effectiveToolId); + } + private async Task HandleLowInterruptionContinueAsync( string? sessionId, string? chatKey, @@ -626,7 +723,9 @@ await ExecuteLowInterruptionContinueAndStreamAsync( effectiveToolId, prompt, actualChatKey, - effectiveOptions.ThinkingMessage); + effectiveOptions.ThinkingMessage, + username, + appId); } catch (Exception ex) { @@ -718,14 +817,14 @@ private async Task HandleShowCategoryAsync(string? if (string.IsNullOrEmpty(categoryId)) { - var card = _cardBuilder.BuildCommandListCardV2(categories); + var card = await BuildHelpCommandListCardAsync(chatId); return _cardBuilder.BuildCardActionResponseV2(card, "📋 显示命令列表", "info"); } var category = categories.FirstOrDefault(c => string.Equals(c.Id, categoryId, StringComparison.OrdinalIgnoreCase)); if (category == null) { - var card = _cardBuilder.BuildCommandListCardV2(categories); + var card = await BuildHelpCommandListCardAsync(chatId); return _cardBuilder.BuildCardActionResponseV2(card, "❌ 分类不存在", "warning"); } @@ -745,7 +844,9 @@ private async Task ExecuteCliAndStreamAsync( string toolId, string userPrompt, string chatId, - string thinkingMessage) + string thinkingMessage, + string? username, + string? appId) { var outputBuilder = new System.Text.StringBuilder(); var assistantMessageBuilder = new System.Text.StringBuilder(); @@ -758,7 +859,9 @@ private async Task ExecuteCliAndStreamAsync( if (tool == null) { streamingChrome.StatusMarkdown = FeishuStreamingStatusFormatter.WithErrorState(baseStatusMarkdown); - await handle.FinishAsync($"错误:未找到 CLI 工具 '{resolvedToolId}',请在配置中添加该工具。"); + await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( + latestRenderedContent, + $"未找到 CLI 工具 '{resolvedToolId}',请在配置中添加该工具。")); _logger.LogWarning("CLI tool not found: {ToolId}", resolvedToolId); return; } @@ -795,7 +898,9 @@ private async Task ExecuteCliAndStreamAsync( chunk.ErrorMessage ?? "Unknown error"); statusPulseCts.Cancel(); streamingChrome.StatusMarkdown = FeishuStreamingStatusFormatter.WithErrorState(baseStatusMarkdown); - await handle.FinishAsync($"错误:{chunk.ErrorMessage ?? "执行失败"}"); + await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( + latestRenderedContent, + chunk.ErrorMessage ?? "执行失败")); return; } @@ -878,6 +983,8 @@ await _feishuChannel.SendMessageAsync( CreatedAt = DateTime.Now }); + await TryQueueCompletedReplyTtsAsync(chatId, username, appId, sessionId, finalOutput); + _logger.LogInformation( "CLI execution completed for session: {SessionId}", sessionId); @@ -887,7 +994,9 @@ await _feishuChannel.SendMessageAsync( _logger.LogError(ex, "CLI execution failed for session: {SessionId}", sessionId); statusPulseCts.Cancel(); streamingChrome.StatusMarkdown = FeishuStreamingStatusFormatter.WithErrorState(baseStatusMarkdown); - await handle.FinishAsync($"执行出错:{ex.Message}"); + await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( + latestRenderedContent, + ex.Message)); } finally { @@ -910,7 +1019,9 @@ private async Task ExecuteLowInterruptionContinueAndStreamAsync( string toolId, string? prompt, string chatId, - string thinkingMessage) + string thinkingMessage, + string? username, + string? appId) { var outputBuilder = new System.Text.StringBuilder(); var assistantMessageBuilder = new System.Text.StringBuilder(); @@ -923,7 +1034,9 @@ private async Task ExecuteLowInterruptionContinueAndStreamAsync( if (tool == null) { streamingChrome.StatusMarkdown = FeishuStreamingStatusFormatter.WithErrorState(baseStatusMarkdown); - await handle.FinishAsync($"错误:未找到 CLI 工具 '{resolvedToolId}',请在配置中添加该工具。"); + await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( + latestRenderedContent, + $"未找到 CLI 工具 '{resolvedToolId}',请在配置中添加该工具。")); _logger.LogWarning("CLI tool not found for low interruption continue: {ToolId}", resolvedToolId); return; } @@ -953,7 +1066,9 @@ private async Task ExecuteLowInterruptionContinueAndStreamAsync( chunk.ErrorMessage ?? "Unknown error"); statusPulseCts.Cancel(); streamingChrome.StatusMarkdown = FeishuStreamingStatusFormatter.WithErrorState(baseStatusMarkdown); - await handle.FinishAsync($"错误:{chunk.ErrorMessage ?? "执行失败"}"); + await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( + latestRenderedContent, + chunk.ErrorMessage ?? "执行失败")); return; } @@ -1033,6 +1148,8 @@ await _feishuChannel.SendMessageAsync( CreatedAt = DateTime.Now }); + await TryQueueCompletedReplyTtsAsync(chatId, username, appId, sessionId, finalOutput); + _logger.LogInformation( "Low interruption continue completed for session: {SessionId}", sessionId); @@ -1042,7 +1159,9 @@ await _feishuChannel.SendMessageAsync( _logger.LogError(ex, "Low interruption continue failed for session: {SessionId}", sessionId); statusPulseCts.Cancel(); streamingChrome.StatusMarkdown = FeishuStreamingStatusFormatter.WithErrorState(baseStatusMarkdown); - await handle.FinishAsync($"执行出错:{ex.Message}"); + await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( + latestRenderedContent, + ex.Message)); } finally { @@ -1076,6 +1195,17 @@ private void TryAttachSuperpowersQuickActions( normalizedChatKey, normalizedToolId, capabilityState); + streamingChrome.AdditionalBottomPrompts.Clear(); + var goalCapabilityState = ResolveGoalCapabilityState(sessionId, normalizedToolId); + var goalPrompt = GoalQuickActionCardHelper.CreateBottomPrompt( + sessionId, + normalizedChatKey, + normalizedToolId, + goalCapabilityState); + if (goalPrompt != null) + { + streamingChrome.AdditionalBottomPrompts.Add(goalPrompt); + } streamingChrome.BottomActions.Clear(); streamingChrome.BottomActions.AddRange(SuperpowersQuickActionCardHelper.CreateBottomActions( sessionId, @@ -1086,6 +1216,9 @@ private void TryAttachSuperpowersQuickActions( streamingChrome.StatusMarkdown = SuperpowersQuickActionCardHelper.MergeCapabilityStatusMarkdown( streamingChrome.StatusMarkdown, capabilityState); + streamingChrome.StatusMarkdown = GoalQuickActionCardHelper.MergeCapabilityStatusMarkdown( + streamingChrome.StatusMarkdown, + goalCapabilityState); } private bool ShouldShowSuperpowersPlanActions(string sessionId) @@ -1116,6 +1249,41 @@ private bool HasSuperpowersPlanFiles(string sessionId) } } + private async Task TryQueueCompletedReplyTtsAsync( + string chatId, + string? username, + string? appId, + string sessionId, + string finalOutput) + { + try + { + using var scope = _serviceProvider.CreateScope(); + var replyTtsOrchestrator = scope.ServiceProvider.GetService(); + if (replyTtsOrchestrator == null) + { + return; + } + + await replyTtsOrchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest + { + ChatId = chatId, + SessionId = sessionId, + Username = username, + AppId = appId, + Output = finalOutput + }); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to queue reply TTS after Feishu card action completion: SessionId={SessionId}, ChatId={ChatId}", + sessionId, + chatId); + } + } + private string? TryGetSessionWorkspacePath(string sessionId) { try @@ -1161,6 +1329,27 @@ private bool HasSuperpowersPlanFiles(string sessionId) }).GetAwaiter().GetResult(); } + private GoalCapabilitySnapshot? ResolveGoalCapabilityState(string sessionId, string toolId) + { + using var scope = _serviceProvider.CreateScope(); + var capabilityService = scope.ServiceProvider.GetService(); + var repo = scope.ServiceProvider.GetService(); + if (capabilityService == null) + { + return null; + } + + var session = repo?.GetByIdAsync(sessionId).GetAwaiter().GetResult(); + var normalizedToolId = NormalizeToolId(session?.CcSwitchSnapshotToolId ?? toolId) ?? toolId; + + return capabilityService.GetStateAsync(new GoalCapabilityContext + { + ToolId = normalizedToolId, + ProviderId = session?.CcSwitchProviderId, + WorkspacePath = session?.WorkspacePath + }).GetAwaiter().GetResult(); + } + private async Task ProbeSuperpowersCapabilityAsync( string sessionId, string toolId, @@ -1182,6 +1371,27 @@ private async Task ProbeSuperpowersCapabilityA forceRefresh: forceRefresh); } + private async Task ProbeGoalCapabilityAsync( + string sessionId, + string toolId, + bool forceRefresh) + { + using var scope = _serviceProvider.CreateScope(); + var capabilityService = scope.ServiceProvider.GetRequiredService(); + var repo = scope.ServiceProvider.GetRequiredService(); + var session = await repo.GetByIdAsync(sessionId); + var normalizedToolId = NormalizeToolId(session?.CcSwitchSnapshotToolId ?? toolId) ?? toolId; + + return await capabilityService.ProbeAsync( + new GoalCapabilityContext + { + ToolId = normalizedToolId, + ProviderId = session?.CcSwitchProviderId, + WorkspacePath = session?.WorkspacePath + }, + forceRefresh: forceRefresh); + } + private static bool SessionContainsSuperpowers(IEnumerable messages) { return messages.Any(message => @@ -1663,7 +1873,7 @@ private async Task SendExternalCliHistoryAsync( cliThreadId); var historyLimit = ResolveHistoryCommandLimit(commandInput); - var messages = await historyService.GetRecentMessagesAsync( + var history = await historyService.GetRecentHistoryAsync( normalizedToolId, cliThreadId, maxCount: historyLimit, @@ -1672,8 +1882,10 @@ private async Task SendExternalCliHistoryAsync( sessionId, toolLabel ?? GetToolDisplayName(sessionEntity.ToolId), workspacePath ?? GetSessionWorkspaceDisplay(sessionId), + cliThreadId, lastActiveTime ?? _feishuChannel.GetSessionLastActiveTime(sessionId), - messages); + history.Messages, + history.SourcePath); var messageId = await _feishuChannel.SendMessageAsync(chatId, content, username, appId); _logger.LogInformation( @@ -1681,66 +1893,26 @@ private async Task SendExternalCliHistoryAsync( sessionId, chatId, messageId, - messages.Count); + history.Messages.Count); } private static string BuildExternalCliHistoryText( string sessionId, string toolLabel, string workspacePath, + string? cliThreadId, 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(); - } - - builder.AppendLine($"显示条数: 最近 {messages.Count} 条"); - builder.AppendLine(); - - foreach (var message in messages) - { - 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(NormalizeHistoryContent(message.Content)); - builder.AppendLine(); - } - - return builder.ToString().TrimEnd(); - } - - private static string NormalizeHistoryContent(string? content) + IReadOnlyList messages, + string? sourcePath) { - if (string.IsNullOrWhiteSpace(content)) - { - return string.Empty; - } - - return content.Replace("\r\n", "\n").Trim(); + return ExternalCliHistoryTextBuilder.Build( + $"当前 CLI 会话历史 {sessionId[..Math.Min(8, sessionId.Length)]}", + messages, + toolLabel, + workspacePath, + cliThreadId, + sourcePath, + lastActiveTime); } private static bool IsHistoryCommand(string? commandInput) @@ -2147,6 +2319,29 @@ private string ResolveDefaultToolId() return _feishuChannel.GetSessionUsername(chatKey); } + private async Task BuildHelpCommandListCardAsync(string? chatId, bool showRefreshButton = true) + { + var actualChatKey = string.IsNullOrWhiteSpace(chatId) ? null : NormalizeChatKey(chatId); + var username = string.IsNullOrWhiteSpace(actualChatKey) ? null : _feishuChannel.GetSessionUsername(actualChatKey); + var toolId = ResolveToolIdForChat(actualChatKey, username); + var categories = await _commandService.GetCategorizedCommandsAsync(toolId); + var replyTtsEnabled = await GetReplyTtsEnabledAsync(username); + return _cardBuilder.BuildCommandListCardV2(categories, showRefreshButton, replyTtsEnabled); + } + + private async Task GetReplyTtsEnabledAsync(string? username) + { + if (string.IsNullOrWhiteSpace(username)) + { + return false; + } + + using var scope = _serviceProvider.CreateScope(); + var userFeishuBotConfigService = scope.ServiceProvider.GetRequiredService(); + var config = await userFeishuBotConfigService.GetByUsernameAsync(username); + return config?.ReplyTtsEnabled == true; + } + private async Task ResolveEffectiveOptionsAsync(string? username, string? appId = null) { using var scope = _serviceProvider.CreateScope(); @@ -2179,6 +2374,12 @@ JsonValueKind.Object when valueElement.TryGetProperty("value", out var nestedVal }; } + private static string? ResolveQuickInputValue(JsonElement? formValue, string key, string? inputValues) + { + var formInput = GetFormStringValue(formValue, key); + return string.IsNullOrWhiteSpace(formInput) ? inputValues : formInput; + } + private CreateProjectRequest BuildProjectRequestFromForm(JsonElement? formValue) { var authType = NormalizeProjectAuthType(GetFormStringValue(formValue, "project_auth_type")); @@ -2874,63 +3075,6 @@ private static void SetTopChipGroupsEnabled(FeishuStreamingCardChrome chrome, bo private ElementsCardV2Dto BuildStreamingCardRefreshCard(string content, FeishuStreamingCardChrome chrome) { - var elements = new List(); - var statusMarkdown = string.IsNullOrWhiteSpace(chrome.StatusMarkdown) - ? "当前会话" - : chrome.StatusMarkdown; - - if (chrome.OverflowOptions.Count > 0) - { - elements.Add(new - { - tag = "div", - text = new - { - tag = "lark_md", - content = statusMarkdown - }, - extra = new - { - tag = "overflow", - options = chrome.OverflowOptions.Select(option => new - { - text = new { tag = "plain_text", content = option.Text }, - value = JsonSerializer.Serialize(option.Value) - }).ToArray() - } - }); - } - else - { - elements.Add(new - { - tag = "div", - text = new - { - tag = "lark_md", - content = statusMarkdown - } - }); - } - - elements.Add(new { tag = "hr" }); - - foreach (var module in FeishuStreamingTopChipLayout.BuildModules(chrome.TopChipGroups, BuildStreamingChipButton)) - { - elements.Add(module); - } - - if (chrome.TopChipGroups.Count > 0) - { - elements.Add(new { tag = "hr" }); - } - - elements.Add(new - { - tag = "markdown", - content - }); - return new ElementsCardV2Dto { Config = new ElementsCardV2Dto.ConfigSuffix @@ -2940,16 +3084,11 @@ private ElementsCardV2Dto BuildStreamingCardRefreshCard(string content, FeishuSt }, Body = new ElementsCardV2Dto.BodySuffix { - Elements = elements.ToArray() + Elements = FeishuCardKitClient.BuildStreamingCardElements(content, chrome) } }; } - private static object BuildStreamingChipButton(FeishuStreamingCardTopChipItem item) - { - return FeishuStreamingTopChipLayout.BuildButton(item); - } - private static string BuildSessionOptionText(ChatSessionEntity session) { var workspaceName = ExtractWorkspaceDirectoryName(session.WorkspacePath) ?? "未命名会话"; diff --git a/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs b/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs index b34238a..784bf06 100644 --- a/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs +++ b/WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs @@ -133,6 +133,80 @@ public async Task SendTextMessageAsync( return ExtractMessageId(result, "send text message"); } + public async Task UploadAudioFileAsync( + string filePath, + int durationMs, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + throw new ArgumentException("Audio file path is required.", nameof(filePath)); + } + + var effectiveOptions = GetEffectiveOptions(optionsOverride); + var token = await EnsureTokenAsync(effectiveOptions, cancellationToken); + + using var fileStream = File.OpenRead(filePath); + using var payload = new MultipartFormDataContent + { + { new StringContent("opus"), "file_type" }, + { new StringContent(Path.GetFileName(filePath)), "file_name" }, + { new StringContent(durationMs.ToString()), "duration" } + }; + payload.Add(new StreamContent(fileStream), "file", Path.GetFileName(filePath)); + + var response = await PostMultipartAsync( + "/open-apis/im/v1/files", + token, + payload, + effectiveOptions, + cancellationToken); + + var result = await ParseResponseAsync(response, cancellationToken); + EnsureBusinessSuccess(result, "Upload Feishu audio file"); + + if (result.TryGetProperty("data", out var data) && + data.TryGetProperty("file_key", out var fileKeyProp)) + { + return fileKeyProp.GetString() ?? string.Empty; + } + + throw new InvalidOperationException("Failed to upload audio file: invalid response"); + } + + public async Task SendAudioMessageAsync( + string chatId, + string fileKey, + int durationMs, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + var effectiveOptions = GetEffectiveOptions(optionsOverride); + var token = await EnsureTokenAsync(effectiveOptions, cancellationToken); + + var payload = new + { + receive_id = chatId, + msg_type = "audio", + content = JsonSerializer.Serialize(new + { + file_key = fileKey + }) + }; + + var response = await PostAsync( + "/open-apis/im/v1/messages?receive_id_type=chat_id", + token, + payload, + effectiveOptions, + cancellationToken); + + var result = await ParseResponseAsync(response, cancellationToken); + EnsureBusinessSuccess(result, "Send Feishu audio message"); + return ExtractMessageId(result, "send audio message"); + } + public async Task ReplyCardMessageAsync( string replyMessageId, string cardId, @@ -395,7 +469,7 @@ private static int ResolveQuietWindowAfterUpdateMs(FeishuStreamingCardChrome? ch return chrome?.OverflowOptions.Count is > 0 ? 4000 : 0; } - private object[] BuildStreamingCardElements(string content, FeishuStreamingCardChrome? chrome) + internal static object[] BuildStreamingCardElements(string content, FeishuStreamingCardChrome? chrome) { if (chrome == null) { @@ -414,8 +488,9 @@ private object[] BuildStreamingCardElements(string content, FeishuStreamingCardC !string.IsNullOrWhiteSpace(group.SummaryMarkdown) || group.OverflowOptions.Count > 0 || group.Items.Any(item => !string.IsNullOrWhiteSpace(item.Text))); + var allBottomPrompts = EnumerateBottomPrompts(chrome).ToArray(); var hasBottomActions = chrome.BottomActions.Count > 0; - var hasBottomPrompt = chrome.BottomPrompt != null; + var hasBottomPrompt = allBottomPrompts.Length > 0; if (!hasStatusSection && !hasTopChipGroups && !hasBottomActions && !hasBottomPrompt) { return @@ -431,53 +506,20 @@ private object[] BuildStreamingCardElements(string content, FeishuStreamingCardC var elements = new List(); if (hasStatusSection) { - var statusMarkdown = string.IsNullOrWhiteSpace(chrome.StatusMarkdown) - ? "当前会话" - : chrome.StatusMarkdown; - - if (chrome.OverflowOptions.Count > 0) - { - elements.Add(new - { - tag = "div", - text = new - { - tag = "lark_md", - content = statusMarkdown - }, - extra = new - { - tag = "overflow", - options = BuildOverflowOptions(chrome.OverflowOptions) - } - }); - } - else - { - elements.Add(new - { - tag = "div", - text = new - { - tag = "lark_md", - content = statusMarkdown - } - }); - } - - elements.Add(new { tag = "hr" }); + elements.Add(BuildStatusModule(chrome)); } if (hasTopChipGroups) { + elements.Add(BuildSectionMarker("思考等级")); + foreach (var module in FeishuStreamingTopChipLayout.BuildModules(chrome.TopChipGroups, BuildTopChipAction)) { elements.Add(module); } - - elements.Add(new { tag = "hr" }); } + elements.Add(BuildSectionMarker("回复内容")); elements.Add(new { tag = "markdown", @@ -486,11 +528,11 @@ private object[] BuildStreamingCardElements(string content, FeishuStreamingCardC if (hasBottomPrompt || hasBottomActions) { - elements.Add(new { tag = "hr" }); + elements.Add(BuildSectionMarker("Superpowers 工作流")); - if (hasBottomPrompt) + foreach (var prompt in allBottomPrompts) { - elements.Add(BuildBottomPromptForm(chrome.BottomPrompt!)); + elements.Add(BuildBottomPromptForm(prompt)); } if (hasBottomActions) @@ -508,6 +550,54 @@ private object[] BuildStreamingCardElements(string content, FeishuStreamingCardC return elements.ToArray(); } + private static object BuildStatusModule(FeishuStreamingCardChrome chrome) + { + var statusMarkdown = string.IsNullOrWhiteSpace(chrome.StatusMarkdown) + ? "当前会话" + : chrome.StatusMarkdown; + + if (chrome.OverflowOptions.Count > 0) + { + return new + { + tag = "div", + text = new + { + tag = "lark_md", + content = statusMarkdown + }, + extra = new + { + tag = "overflow", + options = BuildOverflowOptions(chrome.OverflowOptions) + } + }; + } + + return new + { + tag = "div", + text = new + { + tag = "lark_md", + content = statusMarkdown + } + }; + } + + private static object BuildSectionMarker(string title) + { + return new + { + tag = "div", + text = new + { + tag = "lark_md", + content = $"🟥🟥🟥 **{title}**" + } + }; + } + private static object BuildBottomPromptForm(FeishuStreamingCardBottomPrompt prompt) { return new @@ -555,7 +645,7 @@ private static object BuildBottomPromptForm(FeishuStreamingCardBottomPrompt prom text = new { tag = "plain_text", content = prompt.ButtonText }, type = string.IsNullOrWhiteSpace(prompt.ButtonType) ? "primary" : prompt.ButtonType, action_type = "form_submit", - name = "low_interruption_continue_submit", + name = BuildBottomPromptSubmitButtonName(prompt), value = prompt.Value } } @@ -566,7 +656,47 @@ private static object BuildBottomPromptForm(FeishuStreamingCardBottomPrompt prom }; } - private object[] BuildOverflowOptions(IEnumerable options) + private static IEnumerable EnumerateBottomPrompts(FeishuStreamingCardChrome chrome) + { + if (chrome.BottomPrompt != null) + { + yield return chrome.BottomPrompt; + } + + foreach (var prompt in chrome.AdditionalBottomPrompts) + { + if (prompt != null) + { + yield return prompt; + } + } + } + + private static string BuildBottomPromptSubmitButtonName(FeishuStreamingCardBottomPrompt prompt) + { + var source = !string.IsNullOrWhiteSpace(prompt.InputName) + ? prompt.InputName + : !string.IsNullOrWhiteSpace(prompt.FormName) + ? prompt.FormName + : "bottom_prompt"; + + Span buffer = stackalloc char[source.Length]; + var index = 0; + foreach (var ch in source) + { + buffer[index++] = char.IsLetterOrDigit(ch) ? ch : '_'; + } + + var normalized = new string(buffer[..index]).Trim('_'); + if (string.IsNullOrWhiteSpace(normalized)) + { + normalized = "bottom_prompt"; + } + + return $"{normalized}_submit"; + } + + private static object[] BuildOverflowOptions(IEnumerable options) { return options .Where(option => !string.IsNullOrWhiteSpace(option.Text)) @@ -587,7 +717,7 @@ private static object BuildTopChipAction(FeishuStreamingCardTopChipItem item) return FeishuStreamingTopChipLayout.BuildButton(item); } - private object[] BuildBottomActionColumns(IEnumerable actions) + private static object[] BuildBottomActionColumns(IEnumerable actions) { return actions .Where(action => !string.IsNullOrWhiteSpace(action.Text)) @@ -758,6 +888,26 @@ private async Task PutAsync( return await SendAsync(request, options, cancellationToken); } + private async Task PostMultipartAsync( + string path, + string token, + HttpContent payload, + FeishuOptions options, + CancellationToken cancellationToken) + { + var request = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}{path}") + { + Content = payload + }; + + if (!string.IsNullOrEmpty(token)) + { + request.Headers.Add("Authorization", $"Bearer {token}"); + } + + return await SendAsync(request, options, cancellationToken); + } + private async Task SendAsync( HttpRequestMessage request, FeishuOptions options, diff --git a/WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs b/WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs index 356b5d2..81bf13a 100644 --- a/WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs +++ b/WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs @@ -29,6 +29,7 @@ public class FeishuChannelService : BackgroundService, IFeishuChannelService private FeishuMessageHandler? _messageHandler; private readonly ICliExecutorService _cliExecutor; private readonly IChatSessionService _chatSessionService; + private readonly IReplyTtsOrchestrator? _replyTtsOrchestrator; private bool _isRunning = false; @@ -217,7 +218,8 @@ public FeishuChannelService( IFeishuCardKitClient cardKit, IServiceProvider serviceProvider, ICliExecutorService cliExecutor, - IChatSessionService chatSessionService) + IChatSessionService chatSessionService, + IReplyTtsOrchestrator? replyTtsOrchestrator = null) { _options = options.Value; _logger = logger; @@ -225,6 +227,7 @@ public FeishuChannelService( _serviceProvider = serviceProvider; _cliExecutor = cliExecutor; _chatSessionService = chatSessionService; + _replyTtsOrchestrator = replyTtsOrchestrator; } /// @@ -350,6 +353,7 @@ private async Task OnMessageReceivedAsync(FeishuIncomingMessage message) await ExecuteCliAndStreamAsync( handle, sessionId, + message.ChatId, toolId, cliPrompt, message.MessageId, @@ -648,6 +652,7 @@ private void CleanupUnboundFeishuSessions(IChatSessionRepository repo, IFeishuUs private async Task ExecuteCliAndStreamAsync( FeishuStreamingHandle handle, string sessionId, + string chatId, string toolId, string userPrompt, string messageId, @@ -661,6 +666,7 @@ private async Task ExecuteCliAndStreamAsync( var assistantMessageBuilder = new StringBuilder(); var jsonlBuffer = new StringBuilder(); // JSONL 缂撳啿鍖猴紝澶勭悊涓嶅畬鏁寸殑琛? var hasStructuredTodoList = false; + var latestRenderedContent = thinkingMessage; var resolvedToolId = NormalizeToolId(toolId) ?? ResolveDefaultToolId(); var tool = _cliExecutor.GetTool(resolvedToolId); @@ -668,7 +674,9 @@ private async Task ExecuteCliAndStreamAsync( { activeExecution.CancelPulse(); activeExecution.SetErrorStatus(); - await handle.FinishAsync($"错误:未找到 CLI 工具 '{resolvedToolId}',请在配置中添加该工具。"); + await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( + latestRenderedContent, + $"未找到 CLI 工具 '{resolvedToolId}',请在配置中添加该工具。")); _logger.LogWarning("CLI tool not found: {ToolId}", resolvedToolId); return; } @@ -705,7 +713,9 @@ private async Task ExecuteCliAndStreamAsync( chunk.ErrorMessage ?? "Unknown error"); activeExecution.CancelPulse(); activeExecution.SetErrorStatus(); - await handle.FinishAsync($"错误:{chunk.ErrorMessage ?? "执行失败"}"); + await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( + latestRenderedContent, + chunk.ErrorMessage ?? "执行失败")); return; } @@ -734,6 +744,7 @@ private async Task ExecuteCliAndStreamAsync( // 娴佸紡鏇存柊鍗$墖锛堣妭娴佸湪 handle 鍐呴儴澶勭悊锛? activeExecution.SetLatestRenderedContent(displayContent); + latestRenderedContent = displayContent; activeExecution.PausePulseForOverflowCard(StreamingStatusPulseQuietWindow); await handle.UpdateAsync(displayContent); @@ -817,6 +828,28 @@ await ReplyMessageAsync( await repo.UpdateAsync(session); } + if (_replyTtsOrchestrator != null) + { + try + { + await _replyTtsOrchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest + { + ChatId = chatId, + Username = username, + AppId = appId, + Output = finalOutput + }); + } + catch (Exception ttsQueueEx) + { + _logger.LogWarning( + ttsQueueEx, + "Failed to queue reply TTS after Feishu completion: Session={SessionId}, MessageId={MessageId}", + sessionId, + messageId); + } + } + _logger.LogInformation( "CLI execution completed for message: {MessageId}, session: {SessionId}", messageId, @@ -834,7 +867,9 @@ await ReplyMessageAsync( _logger.LogError(ex, "CLI execution failed for message: {MessageId}", messageId); activeExecution.CancelPulse(); activeExecution.SetErrorStatus(); - await handle.FinishAsync($"执行出错:{ex.Message}"); + await handle.FinishAsync(FeishuStreamingErrorFormatter.AppendError( + latestRenderedContent, + ex.Message)); } } @@ -1266,6 +1301,17 @@ private void TryAttachSuperpowersQuickActions( chatKey, normalizedToolId, capabilityState); + chrome.AdditionalBottomPrompts.Clear(); + var goalCapabilityState = ResolveGoalCapabilityState(sessionId, normalizedToolId); + var goalPrompt = GoalQuickActionCardHelper.CreateBottomPrompt( + sessionId, + chatKey, + normalizedToolId, + goalCapabilityState); + if (goalPrompt != null) + { + chrome.AdditionalBottomPrompts.Add(goalPrompt); + } chrome.BottomActions.Clear(); chrome.BottomActions.AddRange(SuperpowersQuickActionCardHelper.CreateBottomActions( sessionId, @@ -1276,6 +1322,9 @@ private void TryAttachSuperpowersQuickActions( chrome.StatusMarkdown = SuperpowersQuickActionCardHelper.MergeCapabilityStatusMarkdown( chrome.StatusMarkdown, capabilityState); + chrome.StatusMarkdown = GoalQuickActionCardHelper.MergeCapabilityStatusMarkdown( + chrome.StatusMarkdown, + goalCapabilityState); } private bool ShouldShowSuperpowersPlanActions(string sessionId) @@ -1351,6 +1400,27 @@ private bool HasSuperpowersPlanFiles(string sessionId) }).GetAwaiter().GetResult(); } + private GoalCapabilitySnapshot? ResolveGoalCapabilityState(string sessionId, string toolId) + { + using var scope = _serviceProvider.CreateScope(); + var capabilityService = scope.ServiceProvider.GetService(); + var repo = scope.ServiceProvider.GetService(); + if (capabilityService == null) + { + return null; + } + + var session = repo?.GetByIdAsync(sessionId).GetAwaiter().GetResult(); + var normalizedToolId = NormalizeToolId(session?.CcSwitchSnapshotToolId ?? toolId) ?? toolId; + + return capabilityService.GetStateAsync(new GoalCapabilityContext + { + ToolId = normalizedToolId, + ProviderId = session?.CcSwitchProviderId, + WorkspacePath = session?.WorkspacePath + }).GetAwaiter().GetResult(); + } + private static bool SessionContainsSuperpowers(IEnumerable messages) { return messages.Any(message => diff --git a/WebCodeCli.Domain/Domain/Service/Channels/FeishuHelpCardBuilder.cs b/WebCodeCli.Domain/Domain/Service/Channels/FeishuHelpCardBuilder.cs index 98c3068..e1e5c52 100644 --- a/WebCodeCli.Domain/Domain/Service/Channels/FeishuHelpCardBuilder.cs +++ b/WebCodeCli.Domain/Domain/Service/Channels/FeishuHelpCardBuilder.cs @@ -18,7 +18,7 @@ public class FeishuHelpCardBuilder /// /// 构建命令选择卡片(卡片1)- 使用 ElementsCardV2Dto /// - public ElementsCardV2Dto BuildCommandListCardV2(List categories, bool showRefreshButton = true) + public ElementsCardV2Dto BuildCommandListCardV2(List categories, bool showRefreshButton = true, bool replyTtsEnabled = false) { var elements = new List(); @@ -82,6 +82,20 @@ public ElementsCardV2Dto BuildCommandListCardV2(List cate } } }); + + elements.Add(new + { + tag = "column_set", + flex_mode = "none", + background_style = "default", + columns = new[] + { + BuildTopActionColumn( + $"语音回复:{(replyTtsEnabled ? "开" : "关")}", + replyTtsEnabled ? "primary" : "default", + new { action = FeishuHelpCardAction.ToggleReplyTtsAction }) + } + }); } // 每个分组显示为分类按钮,避免首页元素超限 @@ -626,7 +640,7 @@ public CardActionTriggerResponseDto BuildCardActionToastOnlyResponse(string toas /// 命令分组列表 /// 是否显示刷新按钮 /// 飞书卡片JSON - public string BuildCommandListCard(List categories, bool showRefreshButton = true) + public string BuildCommandListCard(List categories, bool showRefreshButton = true, bool replyTtsEnabled = false) { var elements = new List(); @@ -690,6 +704,20 @@ public string BuildCommandListCard(List categories, bool } } }); + + elements.Add(new + { + tag = "column_set", + flex_mode = "none", + background_style = "default", + columns = new[] + { + BuildTopActionColumn( + $"语音回复:{(replyTtsEnabled ? "开" : "关")}", + replyTtsEnabled ? "primary" : "default", + new { action = FeishuHelpCardAction.ToggleReplyTtsAction }) + } + }); } // 每个分组显示为分类按钮,避免首页元素超限 @@ -924,6 +952,7 @@ private static void AppendSuperpowersQuickActionElements(List elements) { elements.Add(new { tag = "hr" }); elements.Add(BuildSuperpowersQuickInput()); + elements.Add(BuildGoalQuickInput()); } private static object BuildSuperpowersQuickInput() @@ -949,6 +978,29 @@ private static object BuildSuperpowersQuickInput() }; } + private static object BuildGoalQuickInput() + { + return new + { + tag = "input", + input_type = "text", + name = GoalQuickActionDefaults.QuickInputFieldName, + label = new { tag = "plain_text", content = GoalQuickActionDefaults.InstructionText }, + placeholder = new { tag = "plain_text", content = GoalQuickActionDefaults.QuickInputPlaceholder }, + behaviors = new[] + { + new + { + type = "callback", + value = new + { + action = FeishuHelpCardAction.SubmitGoalQuickInputAction + } + } + } + }; + } + public object BuildToastResponse(string cardJson, string toastMessage, string toastType = "info") { return new @@ -1055,6 +1107,34 @@ private static object BuildCategoryActionRow(FeishuCommandCategory category) }; } + private static object BuildTopActionColumn(string text, string type, object value) + { + return new + { + tag = "column", + width = "weighted", + weight = 1, + vertical_align = "top", + elements = new object[] + { + new + { + tag = "button", + text = new { tag = "plain_text", content = text }, + type, + behaviors = new[] + { + new + { + type = "callback", + value + } + } + } + } + }; + } + private static object BuildCommandActionRow(FeishuCommand command) { var description = SanitizeMarkdown(command.Description); diff --git a/WebCodeCli.Domain/Domain/Service/Channels/FeishuMessageHandler.cs b/WebCodeCli.Domain/Domain/Service/Channels/FeishuMessageHandler.cs index f42f357..bbfe3bf 100644 --- a/WebCodeCli.Domain/Domain/Service/Channels/FeishuMessageHandler.cs +++ b/WebCodeCli.Domain/Domain/Service/Channels/FeishuMessageHandler.cs @@ -480,7 +480,9 @@ private async Task HandleFeishuHelpAsync(string chatId, string replyToMessageId, { categories = await _commandService.GetCategorizedCommandsAsync(toolId); _logger.LogInformation("🔥 [FeishuHelp] 获取到 {Count} 个分组", categories.Count); - card = _cardBuilder.BuildCommandListCardV2(categories); + card = _cardBuilder.BuildCommandListCardV2( + categories, + replyTtsEnabled: await GetReplyTtsEnabledAsync(chatId, webUsername)); } else { @@ -566,6 +568,22 @@ private async Task ResolveEffectiveOptionsAsync(string? username, return await userFeishuBotConfigService.GetEffectiveOptionsAsync(username); } + private async Task GetReplyTtsEnabledAsync(string chatId, string? username) + { + var resolvedUsername = string.IsNullOrWhiteSpace(username) + ? _feishuChannel.GetSessionUsername(chatId) + : username; + if (string.IsNullOrWhiteSpace(resolvedUsername)) + { + return false; + } + + using var scope = _serviceProvider.CreateScope(); + var userFeishuBotConfigService = scope.ServiceProvider.GetRequiredService(); + var config = await userFeishuBotConfigService.GetByUsernameAsync(resolvedUsername); + return config?.ReplyTtsEnabled == true; + } + private async Task> GetChatSessionEntitiesAsync(string chatKey, string username) { using var scope = _serviceProvider.CreateScope(); diff --git a/WebCodeCli.Domain/Domain/Service/Channels/FeishuReplyTtsPlatformService.cs b/WebCodeCli.Domain/Domain/Service/Channels/FeishuReplyTtsPlatformService.cs new file mode 100644 index 0000000..473739e --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/FeishuReplyTtsPlatformService.cs @@ -0,0 +1,258 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using WebCodeCli.Domain.Common.Extensions; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model.Channels; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +[ServiceDescription(typeof(IFeishuReplyTtsPlatformService), ServiceLifetime.Scoped)] +public sealed class FeishuReplyTtsPlatformService : IFeishuReplyTtsPlatformService +{ + private const string VoicesUnavailableMessage = "Feishu reply TTS voices are currently unavailable."; + + private readonly ReplyTtsStorageRootResolver _storageRootResolver; + private readonly FeishuReplyTtsOptions _options; + private readonly ISherpaKokoroTtsClient _ttsClient; + private readonly IReplyTtsLocalServiceManager _localServiceManager; + + public FeishuReplyTtsPlatformService( + ReplyTtsStorageRootResolver storageRootResolver, + IOptions options, + ISherpaKokoroTtsClient ttsClient, + IReplyTtsLocalServiceManager localServiceManager) + { + _storageRootResolver = storageRootResolver ?? throw new ArgumentNullException(nameof(storageRootResolver)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _ttsClient = ttsClient ?? throw new ArgumentNullException(nameof(ttsClient)); + _localServiceManager = localServiceManager ?? throw new ArgumentNullException(nameof(localServiceManager)); + } + + public async Task GetHealthAsync(CancellationToken cancellationToken = default) + { + var storageHealth = _storageRootResolver.Resolve(); + if (!storageHealth.IsAvailable) + { + return storageHealth; + } + + var ffmpegResolution = ReplyTtsFfmpegPathResolver.Resolve(_options, storageHealth); + if (!ffmpegResolution.IsAvailable) + { + return MergeHealth( + storageHealth, + new FeishuReplyTtsHealthStatus + { + IsAvailable = false, + Message = ffmpegResolution.Message, + ServiceStatus = "ffmpeg-unavailable" + }); + } + + try + { + var serviceHealth = await _ttsClient.GetHealthAsync(cancellationToken); + return MergeHealth(storageHealth, serviceHealth); + } + catch (Exception ex) when (!IsCancellation(ex)) + { + return MergeHealth( + storageHealth, + new FeishuReplyTtsHealthStatus + { + IsAvailable = false, + Message = $"Local Kokoro/sherpa-onnx service is unavailable: {ex.Message}", + ServiceStatus = "unreachable" + }); + } + } + + public async Task> GetVoicesAsync(CancellationToken cancellationToken = default) + { + var storageHealth = _storageRootResolver.Resolve(); + if (!storageHealth.IsAvailable) + { + return []; + } + + try + { + return await _ttsClient.GetVoicesAsync(cancellationToken); + } + catch (Exception ex) when (!IsCancellation(ex)) + { + return []; + } + } + + public async Task ResolveVoiceOrFallbackAsync(string? savedVoiceId, CancellationToken cancellationToken = default) + { + var health = await GetHealthAsync(cancellationToken); + if (!health.IsAvailable) + { + return new FeishuReplyTtsVoiceResolutionResult + { + Success = false, + UsedFallback = false, + Message = string.IsNullOrWhiteSpace(health.Message) + ? VoicesUnavailableMessage + : health.Message + }; + } + + var voices = await GetVoicesAsync(cancellationToken); + if (voices.Count == 0) + { + return new FeishuReplyTtsVoiceResolutionResult + { + Success = false, + UsedFallback = false, + Message = VoicesUnavailableMessage + }; + } + + var normalizedSavedVoiceId = Normalize(savedVoiceId); + var normalizedDefaultVoiceId = await GetEffectiveDefaultVoiceIdAsync(cancellationToken); + + var savedVoice = FindVoice(voices, normalizedSavedVoiceId); + if (savedVoice is not null) + { + return new FeishuReplyTtsVoiceResolutionResult + { + Success = true, + VoiceId = savedVoice.VoiceId, + Voice = savedVoice, + UsedFallback = false + }; + } + + var defaultVoice = FindVoice(voices, normalizedDefaultVoiceId); + if (defaultVoice is not null) + { + return new FeishuReplyTtsVoiceResolutionResult + { + Success = true, + VoiceId = defaultVoice.VoiceId, + Voice = defaultVoice, + UsedFallback = !string.IsNullOrWhiteSpace(normalizedSavedVoiceId), + Message = !string.IsNullOrWhiteSpace(normalizedSavedVoiceId) + ? $"Saved Feishu reply TTS voice '{normalizedSavedVoiceId}' is unavailable. Falling back to '{defaultVoice.VoiceId}'." + : string.Empty + }; + } + + return new FeishuReplyTtsVoiceResolutionResult + { + Success = false, + UsedFallback = false, + Message = "No Feishu reply TTS voice is available. Save a valid voice or configure a default voice." + }; + } + + public async Task EnsureServiceStartedAsync(CancellationToken cancellationToken = default) + { + var storageHealth = _storageRootResolver.Resolve(); + if (!storageHealth.IsAvailable) + { + return storageHealth; + } + + var ffmpegResolution = ReplyTtsFfmpegPathResolver.Resolve(_options, storageHealth); + if (!ffmpegResolution.IsAvailable) + { + return MergeHealth( + storageHealth, + new FeishuReplyTtsHealthStatus + { + IsAvailable = false, + Message = ffmpegResolution.Message, + ServiceStatus = "ffmpeg-unavailable" + }); + } + + var startHealth = await _localServiceManager.EnsureStartedAsync(storageHealth, cancellationToken); + if (!startHealth.IsAvailable) + { + return MergeHealth(storageHealth, startHealth); + } + + return await GetHealthAsync(cancellationToken); + } + + private FeishuReplyTtsHealthStatus MergeHealth( + FeishuReplyTtsHealthStatus storageHealth, + FeishuReplyTtsHealthStatus serviceHealth) + { + var defaultVoiceId = ResolveEffectiveDefaultVoiceId(serviceHealth.DefaultVoiceId); + return new FeishuReplyTtsHealthStatus + { + IsAvailable = storageHealth.IsAvailable && serviceHealth.IsAvailable, + StorageRoot = storageHealth.StorageRoot, + Message = BuildMessage(storageHealth.Message, serviceHealth.Message, serviceHealth.IsAvailable), + ModelsRoot = storageHealth.ModelsRoot, + CacheRoot = storageHealth.CacheRoot, + TempRoot = storageHealth.TempRoot, + LogsRoot = storageHealth.LogsRoot, + VenvRoot = storageHealth.VenvRoot, + ServiceStatus = serviceHealth.ServiceStatus, + Device = serviceHealth.Device, + DefaultVoiceId = defaultVoiceId + }; + } + + private static string BuildMessage(string storageMessage, string serviceMessage, bool isServiceAvailable) + { + if (isServiceAvailable || string.IsNullOrWhiteSpace(serviceMessage)) + { + return storageMessage; + } + + if (string.IsNullOrWhiteSpace(storageMessage)) + { + return serviceMessage; + } + + return $"{storageMessage} {serviceMessage}".Trim(); + } + + private static FeishuReplyTtsVoiceOption? FindVoice( + IReadOnlyList voices, + string? voiceId) + { + if (string.IsNullOrWhiteSpace(voiceId)) + { + return null; + } + + return voices.FirstOrDefault(voice => string.Equals(voice.VoiceId, voiceId, StringComparison.OrdinalIgnoreCase)); + } + + private static string? Normalize(string? value) + { + return string.IsNullOrWhiteSpace(value) + ? null + : value.Trim(); + } + + private async Task GetEffectiveDefaultVoiceIdAsync(CancellationToken cancellationToken) + { + var configuredDefaultVoiceId = Normalize(_options.TtsDefaultVoiceId); + if (!string.IsNullOrWhiteSpace(configuredDefaultVoiceId)) + { + return configuredDefaultVoiceId; + } + + var health = await GetHealthAsync(cancellationToken); + return Normalize(health.DefaultVoiceId); + } + + private string? ResolveEffectiveDefaultVoiceId(string? serviceDefaultVoiceId) + { + return Normalize(_options.TtsDefaultVoiceId) ?? Normalize(serviceDefaultVoiceId); + } + + private static bool IsCancellation(Exception exception) + { + return exception is OperationCanceledException; + } +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/GoalQuickActionCardHelper.cs b/WebCodeCli.Domain/Domain/Service/Channels/GoalQuickActionCardHelper.cs new file mode 100644 index 0000000..87cec26 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/GoalQuickActionCardHelper.cs @@ -0,0 +1,54 @@ +using WebCodeCli.Domain.Domain.Model; +using WebCodeCli.Domain.Domain.Model.Channels; +using WebCodeCli.Domain.Domain.Service; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +internal static class GoalQuickActionCardHelper +{ + public static FeishuStreamingCardBottomPrompt? CreateBottomPrompt( + string sessionId, + string chatKey, + string? toolId, + GoalCapabilitySnapshot? capabilityState = null) + { + if (capabilityState?.State == GoalCapabilityState.Unavailable) + { + return null; + } + + return new FeishuStreamingCardBottomPrompt + { + FormName = "goal_quick_action_form", + InputName = GoalQuickActionDefaults.QuickInputFieldName, + InputLabel = GoalQuickActionDefaults.InstructionText, + Placeholder = GoalQuickActionDefaults.QuickInputPlaceholder, + DefaultValue = string.Empty, + ButtonText = GoalQuickActionDefaults.QuickSubmitButtonText, + ButtonType = "primary", + Value = new + { + action = FeishuHelpCardAction.SubmitGoalQuickInputAction, + session_id = sessionId, + chat_key = chatKey, + tool_id = toolId + } + }; + } + + public static string? MergeCapabilityStatusMarkdown( + string? statusMarkdown, + GoalCapabilitySnapshot? capabilityState) + { + if (capabilityState?.State != GoalCapabilityState.Unavailable + || string.IsNullOrWhiteSpace(capabilityState.Message)) + { + return statusMarkdown; + } + + var capabilityMessage = $"⚠️ {capabilityState.Message}"; + return string.IsNullOrWhiteSpace(statusMarkdown) + ? capabilityMessage + : $"{statusMarkdown}\n{capabilityMessage}"; + } +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/IAudioTranscodeService.cs b/WebCodeCli.Domain/Domain/Service/Channels/IAudioTranscodeService.cs new file mode 100644 index 0000000..ff0803b --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/IAudioTranscodeService.cs @@ -0,0 +1,10 @@ +namespace WebCodeCli.Domain.Domain.Service.Channels; + +public interface IAudioTranscodeService +{ + Task TranscodeChunkAsync( + string jobId, + string inputWavPath, + int chunkIndex, + CancellationToken cancellationToken = default); +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/IExternalProcessRunner.cs b/WebCodeCli.Domain/Domain/Service/Channels/IExternalProcessRunner.cs new file mode 100644 index 0000000..c89a075 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/IExternalProcessRunner.cs @@ -0,0 +1,12 @@ +namespace WebCodeCli.Domain.Domain.Service.Channels; + +public interface IExternalProcessRunner +{ + Task RunAsync( + string fileName, + string arguments, + string? workingDirectory = null, + CancellationToken cancellationToken = default); +} + +public sealed record ExternalProcessResult(int ExitCode, string StandardOutput, string StandardError); diff --git a/WebCodeCli.Domain/Domain/Service/Channels/IFeishuAudioMessageService.cs b/WebCodeCli.Domain/Domain/Service/Channels/IFeishuAudioMessageService.cs new file mode 100644 index 0000000..0d522c8 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/IFeishuAudioMessageService.cs @@ -0,0 +1,12 @@ +namespace WebCodeCli.Domain.Domain.Service.Channels; + +public interface IFeishuAudioMessageService +{ + Task SendAudioMessageAsync( + string chatId, + string filePath, + int durationMs, + string? username = null, + string? appId = null, + CancellationToken cancellationToken = default); +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/IFeishuCardKitClient.cs b/WebCodeCli.Domain/Domain/Service/Channels/IFeishuCardKitClient.cs index 2a8d8ce..3607c11 100644 --- a/WebCodeCli.Domain/Domain/Service/Channels/IFeishuCardKitClient.cs +++ b/WebCodeCli.Domain/Domain/Service/Channels/IFeishuCardKitClient.cs @@ -98,6 +98,25 @@ Task ReplyTextMessageAsync( /// 卡片标题 /// 取消令牌 /// 流式回复句柄 + Task UploadAudioFileAsync( + string filePath, + int durationMs, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + throw new NotSupportedException(); + } + + Task SendAudioMessageAsync( + string chatId, + string fileKey, + int durationMs, + CancellationToken cancellationToken = default, + FeishuOptions? optionsOverride = null) + { + throw new NotSupportedException(); + } + Task CreateStreamingHandleAsync( string chatId, string? replyMessageId, diff --git a/WebCodeCli.Domain/Domain/Service/Channels/IFeishuReplyTtsPlatformService.cs b/WebCodeCli.Domain/Domain/Service/Channels/IFeishuReplyTtsPlatformService.cs new file mode 100644 index 0000000..840c8c4 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/IFeishuReplyTtsPlatformService.cs @@ -0,0 +1,27 @@ +using WebCodeCli.Domain.Domain.Model.Channels; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +public interface IFeishuReplyTtsPlatformService +{ + Task GetHealthAsync(CancellationToken cancellationToken = default); + + Task> GetVoicesAsync(CancellationToken cancellationToken = default); + + Task ResolveVoiceOrFallbackAsync(string? savedVoiceId, CancellationToken cancellationToken = default); + + Task EnsureServiceStartedAsync(CancellationToken cancellationToken = default); +} + +public sealed class FeishuReplyTtsVoiceResolutionResult +{ + public bool Success { get; set; } + + public string? VoiceId { get; set; } + + public FeishuReplyTtsVoiceOption? Voice { get; set; } + + public bool UsedFallback { get; set; } + + public string Message { get; set; } = string.Empty; +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsEnablementService.cs b/WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsEnablementService.cs new file mode 100644 index 0000000..b44249e --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsEnablementService.cs @@ -0,0 +1,6 @@ +namespace WebCodeCli.Domain.Domain.Service.Channels; + +public interface IReplyTtsEnablementService +{ + Task HasEnabledReplyTtsAsync(CancellationToken cancellationToken = default); +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsLocalServiceManager.cs b/WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsLocalServiceManager.cs new file mode 100644 index 0000000..9044112 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsLocalServiceManager.cs @@ -0,0 +1,10 @@ +using WebCodeCli.Domain.Domain.Model.Channels; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +public interface IReplyTtsLocalServiceManager +{ + Task EnsureStartedAsync( + FeishuReplyTtsHealthStatus storageHealth, + CancellationToken cancellationToken = default); +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsOrchestrator.cs b/WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsOrchestrator.cs new file mode 100644 index 0000000..7b0022d --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsOrchestrator.cs @@ -0,0 +1,8 @@ +using WebCodeCli.Domain.Domain.Model.Channels; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +public interface IReplyTtsOrchestrator +{ + Task QueueCompletedReplyAsync(FeishuCompletedReplyTtsRequest request); +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ISherpaKokoroTtsClient.cs b/WebCodeCli.Domain/Domain/Service/Channels/ISherpaKokoroTtsClient.cs new file mode 100644 index 0000000..303394b --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/ISherpaKokoroTtsClient.cs @@ -0,0 +1,12 @@ +using WebCodeCli.Domain.Domain.Model.Channels; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +public interface ISherpaKokoroTtsClient +{ + Task GetHealthAsync(CancellationToken cancellationToken = default); + + Task> GetVoicesAsync(CancellationToken cancellationToken = default); + + Task SynthesizeAsync(string text, string voiceId, CancellationToken cancellationToken = default); +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsChunker.cs b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsChunker.cs new file mode 100644 index 0000000..c541dd3 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsChunker.cs @@ -0,0 +1,327 @@ +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using WebCodeCli.Domain.Common.Extensions; +using WebCodeCli.Domain.Common.Options; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +[ServiceDescription(typeof(ReplyTtsChunker), ServiceLifetime.Scoped)] +public sealed class ReplyTtsChunker +{ + private static readonly char[] SentenceDelimiters = ['。', '!', '?', '!', '?', ';', ';']; + private static readonly char[] ClauseDelimiters = [',', ',', '、', ':', ':']; + + private readonly int _maxChars; + private readonly int _retryMaxChars; + + [ActivatorUtilitiesConstructor] + public ReplyTtsChunker(IOptions options) + : this(options?.Value?.TtsChunkMaxChars ?? 1200) + { + } + + public ReplyTtsChunker(int maxChars) + { + if (maxChars <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxChars), "Max chars must be greater than zero."); + } + + _maxChars = maxChars; + _retryMaxChars = ResolveRetryMaxChars(maxChars); + } + + public IReadOnlyList Split(string? text) + { + var normalized = NormalizeInput(text); + if (string.IsNullOrWhiteSpace(normalized)) + { + return []; + } + + var paragraphs = normalized.Split("\n\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var chunks = new List(); + var current = new StringBuilder(); + + foreach (var paragraph in paragraphs) + { + if (paragraph.Length > _maxChars) + { + FlushCurrentChunk(chunks, current); + foreach (var paragraphChunk in SplitLongSegment(paragraph, _maxChars)) + { + chunks.Add(paragraphChunk); + } + + continue; + } + + if (current.Length == 0) + { + current.Append(paragraph); + continue; + } + + if (current.Length + 2 + paragraph.Length <= _maxChars) + { + current.Append("\n\n"); + current.Append(paragraph); + continue; + } + + FlushCurrentChunk(chunks, current); + current.Append(paragraph); + } + + FlushCurrentChunk(chunks, current); + return chunks; + } + + public IReadOnlyList SplitForRetry(string? text) + { + var normalized = NormalizeInput(text); + if (string.IsNullOrWhiteSpace(normalized)) + { + return []; + } + + var structuredLines = normalized + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (structuredLines.Length > 1) + { + return SplitStructuredLines(structuredLines, _retryMaxChars); + } + + return SplitRetryLongSegment(normalized, _retryMaxChars); + } + + private IReadOnlyList SplitStructuredLines(IReadOnlyList structuredLines, int maxChars) + { + var lineSegments = new List(structuredLines.Count); + foreach (var line in structuredLines) + { + var trimmed = line.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + { + continue; + } + + if (trimmed.Length <= maxChars) + { + lineSegments.Add(trimmed); + continue; + } + + lineSegments.AddRange(SplitRetryLongSegment(trimmed, maxChars)); + } + + if (lineSegments.Count <= 2) + { + return lineSegments; + } + + var chunks = new List((lineSegments.Count + 1) / 2); + var current = new StringBuilder(); + var lineCount = 0; + + foreach (var segment in lineSegments) + { + if (current.Length == 0) + { + current.Append(segment); + lineCount = 1; + continue; + } + + if (lineCount < 2 && current.Length + 1 + segment.Length <= maxChars) + { + current.Append('\n'); + current.Append(segment); + lineCount++; + continue; + } + + FlushCurrentChunk(chunks, current); + current.Append(segment); + lineCount = 1; + } + + FlushCurrentChunk(chunks, current); + return chunks; + } + + private IReadOnlyList SplitRetryLongSegment(string segment, int maxChars) + { + var sentencePieces = SplitByDelimiters(segment, SentenceDelimiters); + if (sentencePieces.Count > 1) + { + return sentencePieces + .SelectMany(piece => SplitRetryPiece(piece, maxChars)) + .ToList(); + } + + return SplitRetryPiece(segment, maxChars); + } + + private IEnumerable SplitLongSegment(string segment, int maxChars) + { + var sentenceChunks = CombineWithinLimit(SplitByDelimiters(segment, SentenceDelimiters), maxChars); + if (sentenceChunks.Count > 1 || sentenceChunks[0].Length <= maxChars) + { + return sentenceChunks; + } + + var clauseChunks = CombineWithinLimit(SplitByDelimiters(segment, ClauseDelimiters), maxChars); + if (clauseChunks.Count > 1 || clauseChunks[0].Length <= maxChars) + { + return clauseChunks; + } + + return HardBreak(segment, maxChars); + } + + private static IReadOnlyList SplitRetryPiece(string piece, int maxChars) + { + var trimmed = piece.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + { + return []; + } + + if (trimmed.Length <= maxChars) + { + return [trimmed]; + } + + var clausePieces = SplitByDelimiters(trimmed, ClauseDelimiters); + if (clausePieces.Count > 1) + { + return clausePieces + .SelectMany(clause => clause.Length <= maxChars ? [clause.Trim()] : HardBreak(clause, maxChars)) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .ToList(); + } + + return HardBreak(trimmed, maxChars); + } + + private static List CombineWithinLimit(IReadOnlyList pieces, int maxChars) + { + var chunks = new List(); + var current = new StringBuilder(); + + foreach (var piece in pieces.Where(static value => !string.IsNullOrWhiteSpace(value))) + { + var trimmed = piece.Trim(); + if (trimmed.Length > maxChars) + { + FlushCurrentChunk(chunks, current); + chunks.AddRange(HardBreak(trimmed, maxChars)); + continue; + } + + if (current.Length == 0) + { + current.Append(trimmed); + continue; + } + + if (current.Length + 1 + trimmed.Length <= maxChars) + { + current.Append(' '); + current.Append(trimmed); + continue; + } + + FlushCurrentChunk(chunks, current); + current.Append(trimmed); + } + + FlushCurrentChunk(chunks, current); + return chunks; + } + + private static List HardBreak(string segment, int maxChars) + { + var chunks = new List(); + var remaining = segment.Trim(); + + while (remaining.Length > maxChars) + { + var breakIndex = remaining.LastIndexOf(' ', maxChars); + if (breakIndex <= 0) + { + breakIndex = maxChars; + } + + chunks.Add(remaining[..breakIndex].Trim()); + remaining = remaining[breakIndex..].Trim(); + } + + if (!string.IsNullOrWhiteSpace(remaining)) + { + chunks.Add(remaining); + } + + return chunks; + } + + private static List SplitByDelimiters(string segment, IReadOnlyCollection delimiters) + { + var pieces = new List(); + var current = new StringBuilder(); + + foreach (var character in segment) + { + current.Append(character); + if (delimiters.Contains(character)) + { + pieces.Add(current.ToString().Trim()); + current.Clear(); + } + } + + if (current.Length > 0) + { + pieces.Add(current.ToString().Trim()); + } + + return pieces; + } + + private static void FlushCurrentChunk(ICollection chunks, StringBuilder current) + { + if (current.Length == 0) + { + return; + } + + chunks.Add(current.ToString().Trim()); + current.Clear(); + } + + private static string NormalizeInput(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return string.Empty; + } + + var normalized = text + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n'); + + var lines = normalized + .Split('\n') + .Select(static line => line.TrimEnd()) + .ToArray(); + + return string.Join("\n", lines).Trim(); + } + + private static int ResolveRetryMaxChars(int maxChars) + { + return Math.Min(maxChars, Math.Max(40, Math.Min(maxChars / 2, 160))); + } +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsEnablementService.cs b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsEnablementService.cs new file mode 100644 index 0000000..c4111da --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsEnablementService.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.DependencyInjection; +using WebCodeCli.Domain.Common.Extensions; +using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +[ServiceDescription(typeof(IReplyTtsEnablementService), ServiceLifetime.Scoped)] +public sealed class ReplyTtsEnablementService : IReplyTtsEnablementService +{ + private readonly IUserFeishuBotConfigRepository _repository; + + public ReplyTtsEnablementService(IUserFeishuBotConfigRepository repository) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public async Task HasEnabledReplyTtsAsync(CancellationToken cancellationToken = default) + { + var enabledConfigs = await _repository.GetListAsync(static config => config.ReplyTtsEnabled); + return enabledConfigs.Any(static config => config.ReplyTtsEnabled); + } +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsFfmpegPathResolver.cs b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsFfmpegPathResolver.cs new file mode 100644 index 0000000..ab8332e --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsFfmpegPathResolver.cs @@ -0,0 +1,80 @@ +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model.Channels; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +public static class ReplyTtsFfmpegPathResolver +{ + public static ReplyTtsExecutableResolutionResult Resolve( + FeishuReplyTtsOptions? options, + FeishuReplyTtsHealthStatus storageHealth) + { + var configuredPath = options?.FfmpegExecutablePath?.Trim(); + + if (!string.IsNullOrWhiteSpace(configuredPath)) + { + if (LooksLikeCommandName(configuredPath)) + { + return new ReplyTtsExecutableResolutionResult(true, configuredPath, "Using configured ffmpeg command."); + } + + if (File.Exists(configuredPath)) + { + return new ReplyTtsExecutableResolutionResult(true, configuredPath, "Using configured ffmpeg executable path."); + } + } + + var bundledPath = BuildBundledPath(storageHealth.StorageRoot); + if (!string.IsNullOrWhiteSpace(bundledPath) && File.Exists(bundledPath)) + { + return new ReplyTtsExecutableResolutionResult(true, bundledPath, "Using ffmpeg from Feishu reply TTS storage root."); + } + + return new ReplyTtsExecutableResolutionResult( + false, + null, + BuildFailureMessage(configuredPath, bundledPath)); + } + + private static string BuildFailureMessage(string? configuredPath, string? bundledPath) + { + var details = new List(); + if (!string.IsNullOrWhiteSpace(configuredPath)) + { + details.Add($"configured path '{configuredPath}' was not found"); + } + + if (!string.IsNullOrWhiteSpace(bundledPath)) + { + details.Add($"bundled path '{bundledPath}' was not found"); + } + + return $"Feishu reply TTS ffmpeg executable is unavailable; {string.Join("; ", details)}. Configure FeishuReplyTts:FfmpegExecutablePath or place ffmpeg under the TTS storage root."; + } + + private static string? BuildBundledPath(string? storageRoot) + { + if (string.IsNullOrWhiteSpace(storageRoot)) + { + return null; + } + + return Path.Combine(storageRoot, "ffmpeg", "bin", GetExecutableFileName()); + } + + private static string GetExecutableFileName() + { + return OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg"; + } + + private static bool LooksLikeCommandName(string configuredPath) + { + return !Path.IsPathRooted(configuredPath) && + configuredPath.IndexOfAny(['\\', '/']) < 0; + } + + public sealed record ReplyTtsExecutableResolutionResult( + bool IsAvailable, + string? ExecutablePath, + string Message); +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsLocalServiceManager.cs b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsLocalServiceManager.cs new file mode 100644 index 0000000..b78a7e9 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsLocalServiceManager.cs @@ -0,0 +1,379 @@ +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using WebCodeCli.Domain.Common.Extensions; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model.Channels; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +[ServiceDescription(typeof(IReplyTtsLocalServiceManager), ServiceLifetime.Singleton)] +public sealed class ReplyTtsLocalServiceManager : IReplyTtsLocalServiceManager +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IOptionsMonitor _optionsMonitor; + private readonly ILogger _logger; + private readonly SemaphoreSlim _startLock = new(1, 1); + private Process? _startedProcess; + + public ReplyTtsLocalServiceManager( + IServiceScopeFactory scopeFactory, + IOptionsMonitor optionsMonitor, + ILogger logger) + { + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + _optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task EnsureStartedAsync( + FeishuReplyTtsHealthStatus storageHealth, + CancellationToken cancellationToken = default) + { + if (storageHealth == null) + { + throw new ArgumentNullException(nameof(storageHealth)); + } + + if (!storageHealth.IsAvailable || string.IsNullOrWhiteSpace(storageHealth.StorageRoot)) + { + return storageHealth; + } + + var existingHealth = await TryGetServiceHealthAsync(cancellationToken); + if (existingHealth?.IsAvailable == true) + { + return existingHealth; + } + + await _startLock.WaitAsync(cancellationToken); + try + { + existingHealth = await TryGetServiceHealthAsync(cancellationToken); + if (existingHealth?.IsAvailable == true) + { + return existingHealth; + } + + var startInfoResult = CreateStartInfo(storageHealth); + if (startInfoResult.StartInfo == null) + { + return Unavailable(startInfoResult.Message, "auto-start-unavailable"); + } + + try + { + _startedProcess = Process.Start(startInfoResult.StartInfo); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to start local Kokoro/sherpa-onnx service."); + return Unavailable($"Failed to start local Kokoro/sherpa-onnx service: {ex.Message}", "start-failed"); + } + + if (_startedProcess == null) + { + return Unavailable("Failed to start local Kokoro/sherpa-onnx service: process was not created.", "start-failed"); + } + + _logger.LogInformation( + "Started local Kokoro/sherpa-onnx service process. Pid={ProcessId}", + _startedProcess.Id); + + return await WaitForServiceReadyAsync(_startedProcess, cancellationToken); + } + finally + { + _startLock.Release(); + } + } + + private async Task WaitForServiceReadyAsync(Process process, CancellationToken cancellationToken) + { + var timeout = TimeSpan.FromSeconds(Math.Max(1, _optionsMonitor.CurrentValue.TtsServiceStartupTimeoutSeconds)); + var deadline = DateTimeOffset.UtcNow.Add(timeout); + FeishuReplyTtsHealthStatus? lastHealth = null; + + while (DateTimeOffset.UtcNow < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + + lastHealth = await TryGetServiceHealthAsync(cancellationToken); + if (lastHealth?.IsAvailable == true) + { + return lastHealth; + } + + if (process.HasExited) + { + return Unavailable( + $"Local Kokoro/sherpa-onnx service exited before becoming healthy. ExitCode={process.ExitCode}.", + "start-exited"); + } + + await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken); + } + + return Unavailable( + lastHealth == null + ? $"Local Kokoro/sherpa-onnx service did not become healthy within {timeout.TotalSeconds:0} seconds." + : $"Local Kokoro/sherpa-onnx service did not become healthy within {timeout.TotalSeconds:0} seconds: {lastHealth.Message}", + "startup-timeout"); + } + + private async Task TryGetServiceHealthAsync(CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var ttsClient = scope.ServiceProvider.GetRequiredService(); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(2)); + + try + { + return await ttsClient.GetHealthAsync(timeoutCts.Token); + } + catch (Exception ex) when (!IsCancellation(ex, cancellationToken)) + { + _logger.LogDebug(ex, "Local Kokoro/sherpa-onnx health probe failed."); + return null; + } + } + + private StartInfoResult CreateStartInfo(FeishuReplyTtsHealthStatus storageHealth) + { + var options = _optionsMonitor.CurrentValue; + if (!TryGetLocalPort(options.TtsServiceBaseUrl, out var port, out var endpointMessage)) + { + return StartInfoResult.Unavailable(endpointMessage); + } + + var isWindows = OperatingSystem.IsWindows(); + var scriptPath = ResolveStartScriptPath(options, isWindows, out var scriptMessage); + if (scriptPath == null) + { + return StartInfoResult.Unavailable(scriptMessage); + } + + var startInfo = isWindows + ? CreateWindowsStartInfo(scriptPath, storageHealth, options, port) + : CreateUnixStartInfo(scriptPath, storageHealth, options, port); + + return StartInfoResult.Available(startInfo); + } + + private static ProcessStartInfo CreateWindowsStartInfo( + string scriptPath, + FeishuReplyTtsHealthStatus storageHealth, + FeishuReplyTtsOptions options, + int port) + { + var startInfo = new ProcessStartInfo("powershell.exe") + { + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.GetDirectoryName(scriptPath) ?? AppContext.BaseDirectory + }; + + startInfo.ArgumentList.Add("-NoProfile"); + startInfo.ArgumentList.Add("-ExecutionPolicy"); + startInfo.ArgumentList.Add("Bypass"); + startInfo.ArgumentList.Add("-File"); + startInfo.ArgumentList.Add(scriptPath); + startInfo.ArgumentList.Add("-StorageRoot"); + startInfo.ArgumentList.Add(storageHealth.StorageRoot!); + startInfo.ArgumentList.Add("-Port"); + startInfo.ArgumentList.Add(port.ToString()); + startInfo.ArgumentList.Add("-DefaultVoiceId"); + startInfo.ArgumentList.Add(ResolveDefaultVoiceId(options)); + startInfo.ArgumentList.Add("-Provider"); + startInfo.ArgumentList.Add(ResolveProvider(options)); + startInfo.ArgumentList.Add("-Python"); + startInfo.ArgumentList.Add(ResolvePythonPath(storageHealth, options, isWindows: true)); + + return startInfo; + } + + private static ProcessStartInfo CreateUnixStartInfo( + string scriptPath, + FeishuReplyTtsHealthStatus storageHealth, + FeishuReplyTtsOptions options, + int port) + { + var startInfo = new ProcessStartInfo("/usr/bin/env") + { + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.GetDirectoryName(scriptPath) ?? AppContext.BaseDirectory + }; + + startInfo.ArgumentList.Add("bash"); + startInfo.ArgumentList.Add(scriptPath); + startInfo.ArgumentList.Add(storageHealth.StorageRoot!); + startInfo.Environment["KOKORO_PORT"] = port.ToString(); + startInfo.Environment["KOKORO_DEFAULT_VOICE_ID"] = ResolveDefaultVoiceId(options); + startInfo.Environment["KOKORO_PROVIDER"] = ResolveProvider(options); + + return startInfo; + } + + private static string ResolvePythonPath( + FeishuReplyTtsHealthStatus storageHealth, + FeishuReplyTtsOptions options, + bool isWindows) + { + if (!string.IsNullOrWhiteSpace(options.TtsServicePythonPath)) + { + return options.TtsServicePythonPath.Trim(); + } + + if (!string.IsNullOrWhiteSpace(storageHealth.VenvRoot)) + { + var pythonPath = isWindows + ? Path.Combine(storageHealth.VenvRoot, "Scripts", "python.exe") + : Path.Combine(storageHealth.VenvRoot, "bin", "python"); + if (File.Exists(pythonPath)) + { + return pythonPath; + } + } + + return "python"; + } + + private static string ResolveProvider(FeishuReplyTtsOptions options) + { + return string.IsNullOrWhiteSpace(options.TtsPreferredDevice) + ? "cpu" + : options.TtsPreferredDevice.Trim(); + } + + private static string ResolveDefaultVoiceId(FeishuReplyTtsOptions options) + { + return string.IsNullOrWhiteSpace(options.TtsDefaultVoiceId) + ? "zh_47" + : options.TtsDefaultVoiceId.Trim(); + } + + private static string? ResolveStartScriptPath( + FeishuReplyTtsOptions options, + bool isWindows, + out string message) + { + var configuredPath = options.TtsServiceStartScriptPath?.Trim(); + if (!string.IsNullOrWhiteSpace(configuredPath)) + { + if (File.Exists(configuredPath)) + { + message = string.Empty; + return Path.GetFullPath(configuredPath); + } + + message = $"Configured Kokoro/sherpa-onnx start script was not found: {configuredPath}."; + return null; + } + + var scriptName = isWindows ? "start.ps1" : "start.sh"; + foreach (var root in EnumerateSearchRoots()) + { + var candidate = Path.Combine(root, "tools", "sherpa-kokoro-service", scriptName); + if (File.Exists(candidate)) + { + message = string.Empty; + return candidate; + } + } + + message = $"Kokoro/sherpa-onnx start script '{scriptName}' was not found. Set FeishuReplyTts:TtsServiceStartScriptPath."; + return null; + } + + private static IEnumerable EnumerateSearchRoots() + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var startPath in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory }) + { + if (string.IsNullOrWhiteSpace(startPath)) + { + continue; + } + + var directory = new DirectoryInfo(startPath); + while (directory != null) + { + if (seen.Add(directory.FullName)) + { + yield return directory.FullName; + } + + directory = directory.Parent; + } + } + } + + private static bool TryGetLocalPort(string? baseUrl, out int port, out string message) + { + port = 0; + var candidate = string.IsNullOrWhiteSpace(baseUrl) + ? "http://127.0.0.1:5058" + : baseUrl.Trim(); + + if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri)) + { + message = $"Invalid FeishuReplyTts:TtsServiceBaseUrl: {candidate}."; + return false; + } + + if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)) + { + message = $"Auto-start only supports local http TTS endpoints. Current endpoint is {uri}."; + return false; + } + + if (!IsLocalHost(uri.Host)) + { + message = $"Auto-start only supports localhost TTS endpoints. Current host is {uri.Host}."; + return false; + } + + port = uri.Port; + message = string.Empty; + return true; + } + + private static bool IsLocalHost(string host) + { + return string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || + string.Equals(host, "127.0.0.1", StringComparison.OrdinalIgnoreCase) || + string.Equals(host, "::1", StringComparison.OrdinalIgnoreCase) || + string.Equals(host, "[::1]", StringComparison.OrdinalIgnoreCase); + } + + private static FeishuReplyTtsHealthStatus Unavailable(string message, string serviceStatus) + { + return new FeishuReplyTtsHealthStatus + { + IsAvailable = false, + Message = message, + ServiceStatus = serviceStatus + }; + } + + private static bool IsCancellation(Exception exception, CancellationToken cancellationToken) + { + return cancellationToken.IsCancellationRequested && exception is OperationCanceledException; + } + + private sealed record StartInfoResult(ProcessStartInfo? StartInfo, string Message) + { + public static StartInfoResult Available(ProcessStartInfo startInfo) + { + return new StartInfoResult(startInfo, string.Empty); + } + + public static StartInfoResult Unavailable(string message) + { + return new StartInfoResult(null, message); + } + } +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsOrchestrator.cs b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsOrchestrator.cs new file mode 100644 index 0000000..6601b65 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsOrchestrator.cs @@ -0,0 +1,458 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using WebCodeCli.Domain.Common.Extensions; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model.Channels; +using WebCodeCli.Domain.Domain.Service; +using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +[ServiceDescription(typeof(IReplyTtsOrchestrator), ServiceLifetime.Singleton)] +public sealed class ReplyTtsOrchestrator : IReplyTtsOrchestrator +{ + private const string FailureNotice = "回复语音发送失败,已停止后续音频。"; + + private readonly IServiceProvider _serviceProvider; + private readonly ReplyTtsStorageRootResolver _storageRootResolver; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _chatLocks = new(StringComparer.OrdinalIgnoreCase); + + public ReplyTtsOrchestrator( + IServiceProvider serviceProvider, + ReplyTtsStorageRootResolver storageRootResolver, + ILogger logger) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _storageRootResolver = storageRootResolver ?? throw new ArgumentNullException(nameof(storageRootResolver)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task QueueCompletedReplyAsync(FeishuCompletedReplyTtsRequest request) + { + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (string.IsNullOrWhiteSpace(request.ChatId)) + { + throw new ArgumentException("Chat ID is required.", nameof(request)); + } + + _ = Task.Run(() => ProcessQueuedReplyAsync(request)); + return Task.CompletedTask; + } + + private async Task ProcessQueuedReplyAsync(FeishuCompletedReplyTtsRequest request) + { + var chatLock = _chatLocks.GetOrAdd(request.ChatId.Trim(), static _ => new SemaphoreSlim(1, 1)); + await chatLock.WaitAsync(); + try + { + await ProcessReplyCoreAsync(request); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Reply TTS orchestration failed for chat {ChatId}", request.ChatId); + } + finally + { + chatLock.Release(); + } + } + + private async Task ProcessReplyCoreAsync(FeishuCompletedReplyTtsRequest request) + { + using var scope = _serviceProvider.CreateScope(); + var configService = scope.ServiceProvider.GetRequiredService(); + var userConfig = await ResolveBotConfigAsync(configService, request.Username, request.AppId); + if (userConfig?.ReplyTtsEnabled != true) + { + return; + } + + var normalizer = scope.ServiceProvider.GetRequiredService(); + var normalizedOutput = normalizer.Normalize(request.Output); + if (string.IsNullOrWhiteSpace(normalizedOutput)) + { + return; + } + + var storageHealth = _storageRootResolver.Resolve(); + if (!storageHealth.IsAvailable || string.IsNullOrWhiteSpace(storageHealth.TempRoot)) + { + _logger.LogWarning( + "Skipping reply TTS for chat {ChatId} because temp storage is unavailable: {Message}", + request.ChatId, + storageHealth.Message); + return; + } + + var platformService = scope.ServiceProvider.GetRequiredService(); + var voiceResolution = await platformService.ResolveVoiceOrFallbackAsync(userConfig.ReplyTtsVoiceId); + if (!voiceResolution.Success || string.IsNullOrWhiteSpace(voiceResolution.VoiceId)) + { + _logger.LogWarning( + "Skipping reply TTS for chat {ChatId} because voice resolution failed: {Message}", + request.ChatId, + voiceResolution.Message); + return; + } + + var chunker = scope.ServiceProvider.GetRequiredService(); + var chunks = chunker.Split(normalizedOutput); + if (chunks.Count == 0) + { + return; + } + + var ttsClient = scope.ServiceProvider.GetRequiredService(); + var audioTranscodeService = scope.ServiceProvider.GetRequiredService(); + var audioMessageService = scope.ServiceProvider.GetRequiredService(); + var cardKitClient = scope.ServiceProvider.GetRequiredService(); + + var jobId = CreateJobId(); + var jobDirectory = Path.Combine(storageHealth.TempRoot, jobId); + Directory.CreateDirectory(jobDirectory); + + try + { + var sequenceTracker = new ChunkSequenceTracker(); + for (var index = 0; index < chunks.Count; index++) + { + var chunkIndex = index + 1; + var chunkText = chunks[index]; + + try + { + await SendChunkWithRetryAsync( + chunker, + chunkText, + voiceResolution.VoiceId, + jobId, + jobDirectory, + request, + ttsClient, + audioTranscodeService, + audioMessageService, + sequenceTracker); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Reply TTS chunk {ChunkIndex} failed for chat {ChatId}; remaining chunks will be skipped.", + chunkIndex, + request.ChatId); + + await SendFailureNoticeAsync(cardKitClient, configService, request); + return; + } + } + } + finally + { + TryDeleteDirectory(jobDirectory); + } + } + + private async Task SendChunkWithRetryAsync( + ReplyTtsChunker chunker, + string chunkText, + string voiceId, + string jobId, + string jobDirectory, + FeishuCompletedReplyTtsRequest request, + ISherpaKokoroTtsClient ttsClient, + IAudioTranscodeService audioTranscodeService, + IFeishuAudioMessageService audioMessageService, + ChunkSequenceTracker sequenceTracker) + { + try + { + await SendChunkAsync( + chunkText, + voiceId, + jobId, + jobDirectory, + request, + ttsClient, + audioTranscodeService, + audioMessageService, + sequenceTracker); + } + catch (Exception ex) when (IsRetriableChunkFailure(ex)) + { + var retryChunks = chunker.SplitForRetry(chunkText); + if (retryChunks.Count <= 1 || + (retryChunks.Count == 1 && string.Equals(retryChunks[0], chunkText, StringComparison.Ordinal))) + { + throw; + } + + _logger.LogInformation( + "Reply TTS chunk timed out for chat {ChatId}; retrying as {RetryChunkCount} smaller chunks. OriginalLength={OriginalLength}", + request.ChatId, + retryChunks.Count, + chunkText.Length); + + foreach (var retryChunk in retryChunks) + { + await SendChunkAsync( + retryChunk, + voiceId, + jobId, + jobDirectory, + request, + ttsClient, + audioTranscodeService, + audioMessageService, + sequenceTracker); + } + } + } + + private async Task SendChunkAsync( + string chunkText, + string voiceId, + string jobId, + string jobDirectory, + FeishuCompletedReplyTtsRequest request, + ISherpaKokoroTtsClient ttsClient, + IAudioTranscodeService audioTranscodeService, + IFeishuAudioMessageService audioMessageService, + ChunkSequenceTracker sequenceTracker) + { + var chunkIndex = sequenceTracker.Next(); + _logger.LogInformation( + "Starting reply TTS chunk {ChunkIndex} for chat {ChatId}. VoiceId={VoiceId}, TextLength={TextLength}", + chunkIndex, + request.ChatId, + voiceId, + chunkText.Length); + + await using var wavStream = await ttsClient.SynthesizeAsync(chunkText, voiceId); + var wavPath = Path.Combine(jobDirectory, $"chunk-{chunkIndex:000}.wav"); + await WriteStreamToFileAsync(wavStream, wavPath); + var wavInfo = new FileInfo(wavPath); + _logger.LogInformation( + "Reply TTS chunk {ChunkIndex} synthesized for chat {ChatId}. WavePath={WavePath}, WaveBytes={WaveBytes}", + chunkIndex, + request.ChatId, + wavPath, + wavInfo.Exists ? wavInfo.Length : 0); + + var durationMs = GetWaveDurationMs(wavPath); + var opusPath = await audioTranscodeService.TranscodeChunkAsync(jobId, wavPath, chunkIndex); + var opusInfo = new FileInfo(opusPath); + _logger.LogInformation( + "Reply TTS chunk {ChunkIndex} transcoded for chat {ChatId}. OpusPath={OpusPath}, OpusBytes={OpusBytes}, DurationMs={DurationMs}", + chunkIndex, + request.ChatId, + opusPath, + opusInfo.Exists ? opusInfo.Length : 0, + durationMs); + + var messageId = await audioMessageService.SendAudioMessageAsync( + request.ChatId, + opusPath, + durationMs, + request.Username, + request.AppId); + _logger.LogInformation( + "Reply TTS chunk {ChunkIndex} sent for chat {ChatId}. AudioMessageId={MessageId}", + chunkIndex, + request.ChatId, + messageId); + } + + private static bool IsRetriableChunkFailure(Exception exception) + { + return exception is OperationCanceledException or TimeoutException; + } + + private async Task SendFailureNoticeAsync( + IFeishuCardKitClient cardKitClient, + IUserFeishuBotConfigService configService, + FeishuCompletedReplyTtsRequest request) + { + try + { + var options = await ResolveEffectiveOptionsAsync(configService, request.Username, request.AppId); + await cardKitClient.SendTextMessageAsync(request.ChatId, FailureNotice, optionsOverride: options); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send reply TTS failure notice for chat {ChatId}", request.ChatId); + } + } + + private static async Task WriteStreamToFileAsync(Stream input, string outputPath) + { + input.Position = 0; + await using var output = File.Create(outputPath); + await input.CopyToAsync(output); + } + + private static int GetWaveDurationMs(string wavPath) + { + using var stream = File.OpenRead(wavPath); + using var reader = new BinaryReader(stream); + + if (!IsChunk(reader, "RIFF")) + { + throw new InvalidOperationException("Reply TTS synthesis did not produce a valid RIFF WAV file."); + } + + _ = reader.ReadInt32(); + + if (!IsChunk(reader, "WAVE")) + { + throw new InvalidOperationException("Reply TTS synthesis did not produce a valid WAVE file."); + } + + int? byteRate = null; + int? dataSize = null; + + while (stream.Position <= stream.Length - 8) + { + var chunkId = new string(reader.ReadChars(4)); + var chunkSize = reader.ReadInt32(); + if (chunkSize < 0) + { + throw new InvalidOperationException("Reply TTS synthesis produced an invalid WAV chunk length."); + } + + switch (chunkId) + { + case "fmt ": + if (chunkSize < 16) + { + throw new InvalidOperationException("Reply TTS synthesis produced an invalid WAV format chunk."); + } + + _ = reader.ReadInt16(); + _ = reader.ReadInt16(); + _ = reader.ReadInt32(); + byteRate = reader.ReadInt32(); + _ = reader.ReadInt16(); + _ = reader.ReadInt16(); + SkipRemainingChunkBytes(stream, chunkSize - 16); + break; + + case "data": + dataSize = chunkSize; + SkipRemainingChunkBytes(stream, chunkSize); + break; + + default: + SkipRemainingChunkBytes(stream, chunkSize); + break; + } + + if ((chunkSize & 1) == 1 && stream.Position < stream.Length) + { + stream.Position++; + } + + if (byteRate.HasValue && dataSize.HasValue) + { + break; + } + } + + if (!byteRate.HasValue || !dataSize.HasValue || byteRate.Value <= 0) + { + throw new InvalidOperationException("Reply TTS synthesis produced a WAV file without duration metadata."); + } + + return Math.Max(1, (int)Math.Ceiling(dataSize.Value * 1000d / byteRate.Value)); + } + + private static bool IsChunk(BinaryReader reader, string expected) + { + return string.Equals(new string(reader.ReadChars(4)), expected, StringComparison.Ordinal); + } + + private static void SkipRemainingChunkBytes(Stream stream, int count) + { + if (count <= 0) + { + return; + } + + stream.Position += count; + } + + private static string CreateJobId() + { + return $"reply-tts-{DateTime.UtcNow:yyyyMMddHHmmssfff}-{Guid.NewGuid():N}"; + } + + private static void TryDeleteDirectory(string jobDirectory) + { + try + { + if (Directory.Exists(jobDirectory)) + { + Directory.Delete(jobDirectory, recursive: true); + } + } + catch + { + } + } + + private static async Task ResolveBotConfigAsync( + IUserFeishuBotConfigService configService, + string? username, + string? appId) + { + if (!string.IsNullOrWhiteSpace(username)) + { + return await configService.GetByUsernameAsync(username.Trim()); + } + + if (!string.IsNullOrWhiteSpace(appId)) + { + return await configService.GetByAppIdAsync(appId.Trim()); + } + + return null; + } + + private static async Task ResolveEffectiveOptionsAsync( + IUserFeishuBotConfigService configService, + string? username, + string? appId) + { + if (!string.IsNullOrWhiteSpace(appId)) + { + var appOptions = await configService.GetEffectiveOptionsByAppIdAsync(appId.Trim()); + if (appOptions != null) + { + return appOptions; + } + } + + if (!string.IsNullOrWhiteSpace(username)) + { + return await configService.GetEffectiveOptionsAsync(username.Trim()); + } + + return configService.GetSharedDefaults(); + } + + private sealed class ChunkSequenceTracker + { + private int _nextIndex; + + public int Next() + { + _nextIndex++; + return _nextIndex; + } + } +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsSpeechTextNormalizer.cs b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsSpeechTextNormalizer.cs new file mode 100644 index 0000000..8289e6e --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsSpeechTextNormalizer.cs @@ -0,0 +1,196 @@ +using System.Text.RegularExpressions; +using Microsoft.Extensions.DependencyInjection; +using WebCodeCli.Domain.Common.Extensions; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +[ServiceDescription(typeof(ReplyTtsSpeechTextNormalizer), ServiceLifetime.Singleton)] +public sealed class ReplyTtsSpeechTextNormalizer +{ + private static readonly Regex CodeBlockRegex = new("```.*?```", RegexOptions.Singleline | RegexOptions.Compiled); + private static readonly Regex InlineCodeRegex = new("`(?[^`]+)`", RegexOptions.Compiled); + private static readonly Regex MarkdownLinkRegex = new(@"\[(?[^\]]+)\]\((?https?://[^)]+)\)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex RawUrlRegex = new(@"https?://\S+", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex HeadingRegex = new(@"^\s{0,3}#{1,6}\s*", RegexOptions.Multiline | RegexOptions.Compiled); + private static readonly Regex QuotePrefixRegex = new(@"^\s{0,3}>\s?", RegexOptions.Multiline | RegexOptions.Compiled); + private static readonly Regex BulletPrefixRegex = new(@"^\s*(?:[-+*]|\d+\.)\s+", RegexOptions.Multiline | RegexOptions.Compiled); + private static readonly Regex FileReferenceRegex = new(@"\b(?:[A-Za-z0-9_.-]+[/\\])+[A-Za-z0-9_.-]+(?::\d+)?\b", RegexOptions.Compiled); + private static readonly Regex SlashSeparatedIdentifierListRegex = new(@"\b[A-Za-z][A-Za-z0-9_]*(?:\s*/\s*[A-Za-z][A-Za-z0-9_]*){2,}\b", RegexOptions.Compiled); + private static readonly Regex CodeLikeIdentifierRegex = new( + @"(?[\p{IsCJKUnifiedIdeographs}A-Za-z0-9]+)\s*/\s*(?[\p{IsCJKUnifiedIdeographs}A-Za-z0-9]+)", RegexOptions.Compiled); + private static readonly Regex CjkInnerSpacesRegex = new(@"(?<=[\p{IsCJKUnifiedIdeographs}])\s+(?=[\p{IsCJKUnifiedIdeographs}])", RegexOptions.Compiled); + private static readonly Regex PunctuationLeadingSpacesRegex = new(@"[ \t]+(?=[,。;:、“”()])", RegexOptions.Compiled); + private static readonly Regex PunctuationTrailingSpacesRegex = new(@"(?<=[,。;:、“”()])[ \t]+", RegexOptions.Compiled); + private static readonly Regex TrailingWhitespaceRegex = new(@"[ \t]+\n", RegexOptions.Compiled); + private static readonly Regex RepeatedBlankLinesRegex = new(@"\n{3,}", RegexOptions.Compiled); + private static readonly Regex RepeatedSpacesRegex = new(@"[ \t]{2,}", RegexOptions.Compiled); + + public string Normalize(string? markdown) + { + if (string.IsNullOrWhiteSpace(markdown)) + { + return string.Empty; + } + + var normalized = markdown + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n'); + + normalized = CodeBlockRegex.Replace(normalized, "\nCode snippet omitted.\n"); + normalized = MarkdownLinkRegex.Replace(normalized, static match => match.Groups["text"].Value.Trim()); + normalized = RawUrlRegex.Replace(normalized, "this link"); + normalized = HeadingRegex.Replace(normalized, string.Empty); + normalized = QuotePrefixRegex.Replace(normalized, string.Empty); + normalized = BulletPrefixRegex.Replace(normalized, string.Empty); + normalized = InlineCodeRegex.Replace(normalized, static match => NormalizeInlineCode(match.Groups["code"].Value)); + normalized = normalized + .Replace("**", string.Empty, StringComparison.Ordinal) + .Replace("__", string.Empty, StringComparison.Ordinal) + .Replace('`', ' '); + normalized = FileReferenceRegex.Replace(normalized, static match => FormatFileReference(match.Value)); + normalized = SlashSeparatedIdentifierListRegex.Replace(normalized, "若干属性字段"); + normalized = CodeLikeIdentifierRegex.Replace(normalized, FormatCodeLikeIdentifierMatch); + normalized = SingleAsteriskRegex.Replace(normalized, string.Empty); + normalized = SingleUnderscoreRegex.Replace(normalized, string.Empty); + normalized = CjkSlashRegex.Replace(normalized, "${left}和${right}"); + normalized = CjkInnerSpacesRegex.Replace(normalized, string.Empty); + normalized = PunctuationLeadingSpacesRegex.Replace(normalized, string.Empty); + normalized = PunctuationTrailingSpacesRegex.Replace(normalized, string.Empty); + normalized = TrailingWhitespaceRegex.Replace(normalized, "\n"); + normalized = RepeatedBlankLinesRegex.Replace(normalized, "\n\n"); + normalized = RepeatedSpacesRegex.Replace(normalized, " "); + + return normalized.Trim(); + } + + private static string NormalizeInlineCode(string code) + { + var trimmed = code.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + { + return string.Empty; + } + + if (FileReferenceRegex.IsMatch(trimmed)) + { + return FileReferenceRegex.Replace(trimmed, static match => FormatFileReference(match.Value)); + } + + if (SlashSeparatedIdentifierListRegex.IsMatch(trimmed)) + { + return "若干属性字段"; + } + + if (trimmed.StartsWith("npx ", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("npm ", StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("dotnet ", StringComparison.OrdinalIgnoreCase)) + { + return "相关命令"; + } + + if (trimmed.Contains('(', StringComparison.Ordinal) || trimmed.Contains(')', StringComparison.Ordinal)) + { + return FormatCallableReference(trimmed); + } + + if (trimmed.Contains('{', StringComparison.Ordinal) || trimmed.Contains('}', StringComparison.Ordinal)) + { + return "相关调用"; + } + + if (CodeLikeIdentifierRegex.IsMatch(trimmed)) + { + return FormatCodeIdentifier(trimmed); + } + + return trimmed; + } + + private static string FormatCodeLikeIdentifierMatch(Match match) + { + if (LooksLikeFileName(match.Value)) + { + return FormatFileReference(match.Value); + } + + return FormatCodeIdentifier(match.Value); + } + + private static string FormatFileReference(string reference) + { + var withoutLine = RemoveTrailingLineNumber(reference.Trim()); + var normalized = withoutLine.Replace('\\', '/'); + var fileName = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault(); + + return string.IsNullOrWhiteSpace(fileName) + ? "相关文件" + : $"{fileName} 文件"; + } + + private static string FormatCallableReference(string reference) + { + var methodCandidate = reference.Split('(', 2, StringSplitOptions.TrimEntries)[0]; + return string.IsNullOrWhiteSpace(methodCandidate) + ? "相关调用" + : FormatCodeIdentifier(methodCandidate, preferMethod: true); + } + + private static string FormatCodeIdentifier(string identifier, bool preferMethod = false) + { + var normalized = identifier.Trim().Trim('`', '.', ':', '/', '\\', '-'); + if (string.IsNullOrWhiteSpace(normalized)) + { + return "相关技术标识"; + } + + if (LooksLikeFileName(normalized)) + { + return FormatFileReference(normalized); + } + + var segments = normalized + .Split(['.', ':', '/', '\\', '-'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (segments.Length >= 2) + { + var typeName = segments[^2]; + var memberName = segments[^1]; + var memberKind = preferMethod || normalized.Contains(':', StringComparison.Ordinal) || LooksLikeMethodName(memberName) + ? "方法" + : "成员"; + return $"{typeName} 类 {memberName} {memberKind}"; + } + + return $"{normalized} 方法"; + } + + private static string RemoveTrailingLineNumber(string reference) + { + var colonIndex = reference.LastIndexOf(':'); + if (colonIndex <= 1 || colonIndex == reference.Length - 1) + { + return reference; + } + + return reference[(colonIndex + 1)..].All(char.IsDigit) + ? reference[..colonIndex] + : reference; + } + + private static bool LooksLikeMethodName(string value) + { + return value.EndsWith("Async", StringComparison.Ordinal) || + value.Contains("(", StringComparison.Ordinal) || + value.Length > 0 && char.IsUpper(value[0]); + } + + private static bool LooksLikeFileName(string value) + { + var extension = Path.GetExtension(RemoveTrailingLineNumber(value)); + return extension is ".cs" or ".vue" or ".ts" or ".tsx" or ".js" or ".jsx" or ".json" or ".md" or ".py" or ".html" or ".css" or ".scss"; + } +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsStartupHostedService.cs b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsStartupHostedService.cs new file mode 100644 index 0000000..6a58704 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsStartupHostedService.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +public sealed class ReplyTtsStartupHostedService : IHostedService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public ReplyTtsStartupHostedService( + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var enablementService = scope.ServiceProvider.GetRequiredService(); + if (!await enablementService.HasEnabledReplyTtsAsync(cancellationToken)) + { + _logger.LogDebug("Skipping local reply TTS startup because no Feishu user has reply TTS enabled."); + return; + } + + var platformService = scope.ServiceProvider.GetRequiredService(); + var health = await platformService.EnsureServiceStartedAsync(cancellationToken); + if (health.IsAvailable) + { + _logger.LogInformation("Local reply TTS service is ready at startup. Status={ServiceStatus}", health.ServiceStatus); + return; + } + + _logger.LogWarning( + "Local reply TTS service was requested at startup but is unavailable. Status={ServiceStatus}, Message={Message}", + health.ServiceStatus, + health.Message); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to ensure local reply TTS service at startup."); + } + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsStorageRootResolver.cs b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsStorageRootResolver.cs new file mode 100644 index 0000000..c2555f1 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsStorageRootResolver.cs @@ -0,0 +1,408 @@ +using Microsoft.Extensions.Options; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model.Channels; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +public sealed class ReplyTtsStorageRootResolver +{ + private const string NonWindowsDefaultRoot = "/data/webcode/kokoro"; + + private readonly IOptionsMonitor _optionsMonitor; + private readonly IReplyTtsHostEnvironment _hostEnvironment; + + public ReplyTtsStorageRootResolver( + IOptionsMonitor optionsMonitor, + IReplyTtsHostEnvironment? hostEnvironment = null) + { + _optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); + _hostEnvironment = hostEnvironment ?? new SystemReplyTtsHostEnvironment(); + } + + public FeishuReplyTtsHealthStatus Resolve() + { + var options = _optionsMonitor.CurrentValue ?? new FeishuReplyTtsOptions(); + var explicitRoot = options.TtsStorageRoot?.Trim(); + if (!string.IsNullOrWhiteSpace(explicitRoot)) + { + var useWindowsPaths = UsesWindowsSeparators(explicitRoot) || _hostEnvironment.IsWindows; + if (useWindowsPaths && IsSameDrive(explicitRoot, _hostEnvironment.SystemDriveRoot)) + { + return new FeishuReplyTtsHealthStatus + { + IsAvailable = false, + Message = "Feishu reply TTS storage is unavailable because Kokoro/sherpa-onnx must be installed on a non-system drive. Set FeishuReplyTts:TtsStorageRoot to a non-C drive." + }; + } + + return CreateAvailable( + NormalizeStorageRoot(explicitRoot, useWindowsPaths), + "Using configured Feishu reply TTS storage root.", + useWindowsPaths); + } + + if (!_hostEnvironment.IsWindows) + { + return CreateAvailable( + NonWindowsDefaultRoot, + "Using default non-Windows Feishu reply TTS storage root.", + useWindowsPaths: false); + } + + var systemDriveRoot = NormalizeDriveRoot(_hostEnvironment.SystemDriveRoot); + var writableDrives = _hostEnvironment.GetFixedDrives() + .Where(d => d.IsReady && d.IsWritable) + .ToList(); + + var existingNonSystemDrive = writableDrives.FirstOrDefault(d => + { + if (IsSameDrive(d.RootPath, systemDriveRoot)) + { + return false; + } + + return HasWindowsInstallEvidence(BuildWindowsStorageRoot(d.RootPath)); + }); + if (existingNonSystemDrive is not null) + { + var resolvedRoot = BuildWindowsStorageRoot(existingNonSystemDrive.RootPath); + return CreateAvailable( + resolvedRoot, + $"Using existing Feishu reply TTS storage root on writable non-system drive '{NormalizeDriveRoot(existingNonSystemDrive.RootPath)}'.", + useWindowsPaths: true); + } + + var nonSystemDrive = writableDrives.FirstOrDefault(d => !IsSameDrive(d.RootPath, systemDriveRoot)); + if (nonSystemDrive is not null) + { + var resolvedRoot = BuildWindowsStorageRoot(nonSystemDrive.RootPath); + return CreateAvailable( + resolvedRoot, + $"Using writable non-system drive '{NormalizeDriveRoot(nonSystemDrive.RootPath)}' for Feishu reply TTS storage.", + useWindowsPaths: true); + } + + var systemDrive = writableDrives.FirstOrDefault(d => IsSameDrive(d.RootPath, systemDriveRoot)); + if (systemDrive is not null) + { + var driveLabel = NormalizeDriveRoot(systemDrive.RootPath); + return new FeishuReplyTtsHealthStatus + { + IsAvailable = false, + Message = $"Feishu reply TTS storage is unavailable because only the Windows system drive '{driveLabel}' is writable. Attach a writable non-system drive and set FeishuReplyTts:TtsStorageRoot to that drive." + }; + } + + return new FeishuReplyTtsHealthStatus + { + IsAvailable = false, + Message = "Feishu reply TTS storage is unavailable because no writable fixed drive was found on Windows. Set FeishuReplyTts:TtsStorageRoot explicitly or attach a writable data drive." + }; + } + + private static FeishuReplyTtsHealthStatus CreateAvailable(string storageRoot, string message, bool useWindowsPaths) + { + return new FeishuReplyTtsHealthStatus + { + IsAvailable = true, + StorageRoot = storageRoot, + Message = message, + ModelsRoot = AppendSegment(storageRoot, "models", useWindowsPaths), + CacheRoot = AppendSegment(storageRoot, "cache", useWindowsPaths), + TempRoot = AppendSegment(storageRoot, "temp", useWindowsPaths), + LogsRoot = AppendSegment(storageRoot, "logs", useWindowsPaths), + VenvRoot = AppendSegment(storageRoot, "venv", useWindowsPaths) + }; + } + + private static string BuildWindowsStorageRoot(string driveRoot) + { + return AppendSegment(AppendSegment(NormalizeDriveRoot(driveRoot), "WebCodeData", useWindowsPaths: true), "Kokoro", useWindowsPaths: true); + } + + private bool HasWindowsInstallEvidence(string storageRoot) + { + if (string.IsNullOrWhiteSpace(storageRoot) || !_hostEnvironment.DirectoryExists(storageRoot)) + { + return false; + } + + var ffmpegPath = AppendSegment(AppendSegment(AppendSegment(storageRoot, "ffmpeg", useWindowsPaths: true), "bin", useWindowsPaths: true), "ffmpeg.exe", useWindowsPaths: true); + if (_hostEnvironment.FileExists(ffmpegPath)) + { + return true; + } + + var modelsRoot = AppendSegment(storageRoot, "models", useWindowsPaths: true); + if (_hostEnvironment.DirectoryExists(modelsRoot)) + { + return true; + } + + var venvRoot = AppendSegment(storageRoot, "venv", useWindowsPaths: true); + if (_hostEnvironment.DirectoryExists(venvRoot)) + { + return true; + } + + var serviceRoot = AppendSegment(storageRoot, "service", useWindowsPaths: true); + return _hostEnvironment.DirectoryExists(serviceRoot); + } + + private static string AppendSegment(string root, string segment, bool useWindowsPaths) + { + var separator = useWindowsPaths ? '\\' : '/'; + var normalizedRoot = NormalizeStorageRoot(root, useWindowsPaths); + var normalizedSegment = segment.Trim().Trim('\\', '/'); + + if (normalizedRoot[^1] == separator) + { + return normalizedRoot + normalizedSegment; + } + + return normalizedRoot + separator + normalizedSegment; + } + + private static bool UsesWindowsSeparators(string path) + { + return path.Contains('\\', StringComparison.Ordinal) || + (path.Length >= 2 && char.IsLetter(path[0]) && path[1] == ':'); + } + + private static bool IsSameDrive(string driveRoot, string systemDriveRoot) + { + return string.Equals( + NormalizeDriveRootForComparison(driveRoot), + NormalizeDriveRootForComparison(systemDriveRoot), + StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeDriveRootForComparison(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return string.Empty; + } + + var normalized = path.Trim().Replace('/', '\\'); + if (normalized.Length >= 2 && char.IsLetter(normalized[0]) && normalized[1] == ':') + { + return $"{char.ToUpperInvariant(normalized[0])}:\\"; + } + + return NormalizeDriveRoot(normalized); + } + + private static string NormalizeDriveRoot(string? root) + { + if (string.IsNullOrWhiteSpace(root)) + { + return string.Empty; + } + + var normalized = root.Trim().Replace('/', '\\'); + if (normalized.Length == 2 && normalized[1] == ':') + { + return normalized + "\\"; + } + + normalized = normalized.TrimEnd('\\'); + if (normalized.Length == 2 && normalized[1] == ':') + { + return normalized + "\\"; + } + + if (!normalized.EndsWith('\\')) + { + normalized += "\\"; + } + + return normalized; + } + + private static string NormalizeStorageRoot(string path, bool useWindowsPaths) + { + var separator = useWindowsPaths ? '\\' : '/'; + var alternateSeparator = useWindowsPaths ? '/' : '\\'; + var normalized = path.Trim().Replace(alternateSeparator, separator); + + if (useWindowsPaths && IsWindowsDriveDesignator(normalized)) + { + return normalized + "\\"; + } + + while (normalized.Length > 1 && normalized.EndsWith(separator)) + { + if (!useWindowsPaths && normalized == "/") + { + break; + } + + if (useWindowsPaths && normalized.Length == 3 && normalized[1] == ':' && normalized[2] == '\\') + { + break; + } + + normalized = normalized[..^1]; + } + + return normalized; + } + + private static bool IsWindowsDriveDesignator(string path) + { + return path.Length == 2 && char.IsLetter(path[0]) && path[1] == ':'; + } + + private sealed class SystemReplyTtsHostEnvironment : IReplyTtsHostEnvironment + { + public bool IsWindows => OperatingSystem.IsWindows(); + + public string? SystemDriveRoot + { + get + { + if (!IsWindows) + { + return null; + } + + var systemDirectory = Environment.GetFolderPath(Environment.SpecialFolder.System); + return string.IsNullOrWhiteSpace(systemDirectory) + ? Environment.GetEnvironmentVariable("SystemDrive") + : Path.GetPathRoot(systemDirectory); + } + } + + public IReadOnlyList GetFixedDrives() + { + return DriveInfo.GetDrives() + .Where(drive => drive.DriveType == DriveType.Fixed) + .OrderBy(drive => drive.Name, StringComparer.OrdinalIgnoreCase) + .Select(drive => new ReplyTtsDriveDescriptor( + drive.RootDirectory.FullName, + drive.IsReady, + CanWriteToDrive(drive))) + .ToArray(); + } + + public bool DirectoryExists(string path) + { + return Directory.Exists(path); + } + + public bool FileExists(string path) + { + return File.Exists(path); + } + + private static bool CanWriteToDrive(DriveInfo drive) + { + if (!drive.IsReady) + { + return false; + } + + var probeToken = Guid.NewGuid().ToString("N"); + var probeSandboxRoot = BuildProbeSandboxRoot(drive.RootDirectory.FullName, probeToken); + var probeDirectory = BuildProbeTargetDirectory(drive.RootDirectory.FullName, probeToken); + var probeFilePath = Path.Combine(probeDirectory, "probe.tmp"); + + try + { + Directory.CreateDirectory(probeDirectory); + + using var stream = new FileStream( + probeFilePath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 1, + FileOptions.DeleteOnClose); + + return true; + } + catch + { + return false; + } + finally + { + TryDeleteProbePath(probeFilePath); + TryDeleteProbeDirectory(probeSandboxRoot); + } + } + + private static string BuildProbeSandboxRoot(string driveRoot, string probeToken) + { + return Path.Combine( + NormalizeDriveRoot(driveRoot), + $".webcode-feishu-reply-tts-probe-{probeToken}"); + } + + private static string BuildProbeTargetDirectory(string driveRoot, string probeToken) + { + return Path.Combine( + BuildProbeSandboxRoot(driveRoot, probeToken), + "webcode", + "kokoro"); + } + + private static void TryDeleteProbePath(string probeFilePath) + { + try + { + if (File.Exists(probeFilePath)) + { + File.Delete(probeFilePath); + } + } + catch + { + } + } + + private static void TryDeleteProbeDirectory(string probeDirectory) + { + try + { + if (Directory.Exists(probeDirectory)) + { + Directory.Delete(probeDirectory, recursive: true); + } + } + catch + { + } + } + } +} + +public interface IReplyTtsHostEnvironment +{ + bool IsWindows { get; } + + string? SystemDriveRoot { get; } + + IReadOnlyList GetFixedDrives(); + + bool DirectoryExists(string path); + + bool FileExists(string path); +} + +public sealed class ReplyTtsDriveDescriptor +{ + public ReplyTtsDriveDescriptor(string rootPath, bool isReady, bool isWritable) + { + RootPath = rootPath ?? throw new ArgumentNullException(nameof(rootPath)); + IsReady = isReady; + IsWritable = isWritable; + } + + public string RootPath { get; } + + public bool IsReady { get; } + + public bool IsWritable { get; } +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/SherpaKokoroTtsClient.cs b/WebCodeCli.Domain/Domain/Service/Channels/SherpaKokoroTtsClient.cs new file mode 100644 index 0000000..3bea847 --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/Channels/SherpaKokoroTtsClient.cs @@ -0,0 +1,213 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using WebCodeCli.Domain.Common.Extensions; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model.Channels; + +namespace WebCodeCli.Domain.Domain.Service.Channels; + +[ServiceDescription(typeof(ISherpaKokoroTtsClient), ServiceLifetime.Scoped)] +public sealed class SherpaKokoroTtsClient : ISherpaKokoroTtsClient +{ + private const string HttpClientName = "SherpaKokoroTtsClient"; + + private readonly FeishuReplyTtsOptions _options; + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + private readonly Uri _baseUri; + + public SherpaKokoroTtsClient( + IOptions options, + ILogger logger, + IHttpClientFactory httpClientFactory) + { + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _httpClient = httpClientFactory?.CreateClient(HttpClientName) ?? throw new ArgumentNullException(nameof(httpClientFactory)); + _httpClient.Timeout = Timeout.InfiniteTimeSpan; + _baseUri = CreateBaseUri(_options.TtsServiceBaseUrl); + } + + public async Task GetHealthAsync(CancellationToken cancellationToken = default) + { + using var request = new HttpRequestMessage(HttpMethod.Get, BuildUri("/health")); + using var response = await SendAsync(request, cancellationToken); + using var document = await ParseResponseAsync(response, cancellationToken); + + var root = document.RootElement; + var status = GetString(root, "status"); + var isAvailable = string.Equals(status, "ok", StringComparison.OrdinalIgnoreCase); + + return new FeishuReplyTtsHealthStatus + { + IsAvailable = isAvailable, + Message = isAvailable + ? "Local Kokoro/sherpa-onnx service is healthy." + : $"Local Kokoro/sherpa-onnx service reported status '{status ?? "unknown"}'.", + ServiceStatus = status, + Device = GetString(root, "device"), + DefaultVoiceId = GetString(root, "defaultVoiceId", "default_voice_id") + }; + } + + public async Task> GetVoicesAsync(CancellationToken cancellationToken = default) + { + using var request = new HttpRequestMessage(HttpMethod.Get, BuildUri("/voices")); + using var response = await SendAsync(request, cancellationToken); + using var document = await ParseResponseAsync(response, cancellationToken); + + var voicesElement = document.RootElement.ValueKind switch + { + JsonValueKind.Array => document.RootElement, + _ when document.RootElement.TryGetProperty("voices", out var arrayElement) => arrayElement, + _ => default + }; + + if (voicesElement.ValueKind != JsonValueKind.Array) + { + return []; + } + + var voices = new List(); + foreach (var item in voicesElement.EnumerateArray()) + { + var voiceId = GetString(item, "voiceId", "voice_id"); + if (string.IsNullOrWhiteSpace(voiceId)) + { + continue; + } + + voices.Add(new FeishuReplyTtsVoiceOption + { + VoiceId = voiceId, + DisplayName = GetString(item, "displayName", "display_name", "name") ?? voiceId, + Language = GetString(item, "language"), + Gender = GetString(item, "gender") + }); + } + + return voices; + } + + public async Task SynthesizeAsync(string text, string voiceId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(text)) + { + throw new ArgumentException("Text is required.", nameof(text)); + } + + if (string.IsNullOrWhiteSpace(voiceId)) + { + throw new ArgumentException("Voice ID is required.", nameof(voiceId)); + } + + using var request = new HttpRequestMessage(HttpMethod.Post, BuildUri("/synthesize")) + { + Content = new StringContent( + JsonSerializer.Serialize(new + { + text, + voice_id = voiceId + }), + Encoding.UTF8, + "application/json") + }; + + _logger.LogInformation( + "Starting Kokoro/sherpa-onnx synthesis. VoiceId={VoiceId}, TextLength={TextLength}", + voiceId, + text.Length); + + using var response = await SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError("Kokoro/sherpa-onnx synthesize request failed: Status={StatusCode}, Content={Content}", response.StatusCode, error); + throw new HttpRequestException($"Kokoro/sherpa-onnx synthesize request failed: {response.StatusCode}"); + } + + await using var source = await response.Content.ReadAsStreamAsync(cancellationToken); + var output = new MemoryStream(); + await source.CopyToAsync(output, cancellationToken); + output.Position = 0; + _logger.LogInformation( + "Completed Kokoro/sherpa-onnx synthesis. VoiceId={VoiceId}, TextLength={TextLength}, WaveBytes={WaveBytes}", + voiceId, + text.Length, + output.Length); + return output; + } + + private async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (_options.TtsServiceTimeoutSeconds <= 0) + { + return await _httpClient.SendAsync(request, cancellationToken); + } + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(_options.TtsServiceTimeoutSeconds)); + try + { + return await _httpClient.SendAsync(request, timeoutCts.Token); + } + catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + _logger.LogWarning( + ex, + "Kokoro/sherpa-onnx request timed out after {TimeoutSeconds}s. Method={Method}, Url={Url}", + _options.TtsServiceTimeoutSeconds, + request.Method, + request.RequestUri); + throw new TimeoutException( + $"Kokoro/sherpa-onnx request timed out after {_options.TtsServiceTimeoutSeconds} seconds.", + ex); + } + } + + private static async Task ParseResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + var content = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"Kokoro/sherpa-onnx request failed: {response.StatusCode}"); + } + + return JsonDocument.Parse(content); + } + + private Uri BuildUri(string relativePath) + { + return new Uri(_baseUri, relativePath.TrimStart('/')); + } + + private static Uri CreateBaseUri(string? baseUrl) + { + var candidate = string.IsNullOrWhiteSpace(baseUrl) + ? "http://127.0.0.1:5058/" + : baseUrl.Trim(); + + if (!candidate.EndsWith("/", StringComparison.Ordinal)) + { + candidate += "/"; + } + + return new Uri(candidate, UriKind.Absolute); + } + + private static string? GetString(JsonElement element, params string[] propertyNames) + { + foreach (var propertyName in propertyNames) + { + if (element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String) + { + return property.GetString(); + } + } + + return null; + } +} diff --git a/WebCodeCli.Domain/Domain/Service/Channels/SuperpowersQuickActionCardHelper.cs b/WebCodeCli.Domain/Domain/Service/Channels/SuperpowersQuickActionCardHelper.cs index 4b9e256..64d2607 100644 --- a/WebCodeCli.Domain/Domain/Service/Channels/SuperpowersQuickActionCardHelper.cs +++ b/WebCodeCli.Domain/Domain/Service/Channels/SuperpowersQuickActionCardHelper.cs @@ -65,12 +65,26 @@ public static IReadOnlyList CreateBottomActions ]; } + var actions = new List + { + new() + { + Text = SuperpowersQuickActionDefaults.ContinueButtonText, + Type = "default", + Value = BuildActionValue( + FeishuHelpCardAction.ContinueSuperpowersAction, + sessionId, + chatKey, + toolId) + } + }; + if (!showPlanActions) { - return []; + return actions; } - return + actions.AddRange( [ new FeishuStreamingCardBottomAction { @@ -92,7 +106,9 @@ public static IReadOnlyList CreateBottomActions chatKey, toolId) } - ]; + ]); + + return actions; } public static string? MergeCapabilityStatusMarkdown( diff --git a/WebCodeCli.Domain/Domain/Service/CliExecutorService.cs b/WebCodeCli.Domain/Domain/Service/CliExecutorService.cs index d581633..0d7835c 100644 --- a/WebCodeCli.Domain/Domain/Service/CliExecutorService.cs +++ b/WebCodeCli.Domain/Domain/Service/CliExecutorService.cs @@ -34,6 +34,7 @@ public class CliExecutorService : ICliExecutorService private readonly IChatSessionService _chatSessionService; private readonly ICliAdapterFactory _adapterFactory; private readonly ICcSwitchService _ccSwitchService; + private readonly IGoalCapabilityService _goalCapabilityService; // 缓存的有效工作区根目录 private string? _effectiveWorkspaceRoot; @@ -58,7 +59,8 @@ public CliExecutorService( IServiceProvider serviceProvider, IChatSessionService chatSessionService, ICliAdapterFactory adapterFactory, - ICcSwitchService ccSwitchService) + ICcSwitchService ccSwitchService, + IGoalCapabilityService? goalCapabilityService = null) { _logger = logger; _options = options.Value; @@ -68,6 +70,7 @@ public CliExecutorService( _chatSessionService = chatSessionService; _adapterFactory = adapterFactory; _ccSwitchService = ccSwitchService; + _goalCapabilityService = goalCapabilityService ?? NullGoalCapabilityService.Instance; // 初始化工作区根目录(延迟加载,首次使用时从数据库获取) InitializeWorkspaceRoot(); @@ -3208,10 +3211,17 @@ private async Task PrepareManagedCodexLaunchConfigAsync( } else { - baseContent = BuildCodexConfigContent(environmentVariables); + baseContent = BuildCodexConfigContent( + environmentVariables, + enableGoalsFeature: await ShouldEnableCodexGoalsAsync(sessionWorkspace, cancellationToken)); } baseContent = StripUnsupportedProjectCodexConfigSections(baseContent); + if (await ShouldEnableCodexGoalsAsync(sessionWorkspace, cancellationToken)) + { + baseContent = UpsertTomlBooleanSetting(baseContent, "features", "goals", true); + } + await WriteFileIfChangedAsync(baseConfigPath, baseContent, cancellationToken); var launchConfigContent = ApplyCodexLaunchOverride(baseContent, launchOverride); @@ -3446,6 +3456,46 @@ private static string UpsertTomlStringSetting(string content, string key, string return normalizedContent + $"{key} = \"{escapedValue}\"{Environment.NewLine}"; } + private static string UpsertTomlBooleanSetting(string content, string sectionName, string key, bool value) + { + var normalizedContent = content.Replace("\r\n", "\n", StringComparison.Ordinal); + var lines = normalizedContent.Split('\n').ToList(); + var sectionHeader = $"[{sectionName}]"; + var assignment = $"{key} = {value.ToString().ToLowerInvariant()}"; + var sectionIndex = lines.FindIndex(line => string.Equals(line.Trim(), sectionHeader, StringComparison.OrdinalIgnoreCase)); + + if (sectionIndex >= 0) + { + for (var i = sectionIndex + 1; i < lines.Count; i++) + { + var trimmedLine = lines[i].Trim(); + if (trimmedLine.StartsWith("[", StringComparison.Ordinal) && trimmedLine.EndsWith("]", StringComparison.Ordinal)) + { + lines.Insert(i, assignment); + return string.Join(Environment.NewLine, lines).TrimEnd() + Environment.NewLine; + } + + if (trimmedLine.StartsWith($"{key} =", StringComparison.OrdinalIgnoreCase)) + { + lines[i] = assignment; + return string.Join(Environment.NewLine, lines).TrimEnd() + Environment.NewLine; + } + } + + lines.Add(assignment); + return string.Join(Environment.NewLine, lines).TrimEnd() + Environment.NewLine; + } + + var builder = normalizedContent.TrimEnd('\n'); + if (!string.IsNullOrWhiteSpace(builder)) + { + builder += "\n\n"; + } + + builder += $"{sectionHeader}\n{assignment}\n"; + return builder.Replace("\n", Environment.NewLine, StringComparison.Ordinal); + } + private static string EscapeTomlString(string value) { return value @@ -3913,7 +3963,9 @@ private void GenerateCodexConfigFile(Dictionary envVars) { try { - var configContent = BuildCodexConfigContent(envVars); + var configContent = BuildCodexConfigContent( + envVars, + enableGoalsFeature: ShouldEnableCodexGoalsSync()); var configHash = configContent.GetHashCode().ToString(); // 检查配置是否变化 @@ -4066,7 +4118,49 @@ private List FilterCcSwitchLaunchReadyTools(IEnumerable envVars) + private async Task ShouldEnableCodexGoalsAsync(string? workspacePath, CancellationToken cancellationToken) + { + try + { + var result = await _goalCapabilityService.ProbeAsync( + new GoalCapabilityContext + { + ToolId = "codex", + WorkspacePath = workspacePath + }, + forceRefresh: false, + cancellationToken: cancellationToken); + + return result.State == GoalCapabilityState.Available; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "检测 Codex /goal 能力失败,当前会话将不注入 goals feature"); + return false; + } + } + + private bool ShouldEnableCodexGoalsSync() + { + try + { + var result = _goalCapabilityService.ProbeAsync( + new GoalCapabilityContext + { + ToolId = "codex" + }, + forceRefresh: false).GetAwaiter().GetResult(); + + return result.State == GoalCapabilityState.Available; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "检测 Codex /goal 能力失败,全局配置将不注入 goals feature"); + return false; + } + } + + private static string BuildCodexConfigContent(Dictionary envVars, bool enableGoalsFeature) { var baseUrl = envVars.GetValueOrDefault("CODEX_BASE_URL", "https://api.routin.ai/v1"); var model = envVars.GetValueOrDefault("CODEX_MODEL", "gpt-5.4"); @@ -4080,6 +4174,9 @@ private static string BuildCodexConfigContent(Dictionary envVars var sandboxMode = envVars.GetValueOrDefault("CODEX_SANDBOX_MODE", "danger-full-access"); var maxContext = envVars.GetValueOrDefault("CODEX_MAX_CONTEXT", "1000000"); var contextCompactLimit = envVars.GetValueOrDefault("CODEX_CONTEXT_COMPACT_LIMIT", "800000"); + var featureFlags = enableGoalsFeature + ? "[features]\ngoals = true\n\n" + : string.Empty; return $@"# Codex CLI 配置文件(由 WebCode 动态生成) @@ -4102,6 +4199,8 @@ private static string BuildCodexConfigContent(Dictionary envVars requires_openai_auth = true wire_api = ""{wireApi}"" +{featureFlags} + [windows] sandbox = ""elevated"" "; diff --git a/WebCodeCli.Domain/Domain/Service/CodexThreadProviderSyncService.cs b/WebCodeCli.Domain/Domain/Service/CodexThreadProviderSyncService.cs index c4b1acc..ecebc08 100644 --- a/WebCodeCli.Domain/Domain/Service/CodexThreadProviderSyncService.cs +++ b/WebCodeCli.Domain/Domain/Service/CodexThreadProviderSyncService.cs @@ -66,7 +66,12 @@ public async Task SyncThreadProviderAsync( { cancellationToken.ThrowIfCancellationRequested(); - var rewriteOutcome = await TryRewriteRolloutFileAsync(rolloutPath, threadId, targetProviderId, cancellationToken); + var rewriteOutcome = await TryRewriteRolloutFileAsync( + rolloutPath, + targetCodexRoot, + threadId, + targetProviderId, + cancellationToken); switch (rewriteOutcome) { case RolloutRewriteOutcome.Updated: @@ -201,6 +206,7 @@ private async Task SeedMissingRolloutFilesAsync( private async Task TryRewriteRolloutFileAsync( string rolloutPath, + string targetCodexRoot, string threadId, string targetProviderId, CancellationToken cancellationToken) @@ -242,6 +248,11 @@ private async Task TryRewriteRolloutFileAsync( var updatedContent = string.Concat(updatedFirstLine, firstLineSplit.LineBreak, firstLineSplit.Remainder); try { + if (!await TryBackupRolloutFileAsync(rolloutPath, targetCodexRoot, content, cancellationToken)) + { + return RolloutRewriteOutcome.Locked; + } + await File.WriteAllTextAsync(rolloutPath, updatedContent, cancellationToken); return RolloutRewriteOutcome.Updated; } @@ -257,6 +268,55 @@ private async Task TryRewriteRolloutFileAsync( } } + private async Task TryBackupRolloutFileAsync( + string rolloutPath, + string targetCodexRoot, + string content, + CancellationToken cancellationToken) + { + string relativePath; + try + { + relativePath = Path.GetRelativePath(targetCodexRoot, rolloutPath); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException) + { + _logger.LogWarning(ex, "Resolve rollout backup path failed: {RolloutPath}", rolloutPath); + return false; + } + + if (relativePath.StartsWith("..", GetPathComparison()) + || Path.IsPathRooted(relativePath)) + { + _logger.LogWarning("Skip rollout backup because path escapes session-local Codex root: {RolloutPath}", rolloutPath); + return false; + } + + var backupPath = Path.Combine(targetCodexRoot, "rollout-backups", relativePath); + + try + { + var backupDirectory = Path.GetDirectoryName(backupPath); + if (!string.IsNullOrWhiteSpace(backupDirectory)) + { + Directory.CreateDirectory(backupDirectory); + } + + await File.WriteAllTextAsync(backupPath, content, cancellationToken); + return true; + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Write rollout backup failed: {BackupPath}", backupPath); + return false; + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Write rollout backup denied: {BackupPath}", backupPath); + return false; + } + } + private static async Task RolloutFileBelongsToThreadAsync( string rolloutPath, string threadId, diff --git a/WebCodeCli.Domain/Domain/Service/ExternalCliHistoryTextBuilder.cs b/WebCodeCli.Domain/Domain/Service/ExternalCliHistoryTextBuilder.cs new file mode 100644 index 0000000..a7c026f --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/ExternalCliHistoryTextBuilder.cs @@ -0,0 +1,81 @@ +using System.Text; +using WebCodeCli.Domain.Domain.Model; + +namespace WebCodeCli.Domain.Domain.Service; + +public static class ExternalCliHistoryTextBuilder +{ + public static string Build( + string title, + IReadOnlyList messages, + string toolLabel, + string? workspacePath, + string? cliThreadId, + string? sourcePath = null, + DateTime? lastActiveTime = null) + { + var builder = new StringBuilder(); + builder.AppendLine(string.IsNullOrWhiteSpace(title) ? "当前 CLI 会话历史" : title.Trim()); + builder.AppendLine($"历史来源: {FormatSourcePath(sourcePath)}"); + builder.AppendLine($"CLI 工具: {toolLabel}"); + builder.AppendLine($"工作目录: {workspacePath ?? "(工作区未初始化或已失效)"}"); + builder.AppendLine($"原生 Thread ID: {FormatThreadId(cliThreadId)}"); + + if (lastActiveTime.HasValue) + { + builder.AppendLine($"最后活跃: {lastActiveTime:yyyy-MM-dd HH:mm}"); + } + + builder.AppendLine(); + + if (messages.Count == 0) + { + builder.AppendLine("该 CLI 会话暂无可解析的历史消息。"); + return builder.ToString().TrimEnd(); + } + + builder.AppendLine($"显示条数: 最近 {messages.Count} 条"); + builder.AppendLine(); + + foreach (var message in messages) + { + 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(NormalizeHistoryContent(message.Content)); + builder.AppendLine(); + } + + return builder.ToString().TrimEnd(); + } + + private static string FormatThreadId(string? cliThreadId) + { + return string.IsNullOrWhiteSpace(cliThreadId) ? "未绑定" : cliThreadId.Trim(); + } + + private static string FormatSourcePath(string? sourcePath) + { + return string.IsNullOrWhiteSpace(sourcePath) ? "未定位" : sourcePath.Trim(); + } + + private static string NormalizeHistoryContent(string? content) + { + if (string.IsNullOrWhiteSpace(content)) + { + return string.Empty; + } + + return content.Replace("\r\n", "\n").Trim(); + } +} diff --git a/WebCodeCli.Domain/Domain/Service/ExternalCliSessionHistoryService.cs b/WebCodeCli.Domain/Domain/Service/ExternalCliSessionHistoryService.cs index 809d339..a2d49a2 100644 --- a/WebCodeCli.Domain/Domain/Service/ExternalCliSessionHistoryService.cs +++ b/WebCodeCli.Domain/Domain/Service/ExternalCliSessionHistoryService.cs @@ -13,6 +13,13 @@ public interface IExternalCliSessionHistoryService /// /// 读取当前系统账户下外部 CLI 原生会话的最近历史消息 /// + Task GetRecentHistoryAsync( + string toolId, + string cliThreadId, + int maxCount = 20, + string? workspacePath = null, + CancellationToken cancellationToken = default); + Task> GetRecentMessagesAsync( string toolId, string cliThreadId, @@ -31,6 +38,56 @@ public ExternalCliSessionHistoryService(ILogger GetRecentHistoryAsync( + string toolId, + string cliThreadId, + int maxCount = 20, + string? workspacePath = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(toolId) || string.IsNullOrWhiteSpace(cliThreadId)) + { + return new ExternalCliHistoryResult(); + } + + var normalizedToolId = NormalizeToolId(toolId); + var normalizedThreadId = cliThreadId.Trim(); + var effectiveMaxCount = maxCount <= 0 ? 20 : maxCount; + + try + { + var messages = await GetRecentMessagesAsync( + normalizedToolId, + normalizedThreadId, + effectiveMaxCount, + workspacePath, + cancellationToken); + + var sourcePath = normalizedToolId switch + { + "codex" => FindCodexRolloutFile(normalizedThreadId, workspacePath, cancellationToken), + "claude-code" => FindClaudeTranscriptFile(normalizedThreadId, cancellationToken), + "opencode" => $"opencode export {normalizedThreadId}", + _ => null + }; + + return new ExternalCliHistoryResult + { + Messages = messages, + SourcePath = sourcePath + }; + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "读取外部 CLI 历史结果失败: ToolId={ToolId}, CliThreadId={CliThreadId}", + normalizedToolId, + normalizedThreadId); + return new ExternalCliHistoryResult(); + } + } + public async Task> GetRecentMessagesAsync( string toolId, string cliThreadId, @@ -191,19 +248,28 @@ private async Task> GetOpenCodeMessagesAsync( { try { - foreach (var sessionsRoot in GetCodexSessionsRootPaths(workspacePath)) + var candidates = GetCodexSessionsRootCandidates(workspacePath).ToList(); + _logger.LogInformation( + "[CodexHistory] Start resolving rollout: CliThreadId={CliThreadId}, WorkspacePath={WorkspacePath}, Roots={Roots}", + cliThreadId, + workspacePath, + string.Join(" | ", candidates.Select(candidate => $"{candidate.Scope}:{candidate.Path}"))); + + foreach (var sessionsRoot in candidates) { var directCandidates = Directory - .EnumerateFiles(sessionsRoot, $"*{cliThreadId}*.jsonl", SearchOption.AllDirectories) + .EnumerateFiles(sessionsRoot.Path, $"*{cliThreadId}*.jsonl", SearchOption.AllDirectories) .OrderByDescending(File.GetLastWriteTimeUtc) .ToList(); if (directCandidates.Count > 0) { - return directCandidates[0]; + var rolloutPath = directCandidates[0]; + LogCodexRolloutResolved(cliThreadId, workspacePath, sessionsRoot, rolloutPath, "filename", directCandidates.Count); + return rolloutPath; } - foreach (var file in Directory.EnumerateFiles(sessionsRoot, "rollout-*.jsonl", SearchOption.AllDirectories)) + foreach (var file in Directory.EnumerateFiles(sessionsRoot.Path, "rollout-*.jsonl", SearchOption.AllDirectories)) { cancellationToken.ThrowIfCancellationRequested(); @@ -225,6 +291,7 @@ private async Task> GetOpenCodeMessagesAsync( var sessionId = GetString(payload, "id"); if (string.Equals(sessionId, cliThreadId, StringComparison.OrdinalIgnoreCase)) { + LogCodexRolloutResolved(cliThreadId, workspacePath, sessionsRoot, file, "payload.id", directCandidateCount: 0); return file; } } @@ -234,10 +301,20 @@ private async Task> GetOpenCodeMessagesAsync( } } } + + _logger.LogWarning( + "[CodexHistory] Rollout not found: CliThreadId={CliThreadId}, WorkspacePath={WorkspacePath}, Roots={Roots}", + cliThreadId, + workspacePath, + string.Join(" | ", candidates.Select(candidate => $"{candidate.Scope}:{candidate.Path}"))); } catch (Exception ex) { - _logger.LogDebug(ex, "定位 Codex rollout 文件失败"); + _logger.LogDebug( + ex, + "[CodexHistory] Resolve rollout failed: CliThreadId={CliThreadId}, WorkspacePath={WorkspacePath}", + cliThreadId, + workspacePath); } return null; @@ -621,7 +698,60 @@ private static string ExtractTextParts(JsonElement items, params string[] suppor return null; } - private IEnumerable GetCodexSessionsRootPaths(string? workspacePath) + private void LogCodexRolloutResolved( + string cliThreadId, + string? workspacePath, + CodexSessionsRootCandidate sessionsRoot, + string rolloutPath, + string matchKind, + int directCandidateCount) + { + var metadata = ReadCodexRolloutMetadata(rolloutPath); + var lastWriteTimeUtc = File.GetLastWriteTimeUtc(rolloutPath); + _logger.LogInformation( + "[CodexHistory] Rollout resolved: CliThreadId={CliThreadId}, Scope={Scope}, MatchKind={MatchKind}, DirectCandidateCount={DirectCandidateCount}, RolloutPath={RolloutPath}, RootPath={RootPath}, LastWriteTimeUtc={LastWriteTimeUtc:O}, FirstLineThreadId={FirstLineThreadId}, FirstLineModelProvider={FirstLineModelProvider}, FirstLineCwd={FirstLineCwd}, WorkspacePath={WorkspacePath}", + cliThreadId, + sessionsRoot.Scope, + matchKind, + directCandidateCount, + rolloutPath, + sessionsRoot.Path, + lastWriteTimeUtc, + metadata.ThreadId, + metadata.ModelProvider, + metadata.Cwd, + workspacePath); + } + + private static CodexRolloutMetadata ReadCodexRolloutMetadata(string rolloutPath) + { + try + { + var firstLine = ReadFirstNonEmptyLine(rolloutPath, maxLines: 3); + if (string.IsNullOrWhiteSpace(firstLine)) + { + return new CodexRolloutMetadata(null, null, null); + } + + using var document = JsonDocument.Parse(firstLine); + if (!TryGetProperty(document.RootElement, "payload", out var payload) + || payload.ValueKind != JsonValueKind.Object) + { + return new CodexRolloutMetadata(null, null, null); + } + + return new CodexRolloutMetadata( + GetString(payload, "id"), + GetString(payload, "model_provider"), + GetString(payload, "cwd")); + } + catch + { + return new CodexRolloutMetadata(null, null, null); + } + } + + private IEnumerable GetCodexSessionsRootCandidates(string? workspacePath) { var yielded = new HashSet(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); @@ -635,7 +765,7 @@ private IEnumerable GetCodexSessionsRootPaths(string? workspacePath) var normalized = Path.GetFullPath(workspaceSessionsRoot); if (yielded.Add(normalized)) { - yield return normalized; + yield return new CodexSessionsRootCandidate(normalized, "workspace"); } } @@ -649,11 +779,15 @@ private IEnumerable GetCodexSessionsRootPaths(string? workspacePath) var normalized = Path.GetFullPath(globalSessionsRoot); if (yielded.Add(normalized)) { - yield return normalized; + yield return new CodexSessionsRootCandidate(normalized, "global"); } } } + private sealed record CodexSessionsRootCandidate(string Path, string Scope); + + private sealed record CodexRolloutMetadata(string? ThreadId, string? ModelProvider, string? Cwd); + private static IEnumerable GetWorkspaceCodexSessionsRootPaths(string? workspacePath) { if (string.IsNullOrWhiteSpace(workspacePath)) diff --git a/WebCodeCli.Domain/Domain/Service/GoalCapabilityService.cs b/WebCodeCli.Domain/Domain/Service/GoalCapabilityService.cs new file mode 100644 index 0000000..132539c --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/GoalCapabilityService.cs @@ -0,0 +1,654 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text.RegularExpressions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using WebCodeCli.Domain.Common.Extensions; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model; + +namespace WebCodeCli.Domain.Domain.Service; + +public interface IGoalCapabilityService +{ + Task GetStateAsync( + GoalCapabilityContext context, + CancellationToken cancellationToken = default); + + Task ProbeAsync( + GoalCapabilityContext context, + bool forceRefresh = false, + CancellationToken cancellationToken = default); +} + +public enum GoalCapabilityState +{ + Unknown, + Available, + Unavailable +} + +public enum GoalCapabilityProbeOutcome +{ + Available, + UnsupportedTool, + UnsupportedVersion, + MissingFeature, + ProbeFailed +} + +public sealed class GoalCapabilityContext +{ + public string ToolId { get; set; } = string.Empty; + + public string? ProviderId { get; set; } + + public string? WorkspacePath { get; set; } +} + +public class GoalCapabilitySnapshot +{ + public string ToolId { get; set; } = string.Empty; + + public string ProviderId { get; set; } = GoalCapabilityService.UnscopedProviderId; + + public string CacheKey { get; set; } = string.Empty; + + public GoalCapabilityState State { get; set; } + + public string? Message { get; set; } + + public string? DetectedVersion { get; set; } +} + +public sealed class GoalCapabilityProbeResult : GoalCapabilitySnapshot +{ + public GoalCapabilityProbeOutcome Outcome { get; set; } + + public bool FromCache { get; set; } + + public bool HasGoalsFeature { get; set; } +} + +[ServiceDescription(typeof(IGoalCapabilityService), ServiceLifetime.Singleton)] +public class GoalCapabilityService : IGoalCapabilityService +{ + public const string UnscopedProviderId = "__default__"; + + private static readonly Version MinimumCodexVersion = new(0, 128, 0); + private static readonly Regex VersionRegex = new(@"(?\d+\.\d+\.\d+)", RegexOptions.Compiled); + private static readonly string[] WindowsExecutableExtensions = + [ + ".exe", + ".cmd", + ".bat", + ".ps1" + ]; + + private readonly ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); + private readonly ICcSwitchService _ccSwitchService; + private readonly ILogger _logger; + private readonly IReadOnlyList _toolConfigs; + + public GoalCapabilityService( + ICcSwitchService ccSwitchService, + IOptions options, + ILogger logger) + { + _ccSwitchService = ccSwitchService; + _logger = logger; + _toolConfigs = options.Value.Tools; + } + + public async Task GetStateAsync( + GoalCapabilityContext context, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + var resolvedContext = await ResolveContextAsync(context, cancellationToken); + if (_cache.TryGetValue(resolvedContext.CacheKey, out var cached)) + { + return cached.ToSnapshot(); + } + + return new GoalCapabilitySnapshot + { + ToolId = resolvedContext.ToolId, + ProviderId = resolvedContext.ProviderId, + CacheKey = resolvedContext.CacheKey, + State = GoalCapabilityState.Unknown + }; + } + + public async Task ProbeAsync( + GoalCapabilityContext context, + bool forceRefresh = false, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + var resolvedContext = await ResolveContextAsync(context, cancellationToken); + if (!forceRefresh && _cache.TryGetValue(resolvedContext.CacheKey, out var cached)) + { + return cached.ToProbeResult(fromCache: true); + } + + try + { + var result = await ProbeCoreAsync(resolvedContext, cancellationToken); + if (result.State == GoalCapabilityState.Unknown) + { + _cache.TryRemove(resolvedContext.CacheKey, out _); + } + else + { + _cache[resolvedContext.CacheKey] = GoalCapabilityCacheEntry.From(result); + } + + return result; + } + catch (Exception ex) + { + _cache.TryRemove(resolvedContext.CacheKey, out _); + _logger.LogWarning( + ex, + "检测 /goal 能力失败: ToolId={ToolId}, ProviderId={ProviderId}", + resolvedContext.ToolId, + resolvedContext.ProviderId); + + return new GoalCapabilityProbeResult + { + ToolId = resolvedContext.ToolId, + ProviderId = resolvedContext.ProviderId, + CacheKey = resolvedContext.CacheKey, + State = GoalCapabilityState.Unknown, + Outcome = GoalCapabilityProbeOutcome.ProbeFailed, + Message = GoalQuickActionDefaults.CapabilityProbeFailedText + }; + } + } + + private async Task ProbeCoreAsync( + ResolvedGoalCapabilityContext context, + CancellationToken cancellationToken) + { + if (!string.Equals(context.ToolId, "codex", StringComparison.OrdinalIgnoreCase)) + { + return new GoalCapabilityProbeResult + { + ToolId = context.ToolId, + ProviderId = context.ProviderId, + CacheKey = context.CacheKey, + State = GoalCapabilityState.Unavailable, + Outcome = GoalCapabilityProbeOutcome.UnsupportedTool, + Message = "只有 Codex 支持 /goal 工作流" + }; + } + + var command = ResolveCodexCommand(); + var versionOutput = await RunCommandAsync( + command, + "--version", + context.WorkspacePath, + cancellationToken); + if (!versionOutput.Success) + { + return ProbeFailed(context, "无法检测 Codex 版本"); + } + + var detectedVersion = ParseVersion(versionOutput.Output); + if (detectedVersion == null) + { + return ProbeFailed(context, "无法解析 Codex 版本"); + } + + if (detectedVersion < MinimumCodexVersion) + { + return new GoalCapabilityProbeResult + { + ToolId = context.ToolId, + ProviderId = context.ProviderId, + CacheKey = context.CacheKey, + State = GoalCapabilityState.Unavailable, + Outcome = GoalCapabilityProbeOutcome.UnsupportedVersion, + Message = $"当前 Codex 版本 {detectedVersion} 不支持 /goal,请升级到 {MinimumCodexVersion} 或更高版本", + DetectedVersion = detectedVersion.ToString() + }; + } + + var featureOutput = await RunCommandAsync( + command, + "features list", + context.WorkspacePath, + cancellationToken); + if (!featureOutput.Success) + { + return ProbeFailed(context, "无法检测 Codex goals feature", detectedVersion.ToString()); + } + + var hasGoalsFeature = featureOutput.Output + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Any(static line => line.TrimStart().StartsWith("goals", StringComparison.OrdinalIgnoreCase)); + if (!hasGoalsFeature) + { + return new GoalCapabilityProbeResult + { + ToolId = context.ToolId, + ProviderId = context.ProviderId, + CacheKey = context.CacheKey, + State = GoalCapabilityState.Unavailable, + Outcome = GoalCapabilityProbeOutcome.MissingFeature, + Message = "当前 Codex 未提供 goals feature,无法启用 /goal", + DetectedVersion = detectedVersion.ToString() + }; + } + + return new GoalCapabilityProbeResult + { + ToolId = context.ToolId, + ProviderId = context.ProviderId, + CacheKey = context.CacheKey, + State = GoalCapabilityState.Available, + Outcome = GoalCapabilityProbeOutcome.Available, + DetectedVersion = detectedVersion.ToString(), + HasGoalsFeature = true + }; + } + + private async Task ResolveContextAsync( + GoalCapabilityContext context, + CancellationToken cancellationToken) + { + var normalizedToolId = NormalizeToolId(context.ToolId); + var providerId = NormalizeProviderId(context.ProviderId); + + if (string.IsNullOrWhiteSpace(providerId) + && !string.IsNullOrWhiteSpace(normalizedToolId) + && _ccSwitchService.IsManagedTool(normalizedToolId)) + { + var toolStatus = await _ccSwitchService.GetToolStatusAsync(normalizedToolId, cancellationToken); + providerId = NormalizeProviderId(toolStatus.ActiveProviderId); + } + + providerId = string.IsNullOrWhiteSpace(providerId) + ? UnscopedProviderId + : providerId; + + return new ResolvedGoalCapabilityContext + { + ToolId = normalizedToolId, + ProviderId = providerId, + WorkspacePath = NormalizeWorkspacePath(context.WorkspacePath), + CacheKey = $"{normalizedToolId}::{providerId}" + }; + } + + private string ResolveCodexCommand() + { + var configuredCommand = _toolConfigs + .FirstOrDefault(static tool => string.Equals(tool.Id, "codex", StringComparison.OrdinalIgnoreCase)) + ?.Command + ?.Trim(); + + return string.IsNullOrWhiteSpace(configuredCommand) ? "codex" : configuredCommand; + } + + protected virtual async Task RunCommandAsync( + string command, + string arguments, + string? workingDirectory, + CancellationToken cancellationToken) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(10)); + + var invocation = ResolveCommandInvocation(command, arguments); + + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = invocation.FileName, + Arguments = invocation.Arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = !string.IsNullOrWhiteSpace(workingDirectory) && Directory.Exists(workingDirectory) + ? workingDirectory + : Environment.CurrentDirectory + } + }; + + try + { + if (!process.Start()) + { + return new CommandProbeResult(false, string.Empty); + } + + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(timeoutCts.Token); + + var stdout = await stdoutTask; + var stderr = await stderrTask; + var output = string.IsNullOrWhiteSpace(stdout) ? stderr : stdout; + + return new CommandProbeResult(process.ExitCode == 0, output.Trim()); + } + catch (OperationCanceledException) + { + TryKill(process); + return new CommandProbeResult(false, string.Empty); + } + catch + { + TryKill(process); + return new CommandProbeResult(false, string.Empty); + } + } + + private static void TryKill(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch + { + } + } + + internal static CommandInvocation ResolveCommandInvocation( + string command, + string arguments, + string? pathEnvironment = null, + string? comSpec = null, + string? powershellPath = null) + { + if (string.IsNullOrWhiteSpace(command)) + { + return new CommandInvocation(string.Empty, arguments); + } + + var trimmedCommand = command.Trim().Trim('"'); + if (!OperatingSystem.IsWindows()) + { + return new CommandInvocation(trimmedCommand, arguments); + } + + var resolvedPath = ResolveWindowsCommandPath(trimmedCommand, pathEnvironment); + var extension = Path.GetExtension(resolvedPath); + + if (string.Equals(extension, ".ps1", StringComparison.OrdinalIgnoreCase)) + { + var shellPath = string.IsNullOrWhiteSpace(powershellPath) + ? "powershell.exe" + : powershellPath; + return new CommandInvocation( + shellPath, + $"-NoProfile -ExecutionPolicy Bypass -File \"{resolvedPath}\" {arguments}".Trim()); + } + + if (string.Equals(extension, ".cmd", StringComparison.OrdinalIgnoreCase) + || string.Equals(extension, ".bat", StringComparison.OrdinalIgnoreCase)) + { + var cmdPath = string.IsNullOrWhiteSpace(comSpec) + ? Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe" + : comSpec; + var commandArguments = string.IsNullOrWhiteSpace(arguments) + ? $"\"{resolvedPath}\"" + : $"\"{resolvedPath}\" {arguments}"; + return new CommandInvocation( + cmdPath, + $"/d /c \"{commandArguments}\""); + } + + return new CommandInvocation(resolvedPath, arguments); + } + + internal static string ResolveWindowsCommandPath(string command, string? pathEnvironment = null) + { + if (string.IsNullOrWhiteSpace(command)) + { + return string.Empty; + } + + if (Path.IsPathRooted(command) || command.Contains(Path.DirectorySeparatorChar) || command.Contains(Path.AltDirectorySeparatorChar)) + { + return command; + } + + if (Path.HasExtension(command)) + { + return FindCommandOnPath(command, pathEnvironment) ?? command; + } + + foreach (var extension in WindowsExecutableExtensions) + { + var candidate = FindCommandOnPath(command + extension, pathEnvironment); + if (!string.IsNullOrWhiteSpace(candidate)) + { + return candidate; + } + } + + return FindCommandOnPath(command, pathEnvironment) ?? command; + } + + private static string? FindCommandOnPath(string command, string? pathEnvironment) + { + var effectivePath = string.IsNullOrWhiteSpace(pathEnvironment) + ? Environment.GetEnvironmentVariable("PATH") + : pathEnvironment; + if (string.IsNullOrWhiteSpace(effectivePath)) + { + return null; + } + + foreach (var rawDirectory in effectivePath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (string.IsNullOrWhiteSpace(rawDirectory)) + { + continue; + } + + try + { + var candidate = Path.Combine(rawDirectory, command); + if (File.Exists(candidate)) + { + return candidate; + } + } + catch + { + } + } + + return null; + } + + private static Version? ParseVersion(string output) + { + if (string.IsNullOrWhiteSpace(output)) + { + return null; + } + + var match = VersionRegex.Match(output); + return match.Success && Version.TryParse(match.Groups["version"].Value, out var version) + ? version + : null; + } + + private static GoalCapabilityProbeResult ProbeFailed( + ResolvedGoalCapabilityContext context, + string message, + string? detectedVersion = null) + { + return new GoalCapabilityProbeResult + { + ToolId = context.ToolId, + ProviderId = context.ProviderId, + CacheKey = context.CacheKey, + State = GoalCapabilityState.Unknown, + Outcome = GoalCapabilityProbeOutcome.ProbeFailed, + Message = message, + DetectedVersion = detectedVersion + }; + } + + 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(); + } + + private static string NormalizeProviderId(string? providerId) + { + return string.IsNullOrWhiteSpace(providerId) + ? string.Empty + : providerId.Trim(); + } + + private static string? NormalizeWorkspacePath(string? workspacePath) + { + return string.IsNullOrWhiteSpace(workspacePath) + ? null + : workspacePath.Trim(); + } + + private sealed class ResolvedGoalCapabilityContext + { + public string ToolId { get; init; } = string.Empty; + + public string ProviderId { get; init; } = UnscopedProviderId; + + public string? WorkspacePath { get; init; } + + public string CacheKey { get; init; } = string.Empty; + } + + private sealed class GoalCapabilityCacheEntry + { + public string ToolId { get; init; } = string.Empty; + + public string ProviderId { get; init; } = UnscopedProviderId; + + public string CacheKey { get; init; } = string.Empty; + + public GoalCapabilityState State { get; init; } + + public GoalCapabilityProbeOutcome Outcome { get; init; } + + public string? Message { get; init; } + + public string? DetectedVersion { get; init; } + + public bool HasGoalsFeature { get; init; } + + public static GoalCapabilityCacheEntry From(GoalCapabilityProbeResult result) + { + return new GoalCapabilityCacheEntry + { + ToolId = result.ToolId, + ProviderId = result.ProviderId, + CacheKey = result.CacheKey, + State = result.State, + Outcome = result.Outcome, + Message = result.Message, + DetectedVersion = result.DetectedVersion, + HasGoalsFeature = result.HasGoalsFeature + }; + } + + public GoalCapabilitySnapshot ToSnapshot() + { + return new GoalCapabilitySnapshot + { + ToolId = ToolId, + ProviderId = ProviderId, + CacheKey = CacheKey, + State = State, + Message = Message, + DetectedVersion = DetectedVersion + }; + } + + public GoalCapabilityProbeResult ToProbeResult(bool fromCache) + { + return new GoalCapabilityProbeResult + { + ToolId = ToolId, + ProviderId = ProviderId, + CacheKey = CacheKey, + State = State, + Outcome = Outcome, + Message = Message, + DetectedVersion = DetectedVersion, + HasGoalsFeature = HasGoalsFeature, + FromCache = fromCache + }; + } + } + + protected readonly record struct CommandProbeResult(bool Success, string Output); + internal readonly record struct CommandInvocation(string FileName, string Arguments); +} + +internal sealed class NullGoalCapabilityService : IGoalCapabilityService +{ + public static NullGoalCapabilityService Instance { get; } = new(); + + public Task GetStateAsync( + GoalCapabilityContext context, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new GoalCapabilitySnapshot + { + ToolId = context.ToolId, + ProviderId = context.ProviderId ?? GoalCapabilityService.UnscopedProviderId, + CacheKey = $"{context.ToolId}::{context.ProviderId ?? GoalCapabilityService.UnscopedProviderId}", + State = GoalCapabilityState.Unknown + }); + } + + public Task ProbeAsync( + GoalCapabilityContext context, + bool forceRefresh = false, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new GoalCapabilityProbeResult + { + ToolId = context.ToolId, + ProviderId = context.ProviderId ?? GoalCapabilityService.UnscopedProviderId, + CacheKey = $"{context.ToolId}::{context.ProviderId ?? GoalCapabilityService.UnscopedProviderId}", + State = GoalCapabilityState.Unknown, + Outcome = GoalCapabilityProbeOutcome.ProbeFailed, + Message = GoalQuickActionDefaults.CapabilityProbeFailedText + }); + } +} diff --git a/WebCodeCli.Domain/Domain/Service/GoalPromptBuilder.cs b/WebCodeCli.Domain/Domain/Service/GoalPromptBuilder.cs new file mode 100644 index 0000000..9904b9b --- /dev/null +++ b/WebCodeCli.Domain/Domain/Service/GoalPromptBuilder.cs @@ -0,0 +1,19 @@ +using WebCodeCli.Domain.Domain.Model; + +namespace WebCodeCli.Domain.Domain.Service; + +public static class GoalPromptBuilder +{ + public static string? BuildGoalPrompt(string? input) + { + var trimmed = input?.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + { + return null; + } + + return trimmed.StartsWith("/goal", StringComparison.OrdinalIgnoreCase) + ? trimmed + : $"{GoalQuickActionDefaults.QuickGoalPrefix}{trimmed}"; + } +} diff --git a/WebCodeCli.Domain/Domain/Service/SuperpowersPromptBuilder.cs b/WebCodeCli.Domain/Domain/Service/SuperpowersPromptBuilder.cs index c9bf3bf..d6eeee2 100644 --- a/WebCodeCli.Domain/Domain/Service/SuperpowersPromptBuilder.cs +++ b/WebCodeCli.Domain/Domain/Service/SuperpowersPromptBuilder.cs @@ -4,6 +4,9 @@ namespace WebCodeCli.Domain.Domain.Service; public static class SuperpowersPromptBuilder { + public static string BuildContinuePrompt() + => SuperpowersQuickActionDefaults.ContinuePrompt; + public static string BuildExecutePlanPrompt() => SuperpowersQuickActionDefaults.ExecutePlanPrompt; diff --git a/WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs b/WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs index 8341bcb..2464b3a 100644 --- a/WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs +++ b/WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs @@ -82,6 +82,8 @@ public async Task SaveAsync(UserFeishuBotConfigEn existing.ThinkingMessage = config.ThinkingMessage; existing.HttpTimeoutSeconds = config.HttpTimeoutSeconds; existing.StreamingThrottleMs = config.StreamingThrottleMs; + existing.ReplyTtsEnabled = config.ReplyTtsEnabled; + existing.ReplyTtsVoiceId = config.ReplyTtsVoiceId; existing.UpdatedAt = now; return await _repository.UpdateAsync(existing) @@ -181,6 +183,7 @@ private static void NormalizeConfig(UserFeishuBotConfigEntity config) config.VerificationToken = NormalizeValue(config.VerificationToken); config.DefaultCardTitle = NormalizeValue(config.DefaultCardTitle); config.ThinkingMessage = NormalizeValue(config.ThinkingMessage); + config.ReplyTtsVoiceId = NormalizeValue(config.ReplyTtsVoiceId); } private static string? NormalizeValue(string? value) diff --git a/WebCodeCli.Domain/Properties/AssemblyInfo.cs b/WebCodeCli.Domain/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..460d6a2 --- /dev/null +++ b/WebCodeCli.Domain/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("WebCodeCli.Tests")] diff --git a/WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs b/WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs index 61b73a8..420a147 100644 --- a/WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs +++ b/WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs @@ -42,6 +42,12 @@ public class UserFeishuBotConfigEntity [SugarColumn(IsNullable = true)] public int? StreamingThrottleMs { get; set; } + [SugarColumn(IsNullable = false)] + public bool ReplyTtsEnabled { get; set; } + + [SugarColumn(Length = 128, IsNullable = true)] + public string? ReplyTtsVoiceId { get; set; } + [SugarColumn(IsNullable = true)] public DateTime? LastStartedAt { get; set; } diff --git a/WebCodeCli/Components/AdminUserManagementModal.razor b/WebCodeCli/Components/AdminUserManagementModal.razor index 8fa5cf3..dec73d8 100644 --- a/WebCodeCli/Components/AdminUserManagementModal.razor +++ b/WebCodeCli/Components/AdminUserManagementModal.razor @@ -209,6 +209,55 @@ @Tx("adminUserManagement.feishuEnabled", "允许此用户机器人被启动", "Allow this user's bot to be started") +
+
+
+
@Tx("adminUserManagement.replyTtsTitle", "回复语音播报", "Reply TTS")
+

@Tx("adminUserManagement.replyTtsHint", "开启后会为飞书回复生成语音;语音列表来自当前平台运行时。", "When enabled, Feishu replies can generate speech using the current runtime voice catalog.")

+
+ +
+ +
+ +
+ @if (_replyTtsHealth.IsAvailable) + { + @Tx("adminUserManagement.replyTtsVoiceCount", "可用语音数", "Available voices"): @ReplyTtsUiState.VoiceOptions.Count + @if (!string.IsNullOrWhiteSpace(_replyTtsHealth.DefaultVoiceId)) + { + @Tx("adminUserManagement.replyTtsDefaultVoice", "默认语音", "Default voice"): @_replyTtsHealth.DefaultVoiceId + } + } + else + { + @Tx("adminUserManagement.replyTtsUnavailable", "平台当前不可用", "Platform currently unavailable") + } +
+
+ @if (!string.IsNullOrWhiteSpace(ReplyTtsUiState.WarningMessage)) + { +
@ReplyTtsUiState.WarningMessage
+ } + else if (!string.IsNullOrWhiteSpace(_replyTtsHealth.Message)) + { +
@_replyTtsHealth.Message
+ } +
diff --git a/WebCodeCli/Components/AdminUserManagementModal.razor.cs b/WebCodeCli/Components/AdminUserManagementModal.razor.cs index c0a561f..4329ed4 100644 --- a/WebCodeCli/Components/AdminUserManagementModal.razor.cs +++ b/WebCodeCli/Components/AdminUserManagementModal.razor.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Microsoft.AspNetCore.Components; using WebCodeCli.Domain.Domain.Model; +using WebCodeCli.Domain.Domain.Model.Channels; using WebCodeCli.Domain.Domain.Service; using WebCodeCli.Helpers; @@ -22,6 +23,7 @@ public partial class AdminUserManagementModal : ComponentBase private bool _isSaving; private bool _isDeletingFeishuConfig; private bool _isRefreshingFeishuStatus; + private bool _isRefreshingReplyTtsPlatform; private bool _isStartingFeishuBot; private bool _isStoppingFeishuBot; private string _errorMessage = string.Empty; @@ -34,8 +36,16 @@ public partial class AdminUserManagementModal : ComponentBase private List _users = new(); private EditableUserModel _editor = EditableUserModel.CreateNew(); private UserFeishuBotRuntimeStatusModel _feishuBotStatus = new(); + private FeishuReplyTtsHealthStatus _replyTtsHealth = new(); + private IReadOnlyList _replyTtsVoices = []; - private bool IsBusy => _isLoadingUsers || _isLoadingDetail || _isSaving || _isDeletingFeishuConfig || _isRefreshingFeishuStatus || _isStartingFeishuBot || _isStoppingFeishuBot; + private bool IsBusy => _isLoadingUsers || _isLoadingDetail || _isSaving || _isDeletingFeishuConfig || _isRefreshingFeishuStatus || _isRefreshingReplyTtsPlatform || _isStartingFeishuBot || _isStoppingFeishuBot; + private AdminUserManagementReplyTtsUiStateResult ReplyTtsUiState => AdminUserManagementReplyTtsUiState.Create( + _editor.FeishuBot.ReplyTtsEnabled, + _editor.FeishuBot.ReplyTtsVoiceId, + _replyTtsVoices, + _replyTtsHealth.IsAvailable, + _replyTtsHealth.Message); private IEnumerable FilteredUsers => _users.Where(user => string.IsNullOrWhiteSpace(_userSearch) || @@ -52,7 +62,12 @@ public async Task ShowAsync() _successMessage = string.Empty; _userSearch = string.Empty; _allTools = CliExecutorService.GetAvailableTools(); - await LoadUsersAsync(); + await RefreshReplyTtsPlatformAsync(); + var usersLoaded = await LoadUsersAsync(); + if (!usersLoaded) + { + return; + } if (_users.Count == 0) { @@ -82,7 +97,12 @@ private async Task CloseAsync() private async Task RefreshAsync() { _allTools = CliExecutorService.GetAvailableTools(); - await LoadUsersAsync(); + await RefreshReplyTtsPlatformAsync(); + var usersLoaded = await LoadUsersAsync(); + if (!usersLoaded) + { + return; + } if (!string.IsNullOrWhiteSpace(_selectedUsername) && _users.Any(x => string.Equals(x.Username, _selectedUsername, StringComparison.OrdinalIgnoreCase))) { @@ -98,7 +118,7 @@ private async Task RefreshAsync() } } - private async Task LoadUsersAsync() + private async Task LoadUsersAsync() { _isLoadingUsers = true; _errorMessage = string.Empty; @@ -111,10 +131,10 @@ private async Task LoadUsersAsync() .ThenByDescending(static x => IsEnabled(x.Status)) .ThenBy(static x => x.Username, StringComparer.OrdinalIgnoreCase) .ToList() ?? new List(); + return true; } catch (Exception ex) { - _users = new List(); _errorMessage = Tx("adminUserManagement.loadUsersFailed", "加载用户列表失败", "Failed to load users") + $": {ex.Message}"; } finally @@ -122,6 +142,8 @@ private async Task LoadUsersAsync() _isLoadingUsers = false; StateHasChanged(); } + + return false; } private void PrepareNewUser() @@ -150,43 +172,73 @@ private async Task SelectUserAsync(string? username) } _selectedUsername = selectedUser.Username; - _editor = EditableUserModel.FromSummary(selectedUser); _errorMessage = string.Empty; _successMessage = string.Empty; _isLoadingDetail = true; StateHasChanged(); + var nextEditor = CreateDetailEditorSeed(selectedUser, _editor); + var nextFeishuStatus = CreateFeishuStatusSeed(selectedUser.Username, _feishuBotStatus); + var detailErrors = new List(); + try { - var toolMapTask = Http.GetFromJsonAsync>($"/api/admin/users/{Uri.EscapeDataString(selectedUser.Username)}/tools"); - var directoriesTask = Http.GetFromJsonAsync>($"/api/admin/users/{Uri.EscapeDataString(selectedUser.Username)}/workspace-policies"); - var feishuTask = Http.GetFromJsonAsync($"/api/admin/users/{Uri.EscapeDataString(selectedUser.Username)}/feishu-bot"); - var feishuStatusTask = Http.GetFromJsonAsync($"/api/admin/users/{Uri.EscapeDataString(selectedUser.Username)}/feishu-bot/status"); - - _editor.AllowedToolIds = AdminUserManagementFormHelper.GetAllowedToolIds(await toolMapTask ?? new Dictionary()); - _editor.AllowedDirectoriesText = AdminUserManagementFormHelper.FormatAllowedDirectories(await directoriesTask ?? new List()); - - var feishuConfig = await feishuTask ?? new UserFeishuBotConfigModel(); - _editor.FeishuBot = EditableFeishuBotConfigModel.From(feishuConfig); - _editor.HasStoredFeishuConfig = AdminUserManagementFormHelper.HasCustomFeishuConfig( - _editor.FeishuBot.IsEnabled, - _editor.FeishuBot.AppId, - _editor.FeishuBot.AppSecret, - _editor.FeishuBot.EncryptKey, - _editor.FeishuBot.VerificationToken, - _editor.FeishuBot.DefaultCardTitle, - _editor.FeishuBot.ThinkingMessage, - _editor.FeishuBot.HttpTimeoutSeconds, - _editor.FeishuBot.StreamingThrottleMs); - _feishuBotStatus = await feishuStatusTask ?? new UserFeishuBotRuntimeStatusModel(); + try + { + var toolMap = await Http.GetFromJsonAsync>($"/api/admin/users/{Uri.EscapeDataString(selectedUser.Username)}/tools"); + nextEditor.AllowedToolIds = AdminUserManagementFormHelper.GetAllowedToolIds(toolMap ?? new Dictionary()); + } + catch (Exception ex) + { + detailErrors.Add($"tools: {ex.Message}"); + } + + try + { + var directories = await Http.GetFromJsonAsync>($"/api/admin/users/{Uri.EscapeDataString(selectedUser.Username)}/workspace-policies"); + nextEditor.AllowedDirectoriesText = AdminUserManagementFormHelper.FormatAllowedDirectories(directories ?? new List()); + } + catch (Exception ex) + { + detailErrors.Add($"workspace policies: {ex.Message}"); + } + + try + { + var feishuConfig = await Http.GetFromJsonAsync($"/api/admin/users/{Uri.EscapeDataString(selectedUser.Username)}/feishu-bot") + ?? new UserFeishuBotConfigModel(); + nextEditor.FeishuBot = EditableFeishuBotConfigModel.From(feishuConfig); + nextEditor.HasStoredFeishuConfig = HasCustomFeishuConfig(nextEditor.FeishuBot); + } + catch (Exception ex) + { + detailErrors.Add($"feishu config: {ex.Message}"); + } + + try + { + nextFeishuStatus = await Http.GetFromJsonAsync($"/api/admin/users/{Uri.EscapeDataString(selectedUser.Username)}/feishu-bot/status") + ?? new UserFeishuBotRuntimeStatusModel(); + } + catch (Exception ex) + { + detailErrors.Add($"feishu status: {ex.Message}"); + } + + _editor = nextEditor; + _feishuBotStatus = nextFeishuStatus; } catch (Exception ex) { _errorMessage = Tx("adminUserManagement.loadUserDetailFailed", "加载用户详情失败", "Failed to load user details") + $": {ex.Message}"; - _feishuBotStatus = new UserFeishuBotRuntimeStatusModel(); } finally { + if (detailErrors.Count > 0) + { + _errorMessage = Tx("adminUserManagement.loadUserDetailFailed", "鍔犺浇鐢ㄦ埛璇︽儏澶辫触", "Failed to load user details") + $": {string.Join("; ", detailErrors)}"; + } + _isLoadingDetail = false; StateHasChanged(); } @@ -233,8 +285,15 @@ private async Task SaveAsync() { await SaveUserCoreAsync(username); _selectedUsername = username; - await LoadUsersAsync(); - await SelectUserAsync(username); + var usersLoaded = await LoadUsersAsync(); + if (usersLoaded && _users.Any(x => string.Equals(x.Username, username, StringComparison.OrdinalIgnoreCase))) + { + await SelectUserAsync(username); + } + else + { + _editor.IsExistingUser = true; + } _editor.Password = string.Empty; _successMessage = Tx("adminUserManagement.saveSuccess", "已保存用户配置。飞书机器人需要手动启动。", "User settings saved. Start the Feishu bot manually when ready."); await OnSaved.InvokeAsync(); @@ -311,16 +370,7 @@ private async Task SaveUserCoreAsync(string username) }); await EnsureSuccessAsync(saveDirectoriesResponse); - var hasCustomFeishuConfig = AdminUserManagementFormHelper.HasCustomFeishuConfig( - _editor.FeishuBot.IsEnabled, - _editor.FeishuBot.AppId, - _editor.FeishuBot.AppSecret, - _editor.FeishuBot.EncryptKey, - _editor.FeishuBot.VerificationToken, - _editor.FeishuBot.DefaultCardTitle, - _editor.FeishuBot.ThinkingMessage, - _editor.FeishuBot.HttpTimeoutSeconds, - _editor.FeishuBot.StreamingThrottleMs); + var hasCustomFeishuConfig = HasCustomFeishuConfig(_editor.FeishuBot); if (hasCustomFeishuConfig) { @@ -363,6 +413,56 @@ private async Task RefreshFeishuBotStatusAsync() } } + private async Task RefreshReplyTtsPlatformAsync() + { + _isRefreshingReplyTtsPlatform = true; + StateHasChanged(); + + FeishuReplyTtsHealthStatus? refreshedHealth = null; + List? refreshedVoices = null; + string? healthError = null; + string? voicesError = null; + + try + { + try + { + refreshedHealth = await Http.GetFromJsonAsync("/api/admin/feishu-tts/health") + ?? new FeishuReplyTtsHealthStatus(); + } + catch (Exception ex) + { + healthError = ex.Message; + } + + try + { + refreshedVoices = await Http.GetFromJsonAsync>("/api/admin/feishu-tts/voices") + ?? []; + } + catch (Exception ex) + { + voicesError = ex.Message; + } + + var mergedState = MergeReplyTtsPlatformState( + _replyTtsHealth, + _replyTtsVoices, + refreshedHealth, + refreshedVoices, + healthError, + voicesError); + + _replyTtsHealth = mergedState.Health; + _replyTtsVoices = mergedState.Voices; + } + finally + { + _isRefreshingReplyTtsPlatform = false; + StateHasChanged(); + } + } + private async Task StartFeishuBotAsync() { if (!_editor.IsExistingUser || string.IsNullOrWhiteSpace(_editor.Username)) @@ -523,14 +623,109 @@ private static Dictionary FlattenTranslations(Dictionary "inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-[11px] font-medium text-gray-600" }; private static string GetMetaValue(DateTime? value) => value.HasValue ? value.Value.ToString("yyyy-MM-dd HH:mm") : "-"; + private static EditableUserModel CreateDetailEditorSeed(UserSummaryDto selectedUser, EditableUserModel currentEditor) + { + if (!string.Equals(currentEditor.Username, selectedUser.Username, StringComparison.OrdinalIgnoreCase)) + { + return EditableUserModel.FromSummary(selectedUser); + } + + return new EditableUserModel + { + Username = selectedUser.Username, + DisplayName = selectedUser.DisplayName ?? string.Empty, + Role = selectedUser.Role, + Enabled = IsEnabled(selectedUser.Status), + IsExistingUser = true, + HasStoredFeishuConfig = currentEditor.HasStoredFeishuConfig, + LastLoginAt = selectedUser.LastLoginAt, + CreatedAt = selectedUser.CreatedAt, + AllowedToolIds = new HashSet(currentEditor.AllowedToolIds, StringComparer.OrdinalIgnoreCase), + AllowedDirectoriesText = currentEditor.AllowedDirectoriesText, + FeishuBot = currentEditor.FeishuBot.Clone() + }; + } + + private static UserFeishuBotRuntimeStatusModel CreateFeishuStatusSeed(string username, UserFeishuBotRuntimeStatusModel currentStatus) + { + if (string.Equals(currentStatus.Username, username, StringComparison.OrdinalIgnoreCase)) + { + return currentStatus.Clone(); + } + + return new UserFeishuBotRuntimeStatusModel + { + Username = username + }; + } + + private static (FeishuReplyTtsHealthStatus Health, IReadOnlyList Voices) MergeReplyTtsPlatformState( + FeishuReplyTtsHealthStatus currentHealth, + IReadOnlyList currentVoices, + FeishuReplyTtsHealthStatus? refreshedHealth, + IReadOnlyList? refreshedVoices, + string? healthError, + string? voicesError) + { + var nextHealth = refreshedHealth != null + ? CloneReplyTtsHealth(refreshedHealth) + : !string.IsNullOrWhiteSpace(healthError) + ? new FeishuReplyTtsHealthStatus + { + IsAvailable = false, + Message = healthError.Trim() + } + : CloneReplyTtsHealth(currentHealth); + + var nextVoices = refreshedVoices ?? currentVoices; + + if (!string.IsNullOrWhiteSpace(voicesError)) + { + var voiceRefreshMessage = $"Voice list may be stale because refresh failed: {voicesError.Trim()}"; + nextHealth = CloneReplyTtsHealth(nextHealth); + nextHealth.Message = string.IsNullOrWhiteSpace(nextHealth.Message) + ? voiceRefreshMessage + : $"{nextHealth.Message} {voiceRefreshMessage}"; + } + + return (nextHealth, nextVoices); + } + + private static FeishuReplyTtsHealthStatus CloneReplyTtsHealth(FeishuReplyTtsHealthStatus health) + { + return new FeishuReplyTtsHealthStatus + { + IsAvailable = health.IsAvailable, + Message = health.Message, + DefaultVoiceId = health.DefaultVoiceId, + StorageRoot = health.StorageRoot, + TempRoot = health.TempRoot + }; + } + + private static bool HasCustomFeishuConfig(EditableFeishuBotConfigModel config) + { + return AdminUserManagementFormHelper.HasCustomFeishuConfig( + config.IsEnabled, + config.AppId, + config.AppSecret, + config.EncryptKey, + config.VerificationToken, + config.DefaultCardTitle, + config.ThinkingMessage, + config.HttpTimeoutSeconds, + config.StreamingThrottleMs) + || config.ReplyTtsEnabled + || !string.IsNullOrWhiteSpace(config.ReplyTtsVoiceId); + } private sealed class UserSummaryDto { public string Username { get; set; } = string.Empty; public string? DisplayName { get; set; } public string Role { get; set; } = UserAccessConstants.UserRole; public string Status { get; set; } = UserAccessConstants.EnabledStatus; public DateTime? LastLoginAt { get; set; } public DateTime CreatedAt { get; set; } } private sealed class SaveUserPayload { public string Username { get; set; } = string.Empty; public string? DisplayName { get; set; } public string? Password { get; set; } public string Role { get; set; } = UserAccessConstants.UserRole; public string Status { get; set; } = UserAccessConstants.EnabledStatus; } private sealed class SaveToolPolicyPayload { public List AllowedToolIds { get; set; } = new(); } private sealed class SaveWorkspacePolicyPayload { public List AllowedDirectories { get; set; } = new(); } - private sealed class UserFeishuBotConfigModel { public string? Username { get; set; } public bool IsEnabled { get; set; } public string? AppId { get; set; } public string? AppSecret { get; set; } public string? EncryptKey { get; set; } public string? VerificationToken { get; set; } public string? DefaultCardTitle { get; set; } public string? ThinkingMessage { get; set; } public int? HttpTimeoutSeconds { get; set; } public int? StreamingThrottleMs { get; set; } } - private sealed class UserFeishuBotRuntimeStatusModel { public string Username { get; set; } = string.Empty; public string? AppId { get; set; } public string State { get; set; } = nameof(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; } public DateTime UpdatedAt { get; set; } } - private sealed class EditableFeishuBotConfigModel { public bool IsEnabled { get; set; } public string? AppId { get; set; } public string? AppSecret { get; set; } public string? EncryptKey { get; set; } public string? VerificationToken { get; set; } public string? DefaultCardTitle { get; set; } public string? ThinkingMessage { get; set; } public int? HttpTimeoutSeconds { get; set; } public int? StreamingThrottleMs { get; set; } public static EditableFeishuBotConfigModel From(UserFeishuBotConfigModel model) => new() { IsEnabled = model.IsEnabled, AppId = model.AppId, AppSecret = model.AppSecret, EncryptKey = model.EncryptKey, VerificationToken = model.VerificationToken, DefaultCardTitle = model.DefaultCardTitle, ThinkingMessage = model.ThinkingMessage, HttpTimeoutSeconds = model.HttpTimeoutSeconds, StreamingThrottleMs = model.StreamingThrottleMs }; public UserFeishuBotConfigModel ToPayload(string username) => new() { Username = username, IsEnabled = IsEnabled, AppId = TrimToNull(AppId), AppSecret = TrimToNull(AppSecret), EncryptKey = TrimToNull(EncryptKey), VerificationToken = TrimToNull(VerificationToken), DefaultCardTitle = TrimToNull(DefaultCardTitle), ThinkingMessage = TrimToNull(ThinkingMessage), HttpTimeoutSeconds = HttpTimeoutSeconds, StreamingThrottleMs = StreamingThrottleMs }; } + private sealed class UserFeishuBotConfigModel { public string? Username { get; set; } public bool IsEnabled { get; set; } public string? AppId { get; set; } public string? AppSecret { get; set; } public string? EncryptKey { get; set; } public string? VerificationToken { get; set; } public string? DefaultCardTitle { get; set; } public string? ThinkingMessage { get; set; } public int? HttpTimeoutSeconds { get; set; } public int? StreamingThrottleMs { get; set; } public bool ReplyTtsEnabled { get; set; } public string? ReplyTtsVoiceId { get; set; } } + private sealed class UserFeishuBotRuntimeStatusModel { public string Username { get; set; } = string.Empty; public string? AppId { get; set; } public string State { get; set; } = nameof(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; } public DateTime UpdatedAt { get; set; } public UserFeishuBotRuntimeStatusModel Clone() => new() { Username = Username, AppId = AppId, State = State, IsConfigured = IsConfigured, CanStart = CanStart, ShouldAutoStart = ShouldAutoStart, Message = Message, LastError = LastError, LastStartedAt = LastStartedAt, UpdatedAt = UpdatedAt }; } + private sealed class EditableFeishuBotConfigModel { public bool IsEnabled { get; set; } public string? AppId { get; set; } public string? AppSecret { get; set; } public string? EncryptKey { get; set; } public string? VerificationToken { get; set; } public string? DefaultCardTitle { get; set; } public string? ThinkingMessage { get; set; } public int? HttpTimeoutSeconds { get; set; } public int? StreamingThrottleMs { get; set; } public bool ReplyTtsEnabled { get; set; } public string? ReplyTtsVoiceId { get; set; } public EditableFeishuBotConfigModel Clone() => new() { IsEnabled = IsEnabled, AppId = AppId, AppSecret = AppSecret, EncryptKey = EncryptKey, VerificationToken = VerificationToken, DefaultCardTitle = DefaultCardTitle, ThinkingMessage = ThinkingMessage, HttpTimeoutSeconds = HttpTimeoutSeconds, StreamingThrottleMs = StreamingThrottleMs, ReplyTtsEnabled = ReplyTtsEnabled, ReplyTtsVoiceId = ReplyTtsVoiceId }; public static EditableFeishuBotConfigModel From(UserFeishuBotConfigModel model) => new() { IsEnabled = model.IsEnabled, AppId = model.AppId, AppSecret = model.AppSecret, EncryptKey = model.EncryptKey, VerificationToken = model.VerificationToken, DefaultCardTitle = model.DefaultCardTitle, ThinkingMessage = model.ThinkingMessage, HttpTimeoutSeconds = model.HttpTimeoutSeconds, StreamingThrottleMs = model.StreamingThrottleMs, ReplyTtsEnabled = model.ReplyTtsEnabled, ReplyTtsVoiceId = model.ReplyTtsVoiceId }; public UserFeishuBotConfigModel ToPayload(string username) => new() { Username = username, IsEnabled = IsEnabled, AppId = TrimToNull(AppId), AppSecret = TrimToNull(AppSecret), EncryptKey = TrimToNull(EncryptKey), VerificationToken = TrimToNull(VerificationToken), DefaultCardTitle = TrimToNull(DefaultCardTitle), ThinkingMessage = TrimToNull(ThinkingMessage), HttpTimeoutSeconds = HttpTimeoutSeconds, StreamingThrottleMs = StreamingThrottleMs, ReplyTtsEnabled = ReplyTtsEnabled, ReplyTtsVoiceId = TrimToNull(ReplyTtsVoiceId) }; } private sealed class EditableUserModel { public string Username { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty; public string Password { get; set; } = string.Empty; public string Role { get; set; } = UserAccessConstants.UserRole; public bool Enabled { get; set; } = true; public bool IsExistingUser { get; set; } public bool HasStoredFeishuConfig { get; set; } public DateTime? LastLoginAt { get; set; } public DateTime? CreatedAt { get; set; } public HashSet AllowedToolIds { get; set; } = new(StringComparer.OrdinalIgnoreCase); public string AllowedDirectoriesText { get; set; } = string.Empty; public EditableFeishuBotConfigModel FeishuBot { get; set; } = new(); public static EditableUserModel CreateNew() => new() { AllowedToolIds = new HashSet(StringComparer.OrdinalIgnoreCase) }; public static EditableUserModel FromSummary(UserSummaryDto user) => new() { Username = user.Username, DisplayName = user.DisplayName ?? string.Empty, Role = user.Role, Enabled = IsEnabled(user.Status), IsExistingUser = true, LastLoginAt = user.LastLoginAt, CreatedAt = user.CreatedAt, AllowedToolIds = new HashSet(StringComparer.OrdinalIgnoreCase) }; } private static string? TrimToNull(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } diff --git a/WebCodeCli/Components/ChatMessageListPanel.razor b/WebCodeCli/Components/ChatMessageListPanel.razor index 8860fcc..33ba045 100644 --- a/WebCodeCli/Components/ChatMessageListPanel.razor +++ b/WebCodeCli/Components/ChatMessageListPanel.razor @@ -98,7 +98,15 @@ placeholder="@SuperpowersQuickInputPlaceholder" /> @if (ShowSuperpowersPlanActions) { -
+
+
} + @if (ShouldShowGoalQuickActions(message)) + { +
+

@GoalInstructionText

+
+ + @if (!string.IsNullOrWhiteSpace(GoalQuickActionStatusMessage) || ShowGoalRetryAction) + { +
+ @if (!string.IsNullOrWhiteSpace(GoalQuickActionStatusMessage)) + { + @GoalQuickActionStatusMessage + } + @if (ShowGoalRetryAction) + { + + } +
+ } +
+
+ }
} @if (IsLoading) {
-
+
@@ -158,35 +199,60 @@
-
@RenderMarkdown(CurrentAssistantMessage)
+
+
@RenderMarkdown(CurrentAssistantMessage)
+
@if (ShouldShowStreamingSuperpowersQuickActions()) { -
-

@SuperpowersInstructionText

-
- - @if (ShowSuperpowersPlanActions) - { -
- - -
- } +
+
+

@SuperpowersInstructionText

+
+ + @if (ShowSuperpowersPlanActions) + { +
+ + + +
+ } +
+
+
+ } + @if (ShouldShowStreamingGoalQuickActions()) + { +
+
+

@GoalInstructionText

+
+ +
} @@ -315,6 +381,8 @@ [Parameter] public string SuperpowersQuickInput { get; set; } = string.Empty; [Parameter] public EventCallback SuperpowersQuickInputChanged { get; set; } [Parameter] public string SuperpowersQuickInputPlaceholder { get; set; } = SuperpowersQuickActionDefaults.QuickInputPlaceholder; + [Parameter] public string ContinueActionText { get; set; } = SuperpowersQuickActionDefaults.ContinueButtonText; + [Parameter] public bool ContinueSuperpowersActionDisabled { get; set; } [Parameter] public string ExecutePlanText { get; set; } = SuperpowersQuickActionDefaults.ExecutePlanButtonText; [Parameter] public string ExecuteSubagentPlanText { get; set; } = SuperpowersQuickActionDefaults.ExecuteSubagentPlanButtonText; [Parameter] public string? SuperpowersQuickActionStatusMessage { get; set; } @@ -322,9 +390,23 @@ [Parameter] public bool SuperpowersRetryActionDisabled { get; set; } [Parameter] public string SuperpowersRetryActionText { get; set; } = SuperpowersQuickActionDefaults.CapabilityRetryButtonText; [Parameter] public EventCallback OnSubmitSuperpowersQuickInput { get; set; } + [Parameter] public EventCallback OnContinueSuperpowersAction { get; set; } [Parameter] public EventCallback OnExecuteSuperpowersPlan { get; set; } [Parameter] public EventCallback OnExecuteSuperpowersSubagentPlan { get; set; } [Parameter] public EventCallback OnRetrySuperpowersCapability { get; set; } + [Parameter] public string? GoalQuickActionMessageId { get; set; } + [Parameter] public bool EnableGoalQuickActions { get; set; } + [Parameter] public bool GoalQuickActionDisabled { get; set; } + [Parameter] public string GoalInstructionText { get; set; } = GoalQuickActionDefaults.InstructionText; + [Parameter] public string GoalQuickInput { get; set; } = string.Empty; + [Parameter] public EventCallback GoalQuickInputChanged { get; set; } + [Parameter] public string GoalQuickInputPlaceholder { get; set; } = GoalQuickActionDefaults.QuickInputPlaceholder; + [Parameter] public string? GoalQuickActionStatusMessage { get; set; } + [Parameter] public bool ShowGoalRetryAction { get; set; } + [Parameter] public bool GoalRetryActionDisabled { get; set; } + [Parameter] public string GoalRetryActionText { get; set; } = GoalQuickActionDefaults.CapabilityRetryButtonText; + [Parameter] public EventCallback OnSubmitGoalQuickInput { get; set; } + [Parameter] public EventCallback OnRetryGoalCapability { get; set; } private string? _copiedMessageId = null; private ChatMessage? _fullscreenMessage = null; @@ -334,7 +416,9 @@ { if (!OnSubmitSuperpowersQuickInput.HasDelegate || (ShowSuperpowersPlanActions - && (!OnExecuteSuperpowersPlan.HasDelegate || !OnExecuteSuperpowersSubagentPlan.HasDelegate))) + && (!OnContinueSuperpowersAction.HasDelegate + || !OnExecuteSuperpowersPlan.HasDelegate + || !OnExecuteSuperpowersSubagentPlan.HasDelegate))) { return false; } @@ -356,7 +440,30 @@ } return !ShowSuperpowersPlanActions - || (OnExecuteSuperpowersPlan.HasDelegate && OnExecuteSuperpowersSubagentPlan.HasDelegate); + || (OnContinueSuperpowersAction.HasDelegate + && OnExecuteSuperpowersPlan.HasDelegate + && OnExecuteSuperpowersSubagentPlan.HasDelegate); + } + + private bool ShouldShowGoalQuickActions(ChatMessage message) + { + if (!EnableGoalQuickActions || !OnSubmitGoalQuickInput.HasDelegate) + { + return false; + } + + if (!string.Equals(message.Role, "assistant", StringComparison.OrdinalIgnoreCase) || message.HasError) + { + return false; + } + + return !string.IsNullOrWhiteSpace(GoalQuickActionMessageId) + && string.Equals(message.Id, GoalQuickActionMessageId, StringComparison.Ordinal); + } + + private bool ShouldShowStreamingGoalQuickActions() + { + return EnableGoalQuickActions && IsLoading && OnSubmitGoalQuickInput.HasDelegate; } private async Task HandleSuperpowersQuickInputKeyDown(ChatMessage message, KeyboardEventArgs args) @@ -379,6 +486,26 @@ } } + private async Task HandleGoalQuickInputKeyDown(ChatMessage message, KeyboardEventArgs args) + { + if (!string.Equals(args.Key, "Enter", StringComparison.Ordinal)) + { + return; + } + + await OnSubmitGoalQuickInput.InvokeAsync(message); + } + + private async Task HandleGoalQuickInputChangedAsync(string? value) + { + GoalQuickInput = value ?? string.Empty; + + if (GoalQuickInputChanged.HasDelegate) + { + await GoalQuickInputChanged.InvokeAsync(GoalQuickInput); + } + } + private async Task CopyMessageContent(ChatMessage message) { var content = message.Content ?? string.Empty; diff --git a/WebCodeCli/Controllers/AdminController.cs b/WebCodeCli/Controllers/AdminController.cs index 06d1e1a..a502daf 100644 --- a/WebCodeCli/Controllers/AdminController.cs +++ b/WebCodeCli/Controllers/AdminController.cs @@ -1,7 +1,9 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using WebCodeCli.Domain.Domain.Model; +using WebCodeCli.Domain.Domain.Model.Channels; using WebCodeCli.Domain.Domain.Service; +using WebCodeCli.Domain.Domain.Service.Channels; using WebCodeCli.Domain.Repositories.Base.UserAccount; using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; @@ -18,6 +20,7 @@ public class AdminController : ControllerBase private readonly IUserFeishuBotConfigService _userFeishuBotConfigService; private readonly IUserFeishuBotRuntimeService _userFeishuBotRuntimeService; private readonly ICliExecutorService _cliExecutorService; + private readonly IFeishuReplyTtsPlatformService _feishuReplyTtsPlatformService; public AdminController( IUserAccountService userAccountService, @@ -25,7 +28,8 @@ public AdminController( IUserWorkspacePolicyService userWorkspacePolicyService, IUserFeishuBotConfigService userFeishuBotConfigService, IUserFeishuBotRuntimeService userFeishuBotRuntimeService, - ICliExecutorService cliExecutorService) + ICliExecutorService cliExecutorService, + IFeishuReplyTtsPlatformService feishuReplyTtsPlatformService) { _userAccountService = userAccountService; _userToolPolicyService = userToolPolicyService; @@ -33,6 +37,7 @@ public AdminController( _userFeishuBotConfigService = userFeishuBotConfigService; _userFeishuBotRuntimeService = userFeishuBotRuntimeService; _cliExecutorService = cliExecutorService; + _feishuReplyTtsPlatformService = feishuReplyTtsPlatformService; } [HttpGet("users")] @@ -157,7 +162,9 @@ public async Task SaveFeishuBotConfig(string username, [FromBody] DefaultCardTitle = request.DefaultCardTitle, ThinkingMessage = request.ThinkingMessage, HttpTimeoutSeconds = request.HttpTimeoutSeconds, - StreamingThrottleMs = request.StreamingThrottleMs + StreamingThrottleMs = request.StreamingThrottleMs, + ReplyTtsEnabled = request.ReplyTtsEnabled, + ReplyTtsVoiceId = request.ReplyTtsVoiceId }); if (!result.Success) @@ -170,8 +177,15 @@ public async Task SaveFeishuBotConfig(string username, [FromBody] return StatusCode(500, new { error = result.ErrorMessage ?? "保存飞书机器人配置失败。" }); } + FeishuReplyTtsHealthStatus? ttsHealth = null; + if (request.ReplyTtsEnabled) + { + ttsHealth = await _feishuReplyTtsPlatformService.EnsureServiceStartedAsync( + HttpContext?.RequestAborted ?? CancellationToken.None); + } + var status = await _userFeishuBotRuntimeService.StopAsync(username); - return Ok(new { success = true, status = MapFeishuRuntimeStatus(status) }); + return Ok(new { success = true, status = MapFeishuRuntimeStatus(status), ttsHealth }); } [HttpDelete("users/{username}/feishu-bot")] @@ -220,6 +234,20 @@ public async Task> StopFeishuBot(str return Ok(MapFeishuRuntimeStatus(status)); } + [HttpGet("feishu-tts/health")] + public async Task> GetFeishuTtsHealth() + { + var health = await _feishuReplyTtsPlatformService.GetHealthAsync(HttpContext?.RequestAborted ?? CancellationToken.None); + return Ok(health); + } + + [HttpGet("feishu-tts/voices")] + public async Task>> GetFeishuTtsVoices() + { + var voices = await _feishuReplyTtsPlatformService.GetVoicesAsync(HttpContext?.RequestAborted ?? CancellationToken.None); + return Ok(voices.ToList()); + } + private static UserAccountResponseDto MapUser(UserAccountEntity account) { return new UserAccountResponseDto @@ -247,7 +275,9 @@ private static UserFeishuBotConfigDto MapFeishuConfig(UserFeishuBotConfigEntity DefaultCardTitle = config.DefaultCardTitle, ThinkingMessage = config.ThinkingMessage, HttpTimeoutSeconds = config.HttpTimeoutSeconds, - StreamingThrottleMs = config.StreamingThrottleMs + StreamingThrottleMs = config.StreamingThrottleMs, + ReplyTtsEnabled = config.ReplyTtsEnabled, + ReplyTtsVoiceId = config.ReplyTtsVoiceId }; } @@ -316,6 +346,8 @@ public sealed class UserFeishuBotConfigDto public string? ThinkingMessage { get; set; } public int? HttpTimeoutSeconds { get; set; } public int? StreamingThrottleMs { get; set; } + public bool ReplyTtsEnabled { get; set; } + public string? ReplyTtsVoiceId { get; set; } } public sealed class UserFeishuBotRuntimeStatusDto diff --git a/WebCodeCli/Helpers/AdminUserManagementReplyTtsUiState.cs b/WebCodeCli/Helpers/AdminUserManagementReplyTtsUiState.cs new file mode 100644 index 0000000..fb862e7 --- /dev/null +++ b/WebCodeCli/Helpers/AdminUserManagementReplyTtsUiState.cs @@ -0,0 +1,134 @@ +using WebCodeCli.Domain.Domain.Model.Channels; + +namespace WebCodeCli.Helpers; + +public static class AdminUserManagementReplyTtsUiState +{ + public static AdminUserManagementReplyTtsUiStateResult Create( + bool replyTtsEnabled, + string? savedVoiceId, + IReadOnlyList? availableVoices, + bool platformIsAvailable, + string? platformMessage) + { + var normalizedSavedVoiceId = Normalize(savedVoiceId); + var voiceOptions = BuildVoiceOptions(availableVoices, normalizedSavedVoiceId, out var savedVoiceExistsInRuntimeList); + + return new AdminUserManagementReplyTtsUiStateResult + { + IsVoiceSelectorDisabled = !replyTtsEnabled || !platformIsAvailable, + WarningMessage = BuildWarningMessage( + replyTtsEnabled, + normalizedSavedVoiceId, + platformIsAvailable, + platformMessage, + voiceOptions.Count, + savedVoiceExistsInRuntimeList), + VoiceOptions = voiceOptions + }; + } + + private static string? BuildWarningMessage( + bool replyTtsEnabled, + string? normalizedSavedVoiceId, + bool platformIsAvailable, + string? platformMessage, + int voiceCount, + bool savedVoiceExistsInRuntimeList) + { + if (!platformIsAvailable) + { + return string.IsNullOrWhiteSpace(platformMessage) + ? "Feishu reply TTS is currently unavailable." + : platformMessage.Trim(); + } + + if (!replyTtsEnabled) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(normalizedSavedVoiceId) && !savedVoiceExistsInRuntimeList) + { + return $"Saved Feishu reply TTS voice '{normalizedSavedVoiceId}' is unavailable. Select a different voice before saving."; + } + + if (voiceCount == 0) + { + return "No Feishu reply TTS voices are available right now. Refresh to try again."; + } + + return null; + } + + private static List BuildVoiceOptions( + IReadOnlyList? availableVoices, + string? normalizedSavedVoiceId, + out bool savedVoiceExistsInRuntimeList) + { + var voiceOptions = new List(); + var seenVoiceIds = new HashSet(StringComparer.OrdinalIgnoreCase); + savedVoiceExistsInRuntimeList = false; + + if (availableVoices != null) + { + foreach (var voice in availableVoices) + { + var normalizedVoiceId = Normalize(voice?.VoiceId); + if (string.IsNullOrWhiteSpace(normalizedVoiceId) || !seenVoiceIds.Add(normalizedVoiceId)) + { + continue; + } + + if (string.Equals(normalizedVoiceId, normalizedSavedVoiceId, StringComparison.OrdinalIgnoreCase)) + { + savedVoiceExistsInRuntimeList = true; + } + + var displayName = voice?.DisplayName; + + voiceOptions.Add(new AdminUserManagementReplyTtsVoiceOption + { + VoiceId = normalizedVoiceId, + DisplayName = string.IsNullOrWhiteSpace(displayName) + ? normalizedVoiceId + : displayName.Trim() + }); + } + } + + if (!string.IsNullOrWhiteSpace(normalizedSavedVoiceId) && !savedVoiceExistsInRuntimeList) + { + voiceOptions.Insert(0, new AdminUserManagementReplyTtsVoiceOption + { + VoiceId = normalizedSavedVoiceId, + DisplayName = $"{normalizedSavedVoiceId} (saved)" + }); + } + + return voiceOptions; + } + + private static string? Normalize(string? value) + { + return string.IsNullOrWhiteSpace(value) + ? null + : value.Trim(); + } +} + +public sealed class AdminUserManagementReplyTtsUiStateResult +{ + public bool IsVoiceSelectorDisabled { get; set; } + + public string? WarningMessage { get; set; } + + public IReadOnlyList VoiceOptions { get; set; } = []; +} + +public sealed class AdminUserManagementReplyTtsVoiceOption +{ + public string VoiceId { get; set; } = string.Empty; + + public string DisplayName { get; set; } = string.Empty; +} diff --git a/WebCodeCli/Helpers/SqliteConnectionStringResolver.cs b/WebCodeCli/Helpers/SqliteConnectionStringResolver.cs new file mode 100644 index 0000000..55b562e --- /dev/null +++ b/WebCodeCli/Helpers/SqliteConnectionStringResolver.cs @@ -0,0 +1,43 @@ +using System.Data.SQLite; + +namespace WebCodeCli.Helpers; + +public static class SqliteConnectionStringResolver +{ + public static string Resolve(string connectionString, string applicationBasePath) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + return connectionString; + } + + ArgumentException.ThrowIfNullOrWhiteSpace(applicationBasePath); + + var builder = new SQLiteConnectionStringBuilder(connectionString); + var dataSource = builder.DataSource?.Trim(); + + if (string.IsNullOrWhiteSpace(dataSource) || IsSpecialDataSource(dataSource)) + { + return connectionString; + } + + var resolvedDatabasePath = Path.IsPathRooted(dataSource) + ? Path.GetFullPath(dataSource) + : Path.GetFullPath(Path.Combine(applicationBasePath, dataSource)); + + var parentDirectory = Path.GetDirectoryName(resolvedDatabasePath); + if (!string.IsNullOrWhiteSpace(parentDirectory)) + { + Directory.CreateDirectory(parentDirectory); + } + + builder.DataSource = resolvedDatabasePath; + return builder.ConnectionString; + } + + private static bool IsSpecialDataSource(string dataSource) + { + return dataSource.Equals(":memory:", StringComparison.OrdinalIgnoreCase) + || dataSource.StartsWith("file:", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/WebCodeCli/Pages/CodeAssistant.razor b/WebCodeCli/Pages/CodeAssistant.razor index bb03f16..7a2c219 100644 --- a/WebCodeCli/Pages/CodeAssistant.razor +++ b/WebCodeCli/Pages/CodeAssistant.razor @@ -431,6 +431,8 @@ SuperpowersQuickInput="@_superpowersQuickInput" SuperpowersQuickInputChanged="@((string value) => _superpowersQuickInput = value)" SuperpowersQuickInputPlaceholder="@SuperpowersQuickActionDefaults.QuickInputPlaceholder" + ContinueActionText="@SuperpowersQuickActionDefaults.ContinueButtonText" + ContinueSuperpowersActionDisabled="@CurrentSuperpowersQuickActionViewState.ContinueActionDisabled" ExecutePlanText="@SuperpowersQuickActionDefaults.ExecutePlanButtonText" ExecuteSubagentPlanText="@SuperpowersQuickActionDefaults.ExecuteSubagentPlanButtonText" SuperpowersQuickActionStatusMessage="@CurrentSuperpowersQuickActionViewState.StatusMessage" @@ -438,9 +440,23 @@ SuperpowersRetryActionDisabled="@CurrentSuperpowersQuickActionViewState.RetryActionDisabled" SuperpowersRetryActionText="@CurrentSuperpowersQuickActionViewState.RetryActionText" OnSubmitSuperpowersQuickInput="OnSubmitSuperpowersQuickInputAsync" + OnContinueSuperpowersAction="OnContinueSuperpowersActionAsync" OnExecuteSuperpowersPlan="OnExecuteSuperpowersPlanAsync" OnExecuteSuperpowersSubagentPlan="OnExecuteSuperpowersSubagentPlanAsync" - OnRetrySuperpowersCapability="RetrySuperpowersCapabilityAsync" /> + OnRetrySuperpowersCapability="RetrySuperpowersCapabilityAsync" + GoalQuickActionMessageId="@CurrentGoalQuickActionEligibility.MessageId" + EnableGoalQuickActions="@IsGoalQuickActionToolSupported()" + GoalQuickActionDisabled="@CurrentGoalQuickActionViewState.IsDisabled" + GoalInstructionText="@GoalQuickActionDefaults.InstructionText" + GoalQuickInput="@_goalQuickInput" + GoalQuickInputChanged="@((string value) => _goalQuickInput = value)" + GoalQuickInputPlaceholder="@GoalQuickActionDefaults.QuickInputPlaceholder" + GoalQuickActionStatusMessage="@CurrentGoalQuickActionViewState.StatusMessage" + ShowGoalRetryAction="@CurrentGoalQuickActionViewState.ShowRetryAction" + GoalRetryActionDisabled="@CurrentGoalQuickActionViewState.RetryActionDisabled" + GoalRetryActionText="@CurrentGoalQuickActionViewState.RetryActionText" + OnSubmitGoalQuickInput="OnSubmitGoalQuickInputAsync" + OnRetryGoalCapability="RetryGoalCapabilityAsync" /> SuperpowersQuickActionHelper.Evaluate( @@ -1920,6 +1928,7 @@ private SuperpowersQuickActionViewState CurrentSuperpowersQuickActionViewState MessageId: eligibility.MessageId, ShowQuickInput: eligibility.ShowQuickInput, ShowPlanActions: eligibility.ShowPlanActions, + ContinueActionDisabled: eligibility.IsDisabled, IsDisabled: eligibility.IsDisabled || _superpowersCapabilityPresentation.IsChecking || _superpowersCapabilityPresentation.State == SuperpowersCapabilityState.Unavailable, @@ -1930,6 +1939,28 @@ private SuperpowersQuickActionViewState CurrentSuperpowersQuickActionViewState } } + private SuperpowersQuickActionEligibility CurrentGoalQuickActionEligibility => + IsGoalQuickActionToolSupported() + ? CurrentSuperpowersQuickActionEligibility with { ShowPlanActions = false } + : SuperpowersQuickActionEligibility.Hidden; + + private GoalQuickActionViewState CurrentGoalQuickActionViewState + { + get + { + var eligibility = CurrentGoalQuickActionEligibility; + return new GoalQuickActionViewState( + MessageId: eligibility.MessageId, + IsDisabled: eligibility.IsDisabled + || _goalCapabilityPresentation.IsChecking + || _goalCapabilityPresentation.State == GoalCapabilityState.Unavailable, + StatusMessage: _goalCapabilityPresentation.StatusMessage, + ShowRetryAction: _goalCapabilityPresentation.ShowRetryAction, + RetryActionDisabled: eligibility.IsDisabled || _goalCapabilityPresentation.IsChecking, + RetryActionText: GoalCapabilityRetryText); + } + } + private bool HasSuperpowersPlanFiles() { try @@ -2296,6 +2327,11 @@ private async Task OnSubmitSuperpowersQuickInputAsync(ChatMessage sourceMessage) await SubmitSuperpowersQuickActionAsync(sourceMessage, SuperpowersQuickActionRequestType.QuickInput); } + private async Task OnContinueSuperpowersActionAsync(ChatMessage sourceMessage) + { + await SubmitSuperpowersQuickActionAsync(sourceMessage, SuperpowersQuickActionRequestType.Continue); + } + private async Task OnExecuteSuperpowersPlanAsync(ChatMessage sourceMessage) { await SubmitSuperpowersQuickActionAsync(sourceMessage, SuperpowersQuickActionRequestType.ExecutePlan); @@ -2325,10 +2361,13 @@ private async Task SubmitSuperpowersQuickActionAsync( return; } - var capabilityAvailable = await EnsureSuperpowersCapabilityAvailableAsync(forceRefresh: false); - if (!capabilityAvailable) + if (requestType != SuperpowersQuickActionRequestType.Continue) { - return; + var capabilityAvailable = await EnsureSuperpowersCapabilityAvailableAsync(forceRefresh: false); + if (!capabilityAvailable) + { + return; + } } if (requestType == SuperpowersQuickActionRequestType.QuickInput) @@ -2350,6 +2389,44 @@ private async Task RetrySuperpowersCapabilityAsync(ChatMessage sourceMessage) await EnsureSuperpowersCapabilityAvailableAsync(forceRefresh: true); } + private async Task OnSubmitGoalQuickInputAsync(ChatMessage sourceMessage) + { + var eligibility = CurrentGoalQuickActionEligibility; + var viewState = CurrentGoalQuickActionViewState; + if (_isLoading + || viewState.IsDisabled + || !SuperpowersQuickActionHelper.IsMessageEligible(sourceMessage, eligibility)) + { + return; + } + + var message = GoalQuickActionSubmissionHelper.BuildMessage(_goalQuickInput); + if (string.IsNullOrWhiteSpace(message)) + { + return; + } + + var capabilityAvailable = await EnsureGoalCapabilityAvailableAsync(forceRefresh: false); + if (!capabilityAvailable) + { + return; + } + + _goalQuickInput = string.Empty; + await SendMessageCoreAsync(message, clearComposerInput: false); + } + + private async Task RetryGoalCapabilityAsync(ChatMessage sourceMessage) + { + var eligibility = CurrentGoalQuickActionEligibility; + if (_isLoading || !SuperpowersQuickActionHelper.IsMessageEligible(sourceMessage, eligibility)) + { + return; + } + + await EnsureGoalCapabilityAvailableAsync(forceRefresh: true); + } + private async Task EnsureSuperpowersCapabilityAvailableAsync(bool forceRefresh) { await RefreshSuperpowersCapabilityPresentationContextAsync(); @@ -2388,6 +2465,46 @@ private async Task EnsureSuperpowersCapabilityAvailableAsync(bool forceRef return _superpowersCapabilityPresentation.State == SuperpowersCapabilityState.Available; } + private async Task EnsureGoalCapabilityAvailableAsync(bool forceRefresh) + { + await RefreshGoalCapabilityPresentationContextAsync(); + + if (_goalCapabilityPresentation.IsChecking) + { + return false; + } + + if (!forceRefresh && _goalCapabilityPresentation.State == GoalCapabilityState.Available) + { + return true; + } + + _goalCapabilityPresentation = GoalCapabilityPresentationState.Checking(GoalCapabilityCheckingText); + await InvokeAsync(StateHasChanged); + + var probeResult = await GoalCapabilityService.ProbeAsync( + BuildGoalCapabilityContext(), + forceRefresh: forceRefresh); + + _goalCapabilityPresentation = probeResult.Outcome switch + { + GoalCapabilityProbeOutcome.Available => GoalCapabilityPresentationState.Available, + GoalCapabilityProbeOutcome.UnsupportedTool or + GoalCapabilityProbeOutcome.UnsupportedVersion or + GoalCapabilityProbeOutcome.MissingFeature => GoalCapabilityPresentationState.Unavailable( + string.IsNullOrWhiteSpace(probeResult.Message) + ? GoalCapabilityUnavailableText + : probeResult.Message), + _ => GoalCapabilityPresentationState.ProbeFailed( + string.IsNullOrWhiteSpace(probeResult.Message) + ? GoalCapabilityProbeFailedText + : probeResult.Message) + }; + + await InvokeAsync(StateHasChanged); + return _goalCapabilityPresentation.State == GoalCapabilityState.Available; + } + private SuperpowersCapabilityContext BuildSuperpowersCapabilityContext() { return new SuperpowersCapabilityContext @@ -2398,6 +2515,16 @@ private SuperpowersCapabilityContext BuildSuperpowersCapabilityContext() }; } + private GoalCapabilityContext BuildGoalCapabilityContext() + { + return new GoalCapabilityContext + { + ToolId = _selectedToolId, + ProviderId = GetCurrentPinnedProviderIdForGoal(), + WorkspacePath = GetCurrentGoalWorkspacePath() + }; + } + private string? GetCurrentPinnedProviderIdForSuperpowers() { if (_currentSession == null || string.IsNullOrWhiteSpace(_currentSession.CcSwitchProviderId)) @@ -2428,6 +2555,36 @@ private SuperpowersCapabilityContext BuildSuperpowersCapabilityContext() } } + private string? GetCurrentPinnedProviderIdForGoal() + { + if (_currentSession == null || string.IsNullOrWhiteSpace(_currentSession.CcSwitchProviderId)) + { + return null; + } + + var selectedToolId = NormalizeSuperpowersCapabilityToolId(_selectedToolId); + var sessionToolId = NormalizeSuperpowersCapabilityToolId(_currentSession.CcSwitchSnapshotToolId ?? _currentSession.ToolId); + if (!string.Equals(selectedToolId, sessionToolId, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return _currentSession.CcSwitchProviderId; + } + + private string? GetCurrentGoalWorkspacePath() + { + try + { + return CliExecutorService.GetSessionWorkspacePath(_sessionId) + ?? _currentSession?.WorkspacePath; + } + catch + { + return _currentSession?.WorkspacePath; + } + } + private async Task RefreshSuperpowersCapabilityPresentationContextAsync() { var nextContextKey = await ResolveSuperpowersCapabilityPresentationContextKeyAsync(); @@ -2457,10 +2614,41 @@ private async Task ResolveSuperpowersCapabilityPresentationContextKeyAsy return BuildFallbackSuperpowersCapabilityPresentationContextKey(); } + private async Task RefreshGoalCapabilityPresentationContextAsync() + { + var nextContextKey = await ResolveGoalCapabilityPresentationContextKeyAsync(); + if (string.Equals(_goalCapabilityPresentationContextKey, nextContextKey, StringComparison.Ordinal)) + { + return; + } + + _goalCapabilityPresentationContextKey = nextContextKey; + _goalCapabilityPresentation = GoalCapabilityPresentationState.Unknown; + } + + private async Task ResolveGoalCapabilityPresentationContextKeyAsync() + { + try + { + var snapshot = await GoalCapabilityService.GetStateAsync(BuildGoalCapabilityContext()); + if (!string.IsNullOrWhiteSpace(snapshot.CacheKey)) + { + return snapshot.CacheKey; + } + } + catch + { + } + + return BuildFallbackGoalCapabilityPresentationContextKey(); + } + private void InvalidateSuperpowersCapabilityPresentation() { _superpowersCapabilityPresentationContextKey = string.Empty; _superpowersCapabilityPresentation = SuperpowersCapabilityPresentationState.Unknown; + _goalCapabilityPresentationContextKey = string.Empty; + _goalCapabilityPresentation = GoalCapabilityPresentationState.Unknown; } private string BuildFallbackSuperpowersCapabilityPresentationContextKey() @@ -2468,6 +2656,23 @@ private string BuildFallbackSuperpowersCapabilityPresentationContextKey() return $"{NormalizeSuperpowersCapabilityToolId(_selectedToolId) ?? string.Empty}::{GetCurrentPinnedProviderIdForSuperpowers() ?? string.Empty}"; } + private string BuildFallbackGoalCapabilityPresentationContextKey() + { + return $"{NormalizeSuperpowersCapabilityToolId(_selectedToolId) ?? string.Empty}::{GetCurrentPinnedProviderIdForGoal() ?? string.Empty}"; + } + + private bool IsGoalQuickActionToolSupported() + { + var selectedToolId = NormalizeSuperpowersCapabilityToolId(_selectedToolId); + if (string.Equals(selectedToolId, "codex", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var sessionToolId = NormalizeSuperpowersCapabilityToolId(_currentSession?.CcSwitchSnapshotToolId ?? _currentSession?.ToolId); + return string.Equals(sessionToolId, "codex", StringComparison.OrdinalIgnoreCase); + } + private static string? NormalizeSuperpowersCapabilityToolId(string? toolId) { if (string.IsNullOrWhiteSpace(toolId)) @@ -2492,6 +2697,7 @@ private readonly record struct SuperpowersQuickActionViewState( string? MessageId, bool ShowQuickInput, bool ShowPlanActions, + bool ContinueActionDisabled, bool IsDisabled, string? StatusMessage, bool ShowRetryAction, @@ -2535,6 +2741,51 @@ private readonly record struct SuperpowersCapabilityPresentationState( ShowRetryAction: true); } + private readonly record struct GoalQuickActionViewState( + string? MessageId, + bool IsDisabled, + string? StatusMessage, + bool ShowRetryAction, + bool RetryActionDisabled, + string RetryActionText); + + private readonly record struct GoalCapabilityPresentationState( + GoalCapabilityState State, + bool IsChecking, + string? StatusMessage, + bool ShowRetryAction) + { + public static GoalCapabilityPresentationState Unknown => new( + GoalCapabilityState.Unknown, + IsChecking: false, + StatusMessage: null, + ShowRetryAction: false); + + public static GoalCapabilityPresentationState Available => new( + GoalCapabilityState.Available, + IsChecking: false, + StatusMessage: null, + ShowRetryAction: false); + + public static GoalCapabilityPresentationState Checking(string statusMessage) => new( + GoalCapabilityState.Unknown, + IsChecking: true, + StatusMessage: statusMessage, + ShowRetryAction: false); + + public static GoalCapabilityPresentationState Unavailable(string statusMessage) => new( + GoalCapabilityState.Unavailable, + IsChecking: false, + StatusMessage: statusMessage, + ShowRetryAction: true); + + public static GoalCapabilityPresentationState ProbeFailed(string statusMessage) => new( + GoalCapabilityState.Unknown, + IsChecking: false, + StatusMessage: statusMessage, + ShowRetryAction: true); + } + private Task UpdatePreview(string content) { if (_disposed) return Task.CompletedTask; @@ -2608,12 +2859,18 @@ private async Task TryHandleHistoryCommandAsync(string message) } else { - var historyMessages = await ExternalCliSessionHistoryService.GetRecentMessagesAsync( + var history = await ExternalCliSessionHistoryService.GetRecentHistoryAsync( toolId, cliThreadId, maxCount: historyLimit, workspacePath: workspacePath); - assistantMessage.Content = BuildExternalCliHistoryText(historyMessages, toolLabel, workspacePath); + assistantMessage.Content = ExternalCliHistoryTextBuilder.Build( + "当前 CLI 会话历史", + history.Messages, + toolLabel, + workspacePath, + cliThreadId, + history.SourcePath); } } catch (Exception ex) @@ -2683,55 +2940,6 @@ private static int ResolveHistoryCommandLimit(string? message) : defaultLimit; } - private static string BuildExternalCliHistoryText( - IReadOnlyList messages, - string toolLabel, - string? workspacePath) - { - var builder = new StringBuilder(); - builder.AppendLine("当前 CLI 会话历史"); - builder.AppendLine($"CLI 工具: {toolLabel}"); - builder.AppendLine($"工作目录: {workspacePath ?? "(工作区未初始化或已失效)"}"); - builder.AppendLine(); - - if (messages.Count == 0) - { - builder.AppendLine("该 CLI 会话暂无可解析的历史消息。"); - return builder.ToString().TrimEnd(); - } - - builder.AppendLine($"显示条数: 最近 {messages.Count} 条"); - builder.AppendLine(); - - foreach (var message in messages) - { - 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(NormalizeHistoryContent(message.Content)); - builder.AppendLine(); - } - - return builder.ToString().TrimEnd(); - } - - private static string NormalizeHistoryContent(string? content) - { - if (string.IsNullOrWhiteSpace(content)) - { - return string.Empty; - } - - return content.Replace("\r\n", "\n").Trim(); - } - private void QueueSaveOutputState(bool forceImmediate = false) { if (_disposed) diff --git a/WebCodeCli/Pages/CodeAssistantMobile.razor b/WebCodeCli/Pages/CodeAssistantMobile.razor index 6791825..3e0c434 100644 --- a/WebCodeCli/Pages/CodeAssistantMobile.razor +++ b/WebCodeCli/Pages/CodeAssistantMobile.razor @@ -553,6 +553,8 @@ { var superpowersEligibility = CurrentSuperpowersQuickActionEligibility; var superpowersViewState = CurrentSuperpowersQuickActionViewState; + var goalEligibility = CurrentGoalQuickActionEligibility; + var goalViewState = CurrentGoalQuickActionViewState; @for (var i = 0; i < _messages.Count; i++) { @@ -562,6 +564,7 @@ var hasOutputDetails = isAssistant && _isJsonlOutputActive && _jsonlEvents.Any(); var isExpanded = IsMessageExpanded(messageIndex); var showSuperpowersQuickActions = IsSuperpowersQuickActionEligible(message, superpowersEligibility); + var showGoalQuickActions = IsSuperpowersQuickActionEligible(message, goalEligibility);
@if (superpowersViewState.ShowPlanActions) { +
+ +
} @if (!string.IsNullOrWhiteSpace(superpowersViewState.StatusMessage) || superpowersViewState.ShowRetryAction) { @@ -661,6 +674,37 @@
} + @if (showGoalQuickActions) + { +
+

@GoalQuickActionDefaults.InstructionText

+
+ + @if (!string.IsNullOrWhiteSpace(goalViewState.StatusMessage) || goalViewState.ShowRetryAction) + { +
+ @if (!string.IsNullOrWhiteSpace(goalViewState.StatusMessage)) + { + @goalViewState.StatusMessage + } + @if (goalViewState.ShowRetryAction) + { + + } +
+ } +
+
+ }
@* 内嵌输出详情面板 *@ @@ -740,31 +784,42 @@
@* 实时输出详情面板 *@ -
-

@SuperpowersQuickActionDefaults.InstructionText

-
- - @if (CurrentSuperpowersQuickActionEligibility.ShowPlanActions) - { - - - } +
+
+

@SuperpowersQuickActionDefaults.InstructionText

+
+ + @if (CurrentSuperpowersQuickActionEligibility.ShowPlanActions) + { +
+ + + +
+ } +
diff --git a/WebCodeCli/Pages/CodeAssistantMobile.razor.cs b/WebCodeCli/Pages/CodeAssistantMobile.razor.cs index 89fc2b7..74ac863 100644 --- a/WebCodeCli/Pages/CodeAssistantMobile.razor.cs +++ b/WebCodeCli/Pages/CodeAssistantMobile.razor.cs @@ -38,6 +38,7 @@ public partial class CodeAssistantMobile : ComponentBase, IAsyncDisposable [Inject] private IUserContextService UserContextService { get; set; } = default!; [Inject] private ICcSwitchService CcSwitchService { get; set; } = default!; [Inject] private ISuperpowersCapabilityService SuperpowersCapabilityService { get; set; } = default!; + [Inject] private IGoalCapabilityService GoalCapabilityService { get; set; } = default!; [Inject] private IVersionService VersionService { get; set; } = default!; [Inject] private HttpClient Http { get; set; } = default!; [Inject] private IFrontendProjectDetector FrontendProjectDetector { get; set; } = default!; @@ -433,11 +434,18 @@ private string GetSkillBadgeClass(string source) private string _superpowersQuickInput = string.Empty; private SuperpowersCapabilityPresentationState _superpowersCapabilityPresentation = SuperpowersCapabilityPresentationState.Unknown; private string _superpowersCapabilityPresentationContextKey = string.Empty; + private string _goalQuickInput = string.Empty; + private GoalCapabilityPresentationState _goalCapabilityPresentation = GoalCapabilityPresentationState.Unknown; + private string _goalCapabilityPresentationContextKey = string.Empty; private const string SuperpowersCapabilityCheckingText = "正在检测 superpowers 能力..."; private const string SuperpowersCapabilityUnavailableText = "当前 Provider 缺少 superpowers 能力"; private const string SuperpowersCapabilityProbeFailedText = "检测 superpowers 能力失败,请重试"; private const string SuperpowersCapabilityRetryText = "重新检测"; + private const string GoalCapabilityCheckingText = GoalQuickActionDefaults.CapabilityCheckingText; + private const string GoalCapabilityUnavailableText = GoalQuickActionDefaults.CapabilityUnavailableText; + private const string GoalCapabilityProbeFailedText = GoalQuickActionDefaults.CapabilityProbeFailedText; + private const string GoalCapabilityRetryText = GoalQuickActionDefaults.CapabilityRetryButtonText; private SuperpowersQuickActionEligibility CurrentSuperpowersQuickActionEligibility => SuperpowersQuickActionHelper.Evaluate( @@ -454,6 +462,7 @@ private SuperpowersQuickActionViewState CurrentSuperpowersQuickActionViewState MessageId: eligibility.MessageId, ShowQuickInput: eligibility.ShowQuickInput, ShowPlanActions: eligibility.ShowPlanActions, + ContinueActionDisabled: eligibility.IsDisabled, IsDisabled: eligibility.IsDisabled || _superpowersCapabilityPresentation.IsChecking || _superpowersCapabilityPresentation.State == SuperpowersCapabilityState.Unavailable, @@ -464,6 +473,28 @@ private SuperpowersQuickActionViewState CurrentSuperpowersQuickActionViewState } } + private SuperpowersQuickActionEligibility CurrentGoalQuickActionEligibility => + IsGoalQuickActionToolSupported() + ? CurrentSuperpowersQuickActionEligibility with { ShowPlanActions = false } + : SuperpowersQuickActionEligibility.Hidden; + + private GoalQuickActionViewState CurrentGoalQuickActionViewState + { + get + { + var eligibility = CurrentGoalQuickActionEligibility; + return new GoalQuickActionViewState( + MessageId: eligibility.MessageId, + IsDisabled: eligibility.IsDisabled + || _goalCapabilityPresentation.IsChecking + || _goalCapabilityPresentation.State == GoalCapabilityState.Unavailable, + StatusMessage: _goalCapabilityPresentation.StatusMessage, + ShowRetryAction: _goalCapabilityPresentation.ShowRetryAction, + RetryActionDisabled: eligibility.IsDisabled || _goalCapabilityPresentation.IsChecking, + RetryActionText: GoalCapabilityRetryText); + } + } + private bool HasSuperpowersPlanFiles() { try @@ -968,6 +999,11 @@ private async Task OnSubmitSuperpowersQuickInputAsync(ChatMessage sourceMessage) await SubmitSuperpowersQuickActionAsync(sourceMessage, SuperpowersQuickActionRequestType.QuickInput); } + private async Task OnContinueSuperpowersActionAsync(ChatMessage sourceMessage) + { + await SubmitSuperpowersQuickActionAsync(sourceMessage, SuperpowersQuickActionRequestType.Continue); + } + private async Task OnExecuteSuperpowersPlanAsync(ChatMessage sourceMessage) { await SubmitSuperpowersQuickActionAsync(sourceMessage, SuperpowersQuickActionRequestType.ExecutePlan); @@ -997,10 +1033,13 @@ private async Task SubmitSuperpowersQuickActionAsync( return; } - var capabilityAvailable = await EnsureSuperpowersCapabilityAvailableAsync(forceRefresh: false); - if (!capabilityAvailable) + if (requestType != SuperpowersQuickActionRequestType.Continue) { - return; + var capabilityAvailable = await EnsureSuperpowersCapabilityAvailableAsync(forceRefresh: false); + if (!capabilityAvailable) + { + return; + } } if (requestType == SuperpowersQuickActionRequestType.QuickInput) @@ -1022,6 +1061,44 @@ private async Task RetrySuperpowersCapabilityAsync(ChatMessage sourceMessage) await EnsureSuperpowersCapabilityAvailableAsync(forceRefresh: true); } + private async Task OnSubmitGoalQuickInputAsync(ChatMessage sourceMessage) + { + var eligibility = CurrentGoalQuickActionEligibility; + var viewState = CurrentGoalQuickActionViewState; + if (_isLoading + || viewState.IsDisabled + || !IsSuperpowersQuickActionEligible(sourceMessage, eligibility)) + { + return; + } + + var message = GoalQuickActionSubmissionHelper.BuildMessage(_goalQuickInput); + if (string.IsNullOrWhiteSpace(message)) + { + return; + } + + var capabilityAvailable = await EnsureGoalCapabilityAvailableAsync(forceRefresh: false); + if (!capabilityAvailable) + { + return; + } + + _goalQuickInput = string.Empty; + await SendMessageCoreAsync(message, clearComposerInput: false, closeTransientPanels: true); + } + + private async Task RetryGoalCapabilityAsync(ChatMessage sourceMessage) + { + var eligibility = CurrentGoalQuickActionEligibility; + if (_isLoading || !IsSuperpowersQuickActionEligible(sourceMessage, eligibility)) + { + return; + } + + await EnsureGoalCapabilityAvailableAsync(forceRefresh: true); + } + private async Task EnsureSuperpowersCapabilityAvailableAsync(bool forceRefresh) { await RefreshSuperpowersCapabilityPresentationContextAsync(); @@ -1060,6 +1137,46 @@ private async Task EnsureSuperpowersCapabilityAvailableAsync(bool forceRef return _superpowersCapabilityPresentation.State == SuperpowersCapabilityState.Available; } + private async Task EnsureGoalCapabilityAvailableAsync(bool forceRefresh) + { + await RefreshGoalCapabilityPresentationContextAsync(); + + if (_goalCapabilityPresentation.IsChecking) + { + return false; + } + + if (!forceRefresh && _goalCapabilityPresentation.State == GoalCapabilityState.Available) + { + return true; + } + + _goalCapabilityPresentation = GoalCapabilityPresentationState.Checking(GoalCapabilityCheckingText); + await InvokeAsync(StateHasChanged); + + var probeResult = await GoalCapabilityService.ProbeAsync( + BuildGoalCapabilityContext(), + forceRefresh: forceRefresh); + + _goalCapabilityPresentation = probeResult.Outcome switch + { + GoalCapabilityProbeOutcome.Available => GoalCapabilityPresentationState.Available, + GoalCapabilityProbeOutcome.UnsupportedTool or + GoalCapabilityProbeOutcome.UnsupportedVersion or + GoalCapabilityProbeOutcome.MissingFeature => GoalCapabilityPresentationState.Unavailable( + string.IsNullOrWhiteSpace(probeResult.Message) + ? GoalCapabilityUnavailableText + : probeResult.Message), + _ => GoalCapabilityPresentationState.ProbeFailed( + string.IsNullOrWhiteSpace(probeResult.Message) + ? GoalCapabilityProbeFailedText + : probeResult.Message) + }; + + await InvokeAsync(StateHasChanged); + return _goalCapabilityPresentation.State == GoalCapabilityState.Available; + } + private SuperpowersCapabilityContext BuildSuperpowersCapabilityContext() { return new SuperpowersCapabilityContext @@ -1070,6 +1187,16 @@ private SuperpowersCapabilityContext BuildSuperpowersCapabilityContext() }; } + private GoalCapabilityContext BuildGoalCapabilityContext() + { + return new GoalCapabilityContext + { + ToolId = _selectedToolId, + ProviderId = GetCurrentPinnedProviderIdForGoal(), + WorkspacePath = GetCurrentGoalWorkspacePath() + }; + } + private string? GetCurrentPinnedProviderIdForSuperpowers() { if (_currentSession == null || string.IsNullOrWhiteSpace(_currentSession.CcSwitchProviderId)) @@ -1100,6 +1227,36 @@ private SuperpowersCapabilityContext BuildSuperpowersCapabilityContext() } } + private string? GetCurrentPinnedProviderIdForGoal() + { + if (_currentSession == null || string.IsNullOrWhiteSpace(_currentSession.CcSwitchProviderId)) + { + return null; + } + + var selectedToolId = NormalizeSuperpowersCapabilityToolId(_selectedToolId); + var sessionToolId = NormalizeSuperpowersCapabilityToolId(_currentSession.CcSwitchSnapshotToolId ?? _currentSession.ToolId); + if (!string.Equals(selectedToolId, sessionToolId, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return _currentSession.CcSwitchProviderId; + } + + private string? GetCurrentGoalWorkspacePath() + { + try + { + return CliExecutorService.GetSessionWorkspacePath(_sessionId) + ?? _currentSession?.WorkspacePath; + } + catch + { + return _currentSession?.WorkspacePath; + } + } + private async Task RefreshSuperpowersCapabilityPresentationContextAsync() { var nextContextKey = await ResolveSuperpowersCapabilityPresentationContextKeyAsync(); @@ -1129,10 +1286,41 @@ private async Task ResolveSuperpowersCapabilityPresentationContextKeyAsy return BuildFallbackSuperpowersCapabilityPresentationContextKey(); } + private async Task RefreshGoalCapabilityPresentationContextAsync() + { + var nextContextKey = await ResolveGoalCapabilityPresentationContextKeyAsync(); + if (string.Equals(_goalCapabilityPresentationContextKey, nextContextKey, StringComparison.Ordinal)) + { + return; + } + + _goalCapabilityPresentationContextKey = nextContextKey; + _goalCapabilityPresentation = GoalCapabilityPresentationState.Unknown; + } + + private async Task ResolveGoalCapabilityPresentationContextKeyAsync() + { + try + { + var snapshot = await GoalCapabilityService.GetStateAsync(BuildGoalCapabilityContext()); + if (!string.IsNullOrWhiteSpace(snapshot.CacheKey)) + { + return snapshot.CacheKey; + } + } + catch + { + } + + return BuildFallbackGoalCapabilityPresentationContextKey(); + } + private void InvalidateSuperpowersCapabilityPresentation() { _superpowersCapabilityPresentationContextKey = string.Empty; _superpowersCapabilityPresentation = SuperpowersCapabilityPresentationState.Unknown; + _goalCapabilityPresentationContextKey = string.Empty; + _goalCapabilityPresentation = GoalCapabilityPresentationState.Unknown; } private string BuildFallbackSuperpowersCapabilityPresentationContextKey() @@ -1140,6 +1328,23 @@ private string BuildFallbackSuperpowersCapabilityPresentationContextKey() return $"{NormalizeSuperpowersCapabilityToolId(_selectedToolId) ?? string.Empty}::{GetCurrentPinnedProviderIdForSuperpowers() ?? string.Empty}"; } + private string BuildFallbackGoalCapabilityPresentationContextKey() + { + return $"{NormalizeSuperpowersCapabilityToolId(_selectedToolId) ?? string.Empty}::{GetCurrentPinnedProviderIdForGoal() ?? string.Empty}"; + } + + private bool IsGoalQuickActionToolSupported() + { + var selectedToolId = NormalizeSuperpowersCapabilityToolId(_selectedToolId); + if (string.Equals(selectedToolId, "codex", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var sessionToolId = NormalizeSuperpowersCapabilityToolId(_currentSession?.CcSwitchSnapshotToolId ?? _currentSession?.ToolId); + return string.Equals(sessionToolId, "codex", StringComparison.OrdinalIgnoreCase); + } + private static string? NormalizeSuperpowersCapabilityToolId(string? toolId) { if (string.IsNullOrWhiteSpace(toolId)) @@ -1164,6 +1369,7 @@ private readonly record struct SuperpowersQuickActionViewState( string? MessageId, bool ShowQuickInput, bool ShowPlanActions, + bool ContinueActionDisabled, bool IsDisabled, string? StatusMessage, bool ShowRetryAction, @@ -1207,6 +1413,51 @@ private readonly record struct SuperpowersCapabilityPresentationState( ShowRetryAction: true); } + private readonly record struct GoalQuickActionViewState( + string? MessageId, + bool IsDisabled, + string? StatusMessage, + bool ShowRetryAction, + bool RetryActionDisabled, + string RetryActionText); + + private readonly record struct GoalCapabilityPresentationState( + GoalCapabilityState State, + bool IsChecking, + string? StatusMessage, + bool ShowRetryAction) + { + public static GoalCapabilityPresentationState Unknown => new( + GoalCapabilityState.Unknown, + IsChecking: false, + StatusMessage: null, + ShowRetryAction: false); + + public static GoalCapabilityPresentationState Available => new( + GoalCapabilityState.Available, + IsChecking: false, + StatusMessage: null, + ShowRetryAction: false); + + public static GoalCapabilityPresentationState Checking(string statusMessage) => new( + GoalCapabilityState.Unknown, + IsChecking: true, + StatusMessage: statusMessage, + ShowRetryAction: false); + + public static GoalCapabilityPresentationState Unavailable(string statusMessage) => new( + GoalCapabilityState.Unavailable, + IsChecking: false, + StatusMessage: statusMessage, + ShowRetryAction: true); + + public static GoalCapabilityPresentationState ProbeFailed(string statusMessage) => new( + GoalCapabilityState.Unknown, + IsChecking: false, + StatusMessage: statusMessage, + ShowRetryAction: true); + } + private async Task HandleSuperpowersQuickInputKeyDown(ChatMessage message, KeyboardEventArgs args) { if (!string.Equals(args.Key, "Enter", StringComparison.Ordinal)) @@ -1217,6 +1468,16 @@ private async Task HandleSuperpowersQuickInputKeyDown(ChatMessage message, Keybo await OnSubmitSuperpowersQuickInputAsync(message); } + private async Task HandleGoalQuickInputKeyDown(ChatMessage message, KeyboardEventArgs args) + { + if (!string.Equals(args.Key, "Enter", StringComparison.Ordinal)) + { + return; + } + + await OnSubmitGoalQuickInputAsync(message); + } + private async Task TryHandleHistoryCommandAsync(string message) { if (!IsHistoryCommand(message)) @@ -1249,12 +1510,18 @@ private async Task TryHandleHistoryCommandAsync(string message) } else { - var historyMessages = await ExternalCliSessionHistoryService.GetRecentMessagesAsync( + var history = await ExternalCliSessionHistoryService.GetRecentHistoryAsync( toolId, cliThreadId, maxCount: historyLimit, workspacePath: workspacePath); - assistantMessage.Content = BuildExternalCliHistoryText(historyMessages, toolLabel, workspacePath); + assistantMessage.Content = ExternalCliHistoryTextBuilder.Build( + "当前 CLI 会话历史", + history.Messages, + toolLabel, + workspacePath, + cliThreadId, + history.SourcePath); } } catch (Exception ex) @@ -1323,55 +1590,6 @@ private static int ResolveHistoryCommandLimit(string? message) : defaultLimit; } - private static string BuildExternalCliHistoryText( - IReadOnlyList messages, - string toolLabel, - string? workspacePath) - { - var builder = new StringBuilder(); - builder.AppendLine("当前 CLI 会话历史"); - builder.AppendLine($"CLI 工具: {toolLabel}"); - builder.AppendLine($"工作目录: {workspacePath ?? "(工作区未初始化或已失效)"}"); - builder.AppendLine(); - - if (messages.Count == 0) - { - builder.AppendLine("该 CLI 会话暂无可解析的历史消息。"); - return builder.ToString().TrimEnd(); - } - - builder.AppendLine($"显示条数: 最近 {messages.Count} 条"); - builder.AppendLine(); - - foreach (var message in messages) - { - 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(NormalizeHistoryContent(message.Content)); - builder.AppendLine(); - } - - return builder.ToString().TrimEnd(); - } - - private static string NormalizeHistoryContent(string? content) - { - if (string.IsNullOrWhiteSpace(content)) - { - return string.Empty; - } - - return content.Replace("\r\n", "\n").Trim(); - } - private async Task ScrollToBottom() { try diff --git a/WebCodeCli/Pages/GoalQuickActionSubmissionHelper.cs b/WebCodeCli/Pages/GoalQuickActionSubmissionHelper.cs new file mode 100644 index 0000000..2c08267 --- /dev/null +++ b/WebCodeCli/Pages/GoalQuickActionSubmissionHelper.cs @@ -0,0 +1,11 @@ +using WebCodeCli.Domain.Domain.Service; + +namespace WebCodeCli.Pages; + +public static class GoalQuickActionSubmissionHelper +{ + public static string? BuildMessage(string? quickInput) + { + return GoalPromptBuilder.BuildGoalPrompt(quickInput); + } +} diff --git a/WebCodeCli/Pages/SuperpowersQuickActionSubmissionHelper.cs b/WebCodeCli/Pages/SuperpowersQuickActionSubmissionHelper.cs index 318b28d..576d900 100644 --- a/WebCodeCli/Pages/SuperpowersQuickActionSubmissionHelper.cs +++ b/WebCodeCli/Pages/SuperpowersQuickActionSubmissionHelper.cs @@ -4,6 +4,7 @@ namespace WebCodeCli.Pages; public enum SuperpowersQuickActionRequestType { + Continue, ExecutePlan, ExecuteSubagentPlan, QuickInput @@ -15,6 +16,7 @@ public static class SuperpowersQuickActionSubmissionHelper { return requestType switch { + SuperpowersQuickActionRequestType.Continue => SuperpowersPromptBuilder.BuildContinuePrompt(), SuperpowersQuickActionRequestType.ExecutePlan => SuperpowersPromptBuilder.BuildExecutePlanPrompt(), SuperpowersQuickActionRequestType.ExecuteSubagentPlan => SuperpowersPromptBuilder.BuildSubagentExecutePlanPrompt(), SuperpowersQuickActionRequestType.QuickInput => SuperpowersPromptBuilder.BuildQuickSkillPrompt(quickInput), diff --git a/WebCodeCli/Program.cs b/WebCodeCli/Program.cs index f2934d0..0d20b8c 100644 --- a/WebCodeCli/Program.cs +++ b/WebCodeCli/Program.cs @@ -129,6 +129,13 @@ } // 设置全局实例 + if (string.Equals(dbConfig.DbType, "Sqlite", StringComparison.OrdinalIgnoreCase)) + { + dbConfig.ConnectionStrings = SqliteConnectionStringResolver.Resolve( + dbConfig.ConnectionStrings, + AppContext.BaseDirectory); + } + DBConnectionOption.Instance = dbConfig; Log.Information($"Database Type: {DBConnectionOption.Instance.DbType}"); @@ -225,6 +232,27 @@ static WebApplicationOptions CreateBuilderOptions(string[] args) return new WebApplicationOptions { Args = args, - WebRootPath = resolvedWebRoot + WebRootPath = resolvedWebRoot, + EnvironmentName = ResolveDefaultEnvironmentName() }; } + +static string ResolveDefaultEnvironmentName() +{ + var configuredEnvironmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + if (string.IsNullOrWhiteSpace(configuredEnvironmentName)) + { + configuredEnvironmentName = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); + } + + if (!string.IsNullOrWhiteSpace(configuredEnvironmentName)) + { + return configuredEnvironmentName; + } + +#if DEBUG + return Environments.Development; +#else + return Environments.Production; +#endif +} diff --git a/WebCodeCli/appsettings.json b/WebCodeCli/appsettings.json index 544a416..f313c39 100644 --- a/WebCodeCli/appsettings.json +++ b/WebCodeCli/appsettings.json @@ -25,6 +25,18 @@ "DefaultCardTitle": "AI助手", "ThinkingMessage": "⏳ 思考中..." }, + "FeishuReplyTts": { + "TtsStorageRoot": "", + "TtsServiceBaseUrl": "http://127.0.0.1:5058", + "TtsServiceTimeoutSeconds": 180, + "TtsPreferredDevice": "cpu", + "TtsDefaultVoiceId": "", + "TtsChunkMaxChars": 160, + "FfmpegExecutablePath": "", + "TtsServiceStartScriptPath": "", + "TtsServicePythonPath": "", + "TtsServiceStartupTimeoutSeconds": 30 + }, "CliTools": { "MaxConcurrentExecutions": 3, "DefaultTimeoutSeconds": 300, diff --git a/docs/superpowers/plans/2026-05-02-feishu-reply-tts-implementation.md b/docs/superpowers/plans/2026-05-02-feishu-reply-tts-implementation.md new file mode 100644 index 0000000..93e0207 --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-feishu-reply-tts-implementation.md @@ -0,0 +1,1082 @@ +# Feishu Reply TTS Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add optional Feishu reply TTS so every completed Feishu streaming reply can asynchronously produce one or more `audio` messages using a same-host `MeloTTS` service, user-level voice preferences, non-`C:` storage rules, and graceful failure fallback. + +**Architecture:** Keep the current Feishu streaming-card execution flow authoritative for text completion, then enqueue a background reply-TTS orchestration pass after the card finishes. Split the feature into four boundaries: user/platform configuration, a local `MeloTTS` HTTP client plus platform availability service, speech normalization/chunking/transcode/audio delivery services, and lightweight completion hooks in `FeishuChannelService` and `FeishuCardActionService`. + +**Tech Stack:** ASP.NET Core, Blazor Server, `HttpClient`, `System.Diagnostics.Process`, Feishu Open Platform IM APIs, SqlSugar code-first entities, xUnit v2/v3 test projects, FastAPI + Python for the local `MeloTTS` wrapper, existing DI scanning via `ServiceDescriptionAttribute`. + +Depends on: +- `docs/superpowers/specs/2026-05-02-feishu-reply-tts-design.md` + +--- + +## File Map + +### Platform config and path policy + +- Create: `WebCodeCli.Domain/Common/Options/FeishuReplyTtsOptions.cs` + Platform-wide TTS settings: storage root, system-drive policy, service base URL, timeout, default voice, chunk size, and `ffmpeg` path. +- Create: `WebCodeCli.Domain/Domain/Model/Channels/FeishuReplyTtsHealthStatus.cs` + DTO for admin health checks and runtime platform availability. +- Create: `WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsStorageRootResolver.cs` + OS-aware storage-root policy for Windows and non-Windows hosts, including the Windows-only-`C:` rejection rule. +- Modify: `WebCodeCli.Domain/Common/Extensions/ServiceCollectionExtensions.cs` + Bind the new options section and register any named `HttpClient` usage needed by the TTS client. +- Modify: `WebCodeCli/appsettings.json` + Add the sample `FeishuReplyTts` section with safe defaults and blank operator-supplied paths. + +### User config and admin surface + +- Modify: `WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs` + Add `ReplyTtsEnabled` and `ReplyTtsVoiceId`. +- Modify: `WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs` + Normalize and persist the new user-level TTS settings. +- Modify: `WebCodeCli/Controllers/AdminController.cs` + Expose the new Feishu bot config fields and add admin endpoints for TTS health and voice discovery. +- Create: `WebCodeCli/Helpers/AdminUserManagementReplyTtsUiState.cs` + Small helper for modal warnings and selector state so the UI logic is unit-testable without bUnit. +- Modify: `WebCodeCli/Components/AdminUserManagementModal.razor` + Add the TTS toggle, voice selector, health message, and reload button. +- Modify: `WebCodeCli/Components/AdminUserManagementModal.razor.cs` + Load health/voices, carry the new DTO fields, and wire save/load behavior through the existing admin API. + +### TTS runtime orchestration + +- Create: `WebCodeCli.Domain/Domain/Model/Channels/FeishuReplyTtsVoiceOption.cs` + Runtime-discovered voice descriptor returned by the local service. +- Create: `WebCodeCli.Domain/Domain/Model/Channels/FeishuCompletedReplyTtsRequest.cs` + Request contract for a completed Feishu reply that should be queued for TTS. +- Create: `WebCodeCli.Domain/Domain/Service/Channels/IFeishuReplyTtsPlatformService.cs` + Contract for health checks, voice enumeration, storage-root availability, and voice fallback resolution. +- Create: `WebCodeCli.Domain/Domain/Service/Channels/FeishuReplyTtsPlatformService.cs` + Platform availability service that combines path policy and local `MeloTTS` health/voice discovery. +- Create: `WebCodeCli.Domain/Domain/Service/Channels/IMeloTtsClient.cs` + Narrow HTTP client contract for `GET /health`, `GET /voices`, and `POST /synthesize`. +- Create: `WebCodeCli.Domain/Domain/Service/Channels/MeloTtsClient.cs` + Same-host HTTP client for the local Python TTS wrapper. +- Create: `WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsSpeechTextNormalizer.cs` + Converts markdown-heavy assistant output into speech-friendly text. +- Create: `WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsChunker.cs` + Paragraph-first, sentence-second chunking service with max-char enforcement. +- Create: `WebCodeCli.Domain/Domain/Service/Channels/IExternalProcessRunner.cs` + Tiny abstraction for invoking `ffmpeg` without binding unit tests to real subprocesses. +- Create: `WebCodeCli.Domain/Domain/Service/Channels/ExternalProcessRunner.cs` + Production process runner used by the transcode service. +- Create: `WebCodeCli.Domain/Domain/Service/Channels/IAudioTranscodeService.cs` + Contract for `wav` to `opus` conversion under the approved storage root. +- Create: `WebCodeCli.Domain/Domain/Service/Channels/AudioTranscodeService.cs` + `ffmpeg`-backed transcode implementation that writes only under the resolved storage root. +- Create: `WebCodeCli.Domain/Domain/Service/Channels/IFeishuAudioMessageService.cs` + Contract for upload + ordered audio send to Feishu. +- Create: `WebCodeCli.Domain/Domain/Service/Channels/FeishuAudioMessageService.cs` + Feishu delivery service that resolves effective Feishu options and sends `audio` messages. +- Create: `WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsOrchestrator.cs` + Public entrypoint for queuing a completed reply for background TTS work. +- Create: `WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsOrchestrator.cs` + Background orchestrator that normalizes text, resolves voices, chunks, synthesizes, transcodes, uploads, and sends failure notices. + +### Feishu API surface and completion hooks + +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/IFeishuCardKitClient.cs` + Add file-upload and audio-message methods. +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs` + Implement Feishu file upload and `audio` send using the official IM APIs. +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs` + Queue reply TTS after successful normal streaming completion. +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs` + Queue reply TTS after card-action streaming completion, including the low-interruption completion path. + +### Local `MeloTTS` wrapper service and deployment docs + +- Create: `tools/melotts-service/README.md` + Setup and operational instructions, including non-`C:` storage and environment variables. +- Create: `tools/melotts-service/requirements.txt` + Python dependencies for FastAPI, Uvicorn, and `MeloTTS`. +- Create: `tools/melotts-service/app.py` + Local HTTP wrapper exposing `/health`, `/voices`, and `/synthesize` with GPU-to-CPU fallback. +- Create: `tools/melotts-service/start.ps1` + Windows startup helper that refuses to default to `C:` unless explicitly allowed. +- Create: `tools/melotts-service/start.sh` + Non-Windows startup helper for the same service. +- Create: `tools/melotts-service/tests/test_app.py` + Lightweight FastAPI tests with a fake engine adapter. + +### Tests + +- Create: `WebCodeCli.Domain.Tests/ReplyTtsStorageRootResolverTests.cs` +- Create: `WebCodeCli.Domain.Tests/UserFeishuBotConfigServiceTests.cs` +- Create: `WebCodeCli.Domain.Tests/MeloTtsClientTests.cs` +- Create: `WebCodeCli.Domain.Tests/FeishuReplyTtsPlatformServiceTests.cs` +- Create: `WebCodeCli.Domain.Tests/ReplyTtsSpeechTextNormalizerTests.cs` +- Create: `WebCodeCli.Domain.Tests/ReplyTtsChunkerTests.cs` +- Create: `WebCodeCli.Domain.Tests/AudioTranscodeServiceTests.cs` +- Create: `WebCodeCli.Domain.Tests/FeishuAudioMessageServiceTests.cs` +- Create: `WebCodeCli.Domain.Tests/ReplyTtsOrchestratorTests.cs` +- Create: `tests/WebCodeCli.Tests/AdminControllerReplyTtsTests.cs` +- Create: `tests/WebCodeCli.Tests/AdminUserManagementReplyTtsUiStateTests.cs` +- Modify: `WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs` +- Modify: `WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs` +- Modify: `WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs` + +### Explicit non-goals + +- Do not add browser-side TTS playback. +- Do not add voice cloning or user-provided reference audio. +- Do not block text reply completion on TTS completion. +- Do not silently install to `C:` when Windows policy forbids it. +- Do not bundle a Dockerized `MeloTTS` service in this first implementation. + +--- + +## Chunk 1: Establish Platform Policy and User Settings + +### Task 1: Add the platform options and storage-root resolver + +**Files:** +- Create: `WebCodeCli.Domain/Common/Options/FeishuReplyTtsOptions.cs` +- Create: `WebCodeCli.Domain/Domain/Model/Channels/FeishuReplyTtsHealthStatus.cs` +- Create: `WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsStorageRootResolver.cs` +- Modify: `WebCodeCli.Domain/Common/Extensions/ServiceCollectionExtensions.cs` +- Modify: `WebCodeCli/appsettings.json` +- Test: `WebCodeCli.Domain.Tests/ReplyTtsStorageRootResolverTests.cs` + +- [ ] **Step 1: Write the failing resolver tests first** + +Add tests that prove: + +- an explicit `TtsStorageRoot` always wins +- Windows picks the first writable non-system drive when no explicit root is set +- Windows with only `C:` and `AllowSystemDriveInstall = false` returns an unavailable result with a clear message +- Windows with only `C:` and `AllowSystemDriveInstall = true` resolves a `C:`-based root +- non-Windows uses `/data/webcode/melotts` when no explicit root is set +- environment subpaths for `models`, `cache`, `temp`, `logs`, and `venv` are all rooted under the resolved storage root + +Use a fake host-environment abstraction so the tests do not depend on the real machine's drives. + +```csharp +[Fact] +public void Resolve_WhenWindowsOnlyHasSystemDriveAndPolicyForbidsIt_ReturnsUnavailable() +{ + var options = new FeishuReplyTtsOptions + { + AllowSystemDriveInstall = false, + TtsStorageRoot = null + }; + var environment = new StubReplyTtsHostEnvironment( + isWindows: true, + systemDriveRoot: @"C:\", + writableRoots: [@"C:\"]); + + var result = new ReplyTtsStorageRootResolver(environment).Resolve(options); + + Assert.False(result.IsAvailable); + Assert.Contains("only the system drive", result.Message, StringComparison.OrdinalIgnoreCase); +} +``` + +- [ ] **Step 2: Run the focused test command and confirm it fails** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ReplyTtsStorageRootResolverTests" +``` + +Expected: + +- FAIL because the new options type and resolver do not exist yet + +- [ ] **Step 3: Implement the options type and resolver** + +Create `FeishuReplyTtsOptions` with the approved platform-level settings: + +```csharp +public sealed class FeishuReplyTtsOptions +{ + public string? TtsStorageRoot { get; set; } + public bool AllowSystemDriveInstall { get; set; } + public string TtsServiceBaseUrl { get; set; } = "http://127.0.0.1:5057"; + public int TtsServiceTimeoutSeconds { get; set; } = 60; + public string TtsPreferredDevice { get; set; } = "gpu-auto"; + public string? TtsDefaultVoiceId { get; set; } + public int TtsChunkMaxChars { get; set; } = 1200; + public string? FfmpegExecutablePath { get; set; } +} +``` + +Implement `ReplyTtsStorageRootResolver` so it returns a structured result with: + +- `IsAvailable` +- `StorageRoot` +- `Message` +- helper properties for `ModelsRoot`, `CacheRoot`, `TempRoot`, `LogsRoot`, and `VenvRoot` + +The resolver must reject Windows-only-`C:` deployments unless the administrator explicitly allows system-drive installation. + +- [ ] **Step 4: Bind the new options and add the sample config section** + +Bind the new section in `AddFeishuChannel(...)` and add a sample `FeishuReplyTts` block to `WebCodeCli/appsettings.json`. + +Use blank operator-supplied paths instead of hard-coded `C:` or `D:` paths: + +```json +"FeishuReplyTts": { + "TtsStorageRoot": "", + "AllowSystemDriveInstall": false, + "TtsServiceBaseUrl": "http://127.0.0.1:5057", + "TtsServiceTimeoutSeconds": 60, + "TtsPreferredDevice": "gpu-auto", + "TtsDefaultVoiceId": "", + "TtsChunkMaxChars": 1200, + "FfmpegExecutablePath": "" +} +``` + +- [ ] **Step 5: Re-run the focused tests and confirm they pass** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ReplyTtsStorageRootResolverTests" +``` + +Expected: + +- PASS + +- [ ] **Step 6: Commit the platform-policy chunk** + +```powershell +git add WebCodeCli.Domain/Common/Options/FeishuReplyTtsOptions.cs WebCodeCli.Domain/Domain/Model/Channels/FeishuReplyTtsHealthStatus.cs WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsStorageRootResolver.cs WebCodeCli.Domain/Common/Extensions/ServiceCollectionExtensions.cs WebCodeCli/appsettings.json WebCodeCli.Domain.Tests/ReplyTtsStorageRootResolverTests.cs +git commit -m "feat: add Feishu reply TTS platform path policy" +``` + +### Task 2: Persist user-level TTS settings in the Feishu bot config + +**Files:** +- Modify: `WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs` +- Modify: `WebCodeCli/Controllers/AdminController.cs` +- Test: `WebCodeCli.Domain.Tests/UserFeishuBotConfigServiceTests.cs` +- Test: `tests/WebCodeCli.Tests/AdminControllerReplyTtsTests.cs` + +- [ ] **Step 1: Write the failing persistence and controller tests** + +Add tests that prove: + +- saving a Feishu bot config preserves `ReplyTtsEnabled` and `ReplyTtsVoiceId` +- updates overwrite previous TTS values instead of leaving stale data +- blank voice ids normalize to `null` +- `AdminController.GetFeishuBotConfig(...)` returns the new fields +- `AdminController.SaveFeishuBotConfig(...)` forwards the new fields into `UserFeishuBotConfigEntity` + +Use the same stub-repository style already used in `UserFeishuBotRuntimeServiceTests` and simple handmade controller stubs in the Web test project. + +```csharp +[Fact] +public async Task SaveAsync_UpdatesReplyTtsFields() +{ + var repository = new InMemoryUserFeishuBotConfigRepository(); + await repository.InsertAsync(new UserFeishuBotConfigEntity + { + Username = "alice", + ReplyTtsEnabled = false, + ReplyTtsVoiceId = "old-voice" + }); + + var service = CreateService(repository); + await service.SaveAsync(new UserFeishuBotConfigEntity + { + Username = "alice", + ReplyTtsEnabled = true, + ReplyTtsVoiceId = "zh_female" + }); + + var saved = await repository.GetByUsernameAsync("alice"); + Assert.True(saved!.ReplyTtsEnabled); + Assert.Equal("zh_female", saved.ReplyTtsVoiceId); +} +``` + +- [ ] **Step 2: Run the focused failing tests** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~UserFeishuBotConfigServiceTests" +dotnet test D:\VSWorkshop\WebCode\tests\WebCodeCli.Tests\WebCodeCli.Tests.csproj --filter "FullyQualifiedName~AdminControllerReplyTtsTests" +``` + +Expected: + +- FAIL because the entity and DTOs do not have the new fields yet + +- [ ] **Step 3: Add the new entity and service fields** + +Add these properties to `UserFeishuBotConfigEntity`: + +```csharp +public bool ReplyTtsEnabled { get; set; } +public string? ReplyTtsVoiceId { get; set; } +``` + +Update `UserFeishuBotConfigService.SaveAsync(...)` and `NormalizeConfig(...)` so the new fields round-trip correctly. + +- [ ] **Step 4: Extend the admin DTO mapping** + +Update `UserFeishuBotConfigDto`, `MapFeishuConfig(...)`, and `SaveFeishuBotConfig(...)` so the admin API round-trips: + +- `ReplyTtsEnabled` +- `ReplyTtsVoiceId` + +Do not add per-user platform config here; only the user preference fields belong in this DTO. + +- [ ] **Step 5: Re-run the focused tests and confirm they pass** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~UserFeishuBotConfigServiceTests" +dotnet test D:\VSWorkshop\WebCode\tests\WebCodeCli.Tests\WebCodeCli.Tests.csproj --filter "FullyQualifiedName~AdminControllerReplyTtsTests" +``` + +Expected: + +- PASS + +- [ ] **Step 6: Commit the user-settings chunk** + +```powershell +git add WebCodeCli.Domain/Repositories/Base/UserFeishuBotConfig/UserFeishuBotConfigEntity.cs WebCodeCli.Domain/Domain/Service/UserFeishuBotConfigService.cs WebCodeCli/Controllers/AdminController.cs WebCodeCli.Domain.Tests/UserFeishuBotConfigServiceTests.cs tests/WebCodeCli.Tests/AdminControllerReplyTtsTests.cs +git commit -m "feat: persist Feishu reply TTS user settings" +``` + +--- + +## Chunk 2: Build the Admin Voice Discovery Surface + +### Task 3: Add the local `MeloTTS` client, platform service, and admin health/voice endpoints + +**Files:** +- Create: `WebCodeCli.Domain/Domain/Model/Channels/FeishuReplyTtsVoiceOption.cs` +- Create: `WebCodeCli.Domain/Domain/Service/Channels/IMeloTtsClient.cs` +- Create: `WebCodeCli.Domain/Domain/Service/Channels/MeloTtsClient.cs` +- Create: `WebCodeCli.Domain/Domain/Service/Channels/IFeishuReplyTtsPlatformService.cs` +- Create: `WebCodeCli.Domain/Domain/Service/Channels/FeishuReplyTtsPlatformService.cs` +- Modify: `WebCodeCli/Controllers/AdminController.cs` +- Test: `WebCodeCli.Domain.Tests/MeloTtsClientTests.cs` +- Test: `WebCodeCli.Domain.Tests/FeishuReplyTtsPlatformServiceTests.cs` +- Test: `tests/WebCodeCli.Tests/AdminControllerReplyTtsTests.cs` + +- [ ] **Step 1: Write the failing client and platform-service tests** + +Add tests that prove: + +- `MeloTtsClient.GetHealthAsync()` parses the local service response +- `MeloTtsClient.GetVoicesAsync()` parses the voice list +- `FeishuReplyTtsPlatformService` reports unavailable when storage-root resolution fails +- `FeishuReplyTtsPlatformService` merges resolver availability with local service health +- `FeishuReplyTtsPlatformService` returns the runtime voice list +- `ResolveVoiceOrFallbackAsync(...)` prefers the saved voice, then the default voice, then fails cleanly + +Use a stub `HttpMessageHandler` for the client and stubbed resolver results for the platform service. + +```csharp +[Fact] +public async Task ResolveVoiceOrFallbackAsync_WhenSavedVoiceIsMissing_UsesDefaultVoice() +{ + var service = CreatePlatformService( + voices: [new FeishuReplyTtsVoiceOption { VoiceId = "default-zh", DisplayName = "Default" }], + defaultVoiceId: "default-zh"); + + var result = await service.ResolveVoiceOrFallbackAsync("missing-voice"); + + Assert.True(result.Success); + Assert.Equal("default-zh", result.VoiceId); + Assert.True(result.UsedFallback); +} +``` + +- [ ] **Step 2: Run the focused failing tests** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~MeloTtsClientTests|FullyQualifiedName~FeishuReplyTtsPlatformServiceTests" +dotnet test D:\VSWorkshop\WebCode\tests\WebCodeCli.Tests\WebCodeCli.Tests.csproj --filter "FullyQualifiedName~AdminControllerReplyTtsTests" +``` + +Expected: + +- FAIL because the TTS client, platform service, and endpoints do not exist yet + +- [ ] **Step 3: Implement the local client and platform service** + +Use a narrow contract: + +```csharp +public interface IMeloTtsClient +{ + Task GetHealthAsync(CancellationToken cancellationToken = default); + Task> GetVoicesAsync(CancellationToken cancellationToken = default); + Task SynthesizeAsync(string text, string voiceId, CancellationToken cancellationToken = default); +} +``` + +Implement `FeishuReplyTtsPlatformService` with methods: + +- `GetHealthAsync()` +- `GetVoicesAsync()` +- `ResolveVoiceOrFallbackAsync(string? savedVoiceId)` + +The service must short-circuit to an unavailable health result when the path resolver says the platform is not allowed to run. + +- [ ] **Step 4: Add admin endpoints** + +Add: + +- `GET /api/admin/feishu-tts/health` +- `GET /api/admin/feishu-tts/voices` + +These endpoints should return WebCode-owned DTOs and should not expose the Python service directly to the browser. + +- [ ] **Step 5: Re-run the focused tests and confirm they pass** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~MeloTtsClientTests|FullyQualifiedName~FeishuReplyTtsPlatformServiceTests" +dotnet test D:\VSWorkshop\WebCode\tests\WebCodeCli.Tests\WebCodeCli.Tests.csproj --filter "FullyQualifiedName~AdminControllerReplyTtsTests" +``` + +Expected: + +- PASS + +- [ ] **Step 6: Commit the voice-discovery chunk** + +```powershell +git add WebCodeCli.Domain/Domain/Model/Channels/FeishuReplyTtsVoiceOption.cs WebCodeCli.Domain/Domain/Service/Channels/IMeloTtsClient.cs WebCodeCli.Domain/Domain/Service/Channels/MeloTtsClient.cs WebCodeCli.Domain/Domain/Service/Channels/IFeishuReplyTtsPlatformService.cs WebCodeCli.Domain/Domain/Service/Channels/FeishuReplyTtsPlatformService.cs WebCodeCli/Controllers/AdminController.cs WebCodeCli.Domain.Tests/MeloTtsClientTests.cs WebCodeCli.Domain.Tests/FeishuReplyTtsPlatformServiceTests.cs tests/WebCodeCli.Tests/AdminControllerReplyTtsTests.cs +git commit -m "feat: add MeloTTS health and voice discovery" +``` + +### Task 4: Wire the admin user-management modal to the new TTS settings and voice list + +**Files:** +- Create: `WebCodeCli/Helpers/AdminUserManagementReplyTtsUiState.cs` +- Modify: `WebCodeCli/Components/AdminUserManagementModal.razor` +- Modify: `WebCodeCli/Components/AdminUserManagementModal.razor.cs` +- Test: `tests/WebCodeCli.Tests/AdminUserManagementReplyTtsUiStateTests.cs` + +- [ ] **Step 1: Write the failing UI-state helper tests** + +Add tests that prove: + +- the voice selector is disabled when reply TTS is off +- the voice selector is disabled when platform health is unavailable +- a missing saved voice produces a fallback warning +- a healthy platform with voices produces no warning + +Keep the helper pure so it can be unit-tested without a Razor harness. + +```csharp +[Fact] +public void Build_WhenSavedVoiceIsMissing_ReturnsFallbackWarning() +{ + var state = AdminUserManagementReplyTtsUiState.Build( + replyTtsEnabled: true, + savedVoiceId: "missing", + availableVoices: [new AdminReplyTtsVoiceOption("default-zh", "默认音色")], + platformAvailable: true, + platformMessage: null); + + Assert.Contains("fallback", state.WarningMessage, StringComparison.OrdinalIgnoreCase); +} +``` + +- [ ] **Step 2: Run the focused failing helper tests** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\tests\WebCodeCli.Tests\WebCodeCli.Tests.csproj --filter "FullyQualifiedName~AdminUserManagementReplyTtsUiStateTests" +``` + +Expected: + +- FAIL because the helper does not exist yet + +- [ ] **Step 3: Implement the helper and modal wiring** + +Add the helper and update the modal to: + +- load `/api/admin/feishu-tts/health` and `/api/admin/feishu-tts/voices` +- carry `ReplyTtsEnabled` and `ReplyTtsVoiceId` through the nested config models +- render the toggle, voice dropdown, refresh action, and warning copy +- keep saved values visible even when health is temporarily unavailable + +Add the nested DTO fields in `AdminUserManagementModal.razor.cs`: + +```csharp +public bool ReplyTtsEnabled { get; set; } +public string? ReplyTtsVoiceId { get; set; } +``` + +- [ ] **Step 4: Run the helper tests and a compile-driven Razor check** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\tests\WebCodeCli.Tests\WebCodeCli.Tests.csproj --filter "FullyQualifiedName~AdminUserManagementReplyTtsUiStateTests" +dotnet msbuild D:\VSWorkshop\WebCode\WebCodeCli\WebCodeCli.csproj /t:CoreCompile /nologo +``` + +Expected: + +- helper tests PASS +- `CoreCompile` PASS, confirming the Razor/C# modal wiring compiles + +- [ ] **Step 5: Commit the admin-modal chunk** + +```powershell +git add WebCodeCli/Helpers/AdminUserManagementReplyTtsUiState.cs WebCodeCli/Components/AdminUserManagementModal.razor WebCodeCli/Components/AdminUserManagementModal.razor.cs tests/WebCodeCli.Tests/AdminUserManagementReplyTtsUiStateTests.cs +git commit -m "feat: add Feishu reply TTS controls to admin modal" +``` + +--- + +## Chunk 3: Build the Speech Pipeline and Feishu Audio Delivery + +### Task 5: Add speech normalization, chunking, and transcode services + +**Files:** +- Create: `WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsSpeechTextNormalizer.cs` +- Create: `WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsChunker.cs` +- Create: `WebCodeCli.Domain/Domain/Service/Channels/IExternalProcessRunner.cs` +- Create: `WebCodeCli.Domain/Domain/Service/Channels/ExternalProcessRunner.cs` +- Create: `WebCodeCli.Domain/Domain/Service/Channels/IAudioTranscodeService.cs` +- Create: `WebCodeCli.Domain/Domain/Service/Channels/AudioTranscodeService.cs` +- Test: `WebCodeCli.Domain.Tests/ReplyTtsSpeechTextNormalizerTests.cs` +- Test: `WebCodeCli.Domain.Tests/ReplyTtsChunkerTests.cs` +- Test: `WebCodeCli.Domain.Tests/AudioTranscodeServiceTests.cs` + +- [ ] **Step 1: Write the failing text and transcode tests** + +Add tests that prove: + +- markdown headings, emphasis markers, and bullet syntax are removed cleanly +- raw links are dropped or replaced with a short cue instead of being read verbatim +- code blocks are replaced with a short summary cue +- short paragraphs stay in one chunk +- long replies split on paragraph and sentence boundaries before falling back to hard breaks +- `AudioTranscodeService` rejects missing `ffmpeg` configuration +- `AudioTranscodeService` writes outputs under the resolved temp root and invokes `ffmpeg` with `libopus`, mono audio, and 16 kHz output + +Use a fake `IExternalProcessRunner` in the transcode tests instead of launching real `ffmpeg`. + +```csharp +[Fact] +public void Chunk_WhenParagraphsExceedLimit_SplitsOnSentenceBoundariesFirst() +{ + var chunker = new ReplyTtsChunker(maxChars: 20); + var chunks = chunker.Split("第一句。第二句很短。\n\n第三段也很短。"); + + Assert.Collection(chunks, + chunk => Assert.Equal("第一句。第二句很短。", chunk), + chunk => Assert.Equal("第三段也很短。", chunk)); +} +``` + +- [ ] **Step 2: Run the focused failing tests** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ReplyTtsSpeechTextNormalizerTests|FullyQualifiedName~ReplyTtsChunkerTests|FullyQualifiedName~AudioTranscodeServiceTests" +``` + +Expected: + +- FAIL because the normalizer, chunker, and transcode services do not exist yet + +- [ ] **Step 3: Implement the normalizer and chunker** + +Implement a speech-friendly normalizer that: + +- strips markdown syntax +- replaces fenced code blocks with a short fixed sentence +- removes or shortens URLs +- preserves natural prose and list meaning + +Implement a chunker that: + +- splits on blank lines first +- then sentence punctuation +- then falls back to smaller punctuation or hard breaks only when needed + +- [ ] **Step 4: Implement the transcode service** + +Implement `AudioTranscodeService` so it: + +- validates `FfmpegExecutablePath` +- creates a per-job temp folder under the resolved TTS temp root +- invokes `ffmpeg` through `IExternalProcessRunner` +- produces deterministic `chunk-001.opus` style output paths + +Use a command shape like: + +```text +ffmpeg -y -i -acodec libopus -ac 1 -ar 16000 +``` + +- [ ] **Step 5: Re-run the focused tests and confirm they pass** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ReplyTtsSpeechTextNormalizerTests|FullyQualifiedName~ReplyTtsChunkerTests|FullyQualifiedName~AudioTranscodeServiceTests" +``` + +Expected: + +- PASS + +- [ ] **Step 6: Commit the speech-pipeline chunk** + +```powershell +git add WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsSpeechTextNormalizer.cs WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsChunker.cs WebCodeCli.Domain/Domain/Service/Channels/IExternalProcessRunner.cs WebCodeCli.Domain/Domain/Service/Channels/ExternalProcessRunner.cs WebCodeCli.Domain/Domain/Service/Channels/IAudioTranscodeService.cs WebCodeCli.Domain/Domain/Service/Channels/AudioTranscodeService.cs WebCodeCli.Domain.Tests/ReplyTtsSpeechTextNormalizerTests.cs WebCodeCli.Domain.Tests/ReplyTtsChunkerTests.cs WebCodeCli.Domain.Tests/AudioTranscodeServiceTests.cs +git commit -m "feat: add Feishu reply TTS speech pipeline" +``` + +### Task 6: Add Feishu file upload and audio-message delivery support + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/IFeishuCardKitClient.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs` +- Create: `WebCodeCli.Domain/Domain/Service/Channels/IFeishuAudioMessageService.cs` +- Create: `WebCodeCli.Domain/Domain/Service/Channels/FeishuAudioMessageService.cs` +- Test: `WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs` +- Test: `WebCodeCli.Domain.Tests/FeishuAudioMessageServiceTests.cs` + +- [ ] **Step 1: Write the failing upload/send tests** + +Add tests that prove: + +- `FeishuCardKitClient.UploadAudioFileAsync(...)` posts `multipart/form-data` to `/open-apis/im/v1/files` +- the upload uses `file_type=opus` +- the upload forwards the duration in milliseconds +- `SendAudioMessageAsync(...)` sends `msg_type = "audio"` with `{"file_key":"..."}` content +- `FeishuAudioMessageService` resolves effective Feishu options via username or app id and sends audio in order + +Use the existing stubbed HTTP-client pattern from `FeishuCardKitClientTests` instead of real network calls. + +```csharp +[Fact] +public async Task SendAudioMessageAsync_SendsAudioPayload() +{ + var client = CreateClient(); + await client.SendAudioMessageAsync("oc_xxx", "file_v2_123", 3200, optionsOverride: CreateOptions()); + + Assert.Equal("audio", client.LastPayload!.GetProperty("msg_type").GetString()); + Assert.Contains("file_v2_123", client.LastPayload!.GetProperty("content").GetString()); +} +``` + +- [ ] **Step 2: Run the focused failing tests** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~FeishuCardKitClientTests|FullyQualifiedName~FeishuAudioMessageServiceTests" +``` + +Expected: + +- FAIL because the upload/audio methods do not exist yet + +- [ ] **Step 3: Implement the upload and send methods** + +Extend `IFeishuCardKitClient` and `FeishuCardKitClient` with: + +```csharp +Task UploadAudioFileAsync(string filePath, int durationMs, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null); +Task SendAudioMessageAsync(string chatId, string fileKey, int durationMs, CancellationToken cancellationToken = default, FeishuOptions? optionsOverride = null); +``` + +Then implement `FeishuAudioMessageService` so it: + +- resolves effective `FeishuOptions` +- uploads the `opus` file +- sends the resulting `file_key` as an `audio` message + +Keep this logic out of the orchestrator so upload/send failures can be tested independently. + +- [ ] **Step 4: Re-run the focused tests and confirm they pass** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~FeishuCardKitClientTests|FullyQualifiedName~FeishuAudioMessageServiceTests" +``` + +Expected: + +- PASS + +- [ ] **Step 5: Commit the Feishu-audio-delivery chunk** + +```powershell +git add WebCodeCli.Domain/Domain/Service/Channels/IFeishuCardKitClient.cs WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs WebCodeCli.Domain/Domain/Service/Channels/IFeishuAudioMessageService.cs WebCodeCli.Domain/Domain/Service/Channels/FeishuAudioMessageService.cs WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs WebCodeCli.Domain.Tests/FeishuAudioMessageServiceTests.cs +git commit -m "feat: add Feishu audio upload and send support" +``` + +--- + +## Chunk 4: Queue Background TTS After Completed Replies + +### Task 7: Build the reply-TTS orchestrator and hook normal Feishu reply completion + +**Files:** +- Create: `WebCodeCli.Domain/Domain/Model/Channels/FeishuCompletedReplyTtsRequest.cs` +- Create: `WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsOrchestrator.cs` +- Create: `WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsOrchestrator.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs` +- Test: `WebCodeCli.Domain.Tests/ReplyTtsOrchestratorTests.cs` +- Test: `WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs` + +- [ ] **Step 1: Write the failing orchestrator and channel-hook tests** + +Add tests that prove: + +- disabled `ReplyTtsEnabled` skips synthesis entirely +- empty or normalization-only-empty output skips synthesis +- a missing saved voice falls back to the platform default voice +- chunk synthesis, transcode, upload, and send happen in order +- one failed chunk stops the remaining chunks and sends exactly one text failure notice +- two jobs for the same chat do not interleave +- `FeishuChannelService` queues TTS only after successful completion, not on error or superseded execution + +Use fakes for the platform service, client, transcode service, audio service, and message sender. + +```csharp +[Fact] +public async Task QueueCompletedReplyAsync_WhenChunkFails_SendsSingleFailureNotice() +{ + var orchestrator = CreateOrchestrator(chunkFailureAt: 2); + + await orchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest + { + ChatId = "oc_chat", + Username = "alice", + AppId = "cli_app", + SessionId = "session-1", + ReplyText = "第一段。\n\n第二段。" + }); + + Assert.Equal(1, orchestrator.SentFailureNotices.Count); + Assert.Equal(1, orchestrator.AudioMessagesSent.Count); +} +``` + +- [ ] **Step 2: Run the focused failing tests** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ReplyTtsOrchestratorTests|FullyQualifiedName~FeishuChannelServiceTests" +``` + +Expected: + +- FAIL because the orchestrator and completion hook do not exist yet + +- [ ] **Step 3: Implement the orchestrator** + +Implement a singleton orchestrator with a public method like: + +```csharp +Task QueueCompletedReplyAsync(FeishuCompletedReplyTtsRequest request); +``` + +Behavior rules: + +- do not block the caller on actual TTS completion +- serialize work per chat, for example with a `ConcurrentDictionary` +- normalize the completed reply +- resolve the saved voice or default-voice fallback +- split into ordered chunks +- for each chunk: synthesize `wav`, transcode to `opus`, upload, send +- on failure: stop remaining chunks and send one short text notice + +Keep temp files under the resolver's `TempRoot` and clean them on success. + +- [ ] **Step 4: Hook `FeishuChannelService` after successful completion** + +After the existing completion flow finishes and the assistant message is persisted, resolve `IReplyTtsOrchestrator` from the existing service provider and queue: + +```csharp +await replyTtsOrchestrator.QueueCompletedReplyAsync(new FeishuCompletedReplyTtsRequest +{ + ChatId = chatId, + Username = username, + AppId = appId, + SessionId = sessionId, + ReplyText = finalOutput +}); +``` + +Do this only on the successful completion path. Do not queue on cancellation, supersession, or error. + +- [ ] **Step 5: Re-run the focused tests and confirm they pass** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ReplyTtsOrchestratorTests|FullyQualifiedName~FeishuChannelServiceTests" +``` + +Expected: + +- PASS + +- [ ] **Step 6: Commit the orchestrator chunk** + +```powershell +git add WebCodeCli.Domain/Domain/Model/Channels/FeishuCompletedReplyTtsRequest.cs WebCodeCli.Domain/Domain/Service/Channels/IReplyTtsOrchestrator.cs WebCodeCli.Domain/Domain/Service/Channels/ReplyTtsOrchestrator.cs WebCodeCli.Domain/Domain/Service/Channels/FeishuChannelService.cs WebCodeCli.Domain.Tests/ReplyTtsOrchestratorTests.cs WebCodeCli.Domain.Tests/FeishuChannelServiceTests.cs +git commit -m "feat: queue reply TTS after Feishu completion" +``` + +### Task 8: Hook card-action and low-interruption streaming completion into the same orchestrator + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs` +- Test: `WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs` + +- [ ] **Step 1: Write the failing card-action completion tests** + +Add tests that prove: + +- normal card-action streaming completion queues reply TTS +- low-interruption completion also queues reply TTS +- error completion does not queue reply TTS +- the queued request carries the chat id, username, app id, session id, and final assistant output + +Use the existing `FeishuCardActionServiceTests` stubs and extend the service-provider stub to supply a fake orchestrator. + +- [ ] **Step 2: Run the focused failing tests** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~FeishuCardActionServiceTests" +``` + +Expected: + +- FAIL because `FeishuCardActionService` does not queue reply TTS yet + +- [ ] **Step 3: Implement the card-action completion hooks** + +Queue the same `FeishuCompletedReplyTtsRequest` in both successful completion methods: + +- the normal card-action streaming completion path +- the low-interruption completion path + +Use the same lazy service-provider resolution approach as Task 7 to avoid broad constructor churn. + +- [ ] **Step 4: Re-run the focused tests and confirm they pass** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~FeishuCardActionServiceTests" +``` + +Expected: + +- PASS + +- [ ] **Step 5: Commit the card-action hook chunk** + +```powershell +git add WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs WebCodeCli.Domain.Tests/FeishuCardActionServiceTests.cs +git commit -m "feat: queue reply TTS for Feishu card actions" +``` + +--- + +## Chunk 5: Add the Local `MeloTTS` Wrapper and Deployment Assets + +### Task 9: Create the local Python service, startup scripts, and operator docs + +**Files:** +- Create: `tools/melotts-service/README.md` +- Create: `tools/melotts-service/requirements.txt` +- Create: `tools/melotts-service/app.py` +- Create: `tools/melotts-service/start.ps1` +- Create: `tools/melotts-service/start.sh` +- Create: `tools/melotts-service/tests/test_app.py` + +- [ ] **Step 1: Write the failing Python service tests** + +Add tests that prove: + +- `/health` reports the active device and default voice +- `/voices` returns a normalized list of voices +- `/synthesize` rejects blank input +- if GPU engine initialization fails, the app falls back to CPU and still serves `/health` + +Keep the FastAPI app structured around a tiny engine adapter so tests can inject a fake adapter instead of importing the real `MeloTTS` runtime. + +```python +def test_health_reports_cpu_fallback(test_client): + response = test_client.get("/health") + assert response.status_code == 200 + body = response.json() + assert body["status"] == "ok" + assert body["device"] in {"cpu", "cuda"} +``` + +- [ ] **Step 2: Run the failing Python tests** + +Run on Windows: + +```powershell +python -m pytest D:\VSWorkshop\WebCode\tools\melotts-service\tests\test_app.py -q +``` + +Run on non-Windows: + +```bash +python -m pytest /path/to/WebCode/tools/melotts-service/tests/test_app.py -q +``` + +Expected: + +- FAIL because the service files do not exist yet + +- [ ] **Step 3: Implement the FastAPI wrapper** + +Implement `app.py` with: + +- startup-time engine creation that tries GPU first, then CPU +- `GET /health` +- `GET /voices` +- `POST /synthesize` + +Keep the `/synthesize` contract narrow: + +```json +{ + "text": "你好,这是测试语音。", + "voice_id": "zh_female_default" +} +``` + +Return synthesized `wav` bytes from the endpoint instead of a shared local path contract. + +- [ ] **Step 4: Add startup scripts and operator docs** + +Write scripts that: + +- require a non-empty storage root +- reject Windows-only-`C:` defaults unless an explicit override flag is set +- export `HF_HOME`, `TRANSFORMERS_CACHE`, `TORCH_HOME`, `TEMP`, `TMP`, and `PIP_CACHE_DIR` under the chosen storage root +- start Uvicorn on the configured loopback port + +Document the approved non-`C:` layout in `README.md`, including: + +- same-host deployment +- Windows vs non-Windows startup +- GPU-to-CPU fallback behavior +- `ffmpeg` placement + +- [ ] **Step 5: Re-run the Python tests and a manual health smoke test** + +Run: + +```powershell +python -m pytest D:\VSWorkshop\WebCode\tools\melotts-service\tests\test_app.py -q +``` + +Then start the service manually and smoke-check: + +```powershell +python D:\VSWorkshop\WebCode\tools\melotts-service\app.py +Invoke-WebRequest -UseBasicParsing http://127.0.0.1:5057/health +``` + +Expected: + +- Python tests PASS +- `/health` returns JSON with `status`, `device`, and `defaultVoiceId` + +- [ ] **Step 6: Commit the local-service chunk** + +```powershell +git add tools/melotts-service/README.md tools/melotts-service/requirements.txt tools/melotts-service/app.py tools/melotts-service/start.ps1 tools/melotts-service/start.sh tools/melotts-service/tests/test_app.py +git commit -m "feat: add local MeloTTS wrapper service" +``` + +--- + +## Verification Pass + +### Task 10: Run the final focused verification matrix before claiming completion + +**Files:** +- No code changes expected unless a verification issue is discovered + +- [ ] **Step 1: Run the Web and Domain test suites for the new feature** + +Run: + +```powershell +dotnet test D:\VSWorkshop\WebCode\WebCodeCli.Domain.Tests\WebCodeCli.Domain.Tests.csproj --filter "FullyQualifiedName~ReplyTts|FullyQualifiedName~FeishuCardKitClientTests|FullyQualifiedName~FeishuChannelServiceTests|FullyQualifiedName~FeishuCardActionServiceTests" +dotnet test D:\VSWorkshop\WebCode\tests\WebCodeCli.Tests\WebCodeCli.Tests.csproj --filter "FullyQualifiedName~AdminControllerReplyTtsTests|FullyQualifiedName~AdminUserManagementReplyTtsUiStateTests" +dotnet msbuild D:\VSWorkshop\WebCode\WebCodeCli\WebCodeCli.csproj /t:CoreCompile /nologo +``` + +Expected: + +- PASS for both test projects +- PASS for `CoreCompile` + +- [ ] **Step 2: Run the Python wrapper tests** + +Run: + +```powershell +python -m pytest D:\VSWorkshop\WebCode\tools\melotts-service\tests\test_app.py -q +``` + +Expected: + +- PASS + +- [ ] **Step 3: Manually verify one same-host end-to-end Feishu flow** + +Manual checklist: + +- enable `ReplyTtsEnabled` for one bound Feishu user +- select a valid runtime voice +- trigger a normal Feishu streaming reply +- confirm the text reply completes normally +- confirm the existing completion text notification still appears +- confirm one or more ordered Feishu `audio` messages arrive afterward +- repeat once with a long reply to confirm chunk splitting +- repeat once with a deliberately broken TTS dependency to confirm one short failure notice appears and the text reply remains intact + +- [ ] **Step 4: Commit only if verification exposed fixes** + +If verification requires code changes: + +```powershell +git add +git commit -m "fix: address Feishu reply TTS verification issues" +``` + +If verification passes with no further code changes, do not create an extra empty commit. diff --git a/docs/superpowers/plans/2026-05-05-feishu-streaming-card-section-separation.md b/docs/superpowers/plans/2026-05-05-feishu-streaming-card-section-separation.md new file mode 100644 index 0000000..4403983 --- /dev/null +++ b/docs/superpowers/plans/2026-05-05-feishu-streaming-card-section-separation.md @@ -0,0 +1,52 @@ +# Feishu Streaming Card Section Separation Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the Feishu streaming reply card clearly separate thinking-level controls, reply content, and bottom Superpowers workflow controls by inserting explicit red visual section markers into the card JSON. + +**Architecture:** Reuse one shared streaming-card element builder for both the CardKit create/update path and the refresh-card path. Preserve all existing action payloads and bottom prompt behavior while replacing weak default section boundaries with explicit marker rows. + +**Tech Stack:** .NET 10, Feishu CardKit JSON card rendering, xUnit + +--- + +Spec reference: `docs/superpowers/specs/2026-05-05-feishu-streaming-card-section-separation-design.md` + +## File Map + +- `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs` + - Source of streaming-card element construction today. +- `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs` + - Contains refresh-card rendering path that must stay aligned. +- `WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs` + - Best place to lock the generated card JSON shape. + +## Task 1: Share the Feishu streaming card section builder + +**Files:** +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardKitClient.cs` +- Modify: `WebCodeCli.Domain/Domain/Service/Channels/FeishuCardActionService.cs` + +- [ ] Extract or expose one shared streaming-card element builder. +- [ ] Add explicit section marker modules for thinking-level, reply-content, and workflow areas. +- [ ] Ensure the workflow marker only appears when bottom prompt or bottom actions exist. +- [ ] Route the refresh-card path through the same section builder or identical helper. + +## Task 2: Lock the generated JSON with tests + +**Files:** +- Modify: `WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs` + +- [ ] Update index-sensitive tests that currently assume raw `hr` placement. +- [ ] Add a test that asserts the section-marker content appears in the card JSON. +- [ ] Keep prompt and action assertions unchanged so behavior remains covered. + +## Task 3: Verify + +**Files:** +- Verify: `WebCodeCli.Domain.Tests/FeishuCardKitClientTests.cs` +- Verify: `WebCodeCli.sln` + +- [ ] Run `dotnet test WebCodeCli.Domain.Tests/WebCodeCli.Domain.Tests.csproj --filter FeishuCardKitClientTests`. +- [ ] Run `dotnet build WebCodeCli.sln`. +- [ ] Confirm no action payload logic changed, only card structure. diff --git a/docs/superpowers/plans/2026-05-05-streaming-reply-card-section-separation.md b/docs/superpowers/plans/2026-05-05-streaming-reply-card-section-separation.md new file mode 100644 index 0000000..4685a02 --- /dev/null +++ b/docs/superpowers/plans/2026-05-05-streaming-reply-card-section-separation.md @@ -0,0 +1,245 @@ +# Streaming Reply Card Section Separation Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the in-flight streaming reply card visually separate the live reply content, thinking/status band, and bottom Superpowers workflow area with obvious red horizontal dividers, without changing behavior. + +**Architecture:** Keep the change local to the two existing Razor surfaces that render streaming replies. Reuse existing Tailwind-style utility classes in markup instead of adding global CSS or changing component logic. Preserve all current event handlers, disabled-state rules, and prompt-routing behavior while wrapping the affected sections with stronger spacing and divider treatment. + +**Tech Stack:** ASP.NET Core Blazor Razor components, Tailwind utility classes, .NET 10 build/test tooling + +--- + +Spec reference: `docs/superpowers/specs/2026-05-05-streaming-reply-card-section-separation-design.md` + +## File Map + +- `WebCodeCli/Components/ChatMessageListPanel.razor` + - Shared desktop streaming assistant card. + - Needs section wrappers and red divider treatment inside the `IsLoading` branch only. +- `WebCodeCli/Pages/CodeAssistantMobile.razor` + - Mobile streaming reply surface. + - Needs matching separation treatment around the live reply block and the bottom Superpowers workflow block. +- No new dependencies. +- No new global stylesheet expected. +- No dedicated component-test harness currently exists in `tests/WebCodeCli.Tests` for these Razor surfaces, so verification stays at build/test/manual UI level. + +## Chunk 1: Shared Desktop Streaming Card + +### Task 1: Add explicit content and control sections to the shared loading card + +**Files:** +- Modify: `WebCodeCli/Components/ChatMessageListPanel.razor` +- Test: manual verification in the running Web UI + +- [ ] **Step 1: Isolate the current desktop streaming card body sections** + +Read the `@if (IsLoading)` branch and identify the current order: + +1. header row +2. live markdown block +3. Superpowers quick-action block +4. goal quick-action block + +Confirm the change stays inside this branch only. + +- [ ] **Step 2: Wrap the live reply content in a dedicated first section** + +Adjust the markup so the current live markdown block sits inside its own neutral section container. + +Target shape: + +```razor +
+
+ @RenderMarkdown(CurrentAssistantMessage) +
+
+``` + +Keep the existing text sizing, overflow behavior, and markdown rendering. + +- [ ] **Step 3: Add a distinct thinking/status divider band before workflow controls** + +Insert a section break immediately after the live reply content using utility classes such as: + +```razor +
+ ... +
+``` + +The separator must be visually obvious. Do not attach any new behavior to it. + +- [ ] **Step 4: Keep the existing Superpowers block but place it behind its own red divider** + +Move the existing `ShouldShowStreamingSuperpowersQuickActions()` block under a second section boundary so it no longer reads as a continuation of the markdown body. + +Target pattern: + +```razor +
+
+ ... +
+
+``` + +Do not change button text, disabled states, placeholders, or callback wiring. + +- [ ] **Step 5: Keep the goal quick-action block visually aligned with the new section treatment** + +If `ShouldShowStreamingGoalQuickActions()` remains in the same card, apply the same section-separation approach so the green goal block also sits behind a clear divider instead of attaching directly to the previous section. + +- [ ] **Step 6: Build the solution to confirm the shared component still compiles** + +Run: + +```powershell +dotnet build WebCodeCli.sln +``` + +Expected: + +- build succeeds +- no Razor parse errors +- no component parameter or markup nesting errors + +- [ ] **Step 7: Commit the desktop card slice** + +```bash +git add WebCodeCli/Components/ChatMessageListPanel.razor +git commit -m "Separate streaming reply sections in the shared desktop card" +``` + +Use the repository Lore commit format when creating the real commit. + +## Chunk 2: Mobile Streaming Reply Alignment + +### Task 2: Mirror the separation pattern in the mobile streaming surface + +**Files:** +- Modify: `WebCodeCli/Pages/CodeAssistantMobile.razor` +- Test: manual verification in the running mobile Web UI + +- [ ] **Step 1: Isolate the current mobile streaming reply structure** + +Read the `_isLoading` streaming branch and confirm the current structure: + +1. reply bubble with live markdown or animated dots +2. optional view-details strip inside the bubble +3. separate bottom Superpowers workflow panel + +Keep this order. + +- [ ] **Step 2: Add a red-divider break between the reply bubble content and the workflow panel** + +Update the wrapper spacing so the mobile workflow panel is visually separated from the live reply area by a clear red horizontal divider treatment. + +Target pattern: + +```razor +
+
+ ... +
+
+``` + +Keep the existing width constraints and button wrapping behavior. + +- [ ] **Step 3: Preserve the inline thinking presentation inside the mobile reply bubble** + +Do not move the existing animated dots or thinking text out of the bubble. This plan only strengthens the boundary between reply content and workflow controls. + +- [ ] **Step 4: Build again after the mobile update** + +Run: + +```powershell +dotnet build WebCodeCli.sln +``` + +Expected: + +- build succeeds again +- no malformed Razor markup + +- [ ] **Step 5: Commit the mobile alignment slice** + +```bash +git add WebCodeCli/Pages/CodeAssistantMobile.razor +git commit -m "Match mobile streaming reply section separators" +``` + +Use the repository Lore commit format when creating the real commit. + +## Chunk 3: Verification and Cleanup + +### Task 3: Verify behavior did not change + +**Files:** +- Verify: `WebCodeCli/Components/ChatMessageListPanel.razor` +- Verify: `WebCodeCli/Pages/CodeAssistantMobile.razor` +- Verify: `WebCodeCli.sln` + +- [ ] **Step 1: Run the fast build + test baseline** + +Run: + +```powershell +dotnet build WebCodeCli.sln +dotnet test tests/WebCodeCli.Tests/WebCodeCli.Tests.csproj --no-build +``` + +Expected: + +- solution builds successfully +- existing test project still passes + +Note: + +- this does not provide visual coverage +- there is no existing dedicated component test harness for these streaming Razor surfaces + +- [ ] **Step 2: Manually verify the desktop streaming card** + +Manual checklist: + +- start a streaming assistant reply in desktop Web +- confirm the markdown body remains readable +- confirm a visible red divider separates content from the next section +- confirm the Superpowers block no longer reads as part of the reply body +- confirm disabled buttons and input still look disabled while streaming + +- [ ] **Step 3: Manually verify the mobile streaming card** + +Manual checklist: + +- open the mobile page +- start a streaming assistant reply +- confirm the reply bubble still renders correctly +- confirm the bottom workflow panel is separated by a visible red divider +- confirm buttons still wrap cleanly on narrow widths + +- [ ] **Step 4: Check the final diff stays narrow** + +Run: + +```powershell +git diff -- WebCodeCli/Components/ChatMessageListPanel.razor WebCodeCli/Pages/CodeAssistantMobile.razor +``` + +Expected: + +- diff is limited to markup/class changes +- no event-handler or prompt-construction logic changed + +- [ ] **Step 5: Commit the verified implementation** + +```bash +git add WebCodeCli/Components/ChatMessageListPanel.razor WebCodeCli/Pages/CodeAssistantMobile.razor +git commit -m "Clarify streaming reply sections with divider styling" +``` + +Use the repository Lore commit format when creating the real commit. diff --git a/docs/superpowers/specs/2026-05-02-feishu-reply-tts-design.md b/docs/superpowers/specs/2026-05-02-feishu-reply-tts-design.md new file mode 100644 index 0000000..17d4cfe --- /dev/null +++ b/docs/superpowers/specs/2026-05-02-feishu-reply-tts-design.md @@ -0,0 +1,423 @@ +# Feishu Reply TTS Design + +Date: 2026-05-02 +Status: approved in discussion, pending implementation planning + +## Goal + +Add an optional "reply text-to-speech" capability for Feishu conversations so that, after a streaming reply card finishes, the system can synthesize the completed assistant reply into one or more audio messages and send them back to Feishu. + +The design must satisfy the following approved constraints: + +- use `MeloTTS` as the TTS engine +- deploy `MeloTTS` on the same machine as `WebCode` and the Feishu bot +- do not install service files, models, caches, temp files, or `ffmpeg` on `C:` by default +- support Windows and non-Windows deployments +- if Windows only has `C:` and the administrator has not explicitly allowed system-drive installation, TTS is unavailable +- prefer GPU inference when available, and automatically fall back to CPU when GPU startup fails or is unavailable +- let each Feishu user enable or disable reply TTS in user management +- let each Feishu user choose from the runtime-discovered `MeloTTS` voice list +- if a saved voice is no longer available, automatically fall back to the platform default voice +- read the completed reply as full text, but normalize markdown, code-heavy sections, and links into speech-friendly text +- if the reply is too long, split it into multiple audio messages using paragraph and sentence boundaries first +- if synthesis, transcoding, upload, or audio send fails, preserve the text reply and append a short text failure notice + +## Scope + +In scope: + +- Feishu-side reply TTS only +- triggering TTS after streaming reply completion +- user-level Feishu TTS preferences in the existing admin user management surface +- a local HTTP wrapper service around `MeloTTS` +- dynamic voice discovery from the local `MeloTTS` service +- audio chunking, transcoding to `opus`, Feishu upload, and Feishu audio-message send +- OS-aware storage-root resolution and system-drive safeguards +- verification and health-check paths for the local TTS stack + +Out of scope: + +- Web or mobile browser-side TTS playback +- provider-agnostic multi-engine TTS abstraction beyond the boundary needed to wrap `MeloTTS` +- user-provided custom voice cloning +- live streaming audio while the text reply is still generating +- automatic voice preview UI in the first version +- editing or replaying previously generated TTS jobs + +## Problem + +The current Feishu experience ends with a completed streaming card plus a text completion notification. Users who want an audio version of the answer must handle that manually outside the product. + +The desired workflow is: + +- the assistant finishes its normal streaming reply card +- the text reply remains the primary source of truth +- the system optionally performs a background TTS job +- the user receives one or more Feishu `audio` messages generated from the completed answer + +There are several practical constraints that shape the design: + +- Feishu supports sending `audio` messages, but the platform does not provide a public TTS API for text synthesis +- a local or external TTS engine must therefore generate audio before Feishu upload +- deployment environments differ across Windows and non-Windows hosts +- Windows hosts may have only a system drive available, and the approved rule is to avoid silently using `C:` +- runtime voice lists may change when the local `MeloTTS` installation or models change + +This feature must therefore be modeled as a separate post-processing pipeline attached to Feishu reply completion, not as part of the main streaming-card execution path. + +## User Experience + +### Feishu Reply Completion + +When a Feishu reply finishes streaming successfully, the system behavior is: + +1. complete the existing streaming reply card as it does today +2. keep the existing completion text notification +3. if reply TTS is enabled for the bound Web user, start a background TTS job +4. send one or more Feishu `audio` messages after synthesis completes + +The TTS job must not block the normal text reply from finishing. + +### TTS User Preference + +The existing Feishu bot settings area in admin user management gains: + +- a toggle: `Enable reply TTS` +- a voice selector populated from the current `MeloTTS` runtime voice list +- a refresh action to reload the available voice list from the local TTS service + +If the local TTS service is temporarily unavailable: + +- existing saved values remain visible +- the voice selector may be disabled or show an availability warning +- saving the toggle should still be allowed +- runtime execution will use the saved voice if available, otherwise the platform default voice + +If the saved voice is missing: + +- the UI should indicate that the saved voice is unavailable +- runtime execution automatically falls back to the platform default voice + +### Failure Messaging + +If text synthesis, audio transcoding, Feishu upload, or audio send fails: + +- do not alter or retract the completed text reply +- send one short text notice to Feishu, for example: + - `⚠️ 本次文字转语音失败,已仅保留文字回复。` + +Only one failure notice should be sent per completed reply, even if multiple internal steps fail. + +## Trigger Rules + +The approved trigger scope is: + +- run reply TTS for all Feishu flows that end in a completed streaming reply card + +This includes: + +- normal Feishu user message execution through `FeishuChannelService` +- Feishu card-action initiated execution through `FeishuCardActionService` +- other future Feishu flows that reuse the same streaming completion path + +This does not include: + +- partial streaming updates before completion +- standalone help cards that do not execute a reply stream +- Web-only or mobile-only reply flows outside Feishu + +## Functional Requirements + +### 1. User-Level Settings + +Add user-scoped Feishu bot settings for: + +- `ReplyTtsEnabled` +- `ReplyTtsVoiceId` + +These values are stored with the existing Feishu bot config record and are exposed through the existing admin APIs and admin modal models. + +The stored voice value should be the stable runtime voice identifier, not just a display label. + +### 2. Platform-Level Settings + +Add platform-level settings for: + +- `TtsStorageRoot` +- `AllowSystemDriveInstall` +- `TtsServiceBaseUrl` +- `TtsServiceTimeoutSeconds` +- `TtsPreferredDevice` +- `TtsDefaultVoiceId` +- `TtsChunkMaxChars` +- `FfmpegExecutablePath` + +These values are deployment concerns and must not be stored per user. + +### 3. Local TTS Service + +Run a local HTTP service that wraps `MeloTTS`. + +Required endpoints: + +- `GET /health` +- `GET /voices` +- `POST /synthesize` + +The `POST /synthesize` response should return synthesized audio bytes, preferably `wav`, rather than a machine-local file path contract. + +### 4. GPU and CPU Fallback + +The local `MeloTTS` service is responsible for choosing the actual runtime device. + +Rules: + +- attempt GPU first when configured to prefer GPU +- if CUDA or model initialization fails, fall back to CPU +- expose the actual device in `GET /health` +- WebCode should treat the service as a black box and should not implement its own GPU-selection logic + +### 5. Speech-Friendly Text Normalization + +Before synthesis, the completed reply must be normalized for speech: + +- strip markdown syntax +- avoid reading raw URLs verbatim +- replace code-heavy sections with a short summary cue +- preserve normal prose and list meaning where possible + +The output should still reflect the completed reply, but in a speech-friendly form rather than a literal character-for-character rendering of markdown and code. + +### 6. Chunking + +If the normalized text exceeds the per-chunk limit: + +- split by paragraph boundaries first +- split paragraphs by sentence boundaries second +- merge short neighboring sentences where this stays within the chunk limit +- if a single sentence still exceeds the limit, fall back to smaller punctuation or hard character boundaries + +The chunker should prioritize natural listening boundaries over fixed-width slicing. + +### 7. Sequential Audio Delivery + +For a single completed reply: + +- synthesize and send chunks in order +- send audio messages sequentially, not in parallel + +For a single Feishu chat: + +- queue reply TTS jobs sequentially to preserve message order and reduce local resource spikes + +The first version should prefer determinism and clarity over maximum throughput. + +### 8. Voice Fallback + +If the user-selected voice is unavailable at runtime: + +- automatically fall back to the platform default voice +- continue with synthesis if the default voice is available +- only treat the job as failed if no usable runtime voice remains + +### 9. Failure Behavior + +Any of the following should cause the TTS job to fail gracefully: + +- local TTS service unavailable +- synthesis failure +- `ffmpeg` failure +- Feishu file upload failure +- Feishu audio send failure + +When a job fails: + +- preserve the completed text reply +- stop processing remaining chunks for that reply +- send one short failure notice +- log detailed failure information server-side + +## Architecture + +### 1. Overall Topology + +The system is split into three layers: + +- `Feishu reply completion layer` + - existing streaming completion points in `FeishuChannelService` and `FeishuCardActionService` +- `WebCode TTS orchestration layer` + - settings lookup, text normalization, chunking, synthesize/transcode/upload/send orchestration +- `local MeloTTS service layer` + - health, voice discovery, waveform generation, device fallback + +This keeps TTS engine concerns separate from Feishu business flow concerns. + +### 2. Recommended WebCode Components + +Recommended boundaries: + +- `IReplyTtsOrchestrator` +- `ITtsEligibilityService` +- `ITtsSpeechTextNormalizer` +- `ITtsChunker` +- `IMeloTtsClient` +- `IAudioTranscodeService` +- `IFeishuAudioMessageService` +- `ITtsStoragePathResolver` + +The reply completion points should only trigger orchestration and should not directly implement audio-generation details. + +### 3. Completion Hooking + +After the existing completion flow finishes: + +- keep the existing completion notification text +- keep `FinishAsync(finalOutput)` +- keep normal assistant-message persistence +- then enqueue or fire a background reply TTS job + +The background TTS job must not delay the visible completion of the normal text response. + +## Deployment and Path Policy + +### 1. Storage Root + +All TTS-related writable data must live under a single storage root: + +- service files +- models +- caches +- temp files +- logs +- Python virtual environment +- optional `ffmpeg` install location + +Recommended root structure: + +- `/service` +- `/models` +- `/cache` +- `/temp` +- `/logs` +- `/venv` + +### 2. Environment Variables + +To prevent libraries from silently writing to undesired locations, the runtime must explicitly redirect: + +- `HF_HOME` +- `TRANSFORMERS_CACHE` +- `TORCH_HOME` +- `TEMP` +- `TMP` +- `PIP_CACHE_DIR` + +These should all resolve under `TtsStorageRoot`. + +### 3. Windows Rules + +If `TtsStorageRoot` is explicitly configured: + +- use it exactly + +If `TtsStorageRoot` is not configured: + +- scan for the first writable non-system drive +- choose a default path such as `:\WebCodeData\MeloTTS` + +If the machine only has `C:` and `AllowSystemDriveInstall = false`: + +- local reply TTS is unavailable +- the system must not silently install to `C:` +- management UI should clearly explain that only the system drive is available and policy forbids using it + +If administrators want to allow `C:`: + +- they must explicitly configure `AllowSystemDriveInstall = true` +- or explicitly set `TtsStorageRoot` to a `C:` path + +### 4. Non-Windows Rules + +If `TtsStorageRoot` is explicitly configured: + +- use it exactly + +If `TtsStorageRoot` is not configured: + +- use a writable default such as `/data/webcode/melotts` + +If the default is not writable: + +- require explicit administrator configuration +- do not chain through a long list of silent fallbacks + +## Runtime Flow + +For one completed Feishu reply: + +1. reply text completes normally +2. existing completion notification is sent +3. reply TTS enablement is checked for the bound Web user +4. completed reply text is normalized for speech +5. runtime voice is resolved +6. missing voice falls back to the platform default +7. text is split into ordered chunks +8. each chunk is synthesized to `wav` +9. each `wav` is transcoded to `opus` +10. each `opus` file is uploaded to Feishu +11. each uploaded file is sent as an `audio` message in order +12. failure at any step stops the remaining chunks and emits one short failure notice + +## Admin and API Surface + +The admin UI should call WebCode-owned APIs rather than talking directly to the local TTS service. + +Recommended admin-facing endpoints: + +- `GET /api/admin/feishu-tts/health` +- `GET /api/admin/feishu-tts/voices` + +This preserves one stable browser-facing trust boundary and keeps the local Python service private to the host machine. + +## Operational Requirements + +- the local TTS service should produce structured logs including device choice, selected voice, chunk counts, and failures +- temp files should be deleted after success +- failed jobs may retain temp files briefly for diagnostics +- a cleanup task should delete stale temp artifacts after a bounded retention window +- health checks should reveal whether the service is running on GPU or CPU + +## Testing Strategy + +### Unit-Level + +- user setting mapping and persistence +- path resolution across Windows and non-Windows cases +- Windows-only-`C:` unavailability behavior +- speech text normalization +- chunking logic +- voice fallback resolution + +### Integration-Level + +- local `MeloTTS` service health and voice enumeration +- successful synthesis to `wav` +- successful `wav` to `opus` transcode +- Feishu upload and `audio` send with a stubbed or test client + +### End-to-End + +- normal Feishu message reply completion followed by audio +- Feishu card-action initiated reply completion followed by audio +- missing saved voice with default-voice fallback +- TTS failure path with a single text failure notice +- oversized replies split into multiple ordered audio messages + +## Design Principles + +1. Keep normal text completion authoritative and non-blocking. +2. Treat TTS as an asynchronous Feishu post-processing pipeline. +3. Keep `MeloTTS` engine concerns behind a narrow local HTTP service. +4. Never silently fall back to Windows `C:` when policy forbids it. +5. Prefer explicit operational behavior over clever hidden fallback chains. +6. Preserve user trust by sending either ordered audio or one clear failure notice, never a noisy mix of partial errors. diff --git a/docs/superpowers/specs/2026-05-05-feishu-streaming-card-section-separation-design.md b/docs/superpowers/specs/2026-05-05-feishu-streaming-card-section-separation-design.md new file mode 100644 index 0000000..e5c3050 --- /dev/null +++ b/docs/superpowers/specs/2026-05-05-feishu-streaming-card-section-separation-design.md @@ -0,0 +1,75 @@ +# Feishu Streaming Card Section Separation Design + +Date: 2026-05-05 +Status: approved in discussion, pending implementation planning + +## Goal + +Make the Feishu streaming reply card clearly separate its thinking-level area, reply-content area, and bottom Superpowers workflow area. + +The change must: + +- target the Feishu CardKit streaming card, not the Web Razor UI +- preserve existing Feishu actions, prompt submission, and card update behavior +- create obvious section boundaries even if CardKit does not support custom `hr` colors +- simulate the requested red divider effect using card content structure instead of Web CSS + +## Scope + +In scope: + +- Feishu streaming card element construction +- Feishu streaming card refresh-card construction +- tests covering Feishu card JSON structure + +Out of scope: + +- Web desktop and mobile cards +- Feishu session launch logic +- Superpowers prompt/action semantics +- provider/model/reasoning switch behavior + +## Problem + +The current Feishu streaming card only inserts default `hr` modules between status, top chips, markdown content, and bottom actions. + +That means: + +- the thinking-level controls do not read as a separate section strongly enough +- the reply body and bottom workflow area still feel visually adjacent +- the requested red divider effect cannot appear because the current card JSON never emits any explicit red visual element + +## Design + +Use structural section markers instead of CSS dividers. + +Each visible major section gets a dedicated marker row rendered as card content: + +- `🟥🟥🟥 思考等级` +- `🟥🟥🟥 回复内容` +- `🟥🟥🟥 Superpowers 工作流` + +These markers act as the red visual divider simulation. They are stable across Feishu rendering because they rely on plain card content, not unsupported CSS classes. + +## Architecture + +Create one shared streaming-card element builder used by: + +- `FeishuCardKitClient` create/update rendering path +- `FeishuCardActionService` refresh-card rendering path + +The shared builder should: + +- render status area first +- render the thinking-level section marker only when top chip groups exist +- render the reply-content section marker before the markdown body when chrome exists +- render the workflow section marker only when bottom prompt or bottom actions exist + +## Verification + +Verify by tests that: + +- section markers are present in the generated Feishu card JSON +- top chip rows remain before reply content +- reply content remains before bottom prompt/actions +- prompt and action payloads remain unchanged diff --git a/docs/superpowers/specs/2026-05-05-streaming-reply-card-section-separation-design.md b/docs/superpowers/specs/2026-05-05-streaming-reply-card-section-separation-design.md new file mode 100644 index 0000000..1ff9050 --- /dev/null +++ b/docs/superpowers/specs/2026-05-05-streaming-reply-card-section-separation-design.md @@ -0,0 +1,227 @@ +# Streaming Reply Card Section Separation Design + +Date: 2026-05-05 +Status: approved in discussion, pending implementation planning + +## Goal + +Make the Web streaming assistant reply card visually separate its main content area, thinking/status area, and bottom Superpowers workflow area so users can distinguish reading content from operating controls at a glance. + +The change must: + +- affect only the in-flight streaming reply card +- keep all existing behaviors, events, and prompt routing unchanged +- add clear visual separation between content and control areas +- use a noticeable red horizontal divider between sections +- keep desktop Web and mobile Web visually aligned + +This is a presentation refinement. It must not introduce new workflow logic, new toggles, or new state transitions. + +## Scope + +In scope: + +- streaming assistant reply card in the shared desktop message-list component +- streaming assistant reply block in the mobile page implementation +- spacing, borders, section wrappers, and light background treatment +- red divider styling between sections + +Out of scope: + +- completed assistant message cards +- Feishu streaming cards +- session launch override dialog behavior +- model or reasoning switch logic +- Superpowers quick-action behavior, text, or eligibility rules + +## Problem + +The current streaming reply card visually reads as one continuous block: + +- the live reply content +- the thinking/status area +- the bottom Superpowers workflow controls + +Because these areas sit close together and share similar card treatment, the card does not provide a strong visual boundary between "read the reply" and "operate on the reply." + +The result is avoidable ambiguity: + +- users can read the workflow block as part of the reply body +- the thinking/status area does not feel like a distinct band +- the workflow controls appear attached to the text rather than clearly separated from it + +The requested change is specifically to make these areas obviously distinct without redesigning the interaction model. + +## User Experience + +### Target Sections + +The streaming reply card should be perceived as three stacked sections: + +1. live reply content +2. thinking/status band +3. Superpowers workflow block + +The user should be able to scan the card and immediately tell which area is content and which area is operational UI. + +### Divider Treatment + +Between adjacent sections, render a visible red divider using a top border treatment such as: + +- `border-t` +- `border-red-300` or `border-red-400` +- matching top padding and top margin to create a real break rather than a hairline only + +The divider should be obvious but not alarm-like. The intent is structural separation, not error signaling. + +### Content Area + +The main streaming markdown output remains the primary reading surface. + +It should: + +- stay visually neutral +- preserve current typography and overflow behavior +- remain the first section in the card + +### Thinking/Status Area + +The thinking/status area remains in the same card and keeps its current semantic role. + +It should: + +- stay between content and workflow controls +- receive distinct spacing from both neighboring sections +- retain a light neutral or light blue presentation + +This area is not being redesigned into a new control surface. The goal is only to make it feel separate from the reply body and from the workflow controls below it. + +### Superpowers Workflow Area + +The Superpowers block remains the bottom operational area. + +It should: + +- keep its existing blue semantic styling +- remain functionally identical +- feel clearly downstream of the content and status areas + +## Functional Requirements + +### Behavior Preservation + +The implementation must not change: + +- streaming text updates +- loading-state behavior +- button enabled and disabled rules +- input enabled and disabled rules +- existing quick-action prompts +- existing event handlers or callback wiring + +### Placement Preservation + +The implementation must preserve the current relative order: + +1. live reply content +2. thinking/status band +3. Superpowers block + +### Responsive Consistency + +Desktop and mobile should follow the same section-separation idea: + +- same three conceptual sections +- same red divider treatment +- same no-behavior-change rule + +Layout can still adapt to viewport size, but the separation pattern should remain consistent. + +## Design Principles + +1. Prefer visual structure over new interaction. +2. Keep the change local to the streaming reply surface. +3. Preserve current semantics and workflow behavior. +4. Make separation obvious enough to be noticed immediately. +5. Avoid expanding scope into completed-message redesign. + +## Architecture + +### 1. Shared Desktop Streaming Card + +Update the loading-state assistant card in: + +- `WebCodeCli/Components/ChatMessageListPanel.razor` + +Recommended structural treatment: + +- wrap the live markdown area in its own section container +- wrap the thinking/status strip in its own section container +- keep the existing Superpowers block in its own bottom section +- insert red divider boundaries between section wrappers + +The existing card container remains the same overall component boundary. + +### 2. Mobile Streaming Reply Block + +Update the streaming reply block in: + +- `WebCodeCli/Pages/CodeAssistantMobile.razor` + +The mobile view already splits some blocks spatially, but it should still use the same clear divider logic so the relationship between content, status, and workflow remains consistent with desktop. + +### 3. Styling Strategy + +Preferred implementation strategy: + +- use existing utility classes in Razor markup +- avoid adding new dependencies +- avoid introducing broad global CSS when local utility-class changes are sufficient + +Recommended styling ingredients: + +- `mt-*` +- `pt-*` +- `border-t` +- `border-red-300` or `border-red-400` +- subtle section background contrast where useful + +## Implementation Notes + +### Desktop + +For the shared streaming card: + +- keep the header row unchanged +- make the live reply content a dedicated first body section +- add a red divider before the thinking/status section +- add another red divider before the Superpowers block + +### Mobile + +For the mobile streaming reply: + +- keep the streaming message bubble behavior unchanged +- add matching red-divider separation before the bottom workflow block +- if the thinking/status affordance is represented inline, separate it with spacing and section treatment rather than moving its behavior + +## Verification + +Verify the following after implementation: + +- desktop streaming reply content and Superpowers block no longer read as a single visual area +- desktop card shows obvious red section dividers +- mobile streaming reply follows the same separation pattern +- action buttons still render correctly on narrow widths +- disabled states remain unchanged during streaming +- live markdown still updates without layout breakage + +## Risks + +- if the red divider is too saturated, it may read like an error state instead of a structural divider +- if spacing increases too much, the streaming card may become overly tall on mobile +- if scope expands into completed-message cards, the diff becomes larger than intended + +## Recommendation + +Implement the smallest possible markup and utility-class changes that create unmistakable section boundaries in the streaming reply card, and stop there. This should remain a focused visual refinement rather than a broader card redesign. diff --git a/installer/windows/WebCode.iss b/installer/windows/WebCode.iss index fab81dd..1a25315 100644 --- a/installer/windows/WebCode.iss +++ b/installer/windows/WebCode.iss @@ -6,6 +6,10 @@ #error PublishDir must be provided. #endif +#ifndef TtsBundleDir + #error TtsBundleDir must be provided. +#endif + #ifndef OutputDir #define OutputDir "." #endif @@ -56,10 +60,15 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; Flags: unchecked Name: "{app}\data" Name: "{app}\logs" Name: "{app}\workspaces" +Name: "{code:GetReplyTtsInstallRoot}\cache" +Name: "{code:GetReplyTtsInstallRoot}\logs" +Name: "{code:GetReplyTtsInstallRoot}\service" +Name: "{code:GetReplyTtsInstallRoot}\temp" [Files] Source: "{#PublishDir}\*"; DestDir: "{app}"; Excludes: "appsettings.json"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "{#PublishDir}\appsettings.json"; DestDir: "{app}"; Flags: onlyifdoesntexist ignoreversion +Source: "{#TtsBundleDir}\*"; DestDir: "{code:GetReplyTtsInstallRoot}"; Flags: ignoreversion recursesubdirs createallsubdirs [Icons] Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppSourceExe}" @@ -67,3 +76,245 @@ Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppSourceExe}"; Tasks: [Run] Filename: "{app}\{#MyAppSourceExe}"; Description: "Launch {#MyAppName}"; Flags: nowait postinstall skipifsilent + +[Code] +const + DRIVE_FIXED = 3; + +var + ReplyTtsDirPage: TInputDirWizardPage; + +function GetDriveType(lpRootPathName: string): Integer; + external 'GetDriveTypeW@kernel32.dll stdcall'; + +function NormalizePathSeparators(Value: string): string; +begin + Result := Trim(Value); + if Result <> '' then + StringChangeEx(Result, '/', '\', True); +end; + +function NormalizeDriveRoot(Value: string): string; +var + Candidate: string; +begin + Candidate := NormalizePathSeparators(Value); + Result := ExtractFileDrive(Candidate); + + if (Result = '') and (Length(Candidate) >= 2) and (Candidate[2] = ':') then + Result := Copy(Candidate, 1, 2); + + if Result <> '' then + Result := Uppercase(Result) + '\'; +end; + +function NormalizeReplyTtsInstallRoot(Value: string): string; +begin + Result := NormalizePathSeparators(Value); + while (Length(Result) > 3) and (Result[Length(Result)] = '\') do + Delete(Result, Length(Result), 1); +end; + +function GetSystemDriveRoot: string; +begin + Result := NormalizeDriveRoot(ExpandConstant('{sys}')); + if Result = '' then + Result := 'C:\'; +end; + +function IsSystemDrivePath(Value: string): Boolean; +begin + Result := NormalizeDriveRoot(Value) = GetSystemDriveRoot; +end; + +function CanWriteToDrive(DriveRoot: string): Boolean; +var + ProbeDir: string; + ProbeFile: string; +begin + Result := False; + ProbeDir := AddBackslash(NormalizeDriveRoot(DriveRoot)) + + '.webcode-kokoro-probe-' + GetDateTimeString('yyyymmddhhnnsszzz', #0, #0); + ProbeFile := AddBackslash(ProbeDir) + 'probe.tmp'; + + if not ForceDirectories(ProbeDir) then + Exit; + + if not SaveStringToFile(ProbeFile, 'probe', False) then begin + RemoveDir(ProbeDir); + Exit; + end; + + Result := True; + DeleteFile(ProbeFile); + RemoveDir(ProbeDir); +end; + +function IsWritableFixedNonSystemDriveRoot(Value: string): Boolean; +var + DriveRoot: string; +begin + DriveRoot := NormalizeDriveRoot(Value); + Result := + (DriveRoot <> '') and + DirExists(DriveRoot) and + (GetDriveType(DriveRoot) = DRIVE_FIXED) and + (not IsSystemDrivePath(DriveRoot)) and + CanWriteToDrive(DriveRoot); +end; + +function FindExistingReplyTtsInstallRoot: string; +var + DriveIndex: Integer; + DriveRoot: string; + CandidateRoot: string; +begin + Result := ''; + + for DriveIndex := Ord('A') to Ord('Z') do begin + DriveRoot := Chr(DriveIndex) + ':\'; + if not IsWritableFixedNonSystemDriveRoot(DriveRoot) then + Continue; + + CandidateRoot := NormalizeReplyTtsInstallRoot(DriveRoot + 'WebCodeData\Kokoro'); + if DirExists(CandidateRoot) then begin + Result := CandidateRoot; + Exit; + end; + end; +end; + +function GetFirstWritableNonSystemDriveRoot: string; +var + DriveIndex: Integer; + DriveRoot: string; +begin + Result := ''; + + for DriveIndex := Ord('A') to Ord('Z') do begin + DriveRoot := Chr(DriveIndex) + ':\'; + if IsWritableFixedNonSystemDriveRoot(DriveRoot) then begin + Result := DriveRoot; + Exit; + end; + end; +end; + +function GetDefaultReplyTtsInstallRoot: string; +var + PreviousRoot: string; + ExistingRoot: string; + DriveRoot: string; +begin + PreviousRoot := NormalizeReplyTtsInstallRoot(GetPreviousData('ReplyTtsInstallRoot', '')); + if (PreviousRoot <> '') and IsWritableFixedNonSystemDriveRoot(PreviousRoot) then begin + Result := PreviousRoot; + Exit; + end; + + ExistingRoot := FindExistingReplyTtsInstallRoot; + if ExistingRoot <> '' then begin + Result := ExistingRoot; + Exit; + end; + + DriveRoot := GetFirstWritableNonSystemDriveRoot; + if DriveRoot <> '' then begin + Result := NormalizeReplyTtsInstallRoot(DriveRoot + 'WebCodeData\Kokoro'); + Exit; + end; + + Result := ''; +end; + +function InitializeSetup(): Boolean; +begin + Result := GetFirstWritableNonSystemDriveRoot <> ''; + if not Result then + MsgBox( + 'WebCode cannot install the bundled Reply TTS payload because this Windows machine has no writable non-system fixed drive. ' + + 'Attach or map a writable data drive, then run Setup again.', + mbCriticalError, + MB_OK); +end; + +procedure InitializeWizard; +begin + ReplyTtsDirPage := CreateInputDirPage( + wpSelectDir, + 'Reply TTS Storage', + 'Where should the bundled Reply TTS payload be installed?', + 'Setup installs the Kokoro/sherpa-onnx model, ffmpeg, Python runtime, and dependencies to a writable non-system drive.', + False, + SetupMessage(msgNewFolderName)); + + ReplyTtsDirPage.Add('Reply TTS storage root:'); + ReplyTtsDirPage.Values[0] := GetDefaultReplyTtsInstallRoot; +end; + +function NextButtonClick(CurPageID: Integer): Boolean; +var + CandidateRoot: string; + DriveRoot: string; +begin + Result := True; + + if (ReplyTtsDirPage <> nil) and (CurPageID = ReplyTtsDirPage.ID) then begin + CandidateRoot := NormalizeReplyTtsInstallRoot(ReplyTtsDirPage.Values[0]); + DriveRoot := NormalizeDriveRoot(CandidateRoot); + + if CandidateRoot = '' then begin + MsgBox('Choose a Reply TTS storage root on a writable non-system drive.', mbError, MB_OK); + Result := False; + Exit; + end; + + if DriveRoot = '' then begin + MsgBox('Reply TTS storage root must be an absolute Windows path such as E:\WebCodeData\Kokoro.', mbError, MB_OK); + Result := False; + Exit; + end; + + if CandidateRoot = DriveRoot then + CandidateRoot := NormalizeReplyTtsInstallRoot(DriveRoot + 'WebCodeData\Kokoro'); + + if IsSystemDrivePath(CandidateRoot) then begin + MsgBox('Reply TTS storage root must be on a non-system drive. Do not use the Windows system drive.', mbError, MB_OK); + Result := False; + Exit; + end; + + if GetDriveType(DriveRoot) <> DRIVE_FIXED then begin + MsgBox('Reply TTS storage root must be on a fixed local drive.', mbError, MB_OK); + Result := False; + Exit; + end; + + if not DirExists(DriveRoot) then begin + MsgBox('The selected Reply TTS drive is not available.', mbError, MB_OK); + Result := False; + Exit; + end; + + if not CanWriteToDrive(DriveRoot) then begin + MsgBox('The selected Reply TTS drive is not writable.', mbError, MB_OK); + Result := False; + Exit; + end; + + ReplyTtsDirPage.Values[0] := CandidateRoot; + end; +end; + +function GetReplyTtsInstallRoot(Param: string): string; +begin + if ReplyTtsDirPage <> nil then + Result := NormalizeReplyTtsInstallRoot(ReplyTtsDirPage.Values[0]) + else + Result := GetDefaultReplyTtsInstallRoot; +end; + +procedure RegisterPreviousData(PreviousDataKey: Integer); +begin + SetPreviousData(PreviousDataKey, 'ReplyTtsInstallRoot', GetReplyTtsInstallRoot('')); +end; diff --git a/skills/codex/webcode-local-windows-tts-installer/SKILL.md b/skills/codex/webcode-local-windows-tts-installer/SKILL.md new file mode 100644 index 0000000..a107ec7 --- /dev/null +++ b/skills/codex/webcode-local-windows-tts-installer/SKILL.md @@ -0,0 +1,60 @@ +--- +name: webcode-local-windows-tts-installer +description: Use when building a local Windows WebCode installer from this repo for machine testing, especially when the package must bundle the Kokoro or sherpa-onnx Reply TTS service, model files, ffmpeg, a private Python runtime, and non-system-drive deployment without publishing a GitHub Release. +--- + +# WebCode Local Windows TTS Installer + +## Overview + +Build the local WebCode Windows installer and portable ZIP with the bundled Reply TTS payload. Use this skill for local validation and handoff artifacts, not for branch push, tag push, or GitHub Release asset sync. + +For GitHub publishing, use `webcode-github-release` instead. + +## Workflow + +1. Confirm the current checkout is the WebCode repo, or pass `-RepoRoot`. +2. Prefer `Debug` when the user wants to try the installer locally before cleanup. Use `Release` only when the user asks for release-grade artifacts. +3. Run `scripts/build-local-installer.ps1` from this skill instead of reconstructing the command by hand. +4. Report the generated installer, portable ZIP, checksums, and release notes from `artifacts/windows-installer/vX.Y.Z/`. +5. If the user asks for runtime verification, smoke-test the generated `tts-bundle` by starting `tools/sherpa-kokoro-service/start.ps1` against it and checking `http://127.0.0.1:/health`. + +## Command + +Default local build: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build-local-installer.ps1 +``` + +Explicit repo root and release build: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build-local-installer.ps1 ` + -RepoRoot "D:\VSWorkshop\WebCode" ` + -Configuration Release +``` + +Override the TTS payload source root if autodetection picks the wrong drive: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build-local-installer.ps1 ` + -ReplyTtsSourceRoot "E:\WebCodeData\Kokoro" +``` + +## Rules + +- Treat `Directory.Build.props` as the version source of truth. +- Treat `tools/build-windows-installer.ps1` as the single source of truth for packaging behavior. Fix that script instead of adding ad hoc shell steps. +- Expect the TTS source root to be a complete payload root containing `models\kokoro-int8-multi-lang-v1_1`, `venv\Scripts\python.exe`, and a bundled `python\` directory. +- Do not rely on a target-machine Python installation. The local installer path for this workflow is the bundled private runtime plus the bundled venv. +- Do not move the TTS payload onto the Windows system drive. The installer must target a writable non-system fixed drive and fail clearly otherwise. +- If multiple non-system drives exist and autodetection chooses the wrong one, rerun with `-ReplyTtsSourceRoot`. +- Expect the artifact set to be `WebCode-Setup-vX.Y.Z-win-x64.exe`, `WebCode-vX.Y.Z-win-x64-portable.zip`, `SHA256SUMS.txt`, and `RELEASE_NOTES.md`. + +## Files + +- Skill wrapper: `scripts/build-local-installer.ps1` +- Repo build script: `\tools\build-windows-installer.ps1` +- Installer definition: `\installer\windows\WebCode.iss` +- TTS service payload: `\tools\sherpa-kokoro-service\` diff --git a/skills/codex/webcode-local-windows-tts-installer/scripts/build-local-installer.ps1 b/skills/codex/webcode-local-windows-tts-installer/scripts/build-local-installer.ps1 new file mode 100644 index 0000000..1134e5a --- /dev/null +++ b/skills/codex/webcode-local-windows-tts-installer/scripts/build-local-installer.ps1 @@ -0,0 +1,130 @@ +[CmdletBinding()] +param( + [string]$RepoRoot, + [string]$Configuration = "Debug", + [string]$Version, + [string]$RuntimeIdentifier = "win-x64", + [string]$OutputRoot, + [string]$ReplyTtsSourceRoot, + [string]$ReplyTtsFfmpegExecutablePath +) + +$ErrorActionPreference = "Stop" + +function Test-IsWebCodeRepoRoot { + param([string]$CandidateRoot) + + if ([string]::IsNullOrWhiteSpace($CandidateRoot)) { + return $false + } + + $resolvedRoot = [System.IO.Path]::GetFullPath($CandidateRoot) + return ( + (Test-Path (Join-Path $resolvedRoot "Directory.Build.props")) -and + (Test-Path (Join-Path $resolvedRoot "tools\build-windows-installer.ps1")) -and + (Test-Path (Join-Path $resolvedRoot "installer\windows\WebCode.iss")) + ) +} + +function Resolve-WebCodeRepoRoot { + param([string]$RequestedRepoRoot) + + if (-not [string]::IsNullOrWhiteSpace($RequestedRepoRoot)) { + if (-not (Test-IsWebCodeRepoRoot -CandidateRoot $RequestedRepoRoot)) { + throw "Repo root '$RequestedRepoRoot' does not look like a WebCode checkout." + } + + return [System.IO.Path]::GetFullPath($RequestedRepoRoot) + } + + $directory = Get-Location + while ($directory -ne $null) { + if (Test-IsWebCodeRepoRoot -CandidateRoot $directory.Path) { + return $directory.Path + } + + $directory = $directory.Parent + } + + throw "Could not locate the WebCode repo root from the current working directory. Pass -RepoRoot explicitly." +} + +function Get-BuildVersion { + param( + [string]$RequestedVersion, + [string]$ResolvedRepoRoot + ) + + if (-not [string]::IsNullOrWhiteSpace($RequestedVersion)) { + return $RequestedVersion.TrimStart("v") + } + + [xml]$props = Get-Content -Path (Join-Path $ResolvedRepoRoot "Directory.Build.props") + $resolvedVersion = $props.Project.PropertyGroup.Version + if (-not $resolvedVersion) { + throw "Could not resolve version from Directory.Build.props." + } + + return $resolvedVersion.TrimStart("v") +} + +$resolvedRepoRoot = Resolve-WebCodeRepoRoot -RequestedRepoRoot $RepoRoot +$resolvedVersion = Get-BuildVersion -RequestedVersion $Version -ResolvedRepoRoot $resolvedRepoRoot +$buildScriptPath = Join-Path $resolvedRepoRoot "tools\build-windows-installer.ps1" + +$buildParams = @{ + Configuration = $Configuration + RuntimeIdentifier = $RuntimeIdentifier +} + +if (-not [string]::IsNullOrWhiteSpace($Version)) { + $buildParams.Version = $Version +} + +if (-not [string]::IsNullOrWhiteSpace($OutputRoot)) { + $buildParams.OutputRoot = $OutputRoot +} + +if (-not [string]::IsNullOrWhiteSpace($ReplyTtsSourceRoot)) { + $buildParams.ReplyTtsSourceRoot = $ReplyTtsSourceRoot +} + +if (-not [string]::IsNullOrWhiteSpace($ReplyTtsFfmpegExecutablePath)) { + $buildParams.ReplyTtsFfmpegExecutablePath = $ReplyTtsFfmpegExecutablePath +} + +Push-Location $resolvedRepoRoot +try { + & $buildScriptPath @buildParams + if ($LASTEXITCODE -ne 0) { + throw "Local Windows installer build failed." + } +} +finally { + Pop-Location +} + +$resolvedOutputRoot = if ([string]::IsNullOrWhiteSpace($OutputRoot)) { + Join-Path $resolvedRepoRoot "artifacts\windows-installer" +} +elseif ([System.IO.Path]::IsPathRooted($OutputRoot)) { + [System.IO.Path]::GetFullPath($OutputRoot) +} +else { + [System.IO.Path]::GetFullPath((Join-Path $resolvedRepoRoot $OutputRoot)) +} + +$versionTag = "v$resolvedVersion" +$releaseRoot = Join-Path $resolvedOutputRoot $versionTag +$installerPath = Join-Path $releaseRoot "installer\WebCode-Setup-$versionTag-$RuntimeIdentifier.exe" +$portableZipPath = Join-Path $releaseRoot "WebCode-$versionTag-$RuntimeIdentifier-portable.zip" +$checksumsPath = Join-Path $releaseRoot "SHA256SUMS.txt" +$releaseNotesPath = Join-Path $releaseRoot "RELEASE_NOTES.md" + +Write-Host "" +Write-Host "Local Windows installer artifacts" +Write-Host "RepoRoot: $resolvedRepoRoot" +Write-Host "Installer: $installerPath" +Write-Host "Portable ZIP: $portableZipPath" +Write-Host "Checksums: $checksumsPath" +Write-Host "Release notes: $releaseNotesPath" diff --git a/tests/WebCodeCli.Tests/AdminControllerReplyTtsTests.cs b/tests/WebCodeCli.Tests/AdminControllerReplyTtsTests.cs new file mode 100644 index 0000000..767a64b --- /dev/null +++ b/tests/WebCodeCli.Tests/AdminControllerReplyTtsTests.cs @@ -0,0 +1,413 @@ +using Microsoft.AspNetCore.Mvc; +using WebCodeCli.Controllers; +using WebCodeCli.Domain.Common.Options; +using WebCodeCli.Domain.Domain.Model; +using WebCodeCli.Domain.Domain.Model.Channels; +using WebCodeCli.Domain.Domain.Service; +using WebCodeCli.Domain.Domain.Service.Adapters; +using WebCodeCli.Domain.Domain.Service.Channels; +using WebCodeCli.Domain.Repositories.Base.UserAccount; +using WebCodeCli.Domain.Repositories.Base.UserFeishuBotConfig; +using Xunit; + +namespace WebCodeCli.Tests; + +public sealed class AdminControllerReplyTtsTests +{ + [Fact] + public async Task GetFeishuBotConfig_ReturnsReplyTtsFields() + { + var configService = new StubUserFeishuBotConfigService + { + ConfigsByUsername = + { + ["alice"] = new UserFeishuBotConfigEntity + { + Username = "alice", + IsEnabled = true, + ReplyTtsEnabled = true, + ReplyTtsVoiceId = "voice-1" + } + } + }; + + var controller = CreateController(configService: configService); + + var result = await controller.GetFeishuBotConfig("alice"); + + var ok = Assert.IsType(result.Result); + var dto = Assert.IsType(ok.Value); + Assert.True(dto.ReplyTtsEnabled); + Assert.Equal("voice-1", dto.ReplyTtsVoiceId); + } + + [Fact] + public async Task GetFeishuBotConfig_WithoutConfig_ReturnsDefaultReplyTtsFields() + { + var configService = new StubUserFeishuBotConfigService + { + ConfigsByUsername = + { + ["alice"] = new UserFeishuBotConfigEntity + { + Username = "alice", + IsEnabled = true, + ReplyTtsEnabled = true, + ReplyTtsVoiceId = "voice-1" + } + } + }; + + var controller = CreateController(configService: configService); + + var result = await controller.GetFeishuBotConfig("bob"); + + var ok = Assert.IsType(result.Result); + var dto = Assert.IsType(ok.Value); + Assert.Equal("bob", dto.Username); + Assert.False(dto.ReplyTtsEnabled); + Assert.Null(dto.ReplyTtsVoiceId); + } + + [Fact] + public async Task SaveFeishuBotConfig_ForwardsReplyTtsFieldsIntoEntity() + { + var configService = new StubUserFeishuBotConfigService(); + var controller = CreateController(configService: configService); + + var result = await controller.SaveFeishuBotConfig("alice", new UserFeishuBotConfigDto + { + IsEnabled = true, + ReplyTtsEnabled = true, + ReplyTtsVoiceId = "voice-9" + }); + + Assert.IsType(result); + Assert.NotNull(configService.LastSavedConfig); + Assert.Equal("alice", configService.LastSavedConfig!.Username); + Assert.True(configService.LastSavedConfig.ReplyTtsEnabled); + Assert.Equal("voice-9", configService.LastSavedConfig.ReplyTtsVoiceId); + } + + [Fact] + public async Task SaveFeishuBotConfig_WhenReplyTtsEnabled_EnsuresTtsServiceStarted() + { + var platformService = new StubFeishuReplyTtsPlatformService(); + var controller = CreateController(platformService: platformService); + + var result = await controller.SaveFeishuBotConfig("alice", new UserFeishuBotConfigDto + { + IsEnabled = true, + ReplyTtsEnabled = true, + ReplyTtsVoiceId = "voice-9" + }); + + Assert.IsType(result); + Assert.Equal(1, platformService.EnsureStartedCallCount); + } + + [Fact] + public async Task SaveFeishuBotConfig_WhenReplyTtsDisabled_DoesNotStartTtsService() + { + var platformService = new StubFeishuReplyTtsPlatformService(); + var controller = CreateController(platformService: platformService); + + var result = await controller.SaveFeishuBotConfig("alice", new UserFeishuBotConfigDto + { + IsEnabled = true, + ReplyTtsEnabled = false + }); + + Assert.IsType(result); + Assert.Equal(0, platformService.EnsureStartedCallCount); + } + + [Fact] + public async Task GetFeishuTtsHealth_ReturnsPlatformHealthDto() + { + var platformService = new StubFeishuReplyTtsPlatformService + { + HealthResult = new FeishuReplyTtsHealthStatus + { + IsAvailable = true, + StorageRoot = @"D:\reply-tts", + ServiceStatus = "ok", + Device = "cpu", + DefaultVoiceId = "voice-default" + } + }; + var controller = CreateController(platformService: platformService); + + var result = await controller.GetFeishuTtsHealth(); + + var ok = Assert.IsType(result.Result); + var dto = Assert.IsType(ok.Value); + Assert.True(dto.IsAvailable); + Assert.Equal(@"D:\reply-tts", dto.StorageRoot); + Assert.Equal("ok", dto.ServiceStatus); + Assert.Equal("cpu", dto.Device); + Assert.Equal("voice-default", dto.DefaultVoiceId); + } + + [Fact] + public async Task GetFeishuTtsVoices_ReturnsPlatformVoiceDtos() + { + var platformService = new StubFeishuReplyTtsPlatformService + { + VoicesResult = + [ + new FeishuReplyTtsVoiceOption + { + VoiceId = "voice-a", + DisplayName = "Voice A" + }, + new FeishuReplyTtsVoiceOption + { + VoiceId = "voice-b", + DisplayName = "Voice B" + } + ] + }; + var controller = CreateController(platformService: platformService); + + var result = await controller.GetFeishuTtsVoices(); + + var ok = Assert.IsType(result.Result); + var dto = Assert.IsType>(ok.Value); + Assert.Collection( + dto, + voice => Assert.Equal("voice-a", voice.VoiceId), + voice => Assert.Equal("voice-b", voice.VoiceId)); + } + + [Fact] + public async Task GetFeishuTtsVoices_WhenPlatformReturnsEmptyList_ReturnsOkEmptyList() + { + var controller = CreateController(platformService: new StubFeishuReplyTtsPlatformService + { + VoicesResult = [] + }); + + var result = await controller.GetFeishuTtsVoices(); + + var ok = Assert.IsType(result.Result); + var dto = Assert.IsType>(ok.Value); + Assert.Empty(dto); + } + + private static AdminController CreateController( + StubUserFeishuBotConfigService? configService = null, + StubUserFeishuBotRuntimeService? runtimeService = null, + StubFeishuReplyTtsPlatformService? platformService = null) + { + return new AdminController( + new StubUserAccountService(), + new StubUserToolPolicyService(), + new StubUserWorkspacePolicyService(), + configService ?? new StubUserFeishuBotConfigService(), + runtimeService ?? new StubUserFeishuBotRuntimeService(), + new StubCliExecutorService(), + platformService ?? new StubFeishuReplyTtsPlatformService()); + } + + private sealed class StubUserFeishuBotConfigService : IUserFeishuBotConfigService + { + public Dictionary ConfigsByUsername { get; } = new(StringComparer.OrdinalIgnoreCase); + public UserFeishuBotConfigEntity? LastSavedConfig { get; private set; } + + public Task GetByUsernameAsync(string username) + { + return Task.FromResult( + ConfigsByUsername.TryGetValue(username, out var config) + ? Clone(config) + : null); + } + + public Task GetByAppIdAsync(string appId) + { + throw new NotSupportedException(); + } + + public Task SaveAsync(UserFeishuBotConfigEntity config) + { + LastSavedConfig = Clone(config); + return Task.FromResult(UserFeishuBotConfigSaveResult.Saved()); + } + + public Task DeleteAsync(string username) + { + throw new NotSupportedException(); + } + + public Task FindConflictingUsernameByAppIdAsync(string username, string? appId) + { + throw new NotSupportedException(); + } + + public Task> GetAutoStartCandidatesAsync() + { + throw new NotSupportedException(); + } + + public Task UpdateRuntimePreferenceAsync(string username, bool autoStartEnabled, DateTime? lastStartedAt = null) + { + throw new NotSupportedException(); + } + + public FeishuOptions GetSharedDefaults() + { + throw new NotSupportedException(); + } + + public Task GetEffectiveOptionsAsync(string? username) + { + throw new NotSupportedException(); + } + + public Task GetEffectiveOptionsByAppIdAsync(string? appId) + { + throw new NotSupportedException(); + } + + 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, + ReplyTtsEnabled = entity.ReplyTtsEnabled, + ReplyTtsVoiceId = entity.ReplyTtsVoiceId, + LastStartedAt = entity.LastStartedAt, + CreatedAt = entity.CreatedAt, + UpdatedAt = entity.UpdatedAt + }; + } + } + + private sealed class StubUserFeishuBotRuntimeService : IUserFeishuBotRuntimeService + { + public Task GetStatusAsync(string username, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task StartAsync(string username, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task StopAsync(string username, CancellationToken cancellationToken = default) + { + return Task.FromResult(new UserFeishuBotRuntimeStatus + { + Username = username, + State = UserFeishuBotRuntimeState.Stopped, + UpdatedAt = DateTime.Now + }); + } + } + + private sealed class StubUserAccountService : IUserAccountService + { + public Task EnsureSeedDataAsync() => throw new NotSupportedException(); + public Task> GetAllAsync() => throw new NotSupportedException(); + public Task> GetAllUsernamesAsync() => throw new NotSupportedException(); + public Task GetByUsernameAsync(string username) => throw new NotSupportedException(); + public Task ValidateCredentialsAsync(string username, string password) => throw new NotSupportedException(); + public Task CreateOrUpdateAsync(UserAccountEntity account, string? plainPassword = null, bool overwritePassword = true) => throw new NotSupportedException(); + public Task IsEnabledAsync(string username) => throw new NotSupportedException(); + public Task IsAdminAsync(string username) => throw new NotSupportedException(); + public Task SetStatusAsync(string username, string status) => throw new NotSupportedException(); + public Task UpdateLastLoginAsync(string username, DateTime? lastLoginAt = null) => throw new NotSupportedException(); + } + + private sealed class StubUserToolPolicyService : IUserToolPolicyService + { + public Task IsToolAllowedAsync(string username, string toolId) => throw new NotSupportedException(); + public Task> GetAllowedToolIdsAsync(string username, IEnumerable allToolIds) => throw new NotSupportedException(); + public Task> GetPolicyMapAsync(string username, IEnumerable allToolIds) => throw new NotSupportedException(); + public Task SaveAllowedToolsAsync(string username, IEnumerable allowedToolIds, IEnumerable allToolIds) => throw new NotSupportedException(); + } + + private sealed class StubUserWorkspacePolicyService : IUserWorkspacePolicyService + { + public Task> GetAllowedDirectoriesAsync(string username) => throw new NotSupportedException(); + public Task IsPathAllowedAsync(string username, string directoryPath) => throw new NotSupportedException(); + public Task SaveAllowedDirectoriesAsync(string username, IEnumerable allowedDirectories) => throw new NotSupportedException(); + } + + private sealed class StubCliExecutorService : ICliExecutorService + { + public ICliToolAdapter? GetAdapter(CliToolConfig tool) => throw new NotSupportedException(); + public ICliToolAdapter? GetAdapterById(string toolId) => throw new NotSupportedException(); + public bool SupportsStreamParsing(CliToolConfig tool) => throw new NotSupportedException(); + public string? GetCliThreadId(string sessionId) => throw new NotSupportedException(); + public void SetCliThreadId(string sessionId, string threadId) => throw new NotSupportedException(); + public Task ResetSessionRuntimeAsync(string sessionId, bool clearCliThreadId = true, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public IAsyncEnumerable ExecuteStreamAsync(string sessionId, string toolId, string userPrompt, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public bool SupportsLowInterruptionContinue(string toolId) => throw new NotSupportedException(); + public bool CanStartLowInterruptionContinue(string sessionId, string toolId) => throw new NotSupportedException(); + public IAsyncEnumerable ExecuteLowInterruptionContinueStreamAsync(string sessionId, string toolId, string? prompt = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public List GetAvailableTools(string? username = null) => []; + public CliToolConfig? GetTool(string toolId, string? username = null) => throw new NotSupportedException(); + public bool ValidateTool(string toolId, string? username = null) => throw new NotSupportedException(); + public void CleanupSessionWorkspace(string sessionId) => throw new NotSupportedException(); + public void CleanupExpiredWorkspaces() => throw new NotSupportedException(); + public string GetSessionWorkspacePath(string sessionId) => throw new NotSupportedException(); + public Task> GetToolEnvironmentVariablesAsync(string toolId, string? username = null) => throw new NotSupportedException(); + public Task SyncSessionCcSwitchSnapshotAsync(string sessionId, string? toolId = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task SyncCodexThreadProviderAsync(string sessionId, string? toolId = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task SaveToolEnvironmentVariablesAsync(string toolId, Dictionary envVars, string? username = null) => throw new NotSupportedException(); + public byte[]? GetWorkspaceFile(string sessionId, string relativePath) => throw new NotSupportedException(); + public byte[]? GetWorkspaceZip(string sessionId) => throw new NotSupportedException(); + public Task UploadFileToWorkspaceAsync(string sessionId, string fileName, byte[] fileContent, string? relativePath = null) => throw new NotSupportedException(); + public Task CreateFolderInWorkspaceAsync(string sessionId, string folderPath) => throw new NotSupportedException(); + public Task DeleteWorkspaceItemAsync(string sessionId, string relativePath, bool isDirectory) => throw new NotSupportedException(); + public Task MoveFileInWorkspaceAsync(string sessionId, string sourcePath, string targetPath) => throw new NotSupportedException(); + public Task CopyFileInWorkspaceAsync(string sessionId, string sourcePath, string targetPath) => throw new NotSupportedException(); + public Task RenameFileInWorkspaceAsync(string sessionId, string oldPath, string newName) => throw new NotSupportedException(); + public Task BatchDeleteFilesAsync(string sessionId, List relativePaths) => throw new NotSupportedException(); + public Task InitializeSessionWorkspaceAsync(string sessionId, string? projectId = null, bool includeGit = false) => throw new NotSupportedException(); + public void RefreshWorkspaceRootCache() => throw new NotSupportedException(); + } + + private sealed class StubFeishuReplyTtsPlatformService : IFeishuReplyTtsPlatformService + { + public FeishuReplyTtsHealthStatus HealthResult { get; set; } = new(); + + public IReadOnlyList VoicesResult { get; set; } = []; + + public int EnsureStartedCallCount { get; private set; } + + public Task GetHealthAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(HealthResult); + } + + public Task> GetVoicesAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(VoicesResult); + } + + public Task ResolveVoiceOrFallbackAsync(string? savedVoiceId, CancellationToken cancellationToken = default) + { + throw new NotSupportedException(); + } + + public Task EnsureServiceStartedAsync(CancellationToken cancellationToken = default) + { + EnsureStartedCallCount++; + return Task.FromResult(HealthResult); + } + } +} diff --git a/tests/WebCodeCli.Tests/AdminUserManagementModalStateTests.cs b/tests/WebCodeCli.Tests/AdminUserManagementModalStateTests.cs new file mode 100644 index 0000000..6ef6130 --- /dev/null +++ b/tests/WebCodeCli.Tests/AdminUserManagementModalStateTests.cs @@ -0,0 +1,127 @@ +using System.Reflection; +using WebCodeCli.Components; +using WebCodeCli.Domain.Domain.Model; +using WebCodeCli.Domain.Domain.Model.Channels; +using Xunit; + +namespace WebCodeCli.Tests; + +public sealed class AdminUserManagementModalStateTests +{ + private static readonly Type ModalType = typeof(AdminUserManagementModal); + + [Fact] + public void CreateDetailEditorSeed_PreservesCurrentDetailSections_WhenReloadingSameUser() + { + var editorType = GetNestedType("EditableUserModel"); + var feishuType = GetNestedType("EditableFeishuBotConfigModel"); + var summaryType = GetNestedType("UserSummaryDto"); + var method = GetStaticMethod("CreateDetailEditorSeed"); + + var currentEditor = Activator.CreateInstance(editorType, nonPublic: true)!; + SetProperty(currentEditor, "Username", "alice"); + SetProperty(currentEditor, "DisplayName", "Old Display"); + SetProperty(currentEditor, "Role", UserAccessConstants.UserRole); + SetProperty(currentEditor, "Enabled", true); + SetProperty(currentEditor, "HasStoredFeishuConfig", true); + SetProperty(currentEditor, "AllowedToolIds", new HashSet(StringComparer.OrdinalIgnoreCase) { "git", "shell" }); + SetProperty(currentEditor, "AllowedDirectoriesText", @"D:\workspace"); + + var currentFeishu = Activator.CreateInstance(feishuType, nonPublic: true)!; + SetProperty(currentFeishu, "AppId", "app-123"); + SetProperty(currentFeishu, "ReplyTtsEnabled", true); + SetProperty(currentFeishu, "ReplyTtsVoiceId", "voice-a"); + SetProperty(currentEditor, "FeishuBot", currentFeishu); + + var selectedUser = Activator.CreateInstance(summaryType, nonPublic: true)!; + SetProperty(selectedUser, "Username", "alice"); + SetProperty(selectedUser, "DisplayName", "New Display"); + SetProperty(selectedUser, "Role", UserAccessConstants.AdminRole); + SetProperty(selectedUser, "Status", UserAccessConstants.DisabledStatus); + SetProperty(selectedUser, "CreatedAt", new DateTime(2026, 5, 2, 10, 0, 0, DateTimeKind.Utc)); + + var seededEditor = method.Invoke(null, [selectedUser, currentEditor])!; + var seededFeishu = GetProperty(seededEditor, "FeishuBot"); + var seededTools = GetProperty>(seededEditor, "AllowedToolIds"); + + Assert.Equal("alice", GetProperty(seededEditor, "Username")); + Assert.Equal("New Display", GetProperty(seededEditor, "DisplayName")); + Assert.Equal(UserAccessConstants.AdminRole, GetProperty(seededEditor, "Role")); + Assert.False(GetProperty(seededEditor, "Enabled")); + Assert.True(GetProperty(seededEditor, "HasStoredFeishuConfig")); + Assert.Equal(@"D:\workspace", GetProperty(seededEditor, "AllowedDirectoriesText")); + Assert.NotSame(currentEditor, seededEditor); + Assert.NotSame(currentFeishu, seededFeishu); + Assert.NotSame(GetProperty>(currentEditor, "AllowedToolIds"), seededTools); + Assert.Equal(["git", "shell"], seededTools.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase).ToArray()); + Assert.Equal("app-123", GetProperty(seededFeishu, "AppId")); + Assert.Equal("voice-a", GetProperty(seededFeishu, "ReplyTtsVoiceId")); + Assert.True(GetProperty(seededFeishu, "ReplyTtsEnabled")); + } + + [Fact] + public void MergeReplyTtsPlatformState_PreservesVoiceCatalog_WhenVoiceRefreshFails() + { + var method = GetStaticMethod("MergeReplyTtsPlatformState"); + var currentHealth = new FeishuReplyTtsHealthStatus + { + IsAvailable = true, + Message = "Healthy", + DefaultVoiceId = "voice-a" + }; + IReadOnlyList currentVoices = + [ + new FeishuReplyTtsVoiceOption + { + VoiceId = "voice-a", + DisplayName = "Voice A" + } + ]; + var refreshedHealth = new FeishuReplyTtsHealthStatus + { + IsAvailable = true, + Message = "Healthy", + DefaultVoiceId = "voice-a" + }; + + var merged = method.Invoke(null, [currentHealth, currentVoices, refreshedHealth, null, null, "voice endpoint timed out"])!; + var mergedType = merged.GetType(); + var mergedHealth = (FeishuReplyTtsHealthStatus)mergedType.GetField("Item1")!.GetValue(merged)!; + var mergedVoices = (IReadOnlyList)mergedType.GetField("Item2")!.GetValue(merged)!; + + Assert.True(mergedHealth.IsAvailable); + Assert.Contains("voice endpoint timed out", mergedHealth.Message, StringComparison.OrdinalIgnoreCase); + Assert.Same(currentVoices, mergedVoices); + Assert.Single(mergedVoices); + Assert.Equal("voice-a", mergedVoices[0].VoiceId); + } + + private static Type GetNestedType(string name) + { + return ModalType.GetNestedType(name, BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Nested type '{name}' was not found."); + } + + private static MethodInfo GetStaticMethod(string name) + { + return ModalType.GetMethod(name, BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException($"Static method '{name}' was not found."); + } + + private static T GetProperty(object instance, string propertyName) + { + return (T)(GetPropertyInfo(instance.GetType(), propertyName).GetValue(instance) + ?? throw new InvalidOperationException($"Property '{propertyName}' on '{instance.GetType().Name}' was null.")); + } + + private static void SetProperty(object instance, string propertyName, object? value) + { + GetPropertyInfo(instance.GetType(), propertyName).SetValue(instance, value); + } + + private static PropertyInfo GetPropertyInfo(Type type, string propertyName) + { + return type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException($"Property '{propertyName}' was not found on '{type.Name}'."); + } +} diff --git a/tests/WebCodeCli.Tests/AdminUserManagementReplyTtsUiStateTests.cs b/tests/WebCodeCli.Tests/AdminUserManagementReplyTtsUiStateTests.cs new file mode 100644 index 0000000..aafb5da --- /dev/null +++ b/tests/WebCodeCli.Tests/AdminUserManagementReplyTtsUiStateTests.cs @@ -0,0 +1,141 @@ +using WebCodeCli.Domain.Domain.Model.Channels; +using WebCodeCli.Helpers; +using Xunit; + +namespace WebCodeCli.Tests; + +public sealed class AdminUserManagementReplyTtsUiStateTests +{ + [Fact] + public void Create_DisablesVoiceSelector_WhenReplyTtsIsOff() + { + var result = AdminUserManagementReplyTtsUiState.Create( + replyTtsEnabled: false, + savedVoiceId: "voice-a", + availableVoices: + [ + new FeishuReplyTtsVoiceOption + { + VoiceId = "voice-a", + DisplayName = "Voice A" + } + ], + platformIsAvailable: true, + platformMessage: null); + + Assert.True(result.IsVoiceSelectorDisabled); + } + + [Fact] + public void Create_DisablesVoiceSelector_WhenPlatformHealthIsUnavailable() + { + var result = AdminUserManagementReplyTtsUiState.Create( + replyTtsEnabled: true, + savedVoiceId: "voice-a", + availableVoices: + [ + new FeishuReplyTtsVoiceOption + { + VoiceId = "voice-a", + DisplayName = "Voice A" + } + ], + platformIsAvailable: false, + platformMessage: "Platform unavailable"); + + Assert.True(result.IsVoiceSelectorDisabled); + } + + [Fact] + public void Create_ReturnsFallbackWarning_WhenSavedVoiceIsMissing() + { + var result = AdminUserManagementReplyTtsUiState.Create( + replyTtsEnabled: true, + savedVoiceId: "voice-missing", + availableVoices: + [ + new FeishuReplyTtsVoiceOption + { + VoiceId = "voice-a", + DisplayName = "Voice A" + } + ], + platformIsAvailable: true, + platformMessage: null); + + Assert.Equal("Saved Feishu reply TTS voice 'voice-missing' is unavailable. Select a different voice before saving.", result.WarningMessage); + } + + [Fact] + public void Create_KeepsMissingSavedVoiceVisibleAsSyntheticOption() + { + var result = AdminUserManagementReplyTtsUiState.Create( + replyTtsEnabled: true, + savedVoiceId: "voice-missing", + availableVoices: + [ + new FeishuReplyTtsVoiceOption + { + VoiceId = "voice-a", + DisplayName = "Voice A" + } + ], + platformIsAvailable: true, + platformMessage: null); + + Assert.Collection( + result.VoiceOptions, + voice => + { + Assert.Equal("voice-missing", voice.VoiceId); + Assert.Equal("voice-missing (saved)", voice.DisplayName); + }, + voice => + { + Assert.Equal("voice-a", voice.VoiceId); + Assert.Equal("Voice A", voice.DisplayName); + }); + } + + [Fact] + public void Create_DoesNotWarnAboutMissingSavedVoice_WhenReplyTtsIsDisabled() + { + var result = AdminUserManagementReplyTtsUiState.Create( + replyTtsEnabled: false, + savedVoiceId: "voice-missing", + availableVoices: + [ + new FeishuReplyTtsVoiceOption + { + VoiceId = "voice-a", + DisplayName = "Voice A" + } + ], + platformIsAvailable: true, + platformMessage: null); + + Assert.True(result.IsVoiceSelectorDisabled); + Assert.Null(result.WarningMessage); + Assert.Equal("voice-missing", result.VoiceOptions[0].VoiceId); + } + + [Fact] + public void Create_ReturnsNoWarning_WhenPlatformIsHealthyAndVoicesExist() + { + var result = AdminUserManagementReplyTtsUiState.Create( + replyTtsEnabled: true, + savedVoiceId: "voice-a", + availableVoices: + [ + new FeishuReplyTtsVoiceOption + { + VoiceId = "voice-a", + DisplayName = "Voice A" + } + ], + platformIsAvailable: true, + platformMessage: "Healthy"); + + Assert.Null(result.WarningMessage); + } +} diff --git a/tests/WebCodeCli.Tests/FeishuStreamingErrorFormatterTests.cs b/tests/WebCodeCli.Tests/FeishuStreamingErrorFormatterTests.cs new file mode 100644 index 0000000..db1d975 --- /dev/null +++ b/tests/WebCodeCli.Tests/FeishuStreamingErrorFormatterTests.cs @@ -0,0 +1,29 @@ +using WebCodeCli.Domain.Domain.Model.Channels; +using Xunit; + +namespace WebCodeCli.Tests; + +public sealed class FeishuStreamingErrorFormatterTests +{ + [Fact] + public void AppendError_WhenContentExists_AppendsSeparatorAndBoldError() + { + var result = FeishuStreamingErrorFormatter.AppendError( + "第一段输出\n第二段输出", + "exceeded retry limit, last status: 429 Too Many Requests"); + + Assert.Equal( + "第一段输出\n第二段输出\n\n---\n\n**错误:exceeded retry limit, last status: 429 Too Many Requests**", + result); + } + + [Fact] + public void AppendError_WhenContentMissing_ReturnsOnlyBoldError() + { + var result = FeishuStreamingErrorFormatter.AppendError( + null, + "执行失败"); + + Assert.Equal("**错误:执行失败**", result); + } +} diff --git a/tests/WebCodeCli.Tests/GoalCapabilityServiceTests.cs b/tests/WebCodeCli.Tests/GoalCapabilityServiceTests.cs new file mode 100644 index 0000000..d1f9a5e --- /dev/null +++ b/tests/WebCodeCli.Tests/GoalCapabilityServiceTests.cs @@ -0,0 +1,55 @@ +using WebCodeCli.Domain.Domain.Service; +using Xunit; + +namespace WebCodeCli.Tests; + +public sealed class GoalCapabilityServiceTests +{ + [Fact] + public void ResolveWindowsCommandPath_PrefersCmdWrapper_FromPath() + { + var tempRoot = Path.Combine(Path.GetTempPath(), "webcode-goal-tests", Guid.NewGuid().ToString("N")); + var binDirectory = Path.Combine(tempRoot, "bin"); + Directory.CreateDirectory(binDirectory); + + try + { + var cmdPath = Path.Combine(binDirectory, "codex.cmd"); + var ps1Path = Path.Combine(binDirectory, "codex.ps1"); + File.WriteAllText(cmdPath, "@echo off"); + File.WriteAllText(ps1Path, "Write-Host test"); + + var resolved = GoalCapabilityService.ResolveWindowsCommandPath("codex", binDirectory); + + Assert.Equal(cmdPath, resolved, StringComparer.OrdinalIgnoreCase); + } + finally + { + Directory.Delete(tempRoot, recursive: true); + } + } + + [Fact] + public void ResolveCommandInvocation_WrapsCmdScripts_WithComSpec() + { + var invocation = GoalCapabilityService.ResolveCommandInvocation( + @"E:\npm-global\codex.cmd", + "--version", + comSpec: @"C:\Windows\System32\cmd.exe"); + + Assert.Equal(@"C:\Windows\System32\cmd.exe", invocation.FileName); + Assert.Equal(@"/d /c """"E:\npm-global\codex.cmd"" --version""", invocation.Arguments); + } + + [Fact] + public void ResolveCommandInvocation_WrapsPowerShellScripts_WithPowerShellHost() + { + var invocation = GoalCapabilityService.ResolveCommandInvocation( + @"E:\npm-global\codex.ps1", + "features list", + powershellPath: "powershell.exe"); + + Assert.Equal("powershell.exe", invocation.FileName); + Assert.Equal(@"-NoProfile -ExecutionPolicy Bypass -File ""E:\npm-global\codex.ps1"" features list", invocation.Arguments); + } +} diff --git a/tests/WebCodeCli.Tests/GoalQuickActionSubmissionHelperTests.cs b/tests/WebCodeCli.Tests/GoalQuickActionSubmissionHelperTests.cs new file mode 100644 index 0000000..762f81a --- /dev/null +++ b/tests/WebCodeCli.Tests/GoalQuickActionSubmissionHelperTests.cs @@ -0,0 +1,29 @@ +using WebCodeCli.Pages; +using Xunit; + +namespace WebCodeCli.Tests; + +public sealed class GoalQuickActionSubmissionHelperTests +{ + [Theory] + [InlineData("整理这个目标", "/goal 整理这个目标")] + [InlineData("/goal 整理这个目标", "/goal 整理这个目标")] + [InlineData(" 整理这个目标 ", "/goal 整理这个目标")] + public void BuildMessage_AppliesGoalPrefixRules(string input, string expected) + { + var result = GoalQuickActionSubmissionHelper.BuildMessage(input); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void BuildMessage_ReturnsNull_ForBlankQuickInput(string? input) + { + var result = GoalQuickActionSubmissionHelper.BuildMessage(input); + + Assert.Null(result); + } +} diff --git a/tests/WebCodeCli.Tests/SqliteConnectionStringResolverTests.cs b/tests/WebCodeCli.Tests/SqliteConnectionStringResolverTests.cs new file mode 100644 index 0000000..c9a64e3 --- /dev/null +++ b/tests/WebCodeCli.Tests/SqliteConnectionStringResolverTests.cs @@ -0,0 +1,83 @@ +using System.Data.SQLite; +using System.Reflection; +using WebCodeCli.Helpers; +using Xunit; + +namespace WebCodeCli.Tests; + +public sealed class SqliteConnectionStringResolverTests : IDisposable +{ + private readonly string _testRoot = Path.Combine(Path.GetTempPath(), "SqliteConnectionStringResolverTests", Guid.NewGuid().ToString("N")); + + [Fact] + public void Resolve_RebasesRelativeDataSourceUnderApplicationBaseDirectory_AndCreatesParentDirectory() + { + var applicationBaseDirectory = CreateDirectory("publish"); + var connectionString = "Data Source=data/WebCodeCli.db"; + + var resolved = InvokeResolve(connectionString, applicationBaseDirectory); + var builder = new SQLiteConnectionStringBuilder(resolved); + var expectedDatabasePath = Path.GetFullPath(Path.Combine(applicationBaseDirectory, "data", "WebCodeCli.db")); + + Assert.Equal(expectedDatabasePath, Path.GetFullPath(builder.DataSource)); + Assert.True(Directory.Exists(Path.Combine(applicationBaseDirectory, "data"))); + } + + [Fact] + public void Resolve_LeavesInMemoryConnectionStringUntouched() + { + const string connectionString = "Data Source=:memory:"; + + var resolved = InvokeResolve(connectionString, CreateDirectory("publish")); + + Assert.Equal(connectionString, resolved); + } + + [Fact] + public void Resolve_ReturnsConnectionStringThatAllowsSQLiteToCreateTheDatabaseFile() + { + var applicationBaseDirectory = CreateDirectory("publish"); + var resolved = InvokeResolve("Data Source=data/WebCodeCli.db", applicationBaseDirectory); + + using (var connection = new SQLiteConnection(resolved)) + { + connection.Open(); + } + + Assert.True(File.Exists(Path.Combine(applicationBaseDirectory, "data", "WebCodeCli.db"))); + } + + public void Dispose() + { + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, recursive: true); + } + } + + private string CreateDirectory(params string[] segments) + { + var path = Path.Combine(new[] { _testRoot }.Concat(segments).ToArray()); + Directory.CreateDirectory(path); + return path; + } + + private static string InvokeResolve(string connectionString, string applicationBaseDirectory) + { + var resolverType = typeof(WebRootPathResolver).Assembly.GetType("WebCodeCli.Helpers.SqliteConnectionStringResolver"); + Assert.NotNull(resolverType); + + var resolveMethod = resolverType!.GetMethod( + "Resolve", + BindingFlags.Public | BindingFlags.Static, + binder: null, + types: new[] { typeof(string), typeof(string) }, + modifiers: null); + + Assert.NotNull(resolveMethod); + + var resolved = resolveMethod!.Invoke(null, new object[] { connectionString, applicationBaseDirectory }); + + return Assert.IsType(resolved); + } +} diff --git a/tests/WebCodeCli.Tests/SuperpowersQuickActionSubmissionHelperTests.cs b/tests/WebCodeCli.Tests/SuperpowersQuickActionSubmissionHelperTests.cs index fb89952..536e64b 100644 --- a/tests/WebCodeCli.Tests/SuperpowersQuickActionSubmissionHelperTests.cs +++ b/tests/WebCodeCli.Tests/SuperpowersQuickActionSubmissionHelperTests.cs @@ -6,6 +6,16 @@ namespace WebCodeCli.Tests; public sealed class SuperpowersQuickActionSubmissionHelperTests { + [Fact] + public void BuildMessage_ReturnsContinuePrompt_ForContinueAction() + { + var result = SuperpowersQuickActionSubmissionHelper.BuildMessage( + SuperpowersQuickActionRequestType.Continue, + quickInput: null); + + Assert.Equal(SuperpowersQuickActionDefaults.ContinuePrompt, result); + } + [Fact] public void BuildMessage_ReturnsExecutePlanPrompt_ForExecutePlanAction() { @@ -27,9 +37,9 @@ public void BuildMessage_ReturnsExecuteSubagentPlanPrompt_ForExecuteSubagentPlan } [Theory] - [InlineData("整理这个 plan", "使用superpowers技能,整理这个 plan")] - [InlineData("使用superpowers技能,整理这个 plan", "使用superpowers技能,整理这个 plan")] - [InlineData(" 整理这个 plan ", "使用superpowers技能,整理这个 plan")] + [InlineData("整理这个 plan", "$superpowers ,使用superpowers技能,整理这个 plan")] + [InlineData("$superpowers ,使用superpowers技能,整理这个 plan", "$superpowers ,使用superpowers技能,整理这个 plan")] + [InlineData(" 整理这个 plan ", "$superpowers ,使用superpowers技能,整理这个 plan")] public void BuildMessage_AppliesQuickInputPrefixRules(string input, string expected) { var result = SuperpowersQuickActionSubmissionHelper.BuildMessage( diff --git a/tools/build-windows-installer.ps1 b/tools/build-windows-installer.ps1 index eb54224..65d375d 100644 --- a/tools/build-windows-installer.ps1 +++ b/tools/build-windows-installer.ps1 @@ -4,7 +4,9 @@ param( [string]$Configuration = "Release", [string]$RuntimeIdentifier = "win-x64", [string]$ProjectPath, - [string]$OutputRoot + [string]$OutputRoot, + [string]$ReplyTtsSourceRoot, + [string]$ReplyTtsFfmpegExecutablePath ) $ErrorActionPreference = "Stop" @@ -74,6 +76,268 @@ function Update-PublishAppSettings { [System.IO.File]::WriteAllText($settingsPath, $json, [System.Text.UTF8Encoding]::new($false)) } +function Copy-ReplyTtsServiceAssets { + param( + [string]$RepoRoot, + [string]$PublishDirectory + ) + + $sourceRoot = Join-Path $RepoRoot "tools\sherpa-kokoro-service" + if (-not (Test-Path $sourceRoot)) { + throw "Reply TTS service assets were not found at $sourceRoot" + } + + $destinationRoot = Join-Path $PublishDirectory "tools\sherpa-kokoro-service" + if (Test-Path $destinationRoot) { + Remove-Item -Recurse -Force $destinationRoot + } + + New-Item -ItemType Directory -Force -Path $destinationRoot | Out-Null + + foreach ($relativePath in @( + "README.md", + "requirements.txt", + "app.py", + "start.ps1", + "start.sh")) { + $sourcePath = Join-Path $sourceRoot $relativePath + if (-not (Test-Path $sourcePath)) { + throw "Required Reply TTS service asset was not found at $sourcePath" + } + + $destinationPath = Join-Path $destinationRoot $relativePath + $destinationParent = Split-Path -Parent $destinationPath + if (-not (Test-Path $destinationParent)) { + New-Item -ItemType Directory -Force -Path $destinationParent | Out-Null + } + + Copy-Item -Path $sourcePath -Destination $destinationPath -Force + } +} + +function Get-WindowsSystemDriveRoot { + $systemRoot = [Environment]::GetFolderPath([Environment+SpecialFolder]::System) + if (-not [string]::IsNullOrWhiteSpace($systemRoot)) { + return [System.IO.Path]::GetPathRoot($systemRoot) + } + + $systemDrive = [Environment]::GetEnvironmentVariable("SystemDrive") + if ([string]::IsNullOrWhiteSpace($systemDrive)) { + return "C:\" + } + + return "$($systemDrive.TrimEnd('\'))\" +} + +function Test-IsSameWindowsDrive { + param( + [string]$Left, + [string]$Right + ) + + if ([string]::IsNullOrWhiteSpace($Left) -or [string]::IsNullOrWhiteSpace($Right)) { + return $false + } + + $leftRoot = [System.IO.Path]::GetPathRoot([System.IO.Path]::GetFullPath($Left)) + $rightRoot = [System.IO.Path]::GetPathRoot([System.IO.Path]::GetFullPath($Right)) + + return $leftRoot.TrimEnd('\') -ieq $rightRoot.TrimEnd('\') +} + +function Resolve-ReplyTtsSourceRoot { + param([string]$RequestedSourceRoot) + + if (-not [string]::IsNullOrWhiteSpace($RequestedSourceRoot)) { + if (-not (Test-Path $RequestedSourceRoot)) { + throw "Reply TTS bundle source root was not found at $RequestedSourceRoot" + } + + return (Resolve-Path $RequestedSourceRoot).Path + } + + $systemDriveRoot = Get-WindowsSystemDriveRoot + $candidateRoots = [System.IO.DriveInfo]::GetDrives() | + Where-Object { + $_.DriveType -eq [System.IO.DriveType]::Fixed -and + $_.IsReady -and + -not (Test-IsSameWindowsDrive $_.RootDirectory.FullName $systemDriveRoot) + } | + Sort-Object Name | + ForEach-Object { Join-Path $_.RootDirectory.FullName "WebCodeData\Kokoro" } + + foreach ($candidateRoot in $candidateRoots) { + if ( + (Test-Path $candidateRoot) -and + (Test-Path (Join-Path $candidateRoot "models\kokoro-int8-multi-lang-v1_1")) -and + (Test-Path (Join-Path $candidateRoot "venv\Scripts\python.exe")) -and + (Test-Path (Join-Path $candidateRoot "python")) + ) { + return (Resolve-Path $candidateRoot).Path + } + } + + throw "Reply TTS bundle source root was not found on any writable non-system fixed drive. Pass -ReplyTtsSourceRoot explicitly." +} + +function Resolve-ReplyTtsFfmpegExecutablePath { + param( + [string]$RequestedExecutablePath, + [string]$SourceRoot + ) + + $candidatePaths = New-Object System.Collections.Generic.List[string] + + if (-not [string]::IsNullOrWhiteSpace($RequestedExecutablePath)) { + $candidatePaths.Add($RequestedExecutablePath) + } + + $candidatePaths.Add((Join-Path $SourceRoot "ffmpeg\bin\ffmpeg.exe")) + + $ffmpegCommand = Get-Command ffmpeg.exe -CommandType Application -ErrorAction SilentlyContinue + if ($ffmpegCommand) { + $candidatePaths.Add($ffmpegCommand.Source) + } + + $candidatePaths.Add("C:\Program Files\ImageMagick-7.1.0-Q16\ffmpeg.exe") + + foreach ($candidatePath in $candidatePaths | Select-Object -Unique) { + if (-not [string]::IsNullOrWhiteSpace($candidatePath) -and (Test-Path $candidatePath)) { + return (Resolve-Path $candidatePath).Path + } + } + + throw "Reply TTS ffmpeg executable was not found. Pass -ReplyTtsFfmpegExecutablePath explicitly." +} + +function Get-ReplyTtsBundledPythonHome { + param([string]$PythonRoot) + + if (-not (Test-Path $PythonRoot)) { + throw "Reply TTS python root was not found at $PythonRoot" + } + + $bundledPythonHome = Get-ChildItem -Path $PythonRoot -Directory | + Sort-Object Name | + Where-Object { Test-Path (Join-Path $_.FullName "python.exe") } | + Select-Object -First 1 + + if ($null -eq $bundledPythonHome) { + throw "Reply TTS python root at $PythonRoot does not contain a bundled python.exe" + } + + return $bundledPythonHome.FullName +} + +function Get-ReplyTtsBundleSourceLayout { + param( + [string]$RequestedSourceRoot, + [string]$RequestedFfmpegExecutablePath + ) + + $sourceRoot = Resolve-ReplyTtsSourceRoot -RequestedSourceRoot $RequestedSourceRoot + $modelRoot = Join-Path $sourceRoot "models\kokoro-int8-multi-lang-v1_1" + $venvRoot = Join-Path $sourceRoot "venv" + $pythonRoot = Join-Path $sourceRoot "python" + $venvPythonPath = Join-Path $venvRoot "Scripts\python.exe" + $venvConfigPath = Join-Path $venvRoot "pyvenv.cfg" + + if (-not (Test-Path $modelRoot)) { + throw "Reply TTS model directory was not found at $modelRoot" + } + + foreach ($requiredRelativePath in @( + "model.int8.onnx", + "voices.bin", + "tokens.txt", + "lexicon-us-en.txt", + "lexicon-zh.txt", + "date-zh.fst", + "phone-zh.fst", + "number-zh.fst", + "espeak-ng-data")) { + $requiredPath = Join-Path $modelRoot $requiredRelativePath + if (-not (Test-Path $requiredPath)) { + throw "Reply TTS model directory is incomplete. Missing $requiredPath" + } + } + + if (-not (Test-Path $venvPythonPath)) { + throw "Reply TTS bundled venv python was not found at $venvPythonPath" + } + + if (-not (Test-Path $venvConfigPath)) { + throw "Reply TTS bundled venv config was not found at $venvConfigPath" + } + + $bundledPythonHome = Get-ReplyTtsBundledPythonHome -PythonRoot $pythonRoot + $ffmpegExecutablePath = Resolve-ReplyTtsFfmpegExecutablePath ` + -RequestedExecutablePath $RequestedFfmpegExecutablePath ` + -SourceRoot $sourceRoot + + return [pscustomobject]@{ + SourceRoot = $sourceRoot + ModelRoot = $modelRoot + VenvRoot = $venvRoot + PythonRoot = $pythonRoot + BundledPythonHome = $bundledPythonHome + FfmpegExecutablePath = $ffmpegExecutablePath + } +} + +function Copy-DirectoryTree { + param( + [string]$Source, + [string]$Destination + ) + + if (-not (Test-Path $Source)) { + throw "Directory copy source was not found at $Source" + } + + $parentPath = Split-Path -Parent $Destination + if (-not [string]::IsNullOrWhiteSpace($parentPath) -and -not (Test-Path $parentPath)) { + New-Item -ItemType Directory -Force -Path $parentPath | Out-Null + } + + if (Test-Path $Destination) { + Remove-Item -Recurse -Force $Destination + } + + Copy-Item -Path $Source -Destination $Destination -Recurse -Force +} + +function Copy-ReplyTtsBundleAssets { + param( + [psobject]$SourceLayout, + [string]$BundleDirectory + ) + + if (Test-Path $BundleDirectory) { + Remove-Item -Recurse -Force $BundleDirectory + } + + New-Item -ItemType Directory -Force -Path $BundleDirectory | Out-Null + + Copy-DirectoryTree ` + -Source $SourceLayout.ModelRoot ` + -Destination (Join-Path $BundleDirectory "models\kokoro-int8-multi-lang-v1_1") + Copy-DirectoryTree ` + -Source $SourceLayout.PythonRoot ` + -Destination (Join-Path $BundleDirectory "python") + Copy-DirectoryTree ` + -Source $SourceLayout.VenvRoot ` + -Destination (Join-Path $BundleDirectory "venv") + + $ffmpegBinDirectory = Join-Path $BundleDirectory "ffmpeg\bin" + New-Item -ItemType Directory -Force -Path $ffmpegBinDirectory | Out-Null + Copy-Item -Path $SourceLayout.FfmpegExecutablePath -Destination (Join-Path $ffmpegBinDirectory "ffmpeg.exe") -Force + + foreach ($relativeDirectory in @("cache", "logs", "service", "temp")) { + New-Item -ItemType Directory -Force -Path (Join-Path $BundleDirectory $relativeDirectory) | Out-Null + } +} + function Get-LaunchUrlForReleaseNotes { param([string]$PublishDirectory) @@ -116,6 +380,9 @@ $repoRoot = Get-RepoRoot -ScriptRoot $PSScriptRoot $projectFullPath = (Resolve-Path $ProjectPath).Path $resolvedVersion = Get-BuildVersion -RequestedVersion $Version -RepoRoot $repoRoot $assemblyVersion = "$resolvedVersion.0" +$replyTtsSourceLayout = Get-ReplyTtsBundleSourceLayout ` + -RequestedSourceRoot $ReplyTtsSourceRoot ` + -RequestedFfmpegExecutablePath $ReplyTtsFfmpegExecutablePath if ($resolvedVersion -notmatch '^\d+\.\d+\.\d+$') { throw "Resolved version '$resolvedVersion' is not in major.minor.patch format." @@ -134,6 +401,7 @@ $portableDirectoryName = "WebCode-$versionTag-$RuntimeIdentifier-portable" $portableStageDirectory = Join-Path $releaseRoot $portableDirectoryName $portableZipPath = Join-Path $releaseRoot "$portableDirectoryName.zip" $installerOutputDirectory = Join-Path $releaseRoot "installer" +$ttsBundleDirectory = Join-Path $releaseRoot "tts-bundle" $installerBaseFileName = "WebCode-Setup-$versionTag-$RuntimeIdentifier" $installerPath = Join-Path $installerOutputDirectory "$installerBaseFileName.exe" $checksumsPath = Join-Path $releaseRoot "SHA256SUMS.txt" @@ -168,6 +436,8 @@ if (-not (Test-Path $publishedExePath)) { } Update-PublishAppSettings -PublishDirectory $publishDirectory +Copy-ReplyTtsServiceAssets -RepoRoot $repoRoot -PublishDirectory $publishDirectory +Copy-ReplyTtsBundleAssets -SourceLayout $replyTtsSourceLayout -BundleDirectory $ttsBundleDirectory $launchUrl = Get-LaunchUrlForReleaseNotes -PublishDirectory $publishDirectory if (Test-Path $portableStageDirectory) { @@ -188,6 +458,7 @@ Write-Host "Compiling Windows installer with Inno Setup ..." & $isccPath ` "/DMyAppVersion=$resolvedVersion" ` "/DPublishDir=$publishDirectory" ` + "/DTtsBundleDir=$ttsBundleDirectory" ` "/DOutputDir=$installerOutputDirectory" ` "/DMyAppInstallerFileName=$installerBaseFileName" ` "/DMyAppSourceExe=WebCodeCli.exe" ` @@ -220,8 +491,11 @@ $releaseNotes = @" - Built from commit $(git -C $repoRoot rev-parse --short HEAD) - Self-contained $RuntimeIdentifier build, no separate .NET runtime installation required - The installer keeps an existing appsettings.json on upgrade -- Default install path is `%LOCALAPPDATA%\Programs\WebCode` -- Default runtime data paths are `data/` and `workspaces/` under the install directory +- Default install path is %LOCALAPPDATA%\Programs\WebCode +- Default runtime data paths are data/ and workspaces/ under the install directory +- Includes the local Kokoro/sherpa-onnx Reply TTS wrapper under tools/sherpa-kokoro-service +- The Windows installer deploys the bundled Reply TTS model, ffmpeg, Python runtime, and venv to a writable non-system drive such as E:\WebCodeData\Kokoro +- The Windows installer stops with an error if only the Windows system drive is writable - After launch, open $launchUrl in the browser "@ [System.IO.File]::WriteAllText($releaseNotesPath, $releaseNotes.Trim() + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) @@ -232,3 +506,4 @@ Write-Host "Installer: $installerPath" Write-Host "Portable ZIP: $portableZipPath" Write-Host "Checksums: $checksumsPath" Write-Host "Release notes: $releaseNotesPath" +Write-Host "Reply TTS source root: $($replyTtsSourceLayout.SourceRoot)" diff --git a/tools/sherpa-kokoro-service/README.md b/tools/sherpa-kokoro-service/README.md new file mode 100644 index 0000000..ca9e18f --- /dev/null +++ b/tools/sherpa-kokoro-service/README.md @@ -0,0 +1,104 @@ +# Kokoro sherpa-onnx Local Service + +This directory contains the same-host Python wrapper used by WebCode reply TTS. + +When shipped inside a Windows WebCode release, this folder is placed under: + +```text +\tools\sherpa-kokoro-service +``` + +In the source repository, the same files live under: + +```text +\tools\sherpa-kokoro-service +``` + +- `GET /health` +- `GET /voices` +- `POST /synthesize` + +The service is intentionally Kokoro/sherpa-onnx only. There is no secondary engine fallback. If synthesis fails, WebCode keeps the already-delivered streaming text reply and stops audio generation. + +## Storage Policy + +Do not install this service, model, cache, virtual environment, or temporary files on the Windows system drive, normally `C:`. + +Approved Windows layout: + +```text +E:\WebCodeData\Kokoro\ + cache\ + pip\ + ffmpeg\ + bin\ + ffmpeg.exe + logs\ + models\ + kokoro-int8-multi-lang-v1_1\ + model.int8.onnx + voices.bin + tokens.txt + lexicon-us-en.txt + lexicon-zh.txt + date-zh.fst + phone-zh.fst + number-zh.fst + espeak-ng-data\ + service\ + temp\ + venv\ +``` + +If Windows has only the system drive and no non-system data drive, do not start the TTS service. Attach or map a non-system drive first, then set `FeishuReplyTts:TtsStorageRoot` to that path. + +Non-Windows default layout is `/data/webcode/kokoro`. + +## Install + +Install the Python runtime and virtual environment under the approved non-system-drive storage root. The example below uses `uv` only as the installer; the Python runtime, venv, packages, cache, temp files, and models all stay under `E:\WebCodeData\Kokoro`. + +```powershell +$env:UV_PYTHON_INSTALL_DIR = "E:\WebCodeData\Kokoro\python" +$env:UV_CACHE_DIR = "E:\WebCodeData\Kokoro\cache\uv" +$env:PIP_CACHE_DIR = "E:\WebCodeData\Kokoro\cache\pip" +$env:TEMP = "E:\WebCodeData\Kokoro\temp" +$env:TMP = "E:\WebCodeData\Kokoro\temp" +uv python install 3.9 + +E:\WebCodeData\Kokoro\python\cpython-3.9.23-windows-x86_64-none\python.exe -m venv E:\WebCodeData\Kokoro\venv +E:\WebCodeData\Kokoro\venv\Scripts\python.exe -m pip install --upgrade pip +E:\WebCodeData\Kokoro\venv\Scripts\python.exe -m pip install -r \tools\sherpa-kokoro-service\requirements.txt +``` + +Place the extracted `kokoro-int8-multi-lang-v1_1` model directory under: + +```text +E:\WebCodeData\Kokoro\models\kokoro-int8-multi-lang-v1_1 +``` + +## Windows Startup + +The script refuses Windows system-drive storage roots. If `-StorageRoot` is omitted on Windows, it selects the first writable fixed non-system drive; if no such drive exists, it exits before creating any service directories. On non-Windows PowerShell, the default storage root is `/data/webcode/kokoro`. + +```powershell +cd \tools\sherpa-kokoro-service +.\start.ps1 -StorageRoot E:\WebCodeData\Kokoro -Port 5058 -Python E:\WebCodeData\Kokoro\venv\Scripts\python.exe +``` + +## Non-Windows Startup + +```bash +cd /tools/sherpa-kokoro-service +chmod +x start.sh +./start.sh /data/webcode/kokoro +``` + +## Manual Smoke Check + +```powershell +Invoke-WebRequest -UseBasicParsing http://127.0.0.1:5058/health +Invoke-WebRequest -UseBasicParsing http://127.0.0.1:5058/voices +``` + +Healthy `/health` output has `status = "ok"` and `device = "cpu"`. diff --git a/tools/sherpa-kokoro-service/app.py b/tools/sherpa-kokoro-service/app.py new file mode 100644 index 0000000..c300180 --- /dev/null +++ b/tools/sherpa-kokoro-service/app.py @@ -0,0 +1,436 @@ +from __future__ import annotations + +import io +import logging +import ntpath +import os +import sys +import wave +from array import array +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any, Callable, Protocol + +import uvicorn +from fastapi import FastAPI, HTTPException, Response +from pydantic import BaseModel + +try: + from pydantic import field_validator +except ImportError: # pragma: no cover - compatibility path + from pydantic import validator as field_validator + + +LOGGER = logging.getLogger("sherpa-kokoro-service") + +DEFAULT_MODEL_NAME = "kokoro-int8-multi-lang-v1_1" +DEFAULT_VOICE_ID = "zh_47" +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 5058 +DEFAULT_PROVIDER = "cpu" +DEFAULT_NUM_THREADS = 4 +DEFAULT_WINDOWS_STORAGE_ROOT = "E:/WebCodeData/Kokoro" +DEFAULT_NON_WINDOWS_STORAGE_ROOT = "/data/webcode/kokoro" + +KNOWN_ZH_SPEAKERS = { + 45: "zh_45", + 46: "zh_46", + 47: "zh_47", + 48: "zh_48", + 49: "zh_49", + 50: "zh_50", + 51: "zh_51", + 52: "zh_52", +} + + +class EngineAdapter(Protocol): + provider: str + + def list_voices(self) -> list[dict[str, str]]: + ... + + def has_voice(self, voice_id: str) -> bool: + ... + + def synthesize(self, text: str, voice_id: str) -> bytes: + ... + + +class EngineRuntime: + def __init__( + self, + *, + adapter: EngineAdapter, + status: str, + provider: str, + error: str | None = None, + ): + self.adapter = adapter + self.status = status + self.provider = provider + self.error = error + + +class SynthesizeRequest(BaseModel): + text: str + voice_id: str + + @field_validator("text") + @classmethod + def validate_text(cls, value: str) -> str: + if not value or not value.strip(): + raise ValueError("text must not be blank") + + return value.strip() + + @field_validator("voice_id") + @classmethod + def validate_voice_id(cls, value: str) -> str: + if not value or not value.strip(): + raise ValueError("voice_id must not be blank") + + return value.strip() + + +class UnavailableEngineAdapter: + def __init__(self, reason: str): + self.provider = "unavailable" + self.reason = reason + + def list_voices(self) -> list[dict[str, str]]: + return [] + + def has_voice(self, voice_id: str) -> bool: + return False + + def synthesize(self, text: str, voice_id: str) -> bytes: + raise RuntimeError(self.reason) + + +class KokoroEngineAdapter: + def __init__( + self, + *, + model_dir: Path, + provider: str = DEFAULT_PROVIDER, + num_threads: int = DEFAULT_NUM_THREADS, + ): + try: + import sherpa_onnx + except Exception as exc: # pragma: no cover - depends on local runtime + raise RuntimeError( + "sherpa-onnx is not installed. Install the service requirements in the configured non-C storage root." + ) from exc + + self.provider = provider + self.model_dir = model_dir + + required_files = [ + "model.int8.onnx", + "voices.bin", + "tokens.txt", + "lexicon-us-en.txt", + "lexicon-zh.txt", + "date-zh.fst", + "phone-zh.fst", + "number-zh.fst", + "espeak-ng-data", + ] + missing = [name for name in required_files if not (model_dir / name).exists()] + if missing: + raise RuntimeError( + f"Kokoro model directory is incomplete: {model_dir}. Missing: {', '.join(missing)}" + ) + + kokoro = sherpa_onnx.OfflineTtsKokoroModelConfig( + model=str(model_dir / "model.int8.onnx"), + voices=str(model_dir / "voices.bin"), + tokens=str(model_dir / "tokens.txt"), + data_dir=str(model_dir / "espeak-ng-data"), + lexicon=",".join( + [ + str(model_dir / "lexicon-us-en.txt"), + str(model_dir / "lexicon-zh.txt"), + ] + ), + ) + model_config = sherpa_onnx.OfflineTtsModelConfig( + kokoro=kokoro, + num_threads=max(1, int(num_threads)), + provider=provider, + ) + config = sherpa_onnx.OfflineTtsConfig( + model=model_config, + rule_fsts=",".join( + [ + str(model_dir / "date-zh.fst"), + str(model_dir / "phone-zh.fst"), + str(model_dir / "number-zh.fst"), + ] + ), + max_num_sentences=1, + ) + self._tts = sherpa_onnx.OfflineTts(config) + self._sample_rate = int(self._tts.sample_rate) + self._voices = build_voice_catalog(int(self._tts.num_speakers)) + + def list_voices(self) -> list[dict[str, str]]: + return list(self._voices.values()) + + def has_voice(self, voice_id: str) -> bool: + return voice_id in self._voices + + def synthesize(self, text: str, voice_id: str) -> bytes: + sid = parse_voice_id(voice_id) + if sid is None or voice_id not in self._voices: + raise ValueError(f"Unknown voice_id: {voice_id}") + + audio = self._tts.generate(text, sid=sid, speed=1.0) + return samples_to_wav_bytes(audio.samples, int(audio.sample_rate or self._sample_rate)) + + +def build_voice_catalog(num_speakers: int) -> dict[str, dict[str, str]]: + voices: dict[str, dict[str, str]] = {} + + for sid in range(max(0, num_speakers)): + voice_id = f"speaker_{sid}" + voices[voice_id] = { + "voiceId": voice_id, + "displayName": f"Kokoro speaker {sid}", + "language": "multi", + "gender": "unknown", + } + + for sid, voice_id in KNOWN_ZH_SPEAKERS.items(): + if sid >= num_speakers: + continue + + voices[voice_id] = { + "voiceId": voice_id, + "displayName": f"Kokoro Chinese speaker {sid}", + "language": "zh", + "gender": "unknown", + } + + return voices + + +def parse_voice_id(voice_id: str) -> int | None: + normalized = (voice_id or "").strip().lower() + if normalized.isdigit(): + return int(normalized) + + for prefix in ("speaker_", "zh_"): + if normalized.startswith(prefix) and normalized[len(prefix) :].isdigit(): + return int(normalized[len(prefix) :]) + + return None + + +def samples_to_wav_bytes(samples: Any, sample_rate: int) -> bytes: + pcm = array("h") + for sample in samples: + value = max(-1.0, min(1.0, float(sample))) + pcm.append(int(value * 32767)) + + if sys.byteorder == "big": # pragma: no cover - CI is little-endian + pcm.byteswap() + + output = io.BytesIO() + with wave.open(output, "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(sample_rate) + wav.writeframes(pcm.tobytes()) + + return output.getvalue() + + +def normalize_voice(raw_voice: dict[str, Any]) -> dict[str, str]: + voice_id = raw_voice.get("voiceId") or raw_voice.get("voice_id") or raw_voice.get("id") or "" + display_name = raw_voice.get("displayName") or raw_voice.get("display_name") or raw_voice.get("name") or voice_id + language = raw_voice.get("language") or "unknown" + gender = raw_voice.get("gender") or "unknown" + + return { + "voiceId": str(voice_id), + "displayName": str(display_name), + "language": str(language), + "gender": str(gender), + } + + +def default_adapter_factory(model_dir: Path, provider: str, num_threads: int) -> EngineAdapter: + return KokoroEngineAdapter( + model_dir=model_dir, + provider=provider, + num_threads=num_threads, + ) + + +def normalize_windows_drive(value: str | None) -> str: + if not value or not value.strip(): + return "" + + candidate = value.strip() + drive = ntpath.splitdrive(candidate)[0] + if not drive and len(candidate) >= 2 and candidate[1] == ":": + drive = candidate[:2] + + return drive.rstrip("\\/").upper() + + +def get_windows_system_drive(system_drive: str | None = None) -> str: + candidate = system_drive or os.getenv("SystemDrive") + if not candidate: + candidate = ntpath.splitdrive(os.getenv("SystemRoot", ""))[0] + + return normalize_windows_drive(candidate) or "C:" + + +def validate_windows_non_system_path( + path: str | None, + *, + label: str, + os_name: str | None = None, + system_drive: str | None = None, +) -> None: + if not path or not path.strip(): + return + + if (os_name or os.name) != "nt": + return + + path_drive = normalize_windows_drive(path) + if not path_drive: + return + + system_drive_normalized = get_windows_system_drive(system_drive) + if path_drive == system_drive_normalized: + raise RuntimeError( + f"Kokoro/sherpa-onnx {label} must be on a Windows non-system drive. " + f"If this machine only has {system_drive_normalized}, attach or map a non-system drive first." + ) + + +def resolve_model_dir(storage_root: str | None, model_dir: str | None) -> Path: + if model_dir and model_dir.strip(): + validate_windows_non_system_path(model_dir, label="model directory") + return Path(model_dir.strip()).resolve() + + if not storage_root or not storage_root.strip(): + storage_root = DEFAULT_WINDOWS_STORAGE_ROOT if os.name == "nt" else DEFAULT_NON_WINDOWS_STORAGE_ROOT + + validate_windows_non_system_path(storage_root, label="storage root") + return (Path(storage_root.strip()) / "models" / DEFAULT_MODEL_NAME).resolve() + + +def initialize_runtime( + adapter_factory: Callable[[Path, str, int], EngineAdapter], + *, + model_dir: Path, + provider: str, + num_threads: int, + default_voice_id: str, +) -> EngineRuntime: + try: + adapter = adapter_factory(model_dir, provider, num_threads) + if not adapter.has_voice(default_voice_id): + raise RuntimeError(f"Default voice '{default_voice_id}' is not available.") + + LOGGER.info("Initialized Kokoro/sherpa-onnx adapter on %s", adapter.provider) + return EngineRuntime(adapter=adapter, status="ok", provider=adapter.provider) + except Exception as exc: + message = f"Unable to initialize Kokoro/sherpa-onnx: {exc}" + LOGGER.error(message) + return EngineRuntime( + adapter=UnavailableEngineAdapter(message), + status="unavailable", + provider="unavailable", + error=message, + ) + + +def create_app( + *, + adapter_factory: Callable[[Path, str, int], EngineAdapter] | None = None, + storage_root: str | None = None, + model_dir: str | None = None, + default_voice_id: str | None = None, + provider: str | None = None, + num_threads: int | None = None, +) -> FastAPI: + adapter_factory = adapter_factory or default_adapter_factory + storage_root = storage_root or os.getenv("KOKORO_STORAGE_ROOT") + model_dir_path = resolve_model_dir(storage_root, model_dir or os.getenv("KOKORO_MODEL_DIR")) + default_voice_id = default_voice_id or os.getenv("KOKORO_DEFAULT_VOICE_ID", DEFAULT_VOICE_ID) + provider = provider or os.getenv("KOKORO_PROVIDER", DEFAULT_PROVIDER) + num_threads = num_threads or int(os.getenv("KOKORO_NUM_THREADS", str(DEFAULT_NUM_THREADS))) + + @asynccontextmanager + async def lifespan(app: FastAPI): + app.state.runtime = initialize_runtime( + adapter_factory, + model_dir=model_dir_path, + provider=provider, + num_threads=num_threads, + default_voice_id=default_voice_id, + ) + yield + + app = FastAPI(title="Kokoro sherpa-onnx Local Service", version="0.1.0", lifespan=lifespan) + app.state.default_voice_id = default_voice_id + app.state.model_dir = str(model_dir_path) + + @app.get("/health") + async def health() -> dict[str, str]: + runtime = app.state.runtime + response = { + "status": runtime.status, + "device": runtime.provider, + "defaultVoiceId": app.state.default_voice_id, + "modelDir": app.state.model_dir, + } + if runtime.error: + response["message"] = runtime.error + + return response + + @app.get("/voices") + async def voices() -> dict[str, list[dict[str, str]]]: + runtime = app.state.runtime + if runtime.status != "ok": + raise HTTPException(status_code=503, detail=runtime.error or "TTS unavailable") + + return {"voices": [normalize_voice(item) for item in runtime.adapter.list_voices()]} + + @app.post("/synthesize") + async def synthesize(request: SynthesizeRequest) -> Response: + runtime = app.state.runtime + if runtime.status != "ok": + raise HTTPException(status_code=503, detail=runtime.error or "TTS unavailable") + + try: + audio = runtime.adapter.synthesize(request.text, request.voice_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + + return Response(content=audio, media_type="audio/wav") + + return app + + +app = create_app() + + +if __name__ == "__main__": + logging.basicConfig(level=os.getenv("KOKORO_LOG_LEVEL", "INFO").upper()) + uvicorn.run( + app, + host=os.getenv("KOKORO_HOST", DEFAULT_HOST), + port=int(os.getenv("KOKORO_PORT", str(DEFAULT_PORT))), + log_level=os.getenv("KOKORO_LOG_LEVEL", "info"), + ) diff --git a/tools/sherpa-kokoro-service/requirements.txt b/tools/sherpa-kokoro-service/requirements.txt new file mode 100644 index 0000000..163db23 --- /dev/null +++ b/tools/sherpa-kokoro-service/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.110,<1.0 +uvicorn[standard]>=0.29,<1.0 +sherpa-onnx>=1.13,<2.0 diff --git a/tools/sherpa-kokoro-service/start.ps1 b/tools/sherpa-kokoro-service/start.ps1 new file mode 100644 index 0000000..802ea9f --- /dev/null +++ b/tools/sherpa-kokoro-service/start.ps1 @@ -0,0 +1,229 @@ +param( + [string]$StorageRoot = "", + [int]$Port = 5058, + [string]$DefaultVoiceId = "zh_47", + [string]$Provider = "cpu", + [int]$NumThreads = 4, + [string]$Python = "python" +) + +$ErrorActionPreference = "Stop" + +function Test-IsWindows { + return [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform( + [System.Runtime.InteropServices.OSPlatform]::Windows) +} + +function Get-WindowsSystemDriveRoot { + $systemRoot = [Environment]::GetFolderPath([Environment+SpecialFolder]::System) + if (-not [string]::IsNullOrWhiteSpace($systemRoot)) { + return [System.IO.Path]::GetPathRoot($systemRoot) + } + + $systemDrive = [Environment]::GetEnvironmentVariable("SystemDrive") + if ([string]::IsNullOrWhiteSpace($systemDrive)) { + return "C:\" + } + + return "$($systemDrive.TrimEnd('\'))\" +} + +function Test-IsSameWindowsDrive { + param( + [string]$Left, + [string]$Right + ) + + if ([string]::IsNullOrWhiteSpace($Left) -or [string]::IsNullOrWhiteSpace($Right)) { + return $false + } + + $leftRoot = [System.IO.Path]::GetPathRoot([System.IO.Path]::GetFullPath($Left)) + $rightRoot = [System.IO.Path]::GetPathRoot([System.IO.Path]::GetFullPath($Right)) + + return $leftRoot.TrimEnd('\') -ieq $rightRoot.TrimEnd('\') +} + +function Test-DriveWritable { + param([System.IO.DriveInfo]$Drive) + + if (-not $Drive.IsReady) { + return $false + } + + $probeRoot = Join-Path $Drive.RootDirectory.FullName ".webcode-kokoro-probe-$([Guid]::NewGuid().ToString('N'))" + try { + New-Item -ItemType Directory -Force -Path $probeRoot | Out-Null + $probeFile = Join-Path $probeRoot "probe.tmp" + [System.IO.File]::WriteAllText($probeFile, "probe") + return $true + } + catch { + return $false + } + finally { + if (Test-Path -LiteralPath $probeRoot) { + Remove-Item -LiteralPath $probeRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +function Resolve-DefaultStorageRoot { + if (-not (Test-IsWindows)) { + return "/data/webcode/kokoro" + } + + $systemDriveRoot = Get-WindowsSystemDriveRoot + $dataDrive = [System.IO.DriveInfo]::GetDrives() | + Where-Object { + $_.DriveType -eq [System.IO.DriveType]::Fixed -and + $_.IsReady -and + -not (Test-IsSameWindowsDrive $_.RootDirectory.FullName $systemDriveRoot) -and + (Test-DriveWritable $_) + } | + Sort-Object Name | + Select-Object -First 1 + + if ($null -eq $dataDrive) { + throw "Kokoro/sherpa-onnx TTS cannot be installed on this Windows machine because only the system drive '$systemDriveRoot' is writable. Attach or map a writable non-system drive, then pass -StorageRoot such as E:\WebCodeData\Kokoro." + } + + return Join-Path $dataDrive.RootDirectory.FullName "WebCodeData\Kokoro" +} + +function Get-BundledPythonHome { + param([string]$StorageRoot) + + $pythonRoot = Join-Path $StorageRoot "python" + if (-not (Test-Path $pythonRoot)) { + return $null + } + + $candidateHomes = Get-ChildItem -Path $pythonRoot -Directory -ErrorAction SilentlyContinue | + Sort-Object Name + foreach ($candidateHome in $candidateHomes) { + $pythonExecutablePath = Join-Path $candidateHome.FullName "python.exe" + if (Test-Path $pythonExecutablePath) { + return $candidateHome.FullName + } + } + + return $null +} + +function Repair-BundledVenvConfig { + param( + [string]$StorageRoot, + [string]$BundledPythonHome + ) + + if ([string]::IsNullOrWhiteSpace($BundledPythonHome)) { + return + } + + $venvConfigPath = Join-Path $StorageRoot "venv\pyvenv.cfg" + if (-not (Test-Path $venvConfigPath)) { + return + } + + $expectedPythonHome = [System.IO.Path]::GetFullPath($BundledPythonHome) + $lines = Get-Content -Path $venvConfigPath + $updated = $false + $hasHomeEntry = $false + + for ($index = 0; $index -lt $lines.Count; $index++) { + if ($lines[$index] -match '^\s*home\s*=') { + $hasHomeEntry = $true + $expectedLine = "home = $expectedPythonHome" + if ($lines[$index] -ne $expectedLine) { + $lines[$index] = $expectedLine + $updated = $true + } + } + } + + if (-not $hasHomeEntry) { + $lines = @("home = $expectedPythonHome") + $lines + $updated = $true + } + + if ($updated) { + [System.IO.File]::WriteAllLines($venvConfigPath, $lines, [System.Text.UTF8Encoding]::new($false)) + } +} + +function Resolve-PythonCommand { + param( + [string]$StorageRoot, + [string]$RequestedPython + ) + + $bundledPythonHome = Get-BundledPythonHome -StorageRoot $StorageRoot + if (-not [string]::IsNullOrWhiteSpace($bundledPythonHome)) { + Repair-BundledVenvConfig -StorageRoot $StorageRoot -BundledPythonHome $bundledPythonHome + } + + if (-not [string]::IsNullOrWhiteSpace($RequestedPython) -and $RequestedPython -ne "python") { + if (-not [System.IO.Path]::IsPathRooted($RequestedPython) -or (Test-Path $RequestedPython)) { + return $RequestedPython + } + } + + $bundledVenvPythonPath = Join-Path $StorageRoot "venv\Scripts\python.exe" + if (Test-Path $bundledVenvPythonPath) { + return $bundledVenvPythonPath + } + + if (-not [string]::IsNullOrWhiteSpace($bundledPythonHome)) { + $bundledPythonPath = Join-Path $bundledPythonHome "python.exe" + if (Test-Path $bundledPythonPath) { + return $bundledPythonPath + } + } + + return "python" +} + +if ([string]::IsNullOrWhiteSpace($StorageRoot)) { + $StorageRoot = Resolve-DefaultStorageRoot +} + +$resolvedRoot = [System.IO.Path]::GetFullPath($StorageRoot) + +if ((Test-IsWindows) -and (Test-IsSameWindowsDrive $resolvedRoot (Get-WindowsSystemDriveRoot))) { + throw "Refusing to use the Windows system drive for Kokoro/sherpa-onnx TTS storage. Configure a writable non-system drive such as E:\WebCodeData\Kokoro." +} + +$directories = @{ + KOKORO_CACHE_ROOT = Join-Path $resolvedRoot "cache" + TEMP = Join-Path $resolvedRoot "temp" + TMP = Join-Path $resolvedRoot "temp" + PIP_CACHE_DIR = Join-Path $resolvedRoot "cache\pip" +} + +foreach ($entry in $directories.GetEnumerator()) { + New-Item -ItemType Directory -Force -Path $entry.Value | Out-Null + Set-Item -Path "Env:$($entry.Key)" -Value $entry.Value +} + +New-Item -ItemType Directory -Force -Path (Join-Path $resolvedRoot "logs") | Out-Null +New-Item -ItemType Directory -Force -Path (Join-Path $resolvedRoot "models") | Out-Null +New-Item -ItemType Directory -Force -Path (Join-Path $resolvedRoot "service") | Out-Null +New-Item -ItemType Directory -Force -Path (Join-Path $resolvedRoot "venv") | Out-Null + +$env:KOKORO_DEFAULT_VOICE_ID = $DefaultVoiceId +$env:KOKORO_PROVIDER = $Provider +$env:KOKORO_NUM_THREADS = "$NumThreads" +$env:KOKORO_HOST = "127.0.0.1" +$env:KOKORO_PORT = "$Port" +$env:KOKORO_STORAGE_ROOT = $resolvedRoot +$Python = Resolve-PythonCommand -StorageRoot $resolvedRoot -RequestedPython $Python + +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +Push-Location $scriptRoot +try { + & $Python -m uvicorn app:app --host 127.0.0.1 --port $Port +} +finally { + Pop-Location +} diff --git a/tools/sherpa-kokoro-service/start.sh b/tools/sherpa-kokoro-service/start.sh new file mode 100644 index 0000000..ee15064 --- /dev/null +++ b/tools/sherpa-kokoro-service/start.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +storage_root="${1:-${KOKORO_STORAGE_ROOT:-}}" +port="${KOKORO_PORT:-5058}" +default_voice_id="${KOKORO_DEFAULT_VOICE_ID:-zh_47}" +provider="${KOKORO_PROVIDER:-cpu}" +num_threads="${KOKORO_NUM_THREADS:-4}" + +if [[ -z "${storage_root}" ]]; then + echo "Storage root is required." >&2 + exit 1 +fi + +case "${storage_root}" in + /|/root|/home|/tmp) + echo "Refusing unsafe storage root: ${storage_root}" >&2 + exit 1 + ;; +esac + +mkdir -p \ + "${storage_root}/cache/pip" \ + "${storage_root}/logs" \ + "${storage_root}/models" \ + "${storage_root}/service" \ + "${storage_root}/temp" \ + "${storage_root}/venv" + +export TEMP="${storage_root}/temp" +export TMP="${storage_root}/temp" +export PIP_CACHE_DIR="${storage_root}/cache/pip" +export KOKORO_CACHE_ROOT="${storage_root}/cache" +export KOKORO_STORAGE_ROOT="${storage_root}" +export KOKORO_DEFAULT_VOICE_ID="${default_voice_id}" +export KOKORO_PROVIDER="${provider}" +export KOKORO_NUM_THREADS="${num_threads}" +export KOKORO_HOST="127.0.0.1" +export KOKORO_PORT="${port}" + +script_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "${script_root}" +python -m uvicorn app:app --host 127.0.0.1 --port "${port}" diff --git a/tools/sherpa-kokoro-service/tests/test_app.py b/tools/sherpa-kokoro-service/tests/test_app.py new file mode 100644 index 0000000..1e32156 --- /dev/null +++ b/tools/sherpa-kokoro-service/tests/test_app.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + +from fastapi.testclient import TestClient + + +APP_PATH = Path(__file__).resolve().parent.parent / "app.py" + + +def load_app_module(): + spec = importlib.util.spec_from_file_location("sherpa_kokoro_service_app", APP_PATH) + if spec is None or spec.loader is None: + raise RuntimeError(f"Unable to load app module from {APP_PATH}") + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class FakeEngineAdapter: + def __init__(self, provider: str, voices: list[dict[str, str]], audio: bytes = b"RIFFfake"): + self.provider = provider + self.voices = voices + self.audio = audio + self.calls: list[tuple[str, str]] = [] + + def list_voices(self): + return list(self.voices) + + def has_voice(self, voice_id: str) -> bool: + return any(voice["voiceId"] == voice_id for voice in self.voices) + + def synthesize(self, text: str, voice_id: str) -> bytes: + self.calls.append((text, voice_id)) + return self.audio + + +def test_health_reports_active_provider_default_voice_and_model_dir(): + app_module = load_app_module() + engine = FakeEngineAdapter( + provider="cpu", + voices=[ + { + "voiceId": "zh_47", + "displayName": "Kokoro Chinese speaker 47", + "language": "zh", + "gender": "unknown", + } + ], + ) + + app = app_module.create_app( + adapter_factory=lambda model_dir, provider, num_threads: engine, + storage_root="E:/WebCodeData/Kokoro", + default_voice_id="zh_47", + ) + + with TestClient(app) as client: + response = client.get("/health") + + assert response.status_code == 200 + body = response.json() + assert body["status"] == "ok" + assert body["device"] == "cpu" + assert body["defaultVoiceId"] == "zh_47" + assert body["modelDir"].endswith("kokoro-int8-multi-lang-v1_1") + + +def test_voices_returns_normalized_voice_list(): + app_module = load_app_module() + engine = FakeEngineAdapter( + provider="cpu", + voices=[ + { + "voiceId": "zh_47", + "displayName": "Kokoro Chinese speaker 47", + "language": "zh", + "gender": "unknown", + } + ], + ) + + app = app_module.create_app( + adapter_factory=lambda model_dir, provider, num_threads: engine, + storage_root="E:/WebCodeData/Kokoro", + default_voice_id="zh_47", + ) + + with TestClient(app) as client: + response = client.get("/voices") + + assert response.status_code == 200 + assert response.json() == { + "voices": [ + { + "voiceId": "zh_47", + "displayName": "Kokoro Chinese speaker 47", + "language": "zh", + "gender": "unknown", + } + ] + } + + +def test_synthesize_rejects_blank_input(): + app_module = load_app_module() + engine = FakeEngineAdapter( + provider="cpu", + voices=[ + { + "voiceId": "zh_47", + "displayName": "Kokoro Chinese speaker 47", + "language": "zh", + "gender": "unknown", + } + ], + ) + + app = app_module.create_app( + adapter_factory=lambda model_dir, provider, num_threads: engine, + storage_root="E:/WebCodeData/Kokoro", + default_voice_id="zh_47", + ) + + with TestClient(app) as client: + response = client.post( + "/synthesize", + json={"text": " ", "voice_id": "zh_47"}, + ) + + assert response.status_code == 422 + + +def test_missing_default_voice_makes_runtime_unavailable(): + app_module = load_app_module() + engine = FakeEngineAdapter(provider="cpu", voices=[]) + + app = app_module.create_app( + adapter_factory=lambda model_dir, provider, num_threads: engine, + storage_root="E:/WebCodeData/Kokoro", + default_voice_id="zh_47", + ) + + with TestClient(app) as client: + health = client.get("/health") + voices = client.get("/voices") + + assert health.status_code == 200 + assert health.json()["status"] == "unavailable" + assert voices.status_code == 503 + + +def test_parse_voice_id_supports_known_aliases(): + app_module = load_app_module() + + assert app_module.parse_voice_id("zh_47") == 47 + assert app_module.parse_voice_id("speaker_47") == 47 + assert app_module.parse_voice_id("47") == 47 + assert app_module.parse_voice_id("bad") is None + + +def test_validate_storage_root_rejects_windows_system_drive(): + app_module = load_app_module() + + try: + app_module.validate_windows_non_system_path( + "C:/WebCodeData/Kokoro", + label="storage root", + os_name="nt", + system_drive="C:", + ) + except RuntimeError as exc: + assert "non-system drive" in str(exc) + else: + raise AssertionError("Expected Windows system drive storage root to be rejected") + + +def test_validate_storage_root_allows_non_windows_paths(): + app_module = load_app_module() + + app_module.validate_windows_non_system_path( + "/data/webcode/kokoro", + label="storage root", + os_name="posix", + system_drive="C:", + ) diff --git a/webcode-github-release.skill b/webcode-github-release.skill new file mode 100644 index 0000000..4c7642d Binary files /dev/null and b/webcode-github-release.skill differ diff --git a/webcode-local-windows-tts-installer.skill b/webcode-local-windows-tts-installer.skill new file mode 100644 index 0000000..5a1b8d4 Binary files /dev/null and b/webcode-local-windows-tts-installer.skill differ