Commit 7837d8c8 by luoqi

feat(ai): 话术生成接入 Gemini,重新生成按钮加模型选择

- 后端:@ai-sdk/google 接入 Gemini provider;AiProviderService.resolve
  支持 deepseek-* / gemini-* 前缀,返回规范化 modelId(落账/计费用之);
  config 加 GEMINI_API_KEY/BASE_URL/DEFAULT_MODEL(默认 gemini-3.5-flash)+ 价格表
- 前端:shadcn DropdownMenu 拆分"重新生成 ▾"按钮,直列具体型号
  (DeepSeek V4 Pro / V4 Flash / Gemini 3.5 Flash),选中即用该模型重生
- 生成中效果精简:只保留 4 个标题 shimmer 扫光(globals.css),
  移除三点脉冲/AI正在生成/流式输出中等其余提示

本地三模型端到端验证 succeeded(deepseek-v4-pro/flash + gemini-3.5-flash)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 735887b3
...@@ -44,6 +44,7 @@ ...@@ -44,6 +44,7 @@
}, },
"dependencies": { "dependencies": {
"@ai-sdk/deepseek": "^2.0.35", "@ai-sdk/deepseek": "^2.0.35",
"@ai-sdk/google": "^3.0.80",
"@bull-board/api": "^7.1.5", "@bull-board/api": "^7.1.5",
"@bull-board/express": "^7.1.5", "@bull-board/express": "^7.1.5",
"@bull-board/nestjs": "^7.1.5", "@bull-board/nestjs": "^7.1.5",
......
...@@ -17,6 +17,12 @@ export interface AppConfig { ...@@ -17,6 +17,12 @@ export interface AppConfig {
deepseekBaseUrl: string; deepseekBaseUrl: string;
/// 默认主力模型(deepseek-v4-pro / deepseek-v4-flash) /// 默认主力模型(deepseek-v4-pro / deepseek-v4-flash)
defaultModel: string; defaultModel: string;
/// Gemini API key(Google AI Studio;海外需可达 generativelanguage.googleapis.com)
geminiApiKey: string;
/// Gemini base URL(空=SDK 默认;走代理 / 镜像 endpoint 时覆盖)
geminiBaseUrl: string;
/// Gemini 默认模型(前端传逻辑键 "gemini" 时解析到此具体型号)
geminiDefaultModel: string;
/// LLM 调用上限(秒),防卡死 /// LLM 调用上限(秒),防卡死
requestTimeoutSec: number; requestTimeoutSec: number;
/// 价格表(¥/M tokens)— 从 AI_PRICE_TABLE_JSON env 读;调价时改 env 重启即可 /// 价格表(¥/M tokens)— 从 AI_PRICE_TABLE_JSON env 读;调价时改 env 重启即可
...@@ -48,6 +54,9 @@ export function loadConfig(): AppConfig { ...@@ -48,6 +54,9 @@ export function loadConfig(): AppConfig {
deepseekApiKey: process.env.DEEPSEEK_API_KEY ?? '', deepseekApiKey: process.env.DEEPSEEK_API_KEY ?? '',
deepseekBaseUrl: process.env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com', deepseekBaseUrl: process.env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com',
defaultModel: process.env.AI_DEFAULT_MODEL ?? 'deepseek-v4-flash', defaultModel: process.env.AI_DEFAULT_MODEL ?? 'deepseek-v4-flash',
geminiApiKey: process.env.GEMINI_API_KEY ?? '',
geminiBaseUrl: process.env.GEMINI_BASE_URL ?? '',
geminiDefaultModel: process.env.GEMINI_DEFAULT_MODEL ?? 'gemini-3.5-flash',
requestTimeoutSec: Number(process.env.AI_REQUEST_TIMEOUT_SEC ?? 60), requestTimeoutSec: Number(process.env.AI_REQUEST_TIMEOUT_SEC ?? 60),
priceTable: parsePriceTable(process.env.AI_PRICE_TABLE_JSON), priceTable: parsePriceTable(process.env.AI_PRICE_TABLE_JSON),
}, },
...@@ -82,6 +91,9 @@ function parsePriceTable(raw: string | undefined): Record<string, { inHit: numbe ...@@ -82,6 +91,9 @@ function parsePriceTable(raw: string | undefined): Record<string, { inHit: numbe
const DEFAULT = { const DEFAULT = {
'deepseek-v4-pro': { inHit: 0.5, inMiss: 3.6, out: 25 }, 'deepseek-v4-pro': { inHit: 0.5, inMiss: 3.6, out: 25 },
'deepseek-v4-flash': { inHit: 0.07, inMiss: 0.5, out: 2 }, 'deepseek-v4-flash': { inHit: 0.07, inMiss: 0.5, out: 2 },
// Gemini Flash 估算价(×7.2 汇率)— 实际调价改 AI_PRICE_TABLE_JSON env
'gemini-3.5-flash': { inHit: 0.54, inMiss: 2.16, out: 18 },
'gemini-2.5-flash': { inHit: 0.54, inMiss: 2.16, out: 18 },
}; };
if (!raw) return DEFAULT; if (!raw) return DEFAULT;
try { try {
......
...@@ -63,8 +63,9 @@ export class AiCallRunnerService { ...@@ -63,8 +63,9 @@ export class AiCallRunnerService {
ctx: AiCallContext, ctx: AiCallContext,
): Promise<AiCallResult<TOutput>> { ): Promise<AiCallResult<TOutput>> {
const inputHash = computeInputHash(call.callKey, call.promptVersion, input); const inputHash = computeInputHash(call.callKey, call.promptVersion, input);
const modelId = ctx.modelIdOverride ?? call.defaultModelId; const requestedModelId = ctx.modelIdOverride ?? call.defaultModelId;
const { model, provider } = this.provider.resolve(modelId); // resolve 返回规范化具体 modelId(裸键 "gemini" → "gemini-2.5-flash"),落账 / 计费都用它
const { model, provider, modelId } = this.provider.resolve(requestedModelId);
// ─── 1. 缓存查询 ─── // ─── 1. 缓存查询 ───
if (!ctx.bustCache) { if (!ctx.bustCache) {
...@@ -181,8 +182,8 @@ export class AiCallRunnerService { ...@@ -181,8 +182,8 @@ export class AiCallRunnerService {
ctx: AiCallContext, ctx: AiCallContext,
): AsyncGenerator<StreamEvent<TOutput>, void, void> { ): AsyncGenerator<StreamEvent<TOutput>, void, void> {
const inputHash = computeInputHash(call.callKey, call.promptVersion, input); const inputHash = computeInputHash(call.callKey, call.promptVersion, input);
const modelId = ctx.modelIdOverride ?? call.defaultModelId; const requestedModelId = ctx.modelIdOverride ?? call.defaultModelId;
const { model, provider } = this.provider.resolve(modelId); const { model, provider, modelId } = this.provider.resolve(requestedModelId);
const { system, prompt } = call.buildPrompt(input); const { system, prompt } = call.buildPrompt(input);
const invocationId = await this.recorder.start({ const invocationId = await this.recorder.start({
......
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { createDeepSeek, type DeepSeekProvider } from '@ai-sdk/deepseek'; import { createDeepSeek, type DeepSeekProvider } from '@ai-sdk/deepseek';
import { createGoogleGenerativeAI, type GoogleGenerativeAIProvider } from '@ai-sdk/google';
import type { LanguageModel } from 'ai'; import type { LanguageModel } from 'ai';
import type { AppConfig } from '../../../config/configuration'; import type { AppConfig } from '../../../config/configuration';
...@@ -8,44 +9,63 @@ import type { AppConfig } from '../../../config/configuration'; ...@@ -8,44 +9,63 @@ import type { AppConfig } from '../../../config/configuration';
* AiProviderService — 模型工厂。 * AiProviderService — 模型工厂。
* *
* harness 原则:AiCall 只声明 modelId 字符串,provider 决定怎么实例化。 * harness 原则:AiCall 只声明 modelId 字符串,provider 决定怎么实例化。
* 这里集中处理 vendor 路由(目前只有 deepseek;以后加 qwen 时本类内部扩 switch)。 * 这里集中处理 vendor 路由(deepseek / gemini;以后加 qwen 时本类内部扩 switch)。
* *
* 设计选择: * 设计选择:
* - DeepSeek 走官方 SDK @ai-sdk/deepseek(国内 endpoint,合规) * - DeepSeek 走官方 SDK @ai-sdk/deepseek(国内 endpoint,合规)
* - Gemini 走 @ai-sdk/google(generativelanguage.googleapis.com;海外需可达,可设 baseURL 走代理)
* - 不走 Vercel AI Gateway(美国基建,数据出境) * - 不走 Vercel AI Gateway(美国基建,数据出境)
* - modelId 字符串直传,不硬编码 enum(SDK v2.0.35 支持任意 string) * - modelId 字符串直传,不硬编码 enum;前端可只传逻辑键 "deepseek" / "gemini",
* 由本类解析到 config 配的具体型号(单一真源在后端 env)
*/ */
@Injectable() @Injectable()
export class AiProviderService { export class AiProviderService {
private readonly logger = new Logger(AiProviderService.name); private readonly logger = new Logger(AiProviderService.name);
private readonly deepseek: DeepSeekProvider; private readonly deepseek: DeepSeekProvider;
private readonly google: GoogleGenerativeAIProvider;
private readonly defaultModel: string; private readonly defaultModel: string;
private readonly geminiDefaultModel: string;
constructor(private readonly config: ConfigService<AppConfig, true>) { constructor(private readonly config: ConfigService<AppConfig, true>) {
const apiKey = this.config.get('ai', { infer: true }).deepseekApiKey; const ai = this.config.get('ai', { infer: true });
const baseURL = this.config.get('ai', { infer: true }).deepseekBaseUrl; this.defaultModel = ai.defaultModel;
this.defaultModel = this.config.get('ai', { infer: true }).defaultModel; this.geminiDefaultModel = ai.geminiDefaultModel;
if (!apiKey) { if (!ai.deepseekApiKey) {
this.logger.warn( this.logger.warn(
'DEEPSEEK_API_KEY 未设置 — AI 调用会失败。开发环境到 https://platform.deepseek.com/api_keys 申请', 'DEEPSEEK_API_KEY 未设置 — DeepSeek 调用会失败。开发环境到 https://platform.deepseek.com/api_keys 申请',
); );
} }
this.deepseek = createDeepSeek({ apiKey, baseURL }); this.deepseek = createDeepSeek({ apiKey: ai.deepseekApiKey, baseURL: ai.deepseekBaseUrl });
if (!ai.geminiApiKey) {
this.logger.warn('GEMINI_API_KEY 未设置 — Gemini 调用会失败。到 https://aistudio.google.com/apikey 申请');
}
this.google = createGoogleGenerativeAI({
apiKey: ai.geminiApiKey,
// baseURL 留空 → SDK 默认 endpoint;设了走代理 / 镜像
...(ai.geminiBaseUrl ? { baseURL: ai.geminiBaseUrl } : {}),
});
} }
/** /**
* 解析 modelId → vendor + 实例 * 解析 modelId → vendor 实例 + 规范化具体 modelId(落账 modelName / 计费 priceTable 用规范化后的)
* 命名约定: * 命名约定:
* "deepseek-v4-pro" → deepseek * "deepseek-v4-pro" / "deepseek-v4-flash" / "deepseek"(裸键)→ deepseek
* "deepseek-v4-flash" → deepseek * "gemini-2.5-flash" / "gemini"(裸键) → gemini
* "qwen-max"(将来) → qwen * "qwen-max"(将来) → qwen
* 裸键(无 "-" 版本)→ 解析到 config 配的默认型号;带版本 → 原样直传 SDK。
*/ */
resolve(modelId: string): { model: LanguageModel; provider: string } { resolve(modelId: string): { model: LanguageModel; provider: string; modelId: string } {
if (modelId.startsWith('deepseek-')) { if (modelId === 'deepseek' || modelId.startsWith('deepseek-')) {
return { model: this.deepseek(modelId), provider: 'deepseek' }; const canonical = modelId === 'deepseek' ? this.defaultModel : modelId;
return { model: this.deepseek(canonical), provider: 'deepseek', modelId: canonical };
}
if (modelId === 'gemini' || modelId.startsWith('gemini-')) {
const canonical = modelId === 'gemini' ? this.geminiDefaultModel : modelId;
return { model: this.google(canonical), provider: 'gemini', modelId: canonical };
} }
throw new Error(`Unsupported model id: ${modelId}(只支持 deepseek-* 前缀,后续扩 qwen-*)`); throw new Error(`Unsupported model id: ${modelId}(支持 deepseek-* / gemini-* 前缀或裸键,后续扩 qwen-*)`);
} }
getDefaultModelId(): string { getDefaultModelId(): string {
......
...@@ -15,6 +15,7 @@ ...@@ -15,6 +15,7 @@
"@pac/utils": "workspace:*", "@pac/utils": "workspace:*",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
......
...@@ -131,3 +131,23 @@ body { ...@@ -131,3 +131,23 @@ body {
from { opacity: 0; transform: translateY(2px); } from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
/* shimmerText — AI 流式生成时标题文字扫光闪动(GPT / Claude Code 风格) */
@keyframes shimmerText {
0% { background-position: 150% 0; }
100% { background-position: -150% 0; }
}
.shimmer-text {
background: linear-gradient(
100deg,
rgb(30 41 59) 35%,
rgb(148 163 184) 50%,
rgb(30 41 59) 65%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
animation: shimmerText 1.6s linear infinite;
}
...@@ -2,7 +2,14 @@ ...@@ -2,7 +2,14 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { RefreshCw } from 'lucide-react'; import { RefreshCw, ChevronDown } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from '@/components/ui/dropdown-menu';
import { plansApi } from '@/components/plans/plans-api'; import { plansApi } from '@/components/plans/plans-api';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { import {
...@@ -36,6 +43,9 @@ import { useScriptStream } from './use-script-stream'; ...@@ -36,6 +43,9 @@ import { useScriptStream } from './use-script-stream';
import { useSummaryStream } from './use-summary-stream'; import { useSummaryStream } from './use-summary-stream';
import { submitExecution, adaptAbandonReasons } from './execution-api'; import { submitExecution, adaptAbandonReasons } from './execution-api';
/// 话术生成模型(具体型号,直传后端 AiProviderService.resolve)
export type ScriptModel = 'deepseek-v4-pro' | 'deepseek-v4-flash' | 'gemini-3.5-flash';
export type PlanDetailAppData = { export type PlanDetailAppData = {
patient: typeof mockPatient; patient: typeof mockPatient;
chains: typeof mockChains; chains: typeof mockChains;
...@@ -75,6 +85,8 @@ export function PlanDetailApp({ ...@@ -75,6 +85,8 @@ export function PlanDetailApp({
const facts = data.facts ?? []; const facts = data.facts ?? [];
const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null); const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null);
const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown'); const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown');
// 话术生成模型(具体型号);默认 deepseek-v4-flash
const [scriptModel, setScriptModel] = useState<ScriptModel>('deepseek-v4-flash');
const { state: streamState, regenerate, abort } = useScriptStream(); const { state: streamState, regenerate, abort } = useScriptStream();
const { state: summaryState, regenerate: regenerateSummary } = useSummaryStream(); const { state: summaryState, regenerate: regenerateSummary } = useSummaryStream();
...@@ -281,12 +293,6 @@ export function PlanDetailApp({ ...@@ -281,12 +293,6 @@ export function PlanDetailApp({
<h2 className="text-[14px] font-semibold text-slate-900 leading-tight">参考话术</h2> <h2 className="text-[14px] font-semibold text-slate-900 leading-tight">参考话术</h2>
<p className="text-[10.5px] text-slate-500 mt-0.5"> <p className="text-[10.5px] text-slate-500 mt-0.5">
{displayedSections.length} {displayedSections.length}
{isStreaming && (
<span className="ml-2 inline-flex items-center gap-1 text-indigo-600">
<DotsThreePulse />
AI 正在生成
</span>
)}
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2"> <div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
...@@ -313,13 +319,14 @@ export function PlanDetailApp({ ...@@ -313,13 +319,14 @@ export function PlanDetailApp({
</div> </div>
<RegenBtn <RegenBtn
streaming={isStreaming} streaming={isStreaming}
onRegen={() => { model={scriptModel}
if (isStreaming) { onStop={() => {
abort(); abort();
showToast('slate', '已停止', '本次 AI 生成被中断'); showToast('slate', '已停止', '本次 AI 生成被中断');
return; }}
} onRegen={(m) => {
void regenerate(plan.id); setScriptModel(m);
void regenerate(plan.id, { model: m });
}} }}
/> />
{/* AI 时间戳 — 窄屏隐藏(信息不关键,腾空间) */} {/* AI 时间戳 — 窄屏隐藏(信息不关键,腾空间) */}
...@@ -338,15 +345,7 @@ export function PlanDetailApp({ ...@@ -338,15 +345,7 @@ export function PlanDetailApp({
</div> </div>
</header> </header>
<div className="flex-1 min-h-0 overflow-y-auto p-4"> <div className="flex-1 min-h-0 overflow-y-auto p-4">
<ScriptView mode={scriptMode} sections={displayedSections} /> <ScriptView mode={scriptMode} sections={displayedSections} streaming={isStreaming} />
{isStreaming && (
<div className="mt-3 text-[10.5px] text-slate-500 font-mono">
模型 {streamState.status === 'streaming' && streamState.modelId
? streamState.modelId
: 'deepseek-v4-pro'}{' '}
· 流式输出中…
</div>
)}
</div> </div>
<AIDisclaimerFooter <AIDisclaimerFooter
onFeedback={async (v) => { onFeedback={async (v) => {
...@@ -1221,18 +1220,38 @@ function PersonaQuickList({ features }: { features: typeof mockPersona.features ...@@ -1221,18 +1220,38 @@ function PersonaQuickList({ features }: { features: typeof mockPersona.features
} }
// 话术生成模型选项(具体型号,直传后端)
const SCRIPT_MODELS: { key: ScriptModel; label: string }[] = [
{ key: 'deepseek-v4-pro', label: 'DeepSeek V4 Pro' },
{ key: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash' },
{ key: 'gemini-3.5-flash', label: 'Gemini 3.5 Flash' },
];
// ────────────────────────────────────────── // ──────────────────────────────────────────
// RegenBtn — 流式重新生成按钮 // RegenBtn — 流式重新生成拆分按钮
// - 空闲态:点击触发 SSE 调用 // - 主键:空闲态点击触发 SSE 生成(用当前选中模型);流式态变"停止",可中断
// - 流式态:按钮变为"停止",可中断 // - 右侧 caret:DropdownMenu 选模型(DeepSeek / Gemini),选中即用该模型重新生成
// ────────────────────────────────────────── // ──────────────────────────────────────────
function RegenBtn({ streaming, onRegen }: { streaming: boolean; onRegen: () => void }) { function RegenBtn({
streaming,
model,
onStop,
onRegen,
}: {
streaming: boolean;
model: ScriptModel;
onStop: () => void;
onRegen: (model: ScriptModel) => void;
}) {
const current = SCRIPT_MODELS.find((m) => m.key === model) ?? SCRIPT_MODELS[0]!;
return ( return (
<div className="inline-flex flex-none items-stretch rounded overflow-hidden">
{/* 主键 */}
<button <button
onClick={onRegen} onClick={() => (streaming ? onStop() : onRegen(model))}
title={streaming ? '点击停止本次生成' : '重新生成话术'} title={streaming ? '点击停止本次生成' : `用 ${current.label} 重新生成话术`}
className={cn( className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10.5px] transition-colors', 'inline-flex items-center gap-1 px-2 py-0.5 text-[10.5px] transition-colors',
streaming streaming
? 'text-rose-600 bg-rose-50 hover:bg-rose-100' ? 'text-rose-600 bg-rose-50 hover:bg-rose-100'
: 'text-slate-500 hover:text-indigo-700 hover:bg-indigo-50', : 'text-slate-500 hover:text-indigo-700 hover:bg-indigo-50',
...@@ -1263,17 +1282,42 @@ function RegenBtn({ streaming, onRegen }: { streaming: boolean; onRegen: () => v ...@@ -1263,17 +1282,42 @@ function RegenBtn({ streaming, onRegen }: { streaming: boolean; onRegen: () => v
</> </>
)} )}
</button> </button>
); {/* 模型选择 caret */}
} <DropdownMenu>
<DropdownMenuTrigger asChild disabled={streaming}>
// 三点脉冲(streaming indicator) <button
function DotsThreePulse() { type="button"
return ( title="选择生成模型"
<span className="inline-flex items-center gap-[2px]"> disabled={streaming}
<span className="w-1 h-1 rounded-full bg-indigo-500 animate-[pulse_1.2s_ease-in-out_infinite]" /> className={cn(
<span className="w-1 h-1 rounded-full bg-indigo-500 animate-[pulse_1.2s_ease-in-out_0.2s_infinite]" /> 'inline-flex items-center gap-0.5 px-1 py-0.5 text-[10.5px] border-l transition-colors',
<span className="w-1 h-1 rounded-full bg-indigo-500 animate-[pulse_1.2s_ease-in-out_0.4s_infinite]" /> streaming
</span> ? 'text-rose-300 border-rose-100 cursor-not-allowed'
: 'text-slate-400 border-slate-200 hover:text-indigo-700 hover:bg-indigo-50',
)}
>
<span className="hidden sm:inline">{current.label}</span>
<ChevronDown className="w-3 h-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[11rem]">
<DropdownMenuRadioGroup
value={model}
onValueChange={(v) => onRegen(v as ScriptModel)}
>
{SCRIPT_MODELS.map((m) => (
<DropdownMenuRadioItem
key={m.key}
value={m.key}
className="text-[12px] font-medium text-slate-800"
>
{m.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
); );
} }
......
...@@ -11,7 +11,7 @@ export type ScriptViewMode = 'copilot' | 'cards' | 'markdown'; ...@@ -11,7 +11,7 @@ export type ScriptViewMode = 'copilot' | 'cards' | 'markdown';
// ────────────────────────────────────────── // ──────────────────────────────────────────
// 原文 — Markdown 全文(折叠分段) // 原文 — Markdown 全文(折叠分段)
// ────────────────────────────────────────── // ──────────────────────────────────────────
export function ScriptMarkdown({ sections }: { sections: ScriptSection[] }) { export function ScriptMarkdown({ sections, streaming = false }: { sections: ScriptSection[]; streaming?: boolean }) {
const [open, setOpen] = useState<Record<string, boolean>>(() => const [open, setOpen] = useState<Record<string, boolean>>(() =>
Object.fromEntries(sections.map((s, i) => [s.id, i === 0 || i === 1])), Object.fromEntries(sections.map((s, i) => [s.id, i === 0 || i === 1])),
); );
...@@ -27,7 +27,9 @@ export function ScriptMarkdown({ sections }: { sections: ScriptSection[] }) { ...@@ -27,7 +27,9 @@ export function ScriptMarkdown({ sections }: { sections: ScriptSection[] }) {
<span className="w-5 h-5 rounded text-[10.5px] flex items-center justify-center font-semibold bg-teal-50 text-teal-700"> <span className="w-5 h-5 rounded text-[10.5px] flex items-center justify-center font-semibold bg-teal-50 text-teal-700">
{idx + 1} {idx + 1}
</span> </span>
<span className="text-[13px] font-semibold text-slate-900">{sec.label}</span> <span className={cn('text-[13px] font-semibold text-slate-900', streaming && 'shimmer-text')}>
{sec.label}
</span>
<span className="text-[10.5px] text-slate-500 tabular-nums">· {sec.durationHint}</span> <span className="text-[10.5px] text-slate-500 tabular-nums">· {sec.durationHint}</span>
</div> </div>
<svg <svg
...@@ -54,7 +56,7 @@ export function ScriptMarkdown({ sections }: { sections: ScriptSection[] }) { ...@@ -54,7 +56,7 @@ export function ScriptMarkdown({ sections }: { sections: ScriptSection[] }) {
// ────────────────────────────────────────── // ──────────────────────────────────────────
// 卡片 — 步骤化卡片(平铺) // 卡片 — 步骤化卡片(平铺)
// ────────────────────────────────────────── // ──────────────────────────────────────────
export function ScriptStepCards({ sections }: { sections: ScriptSection[] }) { export function ScriptStepCards({ sections, streaming = false }: { sections: ScriptSection[]; streaming?: boolean }) {
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{sections.map((sec, i) => ( {sections.map((sec, i) => (
...@@ -64,7 +66,9 @@ export function ScriptStepCards({ sections }: { sections: ScriptSection[] }) { ...@@ -64,7 +66,9 @@ export function ScriptStepCards({ sections }: { sections: ScriptSection[] }) {
<span className="w-6 h-6 rounded-full bg-teal-600 text-white text-[11px] font-semibold flex items-center justify-center"> <span className="w-6 h-6 rounded-full bg-teal-600 text-white text-[11px] font-semibold flex items-center justify-center">
{i + 1} {i + 1}
</span> </span>
<span className="text-[13px] font-semibold text-slate-900">{sec.label}</span> <span className={cn('text-[13px] font-semibold text-slate-900', streaming && 'shimmer-text')}>
{sec.label}
</span>
</div> </div>
<span className="text-[10.5px] text-slate-500 tabular-nums">{sec.durationHint}</span> <span className="text-[10.5px] text-slate-500 tabular-nums">{sec.durationHint}</span>
</div> </div>
...@@ -80,7 +84,7 @@ export function ScriptStepCards({ sections }: { sections: ScriptSection[] }) { ...@@ -80,7 +84,7 @@ export function ScriptStepCards({ sections }: { sections: ScriptSection[] }) {
// ────────────────────────────────────────── // ──────────────────────────────────────────
// 伴飞 — 通话伴飞(单段高亮 + 进度条 + 上/下一段) // 伴飞 — 通话伴飞(单段高亮 + 进度条 + 上/下一段)
// ────────────────────────────────────────── // ──────────────────────────────────────────
export function ScriptCopilot({ sections }: { sections: ScriptSection[] }) { export function ScriptCopilot({ sections, streaming = false }: { sections: ScriptSection[]; streaming?: boolean }) {
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
const cur = sections[active]; const cur = sections[active];
if (!cur) return null; if (!cur) return null;
...@@ -124,7 +128,9 @@ export function ScriptCopilot({ sections }: { sections: ScriptSection[] }) { ...@@ -124,7 +128,9 @@ export function ScriptCopilot({ sections }: { sections: ScriptSection[] }) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-teal-500 animate-pulse" /> <span className="w-2 h-2 rounded-full bg-teal-500 animate-pulse" />
<span className="text-[11px] text-teal-700 font-semibold uppercase tracking-wide">当前段</span> <span className="text-[11px] text-teal-700 font-semibold uppercase tracking-wide">当前段</span>
<span className="text-[14px] font-semibold text-slate-900">{cur.label}</span> <span className={cn('text-[14px] font-semibold text-slate-900', streaming && 'shimmer-text')}>
{cur.label}
</span>
</div> </div>
<span className="text-[11px] text-slate-500 tabular-nums whitespace-nowrap">建议时长 {cur.durationHint}</span> <span className="text-[11px] text-slate-500 tabular-nums whitespace-nowrap">建议时长 {cur.durationHint}</span>
</div> </div>
...@@ -154,8 +160,16 @@ export function ScriptCopilot({ sections }: { sections: ScriptSection[] }) { ...@@ -154,8 +160,16 @@ export function ScriptCopilot({ sections }: { sections: ScriptSection[] }) {
} }
/** 按模式分发 */ /** 按模式分发 */
export function ScriptView({ mode, sections }: { mode: ScriptViewMode; sections: ScriptSection[] }) { export function ScriptView({
if (mode === 'cards') return <ScriptStepCards sections={sections} />; mode,
if (mode === 'markdown') return <ScriptMarkdown sections={sections} />; sections,
return <ScriptCopilot sections={sections} />; streaming = false,
}: {
mode: ScriptViewMode;
sections: ScriptSection[];
streaming?: boolean;
}) {
if (mode === 'cards') return <ScriptStepCards sections={sections} streaming={streaming} />;
if (mode === 'markdown') return <ScriptMarkdown sections={sections} streaming={streaming} />;
return <ScriptCopilot sections={sections} streaming={streaming} />;
} }
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />;
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
...@@ -35,6 +35,9 @@ importers: ...@@ -35,6 +35,9 @@ importers:
'@ai-sdk/deepseek': '@ai-sdk/deepseek':
specifier: ^2.0.35 specifier: ^2.0.35
version: 2.0.35(zod@4.4.3) version: 2.0.35(zod@4.4.3)
'@ai-sdk/google':
specifier: ^3.0.80
version: 3.0.80(zod@4.4.3)
'@bull-board/api': '@bull-board/api':
specifier: ^7.1.5 specifier: ^7.1.5
version: 7.1.5(@bull-board/ui@7.1.5) version: 7.1.5(@bull-board/ui@7.1.5)
...@@ -204,6 +207,9 @@ importers: ...@@ -204,6 +207,9 @@ importers:
'@radix-ui/react-dialog': '@radix-ui/react-dialog':
specifier: ^1.1.15 specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.16
version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-hover-card': '@radix-ui/react-hover-card':
specifier: ^1.1.15 specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
...@@ -305,6 +311,12 @@ packages: ...@@ -305,6 +311,12 @@ packages:
peerDependencies: peerDependencies:
zod: ^3.25.76 || ^4.1.8 zod: ^3.25.76 || ^4.1.8
'@ai-sdk/google@3.0.80':
resolution: {integrity: sha512-5ORbm/yFUPO0MEvZsxBMN0cdKw2+lwU/wVn5KN3KF8Dmk1LughuDuUohMh/7iU/XFTiyB0OvmTW/tdV/J7O9zg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@4.0.27': '@ai-sdk/provider-utils@4.0.27':
resolution: {integrity: sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==} resolution: {integrity: sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==}
engines: {node: '>=18'} engines: {node: '>=18'}
...@@ -1555,6 +1567,19 @@ packages: ...@@ -1555,6 +1567,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-dropdown-menu@2.1.16':
resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-focus-guards@1.1.3': '@radix-ui/react-focus-guards@1.1.3':
resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
peerDependencies: peerDependencies:
...@@ -1599,6 +1624,19 @@ packages: ...@@ -1599,6 +1624,19 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-menu@2.1.16':
resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.8': '@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies: peerDependencies:
...@@ -5699,6 +5737,12 @@ snapshots: ...@@ -5699,6 +5737,12 @@ snapshots:
'@vercel/oidc': 3.2.0 '@vercel/oidc': 3.2.0
zod: 4.4.3 zod: 4.4.3
'@ai-sdk/google@3.0.80(zod@4.4.3)':
dependencies:
'@ai-sdk/provider': 3.0.10
'@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
zod: 4.4.3
'@ai-sdk/provider-utils@4.0.27(zod@4.4.3)': '@ai-sdk/provider-utils@4.0.27(zod@4.4.3)':
dependencies: dependencies:
'@ai-sdk/provider': 3.0.10 '@ai-sdk/provider': 3.0.10
...@@ -6980,6 +7024,21 @@ snapshots: ...@@ -6980,6 +7024,21 @@ snapshots:
'@types/react': 19.2.14 '@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14) '@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.5)': '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.5)':
dependencies: dependencies:
react: 19.2.5 react: 19.2.5
...@@ -7021,6 +7080,32 @@ snapshots: ...@@ -7021,6 +7080,32 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.14 '@types/react': 19.2.14
'@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5)
aria-hidden: 1.2.6
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies: dependencies:
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
......
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