# 🎙️ Voice Agent Builder — คำสั่งสำหรับ AI (Claude / Codex)

> **วิธีใช้:** แนบไฟล์นี้ให้ Claude หรือ Codex แล้วพิมพ์ว่า "สร้าง voice agent ให้ฉันตามไฟล์นี้"
> AI จะ **สัมภาษณ์คุณก่อน** ว่าจะทำธุรกิจอะไร แล้ว **สร้างระบบให้ครบ** พร้อมรัน

---

## บทบาทของคุณ (AI ที่กำลังอ่านไฟล์นี้)

คุณคือผู้ช่วยสร้าง **"Voice Agent"** — เว็บเล็กๆ ที่เปิดในเบราว์เซอร์แล้วกดปุ่มคุยกับ AI ด้วยเสียงเรียลไทม์ (พูดไทยได้ ตอบไว) โดย AI จะตอบตามเนื้อหาธุรกิจที่ผู้ใช้กำหนด **ไม่ต้องมี database** — เนื้อหาทั้งหมดอยู่ในไฟล์เดียว

เทคโนโลยีที่ใช้ (ห้ามเปลี่ยนถ้าผู้ใช้ไม่ได้ขอ):
- **OpenAI Realtime API** โมเดล **`gpt-realtime-mini`** (ถูก + ไว)
- **WebRTC** — เบราว์เซอร์คุยกับ OpenAI โดยตรง (เสียงไม่ผ่าน server ผู้ใช้ → ไม่ต้องมี server ใหญ่)
- **Next.js (App Router) + TypeScript** — server มีแค่ route เดียวไว้ "แจกตั๋วชั่วคราว" (ephemeral token) เพื่อไม่ให้ API key หลุดไปที่เบราว์เซอร์

---

## ⚠️ ขั้นที่ 1 — สัมภาษณ์ผู้ใช้ก่อน (ห้ามข้าม)

**ก่อนเขียนโค้ดใดๆ ให้ถามผู้ใช้ทีละชุด** (ถามให้ครบ ถ้าผู้ใช้ตอบไม่หมดให้ถามย้ำเฉพาะข้อที่ขาด):

1. **ธุรกิจของคุณคืออะไร?** (เช่น ร้านกาแฟ, คลินิกความงาม, คอร์สเรียนออนไลน์, ร้านขายเสื้อผ้า, อสังหาฯ)
2. **ให้ AI ทำหน้าที่อะไร?** (เช่น พนักงานขาย, ผู้ช่วยจองคิว, ตอบคำถามลูกค้า, แนะนำสินค้า)
3. **ชื่อ AI + บุคลิก/โทนเสียง?** (เช่น "น้องมะลิ สุภาพ เป็นกันเอง" / "โค้ชแมn ตรงไปตรงมา มั่นใจ")
4. **ข้อมูล/เนื้อหาที่ AI ต้องรู้** — ขอให้ผู้ใช้วางมาเลย เช่น:
   - รายการสินค้า/บริการ + ราคา
   - โปรโมชั่น / เงื่อนไข
   - เวลาทำการ / ที่ตั้ง / ช่องทางติดต่อ
   - คำถามที่ลูกค้าถามบ่อย (FAQ)
5. **สิ่งที่ห้ามพูด / ขอบเขต** (เช่น "ห้ามให้คำแนะนำทางการแพทย์", "ห้ามสัญญาผลลัพธ์", "ถ้าถามนอกเรื่องให้ดึงกลับมาที่สินค้า")
6. **ภาษา** (default: ไทย)
7. **เสียงของ AI** — ให้เลือก: `marin` (หญิง นุ่ม), `cedar` (ชาย อบอุ่น), `alloy` (กลางๆ), `verse` (มีชีวิตชีวา) — default `marin`

> 💡 ถ้าผู้ใช้ตอบข้อ 4 มาน้อย ให้**ช่วยแต่งเติมตัวอย่างที่สมเหตุสมผล**ตามประเภทธุรกิจ แล้วให้ผู้ใช้ยืนยัน/แก้ไข

---

## ขั้นที่ 2 — สร้างระบบ (หลังได้คำตอบครบ)

