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