Compare commits
3 Commits
47293ac46d
...
9b9fbf432f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b9fbf432f | ||
|
|
f3612a710d | ||
|
|
13684d498b |
@@ -133,6 +133,7 @@ class Assistant(Base):
|
|||||||
config_mode: Mapped[str] = mapped_column(String(32), default="platform")
|
config_mode: Mapped[str] = mapped_column(String(32), default="platform")
|
||||||
api_url: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
api_url: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
api_key: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
api_key: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
app_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
# 模型关联
|
# 模型关联
|
||||||
llm_model_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
llm_model_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||||
asr_model_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
asr_model_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ def _ensure_assistant_schema(db: Session) -> None:
|
|||||||
if "asr_interim_enabled" not in columns:
|
if "asr_interim_enabled" not in columns:
|
||||||
db.execute(text("ALTER TABLE assistants ADD COLUMN asr_interim_enabled BOOLEAN DEFAULT 0"))
|
db.execute(text("ALTER TABLE assistants ADD COLUMN asr_interim_enabled BOOLEAN DEFAULT 0"))
|
||||||
altered = True
|
altered = True
|
||||||
|
if "app_id" not in columns:
|
||||||
|
db.execute(text("ALTER TABLE assistants ADD COLUMN app_id VARCHAR(255)"))
|
||||||
|
altered = True
|
||||||
|
|
||||||
if altered:
|
if altered:
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -297,7 +300,7 @@ def _resolve_runtime_metadata(db: Session, assistant: Assistant) -> tuple[Dict[s
|
|||||||
|
|
||||||
config_mode = str(assistant.config_mode or "platform").strip().lower()
|
config_mode = str(assistant.config_mode or "platform").strip().lower()
|
||||||
|
|
||||||
if config_mode in {"dify", "fastgpt"}:
|
if config_mode == "dify":
|
||||||
metadata["services"]["llm"] = {
|
metadata["services"]["llm"] = {
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model": "",
|
"model": "",
|
||||||
@@ -308,6 +311,19 @@ def _resolve_runtime_metadata(db: Session, assistant: Assistant) -> tuple[Dict[s
|
|||||||
warnings.append(f"External LLM API URL is empty for mode: {assistant.config_mode}")
|
warnings.append(f"External LLM API URL is empty for mode: {assistant.config_mode}")
|
||||||
if not (assistant.api_key or "").strip():
|
if not (assistant.api_key or "").strip():
|
||||||
warnings.append(f"External LLM API key is empty for mode: {assistant.config_mode}")
|
warnings.append(f"External LLM API key is empty for mode: {assistant.config_mode}")
|
||||||
|
elif config_mode == "fastgpt":
|
||||||
|
metadata["services"]["llm"] = {
|
||||||
|
"provider": "fastgpt",
|
||||||
|
"model": "fastgpt",
|
||||||
|
"apiKey": assistant.api_key,
|
||||||
|
"baseUrl": assistant.api_url,
|
||||||
|
}
|
||||||
|
if (assistant.app_id or "").strip():
|
||||||
|
metadata["services"]["llm"]["appId"] = assistant.app_id
|
||||||
|
if not (assistant.api_url or "").strip():
|
||||||
|
warnings.append(f"FastGPT API URL is empty for mode: {assistant.config_mode}")
|
||||||
|
if not (assistant.api_key or "").strip():
|
||||||
|
warnings.append(f"FastGPT API key is empty for mode: {assistant.config_mode}")
|
||||||
elif assistant.llm_model_id:
|
elif assistant.llm_model_id:
|
||||||
llm = db.query(LLMModel).filter(LLMModel.id == assistant.llm_model_id).first()
|
llm = db.query(LLMModel).filter(LLMModel.id == assistant.llm_model_id).first()
|
||||||
if llm:
|
if llm:
|
||||||
@@ -450,6 +466,7 @@ def assistant_to_dict(assistant: Assistant) -> dict:
|
|||||||
"configMode": assistant.config_mode,
|
"configMode": assistant.config_mode,
|
||||||
"apiUrl": assistant.api_url,
|
"apiUrl": assistant.api_url,
|
||||||
"apiKey": assistant.api_key,
|
"apiKey": assistant.api_key,
|
||||||
|
"appId": assistant.app_id,
|
||||||
"llmModelId": assistant.llm_model_id,
|
"llmModelId": assistant.llm_model_id,
|
||||||
"asrModelId": assistant.asr_model_id,
|
"asrModelId": assistant.asr_model_id,
|
||||||
"embeddingModelId": assistant.embedding_model_id,
|
"embeddingModelId": assistant.embedding_model_id,
|
||||||
@@ -472,6 +489,7 @@ def _apply_assistant_update(assistant: Assistant, update_data: dict) -> None:
|
|||||||
"generatedOpenerEnabled": "generated_opener_enabled",
|
"generatedOpenerEnabled": "generated_opener_enabled",
|
||||||
"apiUrl": "api_url",
|
"apiUrl": "api_url",
|
||||||
"apiKey": "api_key",
|
"apiKey": "api_key",
|
||||||
|
"appId": "app_id",
|
||||||
"llmModelId": "llm_model_id",
|
"llmModelId": "llm_model_id",
|
||||||
"asrModelId": "asr_model_id",
|
"asrModelId": "asr_model_id",
|
||||||
"embeddingModelId": "embedding_model_id",
|
"embeddingModelId": "embedding_model_id",
|
||||||
@@ -666,6 +684,7 @@ def create_assistant(data: AssistantCreate, db: Session = Depends(get_db)):
|
|||||||
config_mode=data.configMode,
|
config_mode=data.configMode,
|
||||||
api_url=data.apiUrl,
|
api_url=data.apiUrl,
|
||||||
api_key=data.apiKey,
|
api_key=data.apiKey,
|
||||||
|
app_id=data.appId,
|
||||||
llm_model_id=data.llmModelId,
|
llm_model_id=data.llmModelId,
|
||||||
asr_model_id=data.asrModelId,
|
asr_model_id=data.asrModelId,
|
||||||
embedding_model_id=data.embeddingModelId,
|
embedding_model_id=data.embeddingModelId,
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ class AssistantBase(BaseModel):
|
|||||||
configMode: str = "platform"
|
configMode: str = "platform"
|
||||||
apiUrl: Optional[str] = None
|
apiUrl: Optional[str] = None
|
||||||
apiKey: Optional[str] = None
|
apiKey: Optional[str] = None
|
||||||
|
appId: Optional[str] = None
|
||||||
# 模型关联
|
# 模型关联
|
||||||
llmModelId: Optional[str] = None
|
llmModelId: Optional[str] = None
|
||||||
asrModelId: Optional[str] = None
|
asrModelId: Optional[str] = None
|
||||||
@@ -330,6 +331,7 @@ class AssistantUpdate(BaseModel):
|
|||||||
configMode: Optional[str] = None
|
configMode: Optional[str] = None
|
||||||
apiUrl: Optional[str] = None
|
apiUrl: Optional[str] = None
|
||||||
apiKey: Optional[str] = None
|
apiKey: Optional[str] = None
|
||||||
|
appId: Optional[str] = None
|
||||||
llmModelId: Optional[str] = None
|
llmModelId: Optional[str] = None
|
||||||
asrModelId: Optional[str] = None
|
asrModelId: Optional[str] = None
|
||||||
embeddingModelId: Optional[str] = None
|
embeddingModelId: Optional[str] = None
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class TestAssistantAPI:
|
|||||||
assert data["generatedOpenerEnabled"] is False
|
assert data["generatedOpenerEnabled"] is False
|
||||||
assert data["asrInterimEnabled"] is False
|
assert data["asrInterimEnabled"] is False
|
||||||
assert data["botCannotBeInterrupted"] is False
|
assert data["botCannotBeInterrupted"] is False
|
||||||
|
assert data["appId"] is None
|
||||||
assert "id" in data
|
assert "id" in data
|
||||||
assert data["callCount"] == 0
|
assert data["callCount"] == 0
|
||||||
|
|
||||||
@@ -419,3 +420,21 @@ class TestAssistantAPI:
|
|||||||
assert metadata["greeting"] == ""
|
assert metadata["greeting"] == ""
|
||||||
assert metadata["bargeIn"]["enabled"] is False
|
assert metadata["bargeIn"]["enabled"] is False
|
||||||
assert metadata["bargeIn"]["minDurationMs"] == 900
|
assert metadata["bargeIn"]["minDurationMs"] == 900
|
||||||
|
|
||||||
|
def test_fastgpt_app_id_persists_and_flows_to_runtime(self, client, sample_assistant_data):
|
||||||
|
sample_assistant_data.update({
|
||||||
|
"configMode": "fastgpt",
|
||||||
|
"apiUrl": "https://cloud.fastgpt.cn/api",
|
||||||
|
"apiKey": "fastgpt-key",
|
||||||
|
"appId": "app-fastgpt-123",
|
||||||
|
})
|
||||||
|
assistant_resp = client.post("/api/assistants", json=sample_assistant_data)
|
||||||
|
assert assistant_resp.status_code == 200
|
||||||
|
assistant_id = assistant_resp.json()["id"]
|
||||||
|
assert assistant_resp.json()["appId"] == "app-fastgpt-123"
|
||||||
|
|
||||||
|
runtime_resp = client.get(f"/api/assistants/{assistant_id}/runtime-config")
|
||||||
|
assert runtime_resp.status_code == 200
|
||||||
|
metadata = runtime_resp.json()["sessionStartMetadata"]
|
||||||
|
assert metadata["services"]["llm"]["provider"] == "fastgpt"
|
||||||
|
assert metadata["services"]["llm"]["appId"] == "app-fastgpt-123"
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Build this image from the project parent directory so both
|
||||||
|
# engine-v3/engine and fastgpt-python-sdk are available in the context.
|
||||||
|
# Example:
|
||||||
|
# docker build -f engine-v3/engine/Dockerfile -t engine-v3 .
|
||||||
|
|
||||||
# Install system dependencies for audio processing
|
# Install system dependencies for audio processing
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
@@ -12,11 +17,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install Python dependencies
|
# Install Python dependencies
|
||||||
COPY requirements.txt .
|
COPY engine-v3/engine/requirements.txt /tmp/requirements.txt
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
COPY fastgpt-python-sdk /deps/fastgpt-python-sdk
|
||||||
|
RUN pip install --no-cache-dir -r /tmp/requirements.txt \
|
||||||
|
&& pip install --no-cache-dir /deps/fastgpt-python-sdk
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code
|
||||||
COPY . .
|
COPY engine-v3/engine /app
|
||||||
|
|
||||||
# Create necessary directories
|
# Create necessary directories
|
||||||
RUN mkdir -p /app/logs /app/data/vad
|
RUN mkdir -p /app/logs /app/data/vad
|
||||||
|
|||||||
@@ -214,6 +214,8 @@ class LocalYamlAssistantConfigAdapter(NullBackendAdapter):
|
|||||||
llm_runtime["apiKey"] = cls._as_str(llm.get("api_key"))
|
llm_runtime["apiKey"] = cls._as_str(llm.get("api_key"))
|
||||||
if cls._as_str(llm.get("api_url")):
|
if cls._as_str(llm.get("api_url")):
|
||||||
llm_runtime["baseUrl"] = cls._as_str(llm.get("api_url"))
|
llm_runtime["baseUrl"] = cls._as_str(llm.get("api_url"))
|
||||||
|
if cls._as_str(llm.get("app_id")):
|
||||||
|
llm_runtime["appId"] = cls._as_str(llm.get("app_id"))
|
||||||
if llm_runtime:
|
if llm_runtime:
|
||||||
runtime["services"]["llm"] = llm_runtime
|
runtime["services"]["llm"] = llm_runtime
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class Settings(BaseSettings):
|
|||||||
# LLM Configuration
|
# LLM Configuration
|
||||||
llm_provider: str = Field(
|
llm_provider: str = Field(
|
||||||
default="openai",
|
default="openai",
|
||||||
description="LLM provider (openai, openai_compatible, siliconflow)"
|
description="LLM provider (openai, openai_compatible, siliconflow, fastgpt)"
|
||||||
)
|
)
|
||||||
llm_api_url: Optional[str] = Field(default=None, description="LLM provider API base URL")
|
llm_api_url: Optional[str] = Field(default=None, description="LLM provider API base URL")
|
||||||
llm_model: str = Field(default="gpt-4o-mini", description="LLM model name")
|
llm_model: str = Field(default="gpt-4o-mini", description="LLM model name")
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ from runtime.events import get_event_bus, reset_event_bus
|
|||||||
_HEARTBEAT_CHECK_INTERVAL_SEC = 5
|
_HEARTBEAT_CHECK_INTERVAL_SEC = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _inactivity_deadline(
|
||||||
|
*,
|
||||||
|
last_received_at: float,
|
||||||
|
inactivity_timeout_sec: int,
|
||||||
|
pending_client_tool_deadline: Optional[float] = None,
|
||||||
|
) -> float:
|
||||||
|
deadline = float(last_received_at) + float(inactivity_timeout_sec)
|
||||||
|
if pending_client_tool_deadline is not None:
|
||||||
|
deadline = max(deadline, float(pending_client_tool_deadline))
|
||||||
|
return deadline
|
||||||
|
|
||||||
|
|
||||||
async def heartbeat_and_timeout_task(
|
async def heartbeat_and_timeout_task(
|
||||||
transport: BaseTransport,
|
transport: BaseTransport,
|
||||||
session: Session,
|
session: Session,
|
||||||
@@ -48,8 +60,22 @@ async def heartbeat_and_timeout_task(
|
|||||||
if transport.is_closed:
|
if transport.is_closed:
|
||||||
break
|
break
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if now - last_received_at[0] > inactivity_timeout_sec:
|
pending_client_tool_deadline = session.pipeline.pending_client_tool_deadline()
|
||||||
logger.info(f"Session {session_id}: {inactivity_timeout_sec}s no message, closing")
|
idle_deadline = _inactivity_deadline(
|
||||||
|
last_received_at=last_received_at[0],
|
||||||
|
inactivity_timeout_sec=inactivity_timeout_sec,
|
||||||
|
pending_client_tool_deadline=pending_client_tool_deadline,
|
||||||
|
)
|
||||||
|
if now > idle_deadline:
|
||||||
|
if pending_client_tool_deadline is not None and pending_client_tool_deadline >= (
|
||||||
|
last_received_at[0] + inactivity_timeout_sec
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"Session {}: no message before pending client tool deadline, closing",
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(f"Session {session_id}: {inactivity_timeout_sec}s no message, closing")
|
||||||
await session.cleanup()
|
await session.cleanup()
|
||||||
break
|
break
|
||||||
if now - last_heartbeat_at[0] >= heartbeat_interval_sec:
|
if now - last_heartbeat_at[0] >= heartbeat_interval_sec:
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ agent:
|
|||||||
|
|
||||||
duplex:
|
duplex:
|
||||||
enabled: true
|
enabled: true
|
||||||
system_prompt: You are a helpful, friendly voice assistant. Keep your responses concise and conversational.
|
system_prompt: 你是一个人工智能助手,你用简答语句回答,避免使用标点符号和emoji。
|
||||||
|
|
||||||
barge_in:
|
barge_in:
|
||||||
min_duration_ms: 200
|
min_duration_ms: 200
|
||||||
|
|||||||
47
engine/config/agents/dashscope_ontest.yaml
Normal file
47
engine/config/agents/dashscope_ontest.yaml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Agent behavior configuration for DashScope realtime ASR/TTS.
|
||||||
|
# This file only controls agent-side behavior (VAD/LLM/TTS/ASR providers).
|
||||||
|
# Infra/server/network settings should stay in .env.
|
||||||
|
|
||||||
|
agent:
|
||||||
|
vad:
|
||||||
|
type: silero
|
||||||
|
model_path: data/vad/silero_vad.onnx
|
||||||
|
threshold: 0.5
|
||||||
|
min_speech_duration_ms: 100
|
||||||
|
eou_threshold_ms: 800
|
||||||
|
|
||||||
|
llm:
|
||||||
|
# provider: openai | openai_compatible | siliconflow
|
||||||
|
provider: openai_compatible
|
||||||
|
model: deepseek-v3
|
||||||
|
temperature: 0.7
|
||||||
|
api_key: sk-fc4d59b360475f53401a864db8ce0985010acc4e696723d20a90d6569f38d80a
|
||||||
|
api_url: https://api.qnaigc.com/v1
|
||||||
|
|
||||||
|
tts:
|
||||||
|
provider: dashscope
|
||||||
|
api_key: sk-391f5126d18345d497c6e8717c8c9ad7
|
||||||
|
api_url: wss://dashscope.aliyuncs.com/api-ws/v1/realtime
|
||||||
|
model: qwen3-tts-flash-realtime
|
||||||
|
voice: Cherry
|
||||||
|
dashscope_mode: commit
|
||||||
|
speed: 1.0
|
||||||
|
|
||||||
|
asr:
|
||||||
|
provider: dashscope
|
||||||
|
api_key: sk-391f5126d18345d497c6e8717c8c9ad7
|
||||||
|
api_url: wss://dashscope.aliyuncs.com/api-ws/v1/realtime
|
||||||
|
model: qwen3-asr-flash-realtime
|
||||||
|
interim_interval_ms: 500
|
||||||
|
min_audio_ms: 300
|
||||||
|
start_min_speech_ms: 160
|
||||||
|
pre_speech_ms: 240
|
||||||
|
final_tail_ms: 120
|
||||||
|
|
||||||
|
duplex:
|
||||||
|
enabled: true
|
||||||
|
system_prompt: 你是一个人工智能助手,你用简答语句回答,避免使用标点符号和emoji。
|
||||||
|
|
||||||
|
barge_in:
|
||||||
|
min_duration_ms: 200
|
||||||
|
silence_tolerance_ms: 60
|
||||||
@@ -11,7 +11,7 @@ agent:
|
|||||||
eou_threshold_ms: 800
|
eou_threshold_ms: 800
|
||||||
|
|
||||||
llm:
|
llm:
|
||||||
# provider: openai | openai_compatible | siliconflow
|
# provider: openai | openai_compatible | siliconflow | fastgpt
|
||||||
provider: openai_compatible
|
provider: openai_compatible
|
||||||
model: deepseek-v3
|
model: deepseek-v3
|
||||||
temperature: 0.7
|
temperature: 0.7
|
||||||
@@ -73,3 +73,4 @@ agent:
|
|||||||
barge_in:
|
barge_in:
|
||||||
min_duration_ms: 200
|
min_duration_ms: 200
|
||||||
silence_tolerance_ms: 60
|
silence_tolerance_ms: 60
|
||||||
|
|
||||||
|
|||||||
67
engine/config/agents/volcengine_ontest.yaml
Normal file
67
engine/config/agents/volcengine_ontest.yaml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Agent behavior configuration (safe to edit per profile)
|
||||||
|
# This file only controls agent-side behavior (VAD/LLM/TTS/ASR providers).
|
||||||
|
# Infra/server/network settings should stay in .env.
|
||||||
|
|
||||||
|
agent:
|
||||||
|
vad:
|
||||||
|
type: silero
|
||||||
|
model_path: data/vad/silero_vad.onnx
|
||||||
|
threshold: 0.5
|
||||||
|
min_speech_duration_ms: 100
|
||||||
|
eou_threshold_ms: 800
|
||||||
|
|
||||||
|
llm:
|
||||||
|
# provider: openai | openai_compatible | siliconflow
|
||||||
|
provider: openai_compatible
|
||||||
|
model: deepseek-v3
|
||||||
|
temperature: 0.7
|
||||||
|
# Required: no fallback. You can still reference env explicitly.
|
||||||
|
api_key: sk-fc4d59b360475f53401a864db8ce0985010acc4e696723d20a90d6569f38d80a
|
||||||
|
# Optional for OpenAI-compatible endpoints:
|
||||||
|
api_url: https://api.qnaigc.com/v1
|
||||||
|
|
||||||
|
tts:
|
||||||
|
# provider: edge | openai_compatible | siliconflow | dashscope
|
||||||
|
# dashscope defaults (if omitted):
|
||||||
|
# api_url: wss://dashscope.aliyuncs.com/api-ws/v1/realtime
|
||||||
|
# model: qwen3-tts-flash-realtime
|
||||||
|
# dashscope_mode: commit (engine splits) | server_commit (dashscope splits)
|
||||||
|
# note: dashscope_mode/mode is ONLY used when provider=dashscope.
|
||||||
|
# volcengine defaults (if omitted):
|
||||||
|
provider: volcengine
|
||||||
|
api_url: https://openspeech.bytedance.com/api/v3/tts/unidirectional
|
||||||
|
resource_id: seed-tts-2.0
|
||||||
|
app_id: 2931820332
|
||||||
|
api_key: 4ustCTIpdCq8dE_msFrZvFn4nDpioIVo
|
||||||
|
speed: 1.1
|
||||||
|
voice: zh_female_vv_uranus_bigtts
|
||||||
|
|
||||||
|
asr:
|
||||||
|
provider: volcengine
|
||||||
|
api_url: wss://openspeech.bytedance.com/api/v3/sauc/bigmodel
|
||||||
|
app_id: 8607675070
|
||||||
|
api_key: QiO0AptfmU0GLTSitwn7t5-zeo4gJ6K1
|
||||||
|
resource_id: volc.bigasr.sauc.duration
|
||||||
|
uid: caller-1
|
||||||
|
model: bigmodel
|
||||||
|
request_params:
|
||||||
|
end_window_size: 800
|
||||||
|
force_to_speech_time: 1000
|
||||||
|
enable_punc: true
|
||||||
|
enable_itn: false
|
||||||
|
enable_ddc: false
|
||||||
|
show_utterance: true
|
||||||
|
result_type: single
|
||||||
|
interim_interval_ms: 500
|
||||||
|
min_audio_ms: 300
|
||||||
|
start_min_speech_ms: 160
|
||||||
|
pre_speech_ms: 240
|
||||||
|
final_tail_ms: 120
|
||||||
|
|
||||||
|
duplex:
|
||||||
|
enabled: true
|
||||||
|
system_prompt: 你是一个人工智能助手,你用简答语句回答,避免使用标点符号和emoji。
|
||||||
|
|
||||||
|
barge_in:
|
||||||
|
min_duration_ms: 200
|
||||||
|
silence_tolerance_ms: 60
|
||||||
@@ -3,13 +3,15 @@
|
|||||||
WAV file client for testing duplex voice conversation.
|
WAV file client for testing duplex voice conversation.
|
||||||
|
|
||||||
This client reads audio from a WAV file, sends it to the server,
|
This client reads audio from a WAV file, sends it to the server,
|
||||||
and saves the AI's voice response to an output WAV file.
|
and saves a stereo WAV file with the input audio on the left channel
|
||||||
|
and the AI's voice response on the right channel.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python examples/wav_client.py --input input.wav --output response.wav
|
python examples/wav_client.py --input input.wav --output response.wav
|
||||||
python examples/wav_client.py --input input.wav --output response.wav --url ws://localhost:8000/ws
|
python examples/wav_client.py --input input.wav --output response.wav --url ws://localhost:8000/ws
|
||||||
python examples/wav_client.py --input input.wav --output response.wav --wait-time 10
|
python examples/wav_client.py --input input.wav --output response.wav --wait-time 10
|
||||||
python wav_client.py --input ../data/audio_examples/two_utterances.wav -o response.wav
|
python wav_client.py --input ../data/audio_examples/two_utterances.wav -o response.wav
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
pip install soundfile websockets numpy
|
pip install soundfile websockets numpy
|
||||||
"""
|
"""
|
||||||
@@ -49,7 +51,7 @@ class WavFileClient:
|
|||||||
Features:
|
Features:
|
||||||
- Read audio from WAV file
|
- Read audio from WAV file
|
||||||
- Send audio to WebSocket server
|
- Send audio to WebSocket server
|
||||||
- Receive and save response audio
|
- Receive and save stereo conversation audio
|
||||||
- Event logging
|
- Event logging
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -98,7 +100,10 @@ class WavFileClient:
|
|||||||
self.running = False
|
self.running = False
|
||||||
|
|
||||||
# Audio buffers
|
# Audio buffers
|
||||||
|
self.input_audio = np.array([], dtype=np.int16)
|
||||||
self.received_audio = bytearray()
|
self.received_audio = bytearray()
|
||||||
|
self.output_segments: list[dict[str, object]] = []
|
||||||
|
self.current_output_segment: bytearray | None = None
|
||||||
|
|
||||||
# Statistics
|
# Statistics
|
||||||
self.bytes_sent = 0
|
self.bytes_sent = 0
|
||||||
@@ -106,7 +111,7 @@ class WavFileClient:
|
|||||||
|
|
||||||
# TTFB tracking (per response)
|
# TTFB tracking (per response)
|
||||||
self.send_start_time = None
|
self.send_start_time = None
|
||||||
self.response_start_time = None # set on each trackStart
|
self.response_start_time = None # set on each output.audio.start
|
||||||
self.waiting_for_first_audio = False
|
self.waiting_for_first_audio = False
|
||||||
self.ttfb_ms = None # last TTFB for summary
|
self.ttfb_ms = None # last TTFB for summary
|
||||||
self.ttfb_list = [] # TTFB for each response
|
self.ttfb_list = [] # TTFB for each response
|
||||||
@@ -120,20 +125,20 @@ class WavFileClient:
|
|||||||
# Events log
|
# Events log
|
||||||
self.events_log = []
|
self.events_log = []
|
||||||
|
|
||||||
def log_event(self, direction: str, message: str):
|
def log_event(self, direction: str, message: str) -> None:
|
||||||
"""Log an event with timestamp."""
|
"""Log an event with timestamp."""
|
||||||
timestamp = time.time()
|
timestamp = time.time()
|
||||||
self.events_log.append({
|
self.events_log.append(
|
||||||
"timestamp": timestamp,
|
{
|
||||||
"direction": direction,
|
"timestamp": timestamp,
|
||||||
"message": message
|
"direction": direction,
|
||||||
})
|
"message": message,
|
||||||
# Handle encoding errors on Windows
|
}
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
print(f"{direction} {message}")
|
print(f"{direction} {message}")
|
||||||
except UnicodeEncodeError:
|
except UnicodeEncodeError:
|
||||||
# Replace problematic characters for console output
|
safe_message = message.encode("ascii", errors="replace").decode("ascii")
|
||||||
safe_message = message.encode('ascii', errors='replace').decode('ascii')
|
|
||||||
print(f"{direction} {safe_message}")
|
print(f"{direction} {safe_message}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -153,39 +158,93 @@ class WavFileClient:
|
|||||||
query["assistant_id"] = self.assistant_id
|
query["assistant_id"] = self.assistant_id
|
||||||
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
||||||
|
|
||||||
|
def _current_timeline_sample(self) -> int:
|
||||||
|
"""Return current sample position relative to input send start."""
|
||||||
|
if self.send_start_time is None:
|
||||||
|
return 0
|
||||||
|
elapsed_seconds = max(0.0, time.time() - self.send_start_time)
|
||||||
|
return int(round(elapsed_seconds * self.sample_rate))
|
||||||
|
|
||||||
|
def _start_output_segment(self) -> None:
|
||||||
|
"""Create a new assistant-audio segment if one is not active."""
|
||||||
|
if self.current_output_segment is not None:
|
||||||
|
return
|
||||||
|
self.current_output_segment = bytearray()
|
||||||
|
self.output_segments.append(
|
||||||
|
{
|
||||||
|
"start_sample": self._current_timeline_sample(),
|
||||||
|
"audio": self.current_output_segment,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _close_output_segment(self) -> None:
|
||||||
|
"""Close the active assistant-audio segment, if any."""
|
||||||
|
self.current_output_segment = None
|
||||||
|
|
||||||
|
def _build_input_track(self) -> np.ndarray:
|
||||||
|
"""Build the saved left channel using the streamed input audio."""
|
||||||
|
input_track = self.input_audio.astype(np.int16, copy=True)
|
||||||
|
tail_samples = int(round(self.sample_rate * self.tail_silence_ms / 1000.0))
|
||||||
|
if tail_samples <= 0:
|
||||||
|
return input_track
|
||||||
|
if input_track.size == 0:
|
||||||
|
return np.zeros(tail_samples, dtype=np.int16)
|
||||||
|
return np.concatenate((input_track, np.zeros(tail_samples, dtype=np.int16)))
|
||||||
|
|
||||||
|
def _build_output_track(self) -> np.ndarray:
|
||||||
|
"""Build the saved right channel using received assistant audio."""
|
||||||
|
if not self.output_segments:
|
||||||
|
return np.zeros(0, dtype=np.int16)
|
||||||
|
|
||||||
|
total_samples = max(
|
||||||
|
int(segment["start_sample"]) + (len(segment["audio"]) // 2)
|
||||||
|
for segment in self.output_segments
|
||||||
|
)
|
||||||
|
mixed_track = np.zeros(total_samples, dtype=np.int32)
|
||||||
|
|
||||||
|
for segment in self.output_segments:
|
||||||
|
start_sample = int(segment["start_sample"])
|
||||||
|
segment_audio = np.frombuffer(bytes(segment["audio"]), dtype=np.int16).astype(np.int32)
|
||||||
|
if segment_audio.size == 0:
|
||||||
|
continue
|
||||||
|
end_sample = start_sample + segment_audio.size
|
||||||
|
mixed_track[start_sample:end_sample] += segment_audio
|
||||||
|
|
||||||
|
np.clip(mixed_track, -32768, 32767, out=mixed_track)
|
||||||
|
return mixed_track.astype(np.int16)
|
||||||
|
|
||||||
async def connect(self) -> None:
|
async def connect(self) -> None:
|
||||||
"""Connect to WebSocket server."""
|
"""Connect to WebSocket server."""
|
||||||
session_url = self._session_url()
|
session_url = self._session_url()
|
||||||
self.log_event("→", f"Connecting to {session_url}...")
|
self.log_event("->", f"Connecting to {session_url}...")
|
||||||
self.ws = await websockets.connect(session_url)
|
self.ws = await websockets.connect(session_url)
|
||||||
self.running = True
|
self.running = True
|
||||||
self.log_event("←", "Connected!")
|
self.log_event("->", "Connected!")
|
||||||
|
|
||||||
await self.send_command({
|
await self.send_command(
|
||||||
"type": "session.start",
|
{
|
||||||
"audio": {
|
"type": "session.start",
|
||||||
"encoding": "pcm_s16le",
|
"audio": {
|
||||||
"sample_rate_hz": self.sample_rate,
|
"encoding": "pcm_s16le",
|
||||||
"channels": 1
|
"sample_rate_hz": self.sample_rate,
|
||||||
},
|
"channels": 1,
|
||||||
"metadata": {
|
},
|
||||||
"channel": self.channel,
|
"metadata": {
|
||||||
"source": "wav_client",
|
"channel": self.channel,
|
||||||
},
|
"source": "wav_client",
|
||||||
})
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def send_command(self, cmd: dict) -> None:
|
async def send_command(self, cmd: dict) -> None:
|
||||||
"""Send JSON command to server."""
|
"""Send JSON command to server."""
|
||||||
if self.ws:
|
if self.ws:
|
||||||
await self.ws.send(json.dumps(cmd))
|
await self.ws.send(json.dumps(cmd))
|
||||||
self.log_event("→", f"Command: {cmd.get('type', 'unknown')}")
|
self.log_event("->", f"Command: {cmd.get('type', 'unknown')}")
|
||||||
|
|
||||||
async def send_hangup(self, reason: str = "Session complete") -> None:
|
async def send_hangup(self, reason: str = "Session complete") -> None:
|
||||||
"""Send hangup command."""
|
"""Send hangup command."""
|
||||||
await self.send_command({
|
await self.send_command({"type": "session.stop", "reason": reason})
|
||||||
"type": "session.stop",
|
|
||||||
"reason": reason
|
|
||||||
})
|
|
||||||
|
|
||||||
def load_wav_file(self) -> tuple[np.ndarray, int]:
|
def load_wav_file(self) -> tuple[np.ndarray, int]:
|
||||||
"""
|
"""
|
||||||
@@ -197,36 +256,30 @@ class WavFileClient:
|
|||||||
if not self.input_file.exists():
|
if not self.input_file.exists():
|
||||||
raise FileNotFoundError(f"Input file not found: {self.input_file}")
|
raise FileNotFoundError(f"Input file not found: {self.input_file}")
|
||||||
|
|
||||||
# Load audio file
|
|
||||||
audio_data, file_sample_rate = sf.read(self.input_file)
|
audio_data, file_sample_rate = sf.read(self.input_file)
|
||||||
self.log_event("→", f"Loaded: {self.input_file}")
|
self.log_event("->", f"Loaded: {self.input_file}")
|
||||||
self.log_event("→", f" Original sample rate: {file_sample_rate} Hz")
|
self.log_event("->", f" Original sample rate: {file_sample_rate} Hz")
|
||||||
self.log_event("→", f" Duration: {len(audio_data) / file_sample_rate:.2f}s")
|
self.log_event("->", f" Duration: {len(audio_data) / file_sample_rate:.2f}s")
|
||||||
|
|
||||||
# Convert stereo to mono if needed
|
|
||||||
if len(audio_data.shape) > 1:
|
if len(audio_data.shape) > 1:
|
||||||
audio_data = audio_data.mean(axis=1)
|
audio_data = audio_data.mean(axis=1)
|
||||||
self.log_event("→", " Converted stereo to mono")
|
self.log_event("->", " Converted stereo to mono")
|
||||||
|
|
||||||
# Resample if needed
|
|
||||||
if file_sample_rate != self.sample_rate:
|
if file_sample_rate != self.sample_rate:
|
||||||
# Simple resampling using numpy
|
|
||||||
duration = len(audio_data) / file_sample_rate
|
duration = len(audio_data) / file_sample_rate
|
||||||
num_samples = int(duration * self.sample_rate)
|
num_samples = int(duration * self.sample_rate)
|
||||||
indices = np.linspace(0, len(audio_data) - 1, num_samples)
|
indices = np.linspace(0, len(audio_data) - 1, num_samples)
|
||||||
audio_data = np.interp(indices, np.arange(len(audio_data)), audio_data)
|
audio_data = np.interp(indices, np.arange(len(audio_data)), audio_data)
|
||||||
self.log_event("→", f" Resampled to {self.sample_rate} Hz")
|
self.log_event("->", f" Resampled to {self.sample_rate} Hz")
|
||||||
|
|
||||||
# Convert to int16
|
|
||||||
if audio_data.dtype != np.int16:
|
if audio_data.dtype != np.int16:
|
||||||
# Normalize to [-1, 1] if needed
|
|
||||||
max_val = np.max(np.abs(audio_data))
|
max_val = np.max(np.abs(audio_data))
|
||||||
if max_val > 1.0:
|
if max_val > 1.0:
|
||||||
audio_data = audio_data / max_val
|
audio_data = audio_data / max_val
|
||||||
audio_data = (audio_data * 32767).astype(np.int16)
|
audio_data = (audio_data * 32767).astype(np.int16)
|
||||||
|
|
||||||
self.log_event("→", f" Prepared: {len(audio_data)} samples ({len(audio_data)/self.sample_rate:.2f}s)")
|
self.log_event("->", f" Prepared: {len(audio_data)} samples ({len(audio_data) / self.sample_rate:.2f}s)")
|
||||||
|
self.input_audio = audio_data.copy()
|
||||||
return audio_data, file_sample_rate
|
return audio_data, file_sample_rate
|
||||||
|
|
||||||
async def audio_sender(self, audio_data: np.ndarray) -> None:
|
async def audio_sender(self, audio_data: np.ndarray) -> None:
|
||||||
@@ -236,35 +289,28 @@ class WavFileClient:
|
|||||||
sent_samples = 0
|
sent_samples = 0
|
||||||
|
|
||||||
self.send_start_time = time.time()
|
self.send_start_time = time.time()
|
||||||
self.log_event("→", f"Starting audio transmission ({total_samples} samples)...")
|
self.log_event("->", f"Starting audio transmission ({total_samples} samples)...")
|
||||||
|
|
||||||
while sent_samples < total_samples and self.running:
|
while sent_samples < total_samples and self.running:
|
||||||
# Get next chunk
|
|
||||||
end_sample = min(sent_samples + chunk_size, total_samples)
|
end_sample = min(sent_samples + chunk_size, total_samples)
|
||||||
chunk = audio_data[sent_samples:end_sample]
|
chunk = audio_data[sent_samples:end_sample]
|
||||||
chunk_bytes = chunk.tobytes()
|
chunk_bytes = chunk.tobytes()
|
||||||
if len(chunk_bytes) % self.frame_bytes != 0:
|
if len(chunk_bytes) % self.frame_bytes != 0:
|
||||||
# v1 audio framing requires 640-byte (20ms) PCM units.
|
|
||||||
pad = self.frame_bytes - (len(chunk_bytes) % self.frame_bytes)
|
pad = self.frame_bytes - (len(chunk_bytes) % self.frame_bytes)
|
||||||
chunk_bytes += b"\x00" * pad
|
chunk_bytes += b"\x00" * pad
|
||||||
|
|
||||||
# Send to server
|
|
||||||
if self.ws:
|
if self.ws:
|
||||||
await self.ws.send(chunk_bytes)
|
await self.ws.send(chunk_bytes)
|
||||||
self.bytes_sent += len(chunk_bytes)
|
self.bytes_sent += len(chunk_bytes)
|
||||||
|
|
||||||
sent_samples = end_sample
|
sent_samples = end_sample
|
||||||
|
|
||||||
# Progress logging (every 500ms worth of audio)
|
|
||||||
if self.verbose and sent_samples % (self.sample_rate // 2) == 0:
|
if self.verbose and sent_samples % (self.sample_rate // 2) == 0:
|
||||||
progress = (sent_samples / total_samples) * 100
|
progress = (sent_samples / total_samples) * 100
|
||||||
print(f" Sending: {progress:.0f}%", end="\r")
|
print(f" Sending: {progress:.0f}%", end="\r")
|
||||||
|
|
||||||
# Delay to simulate real-time streaming
|
|
||||||
# Server expects audio at real-time pace for VAD/ASR to work properly
|
|
||||||
await asyncio.sleep(self.chunk_duration_ms / 1000)
|
await asyncio.sleep(self.chunk_duration_ms / 1000)
|
||||||
|
|
||||||
# Add a short silence tail to help VAD/EOU close the final utterance.
|
|
||||||
if self.tail_silence_ms > 0 and self.ws:
|
if self.tail_silence_ms > 0 and self.ws:
|
||||||
tail_frames = max(1, self.tail_silence_ms // 20)
|
tail_frames = max(1, self.tail_silence_ms // 20)
|
||||||
silence = b"\x00" * self.frame_bytes
|
silence = b"\x00" * self.frame_bytes
|
||||||
@@ -272,11 +318,11 @@ class WavFileClient:
|
|||||||
await self.ws.send(silence)
|
await self.ws.send(silence)
|
||||||
self.bytes_sent += len(silence)
|
self.bytes_sent += len(silence)
|
||||||
await asyncio.sleep(0.02)
|
await asyncio.sleep(0.02)
|
||||||
self.log_event("→", f"Sent trailing silence: {self.tail_silence_ms}ms")
|
self.log_event("->", f"Sent trailing silence: {self.tail_silence_ms}ms")
|
||||||
|
|
||||||
self.send_completed = True
|
self.send_completed = True
|
||||||
elapsed = time.time() - self.send_start_time
|
elapsed = time.time() - self.send_start_time
|
||||||
self.log_event("→", f"Audio transmission complete ({elapsed:.2f}s, {self.bytes_sent/1024:.1f} KB)")
|
self.log_event("->", f"Audio transmission complete ({elapsed:.2f}s, {self.bytes_sent / 1024:.1f} KB)")
|
||||||
|
|
||||||
async def receiver(self) -> None:
|
async def receiver(self) -> None:
|
||||||
"""Receive messages from server."""
|
"""Receive messages from server."""
|
||||||
@@ -286,40 +332,37 @@ class WavFileClient:
|
|||||||
message = await asyncio.wait_for(self.ws.recv(), timeout=0.1)
|
message = await asyncio.wait_for(self.ws.recv(), timeout=0.1)
|
||||||
|
|
||||||
if isinstance(message, bytes):
|
if isinstance(message, bytes):
|
||||||
# Audio data received
|
|
||||||
self.bytes_received += len(message)
|
self.bytes_received += len(message)
|
||||||
self.received_audio.extend(message)
|
self.received_audio.extend(message)
|
||||||
|
self._start_output_segment()
|
||||||
|
self.current_output_segment.extend(message)
|
||||||
|
|
||||||
# Calculate TTFB on first audio of each response
|
|
||||||
if self.waiting_for_first_audio and self.response_start_time is not None:
|
if self.waiting_for_first_audio and self.response_start_time is not None:
|
||||||
ttfb_ms = (time.time() - self.response_start_time) * 1000
|
ttfb_ms = (time.time() - self.response_start_time) * 1000
|
||||||
self.ttfb_ms = ttfb_ms
|
self.ttfb_ms = ttfb_ms
|
||||||
self.ttfb_list.append(ttfb_ms)
|
self.ttfb_list.append(ttfb_ms)
|
||||||
self.waiting_for_first_audio = False
|
self.waiting_for_first_audio = False
|
||||||
self.log_event("←", f"[TTFB] First audio latency: {ttfb_ms:.0f}ms")
|
self.log_event("<-", f"[TTFB] First audio latency: {ttfb_ms:.0f}ms")
|
||||||
|
|
||||||
# Log progress
|
|
||||||
duration_ms = len(message) / (self.sample_rate * 2) * 1000
|
duration_ms = len(message) / (self.sample_rate * 2) * 1000
|
||||||
total_ms = len(self.received_audio) / (self.sample_rate * 2) * 1000
|
total_ms = len(self.received_audio) / (self.sample_rate * 2) * 1000
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
print(f"← Audio: +{duration_ms:.0f}ms (total: {total_ms:.0f}ms)", end="\r")
|
print(f"<- Audio: +{duration_ms:.0f}ms (total: {total_ms:.0f}ms)", end="\r")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# JSON event
|
|
||||||
event = json.loads(message)
|
event = json.loads(message)
|
||||||
await self._handle_event(event)
|
await self._handle_event(event)
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
continue
|
continue
|
||||||
except websockets.ConnectionClosed:
|
except websockets.ConnectionClosed:
|
||||||
self.log_event("←", "Connection closed")
|
self.log_event("<-", "Connection closed")
|
||||||
self.running = False
|
self.running = False
|
||||||
break
|
break
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
self.log_event("!", f"Receiver error: {e}")
|
self.log_event("!", f"Receiver error: {exc}")
|
||||||
self.running = False
|
self.running = False
|
||||||
|
|
||||||
async def _handle_event(self, event: dict) -> None:
|
async def _handle_event(self, event: dict) -> None:
|
||||||
@@ -331,14 +374,14 @@ class WavFileClient:
|
|||||||
|
|
||||||
if event_type == "session.started":
|
if event_type == "session.started":
|
||||||
self.session_ready = True
|
self.session_ready = True
|
||||||
self.log_event("←", f"Session ready!{ids}")
|
self.log_event("<-", f"Session ready!{ids}")
|
||||||
elif event_type == "config.resolved":
|
elif event_type == "config.resolved":
|
||||||
config = event.get("config", {})
|
config = event.get("config", {})
|
||||||
self.log_event("←", f"Config resolved (output={config.get('output', {})}){ids}")
|
self.log_event("<-", f"Config resolved (output={config.get('output', {})}){ids}")
|
||||||
elif event_type == "input.speech_started":
|
elif event_type == "input.speech_started":
|
||||||
self.log_event("←", f"Speech detected{ids}")
|
self.log_event("<-", f"Speech detected{ids}")
|
||||||
elif event_type == "input.speech_stopped":
|
elif event_type == "input.speech_stopped":
|
||||||
self.log_event("←", f"Silence detected{ids}")
|
self.log_event("<-", f"Silence detected{ids}")
|
||||||
elif event_type == "transcript.delta":
|
elif event_type == "transcript.delta":
|
||||||
text = event.get("text", "")
|
text = event.get("text", "")
|
||||||
display_text = text[:60] + "..." if len(text) > 60 else text
|
display_text = text[:60] + "..." if len(text) > 60 else text
|
||||||
@@ -346,95 +389,100 @@ class WavFileClient:
|
|||||||
elif event_type == "transcript.final":
|
elif event_type == "transcript.final":
|
||||||
text = event.get("text", "")
|
text = event.get("text", "")
|
||||||
print(" " * 80, end="\r")
|
print(" " * 80, end="\r")
|
||||||
self.log_event("←", f"→ You: {text}{ids}")
|
self.log_event("<-", f"You: {text}{ids}")
|
||||||
elif event_type == "metrics.ttfb":
|
elif event_type == "metrics.ttfb":
|
||||||
latency_ms = event.get("latencyMs", 0)
|
latency_ms = event.get("latencyMs", 0)
|
||||||
self.log_event("←", f"[TTFB] Server latency: {latency_ms}ms")
|
self.log_event("<-", f"[TTFB] Server latency: {latency_ms}ms")
|
||||||
elif event_type == "assistant.response.delta":
|
elif event_type == "assistant.response.delta":
|
||||||
text = event.get("text", "")
|
text = event.get("text", "")
|
||||||
if self.verbose and text:
|
if self.verbose and text:
|
||||||
self.log_event("←", f"LLM: {text}{ids}")
|
self.log_event("<-", f"LLM: {text}{ids}")
|
||||||
elif event_type == "assistant.response.final":
|
elif event_type == "assistant.response.final":
|
||||||
text = event.get("text", "")
|
text = event.get("text", "")
|
||||||
if text:
|
if text:
|
||||||
self.log_event("←", f"LLM Response (final): {text[:100]}{'...' if len(text) > 100 else ''}{ids}")
|
summary = text[:100] + ("..." if len(text) > 100 else "")
|
||||||
|
self.log_event("<-", f"LLM Response (final): {summary}{ids}")
|
||||||
elif event_type == "output.audio.start":
|
elif event_type == "output.audio.start":
|
||||||
self.track_started = True
|
self.track_started = True
|
||||||
self.response_start_time = time.time()
|
self.response_start_time = time.time()
|
||||||
self.waiting_for_first_audio = True
|
self.waiting_for_first_audio = True
|
||||||
self.log_event("←", f"Bot started speaking{ids}")
|
self._close_output_segment()
|
||||||
|
self.log_event("<-", f"Bot started speaking{ids}")
|
||||||
elif event_type == "output.audio.end":
|
elif event_type == "output.audio.end":
|
||||||
self.track_ended = True
|
self.track_ended = True
|
||||||
self.log_event("←", f"Bot finished speaking{ids}")
|
self._close_output_segment()
|
||||||
|
self.log_event("<-", f"Bot finished speaking{ids}")
|
||||||
elif event_type == "response.interrupted":
|
elif event_type == "response.interrupted":
|
||||||
self.log_event("←", f"Bot interrupted!{ids}")
|
self._close_output_segment()
|
||||||
|
self.log_event("<-", f"Bot interrupted!{ids}")
|
||||||
elif event_type == "error":
|
elif event_type == "error":
|
||||||
self.log_event("!", f"Error: {event.get('message')}{ids}")
|
self.log_event("!", f"Error: {event.get('message')}{ids}")
|
||||||
elif event_type == "session.stopped":
|
elif event_type == "session.stopped":
|
||||||
self.log_event("←", f"Session stopped: {event.get('reason')}{ids}")
|
self.log_event("<-", f"Session stopped: {event.get('reason')}{ids}")
|
||||||
self.running = False
|
self.running = False
|
||||||
else:
|
else:
|
||||||
self.log_event("←", f"Event: {event_type}{ids}")
|
self.log_event("<-", f"Event: {event_type}{ids}")
|
||||||
|
|
||||||
def save_output_wav(self) -> None:
|
def save_output_wav(self) -> None:
|
||||||
"""Save received audio to output WAV file."""
|
"""Save the conversation to a stereo WAV file."""
|
||||||
if not self.received_audio:
|
input_track = self._build_input_track()
|
||||||
self.log_event("!", "No audio received to save")
|
output_track = self._build_output_track()
|
||||||
|
|
||||||
|
if input_track.size == 0 and output_track.size == 0:
|
||||||
|
self.log_event("!", "No audio available to save")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Convert bytes to numpy array
|
if not self.received_audio:
|
||||||
audio_data = np.frombuffer(bytes(self.received_audio), dtype=np.int16)
|
self.log_event("!", "No assistant audio received; saving silent right channel")
|
||||||
|
|
||||||
|
total_samples = max(input_track.size, output_track.size)
|
||||||
|
if input_track.size < total_samples:
|
||||||
|
input_track = np.pad(input_track, (0, total_samples - input_track.size))
|
||||||
|
if output_track.size < total_samples:
|
||||||
|
output_track = np.pad(output_track, (0, total_samples - output_track.size))
|
||||||
|
|
||||||
|
stereo_audio = np.column_stack((input_track, output_track)).astype(np.int16, copy=False)
|
||||||
|
|
||||||
# Ensure output directory exists
|
|
||||||
self.output_file.parent.mkdir(parents=True, exist_ok=True)
|
self.output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Save using wave module for compatibility
|
with wave.open(str(self.output_file), "wb") as wav_file:
|
||||||
with wave.open(str(self.output_file), 'wb') as wav_file:
|
wav_file.setnchannels(2)
|
||||||
wav_file.setnchannels(1)
|
|
||||||
wav_file.setsampwidth(2) # 16-bit
|
wav_file.setsampwidth(2) # 16-bit
|
||||||
wav_file.setframerate(self.sample_rate)
|
wav_file.setframerate(self.sample_rate)
|
||||||
wav_file.writeframes(audio_data.tobytes())
|
wav_file.writeframes(stereo_audio.tobytes())
|
||||||
|
|
||||||
duration = len(audio_data) / self.sample_rate
|
duration = total_samples / self.sample_rate
|
||||||
self.log_event("→", f"Saved output: {self.output_file}")
|
self.log_event("->", f"Saved stereo output: {self.output_file}")
|
||||||
self.log_event("→", f" Duration: {duration:.2f}s ({len(audio_data)} samples)")
|
self.log_event("->", f" Duration: {duration:.2f}s ({total_samples} samples/channel)")
|
||||||
self.log_event("→", f" Size: {len(self.received_audio)/1024:.1f} KB")
|
self.log_event("->", " Channels: left=input, right=assistant")
|
||||||
|
self.log_event("->", f" Size: {stereo_audio.nbytes / 1024:.1f} KB")
|
||||||
|
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
"""Run the WAV file test."""
|
"""Run the WAV file test."""
|
||||||
try:
|
try:
|
||||||
# Load input WAV file
|
|
||||||
audio_data, _ = self.load_wav_file()
|
audio_data, _ = self.load_wav_file()
|
||||||
|
|
||||||
# Connect to server
|
|
||||||
await self.connect()
|
await self.connect()
|
||||||
|
|
||||||
# Start receiver task
|
|
||||||
receiver_task = asyncio.create_task(self.receiver())
|
receiver_task = asyncio.create_task(self.receiver())
|
||||||
|
|
||||||
# Wait for session.started before streaming audio
|
|
||||||
ready_start = time.time()
|
ready_start = time.time()
|
||||||
while self.running and not self.session_ready:
|
while self.running and not self.session_ready:
|
||||||
if time.time() - ready_start > 8.0:
|
if time.time() - ready_start > 8.0:
|
||||||
raise TimeoutError("Timeout waiting for session.started")
|
raise TimeoutError("Timeout waiting for session.started")
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
# Send audio
|
|
||||||
await self.audio_sender(audio_data)
|
await self.audio_sender(audio_data)
|
||||||
|
|
||||||
# Wait for response
|
self.log_event("->", f"Waiting {self.wait_time}s for response...")
|
||||||
self.log_event("→", f"Waiting {self.wait_time}s for response...")
|
|
||||||
|
|
||||||
wait_start = time.time()
|
wait_start = time.time()
|
||||||
while self.running and (time.time() - wait_start) < self.wait_time:
|
while self.running and (time.time() - wait_start) < self.wait_time:
|
||||||
# Check if track has ended (response complete)
|
|
||||||
if self.track_ended and self.send_completed:
|
if self.track_ended and self.send_completed:
|
||||||
# Give a little extra time for any remaining audio
|
|
||||||
await asyncio.sleep(1.0)
|
await asyncio.sleep(1.0)
|
||||||
break
|
break
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
self.running = False
|
self.running = False
|
||||||
receiver_task.cancel()
|
receiver_task.cancel()
|
||||||
|
|
||||||
@@ -443,28 +491,26 @@ class WavFileClient:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Save output
|
|
||||||
self.save_output_wav()
|
self.save_output_wav()
|
||||||
|
|
||||||
# Print summary
|
|
||||||
self._print_summary()
|
self._print_summary()
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as exc:
|
||||||
print(f"Error: {e}")
|
print(f"Error: {exc}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
except ConnectionRefusedError:
|
except ConnectionRefusedError:
|
||||||
print(f"Error: Could not connect to {self.url}")
|
print(f"Error: Could not connect to {self.url}")
|
||||||
print("Make sure the server is running.")
|
print("Make sure the server is running.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
print(f"Error: {e}")
|
print(f"Error: {exc}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
finally:
|
finally:
|
||||||
await self.close()
|
await self.close()
|
||||||
|
|
||||||
def _print_summary(self):
|
def _print_summary(self) -> None:
|
||||||
"""Print session summary."""
|
"""Print session summary."""
|
||||||
print("\n" + "=" * 50)
|
print("\n" + "=" * 50)
|
||||||
print("Session Summary")
|
print("Session Summary")
|
||||||
@@ -477,7 +523,8 @@ class WavFileClient:
|
|||||||
if len(self.ttfb_list) == 1:
|
if len(self.ttfb_list) == 1:
|
||||||
print(f" TTFB: {self.ttfb_list[0]:.0f} ms")
|
print(f" TTFB: {self.ttfb_list[0]:.0f} ms")
|
||||||
else:
|
else:
|
||||||
print(f" TTFB (per response): {', '.join(f'{t:.0f}ms' for t in self.ttfb_list)}")
|
values = ", ".join(f"{ttfb:.0f}ms" for ttfb in self.ttfb_list)
|
||||||
|
print(f" TTFB (per response): {values}")
|
||||||
if self.received_audio:
|
if self.received_audio:
|
||||||
duration = len(self.received_audio) / (self.sample_rate * 2)
|
duration = len(self.received_audio) / (self.sample_rate * 2)
|
||||||
print(f" Response duration: {duration:.2f}s")
|
print(f" Response duration: {duration:.2f}s")
|
||||||
@@ -489,7 +536,7 @@ class WavFileClient:
|
|||||||
if self.ws:
|
if self.ws:
|
||||||
try:
|
try:
|
||||||
await self.ws.close()
|
await self.ws.close()
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -498,63 +545,67 @@ async def main():
|
|||||||
description="WAV file client for testing duplex voice conversation"
|
description="WAV file client for testing duplex voice conversation"
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--input", "-i",
|
"--input",
|
||||||
|
"-i",
|
||||||
required=True,
|
required=True,
|
||||||
help="Input WAV file path"
|
help="Input WAV file path",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--output", "-o",
|
"--output",
|
||||||
|
"-o",
|
||||||
required=True,
|
required=True,
|
||||||
help="Output WAV file path for response"
|
help="Output WAV file path for stereo conversation audio",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--url",
|
"--url",
|
||||||
default="ws://localhost:8000/ws",
|
default="ws://localhost:8000/ws",
|
||||||
help="WebSocket server URL (default: ws://localhost:8000/ws)"
|
help="WebSocket server URL (default: ws://localhost:8000/ws)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--sample-rate",
|
"--sample-rate",
|
||||||
type=int,
|
type=int,
|
||||||
default=16000,
|
default=16000,
|
||||||
help="Target sample rate for audio (default: 16000)"
|
help="Target sample rate for audio (default: 16000)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--assistant-id",
|
"--assistant-id",
|
||||||
default="default",
|
default="default",
|
||||||
help="Assistant identifier used in websocket query parameter"
|
help="Assistant identifier used in websocket query parameter",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--channel",
|
"--channel",
|
||||||
default="wav_client",
|
default="wav_client",
|
||||||
help="Client channel name"
|
help="Client channel name",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--chunk-duration",
|
"--chunk-duration",
|
||||||
type=int,
|
type=int,
|
||||||
default=20,
|
default=20,
|
||||||
help="Chunk duration in ms for sending (default: 20)"
|
help="Chunk duration in ms for sending (default: 20)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--wait-time", "-w",
|
"--wait-time",
|
||||||
|
"-w",
|
||||||
type=float,
|
type=float,
|
||||||
default=15.0,
|
default=15.0,
|
||||||
help="Time to wait for response after sending (default: 15.0)"
|
help="Time to wait for response after sending (default: 15.0)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--verbose", "-v",
|
"--verbose",
|
||||||
|
"-v",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Enable verbose output"
|
help="Enable verbose output",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--track-debug",
|
"--track-debug",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Print event trackId for protocol debugging"
|
help="Print event trackId for protocol debugging",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--tail-silence-ms",
|
"--tail-silence-ms",
|
||||||
type=int,
|
type=int,
|
||||||
default=800,
|
default=800,
|
||||||
help="Trailing silence to send after WAV playback for EOU detection (default: 800)"
|
help="Trailing silence to send after WAV playback for EOU detection (default: 800)",
|
||||||
)
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from providers.tts.volcengine import VolcengineTTSService
|
|||||||
_OPENAI_COMPATIBLE_PROVIDERS = {"openai_compatible", "openai-compatible", "siliconflow"}
|
_OPENAI_COMPATIBLE_PROVIDERS = {"openai_compatible", "openai-compatible", "siliconflow"}
|
||||||
_DASHSCOPE_PROVIDERS = {"dashscope"}
|
_DASHSCOPE_PROVIDERS = {"dashscope"}
|
||||||
_VOLCENGINE_PROVIDERS = {"volcengine"}
|
_VOLCENGINE_PROVIDERS = {"volcengine"}
|
||||||
_SUPPORTED_LLM_PROVIDERS = {"openai", *_OPENAI_COMPATIBLE_PROVIDERS}
|
_SUPPORTED_LLM_PROVIDERS = {"openai", "fastgpt", *_OPENAI_COMPATIBLE_PROVIDERS}
|
||||||
|
|
||||||
|
|
||||||
class DefaultRealtimeServiceFactory(RealtimeServiceFactory):
|
class DefaultRealtimeServiceFactory(RealtimeServiceFactory):
|
||||||
@@ -58,7 +58,18 @@ class DefaultRealtimeServiceFactory(RealtimeServiceFactory):
|
|||||||
|
|
||||||
def create_llm_service(self, spec: LLMServiceSpec) -> LLMPort:
|
def create_llm_service(self, spec: LLMServiceSpec) -> LLMPort:
|
||||||
provider = self._normalize_provider(spec.provider)
|
provider = self._normalize_provider(spec.provider)
|
||||||
if provider in _SUPPORTED_LLM_PROVIDERS and spec.api_key:
|
if provider == "fastgpt" and spec.api_key and spec.base_url:
|
||||||
|
from providers.llm.fastgpt import FastGPTLLMService
|
||||||
|
|
||||||
|
return FastGPTLLMService(
|
||||||
|
api_key=spec.api_key,
|
||||||
|
base_url=spec.base_url,
|
||||||
|
app_id=spec.app_id,
|
||||||
|
model=spec.model,
|
||||||
|
system_prompt=spec.system_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if provider in _SUPPORTED_LLM_PROVIDERS and provider != "fastgpt" and spec.api_key:
|
||||||
return OpenAILLMService(
|
return OpenAILLMService(
|
||||||
api_key=spec.api_key,
|
api_key=spec.api_key,
|
||||||
base_url=spec.base_url,
|
base_url=spec.base_url,
|
||||||
|
|||||||
@@ -1 +1,14 @@
|
|||||||
"""LLM providers."""
|
"""LLM providers."""
|
||||||
|
|
||||||
|
from providers.llm.openai import MockLLMService, OpenAILLMService
|
||||||
|
|
||||||
|
try: # pragma: no cover - import depends on optional sibling SDK
|
||||||
|
from providers.llm.fastgpt import FastGPTLLMService
|
||||||
|
except Exception: # pragma: no cover - provider remains lazily available via factory
|
||||||
|
FastGPTLLMService = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FastGPTLLMService",
|
||||||
|
"MockLLMService",
|
||||||
|
"OpenAILLMService",
|
||||||
|
]
|
||||||
|
|||||||
553
engine/providers/llm/fastgpt.py
Normal file
553
engine/providers/llm/fastgpt.py
Normal file
@@ -0,0 +1,553 @@
|
|||||||
|
"""FastGPT-backed LLM provider."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Any, AsyncIterator, Dict, List, Optional
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from providers.common.base import BaseLLMService, LLMMessage, LLMStreamEvent, ServiceState
|
||||||
|
from providers.llm.fastgpt_types import (
|
||||||
|
FastGPTConversationState,
|
||||||
|
FastGPTField,
|
||||||
|
FastGPTInteractivePrompt,
|
||||||
|
FastGPTOption,
|
||||||
|
FastGPTPendingInteraction,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fastgpt_client import AsyncChatClient, aiter_stream_events
|
||||||
|
except Exception as exc: # pragma: no cover - exercised indirectly via connect()
|
||||||
|
AsyncChatClient = None # type: ignore[assignment]
|
||||||
|
aiter_stream_events = None # type: ignore[assignment]
|
||||||
|
_FASTGPT_IMPORT_ERROR: Optional[Exception] = exc
|
||||||
|
else: # pragma: no cover - import success depends on local environment
|
||||||
|
_FASTGPT_IMPORT_ERROR = None
|
||||||
|
|
||||||
|
|
||||||
|
class FastGPTLLMService(BaseLLMService):
|
||||||
|
"""LLM provider that delegates orchestration to FastGPT."""
|
||||||
|
|
||||||
|
INTERACTIVE_TOOL_NAME = "fastgpt.interactive"
|
||||||
|
INTERACTIVE_TIMEOUT_MS = 300000
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
api_key: str,
|
||||||
|
base_url: str,
|
||||||
|
app_id: Optional[str] = None,
|
||||||
|
model: str = "fastgpt",
|
||||||
|
system_prompt: Optional[str] = None,
|
||||||
|
):
|
||||||
|
super().__init__(model=model or "fastgpt")
|
||||||
|
self.api_key = api_key
|
||||||
|
self.base_url = str(base_url or "").rstrip("/")
|
||||||
|
self.app_id = str(app_id or "").strip()
|
||||||
|
self.system_prompt = system_prompt or ""
|
||||||
|
self.client: Any = None
|
||||||
|
self._cancel_event = asyncio.Event()
|
||||||
|
self._state = FastGPTConversationState()
|
||||||
|
self._knowledge_config: Dict[str, Any] = {}
|
||||||
|
self._tool_schemas: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
if AsyncChatClient is None or aiter_stream_events is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"fastgpt_client package is not available. "
|
||||||
|
"Install the sibling fastgpt-python-sdk package first."
|
||||||
|
) from _FASTGPT_IMPORT_ERROR
|
||||||
|
if not self.api_key:
|
||||||
|
raise ValueError("FastGPT API key not provided")
|
||||||
|
if not self.base_url:
|
||||||
|
raise ValueError("FastGPT base URL not provided")
|
||||||
|
self.client = AsyncChatClient(api_key=self.api_key, base_url=self.base_url)
|
||||||
|
self.state = ServiceState.CONNECTED
|
||||||
|
logger.info("FastGPT LLM service connected: base_url={}", self.base_url)
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
if self.client and hasattr(self.client, "close"):
|
||||||
|
await self.client.close()
|
||||||
|
self.client = None
|
||||||
|
self._state.pending_interaction = None
|
||||||
|
self.state = ServiceState.DISCONNECTED
|
||||||
|
logger.info("FastGPT LLM service disconnected")
|
||||||
|
|
||||||
|
def cancel(self) -> None:
|
||||||
|
self._cancel_event.set()
|
||||||
|
self._state.pending_interaction = None
|
||||||
|
|
||||||
|
def set_knowledge_config(self, config: Optional[Dict[str, Any]]) -> None:
|
||||||
|
# FastGPT owns KB orchestration in this provider mode.
|
||||||
|
self._knowledge_config = dict(config or {})
|
||||||
|
|
||||||
|
def set_tool_schemas(self, schemas: Optional[List[Dict[str, Any]]]) -> None:
|
||||||
|
# FastGPT owns workflow and tool orchestration in this provider mode.
|
||||||
|
self._tool_schemas = list(schemas or [])
|
||||||
|
|
||||||
|
def handles_client_tool(self, tool_name: str) -> bool:
|
||||||
|
return str(tool_name or "").strip() == self.INTERACTIVE_TOOL_NAME
|
||||||
|
|
||||||
|
async def get_initial_greeting(self) -> Optional[str]:
|
||||||
|
if not self.client or not self.app_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
response = await self.client.get_chat_init(
|
||||||
|
appId=self.app_id,
|
||||||
|
chatId=self._ensure_chat_id(),
|
||||||
|
)
|
||||||
|
raise_for_status = getattr(response, "raise_for_status", None)
|
||||||
|
if callable(raise_for_status):
|
||||||
|
raise_for_status()
|
||||||
|
elif int(getattr(response, "status_code", 200) or 200) >= 400:
|
||||||
|
raise RuntimeError(f"FastGPT chat init failed: HTTP {getattr(response, 'status_code', 'unknown')}")
|
||||||
|
|
||||||
|
payload = response.json() if hasattr(response, "json") else {}
|
||||||
|
return self._extract_initial_greeting(payload)
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
messages: List[LLMMessage],
|
||||||
|
temperature: float = 0.7,
|
||||||
|
max_tokens: Optional[int] = None,
|
||||||
|
) -> str:
|
||||||
|
parts: List[str] = []
|
||||||
|
async for event in self.generate_stream(messages, temperature=temperature, max_tokens=max_tokens):
|
||||||
|
if event.type == "text_delta" and event.text:
|
||||||
|
parts.append(event.text)
|
||||||
|
if event.type == "tool_call":
|
||||||
|
break
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
async def generate_stream(
|
||||||
|
self,
|
||||||
|
messages: List[LLMMessage],
|
||||||
|
temperature: float = 0.7,
|
||||||
|
max_tokens: Optional[int] = None,
|
||||||
|
) -> AsyncIterator[LLMStreamEvent]:
|
||||||
|
del temperature, max_tokens
|
||||||
|
if not self.client:
|
||||||
|
raise RuntimeError("LLM service not connected")
|
||||||
|
|
||||||
|
self._cancel_event.clear()
|
||||||
|
request_messages = self._build_request_messages(messages)
|
||||||
|
response = await self.client.create_chat_completion(
|
||||||
|
messages=request_messages,
|
||||||
|
chatId=self._ensure_chat_id(),
|
||||||
|
detail=True,
|
||||||
|
stream=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async for event in aiter_stream_events(response):
|
||||||
|
if self._cancel_event.is_set():
|
||||||
|
logger.info("FastGPT stream cancelled")
|
||||||
|
break
|
||||||
|
|
||||||
|
stop_after_event = False
|
||||||
|
for mapped in self._map_stream_event(event):
|
||||||
|
if mapped.type == "tool_call":
|
||||||
|
stop_after_event = True
|
||||||
|
yield mapped
|
||||||
|
if stop_after_event:
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
await self._close_stream_response(response)
|
||||||
|
|
||||||
|
async def resume_after_client_tool_result(
|
||||||
|
self,
|
||||||
|
tool_call_id: str,
|
||||||
|
result: Dict[str, Any],
|
||||||
|
) -> AsyncIterator[LLMStreamEvent]:
|
||||||
|
if not self.client:
|
||||||
|
raise RuntimeError("LLM service not connected")
|
||||||
|
|
||||||
|
pending = self._require_pending_interaction(tool_call_id)
|
||||||
|
follow_up_text = self._build_resume_text(pending, result)
|
||||||
|
self._state.pending_interaction = None
|
||||||
|
|
||||||
|
if not follow_up_text:
|
||||||
|
yield LLMStreamEvent(type="done")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._cancel_event.clear()
|
||||||
|
response = await self.client.create_chat_completion(
|
||||||
|
messages=[{"role": "user", "content": follow_up_text}],
|
||||||
|
chatId=pending.chat_id,
|
||||||
|
detail=True,
|
||||||
|
stream=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async for event in aiter_stream_events(response):
|
||||||
|
if self._cancel_event.is_set():
|
||||||
|
logger.info("FastGPT resume stream cancelled")
|
||||||
|
break
|
||||||
|
|
||||||
|
stop_after_event = False
|
||||||
|
for mapped in self._map_stream_event(event):
|
||||||
|
if mapped.type == "tool_call":
|
||||||
|
stop_after_event = True
|
||||||
|
yield mapped
|
||||||
|
if stop_after_event:
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
await self._close_stream_response(response)
|
||||||
|
|
||||||
|
async def _close_stream_response(self, response: Any) -> None:
|
||||||
|
if response is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# httpx async streaming responses must use `aclose()`.
|
||||||
|
aclose = getattr(response, "aclose", None)
|
||||||
|
if callable(aclose):
|
||||||
|
await aclose()
|
||||||
|
return
|
||||||
|
|
||||||
|
close = getattr(response, "close", None)
|
||||||
|
if callable(close):
|
||||||
|
maybe_awaitable = close()
|
||||||
|
if hasattr(maybe_awaitable, "__await__"):
|
||||||
|
await maybe_awaitable
|
||||||
|
|
||||||
|
def _ensure_chat_id(self) -> str:
|
||||||
|
chat_id = str(self._state.chat_id or "").strip()
|
||||||
|
if not chat_id:
|
||||||
|
chat_id = f"fastgpt_{uuid.uuid4().hex}"
|
||||||
|
self._state.chat_id = chat_id
|
||||||
|
return chat_id
|
||||||
|
|
||||||
|
def _build_request_messages(self, messages: List[LLMMessage]) -> List[Dict[str, Any]]:
|
||||||
|
non_empty = [msg for msg in messages if str(msg.content or "").strip()]
|
||||||
|
if not non_empty:
|
||||||
|
return [{"role": "user", "content": ""}]
|
||||||
|
|
||||||
|
latest_user = next((msg for msg in reversed(non_empty) if msg.role == "user"), None)
|
||||||
|
trailing_system = non_empty[-1] if non_empty and non_empty[-1].role == "system" else None
|
||||||
|
|
||||||
|
request: List[Dict[str, Any]] = []
|
||||||
|
if trailing_system and trailing_system is not latest_user:
|
||||||
|
request.append({"role": "system", "content": trailing_system.content.strip()})
|
||||||
|
if latest_user and str(latest_user.content or "").strip():
|
||||||
|
request.append({"role": "user", "content": latest_user.content.strip()})
|
||||||
|
return request
|
||||||
|
|
||||||
|
last_message = non_empty[-1]
|
||||||
|
payload = last_message.to_dict()
|
||||||
|
payload["content"] = str(payload.get("content") or "").strip()
|
||||||
|
return [payload]
|
||||||
|
|
||||||
|
def _extract_initial_greeting(self, payload: Any) -> Optional[str]:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidates: List[Any] = [
|
||||||
|
payload.get("app"),
|
||||||
|
payload.get("data"),
|
||||||
|
]
|
||||||
|
for container in candidates:
|
||||||
|
if not isinstance(container, dict):
|
||||||
|
continue
|
||||||
|
nested_app = container.get("app") if isinstance(container.get("app"), dict) else None
|
||||||
|
if nested_app:
|
||||||
|
text = self._welcome_text_from_app(nested_app)
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
text = self._welcome_text_from_app(container)
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _welcome_text_from_app(app_payload: Dict[str, Any]) -> Optional[str]:
|
||||||
|
chat_config = app_payload.get("chatConfig") if isinstance(app_payload.get("chatConfig"), dict) else {}
|
||||||
|
text = str(
|
||||||
|
chat_config.get("welcomeText")
|
||||||
|
or app_payload.get("welcomeText")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
def _map_stream_event(self, event: Any) -> List[LLMStreamEvent]:
|
||||||
|
kind = str(getattr(event, "kind", "") or "")
|
||||||
|
data = getattr(event, "data", {})
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
if kind in {"data", "answer", "fastAnswer"}:
|
||||||
|
chunks = self._extract_text_chunks(kind, data)
|
||||||
|
return [LLMStreamEvent(type="text_delta", text=chunk) for chunk in chunks if chunk]
|
||||||
|
|
||||||
|
if kind == "interactive":
|
||||||
|
return [self._build_interactive_tool_event(data)]
|
||||||
|
|
||||||
|
if kind == "error":
|
||||||
|
message = str(data.get("message") or data.get("error") or "FastGPT streaming error")
|
||||||
|
raise RuntimeError(message)
|
||||||
|
|
||||||
|
if kind == "done":
|
||||||
|
return [LLMStreamEvent(type="done")]
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_interactive_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
normalized = payload
|
||||||
|
wrapped = normalized.get("interactive")
|
||||||
|
if isinstance(wrapped, dict):
|
||||||
|
normalized = wrapped
|
||||||
|
|
||||||
|
interaction_type = str(normalized.get("type") or "").strip()
|
||||||
|
if interaction_type == "toolChildrenInteractive":
|
||||||
|
params = normalized.get("params") if isinstance(normalized.get("params"), dict) else {}
|
||||||
|
children_response = params.get("childrenResponse")
|
||||||
|
if isinstance(children_response, dict):
|
||||||
|
normalized = children_response
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _extract_text_chunks(self, kind: str, data: Dict[str, Any]) -> List[str]:
|
||||||
|
if kind in {"answer", "fastAnswer"}:
|
||||||
|
text = str(data.get("text") or "")
|
||||||
|
if text:
|
||||||
|
return [text]
|
||||||
|
|
||||||
|
choices = data.get("choices") if isinstance(data.get("choices"), list) else []
|
||||||
|
if not choices:
|
||||||
|
text = str(data.get("text") or "")
|
||||||
|
return [text] if text else []
|
||||||
|
|
||||||
|
first = choices[0] if isinstance(choices[0], dict) else {}
|
||||||
|
delta = first.get("delta") if isinstance(first.get("delta"), dict) else {}
|
||||||
|
if isinstance(delta.get("content"), str) and delta.get("content"):
|
||||||
|
return [str(delta.get("content"))]
|
||||||
|
message = first.get("message") if isinstance(first.get("message"), dict) else {}
|
||||||
|
if isinstance(message.get("content"), str) and message.get("content"):
|
||||||
|
return [str(message.get("content"))]
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _build_interactive_tool_event(self, payload: Dict[str, Any]) -> LLMStreamEvent:
|
||||||
|
normalized_payload = self._normalize_interactive_payload(payload)
|
||||||
|
prompt = self._parse_interactive_prompt(normalized_payload)
|
||||||
|
call_id = f"fgi_{uuid.uuid4().hex[:12]}"
|
||||||
|
pending = FastGPTPendingInteraction(
|
||||||
|
tool_call_id=call_id,
|
||||||
|
chat_id=self._ensure_chat_id(),
|
||||||
|
prompt=prompt,
|
||||||
|
timeout_ms=self.INTERACTIVE_TIMEOUT_MS,
|
||||||
|
fastgpt_event=dict(normalized_payload),
|
||||||
|
)
|
||||||
|
self._state.pending_interaction = pending
|
||||||
|
arguments = prompt.to_ws_arguments(chat_id=pending.chat_id)
|
||||||
|
tool_call = {
|
||||||
|
"id": call_id,
|
||||||
|
"type": "function",
|
||||||
|
"executor": "client",
|
||||||
|
"wait_for_response": True,
|
||||||
|
"timeout_ms": pending.timeout_ms,
|
||||||
|
"display_name": prompt.title or prompt.description or prompt.prompt or "FastGPT Interactive",
|
||||||
|
"function": {
|
||||||
|
"name": self.INTERACTIVE_TOOL_NAME,
|
||||||
|
"arguments": json.dumps(arguments, ensure_ascii=False),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return LLMStreamEvent(type="tool_call", tool_call=tool_call)
|
||||||
|
|
||||||
|
def _parse_interactive_prompt(self, payload: Dict[str, Any]) -> FastGPTInteractivePrompt:
|
||||||
|
params = payload.get("params") if isinstance(payload.get("params"), dict) else {}
|
||||||
|
kind = str(payload.get("type") or "userSelect").strip() or "userSelect"
|
||||||
|
title = str(
|
||||||
|
payload.get("title")
|
||||||
|
or params.get("title")
|
||||||
|
or payload.get("nodeName")
|
||||||
|
or payload.get("label")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
description = str(
|
||||||
|
payload.get("description")
|
||||||
|
or payload.get("desc")
|
||||||
|
or params.get("description")
|
||||||
|
or params.get("desc")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
prompt_text = str(
|
||||||
|
payload.get("opener")
|
||||||
|
or params.get("opener")
|
||||||
|
or payload.get("intro")
|
||||||
|
or params.get("intro")
|
||||||
|
or payload.get("prompt")
|
||||||
|
or params.get("prompt")
|
||||||
|
or payload.get("text")
|
||||||
|
or params.get("text")
|
||||||
|
or title
|
||||||
|
or description
|
||||||
|
).strip()
|
||||||
|
required = self._coerce_bool(payload.get("required"), default=True)
|
||||||
|
multiple = self._coerce_bool(params.get("multiple") or payload.get("multiple"), default=False)
|
||||||
|
submit_label = str(params.get("submitText") or payload.get("submitText") or "Continue").strip() or "Continue"
|
||||||
|
cancel_label = str(params.get("cancelText") or payload.get("cancelText") or "Cancel").strip() or "Cancel"
|
||||||
|
|
||||||
|
options: List[FastGPTOption] = []
|
||||||
|
raw_options = params.get("userSelectOptions") if isinstance(params.get("userSelectOptions"), list) else []
|
||||||
|
for index, raw_option in enumerate(raw_options):
|
||||||
|
if isinstance(raw_option, str):
|
||||||
|
value = raw_option.strip()
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
options.append(FastGPTOption(id=f"option_{index}", label=value, value=value))
|
||||||
|
continue
|
||||||
|
if not isinstance(raw_option, dict):
|
||||||
|
continue
|
||||||
|
label = str(raw_option.get("label") or raw_option.get("value") or raw_option.get("id") or "").strip()
|
||||||
|
value = str(raw_option.get("value") or raw_option.get("label") or raw_option.get("id") or "").strip()
|
||||||
|
option_id = str(raw_option.get("id") or value or f"option_{index}").strip()
|
||||||
|
if not label and not value:
|
||||||
|
continue
|
||||||
|
options.append(
|
||||||
|
FastGPTOption(
|
||||||
|
id=option_id or f"option_{index}",
|
||||||
|
label=label or value,
|
||||||
|
value=value or label,
|
||||||
|
description=str(
|
||||||
|
raw_option.get("description")
|
||||||
|
or raw_option.get("desc")
|
||||||
|
or raw_option.get("intro")
|
||||||
|
or raw_option.get("summary")
|
||||||
|
or ""
|
||||||
|
).strip(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
form: List[FastGPTField] = []
|
||||||
|
raw_form = params.get("inputForm") if isinstance(params.get("inputForm"), list) else []
|
||||||
|
for index, raw_field in enumerate(raw_form):
|
||||||
|
if not isinstance(raw_field, dict):
|
||||||
|
continue
|
||||||
|
field_options: List[FastGPTOption] = []
|
||||||
|
nested_options = raw_field.get("options") if isinstance(raw_field.get("options"), list) else []
|
||||||
|
for opt_index, option in enumerate(nested_options):
|
||||||
|
if isinstance(option, str):
|
||||||
|
value = option.strip()
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
field_options.append(FastGPTOption(id=f"field_{index}_opt_{opt_index}", label=value, value=value))
|
||||||
|
continue
|
||||||
|
if not isinstance(option, dict):
|
||||||
|
continue
|
||||||
|
label = str(option.get("label") or option.get("value") or option.get("id") or "").strip()
|
||||||
|
value = str(option.get("value") or option.get("label") or option.get("id") or "").strip()
|
||||||
|
option_id = str(option.get("id") or value or f"field_{index}_opt_{opt_index}").strip()
|
||||||
|
if not label and not value:
|
||||||
|
continue
|
||||||
|
field_options.append(
|
||||||
|
FastGPTOption(
|
||||||
|
id=option_id or f"field_{index}_opt_{opt_index}",
|
||||||
|
label=label or value,
|
||||||
|
value=value or label,
|
||||||
|
description=str(
|
||||||
|
option.get("description")
|
||||||
|
or option.get("desc")
|
||||||
|
or option.get("intro")
|
||||||
|
or option.get("summary")
|
||||||
|
or ""
|
||||||
|
).strip(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
name = str(raw_field.get("key") or raw_field.get("name") or raw_field.get("label") or f"field_{index}").strip()
|
||||||
|
label = str(raw_field.get("label") or raw_field.get("name") or name).strip()
|
||||||
|
form.append(
|
||||||
|
FastGPTField(
|
||||||
|
name=name or f"field_{index}",
|
||||||
|
label=label or name or f"field_{index}",
|
||||||
|
input_type=str(raw_field.get("type") or raw_field.get("inputType") or "text").strip() or "text",
|
||||||
|
required=self._coerce_bool(raw_field.get("required"), default=False),
|
||||||
|
placeholder=str(
|
||||||
|
raw_field.get("placeholder")
|
||||||
|
or raw_field.get("description")
|
||||||
|
or raw_field.get("desc")
|
||||||
|
or ""
|
||||||
|
).strip(),
|
||||||
|
default=raw_field.get("defaultValue", raw_field.get("default")),
|
||||||
|
options=field_options,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return FastGPTInteractivePrompt(
|
||||||
|
kind="userInput" if kind == "userInput" else "userSelect",
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
prompt=prompt_text,
|
||||||
|
required=required,
|
||||||
|
multiple=multiple,
|
||||||
|
submit_label=submit_label,
|
||||||
|
cancel_label=cancel_label,
|
||||||
|
options=options,
|
||||||
|
form=form,
|
||||||
|
raw=dict(payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _require_pending_interaction(self, tool_call_id: str) -> FastGPTPendingInteraction:
|
||||||
|
pending = self._state.pending_interaction
|
||||||
|
if pending is None or pending.tool_call_id != tool_call_id:
|
||||||
|
raise ValueError(f"FastGPT interaction not pending for tool call: {tool_call_id}")
|
||||||
|
return pending
|
||||||
|
|
||||||
|
def _build_resume_text(self, pending: FastGPTPendingInteraction, result: Dict[str, Any]) -> str:
|
||||||
|
status = result.get("status") if isinstance(result.get("status"), dict) else {}
|
||||||
|
status_code = self._safe_int(status.get("code"), default=0)
|
||||||
|
output = result.get("output") if isinstance(result.get("output"), dict) else {}
|
||||||
|
action = str(output.get("action") or "").strip().lower()
|
||||||
|
|
||||||
|
if action == "cancel" or status_code == 499:
|
||||||
|
return ""
|
||||||
|
if status_code == 422:
|
||||||
|
raise ValueError("Invalid FastGPT interactive payload from client")
|
||||||
|
if status_code and not 200 <= status_code < 300:
|
||||||
|
raise ValueError(f"FastGPT interactive result rejected with status {status_code}")
|
||||||
|
if action and action != "submit":
|
||||||
|
raise ValueError(f"Unsupported FastGPT interactive action: {action}")
|
||||||
|
|
||||||
|
payload = output.get("result") if isinstance(output.get("result"), dict) else output
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("FastGPT interactive client result must be an object")
|
||||||
|
|
||||||
|
if pending.prompt.kind == "userSelect":
|
||||||
|
selected = str(payload.get("selected") or "").strip()
|
||||||
|
if selected:
|
||||||
|
return selected
|
||||||
|
selected_values = payload.get("selected_values") if isinstance(payload.get("selected_values"), list) else []
|
||||||
|
values = [str(item).strip() for item in selected_values if str(item).strip()]
|
||||||
|
if values:
|
||||||
|
return ", ".join(values)
|
||||||
|
text_value = str(payload.get("text") or "").strip()
|
||||||
|
return text_value
|
||||||
|
|
||||||
|
text_value = str(payload.get("text") or "").strip()
|
||||||
|
if text_value:
|
||||||
|
return text_value
|
||||||
|
fields = payload.get("fields") if isinstance(payload.get("fields"), dict) else {}
|
||||||
|
compact_fields = {str(key): value for key, value in fields.items()}
|
||||||
|
if compact_fields:
|
||||||
|
return json.dumps(compact_fields, ensure_ascii=False)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _coerce_bool(value: Any, *, default: bool) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
if normalized in {"true", "1", "yes", "on"}:
|
||||||
|
return True
|
||||||
|
if normalized in {"false", "0", "no", "off"}:
|
||||||
|
return False
|
||||||
|
return default
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_int(value: Any, *, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
95
engine/providers/llm/fastgpt_types.py
Normal file
95
engine/providers/llm/fastgpt_types.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""FastGPT-specific provider types."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Literal, Optional
|
||||||
|
|
||||||
|
InteractiveKind = Literal["userSelect", "userInput"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FastGPTOption:
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
value: str
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FastGPTField:
|
||||||
|
name: str
|
||||||
|
label: str
|
||||||
|
input_type: str = "text"
|
||||||
|
required: bool = False
|
||||||
|
placeholder: str = ""
|
||||||
|
default: Any = None
|
||||||
|
options: List[FastGPTOption] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FastGPTInteractivePrompt:
|
||||||
|
kind: InteractiveKind
|
||||||
|
title: str = ""
|
||||||
|
description: str = ""
|
||||||
|
prompt: str = ""
|
||||||
|
required: bool = True
|
||||||
|
multiple: bool = False
|
||||||
|
submit_label: str = "Continue"
|
||||||
|
cancel_label: str = "Cancel"
|
||||||
|
options: List[FastGPTOption] = field(default_factory=list)
|
||||||
|
form: List[FastGPTField] = field(default_factory=list)
|
||||||
|
raw: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_ws_arguments(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
turn_id: Optional[str] = None,
|
||||||
|
response_id: Optional[str] = None,
|
||||||
|
chat_id: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
context: Dict[str, Any] = {}
|
||||||
|
if turn_id:
|
||||||
|
context["turn_id"] = turn_id
|
||||||
|
if response_id:
|
||||||
|
context["response_id"] = response_id
|
||||||
|
if chat_id:
|
||||||
|
context["chat_id"] = chat_id
|
||||||
|
return {
|
||||||
|
"provider": "fastgpt",
|
||||||
|
"version": "fastgpt_interactive_v1",
|
||||||
|
"interaction": {
|
||||||
|
"type": self.kind,
|
||||||
|
"title": self.title,
|
||||||
|
"description": self.description,
|
||||||
|
"prompt": self.prompt,
|
||||||
|
"required": self.required,
|
||||||
|
"multiple": self.multiple,
|
||||||
|
"submit_label": self.submit_label,
|
||||||
|
"cancel_label": self.cancel_label,
|
||||||
|
"options": [vars(item) for item in self.options],
|
||||||
|
"form": [
|
||||||
|
{
|
||||||
|
**vars(item),
|
||||||
|
"options": [vars(option) for option in item.options],
|
||||||
|
}
|
||||||
|
for item in self.form
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"context": context,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FastGPTPendingInteraction:
|
||||||
|
tool_call_id: str
|
||||||
|
chat_id: str
|
||||||
|
prompt: FastGPTInteractivePrompt
|
||||||
|
timeout_ms: int
|
||||||
|
fastgpt_event: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FastGPTConversationState:
|
||||||
|
chat_id: Optional[str] = None
|
||||||
|
pending_interaction: Optional[FastGPTPendingInteraction] = None
|
||||||
@@ -33,3 +33,6 @@ dashscope>=1.25.11
|
|||||||
sounddevice>=0.4.6
|
sounddevice>=0.4.6
|
||||||
soundfile>=0.12.1
|
soundfile>=0.12.1
|
||||||
pyaudio>=0.2.13 # More reliable audio on Windows
|
pyaudio>=0.2.13 # More reliable audio on Windows
|
||||||
|
|
||||||
|
# FastGPT runtime support is installed from the sibling fastgpt-python-sdk package.
|
||||||
|
# Local dev: pip install -e ..\\fastgpt-python-sdk
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ class DuplexPipeline:
|
|||||||
_MIN_SPLIT_SPOKEN_CHARS = 6
|
_MIN_SPLIT_SPOKEN_CHARS = 6
|
||||||
_TOOL_WAIT_TIMEOUT_SECONDS = 60.0
|
_TOOL_WAIT_TIMEOUT_SECONDS = 60.0
|
||||||
_SERVER_TOOL_TIMEOUT_SECONDS = 15.0
|
_SERVER_TOOL_TIMEOUT_SECONDS = 15.0
|
||||||
|
_MAX_LLM_ROUNDS = 3
|
||||||
|
_MAX_PROVIDER_MANAGED_ROUNDS = 24
|
||||||
TRACK_AUDIO_IN = "audio_in"
|
TRACK_AUDIO_IN = "audio_in"
|
||||||
TRACK_AUDIO_OUT = "audio_out"
|
TRACK_AUDIO_OUT = "audio_out"
|
||||||
TRACK_CONTROL = "control"
|
TRACK_CONTROL = "control"
|
||||||
@@ -408,6 +410,7 @@ class DuplexPipeline:
|
|||||||
self._runtime_tool_display_names: Dict[str, str] = {}
|
self._runtime_tool_display_names: Dict[str, str] = {}
|
||||||
self._runtime_tool_wait_for_response: Dict[str, bool] = {}
|
self._runtime_tool_wait_for_response: Dict[str, bool] = {}
|
||||||
self._pending_tool_waiters: Dict[str, asyncio.Future] = {}
|
self._pending_tool_waiters: Dict[str, asyncio.Future] = {}
|
||||||
|
self._pending_tool_deadlines: Dict[str, float] = {}
|
||||||
self._early_tool_results: Dict[str, Dict[str, Any]] = {}
|
self._early_tool_results: Dict[str, Dict[str, Any]] = {}
|
||||||
self._completed_tool_call_ids: set[str] = set()
|
self._completed_tool_call_ids: set[str] = set()
|
||||||
self._pending_client_tool_call_ids: set[str] = set()
|
self._pending_client_tool_call_ids: set[str] = set()
|
||||||
@@ -594,6 +597,7 @@ class DuplexPipeline:
|
|||||||
"provider": llm_provider,
|
"provider": llm_provider,
|
||||||
"model": str(self._runtime_llm.get("model") or settings.llm_model),
|
"model": str(self._runtime_llm.get("model") or settings.llm_model),
|
||||||
"baseUrl": llm_base_url,
|
"baseUrl": llm_base_url,
|
||||||
|
"appId": str(self._runtime_llm.get("appId") or ""),
|
||||||
},
|
},
|
||||||
"asr": {
|
"asr": {
|
||||||
"provider": asr_provider,
|
"provider": asr_provider,
|
||||||
@@ -937,6 +941,19 @@ class DuplexPipeline:
|
|||||||
return None
|
return None
|
||||||
return text.strip().strip('"').strip("'")
|
return text.strip().strip('"').strip("'")
|
||||||
|
|
||||||
|
async def _resolve_provider_initial_greeting(self) -> Optional[str]:
|
||||||
|
if not self.llm_service or not hasattr(self.llm_service, "get_initial_greeting"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
greeting = await self.llm_service.get_initial_greeting()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to load provider initial greeting: {}", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
text = str(greeting or "").strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the pipeline and connect services."""
|
"""Start the pipeline and connect services."""
|
||||||
try:
|
try:
|
||||||
@@ -956,6 +973,7 @@ class DuplexPipeline:
|
|||||||
model=str(llm_model),
|
model=str(llm_model),
|
||||||
api_key=str(llm_api_key).strip() if llm_api_key else None,
|
api_key=str(llm_api_key).strip() if llm_api_key else None,
|
||||||
base_url=str(llm_base_url).strip() if llm_base_url else None,
|
base_url=str(llm_base_url).strip() if llm_base_url else None,
|
||||||
|
app_id=str(self._runtime_llm.get("appId")).strip() if self._runtime_llm.get("appId") else None,
|
||||||
system_prompt=self.conversation.system_prompt,
|
system_prompt=self.conversation.system_prompt,
|
||||||
temperature=settings.llm_temperature,
|
temperature=settings.llm_temperature,
|
||||||
knowledge_config=self._resolved_knowledge_config(),
|
knowledge_config=self._resolved_knowledge_config(),
|
||||||
@@ -1096,7 +1114,11 @@ class DuplexPipeline:
|
|||||||
if not self._bot_starts_first():
|
if not self._bot_starts_first():
|
||||||
return
|
return
|
||||||
|
|
||||||
if self._generated_opener_enabled() and self._resolved_tool_schemas():
|
provider_greeting = await self._resolve_provider_initial_greeting()
|
||||||
|
if provider_greeting:
|
||||||
|
self.conversation.greeting = provider_greeting
|
||||||
|
|
||||||
|
if not provider_greeting and self._generated_opener_enabled() and self._resolved_tool_schemas():
|
||||||
# Run generated opener as a normal tool-capable assistant turn.
|
# Run generated opener as a normal tool-capable assistant turn.
|
||||||
# Use an empty user input so the opener can be driven by system prompt policy.
|
# Use an empty user input so the opener can be driven by system prompt policy.
|
||||||
if self._current_turn_task and not self._current_turn_task.done():
|
if self._current_turn_task and not self._current_turn_task.done():
|
||||||
@@ -1107,13 +1129,13 @@ class DuplexPipeline:
|
|||||||
return
|
return
|
||||||
|
|
||||||
manual_opener_execution: Dict[str, List[Dict[str, Any]]] = {"toolCalls": [], "toolResults": []}
|
manual_opener_execution: Dict[str, List[Dict[str, Any]]] = {"toolCalls": [], "toolResults": []}
|
||||||
if not self._generated_opener_enabled() and self._resolved_manual_opener_tool_calls():
|
if not provider_greeting and not self._generated_opener_enabled() and self._resolved_manual_opener_tool_calls():
|
||||||
self._start_turn()
|
self._start_turn()
|
||||||
self._start_response()
|
self._start_response()
|
||||||
manual_opener_execution = await self._execute_manual_opener_tool_calls()
|
manual_opener_execution = await self._execute_manual_opener_tool_calls()
|
||||||
|
|
||||||
greeting_to_speak = self.conversation.greeting
|
greeting_to_speak = self.conversation.greeting
|
||||||
if self._generated_opener_enabled():
|
if not provider_greeting and self._generated_opener_enabled():
|
||||||
generated_greeting = await self._generate_runtime_greeting()
|
generated_greeting = await self._generate_runtime_greeting()
|
||||||
if generated_greeting:
|
if generated_greeting:
|
||||||
greeting_to_speak = generated_greeting
|
greeting_to_speak = generated_greeting
|
||||||
@@ -1954,12 +1976,35 @@ class DuplexPipeline:
|
|||||||
return bool(self._runtime_tool_wait_for_response.get(normalized, False))
|
return bool(self._runtime_tool_wait_for_response.get(normalized, False))
|
||||||
|
|
||||||
def _tool_executor(self, tool_call: Dict[str, Any]) -> str:
|
def _tool_executor(self, tool_call: Dict[str, Any]) -> str:
|
||||||
|
explicit_executor = str(tool_call.get("executor") or "").strip().lower()
|
||||||
|
if explicit_executor in {"client", "server"}:
|
||||||
|
return explicit_executor
|
||||||
name = self._tool_name(tool_call)
|
name = self._tool_name(tool_call)
|
||||||
if name and name in self._runtime_tool_executor:
|
if name and name in self._runtime_tool_executor:
|
||||||
return self._runtime_tool_executor[name]
|
return self._runtime_tool_executor[name]
|
||||||
# Default to server execution unless explicitly marked as client.
|
# Default to server execution unless explicitly marked as client.
|
||||||
return "server"
|
return "server"
|
||||||
|
|
||||||
|
def _tool_wait_for_response_for_call(self, tool_name: str, tool_call: Dict[str, Any]) -> bool:
|
||||||
|
explicit_wait = tool_call.get("wait_for_response")
|
||||||
|
if explicit_wait is None:
|
||||||
|
explicit_wait = tool_call.get("waitForResponse")
|
||||||
|
if isinstance(explicit_wait, bool):
|
||||||
|
return explicit_wait
|
||||||
|
return self._tool_wait_for_response(tool_name)
|
||||||
|
|
||||||
|
def _tool_timeout_ms(self, tool_call: Dict[str, Any]) -> int:
|
||||||
|
raw_timeout = tool_call.get("timeout_ms")
|
||||||
|
if raw_timeout is None:
|
||||||
|
raw_timeout = tool_call.get("timeoutMs")
|
||||||
|
try:
|
||||||
|
timeout_ms = int(raw_timeout)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
timeout_ms = 0
|
||||||
|
if timeout_ms > 0:
|
||||||
|
return timeout_ms
|
||||||
|
return int(self._TOOL_WAIT_TIMEOUT_SECONDS * 1000)
|
||||||
|
|
||||||
def _tool_arguments(self, tool_call: Dict[str, Any]) -> Dict[str, Any]:
|
def _tool_arguments(self, tool_call: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
fn = tool_call.get("function")
|
fn = tool_call.get("function")
|
||||||
if not isinstance(fn, dict):
|
if not isinstance(fn, dict):
|
||||||
@@ -2179,7 +2224,7 @@ class DuplexPipeline:
|
|||||||
self._early_tool_results[call_id] = item
|
self._early_tool_results[call_id] = item
|
||||||
self._completed_tool_call_ids.add(call_id)
|
self._completed_tool_call_ids.add(call_id)
|
||||||
|
|
||||||
async def _wait_for_single_tool_result(self, call_id: str) -> Dict[str, Any]:
|
async def _wait_for_single_tool_result(self, call_id: str, timeout_seconds: Optional[float] = None) -> Dict[str, Any]:
|
||||||
if call_id in self._completed_tool_call_ids and call_id not in self._early_tool_results:
|
if call_id in self._completed_tool_call_ids and call_id not in self._early_tool_results:
|
||||||
return {
|
return {
|
||||||
"tool_call_id": call_id,
|
"tool_call_id": call_id,
|
||||||
@@ -2193,8 +2238,10 @@ class DuplexPipeline:
|
|||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
future = loop.create_future()
|
future = loop.create_future()
|
||||||
self._pending_tool_waiters[call_id] = future
|
self._pending_tool_waiters[call_id] = future
|
||||||
|
timeout = timeout_seconds if isinstance(timeout_seconds, (int, float)) and timeout_seconds > 0 else self._TOOL_WAIT_TIMEOUT_SECONDS
|
||||||
|
self._pending_tool_deadlines[call_id] = time.monotonic() + timeout
|
||||||
try:
|
try:
|
||||||
return await asyncio.wait_for(future, timeout=self._TOOL_WAIT_TIMEOUT_SECONDS)
|
return await asyncio.wait_for(future, timeout=timeout)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
self._completed_tool_call_ids.add(call_id)
|
self._completed_tool_call_ids.add(call_id)
|
||||||
return {
|
return {
|
||||||
@@ -2204,8 +2251,14 @@ class DuplexPipeline:
|
|||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
self._pending_tool_waiters.pop(call_id, None)
|
self._pending_tool_waiters.pop(call_id, None)
|
||||||
|
self._pending_tool_deadlines.pop(call_id, None)
|
||||||
self._pending_client_tool_call_ids.discard(call_id)
|
self._pending_client_tool_call_ids.discard(call_id)
|
||||||
|
|
||||||
|
def pending_client_tool_deadline(self) -> Optional[float]:
|
||||||
|
if not self._pending_tool_deadlines:
|
||||||
|
return None
|
||||||
|
return max(self._pending_tool_deadlines.values())
|
||||||
|
|
||||||
def _normalize_stream_event(self, item: Any) -> LLMStreamEvent:
|
def _normalize_stream_event(self, item: Any) -> LLMStreamEvent:
|
||||||
if isinstance(item, LLMStreamEvent):
|
if isinstance(item, LLMStreamEvent):
|
||||||
return item
|
return item
|
||||||
@@ -2246,7 +2299,8 @@ class DuplexPipeline:
|
|||||||
messages = self.conversation.get_messages()
|
messages = self.conversation.get_messages()
|
||||||
if system_context and system_context.strip():
|
if system_context and system_context.strip():
|
||||||
messages = [*messages, LLMMessage(role="system", content=system_context.strip())]
|
messages = [*messages, LLMMessage(role="system", content=system_context.strip())]
|
||||||
max_rounds = 3
|
llm_rounds = 0
|
||||||
|
provider_rounds_remaining = self._MAX_PROVIDER_MANAGED_ROUNDS
|
||||||
|
|
||||||
await self.conversation.start_assistant_turn()
|
await self.conversation.start_assistant_turn()
|
||||||
self._is_bot_speaking = True
|
self._is_bot_speaking = True
|
||||||
@@ -2256,10 +2310,28 @@ class DuplexPipeline:
|
|||||||
first_audio_sent = False
|
first_audio_sent = False
|
||||||
self._pending_llm_delta = ""
|
self._pending_llm_delta = ""
|
||||||
self._last_llm_delta_emit_ms = 0.0
|
self._last_llm_delta_emit_ms = 0.0
|
||||||
for _ in range(max_rounds):
|
pending_provider_stream = None
|
||||||
|
while True:
|
||||||
if self._interrupt_event.is_set():
|
if self._interrupt_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if pending_provider_stream is not None:
|
||||||
|
if provider_rounds_remaining <= 0:
|
||||||
|
logger.warning(
|
||||||
|
"Provider-managed tool chain exceeded {} rounds; ending turn early",
|
||||||
|
self._MAX_PROVIDER_MANAGED_ROUNDS,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
provider_rounds_remaining -= 1
|
||||||
|
else:
|
||||||
|
if llm_rounds >= self._MAX_LLM_ROUNDS:
|
||||||
|
logger.warning(
|
||||||
|
"LLM tool planning exceeded {} rounds; ending turn early",
|
||||||
|
self._MAX_LLM_ROUNDS,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
llm_rounds += 1
|
||||||
|
|
||||||
sentence_buffer = ""
|
sentence_buffer = ""
|
||||||
pending_punctuation = ""
|
pending_punctuation = ""
|
||||||
round_response = ""
|
round_response = ""
|
||||||
@@ -2267,7 +2339,10 @@ class DuplexPipeline:
|
|||||||
allow_text_output = True
|
allow_text_output = True
|
||||||
use_engine_sentence_split = self._use_engine_sentence_split_for_tts()
|
use_engine_sentence_split = self._use_engine_sentence_split_for_tts()
|
||||||
|
|
||||||
async for raw_event in self.llm_service.generate_stream(messages):
|
stream_iter = pending_provider_stream if pending_provider_stream is not None else self.llm_service.generate_stream(messages)
|
||||||
|
pending_provider_stream = None
|
||||||
|
|
||||||
|
async for raw_event in stream_iter:
|
||||||
if self._interrupt_event.is_set():
|
if self._interrupt_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -2282,14 +2357,21 @@ class DuplexPipeline:
|
|||||||
if not tool_call:
|
if not tool_call:
|
||||||
continue
|
continue
|
||||||
allow_text_output = False
|
allow_text_output = False
|
||||||
|
tool_name = self._tool_name(tool_call) or "unknown_tool"
|
||||||
executor = self._tool_executor(tool_call)
|
executor = self._tool_executor(tool_call)
|
||||||
enriched_tool_call = dict(tool_call)
|
enriched_tool_call = dict(tool_call)
|
||||||
enriched_tool_call["executor"] = executor
|
enriched_tool_call["executor"] = executor
|
||||||
tool_name = self._tool_name(enriched_tool_call) or "unknown_tool"
|
|
||||||
tool_id = self._tool_id_for_name(tool_name)
|
tool_id = self._tool_id_for_name(tool_name)
|
||||||
tool_display_name = self._tool_display_name(tool_name) or tool_name
|
tool_display_name = str(
|
||||||
wait_for_response = self._tool_wait_for_response(tool_name)
|
enriched_tool_call.get("displayName")
|
||||||
|
or enriched_tool_call.get("display_name")
|
||||||
|
or self._tool_display_name(tool_name)
|
||||||
|
or tool_name
|
||||||
|
).strip()
|
||||||
|
wait_for_response = self._tool_wait_for_response_for_call(tool_name, enriched_tool_call)
|
||||||
enriched_tool_call["wait_for_response"] = wait_for_response
|
enriched_tool_call["wait_for_response"] = wait_for_response
|
||||||
|
timeout_ms = self._tool_timeout_ms(enriched_tool_call)
|
||||||
|
enriched_tool_call["timeout_ms"] = timeout_ms
|
||||||
call_id = str(enriched_tool_call.get("id") or "").strip()
|
call_id = str(enriched_tool_call.get("id") or "").strip()
|
||||||
fn_payload = (
|
fn_payload = (
|
||||||
dict(enriched_tool_call.get("function"))
|
dict(enriched_tool_call.get("function"))
|
||||||
@@ -2298,6 +2380,15 @@ class DuplexPipeline:
|
|||||||
)
|
)
|
||||||
raw_args = str(fn_payload.get("arguments") or "") if isinstance(fn_payload, dict) else ""
|
raw_args = str(fn_payload.get("arguments") or "") if isinstance(fn_payload, dict) else ""
|
||||||
tool_arguments = self._tool_arguments(enriched_tool_call)
|
tool_arguments = self._tool_arguments(enriched_tool_call)
|
||||||
|
if tool_name == "fastgpt.interactive":
|
||||||
|
context_payload = (
|
||||||
|
dict(tool_arguments.get("context"))
|
||||||
|
if isinstance(tool_arguments.get("context"), dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
context_payload.setdefault("turn_id", turn_id)
|
||||||
|
context_payload.setdefault("response_id", response_id)
|
||||||
|
tool_arguments["context"] = context_payload
|
||||||
merged_tool_arguments = self._apply_tool_default_args(tool_name, tool_arguments)
|
merged_tool_arguments = self._apply_tool_default_args(tool_name, tool_arguments)
|
||||||
try:
|
try:
|
||||||
merged_args_text = json.dumps(merged_tool_arguments, ensure_ascii=False)
|
merged_args_text = json.dumps(merged_tool_arguments, ensure_ascii=False)
|
||||||
@@ -2324,9 +2415,9 @@ class DuplexPipeline:
|
|||||||
tool_id=tool_id,
|
tool_id=tool_id,
|
||||||
tool_display_name=tool_display_name,
|
tool_display_name=tool_display_name,
|
||||||
wait_for_response=wait_for_response,
|
wait_for_response=wait_for_response,
|
||||||
arguments=tool_arguments,
|
arguments=merged_tool_arguments,
|
||||||
executor=executor,
|
executor=executor,
|
||||||
timeout_ms=int(self._TOOL_WAIT_TIMEOUT_SECONDS * 1000),
|
timeout_ms=timeout_ms,
|
||||||
tool_call=enriched_tool_call,
|
tool_call=enriched_tool_call,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -2457,6 +2548,8 @@ class DuplexPipeline:
|
|||||||
break
|
break
|
||||||
|
|
||||||
tool_results: List[Dict[str, Any]] = []
|
tool_results: List[Dict[str, Any]] = []
|
||||||
|
provider_managed_tool = False
|
||||||
|
provider_resumed = False
|
||||||
for call in tool_calls:
|
for call in tool_calls:
|
||||||
call_id = str(call.get("id") or "").strip()
|
call_id = str(call.get("id") or "").strip()
|
||||||
if not call_id:
|
if not call_id:
|
||||||
@@ -2466,9 +2559,27 @@ class DuplexPipeline:
|
|||||||
tool_id = self._tool_id_for_name(tool_name)
|
tool_id = self._tool_id_for_name(tool_name)
|
||||||
logger.info(f"[Tool] execute start name={tool_name} call_id={call_id} executor={executor}")
|
logger.info(f"[Tool] execute start name={tool_name} call_id={call_id} executor={executor}")
|
||||||
if executor == "client":
|
if executor == "client":
|
||||||
result = await self._wait_for_single_tool_result(call_id)
|
timeout_ms = self._tool_timeout_ms(call)
|
||||||
|
result = await self._wait_for_single_tool_result(
|
||||||
|
call_id,
|
||||||
|
timeout_seconds=(timeout_ms / 1000.0),
|
||||||
|
)
|
||||||
await self._emit_tool_result(result, source="client")
|
await self._emit_tool_result(result, source="client")
|
||||||
tool_results.append(result)
|
tool_results.append(result)
|
||||||
|
if (
|
||||||
|
hasattr(self.llm_service, "handles_client_tool")
|
||||||
|
and hasattr(self.llm_service, "resume_after_client_tool_result")
|
||||||
|
and self.llm_service.handles_client_tool(tool_name)
|
||||||
|
):
|
||||||
|
provider_managed_tool = True
|
||||||
|
status = result.get("status") if isinstance(result.get("status"), dict) else {}
|
||||||
|
status_code = int(status.get("code") or 0) if status else 0
|
||||||
|
output = result.get("output") if isinstance(result.get("output"), dict) else {}
|
||||||
|
action = str(output.get("action") or "").strip().lower()
|
||||||
|
if 200 <= status_code < 300 and action != "cancel":
|
||||||
|
pending_provider_stream = self.llm_service.resume_after_client_tool_result(call_id, result)
|
||||||
|
provider_resumed = True
|
||||||
|
break
|
||||||
continue
|
continue
|
||||||
|
|
||||||
call_for_executor = dict(call)
|
call_for_executor = dict(call)
|
||||||
@@ -2495,6 +2606,11 @@ class DuplexPipeline:
|
|||||||
await self._emit_tool_result(result, source="server")
|
await self._emit_tool_result(result, source="server")
|
||||||
tool_results.append(result)
|
tool_results.append(result)
|
||||||
|
|
||||||
|
if provider_resumed:
|
||||||
|
continue
|
||||||
|
if provider_managed_tool:
|
||||||
|
break
|
||||||
|
|
||||||
messages = [
|
messages = [
|
||||||
*messages,
|
*messages,
|
||||||
LLMMessage(
|
LLMMessage(
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ from runtime.ports.control_plane import (
|
|||||||
KnowledgeRetriever,
|
KnowledgeRetriever,
|
||||||
ToolCatalog,
|
ToolCatalog,
|
||||||
)
|
)
|
||||||
from runtime.ports.llm import LLMCancellable, LLMPort, LLMRuntimeConfigurable, LLMServiceSpec
|
from runtime.ports.llm import (
|
||||||
|
LLMCancellable,
|
||||||
|
LLMClientToolResumable,
|
||||||
|
LLMPort,
|
||||||
|
LLMRuntimeConfigurable,
|
||||||
|
LLMServiceSpec,
|
||||||
|
)
|
||||||
from runtime.ports.service_factory import RealtimeServiceFactory
|
from runtime.ports.service_factory import RealtimeServiceFactory
|
||||||
from runtime.ports.tts import TTSPort, TTSServiceSpec
|
from runtime.ports.tts import TTSPort, TTSServiceSpec
|
||||||
|
|
||||||
@@ -30,6 +36,7 @@ __all__ = [
|
|||||||
"KnowledgeRetriever",
|
"KnowledgeRetriever",
|
||||||
"ToolCatalog",
|
"ToolCatalog",
|
||||||
"LLMCancellable",
|
"LLMCancellable",
|
||||||
|
"LLMClientToolResumable",
|
||||||
"LLMPort",
|
"LLMPort",
|
||||||
"LLMRuntimeConfigurable",
|
"LLMRuntimeConfigurable",
|
||||||
"LLMServiceSpec",
|
"LLMServiceSpec",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class LLMServiceSpec:
|
|||||||
model: str
|
model: str
|
||||||
api_key: Optional[str] = None
|
api_key: Optional[str] = None
|
||||||
base_url: Optional[str] = None
|
base_url: Optional[str] = None
|
||||||
|
app_id: Optional[str] = None
|
||||||
system_prompt: Optional[str] = None
|
system_prompt: Optional[str] = None
|
||||||
temperature: float = 0.7
|
temperature: float = 0.7
|
||||||
knowledge_config: Dict[str, Any] = field(default_factory=dict)
|
knowledge_config: Dict[str, Any] = field(default_factory=dict)
|
||||||
@@ -65,3 +66,17 @@ class LLMRuntimeConfigurable(Protocol):
|
|||||||
|
|
||||||
def set_tool_schemas(self, schemas: Optional[List[Dict[str, Any]]]) -> None:
|
def set_tool_schemas(self, schemas: Optional[List[Dict[str, Any]]]) -> None:
|
||||||
"""Apply runtime tool schemas used for tool calling."""
|
"""Apply runtime tool schemas used for tool calling."""
|
||||||
|
|
||||||
|
|
||||||
|
class LLMClientToolResumable(Protocol):
|
||||||
|
"""Optional extension for providers that pause on client-side tool results."""
|
||||||
|
|
||||||
|
def handles_client_tool(self, tool_name: str) -> bool:
|
||||||
|
"""Return True when the provider owns the lifecycle of this client tool."""
|
||||||
|
|
||||||
|
def resume_after_client_tool_result(
|
||||||
|
self,
|
||||||
|
tool_call_id: str,
|
||||||
|
result: Dict[str, Any],
|
||||||
|
) -> AsyncIterator[LLMStreamEvent]:
|
||||||
|
"""Resume the provider stream after a correlated client-side tool result."""
|
||||||
|
|||||||
@@ -283,6 +283,30 @@ def test_translate_agent_schema_maps_volcengine_fields():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_translate_agent_schema_maps_llm_app_id():
|
||||||
|
payload = {
|
||||||
|
"agent": {
|
||||||
|
"llm": {
|
||||||
|
"provider": "fastgpt",
|
||||||
|
"model": "fastgpt",
|
||||||
|
"api_key": "llm-key",
|
||||||
|
"api_url": "https://cloud.fastgpt.cn/api",
|
||||||
|
"app_id": "app-fastgpt-123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
translated = LocalYamlAssistantConfigAdapter._translate_agent_schema("assistant_demo", payload)
|
||||||
|
assert translated is not None
|
||||||
|
assert translated["services"]["llm"] == {
|
||||||
|
"provider": "fastgpt",
|
||||||
|
"model": "fastgpt",
|
||||||
|
"apiKey": "llm-key",
|
||||||
|
"baseUrl": "https://cloud.fastgpt.cn/api",
|
||||||
|
"appId": "app-fastgpt-123",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_backend_mode_disabled_uses_local_assistant_config_even_with_url(monkeypatch, tmp_path):
|
async def test_backend_mode_disabled_uses_local_assistant_config_even_with_url(monkeypatch, tmp_path):
|
||||||
class _FailIfCalledClientSession:
|
class _FailIfCalledClientSession:
|
||||||
|
|||||||
411
engine/tests/test_fastgpt_provider.py
Normal file
411
engine/tests/test_fastgpt_provider.py
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from providers.common.base import LLMMessage
|
||||||
|
from providers.llm.fastgpt import FastGPTLLMService
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(self, events: List[Any]):
|
||||||
|
self.events = events
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeJSONResponse:
|
||||||
|
def __init__(self, payload: Dict[str, Any], status_code: int = 200):
|
||||||
|
self._payload = payload
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
def json(self) -> Dict[str, Any]:
|
||||||
|
return dict(self._payload)
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
if self.status_code >= 400:
|
||||||
|
raise RuntimeError(f"HTTP {self.status_code}")
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeAsyncStreamResponse(_FakeResponse):
|
||||||
|
def __init__(self, events: List[Any]):
|
||||||
|
super().__init__(events)
|
||||||
|
self.aclosed = False
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
raise AssertionError("sync close should not be used for async stream responses")
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
self.aclosed = True
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeAsyncChatClient:
|
||||||
|
responses: List[_FakeResponse] = []
|
||||||
|
init_payload: Dict[str, Any] | None = None
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, base_url: str):
|
||||||
|
self.api_key = api_key
|
||||||
|
self.base_url = base_url
|
||||||
|
self.requests: List[Dict[str, Any]] = []
|
||||||
|
self.init_requests: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def create_chat_completion(self, **kwargs):
|
||||||
|
self.requests.append(dict(kwargs))
|
||||||
|
if not self.responses:
|
||||||
|
raise AssertionError("No fake FastGPT response queued")
|
||||||
|
return self.responses.pop(0)
|
||||||
|
|
||||||
|
async def get_chat_init(self, **kwargs):
|
||||||
|
self.init_requests.append(dict(kwargs))
|
||||||
|
return _FakeJSONResponse(
|
||||||
|
self.init_payload or {"data": {"app": {"chatConfig": {"welcomeText": ""}}}},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _fake_aiter_stream_events(response: _FakeResponse):
|
||||||
|
for event in response.events:
|
||||||
|
yield event
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fastgpt_provider_streams_text_from_data_event(monkeypatch):
|
||||||
|
monkeypatch.setattr("providers.llm.fastgpt.AsyncChatClient", _FakeAsyncChatClient)
|
||||||
|
monkeypatch.setattr("providers.llm.fastgpt.aiter_stream_events", _fake_aiter_stream_events)
|
||||||
|
|
||||||
|
_FakeAsyncChatClient.responses = [
|
||||||
|
_FakeResponse(
|
||||||
|
[
|
||||||
|
SimpleNamespace(
|
||||||
|
kind="data",
|
||||||
|
data={"choices": [{"delta": {"content": "Hello from FastGPT."}}]},
|
||||||
|
),
|
||||||
|
SimpleNamespace(kind="done", data={}),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
service = FastGPTLLMService(api_key="key", base_url="https://fastgpt.example")
|
||||||
|
await service.connect()
|
||||||
|
|
||||||
|
events = [event async for event in service.generate_stream([LLMMessage(role="user", content="Hi")])]
|
||||||
|
|
||||||
|
assert [event.type for event in events] == ["text_delta", "done"]
|
||||||
|
assert events[0].text == "Hello from FastGPT."
|
||||||
|
assert service.client.requests[0]["messages"] == [{"role": "user", "content": "Hi"}]
|
||||||
|
assert service.client.requests[0]["chatId"] == service._state.chat_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fastgpt_provider_streams_text_from_answer_delta_event(monkeypatch):
|
||||||
|
monkeypatch.setattr("providers.llm.fastgpt.AsyncChatClient", _FakeAsyncChatClient)
|
||||||
|
monkeypatch.setattr("providers.llm.fastgpt.aiter_stream_events", _fake_aiter_stream_events)
|
||||||
|
|
||||||
|
_FakeAsyncChatClient.responses = [
|
||||||
|
_FakeResponse(
|
||||||
|
[
|
||||||
|
SimpleNamespace(
|
||||||
|
kind="answer",
|
||||||
|
data={"choices": [{"delta": {"content": "Hello from answer delta."}}]},
|
||||||
|
),
|
||||||
|
SimpleNamespace(kind="done", data={}),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
service = FastGPTLLMService(api_key="key", base_url="https://fastgpt.example")
|
||||||
|
await service.connect()
|
||||||
|
|
||||||
|
events = [event async for event in service.generate_stream([LLMMessage(role="user", content="Hi")])]
|
||||||
|
|
||||||
|
assert [event.type for event in events] == ["text_delta", "done"]
|
||||||
|
assert events[0].text == "Hello from answer delta."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fastgpt_provider_uses_async_close_for_stream_responses(monkeypatch):
|
||||||
|
monkeypatch.setattr("providers.llm.fastgpt.AsyncChatClient", _FakeAsyncChatClient)
|
||||||
|
monkeypatch.setattr("providers.llm.fastgpt.aiter_stream_events", _fake_aiter_stream_events)
|
||||||
|
|
||||||
|
response = _FakeAsyncStreamResponse(
|
||||||
|
[
|
||||||
|
SimpleNamespace(
|
||||||
|
kind="data",
|
||||||
|
data={"choices": [{"delta": {"content": "Hello from FastGPT."}}]},
|
||||||
|
),
|
||||||
|
SimpleNamespace(kind="done", data={}),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
_FakeAsyncChatClient.responses = [response]
|
||||||
|
|
||||||
|
service = FastGPTLLMService(api_key="key", base_url="https://fastgpt.example")
|
||||||
|
await service.connect()
|
||||||
|
|
||||||
|
events = [event async for event in service.generate_stream([LLMMessage(role="user", content="Hi")])]
|
||||||
|
|
||||||
|
assert [event.type for event in events] == ["text_delta", "done"]
|
||||||
|
assert response.aclosed is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fastgpt_provider_loads_initial_greeting_from_chat_init(monkeypatch):
|
||||||
|
monkeypatch.setattr("providers.llm.fastgpt.AsyncChatClient", _FakeAsyncChatClient)
|
||||||
|
monkeypatch.setattr("providers.llm.fastgpt.aiter_stream_events", _fake_aiter_stream_events)
|
||||||
|
|
||||||
|
_FakeAsyncChatClient.init_payload = {
|
||||||
|
"data": {
|
||||||
|
"app": {
|
||||||
|
"chatConfig": {
|
||||||
|
"welcomeText": "Hello from FastGPT init.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
service = FastGPTLLMService(
|
||||||
|
api_key="key",
|
||||||
|
base_url="https://fastgpt.example",
|
||||||
|
app_id="app-123",
|
||||||
|
)
|
||||||
|
await service.connect()
|
||||||
|
|
||||||
|
greeting = await service.get_initial_greeting()
|
||||||
|
|
||||||
|
assert greeting == "Hello from FastGPT init."
|
||||||
|
assert service.client.init_requests[0] == {
|
||||||
|
"appId": "app-123",
|
||||||
|
"chatId": service._state.chat_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fastgpt_provider_maps_interactive_event_to_client_tool(monkeypatch):
|
||||||
|
monkeypatch.setattr("providers.llm.fastgpt.AsyncChatClient", _FakeAsyncChatClient)
|
||||||
|
monkeypatch.setattr("providers.llm.fastgpt.aiter_stream_events", _fake_aiter_stream_events)
|
||||||
|
|
||||||
|
_FakeAsyncChatClient.responses = [
|
||||||
|
_FakeResponse(
|
||||||
|
[
|
||||||
|
SimpleNamespace(
|
||||||
|
kind="interactive",
|
||||||
|
data={
|
||||||
|
"type": "userSelect",
|
||||||
|
"title": "Choose a plan",
|
||||||
|
"params": {
|
||||||
|
"description": "Pick the best plan for your team.",
|
||||||
|
"userSelectOptions": [
|
||||||
|
{"id": "basic", "label": "Basic", "value": "basic", "desc": "Starter tier"},
|
||||||
|
{"id": "pro", "label": "Pro", "value": "pro", "description": "Advanced tier"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
service = FastGPTLLMService(api_key="key", base_url="https://fastgpt.example")
|
||||||
|
await service.connect()
|
||||||
|
|
||||||
|
events = [event async for event in service.generate_stream([LLMMessage(role="user", content="Start")])]
|
||||||
|
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].type == "tool_call"
|
||||||
|
tool_call = events[0].tool_call
|
||||||
|
assert tool_call["executor"] == "client"
|
||||||
|
assert tool_call["wait_for_response"] is True
|
||||||
|
assert tool_call["timeout_ms"] == 300000
|
||||||
|
assert tool_call["function"]["name"] == "fastgpt.interactive"
|
||||||
|
|
||||||
|
arguments = json.loads(tool_call["function"]["arguments"])
|
||||||
|
assert arguments["provider"] == "fastgpt"
|
||||||
|
assert arguments["version"] == "fastgpt_interactive_v1"
|
||||||
|
assert arguments["interaction"]["type"] == "userSelect"
|
||||||
|
assert arguments["interaction"]["description"] == "Pick the best plan for your team."
|
||||||
|
assert arguments["interaction"]["options"][0]["description"] == "Starter tier"
|
||||||
|
assert arguments["interaction"]["options"][1]["value"] == "pro"
|
||||||
|
assert arguments["interaction"]["options"][1]["description"] == "Advanced tier"
|
||||||
|
assert arguments["context"]["chat_id"] == service._state.chat_id
|
||||||
|
assert service._state.pending_interaction is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fastgpt_provider_unwraps_nested_tool_children_interactive(monkeypatch):
|
||||||
|
monkeypatch.setattr("providers.llm.fastgpt.AsyncChatClient", _FakeAsyncChatClient)
|
||||||
|
monkeypatch.setattr("providers.llm.fastgpt.aiter_stream_events", _fake_aiter_stream_events)
|
||||||
|
|
||||||
|
_FakeAsyncChatClient.responses = [
|
||||||
|
_FakeResponse(
|
||||||
|
[
|
||||||
|
SimpleNamespace(
|
||||||
|
kind="interactive",
|
||||||
|
data={
|
||||||
|
"interactive": {
|
||||||
|
"type": "toolChildrenInteractive",
|
||||||
|
"params": {
|
||||||
|
"childrenResponse": {
|
||||||
|
"type": "userSelect",
|
||||||
|
"params": {
|
||||||
|
"description": "Please choose a workflow branch.",
|
||||||
|
"userSelectOptions": [
|
||||||
|
{"value": "A", "description": "Branch A"},
|
||||||
|
{"value": "B", "description": "Branch B"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
service = FastGPTLLMService(api_key="key", base_url="https://fastgpt.example")
|
||||||
|
await service.connect()
|
||||||
|
|
||||||
|
events = [event async for event in service.generate_stream([LLMMessage(role="user", content="Start")])]
|
||||||
|
|
||||||
|
assert len(events) == 1
|
||||||
|
arguments = json.loads(events[0].tool_call["function"]["arguments"])
|
||||||
|
assert arguments["interaction"]["type"] == "userSelect"
|
||||||
|
assert arguments["interaction"]["description"] == "Please choose a workflow branch."
|
||||||
|
assert arguments["interaction"]["options"][0]["description"] == "Branch A"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fastgpt_provider_uses_opener_for_interactive_prompt_when_prompt_missing(monkeypatch):
|
||||||
|
monkeypatch.setattr("providers.llm.fastgpt.AsyncChatClient", _FakeAsyncChatClient)
|
||||||
|
monkeypatch.setattr("providers.llm.fastgpt.aiter_stream_events", _fake_aiter_stream_events)
|
||||||
|
|
||||||
|
_FakeAsyncChatClient.responses = [
|
||||||
|
_FakeResponse(
|
||||||
|
[
|
||||||
|
SimpleNamespace(
|
||||||
|
kind="interactive",
|
||||||
|
data={
|
||||||
|
"type": "userSelect",
|
||||||
|
"opener": "请确认您是否满意本次服务。",
|
||||||
|
"params": {
|
||||||
|
"userSelectOptions": [
|
||||||
|
{"value": "是"},
|
||||||
|
{"value": "否"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
service = FastGPTLLMService(api_key="key", base_url="https://fastgpt.example")
|
||||||
|
await service.connect()
|
||||||
|
|
||||||
|
events = [event async for event in service.generate_stream([LLMMessage(role="user", content="Start")])]
|
||||||
|
|
||||||
|
assert len(events) == 1
|
||||||
|
tool_call = events[0].tool_call
|
||||||
|
arguments = json.loads(tool_call["function"]["arguments"])
|
||||||
|
assert tool_call["display_name"] == "请确认您是否满意本次服务。"
|
||||||
|
assert arguments["interaction"]["prompt"] == "请确认您是否满意本次服务。"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fastgpt_provider_resumes_same_chat_after_client_result(monkeypatch):
|
||||||
|
monkeypatch.setattr("providers.llm.fastgpt.AsyncChatClient", _FakeAsyncChatClient)
|
||||||
|
monkeypatch.setattr("providers.llm.fastgpt.aiter_stream_events", _fake_aiter_stream_events)
|
||||||
|
|
||||||
|
_FakeAsyncChatClient.responses = [
|
||||||
|
_FakeResponse(
|
||||||
|
[
|
||||||
|
SimpleNamespace(
|
||||||
|
kind="interactive",
|
||||||
|
data={
|
||||||
|
"type": "userSelect",
|
||||||
|
"params": {"userSelectOptions": [{"label": "Pro", "value": "pro"}]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
_FakeResponse(
|
||||||
|
[
|
||||||
|
SimpleNamespace(kind="answer", data={"text": "Resumed answer."}),
|
||||||
|
SimpleNamespace(kind="done", data={}),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
service = FastGPTLLMService(api_key="key", base_url="https://fastgpt.example")
|
||||||
|
await service.connect()
|
||||||
|
|
||||||
|
initial_events = [event async for event in service.generate_stream([LLMMessage(role="user", content="Start")])]
|
||||||
|
call_id = initial_events[0].tool_call["id"]
|
||||||
|
|
||||||
|
resumed_events = [
|
||||||
|
event
|
||||||
|
async for event in service.resume_after_client_tool_result(
|
||||||
|
call_id,
|
||||||
|
{
|
||||||
|
"tool_call_id": call_id,
|
||||||
|
"name": "fastgpt.interactive",
|
||||||
|
"output": {
|
||||||
|
"action": "submit",
|
||||||
|
"result": {"type": "userSelect", "selected": "pro"},
|
||||||
|
},
|
||||||
|
"status": {"code": 200, "message": "ok"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert [event.type for event in resumed_events] == ["text_delta", "done"]
|
||||||
|
assert resumed_events[0].text == "Resumed answer."
|
||||||
|
assert service.client.requests[1]["chatId"] == service.client.requests[0]["chatId"]
|
||||||
|
assert service.client.requests[1]["messages"] == [{"role": "user", "content": "pro"}]
|
||||||
|
assert service._state.pending_interaction is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fastgpt_provider_cancel_result_clears_pending_interaction(monkeypatch):
|
||||||
|
monkeypatch.setattr("providers.llm.fastgpt.AsyncChatClient", _FakeAsyncChatClient)
|
||||||
|
monkeypatch.setattr("providers.llm.fastgpt.aiter_stream_events", _fake_aiter_stream_events)
|
||||||
|
|
||||||
|
_FakeAsyncChatClient.responses = [
|
||||||
|
_FakeResponse(
|
||||||
|
[
|
||||||
|
SimpleNamespace(
|
||||||
|
kind="interactive",
|
||||||
|
data={
|
||||||
|
"type": "userInput",
|
||||||
|
"params": {"inputForm": [{"name": "name", "label": "Name"}]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
service = FastGPTLLMService(api_key="key", base_url="https://fastgpt.example")
|
||||||
|
await service.connect()
|
||||||
|
|
||||||
|
initial_events = [event async for event in service.generate_stream([LLMMessage(role="user", content="Start")])]
|
||||||
|
call_id = initial_events[0].tool_call["id"]
|
||||||
|
|
||||||
|
resumed_events = [
|
||||||
|
event
|
||||||
|
async for event in service.resume_after_client_tool_result(
|
||||||
|
call_id,
|
||||||
|
{
|
||||||
|
"tool_call_id": call_id,
|
||||||
|
"name": "fastgpt.interactive",
|
||||||
|
"output": {"action": "cancel", "result": {}},
|
||||||
|
"status": {"code": 499, "message": "user_cancelled"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert [event.type for event in resumed_events] == ["done"]
|
||||||
|
assert service._state.pending_interaction is None
|
||||||
13
engine/tests/test_session_timeout.py
Normal file
13
engine/tests/test_session_timeout.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from app.main import _inactivity_deadline
|
||||||
|
|
||||||
|
|
||||||
|
def test_inactivity_deadline_uses_default_timeout_without_pending_tool():
|
||||||
|
assert _inactivity_deadline(last_received_at=100.0, inactivity_timeout_sec=60) == 160.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_inactivity_deadline_extends_while_waiting_for_client_tool():
|
||||||
|
assert _inactivity_deadline(
|
||||||
|
last_received_at=100.0,
|
||||||
|
inactivity_timeout_sec=60,
|
||||||
|
pending_client_tool_deadline=340.0,
|
||||||
|
) == 340.0
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -109,6 +110,22 @@ class _CaptureGenerateLLM:
|
|||||||
yield LLMStreamEvent(type="done")
|
yield LLMStreamEvent(type="done")
|
||||||
|
|
||||||
|
|
||||||
|
class _InitGreetingLLM:
|
||||||
|
def __init__(self, greeting: str):
|
||||||
|
self.greeting = greeting
|
||||||
|
self.init_calls = 0
|
||||||
|
|
||||||
|
async def generate(self, _messages, temperature=0.7, max_tokens=None):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
async def generate_stream(self, _messages, temperature=0.7, max_tokens=None):
|
||||||
|
yield LLMStreamEvent(type="done")
|
||||||
|
|
||||||
|
async def get_initial_greeting(self):
|
||||||
|
self.init_calls += 1
|
||||||
|
return self.greeting
|
||||||
|
|
||||||
|
|
||||||
def _build_pipeline(monkeypatch, llm_rounds: List[List[LLMStreamEvent]]) -> tuple[DuplexPipeline, List[Dict[str, Any]]]:
|
def _build_pipeline(monkeypatch, llm_rounds: List[List[LLMStreamEvent]]) -> tuple[DuplexPipeline, List[Dict[str, Any]]]:
|
||||||
monkeypatch.setattr("runtime.pipeline.duplex.SileroVAD", _DummySileroVAD)
|
monkeypatch.setattr("runtime.pipeline.duplex.SileroVAD", _DummySileroVAD)
|
||||||
monkeypatch.setattr("runtime.pipeline.duplex.VADProcessor", _DummyVADProcessor)
|
monkeypatch.setattr("runtime.pipeline.duplex.VADProcessor", _DummyVADProcessor)
|
||||||
@@ -306,6 +323,21 @@ async def test_generated_opener_uses_tool_capable_turn_when_tools_available(monk
|
|||||||
assert called.get("user_text") == ""
|
assert called.get("user_text") == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_provider_initial_greeting_takes_precedence_over_local_opener(monkeypatch):
|
||||||
|
llm = _InitGreetingLLM("FastGPT init greeting")
|
||||||
|
pipeline, events = _build_pipeline_with_custom_llm(monkeypatch, llm)
|
||||||
|
pipeline.apply_runtime_overrides({"output": {"mode": "text"}})
|
||||||
|
pipeline.conversation.greeting = "local fallback greeting"
|
||||||
|
|
||||||
|
await pipeline.emit_initial_greeting()
|
||||||
|
|
||||||
|
finals = [event for event in events if event.get("type") == "assistant.response.final"]
|
||||||
|
assert finals
|
||||||
|
assert finals[-1]["text"] == "FastGPT init greeting"
|
||||||
|
assert llm.init_calls == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_manual_opener_tool_calls_emit_assistant_tool_call(monkeypatch):
|
async def test_manual_opener_tool_calls_emit_assistant_tool_call(monkeypatch):
|
||||||
pipeline, events = _build_pipeline(monkeypatch, [[LLMStreamEvent(type="done")]])
|
pipeline, events = _build_pipeline(monkeypatch, [[LLMStreamEvent(type="done")]])
|
||||||
@@ -736,3 +768,268 @@ async def test_eou_early_return_clears_stale_asr_capture(monkeypatch):
|
|||||||
assert pipeline._asr_capture_active is False
|
assert pipeline._asr_capture_active is False
|
||||||
assert pipeline._asr_capture_started_ms == 0.0
|
assert pipeline._asr_capture_started_ms == 0.0
|
||||||
assert pipeline._pending_speech_audio == b""
|
assert pipeline._pending_speech_audio == b""
|
||||||
|
|
||||||
|
class _FakeResumableLLM:
|
||||||
|
def __init__(self, *, timeout_ms: int = 300000):
|
||||||
|
self.timeout_ms = timeout_ms
|
||||||
|
self.generate_stream_calls = 0
|
||||||
|
self.resumed_results: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def generate(self, _messages, temperature=0.7, max_tokens=None):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
async def generate_stream(self, _messages, temperature=0.7, max_tokens=None):
|
||||||
|
self.generate_stream_calls += 1
|
||||||
|
yield LLMStreamEvent(
|
||||||
|
type="tool_call",
|
||||||
|
tool_call={
|
||||||
|
"id": "call_fastgpt_1",
|
||||||
|
"executor": "client",
|
||||||
|
"wait_for_response": True,
|
||||||
|
"timeout_ms": self.timeout_ms,
|
||||||
|
"display_name": "Choose a plan",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "fastgpt.interactive",
|
||||||
|
"arguments": json.dumps(
|
||||||
|
{
|
||||||
|
"provider": "fastgpt",
|
||||||
|
"version": "fastgpt_interactive_v1",
|
||||||
|
"interaction": {
|
||||||
|
"type": "userSelect",
|
||||||
|
"title": "Choose a plan",
|
||||||
|
"options": [
|
||||||
|
{"id": "basic", "label": "Basic", "value": "basic"},
|
||||||
|
{"id": "pro", "label": "Pro", "value": "pro"},
|
||||||
|
],
|
||||||
|
"form": [],
|
||||||
|
},
|
||||||
|
"context": {"chat_id": "fastgpt_chat_1"},
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
yield LLMStreamEvent(type="done")
|
||||||
|
|
||||||
|
def handles_client_tool(self, tool_name: str) -> bool:
|
||||||
|
return tool_name == "fastgpt.interactive"
|
||||||
|
|
||||||
|
async def resume_after_client_tool_result(self, tool_call_id: str, result: Dict[str, Any]):
|
||||||
|
self.resumed_results.append({"tool_call_id": tool_call_id, "result": dict(result)})
|
||||||
|
yield LLMStreamEvent(type="text_delta", text="provider resumed answer.")
|
||||||
|
yield LLMStreamEvent(type="done")
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeChainedResumableLLM:
|
||||||
|
def __init__(self, call_ids: List[str], *, timeout_ms: int = 300000):
|
||||||
|
self.call_ids = call_ids
|
||||||
|
self.timeout_ms = timeout_ms
|
||||||
|
self.generate_stream_calls = 0
|
||||||
|
self.resumed_results: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
def _tool_call_event(self, call_id: str) -> LLMStreamEvent:
|
||||||
|
return LLMStreamEvent(
|
||||||
|
type="tool_call",
|
||||||
|
tool_call={
|
||||||
|
"id": call_id,
|
||||||
|
"executor": "client",
|
||||||
|
"wait_for_response": True,
|
||||||
|
"timeout_ms": self.timeout_ms,
|
||||||
|
"display_name": f"Collect {call_id}",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "fastgpt.interactive",
|
||||||
|
"arguments": json.dumps(
|
||||||
|
{
|
||||||
|
"provider": "fastgpt",
|
||||||
|
"version": "fastgpt_interactive_v1",
|
||||||
|
"interaction": {
|
||||||
|
"type": "userInput",
|
||||||
|
"title": "",
|
||||||
|
"description": f"Prompt for {call_id}",
|
||||||
|
"prompt": f"Prompt for {call_id}",
|
||||||
|
"form": [{"name": "result", "label": "result", "input_type": "input"}],
|
||||||
|
"options": [],
|
||||||
|
},
|
||||||
|
"context": {"chat_id": "fastgpt_chat_chain"},
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def generate(self, _messages, temperature=0.7, max_tokens=None):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
async def generate_stream(self, _messages, temperature=0.7, max_tokens=None):
|
||||||
|
self.generate_stream_calls += 1
|
||||||
|
yield self._tool_call_event(self.call_ids[0])
|
||||||
|
yield LLMStreamEvent(type="done")
|
||||||
|
|
||||||
|
def handles_client_tool(self, tool_name: str) -> bool:
|
||||||
|
return tool_name == "fastgpt.interactive"
|
||||||
|
|
||||||
|
async def resume_after_client_tool_result(self, tool_call_id: str, result: Dict[str, Any]):
|
||||||
|
self.resumed_results.append({"tool_call_id": tool_call_id, "result": dict(result)})
|
||||||
|
next_index = len(self.resumed_results)
|
||||||
|
if next_index < len(self.call_ids):
|
||||||
|
yield self._tool_call_event(self.call_ids[next_index])
|
||||||
|
else:
|
||||||
|
yield LLMStreamEvent(type="text_delta", text="completed after third interactive input.")
|
||||||
|
yield LLMStreamEvent(type="done")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_pipeline_with_custom_llm(monkeypatch, llm_service) -> tuple[DuplexPipeline, List[Dict[str, Any]]]:
|
||||||
|
monkeypatch.setattr("runtime.pipeline.duplex.SileroVAD", _DummySileroVAD)
|
||||||
|
monkeypatch.setattr("runtime.pipeline.duplex.VADProcessor", _DummyVADProcessor)
|
||||||
|
monkeypatch.setattr("runtime.pipeline.duplex.EouDetector", _DummyEouDetector)
|
||||||
|
|
||||||
|
pipeline = DuplexPipeline(
|
||||||
|
transport=_FakeTransport(),
|
||||||
|
session_id="s_fastgpt",
|
||||||
|
llm_service=llm_service,
|
||||||
|
tts_service=_FakeTTS(),
|
||||||
|
asr_service=_FakeASR(),
|
||||||
|
)
|
||||||
|
events: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def _capture_event(event: Dict[str, Any], priority: int = 20):
|
||||||
|
events.append(event)
|
||||||
|
|
||||||
|
async def _noop_speak(_text: str, *args, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(pipeline, "_send_event", _capture_event)
|
||||||
|
monkeypatch.setattr(pipeline, "_speak_sentence", _noop_speak)
|
||||||
|
return pipeline, events
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fastgpt_provider_managed_tool_resumes_provider_stream(monkeypatch):
|
||||||
|
llm = _FakeResumableLLM(timeout_ms=300000)
|
||||||
|
pipeline, events = _build_pipeline_with_custom_llm(monkeypatch, llm)
|
||||||
|
pipeline.apply_runtime_overrides({"output": {"mode": "text"}})
|
||||||
|
|
||||||
|
task = asyncio.create_task(pipeline._handle_turn("start fastgpt"))
|
||||||
|
for _ in range(200):
|
||||||
|
if any(event.get("type") == "assistant.tool_call" for event in events):
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.005)
|
||||||
|
|
||||||
|
tool_event = next(event for event in events if event.get("type") == "assistant.tool_call")
|
||||||
|
assert tool_event.get("executor") == "client"
|
||||||
|
assert tool_event.get("tool_name") == "fastgpt.interactive"
|
||||||
|
assert tool_event.get("timeout_ms") == 300000
|
||||||
|
assert tool_event.get("arguments", {}).get("context", {}).get("turn_id")
|
||||||
|
assert tool_event.get("arguments", {}).get("context", {}).get("response_id")
|
||||||
|
|
||||||
|
await pipeline.handle_tool_call_results(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"tool_call_id": "call_fastgpt_1",
|
||||||
|
"name": "fastgpt.interactive",
|
||||||
|
"output": {
|
||||||
|
"action": "submit",
|
||||||
|
"result": {"type": "userSelect", "selected": "pro"},
|
||||||
|
},
|
||||||
|
"status": {"code": 200, "message": "ok"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await task
|
||||||
|
|
||||||
|
finals = [event for event in events if event.get("type") == "assistant.response.final"]
|
||||||
|
assert finals
|
||||||
|
assert "provider resumed answer" in finals[-1].get("text", "")
|
||||||
|
assert llm.generate_stream_calls == 1
|
||||||
|
assert len(llm.resumed_results) == 1
|
||||||
|
assert llm.resumed_results[0]["tool_call_id"] == "call_fastgpt_1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fastgpt_provider_managed_tool_timeout_stops_without_generic_tool_prompt(monkeypatch):
|
||||||
|
llm = _FakeResumableLLM(timeout_ms=10)
|
||||||
|
pipeline, events = _build_pipeline_with_custom_llm(monkeypatch, llm)
|
||||||
|
pipeline.apply_runtime_overrides({"output": {"mode": "text"}})
|
||||||
|
|
||||||
|
await pipeline._handle_turn("start fastgpt")
|
||||||
|
|
||||||
|
tool_results = [event for event in events if event.get("type") == "assistant.tool_result"]
|
||||||
|
assert tool_results
|
||||||
|
assert tool_results[-1].get("result", {}).get("status", {}).get("code") == 504
|
||||||
|
finals = [event for event in events if event.get("type") == "assistant.response.final"]
|
||||||
|
assert not finals
|
||||||
|
assert llm.generate_stream_calls == 1
|
||||||
|
assert llm.resumed_results == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fastgpt_provider_managed_tool_chain_can_continue_after_third_result(monkeypatch):
|
||||||
|
llm = _FakeChainedResumableLLM(["call_fastgpt_1", "call_fastgpt_2", "call_fastgpt_3"])
|
||||||
|
pipeline, events = _build_pipeline_with_custom_llm(monkeypatch, llm)
|
||||||
|
pipeline.apply_runtime_overrides({"output": {"mode": "text"}})
|
||||||
|
|
||||||
|
task = asyncio.create_task(pipeline._handle_turn("start chained fastgpt"))
|
||||||
|
|
||||||
|
expected_call_ids = ["call_fastgpt_1", "call_fastgpt_2", "call_fastgpt_3"]
|
||||||
|
for idx, call_id in enumerate(expected_call_ids, start=1):
|
||||||
|
for _ in range(200):
|
||||||
|
seen_call_ids = [event.get("tool_call_id") for event in events if event.get("type") == "assistant.tool_call"]
|
||||||
|
if call_id in seen_call_ids:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.005)
|
||||||
|
|
||||||
|
await pipeline.handle_tool_call_results(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"tool_call_id": call_id,
|
||||||
|
"name": "fastgpt.interactive",
|
||||||
|
"output": {
|
||||||
|
"action": "submit",
|
||||||
|
"result": {"type": "userInput", "fields": {"result": f"value-{idx}"}},
|
||||||
|
},
|
||||||
|
"status": {"code": 200, "message": "ok"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
await task
|
||||||
|
|
||||||
|
finals = [event for event in events if event.get("type") == "assistant.response.final"]
|
||||||
|
assert finals
|
||||||
|
assert "completed after third interactive input" in finals[-1].get("text", "")
|
||||||
|
assert llm.generate_stream_calls == 1
|
||||||
|
assert len(llm.resumed_results) == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pending_client_tool_deadline_tracks_waiting_result(monkeypatch):
|
||||||
|
pipeline, _events = _build_pipeline(monkeypatch, [[LLMStreamEvent(type="done")]])
|
||||||
|
|
||||||
|
waiter = asyncio.create_task(pipeline._wait_for_single_tool_result("call_deadline", timeout_seconds=30))
|
||||||
|
for _ in range(50):
|
||||||
|
deadline = pipeline.pending_client_tool_deadline()
|
||||||
|
if deadline is not None:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.001)
|
||||||
|
|
||||||
|
deadline = pipeline.pending_client_tool_deadline()
|
||||||
|
assert deadline is not None
|
||||||
|
assert deadline > time.monotonic() + 25
|
||||||
|
|
||||||
|
await pipeline.handle_tool_call_results(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"tool_call_id": "call_deadline",
|
||||||
|
"name": "fastgpt.interactive",
|
||||||
|
"output": {"action": "submit", "result": {"type": "userInput", "fields": {"name": "Alice"}}},
|
||||||
|
"status": {"code": 200, "message": "ok"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await waiter
|
||||||
|
|
||||||
|
assert pipeline.pending_client_tool_deadline() is None
|
||||||
|
|||||||
25
web/components.json
Normal file
25
web/components.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -1,63 +1,37 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
// Button
|
// Shadcn UI Imports
|
||||||
|
import { Button as ShadcnButton } from './ui/button';
|
||||||
|
import { Input as ShadcnInput } from './ui/input';
|
||||||
|
import { Switch as ShadcnSwitch } from './ui/switch';
|
||||||
|
import { Card as ShadcnCard } from './ui/card';
|
||||||
|
import { Badge as ShadcnBadge } from './ui/badge';
|
||||||
|
import { TableHeader as ShadcnTableHeader, TableRow as ShadcnTableRow, TableHead as ShadcnTableHead, TableCell as ShadcnTableCell } from './ui/table';
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './ui/sheet';
|
||||||
|
import { Dialog as ShadcnDialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from './ui/dialog';
|
||||||
|
|
||||||
|
// Button Wrapper to match old API
|
||||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive';
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive';
|
||||||
size?: 'sm' | 'md' | 'lg' | 'icon';
|
size?: 'sm' | 'md' | 'lg' | 'icon';
|
||||||
}
|
}
|
||||||
|
export const Button: React.FC<ButtonProps> = ({ variant = 'primary', size = 'md', className, ...props }) => {
|
||||||
|
const vMap: any = { primary: 'default', secondary: 'secondary', outline: 'outline', ghost: 'ghost', destructive: 'destructive' };
|
||||||
|
const sMap: any = { sm: 'sm', md: 'default', lg: 'lg', icon: 'icon' };
|
||||||
|
return <ShadcnButton variant={vMap[variant] || 'default'} size={sMap[size] || 'default'} className={className} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
export const Button: React.FC<ButtonProps> = ({
|
// Input and Switch match seamlessly
|
||||||
className = '',
|
export const Input = ShadcnInput;
|
||||||
variant = 'primary',
|
export const Switch = ShadcnSwitch;
|
||||||
size = 'md',
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const baseStyles = "inline-flex items-center justify-center rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 active:scale-95";
|
|
||||||
|
|
||||||
const variants = {
|
|
||||||
// Primary: Glow effect
|
|
||||||
primary: "bg-primary text-primary-foreground shadow-[0_0_10px_rgba(6,182,212,0.5)] hover:bg-primary/90 hover:shadow-[0_0_15px_rgba(6,182,212,0.6)]",
|
|
||||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
|
||||||
outline: "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground hover:border-primary/50",
|
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
||||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
|
||||||
};
|
|
||||||
|
|
||||||
const sizes = {
|
|
||||||
sm: "h-8 px-3 text-xs",
|
|
||||||
md: "h-9 px-4 py-2",
|
|
||||||
lg: "h-10 px-8",
|
|
||||||
icon: "h-9 w-9",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`} {...props}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Input - Removed border, added subtle background
|
|
||||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
|
||||||
|
|
||||||
export const Input: React.FC<InputProps> = ({ className = '', ...props }) => {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
className={`flex h-9 w-full rounded-md bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {}
|
|
||||||
|
|
||||||
|
// Native Select Wrapper to avoid breaking consumers expecting <select><option></select>
|
||||||
|
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> { }
|
||||||
export const Select: React.FC<SelectProps> = ({ className = '', children, ...props }) => {
|
export const Select: React.FC<SelectProps> = ({ className = '', children, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
className={`flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
|
className={`flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm [&>option]:bg-card [&>option]:text-foreground ${className}`}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -65,143 +39,40 @@ export const Select: React.FC<SelectProps> = ({ className = '', children, ...pro
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SwitchProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> {
|
// Card Wrapper
|
||||||
checked: boolean;
|
interface CardProps extends React.HTMLAttributes<HTMLDivElement> { children: React.ReactNode; }
|
||||||
onCheckedChange: (checked: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Switch: React.FC<SwitchProps> = ({
|
|
||||||
checked,
|
|
||||||
onCheckedChange,
|
|
||||||
className = '',
|
|
||||||
disabled,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={checked}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => {
|
|
||||||
if (!disabled) onCheckedChange(!checked);
|
|
||||||
}}
|
|
||||||
className={`relative h-6 w-11 rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 ${checked ? 'bg-emerald-500/80' : 'bg-white/20'} ${className}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`absolute left-0.5 top-1/2 h-5 w-5 -translate-y-1/2 rounded-full bg-white shadow transition-transform ${checked ? 'translate-x-5' : 'translate-x-0'}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Card - Glassmorphism style, very subtle border
|
|
||||||
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
export const Card: React.FC<CardProps> = ({ children, className = '', ...props }) => (
|
export const Card: React.FC<CardProps> = ({ children, className = '', ...props }) => (
|
||||||
<div className={`rounded-xl border border-white/5 bg-card/40 backdrop-blur-md text-card-foreground shadow-sm ${className}`} {...props}>
|
<ShadcnCard className={`bg-card/40 backdrop-blur-md ${className}`} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</ShadcnCard>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Badge
|
// Badge Wrapper for old variants
|
||||||
interface BadgeProps {
|
interface BadgeProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
variant?: 'default' | 'success' | 'warning' | 'outline';
|
variant?: 'default' | 'success' | 'warning' | 'outline';
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
export const Badge: React.FC<BadgeProps> = ({ children, variant = 'default', className = '' }) => {
|
export const Badge: React.FC<BadgeProps> = ({ children, variant = 'default', className = '' }) => {
|
||||||
const styles = {
|
let cName = className;
|
||||||
default: "border-transparent bg-primary/20 text-primary hover:bg-primary/30 border border-primary/20",
|
let shadcnVariant: any = variant === 'outline' ? 'outline' : 'default';
|
||||||
success: "border-transparent bg-green-500/20 text-green-400 border border-green-500/20",
|
|
||||||
warning: "border-transparent bg-yellow-500/20 text-yellow-400 border border-yellow-500/20",
|
if (variant === 'success') {
|
||||||
outline: "text-foreground border border-white/10 hover:bg-accent hover:text-accent-foreground",
|
cName += ' border-transparent bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30';
|
||||||
};
|
} else if (variant === 'warning') {
|
||||||
return (
|
cName += ' border-transparent bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30';
|
||||||
<div className={`inline-flex items-center rounded-md px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${styles[variant]} ${className}`}>
|
}
|
||||||
{children}
|
|
||||||
</div>
|
return <ShadcnBadge variant={shadcnVariant} className={cName}>{children}</ShadcnBadge>;
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Table - Subtle borders
|
// Table Exports
|
||||||
export const TableHeader: React.FC<{ children: React.ReactNode }> = ({ children }) => <thead className="[&_tr]:border-b [&_tr]:border-white/5">{children}</thead>;
|
export const TableHeader = ShadcnTableHeader;
|
||||||
|
export const TableRow = ShadcnTableRow;
|
||||||
|
export const TableHead = ShadcnTableHead;
|
||||||
|
export const TableCell = ShadcnTableCell;
|
||||||
|
|
||||||
interface TableRowProps extends React.HTMLAttributes<HTMLTableRowElement> {
|
// Drawer (Side Sheet Wrapper)
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
export const TableRow: React.FC<TableRowProps> = ({ children, className = '', ...props }) => <tr className={`border-b border-white/5 transition-colors hover:bg-white/5 data-[state=selected]:bg-muted ${className}`} {...props}>{children}</tr>;
|
|
||||||
|
|
||||||
interface TableHeadProps extends React.ThHTMLAttributes<HTMLTableCellElement> {
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
export const TableHead: React.FC<TableHeadProps> = ({ children, className = '', ...props }) => <th className={`h-10 px-4 text-left align-middle text-sm font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 ${className}`} {...props}>{children}</th>;
|
|
||||||
|
|
||||||
interface TableCellProps extends React.TdHTMLAttributes<HTMLTableCellElement> {
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
export const TableCell: React.FC<TableCellProps> = ({ children, className = '', ...props }) => <td className={`p-4 align-middle text-sm [&:has([role=checkbox])]:pr-0 ${className}`} {...props}>{children}</td>;
|
|
||||||
|
|
||||||
interface LibraryPageShellProps {
|
|
||||||
title: string;
|
|
||||||
primaryAction: React.ReactNode;
|
|
||||||
filterBar: React.ReactNode;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LibraryPageShell: React.FC<LibraryPageShellProps> = ({ title, primaryAction, filterBar, children }) => {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 animate-in fade-in py-4 pb-10">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-white">{title}</h1>
|
|
||||||
{primaryAction}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
|
|
||||||
{filterBar}
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TableStatusRowProps {
|
|
||||||
colSpan: number;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TableStatusRow: React.FC<TableStatusRowProps> = ({ colSpan, text }) => {
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={colSpan} className="text-center py-8 text-muted-foreground">
|
|
||||||
{text}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface LibraryActionCellProps {
|
|
||||||
previewAction?: React.ReactNode;
|
|
||||||
editAction: React.ReactNode;
|
|
||||||
deleteAction: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LibraryActionCell: React.FC<LibraryActionCellProps> = ({ previewAction, editAction, deleteAction }) => {
|
|
||||||
return (
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{previewAction}
|
|
||||||
{editAction}
|
|
||||||
{deleteAction}
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Drawer (Side Sheet)
|
|
||||||
interface DrawerProps {
|
interface DrawerProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -209,32 +80,25 @@ interface DrawerProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Drawer: React.FC<DrawerProps> = ({ isOpen, onClose, title, className, children }) => {
|
export const Drawer: React.FC<DrawerProps> = ({ isOpen, onClose, title, className, children }) => {
|
||||||
if (!isOpen) return null;
|
// Pass `!w-[85vw]` logic directly down from the parent to naturally override Shadcn specificities safely.
|
||||||
|
const containerClasses = className || 'w-full max-w-md sm:max-w-lg';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex">
|
<Sheet open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||||
{/* Backdrop */}
|
<SheetContent className={`flex flex-col p-6 bg-background/95 backdrop-blur-md border-l border-white/10 shadow-2xl [&>button]:top-5 [&>button]:right-5 ${containerClasses}`}>
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity" onClick={onClose} />
|
<SheetHeader className="mb-2 shrink-0 p-0 text-left">
|
||||||
|
<SheetTitle className="text-lg font-semibold">{title}</SheetTitle>
|
||||||
{/* Drawer Content */}
|
</SheetHeader>
|
||||||
<div className={`relative ml-auto flex h-full w-full flex-col bg-background/95 border-l border-white/10 p-6 shadow-2xl animate-in slide-in-from-right ${className || 'max-w-md sm:max-w-lg'}`}>
|
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar flex flex-col">
|
||||||
<div className="flex items-center justify-between mb-4 shrink-0">
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
|
||||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SheetContent>
|
||||||
</div>
|
</Sheet>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dialog (Modal)
|
// Dialog (Modal Wrapper)
|
||||||
interface DialogProps {
|
interface DialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -243,33 +107,66 @@ interface DialogProps {
|
|||||||
footer?: React.ReactNode;
|
footer?: React.ReactNode;
|
||||||
contentClassName?: string;
|
contentClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Dialog: React.FC<DialogProps> = ({ isOpen, onClose, title, children, footer, contentClassName }) => {
|
export const Dialog: React.FC<DialogProps> = ({ isOpen, onClose, title, children, footer, contentClassName }) => {
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<ShadcnDialog open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm transition-opacity animate-in fade-in" onClick={onClose} />
|
<DialogContent className={`max-h-[95vh] flex flex-col ${contentClassName || ''}`}>
|
||||||
<div className={`relative z-50 w-full max-w-lg rounded-xl border border-white/10 bg-card p-6 shadow-2xl animate-in zoom-in-95 duration-200 ${contentClassName || ''}`}>
|
<DialogHeader>
|
||||||
<div className="flex flex-col space-y-1.5 text-center sm:text-left mb-4">
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<h2 className="text-lg font-semibold leading-none tracking-tight">{title}</h2>
|
</DialogHeader>
|
||||||
</div>
|
<div className="py-2 flex-1 min-h-0 overflow-y-auto pr-2 custom-scrollbar">
|
||||||
<div className="py-4">
|
{children}
|
||||||
{children}
|
</div>
|
||||||
</div>
|
{footer && <DialogFooter>{footer}</DialogFooter>}
|
||||||
{footer && (
|
</DialogContent>
|
||||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-2">
|
</ShadcnDialog>
|
||||||
{footer}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------
|
||||||
|
// Custom Application Layout Components
|
||||||
|
// ---------------------------------------------
|
||||||
|
interface LibraryPageShellProps {
|
||||||
|
title: string;
|
||||||
|
primaryAction: React.ReactNode;
|
||||||
|
filterBar: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
export const LibraryPageShell: React.FC<LibraryPageShellProps> = ({ title, primaryAction, filterBar, children }) => (
|
||||||
|
<div className="space-y-6 animate-in fade-in py-4 pb-10">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-foreground">{title}</h1>
|
||||||
|
{primaryAction}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 bg-card/50 p-4 rounded-lg border border-border shadow-sm">
|
||||||
|
{filterBar}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface TableStatusRowProps {
|
||||||
|
colSpan: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
export const TableStatusRow: React.FC<TableStatusRowProps> = ({ colSpan, text }) => (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={colSpan} className="text-center py-8 text-muted-foreground">
|
||||||
|
{text}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface LibraryActionCellProps {
|
||||||
|
previewAction?: React.ReactNode;
|
||||||
|
editAction: React.ReactNode;
|
||||||
|
deleteAction: React.ReactNode;
|
||||||
|
}
|
||||||
|
export const LibraryActionCell: React.FC<LibraryActionCellProps> = ({ previewAction, editAction, deleteAction }) => (
|
||||||
|
<TableCell className="text-right whitespace-nowrap">
|
||||||
|
{previewAction}
|
||||||
|
{editAction}
|
||||||
|
{deleteAction}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
|||||||
52
web/components/ui/badge.tsx
Normal file
52
web/components/ui/badge.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
props: mergeProps<"span">(
|
||||||
|
{
|
||||||
|
className: cn(badgeVariants({ variant }), className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "badge",
|
||||||
|
variant,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
58
web/components/ui/button.tsx
Normal file
58
web/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
103
web/components/ui/card.tsx
Normal file
103
web/components/ui/card.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn(
|
||||||
|
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
157
web/components/ui/dialog.tsx
Normal file
157
web/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Backdrop
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Popup.Props & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Popup
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Popup>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||||
|
Close
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-base leading-none font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
20
web/components/ui/input.tsx
Normal file
20
web/components/ui/input.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<InputPrimitive
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
201
web/components/ui/select.tsx
Normal file
201
web/components/ui/select.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Value
|
||||||
|
data-slot="select-value"
|
||||||
|
className={cn("flex flex-1 text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Trigger.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon
|
||||||
|
render={
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
alignItemWithTrigger = true,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
SelectPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Positioner
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
alignItemWithTrigger={alignItemWithTrigger}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Popup
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={alignItemWithTrigger}
|
||||||
|
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Popup>
|
||||||
|
</SelectPrimitive.Positioner>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.GroupLabel.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.GroupLabel
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.ItemText>
|
||||||
|
<SelectPrimitive.ItemIndicator
|
||||||
|
render={
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpArrow
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollUpArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownArrow
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollDownArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
133
web/components/ui/sheet.tsx
Normal file
133
web/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Backdrop
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Popup.Props & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Popup
|
||||||
|
data-slot="sheet-content"
|
||||||
|
data-side={side}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close
|
||||||
|
data-slot="sheet-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-3 right-3"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Popup>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("text-base font-medium text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
30
web/components/ui/switch.tsx
Normal file
30
web/components/ui/switch.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: SwitchPrimitive.Root.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
116
web/components/ui/table.tsx
Normal file
116
web/components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
158
web/index.css
Normal file
158
web/index.css
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
@import "@fontsource-variable/geist";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.58 0.22 27);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.809 0.105 251.813);
|
||||||
|
--chart-2: oklch(0.623 0.214 259.815);
|
||||||
|
--chart-3: oklch(0.546 0.245 262.881);
|
||||||
|
--chart-4: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-5: oklch(0.424 0.199 265.638);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
/* Using the exact colors from your previous custom theme */
|
||||||
|
--background: hsl(224, 71%, 4%);
|
||||||
|
--foreground: hsl(210, 40%, 98%);
|
||||||
|
--card: hsl(224, 71%, 5%);
|
||||||
|
--card-foreground: hsl(210, 40%, 98%);
|
||||||
|
--popover: hsl(224, 71%, 4%);
|
||||||
|
--popover-foreground: hsl(210, 40%, 98%);
|
||||||
|
--primary: hsl(196, 100%, 50%);
|
||||||
|
--primary-foreground: hsl(222.2, 47.4%, 11.2%);
|
||||||
|
--secondary: hsl(217.2, 32.6%, 17.5%);
|
||||||
|
--secondary-foreground: hsl(210, 40%, 98%);
|
||||||
|
--muted: hsl(217.2, 32.6%, 17.5%);
|
||||||
|
--muted-foreground: hsl(215, 20.2%, 65.1%);
|
||||||
|
--accent: hsl(217.2, 32.6%, 17.5%);
|
||||||
|
--accent-foreground: hsl(210, 40%, 98%);
|
||||||
|
--destructive: hsl(0, 62.8%, 30.6%);
|
||||||
|
--destructive-foreground: hsl(210, 40%, 98%);
|
||||||
|
--border: hsl(217.2, 32.6%, 17.5%);
|
||||||
|
--input: hsl(217.2, 32.6%, 17.5%);
|
||||||
|
--ring: hsl(196, 100%, 50%);
|
||||||
|
--chart-1: oklch(0.809 0.105 251.813);
|
||||||
|
--chart-2: oklch(0.623 0.214 259.815);
|
||||||
|
--chart-3: oklch(0.546 0.245 262.881);
|
||||||
|
--chart-4: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-5: oklch(0.424 0.199 265.638);
|
||||||
|
--sidebar: hsl(224, 71%, 5%);
|
||||||
|
--sidebar-foreground: hsl(210, 40%, 98%);
|
||||||
|
--sidebar-primary: hsl(196, 100%, 50%);
|
||||||
|
--sidebar-primary-foreground: hsl(222.2, 47.4%, 11.2%);
|
||||||
|
--sidebar-accent: hsl(217.2, 32.6%, 17.5%);
|
||||||
|
--sidebar-accent-foreground: hsl(210, 40%, 98%);
|
||||||
|
--sidebar-border: hsl(217.2, 32.6%, 17.5%);
|
||||||
|
--sidebar-ring: hsl(196, 100%, 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-sans: 'Geist Variable', sans-serif;
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
/* Subtle Grid Pattern */
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
||||||
|
background-size: 40px 40px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar styled for dark mode */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
113
web/index.html
113
web/index.html
@@ -1,122 +1,11 @@
|
|||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AI视频助手</title>
|
<title>AI视频助手</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script>
|
|
||||||
tailwind.config = {
|
|
||||||
darkMode: 'class',
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
border: "hsl(var(--border))",
|
|
||||||
input: "hsl(var(--input))",
|
|
||||||
ring: "hsl(var(--ring))",
|
|
||||||
background: "hsl(var(--background))",
|
|
||||||
foreground: "hsl(var(--foreground))",
|
|
||||||
primary: {
|
|
||||||
DEFAULT: "hsl(var(--primary))",
|
|
||||||
foreground: "hsl(var(--primary-foreground))",
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
DEFAULT: "hsl(var(--secondary))",
|
|
||||||
foreground: "hsl(var(--secondary-foreground))",
|
|
||||||
},
|
|
||||||
destructive: {
|
|
||||||
DEFAULT: "hsl(var(--destructive))",
|
|
||||||
foreground: "hsl(var(--destructive-foreground))",
|
|
||||||
},
|
|
||||||
muted: {
|
|
||||||
DEFAULT: "hsl(var(--muted))",
|
|
||||||
foreground: "hsl(var(--muted-foreground))",
|
|
||||||
},
|
|
||||||
accent: {
|
|
||||||
DEFAULT: "hsl(var(--accent))",
|
|
||||||
foreground: "hsl(var(--accent-foreground))",
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
DEFAULT: "hsl(var(--card))",
|
|
||||||
foreground: "hsl(var(--card-foreground))",
|
|
||||||
},
|
|
||||||
popover: {
|
|
||||||
DEFAULT: "hsl(var(--popover))",
|
|
||||||
foreground: "hsl(var(--popover-foreground))",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
lg: "var(--radius)",
|
|
||||||
md: "calc(var(--radius) - 2px)",
|
|
||||||
sm: "calc(var(--radius) - 4px)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
/* Tech/Dark Theme Palette */
|
|
||||||
--background: 224 71% 4%; /* Very Dark Blue #020817 */
|
|
||||||
--foreground: 210 40% 98%; /* Light Gray/White */
|
|
||||||
|
|
||||||
--card: 224 71% 5%; /* Slightly lighter than bg, will use transparency */
|
|
||||||
--card-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--popover: 224 71% 4%;
|
|
||||||
--popover-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--primary: 196 100% 50%; /* Cyan/Electric Blue for high tech feel */
|
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
|
||||||
|
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
|
||||||
--secondary-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--muted: 217.2 32.6% 17.5%;
|
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
|
||||||
|
|
||||||
--accent: 217.2 32.6% 17.5%;
|
|
||||||
--accent-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--destructive: 0 62.8% 30.6%;
|
|
||||||
--destructive-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--border: 217.2 32.6% 17.5%;
|
|
||||||
--input: 217.2 32.6% 17.5%;
|
|
||||||
--ring: 196 100% 50%;
|
|
||||||
|
|
||||||
--radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
background-color: hsl(var(--background));
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
/* Subtle Grid Pattern */
|
|
||||||
background-image:
|
|
||||||
linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
|
||||||
background-size: 40px 40px;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom Scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: hsl(var(--muted));
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: hsl(var(--primary));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client';
|
|||||||
import { QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { queryClient } from './services/queryClient';
|
import { queryClient } from './services/queryClient';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
const rootElement = document.getElementById('root');
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
|
|||||||
6
web/lib/utils.ts
Normal file
6
web/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
BIN
web/old_ui.txt
Normal file
BIN
web/old_ui.txt
Normal file
Binary file not shown.
4398
web/package-lock.json
generated
4398
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,17 +9,28 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@base-ui/react": "^1.2.0",
|
||||||
"lucide-react": "^0.563.0",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"zustand": "^5.0.8",
|
|
||||||
"react-router-dom": "^7.13.0",
|
|
||||||
"@google/genai": "^1.39.0",
|
"@google/genai": "^1.39.0",
|
||||||
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@tanstack/react-query": "^5.90.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.13.0",
|
||||||
|
"shadcn": "^4.0.2",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
"autoprefixer": "^10.4.27",
|
||||||
|
"postcss": "^8.5.8",
|
||||||
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,7 @@ const mapAssistant = (raw: AnyRecord): Assistant => ({
|
|||||||
configMode: readField(raw, ['configMode', 'config_mode'], 'platform') as 'platform' | 'dify' | 'fastgpt' | 'none',
|
configMode: readField(raw, ['configMode', 'config_mode'], 'platform') as 'platform' | 'dify' | 'fastgpt' | 'none',
|
||||||
apiUrl: readField(raw, ['apiUrl', 'api_url'], ''),
|
apiUrl: readField(raw, ['apiUrl', 'api_url'], ''),
|
||||||
apiKey: readField(raw, ['apiKey', 'api_key'], ''),
|
apiKey: readField(raw, ['apiKey', 'api_key'], ''),
|
||||||
|
appId: readField(raw, ['appId', 'app_id'], ''),
|
||||||
llmModelId: readField(raw, ['llmModelId', 'llm_model_id'], ''),
|
llmModelId: readField(raw, ['llmModelId', 'llm_model_id'], ''),
|
||||||
asrModelId: readField(raw, ['asrModelId', 'asr_model_id'], ''),
|
asrModelId: readField(raw, ['asrModelId', 'asr_model_id'], ''),
|
||||||
embeddingModelId: readField(raw, ['embeddingModelId', 'embedding_model_id'], ''),
|
embeddingModelId: readField(raw, ['embeddingModelId', 'embedding_model_id'], ''),
|
||||||
@@ -302,6 +303,7 @@ export const createAssistant = async (data: Partial<Assistant>): Promise<Assista
|
|||||||
configMode: data.configMode || 'platform',
|
configMode: data.configMode || 'platform',
|
||||||
apiUrl: data.apiUrl || '',
|
apiUrl: data.apiUrl || '',
|
||||||
apiKey: data.apiKey || '',
|
apiKey: data.apiKey || '',
|
||||||
|
appId: data.appId || '',
|
||||||
llmModelId: data.llmModelId || '',
|
llmModelId: data.llmModelId || '',
|
||||||
asrModelId: data.asrModelId || '',
|
asrModelId: data.asrModelId || '',
|
||||||
embeddingModelId: data.embeddingModelId || '',
|
embeddingModelId: data.embeddingModelId || '',
|
||||||
@@ -335,6 +337,7 @@ export const updateAssistant = async (id: string, data: Partial<Assistant>): Pro
|
|||||||
configMode: data.configMode,
|
configMode: data.configMode,
|
||||||
apiUrl: data.apiUrl,
|
apiUrl: data.apiUrl,
|
||||||
apiKey: data.apiKey,
|
apiKey: data.apiKey,
|
||||||
|
appId: data.appId,
|
||||||
llmModelId: data.llmModelId,
|
llmModelId: data.llmModelId,
|
||||||
asrModelId: data.asrModelId,
|
asrModelId: data.asrModelId,
|
||||||
embeddingModelId: data.embeddingModelId,
|
embeddingModelId: data.embeddingModelId,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface Assistant {
|
|||||||
configMode?: 'platform' | 'dify' | 'fastgpt' | 'none';
|
configMode?: 'platform' | 'dify' | 'fastgpt' | 'none';
|
||||||
apiUrl?: string;
|
apiUrl?: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
|
appId?: string;
|
||||||
llmModelId?: string;
|
llmModelId?: string;
|
||||||
asrModelId?: string;
|
asrModelId?: string;
|
||||||
embeddingModelId?: string;
|
embeddingModelId?: string;
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { defineConfig, loadEnv } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const env = loadEnv(mode, '.', '');
|
const env = loadEnv(mode, '.', '');
|
||||||
return {
|
return {
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
},
|
},
|
||||||
plugins: [react()],
|
plugins: [react(), tailwindcss()],
|
||||||
define: {
|
define: {
|
||||||
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, '.'),
|
'@': path.resolve(__dirname, '.'),
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user