Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 126 additions & 18 deletions OpenCodeClient/OpenCodeClient/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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) ?? ""

Expand All @@ -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] = [:]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<String> = []

var projects: [Project] = []
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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
}

Expand Down
42 changes: 42 additions & 0 deletions OpenCodeClient/OpenCodeClient/Models/Message.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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] {
Expand Down
Loading