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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -0
  3. data/Dockerfile +3 -0
  4. data/README.md +1 -1
  5. data/README_JA.md +237 -0
  6. data/lib/clacky/agent/session_serializer.rb +65 -11
  7. data/lib/clacky/agent/time_machine.rb +247 -26
  8. data/lib/clacky/agent.rb +12 -1
  9. data/lib/clacky/agent_config.rb +14 -2
  10. data/lib/clacky/brand_config.rb +1 -1
  11. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  12. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  13. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  14. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  15. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  16. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  17. data/lib/clacky/default_skills/media-gen/SKILL.md +30 -6
  18. data/lib/clacky/media/openai_compat.rb +64 -1
  19. data/lib/clacky/media/output_dir.rb +43 -0
  20. data/lib/clacky/message_history.rb +9 -0
  21. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  22. data/lib/clacky/server/git_panel.rb +115 -0
  23. data/lib/clacky/server/http_server.rb +521 -13
  24. data/lib/clacky/server/server_master.rb +6 -4
  25. data/lib/clacky/utils/environment_detector.rb +16 -0
  26. data/lib/clacky/version.rb +1 -1
  27. data/lib/clacky/web/app.css +512 -60
  28. data/lib/clacky/web/app.js +30 -7
  29. data/lib/clacky/web/components/code-editor.js +197 -0
  30. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  31. data/lib/clacky/web/core/aside.js +112 -0
  32. data/lib/clacky/web/core/ext.js +387 -0
  33. data/lib/clacky/web/features/backup/store.js +92 -0
  34. data/lib/clacky/web/features/backup/view.js +94 -0
  35. data/lib/clacky/web/features/billing/store.js +163 -0
  36. data/lib/clacky/web/{billing.js → features/billing/view.js} +134 -242
  37. data/lib/clacky/web/features/brand/store.js +110 -0
  38. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  39. data/lib/clacky/web/features/channels/store.js +103 -0
  40. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  41. data/lib/clacky/web/features/creator/store.js +81 -0
  42. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  43. data/lib/clacky/web/features/mcp/store.js +158 -0
  44. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  45. data/lib/clacky/web/features/model-tester/store.js +77 -0
  46. data/lib/clacky/web/features/model-tester/view.js +7 -0
  47. data/lib/clacky/web/features/profile/store.js +170 -0
  48. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  49. data/lib/clacky/web/features/share/store.js +145 -0
  50. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  51. data/lib/clacky/web/features/skills/store.js +303 -0
  52. data/lib/clacky/web/features/skills/view.js +550 -0
  53. data/lib/clacky/web/features/tasks/store.js +135 -0
  54. data/lib/clacky/web/features/tasks/view.js +241 -0
  55. data/lib/clacky/web/features/trash/store.js +242 -0
  56. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  57. data/lib/clacky/web/features/version/store.js +165 -0
  58. data/lib/clacky/web/features/version/view.js +323 -0
  59. data/lib/clacky/web/features/workspace/store.js +99 -0
  60. data/lib/clacky/web/features/workspace/view.js +305 -0
  61. data/lib/clacky/web/i18n.js +60 -6
  62. data/lib/clacky/web/index.html +117 -57
  63. data/lib/clacky/web/sessions.js +221 -25
  64. data/lib/clacky/web/settings.js +121 -25
  65. data/lib/clacky/web/skills.js +3 -821
  66. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  67. data/lib/clacky.rb +1 -0
  68. metadata +45 -20
  69. data/lib/clacky/web/backup.js +0 -119
  70. data/lib/clacky/web/model-tester.js +0 -66
  71. data/lib/clacky/web/tasks.js +0 -365
  72. data/lib/clacky/web/version.js +0 -449
  73. data/lib/clacky/web/workspace.js +0 -212
  74. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  75. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  76. /data/lib/clacky/web/{onboard.js → components/onboard.js} +0 -0
  77. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  78. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -1,31 +1,21 @@
