Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions Shared/ScriptWidgetRuntime/AI/AIClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//
// AIClient.swift
// ScriptWidget
//
// Thin wrapper around SwiftOpenAI that performs non-streaming chat
// completions against any OpenAI-compatible endpoint configured in
// AISettings.
//

import Foundation
import SwiftOpenAI

struct AITokenUsage: Equatable {
var promptTokens: Int
var completionTokens: Int
var totalTokens: Int

static let zero = AITokenUsage(promptTokens: 0, completionTokens: 0, totalTokens: 0)

static func + (lhs: AITokenUsage, rhs: AITokenUsage) -> AITokenUsage {
AITokenUsage(
promptTokens: lhs.promptTokens + rhs.promptTokens,
completionTokens: lhs.completionTokens + rhs.completionTokens,
totalTokens: lhs.totalTokens + rhs.totalTokens
)
}
}

struct AIChatResult {
let content: String
let usage: AITokenUsage
}

enum AIClientError: LocalizedError {
case missingAPIKey
case invalidBaseURL(String)
case emptyResponse
case upstream(String)

var errorDescription: String? {
switch self {
case .missingAPIKey:
return "API key is not set. Open Settings → AI to configure it."
case .invalidBaseURL(let url):
return "Base URL is invalid: \(url)"
case .emptyResponse:
return "The model returned an empty response."
case .upstream(let message):
return message
}
}
}

actor AIClient {
static let shared = AIClient()

func chat(messages: [AIMessage], settings: AISettings) async throws -> AIChatResult {
let trimmedKey = settings.apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedKey.isEmpty else {
throw AIClientError.missingAPIKey
}
let baseURLString = settings.normalizedBaseURL
guard URL(string: baseURLString) != nil else {
throw AIClientError.invalidBaseURL(baseURLString)
}

let service: OpenAIService
if baseURLString == AISettings.defaultBaseURL {
service = OpenAIServiceFactory.service(apiKey: trimmedKey)
} else {
service = OpenAIServiceFactory.service(
apiKey: trimmedKey,
overrideBaseURL: baseURLString
)
}

let chatMessages: [ChatCompletionParameters.Message] = messages.map { msg in
let role: ChatCompletionParameters.Message.Role
switch msg.role {
case .system: role = .system
case .user: role = .user
case .assistant: role = .assistant
}
return ChatCompletionParameters.Message(role: role, content: .text(msg.content))
}

let modelId = settings.model.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedModel: Model = modelId.isEmpty ? .custom(AISettings.defaultModel) : .custom(modelId)

let parameters = ChatCompletionParameters(
messages: chatMessages,
model: resolvedModel,
temperature: settings.temperature
)

do {
let response = try await service.startChat(parameters: parameters)
guard let content = response.choices?.first?.message?.content, !content.isEmpty else {
throw AIClientError.emptyResponse
}
let usage = AITokenUsage(
promptTokens: response.usage?.promptTokens ?? 0,
completionTokens: response.usage?.completionTokens ?? 0,
totalTokens: response.usage?.totalTokens ?? 0
)
return AIChatResult(content: content, usage: usage)
} catch let err as AIClientError {
throw err
} catch {
throw AIClientError.upstream(error.localizedDescription)
}
}
}
99 changes: 99 additions & 0 deletions Shared/ScriptWidgetRuntime/AI/AIExamplePrompts.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//
// AIExamplePrompts.swift
// ScriptWidget
//
// Curated starter prompts surfaced on the AI Generate screen. Kept
// deliberately concrete (mentioning colors, data sources, layout)
// so the agent loop converges quickly.
//

import Foundation

struct AIExamplePrompt: Identifiable {
let id = UUID()
let title: String
let symbol: String // SF Symbol name
let size: AIWidgetSize
let prompt: String
}

