openclacky 1.3.2 → 1.3.4

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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/Dockerfile +3 -0
  4. data/README.md +1 -1
  5. data/README_JA.md +237 -0
  6. data/docs/rich_ui_guide.md +277 -0
  7. data/docs/rich_ui_refactor_plan.md +396 -0
  8. data/lib/clacky/agent/llm_caller.rb +10 -4
  9. data/lib/clacky/agent/session_serializer.rb +52 -7
  10. data/lib/clacky/agent/time_machine.rb +247 -26
  11. data/lib/clacky/agent.rb +15 -3
  12. data/lib/clacky/api_extension.rb +262 -0
  13. data/lib/clacky/api_extension_loader.rb +156 -0
  14. data/lib/clacky/cli.rb +93 -3
  15. data/lib/clacky/client.rb +38 -13
  16. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  17. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  18. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  19. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  20. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  21. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  22. data/lib/clacky/default_skills/media-gen/SKILL.md +37 -10
  23. data/lib/clacky/idle_compression_timer.rb +3 -1
  24. data/lib/clacky/locales/en.rb +26 -0
  25. data/lib/clacky/locales/i18n.rb +26 -0
  26. data/lib/clacky/locales/zh.rb +26 -0
  27. data/lib/clacky/media/openai_compat.rb +64 -1
  28. data/lib/clacky/message_history.rb +9 -0
  29. data/lib/clacky/rich_ui/components/base_component.rb +50 -0
  30. data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
  31. data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
  32. data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
  33. data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
  34. data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
  35. data/lib/clacky/rich_ui/components/status_view.rb +58 -0
  36. data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
  37. data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
  38. data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
  39. data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
  40. data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
  41. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
  42. data/lib/clacky/rich_ui/view_renderer.rb +291 -0
  43. data/lib/clacky/rich_ui.rb +57 -0
  44. data/lib/clacky/rich_ui_controller.rb +3 -1549
  45. data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
  46. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  47. data/lib/clacky/server/git_panel.rb +115 -0
  48. data/lib/clacky/server/http_server.rb +547 -15
  49. data/lib/clacky/server/server_master.rb +6 -4
  50. data/lib/clacky/server/session_registry.rb +1 -1
  51. data/lib/clacky/shell_hook_loader.rb +1 -1
  52. data/lib/clacky/tools/edit.rb +14 -2
  53. data/lib/clacky/ui2/ui_controller.rb +7 -0
  54. data/lib/clacky/version.rb +1 -1
  55. data/lib/clacky/web/app.css +511 -101
  56. data/lib/clacky/web/app.js +95 -14
  57. data/lib/clacky/web/components/code-editor.js +197 -0
  58. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  59. data/lib/clacky/web/{onboard.js → components/onboard.js} +18 -2
  60. data/lib/clacky/web/core/aside.js +117 -0
  61. data/lib/clacky/web/core/ext.js +387 -0
  62. data/lib/clacky/web/features/backup/store.js +92 -0
  63. data/lib/clacky/web/features/backup/view.js +94 -0
  64. data/lib/clacky/web/features/billing/store.js +163 -0
  65. data/lib/clacky/web/{billing.js → features/billing/view.js} +132 -240
  66. data/lib/clacky/web/features/brand/store.js +110 -0
  67. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  68. data/lib/clacky/web/features/channels/store.js +103 -0
  69. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  70. data/lib/clacky/web/features/creator/store.js +81 -0
  71. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  72. data/lib/clacky/web/features/mcp/store.js +158 -0
  73. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  74. data/lib/clacky/web/features/model-tester/store.js +77 -0
  75. data/lib/clacky/web/features/model-tester/view.js +7 -0
  76. data/lib/clacky/web/features/profile/store.js +170 -0
  77. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  78. data/lib/clacky/web/features/share/store.js +145 -0
  79. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  80. data/lib/clacky/web/features/skills/store.js +331 -0
  81. data/lib/clacky/web/features/skills/view.js +581 -0
  82. data/lib/clacky/web/features/tasks/store.js +135 -0
  83. data/lib/clacky/web/features/tasks/view.js +241 -0
  84. data/lib/clacky/web/features/trash/store.js +242 -0
  85. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  86. data/lib/clacky/web/features/version/store.js +165 -0
  87. data/lib/clacky/web/features/version/view.js +323 -0
  88. data/lib/clacky/web/features/workspace/store.js +99 -0
  89. data/lib/clacky/web/features/workspace/view.js +305 -0
  90. data/lib/clacky/web/i18n.js +68 -6
  91. data/lib/clacky/web/index.html +113 -62
  92. data/lib/clacky/web/sessions.js +493 -39
  93. data/lib/clacky/web/settings.js +143 -49
  94. data/lib/clacky/web/skills.js +3 -863
  95. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  96. data/lib/clacky/web/ws-dispatcher.js +7 -3
  97. data/lib/clacky.rb +17 -1
  98. metadata +81 -20
  99. data/lib/clacky/web/backup.js +0 -119
  100. data/lib/clacky/web/model-tester.js +0 -66
  101. data/lib/clacky/web/tasks.js +0 -373
  102. data/lib/clacky/web/version.js +0 -449
  103. data/lib/clacky/web/workspace.js +0 -316
  104. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  105. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  106. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  107. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: extend-openclacky
