新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
265 lines
9.3 KiB
TypeScript
265 lines
9.3 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import {
|
||
completeTask,
|
||
createTask,
|
||
failTask,
|
||
fireTaskNow,
|
||
listTasks,
|
||
parseTask,
|
||
type ParseTaskResult,
|
||
type Task,
|
||
type TaskType,
|
||
} from "../../lib/api";
|
||
|
||
const CHARACTER_ID = "seed-character-genki";
|
||
const USER_ID = "seed-user-primary";
|
||
|
||
const TASK_TYPE_LABELS: Record<TaskType, string> = {
|
||
ONE_TIME: "單次提醒",
|
||
RECURRING: "週期提醒",
|
||
CONDITIONAL: "條件提醒",
|
||
QUERY_ORGANIZE: "查資料/整理",
|
||
TODO_TRACKING: "代辦追蹤",
|
||
};
|
||
|
||
const STATUS_LABELS: Record<Task["status"], string> = {
|
||
PENDING: "尚未觸發",
|
||
TRIGGERED: "已觸發,等候回應",
|
||
COMPLETED: "已完成",
|
||
FAILED: "失敗",
|
||
CANCELLED: "已取消",
|
||
};
|
||
|
||
function isOverdue(task: Task): boolean {
|
||
return task.status === "TRIGGERED" && task.overdueNudgeCount > 0;
|
||
}
|
||
|
||
// J-8 委託介面:自然語言輸入→(辨識成功即直接建立/辨識失敗則手動表單建立)→列表→觸發→完成或失敗,
|
||
// 對應計畫§委託子系統「角色不只陪聊,還能接受委託」。
|
||
export function TasksView() {
|
||
const [text, setText] = useState("");
|
||
const [tasks, setTasks] = useState<Task[]>([]);
|
||
const [parseResult, setParseResult] = useState<ParseTaskResult | null>(null);
|
||
const [manualDraft, setManualDraft] = useState<{ content: string; triggerAt: string } | null>(null);
|
||
const [pending, setPending] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
async function refresh() {
|
||
try {
|
||
setTasks(await listTasks(CHARACTER_ID, USER_ID));
|
||
} catch {
|
||
setError("無法連線到 api 服務,委託清單暫時無法顯示");
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
void refresh();
|
||
}, []);
|
||
|
||
async function handleParse() {
|
||
const trimmed = text.trim();
|
||
if (!trimmed || pending) {
|
||
return;
|
||
}
|
||
setPending(true);
|
||
setError(null);
|
||
setParseResult(null);
|
||
setManualDraft(null);
|
||
try {
|
||
const result = await parseTask(CHARACTER_ID, USER_ID, trimmed);
|
||
setParseResult(result);
|
||
if (result.created) {
|
||
setText("");
|
||
await refresh();
|
||
} else {
|
||
// 辨識失敗:退回手動表單,讓使用者自己補齊內容後透過 createTask() 建立。
|
||
setManualDraft({ content: trimmed, triggerAt: "" });
|
||
}
|
||
} catch {
|
||
setError("委託解析失敗,請確認 api 服務是否已啟動");
|
||
} finally {
|
||
setPending(false);
|
||
}
|
||
}
|
||
|
||
async function handleManualCreate() {
|
||
if (!manualDraft || !manualDraft.content.trim() || pending) {
|
||
return;
|
||
}
|
||
setPending(true);
|
||
setError(null);
|
||
try {
|
||
await createTask(CHARACTER_ID, USER_ID, {
|
||
type: "TODO_TRACKING",
|
||
content: manualDraft.content.trim(),
|
||
rawText: text.trim() || manualDraft.content.trim(),
|
||
triggerAt: manualDraft.triggerAt ? new Date(manualDraft.triggerAt).toISOString() : undefined,
|
||
});
|
||
setText("");
|
||
setManualDraft(null);
|
||
setParseResult(null);
|
||
await refresh();
|
||
} catch {
|
||
setError("委託建立失敗,請確認 api 服務是否已啟動");
|
||
} finally {
|
||
setPending(false);
|
||
}
|
||
}
|
||
|
||
async function handleAction(action: (taskId: string) => Promise<Task>, taskId: string) {
|
||
setPending(true);
|
||
setError(null);
|
||
try {
|
||
await action(taskId);
|
||
await refresh();
|
||
} catch {
|
||
setError("操作失敗,請確認 api 服務是否已啟動");
|
||
} finally {
|
||
setPending(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex flex-col gap-2 rounded-2xl border p-4" style={{ borderColor: "var(--line)" }}>
|
||
<label className="text-xs" style={{ color: "var(--ink-soft)" }}>
|
||
跟她說一句話,讓她幫你記著(例如「三點提醒我開會」)
|
||
</label>
|
||
<div className="flex gap-2">
|
||
<input
|
||
value={text}
|
||
onChange={(event) => setText(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter") {
|
||
void handleParse();
|
||
}
|
||
}}
|
||
placeholder="三點提醒我開會"
|
||
className="flex-1 rounded-full border bg-transparent px-3 py-2 text-sm outline-none"
|
||
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
|
||
disabled={pending}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleParse()}
|
||
disabled={pending}
|
||
className="rounded-full px-4 py-2 text-sm font-bold text-white disabled:opacity-50"
|
||
style={{ background: "var(--accent-bright)" }}
|
||
>
|
||
送出
|
||
</button>
|
||
</div>
|
||
|
||
{parseResult?.created && (
|
||
<p className="text-xs" style={{ color: "var(--accent)" }}>
|
||
已建立委託:{parseResult.task?.content}
|
||
{parseResult.performanceLine ? `——${parseResult.performanceLine}` : ""}
|
||
</p>
|
||
)}
|
||
|
||
{manualDraft && (
|
||
<div className="flex flex-col gap-2 rounded-xl p-3 text-xs" style={{ background: "var(--bg-soft)" }}>
|
||
<p style={{ color: "var(--ink-soft)" }}>沒辨識出明確時間,改用手動表單建立(分類為「代辦追蹤」):</p>
|
||
<input
|
||
value={manualDraft.content}
|
||
onChange={(event) => setManualDraft({ ...manualDraft, content: event.target.value })}
|
||
placeholder="委託內容"
|
||
className="rounded-full border bg-transparent px-3 py-1.5"
|
||
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
|
||
/>
|
||
<input
|
||
type="datetime-local"
|
||
value={manualDraft.triggerAt}
|
||
onChange={(event) => setManualDraft({ ...manualDraft, triggerAt: event.target.value })}
|
||
className="rounded-full border bg-transparent px-3 py-1.5"
|
||
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleManualCreate()}
|
||
disabled={pending}
|
||
className="self-start rounded-full px-4 py-1.5 font-bold text-white disabled:opacity-50"
|
||
style={{ background: "var(--accent-bright)" }}
|
||
>
|
||
確認建立
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<p className="text-xs" style={{ color: "var(--accent)" }}>
|
||
{error}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
{tasks.length === 0 && (
|
||
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
|
||
目前沒有委託。
|
||
</p>
|
||
)}
|
||
{tasks.map((task) => (
|
||
<div key={task.id} className="flex flex-col gap-1.5 rounded-2xl border p-3 text-xs" style={{ borderColor: "var(--line)" }}>
|
||
<div className="flex items-center justify-between">
|
||
<span className="font-bold" style={{ color: "var(--ink)" }}>
|
||
{task.content}
|
||
</span>
|
||
<span
|
||
className="rounded-full px-2 py-0.5"
|
||
style={{
|
||
background: isOverdue(task) ? "var(--accent-bright)" : "var(--chip-bg)",
|
||
color: isOverdue(task) ? "#fff" : "var(--accent)",
|
||
}}
|
||
>
|
||
{isOverdue(task) ? "逾期未回應" : STATUS_LABELS[task.status]}
|
||
</span>
|
||
</div>
|
||
<span style={{ color: "var(--ink-soft)" }}>
|
||
{TASK_TYPE_LABELS[task.type]}
|
||
{task.triggerAt ? `.${new Date(task.triggerAt).toLocaleString("zh-TW")}` : ""}
|
||
</span>
|
||
{task.triggerMessage && <span style={{ color: "var(--ink-soft)" }}>她說:{task.triggerMessage}</span>}
|
||
|
||
{(task.status === "PENDING" || task.status === "TRIGGERED") && (
|
||
<div className="flex gap-2 pt-1">
|
||
{task.status === "PENDING" && (
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleAction((id) => fireTaskNow(id).then(() => listTasks(CHARACTER_ID, USER_ID).then((all) => all.find((t) => t.id === id)!)), task.id)}
|
||
disabled={pending}
|
||
className="rounded-full border px-3 py-1 disabled:opacity-50"
|
||
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
|
||
>
|
||
立即觸發(測試用)
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleAction(completeTask, task.id)}
|
||
disabled={pending}
|
||
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
|
||
style={{ background: "var(--accent-bright)" }}
|
||
>
|
||
完成
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleAction(failTask, task.id)}
|
||
disabled={pending}
|
||
className="rounded-full border px-3 py-1 disabled:opacity-50"
|
||
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
|
||
>
|
||
失敗
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|