Archived
131 lines
5.2 KiB
Swift
131 lines
5.2 KiB
Swift
import Foundation
|
|
import os
|
|
|
|
private let logger = Logger(subsystem: "dev.shane.voice", category: "tasks")
|
|
|
|
struct TaskStep: Identifiable, Equatable {
|
|
let id = UUID()
|
|
let role: String?
|
|
let content: String?
|
|
}
|
|
|
|
struct TaskItem: Identifiable, Equatable {
|
|
enum Status: Equatable {
|
|
case pending
|
|
case running
|
|
case completed
|
|
case failed
|
|
}
|
|
|
|
let id: String
|
|
var description: String
|
|
var status: Status
|
|
var steps: [TaskStep]
|
|
var result: String?
|
|
var error: String?
|
|
var createdAt: Date
|
|
var completedAt: Date?
|
|
}
|
|
|
|
/// Folds task_event messages into task items, mirroring the web UI's tasks Map.
|
|
@MainActor
|
|
final class TaskStore: ObservableObject {
|
|
@Published private(set) var tasks: [String: TaskItem] = [:]
|
|
|
|
var sortedTasks: [TaskItem] {
|
|
tasks.values.sorted { $0.createdAt > $1.createdAt }
|
|
}
|
|
|
|
var activeCount: Int {
|
|
tasks.values.filter { $0.status == .pending || $0.status == .running }.count
|
|
}
|
|
|
|
func apply(_ payload: ControlChannel.TaskEventPayload) {
|
|
guard let taskId = payload.taskId, !taskId.isEmpty else { return }
|
|
switch payload.type {
|
|
case "created":
|
|
tasks[taskId] = TaskItem(
|
|
id: taskId,
|
|
description: payload.description ?? "Background task",
|
|
status: .pending,
|
|
steps: [],
|
|
result: nil,
|
|
error: nil,
|
|
createdAt: date(from: payload.createdAt),
|
|
completedAt: nil
|
|
)
|
|
case "step":
|
|
var task = tasks[taskId] ?? TaskItem(
|
|
id: taskId,
|
|
description: payload.description ?? "Background task",
|
|
status: .pending,
|
|
steps: [],
|
|
result: nil,
|
|
error: nil,
|
|
createdAt: date(from: payload.createdAt),
|
|
completedAt: nil
|
|
)
|
|
task.steps.append(TaskStep(role: payload.role, content: payload.content))
|
|
if task.status == .pending { task.status = .running }
|
|
tasks[taskId] = task
|
|
case "completed":
|
|
var task = tasks[taskId] ?? TaskItem(
|
|
id: taskId,
|
|
description: payload.description ?? "Background task",
|
|
status: .pending,
|
|
steps: [],
|
|
result: nil,
|
|
error: nil,
|
|
createdAt: date(from: payload.createdAt),
|
|
completedAt: nil
|
|
)
|
|
task.status = .completed
|
|
task.result = payload.result
|
|
task.completedAt = Date()
|
|
tasks[taskId] = task
|
|
case "failed":
|
|
var task = tasks[taskId] ?? TaskItem(
|
|
id: taskId,
|
|
description: payload.description ?? "Background task",
|
|
status: .pending,
|
|
steps: [],
|
|
result: nil,
|
|
error: nil,
|
|
createdAt: date(from: payload.createdAt),
|
|
completedAt: nil
|
|
)
|
|
task.status = .failed
|
|
task.error = payload.error
|
|
task.completedAt = Date()
|
|
tasks[taskId] = task
|
|
default:
|
|
return
|
|
}
|
|
logger.log("task \(payload.type, privacy: .public) id=\(taskId, privacy: .public) steps=\(self.tasks[taskId]?.steps.count ?? 0, privacy: .public)")
|
|
}
|
|
|
|
func clear() {
|
|
tasks.removeAll()
|
|
}
|
|
|
|
#if DEBUG
|
|
func injectSampleEvents() {
|
|
let now = Date().timeIntervalSince1970
|
|
apply(ControlChannel.TaskEventPayload(type: "created", taskId: "sample-a", description: "Research the best coffee roasters in Portland", role: nil, content: nil, result: nil, error: nil, createdAt: now - 45))
|
|
apply(ControlChannel.TaskEventPayload(type: "step", taskId: "sample-a", description: nil, role: "thinking", content: "Searching for Portland roasteries…", result: nil, error: nil, createdAt: nil))
|
|
apply(ControlChannel.TaskEventPayload(type: "step", taskId: "sample-a", description: nil, role: "tool_result", content: "Found 12 results across 3 review sites.", result: nil, error: nil, createdAt: nil))
|
|
apply(ControlChannel.TaskEventPayload(type: "completed", taskId: "sample-a", description: nil, role: nil, content: nil, result: "Top three: Coava, Stumptown, and Heart Roasters — all scored 4.5+ across reviews.", error: nil, createdAt: nil))
|
|
apply(ControlChannel.TaskEventPayload(type: "created", taskId: "sample-b", description: "Compare iPhone 17 vs Pixel 10 battery life", role: nil, content: nil, result: nil, error: nil, createdAt: now - 20))
|
|
apply(ControlChannel.TaskEventPayload(type: "step", taskId: "sample-b", description: nil, role: "thinking", content: "Fetching benchmark articles…", result: nil, error: nil, createdAt: nil))
|
|
apply(ControlChannel.TaskEventPayload(type: "failed", taskId: "sample-b", description: nil, role: nil, content: nil, result: nil, error: "Search backend timed out after 30s", createdAt: nil))
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private extension TaskStore {
|
|
func date(from interval: Double?) -> Date {
|
|
guard let interval, interval > 0 else { return Date() }
|
|
return Date(timeIntervalSince1970: interval)
|
|
}
|
|
}
|