diff --git a/Shared/ScriptWidgetRuntime/AI/AIClient.swift b/Shared/ScriptWidgetRuntime/AI/AIClient.swift new file mode 100644 index 0000000..14c70a8 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/AI/AIClient.swift @@ -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) + } + } +} diff --git a/Shared/ScriptWidgetRuntime/AI/AIExamplePrompts.swift b/Shared/ScriptWidgetRuntime/AI/AIExamplePrompts.swift new file mode 100644 index 0000000..35aa78e --- /dev/null +++ b/Shared/ScriptWidgetRuntime/AI/AIExamplePrompts.swift @@ -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." + ), + ] +} diff --git a/Shared/ScriptWidgetRuntime/AI/AIGenerateProgressView.swift b/Shared/ScriptWidgetRuntime/AI/AIGenerateProgressView.swift new file mode 100644 index 0000000..909e125 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/AI/AIGenerateProgressView.swift @@ -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 + } + } +} diff --git a/Shared/ScriptWidgetRuntime/AI/AIGenerateSession.swift b/Shared/ScriptWidgetRuntime/AI/AIGenerateSession.swift new file mode 100644 index 0000000..a4cf86b --- /dev/null +++ b/Shared/ScriptWidgetRuntime/AI/AIGenerateSession.swift @@ -0,0 +1,171 @@ +// +// AIGenerateSession.swift +// ScriptWidget +// +// Main-actor-facing state machine that drives the agent loop and +// surfaces progress to SwiftUI views. +// + +import Foundation +#if canImport(Combine) +import Combine +#endif + +@MainActor +final class AIGenerateSession: ObservableObject { + + enum Phase: Equatable { + case idle + case thinking(iteration: Int) + case running(iteration: Int) + case fixing(iteration: Int, errorSummary: String) + case done(jsx: String) + case exhausted(lastJSX: String?, lastError: String?) + case failed(String) + case cancelled + } + + struct IterationRecord: Identifiable, Equatable { + let id = UUID() + let iteration: Int + let jsx: String + let errorSummary: String? // nil = success + let logs: [String] + } + + @Published private(set) var phase: Phase = .idle + @Published private(set) var iterationHistory: [IterationRecord] = [] + @Published private(set) var usage: AITokenUsage = .zero + @Published private(set) var lastJSX: String? + @Published private(set) var resultElement: ScriptWidgetRuntimeElement? + @Published private(set) var isRunning: Bool = false + + @Published var size: AIWidgetSize = .medium + + private var currentTask: Task? + + var maxIterationsForProgress: Int { + AISettingsStore.shared.load().maxIterations + } + + var currentIteration: Int { + switch phase { + case .thinking(let i), .running(let i), .fixing(let i, _): + return i + default: + return 0 + } + } + + func start(userDescription: String) { + let description = userDescription.trimmingCharacters(in: .whitespacesAndNewlines) + guard !description.isEmpty else { return } + let settings = AISettingsStore.shared.load() + let request = AgentLoopRequest( + mode: .fresh(userDescription: description), + size: size, + settings: settings, + maxIterations: settings.maxIterations + ) + kickoff(request: request, initialJSX: nil) + } + + func refine(currentCode: String, refineInstruction: String) { + let trimmedCode = currentCode.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedInstr = refineInstruction.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedCode.isEmpty, !trimmedInstr.isEmpty else { return } + let settings = AISettingsStore.shared.load() + let request = AgentLoopRequest( + mode: .refine(currentCode: trimmedCode, refineInstruction: trimmedInstr), + size: size, + settings: settings, + maxIterations: settings.maxIterations + ) + kickoff(request: request, initialJSX: trimmedCode) + } + + func cancel() { + currentTask?.cancel() + } + + func reset() { + cancel() + phase = .idle + iterationHistory = [] + usage = .zero + lastJSX = nil + resultElement = nil + isRunning = false + } + + private func kickoff(request: AgentLoopRequest, initialJSX: String?) { + currentTask?.cancel() + iterationHistory = [] + usage = .zero + lastJSX = initialJSX + resultElement = nil + isRunning = true + phase = .thinking(iteration: 1) + + let loop = AgentLoop() + currentTask = Task { [weak self] in + guard let self else { return } + let outcome = await loop.run(request) { [weak self] event in + guard let self else { return } + self.apply(event: event) + } + self.apply(outcome: outcome) + } + } + + private func apply(event: AgentLoopEvent) { + switch event { + case .thinking(let i): + phase = .thinking(iteration: i) + case .produced(let i, let jsx): + lastJSX = jsx + // History entry is appended on run result (success or fail). + _ = i + case .running(let i): + phase = .running(iteration: i) + case .ranFailed(let i, let summary, let logs): + phase = .fixing(iteration: i, errorSummary: summary) + iterationHistory.append(IterationRecord( + iteration: i, + jsx: lastJSX ?? "", + errorSummary: summary, + logs: logs + )) + case .ranSucceeded(let i): + iterationHistory.append(IterationRecord( + iteration: i, + jsx: lastJSX ?? "", + errorSummary: nil, + logs: [] + )) + case .tokensUsed(let u): + usage = u + } + } + + private func apply(outcome: AgentLoopOutcome) { + isRunning = false + switch outcome { + case .succeeded(let jsx, let element, let usage): + lastJSX = jsx + resultElement = element + self.usage = usage + phase = .done(jsx: jsx) + case .exhausted(let lastJSX, let lastError, let usage): + if let lastJSX { self.lastJSX = lastJSX } + self.usage = usage + phase = .exhausted(lastJSX: lastJSX, lastError: lastError) + case .cancelled(let usage): + self.usage = usage + phase = .cancelled + case .failed(let message, let usage): + self.usage = usage + phase = .failed(message) + } + } +} diff --git a/Shared/ScriptWidgetRuntime/AI/AIReferenceSnapshot.swift b/Shared/ScriptWidgetRuntime/AI/AIReferenceSnapshot.swift new file mode 100644 index 0000000..1ee090f --- /dev/null +++ b/Shared/ScriptWidgetRuntime/AI/AIReferenceSnapshot.swift @@ -0,0 +1,109 @@ +// +// AIReferenceSnapshot.swift +// ScriptWidget +// +// Builds a compact reference manual for the LLM's system prompt by +// sampling real usage examples from Script.bundle (component / api). +// Cached after first build. +// + +import Foundation + +struct AIReferenceSnapshot { + let componentsBlock: String + let apisBlock: String + + var combined: String { + var out = "" + if !componentsBlock.isEmpty { + out += "=== COMPONENTS (JSX tags) ===\n" + out += componentsBlock + out += "\n" + } + if !apisBlock.isEmpty { + out += "=== APIs (globals) ===\n" + out += apisBlock + } + return out + } +} + +enum AIReferenceSnapshotLoader { + // Cap per-file lines so the prompt stays bounded. + private static let maxLinesPerFile = 40 + + // If the full block is larger than this many chars, fall back to a + // curated subset of APIs. + private static let softCharBudget = 60_000 + + private static let priorityAPIs: [String] = [ + "fetch", "http", "storage", "location", "health", + "device", "file", "getenv", "system", "console", + ] + + private static var cached: AIReferenceSnapshot? + + static func load() -> AIReferenceSnapshot { + if let cached = cached { + return cached + } + let snapshot = build() + cached = snapshot + return snapshot + } + + private static func build() -> AIReferenceSnapshot { + guard let bundleURL = Bundle.main.url(forResource: "Script", withExtension: "bundle") else { + return AIReferenceSnapshot(componentsBlock: "", apisBlock: "") + } + + let componentsBlock = readSection( + rootURL: bundleURL.appendingPathComponent("component"), + whitelist: nil + ) + + // First attempt: all APIs. + var apisBlock = readSection( + rootURL: bundleURL.appendingPathComponent("api"), + whitelist: nil + ) + + let overBudget = (componentsBlock.count + apisBlock.count) > softCharBudget + if overBudget { + apisBlock = readSection( + rootURL: bundleURL.appendingPathComponent("api"), + whitelist: Set(priorityAPIs) + ) + } + + return AIReferenceSnapshot(componentsBlock: componentsBlock, apisBlock: apisBlock) + } + + private static func readSection(rootURL: URL, whitelist: Set?) -> String { + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory(atPath: rootURL.path) else { + return "" + } + var pieces: [String] = [] + for name in entries.sorted() { + if let whitelist = whitelist, !whitelist.contains(name) { + continue + } + let mainJsx = rootURL.appendingPathComponent(name).appendingPathComponent("main.jsx") + guard let content = try? String(contentsOf: mainJsx, encoding: .utf8) else { + continue + } + let trimmed = limit(content, lines: maxLinesPerFile) + pieces.append("// === \(name) ===\n\(trimmed)") + } + return pieces.joined(separator: "\n\n") + } + + private static func limit(_ text: String, lines: Int) -> String { + let all = text.split(separator: "\n", omittingEmptySubsequences: false) + if all.count <= lines { + return text + } + return all.prefix(lines).joined(separator: "\n") + "\n// ..." + } +} diff --git a/Shared/ScriptWidgetRuntime/AI/AISettings.swift b/Shared/ScriptWidgetRuntime/AI/AISettings.swift new file mode 100644 index 0000000..1709760 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/AI/AISettings.swift @@ -0,0 +1,106 @@ +// +// AISettings.swift +// ScriptWidget +// +// Persistent configuration for the AI Generate feature. +// Stored in the app-group UserDefaults so both iOS and macOS main apps +// (and potentially extensions) share the same values. +// +// Security note: API key is stored in plain-text UserDefaults in this +// initial revision. A Keychain migration is planned. +// + +import Foundation + +enum AISettingsKey { + static let apiKey = "ai.apiKey" + static let baseURL = "ai.baseURL" + static let model = "ai.model" + static let maxIterations = "ai.maxIterations" + static let temperature = "ai.temperature" +} + +struct AISettings: Equatable { + var apiKey: String + var baseURL: String + var model: String + var maxIterations: Int + var temperature: Double + + static let defaultBaseURL = "https://api.openai.com" + static let defaultModel = "gpt-4o-mini" + static let defaultMaxIterations = 20 + static let defaultTemperature = 0.7 + + static let `default` = AISettings( + apiKey: "", + baseURL: defaultBaseURL, + model: defaultModel, + maxIterations: defaultMaxIterations, + temperature: defaultTemperature + ) + + var isConfigured: Bool { + !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var normalizedBaseURL: String { + let trimmed = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return AISettings.defaultBaseURL + } + // Strip trailing /v1 or / — SwiftOpenAI appends /v1 itself. + var normalized = trimmed + while normalized.hasSuffix("/") { + normalized.removeLast() + } + if normalized.hasSuffix("/v1") { + normalized.removeLast(3) + } + while normalized.hasSuffix("/") { + normalized.removeLast() + } + return normalized + } +} + +final class AISettingsStore { + static let shared = AISettingsStore() + + static let changedNotification = Notification.Name("AISettingsStoreChanged") + + private let defaults: UserDefaults + + private init() { + self.defaults = UserDefaults(suiteName: "group.everettjf.scriptwidget") ?? .standard + } + + func load() -> AISettings { + let apiKey = defaults.string(forKey: AISettingsKey.apiKey) ?? "" + let baseURL = defaults.string(forKey: AISettingsKey.baseURL) ?? AISettings.defaultBaseURL + let model = defaults.string(forKey: AISettingsKey.model) ?? AISettings.defaultModel + + let storedIterations = defaults.object(forKey: AISettingsKey.maxIterations) as? Int + let maxIterations = storedIterations ?? AISettings.defaultMaxIterations + + let storedTemperature = defaults.object(forKey: AISettingsKey.temperature) as? Double + let temperature = storedTemperature ?? AISettings.defaultTemperature + + return AISettings( + apiKey: apiKey, + baseURL: baseURL, + model: model, + maxIterations: maxIterations, + temperature: temperature + ) + } + + func save(_ settings: AISettings) { + defaults.set(settings.apiKey, forKey: AISettingsKey.apiKey) + defaults.set(settings.baseURL, forKey: AISettingsKey.baseURL) + defaults.set(settings.model, forKey: AISettingsKey.model) + defaults.set(settings.maxIterations, forKey: AISettingsKey.maxIterations) + defaults.set(settings.temperature, forKey: AISettingsKey.temperature) + NotificationCenter.default.post(name: AISettingsStore.changedNotification, object: nil) + } +} diff --git a/Shared/ScriptWidgetRuntime/AI/AgentLoop.swift b/Shared/ScriptWidgetRuntime/AI/AgentLoop.swift new file mode 100644 index 0000000..a338b5d --- /dev/null +++ b/Shared/ScriptWidgetRuntime/AI/AgentLoop.swift @@ -0,0 +1,143 @@ +// +// AgentLoop.swift +// ScriptWidget +// +// Core generate → run → fix loop. Stateless; owned by AIGenerateSession. +// + +import Foundation + +enum AgentLoopOutcome { + case succeeded(jsx: String, element: ScriptWidgetRuntimeElement, usage: AITokenUsage) + case exhausted(lastJSX: String?, lastError: String?, usage: AITokenUsage) + case cancelled(usage: AITokenUsage) + case failed(message: String, usage: AITokenUsage) +} + +enum AgentLoopEvent { + case thinking(iteration: Int) + case produced(iteration: Int, jsx: String) + case running(iteration: Int) + case ranFailed(iteration: Int, errorSummary: String, logs: [String]) + case ranSucceeded(iteration: Int) + case tokensUsed(AITokenUsage) // incremental +} + +struct AgentLoopRequest { + enum Mode { + case fresh(userDescription: String) + case refine(currentCode: String, refineInstruction: String) + } + let mode: Mode + let size: AIWidgetSize + let settings: AISettings + let maxIterations: Int +} + +final class AgentLoop { + typealias EventHandler = @MainActor (AgentLoopEvent) -> Void + + private let client: AIClient + private let bridge: AgentRuntimeBridge + + init(client: AIClient = .shared, bridge: AgentRuntimeBridge = .shared) { + self.client = client + self.bridge = bridge + } + + func run(_ request: AgentLoopRequest, onEvent: @escaping EventHandler) async -> AgentLoopOutcome { + var cumulativeUsage = AITokenUsage.zero + + let systemMessage = AIMessage(role: .system, content: PromptBuilder.systemPrompt(reference: AIReferenceSnapshotLoader.load())) + let firstUserMessage: AIMessage + var latestCode: String? + + switch request.mode { + case .fresh(let description): + firstUserMessage = AIMessage(role: .user, content: PromptBuilder.userPromptFirst(userDescription: description, size: request.size)) + latestCode = nil + case .refine(let currentCode, let instruction): + firstUserMessage = AIMessage(role: .user, content: PromptBuilder.userPromptRefine(currentCode: currentCode, refineInstruction: instruction)) + latestCode = currentCode + } + + let package: ScriptWidgetPackage + do { + package = try bridge.makeSandboxPackage() + } catch { + return .failed(message: error.localizedDescription, usage: cumulativeUsage) + } + defer { bridge.cleanupSandboxPackage(package) } + + var lastError: String? + var lastLogs: [String] = [] + + let iterationLimit = max(1, request.maxIterations) + + for iteration in 1...iterationLimit { + if Task.isCancelled { + return .cancelled(usage: cumulativeUsage) + } + + await onEvent(.thinking(iteration: iteration)) + + let messages: [AIMessage] + if let previous = latestCode, iteration > 1, let errMsg = lastError { + messages = [ + systemMessage, + firstUserMessage, + AIMessage(role: .assistant, content: previous), + AIMessage(role: .user, content: PromptBuilder.userPromptFix( + previousCode: previous, errorSummary: errMsg, recentLogs: lastLogs + )), + ] + } else { + messages = [systemMessage, firstUserMessage] + } + + let chatResult: AIChatResult + do { + chatResult = try await client.chat(messages: messages, settings: request.settings) + } catch { + return .failed(message: error.localizedDescription, usage: cumulativeUsage) + } + + cumulativeUsage = cumulativeUsage + chatResult.usage + await onEvent(.tokensUsed(cumulativeUsage)) + + if Task.isCancelled { + return .cancelled(usage: cumulativeUsage) + } + + let jsx = PromptBuilder.stripCodeFences(chatResult.content) + latestCode = jsx + await onEvent(.produced(iteration: iteration, jsx: jsx)) + + await onEvent(.running(iteration: iteration)) + let runResult = await bridge.run(jsx: jsx, in: package, size: request.size) + + if Task.isCancelled { + return .cancelled(usage: cumulativeUsage) + } + + if runResult.didSucceed, let element = runResult.element { + await onEvent(.ranSucceeded(iteration: iteration)) + return .succeeded(jsx: jsx, element: element, usage: cumulativeUsage) + } + + let summary: String + if let err = runResult.error { + summary = err.summaryForPrompt + } else if runResult.element == nil { + summary = "Runtime returned no element." + } else { + summary = "Runtime returned a fallback/placeholder element. The widget did not render real content." + } + lastError = summary + lastLogs = runResult.logs + await onEvent(.ranFailed(iteration: iteration, errorSummary: summary, logs: runResult.logs)) + } + + return .exhausted(lastJSX: latestCode, lastError: lastError, usage: cumulativeUsage) + } +} diff --git a/Shared/ScriptWidgetRuntime/AI/AgentRuntimeBridge.swift b/Shared/ScriptWidgetRuntime/AI/AgentRuntimeBridge.swift new file mode 100644 index 0000000..0345951 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/AI/AgentRuntimeBridge.swift @@ -0,0 +1,129 @@ +// +// AgentRuntimeBridge.swift +// ScriptWidget +// +// Adapts the synchronous ScriptWidgetRuntime.executeJSXSyncForWidget to +// an async interface, persists the generated JSX to a one-shot temp +// package (so $file / $import won't explode), and serializes executions +// (the runtime uses a global `sharedRunningState` for log capture). +// + +import Foundation + +enum AgentRuntimeBridgeError: LocalizedError { + case tempPackageCreationFailed(String) + case writeFailed(String) + + var errorDescription: String? { + switch self { + case .tempPackageCreationFailed(let s): return "Failed to create sandbox package: \(s)" + case .writeFailed(let s): return "Failed to write JSX: \(s)" + } + } +} + +struct AgentRunResult { + let element: ScriptWidgetRuntimeElement? + let error: ScriptWidgetError? + let logs: [String] + + var didSucceed: Bool { + guard error == nil else { return false } + guard let element = element else { return false } + if let tag = element.tagAsString() { + // Fallback sentinels emitted by the runtime when $render is + // missing or the script blew up before reaching it. + if element.children?.contains(where: { value in + if let s = value as? String { + return s == "#UI Not Found#" || s == "#Failed#" || s == "#Loading#" + } + return false + }) ?? false { + return false + } + // Valid widgets start with a layout container or a + // recognized tag; empty tag strings are suspicious. + if tag.isEmpty { return false } + } + // Heuristic: if the only console output was an [error], treat as failed. + if logs.contains(where: { $0.hasPrefix("[error]") || $0.lowercased().contains("uncaught") }) { + return false + } + return true + } +} + +final class AgentRuntimeBridge { + static let shared = AgentRuntimeBridge() + + // Runs are serialized because ScriptWidgetRuntime stores running + // state in a global. + private let serialQueue = DispatchQueue(label: "scriptwidget.ai.runtime.serial") + + private let sessionRoot: URL + + private init() { + let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("ScriptWidgetAI", isDirectory: true) + try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + self.sessionRoot = base + } + + func makeSandboxPackage(prefix: String = "session") throws -> ScriptWidgetPackage { + let dir = sessionRoot.appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) + do { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + } catch { + throw AgentRuntimeBridgeError.tempPackageCreationFailed(error.localizedDescription) + } + return ScriptWidgetPackage(path: dir, readonly: false) + } + + func cleanupSandboxPackage(_ package: ScriptWidgetPackage) { + try? FileManager.default.removeItem(at: package.path) + } + + func run(jsx: String, in package: ScriptWidgetPackage, size: AIWidgetSize) async -> AgentRunResult { + return await withCheckedContinuation { continuation in + serialQueue.async { + // Persist the JSX so packages that read themselves or + // register support files still work. + let writeResult = package.writeMainFile(content: jsx) + if !writeResult.0 { + continuation.resume(returning: AgentRunResult( + element: nil, + error: .internalError("Failed to write main.jsx: \(writeResult.1)"), + logs: [] + )) + return + } + + // Reset the global running state — the runtime will also + // do this in its init, but clearing here keeps log + // capture scoped to this single execution. + sharedRunningState = ScriptWidgetRunningState(package: package) + + let runtime = ScriptWidgetRuntime(package: package, environments: [ + "widget-size": size.rawValue, + "widget-param": "", + ]) + + let (element, err) = runtime.executeJSXSyncForWidget(jsx) + let logs = sharedRunningState?.logger.logs ?? [] + continuation.resume(returning: AgentRunResult(element: element, error: err, logs: logs)) + } + } + } +} + +extension ScriptWidgetError { + var summaryForPrompt: String { + switch self { + case .undefinedRender(let m): return "undefinedRender: \(m)" + case .internalError(let m): return "internalError: \(m)" + case .transformError(let m): return "transformError: \(m)" + case .scriptError(let m): return "scriptError: \(m)" + case .scriptException(let m): return "scriptException: \(m)" + } + } +} diff --git a/Shared/ScriptWidgetRuntime/AI/PromptBuilder.swift b/Shared/ScriptWidgetRuntime/AI/PromptBuilder.swift new file mode 100644 index 0000000..cb2a7a1 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/AI/PromptBuilder.swift @@ -0,0 +1,201 @@ +// +// PromptBuilder.swift +// ScriptWidget +// +// Constructs system / user messages for the widget-generation agent +// and strips code fences from LLM output. +// + +import Foundation + +#if canImport(CoreGraphics) +import CoreGraphics +#endif + +enum AIWidgetSize: String, CaseIterable, Identifiable { + case small + case medium + case large + case extraLarge + case accessoryInline + case accessoryCircular + case accessoryRectangular + + var id: String { rawValue } + + var displayName: String { + switch self { + case .small: return "Small" + case .medium: return "Medium" + case .large: return "Large" + case .extraLarge: return "Extra Large" + case .accessoryInline: return "Accessory Inline" + case .accessoryCircular: return "Accessory Circular" + case .accessoryRectangular: return "Accessory Rectangular" + } + } + + var previewSize: CGSize { + switch self { + case .small: return CGSize(width: 170, height: 170) + case .medium: return CGSize(width: 329, height: 170) + case .large: return CGSize(width: 329, height: 345) + case .extraLarge: return CGSize(width: 345, height: 329) + case .accessoryInline: return CGSize(width: 250, height: 30) + case .accessoryCircular: return CGSize(width: 72, height: 72) + case .accessoryRectangular: return CGSize(width: 170, height: 72) + } + } + + var previewIsCircular: Bool { self == .accessoryCircular } + + var designHint: String { + switch self { + case .small: + return "Square, ~155x155 px. Keep it to one or two key pieces of information." + case .medium: + return "Wide rectangle, ~329x155 px. Room for a small grid or two columns." + case .large: + return "Square, ~329x345 px. Multiple sections / richer layout." + case .extraLarge: + return "Wide rectangle (iPad), ~639x345 px. Dashboard-style density is fine." + case .accessoryInline: + return "Single line of text only. No colors, no layout containers beyond text." + case .accessoryCircular: + return "Very small round area (~72x72). Icon + a number at most." + case .accessoryRectangular: + return "Small rectangle (~160x72). A few short lines of text." + } + } +} + +struct AIMessage { + enum Role: String { case system, user, assistant } + let role: Role + let content: String +} + +enum PromptBuilder { + static func systemPrompt(reference: AIReferenceSnapshot) -> String { + let rules = """ + You are a ScriptWidget code generator. ScriptWidget runs widgets + written in a constrained JSX dialect inside JavaScriptCore. + Output ONLY a single JSX snippet — no markdown fences, no prose, + no explanations, no surrounding backticks. + + RULES: + 1. Call $render(<...>) exactly once. The root element MUST be a + layout container (vstack / hstack / zstack) unless you are + targeting an accessoryInline widget. + 2. Do NOT use `import`, `require`, `module`, any Node APIs, or + any DOM / browser APIs. + 3. Networking is ONLY via the globally injected `fetch(url)` + (returns a string) or the `$http.*` API. + 4. Top-level `await` is allowed — the runtime wraps your code in + an async `$main` function. + 5. Date/time: the global `moment` library is available. Plain JS + `Date` also works. + 6. Persistent data: `$storage.set(key, value)` and + `$storage.get(key)`. + 7. Only use tags, props, and APIs that appear in the REFERENCE + section below. Do not invent new ones. + 8. When calling `fetch`, always wrap it in try/catch so the + widget still renders something useful on network failure. + 9. Prefer readable typography (`font="title"`, `"headline"`, + `"caption"`, `"caption2"`) and sensible spacing. Match the + visual density to the declared widget size. + 10. Keep the output self-contained — no external files, no + image assets the user hasn't provided. + """ + let reference = reference.combined + return rules + "\n\n" + reference + } + + static func userPromptFirst(userDescription: String, size: AIWidgetSize) -> String { + """ + Widget size: \(size.rawValue) + Size hint: \(size.designHint) + + User description: + \(userDescription) + + Return the complete JSX snippet. No markdown, no explanation. + """ + } + + static func userPromptFix( + previousCode: String, + errorSummary: String, + recentLogs: [String] + ) -> String { + let logBlock: String + if recentLogs.isEmpty { + logBlock = "(no console output)" + } else { + logBlock = recentLogs.suffix(10).joined(separator: "\n") + } + return """ + Your previous code failed to run: + + ```jsx + \(previousCode) + ``` + + Runtime feedback: + \(errorSummary) + + Last console output: + \(logBlock) + + Fix the code and return the FULL corrected JSX only. No markdown, no explanation. + """ + } + + static func userPromptRefine(currentCode: String, refineInstruction: String) -> String { + """ + Current working widget code: + + ```jsx + \(currentCode) + ``` + + Apply this change request from the user: + \(refineInstruction) + + Return the FULL updated JSX only. No markdown, no explanation. + """ + } + + // Best-effort extraction: prefer content between matching ```jsx ... + // ``` fences, then strip any leading/trailing prose. + static func stripCodeFences(_ raw: String) -> String { + var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + + // Prefer fenced block if present. + if let fenced = extractFencedBlock(text) { + text = fenced + } + + // Drop stray code-fence markers. + text = text.replacingOccurrences(of: "```jsx", with: "") + text = text.replacingOccurrences(of: "```javascript", with: "") + text = text.replacingOccurrences(of: "```js", with: "") + text = text.replacingOccurrences(of: "```", with: "") + + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func extractFencedBlock(_ raw: String) -> String? { + guard let openRange = raw.range(of: "```") else { return nil } + let afterOpen = raw[openRange.upperBound...] + // Skip optional language tag on the same line. + let afterNewline: Substring + if let nl = afterOpen.firstIndex(of: "\n") { + afterNewline = afterOpen[afterOpen.index(after: nl)...] + } else { + afterNewline = afterOpen + } + guard let closeRange = afterNewline.range(of: "```") else { return nil } + return String(afterNewline[.. (Bool, String) { + let srcPath = self.getPackagePathFromPackageName(packageName: sourcePackageName) + guard FileManager.default.fileExists(atPath: srcPath.path) else { + return (false, "Source not found") + } + let newName = self.getValidPackageName(recommendPackageName: "\(sourcePackageName) Remix") + let destPath = self.getPackagePathFromPackageName(packageName: newName) + do { + try FileManager.default.copyItem(at: srcPath, to: destPath) + } catch { + return (false, "Failed to duplicate: \(error.localizedDescription)") + } + if !self.isBuild { + let package = self.getScriptPackage(packageName: newName) + _ = buildScriptPackage(package: package) + } + return (true, newName) + } func isExist(packageName: String) -> Bool { diff --git a/Shared/ScriptWidgetRuntime/Common/ScriptMetadata.swift b/Shared/ScriptWidgetRuntime/Common/ScriptMetadata.swift new file mode 100644 index 0000000..7247de1 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Common/ScriptMetadata.swift @@ -0,0 +1,87 @@ +// +// ScriptMetadata.swift +// ScriptWidget +// +// Template metadata loaded from meta.json inside a script package. +// + +import Foundation +import SwiftUI + +struct ScriptMetadata: Codable, Equatable { + var description: String? + var category: String? + var tags: [String]? + var difficulty: String? + var icon: String? + var preview: String? + var featured: Bool? + + static let empty = ScriptMetadata() +} + +enum ScriptCategory: String, CaseIterable, Identifiable { + case starter + case time + case weather + case system + case health + case finance + case productivity + case fun + + var id: String { rawValue } + + var displayName: String { + switch self { + case .starter: return "Starter" + case .time: return "Time & Date" + case .weather: return "Weather" + case .system: return "System" + case .health: return "Health" + case .finance: return "Finance" + case .productivity: return "Productivity" + case .fun: return "Fun" + } + } + + var systemImage: String { + switch self { + case .starter: return "square.dashed" + case .time: return "clock.fill" + case .weather: return "cloud.sun.fill" + case .system: return "cpu.fill" + case .health: return "heart.fill" + case .finance: return "chart.line.uptrend.xyaxis" + case .productivity: return "checkmark.circle.fill" + case .fun: return "gamecontroller.fill" + } + } + + var accentColor: Color { + switch self { + case .starter: return .gray + case .time: return .blue + case .weather: return .cyan + case .system: return .indigo + case .health: return .pink + case .finance: return .green + case .productivity: return .orange + case .fun: return .purple + } + } +} + +enum ScriptDifficulty: String, CaseIterable { + case beginner + case medium + case advanced + + var displayName: String { + switch self { + case .beginner: return "Beginner" + case .medium: return "Intermediate" + case .advanced: return "Advanced" + } + } +} diff --git a/Shared/ScriptWidgetRuntime/Common/ScriptModel.swift b/Shared/ScriptWidgetRuntime/Common/ScriptModel.swift index 66508f2..ed35563 100644 --- a/Shared/ScriptWidgetRuntime/Common/ScriptModel.swift +++ b/Shared/ScriptWidgetRuntime/Common/ScriptModel.swift @@ -8,25 +8,53 @@ import SwiftUI struct ScriptModel : Identifiable { - + let id = UUID() let package: ScriptWidgetPackage - + let metadata: ScriptMetadata? + init(package: ScriptWidgetPackage) { self.package = package + self.metadata = package.readMetadata() } - + var name: String { get { self.package.name } } - + var exportFileName: String { get { "\(self.package.name).swt" } } + + var summary: String? { + metadata?.description + } + + var category: ScriptCategory? { + guard let raw = metadata?.category else { return nil } + return ScriptCategory(rawValue: raw) + } + + var tags: [String] { + metadata?.tags ?? [] + } + + var difficulty: ScriptDifficulty? { + guard let raw = metadata?.difficulty else { return nil } + return ScriptDifficulty(rawValue: raw) + } + + var iconSystemName: String { + metadata?.icon ?? category?.systemImage ?? "doc.text.fill" + } + + var isFeatured: Bool { + metadata?.featured ?? false + } } diff --git a/Shared/ScriptWidgetRuntime/Common/ScriptWidgetPackage.swift b/Shared/ScriptWidgetRuntime/Common/ScriptWidgetPackage.swift index 1251ff2..1d13056 100644 --- a/Shared/ScriptWidgetRuntime/Common/ScriptWidgetPackage.swift +++ b/Shared/ScriptWidgetRuntime/Common/ScriptWidgetPackage.swift @@ -30,15 +30,30 @@ struct ScriptWidgetPackage { let name: String let jsxPath: URL let imagePath: URL + let metaPath: URL let readonly: Bool - + init(path: URL, readonly: Bool) { self.readonly = readonly self.path = path self.jsxPath = self.path.appendingPathComponent("main.jsx") self.imagePath = self.path.appendingPathComponent("image") + self.metaPath = self.path.appendingPathComponent("meta.json") self.name = self.path.lastPathComponent } + + func readMetadata() -> ScriptMetadata? { + guard FileManager.default.fileExists(atPath: metaPath.path) else { return nil } + guard let data = try? Data(contentsOf: metaPath) else { return nil } + return try? JSONDecoder().decode(ScriptMetadata.self, from: data) + } + + func previewImageURL() -> URL? { + let meta = readMetadata() + let name = meta?.preview ?? "preview.png" + let url = self.path.appendingPathComponent(name) + return FileManager.default.fileExists(atPath: url.path) ? url : nil + } // readwrite init(path: URL) { diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Air Quality Now/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Air Quality Now/meta.json new file mode 100644 index 0000000..c4ba183 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Air Quality Now/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Current air quality index with a color-coded badge.", + "category": "weather", + "tags": ["aqi","air","network"], + "difficulty": "medium", + "icon": "aqi.medium" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/An Empty Widget/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/An Empty Widget/meta.json new file mode 100644 index 0000000..eb3a406 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/An Empty Widget/meta.json @@ -0,0 +1,8 @@ +{ + "description": "A blank widget to start from scratch — just a hello text.", + "category": "starter", + "tags": ["blank","hello"], + "difficulty": "beginner", + "icon": "square.dashed", + "featured": true +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Animation Aquarium/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Animation Aquarium/meta.json new file mode 100644 index 0000000..5407a72 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Animation Aquarium/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Animated fish swimming across your home screen.", + "category": "fun", + "tags": ["animation","fun"], + "difficulty": "advanced", + "icon": "fish.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Animation Clock/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Animation Clock/meta.json new file mode 100644 index 0000000..a24a58e --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Animation Clock/meta.json @@ -0,0 +1,7 @@ +{ + "description": "A smooth animated analog clock.", + "category": "fun", + "tags": ["animation","clock"], + "difficulty": "advanced", + "icon": "clock.badge.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Battery & Brightness/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Battery & Brightness/meta.json new file mode 100644 index 0000000..bc84d3b --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Battery & Brightness/meta.json @@ -0,0 +1,8 @@ +{ + "description": "Show battery level and screen brightness side by side.", + "category": "system", + "tags": ["battery","brightness","system"], + "difficulty": "beginner", + "icon": "battery.100", + "featured": true +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Check Is Friday Today/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Check Is Friday Today/meta.json new file mode 100644 index 0000000..b7d6af6 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Check Is Friday Today/meta.json @@ -0,0 +1,7 @@ +{ + "description": "A joyful 'Is it Friday?' sign.", + "category": "productivity", + "tags": ["fun","weekday"], + "difficulty": "beginner", + "icon": "face.smiling" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Check Is Working Day Today/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Check Is Working Day Today/meta.json new file mode 100644 index 0000000..5c694ae --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Check Is Working Day Today/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Show whether today is a working day.", + "category": "productivity", + "tags": ["workday","weekday"], + "difficulty": "beginner", + "icon": "briefcase.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Condition Content/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Condition Content/meta.json new file mode 100644 index 0000000..e86800f --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Condition Content/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Swap content based on a condition (if / else).", + "category": "starter", + "tags": ["basics","logic"], + "difficulty": "beginner", + "icon": "arrow.triangle.branch" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Countdown/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Countdown/meta.json new file mode 100644 index 0000000..1ed7c40 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Countdown/meta.json @@ -0,0 +1,8 @@ +{ + "description": "Count down to any future date — birthdays, deadlines, trips.", + "category": "productivity", + "tags": ["countdown","date"], + "difficulty": "beginner", + "icon": "hourglass", + "featured": true +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Crypto Price Ticker/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Crypto Price Ticker/meta.json new file mode 100644 index 0000000..59c276b --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Crypto Price Ticker/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Live crypto prices with 24h change percentage.", + "category": "finance", + "tags": ["crypto","price","network"], + "difficulty": "medium", + "icon": "bitcoinsign.circle.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Currency Pulse/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Currency Pulse/meta.json new file mode 100644 index 0000000..bdeba49 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Currency Pulse/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Exchange rates between fiat currencies.", + "category": "finance", + "tags": ["currency","forex","network"], + "difficulty": "medium", + "icon": "dollarsign.circle.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Daily Quote/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Daily Quote/meta.json new file mode 100644 index 0000000..9663c30 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Daily Quote/meta.json @@ -0,0 +1,7 @@ +{ + "description": "A rotating inspirational quote of the day.", + "category": "productivity", + "tags": ["quote","inspiration"], + "difficulty": "beginner", + "icon": "quote.bubble.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Datetime Current/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Datetime Current/meta.json new file mode 100644 index 0000000..acd49f2 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Datetime Current/meta.json @@ -0,0 +1,8 @@ +{ + "description": "Show the current date and time, updated automatically.", + "category": "time", + "tags": ["time","clock"], + "difficulty": "beginner", + "icon": "clock", + "featured": true +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Datetime Timezone/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Datetime Timezone/meta.json new file mode 100644 index 0000000..3eeba76 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Datetime Timezone/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Display the current time in a specific timezone.", + "category": "time", + "tags": ["time","timezone"], + "difficulty": "medium", + "icon": "globe" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Device Battery Percent/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Device Battery Percent/meta.json new file mode 100644 index 0000000..ae0adc8 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Device Battery Percent/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Just the battery percent — big and readable.", + "category": "system", + "tags": ["battery","system"], + "difficulty": "beginner", + "icon": "battery.75" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Focus Countdown/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Focus Countdown/meta.json new file mode 100644 index 0000000..4718789 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Focus Countdown/meta.json @@ -0,0 +1,7 @@ +{ + "description": "A Pomodoro-style focus timer widget.", + "category": "productivity", + "tags": ["pomodoro","focus"], + "difficulty": "medium", + "icon": "timer" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Gauge Battery/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Gauge Battery/meta.json new file mode 100644 index 0000000..01144c2 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Gauge Battery/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Circular gauge visualizing the current battery level.", + "category": "system", + "tags": ["battery","gauge"], + "difficulty": "beginner", + "icon": "gauge.medium" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/GitHub Repo Stats/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/GitHub Repo Stats/meta.json new file mode 100644 index 0000000..72ee254 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/GitHub Repo Stats/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Stars, forks and issues for any GitHub repository.", + "category": "productivity", + "tags": ["github","stats","api"], + "difficulty": "medium", + "icon": "chevron.left.forwardslash.chevron.right" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Habit Streak Tracker/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Habit Streak Tracker/meta.json new file mode 100644 index 0000000..8217854 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Habit Streak Tracker/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Track your daily habit streak with a visual chain.", + "category": "productivity", + "tags": ["habit","streak"], + "difficulty": "medium", + "icon": "flame.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Health Steps Ring/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Health Steps Ring/meta.json new file mode 100644 index 0000000..cc270c4 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Health Steps Ring/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Daily step count as a ring — pulls from HealthKit.", + "category": "health", + "tags": ["health","steps","healthkit"], + "difficulty": "medium", + "icon": "figure.walk" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Image Basic Usage/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Image Basic Usage/meta.json new file mode 100644 index 0000000..91cc564 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Image Basic Usage/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Learn how to load and display a packaged image asset.", + "category": "starter", + "tags": ["image","basics"], + "difficulty": "beginner", + "icon": "photo.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Image No Margin/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Image No Margin/meta.json new file mode 100644 index 0000000..2abbc62 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Image No Margin/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Fill the whole widget area with an image — no margins.", + "category": "starter", + "tags": ["image","layout"], + "difficulty": "beginner", + "icon": "rectangle.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Image/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Image/meta.json new file mode 100644 index 0000000..c6d230f --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Image/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Render an image with custom size and corner radius.", + "category": "starter", + "tags": ["image","basics"], + "difficulty": "beginner", + "icon": "photo" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Live Activity Demo/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Live Activity Demo/meta.json new file mode 100644 index 0000000..4c80ecf --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Live Activity Demo/meta.json @@ -0,0 +1,7 @@ +{ + "description": "A demo of Live Activity / Dynamic Island rendering.", + "category": "fun", + "tags": ["live-activity","dynamic-island"], + "difficulty": "advanced", + "icon": "sparkles" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Local Weather (Location)/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Local Weather (Location)/meta.json new file mode 100644 index 0000000..b27f571 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Local Weather (Location)/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Use your current location to fetch local weather.", + "category": "weather", + "tags": ["weather","location"], + "difficulty": "medium", + "icon": "location.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Location Snapshot/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Location Snapshot/meta.json new file mode 100644 index 0000000..8cc24cf --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Location Snapshot/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Display current GPS coordinates and a simple map preview.", + "category": "weather", + "tags": ["location","gps"], + "difficulty": "medium", + "icon": "map.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Lunar Date/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Lunar Date/meta.json new file mode 100644 index 0000000..5b77e8b --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Lunar Date/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Show the current Chinese lunar date alongside the Gregorian date.", + "category": "time", + "tags": ["lunar","chinese","date"], + "difficulty": "medium", + "icon": "moon.stars.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Meeting Countdown/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Meeting Countdown/meta.json new file mode 100644 index 0000000..2d07825 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Meeting Countdown/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Show time until the next meeting from your calendar.", + "category": "productivity", + "tags": ["calendar","meeting"], + "difficulty": "medium", + "icon": "person.2.wave.2.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/New Episode Tracker/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/New Episode Tracker/meta.json new file mode 100644 index 0000000..6e39adc --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/New Episode Tracker/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Countdown to your show's next episode.", + "category": "productivity", + "tags": ["tv","countdown"], + "difficulty": "medium", + "icon": "tv.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Nyan Cat/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Nyan Cat/meta.json new file mode 100644 index 0000000..3ee2152 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Nyan Cat/meta.json @@ -0,0 +1,7 @@ +{ + "description": "The classic Nyan Cat, now on your widget.", + "category": "fun", + "tags": ["animation","gif"], + "difficulty": "advanced", + "icon": "pawprint.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Open Link/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Open Link/meta.json new file mode 100644 index 0000000..5649d56 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Open Link/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Open a URL when the widget is tapped.", + "category": "starter", + "tags": ["link","tap"], + "difficulty": "beginner", + "icon": "link" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Shape/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Shape/meta.json new file mode 100644 index 0000000..0f20697 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Shape/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Draw rounded rectangles, circles and capsules with gradients.", + "category": "starter", + "tags": ["shape","drawing"], + "difficulty": "beginner", + "icon": "square.on.circle" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Stock Snapshot/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Stock Snapshot/meta.json new file mode 100644 index 0000000..da14fb3 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Stock Snapshot/meta.json @@ -0,0 +1,7 @@ +{ + "description": "A single stock symbol with price and change.", + "category": "finance", + "tags": ["stock","price","network"], + "difficulty": "medium", + "icon": "chart.line.uptrend.xyaxis" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Storage Ring/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Storage Ring/meta.json new file mode 100644 index 0000000..0c760cb --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Storage Ring/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Free / used storage as a progress ring.", + "category": "system", + "tags": ["storage","ring"], + "difficulty": "medium", + "icon": "internaldrive.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Sunrise & Sunset/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Sunrise & Sunset/meta.json new file mode 100644 index 0000000..46ad8a0 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Sunrise & Sunset/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Today's sunrise and sunset times for your location.", + "category": "weather", + "tags": ["sun","location"], + "difficulty": "medium", + "icon": "sun.horizon.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/System Insights/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/System Insights/meta.json new file mode 100644 index 0000000..4f3e109 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/System Insights/meta.json @@ -0,0 +1,7 @@ +{ + "description": "A dashboard of system stats: battery, storage, memory.", + "category": "system", + "tags": ["system","dashboard"], + "difficulty": "medium", + "icon": "cpu.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/System Status Panel/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/System Status Panel/meta.json new file mode 100644 index 0000000..bfc89f6 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/System Status Panel/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Multi-row system status panel widget.", + "category": "system", + "tags": ["system","panel"], + "difficulty": "medium", + "icon": "square.grid.2x2.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Days To End Of Month/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Days To End Of Month/meta.json new file mode 100644 index 0000000..579a13a --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Days To End Of Month/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Count remaining days until the end of the current month.", + "category": "time", + "tags": ["countdown","days"], + "difficulty": "beginner", + "icon": "calendar.badge.clock" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Days to End Of Year/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Days to End Of Year/meta.json new file mode 100644 index 0000000..fe07a92 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Days to End Of Year/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Count remaining days until the end of the year.", + "category": "time", + "tags": ["countdown","days"], + "difficulty": "beginner", + "icon": "calendar" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Today Week/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Today Week/meta.json new file mode 100644 index 0000000..11008d6 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Today Week/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Show today's weekday name in a friendly format.", + "category": "time", + "tags": ["weekday","date"], + "difficulty": "beginner", + "icon": "calendar.day.timeline.left" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Year Days Left/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Year Days Left/meta.json new file mode 100644 index 0000000..511242e --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Text Year Days Left/meta.json @@ -0,0 +1,7 @@ +{ + "description": "A big-number widget for days left in the year.", + "category": "time", + "tags": ["countdown","year"], + "difficulty": "beginner", + "icon": "number" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Weather Display/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Weather Display/meta.json new file mode 100644 index 0000000..70c3b54 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Weather Display/meta.json @@ -0,0 +1,7 @@ +{ + "description": "Weather layout showcase with high / low temperature.", + "category": "weather", + "tags": ["weather","layout"], + "difficulty": "beginner", + "icon": "cloud.rain.fill" +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Weather Now (Open-Meteo)/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Weather Now (Open-Meteo)/meta.json new file mode 100644 index 0000000..4b3e2c7 --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Weather Now (Open-Meteo)/meta.json @@ -0,0 +1,8 @@ +{ + "description": "Live weather via Open-Meteo — no API key required.", + "category": "weather", + "tags": ["weather","network","api"], + "difficulty": "medium", + "icon": "cloud.bolt.rain.fill", + "featured": true +} diff --git a/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Weather/meta.json b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Weather/meta.json new file mode 100644 index 0000000..bc56caf --- /dev/null +++ b/Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/Weather/meta.json @@ -0,0 +1,7 @@ +{ + "description": "A classic weather widget with icon, temperature and condition.", + "category": "weather", + "tags": ["weather","temperature"], + "difficulty": "beginner", + "icon": "cloud.sun.fill" +} diff --git a/docs/ai-generate.md b/docs/ai-generate.md new file mode 100644 index 0000000..7d95a60 --- /dev/null +++ b/docs/ai-generate.md @@ -0,0 +1,469 @@ +# AI Generate — 设计文档 + +在 ScriptWidget 中引入 "AI 生成 Widget" 能力:用户在设置里配置 OpenAI (或兼容端点) 的 API key,然后在新建 Widget 流程里输入一段自然语言 prompt,由 LLM 生成 JSX 代码,并在本机 runtime 中自动"跑—看错—修"直至通过,最后进入审阅+预览态,由用户确认落盘。 + +本设计为 `feature/ai-generate` 分支的实施依据。实现阶段按第 9 节里程碑推进。 + +--- + +## 1. 目标 + +- 让不会写 JSX/JS 的用户,用一段描述就能得到一个可运行的 Widget。 +- 生成出的 Widget 必须**真的能跑**,不是"看起来像代码"。通过 runtime 侧自动执行 + 错误回灌的 agent loop 保证。 +- 用户体验接近 Claude Code / Codex:能看到迭代进度,能中断,跑完能审阅修改再保存。 + +### 非目标(本期不做) + +- 多轮自由聊天 / 聊天历史。用户在已生成的 widget 基础上再下一句"优化 prompt"即可,不是多轮。 +- Widget extension 二进制里调 LLM。AI 调用仅发生在主 app 进程。 +- 生成图片素材 / DALL·E。只生成 JSX 代码。 +- Streaming UI 打字效果。见 §8,列为第二期。 + +--- + +## 2. 决策摘要 + +| 项 | 决定 | +|---|---| +| 存储 | `UserDefaults(suiteName: "group.everettjf.scriptwidget")`(第一期)。Keychain 迁移留作后续 | +| OpenAI 客户端 | [SwiftOpenAI](https://github.com/jamesrochabrun/SwiftOpenAI) | +| 默认模型 | `gpt-4o-mini`,用户可自填任意 SwiftOpenAI 支持的 model id | +| 默认 base URL | `https://api.openai.com/v1`,用户可自填(兼容 Azure / DeepSeek / 本地 vLLM 等) | +| 默认迭代上限 | 20,用户可设 30 / 40 / 50 | +| 交互形态 | 一次性 prompt → agent loop → 审阅+预览 → 用户点确认落盘 | +| 优化 | 已生成的 widget 上,用户可追加一段 prompt 触发新一轮 agent loop(单轮单次,不留聊天历史) | +| Agent UI | 进度条 + 当前阶段文本 + 错误日志折叠面板。不做流式打字 | +| Agent 循环位置 | 仅主 app,iOS/macOS 双端。不进 widget/share extension | + +--- + +## 3. 总体架构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ SwiftUI │ +│ ┌──────────────┐ ┌───────────────────────┐ ┌─────────────┐ │ +│ │ SettingAIView│ │ AIGenerateView │ │ AIReviewView│ │ +│ │ (config) │──▶│ (prompt + progress) │──▶│ (preview + │ │ +│ │ │ │ │ │ confirm / │ │ +│ │ │ │ │ │ refine) │ │ +│ └──────────────┘ └───────────┬───────────┘ └─────────────┘ │ +│ │ │ +└─────────────────────────────────┼────────────────────────────────┘ + │ + ┌──────────────────────────▼─────────────────────────┐ + │ AIGenerateSession (@MainActor ObservableObject) │ + │ - phase / iteration / logs / currentJSX │ + │ - start(prompt) / refine(prompt) / cancel() │ + └───┬─────────────────────┬──────────────────────────┘ + │ │ + ▼ ▼ + ┌────────────────┐ ┌───────────────────────┐ + │ AIClient │ │ AgentLoop │ + │ (SwiftOpenAI) │◀────▶│ plan→gen→run→fix │ + │ baseURL/key/ │ │ terminates on: │ + │ model/usage │ │ pass / max iter / │ + └────────────────┘ │ cancel │ + └──────────┬────────────┘ + │ runs JSX via + ▼ + ┌────────────────────────────┐ + │ ScriptWidgetRuntime │ + │ (existing JavaScriptCore) │ + │ → element? / error? / │ + │ console logs │ + └────────────────────────────┘ +``` + +关键原则: +- **AI 业务逻辑放在 `Shared/ScriptWidgetRuntime/AI/`**,iOS/macOS 共享。 +- **runtime 不动**,当作沙箱直接复用。AI 侧只消费它的输出。 +- SwiftUI view 各端一份(iOS Form 风格;macOS GroupBox 风格,和现有 `SettingsView` 一致)。 + +--- + +## 4. 详细设计 + +### 4.1 配置存储 + +一组 key,挂在 app group `UserDefaults` 上,以便将来 extension 读写元数据(虽然当前 extension 不调 LLM)。 + +```swift +enum AISettingsKey { + static let apiKey = "ai.apiKey" // String + static let baseURL = "ai.baseURL" // String, default "https://api.openai.com/v1" + static let model = "ai.model" // String, default "gpt-4o-mini" + static let maxIterations = "ai.maxIterations" // Int, default 20 + static let temperature = "ai.temperature" // Double, default 0.7 +} +``` + +- 默认值集中在 `AISettings.default` 静态常量里。 +- `AISettings.isConfigured: Bool { !apiKey.isEmpty }`。 +- **注意**:第一期 UserDefaults 明文存储,**在 UI 上显式告知用户**"key 明文存在本机,请勿在共享设备上配置"。TODO: 下一期迁 Keychain。 + +### 4.2 AI 设置页 `SettingAIView` + +挂在 `SettingsView` 新的 `GroupBox(label: SettingsLabelView(title: "AI", image: "sparkles"))` 下,用 `NavigationLink` 跳转。字段: + +- API Key(`SecureField`,带"显示/隐藏"眼睛) +- Base URL(`TextField`,默认占位符展示官方地址) +- Model(`TextField` + 快捷按钮:`gpt-4o-mini` / `gpt-4o` / `gpt-4.1-mini` / 清空) +- Max Iterations(`Stepper`,范围 5...100,默认 20) +- Temperature(`Slider`,0.0...1.5,默认 0.7) +- "Test Connection" 按钮:发一条最小 chat("ping"),成功绿勾、失败错误信息 +- 一段风险说明文字(key 明文存储) + +### 4.3 生成入口与审阅预览页 + +**入口**:在 `CreateGuideView` 的列表最上方插入一条独立行 "✨ Generate with AI"。 + +- 若 `AISettings.isConfigured == false`:点击弹窗"请先到设置 → AI 配置 API Key",带一个按钮直接跳到 `SettingAIView`。 +- 已配置:push `AIGenerateView`。 + +**`AIGenerateView`**(纯输入态): + +- 多行 `TextEditor`(prompt,至少 4 行可见),placeholder "描述一下你想要的 widget(例如:显示当前天气和三天预报,深色背景)"。 +- Widget Size Picker(small / medium / large / extraLarge / accessoryCircular / accessoryRectangular / accessoryInline),默认 `medium`。 +- 主按钮 "Generate"。 +- 下方嵌一个 `AIGenerateProgressView`(见 §4.8),未开始时隐藏。 + +**`AIReviewView`**(agent loop 结束 + 成功时 push 过来): + +- 上半:复用现有 preview 机制,用生成出来的 JSX 建一个 **临时 package**(见下方"临时 package"设计),走 `ScriptWidgetElementView` 渲染,就是现在 `ScriptCodePreviewView` 那个 widget 预览块。 +- 中间:折叠的 Code 查看器(复用现有 CodeMirror 或 `MirrorEditorScriptView`)—— 只读,避免用户在此编辑后状态混乱;要编辑请先点"Save"进入正式编辑态。 +- 下半: + - "Refine" 区:一个 TextField + "Refine" 按钮 → 回到 `AIGenerateView` 的进度流程,但这次初始 JSX 是上一轮的代码,用户 prompt 是"在原有基础上 …"(见 §4.5 refine prompt)。 + - "Save Widget" 主按钮 → 调用 `sharedScriptManager.createScript(content:, recommendPackageName: "AI Generated ", imageCopyPath: nil)`,随后走和 `CreateGuideView` 相同的 dismiss + `ScriptWidgetHomeViewDataObject.scriptCreateNotification` 通知逻辑。 + - "Discard" 次级按钮 → 返回上一页。 +- 顶部 navbar "Logs" 按钮 → 展示本次 agent loop 的完整迭代历史(每轮的错误 + 修改点概述),只读。 + +**临时 package**:AI 跑 JSX 需要一个 `ScriptWidgetPackage` 路径(runtime 强依赖 package 做 `$import / $file` 支持)。用 `NSTemporaryDirectory()/ScriptWidgetAI//` 建一次性目录,只放 `main.jsx`。审阅页完成或取消时清理。 + +### 4.4 AI 服务层 `AIClient` + +薄封装 SwiftOpenAI。位置 `Shared/ScriptWidgetRuntime/AI/AIClient.swift`。 + +```swift +actor AIClient { + struct Config { + let apiKey: String + let baseURL: URL + let model: String + let temperature: Double + } + + struct Message { let role: Role; let content: String } + enum Role: String { case system, user, assistant } + + struct Response { + let content: String + let promptTokens: Int + let completionTokens: Int + } + + func chat(messages: [Message], config: Config) async throws -> Response +} +``` + +- 内部根据 `config` 构造 SwiftOpenAI 的 `OpenAIService`(支持自定义 baseURL)。 +- 不做流式(第一期)。失败直接抛错,由 `AgentLoop` 捕获。 +- 超时(建议 60s / 次)单独在 config 里预留参数(先硬编码 60s)。 + +### 4.5 Prompt 构造 + +位置 `Shared/ScriptWidgetRuntime/AI/PromptBuilder.swift`。 + +**System Prompt** 组成(顺序): + +1. **角色与铁律**(硬编码、稳定): + ``` + You are a ScriptWidget code generator. Output ONLY a single JSX snippet + that calls $render(...) exactly once. No markdown fences, no explanations. + + RULES: + 1. Must call $render(<...>) exactly once. Root must be a layout container + (vstack / hstack / zstack). + 2. Do NOT use `import`, `require`, `module`, Node APIs, or DOM APIs. + 3. Networking is only via `fetch(url)` (returns string) or `$http.*`. + 4. Top-level `await` is allowed; the runtime wraps code in async $main. + 5. Time/date: use the globally injected `moment` or JS Date. + 6. Persistent data: use `$storage.set(key, value)` / `$storage.get(key)`. + 7. Only use tags and APIs listed in the REFERENCE section below. + 8. Keep the widget visually dense but readable for the given size. + 9. When using `fetch`, handle errors so the widget still renders. + ``` + +2. **REFERENCE 段**(动态拼接): + - 启动时读 `Script.bundle/component/*/main.jsx` 和 `Script.bundle/api/*/main.jsx`,每个文件截取首 40 行,前面加 `// === ===`。整份塞进 system。 + - 这样之后新增组件/新增 API 无需改 prompt,AI 自动知道。 + - 体积控制:若总长超过 ~60K chars,按优先级裁剪(component 全保留,api 保留常用 10 个:fetch / http / storage / location / health / device / file / getenv / system / console)。 + +3. **SIZE HINT**:告诉 AI 当前目标 size 的像素范围和设计建议(e.g., `accessoryCircular` 必须极简,`large` 可放多列等)。 + +**User Prompt(首轮)**: + +``` +Widget size: {size} +User description: +{user_prompt} +``` + +**User Prompt(第 N>1 轮,修错)**: + +``` +Your previous code: +```jsx +{last_code} +``` +It failed to run. Runtime feedback: +- Error type: {errorCase} +- Error detail: {errorDetail} +- Last console lines: +{last_10_log_lines} + +Fix the code. Return the FULL corrected JSX only. +``` + +**User Prompt(Refine)**: + +``` +Current working code: +```jsx +{current_code} +``` +Apply this change request from the user: +{refine_prompt} + +Return the FULL updated JSX only. +``` + +**后处理剥壳**:即便明确说了不要 markdown,仍写一个 `stripCodeFences(_ raw: String) -> String`,容错处理 `` ```jsx `` / `` ``` `` 围栏、以及前后解释文字(找到第一个 `<` 到最后一个 `);` 的片段作为兜底)。 + +### 4.6 Agent 自调试循环 + +位置 `Shared/ScriptWidgetRuntime/AI/AgentLoop.swift`。纯逻辑,返回流式结果给 `AIGenerateSession`。 + +**算法**: + +``` +fun run(userPrompt, size, initialCode?): + lastCode = initialCode + for i in 1...maxIterations: + emit(.thinking(i)) + + if lastCode == nil: + messages = [system, user_first(userPrompt, size)] + else if initialCode != nil and i == 1: + messages = [system, user_refine(lastCode, userPrompt)] + else: + messages = [system, user_first(userPrompt, size), + assistant(lastCode), + user_fix(lastCode, lastError, lastLogs)] + + jsx = stripCodeFences(await aiClient.chat(messages)) + lastCode = jsx + emit(.running(i, jsx)) + + (element, err, logs) = runtime.execute(jsx) + if success(element, err, logs): + emit(.done(jsx, element)) + return .success(jsx) + else: + lastError = err + lastLogs = logs + emit(.fixing(i, err)) + if cancelled: return .cancelled + continue + + return .exhausted(lastCode, lastError) +``` + +**成功判定** `success(element, err, logs)`: +- `err == nil` +- `element != nil` +- element 的 tag 不是 fallback (`"#UI Not Found#"` / `"#Loading#"` / `"#Failed#"`) +- logs 里不存在以 `[error]` 开头的条目(可调:`console.error` 调用会被记录) + +**终止路径**: +- 成功:返回 JSX,UI 跳 `AIReviewView`。 +- 达到 `maxIterations`:把最后一版代码和最后的错误信息一起交给用户,UI 上明示 "Did not converge, showing the last attempt",仍允许用户点进 Review 手动修。 +- 取消:回到输入态,保留 prompt。 + +**上下文成本控制**:每轮构造 messages 时只保留 `[system, firstUser, lastAssistant, lastUserFix]`,**不累积**所有历史。这既省 token 又让模型聚焦当前错误。 + +### 4.7 运行时沙箱 + +直接调 `ScriptWidgetRuntime.executeJSXSyncForWidget`。但有两个点要处理: + +1. **阻塞 → 异步**:现有方法是 `DispatchSemaphore` 同步。agent loop 跑在非主线程没问题,但应该包一层: + + ```swift + func runJSX(_ jsx: String, in package: ScriptWidgetPackage, size: String) + async -> (ScriptWidgetRuntimeElement?, ScriptWidgetError?, [String]) + ``` + + 内部 `await withCheckedContinuation { DispatchQueue.global().async { ... } }`。 + +2. **日志采集**:执行前先 `sharedRunningState = ScriptWidgetRunningState(package: tempPackage)`,执行后读 `sharedRunningState.logger.logs`。这与 `ScriptCodePreviewDataObject` 做法一致。 + - 风险:`sharedRunningState` 是全局 var,和主 app 同时运行的 preview 会撞。实现时加一个 serial queue 保证 AI 执行与 preview 执行互斥;或在 runtime 内部改为每次新建 `ScriptWidgetRunningState` 传入 runtime 实例(更正经,但动了 runtime,放第二期)。**第一期用互斥 queue**。 + +### 4.8 状态机 / 进度展示 + +`AIGenerateSession` 是 `@MainActor ObservableObject`。 + +```swift +enum AIGeneratePhase: Equatable { + case idle + case thinking(iteration: Int) // 在等 LLM + case running(iteration: Int) // 在跑 JSX + case fixing(iteration: Int, error: String) + case done(jsx: String) + case exhausted(lastJSX: String?, lastError: String?) + case failed(String) // 网络 / API 错误等非 agent 循环错 + case cancelled +} + +@Published var phase: AIGeneratePhase = .idle +@Published var iterationHistory: [IterationRecord] = [] +@Published var usage: TokenUsage = .zero // 累计 tokens,仅展示 +``` + +UI(`AIGenerateProgressView`): +- 顶部:小 `ProgressView` + 文本 `"Iteration 3 / 20 — running code…"`。 +- 中部:最新错误 `Text` (一行,截断);点开后展开整条。 +- 底部:`Cancel` 按钮。 +- 历史折叠:`DisclosureGroup("History")` 展示每轮的 role/status/error 一行摘要。 + +**进度条**:用 `ProgressView(value: Double(iteration), total: Double(maxIterations))`,虽然实际 iteration 不可预测,但能给用户"大概还能继续多少次"的感知。 + +### 4.9 优化(Refine)交互 + +在 `AIReviewView` 底部: + +- 输入框:"Ask AI to change it (e.g. 'make background darker, show icon on the left')" +- 按钮 "Refine" → 关闭 review,重新 push `AIGenerateView`(或直接原地覆盖),以 `initialCode = currentJSX` 和 `refinePrompt` 为输入启动 agent loop。 +- 完成后再回到 review,循环。 + +注意:Refine 不保留多轮历史,每次 refine 都是"基于当前 code + 本次 prompt"的独立请求。这是为了成本可控,也符合你"不需要多轮聊天"的决策。 + +--- + +## 5. 文件改动清单(实施时的 checklist) + +**新增**: + +``` +Shared/ScriptWidgetRuntime/AI/ +├── AISettings.swift // UserDefaults 封装 + default values +├── AIClient.swift // SwiftOpenAI 封装 (actor) +├── PromptBuilder.swift // system/user prompt 拼接 + stripCodeFences +├── AgentLoop.swift // 核心循环 +├── AgentRuntimeBridge.swift // runJSX(...) async + 互斥 queue + 日志采集 +├── AIGenerateSession.swift // @MainActor ObservableObject +└── AIReferenceSnapshot.swift // 启动时读 Script.bundle 构造 REFERENCE 段(带缓存) + +iOS/ScriptWidget/App/Settings/ +└── SettingAIView.swift // 新设置页 + +iOS/ScriptWidget/View/AIGenerate/ +├── AIGenerateView.swift // prompt 输入 + progress +├── AIGenerateProgressView.swift // 进度条 + 阶段文本 + 历史 +└── AIReviewView.swift // preview + refine + save + +macOS/ScriptWidgetMac/... // 三件与 iOS 对应的 macOS 版本 +(若 view 可完全跨平台,优先跨端复用;不能则分开写,行为一致) +``` + +**修改**: + +``` +iOS/ScriptWidget/App/Settings/SettingsView.swift + - 加一个 GroupBox "AI" → NavigationLink(SettingAIView) + +iOS/ScriptWidget/App/Scripts/CreateGuideView.swift + - 列表顶部插一行 "✨ Generate with AI" + - 未配置时引导到 SettingAIView;已配置 push AIGenerateView + +iOS/ScriptWidget.xcodeproj/project.pbxproj + - 添加 XCRemoteSwiftPackageReference: SwiftOpenAI + - 主 app target 链接 SwiftOpenAI;widget/share extension 不链接 + +macOS/ScriptWidgetMac.xcodeproj/project.pbxproj + - 同上 +``` + +**显式不改**: +- `Shared/ScriptWidgetRuntime/Widget/Runtime/ScriptWidgetRuntime.swift` 保持不变。 +- `ScriptCodePreviewView` / `ScriptCodePreviewDataObject` 不动。 + +--- + +## 6. 安全 / 隐私 / 成本 + +- **Key 存储**:第一期 UserDefaults 明文。设置页必须有一段红色/橙色说明文字。TODO comment 写清楚"迁 Keychain"。 +- **网络**:只向用户配置的 `baseURL` 发请求。禁止默认值之外的任何 hardcoded endpoint。 +- **数据最小化**:prompt 只包含用户输入 + 我们的系统提示 + 当轮错误信息。不上传用户已有 widgets、设备标识、位置、健康数据等。 +- **成本可感**: + - 每轮请求后累加 `usage.prompt_tokens / completion_tokens`,在 progress view 展示 "used ~3.2K tokens so far"。 + - iteration 上限是硬性保险。UI 上明示 "cost scales with iterations"。 +- **取消即止**:`AgentLoop` 内每轮入口检查 `Task.isCancelled`,一旦 cancel 立即返回,不再发下一次请求。 +- **错误暴露**:SwiftOpenAI 抛的错(401 / 429 / 超时)直接以可读文本展示;不吞异常。 + +--- + +## 7. 跨平台 + +- 共享层(`Shared/ScriptWidgetRuntime/AI/`)纯 Swift + SwiftUI 无平台耦合。 +- 三个 SwiftUI view 若能用 `#if os(iOS)` / `#if os(macOS)` 在单文件内分支处理,尽量单文件跨端。iOS 用 `Form`/`sheet`/`.navigationBarTitle`,macOS 用 `ScrollView`+`GroupBox`(和现有 `SettingsView` macOS 版本一致)。 +- widget / share extension **不**链接 SwiftOpenAI。在 target membership 上严格限制。 + +--- + +## 8. 本期不做 / 延后 + +1. **流式 UI**(`LanguageModelChatUI` 风格打字效果):实现成本中等,但需要改 `AIClient` 为流式 + UI 实时拼接 + 中断半完成响应。判断:不做流式 UX 已经够用(每轮 2–5 秒),延后到第二期。 +2. **多轮聊天历史**:用户不需要。 +3. **Keychain 存储**:第二期。 +4. **Extension 内 LLM 调用**:不做(内存限制 + 隐私)。 +5. **生成图片 / 素材**:不做。 +6. **本地模型 / on-device**:先不做,用户想用的话自填本地 `baseURL`(例如 `http://127.0.0.1:11434/v1`)即可。 + +--- + +## 9. 里程碑拆分 + +建议按以下顺序合并小 PR,每步可独立验证: + +- **M1 — 配置通路**:`AISettings` + `SettingAIView` + 设置入口 + "Test Connection"。无 AI 生成能力,只验证 key 配置与网络联通。 +- **M2 — 单次生成**:`AIClient` + `PromptBuilder`(含 REFERENCE 构造)+ `AIGenerateView` 的 prompt 输入和"单次调用 LLM + 拿到 JSX + 直接渲染"路径,**不做 agent loop**,失败就失败。用来验证 prompt 质量。 +- **M3 — Agent loop**:`AgentRuntimeBridge` + `AgentLoop` + `AIGenerateSession` + 进度 UI。 +- **M4 — 审阅页**:`AIReviewView`(preview + logs + save)。 +- **M5 — Refine**:在 M4 上加 refine 输入与再循环。 +- **M6 — macOS 平齐**:确保 macOS 三个 view 功能等同。 + +本文档合入作为 M0。 + +--- + +## 10. 开放问题 / 待实施时确认 + +1. REFERENCE 段的裁剪策略:是否允许用户在设置里勾选"精简 / 完整"两档以控制 prompt token 成本?(当前设计:固定按优先级裁剪) +2. 临时 package 目录是否应该落在 app group 而非 `NSTemporaryDirectory()`?若 runtime 某些 API 依赖 `ScriptManager.scriptDirectory`,需要验证一下——实施时写个小 spike 跑通再定。 +3. "审阅页"的 code 查看器要不要允许编辑?当前设计是只读;若允许编辑,则与进入正式编辑态后的行为边界需要再想清楚(覆盖还是分叉)。 +4. 失败但 iteration 未耗尽时,是否给用户"再试一轮"按钮(单独追加一轮而非重头来)?第一期先不做,等实际用起来再加。 + +--- + +## 附录 A:相关源码锚点 + +- 运行时入口:`Shared/ScriptWidgetRuntime/Widget/Runtime/ScriptWidgetRuntime.swift` — `executeJSXSyncForWidget` (line 181) +- 错误类型:同文件 `ScriptWidgetError` (line 12) +- 日志聚合:`ScriptWidgetRunningState.logger.logs`(全局 `sharedRunningState`) +- 模板清单:`Shared/ScriptWidgetRuntime/Resource/Script.bundle/template/*/main.jsx` +- 组件用法:`Shared/ScriptWidgetRuntime/Resource/Script.bundle/component/*/main.jsx` +- API 用法:`Shared/ScriptWidgetRuntime/Resource/Script.bundle/api/*/main.jsx` +- 创建入口:`iOS/ScriptWidget/App/Scripts/CreateGuideView.swift` +- 编辑/预览:`iOS/ScriptWidget/View/CodeEditor/ScriptCodeEditorView.swift`、`.../Preview/ScriptCodePreviewView.swift` +- 设置页:`iOS/ScriptWidget/App/Settings/SettingsView.swift` +- 包落盘:`Shared/ScriptWidgetRuntime/Common/ScriptManager.swift` — `createScript(content:recommendPackageName:imageCopyPath:)` diff --git a/iOS/ScriptWidget.xcodeproj/project.pbxproj b/iOS/ScriptWidget.xcodeproj/project.pbxproj index f08a127..e9214c7 100644 --- a/iOS/ScriptWidget.xcodeproj/project.pbxproj +++ b/iOS/ScriptWidget.xcodeproj/project.pbxproj @@ -156,6 +156,9 @@ F29118F32793034D00B860B0 /* ScriptModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F29118EC2793034D00B860B0 /* ScriptModel.swift */; }; F29118F42793034D00B860B0 /* ScriptModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F29118EC2793034D00B860B0 /* ScriptModel.swift */; }; F29118F62793034D00B860B0 /* ScriptModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F29118EC2793034D00B860B0 /* ScriptModel.swift */; }; + D1A0000000E0000000000002 /* ScriptMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0000000E0000000000001 /* ScriptMetadata.swift */; }; + D1A0000000E0000000000003 /* ScriptMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0000000E0000000000001 /* ScriptMetadata.swift */; }; + D1A0000000E0000000000004 /* ScriptMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0000000E0000000000001 /* ScriptMetadata.swift */; }; F29118FB2793034D00B860B0 /* ScriptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F29118EE2793034D00B860B0 /* ScriptManager.swift */; }; F29118FC2793034D00B860B0 /* ScriptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F29118EE2793034D00B860B0 /* ScriptManager.swift */; }; F29118FE2793034D00B860B0 /* ScriptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F29118EE2793034D00B860B0 /* ScriptManager.swift */; }; @@ -308,6 +311,19 @@ F2FC062F27DC749A00A6A99D /* MirrorEditor.bundle in Resources */ = {isa = PBXBuildFile; fileRef = F2FC062E27DC749A00A6A99D /* MirrorEditor.bundle */; }; F2FE0A3525ED3ABF00B4B6B2 /* EmptyListBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2FE0A3425ED3ABF00B4B6B2 /* EmptyListBackgroundView.swift */; }; F2FE0A3725ED3BFA00B4B6B2 /* ScriptWidgetPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2FE0A3625ED3BFA00B4B6B2 /* ScriptWidgetPlaceholderView.swift */; }; + A102000100000000000000B0 /* AISettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000100000000000000B0 /* AISettings.swift */; }; + A102000200000000000000B0 /* AIReferenceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000200000000000000B0 /* AIReferenceSnapshot.swift */; }; + A102000300000000000000B0 /* PromptBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000300000000000000B0 /* PromptBuilder.swift */; }; + A102000400000000000000B0 /* AIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000400000000000000B0 /* AIClient.swift */; }; + A102000500000000000000B0 /* AgentRuntimeBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000500000000000000B0 /* AgentRuntimeBridge.swift */; }; + A102000600000000000000B0 /* AgentLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000600000000000000B0 /* AgentLoop.swift */; }; + A102000700000000000000B0 /* AIGenerateSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000700000000000000B0 /* AIGenerateSession.swift */; }; + A102000800000000000000B0 /* AIGenerateProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000800000000000000B0 /* AIGenerateProgressView.swift */; }; + A104000100000000000000B0 /* SettingAIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A103000100000000000000B0 /* SettingAIView.swift */; }; + A104000200000000000000B0 /* AIGenerateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A103000200000000000000B0 /* AIGenerateView.swift */; }; + A104000300000000000000B0 /* AIReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A103000300000000000000B0 /* AIReviewView.swift */; }; + A106000300000000000000B0 /* SwiftOpenAI in Frameworks */ = {isa = PBXBuildFile; productRef = A106000200000000000000B0 /* SwiftOpenAI */; }; + A102000900000000000000B0 /* AIExamplePrompts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A101000900000000000000B0 /* AIExamplePrompts.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -475,6 +491,7 @@ F28E474225EA92750080A810 /* SettingsICloudView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsICloudView.swift; sourceTree = ""; }; F28EC0A925EE98150047F1ED /* SettingsGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsGroupView.swift; sourceTree = ""; }; F29118EC2793034D00B860B0 /* ScriptModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptModel.swift; sourceTree = ""; }; + D1A0000000E0000000000001 /* ScriptMetadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptMetadata.swift; sourceTree = ""; }; F29118EE2793034D00B860B0 /* ScriptManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptManager.swift; sourceTree = ""; }; F29119012793035E00B860B0 /* MineGaugeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MineGaugeView.swift; sourceTree = ""; }; F29119022793035E00B860B0 /* ScriptWidgetElementColor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptWidgetElementColor.swift; sourceTree = ""; }; @@ -584,6 +601,18 @@ F2FC062E27DC749A00A6A99D /* MirrorEditor.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = MirrorEditor.bundle; sourceTree = ""; }; F2FE0A3425ED3ABF00B4B6B2 /* EmptyListBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyListBackgroundView.swift; sourceTree = ""; }; F2FE0A3625ED3BFA00B4B6B2 /* ScriptWidgetPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptWidgetPlaceholderView.swift; sourceTree = ""; }; + A101000100000000000000B0 /* AISettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISettings.swift; sourceTree = ""; }; + A101000200000000000000B0 /* AIReferenceSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIReferenceSnapshot.swift; sourceTree = ""; }; + A101000300000000000000B0 /* PromptBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptBuilder.swift; sourceTree = ""; }; + A101000400000000000000B0 /* AIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIClient.swift; sourceTree = ""; }; + A101000500000000000000B0 /* AgentRuntimeBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentRuntimeBridge.swift; sourceTree = ""; }; + A101000600000000000000B0 /* AgentLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentLoop.swift; sourceTree = ""; }; + A101000700000000000000B0 /* AIGenerateSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateSession.swift; sourceTree = ""; }; + A101000800000000000000B0 /* AIGenerateProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateProgressView.swift; sourceTree = ""; }; + A103000100000000000000B0 /* SettingAIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingAIView.swift; sourceTree = ""; }; + A103000200000000000000B0 /* AIGenerateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateView.swift; sourceTree = ""; }; + A103000300000000000000B0 /* AIReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIReviewView.swift; sourceTree = ""; }; + A101000900000000000000B0 /* AIExamplePrompts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIExamplePrompts.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -591,6 +620,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A106000300000000000000B0 /* SwiftOpenAI in Frameworks */, F2997F05274A717A00CD7DD6 /* SwiftUIX in Frameworks */, F2B20D1925D3EAEA00B62DAC /* CloudKit.framework in Frameworks */, 5A912FAA2D2135330050F250 /* ClockHandRotationKit in Frameworks */, @@ -722,6 +752,7 @@ D163031A254C6D62005EEB93 /* View */ = { isa = PBXGroup; children = ( + A105000200000000000000B0 /* AIGenerate */, D1824275254476A200223563 /* CodeEditor */, D1403DA22552FE950076F87C /* PhotoPicker */, F2BC995C25CE452400285C7B /* SettingsLabelView.swift */, @@ -913,6 +944,7 @@ F240AC7F27B2D6AB00D249EA /* Settings */ = { isa = PBXGroup; children = ( + A103000100000000000000B0 /* SettingAIView.swift */, 5AC769482CD370BC0022A138 /* ExportView.swift */, 5AC386D12CDA09760027B976 /* ImportView.swift */, F2C5C5E327AF5B5400797C5B /* AppIconsView.swift */, @@ -955,6 +987,7 @@ F29118E92793033500B860B0 /* ScriptWidgetRuntime */ = { isa = PBXGroup; children = ( + A105000100000000000000B0 /* AI */, F291198E2793039700B860B0 /* Resource */, F29118FF2793035E00B860B0 /* Widget */, F29118EA2793034D00B860B0 /* Common */, @@ -967,6 +1000,7 @@ children = ( F29119922793223300B860B0 /* ScriptWidgetPackage.swift */, F29118EC2793034D00B860B0 /* ScriptModel.swift */, + D1A0000000E0000000000001 /* ScriptMetadata.swift */, F29118EE2793034D00B860B0 /* ScriptManager.swift */, ); name = Common; @@ -1294,6 +1328,32 @@ path = Bridge; sourceTree = ""; }; + A105000100000000000000B0 /* AI */ = { + isa = PBXGroup; + children = ( + A101000100000000000000B0 /* AISettings.swift */, + A101000200000000000000B0 /* AIReferenceSnapshot.swift */, + A101000300000000000000B0 /* PromptBuilder.swift */, + A101000400000000000000B0 /* AIClient.swift */, + A101000500000000000000B0 /* AgentRuntimeBridge.swift */, + A101000600000000000000B0 /* AgentLoop.swift */, + A101000700000000000000B0 /* AIGenerateSession.swift */, + A101000800000000000000B0 /* AIGenerateProgressView.swift */, + A101000900000000000000B0 /* AIExamplePrompts.swift */, + ); + name = AI; + path = ../Shared/ScriptWidgetRuntime/AI; + sourceTree = ""; + }; + A105000200000000000000B0 /* AIGenerate */ = { + isa = PBXGroup; + children = ( + A103000200000000000000B0 /* AIGenerateView.swift */, + A103000300000000000000B0 /* AIReviewView.swift */, + ); + path = AIGenerate; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -1314,6 +1374,7 @@ ); name = ScriptWidget; packageProductDependencies = ( + A106000200000000000000B0 /* SwiftOpenAI */, F245BBEC25B9E5DC00F25F68 /* SDWebImageSwiftUI */, F245BBF725B9E62300F25F68 /* SwiftyJSON */, F2997F04274A717A00CD7DD6 /* SwiftUIX */, @@ -1396,6 +1457,7 @@ ); mainGroup = D18AAC23252A12D10065386A; packageReferences = ( + A106000100000000000000B0 /* XCRemoteSwiftPackageReference "SwiftOpenAI" */, F245BBEB25B9E5DC00F25F68 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */, F245BBF625B9E62300F25F68 /* XCRemoteSwiftPackageReference "SwiftyJSON" */, F2997F03274A717A00CD7DD6 /* XCRemoteSwiftPackageReference "SwiftUIX" */, @@ -1522,6 +1584,18 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A102000900000000000000B0 /* AIExamplePrompts.swift in Sources */, + A102000100000000000000B0 /* AISettings.swift in Sources */, + A102000200000000000000B0 /* AIReferenceSnapshot.swift in Sources */, + A102000300000000000000B0 /* PromptBuilder.swift in Sources */, + A102000400000000000000B0 /* AIClient.swift in Sources */, + A102000500000000000000B0 /* AgentRuntimeBridge.swift in Sources */, + A102000600000000000000B0 /* AgentLoop.swift in Sources */, + A102000700000000000000B0 /* AIGenerateSession.swift in Sources */, + A102000800000000000000B0 /* AIGenerateProgressView.swift in Sources */, + A104000100000000000000B0 /* SettingAIView.swift in Sources */, + A104000200000000000000B0 /* AIGenerateView.swift in Sources */, + A104000300000000000000B0 /* AIReviewView.swift in Sources */, F29119702793035E00B860B0 /* ScriptWidgetElementTagZStack.swift in Sources */, D132336528E08C15002C26A2 /* ScriptLiveActivityManager.swift in Sources */, F2B20CC425D2EF5E00B62DAC /* NameAutoImageView.swift in Sources */, @@ -1532,6 +1606,7 @@ F25ED68F27FDF9DD000089D0 /* ScriptWidgetRunningState.swift in Sources */, F23A40BB262492FF0035CBA7 /* DeepLinkManager.swift in Sources */, F29118F32793034D00B860B0 /* ScriptModel.swift in Sources */, + D1A0000000E0000000000002 /* ScriptMetadata.swift in Sources */, D1403DEB2552FEB40076F87C /* TOCropViewController.m in Sources */, F2C5C5E427AF5B5400797C5B /* AppIconsView.swift in Sources */, F277CBA427CB5A2C003AB97D /* ScriptWidgetAttributeOpacityModifier.swift in Sources */, @@ -1720,6 +1795,7 @@ F08113B42AD0831C00605DE1 /* ScriptWidgetTimelineProvider.swift in Sources */, F277CBA527CB5A2C003AB97D /* ScriptWidgetAttributeOpacityModifier.swift in Sources */, F29118F42793034D00B860B0 /* ScriptModel.swift in Sources */, + D1A0000000E0000000000003 /* ScriptMetadata.swift in Sources */, F29119812793035E00B860B0 /* ScriptWidgetRuntimeConsole.swift in Sources */, F29119732793035E00B860B0 /* ScriptWidgetElementTagText.swift in Sources */, F29119772793035E00B860B0 /* ScriptWidgetElementTagGauge.swift in Sources */, @@ -1735,6 +1811,7 @@ buildActionMask = 2147483647; files = ( F29118F62793034D00B860B0 /* ScriptModel.swift in Sources */, + D1A0000000E0000000000004 /* ScriptMetadata.swift in Sources */, F29118FE2793034D00B860B0 /* ScriptManager.swift in Sources */, F2C6AC7426004B0F009CECE9 /* ShareViewController.swift in Sources */, F29119962793223300B860B0 /* ScriptWidgetPackage.swift in Sources */, @@ -2133,6 +2210,14 @@ minimumVersion = 0.1.0; }; }; + A106000100000000000000B0 /* XCRemoteSwiftPackageReference "SwiftOpenAI" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/jamesrochabrun/SwiftOpenAI"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.4.9; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -2189,6 +2274,11 @@ package = F245BBF625B9E62300F25F68 /* XCRemoteSwiftPackageReference "SwiftyJSON" */; productName = SwiftyJSON; }; + A106000200000000000000B0 /* SwiftOpenAI */ = { + isa = XCSwiftPackageProductDependency; + package = A106000100000000000000B0 /* XCRemoteSwiftPackageReference "SwiftOpenAI" */; + productName = SwiftOpenAI; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = D18AAC24252A12D10065386A /* Project object */; diff --git a/iOS/ScriptWidget.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iOS/ScriptWidget.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index febfe8f..1eb8935 100644 --- a/iOS/ScriptWidget.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iOS/ScriptWidget.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "a1224478333d09db55982de830ed5b9826df59c20413798533ad720d4cbf103c", + "originHash" : "db1d8a4d8692124f822529811f5107a0a180e2b7ea16f9edb162c2b9c9d17dd1", "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "3a5b74a58782c3b4c1f0bc75e9b67b10c2494e8f", + "version" : "1.33.1" + } + }, { "identity" : "clockhandrotationkit", "kind" : "remoteSourceControl", @@ -28,6 +37,204 @@ "version" : "1.5.0" } }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", + "version" : "1.1.3" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "5aa1c0d1bc204908df47c2075bdbb39573d05e8d", + "version" : "1.19.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "476538ccb827f2dd18efc5de754cc87d77127a47", + "version" : "4.4.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", + "version" : "1.12.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "cd6710454f25733900e133c6caf5188952763c36", + "version" : "2.98.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "5a48717e29f62cb8326d6d42e46b562ca93847a6", + "version" : "1.34.0" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "81cc18264f92cd307ff98430f89372711d4f6fe9", + "version" : "1.43.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da", + "version" : "2.37.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "9d4e67af1eea85967c7de778ad73e7776e5f1f22", + "version" : "1.27.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle", + "state" : { + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, + { + "identity" : "swiftopenai", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jamesrochabrun/SwiftOpenAI", + "state" : { + "revision" : "bc6b84767c3a4eb9d48942b86e2417a229ef096c", + "version" : "4.4.9" + } + }, { "identity" : "swiftuix", "kind" : "remoteSourceControl", diff --git a/iOS/ScriptWidget/App/Scripts/CreateGuideView.swift b/iOS/ScriptWidget/App/Scripts/CreateGuideView.swift index 3bb7476..595a5bb 100644 --- a/iOS/ScriptWidget/App/Scripts/CreateGuideView.swift +++ b/iOS/ScriptWidget/App/Scripts/CreateGuideView.swift @@ -12,55 +12,60 @@ class CreateGuideDataObject: ObservableObject { @Published var models = [ScriptModel]() init() { - - DispatchQueue.global().async { [self] in - var items = ScriptManager.listBundleScripts(bundle: "Script", relativePath: "template") - if let index = items.firstIndex(where: { (model) -> Bool in - return model.name == "Empty Script" - }) { - items.move(fromOffsets: [index], toOffset: 0) - } - + let items = ScriptManager.listBundleScripts(bundle: "Script", relativePath: "template") DispatchQueue.main.async { self.models = items } } - } } struct CreateGuideView: View { @ObservedObject var dataObject = CreateGuideDataObject() - + @Environment(\.presentationMode) var presentationMode - + + @State private var showingAIGenerate = false + @State private var showingAIConfigAlert = false + @State private var selectedCategory: ScriptCategory? = nil + @State private var searchText: String = "" + var body: some View { NavigationView { - List { - ForEach(dataObject.models) { item in - NavigationLink(destination: ScriptCodeEditorView(mode: .creator,scriptModel:item, actionCreate: { - // create - guard let content = item.package.readMainFile().0 else { return } - - // image copy path - let imageCopyPath = item.package.imagePath - - _ = sharedScriptManager.createScript(content: content, recommendPackageName: item.name, imageCopyPath: imageCopyPath) - - NotificationCenter.default.post(name: ScriptWidgetHomeViewDataObject.scriptCreateNotification, object: nil) - - // dismiss - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: { - self.presentationMode.wrappedValue.dismiss() - }) - })) { - WidgetRowView(model: item) + ScrollView { + VStack(alignment: .leading, spacing: 16) { + aiRow + .padding(.horizontal) + + if !searchText.isEmpty { + // Hide category chips while searching + } else { + categoryChips + } + + if filteredModels.isEmpty { + emptyState + .frame(maxWidth: .infinity) + .padding(.top, 40) + } else { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 160), spacing: 12)], spacing: 12) { + ForEach(filteredModels) { item in + NavigationLink(destination: editorDestination(for: item)) { + TemplateCardView(model: item) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + .padding(.bottom, 20) } } + .padding(.top, 8) } - .navigationBarTitle(Text("Create from template"), displayMode: .large) + .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search templates") + .navigationBarTitle(Text("New Widget"), displayMode: .large) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button(action: { @@ -71,6 +76,234 @@ struct CreateGuideView: View { } } } + .background( + NavigationLink(isActive: $showingAIGenerate) { + AIGenerateView() + } label: { EmptyView() } + .hidden() + ) + .alert("Configure AI First", isPresented: $showingAIConfigAlert) { + Button("OK", role: .cancel) { } + } message: { + Text("Open Settings → AI to add your OpenAI API key, then come back to generate with AI.") + } + } + } + + // MARK: - Derived state + + private var filteredModels: [ScriptModel] { + let q = searchText.trimmingCharacters(in: .whitespaces).lowercased() + return dataObject.models.filter { model in + if !q.isEmpty { + let haystack = ([model.name, model.summary ?? ""] + model.tags).joined(separator: " ").lowercased() + return haystack.contains(q) + } + guard let selected = selectedCategory else { return true } + return model.category == selected + } + } + + // MARK: - Subviews + + private var aiRow: some View { + Button { + if AISettingsStore.shared.load().isConfigured { + showingAIGenerate = true + } else { + showingAIConfigAlert = true + } + } label: { + HStack(spacing: 12) { + Image(systemName: "sparkles") + .font(.title2) + .foregroundColor(.white) + .frame(width: 44, height: 44) + .background( + LinearGradient(colors: [.purple, .blue], startPoint: .topLeading, endPoint: .bottomTrailing) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + VStack(alignment: .leading, spacing: 2) { + Text("Generate with AI") + .font(.headline) + .foregroundColor(.primary) + Text("Describe your widget and let the AI build it.") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(12) + .background(Color.accentColor.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var categoryChips: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + CategoryChip(title: "All", + systemImage: "square.grid.2x2", + color: .gray, + selected: selectedCategory == nil) { + selectedCategory = nil + } + ForEach(ScriptCategory.allCases) { cat in + CategoryChip(title: cat.displayName, + systemImage: cat.systemImage, + color: cat.accentColor, + selected: selectedCategory == cat) { + selectedCategory = (selectedCategory == cat) ? nil : cat + } + } + } + .padding(.horizontal) + } + } + + private var emptyState: some View { + VStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 36)) + .foregroundColor(.secondary) + Text("No templates match").font(.headline) + Text("Try another keyword or category.") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + @ViewBuilder + private func editorDestination(for item: ScriptModel) -> some View { + ScriptCodeEditorView(mode: .creator, scriptModel: item, actionCreate: { + guard let content = item.package.readMainFile().0 else { return } + let imageCopyPath = item.package.imagePath + _ = sharedScriptManager.createScript(content: content, recommendPackageName: item.name, imageCopyPath: imageCopyPath) + NotificationCenter.default.post(name: ScriptWidgetHomeViewDataObject.scriptCreateNotification, object: nil) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: { + self.presentationMode.wrappedValue.dismiss() + }) + }) + } +} + +// MARK: - Category chip + +struct CategoryChip: View { + let title: String + let systemImage: String + let color: Color + let selected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + Image(systemName: systemImage) + .font(.caption) + Text(title) + .font(.subheadline) + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .foregroundColor(selected ? .white : color) + .background(selected ? color : color.opacity(0.12)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } +} + +// MARK: - Template card + +struct TemplateCardView: View { + let model: ScriptModel + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Preview area + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(cardBackground) + + if let url = model.package.previewImageURL(), + let uiImage = UIImage(contentsOfFile: url.path) { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + .clipShape(RoundedRectangle(cornerRadius: 10)) + } else { + Image(systemName: model.iconSystemName) + .font(.system(size: 34, weight: .regular)) + .foregroundColor(accentColor) + } + } + .frame(height: 96) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + // Text + VStack(alignment: .leading, spacing: 4) { + Text(model.name) + .font(.subheadline.weight(.semibold)) + .foregroundColor(.primary) + .lineLimit(1) + if let summary = model.summary, !summary.isEmpty { + Text(summary) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + } + if let difficulty = model.difficulty { + DifficultyBadge(difficulty: difficulty) + .padding(.top, 2) + } + } + .padding(.horizontal, 4) + .padding(.bottom, 4) + } + .padding(6) + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.secondary.opacity(0.15), lineWidth: 0.5) + ) + } + + private var accentColor: Color { + model.category?.accentColor ?? .accentColor + } + + private var cardBackground: LinearGradient { + LinearGradient(colors: [accentColor.opacity(0.18), accentColor.opacity(0.06)], + startPoint: .topLeading, endPoint: .bottomTrailing) + } +} + +struct DifficultyBadge: View { + let difficulty: ScriptDifficulty + + var body: some View { + Text(difficulty.displayName) + .font(.system(size: 10, weight: .semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .foregroundColor(color) + .background(color.opacity(0.15)) + .clipShape(Capsule()) + } + + private var color: Color { + switch difficulty { + case .beginner: return .green + case .medium: return .orange + case .advanced: return .red } } } diff --git a/iOS/ScriptWidget/App/Scripts/ScriptWidgetHomeView.swift b/iOS/ScriptWidget/App/Scripts/ScriptWidgetHomeView.swift index 9cd12e4..4f9a1bb 100644 --- a/iOS/ScriptWidget/App/Scripts/ScriptWidgetHomeView.swift +++ b/iOS/ScriptWidget/App/Scripts/ScriptWidgetHomeView.swift @@ -152,14 +152,24 @@ struct ScriptWidgetHomeView: View { Label("Share", systemImage: "square.and.arrow.up") } .tint(.blue) - + Button { self.selectedEditItem = item } label: { Label("Edit", systemImage: "pencil.circle") } .tint(.systemIndigo) - + + Button { + let result = sharedScriptManager.duplicateScript(sourcePackageName: item.name) + if result.0 { + NotificationCenter.default.post(name: ScriptWidgetHomeViewDataObject.scriptCreateNotification, object: nil) + } + } label: { + Label("Remix", systemImage: "square.on.square") + } + .tint(.purple) + Button(role: .destructive) { self.selectedDeleteItem = item self.isShowingDeleteAlert.toggle() diff --git a/iOS/ScriptWidget/App/Settings/SettingAIView.swift b/iOS/ScriptWidget/App/Settings/SettingAIView.swift new file mode 100644 index 0000000..7bc10fc --- /dev/null +++ b/iOS/ScriptWidget/App/Settings/SettingAIView.swift @@ -0,0 +1,208 @@ +// +// SettingAIView.swift +// ScriptWidget +// +// Lets the user configure the OpenAI (or compatible) endpoint used by +// the AI Generate feature. Values are persisted to the app-group +// UserDefaults via AISettingsStore. +// + +import SwiftUI + +struct SettingAIView: View { + @State private var apiKey: String = "" + @State private var baseURL: String = AISettings.defaultBaseURL + @State private var model: String = AISettings.defaultModel + @State private var maxIterations: Int = AISettings.defaultMaxIterations + @State private var temperature: Double = AISettings.defaultTemperature + @State private var apiKeyVisible: Bool = false + + @State private var testPhase: TestPhase = .idle + @State private var testMessage: String = "" + + @State private var showingSavedToast = false + + private enum TestPhase { case idle, running, success, failure } + + private let modelPresets = ["gpt-4o-mini", "gpt-4o", "gpt-4.1-mini", "o4-mini"] + + var body: some View { + Form { + Section { + HStack { + if apiKeyVisible { + TextField("sk-...", text: $apiKey) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } else { + SecureField("sk-...", text: $apiKey) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + Button { + apiKeyVisible.toggle() + } label: { + Image(systemName: apiKeyVisible ? "eye.slash" : "eye") + } + .buttonStyle(.borderless) + } + } header: { + Text("API Key") + } footer: { + Text("Stored in plain-text UserDefaults on this device. Do not configure on a shared device.") + .foregroundColor(.orange) + } + + Section("Endpoint") { + TextField("https://api.openai.com", text: $baseURL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + } + + Section("Model") { + TextField("gpt-4o-mini", text: $model) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(modelPresets, id: \.self) { preset in + Button(preset) { + model = preset + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + } + + Section("Agent Loop") { + Stepper(value: $maxIterations, in: 5...100, step: 5) { + HStack { + Text("Max Iterations") + Spacer() + Text("\(maxIterations)") + .foregroundStyle(.secondary) + } + } + VStack(alignment: .leading) { + HStack { + Text("Temperature") + Spacer() + Text(String(format: "%.2f", temperature)) + .foregroundStyle(.secondary) + } + Slider(value: $temperature, in: 0.0...1.5, step: 0.05) + } + } + + Section { + Button { + runTest() + } label: { + HStack { + if testPhase == .running { + ProgressView().controlSize(.small) + } + Text(testButtonLabel) + } + } + .disabled(testPhase == .running || apiKey.trimmingCharacters(in: .whitespaces).isEmpty) + + if !testMessage.isEmpty { + Text(testMessage) + .font(.footnote) + .foregroundStyle(testPhase == .failure ? Color.red : Color.green) + } + } header: { + Text("Connection") + } + + Section { + Button { + persist() + } label: { + HStack { + Image(systemName: "checkmark.circle") + Text("Save") + .fontWeight(.semibold) + } + } + } + } + .navigationTitle("AI") + .navigationBarTitleDisplayMode(.inline) + .onAppear(perform: loadFromStore) + .overlay(alignment: .bottom) { + if showingSavedToast { + Text("Saved") + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.green.opacity(0.9)) + .foregroundColor(.white) + .cornerRadius(16) + .padding(.bottom, 24) + .transition(.opacity) + } + } + } + + private var testButtonLabel: String { + switch testPhase { + case .idle: return "Test Connection" + case .running: return "Testing..." + case .success: return "Test Connection" + case .failure: return "Test Connection" + } + } + + private func loadFromStore() { + let s = AISettingsStore.shared.load() + apiKey = s.apiKey + baseURL = s.baseURL + model = s.model + maxIterations = s.maxIterations + temperature = s.temperature + } + + private func persist() { + let settings = AISettings( + apiKey: apiKey.trimmingCharacters(in: .whitespacesAndNewlines), + baseURL: baseURL.trimmingCharacters(in: .whitespacesAndNewlines), + model: model.trimmingCharacters(in: .whitespacesAndNewlines), + maxIterations: maxIterations, + temperature: temperature + ) + AISettingsStore.shared.save(settings) + withAnimation { showingSavedToast = true } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { + withAnimation { showingSavedToast = false } + } + } + + private func runTest() { + persist() + let settings = AISettingsStore.shared.load() + testPhase = .running + testMessage = "" + Task { + do { + let messages = [ + AIMessage(role: .system, content: "You reply with exactly: pong"), + AIMessage(role: .user, content: "ping"), + ] + let result = try await AIClient.shared.chat(messages: messages, settings: settings) + await MainActor.run { + testPhase = .success + testMessage = "OK — \(result.content.prefix(60)) (\(result.usage.totalTokens) tokens)" + } + } catch { + await MainActor.run { + testPhase = .failure + testMessage = error.localizedDescription + } + } + } + } +} diff --git a/iOS/ScriptWidget/App/Settings/SettingsView.swift b/iOS/ScriptWidget/App/Settings/SettingsView.swift index c401b1c..12882f2 100644 --- a/iOS/ScriptWidget/App/Settings/SettingsView.swift +++ b/iOS/ScriptWidget/App/Settings/SettingsView.swift @@ -39,8 +39,14 @@ struct SettingsView: View { SettingsTextRowView(name: "Templates", content: "") } } - - + + GroupBox (label: SettingsLabelView(title: "AI", image: "sparkles")) { + NavigationLink(destination: SettingAIView()) { + SettingsTextRowView(name: "AI Generate", content: "") + } + } + + GroupBox (label: SettingsLabelView(title: "Refresh", image: "paintbrush")) { Divider().padding(.vertical, 4) diff --git a/iOS/ScriptWidget/View/AIGenerate/AIGenerateView.swift b/iOS/ScriptWidget/View/AIGenerate/AIGenerateView.swift new file mode 100644 index 0000000..954b1f3 --- /dev/null +++ b/iOS/ScriptWidget/View/AIGenerate/AIGenerateView.swift @@ -0,0 +1,145 @@ +// +// AIGenerateView.swift +// ScriptWidget +// +// Prompt input → agent loop → review. The session object lives for +// the duration of this view (including nested refines). +// + +import SwiftUI + +struct AIGenerateView: View { + @Environment(\.presentationMode) var presentationMode + @StateObject private var session = AIGenerateSession() + + @State private var prompt: String = "" + @State private var showReview = false + + private let placeholderPrompt = "e.g. Show the current weather for my location, with a minimalist dark background." + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + header + + VStack(alignment: .leading, spacing: 6) { + Text("Describe your widget") + .font(.headline) + ZStack(alignment: .topLeading) { + TextEditor(text: $prompt) + .frame(minHeight: 120) + .padding(4) + .background(Color.secondary.opacity(0.08)) + .cornerRadius(10) + if prompt.isEmpty { + Text(placeholderPrompt) + .foregroundStyle(.secondary) + .padding(.top, 12) + .padding(.leading, 10) + .allowsHitTesting(false) + } + } + } + + examplesSection + + Picker("Size", selection: $session.size) { + ForEach(AIWidgetSize.allCases) { size in + Text(size.displayName).tag(size) + } + } + .pickerStyle(.menu) + + Button { + session.start(userDescription: prompt) + } label: { + HStack { + Image(systemName: "sparkles") + Text(session.isRunning ? "Generating..." : "Generate") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + .buttonStyle(.borderedProminent) + .disabled(session.isRunning || prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + if session.isRunning || hasOutcome { + AIGenerateProgressView(session: session) + } + } + .padding() + } + .navigationTitle("Generate") + .navigationBarTitleDisplayMode(.inline) + .onChange(of: session.phase) { newPhase in + if case .done = newPhase { + showReview = true + } else if case .exhausted = newPhase { + showReview = true + } + } + .background( + NavigationLink(isActive: $showReview) { + AIReviewView(session: session) { + presentationMode.wrappedValue.dismiss() + } + } label: { EmptyView() } + .hidden() + ) + } + + private var header: some View { + HStack(spacing: 10) { + Image(systemName: "sparkles") + .font(.title2) + .foregroundStyle(.tint) + VStack(alignment: .leading, spacing: 2) { + Text("AI Widget Generator") + .font(.title3.weight(.semibold)) + Text("Describe what you want; the AI will iterate until the widget runs.") + .font(.footnote) + .foregroundStyle(.secondary) + } + Spacer() + } + } + + private var hasOutcome: Bool { + switch session.phase { + case .idle: return false + default: return true + } + } + + private var examplesSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Try an example") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(AIExamplePrompts.all) { example in + Button { + prompt = example.prompt + session.size = example.size + } label: { + HStack(spacing: 6) { + Image(systemName: example.symbol) + Text(example.title) + } + .font(.caption) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.accentColor.opacity(0.12)) + .foregroundColor(.accentColor) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 2) + } + } + } +} diff --git a/iOS/ScriptWidget/View/AIGenerate/AIReviewView.swift b/iOS/ScriptWidget/View/AIGenerate/AIReviewView.swift new file mode 100644 index 0000000..4a774ed --- /dev/null +++ b/iOS/ScriptWidget/View/AIGenerate/AIReviewView.swift @@ -0,0 +1,284 @@ +// +// AIReviewView.swift +// ScriptWidget +// +// Shows the generated widget, lets the user refine it, inspect the +// code / logs, discard, or save into the real Scripts library. +// + +import SwiftUI +import WidgetKit + +struct AIReviewView: View { + @ObservedObject var session: AIGenerateSession + /// Called when the user successfully saves (so the parent sheet can close). + var onSaved: () -> Void + + @Environment(\.presentationMode) private var presentationMode + + @State private var refineInstruction: String = "" + @State private var showingCodeSheet = false + @State private var showingLogsSheet = false + @State private var showingSaveNamePrompt = false + @State private var saveName: String = "" + @State private var saveError: String? + @State private var isDebugMode = false + @State private var previewPackage: ScriptWidgetPackage? + + private var jsx: String { session.lastJSX ?? "" } + private var isExhausted: Bool { + if case .exhausted = session.phase { return true } + return false + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + banner + previewSection + actionsSection + refineSection + + if session.isRunning { + AIGenerateProgressView(session: session) + } + } + .padding() + } + .navigationTitle("Review") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + menuToolbar + } + } + .sheet(isPresented: $showingCodeSheet) { codeSheet } + .sheet(isPresented: $showingLogsSheet) { logsSheet } + .alert("Save Widget", isPresented: $showingSaveNamePrompt) { + TextField("Widget name", text: $saveName) + .textInputAutocapitalization(.words) + Button("Cancel", role: .cancel) { } + Button("Save") { performSave() } + } message: { + Text("Choose a name for your new widget.") + } + .alert("Save Failed", isPresented: saveErrorBinding) { + Button("OK") { saveError = nil } + } message: { + Text(saveError ?? "") + } + .onAppear(perform: ensurePreviewPackage) + .onChange(of: jsx) { _ in + refreshPreviewPackage() + } + } + + // MARK: - sub-sections + + @ViewBuilder private var banner: some View { + if isExhausted { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + Text("Did not fully converge — showing the last attempt.") + .font(.footnote) + } + .foregroundStyle(.orange) + } + } + + @ViewBuilder private var previewSection: some View { + let size = session.size.previewSize + ZStack { + Rectangle() + .fill(Color.secondary.opacity(0.15)) + previewContent(size: size) + } + .frame(maxWidth: .infinity) + .frame(height: Swift.max(size.height + 40, 200)) + .cornerRadius(12) + } + + @ViewBuilder + private func previewContent(size: CGSize) -> some View { + if let element = session.resultElement, let pkg = previewPackage { + let context = ScriptWidgetElementContext( + runtime: nil, + debugMode: isDebugMode, + scriptName: "AI Preview", + scriptParameter: "", + package: pkg + ) + ScriptWidgetElementView(element: element, context: context) + .frame(width: size.width, height: size.height) + .background(Color(UIColor.systemBackground)) + .cornerRadius(session.size.previewIsCircular ? size.height / 2 : 10) + } else { + Text("No preview available") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + private var actionsSection: some View { + HStack(spacing: 12) { + Button(role: .destructive) { + presentationMode.wrappedValue.dismiss() + } label: { + Label("Discard", systemImage: "trash") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + + Toggle(isOn: $isDebugMode) { + Text("Debug") + .font(.caption) + } + .toggleStyle(.button) + .controlSize(.small) + + Button { + saveName = "AI " + AIReviewView.defaultNameFormatter.string(from: Date()) + showingSaveNamePrompt = true + } label: { + Label("Save Widget", systemImage: "square.and.arrow.down") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(jsx.isEmpty) + } + } + + private var refineSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Refine") + .font(.headline) + Text("Ask the AI to change something — it'll iterate again.") + .font(.caption) + .foregroundStyle(.secondary) + HStack { + TextField("e.g. use a darker background and larger title", text: $refineInstruction) + .textFieldStyle(.roundedBorder) + Button { + let instruction = refineInstruction + refineInstruction = "" + session.refine(currentCode: jsx, refineInstruction: instruction) + } label: { + Image(systemName: "arrow.right.circle.fill") + .font(.title3) + } + .disabled(jsx.isEmpty || refineInstruction.trimmingCharacters(in: .whitespaces).isEmpty || session.isRunning) + } + } + } + + @ViewBuilder private var menuToolbar: some View { + Menu { + Button { showingCodeSheet = true } label: { + Label("View Code", systemImage: "curlybraces") + } + Button { showingLogsSheet = true } label: { + Label("Logs", systemImage: "text.alignleft") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + + @ViewBuilder private var codeSheet: some View { + NavigationView { + ScrollView { + Text(jsx) + .font(.system(.footnote, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .textSelection(.enabled) + } + .navigationTitle("Generated JSX") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { showingCodeSheet = false } + } + } + } + } + + @ViewBuilder private var logsSheet: some View { + NavigationView { + List { + ForEach(session.iterationHistory) { record in + Section("Iteration \(record.iteration)") { + if let err = record.errorSummary { + Label(err, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } else { + Label("Success", systemImage: "checkmark.circle") + .foregroundStyle(.green) + } + if !record.logs.isEmpty { + ForEach(Array(record.logs.enumerated()), id: \.offset) { _, line in + Text(line) + .font(.caption.monospaced()) + .textSelection(.enabled) + } + } + } + } + } + .navigationTitle("Iteration Logs") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { showingLogsSheet = false } + } + } + } + } + + // MARK: - helpers + + private var saveErrorBinding: Binding { + Binding( + get: { saveError != nil }, + set: { if !$0 { saveError = nil } } + ) + } + + private func ensurePreviewPackage() { + if previewPackage == nil { + previewPackage = try? AgentRuntimeBridge.shared.makeSandboxPackage(prefix: "preview") + } + refreshPreviewPackage() + } + + private func refreshPreviewPackage() { + guard let pkg = previewPackage else { return } + _ = pkg.writeMainFile(content: jsx) + } + + private func performSave() { + let trimmed = saveName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + saveError = "Widget name can not be empty." + return + } + let result = sharedScriptManager.createScript( + content: jsx, + recommendPackageName: trimmed, + imageCopyPath: nil + ) + if result.0 { + NotificationCenter.default.post(name: ScriptWidgetHomeViewDataObject.scriptCreateNotification, object: nil) + WidgetCenter.shared.reloadAllTimelines() + onSaved() + } else { + saveError = result.1 + } + } + + private static let defaultNameFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HHmm" + return f + }() +} diff --git a/iOS/ScriptWidget/View/EmptyListBackgroundView.swift b/iOS/ScriptWidget/View/EmptyListBackgroundView.swift index 6da409f..9e45d07 100644 --- a/iOS/ScriptWidget/View/EmptyListBackgroundView.swift +++ b/iOS/ScriptWidget/View/EmptyListBackgroundView.swift @@ -2,29 +2,238 @@ // EmptyListBackgroundView.swift // ScriptWidget // -// Created by everettjf on 2021/3/1. +// Onboarding shown on first launch when no widgets exist yet. // import SwiftUI +class OnboardingFeaturedDataObject: ObservableObject { + @Published var featured: [ScriptModel] = [] + + init() { + DispatchQueue.global().async { [weak self] in + let all = ScriptManager.listBundleScripts(bundle: "Script", relativePath: "template") + let picked = all.filter { $0.isFeatured } + DispatchQueue.main.async { + self?.featured = picked + } + } + } +} + struct EmptyListBackgroundView: View { + @StateObject private var data = OnboardingFeaturedDataObject() + @State private var showCreate = false + @State private var selectedFeatured: ScriptModel? + var body: some View { - VStack (spacing: 20) { - Image(systemName: "lessthan") - .font(.system(size: 70, weight: .bold, design: .monospaced)) - - Text("ScriptWidget") + ScrollView { + VStack(alignment: .leading, spacing: 24) { + heroSection + .padding(.top, 20) + + howItWorks + + if !data.featured.isEmpty { + featuredSection + } + + Divider().padding(.vertical, 4) + + browseAll + } + .padding(.horizontal, 20) + .padding(.bottom, 40) + } + .fullScreenCover(isPresented: $showCreate) { + CreateGuideView() + } + .sheet(item: $selectedFeatured) { item in + NavigationView { + ScriptCodeEditorView(mode: .creator, scriptModel: item, actionCreate: { + guard let content = item.package.readMainFile().0 else { return } + let imageCopyPath = item.package.imagePath + _ = sharedScriptManager.createScript( + content: content, + recommendPackageName: item.name, + imageCopyPath: imageCopyPath + ) + NotificationCenter.default.post( + name: ScriptWidgetHomeViewDataObject.scriptCreateNotification, + object: nil + ) + selectedFeatured = nil + }) + } + } + } + + // MARK: - Sections + + private var heroSection: some View { + VStack(alignment: .leading, spacing: 10) { + Image(systemName: "sparkles.square.filled.on.square") + .font(.system(size: 48)) + .foregroundStyle( + LinearGradient(colors: [.purple, .blue], + startPoint: .topLeading, + endPoint: .bottomTrailing) + ) + Text("Build widgets with JavaScript") + .font(.title2).bold() + Text("Pick a template, preview it instantly, then add it to your Home Screen. No Xcode required.") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + private var howItWorks: some View { + VStack(alignment: .leading, spacing: 12) { + Text("How it works") .font(.headline) - .fontWeight(.bold) - - Text("Create your first widget by tapping the plus button upper-right of screen :)") + HStack(alignment: .top, spacing: 12) { + OnboardingStep(number: 1, + icon: "square.grid.2x2.fill", + title: "Pick", + detail: "Choose a ready template.") + OnboardingStep(number: 2, + icon: "play.rectangle.fill", + title: "Preview", + detail: "Live preview in the editor.") + OnboardingStep(number: 3, + icon: "rectangle.stack.badge.plus", + title: "Install", + detail: "Add to Home Screen.") + } + } + } + + private var featuredSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Start with one of these") .font(.headline) - .padding(.bottom, 100) - .padding(.leading, 10) - .padding(.trailing, 10) + VStack(spacing: 10) { + ForEach(data.featured.prefix(4)) { item in + Button { + selectedFeatured = item + } label: { + FeaturedRow(model: item) + } + .buttonStyle(.plain) + } + } + } + } + + private var browseAll: some View { + VStack(alignment: .leading, spacing: 10) { + Button { + showCreate = true + } label: { + HStack { + Image(systemName: "square.grid.2x2") + Text("Browse all templates") + .fontWeight(.semibold) + Spacer() + Image(systemName: "chevron.right").font(.caption) + } + .padding(14) + .background(Color.accentColor.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(.plain) + + Text("Or tap ") + .font(.caption) + .foregroundColor(.secondary) + + Text(Image(systemName: "plus.square")) + .font(.caption) + .foregroundColor(.secondary) + + Text(" in the top-right to create from scratch or with AI.") + .font(.caption) + .foregroundColor(.secondary) } - .foregroundColor(Color.gray.opacity(0.75)) - .padding() + } +} + +// MARK: - Step card + +struct OnboardingStep: View { + let number: Int + let icon: String + let title: String + let detail: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ZStack { + Circle() + .fill(Color.accentColor.opacity(0.15)) + .frame(width: 36, height: 36) + Image(systemName: icon) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.accentColor) + } + Text("\(number). \(title)") + .font(.subheadline).bold() + Text(detail) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} + +// MARK: - Featured row + +struct FeaturedRow: View { + let model: ScriptModel + + var body: some View { + HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(LinearGradient( + colors: [accent.opacity(0.25), accent.opacity(0.08)], + startPoint: .topLeading, endPoint: .bottomTrailing + )) + .frame(width: 50, height: 50) + Image(systemName: model.iconSystemName) + .font(.system(size: 22)) + .foregroundColor(accent) + } + VStack(alignment: .leading, spacing: 3) { + Text(model.name) + .font(.subheadline).bold() + .foregroundColor(.primary) + if let summary = model.summary { + Text(summary) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(12) + .background(Color(.systemBackground)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.secondary.opacity(0.15), lineWidth: 0.5) + ) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + private var accent: Color { + model.category?.accentColor ?? .accentColor } } diff --git a/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj b/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj index ad879d8..1e61895 100644 --- a/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj +++ b/macOS/ScriptWidgetMac.xcodeproj/project.pbxproj @@ -20,6 +20,18 @@ 75F45276F84EA01B91EC0CEC /* ScriptWidgetRuntimeHealth.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9F1F298DF5C4FAED8AAD43 /* ScriptWidgetRuntimeHealth.swift */; }; 855878C6168D59CE4C6CB13C /* ScriptWidgetRuntimeSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E3B593386F820DCF99C631E /* ScriptWidgetRuntimeSystem.swift */; }; 966888252E454500AEA7852C /* ScriptWidgetRuntimeLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 124FAA64786342F5A3432631 /* ScriptWidgetRuntimeLocation.swift */; }; + A202000100000000000000B0 /* AISettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000100000000000000B0 /* AISettings.swift */; }; + A202000200000000000000B0 /* AIReferenceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000200000000000000B0 /* AIReferenceSnapshot.swift */; }; + A202000300000000000000B0 /* PromptBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000300000000000000B0 /* PromptBuilder.swift */; }; + A202000400000000000000B0 /* AIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000400000000000000B0 /* AIClient.swift */; }; + A202000500000000000000B0 /* AgentRuntimeBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000500000000000000B0 /* AgentRuntimeBridge.swift */; }; + A202000600000000000000B0 /* AgentLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000600000000000000B0 /* AgentLoop.swift */; }; + A202000700000000000000B0 /* AIGenerateSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000700000000000000B0 /* AIGenerateSession.swift */; }; + A202000800000000000000B0 /* AIGenerateProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000800000000000000B0 /* AIGenerateProgressView.swift */; }; + A202000900000000000000B0 /* AIExamplePrompts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A201000900000000000000B0 /* AIExamplePrompts.swift */; }; + A204000100000000000000B0 /* SettingAIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A203000100000000000000B0 /* SettingAIView.swift */; }; + A204000200000000000000B0 /* AIGenerateWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A203000200000000000000B0 /* AIGenerateWindowView.swift */; }; + A206000300000000000000B0 /* SwiftOpenAI in Frameworks */ = {isa = PBXBuildFile; productRef = A206000200000000000000B0 /* SwiftOpenAI */; }; A621B963FD9840DB82F74813 /* ScriptWidgetRuntimeLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 124FAA64786342F5A3432631 /* ScriptWidgetRuntimeLocation.swift */; }; A990D65181507F3AA8EB7951 /* ScriptWidgetElementTagExtras.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DAACB8F5182390731F6DFE7 /* ScriptWidgetElementTagExtras.swift */; }; D1995C9429536BB800D1BD94 /* MineGaugeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1995C5729536BB800D1BD94 /* MineGaugeView.swift */; }; @@ -118,10 +130,11 @@ D1995CF929536BC700D1BD94 /* ScriptWidgetPackage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1995CF529536BC700D1BD94 /* ScriptWidgetPackage.swift */; }; D1995CFB29536BC700D1BD94 /* ScriptModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1995CF629536BC700D1BD94 /* ScriptModel.swift */; }; D1995CFC29536BC700D1BD94 /* ScriptModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1995CF629536BC700D1BD94 /* ScriptModel.swift */; }; + D1A0000000E0000000001001 /* ScriptMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0000000E0000000001000 /* ScriptMetadata.swift */; }; + D1A0000000E0000000001002 /* ScriptMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0000000E0000000001000 /* ScriptMetadata.swift */; }; D1995CFE29536BC700D1BD94 /* ScriptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1995CF729536BC700D1BD94 /* ScriptManager.swift */; }; D1995CFF29536BC700D1BD94 /* ScriptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1995CF729536BC700D1BD94 /* ScriptManager.swift */; }; D1995D0629536BF000D1BD94 /* Script.bundle in Resources */ = {isa = PBXBuildFile; fileRef = D1995D0529536BF000D1BD94 /* Script.bundle */; }; - D1995D0929536CCE00D1BD94 /* Vapor in Frameworks */ = {isa = PBXBuildFile; productRef = D1995D0829536CCE00D1BD94 /* Vapor */; }; F05227BF2AD1D6DF0014BE09 /* ScriptWidgetElementTagToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05227BD2AD1D6DE0014BE09 /* ScriptWidgetElementTagToggle.swift */; }; F05227C02AD1D6DF0014BE09 /* ScriptWidgetElementTagToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05227BD2AD1D6DE0014BE09 /* ScriptWidgetElementTagToggle.swift */; }; F05227C12AD1D6DF0014BE09 /* ScriptWidgetElementTagButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05227BE2AD1D6DE0014BE09 /* ScriptWidgetElementTagButton.swift */; }; @@ -183,6 +196,7 @@ F2CA7ADB27A23BED00569709 /* ZipArchive in Frameworks */ = {isa = PBXBuildFile; productRef = F2CA7ADA27A23BED00569709 /* ZipArchive */; }; F2D2B24EA420469CBA72564F /* ReloadWidgetAppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8445DB90E4A34EB19697BAEB /* ReloadWidgetAppIntent.swift */; }; F2F90FC427970E3E003095A9 /* CreateGuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F90FC327970E3E003095A9 /* CreateGuideView.swift */; }; + A207000200000000000000B0 /* EditorSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A207000100000000000000B0 /* EditorSchemeHandler.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -217,6 +231,17 @@ 8445DB90E4A34EB19697BAEB /* ReloadWidgetAppIntent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReloadWidgetAppIntent.swift; sourceTree = ""; }; 8CAE66D10656192ADC2F9019 /* ScriptWidgetRuntimeStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptWidgetRuntimeStorage.swift; sourceTree = ""; }; 8E3B593386F820DCF99C631E /* ScriptWidgetRuntimeSystem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptWidgetRuntimeSystem.swift; sourceTree = ""; }; + A201000100000000000000B0 /* AISettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISettings.swift; sourceTree = ""; }; + A201000200000000000000B0 /* AIReferenceSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIReferenceSnapshot.swift; sourceTree = ""; }; + A201000300000000000000B0 /* PromptBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptBuilder.swift; sourceTree = ""; }; + A201000400000000000000B0 /* AIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIClient.swift; sourceTree = ""; }; + A201000500000000000000B0 /* AgentRuntimeBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentRuntimeBridge.swift; sourceTree = ""; }; + A201000600000000000000B0 /* AgentLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentLoop.swift; sourceTree = ""; }; + A201000700000000000000B0 /* AIGenerateSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateSession.swift; sourceTree = ""; }; + A201000800000000000000B0 /* AIGenerateProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateProgressView.swift; sourceTree = ""; }; + A201000900000000000000B0 /* AIExamplePrompts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIExamplePrompts.swift; sourceTree = ""; }; + A203000100000000000000B0 /* SettingAIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingAIView.swift; sourceTree = ""; }; + A203000200000000000000B0 /* AIGenerateWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIGenerateWindowView.swift; sourceTree = ""; }; CD9F1F298DF5C4FAED8AAD43 /* ScriptWidgetRuntimeHealth.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptWidgetRuntimeHealth.swift; sourceTree = ""; }; D1995C5729536BB800D1BD94 /* MineGaugeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MineGaugeView.swift; sourceTree = ""; }; D1995C5829536BB800D1BD94 /* ScriptWidgetElementColor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptWidgetElementColor.swift; sourceTree = ""; }; @@ -266,6 +291,7 @@ D1995C9229536BB800D1BD94 /* Apple ][.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Apple ][.ttf"; sourceTree = ""; }; D1995CF529536BC700D1BD94 /* ScriptWidgetPackage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptWidgetPackage.swift; sourceTree = ""; }; D1995CF629536BC700D1BD94 /* ScriptModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptModel.swift; sourceTree = ""; }; + D1A0000000E0000000001000 /* ScriptMetadata.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptMetadata.swift; sourceTree = ""; }; D1995CF729536BC700D1BD94 /* ScriptManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptManager.swift; sourceTree = ""; }; D1995D0529536BF000D1BD94 /* Script.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Script.bundle; sourceTree = ""; }; F05227BD2AD1D6DE0014BE09 /* ScriptWidgetElementTagToggle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScriptWidgetElementTagToggle.swift; sourceTree = ""; }; @@ -321,6 +347,7 @@ F29118D92792B90700B860B0 /* WKWebViewJavascriptBridgeJS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WKWebViewJavascriptBridgeJS.swift; sourceTree = ""; }; F29118E72792CCDE00B860B0 /* PreviewWidgetSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewWidgetSize.swift; sourceTree = ""; }; F2F90FC327970E3E003095A9 /* CreateGuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGuideView.swift; sourceTree = ""; }; + A207000100000000000000B0 /* EditorSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorSchemeHandler.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -328,9 +355,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A206000300000000000000B0 /* SwiftOpenAI in Frameworks */, F2B99C4327B8BE5E009584A3 /* SDWebImageSwiftUI in Frameworks */, F29118252791D38E00B860B0 /* SwiftyJSON in Frameworks */, - D1995D0929536CCE00D1BD94 /* Vapor in Frameworks */, F29119992794490100B860B0 /* ZipArchive in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -349,9 +376,43 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + A205000100000000000000B0 /* AI */ = { + isa = PBXGroup; + children = ( + A201000100000000000000B0 /* AISettings.swift */, + A201000200000000000000B0 /* AIReferenceSnapshot.swift */, + A201000300000000000000B0 /* PromptBuilder.swift */, + A201000400000000000000B0 /* AIClient.swift */, + A201000500000000000000B0 /* AgentRuntimeBridge.swift */, + A201000600000000000000B0 /* AgentLoop.swift */, + A201000700000000000000B0 /* AIGenerateSession.swift */, + A201000800000000000000B0 /* AIGenerateProgressView.swift */, + A201000900000000000000B0 /* AIExamplePrompts.swift */, + ); + name = AI; + path = ../Shared/ScriptWidgetRuntime/AI; + sourceTree = ""; + }; + A205000200000000000000B0 /* AIGenerate */ = { + isa = PBXGroup; + children = ( + A203000200000000000000B0 /* AIGenerateWindowView.swift */, + ); + path = AIGenerate; + sourceTree = ""; + }; + A205000300000000000000B0 /* Settings */ = { + isa = PBXGroup; + children = ( + A203000100000000000000B0 /* SettingAIView.swift */, + ); + path = Settings; + sourceTree = ""; + }; D1995C5029536B7300D1BD94 /* ScriptWidgetRuntime */ = { isa = PBXGroup; children = ( + A205000100000000000000B0 /* AI */, D1995D0429536BF000D1BD94 /* Resource */, D1995CF429536BC700D1BD94 /* Common */, D1995C5529536BB800D1BD94 /* Widget */, @@ -545,6 +606,7 @@ children = ( D1995CF529536BC700D1BD94 /* ScriptWidgetPackage.swift */, D1995CF629536BC700D1BD94 /* ScriptModel.swift */, + D1A0000000E0000000001000 /* ScriptMetadata.swift */, D1995CF729536BC700D1BD94 /* ScriptManager.swift */, ); name = Common; @@ -618,6 +680,7 @@ F29118D52792B8F600B860B0 /* Bridge */, F29118C72791DEC800B860B0 /* EditorWebView.swift */, F29118D32792B32900B860B0 /* EditorWebSevice.swift */, + A207000100000000000000B0 /* EditorSchemeHandler.swift */, ); path = Editor; sourceTree = ""; @@ -636,6 +699,8 @@ F29117D6279101DE00B860B0 /* ScriptWidgetMac */ = { isa = PBXGroup; children = ( + A205000200000000000000B0 /* AIGenerate */, + A205000300000000000000B0 /* Settings */, F2461643279F87B900C3D90F /* SwiftUIMacKit */, F246163C279E18CE00C3D90F /* Common */, F2F90FC227970DEA003095A9 /* Create */, @@ -792,10 +857,10 @@ ); name = ScriptWidgetMac; packageProductDependencies = ( + A206000200000000000000B0 /* SwiftOpenAI */, F29118242791D38E00B860B0 /* SwiftyJSON */, F29119982794490100B860B0 /* ZipArchive */, F2B99C4227B8BE5E009584A3 /* SDWebImageSwiftUI */, - D1995D0829536CCE00D1BD94 /* Vapor */, ); productName = "ScriptWidgetMac (macOS)"; productReference = F29117E4279101DF00B860B0 /* ScriptWidget.app */; @@ -850,10 +915,10 @@ ); mainGroup = F29117D1279101DE00B860B0; packageReferences = ( + A206000100000000000000B0 /* XCRemoteSwiftPackageReference "SwiftOpenAI" */, F29118232791D38E00B860B0 /* XCRemoteSwiftPackageReference "SwiftyJSON" */, F29119972794490100B860B0 /* XCRemoteSwiftPackageReference "ZipArchive" */, F2B99C4127B8BE5E009584A3 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */, - D1995D0729536CCE00D1BD94 /* XCRemoteSwiftPackageReference "vapor" */, ); productRefGroup = F29117DF279101DF00B860B0 /* Products */; projectDirPath = ""; @@ -905,6 +970,18 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A207000200000000000000B0 /* EditorSchemeHandler.swift in Sources */, + A202000900000000000000B0 /* AIExamplePrompts.swift in Sources */, + A202000100000000000000B0 /* AISettings.swift in Sources */, + A202000200000000000000B0 /* AIReferenceSnapshot.swift in Sources */, + A202000300000000000000B0 /* PromptBuilder.swift in Sources */, + A202000400000000000000B0 /* AIClient.swift in Sources */, + A202000500000000000000B0 /* AgentRuntimeBridge.swift in Sources */, + A202000600000000000000B0 /* AgentLoop.swift in Sources */, + A202000700000000000000B0 /* AIGenerateSession.swift in Sources */, + A202000800000000000000B0 /* AIGenerateProgressView.swift in Sources */, + A204000100000000000000B0 /* SettingAIView.swift in Sources */, + A204000200000000000000B0 /* AIGenerateWindowView.swift in Sources */, D1995CFE29536BC700D1BD94 /* ScriptManager.swift in Sources */, D1995CD029536BB800D1BD94 /* ScriptWidgetElementTagStack.swift in Sources */, D1995CA829536BB800D1BD94 /* ScriptWidgetElementView.swift in Sources */, @@ -963,6 +1040,7 @@ D1995CBC29536BB800D1BD94 /* ScriptWidgetAttributeShadowModifier.swift in Sources */, D1995CA629536BB800D1BD94 /* ScriptWidgetAppState.swift in Sources */, D1995CFB29536BC700D1BD94 /* ScriptModel.swift in Sources */, + D1A0000000E0000000001001 /* ScriptMetadata.swift in Sources */, F29118E82792CCDE00B860B0 /* PreviewWidgetSize.swift in Sources */, F29117E8279101DF00B860B0 /* ScriptWidgetMacApp.swift in Sources */, D1995CDA29536BB800D1BD94 /* ScriptWidgetElementTagText.swift in Sources */, @@ -1005,6 +1083,7 @@ D1995CE129536BB800D1BD94 /* ScriptWidgetElementTagGauge.swift in Sources */, D1995CAB29536BB800D1BD94 /* ScriptWidgetRuntimeElement.swift in Sources */, D1995CFC29536BC700D1BD94 /* ScriptModel.swift in Sources */, + D1A0000000E0000000001002 /* ScriptMetadata.swift in Sources */, D1995CB129536BB800D1BD94 /* ScriptWidgetAttributeClippedModifier.swift in Sources */, D1995C9D29536BB800D1BD94 /* MineRingView.swift in Sources */, D1995CF929536BC700D1BD94 /* ScriptWidgetPackage.swift in Sources */, @@ -1371,12 +1450,12 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - D1995D0729536CCE00D1BD94 /* XCRemoteSwiftPackageReference "vapor" */ = { + A206000100000000000000B0 /* XCRemoteSwiftPackageReference "SwiftOpenAI" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/vapor/vapor.git"; + repositoryURL = "https://github.com/jamesrochabrun/SwiftOpenAI"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 4.0.0; + minimumVersion = 4.4.9; }; }; F29118232791D38E00B860B0 /* XCRemoteSwiftPackageReference "SwiftyJSON" */ = { @@ -1406,10 +1485,10 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - D1995D0829536CCE00D1BD94 /* Vapor */ = { + A206000200000000000000B0 /* SwiftOpenAI */ = { isa = XCSwiftPackageProductDependency; - package = D1995D0729536CCE00D1BD94 /* XCRemoteSwiftPackageReference "vapor" */; - productName = Vapor; + package = A206000100000000000000B0 /* XCRemoteSwiftPackageReference "SwiftOpenAI" */; + productName = SwiftOpenAI; }; F29118242791D38E00B860B0 /* SwiftyJSON */ = { isa = XCSwiftPackageProductDependency; diff --git a/macOS/ScriptWidgetMac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macOS/ScriptWidgetMac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 829e13a..decd854 100644 --- a/macOS/ScriptWidgetMac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/macOS/ScriptWidgetMac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,49 +1,13 @@ { - "originHash" : "0e07b28be0ac117755c66dd76199d83f8117b8aedf072fbdd7130daf68cbffb1", + "originHash" : "22b18168779edad97d27dab9485f3dffdc3fd115c971a0b2661304cf4d096eae", "pins" : [ { "identity" : "async-http-client", "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/async-http-client.git", "state" : { - "revision" : "5bee16a79922e3efcb5cea06ecd27e6f8048b56b", - "version" : "1.13.1" - } - }, - { - "identity" : "async-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/async-kit.git", - "state" : { - "revision" : "929808e51fea04f01de0e911ce826ef70c4db4ea", - "version" : "1.15.0" - } - }, - { - "identity" : "console-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/console-kit.git", - "state" : { - "revision" : "a7e67a1719933318b5ab7eaaed355cde020465b1", - "version" : "4.5.0" - } - }, - { - "identity" : "multipart-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/multipart-kit.git", - "state" : { - "revision" : "0d55c35e788451ee27222783c7d363cb88092fab", - "version" : "4.5.2" - } - }, - { - "identity" : "routing-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/routing-kit.git", - "state" : { - "revision" : "ffac7b3a127ce1e85fb232f1a6271164628809ad", - "version" : "4.6.0" + "revision" : "333f51104b75d1a5b94cb3b99e4c58a3b442c9f7", + "version" : "1.25.2" } }, { @@ -78,17 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { - "revision" : "ff3d2212b6b093db7f177d0855adbc4ef9c5f036", - "version" : "1.0.3" - } - }, - { - "identity" : "swift-backtrace", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/swift-backtrace.git", - "state" : { - "revision" : "f25620d5d05e2f1ba27154b40cafea2b67566956", - "version" : "1.3.3" + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" } }, { @@ -96,17 +51,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", - "version" : "1.0.4" + "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", + "version" : "1.4.1" } }, { - "identity" : "swift-crypto", + "identity" : "swift-http-types", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-crypto.git", + "location" : "https://github.com/apple/swift-http-types", "state" : { - "revision" : "92a04c10fc5ce0504f8396aac7392126033e547c", - "version" : "2.2.2" + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" } }, { @@ -114,17 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "6fe203dc33195667ce1759bf0182975e4653ba1c", - "version" : "1.4.4" - } - }, - { - "identity" : "swift-metrics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-metrics.git", - "state" : { - "revision" : "9b39d811a83cf18b79d7d5513b06f8b290198b10", - "version" : "2.3.3" + "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", + "version" : "1.12.0" } }, { @@ -132,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "7e3b50b38e4e66f31db6cf4a784c6af148bac846", - "version" : "2.46.0" + "revision" : "cd6710454f25733900e133c6caf5188952763c36", + "version" : "2.98.0" } }, { @@ -141,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-extras.git", "state" : { - "revision" : "91dd2d61fb772e1311bb5f13b59266b579d77e42", - "version" : "1.15.0" + "revision" : "2e9746cfc57554f70b650b021b6ae4738abef3e6", + "version" : "1.24.1" } }, { @@ -150,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-http2.git", "state" : { - "revision" : "d6656967f33ed8b368b38e4b198631fc7c484a40", - "version" : "1.23.1" + "revision" : "81cc18264f92cd307ff98430f89372711d4f6fe9", + "version" : "1.43.0" } }, { @@ -159,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-ssl.git", "state" : { - "revision" : "4fb7ead803e38949eb1d6fabb849206a72c580f3", - "version" : "2.23.0" + "revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da", + "version" : "2.37.0" } }, { @@ -168,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { - "revision" : "c0d9a144cfaec8d3d596aadde3039286a266c15c", - "version" : "1.15.0" + "revision" : "3c394067c08d1225ba8442e9cffb520ded417b64", + "version" : "1.23.1" } }, { @@ -182,30 +128,30 @@ } }, { - "identity" : "swiftyjson", + "identity" : "swift-system", "kind" : "remoteSourceControl", - "location" : "https://github.com/SwiftyJSON/SwiftyJSON.git", + "location" : "https://github.com/apple/swift-system.git", "state" : { - "revision" : "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07", - "version" : "5.0.1" + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" } }, { - "identity" : "vapor", + "identity" : "swiftopenai", "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/vapor.git", + "location" : "https://github.com/jamesrochabrun/SwiftOpenAI", "state" : { - "revision" : "eb2da0d749e185789970c32f7fd9c114a339fa13", - "version" : "4.67.5" + "revision" : "bc6b84767c3a4eb9d48942b86e2417a229ef096c", + "version" : "4.4.9" } }, { - "identity" : "websocket-kit", + "identity" : "swiftyjson", "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/websocket-kit.git", + "location" : "https://github.com/SwiftyJSON/SwiftyJSON.git", "state" : { - "revision" : "2d9d2188a08eef4a869d368daab21b3c08510991", - "version" : "2.6.1" + "revision" : "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07", + "version" : "5.0.1" } }, { diff --git a/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift b/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift new file mode 100644 index 0000000..7053f3a --- /dev/null +++ b/macOS/ScriptWidgetMac/AIGenerate/AIGenerateWindowView.swift @@ -0,0 +1,376 @@ +// +// AIGenerateWindowView.swift +// ScriptWidgetMac +// +// The AI Generate experience for macOS, hosted inside a sheet. Shows +// prompt input, progress, and — once a widget is produced — an inline +// preview with refine / discard / save actions. +// + +import SwiftUI +import WidgetKit + +struct AIGenerateWindowView: View { + static let openRequestNotification = Notification.Name("AIGenerateWindowViewOpenRequest") + + @Environment(\.dismiss) private var dismiss + + @StateObject private var session = AIGenerateSession() + + @State private var prompt: String = "" + @State private var refineInstruction: String = "" + @State private var saveName: String = "" + @State private var saveError: String? + @State private var showingCode: Bool = false + @State private var showingLogs: Bool = false + @State private var isDebugMode: Bool = false + @State private var previewPackage: ScriptWidgetPackage? + + private var jsx: String { session.lastJSX ?? "" } + private var hasResult: Bool { + switch session.phase { + case .done, .exhausted: return true + default: return false + } + } + private var isExhausted: Bool { + if case .exhausted = session.phase { return true } + return false + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + HSplitView { + inputSide + .frame(minWidth: 300, idealWidth: 380) + previewSide + .frame(minWidth: 300, idealWidth: 420) + } + Divider() + footer + } + .frame(idealWidth: 860, minHeight: 520, idealHeight: 620) + .frame(minWidth: 720) + .onAppear { + ensurePreviewPackage() + prefillSaveNameIfNeeded() + } + .onChange(of: jsx) { _ in + refreshPreviewPackage() + prefillSaveNameIfNeeded() + } + .sheet(isPresented: $showingCode) { codeSheet } + .sheet(isPresented: $showingLogs) { logsSheet } + .alert("Save Failed", isPresented: saveErrorBinding) { + Button("OK") { saveError = nil } + } message: { + Text(saveError ?? "") + } + } + + // MARK: - layout + + private var header: some View { + HStack(spacing: 10) { + Image(systemName: "sparkles") + .font(.title2) + .foregroundStyle(.tint) + VStack(alignment: .leading, spacing: 2) { + Text("AI Widget Generator").font(.title3.weight(.semibold)) + Text("Describe what you want; the AI will iterate until the widget runs.") + .font(.footnote) + .foregroundStyle(.secondary) + } + Spacer() + Button("Close") { dismiss() } + .keyboardShortcut(.cancelAction) + } + .padding(12) + } + + private var inputSide: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text("Prompt").font(.headline) + TextEditor(text: $prompt) + .font(.body) + .frame(minHeight: 140) + .border(Color.secondary.opacity(0.3)) + + examplesSection + + HStack { + Text("Size") + Picker("", selection: $session.size) { + ForEach(AIWidgetSize.allCases) { size in + Text(size.displayName).tag(size) + } + } + .labelsHidden() + } + + Button { + session.start(userDescription: prompt) + } label: { + HStack { + Image(systemName: "sparkles") + Text(session.isRunning ? "Generating..." : "Generate") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(session.isRunning || prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + if session.isRunning || hasResult { + AIGenerateProgressView(session: session) + } + + if hasResult { + Divider().padding(.vertical, 4) + Text("Refine").font(.headline) + Text("Ask the AI to change something — it will iterate again on top of the current code.") + .font(.caption) + .foregroundStyle(.secondary) + HStack { + TextField("e.g. use a darker background", text: $refineInstruction) + .textFieldStyle(.roundedBorder) + Button { + let instruction = refineInstruction + refineInstruction = "" + session.refine(currentCode: jsx, refineInstruction: instruction) + } label: { + Image(systemName: "arrow.right.circle.fill") + } + .disabled(jsx.isEmpty || refineInstruction.trimmingCharacters(in: .whitespaces).isEmpty || session.isRunning) + } + } + } + .padding(12) + } + } + + private var previewSide: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Preview").font(.headline) + Spacer() + Toggle("Debug", isOn: $isDebugMode) + .toggleStyle(.switch) + .controlSize(.small) + } + + if isExhausted { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + Text("Did not fully converge — showing the last attempt.") + .font(.caption) + } + .foregroundStyle(.orange) + } + + ZStack { + Rectangle().fill(Color.secondary.opacity(0.15)) + previewContent + } + .frame(maxWidth: .infinity) + .frame(height: 360) + .cornerRadius(12) + + HStack { + Button { showingCode = true } label: { + Label("Code", systemImage: "curlybraces") + } + Button { showingLogs = true } label: { + Label("Logs", systemImage: "text.alignleft") + } + Spacer() + } + .disabled(jsx.isEmpty) + } + .padding(12) + } + + private var examplesSection: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Try an example") + .font(.caption) + .foregroundStyle(.secondary) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(AIExamplePrompts.all) { example in + Button { + prompt = example.prompt + session.size = example.size + } label: { + HStack(spacing: 4) { + Image(systemName: example.symbol) + Text(example.title) + } + .font(.caption) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + } + } + + @ViewBuilder + private var previewContent: some View { + let size = session.size.previewSize + if let element = session.resultElement, let pkg = previewPackage { + let context = ScriptWidgetElementContext( + runtime: nil, + debugMode: isDebugMode, + scriptName: "AI Preview", + scriptParameter: "", + package: pkg + ) + ScriptWidgetElementView(element: element, context: context) + .frame(width: size.width, height: size.height) + .background(Color(NSColor.textBackgroundColor)) + .cornerRadius(session.size.previewIsCircular ? size.height / 2 : 10) + } else { + Text(session.isRunning ? "Generating..." : "No preview yet") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + private var footer: some View { + HStack { + Text("Tokens used: \(session.usage.totalTokens)") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button(role: .destructive) { + dismiss() + } label: { + Label("Discard", systemImage: "trash") + } + + TextField("Widget name", text: $saveName) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 200) + + Button { + performSave() + } label: { + Label("Save Widget", systemImage: "square.and.arrow.down") + } + .keyboardShortcut(.defaultAction) + .disabled(jsx.isEmpty || saveName.trimmingCharacters(in: .whitespaces).isEmpty) + } + .padding(12) + } + + @ViewBuilder + private var codeSheet: some View { + VStack(alignment: .leading) { + HStack { + Text("Generated JSX").font(.headline) + Spacer() + Button("Done") { showingCode = false } + .keyboardShortcut(.defaultAction) + } + ScrollView { + Text(jsx) + .font(.system(.footnote, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .textSelection(.enabled) + } + } + .padding(12) + .frame(minWidth: 520, minHeight: 420) + } + + @ViewBuilder + private var logsSheet: some View { + VStack(alignment: .leading) { + HStack { + Text("Iteration Logs").font(.headline) + Spacer() + Button("Done") { showingLogs = false } + .keyboardShortcut(.defaultAction) + } + List { + ForEach(session.iterationHistory) { record in + Section("Iteration \(record.iteration)") { + if let err = record.errorSummary { + Label(err, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } else { + Label("Success", systemImage: "checkmark.circle") + .foregroundStyle(.green) + } + if !record.logs.isEmpty { + ForEach(Array(record.logs.enumerated()), id: \.offset) { _, line in + Text(line) + .font(.caption.monospaced()) + .textSelection(.enabled) + } + } + } + } + } + } + .padding(12) + .frame(minWidth: 520, minHeight: 420) + } + + // MARK: - helpers + + private var saveErrorBinding: Binding { + Binding( + get: { saveError != nil }, + set: { if !$0 { saveError = nil } } + ) + } + + private func ensurePreviewPackage() { + if previewPackage == nil { + previewPackage = try? AgentRuntimeBridge.shared.makeSandboxPackage(prefix: "preview") + } + refreshPreviewPackage() + } + + private func refreshPreviewPackage() { + guard let pkg = previewPackage else { return } + _ = pkg.writeMainFile(content: jsx) + } + + private func prefillSaveNameIfNeeded() { + guard saveName.trimmingCharacters(in: .whitespaces).isEmpty else { return } + saveName = "AI " + AIGenerateWindowView.defaultNameFormatter.string(from: Date()) + } + + private static let defaultNameFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd HHmm" + return f + }() + + private func performSave() { + let trimmed = saveName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + saveError = "Widget name can not be empty." + return + } + let result = sharedScriptManager.createScript( + content: jsx, + recommendPackageName: trimmed, + imageCopyPath: nil + ) + if result.0 { + NotificationCenter.default.post(name: SharedAppStore.scriptCreateNotification, object: nil) + WidgetCenter.shared.reloadAllTimelines() + dismiss() + } else { + saveError = result.1 + } + } +} diff --git a/macOS/ScriptWidgetMac/App/AppDelegate.swift b/macOS/ScriptWidgetMac/App/AppDelegate.swift index 05f5a37..f78d8a1 100644 --- a/macOS/ScriptWidgetMac/App/AppDelegate.swift +++ b/macOS/ScriptWidgetMac/App/AppDelegate.swift @@ -11,8 +11,8 @@ import AppKit class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { print("did finish launching") - - runEditorWebService() + // Monaco editor is now served via a WKURLSchemeHandler — no + // local HTTP server needed. } diff --git a/macOS/ScriptWidgetMac/App/EmptyHelloView.swift b/macOS/ScriptWidgetMac/App/EmptyHelloView.swift index 0343775..51b575f 100644 --- a/macOS/ScriptWidgetMac/App/EmptyHelloView.swift +++ b/macOS/ScriptWidgetMac/App/EmptyHelloView.swift @@ -2,23 +2,214 @@ // EmptyHelloView.swift // ScriptWidgetMac // -// Created by everettjf on 2022/2/26. +// Onboarding / landing pane shown when no widget is selected. // import SwiftUI +import AppKit + +class MacOnboardingFeaturedDataObject: ObservableObject { + @Published var featured: [ScriptModel] = [] + + init() { + DispatchQueue.global().async { [weak self] in + let all = ScriptManager.listBundleScripts(bundle: "Script", relativePath: "template") + let picked = all.filter { $0.isFeatured } + DispatchQueue.main.async { + self?.featured = picked + } + } + } +} struct EmptyHelloView: View { + @StateObject private var data = MacOnboardingFeaturedDataObject() + @State private var showCreate = false + var body: some View { - VStack (spacing: 20) { - Image(systemName: "lessthan") - .font(.system(size: 70, weight: .bold, design: .monospaced)) - - Text("Hello ScriptWidget :)") + ScrollView { + VStack(alignment: .leading, spacing: 28) { + hero + howItWorks + if !data.featured.isEmpty { + featured + } + createRow + } + .padding(32) + .frame(maxWidth: 720, alignment: .leading) + .frame(maxWidth: .infinity) + } + .frame(minWidth: 480, minHeight: 400) + .sheet(isPresented: $showCreate) { + CreateGuideView() + } + } + + private var hero: some View { + VStack(alignment: .leading, spacing: 12) { + Image(systemName: "sparkles.square.filled.on.square") + .font(.system(size: 52)) + .foregroundStyle( + LinearGradient(colors: [.purple, .blue], + startPoint: .topLeading, + endPoint: .bottomTrailing) + ) + Text("Build widgets with JavaScript") + .font(.title).bold() + Text("Pick a template, preview it instantly on your desktop, then add it anywhere widgets go.") + .font(.body) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var howItWorks: some View { + VStack(alignment: .leading, spacing: 12) { + Text("How it works") + .font(.headline) + HStack(alignment: .top, spacing: 14) { + MacOnboardingStep(number: 1, icon: "square.grid.2x2.fill", title: "Pick", detail: "Choose a ready template.") + MacOnboardingStep(number: 2, icon: "play.rectangle.fill", title: "Preview", detail: "Live preview in the editor.") + MacOnboardingStep(number: 3, icon: "rectangle.stack.badge.plus", title: "Install", detail: "Add to Notification Center or Mac home.") + } + } + } + + private var featured: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Start with one of these") .font(.headline) - .fontWeight(.bold) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 260), spacing: 10)], spacing: 10) { + ForEach(data.featured) { item in + Button { + createFromTemplate(item) + } label: { + MacFeaturedRow(model: item) + } + .buttonStyle(.plain) + } + } + } + } + + private var createRow: some View { + HStack(spacing: 10) { + Button { + showCreate = true + } label: { + Label("Browse all templates", systemImage: "square.grid.2x2") + } + .controlSize(.large) + .buttonStyle(.borderedProminent) + + Button { + NotificationCenter.default.post( + name: AIGenerateWindowView.openRequestNotification, + object: nil + ) + } label: { + Label("Generate with AI", systemImage: "sparkles") + } + .controlSize(.large) + } + } + + private func createFromTemplate(_ item: ScriptModel) { + guard let content = item.package.readMainFile().0 else { return } + let result = sharedScriptManager.createScript( + content: content, + recommendPackageName: item.name, + imageCopyPath: item.package.imagePath + ) + if result.0 { + NotificationCenter.default.post(name: SharedAppStore.scriptCreateNotification, object: nil) + } else { + MacKitUtil.alertWarn(title: "Create failed", message: result.1) + } + } +} + +struct MacOnboardingStep: View { + let number: Int + let icon: String + let title: String + let detail: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ZStack { + Circle() + .fill(Color.accentColor.opacity(0.15)) + .frame(width: 38, height: 38) + Image(systemName: icon) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.accentColor) + } + Text("\(number). \(title)") + .font(.subheadline).bold() + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } - .foregroundColor(Color.gray.opacity(0.75)) - .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} + +struct MacFeaturedRow: View { + let model: ScriptModel + + @State private var isHovered = false + + var body: some View { + HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(LinearGradient( + colors: [accent.opacity(0.25), accent.opacity(0.08)], + startPoint: .topLeading, endPoint: .bottomTrailing + )) + .frame(width: 52, height: 52) + Image(systemName: model.iconSystemName) + .font(.system(size: 22)) + .foregroundColor(accent) + } + VStack(alignment: .leading, spacing: 3) { + Text(model.name) + .font(.subheadline).bold() + .foregroundColor(.primary) + if let summary = model.summary { + Text(summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + } + Spacer() + Image(systemName: "plus.circle.fill") + .font(.title3) + .foregroundStyle(accent.opacity(isHovered ? 1.0 : 0.5)) + } + .padding(12) + .background(Color(nsColor: NSColor.controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isHovered ? accent.opacity(0.7) : Color.secondary.opacity(0.2), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .contentShape(Rectangle()) + .onHover { hovering in + isHovered = hovering + } + } + + private var accent: Color { + model.category?.accentColor ?? .accentColor } } diff --git a/macOS/ScriptWidgetMac/App/ScriptWidgetMacApp.swift b/macOS/ScriptWidgetMac/App/ScriptWidgetMacApp.swift index 52904e8..da3fd13 100644 --- a/macOS/ScriptWidgetMac/App/ScriptWidgetMacApp.swift +++ b/macOS/ScriptWidgetMac/App/ScriptWidgetMacApp.swift @@ -9,9 +9,9 @@ import SwiftUI @main struct ScriptWidgetMacApp: App { - + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate; - + var body: some Scene { WindowGroup { ContentView() @@ -20,26 +20,30 @@ struct ScriptWidgetMacApp: App { } .commands { CommandGroup(after: .newItem) { + Button("Generate Widget with AI...") { + NotificationCenter.default.post(name: AIGenerateWindowView.openRequestNotification, object: nil) + }.keyboardShortcut("n", modifiers: [.command, .shift]) + Button("Save") { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { NotificationCenter.default.post(name: EditorService.saveNotification, object: nil, userInfo: nil) NotificationCenter.default.post(name: PreviewService.updateNotification, object: nil, userInfo: nil) } }.keyboardShortcut("s") - + Button("Run") { NotificationCenter.default.post(name: PreviewService.updateNotification, object: nil, userInfo: nil) }.keyboardShortcut("r") - + Button("Open Scripts Directory") { MacKitUtil.revealInFinder(sharedScriptManager.scriptDirectory.path) }.keyboardShortcut("o") - + Button("Update iCloud Scripts") { sharedScriptManager.requestUpdateICloudScripts() }.keyboardShortcut("u") } - + CommandGroup(replacing: .help) { Button("Discord") { NSWorkspace.shared.open(URL(string: "https://discord.gg/eGzEaP6TzR")!) @@ -58,5 +62,9 @@ struct ScriptWidgetMacApp: App { } } } + + Settings { + SettingAIView() + } } } diff --git a/macOS/ScriptWidgetMac/Create/CreateGuideView.swift b/macOS/ScriptWidgetMac/Create/CreateGuideView.swift index 764de81..6c7b7a5 100644 --- a/macOS/ScriptWidgetMac/Create/CreateGuideView.swift +++ b/macOS/ScriptWidgetMac/Create/CreateGuideView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import AppKit let defaultCreateScriptContent = """ @@ -31,57 +32,350 @@ $render( """ +class MacCreateGuideDataObject: ObservableObject { + @Published var models: [ScriptModel] = [] + + init() { + DispatchQueue.global().async { [weak self] in + let items = ScriptManager.listBundleScripts(bundle: "Script", relativePath: "template") + DispatchQueue.main.async { + self?.models = items + } + } + } +} + struct CreateGuideView: View { @Environment(\.dismiss) var dismiss - - @State var enteredText: String = "A New Widget" - + @StateObject private var dataObject = MacCreateGuideDataObject() + + @State private var selectedCategory: ScriptCategory? = nil + @State private var searchText: String = "" + var body: some View { - VStack(alignment:.leading) { - Text("Script name :") - .font(.headline) - - TextField("", text: $enteredText) - - HStack { - Button("Cancel") { - dismiss() + VStack(spacing: 0) { + header + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + aiAndBlankRow + + if searchText.isEmpty { + categoryChips + } + + if filteredModels.isEmpty { + emptyState + .frame(maxWidth: .infinity, minHeight: 180) + } else { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 180), spacing: 12)], spacing: 12) { + ForEach(filteredModels) { item in + MacTemplateCardView(model: item) { + createFromTemplate(item) + } + } + } + } + } + .padding(16) + } + } + .frame(minWidth: 720, idealWidth: 840, minHeight: 540, idealHeight: 620) + } + + private var header: some View { + HStack(spacing: 12) { + Text("New Widget") + .font(.title2).bold() + + Spacer() + + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Search templates", text: $searchText) + .textFieldStyle(.plain) + .frame(minWidth: 180) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 7)) + + Button("Close") { + dismiss() + } + .keyboardShortcut(.cancelAction) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + private var aiAndBlankRow: some View { + HStack(spacing: 12) { + aiCard + blankCard + } + } + + private var aiCard: some View { + Button { + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + NotificationCenter.default.post( + name: AIGenerateWindowView.openRequestNotification, + object: nil + ) + } + } label: { + HStack(spacing: 12) { + Image(systemName: "sparkles") + .font(.title) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + .background(LinearGradient(colors: [.purple, .blue], + startPoint: .topLeading, + endPoint: .bottomTrailing)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + VStack(alignment: .leading, spacing: 2) { + Text("Generate with AI").font(.headline) + Text("Describe your widget and let the AI build it.") + .font(.caption).foregroundStyle(.secondary) } Spacer() - Button("Create") { - - let inputText = enteredText.trim() - if inputText.isEmpty { - MacKitUtil.alertWarn(title: "Invalid name", message: "Name can not be empty") - return + } + .padding(10) + .background(Color.accentColor.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var blankCard: some View { + Button { + createBlank() + } label: { + HStack(spacing: 12) { + Image(systemName: "doc.badge.plus") + .font(.title) + .foregroundStyle(.secondary) + .frame(width: 44, height: 44) + .background(Color.secondary.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + VStack(alignment: .leading, spacing: 2) { + Text("Blank Widget").font(.headline) + Text("Start from an empty template.") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + } + .padding(10) + .background(Color.secondary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var categoryChips: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + MacCategoryChip(title: "All", + systemImage: "square.grid.2x2", + color: .gray, + selected: selectedCategory == nil) { + selectedCategory = nil + } + ForEach(ScriptCategory.allCases) { cat in + MacCategoryChip(title: cat.displayName, + systemImage: cat.systemImage, + color: cat.accentColor, + selected: selectedCategory == cat) { + selectedCategory = (selectedCategory == cat) ? nil : cat } - - if !inputText.checkIfValidFileName() { - MacKitUtil.alertWarn(title: "Invalid name", message: "Please make sure the widget name is an valid file name") - return; + } + } + } + } + + private var emptyState: some View { + VStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 36)) + .foregroundStyle(.secondary) + Text("No templates match").font(.headline) + Text("Try another keyword or category.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + // MARK: - Actions + + private var filteredModels: [ScriptModel] { + let q = searchText.trimmingCharacters(in: .whitespaces).lowercased() + return dataObject.models.filter { model in + if !q.isEmpty { + let haystack = ([model.name, model.summary ?? ""] + model.tags).joined(separator: " ").lowercased() + return haystack.contains(q) + } + guard let selected = selectedCategory else { return true } + return model.category == selected + } + } + + private func createFromTemplate(_ item: ScriptModel) { + guard let content = item.package.readMainFile().0 else { + MacKitUtil.alertWarn(title: "Failed to read template", message: "Please retry or relaunch the app.") + return + } + let result = sharedScriptManager.createScript( + content: content, + recommendPackageName: item.name, + imageCopyPath: item.package.imagePath + ) + if !result.0 { + MacKitUtil.alertWarn(title: "Create failed", message: result.1) + return + } + NotificationCenter.default.post(name: SharedAppStore.scriptCreateNotification, object: nil) + dismiss() + } + + private func createBlank() { + let scriptName = ScriptManager(isBuild: false).getValidPackageName(recommendPackageName: "A New Widget") + let result = sharedScriptManager.createScript( + content: defaultCreateScriptContent, + recommendPackageName: scriptName, + imageCopyPath: nil + ) + if !result.0 { + MacKitUtil.alertWarn(title: "Create failed", message: "Please retry or relaunch app :)\nError : \(result.1)") + return + } + NotificationCenter.default.post(name: SharedAppStore.scriptCreateNotification, object: nil) + dismiss() + } +} + +// MARK: - Mac template card + +struct MacTemplateCardView: View { + let model: ScriptModel + let action: () -> Void + + @State private var isHovered = false + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 8) { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(cardBackground) + + if let url = model.package.previewImageURL(), + let nsImage = NSImage(contentsOfFile: url.path) { + Image(nsImage: nsImage) + .resizable() + .scaledToFill() + .clipShape(RoundedRectangle(cornerRadius: 10)) + } else { + Image(systemName: model.iconSystemName) + .font(.system(size: 30)) + .foregroundColor(accentColor) } - - - // image copy path - // todo - - let scriptName = inputText - let result = sharedScriptManager.createScript(content: defaultCreateScriptContent, recommendPackageName: scriptName, imageCopyPath: nil) - - if !result.0 { - print("Create failed : \(result.1)") - MacKitUtil.alertWarn(title: "Create failed", message: "Please retry or relaunch app :)\nError : \(result.1)") - return + } + .frame(height: 86) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + VStack(alignment: .leading, spacing: 4) { + Text(model.name) + .font(.subheadline.weight(.semibold)) + .foregroundColor(.primary) + .lineLimit(1) + if let summary = model.summary, !summary.isEmpty { + Text(summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + } + if let difficulty = model.difficulty { + MacDifficultyBadge(difficulty: difficulty) + .padding(.top, 2) } - - NotificationCenter.default.post(name: SharedAppStore.scriptCreateNotification, object: nil) - - dismiss() } } + .padding(8) + .background(Color(nsColor: NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isHovered ? accentColor.opacity(0.6) : Color.secondary.opacity(0.2), lineWidth: 1) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in + isHovered = hovering + } + } + + private var accentColor: Color { + model.category?.accentColor ?? .accentColor + } + + private var cardBackground: LinearGradient { + LinearGradient(colors: [accentColor.opacity(0.18), accentColor.opacity(0.06)], + startPoint: .topLeading, endPoint: .bottomTrailing) + } +} + +struct MacCategoryChip: View { + let title: String + let systemImage: String + let color: Color + let selected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + Image(systemName: systemImage).font(.caption) + Text(title).font(.subheadline) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .foregroundColor(selected ? .white : color) + .background(selected ? color : color.opacity(0.12)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } +} + +struct MacDifficultyBadge: View { + let difficulty: ScriptDifficulty + + var body: some View { + Text(difficulty.displayName) + .font(.system(size: 10, weight: .semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .foregroundColor(color) + .background(color.opacity(0.15)) + .clipShape(Capsule()) + } + + private var color: Color { + switch difficulty { + case .beginner: return .green + case .medium: return .orange + case .advanced: return .red } - .frame(width: 300, height: 100) - .padding() } } diff --git a/macOS/ScriptWidgetMac/Editor/Editor/EditorSchemeHandler.swift b/macOS/ScriptWidgetMac/Editor/Editor/EditorSchemeHandler.swift new file mode 100644 index 0000000..d345dc3 --- /dev/null +++ b/macOS/ScriptWidgetMac/Editor/Editor/EditorSchemeHandler.swift @@ -0,0 +1,101 @@ +// +// EditorSchemeHandler.swift +// ScriptWidgetMac +// +// Serves the bundled Monaco editor static files (Editor.bundle/static) +// directly to WKWebView via a custom URL scheme. Replaces the old +// Vapor-based localhost HTTP service. +// + +import Foundation +import WebKit +import UniformTypeIdentifiers + +let kEditorURLScheme = "scriptwidget-editor" + +final class EditorSchemeHandler: NSObject, WKURLSchemeHandler { + + private let staticRoot: URL? + + override init() { + if let bundleURL = Bundle.main.url(forResource: "Editor", withExtension: "bundle") { + self.staticRoot = bundleURL.appendingPathComponent("static", isDirectory: true) + } else { + self.staticRoot = nil + } + super.init() + } + + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + guard let url = urlSchemeTask.request.url else { + urlSchemeTask.didFailWithError(URLError(.badURL)) + return + } + guard let root = staticRoot else { + urlSchemeTask.didFailWithError(URLError(.resourceUnavailable)) + return + } + + var relative = url.path + if relative.hasPrefix("/") { relative.removeFirst() } + if relative.isEmpty { relative = "editor-dark.html" } + + let rootPath = root.standardizedFileURL.path + let candidate = root.appendingPathComponent(relative).standardizedFileURL + // Prevent path traversal — candidate must stay inside staticRoot. + guard candidate.path.hasPrefix(rootPath) else { + urlSchemeTask.didFailWithError(URLError(.noPermissionsToReadFile)) + return + } + + guard let data = try? Data(contentsOf: candidate) else { + urlSchemeTask.didFailWithError(URLError(.fileDoesNotExist)) + return + } + + let mime = Self.mimeType(forPathExtension: candidate.pathExtension) + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": mime, + "Content-Length": "\(data.count)", + "Access-Control-Allow-Origin": "*", + ] + ) ?? URLResponse(url: url, mimeType: mime, expectedContentLength: data.count, textEncodingName: nil) as URLResponse + + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { + // Nothing to cancel — responses are synchronous. + } + + private static func mimeType(forPathExtension ext: String) -> String { + let lower = ext.lowercased() + switch lower { + case "html", "htm": return "text/html; charset=utf-8" + case "js", "mjs": return "application/javascript; charset=utf-8" + case "css": return "text/css; charset=utf-8" + case "json": return "application/json; charset=utf-8" + case "map": return "application/json; charset=utf-8" + case "svg": return "image/svg+xml" + case "png": return "image/png" + case "jpg", "jpeg": return "image/jpeg" + case "gif": return "image/gif" + case "ttf": return "font/ttf" + case "otf": return "font/otf" + case "woff": return "font/woff" + case "woff2": return "font/woff2" + case "wasm": return "application/wasm" + default: + if let type = UTType(filenameExtension: lower), let mime = type.preferredMIMEType { + return mime + } + return "application/octet-stream" + } + } +} diff --git a/macOS/ScriptWidgetMac/Editor/Editor/EditorWebSevice.swift b/macOS/ScriptWidgetMac/Editor/Editor/EditorWebSevice.swift index d4479cf..e828b56 100644 --- a/macOS/ScriptWidgetMac/Editor/Editor/EditorWebSevice.swift +++ b/macOS/ScriptWidgetMac/Editor/Editor/EditorWebSevice.swift @@ -2,65 +2,14 @@ // EditorWebSevice.swift // ScriptWidgetMac // -// Created by everettjf on 2022/1/15. +// Resolves the URL that the Monaco editor WKWebView loads. The assets +// themselves are served from the app bundle via EditorSchemeHandler — +// no localhost HTTP server is required. // import Foundation -import Vapor - -let kEditorWebServiceHost = "127.0.0.1" -let kEditorWebServicePort = 23355 func editorWebServiceUrl() -> String { - var editorName = "" - if MacKitUtil.isSystemThemeDark() { - editorName = "editor-dark.html" - } else { - editorName = "editor-light.html" - } - return "http://\(kEditorWebServiceHost):\(kEditorWebServicePort)/\(editorName)" -} - -func editorWebServiceRoutes(_ app: Application) throws { - app.get("") { req in - return "ScriptWidget Editor Service" - } -} - -// configures your application -public func editorWebServiceAppConfigure(_ app: Application) throws { - // port - app.http.server.configuration.hostname = kEditorWebServiceHost - app.http.server.configuration.port = kEditorWebServicePort - - // serve static files - let resourceDir = Bundle.main.url(forResource: "Editor", withExtension: "bundle") - if let staticDir = resourceDir?.appendingPathComponent("static") { - print("static dir = \(staticDir.path)") - app.middleware.use(FileMiddleware(publicDirectory: staticDir.path)) - } - - // register routes - try editorWebServiceRoutes(app) -} - -func internalRunWebService() { - do { - var env = try Environment.detect() - try LoggingSystem.bootstrap(from: &env) - - let app = Application(env) - defer { app.shutdown() } - - try editorWebServiceAppConfigure(app) - try app.run() - } catch { - print("exception \(error)") - } -} - -func runEditorWebService() { - DispatchQueue.global().async { - internalRunWebService() - } + let editorName = MacKitUtil.isSystemThemeDark() ? "editor-dark.html" : "editor-light.html" + return "\(kEditorURLScheme)://editor/\(editorName)" } diff --git a/macOS/ScriptWidgetMac/Editor/Editor/EditorWebView.swift b/macOS/ScriptWidgetMac/Editor/Editor/EditorWebView.swift index 6ca40ce..36775af 100644 --- a/macOS/ScriptWidgetMac/Editor/Editor/EditorWebView.swift +++ b/macOS/ScriptWidgetMac/Editor/Editor/EditorWebView.swift @@ -27,8 +27,10 @@ class EditorInternalWebView: WKWebView { } init() { - super.init(frame: .zero, configuration: WKWebViewConfiguration()) - + let config = WKWebViewConfiguration() + config.setURLSchemeHandler(EditorSchemeHandler(), forURLScheme: kEditorURLScheme) + super.init(frame: .zero, configuration: config) + self.setValue(false, forKey: "drawsBackground") self.bridge = WKWebViewJavascriptBridge(webView: self) diff --git a/macOS/ScriptWidgetMac/Settings/SettingAIView.swift b/macOS/ScriptWidgetMac/Settings/SettingAIView.swift new file mode 100644 index 0000000..d7ca661 --- /dev/null +++ b/macOS/ScriptWidgetMac/Settings/SettingAIView.swift @@ -0,0 +1,166 @@ +// +// SettingAIView.swift +// ScriptWidgetMac +// +// macOS-flavored AI configuration panel, hosted inside the standard +// Settings scene (Cmd+,). +// + +import SwiftUI + +struct SettingAIView: View { + @State private var apiKey: String = "" + @State private var baseURL: String = AISettings.defaultBaseURL + @State private var model: String = AISettings.defaultModel + @State private var maxIterations: Int = AISettings.defaultMaxIterations + @State private var temperature: Double = AISettings.defaultTemperature + @State private var apiKeyVisible: Bool = false + + @State private var testPhase: TestPhase = .idle + @State private var testMessage: String = "" + + private enum TestPhase { case idle, running, success, failure } + + private let modelPresets = ["gpt-4o-mini", "gpt-4o", "gpt-4.1-mini", "o4-mini"] + + var body: some View { + Form { + Section("API Key") { + HStack { + Group { + if apiKeyVisible { + TextField("sk-...", text: $apiKey) + } else { + SecureField("sk-...", text: $apiKey) + } + } + .textFieldStyle(.roundedBorder) + + Button { + apiKeyVisible.toggle() + } label: { + Image(systemName: apiKeyVisible ? "eye.slash" : "eye") + } + .buttonStyle(.borderless) + } + Text("Stored in plain-text UserDefaults on this device. Do not configure on a shared device.") + .font(.caption) + .foregroundColor(.orange) + } + + Section("Endpoint") { + TextField("https://api.openai.com", text: $baseURL) + .textFieldStyle(.roundedBorder) + } + + Section("Model") { + TextField("gpt-4o-mini", text: $model) + .textFieldStyle(.roundedBorder) + HStack(spacing: 6) { + ForEach(modelPresets, id: \.self) { preset in + Button(preset) { model = preset } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + + Section("Agent Loop") { + Stepper(value: $maxIterations, in: 5...100, step: 5) { + Text("Max Iterations: \(maxIterations)") + } + HStack { + Text("Temperature") + Slider(value: $temperature, in: 0.0...1.5, step: 0.05) + Text(String(format: "%.2f", temperature)) + .monospacedDigit() + .frame(width: 50, alignment: .trailing) + } + } + + Section("Connection") { + HStack { + Button { + runTest() + } label: { + HStack { + if testPhase == .running { + ProgressView().controlSize(.small) + } + Text("Test Connection") + } + } + .disabled(testPhase == .running || apiKey.trimmingCharacters(in: .whitespaces).isEmpty) + + if !testMessage.isEmpty { + Text(testMessage) + .font(.footnote) + .foregroundStyle(testPhase == .failure ? Color.red : Color.green) + } + Spacer() + } + } + + Section { + HStack { + Spacer() + Button { + persist() + } label: { + Label("Save", systemImage: "checkmark.circle") + } + .keyboardShortcut(.defaultAction) + } + } + } + .formStyle(.grouped) + .padding(12) + .frame(minWidth: 520, minHeight: 520) + .onAppear(perform: loadFromStore) + } + + private func loadFromStore() { + let s = AISettingsStore.shared.load() + apiKey = s.apiKey + baseURL = s.baseURL + model = s.model + maxIterations = s.maxIterations + temperature = s.temperature + } + + private func persist() { + let settings = AISettings( + apiKey: apiKey.trimmingCharacters(in: .whitespacesAndNewlines), + baseURL: baseURL.trimmingCharacters(in: .whitespacesAndNewlines), + model: model.trimmingCharacters(in: .whitespacesAndNewlines), + maxIterations: maxIterations, + temperature: temperature + ) + AISettingsStore.shared.save(settings) + } + + private func runTest() { + persist() + let settings = AISettingsStore.shared.load() + testPhase = .running + testMessage = "" + Task { + do { + let messages = [ + AIMessage(role: .system, content: "You reply with exactly: pong"), + AIMessage(role: .user, content: "ping"), + ] + let result = try await AIClient.shared.chat(messages: messages, settings: settings) + await MainActor.run { + testPhase = .success + testMessage = "OK — \(result.content.prefix(60)) (\(result.usage.totalTokens) tokens)" + } + } catch { + await MainActor.run { + testPhase = .failure + testMessage = error.localizedDescription + } + } + } + } +} diff --git a/macOS/ScriptWidgetMac/Sidebar/EmptyListBackgroundView.swift b/macOS/ScriptWidgetMac/Sidebar/EmptyListBackgroundView.swift index dcf0bb8..7754ff1 100644 --- a/macOS/ScriptWidgetMac/Sidebar/EmptyListBackgroundView.swift +++ b/macOS/ScriptWidgetMac/Sidebar/EmptyListBackgroundView.swift @@ -2,22 +2,25 @@ // EmptyListBackgroundView.swift // ScriptWidgetMac // -// Created by everettjf on 2022/1/15. +// Compact empty state shown inside the sidebar when no widgets exist. // - import SwiftUI struct EmptyListBackgroundView: View { var body: some View { - VStack (spacing: 5) { - Text("Create your first widget by tapping the plus button upper-right :)") - .multilineTextAlignment(.leading) - .lineLimit(10) - .font(.headline) + VStack(alignment: .leading, spacing: 6) { + Image(systemName: "sparkles") + .font(.system(size: 20)) + .foregroundStyle(.secondary) + Text("No widgets yet") + .font(.subheadline).bold() + Text("Tap the + button above to browse templates or generate with AI.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } - .foregroundColor(Color.gray.opacity(0.75)) - .padding() + .padding(.vertical, 8) } } diff --git a/macOS/ScriptWidgetMac/Sidebar/SidebarView.swift b/macOS/ScriptWidgetMac/Sidebar/SidebarView.swift index edd5c6e..b60b642 100644 --- a/macOS/ScriptWidgetMac/Sidebar/SidebarView.swift +++ b/macOS/ScriptWidgetMac/Sidebar/SidebarView.swift @@ -14,17 +14,21 @@ struct SidebarView: View { @ObservedObject var store: SharedAppStore // create @State private var createShowingSheet = false - + + // AI generate + @State private var aiGenerateShowingSheet = false + @State private var aiConfigAlertShown = false + // rename @State private var renameCurrentName = "" @State private var renameInputName = "" @State private var renameShowingSheet = false - + // delete @State private var deleteCurrentName = "" @State private var deleteShowingSheet = false - - + + var body: some View { content .frame(minWidth:200, maxWidth: 300, idealHeight: 250) @@ -37,6 +41,21 @@ struct SidebarView: View { .sheet(isPresented: $createShowingSheet) { CreateGuideView() } + .sheet(isPresented: $aiGenerateShowingSheet) { + AIGenerateWindowView() + } + .onReceive(NotificationCenter.default.publisher(for: AIGenerateWindowView.openRequestNotification)) { _ in + if AISettingsStore.shared.load().isConfigured { + aiGenerateShowingSheet = true + } else { + aiConfigAlertShown = true + } + } + .alert("Configure AI First", isPresented: $aiConfigAlertShown) { + Button("OK", role: .cancel) { } + } message: { + Text("Open Settings (⌘,) → AI to add your OpenAI API key, then come back to generate with AI.") + } .toolbar { ToolbarItem(placement: .automatic) { Button{ @@ -45,12 +64,25 @@ struct SidebarView: View { Image(systemName: "sidebar.left") } } + ToolbarItem(placement: .automatic) { + Button { + if AISettingsStore.shared.load().isConfigured { + self.aiGenerateShowingSheet = true + } else { + self.aiConfigAlertShown = true + } + } label: { + Label("Generate with AI", systemImage: "wand.and.stars") + } + .help("Generate with AI (⌘⇧N)") + } ToolbarItem(placement: .automatic) { Button { self.createShowingSheet.toggle() } label: { Image(systemName: "plus.circle") } + .help("New widget") } } } @@ -74,6 +106,14 @@ struct SidebarView: View { Button("Update") { item.package.updateFiles() } + Button("Remix (Duplicate)") { + let result = sharedScriptManager.duplicateScript(sourcePackageName: item.name) + if result.0 { + NotificationCenter.default.post(name: SharedAppStore.scriptCreateNotification, object: nil) + } else { + MacKitUtil.alertWarn(title: "Remix failed", message: result.1) + } + } Button("Rename") { self.renameCurrentName = item.name self.renameInputName = item.name