openclacky 1.3.3 → 1.3.5

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/docs/rich_ui_guide.md +277 -0
  4. data/docs/rich_ui_refactor_plan.md +396 -0
  5. data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
  6. data/lib/clacky/agent/llm_caller.rb +10 -4
  7. data/lib/clacky/agent/session_serializer.rb +5 -3
  8. data/lib/clacky/agent/tool_executor.rb +0 -12
  9. data/lib/clacky/agent.rb +77 -11
  10. data/lib/clacky/agent_config.rb +2 -14
  11. data/lib/clacky/api_extension.rb +343 -0
  12. data/lib/clacky/api_extension_loader.rb +168 -0
  13. data/lib/clacky/cli.rb +93 -3
  14. data/lib/clacky/client.rb +47 -25
  15. data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
  16. data/lib/clacky/default_agents/_panels/time_machine/panel.js +23 -1
  17. data/lib/clacky/default_agents/base_prompt.md +1 -0
  18. data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
  19. data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
  20. data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
  21. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
  22. data/lib/clacky/default_skills/media-gen/SKILL.md +72 -6
  23. data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
  24. data/lib/clacky/idle_compression_timer.rb +3 -1
  25. data/lib/clacky/json_ui_controller.rb +1 -1
  26. data/lib/clacky/locales/en.rb +26 -0
  27. data/lib/clacky/locales/i18n.rb +26 -0
  28. data/lib/clacky/locales/zh.rb +26 -0
  29. data/lib/clacky/media/base.rb +60 -0
  30. data/lib/clacky/media/dashscope.rb +385 -21
  31. data/lib/clacky/media/gemini.rb +9 -0
  32. data/lib/clacky/media/generator.rb +52 -0
  33. data/lib/clacky/media/openai_compat.rb +166 -0
  34. data/lib/clacky/null_ui_controller.rb +13 -0
  35. data/lib/clacky/plain_ui_controller.rb +1 -1
  36. data/lib/clacky/providers.rb +50 -2
  37. data/lib/clacky/rich_ui/components/base_component.rb +50 -0
  38. data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
  39. data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
  40. data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
  41. data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
  42. data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
  43. data/lib/clacky/rich_ui/components/status_view.rb +58 -0
  44. data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
  45. data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
  46. data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
  47. data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
  48. data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
  49. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
  50. data/lib/clacky/rich_ui/view_renderer.rb +291 -0
  51. data/lib/clacky/rich_ui.rb +57 -0
  52. data/lib/clacky/rich_ui_controller.rb +3 -1549
  53. data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
  54. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  55. data/lib/clacky/server/http_server.rb +291 -109
  56. data/lib/clacky/server/session_registry.rb +5 -3
  57. data/lib/clacky/server/web_ui_controller.rb +3 -2
  58. data/lib/clacky/shell_hook_loader.rb +1 -1
  59. data/lib/clacky/skill_loader.rb +14 -2
  60. data/lib/clacky/tools/edit.rb +14 -2
  61. data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
  62. data/lib/clacky/tools/terminal.rb +0 -43
  63. data/lib/clacky/ui2/components/modal_component.rb +1 -1
  64. data/lib/clacky/ui2/ui_controller.rb +147 -31
  65. data/lib/clacky/ui_interface.rb +10 -1
  66. data/lib/clacky/utils/encoding.rb +25 -0
  67. data/lib/clacky/version.rb +1 -1
  68. data/lib/clacky/web/app.css +201 -81
  69. data/lib/clacky/web/app.js +65 -7
  70. data/lib/clacky/web/components/onboard.js +19 -16
  71. data/lib/clacky/web/core/aside.js +8 -3
  72. data/lib/clacky/web/core/ext.js +1 -1
  73. data/lib/clacky/web/features/brand/view.js +8 -5
  74. data/lib/clacky/web/features/channels/store.js +1 -20
  75. data/lib/clacky/web/features/mcp/store.js +1 -20
  76. data/lib/clacky/web/features/profile/store.js +1 -13
  77. data/lib/clacky/web/features/profile/view.js +16 -4
  78. data/lib/clacky/web/features/skills/store.js +36 -23
  79. data/lib/clacky/web/features/skills/view.js +32 -1
  80. data/lib/clacky/web/features/version/store.js +2 -0
  81. data/lib/clacky/web/features/workspace/view.js +1 -1
  82. data/lib/clacky/web/i18n.js +48 -13
  83. data/lib/clacky/web/index.html +24 -17
  84. data/lib/clacky/web/sessions.js +427 -79
  85. data/lib/clacky/web/settings.js +143 -113
  86. data/lib/clacky/web/ws-dispatcher.js +18 -6
  87. data/lib/clacky.rb +27 -5
  88. metadata +45 -2
  89. data/lib/clacky/media/output_dir.rb +0 -43
