openclacky 1.3.4 → 1.3.6
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 +39 -0
- data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
- data/lib/clacky/agent/message_compressor.rb +32 -8
- data/lib/clacky/agent/message_compressor_helper.rb +113 -12
- data/lib/clacky/agent/session_serializer.rb +3 -2
- data/lib/clacky/agent/tool_executor.rb +0 -12
- data/lib/clacky/agent.rb +74 -12
- data/lib/clacky/api_extension.rb +81 -0
- data/lib/clacky/api_extension_loader.rb +13 -1
- data/lib/clacky/cli.rb +0 -1
- data/lib/clacky/client.rb +14 -17
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +22 -0
- data/lib/clacky/default_agents/base_prompt.md +1 -0
- data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
- data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
- data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
- data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
- data/lib/clacky/default_skills/media-gen/SKILL.md +63 -0
- data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
- data/lib/clacky/json_ui_controller.rb +1 -1
- data/lib/clacky/media/base.rb +60 -0
- data/lib/clacky/media/dashscope.rb +385 -21
- data/lib/clacky/media/gemini.rb +9 -0
- data/lib/clacky/media/generator.rb +52 -0
- data/lib/clacky/media/openai_compat.rb +166 -0
- data/lib/clacky/null_ui_controller.rb +13 -0
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/providers.rb +50 -2
- data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
- data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
- data/lib/clacky/server/http_server.rb +260 -122
- data/lib/clacky/server/session_registry.rb +54 -3
- data/lib/clacky/server/web_ui_controller.rb +3 -2
- data/lib/clacky/session_manager.rb +35 -4
- data/lib/clacky/skill_loader.rb +14 -2
- data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
- data/lib/clacky/tools/terminal.rb +0 -43
- data/lib/clacky/ui2/components/modal_component.rb +1 -1
- data/lib/clacky/ui2/layout_manager.rb +0 -5
- data/lib/clacky/ui2/progress_handle.rb +0 -3
- data/lib/clacky/ui2/ui_controller.rb +140 -40
- data/lib/clacky/ui_interface.rb +10 -1
- data/lib/clacky/utils/encoding.rb +25 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +199 -23
- data/lib/clacky/web/components/onboard.js +1 -14
- data/lib/clacky/web/components/sidebar.js +1 -3
- data/lib/clacky/web/features/backup/store.js +23 -0
- data/lib/clacky/web/features/backup/view.js +49 -22
- data/lib/clacky/web/features/brand/view.js +8 -5
- data/lib/clacky/web/features/channels/store.js +1 -20
- data/lib/clacky/web/features/mcp/store.js +1 -20
- data/lib/clacky/web/features/profile/store.js +1 -13
- data/lib/clacky/web/features/profile/view.js +16 -4
- data/lib/clacky/web/features/skills/store.js +6 -21
- data/lib/clacky/web/features/tasks/view.js +77 -28
- data/lib/clacky/web/features/version/store.js +2 -0
- data/lib/clacky/web/i18n.js +46 -3
- data/lib/clacky/web/index.html +67 -26
- data/lib/clacky/web/sessions.js +177 -87
- data/lib/clacky/web/settings.js +34 -2
- data/lib/clacky/web/ws-dispatcher.js +12 -4
- data/lib/clacky.rb +12 -5
- metadata +8 -1
data/lib/clacky/cli.rb
CHANGED
|
@@ -654,7 +654,6 @@ module Clacky
|
|
|
654
654
|
|
|
655
655
|
# Handle agent error/interrupt with cleanup
|
|
656
656
|
def handle_agent_exception(ui_controller, agent, session_manager, exception)
|
|
657
|
-
Clacky::Logger.warn("[ph_debug] handle_agent_exception", klass: exception.class.name, msg: exception.message.to_s[0, 200])
|
|
658
657
|
ui_controller.show_progress(phase: "done")
|
|
659
658
|
ui_controller.set_idle_status
|
|
660
659
|
|
data/lib/clacky/client.rb
CHANGED
|
@@ -34,11 +34,6 @@ module Clacky
|
|
|
34
34
|
# some OpenRouter-compatible relays only honour Bearer — send both).
|
|
35
35
|
@provider_id = provider_id
|
|
36
36
|
|
|
37
|
-
# Determine vision support once at construction time.
|
|
38
|
-
# Non-vision models (DeepSeek, Kimi, MiniMax, etc.) reject image_url
|
|
39
|
-
# content blocks; the conversion layer strips them when this is false.
|
|
40
|
-
@vision_supported = Providers.supports?(provider_id, :vision, model_name: @model)
|
|
41
|
-
|
|
42
37
|
# Optional override for Faraday read_timeout (e.g. benchmark calls).
|
|
43
38
|
# nil means use the default (300s for streaming).
|
|
44
39
|
@read_timeout = read_timeout
|
|
@@ -343,9 +338,12 @@ module Clacky
|
|
|
343
338
|
# OpenRouter proxies Claude with the same cache_control field convention as Anthropic direct.
|
|
344
339
|
messages = apply_message_caching(messages) if caching_enabled
|
|
345
340
|
|
|
341
|
+
# Vision support is resolved against the request's actual model (which may
|
|
342
|
+
# differ from @model after a runtime switch or fallback override), so the
|
|
343
|
+
# conversion layer strips image_url blocks for non-vision models.
|
|
346
344
|
body = MessageFormat::OpenAI.build_request_body(
|
|
347
345
|
messages, model, tools, max_tokens, caching_enabled,
|
|
348
|
-
vision_supported: @
|
|
346
|
+
vision_supported: Providers.supports?(@provider_id, :vision, model_name: model),
|
|
349
347
|
reasoning_effort: reasoning_effort
|
|
350
348
|
)
|
|
351
349
|
return send_openai_stream_request(body, on_chunk) if on_chunk
|
|
@@ -641,35 +639,34 @@ module Clacky
|
|
|
641
639
|
raise InsufficientCreditError.new(
|
|
642
640
|
"[LLM] #{I18n.t("llm.error.insufficient_credit")}",
|
|
643
641
|
error_code: "insufficient_credit",
|
|
644
|
-
provider_id: @provider_id
|
|
642
|
+
provider_id: @provider_id,
|
|
643
|
+
raw_message: error_message
|
|
645
644
|
)
|
|
646
645
|
end
|
|
647
646
|
|
|
648
647
|
case response.status
|
|
649
648
|
when 400
|
|
650
|
-
# Well-behaved APIs (Anthropic, OpenAI) never put quota/availability issues in 400.
|
|
651
|
-
# However, some proxy/relay providers do — so we inspect the message first.
|
|
652
|
-
# Also, Bedrock returns ThrottlingException as 400 instead of 429.
|
|
653
649
|
if error_message.match?(/ThrottlingException|unavailable|quota/i)
|
|
654
650
|
raise RetryableError, "[LLM] #{I18n.t("llm.error.rate_limit_400")}"
|
|
655
651
|
end
|
|
656
652
|
|
|
657
|
-
# True bad request — our message was malformed. Roll back history so the
|
|
658
|
-
# broken message is not replayed on the next user turn.
|
|
659
653
|
raise BadRequestError.new(
|
|
660
654
|
"[LLM] Client request error: #{error_message}",
|
|
661
|
-
display_message: "[LLM] #{I18n.t("llm.error.bad_request")}"
|
|
655
|
+
display_message: "[LLM] #{I18n.t("llm.error.bad_request")}",
|
|
656
|
+
raw_message: error_message
|
|
662
657
|
)
|
|
663
|
-
when 401
|
|
658
|
+
when 401
|
|
659
|
+
raise AgentError.new("[LLM] #{I18n.t("llm.error.invalid_api_key")}", raw_message: error_message)
|
|
664
660
|
when 403
|
|
665
661
|
i18n_key = "llm.error.403.#{error_code}"
|
|
666
662
|
translated = I18n.t(i18n_key)
|
|
667
663
|
translated = I18n.t("llm.error.403.default") if translated == i18n_key
|
|
668
|
-
raise AgentError
|
|
669
|
-
when 404
|
|
664
|
+
raise AgentError.new("[LLM] #{translated}", raw_message: error_message)
|
|
665
|
+
when 404
|
|
666
|
+
raise AgentError.new("[LLM] #{I18n.t("llm.error.endpoint_not_found")}", raw_message: error_message)
|
|
670
667
|
when 429 then raise RetryableError, "[LLM] #{I18n.t("llm.error.rate_limit_429")}"
|
|
671
668
|
when 500..599 then raise RetryableError, "[LLM] #{I18n.t("llm.error.server_error", status: response.status)}"
|
|
672
|
-
else raise AgentError
|
|
669
|
+
else raise AgentError.new("[LLM] #{I18n.t("llm.error.unexpected", status: response.status)}", raw_message: error_message)
|
|
673
670
|
end
|
|
674
671
|
end
|
|
675
672
|
|
|
@@ -19,6 +19,25 @@
|
|
|
19
19
|
(() => {
|
|
20
20
|
if (!window.Clacky || !Clacky.ext) return;
|
|
21
21
|
|
|
22
|
+
// The currently mounted panel's state, refreshed on every mount. A single WS
|
|
23
|
+
// hook (registered once below) reloads it when the active session completes a
|
|
24
|
+
// task, so new snapshots appear without a manual refresh. Kept as a closure
|
|
25
|
+
// singleton because WS.onEvent has no unsubscribe and the panel re-mounts on
|
|
26
|
+
// each session switch.
|
|
27
|
+
let _activeState = null;
|
|
28
|
+
let _wsHooked = false;
|
|
29
|
+
|
|
30
|
+
function _hookWs() {
|
|
31
|
+
if (_wsHooked || typeof WS === "undefined") return;
|
|
32
|
+
_wsHooked = true;
|
|
33
|
+
WS.onEvent((ev) => {
|
|
34
|
+
if (ev && ev.type === "complete" && _activeState &&
|
|
35
|
+
ev.session_id === _activeState.sessionId) {
|
|
36
|
+
loadHistory(_activeState);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
22
41
|
const t = (k, fallback) => {
|
|
23
42
|
const v = (typeof I18n !== "undefined") ? I18n.t(k) : null;
|
|
24
43
|
return (v && v !== k) ? v : fallback;
|
|
@@ -630,6 +649,9 @@
|
|
|
630
649
|
d.mask.onclick = onMaskClick;
|
|
631
650
|
document.addEventListener("keydown", onKey);
|
|
632
651
|
|
|
652
|
+
_activeState = state;
|
|
653
|
+
_hookWs();
|
|
654
|
+
|
|
633
655
|
loadHistory(state);
|
|
634
656
|
return root;
|
|
635
657
|
}, {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
- **ALWAYS use `glob` tool to find files — NEVER use shell `find` command for file discovery**
|
|
11
11
|
- **All operations default to the working directory** (shown in session context)
|
|
12
|
+
- **NEVER write tool calls as text** (e.g. `<invoke name=...>`, `<function_calls>`). Use the structured tool_calls field — text won't execute.
|
|
12
13
|
|
|
13
14
|
## Response Style
|
|
14
15
|
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "base64"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
|
|
8
|
+
# Meeting Extension — real-time transcription, on-demand agent Q&A, and
|
|
9
|
+
# post-meeting summarization. Mounted at /api/ext/meeting/.
|
|
10
|
+
class MeetingExt < Clacky::ApiExtension
|
|
11
|
+
timeout 30
|
|
12
|
+
|
|
13
|
+
MEETINGS_ROOT = File.expand_path("~/.clacky/meetings")
|
|
14
|
+
VOCABULARY_PATH = File.join(MEETINGS_ROOT, "vocabulary.txt")
|
|
15
|
+
DEFAULT_VOCABULARY = "Clacky, OpenClacky, openclacky"
|
|
16
|
+
|
|
17
|
+
# annotate is a read-only analysis: block every side-effecting tool so the
|
|
18
|
+
# forked subagent can only read/think, never write files, run commands,
|
|
19
|
+
# spawn more work, or prompt the user.
|
|
20
|
+
WRITE_TOOLS = %w[write edit terminal trash_manager invoke_skill request_user_feedback browser].freeze
|
|
21
|
+
|
|
22
|
+
# ── Vocabulary (STT biasing hints) ────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
# GET /api/ext/meeting/vocabulary
|
|
25
|
+
get "/vocabulary" do
|
|
26
|
+
json(vocabulary: read_vocabulary)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# POST /api/ext/meeting/vocabulary
|
|
30
|
+
# body: { vocabulary }
|
|
31
|
+
post "/vocabulary" do
|
|
32
|
+
text = json_body["vocabulary"].to_s.strip
|
|
33
|
+
FileUtils.mkdir_p(MEETINGS_ROOT)
|
|
34
|
+
File.write(VOCABULARY_PATH, text)
|
|
35
|
+
json(ok: true, vocabulary: text)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
# POST /api/ext/meeting/start
|
|
41
|
+
# body: { session_id }
|
|
42
|
+
# Creates a new meeting tied to the current session.
|
|
43
|
+
post "/start" do
|
|
44
|
+
sid = json_body["session_id"]
|
|
45
|
+
error!("session_id required", status: 422) unless sid && !sid.empty?
|
|
46
|
+
|
|
47
|
+
meeting_id = "mtg-#{Time.now.strftime('%Y%m%d-%H%M%S')}-#{SecureRandom.hex(4)}"
|
|
48
|
+
dir = File.join(MEETINGS_ROOT, sid, meeting_id)
|
|
49
|
+
FileUtils.mkdir_p(dir)
|
|
50
|
+
|
|
51
|
+
meta = { session_id: sid, meeting_id: meeting_id, started_at: Time.now.utc.iso8601 }
|
|
52
|
+
File.write(File.join(dir, "meta.json"), JSON.pretty_generate(meta))
|
|
53
|
+
File.write(File.join(dir, "transcript.jsonl"), "")
|
|
54
|
+
|
|
55
|
+
json(meeting_id: meeting_id, dir: dir)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# POST /api/ext/meeting/end
|
|
59
|
+
# body: { session_id, meeting_id }
|
|
60
|
+
# Finalizes the meeting and triggers summarization via the session agent.
|
|
61
|
+
post "/end" do
|
|
62
|
+
sid, mid = json_body.values_at("session_id", "meeting_id")
|
|
63
|
+
error!("session_id and meeting_id required", status: 422) unless sid && mid
|
|
64
|
+
|
|
65
|
+
dir = meeting_dir(sid, mid)
|
|
66
|
+
error!("meeting not found", status: 404) unless File.directory?(dir)
|
|
67
|
+
|
|
68
|
+
meta_path = File.join(dir, "meta.json")
|
|
69
|
+
meta = JSON.parse(File.read(meta_path))
|
|
70
|
+
meta["ended_at"] = Time.now.utc.iso8601
|
|
71
|
+
File.write(meta_path, JSON.pretty_generate(meta))
|
|
72
|
+
|
|
73
|
+
transcript_path = File.join(dir, "transcript.jsonl")
|
|
74
|
+
lines = File.readlines(transcript_path).map { |l| JSON.parse(l)["text"] }.reject(&:empty?)
|
|
75
|
+
transcript = lines.join("\n")
|
|
76
|
+
|
|
77
|
+
logger.info("end: sid=#{sid} mid=#{mid} lines=#{lines.size} transcript_len=#{transcript.length}")
|
|
78
|
+
|
|
79
|
+
if transcript.strip.empty?
|
|
80
|
+
logger.warn("end: transcript is empty, skipping summarization")
|
|
81
|
+
json(ok: true, meeting_id: mid, skipped: true)
|
|
82
|
+
next
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
prompt = <<~PROMPT
|
|
86
|
+
A meeting just ended. Invoke the "meeting-summarizer" skill to generate the meeting minutes from the transcript below.
|
|
87
|
+
|
|
88
|
+
Transcript:
|
|
89
|
+
#{transcript}
|
|
90
|
+
PROMPT
|
|
91
|
+
|
|
92
|
+
begin
|
|
93
|
+
submit_task(sid, prompt, display_message: "🛑 Meeting ended — generating meeting minutes…")
|
|
94
|
+
logger.info("end: submit_task succeeded sid=#{sid}")
|
|
95
|
+
rescue => e
|
|
96
|
+
logger.error("end: submit_task failed sid=#{sid} error=#{e.message}")
|
|
97
|
+
json(ok: false, meeting_id: mid, error: e.message)
|
|
98
|
+
next
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
json(ok: true, meeting_id: mid)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# ── Transcription ─────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
# POST /api/ext/meeting/transcribe
|
|
107
|
+
# body: { session_id, meeting_id, audio_base64, format: "wav" }
|
|
108
|
+
# Sends audio chunk to LLM proxy for STT, appends result to transcript.
|
|
109
|
+
post "/transcribe" do
|
|
110
|
+
sid, mid = json_body.values_at("session_id", "meeting_id")
|
|
111
|
+
audio_b64 = json_body["audio_base64"]
|
|
112
|
+
error!("session_id, meeting_id, audio_base64 required", status: 422) unless sid && mid && audio_b64
|
|
113
|
+
|
|
114
|
+
dir = meeting_dir(sid, mid)
|
|
115
|
+
error!("meeting not found", status: 404) unless File.directory?(dir)
|
|
116
|
+
|
|
117
|
+
mime = json_body["mime_type"].to_s.split(";").first.strip
|
|
118
|
+
mime = "audio/webm" if mime.empty?
|
|
119
|
+
vocabulary = json_body["vocabulary"].to_s.strip
|
|
120
|
+
result = call_stt(audio_b64, mime, vocabulary)
|
|
121
|
+
|
|
122
|
+
if result["success"]
|
|
123
|
+
text = result["text"].to_s.strip
|
|
124
|
+
unless text.empty?
|
|
125
|
+
entry = { ts: Time.now.utc.iso8601, text: text }
|
|
126
|
+
File.open(File.join(dir, "transcript.jsonl"), "a") { |f| f.puts(JSON.generate(entry)) }
|
|
127
|
+
end
|
|
128
|
+
json(text: text)
|
|
129
|
+
else
|
|
130
|
+
error!(result["error"] || "STT failed", status: 502)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# ── Agent Q&A (when @-mentioned) ─────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
# POST /api/ext/meeting/ask
|
|
137
|
+
# body: { session_id, meeting_id, question }
|
|
138
|
+
# Submits a question to the session agent with recent transcript as context.
|
|
139
|
+
post "/ask" do
|
|
140
|
+
sid, mid = json_body.values_at("session_id", "meeting_id")
|
|
141
|
+
question = json_body["question"]
|
|
142
|
+
error!("session_id, meeting_id, question required", status: 422) unless sid && mid && question
|
|
143
|
+
|
|
144
|
+
dir = meeting_dir(sid, mid)
|
|
145
|
+
error!("meeting not found", status: 404) unless File.directory?(dir)
|
|
146
|
+
|
|
147
|
+
context = recent_transcript(dir, minutes: 5)
|
|
148
|
+
|
|
149
|
+
prompt = <<~PROMPT
|
|
150
|
+
[Meeting Mode] You are in a team meeting and have been called on to speak. Based on the recent transcript below, answer the question concisely.
|
|
151
|
+
Keep it short — one or two sentences. Do not elaborate at length.
|
|
152
|
+
|
|
153
|
+
--- Recent Transcript ---
|
|
154
|
+
#{context}
|
|
155
|
+
|
|
156
|
+
--- Question ---
|
|
157
|
+
#{question}
|
|
158
|
+
PROMPT
|
|
159
|
+
|
|
160
|
+
submit_task(sid, prompt, display_message: "🎤 #{question}")
|
|
161
|
+
json(ok: true)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# ── Annotation (periodic background tagging) ──────────────────────────────
|
|
165
|
+
|
|
166
|
+
# POST /api/ext/meeting/annotate
|
|
167
|
+
# body: { session_id, meeting_id }
|
|
168
|
+
# Analyzes recent transcript and returns tags (decisions, actions, AI-answerable).
|
|
169
|
+
# Runs as a one-off side LLM call — it must NOT enter the session, otherwise
|
|
170
|
+
# its raw JSON would pollute the chat transcript.
|
|
171
|
+
post "/annotate" do
|
|
172
|
+
sid, mid = json_body.values_at("session_id", "meeting_id")
|
|
173
|
+
error!("session_id and meeting_id required", status: 422) unless sid && mid
|
|
174
|
+
|
|
175
|
+
dir = meeting_dir(sid, mid)
|
|
176
|
+
error!("meeting not found", status: 404) unless File.directory?(dir)
|
|
177
|
+
|
|
178
|
+
context = recent_transcript(dir, minutes: 2)
|
|
179
|
+
next json(annotations: []) if context.strip.empty?
|
|
180
|
+
|
|
181
|
+
prompt = <<~PROMPT
|
|
182
|
+
Analyze the following meeting transcript excerpt and identify:
|
|
183
|
+
1. Decisions (something someone decided)
|
|
184
|
+
2. Action Items (a task assigned to someone)
|
|
185
|
+
3. AI-answerable questions (technical/factual questions asked but not yet answered)
|
|
186
|
+
|
|
187
|
+
Output a JSON array only, no prose, no code fences. Each item:
|
|
188
|
+
{"type":"decision|action|question","text":"...","speaker":"..."}
|
|
189
|
+
If none found, output [].
|
|
190
|
+
|
|
191
|
+
Transcript:
|
|
192
|
+
#{context}
|
|
193
|
+
PROMPT
|
|
194
|
+
|
|
195
|
+
result = dispatch_to_session(sid, prompt, model: "lite", forbidden_tools: WRITE_TOOLS)
|
|
196
|
+
next json(annotations: [], busy: true) if result[:busy]
|
|
197
|
+
|
|
198
|
+
json(annotations: parse_annotations(result[:text].to_s))
|
|
199
|
+
rescue Clacky::ApiExtension::Halt
|
|
200
|
+
raise
|
|
201
|
+
rescue StandardError => e
|
|
202
|
+
logger.error("annotate failed: #{e.message}")
|
|
203
|
+
json(annotations: [])
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# ── Transcript retrieval ──────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
# GET /api/ext/meeting/transcript/:session_id/:meeting_id
|
|
209
|
+
get "/transcript/:session_id/:meeting_id" do
|
|
210
|
+
sid = params[:session_id]
|
|
211
|
+
mid = params[:meeting_id]
|
|
212
|
+
dir = meeting_dir(sid, mid)
|
|
213
|
+
error!("meeting not found", status: 404) unless File.directory?(dir)
|
|
214
|
+
|
|
215
|
+
path = File.join(dir, "transcript.jsonl")
|
|
216
|
+
lines = File.exist?(path) ? File.readlines(path).map { |l| JSON.parse(l) } : []
|
|
217
|
+
json(transcript: lines)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# GET /api/ext/meeting/active/:session_id
|
|
221
|
+
# Returns the most recent in-progress meeting (no ended_at) for the session,
|
|
222
|
+
# so a page refresh can restore the live captions instead of losing them.
|
|
223
|
+
get "/active/:session_id" do
|
|
224
|
+
sid = params[:session_id]
|
|
225
|
+
session_root = File.join(MEETINGS_ROOT, sid)
|
|
226
|
+
next json(active: false) unless File.directory?(session_root)
|
|
227
|
+
|
|
228
|
+
dir = active_meeting_dir(session_root)
|
|
229
|
+
next json(active: false) unless dir
|
|
230
|
+
|
|
231
|
+
mid = File.basename(dir)
|
|
232
|
+
path = File.join(dir, "transcript.jsonl")
|
|
233
|
+
lines = File.exist?(path) ? File.readlines(path).map { |l| JSON.parse(l) } : []
|
|
234
|
+
json(active: true, meeting_id: mid, transcript: lines)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# POST /api/ext/meeting/speak
|
|
238
|
+
# body: { text, voice? }
|
|
239
|
+
# Synthesizes speech from text and returns it as base64 for the browser to play.
|
|
240
|
+
post "/speak" do
|
|
241
|
+
text = json_body["text"].to_s.strip
|
|
242
|
+
error!("text required", status: 422) if text.empty?
|
|
243
|
+
|
|
244
|
+
voice = json_body["voice"].to_s.strip
|
|
245
|
+
voice = nil if voice.empty?
|
|
246
|
+
|
|
247
|
+
Dir.mktmpdir("meeting-tts") do |tmp|
|
|
248
|
+
result = Clacky::Media::Generator.new(agent_config).generate_speech(
|
|
249
|
+
input: text,
|
|
250
|
+
voice: voice,
|
|
251
|
+
output_dir: tmp
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
error!(result["error"] || "TTS failed", status: 502) unless result["success"]
|
|
255
|
+
|
|
256
|
+
path = result["audio"]
|
|
257
|
+
error!("TTS produced no audio", status: 502) unless path && File.exist?(path)
|
|
258
|
+
|
|
259
|
+
audio_b64 = Base64.strict_encode64(File.binread(path))
|
|
260
|
+
mime = result["mime_type"] || "audio/wav"
|
|
261
|
+
json(audio_base64: audio_b64, mime_type: mime)
|
|
262
|
+
end
|
|
263
|
+
rescue Clacky::ApiExtension::Halt
|
|
264
|
+
raise
|
|
265
|
+
rescue StandardError => e
|
|
266
|
+
logger.error("TTS call failed: #{e.message}")
|
|
267
|
+
error!(e.message, status: 502)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
private def meeting_dir(session_id, meeting_id)
|
|
271
|
+
File.join(MEETINGS_ROOT, session_id, meeting_id)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
private def active_meeting_dir(session_root)
|
|
275
|
+
Dir.children(session_root)
|
|
276
|
+
.map { |name| File.join(session_root, name) }
|
|
277
|
+
.select { |d| File.directory?(d) && File.exist?(File.join(d, "meta.json")) }
|
|
278
|
+
.reject { |d| (JSON.parse(File.read(File.join(d, "meta.json"))) rescue {})["ended_at"] }
|
|
279
|
+
.max_by { |d| File.mtime(File.join(d, "meta.json")) }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
private def read_vocabulary
|
|
283
|
+
return DEFAULT_VOCABULARY unless File.exist?(VOCABULARY_PATH)
|
|
284
|
+
|
|
285
|
+
saved = File.read(VOCABULARY_PATH).strip
|
|
286
|
+
saved.empty? ? "" : saved
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
private def recent_transcript(dir, minutes: 5)
|
|
290
|
+
path = File.join(dir, "transcript.jsonl")
|
|
291
|
+
return "" unless File.exist?(path)
|
|
292
|
+
|
|
293
|
+
cutoff = Time.now.utc - (minutes * 60)
|
|
294
|
+
File.readlines(path).filter_map do |line|
|
|
295
|
+
entry = JSON.parse(line)
|
|
296
|
+
ts = Time.parse(entry["ts"]) rescue Time.at(0)
|
|
297
|
+
entry["text"] if ts >= cutoff
|
|
298
|
+
end.join("\n")
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
private def parse_annotations(reply)
|
|
302
|
+
json = reply.strip
|
|
303
|
+
json = json.sub(/\A```(?:json)?\s*/, "").sub(/\s*```\z/, "") # strip code fences if any
|
|
304
|
+
start = json.index("[")
|
|
305
|
+
finish = json.rindex("]")
|
|
306
|
+
return [] unless start && finish && finish > start
|
|
307
|
+
|
|
308
|
+
arr = JSON.parse(json[start..finish])
|
|
309
|
+
return [] unless arr.is_a?(Array)
|
|
310
|
+
|
|
311
|
+
arr.filter_map do |item|
|
|
312
|
+
next unless item.is_a?(Hash)
|
|
313
|
+
text = item["text"].to_s.strip
|
|
314
|
+
next if text.empty?
|
|
315
|
+
{ "type" => item["type"].to_s, "text" => text, "speaker" => item["speaker"].to_s }
|
|
316
|
+
end
|
|
317
|
+
rescue JSON::ParserError
|
|
318
|
+
[]
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
private def call_stt(audio_base64, mime_type, vocabulary = nil)
|
|
322
|
+
Clacky::Media::Generator.new(agent_config).generate_transcription(
|
|
323
|
+
audio_base64: audio_base64,
|
|
324
|
+
mime_type: mime_type,
|
|
325
|
+
prompt: vocabulary.to_s.empty? ? nil : vocabulary
|
|
326
|
+
)
|
|
327
|
+
rescue StandardError => e
|
|
328
|
+
logger.error("STT call failed: #{e.message}")
|
|
329
|
+
{ "success" => false, "text" => nil, "error" => e.message }
|
|
330
|
+
end
|
|
331
|
+
end
|