@ossy/media-tasks 3.15.0 → 3.16.0
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/README.md +37 -24
- package/package.json +4 -4
- package/src/extract-colors.js +71 -0
- package/src/extract-colors.schema.js +43 -0
- package/src/extract-colors.spec.js +44 -0
- package/src/extract-colors.task.js +79 -0
- package/src/load-source-buffer.js +17 -0
- package/src/openrouter.integration.js +30 -0
- package/src/openrouter.integration.spec.js +37 -0
- package/src/resize-common-web.schema.js +38 -5
- package/src/resize-common-web.task.js +59 -31
- package/src/resolve-configured-placement.js +44 -0
- package/src/resolve-configured-placement.spec.js +58 -0
- package/src/run-prompt.action.js +5 -0
- package/src/run-prompt.js +123 -0
- package/src/run-prompt.schema.js +48 -0
- package/src/run-prompt.spec.js +117 -0
- package/src/run-prompt.task.js +187 -0
- package/src/visual-content-descriptors.js +70 -0
- package/src/visual-content-descriptors.schema.js +38 -5
- package/src/visual-content-descriptors.spec.js +105 -0
- package/src/visual-content-descriptors.task.js +91 -13
- package/src/openai.integration.js +0 -74
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve workspace-configured placement folders for a task output.
|
|
3
|
+
* Uses `@ossy/automation/actions/get-task-instance` (string id — no package import).
|
|
4
|
+
*
|
|
5
|
+
* @param {{ invoke: Function }} sdk
|
|
6
|
+
* @param {string} taskId
|
|
7
|
+
* @param {string} outputName
|
|
8
|
+
* @returns {Promise<string[]>}
|
|
9
|
+
*/
|
|
10
|
+
export async function resolveConfiguredPlacements (sdk, taskId, outputName) {
|
|
11
|
+
if (!sdk?.invoke || !taskId || !outputName) return []
|
|
12
|
+
try {
|
|
13
|
+
const instance = await sdk.invoke('@ossy/automation/actions/get-task-instance', { taskId })
|
|
14
|
+
const config = instance?.outputs?.[outputName]
|
|
15
|
+
const fromList = Array.isArray(config?.placementLocations)
|
|
16
|
+
? config.placementLocations
|
|
17
|
+
: null
|
|
18
|
+
const raw = fromList ?? config?.placementLocation
|
|
19
|
+
const list = Array.isArray(raw) ? raw : (typeof raw === 'string' ? [raw] : [])
|
|
20
|
+
const seen = new Set()
|
|
21
|
+
const result = []
|
|
22
|
+
for (const entry of list) {
|
|
23
|
+
const trimmed = typeof entry === 'string' ? entry.trim() : ''
|
|
24
|
+
if (!trimmed || seen.has(trimmed)) continue
|
|
25
|
+
seen.add(trimmed)
|
|
26
|
+
result.push(trimmed)
|
|
27
|
+
}
|
|
28
|
+
return result
|
|
29
|
+
} catch {
|
|
30
|
+
return []
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @deprecated Use {@link resolveConfiguredPlacements}
|
|
36
|
+
* @param {{ invoke: Function }} sdk
|
|
37
|
+
* @param {string} taskId
|
|
38
|
+
* @param {string} outputName
|
|
39
|
+
* @returns {Promise<string | null>}
|
|
40
|
+
*/
|
|
41
|
+
export async function resolveConfiguredPlacement (sdk, taskId, outputName) {
|
|
42
|
+
const locations = await resolveConfiguredPlacements(sdk, taskId, outputName)
|
|
43
|
+
return locations[0] ?? null
|
|
44
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveConfiguredPlacement,
|
|
3
|
+
resolveConfiguredPlacements,
|
|
4
|
+
} from './resolve-configured-placement.js'
|
|
5
|
+
|
|
6
|
+
describe('resolveConfiguredPlacements', () => {
|
|
7
|
+
it('returns empty without sdk or ids', async () => {
|
|
8
|
+
expect(await resolveConfiguredPlacements(null, 't', 'o')).toEqual([])
|
|
9
|
+
expect(await resolveConfiguredPlacements({ invoke: async () => ({}) }, '', 'o')).toEqual([])
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('reads placementLocations from get-task-instance', async () => {
|
|
13
|
+
const calls = []
|
|
14
|
+
const sdk = {
|
|
15
|
+
invoke: async (action, payload) => {
|
|
16
|
+
calls.push([action, payload])
|
|
17
|
+
return {
|
|
18
|
+
outputs: {
|
|
19
|
+
palette: { placementLocations: ['/derived/colors/', ' /also/ ', '/derived/colors/'] },
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
await expect(resolveConfiguredPlacements(sdk, 'task-1', 'palette')).resolves.toEqual([
|
|
25
|
+
'/derived/colors/',
|
|
26
|
+
'/also/',
|
|
27
|
+
])
|
|
28
|
+
expect(calls).toEqual([
|
|
29
|
+
['@ossy/automation/actions/get-task-instance', { taskId: 'task-1' }],
|
|
30
|
+
])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('falls back to singular placementLocation', async () => {
|
|
34
|
+
await expect(resolveConfiguredPlacements({
|
|
35
|
+
invoke: async () => ({ outputs: { palette: { placementLocation: '/one/' } } }),
|
|
36
|
+
}, 't', 'palette')).resolves.toEqual(['/one/'])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('returns empty when invoke fails or locations empty', async () => {
|
|
40
|
+
await expect(resolveConfiguredPlacements({
|
|
41
|
+
invoke: async () => { throw new Error('missing') },
|
|
42
|
+
}, 't', 'palette')).resolves.toEqual([])
|
|
43
|
+
|
|
44
|
+
await expect(resolveConfiguredPlacements({
|
|
45
|
+
invoke: async () => ({ outputs: { palette: { placementLocations: [' '] } } }),
|
|
46
|
+
}, 't', 'palette')).resolves.toEqual([])
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('resolveConfiguredPlacement', () => {
|
|
51
|
+
it('returns the first location', async () => {
|
|
52
|
+
await expect(resolveConfiguredPlacement({
|
|
53
|
+
invoke: async () => ({
|
|
54
|
+
outputs: { palette: { placementLocations: ['/a/', '/b/'] } },
|
|
55
|
+
}),
|
|
56
|
+
}, 't', 'palette')).resolves.toBe('/a/')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** Default OpenRouter model for freeform completions. */
|
|
2
|
+
export const DEFAULT_RUN_PROMPT_MODEL = 'openai/gpt-4o-mini'
|
|
3
|
+
|
|
4
|
+
/** Document schema whose `content.body` is the prompt context. */
|
|
5
|
+
export const MARKDOWN_DOCUMENT_TYPE = '@ossy/resources/schema/markdown'
|
|
6
|
+
|
|
7
|
+
/** Cap resource text included beside the prompt so one file cannot fill the context. */
|
|
8
|
+
export const MAX_PROMPT_CONTEXT_CHARS = 12_000
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} [contentType]
|
|
12
|
+
* @returns {boolean}
|
|
13
|
+
*/
|
|
14
|
+
export function isTextContentType (contentType) {
|
|
15
|
+
const mime = String(contentType || '').split(';')[0].trim().toLowerCase()
|
|
16
|
+
if (!mime) return false
|
|
17
|
+
return mime.startsWith('text/')
|
|
18
|
+
|| mime === 'application/json'
|
|
19
|
+
|| mime === 'application/xml'
|
|
20
|
+
|| mime.endsWith('+json')
|
|
21
|
+
|| mime.endsWith('+xml')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {unknown} temperature
|
|
26
|
+
* @returns {number | undefined}
|
|
27
|
+
*/
|
|
28
|
+
export function clampTemperature (temperature) {
|
|
29
|
+
if (temperature == null || temperature === '') return undefined
|
|
30
|
+
const n = typeof temperature === 'number' ? temperature : Number(temperature)
|
|
31
|
+
if (!Number.isFinite(n)) return undefined
|
|
32
|
+
return Math.min(2, Math.max(0, n))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* True for a Markdown document (text in `content.body`, not a stored file).
|
|
37
|
+
*
|
|
38
|
+
* @param {object | null | undefined} resource
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
export function isMarkdownDocument (resource) {
|
|
42
|
+
return resource?.type === MARKDOWN_DOCUMENT_TYPE
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {object | null | undefined} resource
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function markdownDocumentBody (resource) {
|
|
50
|
+
const body = resource?.content?.body
|
|
51
|
+
return typeof body === 'string' ? body : ''
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Short context block prepended under the workspace prompt.
|
|
56
|
+
*
|
|
57
|
+
* @param {{ name?: string, contentType?: string, text?: string }} resource
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function resourceContextText ({ name, contentType, text } = {}) {
|
|
61
|
+
const lines = []
|
|
62
|
+
if (name) lines.push(`Name: ${name}`)
|
|
63
|
+
if (contentType) lines.push(`Content type: ${contentType}`)
|
|
64
|
+
const body = typeof text === 'string' ? text.trim() : ''
|
|
65
|
+
if (body) {
|
|
66
|
+
const clipped = body.length > MAX_PROMPT_CONTEXT_CHARS
|
|
67
|
+
? `${body.slice(0, MAX_PROMPT_CONTEXT_CHARS)}…`
|
|
68
|
+
: body
|
|
69
|
+
lines.push('', clipped)
|
|
70
|
+
}
|
|
71
|
+
return lines.join('\n').trim()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {import('openai').OpenAI} client
|
|
76
|
+
* @param {{
|
|
77
|
+
* prompt: string,
|
|
78
|
+
* model?: string,
|
|
79
|
+
* temperature?: number,
|
|
80
|
+
* contextText?: string,
|
|
81
|
+
* imageUrl?: string,
|
|
82
|
+
* }} opts
|
|
83
|
+
* @returns {Promise<{ text: string, model: string }>}
|
|
84
|
+
*/
|
|
85
|
+
export async function runOpenRouterPrompt (client, opts = {}) {
|
|
86
|
+
if (!client?.chat?.completions?.create) {
|
|
87
|
+
throw new Error('openrouter client is required')
|
|
88
|
+
}
|
|
89
|
+
const prompt = typeof opts.prompt === 'string' ? opts.prompt.trim() : ''
|
|
90
|
+
if (!prompt) {
|
|
91
|
+
throw new Error('prompt is required')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const model = opts.model || DEFAULT_RUN_PROMPT_MODEL
|
|
95
|
+
const temperature = clampTemperature(opts.temperature)
|
|
96
|
+
const contextText = typeof opts.contextText === 'string' ? opts.contextText.trim() : ''
|
|
97
|
+
const text = contextText ? `${prompt}\n\n---\n${contextText}` : prompt
|
|
98
|
+
const imageUrl = typeof opts.imageUrl === 'string' && opts.imageUrl ? opts.imageUrl : ''
|
|
99
|
+
|
|
100
|
+
/** @type {string | Array<{ type: string, text?: string, image_url?: { url: string } }>} */
|
|
101
|
+
const content = imageUrl
|
|
102
|
+
? [
|
|
103
|
+
{ type: 'text', text },
|
|
104
|
+
{ type: 'image_url', image_url: { url: imageUrl } },
|
|
105
|
+
]
|
|
106
|
+
: text
|
|
107
|
+
|
|
108
|
+
const response = await client.chat.completions.create({
|
|
109
|
+
model,
|
|
110
|
+
...(temperature != null ? { temperature } : {}),
|
|
111
|
+
messages: [{ role: 'user', content }],
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const completion = response?.choices?.[0]?.message?.content
|
|
115
|
+
if (typeof completion !== 'string' || !completion.trim()) {
|
|
116
|
+
throw new Error('run-prompt: model returned an empty completion')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
text: completion,
|
|
121
|
+
model: typeof response?.model === 'string' && response.model ? response.model : model,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
name: 'Prompt completion',
|
|
3
|
+
id: '@ossy/media-tasks/schema/run-prompt',
|
|
4
|
+
categoryName: 'Media processing',
|
|
5
|
+
icon: 'comment',
|
|
6
|
+
fields: [
|
|
7
|
+
{
|
|
8
|
+
name: 'text',
|
|
9
|
+
type: 'textarea',
|
|
10
|
+
description: 'Model completion',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'model',
|
|
14
|
+
type: 'text',
|
|
15
|
+
description: 'OpenRouter model that produced the completion',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'prompt',
|
|
19
|
+
type: 'textarea',
|
|
20
|
+
description: 'Prompt that was sent, before resource context',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'derivedFrom',
|
|
24
|
+
type: 'text',
|
|
25
|
+
description: 'Source resource id this output was derived from',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'taskId',
|
|
29
|
+
type: 'text',
|
|
30
|
+
description: 'Task that produced this resource',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'artifactName',
|
|
34
|
+
type: 'text',
|
|
35
|
+
description: 'Named task output',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'artifactKey',
|
|
39
|
+
type: 'text',
|
|
40
|
+
description: 'Storage key for the artifact bytes/JSON',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'artifactHref',
|
|
44
|
+
type: 'text',
|
|
45
|
+
description: 'Stable /r/…/tasks/… read path',
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_RUN_PROMPT_MODEL,
|
|
3
|
+
MARKDOWN_DOCUMENT_TYPE,
|
|
4
|
+
clampTemperature,
|
|
5
|
+
isMarkdownDocument,
|
|
6
|
+
isTextContentType,
|
|
7
|
+
markdownDocumentBody,
|
|
8
|
+
resourceContextText,
|
|
9
|
+
runOpenRouterPrompt,
|
|
10
|
+
} from './run-prompt.js'
|
|
11
|
+
|
|
12
|
+
describe('isTextContentType', () => {
|
|
13
|
+
it('accepts text, json, and xml', () => {
|
|
14
|
+
expect(isTextContentType('text/plain')).toBe(true)
|
|
15
|
+
expect(isTextContentType('text/markdown; charset=utf-8')).toBe(true)
|
|
16
|
+
expect(isTextContentType('application/json')).toBe(true)
|
|
17
|
+
expect(isTextContentType('application/ld+json')).toBe(true)
|
|
18
|
+
expect(isTextContentType('image/png')).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('isMarkdownDocument', () => {
|
|
23
|
+
it('recognises the markdown schema and reads its body', () => {
|
|
24
|
+
const resource = {
|
|
25
|
+
type: MARKDOWN_DOCUMENT_TYPE,
|
|
26
|
+
name: 'notes',
|
|
27
|
+
content: { body: '# Hello' },
|
|
28
|
+
}
|
|
29
|
+
expect(isMarkdownDocument(resource)).toBe(true)
|
|
30
|
+
expect(markdownDocumentBody(resource)).toBe('# Hello')
|
|
31
|
+
expect(isMarkdownDocument({
|
|
32
|
+
type: '@ossy/platform/schema/file',
|
|
33
|
+
content: { ContentType: 'text/plain', Key: 'abc' },
|
|
34
|
+
})).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('resourceContextText', () => {
|
|
39
|
+
it('includes name, type, and clipped body', () => {
|
|
40
|
+
const text = resourceContextText({
|
|
41
|
+
name: 'notes.md',
|
|
42
|
+
contentType: 'text/markdown',
|
|
43
|
+
text: 'hello',
|
|
44
|
+
})
|
|
45
|
+
expect(text).toContain('Name: notes.md')
|
|
46
|
+
expect(text).toContain('hello')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('clampTemperature', () => {
|
|
51
|
+
it('keeps 0 and clamps the range', () => {
|
|
52
|
+
expect(clampTemperature(0)).toBe(0)
|
|
53
|
+
expect(clampTemperature(3)).toBe(2)
|
|
54
|
+
expect(clampTemperature(-1)).toBe(0)
|
|
55
|
+
expect(clampTemperature(undefined)).toBeUndefined()
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('runOpenRouterPrompt', () => {
|
|
60
|
+
it('sends the prompt and optional temperature, and returns the completion', async () => {
|
|
61
|
+
const calls = []
|
|
62
|
+
const client = {
|
|
63
|
+
chat: {
|
|
64
|
+
completions: {
|
|
65
|
+
create: async (args) => {
|
|
66
|
+
calls.push(args)
|
|
67
|
+
return {
|
|
68
|
+
model: 'openai/gpt-4o-mini',
|
|
69
|
+
choices: [{ message: { content: 'A short title' } }],
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const result = await runOpenRouterPrompt(client, {
|
|
77
|
+
prompt: 'Name this file',
|
|
78
|
+
temperature: 0.2,
|
|
79
|
+
contextText: 'Name: a.png',
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
expect(result).toEqual({ text: 'A short title', model: 'openai/gpt-4o-mini' })
|
|
83
|
+
expect(calls[0].model).toBe(DEFAULT_RUN_PROMPT_MODEL)
|
|
84
|
+
expect(calls[0].temperature).toBe(0.2)
|
|
85
|
+
expect(calls[0].messages[0].content).toContain('Name this file')
|
|
86
|
+
expect(calls[0].messages[0].content).toContain('Name: a.png')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('attaches an image as a content part', async () => {
|
|
90
|
+
const calls = []
|
|
91
|
+
const client = {
|
|
92
|
+
chat: {
|
|
93
|
+
completions: {
|
|
94
|
+
create: async (args) => {
|
|
95
|
+
calls.push(args)
|
|
96
|
+
return { choices: [{ message: { content: 'ok' } }] }
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await runOpenRouterPrompt(client, {
|
|
103
|
+
prompt: 'Describe',
|
|
104
|
+
model: 'google/gemini-2.5-flash-lite',
|
|
105
|
+
imageUrl: 'data:image/png;base64,aaaa',
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
expect(calls[0].model).toBe('google/gemini-2.5-flash-lite')
|
|
109
|
+
expect(calls[0].temperature).toBeUndefined()
|
|
110
|
+
expect(calls[0].messages[0].content[1].image_url.url).toBe('data:image/png;base64,aaaa')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('rejects an empty prompt', async () => {
|
|
114
|
+
const client = { chat: { completions: { create: async () => ({}) } } }
|
|
115
|
+
await expect(runOpenRouterPrompt(client, { prompt: ' ' })).rejects.toThrow(/prompt/)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { createLogger } from '@ossy/observability'
|
|
2
|
+
import { StorageClient } from '@ossy/platform'
|
|
3
|
+
import { taskArtifactObjectKey } from '@ossy/platform/storage-keys'
|
|
4
|
+
import { GetResource, placeTaskArtifacts } from '@ossy/resources'
|
|
5
|
+
import { loadSourceBuffer } from './load-source-buffer.js'
|
|
6
|
+
import { toImageDataUrl } from './visual-content-descriptors.js'
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_RUN_PROMPT_MODEL,
|
|
9
|
+
MARKDOWN_DOCUMENT_TYPE,
|
|
10
|
+
isMarkdownDocument,
|
|
11
|
+
isTextContentType,
|
|
12
|
+
markdownDocumentBody,
|
|
13
|
+
resourceContextText,
|
|
14
|
+
runOpenRouterPrompt,
|
|
15
|
+
} from './run-prompt.js'
|
|
16
|
+
import { resolveConfiguredPlacements } from './resolve-configured-placement.js'
|
|
17
|
+
|
|
18
|
+
const log = createLogger('media-tasks')
|
|
19
|
+
|
|
20
|
+
const TASK_ID = '@ossy/media-tasks/tasks/run-prompt'
|
|
21
|
+
const SCHEMA_ID = '@ossy/media-tasks/schema/run-prompt'
|
|
22
|
+
const CONCEPT = 'run-prompt'
|
|
23
|
+
const OUTPUT = {
|
|
24
|
+
name: 'completion',
|
|
25
|
+
kind: 'location',
|
|
26
|
+
schemaId: SCHEMA_ID,
|
|
27
|
+
contentType: 'application/json',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const metadata = {
|
|
31
|
+
id: TASK_ID,
|
|
32
|
+
triggers: [
|
|
33
|
+
{
|
|
34
|
+
type: '@ossy/platform/schema/file',
|
|
35
|
+
event: 'Created',
|
|
36
|
+
match: { 'content.ContentType': 'text/*' },
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
type: '@ossy/platform/schema/file',
|
|
40
|
+
event: 'Created',
|
|
41
|
+
match: { 'content.ContentType': 'image/*' },
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
type: '@ossy/platform/schema/file',
|
|
45
|
+
event: 'Created',
|
|
46
|
+
match: { 'content.ContentType': 'application/json' },
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
type: MARKDOWN_DOCUMENT_TYPE,
|
|
50
|
+
event: 'Created',
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
outputs: [OUTPUT],
|
|
54
|
+
inputs: [
|
|
55
|
+
{
|
|
56
|
+
name: 'prompt',
|
|
57
|
+
type: 'text',
|
|
58
|
+
multiline: true,
|
|
59
|
+
sources: ['payload', 'config'],
|
|
60
|
+
required: true,
|
|
61
|
+
description: 'Instructions for the model',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'model',
|
|
65
|
+
type: 'text',
|
|
66
|
+
default: DEFAULT_RUN_PROMPT_MODEL,
|
|
67
|
+
sources: ['payload', 'config'],
|
|
68
|
+
description: 'OpenRouter model slug',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'temperature',
|
|
72
|
+
type: 'number',
|
|
73
|
+
sources: ['payload', 'config'],
|
|
74
|
+
description: 'Sampling temperature from 0 to 2. Leave empty for the provider default.',
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
configurable: true,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function run ({ event, payload, sdk, integrations, inputs }) {
|
|
81
|
+
const openrouter = integrations?.get?.('openrouter')
|
|
82
|
+
if (!openrouter) {
|
|
83
|
+
log.warn('[media-tasks/tasks/run-prompt] openrouter integration not connected')
|
|
84
|
+
throw Object.assign(new Error('OpenRouter integration not available'), { status: 503 })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const prompt = typeof inputs?.prompt === 'string' ? inputs.prompt : ''
|
|
88
|
+
const resourceId = event?.resourceId || payload?.resourceId || null
|
|
89
|
+
log.info(`[media-tasks/tasks/run-prompt] starting${resourceId ? ` for resource ${resourceId}` : ''}`)
|
|
90
|
+
|
|
91
|
+
let resource = null
|
|
92
|
+
let contextText = ''
|
|
93
|
+
let imageUrl
|
|
94
|
+
if (resourceId) {
|
|
95
|
+
resource = await sdk.invoke(GetResource, { resourceId })
|
|
96
|
+
const loaded = await loadResourceContext(resource)
|
|
97
|
+
contextText = loaded.contextText
|
|
98
|
+
imageUrl = loaded.imageUrl
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const completion = await runOpenRouterPrompt(openrouter, {
|
|
102
|
+
prompt,
|
|
103
|
+
model: inputs?.model,
|
|
104
|
+
temperature: inputs?.temperature,
|
|
105
|
+
contextText,
|
|
106
|
+
imageUrl,
|
|
107
|
+
})
|
|
108
|
+
const body = {
|
|
109
|
+
text: completion.text,
|
|
110
|
+
model: completion.model,
|
|
111
|
+
prompt,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!resource?.id) {
|
|
115
|
+
return { output: OUTPUT.name, completion: body, placedResourceIds: [] }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const objectKey = taskArtifactObjectKey(resource.id, CONCEPT, OUTPUT.name)
|
|
119
|
+
await StorageClient.save(objectKey, Buffer.from(JSON.stringify(body), 'utf8'))
|
|
120
|
+
log.info(`[media-tasks/tasks/run-prompt] saved ${objectKey}`)
|
|
121
|
+
|
|
122
|
+
const placementLocations = await resolveConfiguredPlacements(sdk, TASK_ID, OUTPUT.name)
|
|
123
|
+
const placed = await placeTaskArtifacts({
|
|
124
|
+
sdk,
|
|
125
|
+
sourceResource: resource,
|
|
126
|
+
taskId: TASK_ID,
|
|
127
|
+
output: OUTPUT,
|
|
128
|
+
body,
|
|
129
|
+
artifactKey: objectKey,
|
|
130
|
+
placementLocations,
|
|
131
|
+
})
|
|
132
|
+
if (placed.length) {
|
|
133
|
+
log.info(`[media-tasks/tasks/run-prompt] placed ${placed.length} resource(s)`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
output: OUTPUT.name,
|
|
138
|
+
key: objectKey,
|
|
139
|
+
completion: body,
|
|
140
|
+
placedResourceIds: placed.map((item) => item.id),
|
|
141
|
+
placedResourceId: placed[0]?.id ?? null,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param {object | null | undefined} resource
|
|
147
|
+
* @returns {Promise<{ contextText: string, imageUrl?: string }>}
|
|
148
|
+
*/
|
|
149
|
+
async function loadResourceContext (resource) {
|
|
150
|
+
const name = resource?.name
|
|
151
|
+
if (isMarkdownDocument(resource)) {
|
|
152
|
+
return {
|
|
153
|
+
contextText: resourceContextText({
|
|
154
|
+
name,
|
|
155
|
+
contentType: MARKDOWN_DOCUMENT_TYPE,
|
|
156
|
+
text: markdownDocumentBody(resource),
|
|
157
|
+
}),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const contentType = resource?.content?.ContentType || ''
|
|
162
|
+
const key = resource?.content?.Key
|
|
163
|
+
if (!key) {
|
|
164
|
+
return { contextText: resourceContextText({ name, contentType }) }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (String(contentType).startsWith('image/')) {
|
|
168
|
+
const buffer = await loadSourceBuffer(key)
|
|
169
|
+
return {
|
|
170
|
+
contextText: resourceContextText({ name, contentType }),
|
|
171
|
+
...(buffer ? { imageUrl: toImageDataUrl(buffer, contentType) } : {}),
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (isTextContentType(contentType)) {
|
|
176
|
+
const buffer = await loadSourceBuffer(key)
|
|
177
|
+
return {
|
|
178
|
+
contextText: resourceContextText({
|
|
179
|
+
name,
|
|
180
|
+
contentType,
|
|
181
|
+
text: buffer ? buffer.toString('utf8') : '',
|
|
182
|
+
}),
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { contextText: resourceContextText({ name, contentType }) }
|
|
187
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Default OpenRouter model slug (vision-capable). */
|
|
2
|
+
export const DEFAULT_VCD_MODEL = 'openai/gpt-4o-mini'
|
|
3
|
+
|
|
4
|
+
const SYSTEM_PROMPT = `You will be provided with images, and your task is to create a json object including the following properties:
|
|
5
|
+
- title: Short attention grabbing title that describes the image
|
|
6
|
+
- description: A short text describing the image, suitable for SEO purposes
|
|
7
|
+
- tags: an array of tags describing the image that will be used for categorization and SEO purposes
|
|
8
|
+
- alt: A short text that describes the image, suitable for alt text`
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build a data URL so the model provider can read the image without fetching
|
|
12
|
+
* our storage URL (localhost / auth-gated URLs fail with OpenRouter 400).
|
|
13
|
+
*
|
|
14
|
+
* @param {Buffer} buffer
|
|
15
|
+
* @param {string} [contentType]
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
export function toImageDataUrl (buffer, contentType = 'application/octet-stream') {
|
|
19
|
+
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
|
|
20
|
+
throw new Error('image buffer is required')
|
|
21
|
+
}
|
|
22
|
+
const mime = String(contentType || 'application/octet-stream').split(';')[0].trim() || 'application/octet-stream'
|
|
23
|
+
return `data:${mime};base64,${buffer.toString('base64')}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Ask an OpenRouter (OpenAI-compatible) client for SEO descriptors of an image.
|
|
28
|
+
*
|
|
29
|
+
* @param {import('openai').OpenAI} client
|
|
30
|
+
* @param {string} imageUrl Absolute http(s) URL or data: URL of the image
|
|
31
|
+
* @param {{ model?: string }} [opts]
|
|
32
|
+
* @returns {Promise<{ title: string | null, description: string | null, tags: string[], alt: string | null }>}
|
|
33
|
+
*/
|
|
34
|
+
export async function getVisualContentDescriptors (client, imageUrl, opts = {}) {
|
|
35
|
+
if (!client?.chat?.completions?.create) {
|
|
36
|
+
throw new Error('openrouter client is required')
|
|
37
|
+
}
|
|
38
|
+
if (!imageUrl) {
|
|
39
|
+
throw new Error('imageUrl is required')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const model = opts.model || DEFAULT_VCD_MODEL
|
|
43
|
+
const response = await client.chat.completions.create({
|
|
44
|
+
model,
|
|
45
|
+
max_tokens: 4000,
|
|
46
|
+
response_format: { type: 'json_object' },
|
|
47
|
+
messages: [
|
|
48
|
+
{ role: 'system', content: SYSTEM_PROMPT },
|
|
49
|
+
{
|
|
50
|
+
role: 'user',
|
|
51
|
+
content: [{ type: 'image_url', image_url: { url: imageUrl } }],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const raw = response?.choices?.[0]?.message?.content ?? '{}'
|
|
57
|
+
let parsed
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(raw)
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error('visual-content-descriptors: model returned non-JSON content')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
title: typeof parsed.title === 'string' ? parsed.title : null,
|
|
66
|
+
description: typeof parsed.description === 'string' ? parsed.description : null,
|
|
67
|
+
tags: Array.isArray(parsed.tags) ? parsed.tags.filter((t) => typeof t === 'string') : [],
|
|
68
|
+
alt: typeof parsed.alt === 'string' ? parsed.alt : null,
|
|
69
|
+
}
|
|
70
|
+
}
|