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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/Dockerfile +3 -0
- data/README.md +1 -1
- data/README_JA.md +237 -0
- data/docs/rich_ui_guide.md +277 -0
- data/docs/rich_ui_refactor_plan.md +396 -0
- data/lib/clacky/agent/llm_caller.rb +10 -4
- data/lib/clacky/agent/session_serializer.rb +52 -7
- data/lib/clacky/agent/time_machine.rb +247 -26
- data/lib/clacky/agent.rb +15 -3
- data/lib/clacky/api_extension.rb +262 -0
- data/lib/clacky/api_extension_loader.rb +156 -0
- data/lib/clacky/cli.rb +93 -3
- data/lib/clacky/client.rb +38 -13
- data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
- data/lib/clacky/default_agents/coding/profile.yml +3 -0
- data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
- data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
- data/lib/clacky/default_skills/media-gen/SKILL.md +37 -10
- data/lib/clacky/idle_compression_timer.rb +3 -1
- data/lib/clacky/locales/en.rb +26 -0
- data/lib/clacky/locales/i18n.rb +26 -0
- data/lib/clacky/locales/zh.rb +26 -0
- data/lib/clacky/media/openai_compat.rb +64 -1
- data/lib/clacky/message_history.rb +9 -0
- data/lib/clacky/rich_ui/components/base_component.rb +50 -0
- data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
- data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
- data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
- data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
- data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
- data/lib/clacky/rich_ui/components/status_view.rb +58 -0
- data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
- data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
- data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
- data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
- data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
- data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
- data/lib/clacky/rich_ui/view_renderer.rb +291 -0
- data/lib/clacky/rich_ui.rb +57 -0
- data/lib/clacky/rich_ui_controller.rb +3 -1549
- data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
- data/lib/clacky/server/channel/channel_manager.rb +26 -0
- data/lib/clacky/server/git_panel.rb +115 -0
- data/lib/clacky/server/http_server.rb +547 -15
- data/lib/clacky/server/server_master.rb +6 -4
- data/lib/clacky/server/session_registry.rb +1 -1
- data/lib/clacky/shell_hook_loader.rb +1 -1
- data/lib/clacky/tools/edit.rb +14 -2
- data/lib/clacky/ui2/ui_controller.rb +7 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +511 -101
- data/lib/clacky/web/app.js +95 -14
- data/lib/clacky/web/components/code-editor.js +197 -0
- data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
- data/lib/clacky/web/{onboard.js → components/onboard.js} +18 -2
- data/lib/clacky/web/core/aside.js +117 -0
- data/lib/clacky/web/core/ext.js +387 -0
- data/lib/clacky/web/features/backup/store.js +92 -0
- data/lib/clacky/web/features/backup/view.js +94 -0
- data/lib/clacky/web/features/billing/store.js +163 -0
- data/lib/clacky/web/{billing.js → features/billing/view.js} +132 -240
- data/lib/clacky/web/features/brand/store.js +110 -0
- data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
- data/lib/clacky/web/features/channels/store.js +103 -0
- data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
- data/lib/clacky/web/features/creator/store.js +81 -0
- data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
- data/lib/clacky/web/features/mcp/store.js +158 -0
- data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
- data/lib/clacky/web/features/model-tester/store.js +77 -0
- data/lib/clacky/web/features/model-tester/view.js +7 -0
- data/lib/clacky/web/features/profile/store.js +170 -0
- data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
- data/lib/clacky/web/features/share/store.js +145 -0
- data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
- data/lib/clacky/web/features/skills/store.js +331 -0
- data/lib/clacky/web/features/skills/view.js +581 -0
- data/lib/clacky/web/features/tasks/store.js +135 -0
- data/lib/clacky/web/features/tasks/view.js +241 -0
- data/lib/clacky/web/features/trash/store.js +242 -0
- data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
- data/lib/clacky/web/features/version/store.js +165 -0
- data/lib/clacky/web/features/version/view.js +323 -0
- data/lib/clacky/web/features/workspace/store.js +99 -0
- data/lib/clacky/web/features/workspace/view.js +305 -0
- data/lib/clacky/web/i18n.js +68 -6
- data/lib/clacky/web/index.html +113 -62
- data/lib/clacky/web/sessions.js +493 -39
- data/lib/clacky/web/settings.js +143 -49
- data/lib/clacky/web/skills.js +3 -863
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
- data/lib/clacky/web/ws-dispatcher.js +7 -3
- data/lib/clacky.rb +17 -1
- metadata +81 -20
- data/lib/clacky/web/backup.js +0 -119
- data/lib/clacky/web/model-tester.js +0 -66
- data/lib/clacky/web/tasks.js +0 -373
- data/lib/clacky/web/version.js +0 -449
- data/lib/clacky/web/workspace.js +0 -316
- /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
- /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
- /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
- /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 —
|
|
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
|
|
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.
|
|
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` |
|
|
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": "
|
|
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` |
|
|
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` |
|
|
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.
|
|
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
|