Enhance conversation history and runtime variable management
- Update ConversationRecorder to include source and nodeId metadata in transcripts for better context tracking. - Introduce optional variable handling in DynamicVariableStore, allowing for unset variables to be rendered as empty without raising errors. - Refactor WorkflowBrain to apply turn configurations and manage interaction policies dynamically, improving agent responsiveness. - Implement tests to ensure proper handling of updated session variables and workflow metadata in various scenarios.
This commit is contained in:
@@ -122,6 +122,10 @@ import {
|
||||
defaultGraph,
|
||||
type WorkflowGraph,
|
||||
} from "@/components/workflow/specs";
|
||||
import {
|
||||
defaultTurnConfig,
|
||||
normalizeTurnConfig,
|
||||
} from "@/lib/turn-config";
|
||||
|
||||
type RuntimeMode = "pipeline" | "realtime";
|
||||
|
||||
@@ -185,24 +189,6 @@ const assistantTypes: AssistantType[] = [
|
||||
"OpenCode",
|
||||
];
|
||||
|
||||
function defaultTurnConfig(): TurnConfig {
|
||||
return {
|
||||
bargeIn: {
|
||||
strategy: "vad",
|
||||
},
|
||||
vad: {
|
||||
confidence: 0.7,
|
||||
startSecs: 0.2,
|
||||
stopSecs: 0.2,
|
||||
minVolume: 0.6,
|
||||
},
|
||||
turnDetection: {
|
||||
strategy: "silence",
|
||||
silenceTimeoutSecs: 0.6,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function defaultKnowledgeRetrievalConfig(): KnowledgeRetrievalConfig {
|
||||
return {
|
||||
mode: "automatic",
|
||||
@@ -305,6 +291,45 @@ function activeDynamicVariableDefinitions(
|
||||
);
|
||||
}
|
||||
|
||||
function activeWorkflowDynamicVariableDefinitions(
|
||||
graph: WorkflowGraph,
|
||||
saved: Record<string, DynamicVariableDefinition>,
|
||||
): Record<string, DynamicVariableDefinition> {
|
||||
const names = new Set(extractDynamicVariableNames(JSON.stringify(graph)));
|
||||
|
||||
// Expression operands and Action assignment destinations are variable
|
||||
// references even though they do not use the {{placeholder}} syntax.
|
||||
for (const edge of graph.edges) {
|
||||
for (const rule of edge.data.expression?.rules ?? []) {
|
||||
if (isPublicDynamicVariableName(rule.variable)) names.add(rule.variable);
|
||||
}
|
||||
}
|
||||
for (const node of graph.nodes) {
|
||||
for (const name of Object.keys(node.data.resultAssignments ?? {})) {
|
||||
if (isPublicDynamicVariableName(name)) names.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
[...names].sort().map((name) => [
|
||||
name,
|
||||
saved[name] ?? {
|
||||
type: "string",
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function isPublicDynamicVariableName(name: string): boolean {
|
||||
return (
|
||||
/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(name) &&
|
||||
!name.startsWith("system__") &&
|
||||
!name.startsWith("secret__")
|
||||
);
|
||||
}
|
||||
|
||||
function blankFastGptForm(name: string): FastGptForm {
|
||||
return {
|
||||
name,
|
||||
@@ -492,6 +517,11 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
[form.prompt, form.greeting],
|
||||
form.dynamicVariableDefinitions,
|
||||
);
|
||||
const effectiveWorkflowDynamicVariableDefinitions =
|
||||
activeWorkflowDynamicVariableDefinitions(
|
||||
workflowGraph,
|
||||
workflowDynamicVariableDefinitions,
|
||||
);
|
||||
|
||||
const loadAssistants = useCallback(async () => {
|
||||
setListLoading(true);
|
||||
@@ -603,7 +633,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
knowledgeRetrievalConfig:
|
||||
a.knowledgeRetrievalConfig ?? defaultKnowledgeRetrievalConfig(),
|
||||
enableInterrupt: a.enableInterrupt,
|
||||
turnConfig: a.turnConfig,
|
||||
turnConfig: normalizeTurnConfig(a.turnConfig),
|
||||
visionEnabled: a.visionEnabled,
|
||||
visionModelResourceId: a.visionModelResourceId ?? "",
|
||||
toolIds: a.toolIds ?? [],
|
||||
@@ -711,7 +741,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
name: workflowName,
|
||||
graph: workflowGraph,
|
||||
settings: workflowSettings,
|
||||
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
|
||||
dynamicVariableDefinitions: effectiveWorkflowDynamicVariableDefinitions,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -756,7 +786,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
asr: a.modelResourceIds.ASR ?? "",
|
||||
voice: a.modelResourceIds.TTS ?? "",
|
||||
enableInterrupt: a.enableInterrupt,
|
||||
turnConfig: a.turnConfig,
|
||||
turnConfig: normalizeTurnConfig(a.turnConfig),
|
||||
};
|
||||
setDifyForm(next);
|
||||
return next;
|
||||
@@ -786,7 +816,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
asr: a.modelResourceIds.ASR ?? "",
|
||||
voice: a.modelResourceIds.TTS ?? "",
|
||||
enableInterrupt: a.enableInterrupt,
|
||||
turnConfig: a.turnConfig,
|
||||
turnConfig: normalizeTurnConfig(a.turnConfig),
|
||||
};
|
||||
setFastGptForm(next);
|
||||
return next;
|
||||
@@ -818,7 +848,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
asr: a.modelResourceIds.ASR ?? "",
|
||||
voice: a.modelResourceIds.TTS ?? "",
|
||||
enableInterrupt: a.enableInterrupt,
|
||||
turnConfig: a.turnConfig,
|
||||
turnConfig: normalizeTurnConfig(a.turnConfig),
|
||||
visionEnabled: a.visionEnabled,
|
||||
visionModelResourceId: a.visionModelResourceId ?? "",
|
||||
};
|
||||
@@ -872,21 +902,29 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
assistant.knowledgeRetrievalConfig.scoreThreshold,
|
||||
},
|
||||
globalPrompt: graph.settings?.globalPrompt ?? "",
|
||||
allowInterrupt: assistant.enableInterrupt,
|
||||
turnConfig: assistant.turnConfig,
|
||||
allowInterrupt:
|
||||
graph.settings?.enableInterrupt ?? assistant.enableInterrupt,
|
||||
turnConfig: normalizeTurnConfig(
|
||||
graph.settings?.turnConfig ?? assistant.turnConfig,
|
||||
),
|
||||
};
|
||||
setWorkflowName(assistant.name);
|
||||
setWorkflowGraph(graph);
|
||||
setWorkflowSettings(wfSettings);
|
||||
const dynamicVariableDefinitions =
|
||||
activeWorkflowDynamicVariableDefinitions(
|
||||
graph,
|
||||
assistant.dynamicVariableDefinitions ?? {},
|
||||
);
|
||||
setWorkflowDynamicVariableDefinitions(
|
||||
assistant.dynamicVariableDefinitions ?? {},
|
||||
dynamicVariableDefinitions,
|
||||
);
|
||||
setSavedSnapshot(
|
||||
JSON.stringify({
|
||||
name: assistant.name,
|
||||
graph,
|
||||
settings: wfSettings,
|
||||
dynamicVariableDefinitions: assistant.dynamicVariableDefinitions ?? {},
|
||||
dynamicVariableDefinitions,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -941,7 +979,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
knowledgeRetrievalConfig: workflowSettings.knowledgeRetrievalConfig,
|
||||
toolIds: workflowSettings.toolIds,
|
||||
graph: workflowGraph as unknown as Record<string, unknown>,
|
||||
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
|
||||
dynamicVariableDefinitions: effectiveWorkflowDynamicVariableDefinitions,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -961,7 +999,8 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
name: workflowName,
|
||||
graph: workflowGraph,
|
||||
settings: workflowSettings,
|
||||
dynamicVariableDefinitions: workflowDynamicVariableDefinitions,
|
||||
dynamicVariableDefinitions:
|
||||
effectiveWorkflowDynamicVariableDefinitions,
|
||||
})
|
||||
: null;
|
||||
const dirty =
|
||||
@@ -1473,7 +1512,9 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
hasUnsavedChanges={dirty}
|
||||
onNodeActive={setActiveNodeId}
|
||||
dynamicVariablesEnabled
|
||||
dynamicVariableDefinitions={workflowDynamicVariableDefinitions}
|
||||
dynamicVariableDefinitions={
|
||||
effectiveWorkflowDynamicVariableDefinitions
|
||||
}
|
||||
/>
|
||||
}
|
||||
activeNodeId={activeNodeId}
|
||||
@@ -1492,7 +1533,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
<DynamicVariablesDialog
|
||||
open={dynamicVariablesOpen}
|
||||
onOpenChange={setDynamicVariablesOpen}
|
||||
definitions={workflowDynamicVariableDefinitions}
|
||||
definitions={effectiveWorkflowDynamicVariableDefinitions}
|
||||
onChange={setWorkflowDynamicVariableDefinitions}
|
||||
/>
|
||||
</div>
|
||||
@@ -1872,80 +1913,6 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||
<SectionCard>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => updateForm("runtimeMode", "pipeline")}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
updateForm("runtimeMode", "pipeline");
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
"cursor-pointer rounded-xl border p-3.5 text-left transition-colors",
|
||||
form.runtimeMode === "pipeline"
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||
: "border-hairline bg-canvas-soft hover:border-hairline-strong",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||
<Waypoints size={15} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-foreground">Pipeline 模式</span>
|
||||
<HelpHint text="通过 ASR、LLM 和 TTS 级联组成语音管线,灵活选配各模块。" />
|
||||
</div>
|
||||
</div>
|
||||
{form.runtimeMode === "pipeline" && (
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => updateForm("runtimeMode", "realtime")}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
updateForm("runtimeMode", "realtime");
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
"cursor-pointer rounded-xl border p-3.5 text-left transition-colors",
|
||||
form.runtimeMode === "realtime"
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||
: "border-hairline bg-canvas-soft hover:border-hairline-strong",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||
<AudioLines size={15} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-foreground">Realtime 模式</span>
|
||||
<HelpHint text="使用原生实时语音模型,模型直接处理音频输入并生成语音回复。" />
|
||||
</div>
|
||||
</div>
|
||||
{form.runtimeMode === "realtime" && (
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<MessageSquareText size={15} />}
|
||||
title="提示词"
|
||||
@@ -1963,12 +1930,38 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
{form.runtimeMode === "pipeline" ? (
|
||||
<SectionCard
|
||||
icon={<Brain size={15} />}
|
||||
title="模型配置"
|
||||
description="从「模型资源」中选择大语言模型、语音识别与语音合成"
|
||||
>
|
||||
<SectionCard
|
||||
icon={<Bot size={15} />}
|
||||
title="开场白"
|
||||
description="助手与用户首次对话时的开场语"
|
||||
>
|
||||
<TextAreaField
|
||||
value={form.greeting}
|
||||
onChange={(value) => updateForm("greeting", value)}
|
||||
placeholder="请输入助手开场白"
|
||||
/>
|
||||
<DynamicVariableEditorHint
|
||||
count={Object.keys(effectiveDynamicVariableDefinitions).length}
|
||||
onOpen={() => setDynamicVariablesOpen(true)}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Brain size={15} />}
|
||||
title="模型配置"
|
||||
description={
|
||||
form.runtimeMode === "pipeline"
|
||||
? "选择运行方式,以及大语言模型、语音识别与语音合成资源"
|
||||
: "选择运行方式;Realtime 模型内置语音识别与语音合成"
|
||||
}
|
||||
>
|
||||
<RuntimeModeSelector
|
||||
value={form.runtimeMode}
|
||||
onChange={(runtimeMode) => updateForm("runtimeMode", runtimeMode)}
|
||||
/>
|
||||
|
||||
{form.runtimeMode === "pipeline" ? (
|
||||
<>
|
||||
<ToggleRow
|
||||
title="视觉理解"
|
||||
hint="开启后,开始对话时会允许助手按需理解当前视频画面。视觉模型选「模型自己」时,大语言模型本身必须支持图片输入。"
|
||||
@@ -2007,13 +2000,8 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
options={credOptions("TTS")}
|
||||
noneLabel="无"
|
||||
/>
|
||||
</SectionCard>
|
||||
) : (
|
||||
<SectionCard
|
||||
icon={<Brain size={15} />}
|
||||
title="模型配置"
|
||||
description="当前模式下 ASR 与 TTS 由 Realtime 模型内置完成"
|
||||
>
|
||||
</>
|
||||
) : (
|
||||
<ResourceSelectField
|
||||
label="Realtime 模型"
|
||||
value={form.realtimeModel}
|
||||
@@ -2021,23 +2009,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
options={credOptions("Realtime")}
|
||||
noneLabel="无"
|
||||
/>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<SectionCard
|
||||
icon={<Bot size={15} />}
|
||||
title="开场白"
|
||||
description="助手与用户首次对话时的开场语"
|
||||
>
|
||||
<TextAreaField
|
||||
value={form.greeting}
|
||||
onChange={(value) => updateForm("greeting", value)}
|
||||
placeholder="请输入助手开场白"
|
||||
/>
|
||||
<DynamicVariableEditorHint
|
||||
count={Object.keys(effectiveDynamicVariableDefinitions).length}
|
||||
onOpen={() => setDynamicVariablesOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{form.runtimeMode === "pipeline" && (
|
||||
@@ -2206,10 +2178,23 @@ function DebugDrawer({
|
||||
>({});
|
||||
const recording =
|
||||
preview.status === "connecting" || preview.status === "connected";
|
||||
const dynamicVariableEntries = Object.entries(dynamicVariableDefinitions);
|
||||
const displayedDefinitions = { ...dynamicVariableDefinitions };
|
||||
for (const [name, value] of Object.entries(preview.sessionVariables)) {
|
||||
displayedDefinitions[name] ??= {
|
||||
type:
|
||||
typeof value === "number"
|
||||
? "number"
|
||||
: typeof value === "boolean"
|
||||
? "boolean"
|
||||
: "string",
|
||||
required: false,
|
||||
default: null,
|
||||
};
|
||||
}
|
||||
const dynamicVariableEntries = Object.entries(displayedDefinitions);
|
||||
const resolvedDynamicVariables: Record<string, string | number | boolean> = {};
|
||||
let dynamicVariablesError = "";
|
||||
for (const [name, definition] of dynamicVariableEntries) {
|
||||
for (const [name, definition] of Object.entries(dynamicVariableDefinitions)) {
|
||||
const value = dynamicVariableValues[name] ?? definition.default;
|
||||
if (value === null || value === undefined || value === "") {
|
||||
if (definition.required && !dynamicVariablesError) {
|
||||
@@ -2265,7 +2250,8 @@ function DebugDrawer({
|
||||
<DynamicVariableValuesPopover
|
||||
entries={dynamicVariableEntries}
|
||||
values={dynamicVariableValues}
|
||||
disabled={recording}
|
||||
sessionValues={preview.sessionVariables}
|
||||
readOnly={recording}
|
||||
onChange={setDynamicVariableValues}
|
||||
/>
|
||||
)}
|
||||
@@ -2330,12 +2316,14 @@ function DebugDrawer({
|
||||
function DynamicVariableValuesPopover({
|
||||
entries,
|
||||
values,
|
||||
disabled,
|
||||
sessionValues,
|
||||
readOnly,
|
||||
onChange,
|
||||
}: {
|
||||
entries: [string, DynamicVariableDefinition][];
|
||||
values: Record<string, string | number | boolean>;
|
||||
disabled: boolean;
|
||||
sessionValues: Record<string, string | number | boolean>;
|
||||
readOnly: boolean;
|
||||
onChange: React.Dispatch<
|
||||
React.SetStateAction<Record<string, string | number | boolean>>
|
||||
>;
|
||||
@@ -2354,10 +2342,9 @@ function DynamicVariableValuesPopover({
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label="设置本次会话变量"
|
||||
title="本次会话变量"
|
||||
className="relative flex h-8 w-8 items-center justify-center rounded-full border border-hairline bg-canvas-soft text-muted-foreground transition-colors hover:bg-surface-strong hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label={readOnly ? "查看本次会话变量" : "设置本次会话变量"}
|
||||
title={readOnly ? "查看实时会话变量" : "本次会话变量"}
|
||||
className="relative flex h-8 w-8 items-center justify-center rounded-full border border-hairline bg-canvas-soft text-muted-foreground transition-colors hover:bg-surface-strong hover:text-foreground"
|
||||
>
|
||||
<Braces size={15} />
|
||||
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full border border-card bg-surface-strong px-1 text-[9px] tabular-nums text-foreground">
|
||||
@@ -2376,7 +2363,9 @@ function DynamicVariableValuesPopover({
|
||||
本次会话变量
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
这些值只用于下一次调试会话,不会修改助手配置。
|
||||
{readOnly
|
||||
? "当前值会在 Action 或工具更新变量后实时刷新。"
|
||||
: "这些值只用于下一次调试会话,不会修改助手配置。"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-h-72 space-y-3 overflow-y-auto pr-1">
|
||||
@@ -2386,11 +2375,14 @@ function DynamicVariableValuesPopover({
|
||||
当前没有会话变量
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] leading-5 text-muted-foreground">
|
||||
在提示词或开场白中添加 {"{{variable_name}}"} 后,可在这里设置调试值。
|
||||
在工作流提示词、节点话术、边条件或 Action 中引用变量后,
|
||||
可在这里设置调试值。
|
||||
</p>
|
||||
</div>
|
||||
) : entries.map(([name, definition]) => {
|
||||
const value = values[name] ?? definition.default ?? "";
|
||||
const value = readOnly
|
||||
? sessionValues[name] ?? definition.default ?? ""
|
||||
: values[name] ?? definition.default ?? "";
|
||||
return (
|
||||
<label key={name} className="block space-y-1.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
|
||||
@@ -2408,6 +2400,7 @@ function DynamicVariableValuesPopover({
|
||||
</span>
|
||||
{definition.type === "boolean" ? (
|
||||
<Select
|
||||
disabled={readOnly}
|
||||
value={value === "" ? "unset" : String(value)}
|
||||
onValueChange={(next) =>
|
||||
setValue(
|
||||
@@ -2430,6 +2423,7 @@ function DynamicVariableValuesPopover({
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
disabled={readOnly}
|
||||
type={definition.type === "number" ? "number" : "text"}
|
||||
value={typeof value === "boolean" ? String(value) : value}
|
||||
onChange={(event) => {
|
||||
@@ -3465,7 +3459,7 @@ function DynamicVariablesDialog({
|
||||
动态变量
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs leading-5">
|
||||
提示词和开场白中使用的自定义变量会自动出现在这里。
|
||||
助手配置中引用的自定义变量会自动出现在这里。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -3493,7 +3487,7 @@ function DynamicVariablesDialog({
|
||||
<div className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft px-4 py-5 text-center">
|
||||
<div className="text-sm font-medium text-foreground">还没有自定义变量</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
在提示词或开场白中输入 {"{{customer_name}}"} 即可添加。
|
||||
在提示词或节点话术中输入 {"{{customer_name}}"} 即可添加。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -3661,6 +3655,78 @@ function DynamicVariablesDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeModeSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: RuntimeMode;
|
||||
onChange: (mode: RuntimeMode) => void;
|
||||
}) {
|
||||
const options = [
|
||||
{
|
||||
value: "pipeline" as const,
|
||||
label: "Pipeline 模式",
|
||||
hint: "通过 ASR、LLM 和 TTS 级联组成语音管线,灵活选配各模块。",
|
||||
icon: Waypoints,
|
||||
},
|
||||
{
|
||||
value: "realtime" as const,
|
||||
label: "Realtime 模式",
|
||||
hint: "使用原生实时语音模型,模型直接处理音频输入并生成语音回复。",
|
||||
icon: AudioLines,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 border-b border-hairline-soft pb-4 md:grid-cols-2">
|
||||
{options.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const selected = value === option.value;
|
||||
const select = () => onChange(option.value);
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={select}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
select();
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
"cursor-pointer rounded-xl border p-3.5 text-left transition-colors",
|
||||
selected
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||
: "border-hairline bg-canvas-soft hover:border-hairline-strong",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||
<Icon size={15} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{option.label}
|
||||
</span>
|
||||
<HelpHint text={option.hint} />
|
||||
</div>
|
||||
</div>
|
||||
{selected && (
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextAreaField({
|
||||
label,
|
||||
value,
|
||||
|
||||
Reference in New Issue
Block a user