Workflow-App/Workflow/app/voice-demo/page.tsx
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
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.
2026-10-18 15:21:45 +02:00

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>
);
}