Commit b3455a80 by luoqi

feat(web): 话术档位下拉(稳健/标准/深度)+ 多段渲染 + 隐藏实时教练

- RegenBtn 加投入档下拉(与模型下拉并列):稳健/标准/深度,选档即用该档重新生成
- tier 走 /script:stream ?tier= query;ServerSection.id → string 支持深度档多段不定渲染
- 详情页暂隐藏实时教练入口(功能未上线;import + 挂载注释)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 4fe0d973
......@@ -61,13 +61,16 @@ import {
import type { AdaptedFact } from './adapt-data';
import { useScriptStream } from './use-script-stream';
import { useSummaryStream } from './use-summary-stream';
import { RealtimeCoach } from '@/components/realtime-coach';
// import { RealtimeCoach } from '@/components/realtime-coach'; // 暂隐藏(功能未上线)
import { CallWidget } from './call-widget';
import { submitExecution, adaptAbandonReasons } from './execution-api';
/// 话术生成模型(具体型号,直传后端 AiProviderService.resolve)
export type ScriptModel = 'deepseek-v4-pro' | 'deepseek-v4-flash' | 'gemini-3.5-flash';
/// 投入档(话术生成档位):稳健=4段模板填空 / 标准=去模板自由编排 / 深度=多步多段+对抗校验。
export type ScriptTier = 'stable' | 'standard' | 'deep';
/// 召回历史一条(患者级,跨所有 plan 版本)— 后端 plan-aggregate.recallHistory
export type RecallHistoryItem = {
id: string;
......@@ -137,6 +140,8 @@ export function PlanDetailApp({
const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown');
// 话术生成模型(具体型号);默认 deepseek-v4-flash
const [scriptModel, setScriptModel] = useState<ScriptModel>('deepseek-v4-flash');
// 投入档(默认稳健);跟模型并列,客服在重新生成处选
const [scriptTier, setScriptTier] = useState<ScriptTier>('stable');
const { state: streamState, regenerate, abort } = useScriptStream();
const { state: summaryState, regenerate: regenerateSummary } = useSummaryStream();
......@@ -408,13 +413,15 @@ export function PlanDetailApp({
<RegenBtn
streaming={isStreaming}
model={scriptModel}
tier={scriptTier}
onStop={() => {
abort();
showToast('slate', '已停止', '本次 AI 生成被中断');
}}
onRegen={(m) => {
onRegen={(m, t) => {
setScriptModel(m);
void regenerate(plan.id, { model: m });
setScriptTier(t);
void regenerate(plan.id, { model: m, tier: t });
}}
/>
{/* AI 时间戳 — 窄屏隐藏;未生成话术时不显示(无 generatedAt) */}
......@@ -468,8 +475,8 @@ export function PlanDetailApp({
}}
/>
</section>
{/* 实时坐席辅助教练:正中底部麦克风悬浮按钮 + 悬浮面板(独立模块) */}
<RealtimeCoach planId={plan.id} />
{/* 实时坐席辅助教练:暂时隐藏(功能未上线)。恢复:取消下行 + 顶部 import 注释。 */}
{/* <RealtimeCoach planId={plan.id} /> */}
</main>
}
rightPane={
......@@ -1666,6 +1673,13 @@ const SCRIPT_MODELS: { key: ScriptModel; label: string }[] = [
{ key: 'gemini-3.5-flash', label: 'Gemini 3.5 Flash' },
];
// 投入档选项(跟模型并列,直传后端 tier)
const SCRIPT_TIERS: { key: ScriptTier; label: string }[] = [
{ key: 'stable', label: '稳健' },
{ key: 'standard', label: '标准' },
{ key: 'deep', label: '深度' },
];
// ──────────────────────────────────────────
// RegenBtn — 流式重新生成拆分按钮
// - 主键:空闲态点击触发 SSE 生成(用当前选中模型);流式态变"停止",可中断
......@@ -1674,21 +1688,52 @@ const SCRIPT_MODELS: { key: ScriptModel; label: string }[] = [
function RegenBtn({
streaming,
model,
tier,
onStop,
onRegen,
}: {
streaming: boolean;
model: ScriptModel;
tier: ScriptTier;
onStop: () => void;
onRegen: (model: ScriptModel) => void;
onRegen: (model: ScriptModel, tier: ScriptTier) => void;
}) {
const current = SCRIPT_MODELS.find((m) => m.key === model) ?? SCRIPT_MODELS[0]!;
const currentTier = SCRIPT_TIERS.find((t) => t.key === tier) ?? SCRIPT_TIERS[0]!;
return (
<div className="inline-flex flex-none items-stretch rounded overflow-hidden">
{/* 档位选择 caret(最左) */}
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={streaming}>
<button
type="button"
title="选择投入档(稳健 / 标准)"
disabled={streaming}
className={cn(
'inline-flex items-center gap-0.5 px-1 py-0.5 text-[10.5px] border-r transition-colors',
streaming
? 'text-rose-300 border-rose-100 cursor-not-allowed'
: 'text-slate-400 border-slate-200 hover:text-indigo-700 hover:bg-indigo-50',
)}
>
<span>{currentTier.label}</span>
<ChevronDown className="w-3 h-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[6rem]">
<DropdownMenuRadioGroup value={tier} onValueChange={(v) => onRegen(model, v as ScriptTier)}>
{SCRIPT_TIERS.map((t) => (
<DropdownMenuRadioItem key={t.key} value={t.key} className="text-[12px] font-medium text-slate-800">
{t.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* 主键 */}
<button
onClick={() => (streaming ? onStop() : onRegen(model))}
title={streaming ? '点击停止本次生成' : `用 ${current.label} 重新生成话术`}
onClick={() => (streaming ? onStop() : onRegen(model, tier))}
title={streaming ? '点击停止本次生成' : `用 ${currentTier.label} · ${current.label} 重新生成话术`}
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 text-[10.5px] transition-colors',
streaming
......@@ -1742,7 +1787,7 @@ function RegenBtn({
<DropdownMenuContent align="end" className="min-w-[11rem]">
<DropdownMenuRadioGroup
value={model}
onValueChange={(v) => onRegen(v as ScriptModel)}
onValueChange={(v) => onRegen(v as ScriptModel, tier)}
>
{SCRIPT_MODELS.map((m) => (
<DropdownMenuRadioItem
......
......@@ -21,13 +21,15 @@ import type { ScriptSection } from './mock-data';
*/
interface ServerSection {
// 2026-06 重构:4 模块(开场白 / 告知应治未治 / 复查建议 / 结束回访语)
id: 'opening' | 'informMissed' | 'reviewAdvice' | 'closing';
// 稳健/标准:固定 4 id(开场白/告知应治未治/复查建议/结束回访语);深度:段数不定,id = s0/s1/…(string)
id: string;
label: string;
durationHint: string;
markdown: string;
}
type FixedSectionId = 'opening' | 'informMissed' | 'reviewAdvice' | 'closing';
export type ScriptStreamState =
| { status: 'idle' }
| { status: 'streaming'; sections: ScriptSection[]; modelId?: string; invocationId?: string }
......@@ -46,21 +48,21 @@ export type ScriptStreamState =
export interface UseScriptStream {
state: ScriptStreamState;
/** 触发流式重新生成 */
regenerate: (planId: string, options?: { model?: string }) => Promise<void>;
regenerate: (planId: string, options?: { model?: string; tier?: string }) => Promise<void>;
/** 中途停掉(用户点暂停 / 离开页面) */
abort: () => void;
reset: () => void;
}
// 2026-06 重构:4 模块 — opening / informMissed / reviewAdvice / closing
const LABEL_FALLBACK: Record<ServerSection['id'], string> = {
const LABEL_FALLBACK: Record<FixedSectionId, string> = {
opening: '开场白',
informMissed: '告知应治未治',
reviewAdvice: '复查建议',
closing: '结束回访语',
};
// 时长无意义,统一空串(UI 不再展示建议时长)
const DURATION_FALLBACK: Record<ServerSection['id'], string> = {
const DURATION_FALLBACK: Record<FixedSectionId, string> = {
opening: '',
informMissed: '',
reviewAdvice: '',
......@@ -78,7 +80,7 @@ export function useScriptStream(): UseScriptStream {
abortRef.current = null;
}, []);
const regenerate = useCallback(async (planId: string, options?: { model?: string }) => {
const regenerate = useCallback(async (planId: string, options?: { model?: string; tier?: string }) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
......@@ -88,6 +90,7 @@ export function useScriptStream(): UseScriptStream {
env.apiBaseUrl,
);
if (options?.model) url.searchParams.set('model', options.model);
if (options?.tier) url.searchParams.set('tier', options.tier);
// 占位 sections,避免第一帧到达前 UI 闪
setState({ status: 'streaming', sections: makeEmptySections() });
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment