hermoso 0.1.372 → 0.1.374
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 +34 -15
- package/bin/hermoso.mjs +1 -1
- package/mcp/client.mjs +38 -3
- package/mcp/http.mjs +7 -2
- package/mcp/openai-extensions.mjs +2110 -0
- package/mcp/roster-scope.mjs +2 -0
- package/mcp/tool-cost.mjs +1 -1
- package/mcp/tools.mjs +728 -270
- package/package.json +2 -2
- package/skills/hermoso-marketing/SKILL.md +2 -2
|
@@ -0,0 +1,2110 @@
|
|
|
1
|
+
// ── CHATGPT PLUGIN EXTENSIONS: THE HERMOSO APP (2026-10-02, behind a flag) ────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// WHAT. One MCP App (spec: github.com/openai/mcp-extensions docs/spec.md and the MCP Apps spec 2026-01-26, both read
|
|
4
|
+
// 2026-10-02) that ChatGPT opens in several places:
|
|
5
|
+
// • the SIDEBAR (`global` entrypoint, tool open_hermoso_home): a Hermoso HOME. A composer that sends to the chat
|
|
6
|
+
// (type @ to add a brand, saved creator, product photo or Library item), quick starts, and cards for the Library,
|
|
7
|
+
// a new ad, Ad Spy, the calendar, connections and brand setup;
|
|
8
|
+
// • beside a conversation (`thread` entrypoint, tool open_library): Library, Renders, Calendar, Create;
|
|
9
|
+
// • inline in the conversation, when the model calls show_ad_concepts (the concept picker: the user picks ONE
|
|
10
|
+
// concept before anything renders), show_ad_results (a gallery of a batch the user already asked for),
|
|
11
|
+
// open_ad_brief (the brief form) or set_up_hermoso (brand + connections setup);
|
|
12
|
+
// • the plugin SETTINGS page (capability `openai/settings`, tools read_hermoso_settings / update_hermoso_settings);
|
|
13
|
+
// • the desktop composer's @-MENTIONS (search_hermoso_items + `hermoso://` resources the model can read).
|
|
14
|
+
// Built on the MCP Apps bridge (`text/html;profile=mcp-app`, `ui/initialize`, `tools/call`), not on the older
|
|
15
|
+
// skybridge widgets the three existing cards use, which stay exactly as they are.
|
|
16
|
+
//
|
|
17
|
+
// WHY A FLAG, AND WHY TWO KEYS TO IT. The ChatGPT plugin is IN REVIEW, and nothing here may move what the reviewed
|
|
18
|
+
// connector sees. So it is registered ONLY when the server sets HERMOSO_CHATGPT_EXT=1 AND the connection asks with
|
|
19
|
+
// `?ext=1` on its MCP URL AND the host is a widget host (ChatGPT). Any one missing and `tools/list`, `resources/list`
|
|
20
|
+
// and the initialize capabilities are byte-for-byte what they were; tools/chatgpt-extensions-check.mjs RUNS
|
|
21
|
+
// registerTools both ways to prove it. After the plugin is published, OpenAI's daily scan reviews new tools on their
|
|
22
|
+
// own without touching the approved ones, which is why these are NEW tools and no entrypoint `_meta` is ever added to
|
|
23
|
+
// an existing, reviewed tool.
|
|
24
|
+
//
|
|
25
|
+
// THE APP NEVER SPENDS AND NEVER PUBLISHES ON ITS OWN: every write it makes is one the user confirmed ON SCREEN, on a
|
|
26
|
+
// step that says what happens (the click IS the confirmation, exactly like a calendar move). Those writes are: moving
|
|
27
|
+
// or cancelling a queued post (Calendar), drafting the brand profile (Setup), switching brand, the settings page,
|
|
28
|
+
// rendering the ONE concept the user picked (Render it), scheduling a finished result (Schedule), and pausing or
|
|
29
|
+
// turning on an ad campaign (Ads). Each goes through its own app-only tool that runs the EXISTING tools' handlers
|
|
30
|
+
// in-process (reschedule_post, cancel_scheduled, draft_brand, use_brand, plan_ad + render_ad / generate_image,
|
|
31
|
+
// schedule_post, the per-platform status tool), never a re-implementation and never a generic executor.
|
|
32
|
+
// WHY RENDER IT CALLS A TOOL AND DOES NOT SEND A MESSAGE (2026-10-03, live): a message the app sends reads to ChatGPT
|
|
33
|
+
// as the app talking, and it refused to spend on one ("its message doesn't authorize a paid render"), so the user had
|
|
34
|
+
// to confirm twice. Wording a message so the model skips its own confirmation reads as weakening a safety check, so
|
|
35
|
+
// the app does not try: the confirmed click runs the render itself. Everything that is still a REQUEST (a new brief,
|
|
36
|
+
// more ideas, building a campaign, the composer) goes to the chat as a message, and the model confirms it there.
|
|
37
|
+
//
|
|
38
|
+
// NO COMMERCE IN THE APP. OpenAI's plugin guidelines forbid selling digital goods inside a plugin, so the app shows
|
|
39
|
+
// no plan, price, balance, top-up or upgrade anywhere; the check greps the HTML for it. A refusal for lack of credits
|
|
40
|
+
// reaches the app as one neutral sentence (neutralNote), never with a buy step.
|
|
41
|
+
//
|
|
42
|
+
// BYTE-TWIN: this file ships in the npm CLI as cli/mcp/openai-extensions.mjs and must stay identical (mcp-parity,
|
|
43
|
+
// cli-package-loads-check). It imports nothing from tools.mjs (no cycle); tools.mjs hands it what it needs.
|
|
44
|
+
import { createHash } from 'node:crypto';
|
|
45
|
+
import { z } from 'zod';
|
|
46
|
+
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
47
|
+
|
|
48
|
+
export const CHATGPT_EXT_ENV = 'HERMOSO_CHATGPT_EXT';
|
|
49
|
+
export const LIBRARY_APP_MIME = 'text/html;profile=mcp-app'; // the MCP Apps profile (ext-apps RESOURCE_MIME_TYPE)
|
|
50
|
+
export const EXT_HOME_TOOL = 'open_hermoso_home';
|
|
51
|
+
export const EXT_ENTRY_TOOL = 'open_library';
|
|
52
|
+
export const EXT_DATA_TOOL = 'library_app_data';
|
|
53
|
+
export const EXT_CONCEPTS_TOOL = 'show_ad_concepts';
|
|
54
|
+
export const EXT_RESULTS_TOOL = 'show_ad_results';
|
|
55
|
+
export const EXT_BRIEF_TOOL = 'open_ad_brief';
|
|
56
|
+
export const EXT_SETUP_TOOL = 'set_up_hermoso';
|
|
57
|
+
export const EXT_SETTINGS_READ_TOOL = 'read_hermoso_settings';
|
|
58
|
+
export const EXT_SETTINGS_UPDATE_TOOL = 'update_hermoso_settings';
|
|
59
|
+
export const EXT_MENTIONS_TOOL = 'search_hermoso_items';
|
|
60
|
+
export const EXT_RESCHEDULE_TOOL = 'reschedule_post_from_calendar';
|
|
61
|
+
export const EXT_CANCEL_TOOL = 'cancel_post_from_calendar';
|
|
62
|
+
export const EXT_DRAFT_TOOL = 'draft_brand_from_setup';
|
|
63
|
+
export const EXT_RENDER_TOOL = 'render_concept_from_app';
|
|
64
|
+
export const EXT_SCHEDULE_TOOL = 'schedule_post_from_app';
|
|
65
|
+
export const EXT_ADS_STATUS_TOOL = 'set_campaign_status_from_app';
|
|
66
|
+
export const EXT_TOOL_NAMES = Object.freeze([EXT_HOME_TOOL, EXT_ENTRY_TOOL, EXT_DATA_TOOL, EXT_CONCEPTS_TOOL, EXT_RESULTS_TOOL, EXT_BRIEF_TOOL, EXT_SETUP_TOOL, EXT_SETTINGS_READ_TOOL, EXT_SETTINGS_UPDATE_TOOL, EXT_MENTIONS_TOOL, EXT_RESCHEDULE_TOOL, EXT_CANCEL_TOOL, EXT_DRAFT_TOOL, EXT_RENDER_TOOL, EXT_SCHEDULE_TOOL, EXT_ADS_STATUS_TOOL]);
|
|
67
|
+
// Tools the app (or ChatGPT itself) calls and the model is never offered (`_meta.ui.visibility: ['app']`).
|
|
68
|
+
export const EXT_APP_ONLY_TOOLS = Object.freeze([EXT_DATA_TOOL, EXT_MENTIONS_TOOL, EXT_RESCHEDULE_TOOL, EXT_CANCEL_TOOL, EXT_DRAFT_TOOL, EXT_RENDER_TOOL, EXT_SCHEDULE_TOOL, EXT_ADS_STATUS_TOOL]);
|
|
69
|
+
// Tools that render the app (each links `_meta.ui.resourceUri`).
|
|
70
|
+
export const EXT_APP_TOOLS = Object.freeze([EXT_HOME_TOOL, EXT_ENTRY_TOOL, EXT_CONCEPTS_TOOL, EXT_RESULTS_TOOL, EXT_BRIEF_TOOL, EXT_SETUP_TOOL]);
|
|
71
|
+
export const EXT_SECTIONS = Object.freeze(['library', 'jobs', 'brands', 'calendar', 'brand', 'connectors', 'creators', 'products', 'prefs', 'ads']);
|
|
72
|
+
export const EXT_VIEWS = Object.freeze(['home', 'library', 'jobs', 'calendar', 'ads', 'create', 'setup', 'concepts', 'results']);
|
|
73
|
+
|
|
74
|
+
// ── THE ADS VIEW: ONE ROW PER PLATFORM, EACH NAMING THE EXISTING TOOLS IT READS AND WRITES THROUGH ────────────────
|
|
75
|
+
// Every platform's own list tool and status tool, with the argument shapes those tools declare (read from their
|
|
76
|
+
// inputSchemas in tools.mjs, 2026-10-03). `provider` is the connection the tools need; the check asserts it equals
|
|
77
|
+
// roster-scope's toolProvider() for both tools, so this table cannot drift from the gate. A platform is read only
|
|
78
|
+
// when its connection is on (list_connectors). `accounts` is the tool that names the ad accounts shared with this
|
|
79
|
+
// brand, where the list tool needs one; the view reads at most three per platform. Both directions of a status change
|
|
80
|
+
// pass confirm:true, because the app only calls after the user confirmed that exact change on screen (and TikTok and
|
|
81
|
+
// Snapchat refuse every change without it). Pause and turn-on only: archive and delete stay chat requests.
|
|
82
|
+
const firstOf = (...v) => v.find((x) => x != null && x !== '');
|
|
83
|
+
export const ADS_PLATFORMS = Object.freeze({
|
|
84
|
+
meta: { provider: 'meta', label: 'Meta',
|
|
85
|
+
accounts: { tool: 'list_meta_pages', args: {}, pick: (d) => (d.adAccounts || []).map((a) => ({ id: `act_${String(a.accountId || a.id || '').replace(/^act_/, '')}`, name: a.name || '' })) },
|
|
86
|
+
list: { tool: 'list_meta_ads', args: (acc) => ({ adAccountId: acc.id, level: 'campaign', limit: 50 }), rows: (d) => d.items },
|
|
87
|
+
status: { tool: 'set_meta_campaign_status', args: (acc, id, on) => ({ campaignId: id, status: on ? 'ACTIVE' : 'PAUSED', confirm: true }) } },
|
|
88
|
+
google_ads: { provider: 'google_ads', label: 'Google Ads',
|
|
89
|
+
accounts: { tool: 'list_google_ads_campaigns', args: {}, pick: (d) => (d.accounts || []).map((a) => ({ id: String(a.customerId || a.id || ''), name: a.name || '' })) },
|
|
90
|
+
list: { tool: 'list_google_ads_campaigns', args: (acc) => ({ customerId: acc.id, limit: 50 }), rows: (d) => d.campaigns, window: 'last 30 days' }, // the tool's own default metrics window
|
|
91
|
+
status: { tool: 'set_google_ads_status', args: (acc, id, on) => ({ customerId: acc.id, level: 'campaign', campaignId: id, status: on ? 'ENABLED' : 'PAUSED', confirm: true }) } },
|
|
92
|
+
microsoft_ads: { provider: 'microsoft_ads', label: 'Microsoft Advertising',
|
|
93
|
+
accounts: { tool: 'list_microsoft_ads_campaigns', args: {}, pick: (d) => (d.accounts || []).map((a) => ({ id: String(a.accountId || a.id || ''), name: a.name || '' })) },
|
|
94
|
+
list: { tool: 'list_microsoft_ads_campaigns', args: (acc) => ({ accountId: acc.id }), rows: (d) => d.campaigns },
|
|
95
|
+
status: { tool: 'set_microsoft_ads_status', args: (acc, id, on) => ({ accountId: acc.id, level: 'campaign', campaignId: id, status: on ? 'Active' : 'Paused', confirm: true }) } },
|
|
96
|
+
linkedin: { provider: 'linkedin', label: 'LinkedIn Ads',
|
|
97
|
+
accounts: { tool: 'list_linkedin_ads_campaigns', args: {}, pick: (d) => (d.accounts || []).map((a) => ({ id: String(a.id || ''), name: a.name || '' })) },
|
|
98
|
+
list: { tool: 'list_linkedin_ads_campaigns', args: (acc) => ({ adAccountId: acc.id }), rows: (d) => d.campaigns },
|
|
99
|
+
status: { tool: 'set_linkedin_ads_status', args: (acc, id, on) => ({ adAccountId: acc.id, level: 'campaign', campaignId: id, status: on ? 'ACTIVE' : 'PAUSED', confirm: true }) } },
|
|
100
|
+
tiktok_ads: { provider: 'tiktok_ads', label: 'TikTok Ads',
|
|
101
|
+
accounts: { tool: 'list_tiktok_ads_accounts', args: {}, pick: (d) => (d.advertisers || []).map((a) => ({ id: String(a.advertiserId || a.id || ''), name: a.name || '' })) },
|
|
102
|
+
list: { tool: 'list_tiktok_ads_campaigns', args: (acc) => ({ advertiserId: acc.id }), rows: (d) => d.campaigns },
|
|
103
|
+
status: { tool: 'set_tiktok_ads_status', args: (acc, id, on) => ({ advertiserId: acc.id, level: 'campaign', ids: [id], status: on ? 'ENABLE' : 'DISABLE', confirm: true }) } },
|
|
104
|
+
snapchat_ads: { provider: 'snapchat_ads', label: 'Snapchat Ads',
|
|
105
|
+
accounts: { tool: 'list_snapchat_ads_accounts', args: {}, pick: (d) => (d.adAccounts || []).map((a) => ({ id: String(a.adAccountId || a.id || ''), name: a.name || '' })) },
|
|
106
|
+
list: { tool: 'list_snapchat_ads_campaigns', args: (acc) => ({ adAccountId: acc.id }), rows: (d) => d.campaigns },
|
|
107
|
+
status: { tool: 'set_snapchat_ads_status', args: (acc, id, on) => ({ adAccountId: acc.id, level: 'campaign', ids: [id], status: on ? 'ACTIVE' : 'PAUSED', confirm: true }) } },
|
|
108
|
+
pinterest_ads: { provider: 'pinterest_ads', label: 'Pinterest Ads',
|
|
109
|
+
accounts: { tool: 'list_pinterest_ads_campaigns', args: {}, pick: (d) => (d.accounts || []).map((a) => ({ id: String(a.adAccountId || a.id || ''), name: a.name || '' })) },
|
|
110
|
+
list: { tool: 'list_pinterest_ads_campaigns', args: (acc) => ({ adAccountId: acc.id }), rows: (d) => d.campaigns },
|
|
111
|
+
status: { tool: 'set_pinterest_ads_status', args: (acc, id, on) => ({ adAccountId: acc.id, level: 'campaign', campaignId: id, status: on ? 'ACTIVE' : 'PAUSED', confirm: true }) } },
|
|
112
|
+
reddit_ads: { provider: 'reddit_ads', label: 'Reddit Ads',
|
|
113
|
+
accounts: { tool: 'list_reddit_ads_campaigns', args: {}, pick: (d) => (d.accounts || []).map((a) => ({ id: String(a.id || a.adAccountId || ''), name: a.name || '' })) },
|
|
114
|
+
list: { tool: 'list_reddit_ads_campaigns', args: (acc) => ({ adAccountId: acc.id }), rows: (d) => d.campaigns },
|
|
115
|
+
status: { tool: 'set_reddit_ads_status', args: (acc, id, on) => ({ adAccountId: acc.id, kind: 'campaign', id, status: on ? 'ACTIVE' : 'PAUSED', confirm: true }) } },
|
|
116
|
+
x_ads: { provider: 'x_ads', label: 'X Ads',
|
|
117
|
+
accounts: { tool: 'list_x_ads_accounts', args: {}, pick: (d) => (d.accounts || []).map((a) => ({ id: String(a.id || a.accountId || ''), name: a.name || '' })) },
|
|
118
|
+
list: { tool: 'list_x_ads_campaigns', args: (acc) => ({ accountId: acc.id }), rows: (d) => d.campaigns },
|
|
119
|
+
status: { tool: 'set_x_ads_status', args: (acc, id, on) => ({ accountId: acc.id, campaignId: id, status: on ? 'ACTIVE' : 'PAUSED', confirm: true }) } },
|
|
120
|
+
openai_ads: { provider: 'openai_ads', label: 'ChatGPT Ads', accounts: null,
|
|
121
|
+
list: { tool: 'list_openai_ads_campaigns', args: () => ({}), rows: (d) => d.campaigns, account: (d) => (d.account ? { id: String(d.account.id || ''), name: d.account.brandName || d.account.name || '' } : null) },
|
|
122
|
+
status: { tool: 'set_openai_ads_status', args: (acc, id, on) => ({ level: 'campaign', campaignId: id, status: on ? 'active' : 'paused', confirm: true }) } },
|
|
123
|
+
applovin_ads: { provider: 'applovin_ads', label: 'AppLovin Ads', accounts: null, // one connection = one AppLovin account
|
|
124
|
+
list: { tool: 'list_applovin_ads_campaigns', args: () => ({}), rows: (d) => d.campaigns },
|
|
125
|
+
status: { tool: 'set_applovin_ads_status', args: (acc, id, on) => ({ level: 'campaign', campaignId: id, status: on ? 'LIVE' : 'PAUSED', confirm: true }) } },
|
|
126
|
+
apple_ads: { provider: 'apple_ads', label: 'Apple Ads', accounts: null,
|
|
127
|
+
list: { tool: 'list_apple_ads_campaigns', args: () => ({}), rows: (d) => d.campaigns },
|
|
128
|
+
status: { tool: 'set_apple_ads_status', args: (acc, id, on) => ({ level: 'campaign', id, status: on ? 'ENABLED' : 'PAUSED', confirm: true }) } },
|
|
129
|
+
});
|
|
130
|
+
export const ADS_STATUS_TOOLS = Object.freeze(Object.values(ADS_PLATFORMS).map((p) => p.status.tool));
|
|
131
|
+
// The existing tool(s) each app-only WRITE runs, in-process, after the user confirmed in the app.
|
|
132
|
+
export const EXT_DELEGATES = Object.freeze({
|
|
133
|
+
[EXT_RESCHEDULE_TOOL]: 'reschedule_post', [EXT_CANCEL_TOOL]: 'cancel_scheduled', [EXT_DRAFT_TOOL]: 'draft_brand',
|
|
134
|
+
[EXT_SCHEDULE_TOOL]: 'schedule_post',
|
|
135
|
+
[EXT_RENDER_TOOL]: Object.freeze(['plan_ad', 'render_ad', 'generate_image']),
|
|
136
|
+
[EXT_ADS_STATUS_TOOL]: ADS_STATUS_TOOLS,
|
|
137
|
+
});
|
|
138
|
+
const ACTIVE_RE = /^(active|enabled?|running|delivering|serving|live)$/i; // live: AppLovin Ads (2026-10-04)
|
|
139
|
+
const PAUSED_RE = /^(paused|disabled?|campaign_paused|on_hold)$/i;
|
|
140
|
+
const money = (n, cur, usd) => (Number.isFinite(+n) ? (usd ? `$${+n}` : `${+n}${cur ? ' ' + cur : ''}`) : '');
|
|
141
|
+
// One campaign row, whatever the platform calls its fields. Spend and results appear ONLY when the list tool returned
|
|
142
|
+
// them (today: Google Ads' metrics); nothing is estimated. `state` decides which switch the row offers: the status a
|
|
143
|
+
// person SET (configured / operation / status) rather than what the platform says is serving.
|
|
144
|
+
export function adRow(c, currency = '', window = '') {
|
|
145
|
+
if (!c || typeof c !== 'object') return null;
|
|
146
|
+
const id = String(firstOf(c.id, c.campaign_id, c.campaignId) ?? '');
|
|
147
|
+
if (!id) return null;
|
|
148
|
+
const setTo = String(firstOf(c.configured_status, c.operation_status, c.status, c.effective_status, c.displayStatus) ?? '');
|
|
149
|
+
const shown = String(firstOf(c.effective_status, c.summaryStatus, c.displayStatus, c.status, c.configured_status, c.operation_status) ?? '');
|
|
150
|
+
const state = ACTIVE_RE.test(setTo) ? 'active' : PAUSED_RE.test(setTo) ? 'paused' : 'other';
|
|
151
|
+
const cur = c.currency || currency || '';
|
|
152
|
+
const daily = c.dailyBudgetUsd != null ? money(c.dailyBudgetUsd, '', true) : c.dailyBudget != null ? money(c.dailyBudget, cur) : c.dailyBudgetAmount?.amount != null ? money(c.dailyBudgetAmount.amount, c.dailyBudgetAmount.currency || cur) : '';
|
|
153
|
+
const total = !daily && c.lifetimeBudget != null ? money(c.lifetimeBudget, cur) : '';
|
|
154
|
+
const m = c.metrics && typeof c.metrics === 'object' ? c.metrics : null;
|
|
155
|
+
const results = m ? [m.costUsd != null && Number.isFinite(+m.costUsd) ? `$${(+m.costUsd).toFixed(2)} spent` : '', m.clicks != null ? `${m.clicks} clicks` : '', m.conversions != null ? `${m.conversions} conversions` : ''].filter(Boolean).join(' · ') : '';
|
|
156
|
+
return { id, name: clip(firstOf(c.name, c.campaign_name, c.title) || id, 90), status: humanize(String(shown).toLowerCase()), state, budget: daily ? `${daily} a day` : total ? `${total} total` : '', results: results && window ? `${results} (${window})` : results };
|
|
157
|
+
}
|
|
158
|
+
const PANEL_VIEWS = ['library', 'jobs', 'calendar'];
|
|
159
|
+
// The per-brand defaults this app adds (captions, end card, default channels). The language and the active brand
|
|
160
|
+
// are NOT stored here: they live where every other surface already reads them (/api/settings, use_brand).
|
|
161
|
+
export const EXT_PREFS_KEY = 'hermoso.chatgpt.prefs.v1';
|
|
162
|
+
export const EXT_SETTINGS_CAPABILITY = Object.freeze({ readTool: EXT_SETTINGS_READ_TOOL, updateTool: EXT_SETTINGS_UPDATE_TOOL });
|
|
163
|
+
|
|
164
|
+
// `?ext=1` on the connector URL. Read by the transport beside `?tools=`; presentation only, never auth or spend.
|
|
165
|
+
export const chatgptExtAsked = (v) => /^(1|true|on|yes)$/i.test(String(Array.isArray(v) ? v[0] : (v ?? '')).trim());
|
|
166
|
+
// All three keys, in one predicate so the transport and the check cannot disagree about what "on" means.
|
|
167
|
+
export const chatgptExtOn = ({ env = process.env, asked, widgetHost } = {}) =>
|
|
168
|
+
!!widgetHost && String(env?.[CHATGPT_EXT_ENV] ?? '').trim() === '1' && chatgptExtAsked(asked);
|
|
169
|
+
|
|
170
|
+
// THE SIDEBAR ICON. The spec asks for a 20x20 monochrome SVG in currentColor. This is the bloom (four petals and a
|
|
171
|
+
// centre, public/brand/bloom-mono-black.svg) redrawn in currentColor at the spec's stroke weight. A data: URI, so it
|
|
172
|
+
// needs no hosting and no CSP entry. SDK 1.29 does not serialize tool `icons`, so installEntrypointIcons adds it.
|
|
173
|
+
const ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20" fill="none">'
|
|
174
|
+
+ '<g stroke="currentColor" stroke-width="1.33">'
|
|
175
|
+
+ [0, 45, 90, 135].map((r) => `<ellipse cx="10" cy="6" rx="2.6" ry="5.2" transform="rotate(${r} 10 10)"/>`).join('')
|
|
176
|
+
+ '</g><circle cx="10" cy="10" r="1.3" fill="currentColor"/></svg>';
|
|
177
|
+
export const ENTRY_ICON = Object.freeze({ src: 'data:image/svg+xml;base64,' + Buffer.from(ICON_SVG).toString('base64'), mimeType: 'image/svg+xml', sizes: ['any'] });
|
|
178
|
+
|
|
179
|
+
// ── THE APP ──────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
180
|
+
// Self-contained HTML + inline JS (no bundler in this repo). The bridge is the MCP Apps postMessage JSON-RPC, written
|
|
181
|
+
// out by hand: the method names are pinned against @modelcontextprotocol/ext-apps spec.types (2026-01-26) and the
|
|
182
|
+
// OpenAI extension keys against @openai/mcp-extensions 0.1.0, both read 2026-10-02. Colours come from the host's own
|
|
183
|
+
// style variables (the names OpenAI's styles.css reads), with light/dark fallbacks.
|
|
184
|
+
// BUTTONS AND ACCENT ARE OURS, NOT THE HOST'S (2026-10-03, founder, from ChatGPT dark mode): the primary button used
|
|
185
|
+
// the host's info blue (--color-text-info) with the page background as its text, which ChatGPT dark mode painted as
|
|
186
|
+
// near-black text on a mid blue. Buttons now use the Typeset tokens: --btn-bg/--btn-fg (near-black on light, near-white
|
|
187
|
+
// on dark), a hairline --ring on secondary buttons, and one warm accent (#c74800, lifted to #ff9a5c on dark) for focus,
|
|
188
|
+
// selection and progress only. tools/chatgpt-extensions-check.mjs computes every button's text contrast in both themes.
|
|
189
|
+
// String.raw so regex backslashes in the inline script survive; nothing below uses ${} or a backtick. The one value
|
|
190
|
+
// spliced in is the bloom (%%BLOOM%%), so the hash below covers it.
|
|
191
|
+
const APP_STYLE = String.raw`<style>
|
|
192
|
+
:root{color-scheme:light dark;
|
|
193
|
+
--bg:var(--color-background-primary,light-dark(#fff,#181818));
|
|
194
|
+
--fg:var(--color-text-primary,light-dark(#1a1c1f,#fff));
|
|
195
|
+
--muted:var(--color-text-secondary,light-dark(rgba(26,28,31,.68),rgba(255,255,255,.62)));
|
|
196
|
+
--line:var(--color-border-secondary,light-dark(rgba(26,28,31,.1),rgba(255,255,255,.12)));
|
|
197
|
+
--card:color-mix(in oklab,var(--fg) 5%,var(--bg));
|
|
198
|
+
--card2:color-mix(in oklab,var(--fg) 9%,var(--bg));
|
|
199
|
+
--accent:light-dark(#c74800,#ff9a5c);
|
|
200
|
+
--accent-ink:light-dark(#a33b00,#ffb489);
|
|
201
|
+
--accent-bg:light-dark(#fbece3,#3a2114);
|
|
202
|
+
--btn-bg:light-dark(#0a0a0a,#fafafa);
|
|
203
|
+
--btn-fg:light-dark(#ffffff,#0a0a0a);
|
|
204
|
+
--ring:light-dark(rgba(10,10,10,.22),rgba(250,250,250,.28));
|
|
205
|
+
--ok:var(--color-text-success,light-dark(#118a3e,#62d38d));
|
|
206
|
+
--warn:var(--color-text-warning,light-dark(#b8450a,#ff8549));
|
|
207
|
+
--danger:light-dark(#b42318,#ff7b72);
|
|
208
|
+
--radius:var(--border-radius-lg,12px);
|
|
209
|
+
--cur:var(--cursor-interaction,pointer)}
|
|
210
|
+
:root[data-theme=light]{color-scheme:light}:root[data-theme=dark]{color-scheme:dark}
|
|
211
|
+
*{box-sizing:border-box}
|
|
212
|
+
[hidden]{display:none!important}
|
|
213
|
+
body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.45 var(--font-sans,-apple-system,system-ui,"Segoe UI",Roboto,sans-serif);-webkit-font-smoothing:antialiased}
|
|
214
|
+
button,select,input,textarea{font:inherit;color:inherit}
|
|
215
|
+
button,select{cursor:var(--cur)}
|
|
216
|
+
button:focus-visible,select:focus-visible,input:focus-visible,textarea:focus-visible,.tile:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
217
|
+
.top{display:flex;align-items:center;gap:6px;padding:8px 12px;border-bottom:1px solid var(--line);flex-wrap:wrap}
|
|
218
|
+
.brand{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:38%;min-width:0}
|
|
219
|
+
.brand select{max-width:100%;background:transparent;border:1px solid var(--line);border-radius:8px;padding:3px 6px}
|
|
220
|
+
.tabs{display:flex;gap:4px;margin-left:auto;flex-wrap:wrap}
|
|
221
|
+
@media (max-width:560px){.brand{max-width:62%}.tabs{order:3;width:100%;margin-left:0;flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none}.tabs::-webkit-scrollbar{display:none}.top .push{margin-left:auto}.tab{flex:none}}
|
|
222
|
+
.tab,.btn,.seg button,.chip{border:1px solid var(--ring);background:transparent;color:var(--fg);border-radius:999px;padding:4px 11px}
|
|
223
|
+
.tab[aria-selected=true],.seg button[aria-pressed=true]{background:var(--fg);color:var(--bg);border-color:var(--fg)}
|
|
224
|
+
.btn.primary{background:var(--btn-bg);border-color:var(--btn-bg);color:var(--btn-fg);font-weight:600}
|
|
225
|
+
.btn.danger{color:var(--danger);border-color:color-mix(in oklab,var(--danger) 45%,transparent)}
|
|
226
|
+
.btn:disabled{opacity:.5;cursor:default}
|
|
227
|
+
.icon{border:0;background:transparent;padding:4px 7px;border-radius:8px;color:var(--muted)}
|
|
228
|
+
.icon:hover{background:var(--card)}
|
|
229
|
+
.cardhead{display:flex;align-items:baseline;gap:8px;padding:10px 12px 0}
|
|
230
|
+
.cardhead b{font-size:15px}.cardhead span{color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
231
|
+
main{padding:12px}
|
|
232
|
+
.note{color:var(--muted);padding:16px 4px;text-align:center}
|
|
233
|
+
.note.err{color:var(--warn)}
|
|
234
|
+
.note.left{text-align:left;padding:6px 0}
|
|
235
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(128px,1fr));gap:10px}
|
|
236
|
+
.tile{position:relative;border:1px solid var(--line);border-radius:var(--radius);overflow:hidden;background:var(--card);aspect-ratio:1/1;padding:0;cursor:var(--cur)}
|
|
237
|
+
.tile img{width:100%;height:100%;object-fit:cover;display:block}
|
|
238
|
+
.tile .ph{display:flex;align-items:center;justify-content:center;height:100%;color:var(--muted);font-size:12px;padding:6px;text-align:center}
|
|
239
|
+
.tile .badge{position:absolute;left:6px;bottom:6px;background:rgba(0,0,0,.62);color:#fff;border-radius:6px;font-size:11px;padding:1px 6px}
|
|
240
|
+
.tile.sel{outline:2px solid var(--accent);outline-offset:-2px}
|
|
241
|
+
.tile .chk{position:absolute;right:6px;top:6px;background:var(--btn-bg);color:var(--btn-fg);border-radius:999px;font-size:11px;padding:1px 7px}
|
|
242
|
+
.tile .bar{position:absolute;left:8px;right:8px;bottom:8px;margin:0}
|
|
243
|
+
.detail{margin-top:12px;border:1px solid var(--line);border-radius:var(--radius);padding:10px;background:var(--card)}
|
|
244
|
+
.detail .media{display:flex;justify-content:center;background:rgba(0,0,0,.04);border-radius:8px;overflow:hidden}
|
|
245
|
+
.detail img,.detail video{max-width:100%;max-height:52vh;display:block}
|
|
246
|
+
.meta{color:var(--muted);font-size:12px;margin:8px 0}
|
|
247
|
+
.row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
|
248
|
+
.list{display:flex;flex-direction:column;gap:8px}
|
|
249
|
+
.item{display:flex;gap:10px;align-items:center;border:1px solid var(--line);border-radius:var(--radius);padding:8px;background:var(--card)}
|
|
250
|
+
.item img{width:48px;height:48px;object-fit:cover;border-radius:8px;flex:none}
|
|
251
|
+
.item .t{font-weight:600}.s{color:var(--muted);font-size:12px}
|
|
252
|
+
.grow{flex:1;min-width:0}
|
|
253
|
+
.bar{height:4px;background:var(--line);border-radius:4px;overflow:hidden;margin-top:6px}.bar i{display:block;height:100%;background:var(--accent)}
|
|
254
|
+
.bar i.busy{width:35%;animation:busy 1.4s ease-in-out infinite}
|
|
255
|
+
@keyframes busy{0%{margin-left:-35%}100%{margin-left:100%}}
|
|
256
|
+
@media (prefers-reduced-motion:reduce){.bar i.busy{animation:none;width:100%;opacity:.5}}
|
|
257
|
+
h2{font-size:17px;margin:4px 0 10px}
|
|
258
|
+
h3{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:16px 0 6px}
|
|
259
|
+
.hero{max-width:680px;margin:18px auto 6px;text-align:center}
|
|
260
|
+
.hero .logo{color:var(--accent);display:inline-flex}.hero .logo svg{width:34px;height:34px}
|
|
261
|
+
.hero h1{font-size:21px;margin:8px 0 14px;font-weight:650}
|
|
262
|
+
.composer{position:relative;text-align:left;border:1px solid var(--line);border-radius:18px;background:var(--card);padding:10px 10px 8px}
|
|
263
|
+
.composer textarea{width:100%;border:0;background:transparent;resize:none;outline:none;min-height:44px;max-height:180px;padding:2px 4px}
|
|
264
|
+
.crow{display:flex;align-items:center;gap:8px}
|
|
265
|
+
.send{margin-left:auto;width:32px;height:32px;border-radius:999px;border:0;background:var(--fg);color:var(--bg);font-weight:700}
|
|
266
|
+
.send:disabled{opacity:.35;cursor:default}
|
|
267
|
+
.mchips{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px}
|
|
268
|
+
.mchip{display:inline-flex;align-items:center;gap:6px;background:var(--accent-bg);color:var(--accent-ink);border-radius:999px;padding:2px 4px 2px 8px;font-size:12px;max-width:100%}
|
|
269
|
+
.mchip img{width:18px;height:18px;border-radius:5px;object-fit:cover}
|
|
270
|
+
.mchip button{border:0;background:transparent;color:inherit;padding:0 4px}
|
|
271
|
+
.menu{position:absolute;left:8px;right:8px;top:100%;margin-top:4px;z-index:5;background:var(--bg);border:1px solid var(--line);border-radius:12px;box-shadow:0 8px 28px rgba(0,0,0,.18);max-height:260px;overflow:auto;padding:4px}
|
|
272
|
+
.menu button{display:flex;gap:8px;align-items:center;width:100%;text-align:left;border:0;background:transparent;padding:6px 8px;border-radius:8px}
|
|
273
|
+
.menu button[aria-selected=true],.menu button:hover{background:var(--card2)}
|
|
274
|
+
.menu img,.menu .sq{width:26px;height:26px;border-radius:6px;object-fit:cover;flex:none;background:var(--card2)}
|
|
275
|
+
.menu .k{color:var(--muted);font-size:11px;margin-left:auto}
|
|
276
|
+
.quick{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;margin:12px 0 4px}
|
|
277
|
+
.chip{font-size:13px;background:var(--card)}
|
|
278
|
+
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
|
|
279
|
+
.gcard{text-align:left;border:1px solid var(--line);border-radius:var(--radius);background:var(--card);padding:12px;cursor:var(--cur)}
|
|
280
|
+
.gcard b{display:block;margin-bottom:2px}.gcard span{color:var(--muted);font-size:12px}
|
|
281
|
+
.field{margin:12px 0}
|
|
282
|
+
.field>label,.flabel{display:block;font-weight:600;margin-bottom:6px}
|
|
283
|
+
.field textarea,.field input[type=text],.field input[type=datetime-local],select.in{width:100%;border:1px solid var(--line);border-radius:10px;background:var(--bg);padding:8px 10px}
|
|
284
|
+
.seg{display:inline-flex;gap:4px;flex-wrap:wrap}
|
|
285
|
+
.pick{display:grid;grid-template-columns:repeat(auto-fill,minmax(76px,1fr));gap:8px}
|
|
286
|
+
.pick .tile{aspect-ratio:1/1}
|
|
287
|
+
.pick .tile .ph{font-size:11px}
|
|
288
|
+
.pick .tile[aria-pressed=true]{outline:2px solid var(--accent);outline-offset:-2px}
|
|
289
|
+
.pick .cap{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(transparent,rgba(0,0,0,.65));color:#fff;font-size:10px;padding:10px 5px 3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
290
|
+
dl.sum{display:grid;grid-template-columns:max-content 1fr;gap:6px 14px;margin:8px 0}
|
|
291
|
+
dl.sum dt{color:var(--muted)}dl.sum dd{margin:0;min-width:0;overflow-wrap:anywhere}
|
|
292
|
+
dl.sum img{width:40px;height:40px;object-fit:cover;border-radius:8px;vertical-align:middle;margin-right:6px}
|
|
293
|
+
.steps{display:flex;gap:6px;margin:0 0 12px;flex-wrap:wrap}
|
|
294
|
+
.steps span{font-size:12px;color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:2px 10px}
|
|
295
|
+
.steps span.on{color:var(--bg);background:var(--fg);border-color:var(--fg)}
|
|
296
|
+
.bcard{display:flex;gap:12px;align-items:flex-start;border:1px solid var(--line);border-radius:var(--radius);padding:12px;background:var(--card)}
|
|
297
|
+
.bcard img{width:56px;height:56px;object-fit:contain;border-radius:10px;background:#fff;flex:none}
|
|
298
|
+
.pill{display:inline-block;font-size:11px;border-radius:999px;padding:1px 8px;border:1px solid var(--line);color:var(--muted)}
|
|
299
|
+
.pill.on{color:var(--ok);border-color:color-mix(in oklab,var(--ok) 45%,transparent)}
|
|
300
|
+
.pill.warn{color:var(--warn);border-color:color-mix(in oklab,var(--warn) 45%,transparent)}
|
|
301
|
+
.concept{border:1px solid var(--line);border-radius:var(--radius);background:var(--card);padding:12px;margin-bottom:10px}
|
|
302
|
+
.concept .n{display:inline-flex;width:22px;height:22px;border-radius:999px;background:var(--fg);color:var(--bg);font-size:12px;font-weight:700;align-items:center;justify-content:center;margin-right:6px}
|
|
303
|
+
.concept .ttl{font-weight:650}
|
|
304
|
+
.concept .hl{margin:6px 0 2px;font-weight:600}
|
|
305
|
+
.concept .vis{color:var(--muted);font-size:12px;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;margin-top:4px}
|
|
306
|
+
.confirm{margin-top:10px;border-top:1px solid var(--line);padding-top:10px}
|
|
307
|
+
.confirm select{border:1px solid var(--line);border-radius:8px;background:var(--bg);padding:3px 6px}
|
|
308
|
+
.week{display:flex;flex-direction:column;gap:8px}
|
|
309
|
+
@media (min-width:760px){.week{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px}.day{min-height:180px}}
|
|
310
|
+
.day{border:1px solid var(--line);border-radius:10px;padding:6px;background:var(--card);min-width:0}
|
|
311
|
+
.day.past{opacity:.6}
|
|
312
|
+
.day.drop{outline:2px dashed var(--accent);outline-offset:-2px}
|
|
313
|
+
.day .dh{font-size:12px;color:var(--muted);margin-bottom:6px;display:flex;justify-content:space-between}
|
|
314
|
+
.day .dh b{color:var(--fg)}
|
|
315
|
+
.post{display:block;width:100%;text-align:left;border:1px solid var(--line);border-radius:8px;background:var(--bg);padding:6px;margin-bottom:6px;cursor:grab}
|
|
316
|
+
.post.ro{cursor:var(--cur)}
|
|
317
|
+
.post .pt{font-weight:600;font-size:12px}.post .pc{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
318
|
+
.post img{width:100%;height:56px;object-fit:cover;border-radius:6px;margin-top:4px;display:block}
|
|
319
|
+
.post.open{outline:2px solid var(--accent);outline-offset:-2px}
|
|
320
|
+
.wkbar{display:flex;align-items:center;gap:8px;margin-bottom:8px}
|
|
321
|
+
.wkbar b{margin-right:auto}
|
|
322
|
+
.modal{position:fixed;inset:0;background:rgba(0,0,0,.42);display:flex;align-items:center;justify-content:center;padding:16px;z-index:20}
|
|
323
|
+
.modal[hidden]{display:none}
|
|
324
|
+
.mbox{background:var(--bg);color:var(--fg);border-radius:16px;max-width:420px;width:100%;padding:16px;box-shadow:0 18px 50px rgba(0,0,0,.3)}
|
|
325
|
+
.mbox h4{margin:0 0 6px;font-size:16px}
|
|
326
|
+
.mbox p{margin:0 0 14px;color:var(--muted)}
|
|
327
|
+
.mbox .row{justify-content:flex-end}
|
|
328
|
+
.toast{position:fixed;left:50%;bottom:14px;transform:translateX(-50%);background:var(--fg);color:var(--bg);border-radius:999px;padding:6px 14px;font-size:13px;z-index:30;max-width:90%}
|
|
329
|
+
.toast[hidden]{display:none}
|
|
330
|
+
.notices{display:flex;flex-direction:column;gap:4px;margin-top:10px}
|
|
331
|
+
.notice{display:flex;gap:7px;align-items:flex-start;font-size:12px;color:var(--muted);text-align:left}
|
|
332
|
+
.notice .ni{flex:none;width:15px;height:15px;border-radius:999px;border:1px solid currentColor;font-size:9px;line-height:13px;text-align:center;font-weight:700;font-style:normal;margin-top:1px}
|
|
333
|
+
body.modal-open main{min-height:300px}
|
|
334
|
+
</style>`;
|
|
335
|
+
|
|
336
|
+
const APP_BODY = String.raw`<div class="top" id="top">
|
|
337
|
+
<div class="brand" id="brand"></div>
|
|
338
|
+
<div class="tabs" role="tablist" id="tabs">
|
|
339
|
+
<button class="tab" role="tab" data-act="tab" data-view="home">Home</button>
|
|
340
|
+
<button class="tab" role="tab" data-act="tab" data-view="library">Library</button>
|
|
341
|
+
<button class="tab" role="tab" data-act="tab" data-view="jobs">Renders</button>
|
|
342
|
+
<button class="tab" role="tab" data-act="tab" data-view="calendar">Calendar</button>
|
|
343
|
+
<button class="tab" role="tab" data-act="tab" data-view="ads">Ads</button>
|
|
344
|
+
<button class="tab" role="tab" data-act="tab" data-view="create">Create</button>
|
|
345
|
+
</div>
|
|
346
|
+
<button class="icon push" data-act="tab" data-view="setup" title="Set up Hermoso" aria-label="Set up Hermoso">⚙</button>
|
|
347
|
+
<button class="icon" id="refresh" data-act="refresh" title="Refresh" aria-label="Refresh">↻</button>
|
|
348
|
+
<button class="icon" id="expand" data-act="expand" title="Expand" aria-label="Expand" hidden>⤢</button>
|
|
349
|
+
</div>
|
|
350
|
+
<div class="cardhead" id="cardhead" hidden><b id="cardtitle"></b><span id="cardsub"></span></div>
|
|
351
|
+
<main id="main"><div class="note">Loading Hermoso...</div></main>
|
|
352
|
+
<div class="modal" id="modal" hidden></div>
|
|
353
|
+
<div class="toast" id="toast" role="status" hidden></div>`;
|
|
354
|
+
|
|
355
|
+
const APP_SCRIPT = String.raw`<script>
|
|
356
|
+
(function(){
|
|
357
|
+
'use strict';
|
|
358
|
+
var PROTOCOL = '2026-01-26';
|
|
359
|
+
var TOOL = { data: 'library_app_data', reschedule: 'reschedule_post_from_calendar', cancel: 'cancel_post_from_calendar', draft: 'draft_brand_from_setup', render: 'render_concept_from_app', schedule: 'schedule_post_from_app', adsStatus: 'set_campaign_status_from_app', useBrand: 'use_brand' };
|
|
360
|
+
var TOOL_VIEW = { open_hermoso_home: 'home', open_library: 'library', open_ad_brief: 'create', set_up_hermoso: 'setup', show_ad_concepts: 'concepts', show_ad_results: 'results' };
|
|
361
|
+
var CARD_VIEWS = ['concepts', 'results'];
|
|
362
|
+
var PANEL_VIEWS = ['home', 'library', 'jobs', 'calendar', 'ads', 'create', 'setup'];
|
|
363
|
+
var SECTIONS_FOR = { home: ['brand', 'brands', 'library', 'jobs', 'prefs'], library: ['library', 'jobs', 'brands', 'calendar'], jobs: ['jobs', 'brands'], calendar: ['calendar', 'brands', 'prefs'], ads: ['ads', 'brands'], create: ['brand', 'brands', 'library', 'creators', 'products', 'prefs'], setup: ['brand', 'brands', 'connectors'] };
|
|
364
|
+
var EDITABLE = ['queued', 'scheduled'];
|
|
365
|
+
var state = {
|
|
366
|
+
view: 'home', surface: 'panel', data: null, host: {}, caps: {}, selected: [], focus: null, busy: false, pollTimer: null, pollStarted: 0, routed: false, ready: false,
|
|
367
|
+
composer: { mentions: [], menu: [], hi: 0, loadingPool: false },
|
|
368
|
+
brief: { step: 'form', format: 'image', aspect: '1:1', length: 15, count: 4, product: null, productPhoto: null, creator: null, asset: null },
|
|
369
|
+
setup: { step: 'brand', mode: 'website', editing: false, drafting: false, draft: null, watch: null, watchUntil: 0, watchTimer: null },
|
|
370
|
+
concepts: { open: null, sent: {}, opts: {}, starting: null, err: null },
|
|
371
|
+
sched: null, started: [], noticesSaid: "[]",
|
|
372
|
+
cal: { offset: 0, open: null, full: {}, drag: null, draftAt: '' }
|
|
373
|
+
};
|
|
374
|
+
var pending = {}; var nextId = 1;
|
|
375
|
+
var $ = function(id){ return document.getElementById(id); };
|
|
376
|
+
var esc = function(s){ return String(s == null ? '' : s).replace(/[&<>"']/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); };
|
|
377
|
+
var D = function(){ return state.data || {}; };
|
|
378
|
+
|
|
379
|
+
// ── the MCP Apps bridge: JSON-RPC over postMessage to the host frame ──
|
|
380
|
+
function send(msg){ try { window.parent.postMessage(msg, '*'); } catch (e) {} }
|
|
381
|
+
function rpc(method, params, ms){
|
|
382
|
+
var id = nextId++;
|
|
383
|
+
send({ jsonrpc: '2.0', id: id, method: method, params: params || {} });
|
|
384
|
+
return new Promise(function(resolve, reject){
|
|
385
|
+
pending[id] = { resolve: resolve, reject: reject };
|
|
386
|
+
setTimeout(function(){ if (pending[id]) { delete pending[id]; reject(new Error('The host did not answer ' + method)); } }, ms || 60000);
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
function notify(method, params){ send({ jsonrpc: '2.0', method: method, params: params || {} }); }
|
|
390
|
+
window.addEventListener('message', function(ev){
|
|
391
|
+
if (ev.source !== window.parent) return;
|
|
392
|
+
var m = ev.data; if (!m || m.jsonrpc !== '2.0') return;
|
|
393
|
+
if (m.id != null && !m.method) {
|
|
394
|
+
var p = pending[m.id]; if (!p) return; delete pending[m.id];
|
|
395
|
+
if (m.error) p.reject(new Error((m.error && m.error.message) || 'Request failed')); else p.resolve(m.result);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (m.method === 'ui/notifications/tool-input') { var v = m.params && m.params.arguments && m.params.arguments.view; if (v && !state.data) setView(v, true); return; }
|
|
399
|
+
if (m.method === 'ui/notifications/tool-result') { var sc = m.params && m.params.structuredContent; if (sc) takeResult(sc); return; }
|
|
400
|
+
if (m.method === 'ui/notifications/host-context-changed') { applyHost(m.params || {}); return; }
|
|
401
|
+
if (m.method === 'ui/resource-teardown' && m.id != null) { stopPoll(); stopWatch(); send({ jsonrpc: '2.0', id: m.id, result: {} }); return; }
|
|
402
|
+
if (m.method === 'ping' && m.id != null) { send({ jsonrpc: '2.0', id: m.id, result: {} }); return; }
|
|
403
|
+
});
|
|
404
|
+
function callTool(name, args, ms){
|
|
405
|
+
return rpc('tools/call', { name: name, arguments: args || {} }, ms).then(function(r){
|
|
406
|
+
if (r && r.isError) throw new Error(textOf(r) || 'That did not work.');
|
|
407
|
+
return r || {};
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
function textOf(r){ var c = (r && r.content) || []; for (var i = 0; i < c.length; i++) if (c[i].type === 'text') return c[i].text; return ''; }
|
|
411
|
+
|
|
412
|
+
// ── host context: theme, style variables, display mode, deep link, attached context ──
|
|
413
|
+
function applyHost(ctx){
|
|
414
|
+
for (var k in ctx) state.host[k] = ctx[k];
|
|
415
|
+
var root = document.documentElement;
|
|
416
|
+
if (ctx.theme) root.setAttribute('data-theme', ctx.theme);
|
|
417
|
+
var vars = ctx.styles && ctx.styles.variables;
|
|
418
|
+
if (vars) for (var name in vars) { if (vars[name] != null) root.style.setProperty(name, vars[name]); }
|
|
419
|
+
if (ctx['openai/interactionCursor']) root.style.setProperty('--cursor-interaction', ctx['openai/interactionCursor'] === 'default' ? 'default' : 'pointer');
|
|
420
|
+
var modes = state.host.availableDisplayModes || [];
|
|
421
|
+
$('expand').hidden = !(state.host.displayMode === 'inline' && modes.indexOf('fullscreen') >= 0);
|
|
422
|
+
if (!state.data && ctx.toolInfo && ctx.toolInfo.tool && TOOL_VIEW[ctx.toolInfo.tool.name]) { state.view = TOOL_VIEW[ctx.toolInfo.tool.name]; state.surface = CARD_VIEWS.indexOf(state.view) >= 0 ? 'card' : 'panel'; }
|
|
423
|
+
if (ctx['openai/deepLink'] && ctx['openai/deepLink'].url) route(ctx['openai/deepLink'].url);
|
|
424
|
+
if ('openai/modelContext' in ctx) {
|
|
425
|
+
var mc = ctx['openai/modelContext'];
|
|
426
|
+
var urls = mc && mc.structuredContent && mc.structuredContent.selected;
|
|
427
|
+
state.selected = Array.isArray(urls) ? urls.filter(function(u){ return typeof u === 'string'; }) : [];
|
|
428
|
+
}
|
|
429
|
+
render();
|
|
430
|
+
}
|
|
431
|
+
function route(url){
|
|
432
|
+
var seg = String(url || '/').split('?')[0].split('/').filter(Boolean)[0] || 'home';
|
|
433
|
+
if (PANEL_VIEWS.indexOf(seg) >= 0) { state.routed = true; state.surface = 'panel'; setView(seg, true); }
|
|
434
|
+
}
|
|
435
|
+
function reportSize(){
|
|
436
|
+
// The CONTENT height, not the frame's: documentElement.scrollHeight is never less than the iframe itself, so an
|
|
437
|
+
// inline card could grow and never shrink back. Sent only after the handshake, as the bridge expects.
|
|
438
|
+
if (!state.ready || (state.host.displayMode && state.host.displayMode !== 'inline')) return;
|
|
439
|
+
notify('ui/notifications/size-changed', { height: Math.ceil(document.body.getBoundingClientRect().height) });
|
|
440
|
+
}
|
|
441
|
+
function takeResult(sc){
|
|
442
|
+
state.data = sc;
|
|
443
|
+
if (sc.view && !state.routed) state.view = sc.view;
|
|
444
|
+
state.surface = sc.surface || (CARD_VIEWS.indexOf(state.view) >= 0 ? 'card' : 'panel');
|
|
445
|
+
if (sc.view === 'create' && sc.brief) { if (sc.brief.format) state.brief.format = sc.brief.format; if (sc.brief.product) state.brief.product = sc.brief.product; if (state.brief.format === 'video' && state.brief.aspect === '1:1') state.brief.aspect = '9:16'; }
|
|
446
|
+
if (sc.view === 'setup' && sc.step) state.setup.step = sc.step;
|
|
447
|
+
render(); schedulePoll();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── server data, through the host (no fetch: the app has no network of its own) ──
|
|
451
|
+
function load(sections, extra){
|
|
452
|
+
if (state.busy) return Promise.resolve();
|
|
453
|
+
state.busy = true; $('refresh').disabled = true;
|
|
454
|
+
var args = { view: state.view, sections: sections || [] };
|
|
455
|
+
for (var x in (extra || {})) args[x] = extra[x];
|
|
456
|
+
return rpc('tools/call', { name: TOOL.data, arguments: args }).then(function(r){
|
|
457
|
+
var sc = r && r.structuredContent;
|
|
458
|
+
if (r && r.isError) throw new Error(textOf(r) || 'Could not load this right now.');
|
|
459
|
+
if (sc) {
|
|
460
|
+
// Merge: a jobs-only poll must not wipe the Library, and a section that now reads must drop its old note.
|
|
461
|
+
var prev = state.data || {}; var notes = {}; var k;
|
|
462
|
+
for (k in (prev.notes || {})) if ((sections || []).indexOf(k) < 0) notes[k] = prev.notes[k];
|
|
463
|
+
for (k in (sc.notes || {})) notes[k] = sc.notes[k];
|
|
464
|
+
for (k in sc) if (k !== 'notes' && k !== 'view' && k !== 'surface' && k !== 'results' && k !== 'post') prev[k] = sc[k];
|
|
465
|
+
if (sc.results) {
|
|
466
|
+
var have = prev.results || [];
|
|
467
|
+
sc.results.forEach(function(n){ var i = -1; for (var j = 0; j < have.length; j++) if (have[j].id && have[j].id === n.id) i = j; if (i >= 0) { if (!n.label) n.label = have[i].label; if (n.caption == null) n.caption = have[i].caption; n.notices = joinNotices(have[i].notices, n.notices); have[i] = n; } else have.push(n); });
|
|
468
|
+
prev.results = have;
|
|
469
|
+
}
|
|
470
|
+
if (sc.post && sc.post.id) state.cal.full[sc.post.id] = sc.post;
|
|
471
|
+
prev.notes = notes; state.data = prev;
|
|
472
|
+
}
|
|
473
|
+
}).catch(function(e){ state.loadError = String(e && e.message || e); }).then(function(){
|
|
474
|
+
state.busy = false; $('refresh').disabled = false; render(); schedulePoll();
|
|
475
|
+
if (startedNoticesKey() !== state.noticesSaid) syncContext();
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
// What a render says to the person (a saved photo of a person left out as the product, a label or product finding):
|
|
479
|
+
// one plain line each, written by the server (renderNotices). The card shows them under the render while it renders
|
|
480
|
+
// and once it is done, and the model is told the same lines as context.
|
|
481
|
+
function joinNotices(a, b){ var out = []; (a || []).concat(b || []).forEach(function(t){ if (t && out.indexOf(t) < 0) out.push(t); }); return out; }
|
|
482
|
+
function noticesFor(jobId){ var rs = D().results || []; for (var i = 0; i < rs.length; i++) if (jobId && rs[i].id === jobId) return rs[i].notices || []; return []; }
|
|
483
|
+
function startedNoticesKey(){ return JSON.stringify(state.started.map(function(s){ return joinNotices(s.notices, noticesFor(s.jobId)); })); }
|
|
484
|
+
function noticeHtml(list, lead){ return (list || []).map(function(t){ return '<div class="notice" role="note"><i class="ni" aria-hidden="true">i</i><span>' + (lead ? esc(lead) + ': ' : '') + esc(t) + '</span></div>'; }).join(''); }
|
|
485
|
+
function activeJobs(){ var j = (D().jobs) || []; return j.filter(function(x){ return x.status === 'queued' || x.status === 'running'; }); }
|
|
486
|
+
function pendingResults(){ var r = D().results || []; return r.filter(function(x){ return x.id && (x.status === 'queued' || x.status === 'running'); }); }
|
|
487
|
+
function schedulePoll(){
|
|
488
|
+
stopPoll();
|
|
489
|
+
var jobs = activeJobs().length, res = state.view === 'results' ? pendingResults() : [];
|
|
490
|
+
if (!jobs && !res.length) { state.pollStarted = 0; return; }
|
|
491
|
+
if (!state.pollStarted) state.pollStarted = Date.now();
|
|
492
|
+
if (Date.now() - state.pollStarted > 20 * 60 * 1000) return; // a stuck render must not poll for ever
|
|
493
|
+
state.pollTimer = setTimeout(function(){
|
|
494
|
+
if (res.length) { load([], { jobIds: res.map(function(x){ return x.id; }) }); return; }
|
|
495
|
+
var before = activeJobs().length;
|
|
496
|
+
load(['jobs']).then(function(){ if (activeJobs().length < before) load(['library', 'jobs']); });
|
|
497
|
+
}, 8000);
|
|
498
|
+
}
|
|
499
|
+
function stopPoll(){ if (state.pollTimer) { clearTimeout(state.pollTimer); state.pollTimer = null; } }
|
|
500
|
+
|
|
501
|
+
// ── talking to the chat ──
|
|
502
|
+
function assetBlock(a){
|
|
503
|
+
var title = (a.kind === 'video' ? 'Video' : 'Image') + (a.label ? ': ' + a.label : ' from your Hermoso Library');
|
|
504
|
+
var block = { type: 'text', text: 'Hermoso Library ' + (a.kind || 'asset') + (a.label ? ' "' + a.label + '"' : '') + '. Asset URL: ' + a.url + (a.model ? ' (made with ' + a.model + ')' : '') + '. Use this exact URL when the user asks to post, schedule, edit or reuse it.', _meta: { 'openai/title': title.slice(0, 80) } };
|
|
505
|
+
if (a.thumb) block._meta['openai/thumbnail'] = { src: a.thumb };
|
|
506
|
+
return block;
|
|
507
|
+
}
|
|
508
|
+
function canContext(){ var c = state.caps || {}; return !!(c.updateModelContext || (c.experimental && c.experimental['openai/modelContext'])); }
|
|
509
|
+
function syncContext(){
|
|
510
|
+
var pool = (D().items || []).concat(D().results || []);
|
|
511
|
+
var seen = {}; var items = pool.filter(function(a){ if (!a.url || seen[a.url] || state.selected.indexOf(a.url) < 0) return false; seen[a.url] = 1; return true; });
|
|
512
|
+
if (!canContext()) return Promise.resolve();
|
|
513
|
+
state.noticesSaid = startedNoticesKey();
|
|
514
|
+
var content = items.map(assetBlock).concat(state.started.map(function(s){ var nt = joinNotices(s.notices, noticesFor(s.jobId)); return { type: 'text', text: 'From the Hermoso app the user confirmed and started a render of ' + s.label + ' (job ' + s.jobId + '). It shows in the app; do not start it again.' + (nt.length ? ' The app shows the user this note about it: ' + nt.join(' ') : '') }; }));
|
|
515
|
+
return rpc('ui/update-model-context', { content: content, structuredContent: { selected: items.map(function(a){ return a.url; }), renders: state.started.map(function(s){ return s.jobId; }) } }).catch(function(){});
|
|
516
|
+
}
|
|
517
|
+
function toggleSelect(url){
|
|
518
|
+
var i = state.selected.indexOf(url);
|
|
519
|
+
if (i >= 0) state.selected.splice(i, 1); else state.selected.push(url);
|
|
520
|
+
syncContext(); render();
|
|
521
|
+
}
|
|
522
|
+
// Every hand-off to the conversation goes through here. Without ui/message the text is put in front of the user
|
|
523
|
+
// to send themselves, never silently dropped.
|
|
524
|
+
function sendMessage(content, okText){
|
|
525
|
+
if (state.caps && state.caps.message) {
|
|
526
|
+
return rpc('ui/message', { role: 'user', content: content }).then(function(){ toast(okText || 'Sent to the chat'); return true; })
|
|
527
|
+
.catch(function(e){ toast('Could not send: ' + (e && e.message || e)); return false; });
|
|
528
|
+
}
|
|
529
|
+
var text = content.map(function(b){ return b.text || ''; }).join('\n');
|
|
530
|
+
showModal({ title: 'Send this in the chat', body: text, ok: 'Close', cancel: null });
|
|
531
|
+
return Promise.resolve(false);
|
|
532
|
+
}
|
|
533
|
+
function askToPost(a){
|
|
534
|
+
var pr = D().prefs || {};
|
|
535
|
+
var tail = pr.channels && pr.channels.length ? ' My default channels are ' + pr.channels.join(', ') + '.' : '';
|
|
536
|
+
sendMessage([assetBlock(a), { type: 'text', text: 'Help me post or schedule this to my channels.' + tail }]);
|
|
537
|
+
}
|
|
538
|
+
function openLink(url){ if (state.caps && state.caps.openLinks) return rpc('ui/open-link', { url: url }).catch(function(){}); toast('Open ' + url); return Promise.resolve(); }
|
|
539
|
+
var toastTimer = null;
|
|
540
|
+
function toast(t){ var el = $('toast'); el.textContent = t; el.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(function(){ el.hidden = true; }, 3200); }
|
|
541
|
+
var modalResolve = null;
|
|
542
|
+
function showModal(o){
|
|
543
|
+
var el = $('modal');
|
|
544
|
+
el.innerHTML = '<div class="mbox" role="dialog" aria-modal="true"><h4>' + esc(o.title) + '</h4><p>' + esc(o.body).replace(/\n/g, '<br>') + '</p><div class="row">'
|
|
545
|
+
+ (o.cancel ? '<button class="btn" data-act="m-cancel">' + esc(o.cancel) + '</button>' : '')
|
|
546
|
+
+ '<button class="btn ' + (o.danger ? 'danger' : 'primary') + '" data-act="m-ok">' + esc(o.ok || 'OK') + '</button></div></div>';
|
|
547
|
+
el.hidden = false; document.body.classList.add('modal-open'); reportSize();
|
|
548
|
+
var b = el.querySelector('[data-act=m-ok]'); if (b) b.focus();
|
|
549
|
+
return new Promise(function(res){ modalResolve = res; });
|
|
550
|
+
}
|
|
551
|
+
function closeModal(v){ var el = $('modal'); el.hidden = true; el.innerHTML = ''; document.body.classList.remove('modal-open'); if (modalResolve) { var r = modalResolve; modalResolve = null; r(v); } reportSize(); }
|
|
552
|
+
|
|
553
|
+
// ── views ──
|
|
554
|
+
function setView(v, quiet){
|
|
555
|
+
if (PANEL_VIEWS.indexOf(v) < 0 && CARD_VIEWS.indexOf(v) < 0) return;
|
|
556
|
+
state.view = v; state.focus = null;
|
|
557
|
+
if (PANEL_VIEWS.indexOf(v) >= 0 && state.surface === 'card' && !quiet) state.surface = 'panel';
|
|
558
|
+
if (!quiet) {
|
|
559
|
+
var have = { library: 'items', jobs: 'jobs', brands: 'brands', calendar: 'scheduled', brand: 'profile', connectors: 'connections', creators: 'creators', products: 'products', prefs: 'prefs', ads: 'ads' };
|
|
560
|
+
var need = (SECTIONS_FOR[v] || []).filter(function(s){ return !(have[s] in D()); });
|
|
561
|
+
if (need.length) load(need);
|
|
562
|
+
}
|
|
563
|
+
render();
|
|
564
|
+
}
|
|
565
|
+
function header(){
|
|
566
|
+
var d = D(); var b = d.brand; var brands = d.brands || [];
|
|
567
|
+
var card = state.surface === 'card';
|
|
568
|
+
$('top').hidden = card; $('cardhead').hidden = !card;
|
|
569
|
+
if (card) {
|
|
570
|
+
var t = state.view === 'concepts' ? 'Ad concepts' : state.view === 'results' ? (d.title || 'Your ads') : state.view === 'setup' ? 'Set up Hermoso' : state.view === 'create' ? 'New ad' : 'Hermoso';
|
|
571
|
+
$('cardtitle').textContent = t;
|
|
572
|
+
$('cardsub').textContent = state.view === 'concepts' && d.product ? 'for ' + d.product : (d.brand && d.brand.name ? d.brand.name : (d.brandName || ''));
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
var el = $('brand');
|
|
576
|
+
if (brands.length > 1) {
|
|
577
|
+
el.innerHTML = '<select id="brandSel" aria-label="Profile">' + brands.map(function(x){ return '<option value="' + esc(x.id) + '"' + (x.active ? ' selected' : '') + '>' + esc(x.name || x.id) + '</option>'; }).join('') + '</select>';
|
|
578
|
+
} else el.textContent = b && b.name ? b.name : 'Hermoso';
|
|
579
|
+
var tabs = document.querySelectorAll('.tab');
|
|
580
|
+
for (var i = 0; i < tabs.length; i++) tabs[i].setAttribute('aria-selected', String(tabs[i].getAttribute('data-view') === state.view));
|
|
581
|
+
}
|
|
582
|
+
function sectionNote(key){ var n = D().notes && D().notes[key]; return n ? '<div class="note err">' + esc(n) + '</div>' : ''; }
|
|
583
|
+
function tileHtml(a, i, act, sel){
|
|
584
|
+
return '<button class="tile' + (sel ? ' sel' : '') + '" data-act="' + act + '" data-i="' + i + '" aria-label="' + esc((a.kind || 'asset') + ' ' + (a.label || '')) + '">'
|
|
585
|
+
+ (a.thumb ? '<img loading="lazy" alt="" src="' + esc(a.thumb) + '">' : '<span class="ph">' + esc(a.kind || 'asset') + '</span>')
|
|
586
|
+
+ (a.kind === 'video' ? '<span class="badge">▶ video</span>' : '')
|
|
587
|
+
+ (sel ? '<span class="chk">in chat</span>' : '') + '</button>';
|
|
588
|
+
}
|
|
589
|
+
function detailHtml(f){
|
|
590
|
+
var sel = state.selected.indexOf(f.url) >= 0;
|
|
591
|
+
return '<div class="detail"><div class="media">'
|
|
592
|
+
+ (f.kind === 'video' && f.playable ? '<video controls playsinline preload="metadata" src="' + esc(f.url) + '"' + (f.thumb ? ' poster="' + esc(f.thumb) + '"' : '') + '></video>' : (f.thumb ? '<img alt="" src="' + esc(f.thumb) + '">' : ''))
|
|
593
|
+
+ '</div><div class="meta">' + esc([f.label, f.model, f.age].filter(Boolean).join(' · ')) + '</div><div class="row">'
|
|
594
|
+
+ (canContext() ? '<button class="btn primary" data-act="sel">' + (sel ? 'Remove from chat' : 'Add to chat') + '</button>' : '')
|
|
595
|
+
+ '<button class="btn" data-act="sched-open">Schedule</button>'
|
|
596
|
+
+ (state.caps && state.caps.message ? '<button class="btn" data-act="post-focus">Ask Hermoso to post it</button>' : '')
|
|
597
|
+
+ (state.caps && state.caps.openLinks ? '<button class="btn" data-act="open-focus">Open file</button>' : '')
|
|
598
|
+
+ '</div>' + (state.sched && state.sched.url === f.url ? schedHtml(f) : '') + '</div>';
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ── SCHEDULE: a finished result to the connected channels, at a time, behind an on-screen confirm ──
|
|
602
|
+
function schedDefaultAt(){ var t = new Date(); t.setDate(t.getDate() + 1); t.setHours(9, 0, 0, 0); return localInput(t); }
|
|
603
|
+
function openSched(f){
|
|
604
|
+
var pr = D().prefs || {};
|
|
605
|
+
state.sched = { url: f.url, kind: f.kind, caption: f.caption || '', at: schedDefaultAt(), picked: (pr.channels || []).slice(), err: '', done: null, busy: false };
|
|
606
|
+
if (!('channels' in D())) load(['connectors']);
|
|
607
|
+
render();
|
|
608
|
+
}
|
|
609
|
+
function schedChannels(f){ return (D().channels || []).filter(function(c){ return f.kind === 'video' || c.id !== 'youtube'; }); }
|
|
610
|
+
function schedHtml(f){
|
|
611
|
+
var s = state.sched;
|
|
612
|
+
if (s.done) return '<div class="confirm"><b>Scheduled</b><div class="s">' + esc(when(s.done.at) + ' to ' + s.done.labels.join(', ')) + '. You can move or cancel it in the Calendar.</div><div class="row" style="margin-top:8px"><button class="btn" data-act="tab" data-view="calendar">Open the Calendar</button><button class="btn" data-act="sched-close">Done</button></div></div>';
|
|
613
|
+
var d = D();
|
|
614
|
+
var html = '<div class="confirm"><b>Schedule this ' + (f.kind === 'video' ? 'video' : 'image') + '</b>';
|
|
615
|
+
if (d.notes && d.notes.connectors) html += sectionNote('connectors');
|
|
616
|
+
else if (!d.channels) html += '<div class="s">Loading your channels...</div>';
|
|
617
|
+
else {
|
|
618
|
+
var chans = schedChannels(f);
|
|
619
|
+
html += '<div class="field"><span class="flabel">Post to</span>' + (chans.length ? '<div class="seg" role="group">' + chans.map(function(c){ return '<button data-act="sched-ch" data-v="' + esc(c.id) + '" aria-pressed="' + (s.picked.indexOf(c.id) >= 0) + '">' + esc(c.label) + '</button>'; }).join('') + '</div>' : '<div class="s">No posting channel is connected yet.</div> <button class="btn" data-act="setup" data-step="connections">Connect a channel</button>') + '</div>';
|
|
620
|
+
html += '<div class="field"><label for="schedAt">When</label><input type="datetime-local" id="schedAt" value="' + esc(s.at) + '"><div class="s">' + esc(tz() || 'Your time zone') + '</div></div>';
|
|
621
|
+
html += '<div class="field"><label for="schedCaption">Caption</label><textarea id="schedCaption" rows="3" placeholder="What the post says">' + esc(s.caption) + '</textarea></div>';
|
|
622
|
+
}
|
|
623
|
+
if (s.err) html += '<div class="note err left">' + esc(s.err) + '</div>';
|
|
624
|
+
html += '<div class="row"><button class="btn" data-act="sched-close">Cancel</button><button class="btn primary" data-act="sched-go"' + (s.busy || !d.channels ? ' disabled' : '') + '>' + (s.busy ? 'Scheduling...' : 'Schedule post') + '</button></div></div>';
|
|
625
|
+
return html;
|
|
626
|
+
}
|
|
627
|
+
function schedule(){
|
|
628
|
+
var f = focused(); var s = state.sched; if (!f || !s || s.busy) return;
|
|
629
|
+
var chans = schedChannels(f).filter(function(c){ return s.picked.indexOf(c.id) >= 0; });
|
|
630
|
+
if (!chans.length) { s.err = 'Pick at least one channel.'; return render(); }
|
|
631
|
+
var t = new Date(s.at);
|
|
632
|
+
if (isNaN(t) || t.getTime() < Date.now() + 120000) { s.err = 'Pick a time in the future.'; return render(); }
|
|
633
|
+
s.err = '';
|
|
634
|
+
var labels = chans.map(function(c){ return c.label; });
|
|
635
|
+
showModal({ title: 'Schedule this post?', body: 'It goes live publicly on ' + labels.join(', ') + '\non ' + when(t) + (tz() ? ' (' + tz() + ')' : '') + '.\nYou can move or cancel it in the Calendar until then.', ok: 'Schedule post', cancel: 'Not yet' }).then(function(yes){
|
|
636
|
+
if (!yes) return render();
|
|
637
|
+
s.busy = true; render();
|
|
638
|
+
var args = { channels: chans.map(function(c){ return c.id; }), at: t.toISOString(), message: s.caption };
|
|
639
|
+
if (f.kind === 'video') args.videoUrl = f.url; else args.imageUrl = f.url;
|
|
640
|
+
callTool(TOOL.schedule, args, 120000).then(function(){ s.busy = false; s.done = { at: t, labels: labels }; toast('Scheduled for ' + when(t)); return load(['calendar']); })
|
|
641
|
+
.catch(function(e){ s.busy = false; s.err = String(e && e.message || e); render(); });
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
function focused(){ var list = state.view === 'results' ? (D().results || []) : (D().items || []); return state.focus != null ? list[state.focus] : null; }
|
|
645
|
+
|
|
646
|
+
// HOME: a composer that sends to the chat, quick starts and shortcuts. Nothing here spends: it hands requests over.
|
|
647
|
+
var BLOOM = '%%BLOOM%%';
|
|
648
|
+
function homeView(){
|
|
649
|
+
var d = D(); var p = d.profile; var live = activeJobs().length;
|
|
650
|
+
var c = state.composer;
|
|
651
|
+
var html = '<section class="hero"><span class="logo" aria-hidden="true">' + BLOOM + '</span><h1>What would you like to make?</h1>'
|
|
652
|
+
+ '<div class="composer"><div class="mchips" id="mchips">' + c.mentions.map(function(m, i){ return '<span class="mchip">' + (m.thumb ? '<img alt="" src="' + esc(m.thumb) + '">' : '') + esc(m.name) + '<button data-act="mention-remove" data-i="' + i + '" aria-label="Remove ' + esc(m.name) + '">×</button></span>'; }).join('') + '</div>'
|
|
653
|
+
+ '<textarea id="composer" rows="2" placeholder="Describe an ad, a post or a question. Type @ to add a profile, creator, product or Library item." aria-label="Message to Hermoso"></textarea>'
|
|
654
|
+
+ '<div class="menu" id="mmenu" role="listbox" hidden></div>'
|
|
655
|
+
+ '<div class="crow"><span class="s">Sends to this chat</span><button class="send" data-act="send" id="sendBtn" aria-label="Send">↑</button></div></div>'
|
|
656
|
+
+ '<div class="quick">'
|
|
657
|
+
+ '<button class="chip" data-act="msg" data-text="Show me the ads my top competitors are running right now.">Research competitors' ads</button>'
|
|
658
|
+
+ '<button class="chip" data-act="create" data-format="image">Make an image ad</button>'
|
|
659
|
+
+ '<button class="chip" data-act="create" data-format="video">Make a video ad</button>'
|
|
660
|
+
+ '<button class="chip" data-act="msg" data-text="Help me schedule a post from my Hermoso Library.">Schedule a post</button>'
|
|
661
|
+
+ '</div></section>';
|
|
662
|
+
html += '<h3>Get started</h3><div class="cards">'
|
|
663
|
+
+ gcard('tab', 'library', 'Library', 'Your images and videos')
|
|
664
|
+
+ gcard('tab', 'create', 'Create', 'Pick options, see concepts, then render')
|
|
665
|
+
+ '<button class="gcard" data-act="msg" data-text="Show me the ads my top competitors are running right now, and what is working for them."><b>Ad Spy</b><span>What competitors are running</span></button>'
|
|
666
|
+
+ gcard('tab', 'calendar', 'Calendar', 'Scheduled posts, move or cancel')
|
|
667
|
+
+ gcard('tab', 'ads', 'Ads', 'Your campaigns, pause or turn on')
|
|
668
|
+
+ gcard('setup', 'connections', 'Connections', 'Channels and ad accounts')
|
|
669
|
+
+ gcard('setup', 'brand', p ? 'Profile' : 'Set up profile', p ? (p.name || 'Your profile') : 'Read your website or description')
|
|
670
|
+
+ '</div>';
|
|
671
|
+
if (live) html += '<div class="note left"><button class="btn" data-act="tab" data-view="jobs">' + live + ' render' + (live === 1 ? '' : 's') + ' in progress</button></div>';
|
|
672
|
+
if (d.notes && d.notes.brand) html += sectionNote('brand');
|
|
673
|
+
return html;
|
|
674
|
+
}
|
|
675
|
+
function gcard(act, target, title, sub){
|
|
676
|
+
return '<button class="gcard" data-act="' + act + '" ' + (act === 'setup' ? 'data-step' : 'data-view') + '="' + target + '"><b>' + esc(title) + '</b><span>' + esc(sub) + '</span></button>';
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// the composer's @-mentions, from what the app has already read (no extra network)
|
|
680
|
+
function mentionPool(){
|
|
681
|
+
var d = D(); var out = [];
|
|
682
|
+
(d.brands || []).forEach(function(b){ out.push({ kind: 'brand', id: b.id, name: b.name || b.id, sub: b.active ? 'Profile, active' : 'Profile' }); });
|
|
683
|
+
(d.creators || []).forEach(function(c){ out.push({ kind: 'creator', id: c.id, name: c.name || 'Creator', thumb: c.thumb, sub: c.preset ? 'Preset creator' : 'Saved creator' }); });
|
|
684
|
+
(d.products || []).forEach(function(p, i){ out.push({ kind: 'product', id: p.url, url: p.url, name: p.label || ('Product photo ' + (i + 1)), thumb: p.thumb, sub: 'Product photo' }); });
|
|
685
|
+
(d.items || []).forEach(function(a, i){ out.push({ kind: 'asset', id: a.url, url: a.url, name: (a.kind === 'video' ? 'Video' : 'Image') + (a.age ? ' · ' + a.age : '') + (a.model ? ' · ' + a.model : ''), thumb: a.thumb, sub: 'Library', asset: a }); });
|
|
686
|
+
return out;
|
|
687
|
+
}
|
|
688
|
+
function mentionQuery(ta){
|
|
689
|
+
var upto = ta.value.slice(0, ta.selectionStart || 0);
|
|
690
|
+
var m = /(^|\s)@([^\s@]{0,40})$/.exec(upto);
|
|
691
|
+
return m ? m[2].toLowerCase() : null;
|
|
692
|
+
}
|
|
693
|
+
function updateMenu(){
|
|
694
|
+
var ta = $('composer'); var menu = $('mmenu'); if (!ta || !menu) return;
|
|
695
|
+
var q = mentionQuery(ta);
|
|
696
|
+
if (q == null) { menu.hidden = true; state.composer.menu = []; return; }
|
|
697
|
+
if (!state.composer.loadingPool && !('creators' in D()) && !('products' in D())) { state.composer.loadingPool = true; load(['creators', 'products']).then(function(){ var t = $('composer'); if (t) { t.focus(); updateMenu(); } }); }
|
|
698
|
+
var list = mentionPool().filter(function(x){ return !q || String(x.name).toLowerCase().indexOf(q) >= 0 || String(x.sub).toLowerCase().indexOf(q) >= 0; }).slice(0, 8);
|
|
699
|
+
state.composer.menu = list; if (state.composer.hi >= list.length) state.composer.hi = 0;
|
|
700
|
+
menu.innerHTML = list.length ? list.map(function(x, i){ return '<button role="option" data-act="mention-pick" data-i="' + i + '" aria-selected="' + (i === state.composer.hi) + '">' + (x.thumb ? '<img alt="" src="' + esc(x.thumb) + '">' : '<span class="sq"></span>') + '<span>' + esc(x.name) + '</span><span class="k">' + esc(x.sub) + '</span></button>'; }).join('') : '<div class="note">Nothing matches.</div>';
|
|
701
|
+
menu.hidden = false; reportSize();
|
|
702
|
+
}
|
|
703
|
+
function pickMention(i){
|
|
704
|
+
var x = state.composer.menu[i]; var ta = $('composer'); if (!x || !ta) return;
|
|
705
|
+
var pos = ta.selectionStart || 0; var before = ta.value.slice(0, pos).replace(/@[^\s@]{0,40}$/, ''); var after = ta.value.slice(pos);
|
|
706
|
+
ta.value = before + after; state.composer.text = ta.value;
|
|
707
|
+
if (!state.composer.mentions.some(function(m){ return m.kind === x.kind && m.id === x.id; })) state.composer.mentions.push(x);
|
|
708
|
+
$('mmenu').hidden = true; state.composer.menu = [];
|
|
709
|
+
$('mchips').innerHTML = state.composer.mentions.map(function(m, j){ return '<span class="mchip">' + (m.thumb ? '<img alt="" src="' + esc(m.thumb) + '">' : '') + esc(m.name) + '<button data-act="mention-remove" data-i="' + j + '" aria-label="Remove ' + esc(m.name) + '">×</button></span>'; }).join('');
|
|
710
|
+
ta.focus(); ta.setSelectionRange(before.length, before.length); reportSize();
|
|
711
|
+
}
|
|
712
|
+
function mentionBlock(m){
|
|
713
|
+
if (m.kind === 'asset' && m.asset) return assetBlock(m.asset);
|
|
714
|
+
if (m.kind === 'brand') return { type: 'text', text: 'Hermoso profile "' + m.name + '" (profile id ' + m.id + '). If it is not the active profile, switch to it with use_brand first.', _meta: { 'openai/title': ('Profile: ' + m.name).slice(0, 80) } };
|
|
715
|
+
if (m.kind === 'creator') return { type: 'text', text: 'Hermoso creator "' + m.name + '" (creator id ' + m.id + '). Cast them with the creator option when making a video.', _meta: { 'openai/title': ('Creator: ' + m.name).slice(0, 80) } };
|
|
716
|
+
return { type: 'text', text: 'Product photo "' + m.name + '": ' + m.url + '. Use this exact image as the product.', _meta: { 'openai/title': ('Product: ' + m.name).slice(0, 80) } };
|
|
717
|
+
}
|
|
718
|
+
function sendComposer(){
|
|
719
|
+
var ta = $('composer'); var text = ta ? ta.value.trim() : '';
|
|
720
|
+
if (!text && !state.composer.mentions.length) { if (ta) ta.focus(); return; }
|
|
721
|
+
var content = [{ type: 'text', text: text || 'Use these.' }].concat(state.composer.mentions.map(mentionBlock));
|
|
722
|
+
sendMessage(content).then(function(ok){ if (ok) { state.composer.mentions = []; if ($('composer')) $('composer').value = ''; render(); } });
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function libraryView(){
|
|
726
|
+
var d = D(); var items = d.items || [];
|
|
727
|
+
if (d.notes && d.notes.library) return sectionNote('library');
|
|
728
|
+
if (!items.length) return '<div class="note">Nothing here yet. Ask Hermoso for an image or video ad and it will appear here.</div>';
|
|
729
|
+
var html = '<div class="grid">' + items.map(function(a, i){ return tileHtml(a, i, 'focus', state.selected.indexOf(a.url) >= 0); }).join('') + '</div>';
|
|
730
|
+
var f = focused(); if (f) html += detailHtml(f);
|
|
731
|
+
return html;
|
|
732
|
+
}
|
|
733
|
+
function jobsView(){
|
|
734
|
+
var d = D(); var jobs = d.jobs || [];
|
|
735
|
+
if (d.notes && d.notes.jobs) return sectionNote('jobs');
|
|
736
|
+
var live = jobs.filter(function(j){ return j.status === 'queued' || j.status === 'running'; });
|
|
737
|
+
var done = jobs.filter(function(j){ return live.indexOf(j) < 0; }).slice(0, 8);
|
|
738
|
+
var row = function(j){
|
|
739
|
+
var pct = Math.max(0, Math.min(100, Math.round(+j.progress || 0)));
|
|
740
|
+
return '<div class="item">' + (j.thumb ? '<img alt="" src="' + esc(j.thumb) + '">' : '')
|
|
741
|
+
+ '<div class="grow"><div class="t">' + esc(j.label || j.type || 'Render') + '</div>'
|
|
742
|
+
+ '<div class="s">' + esc([j.statusText, j.model, j.stage].filter(Boolean).join(' · ')) + '</div>'
|
|
743
|
+
+ ((j.status === 'queued' || j.status === 'running') ? '<div class="bar"><i style="width:' + pct + '%"></i></div>' : '')
|
|
744
|
+
+ '</div></div>';
|
|
745
|
+
};
|
|
746
|
+
var html = '<h3>In progress</h3>' + (live.length ? '<div class="list">' + live.map(row).join('') + '</div>' : '<div class="note">No renders in progress.</div>');
|
|
747
|
+
if (done.length) html += '<h3>Recent</h3><div class="list">' + done.map(row).join('') + '</div>';
|
|
748
|
+
return html;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ── CALENDAR: a week you can drag posts across, with a time picker for the same move, and cancel ──
|
|
752
|
+
var POST_STATUS = { queued: 'Scheduled', scheduled: 'Scheduled', running: 'Posting', done: 'Posted', posted: 'Posted', partial: 'Partly posted', error: 'Failed', failed: 'Failed', cancelled: 'Cancelled' };
|
|
753
|
+
function tz(){ try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) { return ''; } }
|
|
754
|
+
function when(at, opts){ if (!at) return ''; var t = new Date(at); if (isNaN(t)) return ''; try { return t.toLocaleString(state.host.locale || undefined, opts || { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); } catch (e) { return t.toISOString(); } }
|
|
755
|
+
function dayKey(t){ return t.getFullYear() + '-' + (t.getMonth() + 1) + '-' + t.getDate(); }
|
|
756
|
+
function weekDays(){ var s = new Date(); s.setHours(0, 0, 0, 0); s.setDate(s.getDate() + state.cal.offset * 7); var out = []; for (var i = 0; i < 7; i++) { var x = new Date(s); x.setDate(s.getDate() + i); out.push(x); } return out; }
|
|
757
|
+
function findPost(id){ var all = (D().scheduled || []).concat(D().history || []); for (var i = 0; i < all.length; i++) if (all[i].id === id) return all[i]; return null; }
|
|
758
|
+
function localInput(t){ var p = function(n){ return (n < 10 ? '0' : '') + n; }; return t.getFullYear() + '-' + p(t.getMonth() + 1) + '-' + p(t.getDate()) + 'T' + p(t.getHours()) + ':' + p(t.getMinutes()); }
|
|
759
|
+
function calendarView(){
|
|
760
|
+
var d = D();
|
|
761
|
+
if (d.notes && d.notes.calendar) return sectionNote('calendar');
|
|
762
|
+
var up = d.scheduled || []; var past = d.history || [];
|
|
763
|
+
var days = weekDays(); var keys = days.map(dayKey);
|
|
764
|
+
var start = days[0].getTime(), end = days[6].getTime() + 864e5;
|
|
765
|
+
var before = up.filter(function(p){ var t = new Date(p.at).getTime(); return t < start; }).length;
|
|
766
|
+
var after = up.filter(function(p){ var t = new Date(p.at).getTime(); return t >= end; }).length;
|
|
767
|
+
var html = '<div class="wkbar"><b>' + esc(when(days[0], { month: 'short', day: 'numeric' }) + ' to ' + when(days[6], { month: 'short', day: 'numeric' })) + '</b>'
|
|
768
|
+
+ '<button class="btn" data-act="cal-week" data-d="-1" aria-label="Previous week"' + (state.cal.offset <= 0 && !before ? ' disabled' : '') + '>←</button>'
|
|
769
|
+
+ '<button class="btn" data-act="cal-week" data-d="0">This week</button>'
|
|
770
|
+
+ '<button class="btn" data-act="cal-week" data-d="1" aria-label="Next week">→</button></div>';
|
|
771
|
+
html += '<div class="s" style="margin-bottom:8px">Drag a post to another day to move it, or open it to pick a new time. Times are in ' + esc(tz() || 'your time zone') + '.</div>';
|
|
772
|
+
html += '<div class="week">' + days.map(function(day, i){
|
|
773
|
+
var isPast = day.getTime() < new Date().setHours(0, 0, 0, 0);
|
|
774
|
+
var posts = up.filter(function(p){ return p.at && dayKey(new Date(p.at)) === keys[i]; }).sort(function(a, b){ return new Date(a.at) - new Date(b.at); });
|
|
775
|
+
return '<section class="day' + (isPast ? ' past' : '') + '" data-day="' + i + '"' + (isPast ? '' : ' data-drop="1"') + ' aria-label="' + esc(when(day, { weekday: 'long', month: 'long', day: 'numeric' })) + '">'
|
|
776
|
+
+ '<div class="dh"><b>' + esc(when(day, { weekday: 'short' })) + '</b><span>' + esc(when(day, { month: 'short', day: 'numeric' })) + '</span></div>'
|
|
777
|
+
+ posts.map(postChip).join('') + '</section>';
|
|
778
|
+
}).join('') + '</div>';
|
|
779
|
+
if (before || after) html += '<div class="s" style="margin-top:6px">' + [before ? before + ' earlier' : '', after ? after + ' later than this week' : ''].filter(Boolean).join(', ') + '.</div>';
|
|
780
|
+
if (!up.length) html += '<div class="note">Nothing scheduled. Ask Hermoso to schedule a post.</div>';
|
|
781
|
+
var open = state.cal.open ? findPost(state.cal.open) : null;
|
|
782
|
+
if (open) html += postDetail(open);
|
|
783
|
+
if (past.length) html += '<h3>Recently posted</h3><div class="list">' + past.map(function(p){ return '<div class="item"><div class="grow"><div class="t">' + esc(when(p.at) || 'Unscheduled') + '</div><div class="s">' + esc([(p.channels || []).join(', '), POST_STATUS[p.status] || p.status].filter(Boolean).join(' · ')) + '</div>' + (p.message ? '<div class="s">' + esc(p.message) + '</div>' : '') + '</div></div>'; }).join('') + '</div>';
|
|
784
|
+
return html;
|
|
785
|
+
}
|
|
786
|
+
function postChip(p){
|
|
787
|
+
var ed = EDITABLE.indexOf(p.status) >= 0;
|
|
788
|
+
return '<button class="post' + (ed ? '' : ' ro') + (state.cal.open === p.id ? ' open' : '') + '" data-act="cal-open" data-id="' + esc(p.id) + '"' + (ed ? ' draggable="true"' : '') + ' aria-label="' + esc('Post at ' + when(p.at) + ' to ' + (p.channels || []).join(', ')) + '">'
|
|
789
|
+
+ '<div class="pt">' + esc(when(p.at, { hour: 'numeric', minute: '2-digit' })) + ' · ' + esc((p.channels || []).join(', ')) + '</div>'
|
|
790
|
+
+ (p.message ? '<div class="pc">' + esc(p.message) + '</div>' : '')
|
|
791
|
+
+ (p.thumb ? '<img alt="" loading="lazy" src="' + esc(p.thumb) + '">' : '') + '</button>';
|
|
792
|
+
}
|
|
793
|
+
function postDetail(p){
|
|
794
|
+
var full = state.cal.full[p.id] || p; var ed = EDITABLE.indexOf(p.status) >= 0;
|
|
795
|
+
var html = '<div class="detail"><div class="row"><b class="grow">' + esc(when(p.at) || 'Unscheduled') + '</b><span class="pill">' + esc(POST_STATUS[p.status] || p.status) + '</span><button class="icon" data-act="cal-close" aria-label="Close">×</button></div>'
|
|
796
|
+
+ '<div class="meta">' + esc((p.channels || []).join(', ')) + (tz() ? ' · ' + esc(tz()) : '') + '</div>';
|
|
797
|
+
if (full.mediaUrl || full.thumb) html += '<div class="media">' + (full.media === 'video' && full.playable ? '<video controls playsinline preload="metadata" src="' + esc(full.mediaUrl) + '"' + (full.thumb ? ' poster="' + esc(full.thumb) + '"' : '') + '></video>' : (full.thumb ? '<img alt="" src="' + esc(full.thumb) + '">' : '')) + '</div>';
|
|
798
|
+
if (full.message) html += '<p style="white-space:pre-wrap;margin:10px 0">' + esc(full.message) + '</p>';
|
|
799
|
+
if (ed) {
|
|
800
|
+
var at = state.cal.draftAt || (p.at ? localInput(new Date(p.at)) : '');
|
|
801
|
+
html += '<div class="field"><label for="newAt">New time</label><input type="datetime-local" id="newAt" value="' + esc(at) + '"></div>'
|
|
802
|
+
+ '<div class="row"><button class="btn primary" data-act="cal-move" data-id="' + esc(p.id) + '">Move to this time</button><button class="btn danger" data-act="cal-cancel" data-id="' + esc(p.id) + '">Cancel post</button></div>';
|
|
803
|
+
} else html += '<div class="s">This post is not queued any more, so it can no longer be moved or cancelled here.</div>';
|
|
804
|
+
return html + '</div>';
|
|
805
|
+
}
|
|
806
|
+
function moveTo(p, t){
|
|
807
|
+
if (!p || isNaN(t)) return;
|
|
808
|
+
if (t.getTime() < Date.now() + 60000) { toast('Pick a time in the future.'); return; }
|
|
809
|
+
var chans = (p.channels || []).join(', ');
|
|
810
|
+
showModal({ title: 'Move this post?', body: 'From ' + when(p.at) + '\nto ' + when(t) + (tz() ? ' (' + tz() + ')' : '') + '.\nIt posts to ' + (chans || 'its channels') + ' at the new time.', ok: 'Move post', cancel: 'Keep it' }).then(function(yes){
|
|
811
|
+
if (!yes) return;
|
|
812
|
+
callTool(TOOL.reschedule, { id: p.id, at: t.toISOString() }).then(function(){ toast('Moved to ' + when(t)); state.cal.draftAt = ''; return load(['calendar']); })
|
|
813
|
+
.catch(function(e){ showModal({ title: 'Could not move it', body: String(e && e.message || e), ok: 'OK' }); });
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
function cancelPost(p){
|
|
817
|
+
showModal({ title: 'Cancel this post?', body: 'It will not be published' + ((p.channels || []).length ? ' to ' + p.channels.join(', ') : '') + '. This cannot be undone.', ok: 'Cancel post', cancel: 'Keep it', danger: true }).then(function(yes){
|
|
818
|
+
if (!yes) return;
|
|
819
|
+
callTool(TOOL.cancel, { id: p.id }).then(function(){ toast('Post cancelled'); state.cal.open = null; return load(['calendar']); })
|
|
820
|
+
.catch(function(e){ showModal({ title: 'Could not cancel it', body: String(e && e.message || e), ok: 'OK' }); });
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ── ADS: campaigns on every connected ad platform. Pause or turn on behind a confirm; building one is a chat request ──
|
|
825
|
+
function adsView(){
|
|
826
|
+
var d = D();
|
|
827
|
+
var html = '<div class="wkbar"><b>Campaigns</b><button class="btn" data-act="ads-build">Ask Hermoso to build a campaign</button></div>';
|
|
828
|
+
if (d.notes && d.notes.ads) return html + sectionNote('ads');
|
|
829
|
+
var groups = d.ads;
|
|
830
|
+
if (!groups) return html + '<div class="note">Loading your campaigns...</div>';
|
|
831
|
+
if (!groups.length) return html + '<div class="note">No ad platform is connected for this profile yet.</div><div class="row" style="justify-content:center"><button class="btn primary" data-act="setup" data-step="connections">Connect an ad account</button></div>';
|
|
832
|
+
groups.forEach(function(g, gi){
|
|
833
|
+
html += '<h3>' + esc(g.label + (g.accountName ? ' · ' + g.accountName : '')) + '</h3>';
|
|
834
|
+
if (g.note) { html += '<div class="note err left">' + esc(g.note) + '</div>'; return; }
|
|
835
|
+
if (!g.campaigns.length) { html += '<div class="s">No campaigns on this account yet.</div>'; return; }
|
|
836
|
+
html += '<div class="list">' + g.campaigns.map(function(c, ci){
|
|
837
|
+
var pill = '<span class="pill' + (c.state === 'active' ? ' on' : '') + '">' + esc(c.status || (c.state === 'active' ? 'Active' : c.state === 'paused' ? 'Paused' : 'Unknown')) + '</span>';
|
|
838
|
+
var btn = c.state === 'active' ? '<button class="btn" data-act="ads-toggle" data-g="' + gi + '" data-c="' + ci + '">Pause</button>' : c.state === 'paused' ? '<button class="btn" data-act="ads-toggle" data-g="' + gi + '" data-c="' + ci + '">Turn on</button>' : '';
|
|
839
|
+
return '<div class="item"><div class="grow"><div class="t">' + esc(c.name) + '</div><div class="s">' + esc([c.budget, c.results].filter(Boolean).join(' · ')) + '</div></div>' + pill + btn + '</div>';
|
|
840
|
+
}).join('') + '</div>';
|
|
841
|
+
});
|
|
842
|
+
return html;
|
|
843
|
+
}
|
|
844
|
+
function toggleCampaign(g, c){
|
|
845
|
+
if (!g || !c) return;
|
|
846
|
+
var on = c.state !== 'active';
|
|
847
|
+
var body = on ? 'This campaign will start spending on ' + g.label + '.\n"' + c.name + '"' + (c.budget ? '\nBudget: ' + c.budget : '') + (g.accountName ? '\nAd account: ' + g.accountName : '') + '\nAds inside it run only when they are on too.'
|
|
848
|
+
: '"' + c.name + '" on ' + g.label + ' stops spending. You can turn it back on here.';
|
|
849
|
+
showModal({ title: on ? 'Turn this campaign on?' : 'Pause this campaign?', body: body, ok: on ? 'Turn on and start spending' : 'Pause campaign', cancel: on ? 'Keep it paused' : 'Keep it running', danger: on }).then(function(yes){
|
|
850
|
+
if (!yes) return;
|
|
851
|
+
callTool(TOOL.adsStatus, { platform: g.platform, accountId: g.accountId || undefined, campaignId: c.id, status: on ? 'active' : 'paused' }, 120000)
|
|
852
|
+
.then(function(){ toast(on ? 'Campaign turned on' : 'Campaign paused'); return load(['ads']); })
|
|
853
|
+
.catch(function(e){ showModal({ title: on ? 'Could not turn it on' : 'Could not pause it', body: String(e && e.message || e), ok: 'OK' }); });
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
function askToBuildCampaign(){
|
|
857
|
+
var names = (D().ads || []).map(function(g){ return g.label; }).filter(function(x, i, a){ return a.indexOf(x) === i; });
|
|
858
|
+
sendMessage([{ type: 'text', text: 'Help me build a new ad campaign' + (names.length ? ' on ' + names.join(' or ') : '') + '. Ask me what it should promote, the budget and the audience, and show me everything before anything goes live.' }]);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ── CREATE: the brief form. Review, confirm, then the brief goes to the chat, which shows concepts first ──
|
|
862
|
+
var ASPECTS = ['9:16', '1:1', '4:5', '16:9']; var LENGTHS = [8, 15, 30, 60]; var COUNTS = [3, 4, 6];
|
|
863
|
+
function seg(act, list, cur, fmt){ return '<div class="seg" role="group">' + list.map(function(v){ return '<button data-act="' + act + '" data-v="' + esc(v) + '" aria-pressed="' + (String(v) === String(cur)) + '">' + esc(fmt ? fmt(v) : v) + '</button>'; }).join('') + '</div>'; }
|
|
864
|
+
function pickGrid(act, list, cur, none){
|
|
865
|
+
var html = '<div class="pick"><button class="tile" data-act="' + act + '" data-i="-1" aria-pressed="' + (cur == null) + '"><span class="ph">' + esc(none) + '</span></button>';
|
|
866
|
+
html += list.map(function(x, i){ return '<button class="tile" data-act="' + act + '" data-i="' + i + '" aria-pressed="' + (cur === i) + '" aria-label="' + esc(x.name || x.label || 'option') + '">' + (x.thumb ? '<img loading="lazy" alt="" src="' + esc(x.thumb) + '">' : '<span class="ph">' + esc(x.name || x.label || '') + '</span>') + ((x.name || x.label) ? '<span class="cap">' + esc(x.name || x.label) + '</span>' : '') + '</button>'; }).join('');
|
|
867
|
+
return html + '</div>';
|
|
868
|
+
}
|
|
869
|
+
function briefProductText(){ var b = state.brief; if (b.product != null) return b.product; var p = D().profile; return (p && (p.sells || p.name)) || ''; }
|
|
870
|
+
function createView(){
|
|
871
|
+
var b = state.brief; var d = D();
|
|
872
|
+
if (b.step === 'sent') return '<h2>Sent to the chat</h2><p class="s">Your concepts will show up in the chat. Pick one there to render it.</p><div class="row"><button class="btn" data-act="b-again">Start another</button></div>';
|
|
873
|
+
var prefs = d.prefs || {};
|
|
874
|
+
var prod = b.productPhoto != null ? (d.products || [])[b.productPhoto] : null;
|
|
875
|
+
var cr = b.creator != null ? (d.creators || [])[b.creator] : null;
|
|
876
|
+
var as = b.asset != null ? (d.items || [])[b.asset] : null;
|
|
877
|
+
if (b.step === 'review') {
|
|
878
|
+
var row = function(k, v){ return '<dt>' + esc(k) + '</dt><dd>' + v + '</dd>'; };
|
|
879
|
+
return '<h2>Review your ad</h2><dl class="sum">'
|
|
880
|
+
+ row('For', esc(briefProductText() || 'My brand'))
|
|
881
|
+
+ row('Format', esc(b.format === 'video' ? 'Video' : 'Image') + ', ' + esc(b.aspect) + (b.format === 'video' ? ', ' + b.length + ' seconds' : ''))
|
|
882
|
+
+ row('Product photo', prod ? '<img alt="" src="' + esc(prod.thumb) + '">' + esc(prod.label || 'Selected') : 'Hermoso picks')
|
|
883
|
+
+ row('Creator', cr ? (cr.thumb ? '<img alt="" src="' + esc(cr.thumb) + '">' : '') + esc(cr.name) : 'Hermoso picks')
|
|
884
|
+
+ row('Reference', as ? '<img alt="" src="' + esc(as.thumb) + '">From your Library' : 'None')
|
|
885
|
+
+ row('Concepts', String(b.count))
|
|
886
|
+
+ (b.format === 'video' ? row('Captions', prefs.captions ? 'On' : 'Off') + row('End card', prefs.endCard ? 'On' : 'Off') : '')
|
|
887
|
+
+ '</dl><p class="s">Hermoso comes up with ' + b.count + ' concepts first, in the chat. Nothing is rendered until you pick one. Coming up with concepts uses your Hermoso account.</p>'
|
|
888
|
+
+ '<div class="row"><button class="btn" data-act="b-back">Back</button><button class="btn primary" data-act="b-go">Get concepts</button></div>';
|
|
889
|
+
}
|
|
890
|
+
var html = state.surface === 'card' ? '' : '<h2>New ad</h2>';
|
|
891
|
+
html += '<div class="field"><label for="briefProduct">What is it for?</label><textarea id="briefProduct" rows="2" placeholder="A product, an offer or a launch, in your words">' + esc(briefProductText()) + '</textarea></div>';
|
|
892
|
+
html += '<div class="field"><span class="flabel">Format</span>' + seg('b-format', ['image', 'video'], b.format, function(v){ return v === 'video' ? 'Video' : 'Image'; }) + '</div>';
|
|
893
|
+
html += '<div class="field"><span class="flabel">Size</span>' + seg('b-aspect', ASPECTS, b.aspect) + '</div>';
|
|
894
|
+
if (b.format === 'video') html += '<div class="field"><span class="flabel">Length</span>' + seg('b-length', LENGTHS, b.length, function(v){ return v + 's'; }) + '</div>';
|
|
895
|
+
html += '<div class="field"><span class="flabel">Product photo</span>' + (d.notes && d.notes.products ? sectionNote('products') : (d.products ? (d.products.length ? pickGrid('b-product', d.products, b.productPhoto, 'Hermoso picks') : '<div class="s">No product photos saved yet. Hermoso uses your profile.</div>') : '<div class="s">Loading...</div>')) + '</div>';
|
|
896
|
+
html += '<div class="field"><span class="flabel">Creator</span>' + (d.notes && d.notes.creators ? sectionNote('creators') : (d.creators ? (d.creators.length ? pickGrid('b-creator', d.creators, b.creator, 'Hermoso picks') : '<div class="s">No saved creators yet.</div>') : '<div class="s">Loading...</div>')) + '</div>';
|
|
897
|
+
html += '<div class="field"><span class="flabel">Reference from your Library</span>' + (d.items && d.items.length ? pickGrid('b-asset', d.items.slice(0, 11), b.asset, 'None') : '<div class="s">Nothing in your Library yet.</div>') + '</div>';
|
|
898
|
+
html += '<div class="field"><span class="flabel">How many concepts</span>' + seg('b-count', COUNTS, b.count) + '</div>';
|
|
899
|
+
html += '<div class="row"><button class="btn primary" data-act="b-review">Review</button></div>';
|
|
900
|
+
return html;
|
|
901
|
+
}
|
|
902
|
+
function briefMessage(){
|
|
903
|
+
var b = state.brief; var d = D(); var prefs = d.prefs || {};
|
|
904
|
+
var prod = b.productPhoto != null ? (d.products || [])[b.productPhoto] : null;
|
|
905
|
+
var cr = b.creator != null ? (d.creators || [])[b.creator] : null;
|
|
906
|
+
var as = b.asset != null ? (d.items || [])[b.asset] : null;
|
|
907
|
+
var lines = ['Come up with ' + b.count + ' ad concepts for this brief and show them to me with show_ad_concepts. Wait for me to pick one; do not render anything yet.',
|
|
908
|
+
'For: ' + (briefProductText() || 'my brand'),
|
|
909
|
+
'Format: ' + (b.format === 'video' ? 'video, ' + b.aspect + ', ' + b.length + ' seconds' : 'image, ' + b.aspect)];
|
|
910
|
+
if (b.format === 'video') lines.push('Captions: ' + (prefs.captions ? 'on' : 'off') + '. End card: ' + (prefs.endCard ? 'on' : 'off') + '.');
|
|
911
|
+
var content = [{ type: 'text', text: lines.join('\n') }];
|
|
912
|
+
if (prod) content.push({ type: 'text', text: 'Product photo to use: ' + prod.url, _meta: { 'openai/title': ('Product: ' + (prod.label || 'photo')).slice(0, 80) } });
|
|
913
|
+
if (cr) content.push({ type: 'text', text: 'Creator to cast: "' + cr.name + '" (creator id ' + cr.id + ').', _meta: { 'openai/title': ('Creator: ' + cr.name).slice(0, 80) } });
|
|
914
|
+
if (as) content.push(assetBlock(as));
|
|
915
|
+
return content;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// ── SETUP: brand, then connections, then a first ad ──
|
|
919
|
+
var GROUPS = [['channels', 'Social channels'], ['ads', 'Ad platforms'], ['data', 'Data and tools']];
|
|
920
|
+
function setupView(){
|
|
921
|
+
var s = state.setup; var d = D(); var p = d.profile;
|
|
922
|
+
var steps = [['brand', '1. Profile'], ['connections', '2. Connections'], ['first', '3. First ad']];
|
|
923
|
+
var html = '<div class="steps">' + steps.map(function(x){ return '<span class="' + (x[0] === s.step ? 'on' : '') + '">' + x[1] + '</span>'; }).join('') + '</div>';
|
|
924
|
+
if (s.step === 'brand') {
|
|
925
|
+
if (d.notes && d.notes.brand) return html + sectionNote('brand');
|
|
926
|
+
if (s.drafting) return html + '<div class="note">Reading ' + esc(s.input || 'your brand') + '. This can take up to a minute.</div>';
|
|
927
|
+
var shown = s.draft || p;
|
|
928
|
+
if (shown && !s.editing) {
|
|
929
|
+
html += '<div class="bcard">' + (shown.logo ? '<img alt="" src="' + esc(shown.logo) + '">' : '') + '<div class="grow"><b>' + esc(shown.name || 'Your profile') + '</b>'
|
|
930
|
+
+ '<div class="s">' + esc([shown.domain, shown.category].filter(Boolean).join(' · ')) + '</div>'
|
|
931
|
+
+ (shown.summary ? '<p class="s" style="margin:6px 0 0">' + esc(shown.summary) + '</p>' : '') + '</div></div>';
|
|
932
|
+
if (s.draft) html += '<p class="s">' + (s.draft.saved ? 'Saved to your profile.' : 'Not saved.') + ' Is this the right company? If not, try its exact website or describe it.</p>';
|
|
933
|
+
html += '<div class="row" style="margin-top:10px"><button class="btn primary" data-act="s-step" data-step="connections">Looks right, continue</button><button class="btn" data-act="s-edit">Use a different website or description</button></div>';
|
|
934
|
+
return html;
|
|
935
|
+
}
|
|
936
|
+
html += '<p class="s">Hermoso reads your website (or your description) to learn your products, logo, colours and voice.</p>'
|
|
937
|
+
+ '<div class="field">' + seg('s-mode', ['website', 'description'], s.mode, function(v){ return v === 'website' ? 'Website' : 'Describe it'; }) + '</div>'
|
|
938
|
+
+ '<div class="field">' + (s.mode === 'website' ? '<input type="text" inputmode="url" id="setupInput" placeholder="yourbrand.com" value="' + esc(s.input || '') + '" aria-label="Website">' : '<textarea id="setupInput" rows="3" placeholder="What you sell, who it is for, how you sound" aria-label="Profile description">' + esc(s.input || '') + '</textarea>') + '</div>'
|
|
939
|
+
+ '<div class="row"><button class="btn primary" data-act="s-draft">Set up my profile</button>' + (p ? '<button class="btn" data-act="s-keep">Keep ' + esc(p.name || 'the current profile') + '</button>' : '') + '</div>';
|
|
940
|
+
return html;
|
|
941
|
+
}
|
|
942
|
+
if (s.step === 'connections') {
|
|
943
|
+
if (d.notes && d.notes.connectors) return html + sectionNote('connectors') + '<div class="row"><button class="btn" data-act="s-check">Try again</button></div>';
|
|
944
|
+
var conns = d.connections;
|
|
945
|
+
if (!conns) return html + '<div class="note">Loading your accounts...</div>';
|
|
946
|
+
html += '<p class="s">Connecting opens Hermoso in your browser to sign in to that account. Come back here when you are done; this list updates by itself.</p>';
|
|
947
|
+
GROUPS.forEach(function(g){
|
|
948
|
+
var rows = conns.filter(function(c){ return c.group === g[0]; }); if (!rows.length) return;
|
|
949
|
+
html += '<h3>' + esc(g[1]) + '</h3><div class="list">' + rows.map(function(c){
|
|
950
|
+
var pill = c.connected ? (c.needsReconnect ? '<span class="pill warn">Needs reconnect</span>' : '<span class="pill on">Connected</span>') : '<span class="pill">Not connected</span>';
|
|
951
|
+
var btn = !c.connected ? '<button class="btn" data-act="s-connect" data-p="' + esc(c.provider) + '">Connect</button>' : (c.needsReconnect ? '<button class="btn" data-act="s-connect" data-p="' + esc(c.provider) + '">Reconnect</button>' : '');
|
|
952
|
+
return '<div class="item"><div class="grow"><div class="t">' + esc(c.name) + '</div><div class="s">' + esc(c.label || '') + '</div></div>' + pill + btn + '</div>';
|
|
953
|
+
}).join('') + '</div>';
|
|
954
|
+
});
|
|
955
|
+
if (state.setup.watch) html += '<div class="note left">Waiting for ' + esc(state.setup.watch) + ' to connect...</div>';
|
|
956
|
+
html += '<div class="row" style="margin-top:12px"><button class="btn" data-act="s-check">Check again</button><button class="btn primary" data-act="s-step" data-step="first">Continue</button></div>';
|
|
957
|
+
return html;
|
|
958
|
+
}
|
|
959
|
+
html += '<h2>You are set up</h2><p class="s">Make a first ad: pick the options, see a few concepts, and render only the one you like.</p>'
|
|
960
|
+
+ '<div class="row"><button class="btn primary" data-act="tab" data-view="create">Make my first ad</button>'
|
|
961
|
+
+ '<button class="btn" data-act="msg" data-text="Come up with 4 ad concepts for my brand and show them to me with show_ad_concepts. Wait for me to pick one before rendering anything.">Show me ad ideas</button></div>';
|
|
962
|
+
return html;
|
|
963
|
+
}
|
|
964
|
+
function startWatch(provider){
|
|
965
|
+
stopWatch(); state.setup.watch = provider; state.setup.watchUntil = Date.now() + 3 * 60 * 1000;
|
|
966
|
+
var tick = function(){
|
|
967
|
+
if (!state.setup.watch || Date.now() > state.setup.watchUntil) { stopWatch(); render(); return; }
|
|
968
|
+
load(['connectors']).then(function(){
|
|
969
|
+
var c = (D().connections || []).filter(function(x){ return x.provider === provider; })[0];
|
|
970
|
+
if (c && c.connected && !c.needsReconnect) { toast(c.name + ' connected'); stopWatch(); render(); return; }
|
|
971
|
+
state.setup.watchTimer = setTimeout(tick, 6000);
|
|
972
|
+
});
|
|
973
|
+
};
|
|
974
|
+
state.setup.watchTimer = setTimeout(tick, 6000);
|
|
975
|
+
}
|
|
976
|
+
function stopWatch(){ if (state.setup.watchTimer) clearTimeout(state.setup.watchTimer); state.setup.watchTimer = null; state.setup.watch = null; }
|
|
977
|
+
function draftBrand(){
|
|
978
|
+
var s = state.setup; var el = $('setupInput'); s.input = el ? el.value.trim() : (s.input || '');
|
|
979
|
+
if (!s.input) { toast(s.mode === 'website' ? 'Type your website first.' : 'Describe yourself first.'); return; }
|
|
980
|
+
var p = D().profile;
|
|
981
|
+
var go = function(replace){
|
|
982
|
+
s.drafting = true; render();
|
|
983
|
+
var args = s.mode === 'website' ? { domain: s.input } : { description: s.input };
|
|
984
|
+
if (replace) args.replace = true;
|
|
985
|
+
callTool(TOOL.draft, args, 180000).then(function(r){
|
|
986
|
+
var sc = r.structuredContent || {}; s.draft = sc.brand ? { name: sc.brand.name, domain: sc.brand.domain, category: sc.brand.category, summary: sc.brand.summary, logo: sc.brand.logo, saved: !!sc.saved } : null; s.editing = false;
|
|
987
|
+
return load(['brand', 'brands']);
|
|
988
|
+
}).catch(function(e){ showModal({ title: 'Could not set up your profile', body: String(e && e.message || e), ok: 'OK' }); })
|
|
989
|
+
.then(function(){ s.drafting = false; render(); });
|
|
990
|
+
};
|
|
991
|
+
if (!p) { go(false); return; }
|
|
992
|
+
showModal({ title: 'Replace your profile?', body: 'Hermoso reads ' + s.input.slice(0, 120) + ' and replaces the saved profile for ' + (p.name || 'this profile') + ' with what it finds. The current profile is overwritten.', ok: 'Replace it', cancel: 'Cancel', danger: true }).then(function(yes){ if (yes) go(true); });
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// ── CONCEPTS: pick ONE before anything renders. Render it, on the confirm step, renders that one concept itself ──
|
|
996
|
+
function conceptOpts(i){
|
|
997
|
+
var d = D(); var o = state.concepts.opts[i];
|
|
998
|
+
if (!o) { var f = d.format === 'video' ? 'video' : 'image'; o = state.concepts.opts[i] = { format: f, aspect: d.aspectRatio || (f === 'video' ? '9:16' : '1:1'), length: d.durationSeconds || 15 }; }
|
|
999
|
+
return o;
|
|
1000
|
+
}
|
|
1001
|
+
function sel(field, i, list, cur, fmt){ return '<select data-field="' + field + '" data-i="' + i + '" aria-label="' + field + '">' + list.map(function(v){ return '<option value="' + esc(v) + '"' + (String(v) === String(cur) ? ' selected' : '') + '>' + esc(fmt ? fmt(v) : v) + '</option>'; }).join('') + '</select>'; }
|
|
1002
|
+
function conceptsView(){
|
|
1003
|
+
var d = D(); var cs = d.concepts || [];
|
|
1004
|
+
var html = d.note ? '<div class="note err">' + esc(d.note) + '</div>' : '';
|
|
1005
|
+
if (!cs.length) return html || '<div class="note">No concepts yet.</div>';
|
|
1006
|
+
html += cs.map(function(c, i){
|
|
1007
|
+
var open = state.concepts.open === i; var sent = state.concepts.sent[i]; var starting = state.concepts.starting === i;
|
|
1008
|
+
var h = '<div class="concept"><div><span class="n">' + (i + 1) + '</span><span class="ttl">' + esc(c.title || 'Concept ' + (i + 1)) + '</span>' + (c.hook ? ' <span class="pill">' + esc(c.hook) + '</span>' : '') + '</div>'
|
|
1009
|
+
+ (c.headline ? '<div class="hl">“' + esc(c.headline) + '”</div>' : '')
|
|
1010
|
+
+ (c.line ? '<div class="s">' + esc(c.line) + '</div>' : '')
|
|
1011
|
+
+ (c.visual ? '<div class="vis">' + esc(c.visual) + '</div>' : '');
|
|
1012
|
+
if (starting) h += '<div class="confirm"><b>Starting concept ' + (i + 1) + '</b><div class="s">Hermoso is writing the full ad and starting the render. This can take a minute.</div><div class="bar"><i class="busy"></i></div></div>';
|
|
1013
|
+
else if (sent) h += '<div class="confirm"><div class="row"><span class="s grow">Rendering on your Hermoso account.</span><button class="btn" data-act="c-see">See it</button></div>' + (noticesFor(sent).length ? '<div class="notices">' + noticeHtml(noticesFor(sent)) + '</div>' : '') + '</div>';
|
|
1014
|
+
else if (open) {
|
|
1015
|
+
var o = conceptOpts(i);
|
|
1016
|
+
h += '<div class="confirm"><b>Render concept ' + (i + 1) + '?</b><div class="row" style="margin:8px 0">'
|
|
1017
|
+
+ sel('format', i, ['image', 'video'], o.format, function(v){ return v === 'video' ? 'Video' : 'Image'; })
|
|
1018
|
+
+ sel('aspect', i, ASPECTS, o.aspect)
|
|
1019
|
+
+ (o.format === 'video' ? sel('length', i, LENGTHS.indexOf(+o.length) < 0 ? LENGTHS.concat([+o.length]) : LENGTHS, o.length, function(v){ return v + ' seconds'; }) : '')
|
|
1020
|
+
+ '</div><div class="s">Render it makes this concept on your Hermoso account and shows it here. Only this concept is rendered.</div>'
|
|
1021
|
+
+ (state.concepts.err && state.concepts.err.i === i ? '<div class="note err left">' + esc(state.concepts.err.text) + '</div>' : '')
|
|
1022
|
+
+ '<div class="row" style="margin-top:8px"><button class="btn" data-act="c-back">Back</button><button class="btn primary" data-act="c-go" data-i="' + i + '"' + (state.concepts.starting != null ? ' disabled' : '') + '>Render it</button></div></div>';
|
|
1023
|
+
} else h += '<div class="row" style="margin-top:10px"><button class="btn primary" data-act="c-pick" data-i="' + i + '">Render this one</button></div>';
|
|
1024
|
+
return h + '</div>';
|
|
1025
|
+
}).join('');
|
|
1026
|
+
html += '<div class="row"><button class="btn" data-act="c-more">More ideas</button></div>';
|
|
1027
|
+
return html;
|
|
1028
|
+
}
|
|
1029
|
+
// The click on Render it, on the step that says it renders on the user's account, IS the confirmation, exactly like
|
|
1030
|
+
// a calendar move: the app runs the render through its own app-only tool and shows it in place. Nothing is sent to
|
|
1031
|
+
// the chat, so the model is never asked to spend on the app's say-so.
|
|
1032
|
+
function captionFor(c){ return [c.headline, c.line].filter(Boolean).join('\n\n'); }
|
|
1033
|
+
function renderConcept(i){
|
|
1034
|
+
var d = D(); var c = (d.concepts || [])[i]; if (!c || state.concepts.starting != null) return;
|
|
1035
|
+
var o = conceptOpts(i); var prefs = d.prefs || {};
|
|
1036
|
+
var args = { product: d.product || '', concept: { title: c.title, hook: c.hook, hookId: c.hookId, headline: c.headline, line: c.line, visual: c.visual }, format: o.format, aspectRatio: o.aspect };
|
|
1037
|
+
if (o.format === 'video') { args.durationSeconds = +o.length; args.captions = prefs.captions === true; args.endCard = prefs.endCard === true; }
|
|
1038
|
+
if (d.productImage) args.productImage = d.productImage;
|
|
1039
|
+
if (d.creator) args.creator = d.creator;
|
|
1040
|
+
if (d.reference) args.reference = d.reference;
|
|
1041
|
+
state.concepts.starting = i; state.concepts.err = null; render();
|
|
1042
|
+
callTool(TOOL.render, args, 300000).then(function(r){
|
|
1043
|
+
var sc = (r && r.structuredContent) || {};
|
|
1044
|
+
if (!sc.jobId && !sc.url) throw new Error(textOf(r) || 'The render did not start.');
|
|
1045
|
+
var label = 'Concept ' + (i + 1) + (c.title ? ': ' + c.title : '');
|
|
1046
|
+
state.concepts.starting = null; state.concepts.open = null; state.concepts.sent[i] = sc.jobId || sc.url;
|
|
1047
|
+
var nts = Array.isArray(sc.notices) ? sc.notices.filter(function(t){ return typeof t === 'string' && t; }) : [];
|
|
1048
|
+
var dd = D(); dd.results = (dd.results || []).concat([{ id: sc.jobId || '', status: sc.url ? 'done' : 'queued', progress: 0, url: sc.url || '', kind: o.format, thumb: '', label: label, caption: captionFor(c), notices: nts }]);
|
|
1049
|
+
if (sc.jobId) { state.started.push({ jobId: sc.jobId, label: label, notices: nts }); syncContext(); }
|
|
1050
|
+
dd.title = 'Your renders'; state.focus = null; state.view = 'results'; render(); schedulePoll();
|
|
1051
|
+
}).catch(function(e){ state.concepts.starting = null; state.concepts.err = { i: i, text: String(e && e.message || e) }; render(); });
|
|
1052
|
+
}
|
|
1053
|
+
function moreConcepts(){
|
|
1054
|
+
var d = D(); var titles = (d.concepts || []).map(function(c){ return c.title; }).filter(Boolean);
|
|
1055
|
+
showModal({ title: 'More ideas?', body: 'Hermoso comes up with more concepts in the chat. This uses your Hermoso account. Nothing is rendered.', ok: 'Get more ideas', cancel: 'Cancel' }).then(function(yes){
|
|
1056
|
+
if (!yes) return;
|
|
1057
|
+
sendMessage([{ type: 'text', text: 'Come up with ' + ((d.concepts || []).length || 4) + ' more ad concepts' + (d.product ? ' for ' + d.product : '') + ', different from these: ' + titles.join('; ') + '. Show them with show_ad_concepts and wait for me to pick one.' }]);
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// ── RESULTS: a batch the user already asked for. Use one in the chat, or hand it back to post or schedule ──
|
|
1062
|
+
function resultsView(){
|
|
1063
|
+
var d = D(); var rs = d.results || [];
|
|
1064
|
+
var html = d.note ? '<div class="note err">' + esc(d.note) + '</div>' : '';
|
|
1065
|
+
if (!rs.length) return html || '<div class="note">Nothing to show yet.</div>';
|
|
1066
|
+
html += '<div class="grid">' + rs.map(function(a, i){
|
|
1067
|
+
if (a.status === 'done' && a.url) return tileHtml(a, i, 'r-focus', state.selected.indexOf(a.url) >= 0);
|
|
1068
|
+
var pct = Math.max(0, Math.min(100, Math.round(+a.progress || 0)));
|
|
1069
|
+
var label = a.status === 'error' ? 'Failed' : a.status === 'not_found' ? 'Not found' : a.status === 'queued' ? 'Queued' : 'Rendering';
|
|
1070
|
+
return '<div class="tile" role="img" aria-label="' + esc(label) + '"><span class="ph">' + esc(label) + (a.error ? ': ' + esc(a.error) : '') + (a.label ? '<br>' + esc(a.label) : '') + '</span>' + ((a.status === 'running' || a.status === 'queued') ? '<div class="bar">' + (pct >= 5 ? '<i style="width:' + pct + '%"></i>' : '<i class="busy"></i>') + '</div>' : '') + '</div>';
|
|
1071
|
+
}).join('') + '</div>';
|
|
1072
|
+
var said = rs.filter(function(a){ return a.notices && a.notices.length; });
|
|
1073
|
+
if (said.length) html += '<div class="notices">' + said.map(function(a){ return noticeHtml(a.notices, rs.length > 1 ? a.label : ''); }).join('') + '</div>';
|
|
1074
|
+
var live = pendingResults().length;
|
|
1075
|
+
if (live) html += '<div class="s" style="margin-top:8px">' + live + ' render' + (live === 1 ? '' : 's') + ' in progress. ' + (live === 1 ? 'It shows' : 'They show') + ' here when done, usually within a few minutes. You can keep chatting meanwhile.</div>';
|
|
1076
|
+
var f = focused(); if (f) html += detailHtml(f);
|
|
1077
|
+
if ((d.concepts || []).length) html += '<div class="row" style="margin-top:10px"><button class="btn" data-act="r-concepts">Back to the concepts</button></div>';
|
|
1078
|
+
return html;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function render(){
|
|
1082
|
+
header();
|
|
1083
|
+
var main = $('main');
|
|
1084
|
+
var keepComposer = state.view === 'home' && $('composer') ? $('composer').value : null;
|
|
1085
|
+
var act = document.activeElement; var keepId = act && act.id && act !== document.body ? act.id : null; var caret = null;
|
|
1086
|
+
try { if (keepId && typeof act.selectionStart === 'number') caret = [act.selectionStart, act.selectionEnd]; } catch (e) {}
|
|
1087
|
+
if (!state.data) { main.innerHTML = state.loadError ? '<div class="note err">' + esc(state.loadError) + '</div>' : '<div class="note">Loading Hermoso...</div>'; reportSize(); return; }
|
|
1088
|
+
var html = state.loadError ? '<div class="note err">' + esc(state.loadError) + '</div>' : '';
|
|
1089
|
+
var v = state.view;
|
|
1090
|
+
html += v === 'home' ? homeView() : v === 'jobs' ? jobsView() : v === 'calendar' ? calendarView() : v === 'ads' ? adsView() : v === 'create' ? createView() : v === 'setup' ? setupView() : v === 'concepts' ? conceptsView() : v === 'results' ? resultsView() : libraryView();
|
|
1091
|
+
main.innerHTML = html;
|
|
1092
|
+
if (keepComposer != null && $('composer')) $('composer').value = keepComposer;
|
|
1093
|
+
if (keepId && $(keepId)) { try { $(keepId).focus(); if (caret) $(keepId).setSelectionRange(caret[0], caret[1]); } catch (e) {} }
|
|
1094
|
+
state.loadError = null;
|
|
1095
|
+
reportSize();
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// ── one delegated listener for every action ──
|
|
1099
|
+
document.addEventListener('click', function(e){
|
|
1100
|
+
var el = e.target.closest ? e.target.closest('[data-act]') : null; if (!el || el.disabled) return;
|
|
1101
|
+
var a = el.getAttribute('data-act'); var i = +el.getAttribute('data-i');
|
|
1102
|
+
var d = D();
|
|
1103
|
+
if (a === 'm-ok') return closeModal(true);
|
|
1104
|
+
if (a === 'm-cancel') return closeModal(false);
|
|
1105
|
+
if (a === 'tab') { if (el.getAttribute('data-view') === 'setup') state.setup.step = state.setup.step || 'brand'; return setView(el.getAttribute('data-view')); }
|
|
1106
|
+
if (a === 'refresh') return load(SECTIONS_FOR[state.view] || ['library', 'jobs', 'brands', 'calendar']);
|
|
1107
|
+
if (a === 'expand') return rpc('ui/request-display-mode', { mode: 'fullscreen' }).then(function(r){ if (r && r.mode) applyHost({ displayMode: r.mode }); }).catch(function(){});
|
|
1108
|
+
if (a === 'focus' || a === 'r-focus') { state.focus = state.focus === i ? null : i; state.sched = null; return render(); }
|
|
1109
|
+
if (a === 'sel') { var f = focused(); if (f) toggleSelect(f.url); return; }
|
|
1110
|
+
if (a === 'post-focus') { var f2 = focused(); if (f2) askToPost(f2); return; }
|
|
1111
|
+
if (a === 'open-focus') { var f3 = focused(); if (f3) openLink(f3.url); return; }
|
|
1112
|
+
// schedule
|
|
1113
|
+
if (a === 'sched-open') { var f4 = focused(); if (f4) openSched(f4); return; }
|
|
1114
|
+
if (a === 'sched-close') { state.sched = null; return render(); }
|
|
1115
|
+
if (a === 'sched-ch') { var sp = state.sched && state.sched.picked; if (sp) { var cv = el.getAttribute('data-v'); var ix = sp.indexOf(cv); if (ix >= 0) sp.splice(ix, 1); else sp.push(cv); state.sched.err = ''; } return render(); }
|
|
1116
|
+
if (a === 'sched-go') return schedule();
|
|
1117
|
+
// ads
|
|
1118
|
+
if (a === 'ads-toggle') { var ag = (d.ads || [])[+el.getAttribute('data-g')]; return toggleCampaign(ag, ag && ag.campaigns[+el.getAttribute('data-c')]); }
|
|
1119
|
+
if (a === 'ads-build') return askToBuildCampaign();
|
|
1120
|
+
if (a === 'msg') return sendMessage([{ type: 'text', text: el.getAttribute('data-text') }]);
|
|
1121
|
+
if (a === 'create') { state.brief.format = el.getAttribute('data-format') === 'video' ? 'video' : 'image'; state.brief.aspect = state.brief.format === 'video' ? '9:16' : '1:1'; state.brief.step = 'form'; return setView('create'); }
|
|
1122
|
+
if (a === 'setup') { state.setup.step = el.getAttribute('data-step') || 'brand'; setView('setup'); if (state.setup.step === 'connections' && !d.connections) load(['connectors']); return; }
|
|
1123
|
+
if (a === 'send') return sendComposer();
|
|
1124
|
+
if (a === 'mention-pick') return pickMention(i);
|
|
1125
|
+
if (a === 'mention-remove') { state.composer.mentions.splice(i, 1); return render(); }
|
|
1126
|
+
// brief
|
|
1127
|
+
if (a === 'b-format') { state.brief.format = el.getAttribute('data-v'); if (state.brief.format === 'video' && state.brief.aspect === '1:1') state.brief.aspect = '9:16'; return render(); }
|
|
1128
|
+
if (a === 'b-aspect') { state.brief.aspect = el.getAttribute('data-v'); return render(); }
|
|
1129
|
+
if (a === 'b-length') { state.brief.length = +el.getAttribute('data-v'); return render(); }
|
|
1130
|
+
if (a === 'b-count') { state.brief.count = +el.getAttribute('data-v'); return render(); }
|
|
1131
|
+
if (a === 'b-product') { state.brief.productPhoto = i < 0 ? null : i; return render(); }
|
|
1132
|
+
if (a === 'b-creator') { state.brief.creator = i < 0 ? null : i; return render(); }
|
|
1133
|
+
if (a === 'b-asset') { state.brief.asset = i < 0 ? null : i; return render(); }
|
|
1134
|
+
if (a === 'b-review') { var bp = $('briefProduct'); if (bp) state.brief.product = bp.value.trim(); state.brief.step = 'review'; return render(); }
|
|
1135
|
+
if (a === 'b-back') { state.brief.step = 'form'; return render(); }
|
|
1136
|
+
if (a === 'b-go') return sendMessage(briefMessage(), 'Brief sent. Concepts are on the way').then(function(ok){ if (ok) { state.brief.step = 'sent'; render(); } });
|
|
1137
|
+
if (a === 'b-again') { state.brief = { step: 'form', format: 'image', aspect: '1:1', length: 15, count: 4, product: null, productPhoto: null, creator: null, asset: null }; return render(); }
|
|
1138
|
+
// setup
|
|
1139
|
+
if (a === 's-mode') { var si = $('setupInput'); if (si) state.setup.input = si.value; state.setup.mode = el.getAttribute('data-v'); return render(); }
|
|
1140
|
+
if (a === 's-edit') { state.setup.editing = true; state.setup.draft = null; return render(); }
|
|
1141
|
+
if (a === 's-keep') { state.setup.editing = false; return render(); }
|
|
1142
|
+
if (a === 's-draft') return draftBrand();
|
|
1143
|
+
if (a === 's-step') { state.setup.step = el.getAttribute('data-step'); if (state.setup.step === 'connections') load(['connectors']); return render(); }
|
|
1144
|
+
if (a === 's-check') return load(['connectors']);
|
|
1145
|
+
if (a === 's-connect') { var pv = el.getAttribute('data-p'); var c = (d.connections || []).filter(function(x){ return x.provider === pv; })[0]; if (c && c.connectUrl) { openLink(c.connectUrl).then(function(){ startWatch(pv); render(); }); } return; }
|
|
1146
|
+
// concepts
|
|
1147
|
+
if (a === 'c-pick') { state.concepts.open = i; return render(); }
|
|
1148
|
+
if (a === 'c-back') { state.concepts.open = null; return render(); }
|
|
1149
|
+
if (a === 'c-go') return renderConcept(i);
|
|
1150
|
+
if (a === 'c-see') { state.focus = null; state.view = 'results'; render(); return schedulePoll(); }
|
|
1151
|
+
if (a === 'r-concepts') { state.focus = null; state.sched = null; state.view = 'concepts'; return render(); }
|
|
1152
|
+
if (a === 'c-more') return moreConcepts();
|
|
1153
|
+
// calendar
|
|
1154
|
+
if (a === 'cal-week') { var dd = +el.getAttribute('data-d'); state.cal.offset = dd === 0 ? 0 : state.cal.offset + dd; return render(); }
|
|
1155
|
+
if (a === 'cal-open') { var id = el.getAttribute('data-id'); state.cal.draftAt = ''; state.cal.open = state.cal.open === id ? null : id; render(); if (state.cal.open && !state.cal.full[id]) load([], { postId: id }); return; }
|
|
1156
|
+
if (a === 'cal-close') { state.cal.open = null; return render(); }
|
|
1157
|
+
if (a === 'cal-move') { var p = findPost(el.getAttribute('data-id')); var v = $('newAt') ? $('newAt').value : ''; if (!v) { toast('Pick a new time first.'); return; } return moveTo(p, new Date(v)); }
|
|
1158
|
+
if (a === 'cal-cancel') return cancelPost(findPost(el.getAttribute('data-id')));
|
|
1159
|
+
});
|
|
1160
|
+
document.addEventListener('change', function(e){
|
|
1161
|
+
var t = e.target;
|
|
1162
|
+
if (t && t.id === 'brandSel') {
|
|
1163
|
+
var id = t.value; t.disabled = true;
|
|
1164
|
+
callTool(TOOL.useBrand, { brand: id }).catch(function(err){ state.loadError = String(err && err.message || err); })
|
|
1165
|
+
.then(function(){ state.selected = []; syncContext(); return load(['library', 'jobs', 'brands', 'calendar', 'brand', 'prefs']); });
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
if (t && t.getAttribute && t.getAttribute('data-field')) { var o = conceptOpts(+t.getAttribute('data-i')); var f = t.getAttribute('data-field'); o[f] = f === 'length' ? +t.value : t.value; render(); return; }
|
|
1169
|
+
if (t && t.id === 'newAt') state.cal.draftAt = t.value;
|
|
1170
|
+
if (t && t.id === 'schedAt' && state.sched) state.sched.at = t.value;
|
|
1171
|
+
});
|
|
1172
|
+
document.addEventListener('input', function(e){
|
|
1173
|
+
var t = e.target;
|
|
1174
|
+
if (t && t.id === 'composer') { t.style.height = 'auto'; t.style.height = Math.min(180, t.scrollHeight) + 'px'; updateMenu(); return; }
|
|
1175
|
+
if (t && t.id === 'briefProduct') { state.brief.product = t.value; return; }
|
|
1176
|
+
if (t && t.id === 'setupInput') { state.setup.input = t.value; return; }
|
|
1177
|
+
if (t && t.id === 'schedCaption' && state.sched) { state.sched.caption = t.value; return; }
|
|
1178
|
+
if (t && t.id === 'schedAt' && state.sched) { state.sched.at = t.value; return; }
|
|
1179
|
+
});
|
|
1180
|
+
document.addEventListener('keydown', function(e){
|
|
1181
|
+
var t = e.target;
|
|
1182
|
+
if (e.key === 'Escape' && !$('modal').hidden) { closeModal(false); return; }
|
|
1183
|
+
if (!t || t.id !== 'composer') return;
|
|
1184
|
+
var menuOpen = $('mmenu') && !$('mmenu').hidden && state.composer.menu.length;
|
|
1185
|
+
if (menuOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { e.preventDefault(); var n = state.composer.menu.length; state.composer.hi = (state.composer.hi + (e.key === 'ArrowDown' ? 1 : n - 1)) % n; updateMenu(); return; }
|
|
1186
|
+
if (menuOpen && (e.key === 'Enter' || e.key === 'Tab')) { e.preventDefault(); pickMention(state.composer.hi); return; }
|
|
1187
|
+
if (e.key === 'Escape' && menuOpen) { $('mmenu').hidden = true; return; }
|
|
1188
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendComposer(); }
|
|
1189
|
+
});
|
|
1190
|
+
// drag a queued post to another day: same time of day, then the confirm says the exact new time
|
|
1191
|
+
document.addEventListener('dragstart', function(e){ var el = e.target.closest && e.target.closest('.post[draggable=true]'); if (!el) return; state.cal.drag = el.getAttribute('data-id'); try { e.dataTransfer.setData('text/plain', state.cal.drag); e.dataTransfer.effectAllowed = 'move'; } catch (x) {} });
|
|
1192
|
+
document.addEventListener('dragover', function(e){ var z = e.target.closest && e.target.closest('[data-drop]'); if (!z || !state.cal.drag) return; e.preventDefault(); z.classList.add('drop'); });
|
|
1193
|
+
document.addEventListener('dragleave', function(e){ var z = e.target.closest && e.target.closest('[data-drop]'); if (z) z.classList.remove('drop'); });
|
|
1194
|
+
document.addEventListener('dragend', function(){ state.cal.drag = null; var zs = document.querySelectorAll('.drop'); for (var i = 0; i < zs.length; i++) zs[i].classList.remove('drop'); });
|
|
1195
|
+
document.addEventListener('drop', function(e){
|
|
1196
|
+
var z = e.target.closest && e.target.closest('[data-drop]'); if (!z || !state.cal.drag) return; e.preventDefault(); z.classList.remove('drop');
|
|
1197
|
+
var p = findPost(state.cal.drag); state.cal.drag = null; if (!p || !p.at) return;
|
|
1198
|
+
var day = weekDays()[+z.getAttribute('data-day')]; var old = new Date(p.at);
|
|
1199
|
+
var t = new Date(day); t.setHours(old.getHours(), old.getMinutes(), 0, 0);
|
|
1200
|
+
if (dayKey(t) === dayKey(old)) return;
|
|
1201
|
+
moveTo(p, t);
|
|
1202
|
+
});
|
|
1203
|
+
window.addEventListener('focus', function(){ if (state.setup.watch) load(['connectors']); });
|
|
1204
|
+
if (window.ResizeObserver) new ResizeObserver(reportSize).observe(document.body);
|
|
1205
|
+
|
|
1206
|
+
rpc('ui/initialize', { appInfo: { name: 'hermoso', version: '1.1.0' }, appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] }, protocolVersion: PROTOCOL }, 15000)
|
|
1207
|
+
.then(function(r){
|
|
1208
|
+
state.caps = (r && r.hostCapabilities) || {};
|
|
1209
|
+
applyHost((r && r.hostContext) || {});
|
|
1210
|
+
notify('ui/notifications/initialized', {});
|
|
1211
|
+
state.ready = true; reportSize();
|
|
1212
|
+
// The spec asks an app to render the tool result it is handed rather than call again. Only if none arrives
|
|
1213
|
+
// (an entrypoint opened before its result, or a host that skips it) does the app ask for the data itself.
|
|
1214
|
+
setTimeout(function(){
|
|
1215
|
+
if (state.data) return;
|
|
1216
|
+
if (CARD_VIEWS.indexOf(state.view) >= 0) { state.loadError = 'Ask Hermoso again to show this.'; render(); return; }
|
|
1217
|
+
load(SECTIONS_FOR[state.view] || SECTIONS_FOR.home);
|
|
1218
|
+
}, 2500);
|
|
1219
|
+
})
|
|
1220
|
+
.catch(function(e){ state.loadError = 'This view needs ChatGPT to open it. ' + String(e && e.message || e); state.data = state.data || null; render(); });
|
|
1221
|
+
})();
|
|
1222
|
+
</script>`;
|
|
1223
|
+
|
|
1224
|
+
export const LIBRARY_APP_HTML = ('<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">\n'
|
|
1225
|
+
+ APP_STYLE + '</head><body>\n' + APP_BODY + '\n' + APP_SCRIPT + '</body></html>').replace('%%BLOOM%%', ICON_SVG.replace(/'/g, '"').replace('width="20" height="20"', 'width="34" height="34"'));
|
|
1226
|
+
|
|
1227
|
+
export const LIBRARY_APP_URI = `ui://hermoso/library-${createHash('sha1').update(LIBRARY_APP_HTML).digest('hex').slice(0, 10)}.html`;
|
|
1228
|
+
const LIBRARY_ANY_HASH = 'ui://hermoso/library-{hash}.html';
|
|
1229
|
+
export const MENTION_URI_TEMPLATE = 'hermoso://{kind}/{id}';
|
|
1230
|
+
export const LIBRARY_DISPLAY_MODES = Object.freeze({ availableDisplayModes: ['inline', 'fullscreen'], preferredDisplayMode: 'inline' });
|
|
1231
|
+
|
|
1232
|
+
// The resource `_meta`: the CSP the existing cards use (own origins only, no connect, no frames, both shapes) plus
|
|
1233
|
+
// the display modes. `inline` is PREFERRED because a model-invoked card should not take the whole window over a chat;
|
|
1234
|
+
// the two entrypoints open fullscreen regardless (spec: "ChatGPT uses the fullscreen display mode for all
|
|
1235
|
+
// entrypoints"), and the app offers Expand when it is inline.
|
|
1236
|
+
export const libraryResourceMeta = (resourceMeta, origins) => ({
|
|
1237
|
+
...resourceMeta('Hermoso in ChatGPT: a home screen, this profile’s Library, renders in progress, the posting calendar, ad campaigns, a new-ad brief, ad concepts to pick from before rendering, and profile setup.', origins),
|
|
1238
|
+
'openai/ui': { ...LIBRARY_DISPLAY_MODES },
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
// ── A REFUSAL FOR LACK OF CREDITS IS ONE NEUTRAL SENTENCE INSIDE THE APP ─────────────────────────────────────────
|
|
1242
|
+
// Elsewhere an out-of-credits answer names buy_credits and a checkout link. OpenAI's guidelines allow a plugin to say a
|
|
1243
|
+
// feature needs more credits and forbid any step toward a purchase, so inside the app that answer is replaced by the
|
|
1244
|
+
// one sentence below. Everything else passes through as it was written.
|
|
1245
|
+
export const NEUTRAL_CREDIT_NOTE = 'This needs more credits than this Hermoso account has right now.';
|
|
1246
|
+
export const neutralNote = (t) => {
|
|
1247
|
+
const s = String(t ?? '').replace(/^Error:\s*/, '').trim();
|
|
1248
|
+
if (/insufficient|not enough credits|out of credits|buy_credits|top[- ]?up|upgrade_plan|checkout|\b402\b|credit balance/i.test(s)) return NEUTRAL_CREDIT_NOTE;
|
|
1249
|
+
return s.slice(0, 240);
|
|
1250
|
+
};
|
|
1251
|
+
|
|
1252
|
+
// ── WHAT THE APP RENDERS: PURE, SO A CHECK RUNS IT ───────────────────────────────────────────────────────────────
|
|
1253
|
+
// `reads` holds, per section, `{data}` from the existing tool's structuredContent or `{error}` with its text. A
|
|
1254
|
+
// FAILED read is reported as a note and never as an empty list ([[failed-read-is-not-empty]]).
|
|
1255
|
+
// OUR OWN HOUSEKEEPING IS NOT A RENDER. /api/jobs lists every job on the account, including idle nudges, retention
|
|
1256
|
+
// sweeps and drift watches queued for later (measured on a fresh local account: one `agentidle` "nudge", queued for
|
|
1257
|
+
// tomorrow, would have shown as "1 render in progress"). Mirrors server.js JOB_TYPES_HIDDEN_FROM_V1, and the check
|
|
1258
|
+
// fails if that list gains a type this one lacks. A job queued for LATER is not in progress either.
|
|
1259
|
+
export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['purge', 'agentidle', 'agentsetupmail', 'uploadretention', 'connectordrift', 'falwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth']);
|
|
1260
|
+
const ageText = (h) => (h == null || !Number.isFinite(+h) ? '' : +h < 1 ? 'just now' : +h < 48 ? `${Math.round(+h)}h ago` : `${Math.round(+h / 24)}d ago`);
|
|
1261
|
+
const VIDEO_RE = /\.(mp4|webm|mov|m4v)([?#]|$)/i;
|
|
1262
|
+
const clip = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
|
|
1263
|
+
const EDITABLE_POST = ['queued', 'scheduled'];
|
|
1264
|
+
|
|
1265
|
+
// Display name and group for every connector the server offers. An id this table does not know still renders, by
|
|
1266
|
+
// its own id, in the third group, so a new connector is never hidden ([[failed-read-is-not-empty]]).
|
|
1267
|
+
const PROVIDER_INFO = {
|
|
1268
|
+
meta: ['Meta (Facebook and Instagram)', 'channels'], instagram: ['Instagram', 'channels'], threads: ['Threads', 'channels'],
|
|
1269
|
+
tiktok: ['TikTok', 'channels'], youtube: ['YouTube', 'channels'], linkedin: ['LinkedIn', 'channels'], x: ['X', 'channels'],
|
|
1270
|
+
pinterest: ['Pinterest', 'channels'], bluesky: ['Bluesky', 'channels'], telegram: ['Telegram', 'channels'], reddit: ['Reddit', 'channels'],
|
|
1271
|
+
google_business: ['Google Business Profile', 'channels'], whatsapp: ['WhatsApp', 'channels'], snapchat: ['Snapchat', 'channels'],
|
|
1272
|
+
google_ads: ['Google Ads', 'ads'], tiktok_ads: ['TikTok Ads', 'ads'], linkedin_ads: ['LinkedIn Ads', 'ads'], microsoft_ads: ['Microsoft Advertising', 'ads'],
|
|
1273
|
+
reddit_ads: ['Reddit Ads', 'ads'], pinterest_ads: ['Pinterest Ads', 'ads'], snapchat_ads: ['Snapchat Ads', 'ads'], x_ads: ['X Ads', 'ads'],
|
|
1274
|
+
openai_ads: ['ChatGPT Ads', 'ads'], apple_ads: ['Apple Ads', 'ads'], applovin_ads: ['AppLovin Ads', 'ads'],
|
|
1275
|
+
google_drive: ['Google Drive, Sheets and Docs', 'data'], onedrive: ['OneDrive', 'data'], google_analytics: ['Google Analytics', 'data'],
|
|
1276
|
+
search_console: ['Google Search Console', 'data'], merchant_center: ['Google Merchant Center', 'data'], slack: ['Slack', 'data'],
|
|
1277
|
+
shopify: ['Shopify', 'data'], stripe: ['Stripe', 'data'], hubspot: ['HubSpot', 'data'],
|
|
1278
|
+
};
|
|
1279
|
+
export const providerInfo = (p) => {
|
|
1280
|
+
const hit = PROVIDER_INFO[p];
|
|
1281
|
+
if (hit) return { name: hit[0], group: hit[1] };
|
|
1282
|
+
const name = String(p || '').split('_').filter(Boolean).map((w) => (w === 'ads' ? 'Ads' : w.charAt(0).toUpperCase() + w.slice(1))).join(' ') || String(p);
|
|
1283
|
+
return { name, group: /_ads$/.test(String(p)) ? 'ads' : 'data' };
|
|
1284
|
+
};
|
|
1285
|
+
// The publishing channels a connector carries, for the "post here by default" settings. The ids are schedule_post's.
|
|
1286
|
+
const CHANNELS_OF = { meta: ['facebook', 'instagram'], instagram: ['instagram'], threads: ['threads'], tiktok: ['tiktok'], youtube: ['youtube'], linkedin: ['linkedin'], x: ['x'], pinterest: ['pinterest'], google_business: ['google_business'], bluesky: ['bluesky'], telegram: ['telegram'] };
|
|
1287
|
+
const CHANNEL_LABEL = { facebook: 'Facebook', instagram: 'Instagram', threads: 'Threads', tiktok: 'TikTok', youtube: 'YouTube', linkedin: 'LinkedIn', x: 'X', pinterest: 'Pinterest', google_business: 'Google Business Profile', bluesky: 'Bluesky', telegram: 'Telegram' };
|
|
1288
|
+
export const ALL_CHANNELS = Object.freeze(Object.keys(CHANNEL_LABEL));
|
|
1289
|
+
|
|
1290
|
+
export function libraryPayload(reads = {}, { view = 'library', origins = [], proxyBase = '', abs = (u) => u } = {}) {
|
|
1291
|
+
const appOrigin = (() => { try { return new URL(proxyBase).origin; } catch { return ''; } })();
|
|
1292
|
+
const own = (u) => { try { return origins.includes(new URL(u).origin); } catch { return false; } };
|
|
1293
|
+
// A picture on any other host goes through our own /api/img, exactly as the ad card does, or the CSP blanks it.
|
|
1294
|
+
const picture = (u) => (!u ? '' : own(u) ? u : (proxyBase ? proxyBase + encodeURIComponent(u) : ''));
|
|
1295
|
+
const poster = (u) => (appOrigin && u ? `${appOrigin}/api/video/poster?url=${encodeURIComponent(u)}` : '');
|
|
1296
|
+
const out = { view: EXT_VIEWS.includes(view) ? view : 'library', notes: {} };
|
|
1297
|
+
const errText = (r) => neutralNote(r?.error || 'Could not load this right now.');
|
|
1298
|
+
const media = (u) => { const url = abs(u); const video = VIDEO_RE.test(url); return { url, video, thumb: video ? poster(url) : picture(url) }; };
|
|
1299
|
+
|
|
1300
|
+
if (reads.library) {
|
|
1301
|
+
if (reads.library.error) out.notes.library = errText(reads.library);
|
|
1302
|
+
else out.items = (reads.library.data?.assets || []).filter((a) => a && a.url).slice(0, 24).map((a) => {
|
|
1303
|
+
const url = abs(a.url); const video = a.kind === 'video' || VIDEO_RE.test(url);
|
|
1304
|
+
return { url, kind: video ? 'video' : (a.kind || 'image'), model: a.model || '', age: ageText(a.ageHours), thumb: video ? poster(url) : picture(url), playable: video && own(url) };
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
if (reads.jobs) {
|
|
1308
|
+
if (reads.jobs.error) out.notes.jobs = errText(reads.jobs);
|
|
1309
|
+
else {
|
|
1310
|
+
const STATUS = { queued: 'Queued', running: 'Rendering', done: 'Done', error: 'Failed', cancelled: 'Cancelled' };
|
|
1311
|
+
// NOT /api/jobs' own `running`: that is the PROCESS-wide count (every account's renders), not this user's.
|
|
1312
|
+
const now = Date.now();
|
|
1313
|
+
const isWork = (j) => j && !PANEL_HIDDEN_JOB_TYPES.includes(j.type) && !(j.status === 'queued' && Number(j.runAt) > now + 60e3);
|
|
1314
|
+
out.jobs = (reads.jobs.data?.jobs || []).filter(isWork).slice(0, 12).map((j) => {
|
|
1315
|
+
const r = j?.result || {};
|
|
1316
|
+
const img = r.image || (Array.isArray(r.images) && r.images[0]) || (Array.isArray(r.thumbnails) && r.thumbnails[0]?.image) || '';
|
|
1317
|
+
const vid = r.video || (Array.isArray(r.clips) && r.clips[0]?.video) || '';
|
|
1318
|
+
const thumb = img ? picture(abs(img)) : vid ? poster(abs(vid)) : '';
|
|
1319
|
+
return { id: j.id, type: j.type || '', label: j.label || '', status: j.status || '', statusText: STATUS[j.status] || j.status || '', progress: Number(j.progress) || 0, stage: j.status === 'running' ? (j.stage || '') : '', model: j.model || '', thumb };
|
|
1320
|
+
});
|
|
1321
|
+
out.running = out.jobs.filter((j) => j.status === 'queued' || j.status === 'running').length;
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
if (reads.brands) {
|
|
1325
|
+
if (reads.brands.error) out.notes.brands = errText(reads.brands);
|
|
1326
|
+
else {
|
|
1327
|
+
const bs = (reads.brands.data?.brands || []).filter((b) => b && b.id != null).map((b) => ({ id: String(b.id), name: b.name || String(b.id), active: !!b.active }));
|
|
1328
|
+
out.brands = bs;
|
|
1329
|
+
const a = bs.find((b) => b.active);
|
|
1330
|
+
out.brand = a ? { id: a.id, name: a.name } : null;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
if (reads.calendar) {
|
|
1334
|
+
if (reads.calendar.error) out.notes.calendar = errText(reads.calendar);
|
|
1335
|
+
else {
|
|
1336
|
+
const row = (p) => {
|
|
1337
|
+
const m = p.mediaUrl ? media(p.mediaUrl) : null;
|
|
1338
|
+
return { id: p.id || '', at: p.at || null, channels: Array.isArray(p.channels) ? p.channels : [], status: p.status || '', message: clip(p.message, 140), media: p.media || (m ? (m.video ? 'video' : 'image') : 'none'), thumb: m ? m.thumb : '', editable: EDITABLE_POST.includes(p.status) };
|
|
1339
|
+
};
|
|
1340
|
+
out.scheduled = (reads.calendar.data?.scheduled || []).slice(0, 40).map(row);
|
|
1341
|
+
out.history = (reads.calendar.data?.history || []).slice(-6).reverse().map(row);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
if (reads.post) {
|
|
1345
|
+
if (reads.post.error) out.notes.post = errText(reads.post);
|
|
1346
|
+
else {
|
|
1347
|
+
const p = (reads.post.data?.scheduled || [])[0] || (reads.post.data?.history || [])[0] || null;
|
|
1348
|
+
if (p) {
|
|
1349
|
+
const raw = p.videoUrl || p.imageUrl || (Array.isArray(p.imageUrls) && p.imageUrls[0]) || '';
|
|
1350
|
+
const m = raw ? media(raw) : null;
|
|
1351
|
+
out.post = { id: p.id || '', at: p.at || null, channels: Array.isArray(p.channels) ? p.channels : [], status: p.status || '', message: String(p.message || '').slice(0, 2000), mediaUrl: m ? m.url : '', media: m ? (m.video ? 'video' : 'image') : 'none', thumb: m ? m.thumb : '', playable: !!(m && m.video && own(m.url)) };
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
if (reads.brand) {
|
|
1356
|
+
if (reads.brand.error) out.notes.brand = errText(reads.brand);
|
|
1357
|
+
else {
|
|
1358
|
+
const d = reads.brand.data || {}; const b = d.hasBrand ? d.brand : null;
|
|
1359
|
+
const sells = Array.isArray(b?.sells) ? b.sells.join(', ') : b?.sells;
|
|
1360
|
+
out.profile = b ? { name: b.name || b.domain || '', domain: b.domain || '', category: b.category || '', summary: clip(b.summary || b.positioning, 240), sells: clip(sells || b.product || '', 140), logo: b.logo ? picture(abs(b.logo)) : '' } : null;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
if (reads.connectors) {
|
|
1364
|
+
if (reads.connectors.error) out.notes.connectors = errText(reads.connectors);
|
|
1365
|
+
else {
|
|
1366
|
+
const d = reads.connectors.data || {};
|
|
1367
|
+
const tpl = typeof d.connectLink === 'string' && /^https:\/\//.test(d.connectLink) && d.connectLink.includes('{provider}') ? d.connectLink : 'https://app.hermoso.ai/?connect={provider}';
|
|
1368
|
+
const on = new Map();
|
|
1369
|
+
for (const c of d.connectors || []) if (c && c.provider && c.status !== 'revoked') on.set(c.provider, c);
|
|
1370
|
+
const ids = [...new Set([...(Array.isArray(d.providers) ? d.providers : []), ...on.keys()])].filter((p) => typeof p === 'string' && p);
|
|
1371
|
+
const ORDER = { channels: 0, ads: 1, data: 2 };
|
|
1372
|
+
out.connections = ids.map((p) => {
|
|
1373
|
+
const c = on.get(p); const info = providerInfo(p);
|
|
1374
|
+
return { provider: p, name: info.name, group: info.group, connected: !!c, label: c?.agentLabel ? clip(c.agentLabel, 80) : '', needsReconnect: !!(c && (c.scopeDrift?.status === 'missing' || /error|expired|invalid/i.test(String(c.status || '')))), connectUrl: tpl.replace('{provider}', encodeURIComponent(p)) };
|
|
1375
|
+
}).sort((a, b) => (ORDER[a.group] - ORDER[b.group]) || (Number(b.connected) - Number(a.connected)) || a.name.localeCompare(b.name));
|
|
1376
|
+
// The channels a post can be scheduled to right now: schedule_post's ids, from the connections that are on.
|
|
1377
|
+
// Google Business Profile is left out because schedule_post refuses it at enqueue (held back by Google).
|
|
1378
|
+
out.channels = [...new Set(out.connections.filter((c) => c.connected).flatMap((c) => CHANNELS_OF[c.provider] || []))].filter((id) => id !== 'google_business').map((id) => ({ id, label: CHANNEL_LABEL[id] }));
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
if (reads.ads) {
|
|
1382
|
+
if (reads.ads.error) out.notes.ads = errText(reads.ads);
|
|
1383
|
+
else out.ads = (reads.ads.data?.platforms || []).filter((g) => g && ADS_PLATFORMS[g.platform]).map((g) => {
|
|
1384
|
+
const P = ADS_PLATFORMS[g.platform];
|
|
1385
|
+
const base = { platform: g.platform, label: P.label, accountId: g.account?.id || '', accountName: clip(g.account?.name || '', 60) };
|
|
1386
|
+
if (g.error) return { ...base, campaigns: [], note: errText(g) };
|
|
1387
|
+
const d = g.data || {};
|
|
1388
|
+
const acc = !g.account && typeof P.list.account === 'function' ? P.list.account(d) : null;
|
|
1389
|
+
const rows = (Array.isArray(P.list.rows(d)) ? P.list.rows(d) : []).map((c) => adRow(c, d.currency, P.list.window || '')).filter(Boolean).slice(0, 25);
|
|
1390
|
+
return { ...base, ...(acc ? { accountId: acc.id, accountName: clip(acc.name, 60) } : {}), campaigns: rows };
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
if (reads.creators) {
|
|
1394
|
+
if (reads.creators.error) out.notes.creators = errText(reads.creators);
|
|
1395
|
+
else {
|
|
1396
|
+
const d = reads.creators.data || {};
|
|
1397
|
+
const mine = (d.creators || []).filter((c) => c && c.id != null).map((c) => ({ id: String(c.id), name: c.name || 'Creator', thumb: c.image ? picture(abs(c.image)) : '', preset: false }));
|
|
1398
|
+
const presets = (d.presets || []).filter((c) => c && c.id != null).slice(0, 8).map((c) => ({ id: String(c.id), name: c.name || 'Preset creator', thumb: c.image ? picture(abs(c.image)) : '', preset: true }));
|
|
1399
|
+
out.creators = [...mine, ...presets].slice(0, 24);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
if (reads.products) {
|
|
1403
|
+
if (reads.products.error) out.notes.products = errText(reads.products);
|
|
1404
|
+
else out.products = (reads.products.data?.photos || []).filter((p) => p && p.url).slice(0, 24).map((p) => ({ url: abs(p.url), label: clip(p.label || p.title || '', 60), thumb: picture(abs(p.url)) }));
|
|
1405
|
+
}
|
|
1406
|
+
if (reads.prefs) {
|
|
1407
|
+
if (reads.prefs.error) out.notes.prefs = errText(reads.prefs);
|
|
1408
|
+
else {
|
|
1409
|
+
const p = reads.prefs.data || {};
|
|
1410
|
+
out.prefs = { language: p.language || 'English', captions: p.captions === true, endCard: p.endCard === true, channels: (Array.isArray(p.channels) ? p.channels : []).filter((c) => ALL_CHANNELS.includes(c)) };
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
if (reads.results) {
|
|
1414
|
+
out.results = (reads.results.items || []).map((x) => resultItem(x, { abs, picture, poster, own }));
|
|
1415
|
+
}
|
|
1416
|
+
if (!Object.keys(out.notes).length) delete out.notes;
|
|
1417
|
+
return out;
|
|
1418
|
+
}
|
|
1419
|
+
// ── WHAT A RENDER SAYS TO THE PERSON, ONE PLAIN LINE EACH (2026-10-04) ───────────────────────────────────────────────
|
|
1420
|
+
// A render can come back with something the user has to hear: their saved product photo is a picture of a person and was
|
|
1421
|
+
// left out as the product (`brandPhoto`, lib/brand-face-product.mjs, on render_ad and generate_image), the label was
|
|
1422
|
+
// re-printed or still reads wrong (`labelPass`), or the product in the result is not theirs (`productCheck` on an image,
|
|
1423
|
+
// `qa.productMismatch` on a video). The tool replies say these to an agent in its own register (tool names, capitals);
|
|
1424
|
+
// the card shows each as one short sentence a person reads, built from the STRUCTURED fields, never fished out of prose.
|
|
1425
|
+
// A label pass that only confirmed the label ('checked') or was switched off ('off') changed nothing and stays quiet.
|
|
1426
|
+
// Any line that names a tool, a field or anything about paying is dropped rather than shown. Pure: the check RUNS it,
|
|
1427
|
+
// and the app script only displays the strings this returns.
|
|
1428
|
+
const NOTICE_SKIP = /\b(credits?|charged|price|pricing|buy|purchase|checkout|top[- ]?up|upgrade|billing)\b|\b[a-z]+_[a-z_]+\b/i;
|
|
1429
|
+
const noticeLine = (t) => {
|
|
1430
|
+
let s = String(t ?? '').replace(/^[\s\u26a0\ufe0f!]+/u, '').replace(/\s*[\u2014\u2013]\s*/g, ', ').replace(/\s+/g, ' ').trim();
|
|
1431
|
+
if (!s || NOTICE_SKIP.test(s)) return '';
|
|
1432
|
+
if (!/[.!?]$/.test(s)) s += '.';
|
|
1433
|
+
return clip(s, 240);
|
|
1434
|
+
};
|
|
1435
|
+
const payloadOf = (j) => { const r = j && typeof j === 'object' ? j.result : null; return r && typeof r === 'object' && Object.prototype.hasOwnProperty.call(r, 'data') ? r.data : r; };
|
|
1436
|
+
export function renderNotices(x) {
|
|
1437
|
+
const d = x && typeof x === 'object' ? x : {};
|
|
1438
|
+
const out = [];
|
|
1439
|
+
const count = (v) => (Array.isArray(v) ? v.length : 0);
|
|
1440
|
+
const bp = d.brandPhoto && typeof d.brandPhoto === 'object' ? d.brandPhoto : null;
|
|
1441
|
+
if (bp) {
|
|
1442
|
+
const left = count(bp.leftOut), unread = count(bp.unread);
|
|
1443
|
+
if (left === 1) out.push('Your saved product photo shows a person, so it was left out as the product. Add a photo of the product itself in Hermoso, and save the person as a creator to put them in your ads.');
|
|
1444
|
+
else if (left > 1) out.push(`${left} of your saved product photos show a person, so they were left out as the product. Add photos of the product itself in Hermoso, and save the person as a creator to put them in your ads.`);
|
|
1445
|
+
if (unread === 1) out.push('Your saved product photo could not be checked for a person, so it was used as is.');
|
|
1446
|
+
else if (unread > 1) out.push(`${unread} of your saved product photos could not be checked for a person, so they were used as is.`);
|
|
1447
|
+
}
|
|
1448
|
+
const lp = d.labelPass && typeof d.labelPass === 'object' ? d.labelPass : null;
|
|
1449
|
+
if (lp && (lp.status === 'fixed' || lp.status === 'left')) { const t = noticeLine(lp.note); if (t) out.push(t); }
|
|
1450
|
+
const pc = d.productCheck && typeof d.productCheck === 'object' ? d.productCheck : null;
|
|
1451
|
+
if (pc && pc.verdict === 'absent') out.push('Your product does not appear in this image.');
|
|
1452
|
+
else if (pc && pc.verdict === 'mismatch') { const why = noticeLine(Array.isArray(pc.issues) ? pc.issues[0] : '').replace(/\.$/, ''); out.push(`The product in this image does not match your product photo${why ? `: ${why.charAt(0).toLowerCase()}${why.slice(1)}` : ''}.`); }
|
|
1453
|
+
if (d.qa && typeof d.qa === 'object' && d.qa.productMismatch) out.push('The product in this video does not match your product photo.');
|
|
1454
|
+
return [...new Set(out)].slice(0, 4);
|
|
1455
|
+
}
|
|
1456
|
+
// The same lines for the model, so ChatGPT can explain what the card shows. '' when there is nothing to say.
|
|
1457
|
+
export const noticesText = (lines, lead = 'The app shows the user this note about the render') => (Array.isArray(lines) && lines.length ? `${lead}: ${lines.join(' ')}` : '');
|
|
1458
|
+
|
|
1459
|
+
// One gallery item from a get_job read (`{id, data}` / `{id, error}`) or a plain URL (`{url}`).
|
|
1460
|
+
function resultItem(x, { abs, picture, poster, own }) {
|
|
1461
|
+
if (x.url && !x.id) {
|
|
1462
|
+
const url = abs(x.url); const video = VIDEO_RE.test(url);
|
|
1463
|
+
return { id: '', status: 'done', progress: 100, url, kind: video ? 'video' : 'image', thumb: video ? poster(url) : picture(url), playable: video && own(url), label: '' };
|
|
1464
|
+
}
|
|
1465
|
+
if (x.error) return { id: x.id, status: 'error', progress: 0, url: '', kind: '', thumb: '', label: '', error: neutralNote(x.error) };
|
|
1466
|
+
const j = x.data || {};
|
|
1467
|
+
const notices = renderNotices({ ...(payloadOf(j) || {}), ...(j.brandPhoto ? { brandPhoto: j.brandPhoto } : {}) });
|
|
1468
|
+
const raw = j.url || j.result?.video || j.result?.image || (Array.isArray(j.result?.images) && j.result.images[0]) || '';
|
|
1469
|
+
const url = raw ? abs(raw) : '';
|
|
1470
|
+
const video = /video|stitch|avatar|clip|reframe|upscale|dub/i.test(String(j.type || '')) || VIDEO_RE.test(url);
|
|
1471
|
+
const status = String(j.status || 'queued');
|
|
1472
|
+
const pr = Number(j.progress) || 0;
|
|
1473
|
+
return {
|
|
1474
|
+
id: String(j.id || x.id), status, progress: status === 'done' ? 100 : Math.round(pr <= 1 ? pr * 100 : pr), url: status === 'done' ? url : '',
|
|
1475
|
+
kind: video ? 'video' : 'image', thumb: status === 'done' && url ? (video ? poster(url) : picture(url)) : '', playable: video && own(url), label: '',
|
|
1476
|
+
...(status === 'error' || status === 'not_found' ? { error: neutralNote(j.error || (status === 'not_found' ? 'No render with this id on this account.' : 'The render failed.')) } : {}),
|
|
1477
|
+
...(notices.length ? { notices } : {}),
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
// The model's half: a short sentence. The grid itself is the app's; structuredContent carries the URLs.
|
|
1481
|
+
export const libraryText = (p) => {
|
|
1482
|
+
const bits = [];
|
|
1483
|
+
if (p.brand?.name) bits.push(`Profile: ${p.brand.name}.`);
|
|
1484
|
+
if (Array.isArray(p.items)) bits.push(p.items.length ? `${p.items.length} Library asset${p.items.length === 1 ? '' : 's'}, newest first.` : 'The Library is empty.');
|
|
1485
|
+
if (Array.isArray(p.jobs)) { const live = p.jobs.filter((j) => j.status === 'queued' || j.status === 'running').length; bits.push(`${live} render${live === 1 ? '' : 's'} in progress.`); }
|
|
1486
|
+
if (Array.isArray(p.scheduled)) bits.push(`${p.scheduled.length} post${p.scheduled.length === 1 ? '' : 's'} scheduled.`);
|
|
1487
|
+
if (Array.isArray(p.connections)) bits.push(`${p.connections.filter((c) => c.connected).length} of ${p.connections.length} accounts connected.`);
|
|
1488
|
+
if (Array.isArray(p.ads)) bits.push(p.ads.length ? `${p.ads.reduce((n, g) => n + g.campaigns.length, 0)} ad campaigns across ${[...new Set(p.ads.map((g) => g.label))].join(', ')}.` : 'No ad platform is connected.');
|
|
1489
|
+
if ('profile' in p) bits.push(p.profile ? `Saved profile: ${p.profile.name}.` : 'No profile saved yet.');
|
|
1490
|
+
// One reason said once: signed out, all four sections fail with the same sentence.
|
|
1491
|
+
const notes = Object.entries(p.notes || {}); const reasons = [...new Set(notes.map(([, v]) => v))];
|
|
1492
|
+
if (notes.length && reasons.length === 1) bits.push(`${notes.map(([k]) => k).join(', ')} could not be read: ${reasons[0]}`);
|
|
1493
|
+
else for (const [k, v] of notes) bits.push(`${k} could not be read: ${v}`);
|
|
1494
|
+
const lead = { home: 'Hermoso home is open.', ads: 'Hermoso Ads is open: the campaigns on every connected ad platform, which the user can pause or turn on after confirming on screen.', create: 'The new-ad form is open; the user picks options and reviews them, then the form sends the brief to this chat.', setup: 'Hermoso setup is open (brand, then connections, then a first ad).' }[p.view] || 'The Hermoso Library is open.';
|
|
1495
|
+
return `${lead} ${bits.join(' ')} The user can pick an asset to add it to the chat; it then arrives as context carrying its exact URL.`;
|
|
1496
|
+
};
|
|
1497
|
+
|
|
1498
|
+
// ── CONCEPTS ─────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
1499
|
+
// Whatever shape the concepts arrive in (plan_variations' {angle, hook_mechanic, headline, supporting, prompt}, a
|
|
1500
|
+
// plan_ad creative, or a list the model wrote), the cards read the same six fields.
|
|
1501
|
+
const humanize = (s) => String(s || '').replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
|
|
1502
|
+
export function normalizeConcepts(list) {
|
|
1503
|
+
return (Array.isArray(list) ? list : []).filter((c) => c && typeof c === 'object').slice(0, 8).map((c) => ({
|
|
1504
|
+
title: clip(c.title || c.angle || c.name || c.concept || '', 80),
|
|
1505
|
+
hook: clip(c.hook || c.hook_label || (c.hook_mechanic ? humanize(c.hook_mechanic) : '') || '', 60),
|
|
1506
|
+
// the planner's own hook id (list_hooks), carried so Render it asks plan_ad for the same opening mechanic
|
|
1507
|
+
hookId: /^[a-z][a-z0-9_]{1,40}$/.test(String(c.hook_mechanic || c.hookId || '')) ? String(c.hook_mechanic || c.hookId) : '',
|
|
1508
|
+
headline: clip(c.headline || c.copy?.[0]?.headline || '', 140),
|
|
1509
|
+
line: clip(c.line || c.supporting || c.primary || c.copy?.[0]?.primary || '', 200),
|
|
1510
|
+
cta: clip(c.cta || c.copy?.[0]?.cta || '', 40),
|
|
1511
|
+
visual: clip(c.description || c.visual || c.brief || c.prompt || c.image_concept?.prompt || '', 320),
|
|
1512
|
+
})).filter((c) => c.title || c.headline || c.visual);
|
|
1513
|
+
}
|
|
1514
|
+
export const conceptsText = (p) => (p.concepts?.length
|
|
1515
|
+
? `Showing ${p.concepts.length} ad concept${p.concepts.length === 1 ? '' : 's'} as cards. Nothing has been rendered. Wait for the user to pick one: when they confirm a pick on its card, the card itself renders that one concept on their Hermoso account and shows it in place, so do not render it again from the chat. Concepts: ${p.concepts.map((c, i) => `${i + 1}. ${c.title || c.headline}`).join('; ')}.`
|
|
1516
|
+
: `No concepts to show. ${p.note || ''}`.trim());
|
|
1517
|
+
|
|
1518
|
+
// ── SETTINGS (openai/settings) ───────────────────────────────────────────────────────────────────────────────────
|
|
1519
|
+
export const SETTINGS_LANGUAGES = Object.freeze(['English', 'Spanish', 'French', 'German', 'Italian', 'Portuguese', 'Brazilian Portuguese', 'Dutch', 'Polish', 'Swedish', 'Danish', 'Norwegian', 'Finnish', 'Turkish', 'Russian', 'Ukrainian', 'Arabic', 'Hebrew', 'Hindi', 'Indonesian', 'Japanese', 'Korean', 'Chinese (Simplified)', 'Chinese (Traditional)', 'Thai', 'Vietnamese']);
|
|
1520
|
+
// Pure: the settings page from what the existing tools answered. Brand names are the enum (an id like p_x7 means
|
|
1521
|
+
// nothing to a person); two brands with one name get a suffix, and the same function resolves the label back.
|
|
1522
|
+
export function settingsModel({ brands = [], language = 'English', prefs = {}, connectors = [] } = {}) {
|
|
1523
|
+
const seen = new Map(); const brandLabels = [];
|
|
1524
|
+
for (const b of brands) { const base = String(b.name || b.id); const n = (seen.get(base) || 0) + 1; seen.set(base, n); brandLabels.push({ id: String(b.id), label: n > 1 ? `${base} (${n})` : base, active: !!b.active }); }
|
|
1525
|
+
const active = brandLabels.find((b) => b.active) || brandLabels[0];
|
|
1526
|
+
const langs = SETTINGS_LANGUAGES.includes(language) || !language ? [...SETTINGS_LANGUAGES] : [language, ...SETTINGS_LANGUAGES];
|
|
1527
|
+
const channels = [...new Set(connectors.filter((c) => c && c.connected !== false).flatMap((c) => CHANNELS_OF[c.provider] || []))];
|
|
1528
|
+
const want = new Set(Array.isArray(prefs.channels) ? prefs.channels : []);
|
|
1529
|
+
const properties = {};
|
|
1530
|
+
const values = {};
|
|
1531
|
+
if (brandLabels.length) { properties.brand = { type: 'string', title: 'Profile', description: 'The profile Hermoso works on in ChatGPT. Everything below is saved per profile.', enum: brandLabels.map((b) => b.label) }; values.brand = active.label; }
|
|
1532
|
+
properties.language = { type: 'string', title: 'Language for new ads', description: 'The language ads, scripts and copy are written in. Applies everywhere you use Hermoso.', enum: langs }; values.language = language || 'English';
|
|
1533
|
+
properties.captions = { type: 'boolean', title: 'Captions on new videos', description: 'Burn subtitles of the spoken words into new video ads unless you say otherwise.' }; values.captions = prefs.captions === true;
|
|
1534
|
+
properties.endCard = { type: 'boolean', title: 'Brand end card on new videos', description: 'Add your brand end card to new video ads unless you say otherwise.' }; values.endCard = prefs.endCard === true;
|
|
1535
|
+
for (const ch of channels) { properties[`channel_${ch}`] = { type: 'boolean', title: `Post to ${CHANNEL_LABEL[ch]} by default`, description: `Suggest ${CHANNEL_LABEL[ch]} when you ask Hermoso to post or schedule.` }; values[`channel_${ch}`] = want.has(ch); }
|
|
1536
|
+
const layout = [
|
|
1537
|
+
{ kind: 'group', title: 'Profile', items: [...(properties.brand ? [{ kind: 'property', property: 'brand' }] : []), { kind: 'tool', tool: EXT_SETUP_TOOL, title: 'Set up profile and accounts…', description: 'Read your website, then connect your channels and ad accounts.' }] },
|
|
1538
|
+
{ kind: 'group', title: 'New ads', items: [{ kind: 'property', property: 'language' }, { kind: 'property', property: 'captions' }, { kind: 'property', property: 'endCard' }] },
|
|
1539
|
+
];
|
|
1540
|
+
if (channels.length) layout.push({ kind: 'group', title: 'Posting', items: channels.map((ch) => ({ kind: 'property', property: `channel_${ch}` })) });
|
|
1541
|
+
return { schema: { type: 'object', properties }, values, layout, brandIdOf: (label) => (brandLabels.find((b) => b.label === label) || brandLabels.find((b) => b.id === label) || null)?.id || null };
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// ── MENTIONS ─────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
1545
|
+
const assetId = (url) => createHash('sha1').update(String(url)).digest('hex').slice(0, 12);
|
|
1546
|
+
// Every candidate the @-mention picker can offer, with the sentence the model reads when one is mentioned. Pure.
|
|
1547
|
+
export function mentionCandidates(pool = {}, { abs = (u) => u } = {}) {
|
|
1548
|
+
const out = [];
|
|
1549
|
+
for (const b of pool.brands || []) if (b && b.id != null) out.push({ kind: 'brand', id: String(b.id), name: b.name || String(b.id), text: `Hermoso profile "${b.name || b.id}" (profile id ${b.id})${b.active ? ', the active profile' : ''}. To work on it, switch with use_brand("${b.id}").` });
|
|
1550
|
+
for (const c of pool.creators || []) if (c && c.id != null) out.push({ kind: 'creator', id: String(c.id), name: c.name || 'Creator', thumb: c.image ? abs(c.image) : '', text: `Saved Hermoso creator "${c.name || c.id}" (creator id ${c.id}). Cast them in an ad with render_ad creator "${c.id}".` });
|
|
1551
|
+
for (const a of pool.assets || []) if (a && a.url) { const url = abs(a.url); out.push({ kind: 'asset', id: assetId(url), name: `${a.kind === 'video' ? 'Video' : 'Image'}${a.model ? ' · ' + a.model : ''}${a.ageHours != null ? ' · ' + ageText(a.ageHours) : ''}`, thumb: VIDEO_RE.test(url) ? '' : url, text: `Hermoso Library ${a.kind || 'asset'}: ${url}${a.model ? ` (made with ${a.model})` : ''}. Use this exact URL to post, schedule, edit or reuse it.` }); }
|
|
1552
|
+
for (const ad of pool.ads || []) if (ad && ad.key) out.push({ kind: 'ad', id: String(ad.key), name: clip(`${ad.advertiser || 'Saved ad'}${ad.title ? ': ' + ad.title : ''}`, 80), thumb: ad.image ? abs(ad.image) : '', text: clip(`Saved swipefile ad from ${ad.advertiser || 'an advertiser'}${ad.platform ? ` on ${ad.platform}` : ''}: "${ad.title || ''}" ${ad.body || ''} Media: ${ad.video || ad.image || 'none'}.${ad.link ? ` Link: ${ad.link}.` : ''}`, 600) });
|
|
1553
|
+
for (const p of pool.posts || []) if (p && p.id) out.push({ kind: 'post', id: String(p.id), name: clip(`Scheduled ${p.at ? new Date(p.at).toUTCString().slice(0, 22) : ''} · ${(p.channels || []).join(', ')}`, 80), text: clip(`Scheduled post ${p.id}: ${p.status || 'queued'} at ${p.at || 'no time'} to ${(p.channels || []).join(', ') || 'no channel'}. Caption: ${p.message || ''}${p.mediaUrl ? ` Media: ${p.mediaUrl}.` : ''} Change it with reschedule_post or cancel_scheduled (id ${p.id}).`, 600) });
|
|
1554
|
+
return out;
|
|
1555
|
+
}
|
|
1556
|
+
export function mentionItems(cands, query = '', max = 20) {
|
|
1557
|
+
const q = String(query || '').trim().toLowerCase();
|
|
1558
|
+
const hit = q ? cands.filter((c) => c.name.toLowerCase().includes(q) || c.kind.includes(q)) : (() => { const per = {}; return cands.filter((c) => (per[c.kind] = (per[c.kind] || 0) + 1) <= 4); })();
|
|
1559
|
+
const LABEL = { brand: 'Profile', creator: 'Creator', asset: 'Library', ad: 'Swipefile ad', post: 'Scheduled post' };
|
|
1560
|
+
return hit.slice(0, max).map((c) => ({ type: 'resource_link', uri: `hermoso://${c.kind}/${encodeURIComponent(c.id)}`, name: c.name, title: `${LABEL[c.kind]}: ${c.name}`.slice(0, 100), description: c.text.slice(0, 300), mimeType: 'text/plain', ...(c.thumb && /^https:\/\//.test(c.thumb) ? { icons: [{ src: c.thumb }] } : {}) }));
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// Which existing tool answers which section, and with what arguments. They are called IN-PROCESS through their own
|
|
1564
|
+
// registered handlers (same auth, same workspace pin, same error ledger), never re-implemented here.
|
|
1565
|
+
const SECTION_SOURCE = {
|
|
1566
|
+
library: ['list_library', { kind: 'all', limit: 24 }],
|
|
1567
|
+
jobs: ['list_jobs', {}],
|
|
1568
|
+
brands: ['list_brands', {}],
|
|
1569
|
+
calendar: ['list_scheduled', { upcoming: 40, fired: 6 }],
|
|
1570
|
+
brand: ['get_brand', {}],
|
|
1571
|
+
connectors: ['list_connectors', {}],
|
|
1572
|
+
creators: ['list_creators', { limit: 24 }],
|
|
1573
|
+
products: ['list_product_photos', {}],
|
|
1574
|
+
};
|
|
1575
|
+
const textOfResult = (r) => (r?.content || []).find((c) => c?.type === 'text')?.text || '';
|
|
1576
|
+
async function callRead(call, tool, args, extra) {
|
|
1577
|
+
try {
|
|
1578
|
+
const r = await call(tool, args, extra);
|
|
1579
|
+
if (!r) return { error: 'This is not available on this connection.' };
|
|
1580
|
+
if (r.isError) return { error: textOfResult(r) || 'Could not load this right now.' };
|
|
1581
|
+
return { data: r.structuredContent || {} };
|
|
1582
|
+
} catch (e) { return { error: String(e?.message || e).slice(0, 240) }; }
|
|
1583
|
+
}
|
|
1584
|
+
async function readPrefs(prefs) {
|
|
1585
|
+
if (!prefs || typeof prefs.read !== 'function') return {};
|
|
1586
|
+
const v = await prefs.read();
|
|
1587
|
+
return v && typeof v === 'object' ? v : {};
|
|
1588
|
+
}
|
|
1589
|
+
async function readSections(sections, call, extra, deps = {}) {
|
|
1590
|
+
const reads = {};
|
|
1591
|
+
await Promise.all(sections.map(async (s) => {
|
|
1592
|
+
if (s === 'prefs') {
|
|
1593
|
+
const [st, pr] = await Promise.all([callRead(call, 'get_settings', {}, extra), readPrefs(deps.prefs).then((v) => ({ v }), (e) => ({ e }))]);
|
|
1594
|
+
if (st.error) reads.prefs = { error: st.error };
|
|
1595
|
+
else if (pr.e) reads.prefs = { error: String(pr.e?.message || pr.e) };
|
|
1596
|
+
else reads.prefs = { data: { language: st.data.language, ...pr.v } };
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
if (s === 'ads') { reads.ads = await readAds(call, extra); return; }
|
|
1600
|
+
const src = SECTION_SOURCE[s]; if (!src) return;
|
|
1601
|
+
reads[s] = await callRead(call, src[0], src[1], extra);
|
|
1602
|
+
}));
|
|
1603
|
+
return reads;
|
|
1604
|
+
}
|
|
1605
|
+
// The Ads section: which ad platforms are connected (list_connectors), then each one's shared accounts and campaigns
|
|
1606
|
+
// through its own list tools. A platform that could not be read is a note on that platform, never "no campaigns".
|
|
1607
|
+
const ADS_ACCOUNTS_MAX = 3;
|
|
1608
|
+
async function readAds(call, extra) {
|
|
1609
|
+
const conn = await callRead(call, 'list_connectors', {}, extra);
|
|
1610
|
+
if (conn.error) return { error: conn.error };
|
|
1611
|
+
const on = new Set((conn.data?.connectors || []).filter((c) => c && c.provider && c.status !== 'revoked').map((c) => c.provider));
|
|
1612
|
+
const keys = Object.keys(ADS_PLATFORMS).filter((k) => on.has(ADS_PLATFORMS[k].provider));
|
|
1613
|
+
const groups = await Promise.all(keys.map(async (platform) => {
|
|
1614
|
+
const P = ADS_PLATFORMS[platform];
|
|
1615
|
+
let accounts = [null];
|
|
1616
|
+
if (P.accounts) {
|
|
1617
|
+
const a = await callRead(call, P.accounts.tool, P.accounts.args, extra);
|
|
1618
|
+
if (a.error) return [{ platform, account: null, error: a.error }];
|
|
1619
|
+
accounts = P.accounts.pick(a.data || {}).filter((x) => x.id).slice(0, ADS_ACCOUNTS_MAX);
|
|
1620
|
+
if (!accounts.length) return [{ platform, account: null, error: 'No ad account is shared with this profile yet. Pick one in Hermoso under Connections.' }];
|
|
1621
|
+
}
|
|
1622
|
+
return Promise.all(accounts.map(async (account) => {
|
|
1623
|
+
const r = await callRead(call, P.list.tool, P.list.args(account || {}), extra);
|
|
1624
|
+
return r.error ? { platform, account, error: r.error } : { platform, account, data: r.data };
|
|
1625
|
+
}));
|
|
1626
|
+
}));
|
|
1627
|
+
return { data: { platforms: groups.flat() } };
|
|
1628
|
+
}
|
|
1629
|
+
async function readResults(jobIds, urls, call, extra) {
|
|
1630
|
+
const items = await Promise.all(jobIds.map(async (id) => { const r = await callRead(call, 'get_job', { id }, extra); return r.error ? { id, error: r.error } : { id, data: r.data }; }));
|
|
1631
|
+
return { items: [...items, ...urls.map((url) => ({ url }))] };
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// Every hint is explicit (OpenAI's review rule); the reasons live in tools/lib/chatgpt-tool-annotations.json.
|
|
1635
|
+
const READ_ONLY_HINTS = Object.freeze({ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false });
|
|
1636
|
+
const appMeta = () => ({ ui: { resourceUri: LIBRARY_APP_URI } });
|
|
1637
|
+
const APP_ONLY = Object.freeze({ ui: { visibility: ['app'] } });
|
|
1638
|
+
|
|
1639
|
+
export function libraryToolDefs() {
|
|
1640
|
+
const view = z.enum(['library', 'jobs', 'calendar']).optional().describe("which tab to open: 'library' (default), 'jobs' (renders in progress) or 'calendar' (scheduled posts)");
|
|
1641
|
+
const ext = z.string().max(80);
|
|
1642
|
+
return {
|
|
1643
|
+
[EXT_HOME_TOOL]: {
|
|
1644
|
+
title: 'Home',
|
|
1645
|
+
description: "Open Hermoso's home screen: a composer that sends the user's request to this chat (they can type @ to add a profile, saved creator, product photo or Library item), quick starts, and shortcuts to the Library, a new ad, Ad Spy, the posting calendar, connections and profile setup. Use it when the user wants to start something in Hermoso or look around. Read-only: it reads the active profile, its Library and renders in progress, and changes nothing.",
|
|
1646
|
+
inputSchema: {},
|
|
1647
|
+
annotations: { title: 'Home', ...READ_ONLY_HINTS },
|
|
1648
|
+
_meta: { ...appMeta(), 'openai/ui': { entrypoints: [{ type: 'global' }] } },
|
|
1649
|
+
},
|
|
1650
|
+
[EXT_ENTRY_TOOL]: {
|
|
1651
|
+
title: 'Library',
|
|
1652
|
+
description: "Open the Hermoso Library as an interactive panel: this profile's finished images and videos, renders in progress and scheduled posts. The user can pick an asset to add it to the chat, and move or cancel a scheduled post from the calendar after confirming. Use it when the user wants to SEE or choose from their creative or calendar; for a plain list of URLs use list_library. Opening it changes nothing.",
|
|
1653
|
+
inputSchema: { view },
|
|
1654
|
+
annotations: { title: 'Library', ...READ_ONLY_HINTS },
|
|
1655
|
+
_meta: { ...appMeta(), 'openai/ui': { entrypoints: [{ type: 'thread' }] } },
|
|
1656
|
+
},
|
|
1657
|
+
[EXT_DATA_TOOL]: {
|
|
1658
|
+
title: 'Hermoso panel data',
|
|
1659
|
+
description: "Refreshes the Hermoso panel's data (Library assets, renders, profiles, scheduled posts, the saved profile, connected accounts, saved creators, product photos, defaults, render status for a gallery, one scheduled post in full). Called by the panel itself, never by the model. Read-only.",
|
|
1660
|
+
inputSchema: {
|
|
1661
|
+
view: z.enum([...EXT_VIEWS]).optional().describe('which screen is asking'),
|
|
1662
|
+
sections: z.array(z.enum([...EXT_SECTIONS])).optional().describe('which parts to read (default: none beyond jobIds / postId)'),
|
|
1663
|
+
jobIds: z.array(ext).max(12).optional().describe('render job ids to read the status of'),
|
|
1664
|
+
postId: ext.optional().describe('one scheduled post to read in full'),
|
|
1665
|
+
},
|
|
1666
|
+
annotations: { title: 'Hermoso panel data', ...READ_ONLY_HINTS },
|
|
1667
|
+
_meta: { ...APP_ONLY },
|
|
1668
|
+
},
|
|
1669
|
+
[EXT_CONCEPTS_TOOL]: {
|
|
1670
|
+
title: 'Ad concepts',
|
|
1671
|
+
description: "Show ad concepts as cards so the user picks which ONE to render before anything is rendered. Pass the concepts you already have (from plan_ad, or ones you wrote: a title, hook, headline and what it shows), or leave out `concepts` and this comes up with `count` distinct concepts for `product` itself, which spends a few credits like any planning call. Showing the cards renders nothing: each card has a Render button that asks the user to confirm the format, size and length, and only then renders that one concept on their account and shows it in place (do not render it again from the chat), plus a More ideas button. Use it when the user asks for an ad and has not settled on one idea, wants options, or wants to choose before rendering.",
|
|
1672
|
+
inputSchema: {
|
|
1673
|
+
product: z.string().max(600).describe("what the ad is for: the product, offer or brief in the user's words"),
|
|
1674
|
+
concepts: z.array(z.object({
|
|
1675
|
+
title: z.string().max(120).optional().describe('the angle in a few words'),
|
|
1676
|
+
hook: z.string().max(80).optional().describe('the opening hook'),
|
|
1677
|
+
headline: z.string().max(200).optional().describe('the headline'),
|
|
1678
|
+
description: z.string().max(600).optional().describe('what the ad shows'),
|
|
1679
|
+
})).max(8).optional().describe('concepts you already planned; omit to have Hermoso come up with them'),
|
|
1680
|
+
count: z.number().int().min(2).max(8).optional().describe('how many concepts to come up with when `concepts` is omitted (default 4)'),
|
|
1681
|
+
format: z.enum(['image', 'video']).optional().describe('the format the user asked for, if they said; they can change it on the card'),
|
|
1682
|
+
aspectRatio: z.enum(['9:16', '1:1', '4:5', '16:9']).optional().describe('the size the user asked for, if they said'),
|
|
1683
|
+
durationSeconds: z.number().int().min(4).max(180).optional().describe('video length the user asked for, if they said'),
|
|
1684
|
+
productImage: z.string().max(600).optional().describe('a product photo URL the user picked, carried into the render request'),
|
|
1685
|
+
creator: z.string().max(120).optional().describe('a saved creator id or name the user picked, carried into the render request'),
|
|
1686
|
+
reference: z.string().max(600).optional().describe('a reference image or ad URL the user picked, carried into the render request'),
|
|
1687
|
+
language: z.string().max(40).optional().describe('language for the concepts (default: the account language)'),
|
|
1688
|
+
},
|
|
1689
|
+
annotations: { title: 'Ad concepts', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1690
|
+
_meta: appMeta(),
|
|
1691
|
+
},
|
|
1692
|
+
[EXT_RESULTS_TOOL]: {
|
|
1693
|
+
title: 'Ad results',
|
|
1694
|
+
description: "Show a batch of ads the user already asked for as a gallery: pass the job ids the renders returned (render_ad, generate_image, generate_video, multiply_ad and the like) and/or finished media URLs. Renders still in progress update in place. The user can add any item to the chat, hand it back with a request to post it, or schedule it to their connected channels from the gallery after confirming on screen. Opening it is read-only: it starts no render and posts nothing.",
|
|
1695
|
+
inputSchema: {
|
|
1696
|
+
jobIds: z.array(ext).max(12).optional().describe('render job ids'),
|
|
1697
|
+
urls: z.array(z.string().max(1000)).max(12).optional().describe('finished image or video URLs'),
|
|
1698
|
+
title: z.string().max(80).optional().describe('a short title for the gallery'),
|
|
1699
|
+
},
|
|
1700
|
+
annotations: { title: 'Ad results', ...READ_ONLY_HINTS },
|
|
1701
|
+
_meta: appMeta(),
|
|
1702
|
+
},
|
|
1703
|
+
[EXT_BRIEF_TOOL]: {
|
|
1704
|
+
title: 'New ad',
|
|
1705
|
+
description: "Open a form where the user sets up an ad by picking options: what it is for, image or video, size, length, a saved product photo, a saved creator and a Library item as reference, and how many concepts. After the user reviews it, the form sends the brief to this chat and asks for concepts first. Opening it renders nothing and spends nothing. Use it when the user wants to make an ad by choosing options rather than describing everything.",
|
|
1706
|
+
inputSchema: {
|
|
1707
|
+
format: z.enum(['image', 'video']).optional().describe('preselect image or video'),
|
|
1708
|
+
product: z.string().max(600).optional().describe('prefill what the ad is for'),
|
|
1709
|
+
},
|
|
1710
|
+
annotations: { title: 'New ad', ...READ_ONLY_HINTS },
|
|
1711
|
+
_meta: appMeta(),
|
|
1712
|
+
},
|
|
1713
|
+
[EXT_SETUP_TOOL]: {
|
|
1714
|
+
title: 'Set up Hermoso',
|
|
1715
|
+
description: "Open the setup flow: check or draft the profile from a website or a description, see every social channel and ad platform with whether it is connected and open its sign-in page in the browser, then start a first ad. Opening it changes nothing; drafting the profile and connecting accounts happen only when the user clicks them. Use it when the user wants to set up Hermoso, onboard a brand or creator, or connect accounts.",
|
|
1716
|
+
inputSchema: { step: z.enum(['brand', 'connections']).optional().describe("start at 'brand' (default) or 'connections'") },
|
|
1717
|
+
annotations: { title: 'Set up Hermoso', ...READ_ONLY_HINTS },
|
|
1718
|
+
_meta: appMeta(),
|
|
1719
|
+
},
|
|
1720
|
+
[EXT_SETTINGS_READ_TOOL]: {
|
|
1721
|
+
title: 'Hermoso settings',
|
|
1722
|
+
description: "Read Hermoso's settings: which profile Hermoso works on, the language ads are written in, whether new videos get captions and a brand end card by default, and which connected channels new posts go to by default. Read-only.",
|
|
1723
|
+
inputSchema: {},
|
|
1724
|
+
outputSchema: {
|
|
1725
|
+
schema: z.object({ type: z.literal('object'), properties: z.record(z.string(), z.any()), required: z.array(z.string()).optional() }).describe('the settings and their types'),
|
|
1726
|
+
values: z.record(z.string(), z.any()).describe('the current value of every setting'),
|
|
1727
|
+
layout: z.array(z.any()).optional().describe('how the settings are grouped'),
|
|
1728
|
+
},
|
|
1729
|
+
annotations: { title: 'Hermoso settings', ...READ_ONLY_HINTS },
|
|
1730
|
+
},
|
|
1731
|
+
[EXT_SETTINGS_UPDATE_TOOL]: {
|
|
1732
|
+
title: 'Change Hermoso settings',
|
|
1733
|
+
description: "Change Hermoso's settings. `set` holds only what changes, keyed as read_hermoso_settings names them: brand (a profile name from its list; switches the profile every Hermoso tool works on), language (applies to every ad from now on), captions and endCard (true or false, the defaults for new videos on this profile), and channel_<channel> (true or false) for each connected channel. Overwrites the values it is given.",
|
|
1734
|
+
inputSchema: { set: z.record(z.string().max(60), z.union([z.string().max(80), z.number(), z.boolean()])).describe('the settings to change, and their new values') },
|
|
1735
|
+
outputSchema: { values: z.record(z.string(), z.any()).describe('every setting after the change') },
|
|
1736
|
+
annotations: { title: 'Change Hermoso settings', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
1737
|
+
},
|
|
1738
|
+
[EXT_MENTIONS_TOOL]: {
|
|
1739
|
+
title: 'Find Hermoso items',
|
|
1740
|
+
description: 'Typeahead for @-mentions in the ChatGPT composer: finds profiles, saved creators, Library images and videos, saved swipefile ads and scheduled posts by name. Called by ChatGPT, never by the model. Read-only.',
|
|
1741
|
+
inputSchema: { query: z.string().max(80).describe('what the user typed after @, may be empty') },
|
|
1742
|
+
annotations: { title: 'Find Hermoso items', ...READ_ONLY_HINTS },
|
|
1743
|
+
_meta: { 'openai/extensions': { 'mentions/search': {} }, ...APP_ONLY },
|
|
1744
|
+
},
|
|
1745
|
+
[EXT_RESCHEDULE_TOOL]: {
|
|
1746
|
+
title: 'Move a scheduled post',
|
|
1747
|
+
description: 'Moves one queued post to a new time, from the Calendar after the user confirmed the new time on screen. App-only. Same rules as reschedule_post: only a post that is still queued can move, and the new time must be in the future.',
|
|
1748
|
+
inputSchema: { id: ext.describe('the scheduled post id'), at: z.string().max(40).describe('the new time, ISO 8601') },
|
|
1749
|
+
annotations: { title: 'Move a scheduled post', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
1750
|
+
_meta: { ...APP_ONLY },
|
|
1751
|
+
},
|
|
1752
|
+
[EXT_CANCEL_TOOL]: {
|
|
1753
|
+
title: 'Cancel a scheduled post',
|
|
1754
|
+
description: 'Cancels one queued post so it is never published, from the Calendar after the user confirmed on screen. App-only. It cannot be undone, and a post that already went out cannot be unsent.',
|
|
1755
|
+
inputSchema: { id: ext.describe('the scheduled post id') },
|
|
1756
|
+
annotations: { title: 'Cancel a scheduled post', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
1757
|
+
_meta: { ...APP_ONLY },
|
|
1758
|
+
},
|
|
1759
|
+
[EXT_DRAFT_TOOL]: {
|
|
1760
|
+
title: 'Draft the profile in setup',
|
|
1761
|
+
description: 'Drafts the profile from a website or a description during setup, after the user asked for it on screen. App-only. It saves the draft onto the active profile when none is saved, and replaces a saved profile only when the user confirmed a replace. Free.',
|
|
1762
|
+
inputSchema: {
|
|
1763
|
+
domain: z.string().max(200).optional().describe('the brand website'),
|
|
1764
|
+
description: z.string().max(2000).optional().describe('a description of the brand, when there is no website'),
|
|
1765
|
+
replace: z.boolean().optional().describe('replace the saved profile (the user confirmed)'),
|
|
1766
|
+
},
|
|
1767
|
+
annotations: { title: 'Draft the profile in setup', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
1768
|
+
_meta: { ...APP_ONLY },
|
|
1769
|
+
},
|
|
1770
|
+
[EXT_RENDER_TOOL]: {
|
|
1771
|
+
title: 'Render the picked concept',
|
|
1772
|
+
description: "Renders the ONE ad concept the user picked on a concept card, after they confirmed the format, size and length on screen. App-only. It writes the full ad for that concept with plan_ad, then starts the render (render_ad for a video, generate_image for an image) and returns the render's job id at once; the card shows its progress. Spends credits on the user's Hermoso account like any render.",
|
|
1773
|
+
inputSchema: {
|
|
1774
|
+
product: z.string().max(600).optional().describe('what the ad is for, as the concept card holds it'),
|
|
1775
|
+
concept: z.object({
|
|
1776
|
+
title: z.string().max(120).optional(), hook: z.string().max(80).optional(), hookId: z.string().max(60).optional(),
|
|
1777
|
+
headline: z.string().max(200).optional(), line: z.string().max(300).optional(), visual: z.string().max(600).optional(),
|
|
1778
|
+
}).describe('the concept the user picked'),
|
|
1779
|
+
format: z.enum(['image', 'video']).describe('the format the user confirmed'),
|
|
1780
|
+
aspectRatio: z.enum(['9:16', '1:1', '4:5', '16:9']).describe('the size the user confirmed'),
|
|
1781
|
+
durationSeconds: z.number().int().min(4).max(180).optional().describe('video length the user confirmed'),
|
|
1782
|
+
productImage: z.string().max(600).optional().describe('the product photo URL the user picked'),
|
|
1783
|
+
creator: z.string().max(120).optional().describe('the saved creator id or name the user picked (video)'),
|
|
1784
|
+
reference: z.string().max(600).optional().describe('the reference URL the user picked'),
|
|
1785
|
+
captions: z.boolean().optional().describe('burn captions into a video (default off)'),
|
|
1786
|
+
endCard: z.boolean().optional().describe('add the brand end card to a video (default off)'),
|
|
1787
|
+
},
|
|
1788
|
+
annotations: { title: 'Render the picked concept', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1789
|
+
_meta: { ...APP_ONLY },
|
|
1790
|
+
},
|
|
1791
|
+
[EXT_SCHEDULE_TOOL]: {
|
|
1792
|
+
title: 'Schedule a post from the app',
|
|
1793
|
+
description: 'Schedules one finished image or video to the connected channels the user picked, at the time they picked, after they confirmed on screen. App-only. Same rules as schedule_post (per-channel limits, media kinds, a future time); its refusal comes back word for word. The post appears in the Calendar, where it can be moved or cancelled.',
|
|
1794
|
+
inputSchema: {
|
|
1795
|
+
channels: z.array(z.enum([...ALL_CHANNELS])).min(1).max(11).describe('the channels to post to'),
|
|
1796
|
+
at: z.string().max(40).describe('when to post, ISO 8601, in the future'),
|
|
1797
|
+
message: z.string().max(5000).optional().describe('the caption'),
|
|
1798
|
+
imageUrl: z.string().max(1000).optional().describe('the finished image (a Hermoso URL)'),
|
|
1799
|
+
videoUrl: z.string().max(1000).optional().describe('the finished video (a Hermoso URL)'),
|
|
1800
|
+
},
|
|
1801
|
+
annotations: { title: 'Schedule a post from the app', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
1802
|
+
_meta: { ...APP_ONLY },
|
|
1803
|
+
},
|
|
1804
|
+
[EXT_ADS_STATUS_TOOL]: {
|
|
1805
|
+
title: 'Pause or turn on a campaign',
|
|
1806
|
+
description: 'Pauses an ad campaign, or turns one on, from the Ads view after the user confirmed that exact change on screen (turning one on starts spending on that platform). App-only. Runs the platform\'s own status tool (set_meta_campaign_status, set_google_ads_status and so on) and answers with what the platform stored.',
|
|
1807
|
+
inputSchema: {
|
|
1808
|
+
platform: z.enum(Object.keys(ADS_PLATFORMS)).describe('the ad platform'),
|
|
1809
|
+
accountId: z.string().max(80).optional().describe('the ad account the campaign is on'),
|
|
1810
|
+
campaignId: z.string().max(80).describe('the campaign id'),
|
|
1811
|
+
status: z.enum(['active', 'paused']).describe("'paused' stops it; 'active' turns it on and starts spending"),
|
|
1812
|
+
},
|
|
1813
|
+
annotations: { title: 'Pause or turn on a campaign', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1814
|
+
_meta: { ...APP_ONLY },
|
|
1815
|
+
},
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// Registers the app resource, the mention resources, the settings capability and every extension tool. `deps`:
|
|
1820
|
+
// resourceMeta(description, origins) and origins (the widget CSP builder and allowlist tools.mjs already uses),
|
|
1821
|
+
// proxyBase (our /api/img), abs (absolute asset URLs), call(name, args, extra), which runs an EXISTING tool's handler
|
|
1822
|
+
// for this session, and prefs {read, write} for the per-brand defaults this app adds.
|
|
1823
|
+
export function registerLibraryApp(server, deps) {
|
|
1824
|
+
const { resourceMeta, origins = [], proxyBase = '', abs = (u) => u, call = async () => null, prefs = null } = deps || {};
|
|
1825
|
+
const meta = libraryResourceMeta(resourceMeta, origins);
|
|
1826
|
+
const description = meta['openai/widgetDescription'];
|
|
1827
|
+
server.registerResource('hermoso-library-app', LIBRARY_APP_URI, { description, mimeType: LIBRARY_APP_MIME, _meta: meta },
|
|
1828
|
+
async () => ({ contents: [{ uri: LIBRARY_APP_URI, mimeType: LIBRARY_APP_MIME, text: LIBRARY_APP_HTML, _meta: meta }] }));
|
|
1829
|
+
// Any earlier build's hash resolves to the current app (unlisted), the lesson the three cards learned on 2026-08-24.
|
|
1830
|
+
server.registerResource('hermoso-library-app-any-hash', new ResourceTemplate(LIBRARY_ANY_HASH, { list: undefined }),
|
|
1831
|
+
{ description, mimeType: LIBRARY_APP_MIME, _meta: meta },
|
|
1832
|
+
async (uri) => ({ contents: [{ uri: String(uri), mimeType: LIBRARY_APP_MIME, text: LIBRARY_APP_HTML, _meta: meta }] }));
|
|
1833
|
+
|
|
1834
|
+
// THE SETTINGS CAPABILITY. On the initialize revisions we speak (2025-11-25), the spec reads it from `extensions` or
|
|
1835
|
+
// the legacy `experimental`; both are declared. Before connect, and only here, so a flag-off server is unchanged.
|
|
1836
|
+
try {
|
|
1837
|
+
const low = server.server;
|
|
1838
|
+
if (low && typeof low.registerCapabilities === 'function') low.registerCapabilities({ experimental: { 'openai/settings': { ...EXT_SETTINGS_CAPABILITY } }, extensions: { 'openai/settings': { ...EXT_SETTINGS_CAPABILITY } } });
|
|
1839
|
+
} catch {}
|
|
1840
|
+
|
|
1841
|
+
const payloadOpts = { origins, proxyBase, abs };
|
|
1842
|
+
const picture = (u) => { try { return origins.includes(new URL(u).origin) ? u : (proxyBase ? proxyBase + encodeURIComponent(u) : ''); } catch { return ''; } };
|
|
1843
|
+
const defs = libraryToolDefs();
|
|
1844
|
+
const answer = async (args, extra, sections, view, more = {}) => {
|
|
1845
|
+
const reads = await readSections(sections, call, extra, { prefs });
|
|
1846
|
+
const payload = { ...libraryPayload(reads, { view, ...payloadOpts }), surface: more.surface || 'panel', ...(more.payload || {}) };
|
|
1847
|
+
return { content: [{ type: 'text', text: libraryText(payload) }], structuredContent: payload };
|
|
1848
|
+
};
|
|
1849
|
+
const fail = (text) => ({ content: [{ type: 'text', text }], isError: true });
|
|
1850
|
+
const handles = {};
|
|
1851
|
+
const reg = (name, fn) => { handles[name] = server.registerTool(name, defs[name], fn); };
|
|
1852
|
+
|
|
1853
|
+
reg(EXT_HOME_TOOL, async (args, extra) => answer(args, extra, ['brand', 'brands', 'library', 'jobs', 'prefs'], 'home'));
|
|
1854
|
+
reg(EXT_ENTRY_TOOL, async (args, extra) => answer(args, extra, ['library', 'jobs', 'brands', 'calendar'], PANEL_VIEWS.includes(args?.view) ? args.view : 'library'));
|
|
1855
|
+
reg(EXT_BRIEF_TOOL, async (args, extra) => answer(args, extra, ['brand', 'brands', 'library', 'creators', 'products', 'prefs'], 'create', { surface: 'card', payload: { brief: { format: args?.format === 'video' ? 'video' : args?.format === 'image' ? 'image' : '', product: clip(args?.product, 600) } } }));
|
|
1856
|
+
reg(EXT_SETUP_TOOL, async (args, extra) => answer(args, extra, ['brand', 'brands', 'connectors'], 'setup', { surface: 'card', payload: { step: args?.step === 'connections' ? 'connections' : 'brand' } }));
|
|
1857
|
+
|
|
1858
|
+
reg(EXT_DATA_TOOL, async (args, extra) => {
|
|
1859
|
+
const asked = Array.isArray(args?.sections) ? [...new Set(args.sections)].filter((s) => EXT_SECTIONS.includes(s)) : [];
|
|
1860
|
+
const jobIds = (Array.isArray(args?.jobIds) ? args.jobIds : []).map(String).slice(0, 12);
|
|
1861
|
+
const reads = await readSections(asked, call, extra, { prefs });
|
|
1862
|
+
if (jobIds.length) reads.results = await readResults(jobIds, [], call, extra);
|
|
1863
|
+
if (args?.postId) reads.post = await callRead(call, 'list_scheduled', { id: String(args.postId) }, extra);
|
|
1864
|
+
const v = EXT_VIEWS.includes(args?.view) ? args.view : 'library';
|
|
1865
|
+
const payload = libraryPayload(reads, { view: v, ...payloadOpts });
|
|
1866
|
+
return { content: [{ type: 'text', text: libraryText(payload) }], structuredContent: payload };
|
|
1867
|
+
});
|
|
1868
|
+
|
|
1869
|
+
reg(EXT_CONCEPTS_TOOL, async (args, extra) => {
|
|
1870
|
+
const product = clip(args?.product, 600);
|
|
1871
|
+
let concepts = normalizeConcepts(args?.concepts);
|
|
1872
|
+
let note = '';
|
|
1873
|
+
if (!concepts.length) {
|
|
1874
|
+
if (!product) return fail('Pass `product` (what the ad is for), or the `concepts` you already have.');
|
|
1875
|
+
const n = Math.max(2, Math.min(8, Math.round(+args?.count || 4)));
|
|
1876
|
+
const r = await callRead(call, 'plan_variations', { product, count: n, ...(args?.language ? { language: String(args.language) } : {}) }, extra);
|
|
1877
|
+
if (r.error) note = neutralNote(r.error);
|
|
1878
|
+
else { concepts = normalizeConcepts(r.data?.variants || r.data?.angles || []); if (!concepts.length) note = 'No concepts came back. Try again with a little more about the product.'; }
|
|
1879
|
+
}
|
|
1880
|
+
const reads = await readSections(['prefs', 'brands'], call, extra, { prefs });
|
|
1881
|
+
const base = libraryPayload(reads, { view: 'concepts', ...payloadOpts });
|
|
1882
|
+
const safeUrl = (u) => (/^https:\/\//.test(String(u || '')) ? String(u).slice(0, 600) : '');
|
|
1883
|
+
const payload = {
|
|
1884
|
+
...base, surface: 'card', product, concepts,
|
|
1885
|
+
format: args?.format === 'video' ? 'video' : args?.format === 'image' ? 'image' : '',
|
|
1886
|
+
aspectRatio: args?.aspectRatio || '', durationSeconds: Number.isFinite(+args?.durationSeconds) ? Math.round(+args.durationSeconds) : 0,
|
|
1887
|
+
productImage: safeUrl(args?.productImage), creator: clip(args?.creator, 120), reference: safeUrl(args?.reference),
|
|
1888
|
+
...(note ? { note } : {}),
|
|
1889
|
+
};
|
|
1890
|
+
return { content: [{ type: 'text', text: conceptsText(payload) }], structuredContent: payload };
|
|
1891
|
+
});
|
|
1892
|
+
|
|
1893
|
+
reg(EXT_RESULTS_TOOL, async (args, extra) => {
|
|
1894
|
+
const jobIds = [...new Set((Array.isArray(args?.jobIds) ? args.jobIds : []).map(String).filter(Boolean))].slice(0, 12);
|
|
1895
|
+
const urls = [...new Set((Array.isArray(args?.urls) ? args.urls : []).map(String).filter((u) => /^https?:\/\/|^\/generated\//.test(u)))].slice(0, 12);
|
|
1896
|
+
if (!jobIds.length && !urls.length) return fail('Pass `jobIds` (from the renders) or `urls` (finished media) to show.');
|
|
1897
|
+
const reads = await readSections(['prefs'], call, extra, { prefs });
|
|
1898
|
+
reads.results = await readResults(jobIds, urls, call, extra);
|
|
1899
|
+
const payload = { ...libraryPayload(reads, { view: 'results', ...payloadOpts }), surface: 'card', title: clip(args?.title, 80) };
|
|
1900
|
+
const done = payload.results.filter((r) => r.status === 'done').length;
|
|
1901
|
+
const live = payload.results.filter((r) => r.status === 'queued' || r.status === 'running').length;
|
|
1902
|
+
const said = payload.results.filter((r) => r.notices?.length).map((r) => noticesText(r.notices, `The app shows the user this note about ${r.id ? `job ${r.id}` : 'one of them'}`));
|
|
1903
|
+
return { content: [{ type: 'text', text: `Showing ${payload.results.length} ad${payload.results.length === 1 ? '' : 's'} as a gallery: ${done} ready, ${live} still rendering (they update in place). The user can add one to the chat or ask to post or schedule it. Nothing new was rendered.${said.length ? ` ${said.join(' ')}` : ''}` }], structuredContent: payload };
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
// ── settings ──
|
|
1907
|
+
const settingsState = async (extra) => {
|
|
1908
|
+
const [brandsR, settingsR, connR] = await Promise.all([callRead(call, 'list_brands', {}, extra), callRead(call, 'get_settings', {}, extra), callRead(call, 'list_connectors', {}, extra)]);
|
|
1909
|
+
if (settingsR.error) throw new Error(neutralNote(settingsR.error));
|
|
1910
|
+
const pr = await readPrefs(prefs).catch(() => ({}));
|
|
1911
|
+
const brands = brandsR.error ? [] : (brandsR.data?.brands || []).filter((b) => b && b.id != null);
|
|
1912
|
+
const conns = connR.error ? [] : libraryPayload({ connectors: connR }, payloadOpts).connections || [];
|
|
1913
|
+
return { model: settingsModel({ brands, language: settingsR.data?.language || 'English', prefs: pr, connectors: conns }), prefs: pr };
|
|
1914
|
+
};
|
|
1915
|
+
reg(EXT_SETTINGS_READ_TOOL, async (args, extra) => {
|
|
1916
|
+
try {
|
|
1917
|
+
const { model } = await settingsState(extra);
|
|
1918
|
+
const { brandIdOf, ...sc } = model;
|
|
1919
|
+
return { content: [{ type: 'text', text: `Hermoso settings: ${Object.entries(sc.values).map(([k, v]) => `${k} ${v}`).join(', ')}.` }], structuredContent: sc };
|
|
1920
|
+
} catch (e) { return fail(`Could not read the settings: ${String(e?.message || e)}`); }
|
|
1921
|
+
});
|
|
1922
|
+
reg(EXT_SETTINGS_UPDATE_TOOL, async (args, extra) => {
|
|
1923
|
+
const set = args?.set && typeof args.set === 'object' ? args.set : {};
|
|
1924
|
+
if (!Object.keys(set).length) return fail('Pass at least one setting in `set`.');
|
|
1925
|
+
const errs = [];
|
|
1926
|
+
try {
|
|
1927
|
+
let st = await settingsState(extra);
|
|
1928
|
+
if ('brand' in set) {
|
|
1929
|
+
const id = st.model.brandIdOf(String(set.brand));
|
|
1930
|
+
if (!id) errs.push(`No profile called "${set.brand}".`);
|
|
1931
|
+
else { const r = await callRead(call, 'use_brand', { brand: id }, extra); if (r.error) errs.push(neutralNote(r.error)); else st = await settingsState(extra); }
|
|
1932
|
+
}
|
|
1933
|
+
if ('language' in set) {
|
|
1934
|
+
const lang = String(set.language || '').trim();
|
|
1935
|
+
if (!lang) errs.push('language cannot be empty.');
|
|
1936
|
+
else { const r = await callRead(call, 'update_settings', { language: lang }, extra); if (r.error) errs.push(neutralNote(r.error)); }
|
|
1937
|
+
}
|
|
1938
|
+
const next = { ...st.prefs }; let touched = false;
|
|
1939
|
+
for (const [k, v] of Object.entries(set)) {
|
|
1940
|
+
if (k === 'brand' || k === 'language') continue;
|
|
1941
|
+
if (k === 'captions' || k === 'endCard') { next[k] = v === true || v === 'true'; touched = true; continue; }
|
|
1942
|
+
const m = /^channel_([a-z_]+)$/.exec(k);
|
|
1943
|
+
if (m && ALL_CHANNELS.includes(m[1])) { const cur = new Set(Array.isArray(next.channels) ? next.channels : []); if (v === true || v === 'true') cur.add(m[1]); else cur.delete(m[1]); next.channels = [...cur].filter((c) => ALL_CHANNELS.includes(c)); touched = true; continue; }
|
|
1944
|
+
errs.push(`Unknown setting "${k}".`);
|
|
1945
|
+
}
|
|
1946
|
+
if (touched) {
|
|
1947
|
+
if (!prefs || typeof prefs.write !== 'function') errs.push('These defaults cannot be saved on this connection.');
|
|
1948
|
+
else { try { await prefs.write({ ...next, updatedAt: Date.now() }); } catch (e) { errs.push(`Could not save: ${neutralNote(e?.message || e)}`); } }
|
|
1949
|
+
}
|
|
1950
|
+
const after = await settingsState(extra);
|
|
1951
|
+
if (errs.length) return fail(`Some settings were not changed: ${errs.join(' ')}`);
|
|
1952
|
+
return { content: [{ type: 'text', text: `Settings saved: ${Object.keys(set).join(', ')}.` }], structuredContent: { values: after.model.values } };
|
|
1953
|
+
} catch (e) { return fail(`Could not change the settings: ${String(e?.message || e)}`); }
|
|
1954
|
+
});
|
|
1955
|
+
|
|
1956
|
+
// ── mentions: a short per-session cache, so a typeahead does not re-read five lists on every keystroke ──
|
|
1957
|
+
let poolCache = null;
|
|
1958
|
+
const mentionPoolRead = async (extra) => {
|
|
1959
|
+
if (poolCache && poolCache.exp > Date.now()) return poolCache.v;
|
|
1960
|
+
const [brands, creators, library, swipe, cal] = await Promise.all([
|
|
1961
|
+
callRead(call, 'list_brands', {}, extra), callRead(call, 'list_creators', { limit: 24 }, extra), callRead(call, 'list_library', { kind: 'all', limit: 40 }, extra),
|
|
1962
|
+
callRead(call, 'list_swipefile', { limit: 60 }, extra), callRead(call, 'list_scheduled', { upcoming: 25, fired: 0 }, extra),
|
|
1963
|
+
]);
|
|
1964
|
+
const firstErr = [brands, creators, library, swipe, cal].find((r) => r.error);
|
|
1965
|
+
if ([brands, creators, library, swipe, cal].every((r) => r.error)) throw new Error(neutralNote(firstErr.error));
|
|
1966
|
+
const v = mentionCandidates({ brands: brands.data?.brands, creators: creators.data?.creators, assets: library.data?.assets, ads: swipe.data?.ads, posts: cal.data?.scheduled }, { abs });
|
|
1967
|
+
poolCache = { v, exp: Date.now() + 30_000 };
|
|
1968
|
+
return v;
|
|
1969
|
+
};
|
|
1970
|
+
reg(EXT_MENTIONS_TOOL, async (args, extra) => {
|
|
1971
|
+
try {
|
|
1972
|
+
const items = mentionItems(await mentionPoolRead(extra), args?.query).map((it) => (it.icons ? { ...it, icons: it.icons.map((i) => ({ src: picture(i.src) })).filter((i) => i.src) } : it));
|
|
1973
|
+
return { content: [{ type: 'text', text: `${items.length} match${items.length === 1 ? '' : 'es'}.` }], structuredContent: { items } };
|
|
1974
|
+
} catch (e) { return fail(`Could not search: ${String(e?.message || e)}`); }
|
|
1975
|
+
});
|
|
1976
|
+
// What the model reads when the user mentions one: the same sentence the picker carried, fresh.
|
|
1977
|
+
server.registerResource('hermoso-mentions', new ResourceTemplate(MENTION_URI_TEMPLATE, { list: undefined }),
|
|
1978
|
+
{ description: 'A Hermoso item the user @-mentioned: a profile, saved creator, Library asset, swipefile ad or scheduled post.', mimeType: 'text/plain' },
|
|
1979
|
+
async (uri, vars, extra) => {
|
|
1980
|
+
const kind = String(vars?.kind || ''); const id = decodeURIComponent(String(vars?.id || ''));
|
|
1981
|
+
poolCache = null;
|
|
1982
|
+
let text = `No Hermoso ${kind} with id ${id} on this account.`;
|
|
1983
|
+
try { const hit = (await mentionPoolRead(extra)).find((c) => c.kind === kind && c.id === id); if (hit) text = hit.text; } catch (e) { text = `Could not read it: ${String(e?.message || e)}`; }
|
|
1984
|
+
return { contents: [{ uri: String(uri), mimeType: 'text/plain', text }] };
|
|
1985
|
+
});
|
|
1986
|
+
|
|
1987
|
+
// ── the three writes the app makes itself, each after a confirm on screen, each through the existing tool ──
|
|
1988
|
+
reg(EXT_RESCHEDULE_TOOL, async (args, extra) => {
|
|
1989
|
+
const t = Date.parse(String(args?.at || ''));
|
|
1990
|
+
if (!Number.isFinite(t)) return fail('`at` must be an ISO time, like 2026-10-09T09:00:00Z.');
|
|
1991
|
+
if (t < Date.now() + 30_000) return fail('Pick a time in the future.');
|
|
1992
|
+
const r = await call(EXT_DELEGATES[EXT_RESCHEDULE_TOOL], { id: String(args.id), at: new Date(t).toISOString() }, extra);
|
|
1993
|
+
return r || fail('Moving posts is not available on this connection.');
|
|
1994
|
+
});
|
|
1995
|
+
reg(EXT_CANCEL_TOOL, async (args, extra) => {
|
|
1996
|
+
const r = await call(EXT_DELEGATES[EXT_CANCEL_TOOL], { id: String(args?.id || '') }, extra);
|
|
1997
|
+
return r || fail('Cancelling posts is not available on this connection.');
|
|
1998
|
+
});
|
|
1999
|
+
reg(EXT_DRAFT_TOOL, async (args, extra) => {
|
|
2000
|
+
const domain = String(args?.domain || '').trim(); const description = String(args?.description || '').trim();
|
|
2001
|
+
if (!domain && !description) return fail('Pass a website (`domain`) or a `description`.');
|
|
2002
|
+
const r = await call(EXT_DELEGATES[EXT_DRAFT_TOOL], { ...(domain ? { domain } : { description }), ...(args?.replace === true ? { save: true } : {}) }, extra);
|
|
2003
|
+
if (!r) return fail('Drafting a profile is not available on this connection.');
|
|
2004
|
+
if (r.isError) return { ...r, content: [{ type: 'text', text: neutralNote(textOfResult(r)) }] };
|
|
2005
|
+
const p = r.structuredContent || {};
|
|
2006
|
+
const saved = /Saved as the workspace brand/i.test(textOfResult(r));
|
|
2007
|
+
return { content: [{ type: 'text', text: textOfResult(r) }], structuredContent: { saved, brand: { name: p.name || '', domain: p.domain || '', category: p.category || '', summary: clip(p.summary, 240), logo: p.logo ? picture(abs(p.logo)) : '' } } };
|
|
2008
|
+
});
|
|
2009
|
+
|
|
2010
|
+
// ── RENDER IT: the concept the user picked, at the format they confirmed, through plan_ad then render_ad /
|
|
2011
|
+
// generate_image. `noWait` asks the render tool for its job handle the moment the job is queued (the same `waitMs:0`
|
|
2012
|
+
// the /v1 `?wait=0` path uses), so the card gets a job id fast and polls it; the job itself goes through the
|
|
2013
|
+
// ordinary queue (spend gate, balance check, reserve and settle) exactly as a render the model starts.
|
|
2014
|
+
reg(EXT_RENDER_TOOL, async (args, extra) => {
|
|
2015
|
+
const c = args?.concept && typeof args.concept === 'object' ? args.concept : {};
|
|
2016
|
+
const format = args?.format === 'video' ? 'video' : 'image';
|
|
2017
|
+
const product = clip(args?.product, 600);
|
|
2018
|
+
if (!c.title && !c.headline && !c.visual) return fail('Pass the `concept` the user picked.');
|
|
2019
|
+
const reference = /^https:\/\//.test(String(args?.reference || '')) ? String(args.reference) : '';
|
|
2020
|
+
const watchable = /\.(mp4|webm|mov|m4v)([?#]|$)|facebook\.com\/ads\/library|linkedin\.com\/ad-library|adstransparency\.google|tiktok\.com\/|instagram\.com\/(reel|p)\/|youtube\.com\/|youtu\.be\/|(^|\/\/)(www\.)?(x|twitter)\.com\//i.test(reference);
|
|
2021
|
+
const brief = [product || 'my brand', 'Make exactly this concept, which the user picked:',
|
|
2022
|
+
c.title && `Angle: ${clip(c.title, 120)}`, c.hook && `Hook: ${clip(c.hook, 80)}`, c.headline && `Headline: ${clip(c.headline, 200)}`,
|
|
2023
|
+
c.line && `Supporting line: ${clip(c.line, 300)}`, c.visual && `What it shows: ${clip(c.visual, 600)}`,
|
|
2024
|
+
reference && !watchable && `Reference the user picked: ${reference}`].filter(Boolean).join('\n');
|
|
2025
|
+
const len = Number.isFinite(+args?.durationSeconds) && +args.durationSeconds > 0 ? Math.round(+args.durationSeconds) : 0;
|
|
2026
|
+
const [planTool, renderTool, imageTool] = EXT_DELEGATES[EXT_RENDER_TOOL];
|
|
2027
|
+
const plan = await call(planTool, { product: brief, format, ...(format === 'video' && len ? { durationSeconds: len } : {}), ...(c.hookId ? { hook: String(c.hookId) } : {}), ...(watchable ? { reference } : {}) }, extra);
|
|
2028
|
+
if (!plan) return fail('Rendering is not available on this connection.');
|
|
2029
|
+
if (plan.isError) return fail(friendly(textOfResult(plan)));
|
|
2030
|
+
const creative = plan.structuredContent || {};
|
|
2031
|
+
const productImage = /^https:\/\//.test(String(args?.productImage || '')) ? String(args.productImage) : '';
|
|
2032
|
+
let r;
|
|
2033
|
+
if (format === 'video') {
|
|
2034
|
+
if (!creative.video_storyboard) return fail('The concept came back without a video script, so nothing was rendered. Try again, or ask in the chat.');
|
|
2035
|
+
// The product photo the user picked becomes the packshot the render binds (the brand embedded in the plan).
|
|
2036
|
+
const cr = productImage && creative.brand && typeof creative.brand === 'object' && creative.brand.name ? { ...creative, brand: { ...creative.brand, product: productImage, productImages: [productImage] } } : creative;
|
|
2037
|
+
r = await call(renderTool, { creative: cr, aspectRatio: args.aspectRatio, ...(len ? { durationSeconds: len } : {}), ...(args?.creator ? { creator: String(args.creator) } : {}), captions: args?.captions === true, endCard: args?.endCard === true }, extra, { noWait: true });
|
|
2038
|
+
} else {
|
|
2039
|
+
const prompt = creative.image_concept?.prompt;
|
|
2040
|
+
if (!prompt) return fail('The concept came back without an image to make, so nothing was rendered. Try again, or ask in the chat.');
|
|
2041
|
+
r = await call(imageTool, { prompt: String(prompt), aspectRatio: args.aspectRatio, ...(productImage ? { refImages: [productImage] } : {}) }, extra, { noWait: true });
|
|
2042
|
+
}
|
|
2043
|
+
if (!r) return fail('Rendering is not available on this connection.');
|
|
2044
|
+
if (r.isError) return fail(friendly(textOfResult(r)));
|
|
2045
|
+
const sc = r.structuredContent || {};
|
|
2046
|
+
if (sc.needsProductPhoto) return fail('Nothing was rendered: Hermoso has no photo of this product yet, so the packaging would be made up. Add a product photo in Hermoso, or ask in the chat to go ahead without one.');
|
|
2047
|
+
const jobId = String(sc.jobId || '');
|
|
2048
|
+
const url = sc.url || sc.image || '';
|
|
2049
|
+
if (!jobId && !url) return fail(friendly(textOfResult(r)) || 'The render did not start. Try again, or ask in the chat.');
|
|
2050
|
+
// What the render says to the person (a person's photo left out as the product, a label or product finding) rides
|
|
2051
|
+
// the reply: the card shows it under the render, and the text tells the model the same thing.
|
|
2052
|
+
const notices = renderNotices(sc);
|
|
2053
|
+
return { content: [{ type: 'text', text: `Rendering the picked concept (${format}, ${args.aspectRatio}${format === 'video' && len ? `, ${len}s` : ''})${jobId ? ` as job ${jobId}` : ''}. It shows in the app when it is done.${notices.length ? ` ${noticesText(notices)}` : ''}` }], structuredContent: { jobId, format, status: url ? 'done' : 'queued', ...(url ? { url: abs(String(url)) } : {}), ...(notices.length ? { notices } : {}) } };
|
|
2054
|
+
});
|
|
2055
|
+
|
|
2056
|
+
// ── SCHEDULE: one finished result to the channels the user picked, through schedule_post ──
|
|
2057
|
+
reg(EXT_SCHEDULE_TOOL, async (args, extra) => {
|
|
2058
|
+
const channels = [...new Set((Array.isArray(args?.channels) ? args.channels : []).filter((c) => ALL_CHANNELS.includes(c)))];
|
|
2059
|
+
if (!channels.length) return fail('Pick at least one channel.');
|
|
2060
|
+
const t = Date.parse(String(args?.at || ''));
|
|
2061
|
+
if (!Number.isFinite(t)) return fail('`at` must be an ISO time, like 2026-10-09T09:00:00Z.');
|
|
2062
|
+
if (t < Date.now() + 60_000) return fail('Pick a time in the future.');
|
|
2063
|
+
const media = (u) => (/^https:\/\/|^\/generated\//.test(String(u || '')) ? String(u) : '');
|
|
2064
|
+
const imageUrl = media(args?.imageUrl), videoUrl = media(args?.videoUrl);
|
|
2065
|
+
if (!imageUrl && !videoUrl) return fail('Pass the finished image or video to post.');
|
|
2066
|
+
const r = await call(EXT_DELEGATES[EXT_SCHEDULE_TOOL], { channels, at: new Date(t).toISOString(), ...(args?.message ? { message: String(args.message) } : {}), ...(videoUrl ? { videoUrl } : { imageUrl }) }, extra);
|
|
2067
|
+
if (!r) return fail('Scheduling is not available on this connection.');
|
|
2068
|
+
if (r.isError) return fail(friendly(textOfResult(r)));
|
|
2069
|
+
return r;
|
|
2070
|
+
});
|
|
2071
|
+
|
|
2072
|
+
// ── ADS: pause or turn on one campaign through that platform's own status tool ──
|
|
2073
|
+
reg(EXT_ADS_STATUS_TOOL, async (args, extra) => {
|
|
2074
|
+
const P = ADS_PLATFORMS[args?.platform];
|
|
2075
|
+
if (!P) return fail('Unknown ad platform.');
|
|
2076
|
+
const id = String(args?.campaignId || '').trim();
|
|
2077
|
+
if (!id) return fail('Pass the `campaignId`.');
|
|
2078
|
+
if (args?.status !== 'active' && args?.status !== 'paused') return fail("`status` is 'active' or 'paused'.");
|
|
2079
|
+
if (P.accounts && !args?.accountId) return fail('Pass the `accountId` the campaign is on.');
|
|
2080
|
+
const r = await call(P.status.tool, P.status.args({ id: String(args?.accountId || '') }, id, args.status === 'active'), extra);
|
|
2081
|
+
if (!r) return fail('Changing campaigns is not available on this connection.');
|
|
2082
|
+
if (r.isError) return fail(friendly(textOfResult(r)));
|
|
2083
|
+
return r;
|
|
2084
|
+
});
|
|
2085
|
+
return handles;
|
|
2086
|
+
}
|
|
2087
|
+
// A refusal shown in the app: a lack of credits is the one neutral sentence (no buy step), everything else is the
|
|
2088
|
+
// existing tool's own words, long enough to keep a per-channel reason whole.
|
|
2089
|
+
const friendly = (t) => { const n = neutralNote(t); return n === NEUTRAL_CREDIT_NOTE ? n : String(t ?? '').replace(/^Error:\s*/, '').trim().slice(0, 800); };
|
|
2090
|
+
|
|
2091
|
+
// SDK 1.29's McpServer drops `icons` from tools/list (it emits name/title/description/schemas/annotations/_meta),
|
|
2092
|
+
// so the entrypoint icon is added on the way out. Installed only when the app is registered, so a flag-off roster is
|
|
2093
|
+
// untouched. Returns false when there is no request-handler map to wrap (a stub server in a check).
|
|
2094
|
+
export function installEntrypointIcons(mcp, names = [EXT_HOME_TOOL, EXT_ENTRY_TOOL], icon = ENTRY_ICON) {
|
|
2095
|
+
try {
|
|
2096
|
+
const map = mcp && mcp.server && mcp.server._requestHandlers;
|
|
2097
|
+
if (!map || typeof map.get !== 'function') return false;
|
|
2098
|
+
const orig = map.get('tools/list');
|
|
2099
|
+
if (typeof orig !== 'function' || orig._hermosoIcons) return false;
|
|
2100
|
+
const want = new Set(names);
|
|
2101
|
+
const wrapped = async (request, extra) => {
|
|
2102
|
+
const r = await orig(request, extra);
|
|
2103
|
+
for (const t of (r && Array.isArray(r.tools) ? r.tools : [])) if (t && want.has(t.name) && !t.icons) t.icons = [{ ...icon }];
|
|
2104
|
+
return r;
|
|
2105
|
+
};
|
|
2106
|
+
wrapped._hermosoIcons = true;
|
|
2107
|
+
map.set('tools/list', wrapped);
|
|
2108
|
+
return true;
|
|
2109
|
+
} catch { return false; }
|
|
2110
|
+
}
|