diff --git "a/docs/proposed/task/TASK058-DSH\345\274\225\346\223\216\346\267\261\345\272\246\345\257\271\351\275\220\344\270\216DTO\347\273\237\344\270\200.md" "b/docs/proposed/task/TASK058-DSH\345\274\225\346\223\216\346\267\261\345\272\246\345\257\271\351\275\220\344\270\216DTO\347\273\237\344\270\200.md" index 2e6322779..4ff3843a7 100644 --- "a/docs/proposed/task/TASK058-DSH\345\274\225\346\223\216\346\267\261\345\272\246\345\257\271\351\275\220\344\270\216DTO\347\273\237\344\270\200.md" +++ "b/docs/proposed/task/TASK058-DSH\345\274\225\346\223\216\346\267\261\345\272\246\345\257\271\351\275\220\344\270\216DTO\347\273\237\344\270\200.md" @@ -31,8 +31,17 @@ | 10 | 逐字流式 | `assistant-stream` 恒 revision 0 | TASK043 已登记(引擎侧缺陷) | | 11 | steer | `mode≠queue` 返回 `session/busy` | 引擎未暴露轮内注入(0945d7d32 前提记录) | -### P2 — 大块新功能(另行立项) -附件(fileUploads/attachment)、文件树(workspaceFiles 5)、终端(10)、goals 6、`session/updateQueue`。 +### P2 — 大块新功能 + +| # | 功能 | 状态 | +|---|------|------| +| 12 | 文件树 workspaceFiles/list\|read\|readBytes\|stat | ✅ 真机(弹栈式防穿越+version 合成;changes 流深化项) | +| 13 | 附件 fileUploads/upload + session/attachment + uploadFileBinary 直传 | ✅ 真机(魔数探测+四格式尺寸头解析;prompt file part 消费属引擎多模态深化) | +| 14 | 队列 session/updateQueue(edit/remove) | ✅ 单元 4/4+真机契约(steer 诚实返回 steer-unavailable;队列可见性 inbox 投影深化) | +| 15 | 终端(10 方法) | ⬜ 需引擎大扩展(ConPTY/屏幕缓冲/多 shell),另行立项 | +| 16 | goals(7 方法+事件) | ⬜ 需引擎中扩展(clock/goal 生命周期模型映射+revision CAS 适配),另行立项 | +| 17 | steer / prompt mode:"steer" | ⬜ 需引擎大扩展(StreamAsync 轮内注入点),另行立项 | +| 18 | workspaceFiles/changes 流 | ⬜ watcher 桥接深化项 | ### 不接 ❌(DSH 在线服务,与本项目无关) account(11)、pluginManager(12)、dynamicCordisRunner(9)、productAnalytics、schedule、officeToPdf —— 保持 `unimplemented` 引导错误。 diff --git a/modules/apps/engine/app/modules/McpInitModule.cs b/modules/apps/engine/app/modules/McpInitModule.cs index e42f09534..36635e2eb 100644 --- a/modules/apps/engine/app/modules/McpInitModule.cs +++ b/modules/apps/engine/app/modules/McpInitModule.cs @@ -129,6 +129,10 @@ private static async Task RefreshKernelPluginsAsync(IServiceProvider services, I var totalCount = plugins.Sum(p => p.Functions.Count()); logger?.LogDebug("[MCP] kernel.Plugins 已刷新,{GroupCount} 组 {Count} 个工具", plugins.Count, totalCount); + } catch (ObjectDisposedException ex) { + // 引擎关闭期良性竞态:Host 已 dispose(AsyncLock 释放)而后台刷新任务晚到 — 非故障, + // 降级 Debug 避免 E2E NoErrors 断言误报(CLI 每轮退出与 McpPluginBootstrap 后台加载的时序差) + logger?.LogDebug(ex, "[MCP] 刷新 kernel.Plugins 跳过:引擎已关闭"); } catch (Exception ex) { logger?.LogError(ex, "[MCP] 刷新 kernel.Plugins 失败"); } diff --git a/native_gui/src/NativeGui.Core/GlobalUsings.cs b/native_gui/src/NativeGui.Core/GlobalUsings.cs index 25bcf3258..0c2dd2eaa 100644 --- a/native_gui/src/NativeGui.Core/GlobalUsings.cs +++ b/native_gui/src/NativeGui.Core/GlobalUsings.cs @@ -32,6 +32,7 @@ global using JoinCode.BclBridge; global using JoinCode.Gui.Hosting; global using JoinCode.Gui.Theming; +global using System.Buffers.Binary; global using System; global using System.Collections.Concurrent; global using System.Collections.Frozen; diff --git a/native_gui/src/NativeGui.Core/hosting/PlaceholderChatSession.cs b/native_gui/src/NativeGui.Core/hosting/PlaceholderChatSession.cs index b944fbc66..58be0ee84 100644 --- a/native_gui/src/NativeGui.Core/hosting/PlaceholderChatSession.cs +++ b/native_gui/src/NativeGui.Core/hosting/PlaceholderChatSession.cs @@ -164,10 +164,16 @@ public Task ExecuteSlashCommandAsync(string input, CancellationToken can /// 最后一次执行的斜杠命令 — 测试追踪字段 public string? LastExecutedSlashCommand { get; private set; } + /// 轮次闸门(测试接缝):StreamAsync 开头等待此委托返回;null=立即生成。用于测试保持 busy 占用 + internal Func? TurnGate { get; set; } + /// 占位会话流式回显 — 产出思考/工具/正文示例事件供 UI 渲染验证 public async IAsyncEnumerable StreamAsync( string message, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { + if (TurnGate is { } gate) { + await gate(cancellationToken); + } foreach (var ch in "让我先分析一下你的问题。") { cancellationToken.ThrowIfCancellationRequested(); await Task.Yield(); diff --git a/native_gui/src/NativeGui.Core/web_host/DshAttachmentStore.cs b/native_gui/src/NativeGui.Core/web_host/DshAttachmentStore.cs new file mode 100644 index 000000000..3b09ab89f --- /dev/null +++ b/native_gui/src/NativeGui.Core/web_host/DshAttachmentStore.cs @@ -0,0 +1,202 @@ +namespace JoinCode.Gui.WebHost; + +/// +/// 附件存储(TASK058 P2-B2):fileUploads/upload 的 receipt 落盘与 session/attachment 的读取。 +/// 存储 = {dir}/{owner}/ 索引 json + {owner}/{receiptId}.bin;mediaType 由魔数探测 +/// (PNG/JPEG/GIF/WEBP 四类,前端 zod 值域);宽高由文件头解析(手写字节读取,AOT 安全零反射)。 +/// receiptId 同时充当 attachmentId(v1 单层寻址,前端两字段透传一致)。 +/// +internal sealed class DshAttachmentStore(IFileSystem fs, string? directory = null) { + private readonly string _directory = directory + ?? Path.Combine(AppDataPaths.Default.JccDirectory, "dsh_attachments"); + + /// 上传:base64 数据 → 魔数校验 → 落盘 → receipt 元数据 + internal async Task UploadAsync(string owner, string base64Data, string? name, CancellationToken cancellationToken) { + byte[] bytes; + try { + bytes = Convert.FromBase64String(base64Data); + } catch (FormatException) { + return DshRpcResults.Fail("fileUploads/rejected", "data 不是合法 base64;请用 FileReader.readAsDataURL 的纯 base64 部分"); + } + if (bytes.Length == 0) { + return DshRpcResults.Fail("fileUploads/rejected", "附件内容为空;请选择非空文件重试"); + } + var mediaType = SniffMediaType(bytes); + if (mediaType is null) { + return DshRpcResults.Fail("fileUploads/rejected", + "前端仅支持图片附件(image/png、image/jpeg、image/webp、image/gif 四类);请转换格式后重传"); + } + var receiptId = Guid.NewGuid().ToString("N"); + var ownerDir = Path.Combine(_directory, owner); + if (!fs.DirectoryExists(ownerDir)) { + fs.CreateDirectory(ownerDir); + } + await fs.WriteAllBytesAsync(Path.Combine(ownerDir, $"{receiptId}.bin"), bytes, cancellationToken); + var record = new DshAttachmentRecord { + AttachmentId = receiptId, + Name = string.IsNullOrWhiteSpace(name) ? "attachment" : name, + MediaType = mediaType, + Bytes = bytes.Length, + Width = ReadWidth(bytes) ?? 0, + Height = ReadHeight(bytes) ?? 0, + }; + await fs.WriteAllTextAsync(Path.Combine(ownerDir, $"{receiptId}.json"), + RelaxedJsonSerializer.Serialize(record, DshJsonContext.Default), cancellationToken); + return DshRpcResults.Ok(JsonSerializer.SerializeToElement( + new DshFileUploadResult { + ReceiptId = receiptId, + File = new DshUploadedFileInfo { + AttachmentId = receiptId, + Name = record.Name, + Bytes = record.Bytes, + }, + }, + DshJsonContext.Default.DshFileUploadResult)); + } + + /// 读取:receiptId → 元数据 + base64 数据(前端聊天内图片渲染) + internal async Task ReadAsync(string owner, string attachmentId, CancellationToken cancellationToken) { + if (!IsValidReceiptId(attachmentId) || !IsValidOwner(owner)) { + return DshRpcResults.Fail("session/attachment-not-found", + $"附件 {attachmentId} 不存在(ID 非法);请重新上传后用新 receiptId 引用"); + } + var recordPath = Path.Combine(_directory, owner, $"{attachmentId}.json"); + if (!fs.FileExists(recordPath)) { + return DshRpcResults.Fail("session/attachment-not-found", + $"附件 {attachmentId} 不存在;请重新上传后用新 receiptId 引用"); + } + var record = RelaxedJsonSerializer.Deserialize( + await fs.ReadAllTextAsync(recordPath, cancellationToken), DshJsonContext.Default.DshAttachmentRecord); + if (record is null) { + return DshRpcResults.Fail("session/attachment-not-found", $"附件 {attachmentId} 元数据损坏;请重新上传"); + } + var bytes = await fs.ReadAllBytesAsync(Path.Combine(_directory, owner, $"{attachmentId}.bin"), cancellationToken); + return DshRpcResults.Ok(JsonSerializer.SerializeToElement( + new DshSessionAttachmentResult { + Attachment = new DshAttachmentInfo { + AttachmentId = record.AttachmentId, + MediaType = record.MediaType, + Bytes = record.Bytes, + Width = record.Width, + Height = record.Height, + Name = record.Name, + }, + Data = Convert.ToBase64String(bytes), + }, + DshJsonContext.Default.DshSessionAttachmentResult)); + } + + /// 魔数探测(PNG/JPEG/GIF/WEBP;null=非四类图片) + internal static string? SniffMediaType(ReadOnlySpan bytes) { + if (bytes.Length >= 8 && bytes[0] == 0x89 && bytes[1] == (byte)'P' && bytes[2] == (byte)'N' && bytes[3] == (byte)'G') { + return "image/png"; + } + if (bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) { + return "image/jpeg"; + } + if (bytes.Length >= 6 && bytes[0] == (byte)'G' && bytes[1] == (byte)'I' && bytes[2] == (byte)'F') { + return "image/gif"; + } + if (bytes.Length >= 12 && bytes[0] == (byte)'R' && bytes[1] == (byte)'I' && bytes[2] == (byte)'F' + && bytes[8] == (byte)'W' && bytes[9] == (byte)'E' && bytes[10] == (byte)'B' && bytes[11] == (byte)'P') { + return "image/webp"; + } + return null; + } + + /// 读图片宽(PNG IHDER / GIF 头 / WEBP VP8X|VP8|VP8L;JPEG 扫 SOF 段) + internal static int? ReadWidth(ReadOnlySpan bytes) => ReadDimensions(bytes)?.Width; + + /// 读图片高 + internal static int? ReadHeight(ReadOnlySpan bytes) => ReadDimensions(bytes)?.Height; + + /// 四格式尺寸头解析(手写字节读取,AOT 安全;解析失败返回 null) + private static (int Width, int Height)? ReadDimensions(ReadOnlySpan b) { + if (b.Length >= 24 && b[1] == (byte)'P' && b[2] == (byte)'N' && b[3] == (byte)'G') { + // PNG IHDER:宽高在 16..23(大端) + return (BinaryPrimitives.ReadInt32BigEndian(b[16..20]), BinaryPrimitives.ReadInt32BigEndian(b[20..24])); + } + if (b.Length >= 10 && b[0] == (byte)'G' && b[1] == (byte)'I' && b[2] == (byte)'F') { + // GIF 头:宽高在 6..9(小端 ushort) + return (BinaryPrimitives.ReadUInt16LittleEndian(b[6..8]), BinaryPrimitives.ReadUInt16LittleEndian(b[8..10])); + } + if (b.Length >= 30 && b[0] == 0xFF && b[1] == 0xD8) { + return ReadJpegDimensions(b); + } + if (b.Length >= 30 && b[0] == (byte)'R' && b[1] == (byte)'I' && b[2] == (byte)'F') { + return ReadWebpDimensions(b); + } + return null; + } + + /// JPEG 尺寸:扫 SOF0-SOF15 段(跳过 FFE0-FFEF 应用段) + private static (int, int)? ReadJpegDimensions(ReadOnlySpan b) { + var offset = 2; + while (offset + 9 < b.Length) { + if (b[offset] != 0xFF) { + return null; + } + var marker = b[offset + 1]; + if (marker is 0xD8 or 0x01 or (>= 0xD0 and <= 0xD7)) { + offset += 2; + continue; + } + var segmentLength = BinaryPrimitives.ReadUInt16BigEndian(b[(offset + 2)..]); + if (marker is >= 0xC0 and <= 0xCF and not 0xC4 and not 0xC8 and not 0xCC) { + if (offset + 9 >= b.Length) { + return null; + } + // SOF:高在 +5..+7,宽在 +7..+9(大端) + return (BinaryPrimitives.ReadUInt16BigEndian(b[(offset + 7)..(offset + 9)]), + BinaryPrimitives.ReadUInt16BigEndian(b[(offset + 5)..(offset + 7)])); + } + offset += 2 + segmentLength; + } + return null; + } + + /// WEBP 尺寸:VP8X(广域)/VP8(有损 keyframe)/VP8L(无损)三分支 + private static (int, int)? ReadWebpDimensions(ReadOnlySpan b) { + var chunk = b[12..]; + var tag = chunk.Length >= 4 ? System.Text.Encoding.ASCII.GetString(chunk[..4]) : ""; + switch (tag) { + case "VP8X": { + if (chunk.Length < 14) { + return null; + } + // VP8X:canvas 宽=1+24bit(b[4..7] 低 24 位),高=1+24bit(b[7..10]) + var width = 1 + (chunk[4] | chunk[5] << 8 | chunk[6] << 16); + var height = 1 + (chunk[7] | chunk[8] << 8 | chunk[9] << 16); + return (width, height); + } + case "VP8 ": { + if (chunk.Length < 14) { + return null; + } + // VP8 keyframe:宽在 chunk[6..8]、高在 chunk[8..10](小端 ushort) + return (BinaryPrimitives.ReadUInt16LittleEndian(chunk[6..8]), + BinaryPrimitives.ReadUInt16LittleEndian(chunk[8..10])); + } + case "VP8L": { + if (chunk.Length < 9) { + return null; + } + // VP8L:bits[0..13]=宽-1,bits[14..27]=高-1(28 位打包小端位序) + var bits = BinaryPrimitives.ReadUInt32LittleEndian(chunk[4..8]); + return ((int)(bits & 0x3FFF) + 1, (int)(bits >> 14 & 0x3FFF) + 1); + } + default: + return null; + } + } + + /// receipt/attachment ID 合法性(hex 32 位 GUID — 防路径注入) + private static bool IsValidReceiptId(string id) => + id.Length == 32 && id.All(char.IsAsciiHexDigit); + + /// owner(会话/代理 ID)合法性:非空且不含路径分隔符与 .. 段(防目录逃逸;形状由前端会话体系决定,不限定 hex) + private static bool IsValidOwner(string owner) => + !string.IsNullOrWhiteSpace(owner) + && owner.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar, ':']) < 0 + && !owner.Contains(".."); +} diff --git a/native_gui/src/NativeGui.Core/web_host/DshHostServices.cs b/native_gui/src/NativeGui.Core/web_host/DshHostServices.cs index 68546c9bb..2e4ec0979 100644 --- a/native_gui/src/NativeGui.Core/web_host/DshHostServices.cs +++ b/native_gui/src/NativeGui.Core/web_host/DshHostServices.cs @@ -18,6 +18,8 @@ internal DshHostServices(IFileSystem fileSystem, string? workspaceFilePath = nul Models = new DshModelCatalogSource(); Streams = new DshSessionStreamHub(Journal); Approvals = new DshApprovalBroker(); + WorkspaceFiles = new DshWorkspaceFilesService(Sessions, fileSystem); + Attachments = new DshAttachmentStore(fileSystem); } /// 自有引擎会话门面(组装完成后注入;未就绪时发送消息返回引导错误) @@ -47,4 +49,10 @@ internal DshHostServices(IFileSystem fileSystem, string? workspaceFilePath = nul /// 权限确认桥(引擎确认点 → $events waterfall → 前端回帖,TASK058 B1) internal DshApprovalBroker Approvals { get; } + + /// 工作区文件树(workspaceFiles/list|read|readBytes|stat,TASK058 P2-B1) + internal DshWorkspaceFilesService WorkspaceFiles { get; } + + /// 附件存储(fileUploads/upload 与 session/attachment,TASK058 P2-B2) + internal DshAttachmentStore Attachments { get; } } diff --git a/native_gui/src/NativeGui.Core/web_host/DshRpcDispatcher.cs b/native_gui/src/NativeGui.Core/web_host/DshRpcDispatcher.cs index 103112566..e279f5c92 100644 --- a/native_gui/src/NativeGui.Core/web_host/DshRpcDispatcher.cs +++ b/native_gui/src/NativeGui.Core/web_host/DshRpcDispatcher.cs @@ -64,6 +64,16 @@ internal DshRpcDispatcher(DshHostServices services) { ["workspace/unpinSession"] = WorkspaceUnpinSession, // 宿主暂不提供默认工作区(M2 起由自有引擎的会话/工作区目录决定) ["workspace/initializeDefault"] = Sync(_ => OkNull()), + // 工作区文件树(TASK058 P2-B1;changes 流属深化项,前端会收到未注册引导错误) + ["workspaceFiles/list"] = WorkspaceFilesList, + ["workspaceFiles/read"] = WorkspaceFilesRead, + ["workspaceFiles/readBytes"] = WorkspaceFilesReadBytes, + ["workspaceFiles/stat"] = WorkspaceFilesStat, + // 附件链(TASK058 P2-B2;uploadFileBinary 直传路由在 DshWebHost octet-stream 分支) + ["fileUploads/upload"] = FileUploadsUpload, + ["session/attachment"] = SessionAttachment, + // 队列编辑(TASK058 P2-B3;steer=引擎无注入点诚实拒绝) + ["session/updateQueue"] = SessionUpdateQueue, }.ToFrozenDictionary(StringComparer.Ordinal); } @@ -345,6 +355,114 @@ private DshRpcResult LlmListProviders(JsonElement args) => Ok(_services.Models.C .Select(group => new DshProviderInfo { Id = group.Id, Name = group.Name }) .ToList()); + /// workspaceFiles 四方法共用参数解析(失败返回稳定引导错误) + private DshRpcResult? ParseWorkspaceFilesArgs(JsonElement args, out string sessionId, out string path) { + var request = args.Deserialize(DshJsonContext.Default.DshWorkspaceFilesArgs); + sessionId = request?.WorkspaceFileScopeId ?? ""; + path = request?.Path ?? ""; + if (string.IsNullOrWhiteSpace(sessionId)) { + return Fail("workspaceFiles/rejected", + $"workspaceFiles 方法需要 workspaceFileScopeId(会话 ID)与 path;收到 args={Describe(args)}"); + } + return null; + } + + /// workspaceFiles/list:目录条目(目录在前按名排序) + private async Task WorkspaceFilesList(JsonElement args, CancellationToken cancellationToken) { + var error = ParseWorkspaceFilesArgs(args, out var sessionId, out var path); + if (error is not null) { + return error; + } + return await _services.WorkspaceFiles.ListAsync(sessionId, path, cancellationToken); + } + + /// workspaceFiles/read:读文本(全量 + 行数 + eof) + private async Task WorkspaceFilesRead(JsonElement args, CancellationToken cancellationToken) { + var error = ParseWorkspaceFilesArgs(args, out var sessionId, out var path); + if (error is not null) { + return error; + } + return await _services.WorkspaceFiles.ReadAsync(sessionId, path, cancellationToken); + } + + /// workspaceFiles/readBytes:读字节(base64) + private async Task WorkspaceFilesReadBytes(JsonElement args, CancellationToken cancellationToken) { + var error = ParseWorkspaceFilesArgs(args, out var sessionId, out var path); + if (error is not null) { + return error; + } + return await _services.WorkspaceFiles.ReadBytesAsync(sessionId, path, cancellationToken); + } + + /// workspaceFiles/stat:文件元数据(绝对路径 + version) + private async Task WorkspaceFilesStat(JsonElement args, CancellationToken cancellationToken) { + var error = ParseWorkspaceFilesArgs(args, out var sessionId, out var path); + if (error is not null) { + return error; + } + return await _services.WorkspaceFiles.StatAsync(sessionId, path, cancellationToken); + } + + /// fileUploads/upload:base64 附件 → 魔数校验 → receipt 落盘(TASK058 P2-B2) + private async Task FileUploadsUpload(JsonElement args, CancellationToken cancellationToken) { + var request = args.Deserialize(DshJsonContext.Default.DshFileUploadsUploadArgs); + if (request is null || string.IsNullOrWhiteSpace(request.AgentId) || request.Request is null + || string.IsNullOrEmpty(request.Request.Data)) { + return Fail("fileUploads/rejected", + $"fileUploads/upload 需要 {{agentId, request:{{data, name?}}}};收到 args={Describe(args)}"); + } + return await _services.Attachments.UploadAsync(request.AgentId, request.Request.Data, request.Request.Name, cancellationToken); + } + + /// 会话队列快照透传(updateQueue 响应与测试消费;internal 接缝) + internal IReadOnlyList ListQueue(string sessionId) => _turns.ListQueue(sessionId); + + /// + /// session/updateQueue:排队项编辑(TASK058 P2-B3)。edit=重写文本,remove=移除; + /// steer 返回 session/steer-unavailable(引擎 StreamAsync 一轮独占无轮内注入点 — 诚实引导, + /// 消息保持排队将在下一轮执行)。行不在队列/已被 claim 返回 session/queue-item-not-found。 + /// + private Task SessionUpdateQueue(JsonElement args, CancellationToken cancellationToken) { + var request = args.Deserialize(DshJsonContext.Default.DshUpdateQueueArgs); + if (request is null || string.IsNullOrWhiteSpace(request.SessionId) + || string.IsNullOrWhiteSpace(request.ItemId) || request.Action is null) { + return Task.FromResult(Fail("session/rejected", + $"session/updateQueue 需要 {{sessionId, itemId, action:{{kind, content?}}}};收到 args={Describe(args)}")); + } + if (string.Equals(request.Action.Kind, "steer", StringComparison.Ordinal)) { + return Task.FromResult(Fail("session/steer-unavailable", + "引擎暂不支持轮内注入(steer);该消息保持排队,将在当前轮结束后的下一轮执行")); + } + if (string.Equals(request.Action.Kind, "remove", StringComparison.Ordinal)) { + return Task.FromResult(_turns.TryRemoveQueueItem(request.SessionId, request.ItemId) + ? Ok(new DshQueueAcceptedResult()) + : Fail("session/queue-item-not-found", + $"队列项 {request.ItemId} 不在队列中(可能已被下一轮 claim);刷新队列后重试")); + } + if (string.Equals(request.Action.Kind, "edit", StringComparison.Ordinal)) { + var text = string.Concat(request.Action.Content?.Select(part => part.Text ?? "") ?? []); + if (string.IsNullOrWhiteSpace(text)) { + return Task.FromResult(Fail("session/rejected", "edit 的 content 需要非空文本")); + } + return Task.FromResult(_turns.TryEditQueueItem(request.SessionId, request.ItemId, text) + ? Ok(new DshQueueAcceptedResult()) + : Fail("session/queue-item-not-found", + $"队列项 {request.ItemId} 不在队列中(可能已被下一轮 claim);刷新队列后重试")); + } + return Task.FromResult(Fail("session/rejected", + $"updateQueue 的 action.kind 必须是 edit/remove/steer 之一;收到 {request.Action.Kind}")); + } + + /// session/attachment:读附件(元数据 + base64 数据,前端聊天内图片渲染) + private async Task SessionAttachment(JsonElement args, CancellationToken cancellationToken) { + var request = args.Deserialize(DshJsonContext.Default.DshSessionAttachmentArgs); + if (request is null || string.IsNullOrWhiteSpace(request.SessionId) || string.IsNullOrWhiteSpace(request.AttachmentId)) { + return Fail("session/attachment-not-found", + $"session/attachment 需要 {{sessionId, attachmentId}};收到 args={Describe(args)}"); + } + return await _services.Attachments.ReadAsync(request.SessionId, request.AttachmentId, cancellationToken); + } + /// commands/list:引擎斜杠命令目录投影(TASK058 B5 — 引擎 [ChatCommand] 特性为唯一数据源) private DshRpcResult CommandsList(JsonElement args) => Ok( (_services.Engine?.GetAvailableSlashCommands() ?? []) @@ -410,6 +528,7 @@ private async Task PermissionPresetsCatalog(JsonElement args, Canc }); } + private static DshRpcResult Ok(DshQueueAcceptedResult value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshQueueAcceptedResult)); private static DshRpcResult Ok(DshSessionList value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshSessionList)); private static DshRpcResult Ok(DshSessionRenameResult value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshSessionRenameResult)); diff --git a/native_gui/src/NativeGui.Core/web_host/DshRpcProtocol.cs b/native_gui/src/NativeGui.Core/web_host/DshRpcProtocol.cs index 4b8a04c22..7e3f167c7 100644 --- a/native_gui/src/NativeGui.Core/web_host/DshRpcProtocol.cs +++ b/native_gui/src/NativeGui.Core/web_host/DshRpcProtocol.cs @@ -141,6 +141,179 @@ internal sealed class DshEventErrorBody { /// $events/result 回帖响应(前端只看 ok,无业务载荷) internal sealed class DshEventsResultValue; +/// fileUploads/upload 参数(前端 lookup 字段 agentId 即会话/代理 ID;request 包裹 data+name) +internal sealed class DshFileUploadsUploadArgs { + /// 目标会话/代理 + public string AgentId { get; set; } = ""; + /// 上传请求体 + public DshFileUploadRequest? Request { get; set; } +} + +/// 上传请求体 +internal sealed class DshFileUploadRequest { + /// base64 数据 + public string Data { get; set; } = ""; + /// 文件名(可选) + public string? Name { get; set; } +} + +/// fileUploads/upload 响应(receiptId 进 prompt 的 file part) +internal sealed class DshFileUploadResult { + /// 回执 ID(同时是 attachmentId) + public string ReceiptId { get; set; } = ""; + /// 文件元数据 + public DshUploadedFileInfo File { get; set; } = new(); +} + +/// 上传文件元数据 +internal sealed class DshUploadedFileInfo { + /// 附件 ID + public string AttachmentId { get; set; } = ""; + /// 文件名 + public string Name { get; set; } = ""; + /// 字节数 + public int Bytes { get; set; } +} + +/// 附件落盘记录({receiptId}.json) +internal sealed class DshAttachmentRecord { + /// 附件 ID + public string AttachmentId { get; set; } = ""; + /// 文件名 + public string Name { get; set; } = ""; + /// 媒体类型(四类图片) + public string MediaType { get; set; } = ""; + /// 字节数 + public int Bytes { get; set; } + /// 宽 + public int Width { get; set; } + /// 高 + public int Height { get; set; } +} + +/// session/attachment 参数 +internal sealed class DshSessionAttachmentArgs { + /// 会话/代理(上传时的 owner) + public string SessionId { get; set; } = ""; + /// 附件 ID + public string AttachmentId { get; set; } = ""; +} + +/// session/attachment 响应(前端聊天内图片渲染) +internal sealed class DshSessionAttachmentResult { + /// 附件元数据 + public DshAttachmentInfo Attachment { get; set; } = new(); + /// base64 数据 + public string Data { get; set; } = ""; +} + +/// 附件元数据 +internal sealed class DshAttachmentInfo { + /// 附件 ID + public string AttachmentId { get; set; } = ""; + /// 媒体类型 + public string MediaType { get; set; } = ""; + /// 字节数 + public int Bytes { get; set; } + /// 宽 + public int Width { get; set; } + /// 高 + public int Height { get; set; } + /// 文件名 + public string? Name { get; set; } +} + +/// session/updateQueue 参数(QueueDock 行操作) +internal sealed class DshUpdateQueueArgs { + /// 会话 ID + public string SessionId { get; set; } = ""; + /// 队列项 ID(入队时分配的 GUID) + public string ItemId { get; set; } = ""; + /// 操作(edit/remove/steer) + public DshUpdateQueueAction? Action { get; set; } +} + +/// 队列操作 +internal sealed class DshUpdateQueueAction { + /// 操作类型 + public string Kind { get; set; } = ""; + /// edit 的新内容(text part 数组;其他 kind 恒空) + public List Content { get; set; } = []; +} + +/// updateQueue 受理响应 +internal sealed class DshQueueAcceptedResult { + /// 受理标志 + public bool Accepted => true; +} + +/// workspaceFiles 四方法共用参数(前端 lookup 字段 workspaceFileScopeId 即 sessionId) +internal sealed class DshWorkspaceFilesArgs { + /// 会话 ID(文件树根 = 会话 cwd) + public string WorkspaceFileScopeId { get; set; } = ""; + /// 相对路径(空 = 根目录) + public string Path { get; set; } = ""; +} + +/// workspaceFiles/list 响应 +internal sealed class DshWorkspaceFilesListResult { + /// 请求的相对路径(回显) + public string Path { get; set; } = ""; + /// 目录条目(目录在前按名排序) + public List Entries { get; set; } = []; + /// 是否超过截断阈值 + public bool Truncated { get; set; } +} + +/// 文件树条目 +internal sealed class DshWorkspaceFileEntry { + /// 名称(不含路径) + public string Name { get; set; } = ""; + /// 类型(file/directory/other) + public string Type { get; set; } = "file"; + /// 字节数(文件可选) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Size { get; set; } +} + +/// workspaceFiles/read 响应 +internal sealed class DshWorkspaceFilesReadResult { + /// 文本起始偏移(v1 恒 0) + public int Offset { get; set; } + /// 文本内容 + public string Text { get; set; } = ""; + /// 行数 + public int Lines { get; set; } + /// 是否读到末尾 + public bool Eof { get; set; } + /// 绝对路径 + public string AbsolutePath { get; set; } = ""; + /// 版本(size-mtime 合成,前端透传比较失效) + public string Version { get; set; } = ""; +} + +/// workspaceFiles/readBytes 响应(data 为 base64) +internal sealed class DshWorkspaceFilesReadBytesResult { + /// 字节起始偏移(v1 恒 0) + public int Offset { get; set; } + /// base64 编码字节 + public string Data { get; set; } = ""; + /// 是否读到末尾 + public bool Eof { get; set; } + /// 绝对路径 + public string AbsolutePath { get; set; } = ""; + /// 版本 + public string Version { get; set; } = ""; +} + +/// workspaceFiles/stat 响应 +internal sealed class DshWorkspaceFilesStatResult { + /// 绝对路径 + public string AbsolutePath { get; set; } = ""; + /// 版本 + public string Version { get; set; } = ""; +} + /// commands/list 条目(前端以 /{name} 组装命令行) internal sealed class DshCommandInfo { /// 命令名(无 / 前缀) @@ -1241,5 +1414,17 @@ internal sealed class DshStreamErrorBody { [JsonSerializable(typeof(JsonElement[]))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(DshRpcErrorDetails))] +[JsonSerializable(typeof(DshWorkspaceFilesArgs))] +[JsonSerializable(typeof(DshWorkspaceFilesListResult))] +[JsonSerializable(typeof(DshWorkspaceFilesReadResult))] +[JsonSerializable(typeof(DshWorkspaceFilesReadBytesResult))] +[JsonSerializable(typeof(DshWorkspaceFilesStatResult))] +[JsonSerializable(typeof(DshFileUploadsUploadArgs))] +[JsonSerializable(typeof(DshFileUploadResult))] +[JsonSerializable(typeof(DshAttachmentRecord))] +[JsonSerializable(typeof(DshSessionAttachmentArgs))] +[JsonSerializable(typeof(DshSessionAttachmentResult))] +[JsonSerializable(typeof(DshUpdateQueueArgs))] +[JsonSerializable(typeof(DshQueueAcceptedResult))] [JsonSerializable(typeof(object))] internal sealed partial class DshJsonContext : JsonSerializerContext; diff --git a/native_gui/src/NativeGui.Core/web_host/DshSessionStore.cs b/native_gui/src/NativeGui.Core/web_host/DshSessionStore.cs index 7f92898d0..c48180a33 100644 --- a/native_gui/src/NativeGui.Core/web_host/DshSessionStore.cs +++ b/native_gui/src/NativeGui.Core/web_host/DshSessionStore.cs @@ -207,6 +207,14 @@ private static List Records(string sessionId, IReadOnlyList读会话工作目录(workspaceFiles 文件树根);meta 缺失/无 ProjectPath 返回 null + internal async Task GetSessionCwdAsync(string sessionId, CancellationToken cancellationToken) { + if (_transcripts is not { } transcripts) { + return null; + } + return (await transcripts.GetSessionInfoAsync(sessionId, cancellationToken))?.ProjectPath; + } + /// /// 全部会话的投影映射(session/control 基线);引擎未就绪或没有会话时为空。 /// diff --git a/native_gui/src/NativeGui.Core/web_host/DshTurnRunner.cs b/native_gui/src/NativeGui.Core/web_host/DshTurnRunner.cs index b488bfca5..8424a47b2 100644 --- a/native_gui/src/NativeGui.Core/web_host/DshTurnRunner.cs +++ b/native_gui/src/NativeGui.Core/web_host/DshTurnRunner.cs @@ -40,12 +40,12 @@ internal sealed class DshTurnRunner { _services.Streams.Observe(request.SessionId, snapshot.Cursor, MaxTurn(snapshot.Records)); if (_services.Streams.TryBeginTurn(request.SessionId) is not { } turn) { if (string.Equals(request.Mode, "queue", StringComparison.Ordinal)) { - Queue(request.SessionId, new PendingPrompt(request.RequestId, text)); + Queue(request.SessionId, NewPending(request.RequestId, text)); return null; } return ("session/busy", $"会话 {request.SessionId} 上一轮仍在生成;等它结束(接口完成/停止)后再发送,排队请用 mode=queue"); } - await BeginTurnAsync(engine, request.SessionId, new PendingPrompt(request.RequestId, text), turn); + await BeginTurnAsync(engine, request.SessionId, NewPending(request.RequestId, text), turn); return null; } @@ -66,10 +66,14 @@ private async Task BeginTurnAsync(Hosting.IJccChatSession engine, string session _ = RunTurnAsync(engine, sessionId, prompt.Text, turn.Turn, turn.Token); } - /// 把输入放进会话队列 + /// 把输入放进会话队列(ItemId = GUID,前端 updateQueue 的行操作句柄) private void Queue(string sessionId, PendingPrompt prompt) => _pending.GetOrAdd(sessionId, _ => new System.Collections.Concurrent.ConcurrentQueue()).Enqueue(prompt); + /// 构造带操作句柄的排队项 + private static PendingPrompt NewPending(string requestId, string text) => + new(Guid.NewGuid().ToString("N"), requestId, text); + /// 轮次收尾后启动下一条排队输入(无排队则返回) private void StartQueued(Hosting.IJccChatSession engine, string sessionId) { if (!_pending.TryGetValue(sessionId, out var queue) || !queue.TryDequeue(out var prompt)) { @@ -240,5 +244,67 @@ private static int MaxTurn(IReadOnlyList records) => /// 排队中的一条用户输入 /// 提交身份(回填 user/message 的 source.rpcId) /// 已校验文本 - private sealed record PendingPrompt(string RequestId, string Text); + private sealed record PendingPrompt(string ItemId, string RequestId, string Text); + + /// 队列项快照(ListQueue 供 updateQueue 与测试消费) + internal sealed record QueueItem(string ItemId, string RequestId, string Text); + + /// 会话队列快照(按入队顺序) + internal IReadOnlyList ListQueue(string sessionId) { + if (!_pending.TryGetValue(sessionId, out var queue)) { + return []; + } + var snapshot = new List(); + foreach (var prompt in queue) { + snapshot.Add(new QueueItem(prompt.ItemId, prompt.RequestId, prompt.Text)); + } + return snapshot; + } + + /// 编辑排队项文本(无该项返回 false) + internal bool TryEditQueueItem(string sessionId, string itemId, string text) { + if (!_pending.TryGetValue(sessionId, out var queue)) { + return false; + } + lock (queue) { + var rewritten = false; + var rebuilt = new System.Collections.Concurrent.ConcurrentQueue(); + foreach (var prompt in queue) { + if (string.Equals(prompt.ItemId, itemId, StringComparison.Ordinal)) { + rebuilt.Enqueue(prompt with { Text = text }); + rewritten = true; + } else { + rebuilt.Enqueue(prompt); + } + } + if (!rewritten) { + return false; + } + _pending[sessionId] = rebuilt; + return true; + } + } + + /// 移除排队项(无该项返回 false) + internal bool TryRemoveQueueItem(string sessionId, string itemId) { + if (!_pending.TryGetValue(sessionId, out var queue)) { + return false; + } + lock (queue) { + var removed = false; + var rebuilt = new System.Collections.Concurrent.ConcurrentQueue(); + foreach (var prompt in queue) { + if (string.Equals(prompt.ItemId, itemId, StringComparison.Ordinal)) { + removed = true; + } else { + rebuilt.Enqueue(prompt); + } + } + if (!removed) { + return false; + } + _pending[sessionId] = rebuilt; + return true; + } + } } diff --git a/native_gui/src/NativeGui.Core/web_host/DshWebHost.cs b/native_gui/src/NativeGui.Core/web_host/DshWebHost.cs index 85ffe54a2..f2f123be9 100644 --- a/native_gui/src/NativeGui.Core/web_host/DshWebHost.cs +++ b/native_gui/src/NativeGui.Core/web_host/DshWebHost.cs @@ -16,6 +16,7 @@ internal sealed class DshWebHost : IAsyncDisposable { private const string StreamPath = "/api/remote.mux"; private const string ApiPrefix = "/api/"; private const string ComboPrefix = "/plugins/??"; + private const string BinaryUploadPath = "/api/session/uploadFileBinary"; private static readonly FrozenDictionary ContentTypes = new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -102,6 +103,11 @@ private async Task RouteAsync(HttpListenerContext context) { } var path = request.Url?.AbsolutePath ?? "/"; if (path.StartsWith(ApiPrefix, StringComparison.Ordinal) || path == "/api") { + // 附件直传(TASK058 P2-B2):POST /api/session/uploadFileBinary?sessionId=&name=,body 为 octet-stream + if (string.Equals(path, BinaryUploadPath, StringComparison.Ordinal)) { + await HandleBinaryUploadAsync(context); + return; + } await HandleRpcAsync(context); return; } @@ -112,6 +118,50 @@ private async Task RouteAsync(HttpListenerContext context) { await ServeFileAsync(context, path.TrimStart('/')); } + /// + /// 附件二进制直传:body 原样字节(Blob/流式大文件免 base64 膨胀),魔数校验与落盘复用 + /// ;响应与 RPC 同信封(前端 parseFileUploadResult 统一解析)。 + /// + private async Task HandleBinaryUploadAsync(HttpListenerContext context) { + var response = context.Response; + try { + var query = context.Request.Url?.Query ?? ""; + var sessionId = QueryValue(query, "sessionId"); + var name = QueryValue(query, "name"); + DshRpcResult result; + if (string.IsNullOrWhiteSpace(sessionId)) { + result = DshRpcResults.Fail("fileUploads/rejected", "uploadFileBinary 需要 sessionId 与 name 查询参数"); + } else { + using var buffer = new MemoryStream(); + await context.Request.InputStream.CopyToAsync(buffer); + result = await _services.Attachments.UploadAsync(sessionId, Convert.ToBase64String(buffer.ToArray()), name, _lifetime.Token); + } + var reply = new DshRpcResponse { RpcId = "upload", Result = result }; + var payload = JsonSerializer.SerializeToUtf8Bytes(reply, DshJsonContext.Default.DshRpcResponse); + response.StatusCode = 200; + response.ContentType = "application/json; charset=utf-8"; + response.Headers["Cache-Control"] = "no-store"; + response.ContentLength64 = payload.Length; + await response.OutputStream.WriteAsync(payload); + } catch (Exception ex) { + GuiDiag.Log($"[DSH-WEB] binary upload failed: {ex.Message}"); + TryClose(response, 500); + } finally { + TryClose(response, 200); + } + } + + /// 解析查询串单值(无解码需求:sessionId/name 均为简单 ASCII) + private static string? QueryValue(string query, string key) { + foreach (var pair in query.TrimStart('?').Split('&')) { + var equals = pair.IndexOf('='); + if (equals > 0 && string.Equals(pair[..equals], key, StringComparison.OrdinalIgnoreCase)) { + return pair[(equals + 1)..]; + } + } + return null; + } + /// 一元 RPC:解析信封 → 分发 → 回写信封(失败也回 200,错误在 result.error) private async Task HandleRpcAsync(HttpListenerContext context) { var response = context.Response; diff --git a/native_gui/src/NativeGui.Core/web_host/DshWorkspaceFilesService.cs b/native_gui/src/NativeGui.Core/web_host/DshWorkspaceFilesService.cs new file mode 100644 index 000000000..c5395b0d5 --- /dev/null +++ b/native_gui/src/NativeGui.Core/web_host/DshWorkspaceFilesService.cs @@ -0,0 +1,134 @@ +namespace JoinCode.Gui.WebHost; + +/// +/// 工作区文件树服务(TASK058 P2-B1):DSH 前端 sidebar-files 的 list/read/readBytes/stat 四一元方法后端。 +/// 根目录 = 会话 cwd(SessionInfo.ProjectPath);路径解析后必须落在根内(防 ../ 穿越); +/// version 用「size-mtimeTicks」合成(前端仅透传比较失效,无需内容哈希);changes 流属深化项。 +/// +internal sealed class DshWorkspaceFilesService(DshSessionStore sessions, IFileSystem fs) { + /// 列出目录条目(目录在前、名称不区分大小写排序),截断阈值 500 + internal async Task ListAsync(string sessionId, string path, CancellationToken cancellationToken) { + var (error, absolutePath) = await ResolveRootedAsync(sessionId, path, cancellationToken); + if (error is not null || absolutePath is null) { + return error ?? AsInternalError(); + } + if (!fs.DirectoryExists(absolutePath)) { + return DshRpcResults.Fail("workspaceFiles/not-found", $"目录不存在:{path};在文件树里刷新后重试"); + } + var directories = fs.GetDirectories(absolutePath, "*", SearchOption.TopDirectoryOnly) + .Select(name => Path.GetFileName(name)) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase); + var files = fs.GetFiles(absolutePath, "*", SearchOption.TopDirectoryOnly) + .Select(fullPath => new { Name = Path.GetFileName(fullPath), Size = TryGetLength(fullPath) }) + .OrderBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase); + var entries = new List(); + foreach (var name in directories) { + entries.Add(new DshWorkspaceFileEntry { Name = name, Type = "directory" }); + } + foreach (var file in files) { + entries.Add(new DshWorkspaceFileEntry { Name = file.Name, Type = "file", Size = file.Size }); + } + return DshRpcResults.Ok(JsonSerializer.SerializeToElement( + new DshWorkspaceFilesListResult { Path = path, Entries = entries, Truncated = entries.Count > 500 }, + DshJsonContext.Default.DshWorkspaceFilesListResult)); + } + + /// 读文本(v1 全量读 + 行数统计 + eof;range 分页属深化项,offset 恒 0) + internal async Task ReadAsync(string sessionId, string path, CancellationToken cancellationToken) { + var (error, absolutePath) = await ResolveRootedAsync(sessionId, path, cancellationToken); + if (error is not null || absolutePath is null) { + return error ?? AsInternalError(); + } + if (!fs.FileExists(absolutePath)) { + return DshRpcResults.Fail("workspaceFiles/not-found", $"文件不存在:{path};在文件树里刷新后重试"); + } + var text = await fs.ReadAllTextAsync(absolutePath, cancellationToken); + return DshRpcResults.Ok(JsonSerializer.SerializeToElement( + new DshWorkspaceFilesReadResult { + Offset = 0, Text = text, Lines = 1 + text.Count('\n'), Eof = true, + AbsolutePath = absolutePath, Version = VersionFor(absolutePath), + }, + DshJsonContext.Default.DshWorkspaceFilesReadResult)); + } + + /// 读字节(base64 编码返回;v1 全量读,大文件分页属深化项) + internal async Task ReadBytesAsync(string sessionId, string path, CancellationToken cancellationToken) { + var (error, absolutePath) = await ResolveRootedAsync(sessionId, path, cancellationToken); + if (error is not null || absolutePath is null) { + return error ?? AsInternalError(); + } + if (!fs.FileExists(absolutePath)) { + return DshRpcResults.Fail("workspaceFiles/not-found", $"文件不存在:{path};在文件树里刷新后重试"); + } + var bytes = await fs.ReadAllBytesAsync(absolutePath, cancellationToken); + return DshRpcResults.Ok(JsonSerializer.SerializeToElement( + new DshWorkspaceFilesReadBytesResult { + Offset = 0, Data = Convert.ToBase64String(bytes), Eof = true, + AbsolutePath = absolutePath, Version = VersionFor(absolutePath), + }, + DshJsonContext.Default.DshWorkspaceFilesReadBytesResult)); + } + + /// 文件元数据(绝对路径 + version) + internal async Task StatAsync(string sessionId, string path, CancellationToken cancellationToken) { + var (error, absolutePath) = await ResolveRootedAsync(sessionId, path, cancellationToken); + if (error is not null || absolutePath is null) { + return error ?? AsInternalError(); + } + if (!fs.FileExists(absolutePath)) { + return DshRpcResults.Fail("workspaceFiles/not-found", $"文件不存在:{path};在文件树里刷新后重试"); + } + return DshRpcResults.Ok(JsonSerializer.SerializeToElement( + new DshWorkspaceFilesStatResult { AbsolutePath = absolutePath, Version = VersionFor(absolutePath) }, + DshJsonContext.Default.DshWorkspaceFilesStatResult)); + } + + /// 解析「会话 cwd + 相对路径」为根内绝对路径;穿越/无 cwd 返回引导错误(非异常) + private async Task<(DshRpcResult? Error, string? AbsolutePath)> ResolveRootedAsync(string sessionId, string path, CancellationToken cancellationToken) { + var cwd = await sessions.GetSessionCwdAsync(sessionId, cancellationToken); + if (string.IsNullOrWhiteSpace(cwd)) { + return (DshRpcResults.Fail("workspaceFiles/rejected", + $"会话 {sessionId} 无工作目录(meta.json 缺 ProjectPath);用 workspace/create 建会话或重开会话后重试"), null); + } + // 手工段解析(弹栈式防穿越):不用 Path.GetFullPath —— 它会给「/x/y」补盘符, + // 与 InMemoryFileSystem 的字面量路径键不匹配;弹栈法同时杜绝「根前缀碰撞」误判 + var root = cwd.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var segments = new List(); + foreach (var part in path.Split('/', '\\')) { + if (part is "" or ".") { + continue; + } + if (part == "..") { + if (segments.Count == 0) { + return (DshRpcResults.Fail("workspaceFiles/rejected", + $"路径 {path} 解析到工作区外(根={root});请使用工作区内的相对路径"), null); + } + segments.RemoveAt(segments.Count - 1); + } else { + segments.Add(part); + } + } + var candidate = segments.Count == 0 + ? root + : root + Path.DirectorySeparatorChar + string.Join(Path.DirectorySeparatorChar, segments); + return (null, candidate); + } + + /// version 合成:size-mtimeTicks(前端透传比较失效即可,无需内容哈希) + private string VersionFor(string absolutePath) { + var length = TryGetLength(absolutePath) ?? 0; + var mtime = fs.GetLastWriteTimeUtc(absolutePath).Ticks; + return $"{length}-{mtime}"; + } + + private long? TryGetLength(string absolutePath) { + try { + return new FileInfo(absolutePath).Length; + } catch (Exception) { + return null; + } + } + + private static DshRpcResult AsInternalError() => DshRpcResults.Fail( + "workspaceFiles/rejected", "路径解析失败(内部错误);请刷新文件树后重试"); +} diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshAttachmentTests.cs b/native_gui/tests/join_code_gui.tests/web_host/DshAttachmentTests.cs new file mode 100644 index 000000000..f9df361e8 --- /dev/null +++ b/native_gui/tests/join_code_gui.tests/web_host/DshAttachmentTests.cs @@ -0,0 +1,114 @@ +namespace JoinCode.Gui.Tests.WebHost; + +/// +/// 附件链测试(TASK058 P2-B2):fileUploads/upload → receipt 落盘 → session/attachment 读取。 +/// mediaType 魔数探测(PNG/JPEG/GIF/WEBP)+ 尺寸头解析;非四类图片拒绝上传(前端 zod 值域如此)。 +/// +public sealed class DshAttachmentTests { + /// 1x1 红色 PNG 字节(合法 PNG:签名+IHDR+IDAT+IEND) + private static readonly byte[] PngBytes = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="); + + /// GIF89a 1x1 透明像素 + private static readonly byte[] GifBytes = Convert.FromBase64String( + "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"); + + /// 非图片字节(文本) + private static readonly byte[] TextBytes = "hello attachment"u8.ToArray(); + + private static (DshHostServices Services, string AgentId) CreateServices() { + var services = new DshHostServices(new InMemoryFileSystem()); + return (services, "agent-1"); + } + + private static JsonElement UploadArgs(string agentId, byte[] data, string? name = "pic.png") => + JsonSerializer.SerializeToElement(new { + agentId, + request = new { data = Convert.ToBase64String(data), name }, + }); + + [Fact] + public async Task Upload_Png_ReturnsReceiptAndFileMeta() { + var (services, agentId) = CreateServices(); + var dispatcher = new DshRpcDispatcher(services); + + var result = await dispatcher.HandleAsync("fileUploads/upload", UploadArgs(agentId, PngBytes)); + + result.Ok.Should().BeTrue(result.Error?.Message); + var value = result.Value.GetValueOrDefault(); + value.GetProperty("receiptId").GetString().Should().NotBeNullOrEmpty(); + var file = value.GetProperty("file"); + file.GetProperty("attachmentId").GetString().Should().NotBeNullOrEmpty(); + file.GetProperty("name").GetString().Should().Be("pic.png"); + file.GetProperty("bytes").GetInt32().Should().Be(PngBytes.Length); + } + + [Fact] + public async Task Upload_NonImage_ReturnsStableRejection() { + var (services, agentId) = CreateServices(); + var dispatcher = new DshRpcDispatcher(services); + + var result = await dispatcher.HandleAsync("fileUploads/upload", UploadArgs(agentId, TextBytes, "note.txt")); + + result.Ok.Should().BeFalse(); + result.Error?.Code.Should().Be("fileUploads/rejected"); + result.Error?.Message.Should().Contain("图片", "前端 zod 值域仅四种图片类型"); + } + + [Fact] + public async Task Attachment_RoundTrips_PngWithDimensions() { + var (services, agentId) = CreateServices(); + var dispatcher = new DshRpcDispatcher(services); + var upload = await dispatcher.HandleAsync("fileUploads/upload", UploadArgs(agentId, PngBytes)); + var receiptId = upload.Value.GetValueOrDefault().GetProperty("receiptId").GetString() + ?? throw new Xunit.Sdk.XunitException("upload 未返回 receiptId"); + + var result = await dispatcher.HandleAsync("session/attachment", JsonSerializer.SerializeToElement(new { + sessionId = agentId, + attachmentId = receiptId, + })); + + result.Ok.Should().BeTrue(result.Error?.Message); + var value = result.Value.GetValueOrDefault(); + var attachment = value.GetProperty("attachment"); + attachment.GetProperty("mediaType").GetString().Should().Be("image/png"); + attachment.GetProperty("bytes").GetInt32().Should().Be(PngBytes.Length); + attachment.GetProperty("width").GetInt32().Should().Be(1); + attachment.GetProperty("height").GetInt32().Should().Be(1); + var data = value.GetProperty("data").GetString() + ?? throw new Xunit.Sdk.XunitException("data 缺失"); + Convert.FromBase64String(data).Should().Equal(PngBytes); + } + + [Fact] + public async Task Attachment_GifDetected() { + var (services, agentId) = CreateServices(); + var dispatcher = new DshRpcDispatcher(services); + var upload = await dispatcher.HandleAsync("fileUploads/upload", UploadArgs(agentId, GifBytes, "dot.gif")); + var receiptId = upload.Value.GetValueOrDefault().GetProperty("receiptId").GetString() + ?? throw new Xunit.Sdk.XunitException("upload 未返回 receiptId"); + + var result = await dispatcher.HandleAsync("session/attachment", JsonSerializer.SerializeToElement(new { + sessionId = agentId, + attachmentId = receiptId, + })); + + result.Ok.Should().BeTrue(result.Error?.Message); + result.Value.GetValueOrDefault().GetProperty("attachment").GetProperty("mediaType").GetString() + .Should().Be("image/gif"); + } + + [Fact] + public async Task Attachment_UnknownId_ReturnsNotFound() { + var (services, agentId) = CreateServices(); + var dispatcher = new DshRpcDispatcher(services); + + var result = await dispatcher.HandleAsync("session/attachment", JsonSerializer.SerializeToElement(new { + sessionId = agentId, + attachmentId = "no-such-receipt", + })); + + result.Ok.Should().BeFalse(); + result.Error?.Code.Should().Be("session/attachment-not-found"); + } +} diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshUpdateQueueTests.cs b/native_gui/tests/join_code_gui.tests/web_host/DshUpdateQueueTests.cs new file mode 100644 index 000000000..43d2a6ea6 --- /dev/null +++ b/native_gui/tests/join_code_gui.tests/web_host/DshUpdateQueueTests.cs @@ -0,0 +1,129 @@ +namespace JoinCode.Gui.Tests.WebHost; + +/// +/// session/updateQueue 队列编辑测试(TASK058 P2-B3):mode=queue 排队项的 remove/edit 随机访问; +/// steer 返回 session/steer-unavailable(引擎无轮内注入点,诚实引导);操作不存在项返回 queue-item-not-found。 +/// +public sealed class DshUpdateQueueTests { + private static JsonElement QueueArgs(string sessionId, string itemId, string kind, string? text = null) => + text is null + ? JsonSerializer.SerializeToElement(new { + sessionId, itemId, + action = new { kind }, + }) + : JsonSerializer.SerializeToElement(new { + sessionId, itemId, + action = new { kind, content = new[] { new { type = "text", text } } }, + }); + + [Fact] + public async Task UpdateQueue_Remove_DeletesQueuedItem() { + var fs = new InMemoryFileSystem(); + var services = new DshHostServices(fs); + await using var transcripts = new State.TranscriptService(fs, "/test/sessions"); + services.Sessions.AttachTranscripts(transcripts); + var hold = new TaskCompletionSource(); + await using var engine = new PlaceholderChatSession { TurnGate = ct => hold.Task.WaitAsync(ct) }; + services.AttachEngine(engine); + var dispatcher = new DshRpcDispatcher(services); + try { + var created = await services.Sessions.CreateAsync(null, "/test/proj", "deepseek", "deepseek-v4-pro", CancellationToken.None); + var sessionId = (created ?? throw new Xunit.Sdk.XunitException("session/create 失败")).SessionId; + // 第一条占住轮(引擎 busy — TurnGate 挂起 StreamAsync 不返回) + var first = await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "第一问")); + first.Ok.Should().BeTrue(first.Error?.Message); + // 第二/三条排队 + (await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "第二问", queue: true))).Ok.Should().BeTrue(); + (await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "第三问", queue: true))).Ok.Should().BeTrue(); + var items = dispatcher.ListQueue(sessionId); + items.Should().HaveCount(2); + var victim = items[0].ItemId; + + var result = await dispatcher.HandleAsync("session/updateQueue", QueueArgs(sessionId, victim, "remove")); + + result.Ok.Should().BeTrue(result.Error?.Message); + dispatcher.ListQueue(sessionId).Should().ContainSingle().Which.Text.Should().Be("第三问"); + } finally { hold.TrySetCanceled(); } + } + + [Fact] + public async Task UpdateQueue_Edit_RewritesQueuedText() { + var fs = new InMemoryFileSystem(); + var services = new DshHostServices(fs); + await using var transcripts = new State.TranscriptService(fs, "/test/sessions"); + services.Sessions.AttachTranscripts(transcripts); + var hold = new TaskCompletionSource(); + await using var engine = new PlaceholderChatSession { TurnGate = ct => hold.Task.WaitAsync(ct) }; + services.AttachEngine(engine); + var dispatcher = new DshRpcDispatcher(services); + try { + var created = await services.Sessions.CreateAsync(null, "/test/proj", "deepseek", "deepseek-v4-pro", CancellationToken.None); + var sessionId = (created ?? throw new Xunit.Sdk.XunitException("session/create 失败")).SessionId; + (await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "第一问"))).Ok.Should().BeTrue(); + (await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "旧文本", queue: true))).Ok.Should().BeTrue(); + var itemId = dispatcher.ListQueue(sessionId).Single().ItemId; + + var result = await dispatcher.HandleAsync("session/updateQueue", QueueArgs(sessionId, itemId, "edit", "新文本")); + + result.Ok.Should().BeTrue(result.Error?.Message); + dispatcher.ListQueue(sessionId).Single().Text.Should().Be("新文本"); + } finally { hold.TrySetCanceled(); } + } + + [Fact] + public async Task UpdateQueue_Steer_ReturnsStableUnavailable() { + var fs = new InMemoryFileSystem(); + var services = new DshHostServices(fs); + await using var transcripts = new State.TranscriptService(fs, "/test/sessions"); + services.Sessions.AttachTranscripts(transcripts); + var hold = new TaskCompletionSource(); + await using var engine = new PlaceholderChatSession { TurnGate = ct => hold.Task.WaitAsync(ct) }; + services.AttachEngine(engine); + var dispatcher = new DshRpcDispatcher(services); + try { + var created = await services.Sessions.CreateAsync(null, "/test/proj", "deepseek", "deepseek-v4-pro", CancellationToken.None); + var sessionId = (created ?? throw new Xunit.Sdk.XunitException("session/create 失败")).SessionId; + (await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "第一问"))).Ok.Should().BeTrue(); + (await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "排队问", queue: true))).Ok.Should().BeTrue(); + var itemId = dispatcher.ListQueue(sessionId).Single().ItemId; + + var result = await dispatcher.HandleAsync("session/updateQueue", QueueArgs(sessionId, itemId, "steer")); + + result.Ok.Should().BeFalse(); + result.Error?.Code.Should().Be("session/steer-unavailable"); + result.Error?.Message.Should().Contain("排队", "引导用户该消息将在下一轮执行"); + } finally { hold.TrySetCanceled(); } + } + + [Fact] + public async Task UpdateQueue_UnknownItem_ReturnsNotFound() { + var fs = new InMemoryFileSystem(); + var services = new DshHostServices(fs); + await using var transcripts = new State.TranscriptService(fs, "/test/sessions"); + services.Sessions.AttachTranscripts(transcripts); + var hold = new TaskCompletionSource(); + await using var engine = new PlaceholderChatSession { TurnGate = ct => hold.Task.WaitAsync(ct) }; + services.AttachEngine(engine); + var dispatcher = new DshRpcDispatcher(services); + try { + var created = await services.Sessions.CreateAsync(null, "/test/proj", "deepseek", "deepseek-v4-pro", CancellationToken.None); + var sessionId = (created ?? throw new Xunit.Sdk.XunitException("session/create 失败")).SessionId; + (await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "第一问"))).Ok.Should().BeTrue(); + + var result = await dispatcher.HandleAsync("session/updateQueue", QueueArgs(sessionId, "no-such-item", "remove")); + + result.Ok.Should().BeFalse(); + result.Error?.Code.Should().Be("session/queue-item-not-found"); + } finally { hold.TrySetCanceled(); } + } + + private static JsonElement PromptArgs(string sessionId, string text, bool queue = false) => + JsonSerializer.SerializeToElement(new { + request = new { + requestId = Guid.NewGuid().ToString("N"), + sessionId, + mode = queue ? "queue" : "send", + content = new[] { new { type = "text", text } }, + }, + }); +} diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshWorkspaceFilesTests.cs b/native_gui/tests/join_code_gui.tests/web_host/DshWorkspaceFilesTests.cs new file mode 100644 index 000000000..ac4513607 --- /dev/null +++ b/native_gui/tests/join_code_gui.tests/web_host/DshWorkspaceFilesTests.cs @@ -0,0 +1,114 @@ +namespace JoinCode.Gui.Tests.WebHost; + +/// +/// workspaceFiles 文件树测试(TASK058 P2-B1):list/read/readBytes/stat 四一元方法, +/// 根目录 = 会话 cwd(SessionInfo.ProjectPath),路径限制在 cwd 内防穿越,version 用 size-mtime 合成。 +/// +public sealed class DshWorkspaceFilesTests { + /// 构造带文件树的会话(cwd 下建子目录与文本文件) + private static async Task<(DshHostServices Services, string SessionId, string Cwd)> CreateSessionWithFilesAsync() { + var fs = new InMemoryFileSystem(); + var services = new DshHostServices(fs); + await using var transcripts = new State.TranscriptService(fs, "/test/sessions"); + services.Sessions.AttachTranscripts(transcripts); + var created = await services.Sessions.CreateAsync(null, "/test/proj", "deepseek", "deepseek-v4-pro", CancellationToken.None); + var sessionId = (created ?? throw new Xunit.Sdk.XunitException("session/create 失败")).SessionId; + await fs.WriteAllTextAsync("/test/proj/readme.md", "# hello\nline2\nline3\n"); + await fs.WriteAllTextAsync("/test/proj/app/main.cs", "Console.WriteLine(1);\n"); + fs.CreateDirectory("/test/proj/docs"); + return (services, sessionId, "/test/proj"); + } + + private static JsonElement FilesArgs(string sessionId, string path) => JsonSerializer.SerializeToElement(new { + workspaceFileScopeId = sessionId, + path, + }); + + [Fact] + public async Task List_ReturnsEntriesSortedWithTypes() { + var (services, sessionId, _) = await CreateSessionWithFilesAsync(); + var dispatcher = new DshRpcDispatcher(services); + + var result = await dispatcher.HandleAsync("workspaceFiles/list", FilesArgs(sessionId, "")); + + result.Ok.Should().BeTrue(result.Error?.Message); + var value = result.Value.GetValueOrDefault(); + value.GetProperty("path").GetString().Should().Be(""); + var entries = value.GetProperty("entries"); + entries.GetArrayLength().Should().Be(3); + entries[0].GetProperty("name").GetString().Should().Be("app"); + entries[0].GetProperty("type").GetString().Should().Be("directory"); + entries[1].GetProperty("name").GetString().Should().Be("docs"); + entries[2].GetProperty("name").GetString().Should().Be("readme.md"); + entries[2].GetProperty("type").GetString().Should().Be("file"); + value.GetProperty("truncated").GetBoolean().Should().BeFalse(); + } + + [Fact] + public async Task Read_ReturnsTextWithOffsetAndEof() { + var (services, sessionId, _) = await CreateSessionWithFilesAsync(); + var dispatcher = new DshRpcDispatcher(services); + + var result = await dispatcher.HandleAsync("workspaceFiles/read", FilesArgs(sessionId, "readme.md")); + + result.Ok.Should().BeTrue(result.Error?.Message); + var value = result.Value.GetValueOrDefault(); + value.GetProperty("text").GetString().Should().Contain("# hello"); + value.GetProperty("lines").GetInt32().Should().Be(4); + value.GetProperty("eof").GetBoolean().Should().BeTrue(); + value.GetProperty("absolutePath").GetString().Should().Contain("readme.md"); + value.GetProperty("version").GetString().Should().NotBeNullOrEmpty("version=size-mtime 合成"); + } + + [Fact] + public async Task ReadBytes_ReturnsBase64Data() { + var (services, sessionId, _) = await CreateSessionWithFilesAsync(); + var dispatcher = new DshRpcDispatcher(services); + + var result = await dispatcher.HandleAsync("workspaceFiles/readBytes", FilesArgs(sessionId, "readme.md")); + + result.Ok.Should().BeTrue(result.Error?.Message); + var value = result.Value.GetValueOrDefault(); + var data = value.GetProperty("data").GetString() + ?? throw new Xunit.Sdk.XunitException("data 缺失"); + data.Should().NotBeNullOrEmpty("data 为 base64"); + Convert.FromBase64String(data).Length.Should().BeGreaterThan(0); + value.GetProperty("eof").GetBoolean().Should().BeTrue(); + } + + [Fact] + public async Task Stat_ReturnsAbsolutePathAndVersion() { + var (services, sessionId, _) = await CreateSessionWithFilesAsync(); + var dispatcher = new DshRpcDispatcher(services); + + var result = await dispatcher.HandleAsync("workspaceFiles/stat", FilesArgs(sessionId, "app/main.cs")); + + result.Ok.Should().BeTrue(result.Error?.Message); + var value = result.Value.GetValueOrDefault(); + value.GetProperty("absolutePath").GetString().Should().Contain("main.cs"); + value.GetProperty("version").GetString().Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Read_PathTraversal_ReturnsStableError() { + var (services, sessionId, _) = await CreateSessionWithFilesAsync(); + var dispatcher = new DshRpcDispatcher(services); + + var result = await dispatcher.HandleAsync("workspaceFiles/read", FilesArgs(sessionId, "../../etc/passwd")); + + result.Ok.Should().BeFalse(); + result.Error?.Code.Should().Be("workspaceFiles/rejected"); + result.Error?.Message.Should().Contain("工作区外", "路径穿越必须被拦截并给出引导"); + } + + [Fact] + public async Task Read_MissingFile_ReturnsNotFound() { + var (services, sessionId, _) = await CreateSessionWithFilesAsync(); + var dispatcher = new DshRpcDispatcher(services); + + var result = await dispatcher.HandleAsync("workspaceFiles/read", FilesArgs(sessionId, "no-such.txt")); + + result.Ok.Should().BeFalse(); + result.Error?.Code.Should().Be("workspaceFiles/not-found"); + } +}