1
- // share.jsShare hooks for spreading the word at peak-delight moments.
1
+ // ── Share · view themes, Canvas posters, copy, modal, public actions ────
2
2
  //
3
- // Two entry points, one shared UI:
4
- // Share.openModal() always-available header button
5
- // Share.maybePromptOnComplete() — fires after a task succeeds, gated by
6
- // a frequency cap (first + 5th success,
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
- // Brand-aware: when this install is white-labelled, ALL share content
10
- // (product name, landing link, QR target, logo) switches to the brand's
11
- // values. It NEVER leaks "OpenClacky" / openclacky.com into a branded build.
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: qrcode (vendor/qrcode/qrcode.min.js), I18n, Modal.toast.
14
- // ─────────────────────────────────────────────────────────────────────────
15
- const Share = (() => {
16
- const DEFAULT_NAME = "OpenClacky";
17
- const DEFAULT_HOMEPAGE = "https://www.openclacky.com/";
18
-
19
- const COUNT_KEY = "clacky-share-success-count";
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
- // Brand info, hydrated once from /api/brand/status. Falls back to defaults
83
- // (un-branded OpenClacky) until the fetch resolves or if it fails.
84
- let _brand = { name: DEFAULT_NAME, homepageUrl: DEFAULT_HOMEPAGE, logoUrl: null };
85
-
86
- // Current scorecard stats, set by openScorecard() before building the
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: _brand.name });
125
- return list.length ? list : [I18n.t("share.copy.1", { brand: _brand.name })];
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: _brand.name,
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
- // ── Frequency cap (C2: first + 5th, 7-day cooldown after dismissal) ────
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 (pure-frontend Canvas, zero token, instant) ────────────────
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(_brand.name, W / 2, 150);
164
+ ctx.fillText(_brandName(), W / 2, 150);
245
165
 
246
- // Main line = the live editor text (so poster === shared text).
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 (B-line: spend / cache-hit bragging card) ────────
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 showHeat = _scorePeriod === "month" && _scorecard.heatmap && _scorecard.heatmap.length > 0;
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(_brand.name, W / 2, qrY + qrSize + (showHeat ? 40 : 52));
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(_brand.name, W / 2, qrY + 40);
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 `${_brand.name.toLowerCase()}-${_scorecard ? "scorecard" : "share"}.png`;
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 titleKey = _scorecard ? "share.scorecard.modal.title" : "share.modal.title";
573
- const subtitleKey = _scorecard ? "share.scorecard.modal.subtitle" : "share.modal.subtitle";
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">&times;</button>' +
600
- '<h3 class="share-title">' + _esc(I18n.t(titleKey, { brand: _brand.name })) + '</h3>' +
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
- // Seed the editor: prefer the user's last hand-edited draft (product mode
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 avail = _scorecard && _scorecard.periods ? _scorecard.periods : {};
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
- _scorePeriod = b.getAttribute("data-period");
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
- _telemetry("share_open", { type: _scorecard ? "scorecard" : "share" });
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
- _telemetry("share_download", { platform: _activePlatform, type: _scorecard ? "scorecard" : "share" });
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
- _telemetry("share_download", { platform: _activePlatform, type: _scorecard ? "scorecard" : "share" });
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
- _scorecard = stats || null;
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 (!_scorecard || !_overlay) return;
796
- if (!_scorecard.periods) _scorecard.periods = {};
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 n = _bumpSuccessCount();
808
- if (_inCooldown()) return;
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: _brand.name }), "success", {
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, "&gt;").replace(/"/g, "&quot;");
827
688
  }
828
689
 
829
- // ── Init ──────────────────────────────────────────────────────────────
830
690
  function init() {
831
- _hydrateBrand();
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
- return { init, openModal, openScorecard, addScorecardPeriod, closeModal, maybePromptOnComplete };
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 {