M2b:导入改造产出可审阅灵胚(启镜物化世谱)与组装管线(符文展开/火候进入 IR),Kernel 打通采样参数链路
This commit is contained in:
1 parent
e6a3b7121f
commit
5f36e3785c
86 files changed
+424
-14455
No files matched your search
@@ -1,26 +0,0 @@
|
||||
import { ChatShell } from "@/components/chat/ChatShell";
|
||||
|
||||
import { serverApi } from "@/lib/api/server-client";
|
||||
import type { Character, ModelGatewayStatus } from "@/types/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* 对话页:Server Component 预取当前激活人物与模型网关后注入 ChatShell。
|
||||
* 任一不可达时仍渲染界面,由用户感知后端状态。
|
||||
*/
|
||||
export default async function ChatPage() {
|
||||
let character: Character | null = null;
|
||||
let gateway: ModelGatewayStatus | null = null;
|
||||
try {
|
||||
character = await serverApi.get<Character>("/characters/active");
|
||||
} catch {
|
||||
character = null;
|
||||
}
|
||||
try {
|
||||
gateway = await serverApi.get<ModelGatewayStatus>("/settings/models");
|
||||
} catch {
|
||||
gateway = null;
|
||||
}
|
||||
return <ChatShell initialCharacter={character} gateway={gateway} />;
|
||||
}
|
||||
@@ -1,691 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { TextSkeleton } from "@/components/ui/Skeleton";
|
||||
import { ErrorState } from "@/components/shared/StateViews";
|
||||
import { SundialDisk } from "@/components/time/SundialDisk";
|
||||
import { charactersApi, timeApi } from "@/lib/api";
|
||||
import { describeError } from "@/lib/utils/error";
|
||||
import type {
|
||||
AcquaintanceInfo,
|
||||
Anniversary,
|
||||
CreateAnniversaryRequest,
|
||||
TimeConfig,
|
||||
TimeSnapshotResponse,
|
||||
} from "@/types/api";
|
||||
|
||||
// ============================================================
|
||||
// 辰正 · 时态感知
|
||||
// 实时时间展示 + 十二时辰流 + 自动刷新的后端数据
|
||||
// ============================================================
|
||||
|
||||
// —— 十二时辰 ——
|
||||
const SHICHEN_KEYS = [
|
||||
"zi",
|
||||
"chou",
|
||||
"yin",
|
||||
"mao",
|
||||
"chen",
|
||||
"si",
|
||||
"wu",
|
||||
"wei",
|
||||
"shen",
|
||||
"you",
|
||||
"xu",
|
||||
"hai",
|
||||
] as const;
|
||||
|
||||
const SHICHEN_HOURS: Record<string, number> = {
|
||||
chou: 2,
|
||||
yin: 4,
|
||||
mao: 6,
|
||||
chen: 8,
|
||||
si: 10,
|
||||
wu: 12,
|
||||
wei: 14,
|
||||
shen: 16,
|
||||
you: 18,
|
||||
xu: 20,
|
||||
hai: 22,
|
||||
};
|
||||
|
||||
/** 由客户端本地时辰确定时辰 key(与后端逻辑一致) */
|
||||
function shichenFromHour(hour: number): string {
|
||||
if (hour >= 23 || hour < 1) return "zi";
|
||||
// 取各时辰起始值,反向查找
|
||||
let prev = "zi";
|
||||
for (const key of SHICHEN_KEYS) {
|
||||
const start = SHICHEN_HOURS[key];
|
||||
if (hour >= start) prev = key;
|
||||
else break;
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
|
||||
/** 获取目标时区的当前时间组件 */
|
||||
function getLocalTimeInTimezone(timezone: string) {
|
||||
const fmt = new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: timezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
const p = fmt.formatToParts(new Date());
|
||||
const g = (t: string) =>
|
||||
parseInt(p.find((x) => x.type === t)?.value ?? "0", 10);
|
||||
return {
|
||||
year: g("year"),
|
||||
month: g("month"),
|
||||
day: g("day"),
|
||||
hour: g("hour"),
|
||||
minute: g("minute"),
|
||||
second: g("second"),
|
||||
};
|
||||
}
|
||||
|
||||
function getDayPeriod(h: number): string {
|
||||
if (h >= 0 && h < 5) return "凌晨";
|
||||
if (h >= 5 && h < 7) return "清晨";
|
||||
if (h >= 7 && h < 11) return "上午";
|
||||
if (h >= 11 && h < 13) return "中午";
|
||||
if (h >= 13 && h < 17) return "下午";
|
||||
if (h >= 17 && h < 19) return "傍晚";
|
||||
if (h >= 19 && h < 22) return "夜晚";
|
||||
return "深夜";
|
||||
}
|
||||
|
||||
const KIND_OPTIONS = [
|
||||
{ value: "anniversary", label: "纪念日" },
|
||||
{ value: "birthday", label: "生日" },
|
||||
{ value: "custom", label: "自定义" },
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// 子组件
|
||||
// ============================================================
|
||||
|
||||
/** 调制因子(设计稿 .factor 渐变轨道) */
|
||||
function FactorFactor({
|
||||
label,
|
||||
value,
|
||||
cls,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
cls: string;
|
||||
}) {
|
||||
const pct = Math.round(Math.max(0, Math.min(1, value)) * 100);
|
||||
return (
|
||||
<div className={`factor ${cls}`}>
|
||||
<span className="fk">{label}</span>
|
||||
<div className="ftrack">
|
||||
<div className="ffill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="fv">{pct}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 主页面
|
||||
// ============================================================
|
||||
|
||||
export default function TimePage() {
|
||||
// —— 后端数据 ——
|
||||
const [snapshot, setSnapshot] = useState<TimeSnapshotResponse | null>(null);
|
||||
const [acquaintance, setAcquaintance] = useState<AcquaintanceInfo | null>(
|
||||
null,
|
||||
);
|
||||
const [config, setConfig] = useState<TimeConfig | null>(null);
|
||||
const [currentMood, setCurrentMood] = useState<{
|
||||
mood: string;
|
||||
intensity: number;
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// —— 实时时钟 ——
|
||||
const [, setNow] = useState<Date>(new Date());
|
||||
const [, setPrevShichen] = useState<string | null>(null);
|
||||
const [shichenChanged, setShichenChanged] = useState(false);
|
||||
const shichenRef = useRef("");
|
||||
|
||||
// 每秒 tick
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// 检测时辰变化
|
||||
const timezone =
|
||||
snapshot?.snapshot?.timezone ?? config?.user_timezone ?? "Asia/Shanghai";
|
||||
const local = getLocalTimeInTimezone(timezone);
|
||||
const currentShichen = shichenFromHour(local.hour);
|
||||
|
||||
useEffect(() => {
|
||||
if (shichenRef.current && shichenRef.current !== currentShichen) {
|
||||
setPrevShichen(shichenRef.current);
|
||||
setShichenChanged(true);
|
||||
setTimeout(() => setShichenChanged(false), 2000);
|
||||
}
|
||||
shichenRef.current = currentShichen;
|
||||
}, [currentShichen]);
|
||||
|
||||
// —— 数据获取 ——
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [s, acq, cfg] = await Promise.all([
|
||||
timeApi.getTimeSnapshot(),
|
||||
timeApi.getAcquaintance(),
|
||||
timeApi.getTimeConfig(),
|
||||
]);
|
||||
setSnapshot(s);
|
||||
setAcquaintance(acq);
|
||||
setConfig(cfg);
|
||||
} catch (e) {
|
||||
if (!snapshot) setError(describeError(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [snapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchData();
|
||||
// 获取当前人物心情
|
||||
charactersApi
|
||||
.getActiveCharacter()
|
||||
.then((c) => {
|
||||
if (c.current_mood) setCurrentMood(c.current_mood);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 自动刷新:每 60 秒重新拉取后端数据(农历/节气/节日变化)
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
const timer = setInterval(fetchData, 60_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [fetchData, loading]);
|
||||
|
||||
// —— 渲染 ——
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8">
|
||||
<TextSkeleton lines={1} className="mb-8 w-48" />
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-5">
|
||||
<div className="md:col-span-3">
|
||||
<div className="glass rounded-2xl p-8">
|
||||
<TextSkeleton lines={3} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<div className="glass rounded-2xl p-5">
|
||||
<TextSkeleton lines={6} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="glass mt-6 rounded-2xl p-5">
|
||||
<TextSkeleton lines={2} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !snapshot) {
|
||||
return <ErrorState message={error} onRetry={fetchData} />;
|
||||
}
|
||||
|
||||
const s = snapshot?.snapshot;
|
||||
const factors = snapshot?.factors;
|
||||
const context_text = snapshot?.context_text;
|
||||
|
||||
// 实时时辰信息(优先用本地计算的,回退后端数据)
|
||||
const displayShichen = currentShichen;
|
||||
const displayDayPeriod = getDayPeriod(local.hour);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8">
|
||||
{/* ===== 页头 ===== */}
|
||||
<header className="mb-8 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="font-serif text-2xl font-semibold text-fg">辰正</h1>
|
||||
<span className="rounded-full bg-gold/10 px-2.5 py-0.5 text-[10px] font-medium tracking-wider text-gold">
|
||||
时态感知
|
||||
</span>
|
||||
{shichenChanged && (
|
||||
<span className="animate-pulse rounded-full bg-gold/30 px-2 py-0.5 text-[10px] text-gold">
|
||||
更时
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{config && (
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
{config.default_city} · {config.user_timezone}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-patina" />
|
||||
辰光实时
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ===== 日晷时辰盘 + 调制因子(设计稿 .sundial-region) ===== */}
|
||||
<div className="sundial-region">
|
||||
{/* 日晷 */}
|
||||
<SundialDisk
|
||||
currentShichen={displayShichen}
|
||||
hour={local.hour}
|
||||
minute={local.minute}
|
||||
lunarText={s?.lunar?.text}
|
||||
festivals={s?.festivals}
|
||||
solarTerm={s?.solar_term?.name}
|
||||
/>
|
||||
|
||||
{/* 调制因子(设计稿 .factor-card) */}
|
||||
{factors && (
|
||||
<div className="factor-card">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<h3 className="font-serif text-sm font-semibold text-fg">
|
||||
调制因子
|
||||
</h3>
|
||||
<span className="h-px flex-1 bg-rule" />
|
||||
</div>
|
||||
<div className="factor-list">
|
||||
<FactorFactor
|
||||
label="节日喜庆"
|
||||
value={factors.festival_joy}
|
||||
cls="f-joy"
|
||||
/>
|
||||
<FactorFactor
|
||||
label="深夜脆弱"
|
||||
value={factors.late_night_fragility}
|
||||
cls="f-frag"
|
||||
/>
|
||||
<FactorFactor
|
||||
label="重逢强度"
|
||||
value={factors.reunion_intensity}
|
||||
cls="f-reun"
|
||||
/>
|
||||
<FactorFactor
|
||||
label="周末放松"
|
||||
value={factors.weekend_relax}
|
||||
cls="f-wknd"
|
||||
/>
|
||||
<FactorFactor
|
||||
label="季节共鸣"
|
||||
value={factors.season_resonance}
|
||||
cls="f-season"
|
||||
/>
|
||||
<FactorFactor
|
||||
label="同期共振"
|
||||
value={factors.same_period_resonance}
|
||||
cls="f-period"
|
||||
/>
|
||||
<FactorFactor
|
||||
label="关怀倾向"
|
||||
value={factors.care_suggest}
|
||||
cls="f-care"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{currentMood && (
|
||||
<div className="mt-4 rounded-xl bg-glass/40 px-4 py-3">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<h3 className="font-serif text-sm font-semibold text-fg">
|
||||
当前心情
|
||||
</h3>
|
||||
<span className="h-px flex-1 bg-rule" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">
|
||||
{currentMood.mood === "joy"
|
||||
? "😊"
|
||||
: currentMood.mood === "sadness"
|
||||
? "😢"
|
||||
: currentMood.mood === "anger"
|
||||
? "😠"
|
||||
: currentMood.mood === "fear"
|
||||
? "😨"
|
||||
: currentMood.mood === "surprise"
|
||||
? "😲"
|
||||
: currentMood.mood === "disgust"
|
||||
? "😣"
|
||||
: "😐"}
|
||||
</span>
|
||||
<span className="font-serif text-base font-medium text-fg">
|
||||
{currentMood.mood}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-muted">
|
||||
{Math.round(currentMood.intensity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{factors.care_kind && (
|
||||
<div className="care-note">
|
||||
<b>关怀</b> · {factors.care_kind}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== 相识 / 离线 / 里程碑 快照格(设计稿 .snap-grid) ===== */}
|
||||
{s && (
|
||||
<div className="snap-grid">
|
||||
<div className="snap-card">
|
||||
<div className="k">相识</div>
|
||||
<div className="v">
|
||||
{s.acquaintance.total_days}
|
||||
<small>天</small>
|
||||
</div>
|
||||
<div className="sub">{s.acquaintance.natural_language}</div>
|
||||
</div>
|
||||
<div className="snap-card">
|
||||
<div className="k">离线</div>
|
||||
<div className="v">
|
||||
{Math.round(s.offline.seconds / 86400)}
|
||||
<small>天</small>
|
||||
</div>
|
||||
<div className="sub">{s.offline.natural_language}</div>
|
||||
</div>
|
||||
<div className="snap-card milestone">
|
||||
<div className="k">下个纪念日</div>
|
||||
<div className="v">
|
||||
{s.next_milestone?.days_until ?? "—"}
|
||||
<small>天后</small>
|
||||
</div>
|
||||
<div className="sub">{s.next_milestone?.name ?? "无"}</div>
|
||||
</div>
|
||||
<div className="snap-card">
|
||||
<div className="k">辰光</div>
|
||||
<div className="v">
|
||||
{displayShichen}
|
||||
<small>· {displayDayPeriod}</small>
|
||||
</div>
|
||||
<div className="sub">{s.season ?? "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 时态感知文本(全宽) ===== */}
|
||||
{context_text && (
|
||||
<Card variant="surface" className="p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<h3 className="font-serif text-sm font-semibold text-fg">
|
||||
时态感知
|
||||
</h3>
|
||||
<span className="h-px flex-1 bg-rule" />
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed text-fg/80">
|
||||
{context_text}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ===== 底部双栏:相识 + 纪念日管理(设计稿 .lower-region) ===== */}
|
||||
<div className="lower-region">
|
||||
{acquaintance && (
|
||||
<Card variant="surface" className="p-6 event-main">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<h3 className="font-serif text-sm font-semibold text-fg">相识</h3>
|
||||
<span className="h-px flex-1 bg-rule" />
|
||||
</div>
|
||||
<p className="font-serif text-4xl font-semibold text-fg">
|
||||
{acquaintance.total_days}
|
||||
<span className="ml-1 text-base font-normal text-muted">天</span>
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{acquaintance.natural_language}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
<div className="anniv-rail">
|
||||
<AnniversaryManager />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 纪念日管理(保持原功能不变)
|
||||
// ============================================================
|
||||
|
||||
function AnniversaryManager() {
|
||||
const [anniversaries, setAnniversaries] = useState<Anniversary[]>([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<CreateAnniversaryRequest & { date: string }>(
|
||||
{
|
||||
name: "",
|
||||
date: "",
|
||||
kind: "anniversary",
|
||||
calendar: "solar",
|
||||
subject_type: "relationship",
|
||||
source: "manual",
|
||||
},
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
try {
|
||||
const data = await timeApi.listAnniversaries();
|
||||
setAnniversaries(data);
|
||||
} catch {
|
||||
/* silent */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetch();
|
||||
}, [fetch]);
|
||||
|
||||
function resetForm() {
|
||||
setForm({
|
||||
name: "",
|
||||
date: "",
|
||||
kind: "anniversary",
|
||||
calendar: "solar",
|
||||
subject_type: "relationship",
|
||||
source: "manual",
|
||||
});
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!form.name.trim() || !form.date) {
|
||||
setError("请填写名称与日期。");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await timeApi.createAnniversary(form);
|
||||
resetForm();
|
||||
await fetch();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "创建失败。");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate(id: string) {
|
||||
if (!form.name.trim() || !form.date) {
|
||||
setError("请填写名称与日期。");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await timeApi.updateAnniversary(id, form);
|
||||
resetForm();
|
||||
await fetch();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "更新失败。");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await timeApi.deleteAnniversary(id);
|
||||
await fetch();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "删除失败。");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(a: Anniversary) {
|
||||
setEditingId(a.id);
|
||||
setShowForm(false);
|
||||
setForm({
|
||||
name: a.name,
|
||||
date: a.date,
|
||||
kind: a.kind,
|
||||
calendar: a.calendar,
|
||||
subject_type: a.subject_type,
|
||||
source: a.source,
|
||||
});
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg outline-none focus:border-patina/60 focus:shadow-[0_0_0_2px] focus:shadow-patina/20";
|
||||
|
||||
return (
|
||||
<Card variant="surface" className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-serif text-sm font-semibold text-fg">
|
||||
纪念日({anniversaries.length})
|
||||
</h3>
|
||||
{!showForm && !editingId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setShowForm(true);
|
||||
}}
|
||||
className="rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
|
||||
>
|
||||
+ 新增
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(showForm || editingId) && (
|
||||
<div className="mt-3 space-y-2 rounded-lg border border-border-glass bg-glass/50 p-3">
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="名称"
|
||||
className={inputCls}
|
||||
/>
|
||||
<input
|
||||
value={form.date}
|
||||
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
||||
type="date"
|
||||
className={inputCls}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={form.kind}
|
||||
onChange={(e) => setForm({ ...form, kind: e.target.value })}
|
||||
className={inputCls}
|
||||
>
|
||||
{KIND_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={form.calendar}
|
||||
onChange={(e) => setForm({ ...form, calendar: e.target.value })}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="solar">公历</option>
|
||||
<option value="lunar">农历</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={editingId ? () => handleUpdate(editingId) : handleCreate}
|
||||
disabled={busy}
|
||||
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
|
||||
>
|
||||
{editingId ? "保存" : "创建"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-cinnabar">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
{anniversaries.length === 0 ? (
|
||||
<p className="text-sm text-muted">暂无纪念日。</p>
|
||||
) : (
|
||||
anniversaries.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex items-center justify-between rounded-lg bg-glass px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm text-fg">{a.name}</span>
|
||||
<span className="ml-2 text-[11px] text-muted">{a.date}</span>
|
||||
<span className="ml-2 rounded bg-gold/10 px-1.5 py-0.5 text-[10px] text-gold">
|
||||
{a.kind}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-2 flex shrink-0 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEdit(a)}
|
||||
className="text-[11px] text-patina hover:underline"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(a.id)}
|
||||
disabled={busy}
|
||||
className="text-[11px] text-cinnabar hover:underline disabled:opacity-50"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { ActiveCharacterHeader } from "./ActiveCharacterHeader";
|
||||
import { useChatStore } from "@/lib/stores/chat-store";
|
||||
|
||||
const mockCharacter = {
|
||||
id: "char_1",
|
||||
name: "测试角色",
|
||||
display_name: "小测",
|
||||
status: "active" as const,
|
||||
language: "zh",
|
||||
profile: {},
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
describe("ActiveCharacterHeader", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
useChatStore.setState({
|
||||
runStatus: null,
|
||||
activeRunId: null,
|
||||
modelHint: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("渲染人物名称", () => {
|
||||
render(<ActiveCharacterHeader character={mockCharacter} />);
|
||||
expect(screen.getByText("小测")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("无 display_name 时回退 name", () => {
|
||||
render(
|
||||
<ActiveCharacterHeader
|
||||
character={{ ...mockCharacter, display_name: undefined }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("测试角色")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("无人物时显示默认文案", () => {
|
||||
render(<ActiveCharacterHeader character={null} />);
|
||||
expect(screen.getByText("未加载人物")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("runStatus=running 渲染 Run 状态链接", () => {
|
||||
useChatStore.setState({ runStatus: "running", activeRunId: "run_1" });
|
||||
render(<ActiveCharacterHeader character={mockCharacter} />);
|
||||
expect(screen.getByText("定策中")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("无 runStatus 时常驻显示静默态'静鉴'", () => {
|
||||
render(<ActiveCharacterHeader character={mockCharacter} />);
|
||||
// RunBadge 常驻:无 run 时呈静默"静鉴",而非消失
|
||||
expect(screen.getByText("静鉴")).toBeInTheDocument();
|
||||
expect(screen.queryByText("定策中")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,234 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { CharacterSwitcher } from "./CharacterSwitcher";
|
||||
import { useChatStore } from "@/lib/stores/chat-store";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import type { Character, LogicalModel } from "@/types/api";
|
||||
import type { RunStatus } from "@/types/domain";
|
||||
|
||||
/** 运行态 → 中文爻位短语(设计稿 run-badge 文案,如 running=定策中) */
|
||||
const RUN_LABEL: Record<RunStatus, string> = {
|
||||
queued: "起意中",
|
||||
running: "定策中",
|
||||
waiting_confirmation: "待确认",
|
||||
completed: "已入藏",
|
||||
failed: "归真",
|
||||
cancelled: "已止",
|
||||
};
|
||||
/** 进行中态(脉冲点亮);终态静止,不再呼吸 */
|
||||
const RUN_LIVE = new Set<RunStatus>([
|
||||
"queued",
|
||||
"running",
|
||||
"waiting_confirmation",
|
||||
]);
|
||||
|
||||
/**
|
||||
* 右上角运行态徽标:常驻显示。
|
||||
* 有进行中 run 时呈现爻位态(脉冲点亮,可点入轨迹);无 run 或已终态时
|
||||
* 静默"静鉴",保持顶栏视觉锚点不消失(镜始终在场)。
|
||||
*/
|
||||
function RunBadge({
|
||||
runStatus,
|
||||
activeRunId,
|
||||
}: {
|
||||
runStatus: RunStatus | null;
|
||||
activeRunId: string | null;
|
||||
}) {
|
||||
const live = !!activeRunId && !!runStatus && RUN_LIVE.has(runStatus);
|
||||
const label = activeRunId && runStatus ? RUN_LABEL[runStatus] : "静鉴";
|
||||
const cls = cn(
|
||||
"inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border px-2.5 py-[3px] text-[12px] transition-colors",
|
||||
live
|
||||
? "border-patina-dim bg-patina-wash text-patina hover:border-patina hover:text-patina-bright"
|
||||
: "border-rule bg-surface/60 text-fg-faint",
|
||||
);
|
||||
const dot = (
|
||||
<span
|
||||
className={cn(
|
||||
"h-[5px] w-[5px] shrink-0 rounded-full",
|
||||
live ? "animate-pulse-ring bg-patina" : "bg-fg-faint/50",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
if (activeRunId && runStatus) {
|
||||
return (
|
||||
<Link
|
||||
href={`/runs/${activeRunId}`}
|
||||
aria-label={`查看运行 ${label}`}
|
||||
className={cls}
|
||||
>
|
||||
{dot}
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={cls} aria-label="镜面静待铭文">
|
||||
{dot}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 铜镜鉴心 v2 顶栏(Topbar)。
|
||||
*
|
||||
* 极简:左字标 ●魂鏡 + 镜背轨开关 + 当前人物 | 右 pill 组(运行态 / 模型 / 人物)
|
||||
* 底部 1px rule 发丝线。Run 状态由铜镜呼吸隐式表达。
|
||||
*/
|
||||
export function ActiveCharacterHeader({
|
||||
character,
|
||||
models,
|
||||
railCollapsed = false,
|
||||
onToggleRail,
|
||||
}: {
|
||||
character: Character | null;
|
||||
models?: LogicalModel[];
|
||||
/** 镜背轨(chat-region 内的会话/状态轨)是否收起 */
|
||||
railCollapsed?: boolean;
|
||||
/** 切换镜背轨展开 / 收起 */
|
||||
onToggleRail?: () => void;
|
||||
}) {
|
||||
const runStatus = useChatStore(
|
||||
(state) => state.runStatus,
|
||||
) as RunStatus | null;
|
||||
const activeRunId = useChatStore((state) => state.activeRunId);
|
||||
const displayName =
|
||||
character?.display_name || character?.name || "未加载人物";
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 flex items-center gap-2 border-b border-rule bg-ink/78 px-4 py-3 backdrop-blur-lg sm:gap-4 sm:px-5">
|
||||
{/* 镜背轨开关(桌面,替代原浮动圆钮) */}
|
||||
{onToggleRail && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleRail}
|
||||
aria-label={railCollapsed ? "展开镜背轨" : "收起镜背轨"}
|
||||
title={railCollapsed ? "展开镜背轨" : "收起镜背轨"}
|
||||
className={cn(
|
||||
"hidden h-8 w-8 shrink-0 items-center justify-center rounded-lg border transition-colors md:inline-flex",
|
||||
railCollapsed
|
||||
? "border-rule bg-surface text-fg-dim hover:border-gilt-dim hover:text-fg"
|
||||
: "border-patina-dim bg-patina-wash text-patina",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
>
|
||||
<rect
|
||||
x="2"
|
||||
y="2"
|
||||
width="12"
|
||||
height="12"
|
||||
rx="1.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
<path d="M6 2v12" stroke="currentColor" strokeWidth="1.3" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 人物切换器:按钮即当前人物(设计稿紧挨 rail-toggle,左侧) */}
|
||||
<CharacterSwitcher
|
||||
currentCharacterId={character?.id}
|
||||
currentDisplayName={displayName}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1" />
|
||||
|
||||
{/* 右侧 pills:运行态(常驻)+ 模型 */}
|
||||
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
|
||||
<RunBadge runStatus={runStatus} activeRunId={activeRunId} />
|
||||
|
||||
<ModelPill models={models} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/** 模型选择:下拉框选择模型 */
|
||||
function ModelPill({ models }: { models?: LogicalModel[] }) {
|
||||
const modelHint = useChatStore((s) => s.modelHint);
|
||||
const setModelHint = useChatStore((s) => s.setModelHint);
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentModel = models?.find((m) => m.name === modelHint);
|
||||
const label = currentModel?.display_name || modelHint || "模型";
|
||||
|
||||
// 点击外部收起
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
title="切换模型"
|
||||
className="inline-flex max-w-24 items-center gap-1 rounded-full border border-rule bg-surface px-2.5 py-1 text-[10px] text-fg-dim transition-colors hover:border-gilt-dim hover:text-fg sm:max-w-40 sm:px-3 sm:text-[11px]"
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
className={cn("shrink-0 transition-transform", open && "rotate-180")}
|
||||
>
|
||||
<path
|
||||
d="M4 6l4 4 4-4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 min-w-44 rounded-xl border border-border-glass bg-glass-strong p-1 shadow-xl backdrop-blur-xl">
|
||||
{!models || models.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted">无可用模型</div>
|
||||
) : (
|
||||
models.map((m) => (
|
||||
<button
|
||||
key={m.name}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setModelHint(m.name);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-xs transition-colors hover:bg-fg/5"
|
||||
>
|
||||
<span className="flex-1 truncate text-fg-dim">
|
||||
{m.display_name || m.name}
|
||||
</span>
|
||||
{m.name === modelHint && (
|
||||
<span className="shrink-0 text-[9px] text-patina">当前</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
|
||||
// 不触发实际的 API 调用
|
||||
vi.mock("@/lib/api", () => ({
|
||||
charactersApi: {
|
||||
listCharacters: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { CharacterSwitcher } from "./CharacterSwitcher";
|
||||
|
||||
describe("CharacterSwitcher", () => {
|
||||
it("渲染当前人物名", () => {
|
||||
render(
|
||||
<CharacterSwitcher
|
||||
currentCharacterId="char_1"
|
||||
currentDisplayName="小测"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("小测")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("点击展开下拉菜单", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<CharacterSwitcher
|
||||
currentCharacterId="char_1"
|
||||
currentDisplayName="小测"
|
||||
/>,
|
||||
);
|
||||
// 当前人物名可点击
|
||||
await user.click(screen.getByText("小测"));
|
||||
// dropdown 应该出现——因为 listCharacters 返回空列表,
|
||||
// 此处只验证下拉渲染不报错
|
||||
expect(screen.getByText("小测")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,161 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { charactersApi } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import { describeError } from "@/lib/utils/error";
|
||||
import type { CharacterListItem } from "@/types/api";
|
||||
|
||||
/** 人物缩略头像(首字):激活态实心铜绿,其余铜绿晕底。与镜背轨状态卡同源。 */
|
||||
function AvatarMini({ name, active }: { name: string; active?: boolean }) {
|
||||
const ch = name?.trim()?.[0] ?? "镜";
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full font-serif text-[12px] font-semibold",
|
||||
active ? "bg-patina text-ink" : "bg-patina-wash text-patina",
|
||||
)}
|
||||
>
|
||||
{ch}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对话页人物切换器:下拉列出全部人物,选中后激活并刷新页面。
|
||||
* 点击外部自动收起。
|
||||
*/
|
||||
export function CharacterSwitcher({
|
||||
currentCharacterId,
|
||||
currentDisplayName,
|
||||
}: {
|
||||
currentCharacterId: string | undefined;
|
||||
currentDisplayName: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [characters, setCharacters] = useState<CharacterListItem[]>([]);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 打开时拉取人物列表
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
charactersApi
|
||||
.listCharacters({ page_size: 50 })
|
||||
.then((res) => {
|
||||
if (!cancelled) setCharacters(res.items);
|
||||
})
|
||||
.catch(() => {
|
||||
/* 拉取失败静默 */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// 点击外部收起
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
async function handleSwitch(characterId: string) {
|
||||
if (characterId === currentCharacterId) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setSwitching(true);
|
||||
try {
|
||||
await charactersApi.activateCharacter(characterId);
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
/* 切换失败不阻塞,静默保留当前人物 */
|
||||
console.error("切换人物失败:", describeError(e));
|
||||
setSwitching(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border border-rule py-0.5 pl-0.5 pr-2.5 text-[13px] font-medium text-fg-dim transition-colors hover:border-gilt-dim hover:text-fg",
|
||||
switching && "pointer-events-none opacity-60",
|
||||
)}
|
||||
disabled={switching}
|
||||
title="切换人物"
|
||||
>
|
||||
<AvatarMini name={currentDisplayName} active />
|
||||
<span className="max-w-[120px] truncate">{currentDisplayName}</span>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
className={cn("transition-transform", open && "rotate-180")}
|
||||
>
|
||||
<path
|
||||
d="M4 6l4 4 4-4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 top-full z-50 mt-1 w-56 rounded-xl border border-border-glass bg-glass-strong p-1 shadow-xl backdrop-blur-xl">
|
||||
{characters.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-muted">加载中…</div>
|
||||
) : (
|
||||
characters.map((c) => {
|
||||
const isCurrent = c.id === currentCharacterId;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => handleSwitch(c.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[13px] transition-colors",
|
||||
isCurrent
|
||||
? "bg-patina-wash text-patina"
|
||||
: "text-fg-dim hover:bg-fg/5 hover:text-fg",
|
||||
)}
|
||||
>
|
||||
<AvatarMini
|
||||
name={c.display_name || c.name}
|
||||
active={isCurrent}
|
||||
/>
|
||||
<span className="flex-1 truncate">
|
||||
{c.display_name || c.name}
|
||||
</span>
|
||||
{c.status && (
|
||||
<span className="shrink-0 rounded-full bg-surface px-1.5 py-px text-[10px] text-fg-faint">
|
||||
{c.status}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { memoryApi } from "@/lib/api";
|
||||
import { useChatStore } from "@/lib/stores/chat-store";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import type { Character, RetrievedMemoryItem } from "@/types/api";
|
||||
import type { RunStatus } from "@/types/domain";
|
||||
|
||||
const HEXAGRAM_STAGES = [
|
||||
{ key: "起意", order: 1 },
|
||||
{ key: "察势", order: 2 },
|
||||
{ key: "定策", order: 3 },
|
||||
{ key: "行事", order: 4 },
|
||||
{ key: "归真", order: 5 },
|
||||
{ key: "入藏", order: 6 },
|
||||
] as const;
|
||||
|
||||
const RUN_TO_HEXAGRAM: Record<
|
||||
string,
|
||||
(typeof HEXAGRAM_STAGES)[number]["key"] | null
|
||||
> = {
|
||||
queued: "起意",
|
||||
running: "定策",
|
||||
waiting_confirmation: "行事",
|
||||
completed: "入藏",
|
||||
failed: "归真",
|
||||
cancelled: "归真",
|
||||
};
|
||||
|
||||
/**
|
||||
* 镜背轨的实时上下文区:人物状态卡、六爻、息壤。
|
||||
* embedded 模式不再生成独立侧栏,以便与历史会话共用一条镜背轨。
|
||||
*/
|
||||
export function ChatContextRail({
|
||||
character,
|
||||
className,
|
||||
embedded = false,
|
||||
}: {
|
||||
character?: Character | null;
|
||||
className?: string;
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
const [xirangOpen, setXirangOpen] = useState(true);
|
||||
const [hexOpen, setHexOpen] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const runStatus = useChatStore(
|
||||
(state) => state.runStatus,
|
||||
) as RunStatus | null;
|
||||
const memories = useChatStore((state) => state.retrievedMemories);
|
||||
const setRetrievedMemories = useChatStore(
|
||||
(state) => state.setRetrievedMemories,
|
||||
);
|
||||
|
||||
const hexagrams = useMemo(() => {
|
||||
const activeKey = runStatus ? RUN_TO_HEXAGRAM[runStatus] : null;
|
||||
const activeOrder = activeKey
|
||||
? (HEXAGRAM_STAGES.find((stage) => stage.key === activeKey)?.order ?? 0)
|
||||
: 0;
|
||||
|
||||
return HEXAGRAM_STAGES.map((stage) => ({
|
||||
...stage,
|
||||
state:
|
||||
stage.order < activeOrder
|
||||
? ("done" as const)
|
||||
: stage.order === activeOrder
|
||||
? ("active" as const)
|
||||
: ("future" as const),
|
||||
}));
|
||||
}, [runStatus]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const data = await memoryApi.listMemories({ page_size: 10 });
|
||||
const items: RetrievedMemoryItem[] = data.items.map((m) => ({
|
||||
id: m.id,
|
||||
title: m.title ?? "",
|
||||
content: m.content,
|
||||
source: m.source,
|
||||
confidence: m.confidence ?? 1,
|
||||
importance: m.importance ?? 0.5,
|
||||
type: m.type,
|
||||
}));
|
||||
setRetrievedMemories(items);
|
||||
} catch {
|
||||
// 静默
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [setRetrievedMemories]);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{/* 人物状态卡:avatar-ring + 认识天数 + 情绪/内驱 stats */}
|
||||
<StatusCard character={character} />
|
||||
|
||||
{/* 六爻:本次运行所处的爻位(可折叠,与息壤同构) */}
|
||||
<div className="shrink-0 border-b border-rule px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<RailLabel label="六爻" sub="本次运行" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHexOpen((v) => !v)}
|
||||
aria-expanded={hexOpen}
|
||||
className="ml-auto inline-flex items-center gap-1 rounded-full border border-rule bg-transparent px-2 py-0.5 text-[9px] font-sans text-fg-faint transition-colors hover:border-gilt-dim hover:text-fg"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block text-[7px] transition-transform duration-250",
|
||||
hexOpen && "rotate-180",
|
||||
)}
|
||||
>
|
||||
▼
|
||||
</span>
|
||||
{hexOpen ? "收起" : "展开"}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-[400ms] ease-[cubic-bezier(0.4,0,0.2,1)]",
|
||||
hexOpen ? "mt-2 max-h-[400px] opacity-100" : "max-h-0 opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{hexagrams.map((hexagram) => {
|
||||
const isActive = hexagram.state === "active";
|
||||
const isDone = hexagram.state === "done";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={hexagram.key}
|
||||
className="flex items-center gap-2 py-0.5"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mono w-[18px] text-right text-[10px]",
|
||||
isActive
|
||||
? "text-gilt"
|
||||
: isDone
|
||||
? "text-fg-dim"
|
||||
: "text-fg-faint",
|
||||
)}
|
||||
>
|
||||
{["初", "二", "三", "四", "五", "上"][hexagram.order - 1]}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 text-[12px]",
|
||||
isActive
|
||||
? "font-medium text-gilt"
|
||||
: isDone
|
||||
? "text-fg-dim"
|
||||
: "text-fg-faint",
|
||||
)}
|
||||
>
|
||||
{hexagram.key}
|
||||
</span>
|
||||
{/* 爻象进度:容器虚线(阴爻·未达),fill 覆盖为阳爻(已达·铜绿 / 当爻·鎏金流光) */}
|
||||
<div className="h-[5px] w-16 shrink-0 overflow-hidden rounded-[2px] [background:repeating-linear-gradient(90deg,var(--rule)_0_4px,transparent_4px_8px)]">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-[2px] transition-all duration-500",
|
||||
isActive &&
|
||||
"w-full animate-hex-shimmer bg-[linear-gradient(90deg,var(--gilt-dim),var(--gilt),var(--gilt-dim))] bg-[length:200%_100%]",
|
||||
isDone &&
|
||||
"w-full bg-[linear-gradient(90deg,var(--patina-dim),var(--patina))]",
|
||||
!isActive && !isDone && "w-0",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 息壤:从 memory.retrieved SSE 事件推送的记忆数据 */}
|
||||
<div
|
||||
className={cn(
|
||||
"border-b border-rule px-4 py-4",
|
||||
xirangOpen ? "flex min-h-0 flex-1 flex-col" : "shrink-0",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RailLabel label="息壤" sub="浮现" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="ml-auto inline-flex items-center gap-1 rounded-full border border-rule bg-transparent px-2 py-0.5 text-[9px] font-sans text-fg-faint transition-colors hover:border-gilt-dim hover:text-fg disabled:opacity-40"
|
||||
title="刷新记忆"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block text-[10px] transition-transform duration-300",
|
||||
refreshing && "animate-spin",
|
||||
)}
|
||||
>
|
||||
↻
|
||||
</span>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setXirangOpen((v) => !v)}
|
||||
aria-expanded={xirangOpen}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-rule bg-transparent px-2 py-0.5 text-[9px] font-sans text-fg-faint transition-colors hover:border-gilt-dim hover:text-fg"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block text-[7px] transition-transform duration-250",
|
||||
xirangOpen && "rotate-180",
|
||||
)}
|
||||
>
|
||||
▼
|
||||
</span>
|
||||
{xirangOpen ? "收起" : "展开"}
|
||||
</button>
|
||||
</div>
|
||||
{xirangOpen && (
|
||||
<div className="mt-2 flex-1 overflow-y-auto">
|
||||
{memories.length > 0 ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{memories.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="rounded-[3px] border border-rule/40 bg-surface/50 px-2.5 py-2 text-[11px] leading-relaxed text-fg-dim transition-colors hover:border-rule/70"
|
||||
>
|
||||
{m.title && (
|
||||
<div className="mb-0.5 truncate text-[11px] font-medium text-fg">
|
||||
{m.title}
|
||||
</div>
|
||||
)}
|
||||
<div className="line-clamp-3">{m.content}</div>
|
||||
<span className="mt-1 block text-[10px] text-fg-faint">
|
||||
置信{" "}
|
||||
<span className="font-medium text-patina">
|
||||
{m.confidence.toFixed(2)}
|
||||
</span>
|
||||
{m.source && <> · {sourceLabel(m.source)}</>}
|
||||
{m.importance > 0.7 && (
|
||||
<>
|
||||
{" "}
|
||||
· <span className="text-gilt">重要</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-l-2 border-patina bg-patina-wash px-2.5 py-2 text-[11px] leading-relaxed text-fg-dim [border-radius:0_3px_3px_0]">
|
||||
发送消息后,检索到的记忆将在此浮现。
|
||||
<small className="mt-1 block text-[9px] text-fg-faint">
|
||||
对话结束后自动沉淀
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) return content;
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"hidden min-h-0 w-[220px] shrink-0 flex-col overflow-hidden border-r border-rule bg-sidebar md:flex",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 人物状态卡:镜背轨顶部的"被照见者"——头像鎏金环 + 名号 + 认识天数,
|
||||
* 下方两格 stats 呈现情绪与内驱。与设计稿 status-card 同源。
|
||||
*/
|
||||
function StatusCard({ character }: { character?: Character | null }) {
|
||||
const sending = useChatStore((state) => state.sending);
|
||||
const displayName = character?.display_name || character?.name;
|
||||
const avatarChar = (displayName || "镜").slice(0, 1);
|
||||
const moodLabel = character?.current_mood?.mood ?? "沉静";
|
||||
// 认识天数:以人物创建时刻为关系起点;挂载时计算一次(会话期间不变)
|
||||
const [days] = useState<number | null>(() => {
|
||||
const created = character?.created_at;
|
||||
if (!created) return null;
|
||||
const d = Math.floor(
|
||||
(Date.now() - new Date(created).getTime()) / 86_400_000,
|
||||
);
|
||||
return d > 0 ? d : 1;
|
||||
});
|
||||
// 内驱暂无独立后端数据源,以运行态作语义占位(运筹/守候),避免呈现虚假具体值
|
||||
const driveLabel = sending ? "运筹" : "守候";
|
||||
const driveHint = sending ? "倾听" : "回应";
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-b border-rule px-[18px] pb-3 pt-[18px]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border-[1.5px] border-patina-dim bg-patina-wash">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-patina font-serif text-base font-bold text-ink">
|
||||
{avatarChar}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-serif text-[18px] font-bold tracking-[0.02em] text-fg">
|
||||
{displayName ?? "未加载人物"}
|
||||
</div>
|
||||
<div className="mt-px flex items-center gap-1.5 text-[12px] text-fg-dim">
|
||||
<span className="h-1.5 w-1.5 shrink-0 animate-pulse-ring rounded-full bg-patina" />
|
||||
<span className="truncate">
|
||||
{days ? `认识你第 ${days} 日` : "初识"} · {moodLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<Stat k="情绪">
|
||||
<span className="text-fg">{moodLabel}</span>
|
||||
<small className="text-[11px] font-normal text-fg-dim">
|
||||
{sending ? "运筹" : "平稳"}
|
||||
</small>
|
||||
</Stat>
|
||||
<Stat k="内驱">
|
||||
<span className="text-fg">{driveLabel}</span>
|
||||
<small className="text-[11px] font-normal text-fg-dim">
|
||||
{driveHint}
|
||||
</small>
|
||||
</Stat>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ k, children }: { k: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-[5px] border border-rule bg-surface px-2.5 py-2">
|
||||
<div className="text-[9px] uppercase tracking-[0.12em] text-fg-faint">
|
||||
{k}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-baseline gap-1 text-[14px] font-medium">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 记忆来源中文标签 */
|
||||
function sourceLabel(source: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
user_explicit: "用户明确",
|
||||
conversation: "对话推断",
|
||||
material_import: "资料导入",
|
||||
tool_result: "工具结果",
|
||||
reflection: "反思沉淀",
|
||||
};
|
||||
return labels[source] ?? source;
|
||||
}
|
||||
|
||||
function RailLabel({ label, sub }: { label: string; sub: string }) {
|
||||
return (
|
||||
<p className="mono text-[10px] uppercase tracking-[0.2em] text-fg-faint">
|
||||
{label}
|
||||
<span className="ml-1 font-sans text-[10px] font-normal normal-case text-fg-faint/60">
|
||||
{sub}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import { useChatStore } from "@/lib/stores/chat-store";
|
||||
|
||||
describe("ChatInput", () => {
|
||||
afterEach(() => {
|
||||
useChatStore.setState({
|
||||
sending: false,
|
||||
thinkingMode: true,
|
||||
thinkingEffort: "medium",
|
||||
webSearchEnabled: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
const PLACEHOLDER = "输入消息,Enter 发送,Shift+Enter 换行";
|
||||
|
||||
it("渲染输入框", () => {
|
||||
render(<ChatInput onSend={() => {}} />);
|
||||
expect(screen.getByPlaceholderText(PLACEHOLDER)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("输入文本后按 Enter 发送", async () => {
|
||||
const onSend = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<ChatInput onSend={onSend} />);
|
||||
const input = screen.getByPlaceholderText(PLACEHOLDER);
|
||||
await user.type(input, "你好");
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onSend).toHaveBeenCalledWith("你好");
|
||||
});
|
||||
|
||||
it("空文本按 Enter 不发送", async () => {
|
||||
const onSend = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<ChatInput onSend={onSend} />);
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disabled 状态下显示不同占位且不发送", async () => {
|
||||
const onSend = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<ChatInput onSend={onSend} disabled />);
|
||||
expect(screen.getByPlaceholderText("请先创建会话")).toBeInTheDocument();
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("发送后清空输入框", async () => {
|
||||
const onSend = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<ChatInput onSend={onSend} />);
|
||||
const input = screen.getByPlaceholderText(PLACEHOLDER);
|
||||
await user.type(input, "消息");
|
||||
await user.keyboard("{Enter}");
|
||||
// 发送后 input 应清空
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("sending 时显示停止按钮", () => {
|
||||
useChatStore.setState({ sending: true });
|
||||
render(<ChatInput onSend={() => {}} onStop={() => {}} />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "停止生成" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("不存在 sending 时不显示停止按钮", () => {
|
||||
render(<ChatInput onSend={() => {}} onStop={() => {}} />);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "停止生成" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("思考模式按钮可切换", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ChatInput onSend={() => {}} />);
|
||||
const btn = screen.getByRole("button", { name: /思考模式/ });
|
||||
expect(btn).toBeInTheDocument();
|
||||
await user.click(btn);
|
||||
expect(useChatStore.getState().thinkingMode).toBe(false);
|
||||
});
|
||||
|
||||
it("超长消息显示错误", async () => {
|
||||
const longText = "a".repeat(33000);
|
||||
const onSend = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<ChatInput onSend={onSend} />);
|
||||
const input = screen.getByPlaceholderText(PLACEHOLDER);
|
||||
// 用 click + keyboard 避免 userEvent.type 逐字符输入 33000 字符
|
||||
await user.click(input);
|
||||
await user.paste(longText);
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
expect(useChatStore.getState().error).toBe("单条消息过长,请精简后发送。");
|
||||
});
|
||||
});
|
||||
@@ -1,368 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import type { CSSProperties, KeyboardEvent } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import { useChatStore } from "@/lib/stores/chat-store";
|
||||
import { useVoiceInput } from "@/lib/hooks/useVoiceInput";
|
||||
import type { ThinkingEffort } from "@/types/thinking";
|
||||
|
||||
/** 单条消息字节上限(API §5.6,默认 32 KiB) */
|
||||
const MAX_TEXT_BYTES = 32 * 1024;
|
||||
|
||||
/** textarea 最大高度:超出后内部滚动 */
|
||||
const TEXTAREA_MAX_H = "10rem";
|
||||
|
||||
const EFFORT_OPTIONS: { value: ThinkingEffort; label: string; hint: string }[] =
|
||||
[
|
||||
{ value: "low", label: "low", hint: "快速 · 简短思考" },
|
||||
{ value: "medium", label: "medium", hint: "均衡 · 适度推演" },
|
||||
{ value: "high", label: "high", hint: "深思 · 详尽推演" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 铜镜鉴心 v2 铭文输入框(Composer)。
|
||||
*
|
||||
* 铭文刻线分隔 + 透明输入框 + 呈镜按钮。思考模式/搜索开关移至顶部
|
||||
* 以 pill 容纳,此处只保留最简的发送行为。
|
||||
*/
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
onFollowUp,
|
||||
disabled,
|
||||
onStop,
|
||||
stopping,
|
||||
}: {
|
||||
onSend: (content: string) => void;
|
||||
onFollowUp?: (content: string) => void;
|
||||
disabled?: boolean;
|
||||
onStop?: () => void;
|
||||
stopping?: boolean;
|
||||
}) {
|
||||
const sending = useChatStore((s) => s.sending);
|
||||
const thinkingMode = useChatStore((s) => s.thinkingMode);
|
||||
const thinkingEffort = useChatStore((s) => s.thinkingEffort);
|
||||
const setThinkingMode = useChatStore((s) => s.setThinkingMode);
|
||||
const setThinkingEffort = useChatStore((s) => s.setThinkingEffort);
|
||||
const webSearchEnabled = useChatStore((s) => s.webSearchEnabled);
|
||||
const setWebSearchEnabled = useChatStore((s) => s.setWebSearchEnabled);
|
||||
const [text, setText] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
/** 将转写文本追加到当前输入框(在已有内容后以空格分隔)。 */
|
||||
const insertTranscript = (t: string) => {
|
||||
const trimmed = t.trim();
|
||||
if (!trimmed) return;
|
||||
setText((prev) => (prev ? `${prev} ${trimmed}` : trimmed));
|
||||
// 转写完成后将焦点归还输入框
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
};
|
||||
|
||||
const voice = useVoiceInput({
|
||||
language: "zh",
|
||||
onResult: insertTranscript,
|
||||
onError: (msg) => useChatStore.getState().setError(msg),
|
||||
});
|
||||
|
||||
const send = () => {
|
||||
const content = text.trim();
|
||||
// 运行中(sending)也允许发送:经 ChatShell handleSend 分流为 steer,
|
||||
// 让用户能在生成过程中补充指引;disabled 仅在无会话时阻断。
|
||||
if (!content || disabled) return;
|
||||
if (new Blob([content]).size > MAX_TEXT_BYTES) {
|
||||
useChatStore.getState().setError("单条消息过长,请精简后发送。");
|
||||
return;
|
||||
}
|
||||
onSend(content);
|
||||
setText("");
|
||||
};
|
||||
|
||||
const sendFollowUp = () => {
|
||||
const content = text.trim();
|
||||
if (!content || disabled) return;
|
||||
onFollowUp?.(content);
|
||||
setText("");
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
};
|
||||
|
||||
const sendDisabled = disabled || sending || !text.trim();
|
||||
|
||||
return (
|
||||
<div className="border-t border-rule px-5 py-3">
|
||||
{/* 铭文输入区 */}
|
||||
<div className="flex items-end gap-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={1}
|
||||
placeholder={
|
||||
disabled
|
||||
? "请先创建会话"
|
||||
: sending
|
||||
? "Enter 补充指引(steer)· 点「追加」排队后续任务"
|
||||
: "输入消息,Enter 发送,Shift+Enter 换行"
|
||||
}
|
||||
disabled={disabled}
|
||||
style={{ "--textarea-max-h": TEXTAREA_MAX_H } as CSSProperties}
|
||||
className={cn(
|
||||
"[field-sizing:content] block w-full resize-none overflow-y-auto border-none bg-transparent px-0 py-2 text-sm text-fg outline-none placeholder:text-fg-faint disabled:opacity-50",
|
||||
"min-h-[2.2rem] max-h-[var(--textarea-max-h)]",
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 语音输入:点击开始/停止录音并转写(与"呈镜"同款样式)*/}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void voice.toggle()}
|
||||
disabled={disabled}
|
||||
aria-pressed={voice.status === "recording"}
|
||||
aria-label={
|
||||
voice.status === "recording"
|
||||
? "停止录音并转写"
|
||||
: voice.status === "transcribing"
|
||||
? "正在转写语音"
|
||||
: "语音输入"
|
||||
}
|
||||
title={
|
||||
voice.status === "recording"
|
||||
? "停止录音并转写"
|
||||
: voice.status === "transcribing"
|
||||
? "正在转写…"
|
||||
: "语音输入"
|
||||
}
|
||||
className={cn(
|
||||
"font-serif inline-flex shrink-0 items-center justify-center gap-1.5 rounded border px-4 py-1.5 text-sm tracking-[0.08em] transition-colors focus-visible:outline-none focus-visible:ring-2",
|
||||
voice.status === "recording"
|
||||
? "border-cinnabar-dim bg-transparent text-cinnabar focus-visible:ring-cinnabar/40"
|
||||
: voice.status === "transcribing"
|
||||
? "border-gilt-dim bg-transparent text-gilt focus-visible:ring-gilt/40"
|
||||
: "border-patina-dim bg-transparent text-patina hover:border-patina hover:text-patina-bright focus-visible:ring-patina/40",
|
||||
"disabled:cursor-not-allowed disabled:border-rule disabled:text-fg-faint",
|
||||
)}
|
||||
>
|
||||
{voice.status === "transcribing" ? (
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
>
|
||||
<rect
|
||||
x="6"
|
||||
y="2"
|
||||
width="4"
|
||||
height="7"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<path
|
||||
d="M3.5 7.5a4.5 4.5 0 0 0 9 0M8 12v2.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{voice.status === "recording"
|
||||
? "停录"
|
||||
: voice.status === "transcribing"
|
||||
? "转写中"
|
||||
: "语音"}
|
||||
</button>
|
||||
|
||||
{sending && onStop ? (
|
||||
<>
|
||||
{onFollowUp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={sendFollowUp}
|
||||
disabled={!text.trim() || stopping}
|
||||
title="追加后续任务(当前任务完成后执行)"
|
||||
aria-label="追加后续任务"
|
||||
className={cn(
|
||||
"font-serif flex h-8 shrink-0 items-center justify-center rounded border border-patina-dim bg-patina/10 px-3 text-xs tracking-[0.06em] text-patina transition-all hover:bg-patina/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-patina/40",
|
||||
"disabled:cursor-not-allowed disabled:border-rule disabled:bg-transparent disabled:text-fg-faint",
|
||||
)}
|
||||
>
|
||||
追加
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
disabled={stopping}
|
||||
title={stopping ? "正在停止…" : "停止生成"}
|
||||
aria-label="停止生成"
|
||||
className={cn(
|
||||
"flex h-8 w-16 shrink-0 items-center justify-center rounded border border-cinnabar-dim bg-cinnabar/10 text-cinnabar text-xs transition-all hover:bg-cinnabar/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/40",
|
||||
stopping && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
{stopping ? (
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
"终止"
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={send}
|
||||
disabled={sendDisabled}
|
||||
title="呈镜(Enter)"
|
||||
aria-label="呈镜发送"
|
||||
className={cn(
|
||||
"font-serif inline-flex shrink-0 items-center justify-center gap-1.5 rounded border border-patina-dim bg-transparent px-4 py-1.5 text-sm tracking-[0.08em] text-patina transition-colors hover:border-patina hover:text-patina-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-patina/40",
|
||||
"disabled:cursor-not-allowed disabled:border-rule disabled:text-fg-faint",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
>
|
||||
<circle cx="8" cy="8" r="1.3" fill="currentColor" />
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="3.3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.1"
|
||||
opacity="0.85"
|
||||
/>
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="5.6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.1"
|
||||
opacity="0.5"
|
||||
/>
|
||||
</svg>
|
||||
呈镜
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 控制开关行 */}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setThinkingMode(!thinkingMode)}
|
||||
aria-pressed={thinkingMode}
|
||||
aria-label={`思考模式${thinkingMode ? "已开启" : "已关闭"}`}
|
||||
title="思考模式"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded border px-2 py-1 text-[10px] font-medium transition-colors",
|
||||
thinkingMode
|
||||
? "border-gilt-dim bg-gilt/10 text-gilt"
|
||||
: "border-rule text-fg-faint hover:text-fg-dim",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M8 1.5A4 4 0 0 0 5.2 8.4c.3.4.5.9.5 1.4v.2a1 1 0 0 0 1 1h2.6a1 1 0 0 0 1-1v-.2c0-.5.2-1 .5-1.4A4 4 0 0 0 8 1.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6.5 13h3M7 14.5h2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
深虑
|
||||
</button>
|
||||
|
||||
{thinkingMode && (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="思考力度"
|
||||
className="inline-flex items-center gap-0.5 rounded-full border border-gilt-dim p-0.5"
|
||||
>
|
||||
{EFFORT_OPTIONS.map((opt) => {
|
||||
const active = thinkingEffort === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
title={opt.hint}
|
||||
onClick={() => setThinkingEffort(opt.value)}
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-0.5 font-mono text-[11px] font-medium transition-colors",
|
||||
active
|
||||
? "bg-gilt-faint text-gilt"
|
||||
: "text-fg-faint hover:text-fg-dim",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWebSearchEnabled(!webSearchEnabled)}
|
||||
aria-pressed={webSearchEnabled}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded border px-2 py-1 text-[10px] font-medium transition-colors",
|
||||
webSearchEnabled
|
||||
? "border-patina-dim bg-patina/10 text-patina"
|
||||
: "border-rule text-fg-faint hover:text-fg-dim",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
>
|
||||
<circle
|
||||
cx="7"
|
||||
cy="7"
|
||||
r="4.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<path
|
||||
d="M10.5 10.5l3 3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
博闻
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,831 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { ActiveCharacterHeader } from "./ActiveCharacterHeader";
|
||||
import { ChatContextRail } from "./ChatContextRail";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import { FilePreviewPanel } from "./FilePreviewPanel";
|
||||
import { MessageList } from "./MessageList";
|
||||
import { SessionSidebar } from "./SessionSidebar";
|
||||
import { MirrorHero } from "./MirrorHero";
|
||||
import { PlanPanel } from "./PlanPanel";
|
||||
import { TextSkeleton } from "@/components/ui/Skeleton";
|
||||
import { ErrorTraceLink } from "@/components/shared/ErrorTraceLink";
|
||||
import { describeError, traceIdOf } from "@/lib/utils/error";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import { charactersApi, chatApi, runsApi } from "@/lib/api";
|
||||
import { useRunStream } from "@/lib/hooks/useRunStream";
|
||||
import { useGlobalEventStream } from "@/lib/hooks/useGlobalEventStream";
|
||||
import { useChatStore } from "@/lib/stores/chat-store";
|
||||
import type { MessageVersion } from "@/lib/stores/chat-store";
|
||||
import type { Character, ModelGatewayStatus, RunEvent } from "@/types/api";
|
||||
import type { Plan } from "@/types/plan";
|
||||
|
||||
/**
|
||||
* 对话编排壳:聚合 Header / 消息列表 / 输入 / 洛书计划侧栏,编排会话创建、
|
||||
* 消息发送与 SSE 事件回流(含思考摘要 reasoning)。初始人物由 Server Component
|
||||
* 预取后注入。
|
||||
*/
|
||||
export function ChatShell({
|
||||
initialCharacter,
|
||||
gateway,
|
||||
}: {
|
||||
initialCharacter: Character | null;
|
||||
gateway?: ModelGatewayStatus | null;
|
||||
}) {
|
||||
const sessionId = useChatStore((s) => s.sessionId);
|
||||
const error = useChatStore((s) => s.error);
|
||||
const errorTraceId = useChatStore((s) => s.errorTraceId);
|
||||
const activeRunId = useChatStore((s) => s.activeRunId);
|
||||
const sending = useChatStore((s) => s.sending);
|
||||
const thinkingMode = useChatStore((s) => s.thinkingMode);
|
||||
const thinkingEffort = useChatStore((s) => s.thinkingEffort);
|
||||
const webSearchEnabled = useChatStore((s) => s.webSearchEnabled);
|
||||
const modelHint = useChatStore((s) => s.modelHint);
|
||||
const setModelHint = useChatStore((s) => s.setModelHint);
|
||||
const setRetrievedMemories = useChatStore((s) => s.setRetrievedMemories);
|
||||
const clearRetrievedMemories = useChatStore((s) => s.clearRetrievedMemories);
|
||||
const addSession = useChatStore((s) => s.addSession);
|
||||
const setSessions = useChatStore((s) => s.setSessions);
|
||||
const addNewSessionId = useChatStore((s) => s.addNewSessionId);
|
||||
const markSessionSeen = useChatStore((s) => s.markSessionSeen);
|
||||
const switchSession = useChatStore((s) => s.switchSession);
|
||||
const setActiveRunId = useChatStore((s) => s.setActiveRunId);
|
||||
const truncateMessagesAfter = useChatStore((s) => s.truncateMessagesAfter);
|
||||
|
||||
const initSession = useChatStore((s) => s.initSession);
|
||||
const hydrateMessages = useChatStore((s) => s.hydrateMessages);
|
||||
const pushUserMessage = useChatStore((s) => s.pushUserMessage);
|
||||
const beginAssistantStream = useChatStore((s) => s.beginAssistantStream);
|
||||
const appendDelta = useChatStore((s) => s.appendDelta);
|
||||
const finalizeAssistant = useChatStore((s) => s.finalizeAssistant);
|
||||
const appendReasoningDelta = useChatStore((s) => s.appendReasoningDelta);
|
||||
const finalizeReasoning = useChatStore((s) => s.finalizeReasoning);
|
||||
const beginToolCall = useChatStore((s) => s.beginToolCall);
|
||||
const completeToolCall = useChatStore((s) => s.completeToolCall);
|
||||
const requireToolConfirmation = useChatStore(
|
||||
(s) => s.requireToolConfirmation,
|
||||
);
|
||||
const markToolCallResolving = useChatStore((s) => s.markToolCallResolving);
|
||||
const setRunStatus = useChatStore((s) => s.setRunStatus);
|
||||
const setSending = useChatStore((s) => s.setSending);
|
||||
const setError = useChatStore((s) => s.setError);
|
||||
const sidebarCollapsed = useChatStore((s) => s.sidebarCollapsed);
|
||||
const toggleSidebar = useChatStore((s) => s.toggleSidebar);
|
||||
const activePreviewIndex = useChatStore((s) => s.activePreviewIndex);
|
||||
const newSessionIds = useChatStore((s) => s.newSessionIds);
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
const [mobileRailOpen, setMobileRailOpen] = useState(false);
|
||||
const [railView, setRailView] = useState<"sessions" | "now">("sessions");
|
||||
const [characterNameMap, setCharacterNameMap] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [activePlan, setActivePlan] = useState<Plan | null>(null);
|
||||
|
||||
useRunStream(
|
||||
activeRunId,
|
||||
{
|
||||
onEvent: handleRunEvent,
|
||||
onError: () => setError("运行事件流连接中断,正在自动重连…"),
|
||||
},
|
||||
{
|
||||
thinking: thinkingMode,
|
||||
effort: thinkingEffort,
|
||||
characterName: initialCharacter?.name,
|
||||
},
|
||||
);
|
||||
|
||||
// 全局一炁事件流:接收 proactive.session(叩心枢主动会话)等全局广播事件
|
||||
useGlobalEventStream({
|
||||
onEvent(event) {
|
||||
if (event.type === "proactive.session") {
|
||||
// 主动会话创建后刷新会话列表,并标记该会话为"新"(前端通知红点)
|
||||
const newSessionId = event.payload?.session_id as string | undefined;
|
||||
if (newSessionId) {
|
||||
addNewSessionId(newSessionId);
|
||||
}
|
||||
chatApi
|
||||
.listSessions()
|
||||
.then((res) => setSessions(res.items))
|
||||
.catch(() => {
|
||||
/* 刷新失败静默 */
|
||||
});
|
||||
} else if (event.type === "proactive.message") {
|
||||
// 追加到已有会话的消息:若正是当前在看的会话,立刻拉取完整消息列表
|
||||
const msgSessionId = event.payload?.session_id as string | undefined;
|
||||
if (
|
||||
msgSessionId &&
|
||||
msgSessionId === useChatStore.getState().sessionId
|
||||
) {
|
||||
chatApi
|
||||
.listMessages(msgSessionId, { page_size: 100 })
|
||||
.then((res) => hydrateMessages(res.items))
|
||||
.catch(() => {});
|
||||
} else if (msgSessionId) {
|
||||
// 非当前会话:标记红点提示
|
||||
addNewSessionId(msgSessionId);
|
||||
chatApi
|
||||
.listSessions()
|
||||
.then((res) => setSessions(res.items))
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 会话恢复:sessionId 来自持久化存储(localStorage),Server Component 无法预知,
|
||||
// 故在客户端对齐一次最新历史。此为持久会话的恢复,非「页面初始数据请求」
|
||||
// (与 useRunStream 同理);fetchedSessionRef 确保同一会话只拉一次。
|
||||
const fetchedSessionRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!sessionId || fetchedSessionRef.current === sessionId) return;
|
||||
fetchedSessionRef.current = sessionId;
|
||||
chatApi
|
||||
.listMessages(sessionId, { page_size: 100 })
|
||||
.then((res) => hydrateMessages(res.items))
|
||||
.catch(() => {});
|
||||
}, [sessionId, hydrateMessages]);
|
||||
|
||||
// 模型默认值:modelHint 未设置时以模型网关默认初始化;用户已持久化的偏好则保留
|
||||
useEffect(() => {
|
||||
if (gateway?.default_model_hint && !modelHint) {
|
||||
setModelHint(gateway.default_model_hint);
|
||||
}
|
||||
}, [gateway, modelHint, setModelHint]);
|
||||
|
||||
// 构造人物名映射(character_id → display_name),供侧栏显示会话归属
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
charactersApi
|
||||
.listCharacters({ page_size: 50 })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
const map: Record<string, string> = {};
|
||||
for (const c of res.items) {
|
||||
map[c.id] = c.display_name || c.name;
|
||||
}
|
||||
setCharacterNameMap(map);
|
||||
})
|
||||
.catch(() => {
|
||||
/* 拉取失败静默 */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 会话列表:不按人物过滤,显示当前用户全部会话。
|
||||
// 恢复策略:当前持久化的会话若有对话内容(title 非空)则保留;否则按 updated_at
|
||||
// 降序选最近的有内容会话;都没有则任意最近会话。关键:跳过 title 为空的空会话
|
||||
// (刚建、无消息),避免落到空会话上看不到历史。
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
chatApi
|
||||
.listSessions()
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setSessions(res.items);
|
||||
const sorted = [...res.items].sort((a, b) =>
|
||||
(b.updated_at ?? "").localeCompare(a.updated_at ?? ""),
|
||||
);
|
||||
const withContent = sorted.filter((s) => s.title);
|
||||
const current = useChatStore.getState().sessionId;
|
||||
const currentEntry = current
|
||||
? res.items.find((s) => s.session_id === current)
|
||||
: undefined;
|
||||
// 当前会话有内容则保留;否则取最近有内容的;都没有则取最近任意会话
|
||||
const target = currentEntry?.title
|
||||
? currentEntry
|
||||
: (withContent[0] ?? sorted[0]);
|
||||
if (target) {
|
||||
if (target.session_id !== current) {
|
||||
switchSession(target.session_id);
|
||||
}
|
||||
} else if (!currentEntry) {
|
||||
// 列表为空且当前会话也已失效:新建
|
||||
void handleCreateSession();
|
||||
}
|
||||
setInitializing(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
if (!useChatStore.getState().sessionId) {
|
||||
void handleCreateSession();
|
||||
}
|
||||
setInitializing(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// handleCreateSession 为组件内函数(每次渲染重建),仅在人物变化时重拉
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialCharacter?.id, setSessions, switchSession]);
|
||||
|
||||
function handleRunEvent(event: RunEvent) {
|
||||
const payload = event.payload ?? {};
|
||||
const runId = event.run_id;
|
||||
|
||||
switch (event.type) {
|
||||
case "plan.created":
|
||||
setActivePlan(planFromCreatedEvent(payload));
|
||||
break;
|
||||
case "plan.step.started":
|
||||
case "plan.step.completed":
|
||||
case "plan.step.failed":
|
||||
setActivePlan((plan) => updatePlanStep(plan, payload));
|
||||
break;
|
||||
case "reasoning.delta":
|
||||
appendReasoningDelta(runId, String(payload.delta ?? ""));
|
||||
break;
|
||||
case "reasoning.completed":
|
||||
finalizeReasoning(runId);
|
||||
break;
|
||||
case "message.delta":
|
||||
appendDelta(runId, String(payload.delta ?? ""));
|
||||
break;
|
||||
case "message.completed":
|
||||
finalizeAssistant(
|
||||
runId,
|
||||
String(payload.message_id ?? `final:${runId}`),
|
||||
typeof payload.content === "string" ? payload.content : undefined,
|
||||
);
|
||||
break;
|
||||
// —— 工具调用事件(后端统一用 completed(status=error) 承载失败,不发 failed)——
|
||||
case "tool.started":
|
||||
beginToolCall(runId, {
|
||||
tool_name: String(payload.tool_name ?? ""),
|
||||
tool_call_id: String(payload.tool_call_id ?? ""),
|
||||
args: typeof payload.args === "string" ? payload.args : "",
|
||||
});
|
||||
break;
|
||||
case "tool.completed":
|
||||
completeToolCall(runId, {
|
||||
tool_name: String(payload.tool_name ?? ""),
|
||||
tool_call_id: String(payload.tool_call_id ?? ""),
|
||||
status: (["success", "error", "denied", "skipped"].includes(
|
||||
payload.status as string,
|
||||
)
|
||||
? payload.status
|
||||
: "error") as "success" | "error" | "denied" | "skipped",
|
||||
result: typeof payload.result === "string" ? payload.result : "",
|
||||
});
|
||||
break;
|
||||
case "tool.requires_confirmation":
|
||||
setActivePlan((plan) =>
|
||||
updatePlanStepForTool(plan, String(payload.tool_name ?? "")),
|
||||
);
|
||||
requireToolConfirmation(runId, {
|
||||
confirmation_id: String(payload.confirmation_id ?? ""),
|
||||
tool_name: String(payload.tool_name ?? ""),
|
||||
tool_call_id: String(payload.tool_call_id ?? ""),
|
||||
risk_level: String(payload.risk_level ?? ""),
|
||||
reason: String(payload.reason ?? ""),
|
||||
args: typeof payload.args === "string" ? payload.args : "",
|
||||
});
|
||||
break;
|
||||
case "run.started":
|
||||
setRunStatus("running");
|
||||
break;
|
||||
case "run.completed":
|
||||
setRunStatus("completed");
|
||||
setSending(false);
|
||||
// 终态后解除订阅:后端会在 run 终态时关闭 SSE,若不清空 activeRunId,
|
||||
// 存活的 EventSource 会据规范自动重连并反复触发 onError 提示。
|
||||
setActiveRunId(null);
|
||||
// 首轮对话后端可能已自动重命名会话,刷新侧栏以显示新标题。
|
||||
chatApi
|
||||
.listSessions()
|
||||
.then((res) => setSessions(res.items))
|
||||
.catch(() => {
|
||||
/* 刷新失败静默,不影响对话主流程 */
|
||||
});
|
||||
break;
|
||||
case "run.failed":
|
||||
finalizeAssistant(runId, `failed:${runId}`);
|
||||
setRunStatus("failed");
|
||||
setSending(false);
|
||||
setError(
|
||||
typeof payload.error_message === "string"
|
||||
? payload.error_message
|
||||
: "运行失败,请稍后重试。",
|
||||
);
|
||||
clearRetrievedMemories();
|
||||
setActiveRunId(null);
|
||||
break;
|
||||
case "memory.retrieved":
|
||||
if (Array.isArray(payload.memories)) {
|
||||
setRetrievedMemories(payload.memories);
|
||||
}
|
||||
break;
|
||||
case "run.cancelled":
|
||||
finalizeAssistant(runId, `cancelled:${runId}`);
|
||||
setRunStatus("cancelled");
|
||||
setSending(false);
|
||||
clearRetrievedMemories();
|
||||
setActiveRunId(null);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateSession() {
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const session = await chatApi.createSession({
|
||||
character_id: initialCharacter?.id,
|
||||
});
|
||||
initSession(session);
|
||||
addSession(session);
|
||||
} catch (err) {
|
||||
setError(describeError(err), traceIdOf(err));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 会话初始化(恢复最近会话 / 新建)由上方 listSessions useEffect 统一承担:
|
||||
// sessionId 无效时优先恢复最近历史会话,仅在无任何历史会话时才新建。
|
||||
|
||||
async function handleSend(content: string) {
|
||||
if (!sessionId) return;
|
||||
// 运行中发送视为 steer:在当前工具批次后注入指引,不新建消息或 Run。
|
||||
if (sending && activeRunId) {
|
||||
try {
|
||||
await runsApi.steerRun(activeRunId, content);
|
||||
} catch (err) {
|
||||
setError(describeError(err), traceIdOf(err));
|
||||
}
|
||||
return;
|
||||
}
|
||||
pushUserMessage(content);
|
||||
setActivePlan(null);
|
||||
setSending(true);
|
||||
setError(null);
|
||||
const idempotencyKey = `${sessionId}:${Date.now()}`;
|
||||
try {
|
||||
const res = await chatApi.sendMessage(
|
||||
sessionId,
|
||||
{
|
||||
content,
|
||||
model_hint: modelHint ?? undefined,
|
||||
thinking: thinkingMode,
|
||||
reasoning_effort: thinkingMode ? thinkingEffort : undefined,
|
||||
web_search: webSearchEnabled,
|
||||
},
|
||||
idempotencyKey,
|
||||
);
|
||||
beginAssistantStream(res.run_id);
|
||||
} catch (err) {
|
||||
setSending(false);
|
||||
setRunStatus("failed");
|
||||
setError(describeError(err), traceIdOf(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
if (!activeRunId) return;
|
||||
const runId = activeRunId;
|
||||
setStopping(true);
|
||||
try {
|
||||
await runsApi.cancelRun(runId);
|
||||
} catch {
|
||||
/* 静默:即便取消请求失败也本地中止,定格已生成内容 */
|
||||
}
|
||||
finalizeAssistant(runId, `stopped:${runId}`);
|
||||
setRunStatus("cancelled");
|
||||
setSending(false);
|
||||
setActiveRunId(null);
|
||||
setStopping(false);
|
||||
}
|
||||
|
||||
async function handleFollowUp(content: string) {
|
||||
if (!activeRunId) return;
|
||||
// follow-up:当前任务完成后排队追加新任务(区别于 steer 修正当前方向)。
|
||||
try {
|
||||
await runsApi.followUpRun(activeRunId, content);
|
||||
} catch (err) {
|
||||
setError(describeError(err), traceIdOf(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSessionSelect(
|
||||
sessionId: string,
|
||||
sessionCharacterId: string,
|
||||
) {
|
||||
const currentId = useChatStore.getState().sessionId;
|
||||
if (sessionId === currentId) return;
|
||||
// 切换 session 时同步激活该会话所属的人物,使后端操作不受 character_id 校验阻挡
|
||||
if (sessionCharacterId && sessionCharacterId !== initialCharacter?.id) {
|
||||
try {
|
||||
await charactersApi.activateCharacter(sessionCharacterId);
|
||||
} catch {
|
||||
/* 激活失败不阻塞切换 */
|
||||
}
|
||||
}
|
||||
markSessionSeen(sessionId);
|
||||
switchSession(sessionId, sessionCharacterId);
|
||||
}
|
||||
|
||||
async function handleRegenerate() {
|
||||
if (!sessionId || sending) return;
|
||||
const msgs = useChatStore.getState().messages;
|
||||
let lastUserIdx = -1;
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
if (msgs[i].role === "user") {
|
||||
lastUserIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastUserIdx === -1) return;
|
||||
// 保留旧 assistant 的版本历史,迁入新消息:重答不是丢弃旧版本,而是叠加新版
|
||||
const oldAssistant = msgs
|
||||
.slice(lastUserIdx + 1)
|
||||
.find((m) => m.role === "assistant" && !m.streaming);
|
||||
let priorVersions: MessageVersion[] | undefined;
|
||||
if (oldAssistant) {
|
||||
const prev = oldAssistant.versions ?? [];
|
||||
priorVersions =
|
||||
prev.length > 0
|
||||
? prev
|
||||
: oldAssistant.content
|
||||
? [
|
||||
{
|
||||
content: oldAssistant.content,
|
||||
created_at:
|
||||
oldAssistant.created_at ?? new Date().toISOString(),
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
}
|
||||
const content = msgs[lastUserIdx].content;
|
||||
truncateMessagesAfter(lastUserIdx);
|
||||
setSending(true);
|
||||
setError(null);
|
||||
const idempotencyKey = `${sessionId}:regen:${Date.now()}`;
|
||||
try {
|
||||
const res = await chatApi.sendMessage(
|
||||
sessionId,
|
||||
{
|
||||
content,
|
||||
model_hint: modelHint ?? undefined,
|
||||
thinking: thinkingMode,
|
||||
reasoning_effort: thinkingMode ? thinkingEffort : undefined,
|
||||
web_search: webSearchEnabled,
|
||||
prior_versions: priorVersions,
|
||||
},
|
||||
idempotencyKey,
|
||||
);
|
||||
beginAssistantStream(res.run_id, { priorVersions });
|
||||
} catch (err) {
|
||||
setSending(false);
|
||||
setRunStatus("failed");
|
||||
setError(describeError(err), traceIdOf(err));
|
||||
}
|
||||
}
|
||||
|
||||
/** 确认卡片回调:批准高风险工具调用(run-scoped)。成功后状态回 running 等后续 tool.completed */
|
||||
async function handleApproveToolCall(
|
||||
runId: string,
|
||||
confirmationId: string,
|
||||
toolCallId: string,
|
||||
) {
|
||||
try {
|
||||
await runsApi.approveRunConfirmation(runId, confirmationId);
|
||||
markToolCallResolving(runId, toolCallId);
|
||||
} catch (err) {
|
||||
// 410 confirmation_expired / 404 not_found / 503 unavailable 等统一经 describeError
|
||||
setError(describeError(err), traceIdOf(err));
|
||||
}
|
||||
}
|
||||
|
||||
/** 确认卡片回调:拒绝高风险工具调用(run-scoped)。本地不动 status,等 tool.completed(status=denied) */
|
||||
async function handleDenyToolCall(
|
||||
runId: string,
|
||||
confirmationId: string,
|
||||
toolCallId: string,
|
||||
) {
|
||||
try {
|
||||
await runsApi.denyRunConfirmation(runId, confirmationId);
|
||||
markToolCallResolving(runId, toolCallId);
|
||||
} catch (err) {
|
||||
setError(describeError(err), traceIdOf(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<section
|
||||
id="chat-region"
|
||||
aria-label="镜面对话"
|
||||
className="relative flex min-h-0 w-full flex-1 overflow-hidden border-rule bg-ink"
|
||||
>
|
||||
{/* 镜背轨:铭文往来 / 此刻 两面照见,tab 切换 */}
|
||||
{!sidebarCollapsed && (
|
||||
<div className="relative hidden shrink-0 md:flex">
|
||||
<aside className="min-h-0 w-70 flex flex-col overflow-hidden border-r border-rule bg-sidebar">
|
||||
<RailTabs view={railView} onChange={setRailView} />
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{railView === "now" ? (
|
||||
sessionId ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<ChatContextRail embedded character={initialCharacter} />
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<SessionSidebar
|
||||
embedded
|
||||
onCreate={handleCreateSession}
|
||||
creating={creating}
|
||||
characterNameMap={characterNameMap}
|
||||
onSessionSelect={handleSessionSelect}
|
||||
newSessionIds={newSessionIds}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 镜面:人物、对话流与铭文输入框 */}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-[linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0.006)_28%,transparent)]">
|
||||
<ActiveCharacterHeader
|
||||
character={initialCharacter}
|
||||
models={gateway?.models}
|
||||
railCollapsed={sidebarCollapsed}
|
||||
onToggleRail={toggleSidebar}
|
||||
/>
|
||||
|
||||
<div className="flex shrink-0 items-center border-b border-rule px-3 py-1.5 md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileRailOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-[11px] text-fg-faint transition-colors hover:bg-fg/5 hover:text-fg"
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
镜背 · 往来
|
||||
{newSessionIds.length > 0 && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-cinnabar" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-cinnabar-dim bg-cinnabar/10 px-4 py-1.5 text-xs text-cinnabar backdrop-blur-sm">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">{error}</span>
|
||||
{errorTraceId && <ErrorTraceLink traceId={errorTraceId} />}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setError(null)}
|
||||
className="shrink-0 underline"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{!sessionId ? (
|
||||
creating || initializing ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6">
|
||||
<TextSkeleton lines={2} className="w-64" />
|
||||
</div>
|
||||
) : (
|
||||
<MirrorHero character={initialCharacter} />
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<MessageList
|
||||
onApproveToolCall={handleApproveToolCall}
|
||||
onDenyToolCall={handleDenyToolCall}
|
||||
onRegenerate={handleRegenerate}
|
||||
characterName={
|
||||
initialCharacter?.display_name || initialCharacter?.name
|
||||
}
|
||||
/>
|
||||
{activePlan && <PlanPanel plan={activePlan} />}
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onFollowUp={handleFollowUp}
|
||||
onStop={handleStop}
|
||||
stopping={stopping}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧文件预览面板:独立于聊天区 */}
|
||||
{activePreviewIndex >= 0 && <FilePreviewPanel />}
|
||||
|
||||
{/* 移动端镜背抽屉:限制在对话区域内,不覆盖全局导航 */}
|
||||
{mobileRailOpen && (
|
||||
<div className="absolute inset-0 z-50 overflow-hidden md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭镜背"
|
||||
onClick={() => setMobileRailOpen(false)}
|
||||
className="absolute inset-0 cursor-default bg-black/50 backdrop-blur-sm"
|
||||
/>
|
||||
<aside className="absolute inset-y-0 left-0 flex w-[min(84vw,320px)] min-h-0 flex-col overflow-hidden border-r border-rule bg-sidebar shadow-2xl">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-rule px-4 py-3">
|
||||
<span className="font-serif text-sm text-fg-dim">镜背</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileRailOpen(false)}
|
||||
className="rounded border border-rule px-2 py-1 text-[10px] text-fg-faint hover:text-fg"
|
||||
>
|
||||
合镜
|
||||
</button>
|
||||
</div>
|
||||
<RailTabs view={railView} onChange={setRailView} />
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{railView === "now" ? (
|
||||
sessionId ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<ChatContextRail embedded character={initialCharacter} />
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<SessionSidebar
|
||||
embedded
|
||||
onCreate={handleCreateSession}
|
||||
creating={creating}
|
||||
characterNameMap={characterNameMap}
|
||||
onSessionSelect={(selectedSessionId, characterId) => {
|
||||
setMobileRailOpen(false);
|
||||
void handleSessionSelect(selectedSessionId, characterId);
|
||||
}}
|
||||
newSessionIds={newSessionIds}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function planFromCreatedEvent(payload: Record<string, unknown>): Plan {
|
||||
const steps = Array.isArray(payload.steps)
|
||||
? payload.steps.flatMap((value) => {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value.id !== "string" ||
|
||||
typeof value.title !== "string"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: value.id,
|
||||
title: value.title,
|
||||
description: stringValue(value.description) ?? "",
|
||||
tool: stringValue(value.tool),
|
||||
status: planStepStatus(value.status),
|
||||
risk_level: riskLevel(value.risk_level),
|
||||
},
|
||||
];
|
||||
})
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: stringValue(payload.task_id) ?? "active-plan",
|
||||
goal_id: stringValue(payload.task_id) ?? "active-plan",
|
||||
steps,
|
||||
assumptions: stringItems(payload.assumptions),
|
||||
risks: stringItems(payload.risks),
|
||||
required_tools: steps.flatMap((step) => (step.tool ? [step.tool] : [])),
|
||||
success_criteria: [],
|
||||
status: "running",
|
||||
};
|
||||
}
|
||||
|
||||
function updatePlanStep(
|
||||
plan: Plan | null,
|
||||
payload: Record<string, unknown>,
|
||||
): Plan | null {
|
||||
const stepId = stringValue(payload.step_id);
|
||||
if (!plan || !stepId) return plan;
|
||||
return {
|
||||
...plan,
|
||||
steps: plan.steps.map((step) =>
|
||||
step.id === stepId
|
||||
? {
|
||||
...step,
|
||||
status: planStepStatus(payload.status),
|
||||
result_summary: stringValue(payload.result_summary),
|
||||
}
|
||||
: step,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function updatePlanStepForTool(
|
||||
plan: Plan | null,
|
||||
toolName: string,
|
||||
): Plan | null {
|
||||
if (!plan || !toolName) return plan;
|
||||
const step = plan.steps.find(
|
||||
(item) => item.tool === toolName && item.status !== "completed",
|
||||
);
|
||||
if (!step) return plan;
|
||||
return {
|
||||
...plan,
|
||||
steps: plan.steps.map((item) =>
|
||||
item.id === step.id ? { ...item, status: "waiting_confirmation" } : item,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function stringItems(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function planStepStatus(value: unknown): Plan["steps"][number]["status"] {
|
||||
return value === "running" ||
|
||||
value === "waiting_confirmation" ||
|
||||
value === "completed" ||
|
||||
value === "failed" ||
|
||||
value === "skipped"
|
||||
? value
|
||||
: "pending";
|
||||
}
|
||||
|
||||
function riskLevel(value: unknown): Plan["steps"][number]["risk_level"] {
|
||||
return value === "L1" || value === "L2" || value === "L3" || value === "L4"
|
||||
? value
|
||||
: "L0";
|
||||
}
|
||||
|
||||
function ChevronRightIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M6 3l5 5-5 5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 镜背轨双视图切换:铭文往来(会话历史)/ 此刻(人物状态·六爻·息壤)。
|
||||
* 设计稿 .rail-tab 同源——mono 字标、active 鎏金、底部短下划线。 */
|
||||
function RailTabs({
|
||||
view,
|
||||
onChange,
|
||||
}: {
|
||||
view: "sessions" | "now";
|
||||
onChange: (view: "sessions" | "now") => void;
|
||||
}) {
|
||||
const tabs: { key: "sessions" | "now"; label: string }[] = [
|
||||
{ key: "sessions", label: "铭文往来" },
|
||||
{ key: "now", label: "此刻" },
|
||||
];
|
||||
return (
|
||||
<div className="flex shrink-0 border-b border-rule">
|
||||
{tabs.map((tab) => {
|
||||
const active = view === tab.key;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.key)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"mono relative flex-1 py-2.5 text-[11px] tracking-[0.08em] transition-colors",
|
||||
active ? "text-gilt" : "text-fg-faint hover:text-fg-dim",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{active && (
|
||||
<span className="absolute inset-x-[24%] -bottom-px h-[1.5px] bg-gilt" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,364 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { readSandboxFile } from "@/lib/api/sandbox";
|
||||
import { useChatStore } from "@/lib/stores/chat-store";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import { MarkdownContent } from "@/components/chat/MarkdownContent";
|
||||
|
||||
type ViewMode = "source" | "preview";
|
||||
|
||||
const MIN_PANEL_WIDTH = 280;
|
||||
const MAX_PANEL_FRACTION = 0.6;
|
||||
|
||||
/**
|
||||
* 右侧独立文件预览面板,支持多文件标签页切换。
|
||||
*/
|
||||
export function FilePreviewPanel() {
|
||||
const previewFiles = useChatStore((s) => s.previewFiles);
|
||||
const activeIndex = useChatStore((s) => s.activePreviewIndex);
|
||||
const switchFile = useChatStore((s) => s.switchPreviewFile);
|
||||
const closePanel = useChatStore((s) => s.closePreviewPanel);
|
||||
const characterId = useChatStore((s) => s.characterId);
|
||||
|
||||
const file = previewFiles[activeIndex] ?? null;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const [mimeType, setMimeType] = useState("");
|
||||
const [sourceText, setSourceText] = useState("");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("preview");
|
||||
const objectUrlRef = useRef<string | null>(null);
|
||||
|
||||
// 拖拽:使用 ref + 直接 DOM 操作避免 re-render 风暴;仅在 mouseup 时 commit 最终宽度
|
||||
const [panelWidth, setPanelWidth] = useState(420);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const dragStartXRef = useRef(0);
|
||||
const dragStartWidthRef = useRef(0);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragStartXRef.current = e.clientX;
|
||||
dragStartWidthRef.current = panelWidth;
|
||||
setIsDragging(true);
|
||||
},
|
||||
[panelWidth],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const el = panelRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const delta = dragStartXRef.current - e.clientX;
|
||||
const newWidth = dragStartWidthRef.current + delta;
|
||||
const clamped = Math.max(
|
||||
MIN_PANEL_WIDTH,
|
||||
Math.min(newWidth, window.innerWidth * MAX_PANEL_FRACTION),
|
||||
);
|
||||
// 直接操作 DOM 跳过 React 渲染周期
|
||||
el.style.width = `${clamped}px`;
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
// 在 mouseup 时才将最终宽度同步到 state(供下次拖拽起始宽度使用)
|
||||
if (el) {
|
||||
const finalWidth = parseFloat(el.style.width) || panelWidth;
|
||||
setPanelWidth(finalWidth);
|
||||
}
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.userSelect = "none";
|
||||
document.body.style.cursor = "col-resize";
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
}, [isDragging, panelWidth]);
|
||||
|
||||
// 当前文件详情
|
||||
const displayName = file?.fileName || file?.path.split("/").pop() || "";
|
||||
const ext = file?.path.split(".").pop()?.toLowerCase() ?? "";
|
||||
|
||||
const canRender =
|
||||
mimeType === "text/html" ||
|
||||
mimeType.startsWith("image/") ||
|
||||
mimeType === "application/pdf" ||
|
||||
mimeType === "text/markdown" ||
|
||||
ext === "svg" ||
|
||||
ext === "md" ||
|
||||
ext === "markdown";
|
||||
|
||||
// 切换文件时重新加载
|
||||
useEffect(() => {
|
||||
if (!file) return;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setMimeType("");
|
||||
setSourceText("");
|
||||
setViewMode("preview");
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
}
|
||||
setObjectUrl(null);
|
||||
|
||||
const blob = await readSandboxFile(file.path, {
|
||||
runId: file.runId,
|
||||
characterId: characterId ?? undefined,
|
||||
isolated: file.isolated ?? true,
|
||||
});
|
||||
if (cancelled) return;
|
||||
const mt = blob.type || "text/plain";
|
||||
setMimeType(mt);
|
||||
const url = URL.createObjectURL(blob);
|
||||
objectUrlRef.current = url;
|
||||
setObjectUrl(url);
|
||||
|
||||
if (mt.startsWith("text/") || !mt || ext === "svg") {
|
||||
const text = await blob.text();
|
||||
if (!cancelled) setSourceText(text);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "读取文件失败");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [file, characterId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 卸载清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!file) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex shrink-0 will-change-[width]"
|
||||
ref={panelRef}
|
||||
style={{ width: panelWidth }}
|
||||
>
|
||||
{/* 拖拽手柄 */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 top-0 z-10 h-full w-1 cursor-col-resize transition-colors",
|
||||
isDragging ? "bg-patina/50" : "hover:bg-patina/30",
|
||||
)}
|
||||
onMouseDown={handleDragStart}
|
||||
/>
|
||||
|
||||
<aside className="flex min-h-0 flex-1 flex-col overflow-hidden border-l border-rule bg-night-bronze/95 backdrop-blur-sm">
|
||||
{/* —— 标签栏:文件列表 + 关闭 —— */}
|
||||
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-rule px-3 py-2 has-[button:only-of-type]:py-1.5">
|
||||
{previewFiles.map((f, i) => {
|
||||
const tabExt = f.path.split(".").pop()?.toLowerCase() ?? "";
|
||||
const tabName = f.fileName || f.path.split("/").pop() || f.path;
|
||||
return (
|
||||
<button
|
||||
key={`${f.runId ?? ""}:${f.path}`}
|
||||
type="button"
|
||||
onClick={() => switchFile(i)}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1 rounded px-2 py-1 text-[11px] font-mono transition-colors",
|
||||
i === activeIndex
|
||||
? "bg-patina/15 text-patina"
|
||||
: "text-muted hover:bg-fg/5 hover:text-fg",
|
||||
)}
|
||||
>
|
||||
<FileIcon ext={tabExt} />
|
||||
<span className="max-w-[120px] truncate">{tabName}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="ml-auto flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closePanel}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted transition-colors hover:bg-fg/5 hover:text-fg"
|
||||
aria-label="关闭预览"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M4 4l8 8M12 4l-8 8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* —— 模式切换(当前激活文件) —— */}
|
||||
{canRender && !loading && objectUrl && (
|
||||
<div className="flex shrink-0 items-center justify-end gap-0.5 border-b border-rule px-4 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("source")}
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-[10px] transition-colors",
|
||||
viewMode === "source"
|
||||
? "bg-patina text-white"
|
||||
: "text-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
{"<"}源码{">"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("preview")}
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-[10px] transition-colors",
|
||||
viewMode === "preview"
|
||||
? "bg-patina text-white"
|
||||
: "text-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
◉ 预览
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* —— 内容区 —— */}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-xs text-muted/60">
|
||||
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-muted/30 border-t-patina" />
|
||||
加载中…
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
|
||||
<div className="text-center text-xs text-cinnabar">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "source" && !loading && sourceText && (
|
||||
<pre className="min-h-0 flex-1 overflow-auto p-4 font-mono text-[12px] leading-relaxed text-fg/80">
|
||||
{sourceText}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{viewMode === "preview" && !loading && objectUrl && (
|
||||
<>
|
||||
{mimeType === "text/html" ? (
|
||||
<iframe
|
||||
src={objectUrl}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals"
|
||||
className="min-h-0 flex-1 w-full border-0"
|
||||
title={displayName}
|
||||
/>
|
||||
) : mimeType.startsWith("image/") || ext === "svg" ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center bg-fg/[0.02] p-6">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={objectUrl}
|
||||
alt={displayName}
|
||||
className="max-h-full max-w-full rounded object-contain shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
) : mimeType === "application/pdf" ? (
|
||||
<iframe
|
||||
src={objectUrl}
|
||||
className="min-h-0 flex-1 w-full border-0"
|
||||
title={displayName}
|
||||
/>
|
||||
) : ext === "md" ||
|
||||
ext === "markdown" ||
|
||||
mimeType === "text/markdown" ? (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<MarkdownContent content={sourceText} />
|
||||
</div>
|
||||
) : (
|
||||
<pre className="min-h-0 flex-1 overflow-auto p-4 font-mono text-[12px] leading-relaxed text-fg/80">
|
||||
{sourceText || "(不支持预览此文件类型)"}
|
||||
</pre>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && !error && !objectUrl && !sourceText && (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center">
|
||||
<span className="text-xs text-muted/50">(空内容)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileIcon({ ext }: { ext: string }) {
|
||||
const icon = EXT_ICONS[ext] ?? EXT_ICONS._default;
|
||||
return (
|
||||
<span className="shrink-0" aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const EXT_ICONS: Record<string, string> = {
|
||||
html: "🌐",
|
||||
htm: "🌐",
|
||||
svg: "🎨",
|
||||
png: "🖼",
|
||||
jpg: "🖼",
|
||||
jpeg: "🖼",
|
||||
gif: "🖼",
|
||||
webp: "🖼",
|
||||
pdf: "📄",
|
||||
json: "📋",
|
||||
yaml: "📋",
|
||||
yml: "📋",
|
||||
toml: "📋",
|
||||
md: "📝",
|
||||
markdown: "📝",
|
||||
py: "🐍",
|
||||
js: "📜",
|
||||
ts: "📘",
|
||||
tsx: "⚛️",
|
||||
jsx: "⚛️",
|
||||
css: "🎨",
|
||||
scss: "🎨",
|
||||
rs: "🦀",
|
||||
go: "🔷",
|
||||
sh: "💻",
|
||||
bash: "💻",
|
||||
txt: "📄",
|
||||
csv: "📊",
|
||||
xml: "📋",
|
||||
_default: "📄",
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MarkdownContent } from "./MarkdownContent";
|
||||
|
||||
describe("MarkdownContent", () => {
|
||||
it("渲染纯文本", () => {
|
||||
render(<MarkdownContent content="Hello" />);
|
||||
expect(screen.getByText("Hello")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("渲染 Markdown 加粗", () => {
|
||||
render(<MarkdownContent content="**加粗**" />);
|
||||
expect(screen.getByText("加粗")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("渲染链接", () => {
|
||||
render(<MarkdownContent content="[链接](https://example.com)" />);
|
||||
const link = screen.getByRole("link", { name: "链接" });
|
||||
expect(link).toHaveAttribute("href", "https://example.com");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
it("渲染行内代码", () => {
|
||||
render(<MarkdownContent content="`code`" />);
|
||||
expect(screen.getByText("code")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("渲染列表项", () => {
|
||||
render(<MarkdownContent content={"- 项1\n- 项2"} />);
|
||||
expect(screen.getByText("项1")).toBeInTheDocument();
|
||||
expect(screen.getByText("项2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,268 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
isValidElement,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import "katex/dist/katex.min.css";
|
||||
|
||||
import { MermaidBlock } from "./MermaidBlock";
|
||||
|
||||
/**
|
||||
* 消息 Markdown 渲染(外层 .md-body 承载 globals.css 的排版与 hljs 配色):
|
||||
* - 代码块:固定深色「代码铭版」+ 鎏金描边/标签 + 一键复制(亮 / 暗主题一致)。
|
||||
* - Mermaid 块(```mermaid):渲染为图表,见 MermaidBlock。
|
||||
* - 数学公式:remark-math + rehype-katex,行内 $…$ / 块级 $$…$$,色随 --fg。
|
||||
* - 行内代码:铜金等宽标签。
|
||||
* - 图片:点击进入 Lightbox 放大查看。
|
||||
* - 链接:新标签打开。
|
||||
* - 不渲染原始 HTML(react-markdown 默认安全,防 XSS)。
|
||||
*/
|
||||
export function MarkdownContent({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="md-body">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath, remarkInlineMathLimits]}
|
||||
rehypePlugins={[
|
||||
rehypeKatex,
|
||||
[rehypeHighlight, { detect: true, ignoreMissing: true }],
|
||||
]}
|
||||
components={{ pre: PreBlock, code: CodeBlock, a: Anchor, img: Img }}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** pre 组件:mermaid 走图表渲染,其余为代码铭版 */
|
||||
function PreBlock({ children }: ComponentPropsWithoutRef<"pre">) {
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const lang = extractLang(children);
|
||||
|
||||
if (lang === "mermaid") {
|
||||
return <MermaidBlock code={extractText(children)} />;
|
||||
}
|
||||
|
||||
const copy = async () => {
|
||||
const text = preRef.current?.textContent ?? "";
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* 剪贴板不可用时静默 */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="my-2.5 overflow-hidden rounded-xl border border-[var(--code-edge)] bg-[var(--code-ink)] backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between border-b border-[var(--code-rule)] px-3 py-1.5">
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--code-label)]">
|
||||
{lang || "code"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="font-mono text-[10px] text-[var(--code-label-dim)] transition-colors hover:text-[var(--code-label-hi)]"
|
||||
>
|
||||
{copied ? "已复制" : "复制"}
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
ref={preRef}
|
||||
className="overflow-x-auto p-3 font-mono text-xs leading-relaxed"
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 行内代码 vs 块代码:块代码由 rehype-highlight 加 hljs/language- class */
|
||||
function CodeBlock({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<"code">) {
|
||||
const isBlock = /hljs|language-/.test(className ?? "");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code
|
||||
className="rounded bg-fg/10 px-1.5 py-0.5 font-mono text-[0.85em] text-gold"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
function Anchor({ href, children, ...props }: ComponentPropsWithoutRef<"a">) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-patina underline underline-offset-2"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** 图片:内联时点击进入 Lightbox 放大 */
|
||||
function Img({ src, alt, ...props }: ComponentPropsWithoutRef<"img">) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// src 在 React 19 类型里可能为 Blob;此处仅处理 string URL(markdown 图片均为 URL)。
|
||||
const srcStr = typeof src === "string" ? src : "";
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="my-2 block max-w-full cursor-zoom-in rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-patina"
|
||||
aria-label={`放大查看:${alt || "图片"}`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={srcStr}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
className="max-h-[60vh] w-auto max-w-full rounded-md border border-rule"
|
||||
{...props}
|
||||
/>
|
||||
</button>
|
||||
{open && srcStr && (
|
||||
<ImageLightbox
|
||||
src={srcStr}
|
||||
alt={alt ?? ""}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片放大层:点背景 / Esc 关闭,打开期间锁 body 滚动。
|
||||
* 经 createPortal 挂到 document.body,避免被气泡 overflow 截断。
|
||||
*/
|
||||
function ImageLightbox({
|
||||
src,
|
||||
alt,
|
||||
onClose,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`图片预览:${alt || "图片"}`}
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/80 p-6 backdrop-blur-md"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
||||
initial={{ scale: 0.96 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0.96 }}
|
||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 行内公式 limits 修正:\lim / \sum / \prod 等「可叠加」算子在行内 \textstyle 下,
|
||||
* KaTeX 默认把上下标放在符号右侧(数学排版规范)。阅读时更期望下标落于正下方,
|
||||
* 故仅对这类算子显式注入 \limits;已写 \limits / \nolimits 的不重复处理。
|
||||
* 块级 $$…$$ 本就用 display style;\int 类积分依惯例保持上下标在侧,均不在此处理。
|
||||
*/
|
||||
const LIMITS_RE =
|
||||
/\\(limsup|liminf|lim|sum|prod|coprod|bigcup|bigcap|bigsqcup|bigvee|bigwedge|bigotimes|bigoplus|bigodot|biguplus)(?![a-zA-Z])(?!\\limits)(?!\\nolimits)/g;
|
||||
|
||||
function remarkInlineMathLimits() {
|
||||
return (tree: unknown) => {
|
||||
const walk = (node: unknown): void => {
|
||||
if (!node || typeof node !== "object") return;
|
||||
const n = node as {
|
||||
type?: string;
|
||||
value?: unknown;
|
||||
children?: unknown[];
|
||||
};
|
||||
if (n.type === "inlineMath" && typeof n.value === "string") {
|
||||
n.value = n.value.replace(LIMITS_RE, "\\$1\\limits");
|
||||
}
|
||||
if (Array.isArray(n.children)) n.children.forEach(walk);
|
||||
};
|
||||
walk(tree);
|
||||
};
|
||||
}
|
||||
|
||||
/** 从 pre 的子 code 元素提取语言标识 */
|
||||
function extractLang(children: ReactNode): string {
|
||||
const child = Array.isArray(children) ? children[0] : children;
|
||||
if (isValidElement(child)) {
|
||||
const cls = (child.props as { className?: string }).className ?? "";
|
||||
const m = /language-([\w-]+)/.exec(cls);
|
||||
if (m) return m[1];
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 从 pre 的子 code 元素提取源码文本(供 mermaid 渲染) */
|
||||
function extractText(children: ReactNode): string {
|
||||
const child = Array.isArray(children) ? children[0] : children;
|
||||
if (isValidElement(child)) {
|
||||
const inner = (child.props as { children?: ReactNode }).children;
|
||||
if (typeof inner === "string") return inner;
|
||||
if (Array.isArray(inner)) {
|
||||
return inner.map((n) => (typeof n === "string" ? n : "")).join("");
|
||||
}
|
||||
}
|
||||
return typeof child === "string" ? child : "";
|
||||
}
|
||||
@@ -1,423 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import { describeError } from "@/lib/utils/error";
|
||||
import { resolveTheme, useSettingsStore } from "@/lib/stores/settings-store";
|
||||
|
||||
/**
|
||||
* Mermaid 图表渲染。
|
||||
* - 动态 import mermaid,避免首屏加载(仅出现图表时才拉取)。
|
||||
* - 防抖 300ms,避免流式逐字时频繁重渲染。
|
||||
* - 渲染失败时显示错误并支持查看源码,便于调试。
|
||||
* - 图表源码来自模型输出,securityLevel 设为 loose 以支持完整渲染。
|
||||
* - 内联图表可点击放大,进入 MermaidZoom 全屏交互视图。
|
||||
*/
|
||||
export function MermaidBlock({ code }: { code: string }) {
|
||||
const [svg, setSvg] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
const [zoomOpen, setZoomOpen] = useState(false);
|
||||
|
||||
// 图表配色跟随应用主题:亮色用 default + 琉璃镜矿物色(背景透明让镜面气泡透出),
|
||||
// 暗色沿用内置 dark(已为深底调好,不再覆盖 themeVariables)。
|
||||
const theme = useSettingsStore((s) => s.theme);
|
||||
const isDark = resolveTheme(theme) === "dark";
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const { default: mermaid } = await import("mermaid");
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: isDark ? "dark" : "default",
|
||||
securityLevel: "loose",
|
||||
fontFamily: "inherit",
|
||||
themeVariables: isDark
|
||||
? {}
|
||||
: {
|
||||
background: "transparent",
|
||||
primaryColor: "#f4f1e8",
|
||||
primaryTextColor: "#1c2420",
|
||||
primaryBorderColor: "#8a6d28",
|
||||
lineColor: "#3f7d68",
|
||||
secondaryColor: "#ece8dc",
|
||||
tertiaryColor: "#e3dfd0",
|
||||
},
|
||||
});
|
||||
const id = `mmd-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const result = await mermaid.render(id, code);
|
||||
if (!cancelled) {
|
||||
setSvg(result.svg);
|
||||
setError(null);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(describeError(e));
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [code, isDark]);
|
||||
|
||||
return (
|
||||
<div className="glass my-2.5 overflow-hidden rounded-xl">
|
||||
<div className="flex items-center justify-between border-b border-border-glass px-3 py-1.5">
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-muted">
|
||||
mermaid
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomOpen(true)}
|
||||
disabled={!svg}
|
||||
className="font-mono text-[10px] text-muted transition-colors hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
放大
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSource((s) => !s)}
|
||||
className="font-mono text-[10px] text-muted transition-colors hover:text-fg"
|
||||
>
|
||||
{showSource ? "隐藏源码" : "查看源码"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
{error ? (
|
||||
<p className="text-xs text-cinnabar">图表渲染失败:{error}</p>
|
||||
) : svg ? (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setZoomOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setZoomOpen(true);
|
||||
}
|
||||
}}
|
||||
title="点击放大查看"
|
||||
aria-label="放大查看 mermaid 图表"
|
||||
className="cursor-zoom-in overflow-x-auto transition-opacity hover:opacity-90 [&_svg]:h-auto [&_svg]:max-w-full [&_svg]:bg-transparent [&_svg]:transition-transform"
|
||||
>
|
||||
<div dangerouslySetInnerHTML={{ __html: svg }} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted">渲染中…</p>
|
||||
)}
|
||||
|
||||
{showSource && (
|
||||
<pre className="mt-2 overflow-x-auto rounded-lg bg-fg/5 p-2 font-mono text-xs text-fg/80">
|
||||
{code}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{zoomOpen && svg && (
|
||||
<MermaidZoom svg={svg} code={code} onClose={() => setZoomOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MIN_SCALE = 0.25;
|
||||
const MAX_SCALE = 30;
|
||||
const ZOOM_STEP = 1.2;
|
||||
/** 滚轮指数缩放灵敏度:factor = 2^(-deltaY / WHEEL_DIVISOR),值越大越平缓 */
|
||||
const WHEEL_DIVISOR = 300;
|
||||
/** 打开放大视图的初始倍率(也作为「重置」的目标) */
|
||||
const INITIAL_SCALE = 5;
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) =>
|
||||
Math.min(hi, Math.max(lo, v));
|
||||
|
||||
interface View {
|
||||
scale: number;
|
||||
tx: number;
|
||||
ty: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mermaid 全屏放大视图。
|
||||
* - 滚轮以光标为锚点缩放(0.25× ~ 30×),拖拽平移,双击重置。
|
||||
* - ESC / 点背景关闭,打开期间锁定 body 滚动。
|
||||
* - 通过 createPortal 挂到 document.body,避免被祖先 overflow / transform 截断。
|
||||
*/
|
||||
function MermaidZoom({
|
||||
svg,
|
||||
code,
|
||||
onClose,
|
||||
}: {
|
||||
svg: string;
|
||||
code: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const svgWrapRef = useRef<HTMLDivElement>(null);
|
||||
// svg 原始布局尺寸(scale=1 基准),mount 后测一次即可
|
||||
const svgSize = useRef({ W: 0, H: 0 });
|
||||
const [view, setView] = useState<View>({
|
||||
scale: INITIAL_SCALE,
|
||||
tx: 0,
|
||||
ty: 0,
|
||||
});
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const drag = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
// ESC 关闭 + 锁 body 滚动
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
// svg 基准尺寸首测:mount 后 svg 已按 INITIAL_SCALE 渲染,
|
||||
// getBoundingClientRect 受 transform 影响,故除回 INITIAL_SCALE 还原 scale=1 基准。
|
||||
useEffect(() => {
|
||||
const svgEl = svgWrapRef.current?.querySelector("svg");
|
||||
if (!svgEl) return;
|
||||
const rect = svgEl.getBoundingClientRect();
|
||||
svgSize.current = {
|
||||
W: rect.width / INITIAL_SCALE,
|
||||
H: rect.height / INITIAL_SCALE,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 画布视口与 svg 基准尺寸
|
||||
const getBounds = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
cw: rect.width,
|
||||
ch: rect.height,
|
||||
W: svgSize.current.W,
|
||||
H: svgSize.current.H,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 统一缩放:给定 anchor 时以光标为锚点(pin-to-cursor),否则围绕图片中心;
|
||||
// 随后 clamp 平移量 —— 图片比视口大时必须覆盖视口、比视口小时强制居中,杜绝整体漂出。
|
||||
const applyZoom = useCallback(
|
||||
(factor: number, anchor?: { mx: number; my: number }) => {
|
||||
const b = getBounds();
|
||||
setView((v) => {
|
||||
const ns = clamp(v.scale * factor, MIN_SCALE, MAX_SCALE);
|
||||
if (ns === v.scale) return v;
|
||||
let ntx = v.tx;
|
||||
let nty = v.ty;
|
||||
if (anchor) {
|
||||
const ix = (anchor.mx - v.tx) / v.scale;
|
||||
const iy = (anchor.my - v.ty) / v.scale;
|
||||
ntx = anchor.mx - ix * ns;
|
||||
nty = anchor.my - iy * ns;
|
||||
}
|
||||
if (b) {
|
||||
const maxTx = Math.max(0, (b.W * ns - b.cw) / 2);
|
||||
const maxTy = Math.max(0, (b.H * ns - b.ch) / 2);
|
||||
ntx = clamp(ntx, -maxTx, maxTx);
|
||||
nty = clamp(nty, -maxTy, maxTy);
|
||||
}
|
||||
return { scale: ns, tx: ntx, ty: nty };
|
||||
});
|
||||
},
|
||||
[getBounds],
|
||||
);
|
||||
|
||||
// 滚轮缩放:以光标为锚点放大(鼠标下的节点保持不动),由 applyZoom 负责防漂移
|
||||
const onWheel = useCallback(
|
||||
(e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
const anchor = rect
|
||||
? {
|
||||
mx: e.clientX - rect.left - rect.width / 2,
|
||||
my: e.clientY - rect.top - rect.height / 2,
|
||||
}
|
||||
: undefined;
|
||||
// 指数缩放:factor 随 deltaY 变化 —— 触控板轻划(小 delta)平滑微调,
|
||||
// 鼠标一格(deltaY≈±100)约 ±1.26×,避免轻划一下就跳到极值
|
||||
applyZoom(2 ** (-e.deltaY / WHEEL_DIVISOR), anchor);
|
||||
},
|
||||
[applyZoom],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = canvasRef.current;
|
||||
if (!el) return;
|
||||
el.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => el.removeEventListener("wheel", onWheel);
|
||||
}, [onWheel]);
|
||||
|
||||
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
drag.current = { x: e.clientX, y: e.clientY };
|
||||
setDragging(true);
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!drag.current) return;
|
||||
const dx = e.clientX - drag.current.x;
|
||||
const dy = e.clientY - drag.current.y;
|
||||
drag.current = { x: e.clientX, y: e.clientY };
|
||||
const b = getBounds();
|
||||
setView((v) => {
|
||||
let ntx = v.tx + dx;
|
||||
let nty = v.ty + dy;
|
||||
if (b) {
|
||||
const maxTx = Math.max(0, (b.W * v.scale - b.cw) / 2);
|
||||
const maxTy = Math.max(0, (b.H * v.scale - b.ch) / 2);
|
||||
ntx = clamp(ntx, -maxTx, maxTx);
|
||||
nty = clamp(nty, -maxTy, maxTy);
|
||||
}
|
||||
return { ...v, tx: ntx, ty: nty };
|
||||
});
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
drag.current = null;
|
||||
setDragging(false);
|
||||
};
|
||||
|
||||
const zoomBy = (factor: number) => applyZoom(factor);
|
||||
|
||||
const reset = () => setView({ scale: INITIAL_SCALE, tx: 0, ty: 0 });
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
className="fixed inset-0 z-120 flex flex-col"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/80 backdrop-blur-md"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<div className="relative z-10 flex items-center justify-between gap-3 px-4 py-3">
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-white/50">
|
||||
mermaid · 放大查看
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ZoomBtn
|
||||
label="缩小"
|
||||
onClick={() => zoomBy(1 / ZOOM_STEP)}
|
||||
disabled={view.scale <= MIN_SCALE}
|
||||
>
|
||||
−
|
||||
</ZoomBtn>
|
||||
<span className="w-12 text-center font-mono text-[10px] text-white/50">
|
||||
{Math.round(view.scale * 100)}%
|
||||
</span>
|
||||
<ZoomBtn
|
||||
label="放大"
|
||||
onClick={() => zoomBy(ZOOM_STEP)}
|
||||
disabled={view.scale >= MAX_SCALE}
|
||||
>
|
||||
+
|
||||
</ZoomBtn>
|
||||
<ZoomBtn label="重置视图" onClick={reset}>
|
||||
重置
|
||||
</ZoomBtn>
|
||||
<ZoomBtn label="切换源码" onClick={() => setShowSource((s) => !s)}>
|
||||
{showSource ? "隐藏源码" : "源码"}
|
||||
</ZoomBtn>
|
||||
<ZoomBtn label="关闭" onClick={onClose}>
|
||||
✕
|
||||
</ZoomBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 画布 */}
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="relative z-10 flex-1 touch-none overflow-hidden"
|
||||
style={{ cursor: dragging ? "grabbing" : "grab" }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
onDoubleClick={reset}
|
||||
>
|
||||
<div
|
||||
className="flex min-h-full items-center justify-center"
|
||||
style={{
|
||||
transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.scale})`,
|
||||
transition: dragging ? "none" : "transform 0.12s ease-out",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={svgWrapRef}
|
||||
className="max-w-[88vw] [&_svg]:h-auto [&_svg]:max-w-[88vw]"
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSource && (
|
||||
<div className="relative z-10 max-h-[28vh] overflow-auto border-t border-white/10 bg-black/40 p-3">
|
||||
<pre className="font-mono text-xs leading-relaxed text-white/70">
|
||||
{code}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 px-4 py-2 text-center font-mono text-[10px] text-white/35">
|
||||
滚轮缩放 · 拖拽平移 · 双击重置 · Esc 关闭
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function ZoomBtn({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="glass-strong min-w-7 rounded-md px-2 py-1 font-mono text-xs text-white/80 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// 浅层 mock 子组件——只测 MessageBubble 本身的渲染逻辑
|
||||
vi.mock("./MarkdownContent", () => ({
|
||||
MarkdownContent: ({ content }: { content: string }) => (
|
||||
<div data-testid="markdown">{content}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./ThoughtBlock", () => ({
|
||||
ThoughtBlock: () => <div data-testid="thought-block" />,
|
||||
}));
|
||||
vi.mock("./ToolCallBlock", () => ({
|
||||
ToolCallBlock: () => <div data-testid="tool-call-block" />,
|
||||
}));
|
||||
|
||||
import { MessageBubble } from "./MessageBubble";
|
||||
|
||||
describe("MessageBubble", () => {
|
||||
it("用户消息靠右渲染文本", () => {
|
||||
render(
|
||||
<MessageBubble
|
||||
message={{
|
||||
message_id: "m1",
|
||||
session_id: "s1",
|
||||
role: "user",
|
||||
modality: "text",
|
||||
content: "你好",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("你好")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("空用户消息显示占位文案", () => {
|
||||
render(
|
||||
<MessageBubble
|
||||
message={{
|
||||
message_id: "m2",
|
||||
session_id: "s1",
|
||||
role: "user",
|
||||
modality: "text",
|
||||
content: "",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("(空消息)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("助手消息渲染 Markdown 内容", () => {
|
||||
render(
|
||||
<MessageBubble
|
||||
message={{
|
||||
message_id: "m3",
|
||||
session_id: "s1",
|
||||
role: "assistant",
|
||||
modality: "text",
|
||||
content: "回复内容",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("markdown")).toHaveTextContent("回复内容");
|
||||
});
|
||||
|
||||
it("助手消息带思考块时渲染", () => {
|
||||
render(
|
||||
<MessageBubble
|
||||
message={{
|
||||
message_id: "m4",
|
||||
session_id: "s1",
|
||||
role: "assistant",
|
||||
modality: "text",
|
||||
content: "回复",
|
||||
reasoning: "正在思考…",
|
||||
reasoningStreaming: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("thought-block")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("助手消息带工具调用时渲染 tool call 块", () => {
|
||||
render(
|
||||
<MessageBubble
|
||||
message={{
|
||||
message_id: "m5",
|
||||
session_id: "s1",
|
||||
role: "assistant",
|
||||
modality: "text",
|
||||
content: "回复",
|
||||
toolCalls: [
|
||||
{
|
||||
toolName: "web_search",
|
||||
toolCallId: "tc_1",
|
||||
status: "success",
|
||||
result: "ok",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("tool-call-block")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("system 角色居中显示", () => {
|
||||
render(
|
||||
<MessageBubble
|
||||
message={{
|
||||
message_id: "m6",
|
||||
session_id: "s1",
|
||||
role: "system",
|
||||
modality: "text",
|
||||
content: "系统消息",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/系统消息/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("最后一条助手消息且 canRegenerate 时显示重答按钮", () => {
|
||||
render(
|
||||
<MessageBubble
|
||||
message={{
|
||||
message_id: "m7",
|
||||
session_id: "s1",
|
||||
role: "assistant",
|
||||
modality: "text",
|
||||
content: "回复",
|
||||
}}
|
||||
isLastAssistant
|
||||
canRegenerate
|
||||
onRegenerate={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "重答" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,458 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import { MESSAGE_ROLE_LABEL } from "@/lib/constants/labels";
|
||||
import { synthesizeSpeech } from "@/lib/api/audio";
|
||||
import { useChatStore, type ChatMessageDraft } from "@/lib/stores/chat-store";
|
||||
import { MarkdownContent } from "./MarkdownContent";
|
||||
import { ThoughtBlock } from "./ThoughtBlock";
|
||||
import { ToolCallBlock } from "./ToolCallBlock";
|
||||
import { VersionPanel } from "./VersionPanel";
|
||||
import type { ToolConfirmationHandler } from "./MessageList";
|
||||
|
||||
/** 正文超过此高度(px)才出现「展开/收起」 */
|
||||
const COLLAPSE_THRESHOLD = 260;
|
||||
|
||||
/**
|
||||
* 铜镜鉴心 v2 消息气泡:
|
||||
* - Assistant → 镜面浮出(mirror-face 渐变 + gilt-dim 描边 + 内阴影)
|
||||
* - User → 纸笺(无背景,右缘 gilt 发丝线,右对齐)
|
||||
* - Speaker mono 眉标在上方,时间戳在眉标行右侧
|
||||
* - 操作按钮(朗读 / 重答)位于气泡内底部,hover / focus 时浮现
|
||||
*/
|
||||
export function MessageBubble({
|
||||
message,
|
||||
isLastAssistant,
|
||||
canRegenerate,
|
||||
onApproveToolCall,
|
||||
onDenyToolCall,
|
||||
onRegenerate,
|
||||
characterName,
|
||||
}: {
|
||||
message: ChatMessageDraft;
|
||||
isLastAssistant?: boolean;
|
||||
canRegenerate?: boolean;
|
||||
onApproveToolCall?: ToolConfirmationHandler;
|
||||
onDenyToolCall?: ToolConfirmationHandler;
|
||||
onRegenerate?: () => void;
|
||||
/** 助手说话人名称(默认回退「助手」) */
|
||||
characterName?: string;
|
||||
}) {
|
||||
const [ttsState, setTtsState] = useState<"idle" | "loading" | "playing">(
|
||||
"idle",
|
||||
);
|
||||
const [vpOpen, setVpOpen] = useState(false);
|
||||
const browseVersion = useChatStore((s) => s.browseVersion);
|
||||
const applyVersion = useChatStore((s) => s.useVersion);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTts = useCallback(async () => {
|
||||
if (!message.content) return;
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
setTtsState("idle");
|
||||
return;
|
||||
}
|
||||
if (ttsState === "loading") return;
|
||||
setTtsState("loading");
|
||||
try {
|
||||
const blob = await synthesizeSpeech({
|
||||
text: message.content,
|
||||
voice: "default",
|
||||
format: "wav",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const audio = new Audio(url);
|
||||
audioRef.current = audio;
|
||||
audio.onended = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
audioRef.current = null;
|
||||
setTtsState("idle");
|
||||
};
|
||||
audio.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
audioRef.current = null;
|
||||
setTtsState("idle");
|
||||
};
|
||||
await audio.play();
|
||||
setTtsState("playing");
|
||||
} catch {
|
||||
setTtsState("idle");
|
||||
}
|
||||
}, [ttsState, message.content]);
|
||||
|
||||
if (message.role === "system" || message.role === "tool") {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<span className="rounded border border-rule bg-surface px-2 py-1 text-xs text-muted">
|
||||
{MESSAGE_ROLE_LABEL[message.role]}:{message.content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isUser = message.role === "user";
|
||||
const hasReasoning =
|
||||
!isUser && message.reasoning !== undefined && message.reasoning !== null;
|
||||
const hasToolCalls = !isUser && !!message.toolCalls?.length;
|
||||
const showRegenerate =
|
||||
canRegenerate && isLastAssistant && !!onRegenerate && !message.streaming;
|
||||
const speakerName = isUser ? "你" : characterName || "助手";
|
||||
|
||||
const timeLabel = message.created_at
|
||||
? new Date(message.created_at).toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: null;
|
||||
|
||||
const showActions = !isUser && !!message.content;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1", isUser && "items-end")}>
|
||||
{/* Speaker 眉标 */}
|
||||
<div
|
||||
className={cn("flex items-center gap-2", isUser && "flex-row-reverse")}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mono text-[10px] tracking-[0.18em] uppercase",
|
||||
isUser ? "text-gilt" : "text-patina",
|
||||
)}
|
||||
>
|
||||
{speakerName}
|
||||
</span>
|
||||
{timeLabel && (
|
||||
<span className="mono text-[9px] text-fg-faint">{timeLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 消息体 */}
|
||||
<div
|
||||
className={cn(
|
||||
"group relative",
|
||||
isUser ? "w-fit max-w-[75%] min-w-[20%]" : "w-full",
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
/* User:纸笺 — 与 AI 镜面对偶,透明底 + 右缘鎏金脊(镜缘),
|
||||
* 设计稿说「纸笺无背景」,用透明底 + 右缘 2px 细脊替代实色卡片。
|
||||
* 圆角不对称:左圆右直(方向感) */
|
||||
<div className="inline-block max-w-full rounded-l-lg border border-rule border-r-2 border-r-[var(--gilt-dim)] bg-transparent py-[11px] pl-[18px] pr-4 text-right [box-shadow:inset_0_1px_0_rgba(255,255,255,0.025)]">
|
||||
<span className="whitespace-pre-wrap break-words text-[15px] leading-[1.7] text-fg [word-break:break-word]">
|
||||
{message.content || (message.streaming ? "" : "(空消息)")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
/* Assistant:镜面浮出 — 静镜(已完成)/ 活镜(流式中光泽流转)
|
||||
* mirror-face: 抛光铜镜(渐变 + gilt-dim 描边 + 内阴影)
|
||||
* mirror-sweep: 光泽漫反射(静态 opacity 0.42,is-live 时流转)
|
||||
* mirror-grain: 铜面颗粒(feTurbulence 噪点)
|
||||
* 圆角不对称:右圆左直(与用户对偶) */
|
||||
<div
|
||||
className={cn(
|
||||
"mirror-face mirror-grain mirror-sweep rounded-r-lg",
|
||||
message.streaming && "is-live",
|
||||
"px-[22px] py-[18px]",
|
||||
)}
|
||||
>
|
||||
<div className="relative z-[1]">
|
||||
{hasReasoning && (
|
||||
<ThoughtBlock
|
||||
reasoning={message.reasoning ?? ""}
|
||||
streaming={message.reasoningStreaming}
|
||||
/>
|
||||
)}
|
||||
{hasToolCalls &&
|
||||
message.toolCalls?.map((tc) => (
|
||||
<ToolCallBlock
|
||||
key={tc.toolCallId}
|
||||
toolCall={tc}
|
||||
runId={message.run_id}
|
||||
onApprove={onApproveToolCall}
|
||||
onDeny={onDenyToolCall}
|
||||
/>
|
||||
))}
|
||||
<CollapsibleMarkdown
|
||||
content={message.content}
|
||||
streaming={message.streaming}
|
||||
/>
|
||||
|
||||
{/* 操作栏:气泡内底部,hover/focus 浮现(语义始终可达) */}
|
||||
{showActions && (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2.5 flex items-center gap-0.5 border-t border-rule pt-1.5 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100",
|
||||
message.streaming && "opacity-100",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTts}
|
||||
title={
|
||||
ttsState === "loading"
|
||||
? "合成中…"
|
||||
: ttsState === "playing"
|
||||
? "点击停止"
|
||||
: "朗读回复"
|
||||
}
|
||||
aria-label="朗读"
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] text-fg-faint transition-colors hover:bg-fg/5 hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-patina/40 disabled:opacity-50"
|
||||
disabled={ttsState === "loading"}
|
||||
>
|
||||
{ttsState === "loading" ? (
|
||||
<TtsLoadingIcon />
|
||||
) : (
|
||||
<SpeakerIcon active={ttsState === "playing"} />
|
||||
)}
|
||||
朗读
|
||||
</button>
|
||||
{!!message.versions && message.versions.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVpOpen((v) => !v)}
|
||||
title={vpOpen ? "收起版本面板" : "浏览版本历史"}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-patina/40",
|
||||
vpOpen
|
||||
? "bg-patina-wash text-patina"
|
||||
: "text-fg-faint hover:bg-fg/5 hover:text-fg",
|
||||
)}
|
||||
>
|
||||
<HistoryIcon />
|
||||
版本
|
||||
</button>
|
||||
)}
|
||||
{showRegenerate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRegenerate?.()}
|
||||
title="重新生成回答"
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] text-fg-faint transition-colors hover:bg-fg/5 hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-patina/40"
|
||||
>
|
||||
<RegenerateIcon />
|
||||
重答
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 版本历史面板:重答留存的多版本浏览(有 >1 个版本时才展示,单版本无历史可言) */}
|
||||
{vpOpen && !!message.versions && message.versions.length > 1 && (
|
||||
<VersionPanel
|
||||
versions={message.versions}
|
||||
activeIdx={message.activeVersionIdx ?? 0}
|
||||
messageId={message.message_id}
|
||||
onBrowse={(idx, content) => {
|
||||
browseVersion(message.message_id, idx, content);
|
||||
}}
|
||||
onUseVersion={(mid, idx) => {
|
||||
applyVersion(mid, idx);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 助手正文:流式完成后,内容超过阈值可手动折叠 */
|
||||
function CollapsibleMarkdown({
|
||||
content,
|
||||
streaming,
|
||||
}: {
|
||||
content: string;
|
||||
streaming?: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [collapsible, setCollapsible] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
if (streaming) {
|
||||
setCollapsible(false);
|
||||
return;
|
||||
}
|
||||
setCollapsible(el.scrollHeight > COLLAPSE_THRESHOLD);
|
||||
};
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [streaming]);
|
||||
|
||||
const collapsed = collapsible && !expanded;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative break-words font-serif leading-[1.75]",
|
||||
collapsed && "max-h-[260px] overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{content ? (
|
||||
<MarkdownContent content={content} />
|
||||
) : streaming ? null : (
|
||||
"(空消息)"
|
||||
)}
|
||||
{streaming && (
|
||||
<span className="ml-0.5 inline-block h-3.5 w-1.5 animate-pulse bg-current align-middle" />
|
||||
)}
|
||||
</div>
|
||||
{collapsed && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-12 rounded-b-2xl bg-gradient-to-t from-[var(--glass)] to-transparent" />
|
||||
)}
|
||||
{collapsible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="mt-1 text-xs text-patina transition-opacity hover:opacity-80"
|
||||
>
|
||||
{expanded ? "收起" : "展开全文"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegenerateIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2v3.5H10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SpeakerIcon({ active }: { active?: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
className={active ? "text-patina" : ""}
|
||||
>
|
||||
<path
|
||||
d="M2 5.5v5h2.5l4 3V2.5l-4 3H2Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{active ? (
|
||||
<>
|
||||
<path
|
||||
d="M11 5a4 4 0 0 1 0 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M12.5 3a7 7 0 0 1 0 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path
|
||||
d="M11 6.5a2.5 2.5 0 0 1 0 3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M12.5 5a5 5 0 0 1 0 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.1"
|
||||
strokeLinecap="round"
|
||||
opacity="0.5"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TtsLoadingIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
className="animate-spin"
|
||||
>
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
opacity="0.3"
|
||||
/>
|
||||
<path
|
||||
d="M8 2a6 6 0 0 1 6 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 版本历史图标 */
|
||||
function HistoryIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<circle cx="8" cy="8" r="5.5" stroke="currentColor" strokeWidth="1.3" />
|
||||
<path
|
||||
d="M8 5v3.5L10 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2.5 2.5l2 2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M2 8H.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock MessageBubble to isolate MessageList from its dependencies
|
||||
vi.mock("./MessageBubble", () => ({
|
||||
MessageBubble: ({
|
||||
message,
|
||||
isLastAssistant,
|
||||
canRegenerate,
|
||||
}: {
|
||||
message: { role: string; content: string };
|
||||
isLastAssistant: boolean;
|
||||
canRegenerate: boolean;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="message-bubble"
|
||||
data-role={message.role}
|
||||
data-last={isLastAssistant}
|
||||
data-canregen={canRegenerate}
|
||||
>
|
||||
{message.content || `(${message.role})`}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import type { ChatMessageDraft } from "@/lib/stores/chat-store";
|
||||
import { useChatStore } from "@/lib/stores/chat-store";
|
||||
import { MessageList } from "./MessageList";
|
||||
|
||||
function makeMsg(overrides: Partial<ChatMessageDraft>): ChatMessageDraft {
|
||||
return {
|
||||
message_id: "m1",
|
||||
session_id: "s1",
|
||||
role: "user",
|
||||
modality: "text",
|
||||
content: "测试",
|
||||
...overrides,
|
||||
} as ChatMessageDraft;
|
||||
}
|
||||
|
||||
describe("MessageList", () => {
|
||||
afterEach(() => {
|
||||
useChatStore.setState({ messages: [], sending: false });
|
||||
});
|
||||
|
||||
it("无消息时渲染空状态", () => {
|
||||
render(<MessageList />);
|
||||
expect(screen.getByText("镜面空明")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("有消息时渲染气泡", () => {
|
||||
useChatStore.setState({
|
||||
messages: [makeMsg({ message_id: "m1", content: "你好" })],
|
||||
});
|
||||
render(<MessageList />);
|
||||
expect(screen.getByText("你好")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("仅最后一条 assistant 标记 canRegenerate", () => {
|
||||
useChatStore.setState({
|
||||
sending: false,
|
||||
messages: [
|
||||
makeMsg({ message_id: "m1", role: "user", content: "你好" }),
|
||||
makeMsg({ message_id: "m2", role: "assistant", content: "回复1" }),
|
||||
makeMsg({ message_id: "m3", role: "user", content: "再问" }),
|
||||
makeMsg({ message_id: "m4", role: "assistant", content: "回复2" }),
|
||||
],
|
||||
});
|
||||
render(<MessageList />);
|
||||
const bubbles = screen.getAllByTestId("message-bubble");
|
||||
// assistant 索引 1 和 3,只有索引 3 是最后一条
|
||||
expect(bubbles[1]).toHaveAttribute("data-last", "false");
|
||||
expect(bubbles[1]).toHaveAttribute("data-canregen", "false");
|
||||
expect(bubbles[3]).toHaveAttribute("data-last", "true");
|
||||
expect(bubbles[3]).toHaveAttribute("data-canregen", "true");
|
||||
});
|
||||
|
||||
it("发送中时不标记 canRegenerate", () => {
|
||||
useChatStore.setState({
|
||||
sending: true,
|
||||
messages: [
|
||||
makeMsg({ message_id: "m1", role: "user", content: "你好" }),
|
||||
makeMsg({ message_id: "m2", role: "assistant", content: "回复" }),
|
||||
],
|
||||
});
|
||||
render(<MessageList />);
|
||||
const bubbles = screen.getAllByTestId("message-bubble");
|
||||
expect(bubbles[1]).toHaveAttribute("data-last", "true");
|
||||
expect(bubbles[1]).toHaveAttribute("data-canregen", "false");
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { EmptyState } from "@/components/shared/StateViews";
|
||||
import { MessageBubble } from "./MessageBubble";
|
||||
import { useChatStore } from "@/lib/stores/chat-store";
|
||||
|
||||
/** 工具确认回调签名:传 run_id / confirmation_id / tool_call_id 供端点调用 */
|
||||
export type ToolConfirmationHandler = (
|
||||
runId: string,
|
||||
confirmationId: string,
|
||||
toolCallId: string,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* 消息列表:自管理滚动到底。
|
||||
*
|
||||
* 计划 todo(SessionPlanCard)暂不挂载——当前阶段(implementation-status.md §8)不实现
|
||||
* 自动长期任务规划,AI 单次回复内的推演已由回复气泡的「思考过程」承担。待后端
|
||||
* plan.* 运行事件落地(planner-design.md §5)后,再于「AI 实际产出计划」时挂载,
|
||||
* 无计划则不渲染(而非用 mock 常驻显示)。
|
||||
*/
|
||||
export function MessageList({
|
||||
onApproveToolCall,
|
||||
onDenyToolCall,
|
||||
onRegenerate,
|
||||
characterName,
|
||||
}: {
|
||||
onApproveToolCall?: ToolConfirmationHandler;
|
||||
onDenyToolCall?: ToolConfirmationHandler;
|
||||
onRegenerate?: () => void;
|
||||
characterName?: string;
|
||||
} = {}) {
|
||||
const messages = useChatStore((s) => s.messages);
|
||||
const sending = useChatStore((s) => s.sending);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
// 空会话:不展示计划 todo,仅空状态引导
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<EmptyState
|
||||
title="镜面空明"
|
||||
description="发送一条文本消息,开启与当前人物的对话。"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 找到最后一条 assistant 消息的索引:用于在该气泡下显示「重新回答」按钮。
|
||||
// 发送中(sending)时正在生成新的助手消息,此时不显示该按钮——由 MessageBubble
|
||||
// 内部根据 isLastAssistant && !sending 综合判定。
|
||||
let lastAssistantIdx = -1;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === "assistant") {
|
||||
lastAssistantIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6 sm:px-6">
|
||||
<div className="space-y-5">
|
||||
{messages.map((message, idx) => (
|
||||
<MessageBubble
|
||||
key={message.message_id}
|
||||
message={message}
|
||||
isLastAssistant={idx === lastAssistantIdx}
|
||||
canRegenerate={idx === lastAssistantIdx && !sending}
|
||||
onApproveToolCall={onApproveToolCall}
|
||||
onDenyToolCall={onDenyToolCall}
|
||||
onRegenerate={onRegenerate}
|
||||
characterName={characterName}
|
||||
/>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { Character } from "@/types/api";
|
||||
|
||||
/**
|
||||
* 铜镜鉴心 Hero —— 数字生命体征(设计稿 copper-mirror-redesign-v2.html §hero)。
|
||||
*
|
||||
* 当无活跃会话时,作为空状态展示:九枢星盘(活的仪器)+ 右侧人物内在状态。
|
||||
* 数据取自当前激活人物及其健康度。
|
||||
*/
|
||||
export function MirrorHero({ character }: { character: Character | null }) {
|
||||
const characterName = character?.display_name ?? character?.name ?? "青梧";
|
||||
const subtitle = character?.profile?.description ?? "认识你第 41 日";
|
||||
|
||||
// 三魂数据(设计稿 .souls):胎光(元识)/ 爽灵(智识)/ 幽精(情识)
|
||||
const souls = [
|
||||
{ key: "tai", label: "胎光", sub: "元识", value: "守一" },
|
||||
{
|
||||
key: "shuang",
|
||||
label: "爽灵",
|
||||
sub: "智识",
|
||||
value: "正在决定如何回应你",
|
||||
hint: "定策",
|
||||
},
|
||||
{ key: "you", label: "幽精", sub: "情识", value: "沉静,念你" },
|
||||
{ key: "neiqu", label: "内驱", sub: "好奇", value: "想弄清你的偏好" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-1 flex-col justify-center"
|
||||
style={{
|
||||
padding: "clamp(20px, 4vw, 48px) clamp(16px, 4vw, 48px)",
|
||||
maxWidth: 1180,
|
||||
margin: "0 auto",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{/* Hero 网格:星盘 + 体征(设计稿 .hero) */}
|
||||
<div className="grid items-center gap-6 pb-8 md:gap-12 grid-cols-1 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
{/* 左侧:九枢星盘 */}
|
||||
<div className="astrolabe-wrap">
|
||||
<NinePivotAstrolabe />
|
||||
</div>
|
||||
|
||||
{/* 右侧:人物体征 */}
|
||||
<div className="vitals">
|
||||
<p className="mb-1.5 text-xs text-patina">{subtitle}</p>
|
||||
<h1
|
||||
className="font-serif font-bold leading-tight tracking-wide text-fg"
|
||||
style={{
|
||||
fontSize: "clamp(34px, 5vw, 52px)",
|
||||
margin: "0 0 4px",
|
||||
}}
|
||||
>
|
||||
{characterName}
|
||||
</h1>
|
||||
{/* 在场脉冲 */}
|
||||
<div className="mb-5 flex items-center gap-2 text-sm text-fg-dim">
|
||||
<span className="animate-pulse-ring inline-block h-1.5 w-1.5 rounded-full bg-patina" />
|
||||
正在 三爻 定策,沉静
|
||||
</div>
|
||||
|
||||
{/* 三魂网格(设计稿 .souls) */}
|
||||
<div className="mb-5 grid grid-cols-2 overflow-hidden rounded-md border border-rule bg-night-bronze">
|
||||
{souls.map((s) => (
|
||||
<div key={s.key} className="px-3.5 py-3 bg-night-bronze">
|
||||
<div className="font-mono text-[10px] tracking-widest uppercase text-fg-faint">
|
||||
<b className="mr-1 font-serif text-xs font-semibold text-gilt">
|
||||
{s.label}
|
||||
</b>
|
||||
{s.sub}
|
||||
</div>
|
||||
<div className="mt-1 font-serif text-sm text-fg">{s.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 主操作行 */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border px-5 py-2.5 text-sm font-semibold transition-colors"
|
||||
style={{
|
||||
background: "var(--patina)",
|
||||
borderColor: "var(--patina)",
|
||||
color: "var(--ink)",
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.background = "var(--patina-bright)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.background = "var(--patina)")
|
||||
}
|
||||
disabled
|
||||
title="创建新会话以开始对话"
|
||||
>
|
||||
与{characterName}对话
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border px-5 py-2.5 text-sm font-medium transition-colors"
|
||||
style={{
|
||||
borderColor: "var(--rule)",
|
||||
background: "var(--surface)",
|
||||
color: "var(--fg)",
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.background = "var(--surface-hi)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.background = "var(--surface)")
|
||||
}
|
||||
>
|
||||
查看运行轨迹
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 九枢星盘 SVG —— 活的仪器(设计稿 §astrolabe)。
|
||||
* 无交互、纯展示版本,用于 Hero 空状态。
|
||||
* 枢点数据为示意,真实场景应接入 pivot_health 实时数据。
|
||||
*/
|
||||
function NinePivotAstrolabe() {
|
||||
return (
|
||||
<svg
|
||||
className="mx-auto block w-full"
|
||||
style={{ maxWidth: 400, aspectRatio: "1" }}
|
||||
viewBox="0 0 400 400"
|
||||
role="img"
|
||||
aria-label="九枢星盘"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id="heroMirrorFace" cx="40%" cy="34%" r="70%">
|
||||
<stop offset="0%" stopColor="var(--patina)" stopOpacity="0.16" />
|
||||
<stop offset="60%" stopColor="var(--patina)" stopOpacity="0.05" />
|
||||
<stop offset="100%" stopColor="var(--patina)" stopOpacity="0.01" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
{/* 旋转外环 */}
|
||||
<g className="animate-slow-rot" style={{ transformOrigin: "center" }}>
|
||||
<circle cx="200" cy="200" r="190" className="astrolabe-ring" />
|
||||
<circle cx="200" cy="200" r="184" className="astrolabe-ring-fine" />
|
||||
</g>
|
||||
|
||||
{/* 刻度 */}
|
||||
<circle
|
||||
cx="200"
|
||||
cy="200"
|
||||
r="185"
|
||||
className="astrolabe-tick"
|
||||
strokeDasharray="1.5 22.98"
|
||||
/>
|
||||
|
||||
{/* 内环 */}
|
||||
<circle cx="200" cy="200" r="150" className="astrolabe-ring" />
|
||||
<circle cx="200" cy="200" r="92" className="astrolabe-ring-patina" />
|
||||
<circle cx="200" cy="200" r="58" className="astrolabe-ring-fine" />
|
||||
|
||||
{/* 核心辉光 */}
|
||||
<circle
|
||||
className="astrolabe-core-glow"
|
||||
cx="200"
|
||||
cy="200"
|
||||
r="52"
|
||||
style={{ fill: "var(--patina)", opacity: 0.08 }}
|
||||
/>
|
||||
|
||||
{/* 道体核 */}
|
||||
<circle
|
||||
cx="200"
|
||||
cy="200"
|
||||
r="40"
|
||||
fill="url(#heroMirrorFace)"
|
||||
style={{
|
||||
stroke: "var(--gilt)",
|
||||
strokeWidth: 1,
|
||||
strokeOpacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
<text
|
||||
x="200"
|
||||
y="197"
|
||||
textAnchor="middle"
|
||||
className="astrolabe-core-glyph"
|
||||
>
|
||||
道體
|
||||
</text>
|
||||
<text
|
||||
x="200"
|
||||
y="213"
|
||||
textAnchor="middle"
|
||||
className="astrolabe-core-label"
|
||||
>
|
||||
DAO TI
|
||||
</text>
|
||||
|
||||
{/* 枢轴向辐条 + 枢点 */}
|
||||
{PIVOT_DATA.map((p, i) => {
|
||||
const ang = ((-90 + i * (360 / PIVOT_DATA.length)) * Math.PI) / 180;
|
||||
const x = 200 + 150 * Math.cos(ang);
|
||||
const y = 200 + 150 * Math.sin(ang);
|
||||
const bx = 200 + 40 * Math.cos(ang);
|
||||
const by = 200 + 40 * Math.sin(ang);
|
||||
const lx = 200 + (150 + 26) * Math.cos(ang);
|
||||
const ly = 200 + (150 + 26) * Math.sin(ang) + 3;
|
||||
const col =
|
||||
HEALTH_COLOR[p.state as keyof typeof HEALTH_COLOR] ??
|
||||
HEALTH_COLOR.steady;
|
||||
|
||||
return (
|
||||
<g key={p.en}>
|
||||
{/* 辐条 */}
|
||||
<line x1={bx} y1={by} x2={x} y2={y} className="astrolabe-spoke" />
|
||||
{/* 枢点 */}
|
||||
{p.state === "cracked" ? (
|
||||
<g>
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r="9"
|
||||
fill="none"
|
||||
className="astrolabe-alert"
|
||||
style={{
|
||||
stroke: col,
|
||||
strokeWidth: 1.2,
|
||||
transformOrigin: `${x}px ${y}px`,
|
||||
}}
|
||||
/>
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r="5.5"
|
||||
style={{
|
||||
fill: col,
|
||||
fillOpacity: 0.92,
|
||||
stroke: "var(--gilt)",
|
||||
strokeWidth: 1,
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
) : (
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r="5.5"
|
||||
style={{
|
||||
fill: col,
|
||||
fillOpacity: 0.92,
|
||||
stroke: "var(--gilt)",
|
||||
strokeWidth: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* 标签 */}
|
||||
<text
|
||||
x={lx}
|
||||
y={ly}
|
||||
textAnchor="middle"
|
||||
className="astrolabe-pivot-label"
|
||||
>
|
||||
{p.label}
|
||||
</text>
|
||||
<text
|
||||
x={lx}
|
||||
y={ly + 11}
|
||||
textAnchor="middle"
|
||||
className="astrolabe-pivot-sub"
|
||||
>
|
||||
{p.en}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 九枢示意 */
|
||||
const PIVOT_DATA = [
|
||||
{ label: "灵核", en: "CORE", state: "steady" },
|
||||
{ label: "太一", en: "GOAL", state: "steady" },
|
||||
{ label: "司命", en: "SAFETY", state: "steady" },
|
||||
{ label: "烛照", en: "PERCEPT", state: "cracked" },
|
||||
{ label: "河图", en: "WORLD", state: "steady" },
|
||||
{ label: "洛书", en: "PLANNER", state: "steady" },
|
||||
{ label: "息壤", en: "MEMORY", state: "breathing" },
|
||||
{ label: "百工", en: "TOOLS", state: "cracked" },
|
||||
{ label: "天工阁", en: "AGENTS", state: "steady" },
|
||||
];
|
||||
|
||||
const HEALTH_COLOR = {
|
||||
void: "var(--fg-faint)",
|
||||
steady: "var(--patina)",
|
||||
breathing: "var(--gilt)",
|
||||
cracked: "var(--cinnabar)",
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ModelSelector } from "./ModelSelector";
|
||||
import { useChatStore } from "@/lib/stores/chat-store";
|
||||
import type { ModelCapability } from "@/types/domain";
|
||||
|
||||
const mockModels = [
|
||||
{
|
||||
name: "default-chat",
|
||||
display_name: "默认对话",
|
||||
provider: "openai" as const,
|
||||
capabilities: ["chat"] as ModelCapability[],
|
||||
routing_strategy: "round_robin" as const,
|
||||
supports_streaming: true,
|
||||
supports_thinking: false,
|
||||
supports_structured_output: true,
|
||||
context_window: 128000,
|
||||
},
|
||||
{
|
||||
name: "deep-think",
|
||||
display_name: "深度思考",
|
||||
provider: "anthropic" as const,
|
||||
capabilities: ["chat"] as ModelCapability[],
|
||||
routing_strategy: "round_robin" as const,
|
||||
supports_streaming: true,
|
||||
supports_thinking: true,
|
||||
supports_structured_output: true,
|
||||
context_window: 200000,
|
||||
},
|
||||
];
|
||||
|
||||
describe("ModelSelector", () => {
|
||||
afterEach(() => {
|
||||
useChatStore.setState({ modelHint: null });
|
||||
});
|
||||
|
||||
it("无匹配模型时显示未选择", () => {
|
||||
render(<ModelSelector models={mockModels} />);
|
||||
expect(screen.getByText("未选择模型")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("显示当前模型名", () => {
|
||||
useChatStore.setState({ modelHint: "default-chat" });
|
||||
render(<ModelSelector models={mockModels} />);
|
||||
expect(screen.getByText("默认对话")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("点击展开下拉列表", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelector models={mockModels} />);
|
||||
const btn = screen.getByRole("button", { name: "切换模型" });
|
||||
await user.click(btn);
|
||||
expect(screen.getByText("深度思考")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("点击选项切换模型并收起", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelector models={mockModels} />);
|
||||
await user.click(screen.getByRole("button", { name: "切换模型" }));
|
||||
await user.click(screen.getByText("深度思考"));
|
||||
expect(useChatStore.getState().modelHint).toBe("deep-think");
|
||||
// 切换后下拉应关闭
|
||||
expect(screen.getByRole("button", { name: "切换模型" })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("点击外部关闭下拉", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelector models={mockModels} />);
|
||||
await user.click(screen.getByRole("button", { name: "切换模型" }));
|
||||
expect(screen.getByText("深度思考")).toBeInTheDocument();
|
||||
await user.click(document.body);
|
||||
expect(screen.queryByText("深度思考")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { MODEL_PROVIDER_LABEL } from "@/lib/constants/labels";
|
||||
import { useChatStore } from "@/lib/stores/chat-store";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import type { LogicalModel } from "@/types/api";
|
||||
|
||||
/**
|
||||
* 对话模型选择器:左侧纯展示当前模型名(不可点击),右侧下拉按钮点击展开切换。
|
||||
* 用于对话头部(ActiveCharacterHeader 人物名下方那一行);选择写入 chat-store
|
||||
* 并持久化为偏好。
|
||||
*/
|
||||
export function ModelSelector({ models }: { models: LogicalModel[] }) {
|
||||
const modelHint = useChatStore((s) => s.modelHint);
|
||||
const setModelHint = useChatStore((s) => s.setModelHint);
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onPointerDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown);
|
||||
}, [open]);
|
||||
|
||||
const current = models.find((m) => m.name === modelHint);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative inline-flex items-center gap-0.5">
|
||||
{/* 当前模型名(纯展示) */}
|
||||
<span className="inline-flex min-w-0 items-center gap-1 text-xs text-muted">
|
||||
<ModelIcon />
|
||||
<span className="max-w-[8rem] truncate">
|
||||
{current ? current.display_name : "未选择模型"}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* 下拉触发按钮:点击展开切换其他模型 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label="切换模型"
|
||||
title="切换模型"
|
||||
className={cn(
|
||||
"flex h-5 w-5 items-center justify-center rounded transition-colors",
|
||||
open
|
||||
? "bg-patina/10 text-patina"
|
||||
: "text-muted hover:bg-fg/10 hover:text-fg",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
className={cn("transition-transform", open && "rotate-180")}
|
||||
>
|
||||
<path
|
||||
d="M4 6l4 4 4-4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul
|
||||
role="listbox"
|
||||
aria-label="选择对话模型"
|
||||
className="glass-strong absolute top-full left-0 z-30 mt-1 max-h-72 w-64 overflow-y-auto rounded-xl p-1 shadow-lg shadow-black/10"
|
||||
>
|
||||
{models.map((m) => {
|
||||
const active = m.name === modelHint;
|
||||
return (
|
||||
<li key={m.name}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onClick={() => {
|
||||
setModelHint(m.name);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-0.5 rounded-lg px-2.5 py-1.5 text-left transition-colors",
|
||||
active ? "bg-patina/15" : "hover:bg-fg/5",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-xs text-fg">{m.name}</span>
|
||||
{m.is_default && (
|
||||
<span className="rounded bg-patina/15 px-1 text-[9px] text-patina">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-[10px] text-muted">
|
||||
{MODEL_PROVIDER_LABEL[m.provider]}
|
||||
</span>
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-muted">
|
||||
{m.display_name}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<rect
|
||||
x="4.5"
|
||||
y="4.5"
|
||||
width="7"
|
||||
height="7"
|
||||
rx="1.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<path
|
||||
d="M8 1.5v2.5M8 12v2.5M1.5 8H4M12 8h2.5M3.2 3.2l1.5 1.5M11.3 11.3l1.5 1.5M3.2 12.8l1.5-1.5M11.3 4.7l1.5-1.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import { Badge, type BadgeTone } from "@/components/ui/Badge";
|
||||
import { RISK_LEVEL_LABEL } from "@/lib/constants/labels";
|
||||
import { mockActivePlan } from "@/lib/mock/plans";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import type { Plan, PlanStatus, PlanStep, PlanStepStatus } from "@/types/plan";
|
||||
|
||||
/**
|
||||
* 洛书计划面板(前瞻原型)。
|
||||
*
|
||||
* 字段与 docs/domain/core-domains.md §5 对齐,数据来自 lib/mock/plans.ts。
|
||||
* 后端 plan.* 事件尚未落地(见 docs/operations/capability-matrix.md),此处仅用于
|
||||
* UI 验证;接入真实数据时把 plan prop 换成 store / API,渲染层无需改动。
|
||||
*/
|
||||
|
||||
const PLAN_STATUS_LABEL: Record<PlanStatus, string> = {
|
||||
draft: "草案",
|
||||
approved: "已批准",
|
||||
running: "执行中",
|
||||
completed: "已完成",
|
||||
failed: "已失败",
|
||||
};
|
||||
|
||||
const PLAN_STATUS_TONE: Record<PlanStatus, BadgeTone> = {
|
||||
draft: "neutral",
|
||||
approved: "info",
|
||||
running: "accent",
|
||||
completed: "success",
|
||||
failed: "danger",
|
||||
};
|
||||
|
||||
const STEP_STATUS_LABEL: Record<PlanStepStatus, string> = {
|
||||
pending: "待执行",
|
||||
running: "执行中",
|
||||
waiting_confirmation: "等待确认",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
skipped: "已跳过",
|
||||
};
|
||||
|
||||
/** 风险等级 → 徽章色调(L0 不展示) */
|
||||
const RISK_TONE: Record<string, BadgeTone> = {
|
||||
L0: "neutral",
|
||||
L1: "info",
|
||||
L2: "warning",
|
||||
L3: "danger",
|
||||
L4: "danger",
|
||||
};
|
||||
|
||||
const STEP_STATUS_TEXT: Record<PlanStepStatus, string> = {
|
||||
pending: "text-muted",
|
||||
running: "text-patina",
|
||||
waiting_confirmation: "text-gilt",
|
||||
completed: "text-emerald-600 dark:text-emerald-400",
|
||||
failed: "text-cinnabar",
|
||||
skipped: "text-muted",
|
||||
};
|
||||
|
||||
/** 步骤状态圆点 */
|
||||
function StepDot({ status }: { status: PlanStepStatus }) {
|
||||
if (status === "running") {
|
||||
return (
|
||||
<span className="relative flex h-5 w-5 items-center justify-center">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-patina/40" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-patina" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "waiting_confirmation") {
|
||||
return (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-gilt text-ink shadow-sm shadow-gilt/30">
|
||||
<span className="text-xs font-bold">!</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "completed") {
|
||||
return (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-patina text-white shadow-sm shadow-patina/30">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M2.5 6.2l2.3 2.3L9.5 3.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "failed") {
|
||||
return (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-cinnabar text-white shadow-sm shadow-cinnabar/30">
|
||||
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M3 3l6 6M9 3l-6 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "skipped") {
|
||||
return (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full border border-muted/50 bg-bg text-muted">
|
||||
<span className="h-0.5 w-2.5 rounded-full bg-muted" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// pending:空心圆环,bg 底色盖住时间线连接线
|
||||
return (
|
||||
<span className="h-5 w-5 rounded-full border-2 border-muted/40 bg-bg" />
|
||||
);
|
||||
}
|
||||
|
||||
function StepRow({ step, last }: { step: PlanStep; last: boolean }) {
|
||||
return (
|
||||
<div className="relative flex gap-3 pb-4 last:pb-0">
|
||||
{!last && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-[9px] top-5 h-[calc(100%-1.25rem)] w-px bg-border-glass"
|
||||
/>
|
||||
)}
|
||||
<div className="relative z-10 mt-0.5 shrink-0">
|
||||
<StepDot status={step.status} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="text-sm font-medium leading-snug text-fg">
|
||||
{step.title}
|
||||
</h4>
|
||||
{step.risk_level !== "L0" && (
|
||||
<Badge
|
||||
tone={RISK_TONE[step.risk_level] ?? "neutral"}
|
||||
className="shrink-0"
|
||||
>
|
||||
{RISK_LEVEL_LABEL[step.risk_level]}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-muted">
|
||||
{step.description}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-medium",
|
||||
STEP_STATUS_TEXT[step.status],
|
||||
)}
|
||||
>
|
||||
{STEP_STATUS_LABEL[step.status]}
|
||||
</span>
|
||||
{step.tool && (
|
||||
<>
|
||||
<span className="text-[11px] text-muted/60">·</span>
|
||||
<Badge tone="info" className="px-1.5 py-0 text-[10px]">
|
||||
{step.tool}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{step.result_summary && (
|
||||
<p className="mt-1.5 border-l-2 border-patina/40 bg-patina/5 px-2 py-1 text-[11px] leading-relaxed text-fg/75">
|
||||
{step.result_summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSection({
|
||||
title,
|
||||
items,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
title: string;
|
||||
items: string[];
|
||||
tone?: "neutral" | "warning";
|
||||
}) {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-muted/70">
|
||||
{title}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="flex gap-1.5 text-xs leading-relaxed text-fg/80"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-[7px] h-1 w-1 shrink-0 rounded-full",
|
||||
tone === "warning" ? "bg-gold" : "bg-muted/50",
|
||||
)}
|
||||
/>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlanPanel({ plan }: { plan?: Plan | null }) {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const activePlan = plan ?? mockActivePlan;
|
||||
|
||||
const steps = activePlan.steps;
|
||||
const total = steps.length;
|
||||
const done = steps.filter(
|
||||
(s) => s.status === "completed" || s.status === "skipped",
|
||||
).length;
|
||||
const ratio = total > 0 ? done / total : 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
{/* 状态 + 进度 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge tone={PLAN_STATUS_TONE[activePlan.status]}>
|
||||
{PLAN_STATUS_LABEL[activePlan.status]}
|
||||
</Badge>
|
||||
<Badge tone="neutral" className="text-[10px]">
|
||||
模拟
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-muted">
|
||||
{done}/{total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-fg/10">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-gradient-to-r from-patina to-gold"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${Math.round(ratio * 100)}%` }}
|
||||
transition={{ duration: 0.7, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 步骤时间线 */}
|
||||
<motion.ul
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
variants={{
|
||||
hidden: {},
|
||||
show: { transition: { staggerChildren: 0.06 } },
|
||||
}}
|
||||
>
|
||||
{steps.map((step, i) => (
|
||||
<motion.li
|
||||
key={step.id}
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 6 },
|
||||
show: { opacity: 1, y: 0 },
|
||||
}}
|
||||
>
|
||||
<StepRow step={step} last={i === steps.length - 1} />
|
||||
</motion.li>
|
||||
))}
|
||||
</motion.ul>
|
||||
|
||||
{/* 计划详情(折叠) */}
|
||||
<div className="border-t border-border-glass pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDetails((v) => !v)}
|
||||
className="flex w-full items-center justify-between text-xs font-medium text-muted transition-colors hover:text-fg"
|
||||
>
|
||||
<span>计划详情</span>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"transition-transform duration-200",
|
||||
showDetails && "rotate-180",
|
||||
)}
|
||||
>
|
||||
<path
|
||||
d="M3 4.5l3 3 3-3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{showDetails && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeInOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="space-y-3 pt-3">
|
||||
{activePlan.required_tools.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="mr-1 text-[11px] font-semibold uppercase tracking-wide text-muted/70">
|
||||
依赖工具
|
||||
</span>
|
||||
{activePlan.required_tools.map((t) => (
|
||||
<Badge key={t} tone="info" className="text-[10px]">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<DetailSection title="假设" items={activePlan.assumptions} />
|
||||
<DetailSection
|
||||
title="风险"
|
||||
items={activePlan.risks}
|
||||
tone="warning"
|
||||
/>
|
||||
<DetailSection
|
||||
title="成功标准"
|
||||
items={activePlan.success_criteria}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对话页右侧计划侧栏:lg 以上常驻,可折叠为窄竖条。
|
||||
* 小屏隐藏(移动端对话优先),后端计划 API 落地后可改为抽屉式。
|
||||
*/
|
||||
export function PlanAside({
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label="展开计划面板"
|
||||
className="hidden w-11 shrink-0 items-center justify-center border-l border-border-glass text-muted transition-colors hover:bg-fg/5 hover:text-fg lg:flex"
|
||||
>
|
||||
<span
|
||||
className="flex flex-col items-center gap-2 text-[11px]"
|
||||
style={{ writingMode: "vertical-rl" }}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M9 3l-4 4 4 4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
洛书·计划
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hidden w-80 shrink-0 flex-col border-l border-border-glass lg:flex">
|
||||
<div className="flex items-center justify-between border-b border-border-glass px-4 py-2.5">
|
||||
<span className="font-serif text-sm font-semibold">洛书·计划</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label="收起计划面板"
|
||||
className="rounded-md p-1 text-muted transition-colors hover:bg-fg/5 hover:text-fg"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M10 3l-5 5 5 5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<PlanPanel />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { readSandboxFile } from "@/lib/api/sandbox";
|
||||
import { useChatStore } from "@/lib/stores/chat-store";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import { MarkdownContent } from "@/components/chat/MarkdownContent";
|
||||
|
||||
type ViewMode = "source" | "preview";
|
||||
|
||||
interface SandboxFilePreviewProps {
|
||||
/** 文件路径(write_file 传入的 path) */
|
||||
path: string;
|
||||
/** 关联的 run_id(可选) */
|
||||
runId?: string;
|
||||
/** 显示的文件名(默认取 path 末尾) */
|
||||
fileName?: string;
|
||||
/** 是否默认展开 */
|
||||
defaultOpen?: boolean;
|
||||
/** 是否自动按 isolated_workspace 规则补全路径前缀(默认 true) */
|
||||
isolated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 沙箱文件预览组件。
|
||||
*
|
||||
* 双模式切换(源码 / 渲染):
|
||||
* - HTML 文件 → iframe sandbox 渲染预览 + 源码切换
|
||||
* - 图片(SVG/PNG/JPG)→ img 展示 + 源码切换
|
||||
* - 文本/代码 → 源码高亮
|
||||
* - 其他 → 源码文本
|
||||
*
|
||||
* 样式遵循琉璃镜设计语言,与 ToolCallBlock / ThoughtBlock 视觉一致。
|
||||
*/
|
||||
export function SandboxFilePreview({
|
||||
path,
|
||||
runId,
|
||||
fileName,
|
||||
defaultOpen = false,
|
||||
isolated = true,
|
||||
}: SandboxFilePreviewProps) {
|
||||
const characterId = useChatStore((s) => s.characterId);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const [mimeType, setMimeType] = useState("");
|
||||
const [sourceText, setSourceText] = useState("");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("preview");
|
||||
|
||||
const displayName = fileName || path.split("/").pop() || path;
|
||||
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
||||
|
||||
// 可渲染的文件类型
|
||||
const canRender =
|
||||
mimeType === "text/html" ||
|
||||
mimeType.startsWith("image/") ||
|
||||
mimeType === "application/pdf" ||
|
||||
mimeType === "text/markdown" ||
|
||||
ext === "svg" ||
|
||||
ext === "md" ||
|
||||
ext === "markdown";
|
||||
|
||||
// 首次打开时加载文件
|
||||
useEffect(() => {
|
||||
if (!open || objectUrl) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const blob = await readSandboxFile(path, {
|
||||
runId,
|
||||
characterId: characterId ?? undefined,
|
||||
isolated,
|
||||
});
|
||||
if (cancelled) return;
|
||||
const mt = blob.type || "text/plain";
|
||||
setMimeType(mt);
|
||||
const url = URL.createObjectURL(blob);
|
||||
setObjectUrl(url);
|
||||
|
||||
// 预读文本内容供源码模式使用(仅文本类文件)
|
||||
if (mt.startsWith("text/") || !mt || ext === "svg") {
|
||||
const text = await blob.text();
|
||||
if (!cancelled) setSourceText(text);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "读取文件失败");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, path, runId, characterId, isolated, objectUrl, ext]);
|
||||
|
||||
// 进入预览模式时如有文本内容则尝试更新
|
||||
const previewUrl = objectUrl;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
setObjectUrl(null);
|
||||
}
|
||||
setOpen(false);
|
||||
setSourceText("");
|
||||
setMimeType("");
|
||||
}, [objectUrl]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (open) {
|
||||
handleClose();
|
||||
} else {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [open, handleClose]);
|
||||
|
||||
return (
|
||||
<div className="mt-2 overflow-hidden rounded-lg border border-[var(--border-glass)] bg-fg/[0.02]">
|
||||
{/* 顶栏 */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-[var(--border-glass)] px-3 py-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{/* 文件图标 */}
|
||||
<FileIcon ext={ext} />
|
||||
{/* 文件名 */}
|
||||
<span className="truncate font-mono text-[11px] text-fg/70">
|
||||
{displayName}
|
||||
</span>
|
||||
{/* 模式切换(仅可渲染文件且已加载时显示) */}
|
||||
{open && canRender && objectUrl && (
|
||||
<div className="flex items-center gap-0.5 rounded bg-fg/5 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("source")}
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-[10px] transition-colors",
|
||||
viewMode === "source"
|
||||
? "bg-patina text-white"
|
||||
: "text-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
{"<"}源码{">"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("preview")}
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-[10px] transition-colors",
|
||||
viewMode === "preview"
|
||||
? "bg-patina text-white"
|
||||
: "text-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
◉ 预览
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
className="shrink-0 text-[11px] text-patina transition-opacity hover:opacity-80"
|
||||
>
|
||||
{open ? "关闭" : "预览"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
{open && (
|
||||
<div className="max-h-[55vh] overflow-auto">
|
||||
{/* 加载态 */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center px-4 py-6">
|
||||
<div className="flex items-center gap-2 text-xs text-muted/60">
|
||||
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-muted/30 border-t-patina" />
|
||||
加载中…
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误态 */}
|
||||
{error && (
|
||||
<div className="px-3 py-2 text-xs text-cinnabar">{error}</div>
|
||||
)}
|
||||
|
||||
{/* 源码模式 */}
|
||||
{viewMode === "source" && !loading && sourceText && (
|
||||
<pre className="overflow-x-auto p-3 font-mono text-[11px] leading-relaxed text-fg/80">
|
||||
{sourceText}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* 预览模式 */}
|
||||
{viewMode === "preview" && !loading && previewUrl && (
|
||||
<>
|
||||
{mimeType === "text/html" ? (
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals"
|
||||
className="h-[400px] w-full border-0"
|
||||
title={displayName}
|
||||
/>
|
||||
) : mimeType.startsWith("image/") || ext === "svg" ? (
|
||||
<div className="flex items-center justify-center bg-fg/[0.02] p-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={displayName}
|
||||
className="max-h-[50vh] max-w-full rounded object-contain shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
) : mimeType === "application/pdf" ? (
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
className="h-[400px] w-full border-0"
|
||||
title={displayName}
|
||||
/>
|
||||
) : ext === "md" ||
|
||||
ext === "markdown" ||
|
||||
mimeType === "text/markdown" ? (
|
||||
<div className="p-3">
|
||||
<MarkdownContent content={sourceText} />
|
||||
</div>
|
||||
) : (
|
||||
<pre className="overflow-x-auto p-3 font-mono text-[11px] leading-relaxed text-fg/80">
|
||||
{sourceText || "(不支持预览此文件类型)"}
|
||||
</pre>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 空内容 */}
|
||||
{!loading && !error && !objectUrl && !sourceText && (
|
||||
<div className="px-3 py-4 text-center text-xs text-muted/50">
|
||||
(空内容)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 根据扩展名返回文件图标 */
|
||||
function FileIcon({ ext }: { ext: string }) {
|
||||
const icon = EXT_ICONS[ext] ?? EXT_ICONS._default;
|
||||
return (
|
||||
<span className="shrink-0 text-muted/60" aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const EXT_ICONS: Record<string, string> = {
|
||||
html: "🌐",
|
||||
htm: "🌐",
|
||||
svg: "🎨",
|
||||
png: "🖼",
|
||||
jpg: "🖼",
|
||||
jpeg: "🖼",
|
||||
gif: "🖼",
|
||||
webp: "🖼",
|
||||
pdf: "📄",
|
||||
json: "📋",
|
||||
yaml: "📋",
|
||||
yml: "📋",
|
||||
toml: "📋",
|
||||
md: "📝",
|
||||
markdown: "📝",
|
||||
py: "🐍",
|
||||
js: "📜",
|
||||
ts: "📘",
|
||||
tsx: "⚛️",
|
||||
jsx: "⚛️",
|
||||
css: "🎨",
|
||||
scss: "🎨",
|
||||
rs: "🦀",
|
||||
go: "🔷",
|
||||
sh: "💻",
|
||||
bash: "💻",
|
||||
txt: "📄",
|
||||
csv: "📊",
|
||||
xml: "📋",
|
||||
_default: "📄",
|
||||
};
|
||||
@@ -1,232 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import { mockSessionPlan } from "@/lib/mock/plans";
|
||||
import type { Plan, PlanStep, PlanStepStatus } from "@/types/plan";
|
||||
|
||||
/**
|
||||
* 会话级计划卡片:贯穿整个对话的 AI 目标 todo 清单。
|
||||
*
|
||||
* 与 per-message 的「思考过程」(ThoughtBlock / reasoning)并列但职责不同——思考
|
||||
* 过程是单条回复的内部推演,本卡片是整个会话共享的计划进度,随对话推进持续刷新
|
||||
* 步骤状态(pending → running → completed)。
|
||||
*
|
||||
* ⚠️ 当前未在对话页挂载——implementation-status.md §8 暂不实现自动长期任务规划,AI 单次
|
||||
* 回复内的推演已由回复气泡的「思考过程」承担。本组件作为前瞻原型保留:待后端
|
||||
* plan.* 运行事件落地(planner-design.md §5)后,于「AI 实际产出计划」时挂载,
|
||||
* 无计划则不渲染。步骤推进现为 Mock 演示(定时器模拟),落地后替换为 store / SSE
|
||||
* 订阅,渲染层无需改动。
|
||||
*/
|
||||
|
||||
const STEP_LABEL: Record<PlanStepStatus, string> = {
|
||||
pending: "待执行",
|
||||
running: "进行中",
|
||||
waiting_confirmation: "等待确认",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
skipped: "已跳过",
|
||||
};
|
||||
|
||||
function StepIcon({ status }: { status: PlanStepStatus }) {
|
||||
if (status === "running") {
|
||||
return (
|
||||
<span className="relative flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-patina/40" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-patina" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "waiting_confirmation") {
|
||||
return (
|
||||
<span className="h-4 w-4 shrink-0 rounded-full border-2 border-gilt bg-gilt/20" />
|
||||
);
|
||||
}
|
||||
if (status === "completed") {
|
||||
return (
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-patina text-white">
|
||||
<svg width="9" height="9" viewBox="0 0 12 12" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M2.5 6.2l2.3 2.3L9.5 3.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// pending / failed / skipped:空心环
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 rounded-full border-2 bg-bg",
|
||||
status === "failed" ? "border-cinnabar" : "border-muted/40",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 推进一步:当前 running → completed,下一个 pending → running */
|
||||
function advance(steps: PlanStep[]): PlanStep[] {
|
||||
const next = steps.map((s) => ({ ...s }));
|
||||
const runningIdx = next.findIndex((s) => s.status === "running");
|
||||
if (runningIdx === -1) return next;
|
||||
next[runningIdx] = { ...next[runningIdx], status: "completed" };
|
||||
const nextPending = next.findIndex((s) => s.status === "pending");
|
||||
if (nextPending !== -1) {
|
||||
next[nextPending] = { ...next[nextPending], status: "running" };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function SessionPlanCard({
|
||||
plan = mockSessionPlan,
|
||||
}: {
|
||||
plan?: Plan | null;
|
||||
}) {
|
||||
const [steps, setSteps] = useState<PlanStep[]>(plan?.steps ?? []);
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
// Mock:定时推进步骤状态,演示「计划随对话刷新」。无待执行步骤后自动停止。
|
||||
// 真实场景由后端 plan.* 事件驱动,届时移除此 effect。
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setSteps((prev) => {
|
||||
const hasActive = prev.some(
|
||||
(s) => s.status === "running" || s.status === "pending",
|
||||
);
|
||||
if (!hasActive) return prev; // 引用不变,不触发渲染
|
||||
return advance(prev);
|
||||
});
|
||||
}, 4000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
if (!plan) return null;
|
||||
|
||||
const total = steps.length;
|
||||
const done = steps.filter(
|
||||
(s) => s.status === "completed" || s.status === "skipped",
|
||||
).length;
|
||||
const ratio = total > 0 ? done / total : 0;
|
||||
const allDone = total > 0 && done === total;
|
||||
|
||||
return (
|
||||
<div className="glass-strong border-b border-border-glass">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-2.5 px-4 py-2 text-left transition-colors hover:bg-fg/[0.02]"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="text-gold">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
>
|
||||
<rect
|
||||
x="2"
|
||||
y="2"
|
||||
width="12"
|
||||
height="12"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
<path
|
||||
d="M5.5 8h5M8 5.5v5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-xs font-medium text-fg/90">当前计划</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-muted">
|
||||
{allDone ? "已完成" : "陪伴散人梳理此刻心绪"}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-muted">
|
||||
{done}/{total}
|
||||
</span>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"text-muted transition-transform duration-200",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
>
|
||||
<path
|
||||
d="M3 4.5l3 3 3-3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 进度条(始终展示) */}
|
||||
<div className="px-4 pb-2">
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-fg/10">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-gradient-to-r from-patina to-gold"
|
||||
initial={false}
|
||||
animate={{ width: `${Math.round(ratio * 100)}%` }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<motion.ul
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeInOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<li className="space-y-1.5 px-4 pb-3">
|
||||
{steps.map((step) => {
|
||||
const active = step.status === "running";
|
||||
return (
|
||||
<div key={step.id} className="flex items-center gap-2.5">
|
||||
<StepIcon status={step.status} />
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs",
|
||||
step.status === "completed"
|
||||
? "text-muted"
|
||||
: active
|
||||
? "font-medium text-patina"
|
||||
: "text-fg/85",
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</span>
|
||||
{active && (
|
||||
<span className="text-[10px] text-patina/80">
|
||||
{STEP_LABEL[step.status]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</li>
|
||||
</motion.ul>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { SessionSidebar } from "./SessionSidebar";
|
||||
import { useChatStore } from "@/lib/stores/chat-store";
|
||||
import type { ChatSession } from "@/types/api";
|
||||
|
||||
function makeSession(overrides: Partial<ChatSession> = {}): ChatSession {
|
||||
return {
|
||||
session_id: "s1",
|
||||
character_id: "c1",
|
||||
title: undefined,
|
||||
status: "active",
|
||||
created_at: "2026-06-27T00:00:00Z",
|
||||
updated_at: "2026-06-27T00:00:00Z",
|
||||
...overrides,
|
||||
} as ChatSession;
|
||||
}
|
||||
|
||||
describe("SessionSidebar 会话列表过滤", () => {
|
||||
beforeEach(() => {
|
||||
useChatStore.setState({ sessions: [], sessionId: null });
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("当前活跃会话即使无标题也显示(占位「未命名会话」)", () => {
|
||||
useChatStore.setState({
|
||||
sessions: [makeSession({ session_id: "active-empty", title: undefined })],
|
||||
sessionId: "active-empty",
|
||||
});
|
||||
render(<SessionSidebar />);
|
||||
expect(screen.getByText("未命名会话")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("非当前的无标题会话仍被隐藏,避免空会话污染列表", () => {
|
||||
useChatStore.setState({
|
||||
sessions: [
|
||||
makeSession({ session_id: "active-empty", title: undefined }),
|
||||
makeSession({ session_id: "other-empty", title: undefined }),
|
||||
],
|
||||
sessionId: "active-empty",
|
||||
});
|
||||
render(<SessionSidebar />);
|
||||
// 仅当前活跃空会话一条
|
||||
expect(screen.getAllByText("未命名会话")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("有标题的会话正常显示真实标题", () => {
|
||||
useChatStore.setState({
|
||||
sessions: [makeSession({ session_id: "s-titled", title: "你好" })],
|
||||
sessionId: "s-titled",
|
||||
});
|
||||
render(<SessionSidebar />);
|
||||
expect(screen.getByText("你好")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,568 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { chatApi } from "@/lib/api";
|
||||
import { useChatStore } from "@/lib/stores/chat-store";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import type { ChatSession } from "@/types/api";
|
||||
|
||||
/** 会话按时间分组:今日 / 本周 / 更早 / 归藏(设计稿 session-group 同源)。 */
|
||||
function groupSessionsByTime(
|
||||
sessions: ChatSession[],
|
||||
): { label: string; items: ChatSession[] }[] {
|
||||
const startOfToday = new Date();
|
||||
startOfToday.setHours(0, 0, 0, 0);
|
||||
const todayMs = startOfToday.getTime();
|
||||
const weekMs = todayMs - 6 * 86_400_000;
|
||||
const buckets: { label: string; items: ChatSession[] }[] = [
|
||||
{ label: "今日", items: [] },
|
||||
{ label: "本周", items: [] },
|
||||
{ label: "更早", items: [] },
|
||||
{ label: "归藏", items: [] },
|
||||
];
|
||||
for (const s of sessions) {
|
||||
if (s.status === "archived") {
|
||||
buckets[3].items.push(s);
|
||||
continue;
|
||||
}
|
||||
const t = new Date(s.updated_at ?? s.created_at).getTime();
|
||||
if (Number.isNaN(t) || t < weekMs) buckets[2].items.push(s);
|
||||
else if (t >= todayMs) buckets[0].items.push(s);
|
||||
else buckets[1].items.push(s);
|
||||
}
|
||||
return buckets.filter((b) => b.items.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 铭文册 · 历史会话。
|
||||
*
|
||||
* 嵌入镜背轨时只渲染内容,不另占一条侧栏;操作菜单在列表内展开,删除确认通过
|
||||
* portal 挂到 #chat-region,因此遮罩与弹窗都被限制在对话区域内。
|
||||
*/
|
||||
export function SessionSidebar({
|
||||
onCreate,
|
||||
creating,
|
||||
collapsed = false,
|
||||
characterNameMap = {},
|
||||
onSessionSelect,
|
||||
newSessionIds = [],
|
||||
embedded = false,
|
||||
}: {
|
||||
onCreate?: () => void;
|
||||
creating?: boolean;
|
||||
collapsed?: boolean;
|
||||
characterNameMap?: Record<string, string>;
|
||||
onSessionSelect?: (sessionId: string, characterId: string) => void;
|
||||
newSessionIds?: string[];
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
const sessions = useChatStore((s) => s.sessions);
|
||||
const sessionId = useChatStore((s) => s.sessionId);
|
||||
const switchSession = useChatStore((s) => s.switchSession);
|
||||
const patchSession = useChatStore((s) => s.patchSession);
|
||||
const removeSession = useChatStore((s) => s.removeSession);
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [draftTitle, setDraftTitle] = useState("");
|
||||
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ChatSession | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpenId) return;
|
||||
function onDown(e: MouseEvent) {
|
||||
if (!(e.target as HTMLElement).closest("[data-session-menu]")) {
|
||||
setMenuOpenId(null);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [menuOpenId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deleteTarget) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setDeleteTarget(null);
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [deleteTarget]);
|
||||
|
||||
async function saveRename(id: string) {
|
||||
const title = draftTitle.trim();
|
||||
setEditingId(null);
|
||||
if (!title) return;
|
||||
setBusyId(id);
|
||||
try {
|
||||
const updated = await chatApi.updateSession(id, { title });
|
||||
patchSession(id, updated);
|
||||
} catch {
|
||||
/* 保留原标题 */
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleArchive(session: ChatSession) {
|
||||
setMenuOpenId(null);
|
||||
const next = session.status === "archived" ? "active" : "archived";
|
||||
setBusyId(session.session_id);
|
||||
try {
|
||||
const updated = await chatApi.updateSession(session.session_id, {
|
||||
status: next,
|
||||
});
|
||||
patchSession(session.session_id, updated);
|
||||
} catch {
|
||||
/* 保留原状态 */
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return;
|
||||
const id = deleteTarget.session_id;
|
||||
setBusyId(id);
|
||||
try {
|
||||
await chatApi.deleteSession(id);
|
||||
const otherSessions = sessions.filter(
|
||||
(session) => session.session_id !== id,
|
||||
);
|
||||
removeSession(id);
|
||||
if (id === sessionId) {
|
||||
const next = otherSessions.find(
|
||||
(session) => session.status !== "archived",
|
||||
);
|
||||
if (next) {
|
||||
switchSession(next.session_id);
|
||||
} else {
|
||||
switchSession(null);
|
||||
onCreate?.();
|
||||
}
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
/* 删除失败时保留弹窗,允许重试 */
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = sessions
|
||||
.filter((session) => session.title || session.session_id === sessionId)
|
||||
.sort((a, b) => {
|
||||
if ((a.status === "archived") !== (b.status === "archived")) {
|
||||
return a.status === "archived" ? 1 : -1;
|
||||
}
|
||||
return (b.updated_at ?? b.created_at).localeCompare(
|
||||
a.updated_at ?? a.created_at,
|
||||
);
|
||||
});
|
||||
|
||||
// 底部统计卡:本月铭文数 / 活跃人物数(纯前端从会话列表推算,免新增后端接口)
|
||||
const statsNow = new Date();
|
||||
const statsMonthPrefix = `${statsNow.getFullYear()}-${String(
|
||||
statsNow.getMonth() + 1,
|
||||
).padStart(2, "0")}`;
|
||||
let monthCount = 0;
|
||||
const activeChars = new Set<string>();
|
||||
for (const s of sessions) {
|
||||
if (s.status === "archived") continue;
|
||||
if ((s.updated_at ?? s.created_at ?? "").startsWith(statsMonthPrefix)) {
|
||||
monthCount++;
|
||||
}
|
||||
if (s.character_id) activeChars.add(s.character_id);
|
||||
}
|
||||
const activeCharCount = activeChars.size;
|
||||
|
||||
const content = (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-rule px-3 py-2.5">
|
||||
<span className="mono text-[10px] uppercase tracking-[0.2em] text-fg-faint">
|
||||
铭文 · 往来
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
disabled={!onCreate || creating}
|
||||
className="inline-flex items-center rounded border border-rule px-2 py-1 text-[10px] text-fg-faint transition-colors hover:border-gilt-dim hover:text-fg disabled:opacity-50"
|
||||
>
|
||||
{creating ? "启镜中" : "+ 新启"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 py-2 flex flex-col">
|
||||
{sorted.length === 0 ? (
|
||||
<p className="px-2 py-6 text-center text-[11px] text-fg-faint">
|
||||
尚无往来铭文
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{groupSessionsByTime(sorted).map((group) => (
|
||||
<section key={group.label} className="flex flex-col">
|
||||
<p className="mono px-2 pb-1 pt-1 text-[9px] uppercase tracking-[0.12em] text-fg-faint">
|
||||
{group.label}
|
||||
</p>
|
||||
<ul className="flex flex-col gap-px">
|
||||
{group.items.map((session) => {
|
||||
const active = session.session_id === sessionId;
|
||||
const archived = session.status === "archived";
|
||||
const isEditing = editingId === session.session_id;
|
||||
const menuOpen = menuOpenId === session.session_id;
|
||||
const isNew = newSessionIds.includes(session.session_id);
|
||||
|
||||
return (
|
||||
<li key={session.session_id}>
|
||||
{isEditing ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draftTitle}
|
||||
onChange={(event) =>
|
||||
setDraftTitle(event.target.value)
|
||||
}
|
||||
onBlur={() => saveRename(session.session_id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter")
|
||||
saveRename(session.session_id);
|
||||
if (event.key === "Escape") setEditingId(null);
|
||||
}}
|
||||
className="w-full rounded border border-patina-dim bg-glass px-2 py-1.5 text-sm text-fg outline-none"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"group rounded px-2 py-1.5 transition-colors",
|
||||
active ? "bg-patina-wash" : "hover:bg-fg/5",
|
||||
archived && !active && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSessionSelect
|
||||
? onSessionSelect(
|
||||
session.session_id,
|
||||
session.character_id,
|
||||
)
|
||||
: switchSession(session.session_id)
|
||||
}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
disabled={busyId === session.session_id}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{/* s-dot 状态色点:新消息朱砂(脉冲) / 当前铜绿 / 归藏灰暗 / 其余静灰 */}
|
||||
{isNew ? (
|
||||
<span
|
||||
className="relative flex h-1.5 w-1.5 shrink-0"
|
||||
title="新会话"
|
||||
>
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-cinnabar/60" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-cinnabar" />
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 shrink-0 rounded-full",
|
||||
active
|
||||
? "bg-patina"
|
||||
: archived
|
||||
? "bg-fg-faint/40"
|
||||
: "bg-fg-faint/60",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-[13px]",
|
||||
active
|
||||
? "font-medium text-patina"
|
||||
: "text-fg-dim",
|
||||
)}
|
||||
>
|
||||
{session.title || "未命名会话"}
|
||||
</span>
|
||||
{archived && (
|
||||
<span className="shrink-0 rounded bg-fg/10 px-1 text-[8px] text-fg-faint">
|
||||
归藏
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="mono mt-0.5 flex items-center gap-1.5 text-[9px] text-fg-faint">
|
||||
{characterNameMap[session.character_id] && (
|
||||
<span className="max-w-[70px] truncate">
|
||||
{characterNameMap[session.character_id]}
|
||||
</span>
|
||||
)}
|
||||
{(
|
||||
session.updated_at ?? session.created_at
|
||||
).slice(0, 10)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div data-session-menu className="shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
title="会话操作"
|
||||
aria-label="会话操作"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() =>
|
||||
setMenuOpenId(
|
||||
menuOpen ? null : session.session_id,
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"flex h-5 w-5 items-center justify-center rounded text-fg-faint transition-colors hover:bg-fg/10 hover:text-fg",
|
||||
menuOpen
|
||||
? "bg-fg/10 text-fg"
|
||||
: "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100",
|
||||
)}
|
||||
>
|
||||
<MoreIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内联操作,不越出历史会话滚动区域 */}
|
||||
{menuOpen && (
|
||||
<div
|
||||
data-session-menu
|
||||
role="menu"
|
||||
className="mt-1 grid grid-cols-3 gap-px border-t border-rule pt-1"
|
||||
>
|
||||
<MenuItem
|
||||
icon={<PencilIcon />}
|
||||
label="重命名"
|
||||
onClick={() => {
|
||||
setMenuOpenId(null);
|
||||
setEditingId(session.session_id);
|
||||
setDraftTitle(session.title ?? "");
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<ArchiveIcon />}
|
||||
label={archived ? "复归" : "归藏"}
|
||||
onClick={() => toggleArchive(session)}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<TrashIcon />}
|
||||
label="删除"
|
||||
danger
|
||||
onClick={() => {
|
||||
setMenuOpenId(null);
|
||||
setDeleteTarget(session);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部统计:本月铭文 / 活跃人物(前端从会话列表推算) */}
|
||||
<div className="grid shrink-0 grid-cols-2 gap-2 border-t border-rule px-3 py-2">
|
||||
<div className="rounded-[5px] border border-rule bg-surface px-2.5 py-2">
|
||||
<div className="text-[9px] uppercase tracking-[0.1em] text-fg-faint">
|
||||
本月铭文
|
||||
</div>
|
||||
<div className="mt-0.5 font-serif text-base font-semibold text-fg">
|
||||
{monthCount}
|
||||
<small className="ml-0.5 text-[10px] font-normal text-fg-dim">
|
||||
通
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[5px] border border-rule bg-surface px-2.5 py-2">
|
||||
<div className="text-[9px] uppercase tracking-[0.1em] text-fg-faint">
|
||||
活跃人物
|
||||
</div>
|
||||
<div className="mt-0.5 font-serif text-base font-semibold text-fg">
|
||||
{activeCharCount}
|
||||
<small className="ml-0.5 text-[10px] font-normal text-fg-dim">
|
||||
位
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const dialogHost =
|
||||
typeof document === "undefined"
|
||||
? null
|
||||
: document.getElementById("chat-region");
|
||||
|
||||
const deleteDialog =
|
||||
deleteTarget && dialogHost
|
||||
? createPortal(
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center overflow-hidden p-4">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭删除确认"
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
className="absolute inset-0 cursor-default bg-black/55 backdrop-blur-sm"
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-session-title"
|
||||
className="mirror-face relative max-h-[calc(100%-2rem)] w-full max-w-sm overflow-y-auto rounded p-5 shadow-2xl"
|
||||
>
|
||||
<p
|
||||
id="delete-session-title"
|
||||
className="font-serif text-base font-semibold text-fg"
|
||||
>
|
||||
擦除铭文
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-relaxed text-fg-dim">
|
||||
确定删除「{deleteTarget.title || "未命名会话"}
|
||||
」吗?此操作无法撤销。
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
className="rounded border border-rule bg-surface px-3 py-1.5 text-xs text-fg-dim hover:bg-surface-hi"
|
||||
>
|
||||
保留
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmDelete}
|
||||
disabled={busyId === deleteTarget.session_id}
|
||||
className="rounded border border-cinnabar-dim bg-cinnabar/10 px-3 py-1.5 text-xs text-cinnabar hover:bg-cinnabar/20 disabled:opacity-50"
|
||||
>
|
||||
{busyId === deleteTarget.session_id ? "擦除中…" : "确认擦除"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
dialogHost,
|
||||
)
|
||||
: null;
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<>
|
||||
{content}
|
||||
{deleteDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
className={cn(
|
||||
"shrink-0 border-r border-rule bg-sidebar",
|
||||
collapsed ? "hidden" : "hidden md:flex md:w-56 md:flex-col",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</aside>
|
||||
{deleteDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
icon,
|
||||
label,
|
||||
danger,
|
||||
onClick,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
danger?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex min-w-0 items-center justify-center gap-1 rounded px-1 py-1 text-[9px] transition-colors",
|
||||
danger
|
||||
? "text-cinnabar hover:bg-cinnabar/10"
|
||||
: "text-fg-faint hover:bg-fg/5 hover:text-fg-dim",
|
||||
)}
|
||||
>
|
||||
<span className="flex h-3 w-3 shrink-0 items-center justify-center">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MoreIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<circle cx="3.5" cy="8" r="1.2" fill="currentColor" />
|
||||
<circle cx="8" cy="8" r="1.2" fill="currentColor" />
|
||||
<circle cx="12.5" cy="8" r="1.2" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PencilIcon() {
|
||||
return (
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M11.5 2.5l2 2-8 8H3.5v-2l8-8z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ArchiveIcon() {
|
||||
return (
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<rect
|
||||
x="2.5"
|
||||
y="3"
|
||||
width="11"
|
||||
height="2.5"
|
||||
rx="0.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
<path
|
||||
d="M3.5 5.5v8a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1v-8M6.5 8.5h3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TrashIcon() {
|
||||
return (
|
||||
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M3 4.5h10M6.5 4.5V3a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1.5M4.5 4.5l.5 8a1 1 0 0 0 1 .9h4a1 1 0 0 0 1-.9l.5-8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ThoughtBlock } from "./ThoughtBlock";
|
||||
|
||||
describe("ThoughtBlock", () => {
|
||||
it("流式中渲染思考内容", () => {
|
||||
render(<ThoughtBlock reasoning="正在思考…" streaming />);
|
||||
expect(screen.getByText("正在思考…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("流式中默认展开", () => {
|
||||
render(<ThoughtBlock reasoning="思考中" streaming />);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(btn).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("完成后默认折叠", () => {
|
||||
render(<ThoughtBlock reasoning="已完成思考" />);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(btn).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("点击展开/收起切换折叠状态", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ThoughtBlock reasoning="可折叠的内容" />);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(btn).toHaveAttribute("aria-expanded", "false");
|
||||
await user.click(btn);
|
||||
expect(btn).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
/**
|
||||
* 思考过程块(reasoning / thinking summary):可折叠展示模型的内省摘要。
|
||||
*
|
||||
* - 流式进行中默认展开、图标脉冲;
|
||||
* - 完成后默认折叠,用户可手动展开/收起;
|
||||
* - 内容为纯文本(与正文 Markdown 区分),柔和 muted 色。
|
||||
*
|
||||
* 语义对齐 docs/architecture/service-boundaries.md §11.4
|
||||
* (thinking display: summarized);raw CoT 不持久化,故仅承接流式临时态。
|
||||
*/
|
||||
export function ThoughtBlock({
|
||||
reasoning,
|
||||
streaming,
|
||||
}: {
|
||||
reasoning: string;
|
||||
streaming?: boolean;
|
||||
}) {
|
||||
// 流式中默认展开;已完成(非流式)默认折叠
|
||||
const [open, setOpen] = useState(streaming ?? false);
|
||||
|
||||
return (
|
||||
<div className="mb-2 overflow-hidden rounded-xl border border-border-glass bg-fg/[0.03]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-fg/[0.02]"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-gold transition-opacity",
|
||||
streaming && "animate-pulse",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M8 1.5A4 4 0 0 0 5.2 8.4c.3.4.5.9.5 1.4v.2a1 1 0 0 0 1 1h2.6a1 1 0 0 0 1-1v-.2c0-.5.2-1 .5-1.4A4 4 0 0 0 8 1.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6.5 13h3M7 14.5h2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-xs font-medium text-muted">
|
||||
{streaming ? "思考中…" : "思考过程"}
|
||||
</span>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"ml-auto text-muted transition-transform duration-200",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
>
|
||||
<path
|
||||
d="M3 4.5l3 3 3-3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ scaleY: 0, opacity: 0 }}
|
||||
animate={{ scaleY: 1, opacity: 1 }}
|
||||
exit={{ scaleY: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className="origin-top overflow-hidden"
|
||||
>
|
||||
<div className="border-t border-border-glass px-3 py-2">
|
||||
<p className="whitespace-pre-wrap text-xs leading-relaxed text-muted">
|
||||
{reasoning || (streaming ? "…" : "")}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/tools/ToolConfirmationCard", () => ({
|
||||
ToolConfirmationCard: () => <div data-testid="confirmation-card" />,
|
||||
}));
|
||||
|
||||
import { ToolCallBlock } from "./ToolCallBlock";
|
||||
|
||||
describe("ToolCallBlock", () => {
|
||||
it("running 状态显示工具名和状态文案", () => {
|
||||
render(
|
||||
<ToolCallBlock
|
||||
toolCall={{
|
||||
toolName: "web_search",
|
||||
toolCallId: "tc_1",
|
||||
status: "running",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("web_search")).toBeInTheDocument();
|
||||
expect(screen.getByText("执行中")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("success 状态显示已完成", () => {
|
||||
render(
|
||||
<ToolCallBlock
|
||||
toolCall={{
|
||||
toolName: "web_search",
|
||||
toolCallId: "tc_1",
|
||||
status: "success",
|
||||
result: "搜索结果",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("已完成")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("error 状态显示失败", () => {
|
||||
render(
|
||||
<ToolCallBlock
|
||||
toolCall={{
|
||||
toolName: "shell",
|
||||
toolCallId: "tc_2",
|
||||
status: "error",
|
||||
result: "错误信息",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("失败")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("confirming 状态显示确认卡片", () => {
|
||||
render(
|
||||
<ToolCallBlock
|
||||
toolCall={{
|
||||
toolName: "shell",
|
||||
toolCallId: "tc_3",
|
||||
status: "confirming",
|
||||
confirmationId: "confirm_1",
|
||||
riskLevel: "L3",
|
||||
reason: "高风险操作",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("confirmation-card")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("running 时默认展开", () => {
|
||||
render(
|
||||
<ToolCallBlock
|
||||
toolCall={{
|
||||
toolName: "web_search",
|
||||
toolCallId: "tc_4",
|
||||
status: "running",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(btn).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("success 时默认折叠", () => {
|
||||
render(
|
||||
<ToolCallBlock
|
||||
toolCall={{
|
||||
toolName: "web_search",
|
||||
toolCallId: "tc_5",
|
||||
status: "success",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(btn).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
});
|
||||
@@ -1,390 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import { ToolConfirmationCard } from "@/components/tools/ToolConfirmationCard";
|
||||
import { Badge, type BadgeTone } from "@/components/ui/Badge";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import { useChatStore } from "@/lib/stores/chat-store";
|
||||
import type { ToolCallDisplay } from "@/lib/stores/chat-store";
|
||||
|
||||
/** 结果文本展示截断长度(超过则默认折叠) */
|
||||
const RESULT_COLLAPSE_LENGTH = 280;
|
||||
|
||||
/** 工具调用状态 → 徽章色调 + 中文文案 */
|
||||
const STATUS_META: Record<
|
||||
ToolCallDisplay["status"],
|
||||
{ label: string; tone: BadgeTone; pulse?: boolean }
|
||||
> = {
|
||||
running: { label: "执行中", tone: "warning", pulse: true },
|
||||
confirming: { label: "待确认", tone: "warning", pulse: true },
|
||||
success: { label: "已完成", tone: "success" },
|
||||
error: { label: "失败", tone: "danger" },
|
||||
denied: { label: "已拒绝", tone: "danger" },
|
||||
skipped: { label: "已跳过", tone: "neutral" },
|
||||
};
|
||||
|
||||
export interface ToolCallBlockProps {
|
||||
toolCall: ToolCallDisplay;
|
||||
/** 消息所属 run_id(确认端点调用需要) */
|
||||
runId?: string;
|
||||
onApprove?: (
|
||||
runId: string,
|
||||
confirmationId: string,
|
||||
toolCallId: string,
|
||||
) => void;
|
||||
onDeny?: (runId: string, confirmationId: string, toolCallId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具调用块(对话流 tool.* 事件可视化)。结构与 ThoughtBlock 对齐:
|
||||
* 可折叠头(工具名 + 状态徽章)+ 展开态结果文本(截断 + 展开)。
|
||||
*
|
||||
* 状态为 confirming 时,在结果下方嵌入 ToolConfirmationCard(复用)。
|
||||
* 样式遵循琉璃镜(玻璃 + 矿物色),与 ThoughtBlock 视觉一致。
|
||||
*
|
||||
* 状态机:
|
||||
* running(金,脉冲)→ success(铜绿)/ error(朱砂)/ denied(朱砂)/ skipped(灰)
|
||||
* 中间可能进入 confirming(金,脉冲)展示确认卡片,用户操作后回 running。
|
||||
*/
|
||||
export function ToolCallBlock({
|
||||
toolCall,
|
||||
runId,
|
||||
onApprove,
|
||||
onDeny,
|
||||
}: ToolCallBlockProps) {
|
||||
const meta = STATUS_META[toolCall.status] ?? STATUS_META.running;
|
||||
// running / confirming 时默认展开让用户看到实时状态;终态默认折叠
|
||||
const isActive =
|
||||
toolCall.status === "running" || toolCall.status === "confirming";
|
||||
const [open, setOpen] = useState(isActive);
|
||||
|
||||
// 确认卡片需要 confirmation_id;同时需要本地 loading 态(用户点击后等待后端事件)
|
||||
const [resolving, setResolving] = useState(false);
|
||||
|
||||
const handleApprove = () => {
|
||||
if (!runId || !toolCall.confirmationId || !onApprove) return;
|
||||
setResolving(true);
|
||||
onApprove(runId, toolCall.confirmationId, toolCall.toolCallId);
|
||||
// 不在此处清 resolving——后端推 tool.completed 时 toolCall.status 会变化,
|
||||
// 此组件卸载或重渲染。若后端迟迟不推(如 410/503),用户可在错误提示后重试。
|
||||
// 保守起见,等几秒自动解除 loading,避免按钮永久转圈。
|
||||
setTimeout(() => setResolving(false), 4000);
|
||||
};
|
||||
|
||||
const handleDeny = () => {
|
||||
if (!runId || !toolCall.confirmationId || !onDeny) return;
|
||||
setResolving(true);
|
||||
onDeny(runId, toolCall.confirmationId, toolCall.toolCallId);
|
||||
setTimeout(() => setResolving(false), 4000);
|
||||
};
|
||||
|
||||
const hasResult = toolCall.result !== undefined && toolCall.result !== "";
|
||||
const resultLong = (toolCall.result?.length ?? 0) > RESULT_COLLAPSE_LENGTH;
|
||||
const [resultExpanded, setResultExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="mb-2 overflow-hidden rounded-xl border border-border-glass bg-fg/[0.03]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-fg/[0.02]"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-gold transition-opacity",
|
||||
meta.pulse && "animate-pulse",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<WrenchIcon />
|
||||
</span>
|
||||
<span className="text-xs font-medium text-fg/80">
|
||||
{toolCall.toolName || "工具调用"}
|
||||
</span>
|
||||
<Badge tone={meta.tone} className="ml-0.5">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"ml-auto text-muted transition-transform duration-200",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
>
|
||||
<path
|
||||
d="M3 4.5l3 3 3-3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ scaleY: 0, opacity: 0 }}
|
||||
animate={{ scaleY: 1, opacity: 1 }}
|
||||
exit={{ scaleY: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className="origin-top overflow-hidden"
|
||||
>
|
||||
<div className="space-y-2 border-t border-border-glass px-3 py-2">
|
||||
{/* 调用参数:confirming 态由确认卡片承载 args,避免重复 */}
|
||||
{toolCall.args && toolCall.status !== "confirming" && (
|
||||
<ToolCallArgs
|
||||
toolName={toolCall.toolName}
|
||||
args={toolCall.args}
|
||||
/>
|
||||
)}
|
||||
{/* 结果文本(success/error/denied/skipped 才有) */}
|
||||
{hasResult && (
|
||||
<div>
|
||||
<pre
|
||||
className={cn(
|
||||
"whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-muted",
|
||||
resultLong && !resultExpanded && "line-clamp-6",
|
||||
)}
|
||||
>
|
||||
{toolCall.result}
|
||||
</pre>
|
||||
{resultLong && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResultExpanded((v) => !v)}
|
||||
className="mt-1 text-xs text-patina transition-opacity hover:opacity-80"
|
||||
>
|
||||
{resultExpanded ? "收起结果" : "展开结果"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文件预览:write_file 成功时提供独立面板预览入口 */}
|
||||
{toolCall.status === "success" &&
|
||||
toolCall.toolName === "write_file" && (
|
||||
<FilePreviewButton args={toolCall.args} runId={runId} />
|
||||
)}
|
||||
{/* 执行中无结果时显示轻量提示 */}
|
||||
{!hasResult && toolCall.status === "running" && (
|
||||
<p className="text-xs text-muted/70">正在执行…</p>
|
||||
)}
|
||||
|
||||
{/* 确认卡片:仅在 confirming 态且有 confirmationId 时展示 */}
|
||||
{toolCall.status === "confirming" &&
|
||||
toolCall.confirmationId &&
|
||||
toolCall.riskLevel && (
|
||||
<ToolConfirmationCard
|
||||
tool={toolCall.toolName}
|
||||
riskLevel={toolCall.riskLevel}
|
||||
reason={toolCall.reason ?? ""}
|
||||
args={toolCall.args}
|
||||
onApprove={handleApprove}
|
||||
onDeny={handleDeny}
|
||||
loading={resolving}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WrenchIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M11.5 2.5a2.5 2.5 0 0 0-3.4 2.9L3 10.5a1.4 1.4 0 0 0 2 2l5.1-5.1a2.5 2.5 0 0 0 2.9-3.4l-1.7 1.7-1.4-.2-.2-1.4 1.8-1.6Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开全局面板预览沙箱文件。
|
||||
*/
|
||||
function FilePreviewButton({
|
||||
args,
|
||||
runId,
|
||||
}: {
|
||||
args: string | undefined;
|
||||
runId: string | undefined;
|
||||
}) {
|
||||
const openPreviewFile = useChatStore((s) => s.openPreviewFile);
|
||||
const filePath = extractFilePath(args);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
openPreviewFile({ path: filePath, runId });
|
||||
}, [filePath, runId, openPreviewFile]);
|
||||
|
||||
if (!filePath) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
className="inline-flex items-center gap-1.5 rounded border border-border-glass px-2.5 py-1 text-[11px] text-patina transition-colors hover:bg-patina/10"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<rect
|
||||
x="2"
|
||||
y="3"
|
||||
width="12"
|
||||
height="10"
|
||||
rx="1.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<circle cx="6" cy="7.5" r="1" fill="currentColor" />
|
||||
<path
|
||||
d="M2 10l3.5-2.5L9 10l2-1.5 3 2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
预览文件
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 write_file 的 args JSON 中提取文件路径。
|
||||
* args 格式:``{"path": "output/report.html", "content": "..."}``
|
||||
*/
|
||||
function extractFilePath(args: string | undefined): string {
|
||||
if (!args) return "";
|
||||
try {
|
||||
const parsed = JSON.parse(args) as Record<string, unknown>;
|
||||
const p = parsed.path;
|
||||
return typeof p === "string" && p.trim() ? p.trim() : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** 单个入参值的截断长度:超过则折叠为摘要(write_file 的 content 可能极长) */
|
||||
const ARG_VALUE_PREVIEW = 120;
|
||||
|
||||
/** 解析工具调用入参 JSON 字符串;失败或非对象时回退到原始文本展示。 */
|
||||
function parseToolArgs(
|
||||
args: string | undefined,
|
||||
): Record<string, unknown> | null {
|
||||
if (!args) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 把入参值格式化为可读字符串;过长的字符串折叠为「前缀…(共 N 字符)」。 */
|
||||
function previewToolArgValue(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value.length > ARG_VALUE_PREVIEW
|
||||
? `${value.slice(0, ARG_VALUE_PREVIEW)}…(共 ${value.length} 字符)`
|
||||
: value;
|
||||
}
|
||||
if (value === null) return "null";
|
||||
if (typeof value === "object") {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 入参键值对展示(dl/dt/dd)。长值经 previewToolArgValue 折叠。
|
||||
*/
|
||||
function ToolArgKeyValue({ entries }: { entries: [string, unknown][] }) {
|
||||
return (
|
||||
<dl className="mt-1 space-y-0.5 rounded-lg bg-fg/[0.04] p-2 font-mono text-xs leading-relaxed">
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k} className="flex gap-2">
|
||||
<dt className="shrink-0 text-muted">{k}:</dt>
|
||||
<dd className="break-all text-fg/80">{previewToolArgValue(v)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具调用入参展示。所有状态(只要有 args)都显示,让用户能判断 AI 实际调用了什么——
|
||||
* 尤其 shell 工具的 command,否则仅看执行结果无法判断命令是否正确。
|
||||
*
|
||||
* shell:终端样式突出 command(用户最关心的「命令」),其余键(timeout_ms/stdin)附在下方;
|
||||
* 其它工具:键值对形式,长值折叠。
|
||||
*/
|
||||
function ToolCallArgs({
|
||||
toolName,
|
||||
args,
|
||||
}: {
|
||||
toolName: string;
|
||||
args: string | undefined;
|
||||
}) {
|
||||
const parsed = parseToolArgs(args);
|
||||
|
||||
// 解析失败:回退为原始文本(args 为空则不渲染)
|
||||
if (!parsed) {
|
||||
if (!args) return null;
|
||||
return (
|
||||
<div>
|
||||
<span className="text-[11px] font-medium tracking-wide text-muted/60">
|
||||
调用参数
|
||||
</span>
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words rounded-lg bg-fg/[0.04] p-2 font-mono text-xs leading-relaxed text-fg/80">
|
||||
{args}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const entries = Object.entries(parsed);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
// shell:突出命令行,其余参数作为附属键值对
|
||||
if (toolName === "shell" && typeof parsed.command === "string") {
|
||||
const extras = entries.filter(([k]) => k !== "command");
|
||||
return (
|
||||
<div>
|
||||
<span className="text-[11px] font-medium tracking-wide text-muted/60">
|
||||
命令
|
||||
</span>
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words rounded-lg border-l-2 border-patina/50 bg-fg/[0.05] p-2 font-mono text-xs leading-relaxed text-fg">
|
||||
<span className="select-none text-patina">$ </span>
|
||||
{parsed.command}
|
||||
</pre>
|
||||
{extras.length > 0 && <ToolArgKeyValue entries={extras} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="text-[11px] font-medium tracking-wide text-muted/60">
|
||||
调用参数
|
||||
</span>
|
||||
<ToolArgKeyValue entries={entries} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import type { MessageVersion } from "@/lib/stores/chat-store";
|
||||
|
||||
/**
|
||||
* 版本历史面板(设计稿 .version-panel)。
|
||||
*
|
||||
* 浏览同一条助手回复的多个历史版本:
|
||||
* - ◀▶ 切换预览,实时同步主消息 content
|
||||
* - 「使用此版」将预览版永久置为当前回复(写入 store 的 activeVersionIdx)
|
||||
* - 「关闭」收起面板
|
||||
*/
|
||||
export function VersionPanel({
|
||||
versions,
|
||||
activeIdx,
|
||||
messageId,
|
||||
onBrowse,
|
||||
onUseVersion,
|
||||
}: {
|
||||
versions: MessageVersion[];
|
||||
activeIdx: number;
|
||||
messageId?: string;
|
||||
/** ◀▶ 翻页时回调,实时同步主消息 content */
|
||||
onBrowse?: (idx: number, content: string) => void;
|
||||
/** 「使用此版」回调 */
|
||||
onUseVersion?: (messageId: string, versionIdx: number) => void;
|
||||
}) {
|
||||
const multi = versions.length > 1;
|
||||
const [browseIdx, setBrowseIdx] = useState(
|
||||
Math.min(activeIdx, versions.length - 1),
|
||||
);
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
const atStart = browseIdx <= 0;
|
||||
const atEnd = browseIdx >= versions.length - 1;
|
||||
const currentVersion = versions[browseIdx];
|
||||
|
||||
const handlePrev = () => {
|
||||
const next = Math.max(0, browseIdx - 1);
|
||||
setBrowseIdx(next);
|
||||
onBrowse?.(next, versions[next].content);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
const next = Math.min(versions.length - 1, browseIdx + 1);
|
||||
setBrowseIdx(next);
|
||||
onBrowse?.(next, versions[next].content);
|
||||
};
|
||||
|
||||
const handleUseVersion = () => {
|
||||
if (!messageId) return;
|
||||
onUseVersion?.(messageId, browseIdx);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.28, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="relative z-[1] mt-2 border-t border-rule pt-2"
|
||||
>
|
||||
{/* ── 头部:版本导航 ── */}
|
||||
<div className="mono flex items-center gap-1.5 text-[11px] tracking-[0.04em] text-fg-dim">
|
||||
<span className="text-gilt/70">⟡</span>
|
||||
版本历史
|
||||
{multi && (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrev}
|
||||
disabled={atStart}
|
||||
title="上一版"
|
||||
aria-label="上一版"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-rule text-fg-faint transition-colors hover:border-gilt-dim hover:bg-fg/5 hover:text-fg disabled:pointer-events-none disabled:opacity-30"
|
||||
>
|
||||
<ChevronIcon dir="left" />
|
||||
</button>
|
||||
<span className="mono min-w-[48px] text-center text-[11px] text-fg-faint">
|
||||
<span className="font-semibold text-gilt">{browseIdx + 1}</span>
|
||||
/{versions.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
disabled={atEnd}
|
||||
title="下一版"
|
||||
aria-label="下一版"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-rule text-fg-faint transition-colors hover:border-gilt-dim hover:bg-fg/5 hover:text-fg disabled:pointer-events-none disabled:opacity-30"
|
||||
>
|
||||
<ChevronIcon dir="right" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 正文预览区:显示当前浏览版本的内容(设计稿 .vp-body) ── */}
|
||||
<div className="mt-2 rounded border border-patina-dim bg-patina-wash px-3 py-2 text-[13px] leading-relaxed text-fg-dim">
|
||||
{currentVersion?.content ? (
|
||||
<p className="line-clamp-4">{currentVersion.content}</p>
|
||||
) : (
|
||||
<p className="italic text-fg-faint">(空版本)</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 操作按钮:使用此版 / 关闭(设计稿 .vp-actions) ── */}
|
||||
<div className="mt-1.5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded border border-rule bg-transparent px-2.5 py-1 text-[11px] text-fg-faint transition-colors hover:bg-fg/5 hover:text-fg"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUseVersion}
|
||||
className="rounded border border-patina-dim bg-patina-wash px-2.5 py-1 text-[11px] text-patina transition-colors hover:border-[var(--patina)] hover:bg-patina-dim hover:text-ink"
|
||||
>
|
||||
使用此版
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon({ dir }: { dir: "left" | "right" }) {
|
||||
return (
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden>
|
||||
<path
|
||||
d={dir === "left" ? "M7.5 3L4.5 6l3 3" : "M4.5 3l3 3-3 3"}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./client", () => ({
|
||||
apiDelete: vi.fn(),
|
||||
apiGet: vi.fn(),
|
||||
apiPatch: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
}));
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
|
||||
import {
|
||||
createSession,
|
||||
deleteSession,
|
||||
listMessages,
|
||||
listSessions,
|
||||
sendMessage,
|
||||
updateSession,
|
||||
} from "./chat";
|
||||
|
||||
describe("chat api", () => {
|
||||
it("createSession POST /chat/sessions", async () => {
|
||||
vi.mocked(apiPost).mockResolvedValue({ id: "session_1" });
|
||||
await createSession({ title: "新会话" });
|
||||
expect(apiPost).toHaveBeenCalledWith("/chat/sessions", { title: "新会话" });
|
||||
});
|
||||
|
||||
it("sendMessage POST 会话消息带 modality & idempotencyKey", async () => {
|
||||
vi.mocked(apiPost).mockResolvedValue({ run_id: "run_1" });
|
||||
await sendMessage("session_1", { content: "你好" }, "idem_1");
|
||||
expect(apiPost).toHaveBeenCalledWith(
|
||||
"/chat/sessions/session_1/messages",
|
||||
{ content: "你好", modality: "text" },
|
||||
{ idempotencyKey: "idem_1" },
|
||||
);
|
||||
});
|
||||
|
||||
it("listMessages GET 分页消息", async () => {
|
||||
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
|
||||
await listMessages("session_1", { page: 1, page_size: 20 });
|
||||
expect(apiGet).toHaveBeenCalledWith("/chat/sessions/session_1/messages", {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("listSessions GET 支持 character_id 过滤", async () => {
|
||||
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
|
||||
await listSessions({ character_id: "char_1", status: "active" });
|
||||
expect(apiGet).toHaveBeenCalledWith("/chat/sessions", {
|
||||
character_id: "char_1",
|
||||
status: "active",
|
||||
});
|
||||
});
|
||||
|
||||
it("updateSession PATCH /chat/sessions/:id", async () => {
|
||||
vi.mocked(apiPatch).mockResolvedValue({ id: "session_1" });
|
||||
await updateSession("session_1", { title: "新标题" });
|
||||
expect(apiPatch).toHaveBeenCalledWith("/chat/sessions/session_1", {
|
||||
title: "新标题",
|
||||
});
|
||||
});
|
||||
|
||||
it("deleteSession DELETE /chat/sessions/:id", async () => {
|
||||
vi.mocked(apiDelete).mockResolvedValue(undefined);
|
||||
await deleteSession("session_1");
|
||||
expect(apiDelete).toHaveBeenCalledWith("/chat/sessions/session_1");
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* 对话 API Client(对应 后端生成的 OpenAPI 契约 §7)。
|
||||
* 创建会话、发送消息、拉取历史消息。
|
||||
*/
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatSession,
|
||||
CreateSessionRequest,
|
||||
Paginated,
|
||||
SendMessageRequest,
|
||||
SendMessageResponse,
|
||||
UpdateSessionRequest,
|
||||
} from "@/types/api";
|
||||
|
||||
/** 创建会话;character_id 省略时后端使用当前激活人物 */
|
||||
export function createSession(body: CreateSessionRequest = {}) {
|
||||
return apiPost<ChatSession>("/chat/sessions", body);
|
||||
}
|
||||
|
||||
/** 获取单个会话详情 */
|
||||
export function getSession(sessionId: string) {
|
||||
return apiGet<ChatSession>(`/chat/sessions/${sessionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文本消息。
|
||||
* 使用 Idempotency-Key 防止重复提交产生重复 Run(API §5.6)。
|
||||
*/
|
||||
export function sendMessage(
|
||||
sessionId: string,
|
||||
body: SendMessageRequest,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
return apiPost<SendMessageResponse>(
|
||||
`/chat/sessions/${sessionId}/messages`,
|
||||
{ ...body, modality: body.modality ?? "text" },
|
||||
{ idempotencyKey },
|
||||
);
|
||||
}
|
||||
|
||||
/** 拉取会话历史消息 */
|
||||
export function listMessages(
|
||||
sessionId: string,
|
||||
params?: { page?: number; page_size?: number },
|
||||
) {
|
||||
return apiGet<Paginated<ChatMessage>>(
|
||||
`/chat/sessions/${sessionId}/messages`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
/** 会话列表(可选按人物 / 状态过滤) */
|
||||
export function listSessions(params?: {
|
||||
character_id?: string;
|
||||
status?: ChatSession["status"];
|
||||
}) {
|
||||
return apiGet<Paginated<ChatSession>>("/chat/sessions", params);
|
||||
}
|
||||
|
||||
/** 更新会话(重命名 / 归档 / 删除态切换) */
|
||||
export function updateSession(sessionId: string, body: UpdateSessionRequest) {
|
||||
return apiPatch<ChatSession>(`/chat/sessions/${sessionId}`, body);
|
||||
}
|
||||
|
||||
/** 删除会话(软删除,status → deleted) */
|
||||
export function deleteSession(sessionId: string) {
|
||||
return apiDelete<void>(`/chat/sessions/${sessionId}`);
|
||||
}
|
||||
|
||||
/** 选择/激活消息的某个历史版本。后端存储 active_version_idx,refresh 后返回 */
|
||||
export function selectVersion(
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
versionIdx: number,
|
||||
) {
|
||||
return apiPatch<{
|
||||
message_id: string;
|
||||
active_version_idx: number;
|
||||
versions: import("@/types/api").MessageVersion[];
|
||||
}>(`/chat/sessions/${sessionId}/messages/${messageId}/version`, {
|
||||
version_idx: versionIdx,
|
||||
});
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* 辰正时态 API Client(对应 后端生成的 OpenAPI 契约 §10)。
|
||||
*
|
||||
* 提供数字生命对时间流逝的感知:时态快照、纪念日、相识时长与配置。
|
||||
*/
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
|
||||
import type {
|
||||
AcquaintanceInfo,
|
||||
Anniversary,
|
||||
CreateAnniversaryRequest,
|
||||
TimeConfig,
|
||||
TimeSnapshotResponse,
|
||||
UpdateAnniversaryRequest,
|
||||
} from "@/types/api";
|
||||
|
||||
/** 当前时态快照 + 调制因子(GET /time/snapshot) */
|
||||
export function getTimeSnapshot() {
|
||||
return apiGet<TimeSnapshotResponse>("/time/snapshot");
|
||||
}
|
||||
|
||||
/** 纪念日列表(GET /time/anniversaries) */
|
||||
export function listAnniversaries() {
|
||||
return apiGet<Anniversary[]>("/time/anniversaries");
|
||||
}
|
||||
|
||||
/** 新增纪念日(POST /time/anniversaries) */
|
||||
export function createAnniversary(body: CreateAnniversaryRequest) {
|
||||
return apiPost<Anniversary>("/time/anniversaries", body);
|
||||
}
|
||||
|
||||
/** 单条纪念日(GET /time/anniversaries/{id}) */
|
||||
export function getAnniversary(id: string) {
|
||||
return apiGet<Anniversary>(`/time/anniversaries/${id}`);
|
||||
}
|
||||
|
||||
/** 更新纪念日(PATCH /time/anniversaries/{id}) */
|
||||
export function updateAnniversary(id: string, body: UpdateAnniversaryRequest) {
|
||||
return apiPatch<Anniversary>(`/time/anniversaries/${id}`, body);
|
||||
}
|
||||
|
||||
/** 删除纪念日(DELETE /time/anniversaries/{id}) */
|
||||
export function deleteAnniversary(id: string) {
|
||||
return apiDelete<void>(`/time/anniversaries/${id}`);
|
||||
}
|
||||
|
||||
/** 相识时长(GET /time/acquaintance) */
|
||||
export function getAcquaintance() {
|
||||
return apiGet<AcquaintanceInfo>("/time/acquaintance");
|
||||
}
|
||||
|
||||
/** 时态配置(GET /time/config,只读) */
|
||||
export function getTimeConfig() {
|
||||
return apiGet<TimeConfig>("/time/config");
|
||||
}
|
||||
Reference in new issue
Block a user