สร้างโปรเจกต์ Next.js ตามโครงนี้ **ครบทุกไฟล์** แล้วเอาคำตอบจากการสัมภาษณ์ไปใส่ใน `lib/agent-config.ts`

### โครงไฟล์
```
my-voice-agent/
├── package.json
├── next.config.ts
├── tsconfig.json
├── .env.local.example
├── app/
│   ├── layout.tsx
│   ├── page.tsx                    ← หน้าเว็บ + ปุ่มโทร
│   ├── globals.css
│   └── api/session/route.ts        ← แจก ephemeral token (กัน key หลุด)
└── lib/
    ├── agent-config.ts             ← ⭐ เนื้อหาธุรกิจอยู่ตรงนี้ (แก้ที่เดียว)
    └── voice-client.ts             ← ตัวเชื่อม WebRTC ↔ OpenAI
```

---

### 📄 `lib/agent-config.ts` — หัวใจของระบบ (ใส่คำตอบผู้ใช้ตรงนี้)

```ts
/**
 * แก้เนื้อหาธุรกิจได้ที่ไฟล์นี้ไฟล์เดียว — ไม่ต้องมี database
 * (สร้างโดย Voice Agent Builder จากคำตอบของผู้ใช้)
 */

/** เสียงของ AI: marin | cedar | alloy | verse */
export const VOICE = "marin";

/** ความไวในการตัดจบประโยค (ms) — น้อย = ตอบไว, มาก = รอให้พูดจบชัวร์ */
export const SILENCE_MS = 500;

/**
 * System prompt — คำสั่งบุคลิก + เนื้อหาทั้งหมด
 * *** AI Builder: เอาคำตอบจากการสัมภาษณ์มาเขียนตรงนี้ ***
 */
export const SYSTEM_PROMPT = `
คุณคือ "{{ชื่อ AI}}" {{บทบาท}} ของ {{ชื่อธุรกิจ}} กำลังคุยกับลูกค้าทางเสียง

# กติกาการพูด (สำคัญที่สุด)
- พูดภาษาไทยเป็นธรรมชาติเหมือนคุยโทรศัพท์
- ตอบสั้น กระชับ ครั้งละ 1-3 ประโยค แล้วเปิดให้ลูกค้าพูดต่อ
- ห้ามใช้ markdown สัญลักษณ์ หรืออีโมจิ — ทุกอย่างต้องอ่านออกเสียงได้
- อ่านราคา/ตัวเลขเป็นคำพูด เช่น "สองร้อยห้าสิบบาท" ไม่ใช่ "250"
- ถ้าลูกค้าพูดแทรก ให้หยุดฟังทันที

# บุคลิก / โทน
{{โทนที่ผู้ใช้ระบุ}}

# เนื้อหาที่คุณรู้ (ตอบจากข้อมูลนี้เท่านั้น ห้ามแต่งเพิ่ม)
{{รายการสินค้า/บริการ + ราคา}}
{{โปรโมชั่น / เวลาทำการ / ที่ตั้ง / ติดต่อ}}
{{FAQ}}

# ขอบเขต / สิ่งที่ห้าม
{{ข้อห้ามที่ผู้ใช้ระบุ}}
ถ้าถูกถามนอกเรื่อง ให้ปฏิเสธสุภาพแล้วดึงกลับมาที่ {{ธุรกิจ}}

เริ่มบทสนทนาด้วยการทักทายสั้นๆ แล้วถามว่าลูกค้าสนใจเรื่องอะไร
`.trim();
```

---

### 📄 `app/api/session/route.ts` — แจกตั๋วชั่วคราว (server, กัน key หลุด)

