feat: add tiered channel agent and mobile Codex bridge
This commit is contained in:
parent
950b7e48b3
commit
71db11e7e8
35 changed files with 2242 additions and 76 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))。"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue