feat: add tiered channel agent and mobile Codex bridge

This commit is contained in:
冰朔 2026-08-21 08:21:03 +08:00
commit 71db11e7e8
35 changed files with 2242 additions and 76 deletions

View file

@ -6,6 +6,8 @@ struct ContentView: View {
@State private var pairingText = ""
@State private var captureTitle = ""
@State private var captureBody = ""
@State private var agentDraft = ""
@State private var agentDestination = "HOLOLAKE_AGENT"
var body: some View {
NavigationStack {
@ -133,6 +135,89 @@ struct ContentView: View {
}
}
LakeCard {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text("与电脑端 HoloLake 对话").font(.headline)
Text("回复与工具均由已配对的电脑根节点执行")
.font(.caption)
.foregroundStyle(HoloLakeDesign.muted)
}
Spacer()
if let exchange = client.snapshot?.agentExchange {
Text(exchange.personaBindingState == "BOUND_VERIFY_PASS" ? "人格已绑定" : "系统频道")
.font(.caption2.weight(.semibold))
.foregroundStyle(exchange.personaBindingState == "BOUND_VERIFY_PASS" ? HoloLakeDesign.mint : HoloLakeDesign.gold)
}
}
Picker("消息目标", selection: $agentDestination) {
Text("HoloLake Agent").tag("HOLOLAKE_AGENT")
if let target = client.snapshot?.codexTarget {
Text("Codex · \(target.name)").tag("CODEX_SELECTED_THREAD")
}
}
.pickerStyle(.segmented)
if agentDestination == "CODEX_SELECTED_THREAD" {
Text("Codex 任务以只读沙箱运行;需要命令、改文件或额外权限时,必须回到电脑端确认。")
.font(.caption)
.foregroundStyle(HoloLakeDesign.gold)
}
if let exchange = client.snapshot?.agentExchange {
VStack(alignment: .leading, spacing: 8) {
Text(exchange.responderName)
.font(.caption.weight(.semibold))
.foregroundStyle(HoloLakeDesign.mint)
Text(exchange.reply)
.font(.body)
.textSelection(.enabled)
if !exchange.toolReceipts.isEmpty {
Divider().overlay(.white.opacity(0.12))
ForEach(exchange.toolReceipts) { receipt in
Label(receipt.summary, systemImage: "checkmark.seal")
.font(.caption)
.foregroundStyle(HoloLakeDesign.muted)
}
}
}
.padding(13)
.background(.black.opacity(0.18), in: RoundedRectangle(cornerRadius: 14))
}
TextField("给电脑端 HoloLake 发送消息", text: $agentDraft, axis: .vertical)
.lineLimit(3...8)
.padding(12)
.background(.black.opacity(0.2), in: RoundedRectangle(cornerRadius: 14))
Button {
let content = agentDraft
Task {
await client.sendAgentMessage(content, destination: agentDestination)
if client.errorText == nil {
agentDraft = ""
}
}
} label: {
if client.isWorking {
ProgressView()
} else {
Label("发送到电脑端", systemImage: "arrow.up.circle.fill")
}
}
.buttonStyle(.borderedProminent)
.tint(HoloLakeDesign.gold)
.foregroundStyle(HoloLakeDesign.deep)
.disabled(
agentDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| client.isWorking
|| (agentDestination == "CODEX_SELECTED_THREAD" && client.snapshot?.codexTarget == nil)
)
}
}
LakeCard {
VStack(alignment: .leading, spacing: 12) {
Text("随手记回频道").font(.headline)
@ -200,4 +285,3 @@ struct ContentView: View {
.padding(.horizontal, 4)
}
}

View file

@ -45,6 +45,10 @@ struct StoredSession: Codable, Equatable {
let desktopName: String
var counter: UInt64
var cursor: UInt64
var agentConversationID: String?
var pendingAgentRequestID: String?
var pendingAgentContent: String?
var pendingAgentDestination: String?
var baseURL: URL {
URL(string: "http://\(host):\(port)")!
@ -71,6 +75,7 @@ struct SyncRequest: Codable {
let counter: UInt64
let afterCursor: UInt64?
let capture: MobileCaptureInput?
let agentMessage: MobileAgentMessageInput?
}
struct MobileCaptureInput: Codable {
@ -79,6 +84,14 @@ struct MobileCaptureInput: Codable {
let requestID: String
}
struct MobileAgentMessageInput: Codable {
let destination: String
let codexThreadID: String?
let conversationID: String?
let content: String
let requestID: String
}
struct MobileSyncSnapshot: Codable {
let schema: String
let state: String
@ -90,9 +103,43 @@ struct MobileSyncSnapshot: Codable {
let webNovel: MobileWebNovelProjection
let education: MobileEducationProjection
let recentCaptures: [MobileCaptureProjection]
let agentExchange: MobileAgentExchangeProjection?
let recentAgentExchanges: [MobileAgentExchangeProjection]
let codexTarget: MobileCodexTargetProjection?
let boundary: MobileBoundaryProjection
}
struct MobileCodexTargetProjection: Codable, Equatable {
let threadID: String
let name: String
let preview: String
let cwd: String
let updatedAtUnixSeconds: UInt64
let status: String
}
struct MobileAgentExchangeProjection: Codable, Equatable {
let requestID: String
let conversationID: String
let state: String
let stateVersion: UInt64
let reply: String
let responderNumber: String
let responderName: String
let responderKind: String
let personaBindingState: String
let toolReceipts: [MobileAgentToolReceipt]
}
struct MobileAgentToolReceipt: Codable, Equatable, Identifiable {
let toolNumber: String
let toolName: String
let targetPath: String
let contentSha256: String
let summary: String
var id: String { "\(toolNumber):\(contentSha256)" }
}
struct MobileChannelProjection: Codable {
let home: String
let growthEventCount: UInt64
@ -145,6 +192,7 @@ enum HoloLakeMobileError: LocalizedError {
case rejected(String)
case missingNonce
case missingSession
case pendingAgentRequest
case keychain(OSStatus)
var errorDescription: String? {
@ -154,6 +202,7 @@ enum HoloLakeMobileError: LocalizedError {
case .rejected(let code): return "电脑端拒绝了本次请求:\(code)"
case .missingNonce: return "加密响应缺少一次性随机数。"
case .missingSession: return "请先与电脑端 HoloLake 配对。"
case .pendingAgentRequest: return "上一条消息仍在等待电脑端回执,请先重试该消息。"
case .keychain(let status): return "本机钥匙串写入失败(\(status))。"
}
}

View file

@ -53,7 +53,11 @@ final class HoloLakeSyncClient: ObservableObject {
sessionKey: sessionKey,
desktopName: response.desktopName,
counter: 0,
cursor: 0
cursor: 0,
agentConversationID: nil,
pendingAgentRequestID: nil,
pendingAgentContent: nil,
pendingAgentDestination: nil
)
try HoloLakeKeychain.save(next)
session = next
@ -69,9 +73,28 @@ final class HoloLakeSyncClient: ObservableObject {
await pair(with: url)
}
func sync(captureTitle: String? = nil, captureBody: String? = nil) async {
func sync(
captureTitle: String? = nil,
captureBody: String? = nil,
agentContent: String? = nil,
agentDestination: String = "HOLOLAKE_AGENT"
) async {
await perform("正在与电脑同步…") {
guard var current = session else { throw HoloLakeMobileError.missingSession }
let requestedAgentContent = agentContent?.trimmingCharacters(in: .whitespacesAndNewlines)
if let requestedAgentContent, !requestedAgentContent.isEmpty {
if let pending = current.pendingAgentContent, pending != requestedAgentContent {
throw HoloLakeMobileError.pendingAgentRequest
}
if let pending = current.pendingAgentDestination, pending != agentDestination {
throw HoloLakeMobileError.pendingAgentRequest
}
if current.pendingAgentRequestID == nil {
current.pendingAgentRequestID = UUID().uuidString.lowercased()
current.pendingAgentContent = requestedAgentContent
current.pendingAgentDestination = agentDestination
}
}
current.counter += 1
// Consume and persist the counter before transport. If the root node accepts a
// request but its response is lost, the next manual retry still moves forward.
@ -87,13 +110,32 @@ final class HoloLakeSyncClient: ObservableObject {
} else {
capture = nil
}
let agentMessage: MobileAgentMessageInput?
if let requestID = current.pendingAgentRequestID,
let content = current.pendingAgentContent,
requestedAgentContent != nil {
agentMessage = MobileAgentMessageInput(
destination: current.pendingAgentDestination ?? "HOLOLAKE_AGENT",
codexThreadID: (current.pendingAgentDestination == "CODEX_SELECTED_THREAD") ? snapshot?.codexTarget?.threadID : nil,
conversationID: (current.pendingAgentDestination == "CODEX_SELECTED_THREAD") ? nil : current.agentConversationID,
content: content,
requestID: requestID
)
} else {
agentMessage = nil
}
let aad = Data("hololake.mobile.sync/v1:\(current.deviceID)".utf8)
let responseData = try await Self.exchange(
url: current.baseURL.appending(path: "v1/sync"),
key: current.sessionKey,
aad: aad,
headers: ["X-HoloLake-Device-ID": current.deviceID],
payload: SyncRequest(counter: current.counter, afterCursor: nil, capture: capture)
payload: SyncRequest(
counter: current.counter,
afterCursor: nil,
capture: capture,
agentMessage: agentMessage
)
)
let nextSnapshot = try JSONDecoder().decode(MobileSyncSnapshot.self, from: responseData)
guard nextSnapshot.schema == "hololake.mobile-sync/v1",
@ -105,6 +147,16 @@ final class HoloLakeSyncClient: ObservableObject {
throw HoloLakeMobileError.invalidResponse
}
current.cursor = nextSnapshot.cursor
if let exchange = nextSnapshot.agentExchange,
exchange.requestID == current.pendingAgentRequestID,
exchange.state == "COMPLETED" {
if current.pendingAgentDestination != "CODEX_SELECTED_THREAD" {
current.agentConversationID = exchange.conversationID
}
current.pendingAgentRequestID = nil
current.pendingAgentContent = nil
current.pendingAgentDestination = nil
}
try HoloLakeKeychain.save(current)
session = current
snapshot = nextSnapshot
@ -112,6 +164,10 @@ final class HoloLakeSyncClient: ObservableObject {
}
}
func sendAgentMessage(_ content: String, destination: String) async {
await sync(agentContent: content, agentDestination: destination)
}
func disconnect() {
do {
try HoloLakeKeychain.remove()
@ -156,7 +212,7 @@ final class HoloLakeSyncClient: ObservableObject {
)
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = 8
request.timeoutInterval = 120
request.cachePolicy = .reloadIgnoringLocalCacheData
request.httpBody = sealed.ciphertext + sealed.tag
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")