@@ -0,0 +1,3 @@
1
+ name: meeting
2
+ description: Real-time meeting transcription and AI assistant
3
+ version: "0.1.0"
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: meeting-summarizer
3
+ description: Summarize a completed meeting from its transcript. Produces a structured summary with key decisions, action items, and discussion highlights. Triggered automatically when a meeting ends.
4
+ user-invocable: false
5
+ auto_summarize: false
6
+ ---
7
+
8
+ # Meeting Summarizer
9
+
10
+ You are a meeting summarization assistant. You have been given a meeting transcript and must produce a clear, actionable summary.
11
+
12
+ ## Input
13
+
14
+ The user message contains the full meeting transcript (timestamped lines of dialogue).
15
+
16
+ ## Output Format
17
+
18
+ Produce the summary in this structure:
19
+
20
+ ### Meeting Summary
21
+
22
+ **Duration**: [start time] – [end time]
23
+
24
+ #### Key Decisions
25
+ - List each decision made during the meeting
26
+
27
+ #### Action Items
28
+ - [ ] Action item with owner if identifiable
29
+
30
+ #### Discussion Highlights
31
+ - Brief bullet points of important topics discussed
32
+
33
+ #### Open Questions
34
+ - Any unresolved questions raised but not answered
35
+
36
+ ---
37
+
38
+ ## Rules
39
+
40
+ 1. Be concise — each bullet should be one sentence max.
41
+ 2. If speakers are identifiable from context, attribute decisions and actions to them.
42
+ 3. Ignore filler words, small talk, and off-topic tangents.
43
+ 4. If the transcript is too short or empty, say so and skip the structured output.
44
+ 5. Write the summary in the same language the meeting was conducted in.
@@ -59,7 +59,8 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/ima
59
59
  -H "Content-Type: application/json" \
60
60
  -d '{
61
61
  "prompt": "A clean, modern hero illustration for a tech startup landing page. Soft gradient background, abstract geometric shapes in blue and purple, minimal style, 4K quality.",
62
- "aspect_ratio": "landscape"
62
+ "aspect_ratio": "landscape",
63
+ "output_dir": "'"$(pwd)"'"
63
64
  }'
64
65
  ```
65
66
 
@@ -73,7 +74,7 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/ima
73
74
  |----------------|----------|-------------------------------------|-------|
74
75
  | `prompt` | yes | string | Be detailed and concrete. See prompt tips below. |
75
76
  | `aspect_ratio` | no | `landscape` / `square` / `portrait` | Defaults to `landscape`. |
76
- | `output_dir` | no | absolute path | Per-call override. When omitted, falls back to the user's `media_output_dir` setting (Settings → Models → Media Output Directory), then to the working directory. The image is always saved into the `assets/generated/` subdirectory of whichever path is used. |
77
+ | `output_dir` | yes | absolute path | Always pass `$(pwd)` so files land in the current session workspace. The image is saved under `<output_dir>/assets/generated/`. |
77
78
  | `image` | no | file path / base64 / data URL | A single input image to **edit**. Triggers image-edit mode (see below). |
78
79
  | `images` | no | array of the above | Multiple input images for a multi-image edit. Takes precedence over `image`. |
79
80
 
@@ -204,7 +205,8 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/vid
204
205
  -d '{
205
206
  "prompt": "A cinematic drone shot flying over a misty mountain range at sunrise, golden light, 4K.",
206
207
  "aspect_ratio": "landscape",
207
- "duration_seconds": 8
208
+ "duration_seconds": 8,
209
+ "output_dir": "'"$(pwd)"'"
208
210
  }'
209
211
  ```
