// 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? 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 } }