feat: add runtime API config page (#113)

This commit is contained in:
lyfics
2026-06-22 14:15:57 +08:00
committed by GitHub
parent 23429cde77
commit 8a2dc83094
14 changed files with 987 additions and 468 deletions

View File

@@ -34,7 +34,7 @@ OPENTALKING_TTS_STREAMING_DECODE=1
OPENTALKING_LLM_PROVIDER=openai_compatible
OPENTALKING_LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
OPENTALKING_LLM_API_KEY=
OPENTALKING_LLM_MODEL=qwen-turbo
OPENTALKING_LLM_MODEL=qwen-flash
OPENTALKING_LLM_SYSTEM_PROMPT=You are a friendly digital human assistant.
# Agent 知识库检索使用 LightRAG。默认复用上面的 LLM base_url/api_key

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from dataclasses import asdict
from typing import Literal
import uuid
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
@@ -44,6 +45,10 @@ def _ensure_character(value: str | None) -> str:
return character_id
def _library_id(value: str | None) -> str:
return (value or "").strip() or f"lib_{uuid.uuid4().hex[:12]}"
@router.get("/libraries")
async def list_libraries(
profile_id: str = Query("default"),
@@ -61,7 +66,7 @@ async def list_libraries(
async def create_library(body: MemoryLibraryRequest) -> dict[str, object]:
provider = build_memory_provider()
library = await provider.create_library(
library_id=(body.id or "").strip() or None,
library_id=_library_id(body.id),
name=(body.name or "").strip() or None,
profile_id=_profile(body.profile_id),
character_id=_ensure_character(body.character_id),

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import os
import re
import shutil
import time
from pathlib import Path
@@ -10,6 +11,7 @@ from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from apps.api.core.config import get_settings
from opentalking.providers.memory.factory import build_memory_provider
from opentalking.providers.stt.factory import (
clear_stt_adapter_cache,
normalize_stt_provider,
@@ -22,6 +24,7 @@ from opentalking.providers.tts.providers import normalize_tts_provider
router = APIRouter(prefix="/runtime-config", tags=["runtime-config"])
_ENV_PATH = Path(__file__).resolve().parents[3] / ".env"
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)")
_RUNTIME_ENV_KEYS = {
"DASHSCOPE_API_KEY",
@@ -70,6 +73,14 @@ _RUNTIME_ENV_KEYS = {
"OPENTALKING_TTS_XIAOMI_MODEL",
"OPENTALKING_TTS_XIAOMI_VOICE",
"OPENTALKING_TTS_XIAOMI_API_KEY",
"OPENTALKING_MEMORY_MEM0_LLM_PROVIDER",
"OPENTALKING_MEMORY_MEM0_LLM_BASE_URL",
"OPENTALKING_MEMORY_MEM0_LLM_API_KEY",
"OPENTALKING_MEMORY_MEM0_LLM_MODEL",
"OPENTALKING_MEMORY_MEM0_EMBEDDER_PROVIDER",
"OPENTALKING_MEMORY_MEM0_EMBEDDER_BASE_URL",
"OPENTALKING_MEMORY_MEM0_EMBEDDER_API_KEY",
"OPENTALKING_MEMORY_MEM0_EMBEDDER_MODEL",
}
@@ -86,6 +97,14 @@ class RuntimeConfigPayload(BaseModel):
tts_model: Optional[str] = Field(default=None, max_length=256)
tts_voice: Optional[str] = Field(default=None, max_length=256)
tts_api_key: Optional[str] = Field(default=None, max_length=4096)
mem0_llm_provider: Optional[str] = Field(default=None, max_length=64)
mem0_llm_base_url: Optional[str] = Field(default=None, max_length=2048)
mem0_llm_api_key: Optional[str] = Field(default=None, max_length=4096)
mem0_llm_model: Optional[str] = Field(default=None, max_length=256)
mem0_embedder_provider: Optional[str] = Field(default=None, max_length=64)
mem0_embedder_base_url: Optional[str] = Field(default=None, max_length=2048)
mem0_embedder_api_key: Optional[str] = Field(default=None, max_length=4096)
mem0_embedder_model: Optional[str] = Field(default=None, max_length=256)
sync_dashscope_api_key: bool = True
@@ -125,7 +144,22 @@ def _read_env_lines(path: Path) -> tuple[list[str], dict[str, str]]:
def _env_value(values: dict[str, str], key: str, fallback: str = "") -> str:
return os.environ.get(key, "").strip() or values.get(key, "").strip() or fallback
value = os.environ.get(key, "").strip() or values.get(key, "").strip() or fallback
return _expand_env_refs(str(value), values).strip()
def _expand_env_refs(value: str, values: dict[str, str]) -> str:
def replace(match: re.Match[str]) -> str:
key = match.group(1) or match.group(2) or ""
return os.environ.get(key, "") or values.get(key, "") or match.group(0)
previous = value
for _ in range(5):
expanded = _ENV_REF_RE.sub(replace, previous)
if expanded == previous:
return expanded
previous = expanded
return previous
def _settings_value(settings: Any, name: str, default: str = "") -> str:
@@ -286,6 +320,61 @@ def _current_tts_payload(provider: str, settings: Any, values: dict[str, str]) -
}
def _current_mem0_model_payload(
*,
values: dict[str, str],
settings: Any,
prefix: str,
settings_prefix: str,
default_model: str,
) -> dict[str, Any]:
provider = _env_value(
values,
f"OPENTALKING_MEMORY_MEM0_{prefix}_PROVIDER",
_settings_value(settings, f"memory_mem0_{settings_prefix}_provider", "openai"),
)
base_url = _env_value(
values,
f"OPENTALKING_MEMORY_MEM0_{prefix}_BASE_URL",
_settings_value(settings, f"memory_mem0_{settings_prefix}_base_url"),
)
model = _env_value(
values,
f"OPENTALKING_MEMORY_MEM0_{prefix}_MODEL",
_settings_value(settings, f"memory_mem0_{settings_prefix}_model", default_model),
)
key = _env_value(
values,
f"OPENTALKING_MEMORY_MEM0_{prefix}_API_KEY",
_settings_value(settings, f"memory_mem0_{settings_prefix}_api_key"),
)
return {
"provider": provider or "openai",
"base_url": base_url.rstrip("/"),
"model": model,
"api_key_set": bool(key),
}
def _current_mem0_payload(settings: Any, values: dict[str, str]) -> dict[str, Any]:
return {
"llm": _current_mem0_model_payload(
values=values,
settings=settings,
prefix="LLM",
settings_prefix="llm",
default_model="qwen-flash",
),
"embedder": _current_mem0_model_payload(
values=values,
settings=settings,
prefix="EMBEDDER",
settings_prefix="embedder",
default_model="text-embedding-v4",
),
}
def _current_payload(settings: Any | None = None) -> dict[str, Any]:
settings = settings or get_settings()
_, values = _read_env_lines(_ENV_PATH)
@@ -306,11 +395,12 @@ def _current_payload(settings: Any | None = None) -> dict[str, Any]:
return {
"llm": {
"base_url": _env_value(values, "OPENTALKING_LLM_BASE_URL", _settings_value(settings, "llm_base_url")).rstrip("/"),
"model": _env_value(values, "OPENTALKING_LLM_MODEL", _settings_value(settings, "llm_model", "qwen-turbo")),
"model": _env_value(values, "OPENTALKING_LLM_MODEL", _settings_value(settings, "llm_model", "qwen-flash")),
"api_key_set": bool(llm_key),
},
"stt": _current_stt_payload(stt_provider, settings, values),
"tts": _current_tts_payload(tts_provider, settings, values),
"mem0": _current_mem0_payload(settings, values),
}
@@ -416,6 +506,26 @@ def _build_updates(payload: RuntimeConfigPayload) -> dict[str, str]:
updates["OPENTALKING_TTS_DASHSCOPE_API_KEY"] = value
sync_key = sync_key or value
if value := _strip(payload.mem0_llm_provider):
updates["OPENTALKING_MEMORY_MEM0_LLM_PROVIDER"] = value
if value := _strip(payload.mem0_llm_base_url):
updates["OPENTALKING_MEMORY_MEM0_LLM_BASE_URL"] = value.rstrip("/")
if value := _strip(payload.mem0_llm_model):
updates["OPENTALKING_MEMORY_MEM0_LLM_MODEL"] = value
if value := _strip(payload.mem0_llm_api_key):
updates["OPENTALKING_MEMORY_MEM0_LLM_API_KEY"] = value
sync_key = sync_key or value
if value := _strip(payload.mem0_embedder_provider):
updates["OPENTALKING_MEMORY_MEM0_EMBEDDER_PROVIDER"] = value
if value := _strip(payload.mem0_embedder_base_url):
updates["OPENTALKING_MEMORY_MEM0_EMBEDDER_BASE_URL"] = value.rstrip("/")
if value := _strip(payload.mem0_embedder_model):
updates["OPENTALKING_MEMORY_MEM0_EMBEDDER_MODEL"] = value
if value := _strip(payload.mem0_embedder_api_key):
updates["OPENTALKING_MEMORY_MEM0_EMBEDDER_API_KEY"] = value
sync_key = sync_key or value
if payload.sync_dashscope_api_key and sync_key:
updates.setdefault("DASHSCOPE_API_KEY", sync_key)
return updates
@@ -426,6 +536,7 @@ def _refresh_settings(request: Request) -> Any:
settings = get_settings()
request.app.state.settings = settings
clear_stt_adapter_cache()
build_memory_provider.cache_clear()
return settings

View File

@@ -69,6 +69,36 @@ def test_memory_api_import_list_delete(monkeypatch) -> None:
assert deleted.json() == {"deleted": True}
def test_memory_api_generates_unique_library_ids_when_id_is_omitted(monkeypatch) -> None:
provider = InMemoryMemoryProvider()
monkeypatch.setattr(memory_routes, "build_memory_provider", lambda: provider)
app = FastAPI()
app.include_router(memory_routes.router)
with TestClient(app) as client:
first = client.post(
"/memory/libraries",
json={"name": "First", "character_id": "avatar-a"},
)
second = client.post(
"/memory/libraries",
json={"name": "Second", "character_id": "avatar-a"},
)
libraries = client.get(
"/memory/libraries",
params={"profile_id": "default", "character_id": "avatar-a"},
)
assert first.status_code == 200
assert second.status_code == 200
assert first.json()["id"] != second.json()["id"]
assert first.json()["id"] != "default"
assert second.json()["id"] != "default"
assert libraries.status_code == 200
assert len(libraries.json()["items"]) == 2
def test_memory_api_uses_configured_sqlite_provider(monkeypatch, tmp_path) -> None:
config_file = tmp_path / "opentalking.yaml"
config_file.write_text(

View File

@@ -42,6 +42,14 @@ async def test_runtime_config_get_masks_secret_values(monkeypatch, tmp_path) ->
"OPENTALKING_TTS_OPENAI_BASE_URL=https://tts.example.test/v1",
"OPENTALKING_TTS_OPENAI_MODEL=gpt-4o-mini-tts",
"OPENTALKING_TTS_OPENAI_API_KEY=sk-tts-secret",
"OPENTALKING_MEMORY_MEM0_LLM_PROVIDER=openai",
"OPENTALKING_MEMORY_MEM0_LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1",
"OPENTALKING_MEMORY_MEM0_LLM_MODEL=qwen-flash",
"OPENTALKING_MEMORY_MEM0_LLM_API_KEY=sk-mem0-llm-secret",
"OPENTALKING_MEMORY_MEM0_EMBEDDER_PROVIDER=openai",
"OPENTALKING_MEMORY_MEM0_EMBEDDER_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1",
"OPENTALKING_MEMORY_MEM0_EMBEDDER_MODEL=text-embedding-v4",
"OPENTALKING_MEMORY_MEM0_EMBEDDER_API_KEY=sk-mem0-embedder-secret",
]
)
+ "\n",
@@ -59,9 +67,39 @@ async def test_runtime_config_get_masks_secret_values(monkeypatch, tmp_path) ->
assert payload["tts"]["base_url"] == "https://tts.example.test/v1"
assert payload["tts"]["model"] == "gpt-4o-mini-tts"
assert payload["tts"]["api_key_set"] is True
assert payload["mem0"]["llm"]["provider"] == "openai"
assert payload["mem0"]["llm"]["base_url"] == "https://dashscope.aliyuncs.com/compatible-mode/v1"
assert payload["mem0"]["llm"]["model"] == "qwen-flash"
assert payload["mem0"]["llm"]["api_key_set"] is True
assert payload["mem0"]["embedder"]["provider"] == "openai"
assert payload["mem0"]["embedder"]["base_url"] == "https://dashscope.aliyuncs.com/compatible-mode/v1"
assert payload["mem0"]["embedder"]["model"] == "text-embedding-v4"
assert payload["mem0"]["embedder"]["api_key_set"] is True
assert "sk-llm-secret" not in str(payload)
assert "sk-stt-secret" not in str(payload)
assert "sk-tts-secret" not in str(payload)
assert "sk-mem0-llm-secret" not in str(payload)
assert "sk-mem0-embedder-secret" not in str(payload)
async def test_runtime_config_get_expands_mem0_base_url_references(monkeypatch, tmp_path) -> None:
(tmp_path / ".env").write_text(
"\n".join(
[
"OPENTALKING_LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1",
"OPENTALKING_MEMORY_MEM0_LLM_BASE_URL=${OPENTALKING_LLM_BASE_URL}",
"OPENTALKING_MEMORY_MEM0_EMBEDDER_BASE_URL=${OPENTALKING_LLM_BASE_URL}",
]
)
+ "\n",
encoding="utf-8",
)
payload = await runtime_config.get_runtime_config(_request(monkeypatch, tmp_path))
assert payload["mem0"]["llm"]["base_url"] == "https://dashscope.aliyuncs.com/compatible-mode/v1"
assert payload["mem0"]["embedder"]["base_url"] == "https://dashscope.aliyuncs.com/compatible-mode/v1"
assert "${OPENTALKING_LLM_BASE_URL}" not in str(payload)
async def test_runtime_config_apply_persists_llm_stt_tts_and_keeps_blank_keys(monkeypatch, tmp_path) -> None:
@@ -71,6 +109,8 @@ async def test_runtime_config_apply_persists_llm_stt_tts_and_keeps_blank_keys(mo
"OPENTALKING_LLM_API_KEY=sk-existing-llm",
"OPENTALKING_STT_OPENAI_API_KEY=sk-existing-stt",
"OPENTALKING_TTS_OPENAI_API_KEY=sk-existing-tts",
"OPENTALKING_MEMORY_MEM0_LLM_API_KEY=sk-existing-mem0-llm",
"OPENTALKING_MEMORY_MEM0_EMBEDDER_API_KEY=sk-existing-mem0-embedder",
]
)
+ "\n",
@@ -91,6 +131,14 @@ async def test_runtime_config_apply_persists_llm_stt_tts_and_keeps_blank_keys(mo
tts_model="gpt-4o-mini-tts",
tts_voice="alloy",
tts_api_key="",
mem0_llm_provider="openai",
mem0_llm_base_url="https://mem0-llm.example.test/v1/",
mem0_llm_model="qwen-flash",
mem0_llm_api_key="",
mem0_embedder_provider="openai",
mem0_embedder_base_url="https://mem0-embed.example.test/v1/",
mem0_embedder_model="text-embedding-v4",
mem0_embedder_api_key="",
),
_request(monkeypatch, tmp_path),
)
@@ -108,12 +156,58 @@ async def test_runtime_config_apply_persists_llm_stt_tts_and_keeps_blank_keys(mo
assert payload["tts"]["model"] == "gpt-4o-mini-tts"
assert payload["tts"]["voice"] == "alloy"
assert payload["tts"]["api_key_set"] is True
assert payload["mem0"]["llm"]["provider"] == "openai"
assert payload["mem0"]["llm"]["base_url"] == "https://mem0-llm.example.test/v1"
assert payload["mem0"]["llm"]["model"] == "qwen-flash"
assert payload["mem0"]["llm"]["api_key_set"] is True
assert payload["mem0"]["embedder"]["provider"] == "openai"
assert payload["mem0"]["embedder"]["base_url"] == "https://mem0-embed.example.test/v1"
assert payload["mem0"]["embedder"]["model"] == "text-embedding-v4"
assert payload["mem0"]["embedder"]["api_key_set"] is True
assert "sk-existing-llm" not in str(payload)
assert "sk-existing-stt" not in str(payload)
assert "sk-existing-tts" not in str(payload)
assert "sk-existing-mem0-llm" not in str(payload)
assert "sk-existing-mem0-embedder" not in str(payload)
assert os.environ["OPENTALKING_LLM_API_KEY"] == "sk-existing-llm"
assert os.environ["OPENTALKING_STT_OPENAI_API_KEY"] == "sk-existing-stt"
assert os.environ["OPENTALKING_TTS_OPENAI_API_KEY"] == "sk-existing-tts"
assert os.environ["OPENTALKING_MEMORY_MEM0_LLM_API_KEY"] == "sk-existing-mem0-llm"
assert os.environ["OPENTALKING_MEMORY_MEM0_EMBEDDER_API_KEY"] == "sk-existing-mem0-embedder"
async def test_runtime_config_apply_updates_mem0_keys_and_refreshes_memory_provider(monkeypatch, tmp_path) -> None:
cleared = False
def fake_cache_clear() -> None:
nonlocal cleared
cleared = True
monkeypatch.setattr(runtime_config, "build_memory_provider", SimpleNamespace(cache_clear=fake_cache_clear))
payload = await runtime_config.apply_runtime_config(
runtime_config.RuntimeConfigPayload(
mem0_llm_provider="openai",
mem0_llm_base_url="https://dashscope.aliyuncs.com/compatible-mode/v1/",
mem0_llm_model="qwen-flash",
mem0_llm_api_key="sk-new-mem0-llm",
mem0_embedder_provider="openai",
mem0_embedder_base_url="https://dashscope.aliyuncs.com/compatible-mode/v1/",
mem0_embedder_model="text-embedding-v4",
mem0_embedder_api_key="sk-new-mem0-embedder",
sync_dashscope_api_key=False,
),
_request(monkeypatch, tmp_path),
)
assert cleared is True
assert payload["mem0"]["llm"]["api_key_set"] is True
assert payload["mem0"]["embedder"]["api_key_set"] is True
assert "sk-new-mem0-llm" not in str(payload)
assert "sk-new-mem0-embedder" not in str(payload)
assert os.environ["OPENTALKING_MEMORY_MEM0_LLM_API_KEY"] == "sk-new-mem0-llm"
assert os.environ["OPENTALKING_MEMORY_MEM0_EMBEDDER_API_KEY"] == "sk-new-mem0-embedder"
assert os.environ.get("DASHSCOPE_API_KEY") != "sk-new-mem0-llm"
async def test_runtime_config_apply_rejects_unknown_provider(monkeypatch, tmp_path) -> None:

View File

@@ -10,6 +10,7 @@ import {
type FasterLivePortraitConfig,
type Wav2LipPostprocessMode,
} from "./components/SettingsPanel";
import { RuntimeConfigWorkspace } from "./components/RuntimeConfigWorkspace";
import { TopBar, type StudioWorkflow } from "./components/TopBar";
import { ToastStack, type ToastMessage, type ToastTone } from "./components/ToastStack";
import { VideoBackground } from "./components/VideoBackground";
@@ -2616,6 +2617,22 @@ export default function App() {
? EDGE_ZH_VOICES.find((voice) => voice.id === edgeVoice)?.label ?? edgeVoice
: bailianVoices.find((voice) => voice.id === qwenVoice)?.label ?? (qwenVoice || "暂无音色");
const selectedMemoryLibrary = memoryLibraries.find((library) => library.id === memoryLibraryId) ?? null;
const runtimeConfigTtsProvider = runtimeConfig?.tts.provider ?? "";
const runtimeConfigTtsReady = Boolean(
runtimeConfig?.tts.api_key_set
|| runtimeConfigTtsProvider === "edge"
|| runtimeConfigTtsProvider === "local_cosyvoice"
|| runtimeConfigTtsProvider === "indextts"
|| runtimeConfigTtsProvider === "local_indextts"
|| runtimeConfigTtsProvider === "omnirt_indextts",
);
const runtimeConfigReady = Boolean(
runtimeConfig?.llm.api_key_set
&& runtimeConfig.stt.api_key_set
&& runtimeConfigTtsReady
&& runtimeConfig.mem0?.llm.api_key_set
&& runtimeConfig.mem0?.embedder.api_key_set,
);
const memorySummary = {
enabled: memoryEnabled && Boolean(selectedMemoryLibrary),
libraryName: selectedMemoryLibrary?.name || selectedMemoryLibrary?.id || null,
@@ -2630,6 +2647,8 @@ export default function App() {
flashtalkRecordPhase={ftRecordPhase}
flashtalkRecordBusy={ftRecordBusy}
recordingSaving={recordingSaving}
runtimeConfigReady={runtimeConfigReady}
runtimeConfigLoading={runtimeConfigLoading}
onInactiveModuleClick={(label) => notify(`${label}模块规划中。当前可用的是实时对话、视频克隆、数字人配置、语音驱动和导出能力。`, "info")}
onWorkflowChange={(next) => {
setWorkflow(next);
@@ -2731,6 +2750,16 @@ export default function App() {
onNotify={notify}
/>
</div>
) : workflow === "runtimeConfig" ? (
<div className="flex min-h-0 lg:h-[calc(100vh-3.5rem)]">
<RuntimeConfigWorkspace
runtimeConfig={runtimeConfig}
runtimeConfigLoading={runtimeConfigLoading}
runtimeConfigApplying={runtimeConfigApplying}
onRuntimeConfigRefresh={() => void refreshRuntimeConfig()}
onRuntimeConfigApply={handleApplyRuntimeConfig}
/>
</div>
) : (
<div className="flex min-h-0 flex-col lg:h-[calc(100vh-3.5rem)] lg:flex-row">
<div className="order-2 min-h-0 lg:order-none lg:h-full lg:shrink-0">
@@ -2743,11 +2772,6 @@ export default function App() {
avatarId={avatarId}
model={model}
modelConnected={selectedModelConnected}
runtimeConfig={runtimeConfig}
runtimeConfigLoading={runtimeConfigLoading}
runtimeConfigApplying={runtimeConfigApplying}
onRuntimeConfigRefresh={() => void refreshRuntimeConfig()}
onRuntimeConfigApply={handleApplyRuntimeConfig}
wav2lipPostprocessMode={wav2lipPostprocessMode}
wav2lipPostprocessModeLocked={wav2lipPostprocessModeLocked}
fasterliveportraitConfig={fasterliveportraitConfig}

View File

@@ -388,7 +388,7 @@ export function BailianVoiceClone({ onSuccess, onClose }: BailianVoiceCloneProps
onChange={(e) => onProviderChange(e.target.value as CloneProvider)}
disabled={busy}
>
<option value="dashscope">DashScope </option>
<option value="dashscope"></option>
<option value="xiaomi_mimo"> MiMo VoiceClone</option>
<option value="local_cosyvoice"> CosyVoice</option>
<option value="indextts">Local IndexTTS</option>

View File

@@ -40,7 +40,7 @@ async function awaitSttWsReply(ws: WebSocket): Promise<{ text?: string; error?:
settle(() =>
reject(
new Error(
`语音识别等待超时(${STT_WS_REPLY_MS / 1000}s。若后端无 STT 日志,多为连接未到达服务或 DashScope 阻塞。`,
`语音识别等待超时(${STT_WS_REPLY_MS / 1000}s。若后端无 STT 日志,多为连接未到达服务或百炼阻塞。`,
),
),
);

View File

@@ -0,0 +1,590 @@
import { useEffect, useState } from "react";
import type { RuntimeConfigApplyInput, RuntimeConfigResponse } from "../lib/api";
import type { TtsProviderExtended } from "../constants/ttsBailian";
const RUNTIME_LLM_DEFAULT = {
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
model: "qwen-flash",
};
const RUNTIME_STT_PRESETS: Record<string, { label: string; baseUrl: string; model: string; needsKey: boolean }> = {
dashscope: {
label: "百炼",
baseUrl: "https://dashscope.aliyuncs.com",
model: "paraformer-realtime-v2",
needsKey: true,
},
openai_compatible: {
label: "OpenAI-compatible",
baseUrl: "https://api.openai.com/v1",
model: "whisper-1",
needsKey: true,
},
xiaomi_mimo: {
label: "小米 MiMo",
baseUrl: "",
model: "mimo-v2.5-asr",
needsKey: true,
},
sensevoice: {
label: "SenseVoice",
baseUrl: "",
model: "iic/SenseVoiceSmall",
needsKey: false,
},
};
const RUNTIME_TTS_PRESETS: Record<TtsProviderExtended, { label: string; baseUrl: string; model: string; voice: string; needsKey: boolean }> = {
edge: {
label: "Edge无需配置",
baseUrl: "",
model: "",
voice: "zh-CN-XiaoxiaoNeural",
needsKey: false,
},
dashscope: {
label: "Qwen",
baseUrl: "wss://dashscope.aliyuncs.com/api-ws/v1/realtime",
model: "qwen3-tts-flash-realtime",
voice: "Cherry",
needsKey: true,
},
cosyvoice: {
label: "CosyVoice",
baseUrl: "",
model: "cosyvoice-v3-flash",
voice: "longanyang",
needsKey: true,
},
sambert: {
label: "Sambert",
baseUrl: "",
model: "sambert-zhichu-v1",
voice: "",
needsKey: true,
},
local_cosyvoice: {
label: "Local CosyVoice",
baseUrl: "http://127.0.0.1:9880",
model: "FunAudioLLM/Fun-CosyVoice3-0.5B-2512",
voice: "",
needsKey: false,
},
indextts: {
label: "Local IndexTTS",
baseUrl: "http://127.0.0.1:9880",
model: "IndexTeam/IndexTTS-2",
voice: "",
needsKey: false,
},
xiaomi_mimo: {
label: "小米 MiMo",
baseUrl: "",
model: "mimo-v2.5-tts",
voice: "mimo_default",
needsKey: true,
},
openai_compatible: {
label: "OpenAI-compatible",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy",
needsKey: true,
},
};
const MEM0_MODEL_PRESETS = {
llm: {
provider: "openai",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
model: "qwen-flash",
},
embedder: {
provider: "openai",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
model: "text-embedding-v4",
},
};
type RuntimeConfigForm = {
llmBaseUrl: string;
llmModel: string;
llmApiKey: string;
sttProvider: string;
sttBaseUrl: string;
sttModel: string;
sttApiKey: string;
ttsProvider: TtsProviderExtended;
ttsBaseUrl: string;
ttsModel: string;
ttsApiKey: string;
mem0LlmProvider: string;
mem0LlmBaseUrl: string;
mem0LlmModel: string;
mem0LlmApiKey: string;
mem0EmbedderProvider: string;
mem0EmbedderBaseUrl: string;
mem0EmbedderModel: string;
mem0EmbedderApiKey: string;
syncDashscopeApiKey: boolean;
};
const RUNTIME_FORM_DEFAULTS: RuntimeConfigForm = {
llmBaseUrl: RUNTIME_LLM_DEFAULT.baseUrl,
llmModel: RUNTIME_LLM_DEFAULT.model,
llmApiKey: "",
sttProvider: "dashscope",
sttBaseUrl: RUNTIME_STT_PRESETS.dashscope.baseUrl,
sttModel: RUNTIME_STT_PRESETS.dashscope.model,
sttApiKey: "",
ttsProvider: "dashscope",
ttsBaseUrl: RUNTIME_TTS_PRESETS.dashscope.baseUrl,
ttsModel: RUNTIME_TTS_PRESETS.dashscope.model,
ttsApiKey: "",
mem0LlmProvider: MEM0_MODEL_PRESETS.llm.provider,
mem0LlmBaseUrl: MEM0_MODEL_PRESETS.llm.baseUrl,
mem0LlmModel: MEM0_MODEL_PRESETS.llm.model,
mem0LlmApiKey: "",
mem0EmbedderProvider: MEM0_MODEL_PRESETS.embedder.provider,
mem0EmbedderBaseUrl: MEM0_MODEL_PRESETS.embedder.baseUrl,
mem0EmbedderModel: MEM0_MODEL_PRESETS.embedder.model,
mem0EmbedderApiKey: "",
syncDashscopeApiKey: true,
};
function normalizeRuntimeTtsProvider(value: string | null | undefined): TtsProviderExtended {
const normalized = (value ?? "").trim();
if (normalized === "local_indextts" || normalized === "omnirt_indextts") return "indextts";
return Object.prototype.hasOwnProperty.call(RUNTIME_TTS_PRESETS, normalized)
? normalized as TtsProviderExtended
: "dashscope";
}
function runtimeFormFromConfig(runtimeConfig: RuntimeConfigResponse | null): RuntimeConfigForm {
if (!runtimeConfig) return { ...RUNTIME_FORM_DEFAULTS };
const sttProvider = Object.prototype.hasOwnProperty.call(RUNTIME_STT_PRESETS, runtimeConfig.stt.provider)
? runtimeConfig.stt.provider
: "dashscope";
const sttPreset = RUNTIME_STT_PRESETS[sttProvider] ?? RUNTIME_STT_PRESETS.dashscope;
const ttsProvider = normalizeRuntimeTtsProvider(runtimeConfig.tts.provider);
const ttsPreset = RUNTIME_TTS_PRESETS[ttsProvider];
return {
llmBaseUrl: runtimeConfig.llm.base_url || RUNTIME_LLM_DEFAULT.baseUrl,
llmModel: runtimeConfig.llm.model || RUNTIME_LLM_DEFAULT.model,
llmApiKey: "",
sttProvider,
sttBaseUrl: runtimeConfig.stt.base_url || sttPreset.baseUrl,
sttModel: runtimeConfig.stt.model || sttPreset.model,
sttApiKey: "",
ttsProvider,
ttsBaseUrl: runtimeConfig.tts.base_url || ttsPreset.baseUrl,
ttsModel: runtimeConfig.tts.model || ttsPreset.model,
ttsApiKey: "",
mem0LlmProvider: runtimeConfig.mem0?.llm.provider || MEM0_MODEL_PRESETS.llm.provider,
mem0LlmBaseUrl: runtimeConfig.mem0?.llm.base_url || MEM0_MODEL_PRESETS.llm.baseUrl,
mem0LlmModel: runtimeConfig.mem0?.llm.model || MEM0_MODEL_PRESETS.llm.model,
mem0LlmApiKey: "",
mem0EmbedderProvider: runtimeConfig.mem0?.embedder.provider || MEM0_MODEL_PRESETS.embedder.provider,
mem0EmbedderBaseUrl: runtimeConfig.mem0?.embedder.base_url || MEM0_MODEL_PRESETS.embedder.baseUrl,
mem0EmbedderModel: runtimeConfig.mem0?.embedder.model || MEM0_MODEL_PRESETS.embedder.model,
mem0EmbedderApiKey: "",
syncDashscopeApiKey: true,
};
}
interface RuntimeConfigWorkspaceProps {
runtimeConfig: RuntimeConfigResponse | null;
runtimeConfigLoading?: boolean;
runtimeConfigApplying?: boolean;
onRuntimeConfigRefresh: () => void;
onRuntimeConfigApply: (input: RuntimeConfigApplyInput) => Promise<void>;
}
export function RuntimeConfigWorkspace({
runtimeConfig,
runtimeConfigLoading = false,
runtimeConfigApplying = false,
onRuntimeConfigRefresh,
onRuntimeConfigApply,
}: RuntimeConfigWorkspaceProps) {
const [runtimeForm, setRuntimeForm] = useState<RuntimeConfigForm>(() => runtimeFormFromConfig(runtimeConfig));
useEffect(() => {
setRuntimeForm(runtimeFormFromConfig(runtimeConfig));
}, [runtimeConfig]);
const updateRuntimeForm = <K extends keyof RuntimeConfigForm>(key: K, value: RuntimeConfigForm[K]) => {
setRuntimeForm((prev) => ({ ...prev, [key]: value }));
};
const selectRuntimeSttProvider = (provider: string) => {
const preset = RUNTIME_STT_PRESETS[provider] ?? RUNTIME_STT_PRESETS.dashscope;
setRuntimeForm((prev) => ({
...prev,
sttProvider: provider,
sttBaseUrl: preset.baseUrl,
sttModel: preset.model,
}));
};
const selectRuntimeTtsProvider = (provider: TtsProviderExtended) => {
const preset = RUNTIME_TTS_PRESETS[provider];
setRuntimeForm((prev) => ({
...prev,
ttsProvider: provider,
ttsBaseUrl: preset.baseUrl,
ttsModel: preset.model,
}));
};
const handleRuntimeApply = async () => {
const payload: RuntimeConfigApplyInput = {
llm_base_url: runtimeForm.llmBaseUrl.trim(),
llm_model: runtimeForm.llmModel.trim(),
stt_provider: runtimeForm.sttProvider,
stt_base_url: runtimeForm.sttBaseUrl.trim(),
stt_model: runtimeForm.sttModel.trim(),
tts_provider: runtimeForm.ttsProvider,
mem0_llm_provider: runtimeForm.mem0LlmProvider.trim(),
mem0_llm_base_url: runtimeForm.mem0LlmBaseUrl.trim(),
mem0_llm_model: runtimeForm.mem0LlmModel.trim(),
mem0_embedder_provider: runtimeForm.mem0EmbedderProvider.trim(),
mem0_embedder_base_url: runtimeForm.mem0EmbedderBaseUrl.trim(),
mem0_embedder_model: runtimeForm.mem0EmbedderModel.trim(),
sync_dashscope_api_key: runtimeForm.syncDashscopeApiKey,
};
const llmApiKey = runtimeForm.llmApiKey.trim();
const sttApiKey = runtimeForm.sttApiKey.trim();
const ttsApiKey = runtimeForm.ttsApiKey.trim();
const mem0LlmApiKey = runtimeForm.mem0LlmApiKey.trim();
const mem0EmbedderApiKey = runtimeForm.mem0EmbedderApiKey.trim();
if (llmApiKey) payload.llm_api_key = llmApiKey;
if (sttApiKey) payload.stt_api_key = sttApiKey;
if (runtimeForm.ttsProvider !== "edge") {
payload.tts_base_url = runtimeForm.ttsBaseUrl.trim();
payload.tts_model = runtimeForm.ttsModel.trim();
}
if (ttsApiKey) payload.tts_api_key = ttsApiKey;
if (mem0LlmApiKey) payload.mem0_llm_api_key = mem0LlmApiKey;
if (mem0EmbedderApiKey) payload.mem0_embedder_api_key = mem0EmbedderApiKey;
await onRuntimeConfigApply(payload);
setRuntimeForm((prev) => ({
...prev,
llmApiKey: "",
sttApiKey: "",
ttsApiKey: "",
mem0LlmApiKey: "",
mem0EmbedderApiKey: "",
}));
};
const runtimeSttPreset = RUNTIME_STT_PRESETS[runtimeForm.sttProvider] ?? RUNTIME_STT_PRESETS.dashscope;
const runtimeTtsPreset = RUNTIME_TTS_PRESETS[runtimeForm.ttsProvider];
const runtimeTtsNeedsSetup = runtimeForm.ttsProvider !== "edge";
const runtimeLlmKeySet = Boolean(runtimeConfig?.llm.api_key_set || runtimeForm.llmApiKey.trim());
const runtimeSttSavedKeySet = runtimeConfig?.stt.provider === runtimeForm.sttProvider && runtimeConfig.stt.api_key_set;
const runtimeTtsSavedKeySet = normalizeRuntimeTtsProvider(runtimeConfig?.tts.provider) === runtimeForm.ttsProvider && runtimeConfig?.tts.api_key_set;
const runtimeSttKeySet = Boolean(runtimeSttSavedKeySet || runtimeForm.sttApiKey.trim() || !runtimeSttPreset.needsKey);
const runtimeTtsKeySet = Boolean(!runtimeTtsNeedsSetup || runtimeTtsSavedKeySet || runtimeForm.ttsApiKey.trim() || !runtimeTtsPreset.needsKey);
const runtimeMem0LlmKeySet = Boolean(runtimeConfig?.mem0?.llm.api_key_set || runtimeForm.mem0LlmApiKey.trim());
const runtimeMem0EmbedderKeySet = Boolean(runtimeConfig?.mem0?.embedder.api_key_set || runtimeForm.mem0EmbedderApiKey.trim());
const runtimeSttProviderOptions = Object.entries(RUNTIME_STT_PRESETS);
const runtimeTtsProviderOptions = Object.entries(RUNTIME_TTS_PRESETS) as [TtsProviderExtended, typeof RUNTIME_TTS_PRESETS[TtsProviderExtended]][];
return (
<main className="min-h-0 flex-1 overflow-y-auto bg-slate-100">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 p-4 lg:p-6">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(20rem,0.9fr)]">
<section className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm shadow-slate-200/60">
<div className="mb-4 flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-slate-900">LLM</h2>
<span className={`rounded-full border px-2 py-0.5 text-xs font-semibold ${
runtimeLlmKeySet ? "border-emerald-200 bg-emerald-50 text-emerald-700" : "border-amber-200 bg-amber-50 text-amber-700"
}`}>
{runtimeLlmKeySet ? "Key 已设置" : "Key 未设置"}
</span>
</div>
<div className="grid gap-3">
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Base URL</span>
<input
value={runtimeForm.llmBaseUrl}
onChange={(event) => updateRuntimeForm("llmBaseUrl", event.target.value)}
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Model</span>
<input
value={runtimeForm.llmModel}
onChange={(event) => updateRuntimeForm("llmModel", event.target.value)}
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">API Key</span>
<input
type="password"
value={runtimeForm.llmApiKey}
onChange={(event) => updateRuntimeForm("llmApiKey", event.target.value)}
autoComplete="new-password"
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
</div>
</div>
</section>
<section className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm shadow-slate-200/60">
<div className="mb-4 flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-slate-900">STT</h2>
<span className={`rounded-full border px-2 py-0.5 text-xs font-semibold ${
runtimeSttKeySet ? "border-emerald-200 bg-emerald-50 text-emerald-700" : "border-amber-200 bg-amber-50 text-amber-700"
}`}>
{runtimeSttKeySet ? "Key 已设置" : "Key 未设置"}
</span>
</div>
<div className="grid gap-3">
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Provider</span>
<select
value={runtimeForm.sttProvider}
onChange={(event) => selectRuntimeSttProvider(event.target.value)}
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm font-semibold text-slate-800 outline-none transition focus:border-cyan-300"
>
{runtimeSttProviderOptions.map(([provider, preset]) => (
<option key={provider} value={provider}>{preset.label}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Base URL</span>
<input
value={runtimeForm.sttBaseUrl}
onChange={(event) => updateRuntimeForm("sttBaseUrl", event.target.value)}
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Model</span>
<input
value={runtimeForm.sttModel}
onChange={(event) => updateRuntimeForm("sttModel", event.target.value)}
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">API Key</span>
<input
type="password"
value={runtimeForm.sttApiKey}
onChange={(event) => updateRuntimeForm("sttApiKey", event.target.value)}
autoComplete="new-password"
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
</div>
</div>
</section>
<section className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm shadow-slate-200/60 lg:col-span-2">
<div className="mb-4 flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-slate-900">TTS</h2>
<span className={`rounded-full border px-2 py-0.5 text-xs font-semibold ${
runtimeTtsKeySet ? "border-emerald-200 bg-emerald-50 text-emerald-700" : "border-amber-200 bg-amber-50 text-amber-700"
}`}>
{runtimeTtsNeedsSetup ? (runtimeTtsKeySet ? "Key 已设置" : "Key 未设置") : "无需配置"}
</span>
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(11rem,0.45fr)_minmax(0,1fr)]">
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Provider</span>
<select
value={runtimeForm.ttsProvider}
onChange={(event) => selectRuntimeTtsProvider(event.target.value as TtsProviderExtended)}
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm font-semibold text-slate-800 outline-none transition focus:border-cyan-300"
>
{runtimeTtsProviderOptions.map(([provider, preset]) => (
<option key={provider} value={provider}>{preset.label}</option>
))}
</select>
</label>
{runtimeTtsNeedsSetup ? (
<>
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Base URL</span>
<input
value={runtimeForm.ttsBaseUrl}
onChange={(event) => updateRuntimeForm("ttsBaseUrl", event.target.value)}
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
<div className="grid gap-3 sm:grid-cols-2 lg:col-span-2">
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Model</span>
<input
value={runtimeForm.ttsModel}
onChange={(event) => updateRuntimeForm("ttsModel", event.target.value)}
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">API Key</span>
<input
type="password"
value={runtimeForm.ttsApiKey}
onChange={(event) => updateRuntimeForm("ttsApiKey", event.target.value)}
autoComplete="new-password"
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
</div>
<p className="text-xs text-slate-500 lg:col-span-2">TTS </p>
</>
) : (
null
)}
</div>
</section>
<section className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm shadow-slate-200/60">
<div className="mb-4 flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-slate-900">Mem0 LLM</h2>
<span className={`rounded-full border px-2 py-0.5 text-xs font-semibold ${
runtimeMem0LlmKeySet ? "border-emerald-200 bg-emerald-50 text-emerald-700" : "border-amber-200 bg-amber-50 text-amber-700"
}`}>
{runtimeMem0LlmKeySet ? "Key 已设置" : "Key 未设置"}
</span>
</div>
<div className="grid gap-3">
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Provider</span>
<input
value={runtimeForm.mem0LlmProvider}
onChange={(event) => updateRuntimeForm("mem0LlmProvider", event.target.value)}
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Base URL</span>
<input
value={runtimeForm.mem0LlmBaseUrl}
onChange={(event) => updateRuntimeForm("mem0LlmBaseUrl", event.target.value)}
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Model</span>
<input
value={runtimeForm.mem0LlmModel}
onChange={(event) => updateRuntimeForm("mem0LlmModel", event.target.value)}
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">API Key</span>
<input
type="password"
value={runtimeForm.mem0LlmApiKey}
onChange={(event) => updateRuntimeForm("mem0LlmApiKey", event.target.value)}
autoComplete="new-password"
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
</div>
</div>
</section>
<section className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm shadow-slate-200/60">
<div className="mb-4 flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-slate-900">Mem0 Embedding</h2>
<span className={`rounded-full border px-2 py-0.5 text-xs font-semibold ${
runtimeMem0EmbedderKeySet ? "border-emerald-200 bg-emerald-50 text-emerald-700" : "border-amber-200 bg-amber-50 text-amber-700"
}`}>
{runtimeMem0EmbedderKeySet ? "Key 已设置" : "Key 未设置"}
</span>
</div>
<div className="grid gap-3">
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Provider</span>
<input
value={runtimeForm.mem0EmbedderProvider}
onChange={(event) => updateRuntimeForm("mem0EmbedderProvider", event.target.value)}
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Base URL</span>
<input
value={runtimeForm.mem0EmbedderBaseUrl}
onChange={(event) => updateRuntimeForm("mem0EmbedderBaseUrl", event.target.value)}
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Model</span>
<input
value={runtimeForm.mem0EmbedderModel}
onChange={(event) => updateRuntimeForm("mem0EmbedderModel", event.target.value)}
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">API Key</span>
<input
type="password"
value={runtimeForm.mem0EmbedderApiKey}
onChange={(event) => updateRuntimeForm("mem0EmbedderApiKey", event.target.value)}
autoComplete="new-password"
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
</div>
</div>
</section>
<section className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm shadow-slate-200/60 lg:col-span-2">
<div>
<label className="flex min-w-0 items-start gap-3">
<input
type="checkbox"
checked={runtimeForm.syncDashscopeApiKey}
onChange={(event) => updateRuntimeForm("syncDashscopeApiKey", event.target.checked)}
className="mt-0.5 h-4 w-4 shrink-0 accent-cyan-600"
/>
<span className="min-w-0">
<span className="block text-sm font-semibold text-slate-700"> Key</span>
<span className="mt-1 block text-xs leading-5 text-slate-500">
DASHSCOPE_API_KEY使 Key
</span>
</span>
</label>
<div className="mt-3 flex flex-wrap items-center justify-end gap-2 border-t border-slate-100 pt-3">
<button
type="button"
onClick={onRuntimeConfigRefresh}
disabled={runtimeConfigLoading || runtimeConfigApplying}
className="rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:border-cyan-200 hover:text-cyan-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{runtimeConfigLoading ? "读取中" : "刷新"}
</button>
<button
type="button"
onClick={() => void handleRuntimeApply()}
disabled={runtimeConfigApplying || runtimeConfigLoading}
className="rounded-lg bg-slate-950 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-60"
>
{runtimeConfigApplying ? "应用中..." : "应用配置"}
</button>
</div>
</div>
</section>
</div>
</div>
</main>
);
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, type ReactNode } from "react";
import type { AgentConfig } from "./AvatarSelectionStage";
import type { AvatarSummary, KnowledgeBaseSummary, RuntimeConfigApplyInput, RuntimeConfigResponse } from "../lib/api";
import type { AvatarSummary, KnowledgeBaseSummary } from "../lib/api";
import { modelConnectionBadge, type ModelStatus } from "../lib/modelStatus";
import type { TtsProviderExtended } from "../constants/ttsBailian";
import type { MemoryLibrary } from "../types";
@@ -90,7 +90,7 @@ const ASR_PROVIDER_LABELS: Record<string, string> = {
};
const ASR_PROVIDER_SUBTITLES: Record<string, string> = {
dashscope: "DashScope API",
dashscope: "百炼 API",
xiaomi_mimo: "MiMo ASR",
openai_compatible: "OpenAI-compatible",
sensevoice: "本地模型",
@@ -103,97 +103,6 @@ const ASR_PROVIDER_MODELS: Record<string, string> = {
sensevoice: "iic/SenseVoiceSmall",
};
const RUNTIME_LLM_DEFAULT = {
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
model: "qwen-turbo",
};
const RUNTIME_STT_PRESETS: Record<string, { label: string; baseUrl: string; model: string; needsKey: boolean }> = {
dashscope: {
label: "DashScope",
baseUrl: "https://dashscope.aliyuncs.com",
model: "paraformer-realtime-v2",
needsKey: true,
},
openai_compatible: {
label: "OpenAI-compatible",
baseUrl: "https://api.openai.com/v1",
model: "whisper-1",
needsKey: true,
},
xiaomi_mimo: {
label: "小米 MiMo",
baseUrl: "",
model: "mimo-v2.5-asr",
needsKey: true,
},
sensevoice: {
label: "SenseVoice",
baseUrl: "",
model: "iic/SenseVoiceSmall",
needsKey: false,
},
};
const RUNTIME_TTS_PRESETS: Record<TtsProviderExtended, { label: string; baseUrl: string; model: string; voice: string; needsKey: boolean }> = {
edge: {
label: "Edge",
baseUrl: "",
model: "",
voice: "zh-CN-XiaoxiaoNeural",
needsKey: false,
},
dashscope: {
label: "Qwen",
baseUrl: "wss://dashscope.aliyuncs.com/api-ws/v1/realtime",
model: "qwen3-tts-flash-realtime",
voice: "Cherry",
needsKey: true,
},
cosyvoice: {
label: "CosyVoice",
baseUrl: "",
model: "cosyvoice-v3-flash",
voice: "longanyang",
needsKey: true,
},
sambert: {
label: "Sambert",
baseUrl: "",
model: "sambert-zhichu-v1",
voice: "",
needsKey: true,
},
local_cosyvoice: {
label: "Local CosyVoice",
baseUrl: "http://127.0.0.1:9880",
model: "FunAudioLLM/Fun-CosyVoice3-0.5B-2512",
voice: "",
needsKey: false,
},
indextts: {
label: "Local IndexTTS",
baseUrl: "http://127.0.0.1:9880",
model: "IndexTeam/IndexTTS-2",
voice: "",
needsKey: false,
},
xiaomi_mimo: {
label: "小米 MiMo",
baseUrl: "",
model: "mimo-v2.5-tts",
voice: "mimo_default",
needsKey: true,
},
openai_compatible: {
label: "OpenAI-compatible",
baseUrl: "https://api.openai.com/v1",
model: "gpt-4o-mini-tts",
voice: "alloy",
needsKey: true,
},
};
const WAV2LIP_POSTPROCESS_OPTIONS: { id: Wav2LipPostprocessMode; label: string }[] = [
{ id: "auto", label: "自动推荐" },
{ id: "basic", label: "基础" },
@@ -244,71 +153,6 @@ const FASTERLIVEPORTRAIT_ANIMATION_REGION_OPTIONS: {
{ id: "eyes", label: "眼睛" },
];
type RuntimeConfigForm = {
llmBaseUrl: string;
llmModel: string;
llmApiKey: string;
sttProvider: string;
sttBaseUrl: string;
sttModel: string;
sttApiKey: string;
ttsProvider: TtsProviderExtended;
ttsBaseUrl: string;
ttsModel: string;
ttsVoice: string;
ttsApiKey: string;
syncDashscopeApiKey: boolean;
};
const RUNTIME_FORM_DEFAULTS: RuntimeConfigForm = {
llmBaseUrl: RUNTIME_LLM_DEFAULT.baseUrl,
llmModel: RUNTIME_LLM_DEFAULT.model,
llmApiKey: "",
sttProvider: "dashscope",
sttBaseUrl: RUNTIME_STT_PRESETS.dashscope.baseUrl,
sttModel: RUNTIME_STT_PRESETS.dashscope.model,
sttApiKey: "",
ttsProvider: "dashscope",
ttsBaseUrl: RUNTIME_TTS_PRESETS.dashscope.baseUrl,
ttsModel: RUNTIME_TTS_PRESETS.dashscope.model,
ttsVoice: RUNTIME_TTS_PRESETS.dashscope.voice,
ttsApiKey: "",
syncDashscopeApiKey: true,
};
function normalizeRuntimeTtsProvider(value: string | null | undefined): TtsProviderExtended {
const normalized = (value ?? "").trim();
if (normalized === "local_indextts" || normalized === "omnirt_indextts") return "indextts";
return Object.prototype.hasOwnProperty.call(RUNTIME_TTS_PRESETS, normalized)
? normalized as TtsProviderExtended
: "dashscope";
}
function runtimeFormFromConfig(runtimeConfig: RuntimeConfigResponse | null): RuntimeConfigForm {
if (!runtimeConfig) return { ...RUNTIME_FORM_DEFAULTS };
const sttProvider = Object.prototype.hasOwnProperty.call(RUNTIME_STT_PRESETS, runtimeConfig.stt.provider)
? runtimeConfig.stt.provider
: "dashscope";
const sttPreset = RUNTIME_STT_PRESETS[sttProvider] ?? RUNTIME_STT_PRESETS.dashscope;
const ttsProvider = normalizeRuntimeTtsProvider(runtimeConfig.tts.provider);
const ttsPreset = RUNTIME_TTS_PRESETS[ttsProvider];
return {
llmBaseUrl: runtimeConfig.llm.base_url || RUNTIME_LLM_DEFAULT.baseUrl,
llmModel: runtimeConfig.llm.model || RUNTIME_LLM_DEFAULT.model,
llmApiKey: "",
sttProvider,
sttBaseUrl: runtimeConfig.stt.base_url || sttPreset.baseUrl,
sttModel: runtimeConfig.stt.model || sttPreset.model,
sttApiKey: "",
ttsProvider,
ttsBaseUrl: runtimeConfig.tts.base_url || ttsPreset.baseUrl,
ttsModel: runtimeConfig.tts.model || ttsPreset.model,
ttsVoice: runtimeConfig.tts.voice || ttsPreset.voice,
ttsApiKey: "",
syncDashscopeApiKey: true,
};
}
interface SettingsPanelProps {
/** 展开时显示表单;收起时仅保留右侧竖条入口 */
expanded: boolean;
@@ -319,11 +163,6 @@ interface SettingsPanelProps {
avatarId: string;
model: string;
modelConnected: boolean;
runtimeConfig: RuntimeConfigResponse | null;
runtimeConfigLoading?: boolean;
runtimeConfigApplying?: boolean;
onRuntimeConfigRefresh: () => void;
onRuntimeConfigApply: (input: RuntimeConfigApplyInput) => Promise<void>;
wav2lipPostprocessMode: Wav2LipPostprocessMode;
wav2lipPostprocessModeLocked: boolean;
fasterliveportraitConfig: FasterLivePortraitConfig;
@@ -554,11 +393,6 @@ export function SettingsPanel({
avatarId,
model,
modelConnected,
runtimeConfig,
runtimeConfigLoading = false,
runtimeConfigApplying = false,
onRuntimeConfigRefresh,
onRuntimeConfigApply,
wav2lipPostprocessMode,
wav2lipPostprocessModeLocked,
fasterliveportraitConfig,
@@ -607,7 +441,6 @@ export function SettingsPanel({
onManageMemoryLibraries,
}: SettingsPanelProps) {
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
runtime: true,
avatars: true,
knowledge: true,
memory: true,
@@ -617,7 +450,6 @@ export function SettingsPanel({
role: true,
});
const [voiceView, setVoiceView] = useState<"providers" | "models" | "voices">("providers");
const [runtimeForm, setRuntimeForm] = useState<RuntimeConfigForm>(() => runtimeFormFromConfig(runtimeConfig));
useEffect(() => {
if (!voiceApplyNotice) return;
@@ -625,10 +457,6 @@ export function SettingsPanel({
setVoiceView("voices");
}, [voiceApplyNotice]);
useEffect(() => {
setRuntimeForm(runtimeFormFromConfig(runtimeConfig));
}, [runtimeConfig]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" && expanded) {
@@ -642,55 +470,6 @@ export function SettingsPanel({
const toggleSection = (id: string) => {
setOpenSections((prev) => ({ ...prev, [id]: !prev[id] }));
};
const updateRuntimeForm = <K extends keyof RuntimeConfigForm>(key: K, value: RuntimeConfigForm[K]) => {
setRuntimeForm((prev) => ({ ...prev, [key]: value }));
};
const selectRuntimeSttProvider = (provider: string) => {
const preset = RUNTIME_STT_PRESETS[provider] ?? RUNTIME_STT_PRESETS.dashscope;
setRuntimeForm((prev) => ({
...prev,
sttProvider: provider,
sttBaseUrl: preset.baseUrl,
sttModel: preset.model,
}));
};
const selectRuntimeTtsProvider = (provider: TtsProviderExtended) => {
const preset = RUNTIME_TTS_PRESETS[provider];
setRuntimeForm((prev) => ({
...prev,
ttsProvider: provider,
ttsBaseUrl: preset.baseUrl,
ttsModel: preset.model,
ttsVoice: preset.voice,
}));
};
const handleRuntimeApply = async () => {
const payload: RuntimeConfigApplyInput = {
llm_base_url: runtimeForm.llmBaseUrl.trim(),
llm_model: runtimeForm.llmModel.trim(),
stt_provider: runtimeForm.sttProvider,
stt_base_url: runtimeForm.sttBaseUrl.trim(),
stt_model: runtimeForm.sttModel.trim(),
tts_provider: runtimeForm.ttsProvider,
tts_base_url: runtimeForm.ttsBaseUrl.trim(),
tts_model: runtimeForm.ttsModel.trim(),
tts_voice: runtimeForm.ttsVoice.trim(),
sync_dashscope_api_key: runtimeForm.syncDashscopeApiKey,
};
const llmApiKey = runtimeForm.llmApiKey.trim();
const sttApiKey = runtimeForm.sttApiKey.trim();
const ttsApiKey = runtimeForm.ttsApiKey.trim();
if (llmApiKey) payload.llm_api_key = llmApiKey;
if (sttApiKey) payload.stt_api_key = sttApiKey;
if (ttsApiKey) payload.tts_api_key = ttsApiKey;
await onRuntimeConfigApply(payload);
setRuntimeForm((prev) => ({
...prev,
llmApiKey: "",
sttApiKey: "",
ttsApiKey: "",
}));
};
const selectedKnowledgeBaseSet = new Set(agentConfig.knowledgeBaseIds);
const updateKnowledgeBaseIds = (nextIds: string[]) => {
const deduped = Array.from(new Set(nextIds.filter((id) => id.trim())));
@@ -744,21 +523,6 @@ export function SettingsPanel({
label: option.label,
subtitle: option.id,
}));
const runtimeSttPreset = RUNTIME_STT_PRESETS[runtimeForm.sttProvider] ?? RUNTIME_STT_PRESETS.dashscope;
const runtimeTtsPreset = RUNTIME_TTS_PRESETS[runtimeForm.ttsProvider];
const runtimeLlmKeySet = Boolean(runtimeConfig?.llm.api_key_set || runtimeForm.llmApiKey.trim());
const runtimeSttSavedKeySet = runtimeConfig?.stt.provider === runtimeForm.sttProvider && runtimeConfig.stt.api_key_set;
const runtimeTtsSavedKeySet = normalizeRuntimeTtsProvider(runtimeConfig?.tts.provider) === runtimeForm.ttsProvider && runtimeConfig?.tts.api_key_set;
const runtimeSttKeySet = Boolean(runtimeSttSavedKeySet || runtimeForm.sttApiKey.trim() || !runtimeSttPreset.needsKey);
const runtimeTtsKeySet = Boolean(runtimeTtsSavedKeySet || runtimeForm.ttsApiKey.trim() || !runtimeTtsPreset.needsKey);
const runtimeConfigNeedsSetup = !runtimeLlmKeySet || !runtimeSttKeySet || !runtimeTtsKeySet;
const runtimeBadgeLabel = runtimeConfigLoading ? "读取中" : runtimeConfigNeedsSetup ? "待配置" : "已配置";
const runtimeBadgeClass = runtimeConfigNeedsSetup
? "border-amber-200 bg-amber-50 text-amber-700"
: "border-emerald-200 bg-emerald-50 text-emerald-700";
const runtimeSttProviderOptions = Object.entries(RUNTIME_STT_PRESETS);
const runtimeTtsProviderOptions = Object.entries(RUNTIME_TTS_PRESETS) as [TtsProviderExtended, typeof RUNTIME_TTS_PRESETS[TtsProviderExtended]][];
const providerHasSingleModel = (provider: TtsProviderExtended) => {
if (provider === "edge" || provider === "openai_compatible") return true;
if (provider !== ttsProvider) return false;
@@ -815,202 +579,6 @@ export function SettingsPanel({
</div>
<div className="space-y-4 p-4 pt-0 lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
<SettingsSection
id="runtime"
title="运行配置"
open={openSections.runtime}
onToggle={toggleSection}
action={
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={onRuntimeConfigRefresh}
disabled={runtimeConfigLoading || runtimeConfigApplying}
className="min-h-8 px-1 text-xs font-semibold text-slate-600 transition hover:text-cyan-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{runtimeConfigLoading ? "读取中" : "刷新"}
</button>
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-xs font-semibold ${runtimeBadgeClass}`}>
{runtimeBadgeLabel}
</span>
</div>
}
>
<div className="space-y-3">
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-slate-500">LLM</p>
<span className={`rounded-full border px-2 py-0.5 text-[11px] font-semibold ${
runtimeLlmKeySet ? "border-emerald-200 bg-emerald-50 text-emerald-700" : "border-amber-200 bg-amber-50 text-amber-700"
}`}>
{runtimeLlmKeySet ? "Key 已设置" : "Key 未设置"}
</span>
</div>
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Base URL</span>
<input
value={runtimeForm.llmBaseUrl}
onChange={(event) => updateRuntimeForm("llmBaseUrl", event.target.value)}
className="h-9 w-full rounded-md border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Model</span>
<input
value={runtimeForm.llmModel}
onChange={(event) => updateRuntimeForm("llmModel", event.target.value)}
className="h-9 w-full rounded-md border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">API Key</span>
<input
type="password"
value={runtimeForm.llmApiKey}
onChange={(event) => updateRuntimeForm("llmApiKey", event.target.value)}
autoComplete="new-password"
className="h-9 w-full rounded-md border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
</div>
</div>
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-slate-500">STT</p>
<span className={`rounded-full border px-2 py-0.5 text-[11px] font-semibold ${
runtimeSttKeySet ? "border-emerald-200 bg-emerald-50 text-emerald-700" : "border-amber-200 bg-amber-50 text-amber-700"
}`}>
{runtimeSttKeySet ? "Key 已设置" : "Key 未设置"}
</span>
</div>
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Provider</span>
<select
value={runtimeForm.sttProvider}
onChange={(event) => selectRuntimeSttProvider(event.target.value)}
className="h-9 w-full rounded-md border border-slate-200 bg-white px-2.5 text-xs font-semibold text-slate-800 outline-none transition focus:border-cyan-300"
>
{runtimeSttProviderOptions.map(([provider, preset]) => (
<option key={provider} value={provider}>{preset.label}</option>
))}
</select>
</label>
<div className="mt-2 grid grid-cols-1 gap-2">
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Base URL</span>
<input
value={runtimeForm.sttBaseUrl}
onChange={(event) => updateRuntimeForm("sttBaseUrl", event.target.value)}
className="h-9 w-full rounded-md border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Model</span>
<input
value={runtimeForm.sttModel}
onChange={(event) => updateRuntimeForm("sttModel", event.target.value)}
className="h-9 w-full rounded-md border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">API Key</span>
<input
type="password"
value={runtimeForm.sttApiKey}
onChange={(event) => updateRuntimeForm("sttApiKey", event.target.value)}
autoComplete="new-password"
className="h-9 w-full rounded-md border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
</div>
</div>
</div>
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-slate-500">TTS</p>
<span className={`rounded-full border px-2 py-0.5 text-[11px] font-semibold ${
runtimeTtsKeySet ? "border-emerald-200 bg-emerald-50 text-emerald-700" : "border-amber-200 bg-amber-50 text-amber-700"
}`}>
{runtimeTtsKeySet ? "Key 已设置" : "Key 未设置"}
</span>
</div>
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Provider</span>
<select
value={runtimeForm.ttsProvider}
onChange={(event) => selectRuntimeTtsProvider(event.target.value as TtsProviderExtended)}
className="h-9 w-full rounded-md border border-slate-200 bg-white px-2.5 text-xs font-semibold text-slate-800 outline-none transition focus:border-cyan-300"
>
{runtimeTtsProviderOptions.map(([provider, preset]) => (
<option key={provider} value={provider}>{preset.label}</option>
))}
</select>
</label>
<div className="mt-2 grid grid-cols-1 gap-2">
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Base URL</span>
<input
value={runtimeForm.ttsBaseUrl}
onChange={(event) => updateRuntimeForm("ttsBaseUrl", event.target.value)}
className="h-9 w-full rounded-md border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Model</span>
<input
value={runtimeForm.ttsModel}
onChange={(event) => updateRuntimeForm("ttsModel", event.target.value)}
className="h-9 w-full rounded-md border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">Voice</span>
<input
value={runtimeForm.ttsVoice}
onChange={(event) => updateRuntimeForm("ttsVoice", event.target.value)}
className="h-9 w-full rounded-md border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-slate-500">API Key</span>
<input
type="password"
value={runtimeForm.ttsApiKey}
onChange={(event) => updateRuntimeForm("ttsApiKey", event.target.value)}
autoComplete="new-password"
className="h-9 w-full rounded-md border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-800 outline-none transition focus:border-cyan-300"
/>
</label>
</div>
</div>
</div>
<label className="flex items-center justify-between gap-3 rounded-lg border border-slate-200 bg-white px-3 py-2">
<span className="min-w-0 text-xs font-semibold text-slate-600"> DashScope Key</span>
<input
type="checkbox"
checked={runtimeForm.syncDashscopeApiKey}
onChange={(event) => updateRuntimeForm("syncDashscopeApiKey", event.target.checked)}
className="h-4 w-4 shrink-0 accent-cyan-600"
/>
</label>
<button
type="button"
onClick={() => void handleRuntimeApply()}
disabled={runtimeConfigApplying || runtimeConfigLoading}
className="w-full rounded-lg bg-slate-950 px-3 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-60"
>
{runtimeConfigApplying ? "应用中..." : "应用配置"}
</button>
</div>
</SettingsSection>
<SettingsSection
id="avatars"
title="数字人形象"
@@ -1359,7 +927,7 @@ export function SettingsPanel({
</p>
) : (
<p className="mt-2 text-xs leading-relaxed text-slate-500"> OPENTALKING_STT_DEFAULT_PROVIDER /API STT DashScope MiMo OpenAI-compatible STT</p>
<p className="mt-2 text-xs leading-relaxed text-slate-500"> OPENTALKING_STT_DEFAULT_PROVIDER /API STT MiMo OpenAI-compatible STT</p>
)}
</div>
</SettingsSection>

View File

@@ -28,7 +28,7 @@ const DOT_LABELS: Record<ConnectionStatus, string> = {
};
export type FlashtalkRecordPhase = "idle" | "recording" | "stopped";
export type StudioWorkflow = "realtime" | "videoCreation" | "videoClone" | "assetLibrary";
export type StudioWorkflow = "realtime" | "videoCreation" | "videoClone" | "assetLibrary" | "runtimeConfig";
interface TopBarProps {
connection: ConnectionStatus;
@@ -37,6 +37,8 @@ interface TopBarProps {
flashtalkRecordPhase?: FlashtalkRecordPhase;
flashtalkRecordBusy?: boolean;
recordingSaving?: boolean;
runtimeConfigReady?: boolean;
runtimeConfigLoading?: boolean;
onInactiveModuleClick?: (label: string) => void;
onFlashtalkRecordStart?: () => void;
onFlashtalkRecordStop?: () => void;
@@ -51,6 +53,8 @@ export function TopBar({
flashtalkRecordPhase = "idle",
flashtalkRecordBusy = false,
recordingSaving = false,
runtimeConfigReady = false,
runtimeConfigLoading = false,
onInactiveModuleClick,
onFlashtalkRecordStart,
onFlashtalkRecordStop,
@@ -99,17 +103,14 @@ export function TopBar({
</button>
);
})}
{["运行监控"].map((item) => (
<button
key={item}
type="button"
className="rounded-md px-3 py-1.5 text-xs font-medium text-slate-500 transition hover:bg-white/70 hover:text-slate-700"
title={`${item}规划中`}
onClick={() => onInactiveModuleClick?.(item)}
>
{item}
</button>
))}
<button
type="button"
className="rounded-md px-3 py-1.5 text-xs font-medium text-slate-500 transition hover:bg-white/70 hover:text-slate-700"
title="运行监控规划中"
onClick={() => onInactiveModuleClick?.("运行监控")}
>
</button>
</nav>
<div className="flex min-w-0 flex-wrap items-center justify-end gap-1.5 sm:gap-2">
@@ -166,6 +167,20 @@ export function TopBar({
) : null}
</div>
) : null}
<button
type="button"
onClick={() => onWorkflowChange?.("runtimeConfig")}
className={`h-8 rounded-lg border px-3 text-xs font-semibold transition ${
workflow === "runtimeConfig"
? "border-cyan-200 bg-cyan-50 text-cyan-700 shadow-sm"
: runtimeConfigReady
? "border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100"
: "border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100"
}`}
title={runtimeConfigLoading ? "API 配置读取中" : runtimeConfigReady ? "API 配置已配置" : "API 配置未配置"}
>
{runtimeConfigReady ? "API配置已配置" : "API配置未配置"}
</button>
<div
className={`flex items-center gap-1.5 rounded-full border px-2 py-1 text-xs font-medium sm:px-2.5 ${PILL_COLORS[connection]}`}
title={DOT_LABELS[connection]}

View File

@@ -142,10 +142,23 @@ export type RuntimeConfigTts = {
service_url_set: boolean;
};
export type RuntimeConfigMem0Model = {
provider: string;
base_url: string;
model: string;
api_key_set: boolean;
};
export type RuntimeConfigMem0 = {
llm: RuntimeConfigMem0Model;
embedder: RuntimeConfigMem0Model;
};
export type RuntimeConfigResponse = {
llm: RuntimeConfigLlm;
stt: RuntimeConfigStt;
tts: RuntimeConfigTts;
mem0: RuntimeConfigMem0;
applied?: boolean;
requires_new_session?: boolean;
live_runners_refreshed?: number;
@@ -164,6 +177,14 @@ export type RuntimeConfigApplyInput = {
tts_model?: string;
tts_voice?: string;
tts_api_key?: string;
mem0_llm_provider?: string;
mem0_llm_base_url?: string;
mem0_llm_api_key?: string;
mem0_llm_model?: string;
mem0_embedder_provider?: string;
mem0_embedder_base_url?: string;
mem0_embedder_api_key?: string;
mem0_embedder_model?: string;
sync_dashscope_api_key?: boolean;
};

View File

@@ -5,32 +5,92 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_runtime_config_section_is_first_realtime_settings_section() -> None:
source = (ROOT / "apps/web/src/components/SettingsPanel.tsx").read_text(encoding="utf-8")
def test_runtime_config_is_top_level_workspace_before_connection_status() -> None:
topbar_source = (ROOT / "apps/web/src/components/TopBar.tsx").read_text(encoding="utf-8")
app_source = (ROOT / "apps/web/src/App.tsx").read_text(encoding="utf-8")
settings_source = (ROOT / "apps/web/src/components/SettingsPanel.tsx").read_text(encoding="utf-8")
runtime_idx = source.index('title="运行配置"')
avatar_idx = source.index('title="数字人形象"')
assert runtime_idx < avatar_idx
assert "runtimeConfig: RuntimeConfigResponse | null" in source
assert "onRuntimeConfigApply" in source
assert "应用配置" in source
runtime_idx = topbar_source.index("API 配置")
status_idx = topbar_source.index("DOT_LABELS[connection]")
assert runtime_idx < status_idx
assert '"runtimeConfig"' in topbar_source
assert "runtimeConfigReady" in topbar_source
assert 'className={`h-8 rounded-lg border px-3 text-xs font-semibold transition' in topbar_source
assert "API配置已配置" in topbar_source
assert "API配置未配置" in topbar_source
assert "border-emerald-200 bg-emerald-50 text-emerald-700" in topbar_source
assert "border-amber-200 bg-amber-50 text-amber-700" in topbar_source
assert 'workflow === "runtimeConfig"' in app_source
assert 'title="运行配置"' not in settings_source
def test_runtime_config_inputs_do_not_reveal_keys() -> None:
settings_source = (ROOT / "apps/web/src/components/SettingsPanel.tsx").read_text(encoding="utf-8")
settings_source = (ROOT / "apps/web/src/components/RuntimeConfigWorkspace.tsx").read_text(encoding="utf-8")
api_source = (ROOT / "apps/web/src/lib/api.ts").read_text(encoding="utf-8")
assert "llmApiKey" in settings_source
assert "sttApiKey" in settings_source
assert "ttsApiKey" in settings_source
assert "mem0LlmApiKey" in settings_source
assert "mem0EmbedderApiKey" in settings_source
assert 'type="password"' in settings_source
assert 'llmApiKey: ""' in settings_source
assert 'sttApiKey: ""' in settings_source
assert 'ttsApiKey: ""' in settings_source
assert 'mem0LlmApiKey: ""' in settings_source
assert 'mem0EmbedderApiKey: ""' in settings_source
assert "api_key_set: boolean" in api_source
assert "api_key: string" not in api_source
def test_runtime_config_page_exposes_mem0_llm_and_embedder_settings() -> None:
source = (ROOT / "apps/web/src/components/RuntimeConfigWorkspace.tsx").read_text(encoding="utf-8")
api_source = (ROOT / "apps/web/src/lib/api.ts").read_text(encoding="utf-8")
assert "Mem0 LLM" in source
assert "Mem0 Embedding" in source
assert "mem0_llm_provider" in api_source
assert "mem0_embedder_provider" in api_source
assert "mem0: RuntimeConfigMem0" in api_source
def test_runtime_config_tts_hides_voice_and_marks_edge_as_no_setup() -> None:
source = (ROOT / "apps/web/src/components/RuntimeConfigWorkspace.tsx").read_text(encoding="utf-8")
assert 'const runtimeTtsNeedsSetup = runtimeForm.ttsProvider !== "edge";' in source
assert "{runtimeTtsNeedsSetup ? (" in source
assert 'label: "Edge无需配置"' in source
assert 'runtimeTtsNeedsSetup ? (runtimeTtsKeySet ? "Key 已设置" : "Key 未设置") : "无需配置"' in source
assert "TTS 音色请在实时对话里选择" in source
assert "Edge 无需配置" not in source
assert "<span className=\"mb-1 block text-xs font-medium text-slate-500\">Voice</span>" not in source
assert "同时保存为通用百炼 Key" in source
assert "供百炼语音识别、语音合成及旧版配置读取" in source
assert "若不同服务使用不同 Key请关闭" in source
def test_runtime_config_llm_defaults_to_qwen_flash() -> None:
source = (ROOT / "apps/web/src/components/RuntimeConfigWorkspace.tsx").read_text(encoding="utf-8")
assert 'model: "qwen-flash"' in source
assert 'model: "qwen-turbo"' not in source
def test_runtime_config_page_uses_bottom_actions_without_header_card() -> None:
source = (ROOT / "apps/web/src/components/RuntimeConfigWorkspace.tsx").read_text(encoding="utf-8")
assert "<p className=\"text-xs font-medium text-slate-500\">运行配置</p>" not in source
assert "<h1 className=\"mt-1 text-lg font-semibold text-slate-950\">API 配置</h1>" not in source
assert 'className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm shadow-slate-200/70' not in source
tts_section_idx = source.index("<h2 className=\"text-sm font-semibold text-slate-900\">TTS</h2>")
model_idx = source.index(">Model</span>", tts_section_idx)
key_idx = source.index(">API Key</span>", tts_section_idx)
actions_idx = source.rindex("{runtimeConfigLoading ? \"读取中\" : \"刷新\"}")
apply_idx = source.rindex("{runtimeConfigApplying ? \"应用中...\" : \"应用配置\"}")
assert "mt-3 flex flex-wrap items-center justify-end gap-2 border-t border-slate-100 pt-3" in source
assert model_idx < key_idx < actions_idx < apply_idx
def test_app_loads_and_applies_runtime_config() -> None:
app_source = (ROOT / "apps/web/src/App.tsx").read_text(encoding="utf-8")
api_source = (ROOT / "apps/web/src/lib/api.ts").read_text(encoding="utf-8")
@@ -42,3 +102,4 @@ def test_app_loads_and_applies_runtime_config() -> None:
assert "setRuntimeConfig(next)" in app_source
assert "setAsrProvider" in app_source
assert "setTtsProvider" in app_source
assert "runtimeConfigTtsReady" in app_source

View File

@@ -67,7 +67,7 @@ def test_frontend_exposes_api_stt_provider_selection():
app = (WEB / "App.tsx").read_text(encoding="utf-8")
assert "API 语音识别" in settings
assert "DashScope API" in settings
assert "百炼 API" in settings
assert "onAsrProviderChange" in settings
assert "stt_provider" in chat_input
assert "fd.append(\"stt_provider\"" in app