Files
writer-work-flow/apps/web/components/workbench/ChapterAssistant.tsx

292 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import Link from "next/link";
import { Minus, Pin, PinOff, Plus, RotateCcw, Undo2, X } from "lucide-react";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { Button } from "@/components/ui/Button";
import { SectionHeader } from "@/components/ui/SectionHeader";
import { StatusNote } from "@/components/ui/StatusNote";
import { buttonClass } from "@/lib/ui/variants";
import {
isPinned,
kindLabel,
reasonLabel,
RECENT_N_MAX,
RECENT_N_MIN,
type InjectionEntity,
type InjectionEntityRef,
} from "@/lib/workbench/injection";
import { useInjection } from "@/lib/workbench/useInjection";
interface ChapterAssistantProps {
projectId: string;
chapterNo: number;
}
// 右栏「本章助手」UX §6.3)。
// 「本章注入(透明)」读 B0 端点,列出确定性选中的设定/角色 + 入选理由徽标,
// 并让作者 📌置顶 / ✕排除 / 调近 N 章(可控版)——「看到的=写章用的」信任牌(不变量 #6
// 桌面 aside 包裹;移动端经 Workbench 抽屉复用 AssistantContent。
export function ChapterAssistant({ projectId, chapterNo }: ChapterAssistantProps) {
return (
<aside className="hidden border-l border-line bg-panel py-4 xl:block">
<AssistantContent projectId={projectId} chapterNo={chapterNo} />
</aside>
);
}
export function AssistantContent({ projectId, chapterNo }: ChapterAssistantProps) {
const injection = useInjection(projectId, chapterNo);
const data = injection.data;
const selected = data?.selected ?? [];
const excluded = data?.excluded ?? [];
return (
<div className="px-4">
<SectionHeader
title="本章助手"
action={
injection.saving ? (
<ThinkingIndicator
label="助手保存中"
className="text-xs text-ink-soft"
/>
) : null
}
/>
<section className="mt-4">
<SectionHeader
title="本章注入(透明)"
action={
data ? (
<RecentStepper
value={data.recent_n}
disabled={injection.saving}
onChange={injection.setRecentN}
/>
) : null
}
/>
{injection.loading ? (
<p className="mt-2 text-xs text-ink-soft"></p>
) : !data ? (
// 首次加载失败data 仍为空 → 满屏错误框 + 重试。
<StatusNote variant="danger" className="mt-2 text-xs" title="注入信息暂不可用">
<p></p>
<button
type="button"
onClick={injection.reload}
className={buttonClass({ variant: "outline", size: "sm", className: "mt-2" })}
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</StatusNote>
) : (
<>
{injection.saveError ? (
// 保存失败:悬浮提示,不替换列表;可关闭 + 重试上一次覆盖。
<StatusNote variant="danger" className="mt-2 text-xs" title={injection.saveError}>
<div className="mt-1 flex items-center gap-2">
<button
type="button"
onClick={() => void injection.retrySave()}
disabled={injection.saving}
className={buttonClass({ variant: "outline", size: "sm" })}
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={injection.clearSaveError}
aria-label="关闭保存失败提示"
className={buttonClass({ variant: "ghost", size: "sm" })}
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
</StatusNote>
) : null}
{selected.length === 0 ? (
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
/
</p>
) : (
<ul className="mt-2 space-y-2">
{selected.map((entity) => (
<EntityRow
key={`${entity.kind}:${entity.name}`}
entity={entity}
pinned={isPinned(
{ pinned: data.pinned, excluded: data.excluded, recent_n: data.recent_n },
entity,
)}
disabled={injection.saving}
onTogglePin={injection.togglePin}
onExclude={injection.exclude}
/>
))}
</ul>
)}
</>
)}
{excluded.length > 0 ? (
<div className="mt-3">
<SectionHeader title="已排除(不注入)" />
<ul className="mt-1 space-y-1">
{excluded.map((ref) => (
<ExcludedRow
key={`${ref.kind}:${ref.name}`}
refItem={ref}
disabled={injection.saving}
onRestore={injection.restore}
/>
))}
</ul>
</div>
) : null}
</section>
<section className="mt-6">
<SectionHeader
title="四审"
description="一致性 / 伏笔 / 文风 / 节奏,在审稿页逐条裁决。"
/>
<Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className={buttonClass({ variant: "outline", size: "sm", className: "mt-2" })}
>
稿
</Link>
</section>
</div>
);
}
interface EntityRowProps {
entity: InjectionEntity;
pinned: boolean;
disabled: boolean;
onTogglePin: (ref: InjectionEntityRef) => void;
onExclude: (ref: InjectionEntityRef) => void;
}
function EntityRow({ entity, pinned, disabled, onTogglePin, onExclude }: EntityRowProps) {
const ref: InjectionEntityRef = { kind: entity.kind, name: entity.name };
return (
<li className="rounded border border-line bg-bg p-2">
<div className="flex items-center gap-2">
<span className="rounded bg-panel px-1.5 py-0.5 text-2xs text-ink-soft">
{kindLabel(entity.kind)}
</span>
<span className="text-sm text-ink">{entity.name}</span>
<div className="ml-auto flex items-center gap-1">
<Button
variant="ghost"
size="icon"
disabled={disabled}
aria-pressed={pinned}
aria-label={
pinned ? `取消置顶 ${entity.name}` : `置顶 ${entity.name}(强制注入)`
}
onClick={() => onTogglePin(ref)}
className={pinned ? "text-cinnabar" : undefined}
>
{pinned ? (
<Pin className="h-4 w-4" aria-hidden="true" />
) : (
<PinOff className="h-4 w-4" aria-hidden="true" />
)}
</Button>
<Button
variant="ghost"
size="icon"
disabled={disabled}
aria-label={`排除 ${entity.name}(不注入本章)`}
onClick={() => onExclude(ref)}
>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</div>
<div className="mt-1.5 flex flex-wrap gap-1">
{(entity.reasons ?? []).map((reason) => (
<span
key={reason}
className="rounded bg-[var(--color-cinnabar-wash)] px-1.5 py-0.5 text-2xs text-cinnabar"
>
{reasonLabel(reason)}
</span>
))}
</div>
</li>
);
}
interface ExcludedRowProps {
refItem: InjectionEntityRef;
disabled: boolean;
onRestore: (ref: InjectionEntityRef) => void;
}
function ExcludedRow({ refItem, disabled, onRestore }: ExcludedRowProps) {
return (
<li className="flex items-center gap-2 rounded border border-dashed border-line px-2 py-1">
<span className="text-2xs text-ink-soft">{kindLabel(refItem.kind)}</span>
<span className="text-xs text-ink-soft line-through">{refItem.name}</span>
<Button
variant="ghost"
size="icon"
disabled={disabled}
aria-label={`恢复 ${refItem.name}(回到自动判定)`}
onClick={() => onRestore(refItem)}
className="ml-auto text-cinnabar"
>
<Undo2 className="h-4 w-4" aria-hidden="true" />
</Button>
</li>
);
}
interface RecentStepperProps {
value: number;
disabled: boolean;
onChange: (n: number) => void;
}
// 「关联近 N 章」步进器:覆盖近况摘要回看章数(钳在 [MIN, MAX])。
function RecentStepper({ value, disabled, onChange }: RecentStepperProps) {
return (
<div className="flex items-center gap-1 text-2xs text-ink-soft">
<span></span>
<Button
variant="ghost"
size="icon"
disabled={disabled || value <= RECENT_N_MIN}
aria-label="减少关联章数"
onClick={() => onChange(value - 1)}
>
<Minus className="h-4 w-4" aria-hidden="true" />
</Button>
<span className="w-4 text-center tabular-nums text-ink">{value}</span>
<Button
variant="ghost"
size="icon"
disabled={disabled || value >= RECENT_N_MAX}
aria-label="增加关联章数"
onClick={() => onChange(value + 1)}
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Button>
<span></span>
</div>
);
}