mirror of
https://github.com/datascale-ai/opentalking.git
synced 2026-07-03 23:56:46 +08:00
feat: add runtime API config page (#113)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 日志,多为连接未到达服务或百炼阻塞。`,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
590
apps/web/src/components/RuntimeConfigWorkspace.tsx
Normal file
590
apps/web/src/components/RuntimeConfigWorkspace.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user