3
- description: Customize, fix, override or extend openclacky itself — e.g. change a built-in tool's behavior, intercept/audit/block tool calls with shell scripts, or plug in a new IM channel (Slack, in-house IM, etc.). Trigger on phrases like "patch clacky", "patch openclacky", "change WebSearch behavior", "block dangerous commands", "audit tool use", "add Slack channel", " openclacky 内置", "改 clacky 内置", "monkey patch openclacky", "拦截工具调用". Do NOT trigger for ordinary feature work in the user's own project that doesn't touch openclacky.
3
+ description: Customize, fix, override or extend openclacky itself — change a built-in tool's behavior, intercept/audit/block tool calls, plug in a new IM channel (Slack, in-house IM…), or add UI to the Web UI (panel, button, settings tab). Trigger on "patch openclacky", "block dangerous commands", "audit tool use", "add Slack channel", "extend the web ui", "改 openclacky 内置", "拦截工具调用", "扩展 web 界面". Do NOT trigger for ordinary feature work in the user's own project that doesn't touch openclacky.
4
4
  ---
5
5
 
6
6
  # Extending Openclacky
7
7
 
8
- Openclacky ships three official extension mechanisms that survive `gem update` and never require editing the gem source.
8
+ Openclacky ships four official extension mechanisms that survive `gem update` and never require editing the gem source.
9
9
  **Never tell the user to `bundle show openclacky` and edit the gem — always use one of these.**
10
10
 
11
11
  ## Pick the right mechanism
@@ -15,6 +15,7 @@ Openclacky ships three official extension mechanisms that survive `gem update` a
15
15
  | Change behavior of an **existing method** in openclacky (e.g. `WebSearch#execute` timeout, fix a bug in a built-in tool) | **Patch** | `clacky patch_new <id> "Const#method" -d "<desc>"` | `clacky patch_verify` |
16
16
  | **Audit / block / observe** tool calls (block `rm -rf /`, log every shell command) — no Ruby needed | **Shell Hook** | `clacky hook_new <id> -e <event>` | `clacky hook_verify` |
17
17
  | Plug openclacky into a **new IM platform** (Slack, in-house IM, custom webhook…) | **Channel Adapter** | `clacky channel_new <platform_id>` | `clacky channel_verify` |
18
+ | Add UI to the **Web UI** (custom panel, header button, settings tab, visualize data) | **Web UI Extension** | drop a `.js` file in `~/.clacky/webui_ext/` | reload page; `Clacky.ext.slots()` in console; `?pure=true` to escape |
18
19
 
19
20
  ## Authoritative documentation
20
21
 