210
212
 
@@ -214,7 +216,7 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/vid
214
216
  | `aspect_ratio` | no | `landscape` / `portrait` | Defaults to `landscape` (16:9). |
215
217
  | `duration_seconds` | no | 4–8 | Defaults to 8. |
216
218
  | `image` | no | `{ "b64_json": "...", "mime_type": "image/png" }` | Optional first frame for image-to-video. |
217
- | `output_dir` | no | absolute path | Per-call override; same fallback chain as `image` (user setting cwd). MP4 saved into the `assets/generated/` subdirectory of whichever path is used. |
219
+ | `output_dir` | yes | absolute path | Always pass `$(pwd)` so files land in the current session workspace. MP4 saved under `<output_dir>/assets/generated/`. |
218
220
 
219
221
  ### Response (success)
220
222
 
@@ -240,6 +242,69 @@ it in documents with a relative path under `./assets/generated/`.
240
242
  Same shape and `error_type` values as image generation, but with `"video": null`.
241
243
  `not_configured` means no `type=video` model is set up.
242
244
 
245
+ ### Continuous / long video (last-frame chaining)
246
+
247
+ A single Veo call maxes out at 8 seconds, and separate calls are visually
248
+ **unrelated** (the character, lighting and framing jump between clips). To make
249
+ several clips flow as one continuous shot, chain them: take the **last frame**
250
+ of clip N and feed it as the `image` (first frame) of clip N+1. Veo's
251
+ image-to-video then continues from exactly where the previous clip ended, so
252
+ the seam is smooth.
253
+
254
+ Use the helper script (it only does the ffmpeg mechanics — you drive the
255
+ generation with the same `/api/media/video` curl as above). The script's
256
+ absolute path is given in the **Supporting Files** block; assign it once:
257
+
258
+ ```bash
259
+ SEQ="SKILL_DIR/scripts/video_seq.sh" # SKILL_DIR is provided in Supporting Files
260
+ # subcommands: lastframe | tob64 | payload | concat | probe
261
+ ```
262
+
263
+ Workflow for an N-segment continuous video:
264
+
265
+ 1. **Plan the shots.** Split the story into 4–8s beats. Write one prompt per
266
+ beat; each prompt should describe the *continuation*, e.g. "The same girl
267
+ keeps walking forward, the camera pushes in…". Keep subject, style and
268
+ lighting wording consistent across prompts.
269
+ 2. **Segment 1** — normal text-to-video call. Save the returned mp4 path.
270
+ 3. **Extract its last frame** (as JPEG — keep the `.jpg` extension):
271
+ ```bash
272
+ "$SEQ" lastframe seg1.mp4 /tmp/seg1_last.jpg
273
+ ```
274
+ 4. **Segment 2** — build the request body with `payload`, then post it with
275
+ `curl --data @file`. **Do NOT inline the base64 into `-d "{…}"`** — a frame's
276
+ base64 is ~150KB+ and overflows the shell's argument limit ("Argument list
277
+ too long"). The `payload` subcommand reads the frame, base64-encodes it, and
278
+ writes a ready-to-send JSON file:
279
+ ```bash
280
+ "$SEQ" payload /tmp/seg2.json /tmp/seg1_last.jpg 8 landscape "$OUT_DIR" \
281
+ "Continuing the same scene, the camera keeps pushing forward…"
282
+ curl -s -X POST .../api/media/video -H "Content-Type: application/json" \
283
+ --data @/tmp/seg2.json
284
+ ```
285
+ (`payload <out.json> <frame> <duration_seconds> <aspect_ratio> <output_dir> <prompt>`)
286
+ 5. **Repeat** steps 3–4 for each subsequent segment, always chaining off the
287
+ *previous* segment's last frame.
288
+ 6. **Stitch** all clips in order into one file:
289
+ ```bash
290
+ "$SEQ" concat final.mp4 seg1.mp4 seg2.mp4 seg3.mp4
291
+ ```
292
+
293
+ Rules & caveats:
294
+
295
+ - **Strictly sequential.** Generate one segment, wait for it, extract its
296
+ frame, then start the next. Never run two video generations at once.
297
+ - **Keep prompts consistent.** The image carries visual continuity, but the
298
+ prompt must not contradict it (don't switch the subject or scene mid-chain
299
+ unless you intend a cut).
300
+ - **Aspect ratio must match** across all segments, or `concat` falls back to a
301
+ slower re-encode (and may letterbox). Use the same `aspect_ratio` everywhere.
302
+ - **Cost adds up linearly** — N segments ≈ N × single-clip price. Confirm the
303
+ number of segments and total length with the user before starting.
304
+ - For >30s or a true single-take >8s with no seam at all, this client-side
305
+ chaining is the practical option today; Veo's native server-side `extend`
306
+ (148s) is not wired into this endpoint yet.
307
+
243
308
  ## Generating speech (Gemini TTS)
