diff --git a/OpenCodeClient/OpenCodeClient/AppState.swift b/OpenCodeClient/OpenCodeClient/AppState.swift index 076085d..ddb1b31 100644 --- a/OpenCodeClient/OpenCodeClient/AppState.swift +++ b/OpenCodeClient/OpenCodeClient/AppState.swift @@ -8,6 +8,21 @@ import CryptoKit import Observation import os +enum ChatAttachmentKind: String, Codable, Sendable { + case image + case pdf + case text +} + +struct ComposerAttachment: Identifiable, Hashable, Sendable { + let id: String + let filename: String + let mimeType: String + let dataURL: String + let kind: ChatAttachmentKind + let byteCount: Int +} + struct SessionNode: Identifiable { let session: Session let children: [SessionNode] @@ -167,6 +182,7 @@ final class AppState { private static let draftInputsBySessionKey = "draftInputsBySession" private static let selectedModelBySessionKey = "selectedModelBySession" private static let showArchivedSessionsKey = "showArchivedSessions" + private static let showAttachmentButtonKey = "showAttachmentButton" private static let selectedProjectWorktreeKey = "selectedProjectWorktree" private static let customProjectPathKey = "customProjectPath" @@ -196,6 +212,7 @@ final class AppState { _aiBuilderCustomPrompt = UserDefaults.standard.string(forKey: Self.aiBuilderCustomPromptKey) ?? Self.defaultAIBuilderCustomPrompt _aiBuilderTerminology = UserDefaults.standard.string(forKey: Self.aiBuilderTerminologyKey) ?? Self.defaultAIBuilderTerminology _showArchivedSessions = UserDefaults.standard.bool(forKey: Self.showArchivedSessionsKey) + _showAttachmentButton = UserDefaults.standard.bool(forKey: Self.showAttachmentButtonKey) _selectedProjectWorktree = UserDefaults.standard.string(forKey: Self.selectedProjectWorktreeKey) _customProjectPath = UserDefaults.standard.string(forKey: Self.customProjectPathKey) ?? "" @@ -222,6 +239,7 @@ final class AppState { // Unsent composer drafts per session. private var draftInputsBySessionID: [String: String] = [:] + private var composerAttachmentsBySessionID: [String: [ComposerAttachment]] = [:] // Selected model (providerID/modelID) per session. private var selectedModelIDBySessionID: [String: String] = [:] @@ -259,6 +277,32 @@ final class AppState { } } + func composerAttachments(for sessionID: String?) -> [ComposerAttachment] { + guard let sessionID else { return [] } + return composerAttachmentsBySessionID[sessionID] ?? [] + } + + func appendComposerAttachments(_ attachments: [ComposerAttachment], for sessionID: String?) { + guard let sessionID, !attachments.isEmpty else { return } + composerAttachmentsBySessionID[sessionID, default: []].append(contentsOf: attachments) + } + + func removeComposerAttachment(id: String, for sessionID: String?) { + guard let sessionID else { return } + var existing = composerAttachmentsBySessionID[sessionID] ?? [] + existing.removeAll { $0.id == id } + composerAttachmentsBySessionID[sessionID] = existing.isEmpty ? nil : existing + } + + func clearComposerAttachments(for sessionID: String?) { + guard let sessionID else { return } + composerAttachmentsBySessionID[sessionID] = nil + } + + func clearAllComposerAttachments() { + composerAttachmentsBySessionID.removeAll() + } + private static func aiBuilderSignature(baseURL: String, token: String) -> String { let base = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) let tok = token.trimmingCharacters(in: .whitespacesAndNewlines) @@ -441,6 +485,17 @@ final class AppState { } } private var _showArchivedSessions: Bool = false + var showAttachmentButton: Bool { + get { _showAttachmentButton } + set { + _showAttachmentButton = newValue + UserDefaults.standard.set(newValue, forKey: Self.showAttachmentButtonKey) + if !newValue { + clearAllComposerAttachments() + } + } + } + private var _showAttachmentButton: Bool = false var expandedSessionIDs: Set = [] var projects: [Project] = [] @@ -556,6 +611,28 @@ final class AppState { guard modelPresets.indices.contains(selectedModelIndex) else { return nil } return modelPresets[selectedModelIndex] } + + func attachmentSupportError(for attachments: [ComposerAttachment], model: Message.ModelInfo?) -> String? { + guard !attachments.isEmpty else { return nil } + guard let model else { return nil } + guard let providerModel = providerModelsIndex["\(model.providerID)/\(model.modelID)"] else { return nil } + + if let attachment = providerModel.attachment, !attachment { + return L10n.t(.chatAttachmentUnsupportedModel) + } + + let inputModalities = Set(providerModel.modalities?.input ?? []) + if !inputModalities.isEmpty { + if attachments.contains(where: { $0.kind == .image }) && !inputModalities.contains("image") { + return L10n.t(.chatAttachmentUnsupportedImages) + } + if attachments.contains(where: { $0.kind == .pdf }) && !inputModalities.contains("pdf") { + return L10n.t(.chatAttachmentUnsupportedPDFs) + } + } + + return nil + } var selectedAgent: AgentInfo? { let visibleAgents = agents.filter { $0.isVisible } @@ -1270,11 +1347,32 @@ final class AppState { sendError = L10n.t(.chatSelectSessionFirst) return false } - let tempMessageID = appendOptimisticUserMessage(text) + let attachments = composerAttachments(for: sessionID) let model = selectedModel.map { Message.ModelInfo(providerID: $0.providerID, modelID: $0.modelID) } + if let supportError = attachmentSupportError(for: attachments, model: model) { + sendError = supportError + return false + } + + let tempMessageID = appendOptimisticUserMessage(text, attachments: attachments) let agentName = selectedAgent?.name ?? "build" do { - try await apiClient.promptAsync(sessionID: sessionID, text: text, agent: agentName, model: model) + try await apiClient.promptAsync( + sessionID: sessionID, + text: text, + attachments: attachments.map { + ComposerAttachmentPayload( + filename: $0.filename, + mimeType: $0.mimeType, + dataURL: $0.dataURL, + kind: $0.kind, + byteCount: $0.byteCount + ) + }, + agent: agentName, + model: model + ) + clearComposerAttachments(for: sessionID) return true } catch { let recovered = await recoverFromMissingCurrentSessionIfNeeded(error: error, requestedSessionID: sessionID) @@ -1285,11 +1383,10 @@ final class AppState { } @discardableResult - func appendOptimisticUserMessage(_ text: String) -> String { + func appendOptimisticUserMessage(_ text: String, attachments: [ComposerAttachment] = []) -> String { guard let sessionID = currentSessionID else { return "" } let now = Int(Date().timeIntervalSince1970 * 1000) let messageID = "temp-user-\(UUID().uuidString)" - let partID = "temp-part-\(messageID)" let message = Message( id: messageID, sessionID: sessionID, @@ -1304,21 +1401,32 @@ final class AppState { tokens: nil, cost: nil ) - let part = Part( - id: partID, - messageID: messageID, - sessionID: sessionID, - type: "text", - text: text, - tool: nil, - callID: nil, - state: nil, - metadata: nil, - files: nil - ) - let row = MessageWithParts(info: message, parts: [part]) + var parts: [Part] = [] + if !text.isEmpty { + parts.append( + Part( + id: "temp-part-text-\(messageID)", + messageID: messageID, + sessionID: sessionID, + type: "text", + text: text + ) + ) + } + parts.append(contentsOf: attachments.map { attachment in + Part( + id: "temp-part-file-\(attachment.id)", + messageID: messageID, + sessionID: sessionID, + type: "file", + mime: attachment.mimeType, + filename: attachment.filename, + url: attachment.dataURL + ) + }) + let row = MessageWithParts(info: message, parts: parts) messages.append(row) - partsByMessage[messageID] = [part] + partsByMessage[messageID] = parts return messageID } diff --git a/OpenCodeClient/OpenCodeClient/Models/Message.swift b/OpenCodeClient/OpenCodeClient/Models/Message.swift index 53419e9..84e0166 100644 --- a/OpenCodeClient/OpenCodeClient/Models/Message.swift +++ b/OpenCodeClient/OpenCodeClient/Models/Message.swift @@ -229,12 +229,45 @@ struct Part: Codable, Identifiable { let sessionID: String let type: String let text: String? + let mime: String? + let filename: String? + let url: String? let tool: String? let callID: String? let state: PartStateBridge? let metadata: PartMetadata? let files: [FileChange]? + init( + id: String, + messageID: String, + sessionID: String, + type: String, + text: String? = nil, + mime: String? = nil, + filename: String? = nil, + url: String? = nil, + tool: String? = nil, + callID: String? = nil, + state: PartStateBridge? = nil, + metadata: PartMetadata? = nil, + files: [FileChange]? = nil + ) { + self.id = id + self.messageID = messageID + self.sessionID = sessionID + self.type = type + self.text = text + self.mime = mime + self.filename = filename + self.url = url + self.tool = tool + self.callID = callID + self.state = state + self.metadata = metadata + self.files = files + } + /// For UI display; handles both string and object state var stateDisplay: String? { state?.displayString } /// 调用的理由/描述(用于 tool label) @@ -316,9 +349,18 @@ struct Part: Codable, Identifiable { } var isText: Bool { type == "text" } + var isFile: Bool { type == "file" } var isReasoning: Bool { type == "reasoning" } var isTool: Bool { type == "tool" } var isPatch: Bool { type == "patch" } + var displayFilename: String { + if let filename, !filename.isEmpty { return filename } + if let url, let parsed = URL(string: url) { + let candidate = parsed.lastPathComponent + if !candidate.isEmpty { return candidate } + } + return "Attachment" + } /// 可跳转的文件路径列表:来自 files 数组、metadata.path、或 state.input 中的 path/patchText 解析 var filePathsForNavigation: [String] { diff --git a/OpenCodeClient/OpenCodeClient/Services/APIClient.swift b/OpenCodeClient/OpenCodeClient/Services/APIClient.swift index 9aa4c50..ee33d29 100644 --- a/OpenCodeClient/OpenCodeClient/Services/APIClient.swift +++ b/OpenCodeClient/OpenCodeClient/Services/APIClient.swift @@ -5,6 +5,14 @@ import Foundation +struct ComposerAttachmentPayload: Sendable, Hashable { + let filename: String + let mimeType: String + let dataURL: String + let kind: ChatAttachmentKind + let byteCount: Int +} + actor APIClient { private var baseURL: String private var username: String? @@ -233,14 +241,37 @@ actor APIClient { return try? decoder.decode(type, from: data) } - func promptAsync(sessionID: String, text: String, agent: String = "build", model: Message.ModelInfo?) async throws { + func promptAsync( + sessionID: String, + text: String, + attachments: [ComposerAttachmentPayload] = [], + agent: String = "build", + model: Message.ModelInfo? + ) async throws { struct PromptBody: Encodable { let parts: [PartInput] let agent: String let model: ModelInput? struct PartInput: Encodable { - let type = "text" - let text: String + let type: String + let text: String? + let mime: String? + let filename: String? + let url: String? + + static func text(_ text: String) -> Self { + .init(type: "text", text: text, mime: nil, filename: nil, url: nil) + } + + static func file(_ attachment: ComposerAttachmentPayload) -> Self { + .init( + type: "file", + text: nil, + mime: attachment.mimeType, + filename: attachment.filename, + url: attachment.dataURL, + ) + } } struct ModelInput: Encodable { let providerID: String @@ -248,7 +279,7 @@ actor APIClient { } } let body = PromptBody( - parts: [.init(text: text)], + parts: [.text(text)] + attachments.map(PromptBody.PartInput.file), agent: agent, model: model.map { .init(providerID: $0.providerID, modelID: $0.modelID) } ) @@ -560,6 +591,8 @@ struct ProviderModel: Decodable { let id: String let name: String? let providerID: String? + let attachment: Bool? + let modalities: ProviderModelModalities? let limit: ProviderModelLimit? private enum CodingKeys: String, CodingKey { @@ -567,13 +600,24 @@ struct ProviderModel: Decodable { case name case providerID case providerId + case attachment + case modalities case limit } - init(id: String, name: String?, providerID: String?, limit: ProviderModelLimit?) { + init( + id: String, + name: String?, + providerID: String?, + attachment: Bool? = nil, + modalities: ProviderModelModalities? = nil, + limit: ProviderModelLimit? + ) { self.id = id self.name = name self.providerID = providerID + self.attachment = attachment + self.modalities = modalities self.limit = limit } @@ -582,10 +626,33 @@ struct ProviderModel: Decodable { id = (try? c.decode(String.self, forKey: .id)) ?? "" name = try? c.decode(String.self, forKey: .name) providerID = (try? c.decode(String.self, forKey: .providerID)) ?? (try? c.decode(String.self, forKey: .providerId)) + attachment = try? c.decode(Bool.self, forKey: .attachment) + modalities = try? c.decode(ProviderModelModalities.self, forKey: .modalities) limit = try? c.decode(ProviderModelLimit.self, forKey: .limit) } } +struct ProviderModelModalities: Codable { + let input: [String] + let output: [String] + + private enum CodingKeys: String, CodingKey { + case input + case output + } + + init(input: [String], output: [String]) { + self.input = input + self.output = output + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + input = try c.decodeIfPresent([String].self, forKey: .input) ?? [] + output = try c.decodeIfPresent([String].self, forKey: .output) ?? [] + } +} + struct ProviderModelLimit: Codable { let context: Int? let input: Int? @@ -617,7 +684,7 @@ protocol APIClientProtocol: Actor { func updateSession(sessionID: String, title: String) async throws -> Session func deleteSession(sessionID: String) async throws func messages(sessionID: String, limit: Int?) async throws -> [MessageWithParts] - func promptAsync(sessionID: String, text: String, agent: String, model: Message.ModelInfo?) async throws + func promptAsync(sessionID: String, text: String, attachments: [ComposerAttachmentPayload], agent: String, model: Message.ModelInfo?) async throws func abort(sessionID: String) async throws func sessionStatus() async throws -> [String: SessionStatus] func pendingPermissions() async throws -> [APIClient.PermissionRequest] diff --git a/OpenCodeClient/OpenCodeClient/Support/L10n.swift b/OpenCodeClient/OpenCodeClient/Support/L10n.swift index e31893d..33e61c6 100644 --- a/OpenCodeClient/OpenCodeClient/Support/L10n.swift +++ b/OpenCodeClient/OpenCodeClient/Support/L10n.swift @@ -78,6 +78,9 @@ enum L10n { case settingsShowArchivedSessions case settingsConnecting + case settingsChat + case settingsShowAttachmentButton + case settingsAttachmentButtonHelp case settingsProject case settingsProjectServerDefault case settingsProjectCustomPath @@ -107,6 +110,20 @@ enum L10n { case chatSessionStatusIdle case chatPullToLoadMore case chatLoadingMoreHistory + case chatChooseAttachment + case chatChooseImage + case chatChooseFile + case chatAttachmentMenuTitle + case chatAttachmentUnsupportedModel + case chatAttachmentUnsupportedImages + case chatAttachmentUnsupportedPDFs + case chatAttachmentUnsupportedFileType + case chatAttachmentFileTooLarge + case chatAttachmentReadFailed + case chatAttachmentRemove + case chatAttachmentImageLabel + case chatAttachmentPDFLabel + case chatAttachmentTextLabel case permissionRequired case permissionAllowOnce @@ -286,6 +303,9 @@ enum L10n { Key.settingsRotate.rawValue: "Rotate", Key.settingsShowArchivedSessions.rawValue: "Show Archived Sessions", Key.settingsConnecting.rawValue: "Connecting...", + Key.settingsChat.rawValue: "Chat", + Key.settingsShowAttachmentButton.rawValue: "Show Attachment Button", + Key.settingsAttachmentButtonHelp.rawValue: "Show an attachment menu in the chat composer for images, PDFs, and text files.", Key.settingsProject.rawValue: "Project (Workspace)", Key.settingsProjectServerDefault.rawValue: "Server default", Key.settingsProjectCustomPath.rawValue: "Custom path", @@ -315,6 +335,20 @@ enum L10n { Key.chatSessionStatusIdle.rawValue: "Idle", Key.chatPullToLoadMore.rawValue: "Pull down to load more history", Key.chatLoadingMoreHistory.rawValue: "Loading more history...", + Key.chatChooseAttachment.rawValue: "Add Attachment", + Key.chatChooseImage.rawValue: "Choose Image", + Key.chatChooseFile.rawValue: "Choose File", + Key.chatAttachmentMenuTitle.rawValue: "Add to message", + Key.chatAttachmentUnsupportedModel.rawValue: "The selected model does not support file attachments.", + Key.chatAttachmentUnsupportedImages.rawValue: "The selected model does not support image attachments.", + Key.chatAttachmentUnsupportedPDFs.rawValue: "The selected model does not support PDF attachments.", + Key.chatAttachmentUnsupportedFileType.rawValue: "Only images, PDFs, and text files are supported.", + Key.chatAttachmentFileTooLarge.rawValue: "This attachment is too large to send from iPhone.", + Key.chatAttachmentReadFailed.rawValue: "Unable to read the selected attachment.", + Key.chatAttachmentRemove.rawValue: "Remove attachment", + Key.chatAttachmentImageLabel.rawValue: "Image", + Key.chatAttachmentPDFLabel.rawValue: "PDF", + Key.chatAttachmentTextLabel.rawValue: "Text file", Key.permissionRequired.rawValue: "Permission Required", Key.permissionAllowOnce.rawValue: "Allow Once", @@ -498,6 +532,9 @@ enum L10n { Key.errorUsingLanHttp.rawValue: "正在使用 LAN HTTP", Key.settingsShowArchivedSessions.rawValue: "显示已归档会话", Key.settingsConnecting.rawValue: "连接中...", + Key.settingsChat.rawValue: "聊天", + Key.settingsShowAttachmentButton.rawValue: "显示附件按钮", + Key.settingsAttachmentButtonHelp.rawValue: "在聊天输入区显示附件入口,可发送图片、PDF 和文本文件。", Key.settingsProject.rawValue: "项目 (Workspace)", Key.settingsProjectServerDefault.rawValue: "服务器默认", Key.settingsProjectCustomPath.rawValue: "自定义路径", @@ -527,6 +564,20 @@ enum L10n { Key.chatSessionStatusIdle.rawValue: "空闲", Key.chatPullToLoadMore.rawValue: "下拉加载更多历史消息", Key.chatLoadingMoreHistory.rawValue: "正在加载更多历史消息...", + Key.chatChooseAttachment.rawValue: "添加附件", + Key.chatChooseImage.rawValue: "选择图片", + Key.chatChooseFile.rawValue: "选择文件", + Key.chatAttachmentMenuTitle.rawValue: "添加到消息", + Key.chatAttachmentUnsupportedModel.rawValue: "当前模型不支持文件附件。", + Key.chatAttachmentUnsupportedImages.rawValue: "当前模型不支持图片附件。", + Key.chatAttachmentUnsupportedPDFs.rawValue: "当前模型不支持 PDF 附件。", + Key.chatAttachmentUnsupportedFileType.rawValue: "当前仅支持图片、PDF 和文本文件。", + Key.chatAttachmentFileTooLarge.rawValue: "该附件过大,无法从 iPhone 直接发送。", + Key.chatAttachmentReadFailed.rawValue: "无法读取所选附件。", + Key.chatAttachmentRemove.rawValue: "移除附件", + Key.chatAttachmentImageLabel.rawValue: "图片", + Key.chatAttachmentPDFLabel.rawValue: "PDF", + Key.chatAttachmentTextLabel.rawValue: "文本文件", Key.permissionRequired.rawValue: "需要授权", Key.permissionAllowOnce.rawValue: "允许一次", diff --git a/OpenCodeClient/OpenCodeClient/Views/Chat/ChatComposerTextView.swift b/OpenCodeClient/OpenCodeClient/Views/Chat/ChatComposerTextView.swift index 24e227f..d3664c6 100644 --- a/OpenCodeClient/OpenCodeClient/Views/Chat/ChatComposerTextView.swift +++ b/OpenCodeClient/OpenCodeClient/Views/Chat/ChatComposerTextView.swift @@ -16,9 +16,9 @@ enum ChatComposerKeyAction: Equatable { } enum ChatComposerSendGate { - static func canSend(text: String, isSending: Bool, hasMarkedText: Bool) -> Bool { + static func canSend(text: String, hasAttachments: Bool, isSending: Bool, hasMarkedText: Bool) -> Bool { guard !isSending, !hasMarkedText else { return false } - return !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return hasAttachments || !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } diff --git a/OpenCodeClient/OpenCodeClient/Views/Chat/ChatTabView.swift b/OpenCodeClient/OpenCodeClient/Views/Chat/ChatTabView.swift index 46e8230..0ae627c 100644 --- a/OpenCodeClient/OpenCodeClient/Views/Chat/ChatTabView.swift +++ b/OpenCodeClient/OpenCodeClient/Views/Chat/ChatTabView.swift @@ -4,6 +4,8 @@ // import SwiftUI +import PhotosUI +import UniformTypeIdentifiers import os #if canImport(UIKit) import UIKit @@ -28,6 +30,101 @@ private enum MessageGroupItem: Identifiable { } } +private enum ChatAttachmentPicker { + static let allowedFileTypes: [UTType] = [ + .image, + .pdf, + .text, + .plainText, + .sourceCode, + .json, + .xml, + .commaSeparatedText, + .tabSeparatedText, + ] + + static func byteLimit(for kind: ChatAttachmentKind) -> Int { + switch kind { + case .image, .pdf: + return 8 * 1024 * 1024 + case .text: + return 1 * 1024 * 1024 + } + } + + static func isTextType(_ type: UTType, filename: String) -> Bool { + if type.conforms(to: .text) || type.conforms(to: .plainText) || type.conforms(to: .sourceCode) { + return true + } + if type.conforms(to: .json) || type.conforms(to: .xml) || type.conforms(to: .commaSeparatedText) || type.conforms(to: .tabSeparatedText) { + return true + } + + let ext = URL(fileURLWithPath: filename).pathExtension.lowercased() + return ["txt", "md", "mdx", "json", "yaml", "yml", "xml", "csv", "log", "swift", "ts", "tsx", "js", "jsx", "py", "rb", "go", "rs", "java", "c", "cpp", "h", "hpp", "sh", "zsh", "toml", "ini"].contains(ext) + } +} + +private struct AttachmentChipView: View { + let attachment: ComposerAttachment + let onRemove: () -> Void + + private var label: String { + switch attachment.kind { + case .image: + return L10n.t(.chatAttachmentImageLabel) + case .pdf: + return L10n.t(.chatAttachmentPDFLabel) + case .text: + return L10n.t(.chatAttachmentTextLabel) + } + } + + private var iconName: String { + switch attachment.kind { + case .image: + return "photo" + case .pdf: + return "doc.richtext" + case .text: + return "doc.text" + } + } + + var body: some View { + HStack(spacing: DesignSpacing.xs) { + Image(systemName: iconName) + .font(.caption) + .foregroundStyle(DesignColors.Brand.primary) + + VStack(alignment: .leading, spacing: 1) { + Text(attachment.filename) + .font(DesignTypography.micro) + .lineLimit(1) + Text(label) + .font(DesignTypography.micro) + .foregroundStyle(.secondary) + } + + Button(action: onRemove) { + Image(systemName: "xmark.circle.fill") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(L10n.t(.chatAttachmentRemove)) + } + .padding(.horizontal, DesignSpacing.sm) + .padding(.vertical, 6) + .background(DesignColors.Brand.primary.opacity(0.08)) + .clipShape(Capsule()) + .overlay( + Capsule() + .stroke(DesignColors.Brand.primary.opacity(DesignColors.Opacity.borderStroke), lineWidth: 1) + ) + } +} + enum ChatScrollBehavior { static let followThreshold: CGFloat = 80 @@ -87,6 +184,10 @@ struct ChatTabView: View { @State private var isRecording = false @State private var isTranscribing = false @State private var speechError: String? + @State private var showAttachmentOptions = false + @State private var showPhotoPicker = false + @State private var showFileImporter = false + @State private var selectedPhotoItems: [PhotosPickerItem] = [] @State private var pendingScrollTask: Task? @State private var pendingBottomVisibilityTask: Task? @State private var isNearBottom = true @@ -94,6 +195,17 @@ struct ChatTabView: View { @Environment(\.colorScheme) private var colorScheme private var useGridCards: Bool { sizeClass == .regular } + private var currentComposerAttachments: [ComposerAttachment] { + state.showAttachmentButton ? state.composerAttachments(for: state.currentSessionID) : [] + } + private var canSendCurrentMessage: Bool { + ChatComposerSendGate.canSend( + text: inputText, + hasAttachments: !currentComposerAttachments.isEmpty, + isSending: isSending, + hasMarkedText: hasMarkedText + ) + } fileprivate struct TurnActivity: Identifiable { enum State { @@ -479,87 +591,121 @@ struct ChatTabView: View { } } - Divider() - HStack(alignment: .bottom, spacing: DesignSpacing.md) { - ZStack(alignment: .topLeading) { - ChatComposerTextView( - text: $inputText, - hasMarkedText: $hasMarkedText, - placeholder: L10n.t(.chatInputPlaceholder), - onSubmit: sendCurrentInput - ) - .frame(minHeight: 32, maxHeight: 100) - .accessibilityIdentifier("chat-input") - - if inputText.isEmpty { - Text(L10n.t(.chatInputPlaceholder)) - .foregroundStyle(.secondary) - .allowsHitTesting(false) - .accessibilityHidden(true) - } - } - .padding(.horizontal, 14) - .padding(.vertical, 5) - .background(colorScheme == .dark ? DesignColors.Neutral.composerDark : DesignColors.Neutral.composerLight) - .clipShape(RoundedRectangle(cornerRadius: DesignCorners.large)) - - VStack(spacing: DesignSpacing.sm) { - Button { - Task { await toggleRecording() } - } label: { - ZStack { - if isTranscribing { - ProgressView() - .controlSize(.small) - } else { - Image(systemName: "mic.fill") - .font(.callout) - .foregroundStyle(isRecording ? .white : DesignColors.Brand.primary) + Divider() + VStack(alignment: .leading, spacing: DesignSpacing.sm) { + if state.showAttachmentButton, !currentComposerAttachments.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: DesignSpacing.sm) { + ForEach(currentComposerAttachments) { attachment in + AttachmentChipView(attachment: attachment) { + state.removeComposerAttachment(id: attachment.id, for: state.currentSessionID) + } } } - .frame(width: 32, height: 32) - .background(isRecording ? Color.red : (isTranscribing ? (colorScheme == .dark ? DesignColors.Neutral.surfaceDark : DesignColors.Neutral.surfaceLight) : Color.clear)) - .clipShape(RoundedRectangle(cornerRadius: DesignCorners.medium)) - .overlay( - RoundedRectangle(cornerRadius: DesignCorners.medium) - .stroke( - isRecording ? Color.clear : (isTranscribing ? DesignColors.Brand.primary.opacity(DesignColors.Opacity.borderStroke) : DesignColors.Brand.primary.opacity(DesignColors.Opacity.borderStroke)), - lineWidth: 1.5 + .padding(.horizontal, 1) + } + } + + HStack(alignment: .bottom, spacing: DesignSpacing.md) { + if state.showAttachmentButton { + Button { + showAttachmentOptions = true + } label: { + Image(systemName: "paperclip") + .font(.callout.weight(.semibold)) + .foregroundStyle(DesignColors.Brand.primary) + .frame(width: 32, height: 32) + .background(Color.clear) + .clipShape(RoundedRectangle(cornerRadius: DesignCorners.medium)) + .overlay( + RoundedRectangle(cornerRadius: DesignCorners.medium) + .stroke(DesignColors.Brand.primary.opacity(DesignColors.Opacity.borderStroke), lineWidth: 1.5) ) + } + .accessibilityLabel(L10n.t(.chatChooseAttachment)) + .disabled(isSending) + } + + ZStack(alignment: .topLeading) { + ChatComposerTextView( + text: $inputText, + hasMarkedText: $hasMarkedText, + placeholder: L10n.t(.chatInputPlaceholder), + onSubmit: sendCurrentInput ) + .frame(minHeight: 32, maxHeight: 100) + .accessibilityIdentifier("chat-input") + + if inputText.isEmpty { + Text(L10n.t(.chatInputPlaceholder)) + .foregroundStyle(.secondary) + .allowsHitTesting(false) + .accessibilityHidden(true) + } } - .disabled(isSending || isTranscribing) - - Button { - sendCurrentInput() - } label: { - ZStack { - if isSending { - ProgressView() - .controlSize(.small) - .tint(.white) - } else { - Image(systemName: "arrow.up") - .font(.body.bold()) - .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 5) + .background(colorScheme == .dark ? DesignColors.Neutral.composerDark : DesignColors.Neutral.composerLight) + .clipShape(RoundedRectangle(cornerRadius: DesignCorners.large)) + + VStack(spacing: DesignSpacing.sm) { + Button { + Task { await toggleRecording() } + } label: { + ZStack { + if isTranscribing { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "mic.fill") + .font(.callout) + .foregroundStyle(isRecording ? .white : DesignColors.Brand.primary) + } } + .frame(width: 32, height: 32) + .background(isRecording ? Color.red : (isTranscribing ? (colorScheme == .dark ? DesignColors.Neutral.surfaceDark : DesignColors.Neutral.surfaceLight) : Color.clear)) + .clipShape(RoundedRectangle(cornerRadius: DesignCorners.medium)) + .overlay( + RoundedRectangle(cornerRadius: DesignCorners.medium) + .stroke( + isRecording ? Color.clear : DesignColors.Brand.primary.opacity(DesignColors.Opacity.borderStroke), + lineWidth: 1.5 + ) + ) } - .frame(width: 32, height: 32) - .background(DesignColors.Brand.primary) - .clipShape(RoundedRectangle(cornerRadius: DesignCorners.medium)) - } - .disabled(!ChatComposerSendGate.canSend(text: inputText, isSending: isSending, hasMarkedText: hasMarkedText) || isRecording || isTranscribing) + .disabled(isSending || isTranscribing) - if state.isBusy { Button { - Task { await state.abortSession() } + sendCurrentInput() } label: { - Image(systemName: "stop.fill") - .font(.body.bold()) - .foregroundStyle(.white) - .frame(width: 32, height: 32) - .background(Color.red) - .clipShape(RoundedRectangle(cornerRadius: DesignCorners.medium)) + ZStack { + if isSending { + ProgressView() + .controlSize(.small) + .tint(.white) + } else { + Image(systemName: "arrow.up") + .font(.body.bold()) + .foregroundStyle(.white) + } + } + .frame(width: 32, height: 32) + .background(DesignColors.Brand.primary) + .clipShape(RoundedRectangle(cornerRadius: DesignCorners.medium)) + } + .disabled(!canSendCurrentMessage || isRecording || isTranscribing) + + if state.isBusy { + Button { + Task { await state.abortSession() } + } label: { + Image(systemName: "stop.fill") + .font(.body.bold()) + .foregroundStyle(.white) + .frame(width: 32, height: 32) + .background(Color.red) + .clipShape(RoundedRectangle(cornerRadius: DesignCorners.medium)) + } } } } @@ -602,6 +748,28 @@ struct ChatTabView: View { } message: { Text(speechError ?? "") } + .confirmationDialog(L10n.t(.chatAttachmentMenuTitle), isPresented: $showAttachmentOptions, titleVisibility: .visible) { + Button(L10n.t(.chatChooseImage)) { + showPhotoPicker = true + } + Button(L10n.t(.chatChooseFile)) { + showFileImporter = true + } + Button(L10n.t(.commonCancel), role: .cancel) {} + } + .photosPicker( + isPresented: $showPhotoPicker, + selection: $selectedPhotoItems, + maxSelectionCount: 5, + matching: .images + ) + .fileImporter( + isPresented: $showFileImporter, + allowedContentTypes: ChatAttachmentPicker.allowedFileTypes, + allowsMultipleSelection: true + ) { result in + handleImportedFiles(result) + } .onAppear { syncDraftFromState(sessionID: state.currentSessionID) } @@ -619,6 +787,15 @@ struct ChatTabView: View { guard !isSyncingDraft else { return } state.setDraftText(newValue, for: state.currentSessionID) } + .onChange(of: selectedPhotoItems) { _, newValue in + guard !newValue.isEmpty else { return } + Task { + await importPhotoAttachments(newValue) + await MainActor.run { + selectedPhotoItems = [] + } + } + } } } @@ -659,8 +836,9 @@ struct ChatTabView: View { } private func sendCurrentInput() { - guard ChatComposerSendGate.canSend(text: inputText, isSending: isSending, hasMarkedText: hasMarkedText) else { return } + guard canSendCurrentMessage else { return } let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines) + let previousAttachments = currentComposerAttachments inputText = "" hasMarkedText = false @@ -670,7 +848,155 @@ struct ChatTabView: View { isSending = false if !success { inputText = text + if state.showAttachmentButton { + state.clearComposerAttachments(for: state.currentSessionID) + state.appendComposerAttachments(previousAttachments, for: state.currentSessionID) + } + } + } + } + + private func handleImportedFiles(_ result: Result<[URL], Error>) { + switch result { + case .success(let urls): + Task { + await importFileAttachments(urls) } + case .failure: + state.sendError = L10n.t(.chatAttachmentReadFailed) + } + } + + private func importPhotoAttachments(_ items: [PhotosPickerItem]) async { + var attachments: [ComposerAttachment] = [] + + for item in items { + do { + guard let data = try await item.loadTransferable(type: Data.self) else { + await MainActor.run { state.sendError = L10n.t(.chatAttachmentReadFailed) } + continue + } + let type = item.supportedContentTypes.first(where: { $0.conforms(to: .image) }) ?? .png + if let attachment = makeAttachment( + data: data, + filename: generatedFilename(for: .image, type: type), + type: type, + fallbackKind: .image + ) { + attachments.append(attachment) + } + } catch { + await MainActor.run { state.sendError = L10n.t(.chatAttachmentReadFailed) } + } + } + + await MainActor.run { + state.appendComposerAttachments(attachments, for: state.currentSessionID) + } + } + + private func importFileAttachments(_ urls: [URL]) async { + var attachments: [ComposerAttachment] = [] + + for url in urls { + let didAccess = url.startAccessingSecurityScopedResource() + defer { + if didAccess { + url.stopAccessingSecurityScopedResource() + } + } + + do { + let data = try Data(contentsOf: url) + let type = inferredType(for: url) + if let attachment = makeAttachment( + data: data, + filename: url.lastPathComponent, + type: type, + fallbackKind: nil + ) { + attachments.append(attachment) + } + } catch { + await MainActor.run { state.sendError = L10n.t(.chatAttachmentReadFailed) } + } + } + + await MainActor.run { + state.appendComposerAttachments(attachments, for: state.currentSessionID) + } + } + + private func makeAttachment( + data: Data, + filename: String, + type: UTType, + fallbackKind: ChatAttachmentKind? + ) -> ComposerAttachment? { + let kind = attachmentKind(for: type, filename: filename) ?? fallbackKind + guard let kind else { + state.sendError = L10n.t(.chatAttachmentUnsupportedFileType) + return nil + } + + let byteLimit = ChatAttachmentPicker.byteLimit(for: kind) + guard data.count <= byteLimit else { + state.sendError = L10n.t(.chatAttachmentFileTooLarge) + return nil + } + + let mimeType = resolvedMimeType(for: type, kind: kind, filename: filename) + let encoded = data.base64EncodedString() + return ComposerAttachment( + id: UUID().uuidString, + filename: filename, + mimeType: mimeType, + dataURL: "data:\(mimeType);base64,\(encoded)", + kind: kind, + byteCount: data.count + ) + } + + private func inferredType(for url: URL) -> UTType { + if let type = UTType(filenameExtension: url.pathExtension) { + return type + } + return .data + } + + private func generatedFilename(for kind: ChatAttachmentKind, type: UTType) -> String { + let ext = type.preferredFilenameExtension ?? { + switch kind { + case .image: return "png" + case .pdf: return "pdf" + case .text: return "txt" + } + }() + return "attachment-\(UUID().uuidString.prefix(8)).\(ext)" + } + + private func attachmentKind(for type: UTType, filename: String) -> ChatAttachmentKind? { + if type.conforms(to: .image) { return .image } + if type.conforms(to: .pdf) { return .pdf } + if ChatAttachmentPicker.isTextType(type, filename: filename) { return .text } + return nil + } + + private func resolvedMimeType(for type: UTType, kind: ChatAttachmentKind, filename: String) -> String { + if let mime = type.preferredMIMEType { + return mime + } + switch kind { + case .image: + let ext = URL(fileURLWithPath: filename).pathExtension.lowercased() + if ext == "jpg" || ext == "jpeg" { return "image/jpeg" } + if ext == "gif" { return "image/gif" } + if ext == "webp" { return "image/webp" } + return "image/png" + case .pdf: + return "application/pdf" + case .text: + return "text/plain" } } diff --git a/OpenCodeClient/OpenCodeClient/Views/Chat/MessageRowView.swift b/OpenCodeClient/OpenCodeClient/Views/Chat/MessageRowView.swift index 192f833..55560fb 100644 --- a/OpenCodeClient/OpenCodeClient/Views/Chat/MessageRowView.swift +++ b/OpenCodeClient/OpenCodeClient/Views/Chat/MessageRowView.swift @@ -67,6 +67,14 @@ struct MessageRowView: View { return blocks } + private var userTextParts: [Part] { + message.parts.filter { $0.isText } + } + + private var userFileParts: [Part] { + message.parts.filter { $0.isFile } + } + @ViewBuilder private func markdownText(_ text: String, isUser: Bool) -> some View { let font = isUser ? DesignTypography.bodyProminent : DesignTypography.body @@ -144,10 +152,21 @@ struct MessageRowView: View { .frame(width: 4) VStack(alignment: .leading, spacing: 0) { - ForEach(message.parts.filter { $0.isText }, id: \.id) { part in + ForEach(userTextParts, id: \.id) { part in markdownText(part.text ?? "", isUser: true) .padding(.horizontal, 14) - .padding(.vertical, 10) + .padding(.top, 10) + .padding(.bottom, userFileParts.isEmpty ? 10 : 6) + } + + if !userFileParts.isEmpty { + VStack(alignment: .leading, spacing: DesignSpacing.xs) { + ForEach(userFileParts, id: \.id) { part in + UserAttachmentRow(part: part) + } + } + .padding(.horizontal, 14) + .padding(.bottom, 10) } } } @@ -237,3 +256,42 @@ struct MessageRowView: View { } } } + +private struct UserAttachmentRow: View { + let part: Part + + private var iconName: String { + guard let mime = part.mime?.lowercased() else { return "paperclip" } + if mime.hasPrefix("image/") { return "photo" } + if mime == "application/pdf" { return "doc.richtext" } + return "doc.text" + } + + private var detail: String { + guard let mime = part.mime?.lowercased() else { return "" } + if mime.hasPrefix("image/") { return L10n.t(.chatAttachmentImageLabel) } + if mime == "application/pdf" { return L10n.t(.chatAttachmentPDFLabel) } + return L10n.t(.chatAttachmentTextLabel) + } + + var body: some View { + HStack(spacing: DesignSpacing.sm) { + Image(systemName: iconName) + .font(.caption) + .foregroundStyle(DesignColors.Brand.primary) + VStack(alignment: .leading, spacing: 2) { + Text(part.displayFilename) + .font(DesignTypography.micro) + .lineLimit(1) + Text(detail) + .font(DesignTypography.micro) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(.white.opacity(0.45)) + .clipShape(RoundedRectangle(cornerRadius: DesignCorners.medium)) + } +} diff --git a/OpenCodeClient/OpenCodeClient/Views/SettingsTabView.swift b/OpenCodeClient/OpenCodeClient/Views/SettingsTabView.swift index de6ae8d..f56db19 100644 --- a/OpenCodeClient/OpenCodeClient/Views/SettingsTabView.swift +++ b/OpenCodeClient/OpenCodeClient/Views/SettingsTabView.swift @@ -290,6 +290,14 @@ struct SettingsTabView: View { Toggle(L10n.t(.settingsShowArchivedSessions), isOn: $state.showArchivedSessions) } + Section(L10n.t(.settingsChat)) { + Toggle(L10n.t(.settingsShowAttachmentButton), isOn: $state.showAttachmentButton) + + Text(L10n.t(.settingsAttachmentButtonHelp)) + .font(.caption) + .foregroundStyle(.secondary) + } + Section(L10n.t(.settingsSpeechRecognition)) { TextField(L10n.t(.settingsAiBuilderBaseURL), text: $state.aiBuilderBaseURL) .textContentType(.URL) diff --git a/OpenCodeClient/OpenCodeClientTests/OpenCodeClientTests.swift b/OpenCodeClient/OpenCodeClientTests/OpenCodeClientTests.swift index 291bca8..fabefc9 100644 --- a/OpenCodeClient/OpenCodeClientTests/OpenCodeClientTests.swift +++ b/OpenCodeClient/OpenCodeClientTests/OpenCodeClientTests.swift @@ -1015,16 +1015,20 @@ struct AIBuildersAudioClientTests { } @Test func chatComposerSendGateRejectsMarkedText() { - #expect(ChatComposerSendGate.canSend(text: "nihao", isSending: false, hasMarkedText: true) == false) + #expect(ChatComposerSendGate.canSend(text: "nihao", hasAttachments: false, isSending: false, hasMarkedText: true) == false) } @Test func chatComposerSendGateRejectsWhitespaceAndActiveSend() { - #expect(ChatComposerSendGate.canSend(text: " ", isSending: false, hasMarkedText: false) == false) - #expect(ChatComposerSendGate.canSend(text: "hello", isSending: true, hasMarkedText: false) == false) + #expect(ChatComposerSendGate.canSend(text: " ", hasAttachments: false, isSending: false, hasMarkedText: false) == false) + #expect(ChatComposerSendGate.canSend(text: "hello", hasAttachments: false, isSending: true, hasMarkedText: false) == false) } @Test func chatComposerSendGateAllowsCommittedText() { - #expect(ChatComposerSendGate.canSend(text: "hello", isSending: false, hasMarkedText: false) == true) + #expect(ChatComposerSendGate.canSend(text: "hello", hasAttachments: false, isSending: false, hasMarkedText: false) == true) + } + + @Test func chatComposerSendGateAllowsAttachmentsWithoutText() { + #expect(ChatComposerSendGate.canSend(text: " ", hasAttachments: true, isSending: false, hasMarkedText: false) == true) } } @@ -2243,7 +2247,7 @@ actor MockAPIClient: APIClientProtocol { return messagesResult } - func promptAsync(sessionID: String, text: String, agent: String, model: Message.ModelInfo?) async throws { + func promptAsync(sessionID: String, text: String, attachments: [ComposerAttachmentPayload], agent: String, model: Message.ModelInfo?) async throws { if let promptError { throw promptError } }