@@ -23,14 +24,15 @@ Each mechanism has a full reference doc — read the relevant one with `web_fetc
23
24
  - Patches → https://www.openclacky.com/docs/extend-patches
24
25
  - Shell Hooks → https://www.openclacky.com/docs/extend-shell-hooks
25
26
  - Channel Adapters → https://www.openclacky.com/docs/extend-channel-adapter
27
+ - Web UI Extensions → https://www.openclacky.com/docs/extend-webui
26
28
 
27
29
  ## Execution playbook
28
30
 
29
31
  1. **Identify** which mechanism fits (use the table above; ask if genuinely ambiguous).
30
32
  2. **Read the doc** for that mechanism with `web_fetch`. Don't guess fields, hook events, or required methods — the doc is the contract.
31
- 3. **Run the scaffold** CLI command. It generates the file(s) in `~/.clacky/...` with correct meta.
33
+ 3. **Run the scaffold** CLI command. It generates the file(s) in `~/.clacky/...` with correct meta. *(Web UI Extensions have no scaffold — just create a `.js` file under `~/.clacky/webui_ext/`; the doc shows the `Clacky.ext` contract.)*
32
34
  4. **Edit** the generated file to implement the user's intent. Keep generated meta fields (`target`, `event`, `platform_id`, the `Clacky::ChannelRegistry.register(...)` line, etc.) intact unless the doc says otherwise.
33
- 5. **Verify** with the matching `*_verify` command. Surface any `[FAIL]` lines to the user verbatim.
35
+ 5. **Verify** with the matching `*_verify` command. Surface any `[FAIL]` lines to the user verbatim. *(Web UI Extensions have no verify command — reload the page and confirm the slot rendered; if anything breaks, `?pure=true` disables all extensions instantly.)*
34
36
 
35
37
  ## When NOT to use this skill
36
38
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: media-gen
3
- description: 'Generate images, videos, or audio (text-to-speech) in the current task. Use whenever the user asks to create/generate/produce a picture / image / illustration / cover / poster / icon / artwork, a video / clip / animation, or speech / voiceover / narration / TTS — e.g. 生成图片, 画一张, 做封面, 配图, generate image, make a picture, draw, design a cover, 生成视频, 做个视频, text-to-video, 朗读, 配音, 旁白, 文字转语音, generate speech, voiceover. Also use when a document (slides, poster, README hero) needs an inline image.'
3
+ description: 'Generate or edit images, videos, or audio (text-to-speech) in the current task. Use whenever the user asks to create/generate/produce or edit/modify a picture / image / illustration / cover / poster / icon / artwork, a video / clip / animation, or speech / voiceover / narration / TTS — e.g. generate image, draw, design a cover, edit this image, change the background, text-to-video, generate speech; 画一张, 配图, 编辑图片, 改图, 换背景, 做个视频, 配音, 文字转语音. Also use when a document (slides, poster, README hero) needs an inline image.'
4
4
  disable-model-invocation: false
5
5
  user-invocable: true
6
6
  always-show: true
@@ -8,7 +8,7 @@ always-show: true
8
8
 
9
9
  # media-gen
10
10
 
11
- Generate images on demand by calling the local Clacky HTTP server, which dispatches to whichever image-generation model the user configured (`type=image` in their model settings).
11
+ Generate **and edit** images on demand by calling the local Clacky HTTP server, which dispatches to whichever image-generation model the user configured (`type=image` in their model settings). Editing (image-in → image-out) works with any image model that accepts image input — most current ones do.
12
12
 
13
13
  ## Endpoint
14
14
 
@@ -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,32 @@ 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 | Defaults to the current working directory. The image is saved under `<output_dir>/assets/generated/`. |
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/`. |
78
+ | `image` | no | file path / base64 / data URL | A single input image to **edit**. Triggers image-edit mode (see below). |
79
+ | `images` | no | array of the above | Multiple input images for a multi-image edit. Takes precedence over `image`. |
80
+
81
+ ### Editing an existing image
82
+
83
+ To edit instead of generate from scratch, pass the existing image as `image`
84
+ (a local file path is easiest — the skill reads and encodes it for you) plus a
85
+ `prompt` describing the change. The configured image model receives the
86
+ image alongside the prompt and returns an edited result.
87
+
88
+ ```bash
89
+ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/image \
90
+ -H "Content-Type: application/json" \
91
+ -d '{
92
+ "prompt": "change the background to a starry night sky, keep the cat unchanged",
93
+ "image": "/abs/path/to/input.png"
94
+ }'
95
+ ```
96
+
97
+ - The result is a **new** edited image saved under `assets/generated/` — the
98
+ original file is never modified in place.
99
+ - For combining several inputs (e.g. "put the product from image 1 onto the
100
+ background from image 2"), pass them as `images: ["/path/a.png", "/path/b.png"]`
101
+ and describe the composition in the prompt.
102
+ - Same speed/concurrency rules apply: editing is as slow as generation, one at a time.
77
103
 
78
104
  ### Response shape (success)
79
105
 
@@ -81,7 +107,7 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/ima
81
107
  {
82
108
  "success": true,
83
109
  "image": "/abs/path/to/working_dir/assets/generated/img_20260525_011820_a1b2c3d4.png",
84
- "model": "or-gemini-3-pro-image",
110
+ "model": "<the configured image model>",
85
111
  "provider": "openclacky",
86
112
  "prompt": "A clean, modern hero illustration ...",
87
113
  "aspect_ratio": "landscape",
@@ -145,7 +171,6 @@ When the user gives a vague request like "给我配张图", ask one clarifying q
145
171
 
146
172
  ## When NOT to use this skill
147
173
 
148
- - The user asks to **edit** an existing image (this skill is text-to-image only today)
149
174
  - The user wants a **diagram / chart** with specific data — use a charting library (matplotlib, mermaid, etc.) instead; image gen is for illustrations, not data viz
150
175
  - The user asks for **screenshots** of real software — use the browser tool
151
176
 
@@ -180,7 +205,8 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/vid
180
205
  -d '{
181
206
  "prompt": "A cinematic drone shot flying over a misty mountain range at sunrise, golden light, 4K.",
182
207
  "aspect_ratio": "landscape",
183
- "duration_seconds": 8
208
+ "duration_seconds": 8,
209
+ "output_dir": "'"$(pwd)"'"
184
210
  }'
185
211
  ```
186
212
 
@@ -190,7 +216,7 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/vid
190
216
  | `aspect_ratio` | no | `landscape` / `portrait` | Defaults to `landscape` (16:9). |
191
217
  | `duration_seconds` | no | 4–8 | Defaults to 8. |
192
218
  | `image` | no | `{ "b64_json": "...", "mime_type": "image/png" }` | Optional first frame for image-to-video. |
193
- | `output_dir` | no | absolute path | MP4 saved under `<output_dir>/assets/generated/`. |
219
+ | `output_dir` | yes | absolute path | Always pass `$(pwd)` so files land in the current session workspace. MP4 saved under `<output_dir>/assets/generated/`. |
194
220
 
195
221
  ### Response (success)
196
222
 
@@ -238,7 +264,8 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/aud
238
264
  -H "Content-Type: application/json" \
239
265
  -d '{
240
266
  "input": "Hello and welcome to openclacky. Today we will explore...",
241
- "voice": "Kore"
267
+ "voice": "Kore",
268
+ "output_dir": "'"$(pwd)"'"
242
269
  }'
243
270
  ```
244
271
 
@@ -246,7 +273,7 @@ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/aud
246
273
  |--------------|----------|---------------------------------|-------|
247
274
  | `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:". |
248
275
  | `voice` | no | string voice name | Defaults to `Kore`. Common Gemini voices: `Kore`, `Puck`, `Charon`, `Fenrir`, `Aoede`. |
249
- | `output_dir` | no | absolute path | WAV saved under `<output_dir>/assets/generated/`. |
276
+ | `output_dir` | yes | absolute path | Always pass `$(pwd)` so files land in the current session workspace. WAV saved under `<output_dir>/assets/generated/`. |
250
277
 
251
278
  Generation typically takes 2–10 seconds depending on length. The request
252
279
  blocks until the WAV is ready.
@@ -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)
@@ -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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "faraday"
4
4
  require "json"
5
+ require "base64"
5
6
  require_relative "base"
6
7
 
7
8
  module Clacky
@@ -28,7 +29,7 @@ module Clacky
28
29
  VIDEO_ASPECTS = %w[landscape portrait].freeze
29
30
  DEFAULT_VIDEO_DURATION = 8
30
31
 
31
- def generate_image(prompt:, aspect_ratio: DEFAULT_ASPECT, output_dir: nil, n: 1, **_kwargs)
32
+ def generate_image(prompt:, aspect_ratio: DEFAULT_ASPECT, output_dir: nil, n: 1, image: nil, images: nil, **_kwargs)
32
33
  provider_id = Clacky::Providers.find_by_base_url(@base_url) || "custom"
33
34
  aspect = ASPECT_TO_SIZE.key?(aspect_ratio) ? aspect_ratio : DEFAULT_ASPECT
34
35
  size = ASPECT_TO_SIZE[aspect]
@@ -52,6 +53,18 @@ module Clacky
52
53
  )
53
54
  end
54
55
 
56
+ begin
57
+ input_images = normalize_input_images(image, images)
58
+ rescue ArgumentError => e
59
+ return error_response(
60
+ error: e.message,
61
+ error_type: "invalid_argument",
62
+ provider: provider_id,
63
+ prompt: prompt,
64
+ aspect_ratio: aspect
65
+ )
66
+ end
67
+
55
68
  payload = { model: @model, n: n }
56
69
  if gemini_family?(@model)
57
70
  # Gemini image models (routed via openclacky / openrouter gateway)
@@ -64,6 +77,11 @@ module Clacky
64
77
  payload[:size] = size
65
78
  end
66
79
 
80
+ # With input image(s) this becomes an edit: the gateway forwards them
81
+ # to the model alongside the prompt. Sent as `images` (array) so
82
+ # multi-image edits work; the gateway also accepts a single `image`.
83
+ payload[:images] = input_images unless input_images.empty?
84
+
67
85
  begin
68
86
  response = connection.post("images/generations") do |req|
69
87
  req.headers["Content-Type"] = "application/json"
@@ -306,6 +324,51 @@ module Clacky
306
324
  model_name.to_s.match?(/gemini|imagen/i)
307
325
  end
308
326
 
327
+ # Normalise the optional image/edit inputs into an array of data URLs
328
+ # ("data:<mime>;base64,<payload>") the gateway understands. Each input
329
+ # may be a local file path, a data URL, or a bare base64 string.
330
+ # `images` (array or single) takes precedence over `image`.
331
+ # Raises ArgumentError on a missing file or undecodable input.
332
+ private def normalize_input_images(image, images)
333
+ raw = images.nil? ? image : images
334
+ return [] if raw.nil?
335
+
336
+ list = raw.is_a?(Array) ? raw : [raw]
337
+ list.filter_map do |item|
338
+ s = item.to_s.strip
339
+ next if s.empty?
340
+ to_data_url(s)
341
+ end
342
+ end
343
+
344
+ private def to_data_url(input)
345
+ return input if input.start_with?("data:")
346
+
347
+ # A filesystem path → read and encode.
348
+ if File.file?(input)
349
+ bytes = File.binread(input)
350
+ mime = mime_for_path(input)
351
+ return "data:#{mime};base64,#{Base64.strict_encode64(bytes)}"
352
+ end
353
+
354
+ # Otherwise treat as a bare base64 payload; validate it decodes.
355
+ begin
356
+ Base64.strict_decode64(input)
357
+ rescue ArgumentError
358
+ raise ArgumentError, "input image is neither an existing file path nor valid base64"
359
+ end
360
+ "data:image/png;base64,#{input}"
361
+ end
362
+
363
+ private def mime_for_path(path)
364
+ case File.extname(path).downcase
365
+ when ".jpg", ".jpeg" then "image/jpeg"
366
+ when ".webp" then "image/webp"
367
+ when ".gif" then "image/gif"
368
+ else "image/png"
369
+ end
370
+ end
371
+
309
372
  # base_url is taken verbatim from PRESETS (each provider already
310
373
  # includes the API version segment when needed). We only ensure a
311
374
  # trailing slash so Faraday's relative-path join behaves.
@@ -85,6 +85,15 @@ module Clacky
85
85
  self
86
86
  end
87
87
 
88
+ # Truncate history starting from the user message with the given created_at timestamp.
89
+ # Removes that message and everything after it. Returns self.
90
+ def truncate_from_created_at(created_at)
91
+ idx = @messages.index { |m| m[:role] == "user" && m[:created_at].to_s == created_at.to_s }
92
+ return self unless idx
93
+
94
+ truncate_from(idx)
95
+ end
96
+
88
97
  # Roll back the history to just before the given message object.
89
98
  # Removes the message and anything appended after it.
90
99
  # Used to undo a failed speculative append (e.g. compression message that errored).
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_rich"
4
+
5
+ module Clacky
6
+ module RichUI
7
+ module Components
8
+ # BaseComponent provides shared rendering primitives for RichUI components.
9
+ # Used by sidebar panels and dialogs to eliminate duplicated ANSI-color helpers.
10
+ module BaseComponent
11
+ # Render muted (dim) text commonly used for secondary info
12
+ def muted(text)
13
+ "#{RubyRich::AnsiCode.color(:black, true)}#{text}#{RubyRich::AnsiCode.reset}"
14
+ end
15
+
16
+ # Render colored text with a named color
17
+ def colored(text, color)
18
+ "#{RubyRich::AnsiCode.color(color, true)}#{text}#{RubyRich::AnsiCode.reset}"
19
+ end
20
+
21
+ # Status marker symbol for todo / activity items
22
+ def status_marker(status)
23
+ case status
24
+ when :done, :completed
25
+ colored("✓", :green)
26
+ when :running, :in_progress, :active
27
+ colored("●", :blue)
28
+ when :failed, :error
29
+ colored("!", :red)
30
+ else
31
+ muted("○")
32
+ end
33
+ end
34
+
35
+ # Truncate text to a maximum length, appending "…" when cut
36
+ def truncate(text, limit = 40)
37
+ return "" if text.nil? || text.empty?
38
+
39
+ text.length > limit ? "#{text[0...limit]}…" : text
40
+ end
41
+
42
+ # Theme accessor for future theme switching.
43
+ # Currently defaults to agent_dark; can be overridden per-component.
44
+ def theme
45
+ @theme ||= RubyRich::Theme.agent_dark
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_rich"
4
+ require_relative "../base_component"
5
+
6
+ module Clacky
7
+ module RichUI
8
+ class ApprovalDialog
9
+ include Clacky::RichUI::Components::BaseComponent
10
+
11
+ RISK_LEVELS = {
12
+ low: { label: "Low", color: :green, bar: "●○○○" },
13
+ medium: { label: "Medium", color: :yellow, bar: "●●○○" },
14
+ high: { label: "High", color: :yellow, bar: "●●●○" },
15
+ critical: { label: "Critical", color: :red, bar: "●●●●" }
16
+ }.freeze
17
+
18
+ CATEGORY_COLORS = {
19
+ file: :blue, shell: :yellow, network: :cyan, paid: :magenta
20
+ }.freeze
21
+
22
+ CHOICES = [
23
+ { key: :approve, label: "Approve", color: :green },
24
+ { key: :deny, label: "Deny", color: :red },
25
+ { key: :always_allow, label: "Always allow", color: :cyan }
26
+ ].freeze
27
+
28
+ attr_accessor :width, :height
29
+
30
+ def initialize(tool_name:, message:, params: {}, risk: :medium, category: :file)
31
+ @tool_name = tool_name
32
+ @message = message
33
+ @params = params
34
+ @risk = RISK_LEVELS[risk] || RISK_LEVELS[:medium]
35
+ @category = category
36
+ @category_color = CATEGORY_COLORS[category] || :blue
37
+ @selected_index = 0
38
+ @width = 72
39
+ @height = [params.length + 10, 12].max
40
+ @event_listeners = {}
41
+ @mutex = Mutex.new
42
+ @condition = ConditionVariable.new
43
+ @finished = false
44
+ @result = nil
45
+ @panel = RubyRich::Panel.new("", title: "Approval", border_style: @risk[:color], title_align: :center)
46
+ @layout = RubyRich::Layout.new(name: :approval_dialog, width: @width, height: @height)
47
+ @layout.update_content(@panel)
48
+ @layout.calculate_dimensions(@width, @height)
49
+ wire_keys
50
+ end
51
+
52
+ def finish(value)
53
+ @mutex.synchronize do
54
+ @result = value
55
+ @finished = true
56
+ @condition.signal
57
+ end
58
+ true
59
+ end
60
+
61
+ def wait
62
+ @mutex.synchronize { @condition.wait(@mutex) until @finished }
63
+ @result
64
+ end
65
+
66
+ def key(event_name, priority = 0, &block)
67
+ @event_listeners[event_name] ||= []
68
+ @event_listeners[event_name] << { priority: priority, block: block }
69
+ @event_listeners[event_name].sort_by! { |l| -l[:priority] }
70
+ end
71
+
72
+ def notify_listeners(event_data)
73
+ Array(@event_listeners[event_data[:name]]).each { |l| l[:block].call(event_data, nil) }
74
+ end
75
+
76
+ def render_to_buffer
77
+ @panel.content = render_content
78
+ @layout.calculate_dimensions(@width, @height)
79
+ @layout.render_to_buffer
80
+ end
81
+
82
+ private def wire_keys
83
+ key(:left, 100) { move_selection(-1); true }
84
+ key(:right, 100) { move_selection(1); true }
85
+ key(:string, 100) do |event, _live|
86
+ case event[:value]
87
+ when "h" then move_selection(-1)
88
+ when "l" then move_selection(1)
89
+ end
90
+ true
91
+ end
92
+ key(:enter, 100) do
93
+ sel = CHOICES[@selected_index]
94
+ finish(sel ? sel[:key] : :deny)
95
+ end
96
+ key(:escape, 100) { finish(:deny) }
97
+ key(:ctrl_c, 100) { finish(:deny) }
98
+ end
99
+
100
+ def move_selection(delta)
101
+ @selected_index = (@selected_index + delta) % CHOICES.length
102
+ end
103
+
104
+ def render_content
105
+ risk = @risk
106
+ lines = []
107
+ lines << ""
108
+ lines << " #{colored("Tool:", :body)} #{colored(@tool_name, :accent)} #{category_badge}"
109
+ lines << " #{colored("Risk:", :body)} #{colored(risk[:label], risk[:color])} #{colored(risk[:bar], risk[:color])}"
110
+ lines << " #{colored("Info:", :body)} #{colored(@message, :body)}"
111
+
112
+ unless @params.empty?
113
+ lines << ""
114
+ @params.each do |key, value|
115
+ val = value.to_s
116
+ val = "#{val[0..50]}..." if val.length > 54
117
+ lines << " #{muted("#{key}:")} #{colored(val, :body)}"
118
+ end
119
+ end
120
+
121
+ lines << ""
122
+ lines << render_choices
123
+ lines << ""
124
+ lines.join("\n")
125
+ end
126
+
127
+ def render_choices
128
+ CHOICES.each_with_index.map do |choice, i|
129
+ selected = i == @selected_index
130
+ prefix = selected ? "#{RubyRich::AnsiCode.color(:cyan, true)}➜#{RubyRich::AnsiCode.reset}" : " "
131
+ label = selected ? colored(choice[:label], choice[:color]) : muted(choice[:label])
132
+ "#{prefix} [#{label}]"
133
+ end.join(" ")
134
+ end
135
+
136
+ def category_badge
137
+ label = @category.to_s.capitalize
138
+ colored("[#{label}]", @category_color)
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_rich"
4
+ require_relative "../base_component"
5
+
6
+ module Clacky
7
+ module RichUI
8
+ class ConfigMenuDialog
9
+ include Clacky::RichUI::Components::BaseComponent
10
+
11
+ attr_accessor :width, :height
12
+
13
+ def initialize(choices:, selected_index: 0, title: "Model Configuration", width: 86)
14
+ @choices = choices
15
+ @selected_index = selected_index
16
+ @width = width
17
+ @height = [choices.length + 7, 12].max
18
+ @event_listeners = {}
19
+ @mutex = Mutex.new
20
+ @condition = ConditionVariable.new
21
+ @finished = false
22
+ @result = nil
23
+ @panel = RubyRich::Panel.new("", title: title, border_style: :cyan, title_align: :center)
24
+ @layout = RubyRich::Layout.new(name: :config_dialog, width: @width, height: @height)
25
+ @layout.update_content(@panel)
26
+ @layout.calculate_dimensions(@width, @height)
27
+ end
28
+
29
+ def selected_choice
30
+ @choices[@selected_index]
31
+ end
32
+
33
+ def move_up
34
+ move(-1)
35
+ end
36
+
37
+ def move_down
38
+ move(1)
39
+ end
40
+
41
+ def finish(value)
42
+ @mutex.synchronize do
43
+ @result = value
44
+ @finished = true
45
+ @condition.signal
46
+ end
47
+ true
48
+ end
49
+
50
+ def wait
51
+ @mutex.synchronize { @condition.wait(@mutex) until @finished }
52
+ @result
53
+ end
54
+
55
+ def key(event_name, priority = 0, &block)
56
+ @event_listeners[event_name] ||= []
57
+ @event_listeners[event_name] << { priority: priority, block: block }
58
+ @event_listeners[event_name].sort_by! { |listener| -listener[:priority] }
59
+ end
60
+
61
+ def notify_listeners(event_data)
62
+ Array(@event_listeners[event_data[:name]]).each { |listener| listener[:block].call(event_data, nil) }
63
+ end
64
+
65
+ def render_to_buffer
66
+ @panel.content = render_content
67
+ @layout.calculate_dimensions(@width, @height)
68
+ @layout.render_to_buffer
69
+ end
70
+
71
+ def move(delta)
72
+ return if @choices.empty?
73
+
74
+ index = @selected_index
75
+ loop do
76
+ index = (index + delta) % @choices.length
77
+ break unless @choices[index][:disabled]
78
+ break if index == @selected_index
79
+ end
80
+ @selected_index = index
81
+ end
82
+
83
+ def render_content
84
+ lines = [""]
85
+ @choices.each_with_index do |choice, index|
86
+ lines << choice_line(choice, selected: index == @selected_index)
87
+ end
88
+ lines << ""
89
+ lines << "#{muted("↑↓/jk: Navigate")} • #{muted("Enter: Select")} • #{muted("Esc/q: Cancel")}"
90
+ lines.join("\n")
91
+ end
92
+
93
+ def choice_line(choice, selected:)
94
+ return " #{muted(choice[:label])}" if choice[:disabled]
95
+
96
+ prefix = selected ? "#{RubyRich::AnsiCode.color(:cyan, true)}➜#{RubyRich::AnsiCode.reset} " : " "
97
+ label = selected ? RubyRich::AnsiCode.color(:white, true) + choice[:label] + RubyRich::AnsiCode.reset : choice[:label]
98
+ "#{prefix}#{label}"
99
+ end
100
+
101
+ private :move,
102
+ :render_content,
103
+ :choice_line
104
+ end
105
+ end
106
+ end