@usercase/mcp 0.2.11-next.0 → 0.2.11-next.2
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.
- package/agents/fix.md +36 -0
- package/mcp/hub.js +115 -2
- package/mcp/report.js +8 -0
- package/mcp/server.js +98 -1
- package/package.json +1 -1
- package/src/browser/open.js +3 -0
- package/src/browser/study.js +17 -2
- package/src/browser/voice.js +241 -0
package/agents/fix.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Fix a problem found by a Usercase research
|
|
2
|
+
|
|
3
|
+
Research: {{research}}
|
|
4
|
+
Finding: {{finding}}
|
|
5
|
+
Report: {{audience}}
|
|
6
|
+
|
|
7
|
+
A Usercase research watched people (or virtual users) do tasks on this site. Its report lists findings — problems on the site, each backed by what people did, what the screen showed in the recordings, and what they wrote. You fix one finding in this repository's code.
|
|
8
|
+
|
|
9
|
+
## 1. Read the finding
|
|
10
|
+
|
|
11
|
+
Call `report_finding` with the research id, the finding key and the report. If no finding key is given above, call `report_findings` first, show the user the list (key, severity, title, and whether someone is already fixing it), and ask which one to fix.
|
|
12
|
+
|
|
13
|
+
Read all of it: the title and why, who it hit, the screen checks (what the page really showed — these come from the recordings, not from opinions), the paths people took, the groups of what they wrote, and the suggested changes. The suggested changes were written without seeing the code; treat them as a direction, not a spec.
|
|
14
|
+
|
|
15
|
+
## 2. Find the code
|
|
16
|
+
|
|
17
|
+
From the pages, buttons and texts in the evidence, find the code behind them: routes, the page components, the button and its click handler, the text shown. Confirm you found the right place by matching the exact text on screen (a button label, a heading) to the code.
|
|
18
|
+
|
|
19
|
+
## 3. Propose the change — before changing anything
|
|
20
|
+
|
|
21
|
+
Tell the user, briefly:
|
|
22
|
+
- what in the code causes the problem, with file paths,
|
|
23
|
+
- the change you propose and why it addresses what people did and said, and
|
|
24
|
+
- anything the finding suggests that you would not do, and why (it would break something, it needs a product decision, the code already does it).
|
|
25
|
+
|
|
26
|
+
Keep the change as small as solves the problem. Do not redesign unrelated parts. Wait for the user's approval or corrections.
|
|
27
|
+
|
|
28
|
+
## 4. Change the code
|
|
29
|
+
|
|
30
|
+
When the user approves, call `report_fix` with `status` `fixing` and the files you will change. Then make the change. Follow the repository's own conventions, and run its checks (type check, lint, tests) if it has them.
|
|
31
|
+
|
|
32
|
+
## 5. Record the fix
|
|
33
|
+
|
|
34
|
+
When the change is done, call `report_fix` with `status` `fixed`, the files changed, a one-sentence summary written for the team (what a visitor now sees differently), and the pull request link if you opened one. Do not commit, push or open a pull request unless the user asks.
|
|
35
|
+
|
|
36
|
+
Then tell the user that the finding shows as fixed in the report, and how to check it worked: {{check}}
|
package/mcp/hub.js
CHANGED
|
@@ -25,6 +25,7 @@ const ws = require("./workspace.js");
|
|
|
25
25
|
const signin = require("../src/browser/signin.js");
|
|
26
26
|
const { openRealChrome } = require("../src/browser/real-chrome.js");
|
|
27
27
|
const study = require("../src/browser/study.js");
|
|
28
|
+
const voice = require("../src/browser/voice.js");
|
|
28
29
|
const report = require("./report.js");
|
|
29
30
|
|
|
30
31
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -49,7 +50,9 @@ function trace(rec, o) {
|
|
|
49
50
|
fs.appendFileSync(TRACE, JSON.stringify({ at: Date.now(), person: rec && rec.person ? rec.person.id : null, ...o }) + "\n");
|
|
50
51
|
} catch {}
|
|
51
52
|
}
|
|
52
|
-
|
|
53
|
+
/** 음성 설문이 끝나고 위젯이 간 곳 — 여기면 마지막 말은 인사다 */
|
|
54
|
+
const VOICE_OVER = new Set(["testing", "next", "submit", "loading", "done", "complete", "idle"]);
|
|
55
|
+
const STUDY_ACTIVE = new Set(["consent", "mic", "loading", "testing", "timeout", "survey", "ai", "next", "submit"]);
|
|
53
56
|
|
|
54
57
|
/**
|
|
55
58
|
* 가상 사용자 표시 이름. 서버가 이 이름으로 가상 사용자 답을 진짜 답과 갈라 센다
|
|
@@ -459,7 +462,7 @@ class Hub {
|
|
|
459
462
|
const sdk = await rec.visit.begin();
|
|
460
463
|
// 리서치 참여 — 초대 수락·녹화 동의까지는 참여하기로 한 사람이 늘 하는 것이라 도구가 한다
|
|
461
464
|
if (research) {
|
|
462
|
-
rec.study = { research, answers: [], tasks: [], skipped: [], lastTask: null, over: false, surveyAt: null };
|
|
465
|
+
rec.study = { research, answers: [], tasks: [], skipped: [], lastTask: null, over: false, surveyAt: null, heardFrom: 0 };
|
|
463
466
|
const j = await study.join(page);
|
|
464
467
|
if (!j.ok) {
|
|
465
468
|
await this.finish(rec, { study: { research, failed: j.why } });
|
|
@@ -482,6 +485,11 @@ class Hub {
|
|
|
482
485
|
"You agreed to take part, so you stay with the study until its tasks are done — you do not leave the site in the middle of it over a small thing. The study may ask you a few questions along the way; answer them with answer_card.",
|
|
483
486
|
"If you get stuck on a task, do what a person does: keep looking for it — try other menus and pages, go back, scroll through lists, try what you have not tried yet. Studies usually offer a way to give up on a task after a while; when the panel offers it and you would give up, take it and go on to the next task. Leave in the middle only if you really would — something about you or your moment makes you stop.",
|
|
484
487
|
);
|
|
488
|
+
// AI 모더레이터 리서치 — 마이크를 받아 갔다(voice.js). 목소리가 말을 걸고, 방문자는 소리 내어 답한다
|
|
489
|
+
if (research && (await page.evaluate(() => !!(window.__ucVoice && window.__ucVoice.used())).catch(() => false)))
|
|
490
|
+
lines.push(
|
|
491
|
+
"This study has a voice: you allowed your microphone, and a voice may talk to you during the study (you see its words as a caption). When it asks you something, you answer out loud — with answer_card.",
|
|
492
|
+
);
|
|
485
493
|
if (!sdk.ready) lines.push("(note for whoever runs this: the Usercase SDK did not start on this page — this visit may not be recorded)");
|
|
486
494
|
lines.push("", await this.screen(rec));
|
|
487
495
|
return { id, text: lines.join("\n") };
|
|
@@ -558,6 +566,17 @@ class Hub {
|
|
|
558
566
|
await study.waitState(page, ["testing", "survey", "submit", "done"], 15000);
|
|
559
567
|
continue;
|
|
560
568
|
}
|
|
569
|
+
// AI 모더레이터가 소리로 묻는 중(음성 후속설문). 물음이 들리면 방문자가 소리 내어 답한다
|
|
570
|
+
if (s.state === "ai") {
|
|
571
|
+
const q = await this.voiceQuestion(rec);
|
|
572
|
+
if (q) {
|
|
573
|
+
rec.studyPending = { kind: "voice", q, task: st.lastTask };
|
|
574
|
+
return { card: renderVoiceQuestion(q, st.lastTask) };
|
|
575
|
+
}
|
|
576
|
+
s = study.isSurveyUrl(page.url()) ? null : await study.readStudy(page);
|
|
577
|
+
if (!s || s.state === "ai") break;
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
561
580
|
if (s.state === "submit" || s.state === "loading") {
|
|
562
581
|
await study.waitState(page, ["done", "complete", "testing", "survey", "next", "idle", "invite"], 120000);
|
|
563
582
|
continue;
|
|
@@ -581,6 +600,14 @@ class Hub {
|
|
|
581
600
|
}
|
|
582
601
|
st.lastTaskKey = key;
|
|
583
602
|
st.lastTask = s.task.title;
|
|
603
|
+
// 과제 중에 모더레이터가 한 말(음성 리서치) — 사람도 듣는다
|
|
604
|
+
// 아직 말하는 중인 줄은 다음 화면에서 — 자막이 늘어나며 이어지는 동안 읽으면 말이 반쯤 잘린다
|
|
605
|
+
const h = await voice.heard(page, st.heardFrom);
|
|
606
|
+
if (h && h.closed) {
|
|
607
|
+
st.heardFrom += h.closed;
|
|
608
|
+
const said = `(The study's voice said: ${h.lines.slice(0, h.closed).map((l) => `"${l.text}"`).join(" ")})`;
|
|
609
|
+
note = note ? `${note}\n${said}` : said;
|
|
610
|
+
}
|
|
584
611
|
// 패널에는 지금 과제에 머문 시간이 보인다(SDK 알약의 타이머) — 방문자도 같은 걸 본다
|
|
585
612
|
const onTaskMs = Date.now() - rec.startedAt - st.tasks[st.tasks.length - 1].at;
|
|
586
613
|
st.canSkip = !!s.task.skip;
|
|
@@ -602,6 +629,55 @@ class Hub {
|
|
|
602
629
|
return null;
|
|
603
630
|
}
|
|
604
631
|
|
|
632
|
+
/**
|
|
633
|
+
* 모더레이터가 소리로 묻는 것(자막). 아직 답하지 않은 새 말이 들릴 때까지 기다린다 — 말이 이어지면 다 들은 뒤.
|
|
634
|
+
* 위젯이 묻는 상태(ai)를 벗어나거나 한참 조용하면 null
|
|
635
|
+
*/
|
|
636
|
+
async voiceQuestion(rec, ms = 25000) {
|
|
637
|
+
const st = rec.study;
|
|
638
|
+
const page = rec.page;
|
|
639
|
+
let h = null;
|
|
640
|
+
for (let t = 0; t < ms; t += 300) {
|
|
641
|
+
h = await voice.heard(page, st.heardFrom);
|
|
642
|
+
if (!h) return null;
|
|
643
|
+
// 설문 전에(과제 중에) 한 말은 묻는 말이 아니다 — 지나간다. 안 그러면 인사에 답하고 답이 한 칸씩 밀린다(11/04 실험실)
|
|
644
|
+
let skip = 0;
|
|
645
|
+
while (skip < h.lines.length && h.lines[skip].state !== "ai" && (skip < h.closed || !h.speaking)) skip++;
|
|
646
|
+
if (skip) {
|
|
647
|
+
if (TRACE) trace(rec, { ev: "voice_skip", lines: h.lines.slice(0, skip).map((l) => `${l.state}: ${l.text.slice(0, 80)}`) });
|
|
648
|
+
st.heardFrom += skip;
|
|
649
|
+
h = await voice.heard(page, st.heardFrom);
|
|
650
|
+
if (!h) return null;
|
|
651
|
+
}
|
|
652
|
+
if (h.lines.length && h.lines[0].state === "ai") break;
|
|
653
|
+
const s = await study.readStudy(page);
|
|
654
|
+
if (!s || s.state !== "ai") return null;
|
|
655
|
+
await sleep(300);
|
|
656
|
+
}
|
|
657
|
+
if (!h || !h.lines.length || h.lines[0].state !== "ai") return null;
|
|
658
|
+
// 말을 다 들을 때까지(자막이 내려갈 때까지). 서버는 질문을 말하기 시작할 때부터 답을 기다리니 너무 오래는 못 듣는다
|
|
659
|
+
for (let t = 0; t < 10000 && h.speaking; t += 300) {
|
|
660
|
+
await sleep(300);
|
|
661
|
+
h = (await voice.heard(page, st.heardFrom)) || h;
|
|
662
|
+
}
|
|
663
|
+
// 마지막 인사("고마워요")로 설문이 끝나면 위젯이 곧 묻는 상태를 벗어난다 — 그건 답할 말이 아니다
|
|
664
|
+
// 끝났다고 보는 건 위젯이 분명히 다음으로 간 때만 — 못 읽었거나 잠깐 바뀌는 중이면 묻는 말로 둔다.
|
|
665
|
+
// "아니면 끝" 으로 봤더니 진짜 질문을 넘겨 서버가 답 없이 다음으로 갔다(11/04 실험실 p2132)
|
|
666
|
+
let after = null;
|
|
667
|
+
for (let t = 0; t < 1500; t += 300) {
|
|
668
|
+
await sleep(300);
|
|
669
|
+
after = await study.readStudy(page);
|
|
670
|
+
if (after && VOICE_OVER.has(after.state)) break;
|
|
671
|
+
}
|
|
672
|
+
const text = h.lines.map((l) => l.text).join(" ");
|
|
673
|
+
if (TRACE) trace(rec, { ev: "voice_q", text: text.slice(0, 120), after: after ? after.state : null });
|
|
674
|
+
if (after && VOICE_OVER.has(after.state)) {
|
|
675
|
+
st.heardFrom = h.total;
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
return { text, upTo: h.total };
|
|
679
|
+
}
|
|
680
|
+
|
|
605
681
|
/** 과제 질문·시간 초과 창에 답한다. 참여하기로 한 사람이라 닫지 않는다 — 비워 두고 넘기는 건 된다(필수가 아니면) */
|
|
606
682
|
async studyAnswer(rec, decision) {
|
|
607
683
|
const p = rec.studyPending;
|
|
@@ -610,6 +686,31 @@ class Hub {
|
|
|
610
686
|
// 손이 고른 답으로 가는 시간. 읽고 정하는 건 AI 가 이미 했다(11/02: 1.2~3.7초를 또 기다렸다)
|
|
611
687
|
await sleep(300 + Math.random() * 500);
|
|
612
688
|
let said;
|
|
689
|
+
if (p.kind === "voice") {
|
|
690
|
+
// 소리 내어 답한다. 모더레이터가 말을 마칠 때까지 기다린다 — 끼어들지 않는다
|
|
691
|
+
st.heardFrom = p.q.upTo;
|
|
692
|
+
const words = decision.action === "other" ? String(decision.otherText || "").trim() : "";
|
|
693
|
+
for (let t = 0; t < 20000; t += 300) {
|
|
694
|
+
const h = await voice.heard(page, st.heardFrom);
|
|
695
|
+
if (!h || !h.speaking) break;
|
|
696
|
+
await sleep(300);
|
|
697
|
+
}
|
|
698
|
+
let answer = null;
|
|
699
|
+
if (words) {
|
|
700
|
+
try {
|
|
701
|
+
await voice.speak(page, words, { seed: rec.person.id });
|
|
702
|
+
answer = words;
|
|
703
|
+
said = `You said: "${words}"`;
|
|
704
|
+
} catch (e) {
|
|
705
|
+
said = `You tried to answer out loud, but no sound came out (${String(e.message).split("\n")[0]}).`;
|
|
706
|
+
}
|
|
707
|
+
} else said = "You stayed quiet and did not answer.";
|
|
708
|
+
st.answers.push({ task: p.task, question: p.q.text, answer, why: decision.why, spoken: true });
|
|
709
|
+
rec.studyPending = null;
|
|
710
|
+
rec.visit.history.push(`The study's voice asked "${p.q.text}" — ${said}`);
|
|
711
|
+
await sleep(600);
|
|
712
|
+
return [said, "", await this.screen(rec)].join("\n");
|
|
713
|
+
}
|
|
613
714
|
if (p.kind === "timeout") {
|
|
614
715
|
const skip = decision.action === "choose" && decision.choiceIndex === 1;
|
|
615
716
|
await study.press(page, skip ? "timeoutSkip" : "keepGoing").catch(() => {});
|
|
@@ -1062,6 +1163,18 @@ function renderStudyQuestion(q, task) {
|
|
|
1062
1163
|
return L.join("\n");
|
|
1063
1164
|
}
|
|
1064
1165
|
|
|
1166
|
+
/** AI 모더레이터가 소리로 묻는다(음성 리서치) */
|
|
1167
|
+
function renderVoiceQuestion(q, task) {
|
|
1168
|
+
return [
|
|
1169
|
+
"## The study's voice is asking you something, out loud",
|
|
1170
|
+
`This is part of the study you agreed to take part in${task ? ` (it comes after the task "${task}")` : ""}. A voice speaks to you, and its words show as a caption:`,
|
|
1171
|
+
"",
|
|
1172
|
+
`"${q.text}"`,
|
|
1173
|
+
"",
|
|
1174
|
+
"Answer out loud, as yourself, with answer_card: bothered true, action other, and in other_text exactly what you say — spoken words in the language of the voice, the way this person talks, in one go (a short pause ends your answer). It waits for you only a few seconds, so answer right away. Close only if you would stay quiet.",
|
|
1175
|
+
].join("\n");
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1065
1178
|
/** 과제에 시간이 오래 걸리면 패널이 묻는다 */
|
|
1066
1179
|
function renderStudyTimeout(t, task) {
|
|
1067
1180
|
return [
|
package/mcp/report.js
CHANGED
|
@@ -292,6 +292,11 @@ const sendSteps = (session, body) => keyed("PUT", `/agent/questions/steps/${enco
|
|
|
292
292
|
/** 리서치 초안(new-research) — 무엇을 리서치할지 고를 근거와, Inbox 초안으로 저장 */
|
|
293
293
|
const getResearchContext = () => keyed("GET", "/agent/researches/context", null, 30000);
|
|
294
294
|
const saveResearchDraft = (body) => keyed("POST", "/agent/researches/drafts", body, 30000);
|
|
295
|
+
/** 리서치 Report(fix) — 발견 목록, 발견 하나와 근거, 고친 기록 */
|
|
296
|
+
const rq = (audience) => (audience === "real" ? "?audience=real" : "?audience=virtual");
|
|
297
|
+
const getReport = (researchId, audience) => keyed("GET", `/agent/researches/${encodeURIComponent(researchId)}/report${rq(audience)}`);
|
|
298
|
+
const getFinding = (researchId, key, audience) => keyed("GET", `/agent/researches/${encodeURIComponent(researchId)}/report/findings/${encodeURIComponent(key)}${rq(audience)}`);
|
|
299
|
+
const saveFix = (researchId, key, body) => keyed("PUT", `/agent/researches/${encodeURIComponent(researchId)}/report/findings/${encodeURIComponent(key)}/fix`, body);
|
|
295
300
|
/** 대시보드 주소. API 주소에서 /koa 를 뗀다 */
|
|
296
301
|
const dashboardUrl = (p) => apiBase().replace(/\/koa$/, "") + p;
|
|
297
302
|
|
|
@@ -315,5 +320,8 @@ module.exports = {
|
|
|
315
320
|
sendSteps,
|
|
316
321
|
getResearchContext,
|
|
317
322
|
saveResearchDraft,
|
|
323
|
+
getReport,
|
|
324
|
+
getFinding,
|
|
325
|
+
saveFix,
|
|
318
326
|
dashboardUrl,
|
|
319
327
|
};
|
package/mcp/server.js
CHANGED
|
@@ -556,6 +556,87 @@ server.registerTool(
|
|
|
556
556
|
|
|
557
557
|
const recorder = new PathRecorder(hub);
|
|
558
558
|
|
|
559
|
+
// ── 리서치 Report 의 발견 고치기(fix) ──
|
|
560
|
+
const audienceArg = z.enum(["virtual", "real"]).optional().describe("Which report: virtual users (default) or real people");
|
|
561
|
+
const fixLine = (f) => (f ? ` — ${f.status === "fixed" ? "fixed" : "being fixed"}${f.agent ? ` by ${f.agent}` : ""}` : "");
|
|
562
|
+
const whoWord = (audience) => (audience === "real" ? "people" : "virtual users");
|
|
563
|
+
|
|
564
|
+
server.registerTool(
|
|
565
|
+
"report_findings",
|
|
566
|
+
{
|
|
567
|
+
description:
|
|
568
|
+
"List the findings of a Usercase research's latest report (key, severity, title, who, and whether one is being fixed). Use when the fix command has no finding key.",
|
|
569
|
+
inputSchema: { research: z.string().describe("The research id"), audience: audienceArg },
|
|
570
|
+
},
|
|
571
|
+
wrap(async ({ research, audience }) => {
|
|
572
|
+
const r = await report.getReport(research, audience);
|
|
573
|
+
if (!r.findings)
|
|
574
|
+
return text(r.making ? "The report is being written right now. Try again in a minute." : `This research has no ${whoWord(r.audience)} report yet. It is written once 5 have finished.`);
|
|
575
|
+
return text(
|
|
576
|
+
[
|
|
577
|
+
`${r.research.title} — report of ${r.people} ${whoWord(r.audience)}`,
|
|
578
|
+
`Summary: ${r.summary}`,
|
|
579
|
+
"",
|
|
580
|
+
...r.findings.map((f) => `${f.key} [${f.severity}] ${f.title} — ${f.who}${fixLine(f.fix)}`),
|
|
581
|
+
"",
|
|
582
|
+
`Report: ${r.url}`,
|
|
583
|
+
].join("\n"),
|
|
584
|
+
);
|
|
585
|
+
}),
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
server.registerTool(
|
|
589
|
+
"report_finding",
|
|
590
|
+
{
|
|
591
|
+
description:
|
|
592
|
+
"Read one finding of a Usercase research report with all its evidence: why, who, the screen checks from the recordings, the paths people took, what they wrote, and the suggested changes. Read it before looking for the code.",
|
|
593
|
+
inputSchema: { research: z.string().describe("The research id"), finding: z.string().describe("Finding key, e.g. F1"), audience: audienceArg },
|
|
594
|
+
},
|
|
595
|
+
wrap(async ({ research, finding, audience }) => {
|
|
596
|
+
const r = await report.getFinding(research, finding, audience);
|
|
597
|
+
const f = r.finding;
|
|
598
|
+
const e = r.evidence;
|
|
599
|
+
const L = [
|
|
600
|
+
`${f.key} [${f.severity}] ${f.title}${fixLine(f.fix)}`,
|
|
601
|
+
`Research: ${r.research.title} (report of ${r.people} ${whoWord(r.audience)})`,
|
|
602
|
+
"",
|
|
603
|
+
`Why: ${f.why}`,
|
|
604
|
+
`Who: ${f.who}`,
|
|
605
|
+
`Where: ${f.where}`,
|
|
606
|
+
];
|
|
607
|
+
if (e.screen.length) L.push("", "What the screen showed (from the recordings):", ...e.screen.map((x) => `- ${x.replace(/^Screen check:\s*/, "")}`));
|
|
608
|
+
if (e.paths.length) L.push("", "Paths people took:", ...e.paths.map((p) => `- ${(p.steps || []).join(" → ")} (${p.people} people)`));
|
|
609
|
+
if (e.words.length) L.push("", "What people wrote (grouped by meaning, with examples):", ...e.words.map((w) => `- ${w.text}`));
|
|
610
|
+
if (e.numbers.length) L.push("", "Numbers:", ...e.numbers.map((x) => `- ${x}`));
|
|
611
|
+
L.push("", "Suggested changes (written without seeing the code):", ...f.changes.map((c, i) => `${i + 1}. ${c}`), `Check after the fix: ${f.measure}`);
|
|
612
|
+
if (r.sessionsUrl) L.push("", `Watch these sessions: ${r.sessionsUrl}`);
|
|
613
|
+
L.push(`Report: ${r.reportUrl}`);
|
|
614
|
+
return text(L.join("\n"));
|
|
615
|
+
}),
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
server.registerTool(
|
|
619
|
+
"report_fix",
|
|
620
|
+
{
|
|
621
|
+
description:
|
|
622
|
+
"Record on the research report that you are fixing a finding (status fixing, once the user approved the change) or have fixed it (status fixed, with the files, a one-sentence summary for the team, and the pull request link if any). The report shows it on that finding.",
|
|
623
|
+
inputSchema: {
|
|
624
|
+
research: z.string().describe("The research id"),
|
|
625
|
+
finding: z.string().describe("Finding key, e.g. F1"),
|
|
626
|
+
audience: audienceArg,
|
|
627
|
+
status: z.enum(["fixing", "fixed"]),
|
|
628
|
+
files: z.array(z.string()).optional().describe("Repository paths of the files changed (or to be changed)"),
|
|
629
|
+
summary: z.string().optional().describe("One sentence for the team: what visitors now see differently"),
|
|
630
|
+
prUrl: z.string().optional().describe("Pull request link, if one was opened"),
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
wrap(async ({ research, finding, audience, status, files, summary, prUrl }) => {
|
|
634
|
+
const agent = CODEX ? "Codex" : OTHER ? null : "Claude Code";
|
|
635
|
+
const r = await report.saveFix(research, finding, { audience: audience || "virtual", status, agent, files, summary, prUrl });
|
|
636
|
+
return text(`${r.key} is marked ${r.status} on the report: ${r.reportUrl}`);
|
|
637
|
+
}),
|
|
638
|
+
);
|
|
639
|
+
|
|
559
640
|
server.registerTool(
|
|
560
641
|
"path_tasks",
|
|
561
642
|
{
|
|
@@ -1047,6 +1128,21 @@ const COMMANDS = {
|
|
|
1047
1128
|
"Only this repository's folder changes. Other repositories keep their own project. Until the new project is approved, the current connection stays.",
|
|
1048
1129
|
].join("\n"),
|
|
1049
1130
|
},
|
|
1131
|
+
// 리서치 Report 의 발견 하나를 고친다 — 대시보드 Report 의 "Fix with your agent" 명령
|
|
1132
|
+
fix: {
|
|
1133
|
+
description: "Fix a finding from a Usercase research report in this repository's code",
|
|
1134
|
+
argsSchema: { research: z.string().optional(), finding: z.string().optional(), audience: z.string().optional() },
|
|
1135
|
+
body: ({ research, finding, audience }) =>
|
|
1136
|
+
agentDoc("fix.md", {
|
|
1137
|
+
research: research || "(ask the user — the dashboard Report shows the command)",
|
|
1138
|
+
finding: finding ? String(finding).toUpperCase() : "(not given — list the findings and ask)",
|
|
1139
|
+
audience: audience === "real" ? "real (the report of real people)" : "virtual (the report of virtual users)",
|
|
1140
|
+
check:
|
|
1141
|
+
audience === "real"
|
|
1142
|
+
? "the next report, made as more people take part, shows whether people still hit it."
|
|
1143
|
+
: `run the same tasks with virtual users again (${cmd("research")} <site> 5 research=${research || "<research id>"}). The next report shows whether they still hit it.`,
|
|
1144
|
+
}),
|
|
1145
|
+
},
|
|
1050
1146
|
steps: {
|
|
1051
1147
|
description: "Write the steps for the question open in your dashboard (New question → Several steps)",
|
|
1052
1148
|
argsSchema: { session: z.string().optional().describe("The window id shown in the dashboard command") },
|
|
@@ -1059,12 +1155,13 @@ if (PLUGIN || CODEX || OTHER) {
|
|
|
1059
1155
|
"instructions",
|
|
1060
1156
|
{
|
|
1061
1157
|
description:
|
|
1062
|
-
"The step-by-step instructions for a Usercase command (research, install, path, question, new-research, steps, connect). The plugin's /usercase:* commands call this; follow what it returns as the user's request.",
|
|
1158
|
+
"The step-by-step instructions for a Usercase command (research, install, path, question, new-research, steps, connect, fix). The plugin's /usercase:* commands call this; follow what it returns as the user's request.",
|
|
1063
1159
|
inputSchema: {
|
|
1064
1160
|
command: z.enum(Object.keys(COMMANDS)),
|
|
1065
1161
|
...COMMANDS.research.argsSchema,
|
|
1066
1162
|
...COMMANDS.path.argsSchema,
|
|
1067
1163
|
...COMMANDS.steps.argsSchema,
|
|
1164
|
+
...COMMANDS.fix.argsSchema,
|
|
1068
1165
|
},
|
|
1069
1166
|
},
|
|
1070
1167
|
wrap(async ({ command, ...args }) => text(COMMANDS[command].body(byName(args, COMMANDS[command].argsSchema)))),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usercase/mcp",
|
|
3
|
-
"version": "0.2.11-next.
|
|
3
|
+
"version": "0.2.11-next.2",
|
|
4
4
|
"description": "Virtual users that browse your website and tell you what they thought — an MCP server for Claude Code",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"homepage": "https://usercase.io",
|
package/src/browser/open.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
const { markAgent, withAgentUA } = require("./card.js");
|
|
14
|
+
const { installVoice } = require("./voice.js");
|
|
14
15
|
|
|
15
16
|
/** HTTPS 사이트에서 localhost SDK·소켓을 부르려면 사설망 제한을 꺼야 한다. 로컬 SDK 로 돌릴 때만 필요하다 */
|
|
16
17
|
const LOCAL_NETWORK_ARGS = ["--disable-features=LocalNetworkAccessChecks,PrivateNetworkAccessSendPreflights,PrivateNetworkAccessRespectPreflightResults"];
|
|
@@ -54,6 +55,8 @@ async function openVisit(browser, devices, { agent, site, guard, device = "deskt
|
|
|
54
55
|
await guard.apply(ctx, { onOffsite, onWrite, onExternal });
|
|
55
56
|
// **반드시 guard 뒤.** markAgent 의 비밀값 route 가 마지막에 걸려야 먼저 돈다
|
|
56
57
|
await markAgent(ctx, agent, { siteOrigin: site, persona, research, debugCards, hideDevOverlay: guard.local });
|
|
58
|
+
// 리서치 참여자는 목소리를 가진다 — AI 모더레이터 리서치가 마이크를 달라면 우리 목소리를 준다(voice.js)
|
|
59
|
+
if (research) await installVoice(ctx);
|
|
57
60
|
return ctx;
|
|
58
61
|
}
|
|
59
62
|
|
package/src/browser/study.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* 질문에 무엇을 답하는지, 포기할지는 방문자가 정한다.
|
|
9
9
|
*
|
|
10
10
|
* 화면 구성(earningbirds-core layouts/Widget.ts · components/frost/*):
|
|
11
|
-
* #eb-pill[data-state] — idle·invite·consent·loading·testing·timeout·survey·next·submit·done …
|
|
11
|
+
* #eb-pill[data-state] — idle·invite·consent·mic·loading·testing·timeout·survey·ai·next·submit·done …
|
|
12
|
+
* mic: #eb-mic-start(음성 리서치의 마이크 확인) · ai: AI 모더레이터가 소리로 묻는 중(자막은 voice.js 가 읽는다)
|
|
12
13
|
* #eb-frost(.eb-visible 이면 열림) > #eb-frost-body
|
|
13
14
|
* testing: .eb-frost-testing .eb-gf-section(진행) .eb-gf-flat-title(과제) .eb-gf-flat-sub(설명) #eb-task-lefttime-text #eb-test-skip
|
|
14
15
|
* timeout: .eb-frost-timeout #eb-timeout-continue #eb-timeout-skip
|
|
@@ -152,7 +153,21 @@ async function join(page, { inviteMs = 25000 } = {}) {
|
|
|
152
153
|
await sleep(1500 + Math.random() * 2500);
|
|
153
154
|
await page.locator("#eb-plugin #eb-consent-agree").click({ timeout: 5000 }).catch(() => {});
|
|
154
155
|
}
|
|
155
|
-
|
|
156
|
+
let s = await waitState(page, ["mic", "testing", "survey", "done"], 40000);
|
|
157
|
+
// AI 모더레이터(음성) 리서치는 동의 뒤 마이크 확인 화면이 있다. 마이크는 voice.js 가 준 우리 목소리다 —
|
|
158
|
+
// 연결됐다고 뜨면 사람처럼 잠깐 보고 시작을 누른다. Gemini 를 여는 데 시간이 걸려 시작까지 넉넉히 기다린다
|
|
159
|
+
if (s && s.state === "mic") {
|
|
160
|
+
const ready = await page
|
|
161
|
+
.locator("#eb-plugin #eb-mic-start:not([disabled])")
|
|
162
|
+
.first()
|
|
163
|
+
.waitFor({ timeout: 15000 })
|
|
164
|
+
.then(() => true)
|
|
165
|
+
.catch(() => false);
|
|
166
|
+
if (!ready) return { ok: false, why: "the study's microphone check did not accept the virtual user's microphone" };
|
|
167
|
+
await sleep(1200 + Math.random() * 1800);
|
|
168
|
+
await page.locator("#eb-plugin #eb-mic-start").first().click({ timeout: 5000 }).catch(() => {});
|
|
169
|
+
s = await waitState(page, ["testing", "survey", "done"], 60000);
|
|
170
|
+
}
|
|
156
171
|
if (!s || s.state !== "testing") return { ok: false, why: `the research did not start (the study panel is at "${s ? s.state || "?" : "nothing"}")` };
|
|
157
172
|
return { ok: true, study: s };
|
|
158
173
|
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 가상 사용자의 목소리 — AI 모더레이터(음성) 리서치에 참여할 때.
|
|
4
|
+
*
|
|
5
|
+
* 사람 참여자와 같은 길로 간다: SDK 는 마이크를 받고, 그 마이크로 들어온 소리를 Gemini·서버 음성 인식이 듣는다.
|
|
6
|
+
* - 귀: 모더레이터가 하는 말은 위젯 자막(.eb-ai-caption-text, core components/widget/Widget.ts)으로 뜬다. 그것을 읽는다
|
|
7
|
+
* - 입: 페이지의 마이크 요청(getUserMedia)이 진짜 마이크 대신 우리가 만든 오디오 줄(MediaStreamDestination)을 받는다.
|
|
8
|
+
* 할 말은 이 컴퓨터에 깔린 음성(Windows 음성 · macOS say · espeak)으로 wav 를 만들어 그 줄로 튼다 — 바깥 서비스·비용 없음
|
|
9
|
+
*
|
|
10
|
+
* 리서치 참여 방문에만 건다(open.js). 음성을 안 쓰는 리서치는 마이크를 부르지 않으니 아무 일도 없다.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const os = require("os");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
const { execFile } = require("child_process");
|
|
17
|
+
|
|
18
|
+
/** 페이지가 뜨기 전에 심는다. 마이크·권한·장치 목록을 우리 오디오 줄로 바꾸고, 모더레이터 자막을 적어 둔다 */
|
|
19
|
+
const PAGE_SCRIPT = `(() => {
|
|
20
|
+
if (window.__ucVoice) return;
|
|
21
|
+
const md = navigator.mediaDevices;
|
|
22
|
+
if (!md || !md.getUserMedia) return;
|
|
23
|
+
let ac = null;
|
|
24
|
+
let dest = null;
|
|
25
|
+
const line = () => {
|
|
26
|
+
if (!ac) {
|
|
27
|
+
ac = new (window.AudioContext || window.webkitAudioContext)();
|
|
28
|
+
dest = ac.createMediaStreamDestination();
|
|
29
|
+
}
|
|
30
|
+
if (ac.state === "suspended") ac.resume().catch(() => {});
|
|
31
|
+
return dest;
|
|
32
|
+
};
|
|
33
|
+
const MIC = { deviceId: "usercase-voice", groupId: "usercase-voice", kind: "audioinput", label: "Microphone" };
|
|
34
|
+
MIC.toJSON = () => ({ deviceId: MIC.deviceId, groupId: MIC.groupId, kind: MIC.kind, label: MIC.label });
|
|
35
|
+
const enumerate = md.enumerateDevices ? md.enumerateDevices.bind(md) : async () => [];
|
|
36
|
+
md.enumerateDevices = async () => {
|
|
37
|
+
const list = await enumerate().catch(() => []);
|
|
38
|
+
return [MIC, ...list.filter((d) => d.kind !== "audioinput")];
|
|
39
|
+
};
|
|
40
|
+
const gum = md.getUserMedia.bind(md);
|
|
41
|
+
md.getUserMedia = async (c) => {
|
|
42
|
+
if (c && c.audio && !c.video) {
|
|
43
|
+
const d = line();
|
|
44
|
+
// 받는 쪽이 트랙을 멈춰도(stop) 원본 줄은 살아 있게 복제해 준다
|
|
45
|
+
return new MediaStream(d.stream.getAudioTracks().map((t) => t.clone()));
|
|
46
|
+
}
|
|
47
|
+
return gum(c);
|
|
48
|
+
};
|
|
49
|
+
if (navigator.permissions && navigator.permissions.query) {
|
|
50
|
+
const query = navigator.permissions.query.bind(navigator.permissions);
|
|
51
|
+
navigator.permissions.query = (p) =>
|
|
52
|
+
p && p.name === "microphone"
|
|
53
|
+
? Promise.resolve({ name: "microphone", state: "granted", onchange: null, addEventListener() {}, removeEventListener() {} })
|
|
54
|
+
: query(p);
|
|
55
|
+
}
|
|
56
|
+
// 모더레이터 자막. 보이는 동안이 말하는 동안이다. 글이 늘어나며 이어지면(스트리밍 자막) 한 줄로 친다
|
|
57
|
+
const heard = [];
|
|
58
|
+
let shownText = "";
|
|
59
|
+
let speaking = false;
|
|
60
|
+
setInterval(() => {
|
|
61
|
+
const host = document.getElementById("eb-plugin");
|
|
62
|
+
const root = (host && host.shadowRoot) || document;
|
|
63
|
+
const box = root.querySelector(".eb-ai-caption.eb-ai-caption-visible .eb-ai-caption-text");
|
|
64
|
+
const t = box ? (box.innerText || box.textContent || "").replace(/\\s+/g, " ").trim() : "";
|
|
65
|
+
speaking = !!t;
|
|
66
|
+
if (!t || t === shownText) return;
|
|
67
|
+
// 그 말이 나올 때 위젯 상태 — ai 면 소리로 묻는 설문(답을 기다린다), 그 밖(과제 중)은 그냥 하는 말이다
|
|
68
|
+
const pill = root.querySelector("#eb-pill");
|
|
69
|
+
const state = (pill && pill.getAttribute("data-state")) || "";
|
|
70
|
+
const last = heard[heard.length - 1];
|
|
71
|
+
if (last && shownText && t.startsWith(shownText)) {
|
|
72
|
+
last.text = t;
|
|
73
|
+
last.at = Date.now();
|
|
74
|
+
} else heard.push({ n: heard.length, text: t, at: Date.now(), state });
|
|
75
|
+
shownText = t;
|
|
76
|
+
}, 200);
|
|
77
|
+
window.__ucVoice = {
|
|
78
|
+
used: () => !!ac,
|
|
79
|
+
// closed: 다 말한 줄 수(from 부터). 말하는 중인 마지막 줄은 아직 늘어날 수 있다
|
|
80
|
+
heard: (from) => {
|
|
81
|
+
const lines = heard.slice(from || 0);
|
|
82
|
+
return { speaking, lines, total: heard.length, closed: speaking && lines.length ? lines.length - 1 : lines.length };
|
|
83
|
+
},
|
|
84
|
+
say: async (b64) => {
|
|
85
|
+
const d = line();
|
|
86
|
+
if (ac.state !== "running") await ac.resume().catch(() => {});
|
|
87
|
+
const bin = atob(b64);
|
|
88
|
+
const buf = new Uint8Array(bin.length);
|
|
89
|
+
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
|
90
|
+
const audio = await ac.decodeAudioData(buf.buffer);
|
|
91
|
+
const src = ac.createBufferSource();
|
|
92
|
+
src.buffer = audio;
|
|
93
|
+
src.connect(d);
|
|
94
|
+
await new Promise((r) => {
|
|
95
|
+
src.onended = r;
|
|
96
|
+
src.start();
|
|
97
|
+
});
|
|
98
|
+
return audio.duration;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
})();`;
|
|
102
|
+
|
|
103
|
+
/** 방문 컨텍스트에 목소리를 건다. 페이지보다 먼저 */
|
|
104
|
+
async function installVoice(ctx) {
|
|
105
|
+
await ctx.addInitScript({ content: PAGE_SCRIPT });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 글의 언어. 이 컴퓨터 음성을 고르는 데만 쓴다 */
|
|
109
|
+
function langOf(text) {
|
|
110
|
+
const s = String(text || "");
|
|
111
|
+
if (/[가-힣]/.test(s)) return "ko";
|
|
112
|
+
if (/[-ヿ]/.test(s)) return "ja";
|
|
113
|
+
if (/[一-鿿]/.test(s)) return "zh";
|
|
114
|
+
if (/[Ѐ-ӿ]/.test(s)) return "ru";
|
|
115
|
+
return "en";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const run = (cmd, args, ms = 30000) =>
|
|
119
|
+
new Promise((resolve, reject) =>
|
|
120
|
+
execFile(cmd, args, { timeout: ms, windowsHide: true, maxBuffer: 4 << 20 }, (err, stdout, stderr) =>
|
|
121
|
+
err ? reject(new Error(String(stderr || err.message).trim().split("\n")[0])) : resolve(String(stdout)),
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
/** 사람마다 같은 목소리 — 같은 언어 음성이 여럿이면 사람 번호로 하나를 고정해 고른다 */
|
|
126
|
+
const pickIndex = (seed, n) => {
|
|
127
|
+
let h = 0;
|
|
128
|
+
for (const c of String(seed || "")) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
|
129
|
+
return n ? h % n : 0;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
async function synthWindows(textFile, out, lang, seed) {
|
|
133
|
+
const ps = [
|
|
134
|
+
"$ErrorActionPreference='Stop'",
|
|
135
|
+
"Add-Type -AssemblyName System.Speech",
|
|
136
|
+
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer",
|
|
137
|
+
`$v = @($s.GetInstalledVoices() | Where-Object { $_.Enabled -and $_.VoiceInfo.Culture.Name -like '${lang}*' })`,
|
|
138
|
+
`if ($v.Count -gt 0) { $s.SelectVoice($v[${pickIndex(seed, 97)} % $v.Count].VoiceInfo.Name) }`,
|
|
139
|
+
`$s.SetOutputToWaveFile('${out.replace(/'/g, "''")}')`,
|
|
140
|
+
`$s.Speak([IO.File]::ReadAllText('${textFile.replace(/'/g, "''")}', [Text.Encoding]::UTF8))`,
|
|
141
|
+
"$s.Dispose()",
|
|
142
|
+
].join("; ");
|
|
143
|
+
await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps]);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function synthMac(textFile, out, lang, seed) {
|
|
147
|
+
// say -v '?' : "Yuna ko_KR # 안녕하세요" — 그 언어 음성 이름들
|
|
148
|
+
let voice = null;
|
|
149
|
+
try {
|
|
150
|
+
const names = (await run("say", ["-v", "?"]))
|
|
151
|
+
.split("\n")
|
|
152
|
+
.map((l) => l.match(/^(.+?)\s+([a-z]{2})[_-][A-Z]{2}\s+#/))
|
|
153
|
+
.filter((m) => m && m[2] === lang)
|
|
154
|
+
.map((m) => m[1].trim());
|
|
155
|
+
if (names.length) voice = names[pickIndex(seed, names.length)];
|
|
156
|
+
} catch {
|
|
157
|
+
// 목록을 못 읽으면 기본 음성
|
|
158
|
+
}
|
|
159
|
+
await run("say", [...(voice ? ["-v", voice] : []), "--file-format=WAVE", "--data-format=LEI16@22050", "-o", out, "-f", textFile]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function synthLinux(textFile, out, lang) {
|
|
163
|
+
for (const cmd of ["espeak-ng", "espeak"]) {
|
|
164
|
+
try {
|
|
165
|
+
await run(cmd, ["-v", lang, "-w", out, "-f", textFile]);
|
|
166
|
+
return;
|
|
167
|
+
} catch (e) {
|
|
168
|
+
if (!/ENOENT|not found/i.test(e.message)) throw e;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
throw new Error("no speech voice on this computer (install espeak-ng)");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 문장 사이 쉼을 줄인다. 서버 음성 인식은 0.7초 조용하면 답이 끝난 것으로 친다(server voiceConfig speechEndTimeoutSec) —
|
|
176
|
+
* 이 컴퓨터 음성은 문장마다 그보다 길게 쉬어서 두 문장째가 잘린다. 16비트 PCM wav 만 고친다(그 밖은 그대로)
|
|
177
|
+
*/
|
|
178
|
+
function tightenPauses(wav, { maxGapSec = 0.3, floor = 400 } = {}) {
|
|
179
|
+
if (wav.length < 44 || wav.toString("ascii", 0, 4) !== "RIFF" || wav.toString("ascii", 8, 12) !== "WAVE") return wav;
|
|
180
|
+
let off = 12;
|
|
181
|
+
let fmt = null;
|
|
182
|
+
while (off + 8 <= wav.length) {
|
|
183
|
+
const id = wav.toString("ascii", off, off + 4);
|
|
184
|
+
const size = wav.readUInt32LE(off + 4);
|
|
185
|
+
if (id === "fmt ") fmt = { channels: wav.readUInt16LE(off + 10), rate: wav.readUInt32LE(off + 12), bits: wav.readUInt16LE(off + 22) };
|
|
186
|
+
if (id === "data") {
|
|
187
|
+
if (!fmt || fmt.bits !== 16) return wav;
|
|
188
|
+
const frame = 2 * fmt.channels;
|
|
189
|
+
const start = off + 8;
|
|
190
|
+
const end = Math.min(wav.length, start + size);
|
|
191
|
+
const maxGap = Math.round(maxGapSec * fmt.rate) * frame;
|
|
192
|
+
const out = [];
|
|
193
|
+
let run = 0; // 이어진 조용한 바이트
|
|
194
|
+
for (let i = start; i + frame <= end; i += frame) {
|
|
195
|
+
let loud = false;
|
|
196
|
+
for (let c = 0; c < fmt.channels; c++) if (Math.abs(wav.readInt16LE(i + 2 * c)) > floor) loud = true;
|
|
197
|
+
run = loud ? 0 : run + frame;
|
|
198
|
+
if (run <= maxGap) out.push(wav.subarray(i, i + frame));
|
|
199
|
+
}
|
|
200
|
+
const data = Buffer.concat(out);
|
|
201
|
+
const head = Buffer.from(wav.subarray(0, start));
|
|
202
|
+
head.writeUInt32LE(data.length, off + 4);
|
|
203
|
+
head.writeUInt32LE(head.length - 8 + data.length, 4);
|
|
204
|
+
return Buffer.concat([head, data]);
|
|
205
|
+
}
|
|
206
|
+
off += 8 + size + (size & 1);
|
|
207
|
+
}
|
|
208
|
+
return wav;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 할 말을 이 컴퓨터 음성으로 wav 로. @returns Buffer */
|
|
212
|
+
async function synth(text, { seed } = {}) {
|
|
213
|
+
const lang = langOf(text);
|
|
214
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "usercase-voice-"));
|
|
215
|
+
const textFile = path.join(dir, "say.txt");
|
|
216
|
+
const out = path.join(dir, "say.wav");
|
|
217
|
+
try {
|
|
218
|
+
fs.writeFileSync(textFile, String(text), "utf8");
|
|
219
|
+
if (process.platform === "win32") await synthWindows(textFile, out, lang, seed);
|
|
220
|
+
else if (process.platform === "darwin") await synthMac(textFile, out, lang, seed);
|
|
221
|
+
else await synthLinux(textFile, out, lang);
|
|
222
|
+
const wav = fs.readFileSync(out);
|
|
223
|
+
if (wav.length < 100) throw new Error("the voice made no sound");
|
|
224
|
+
return tightenPauses(wav);
|
|
225
|
+
} finally {
|
|
226
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 소리 내어 말한다 — 끝까지 틀고 돌아온다. @returns 말한 초 */
|
|
231
|
+
async function speak(page, text, opts = {}) {
|
|
232
|
+
const wav = await synth(text, opts);
|
|
233
|
+
return page.evaluate((b64) => window.__ucVoice.say(b64), wav.toString("base64"));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 모더레이터가 한 말(자막). from 번째 줄부터. 페이지가 아직 안 심겼으면 null */
|
|
237
|
+
async function heard(page, from = 0) {
|
|
238
|
+
return page.evaluate((f) => (window.__ucVoice ? window.__ucVoice.heard(f) : null), from).catch(() => null);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
module.exports = { installVoice, speak, synth, heard, langOf, tightenPauses, PAGE_SCRIPT };
|