244
309
 
245
310
  The same `/api/media/` namespace serves text-to-speech. The user must
@@ -262,7 +327,8 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/aud
262
327
  -H "Content-Type: application/json" \
263
328
  -d '{
264
329
  "input": "Hello and welcome to openclacky. Today we will explore...",
265
- "voice": "Kore"
330
+ "voice": "Kore",
331
+ "output_dir": "'"$(pwd)"'"
266
332
  }'
267
333
  ```
268
334
 
@@ -270,7 +336,7 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/aud
270
336
  |--------------|----------|---------------------------------|-------|
271
337
  | `input` | yes | string | The text to speak. Plain prose works best; you can prefix with style cues like "Say cheerfully:" or "In a calm tone:". |
272
338
  | `voice` | no | string voice name | Defaults to `Kore`. Common Gemini voices: `Kore`, `Puck`, `Charon`, `Fenrir`, `Aoede`. |
273
- | `output_dir` | no | absolute path | WAV saved under `<output_dir>/assets/generated/`. |
339
+ | `output_dir` | yes | absolute path | Always pass `$(pwd)` so files land in the current session workspace. WAV saved under `<output_dir>/assets/generated/`. |
274
340
 
275
341
  Generation typically takes 2–10 seconds depending on length. The request
276
342
  blocks until the WAV is ready.
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env bash
2
+ # Helpers for stitching multiple Veo clips into one continuous video using the
3
+ # "last-frame chaining" technique (method A): the last frame of clip N becomes
4
+ # the first frame (image-to-video) of clip N+1, so the seam is visually
5
+ # continuous. The agent drives generation via the /api/media/video endpoint;
6
+ # this script only does the mechanical ffmpeg steps.
7
+ #
8
+ # Requires: ffmpeg, ffprobe (both ship with the standard image).
9
+ #
10
+ # Subcommands:
11
+ # lastframe <video.mp4> <out.jpg> extract the final frame (JPEG by default)
12
+ # tob64 <image> print base64 (no newlines) to stdout
13
+ # payload <out.json> <frame.jpg> <dur> <aspect> <output_dir> <prompt>
14
+ # build an image-to-video JSON body
15
+ # for `curl --data @out.json`
16
+ # concat <out.mp4> <clip1.mp4> [clip2 …] losslessly join clips in order
17
+ # probe <video.mp4> print "WIDTHxHEIGHT FPS DURATION"
18
+ set -euo pipefail
19
+
20
+ die() { echo "error: $*" >&2; exit 1; }
21
+ need() { command -v "$1" >/dev/null 2>&1 || die "$1 not found on PATH"; }
22
+
23
+ cmd_lastframe() {
24
+ local src="$1" out="$2"
25
+ [[ -f "$src" ]] || die "no such video: $src"
26
+ need ffmpeg; need ffprobe
27
+ # sseof seeks relative to end; -update 1 keeps overwriting so we land on the
28
+ # genuinely last decodable frame regardless of exact timestamp.
29
+ # JPEG (-q:v 3) keeps the base64 ~8x smaller than PNG, which matters because a
30
+ # PNG frame's base64 (~1.5MB) overflows ARG_MAX when inlined into a shell arg.
31
+ ffmpeg -nostdin -loglevel error -y -sseof -0.5 -i "$src" \
32
+ -update 1 -frames:v 1 -q:v 3 "$out"
33
+ [[ -f "$out" ]] || die "failed to extract last frame"
34
+ echo "$out"
35
+ }
36
+
37
+ cmd_tob64() {
38
+ local img="$1"
39
+ [[ -f "$img" ]] || die "no such image: $img"
40
+ base64 < "$img" | tr -d '\n'
41
+ }
42
+
43
+ # Build the image-to-video request body as a file so curl can send it with
44
+ # `--data @file`, avoiding "Argument list too long" from inlining base64.
45
+ cmd_payload() {
46
+ local out="$1" frame="$2" dur="$3" aspect="$4" odir="$5" prompt="$6"
47
+ [[ -f "$frame" ]] || die "no such frame: $frame"
48
+ need ffprobe
49
+ local mime b64
50
+ case "$frame" in
51
+ *.png) mime="image/png" ;;
52
+ *) mime="image/jpeg" ;;
53
+ esac
54
+ b64="$(base64 < "$frame" | tr -d '\n')"
55
+ FRAME_B64="$b64" FRAME_MIME="$mime" P_PROMPT="$prompt" P_DUR="$dur" \
56
+ P_ASPECT="$aspect" P_ODIR="$odir" python3 - "$out" <<'PY'
57
+ import json, os, sys
58
+ body = {
59
+ "prompt": os.environ["P_PROMPT"],
60
+ "aspect_ratio": os.environ["P_ASPECT"],
61
+ "duration_seconds": int(os.environ["P_DUR"]),
62
+ "output_dir": os.environ["P_ODIR"],
63
+ "image": {"b64_json": os.environ["FRAME_B64"], "mime_type": os.environ["FRAME_MIME"]},
64
+ }
65
+ open(sys.argv[1], "w").write(json.dumps(body))
66
+ PY
67
+ [[ -f "$out" ]] || die "failed to write payload"
68
+ echo "$out"
69
+ }
70
+
71
+ cmd_concat() {
72
+ local out="$1"; shift
73
+ [[ $# -ge 1 ]] || die "concat needs at least one clip"
74
+ need ffmpeg
75
+ local listfile
76
+ listfile="$(mktemp -t veo_concat.XXXXXX)"
77
+ trap 'rm -f "$listfile"' RETURN
78
+ local clip abs
79
+ for clip in "$@"; do
80
+ [[ -f "$clip" ]] || die "no such clip: $clip"
81
+ abs="$(cd "$(dirname "$clip")" && pwd)/$(basename "$clip")"
82
+ printf "file '%s'\n" "$abs" >> "$listfile"
83
+ done
84
+ # Try stream-copy first (fast, lossless); fall back to re-encode if the clips
85
+ # are not bit-compatible for the concat demuxer.
86
+ if ! ffmpeg -nostdin -loglevel error -y -f concat -safe 0 -i "$listfile" \
87
+ -c copy "$out" 2>/dev/null; then
88
+ ffmpeg -nostdin -loglevel error -y -f concat -safe 0 -i "$listfile" \
89
+ -c:v libx264 -pix_fmt yuv420p -c:a aac "$out"
90
+ fi
91
+ echo "$out"
92
+ }
93
+
94
+ cmd_probe() {
95
+ local src="$1"
96
+ [[ -f "$src" ]] || die "no such video: $src"
97
+ need ffprobe
98
+ ffprobe -v error -select_streams v:0 \
99
+ -show_entries stream=width,height,r_frame_rate \
100
+ -show_entries format=duration \
101
+ -of default=noprint_wrappers=1:nokey=1 "$src" \
102
+ | paste -sd' ' -
103
+ }
104
+
105
+ [[ $# -ge 1 ]] || die "usage: $0 {lastframe|tob64|payload|concat|probe} ..."
106
+ sub="$1"; shift
107
+ case "$sub" in
108
+ lastframe) cmd_lastframe "$@" ;;
109
+ tob64) cmd_tob64 "$@" ;;
110
+ payload) cmd_payload "$@" ;;
111
+ concat) cmd_concat "$@" ;;
112
+ probe) cmd_probe "$@" ;;
113
+ *) die "unknown subcommand: $sub" ;;
114
+ esac
@@ -121,7 +121,9 @@ module Clacky
121
121
  success = @agent.trigger_idle_compression
122
122
 
123
123
  if success && @session_manager
124
- @session_manager.save(@agent.to_session_data(status: :success))
124
+ existing = @session_manager.load(@agent.session_id)
125
+ original_updated_at = existing&.dig(:updated_at) ? Time.parse(existing[:updated_at].to_s) : nil
126
+ @session_manager.save(@agent.to_session_data(status: :success, updated_at: original_updated_at))
125
127
  end
126
128
 
127
129
  @on_compress&.call(success)
@@ -101,7 +101,7 @@ module Clacky
101
101
  emit("warning", message: message)
102
102
  end
103
103
 
104
- def show_error(message, code: nil, top_up_url: nil)
104
+ def show_error(message, code: nil, top_up_url: nil, raw_message: nil)
105
105
  payload = { message: message }
106
106
  payload[:code] = code if code
107
107
  payload[:top_up_url] = top_up_url if top_up_url
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ module Locales
5
+ EN = {
6
+ "llm.error.insufficient_credit" => "Insufficient credit, please top up your account to continue",
7
+ "llm.error.rate_limit_400" => "Rate limit or service issue, retrying...",
8
+ "llm.error.invalid_api_key" => "Invalid API key, please check your configuration",
9
+ "llm.error.403.model_not_allowed" => "This model is not available on your current plan",
10
+ "llm.error.403.api_key_revoked" => "API key has been revoked, please generate a new one",
11
+ "llm.error.403.api_key_expired" => "API key has expired, please generate a new one",
12
+ "llm.error.403.quota_exceeded" => "Quota exceeded, please upgrade your plan",
13
+ "llm.error.403.access_denied" => "Access denied, please check your API key permissions",
14
+ "llm.error.403.default" => "Access denied",
15
+ "llm.error.endpoint_not_found" => "API endpoint not found, please check your service URL",
16
+ "llm.error.rate_limit_429" => "Rate limit exceeded, please wait a moment",
17
+ "llm.error.server_error" => "Service temporarily unavailable (%<status>d), retrying...",
18
+ "llm.error.unexpected" => "Unexpected error (%<status>d)",
19
+ "llm.error.html_response" => "Service temporarily unavailable (received HTML error page), retrying...",
20
+ "llm.error.bad_request" => "Bad request: invalid parameters. Please check your model configuration",
21
+ "llm.error.request_timeout" => "Request timed out after %<retries>d retries",
22
+ "llm.error.network_failed" => "Network connection failed after %<retries>d retries",
23
+ "llm.error.service_unavailable" => "Service unavailable after %<retries>d retries"
24
+ }.freeze
25
+ end
26
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "en"
4
+ require_relative "zh"
5
+
6
+ module Clacky
7
+ module I18n
8
+ LOCALES = {
9
+ "zh" => Clacky::Locales::ZH,
10
+ "en" => Clacky::Locales::EN
11
+ }.freeze
12
+
13
+ def self.t(key, **vars)
14
+ table = LOCALES[locale] || LOCALES["en"]
15
+ msg = table[key] || LOCALES["en"][key] || key
16
+ vars.empty? ? msg : format(msg, **vars)
17
+ end
18
+
19
+ def self.locale
20
+ return Thread.current[:lang] if Thread.current[:lang]
21
+
22
+ lang = ENV["LC_ALL"] || ENV["LC_MESSAGES"] || ENV["LANG"] || ""
23
+ lang.match?(/\Azh/i) ? "zh" : "en"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ module Locales
5
+ ZH = {
6
+ "llm.error.insufficient_credit" => "账户余额不足,请前往控制台充值后继续使用",
7
+ "llm.error.rate_limit_400" => "请求频率过高或服务暂时不可用,正在重试...",
8
+ "llm.error.invalid_api_key" => "API 密钥无效,请检查配置",
9
+ "llm.error.403.model_not_allowed" => "当前模型不支持免费试用,请升级套餐或切换其他模型",
10
+ "llm.error.403.api_key_revoked" => "API 密钥已被撤销,请前往控制台重新生成",
11
+ "llm.error.403.api_key_expired" => "API 密钥已过期,请前往控制台重新生成",
12
+ "llm.error.403.quota_exceeded" => "配额已用完,请升级套餐",
13
+ "llm.error.403.access_denied" => "访问被拒绝,请检查 API 密钥权限",
14
+ "llm.error.403.default" => "访问被拒绝",
15
+ "llm.error.endpoint_not_found" => "API 端点不存在,请检查服务地址配置",
16
+ "llm.error.rate_limit_429" => "请求过于频繁,请稍候重试",
17
+ "llm.error.server_error" => "服务暂时不可用(%<status>d),正在重试...",
18
+ "llm.error.unexpected" => "请求失败(%<status>d)",
19
+ "llm.error.html_response" => "服务暂时不可用(收到 HTML 错误页),正在重试...",
20
+ "llm.error.bad_request" => "请求参数有误,请检查模型配置或重试",
21
+ "llm.error.request_timeout" => "请求超时(已重试 %<retries>d 次)",
22
+ "llm.error.network_failed" => "网络连接失败(已重试 %<retries>d 次)",
23
+ "llm.error.service_unavailable" => "服务暂时不可用(已重试 %<retries>d 次)"
24
+ }.freeze
25
+ end
26
+ end
@@ -51,6 +51,24 @@ module Clacky
51
51
  )
52
52
  end
53
53
 
54
+ def generate_transcription(audio_base64:, mime_type:, **_kwargs)
55
+ transcription_error_response(
56
+ error: "Speech-to-text is not supported by #{self.class.name.split("::").last}. Use the openclacky gateway with an STT model such as or-stt-gemini-3-5-flash.",
57
+ error_type: "not_implemented",
58
+ provider: ""
59
+ )
60
+ end
61
+
62
+ # @return [Hash] either video_understanding_success_response(...) or
63
+ # video_understanding_error_response(...)
64
+ def understand_video(video_base64:, mime_type:, prompt: nil, **_kwargs)
65
+ video_understanding_error_response(
66
+ error: "Video understanding is not supported by #{self.class.name.split("::").last}. Use the openclacky gateway with a video understanding model such as or-gemini-3-5-flash.",
67
+ error_type: "not_implemented",
68
+ provider: ""
69
+ )
70
+ end
71
+
54
72
  # Persist a base64-encoded image under <output_dir>/assets/generated/.
55
73
  # Returns the absolute path on disk.
56
74
  private def save_b64_image(b64_data, output_dir:, prefix: "img", extension: "png")
@@ -188,6 +206,48 @@ module Clacky
188
206
  "provider" => provider
189
207
  }
190
208
  end
209
+
210
+ private def transcription_success_response(text:, provider:, extra: {})
211
+ {
212
+ "success" => true,
213
+ "text" => text,
214
+ "model" => @model,
215
+ "provider" => provider
216
+ }.merge(extra)
217
+ end
218
+
219
+ private def transcription_error_response(error:, error_type: "provider_error", provider: "")
220
+ {
221
+ "success" => false,
222
+ "text" => nil,
223
+ "error" => error,
224
+ "error_type" => error_type,
225
+ "model" => @model,
226
+ "provider" => provider
227
+ }
228
+ end
229
+
230
+ private def video_understanding_success_response(analysis:, prompt:, provider:, extra: {})
231
+ {
232
+ "success" => true,
233
+ "analysis" => analysis,
234
+ "model" => @model,
235
+ "prompt" => prompt,
236
+ "provider" => provider
237
+ }.merge(extra)
238
+ end
239
+
240
+ private def video_understanding_error_response(error:, error_type: "provider_error", provider:, prompt: "")
241
+ {
242
+ "success" => false,
243
+ "analysis" => nil,
244
+ "error" => error,
245
+ "error_type" => error_type,
246
+ "model" => @model,
247
+ "prompt" => prompt,
248
+ "provider" => provider
249
+ }
250
+ end
191
251
  end
192
252
  end
193
253
  end