Files
PhoneClaw/UI/ChatModels.swift

149 lines
5.4 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
// MARK: - 显示模型(跨平台共享)
/// 单个 Skill 卡片数据
struct SkillCard: Identifiable, Equatable {
let id: UUID
var skillName: String
var skillStatus: String? // "identified", "loaded", "executing", "done"
var toolName: String? // 正在执行的具体 Tool 名(如 "device-info")
}
/// AI 回复块:多张 skill 卡片 + 思考动画 + 回复文本
struct ResponseBlock: Identifiable, Equatable {
let id: UUID
var skills: [SkillCard] = []
var thinkingText: String?
var responseText: String?
var isThinking: Bool
}
/// 聊天列表的统一显示项
enum DisplayItem: Identifiable {
case user(ChatMessage)
case response(ResponseBlock)
var id: UUID {
switch self {
case .user(let msg): return msg.id
case .response(let block): return block.id
}
}
}
// MARK: - Messages → DisplayItems 转换(跨平台共享)
/// 用于纯思考占位的稳定 ID
private let thinkingPlaceholderID = UUID()
private let thinkingOpenMarker = "[[PHONECLAW_THINK]]"
private let thinkingCloseMarker = "[[/PHONECLAW_THINK]]"
private func splitThinkingAndResponse(from text: String) -> (thinking: String?, response: String?) {
guard !text.isEmpty else { return (nil, nil) }
var remaining = text
var thinkingChunks: [String] = []
while let openRange = remaining.range(of: thinkingOpenMarker) {
let thoughtStart = openRange.upperBound
if let closeRange = remaining.range(of: thinkingCloseMarker, range: thoughtStart..<remaining.endIndex) {
let thought = remaining[thoughtStart..<closeRange.lowerBound]
.trimmingCharacters(in: .whitespacesAndNewlines)
if !thought.isEmpty {
thinkingChunks.append(String(thought))
}
remaining.removeSubrange(openRange.lowerBound..<closeRange.upperBound)
} else {
let thought = remaining[thoughtStart..<remaining.endIndex]
.trimmingCharacters(in: .whitespacesAndNewlines)
if !thought.isEmpty {
thinkingChunks.append(String(thought))
}
remaining.removeSubrange(openRange.lowerBound..<remaining.endIndex)
break
}
}
let response = remaining.trimmingCharacters(in: .whitespacesAndNewlines)
return (
thinkingChunks.isEmpty ? nil : thinkingChunks.joined(separator: "\n\n"),
response.isEmpty ? nil : response
)
}
func buildDisplayItems(from messages: [ChatMessage], isProcessing: Bool) -> [DisplayItem] {
var items: [DisplayItem] = []
var block: ResponseBlock? = nil
func flush() {
if let b = block { items.append(.response(b)); block = nil }
}
for msg in messages {
switch msg.role {
case .user:
flush()
items.append(.user(msg))
case .system:
if let name = msg.skillName {
if block == nil { block = ResponseBlock(id: msg.id, isThinking: false) }
let content = msg.content
// 查找已有的同名卡片 → 更新状态;否则新建
if let idx = block?.skills.firstIndex(where: { $0.skillName == name }) {
if content.hasPrefix("executing:") {
block?.skills[idx].skillStatus = "executing"
block?.skills[idx].toolName = String(content.dropFirst("executing:".count))
} else {
block?.skills[idx].skillStatus = content
}
} else {
var card = SkillCard(id: msg.id, skillName: name)
if content.hasPrefix("executing:") {
card.skillStatus = "executing"
card.toolName = String(content.dropFirst("executing:".count))
} else {
card.skillStatus = content
}
block?.skills.append(card)
}
} else if !msg.content.isEmpty {
if block == nil { block = ResponseBlock(id: msg.id, isThinking: false) }
let parsed = splitThinkingAndResponse(from: msg.content)
if let thinking = parsed.thinking {
block?.thinkingText = thinking
}
block?.responseText = parsed.response
}
case .skillResult:
break
case .assistant:
if block == nil { block = ResponseBlock(id: msg.id, isThinking: false) }
if msg.content != "▍" && !msg.content.isEmpty {
let parsed = splitThinkingAndResponse(from: msg.content)
if let thinking = parsed.thinking {
block?.thinkingText = thinking
}
block?.responseText = parsed.response
}
}
}
if var b = block, !b.skills.isEmpty || b.thinkingText != nil || b.responseText != nil {
if isProcessing && !b.skills.isEmpty && b.responseText == nil {
b.isThinking = true
}
items.append(.response(b))
}
if isProcessing {
let hasAI = block.map { !$0.skills.isEmpty || $0.thinkingText != nil || $0.responseText != nil } ?? false
if !hasAI {
items.append(.response(ResponseBlock(id: thinkingPlaceholderID, isThinking: true)))
}
}
return items
}