Commit 60ce7324 by luoqi

feat(ai-script): 深度档流式可见过程(GPT/Claude 式:规划→撰写→自检→修订)

深度档原来是 start →(黑盒30-60s)→ done,看不到中间过程。改成逐步可见:
后端:
- deep.strategy 加 runStream():逐步 yield step 事件(plan/write/verify/repair 的 running/done +
  各步摘要:大纲/质量分/问题数);撰写/修订步走 runner.stream 逐字 partial(打字机)。run() 保留不动。
- orchestrator 深度档 SSE 路径改为消费 runStream → 转发 step + partial 事件;新增 PlanScriptStreamEvent.step。
前端:
- use-script-stream 解析 step 事件,维护 steps 时间线(done 后保留可回看)。
- 新增 ScriptDeepProcess 组件:步骤时间线(spinner/✓ + 大纲展示 + 接地安全/质量分 + 修订问题数)。
- plan-detail-app 在话术区顶部渲染过程面板;正文 sections 在下方逐字流。
端到端实测(本地 deepseek-v4-flash):start→step plan(running/done)→step write+逐字partial→
  step verify→step repair→done,全程事件正常流出。abort/停止复用现有。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 219f7440
......@@ -20,6 +20,22 @@ export interface DeepScriptResult {
stepsRun: string[];
}
/** 深度档可见过程的步骤名 */
export type DeepStepName = 'plan' | 'write' | 'verify' | 'repair';
/** 各步收尾时透传给前端的摘要(让"中间过程"可见) */
export interface DeepStepDetail {
outline?: { title: string; intent: string }[]; // plan done:大纲
pass?: boolean; // verify done
quality?: DeepVerify['quality'] | null; // verify done:质量分
issuesCount?: number; // verify done:发现问题数 / repair running:待修问题数
}
/** runStream 逐步 yield 的事件(step 状态切换 + write 逐字 partial)*/
export type DeepStepEvent =
| { kind: 'step'; step: DeepStepName; status: 'running' | 'done'; detail?: DeepStepDetail }
| { kind: 'partial'; draft: Partial<DeepDraft> };
/**
* DeepScriptStrategy —— 深度档 3 步编排(脊柱仍是 AiCallRunner,每步各落 agent_invocations)。
* 规划(plan) → 写(write,多段) → 独立对抗校验(verify) → 不过则 repair(≤1 轮) → 仍不过则稳健兜底。
......@@ -132,6 +148,131 @@ export class DeepScriptStrategy {
return this.done(draft, 'agent', invocationId, cost, promptTokens, completionTokens, steps, ranAny && allCacheHit);
}
/**
* 流式版 —— 逐步 yield 给前端可见的过程(规划→撰写→自检→[修订]),撰写/修订步走 runner.stream
* 逐字 partial(打字机)。返回值同 run()(DeepScriptResult)。供 orchestrator 的 SSE 深度档路径消费。
*/
async *runStream(ctx: ScriptContext, runCtx: AiCallContext): AsyncGenerator<DeepStepEvent, DeepScriptResult> {
const steps: string[] = [];
let cost = 0;
let promptTokens = 0;
let completionTokens = 0;
const acc = (r: { costYuan: number; promptTokens: number; completionTokens: number }) => {
cost += r.costYuan;
promptTokens += r.promptTokens;
completionTokens += r.completionTokens;
};
const ensureLive = () => {
if (runCtx.signal?.aborted) throw new Error('生成已取消(客户端断连)');
};
// ── 步骤1:规划(best-effort)──
ensureLive();
yield { kind: 'step', step: 'plan', status: 'running' };
let plan: DeepPlan;
try {
const r = await this.runner.run(this.planCall, ctx, runCtx);
acc(r);
plan = r.output;
steps.push('plan');
} catch (err) {
if (runCtx.signal?.aborted) throw err;
this.logger.warn(`deep plan 失败,用退化大纲: ${(err as Error).message}`);
plan = degeneratePlan();
steps.push('plan:degenerate');
}
yield {
kind: 'step',
step: 'plan',
status: 'done',
detail: { outline: plan.sections.map((s) => ({ title: s.title, intent: s.intent })) },
};
// ── 步骤2:写(逐字流)──
ensureLive();
yield { kind: 'step', step: 'write', status: 'running' };
const w = yield* this.streamWrite({ ctx, plan }, runCtx, acc);
let draft = w.output;
let invocationId = w.invocationId;
steps.push(w.source === 'template_fallback' ? 'write:fallback' : 'write');
yield { kind: 'step', step: 'write', status: 'done' };
if (w.source === 'template_fallback') {
return this.done(draft, 'template_fallback', invocationId, cost, promptTokens, completionTokens, steps, false, w.fallbackReason);
}
// ── 步骤3:独立对抗校验 + 机器扫 ──
const issues: DeepVerifyIssue[] = machineScanIssues(draft);
let quality: DeepVerify['quality'] | null = null;
ensureLive();
yield { kind: 'step', step: 'verify', status: 'running' };
try {
const v = await this.runner.run(this.verifyCall, { ctx, draft }, runCtx);
acc(v);
steps.push('verify');
if (!v.output.pass) issues.push(...v.output.issues);
quality = v.output.quality ?? null;
} catch (err) {
if (runCtx.signal?.aborted) throw err;
this.logger.warn(`deep verify 失败,仅依据机器扫: ${(err as Error).message}`);
steps.push('verify:skip');
}
yield {
kind: 'step',
step: 'verify',
status: 'done',
detail: { pass: issues.length === 0, quality, issuesCount: issues.length },
};
// ── repair(≤1 轮)──
if (issues.length > 0) {
ensureLive();
yield { kind: 'step', step: 'repair', status: 'running', detail: { issuesCount: issues.length } };
const w2 = yield* this.streamWrite({ ctx, plan, repairIssues: issues }, runCtx, acc);
draft = w2.output;
invocationId = w2.invocationId;
steps.push(w2.source === 'template_fallback' ? 'repair:fallback' : 'repair');
yield { kind: 'step', step: 'repair', status: 'done' };
const stillBad = machineSafetyScan(joinDraft(draft));
if (w2.source === 'template_fallback' || stillBad.length > 0) {
const fb = draftOutputToDeep(stableTemplateFallback(ctx));
return this.done(fb, 'template_fallback', invocationId, cost, promptTokens, completionTokens, steps, false, `repair 后仍不过: ${stillBad.join(';')}`);
}
}
if (quality) {
await this.recorder
.attachJudge(invocationId, quality.overall, {
natural: quality.natural,
warmth: quality.warmth,
focus: quality.focus,
nonPushy: quality.nonPushy,
})
.catch((e) => this.logger.warn(`attachJudge 失败(忽略): ${(e as Error).message}`));
}
return this.done(draft, 'agent', invocationId, cost, promptTokens, completionTokens, steps, false);
}
/** 流式跑 write/repair:逐字 yield partial,返回 done(output/invocationId/source) */
private async *streamWrite(
wInput: { ctx: ScriptContext; plan: DeepPlan; repairIssues?: DeepVerifyIssue[] },
runCtx: AiCallContext,
acc: (r: { costYuan: number; promptTokens: number; completionTokens: number }) => void,
): AsyncGenerator<DeepStepEvent, { output: DeepDraft; source: 'agent' | 'template_fallback'; invocationId: string; fallbackReason?: string }> {
let done:
| { output: DeepDraft; source: 'agent' | 'template_fallback'; invocationId: string; costYuan: number; promptTokens: number; completionTokens: number; fallbackReason?: string }
| undefined;
for await (const ev of this.runner.stream(this.writeCall, wInput, runCtx)) {
if (ev.type === 'partial') yield { kind: 'partial', draft: ev.partial };
else if (ev.type === 'done') {
acc(ev);
done = ev;
}
}
if (!done) throw new Error('deep write stream 无 done 事件');
return { output: done.output, source: done.source, invocationId: done.invocationId, fallbackReason: done.fallbackReason };
}
private done(
draft: DeepDraft,
source: 'agent' | 'template_fallback',
......
......@@ -9,7 +9,7 @@ import type { StreamEvent } from '../ai-call-runner.service';
import type { AiCall, AiCallContext } from '../ai-call.interface';
import { DraftPlanScriptCall } from '../calls/draft-plan-script/tiers/stable/stable.call';
import { StandardScriptCall } from '../calls/draft-plan-script/tiers/standard/standard.call';
import { DeepScriptStrategy } from '../calls/draft-plan-script/tiers/deep/deep.strategy';
import { DeepScriptStrategy, type DeepScriptResult } from '../calls/draft-plan-script/tiers/deep/deep.strategy';
import type { DeepDraft } from '../calls/draft-plan-script/tiers/deep/types';
import type { ScriptTier } from '../calls/draft-plan-script/shared/skill.types';
import { callSalutation, pickGuardian } from '../calls/draft-plan-script/shared/pii';
......@@ -27,8 +27,20 @@ import type {
export type PlanScriptStreamEvent =
| { type: 'start'; invocationId: string; modelId: string; promptVersion: string }
| {
// 深度档可见过程:步骤切换(规划→撰写→自检→[修订])+ 各步收尾摘要(大纲/质量分/问题数)
type: 'step';
step: 'plan' | 'write' | 'verify' | 'repair';
status: 'running' | 'done';
detail?: {
outline?: { title: string; intent: string }[];
pass?: boolean;
quality?: { natural: number; warmth: number; focus: number; nonPushy: number; overall: number } | null;
issuesCount?: number;
};
}
| {
type: 'partial';
structured: unknown; // 稳健/标准=Partial<DraftPlanScriptOutput>;深度无 partial。前端只用 sections
structured: unknown; // 稳健/标准=Partial<DraftPlanScriptOutput>;深度=Partial<DeepDraft>。前端只用 sections
sections: ScriptSectionDto[];
}
| {
......@@ -250,9 +262,21 @@ export class PlanScriptOrchestrator {
modelId: options.modelIdOverride ?? 'deepseek-v4-flash',
promptVersion: 'draft_plan_script@deep-pipeline',
};
let r;
let r: DeepScriptResult;
try {
r = await this.deepStrategy.run(input, runCtx);
// 逐步消费深度档生成器:step 事件 → 步骤可见;partial → 撰写/修订逐字流
const g = this.deepStrategy.runStream(input, runCtx);
let res = await g.next();
while (!res.done) {
const ev = res.value;
if (ev.kind === 'step') {
yield { type: 'step', step: ev.step, status: ev.status, detail: ev.detail };
} else {
yield { type: 'partial', structured: ev.draft, sections: deepDraftPartialToSections(ev.draft) };
}
res = await g.next();
}
r = res.value;
} catch (err) {
yield { type: 'error', message: err instanceof Error ? err.message : String(err) };
return;
......
......@@ -44,6 +44,7 @@ import { shortPersonaValueLabel } from './persona-display';
import { PersonaFeatureHover } from './persona-feature-hover';
import { ReasonLine } from './reason-line';
import { ScriptView, type ScriptViewMode } from './script-viewer';
import { ScriptDeepProcess } from './script-deep-process';
import { OutcomeForm } from './outcome-form';
import { Drawer, type DrawerKind } from './drawer';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
......@@ -217,6 +218,9 @@ export function PlanDetailApp({
}, [streamState, script.sections]);
const isStreaming = streamState.status === 'streaming';
// 深度档可见过程步骤(规划→撰写→自检→[修订]);仅深度档有 step 事件
const deepSteps =
streamState.status === 'streaming' || streamState.status === 'done' ? streamState.steps : undefined;
// 是否已有话术内容(没生成过 → 空态提示,不显示默认 demo / 空标题)
const hasScriptContent = displayedSections.some((s) => s.markdown.trim().length > 0);
......@@ -414,6 +418,9 @@ export function PlanDetailApp({
</div>
</header>
<div className="flex-1 min-h-0 overflow-y-auto p-4">
{deepSteps && deepSteps.length > 0 && (
<ScriptDeepProcess steps={deepSteps} active={isStreaming} />
)}
{!isStreaming && !hasScriptContent ? (
<div className="h-full min-h-[160px] flex flex-col items-center justify-center text-center gap-2 text-slate-400">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-8 h-8">
......
'use client';
import type { DeepStep } from './use-script-stream';
/**
* ScriptDeepProcess — 深度档"可见生成过程"时间线(规划→撰写→自检→[修订])。
* 让客服像看 GPT/Claude 一样看到多步推理:大纲拟定、正文撰写(正文本身在下方逐字流)、
* 接地+安全自检结果、按问题修订。仅深度档(后端 step 事件)出现。
*/
const STEP_LABEL: Record<DeepStep['step'], string> = {
plan: '规划大纲',
write: '撰写话术',
verify: '接地 + 安全自检',
repair: '按问题修订',
};
export function ScriptDeepProcess({ steps, active }: { steps: DeepStep[]; active: boolean }) {
if (!steps.length) return null;
return (
<div className="mb-3 rounded-lg border border-indigo-100 bg-indigo-50/40 p-3">
<div className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold text-indigo-600">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-3.5 w-3.5">
<path d="M12 3l1.9 5.8L20 10l-5.1 1.8L12 18l-1.9-6.2L5 10l6.1-1.2z" strokeLinejoin="round" />
</svg>
深度生成过程
{active && <span className="text-indigo-400">· 进行中</span>}
</div>
<ol className="space-y-2.5">
{steps.map((s) => (
<li key={s.step} className="flex gap-2.5 text-[12px]">
<StepIcon status={s.status} />
<div className="min-w-0 flex-1 leading-snug">
<div className="font-medium text-slate-700">
{STEP_LABEL[s.step]}
{s.status === 'running' && <span className="shimmer-text ml-1 text-slate-400"></span>}
</div>
<StepDetail step={s} />
</div>
</li>
))}
</ol>
</div>
);
}
function StepIcon({ status }: { status: DeepStep['status'] }) {
if (status === 'running') {
return (
<svg viewBox="0 0 24 24" fill="none" className="mt-0.5 h-3.5 w-3.5 flex-none animate-spin text-indigo-500">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="3" strokeOpacity="0.25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
);
}
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="mt-0.5 h-3.5 w-3.5 flex-none text-emerald-500">
<path d="M20 6L9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function StepDetail({ step: s }: { step: DeepStep }) {
const d = s.detail;
if (!d) return null;
// 规划:展示大纲(N 段:标题 — 意图)
if (s.step === 'plan' && d.outline?.length) {
return (
<ul className="mt-1 space-y-0.5 text-[11px] text-slate-500">
{d.outline.map((o, i) => (
<li key={i} className="truncate">
<span className="text-slate-600">{o.title}</span>
{o.intent && <span className="text-slate-400">{o.intent}</span>}
</li>
))}
</ul>
);
}
// 自检:接地+安全 + 质量分 + 待修数
if (s.step === 'verify') {
const q = d.quality?.overall;
return (
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px]">
<span className={d.pass ? 'text-emerald-600' : 'text-amber-600'}>
接地 + 安全 {d.pass ? '通过' : `${d.issuesCount ?? 0} 处待修`}
</span>
{typeof q === 'number' && <span className="text-slate-400">质量 {q.toFixed(1)} / 5</span>}
</div>
);
}
// 修订:针对 N 处问题重写
if (s.step === 'repair' && typeof d.issuesCount === 'number') {
return <div className="mt-0.5 text-[11px] text-slate-500">针对 {d.issuesCount} 处问题重写</div>;
}
return null;
}
......@@ -30,12 +30,25 @@ interface ServerSection {
type FixedSectionId = 'opening' | 'informMissed' | 'reviewAdvice' | 'closing';
/** 深度档可见过程的步骤(规划→撰写→自检→[修订])*/
export interface DeepStep {
step: 'plan' | 'write' | 'verify' | 'repair';
status: 'running' | 'done';
detail?: {
outline?: { title: string; intent: string }[];
pass?: boolean;
quality?: { natural: number; warmth: number; focus: number; nonPushy: number; overall: number } | null;
issuesCount?: number;
};
}
export type ScriptStreamState =
| { status: 'idle' }
| { status: 'streaming'; sections: ScriptSection[]; modelId?: string; invocationId?: string }
| { status: 'streaming'; sections: ScriptSection[]; steps?: DeepStep[]; modelId?: string; invocationId?: string }
| {
status: 'done';
sections: ScriptSection[];
steps?: DeepStep[];
planScriptId: string | null;
source: 'agent' | 'template_fallback';
costYuan: number;
......@@ -165,6 +178,12 @@ export function useScriptStream(): UseScriptStream {
modelId: evt.modelId,
invocationId: evt.invocationId,
});
} else if (evt.type === 'step') {
setState((prev) =>
prev.status === 'streaming'
? { ...prev, steps: upsertStep(prev.steps ?? [], evt) }
: prev,
);
} else if (evt.type === 'partial') {
setState((prev) =>
prev.status === 'streaming'
......@@ -172,16 +191,17 @@ export function useScriptStream(): UseScriptStream {
: prev,
);
} else if (evt.type === 'done') {
setState({
setState((prev) => ({
status: 'done',
sections: serverToClientSections(evt.sections),
steps: prev.status === 'streaming' ? prev.steps : undefined, // 保留过程时间线供完成后回看
planScriptId: evt.planScriptId,
source: evt.source,
costYuan: evt.costYuan,
promptTokens: evt.promptTokens,
completionTokens: evt.completionTokens,
fallbackReason: evt.fallbackReason,
});
}));
} else if (evt.type === 'error') {
setState({ status: 'error', message: evt.message });
}
......@@ -235,7 +255,24 @@ interface SseErrorEvent {
type: 'error';
message: string;
}
type SseEvent = SseStartEvent | SsePartialEvent | SseDoneEvent | SseErrorEvent;
interface SseStepEvent {
type: 'step';
step: DeepStep['step'];
status: DeepStep['status'];
detail?: DeepStep['detail'];
}
type SseEvent = SseStartEvent | SsePartialEvent | SseDoneEvent | SseErrorEvent | SseStepEvent;
/** 合并 step 事件到时间线:同名步更新状态/详情,否则追加(保持 plan→write→verify→repair 顺序)*/
function upsertStep(steps: DeepStep[], evt: SseStepEvent): DeepStep[] {
const next: DeepStep = { step: evt.step, status: evt.status, detail: evt.detail };
const i = steps.findIndex((s) => s.step === evt.step);
if (i === -1) return [...steps, next];
const copy = steps.slice();
const prev = copy[i]!;
copy[i] = { ...prev, ...next, detail: evt.detail ?? prev.detail };
return copy;
}
function parseSseFrame(frame: string): SseEvent | null {
// SSE 帧的每一行可能是 "event: <name>" / "data: <json>" / ": comment"
......
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