-
Notifications
You must be signed in to change notification settings - Fork 32
Expand file tree
/
Copy pathMessage.swift
More file actions
340 lines (299 loc) · 12.2 KB
/
Message.swift
File metadata and controls
340 lines (299 loc) · 12.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
//
// Message.swift
// OpenCodeClient
//
import Foundation
struct Message: Codable, Identifiable {
let id: String
let sessionID: String
let role: String
let parentID: String?
/// Some servers return providerID/modelID as top-level fields (instead of `model`).
let providerID: String?
let modelID: String?
let model: ModelInfo?
let variant: String?
let error: MessageError?
let time: TimeInfo
let finish: String?
let tokens: TokenInfo?
let cost: Double?
struct ModelInfo: Codable {
let providerID: String
let modelID: String
}
struct TokenInfo: Codable {
let total: Int
let input: Int
let output: Int
let reasoning: Int
let cache: CacheInfo?
struct CacheInfo: Codable {
let read: Int
let write: Int
}
private enum CodingKeys: String, CodingKey {
case total
case input
case output
case reasoning
case cache
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
input = try c.decodeIfPresent(Int.self, forKey: .input) ?? 0
output = try c.decodeIfPresent(Int.self, forKey: .output) ?? 0
reasoning = try c.decodeIfPresent(Int.self, forKey: .reasoning) ?? 0
cache = try c.decodeIfPresent(CacheInfo.self, forKey: .cache)
// Newer OpenCode server payloads may omit `total`.
total = try c.decodeIfPresent(Int.self, forKey: .total) ?? (input + output + reasoning)
}
}
struct TimeInfo: Codable {
let created: Int
let completed: Int?
}
struct MessageError: Codable {
let name: String
let data: [String: AnyCodable]
var message: String? {
if let msg = data["message"]?.value as? String { return msg }
if let msg = data["error"]?.value as? String { return msg }
return nil
}
}
var isUser: Bool { role == "user" }
var isAssistant: Bool { role == "assistant" }
var resolvedModel: ModelInfo? {
if let model { return model }
if let providerID, let modelID { return ModelInfo(providerID: providerID, modelID: modelID) }
return nil
}
var errorMessageForDisplay: String? {
let trimmed = error?.message?.trimmingCharacters(in: .whitespacesAndNewlines)
return (trimmed?.isEmpty == false) ? trimmed : nil
}
}
struct MessageWithParts: Codable {
let info: Message
let parts: [Part]
}
/// Part.state can be String (simple) or object (ToolState with status/title/input/output)
struct PartStateBridge: Codable {
let displayString: String
/// 调用的理由/描述,来自 state.title 或 state.metadata.description
let title: String?
/// 命令/输入,来自 state.input 或 state.metadata
let inputSummary: String?
/// 输出结果,来自 state.output 或 state.metadata.output
let output: String?
/// 文件路径,来自 state.input.path/file_path/filePath 或 patchText 中的 *** Add File: / *** Update File:
let pathFromInput: String?
/// For todowrite: updated todo list (if present)
let todos: [TodoItem]?
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
func decodeTodos(from obj: Any) -> [TodoItem]? {
guard JSONSerialization.isValidJSONObject(obj) else { return nil }
guard let data = try? JSONSerialization.data(withJSONObject: obj) else { return nil }
return try? JSONDecoder().decode([TodoItem].self, from: data)
}
func decodeTodosFromJSONText(_ text: String?) -> [TodoItem]? {
guard let text else { return nil }
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard let data = trimmed.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode([TodoItem].self, from: data)
}
if let str = try? container.decode(String.self) {
displayString = str
title = nil
inputSummary = nil
output = nil
pathFromInput = nil
todos = nil
} else if let dict = try? container.decode([String: AnyCodable].self) {
if let status = dict["status"]?.value as? String {
displayString = status
} else if let t = dict["title"]?.value as? String {
displayString = t
} else {
displayString = "…"
}
var tit: String? = dict["title"]?.value as? String
var out: String? = dict["output"]?.value as? String
if let meta = dict["metadata"]?.value as? [String: Any] {
if out == nil, let o = meta["output"] as? String { out = o }
if tit == nil, let d = meta["description"] as? String { tit = d }
}
var inp: String?
var pathInp: String?
var todoList: [TodoItem]?
if let inputVal = dict["input"]?.value {
if let inputStr = inputVal as? String {
inp = inputStr
pathInp = nil
} else {
func getStr(_ d: [String: Any], _ k: String) -> String? {
if let v = d[k] as? String { return v }
if let arr = d[k] as? [String], let first = arr.first { return first }
return nil
}
let inputDict: [String: Any]?
if let id = inputVal as? [String: Any] {
inputDict = id
} else if let id2 = inputVal as? [String: AnyCodable] {
inputDict = id2.mapValues { $0.value }
} else {
inputDict = nil
}
if let d = inputDict {
inp = getStr(d, "command") ?? getStr(d, "path")
if let todosObj = d["todos"], let decoded = decodeTodos(from: todosObj) {
todoList = decoded
}
// Extract file path for write/edit/apply_patch
var pathVal = getStr(d, "path") ?? getStr(d, "file_path") ?? getStr(d, "filePath")
if pathVal == nil, let patchText = getStr(d, "patchText") {
// Parse "*** Add File: path" or "*** Update File: path" (may appear after *** Begin Patch\n)
for prefix in ["*** Add File: ", "*** Update File: "] {
if let range = patchText.range(of: prefix) {
let rest = String(patchText[range.upperBound...])
pathVal = rest.split(separator: "\n").first.map(String.init)?.trimmingCharacters(in: .whitespaces)
break
}
}
}
pathInp = pathVal
} else {
pathInp = nil
}
}
} else {
pathInp = nil
}
if todoList == nil,
let meta = dict["metadata"]?.value as? [String: Any],
let todosObj = meta["todos"] {
todoList = decodeTodos(from: todosObj)
}
if todoList == nil {
todoList = decodeTodosFromJSONText(out)
}
pathFromInput = pathInp
title = tit
inputSummary = inp
output = out
todos = todoList
} else {
pathFromInput = nil
todos = nil
throw DecodingError.typeMismatch(PartStateBridge.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Part.state must be String or object"))
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(displayString)
}
}
struct Part: Codable, Identifiable {
let id: String
let messageID: String
let sessionID: String
let type: String
let text: String?
let tool: String?
let callID: String?
let state: PartStateBridge?
let metadata: PartMetadata?
let files: [FileChange]?
/// For UI display; handles both string and object state
var stateDisplay: String? { state?.displayString }
/// 调用的理由/描述(用于 tool label)
var toolReason: String? { state?.title }
/// 命令/输入摘要
var toolInputSummary: String? { state?.inputSummary }
/// 输出结果
var toolOutput: String? { state?.output }
var toolTodos: [TodoItem] {
if let t = metadata?.todos, !t.isEmpty { return t }
if let t = state?.todos, !t.isEmpty { return t }
return []
}
struct FileChange: Codable {
let path: String
let additions: Int
let deletions: Int
let status: String?
}
struct PartMetadata: Codable {
let path: String?
let title: String?
let input: String?
let todos: [TodoItem]?
private enum CodingKeys: String, CodingKey {
case path
case title
case input
case todos
}
init(path: String?, title: String?, input: String?, todos: [TodoItem]?) {
self.path = path
self.title = title
self.input = input
self.todos = todos
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
path = try? c.decode(String.self, forKey: .path)
title = try? c.decode(String.self, forKey: .title)
if let inputString = try? c.decode(String.self, forKey: .input) {
input = inputString
} else if let inputObject = try? c.decode([String: AnyCodable].self, forKey: .input),
JSONSerialization.isValidJSONObject(inputObject.mapValues({ $0.value })),
let data = try? JSONSerialization.data(withJSONObject: inputObject.mapValues({ $0.value })),
let text = String(data: data, encoding: .utf8) {
input = text
} else {
input = nil
}
if let decoded = try? c.decode([TodoItem].self, forKey: .todos) {
todos = decoded
} else if let raw = try? c.decode([AnyCodable].self, forKey: .todos),
JSONSerialization.isValidJSONObject(raw.map({ $0.value })),
let data = try? JSONSerialization.data(withJSONObject: raw.map({ $0.value })),
let decoded = try? JSONDecoder().decode([TodoItem].self, from: data) {
todos = decoded
} else {
todos = nil
}
}
func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encodeIfPresent(path, forKey: .path)
try c.encodeIfPresent(title, forKey: .title)
try c.encodeIfPresent(input, forKey: .input)
try c.encodeIfPresent(todos, forKey: .todos)
}
}
var isText: Bool { type == "text" }
var isReasoning: Bool { type == "reasoning" }
var isTool: Bool { type == "tool" }
var isPatch: Bool { type == "patch" }
/// 可跳转的文件路径列表:来自 files 数组、metadata.path、或 state.input 中的 path/patchText 解析
var filePathsForNavigation: [String] {
var out: [String] = []
if let files = files {
out.append(contentsOf: files.map { PathNormalizer.normalize($0.path) })
}
if let p = metadata?.path.map({ PathNormalizer.normalize($0) }), !p.isEmpty {
out.append(p)
}
if let p = state?.pathFromInput.map({ PathNormalizer.normalize($0) }), !p.isEmpty, !out.contains(p) {
out.append(p)
}
return out
}
var isStepStart: Bool { type == "step-start" }
var isStepFinish: Bool { type == "step-finish" }
}