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