openclacky 1.3.1 → 1.3.3
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -0
- data/Dockerfile +3 -0
- data/README.md +1 -1
- data/README_JA.md +237 -0
- data/lib/clacky/agent/session_serializer.rb +65 -11
- data/lib/clacky/agent/time_machine.rb +247 -26
- data/lib/clacky/agent.rb +12 -1
- data/lib/clacky/agent_config.rb +14 -2
- data/lib/clacky/brand_config.rb +1 -1
- data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
- data/lib/clacky/default_agents/coding/profile.yml +3 -0
- data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
- data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
- data/lib/clacky/default_skills/media-gen/SKILL.md +30 -6
- data/lib/clacky/media/openai_compat.rb +64 -1
- data/lib/clacky/media/output_dir.rb +43 -0
- data/lib/clacky/message_history.rb +9 -0
- data/lib/clacky/server/channel/channel_manager.rb +26 -0
- data/lib/clacky/server/git_panel.rb +115 -0
- data/lib/clacky/server/http_server.rb +521 -13
- data/lib/clacky/server/server_master.rb +6 -4
- data/lib/clacky/utils/environment_detector.rb +16 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +512 -60
- data/lib/clacky/web/app.js +30 -7
- data/lib/clacky/web/components/code-editor.js +197 -0
- data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
- data/lib/clacky/web/core/aside.js +112 -0
- data/lib/clacky/web/core/ext.js +387 -0
- data/lib/clacky/web/features/backup/store.js +92 -0
- data/lib/clacky/web/features/backup/view.js +94 -0
- data/lib/clacky/web/features/billing/store.js +163 -0
- data/lib/clacky/web/{billing.js → features/billing/view.js} +134 -242
- data/lib/clacky/web/features/brand/store.js +110 -0
- data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
- data/lib/clacky/web/features/channels/store.js +103 -0
- data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
- data/lib/clacky/web/features/creator/store.js +81 -0
- data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
- data/lib/clacky/web/features/mcp/store.js +158 -0
- data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
- data/lib/clacky/web/features/model-tester/store.js +77 -0
- data/lib/clacky/web/features/model-tester/view.js +7 -0
- data/lib/clacky/web/features/profile/store.js +170 -0
- data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
- data/lib/clacky/web/features/share/store.js +145 -0
- data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
- data/lib/clacky/web/features/skills/store.js +303 -0
- data/lib/clacky/web/features/skills/view.js +550 -0
- data/lib/clacky/web/features/tasks/store.js +135 -0
- data/lib/clacky/web/features/tasks/view.js +241 -0
- data/lib/clacky/web/features/trash/store.js +242 -0
- data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
- data/lib/clacky/web/features/version/store.js +165 -0
- data/lib/clacky/web/features/version/view.js +323 -0
- data/lib/clacky/web/features/workspace/store.js +99 -0
- data/lib/clacky/web/features/workspace/view.js +305 -0
- data/lib/clacky/web/i18n.js +60 -6
- data/lib/clacky/web/index.html +117 -57
- data/lib/clacky/web/sessions.js +221 -25
- data/lib/clacky/web/settings.js +121 -25
- data/lib/clacky/web/skills.js +3 -821
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
- data/lib/clacky.rb +1 -0
- metadata +45 -20
- data/lib/clacky/web/backup.js +0 -119
- data/lib/clacky/web/model-tester.js +0 -66
- data/lib/clacky/web/tasks.js +0 -365
- data/lib/clacky/web/version.js +0 -449
- data/lib/clacky/web/workspace.js +0 -212
- /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
- /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
- /data/lib/clacky/web/{onboard.js → components/onboard.js} +0 -0
- /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
- /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
|
@@ -1,31 +1,21 @@
|
|
|
1
|
-
//
|
|
1
|
+
// ── Share · view — themes, Canvas posters, copy, modal, public actions ────
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// then a 7-day cooldown after dismissal)
|
|
3
|
+
// Renders the share modal and the pure-frontend Canvas posters (product +
|
|
4
|
+
// scorecard), owns the copy/clipboard/theme/period UI, and exposes the public
|
|
5
|
+
// entry points (openModal / openScorecard / addScorecardPeriod / closeModal /
|
|
6
|
+
// maybePromptOnComplete / init) on the `Share` facade.
|
|
8
7
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// All data — brand identity, scorecard stats, frequency cap, telemetry —
|
|
9
|
+
// lives in ShareStore; the view reads it through Share.state and drives state
|
|
10
|
+
// changes through store actions.
|
|
12
11
|
//
|
|
13
|
-
// Depends on:
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const COOLDOWN_KEY = "clacky-share-cooldown-until";
|
|
21
|
-
const COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
|
|
22
|
-
const PROMPT_AT = [1, 5]; // success counts that trigger an auto-prompt
|
|
23
|
-
|
|
24
|
-
const THEME_KEY = "clacky-share-theme"; // remembers last picked poster style
|
|
25
|
-
const DRAFT_KEY = "clacky-share-draft"; // remembers last hand-edited text
|
|
26
|
-
|
|
27
|
-
// Poster themes: each is a palette the canvas renderers read from, so the
|
|
28
|
-
// layout stays shared and only colors change. Order = picker order.
|
|
12
|
+
// Depends on: ShareStore, qrcode, I18n, Modal.
|
|
13
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const ShareView = (() => {
|
|
16
|
+
const THEME_KEY = "clacky-share-theme";
|
|
17
|
+
const DRAFT_KEY = "clacky-share-draft";
|
|
18
|
+
|
|
29
19
|
const THEMES = {
|
|
30
20
|
geek: {
|
|
31
21
|
labelKey: "share.theme.geek", style: "geek",
|
|
@@ -79,50 +69,20 @@ const Share = (() => {
|
|
|
79
69
|
function _theme() { return THEMES[_themeId()]; }
|
|
80
70
|
function _setTheme(id) { if (THEMES[id]) localStorage.setItem(THEME_KEY, id); }
|
|
81
71
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
// scorecard poster / copy. Null in plain product-share mode.
|
|
88
|
-
// Shape: { periods: { day|week|month: {period,cacheHitRate,costStr,...} },
|
|
89
|
-
// defaultPeriod, heatmap: [{date,tokens}], ...flat fallback }
|
|
90
|
-
let _scorecard = null;
|
|
91
|
-
// Currently selected scorecard period key (day | week | month).
|
|
92
|
-
let _scorePeriod = null;
|
|
93
|
-
// Set while a scorecard modal is open; lets billing hot-swap in late-arriving
|
|
94
|
-
// period data and re-render the period switcher.
|
|
95
|
-
let _rerenderPeriods = null;
|
|
96
|
-
|
|
97
|
-
// ── Brand source (single source of truth) ─────────────────────────────
|
|
98
|
-
async function _hydrateBrand() {
|
|
99
|
-
try {
|
|
100
|
-
const res = await fetch("/api/brand/status");
|
|
101
|
-
if (!res.ok) return;
|
|
102
|
-
const data = await res.json();
|
|
103
|
-
if (data && data.branded) {
|
|
104
|
-
_brand = {
|
|
105
|
-
name: (data.product_name || "").trim() || DEFAULT_NAME,
|
|
106
|
-
// Branded builds must NEVER fall back to openclacky.com. If the brand
|
|
107
|
-
// has no homepage configured, we simply show no link / QR.
|
|
108
|
-
homepageUrl: (data.homepage_url || "").trim() || null,
|
|
109
|
-
logoUrl: (data.logo_url || "").trim() || null
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
} catch (_e) { /* keep defaults */ }
|
|
113
|
-
}
|
|
72
|
+
function _brandName() { return Share.state.brand.name; }
|
|
73
|
+
function _shareUrl() { return Share.state.shareUrl(); }
|
|
74
|
+
function _scorecard() { return Share.state.scorecard; }
|
|
75
|
+
function _scorePeriod() { return Share.state.scorePeriod; }
|
|
76
|
+
function _curStats() { return Share.state.curStats(); }
|
|
114
77
|
|
|
115
78
|
// ── Share copy (i18n + brand interpolation) ───────────────────────────
|
|
116
|
-
// Candidate variants per platform; the UI shuffles among them and lets the
|
|
117
|
-
// user hand-edit before posting. Product mode shares one generic pool
|
|
118
|
-
// (`share.copy.*`); scorecard mode has per-platform pools with live numbers.
|
|
119
79
|
function _candidatesFor(platform) {
|
|
120
|
-
if (_scorecard) {
|
|
80
|
+
if (_scorecard()) {
|
|
121
81
|
const list = I18n.tList("share.scorecard.copy." + platform, _scorecardVars());
|
|
122
82
|
return list.length ? list : [_scorecardCopy("copylink")];
|
|
123
83
|
}
|
|
124
|
-
const list = I18n.tList("share.copy", { brand:
|
|
125
|
-
return list.length ? list : [I18n.t("share.copy.1", { brand:
|
|
84
|
+
const list = I18n.tList("share.copy", { brand: _brandName() });
|
|
85
|
+
return list.length ? list : [I18n.t("share.copy.1", { brand: _brandName() })];
|
|
126
86
|
}
|
|
127
87
|
|
|
128
88
|
function _pickCopy(platform, exclude) {
|
|
@@ -138,24 +98,10 @@ const Share = (() => {
|
|
|
138
98
|
return pick;
|
|
139
99
|
}
|
|
140
100
|
|
|
141
|
-
function _shareUrl() {
|
|
142
|
-
return _brand.homepageUrl; // null when branded build has no homepage
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// ── Scorecard copy (per-platform, numbers from stats) ─────────────────
|
|
146
|
-
// Returns the stats object for the currently-selected period, falling back
|
|
147
|
-
// to the flat fields billing.js passes for back-compat.
|
|
148
|
-
function _curStats() {
|
|
149
|
-
if (_scorecard.periods && _scorePeriod && _scorecard.periods[_scorePeriod]) {
|
|
150
|
-
return _scorecard.periods[_scorePeriod];
|
|
151
|
-
}
|
|
152
|
-
return _scorecard;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
101
|
function _scorecardVars() {
|
|
156
102
|
const s = _curStats();
|
|
157
103
|
return {
|
|
158
|
-
brand:
|
|
104
|
+
brand: _brandName(),
|
|
159
105
|
period: s.period,
|
|
160
106
|
cacheHitRate: s.cacheHitRate,
|
|
161
107
|
cost: s.costStr,
|
|
@@ -168,35 +114,13 @@ const Share = (() => {
|
|
|
168
114
|
return I18n.t("share.scorecard.copy." + platform + ".1", _scorecardVars()).trim();
|
|
169
115
|
}
|
|
170
116
|
|
|
171
|
-
// Pick a dynamic golden line based on how strong the numbers are.
|
|
172
117
|
function _scorecardGoldenLine() {
|
|
173
118
|
const rate = parseFloat(_curStats().cacheHitRate) || 0;
|
|
174
119
|
const key = rate >= 90 ? "high" : rate >= 60 ? "mid" : "low";
|
|
175
120
|
return I18n.t("share.scorecard.golden." + key, _scorecardVars());
|
|
176
121
|
}
|
|
177
122
|
|
|
178
|
-
// ──
|
|
179
|
-
function _successCount() {
|
|
180
|
-
return parseInt(localStorage.getItem(COUNT_KEY) || "0", 10) || 0;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function _bumpSuccessCount() {
|
|
184
|
-
const n = _successCount() + 1;
|
|
185
|
-
localStorage.setItem(COUNT_KEY, String(n));
|
|
186
|
-
return n;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function _inCooldown() {
|
|
190
|
-
const until = parseInt(localStorage.getItem(COOLDOWN_KEY) || "0", 10) || 0;
|
|
191
|
-
return Date.now() < until;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function _startCooldown() {
|
|
195
|
-
localStorage.setItem(COOLDOWN_KEY, String(Date.now() + COOLDOWN_MS));
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// ── QR code (reuses qrcode-generator) ─────────────────────────────────
|
|
199
|
-
// Draws the QR for `url` onto a canvas 2d context at (x, y) sized `sizePx`.
|
|
123
|
+
// ── QR code ───────────────────────────────────────────────────────────
|
|
200
124
|
function _drawQrToCanvas(ctx, url, x, y, sizePx) {
|
|
201
125
|
const qr = qrcode(0, "M");
|
|
202
126
|
qr.addData(url);
|
|
@@ -223,9 +147,7 @@ const Share = (() => {
|
|
|
223
147
|
}
|
|
224
148
|
}
|
|
225
149
|
|
|
226
|
-
// ── Poster (
|
|
227
|
-
// Returns a data URL (PNG). `copy` is the live editor text, drawn as the
|
|
228
|
-
// poster's main line so the poster matches exactly what the user will post.
|
|
150
|
+
// ── Poster (product) ──────────────────────────────────────────────────
|
|
229
151
|
function _buildPoster(copy) {
|
|
230
152
|
const W = 720, H = 1080;
|
|
231
153
|
const t = _theme();
|
|
@@ -234,21 +156,17 @@ const Share = (() => {
|
|
|
234
156
|
canvas.height = H;
|
|
235
157
|
const ctx = canvas.getContext("2d");
|
|
236
158
|
|
|
237
|
-
// Background.
|
|
238
159
|
_paintBackground(ctx, W, H, t, t.bg);
|
|
239
160
|
|
|
240
|
-
// Brand name.
|
|
241
161
|
ctx.fillStyle = t.title;
|
|
242
162
|
ctx.textAlign = "center";
|
|
243
163
|
ctx.font = "700 60px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
|
244
|
-
ctx.fillText(
|
|
164
|
+
ctx.fillText(_brandName(), W / 2, 150);
|
|
245
165
|
|
|
246
|
-
|
|
247
|
-
const text = (copy || "").trim() || I18n.t("share.poster.tagline", { brand: _brand.name });
|
|
166
|
+
const text = (copy || "").trim() || I18n.t("share.poster.tagline", { brand: _brandName() });
|
|
248
167
|
ctx.fillStyle = t.tagline;
|
|
249
168
|
_drawAutoText(ctx, text, W / 2, 250, W - 120, 480 - 250);
|
|
250
169
|
|
|
251
|
-
// QR code (only when there's a URL to point at).
|
|
252
170
|
const url = _shareUrl();
|
|
253
171
|
if (url) {
|
|
254
172
|
const qrSize = 300;
|
|
@@ -271,8 +189,7 @@ const Share = (() => {
|
|
|
271
189
|
return canvas.toDataURL("image/png");
|
|
272
190
|
}
|
|
273
191
|
|
|
274
|
-
// ── Scorecard poster
|
|
275
|
-
// Hero = the cache-hit rate (the delight number). Brand is a footer chip.
|
|
192
|
+
// ── Scorecard poster ──────────────────────────────────────────────────
|
|
276
193
|
function _buildScorecardPoster(copy) {
|
|
277
194
|
const W = 720, H = 1080;
|
|
278
195
|
const t = _theme();
|
|
@@ -285,19 +202,18 @@ const Share = (() => {
|
|
|
285
202
|
|
|
286
203
|
ctx.textAlign = "center";
|
|
287
204
|
|
|
288
|
-
// Title + period.
|
|
289
205
|
ctx.fillStyle = t.title;
|
|
290
206
|
ctx.font = "700 48px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
|
291
207
|
ctx.fillText(I18n.t("share.scorecard.poster.title"), W / 2, 110);
|
|
292
208
|
|
|
293
209
|
const s = _curStats();
|
|
294
|
-
const
|
|
210
|
+
const sc = _scorecard();
|
|
211
|
+
const showHeat = _scorePeriod() === "month" && sc.heatmap && sc.heatmap.length > 0;
|
|
295
212
|
|
|
296
213
|
ctx.fillStyle = t.period;
|
|
297
214
|
ctx.font = "400 28px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
|
298
215
|
ctx.fillText(s.period, W / 2, 162);
|
|
299
216
|
|
|
300
|
-
// Hero: cache hit rate.
|
|
301
217
|
ctx.fillStyle = t.hero;
|
|
302
218
|
ctx.font = "800 150px -apple-system, 'PingFang SC', sans-serif";
|
|
303
219
|
ctx.fillText(s.cacheHitRate + "%", W / 2, 348);
|
|
@@ -306,7 +222,6 @@ const Share = (() => {
|
|
|
306
222
|
ctx.font = "400 32px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
|
307
223
|
ctx.fillText(I18n.t("share.scorecard.poster.cacheLabel"), W / 2, 408);
|
|
308
224
|
|
|
309
|
-
// Secondary metrics: cost + tokens.
|
|
310
225
|
ctx.fillStyle = t.metric;
|
|
311
226
|
ctx.font = "700 48px -apple-system, 'PingFang SC', sans-serif";
|
|
312
227
|
ctx.fillText(s.costStr, W / 2 - 160, 494);
|
|
@@ -316,20 +231,17 @@ const Share = (() => {
|
|
|
316
231
|
ctx.fillText(I18n.t("share.scorecard.poster.costLabel"), W / 2 - 160, 534);
|
|
317
232
|
ctx.fillText(I18n.t("share.scorecard.poster.tokensLabel"), W / 2 + 160, 534);
|
|
318
233
|
|
|
319
|
-
// Monthly heatmap (calendar style). Pushes everything below down.
|
|
320
234
|
let cursorY = 574;
|
|
321
235
|
if (showHeat) {
|
|
322
236
|
cursorY = _drawHeatmap(ctx, W, 552, t);
|
|
323
237
|
}
|
|
324
238
|
|
|
325
|
-
// Golden line = live editor text (poster matches what gets posted).
|
|
326
239
|
const goldenTop = showHeat ? cursorY + 14 : 614;
|
|
327
240
|
const goldenH = showHeat ? 78 : 110;
|
|
328
241
|
const line = (copy || "").trim() || _scorecardGoldenLine();
|
|
329
242
|
ctx.fillStyle = t.golden;
|
|
330
243
|
_drawAutoText(ctx, line, W / 2, goldenTop, W - 120, goldenH);
|
|
331
244
|
|
|
332
|
-
// QR + brand chip (gated: branded builds with no homepage show neither).
|
|
333
245
|
const url = _shareUrl();
|
|
334
246
|
const qrSize = showHeat ? 152 : 230;
|
|
335
247
|
const qrY = goldenTop + goldenH + (showHeat ? 14 : 30);
|
|
@@ -342,7 +254,7 @@ const Share = (() => {
|
|
|
342
254
|
|
|
343
255
|
ctx.fillStyle = t.brand;
|
|
344
256
|
ctx.font = "600 28px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
|
345
|
-
ctx.fillText(
|
|
257
|
+
ctx.fillText(_brandName(), W / 2, qrY + qrSize + (showHeat ? 40 : 52));
|
|
346
258
|
if (!showHeat) {
|
|
347
259
|
ctx.fillStyle = t.scan;
|
|
348
260
|
ctx.font = "400 23px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
|
@@ -351,19 +263,14 @@ const Share = (() => {
|
|
|
351
263
|
} else {
|
|
352
264
|
ctx.fillStyle = t.brand;
|
|
353
265
|
ctx.font = "600 34px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
|
354
|
-
ctx.fillText(
|
|
266
|
+
ctx.fillText(_brandName(), W / 2, qrY + 40);
|
|
355
267
|
}
|
|
356
268
|
|
|
357
269
|
return canvas.toDataURL("image/png");
|
|
358
270
|
}
|
|
359
271
|
|
|
360
|
-
// Draws a GitHub-contribution-style heatmap of daily token usage. Cells are
|
|
361
|
-
// laid out in columns of 7 (one week per column), oldest → newest. Color
|
|
362
|
-
// Draws a calendar-style heatmap of daily token usage: 7 columns (Sun→Sat),
|
|
363
|
-
// one week per row, aligned to real weekdays. Color ramps with usage via the
|
|
364
|
-
// theme's `heat` scale. Returns the Y just below it.
|
|
365
272
|
function _drawHeatmap(ctx, W, top, t) {
|
|
366
|
-
const days = _scorecard.heatmap;
|
|
273
|
+
const days = _scorecard().heatmap;
|
|
367
274
|
const heat = t.heat || { empty: "rgba(255,255,255,0.08)", scale: ["#9be9a8", "#40c463", "#30a14e", "#216e39", "#0a4020"] };
|
|
368
275
|
const cols = 7;
|
|
369
276
|
const gap = 6;
|
|
@@ -371,7 +278,6 @@ const Share = (() => {
|
|
|
371
278
|
const gridW = cols * cell + (cols - 1) * gap;
|
|
372
279
|
const x0 = (W - gridW) / 2;
|
|
373
280
|
|
|
374
|
-
// Title above the grid.
|
|
375
281
|
ctx.textAlign = "center";
|
|
376
282
|
ctx.fillStyle = t.metricLabel;
|
|
377
283
|
ctx.font = "400 24px -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
|
@@ -380,7 +286,6 @@ const Share = (() => {
|
|
|
380
286
|
const gridTop = top + 18;
|
|
381
287
|
const maxTok = Math.max.apply(null, days.map((d) => d.tokens).concat([1]));
|
|
382
288
|
|
|
383
|
-
// Weekday offset of the first day so columns line up with real weekdays.
|
|
384
289
|
const firstDow = days.length ? new Date(days[0].date + "T00:00:00").getDay() : 0;
|
|
385
290
|
let maxRow = 0;
|
|
386
291
|
|
|
@@ -405,9 +310,6 @@ const Share = (() => {
|
|
|
405
310
|
return gridTop + (maxRow + 1) * (cell + gap) - gap;
|
|
406
311
|
}
|
|
407
312
|
|
|
408
|
-
// Draws `text` centered at cx, fitting within (maxWidth × maxHeight) starting
|
|
409
|
-
// at startY. Picks the largest font (from a descending ladder) whose wrapped
|
|
410
|
-
// lines fit the height, honoring explicit "\n" breaks. Vertically centers.
|
|
411
313
|
function _drawAutoText(ctx, text, cx, startY, maxWidth, maxHeight) {
|
|
412
314
|
const sizes = [40, 36, 32, 28, 24, 20];
|
|
413
315
|
let chosen = null;
|
|
@@ -429,7 +331,6 @@ const Share = (() => {
|
|
|
429
331
|
}
|
|
430
332
|
}
|
|
431
333
|
|
|
432
|
-
// Wraps text into lines fitting maxWidth, honoring explicit "\n" breaks.
|
|
433
334
|
function _wrapLines(ctx, text, maxWidth) {
|
|
434
335
|
const out = [];
|
|
435
336
|
for (const para of String(text).split("\n")) {
|
|
@@ -457,9 +358,6 @@ const Share = (() => {
|
|
|
457
358
|
ctx.closePath();
|
|
458
359
|
}
|
|
459
360
|
|
|
460
|
-
// Paints a richer-than-flat poster background: base diagonal gradient, soft
|
|
461
|
-
// radial color glows, and a faint grid. Deterministic (no randomness) so the
|
|
462
|
-
// poster is reproducible and white-label safe.
|
|
463
361
|
function _paintBackground(ctx, W, H, t, base) {
|
|
464
362
|
const baseGrad = ctx.createLinearGradient(0, 0, W, H);
|
|
465
363
|
baseGrad.addColorStop(0, base[0]);
|
|
@@ -487,7 +385,6 @@ const Share = (() => {
|
|
|
487
385
|
ctx.stroke();
|
|
488
386
|
}
|
|
489
387
|
|
|
490
|
-
// Subtle top sheen for depth.
|
|
491
388
|
const sheen = ctx.createLinearGradient(0, 0, 0, H * 0.35);
|
|
492
389
|
sheen.addColorStop(0, t.style === "geek" ? "rgba(255,255,255,0.06)" : "rgba(255,255,255,0.18)");
|
|
493
390
|
sheen.addColorStop(1, "rgba(255,255,255,0)");
|
|
@@ -519,7 +416,6 @@ const Share = (() => {
|
|
|
519
416
|
finally { ta.remove(); }
|
|
520
417
|
}
|
|
521
418
|
|
|
522
|
-
// Append the landing URL (when present) to a body of share text.
|
|
523
419
|
function _withUrl(body) {
|
|
524
420
|
const url = _shareUrl();
|
|
525
421
|
return url ? `${body} ${url}`.trim() : body;
|
|
@@ -532,14 +428,13 @@ const Share = (() => {
|
|
|
532
428
|
window.open(share, "_blank", "noopener,noreferrer");
|
|
533
429
|
}
|
|
534
430
|
|
|
535
|
-
// ── Poster sharing helpers ────────────────────────────────────────────
|
|
536
431
|
function _posterFilename() {
|
|
537
|
-
return `${
|
|
432
|
+
return `${_brandName().toLowerCase()}-${_scorecard() ? "scorecard" : "share"}.png`;
|
|
538
433
|
}
|
|
539
434
|
|
|
540
435
|
function _downloadPoster(copy) {
|
|
541
436
|
const a = document.createElement("a");
|
|
542
|
-
a.href = _scorecard ? _buildScorecardPoster(copy) : _buildPoster(copy);
|
|
437
|
+
a.href = _scorecard() ? _buildScorecardPoster(copy) : _buildPoster(copy);
|
|
543
438
|
a.download = _posterFilename();
|
|
544
439
|
a.click();
|
|
545
440
|
}
|
|
@@ -554,23 +449,17 @@ const Share = (() => {
|
|
|
554
449
|
// ── Modal UI ──────────────────────────────────────────────────────────
|
|
555
450
|
let _overlay = null;
|
|
556
451
|
let _activePlatform = "weibo";
|
|
452
|
+
let _rerenderPeriods = null;
|
|
557
453
|
|
|
558
454
|
const PLATFORMS = ["weibo", "xhs", "wechat", "bilibili"];
|
|
559
455
|
|
|
560
|
-
function _telemetry(event, extra) {
|
|
561
|
-
if (typeof fetch === "undefined") return;
|
|
562
|
-
const body = JSON.stringify({ event: event, extra: extra || {} });
|
|
563
|
-
fetch("/api/telemetry", { method: "POST", headers: { "Content-Type": "application/json" }, body: body }).catch(() => {});
|
|
564
|
-
}
|
|
565
|
-
|
|
566
456
|
function openModal() {
|
|
567
|
-
// Tear down a stale overlay without clearing scorecard state (closeModal
|
|
568
|
-
// nulls _scorecard, which openScorecard has just set).
|
|
569
457
|
if (_overlay) { _overlay.remove(); _overlay = null; }
|
|
570
458
|
|
|
571
459
|
const hasUrl = !!_shareUrl();
|
|
572
|
-
const
|
|
573
|
-
const
|
|
460
|
+
const sc = _scorecard();
|
|
461
|
+
const titleKey = sc ? "share.scorecard.modal.title" : "share.modal.title";
|
|
462
|
+
const subtitleKey = sc ? "share.scorecard.modal.subtitle" : "share.modal.subtitle";
|
|
574
463
|
|
|
575
464
|
_activePlatform = "weibo";
|
|
576
465
|
|
|
@@ -591,13 +480,10 @@ const Share = (() => {
|
|
|
591
480
|
_esc(I18n.t("share.platform." + p)) + '</button>';
|
|
592
481
|
}).join("");
|
|
593
482
|
|
|
594
|
-
// Scorecard period switcher slot (filled by renderPeriodTabs below; can be
|
|
595
|
-
// re-rendered when billing hot-swaps in more periods asynchronously).
|
|
596
|
-
|
|
597
483
|
o.innerHTML =
|
|
598
484
|
'<div class="share-modal" role="dialog" aria-modal="true">' +
|
|
599
485
|
'<button type="button" class="share-close" aria-label="Close">×</button>' +
|
|
600
|
-
'<h3 class="share-title">' + _esc(I18n.t(titleKey, { brand:
|
|
486
|
+
'<h3 class="share-title">' + _esc(I18n.t(titleKey, { brand: _brandName() })) + '</h3>' +
|
|
601
487
|
'<p class="share-subtitle">' + _esc(I18n.t(subtitleKey)) + '</p>' +
|
|
602
488
|
'<div class="share-body">' +
|
|
603
489
|
'<div class="share-poster-wrap"><img class="share-poster-img" alt="poster"/></div>' +
|
|
@@ -633,18 +519,16 @@ const Share = (() => {
|
|
|
633
519
|
|
|
634
520
|
const renderPoster = () => {
|
|
635
521
|
const copy = (textarea.value || "").trim();
|
|
636
|
-
try { img.src = _scorecard ? _buildScorecardPoster(copy) : _buildPoster(copy); }
|
|
522
|
+
try { img.src = _scorecard() ? _buildScorecardPoster(copy) : _buildPoster(copy); }
|
|
637
523
|
catch (_e) { o.querySelector(".share-poster-wrap").style.display = "none"; }
|
|
638
524
|
};
|
|
639
525
|
|
|
640
|
-
|
|
641
|
-
// only — scorecard text carries live numbers and shouldn't be stale).
|
|
642
|
-
const draft = _scorecard ? "" : _loadDraft();
|
|
526
|
+
const draft = _scorecard() ? "" : _loadDraft();
|
|
643
527
|
textarea.value = draft || _pickCopy(_activePlatform);
|
|
644
528
|
renderPoster();
|
|
645
529
|
|
|
646
530
|
textarea.addEventListener("input", () => {
|
|
647
|
-
if (!_scorecard) _saveDraft(textarea.value);
|
|
531
|
+
if (!_scorecard()) _saveDraft(textarea.value);
|
|
648
532
|
renderPoster();
|
|
649
533
|
});
|
|
650
534
|
|
|
@@ -654,7 +538,7 @@ const Share = (() => {
|
|
|
654
538
|
b.classList.toggle("is-active", b.getAttribute("data-platform") === p);
|
|
655
539
|
});
|
|
656
540
|
textarea.value = _pickCopy(p);
|
|
657
|
-
if (!_scorecard) _saveDraft(textarea.value);
|
|
541
|
+
if (!_scorecard()) _saveDraft(textarea.value);
|
|
658
542
|
_updatePrimaryLabel(o);
|
|
659
543
|
renderPoster();
|
|
660
544
|
};
|
|
@@ -677,17 +561,18 @@ const Share = (() => {
|
|
|
677
561
|
const slot = o.querySelector(".share-periods-slot");
|
|
678
562
|
if (!slot) return;
|
|
679
563
|
const order = ["day", "week", "month"];
|
|
680
|
-
const
|
|
564
|
+
const sc2 = _scorecard();
|
|
565
|
+
const avail = sc2 && sc2.periods ? sc2.periods : {};
|
|
681
566
|
const keys = order.filter((p) => avail[p]);
|
|
682
567
|
if (keys.length <= 1) { slot.innerHTML = ""; return; }
|
|
683
568
|
slot.innerHTML = '<div class="share-periods">' + keys.map((p) => {
|
|
684
|
-
const on = p === _scorePeriod ? " is-active" : "";
|
|
569
|
+
const on = p === _scorePeriod() ? " is-active" : "";
|
|
685
570
|
return '<button type="button" class="share-period' + on + '" data-period="' + p + '">' +
|
|
686
571
|
_esc(I18n.t("share.scorecard.period." + p)) + '</button>';
|
|
687
572
|
}).join("") + '</div>';
|
|
688
573
|
slot.querySelectorAll(".share-period").forEach((b) => {
|
|
689
574
|
b.onclick = () => {
|
|
690
|
-
|
|
575
|
+
Share.state.scorePeriod = b.getAttribute("data-period");
|
|
691
576
|
slot.querySelectorAll(".share-period").forEach((x) => {
|
|
692
577
|
x.classList.toggle("is-active", x === b);
|
|
693
578
|
});
|
|
@@ -708,7 +593,7 @@ const Share = (() => {
|
|
|
708
593
|
if (act === "shuffle") {
|
|
709
594
|
btn.onclick = () => {
|
|
710
595
|
textarea.value = _pickCopy(_activePlatform, textarea.value.trim());
|
|
711
|
-
if (!_scorecard) _saveDraft(textarea.value);
|
|
596
|
+
if (!_scorecard()) _saveDraft(textarea.value);
|
|
712
597
|
renderPoster();
|
|
713
598
|
};
|
|
714
599
|
} else {
|
|
@@ -717,13 +602,10 @@ const Share = (() => {
|
|
|
717
602
|
});
|
|
718
603
|
|
|
719
604
|
_updatePrimaryLabel(o);
|
|
720
|
-
|
|
605
|
+
Share.telemetry("share_open", { type: _scorecard() ? "scorecard" : "share" });
|
|
721
606
|
requestAnimationFrame(() => o.classList.add("open"));
|
|
722
607
|
}
|
|
723
608
|
|
|
724
|
-
// Primary button label/behavior depends on the active platform: Weibo gets
|
|
725
|
-
// a real one-click jump; image-first platforms (xhs/wechat/bilibili) get
|
|
726
|
-
// "download poster + copy text".
|
|
727
609
|
function _updatePrimaryLabel(o) {
|
|
728
610
|
const btn = o.querySelector('[data-act="primary"]');
|
|
729
611
|
if (!btn) return;
|
|
@@ -740,7 +622,7 @@ const Share = (() => {
|
|
|
740
622
|
break;
|
|
741
623
|
case "download":
|
|
742
624
|
_downloadPoster(text);
|
|
743
|
-
|
|
625
|
+
Share.telemetry("share_download", { platform: _activePlatform, type: _scorecard() ? "scorecard" : "share" });
|
|
744
626
|
break;
|
|
745
627
|
case "copylink":
|
|
746
628
|
_copy(_shareUrl());
|
|
@@ -759,7 +641,7 @@ const Share = (() => {
|
|
|
759
641
|
default:
|
|
760
642
|
_downloadPoster(text);
|
|
761
643
|
_copy(_withUrl(text));
|
|
762
|
-
|
|
644
|
+
Share.telemetry("share_download", { platform: _activePlatform, type: _scorecard() ? "scorecard" : "share" });
|
|
763
645
|
Modal.toast(I18n.t("share.hint." + _activePlatform), "info");
|
|
764
646
|
break;
|
|
765
647
|
}
|
|
@@ -769,55 +651,34 @@ const Share = (() => {
|
|
|
769
651
|
if (!_overlay) return;
|
|
770
652
|
const o = _overlay;
|
|
771
653
|
_overlay = null;
|
|
772
|
-
_scorecard = null;
|
|
773
|
-
_scorePeriod = null;
|
|
774
654
|
_rerenderPeriods = null;
|
|
655
|
+
Share.clearScorecard();
|
|
775
656
|
o.classList.remove("open");
|
|
776
657
|
setTimeout(() => o.remove(), 200);
|
|
777
658
|
}
|
|
778
659
|
|
|
779
|
-
// ── Scorecard entry (B-line) ──────────────────────────────────────────
|
|
780
|
-
// Opens the share modal in "scorecard" mode with live billing numbers.
|
|
781
|
-
// stats: { period, cacheHitRate, costStr, tokensStr, requests }
|
|
782
660
|
function openScorecard(stats) {
|
|
783
|
-
|
|
784
|
-
_scorePeriod = _scorecard
|
|
785
|
-
? (_scorecard.defaultPeriod && _scorecard.periods && _scorecard.periods[_scorecard.defaultPeriod]
|
|
786
|
-
? _scorecard.defaultPeriod
|
|
787
|
-
: (_scorecard.periods ? Object.keys(_scorecard.periods)[0] : null))
|
|
788
|
-
: null;
|
|
661
|
+
Share.setScorecard(stats);
|
|
789
662
|
openModal();
|
|
790
663
|
}
|
|
791
664
|
|
|
792
|
-
// Called by billing when a period's numbers arrive after the modal already
|
|
793
|
-
// opened (instant-open path). Merges them and re-renders the switcher.
|
|
794
665
|
function addScorecardPeriod(key, stats) {
|
|
795
|
-
if (!
|
|
796
|
-
if (!
|
|
797
|
-
_scorecard.periods[key] = stats;
|
|
666
|
+
if (!_overlay) return;
|
|
667
|
+
if (!Share.addScorecardPeriod(key, stats)) return;
|
|
798
668
|
if (_rerenderPeriods) _rerenderPeriods();
|
|
799
669
|
}
|
|
800
670
|
|
|
801
|
-
// ── Auto-prompt at peak-delight moments ───────────────────────────────
|
|
802
|
-
// Called from ws-dispatcher on a successful `complete` event (the active
|
|
803
|
-
// session the user is watching — onboarding runs in its own panel, never
|
|
804
|
-
// emitting this for the chat view). Shows a gentle toast, not a modal,
|
|
805
|
-
// so we never interrupt the moment.
|
|
806
671
|
function maybePromptOnComplete() {
|
|
807
|
-
const
|
|
808
|
-
if (
|
|
809
|
-
if (!PROMPT_AT.includes(n)) return;
|
|
672
|
+
const { prompt } = Share.consumeSuccess();
|
|
673
|
+
if (!prompt) return;
|
|
810
674
|
|
|
811
|
-
Modal.toast(I18n.t("share.prompt.message", { brand:
|
|
675
|
+
Modal.toast(I18n.t("share.prompt.message", { brand: _brandName() }), "success", {
|
|
812
676
|
duration: 8000,
|
|
813
677
|
action: {
|
|
814
678
|
label: I18n.t("share.prompt.action"),
|
|
815
679
|
onClick: openModal
|
|
816
680
|
}
|
|
817
681
|
});
|
|
818
|
-
// Any prompt (taken or ignored) starts the cooldown so we stay quiet
|
|
819
|
-
// for the next 7 days regardless.
|
|
820
|
-
_startCooldown();
|
|
821
682
|
}
|
|
822
683
|
|
|
823
684
|
function _esc(s) {
|
|
@@ -826,16 +687,19 @@ const Share = (() => {
|
|
|
826
687
|
.replace(/>/g, ">").replace(/"/g, """);
|
|
827
688
|
}
|
|
828
689
|
|
|
829
|
-
// ── Init ──────────────────────────────────────────────────────────────
|
|
830
690
|
function init() {
|
|
831
|
-
|
|
691
|
+
Share.hydrateBrand();
|
|
832
692
|
const btn = document.getElementById("share-toggle-header");
|
|
833
693
|
if (btn) btn.addEventListener("click", openModal);
|
|
834
694
|
}
|
|
835
695
|
|
|
836
|
-
|
|
696
|
+
const api = { init, openModal, openScorecard, addScorecardPeriod, closeModal, maybePromptOnComplete };
|
|
697
|
+
|
|
698
|
+
return { api };
|
|
837
699
|
})();
|
|
838
700
|
|
|
701
|
+
Object.assign(Share, ShareView.api);
|
|
702
|
+
|
|
839
703
|
if (document.readyState === "loading") {
|
|
840
704
|
document.addEventListener("DOMContentLoaded", () => Share.init());
|
|
841
705
|
} else {
|