@cyber-dash-tech/revela 0.15.1 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/edit/prompt.ts +12 -0
- package/lib/inspect/prompt.ts +6 -1
- package/lib/media/download.ts +36 -11
- package/lib/media/save.ts +24 -0
- package/lib/media/search.ts +385 -0
- package/lib/media/types.ts +12 -0
- package/lib/qa/checks.ts +2 -1
- package/lib/qa/index.ts +73 -2
- package/lib/refine/server.ts +758 -68
- package/package.json +1 -1
- package/tools/media-save.ts +6 -0
package/lib/edit/prompt.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface EditCommentPayload extends EditSelectedElementPayload {
|
|
|
24
24
|
comment: string
|
|
25
25
|
elements?: EditSelectedElementPayload[]
|
|
26
26
|
comments?: EditCommentDraftPayload[]
|
|
27
|
+
asset?: Record<string, unknown>
|
|
28
|
+
drop?: Record<string, unknown>
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export function buildEditPrompt(payload: EditCommentPayload): string {
|
|
@@ -54,6 +56,8 @@ export function buildEditPrompt(payload: EditCommentPayload): string {
|
|
|
54
56
|
deck: payload.deck,
|
|
55
57
|
file: payload.file,
|
|
56
58
|
comments,
|
|
59
|
+
asset: payload.asset,
|
|
60
|
+
drop: payload.drop,
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
return `The user left a visual edit comment on a Revela slide deck.
|
|
@@ -75,6 +79,14 @@ Instructions:
|
|
|
75
79
|
- Pure artifact polish such as layout, spacing, typography, alignment, color, image crop, animation, export fidelity, runtime JavaScript fixes, or deck HTML contract fixes may remain an artifact-level edit.
|
|
76
80
|
- If the request mixes content meaning and visual polish, treat it as narrative-impacting unless the user clarifies otherwise.
|
|
77
81
|
- Preserve the existing deck structure, active design language, typography, spacing system, animations, and slide count unless the comment explicitly asks otherwise.
|
|
82
|
+
- If an asset/drop payload is present, this is an asset placement request. Use only the saved local asset path from the asset payload in deck HTML. Prefer asset.deckPath when present because it is relative to the target HTML file; otherwise use asset.path.
|
|
83
|
+
- Do not write remote imageUrl, thumbnailUrl, source page URLs, or ${"`/__revela_asset`"} proxy URLs into deck HTML.
|
|
84
|
+
- Logo assets should remain small, clear, and brand-like; do not use logos as decorative backgrounds.
|
|
85
|
+
- Photography can be cropped or masked when appropriate, but must not cover text, charts, tables, evidence, or important claims.
|
|
86
|
+
- Screenshots, diagrams, charts, tables, and evidence images must remain readable and should not be converted into decorative hero imagery.
|
|
87
|
+
- For asset targetMode ${"`replace`"}, prefer replacing the targeted image or visual element. For ${"`insert-into`"}, place the asset inside the targeted card, media box, or semantic container while preserving that element's layout role. For ${"`add`"}, place the asset near the drop coordinates within the existing layout or semantic box. Do not invent a new visual system when the existing deck grammar can express the placement.
|
|
88
|
+
- If an asset payload is present without drop coordinates, use the user's comment and selected element context to choose placement; if placement remains ambiguous, ask one concise clarification question instead of guessing.
|
|
89
|
+
- Preserve source/license/attribution facts if you surface them in visible notes; do not invent missing licensing or attribution.
|
|
78
90
|
- Do not rewrite unrelated slides or broad sections of the deck.
|
|
79
91
|
- Locate each target primarily with slideIndex, slideTitle, selected text, nearbyText, and outerHTMLExcerpt. Use selector/domPath as hints; they may be approximate.
|
|
80
92
|
- For targeted artifact-level edits, patch ${"`decks/*.html`"} directly. Do not call ${"`revela-decks`"} action ${"`review`"} as a precondition, and do not let ${"`writeReadiness`"}, ${"`planReview`"}, or ${"`slide_plan_unconfirmed`"} block the patch.
|
package/lib/inspect/prompt.ts
CHANGED
|
@@ -5,21 +5,25 @@ export function buildInspectionPrompt(input: {
|
|
|
5
5
|
file: string
|
|
6
6
|
projection: InspectionPromptProjection
|
|
7
7
|
language?: string
|
|
8
|
+
comment?: string
|
|
8
9
|
}): string {
|
|
9
10
|
const language = normalizeInspectLanguage(input.language)
|
|
11
|
+
const comment = typeof input.comment === "string" && input.comment.trim() ? input.comment.trim() : ""
|
|
10
12
|
return `A user selected slide content in Revela Evidence Inspector. The selection may contain one referenced element, a whole slide, or multiple referenced elements selected with Cmd/Ctrl-click.
|
|
11
13
|
|
|
12
14
|
Target file: ${input.file}
|
|
13
15
|
Inspection request id: ${input.requestId}
|
|
14
16
|
Display language: ${language}
|
|
17
|
+
User inspect comment: ${comment || "(none; explain purpose and source only)"}
|
|
15
18
|
|
|
16
|
-
Use the structured projection below to produce the final inspector cards. This is LLM judgment with grounded boundaries
|
|
19
|
+
Use the structured projection below to produce the final inspector cards. This is LLM judgment with grounded boundaries. The user's inspect comment is the complete request about the selected reference; do not parse it into a separate question field. The user primarily wants to understand the selected component: what purpose it serves and what source support exists. Use narrative reading and exploratory reading only as internal grounding unless needed to answer the user's comment. Do not edit files. Do not mutate DECKS.json. Do not invent claim ids, evidence binding ids, sources, quotes, URLs, page references, caveats, objections, risks, artifact coverage, or evidence not present in the projection.
|
|
17
20
|
|
|
18
21
|
Language boundary: the selected display language affects only human-readable card copy. Preserve all claim ids, canonical claim ids, evidence binding ids, source paths, findings files, URLs, numbers, quoted/source facts, caveats, artifact ids, and coverage statuses exactly as grounded in the projection. If the display language is Auto, use projection.deck.language when available; otherwise follow the user's/browser context or default to English.
|
|
19
22
|
|
|
20
23
|
Return the result only by calling the \`revela-inspection-result\` tool with this request id. Do not answer in chat.
|
|
21
24
|
|
|
22
25
|
Required card model:
|
|
26
|
+
- User inspect comment: if present, answer it through the Purpose and Source cards first. If it asks about trust, provenance, evidence, factuality, or where a number came from, prioritize Source. If it asks why something is on the slide or what it is doing, prioritize Purpose.
|
|
23
27
|
- Narrative Reading: when the projection includes a matched claim, preserve its claim id, canonical claim id, evidence binding ids, supported scope, unsupported scope, caveats, related objections, related risks, and artifact coverage. Artifact coverage must come only from projection.cards.artifacts; do not invent where a claim appears or whether an artifact is stale/current/partial/missing. If canonical narrative linkage is missing, say so and fall back to the matched slide claim; do not invent canonical ids.
|
|
24
28
|
- Candidate boundary: when projection.match.claim is absent but projection.match.candidateClaims is present, explain the selected child element only within those candidate claim boundaries. You may describe that the child element functions as a detail, prerequisite, source note, risk cue, or evidence cue inside the slide, but you must not select one candidate claim id by semantic guess. If projection.match.confidence is none or candidateClaims is empty, explain the mapping gap instead of inventing a plausible claim.
|
|
25
29
|
- Exploratory Reading: provide bounded, non-official reading cues for objection prep, audience reframing, appendix leads, and meeting prep only from the projection. Mark official as false. Keep missing evidence, caveats, unsupported scope, and stale artifacts visible. Do not make exploratory text sound like approved artifact content, and do not turn this into chat or a fix plan.
|
|
@@ -29,6 +33,7 @@ Required card model:
|
|
|
29
33
|
Boundaries:
|
|
30
34
|
- Do not hunt for problems. If it works, say it works.
|
|
31
35
|
- Do not recommend edits or fixes; this inspector view only explains narrative context, bounded exploratory reading context, purpose, and source credibility.
|
|
36
|
+
- Keep Purpose and Source concise and directly useful. Avoid long narrative-reading exposition unless the selected content cannot be explained without it.
|
|
32
37
|
- Do not turn every caveat into a problem.
|
|
33
38
|
- If confidence is low, use unclear or unknown instead of pretending certainty.
|
|
34
39
|
|
package/lib/media/download.ts
CHANGED
|
@@ -3,11 +3,15 @@ import { extname } from "path"
|
|
|
3
3
|
const MIME_TO_EXT: Record<string, string> = {
|
|
4
4
|
"image/png": ".png",
|
|
5
5
|
"image/jpeg": ".jpg",
|
|
6
|
+
"image/svg+xml": ".svg",
|
|
6
7
|
"image/webp": ".webp",
|
|
7
8
|
"image/gif": ".gif",
|
|
9
|
+
"image/x-icon": ".ico",
|
|
10
|
+
"image/vnd.microsoft.icon": ".ico",
|
|
8
11
|
}
|
|
9
12
|
|
|
10
|
-
const ALLOWED_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif"])
|
|
13
|
+
const ALLOWED_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp", ".gif", ".ico"])
|
|
14
|
+
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 10_000
|
|
11
15
|
|
|
12
16
|
function normalizeExtension(ext: string): string {
|
|
13
17
|
const value = ext.toLowerCase()
|
|
@@ -30,6 +34,7 @@ export function inferImageExtension(contentType: string | null, sourceName = "")
|
|
|
30
34
|
|
|
31
35
|
export async function downloadImageFromUrl(
|
|
32
36
|
url: string,
|
|
37
|
+
options: { timeoutMs?: number } = {},
|
|
33
38
|
): Promise<{ buffer: Buffer; contentType: string | null; extension: string }> {
|
|
34
39
|
let parsed: URL
|
|
35
40
|
try {
|
|
@@ -42,16 +47,36 @@ export async function downloadImageFromUrl(
|
|
|
42
47
|
throw new Error("INVALID_URL")
|
|
43
48
|
}
|
|
44
49
|
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
})
|
|
50
|
+
const controller = new AbortController()
|
|
51
|
+
let timedOut = false
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
timedOut = true
|
|
54
|
+
controller.abort()
|
|
55
|
+
}, Math.max(1, options.timeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS))
|
|
52
56
|
|
|
53
|
-
|
|
54
|
-
|
|
57
|
+
let response: Response
|
|
58
|
+
let buffer: Buffer
|
|
59
|
+
try {
|
|
60
|
+
response = await fetch(parsed, {
|
|
61
|
+
headers: {
|
|
62
|
+
Accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
|
63
|
+
"User-Agent":
|
|
64
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
|
|
65
|
+
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
|
66
|
+
},
|
|
67
|
+
signal: controller.signal,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
throw new Error(`DOWNLOAD_FAILED:${response.status}`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
buffer = Buffer.from(await response.arrayBuffer())
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (timedOut) throw new Error("DOWNLOAD_TIMEOUT")
|
|
77
|
+
throw error
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(timer)
|
|
55
80
|
}
|
|
56
81
|
|
|
57
82
|
const contentType = response.headers.get("content-type")
|
|
@@ -61,7 +86,7 @@ export async function downloadImageFromUrl(
|
|
|
61
86
|
}
|
|
62
87
|
|
|
63
88
|
return {
|
|
64
|
-
buffer
|
|
89
|
+
buffer,
|
|
65
90
|
contentType,
|
|
66
91
|
extension,
|
|
67
92
|
}
|
package/lib/media/save.ts
CHANGED
|
@@ -69,6 +69,12 @@ function buildFailureRecord(
|
|
|
69
69
|
alt: input.alt,
|
|
70
70
|
notes: input.notes,
|
|
71
71
|
failureReason,
|
|
72
|
+
provider: input.provider,
|
|
73
|
+
sourcePageUrl: input.sourcePageUrl,
|
|
74
|
+
license: input.license,
|
|
75
|
+
attribution: input.attribution,
|
|
76
|
+
width: input.width,
|
|
77
|
+
height: input.height,
|
|
72
78
|
savedAt: nowIso(),
|
|
73
79
|
}
|
|
74
80
|
}
|
|
@@ -112,6 +118,12 @@ function saveFailureResult(
|
|
|
112
118
|
sourcePath: sourcePath ?? existing.sourcePath,
|
|
113
119
|
alt: input.alt ?? existing.alt,
|
|
114
120
|
notes: input.notes ?? existing.notes,
|
|
121
|
+
provider: input.provider ?? existing.provider,
|
|
122
|
+
sourcePageUrl: input.sourcePageUrl ?? existing.sourcePageUrl,
|
|
123
|
+
license: input.license ?? existing.license,
|
|
124
|
+
attribution: input.attribution ?? existing.attribution,
|
|
125
|
+
width: input.width ?? existing.width,
|
|
126
|
+
height: input.height ?? existing.height,
|
|
115
127
|
failureReason,
|
|
116
128
|
}
|
|
117
129
|
: buildFailureRecord(input, topic, status, failureReason, sourcePath)
|
|
@@ -196,6 +208,12 @@ export async function saveMediaAsset(input: MediaSaveInput, workspaceDir: string
|
|
|
196
208
|
intendedSection: input.intendedSection,
|
|
197
209
|
alt: input.alt,
|
|
198
210
|
notes: input.notes,
|
|
211
|
+
provider: input.provider,
|
|
212
|
+
sourcePageUrl: input.sourcePageUrl,
|
|
213
|
+
license: input.license,
|
|
214
|
+
attribution: input.attribution,
|
|
215
|
+
width: input.width,
|
|
216
|
+
height: input.height,
|
|
199
217
|
savedAt: nowIso(),
|
|
200
218
|
}
|
|
201
219
|
const { manifest: nextManifest } = upsertAsset(manifest, record)
|
|
@@ -248,6 +266,12 @@ export async function saveMediaAsset(input: MediaSaveInput, workspaceDir: string
|
|
|
248
266
|
intendedSection: input.intendedSection,
|
|
249
267
|
alt: input.alt,
|
|
250
268
|
notes: input.notes,
|
|
269
|
+
provider: input.provider,
|
|
270
|
+
sourcePageUrl: input.sourcePageUrl,
|
|
271
|
+
license: input.license,
|
|
272
|
+
attribution: input.attribution,
|
|
273
|
+
width: input.width,
|
|
274
|
+
height: input.height,
|
|
251
275
|
savedAt: nowIso(),
|
|
252
276
|
}
|
|
253
277
|
const { manifest: nextManifest } = upsertAsset(manifest, record)
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import type { MediaPurpose } from "./types"
|
|
2
|
+
|
|
3
|
+
export interface ImageCandidate {
|
|
4
|
+
candidateId: string
|
|
5
|
+
provider: string
|
|
6
|
+
title: string
|
|
7
|
+
thumbnailUrl: string
|
|
8
|
+
imageUrl: string
|
|
9
|
+
sourcePageUrl?: string
|
|
10
|
+
width?: number
|
|
11
|
+
height?: number
|
|
12
|
+
alt?: string
|
|
13
|
+
license?: string
|
|
14
|
+
attribution?: string
|
|
15
|
+
purpose?: MediaPurpose
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ImageSearchInput {
|
|
19
|
+
query: string
|
|
20
|
+
purpose?: MediaPurpose
|
|
21
|
+
limit?: number
|
|
22
|
+
page?: number
|
|
23
|
+
signal?: AbortSignal
|
|
24
|
+
providerTimeoutMs?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ImageSearchProvider {
|
|
28
|
+
name: string
|
|
29
|
+
search(input: ImageSearchInput): Promise<ImageCandidate[]>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const PROVIDER_TIMEOUT_MS: Record<string, number> = {
|
|
33
|
+
"website-metadata": 3000,
|
|
34
|
+
unsplash: 6000,
|
|
35
|
+
"wikimedia-commons": 7000,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function slugify(value: string): string {
|
|
39
|
+
return value
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
42
|
+
.replace(/^-+|-+$/g, "")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeLimit(limit: number | undefined): number {
|
|
46
|
+
if (!Number.isFinite(limit ?? Number.NaN)) return 12
|
|
47
|
+
return Math.max(1, Math.min(48, Math.floor(limit!)))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizePage(page: number | undefined): number {
|
|
51
|
+
if (!Number.isFinite(page ?? Number.NaN)) return 1
|
|
52
|
+
return Math.max(1, Math.floor(page!))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function domainFromQuery(query: string): string | null {
|
|
56
|
+
const value = query.trim().toLowerCase()
|
|
57
|
+
if (!value) return null
|
|
58
|
+
const withoutProtocol = value.replace(/^https?:\/\//, "").replace(/^www\./, "")
|
|
59
|
+
const first = withoutProtocol.split(/[\s/]+/)[0]
|
|
60
|
+
if (/^[a-z0-9.-]+\.[a-z]{2,}$/.test(first)) return first
|
|
61
|
+
const compact = value.replace(/\b(logo|company|brand)\b/g, "").replace(/[^a-z0-9]+/g, "")
|
|
62
|
+
if (!compact) return null
|
|
63
|
+
return `${compact}.com`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function textFromMetadata(value: unknown): string | undefined {
|
|
67
|
+
if (!value || typeof value !== "object") return undefined
|
|
68
|
+
const raw = (value as { value?: unknown }).value
|
|
69
|
+
return typeof raw === "string" && raw.trim() ? raw.replace(/<[^>]+>/g, "").trim() : undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function compactBrandName(query: string): string | null {
|
|
73
|
+
const value = query
|
|
74
|
+
.toLowerCase()
|
|
75
|
+
.replace(/^https?:\/\//, "")
|
|
76
|
+
.replace(/^www\./, "")
|
|
77
|
+
.split(/[\s/]+/)[0]
|
|
78
|
+
.replace(/\.[a-z]{2,}$/, "")
|
|
79
|
+
.replace(/\b(logo|company|brand)\b/g, "")
|
|
80
|
+
.replace(/[^a-z0-9]+/g, "")
|
|
81
|
+
return value || null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function absoluteUrl(value: string, base: string): string | undefined {
|
|
85
|
+
try {
|
|
86
|
+
return new URL(value, base).toString()
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function uniqueValues(values: Array<string | undefined>): string[] {
|
|
93
|
+
const seen = new Set<string>()
|
|
94
|
+
return values.flatMap((value) => {
|
|
95
|
+
if (!value || seen.has(value)) return []
|
|
96
|
+
seen.add(value)
|
|
97
|
+
return [value]
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function htmlAttr(tag: string, attr: string): string | undefined {
|
|
102
|
+
const pattern = new RegExp(`${attr}=["']([^"']+)["']`, "i")
|
|
103
|
+
return tag.match(pattern)?.[1]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function htmlMetaContent(html: string, property: string): string | undefined {
|
|
107
|
+
const tags = html.match(/<meta\b[^>]*>/gi) ?? []
|
|
108
|
+
const needle = property.toLowerCase()
|
|
109
|
+
for (const tag of tags) {
|
|
110
|
+
const key = htmlAttr(tag, "property") || htmlAttr(tag, "name")
|
|
111
|
+
if (key?.toLowerCase() === needle) return htmlAttr(tag, "content")
|
|
112
|
+
}
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function htmlIconLinks(html: string): string[] {
|
|
117
|
+
const tags = html.match(/<link\b[^>]*>/gi) ?? []
|
|
118
|
+
return tags.flatMap((tag) => {
|
|
119
|
+
const rel = htmlAttr(tag, "rel")?.toLowerCase() ?? ""
|
|
120
|
+
if (!rel.includes("icon") && !rel.includes("apple-touch-icon")) return []
|
|
121
|
+
return htmlAttr(tag, "href") ?? []
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function queryForPurpose(query: string, purpose: MediaPurpose | undefined): string {
|
|
126
|
+
const value = query.trim()
|
|
127
|
+
if (!value) return value
|
|
128
|
+
if (purpose === "hero") return `${value} hero background professional photography`
|
|
129
|
+
if (purpose === "portrait") return `${value} portrait headshot`
|
|
130
|
+
if (purpose === "screenshot") return `${value} screenshot interface product`
|
|
131
|
+
if (purpose === "illustration") return `${value} professional photo concept visual`
|
|
132
|
+
return value
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function wikimediaQueryForPurpose(query: string, purpose: MediaPurpose | undefined): string {
|
|
136
|
+
const value = query.trim()
|
|
137
|
+
if (!value) return value
|
|
138
|
+
if (purpose === "portrait") return `${value} portrait`
|
|
139
|
+
if (purpose === "screenshot") return `${value} screenshot`
|
|
140
|
+
return value
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const clearbitLogoProvider: ImageSearchProvider = {
|
|
144
|
+
name: "clearbit-logo",
|
|
145
|
+
async search(input) {
|
|
146
|
+
if (normalizePage(input.page) > 1) return []
|
|
147
|
+
if (input.purpose && input.purpose !== "logo") return []
|
|
148
|
+
const domain = domainFromQuery(input.query)
|
|
149
|
+
if (!domain) return []
|
|
150
|
+
const title = `${domain} logo`
|
|
151
|
+
return [{
|
|
152
|
+
candidateId: `clearbit-${slugify(domain)}`,
|
|
153
|
+
provider: this.name,
|
|
154
|
+
title,
|
|
155
|
+
thumbnailUrl: `https://logo.clearbit.com/${encodeURIComponent(domain)}`,
|
|
156
|
+
imageUrl: `https://logo.clearbit.com/${encodeURIComponent(domain)}`,
|
|
157
|
+
sourcePageUrl: `https://${domain}`,
|
|
158
|
+
alt: title,
|
|
159
|
+
purpose: "logo",
|
|
160
|
+
}]
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const simpleIconsProvider: ImageSearchProvider = {
|
|
165
|
+
name: "simple-icons",
|
|
166
|
+
async search(input) {
|
|
167
|
+
if (normalizePage(input.page) > 1) return []
|
|
168
|
+
if (input.purpose && input.purpose !== "logo") return []
|
|
169
|
+
const brand = compactBrandName(input.query)
|
|
170
|
+
if (!brand) return []
|
|
171
|
+
const title = `${brand} logo`
|
|
172
|
+
const cdnUrl = `https://cdn.simpleicons.org/${encodeURIComponent(brand)}`
|
|
173
|
+
const imageUrl = `https://raw.githubusercontent.com/simple-icons/simple-icons/develop/icons/${encodeURIComponent(brand)}.svg`
|
|
174
|
+
return [{
|
|
175
|
+
candidateId: `simple-icons-${slugify(brand)}`,
|
|
176
|
+
provider: this.name,
|
|
177
|
+
title,
|
|
178
|
+
thumbnailUrl: cdnUrl,
|
|
179
|
+
imageUrl,
|
|
180
|
+
sourcePageUrl: `https://simpleicons.org/?q=${encodeURIComponent(brand)}`,
|
|
181
|
+
alt: title,
|
|
182
|
+
license: "See Simple Icons source license",
|
|
183
|
+
attribution: "Simple Icons",
|
|
184
|
+
purpose: "logo",
|
|
185
|
+
}]
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export const websiteMetadataProvider: ImageSearchProvider = {
|
|
190
|
+
name: "website-metadata",
|
|
191
|
+
async search(input) {
|
|
192
|
+
if (normalizePage(input.page) > 1) return []
|
|
193
|
+
const domain = domainFromQuery(input.query)
|
|
194
|
+
if (!domain) return []
|
|
195
|
+
const homepage = `https://${domain}`
|
|
196
|
+
const response = await fetch(homepage, {
|
|
197
|
+
headers: { "user-agent": "Revela/0.15 asset search" },
|
|
198
|
+
signal: input.signal,
|
|
199
|
+
})
|
|
200
|
+
if (!response.ok) throw new Error(`Website metadata fetch failed: ${response.status}`)
|
|
201
|
+
const html = await response.text()
|
|
202
|
+
const title = htmlMetaContent(html, "og:site_name") || html.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.trim() || domain
|
|
203
|
+
const socialImages = [htmlMetaContent(html, "og:image"), htmlMetaContent(html, "twitter:image")]
|
|
204
|
+
const icons = [...htmlIconLinks(html), "/favicon.ico"]
|
|
205
|
+
const sourceUrls = input.purpose === "logo"
|
|
206
|
+
? icons
|
|
207
|
+
: input.purpose === "screenshot"
|
|
208
|
+
? socialImages
|
|
209
|
+
: socialImages
|
|
210
|
+
const urls = uniqueValues(sourceUrls.map((value) => value ? absoluteUrl(value, homepage) : undefined))
|
|
211
|
+
return urls.slice(0, normalizeLimit(input.limit)).map((imageUrl, index) => ({
|
|
212
|
+
candidateId: `website-${slugify(domain)}-${index + 1}`,
|
|
213
|
+
provider: this.name,
|
|
214
|
+
title: index === 0 ? `${title} image` : `${title} icon ${index}`,
|
|
215
|
+
thumbnailUrl: imageUrl,
|
|
216
|
+
imageUrl,
|
|
217
|
+
sourcePageUrl: homepage,
|
|
218
|
+
alt: `${title} image`,
|
|
219
|
+
purpose: input.purpose,
|
|
220
|
+
}))
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export const unsplashProvider: ImageSearchProvider = {
|
|
225
|
+
name: "unsplash",
|
|
226
|
+
async search(input) {
|
|
227
|
+
const accessKey = process.env.UNSPLASH_ACCESS_KEY?.trim()
|
|
228
|
+
if (!accessKey) return []
|
|
229
|
+
if (input.purpose === "logo" || input.purpose === "screenshot") return []
|
|
230
|
+
const limit = normalizeLimit(input.limit)
|
|
231
|
+
const page = normalizePage(input.page)
|
|
232
|
+
const url = new URL("https://api.unsplash.com/search/photos")
|
|
233
|
+
url.searchParams.set("query", input.query)
|
|
234
|
+
url.searchParams.set("per_page", String(Math.min(30, limit)))
|
|
235
|
+
url.searchParams.set("page", String(page))
|
|
236
|
+
url.searchParams.set("orientation", input.purpose === "portrait" ? "portrait" : "landscape")
|
|
237
|
+
const response = await fetch(url, {
|
|
238
|
+
headers: {
|
|
239
|
+
authorization: `Client-ID ${accessKey}`,
|
|
240
|
+
"accept-version": "v1",
|
|
241
|
+
"user-agent": "Revela/0.15 asset search",
|
|
242
|
+
},
|
|
243
|
+
signal: input.signal,
|
|
244
|
+
})
|
|
245
|
+
if (!response.ok) throw new Error(`Unsplash search failed: ${response.status}`)
|
|
246
|
+
const body = await response.json() as any
|
|
247
|
+
const results = Array.isArray(body?.results) ? body.results : []
|
|
248
|
+
return results.flatMap((item: any) => {
|
|
249
|
+
const imageUrl = typeof item?.urls?.regular === "string" ? item.urls.regular : ""
|
|
250
|
+
if (!imageUrl) return []
|
|
251
|
+
const title = item.description || item.alt_description || "Unsplash photo"
|
|
252
|
+
const author = item.user?.name ? String(item.user.name) : undefined
|
|
253
|
+
return [{
|
|
254
|
+
candidateId: `unsplash-${slugify(String(item.id || title))}`,
|
|
255
|
+
provider: this.name,
|
|
256
|
+
title,
|
|
257
|
+
thumbnailUrl: typeof item.urls?.small === "string" ? item.urls.small : imageUrl,
|
|
258
|
+
imageUrl,
|
|
259
|
+
sourcePageUrl: typeof item.links?.html === "string" ? item.links.html : undefined,
|
|
260
|
+
width: typeof item.width === "number" ? item.width : undefined,
|
|
261
|
+
height: typeof item.height === "number" ? item.height : undefined,
|
|
262
|
+
alt: item.alt_description || title,
|
|
263
|
+
license: "Unsplash License",
|
|
264
|
+
attribution: author ? `Photo by ${author} on Unsplash` : "Unsplash",
|
|
265
|
+
purpose: input.purpose,
|
|
266
|
+
}]
|
|
267
|
+
}).slice(0, limit)
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export const wikimediaProvider: ImageSearchProvider = {
|
|
272
|
+
name: "wikimedia-commons",
|
|
273
|
+
async search(input) {
|
|
274
|
+
const limit = normalizeLimit(input.limit)
|
|
275
|
+
const page = normalizePage(input.page)
|
|
276
|
+
const url = new URL("https://commons.wikimedia.org/w/api.php")
|
|
277
|
+
url.searchParams.set("action", "query")
|
|
278
|
+
url.searchParams.set("format", "json")
|
|
279
|
+
url.searchParams.set("origin", "*")
|
|
280
|
+
url.searchParams.set("generator", "search")
|
|
281
|
+
url.searchParams.set("gsrnamespace", "6")
|
|
282
|
+
url.searchParams.set("gsrlimit", String(limit))
|
|
283
|
+
url.searchParams.set("gsroffset", String((page - 1) * limit))
|
|
284
|
+
url.searchParams.set("gsrsearch", input.query)
|
|
285
|
+
url.searchParams.set("prop", "imageinfo|info")
|
|
286
|
+
url.searchParams.set("iiprop", "url|thumburl|mime|size|extmetadata")
|
|
287
|
+
url.searchParams.set("iiurlwidth", "320")
|
|
288
|
+
url.searchParams.set("inprop", "url")
|
|
289
|
+
|
|
290
|
+
const response = await fetch(url, {
|
|
291
|
+
headers: { "user-agent": "Revela/0.15 asset search" },
|
|
292
|
+
signal: input.signal,
|
|
293
|
+
})
|
|
294
|
+
if (!response.ok) throw new Error(`Wikimedia search failed: ${response.status}`)
|
|
295
|
+
const body = await response.json() as any
|
|
296
|
+
const pages = Object.values(body?.query?.pages ?? {}) as any[]
|
|
297
|
+
return pages.flatMap((page) => {
|
|
298
|
+
const info = page?.imageinfo?.[0]
|
|
299
|
+
const mime = typeof info?.mime === "string" ? info.mime : ""
|
|
300
|
+
if (!mime.startsWith("image/")) return []
|
|
301
|
+
const imageUrl = typeof info?.url === "string" ? info.url : ""
|
|
302
|
+
if (!imageUrl) return []
|
|
303
|
+
const thumbnailUrl = typeof info?.thumburl === "string" ? info.thumburl : imageUrl
|
|
304
|
+
const metadata = info?.extmetadata ?? {}
|
|
305
|
+
const title = String(page.title || "Wikimedia image").replace(/^File:/, "")
|
|
306
|
+
return [{
|
|
307
|
+
candidateId: `wikimedia-${slugify(title).slice(0, 80)}`,
|
|
308
|
+
provider: this.name,
|
|
309
|
+
title,
|
|
310
|
+
thumbnailUrl,
|
|
311
|
+
imageUrl,
|
|
312
|
+
sourcePageUrl: typeof page.fullurl === "string" ? page.fullurl : undefined,
|
|
313
|
+
width: typeof info.width === "number" ? info.width : undefined,
|
|
314
|
+
height: typeof info.height === "number" ? info.height : undefined,
|
|
315
|
+
alt: title,
|
|
316
|
+
license: textFromMetadata(metadata.LicenseShortName) || textFromMetadata(metadata.UsageTerms),
|
|
317
|
+
attribution: textFromMetadata(metadata.Artist) || textFromMetadata(metadata.Credit),
|
|
318
|
+
purpose: input.purpose,
|
|
319
|
+
}]
|
|
320
|
+
}).slice(0, limit)
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export async function searchRemoteImages(input: ImageSearchInput): Promise<ImageCandidate[]> {
|
|
325
|
+
const query = input.query.trim()
|
|
326
|
+
if (!query) return []
|
|
327
|
+
const limit = normalizeLimit(input.limit)
|
|
328
|
+
const page = normalizePage(input.page)
|
|
329
|
+
const mediaQuery = queryForPurpose(query, input.purpose)
|
|
330
|
+
const wikimediaQuery = wikimediaQueryForPurpose(query, input.purpose)
|
|
331
|
+
const searches: Array<Promise<ImageCandidate[]>> = input.purpose === "logo"
|
|
332
|
+
? [
|
|
333
|
+
runProvider(clearbitLogoProvider, { ...input, query, limit, page }),
|
|
334
|
+
runProvider(simpleIconsProvider, { ...input, query, limit, page }),
|
|
335
|
+
runProvider(websiteMetadataProvider, { ...input, query, limit, page }),
|
|
336
|
+
]
|
|
337
|
+
: input.purpose === "screenshot"
|
|
338
|
+
? [
|
|
339
|
+
runProvider(websiteMetadataProvider, { ...input, query, limit, page }),
|
|
340
|
+
runProvider(wikimediaProvider, { ...input, query: wikimediaQuery, limit, page }),
|
|
341
|
+
]
|
|
342
|
+
: [
|
|
343
|
+
runProvider(websiteMetadataProvider, { ...input, query, limit, page }),
|
|
344
|
+
runProvider(unsplashProvider, { ...input, query: mediaQuery, limit, page }),
|
|
345
|
+
runProvider(wikimediaProvider, { ...input, query: wikimediaQuery, limit, page }),
|
|
346
|
+
]
|
|
347
|
+
const batches = await Promise.allSettled(searches)
|
|
348
|
+
const candidates = batches.flatMap((result) => result.status === "fulfilled" ? result.value : [])
|
|
349
|
+
const seen = new Set<string>()
|
|
350
|
+
return candidates.filter((candidate) => {
|
|
351
|
+
const key = candidate.imageUrl || candidate.thumbnailUrl || candidate.candidateId
|
|
352
|
+
if (seen.has(key)) return false
|
|
353
|
+
seen.add(key)
|
|
354
|
+
return true
|
|
355
|
+
}).slice(0, limit)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function runProvider(provider: ImageSearchProvider, input: ImageSearchInput): Promise<ImageCandidate[]> {
|
|
359
|
+
const timeoutMs = input.providerTimeoutMs ?? PROVIDER_TIMEOUT_MS[provider.name]
|
|
360
|
+
if (!timeoutMs) return provider.search(input).catch(() => [])
|
|
361
|
+
|
|
362
|
+
const controller = new AbortController()
|
|
363
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
364
|
+
const signal = input.signal ? anySignal([input.signal, controller.signal]) : controller.signal
|
|
365
|
+
try {
|
|
366
|
+
return await provider.search({ ...input, signal })
|
|
367
|
+
} catch {
|
|
368
|
+
return []
|
|
369
|
+
} finally {
|
|
370
|
+
clearTimeout(timer)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function anySignal(signals: AbortSignal[]): AbortSignal {
|
|
375
|
+
const controller = new AbortController()
|
|
376
|
+
const abort = () => controller.abort()
|
|
377
|
+
for (const signal of signals) {
|
|
378
|
+
if (signal.aborted) {
|
|
379
|
+
controller.abort()
|
|
380
|
+
break
|
|
381
|
+
}
|
|
382
|
+
signal.addEventListener("abort", abort, { once: true })
|
|
383
|
+
}
|
|
384
|
+
return controller.signal
|
|
385
|
+
}
|
package/lib/media/types.ts
CHANGED
|
@@ -15,6 +15,12 @@ export interface MediaSaveInput {
|
|
|
15
15
|
alt?: string
|
|
16
16
|
notes?: string
|
|
17
17
|
failureReason?: string
|
|
18
|
+
provider?: string
|
|
19
|
+
sourcePageUrl?: string
|
|
20
|
+
license?: string
|
|
21
|
+
attribution?: string
|
|
22
|
+
width?: number
|
|
23
|
+
height?: number
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
export interface MediaAssetRecord {
|
|
@@ -30,6 +36,12 @@ export interface MediaAssetRecord {
|
|
|
30
36
|
alt?: string
|
|
31
37
|
notes?: string
|
|
32
38
|
failureReason?: string
|
|
39
|
+
provider?: string
|
|
40
|
+
sourcePageUrl?: string
|
|
41
|
+
license?: string
|
|
42
|
+
attribution?: string
|
|
43
|
+
width?: number
|
|
44
|
+
height?: number
|
|
33
45
|
savedAt: string
|
|
34
46
|
}
|
|
35
47
|
|
package/lib/qa/checks.ts
CHANGED
|
@@ -23,13 +23,14 @@ import { CANVAS_W, CANVAS_H } from "./measure"
|
|
|
23
23
|
export type IssueSeverity = "error" | "warning" | "info"
|
|
24
24
|
|
|
25
25
|
export interface LayoutIssue {
|
|
26
|
-
type: "canvas" | "scrollbar" | "overflow" | "text_overflow" | "density" | "balance" | "symmetry" | "rhythm" | "compliance"
|
|
26
|
+
type: "canvas" | "scrollbar" | "overflow" | "text_overflow" | "density" | "balance" | "symmetry" | "rhythm" | "compliance" | "asset"
|
|
27
27
|
/** Sub-category within the dimension */
|
|
28
28
|
sub?: "size_mismatch" | "page_scroll" | "text_clipped" | "thin_content"
|
|
29
29
|
| "centroid_offset" | "bottom_gap" | "sparse"
|
|
30
30
|
| "height_mismatch" | "density_mismatch"
|
|
31
31
|
| "gap_variance"
|
|
32
32
|
| "unknown_class" | "novel_css_rule"
|
|
33
|
+
| "remote_url" | "refine_proxy" | "missing_file"
|
|
33
34
|
severity: IssueSeverity
|
|
34
35
|
/** Human-readable description for the LLM to act on */
|
|
35
36
|
detail: string
|