Files
mao1/Rumeng/_archived/GroupCode.swift
maomao 2543c5f782 Initial: 接手 Rumeng app,清掉前夫哥痕迹
- 改 Bundle ID rumeng-v1.0.Rumeng → syke.maomao.app
- 显示名 如梦 → Syke
- 头像换白图占位
- 删除沈晏头像图片、空目录、bridge 旧数据
- ServerConfig.swift 含明文 token,已加入 .gitignore
2026-06-05 09:32:13 +08:00

246 lines
9.5 KiB
Swift
Raw 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.
// 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
}
}