Adds the full Next.js app (app/, components/, lib/, scripts/, assets/, docs/, Dockerfile, compose, configs) to version control. Security: - CIPC BizPortal customer-code logins (Login 1 + Login 3) now read CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE / CIPC_LOGIN3_PASSWORD from .env.local instead of source. - BizPortal personal login in apply-bizportal route reads BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD. - Added .env.example as the committed template. .gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright storage_state.json (live CIPC cookies), scratch dirs, client PDFs and debug screenshots.
152 lines
5.5 KiB
TypeScript
152 lines
5.5 KiB
TypeScript
// app/voice-demo/page.tsx
|
|
// Demonstrates the symmetric voice↔voice loop in Ysterbaard.
|
|
//
|
|
// Open http://localhost:6001/voice-demo
|
|
// 1. Tap the 🎤 mic, dictate a question → text appears in the field.
|
|
// 2. Click "Send" → Ysterbaard auto-narrates its reply aloud.
|
|
// 3. Now type a question instead → click "Send" → reply stays silent.
|
|
//
|
|
// The speaker icon on the field replays the user text; the 🔇 toggle mutes
|
|
// speak-back globally (persisted to localStorage).
|
|
|
|
"use client";
|
|
|
|
import { useState } from "react";
|
|
import VoiceField, { type InputMode } from "@/components/VoiceField";
|
|
import AgentVoiceReply from "@/components/AgentVoiceReply";
|
|
|
|
/**
|
|
* Toy canned-reply engine. Real Ysterbaard replies would come from an LLM
|
|
* or a backend route. Here we generate a short acknowledgement from the
|
|
* user's text so the demo has something to speak back.
|
|
*/
|
|
function generateReply(userText: string): string {
|
|
const t = userText.trim();
|
|
if (!t) return "";
|
|
// Friendly canned phrasing — the demo isn't trying to be clever, just to
|
|
// prove the speak-back loop fires.
|
|
return `Got it. You said: ${t}. Ysterbaard has received your note and will follow up shortly.`;
|
|
}
|
|
|
|
export default function VoiceDemoPage() {
|
|
const [draft, setDraft] = useState("");
|
|
const [inputMode, setInputMode] = useState<InputMode>("text");
|
|
const [agentReply, setAgentReply] = useState("");
|
|
const [replyVersion, setReplyVersion] = useState(0);
|
|
const [history, setHistory] = useState<Array<{ user: string; mode: InputMode; agent: string }>>([]);
|
|
|
|
const send = () => {
|
|
const userText = draft.trim();
|
|
if (!userText) return;
|
|
const reply = generateReply(userText);
|
|
setHistory((h) => [...h, { user: userText, mode: inputMode, agent: reply }]);
|
|
setAgentReply(reply);
|
|
setReplyVersion((v) => v + 1);
|
|
setDraft("");
|
|
// Reset to text mode after send so the next interaction defaults to typing
|
|
// unless the user explicitly uses the mic again.
|
|
if (inputMode === "voice") {
|
|
setInputMode("text");
|
|
}
|
|
};
|
|
|
|
const clearHistory = () => {
|
|
setHistory([]);
|
|
setAgentReply("");
|
|
};
|
|
|
|
return (
|
|
<main className="mx-auto max-w-2xl px-4 py-10">
|
|
<header className="mb-6">
|
|
<h1 className="text-2xl font-semibold text-neutral-900">Voice demo</h1>
|
|
<p className="mt-1 text-sm text-neutral-600">
|
|
Speak to Ysterbaard → Ysterbaard speaks back. Type → it stays quiet.
|
|
Mic, 🔊, and mute-toggle are all wired into one VoiceField below.
|
|
</p>
|
|
</header>
|
|
|
|
<section className="rounded-xl border border-neutral-200 bg-white p-5 shadow-sm">
|
|
<VoiceField
|
|
id="voice-demo"
|
|
label="Your message"
|
|
hint="(🎤 = dictate, 🔊 = replay, 🔇 = global mute)"
|
|
value={draft}
|
|
onChange={setDraft}
|
|
onInputModeChange={setInputMode}
|
|
rows={3}
|
|
placeholder='Try: "Hello Ysterbaard, my preferred effective date is the first of next month."'
|
|
/>
|
|
|
|
<div className="mt-3 flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={send}
|
|
disabled={!draft.trim()}
|
|
className="rounded-lg bg-rose-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-rose-600 disabled:cursor-not-allowed disabled:bg-neutral-300"
|
|
>
|
|
Send
|
|
</button>
|
|
<span className="text-xs text-neutral-500">
|
|
current mode: <span className="font-medium">{inputMode}</span>
|
|
</span>
|
|
{history.length > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={clearHistory}
|
|
className="ml-auto text-xs text-neutral-500 underline hover:text-neutral-700"
|
|
>
|
|
clear history
|
|
</button>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{agentReply && (
|
|
<section
|
|
aria-live="polite"
|
|
className="mt-5 rounded-xl border border-emerald-200 bg-emerald-50 p-4 shadow-sm"
|
|
>
|
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-emerald-700">
|
|
Ysterbaard
|
|
{inputMode === "voice" && (
|
|
<span className="ml-2 inline-flex items-center gap-1 rounded-full bg-emerald-200 px-2 py-0.5 text-[10px] text-emerald-900">
|
|
🔊 auto-speaking
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-neutral-900">{agentReply}</p>
|
|
</section>
|
|
)}
|
|
|
|
{history.length > 0 && (
|
|
<section className="mt-6">
|
|
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wider text-neutral-700">
|
|
Conversation
|
|
</h2>
|
|
<ul className="space-y-2 text-sm">
|
|
{history.map((turn, i) => (
|
|
<li key={i} className="rounded-lg border border-neutral-200 bg-white p-3">
|
|
<div className="mb-1 flex items-center gap-2">
|
|
<span className="rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] uppercase text-neutral-600">
|
|
you · {turn.mode}
|
|
</span>
|
|
</div>
|
|
<p className="text-neutral-800">{turn.user}</p>
|
|
<div className="my-1 ml-3 border-l-2 border-emerald-300 pl-3 text-neutral-700">
|
|
{turn.agent}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
|
|
{/* The actual TTS loop. Renders nothing visible. */}
|
|
<AgentVoiceReply
|
|
replyText={agentReply}
|
|
inputMode={inputMode}
|
|
replyVersion={replyVersion}
|
|
/>
|
|
</main>
|
|
);
|
|
}
|