M2b:导入改造产出可审阅灵胚(启镜物化世谱)与组装管线(符文展开/火候进入 IR),Kernel 打通采样参数链路

This commit is contained in:
chenxin committed 2026-08-21 23:43:29 +08:00
1 parent e6a3b7121f
commit 5f36e3785c
86 files changed
+424 -14455

No files matched your search

-26
View File
@@ -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} />;
}
-691
View File
@@ -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("单条消息过长,请精简后发送。");
});
});
-368
View File
@@ -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>
);
}
-831
View File
@@ -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>
);
}
-303
View File
@@ -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>
);
}
-419
View File
@@ -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>
);
}
-68
View File
@@ -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");
});
});
-85
View File
@@ -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,
});
}
-55
View File
@@ -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");
}