mirror of
https://github.com/langgenius/dify.git
synced 2026-07-20 09:38:32 +08:00
refactor(chat): modernize speech-to-text pipeline (#38653)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Yansong Zhang <916125788@qq.com>
This commit is contained in:
parent
408cd12835
commit
ab521c4cfd
23
.github/workflows/post-merge.yml
vendored
23
.github/workflows/post-merge.yml
vendored
@ -25,8 +25,15 @@ jobs:
|
||||
filters: |
|
||||
external_e2e:
|
||||
- 'e2e/features/agent-v2/**'
|
||||
- 'e2e/features/step-definitions/agent-v2/**'
|
||||
- 'e2e/features/support/**'
|
||||
- 'e2e/fixtures/test-materials/**'
|
||||
- 'e2e/scripts/**'
|
||||
- 'e2e/support/**'
|
||||
- 'e2e/cucumber.config.ts'
|
||||
- 'e2e/package.json'
|
||||
- 'e2e/test-env.ts'
|
||||
- 'e2e/tsx-register.js'
|
||||
- '.github/workflows/post-merge.yml'
|
||||
- '.github/workflows/web-e2e.yml'
|
||||
- '.github/actions/setup-web/**'
|
||||
@ -40,9 +47,25 @@ jobs:
|
||||
- 'api/services/plugin/**'
|
||||
- 'api/core/tools/**'
|
||||
- 'api/services/tools/**'
|
||||
- 'packages/contracts/generated/api/console/agent/**'
|
||||
- 'packages/contracts/generated/api/console/orpc.gen.ts'
|
||||
- 'web/features/agent-v2/**'
|
||||
- 'web/app/(commonLayout)/agents/**'
|
||||
- 'web/app/(commonLayout)/@detailSidebar/agents/**'
|
||||
- 'web/app/(commonLayout)/roster/**'
|
||||
- 'web/app/components/base/chat/chat/**'
|
||||
- 'web/app/components/base/voice-input/**'
|
||||
- 'web/app/components/workflow/nodes/agent-v2/**'
|
||||
- 'web/i18n/en-US/agent-v-2.json'
|
||||
- 'web/i18n/en-US/common.json'
|
||||
- 'web/service/base.ts'
|
||||
- 'web/service/client.ts'
|
||||
- 'web/service/console-link.ts'
|
||||
- 'web/service/console-openapi-url.ts'
|
||||
- 'web/service/console-router-loader.ts'
|
||||
- 'web/service/share.ts'
|
||||
- 'web/package.json'
|
||||
- 'pnpm-workspace.yaml'
|
||||
|
||||
external-e2e:
|
||||
name: External Runtime E2E
|
||||
|
||||
2
.github/workflows/web-e2e.yml
vendored
2
.github/workflows/web-e2e.yml
vendored
@ -80,6 +80,8 @@ jobs:
|
||||
E2E_MARKETPLACE_PLUGIN_IDS: ${{ vars.E2E_MARKETPLACE_PLUGIN_IDS }}
|
||||
E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS: ${{ vars.E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS }}
|
||||
E2E_MODEL_PROVIDER_CREDENTIALS_JSON: ${{ secrets.E2E_MODEL_PROVIDER_CREDENTIALS_JSON }}
|
||||
E2E_SPEECH_TO_TEXT_MODEL_NAME: ${{ vars.E2E_SPEECH_TO_TEXT_MODEL_NAME || 'gpt-4o-mini-transcribe' }}
|
||||
E2E_SPEECH_TO_TEXT_MODEL_PROVIDER: ${{ vars.E2E_SPEECH_TO_TEXT_MODEL_PROVIDER || 'openai' }}
|
||||
E2E_START_AGENT_BACKEND: ${{ vars.E2E_START_AGENT_BACKEND || '1' }}
|
||||
E2E_STABLE_MODEL_NAME: ${{ vars.E2E_STABLE_MODEL_NAME || 'gpt-5-nano' }}
|
||||
E2E_STABLE_MODEL_PROVIDER: ${{ vars.E2E_STABLE_MODEL_PROVIDER || 'openai' }}
|
||||
|
||||
@ -1,13 +1,18 @@
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, RootModel
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
from pydantic import BaseModel, ConfigDict, Field, RootModel
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.exceptions import HTTPException, InternalServerError
|
||||
|
||||
import services
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.wraps import enforce_rbac_access
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.error import (
|
||||
AppUnavailableError,
|
||||
AudioTooLargeError,
|
||||
@ -17,15 +22,19 @@ from controllers.console.app.error import (
|
||||
ProviderNotInitializeError,
|
||||
ProviderNotSupportSpeechToTextError,
|
||||
ProviderQuotaExceededError,
|
||||
SpeechToTextDisabledError,
|
||||
UnsupportedAudioTypeError,
|
||||
)
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.app.wraps import get_app_model, with_session
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from extensions.ext_database import db
|
||||
@ -33,13 +42,17 @@ from fields.base import ResponseModel
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs.helper import dump_response
|
||||
from libs.login import current_user, login_required
|
||||
from models import App, AppMode
|
||||
from models import Account, App, AppMode
|
||||
from models.agent import AgentConfigDraftType
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.app_ref_service import AppRefService
|
||||
from services.audio_service import AudioService
|
||||
from services.errors.audio import (
|
||||
AudioTooLargeServiceError,
|
||||
NoAudioUploadedServiceError,
|
||||
ProviderNotSupportSpeechToTextServiceError,
|
||||
SpeechToTextDisabledServiceError,
|
||||
UnsupportedAudioTypeServiceError,
|
||||
)
|
||||
|
||||
@ -61,6 +74,12 @@ class AudioTranscriptResponse(ResponseModel):
|
||||
text: str = Field(description="Transcribed text from audio")
|
||||
|
||||
|
||||
class AgentAudioTranscriptFormPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
draft_type: AgentConfigDraftType = AgentConfigDraftType.DRAFT
|
||||
|
||||
|
||||
class TextToSpeechVoiceResponse(ResponseModel):
|
||||
# see api/core/plugin/impl/model.py
|
||||
name: str = Field(description="Voice display name")
|
||||
@ -71,7 +90,7 @@ class TextToSpeechVoiceListResponse(RootModel[list[TextToSpeechVoiceResponse]]):
|
||||
root: list[TextToSpeechVoiceResponse] = Field(description="Available voices")
|
||||
|
||||
|
||||
register_schema_models(console_ns, TextToSpeechPayload, TextToSpeechVoiceQuery)
|
||||
register_schema_models(console_ns, AgentAudioTranscriptFormPayload, TextToSpeechPayload, TextToSpeechVoiceQuery)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AudioTranscriptResponse,
|
||||
@ -79,12 +98,90 @@ register_response_schema_models(
|
||||
TextToSpeechVoiceListResponse,
|
||||
)
|
||||
|
||||
_AUDIO_TRANSCRIPT_FILE_PARAM = {
|
||||
"description": "MP3 audio to transcribe",
|
||||
"in": "formData",
|
||||
"type": "file",
|
||||
"required": True,
|
||||
}
|
||||
_AGENT_AUDIO_TRANSCRIPT_PARAMS = {
|
||||
"file": _AUDIO_TRANSCRIPT_FILE_PARAM,
|
||||
"draft_type": {
|
||||
"description": "Agent debug config source",
|
||||
"in": "formData",
|
||||
"type": "string",
|
||||
"enum": [draft_type.value for draft_type in AgentConfigDraftType],
|
||||
"default": AgentConfigDraftType.DRAFT.value,
|
||||
"required": False,
|
||||
},
|
||||
}
|
||||
_CONSOLE_AUDIO_TRANSCRIPT_APP_MODES = [
|
||||
AppMode.CHAT,
|
||||
AppMode.AGENT_CHAT,
|
||||
AppMode.ADVANCED_CHAT,
|
||||
AppMode.AGENT,
|
||||
]
|
||||
|
||||
|
||||
def _transcribe_audio_to_text(
|
||||
*,
|
||||
app_model: App,
|
||||
file: FileStorage | None,
|
||||
agent_soul: AgentSoulConfig | None = None,
|
||||
) -> dict[str, str]:
|
||||
try:
|
||||
if agent_soul is None:
|
||||
response = AudioService.transcript_asr(
|
||||
app_model=app_model,
|
||||
file=file,
|
||||
end_user=None,
|
||||
)
|
||||
else:
|
||||
response = AudioService.transcript_agent_asr(
|
||||
app_model=app_model,
|
||||
agent_soul=agent_soul,
|
||||
file=file,
|
||||
end_user=None,
|
||||
)
|
||||
return dump_response(AudioTranscriptResponse, response)
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
logger.exception("App model config broken.")
|
||||
raise AppUnavailableError()
|
||||
except NoAudioUploadedServiceError:
|
||||
raise NoAudioUploadedError()
|
||||
except AudioTooLargeServiceError as e:
|
||||
raise AudioTooLargeError(str(e))
|
||||
except UnsupportedAudioTypeServiceError:
|
||||
raise UnsupportedAudioTypeError()
|
||||
except ProviderNotSupportSpeechToTextServiceError:
|
||||
raise ProviderNotSupportSpeechToTextError()
|
||||
except SpeechToTextDisabledServiceError:
|
||||
raise SpeechToTextDisabledError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
raise ProviderQuotaExceededError()
|
||||
except ModelCurrentlyNotSupportError:
|
||||
raise ProviderModelCurrentlyNotSupportError()
|
||||
except InvokeError as e:
|
||||
raise CompletionRequestError(e.description)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("Failed to transcribe audio to text")
|
||||
raise InternalServerError() from e
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/audio-to-text")
|
||||
class ChatMessageAudioApi(Resource):
|
||||
@console_ns.doc("chat_message_audio_transcript")
|
||||
@console_ns.doc(description="Transcript audio to text for chat messages")
|
||||
@console_ns.doc(params={"app_id": "App ID"})
|
||||
@console_ns.doc(
|
||||
consumes=["multipart/form-data"],
|
||||
params={"app_id": "App ID", "file": _AUDIO_TRANSCRIPT_FILE_PARAM},
|
||||
)
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Audio transcription successful",
|
||||
@ -95,42 +192,63 @@ class ChatMessageAudioApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
@get_app_model(mode=_CONSOLE_AUDIO_TRANSCRIPT_APP_MODES)
|
||||
def post(self, app_model: App):
|
||||
file = request.files["file"]
|
||||
return _transcribe_audio_to_text(app_model=app_model, file=request.files.get("file"))
|
||||
|
||||
try:
|
||||
response = AudioService.transcript_asr(
|
||||
app_model=app_model,
|
||||
file=file,
|
||||
end_user=None,
|
||||
)
|
||||
|
||||
return dump_response(AudioTranscriptResponse, response)
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
logger.exception("App model config broken.")
|
||||
raise AppUnavailableError()
|
||||
except NoAudioUploadedServiceError:
|
||||
raise NoAudioUploadedError()
|
||||
except AudioTooLargeServiceError as e:
|
||||
raise AudioTooLargeError(str(e))
|
||||
except UnsupportedAudioTypeServiceError:
|
||||
raise UnsupportedAudioTypeError()
|
||||
except ProviderNotSupportSpeechToTextServiceError:
|
||||
raise ProviderNotSupportSpeechToTextError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
raise ProviderQuotaExceededError()
|
||||
except ModelCurrentlyNotSupportError:
|
||||
raise ProviderModelCurrentlyNotSupportError()
|
||||
except InvokeError as e:
|
||||
raise CompletionRequestError(e.description)
|
||||
except ValueError as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.exception("Failed to handle post request to ChatMessageAudioApi")
|
||||
raise InternalServerError()
|
||||
@console_ns.route("/agent/<uuid:agent_id>/audio-to-text")
|
||||
class AgentChatMessageAudioApi(Resource):
|
||||
@console_ns.doc("agent_chat_message_audio_transcript")
|
||||
@console_ns.doc(description="Transcribe audio using the current Agent debug configuration")
|
||||
@console_ns.doc(
|
||||
consumes=["multipart/form-data"],
|
||||
params={"agent_id": "Agent ID", **_AGENT_AUDIO_TRANSCRIPT_PARAMS},
|
||||
)
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Audio transcription successful",
|
||||
console_ns.models[AudioTranscriptResponse.__name__],
|
||||
)
|
||||
@console_ns.response(400, "Bad request - Speech to text disabled or unsupported audio")
|
||||
@console_ns.response(404, "Agent or build draft not found")
|
||||
@console_ns.response(413, "Audio file too large")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(
|
||||
self,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
agent_id: UUID,
|
||||
):
|
||||
payload = AgentAudioTranscriptFormPayload.model_validate(request.form.to_dict(flat=True))
|
||||
app_model = resolve_agent_runtime_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
# Agent routes expose Agent ids, while APP RBAC is keyed by the resolved runtime App id.
|
||||
enforce_rbac_access(
|
||||
tenant_id=current_tenant_id,
|
||||
account_id=current_user.id,
|
||||
resource_type=RBACResourceScope.APP,
|
||||
scene=RBACPermission.APP_TEST_AND_RUN,
|
||||
path_args={"app_id": app_model.id},
|
||||
)
|
||||
agent_soul = AgentComposerService.load_agent_soul_for_debug(
|
||||
tenant_id=current_tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
draft_type=payload.draft_type,
|
||||
session=session,
|
||||
)
|
||||
return _transcribe_audio_to_text(
|
||||
app_model=app_model,
|
||||
agent_soul=agent_soul,
|
||||
file=request.files.get("file"),
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/text-to-audio")
|
||||
|
||||
@ -79,6 +79,12 @@ class ProviderNotSupportSpeechToTextError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class SpeechToTextDisabledError(BaseHTTPException):
|
||||
error_code = "speech_to_text_disabled"
|
||||
description = "Speech to text is disabled."
|
||||
code = 400
|
||||
|
||||
|
||||
class DraftWorkflowNotExist(BaseHTTPException):
|
||||
error_code = "draft_workflow_not_exist"
|
||||
description = "Draft workflow need to be initialized."
|
||||
|
||||
@ -16,6 +16,7 @@ from controllers.console.app.error import (
|
||||
ProviderNotInitializeError,
|
||||
ProviderNotSupportSpeechToTextError,
|
||||
ProviderQuotaExceededError,
|
||||
SpeechToTextDisabledError,
|
||||
UnsupportedAudioTypeError,
|
||||
)
|
||||
from controllers.console.explore.wraps import InstalledAppResource
|
||||
@ -30,6 +31,7 @@ from services.errors.audio import (
|
||||
AudioTooLargeServiceError,
|
||||
NoAudioUploadedServiceError,
|
||||
ProviderNotSupportSpeechToTextServiceError,
|
||||
SpeechToTextDisabledServiceError,
|
||||
UnsupportedAudioTypeServiceError,
|
||||
)
|
||||
|
||||
@ -69,6 +71,8 @@ class ChatAudioApi(InstalledAppResource):
|
||||
raise UnsupportedAudioTypeError()
|
||||
except ProviderNotSupportSpeechToTextServiceError:
|
||||
raise ProviderNotSupportSpeechToTextError()
|
||||
except SpeechToTextDisabledServiceError:
|
||||
raise SpeechToTextDisabledError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
|
||||
@ -34,6 +34,7 @@ from controllers.console.app.error import (
|
||||
ProviderNotInitializeError,
|
||||
ProviderNotSupportSpeechToTextError,
|
||||
ProviderQuotaExceededError,
|
||||
SpeechToTextDisabledError,
|
||||
UnsupportedAudioTypeError,
|
||||
)
|
||||
from controllers.console.app.wraps import get_app_model_with_trial, with_session
|
||||
@ -75,6 +76,7 @@ from services.errors.audio import (
|
||||
AudioTooLargeServiceError,
|
||||
NoAudioUploadedServiceError,
|
||||
ProviderNotSupportSpeechToTextServiceError,
|
||||
SpeechToTextDisabledServiceError,
|
||||
UnsupportedAudioTypeServiceError,
|
||||
)
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
@ -606,6 +608,8 @@ class TrialChatAudioApi(TrialAppResource):
|
||||
raise UnsupportedAudioTypeError()
|
||||
except ProviderNotSupportSpeechToTextServiceError:
|
||||
raise ProviderNotSupportSpeechToTextError()
|
||||
except SpeechToTextDisabledServiceError:
|
||||
raise SpeechToTextDisabledError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
|
||||
@ -18,6 +18,7 @@ from controllers.service_api.app.error import (
|
||||
ProviderNotInitializeError,
|
||||
ProviderNotSupportSpeechToTextError,
|
||||
ProviderQuotaExceededError,
|
||||
SpeechToTextDisabledError,
|
||||
UnsupportedAudioTypeError,
|
||||
)
|
||||
from controllers.service_api.schema import binary_response, expect_with_user, multipart_file_params
|
||||
@ -33,6 +34,7 @@ from services.errors.audio import (
|
||||
AudioTooLargeServiceError,
|
||||
NoAudioUploadedServiceError,
|
||||
ProviderNotSupportSpeechToTextServiceError,
|
||||
SpeechToTextDisabledServiceError,
|
||||
UnsupportedAudioTypeServiceError,
|
||||
)
|
||||
|
||||
@ -54,6 +56,7 @@ class AudioApi(Resource):
|
||||
200: "Successfully converted audio to text.",
|
||||
400: (
|
||||
"- `app_unavailable` : App unavailable or misconfigured.\n"
|
||||
"- `speech_to_text_disabled` : Speech-to-text is disabled for this app.\n"
|
||||
"- `provider_not_support_speech_to_text` : Model provider does not support speech-to-text.\n"
|
||||
"- `provider_not_initialize` : No valid model provider credentials found.\n"
|
||||
"- `provider_quota_exceeded` : Model provider quota exhausted.\n"
|
||||
@ -115,6 +118,8 @@ class AudioApi(Resource):
|
||||
raise UnsupportedAudioTypeError()
|
||||
except ProviderNotSupportSpeechToTextServiceError:
|
||||
raise ProviderNotSupportSpeechToTextError()
|
||||
except SpeechToTextDisabledServiceError:
|
||||
raise SpeechToTextDisabledError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
|
||||
@ -97,6 +97,12 @@ class ProviderNotSupportSpeechToTextError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class SpeechToTextDisabledError(BaseHTTPException):
|
||||
error_code = "speech_to_text_disabled"
|
||||
description = "Speech to text is disabled."
|
||||
code = 400
|
||||
|
||||
|
||||
class FileNotFoundError(BaseHTTPException):
|
||||
error_code = "file_not_found"
|
||||
description = "The requested file was not found."
|
||||
|
||||
@ -16,6 +16,7 @@ from controllers.web.error import (
|
||||
ProviderNotInitializeError,
|
||||
ProviderNotSupportSpeechToTextError,
|
||||
ProviderQuotaExceededError,
|
||||
SpeechToTextDisabledError,
|
||||
UnsupportedAudioTypeError,
|
||||
)
|
||||
from controllers.web.wraps import WebApiResource
|
||||
@ -31,6 +32,7 @@ from services.errors.audio import (
|
||||
AudioTooLargeServiceError,
|
||||
NoAudioUploadedServiceError,
|
||||
ProviderNotSupportSpeechToTextServiceError,
|
||||
SpeechToTextDisabledServiceError,
|
||||
UnsupportedAudioTypeServiceError,
|
||||
)
|
||||
|
||||
@ -91,6 +93,8 @@ class AudioApi(WebApiResource):
|
||||
raise UnsupportedAudioTypeError()
|
||||
except ProviderNotSupportSpeechToTextServiceError:
|
||||
raise ProviderNotSupportSpeechToTextError()
|
||||
except SpeechToTextDisabledServiceError:
|
||||
raise SpeechToTextDisabledError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
|
||||
@ -103,6 +103,12 @@ class ProviderNotSupportSpeechToTextError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class SpeechToTextDisabledError(BaseHTTPException):
|
||||
error_code = "speech_to_text_disabled"
|
||||
description = "Speech to text is disabled."
|
||||
code = 400
|
||||
|
||||
|
||||
class WebAppAuthRequiredError(BaseHTTPException):
|
||||
error_code = "web_sso_auth_required"
|
||||
description = "Web app authentication required."
|
||||
|
||||
@ -465,6 +465,30 @@ Check if activation token is valid
|
||||
| ---- | ----------- |
|
||||
| 204 | Agent service API key deleted |
|
||||
|
||||
### [POST] /agent/{agent_id}/audio-to-text
|
||||
Transcribe audio using the current Agent debug configuration
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | Agent ID | Yes | string (uuid) |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **multipart/form-data**: { **"draft_type"**: string, <br>**Available values:** "debug_build", "draft", <br>**Default:** draft, **"file"**: binary }<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Audio transcription successful | **application/json**: [AudioTranscriptResponse](#audiotranscriptresponse)<br> |
|
||||
| 400 | Bad request - Speech to text disabled or unsupported audio | |
|
||||
| 404 | Agent or build draft not found | |
|
||||
| 413 | Audio file too large | |
|
||||
|
||||
### [POST] /agent/{agent_id}/build-chat/finalize
|
||||
Run a build-draft Agent App turn that asks the agent to push config updates
|
||||
|
||||
@ -2638,6 +2662,12 @@ Transcript audio to text for chat messages
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | App ID | Yes | string (uuid) |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **multipart/form-data**: { **"file"**: binary }<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
@ -13096,6 +13126,12 @@ default (the config form sends the full desired feature state on save).
|
||||
| role | string | Agent role | No |
|
||||
| use_icon_as_answer_icon | boolean | Use icon as answer icon | No |
|
||||
|
||||
#### AgentAudioTranscriptFormPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| draft_type | [AgentConfigDraftType](#agentconfigdrafttype) | | No |
|
||||
|
||||
#### AgentAverageResponseTimeStatisticResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@ -278,7 +278,7 @@ Convert audio file to text. Supported MIME types: `audio/mp3`, `audio/mpga`, `au
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Successfully converted audio to text. | **application/json**: [AudioTranscriptResponse](#audiotranscriptresponse)<br> |
|
||||
| 400 | - `app_unavailable` : App unavailable or misconfigured. - `provider_not_support_speech_to_text` : Model provider does not support speech-to-text. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model does not support this operation. - `completion_request_error` : Speech recognition request failed. | |
|
||||
| 400 | - `app_unavailable` : App unavailable or misconfigured. - `speech_to_text_disabled` : Speech-to-text is disabled for this app. - `provider_not_support_speech_to_text` : Model provider does not support speech-to-text. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model does not support this operation. - `completion_request_error` : Speech recognition request failed. | |
|
||||
| 401 | Unauthorized - invalid API token | |
|
||||
| 403 | Forbidden - token scope, app, dataset, or workspace access denied | |
|
||||
| 413 | `audio_too_large` : Audio file size exceeded the limit. | |
|
||||
|
||||
@ -358,6 +358,32 @@ class AgentComposerService:
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id, session=session)
|
||||
return cls._load_agent_composer_for_agent(tenant_id=tenant_id, agent=agent, session=session)
|
||||
|
||||
@classmethod
|
||||
def load_agent_soul_for_debug(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
account_id: str,
|
||||
draft_type: AgentConfigDraftType,
|
||||
session: Session,
|
||||
) -> AgentSoulConfig:
|
||||
"""Load the same normal or account-owned build draft used by Agent debug chat."""
|
||||
if draft_type == AgentConfigDraftType.DEBUG_BUILD:
|
||||
state = cls.load_agent_app_build_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=account_id,
|
||||
session=session,
|
||||
)
|
||||
else:
|
||||
state = cls.load_agent_composer(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
session=session,
|
||||
)
|
||||
return AgentSoulConfig.model_validate(state["agent_soul"])
|
||||
|
||||
@classmethod
|
||||
def _load_agent_composer_for_agent(cls, *, tenant_id: str, agent: Agent, session: Session) -> dict[str, Any]:
|
||||
draft = cls._get_or_create_agent_draft(
|
||||
|
||||
@ -798,6 +798,18 @@ class AgentRosterService:
|
||||
)
|
||||
)
|
||||
|
||||
def get_published_agent_soul_for_app(self, *, tenant_id: str, app_id: str) -> AgentSoulConfig | None:
|
||||
"""Return the active Agent Soul used by a published Agent App runtime."""
|
||||
agent = self.get_app_backing_agent(tenant_id=tenant_id, app_id=app_id)
|
||||
if agent is None:
|
||||
return None
|
||||
version = self._get_version(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
version_id=agent.active_config_snapshot_id,
|
||||
)
|
||||
return AgentSoulConfig.model_validate(version.config_snapshot_dict)
|
||||
|
||||
def get_agent_app_model(self, *, tenant_id: str, agent_id: str) -> App:
|
||||
"""Resolve the Agent App hidden behind an app-backed Agent id.
|
||||
|
||||
|
||||
@ -10,16 +10,21 @@ from sqlalchemy.orm import Session
|
||||
from werkzeug.datastructures import FileStorage
|
||||
|
||||
from constants import AUDIO_EXTENSIONS
|
||||
from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
|
||||
from core.model_manager import ModelManager
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.enums import MessageStatus
|
||||
from models.model import App, AppMode, Message
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
from services.app_ref_service import MessageRef
|
||||
from services.errors.audio import (
|
||||
AudioTooLargeServiceError,
|
||||
NoAudioUploadedServiceError,
|
||||
ProviderNotSupportSpeechToTextServiceError,
|
||||
ProviderNotSupportTextToSpeechServiceError,
|
||||
SpeechToTextDisabledServiceError,
|
||||
UnsupportedAudioTypeServiceError,
|
||||
)
|
||||
from services.workflow_service import WorkflowService
|
||||
@ -41,23 +46,72 @@ class AudioService:
|
||||
return session.scalar(stmt.limit(1))
|
||||
|
||||
@classmethod
|
||||
def transcript_asr(cls, app_model: App, file: FileStorage | None, end_user: str | None = None):
|
||||
def transcript_asr(cls, app_model: App, file: FileStorage | None, end_user: str | None = None) -> dict[str, str]:
|
||||
"""Transcribe audio after enforcing the effective feature configuration.
|
||||
|
||||
Published Agent Apps use their active Agent Soul. Historical Agent Apps
|
||||
without a backing roster Agent retain the legacy AppModelConfig fallback.
|
||||
|
||||
Raises:
|
||||
SpeechToTextDisabledServiceError: If the effective feature configuration disables STT.
|
||||
"""
|
||||
if app_model.mode == AppMode.AGENT:
|
||||
agent_soul = AgentRosterService(db.session).get_published_agent_soul_for_app(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
)
|
||||
if agent_soul is not None:
|
||||
return cls.transcript_agent_asr(
|
||||
app_model=app_model,
|
||||
agent_soul=agent_soul,
|
||||
file=file,
|
||||
end_user=end_user,
|
||||
)
|
||||
|
||||
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
|
||||
workflow = app_model.workflow
|
||||
if workflow is None:
|
||||
raise ValueError("Speech to text is not enabled")
|
||||
raise SpeechToTextDisabledServiceError()
|
||||
|
||||
features_dict = workflow.features_dict
|
||||
if "speech_to_text" not in features_dict or not features_dict["speech_to_text"].get("enabled"):
|
||||
raise ValueError("Speech to text is not enabled")
|
||||
raise SpeechToTextDisabledServiceError()
|
||||
else:
|
||||
app_model_config = app_model.app_model_config
|
||||
if not app_model_config:
|
||||
raise ValueError("Speech to text is not enabled")
|
||||
raise SpeechToTextDisabledServiceError()
|
||||
|
||||
if not app_model_config.speech_to_text_dict["enabled"]:
|
||||
raise ValueError("Speech to text is not enabled")
|
||||
raise SpeechToTextDisabledServiceError()
|
||||
|
||||
return cls._invoke_speech_to_text(app_model=app_model, file=file, end_user=end_user)
|
||||
|
||||
@classmethod
|
||||
def transcript_agent_asr(
|
||||
cls,
|
||||
app_model: App,
|
||||
agent_soul: AgentSoulConfig,
|
||||
file: FileStorage | None,
|
||||
end_user: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Transcribe Agent audio after applying Soul-first runtime feature projection.
|
||||
|
||||
Raises:
|
||||
SpeechToTextDisabledServiceError: If the merged Agent feature configuration disables STT.
|
||||
"""
|
||||
features = merge_agent_app_features(
|
||||
agent_soul=agent_soul,
|
||||
app_model_config=app_model.app_model_config,
|
||||
)
|
||||
if not features.get("speech_to_text", {}).get("enabled"):
|
||||
raise SpeechToTextDisabledServiceError()
|
||||
|
||||
return cls._invoke_speech_to_text(app_model=app_model, file=file, end_user=end_user)
|
||||
|
||||
@classmethod
|
||||
def _invoke_speech_to_text(
|
||||
cls, app_model: App, file: FileStorage | None, end_user: str | None = None
|
||||
) -> dict[str, str]:
|
||||
if file is None:
|
||||
raise NoAudioUploadedServiceError()
|
||||
|
||||
|
||||
@ -14,6 +14,10 @@ class ProviderNotSupportSpeechToTextServiceError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SpeechToTextDisabledServiceError(Exception):
|
||||
"""Raised when the effective app configuration disables speech-to-text."""
|
||||
|
||||
|
||||
class ProviderNotSupportTextToSpeechServiceError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@ -4,13 +4,20 @@ import io
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
from werkzeug.exceptions import Forbidden, InternalServerError
|
||||
|
||||
from controllers.console.app.audio import ChatMessageAudioApi, ChatMessageTextApi, TextModesApi
|
||||
from controllers.console.app import audio as audio_module
|
||||
from controllers.console.app.audio import (
|
||||
AgentChatMessageAudioApi,
|
||||
ChatMessageAudioApi,
|
||||
ChatMessageTextApi,
|
||||
TextModesApi,
|
||||
)
|
||||
from controllers.console.app.error import (
|
||||
AppUnavailableError,
|
||||
AudioTooLargeError,
|
||||
@ -20,10 +27,16 @@ from controllers.console.app.error import (
|
||||
ProviderNotInitializeError,
|
||||
ProviderNotSupportSpeechToTextError,
|
||||
ProviderQuotaExceededError,
|
||||
SpeechToTextDisabledError,
|
||||
UnsupportedAudioTypeError,
|
||||
)
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from models import AppMode
|
||||
from models.agent import AgentConfigDraftType
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent.errors import AgentVersionNotFoundError
|
||||
from services.app_ref_service import MessageRef
|
||||
from services.audio_service import AudioService
|
||||
from services.errors.app_model_config import AppModelConfigBrokenError
|
||||
@ -32,6 +45,7 @@ from services.errors.audio import (
|
||||
NoAudioUploadedServiceError,
|
||||
ProviderNotSupportSpeechToTextServiceError,
|
||||
ProviderNotSupportTextToSpeechLanageServiceError,
|
||||
SpeechToTextDisabledServiceError,
|
||||
UnsupportedAudioTypeServiceError,
|
||||
)
|
||||
|
||||
@ -52,6 +66,180 @@ def test_console_audio_api_success(app: Flask, monkeypatch: pytest.MonkeyPatch)
|
||||
assert response == {"text": "ok"}
|
||||
|
||||
|
||||
def test_console_audio_api_accepts_published_agent_apps() -> None:
|
||||
assert AppMode.AGENT in audio_module._CONSOLE_AUDIO_TRANSCRIPT_APP_MODES
|
||||
|
||||
|
||||
def test_agent_console_audio_api_uses_agent_draft(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
agent_id = UUID("019ef3d2-b24c-7803-b428-18b5ee8fb853")
|
||||
app_model = SimpleNamespace(id="backing-app-1")
|
||||
agent_soul = AgentSoulConfig.model_validate({"app_features": {"speech_to_text": {"enabled": True}}})
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
def resolve_agent_runtime_app_model(**kwargs):
|
||||
calls["resolver"] = kwargs
|
||||
return app_model
|
||||
|
||||
def load_agent_soul_for_debug(**kwargs):
|
||||
calls["draft"] = kwargs
|
||||
return agent_soul
|
||||
|
||||
def transcript_agent_asr(**kwargs):
|
||||
calls["asr"] = kwargs
|
||||
return {"text": "agent transcript"}
|
||||
|
||||
def enforce_rbac_access(**kwargs):
|
||||
calls["rbac"] = kwargs
|
||||
|
||||
monkeypatch.setattr(audio_module, "resolve_agent_runtime_app_model", resolve_agent_runtime_app_model)
|
||||
monkeypatch.setattr(audio_module, "enforce_rbac_access", enforce_rbac_access)
|
||||
monkeypatch.setattr(AgentComposerService, "load_agent_soul_for_debug", load_agent_soul_for_debug)
|
||||
monkeypatch.setattr(AudioService, "transcript_agent_asr", transcript_agent_asr)
|
||||
|
||||
api = AgentChatMessageAudioApi()
|
||||
handler = unwrap(api.post)
|
||||
session = SimpleNamespace()
|
||||
current_user = SimpleNamespace(id="account-1")
|
||||
with app.test_request_context(
|
||||
f"/console/api/agent/{agent_id}/audio-to-text",
|
||||
method="POST",
|
||||
data={"file": _file_data(), "draft_type": "debug_build"},
|
||||
):
|
||||
response = handler(
|
||||
api,
|
||||
session=session,
|
||||
current_tenant_id="tenant-1",
|
||||
current_user=current_user,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
|
||||
assert response == {"text": "agent transcript"}
|
||||
assert calls["resolver"] == {"tenant_id": "tenant-1", "agent_id": agent_id}
|
||||
assert calls["rbac"] == {
|
||||
"tenant_id": "tenant-1",
|
||||
"account_id": "account-1",
|
||||
"resource_type": audio_module.RBACResourceScope.APP,
|
||||
"scene": audio_module.RBACPermission.APP_TEST_AND_RUN,
|
||||
"path_args": {"app_id": "backing-app-1"},
|
||||
}
|
||||
assert calls["draft"] == {
|
||||
"tenant_id": "tenant-1",
|
||||
"agent_id": str(agent_id),
|
||||
"account_id": "account-1",
|
||||
"draft_type": AgentConfigDraftType.DEBUG_BUILD,
|
||||
"session": session,
|
||||
}
|
||||
assert calls["asr"] == {
|
||||
"app_model": app_model,
|
||||
"agent_soul": agent_soul,
|
||||
"file": calls["asr"]["file"],
|
||||
"end_user": None,
|
||||
}
|
||||
|
||||
|
||||
def test_agent_console_audio_api_defaults_to_normal_draft(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
agent_id = UUID("019ef3d2-b24c-7803-b428-18b5ee8fb853")
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(
|
||||
audio_module,
|
||||
"resolve_agent_runtime_app_model",
|
||||
lambda **_kwargs: SimpleNamespace(id="backing-app-1"),
|
||||
)
|
||||
|
||||
def load_agent_soul_for_debug(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return AgentSoulConfig.model_validate({"app_features": {"speech_to_text": {"enabled": True}}})
|
||||
|
||||
monkeypatch.setattr(AgentComposerService, "load_agent_soul_for_debug", load_agent_soul_for_debug)
|
||||
monkeypatch.setattr(AudioService, "transcript_agent_asr", lambda **_kwargs: {"text": "ok"})
|
||||
|
||||
api = AgentChatMessageAudioApi()
|
||||
handler = unwrap(api.post)
|
||||
with app.test_request_context(
|
||||
f"/console/api/agent/{agent_id}/audio-to-text",
|
||||
method="POST",
|
||||
data={"file": _file_data()},
|
||||
):
|
||||
response = handler(
|
||||
api,
|
||||
session=SimpleNamespace(),
|
||||
current_tenant_id="tenant-1",
|
||||
current_user=SimpleNamespace(id="account-1"),
|
||||
agent_id=agent_id,
|
||||
)
|
||||
|
||||
assert response == {"text": "ok"}
|
||||
assert captured["draft_type"] == AgentConfigDraftType.DRAFT
|
||||
|
||||
|
||||
def test_agent_console_audio_api_checks_rbac_with_backing_app_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
agent_id = UUID("019ef3d2-b24c-7803-b428-18b5ee8fb853")
|
||||
app_model = SimpleNamespace(id="backing-app-1")
|
||||
soul_loaded = False
|
||||
|
||||
monkeypatch.setattr(audio_module, "resolve_agent_runtime_app_model", lambda **_kwargs: app_model)
|
||||
|
||||
def deny_access(**kwargs):
|
||||
assert kwargs["path_args"] == {"app_id": "backing-app-1"}
|
||||
raise Forbidden()
|
||||
|
||||
def load_agent_soul_for_debug(**_kwargs):
|
||||
nonlocal soul_loaded
|
||||
soul_loaded = True
|
||||
return AgentSoulConfig()
|
||||
|
||||
monkeypatch.setattr(audio_module, "enforce_rbac_access", deny_access)
|
||||
monkeypatch.setattr(AgentComposerService, "load_agent_soul_for_debug", load_agent_soul_for_debug)
|
||||
|
||||
api = AgentChatMessageAudioApi()
|
||||
handler = unwrap(api.post)
|
||||
with app.test_request_context(
|
||||
f"/console/api/agent/{agent_id}/audio-to-text",
|
||||
method="POST",
|
||||
data={"file": _file_data()},
|
||||
):
|
||||
with pytest.raises(Forbidden):
|
||||
handler(
|
||||
api,
|
||||
session=SimpleNamespace(),
|
||||
current_tenant_id="tenant-1",
|
||||
current_user=SimpleNamespace(id="account-1"),
|
||||
agent_id=agent_id,
|
||||
)
|
||||
|
||||
assert soul_loaded is False
|
||||
|
||||
|
||||
def test_agent_console_audio_api_preserves_missing_build_draft_404(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
agent_id = UUID("019ef3d2-b24c-7803-b428-18b5ee8fb853")
|
||||
monkeypatch.setattr(
|
||||
audio_module,
|
||||
"resolve_agent_runtime_app_model",
|
||||
lambda **_kwargs: SimpleNamespace(id="backing-app-1"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"load_agent_soul_for_debug",
|
||||
lambda **_kwargs: (_ for _ in ()).throw(AgentVersionNotFoundError()),
|
||||
)
|
||||
|
||||
api = AgentChatMessageAudioApi()
|
||||
handler = unwrap(api.post)
|
||||
with app.test_request_context(
|
||||
f"/console/api/agent/{agent_id}/audio-to-text",
|
||||
method="POST",
|
||||
data={"file": _file_data(), "draft_type": "debug_build"},
|
||||
):
|
||||
with pytest.raises(AgentVersionNotFoundError):
|
||||
handler(
|
||||
api,
|
||||
session=SimpleNamespace(),
|
||||
current_tenant_id="tenant-1",
|
||||
current_user=SimpleNamespace(id="account-1"),
|
||||
agent_id=agent_id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exc", "expected"),
|
||||
[
|
||||
@ -60,6 +248,7 @@ def test_console_audio_api_success(app: Flask, monkeypatch: pytest.MonkeyPatch)
|
||||
(AudioTooLargeServiceError("too big"), AudioTooLargeError),
|
||||
(UnsupportedAudioTypeServiceError(), UnsupportedAudioTypeError),
|
||||
(ProviderNotSupportSpeechToTextServiceError(), ProviderNotSupportSpeechToTextError),
|
||||
(SpeechToTextDisabledServiceError(), SpeechToTextDisabledError),
|
||||
(ProviderTokenNotInitError("token"), ProviderNotInitializeError),
|
||||
(QuotaExceededError(), ProviderQuotaExceededError),
|
||||
(ModelCurrentlyNotSupportError(), ProviderModelCurrentlyNotSupportError),
|
||||
|
||||
@ -14,6 +14,7 @@ from controllers.console.app.error import (
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
SpeechToTextDisabledError,
|
||||
)
|
||||
from core.errors.error import (
|
||||
ModelCurrentlyNotSupportError,
|
||||
@ -25,6 +26,7 @@ from services.app_ref_service import MessageRef
|
||||
from services.errors.audio import (
|
||||
AudioTooLargeServiceError,
|
||||
NoAudioUploadedServiceError,
|
||||
SpeechToTextDisabledServiceError,
|
||||
)
|
||||
|
||||
|
||||
@ -185,6 +187,22 @@ class TestChatAudioApi:
|
||||
with pytest.raises(audio_module.ProviderNotSupportSpeechToTextError):
|
||||
self.method(installed_app)
|
||||
|
||||
def test_speech_to_text_disabled(self, app: Flask, installed_app, audio_file):
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/",
|
||||
data={"file": audio_file},
|
||||
content_type="multipart/form-data",
|
||||
),
|
||||
patch.object(
|
||||
audio_module.AudioService,
|
||||
"transcript_asr",
|
||||
side_effect=SpeechToTextDisabledServiceError(),
|
||||
),
|
||||
):
|
||||
with pytest.raises(SpeechToTextDisabledError):
|
||||
self.method(installed_app)
|
||||
|
||||
def test_provider_not_initialized(self, app: Flask, installed_app, audio_file):
|
||||
with (
|
||||
app.test_request_context(
|
||||
|
||||
@ -18,6 +18,7 @@ from controllers.console.app.error import (
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
SpeechToTextDisabledError,
|
||||
)
|
||||
from controllers.console.explore.error import (
|
||||
NotChatAppError,
|
||||
@ -35,6 +36,7 @@ from models import Account
|
||||
from models.account import TenantStatus
|
||||
from models.model import AppMode
|
||||
from services.app_ref_service import MessageRef
|
||||
from services.errors.audio import SpeechToTextDisabledServiceError
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
|
||||
@ -784,6 +786,24 @@ class TestTrialChatAudioApi:
|
||||
with pytest.raises(module.ProviderNotSupportSpeechToTextError):
|
||||
method(api, account, trial_app_chat)
|
||||
|
||||
def test_speech_to_text_disabled(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
|
||||
api = module.TrialChatAudioApi()
|
||||
method = unwrap(api.post)
|
||||
file_data = _file_data()
|
||||
|
||||
with (
|
||||
app.test_request_context(
|
||||
"/", method="POST", data={"file": (file_data, "test.wav")}, content_type="multipart/form-data"
|
||||
),
|
||||
patch.object(
|
||||
module.AudioService,
|
||||
"transcript_asr",
|
||||
side_effect=SpeechToTextDisabledServiceError(),
|
||||
),
|
||||
):
|
||||
with pytest.raises(SpeechToTextDisabledError):
|
||||
method(api, account, trial_app_chat)
|
||||
|
||||
def test_provider_not_init(self, app: Flask, trial_app_chat: MagicMock, account: Account) -> None:
|
||||
api = module.TrialChatAudioApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@ -28,6 +28,7 @@ from controllers.service_api.app.error import (
|
||||
ProviderNotInitializeError,
|
||||
ProviderNotSupportSpeechToTextError,
|
||||
ProviderQuotaExceededError,
|
||||
SpeechToTextDisabledError,
|
||||
UnsupportedAudioTypeError,
|
||||
)
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
@ -39,6 +40,7 @@ from services.errors.audio import (
|
||||
AudioTooLargeServiceError,
|
||||
NoAudioUploadedServiceError,
|
||||
ProviderNotSupportSpeechToTextServiceError,
|
||||
SpeechToTextDisabledServiceError,
|
||||
UnsupportedAudioTypeServiceError,
|
||||
)
|
||||
|
||||
@ -207,6 +209,7 @@ class TestAudioApi:
|
||||
(AudioTooLargeServiceError("too big"), AudioTooLargeError),
|
||||
(UnsupportedAudioTypeServiceError(), UnsupportedAudioTypeError),
|
||||
(ProviderNotSupportSpeechToTextServiceError(), ProviderNotSupportSpeechToTextError),
|
||||
(SpeechToTextDisabledServiceError(), SpeechToTextDisabledError),
|
||||
(ProviderTokenNotInitError("token"), ProviderNotInitializeError),
|
||||
(QuotaExceededError(), ProviderQuotaExceededError),
|
||||
(ModelCurrentlyNotSupportError(), ProviderModelCurrentlyNotSupportError),
|
||||
|
||||
@ -18,6 +18,7 @@ from controllers.web.error import (
|
||||
ProviderNotInitializeError,
|
||||
ProviderNotSupportSpeechToTextError,
|
||||
ProviderQuotaExceededError,
|
||||
SpeechToTextDisabledError,
|
||||
UnsupportedAudioTypeError,
|
||||
)
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
@ -27,6 +28,7 @@ from services.errors.audio import (
|
||||
AudioTooLargeServiceError,
|
||||
NoAudioUploadedServiceError,
|
||||
ProviderNotSupportSpeechToTextServiceError,
|
||||
SpeechToTextDisabledServiceError,
|
||||
UnsupportedAudioTypeServiceError,
|
||||
)
|
||||
|
||||
@ -83,6 +85,16 @@ class TestAudioApi:
|
||||
with pytest.raises(ProviderNotSupportSpeechToTextError):
|
||||
AudioApi().post(_app_model(), _end_user())
|
||||
|
||||
@patch(
|
||||
"controllers.web.audio.AudioService.transcript_asr",
|
||||
side_effect=SpeechToTextDisabledServiceError(),
|
||||
)
|
||||
def test_speech_to_text_disabled(self, mock_asr: MagicMock, app: Flask) -> None:
|
||||
data = {"file": (BytesIO(b"x"), "x.mp3")}
|
||||
with app.test_request_context("/audio-to-text", method="POST", data=data, content_type="multipart/form-data"):
|
||||
with pytest.raises(SpeechToTextDisabledError):
|
||||
AudioApi().post(_app_model(), _end_user())
|
||||
|
||||
@patch(
|
||||
"controllers.web.audio.AudioService.transcript_asr",
|
||||
side_effect=ProviderTokenNotInitError(description="no token"),
|
||||
|
||||
@ -22,6 +22,7 @@ from controllers.web.error import (
|
||||
ProviderNotInitializeError,
|
||||
ProviderNotSupportSpeechToTextError,
|
||||
ProviderQuotaExceededError,
|
||||
SpeechToTextDisabledError,
|
||||
UnsupportedAudioTypeError,
|
||||
WebAppAuthAccessDeniedError,
|
||||
WebAppAuthRequiredError,
|
||||
@ -45,6 +46,7 @@ _ERROR_SPECS: list[tuple[type, str, int]] = [
|
||||
(AudioTooLargeError, "audio_too_large", 413),
|
||||
(UnsupportedAudioTypeError, "unsupported_audio_type", 415),
|
||||
(ProviderNotSupportSpeechToTextError, "provider_not_support_speech_to_text", 400),
|
||||
(SpeechToTextDisabledError, "speech_to_text_disabled", 400),
|
||||
(WebAppAuthRequiredError, "web_sso_auth_required", 401),
|
||||
(WebAppAuthAccessDeniedError, "web_app_access_denied", 401),
|
||||
(InvokeRateLimitError, "rate_limit_error", 429),
|
||||
|
||||
@ -116,6 +116,25 @@ def test_agent_soul_has_model():
|
||||
assert agent_soul_has_model(AgentSoulConfig()) is False
|
||||
|
||||
|
||||
def test_get_published_agent_soul_for_app_uses_active_snapshot():
|
||||
agent_soul = AgentSoulConfig.model_validate({"app_features": {"speech_to_text": {"enabled": True}}})
|
||||
agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")
|
||||
version = SimpleNamespace(config_snapshot_dict=agent_soul.model_dump(mode="json"))
|
||||
service = AgentRosterService(FakeSession(scalar=[agent, version]))
|
||||
|
||||
result = service.get_published_agent_soul_for_app(tenant_id="tenant-1", app_id="app-1")
|
||||
|
||||
assert result == agent_soul
|
||||
|
||||
|
||||
def test_get_published_agent_soul_for_app_returns_none_without_backing_agent():
|
||||
service = AgentRosterService(FakeSession(scalar=[None]))
|
||||
|
||||
result = service.get_published_agent_soul_for_app(tenant_id="tenant-1", app_id="legacy-app-1")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_load_workflow_composer_returns_empty_state(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1"))
|
||||
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: None)
|
||||
@ -632,7 +651,7 @@ def test_publish_agent_app_draft_rejects_missing_model(monkeypatch: pytest.Monke
|
||||
base_snapshot_id="version-1",
|
||||
config_snapshot=AgentSoulConfig(),
|
||||
)
|
||||
fake_session = FakeSession(scalar=[agent, draft])
|
||||
fake_session = FakeSession(scalar=[agent, draft, None])
|
||||
|
||||
def fail_create_config_version(**_kwargs):
|
||||
raise AssertionError("config version must not be created when Agent Soul has no model")
|
||||
@ -777,6 +796,74 @@ def test_agent_app_build_draft_checkout_and_apply_use_user_isolated_draft(monkey
|
||||
assert fake_session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("draft_type", "account_id"),
|
||||
[
|
||||
(AgentConfigDraftType.DRAFT, None),
|
||||
(AgentConfigDraftType.DEBUG_BUILD, "account-1"),
|
||||
],
|
||||
)
|
||||
def test_load_agent_soul_for_debug_selects_requested_draft(
|
||||
draft_type: AgentConfigDraftType,
|
||||
account_id: str | None,
|
||||
):
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Iris",
|
||||
description="",
|
||||
agent_kind=AgentKind.DIFY_AGENT,
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
status=AgentStatus.ACTIVE,
|
||||
)
|
||||
agent_soul = AgentSoulConfig.model_validate({"app_features": {"speech_to_text": {"enabled": True}}})
|
||||
draft = AgentConfigDraft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
draft_type=draft_type,
|
||||
account_id=account_id,
|
||||
draft_owner_key=account_id or "",
|
||||
config_snapshot=agent_soul,
|
||||
)
|
||||
fake_session = FakeSession(
|
||||
scalar=[draft] if draft_type == AgentConfigDraftType.DEBUG_BUILD else [agent, draft, None]
|
||||
)
|
||||
|
||||
result = AgentComposerService.load_agent_soul_for_debug(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
account_id="account-1",
|
||||
draft_type=draft_type,
|
||||
session=fake_session,
|
||||
)
|
||||
|
||||
assert result == agent_soul
|
||||
|
||||
|
||||
def test_load_agent_soul_for_debug_requires_existing_build_draft():
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Iris",
|
||||
description="",
|
||||
agent_kind=AgentKind.DIFY_AGENT,
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
status=AgentStatus.ACTIVE,
|
||||
)
|
||||
fake_session = FakeSession(scalar=[None])
|
||||
|
||||
with pytest.raises(AgentVersionNotFoundError):
|
||||
AgentComposerService.load_agent_soul_for_debug(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
account_id="account-1",
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
session=fake_session,
|
||||
)
|
||||
|
||||
|
||||
def test_agent_app_build_draft_apply_marks_unpublished_when_build_draft_differs(monkeypatch: pytest.MonkeyPatch):
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
|
||||
@ -59,6 +59,7 @@ from unittest.mock import MagicMock, Mock, create_autospec, patch
|
||||
import pytest
|
||||
from werkzeug.datastructures import FileStorage
|
||||
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.enums import MessageStatus
|
||||
from models.model import App, AppMode, AppModelConfig, Message
|
||||
from models.workflow import Workflow
|
||||
@ -69,6 +70,7 @@ from services.errors.audio import (
|
||||
NoAudioUploadedServiceError,
|
||||
ProviderNotSupportSpeechToTextServiceError,
|
||||
ProviderNotSupportTextToSpeechServiceError,
|
||||
SpeechToTextDisabledServiceError,
|
||||
UnsupportedAudioTypeServiceError,
|
||||
)
|
||||
|
||||
@ -267,6 +269,117 @@ class TestAudioServiceASR:
|
||||
# Assert
|
||||
assert result == {"text": "Workflow transcribed text"}
|
||||
|
||||
@patch("services.audio_service.AgentRosterService", autospec=True)
|
||||
@patch("services.audio_service.ModelManager.for_tenant", autospec=True)
|
||||
def test_transcript_asr_success_published_agent_mode(
|
||||
self,
|
||||
mock_model_manager_class,
|
||||
mock_roster_service_class,
|
||||
factory: AudioServiceTestDataFactory,
|
||||
):
|
||||
app = factory.create_app_mock(mode=AppMode.AGENT)
|
||||
file = factory.create_file_storage_mock()
|
||||
agent_soul = AgentSoulConfig.model_validate({"app_features": {"speech_to_text": {"enabled": True}}})
|
||||
mock_roster_service_class.return_value.get_published_agent_soul_for_app.return_value = agent_soul
|
||||
mock_model_instance = MagicMock()
|
||||
mock_model_instance.invoke_speech2text.return_value = "Published Agent transcript"
|
||||
mock_model_manager_class.return_value.get_default_model_instance.return_value = mock_model_instance
|
||||
|
||||
result = AudioService.transcript_asr(app_model=app, file=file, end_user="end-user-1")
|
||||
|
||||
assert result == {"text": "Published Agent transcript"}
|
||||
mock_roster_service_class.return_value.get_published_agent_soul_for_app.assert_called_once_with(
|
||||
tenant_id=app.tenant_id,
|
||||
app_id=app.id,
|
||||
)
|
||||
|
||||
@patch("services.audio_service.AgentRosterService", autospec=True)
|
||||
@patch("services.audio_service.ModelManager.for_tenant", autospec=True)
|
||||
def test_transcript_asr_legacy_agent_falls_back_to_app_model_config(
|
||||
self,
|
||||
mock_model_manager_class,
|
||||
mock_roster_service_class,
|
||||
factory: AudioServiceTestDataFactory,
|
||||
):
|
||||
app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True})
|
||||
app = factory.create_app_mock(mode=AppMode.AGENT, app_model_config=app_model_config)
|
||||
file = factory.create_file_storage_mock()
|
||||
mock_roster_service_class.return_value.get_published_agent_soul_for_app.return_value = None
|
||||
mock_model_instance = MagicMock()
|
||||
mock_model_instance.invoke_speech2text.return_value = "Legacy Agent transcript"
|
||||
mock_model_manager_class.return_value.get_default_model_instance.return_value = mock_model_instance
|
||||
|
||||
result = AudioService.transcript_asr(app_model=app, file=file)
|
||||
|
||||
assert result == {"text": "Legacy Agent transcript"}
|
||||
|
||||
@patch("services.audio_service.ModelManager.for_tenant", autospec=True)
|
||||
def test_transcript_agent_asr_uses_agent_soul_feature(
|
||||
self, mock_model_manager_class, factory: AudioServiceTestDataFactory
|
||||
):
|
||||
app = factory.create_app_mock(mode=AppMode.AGENT)
|
||||
file = factory.create_file_storage_mock()
|
||||
agent_soul = AgentSoulConfig.model_validate({"app_features": {"speech_to_text": {"enabled": True}}})
|
||||
mock_model_instance = MagicMock()
|
||||
mock_model_instance.invoke_speech2text.return_value = "Agent transcript"
|
||||
mock_model_manager_class.return_value.get_default_model_instance.return_value = mock_model_instance
|
||||
|
||||
result = AudioService.transcript_agent_asr(
|
||||
app_model=app,
|
||||
agent_soul=agent_soul,
|
||||
file=file,
|
||||
end_user="account-1",
|
||||
)
|
||||
|
||||
assert result == {"text": "Agent transcript"}
|
||||
mock_model_manager_class.assert_called_once_with(tenant_id=app.tenant_id, user_id="account-1")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"agent_soul",
|
||||
[
|
||||
AgentSoulConfig(),
|
||||
AgentSoulConfig.model_validate({"app_features": {"speech_to_text": {"enabled": False}}}),
|
||||
],
|
||||
)
|
||||
def test_transcript_agent_asr_rejects_disabled_feature(
|
||||
self, factory: AudioServiceTestDataFactory, agent_soul: AgentSoulConfig
|
||||
):
|
||||
app = factory.create_app_mock(mode=AppMode.AGENT)
|
||||
file = factory.create_file_storage_mock()
|
||||
|
||||
with pytest.raises(SpeechToTextDisabledServiceError):
|
||||
AudioService.transcript_agent_asr(app_model=app, agent_soul=agent_soul, file=file)
|
||||
|
||||
@patch("services.audio_service.ModelManager.for_tenant", autospec=True)
|
||||
def test_transcript_agent_asr_preserves_legacy_feature_fallback(
|
||||
self, mock_model_manager_class, factory: AudioServiceTestDataFactory
|
||||
):
|
||||
app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True})
|
||||
app_model_config.to_dict.return_value = {"speech_to_text": {"enabled": True}}
|
||||
app = factory.create_app_mock(mode=AppMode.AGENT, app_model_config=app_model_config)
|
||||
file = factory.create_file_storage_mock()
|
||||
mock_model_instance = MagicMock()
|
||||
mock_model_instance.invoke_speech2text.return_value = "Legacy feature transcript"
|
||||
mock_model_manager_class.return_value.get_default_model_instance.return_value = mock_model_instance
|
||||
|
||||
result = AudioService.transcript_agent_asr(
|
||||
app_model=app,
|
||||
agent_soul=AgentSoulConfig(),
|
||||
file=file,
|
||||
)
|
||||
|
||||
assert result == {"text": "Legacy feature transcript"}
|
||||
|
||||
def test_transcript_agent_asr_soul_disabled_overrides_legacy_feature(self, factory: AudioServiceTestDataFactory):
|
||||
app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True})
|
||||
app_model_config.to_dict.return_value = {"speech_to_text": {"enabled": True}}
|
||||
app = factory.create_app_mock(mode=AppMode.AGENT, app_model_config=app_model_config)
|
||||
file = factory.create_file_storage_mock()
|
||||
agent_soul = AgentSoulConfig.model_validate({"app_features": {"speech_to_text": {"enabled": False}}})
|
||||
|
||||
with pytest.raises(SpeechToTextDisabledServiceError):
|
||||
AudioService.transcript_agent_asr(app_model=app, agent_soul=agent_soul, file=file)
|
||||
|
||||
def test_transcript_asr_raises_error_when_feature_disabled_chat_mode(self, factory: AudioServiceTestDataFactory):
|
||||
"""Test that ASR raises error when speech-to-text is disabled in CHAT mode."""
|
||||
# Arrange
|
||||
@ -278,7 +391,7 @@ class TestAudioServiceASR:
|
||||
file = factory.create_file_storage_mock()
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError, match="Speech to text is not enabled"):
|
||||
with pytest.raises(SpeechToTextDisabledServiceError):
|
||||
AudioService.transcript_asr(app_model=app, file=file)
|
||||
|
||||
def test_transcript_asr_raises_error_when_feature_disabled_workflow_mode(
|
||||
@ -294,7 +407,7 @@ class TestAudioServiceASR:
|
||||
file = factory.create_file_storage_mock()
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError, match="Speech to text is not enabled"):
|
||||
with pytest.raises(SpeechToTextDisabledServiceError):
|
||||
AudioService.transcript_asr(app_model=app, file=file)
|
||||
|
||||
def test_transcript_asr_raises_error_when_workflow_missing(self, factory: AudioServiceTestDataFactory):
|
||||
@ -307,7 +420,7 @@ class TestAudioServiceASR:
|
||||
file = factory.create_file_storage_mock()
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError, match="Speech to text is not enabled"):
|
||||
with pytest.raises(SpeechToTextDisabledServiceError):
|
||||
AudioService.transcript_asr(app_model=app, file=file)
|
||||
|
||||
def test_transcript_asr_raises_error_when_no_file_uploaded(self, factory: AudioServiceTestDataFactory):
|
||||
|
||||
@ -12,6 +12,9 @@ E2E_AGENT_DECISION_MODEL_PROVIDER=openai
|
||||
E2E_AGENT_DECISION_MODEL_NAME=gpt-5.5
|
||||
E2E_AGENT_DECISION_MODEL_TYPE=llm
|
||||
|
||||
E2E_SPEECH_TO_TEXT_MODEL_PROVIDER=openai
|
||||
E2E_SPEECH_TO_TEXT_MODEL_NAME=gpt-4o-mini-transcribe
|
||||
|
||||
E2E_MODEL_PROVIDER_CREDENTIALS_JSON='{"openai_api_key":"replace-with-real-key"}'
|
||||
|
||||
# Optional: external runtime CI/local profile. Defaults prepare Agent V2 runtime
|
||||
|
||||
@ -209,10 +209,13 @@ Feature: Create dataset
|
||||
- `@fresh` — only runs in `e2e:full` mode (requires uninitialized instance)
|
||||
- `@external-model` — scenario execution can call a real model provider. Use this only for runtime requests, not for scenarios that only require an active model fixture.
|
||||
- `@external-tool` — scenario execution can call a real third-party tool provider. Use this only for runtime tool execution, not for plugin installation, discovery, or local deterministic tools.
|
||||
- `@microphone` — runs the scenario in an isolated Chromium instance backed by the checked-in fake audio fixture and grants microphone permission only to that scenario context.
|
||||
- `@skip` — excluded from all runs
|
||||
|
||||
External runtime commands are opt-in. `pnpm -C e2e e2e:external:prepare` reads `E2E_EXTERNAL_RUNTIME_SEED_SPECS`, defaulting to `agent-v2:external-runtime`, and runs the matching seed packs before the external suite. `pnpm -C e2e e2e:external` reads `E2E_EXTERNAL_RUNTIME_TAGS`, defaulting to `(@external-model or @external-tool) and not @feature-gated and not @skip and not @preview`.
|
||||
|
||||
The Agent v2 external runtime seed also prepares the workspace default Speech-to-Text model. `E2E_SPEECH_TO_TEXT_MODEL_PROVIDER` and `E2E_SPEECH_TO_TEXT_MODEL_NAME` select an existing model or the model configured through `E2E_MODEL_PROVIDER_CREDENTIALS_JSON`; they default to `openai` and `gpt-4o-mini-transcribe`.
|
||||
|
||||
Some external runtime scenarios need feature-owned services in addition to a real model or tool provider. Do not overload `@external-model` or `@external-tool` to mean those services are available. For Agent v2, scenarios that require the standalone `dify-agent` run server use the feature tag `@agent-backend-runtime` plus the explicit step `the Agent v2 runtime backend is available`. Run them with `E2E_START_AGENT_BACKEND=1` to let E2E start `dify-agent` and the shellctl local sandbox required by its `dify.config`/`dify.shell` runtime layers, or set `E2E_AGENT_BACKEND_URL`/`AGENT_BACKEND_BASE_URL` when an existing server should be reused.
|
||||
|
||||
Keep scenarios short and declarative. Each step should describe **what** the user does, not **how** the UI works.
|
||||
|
||||
@ -36,6 +36,8 @@ Use tags in three layers:
|
||||
- `@publish` — publish and publish-bar state.
|
||||
- `@access-point` — Web app, Backend service API, and Workflow access surfaces.
|
||||
- `@stable-model` — active model fixture dependency. Apply this to every scenario that includes `the Agent Builder stable chat model is available` or otherwise requires an active model configured in the workspace.
|
||||
- `@speech-to-text-model` — active workspace default Speech-to-Text model dependency. Apply this to scenarios that include `the workspace default speech-to-text model is active`.
|
||||
- `@microphone` — deterministic Chromium fake microphone dependency. The scenario hook launches a fake-audio browser and grants microphone permission only to tagged scenarios.
|
||||
- `@agent-decision-model` — stronger active model fixture dependency for scenarios that specifically validate Agent autonomous planning or resource-selection behavior, such as generated-query Knowledge Retrieval. Do not use it for scenarios that only need a model to exist or answer a deterministic prompt.
|
||||
- `@tool-fixture` — preseeded Tool dependency such as `JSON Process / JSON Replace` or `Tavily / Tavily Search`.
|
||||
- `@skill-fixture` — checked-in or preseeded Skill dependency such as `e2e-summary-skill`.
|
||||
@ -75,6 +77,7 @@ Keep Agent v2 step definitions grouped by user capability, not by DOM component
|
||||
- `access-point-service-api.steps.ts` — Backend service API entrypoints, keys, API reference, and service requests.
|
||||
- `access-point-workflow.steps.ts` — Workflow access references.
|
||||
- `preflight.steps.ts` — explicit `Given` entrypoints for Agent Builder preflight resources.
|
||||
- `speech-to-text.steps.ts` — Agent Build voice input, multipart request, and transcribed input behavior.
|
||||
|
||||
Cucumber step definitions are globally registered. Do not duplicate the same step text across files, even if one is written as `Given` and another as `Then`.
|
||||
|
||||
@ -87,6 +90,7 @@ Agent v2 business state belongs under `world.agentBuilder`; do not keep adding A
|
||||
Use the existing namespace shape:
|
||||
|
||||
- `world.agentBuilder.preflight.stableModel`
|
||||
- `world.agentBuilder.preflight.speechToTextModel`
|
||||
- `world.agentBuilder.preflight.brokenModel`
|
||||
- `world.agentBuilder.preflight.preseededResources`
|
||||
- `world.agentBuilder.accessPoint.serviceApiBaseURL`
|
||||
@ -98,6 +102,7 @@ Use the existing namespace shape:
|
||||
- `world.agentBuilder.accessPoint.workflowReferencePage`
|
||||
- `world.agentBuilder.accessPoint.composerDraftSnapshot`
|
||||
- `world.agentBuilder.configure.concurrentPage`
|
||||
- `world.agentBuilder.speechToText.request`
|
||||
- `world.agentBuilder.workflow.agentConsolePage`
|
||||
- `world.agentBuilder.workflow.outputVariables`
|
||||
|
||||
@ -163,6 +168,8 @@ Use `the Agent v2 runtime backend is available` before scenarios tagged `@agent-
|
||||
|
||||
Use `the Agent Builder stable chat model is available` before scenarios that need a real Agent Soul model configuration. This includes true runtime scenarios, model-backed build-mode assertions, and Workflow Agent v2 node setup because the backend rejects Agent nodes without model config. Do not add the model preflight to pure navigation or identity checks unless the setup API itself requires model config. `E2E_STABLE_MODEL_PROVIDER`, `E2E_STABLE_MODEL_NAME`, and optional `E2E_STABLE_MODEL_TYPE` are selectors for a model already configured in the workspace; they are not provider credentials. The step defaults to `openai` / `gpt-5-nano` / `llm`, verifies the selected model is present and `active` through `/console/api/workspaces/current/models/model-types/{type}`, then stores it on `DifyWorld.agentBuilder.preflight.stableModel`.
|
||||
|
||||
Use `the workspace default speech-to-text model is active` before real speech-to-text scenarios. The preflight reads `/console/api/workspaces/current/default-model?model_type=speech2text`, verifies that same provider/model is active in the Speech-to-Text model list, and stores it on `DifyWorld.agentBuilder.preflight.speechToTextModel`. It must not configure a model or provider credential. External runtime preparation selects `E2E_SPEECH_TO_TEXT_MODEL_PROVIDER` / `E2E_SPEECH_TO_TEXT_MODEL_NAME`, defaulting to `openai` / `gpt-4o-mini-transcribe`, reuses `E2E_MODEL_PROVIDER_CREDENTIALS_JSON` when provider setup is required, and selects the active model as the workspace default.
|
||||
|
||||
Use `the Agent Builder agent-decision chat model is available` before scenarios that need a stronger model to exercise Agent autonomous planning, generated query selection, or tool/resource choice. `E2E_AGENT_DECISION_MODEL_PROVIDER`, `E2E_AGENT_DECISION_MODEL_NAME`, and optional `E2E_AGENT_DECISION_MODEL_TYPE` are selectors for a second active model fixture, defaulting to `openai` / `gpt-5.5` / `llm`. The step stores the model on `DifyWorld.agentBuilder.preflight.agentDecisionModel`. Do not use this fixture as a broad replacement for `@stable-model`; it is intentionally narrower and costlier.
|
||||
|
||||
Keep `@stable-model` on Build draft apply scenarios that click `Apply`. The current product path calls `/build-chat/finalize` before applying the draft, and the backend returns `model is required` when the Agent Soul has no model config. Discard-only and pending-draft isolation scenarios can stay model-free when they do not finalize the Build draft.
|
||||
|
||||
@ -14,6 +14,11 @@ Feature: Agent Builder preseeded environment
|
||||
Given I am signed in as the default E2E admin
|
||||
And the Agent Builder stable chat model is available
|
||||
|
||||
@speech-to-text-model
|
||||
Scenario: Default speech-to-text model is available
|
||||
Given I am signed in as the default E2E admin
|
||||
And the workspace default speech-to-text model is active
|
||||
|
||||
@agent-decision-model
|
||||
Scenario: Agent-decision chat model is available
|
||||
Given I am signed in as the default E2E admin
|
||||
|
||||
13
e2e/features/agent-v2/speech-to-text.feature
Normal file
13
e2e/features/agent-v2/speech-to-text.feature
Normal file
@ -0,0 +1,13 @@
|
||||
@agent-v2 @authenticated
|
||||
Feature: Agent v2 speech-to-text
|
||||
@build @speech-to-text @microphone @external-model @speech-to-text-model
|
||||
Scenario: Recorded speech is transcribed into the current Agent input
|
||||
Given I am signed in as the default E2E admin
|
||||
And the workspace default speech-to-text model is active
|
||||
And an Agent v2 test agent with speech-to-text enabled has been created via API
|
||||
When I open the Agent v2 configure page
|
||||
And I start Agent v2 voice input
|
||||
And I stop Agent v2 voice input after the fixture speech has played
|
||||
Then the Agent v2 speech-to-text request should succeed
|
||||
And the transcribed fixture phrase "Purple elephant seven" should appear in the Agent v2 input
|
||||
And the Agent v2 input should regain focus after transcription
|
||||
@ -1,5 +1,6 @@
|
||||
export const agentBuilderPreseededResources = {
|
||||
stableChatModel: 'E2E Stable Chat Model',
|
||||
speechToTextModel: 'Workspace default Speech-to-Text model',
|
||||
summarySkill: 'e2e-summary-skill',
|
||||
jsonReplaceTool: 'JSON Process / JSON Replace',
|
||||
tavilySearchTool: 'Tavily / Tavily Search',
|
||||
|
||||
@ -85,6 +85,18 @@ export function createAgentSoulConfigWithModel(
|
||||
}
|
||||
}
|
||||
|
||||
export function createAgentSoulConfigWithSpeechToText(agentSoul: AgentSoulConfig): AgentSoulConfig {
|
||||
return {
|
||||
...agentSoul,
|
||||
app_features: {
|
||||
...agentSoul.app_features,
|
||||
speech_to_text: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createPublishableAgentSoulConfig(agentSoul: AgentSoulConfig): AgentSoulConfig {
|
||||
if (agentSoul.model) return agentSoul
|
||||
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import type { ProviderWithModelsResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type {
|
||||
DefaultModelDataResponse,
|
||||
ProviderWithModelsResponse,
|
||||
} from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { DifyWorld } from '../../../support/world'
|
||||
import { createApiContext, expectApiResponseOK } from '../../../../support/api'
|
||||
import { agentBuilderPreseededResources } from '../agent-builder-resources'
|
||||
@ -151,6 +154,49 @@ export async function skipMissingAgentBuilderStableChatModel(
|
||||
})
|
||||
}
|
||||
|
||||
export async function skipMissingAgentBuilderSpeechToTextModel(
|
||||
world: DifyWorld,
|
||||
): Promise<'skipped' | NonNullable<DifyWorld['agentBuilder']['preflight']['speechToTextModel']>> {
|
||||
const ctx = await createApiContext()
|
||||
let defaultModel: NonNullable<DefaultModelDataResponse['data']>
|
||||
|
||||
try {
|
||||
const response = await ctx.get(
|
||||
'/console/api/workspaces/current/default-model?model_type=speech2text',
|
||||
)
|
||||
await expectApiResponseOK(response, `Check ${agentBuilderPreseededResources.speechToTextModel}`)
|
||||
const body = (await response.json()) as DefaultModelDataResponse
|
||||
if (!body.data) {
|
||||
return skipBlockedPrecondition(
|
||||
world,
|
||||
`${agentBuilderPreseededResources.speechToTextModel} is not configured.`,
|
||||
{
|
||||
owner: 'model-provider/seed',
|
||||
remediation:
|
||||
'Configure an active workspace default Speech-to-Text model before running the external scenario.',
|
||||
},
|
||||
)
|
||||
}
|
||||
defaultModel = body.data
|
||||
} finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
|
||||
return skipMissingAgentBuilderModel(
|
||||
world,
|
||||
{
|
||||
ok: true,
|
||||
provider: defaultModel.provider.provider,
|
||||
resourceName: agentBuilderPreseededResources.speechToTextModel,
|
||||
type: 'speech2text',
|
||||
value: defaultModel.model,
|
||||
},
|
||||
{
|
||||
requireActive: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function skipMissingAgentBuilderAgentDecisionChatModel(
|
||||
world: DifyWorld,
|
||||
): Promise<'skipped' | NonNullable<DifyWorld['agentBuilder']['preflight']['stableModel']>> {
|
||||
|
||||
@ -11,6 +11,7 @@ import type {
|
||||
} from '@dify/contracts/api/console/datasets/types.gen'
|
||||
import type {
|
||||
AvailableModelListResponse,
|
||||
DefaultModelDataResponse,
|
||||
ModelProviderListResponse,
|
||||
} from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { SeedContext, SeedResource, SeedTask } from '../../../support/seed'
|
||||
@ -65,6 +66,8 @@ type ToolResource = SeedResource & {
|
||||
}
|
||||
|
||||
const modelCredentialEnv = 'E2E_MODEL_PROVIDER_CREDENTIALS_JSON'
|
||||
const speechToTextModelProviderEnv = 'E2E_SPEECH_TO_TEXT_MODEL_PROVIDER'
|
||||
const speechToTextModelNameEnv = 'E2E_SPEECH_TO_TEXT_MODEL_NAME'
|
||||
const marketplacePluginIdsEnv = 'E2E_MARKETPLACE_PLUGIN_IDS'
|
||||
const marketplacePluginUniqueIdentifiersEnv = 'E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS'
|
||||
const oauthToolCredentialIdEnv = 'E2E_OAUTH_TOOL_CREDENTIAL_ID'
|
||||
@ -104,6 +107,12 @@ const agentDecisionModelConfig = (): StableModel => ({
|
||||
type: process.env.E2E_AGENT_DECISION_MODEL_TYPE?.trim() || 'llm',
|
||||
})
|
||||
|
||||
const speechToTextModelConfig = (): StableModel => ({
|
||||
name: process.env[speechToTextModelNameEnv]?.trim() || 'gpt-4o-mini-transcribe',
|
||||
provider: process.env[speechToTextModelProviderEnv]?.trim() || 'openai',
|
||||
type: 'speech2text',
|
||||
})
|
||||
|
||||
const parseJsonEnv = (envName: string) => {
|
||||
const raw = process.env[envName]?.trim()
|
||||
if (!raw) return { ok: false as const, reason: `${envName} is required.` }
|
||||
@ -121,7 +130,7 @@ const parseJsonEnv = (envName: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const findChatModel = async (config: StableModel, title: string) => {
|
||||
const findModel = async (config: StableModel, title: string) => {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const response = await ctx.get(
|
||||
@ -237,7 +246,7 @@ const upsertStableProviderCredential = async (
|
||||
}
|
||||
}
|
||||
|
||||
const seedChatModel = async (
|
||||
const seedModel = async (
|
||||
context: SeedContext,
|
||||
{
|
||||
config,
|
||||
@ -247,7 +256,7 @@ const seedChatModel = async (
|
||||
title: string
|
||||
},
|
||||
) => {
|
||||
const existing = await findChatModel(config, title)
|
||||
const existing = await findModel(config, title)
|
||||
const resource = {
|
||||
id: `${existing?.provider ?? config.provider}/${existing?.name ?? config.name}`,
|
||||
kind: 'model',
|
||||
@ -303,7 +312,7 @@ const seedChatModel = async (
|
||||
}
|
||||
}
|
||||
|
||||
const seeded = await findChatModel(config, title)
|
||||
const seeded = await findModel(config, title)
|
||||
if (seeded?.status !== activeModelStatus) {
|
||||
return blocked(
|
||||
title,
|
||||
@ -319,17 +328,100 @@ const seedChatModel = async (
|
||||
}
|
||||
|
||||
const seedStableModel = async (context: SeedContext) =>
|
||||
seedChatModel(context, {
|
||||
seedModel(context, {
|
||||
config: stableModelConfig(),
|
||||
title: agentBuilderPreseededResources.stableChatModel,
|
||||
})
|
||||
|
||||
const seedAgentDecisionModel = async (context: SeedContext) =>
|
||||
seedChatModel(context, {
|
||||
seedModel(context, {
|
||||
config: agentDecisionModelConfig(),
|
||||
title: agentBuilderPreseededResources.agentDecisionChatModel,
|
||||
})
|
||||
|
||||
const getDefaultModel = async (modelType: string) => {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const response = await ctx.get(
|
||||
`/console/api/workspaces/current/default-model?${buildQuery({ model_type: modelType })}`,
|
||||
)
|
||||
await expectApiResponseOK(response, `Get default ${modelType} model`)
|
||||
const body = (await response.json()) as DefaultModelDataResponse
|
||||
return body.data
|
||||
} finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
const setDefaultModel = async (model: StableModel) => {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const response = await ctx.post('/console/api/workspaces/current/default-model', {
|
||||
data: {
|
||||
model_settings: [
|
||||
{
|
||||
model: model.name,
|
||||
model_type: model.type,
|
||||
provider: model.provider,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
await expectApiResponseOK(response, `Set default ${model.type} model`)
|
||||
} finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
const seedSpeechToTextModel = async (context: SeedContext) => {
|
||||
const config = speechToTextModelConfig()
|
||||
const title = agentBuilderPreseededResources.speechToTextModel
|
||||
const modelResult = await seedModel(context, { config, title })
|
||||
if (modelResult.status === 'blocked' || modelResult.status === 'skipped') return modelResult
|
||||
|
||||
const model = await findModel(config, title)
|
||||
if (!model || model.status !== activeModelStatus)
|
||||
return blocked(title, `${config.provider}/${config.name} is not active after model setup.`)
|
||||
|
||||
const resource = {
|
||||
id: `${model.provider}/${model.name}`,
|
||||
kind: 'model',
|
||||
name: title,
|
||||
}
|
||||
const defaultModel = await getDefaultModel(config.type)
|
||||
const isExpectedDefault =
|
||||
defaultModel?.model === model.name &&
|
||||
matchesProvider(defaultModel.provider.provider, model.provider)
|
||||
|
||||
if (isExpectedDefault)
|
||||
return modelResult.status === 'updated' ? modelResult : verified(title, resource)
|
||||
|
||||
if (context.dryRun)
|
||||
return skipped(
|
||||
title,
|
||||
`Would set ${model.provider}/${model.name} as the workspace default Speech-to-Text model.`,
|
||||
)
|
||||
|
||||
await setDefaultModel({
|
||||
name: model.name,
|
||||
provider: model.provider,
|
||||
type: config.type,
|
||||
})
|
||||
|
||||
const updatedDefaultModel = await getDefaultModel(config.type)
|
||||
if (
|
||||
updatedDefaultModel?.model !== model.name ||
|
||||
!matchesProvider(updatedDefaultModel.provider.provider, model.provider)
|
||||
) {
|
||||
return blocked(
|
||||
title,
|
||||
`${model.provider}/${model.name} was not selected as the workspace default Speech-to-Text model.`,
|
||||
)
|
||||
}
|
||||
|
||||
return updated(title, resource)
|
||||
}
|
||||
|
||||
type BuiltinToolProvider = {
|
||||
label?: { en_US?: string; zh_Hans?: string }
|
||||
name: string
|
||||
@ -1003,10 +1095,19 @@ const agentV2FullSeedTasks = (): SeedTask[] => [
|
||||
},
|
||||
]
|
||||
|
||||
const agentV2ExternalRuntimeSeedTasks = (): SeedTask[] => [
|
||||
...agentV2BaseSeedTasks(),
|
||||
{
|
||||
id: 'speech-to-text-model',
|
||||
title: agentBuilderPreseededResources.speechToTextModel,
|
||||
run: seedSpeechToTextModel,
|
||||
},
|
||||
]
|
||||
|
||||
export const createAgentV2SeedTasks = (profile: string = 'full'): SeedTask[] => {
|
||||
if (profile === 'full') return agentV2FullSeedTasks()
|
||||
|
||||
if (profile === 'external-runtime') return agentV2BaseSeedTasks()
|
||||
if (profile === 'external-runtime') return agentV2ExternalRuntimeSeedTasks()
|
||||
|
||||
throw new Error(`Unknown Agent V2 seed profile "${profile}".`)
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ import {
|
||||
import {
|
||||
skipMissingAgentBuilderAgentDecisionChatModel,
|
||||
skipMissingAgentBuilderBrokenChatModel,
|
||||
skipMissingAgentBuilderSpeechToTextModel,
|
||||
skipMissingAgentBuilderStableChatModel,
|
||||
} from '../../agent-v2/support/preflight/models'
|
||||
import { skipMissingPreseededTool } from '../../agent-v2/support/preflight/tools'
|
||||
@ -34,6 +35,13 @@ Given('the Agent Builder stable chat model is available', async function (this:
|
||||
this.agentBuilder.preflight.stableModel = stableModel
|
||||
})
|
||||
|
||||
Given('the workspace default speech-to-text model is active', async function (this: DifyWorld) {
|
||||
const speechToTextModel = await skipMissingAgentBuilderSpeechToTextModel(this)
|
||||
if (speechToTextModel === 'skipped') return speechToTextModel
|
||||
|
||||
this.agentBuilder.preflight.speechToTextModel = speechToTextModel
|
||||
})
|
||||
|
||||
Given('the Agent Builder agent-decision chat model is available', async function (this: DifyWorld) {
|
||||
const agentDecisionModel = await skipMissingAgentBuilderAgentDecisionChatModel(this)
|
||||
if (agentDecisionModel === 'skipped') return agentDecisionModel
|
||||
|
||||
118
e2e/features/step-definitions/agent-v2/speech-to-text.steps.ts
Normal file
118
e2e/features/step-definitions/agent-v2/speech-to-text.steps.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Given, Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import { voiceInputTestMaterial } from '../../../support/test-materials'
|
||||
import { createConfiguredTestAgent } from '../../agent-v2/support/agent'
|
||||
import {
|
||||
createAgentSoulConfigWithSpeechToText,
|
||||
normalAgentSoulConfig,
|
||||
} from '../../agent-v2/support/agent-soul'
|
||||
import { getCurrentAgentId } from './configure-helpers'
|
||||
|
||||
const getAgentInput = (world: DifyWorld) =>
|
||||
world.getPage().getByPlaceholder('Describe what your agent should do')
|
||||
|
||||
const normalizeFixturePhrase = (value: string) =>
|
||||
value
|
||||
.toLocaleLowerCase()
|
||||
.replaceAll('seven', '7')
|
||||
.replaceAll(/[^\p{L}\p{N}]+/gu, '')
|
||||
|
||||
Given(
|
||||
'an Agent v2 test agent with speech-to-text enabled has been created via API',
|
||||
async function (this: DifyWorld) {
|
||||
if (!this.agentBuilder.preflight.speechToTextModel) {
|
||||
throw new Error(
|
||||
'Create a speech-to-text Agent v2 test agent after the default Speech-to-Text model preflight.',
|
||||
)
|
||||
}
|
||||
|
||||
const agent = await createConfiguredTestAgent({
|
||||
agentSoul: createAgentSoulConfigWithSpeechToText(normalAgentSoulConfig),
|
||||
})
|
||||
this.createdAgentIds.push(agent.id)
|
||||
this.lastCreatedAgentName = agent.name
|
||||
this.lastCreatedAgentRole = agent.role ?? undefined
|
||||
},
|
||||
)
|
||||
|
||||
When('I start Agent v2 voice input', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
|
||||
await expect(getAgentInput(this)).toBeVisible({ timeout: 30_000 })
|
||||
await page.getByRole('button', { name: 'Voice input' }).click()
|
||||
await expect(page.getByText('Speak now...')).toBeVisible({ timeout: 30_000 })
|
||||
})
|
||||
|
||||
When(
|
||||
'I stop Agent v2 voice input after the fixture speech has played',
|
||||
async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const agentId = getCurrentAgentId(this)
|
||||
|
||||
await expect(page.getByTestId('voice-input-timer')).toHaveText(
|
||||
voiceInputTestMaterial.recordingDuration,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === 'POST' &&
|
||||
new URL(response.url()).pathname === `/console/api/agent/${agentId}/audio-to-text`,
|
||||
)
|
||||
await page.getByRole('button', { name: 'Stop recording' }).click()
|
||||
const response = await responsePromise
|
||||
const request = response.request()
|
||||
|
||||
this.agentBuilder.speechToText.request = {
|
||||
contentType: request.headers()['content-type'] ?? '',
|
||||
path: new URL(response.url()).pathname,
|
||||
status: response.status(),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Then('the Agent v2 speech-to-text request should succeed', async function (this: DifyWorld) {
|
||||
const request = this.agentBuilder.speechToText.request
|
||||
if (!request) throw new Error('No Agent v2 speech-to-text request was captured.')
|
||||
|
||||
expect(request).toEqual(
|
||||
expect.objectContaining({
|
||||
contentType: expect.stringContaining('multipart/form-data'),
|
||||
path: `/console/api/agent/${getCurrentAgentId(this)}/audio-to-text`,
|
||||
status: 200,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
Then(
|
||||
'the transcribed fixture phrase {string} should appear in the Agent v2 input',
|
||||
async function (this: DifyWorld, expectedPhrase: string) {
|
||||
const input = getAgentInput(this)
|
||||
await expect
|
||||
.poll(async () => normalizeFixturePhrase(await input.inputValue()), { timeout: 60_000 })
|
||||
.toBe(normalizeFixturePhrase(expectedPhrase))
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'the Agent v2 input should regain focus after transcription',
|
||||
async function (this: DifyWorld) {
|
||||
const input = getAgentInput(this)
|
||||
await expect(input).toBeFocused()
|
||||
|
||||
const selection = await input.evaluate((element) => {
|
||||
const textarea = element as HTMLTextAreaElement
|
||||
return {
|
||||
end: textarea.selectionEnd,
|
||||
length: textarea.value.length,
|
||||
start: textarea.selectionStart,
|
||||
}
|
||||
})
|
||||
expect(selection).toEqual({
|
||||
end: selection.length,
|
||||
length: selection.length,
|
||||
start: selection.length,
|
||||
})
|
||||
},
|
||||
)
|
||||
@ -9,6 +9,7 @@ import { chromium } from '@playwright/test'
|
||||
import { AUTH_BOOTSTRAP_TIMEOUT_MS, ensureAuthenticatedState } from '../../fixtures/auth'
|
||||
import { deleteTestApp } from '../../support/api'
|
||||
import { deleteTestDataset } from '../../support/datasets'
|
||||
import { getVoiceInputTestMaterialPath } from '../../support/test-materials'
|
||||
import { deleteBuiltinToolCredential } from '../../support/tools'
|
||||
import { baseURL, cucumberHeadless, cucumberSlowMo } from '../../test-env'
|
||||
import { deleteTestAgent } from '../agent-v2/support/agent'
|
||||
@ -22,6 +23,7 @@ const e2eRoot = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const artifactsDir = path.join(e2eRoot, 'cucumber-report', 'artifacts')
|
||||
|
||||
let browser: Browser | undefined
|
||||
let microphoneBrowserPromise: Promise<Browser> | undefined
|
||||
|
||||
setDefaultTimeout(60_000)
|
||||
|
||||
@ -101,17 +103,43 @@ BeforeAll({ timeout: AUTH_BOOTSTRAP_TIMEOUT_MS }, async () => {
|
||||
await ensureAuthenticatedState(browser, baseURL)
|
||||
})
|
||||
|
||||
const getMicrophoneBrowser = () => {
|
||||
microphoneBrowserPromise ??= chromium.launch({
|
||||
args: [
|
||||
'--use-fake-device-for-media-stream',
|
||||
'--use-fake-ui-for-media-stream',
|
||||
`--use-file-for-fake-audio-capture=${getVoiceInputTestMaterialPath()}%noloop`,
|
||||
],
|
||||
headless: cucumberHeadless,
|
||||
slowMo: cucumberSlowMo,
|
||||
})
|
||||
|
||||
return microphoneBrowserPromise
|
||||
}
|
||||
|
||||
Before(async function (this: DifyWorld, { pickle }) {
|
||||
if (!browser) throw new Error('Shared Playwright browser is not available.')
|
||||
|
||||
const isUnauthenticatedScenario = pickle.tags.some((tag) => tag.name === '@unauthenticated')
|
||||
const scenarioTags = pickle.tags.map((tag) => tag.name)
|
||||
const isMicrophoneScenario = scenarioTags.includes('@microphone')
|
||||
const isUnauthenticatedScenario = scenarioTags.includes('@unauthenticated')
|
||||
const scenarioBrowser = isMicrophoneScenario ? await getMicrophoneBrowser() : browser
|
||||
|
||||
if (isUnauthenticatedScenario) await this.startUnauthenticatedSession(browser)
|
||||
else await this.startAuthenticatedSession(browser)
|
||||
if (isUnauthenticatedScenario) await this.startUnauthenticatedSession(scenarioBrowser)
|
||||
else await this.startAuthenticatedSession(scenarioBrowser)
|
||||
|
||||
if (isMicrophoneScenario) {
|
||||
if (!this.context)
|
||||
throw new Error('Playwright context has not been initialized for the microphone scenario.')
|
||||
|
||||
await this.context.grantPermissions(['microphone'], {
|
||||
origin: new URL(baseURL).origin,
|
||||
})
|
||||
}
|
||||
|
||||
this.scenarioStartedAt = Date.now()
|
||||
|
||||
const tags = pickle.tags.map((tag) => tag.name).join(' ')
|
||||
const tags = scenarioTags.join(' ')
|
||||
console.warn(`[e2e] start ${pickle.name}${tags ? ` ${tags}` : ''}`)
|
||||
})
|
||||
|
||||
@ -197,6 +225,9 @@ After(async function (this: DifyWorld, { pickle, result }) {
|
||||
})
|
||||
|
||||
AfterAll(async () => {
|
||||
const microphoneBrowser = await microphoneBrowserPromise?.catch(() => undefined)
|
||||
await microphoneBrowser?.close()
|
||||
await browser?.close()
|
||||
microphoneBrowserPromise = undefined
|
||||
browser = undefined
|
||||
})
|
||||
|
||||
@ -36,12 +36,18 @@ export type AgentV2WorkflowOutputVariable = {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
export type AgentBuilderSpeechToTextRequest = {
|
||||
contentType: string
|
||||
path: string
|
||||
status: number
|
||||
}
|
||||
|
||||
export const createAgentBuilderWorldState = () => ({
|
||||
preflight: {
|
||||
agentDecisionModel: undefined as AgentBuilderChatModel | undefined,
|
||||
brokenModel: undefined as AgentBuilderChatModel | undefined,
|
||||
preseededResources: {} as Record<string, AgentBuilderPreseededResource>,
|
||||
speechToTextModel: undefined as AgentBuilderChatModel | undefined,
|
||||
stableModel: undefined as AgentBuilderChatModel | undefined,
|
||||
},
|
||||
accessPoint: {
|
||||
@ -57,6 +63,9 @@ export const createAgentBuilderWorldState = () => ({
|
||||
configure: {
|
||||
concurrentPage: undefined as Page | undefined,
|
||||
},
|
||||
speechToText: {
|
||||
request: undefined as AgentBuilderSpeechToTextRequest | undefined,
|
||||
},
|
||||
workflow: {
|
||||
agentConsolePage: undefined as Page | undefined,
|
||||
outputVariables: [] as AgentV2WorkflowOutputVariable[],
|
||||
|
||||
BIN
e2e/fixtures/test-materials/voice-input.wav
Normal file
BIN
e2e/fixtures/test-materials/voice-input.wav
Normal file
Binary file not shown.
@ -76,6 +76,9 @@ export const validateE2eEnv = () =>
|
||||
E2E_ADMIN_PASSWORD: process.env.E2E_ADMIN_PASSWORD,
|
||||
E2E_AGENT_BACKEND_PORT: process.env.E2E_AGENT_BACKEND_PORT,
|
||||
E2E_AGENT_BACKEND_URL: process.env.E2E_AGENT_BACKEND_URL,
|
||||
E2E_AGENT_DECISION_MODEL_NAME: process.env.E2E_AGENT_DECISION_MODEL_NAME,
|
||||
E2E_AGENT_DECISION_MODEL_PROVIDER: process.env.E2E_AGENT_DECISION_MODEL_PROVIDER,
|
||||
E2E_AGENT_DECISION_MODEL_TYPE: process.env.E2E_AGENT_DECISION_MODEL_TYPE,
|
||||
E2E_API_URL: process.env.E2E_API_URL,
|
||||
E2E_BASE_URL: process.env.E2E_BASE_URL,
|
||||
E2E_BROKEN_MODEL_NAME: process.env.E2E_BROKEN_MODEL_NAME,
|
||||
@ -101,6 +104,8 @@ export const validateE2eEnv = () =>
|
||||
E2E_SHELLCTL_IMAGE: process.env.E2E_SHELLCTL_IMAGE,
|
||||
E2E_SHELLCTL_PORT: process.env.E2E_SHELLCTL_PORT,
|
||||
E2E_SHELLCTL_URL: process.env.E2E_SHELLCTL_URL,
|
||||
E2E_SPEECH_TO_TEXT_MODEL_NAME: process.env.E2E_SPEECH_TO_TEXT_MODEL_NAME,
|
||||
E2E_SPEECH_TO_TEXT_MODEL_PROVIDER: process.env.E2E_SPEECH_TO_TEXT_MODEL_PROVIDER,
|
||||
E2E_START_AGENT_BACKEND: process.env.E2E_START_AGENT_BACKEND,
|
||||
E2E_STABLE_MODEL_NAME: process.env.E2E_STABLE_MODEL_NAME,
|
||||
E2E_STABLE_MODEL_PROVIDER: process.env.E2E_STABLE_MODEL_PROVIDER,
|
||||
@ -113,6 +118,9 @@ export const validateE2eEnv = () =>
|
||||
E2E_ADMIN_PASSWORD: z.string().min(1).optional(),
|
||||
E2E_AGENT_BACKEND_PORT: z.coerce.number().int().positive().max(65535).optional(),
|
||||
E2E_AGENT_BACKEND_URL: z.url().optional(),
|
||||
E2E_AGENT_DECISION_MODEL_NAME: z.string().min(1).optional(),
|
||||
E2E_AGENT_DECISION_MODEL_PROVIDER: z.string().min(1).optional(),
|
||||
E2E_AGENT_DECISION_MODEL_TYPE: z.string().min(1).optional(),
|
||||
E2E_API_URL: z.url().optional(),
|
||||
E2E_BASE_URL: z.url().optional(),
|
||||
E2E_BROKEN_MODEL_NAME: z.string().min(1).optional(),
|
||||
@ -137,6 +145,8 @@ export const validateE2eEnv = () =>
|
||||
E2E_SHELLCTL_IMAGE: z.string().min(1).optional(),
|
||||
E2E_SHELLCTL_PORT: z.coerce.number().int().positive().max(65535).optional(),
|
||||
E2E_SHELLCTL_URL: z.url().optional(),
|
||||
E2E_SPEECH_TO_TEXT_MODEL_NAME: z.string().min(1).optional(),
|
||||
E2E_SPEECH_TO_TEXT_MODEL_PROVIDER: z.string().min(1).optional(),
|
||||
E2E_START_AGENT_BACKEND: booleanString.optional(),
|
||||
E2E_STABLE_MODEL_NAME: z.string().min(1).optional(),
|
||||
E2E_STABLE_MODEL_PROVIDER: z.string().min(1).optional(),
|
||||
|
||||
@ -10,7 +10,14 @@ export const generatedTestMaterialsDir = fileURLToPath(
|
||||
new URL('../.generated-test-materials', import.meta.url),
|
||||
)
|
||||
|
||||
export const voiceInputTestMaterial = {
|
||||
fileName: 'voice-input.wav',
|
||||
recordingDuration: '00:07',
|
||||
} as const
|
||||
|
||||
export const getTestMaterialPath = (fileName: string) => path.join(testMaterialsDir, fileName)
|
||||
export const getVoiceInputTestMaterialPath = () =>
|
||||
getTestMaterialPath(voiceInputTestMaterial.fileName)
|
||||
|
||||
export async function getGeneratedTextMaterialPath({
|
||||
fileName,
|
||||
|
||||
@ -2308,11 +2308,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/voice-input/__tests__/index.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/base/voice-input/index.stories.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 2
|
||||
@ -2330,19 +2325,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/voice-input/index.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 2
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/voice-input/utils.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/billing/plan/assets/index.tsx": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 4
|
||||
@ -7078,11 +7060,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/types/lamejs.d.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/types/pipeline.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
|
||||
@ -117,6 +117,9 @@ import {
|
||||
zPostAgentByAgentIdApiEnableResponse,
|
||||
zPostAgentByAgentIdApiKeysPath,
|
||||
zPostAgentByAgentIdApiKeysResponse,
|
||||
zPostAgentByAgentIdAudioToTextBody,
|
||||
zPostAgentByAgentIdAudioToTextPath,
|
||||
zPostAgentByAgentIdAudioToTextResponse,
|
||||
zPostAgentByAgentIdBuildChatFinalizePath,
|
||||
zPostAgentByAgentIdBuildChatFinalizeResponse,
|
||||
zPostAgentByAgentIdBuildDraftApplyPath,
|
||||
@ -269,9 +272,33 @@ export const apiKeys = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a build-draft Agent App turn that asks the agent to push config updates
|
||||
* Transcribe audio using the current Agent debug configuration
|
||||
*/
|
||||
export const post3 = oc
|
||||
.route({
|
||||
description: 'Transcribe audio using the current Agent debug configuration',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postAgentByAgentIdAudioToText',
|
||||
path: '/agent/{agent_id}/audio-to-text',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zPostAgentByAgentIdAudioToTextBody,
|
||||
params: zPostAgentByAgentIdAudioToTextPath,
|
||||
}),
|
||||
)
|
||||
.output(zPostAgentByAgentIdAudioToTextResponse)
|
||||
|
||||
export const audioToText = {
|
||||
post: post3,
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a build-draft Agent App turn that asks the agent to push config updates
|
||||
*/
|
||||
export const post4 = oc
|
||||
.route({
|
||||
description: 'Run a build-draft Agent App turn that asks the agent to push config updates',
|
||||
inputStructure: 'detailed',
|
||||
@ -284,14 +311,14 @@ export const post3 = oc
|
||||
.output(zPostAgentByAgentIdBuildChatFinalizeResponse)
|
||||
|
||||
export const finalize = {
|
||||
post: post3,
|
||||
post: post4,
|
||||
}
|
||||
|
||||
export const buildChat = {
|
||||
finalize,
|
||||
}
|
||||
|
||||
export const post4 = oc
|
||||
export const post5 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@ -303,10 +330,10 @@ export const post4 = oc
|
||||
.output(zPostAgentByAgentIdBuildDraftApplyResponse)
|
||||
|
||||
export const apply = {
|
||||
post: post4,
|
||||
post: post5,
|
||||
}
|
||||
|
||||
export const post5 = oc
|
||||
export const post6 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@ -323,7 +350,7 @@ export const post5 = oc
|
||||
.output(zPostAgentByAgentIdBuildDraftCheckoutResponse)
|
||||
|
||||
export const checkout = {
|
||||
post: post5,
|
||||
post: post6,
|
||||
}
|
||||
|
||||
export const delete2 = oc
|
||||
@ -395,7 +422,7 @@ export const byMessageId = {
|
||||
/**
|
||||
* Stop a running Agent App chat message generation
|
||||
*/
|
||||
export const post6 = oc
|
||||
export const post7 = oc
|
||||
.route({
|
||||
description: 'Stop a running Agent App chat message generation',
|
||||
inputStructure: 'detailed',
|
||||
@ -408,7 +435,7 @@ export const post6 = oc
|
||||
.output(zPostAgentByAgentIdChatMessagesByTaskIdStopResponse)
|
||||
|
||||
export const stop = {
|
||||
post: post6,
|
||||
post: post7,
|
||||
}
|
||||
|
||||
export const byTaskId = {
|
||||
@ -456,7 +483,7 @@ export const candidates = {
|
||||
get: get7,
|
||||
}
|
||||
|
||||
export const post7 = oc
|
||||
export const post8 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@ -473,7 +500,7 @@ export const post7 = oc
|
||||
.output(zPostAgentByAgentIdComposerValidateResponse)
|
||||
|
||||
export const validate = {
|
||||
post: post7,
|
||||
post: post8,
|
||||
}
|
||||
|
||||
export const get8 = oc
|
||||
@ -583,7 +610,7 @@ export const get11 = oc
|
||||
)
|
||||
.output(zGetAgentByAgentIdConfigFilesResponse)
|
||||
|
||||
export const post8 = oc
|
||||
export const post9 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@ -603,7 +630,7 @@ export const post8 = oc
|
||||
|
||||
export const files = {
|
||||
get: get11,
|
||||
post: post8,
|
||||
post: post9,
|
||||
byName,
|
||||
}
|
||||
|
||||
@ -627,7 +654,7 @@ export const manifest = {
|
||||
get: get12,
|
||||
}
|
||||
|
||||
export const post9 = oc
|
||||
export const post10 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@ -646,7 +673,7 @@ export const post9 = oc
|
||||
.output(zPostAgentByAgentIdConfigSkillsUploadResponse)
|
||||
|
||||
export const upload = {
|
||||
post: post9,
|
||||
post: post10,
|
||||
}
|
||||
|
||||
export const get13 = oc
|
||||
@ -801,7 +828,7 @@ export const config = {
|
||||
skills,
|
||||
}
|
||||
|
||||
export const post10 = oc
|
||||
export const post11 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@ -814,10 +841,10 @@ export const post10 = oc
|
||||
.output(zPostAgentByAgentIdCopyResponse)
|
||||
|
||||
export const copy = {
|
||||
post: post10,
|
||||
post: post11,
|
||||
}
|
||||
|
||||
export const post11 = oc
|
||||
export const post12 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@ -829,7 +856,7 @@ export const post11 = oc
|
||||
.output(zPostAgentByAgentIdDebugConversationRefreshResponse)
|
||||
|
||||
export const refresh = {
|
||||
post: post11,
|
||||
post: post12,
|
||||
}
|
||||
|
||||
export const debugConversation = {
|
||||
@ -961,7 +988,7 @@ export const drive = {
|
||||
/**
|
||||
* Update an Agent App's presentation features (opener, follow-up, citations, ...)
|
||||
*/
|
||||
export const post12 = oc
|
||||
export const post13 = oc
|
||||
.route({
|
||||
description: "Update an Agent App's presentation features (opener, follow-up, citations, ...)",
|
||||
inputStructure: 'detailed',
|
||||
@ -976,13 +1003,13 @@ export const post12 = oc
|
||||
.output(zPostAgentByAgentIdFeaturesResponse)
|
||||
|
||||
export const features = {
|
||||
post: post12,
|
||||
post: post13,
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update Agent App message feedback
|
||||
*/
|
||||
export const post13 = oc
|
||||
export const post14 = oc
|
||||
.route({
|
||||
description: 'Create or update Agent App message feedback',
|
||||
inputStructure: 'detailed',
|
||||
@ -997,7 +1024,7 @@ export const post13 = oc
|
||||
.output(zPostAgentByAgentIdFeedbacksResponse)
|
||||
|
||||
export const feedbacks = {
|
||||
post: post13,
|
||||
post: post14,
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1020,7 +1047,7 @@ export const delete5 = oc
|
||||
/**
|
||||
* Commit an uploaded file into the Agent App drive under files/<name>
|
||||
*/
|
||||
export const post14 = oc
|
||||
export const post15 = oc
|
||||
.route({
|
||||
description: 'Commit an uploaded file into the Agent App drive under files/<name>',
|
||||
inputStructure: 'detailed',
|
||||
@ -1035,7 +1062,7 @@ export const post14 = oc
|
||||
|
||||
export const files4 = {
|
||||
delete: delete5,
|
||||
post: post14,
|
||||
post: post15,
|
||||
}
|
||||
|
||||
export const get24 = oc
|
||||
@ -1118,7 +1145,7 @@ export const messages2 = {
|
||||
byMessageId: byMessageId2,
|
||||
}
|
||||
|
||||
export const post15 = oc
|
||||
export const post16 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@ -1130,7 +1157,7 @@ export const post15 = oc
|
||||
.output(zPostAgentByAgentIdPublishResponse)
|
||||
|
||||
export const publish = {
|
||||
post: post15,
|
||||
post: post16,
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1179,7 +1206,7 @@ export const read = {
|
||||
/**
|
||||
* Upload one Agent App sandbox file and return a signed download URL
|
||||
*/
|
||||
export const post16 = oc
|
||||
export const post17 = oc
|
||||
.route({
|
||||
description: 'Upload one Agent App sandbox file and return a signed download URL',
|
||||
inputStructure: 'detailed',
|
||||
@ -1197,7 +1224,7 @@ export const post16 = oc
|
||||
.output(zPostAgentByAgentIdSandboxFilesUploadResponse)
|
||||
|
||||
export const upload2 = {
|
||||
post: post16,
|
||||
post: post17,
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1249,7 +1276,7 @@ export const sandbox = {
|
||||
/**
|
||||
* Upload + standardize a Skill into an Agent App drive
|
||||
*/
|
||||
export const post17 = oc
|
||||
export const post18 = oc
|
||||
.route({
|
||||
description: 'Upload + standardize a Skill into an Agent App drive',
|
||||
inputStructure: 'detailed',
|
||||
@ -1268,13 +1295,13 @@ export const post17 = oc
|
||||
.output(zPostAgentByAgentIdSkillsUploadResponse)
|
||||
|
||||
export const upload3 = {
|
||||
post: post17,
|
||||
post: post18,
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer CLI tool + ENV suggestions from a standardized Agent App skill
|
||||
*/
|
||||
export const post18 = oc
|
||||
export const post19 = oc
|
||||
.route({
|
||||
description: 'Infer CLI tool + ENV suggestions from a standardized Agent App skill',
|
||||
inputStructure: 'detailed',
|
||||
@ -1287,7 +1314,7 @@ export const post18 = oc
|
||||
.output(zPostAgentByAgentIdSkillsBySlugInferToolsResponse)
|
||||
|
||||
export const inferTools = {
|
||||
post: post18,
|
||||
post: post19,
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1339,7 +1366,7 @@ export const statistics = {
|
||||
summary,
|
||||
}
|
||||
|
||||
export const post19 = oc
|
||||
export const post20 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@ -1351,7 +1378,7 @@ export const post19 = oc
|
||||
.output(zPostAgentByAgentIdVersionsByVersionIdRestoreResponse)
|
||||
|
||||
export const restore = {
|
||||
post: post19,
|
||||
post: post20,
|
||||
}
|
||||
|
||||
export const get33 = oc
|
||||
@ -1427,6 +1454,7 @@ export const byAgentId = {
|
||||
apiAccess,
|
||||
apiEnable,
|
||||
apiKeys,
|
||||
audioToText,
|
||||
buildChat,
|
||||
buildDraft,
|
||||
chatMessages,
|
||||
@ -1460,7 +1488,7 @@ export const get36 = oc
|
||||
.input(z.object({ query: zGetAgentQuery.optional() }))
|
||||
.output(zGetAgentResponse)
|
||||
|
||||
export const post20 = oc
|
||||
export const post21 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@ -1474,7 +1502,7 @@ export const post20 = oc
|
||||
|
||||
export const agent = {
|
||||
get: get36,
|
||||
post: post20,
|
||||
post: post21,
|
||||
inviteOptions,
|
||||
byAgentId,
|
||||
}
|
||||
|
||||
@ -111,6 +111,10 @@ export type ApiKeyItem = {
|
||||
type: string
|
||||
}
|
||||
|
||||
export type AudioTranscriptResponse = {
|
||||
text: string
|
||||
}
|
||||
|
||||
export type SimpleResultResponse = {
|
||||
result: string
|
||||
}
|
||||
@ -2160,6 +2164,31 @@ export type DeleteAgentByAgentIdApiKeysByApiKeyIdResponses = {
|
||||
export type DeleteAgentByAgentIdApiKeysByApiKeyIdResponse =
|
||||
DeleteAgentByAgentIdApiKeysByApiKeyIdResponses[keyof DeleteAgentByAgentIdApiKeysByApiKeyIdResponses]
|
||||
|
||||
export type PostAgentByAgentIdAudioToTextData = {
|
||||
body: {
|
||||
draft_type?: 'debug_build' | 'draft'
|
||||
file: Blob | File
|
||||
}
|
||||
path: {
|
||||
agent_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/agent/{agent_id}/audio-to-text'
|
||||
}
|
||||
|
||||
export type PostAgentByAgentIdAudioToTextErrors = {
|
||||
400: unknown
|
||||
404: unknown
|
||||
413: unknown
|
||||
}
|
||||
|
||||
export type PostAgentByAgentIdAudioToTextResponses = {
|
||||
200: AudioTranscriptResponse
|
||||
}
|
||||
|
||||
export type PostAgentByAgentIdAudioToTextResponse =
|
||||
PostAgentByAgentIdAudioToTextResponses[keyof PostAgentByAgentIdAudioToTextResponses]
|
||||
|
||||
export type PostAgentByAgentIdBuildChatFinalizeData = {
|
||||
body?: never
|
||||
path: {
|
||||
|
||||
@ -47,6 +47,13 @@ export const zApiKeyList = z.object({
|
||||
data: z.array(zApiKeyItem),
|
||||
})
|
||||
|
||||
/**
|
||||
* AudioTranscriptResponse
|
||||
*/
|
||||
export const zAudioTranscriptResponse = z.object({
|
||||
text: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SimpleResultResponse
|
||||
*/
|
||||
@ -2842,6 +2849,20 @@ export const zDeleteAgentByAgentIdApiKeysByApiKeyIdPath = z.object({
|
||||
*/
|
||||
export const zDeleteAgentByAgentIdApiKeysByApiKeyIdResponse = z.void()
|
||||
|
||||
export const zPostAgentByAgentIdAudioToTextBody = z.object({
|
||||
draft_type: z.enum(['debug_build', 'draft']).optional().default('draft'),
|
||||
file: z.custom<Blob | File>(),
|
||||
})
|
||||
|
||||
export const zPostAgentByAgentIdAudioToTextPath = z.object({
|
||||
agent_id: z.uuid(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Audio transcription successful
|
||||
*/
|
||||
export const zPostAgentByAgentIdAudioToTextResponse = zAudioTranscriptResponse
|
||||
|
||||
export const zPostAgentByAgentIdBuildChatFinalizePath = z.object({
|
||||
agent_id: z.uuid(),
|
||||
})
|
||||
|
||||
@ -341,6 +341,7 @@ import {
|
||||
zPostAppsByAppIdApiEnableBody,
|
||||
zPostAppsByAppIdApiEnablePath,
|
||||
zPostAppsByAppIdApiEnableResponse,
|
||||
zPostAppsByAppIdAudioToTextBody,
|
||||
zPostAppsByAppIdAudioToTextPath,
|
||||
zPostAppsByAppIdAudioToTextResponse,
|
||||
zPostAppsByAppIdChatMessagesByTaskIdStopPath,
|
||||
@ -1768,7 +1769,9 @@ export const post20 = oc
|
||||
path: '/apps/{app_id}/audio-to-text',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zPostAppsByAppIdAudioToTextPath }))
|
||||
.input(
|
||||
z.object({ body: zPostAppsByAppIdAudioToTextBody, params: zPostAppsByAppIdAudioToTextPath }),
|
||||
)
|
||||
.output(zPostAppsByAppIdAudioToTextResponse)
|
||||
|
||||
export const audioToText = {
|
||||
|
||||
@ -4357,7 +4357,9 @@ export type PostAppsByAppIdApiEnableResponse =
|
||||
PostAppsByAppIdApiEnableResponses[keyof PostAppsByAppIdApiEnableResponses]
|
||||
|
||||
export type PostAppsByAppIdAudioToTextData = {
|
||||
body?: never
|
||||
body: {
|
||||
file: Blob | File
|
||||
}
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
|
||||
@ -5094,6 +5094,10 @@ export const zPostAppsByAppIdApiEnablePath = z.object({
|
||||
*/
|
||||
export const zPostAppsByAppIdApiEnableResponse = zAppDetail
|
||||
|
||||
export const zPostAppsByAppIdAudioToTextBody = z.object({
|
||||
file: z.custom<Blob | File>(),
|
||||
})
|
||||
|
||||
export const zPostAppsByAppIdAudioToTextPath = z.object({
|
||||
app_id: z.uuid(),
|
||||
})
|
||||
|
||||
153
pnpm-lock.yaml
generated
153
pnpm-lock.yaml
generated
@ -84,6 +84,9 @@ catalogs:
|
||||
'@mdx-js/rollup':
|
||||
specifier: 3.1.1
|
||||
version: 3.1.1
|
||||
'@mediabunny/mp3-encoder':
|
||||
specifier: 1.50.7
|
||||
version: 1.50.7
|
||||
'@monaco-editor/react':
|
||||
specifier: 4.7.0
|
||||
version: 4.7.0
|
||||
@ -408,9 +411,6 @@ catalogs:
|
||||
jotai-tanstack-query:
|
||||
specifier: 0.11.0
|
||||
version: 0.11.0
|
||||
js-audio-recorder:
|
||||
specifier: 1.0.7
|
||||
version: 1.0.7
|
||||
js-cookie:
|
||||
specifier: 3.0.8
|
||||
version: 3.0.8
|
||||
@ -429,9 +429,6 @@ catalogs:
|
||||
ky:
|
||||
specifier: 2.0.2
|
||||
version: 2.0.2
|
||||
lamejs:
|
||||
specifier: 1.2.1
|
||||
version: 1.2.1
|
||||
lexical:
|
||||
specifier: 0.46.0
|
||||
version: 0.46.0
|
||||
@ -441,6 +438,9 @@ catalogs:
|
||||
loro-crdt:
|
||||
specifier: 1.13.6
|
||||
version: 1.13.6
|
||||
mediabunny:
|
||||
specifier: 1.50.7
|
||||
version: 1.50.7
|
||||
mermaid:
|
||||
specifier: 11.16.0
|
||||
version: 11.16.0
|
||||
@ -1155,6 +1155,9 @@ importers:
|
||||
'@lexical/utils':
|
||||
specifier: 'catalog:'
|
||||
version: 0.46.0(@typescript/typescript6@6.0.2)
|
||||
'@mediabunny/mp3-encoder':
|
||||
specifier: 'catalog:'
|
||||
version: 1.50.7(mediabunny@1.50.7)
|
||||
'@monaco-editor/react':
|
||||
specifier: 'catalog:'
|
||||
version: 4.7.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@ -1299,9 +1302,6 @@ importers:
|
||||
jotai-tanstack-query:
|
||||
specifier: 'catalog:'
|
||||
version: 0.11.0(@tanstack/query-core@5.101.2)(@tanstack/react-query@5.101.2(react@19.2.7))(jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)
|
||||
js-audio-recorder:
|
||||
specifier: 'catalog:'
|
||||
version: 1.0.7
|
||||
js-cookie:
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.8
|
||||
@ -1317,15 +1317,15 @@ importers:
|
||||
ky:
|
||||
specifier: 'catalog:'
|
||||
version: 2.0.2
|
||||
lamejs:
|
||||
specifier: 'catalog:'
|
||||
version: 1.2.1
|
||||
lexical:
|
||||
specifier: 'catalog:'
|
||||
version: 0.46.0(@typescript/typescript6@6.0.2)
|
||||
loro-crdt:
|
||||
specifier: 'catalog:'
|
||||
version: 1.13.6
|
||||
mediabunny:
|
||||
specifier: 'catalog:'
|
||||
version: 1.50.7
|
||||
mermaid:
|
||||
specifier: 'catalog:'
|
||||
version: 11.16.0
|
||||
@ -1752,7 +1752,6 @@ packages:
|
||||
|
||||
'@antfu/eslint-config@9.1.0':
|
||||
resolution: {integrity: sha512-Vtjt+W/6/bV9hgQG8AiWG66OpAeLKmpo5bZaAmUdRvZsMyirVvIJeHnbM4XUJf4urIuLvK0gGSvXD74PI3ZnGQ==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@angular-eslint/eslint-plugin': ^21.1.0
|
||||
'@angular-eslint/eslint-plugin-template': ^21.1.0
|
||||
@ -1870,7 +1869,6 @@ packages:
|
||||
'@babel/parser@7.29.7':
|
||||
resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/runtime@7.29.7':
|
||||
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||
@ -1973,11 +1971,9 @@ packages:
|
||||
'@cucumber/cucumber@13.0.0':
|
||||
resolution: {integrity: sha512-lUD/IxGZXbfSP+pd7zaYvJIJXBaTZ36CmQxcLDYkilGH8y4ycaOnXe7ll0QFukYFh9/1x6S7pw6lYspJg6g7jw==}
|
||||
engines: {node: 22 || 24 || >=26}
|
||||
hasBin: true
|
||||
|
||||
'@cucumber/gherkin-streams@6.0.0':
|
||||
resolution: {integrity: sha512-HLSHMmdDH0vCr7vsVEURcDA4WwnRLdjkhqr6a4HQ3i4RFK1wiDGPjBGVdGJLyuXuRdJpJbFc6QxHvT8pU4t6jw==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@cucumber/gherkin': '>=22.0.0'
|
||||
'@cucumber/message-streams': '>=4.0.0'
|
||||
@ -1985,7 +1981,6 @@ packages:
|
||||
|
||||
'@cucumber/gherkin-utils@11.0.0':
|
||||
resolution: {integrity: sha512-LJ+s4+TepHTgdKWDR4zbPyT7rQjmYIcukTwNbwNwgqr6i8Gjcmzf6NmtbYDA19m1ZFg6kWbFsmHnj37ZuX+kZA==}
|
||||
hasBin: true
|
||||
|
||||
'@cucumber/gherkin@38.0.0':
|
||||
resolution: {integrity: sha512-duEXK+KDfQUzu3vsSzXjkxQ2tirF5PRsc1Xrts6THKHJO6mjw4RjM8RV+vliuDasmhhrmdLcOcM7d9nurNTJKw==}
|
||||
@ -2416,7 +2411,6 @@ packages:
|
||||
'@hey-api/openapi-ts@0.98.2':
|
||||
resolution: {integrity: sha512-2nVJXH8tpFPGTBOhxyjEd1Jw0hsRqJqeTQW3kltAjVdSU4YWxeu97x5sgNOmsbsfeg6Dqz7Wfzs26walBOuswA==}
|
||||
engines: {node: '>=22.18.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: '>=5.5.3 || >=6.0.0 || 6.0.1-rc'
|
||||
|
||||
@ -3027,6 +3021,11 @@ packages:
|
||||
peerDependencies:
|
||||
rollup: 4.62.2
|
||||
|
||||
'@mediabunny/mp3-encoder@1.50.7':
|
||||
resolution: {integrity: sha512-nYhphp1q9xSBRL4rhMg2yw63Krw2lAy4i7Lv6WjqWYabJayOd5ckHV7jB6Rn8A0SeqdUl5fwwia5uRJGcujNrg==}
|
||||
peerDependencies:
|
||||
mediabunny: ^1.0.0
|
||||
|
||||
'@mermaid-js/parser@1.2.0':
|
||||
resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==}
|
||||
|
||||
@ -4047,7 +4046,6 @@ packages:
|
||||
'@playwright/test@1.61.1':
|
||||
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@polka/url@1.0.0-next.29':
|
||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||
@ -4364,7 +4362,6 @@ packages:
|
||||
'@shuding/opentype.js@1.4.0-beta.0':
|
||||
resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==}
|
||||
engines: {node: '>= 8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@sindresorhus/base62@1.0.0':
|
||||
resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==}
|
||||
@ -4679,7 +4676,6 @@ packages:
|
||||
'@tanstack/devtools-event-client@0.4.4':
|
||||
resolution: {integrity: sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@tanstack/eslint-plugin-query@5.101.2':
|
||||
resolution: {integrity: sha512-cPE99s3XZwlObfn8lCezT4j4JLj2CVzpIEywx0H4hzfPsX/o9QhdwaOwcDXxrQAqx2ds7TbvTinxhB8B/ywb6w==}
|
||||
@ -4779,7 +4775,6 @@ packages:
|
||||
'@tsslint/cli@3.1.4':
|
||||
resolution: {integrity: sha512-svoLfFkoWmdsDrIRLllFnrxydfMjKKZ1UBjv7Sua1KjFkx6VaJ88+YGYqNiTbB/dDcU10qnSMXRavNTJ0fjBkQ==}
|
||||
engines: {node: '>=22.6.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: '*'
|
||||
|
||||
@ -4791,7 +4786,6 @@ packages:
|
||||
'@tsslint/config@3.1.4':
|
||||
resolution: {integrity: sha512-6VcUimc170M1v3b0vmOhRW7NI/b7DqXB5Wpo+1wCNLprDTN1HwjsbfdFNm/nsd0jXWChNr/cJhmsnVp4xHDCKw==}
|
||||
engines: {node: '>=22.6.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@tsslint/compat-eslint': ^3.0.0
|
||||
tsl: ^1.0.28
|
||||
@ -4934,6 +4928,12 @@ packages:
|
||||
'@types/doctrine@0.0.9':
|
||||
resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==}
|
||||
|
||||
'@types/dom-mediacapture-transform@0.1.11':
|
||||
resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==}
|
||||
|
||||
'@types/dom-webcodecs@0.1.13':
|
||||
resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==}
|
||||
|
||||
'@types/esrecurse@4.3.1':
|
||||
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
|
||||
|
||||
@ -5204,7 +5204,6 @@ packages:
|
||||
|
||||
'@typescript/typescript6@6.0.2':
|
||||
resolution: {integrity: sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==}
|
||||
hasBin: true
|
||||
|
||||
'@ungap/structured-clone@1.3.2':
|
||||
resolution: {integrity: sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==}
|
||||
@ -5496,7 +5495,6 @@ packages:
|
||||
acorn@8.17.0:
|
||||
resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
agentation@3.0.2:
|
||||
resolution: {integrity: sha512-iGzBxFVTuZEIKzLY6AExSLAQH6i6SwxV4pAu7v7m3X6bInZ7qlZXAwrEqyc4+EfP4gM7z2RXBF6SF4DeH0f2lA==}
|
||||
@ -5603,7 +5601,6 @@ packages:
|
||||
|
||||
astring@1.9.0:
|
||||
resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==}
|
||||
hasBin: true
|
||||
|
||||
async-function@1.0.0:
|
||||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||
@ -5648,7 +5645,6 @@ packages:
|
||||
baseline-browser-mapping@2.10.40:
|
||||
resolution: {integrity: sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
birecord@0.1.1:
|
||||
resolution: {integrity: sha512-VUpsf/qykW0heRlC8LooCq28Kxn3mAqKohhDG/49rrsQ1dT1CXyj/pgXS+5BSRzFTR/3DyIBOqQOrGyZOh71Aw==}
|
||||
@ -5676,7 +5672,6 @@ packages:
|
||||
browserslist@4.28.4:
|
||||
resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==}
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
buffer-crc32@0.2.13:
|
||||
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
||||
@ -5815,7 +5810,6 @@ packages:
|
||||
|
||||
chromatic@16.10.0:
|
||||
resolution: {integrity: sha512-nFsztmnu7rFiGafUJgXvLUNpqmRylz92eNvzBoJNTKKQj4EQUyxznwnfpf1dTs7hXtWD8JwcH92jADydaHA1sw==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@chromatic-com/cypress': ^0.*.* || ^1.0.0
|
||||
'@chromatic-com/playwright': ^0.*.* || ^1.0.0
|
||||
@ -5888,7 +5882,6 @@ packages:
|
||||
|
||||
color-support@1.1.3:
|
||||
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
|
||||
hasBin: true
|
||||
|
||||
comma-separated-tokens@2.0.3:
|
||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||
@ -5930,7 +5923,6 @@ packages:
|
||||
concurrently@10.0.3:
|
||||
resolution: {integrity: sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==}
|
||||
engines: {node: '>=22'}
|
||||
hasBin: true
|
||||
|
||||
confbox@0.1.8:
|
||||
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
||||
@ -5999,7 +5991,6 @@ packages:
|
||||
cssesc@3.0.0:
|
||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
cssfontparser@1.2.1:
|
||||
resolution: {integrity: sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==}
|
||||
@ -6067,7 +6058,6 @@ packages:
|
||||
d3-dsv@3.0.1:
|
||||
resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
|
||||
d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
@ -6461,7 +6451,6 @@ packages:
|
||||
esbuild@0.28.1:
|
||||
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
escalade@3.2.0:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
@ -6743,7 +6732,6 @@ packages:
|
||||
eslint@10.6.0:
|
||||
resolution: {integrity: sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
jiti: '*'
|
||||
peerDependenciesMeta:
|
||||
@ -6761,7 +6749,6 @@ packages:
|
||||
esprima@4.0.1:
|
||||
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
esquery@1.7.0:
|
||||
resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
|
||||
@ -6827,7 +6814,6 @@ packages:
|
||||
extract-zip@2.0.1:
|
||||
resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
|
||||
engines: {node: '>= 10.17.0'}
|
||||
hasBin: true
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
@ -6917,7 +6903,6 @@ packages:
|
||||
formatly@0.3.0:
|
||||
resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==}
|
||||
engines: {node: '>=18.3.0'}
|
||||
hasBin: true
|
||||
|
||||
foxact@0.3.8:
|
||||
resolution: {integrity: sha512-lFakEG8xm6A6wMH4ycWhXvwUybq4ATnMxJfBD4hj6hm9CKo72q2M/YaiEemoWGh9sUjQqX+pPXfq8S/jxedWzA==}
|
||||
@ -7012,7 +6997,6 @@ packages:
|
||||
|
||||
giget@3.3.0:
|
||||
resolution: {integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==}
|
||||
hasBin: true
|
||||
|
||||
github-from-package@0.0.0:
|
||||
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
|
||||
@ -7061,7 +7045,6 @@ packages:
|
||||
h3@2.0.1-rc.22:
|
||||
resolution: {integrity: sha512-Esv0DMIuPkCTSWCA0vO73vcTqwzH1wjSrAO1TXNu/K3up1sZHa9EKMapbmxCDYBeymC3fVTk4qxp7ogQWQ+KgA==}
|
||||
engines: {node: '>=20.11.1'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
crossws: ^0.4.1
|
||||
peerDependenciesMeta:
|
||||
@ -7222,7 +7205,6 @@ packages:
|
||||
image-size@2.0.2:
|
||||
resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==}
|
||||
engines: {node: '>=16.x'}
|
||||
hasBin: true
|
||||
|
||||
immer@11.1.11:
|
||||
resolution: {integrity: sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==}
|
||||
@ -7322,7 +7304,6 @@ packages:
|
||||
is-docker@3.0.0:
|
||||
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
hasBin: true
|
||||
|
||||
is-document.all@1.0.0:
|
||||
resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==}
|
||||
@ -7354,7 +7335,6 @@ packages:
|
||||
is-inside-container@1.0.0:
|
||||
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
|
||||
engines: {node: '>=14.16'}
|
||||
hasBin: true
|
||||
|
||||
is-installed-globally@1.0.0:
|
||||
resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==}
|
||||
@ -7456,7 +7436,6 @@ packages:
|
||||
|
||||
jiti@2.7.0:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
jotai-effect@2.3.1:
|
||||
resolution: {integrity: sha512-FBBVXDM2podbbxJsZ19+uDv45LxeXoVA5yh6CfkQ35AVCuRHj7Lanlcjiea3b67Q7+/MMGXWf8+GeB73JcbeMg==}
|
||||
@ -7502,9 +7481,6 @@ packages:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
js-audio-recorder@1.0.7:
|
||||
resolution: {integrity: sha512-JiDODCElVHGrFyjGYwYyNi7zCbKk9va9C77w+zCPMmi4C6ix7zsX2h3ddHugmo4dOTOTCym9++b/wVW9nC0IaA==}
|
||||
|
||||
js-base64@3.8.0:
|
||||
resolution: {integrity: sha512-65kvbemyZhj+ExQt1PEFyBEjL5vAHysu1lJdW1AwhhChkO8ZBPizYk/m9GVrpbS2Je1hF+UYZ+6KywqtZV8mHw==}
|
||||
|
||||
@ -7522,11 +7498,9 @@ packages:
|
||||
|
||||
js-yaml@4.3.0:
|
||||
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
|
||||
hasBin: true
|
||||
|
||||
js-yaml@5.2.1:
|
||||
resolution: {integrity: sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==}
|
||||
hasBin: true
|
||||
|
||||
jsdoc-type-pratt-parser@7.1.1:
|
||||
resolution: {integrity: sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==}
|
||||
@ -7539,7 +7513,6 @@ packages:
|
||||
jsesc@3.1.0:
|
||||
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
json-buffer@3.0.1:
|
||||
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
|
||||
@ -7553,7 +7526,6 @@ packages:
|
||||
json5@2.2.3:
|
||||
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jsonc-eslint-parser@3.1.0:
|
||||
resolution: {integrity: sha512-75EA7EWZExL/j+MDKQrRbdzcRI2HOkRlmUw8fZJc1ioqFEOvBsq7Rt+A6yCxOt9w/TYNpkt52gC6nm/g5tFIng==}
|
||||
@ -7571,11 +7543,9 @@ packages:
|
||||
|
||||
katex@0.16.47:
|
||||
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
|
||||
hasBin: true
|
||||
|
||||
katex@0.17.0:
|
||||
resolution: {integrity: sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==}
|
||||
hasBin: true
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
@ -7586,7 +7556,6 @@ packages:
|
||||
knip@6.25.0:
|
||||
resolution: {integrity: sha512-Q3n41VjOOB/aqsbxb8kallAcFKrUz3b2S5fD5pTODljVpP01t+rvAgy2x3j0Cq8yEpRRHNdar1vHuqFfGuIakQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
knuth-shuffle-seeded@1.0.6:
|
||||
resolution: {integrity: sha512-9pFH0SplrfyKyojCLxZfMcvkhf5hH0d+UwR9nTVJ/DDQJGuzcXjTwB7TP7sDfehSudlGGaOLblmEWqv04ERVWg==}
|
||||
@ -7598,9 +7567,6 @@ packages:
|
||||
resolution: {integrity: sha512-/GmXpo9F9W+f8n4Ivr2iH+7h7wL7jLbLKWkMlpflcCRb6kGjBfTlASEXaZ9qUgNTn4VgS0P2pwxxzQ4EM6Ulgg==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
lamejs@1.2.1:
|
||||
resolution: {integrity: sha512-s7bxvjvYthw6oPLCm5pFxvA84wUROODB8jEO2+CE1adhKgrIvVOlmMgY8zyugxGrvRaDHNJanOiS21/emty6dQ==}
|
||||
|
||||
language-subtag-registry@0.3.23:
|
||||
resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
|
||||
|
||||
@ -7747,7 +7713,6 @@ packages:
|
||||
|
||||
loose-envify@1.4.0:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
loro-crdt@1.13.6:
|
||||
resolution: {integrity: sha512-OsnKW64J+1XdCMafAqR+aASn/wQEgDzwcDwu6dDOVSxxbLPFgnU6LX8ja+hd5PnEcT+kuz9eXSagvlYXsRbQIw==}
|
||||
@ -7768,7 +7733,6 @@ packages:
|
||||
|
||||
lz-string@1.5.0:
|
||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||
hasBin: true
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
@ -7790,12 +7754,10 @@ packages:
|
||||
marked@16.4.2:
|
||||
resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==}
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
marked@17.0.6:
|
||||
resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==}
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
@ -7870,6 +7832,9 @@ packages:
|
||||
mdn-data@2.28.1:
|
||||
resolution: {integrity: sha512-U9w+PzSZ00Z5m9rZ5ARVFL5xOfuCHdKYi/1RRwDCJsboFgJDNT3zT6PIPD7mZQYaQLhsZM3GfDRgSMRHhSmVng==}
|
||||
|
||||
mediabunny@1.50.7:
|
||||
resolution: {integrity: sha512-mv+FXkQNOobx4b43GJV1t0n8cx26d8nZpBMtCTaNKk6xzVpNPrwrbDq+dggm7Zpjvtx2ycgtYciFcySUsO5QGA==}
|
||||
|
||||
merge2@1.4.1:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
engines: {node: '>= 8'}
|
||||
@ -7998,12 +7963,10 @@ packages:
|
||||
mime@3.0.0:
|
||||
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
hasBin: true
|
||||
|
||||
mime@4.1.0:
|
||||
resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
mimic-function@5.0.1:
|
||||
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
|
||||
@ -8044,7 +8007,6 @@ packages:
|
||||
mkdirp@3.0.1:
|
||||
resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
mlly@1.8.2:
|
||||
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
|
||||
@ -8088,7 +8050,6 @@ packages:
|
||||
nanoid@3.3.15:
|
||||
resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
napi-build-utils@2.0.0:
|
||||
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
|
||||
@ -8116,7 +8077,6 @@ packages:
|
||||
next@16.2.10:
|
||||
resolution: {integrity: sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.1.0
|
||||
'@playwright/test': ^1.51.1
|
||||
@ -8411,7 +8371,6 @@ packages:
|
||||
oxfmt@0.57.0:
|
||||
resolution: {integrity: sha512-ZB7Bi+rGDSqmVIo9jwcLyFgjxXvQhDdU+jx+ZrVy6VRiVXK2+CHc4hO3J4dUQjHe7V0ymHB+MDuv5z+NhK07HA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
svelte: ^5.0.0
|
||||
vite-plus: '*'
|
||||
@ -8423,12 +8382,10 @@ packages:
|
||||
|
||||
oxlint-tsgolint@0.24.0:
|
||||
resolution: {integrity: sha512-giCk5sEvG02d5tzPmFMX3hem8ndzEEu1xvGYS5OwNfO2WGl6ZVxt5LjE0yiMDoz94INI7XkXwgFAQiydPvVHDw==}
|
||||
hasBin: true
|
||||
|
||||
oxlint@1.72.0:
|
||||
resolution: {integrity: sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
oxlint-tsgolint: '>=0.22.1'
|
||||
vite-plus: '*'
|
||||
@ -8556,12 +8513,10 @@ packages:
|
||||
playwright-core@1.61.1:
|
||||
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.61.1:
|
||||
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
pluralize@8.0.0:
|
||||
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
|
||||
@ -8611,7 +8566,6 @@ packages:
|
||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
|
||||
hasBin: true
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
@ -8661,7 +8615,6 @@ packages:
|
||||
|
||||
rc@1.2.8:
|
||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||
hasBin: true
|
||||
|
||||
re-resizable@6.11.2:
|
||||
resolution: {integrity: sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==}
|
||||
@ -8871,7 +8824,6 @@ packages:
|
||||
|
||||
regexp-tree@0.1.27:
|
||||
resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==}
|
||||
hasBin: true
|
||||
|
||||
regexp.prototype.flags@1.5.4:
|
||||
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
||||
@ -8879,7 +8831,6 @@ packages:
|
||||
|
||||
regjsparser@0.13.2:
|
||||
resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==}
|
||||
hasBin: true
|
||||
|
||||
rehype-harden@1.1.8:
|
||||
resolution: {integrity: sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw==}
|
||||
@ -8943,7 +8894,6 @@ packages:
|
||||
resolve@1.22.12:
|
||||
resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
hasBin: true
|
||||
|
||||
restore-cursor@5.1.0:
|
||||
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
||||
@ -9017,22 +8967,18 @@ packages:
|
||||
|
||||
semver@6.3.1:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
|
||||
semver@7.8.1:
|
||||
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
semver@7.8.2:
|
||||
resolution: {integrity: sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
semver@7.8.5:
|
||||
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
server-only@0.0.1:
|
||||
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
||||
@ -9155,7 +9101,6 @@ packages:
|
||||
srvx@0.11.17:
|
||||
resolution: {integrity: sha512-43yM4luKfCJamyCMhrUeHUPOrf8TdZe7kN8s5zayZCH5OeprYqi49Aso5ZvHXR4aB+DHaRNO/diNFgZSMNG8Xw==}
|
||||
engines: {node: '>=20.16.0'}
|
||||
hasBin: true
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
@ -9183,7 +9128,6 @@ packages:
|
||||
|
||||
storybook@10.4.6:
|
||||
resolution: {integrity: sha512-6wkA6LxfDSSilloITsrFOJfsnw0mDUP2h8Ls+lRt8oRsudtz2RWFhLv+Toiwg6NW7hUpdTDc2hzR7DztJid6+A==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
prettier: ^2 || ^3
|
||||
@ -9302,7 +9246,6 @@ packages:
|
||||
svgo@3.3.3:
|
||||
resolution: {integrity: sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
synckit@0.11.13:
|
||||
resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==}
|
||||
@ -9389,7 +9332,6 @@ packages:
|
||||
|
||||
tldts@7.4.7:
|
||||
resolution: {integrity: sha512-56L0/9HELHSsG1bFCzay8UoLxzRL7kpFf7Wl5q/kSYwiSJGACvro61xnKzPNM+SadxllzdtXsKDSXE7HPeqIAw==}
|
||||
hasBin: true
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
@ -9412,7 +9354,6 @@ packages:
|
||||
|
||||
tree-kill@1.2.2:
|
||||
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
||||
hasBin: true
|
||||
|
||||
trim-lines@3.0.1:
|
||||
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
|
||||
@ -9440,7 +9381,6 @@ packages:
|
||||
resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
|
||||
engines: {node: ^18 || >=20}
|
||||
deprecated: unmaintained
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
@ -9467,7 +9407,6 @@ packages:
|
||||
tsx@4.23.0:
|
||||
resolution: {integrity: sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tunnel-agent@0.6.0:
|
||||
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
||||
@ -9515,7 +9454,6 @@ packages:
|
||||
typescript@7.0.2:
|
||||
resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
hasBin: true
|
||||
|
||||
ufo@1.6.4:
|
||||
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
|
||||
@ -9523,7 +9461,6 @@ packages:
|
||||
uglify-js@3.19.3:
|
||||
resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==}
|
||||
engines: {node: '>=0.8.0'}
|
||||
hasBin: true
|
||||
|
||||
unbash@4.0.2:
|
||||
resolution: {integrity: sha512-8gwNZ29+0/3zmXw7ToIHZtg6wK37xnniRUdBt7B27xZxaxfgR5tGMaGHT0t0dLtBV9fXE7zurh0s6Z1DHVjfWg==}
|
||||
@ -9627,7 +9564,6 @@ packages:
|
||||
|
||||
update-browserslist-db@1.2.3:
|
||||
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
browserslist: '>= 4.21.0'
|
||||
|
||||
@ -9687,9 +9623,6 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
use-strict@1.0.1:
|
||||
resolution: {integrity: sha512-IeiWvvEXfW5ltKVMkxq6FvNf2LojMKvB2OCeja6+ct24S1XOmQw2dGr2JyndwACWAGJva9B7yPHwAmeA9QCqAQ==}
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||
peerDependencies:
|
||||
@ -9703,7 +9636,6 @@ packages:
|
||||
|
||||
uuid@14.0.1:
|
||||
resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==}
|
||||
hasBin: true
|
||||
|
||||
valibot@1.4.2:
|
||||
resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==}
|
||||
@ -9728,7 +9660,6 @@ packages:
|
||||
vinext@1.0.0-beta.0:
|
||||
resolution: {integrity: sha512-O0YkPs435h9kSYRQPjgPvtewpiKmUUJa6eBOoP3eQtgg0wR5L6w8HezJGTq0T/mHL1EaGCDpYbED3TFhriFklg==}
|
||||
engines: {node: '>=22'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@mdx-js/rollup': ^3.0.0
|
||||
'@vitejs/plugin-react': ^5.1.4 || ^6.0.0
|
||||
@ -9771,7 +9702,6 @@ packages:
|
||||
vite-plus@0.2.4:
|
||||
resolution: {integrity: sha512-gaBBjOXIq9lLRU44oAYdIr99p+JBLX1kxs+l/6LqGgSXwcVKAdDa1boSrOTELqYCkQQ0fpppXUGWi9o6JDT5zw==}
|
||||
engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@vitest/browser-playwright': 4.1.10
|
||||
'@vitest/browser-webdriverio': 4.1.10
|
||||
@ -9819,7 +9749,6 @@ packages:
|
||||
vitest@4.1.10:
|
||||
resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
@ -9922,12 +9851,10 @@ packages:
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
word-wrap@1.2.5:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
@ -9990,7 +9917,6 @@ packages:
|
||||
yaml@2.9.0:
|
||||
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@22.0.0:
|
||||
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
|
||||
@ -11882,6 +11808,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@mediabunny/mp3-encoder@1.50.7(mediabunny@1.50.7)':
|
||||
dependencies:
|
||||
mediabunny: 1.50.7
|
||||
|
||||
'@mermaid-js/parser@1.2.0':
|
||||
dependencies:
|
||||
'@chevrotain/types': 11.1.2
|
||||
@ -13517,6 +13447,12 @@ snapshots:
|
||||
|
||||
'@types/doctrine@0.0.9': {}
|
||||
|
||||
'@types/dom-mediacapture-transform@0.1.11':
|
||||
dependencies:
|
||||
'@types/dom-webcodecs': 0.1.13
|
||||
|
||||
'@types/dom-webcodecs@0.1.13': {}
|
||||
|
||||
'@types/esrecurse@4.3.1': {}
|
||||
|
||||
'@types/estree-jsx@1.0.5':
|
||||
@ -16959,8 +16895,6 @@ snapshots:
|
||||
'@types/react': 19.2.17
|
||||
react: 19.2.7
|
||||
|
||||
js-audio-recorder@1.0.7: {}
|
||||
|
||||
js-base64@3.8.0: {}
|
||||
|
||||
js-cookie@3.0.8: {}
|
||||
@ -17052,10 +16986,6 @@ snapshots:
|
||||
|
||||
ky@2.0.2: {}
|
||||
|
||||
lamejs@1.2.1:
|
||||
dependencies:
|
||||
use-strict: 1.0.1
|
||||
|
||||
language-subtag-registry@0.3.23: {}
|
||||
|
||||
language-tags@1.0.9:
|
||||
@ -17425,6 +17355,11 @@ snapshots:
|
||||
|
||||
mdn-data@2.28.1: {}
|
||||
|
||||
mediabunny@1.50.7:
|
||||
dependencies:
|
||||
'@types/dom-mediacapture-transform': 0.1.11
|
||||
'@types/dom-webcodecs': 0.1.13
|
||||
|
||||
merge2@1.4.1: {}
|
||||
|
||||
mermaid@11.16.0:
|
||||
@ -19777,8 +19712,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
use-strict@1.0.1: {}
|
||||
|
||||
use-sync-external-store@1.6.0(react@19.2.7):
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
@ -20501,6 +20434,7 @@ time:
|
||||
'@mdx-js/loader@3.1.1': '2025-08-29T18:03:05.606Z'
|
||||
'@mdx-js/react@3.1.1': '2025-08-29T18:02:56.462Z'
|
||||
'@mdx-js/rollup@3.1.1': '2025-08-29T18:03:10.680Z'
|
||||
'@mediabunny/mp3-encoder@1.50.7': '2026-07-07T19:31:44.869Z'
|
||||
'@monaco-editor/react@4.7.0': '2025-02-13T16:13:41.390Z'
|
||||
'@napi-rs/keyring@1.3.0': '2026-04-30T09:56:44.246Z'
|
||||
'@next/eslint-plugin-next@16.2.10': '2026-07-01T20:11:43.814Z'
|
||||
@ -20610,17 +20544,16 @@ time:
|
||||
jotai-scope@0.11.0: '2026-05-13T18:43:15.331Z'
|
||||
jotai-tanstack-query@0.11.0: '2025-08-01T02:55:49.826Z'
|
||||
jotai@2.20.1: '2026-06-11T06:30:45.782Z'
|
||||
js-audio-recorder@1.0.7: '2021-01-09T10:20:49.923Z'
|
||||
js-cookie@3.0.8: '2026-05-29T10:51:39.065Z'
|
||||
js-yaml@5.2.1: '2026-07-02T00:55:21.714Z'
|
||||
jsonschema@1.5.0: '2025-01-07T15:09:11.287Z'
|
||||
katex@0.17.0: '2026-05-22T08:06:26.967Z'
|
||||
knip@6.25.0: '2026-07-07T08:47:13.416Z'
|
||||
ky@2.0.2: '2026-04-21T08:58:46.923Z'
|
||||
lamejs@1.2.1: '2021-12-02T15:44:40.036Z'
|
||||
lexical@0.46.0: '2026-06-26T04:53:00.532Z'
|
||||
lockfile@1.0.4: '2018-04-17T00:36:12.565Z'
|
||||
loro-crdt@1.13.6: '2026-06-21T15:40:04.671Z'
|
||||
mediabunny@1.50.7: '2026-07-07T19:31:25.625Z'
|
||||
mermaid@11.16.0: '2026-06-25T11:30:40.280Z'
|
||||
mime@4.1.0: '2025-09-12T17:53:01.376Z'
|
||||
mitt@3.0.1: '2023-07-04T17:31:47.638Z'
|
||||
|
||||
@ -77,6 +77,7 @@ catalog:
|
||||
'@mdx-js/loader': 3.1.1
|
||||
'@mdx-js/react': 3.1.1
|
||||
'@mdx-js/rollup': 3.1.1
|
||||
'@mediabunny/mp3-encoder': 1.50.7
|
||||
'@monaco-editor/react': 4.7.0
|
||||
'@napi-rs/keyring': 1.3.0
|
||||
'@next/eslint-plugin-next': 16.2.10
|
||||
@ -185,17 +186,16 @@ catalog:
|
||||
jotai-effect: 2.3.1
|
||||
jotai-scope: 0.11.0
|
||||
jotai-tanstack-query: 0.11.0
|
||||
js-audio-recorder: 1.0.7
|
||||
js-cookie: 3.0.8
|
||||
js-yaml: 5.2.1
|
||||
jsonschema: 1.5.0
|
||||
katex: 0.17.0
|
||||
knip: 6.25.0
|
||||
ky: 2.0.2
|
||||
lamejs: 1.2.1
|
||||
lexical: 0.46.0
|
||||
lockfile: 1.0.4
|
||||
loro-crdt: 1.13.6
|
||||
mediabunny: 1.50.7
|
||||
mermaid: 11.16.0
|
||||
mime: 4.1.0
|
||||
mitt: 3.0.1
|
||||
|
||||
@ -19,6 +19,7 @@ const DebugWithMultipleModel = () => {
|
||||
mode,
|
||||
inputs,
|
||||
modelConfig,
|
||||
appId,
|
||||
readonly,
|
||||
canTestAndRun = false,
|
||||
} = useDebugConfigurationContext()
|
||||
@ -135,6 +136,7 @@ const DebugWithMultipleModel = () => {
|
||||
onFeatureBarClick={setShowAppConfigureFeaturesModal}
|
||||
onSend={handleSend}
|
||||
speechToTextConfig={speech2text as any}
|
||||
speechToTextTarget={{ type: 'consoleApp', appId }}
|
||||
visionConfig={file}
|
||||
inputs={inputs}
|
||||
inputsForm={inputsForm}
|
||||
|
||||
@ -185,6 +185,7 @@ const DebugWithSingleModel = ({
|
||||
<Chat
|
||||
readonly={debugInputReadonly}
|
||||
config={config}
|
||||
speechToTextTarget={{ type: 'consoleApp', appId }}
|
||||
chatList={chatList}
|
||||
isResponding={isResponding}
|
||||
chatContainerClassName="px-3 pt-6"
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { FileEntity } from '../../file-uploader/types'
|
||||
import type { SpeechToTextTarget } from '../../voice-input/types'
|
||||
import type { ChatConfig, ChatItem, ChatItemInTree, OnSend } from '../types'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@ -414,12 +415,19 @@ const ChatWrapper = () => {
|
||||
imageUrl={appData.site.icon_url}
|
||||
/>
|
||||
) : null
|
||||
const speechToTextTarget: SpeechToTextTarget | undefined =
|
||||
appSourceType === AppSourceType.webApp
|
||||
? { type: 'app' as const, appSourceType: AppSourceType.webApp }
|
||||
: appId
|
||||
? { type: 'app' as const, appId, appSourceType }
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-hidden bg-chatbot-bg">
|
||||
<Chat
|
||||
appData={appData ?? undefined}
|
||||
config={appConfig}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
chatList={messageList}
|
||||
isResponding={respondingState}
|
||||
chatContainerInnerClassName={`mx-auto pt-6 w-full max-w-[768px] ${isMobile && 'px-4'}`}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { ChatConfig, ChatItem, OnSend } from '../../types'
|
||||
import type { ChatProps } from '../index'
|
||||
import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
@ -10,8 +11,8 @@ import Chat from '../index'
|
||||
// Answer – transitively pulls Markdown (rehype/remark/katex), AgentContent,
|
||||
// WorkflowProcessItem and Operation; none can resolve in the test DOM runtime.
|
||||
// Question – pulls Markdown, copy-to-clipboard, react-textarea-autosize.
|
||||
// ChatInputArea – pulls js-audio-recorder (requires Web Audio API unavailable in
|
||||
// the test DOM runtime) and VoiceInput / FileContextProvider chains.
|
||||
// ChatInputArea – pulls browser audio APIs unavailable in the test DOM runtime
|
||||
// and the VoiceInput / FileContextProvider chains.
|
||||
// PromptLogModal– pulls CopyFeedbackNew and deep modal dep chain.
|
||||
// AgentLogModal – pulls @remixicon/react (causes lint push error), useClickAway
|
||||
// from ahooks, and AgentLogDetail (workflow graph renderer).
|
||||
@ -45,17 +46,30 @@ vi.mock('../chat-input-area', () => ({
|
||||
disabled,
|
||||
readonly,
|
||||
footerNotice,
|
||||
onBeforeSpeechToText,
|
||||
speechToTextTarget,
|
||||
}: {
|
||||
customPlaceholder?: string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
footerNotice?: string
|
||||
onBeforeSpeechToText?: () => Promise<unknown>
|
||||
speechToTextTarget?: SpeechToTextTarget
|
||||
}) => (
|
||||
<div
|
||||
data-testid="chat-input-area"
|
||||
data-custom-placeholder={customPlaceholder}
|
||||
data-disabled={String(!!disabled)}
|
||||
data-readonly={String(!!readonly)}
|
||||
data-has-before-speech={String(!!onBeforeSpeechToText)}
|
||||
data-speech-app-id={
|
||||
speechToTextTarget?.type !== 'agent' ? speechToTextTarget?.appId : undefined
|
||||
}
|
||||
data-speech-source={
|
||||
speechToTextTarget?.type === 'app'
|
||||
? speechToTextTarget.appSourceType
|
||||
: speechToTextTarget?.type
|
||||
}
|
||||
>
|
||||
{footerNotice}
|
||||
</div>
|
||||
@ -401,6 +415,29 @@ describe('Chat', () => {
|
||||
renderChat({ readonly: true })
|
||||
expect(screen.getByTestId('chat-input-area')).toHaveAttribute('data-readonly', 'true')
|
||||
})
|
||||
|
||||
it('should pass the explicit speech-to-text target to ChatInputArea', () => {
|
||||
renderChat({
|
||||
speechToTextTarget: {
|
||||
type: 'consoleApp',
|
||||
appId: 'app-123',
|
||||
},
|
||||
})
|
||||
expect(screen.getByTestId('chat-input-area')).toHaveAttribute('data-speech-app-id', 'app-123')
|
||||
expect(screen.getByTestId('chat-input-area')).toHaveAttribute(
|
||||
'data-speech-source',
|
||||
'consoleApp',
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass the save-before-transcribe callback to ChatInputArea', () => {
|
||||
renderChat({ onBeforeSpeechToText: vi.fn().mockResolvedValue(undefined) })
|
||||
|
||||
expect(screen.getByTestId('chat-input-area')).toHaveAttribute(
|
||||
'data-has-before-speech',
|
||||
'true',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PromptLogModal', () => {
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import type { VoiceRecorder } from '@/app/components/base/voice-input/recorder'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { transcribeAudio } from '@/app/components/base/voice-input/api'
|
||||
import { startVoiceRecorder } from '@/app/components/base/voice-input/recorder'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import ChatInputArea from '../index'
|
||||
|
||||
@ -12,82 +15,28 @@ vi.setConfig({ testTimeout: 60000 })
|
||||
// External dependency mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Track whether getPermission should reject
|
||||
const { mockGetPermissionConfig } = vi.hoisted(() => ({
|
||||
mockGetPermissionConfig: { shouldReject: false },
|
||||
const recorderStop = vi.fn<() => Promise<Blob>>()
|
||||
const recorderCancel = vi.fn<() => Promise<void>>()
|
||||
const recorder: VoiceRecorder = {
|
||||
analyser: {
|
||||
frequencyBinCount: 8,
|
||||
getByteFrequencyData: vi.fn(),
|
||||
} as unknown as AnalyserNode,
|
||||
stop: recorderStop,
|
||||
cancel: recorderCancel,
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/base/voice-input/recorder', () => ({
|
||||
startVoiceRecorder: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('js-audio-recorder', () => ({
|
||||
default: class MockRecorder {
|
||||
static getPermission = vi.fn().mockImplementation(() => {
|
||||
if (mockGetPermissionConfig.shouldReject) {
|
||||
return Promise.reject(new Error('Permission denied'))
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
vi.mock('@/app/components/base/voice-input/api', () => ({ transcribeAudio: vi.fn() }))
|
||||
|
||||
start = vi.fn().mockResolvedValue(undefined)
|
||||
stop = vi.fn()
|
||||
getWAVBlob = vi.fn().mockReturnValue(new Blob([''], { type: 'audio/wav' }))
|
||||
getRecordAnalyseData = vi.fn().mockReturnValue(new Uint8Array(128))
|
||||
getChannelData = vi
|
||||
.fn()
|
||||
.mockReturnValue({ left: new Float32Array(0), right: new Float32Array(0) })
|
||||
getWAV = vi.fn().mockReturnValue(new ArrayBuffer(0))
|
||||
destroy = vi.fn()
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/voice-input/utils', () => ({
|
||||
convertToMp3: vi.fn().mockReturnValue(new Blob([''], { type: 'audio/mp3' })),
|
||||
}))
|
||||
|
||||
// Mock VoiceInput component - simplified version
|
||||
vi.mock('@/app/components/base/voice-input', () => {
|
||||
const VoiceInputMock = ({
|
||||
onCancel,
|
||||
onConverted,
|
||||
}: {
|
||||
onCancel: () => void
|
||||
onConverted: (text: string) => void
|
||||
}) => {
|
||||
// Use module-level state for simplicity
|
||||
const [showStop, setShowStop] = React.useState(true)
|
||||
|
||||
const handleStop = () => {
|
||||
setShowStop(false)
|
||||
// Simulate async conversion
|
||||
setTimeout(() => {
|
||||
onConverted('Converted voice text')
|
||||
setShowStop(true)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="voice-input-mock">
|
||||
<div data-testid="voice-input-speaking">voiceInput.speaking</div>
|
||||
<div data-testid="voice-input-converting-text">voiceInput.converting</div>
|
||||
{showStop && (
|
||||
<button data-testid="voice-input-stop" onClick={handleStop}>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
<button data-testid="voice-input-cancel" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
default: VoiceInputMock,
|
||||
}
|
||||
})
|
||||
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) =>
|
||||
setTimeout(() => cb(Date.now()), 16),
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn(() => 1),
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => clearTimeout(id))
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
vi.stubGlobal('devicePixelRatio', 1)
|
||||
|
||||
// Mock Canvas
|
||||
@ -106,11 +55,6 @@ HTMLCanvasElement.prototype.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
height: 50,
|
||||
})
|
||||
|
||||
vi.mock('@/service/share', () => ({
|
||||
audioToText: vi.fn().mockResolvedValue({ text: 'Converted voice text' }),
|
||||
AppSourceType: { webApp: 'webApp', installedApp: 'installedApp' },
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File-uploader store
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -253,6 +197,11 @@ const mockVisionConfig: FileUpload = {
|
||||
},
|
||||
}
|
||||
|
||||
const speechToTextTarget = {
|
||||
type: 'consoleApp' as const,
|
||||
appId: 'app-123',
|
||||
}
|
||||
|
||||
const makeFile = (overrides: Partial<FileEntity> = {}): FileEntity =>
|
||||
({
|
||||
id: 'file-1',
|
||||
@ -278,6 +227,10 @@ const getTextarea = () =>
|
||||
describe('ChatInputArea', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(startVoiceRecorder).mockResolvedValue(recorder)
|
||||
recorderStop.mockResolvedValue(new Blob(['mp3-data'], { type: 'audio/mp3' }))
|
||||
recorderCancel.mockResolvedValue()
|
||||
vi.mocked(transcribeAudio).mockResolvedValue({ text: 'Converted voice text' })
|
||||
mockFileStore.files = []
|
||||
mockIsDragActive.value = false
|
||||
mockIsMultipleLine.value = false
|
||||
@ -536,88 +489,360 @@ describe('ChatInputArea', () => {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Voice Input', () => {
|
||||
it('should render the voice input button when enabled', () => {
|
||||
it('should hide the voice input button without an API target', () => {
|
||||
render(
|
||||
<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'common.voiceInput.start' })).toBeTruthy()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.voiceInput.start' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the voice input button when enabled with an API target', () => {
|
||||
render(
|
||||
<ChatInputArea
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
visionConfig={mockVisionConfig}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'common.voiceInput.start' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the active recorder when the chat input rerenders', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
const { rerender } = render(
|
||||
<ChatInputArea
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
visionConfig={mockVisionConfig}
|
||||
/>,
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'common.voiceInput.start' }))
|
||||
await screen.findByRole('button', { name: 'common.voiceInput.stop' })
|
||||
|
||||
rerender(
|
||||
<ChatInputArea
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
visionConfig={mockVisionConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(startVoiceRecorder).toHaveBeenCalledTimes(1)
|
||||
expect(recorderCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle stop recording in VoiceInput', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
render(
|
||||
<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />,
|
||||
<ChatInputArea
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
visionConfig={mockVisionConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.voiceInput.start' }))
|
||||
// Wait for VoiceInput to show speaking
|
||||
await screen.findByText(/voiceInput.speaking/i)
|
||||
const stopBtn = screen.getByTestId('voice-input-stop')
|
||||
await user.click(stopBtn)
|
||||
|
||||
// Converting should show up
|
||||
await screen.findByText(/voiceInput.converting/i)
|
||||
await user.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getTextarea()!).toHaveValue('Converted voice text')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle cancel in VoiceInput', async () => {
|
||||
it('should focus the textarea at the end of converted voice text', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
render(
|
||||
<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />,
|
||||
<ChatInputArea
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
visionConfig={mockVisionConfig}
|
||||
/>,
|
||||
)
|
||||
const textarea = getTextarea()!
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.voiceInput.start' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
|
||||
|
||||
await waitFor(() => expect(textarea).toHaveValue('Converted voice text'))
|
||||
await waitFor(() => expect(textarea).toHaveFocus())
|
||||
expect(textarea.selectionStart).toBe(textarea.value.length)
|
||||
expect(textarea.selectionEnd).toBe(textarea.value.length)
|
||||
})
|
||||
|
||||
it('should focus the textarea when conversion completes while focus is in the voice input', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
let resolveTranscription: ((value: { text: string }) => void) | undefined
|
||||
vi.mocked(transcribeAudio).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveTranscription = resolve
|
||||
}),
|
||||
)
|
||||
render(
|
||||
<ChatInputArea
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
visionConfig={mockVisionConfig}
|
||||
/>,
|
||||
)
|
||||
const textarea = getTextarea()!
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.voiceInput.start' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
|
||||
const cancelButton = await screen.findByRole('button', { name: 'common.operation.cancel' })
|
||||
cancelButton.focus()
|
||||
await act(async () => resolveTranscription?.({ text: 'Converted voice text' }))
|
||||
|
||||
await waitFor(() => expect(textarea).toHaveFocus())
|
||||
expect(textarea).toHaveValue('Converted voice text')
|
||||
})
|
||||
|
||||
it('should preserve focus when the user moves elsewhere during transcription', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
let resolveTranscription: ((value: { text: string }) => void) | undefined
|
||||
vi.mocked(transcribeAudio).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveTranscription = resolve
|
||||
}),
|
||||
)
|
||||
render(
|
||||
<>
|
||||
<button type="button">Elsewhere</button>
|
||||
<ChatInputArea
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
visionConfig={mockVisionConfig}
|
||||
/>
|
||||
</>,
|
||||
)
|
||||
const textarea = getTextarea()!
|
||||
const elsewhereButton = screen.getByRole('button', { name: 'Elsewhere' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.voiceInput.start' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
|
||||
await waitFor(() => expect(transcribeAudio).toHaveBeenCalledTimes(1))
|
||||
await user.click(elsewhereButton)
|
||||
await act(async () => resolveTranscription?.({ text: 'Converted voice text' }))
|
||||
|
||||
await waitFor(() => expect(textarea).toHaveValue('Converted voice text'))
|
||||
expect(elsewhereButton).toHaveFocus()
|
||||
})
|
||||
|
||||
it('should wait for the owning draft before transcription', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
const onBeforeSpeechToText = vi.fn().mockResolvedValue(undefined)
|
||||
render(
|
||||
<ChatInputArea
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
onBeforeSpeechToText={onBeforeSpeechToText}
|
||||
visionConfig={mockVisionConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.voiceInput.start' }))
|
||||
await screen.findByText(/voiceInput.speaking/i)
|
||||
const stopBtn = screen.getByTestId('voice-input-stop')
|
||||
await user.click(stopBtn)
|
||||
await user.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
|
||||
|
||||
// Wait for converting and cancel button
|
||||
const cancelBtn = await screen.findByTestId('voice-input-cancel')
|
||||
await waitFor(() => expect(transcribeAudio).toHaveBeenCalledTimes(1))
|
||||
expect(onBeforeSpeechToText).toHaveBeenCalledTimes(1)
|
||||
expect(onBeforeSpeechToText.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(transcribeAudio).mock.invocationCallOrder[0]!,
|
||||
)
|
||||
})
|
||||
|
||||
it('should preserve the current query and report transcription failure', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
vi.mocked(transcribeAudio).mockRejectedValueOnce(new Error('API error'))
|
||||
render(
|
||||
<ChatInputArea
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
visionConfig={mockVisionConfig}
|
||||
/>,
|
||||
)
|
||||
const voiceInputButton = screen.getByRole('button', { name: 'common.voiceInput.start' })
|
||||
await user.type(getTextarea()!, 'Keep this text')
|
||||
|
||||
await user.click(voiceInputButton)
|
||||
await user.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'common.api.actionFailed',
|
||||
})
|
||||
})
|
||||
expect(getTextarea()!).toHaveValue('Keep this text')
|
||||
await waitFor(() => expect(voiceInputButton).toHaveFocus())
|
||||
})
|
||||
|
||||
it('should restore focus to the voice input trigger when conversion is cancelled', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
vi.mocked(transcribeAudio).mockImplementationOnce(() => new Promise(() => {}))
|
||||
render(
|
||||
<ChatInputArea
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
visionConfig={mockVisionConfig}
|
||||
/>,
|
||||
)
|
||||
const voiceInputButton = screen.getByRole('button', { name: 'common.voiceInput.start' })
|
||||
|
||||
await user.click(voiceInputButton)
|
||||
await user.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
|
||||
|
||||
const cancelBtn = await screen.findByRole('button', { name: 'common.operation.cancel' })
|
||||
await user.click(cancelBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('voice-input-stop')).toBeNull()
|
||||
expect(screen.queryByText(/voiceInput.converting/i)).not.toBeInTheDocument()
|
||||
})
|
||||
await waitFor(() => expect(voiceInputButton).toHaveFocus())
|
||||
})
|
||||
|
||||
it('should restore focus to the voice input trigger when setup is cancelled', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
vi.mocked(startVoiceRecorder).mockReturnValueOnce(new Promise(() => {}))
|
||||
render(
|
||||
<ChatInputArea
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
visionConfig={mockVisionConfig}
|
||||
/>,
|
||||
)
|
||||
const voiceInputButton = screen.getByRole('button', { name: 'common.voiceInput.start' })
|
||||
|
||||
await user.click(voiceInputButton)
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
await waitFor(() => expect(voiceInputButton).toHaveFocus())
|
||||
})
|
||||
|
||||
it('should restore focus to the voice input trigger when stopping the recorder fails', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
recorderStop.mockRejectedValueOnce(new Error('Recorder failed'))
|
||||
render(
|
||||
<ChatInputArea
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
visionConfig={mockVisionConfig}
|
||||
/>,
|
||||
)
|
||||
const voiceInputButton = screen.getByRole('button', { name: 'common.voiceInput.start' })
|
||||
|
||||
await user.click(voiceInputButton)
|
||||
await user.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'common.api.actionFailed',
|
||||
})
|
||||
})
|
||||
await waitFor(() => expect(voiceInputButton).toHaveFocus())
|
||||
})
|
||||
|
||||
it('should preserve focus when the user moves elsewhere before transcription fails', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
let rejectTranscription: ((reason?: unknown) => void) | undefined
|
||||
vi.mocked(transcribeAudio).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectTranscription = reject
|
||||
}),
|
||||
)
|
||||
render(
|
||||
<>
|
||||
<button type="button">Elsewhere</button>
|
||||
<ChatInputArea
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
visionConfig={mockVisionConfig}
|
||||
/>
|
||||
</>,
|
||||
)
|
||||
const elsewhereButton = screen.getByRole('button', { name: 'Elsewhere' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.voiceInput.start' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
|
||||
await waitFor(() => expect(transcribeAudio).toHaveBeenCalledTimes(1))
|
||||
await user.click(elsewhereButton)
|
||||
await act(async () => rejectTranscription?.(new Error('API error')))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'common.api.actionFailed',
|
||||
})
|
||||
})
|
||||
expect(elsewhereButton).toHaveFocus()
|
||||
})
|
||||
|
||||
it('should show error toast when voice permission is denied', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
mockGetPermissionConfig.shouldReject = true
|
||||
vi.mocked(startVoiceRecorder).mockRejectedValueOnce(
|
||||
new DOMException('Permission denied', 'NotAllowedError'),
|
||||
)
|
||||
|
||||
render(
|
||||
<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />,
|
||||
<ChatInputArea
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
visionConfig={mockVisionConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.voiceInput.start' }))
|
||||
|
||||
// Permission denied should trigger error toast
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'common.voiceInput.notAllow',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
mockGetPermissionConfig.shouldReject = false
|
||||
it('should show a generic error when voice input setup fails after permission', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
vi.mocked(startVoiceRecorder).mockRejectedValueOnce(new Error('AudioWorklet unavailable'))
|
||||
|
||||
render(
|
||||
<ChatInputArea
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
visionConfig={mockVisionConfig}
|
||||
/>,
|
||||
)
|
||||
const voiceInputButton = screen.getByRole('button', { name: 'common.voiceInput.start' })
|
||||
|
||||
await user.click(voiceInputButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'common.api.actionFailed',
|
||||
})
|
||||
})
|
||||
expect(voiceInputButton).toHaveFocus()
|
||||
})
|
||||
|
||||
it('should handle empty converted text in VoiceInput', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
// Mock failure or empty result
|
||||
const { audioToText } = await import('@/service/share')
|
||||
vi.mocked(audioToText).mockResolvedValueOnce({ text: '' })
|
||||
vi.mocked(transcribeAudio).mockResolvedValueOnce({ text: '' })
|
||||
|
||||
render(
|
||||
<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />,
|
||||
<ChatInputArea
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
visionConfig={mockVisionConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.voiceInput.start' }))
|
||||
await screen.findByText(/voiceInput.speaking/i)
|
||||
const stopBtn = screen.getByTestId('voice-input-stop')
|
||||
await user.click(stopBtn)
|
||||
await user.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('voice-input-stop')).toBeNull()
|
||||
expect(screen.queryByText(/voiceInput.converting/i)).not.toBeInTheDocument()
|
||||
})
|
||||
expect(getTextarea()!).toHaveValue('')
|
||||
})
|
||||
|
||||
@ -43,10 +43,12 @@ describe('Operation', () => {
|
||||
expect(screen.queryByTestId('file-uploader')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render voice input button when speechToTextConfig.enabled is true', () => {
|
||||
it('should render voice input button when speech-to-text is enabled with a handler', () => {
|
||||
const speechConfig: EnableType = { enabled: true }
|
||||
|
||||
render(<Operation onSend={vi.fn()} speechToTextConfig={speechConfig} />)
|
||||
render(
|
||||
<Operation onSend={vi.fn()} speechToTextConfig={speechConfig} onShowVoiceInput={vi.fn()} />,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'common.voiceInput.start' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.send' })).toBeInTheDocument()
|
||||
@ -57,7 +59,12 @@ describe('Operation', () => {
|
||||
const speechConfig: EnableType = { enabled: true }
|
||||
|
||||
render(
|
||||
<Operation onSend={vi.fn()} fileConfig={fileConfig} speechToTextConfig={speechConfig} />,
|
||||
<Operation
|
||||
onSend={vi.fn()}
|
||||
fileConfig={fileConfig}
|
||||
speechToTextConfig={speechConfig}
|
||||
onShowVoiceInput={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const fileUploader = screen.getByTestId('file-uploader')
|
||||
|
||||
@ -3,11 +3,11 @@ import type { Theme } from '../../embedded-chatbot/theme/theme-context'
|
||||
import type { EnableType, OnSend } from '../../types'
|
||||
import type { InputForm } from '../type'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { decode } from 'html-entities'
|
||||
import Recorder from 'js-audio-recorder'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from 'react-textarea-autosize'
|
||||
@ -22,12 +22,12 @@ import { useCheckInputsForms } from '../check-input-forms-hooks'
|
||||
import { useTextAreaHeight } from './hooks'
|
||||
import Operation from './operation'
|
||||
|
||||
type AudioRecorderWithPermission = typeof Recorder & {
|
||||
getPermission: () => Promise<void>
|
||||
}
|
||||
|
||||
type SendAcceptance = void | boolean | Promise<void | boolean>
|
||||
|
||||
function isMicrophonePermissionDenied(error: unknown) {
|
||||
return error instanceof DOMException && error.name === 'NotAllowedError'
|
||||
}
|
||||
|
||||
type ChatInputAreaProps = {
|
||||
readonly?: boolean
|
||||
botName?: string
|
||||
@ -39,6 +39,8 @@ type ChatInputAreaProps = {
|
||||
onFeatureBarClick?: (state: boolean) => void
|
||||
visionConfig?: FileUpload
|
||||
speechToTextConfig?: EnableType
|
||||
speechToTextTarget?: SpeechToTextTarget
|
||||
onBeforeSpeechToText?: () => Promise<unknown>
|
||||
onSend?: OnSend
|
||||
inputs?: Record<string, unknown>
|
||||
inputsForm?: InputForm[]
|
||||
@ -69,6 +71,8 @@ const ChatInputArea = ({
|
||||
onFeatureBarClick,
|
||||
visionConfig,
|
||||
speechToTextConfig = { enabled: true },
|
||||
speechToTextTarget,
|
||||
onBeforeSpeechToText,
|
||||
onSend,
|
||||
inputs = {},
|
||||
inputsForm = [],
|
||||
@ -108,6 +112,9 @@ const ChatInputArea = ({
|
||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||
const isComposingRef = useRef(false)
|
||||
const queryRef = useRef('')
|
||||
const voiceInputRef = useRef<HTMLDivElement>(null)
|
||||
const voiceInputReturnFocusRef = useRef<HTMLElement | null>(null)
|
||||
const voiceInputCompletedRef = useRef(false)
|
||||
const handleQueryChange = useCallback(
|
||||
(value: string) => {
|
||||
queryRef.current = value
|
||||
@ -199,22 +206,85 @@ const ChatInputArea = ({
|
||||
}
|
||||
}
|
||||
const handleShowVoiceInput = useCallback(() => {
|
||||
;(Recorder as AudioRecorderWithPermission).getPermission().then(
|
||||
() => {
|
||||
setShowVoiceInput(true)
|
||||
},
|
||||
() => {
|
||||
toast.error(t(($) => $['voiceInput.notAllow'], { ns: 'common' }))
|
||||
},
|
||||
)
|
||||
const textareaElement = textareaRef.current
|
||||
if (textareaElement) {
|
||||
const activeElement = textareaElement.ownerDocument.activeElement
|
||||
voiceInputReturnFocusRef.current =
|
||||
activeElement instanceof HTMLElement && activeElement !== textareaElement.ownerDocument.body
|
||||
? activeElement
|
||||
: textareaElement
|
||||
}
|
||||
voiceInputCompletedRef.current = false
|
||||
setShowVoiceInput(true)
|
||||
}, [textareaRef])
|
||||
const handleVoiceInputStartError = useCallback(
|
||||
(error: unknown) => {
|
||||
toast.error(
|
||||
t(
|
||||
($) =>
|
||||
$[isMicrophonePermissionDenied(error) ? 'voiceInput.notAllow' : 'api.actionFailed'],
|
||||
{ ns: 'common' },
|
||||
),
|
||||
)
|
||||
},
|
||||
[t],
|
||||
)
|
||||
const handleVoiceInputError = useCallback(() => {
|
||||
toast.error(t(($) => $['api.actionFailed'], { ns: 'common' }))
|
||||
}, [t])
|
||||
const handleHideVoiceInput = useCallback(() => {
|
||||
const textareaElement = textareaRef.current
|
||||
const documentElement = textareaElement?.ownerDocument
|
||||
const activeElement = documentElement?.activeElement
|
||||
const shouldRestoreFocus =
|
||||
!!documentElement &&
|
||||
(activeElement === documentElement.body ||
|
||||
!!(activeElement && voiceInputRef.current?.contains(activeElement)))
|
||||
const returnFocusElement = voiceInputReturnFocusRef.current
|
||||
const voiceInputCompleted = voiceInputCompletedRef.current
|
||||
voiceInputReturnFocusRef.current = null
|
||||
voiceInputCompletedRef.current = false
|
||||
setShowVoiceInput(false)
|
||||
if (voiceInputCompleted || !shouldRestoreFocus || !documentElement) return
|
||||
|
||||
queueMicrotask(() => {
|
||||
const currentActiveElement = documentElement.activeElement
|
||||
if (currentActiveElement !== documentElement.body && currentActiveElement !== activeElement)
|
||||
return
|
||||
|
||||
const elementToFocus = returnFocusElement?.isConnected ? returnFocusElement : textareaElement
|
||||
elementToFocus?.focus({ preventScroll: true })
|
||||
})
|
||||
}, [textareaRef])
|
||||
const handleVoiceInputConverted = (text: string) => {
|
||||
voiceInputCompletedRef.current = true
|
||||
handleQueryChange(text)
|
||||
queueMicrotask(() => {
|
||||
const textareaElement = textareaRef.current
|
||||
if (!textareaElement) return
|
||||
|
||||
const activeElement = textareaElement.ownerDocument.activeElement
|
||||
if (
|
||||
activeElement &&
|
||||
activeElement !== textareaElement.ownerDocument.body &&
|
||||
activeElement !== textareaElement &&
|
||||
!voiceInputRef.current?.contains(activeElement)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
textareaElement.focus({ preventScroll: true })
|
||||
const caretPosition = textareaElement.value.length
|
||||
textareaElement.setSelectionRange(caretPosition, caretPosition)
|
||||
})
|
||||
}
|
||||
const operation = (
|
||||
<Operation
|
||||
ref={holdSpaceRef}
|
||||
readonly={readonly}
|
||||
fileConfig={visionConfig}
|
||||
speechToTextConfig={speechToTextConfig}
|
||||
onShowVoiceInput={handleShowVoiceInput}
|
||||
onShowVoiceInput={speechToTextTarget ? handleShowVoiceInput : undefined}
|
||||
onSend={handleSend}
|
||||
sendButtonLabel={sendButtonLabel}
|
||||
sendButtonLoading={sendButtonLoading}
|
||||
@ -286,10 +356,15 @@ const ChatInputArea = ({
|
||||
</div>
|
||||
{!isMultipleLine && operation}
|
||||
</div>
|
||||
{showVoiceInput && (
|
||||
{showVoiceInput && speechToTextTarget && (
|
||||
<VoiceInput
|
||||
onCancel={() => setShowVoiceInput(false)}
|
||||
onConverted={(text) => handleQueryChange(text)}
|
||||
ref={voiceInputRef}
|
||||
target={speechToTextTarget}
|
||||
onCancel={handleHideVoiceInput}
|
||||
onBeforeTranscribe={onBeforeSpeechToText}
|
||||
onConverted={handleVoiceInputConverted}
|
||||
onError={handleVoiceInputError}
|
||||
onStartError={handleVoiceInputStartError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -42,8 +42,9 @@ const Operation: FC<OperationProps> = ({
|
||||
{fileConfig?.enabled && (
|
||||
<FileUploaderInChatInput readonly={readonly} fileConfig={fileConfig} />
|
||||
)}
|
||||
{speechToTextConfig?.enabled && (
|
||||
{speechToTextConfig?.enabled && onShowVoiceInput && (
|
||||
<ActionButton
|
||||
className="shrink-0 outline-hidden focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid"
|
||||
size="l"
|
||||
aria-label={t(($) => $['voiceInput.start'], { ns: 'common' })}
|
||||
disabled={readonly}
|
||||
|
||||
@ -3,6 +3,7 @@ import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context'
|
||||
import type { ChatConfig, ChatItem, Feedback, OnRegenerate, OnSend } from '../types'
|
||||
import type { HumanInputFormSubmitData } from './answer/human-input-content/type'
|
||||
import type { InputForm } from './type'
|
||||
import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types'
|
||||
import type { HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import type { AppData, ToolIcon } from '@/models/share'
|
||||
@ -75,6 +76,8 @@ export type ChatProps = {
|
||||
sidebarCollapseState?: boolean
|
||||
hideAvatar?: boolean
|
||||
sendOnEnter?: boolean
|
||||
speechToTextTarget?: SpeechToTextTarget
|
||||
onBeforeSpeechToText?: () => Promise<unknown>
|
||||
renderAgentContent?: (props: {
|
||||
item: ChatItem
|
||||
responding?: boolean
|
||||
@ -132,6 +135,8 @@ const Chat: FC<ChatProps> = ({
|
||||
sidebarCollapseState,
|
||||
hideAvatar,
|
||||
sendOnEnter,
|
||||
speechToTextTarget,
|
||||
onBeforeSpeechToText,
|
||||
renderAgentContent,
|
||||
onHumanInputFormSubmit,
|
||||
getHumanInputNodeData,
|
||||
@ -281,6 +286,8 @@ const Chat: FC<ChatProps> = ({
|
||||
onFeatureBarClick={onFeatureBarClick}
|
||||
visionConfig={config?.file_upload}
|
||||
speechToTextConfig={config?.speech_to_text}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
onBeforeSpeechToText={onBeforeSpeechToText}
|
||||
onSend={onSend}
|
||||
inputs={inputs}
|
||||
inputsForm={inputsForm}
|
||||
|
||||
@ -420,12 +420,19 @@ const ChatWrapper = () => {
|
||||
imageUrl={appData.site.icon_url}
|
||||
/>
|
||||
) : null
|
||||
const speechToTextTarget =
|
||||
appSourceType === AppSourceType.webApp
|
||||
? { type: 'app' as const, appSourceType }
|
||||
: appId
|
||||
? { type: 'app' as const, appId, appSourceType }
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Chat
|
||||
isTryApp={isTryApp}
|
||||
appData={appData || undefined}
|
||||
config={appConfig}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
chatList={messageList}
|
||||
isResponding={respondingState}
|
||||
chatContainerInnerClassName={cn(
|
||||
|
||||
110
web/app/components/base/voice-input/__tests__/api.spec.ts
Normal file
110
web/app/components/base/voice-input/__tests__/api.spec.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import { consoleClient } from '@/service/client'
|
||||
import { AppSourceType, audioToText } from '@/service/share'
|
||||
import { transcribeAudio } from '../api'
|
||||
|
||||
vi.mock('@/service/share', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@/service/share')>()
|
||||
return {
|
||||
...original,
|
||||
audioToText: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
apps: {
|
||||
byAppId: {
|
||||
audioToText: {
|
||||
post: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
byAgentId: {
|
||||
audioToText: {
|
||||
post: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
describe('transcribeAudio', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// App surfaces retain their existing transport-specific endpoints.
|
||||
it('should send app audio through the existing app transport', async () => {
|
||||
const file = new File(['mp3'], 'temp.mp3', { type: 'audio/mp3' })
|
||||
const signal = new AbortController().signal
|
||||
vi.mocked(audioToText).mockResolvedValue({ text: 'app transcript' })
|
||||
|
||||
const result = await transcribeAudio(
|
||||
{
|
||||
type: 'app',
|
||||
appId: 'app-1',
|
||||
appSourceType: AppSourceType.installedApp,
|
||||
},
|
||||
file,
|
||||
signal,
|
||||
)
|
||||
|
||||
expect(result).toEqual({ text: 'app transcript' })
|
||||
const formData = vi.mocked(audioToText).mock.calls[0]![2]
|
||||
expect(audioToText).toHaveBeenCalledWith(AppSourceType.installedApp, 'app-1', formData, signal)
|
||||
expect(formData.get('file')).toBe(file)
|
||||
})
|
||||
|
||||
it('should send console App audio through its generated multipart contract', async () => {
|
||||
const file = new File(['mp3'], 'temp.mp3', { type: 'audio/mp3' })
|
||||
const signal = new AbortController().signal
|
||||
const post = consoleClient.apps.byAppId.audioToText.post
|
||||
vi.mocked(post).mockResolvedValue({ text: 'console app transcript' })
|
||||
|
||||
const result = await transcribeAudio({ type: 'consoleApp', appId: 'app-1' }, file, signal)
|
||||
|
||||
expect(result).toEqual({ text: 'console app transcript' })
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
{
|
||||
body: { file },
|
||||
params: { app_id: 'app-1' },
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
expect(audioToText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Agent debug audio uses its generated ID-first multipart contract.
|
||||
it('should send Agent audio with the requested draft type', async () => {
|
||||
const file = new File(['mp3'], 'temp.mp3', { type: 'audio/mp3' })
|
||||
const signal = new AbortController().signal
|
||||
const post = consoleClient.agent.byAgentId.audioToText.post
|
||||
vi.mocked(post).mockResolvedValue({ text: 'agent transcript' })
|
||||
|
||||
const result = await transcribeAudio(
|
||||
{
|
||||
type: 'agent',
|
||||
agentId: 'agent-1',
|
||||
draftType: 'debug_build',
|
||||
},
|
||||
file,
|
||||
signal,
|
||||
)
|
||||
|
||||
expect(result).toEqual({ text: 'agent transcript' })
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
{
|
||||
body: {
|
||||
draft_type: 'debug_build',
|
||||
file,
|
||||
},
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
expect(audioToText).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -1,523 +1,318 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { audioToText } from '@/service/share'
|
||||
import type { VoiceRecorder } from '../recorder'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { StrictMode } from 'react'
|
||||
import { transcribeAudio } from '../api'
|
||||
import VoiceInput from '../index'
|
||||
import { startVoiceRecorder } from '../recorder'
|
||||
|
||||
const { mockState, MockRecorder, rafState } = vi.hoisted(() => {
|
||||
const state = {
|
||||
params: {} as Record<string, string>,
|
||||
pathname: '/test',
|
||||
recorderInstances: [] as unknown[],
|
||||
startOverride: null as (() => Promise<void>) | null,
|
||||
analyseData: new Uint8Array(1024).fill(150) as Uint8Array,
|
||||
}
|
||||
const rafStateObj = {
|
||||
callback: null as (() => void) | null,
|
||||
}
|
||||
vi.mock('../api', () => ({ transcribeAudio: vi.fn() }))
|
||||
|
||||
class MockRecorderClass {
|
||||
start = vi.fn((..._args: unknown[]) => {
|
||||
if (state.startOverride) return state.startOverride()
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
stop = vi.fn()
|
||||
getRecordAnalyseData = vi.fn(() => state.analyseData)
|
||||
getWAV = vi.fn(() => new ArrayBuffer(0))
|
||||
getChannelData = vi.fn(() => ({
|
||||
left: { buffer: new ArrayBuffer(2048), byteLength: 2048 },
|
||||
right: { buffer: new ArrayBuffer(2048), byteLength: 2048 },
|
||||
}))
|
||||
|
||||
constructor() {
|
||||
state.recorderInstances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
return { mockState: state, MockRecorder: MockRecorderClass, rafState: rafStateObj }
|
||||
})
|
||||
|
||||
vi.mock('js-audio-recorder', () => ({
|
||||
default: MockRecorder,
|
||||
vi.mock('../recorder', () => ({
|
||||
startVoiceRecorder: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/share', () => ({
|
||||
AppSourceType: { webApp: 'webApp', installedApp: 'installedApp' },
|
||||
audioToText: vi.fn(),
|
||||
}))
|
||||
const recorderStop = vi.fn<() => Promise<Blob>>()
|
||||
const recorderCancel = vi.fn<() => Promise<void>>()
|
||||
const getByteFrequencyData = vi.fn()
|
||||
const recorder: VoiceRecorder = {
|
||||
analyser: {
|
||||
frequencyBinCount: 8,
|
||||
getByteFrequencyData,
|
||||
} as unknown as AnalyserNode,
|
||||
stop: recorderStop,
|
||||
cancel: recorderCancel,
|
||||
}
|
||||
const target = {
|
||||
type: 'consoleApp' as const,
|
||||
appId: 'app-123',
|
||||
}
|
||||
const canvasContext = {
|
||||
beginPath: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
get fillStyle() {
|
||||
return ''
|
||||
},
|
||||
set fillStyle(_value: string) {},
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useParams: vi.fn(() => mockState.params),
|
||||
usePathname: vi.fn(() => mockState.pathname),
|
||||
}))
|
||||
|
||||
vi.mock('../utils', () => ({
|
||||
convertToMp3: vi.fn(() => new Blob(['test'], { type: 'audio/mp3' })),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ahooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useRafInterval: vi.fn((fn) => {
|
||||
rafState.callback = fn
|
||||
return vi.fn()
|
||||
}),
|
||||
const renderVoiceInput = (overrides: Partial<React.ComponentProps<typeof VoiceInput>> = {}) => {
|
||||
const props: React.ComponentProps<typeof VoiceInput> = {
|
||||
onCancel: vi.fn(),
|
||||
onBeforeTranscribe: vi.fn().mockResolvedValue(undefined),
|
||||
onConverted: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
onStartError: vi.fn(),
|
||||
target,
|
||||
...overrides,
|
||||
}
|
||||
})
|
||||
return { ...render(<VoiceInput {...props} />), props }
|
||||
}
|
||||
|
||||
describe('VoiceInput', () => {
|
||||
const onConverted = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockState.params = {}
|
||||
mockState.pathname = '/test'
|
||||
mockState.recorderInstances = []
|
||||
mockState.startOverride = null
|
||||
rafState.callback = null
|
||||
|
||||
// Ensure canvas has non-zero dimensions for initCanvas()
|
||||
HTMLCanvasElement.prototype.getBoundingClientRect = vi.fn(() => ({
|
||||
width: 300,
|
||||
height: 32,
|
||||
top: 0,
|
||||
vi.mocked(startVoiceRecorder).mockResolvedValue(recorder)
|
||||
recorderStop.mockResolvedValue(new Blob(['mp3-data'], { type: 'audio/mp3' }))
|
||||
recorderCancel.mockResolvedValue()
|
||||
vi.mocked(transcribeAudio).mockResolvedValue({ text: 'transcribed text' })
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(canvasContext)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
bottom: 16,
|
||||
height: 16,
|
||||
left: 0,
|
||||
right: 300,
|
||||
bottom: 32,
|
||||
top: 0,
|
||||
width: 300,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('should start recording on mount and show speaking state', async () => {
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
const recorder = mockState.recorderInstances[0] as any
|
||||
expect(recorder.start).toHaveBeenCalled()
|
||||
expect(await screen.findByText('common.voiceInput.speaking'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('voice-input-stop'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('voice-input-timer'))!.toHaveTextContent('00:00')
|
||||
})
|
||||
|
||||
it('should call onCancel when recording start fails', async () => {
|
||||
mockState.startOverride = () => Promise.reject(new Error('Permission denied'))
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await waitFor(() => {
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn(() => 1),
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
})
|
||||
|
||||
it('should stop recording and convert audio on stop click', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'hello world' })
|
||||
mockState.params = { token: 'abc' }
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
const stopBtn = await screen.findByTestId('voice-input-stop')
|
||||
await user.click(stopBtn)
|
||||
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
const recorder = mockState.recorderInstances[0] as any
|
||||
expect(await screen.findByTestId('voice-input-converting-text'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('common.voiceInput.converting'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('voice-input-loader'))!.toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(recorder.stop).toHaveBeenCalled()
|
||||
expect(onConverted).toHaveBeenCalledWith('hello world')
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onConverted with empty string on conversion failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(audioToText).mockRejectedValueOnce(new Error('API error'))
|
||||
mockState.params = { token: 'abc' }
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
const stopBtn = await screen.findByTestId('voice-input-stop')
|
||||
await user.click(stopBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onConverted).toHaveBeenCalledWith('')
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show cancel button during conversion and cancel on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(audioToText).mockImplementation(() => new Promise(() => {}))
|
||||
mockState.params = { token: 'abc' }
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
const stopBtn = await screen.findByTestId('voice-input-stop')
|
||||
await user.click(stopBtn)
|
||||
|
||||
const cancelBtn = await screen.findByTestId('voice-input-cancel')
|
||||
await user.click(cancelBtn)
|
||||
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should draw on canvas with low data values triggering v < 128 clamp', async () => {
|
||||
mockState.analyseData = new Uint8Array(1024).fill(50)
|
||||
|
||||
let rafCalls = 0
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
rafCalls++
|
||||
if (rafCalls <= 2) cb(0)
|
||||
return rafCalls
|
||||
})
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await screen.findByTestId('voice-input-stop')
|
||||
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
const firstRecorder = mockState.recorderInstances[0] as any
|
||||
expect(firstRecorder.getRecordAnalyseData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should draw on canvas with high data values triggering v > 178 clamp', async () => {
|
||||
mockState.analyseData = new Uint8Array(1024).fill(250)
|
||||
|
||||
let rafCalls = 0
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
rafCalls++
|
||||
if (rafCalls <= 2) cb(0)
|
||||
return rafCalls
|
||||
})
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await screen.findByTestId('voice-input-stop')
|
||||
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
const firstRecorder = mockState.recorderInstances[0] as any
|
||||
expect(firstRecorder.getRecordAnalyseData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should pass wordTimestamps in form data', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'test' })
|
||||
mockState.params = { token: 'abc' }
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} wordTimestamps="enabled" />)
|
||||
const stopBtn = await screen.findByTestId('voice-input-stop')
|
||||
await user.click(stopBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(audioToText).toHaveBeenCalled()
|
||||
const formData = vi.mocked(audioToText).mock.calls[0]![2] as FormData
|
||||
expect(formData.get('word_timestamps')).toBe('enabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL patterns', () => {
|
||||
it('should use webApp source with /audio-to-text for token-based URL', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'test' })
|
||||
mockState.params = { token: 'my-token' }
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await user.click(await screen.findByTestId('voice-input-stop'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(audioToText).toHaveBeenCalledWith('/audio-to-text', 'webApp', expect.any(FormData))
|
||||
// The component owns the local recording state and browser-resource lifecycle.
|
||||
describe('Recording state', () => {
|
||||
it('should expose a cancellable starting state while microphone permission is pending', async () => {
|
||||
let setupSignal: AbortSignal | undefined
|
||||
vi.mocked(startVoiceRecorder).mockImplementationOnce((signal) => {
|
||||
setupSignal = signal
|
||||
return new Promise(() => {})
|
||||
})
|
||||
const { props } = renderVoiceInput()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('common.voiceInput.starting')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1)
|
||||
expect(setupSignal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('should use installed-apps URL when pathname is an installed app route', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'test' })
|
||||
mockState.params = { appId: 'app-123' }
|
||||
mockState.pathname = '/installed/app-123'
|
||||
it('should show the recording state after microphone setup succeeds', async () => {
|
||||
renderVoiceInput()
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await user.click(await screen.findByTestId('voice-input-stop'))
|
||||
expect(await screen.findByText('common.voiceInput.speaking')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.voiceInput.stop' })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('voice-input-timer')).toHaveTextContent('00:00')
|
||||
expect(getByteFrequencyData).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(audioToText).toHaveBeenCalledWith(
|
||||
'/installed-apps/app-123/audio-to-text',
|
||||
'installedApp',
|
||||
expect.any(FormData),
|
||||
it('should report the original setup failure and close the voice input', async () => {
|
||||
const startError = new DOMException('microphone denied', 'NotAllowedError')
|
||||
vi.mocked(startVoiceRecorder).mockRejectedValueOnce(startError)
|
||||
const { props } = renderVoiceInput()
|
||||
|
||||
await waitFor(() => expect(props.onStartError).toHaveBeenCalledWith(startError))
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should cancel recorder resources when unmounted', async () => {
|
||||
const { unmount } = renderVoiceInput()
|
||||
await screen.findByText('common.voiceInput.speaking')
|
||||
|
||||
unmount()
|
||||
|
||||
expect(recorderCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should discard a stale recorder when effects are replayed', async () => {
|
||||
let resolveFirstRecorder: (value: VoiceRecorder) => void = () => {}
|
||||
let resolveSecondRecorder: (value: VoiceRecorder) => void = () => {}
|
||||
const staleCancel = vi.fn().mockResolvedValue(undefined)
|
||||
const activeCancel = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(startVoiceRecorder)
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveFirstRecorder = resolve
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should use /apps URL for non-explore paths with appId', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'test' })
|
||||
mockState.params = { appId: 'app-456' }
|
||||
mockState.pathname = '/dashboard/apps'
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await user.click(await screen.findByTestId('voice-input-stop'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(audioToText).toHaveBeenCalledWith(
|
||||
'/apps/app-456/audio-to-text',
|
||||
'installedApp',
|
||||
expect.any(FormData),
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveSecondRecorder = resolve
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should use fallback rect when canvas roundRect is not available', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'test' })
|
||||
mockState.params = { token: 'abc' }
|
||||
mockState.analyseData = new Uint8Array(1024).fill(150)
|
||||
|
||||
const oldGetContext = HTMLCanvasElement.prototype.getContext
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn(() => ({
|
||||
scale: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
})) as unknown as typeof HTMLCanvasElement.prototype.getContext
|
||||
|
||||
let rafCalls = 0
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
rafCalls++
|
||||
if (rafCalls <= 1) cb(0)
|
||||
return rafCalls
|
||||
})
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await user.click(await screen.findByTestId('voice-input-stop'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onConverted).toHaveBeenCalled()
|
||||
})
|
||||
HTMLCanvasElement.prototype.getContext = oldGetContext
|
||||
})
|
||||
|
||||
it('should display timer in MM:SS format correctly', async () => {
|
||||
mockState.params = { token: 'abc' }
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
const timer = await screen.findByTestId('voice-input-timer')
|
||||
expect(timer)!.toHaveTextContent('00:00')
|
||||
|
||||
await act(async () => {
|
||||
if (rafState.callback) rafState.callback()
|
||||
})
|
||||
expect(timer)!.toHaveTextContent('00:01')
|
||||
|
||||
for (let i = 0; i < 9; i++) {
|
||||
await act(async () => {
|
||||
if (rafState.callback) rafState.callback()
|
||||
})
|
||||
}
|
||||
expect(timer)!.toHaveTextContent('00:10')
|
||||
})
|
||||
|
||||
it('should show timer element with formatted time', async () => {
|
||||
mockState.params = { token: 'abc' }
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
const timer = screen.getByTestId('voice-input-timer')
|
||||
expect(timer)!.toBeInTheDocument()
|
||||
// Initial state should show 00:00
|
||||
expect(timer.textContent).toMatch(/0\d:\d{2}/)
|
||||
})
|
||||
|
||||
it('should handle data values in normal range (between 128 and 178)', async () => {
|
||||
mockState.analyseData = new Uint8Array(1024).fill(150)
|
||||
|
||||
let rafCalls = 0
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
rafCalls++
|
||||
if (rafCalls <= 2) cb(0)
|
||||
return rafCalls
|
||||
})
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await screen.findByTestId('voice-input-stop')
|
||||
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
const recorder = mockState.recorderInstances[0] as any
|
||||
expect(recorder.getRecordAnalyseData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle canvas context and device pixel ratio', async () => {
|
||||
const dprSpy = vi.spyOn(window, 'devicePixelRatio', 'get')
|
||||
dprSpy.mockReturnValue(2)
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await screen.findByTestId('voice-input-stop')
|
||||
|
||||
expect(screen.getByTestId('voice-input-stop'))!.toBeInTheDocument()
|
||||
|
||||
dprSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should handle empty params with no token or appId', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'test' })
|
||||
mockState.params = {}
|
||||
mockState.pathname = '/test'
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
const stopBtn = await screen.findByTestId('voice-input-stop')
|
||||
await user.click(stopBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
// Should call audioToText with empty URL when neither token nor appId is present
|
||||
expect(audioToText).toHaveBeenCalledWith('', 'installedApp', expect.any(FormData))
|
||||
})
|
||||
})
|
||||
|
||||
it('should render speaking state indicator', async () => {
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
expect(await screen.findByText('common.voiceInput.speaking'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should cleanup on unmount', () => {
|
||||
const { unmount } = render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
const recorder = mockState.recorderInstances[0] as any
|
||||
|
||||
unmount()
|
||||
|
||||
expect(recorder.stop).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle all data in recordAnalyseData for canvas drawing', async () => {
|
||||
const allDataValues = []
|
||||
for (let i = 0; i < 256; i++) {
|
||||
allDataValues.push(i)
|
||||
}
|
||||
mockState.analyseData = new Uint8Array(allDataValues)
|
||||
|
||||
let rafCalls = 0
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
rafCalls++
|
||||
if (rafCalls <= 2) cb(0)
|
||||
return rafCalls
|
||||
})
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await screen.findByTestId('voice-input-stop')
|
||||
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
const recorder = mockState.recorderInstances[0] as any
|
||||
expect(recorder.getRecordAnalyseData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should pass multiple props correctly', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'test' })
|
||||
mockState.params = { token: 'token123' }
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} wordTimestamps="enabled" />)
|
||||
|
||||
const stopBtn = await screen.findByTestId('voice-input-stop')
|
||||
await user.click(stopBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = vi.mocked(audioToText).mock.calls
|
||||
expect(calls.length).toBeGreaterThan(0)
|
||||
const [url, sourceType, formData] = (calls[0] ?? []) as [any, any, any]
|
||||
expect(url).toBe('/audio-to-text')
|
||||
expect(sourceType).toBe('webApp')
|
||||
expect(formData.get('word_timestamps')).toBe('enabled')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle legacy explore installed pathname when appId exists', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'test' })
|
||||
mockState.params = { appId: 'app-id-123' }
|
||||
mockState.pathname = '/explore/installed/app-details'
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
const stopBtn = await screen.findByTestId('voice-input-stop')
|
||||
await user.click(stopBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(audioToText).toHaveBeenCalledWith(
|
||||
'/installed-apps/app-id-123/audio-to-text',
|
||||
'installedApp',
|
||||
expect.any(FormData),
|
||||
render(
|
||||
<StrictMode>
|
||||
<VoiceInput onCancel={vi.fn()} onConverted={vi.fn()} target={target} />
|
||||
</StrictMode>,
|
||||
)
|
||||
resolveSecondRecorder({ ...recorder, cancel: activeCancel })
|
||||
expect(await screen.findByText('common.voiceInput.speaking')).toBeInTheDocument()
|
||||
|
||||
resolveFirstRecorder({ ...recorder, cancel: staleCancel })
|
||||
|
||||
await waitFor(() => expect(staleCancel).toHaveBeenCalledTimes(1))
|
||||
expect(activeCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep the active recorder when error callbacks change', async () => {
|
||||
const { props, rerender } = renderVoiceInput()
|
||||
await screen.findByText('common.voiceInput.speaking')
|
||||
|
||||
rerender(<VoiceInput {...props} onCancel={vi.fn()} onStartError={vi.fn()} />)
|
||||
|
||||
expect(startVoiceRecorder).toHaveBeenCalledTimes(1)
|
||||
expect(recorderCancel).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: 'common.voiceInput.stop' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render timer with initial 00:00 value', () => {
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
const timer = screen.getByTestId('voice-input-timer')
|
||||
expect(timer)!.toHaveTextContent('00:00')
|
||||
})
|
||||
// Stopping must upload a real MP3 file to the explicit owner-provided target.
|
||||
describe('Conversion', () => {
|
||||
it('should upload MP3 bytes to the explicit app target', async () => {
|
||||
const { props } = renderVoiceInput()
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
|
||||
|
||||
it('should render stop button during recording', async () => {
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
expect(await screen.findByTestId('voice-input-stop'))!.toBeInTheDocument()
|
||||
})
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('common.voiceInput.converting')
|
||||
await waitFor(() => expect(transcribeAudio).toHaveBeenCalledTimes(1))
|
||||
const [requestTarget, file] = vi.mocked(transcribeAudio).mock.calls[0]!
|
||||
expect(requestTarget).toBe(target)
|
||||
expect(file.name).toBe('temp.mp3')
|
||||
expect(file.type).toBe('audio/mp3')
|
||||
expect(props.onBeforeTranscribe).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(props.onBeforeTranscribe!).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
vi.mocked(transcribeAudio).mock.invocationCallOrder[0]!,
|
||||
)
|
||||
await waitFor(() => expect(props.onConverted).toHaveBeenCalledWith('transcribed text'))
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render converting UI after stopping', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(audioToText).mockImplementation(() => new Promise(() => {}))
|
||||
mockState.params = { token: 'abc' }
|
||||
it('should ignore repeated stop requests', async () => {
|
||||
let resolveStop: (blob: Blob) => void = () => {}
|
||||
recorderStop.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveStop = resolve
|
||||
}),
|
||||
)
|
||||
renderVoiceInput()
|
||||
const stopButton = await screen.findByRole('button', { name: 'common.voiceInput.stop' })
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
const stopBtn = await screen.findByTestId('voice-input-stop')
|
||||
await user.click(stopBtn)
|
||||
fireEvent.click(stopButton)
|
||||
fireEvent.click(stopButton)
|
||||
|
||||
await screen.findByTestId('voice-input-loader')
|
||||
expect(screen.getByTestId('voice-input-converting-text'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('voice-input-cancel'))!.toBeInTheDocument()
|
||||
})
|
||||
expect(recorderStop).toHaveBeenCalledTimes(1)
|
||||
resolveStop(new Blob(['mp3-data'], { type: 'audio/mp3' }))
|
||||
await waitFor(() => expect(transcribeAudio).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('should auto-stop recording and convert audio when duration reaches 10 minutes (600s)', async () => {
|
||||
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'auto-stopped text' })
|
||||
mockState.params = { token: 'abc' }
|
||||
it('should report conversion failure without replacing the current text', async () => {
|
||||
vi.mocked(transcribeAudio).mockRejectedValueOnce(new Error('API error'))
|
||||
const { props } = renderVoiceInput()
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
|
||||
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
expect(await screen.findByTestId('voice-input-stop'))!.toBeInTheDocument()
|
||||
await waitFor(() => expect(props.onError).toHaveBeenCalledTimes(1))
|
||||
expect(props.onConverted).not.toHaveBeenCalled()
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
for (let i = 0; i < 601; i++) {
|
||||
await act(async () => {
|
||||
if (rafState.callback) rafState.callback()
|
||||
it('should not upload when saving the owning draft fails', async () => {
|
||||
const onBeforeTranscribe = vi.fn().mockRejectedValue(new Error('save failed'))
|
||||
const { props } = renderVoiceInput({ onBeforeTranscribe })
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
|
||||
|
||||
await waitFor(() => expect(props.onCancel).toHaveBeenCalledTimes(1))
|
||||
expect(transcribeAudio).not.toHaveBeenCalled()
|
||||
expect(props.onConverted).not.toHaveBeenCalled()
|
||||
expect(props.onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close without publishing late results when conversion is cancelled', async () => {
|
||||
let requestSignal: AbortSignal | undefined
|
||||
vi.mocked(transcribeAudio).mockImplementationOnce((_target, _file, signal) => {
|
||||
requestSignal = signal
|
||||
return new Promise(() => {})
|
||||
})
|
||||
}
|
||||
const { props } = renderVoiceInput()
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
|
||||
await waitFor(() => expect(transcribeAudio).toHaveBeenCalledTimes(1))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(await screen.findByTestId('voice-input-converting-text'))!.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(onConverted).toHaveBeenCalledWith('auto-stopped text')
|
||||
expect(recorderCancel).toHaveBeenCalledTimes(1)
|
||||
expect(requestSignal?.aborted).toBe(true)
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1)
|
||||
expect(props.onConverted).not.toHaveBeenCalled()
|
||||
})
|
||||
}, 10000)
|
||||
|
||||
it('should handle null canvas element gracefully during initialization', async () => {
|
||||
const getElementByIdMock = vi.spyOn(document, 'getElementById').mockReturnValue(null)
|
||||
it('should not upload when conversion is cancelled while the recorder is stopping', async () => {
|
||||
let resolveStop: (blob: Blob) => void = () => {}
|
||||
recorderStop.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveStop = resolve
|
||||
}),
|
||||
)
|
||||
const { props } = renderVoiceInput()
|
||||
|
||||
const { unmount } = render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await screen.findByTestId('voice-input-stop')
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.cancel' }))
|
||||
resolveStop(new Blob(['mp3-data'], { type: 'audio/mp3' }))
|
||||
|
||||
unmount()
|
||||
await act(async () => {})
|
||||
expect(transcribeAudio).not.toHaveBeenCalled()
|
||||
expect(props.onBeforeTranscribe).not.toHaveBeenCalled()
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1)
|
||||
expect(props.onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
getElementByIdMock.mockRestore()
|
||||
it('should not upload when conversion is cancelled while the draft is saving', async () => {
|
||||
let resolveDraftSave: () => void = () => {}
|
||||
const onBeforeTranscribe = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveDraftSave = resolve
|
||||
}),
|
||||
)
|
||||
const { props } = renderVoiceInput({ onBeforeTranscribe })
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'common.voiceInput.stop' }))
|
||||
await waitFor(() => expect(onBeforeTranscribe).toHaveBeenCalledTimes(1))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
resolveDraftSave()
|
||||
|
||||
await act(async () => {})
|
||||
expect(transcribeAudio).not.toHaveBeenCalled()
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1)
|
||||
expect(props.onError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle getContext returning null gracefully during initialization', async () => {
|
||||
const oldGetContext = HTMLCanvasElement.prototype.getContext
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue(null)
|
||||
// The ten-minute limit is a local timer transition, not a render-time side effect.
|
||||
describe('Duration limit', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
const { unmount } = render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await screen.findByTestId('voice-input-stop')
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
unmount()
|
||||
it('should update the timer while recording', async () => {
|
||||
renderVoiceInput()
|
||||
await act(async () => {})
|
||||
|
||||
HTMLCanvasElement.prototype.getContext = oldGetContext
|
||||
act(() => vi.advanceTimersByTime(1000))
|
||||
|
||||
expect(screen.getByTestId('voice-input-timer')).toHaveTextContent('00:01')
|
||||
})
|
||||
|
||||
it('should stop automatically at ten minutes', async () => {
|
||||
renderVoiceInput()
|
||||
await act(async () => {})
|
||||
|
||||
act(() => vi.advanceTimersByTime(600_000))
|
||||
await act(async () => {})
|
||||
|
||||
expect(recorderStop).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByTestId('voice-input-timer')).toHaveTextContent('10:00')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
442
web/app/components/base/voice-input/__tests__/recorder.spec.ts
Normal file
442
web/app/components/base/voice-input/__tests__/recorder.spec.ts
Normal file
@ -0,0 +1,442 @@
|
||||
import { startVoiceRecorder } from '../recorder'
|
||||
|
||||
const mediaMocks = vi.hoisted(() => ({
|
||||
audioSourceAdd: vi.fn<(buffer: AudioBuffer) => Promise<void>>(),
|
||||
audioSourceConfig: undefined as unknown,
|
||||
outputCancel: vi.fn<() => Promise<void>>(),
|
||||
outputFinalize: vi.fn<() => Promise<void>>(),
|
||||
outputStart: vi.fn<() => Promise<void>>(),
|
||||
registerMp3Encoder: vi.fn(),
|
||||
target: undefined as { buffer: ArrayBuffer | null } | undefined,
|
||||
}))
|
||||
|
||||
vi.mock('@mediabunny/mp3-encoder', () => ({
|
||||
registerMp3Encoder: mediaMocks.registerMp3Encoder,
|
||||
}))
|
||||
|
||||
vi.mock('mediabunny', () => ({
|
||||
AudioBufferSource: class MockAudioBufferSource {
|
||||
constructor(config: unknown) {
|
||||
mediaMocks.audioSourceConfig = config
|
||||
}
|
||||
|
||||
add(buffer: AudioBuffer) {
|
||||
return mediaMocks.audioSourceAdd(buffer)
|
||||
}
|
||||
},
|
||||
BufferTarget: class MockBufferTarget {
|
||||
buffer: ArrayBuffer | null = null
|
||||
|
||||
constructor() {
|
||||
mediaMocks.target = this
|
||||
}
|
||||
},
|
||||
Mp3OutputFormat: class MockMp3OutputFormat {},
|
||||
Output: class MockOutput {
|
||||
addAudioTrack = vi.fn()
|
||||
|
||||
start() {
|
||||
return mediaMocks.outputStart()
|
||||
}
|
||||
|
||||
finalize() {
|
||||
return mediaMocks.outputFinalize()
|
||||
}
|
||||
|
||||
cancel() {
|
||||
return mediaMocks.outputCancel()
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const trackStop = vi.fn()
|
||||
const stream = {
|
||||
getAudioTracks: vi.fn(() => [{ stop: trackStop }]),
|
||||
getTracks: vi.fn(() => [{ stop: trackStop }]),
|
||||
} as unknown as MediaStream
|
||||
const getUserMedia = vi.fn<() => Promise<MediaStream>>()
|
||||
const addModule = vi.fn<() => Promise<void>>()
|
||||
const audioContextClose = vi.fn<() => Promise<void>>()
|
||||
const audioContextResume = vi.fn<() => Promise<void>>()
|
||||
const analyserDisconnect = vi.fn()
|
||||
const analyser = {
|
||||
disconnect: analyserDisconnect,
|
||||
fftSize: 0,
|
||||
} as unknown as AnalyserNode
|
||||
const streamSourceConnect = vi.fn()
|
||||
const streamSourceDisconnect = vi.fn()
|
||||
const streamSource = {
|
||||
connect: streamSourceConnect,
|
||||
disconnect: streamSourceDisconnect,
|
||||
} as unknown as MediaStreamAudioSourceNode
|
||||
const copyToChannel = vi.fn()
|
||||
let audioContextState: AudioContextState = 'running'
|
||||
|
||||
class MockAudioContext {
|
||||
audioWorklet = { addModule }
|
||||
destination = {}
|
||||
sampleRate = 48_000
|
||||
|
||||
get state() {
|
||||
return audioContextState
|
||||
}
|
||||
|
||||
createAnalyser() {
|
||||
return analyser
|
||||
}
|
||||
|
||||
createMediaStreamSource() {
|
||||
return streamSource
|
||||
}
|
||||
|
||||
createBuffer() {
|
||||
return { copyToChannel } as unknown as AudioBuffer
|
||||
}
|
||||
|
||||
close() {
|
||||
return audioContextClose()
|
||||
}
|
||||
|
||||
resume() {
|
||||
audioContextState = 'running'
|
||||
return audioContextResume()
|
||||
}
|
||||
}
|
||||
|
||||
const workletConnect = vi.fn()
|
||||
const workletDisconnect = vi.fn()
|
||||
let workletRespondsToStop = true
|
||||
|
||||
class MockAudioWorkletNode {
|
||||
port: {
|
||||
onmessage:
|
||||
| ((event: MessageEvent<{ type: 'data'; buffer: ArrayBuffer } | { type: 'stopped' }>) => void)
|
||||
| null
|
||||
postMessage: (message: { type: string }) => void
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.port = {
|
||||
onmessage: null,
|
||||
postMessage: vi.fn((message: { type: string }) => {
|
||||
if (message.type !== 'stop' || !workletRespondsToStop) return
|
||||
this.port.onmessage?.({
|
||||
data: { type: 'data', buffer: new Float32Array([0.25, -0.25]).buffer },
|
||||
} as MessageEvent<{ type: 'data'; buffer: ArrayBuffer }>)
|
||||
this.port.onmessage?.({ data: { type: 'stopped' } } as MessageEvent<{ type: 'stopped' }>)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
connect = workletConnect
|
||||
disconnect = workletDisconnect
|
||||
}
|
||||
|
||||
describe('startVoiceRecorder', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mediaMocks.target = undefined
|
||||
mediaMocks.audioSourceConfig = undefined
|
||||
getUserMedia.mockResolvedValue(stream)
|
||||
addModule.mockResolvedValue()
|
||||
audioContextClose.mockResolvedValue()
|
||||
audioContextResume.mockResolvedValue()
|
||||
mediaMocks.audioSourceAdd.mockResolvedValue()
|
||||
mediaMocks.outputCancel.mockResolvedValue()
|
||||
mediaMocks.outputStart.mockResolvedValue()
|
||||
mediaMocks.outputFinalize.mockImplementation(async () => {
|
||||
if (mediaMocks.target) mediaMocks.target.buffer = new Uint8Array([1, 2, 3]).buffer
|
||||
})
|
||||
workletRespondsToStop = true
|
||||
audioContextState = 'running'
|
||||
vi.stubGlobal('AudioContext', MockAudioContext)
|
||||
vi.stubGlobal('AudioWorkletNode', MockAudioWorkletNode)
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: { getUserMedia },
|
||||
})
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:voice-worklet')
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
// Recording setup uses one browser-native PCM path and one MP3 encoder configuration.
|
||||
describe('Setup', () => {
|
||||
it('should initialize mono microphone capture and the MP3 encoder', async () => {
|
||||
await startVoiceRecorder()
|
||||
|
||||
expect(getUserMedia).toHaveBeenCalledWith({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
},
|
||||
})
|
||||
expect(mediaMocks.registerMp3Encoder).toHaveBeenCalledTimes(1)
|
||||
expect(mediaMocks.audioSourceConfig).toEqual({
|
||||
bitrate: 128_000,
|
||||
codec: 'mp3',
|
||||
transform: {
|
||||
numberOfChannels: 1,
|
||||
sampleRate: 16_000,
|
||||
},
|
||||
})
|
||||
expect(addModule).toHaveBeenCalledWith('blob:voice-worklet')
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:voice-worklet')
|
||||
})
|
||||
|
||||
it('should resume a suspended audio context before recording', async () => {
|
||||
audioContextState = 'suspended'
|
||||
|
||||
await startVoiceRecorder()
|
||||
|
||||
expect(audioContextResume).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should release a microphone stream that resolves after setup is cancelled', async () => {
|
||||
let resolveStream: (stream: MediaStream) => void = () => {}
|
||||
getUserMedia.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveStream = resolve
|
||||
}),
|
||||
)
|
||||
const abortController = new AbortController()
|
||||
const recorderPromise = startVoiceRecorder(abortController.signal)
|
||||
await vi.waitFor(() => expect(getUserMedia).toHaveBeenCalledTimes(1))
|
||||
|
||||
abortController.abort()
|
||||
resolveStream(stream)
|
||||
|
||||
await expect(recorderPromise).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(trackStop).toHaveBeenCalledTimes(1)
|
||||
expect(addModule).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should abort and clean up while the worklet module is still loading', async () => {
|
||||
addModule.mockReturnValueOnce(new Promise(() => {}))
|
||||
const abortController = new AbortController()
|
||||
const recorderPromise = startVoiceRecorder(abortController.signal)
|
||||
await vi.waitFor(() => expect(addModule).toHaveBeenCalledTimes(1))
|
||||
|
||||
abortController.abort()
|
||||
|
||||
await expect(recorderPromise).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(trackStop).toHaveBeenCalledTimes(1)
|
||||
expect(audioContextClose).toHaveBeenCalledTimes(1)
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:voice-worklet')
|
||||
})
|
||||
|
||||
it('should abort and clean up while the encoder is still starting', async () => {
|
||||
mediaMocks.outputStart.mockReturnValueOnce(new Promise(() => {}))
|
||||
const abortController = new AbortController()
|
||||
const recorderPromise = startVoiceRecorder(abortController.signal)
|
||||
await vi.waitFor(() => expect(mediaMocks.outputStart).toHaveBeenCalledTimes(1))
|
||||
|
||||
abortController.abort()
|
||||
|
||||
await expect(recorderPromise).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(trackStop).toHaveBeenCalledTimes(1)
|
||||
expect(audioContextClose).toHaveBeenCalledTimes(1)
|
||||
expect(mediaMocks.outputCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should abort and clean up while the audio context is still resuming', async () => {
|
||||
audioContextState = 'suspended'
|
||||
audioContextResume.mockReturnValueOnce(new Promise(() => {}))
|
||||
const abortController = new AbortController()
|
||||
const recorderPromise = startVoiceRecorder(abortController.signal)
|
||||
await vi.waitFor(() => expect(audioContextResume).toHaveBeenCalledTimes(1))
|
||||
|
||||
abortController.abort()
|
||||
|
||||
await expect(recorderPromise).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(trackStop).toHaveBeenCalledTimes(1)
|
||||
expect(audioContextClose).toHaveBeenCalledTimes(1)
|
||||
expect(mediaMocks.outputCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Stopping flushes queued PCM before exposing the final MP3 blob.
|
||||
describe('Stop', () => {
|
||||
it('should encode queued PCM and release microphone resources', async () => {
|
||||
const recorder = await startVoiceRecorder()
|
||||
|
||||
const result = await recorder.stop()
|
||||
|
||||
expect(copyToChannel).toHaveBeenCalledTimes(1)
|
||||
expect(mediaMocks.audioSourceAdd).toHaveBeenCalledTimes(1)
|
||||
expect(mediaMocks.outputFinalize).toHaveBeenCalledTimes(1)
|
||||
expect(result.type).toBe('audio/mp3')
|
||||
expect(result.size).toBe(3)
|
||||
expect(trackStop).toHaveBeenCalled()
|
||||
expect(audioContextClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should release microphone resources before MP3 finalization completes', async () => {
|
||||
let resolveFinalize: () => void = () => {}
|
||||
mediaMocks.outputFinalize.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveFinalize = () => {
|
||||
if (mediaMocks.target) mediaMocks.target.buffer = new Uint8Array([1, 2, 3]).buffer
|
||||
resolve()
|
||||
}
|
||||
}),
|
||||
)
|
||||
const recorder = await startVoiceRecorder()
|
||||
|
||||
const stopPromise = recorder.stop()
|
||||
await vi.waitFor(() => expect(mediaMocks.outputFinalize).toHaveBeenCalledTimes(1))
|
||||
const trackStopCallsBeforeFinalize = trackStop.mock.calls.length
|
||||
const audioContextCloseCallsBeforeFinalize = audioContextClose.mock.calls.length
|
||||
resolveFinalize()
|
||||
await stopPromise
|
||||
|
||||
expect(trackStopCallsBeforeFinalize).toBe(1)
|
||||
expect(audioContextCloseCallsBeforeFinalize).toBe(1)
|
||||
})
|
||||
|
||||
it('should reuse the in-flight stop operation', async () => {
|
||||
const recorder = await startVoiceRecorder()
|
||||
|
||||
const firstStop = recorder.stop()
|
||||
const secondStop = recorder.stop()
|
||||
|
||||
await expect(secondStop).resolves.toBe(await firstStop)
|
||||
expect(mediaMocks.outputFinalize).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should reject when the encoder produces no MP3 bytes', async () => {
|
||||
mediaMocks.outputFinalize.mockResolvedValueOnce()
|
||||
const recorder = await startVoiceRecorder()
|
||||
|
||||
await expect(recorder.stop()).rejects.toThrow('produced no audio data')
|
||||
})
|
||||
|
||||
it('should cancel output when queued PCM encoding fails', async () => {
|
||||
mediaMocks.audioSourceAdd.mockRejectedValueOnce(new Error('encode failed'))
|
||||
const recorder = await startVoiceRecorder()
|
||||
|
||||
await expect(recorder.stop()).rejects.toThrow('encode failed')
|
||||
expect(mediaMocks.outputCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Cancellation and setup failures must release the microphone without uploading data.
|
||||
describe('Cleanup', () => {
|
||||
it('should cancel encoding and release resources', async () => {
|
||||
const recorder = await startVoiceRecorder()
|
||||
|
||||
await recorder.cancel()
|
||||
|
||||
expect(mediaMocks.outputCancel).toHaveBeenCalledTimes(1)
|
||||
expect(trackStop).toHaveBeenCalled()
|
||||
expect(audioContextClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should release the stream when no audio track is available', async () => {
|
||||
const emptyStream = {
|
||||
getAudioTracks: () => [],
|
||||
getTracks: () => [{ stop: trackStop }],
|
||||
} as unknown as MediaStream
|
||||
getUserMedia.mockResolvedValueOnce(emptyStream)
|
||||
|
||||
await expect(startVoiceRecorder()).rejects.toThrow('No audio track')
|
||||
expect(trackStop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should cancel partial output when encoder startup fails', async () => {
|
||||
mediaMocks.outputStart.mockRejectedValueOnce(new Error('encoder unavailable'))
|
||||
|
||||
await expect(startVoiceRecorder()).rejects.toThrow('encoder unavailable')
|
||||
expect(mediaMocks.outputCancel).toHaveBeenCalledTimes(1)
|
||||
expect(trackStop).toHaveBeenCalled()
|
||||
expect(audioContextClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should release the microphone before canceling a failed encoder setup', async () => {
|
||||
mediaMocks.outputStart.mockRejectedValueOnce(new Error('encoder unavailable'))
|
||||
mediaMocks.outputCancel.mockRejectedValueOnce(new Error('encoder cleanup failed'))
|
||||
|
||||
await expect(startVoiceRecorder()).rejects.toThrow()
|
||||
|
||||
expect(trackStop).toHaveBeenCalledTimes(1)
|
||||
expect(audioContextClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should release capture while an in-flight stop finishes encoding', async () => {
|
||||
let resolveFinalize: () => void = () => {}
|
||||
mediaMocks.outputFinalize.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveFinalize = () => {
|
||||
if (mediaMocks.target) mediaMocks.target.buffer = new Uint8Array([1, 2, 3]).buffer
|
||||
resolve()
|
||||
}
|
||||
}),
|
||||
)
|
||||
const recorder = await startVoiceRecorder()
|
||||
const stopPromise = recorder.stop()
|
||||
await vi.waitFor(() => expect(mediaMocks.outputFinalize).toHaveBeenCalledTimes(1))
|
||||
|
||||
const cancelPromise = recorder.cancel()
|
||||
await vi.waitFor(() => expect(trackStop).toHaveBeenCalledTimes(1))
|
||||
resolveFinalize()
|
||||
|
||||
await cancelPromise
|
||||
await expect(stopPromise).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(mediaMocks.outputCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should cancel capture when the worklet stop acknowledgement is pending', async () => {
|
||||
workletRespondsToStop = false
|
||||
const recorder = await startVoiceRecorder()
|
||||
const stopPromise = recorder.stop()
|
||||
|
||||
await recorder.cancel()
|
||||
|
||||
await expect(stopPromise).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(trackStop).toHaveBeenCalledTimes(1)
|
||||
expect(audioContextClose).toHaveBeenCalledTimes(1)
|
||||
expect(mediaMocks.outputCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should complete runtime cleanup when a node disconnect fails', async () => {
|
||||
streamSourceDisconnect.mockImplementationOnce(() => {
|
||||
throw new Error('disconnect failed')
|
||||
})
|
||||
const recorder = await startVoiceRecorder()
|
||||
|
||||
await expect(recorder.cancel()).resolves.toBeUndefined()
|
||||
|
||||
expect(trackStop).toHaveBeenCalledTimes(1)
|
||||
expect(analyserDisconnect).toHaveBeenCalledTimes(1)
|
||||
expect(workletDisconnect).toHaveBeenCalledTimes(1)
|
||||
expect(audioContextClose).toHaveBeenCalledTimes(1)
|
||||
expect(mediaMocks.outputCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should resolve cancellation when encoder cleanup fails', async () => {
|
||||
mediaMocks.outputCancel.mockRejectedValueOnce(new Error('encoder cleanup failed'))
|
||||
const recorder = await startVoiceRecorder()
|
||||
|
||||
await expect(recorder.cancel()).resolves.toBeUndefined()
|
||||
|
||||
expect(trackStop).toHaveBeenCalledTimes(1)
|
||||
expect(audioContextClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not open the microphone when the encoder module fails to load', async () => {
|
||||
vi.resetModules()
|
||||
vi.doMock('../mp3-encoder', () => {
|
||||
throw new Error('encoder chunk unavailable')
|
||||
})
|
||||
|
||||
try {
|
||||
const { startVoiceRecorder: startWithUnavailableEncoder } = await import('../recorder')
|
||||
|
||||
await expect(startWithUnavailableEncoder()).rejects.toThrow()
|
||||
expect(getUserMedia).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.doUnmock('../mp3-encoder')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,196 +0,0 @@
|
||||
import { convertToMp3 } from '../utils'
|
||||
|
||||
// ── Hoisted mocks ──
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const readHeader = vi.fn()
|
||||
const encodeBuffer = vi.fn()
|
||||
const flush = vi.fn()
|
||||
|
||||
return { readHeader, encodeBuffer, flush }
|
||||
})
|
||||
|
||||
vi.mock('lamejs', () => ({
|
||||
default: {
|
||||
WavHeader: {
|
||||
readHeader: mocks.readHeader,
|
||||
},
|
||||
Mp3Encoder: class MockMp3Encoder {
|
||||
encodeBuffer = mocks.encodeBuffer
|
||||
flush = mocks.flush
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('lamejs/src/js/BitStream', () => ({ default: {} }))
|
||||
vi.mock('lamejs/src/js/Lame', () => ({ default: {} }))
|
||||
vi.mock('lamejs/src/js/MPEGMode', () => ({ default: {} }))
|
||||
|
||||
// ── helpers ──
|
||||
|
||||
/** Build a fake recorder whose getChannelData returns DataView-like objects with .buffer and .byteLength. */
|
||||
function createMockRecorder(opts: {
|
||||
channels: number
|
||||
sampleRate: number
|
||||
leftSamples: number[]
|
||||
rightSamples?: number[]
|
||||
}) {
|
||||
const toDataView = (samples: number[]) => {
|
||||
const buf = new ArrayBuffer(samples.length * 2)
|
||||
const view = new DataView(buf)
|
||||
samples.forEach((v, i) => {
|
||||
view.setInt16(i * 2, v, true)
|
||||
})
|
||||
return view
|
||||
}
|
||||
|
||||
const leftView = toDataView(opts.leftSamples)
|
||||
const rightView = opts.rightSamples ? toDataView(opts.rightSamples) : null
|
||||
|
||||
mocks.readHeader.mockReturnValue({
|
||||
channels: opts.channels,
|
||||
sampleRate: opts.sampleRate,
|
||||
})
|
||||
|
||||
return {
|
||||
getWAV: vi.fn(() => new ArrayBuffer(44)),
|
||||
getChannelData: vi.fn(() => ({
|
||||
left: leftView,
|
||||
right: rightView,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
describe('convertToMp3', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should convert mono WAV data to an MP3 blob', () => {
|
||||
const recorder = createMockRecorder({
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
leftSamples: [100, 200, 300, 400],
|
||||
})
|
||||
|
||||
mocks.encodeBuffer.mockReturnValue(new Int8Array([1, 2, 3]))
|
||||
mocks.flush.mockReturnValue(new Int8Array([4, 5]))
|
||||
|
||||
const result = convertToMp3(recorder)
|
||||
|
||||
expect(result).toBeInstanceOf(Blob)
|
||||
expect(result.type).toBe('audio/mp3')
|
||||
expect(mocks.encodeBuffer).toHaveBeenCalled()
|
||||
// Mono: encodeBuffer called with only left data
|
||||
const firstCall = mocks.encodeBuffer.mock.calls[0]
|
||||
expect(firstCall).toHaveLength(1)
|
||||
expect(mocks.flush).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should convert stereo WAV data to an MP3 blob', () => {
|
||||
const recorder = createMockRecorder({
|
||||
channels: 2,
|
||||
sampleRate: 48000,
|
||||
leftSamples: [100, 200],
|
||||
rightSamples: [300, 400],
|
||||
})
|
||||
|
||||
mocks.encodeBuffer.mockReturnValue(new Int8Array([10, 20]))
|
||||
mocks.flush.mockReturnValue(new Int8Array([30]))
|
||||
|
||||
const result = convertToMp3(recorder)
|
||||
|
||||
expect(result).toBeInstanceOf(Blob)
|
||||
expect(result.type).toBe('audio/mp3')
|
||||
// Stereo: encodeBuffer called with left AND right
|
||||
const firstCall = mocks.encodeBuffer.mock.calls[0]
|
||||
expect(firstCall).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should skip empty encoded buffers', () => {
|
||||
const recorder = createMockRecorder({
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
leftSamples: [100, 200],
|
||||
})
|
||||
|
||||
mocks.encodeBuffer.mockReturnValue(new Int8Array(0))
|
||||
mocks.flush.mockReturnValue(new Int8Array(0))
|
||||
|
||||
const result = convertToMp3(recorder)
|
||||
|
||||
expect(result).toBeInstanceOf(Blob)
|
||||
expect(result.type).toBe('audio/mp3')
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
it('should include flush data when flush returns non-empty buffer', () => {
|
||||
const recorder = createMockRecorder({
|
||||
channels: 1,
|
||||
sampleRate: 22050,
|
||||
leftSamples: [1],
|
||||
})
|
||||
|
||||
mocks.encodeBuffer.mockReturnValue(new Int8Array(0))
|
||||
mocks.flush.mockReturnValue(new Int8Array([99, 98, 97]))
|
||||
|
||||
const result = convertToMp3(recorder)
|
||||
|
||||
expect(result).toBeInstanceOf(Blob)
|
||||
expect(result.size).toBe(3)
|
||||
})
|
||||
|
||||
it('should omit flush data when flush returns empty buffer', () => {
|
||||
const recorder = createMockRecorder({
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
leftSamples: [10, 20],
|
||||
})
|
||||
|
||||
mocks.encodeBuffer.mockReturnValue(new Int8Array([1, 2]))
|
||||
mocks.flush.mockReturnValue(new Int8Array(0))
|
||||
|
||||
const result = convertToMp3(recorder)
|
||||
|
||||
expect(result).toBeInstanceOf(Blob)
|
||||
expect(result.size).toBe(2)
|
||||
})
|
||||
|
||||
it('should process multiple chunks when sample count exceeds maxSamples (1152)', () => {
|
||||
const samples = Array.from({ length: 2400 }, (_, i) => i % 32767)
|
||||
const recorder = createMockRecorder({
|
||||
channels: 1,
|
||||
sampleRate: 44100,
|
||||
leftSamples: samples,
|
||||
})
|
||||
|
||||
mocks.encodeBuffer.mockReturnValue(new Int8Array([1]))
|
||||
mocks.flush.mockReturnValue(new Int8Array(0))
|
||||
|
||||
const result = convertToMp3(recorder)
|
||||
|
||||
expect(mocks.encodeBuffer.mock.calls.length).toBeGreaterThan(1)
|
||||
expect(result).toBeInstanceOf(Blob)
|
||||
})
|
||||
|
||||
it('should encode stereo with right channel subarray', () => {
|
||||
const recorder = createMockRecorder({
|
||||
channels: 2,
|
||||
sampleRate: 44100,
|
||||
leftSamples: [100, 200, 300],
|
||||
rightSamples: [400, 500, 600],
|
||||
})
|
||||
|
||||
mocks.encodeBuffer.mockReturnValue(new Int8Array([5, 6, 7]))
|
||||
mocks.flush.mockReturnValue(new Int8Array([8]))
|
||||
|
||||
const result = convertToMp3(recorder)
|
||||
|
||||
expect(result).toBeInstanceOf(Blob)
|
||||
for (const call of mocks.encodeBuffer.mock.calls) {
|
||||
expect(call).toHaveLength(2)
|
||||
expect(call[0]).toBeInstanceOf(Int16Array)
|
||||
expect(call[1]).toBeInstanceOf(Int16Array)
|
||||
}
|
||||
})
|
||||
})
|
||||
37
web/app/components/base/voice-input/api.ts
Normal file
37
web/app/components/base/voice-input/api.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import type { SpeechToTextTarget } from './types'
|
||||
import { audioToText } from '@/service/share'
|
||||
|
||||
export async function transcribeAudio(
|
||||
target: SpeechToTextTarget,
|
||||
file: File,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ text: string }> {
|
||||
if (target.type === 'agent' || target.type === 'consoleApp') {
|
||||
const { consoleClient } = await import('@/service/client')
|
||||
if (target.type === 'agent') {
|
||||
return consoleClient.agent.byAgentId.audioToText.post(
|
||||
{
|
||||
body: {
|
||||
draft_type: target.draftType,
|
||||
file,
|
||||
},
|
||||
params: {
|
||||
agent_id: target.agentId,
|
||||
},
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
return consoleClient.apps.byAppId.audioToText.post(
|
||||
{
|
||||
body: { file },
|
||||
params: { app_id: target.appId },
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return audioToText(target.appSourceType, target.appId, formData, signal)
|
||||
}
|
||||
@ -88,7 +88,7 @@ const meta = {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Voice input component for recording audio and converting speech to text. Features waveform visualization, recording timer (max 10 minutes), and audio-to-text conversion using js-audio-recorder.\n\n**Note:** This is a simplified mock for Storybook. The actual component requires microphone permissions and audio-to-text API.',
|
||||
'Voice input component for recording audio and converting speech to text. Features waveform visualization, recording timer (max 10 minutes), and browser-side MP3 encoding.\n\n**Note:** This is a simplified mock for Storybook. The actual component requires microphone permissions and audio-to-text API.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -451,7 +451,7 @@ export const FeaturesShowcase: Story = {
|
||||
<div className="rounded-lg bg-blue-50 p-4">
|
||||
<div className="mb-2 text-sm font-medium text-blue-900">🎤 Audio Recording</div>
|
||||
<ul className="space-y-1 text-xs text-blue-800">
|
||||
<li>• Uses js-audio-recorder for browser-based recording</li>
|
||||
<li>• Uses AudioWorklet for browser-based recording</li>
|
||||
<li>• 16kHz sample rate, 16-bit, mono channel</li>
|
||||
<li>• Converts to MP3 format for transmission</li>
|
||||
</ul>
|
||||
|
||||
@ -1,200 +1,267 @@
|
||||
import type { Ref } from 'react'
|
||||
import type { VoiceRecorder } from './recorder'
|
||||
import type { SpeechToTextTarget } from './types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useRafInterval } from 'ahooks'
|
||||
import Recorder from 'js-audio-recorder'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useEffectEvent, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { isInstalledAppPath } from '@/app/components/explore/installed-app/routes'
|
||||
import { useParams, usePathname } from '@/next/navigation'
|
||||
import { AppSourceType, audioToText } from '@/service/share'
|
||||
import { transcribeAudio } from './api'
|
||||
import s from './index.module.css'
|
||||
import { convertToMp3 } from './utils'
|
||||
import { startVoiceRecorder } from './recorder'
|
||||
|
||||
type VoiceInputTypes = {
|
||||
const MAX_RECORDING_DURATION = 600
|
||||
|
||||
type VoiceInputStatus = 'starting' | 'recording' | 'converting'
|
||||
|
||||
type VoiceInputProps = {
|
||||
ref?: Ref<HTMLDivElement>
|
||||
onConverted: (text: string) => void
|
||||
onCancel: () => void
|
||||
wordTimestamps?: string
|
||||
onBeforeTranscribe?: () => Promise<unknown>
|
||||
onError?: () => void
|
||||
onStartError?: (error: unknown) => void
|
||||
target: SpeechToTextTarget
|
||||
}
|
||||
|
||||
const VoiceInput = ({ onCancel, onConverted, wordTimestamps }: VoiceInputTypes) => {
|
||||
function VoiceInput({
|
||||
ref,
|
||||
onCancel,
|
||||
onBeforeTranscribe,
|
||||
onConverted,
|
||||
onError,
|
||||
onStartError,
|
||||
target,
|
||||
}: VoiceInputProps) {
|
||||
const { t } = useTranslation()
|
||||
const recorder = useRef(
|
||||
new Recorder({
|
||||
sampleBits: 16,
|
||||
sampleRate: 16000,
|
||||
numChannels: 1,
|
||||
compiling: false,
|
||||
}),
|
||||
)
|
||||
const recorderRef = useRef<VoiceRecorder | null>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const ctxRef = useRef<CanvasRenderingContext2D | null>(null)
|
||||
const drawRecordId = useRef<number | null>(null)
|
||||
const [originDuration, setOriginDuration] = useState(0)
|
||||
const [startRecord, setStartRecord] = useState(false)
|
||||
const [startConvert, setStartConvert] = useState(false)
|
||||
const pathname = usePathname()
|
||||
const params = useParams()
|
||||
const clearInterval = useRafInterval(() => {
|
||||
setOriginDuration(originDuration + 1)
|
||||
}, 1000)
|
||||
const canvasContextRef = useRef<CanvasRenderingContext2D | null>(null)
|
||||
const canvasSizeRef = useRef({ height: 0, width: 0 })
|
||||
const drawRecordIdRef = useRef<number | null>(null)
|
||||
const mountedRef = useRef(true)
|
||||
const stopRequestedRef = useRef(false)
|
||||
const cancelledRef = useRef(false)
|
||||
const setupAbortControllerRef = useRef<AbortController | null>(null)
|
||||
const transcriptionAbortControllerRef = useRef<AbortController | null>(null)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [status, setStatus] = useState<VoiceInputStatus>('starting')
|
||||
const handleRecorderStartError = useEffectEvent((error: unknown) => {
|
||||
onStartError?.(error)
|
||||
onCancel()
|
||||
})
|
||||
|
||||
const stopDrawing = useCallback(() => {
|
||||
if (drawRecordIdRef.current !== null) cancelAnimationFrame(drawRecordIdRef.current)
|
||||
drawRecordIdRef.current = null
|
||||
const { height, width } = canvasSizeRef.current
|
||||
canvasContextRef.current?.clearRect(0, 0, width, height)
|
||||
}, [])
|
||||
|
||||
const drawRecord = useCallback(() => {
|
||||
drawRecordId.current = requestAnimationFrame(drawRecord)
|
||||
const canvas = canvasRef.current!
|
||||
const ctx = ctxRef.current!
|
||||
const dataUnit8Array = recorder.current.getRecordAnalyseData()
|
||||
const dataArray = [].slice.call(dataUnit8Array)
|
||||
const lineLength = Number.parseInt(`${canvas.width / 3}`)
|
||||
const gap = Number.parseInt(`${1024 / lineLength}`)
|
||||
const recorder = recorderRef.current
|
||||
const context = canvasContextRef.current
|
||||
const { height, width } = canvasSizeRef.current
|
||||
if (!recorder || !context || !height || !width) return
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.beginPath()
|
||||
let x = 0
|
||||
for (let i = 0; i < lineLength; i++) {
|
||||
let v =
|
||||
dataArray.slice(i * gap, i * gap + gap).reduce((prev: number, next: number) => {
|
||||
return prev + next
|
||||
}, 0) / gap
|
||||
const frequencyData = new Uint8Array(recorder.analyser.frequencyBinCount)
|
||||
recorder.analyser.getByteFrequencyData(frequencyData)
|
||||
const lineCount = Math.max(1, Math.floor(width / 3))
|
||||
const sampleStep = Math.max(1, Math.floor(frequencyData.length / lineCount))
|
||||
|
||||
if (v < 128) v = 128
|
||||
if (v > 178) v = 178
|
||||
const y = ((v - 128) / 50) * canvas.height
|
||||
|
||||
ctx.moveTo(x, 16)
|
||||
if (ctx.roundRect) ctx.roundRect(x, 16 - y, 2, y, [1, 1, 0, 0])
|
||||
else ctx.rect(x, 16 - y, 2, y)
|
||||
ctx.fill()
|
||||
x += 3
|
||||
context.clearRect(0, 0, width, height)
|
||||
context.beginPath()
|
||||
for (let index = 0; index < lineCount; index++) {
|
||||
const amplitude = frequencyData[index * sampleStep] ?? 0
|
||||
const lineHeight = Math.max(1, (amplitude / 255) * height)
|
||||
const x = index * 3
|
||||
if (context.roundRect) context.roundRect(x, height - lineHeight, 2, lineHeight, [1, 1, 0, 0])
|
||||
else context.rect(x, height - lineHeight, 2, lineHeight)
|
||||
}
|
||||
ctx.closePath()
|
||||
context.fill()
|
||||
context.closePath()
|
||||
drawRecordIdRef.current = requestAnimationFrame(drawRecord)
|
||||
}, [])
|
||||
|
||||
const handleStopRecorder = useCallback(async () => {
|
||||
clearInterval()
|
||||
setStartRecord(false)
|
||||
setStartConvert(true)
|
||||
recorder.current.stop()
|
||||
if (drawRecordId.current) cancelAnimationFrame(drawRecordId.current)
|
||||
drawRecordId.current = null
|
||||
const canvas = canvasRef.current!
|
||||
const ctx = ctxRef.current!
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
const mp3Blob = convertToMp3(recorder.current)
|
||||
const mp3File = new File([mp3Blob], 'temp.mp3', { type: 'audio/mp3' })
|
||||
const formData = new FormData()
|
||||
formData.append('file', mp3File)
|
||||
formData.append('word_timestamps', wordTimestamps || 'disabled')
|
||||
|
||||
let url = ''
|
||||
let isPublic = false
|
||||
|
||||
if (params.token) {
|
||||
url = '/audio-to-text'
|
||||
isPublic = true
|
||||
} else if (params.appId) {
|
||||
if (isInstalledAppPath(pathname)) url = `/installed-apps/${params.appId}/audio-to-text`
|
||||
else url = `/apps/${params.appId}/audio-to-text`
|
||||
}
|
||||
const recorder = recorderRef.current
|
||||
if (!recorder || status !== 'recording' || stopRequestedRef.current) return
|
||||
|
||||
stopRequestedRef.current = true
|
||||
setStatus('converting')
|
||||
stopDrawing()
|
||||
let mp3Blob: Blob
|
||||
try {
|
||||
const audioResponse = await audioToText(
|
||||
url,
|
||||
isPublic ? AppSourceType.webApp : AppSourceType.installedApp,
|
||||
formData,
|
||||
)
|
||||
onConverted(audioResponse.text)
|
||||
onCancel()
|
||||
mp3Blob = await recorder.stop()
|
||||
} catch {
|
||||
onConverted('')
|
||||
onCancel()
|
||||
}
|
||||
}, [clearInterval, onCancel, onConverted, params.appId, params.token, pathname, wordTimestamps])
|
||||
const handleStartRecord = useCallback(async () => {
|
||||
try {
|
||||
await recorder.current.start()
|
||||
setStartRecord(true)
|
||||
setStartConvert(false)
|
||||
|
||||
if (canvasRef.current && ctxRef.current) drawRecord()
|
||||
} catch {
|
||||
onCancel()
|
||||
}
|
||||
}, [drawRecord, onCancel, setStartRecord, setStartConvert])
|
||||
const initCanvas = useCallback(() => {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const canvas = document.getElementById('voice-input-record') as HTMLCanvasElement
|
||||
|
||||
if (canvas) {
|
||||
const { width: cssWidth, height: cssHeight } = canvas.getBoundingClientRect()
|
||||
|
||||
canvas.width = dpr * cssWidth
|
||||
canvas.height = dpr * cssHeight
|
||||
canvasRef.current = canvas
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.scale(dpr, dpr)
|
||||
ctx.fillStyle = 'rgba(209, 224, 255, 1)'
|
||||
ctxRef.current = ctx
|
||||
if (mountedRef.current && !cancelledRef.current) {
|
||||
onError?.()
|
||||
onCancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
}, [])
|
||||
if (originDuration >= 600 && startRecord) handleStopRecorder()
|
||||
if (cancelledRef.current) return
|
||||
|
||||
try {
|
||||
await onBeforeTranscribe?.()
|
||||
} catch {
|
||||
if (mountedRef.current && !cancelledRef.current) onCancel()
|
||||
return
|
||||
}
|
||||
if (cancelledRef.current) return
|
||||
|
||||
const file = new File([mp3Blob], 'temp.mp3', { type: 'audio/mp3' })
|
||||
const abortController = new AbortController()
|
||||
transcriptionAbortControllerRef.current = abortController
|
||||
try {
|
||||
const audioResponse = await transcribeAudio(target, file, abortController.signal)
|
||||
if (mountedRef.current && !cancelledRef.current) onConverted(audioResponse.text)
|
||||
} catch {
|
||||
if (mountedRef.current && !cancelledRef.current) onError?.()
|
||||
} finally {
|
||||
transcriptionAbortControllerRef.current = null
|
||||
if (mountedRef.current && !cancelledRef.current) onCancel()
|
||||
}
|
||||
}, [onBeforeTranscribe, onCancel, onConverted, onError, status, stopDrawing, target])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
cancelledRef.current = true
|
||||
setupAbortControllerRef.current?.abort()
|
||||
transcriptionAbortControllerRef.current?.abort()
|
||||
void recorderRef.current?.cancel()
|
||||
onCancel()
|
||||
}, [onCancel])
|
||||
|
||||
useEffect(() => {
|
||||
initCanvas()
|
||||
handleStartRecord()
|
||||
const recorderRef = recorder?.current
|
||||
return () => {
|
||||
recorderRef?.stop()
|
||||
}
|
||||
}, [handleStartRecord, initCanvas])
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const devicePixelRatio = window.devicePixelRatio || 1
|
||||
const { height, width } = canvas.getBoundingClientRect()
|
||||
canvas.width = devicePixelRatio * width
|
||||
canvas.height = devicePixelRatio * height
|
||||
canvasSizeRef.current = { height, width }
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return
|
||||
context.scale(devicePixelRatio, devicePixelRatio)
|
||||
context.fillStyle = 'rgba(209, 224, 255, 1)'
|
||||
canvasContextRef.current = context
|
||||
}, [])
|
||||
|
||||
const minutes = Number.parseInt(`${Number.parseInt(`${originDuration}`) / 60}`)
|
||||
const seconds = Number.parseInt(`${originDuration}`) % 60
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
cancelledRef.current = false
|
||||
let effectCancelled = false
|
||||
const abortController = new AbortController()
|
||||
setupAbortControllerRef.current = abortController
|
||||
void startVoiceRecorder(abortController.signal)
|
||||
.then((recorder) => {
|
||||
if (setupAbortControllerRef.current === abortController)
|
||||
setupAbortControllerRef.current = null
|
||||
if (effectCancelled) {
|
||||
void recorder.cancel()
|
||||
return
|
||||
}
|
||||
recorderRef.current = recorder
|
||||
setStatus('recording')
|
||||
drawRecord()
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (setupAbortControllerRef.current === abortController)
|
||||
setupAbortControllerRef.current = null
|
||||
if (effectCancelled || abortController.signal.aborted) return
|
||||
handleRecorderStartError(error)
|
||||
})
|
||||
|
||||
return () => {
|
||||
effectCancelled = true
|
||||
mountedRef.current = false
|
||||
cancelledRef.current = true
|
||||
abortController.abort()
|
||||
transcriptionAbortControllerRef.current?.abort()
|
||||
stopDrawing()
|
||||
void recorderRef.current?.cancel()
|
||||
}
|
||||
}, [drawRecord, stopDrawing])
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'recording') return
|
||||
const intervalId = window.setInterval(() => {
|
||||
setDuration((currentDuration) => currentDuration + 1)
|
||||
}, 1000)
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [status])
|
||||
|
||||
useEffect(() => {
|
||||
if (duration >= MAX_RECORDING_DURATION && status === 'recording') void handleStopRecorder()
|
||||
}, [duration, handleStopRecorder, status])
|
||||
|
||||
const minutes = Math.floor(duration / 60)
|
||||
.toString()
|
||||
.padStart(2, '0')
|
||||
const seconds = (duration % 60).toString().padStart(2, '0')
|
||||
const isStarting = status === 'starting'
|
||||
const isRecording = status === 'recording'
|
||||
const isConverting = status === 'converting'
|
||||
|
||||
return (
|
||||
<div className={cn(s.wrapper, 'absolute inset-0 rounded-xl')}>
|
||||
<div ref={ref} className={cn(s.wrapper, 'absolute inset-0 rounded-xl')}>
|
||||
<div className="absolute inset-[1.5px] flex items-center overflow-hidden rounded-[10.5px] bg-primary-25 py-[14px] pr-[6.5px] pl-[14.5px]">
|
||||
<canvas id="voice-input-record" className="absolute bottom-0 left-0 h-4 w-full" />
|
||||
{startConvert && (
|
||||
<canvas ref={canvasRef} className="absolute bottom-0 left-0 h-4 w-full" />
|
||||
{(isStarting || isConverting) && (
|
||||
<div
|
||||
className="mr-2 i-ri-loader-2-line size-4 animate-spin text-primary-700"
|
||||
aria-hidden="true"
|
||||
data-testid="voice-input-loader"
|
||||
/>
|
||||
)}
|
||||
<div className="grow">
|
||||
{startRecord && (
|
||||
{isStarting && (
|
||||
<div className="text-sm text-gray-500" role="status">
|
||||
{t(($) => $['voiceInput.starting'], { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
{isRecording && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{t(($) => $['voiceInput.speaking'], { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
{startConvert && (
|
||||
<div className={cn(s.convert, 'text-sm')} data-testid="voice-input-converting-text">
|
||||
{isConverting && (
|
||||
<div
|
||||
className={cn(s.convert, 'text-sm')}
|
||||
role="status"
|
||||
data-testid="voice-input-converting-text"
|
||||
>
|
||||
{t(($) => $['voiceInput.converting'], { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{startRecord && (
|
||||
<div
|
||||
className="mr-1 flex size-8 cursor-pointer items-center justify-center rounded-lg hover:bg-primary-100"
|
||||
{isRecording && (
|
||||
<button
|
||||
type="button"
|
||||
className="mr-1 flex size-8 items-center justify-center rounded-lg outline-hidden hover:bg-primary-100 focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
aria-label={t(($) => $['voiceInput.stop'], { ns: 'common' })}
|
||||
onClick={handleStopRecorder}
|
||||
data-testid="voice-input-stop"
|
||||
>
|
||||
<div className="i-ri-stop-circle-line size-5 text-primary-600" />
|
||||
</div>
|
||||
<span className="i-ri-stop-circle-line size-5 text-primary-600" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{startConvert && (
|
||||
<div
|
||||
className="mr-1 flex size-8 cursor-pointer items-center justify-center rounded-lg hover:bg-gray-200"
|
||||
onClick={onCancel}
|
||||
data-testid="voice-input-cancel"
|
||||
{(isStarting || isConverting) && (
|
||||
<button
|
||||
type="button"
|
||||
className="mr-1 flex size-8 items-center justify-center rounded-lg outline-hidden hover:bg-gray-200 focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
aria-label={t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<div className="i-ri-close-line size-4 text-gray-500" />
|
||||
</div>
|
||||
<span className="i-ri-close-line size-4 text-gray-500" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={`w-[45px] pl-1 text-xs font-medium ${originDuration > 500 ? 'text-[#F04438]' : 'text-gray-700'}`}
|
||||
className={cn(
|
||||
'w-[45px] pl-1 text-xs font-medium',
|
||||
duration > 500 ? 'text-text-destructive' : 'text-text-secondary',
|
||||
)}
|
||||
data-testid="voice-input-timer"
|
||||
>{`0${minutes.toFixed(0)}:${seconds >= 10 ? seconds : `0${seconds}`}`}</div>
|
||||
>
|
||||
{`${minutes}:${seconds}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
26
web/app/components/base/voice-input/mp3-encoder.ts
Normal file
26
web/app/components/base/voice-input/mp3-encoder.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { registerMp3Encoder } from '@mediabunny/mp3-encoder'
|
||||
import { AudioBufferSource, BufferTarget, Mp3OutputFormat, Output } from 'mediabunny'
|
||||
|
||||
const AUDIO_BITRATE = 128_000
|
||||
const AUDIO_SAMPLE_RATE = 16_000
|
||||
|
||||
export function createMp3Encoder() {
|
||||
registerMp3Encoder()
|
||||
|
||||
const target = new BufferTarget()
|
||||
const output = new Output({
|
||||
format: new Mp3OutputFormat(),
|
||||
target,
|
||||
})
|
||||
const audioSource = new AudioBufferSource({
|
||||
bitrate: AUDIO_BITRATE,
|
||||
codec: 'mp3',
|
||||
transform: {
|
||||
numberOfChannels: 1,
|
||||
sampleRate: AUDIO_SAMPLE_RATE,
|
||||
},
|
||||
})
|
||||
output.addAudioTrack(audioSource)
|
||||
|
||||
return { audioSource, output, target }
|
||||
}
|
||||
229
web/app/components/base/voice-input/recorder.ts
Normal file
229
web/app/components/base/voice-input/recorder.ts
Normal file
@ -0,0 +1,229 @@
|
||||
const AUDIO_MIME_TYPE = 'audio/mp3'
|
||||
const AUDIO_WORKLET_NAME = 'voice-input-recorder'
|
||||
const AUDIO_WORKLET_BUFFER_SIZE = 4096
|
||||
|
||||
const AUDIO_WORKLET_SOURCE = `
|
||||
class VoiceInputRecorderProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super()
|
||||
this.buffer = new Float32Array(${AUDIO_WORKLET_BUFFER_SIZE})
|
||||
this.offset = 0
|
||||
this.stopped = false
|
||||
this.port.onmessage = (event) => {
|
||||
if (event.data?.type !== 'stop')
|
||||
return
|
||||
this.flush()
|
||||
this.stopped = true
|
||||
this.port.postMessage({ type: 'stopped' })
|
||||
}
|
||||
}
|
||||
|
||||
flush() {
|
||||
if (!this.offset)
|
||||
return
|
||||
const buffer = this.buffer.slice(0, this.offset)
|
||||
this.port.postMessage({ type: 'data', buffer: buffer.buffer }, [buffer.buffer])
|
||||
this.buffer = new Float32Array(${AUDIO_WORKLET_BUFFER_SIZE})
|
||||
this.offset = 0
|
||||
}
|
||||
|
||||
process(inputs) {
|
||||
if (this.stopped)
|
||||
return false
|
||||
const channel = inputs[0]?.[0]
|
||||
if (!channel)
|
||||
return true
|
||||
|
||||
let sourceOffset = 0
|
||||
while (sourceOffset < channel.length) {
|
||||
const copyLength = Math.min(channel.length - sourceOffset, this.buffer.length - this.offset)
|
||||
this.buffer.set(channel.subarray(sourceOffset, sourceOffset + copyLength), this.offset)
|
||||
this.offset += copyLength
|
||||
sourceOffset += copyLength
|
||||
if (this.offset === this.buffer.length)
|
||||
this.flush()
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('${AUDIO_WORKLET_NAME}', VoiceInputRecorderProcessor)
|
||||
`
|
||||
|
||||
export type VoiceRecorder = {
|
||||
analyser: AnalyserNode
|
||||
stop: () => Promise<Blob>
|
||||
cancel: () => Promise<void>
|
||||
}
|
||||
|
||||
function waitForAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (!signal) return promise
|
||||
|
||||
signal.throwIfAborted()
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const handleAbort = () => {
|
||||
signal.removeEventListener('abort', handleAbort)
|
||||
reject(signal.reason)
|
||||
}
|
||||
signal.addEventListener('abort', handleAbort, { once: true })
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', handleAbort)
|
||||
resolve(value)
|
||||
},
|
||||
(error) => {
|
||||
signal.removeEventListener('abort', handleAbort)
|
||||
reject(error)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export async function startVoiceRecorder(signal?: AbortSignal): Promise<VoiceRecorder> {
|
||||
let stream: MediaStream | undefined
|
||||
let audioContext: AudioContext | undefined
|
||||
let output:
|
||||
| Awaited<ReturnType<typeof import('./mp3-encoder').createMp3Encoder>>['output']
|
||||
| undefined
|
||||
let streamStopped = false
|
||||
const stopStream = () => {
|
||||
if (!stream || streamStopped) return
|
||||
streamStopped = true
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
}
|
||||
|
||||
try {
|
||||
signal?.throwIfAborted()
|
||||
const { createMp3Encoder } = await waitForAbort(import('./mp3-encoder'), signal)
|
||||
signal?.throwIfAborted()
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
},
|
||||
})
|
||||
signal?.addEventListener('abort', stopStream, { once: true })
|
||||
signal?.throwIfAborted()
|
||||
|
||||
const audioTrack = stream.getAudioTracks()[0]
|
||||
if (!audioTrack) throw new Error('No audio track is available.')
|
||||
|
||||
const context = new AudioContext()
|
||||
audioContext = context
|
||||
const workletUrl = URL.createObjectURL(
|
||||
new Blob([AUDIO_WORKLET_SOURCE], { type: 'application/javascript' }),
|
||||
)
|
||||
try {
|
||||
await waitForAbort(context.audioWorklet.addModule(workletUrl), signal)
|
||||
} finally {
|
||||
URL.revokeObjectURL(workletUrl)
|
||||
}
|
||||
|
||||
const encoder = createMp3Encoder()
|
||||
const { audioSource, target } = encoder
|
||||
output = encoder.output
|
||||
await waitForAbort(output.start(), signal)
|
||||
|
||||
const analyser = context.createAnalyser()
|
||||
analyser.fftSize = 2048
|
||||
const streamSource = context.createMediaStreamSource(stream)
|
||||
const workletNode = new AudioWorkletNode(context, AUDIO_WORKLET_NAME)
|
||||
let resolveWorkletStopped: () => void = () => {}
|
||||
const workletStopped = new Promise<void>((resolve) => {
|
||||
resolveWorkletStopped = resolve
|
||||
})
|
||||
let writeError: unknown
|
||||
let writeQueue = Promise.resolve()
|
||||
|
||||
workletNode.port.onmessage = (
|
||||
event: MessageEvent<{ type: 'data'; buffer: ArrayBuffer } | { type: 'stopped' }>,
|
||||
) => {
|
||||
if (event.data.type === 'stopped') {
|
||||
resolveWorkletStopped()
|
||||
return
|
||||
}
|
||||
|
||||
const samples = new Float32Array(event.data.buffer)
|
||||
const audioBuffer = context.createBuffer(1, samples.length, context.sampleRate)
|
||||
audioBuffer.copyToChannel(samples, 0)
|
||||
writeQueue = writeQueue.then(async () => {
|
||||
if (writeError) return
|
||||
try {
|
||||
await audioSource.add(audioBuffer)
|
||||
} catch (error) {
|
||||
writeError = error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
streamSource.connect(analyser)
|
||||
streamSource.connect(workletNode)
|
||||
workletNode.connect(context.destination)
|
||||
if (context.state === 'suspended') await waitForAbort(context.resume(), signal)
|
||||
|
||||
let releasePromise: Promise<void> | undefined
|
||||
const release = () => {
|
||||
releasePromise ??= (async () => {
|
||||
stopStream()
|
||||
await Promise.allSettled([
|
||||
(async () => streamSource.disconnect())(),
|
||||
(async () => analyser.disconnect())(),
|
||||
(async () => workletNode.disconnect())(),
|
||||
(async () => context.close())(),
|
||||
])
|
||||
})()
|
||||
return releasePromise
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let resolveCancelled: () => void = () => {}
|
||||
const cancellation = new Promise<void>((resolve) => {
|
||||
resolveCancelled = resolve
|
||||
})
|
||||
const waitForCancellation = async <T>(promise: Promise<T>) => {
|
||||
await Promise.race([promise, cancellation])
|
||||
if (cancelled) throw new DOMException('Recording cancelled.', 'AbortError')
|
||||
return promise
|
||||
}
|
||||
|
||||
let stopPromise: Promise<Blob> | undefined
|
||||
const stop = () => {
|
||||
stopPromise ??= (async () => {
|
||||
try {
|
||||
if (context.state === 'suspended') await context.resume()
|
||||
workletNode.port.postMessage({ type: 'stop' })
|
||||
await waitForCancellation(workletStopped)
|
||||
await waitForCancellation(writeQueue)
|
||||
await release()
|
||||
if (writeError) {
|
||||
await output!.cancel()
|
||||
throw writeError
|
||||
}
|
||||
await waitForCancellation(output!.finalize())
|
||||
if (!target.buffer?.byteLength) throw new Error('The MP3 encoder produced no audio data.')
|
||||
return new Blob([target.buffer], { type: AUDIO_MIME_TYPE })
|
||||
} finally {
|
||||
await release()
|
||||
}
|
||||
})()
|
||||
return stopPromise
|
||||
}
|
||||
|
||||
const cancel = async () => {
|
||||
cancelled = true
|
||||
resolveCancelled()
|
||||
await release()
|
||||
await Promise.allSettled([output!.cancel()])
|
||||
}
|
||||
|
||||
signal?.throwIfAborted()
|
||||
signal?.removeEventListener('abort', stopStream)
|
||||
return { analyser, stop, cancel }
|
||||
} catch (error) {
|
||||
signal?.removeEventListener('abort', stopStream)
|
||||
stopStream()
|
||||
await Promise.allSettled([audioContext?.close(), output?.cancel()])
|
||||
throw error
|
||||
}
|
||||
}
|
||||
27
web/app/components/base/voice-input/types.ts
Normal file
27
web/app/components/base/voice-input/types.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import type { PostAgentByAgentIdAudioToTextData } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AppSourceType } from '@/service/share'
|
||||
|
||||
type AgentSpeechToTextDraftType = NonNullable<
|
||||
PostAgentByAgentIdAudioToTextData['body']['draft_type']
|
||||
>
|
||||
|
||||
export type SpeechToTextTarget =
|
||||
| {
|
||||
type: 'app'
|
||||
appId?: string
|
||||
appSourceType: AppSourceType.webApp
|
||||
}
|
||||
| {
|
||||
type: 'app'
|
||||
appId: string
|
||||
appSourceType: Exclude<AppSourceType, AppSourceType.webApp>
|
||||
}
|
||||
| {
|
||||
type: 'consoleApp'
|
||||
appId: string
|
||||
}
|
||||
| {
|
||||
type: 'agent'
|
||||
agentId: string
|
||||
draftType: AgentSpeechToTextDraftType
|
||||
}
|
||||
@ -1,52 +0,0 @@
|
||||
import lamejs from 'lamejs'
|
||||
import BitStream from 'lamejs/src/js/BitStream'
|
||||
import Lame from 'lamejs/src/js/Lame'
|
||||
import MPEGMode from 'lamejs/src/js/MPEGMode'
|
||||
|
||||
/* v8 ignore next - @preserve */
|
||||
if (globalThis) {
|
||||
;(globalThis as any).MPEGMode = MPEGMode
|
||||
;(globalThis as any).Lame = Lame
|
||||
;(globalThis as any).BitStream = BitStream
|
||||
}
|
||||
|
||||
export const convertToMp3 = (recorder: any) => {
|
||||
const wav = lamejs.WavHeader.readHeader(recorder.getWAV())
|
||||
const { channels, sampleRate } = wav
|
||||
const mp3enc = new lamejs.Mp3Encoder(channels, sampleRate, 128)
|
||||
const result = recorder.getChannelData()
|
||||
const buffer: BlobPart[] = []
|
||||
|
||||
const leftData = result.left && new Int16Array(result.left.buffer, 0, result.left.byteLength / 2)
|
||||
const rightData =
|
||||
result.right && new Int16Array(result.right.buffer, 0, result.right.byteLength / 2)
|
||||
const remaining = leftData.length + (rightData ? rightData.length : 0)
|
||||
|
||||
const maxSamples = 1152
|
||||
const toArrayBuffer = (bytes: Int8Array) => {
|
||||
const arrayBuffer = new ArrayBuffer(bytes.length)
|
||||
new Uint8Array(arrayBuffer).set(bytes)
|
||||
return arrayBuffer
|
||||
}
|
||||
|
||||
for (let i = 0; i < remaining; i += maxSamples) {
|
||||
const left = leftData.subarray(i, i + maxSamples)
|
||||
let right = null
|
||||
let mp3buf = null
|
||||
|
||||
if (channels === 2) {
|
||||
right = rightData.subarray(i, i + maxSamples)
|
||||
mp3buf = mp3enc.encodeBuffer(left, right)
|
||||
} else {
|
||||
mp3buf = mp3enc.encodeBuffer(left)
|
||||
}
|
||||
|
||||
if (mp3buf.length > 0) buffer.push(toArrayBuffer(mp3buf))
|
||||
}
|
||||
|
||||
const enc = mp3enc.flush()
|
||||
|
||||
if (enc.length > 0) buffer.push(toArrayBuffer(enc))
|
||||
|
||||
return new Blob(buffer, { type: 'audio/mp3' })
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import type { ChatWrapperRefType } from '../index'
|
||||
import type { HumanInputFieldValue } from '@/app/components/base/chat/chat/answer/human-input-content/field-renderer'
|
||||
import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
@ -27,6 +28,7 @@ vi.mock('@/app/components/base/chat/chat', () => ({
|
||||
switchSibling,
|
||||
onHumanInputFormSubmit,
|
||||
onFeatureBarClick,
|
||||
speechToTextTarget,
|
||||
}: {
|
||||
chatNode: React.ReactNode
|
||||
inputDisabled?: boolean
|
||||
@ -43,8 +45,15 @@ vi.mock('@/app/components/base/chat/chat', () => ({
|
||||
formData: { inputs: Record<string, HumanInputFieldValue>; action: string },
|
||||
) => Promise<void>
|
||||
onFeatureBarClick?: (state: boolean) => void
|
||||
speechToTextTarget?: SpeechToTextTarget
|
||||
}) => (
|
||||
<div data-testid="chat-shell">
|
||||
<div
|
||||
data-testid="chat-shell"
|
||||
data-speech-app-id={
|
||||
speechToTextTarget?.type === 'consoleApp' ? speechToTextTarget.appId : undefined
|
||||
}
|
||||
data-speech-target={speechToTextTarget?.type}
|
||||
>
|
||||
<div data-testid="chat-input-disabled">{`${inputDisabled}`}</div>
|
||||
<button type="button" onClick={() => onSend?.('hello', [])}>
|
||||
send-chat
|
||||
@ -384,4 +393,23 @@ describe('ChatWrapper', () => {
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the workflow App target to speech-to-text', () => {
|
||||
renderWorkflowFlowComponent(
|
||||
<ChatWrapper
|
||||
ref={createChatWrapperRef()}
|
||||
showConversationVariableModal={false}
|
||||
onConversationModalHide={vi.fn()}
|
||||
showInputsFieldsPanel={false}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
{
|
||||
nodes: [createStartNode()],
|
||||
edges: [],
|
||||
},
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('chat-shell')).toHaveAttribute('data-speech-target', 'consoleApp')
|
||||
expect(screen.getByTestId('chat-shell')).toHaveAttribute('data-speech-app-id', 'app-1')
|
||||
})
|
||||
})
|
||||
|
||||
@ -198,6 +198,7 @@ const ChatWrapper = ({
|
||||
supportCitationHitInfo: true,
|
||||
} as any
|
||||
}
|
||||
speechToTextTarget={appDetail ? { type: 'consoleApp', appId: appDetail.id } : undefined}
|
||||
chatList={chatList}
|
||||
isResponding={isResponding}
|
||||
chatContainerClassName="px-3"
|
||||
|
||||
@ -416,6 +416,11 @@ function AgentConfigurePageComposerContent({
|
||||
onConversationIdChange={(mode, conversationId) => {
|
||||
setConversationId({ mode, conversationId })
|
||||
}}
|
||||
onBeforeSpeechToText={
|
||||
rightPanelChatMode === 'build'
|
||||
? buildDraftActions.prepareBuildDraftBeforeRun
|
||||
: saveDraft
|
||||
}
|
||||
onSaveDraftBeforeRun={
|
||||
rightPanelChatMode === 'build'
|
||||
? async () => {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
@ -35,6 +36,8 @@ vi.mock('@/next/dynamic', async () => {
|
||||
showPromptLog?: boolean
|
||||
footerNotice?: string
|
||||
chatNode?: ReactNode
|
||||
speechToTextTarget?: SpeechToTextTarget
|
||||
onBeforeSpeechToText?: () => Promise<unknown>
|
||||
}) {
|
||||
const [sent, setSent] = useState(false)
|
||||
|
||||
@ -45,6 +48,12 @@ vi.mock('@/next/dynamic', async () => {
|
||||
data-send-button-loading={String(!!props.sendButtonLoading)}
|
||||
data-show-prompt-log={String(!!props.showPromptLog)}
|
||||
data-footer-notice={props.footerNotice ?? ''}
|
||||
data-speech-agent-id={
|
||||
props.speechToTextTarget?.type === 'agent' ? props.speechToTextTarget.agentId : ''
|
||||
}
|
||||
data-speech-draft-type={
|
||||
props.speechToTextTarget?.type === 'agent' ? props.speechToTextTarget.draftType : ''
|
||||
}
|
||||
>
|
||||
{props.chatNode}
|
||||
<span>{`sessionSent:${sent ? 'yes' : 'no'}`}</span>
|
||||
@ -60,6 +69,9 @@ vi.mock('@/next/dynamic', async () => {
|
||||
<button type="button" onClick={props.onStopResponding}>
|
||||
stop
|
||||
</button>
|
||||
<button type="button" onClick={() => void props.onBeforeSpeechToText?.()}>
|
||||
before speech
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@ -67,8 +79,22 @@ vi.mock('@/next/dynamic', async () => {
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat/chat-input-area', () => ({
|
||||
default: ({ footerNotice }: { footerNotice?: ReactNode }) => (
|
||||
<div data-testid="agent-preview-chat-input">{footerNotice}</div>
|
||||
default: ({
|
||||
footerNotice,
|
||||
speechToTextTarget,
|
||||
}: {
|
||||
footerNotice?: ReactNode
|
||||
speechToTextTarget?: SpeechToTextTarget
|
||||
}) => (
|
||||
<div
|
||||
data-testid="agent-preview-chat-input"
|
||||
data-speech-agent-id={speechToTextTarget?.type === 'agent' ? speechToTextTarget.agentId : ''}
|
||||
data-speech-draft-type={
|
||||
speechToTextTarget?.type === 'agent' ? speechToTextTarget.draftType : ''
|
||||
}
|
||||
>
|
||||
{footerNotice}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
@ -341,6 +367,45 @@ describe('AgentPreviewChat', () => {
|
||||
sendResultRef.current = undefined
|
||||
})
|
||||
|
||||
it('should bind Agent preview voice input to the normal Agent draft', () => {
|
||||
renderPreviewChat({
|
||||
renderEmptyState: ({ inputNode }) => inputNode,
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('agent-preview-chat-input')).toHaveAttribute(
|
||||
'data-speech-agent-id',
|
||||
'agent-1',
|
||||
)
|
||||
expect(screen.getByTestId('agent-preview-chat-input')).toHaveAttribute(
|
||||
'data-speech-draft-type',
|
||||
'draft',
|
||||
)
|
||||
expect(screen.getByTestId('mock-chat')).toHaveAttribute('data-speech-agent-id', 'agent-1')
|
||||
expect(screen.getByTestId('mock-chat')).toHaveAttribute('data-speech-draft-type', 'draft')
|
||||
})
|
||||
|
||||
it('should bind Agent build voice input to the account build draft', () => {
|
||||
renderPreviewChat({
|
||||
draftType: 'debug_build',
|
||||
renderEmptyState: ({ inputNode }) => inputNode,
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('agent-preview-chat-input')).toHaveAttribute(
|
||||
'data-speech-draft-type',
|
||||
'debug_build',
|
||||
)
|
||||
expect(screen.getByTestId('mock-chat')).toHaveAttribute('data-speech-draft-type', 'debug_build')
|
||||
})
|
||||
|
||||
it('should expose the owning save-before-transcribe callback', () => {
|
||||
const onBeforeSpeechToText = vi.fn().mockResolvedValue(undefined)
|
||||
renderPreviewChat({ onBeforeSpeechToText })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'before speech' }))
|
||||
|
||||
expect(onBeforeSpeechToText).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should initialize preview chat with the stable debug conversation history', async () => {
|
||||
chatMessagesGetMock.mockResolvedValue({
|
||||
data: [
|
||||
|
||||
@ -18,6 +18,7 @@ import type {
|
||||
import type { ChatConfig, ChatItem, ChatItemInTree, OnSend } from '@/app/components/base/chat/types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types'
|
||||
import type { AgentComposerModel } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import type { Inputs } from '@/models/debug'
|
||||
import type { MessageRating } from '@/models/log'
|
||||
@ -476,6 +477,7 @@ export type AgentChatRuntimeProps = {
|
||||
onConversationComplete?: (conversationId: string, workflowRunId?: string) => void
|
||||
onConversationIdChange?: (conversationId: string) => void
|
||||
onWorkflowRunIdChange?: (workflowRunId: string | null) => void
|
||||
onBeforeSpeechToText?: () => Promise<unknown>
|
||||
onSaveDraftBeforeRun?: () => Promise<AgentSoulConfig | void>
|
||||
onSendInterrupted?: () => void
|
||||
}
|
||||
@ -498,6 +500,7 @@ export function AgentChatRuntime({
|
||||
onConversationComplete,
|
||||
onConversationIdChange,
|
||||
onWorkflowRunIdChange,
|
||||
onBeforeSpeechToText,
|
||||
onSendInterrupted,
|
||||
onSaveDraftBeforeRun,
|
||||
}: AgentChatRuntimeProps) {
|
||||
@ -570,6 +573,7 @@ export function AgentChatRuntime({
|
||||
onConversationComplete={onConversationComplete}
|
||||
onConversationIdChange={onConversationIdChange}
|
||||
onCurrentSessionConversationIdChange={setCurrentSessionConversationId}
|
||||
onBeforeSpeechToText={onBeforeSpeechToText}
|
||||
onSendInterrupted={onSendInterrupted}
|
||||
onSaveDraftBeforeRun={onSaveDraftBeforeRun}
|
||||
/>
|
||||
@ -595,6 +599,7 @@ function AgentPreviewChatSession({
|
||||
onConversationComplete,
|
||||
onConversationIdChange,
|
||||
onCurrentSessionConversationIdChange,
|
||||
onBeforeSpeechToText,
|
||||
onSendInterrupted,
|
||||
onSaveDraftBeforeRun,
|
||||
}: {
|
||||
@ -616,6 +621,7 @@ function AgentPreviewChatSession({
|
||||
onConversationComplete?: (conversationId: string, workflowRunId?: string) => void
|
||||
onConversationIdChange?: (conversationId: string) => void
|
||||
onCurrentSessionConversationIdChange: (conversationId: string) => void
|
||||
onBeforeSpeechToText?: () => Promise<unknown>
|
||||
onSaveDraftBeforeRun?: () => Promise<AgentSoulConfig | void>
|
||||
onSendInterrupted?: () => void
|
||||
}) {
|
||||
@ -807,6 +813,11 @@ function AgentPreviewChatSession({
|
||||
const sandboxNotice = t(($) => $['agentDetail.configure.preview.sandboxNotice'])
|
||||
const sandboxNoticeTooltip = t(($) => $['agentDetail.configure.preview.sandboxNoticeTooltip'])
|
||||
const showSandboxNotice = isEmptyChat && !isSendPending && !isResponding
|
||||
const speechToTextTarget: SpeechToTextTarget = {
|
||||
type: 'agent',
|
||||
agentId,
|
||||
draftType: draftType ?? 'draft',
|
||||
}
|
||||
const emptyChatInputNode = (
|
||||
<div className="pointer-events-auto mt-5 w-full">
|
||||
<ChatInputArea
|
||||
@ -820,6 +831,8 @@ function AgentPreviewChatSession({
|
||||
showFileUpload={false}
|
||||
visionConfig={config.file_upload}
|
||||
speechToTextConfig={config.speech_to_text}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
onBeforeSpeechToText={onBeforeSpeechToText}
|
||||
onSend={doSend}
|
||||
inputs={inputs}
|
||||
inputsForm={inputsForm}
|
||||
@ -833,6 +846,8 @@ function AgentPreviewChatSession({
|
||||
return (
|
||||
<Chat
|
||||
config={config}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
onBeforeSpeechToText={onBeforeSpeechToText}
|
||||
chatList={chatList}
|
||||
isResponding={isResponding}
|
||||
sendButtonLoading={sendButtonLoading}
|
||||
|
||||
5
web/global.d.ts
vendored
5
web/global.d.ts
vendored
@ -3,11 +3,6 @@ import './types/jsx'
|
||||
import './types/mdx'
|
||||
import './types/assets'
|
||||
|
||||
declare module 'lamejs'
|
||||
declare module 'lamejs/src/js/MPEGMode'
|
||||
declare module 'lamejs/src/js/Lame'
|
||||
declare module 'lamejs/src/js/BitStream'
|
||||
|
||||
declare global {
|
||||
// Google Analytics gtag types
|
||||
type GtagEventParams = {
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "الميكروفون غير مصرح به",
|
||||
"voiceInput.speaking": "تحدث الآن...",
|
||||
"voiceInput.start": "إدخال صوتي",
|
||||
"voiceInput.starting": "جارٍ تشغيل الميكروفون...",
|
||||
"voiceInput.stop": "إيقاف التسجيل",
|
||||
"workflowAsToolPage.description": "حوّل أي workflow منشور إلى أداة قابلة لإعادة الاستخدام. استخدمها عبر تطبيقات متعددة للحفاظ على البنية المعيارية وتجنب التكرار.",
|
||||
"you": "أنت"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "Mikrofon nicht autorisiert",
|
||||
"voiceInput.speaking": "Sprechen Sie jetzt...",
|
||||
"voiceInput.start": "Spracheingabe",
|
||||
"voiceInput.starting": "Mikrofon wird gestartet...",
|
||||
"voiceInput.stop": "Aufnahme stoppen",
|
||||
"workflowAsToolPage.description": "Wandle jeden veröffentlichten Workflow in ein wiederverwendbares Tool um. Nutze es in mehreren Apps, um modular zu bleiben und Wiederholungen zu vermeiden.",
|
||||
"you": "Du"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "microphone not authorized",
|
||||
"voiceInput.speaking": "Speak now...",
|
||||
"voiceInput.start": "Voice input",
|
||||
"voiceInput.starting": "Starting microphone...",
|
||||
"voiceInput.stop": "Stop recording",
|
||||
"workflowAsToolPage.description": "Turn any published workflow into a reusable tool. Use it across multiple apps to keep your builds modular and avoid repetition.",
|
||||
"you": "You"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "micrófono no autorizado",
|
||||
"voiceInput.speaking": "Habla ahora...",
|
||||
"voiceInput.start": "Entrada de voz",
|
||||
"voiceInput.starting": "Iniciando micrófono...",
|
||||
"voiceInput.stop": "Detener grabación",
|
||||
"workflowAsToolPage.description": "Convierte cualquier workflow publicado en una herramienta reutilizable. Úsala en varias apps para mantener tus desarrollos modulares y evitar repeticiones.",
|
||||
"you": "Tú"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "میکروفون مجاز نیست",
|
||||
"voiceInput.speaking": "اکنون صحبت کنید...",
|
||||
"voiceInput.start": "ورودی صوتی",
|
||||
"voiceInput.starting": "در حال راهاندازی میکروفون...",
|
||||
"voiceInput.stop": "توقف ضبط",
|
||||
"workflowAsToolPage.description": "هر workflow منتشرشده را به ابزاری قابل استفاده مجدد تبدیل کنید. از آن در چندین برنامه استفاده کنید تا ساختار ماژولار بماند و از تکرار جلوگیری شود.",
|
||||
"you": "تو"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "microphone non autorisé",
|
||||
"voiceInput.speaking": "Parle maintenant...",
|
||||
"voiceInput.start": "Saisie vocale",
|
||||
"voiceInput.starting": "Démarrage du microphone...",
|
||||
"voiceInput.stop": "Arrêter l’enregistrement",
|
||||
"workflowAsToolPage.description": "Transformez tout workflow publié en outil réutilisable. Utilisez-le dans plusieurs apps pour garder vos projets modulaires et éviter les répétitions.",
|
||||
"you": "Vous"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "माइक्रोफोन अधिकृत नहीं है",
|
||||
"voiceInput.speaking": "अब बोलें...",
|
||||
"voiceInput.start": "वॉइस इनपुट",
|
||||
"voiceInput.starting": "माइक्रोफ़ोन शुरू हो रहा है...",
|
||||
"voiceInput.stop": "रिकॉर्डिंग रोकें",
|
||||
"workflowAsToolPage.description": "किसी भी प्रकाशित workflow को reusable tool में बदलें। अपने builds को modular रखने और दोहराव से बचने के लिए इसे कई apps में उपयोग करें।",
|
||||
"you": "आप"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "mikrofon tidak diizinkan",
|
||||
"voiceInput.speaking": "Bicaralah sekarang...",
|
||||
"voiceInput.start": "Input suara",
|
||||
"voiceInput.starting": "Memulai mikrofon...",
|
||||
"voiceInput.stop": "Hentikan rekaman",
|
||||
"workflowAsToolPage.description": "Ubah workflow yang telah dipublikasikan menjadi alat yang dapat digunakan kembali. Gunakan di beberapa aplikasi agar build tetap modular dan tidak berulang.",
|
||||
"you": "Kamu"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "microfono non autorizzato",
|
||||
"voiceInput.speaking": "Parla ora...",
|
||||
"voiceInput.start": "Input vocale",
|
||||
"voiceInput.starting": "Avvio del microfono...",
|
||||
"voiceInput.stop": "Interrompi registrazione",
|
||||
"workflowAsToolPage.description": "Trasforma qualsiasi workflow pubblicato in uno strumento riutilizzabile. Usalo in più app per mantenere i build modulari ed evitare ripetizioni.",
|
||||
"you": "Tu"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "マイクが許可されていません",
|
||||
"voiceInput.speaking": "今話しています...",
|
||||
"voiceInput.start": "音声入力",
|
||||
"voiceInput.starting": "マイクを起動しています...",
|
||||
"voiceInput.stop": "録音を停止",
|
||||
"workflowAsToolPage.description": "公開済みのワークフローを再利用可能なツールに変換し、複数のアプリで使い回せます。構成をモジュール化し、重複作業を防げます。",
|
||||
"you": "あなた"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "마이크가 허용되지 않았습니다",
|
||||
"voiceInput.speaking": "지금 말하고 있습니다...",
|
||||
"voiceInput.start": "음성 입력",
|
||||
"voiceInput.starting": "마이크를 시작하는 중...",
|
||||
"voiceInput.stop": "녹음 중지",
|
||||
"workflowAsToolPage.description": "게시된 workflow를 재사용 가능한 도구로 전환하세요. 여러 앱에서 사용해 빌드를 모듈화하고 반복을 줄일 수 있습니다.",
|
||||
"you": "나"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "microphone not authorized",
|
||||
"voiceInput.speaking": "Speak now...",
|
||||
"voiceInput.start": "Spraakinvoer",
|
||||
"voiceInput.starting": "Microfoon starten...",
|
||||
"voiceInput.stop": "Opname stoppen",
|
||||
"workflowAsToolPage.description": "Zet elke gepubliceerde workflow om in een herbruikbare tool. Gebruik deze in meerdere apps om modulair te blijven en herhaling te voorkomen.",
|
||||
"you": "You"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "mikrofon nieautoryzowany",
|
||||
"voiceInput.speaking": "Mów teraz...",
|
||||
"voiceInput.start": "Wprowadzanie głosowe",
|
||||
"voiceInput.starting": "Uruchamianie mikrofonu...",
|
||||
"voiceInput.stop": "Zatrzymaj nagrywanie",
|
||||
"workflowAsToolPage.description": "Zamień dowolny opublikowany workflow w narzędzie wielokrotnego użytku. Używaj go w wielu aplikacjach, aby zachować modułowość i unikać powtórzeń.",
|
||||
"you": "Ty"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "microfone não autorizado",
|
||||
"voiceInput.speaking": "Fale agora...",
|
||||
"voiceInput.start": "Entrada por voz",
|
||||
"voiceInput.starting": "Iniciando microfone...",
|
||||
"voiceInput.stop": "Parar gravação",
|
||||
"workflowAsToolPage.description": "Transforme qualquer workflow publicado em uma ferramenta reutilizável. Use em vários apps para manter seus builds modulares e evitar repetição.",
|
||||
"you": "Você"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "microfonul nu este autorizat",
|
||||
"voiceInput.speaking": "Vorbiți acum...",
|
||||
"voiceInput.start": "Introducere vocală",
|
||||
"voiceInput.starting": "Se pornește microfonul...",
|
||||
"voiceInput.stop": "Oprește înregistrarea",
|
||||
"workflowAsToolPage.description": "Transformă orice workflow publicat într-un instrument reutilizabil. Folosește-l în mai multe aplicații pentru a păstra buildurile modulare și a evita repetarea.",
|
||||
"you": "Tu"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "микрофон не авторизован",
|
||||
"voiceInput.speaking": "Говорите сейчас...",
|
||||
"voiceInput.start": "Голосовой ввод",
|
||||
"voiceInput.starting": "Запуск микрофона...",
|
||||
"voiceInput.stop": "Остановить запись",
|
||||
"workflowAsToolPage.description": "Преобразуйте любой опубликованный workflow в переиспользуемый инструмент. Используйте его в нескольких приложениях, чтобы сохранять модульность и избегать повторений.",
|
||||
"you": "Ты"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "Mikrofon ni pooblaščen",
|
||||
"voiceInput.speaking": "Spregovorite zdaj ...",
|
||||
"voiceInput.start": "Glasovni vnos",
|
||||
"voiceInput.starting": "Zagon mikrofona ...",
|
||||
"voiceInput.stop": "Ustavi snemanje",
|
||||
"workflowAsToolPage.description": "Vsak objavljen workflow pretvorite v ponovno uporabno orodje. Uporabite ga v več aplikacijah, da ohranite modularnost in se izognete ponavljanju.",
|
||||
"you": "Ti"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "ไม่ได้รับอนุญาตไมโครโฟน",
|
||||
"voiceInput.speaking": "พูดเดี๋ยวนี้...",
|
||||
"voiceInput.start": "ป้อนข้อมูลด้วยเสียง",
|
||||
"voiceInput.starting": "กำลังเริ่มไมโครโฟน...",
|
||||
"voiceInput.stop": "หยุดการบันทึก",
|
||||
"workflowAsToolPage.description": "เปลี่ยน workflow ที่เผยแพร่แล้วให้เป็นเครื่องมือที่ใช้ซ้ำได้ ใช้ได้ในหลายแอปเพื่อให้การสร้างงานเป็นโมดูลและหลีกเลี่ยงการทำซ้ำ",
|
||||
"you": "คุณ"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "mikrofon yetkilendirilmedi",
|
||||
"voiceInput.speaking": "Şimdi konuş...",
|
||||
"voiceInput.start": "Sesli giriş",
|
||||
"voiceInput.starting": "Mikrofon başlatılıyor...",
|
||||
"voiceInput.stop": "Kaydı durdur",
|
||||
"workflowAsToolPage.description": "Yayınlanmış herhangi bir workflowu yeniden kullanılabilir bir araca dönüştürün. Modüler kalmak ve tekrarı önlemek için birden çok uygulamada kullanın.",
|
||||
"you": "Sen"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "мікрофон не авторизований",
|
||||
"voiceInput.speaking": "Говоріть зараз...",
|
||||
"voiceInput.start": "Голосове введення",
|
||||
"voiceInput.starting": "Запуск мікрофона...",
|
||||
"voiceInput.stop": "Зупинити запис",
|
||||
"workflowAsToolPage.description": "Перетворіть будь-який опублікований workflow на багаторазовий інструмент. Використовуйте його в кількох застосунках, щоб зберігати модульність і уникати повторень.",
|
||||
"you": "Ти"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "micro không được ủy quyền",
|
||||
"voiceInput.speaking": "Hãy nói...",
|
||||
"voiceInput.start": "Nhập bằng giọng nói",
|
||||
"voiceInput.starting": "Đang khởi động micrô...",
|
||||
"voiceInput.stop": "Dừng ghi âm",
|
||||
"workflowAsToolPage.description": "Biến bất kỳ workflow đã xuất bản nào thành công cụ có thể tái sử dụng. Dùng trong nhiều ứng dụng để giữ tính mô-đun và tránh lặp lại.",
|
||||
"you": "Bạn"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "麦克风未授权",
|
||||
"voiceInput.speaking": "现在讲...",
|
||||
"voiceInput.start": "语音输入",
|
||||
"voiceInput.starting": "正在启动麦克风...",
|
||||
"voiceInput.stop": "停止录音",
|
||||
"workflowAsToolPage.description": "将任意已发布的工作流转化为可复用的工具,在多个应用中调用,让你的构建保持模块化、避免重复劳动。",
|
||||
"you": "你"
|
||||
}
|
||||
|
||||
@ -596,6 +596,8 @@
|
||||
"voiceInput.notAllow": "麥克風未授權",
|
||||
"voiceInput.speaking": "現在講...",
|
||||
"voiceInput.start": "語音輸入",
|
||||
"voiceInput.starting": "正在啟動麥克風...",
|
||||
"voiceInput.stop": "停止錄音",
|
||||
"workflowAsToolPage.description": "將任意已發布工作流程作為可重複使用的工具,在多個應用中使用以保持模組化並避免重複設定。",
|
||||
"you": "你"
|
||||
}
|
||||
|
||||
@ -52,6 +52,7 @@
|
||||
"@lexical/selection": "catalog:",
|
||||
"@lexical/text": "catalog:",
|
||||
"@lexical/utils": "catalog:",
|
||||
"@mediabunny/mp3-encoder": "catalog:",
|
||||
"@monaco-editor/react": "catalog:",
|
||||
"@orpc/client": "catalog:",
|
||||
"@orpc/contract": "catalog:",
|
||||
@ -100,15 +101,14 @@
|
||||
"jotai-scope": "catalog:",
|
||||
"jotai-tanstack-form": "workspace:*",
|
||||
"jotai-tanstack-query": "catalog:",
|
||||
"js-audio-recorder": "catalog:",
|
||||
"js-cookie": "catalog:",
|
||||
"js-yaml": "catalog:",
|
||||
"jsonschema": "catalog:",
|
||||
"katex": "catalog:",
|
||||
"ky": "catalog:",
|
||||
"lamejs": "catalog:",
|
||||
"lexical": "catalog:",
|
||||
"loro-crdt": "catalog:",
|
||||
"mediabunny": "catalog:",
|
||||
"mermaid": "catalog:",
|
||||
"mime": "catalog:",
|
||||
"mitt": "catalog:",
|
||||
@ -227,7 +227,7 @@
|
||||
"last 1 Firefox version",
|
||||
"last 1 Edge version",
|
||||
"last 1 Safari version",
|
||||
"iOS >=15",
|
||||
"iOS >=16.4",
|
||||
"Android >= 10",
|
||||
"and_chr >= 126",
|
||||
"and_ff >= 137",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user