openclacky 1.3.6 → 1.3.7
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 +30 -0
- data/lib/clacky/agent.rb +8 -1
- data/lib/clacky/agent_profile.rb +121 -53
- data/lib/clacky/brand_config.rb +309 -6
- data/lib/clacky/cli.rb +4 -57
- data/lib/clacky/default_extensions/coding/ext.yml +18 -0
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +95 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +254 -0
- data/lib/clacky/default_extensions/ext-studio/ext.yml +32 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +1269 -0
- data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +71 -0
- data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +74 -0
- data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +65 -0
- data/lib/clacky/default_extensions/general/ext.yml +18 -0
- data/lib/clacky/default_extensions/git/ext.yml +12 -0
- data/lib/clacky/{default_agents/_panels/git/panel.js → default_extensions/git/panels/git/view.js} +1 -2
- data/lib/clacky/default_extensions/meeting/{handler.rb → api/handler.rb} +78 -26
- data/lib/clacky/default_extensions/meeting/ext.yml +15 -0
- data/lib/clacky/default_extensions/meeting/{meeting.js → panels/meeting/view.js} +266 -89
- data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +2 -1
- data/lib/clacky/default_extensions/time_machine/ext.yml +12 -0
- data/lib/clacky/{default_agents/_panels/time_machine/panel.js → default_extensions/time_machine/panels/time_machine/view.js} +58 -47
- data/lib/clacky/default_skills/channel-manager/discord_setup.rb +1 -1
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +83 -18
- data/lib/clacky/{api_extension.rb → extension/api_extension.rb} +27 -11
- data/lib/clacky/extension/api_loader.rb +136 -0
- data/lib/clacky/extension/cli_commands.rb +226 -0
- data/lib/clacky/{server/api_extension_dispatcher.rb → extension/dispatcher.rb} +22 -9
- data/lib/clacky/extension/hook_loader.rb +77 -0
- data/lib/clacky/extension/loader.rb +483 -0
- data/lib/clacky/extension/packager.rb +226 -0
- data/lib/clacky/{patch_loader.rb → extension/patch_loader.rb} +45 -0
- data/lib/clacky/extension/scaffold/template_renderer.rb +64 -0
- data/lib/clacky/extension/scaffold/templates/full/README.md.erb +48 -0
- data/lib/clacky/extension/scaffold/templates/full/agents/designer.md +5 -0
- data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
- data/lib/clacky/extension/scaffold/templates/full/channels/noop.rb.erb +40 -0
- data/lib/clacky/extension/scaffold/templates/full/ext.yml.erb +41 -0
- data/lib/clacky/extension/scaffold/templates/full/hooks/audit.rb +9 -0
- data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +194 -0
- data/lib/clacky/extension/scaffold/templates/full/patches/audit.rb +15 -0
- data/lib/clacky/extension/scaffold/templates/full/skills/__slug__-skill/SKILL.md.erb +8 -0
- data/lib/clacky/extension/scaffold/templates/hello/api/handler.rb.erb +9 -0
- data/lib/clacky/extension/scaffold/templates/hello/ext.yml.erb +12 -0
- data/lib/clacky/extension/scaffold/templates/hello/panels/hello/view.js.erb +31 -0
- data/lib/clacky/extension/scaffold.rb +55 -0
- data/lib/clacky/extension/verifier.rb +196 -0
- data/lib/clacky/identity.rb +70 -0
- data/lib/clacky/locales/en.rb +19 -1
- data/lib/clacky/locales/zh.rb +19 -1
- data/lib/clacky/platform_http_client.rb +18 -15
- data/lib/clacky/server/channel/extension_adapter_loader.rb +32 -0
- data/lib/clacky/server/channel.rb +6 -0
- data/lib/clacky/server/http_server.rb +313 -144
- data/lib/clacky/server/session_registry.rb +9 -2
- data/lib/clacky/skill_loader.rb +28 -1
- data/lib/clacky/ui2/ui_controller.rb +9 -6
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +748 -573
- data/lib/clacky/web/app.js +63 -15
- data/lib/clacky/web/auth.js +2 -0
- data/lib/clacky/web/components/notify.js +2 -0
- data/lib/clacky/web/components/sidebar.js +1 -5
- data/lib/clacky/web/core/ext.js +316 -79
- data/lib/clacky/web/features/backup/store.js +1 -0
- data/lib/clacky/web/features/backup/view.js +0 -1
- data/lib/clacky/web/features/brand/view.js +13 -5
- data/lib/clacky/web/features/extensions/store.js +184 -0
- data/lib/clacky/web/features/extensions/view.js +389 -0
- data/lib/clacky/web/features/new-session/store.js +186 -0
- data/lib/clacky/web/features/new-session/view.js +319 -0
- data/lib/clacky/web/features/share/view.js +63 -14
- data/lib/clacky/web/features/skills/store.js +3 -1
- data/lib/clacky/web/features/skills/view.js +1 -1
- data/lib/clacky/web/features/tasks/view.js +12 -0
- data/lib/clacky/web/features/trash/store.js +24 -6
- data/lib/clacky/web/features/trash/view.js +46 -9
- data/lib/clacky/web/features/workspace/store.js +2 -0
- data/lib/clacky/web/features/workspace/view.js +0 -1
- data/lib/clacky/web/i18n.js +118 -106
- data/lib/clacky/web/index.html +138 -188
- data/lib/clacky/web/sessions.js +64 -269
- data/lib/clacky/web/settings.js +1 -1
- data/lib/clacky/web/skills.js +2 -0
- data/lib/clacky/web/theme.js +2 -0
- data/lib/clacky/web/ws-dispatcher.js +1 -0
- data/lib/clacky/web/ws.js +2 -0
- data/lib/clacky.rb +21 -10
- metadata +55 -24
- data/benchmark/runner.rb +0 -441
- data/lib/clacky/api_extension_loader.rb +0 -168
- data/lib/clacky/default_agents/SOUL.md +0 -3
- data/lib/clacky/default_agents/USER.md +0 -1
- data/lib/clacky/default_agents/coding/profile.yml +0 -5
- data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
- data/lib/clacky/default_agents/general/profile.yml +0 -2
- data/lib/clacky/default_extensions/meeting/meta.yml +0 -3
- data/lib/clacky/web/features/creator/store.js +0 -81
- data/lib/clacky/web/features/creator/view.js +0 -380
- /data/lib/clacky/{default_agents → default_extensions/coding/agents}/coding/system_prompt.md +0 -0
- /data/lib/clacky/{default_agents → default_extensions/general/agents}/general/system_prompt.md +0 -0
- /data/lib/clacky/{default_agents/base_prompt.md → prompts/base.md} +0 -0
|
@@ -4,23 +4,51 @@
|
|
|
4
4
|
|
|
5
5
|
(function () {
|
|
6
6
|
const ANNOTATE_INTERVAL_MS = 120000;
|
|
7
|
-
|
|
7
|
+
// Wake phrases must be "greeting + name", never a bare product word.
|
|
8
|
+
// Bare "clacky" / "小克" are too easily tripped by casual mentions of the
|
|
9
|
+
// brand or STT hallucinations pulled from the meeting vocabulary.
|
|
10
|
+
// Reduplicated Chinese calls ("小克小克", "克拉奇克拉奇") are natural
|
|
11
|
+
// attention-getters and count as wake too.
|
|
12
|
+
const WAKE_PATTERNS = [
|
|
13
|
+
/@clacky\b/i,
|
|
14
|
+
/\b(hey|hi|hello|ok|okay|yo)[\s,,]+clacky\b/i,
|
|
15
|
+
/(你好|嘿|喂|哎|请问)[\s,,]*小[克客可刻课氪]/,
|
|
16
|
+
/(你好|嘿|喂|哎|请问)[\s,,]*克拉奇/,
|
|
17
|
+
/小[克客可刻课氪][\s,,]*小[克客可刻课氪]/,
|
|
18
|
+
/克拉奇[\s,,]*克拉奇/,
|
|
19
|
+
];
|
|
20
|
+
const WAKE_STRIP = [
|
|
21
|
+
/@clacky/gi,
|
|
22
|
+
/\b(hey|hi|hello|ok|okay|yo)[\s,,]+clacky\b/gi,
|
|
23
|
+
/(你好|嘿|喂|哎|请问)[\s,,]*小[克客可刻课氪]/g,
|
|
24
|
+
/(你好|嘿|喂|哎|请问)[\s,,]*克拉奇/g,
|
|
25
|
+
/小[克客可刻课氪][\s,,]*小[克客可刻课氪]/g,
|
|
26
|
+
/克拉奇[\s,,]*克拉奇/g,
|
|
27
|
+
];
|
|
8
28
|
|
|
9
29
|
// Self-contained i18n: extensions can't register keys into the host I18n
|
|
10
30
|
// dictionary, so we keep our own table and pick the language via I18n.lang().
|
|
11
31
|
const MEETING_I18N = {
|
|
12
32
|
en: {
|
|
13
|
-
"tab.label": "Meeting",
|
|
14
|
-
"btn.start": "Start
|
|
15
|
-
"btn.stop": "
|
|
33
|
+
"tab.label": "Meeting Assistant",
|
|
34
|
+
"btn.start": "Start Recording",
|
|
35
|
+
"btn.stop": "Summarize Notes",
|
|
16
36
|
"btn.resume": "Resume Recording",
|
|
17
|
-
"
|
|
37
|
+
"btn.mute": "Mute",
|
|
38
|
+
"btn.unmute": "Unmute",
|
|
39
|
+
"btn.mute.hint": "Temporarily stop recording (mic off)",
|
|
40
|
+
"btn.silence": "Stop Voice",
|
|
41
|
+
"btn.silence.hint": "Stop the current voice playback",
|
|
42
|
+
"btn.interrupt": "Interrupt",
|
|
43
|
+
"btn.interrupt.hint": "Cancel the current answer",
|
|
44
|
+
"hint.wake": 'Say "Hey Clacky" or "@clacky" to ask a question during the meeting.',
|
|
18
45
|
"hint.resume": "A meeting is still in progress. Resume recording to continue (microphone access required again).",
|
|
19
46
|
"status.recording": "Recording",
|
|
20
47
|
"status.transcribing": "Transcribing…",
|
|
21
48
|
"status.listening": "Listening… ({{n}}s)",
|
|
22
49
|
"status.thinking": "Thinking…",
|
|
23
50
|
"status.speaking": "Playing…",
|
|
51
|
+
"status.muted": "Muted",
|
|
24
52
|
"vocab.label": "Meeting vocabulary (proper nouns)",
|
|
25
53
|
"vocab.placeholder": "Type a term, press Enter",
|
|
26
54
|
"vocab.save": "Save vocabulary",
|
|
@@ -32,19 +60,28 @@
|
|
|
32
60
|
"stt.failed": "⚠ Transcription failed: {{msg}}",
|
|
33
61
|
"alert.noSession": "No active session",
|
|
34
62
|
"alert.startFailed": "Failed to start meeting: {{msg}}",
|
|
63
|
+
"end.displayMessage": "🛑 Meeting ended — generating meeting minutes…",
|
|
35
64
|
},
|
|
36
65
|
zh: {
|
|
37
|
-
"tab.label": "
|
|
38
|
-
"btn.start": "
|
|
39
|
-
"btn.stop": "
|
|
66
|
+
"tab.label": "会议助理",
|
|
67
|
+
"btn.start": "开始记录",
|
|
68
|
+
"btn.stop": "总结纪要",
|
|
40
69
|
"btn.resume": "继续录音",
|
|
41
|
-
"
|
|
70
|
+
"btn.mute": "静音",
|
|
71
|
+
"btn.unmute": "解除静音",
|
|
72
|
+
"btn.mute.hint": "临时暂停录音(关闭麦克风)",
|
|
73
|
+
"btn.silence": "停止播报",
|
|
74
|
+
"btn.silence.hint": "停止当前语音播放",
|
|
75
|
+
"btn.interrupt": "打断",
|
|
76
|
+
"btn.interrupt.hint": "取消当前回答",
|
|
77
|
+
"hint.wake": "会议中说「你好小克」「小克小克」或「@clacky」即可向我提问。",
|
|
42
78
|
"hint.resume": "有一场会议仍在进行中。点击「继续录音」继续(需要重新授权麦克风)。",
|
|
43
79
|
"status.recording": "录音中",
|
|
44
80
|
"status.transcribing": "识别中…",
|
|
45
81
|
"status.listening": "正在听你说…({{n}}s)",
|
|
46
82
|
"status.thinking": "思考中…",
|
|
47
83
|
"status.speaking": "播放中…",
|
|
84
|
+
"status.muted": "已静音",
|
|
48
85
|
"vocab.label": "会议词汇(专有名词)",
|
|
49
86
|
"vocab.placeholder": "输入词汇后回车添加",
|
|
50
87
|
"vocab.save": "保存词汇",
|
|
@@ -56,6 +93,7 @@
|
|
|
56
93
|
"stt.failed": "⚠ 识别失败:{{msg}}",
|
|
57
94
|
"alert.noSession": "没有进行中的会话",
|
|
58
95
|
"alert.startFailed": "开启会议失败:{{msg}}",
|
|
96
|
+
"end.displayMessage": "🛑 会议结束 — 正在生成会议纪要…",
|
|
59
97
|
},
|
|
60
98
|
};
|
|
61
99
|
|
|
@@ -67,53 +105,45 @@
|
|
|
67
105
|
return str;
|
|
68
106
|
}
|
|
69
107
|
|
|
108
|
+
// ── Layer 1: module-level (page lifetime) ────────────────────────────
|
|
109
|
+
// A registry of all live meeting sessions on this page so the module-level
|
|
110
|
+
// `session:assistant-message` subscription and the `langchange` listener
|
|
111
|
+
// can dispatch to whichever session the event is about, without any of
|
|
112
|
+
// them holding a reference to the module `state` (there is no module state).
|
|
113
|
+
const _sessionsById = new Map();
|
|
114
|
+
|
|
115
|
+
// When the agent produces a reply during meeting mode we dispatch it to the
|
|
116
|
+
// matching per-session runtime — every session's meeting has its own
|
|
117
|
+
// "expecting speech" flag and its own audio player.
|
|
118
|
+
Clacky.ext.subscribe("session:assistant-message", function (payload) {
|
|
119
|
+
const sess = payload && payload.sessionId && _sessionsById.get(payload.sessionId);
|
|
120
|
+
if (sess) sess.onAssistantMessage(payload);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Re-render every live meeting on language switch so labels follow the
|
|
124
|
+
// host language.
|
|
125
|
+
document.addEventListener("langchange", function () {
|
|
126
|
+
_sessionsById.forEach((sess) => sess.rerender());
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ── Layer 2/3: per-session runtime ──────────────────────────────────
|
|
130
|
+
// Everything below is instantiated fresh by `create(ctx)` each time a
|
|
131
|
+
// session shows the meeting tab for the first time; state stays alive
|
|
132
|
+
// until the session leaves the sidebar (or the page unloads).
|
|
133
|
+
function createMeetingSession(ctx) {
|
|
134
|
+
|
|
70
135
|
// VAD (voice activity detection) — slice on natural speech pauses instead
|
|
71
136
|
// of a fixed timer, so a sentence is never cut mid-word.
|
|
72
|
-
const VAD_SILENCE_THRESHOLD = 0.
|
|
73
|
-
const VAD_SILENCE_HOLD_MS =
|
|
74
|
-
const VAD_MIN_SPEECH_MS =
|
|
75
|
-
const VAD_MAX_SEGMENT_MS = 12000;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
"no", "no.", "yes", "yes.", "ok", "okay", "thank you", "thank you.",
|
|
79
|
-
"thanks", "thanks for watching", "thanks for watching!", "you", "bye",
|
|
80
|
-
"uh", "um", "hmm", "mm", "mm-hmm", ".", "..", "...",
|
|
81
|
-
"yeah", "yeah.", "yep", "but", "and", "so", "oh", "ah", "ahh", "huh",
|
|
82
|
-
"an", "a", "i", "the", "more", "well", "right", "hi", "hey", "wow",
|
|
83
|
-
"嗯", "啊", "哦", "呃", "谢谢", "谢谢观看", "谢谢大家", "好", "好的", "对",
|
|
84
|
-
]);
|
|
85
|
-
|
|
86
|
-
// STT models hallucinate single isolated words during silence/noise. Real
|
|
87
|
-
// speech segments (gated by VAD_MIN_SPEECH_MS) almost never decode to a lone
|
|
88
|
-
// 1-2 word fragment, so treat those as noise.
|
|
89
|
-
function isStructuralNoise(t) {
|
|
90
|
-
const stripped = t.replace(/[\(\[][^\)\]]*[\)\]]/g, "").trim();
|
|
91
|
-
if (stripped === "") return true;
|
|
92
|
-
// Pure punctuation / digits / timestamp-like fragments ("00:00", "1.", ":").
|
|
93
|
-
if (/^[\s\d.:,;!?。,、!?\-—]+$/.test(stripped)) return true;
|
|
94
|
-
const hasCJK = /[\u4e00-\u9fff\u3040-\u30ff]/.test(stripped);
|
|
95
|
-
const core = stripped.replace(/[\s。,、!?!?.,;:]+/g, "");
|
|
96
|
-
if (hasCJK) {
|
|
97
|
-
// A single isolated CJK character is almost always a filler/hallucination.
|
|
98
|
-
if (core.length <= 1) return true;
|
|
99
|
-
} else {
|
|
100
|
-
const words = stripped.split(/\s+/).filter(Boolean);
|
|
101
|
-
// A single short Latin word (≤3 chars), e.g. "An", "Oh", "Zero" miswrites.
|
|
102
|
-
if (words.length === 1 && words[0].replace(/[^A-Za-z]/g, "").length <= 3) return true;
|
|
103
|
-
}
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function isHallucination(text) {
|
|
108
|
-
const t = text.trim();
|
|
109
|
-
if (isStructuralNoise(t)) return true;
|
|
110
|
-
const normalized = t.toLowerCase().replace(/[\s。,,!!??]+/g, " ").trim();
|
|
111
|
-
return normalized === "" || HALLUCINATION_PHRASES.has(normalized);
|
|
112
|
-
}
|
|
137
|
+
const VAD_SILENCE_THRESHOLD = 0.02; // RMS below this counts as silence
|
|
138
|
+
const VAD_SILENCE_HOLD_MS = 700; // pause this long => end of utterance
|
|
139
|
+
const VAD_MIN_SPEECH_MS = 500; // ignore utterances shorter than this
|
|
140
|
+
const VAD_MAX_SEGMENT_MS = 12000; // force-cut a very long monologue
|
|
141
|
+
const VAD_MIN_ACTIVE_RATIO = 0.25; // fraction of samples above threshold
|
|
142
|
+
// required to accept a segment
|
|
113
143
|
|
|
114
144
|
let state = {
|
|
115
145
|
active: false,
|
|
116
|
-
sessionId:
|
|
146
|
+
sessionId: ctx.sessionId,
|
|
117
147
|
meetingId: null,
|
|
118
148
|
mediaRecorder: null,
|
|
119
149
|
annotateTimer: null,
|
|
@@ -132,6 +162,8 @@
|
|
|
132
162
|
transcribing: 0, // count of in-flight STT requests
|
|
133
163
|
container: null,
|
|
134
164
|
resumable: false,
|
|
165
|
+
micMuted: false,
|
|
166
|
+
ttsAudio: null,
|
|
135
167
|
};
|
|
136
168
|
|
|
137
169
|
function apiUrl(path) {
|
|
@@ -226,6 +258,8 @@
|
|
|
226
258
|
let hadSpeech = false;
|
|
227
259
|
let segmentStart = 0;
|
|
228
260
|
let silenceStart = 0;
|
|
261
|
+
let activeSamples = 0;
|
|
262
|
+
let totalSamples = 0;
|
|
229
263
|
|
|
230
264
|
function newRecorder() {
|
|
231
265
|
const r = new MediaRecorder(stream, mime ? { mimeType: mime } : {});
|
|
@@ -233,13 +267,16 @@
|
|
|
233
267
|
hadSpeech = false;
|
|
234
268
|
segmentStart = performance.now();
|
|
235
269
|
silenceStart = 0;
|
|
270
|
+
activeSamples = 0;
|
|
271
|
+
totalSamples = 0;
|
|
236
272
|
r.ondataavailable = (e) => { if (e.data && e.data.size > 0) chunks.push(e.data); };
|
|
237
273
|
r.onstop = () => {
|
|
238
274
|
const captured = chunks;
|
|
239
275
|
const speech = hadSpeech;
|
|
240
276
|
const dur = performance.now() - segmentStart;
|
|
277
|
+
const ratio = totalSamples > 0 ? activeSamples / totalSamples : 0;
|
|
241
278
|
if (state.active) newRecorder();
|
|
242
|
-
if (speech && dur >= VAD_MIN_SPEECH_MS && captured.length) {
|
|
279
|
+
if (speech && dur >= VAD_MIN_SPEECH_MS && ratio >= VAD_MIN_ACTIVE_RATIO && captured.length) {
|
|
243
280
|
sendAudioChunk(new Blob(captured, mime ? { type: mime } : {}));
|
|
244
281
|
}
|
|
245
282
|
};
|
|
@@ -260,7 +297,9 @@
|
|
|
260
297
|
const now = performance.now();
|
|
261
298
|
const level = rms();
|
|
262
299
|
|
|
300
|
+
totalSamples++;
|
|
263
301
|
if (level >= VAD_SILENCE_THRESHOLD) {
|
|
302
|
+
activeSamples++;
|
|
264
303
|
hadSpeech = true;
|
|
265
304
|
silenceStart = 0;
|
|
266
305
|
} else if (hadSpeech) {
|
|
@@ -296,15 +335,11 @@
|
|
|
296
335
|
clearInterval(state.annotateTimer);
|
|
297
336
|
|
|
298
337
|
try {
|
|
299
|
-
|
|
338
|
+
await postJson("/end", {
|
|
300
339
|
session_id: state.sessionId,
|
|
301
340
|
meeting_id: state.meetingId,
|
|
341
|
+
display_message: t("end.displayMessage"),
|
|
302
342
|
});
|
|
303
|
-
if (result && result.ok === false) {
|
|
304
|
-
console.error("[meeting] end summarization failed:", result.error);
|
|
305
|
-
} else if (result && result.skipped) {
|
|
306
|
-
console.warn("[meeting] end: transcript was empty, no summary generated");
|
|
307
|
-
}
|
|
308
343
|
} catch (err) {
|
|
309
344
|
console.error("[meeting] end failed:", err);
|
|
310
345
|
}
|
|
@@ -329,6 +364,7 @@
|
|
|
329
364
|
const MIN_AUDIO_BYTES = 2000; // drop near-empty blobs before hitting STT
|
|
330
365
|
|
|
331
366
|
async function sendAudioChunk(blob) {
|
|
367
|
+
if (state.micMuted) return;
|
|
332
368
|
if (!blob || blob.size < MIN_AUDIO_BYTES) return; // too little audio to be speech
|
|
333
369
|
const buf = await blob.arrayBuffer();
|
|
334
370
|
const bytes = new Uint8Array(buf);
|
|
@@ -347,7 +383,7 @@
|
|
|
347
383
|
vocabulary: state.vocabulary,
|
|
348
384
|
});
|
|
349
385
|
|
|
350
|
-
if (result.text && result.text.trim()
|
|
386
|
+
if (result.text && result.text.trim()) {
|
|
351
387
|
const entry = { ts: Date.now(), text: result.text.trim() };
|
|
352
388
|
state.transcripts.push(entry);
|
|
353
389
|
checkWakeWord(entry.text);
|
|
@@ -368,19 +404,38 @@
|
|
|
368
404
|
}
|
|
369
405
|
|
|
370
406
|
const CONVERSATION_WINDOW_MS = 30000; // after a wake word, keep listening this long without re-triggering
|
|
407
|
+
const MIN_QUESTION_CHARS = 4;
|
|
408
|
+
const WAKE_ONLY_TAIL = /^\s*[??!!。.,,]?\s*$/;
|
|
409
|
+
|
|
410
|
+
function stripWake(text) {
|
|
411
|
+
let out = text;
|
|
412
|
+
for (const re of WAKE_STRIP) out = out.replace(re, " ");
|
|
413
|
+
return out.replace(/[\s\p{P}]+/gu, " ").trim();
|
|
414
|
+
}
|
|
371
415
|
|
|
372
416
|
function checkWakeWord(text) {
|
|
373
417
|
const triggered = WAKE_PATTERNS.some((p) => p.test(text));
|
|
374
418
|
const inConversation = state.conversationUntil && Date.now() < state.conversationUntil;
|
|
375
419
|
|
|
376
420
|
if (!triggered && !inConversation) return;
|
|
377
|
-
if (state.asking) return; // a question is still being answered; don't pile on
|
|
378
421
|
|
|
379
|
-
const question = text
|
|
380
|
-
|
|
422
|
+
const question = stripWake(text);
|
|
423
|
+
// Wake phrase alone (no real question in the same segment): arm listening
|
|
424
|
+
// but don't dispatch — the next segment will be treated as the question.
|
|
425
|
+
if (triggered && WAKE_ONLY_TAIL.test(question.replace(/[\s\p{P}]/gu, ""))) {
|
|
426
|
+
state.conversationUntil = Date.now() + CONVERSATION_WINDOW_MS;
|
|
427
|
+
state.expectingSpeech = true;
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const meaningful = question.replace(/[^\p{L}\p{N}]/gu, "");
|
|
431
|
+
if (meaningful.length < MIN_QUESTION_CHARS) return;
|
|
381
432
|
|
|
382
433
|
state.conversationUntil = Date.now() + CONVERSATION_WINDOW_MS;
|
|
383
434
|
state.expectingSpeech = true;
|
|
435
|
+
// A new question supersedes any in-flight answer: stop the current TTS
|
|
436
|
+
// playback right away; the backend `/ask` handler will interrupt the
|
|
437
|
+
// running agent turn before submitting the new one.
|
|
438
|
+
stopTts();
|
|
384
439
|
state.asking = true;
|
|
385
440
|
postJson("/ask", {
|
|
386
441
|
session_id: state.sessionId,
|
|
@@ -410,11 +465,14 @@
|
|
|
410
465
|
// speaking > thinking > conversation(countdown) > transcribing > listening.
|
|
411
466
|
function updateStatus() {
|
|
412
467
|
const el = document.getElementById("meeting-status");
|
|
413
|
-
if (!el) return;
|
|
468
|
+
if (!el) { updateControls(); return; }
|
|
414
469
|
const now = Date.now();
|
|
415
470
|
const inConversation = state.conversationUntil && now < state.conversationUntil;
|
|
416
471
|
let text, cls;
|
|
417
|
-
if (state.
|
|
472
|
+
if (state.micMuted) {
|
|
473
|
+
text = t("status.muted");
|
|
474
|
+
cls = "muted";
|
|
475
|
+
} else if (state.phase === "speaking") {
|
|
418
476
|
text = t("status.speaking");
|
|
419
477
|
cls = "speaking";
|
|
420
478
|
} else if (state.asking) {
|
|
@@ -433,6 +491,7 @@
|
|
|
433
491
|
}
|
|
434
492
|
el.textContent = " " + text;
|
|
435
493
|
el.className = "meeting-status meeting-status-" + cls;
|
|
494
|
+
updateControls();
|
|
436
495
|
}
|
|
437
496
|
|
|
438
497
|
function startStatusTicker() {
|
|
@@ -640,9 +699,41 @@
|
|
|
640
699
|
stopBtn.className = "meeting-btn meeting-btn-stop";
|
|
641
700
|
stopBtn.textContent = t("btn.stop");
|
|
642
701
|
stopBtn.onclick = () => stopMeeting(container);
|
|
702
|
+
|
|
703
|
+
const muteBtn = document.createElement("button");
|
|
704
|
+
muteBtn.id = "meeting-mute-btn";
|
|
705
|
+
muteBtn.className = "meeting-btn meeting-btn-mute";
|
|
706
|
+
muteBtn.textContent = state.micMuted ? t("btn.unmute") : t("btn.mute");
|
|
707
|
+
muteBtn.title = t("btn.mute.hint");
|
|
708
|
+
muteBtn.onclick = toggleMic;
|
|
709
|
+
|
|
710
|
+
const stopTtsBtn = document.createElement("button");
|
|
711
|
+
stopTtsBtn.id = "meeting-stop-tts";
|
|
712
|
+
stopTtsBtn.className = "meeting-btn meeting-btn-silence";
|
|
713
|
+
stopTtsBtn.textContent = t("btn.silence");
|
|
714
|
+
stopTtsBtn.title = t("btn.silence.hint");
|
|
715
|
+
stopTtsBtn.style.display = state.phase === "speaking" ? "" : "none";
|
|
716
|
+
stopTtsBtn.onclick = stopTts;
|
|
717
|
+
|
|
718
|
+
const interruptBtn = document.createElement("button");
|
|
719
|
+
interruptBtn.id = "meeting-interrupt-btn";
|
|
720
|
+
interruptBtn.className = "meeting-btn meeting-btn-interrupt";
|
|
721
|
+
interruptBtn.textContent = t("btn.interrupt");
|
|
722
|
+
interruptBtn.title = t("btn.interrupt.hint");
|
|
723
|
+
interruptBtn.style.display = state.asking ? "" : "none";
|
|
724
|
+
interruptBtn.onclick = interruptAsk;
|
|
725
|
+
|
|
726
|
+
header.appendChild(muteBtn);
|
|
727
|
+
header.appendChild(stopTtsBtn);
|
|
728
|
+
header.appendChild(interruptBtn);
|
|
643
729
|
header.appendChild(stopBtn);
|
|
644
730
|
wrapper.appendChild(header);
|
|
645
731
|
|
|
732
|
+
const activeHint = document.createElement("p");
|
|
733
|
+
activeHint.className = "meeting-hint meeting-hint-active";
|
|
734
|
+
activeHint.textContent = t("hint.wake");
|
|
735
|
+
wrapper.appendChild(activeHint);
|
|
736
|
+
|
|
646
737
|
const sttError = document.createElement("div");
|
|
647
738
|
sttError.id = "meeting-stt-error";
|
|
648
739
|
sttError.className = "meeting-stt-error";
|
|
@@ -698,9 +789,14 @@
|
|
|
698
789
|
const data = await postJson("/speak", { text: text });
|
|
699
790
|
if (!data.audio_base64) return;
|
|
700
791
|
const audio = new Audio(`data:${data.mime_type || "audio/wav"};base64,${data.audio_base64}`);
|
|
792
|
+
state.ttsAudio = audio;
|
|
701
793
|
state.phase = "speaking";
|
|
702
794
|
updateStatus();
|
|
703
|
-
const clear = () => {
|
|
795
|
+
const clear = () => {
|
|
796
|
+
if (state.ttsAudio === audio) state.ttsAudio = null;
|
|
797
|
+
state.phase = "idle";
|
|
798
|
+
updateStatus();
|
|
799
|
+
};
|
|
704
800
|
audio.onended = clear;
|
|
705
801
|
audio.onerror = clear;
|
|
706
802
|
audio.play().catch(clear);
|
|
@@ -711,45 +807,119 @@
|
|
|
711
807
|
}
|
|
712
808
|
}
|
|
713
809
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
if (
|
|
810
|
+
function stopTts() {
|
|
811
|
+
const a = state.ttsAudio;
|
|
812
|
+
if (!a) return;
|
|
813
|
+
try { a.pause(); a.currentTime = 0; } catch (_) {}
|
|
814
|
+
state.ttsAudio = null;
|
|
815
|
+
if (state.phase === "speaking") {
|
|
816
|
+
state.phase = "idle";
|
|
817
|
+
updateStatus();
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function toggleMic() {
|
|
822
|
+
if (!state.stream) return;
|
|
823
|
+
const tracks = state.stream.getAudioTracks();
|
|
824
|
+
if (!tracks.length) return;
|
|
825
|
+
state.micMuted = !state.micMuted;
|
|
826
|
+
tracks.forEach((t) => { t.enabled = !state.micMuted; });
|
|
827
|
+
updateStatus();
|
|
828
|
+
updateControls();
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function interruptAsk() {
|
|
832
|
+
stopTts();
|
|
717
833
|
state.asking = false;
|
|
718
|
-
if (!state.active) return;
|
|
719
|
-
if (!state.expectingSpeech) return;
|
|
720
834
|
state.expectingSpeech = false;
|
|
721
|
-
|
|
722
|
-
|
|
835
|
+
state.conversationUntil = 0;
|
|
836
|
+
try {
|
|
837
|
+
if (Clacky.WS && typeof Clacky.WS.send === "function") {
|
|
838
|
+
Clacky.WS.send({ type: "interrupt", session_id: state.sessionId });
|
|
839
|
+
}
|
|
840
|
+
} catch (e) {
|
|
841
|
+
console.error("[meeting] interrupt send failed:", e && e.message);
|
|
842
|
+
}
|
|
843
|
+
updateStatus();
|
|
844
|
+
updateControls();
|
|
845
|
+
}
|
|
723
846
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
847
|
+
function updateControls() {
|
|
848
|
+
if (!state.container) return;
|
|
849
|
+
const muteBtn = state.container.querySelector("#meeting-mute-btn");
|
|
850
|
+
if (muteBtn) {
|
|
851
|
+
muteBtn.textContent = state.micMuted ? t("btn.unmute") : t("btn.mute");
|
|
852
|
+
muteBtn.classList.toggle("meeting-btn-muted", state.micMuted);
|
|
853
|
+
}
|
|
854
|
+
const stopTtsBtn = state.container.querySelector("#meeting-stop-tts");
|
|
855
|
+
if (stopTtsBtn) stopTtsBtn.style.display = state.phase === "speaking" ? "" : "none";
|
|
856
|
+
const interruptBtn = state.container.querySelector("#meeting-interrupt-btn");
|
|
857
|
+
if (interruptBtn) interruptBtn.style.display = state.asking ? "" : "none";
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Handler dispatched from the module-level assistant-message subscription.
|
|
861
|
+
// - if we're expecting speech (a wake-word triggered ask), speak it and
|
|
862
|
+
// keep `asking` locked until playback ends (or fails).
|
|
863
|
+
// - otherwise release `asking` immediately.
|
|
864
|
+
function onAssistantMessage(payload) {
|
|
865
|
+
if (!state.active || !state.expectingSpeech) {
|
|
866
|
+
state.asking = false;
|
|
867
|
+
updateStatus();
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
state.expectingSpeech = false;
|
|
871
|
+
speakAnswer(payload && payload.content);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Register into the module registry so cross-session events reach us.
|
|
875
|
+
_sessionsById.set(ctx.sessionId, {
|
|
876
|
+
onAssistantMessage,
|
|
877
|
+
rerender() { if (state.container) renderUI(state.container); },
|
|
878
|
+
});
|
|
730
879
|
|
|
731
|
-
//
|
|
732
|
-
//
|
|
880
|
+
// Kick off vocabulary + resume probe eagerly so the tab is ready even
|
|
881
|
+
// before the user first opens it (create runs on first render).
|
|
733
882
|
fetch(apiUrl("/vocabulary"))
|
|
734
883
|
.then((r) => r.json())
|
|
735
884
|
.then((d) => {
|
|
736
885
|
state.vocabulary = (d && d.vocabulary) || "";
|
|
737
|
-
renderUI(container);
|
|
886
|
+
if (state.container) renderUI(state.container);
|
|
738
887
|
})
|
|
739
888
|
.catch(() => null)
|
|
740
|
-
.then(() => probeActiveMeeting(container));
|
|
889
|
+
.then(() => probeActiveMeeting(state.container));
|
|
890
|
+
|
|
891
|
+
return {
|
|
892
|
+
state,
|
|
893
|
+
// Every tab activation gets a fresh empty container; wire it into state
|
|
894
|
+
// and paint the current state onto it.
|
|
895
|
+
attach(container) {
|
|
896
|
+
container.className = "meeting-container";
|
|
897
|
+
state.container = container;
|
|
898
|
+
renderUI(container);
|
|
899
|
+
},
|
|
900
|
+
// Session left the sidebar for good — release recorder / audio / timers.
|
|
901
|
+
dispose() {
|
|
902
|
+
_sessionsById.delete(ctx.sessionId);
|
|
903
|
+
state.active = false;
|
|
904
|
+
if (state.vadRaf) cancelAnimationFrame(state.vadRaf);
|
|
905
|
+
try { if (state.mediaRecorder && state.mediaRecorder.state !== "inactive") state.mediaRecorder.stop(); } catch (_) {}
|
|
906
|
+
if (state.stream) state.stream.getTracks().forEach((t) => t.stop());
|
|
907
|
+
if (state.audioCtx) { try { state.audioCtx.close(); } catch (_) {} }
|
|
908
|
+
clearInterval(state.annotateTimer);
|
|
909
|
+
stopStatusTicker();
|
|
910
|
+
stopTts();
|
|
911
|
+
},
|
|
912
|
+
};
|
|
913
|
+
}
|
|
741
914
|
|
|
742
|
-
|
|
915
|
+
Clacky.ext.ui.mount("session.aside", {
|
|
916
|
+
create(ctx) { return createMeetingSession(ctx); },
|
|
917
|
+
render(container, ctx, runtime) { runtime.attach(container); },
|
|
743
918
|
}, {
|
|
744
919
|
tab: { id: "meeting", label: () => t("tab.label") },
|
|
745
920
|
order: 200,
|
|
746
921
|
});
|
|
747
922
|
|
|
748
|
-
// Re-render on language switch so all labels follow the host language.
|
|
749
|
-
document.addEventListener("langchange", function () {
|
|
750
|
-
if (state.container) renderUI(state.container);
|
|
751
|
-
});
|
|
752
|
-
|
|
753
923
|
// Inject minimal styles
|
|
754
924
|
const style = document.createElement("style");
|
|
755
925
|
style.textContent = `
|
|
@@ -757,9 +927,16 @@
|
|
|
757
927
|
.meeting-btn { padding: 7px 14px; border-radius: var(--radius-sm); border: 1px solid transparent; cursor: pointer; font-size: 13px; font-weight: 500; transition: background .15s, border-color .15s; }
|
|
758
928
|
.meeting-btn-start { background: var(--color-button-primary); color: var(--color-button-primary-text); }
|
|
759
929
|
.meeting-btn-start:hover { background: var(--color-button-primary-hover); }
|
|
760
|
-
.meeting-btn-stop { background: transparent; color: var(--color-error); border-color: var(--color-error-border);
|
|
930
|
+
.meeting-btn-stop { background: transparent; color: var(--color-error); border-color: var(--color-error-border); }
|
|
761
931
|
.meeting-btn-stop:hover { background: var(--color-error-bg); }
|
|
932
|
+
.meeting-btn-mute, .meeting-btn-silence, .meeting-btn-interrupt { background: transparent; color: var(--color-text-secondary); border-color: var(--color-border-primary); }
|
|
933
|
+
.meeting-btn-mute:hover, .meeting-btn-silence:hover, .meeting-btn-interrupt:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
|
|
934
|
+
.meeting-btn-mute.meeting-btn-muted { color: var(--color-error); border-color: var(--color-error-border); background: var(--color-error-bg); }
|
|
935
|
+
.meeting-btn-silence, .meeting-btn-interrupt { color: var(--color-warning, var(--color-text-secondary)); border-color: var(--color-warning-border, var(--color-border-primary)); }
|
|
936
|
+
.meeting-header .meeting-btn-mute { margin-left: auto; }
|
|
937
|
+
.meeting-header .meeting-btn-mute + .meeting-btn { margin-left: 6px; }
|
|
762
938
|
.meeting-hint { color: var(--color-text-tertiary); margin: 10px 0 0; font-size: 12px; line-height: 1.5; }
|
|
939
|
+
.meeting-hint-active { margin: 8px 0 4px; font-size: 11px; opacity: 0.75; }
|
|
763
940
|
.meeting-vocab-section { margin-top: 20px; border-top: 1px solid var(--color-border-primary); padding-top: 16px; }
|
|
764
941
|
.meeting-vocab-label { display: block; font-size: 12px; color: var(--color-text-tertiary); margin-bottom: 6px; }
|
|
765
942
|
.meeting-vocab-box { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; width: 100%; box-sizing: border-box; background: var(--color-bg-input); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); padding: 6px 8px; cursor: text; min-height: 36px; }
|
|
@@ -40,5 +40,6 @@ Produce the summary in this structure:
|
|
|
40
40
|
1. Be concise — each bullet should be one sentence max.
|
|
41
41
|
2. If speakers are identifiable from context, attribute decisions and actions to them.
|
|
42
42
|
3. Ignore filler words, small talk, and off-topic tangents.
|
|
43
|
-
4. If the transcript is
|
|
43
|
+
4. **If the transcript is empty, or contains only a handful of repeated words / meaningless fragments (e.g. the same term repeated with no real dialogue), do NOT invent content and do NOT produce the structured output.** Respond with a single short line noting there was no meaningful conversation to summarize, and stop.
|
|
44
44
|
5. Write the summary in the same language the meeting was conducted in.
|
|
45
|
+
6. Do not use tools other than reading the transcript from the user message. Never open a browser, run shell commands, or invoke unrelated skills.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
id: time_machine
|
|
2
|
+
name: Time Machine
|
|
3
|
+
description: Vertical timeline of session tasks with per-task diff and one-click restore
|
|
4
|
+
version: "0.1.0"
|
|
5
|
+
author: OpenClacky
|
|
6
|
+
homepage: https://www.openclacky.com
|
|
7
|
+
license: MIT
|
|
8
|
+
origin: self
|
|
9
|
+
contributes:
|
|
10
|
+
panels:
|
|
11
|
+
- id: time_machine
|
|
12
|
+
view: panels/time_machine/view.js
|