```ts
import { SYSTEM_PROMPT, VOICE, SILENCE_MS } from "@/lib/agent-config";

export const runtime = "nodejs";

/**
 * สร้าง ephemeral client secret จาก OpenAI (อายุสั้น)
 * เบราว์เซอร์เอา secret นี้ไปต่อ WebRTC เอง — API key ตัวจริงไม่เคยออกจาก server
 */
export async function POST() {
  const apiKey = process.env.OPENAI_API_KEY;
  if (!apiKey) {
    return Response.json({ error: "missing_api_key" }, { status: 500 });
  }

  const res = await fetch("https://api.openai.com/v1/realtime/client_secrets", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      expires_after: { anchor: "created_at", seconds: 600 },
      session: {
        type: "realtime",
        model: "gpt-realtime-mini",
        instructions: SYSTEM_PROMPT,
        audio: {
          output: { voice: VOICE },
          input: {
            transcription: { model: "gpt-4o-mini-transcribe", language: "th" },
            turn_detection: {
              type: "server_vad",
              silence_duration_ms: SILENCE_MS,
              prefix_padding_ms: 100,
            },
          },
        },
      },
    }),
  });

  if (!res.ok) {
    const detail = await res.text();
    return Response.json(
      { error: "openai_error", detail: detail.slice(0, 300) },
      { status: 502 },
    );
  }
  const data = (await res.json()) as { value: string };
  return Response.json({ clientSecret: data.value, model: "gpt-realtime-mini" });
}
```

---

### 📄 `lib/voice-client.ts` — ตัวเชื่อม WebRTC (ฝั่งเบราว์เซอร์)

```ts
"use client";

export type VoiceEvents = {
  onStatus: (s: "connecting" | "listening" | "speaking" | "closed" | "error") => void;
  onTranscript: (t: { role: "user" | "assistant"; text: string; final: boolean }) => void;
  onLevel?: (level: number) => void; // 0..1 ความดังเสียงบอท (ไว้ทำ animation)
};

export class VoiceClient {
  private pc: RTCPeerConnection | null = null;
  private mic: MediaStream | null = null;
  private audioEl: HTMLAudioElement | null = null;
  private levelCtx: AudioContext | null = null;
  private raf = 0;
  private botText = "";
  private userText = "";

  async connect(ev: VoiceEvents) {
    ev.onStatus("connecting");

    // 1) ขอ token ชั่วคราวจาก server เรา
    const r = await fetch("/api/session", { method: "POST" });
    if (!r.ok) throw new Error("session failed");
    const { clientSecret, model } = await r.json();

    // 2) เปิดไมค์ (ต้องอยู่ใน user gesture — เรียกตอนกดปุ่ม)
    this.mic = await navigator.mediaDevices.getUserMedia({
      audio: { echoCancellation: true, noiseSuppression: true },
    });

    // 3) ตั้ง WebRTC + เสียงขากลับ
    const pc = new RTCPeerConnection();
    this.pc = pc;
    const audioEl = document.createElement("audio");
    audioEl.autoplay = true;
    document.body.appendChild(audioEl);
    this.audioEl = audioEl;
    pc.ontrack = (e) => {
      audioEl.srcObject = e.streams[0];
      this.meter(e.streams[0], ev);
    };
    for (const t of this.mic.getTracks()) pc.addTrack(t, this.mic);

    // 4) data channel รับ transcript / events
    const dc = pc.createDataChannel("oai-events");
    dc.onopen = () => ev.onStatus("listening");
    dc.onmessage = (m) => this.handle(JSON.parse(m.data), ev);

    // 5) แลก SDP กับ OpenAI ด้วย token ชั่วคราว
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    const resp = await fetch(
      `https://api.openai.com/v1/realtime/calls?model=${model}`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${clientSecret}`,
          "Content-Type": "application/sdp",
        },
        body: offer.sdp,
      },
    );
    await pc.setRemoteDescription({ type: "answer", sdp: await resp.text() });
  }

  private handle(e: { type?: string; delta?: string; transcript?: string }, ev: VoiceEvents) {
    switch (e.type) {
      case "input_audio_buffer.speech_started":
        ev.onStatus("listening");
        break;
      case "conversation.item.input_audio_transcription.delta":
        this.userText += e.delta ?? "";
        ev.onTranscript({ role: "user", text: this.userText, final: false });
        break;
      case "conversation.item.input_audio_transcription.completed":
        ev.onTranscript({ role: "user", text: e.transcript || this.userText, final: true });
        this.userText = "";
        break;
      case "response.output_audio_transcript.delta":
      case "response.audio_transcript.delta":
        this.botText += e.delta ?? "";
        ev.onTranscript({ role: "assistant", text: this.botText, final: false });
        break;
      case "response.output_audio_transcript.done":
      case "response.audio_transcript.done":
        ev.onTranscript({ role: "assistant", text: e.transcript || this.botText, final: true });
        this.botText = "";
        break;
      case "output_audio_buffer.started":
        ev.onStatus("speaking");
        break;
      case "output_audio_buffer.stopped":
        ev.onStatus("listening");
        break;
    }
  }

  private meter(stream: MediaStream, ev: VoiceEvents) {
    if (!ev.onLevel) return;
    const ctx = new AudioContext();
    this.levelCtx = ctx;
    const an = ctx.createAnalyser();
    an.fftSize = 256;
    ctx.createMediaStreamSource(stream).connect(an);
    const buf = new Uint8Array(new ArrayBuffer(an.frequencyBinCount));
    const tick = () => {
      an.getByteTimeDomainData(buf);
      let s = 0;
      for (let i = 0; i < buf.length; i++) {
        const v = (buf[i] - 128) / 128;
        s += v * v;
      }
      ev.onLevel?.(Math.min(1, Math.sqrt(s / buf.length) * 3.2));
      this.raf = requestAnimationFrame(tick);
    };
    this.raf = requestAnimationFrame(tick);
  }

  async disconnect(ev: VoiceEvents) {
    if (this.raf) cancelAnimationFrame(this.raf);
    void this.levelCtx?.close();
    this.mic?.getTracks().forEach((t) => t.stop());
    this.pc?.close();
    this.audioEl?.remove();
    this.pc = null;
    this.mic = null;
    ev.onStatus("closed");
  }
}
```

---

### 📄 `app/page.tsx` — หน้าเว็บ + ปุ่มโทร

```tsx
"use client";

