Files
mao1/Rumeng/_archived/GroupCode.swift

246 lines
9.5 KiB
Swift
Raw Normal View History

// MARK: -
struct GroupMember: Identifiable, Codable, Hashable {
let id: String
let displayName: String
let kind: String?
let color: String?
let model: String?
enum CodingKeys: String, CodingKey {
case id, kind, color, model
case displayName = "display_name"
}
var avatarText: String { String(displayName.prefix(1)) }
func uiColor(theme _: Theme) -> Color {
switch color {
case "orange": return Color(hex: "CC8033")
case "green": return Color(hex: "6B9163")
case "blue": return Color(hex: "5E8CA8")
case "purple": return Color(hex: "8770A8")
default: return Color(hex: "8C8273")
}
}
}
struct GroupMessage: Identifiable, Codable, Hashable {
let id: String
let ts: String
let senderId: String
let text: String
let mentions: [String]
let replyTo: String?
enum CodingKeys: String, CodingKey {
case id, ts, text, mentions
case senderId = "sender_id"
case replyTo = "reply_to"
}
var shortTime: String {
guard let t = ts.firstIndex(of: "T") else { return "" }
return String(ts[ts.index(after: t)...].prefix(5))
}
}
struct GroupPollResponse: Codable {
let ok: Bool
let records: [GroupMessage]
let lastTs: String?
let roster: [GroupMember]?
let statusWrapper: GroupStatusSnapshot?
enum CodingKeys: String, CodingKey {
case ok, records, roster
case lastTs = "last_ts"
case statusWrapper = "status"
}
}
struct GroupStatusSnapshot: Codable {
let agents: [String: GroupAgentStatus]?
}
struct GroupAgentStatus: Codable, Hashable {
let state: String?
let tmux: String?
}
@MainActor
final class GroupStore: ObservableObject {
@Published var messages: [GroupMessage] = []
@Published var members: [GroupMember] = []
@Published var agentStates: [String: GroupAgentStatus] = [:]
private var lastTs: String = ""
private var task: Task<Void, Never>?
func start() {
task?.cancel()
task = Task { await poll() }
}
func stop() { task?.cancel(); task = nil }
private func poll() async {
while !Task.isCancelled {
do {
var url = ServerConfig.baseURL.appendingPathComponent("group/poll")
if !lastTs.isEmpty {
var comps = URLComponents(url: url, resolvingAgainstBaseURL: false)!
comps.queryItems = [URLQueryItem(name: "since", value: lastTs)]
url = comps.url ?? url
}
var req = ServerConfig.makeRequest(path: "")
req.url = url
req.timeoutInterval = 8
print("[GroupStore] poll → \(url)")
let (data, resp) = try await URLSession.shared.data(for: req)
let statusCode = (resp as? HTTPURLResponse)?.statusCode ?? -1
print("[GroupStore] status=\(statusCode) bytes=\(data.count)")
if let raw = String(data: data, encoding: .utf8) {
print("[GroupStore] body=\(raw.prefix(500))")
}
do {
let decoded = try JSONDecoder().decode(GroupPollResponse.self, from: data)
guard decoded.ok else {
print("[GroupStore] ok=false, skipping")
try? await Task.sleep(for: .seconds(5))
continue
}
if let roster = decoded.roster { members = roster }
if let agents = decoded.statusWrapper?.agents { agentStates = agents }
print("[GroupStore] agentStates=\(agentStates)")
print("[GroupStore] records=\(decoded.records.count) messages_total=\(messages.count + decoded.records.count)")
if !decoded.records.isEmpty {
messages.append(contentsOf: decoded.records)
lastTs = decoded.lastTs ?? lastTs
}
} catch {
print("[GroupStore] decode error: \(error)")
}
} catch {
print("[GroupStore] network error: \(error)")
try? await Task.sleep(for: .seconds(2))
}
try? await Task.sleep(for: .seconds(2))
}
}
func send(text: String) async {
guard !text.isEmpty else { return }
var req = ServerConfig.makeRequest(path: "group/send")
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
let mentions = resolveMentions(in: text)
let body: [String: Any] = ["sender_id": "amian", "text": text, "mentions": mentions]
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
do {
let (_, _) = try await URLSession.shared.data(for: req)
} catch {}
}
private func resolveMentions(in text: String) -> [String] {
let pattern = try! NSRegularExpression(pattern: "@([一-鿿]+|[A-Za-z0-9_]+)")
let range = NSRange(text.startIndex..., in: text)
let matches = pattern.matches(in: text, range: range)
var ids: [String] = []
for m in matches {
guard let r = Range(m.range(at: 1), in: text) else { continue }
let name = String(text[r]).lowercased()
if name.contains("张三") || name == "zs" { ids.append("zhangsan") }
}
return ids
}
func memberColor(for senderId: String, theme: Theme) -> Color {
if let m = members.first(where: { $0.id == senderId }) { return m.uiColor(theme: theme) }
return senderId == "shenyan" ? Color(hex: "CC8033") : senderId == "amian" ? Color(hex: "8C8273") : Color(hex: "6B9163")
}
}
struct GroupChatPage: View {
let theme: Theme
@StateObject private var store = GroupStore()
@State private var input = ""
@FocusState private var focused: Bool
var body: some View {
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 4) {
ForEach(store.messages) { msg in
HStack(alignment: .top, spacing: 6) {
Rectangle().fill(store.memberColor(for: msg.senderId, theme: theme))
.frame(width: 5, height: 5)
VStack(alignment: .leading, spacing: 2) {
Text(displayName(for: msg.senderId))
.font(.custom("HYPixel-11px-J", size: 10).weight(.bold))
.foregroundStyle(store.memberColor(for: msg.senderId, theme: theme))
Text(msg.text)
.font(.custom("HYPixel-11px-J", size: 13))
.foregroundStyle(theme.textPrimary)
}
Spacer()
Text(msg.shortTime)
.font(.custom("HYPixel-11px-J", size: 9))
.foregroundStyle(theme.textPrimary.opacity(0.3))
}
.padding(.horizontal, 12).padding(.vertical, 4)
.id(msg.id)
}
}
.padding(.vertical, 4)
}
.onChange(of: store.messages.count) { _, _ in
if let last = store.messages.last {
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
}
}
}
// online bar
HStack(spacing: 0) {
let onlineAgents = store.members.filter { $0.kind == "agent" && store.agentStates[$0.id]?.state == "online" }
if !onlineAgents.isEmpty {
Text(onlineAgents.map { $0.displayName }.joined(separator: " / "))
.font(.custom("HYPixel-11px-J", size: 11))
.foregroundStyle(theme.textPrimary.opacity(0.6))
} else {
Text("无人在线")
.font(.custom("HYPixel-11px-J", size: 11))
.foregroundStyle(theme.textPrimary.opacity(0.3))
}
Spacer()
}
.padding(.horizontal, 12).padding(.top, 4)
// input
HStack(spacing: 8) {
TextField("@某人 说点什么...", text: $input)
.font(.custom("HYPixel-11px-J", size: 14))
.foregroundStyle(theme.textPrimary)
.focused($focused)
Button {
let text = input.trimmingCharacters(in: .whitespaces)
input = ""
Task { await store.send(text: text) }
} label: {
Image(Theme.themedIcon("btn_send", tint: theme.iconTint)).resizable().frame(width: 29, height: 29).opacity(0.3)
}
}
.padding(.horizontal, 12).padding(.vertical, 8)
.background(Rectangle().fill(theme.inputBg))
}
.background(theme.bg.ignoresSafeArea())
.onAppear { store.start() }
.onDisappear { store.stop() }
}
private func displayName(for senderId: String) -> String {
store.members.first(where: { $0.id == senderId })?.displayName ?? senderId
}
}