- ScheduleService:I-1 平日/週末/假期時段表(可為角色寫入高中生預設作息)、 I-3 時間感知(Asia/Taipei 當地時間,不受伺服器時區影響)、剛醒/睡前偵測 - constants.ts:I-2 時段狀態 → 回應可用度對照(FREE/SEMI_BUSY/BUSY/SLEEP → NORMAL/BRIEF/DELAYED/NO_REPLY),沒有作息表的角色一律視為 NORMAL(相容既有角色) - ScheduleExceptionService:I-4 三種破例(深夜還陪你聊/上課偷回訊息/為你調整行程), 各自獨立冷卻期,觸發時記入關係帳本 - ScheduleEmotionBaselineService:I-5 作息驅動情緒基線(社團剛結束的疲憊、假日偏高心情), 只在本輪沒有明確情緒訊號時介入,串接進 EmotionService.processInput 的新增選項 - OfflineEventService:I-6 離線事件生成,依「今天已發生的固定行程」挑選對應模板, 一天只生成一次,寫入低權重情節記憶 - DialogueService 整合以上機制:離線事件生成 → 情緒基線標記 → 累積親密度 → 作息可用度判定 → 不符合則嘗試破例 → 依可用度決定回覆內容 - 修正真實 bug:G-5 角色建立流程誤用 source=OFFLINE_GENERATED 標記背景故事, 與 I-6「今天是否已生成離線事件」的判斷共用同一標記,已改為 SOURCE_EXTRACTION - ChatView.tsx 補上 NO_REPLY/context 為 null 的防呆(作息可用度整合後的必要調整) - scripts/smoke/I.mjs:以獨立測試角色(避免污染種子角色的排程狀態)驗證全部六項 npm run restart && npm run smoke -- I 皆通過(I-V),A~H 群組冒煙測試無回歸。
145 lines
5.0 KiB
TypeScript
145 lines
5.0 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { sendChatMessage } from "../../lib/api";
|
|
import { EMOTION_LABELS } from "../../lib/emotion-labels";
|
|
import { ChatBubble } from "../../components/ChatBubble";
|
|
import { HeartbeatWave } from "../../components/HeartbeatWave";
|
|
import type { EmotionTag } from "@kokorone/shared";
|
|
|
|
const CHARACTER_ID = "seed-character-genki";
|
|
const USER_ID = "seed-user-primary";
|
|
|
|
interface Message {
|
|
role: "user" | "character";
|
|
text: string;
|
|
}
|
|
|
|
export function ChatView() {
|
|
const [sessionId] = useState(() => crypto.randomUUID());
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
const [input, setInput] = useState("");
|
|
const [sending, setSending] = useState(false);
|
|
const [dominant, setDominant] = useState<EmotionTag>("CALM");
|
|
const [intimacy, setIntimacy] = useState(0);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function handleSend() {
|
|
const text = input.trim();
|
|
if (!text || sending) {
|
|
return;
|
|
}
|
|
setSending(true);
|
|
setError(null);
|
|
setInput("");
|
|
setMessages((prev) => [...prev, { role: "user", text }]);
|
|
|
|
try {
|
|
const result = await sendChatMessage(CHARACTER_ID, USER_ID, sessionId, text);
|
|
if (result.availability === "NO_REPLY") {
|
|
// I-2:作息狀態為睡眠且未破例時,這輪不會有真的回覆。
|
|
return;
|
|
}
|
|
setMessages((prev) => [...prev, { role: "character", text: result.text }]);
|
|
if (result.context) {
|
|
setDominant(result.context.emotion.dominant);
|
|
setIntimacy(result.context.relationship.intimacy);
|
|
}
|
|
} catch {
|
|
setError("訊息傳送失敗,請確認 api 服務是否已啟動");
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-dvh flex-col md:flex-row">
|
|
<div
|
|
className="relative flex h-[38vh] shrink-0 items-end justify-center overflow-hidden md:order-2 md:h-auto md:w-[38%] md:min-w-[280px]"
|
|
style={{ background: "linear-gradient(180deg, var(--bg-soft), var(--card))" }}
|
|
>
|
|
<span className="absolute top-3.5 left-3.5 text-[11px]" style={{ color: "var(--ink-soft)" }}>
|
|
親密度 <b style={{ color: "var(--accent)" }}>{intimacy}</b>
|
|
</span>
|
|
<span
|
|
className="absolute top-3.5 right-3.5 rounded-full px-3 py-1 text-xs font-bold"
|
|
style={{ background: "var(--chip-bg)", color: "var(--accent)" }}
|
|
>
|
|
{EMOTION_LABELS[dominant]}
|
|
</span>
|
|
<div className="mb-6 flex flex-col items-center gap-2">
|
|
<div
|
|
className="relative h-[190px] w-[120px] rounded-t-[60px] rounded-b-[24px] opacity-85"
|
|
style={{ background: "linear-gradient(180deg, var(--accent-bright), var(--violet))" }}
|
|
>
|
|
<div
|
|
className="absolute -top-11 left-1/2 h-21 w-21 -translate-x-1/2 rounded-full"
|
|
style={{ background: "linear-gradient(180deg, var(--accent-bright), var(--violet) 140%)" }}
|
|
/>
|
|
</div>
|
|
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
|
|
立繪(表情差分即時變化)
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="flex flex-1 flex-col gap-2.5 overflow-y-auto p-4 md:order-1 md:border-r"
|
|
style={{ borderColor: "var(--line)" }}
|
|
>
|
|
<div className="mb-2 flex items-center gap-3">
|
|
<HeartbeatWave amplitude={intimacy} className="h-6 w-24" />
|
|
<h1 className="text-sm font-bold" style={{ color: "var(--accent)" }}>
|
|
心音 Kokorone
|
|
</h1>
|
|
</div>
|
|
|
|
<div className="flex flex-1 flex-col gap-2.5 overflow-y-auto">
|
|
{messages.length === 0 && (
|
|
<p className="text-sm" style={{ color: "var(--ink-soft)" }}>
|
|
傳個訊息,開始今天的對話吧。
|
|
</p>
|
|
)}
|
|
{messages.map((message, index) => (
|
|
<ChatBubble key={index} role={message.role} text={message.text} />
|
|
))}
|
|
</div>
|
|
|
|
{error && (
|
|
<p className="text-xs" style={{ color: "var(--accent)" }}>
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<div
|
|
className="mt-2 flex items-center justify-between gap-2 rounded-full border px-4 py-2 text-sm"
|
|
style={{ borderColor: "var(--line)" }}
|
|
>
|
|
<input
|
|
value={input}
|
|
onChange={(event) => setInput(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
void handleSend();
|
|
}
|
|
}}
|
|
placeholder="輸入訊息……"
|
|
className="flex-1 bg-transparent outline-none"
|
|
style={{ color: "var(--ink)" }}
|
|
disabled={sending}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleSend()}
|
|
disabled={sending}
|
|
className="font-bold disabled:opacity-50"
|
|
style={{ color: "var(--accent)" }}
|
|
>
|
|
送出
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|