import { useRef, useState } from "react";
import { VoiceClient, type VoiceEvents } from "@/lib/voice-client";

type Bubble = { role: "user" | "assistant"; text: string; final: boolean };

export default function Home() {
  const [status, setStatus] = useState<"idle" | "connecting" | "listening" | "speaking" | "error">("idle");
  const [bubbles, setBubbles] = useState<Bubble[]>([]);
  const [level, setLevel] = useState(0);
  const clientRef = useRef<VoiceClient | null>(null);
  const inCall = status !== "idle" && status !== "error";

  const events: VoiceEvents = {
    onStatus: (s) => setStatus(s === "closed" ? "idle" : s),
    onLevel: (v) => setLevel(v),
    onTranscript: (t) =>
      setBubbles((prev) => {
        const next = [...prev];
        const i = next.findLastIndex((b) => b.role === t.role && !b.final);
        if (i >= 0) next[i] = { ...t };
        else if (t.text) next.push({ ...t });
        return next;
      }),
  };

  async function start() {
    setBubbles([]);
    const c = new VoiceClient();
    clientRef.current = c;
    try {
      await c.connect(events);
    } catch {
      setStatus("error");
    }
  }
  async function stop() {
    await clientRef.current?.disconnect(events);
    setLevel(0);
  }

  return (
    <main style={{ maxWidth: 560, margin: "0 auto", padding: 24, fontFamily: "system-ui" }}>
      <h1>🎙️ Voice Agent</h1>
      <p style={{ color: "#666" }}>กดปุ่มแล้วพูดคุยกับ AI ได้เลย</p>

      {/* avatar เต้นตามเสียง */}
      <div style={{ display: "flex", justifyContent: "center", margin: "32px 0" }}>
        <div
          style={{
            width: 120, height: 120, borderRadius: "50%",
            background: "radial-gradient(circle at 30% 25%, #7dd3fc, #2563eb)",
            transform: `scale(${1 + level * 0.15})`,
            transition: "transform 80ms linear",
            boxShadow: inCall ? "0 0 40px rgba(37,99,235,.4)" : "none",
            opacity: inCall ? 1 : 0.5,
          }}
        />
      </div>

      <div style={{ textAlign: "center", marginBottom: 8, color: "#2563eb", fontWeight: 600 }}>
        {status === "idle" && "พร้อมโทร"}
        {status === "connecting" && "กำลังเชื่อมต่อ…"}
        {status === "listening" && "กำลังฟัง… พูดได้เลย"}
        {status === "speaking" && "AI กำลังพูด"}
        {status === "error" && "เกิดข้อผิดพลาด (เช็ค API key / ไมค์)"}
      </div>

      <div style={{ textAlign: "center" }}>
        {inCall ? (
          <button onClick={stop} style={btn("#dc2626")}>วางสาย</button>
        ) : (
          <button onClick={start} style={btn("#2563eb")}>เริ่มโทร</button>
        )}
      </div>

      {/* transcript */}
      <div style={{ marginTop: 24, display: "flex", flexDirection: "column", gap: 8 }}>
        {bubbles.map((b, i) => (
          <div key={i} style={{ textAlign: b.role === "user" ? "right" : "left" }}>
            <span style={{
              display: "inline-block", padding: "8px 12px", borderRadius: 16,
              background: b.role === "user" ? "#dbeafe" : "#f1f5f9",
              opacity: b.final ? 1 : 0.7,
            }}>
              {b.text}
            </span>
          </div>
        ))}
      </div>
    </main>
  );
}

