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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Editor/editorfe/src/scriptWidgetAPI.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 46 additions & 0 deletions Scripts/ai-quality-images.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Local screenshot inspection only. OCR is a review signal, not a visual-quality proof.
import Foundation
import Vision
import ImageIO

struct Sample: Decodable {
let id: String
let attempt: Int
let expectedText: [String]?
let screenshots: [String]
}
struct Observation: Encodable {
let image: String
let recognizedText: String
let unrecognizedExpectedText: [String]
}
func normalized(_ text: String) -> String {
String(text.lowercased().unicodeScalars.filter { CharacterSet.alphanumerics.contains($0) })
}
guard CommandLine.arguments.count == 2 else {
fatalError("Usage: swift Scripts/ai-quality-images.swift /absolute/artifact/directory")
}
let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
let samples = try JSONDecoder().decode([Sample].self, from: Data(contentsOf: directory.appendingPathComponent("quality.json")))
var observations: [Observation] = []
for sample in samples {
for filename in sample.screenshots {
// Only inspect locally generated flat PNG names, never arbitrary report paths.
guard filename == URL(fileURLWithPath: filename).lastPathComponent,
!filename.contains(".."), filename.hasSuffix(".png") else { fatalError("Invalid screenshot filename") }
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
let hasChinese = (sample.expectedText ?? []).joined().unicodeScalars.contains { (0x4E00...0x9FFF).contains($0.value) }
request.recognitionLanguages = hasChinese ? ["zh-Hans", "en-US"] : ["en-US"]
request.automaticallyDetectsLanguage = true
request.usesLanguageCorrection = false
try VNImageRequestHandler(url: directory.appendingPathComponent(filename)).perform([request])
let text = (request.results ?? []).compactMap { $0.topCandidates(1).first?.string }.joined(separator: " ")
let absent = (sample.expectedText ?? []).filter { !normalized(text).contains(normalized($0)) }
observations.append(.init(image: filename, recognizedText: text, unrecognizedExpectedText: absent))
}
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try encoder.encode(observations).write(to: directory.appendingPathComponent("vision.json"), options: .atomic)
print("OCR inspected \(observations.count) screenshots; \(observations.filter { !$0.unrecognizedExpectedText.isEmpty }.count) need review.")
29 changes: 29 additions & 0 deletions Scripts/ai-quality-report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Make an offline, inspectable visual report; no uploads or external resources."""
import html
import json
import pathlib
import sys

root = pathlib.Path(sys.argv[1]).resolve()
samples = json.loads((root / "quality.json").read_text())
vision_path = root / "vision.json"
vision = {x["image"]: x for x in json.loads(vision_path.read_text())} if vision_path.exists() else {}
config_path = root / "config.json"
config = json.loads(config_path.read_text()) if config_path.exists() else {"status": "run incomplete"}
escape = lambda value: html.escape(str(value), quote=True)
cards = []
for item in samples:
figures = []
for filename in item["screenshots"]:
if pathlib.Path(filename).name != filename or ".." in filename or not filename.endswith(".png"):
raise ValueError("Invalid screenshot filename")
finding = vision.get(filename, {})
missing = finding.get("unrecognizedExpectedText", [])
review = ", ".join(missing) or ("no missing expected text flagged" if finding else "not run")
figures.append(f'<figure><img loading="lazy" src="{escape(filename)}" alt="{escape(filename)}"><figcaption>{escape(filename)}<br>OCR review: {escape(review)}</figcaption></figure>')
cards.append(f'<article><h2>{escape(item["id"])} · attempt {item["attempt"]}</h2><p>{escape(item["prompt"])}</p><p>Runtime: {item["runtimePassed"]} · Missing text: {escape(item["missingText"])} · Missing components: {escape(item.get("missingComponents", []))} · Tokens: {item["tokens"]}</p><div class="images">{"".join(figures)}</div></article>')
page = '''<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ScriptWidget AI quality review</title><style>body{font:16px system-ui;margin:32px;background:#eef1f5;color:#17202e}main{max-width:1400px;margin:auto}article{background:white;border-radius:16px;padding:24px;margin:24px 0}h1,h2{line-height:1.2}.images{display:flex;gap:24px;align-items:start;flex-wrap:wrap}figure{margin:0;max-width:100%;flex:1 1 320px}img{max-width:100%;height:auto;border:1px solid #aaa}figcaption{font-size:13px;margin-top:8px;overflow-wrap:anywhere}code{white-space:pre-wrap}</style><main><h1>ScriptWidget AI quality review</h1><p>Native macOS snapshots of synthetic offline prompts. Runtime/text checks do not prove readability. OCR flags require visual review; it can miss small text or punctuation. WidgetKit device behavior is not certified by this report.</p>'''
page += f'<p>Configuration: {escape(config)}</p>' + "".join(cards) + "</main></html>"
(root / "index.html").write_text(page)
print(root / "index.html")
34 changes: 34 additions & 0 deletions Scripts/ai-quality.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/bin/sh
# Opt-in, bounded live generation using synthetic offline prompts.
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT"
if [ -z "${AI_QUALITY_REPLAY:-}" ]; then
: "${ROCKY_OPENAI_APIKEY:?Set ROCKY_OPENAI_APIKEY to your provider key}"
: "${ROCKY_OPENAI_BASEURL:?Set ROCKY_OPENAI_BASEURL to your provider base URL}"
: "${ROCKY_OPENAI_MODEL:?Set ROCKY_OPENAI_MODEL to a model returned by your server}"
fi
OUTPUT=${AI_QUALITY_OUTPUT:-"$ROOT/__Release/ai-quality/$(date +%Y%m%d-%H%M%S)"}
case "$OUTPUT" in /*) ;; *) echo 'AI_QUALITY_OUTPUT must be an absolute path' >&2; exit 2 ;; esac
export TEST_RUNNER_ROCKY_OPENAI_APIKEY="${ROCKY_OPENAI_APIKEY:-}"
export TEST_RUNNER_ROCKY_OPENAI_BASEURL="${ROCKY_OPENAI_BASEURL:-http://localhost}"
export TEST_RUNNER_ROCKY_OPENAI_MODEL="${ROCKY_OPENAI_MODEL:-replay}"
export TEST_RUNNER_AI_QUALITY=1
export TEST_RUNNER_AI_QUALITY_ATTEMPTS=${AI_QUALITY_ATTEMPTS:-2}
export TEST_RUNNER_AI_QUALITY_OUTPUT="$OUTPUT"
if [ -n "${AI_QUALITY_REPLAY:-}" ]; then
export TEST_RUNNER_AI_QUALITY_REPLAY="$AI_QUALITY_REPLAY"
else
unset TEST_RUNNER_AI_QUALITY_REPLAY
fi
STATUS=0
xcodebuild -quiet -project macOS/ScriptWidgetMac.xcodeproj -scheme ScriptWidgetRuntimeTests \
-destination 'platform=macOS' -derivedDataPath "${SCRIPTWIDGET_DERIVED_DATA:-/tmp/scriptwidget-ai-quality}" \
-only-testing:ScriptWidgetRuntimeTests/AIGenerationTests/testWidgetQualityMatrix \
CODE_SIGNING_ALLOWED=NO test || STATUS=$?
if [ -f "$OUTPUT/quality.json" ]; then
swift Scripts/ai-quality-images.swift "$OUTPUT"
python3 Scripts/ai-quality-report.py "$OUTPUT"
fi
printf 'AI quality artifacts: %s\n' "$OUTPUT"
exit "$STATUS"
2 changes: 1 addition & 1 deletion Shared/ScriptWidgetRuntime/AI/Eval/AIEvalRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ struct AIEvalRunnerProgress {
/// composition is changed substantively. Reports get tagged with this
/// so two reports against the same dataset can be diffed meaningfully.
enum AIEvalPromptVersion {
static let current = "v1"
static let current = "v2"
}

actor AIEvalRunner {
Expand Down
25 changes: 21 additions & 4 deletions Shared/ScriptWidgetRuntime/AI/PromptBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ enum AIWidgetSize: String, CaseIterable, Identifiable, Codable {
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 .extraLarge: return CGSize(width: 639, height: 345)
case .extraLargePortrait: return CGSize(width: 345, height: 639)
case .accessoryInline: return CGSize(width: 250, height: 30)
case .accessoryCircular: return CGSize(width: 72, height: 72)
Expand All @@ -55,9 +55,9 @@ enum AIWidgetSize: String, CaseIterable, Identifiable, Codable {
var designHint: String {
switch self {
case .small:
return "Square, ~155x155 px. Keep it to one or two key pieces of information."
return "Square, ~170x170 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."
return "Wide rectangle, ~329x170 px. Room for a small grid or two columns."
case .large:
return "Square, ~329x345 px. Multiple sections / richer layout."
case .extraLarge:
Expand All @@ -69,7 +69,7 @@ enum AIWidgetSize: String, CaseIterable, Identifiable, Codable {
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."
return "Small rectangle (~170x72). A few short lines of text."
}
}
}
Expand Down Expand Up @@ -131,6 +131,23 @@ enum PromptBuilder {
visual density to the declared widget size.
10. Keep the output self-contained — no external files, no
image assets the user hasn't provided.
11. Respect light and dark appearances. Prefer color="primary"
and color="secondary" for text. On accessory widgets use
primary/secondary monochrome content with no background;
never hardcode white text on a transparent background.
12. Use frame="width,height" for explicit sizes, e.g. frame="60,60"
for a ring inside a 72x72 accessory widget. Leave room for
stroke thickness and padding. Do not apply frame="max" to
every row: it expands height too. Use frame="max,24" for a
full-width row with a fixed height when appropriate.
13. Charts use data={$json([{label: "Mon", value: 3}])}, not JSX
child marks. Keep chart axes legible: avoid a fixed dark
background with default light-appearance axes. Prefer the
default background or select a matching background using
$device.isdarkmode().
14. Preserve the requested labels and values. Do not invent
meeting locations, extra events, or live data. For fixed
demo data do not call network, location or Health APIs.
"""
let reference = reference.combined
return rules + "\n\n" + reference
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,12 @@ import UIKit
#if os(macOS)


static var systemIsDarkMode: Bool {
NSAppearance.currentDrawing().bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
}

static func isdarkmode() -> Bool {
return true
JSContext.current()?.scriptWidgetRunningState?.isDarkMode ?? systemIsDarkMode
}

static func screen() -> [AnyHashable : Any]! {
Expand Down Expand Up @@ -124,8 +128,12 @@ import UIKit
#else


static var systemIsDarkMode: Bool {
UITraitCollection.current.userInterfaceStyle == .dark
}

static func isdarkmode() -> Bool {
return UITraitCollection.current.userInterfaceStyle == .dark
JSContext.current()?.scriptWidgetRunningState?.isDarkMode ?? systemIsDarkMode
}

static func screen() -> [AnyHashable : Any]! {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,20 @@ struct ScriptWidgetAttributeFrameModifier: ViewModifier {
var width: CGFloat = 0
var height: CGFloat = 0

if let frameValue = element.getPropString("frame") {
let numericFrame = (element.getProps()["frame"] as? NSNumber).flatMap { value -> String? in
guard CFGetTypeID(value) != CFBooleanGetTypeID() else { return nil }
return String(value.doubleValue)
}
if let frameValue = element.getPropString("frame") ?? numericFrame {
let parts = frameValue.split(separator: ",")
if parts.count == 1 {
// frame="max"
if frameValue == "max" {
frameMode = .max_alignment
} else if let side = Double(frameValue), side.isFinite, side >= 0 {
width = CGFloat(side)
height = CGFloat(side)
frameMode = .width_height_alignment
}
} else if parts.count == 2 {
let part1 = String(parts[0])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,12 @@ class ScriptWidgetRunningState {
var package: ScriptWidgetPackage
let executionSession: ScriptWidgetExecutionSession

let isDarkMode: Bool?

var storageNamespace: String { "script.\(package.name)." }

init(package: ScriptWidgetPackage) {
init(package: ScriptWidgetPackage, isDarkMode: Bool? = nil) {
self.isDarkMode = isDarkMode
self.logger = ScriptWidgetConsoleLogger()
self.package = package
self.executionSession = ScriptWidgetExecutionSession()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,10 @@ class ScriptWidgetRuntime {
self.environments = environments
// APIs like $file/$console read per-execution state from the
// owning JSContext.
runtimeContext.scriptWidgetRunningState = ScriptWidgetRunningState(package: package)
// Capture appearance before JavaScript moves to a worker queue.
runtimeContext.scriptWidgetRunningState = ScriptWidgetRunningState(
package: package, isDarkMode: ScriptWidgetRuntimeDevice.systemIsDarkMode
)
ScriptWidgetMemoryPressure.install()
ScriptWidgetPrecompiler.install()
}
Expand Down
Loading
Loading