enum AIExamplePrompts {
static let all: [AIExamplePrompt] = [
AIExamplePrompt(
title: "Weather",
symbol: "cloud.sun.fill",
size: .medium,
prompt:
"Show the current weather for my device location using the Open-Meteo API " +
"(https://api.open-meteo.com/v1/forecast). Dark navy background. " +
"Big temperature in Celsius, feels-like temperature below in a smaller caption, " +
"and the weather code. Handle missing location gracefully with a message."
),
AIExamplePrompt(
title: "Clock",
symbol: "clock.fill",
size: .small,
prompt:
"A minimalist clock widget. Show the current time as a large HH:mm, " +
"today's weekday and date below in a muted caption. " +
"Dark gradient background from near-black to deep purple."
),
AIExamplePrompt(
title: "Countdown",
symbol: "calendar.badge.clock",
size: .medium,
prompt:
"A countdown widget to 2026-12-31. Show days remaining as a big number, " +
"with the label 'days until New Year' underneath. " +
"Warm orange-to-red gradient background, light text."
),
AIExamplePrompt(
title: "Crypto Price",
symbol: "bitcoinsign.circle.fill",
size: .medium,
prompt:
"Fetch the current Bitcoin price in USD from " +
"https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd&include_24hr_change=true " +
"and display it. Large USD price, a second line with the 24h change " +
"(green with ▲ if positive, red with ▼ if negative). Black background."
),
AIExamplePrompt(
title: "Battery Ring",
symbol: "battery.75percent",
size: .small,
prompt:
"Show the device battery percentage via $device as a number in the center " +
"of a circular gauge ring. Use green above 50%, yellow 20-50%, red below 20%. " +
"Dark background."
),
AIExamplePrompt(
title: "Quote",
symbol: "quote.bubble.fill",
size: .large,
prompt:
"A daily quote widget. Hardcode 7 short inspirational quotes (one per weekday) " +
"and display the one matching today's weekday. " +
"Quote in a readable body font centered, author on a second line in caption. " +
"Soft pastel gradient background."
),
AIExamplePrompt(
title: "Steps",
symbol: "figure.walk",
size: .small,
prompt:
"Show today's step count from $health. Large number centered, " +
"'steps' label below in caption. Progress bar at the bottom " +
"showing progress toward a 10000-step goal. Dark teal background."
),
AIExamplePrompt(
title: "Habit Grid",
symbol: "checkmark.square.fill",
size: .large,
prompt:
"A GitHub-style 7x5 habit tracker grid (35 cells). Hardcode a boolean " +
"array of 35 values representing the last 35 days of a 'read 20 mins' habit. " +
"Green filled cells for completed, gray for missed. " +
"Header: 'Reading streak' with current streak count in the top-right."
),
]
}
158 changes: 158 additions & 0 deletions Shared/ScriptWidgetRuntime/AI/AIGenerateProgressView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
//
// AIGenerateProgressView.swift
// ScriptWidget
//
// Live status + history for the agent loop.
//

import SwiftUI

struct AIGenerateProgressView: View {
@ObservedObject var session: AIGenerateSession

@State private var historyExpanded = false

var body: some View {
VStack(alignment: .leading, spacing: 12) {
phaseHeader

if let progress = progressFraction {
ProgressView(value: progress)
.tint(tintForPhase)
}

if let detail = detailLine {
Text(detail)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(3)
}

HStack {
Label("\(session.usage.totalTokens) tokens", systemImage: "bolt")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if session.isRunning {
Button(role: .destructive) {
session.cancel()
} label: {
Label("Cancel", systemImage: "xmark.circle")
.font(.caption)
}
.buttonStyle(.bordered)
}
}

if !session.iterationHistory.isEmpty {
DisclosureGroup(isExpanded: $historyExpanded) {
VStack(alignment: .leading, spacing: 6) {
ForEach(session.iterationHistory) { record in
HStack(alignment: .top, spacing: 8) {
Text("#\(record.iteration)")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 28, alignment: .leading)
if let err = record.errorSummary {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.font(.caption2)
Text(err)
.font(.caption2)
.lineLimit(2)
} else {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.caption2)
Text("ran successfully")
.font(.caption2)
}
Spacer()
}
}
}
.padding(.vertical, 4)
} label: {
Text("History (\(session.iterationHistory.count))")
.font(.footnote)
}
}
}
.padding()
.background(Color.secondary.opacity(0.08))
.cornerRadius(12)
}

// MARK: - derived

private var phaseHeader: some View {
HStack(spacing: 8) {
icon
.font(.title3)
.foregroundStyle(tintForPhase)
Text(title)
.font(.headline)
Spacer()
}
}

@ViewBuilder private var icon: some View {
switch session.phase {
case .idle: Image(systemName: "sparkles")
case .thinking: Image(systemName: "brain")
case .running: Image(systemName: "play.circle")
case .fixing: Image(systemName: "wrench.and.screwdriver")
case .done: Image(systemName: "checkmark.seal.fill")
case .exhausted: Image(systemName: "exclamationmark.triangle")
case .failed: Image(systemName: "xmark.octagon.fill")
case .cancelled: Image(systemName: "xmark.circle")
}
}

private var title: String {
let limit = session.maxIterationsForProgress
switch session.phase {
case .idle: return "Ready"
case .thinking(let i): return "Thinking (iteration \(i) / \(limit))"
case .running(let i): return "Running (iteration \(i) / \(limit))"
case .fixing(let i, _): return "Fixing (iteration \(i) / \(limit))"
case .done: return "Done"
case .exhausted: return "Did not converge"
case .failed: return "Failed"
case .cancelled: return "Cancelled"
}
}

private var detailLine: String? {
switch session.phase {
case .fixing(_, let summary): return summary
case .failed(let msg): return msg
case .exhausted(_, let lastError): return lastError
case .running: return "Executing generated JSX inside the sandbox runtime."
case .thinking: return "Waiting on the model response."
default: return nil
}
}

private var progressFraction: Double? {
let limit = Double(session.maxIterationsForProgress)
guard limit > 0 else { return nil }
let i = Double(session.currentIteration)
switch session.phase {
case .thinking, .running, .fixing:
return Swift.min(1.0, i / limit)
case .done: return 1.0
case .exhausted: return 1.0
default: return nil
}
}

private var tintForPhase: Color {
switch session.phase {
case .done: return .green
case .failed, .exhausted: return .orange
case .cancelled: return .gray
default: return .accentColor
}
}
}
Loading