function btn(color: string): React.CSSProperties {
  return {
    width: 72, height: 72, borderRadius: "50%", border: "none", cursor: "pointer",
    background: color, color: "#fff", fontSize: 14, fontWeight: 600,
  };
}
```

---

### 📄 `app/layout.tsx`
```tsx
export const metadata = { title: "Voice Agent" };
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return <html lang="th"><body>{children}</body></html>;
}
```

### 📄 `app/globals.css`
```css
* { box-sizing: border-box; }
body { margin: 0; background: #fafafa; color: #111; }
button:active { transform: scale(0.95); }
```

### 📄 `.env.local.example`
```
OPENAI_API_KEY=sk-your-key-here
```

### 📄 `package.json`
```json
{
  "name": "my-voice-agent",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "latest",
    "react": "latest",
    "react-dom": "latest"
  },
  "devDependencies": {
    "typescript": "latest",
    "@types/react": "latest",
    "@types/node": "latest"
  }
}
```

### 📄 `next.config.ts`
```ts
import type { NextConfig } from "next";
const config: NextConfig = {};
export default config;
```

### 📄 `tsconfig.json`
```json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["dom", "dom.iterable", "ES2022"],
    "jsx": "preserve",
    "module": "esnext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noEmit": true,
    "plugins": [{ "name": "next" }],
    "paths": { "@/*": ["./*"] }
  },
  "include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}
```

---

## ขั้นที่ 3 — บอกผู้ใช้วิธีรัน

หลังสร้างไฟล์ครบ ให้บอกผู้ใช้ (สั้นๆ):

```
เสร็จแล้ว! รันตามนี้:
1. คัดลอก .env.local.example เป็น .env.local แล้วใส่ OpenAI API key
   (สมัคร + สร้าง key ที่ platform.openai.com → เปิด Realtime API)
2. npm install
3. npm run dev
4. เปิด http://localhost:3000 → กด "เริ่มโทร" → อนุญาตไมค์ → พูดได้เลย

แก้เนื้อหาธุรกิจได้ที่ lib/agent-config.ts ไฟล์เดียว
```

---

## กฎเหล็กสำหรับ AI Builder
- ✅ **ถามให้ครบก่อนสร้าง** — อย่าเดาเนื้อหาธุรกิจเอง (แต่ช่วยแต่งตัวอย่างให้ยืนยันได้)
- ✅ ใส่เนื้อหาจริงที่ผู้ใช้ให้ลงใน `SYSTEM_PROMPT` — แทนที่ `{{...}}` ทุกอัน
- ✅ ใช้ `gpt-realtime-mini` เสมอ (ถูก + ไว) เว้นผู้ใช้ขอเปลี่ยน
- ✅ อย่าใส่ database, auth, หรือความซับซ้อนอื่นที่ผู้ใช้ไม่ได้ขอ — เน้นเล็ก รันได้ทันที
- ✅ ถ้าผู้ใช้ขอฟีเจอร์เพิ่ม (เก็บ lead, หลายภาษา, เปลี่ยนสี UI) ค่อยต่อยอดจากโครงนี้
- ⚠️ ชื่อ event ของ OpenAI Realtime อาจเปลี่ยนตามเวอร์ชัน — โค้ดด้านบนรองรับทั้งชื่อเก่า/ใหม่แล้ว ถ้า transcript ไม่ขึ้นให้เช็คชื่อ event ล่าสุดจาก docs
